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:
saravanakumardb1 2026-02-28 14:10:11 -08:00
parent 20e0ef2201
commit ba2641c552
16 changed files with 659 additions and 27 deletions

View File

@ -10,7 +10,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { logError } from '@/lib/logger';
import { getCurrentUser } from '@/lib/auth-server';
import { getContainer } from '@/lib/cosmos';
import { PRODUCT_ID } from '@/lib/product-config';
import { getRequestProductId } from '@/lib/product-config';
interface CohortRow {
cohortWeek: string; // e.g. "2026-W05"
cohortStart: string; // ISO date of Monday
@ -43,6 +43,7 @@ export async function GET(req: NextRequest) {
}
const url = new URL(req.url);
const weeks = parseInt(url.searchParams.get('weeks') ?? '8', 10);
const productId = getRequestProductId(req);
// Get users created in the last N weeks
const sinceDate = new Date(Date.now() - weeks * 7 * 86400000).toISOString().slice(0, 10);
const usersContainer = getContainer('users');
@ -53,7 +54,7 @@ export async function GET(req: NextRequest) {
'WHERE c.productId = @pid AND c.createdAt >= @since ' +
'ORDER BY c.createdAt ASC',
parameters: [
{ name: '@pid', value: PRODUCT_ID },
{ name: '@pid', value: productId },
{ name: '@since', value: sinceDate },
],
})
@ -78,7 +79,7 @@ export async function GET(req: NextRequest) {
.query<{ userId: string; date: string }>({
query: 'SELECT c.userId, c.date FROM c ' + 'WHERE c.productId = @pid AND c.date >= @since',
parameters: [
{ name: '@pid', value: PRODUCT_ID },
{ name: '@pid', value: productId },
{ name: '@since', value: sinceDate },
],
})

View File

@ -8,7 +8,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth-server';
import { getContainer } from '@/lib/cosmos';
import { PRODUCT_ID } from '@/lib/product-config';
import { getRequestProductId } from '@/lib/product-config';
interface MonthlyRevenue {
month: string; // YYYY-MM
@ -50,7 +50,7 @@ export async function GET(req: NextRequest) {
query:
'SELECT c.id, c.plan, c.price, c.status, c.createdAt FROM c ' +
"WHERE c.productId = @pid AND c.status = 'active'",
parameters: [{ name: '@pid', value: PRODUCT_ID }],
parameters: [{ name: '@pid', value: productId }],
})
.fetchAll();

View File

@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { forgotPasswordViaService } from '@/lib/platform-client';
import { PRODUCT_ID } from '@/lib/product-config';
import { getRequestProductId } from '@/lib/product-config';
import { logError } from '@/lib/logger';
export async function POST(req: NextRequest) {
@ -10,7 +10,7 @@ export async function POST(req: NextRequest) {
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);
} catch (error) {
logError('Forgot password error', error);

View File

@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { logError } from '@/lib/logger';
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) {
try {
@ -13,7 +13,7 @@ export async function POST(req: NextRequest) {
const userAgent = req.headers.get('user-agent') ?? '';
try {
const result = await loginViaService(email, password, PRODUCT_ID);
const result = await loginViaService(email, password, getRequestProductId(req));
await logAudit({
userId: result.user.id,

View File

@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { logError } from '@/lib/logger';
import { requireAdmin } from '@/lib/auth-server';
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) {
try {
@ -49,7 +49,7 @@ export async function POST(req: NextRequest) {
password,
displayName: name,
role,
productId: PRODUCT_ID,
productId: getRequestProductId(req),
});
return NextResponse.json(result.user, { status: 201 });

View File

@ -8,9 +8,12 @@ const API_BASE = '/api';
function getAuthHeaders(): HeadersInit {
if (typeof window === 'undefined') return {};
const headers: Record<string, string> = {};
const token = localStorage.getItem('admin_access_token');
if (!token) return {};
return { Authorization: `Bearer ${token}` };
if (token) headers['Authorization'] = `Bearer ${token}`;
const productId = localStorage.getItem('admin_selected_product');
if (productId) headers['x-product-id'] = productId;
return headers;
}
export async function apiFetch<T>(

View File

@ -6,6 +6,7 @@
*/
import { loadProductIdentity } from '@bytelyst/config';
import type { NextRequest } from 'next/server';
const identity = loadProductIdentity();
@ -13,3 +14,19 @@ export const PRODUCT_ID = identity.productId;
export const DISPLAY_NAME = identity.displayName;
export const LICENSE_PREFIX = identity.licensePrefix;
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;

View 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;
}

View File

@ -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 = {
query:
"SELECT * FROM c WHERE c.productId = @productId AND c.status != 'expired' ORDER BY c.createdAt DESC OFFSET 0 LIMIT @limit",
parameters: [
{ name: '@productId', value: PRODUCT_ID },
{ name: '@productId', value: productId },
{ name: '@limit', value: limit },
],
};
@ -56,12 +56,15 @@ export async function listTokens(limit = 100): Promise<ApiTokenResponse[]> {
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 = {
query:
'SELECT * FROM c WHERE c.productId = @productId AND c.userId = @userId ORDER BY c.createdAt DESC',
parameters: [
{ name: '@productId', value: PRODUCT_ID },
{ name: '@productId', value: productId },
{ name: '@userId', value: userId },
],
};
@ -96,8 +99,8 @@ export async function deleteToken(id: string, userId: string): Promise<boolean>
}
}
export async function countActiveTokens(): Promise<number> {
const query = `SELECT VALUE COUNT(1) FROM c WHERE c.productId = '${PRODUCT_ID}' AND c.status = 'active'`;
export async function countActiveTokens(productId = PRODUCT_ID): Promise<number> {
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();
return resources[0] ?? 0;
}

View File

@ -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 = {
query: 'SELECT * FROM c WHERE c.productId = @productId AND c.email = @email',
parameters: [
{ name: '@productId', value: PRODUCT_ID },
{ name: '@productId', value: productId },
{ name: '@email', value: email.toLowerCase() },
],
};
@ -55,12 +58,16 @@ export async function getUserByEmail(email: string): Promise<UserDoc | 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 = {
query:
'SELECT * FROM c WHERE c.productId = @productId ORDER BY c.createdAt DESC OFFSET @offset LIMIT @limit',
parameters: [
{ name: '@productId', value: PRODUCT_ID },
{ name: '@productId', value: productId },
{ name: '@offset', value: offset },
{ 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()
.items.query<number>({
query: 'SELECT VALUE COUNT(1) FROM c WHERE c.productId = @productId',
parameters: [{ name: '@productId', value: PRODUCT_ID }],
parameters: [{ name: '@productId', value: productId }],
})
.fetchAll();
return resources[0] ?? 0;
}
export async function countUsersByPlan(): Promise<Record<string, number>> {
const query = `SELECT c.plan, COUNT(1) AS count FROM c WHERE c.productId = '${PRODUCT_ID}' GROUP BY c.plan`;
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 = '${productId}' GROUP BY c.plan`;
const { resources } = await container()
.items.query<{ plan: string; count: number }>(query)
.fetchAll();

View File

@ -76,6 +76,8 @@ const CONTAINER_DEFS: Record<string, ContainerConfig> = {
feedback: { partitionKeyPath: '/productId' },
impersonation_sessions: { partitionKeyPath: '/productId', defaultTtl: 90 * 86400 },
changelog: { partitionKeyPath: '/productId' },
// Push notification triggers (NomGap)
push_triggers: { partitionKeyPath: '/productId', defaultTtl: 30 * 86400 },
};
export async function initCosmosIfNeeded(): Promise<void> {

View File

@ -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();
});
});

View File

@ -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;
}

View File

@ -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);
});
}

View 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}}`);
}

View File

@ -69,6 +69,7 @@ import { analyticsRoutes } from './modules/analytics/routes.js';
import { feedbackRoutes } from './modules/feedback/routes.js';
import { impersonationRoutes } from './modules/impersonation/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 { config } from './lib/config.js';
@ -176,5 +177,7 @@ await app.register(analyticsRoutes, { prefix: '/api' });
await app.register(feedbackRoutes, { prefix: '/api' });
await app.register(impersonationRoutes, { 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 });