feat(platform-service): add brains, daily-briefs, reflections, streaks modules

This commit is contained in:
saravanakumardb1 2026-02-28 20:24:06 -08:00
parent 4863b62055
commit 33160a5daa
16 changed files with 1121 additions and 0 deletions

View File

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

View File

@ -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<number>({
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<BrainDoc>({
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<BrainDoc | null> {
try {
const { resource } = await container().item(id, userId).read<BrainDoc>();
return resource ?? null;
} catch {
return null;
}
}
export async function create(doc: BrainDoc): Promise<BrainDoc> {
const { resource } = await container().items.create(doc);
return resource as BrainDoc;
}
export async function replace(doc: BrainDoc): Promise<BrainDoc> {
const { resource } = await container().item(doc.id, doc.userId).replace(doc);
return resource as BrainDoc;
}
export async function remove(id: string, userId: string): Promise<boolean> {
try {
await container().item(id, userId).delete();
return true;
} catch {
return false;
}
}

View File

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

View File

@ -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<typeof CreateBrainSchema>;
export type UpdateBrainInput = z.infer<typeof UpdateBrainSchema>;
export type ListBrainsQuery = z.infer<typeof ListBrainsQuerySchema>;

View File

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

View File

@ -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<number>({
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<DailyBriefDoc>({
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<DailyBriefDoc | null> {
const { resources } = await container()
.items.query<DailyBriefDoc>({
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<DailyBriefDoc | null> {
try {
const { resource } = await container().item(id, userId).read<DailyBriefDoc>();
return resource ?? null;
} catch {
return null;
}
}
export async function create(doc: DailyBriefDoc): Promise<DailyBriefDoc> {
const { resource } = await container().items.create(doc);
return resource as DailyBriefDoc;
}
export async function replace(doc: DailyBriefDoc): Promise<DailyBriefDoc> {
const { resource } = await container().item(doc.id, doc.userId).replace(doc);
return resource as DailyBriefDoc;
}

View File

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

View File

@ -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<string, string>;
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<typeof CreateDailyBriefSchema>;
export type ListDailyBriefsQuery = z.infer<typeof ListDailyBriefsQuerySchema>;

View File

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

View File

@ -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<number>({
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<ReflectionDoc>({
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<ReflectionDoc | null> {
try {
const { resource } = await container().item(id, userId).read<ReflectionDoc>();
return resource ?? null;
} catch {
return null;
}
}
export async function create(doc: ReflectionDoc): Promise<ReflectionDoc> {
const { resource } = await container().items.create(doc);
return resource as ReflectionDoc;
}
export async function replace(doc: ReflectionDoc): Promise<ReflectionDoc> {
const { resource } = await container().item(doc.id, doc.userId).replace(doc);
return resource as ReflectionDoc;
}

View File

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

View File

@ -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<string, number>;
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<typeof CreateReflectionSchema>;
export type ListReflectionsQuery = z.infer<typeof ListReflectionsQuerySchema>;

View File

@ -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<StreakDoc | null> {
const { resources } = await container()
.items.query<StreakDoc>({
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<StreakDoc> {
const { resource } = await container().items.create(doc);
return resource as StreakDoc;
}
export async function replace(doc: StreakDoc): Promise<StreakDoc> {
const { resource } = await container().item(doc.id, doc.userId).replace(doc);
return resource as StreakDoc;
}

View File

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

View File

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

View File

@ -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<typeof RecordActivitySchema>;