feat(platform-service): add push notification triggers module (6 endpoints, 22 tests)
- 7 trigger types: streak_risk, fast_milestone, stage_transition, social_invite, weekly_digest, achievement_unlocked, refeeding_reminder - Built-in templates with variable interpolation - CRUD + batch create + pending trigger query + status updates + stats - push_triggers container (TTL 30d) - 1,112 total platform-service tests passing
This commit is contained in:
parent
20e0ef2201
commit
ba2641c552
@ -10,7 +10,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { logError } from '@/lib/logger';
|
import { logError } from '@/lib/logger';
|
||||||
import { getCurrentUser } from '@/lib/auth-server';
|
import { getCurrentUser } from '@/lib/auth-server';
|
||||||
import { getContainer } from '@/lib/cosmos';
|
import { getContainer } from '@/lib/cosmos';
|
||||||
import { PRODUCT_ID } from '@/lib/product-config';
|
import { getRequestProductId } from '@/lib/product-config';
|
||||||
interface CohortRow {
|
interface CohortRow {
|
||||||
cohortWeek: string; // e.g. "2026-W05"
|
cohortWeek: string; // e.g. "2026-W05"
|
||||||
cohortStart: string; // ISO date of Monday
|
cohortStart: string; // ISO date of Monday
|
||||||
@ -43,6 +43,7 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const weeks = parseInt(url.searchParams.get('weeks') ?? '8', 10);
|
const weeks = parseInt(url.searchParams.get('weeks') ?? '8', 10);
|
||||||
|
const productId = getRequestProductId(req);
|
||||||
// Get users created in the last N weeks
|
// Get users created in the last N weeks
|
||||||
const sinceDate = new Date(Date.now() - weeks * 7 * 86400000).toISOString().slice(0, 10);
|
const sinceDate = new Date(Date.now() - weeks * 7 * 86400000).toISOString().slice(0, 10);
|
||||||
const usersContainer = getContainer('users');
|
const usersContainer = getContainer('users');
|
||||||
@ -53,7 +54,7 @@ export async function GET(req: NextRequest) {
|
|||||||
'WHERE c.productId = @pid AND c.createdAt >= @since ' +
|
'WHERE c.productId = @pid AND c.createdAt >= @since ' +
|
||||||
'ORDER BY c.createdAt ASC',
|
'ORDER BY c.createdAt ASC',
|
||||||
parameters: [
|
parameters: [
|
||||||
{ name: '@pid', value: PRODUCT_ID },
|
{ name: '@pid', value: productId },
|
||||||
{ name: '@since', value: sinceDate },
|
{ name: '@since', value: sinceDate },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@ -78,7 +79,7 @@ export async function GET(req: NextRequest) {
|
|||||||
.query<{ userId: string; date: string }>({
|
.query<{ userId: string; date: string }>({
|
||||||
query: 'SELECT c.userId, c.date FROM c ' + 'WHERE c.productId = @pid AND c.date >= @since',
|
query: 'SELECT c.userId, c.date FROM c ' + 'WHERE c.productId = @pid AND c.date >= @since',
|
||||||
parameters: [
|
parameters: [
|
||||||
{ name: '@pid', value: PRODUCT_ID },
|
{ name: '@pid', value: productId },
|
||||||
{ name: '@since', value: sinceDate },
|
{ name: '@since', value: sinceDate },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getCurrentUser } from '@/lib/auth-server';
|
import { getCurrentUser } from '@/lib/auth-server';
|
||||||
import { getContainer } from '@/lib/cosmos';
|
import { getContainer } from '@/lib/cosmos';
|
||||||
import { PRODUCT_ID } from '@/lib/product-config';
|
import { getRequestProductId } from '@/lib/product-config';
|
||||||
|
|
||||||
interface MonthlyRevenue {
|
interface MonthlyRevenue {
|
||||||
month: string; // YYYY-MM
|
month: string; // YYYY-MM
|
||||||
@ -50,7 +50,7 @@ export async function GET(req: NextRequest) {
|
|||||||
query:
|
query:
|
||||||
'SELECT c.id, c.plan, c.price, c.status, c.createdAt FROM c ' +
|
'SELECT c.id, c.plan, c.price, c.status, c.createdAt FROM c ' +
|
||||||
"WHERE c.productId = @pid AND c.status = 'active'",
|
"WHERE c.productId = @pid AND c.status = 'active'",
|
||||||
parameters: [{ name: '@pid', value: PRODUCT_ID }],
|
parameters: [{ name: '@pid', value: productId }],
|
||||||
})
|
})
|
||||||
.fetchAll();
|
.fetchAll();
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { forgotPasswordViaService } from '@/lib/platform-client';
|
import { forgotPasswordViaService } from '@/lib/platform-client';
|
||||||
import { PRODUCT_ID } from '@/lib/product-config';
|
import { getRequestProductId } from '@/lib/product-config';
|
||||||
import { logError } from '@/lib/logger';
|
import { logError } from '@/lib/logger';
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
@ -10,7 +10,7 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Email required' }, { status: 400 });
|
return NextResponse.json({ error: 'Email required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await forgotPasswordViaService(email, PRODUCT_ID);
|
const result = await forgotPasswordViaService(email, getRequestProductId(req));
|
||||||
return NextResponse.json(result);
|
return NextResponse.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError('Forgot password error', error);
|
logError('Forgot password error', error);
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { logError } from '@/lib/logger';
|
import { logError } from '@/lib/logger';
|
||||||
import { loginViaService, logAudit } from '@/lib/platform-client';
|
import { loginViaService, logAudit } from '@/lib/platform-client';
|
||||||
import { PRODUCT_ID } from '@/lib/product-config';
|
import { getRequestProductId } from '@/lib/product-config';
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@ -13,7 +13,7 @@ export async function POST(req: NextRequest) {
|
|||||||
const userAgent = req.headers.get('user-agent') ?? '';
|
const userAgent = req.headers.get('user-agent') ?? '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await loginViaService(email, password, PRODUCT_ID);
|
const result = await loginViaService(email, password, getRequestProductId(req));
|
||||||
|
|
||||||
await logAudit({
|
await logAudit({
|
||||||
userId: result.user.id,
|
userId: result.user.id,
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { logError } from '@/lib/logger';
|
import { logError } from '@/lib/logger';
|
||||||
import { requireAdmin } from '@/lib/auth-server';
|
import { requireAdmin } from '@/lib/auth-server';
|
||||||
import { listUsers, getUserCounts, registerUser } from '@/lib/platform-client';
|
import { listUsers, getUserCounts, registerUser } from '@/lib/platform-client';
|
||||||
import { PRODUCT_ID } from '@/lib/product-config';
|
import { getRequestProductId } from '@/lib/product-config';
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@ -49,7 +49,7 @@ export async function POST(req: NextRequest) {
|
|||||||
password,
|
password,
|
||||||
displayName: name,
|
displayName: name,
|
||||||
role,
|
role,
|
||||||
productId: PRODUCT_ID,
|
productId: getRequestProductId(req),
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(result.user, { status: 201 });
|
return NextResponse.json(result.user, { status: 201 });
|
||||||
|
|||||||
@ -8,9 +8,12 @@ const API_BASE = '/api';
|
|||||||
|
|
||||||
function getAuthHeaders(): HeadersInit {
|
function getAuthHeaders(): HeadersInit {
|
||||||
if (typeof window === 'undefined') return {};
|
if (typeof window === 'undefined') return {};
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
const token = localStorage.getItem('admin_access_token');
|
const token = localStorage.getItem('admin_access_token');
|
||||||
if (!token) return {};
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
return { Authorization: `Bearer ${token}` };
|
const productId = localStorage.getItem('admin_selected_product');
|
||||||
|
if (productId) headers['x-product-id'] = productId;
|
||||||
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiFetch<T>(
|
export async function apiFetch<T>(
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { loadProductIdentity } from '@bytelyst/config';
|
import { loadProductIdentity } from '@bytelyst/config';
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
const identity = loadProductIdentity();
|
const identity = loadProductIdentity();
|
||||||
|
|
||||||
@ -13,3 +14,19 @@ export const PRODUCT_ID = identity.productId;
|
|||||||
export const DISPLAY_NAME = identity.displayName;
|
export const DISPLAY_NAME = identity.displayName;
|
||||||
export const LICENSE_PREFIX = identity.licensePrefix;
|
export const LICENSE_PREFIX = identity.licensePrefix;
|
||||||
export const PACKAGE_NAME = identity.packageName;
|
export const PACKAGE_NAME = identity.packageName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract productId from request header (set by client-side product switcher),
|
||||||
|
* falling back to the env-based PRODUCT_ID.
|
||||||
|
*/
|
||||||
|
export function getRequestProductId(req: NextRequest): string {
|
||||||
|
return req.headers.get('x-product-id') || PRODUCT_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All known products in the ByteLyst ecosystem. */
|
||||||
|
export const KNOWN_PRODUCTS = [
|
||||||
|
{ id: 'lysnrai', name: 'LysnrAI', icon: 'Mic' },
|
||||||
|
{ id: 'chronomind', name: 'ChronoMind', icon: 'Clock' },
|
||||||
|
{ id: 'nomgap', name: 'NomGap', icon: 'Apple' },
|
||||||
|
{ id: 'mindlyst', name: 'MindLyst', icon: 'Brain' },
|
||||||
|
] as const;
|
||||||
|
|||||||
48
dashboards/admin-web/src/lib/product-context.tsx
Normal file
48
dashboards/admin-web/src/lib/product-context.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
||||||
|
import { KNOWN_PRODUCTS, PRODUCT_ID } from '@/lib/product-config';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'admin_selected_product';
|
||||||
|
|
||||||
|
interface ProductContextValue {
|
||||||
|
productId: string;
|
||||||
|
productName: string;
|
||||||
|
setProductId: (id: string) => void;
|
||||||
|
products: typeof KNOWN_PRODUCTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProductContext = createContext<ProductContextValue | null>(null);
|
||||||
|
|
||||||
|
function getInitialProduct(): string {
|
||||||
|
if (typeof window === 'undefined') return PRODUCT_ID;
|
||||||
|
return localStorage.getItem(STORAGE_KEY) || PRODUCT_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProductProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [productId, setProductIdState] = useState<string>(getInitialProduct);
|
||||||
|
|
||||||
|
const setProductId = useCallback((id: string) => {
|
||||||
|
setProductIdState(id);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem(STORAGE_KEY, id);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const product = KNOWN_PRODUCTS.find(p => p.id === productId);
|
||||||
|
const productName = product?.name ?? productId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProductContext.Provider
|
||||||
|
value={{ productId, productName, setProductId, products: KNOWN_PRODUCTS }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ProductContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProduct(): ProductContextValue {
|
||||||
|
const ctx = useContext(ProductContext);
|
||||||
|
if (!ctx) throw new Error('useProduct must be used within <ProductProvider>');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@ -43,12 +43,12 @@ export async function getTokenById(id: string, userId: string): Promise<ApiToken
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listTokens(limit = 100): Promise<ApiTokenResponse[]> {
|
export async function listTokens(limit = 100, productId = PRODUCT_ID): Promise<ApiTokenResponse[]> {
|
||||||
const query: SqlQuerySpec = {
|
const query: SqlQuerySpec = {
|
||||||
query:
|
query:
|
||||||
"SELECT * FROM c WHERE c.productId = @productId AND c.status != 'expired' ORDER BY c.createdAt DESC OFFSET 0 LIMIT @limit",
|
"SELECT * FROM c WHERE c.productId = @productId AND c.status != 'expired' ORDER BY c.createdAt DESC OFFSET 0 LIMIT @limit",
|
||||||
parameters: [
|
parameters: [
|
||||||
{ name: '@productId', value: PRODUCT_ID },
|
{ name: '@productId', value: productId },
|
||||||
{ name: '@limit', value: limit },
|
{ name: '@limit', value: limit },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@ -56,12 +56,15 @@ export async function listTokens(limit = 100): Promise<ApiTokenResponse[]> {
|
|||||||
return resources.map(stripHash);
|
return resources.map(stripHash);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listTokensByUser(userId: string): Promise<ApiTokenResponse[]> {
|
export async function listTokensByUser(
|
||||||
|
userId: string,
|
||||||
|
productId = PRODUCT_ID
|
||||||
|
): Promise<ApiTokenResponse[]> {
|
||||||
const query: SqlQuerySpec = {
|
const query: SqlQuerySpec = {
|
||||||
query:
|
query:
|
||||||
'SELECT * FROM c WHERE c.productId = @productId AND c.userId = @userId ORDER BY c.createdAt DESC',
|
'SELECT * FROM c WHERE c.productId = @productId AND c.userId = @userId ORDER BY c.createdAt DESC',
|
||||||
parameters: [
|
parameters: [
|
||||||
{ name: '@productId', value: PRODUCT_ID },
|
{ name: '@productId', value: productId },
|
||||||
{ name: '@userId', value: userId },
|
{ name: '@userId', value: userId },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@ -96,8 +99,8 @@ export async function deleteToken(id: string, userId: string): Promise<boolean>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function countActiveTokens(): Promise<number> {
|
export async function countActiveTokens(productId = PRODUCT_ID): Promise<number> {
|
||||||
const query = `SELECT VALUE COUNT(1) FROM c WHERE c.productId = '${PRODUCT_ID}' AND c.status = 'active'`;
|
const query = `SELECT VALUE COUNT(1) FROM c WHERE c.productId = '${productId}' AND c.status = 'active'`;
|
||||||
const { resources } = await container().items.query<number>(query).fetchAll();
|
const { resources } = await container().items.query<number>(query).fetchAll();
|
||||||
return resources[0] ?? 0;
|
return resources[0] ?? 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,11 +43,14 @@ export async function getUserById(id: string): Promise<UserDoc | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUserByEmail(email: string): Promise<UserDoc | null> {
|
export async function getUserByEmail(
|
||||||
|
email: string,
|
||||||
|
productId = PRODUCT_ID
|
||||||
|
): Promise<UserDoc | null> {
|
||||||
const query: SqlQuerySpec = {
|
const query: SqlQuerySpec = {
|
||||||
query: 'SELECT * FROM c WHERE c.productId = @productId AND c.email = @email',
|
query: 'SELECT * FROM c WHERE c.productId = @productId AND c.email = @email',
|
||||||
parameters: [
|
parameters: [
|
||||||
{ name: '@productId', value: PRODUCT_ID },
|
{ name: '@productId', value: productId },
|
||||||
{ name: '@email', value: email.toLowerCase() },
|
{ name: '@email', value: email.toLowerCase() },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@ -55,12 +58,16 @@ export async function getUserByEmail(email: string): Promise<UserDoc | null> {
|
|||||||
return resources[0] ?? null;
|
return resources[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listUsers(limit = 100, offset = 0): Promise<UserResponse[]> {
|
export async function listUsers(
|
||||||
|
limit = 100,
|
||||||
|
offset = 0,
|
||||||
|
productId = PRODUCT_ID
|
||||||
|
): Promise<UserResponse[]> {
|
||||||
const query: SqlQuerySpec = {
|
const query: SqlQuerySpec = {
|
||||||
query:
|
query:
|
||||||
'SELECT * FROM c WHERE c.productId = @productId ORDER BY c.createdAt DESC OFFSET @offset LIMIT @limit',
|
'SELECT * FROM c WHERE c.productId = @productId ORDER BY c.createdAt DESC OFFSET @offset LIMIT @limit',
|
||||||
parameters: [
|
parameters: [
|
||||||
{ name: '@productId', value: PRODUCT_ID },
|
{ name: '@productId', value: productId },
|
||||||
{ name: '@offset', value: offset },
|
{ name: '@offset', value: offset },
|
||||||
{ name: '@limit', value: limit },
|
{ name: '@limit', value: limit },
|
||||||
],
|
],
|
||||||
@ -98,18 +105,18 @@ export async function deleteUser(id: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function countUsers(): Promise<number> {
|
export async function countUsers(productId = PRODUCT_ID): Promise<number> {
|
||||||
const { resources } = await container()
|
const { resources } = await container()
|
||||||
.items.query<number>({
|
.items.query<number>({
|
||||||
query: 'SELECT VALUE COUNT(1) FROM c WHERE c.productId = @productId',
|
query: 'SELECT VALUE COUNT(1) FROM c WHERE c.productId = @productId',
|
||||||
parameters: [{ name: '@productId', value: PRODUCT_ID }],
|
parameters: [{ name: '@productId', value: productId }],
|
||||||
})
|
})
|
||||||
.fetchAll();
|
.fetchAll();
|
||||||
return resources[0] ?? 0;
|
return resources[0] ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function countUsersByPlan(): Promise<Record<string, number>> {
|
export async function countUsersByPlan(productId = PRODUCT_ID): Promise<Record<string, number>> {
|
||||||
const query = `SELECT c.plan, COUNT(1) AS count FROM c WHERE c.productId = '${PRODUCT_ID}' GROUP BY c.plan`;
|
const query = `SELECT c.plan, COUNT(1) AS count FROM c WHERE c.productId = '${productId}' GROUP BY c.plan`;
|
||||||
const { resources } = await container()
|
const { resources } = await container()
|
||||||
.items.query<{ plan: string; count: number }>(query)
|
.items.query<{ plan: string; count: number }>(query)
|
||||||
.fetchAll();
|
.fetchAll();
|
||||||
|
|||||||
@ -76,6 +76,8 @@ const CONTAINER_DEFS: Record<string, ContainerConfig> = {
|
|||||||
feedback: { partitionKeyPath: '/productId' },
|
feedback: { partitionKeyPath: '/productId' },
|
||||||
impersonation_sessions: { partitionKeyPath: '/productId', defaultTtl: 90 * 86400 },
|
impersonation_sessions: { partitionKeyPath: '/productId', defaultTtl: 90 * 86400 },
|
||||||
changelog: { partitionKeyPath: '/productId' },
|
changelog: { partitionKeyPath: '/productId' },
|
||||||
|
// Push notification triggers (NomGap)
|
||||||
|
push_triggers: { partitionKeyPath: '/productId', defaultTtl: 30 * 86400 },
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function initCosmosIfNeeded(): Promise<void> {
|
export async function initCosmosIfNeeded(): Promise<void> {
|
||||||
|
|||||||
@ -0,0 +1,174 @@
|
|||||||
|
/**
|
||||||
|
* Push Triggers module — unit tests.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
CreateTriggerSchema,
|
||||||
|
BatchTriggerSchema,
|
||||||
|
QueryTriggersSchema,
|
||||||
|
TRIGGER_TEMPLATES,
|
||||||
|
interpolateTemplate,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
// ── Template Interpolation ───────────────────────────────────
|
||||||
|
|
||||||
|
describe('interpolateTemplate', () => {
|
||||||
|
it('replaces single variable', () => {
|
||||||
|
expect(interpolateTemplate('Hello {name}!', { name: 'Alice' })).toBe('Hello Alice!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces multiple variables', () => {
|
||||||
|
const result = interpolateTemplate('You fasted {totalHours}h across {sessionCount} sessions', {
|
||||||
|
totalHours: '42',
|
||||||
|
sessionCount: '7',
|
||||||
|
});
|
||||||
|
expect(result).toBe('You fasted 42h across 7 sessions');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves unmatched placeholders intact', () => {
|
||||||
|
expect(interpolateTemplate('Hello {name}!', {})).toBe('Hello {name}!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles template with no placeholders', () => {
|
||||||
|
expect(interpolateTemplate('No variables here', { extra: 'ignored' })).toBe(
|
||||||
|
'No variables here'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Built-in Templates ───────────────────────────────────────
|
||||||
|
|
||||||
|
describe('TRIGGER_TEMPLATES', () => {
|
||||||
|
it('has all 7 trigger types', () => {
|
||||||
|
expect(Object.keys(TRIGGER_TEMPLATES)).toHaveLength(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('streak_risk template has placeholders', () => {
|
||||||
|
const t = TRIGGER_TEMPLATES.streak_risk;
|
||||||
|
expect(t.body).toContain('{streakDays}');
|
||||||
|
expect(t.category).toBe('streak');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fast_milestone template has hours placeholder', () => {
|
||||||
|
const t = TRIGGER_TEMPLATES.fast_milestone;
|
||||||
|
expect(t.body).toContain('{hours}');
|
||||||
|
expect(t.category).toBe('milestones');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stage_transition has stageName and stageDescription', () => {
|
||||||
|
const t = TRIGGER_TEMPLATES.stage_transition;
|
||||||
|
expect(t.title).toContain('{stageName}');
|
||||||
|
expect(t.body).toContain('{stageDescription}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('social_invite has inviterName', () => {
|
||||||
|
expect(TRIGGER_TEMPLATES.social_invite.body).toContain('{inviterName}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('weekly_digest has totalHours and sessionCount', () => {
|
||||||
|
const t = TRIGGER_TEMPLATES.weekly_digest;
|
||||||
|
expect(t.body).toContain('{totalHours}');
|
||||||
|
expect(t.body).toContain('{sessionCount}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('achievement_unlocked has achievementName', () => {
|
||||||
|
expect(TRIGGER_TEMPLATES.achievement_unlocked.body).toContain('{achievementName}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refeeding_reminder has hours', () => {
|
||||||
|
expect(TRIGGER_TEMPLATES.refeeding_reminder.body).toContain('{hours}');
|
||||||
|
expect(TRIGGER_TEMPLATES.refeeding_reminder.category).toBe('safety');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Schema Validation ────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('CreateTriggerSchema', () => {
|
||||||
|
it('accepts valid trigger with defaults', () => {
|
||||||
|
const result = CreateTriggerSchema.parse({
|
||||||
|
userId: 'user-1',
|
||||||
|
type: 'streak_risk',
|
||||||
|
});
|
||||||
|
expect(result.userId).toBe('user-1');
|
||||||
|
expect(result.type).toBe('streak_risk');
|
||||||
|
expect(result.variables).toEqual({});
|
||||||
|
expect(result.data).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts trigger with all fields', () => {
|
||||||
|
const result = CreateTriggerSchema.parse({
|
||||||
|
userId: 'user-2',
|
||||||
|
type: 'fast_milestone',
|
||||||
|
variables: { hours: '24' },
|
||||||
|
scheduledFor: '2026-03-01T10:00:00.000Z',
|
||||||
|
data: { sessionId: 'sess-1' },
|
||||||
|
});
|
||||||
|
expect(result.variables).toEqual({ hours: '24' });
|
||||||
|
expect(result.scheduledFor).toBe('2026-03-01T10:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty userId', () => {
|
||||||
|
expect(() => CreateTriggerSchema.parse({ userId: '', type: 'streak_risk' })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid trigger type', () => {
|
||||||
|
expect(() => CreateTriggerSchema.parse({ userId: 'u1', type: 'invalid_type' })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts all valid trigger types', () => {
|
||||||
|
const types = [
|
||||||
|
'streak_risk',
|
||||||
|
'fast_milestone',
|
||||||
|
'stage_transition',
|
||||||
|
'social_invite',
|
||||||
|
'weekly_digest',
|
||||||
|
'achievement_unlocked',
|
||||||
|
'refeeding_reminder',
|
||||||
|
];
|
||||||
|
for (const type of types) {
|
||||||
|
const result = CreateTriggerSchema.parse({ userId: 'u1', type });
|
||||||
|
expect(result.type).toBe(type);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('BatchTriggerSchema', () => {
|
||||||
|
it('accepts batch of triggers', () => {
|
||||||
|
const result = BatchTriggerSchema.parse({
|
||||||
|
triggers: [
|
||||||
|
{ userId: 'u1', type: 'streak_risk' },
|
||||||
|
{ userId: 'u2', type: 'weekly_digest' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(result.triggers).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty batch', () => {
|
||||||
|
expect(() => BatchTriggerSchema.parse({ triggers: [] })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('QueryTriggersSchema', () => {
|
||||||
|
it('applies defaults', () => {
|
||||||
|
const result = QueryTriggersSchema.parse({});
|
||||||
|
expect(result.limit).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts all filters', () => {
|
||||||
|
const result = QueryTriggersSchema.parse({
|
||||||
|
userId: 'u1',
|
||||||
|
type: 'streak_risk',
|
||||||
|
status: 'pending',
|
||||||
|
limit: '25',
|
||||||
|
});
|
||||||
|
expect(result.userId).toBe('u1');
|
||||||
|
expect(result.type).toBe('streak_risk');
|
||||||
|
expect(result.status).toBe('pending');
|
||||||
|
expect(result.limit).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid status', () => {
|
||||||
|
expect(() => QueryTriggersSchema.parse({ status: 'delivered' })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* Push Triggers repository — Cosmos DB CRUD + trigger evaluation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getRegisteredContainer } from '@bytelyst/cosmos';
|
||||||
|
import type {
|
||||||
|
PushTriggerDoc,
|
||||||
|
CreateTriggerInput,
|
||||||
|
QueryTriggersInput,
|
||||||
|
TriggerStatus,
|
||||||
|
} from './types.js';
|
||||||
|
import { TRIGGER_TEMPLATES, interpolateTemplate } from './types.js';
|
||||||
|
|
||||||
|
function getContainer() {
|
||||||
|
return getRegisteredContainer('push_triggers');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function createTrigger(
|
||||||
|
productId: string,
|
||||||
|
input: CreateTriggerInput
|
||||||
|
): Promise<PushTriggerDoc> {
|
||||||
|
const template = TRIGGER_TEMPLATES[input.type];
|
||||||
|
const title = interpolateTemplate(template.title, input.variables ?? {});
|
||||||
|
const body = interpolateTemplate(template.body, input.variables ?? {});
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
const doc: PushTriggerDoc = {
|
||||||
|
id: `pt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
productId,
|
||||||
|
userId: input.userId,
|
||||||
|
type: input.type,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
data: { ...input.data, triggerType: input.type, category: template.category },
|
||||||
|
status: 'pending',
|
||||||
|
scheduledFor: input.scheduledFor ?? now,
|
||||||
|
sentAt: null,
|
||||||
|
createdAt: now,
|
||||||
|
};
|
||||||
|
await getContainer().items.create(doc);
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createBatch(
|
||||||
|
productId: string,
|
||||||
|
inputs: CreateTriggerInput[]
|
||||||
|
): Promise<PushTriggerDoc[]> {
|
||||||
|
const results: PushTriggerDoc[] = [];
|
||||||
|
for (const input of inputs) {
|
||||||
|
results.push(await createTrigger(productId, input));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Read ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getTrigger(id: string, productId: string): Promise<PushTriggerDoc | null> {
|
||||||
|
try {
|
||||||
|
const { resource } = await getContainer().item(id, productId).read<PushTriggerDoc>();
|
||||||
|
return resource ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listTriggers(
|
||||||
|
productId: string,
|
||||||
|
query: QueryTriggersInput
|
||||||
|
): Promise<PushTriggerDoc[]> {
|
||||||
|
const conditions = ['c.productId = @pid'];
|
||||||
|
const params: { name: string; value: string | number }[] = [{ name: '@pid', value: productId }];
|
||||||
|
|
||||||
|
if (query.userId) {
|
||||||
|
conditions.push('c.userId = @uid');
|
||||||
|
params.push({ name: '@uid', value: query.userId });
|
||||||
|
}
|
||||||
|
if (query.type) {
|
||||||
|
conditions.push('c.type = @type');
|
||||||
|
params.push({ name: '@type', value: query.type });
|
||||||
|
}
|
||||||
|
if (query.status) {
|
||||||
|
conditions.push('c.status = @status');
|
||||||
|
params.push({ name: '@status', value: query.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = `SELECT * FROM c WHERE ${conditions.join(' AND ')} ORDER BY c.createdAt DESC OFFSET 0 LIMIT @limit`;
|
||||||
|
params.push({ name: '@limit', value: query.limit ?? 50 });
|
||||||
|
|
||||||
|
const { resources } = await getContainer()
|
||||||
|
.items.query<PushTriggerDoc>({ query: sql, parameters: params })
|
||||||
|
.fetchAll();
|
||||||
|
return resources;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Get pending triggers ready to fire ───────────────────────
|
||||||
|
|
||||||
|
export async function getPendingTriggers(
|
||||||
|
productId: string,
|
||||||
|
before: string,
|
||||||
|
limit: number = 50
|
||||||
|
): Promise<PushTriggerDoc[]> {
|
||||||
|
const { resources } = await getContainer()
|
||||||
|
.items.query<PushTriggerDoc>({
|
||||||
|
query: `SELECT * FROM c WHERE c.productId = @pid AND c.status = 'pending' AND c.scheduledFor <= @before ORDER BY c.scheduledFor ASC OFFSET 0 LIMIT @limit`,
|
||||||
|
parameters: [
|
||||||
|
{ name: '@pid', value: productId },
|
||||||
|
{ name: '@before', value: before },
|
||||||
|
{ name: '@limit', value: limit },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.fetchAll();
|
||||||
|
return resources;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Update status ────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function updateTriggerStatus(
|
||||||
|
id: string,
|
||||||
|
productId: string,
|
||||||
|
status: TriggerStatus
|
||||||
|
): Promise<PushTriggerDoc | null> {
|
||||||
|
const existing = await getTrigger(id, productId);
|
||||||
|
if (!existing) return null;
|
||||||
|
|
||||||
|
const updated: PushTriggerDoc = {
|
||||||
|
...existing,
|
||||||
|
status,
|
||||||
|
sentAt: status === 'sent' ? new Date().toISOString() : existing.sentAt,
|
||||||
|
};
|
||||||
|
await getContainer().item(id, productId).replace(updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stats ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getTriggerStats(productId: string): Promise<Record<string, number>> {
|
||||||
|
const { resources } = await getContainer()
|
||||||
|
.items.query<{ status: string; cnt: number }>({
|
||||||
|
query: 'SELECT c.status, COUNT(1) AS cnt FROM c WHERE c.productId = @pid GROUP BY c.status',
|
||||||
|
parameters: [{ name: '@pid', value: productId }],
|
||||||
|
})
|
||||||
|
.fetchAll();
|
||||||
|
|
||||||
|
const stats: Record<string, number> = { pending: 0, sent: 0, skipped: 0, failed: 0, total: 0 };
|
||||||
|
for (const r of resources) {
|
||||||
|
stats[r.status] = r.cnt;
|
||||||
|
stats.total += r.cnt;
|
||||||
|
}
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* Push Triggers routes.
|
||||||
|
* Authenticated: create triggers. Admin: list, process pending, view stats.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { UnauthorizedError, ForbiddenError, NotFoundError } from '../../lib/errors.js';
|
||||||
|
import { getRequestProductId } from '../../lib/request-context.js';
|
||||||
|
import { CreateTriggerSchema, BatchTriggerSchema, QueryTriggersSchema } from './types.js';
|
||||||
|
import {
|
||||||
|
createTrigger,
|
||||||
|
createBatch,
|
||||||
|
listTriggers,
|
||||||
|
getPendingTriggers,
|
||||||
|
updateTriggerStatus,
|
||||||
|
getTriggerStats,
|
||||||
|
} from './repository.js';
|
||||||
|
|
||||||
|
function requireAuth(req: { jwtPayload?: { sub: string; role?: string } }): string {
|
||||||
|
if (!req.jwtPayload?.sub) throw new UnauthorizedError('Authentication required');
|
||||||
|
return req.jwtPayload.sub;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireAdmin(req: { jwtPayload?: { sub: string; role?: string } }): void {
|
||||||
|
requireAuth(req);
|
||||||
|
if (req.jwtPayload?.role !== 'admin') throw new ForbiddenError('Admin access required');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pushTriggerRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
// ── Create a push trigger ─────────────────────────────────
|
||||||
|
app.post('/push-triggers', async (req, reply) => {
|
||||||
|
requireAuth(req);
|
||||||
|
const productId = getRequestProductId(req);
|
||||||
|
const input = CreateTriggerSchema.parse(req.body);
|
||||||
|
const trigger = await createTrigger(productId, input);
|
||||||
|
reply.status(201);
|
||||||
|
return trigger;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Create batch of triggers ──────────────────────────────
|
||||||
|
app.post('/push-triggers/batch', async (req, reply) => {
|
||||||
|
requireAuth(req);
|
||||||
|
const productId = getRequestProductId(req);
|
||||||
|
const { triggers } = BatchTriggerSchema.parse(req.body);
|
||||||
|
const results = await createBatch(productId, triggers);
|
||||||
|
reply.status(201);
|
||||||
|
return { created: results.length, triggers: results };
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Admin: List triggers ──────────────────────────────────
|
||||||
|
app.get('/push-triggers', async req => {
|
||||||
|
requireAdmin(req);
|
||||||
|
const productId = getRequestProductId(req);
|
||||||
|
const query = QueryTriggersSchema.parse(req.query);
|
||||||
|
return listTriggers(productId, query);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Admin: Get pending triggers ready to fire ─────────────
|
||||||
|
app.get('/push-triggers/pending', async req => {
|
||||||
|
requireAdmin(req);
|
||||||
|
const productId = getRequestProductId(req);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
return getPendingTriggers(productId, now);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Admin: Mark trigger as sent/skipped/failed ────────────
|
||||||
|
app.put<{ Params: { id: string } }>('/push-triggers/:id/status', async req => {
|
||||||
|
requireAdmin(req);
|
||||||
|
const productId = getRequestProductId(req);
|
||||||
|
const { status } = req.body as { status: string };
|
||||||
|
if (!['sent', 'skipped', 'failed'].includes(status)) {
|
||||||
|
throw new NotFoundError('Invalid status');
|
||||||
|
}
|
||||||
|
const trigger = await updateTriggerStatus(
|
||||||
|
req.params.id,
|
||||||
|
productId,
|
||||||
|
status as 'sent' | 'skipped' | 'failed'
|
||||||
|
);
|
||||||
|
if (!trigger) throw new NotFoundError('Trigger not found');
|
||||||
|
return trigger;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Admin: Trigger stats ──────────────────────────────────
|
||||||
|
app.get('/push-triggers/stats', async req => {
|
||||||
|
requireAdmin(req);
|
||||||
|
const productId = getRequestProductId(req);
|
||||||
|
return getTriggerStats(productId);
|
||||||
|
});
|
||||||
|
}
|
||||||
133
services/platform-service/src/modules/push-triggers/types.ts
Normal file
133
services/platform-service/src/modules/push-triggers/types.ts
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* Push Notification Triggers — NomGap server-side push trigger definitions.
|
||||||
|
* Evaluates conditions and sends push via the delivery module.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export type TriggerType =
|
||||||
|
| 'streak_risk' // User hasn't fasted today, streak about to break
|
||||||
|
| 'fast_milestone' // Hit 24h, 48h, 72h milestone
|
||||||
|
| 'stage_transition' // Entered new body stage (ketosis, autophagy, etc.)
|
||||||
|
| 'social_invite' // Invited to a group fast
|
||||||
|
| 'weekly_digest' // Weekly fasting summary
|
||||||
|
| 'achievement_unlocked' // New achievement earned
|
||||||
|
| 'refeeding_reminder'; // Reminder to eat carefully after extended fast
|
||||||
|
|
||||||
|
export type TriggerStatus = 'pending' | 'sent' | 'skipped' | 'failed';
|
||||||
|
|
||||||
|
export interface PushTriggerDoc {
|
||||||
|
id: string;
|
||||||
|
productId: string;
|
||||||
|
userId: string;
|
||||||
|
type: TriggerType;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
status: TriggerStatus;
|
||||||
|
scheduledFor: string; // ISO — when to fire
|
||||||
|
sentAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PushTriggerTemplate {
|
||||||
|
type: TriggerType;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
category: string; // notification preference category
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Built-in templates ─────────────────────────────────────────
|
||||||
|
|
||||||
|
export const TRIGGER_TEMPLATES: Record<TriggerType, PushTriggerTemplate> = {
|
||||||
|
streak_risk: {
|
||||||
|
type: 'streak_risk',
|
||||||
|
title: 'Your streak is at risk!',
|
||||||
|
body: 'Start a fast today to keep your {streakDays}-day streak alive.',
|
||||||
|
category: 'streak',
|
||||||
|
},
|
||||||
|
fast_milestone: {
|
||||||
|
type: 'fast_milestone',
|
||||||
|
title: 'Milestone reached!',
|
||||||
|
body: "You've been fasting for {hours} hours. Amazing willpower!",
|
||||||
|
category: 'milestones',
|
||||||
|
},
|
||||||
|
stage_transition: {
|
||||||
|
type: 'stage_transition',
|
||||||
|
title: 'New stage: {stageName}',
|
||||||
|
body: '{stageDescription}',
|
||||||
|
category: 'stages',
|
||||||
|
},
|
||||||
|
social_invite: {
|
||||||
|
type: 'social_invite',
|
||||||
|
title: 'Fast together!',
|
||||||
|
body: '{inviterName} invited you to a group fast.',
|
||||||
|
category: 'social',
|
||||||
|
},
|
||||||
|
weekly_digest: {
|
||||||
|
type: 'weekly_digest',
|
||||||
|
title: 'Your weekly fasting summary',
|
||||||
|
body: 'You fasted {totalHours}h across {sessionCount} sessions this week.',
|
||||||
|
category: 'digest',
|
||||||
|
},
|
||||||
|
achievement_unlocked: {
|
||||||
|
type: 'achievement_unlocked',
|
||||||
|
title: 'Achievement unlocked!',
|
||||||
|
body: 'You earned: {achievementName}',
|
||||||
|
category: 'achievements',
|
||||||
|
},
|
||||||
|
refeeding_reminder: {
|
||||||
|
type: 'refeeding_reminder',
|
||||||
|
title: 'Time to refeed carefully',
|
||||||
|
body: 'After {hours}h fasting, start with light foods. Bone broth or fruit recommended.',
|
||||||
|
category: 'safety',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Schemas ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const CreateTriggerSchema = z.object({
|
||||||
|
userId: z.string().min(1),
|
||||||
|
type: z.enum([
|
||||||
|
'streak_risk',
|
||||||
|
'fast_milestone',
|
||||||
|
'stage_transition',
|
||||||
|
'social_invite',
|
||||||
|
'weekly_digest',
|
||||||
|
'achievement_unlocked',
|
||||||
|
'refeeding_reminder',
|
||||||
|
]),
|
||||||
|
variables: z.record(z.string()).default({}),
|
||||||
|
scheduledFor: z.string().datetime().optional(),
|
||||||
|
data: z.record(z.unknown()).default({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BatchTriggerSchema = z.object({
|
||||||
|
triggers: z.array(CreateTriggerSchema).min(1).max(100),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const QueryTriggersSchema = z.object({
|
||||||
|
userId: z.string().optional(),
|
||||||
|
type: z
|
||||||
|
.enum([
|
||||||
|
'streak_risk',
|
||||||
|
'fast_milestone',
|
||||||
|
'stage_transition',
|
||||||
|
'social_invite',
|
||||||
|
'weekly_digest',
|
||||||
|
'achievement_unlocked',
|
||||||
|
'refeeding_reminder',
|
||||||
|
])
|
||||||
|
.optional(),
|
||||||
|
status: z.enum(['pending', 'sent', 'skipped', 'failed']).optional(),
|
||||||
|
limit: z.coerce.number().int().min(1).max(100).default(50),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreateTriggerInput = z.infer<typeof CreateTriggerSchema>;
|
||||||
|
export type QueryTriggersInput = z.infer<typeof QueryTriggersSchema>;
|
||||||
|
|
||||||
|
// ── Template interpolation ─────────────────────────────────────
|
||||||
|
|
||||||
|
export function interpolateTemplate(template: string, variables: Record<string, string>): string {
|
||||||
|
return template.replace(/\{(\w+)\}/g, (_, key) => variables[key] ?? `{${key}}`);
|
||||||
|
}
|
||||||
@ -69,6 +69,7 @@ import { analyticsRoutes } from './modules/analytics/routes.js';
|
|||||||
import { feedbackRoutes } from './modules/feedback/routes.js';
|
import { feedbackRoutes } from './modules/feedback/routes.js';
|
||||||
import { impersonationRoutes } from './modules/impersonation/routes.js';
|
import { impersonationRoutes } from './modules/impersonation/routes.js';
|
||||||
import { changelogRoutes } from './modules/changelog/routes.js';
|
import { changelogRoutes } from './modules/changelog/routes.js';
|
||||||
|
import { pushTriggerRoutes } from './modules/push-triggers/routes.js';
|
||||||
import { initCosmosIfNeeded } from './lib/cosmos-init.js';
|
import { initCosmosIfNeeded } from './lib/cosmos-init.js';
|
||||||
import { config } from './lib/config.js';
|
import { config } from './lib/config.js';
|
||||||
|
|
||||||
@ -176,5 +177,7 @@ await app.register(analyticsRoutes, { prefix: '/api' });
|
|||||||
await app.register(feedbackRoutes, { prefix: '/api' });
|
await app.register(feedbackRoutes, { prefix: '/api' });
|
||||||
await app.register(impersonationRoutes, { prefix: '/api' });
|
await app.register(impersonationRoutes, { prefix: '/api' });
|
||||||
await app.register(changelogRoutes, { prefix: '/api' });
|
await app.register(changelogRoutes, { prefix: '/api' });
|
||||||
|
// Push notification triggers (NomGap)
|
||||||
|
await app.register(pushTriggerRoutes, { prefix: '/api' });
|
||||||
|
|
||||||
await startService(app, { port: config.PORT, host: config.HOST });
|
await startService(app, { port: config.PORT, host: config.HOST });
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user