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 { 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 },
|
||||
],
|
||||
})
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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>(
|
||||
|
||||
@ -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;
|
||||
|
||||
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 = {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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 { 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 });
|
||||
|
||||
Loading…
Reference in New Issue
Block a user