From ba2641c552b309f978e7a94521406b23df1bf3d3 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Sat, 28 Feb 2026 14:10:11 -0800 Subject: [PATCH] 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 --- .../src/app/api/analytics/retention/route.ts | 7 +- .../src/app/api/analytics/revenue/route.ts | 4 +- .../src/app/api/auth/forgot-password/route.ts | 4 +- .../admin-web/src/app/api/auth/login/route.ts | 4 +- .../admin-web/src/app/api/users/route.ts | 4 +- dashboards/admin-web/src/lib/api.ts | 7 +- .../admin-web/src/lib/product-config.ts | 17 ++ .../admin-web/src/lib/product-context.tsx | 48 +++++ .../admin-web/src/lib/repositories/tokens.ts | 15 +- .../admin-web/src/lib/repositories/users.ts | 23 ++- .../platform-service/src/lib/cosmos-init.ts | 2 + .../push-triggers/push-triggers.test.ts | 174 ++++++++++++++++++ .../src/modules/push-triggers/repository.ts | 152 +++++++++++++++ .../src/modules/push-triggers/routes.ts | 89 +++++++++ .../src/modules/push-triggers/types.ts | 133 +++++++++++++ services/platform-service/src/server.ts | 3 + 16 files changed, 659 insertions(+), 27 deletions(-) create mode 100644 dashboards/admin-web/src/lib/product-context.tsx create mode 100644 services/platform-service/src/modules/push-triggers/push-triggers.test.ts create mode 100644 services/platform-service/src/modules/push-triggers/repository.ts create mode 100644 services/platform-service/src/modules/push-triggers/routes.ts create mode 100644 services/platform-service/src/modules/push-triggers/types.ts diff --git a/dashboards/admin-web/src/app/api/analytics/retention/route.ts b/dashboards/admin-web/src/app/api/analytics/retention/route.ts index 5dda4fa2..1bdb6546 100644 --- a/dashboards/admin-web/src/app/api/analytics/retention/route.ts +++ b/dashboards/admin-web/src/app/api/analytics/retention/route.ts @@ -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 }, ], }) diff --git a/dashboards/admin-web/src/app/api/analytics/revenue/route.ts b/dashboards/admin-web/src/app/api/analytics/revenue/route.ts index f35c72b3..e811eda3 100644 --- a/dashboards/admin-web/src/app/api/analytics/revenue/route.ts +++ b/dashboards/admin-web/src/app/api/analytics/revenue/route.ts @@ -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(); diff --git a/dashboards/admin-web/src/app/api/auth/forgot-password/route.ts b/dashboards/admin-web/src/app/api/auth/forgot-password/route.ts index f6a40aa6..bcaa38d6 100644 --- a/dashboards/admin-web/src/app/api/auth/forgot-password/route.ts +++ b/dashboards/admin-web/src/app/api/auth/forgot-password/route.ts @@ -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); diff --git a/dashboards/admin-web/src/app/api/auth/login/route.ts b/dashboards/admin-web/src/app/api/auth/login/route.ts index 8a61b7a2..92141469 100644 --- a/dashboards/admin-web/src/app/api/auth/login/route.ts +++ b/dashboards/admin-web/src/app/api/auth/login/route.ts @@ -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, diff --git a/dashboards/admin-web/src/app/api/users/route.ts b/dashboards/admin-web/src/app/api/users/route.ts index c4bc6715..b84b0e25 100644 --- a/dashboards/admin-web/src/app/api/users/route.ts +++ b/dashboards/admin-web/src/app/api/users/route.ts @@ -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 }); diff --git a/dashboards/admin-web/src/lib/api.ts b/dashboards/admin-web/src/lib/api.ts index ff954c5f..410308ee 100644 --- a/dashboards/admin-web/src/lib/api.ts +++ b/dashboards/admin-web/src/lib/api.ts @@ -8,9 +8,12 @@ const API_BASE = '/api'; function getAuthHeaders(): HeadersInit { if (typeof window === 'undefined') return {}; + const headers: Record = {}; 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( diff --git a/dashboards/admin-web/src/lib/product-config.ts b/dashboards/admin-web/src/lib/product-config.ts index 518320cb..bf4a1d8f 100644 --- a/dashboards/admin-web/src/lib/product-config.ts +++ b/dashboards/admin-web/src/lib/product-config.ts @@ -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; diff --git a/dashboards/admin-web/src/lib/product-context.tsx b/dashboards/admin-web/src/lib/product-context.tsx new file mode 100644 index 00000000..faad6f69 --- /dev/null +++ b/dashboards/admin-web/src/lib/product-context.tsx @@ -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(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(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 ( + + {children} + + ); +} + +export function useProduct(): ProductContextValue { + const ctx = useContext(ProductContext); + if (!ctx) throw new Error('useProduct must be used within '); + return ctx; +} diff --git a/dashboards/admin-web/src/lib/repositories/tokens.ts b/dashboards/admin-web/src/lib/repositories/tokens.ts index 80f692ec..1fa16e00 100644 --- a/dashboards/admin-web/src/lib/repositories/tokens.ts +++ b/dashboards/admin-web/src/lib/repositories/tokens.ts @@ -43,12 +43,12 @@ export async function getTokenById(id: string, userId: string): Promise { +export async function listTokens(limit = 100, productId = PRODUCT_ID): Promise { 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 { return resources.map(stripHash); } -export async function listTokensByUser(userId: string): Promise { +export async function listTokensByUser( + userId: string, + productId = PRODUCT_ID +): Promise { 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 } } -export async function countActiveTokens(): Promise { - 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 { + const query = `SELECT VALUE COUNT(1) FROM c WHERE c.productId = '${productId}' AND c.status = 'active'`; const { resources } = await container().items.query(query).fetchAll(); return resources[0] ?? 0; } diff --git a/dashboards/admin-web/src/lib/repositories/users.ts b/dashboards/admin-web/src/lib/repositories/users.ts index bf64b392..6970a883 100644 --- a/dashboards/admin-web/src/lib/repositories/users.ts +++ b/dashboards/admin-web/src/lib/repositories/users.ts @@ -43,11 +43,14 @@ export async function getUserById(id: string): Promise { } } -export async function getUserByEmail(email: string): Promise { +export async function getUserByEmail( + email: string, + productId = PRODUCT_ID +): Promise { 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 { return resources[0] ?? null; } -export async function listUsers(limit = 100, offset = 0): Promise { +export async function listUsers( + limit = 100, + offset = 0, + productId = PRODUCT_ID +): Promise { 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 { } } -export async function countUsers(): Promise { +export async function countUsers(productId = PRODUCT_ID): Promise { const { resources } = await container() .items.query({ 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> { - 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> { + 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(); diff --git a/services/platform-service/src/lib/cosmos-init.ts b/services/platform-service/src/lib/cosmos-init.ts index 7a080a4a..7e940933 100644 --- a/services/platform-service/src/lib/cosmos-init.ts +++ b/services/platform-service/src/lib/cosmos-init.ts @@ -76,6 +76,8 @@ const CONTAINER_DEFS: Record = { 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 { diff --git a/services/platform-service/src/modules/push-triggers/push-triggers.test.ts b/services/platform-service/src/modules/push-triggers/push-triggers.test.ts new file mode 100644 index 00000000..adab1ad6 --- /dev/null +++ b/services/platform-service/src/modules/push-triggers/push-triggers.test.ts @@ -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(); + }); +}); diff --git a/services/platform-service/src/modules/push-triggers/repository.ts b/services/platform-service/src/modules/push-triggers/repository.ts new file mode 100644 index 00000000..9bf00731 --- /dev/null +++ b/services/platform-service/src/modules/push-triggers/repository.ts @@ -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 { + 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 { + 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 { + try { + const { resource } = await getContainer().item(id, productId).read(); + return resource ?? null; + } catch { + return null; + } +} + +export async function listTriggers( + productId: string, + query: QueryTriggersInput +): Promise { + 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({ 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 { + const { resources } = await getContainer() + .items.query({ + 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 { + 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> { + 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 = { 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; +} diff --git a/services/platform-service/src/modules/push-triggers/routes.ts b/services/platform-service/src/modules/push-triggers/routes.ts new file mode 100644 index 00000000..de08b167 --- /dev/null +++ b/services/platform-service/src/modules/push-triggers/routes.ts @@ -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 { + // ── 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); + }); +} diff --git a/services/platform-service/src/modules/push-triggers/types.ts b/services/platform-service/src/modules/push-triggers/types.ts new file mode 100644 index 00000000..cd4cba52 --- /dev/null +++ b/services/platform-service/src/modules/push-triggers/types.ts @@ -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; + 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 = { + 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; +export type QueryTriggersInput = z.infer; + +// ── Template interpolation ───────────────────────────────────── + +export function interpolateTemplate(template: string, variables: Record): string { + return template.replace(/\{(\w+)\}/g, (_, key) => variables[key] ?? `{${key}}`); +} diff --git a/services/platform-service/src/server.ts b/services/platform-service/src/server.ts index 07e39cd5..0a9efa09 100644 --- a/services/platform-service/src/server.ts +++ b/services/platform-service/src/server.ts @@ -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 });