From 33160a5daa9b134dee8877ff54150400718d886c Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Sat, 28 Feb 2026 20:24:06 -0800 Subject: [PATCH] feat(platform-service): add brains, daily-briefs, reflections, streaks modules --- .../src/modules/brains/brains.test.ts | 92 +++++++++++++ .../src/modules/brains/repository.ts | 73 ++++++++++ .../src/modules/brains/routes.ts | 125 +++++++++++++++++ .../src/modules/brains/types.ts | 47 +++++++ .../modules/daily-briefs/daily-briefs.test.ts | 73 ++++++++++ .../src/modules/daily-briefs/repository.ts | 83 +++++++++++ .../src/modules/daily-briefs/routes.ts | 76 +++++++++++ .../src/modules/daily-briefs/types.ts | 38 ++++++ .../modules/reflections/reflections.test.ts | 77 +++++++++++ .../src/modules/reflections/repository.ts | 64 +++++++++ .../src/modules/reflections/routes.ts | 66 +++++++++ .../src/modules/reflections/types.ts | 59 ++++++++ .../src/modules/streaks/repository.ts | 35 +++++ .../src/modules/streaks/routes.ts | 129 ++++++++++++++++++ .../src/modules/streaks/streaks.test.ts | 52 +++++++ .../src/modules/streaks/types.ts | 32 +++++ 16 files changed, 1121 insertions(+) create mode 100644 services/platform-service/src/modules/brains/brains.test.ts create mode 100644 services/platform-service/src/modules/brains/repository.ts create mode 100644 services/platform-service/src/modules/brains/routes.ts create mode 100644 services/platform-service/src/modules/brains/types.ts create mode 100644 services/platform-service/src/modules/daily-briefs/daily-briefs.test.ts create mode 100644 services/platform-service/src/modules/daily-briefs/repository.ts create mode 100644 services/platform-service/src/modules/daily-briefs/routes.ts create mode 100644 services/platform-service/src/modules/daily-briefs/types.ts create mode 100644 services/platform-service/src/modules/reflections/reflections.test.ts create mode 100644 services/platform-service/src/modules/reflections/repository.ts create mode 100644 services/platform-service/src/modules/reflections/routes.ts create mode 100644 services/platform-service/src/modules/reflections/types.ts create mode 100644 services/platform-service/src/modules/streaks/repository.ts create mode 100644 services/platform-service/src/modules/streaks/routes.ts create mode 100644 services/platform-service/src/modules/streaks/streaks.test.ts create mode 100644 services/platform-service/src/modules/streaks/types.ts diff --git a/services/platform-service/src/modules/brains/brains.test.ts b/services/platform-service/src/modules/brains/brains.test.ts new file mode 100644 index 00000000..d41c39bb --- /dev/null +++ b/services/platform-service/src/modules/brains/brains.test.ts @@ -0,0 +1,92 @@ +/** + * Tests for brain schemas. + */ + +import { describe, it, expect } from 'vitest'; +import { CreateBrainSchema, UpdateBrainSchema, ListBrainsQuerySchema } from './types.js'; + +describe('ListBrainsQuerySchema', () => { + it('accepts defaults', () => { + const result = ListBrainsQuerySchema.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.limit).toBe(20); + expect(result.data.offset).toBe(0); + } + }); + + it('rejects huge limit', () => { + const result = ListBrainsQuerySchema.safeParse({ limit: 9999 }); + expect(result.success).toBe(false); + }); + + it('coerces string numbers', () => { + const result = ListBrainsQuerySchema.safeParse({ limit: '10', offset: '5' }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.limit).toBe(10); + expect(result.data.offset).toBe(5); + } + }); +}); + +describe('CreateBrainSchema', () => { + it('requires name', () => { + const result = CreateBrainSchema.safeParse({}); + expect(result.success).toBe(false); + }); + + it('accepts minimal valid payload', () => { + const result = CreateBrainSchema.safeParse({ name: 'War Room' }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.name).toBe('War Room'); + expect(result.data.tone).toBe('balanced'); + expect(result.data.rolePrompt).toBe(''); + expect(result.data.colorFrom).toBe('#A5B1C7'); + expect(result.data.colorTo).toBe('#6C7C98'); + } + }); + + it('accepts full payload with custom id', () => { + const result = CreateBrainSchema.safeParse({ + id: 'work', + name: 'War Room', + rolePrompt: 'Strategic execution assistant.', + tone: 'direct', + colorFrom: '#5A8CFF', + colorTo: '#2EE6D6', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.id).toBe('work'); + } + }); + + it('rejects empty name', () => { + const result = CreateBrainSchema.safeParse({ name: '' }); + expect(result.success).toBe(false); + }); + + it('rejects name exceeding max length', () => { + const result = CreateBrainSchema.safeParse({ name: 'x'.repeat(201) }); + expect(result.success).toBe(false); + }); +}); + +describe('UpdateBrainSchema', () => { + it('accepts empty update (all optional)', () => { + const result = UpdateBrainSchema.safeParse({}); + expect(result.success).toBe(true); + }); + + it('accepts partial update', () => { + const result = UpdateBrainSchema.safeParse({ name: 'Renamed', tone: 'warm' }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.name).toBe('Renamed'); + expect(result.data.tone).toBe('warm'); + expect(result.data.rolePrompt).toBeUndefined(); + } + }); +}); diff --git a/services/platform-service/src/modules/brains/repository.ts b/services/platform-service/src/modules/brains/repository.ts new file mode 100644 index 00000000..45f11d1d --- /dev/null +++ b/services/platform-service/src/modules/brains/repository.ts @@ -0,0 +1,73 @@ +/** + * Brains repository — Cosmos DB CRUD. + * + * Container: brains (partition key: /userId) + */ + +import { getContainer } from '../../lib/cosmos.js'; +import type { BrainDoc } from './types.js'; + +function container() { + return getContainer('brains'); +} + +export async function list( + userId: string, + productId: string, + limit: number, + offset: number +): Promise<{ items: BrainDoc[]; total: number }> { + const countResult = await container() + .items.query({ + query: 'SELECT VALUE COUNT(1) FROM c WHERE c.userId = @userId AND c.productId = @productId', + parameters: [ + { name: '@userId', value: userId }, + { name: '@productId', value: productId }, + ], + }) + .fetchAll(); + const total = countResult.resources[0] ?? 0; + + const { resources } = await container() + .items.query({ + query: + 'SELECT * FROM c WHERE c.userId = @userId AND c.productId = @productId ORDER BY c.createdAt ASC OFFSET @offset LIMIT @limit', + parameters: [ + { name: '@userId', value: userId }, + { name: '@productId', value: productId }, + { name: '@offset', value: offset }, + { name: '@limit', value: limit }, + ], + }) + .fetchAll(); + + return { items: resources, total }; +} + +export async function getById(id: string, userId: string): Promise { + try { + const { resource } = await container().item(id, userId).read(); + return resource ?? null; + } catch { + return null; + } +} + +export async function create(doc: BrainDoc): Promise { + const { resource } = await container().items.create(doc); + return resource as BrainDoc; +} + +export async function replace(doc: BrainDoc): Promise { + const { resource } = await container().item(doc.id, doc.userId).replace(doc); + return resource as BrainDoc; +} + +export async function remove(id: string, userId: string): Promise { + try { + await container().item(id, userId).delete(); + return true; + } catch { + return false; + } +} diff --git a/services/platform-service/src/modules/brains/routes.ts b/services/platform-service/src/modules/brains/routes.ts new file mode 100644 index 00000000..a906fd1e --- /dev/null +++ b/services/platform-service/src/modules/brains/routes.ts @@ -0,0 +1,125 @@ +/** + * Brain REST endpoints — MindLyst role-based brains. + * + * GET /brains — list user's brains + * GET /brains/:id — single brain + * POST /brains — create brain (max 10 per user) + * PUT /brains/:id — update brain + * DELETE /brains/:id — delete brain (cannot delete "global") + * + * Container: brains (partition key: /userId) + */ + +import type { FastifyInstance } from 'fastify'; +import { randomUUID } from 'node:crypto'; +import { getRequestProductId } from '../../lib/request-context.js'; +import { BadRequestError, NotFoundError } from '../../lib/errors.js'; +import { extractAuth } from '../../lib/auth.js'; +import * as repo from './repository.js'; +import { + CreateBrainSchema, + UpdateBrainSchema, + ListBrainsQuerySchema, + type BrainDoc, +} from './types.js'; + +const MAX_BRAINS = 10; + +export async function brainRoutes(app: FastifyInstance) { + // List brains + app.get('/brains', async req => { + const auth = await extractAuth(req); + const parsed = ListBrainsQuerySchema.safeParse(req.query); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + const pid = getRequestProductId(req); + const { items, total } = await repo.list(auth.sub, pid, parsed.data.limit, parsed.data.offset); + return { items, total, limit: parsed.data.limit, offset: parsed.data.offset }; + }); + + // Get single brain + app.get('/brains/:id', async req => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + const pid = getRequestProductId(req); + const brain = await repo.getById(id, auth.sub); + if (!brain || brain.productId !== pid) throw new NotFoundError('Brain not found'); + return brain; + }); + + // Create brain + app.post('/brains', async (req, reply) => { + const auth = await extractAuth(req); + const parsed = CreateBrainSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + const pid = getRequestProductId(req); + const { total } = await repo.list(auth.sub, pid, 1, 0); + if (total >= MAX_BRAINS) { + throw new BadRequestError(`Maximum ${MAX_BRAINS} brains allowed`); + } + + const now = new Date().toISOString(); + const doc: BrainDoc = { + id: parsed.data.id || `brain_${randomUUID()}`, + userId: auth.sub, + productId: pid, + name: parsed.data.name, + rolePrompt: parsed.data.rolePrompt, + tone: parsed.data.tone, + colorFrom: parsed.data.colorFrom, + colorTo: parsed.data.colorTo, + createdAt: now, + updatedAt: null, + }; + + req.log.info({ brainId: doc.id }, 'Creating brain'); + const created = await repo.create(doc); + reply.code(201); + return created; + }); + + // Update brain + app.put('/brains/:id', async req => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + const parsed = UpdateBrainSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + const pid = getRequestProductId(req); + const existing = await repo.getById(id, auth.sub); + if (!existing || existing.productId !== pid) throw new NotFoundError('Brain not found'); + + const updated: BrainDoc = { + ...existing, + ...parsed.data, + updatedAt: new Date().toISOString(), + }; + + req.log.info({ brainId: id }, 'Updated brain'); + return repo.replace(updated); + }); + + // Delete brain + app.delete('/brains/:id', async req => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + + if (id === 'global') { + throw new BadRequestError('Cannot delete Global Brain'); + } + + const pid = getRequestProductId(req); + const existing = await repo.getById(id, auth.sub); + if (!existing || existing.productId !== pid) throw new NotFoundError('Brain not found'); + + await repo.remove(id, auth.sub); + req.log.info({ brainId: id }, 'Deleted brain'); + return { success: true }; + }); +} diff --git a/services/platform-service/src/modules/brains/types.ts b/services/platform-service/src/modules/brains/types.ts new file mode 100644 index 00000000..3215eb4c --- /dev/null +++ b/services/platform-service/src/modules/brains/types.ts @@ -0,0 +1,47 @@ +/** + * Brain types — MindLyst role-based "second brain" containers. + * + * Cosmos container: `brains` (partition key: `/userId`) + * Product ID: per-request (typically "mindlyst") + */ + +import { z } from 'zod'; + +export interface BrainDoc { + id: string; + userId: string; + productId: string; + name: string; + rolePrompt: string; + tone: string; + colorFrom: string; + colorTo: string; + createdAt: string; + updatedAt: string | null; +} + +export const CreateBrainSchema = z.object({ + id: z.string().min(1).max(128).optional(), + name: z.string().min(1).max(200), + rolePrompt: z.string().max(2000).default(''), + tone: z.string().max(64).default('balanced'), + colorFrom: z.string().max(32).default('#A5B1C7'), + colorTo: z.string().max(32).default('#6C7C98'), +}); + +export const UpdateBrainSchema = z.object({ + name: z.string().min(1).max(200).optional(), + rolePrompt: z.string().max(2000).optional(), + tone: z.string().max(64).optional(), + colorFrom: z.string().max(32).optional(), + colorTo: z.string().max(32).optional(), +}); + +export const ListBrainsQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(50).default(20), + offset: z.coerce.number().int().min(0).default(0), +}); + +export type CreateBrainInput = z.infer; +export type UpdateBrainInput = z.infer; +export type ListBrainsQuery = z.infer; diff --git a/services/platform-service/src/modules/daily-briefs/daily-briefs.test.ts b/services/platform-service/src/modules/daily-briefs/daily-briefs.test.ts new file mode 100644 index 00000000..1c414e5f --- /dev/null +++ b/services/platform-service/src/modules/daily-briefs/daily-briefs.test.ts @@ -0,0 +1,73 @@ +/** + * Tests for daily brief schemas. + */ + +import { describe, it, expect } from 'vitest'; +import { CreateDailyBriefSchema, ListDailyBriefsQuerySchema } from './types.js'; + +describe('ListDailyBriefsQuerySchema', () => { + it('accepts defaults', () => { + const result = ListDailyBriefsQuerySchema.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.limit).toBe(7); + expect(result.data.offset).toBe(0); + } + }); + + it('rejects limit above 30', () => { + const result = ListDailyBriefsQuerySchema.safeParse({ limit: 50 }); + expect(result.success).toBe(false); + }); +}); + +describe('CreateDailyBriefSchema', () => { + it('requires date', () => { + const result = CreateDailyBriefSchema.safeParse({}); + expect(result.success).toBe(false); + }); + + it('accepts minimal valid payload', () => { + const result = CreateDailyBriefSchema.safeParse({ date: '2026-02-28' }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.greeting).toBe('Good morning!'); + expect(result.data.priorityItems).toEqual([]); + expect(result.data.brainSummaries).toEqual({}); + expect(result.data.streakMessage).toBeNull(); + expect(result.data.motivationalQuote).toBeNull(); + } + }); + + it('accepts full payload', () => { + const result = CreateDailyBriefSchema.safeParse({ + date: '2026-02-28', + greeting: 'Good morning, commander!', + priorityItems: ['Ship feature X', 'Review PR #42'], + brainSummaries: { + work: '3 tasks pending, 1 high urgency', + health: 'Workout scheduled at 6pm', + }, + streakMessage: '7-day streak! Keep it up!', + motivationalQuote: 'The only way to do great work is to love what you do.', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.priorityItems).toHaveLength(2); + expect(result.data.brainSummaries.work).toContain('3 tasks'); + } + }); + + it('rejects invalid date format', () => { + const result = CreateDailyBriefSchema.safeParse({ date: 'tomorrow' }); + expect(result.success).toBe(false); + }); + + it('rejects greeting exceeding max length', () => { + const result = CreateDailyBriefSchema.safeParse({ + date: '2026-02-28', + greeting: 'x'.repeat(501), + }); + expect(result.success).toBe(false); + }); +}); diff --git a/services/platform-service/src/modules/daily-briefs/repository.ts b/services/platform-service/src/modules/daily-briefs/repository.ts new file mode 100644 index 00000000..95ee6505 --- /dev/null +++ b/services/platform-service/src/modules/daily-briefs/repository.ts @@ -0,0 +1,83 @@ +/** + * Daily briefs repository — Cosmos DB CRUD. + * + * Container: daily_briefs (partition key: /userId) + */ + +import { getContainer } from '../../lib/cosmos.js'; +import type { DailyBriefDoc } from './types.js'; + +function container() { + return getContainer('daily_briefs'); +} + +export async function list( + userId: string, + productId: string, + limit: number, + offset: number +): Promise<{ items: DailyBriefDoc[]; total: number }> { + const countResult = await container() + .items.query({ + query: 'SELECT VALUE COUNT(1) FROM c WHERE c.userId = @userId AND c.productId = @productId', + parameters: [ + { name: '@userId', value: userId }, + { name: '@productId', value: productId }, + ], + }) + .fetchAll(); + const total = countResult.resources[0] ?? 0; + + const { resources } = await container() + .items.query({ + query: + 'SELECT * FROM c WHERE c.userId = @userId AND c.productId = @productId ORDER BY c.date DESC OFFSET @offset LIMIT @limit', + parameters: [ + { name: '@userId', value: userId }, + { name: '@productId', value: productId }, + { name: '@offset', value: offset }, + { name: '@limit', value: limit }, + ], + }) + .fetchAll(); + + return { items: resources, total }; +} + +export async function getByDate( + userId: string, + productId: string, + date: string +): Promise { + const { resources } = await container() + .items.query({ + query: + 'SELECT * FROM c WHERE c.userId = @userId AND c.productId = @productId AND c.date = @date', + parameters: [ + { name: '@userId', value: userId }, + { name: '@productId', value: productId }, + { name: '@date', value: date }, + ], + }) + .fetchAll(); + return resources[0] ?? null; +} + +export async function getById(id: string, userId: string): Promise { + try { + const { resource } = await container().item(id, userId).read(); + return resource ?? null; + } catch { + return null; + } +} + +export async function create(doc: DailyBriefDoc): Promise { + const { resource } = await container().items.create(doc); + return resource as DailyBriefDoc; +} + +export async function replace(doc: DailyBriefDoc): Promise { + const { resource } = await container().item(doc.id, doc.userId).replace(doc); + return resource as DailyBriefDoc; +} diff --git a/services/platform-service/src/modules/daily-briefs/routes.ts b/services/platform-service/src/modules/daily-briefs/routes.ts new file mode 100644 index 00000000..6e889f08 --- /dev/null +++ b/services/platform-service/src/modules/daily-briefs/routes.ts @@ -0,0 +1,76 @@ +/** + * Daily brief REST endpoints — MindLyst morning briefings. + * + * GET /daily-briefs — list recent briefs + * GET /daily-briefs/today — get today's brief (or 404) + * GET /daily-briefs/:id — single brief by id + * POST /daily-briefs — create/store a daily brief + * + * Container: daily_briefs (partition key: /userId) + */ + +import type { FastifyInstance } from 'fastify'; +import { randomUUID } from 'node:crypto'; +import { getRequestProductId } from '../../lib/request-context.js'; +import { BadRequestError, NotFoundError } from '../../lib/errors.js'; +import { extractAuth } from '../../lib/auth.js'; +import * as repo from './repository.js'; +import { CreateDailyBriefSchema, ListDailyBriefsQuerySchema, type DailyBriefDoc } from './types.js'; + +export async function dailyBriefRoutes(app: FastifyInstance) { + // Today's brief — must be before :id param route + app.get('/daily-briefs/today', async req => { + const auth = await extractAuth(req); + const pid = getRequestProductId(req); + const today = new Date().toISOString().slice(0, 10); + const brief = await repo.getByDate(auth.sub, pid, today); + if (!brief) throw new NotFoundError('No brief for today'); + return brief; + }); + + // List briefs + app.get('/daily-briefs', async req => { + const auth = await extractAuth(req); + const parsed = ListDailyBriefsQuerySchema.safeParse(req.query); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + const pid = getRequestProductId(req); + const { items, total } = await repo.list(auth.sub, pid, parsed.data.limit, parsed.data.offset); + return { items, total, limit: parsed.data.limit, offset: parsed.data.offset }; + }); + + // Get single brief + app.get('/daily-briefs/:id', async req => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + const pid = getRequestProductId(req); + const brief = await repo.getById(id, auth.sub); + if (!brief || brief.productId !== pid) throw new NotFoundError('Brief not found'); + return brief; + }); + + // Create brief + app.post('/daily-briefs', async (req, reply) => { + const auth = await extractAuth(req); + const parsed = CreateDailyBriefSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + const pid = getRequestProductId(req); + const now = new Date().toISOString(); + const doc: DailyBriefDoc = { + id: `brief_${parsed.data.date}_${randomUUID()}`, + userId: auth.sub, + productId: pid, + ...parsed.data, + createdAt: now, + }; + + req.log.info({ briefId: doc.id, date: doc.date }, 'Creating daily brief'); + const created = await repo.create(doc); + reply.code(201); + return created; + }); +} diff --git a/services/platform-service/src/modules/daily-briefs/types.ts b/services/platform-service/src/modules/daily-briefs/types.ts new file mode 100644 index 00000000..a3bb2413 --- /dev/null +++ b/services/platform-service/src/modules/daily-briefs/types.ts @@ -0,0 +1,38 @@ +/** + * Daily brief types — MindLyst morning briefing content. + * + * Cosmos container: `daily_briefs` (partition key: `/userId`) + * Product ID: per-request (typically "mindlyst") + */ + +import { z } from 'zod'; + +export interface DailyBriefDoc { + id: string; + userId: string; + productId: string; + date: string; + greeting: string; + priorityItems: string[]; + brainSummaries: Record; + streakMessage: string | null; + motivationalQuote: string | null; + createdAt: string; +} + +export const CreateDailyBriefSchema = z.object({ + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Must be YYYY-MM-DD'), + greeting: z.string().max(500).default('Good morning!'), + priorityItems: z.array(z.string().max(500)).default([]), + brainSummaries: z.record(z.string(), z.string().max(1000)).default({}), + streakMessage: z.string().max(500).nullable().default(null), + motivationalQuote: z.string().max(500).nullable().default(null), +}); + +export const ListDailyBriefsQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(30).default(7), + offset: z.coerce.number().int().min(0).default(0), +}); + +export type CreateDailyBriefInput = z.infer; +export type ListDailyBriefsQuery = z.infer; diff --git a/services/platform-service/src/modules/reflections/reflections.test.ts b/services/platform-service/src/modules/reflections/reflections.test.ts new file mode 100644 index 00000000..4143c2f3 --- /dev/null +++ b/services/platform-service/src/modules/reflections/reflections.test.ts @@ -0,0 +1,77 @@ +/** + * Tests for reflection schemas. + */ + +import { describe, it, expect } from 'vitest'; +import { CreateReflectionSchema, ListReflectionsQuerySchema } from './types.js'; + +describe('ListReflectionsQuerySchema', () => { + it('accepts defaults', () => { + const result = ListReflectionsQuerySchema.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.limit).toBe(10); + expect(result.data.offset).toBe(0); + } + }); + + it('rejects huge limit', () => { + const result = ListReflectionsQuerySchema.safeParse({ limit: 9999 }); + expect(result.success).toBe(false); + }); +}); + +describe('CreateReflectionSchema', () => { + it('requires weekStartDate and weekEndDate', () => { + const result = CreateReflectionSchema.safeParse({}); + expect(result.success).toBe(false); + }); + + it('accepts minimal valid payload', () => { + const result = CreateReflectionSchema.safeParse({ + weekStartDate: '2026-02-21', + weekEndDate: '2026-02-28', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.repeatedThemes).toEqual([]); + expect(result.data.postponedItems).toEqual([]); + expect(result.data.totalCaptured).toBe(0); + expect(result.data.totalCompleted).toBe(0); + expect(result.data.vsLastWeek).toBeNull(); + } + }); + + it('accepts full payload with vsLastWeek', () => { + const result = CreateReflectionSchema.safeParse({ + weekStartDate: '2026-02-21', + weekEndDate: '2026-02-28', + repeatedThemes: ['task: 5 items this week'], + postponedItems: ['Fix CI pipeline'], + roleImbalanceSignals: ['War Room consumed 70% of attention'], + suggestedAdjustments: ['Block focus time for Health brain'], + totalCaptured: 12, + totalCompleted: 8, + brainBreakdown: { work: 8, home: 2, health: 2 }, + vsLastWeek: { + capturedDelta: 3, + completedDelta: 2, + completionRateDelta: 5, + summary: 'vs. last week: +3 captures, +2 completed', + }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.totalCaptured).toBe(12); + expect(result.data.vsLastWeek?.capturedDelta).toBe(3); + } + }); + + it('rejects invalid date format', () => { + const result = CreateReflectionSchema.safeParse({ + weekStartDate: 'Feb 21', + weekEndDate: '2026-02-28', + }); + expect(result.success).toBe(false); + }); +}); diff --git a/services/platform-service/src/modules/reflections/repository.ts b/services/platform-service/src/modules/reflections/repository.ts new file mode 100644 index 00000000..313db8fa --- /dev/null +++ b/services/platform-service/src/modules/reflections/repository.ts @@ -0,0 +1,64 @@ +/** + * Reflections repository — Cosmos DB CRUD. + * + * Container: reflections (partition key: /userId) + */ + +import { getContainer } from '../../lib/cosmos.js'; +import type { ReflectionDoc } from './types.js'; + +function container() { + return getContainer('reflections'); +} + +export async function list( + userId: string, + productId: string, + limit: number, + offset: number +): Promise<{ items: ReflectionDoc[]; total: number }> { + const countResult = await container() + .items.query({ + query: 'SELECT VALUE COUNT(1) FROM c WHERE c.userId = @userId AND c.productId = @productId', + parameters: [ + { name: '@userId', value: userId }, + { name: '@productId', value: productId }, + ], + }) + .fetchAll(); + const total = countResult.resources[0] ?? 0; + + const { resources } = await container() + .items.query({ + query: + 'SELECT * FROM c WHERE c.userId = @userId AND c.productId = @productId ORDER BY c.createdAt DESC OFFSET @offset LIMIT @limit', + parameters: [ + { name: '@userId', value: userId }, + { name: '@productId', value: productId }, + { name: '@offset', value: offset }, + { name: '@limit', value: limit }, + ], + }) + .fetchAll(); + + return { items: resources, total }; +} + +export async function getById(id: string, userId: string): Promise { + try { + const { resource } = await container().item(id, userId).read(); + return resource ?? null; + } catch { + return null; + } +} + +export async function create(doc: ReflectionDoc): Promise { + const { resource } = await container().items.create(doc); + return resource as ReflectionDoc; +} + +export async function replace(doc: ReflectionDoc): Promise { + const { resource } = await container().item(doc.id, doc.userId).replace(doc); + return resource as ReflectionDoc; +} diff --git a/services/platform-service/src/modules/reflections/routes.ts b/services/platform-service/src/modules/reflections/routes.ts new file mode 100644 index 00000000..967c0ad1 --- /dev/null +++ b/services/platform-service/src/modules/reflections/routes.ts @@ -0,0 +1,66 @@ +/** + * Reflection REST endpoints — MindLyst weekly reflection reports. + * + * GET /reflections — list past reflection reports + * GET /reflections/:id — single reflection report + * POST /reflections — create/store a reflection report + * + * Container: reflections (partition key: /userId) + */ + +import type { FastifyInstance } from 'fastify'; +import { randomUUID } from 'node:crypto'; +import { getRequestProductId } from '../../lib/request-context.js'; +import { BadRequestError, NotFoundError } from '../../lib/errors.js'; +import { extractAuth } from '../../lib/auth.js'; +import * as repo from './repository.js'; +import { CreateReflectionSchema, ListReflectionsQuerySchema, type ReflectionDoc } from './types.js'; + +export async function reflectionRoutes(app: FastifyInstance) { + // List reflections + app.get('/reflections', async req => { + const auth = await extractAuth(req); + const parsed = ListReflectionsQuerySchema.safeParse(req.query); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + const pid = getRequestProductId(req); + const { items, total } = await repo.list(auth.sub, pid, parsed.data.limit, parsed.data.offset); + return { items, total, limit: parsed.data.limit, offset: parsed.data.offset }; + }); + + // Get single reflection + app.get('/reflections/:id', async req => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + const pid = getRequestProductId(req); + const reflection = await repo.getById(id, auth.sub); + if (!reflection || reflection.productId !== pid) + throw new NotFoundError('Reflection not found'); + return reflection; + }); + + // Create reflection + app.post('/reflections', async (req, reply) => { + const auth = await extractAuth(req); + const parsed = CreateReflectionSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + const pid = getRequestProductId(req); + const now = new Date().toISOString(); + const doc: ReflectionDoc = { + id: `reflect_${Date.now()}_${randomUUID()}`, + userId: auth.sub, + productId: pid, + ...parsed.data, + createdAt: now, + }; + + req.log.info({ reflectionId: doc.id, week: doc.weekStartDate }, 'Creating reflection'); + const created = await repo.create(doc); + reply.code(201); + return created; + }); +} diff --git a/services/platform-service/src/modules/reflections/types.ts b/services/platform-service/src/modules/reflections/types.ts new file mode 100644 index 00000000..a71a2d80 --- /dev/null +++ b/services/platform-service/src/modules/reflections/types.ts @@ -0,0 +1,59 @@ +/** + * Reflection types — MindLyst weekly reflection reports. + * + * Cosmos container: `reflections` (partition key: `/userId`) + * Product ID: per-request (typically "mindlyst") + */ + +import { z } from 'zod'; + +export interface ReflectionDoc { + id: string; + userId: string; + productId: string; + weekStartDate: string; + weekEndDate: string; + repeatedThemes: string[]; + postponedItems: string[]; + roleImbalanceSignals: string[]; + suggestedAdjustments: string[]; + totalCaptured: number; + totalCompleted: number; + brainBreakdown: Record; + vsLastWeek: { + capturedDelta: number; + completedDelta: number; + completionRateDelta: number; + summary: string; + } | null; + createdAt: string; +} + +export const CreateReflectionSchema = z.object({ + weekStartDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Must be YYYY-MM-DD'), + weekEndDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Must be YYYY-MM-DD'), + repeatedThemes: z.array(z.string().max(500)).default([]), + postponedItems: z.array(z.string().max(500)).default([]), + roleImbalanceSignals: z.array(z.string().max(500)).default([]), + suggestedAdjustments: z.array(z.string().max(500)).default([]), + totalCaptured: z.number().int().min(0).default(0), + totalCompleted: z.number().int().min(0).default(0), + brainBreakdown: z.record(z.string(), z.number().int().min(0)).default({}), + vsLastWeek: z + .object({ + capturedDelta: z.number().int(), + completedDelta: z.number().int(), + completionRateDelta: z.number().int(), + summary: z.string().max(500), + }) + .nullable() + .default(null), +}); + +export const ListReflectionsQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(50).default(10), + offset: z.coerce.number().int().min(0).default(0), +}); + +export type CreateReflectionInput = z.infer; +export type ListReflectionsQuery = z.infer; diff --git a/services/platform-service/src/modules/streaks/repository.ts b/services/platform-service/src/modules/streaks/repository.ts new file mode 100644 index 00000000..b669dddd --- /dev/null +++ b/services/platform-service/src/modules/streaks/repository.ts @@ -0,0 +1,35 @@ +/** + * Streaks repository — Cosmos DB CRUD. + * + * Container: streaks (partition key: /userId) + */ + +import { getContainer } from '../../lib/cosmos.js'; +import type { StreakDoc } from './types.js'; + +function container() { + return getContainer('streaks'); +} + +export async function getByUser(userId: string, productId: string): Promise { + const { resources } = await container() + .items.query({ + query: 'SELECT * FROM c WHERE c.userId = @userId AND c.productId = @productId', + parameters: [ + { name: '@userId', value: userId }, + { name: '@productId', value: productId }, + ], + }) + .fetchAll(); + return resources[0] ?? null; +} + +export async function create(doc: StreakDoc): Promise { + const { resource } = await container().items.create(doc); + return resource as StreakDoc; +} + +export async function replace(doc: StreakDoc): Promise { + const { resource } = await container().item(doc.id, doc.userId).replace(doc); + return resource as StreakDoc; +} diff --git a/services/platform-service/src/modules/streaks/routes.ts b/services/platform-service/src/modules/streaks/routes.ts new file mode 100644 index 00000000..c3702b0e --- /dev/null +++ b/services/platform-service/src/modules/streaks/routes.ts @@ -0,0 +1,129 @@ +/** + * Streak REST endpoints — MindLyst usage streak tracking. + * + * GET /streaks — get current streak state + * GET /streaks/milestone — check if current streak is a milestone + * POST /streaks/activity — record activity for today + * + * Container: streaks (partition key: /userId) + */ + +import type { FastifyInstance } from 'fastify'; +import { getRequestProductId } from '../../lib/request-context.js'; +import { BadRequestError } from '../../lib/errors.js'; +import { extractAuth } from '../../lib/auth.js'; +import * as repo from './repository.js'; +import { RecordActivitySchema, MILESTONES, type StreakDoc } from './types.js'; + +function todayString(): string { + return new Date().toISOString().slice(0, 10); +} + +function daysBetween(dateA: string, dateB: string): number { + const a = new Date(dateA).getTime(); + const b = new Date(dateB).getTime(); + return Math.round((b - a) / (24 * 3600 * 1000)); +} + +async function getOrCreate(userId: string, productId: string): Promise { + const existing = await repo.getByUser(userId, productId); + if (existing) return existing; + + const now = new Date().toISOString(); + const doc: StreakDoc = { + id: `streak_${userId}`, + userId, + productId, + currentStreak: 0, + longestStreak: 0, + lastActiveDate: todayString(), + streakFreezeAvailable: true, + totalActiveDays: 0, + createdAt: now, + updatedAt: now, + }; + return repo.create(doc); +} + +export async function streakRoutes(app: FastifyInstance) { + // Get current streak + app.get('/streaks', async req => { + const auth = await extractAuth(req); + const pid = getRequestProductId(req); + return getOrCreate(auth.sub, pid); + }); + + // Check milestone + app.get('/streaks/milestone', async req => { + const auth = await extractAuth(req); + const pid = getRequestProductId(req); + const current = await getOrCreate(auth.sub, pid); + const milestone = (MILESTONES as readonly number[]).includes(current.currentStreak) + ? current.currentStreak + : null; + return { milestone, currentStreak: current.currentStreak }; + }); + + // Record activity + app.post('/streaks/activity', async req => { + const auth = await extractAuth(req); + const parsed = RecordActivitySchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + const pid = getRequestProductId(req); + const current = await getOrCreate(auth.sub, pid); + const today = parsed.data.date || todayString(); + + if (current.lastActiveDate === today) { + return { ...current, message: 'Already recorded today' }; + } + + const gap = daysBetween(current.lastActiveDate, today); + let updated: StreakDoc; + + if (gap === 1) { + updated = { + ...current, + currentStreak: current.currentStreak + 1, + longestStreak: Math.max(current.longestStreak, current.currentStreak + 1), + lastActiveDate: today, + totalActiveDays: current.totalActiveDays + 1, + updatedAt: new Date().toISOString(), + }; + } else if (gap === 2 && current.streakFreezeAvailable) { + updated = { + ...current, + currentStreak: current.currentStreak + 1, + longestStreak: Math.max(current.longestStreak, current.currentStreak + 1), + lastActiveDate: today, + totalActiveDays: current.totalActiveDays + 1, + streakFreezeAvailable: false, + updatedAt: new Date().toISOString(), + }; + } else { + updated = { + ...current, + currentStreak: 1, + lastActiveDate: today, + totalActiveDays: current.totalActiveDays + 1, + streakFreezeAvailable: true, + updatedAt: new Date().toISOString(), + }; + } + + // Refresh freeze every 7 days + if (updated.currentStreak > 0 && updated.currentStreak % 7 === 0) { + updated = { ...updated, streakFreezeAvailable: true }; + } + + const saved = await repo.replace(updated); + const milestone = (MILESTONES as readonly number[]).includes(saved.currentStreak) + ? saved.currentStreak + : null; + + req.log.info({ streak: saved.currentStreak, milestone }, 'Streak activity recorded'); + return { ...saved, milestone }; + }); +} diff --git a/services/platform-service/src/modules/streaks/streaks.test.ts b/services/platform-service/src/modules/streaks/streaks.test.ts new file mode 100644 index 00000000..6d6853d8 --- /dev/null +++ b/services/platform-service/src/modules/streaks/streaks.test.ts @@ -0,0 +1,52 @@ +/** + * Tests for streak schemas and constants. + */ + +import { describe, it, expect } from 'vitest'; +import { RecordActivitySchema, MILESTONES } from './types.js'; + +describe('RecordActivitySchema', () => { + it('accepts empty body (defaults to today)', () => { + const result = RecordActivitySchema.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.date).toBeUndefined(); + } + }); + + it('accepts valid YYYY-MM-DD date', () => { + const result = RecordActivitySchema.safeParse({ date: '2026-02-28' }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.date).toBe('2026-02-28'); + } + }); + + it('rejects invalid date format', () => { + const result = RecordActivitySchema.safeParse({ date: '28/02/2026' }); + expect(result.success).toBe(false); + }); + + it('rejects partial date', () => { + const result = RecordActivitySchema.safeParse({ date: '2026-02' }); + expect(result.success).toBe(false); + }); +}); + +describe('MILESTONES', () => { + it('contains expected milestone days', () => { + expect(MILESTONES).toContain(3); + expect(MILESTONES).toContain(7); + expect(MILESTONES).toContain(14); + expect(MILESTONES).toContain(30); + expect(MILESTONES).toContain(60); + expect(MILESTONES).toContain(100); + expect(MILESTONES).toContain(365); + }); + + it('is sorted ascending', () => { + for (let i = 1; i < MILESTONES.length; i++) { + expect(MILESTONES[i]).toBeGreaterThan(MILESTONES[i - 1]); + } + }); +}); diff --git a/services/platform-service/src/modules/streaks/types.ts b/services/platform-service/src/modules/streaks/types.ts new file mode 100644 index 00000000..d462f0b7 --- /dev/null +++ b/services/platform-service/src/modules/streaks/types.ts @@ -0,0 +1,32 @@ +/** + * Streak types — MindLyst consecutive usage day tracking. + * + * Cosmos container: `streaks` (partition key: `/userId`) + * Product ID: per-request (typically "mindlyst") + */ + +import { z } from 'zod'; + +export interface StreakDoc { + id: string; + userId: string; + productId: string; + currentStreak: number; + longestStreak: number; + lastActiveDate: string; + streakFreezeAvailable: boolean; + totalActiveDays: number; + createdAt: string; + updatedAt: string; +} + +export const RecordActivitySchema = z.object({ + date: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/, 'date must be YYYY-MM-DD') + .optional(), +}); + +export const MILESTONES = [3, 7, 14, 30, 60, 100, 365] as const; + +export type RecordActivityInput = z.infer;