feat(platform-service): add brains, daily-briefs, reflections, streaks modules
This commit is contained in:
parent
4863b62055
commit
33160a5daa
92
services/platform-service/src/modules/brains/brains.test.ts
Normal file
92
services/platform-service/src/modules/brains/brains.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
73
services/platform-service/src/modules/brains/repository.ts
Normal file
73
services/platform-service/src/modules/brains/repository.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
125
services/platform-service/src/modules/brains/routes.ts
Normal file
125
services/platform-service/src/modules/brains/routes.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
47
services/platform-service/src/modules/brains/types.ts
Normal file
47
services/platform-service/src/modules/brains/types.ts
Normal 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>;
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
76
services/platform-service/src/modules/daily-briefs/routes.ts
Normal file
76
services/platform-service/src/modules/daily-briefs/routes.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
38
services/platform-service/src/modules/daily-briefs/types.ts
Normal file
38
services/platform-service/src/modules/daily-briefs/types.ts
Normal 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>;
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
66
services/platform-service/src/modules/reflections/routes.ts
Normal file
66
services/platform-service/src/modules/reflections/routes.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
59
services/platform-service/src/modules/reflections/types.ts
Normal file
59
services/platform-service/src/modules/reflections/types.ts
Normal 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>;
|
||||
35
services/platform-service/src/modules/streaks/repository.ts
Normal file
35
services/platform-service/src/modules/streaks/repository.ts
Normal 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;
|
||||
}
|
||||
129
services/platform-service/src/modules/streaks/routes.ts
Normal file
129
services/platform-service/src/modules/streaks/routes.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
@ -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]);
|
||||
}
|
||||
});
|
||||
});
|
||||
32
services/platform-service/src/modules/streaks/types.ts
Normal file
32
services/platform-service/src/modules/streaks/types.ts
Normal 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>;
|
||||
Loading…
Reference in New Issue
Block a user