From 17c41e8441ee63b3e64cc9c283ebad99b860c68b Mon Sep 17 00:00:00 2001 From: Saravana Dhandapani Date: Sun, 15 Feb 2026 03:20:09 -0800 Subject: [PATCH] feat(platform-service): add memory-items API backed by Cosmos --- .../MOBILE_WORKSTREAM_REMAINING.md | 6 +- .../platform-service/src/lib/cosmos-init.ts | 5 + .../src/modules/memory/memory.test.ts | 63 +++++++ .../src/modules/memory/repository.ts | 101 +++++++++++ .../src/modules/memory/routes.ts | 170 ++++++++++++++++++ .../src/modules/memory/types.ts | 90 ++++++++++ services/platform-service/src/server.ts | 3 + 7 files changed, 436 insertions(+), 2 deletions(-) create mode 100644 services/platform-service/src/modules/memory/memory.test.ts create mode 100644 services/platform-service/src/modules/memory/repository.ts create mode 100644 services/platform-service/src/modules/memory/routes.ts create mode 100644 services/platform-service/src/modules/memory/types.ts diff --git a/docs/workstreams/MOBILE_WORKSTREAM_REMAINING.md b/docs/workstreams/MOBILE_WORKSTREAM_REMAINING.md index 7f6b6a9a..f4fe90e8 100644 --- a/docs/workstreams/MOBILE_WORKSTREAM_REMAINING.md +++ b/docs/workstreams/MOBILE_WORKSTREAM_REMAINING.md @@ -85,7 +85,8 @@ Voice capture pipeline Text capture -- [ ] **P0** — Persist raw text to `memory_items` Cosmos container (currently in-memory) +- [x] **P0** — Backend: persist raw text to `memory_items` Cosmos container (`platform-service` `POST /api/memory-items`) +- [ ] **P0** — Mobile: call `platform-service` `POST /api/memory-items` during capture/triage flow Image/screenshot capture @@ -94,7 +95,8 @@ Image/screenshot capture Triage persistence -- [ ] **P0** — Store structured `MemoryItem` in Cosmos DB with all triage fields (currently in-memory) +- [x] **P0** — Backend: store structured `MemoryItem` in Cosmos DB with triage fields (`platform-service` `memory_items`) +- [ ] **P0** — Mobile: wire triage confirmation to send `triageResult` + `brainIds` to backend (`POST /api/memory-items`) Widgets diff --git a/services/platform-service/src/lib/cosmos-init.ts b/services/platform-service/src/lib/cosmos-init.ts index 65df1488..5322c0d8 100644 --- a/services/platform-service/src/lib/cosmos-init.ts +++ b/services/platform-service/src/lib/cosmos-init.ts @@ -8,6 +8,11 @@ const CONTAINER_DEFS: Record = { notification_prefs: { partitionKeyPath: '/userId' }, audit_log: { partitionKeyPath: '/category', defaultTtl: 90 * 86400 }, feature_flags: { partitionKeyPath: '/id' }, + // Mobile capture primitives (MindLyst-style). + memory_items: { partitionKeyPath: '/userId' }, + daily_briefs: { partitionKeyPath: '/userId' }, + reflections: { partitionKeyPath: '/userId' }, + brain_insights: { partitionKeyPath: '/userId' }, }; export async function initCosmosIfNeeded(): Promise { diff --git a/services/platform-service/src/modules/memory/memory.test.ts b/services/platform-service/src/modules/memory/memory.test.ts new file mode 100644 index 00000000..db189a15 --- /dev/null +++ b/services/platform-service/src/modules/memory/memory.test.ts @@ -0,0 +1,63 @@ +/** + * Tests for memory item schemas. + */ + +import { describe, it, expect } from 'vitest'; +import { + CreateMemoryItemSchema, + ListMemoryItemsQuerySchema, + PatchMemoryItemSchema, + ReassignMemoryItemSchema, +} from './types.js'; + +describe('ListMemoryItemsQuerySchema', () => { + it('accepts defaults', () => { + const result = ListMemoryItemsQuerySchema.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.limit).toBe(50); + expect(result.data.offset).toBe(0); + } + }); + + it('rejects huge limit', () => { + const result = ListMemoryItemsQuerySchema.safeParse({ limit: 9999 }); + expect(result.success).toBe(false); + }); +}); + +describe('CreateMemoryItemSchema', () => { + it('requires rawContent', () => { + const result = CreateMemoryItemSchema.safeParse({ sourceType: 'text' }); + expect(result.success).toBe(false); + }); + + it('accepts minimal valid payload', () => { + const result = CreateMemoryItemSchema.safeParse({ rawContent: 'hello world' }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.sourceType).toBe('text'); + expect(result.data.captureSurface).toBe('app'); + } + }); +}); + +describe('ReassignMemoryItemSchema', () => { + it('accepts newBrainId', () => { + const result = ReassignMemoryItemSchema.safeParse({ newBrainId: 'work' }); + expect(result.success).toBe(true); + }); +}); + +describe('PatchMemoryItemSchema', () => { + it('requires reminderAt for set_reminder at route level', () => { + const result = PatchMemoryItemSchema.safeParse({ action: 'set_reminder' }); + expect(result.success).toBe(true); + }); + + it('rejects invalid action', () => { + const result = PatchMemoryItemSchema.safeParse({ action: 'nope' }); + expect(result.success).toBe(false); + }); +}); + diff --git a/services/platform-service/src/modules/memory/repository.ts b/services/platform-service/src/modules/memory/repository.ts new file mode 100644 index 00000000..ceb9e633 --- /dev/null +++ b/services/platform-service/src/modules/memory/repository.ts @@ -0,0 +1,101 @@ +/** + * Memory items repository — Cosmos DB CRUD. + * + * Container: memory_items (partition: /userId) + */ + +import { getContainer } from '../../lib/cosmos.js'; +import type { MemoryItemDoc } from './types.js'; + +function container() { + return getContainer('memory_items'); +} + +export type ListMemoryItemsQuery = { + productId: string; + userId: string; + brainId?: string; + filter?: 'forgotten' | 'completed_today'; + limit: number; + offset: number; +}; + +export async function list(query: ListMemoryItemsQuery): Promise<{ items: MemoryItemDoc[] }> { + const conditions: string[] = ['c.userId = @userId', 'c.productId = @productId']; + const params: { name: string; value: string | number | boolean }[] = [ + { name: '@userId', value: query.userId }, + { name: '@productId', value: query.productId }, + ]; + + if (query.brainId) { + conditions.push('ARRAY_CONTAINS(c.brainIds, @brainId)'); + params.push({ name: '@brainId', value: query.brainId }); + } + + if (query.filter === 'forgotten') { + const before = new Date(Date.now() - 48 * 3600 * 1000).toISOString(); + conditions.push('c.actedOn = false'); + conditions.push('c.createdAt < @before'); + params.push({ name: '@before', value: before }); + } + + if (query.filter === 'completed_today') { + const todayPrefix = new Date().toISOString().slice(0, 10); + conditions.push('c.actedOn = true'); + conditions.push('STARTSWITH(c.actedOnAt, @todayPrefix)'); + params.push({ name: '@todayPrefix', value: todayPrefix }); + } + + const where = `WHERE ${conditions.join(' AND ')}`; + + const { resources } = await container() + .items.query( + { + query: `SELECT * FROM c ${where} ORDER BY c.createdAt DESC OFFSET @offset LIMIT @limit`, + parameters: [ + ...params, + { name: '@offset', value: query.offset }, + { name: '@limit', value: query.limit }, + ], + }, + { partitionKey: query.userId } + ) + .fetchAll(); + + return { items: resources ?? [] }; +} + +export async function getById( + id: string, + userId: string, + productId: string +): Promise { + try { + const { resource } = await container().item(id, userId).read(); + if (!resource) return null; + if (resource.productId !== productId) return null; + return resource; + } catch { + return null; + } +} + +export async function create(doc: MemoryItemDoc): Promise { + const { resource } = await container().items.create(doc); + return resource as MemoryItemDoc; +} + +export async function replace(doc: MemoryItemDoc): Promise { + const { resource } = await container().item(doc.id, doc.userId).replace(doc); + return resource as MemoryItemDoc; +} + +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/memory/routes.ts b/services/platform-service/src/modules/memory/routes.ts new file mode 100644 index 00000000..7522061d --- /dev/null +++ b/services/platform-service/src/modules/memory/routes.ts @@ -0,0 +1,170 @@ +/** + * Memory items REST endpoints. + * + * GET /memory-items — list (optional brainId/filter) + * POST /memory-items — create + * PUT /memory-items/:id/reassign — reassign to another brain + * PATCH /memory-items/:id — mark done/undone, increment nudge, set reminder + * DELETE /memory-items/:id — delete + * + * Container: memory_items (partition key: /userId) + */ + +import type { FastifyInstance } from 'fastify'; +import { randomUUID } from 'node:crypto'; +import { DEFAULT_PRODUCT_ID } from '../../lib/product-config.js'; +import { BadRequestError, NotFoundError } from '../../lib/errors.js'; +import { extractAuth } from '../../lib/auth.js'; +import * as repo from './repository.js'; +import { + CreateMemoryItemSchema, + ListMemoryItemsQuerySchema, + PatchMemoryItemSchema, + ReassignMemoryItemSchema, + type MemoryItemDoc, + type TriageResult, +} from './types.js'; + +function defaultTriage(rawContent: string): TriageResult { + return { + contentType: 'memory', + summary: rawContent.slice(0, 200), + urgencyScore: 0.3, + emotionScore: 0, + confidenceScore: 0.5, + suggestedBrainId: 'global', + entities: [], + suggestedActions: [], + }; +} + +export async function memoryRoutes(app: FastifyInstance) { + // List memory items + app.get('/memory-items', async req => { + const auth = await extractAuth(req); + const parsed = ListMemoryItemsQuerySchema.safeParse(req.query); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + const q = parsed.data; + const pid = q.productId || DEFAULT_PRODUCT_ID; + + const { items } = await repo.list({ + productId: pid, + userId: auth.sub, + brainId: q.brainId, + filter: q.filter, + limit: q.limit, + offset: q.offset, + }); + + return { items, limit: q.limit, offset: q.offset }; + }); + + // Create memory item + app.post('/memory-items', async (req, reply) => { + const auth = await extractAuth(req); + const parsed = CreateMemoryItemSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + const input = parsed.data; + const pid = input.productId || DEFAULT_PRODUCT_ID; + const now = new Date().toISOString(); + const triage = input.triageResult ?? defaultTriage(input.rawContent); + const brainIds = input.brainIds && input.brainIds.length > 0 ? input.brainIds : [triage.suggestedBrainId]; + + const doc: MemoryItemDoc = { + id: `mem_${Date.now()}_${randomUUID()}`, + productId: pid, + userId: auth.sub, + sourceType: input.sourceType, + captureSurface: input.captureSurface, + rawContent: input.rawContent, + triageResult: triage, + brainIds, + ...(input.reminderAt && { reminderAt: input.reminderAt }), + actedOn: false, + actedOnAt: null, + nudgeCount: 0, + userCorrection: null, + isSensitive: input.isSensitive ?? false, + createdAt: now, + updatedAt: now, + }; + + const created = await repo.create(doc); + reply.code(201); + return created; + }); + + // Reassign + app.put('/memory-items/:id/reassign', async req => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + const parsed = ReassignMemoryItemSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + const pid = (req.query as { productId?: string }).productId || DEFAULT_PRODUCT_ID; + const item = await repo.getById(id, auth.sub, pid); + if (!item) throw new NotFoundError('Memory item not found'); + + const updated: MemoryItemDoc = { + ...item, + brainIds: [parsed.data.newBrainId], + userCorrection: parsed.data.newBrainId, + updatedAt: new Date().toISOString(), + }; + return repo.replace(updated); + }); + + // Patch actions + app.patch('/memory-items/:id', async req => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + const parsed = PatchMemoryItemSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + const pid = (req.query as { productId?: string }).productId || DEFAULT_PRODUCT_ID; + const item = await repo.getById(id, auth.sub, pid); + if (!item) throw new NotFoundError('Memory item not found'); + + const action = parsed.data.action; + const now = new Date().toISOString(); + + let updated: MemoryItemDoc = { ...item, updatedAt: now }; + if (action === 'mark_done') { + updated = { ...updated, actedOn: true, actedOnAt: now }; + } else if (action === 'mark_undone') { + updated = { ...updated, actedOn: false, actedOnAt: null }; + } else if (action === 'increment_nudge') { + updated = { ...updated, nudgeCount: item.nudgeCount + 1 }; + } else if (action === 'set_reminder') { + if (!parsed.data.reminderAt) { + throw new BadRequestError('reminderAt is required for set_reminder'); + } + updated = { ...updated, reminderAt: parsed.data.reminderAt }; + } + + return repo.replace(updated); + }); + + // Delete + app.delete('/memory-items/:id', async req => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + const pid = (req.query as { productId?: string }).productId || DEFAULT_PRODUCT_ID; + + const item = await repo.getById(id, auth.sub, pid); + if (!item) throw new NotFoundError('Memory item not found'); + + await repo.remove(id, auth.sub); + return { success: true }; + }); +} diff --git a/services/platform-service/src/modules/memory/types.ts b/services/platform-service/src/modules/memory/types.ts new file mode 100644 index 00000000..4696036e --- /dev/null +++ b/services/platform-service/src/modules/memory/types.ts @@ -0,0 +1,90 @@ +/** + * MindLyst-style "memory items" — persisted capture + triage results. + * + * Container: memory_items (partition key: /userId) + * + * This is intentionally product-agnostic: every doc includes productId. + */ + +import { z } from 'zod'; + +export const SourceTypeSchema = z.enum(['voice', 'image', 'link', 'email', 'text']); + +export const CaptureSurfaceSchema = z.enum([ + 'app', + 'widget', + 'share_sheet', + 'siri', + 'email', + 'web', + 'notification_reply', +]); + +export const ContentTypeSchema = z.enum(['task', 'reminder', 'memory', 'idea', 'risk', 'reference']); + +export const TriageResultSchema = z.object({ + contentType: ContentTypeSchema, + summary: z.string().min(1).max(2000), + urgencyScore: z.number().min(0).max(1), + emotionScore: z.number().min(-1).max(1), + confidenceScore: z.number().min(0).max(1), + suggestedBrainId: z.string().min(1).max(128), + entities: z.array(z.string().min(1).max(128)).default([]), + suggestedActions: z.array(z.string().min(1).max(256)).default([]), +}); + +export const ListMemoryItemsQuerySchema = z.object({ + productId: z.string().min(1).max(64).optional(), + brainId: z.string().min(1).max(128).optional(), + filter: z.enum(['forgotten', 'completed_today']).optional(), + limit: z.coerce.number().int().min(1).max(200).default(50), + offset: z.coerce.number().int().min(0).max(50_000).default(0), +}); + +export const CreateMemoryItemSchema = z.object({ + productId: z.string().min(1).max(64).optional(), + sourceType: SourceTypeSchema.default('text'), + captureSurface: CaptureSurfaceSchema.default('app'), + rawContent: z.string().min(1).max(50_000), + triageResult: TriageResultSchema.optional(), + brainIds: z.array(z.string().min(1).max(128)).optional(), + isSensitive: z.boolean().optional(), + reminderAt: z + .string() + .refine(v => !Number.isNaN(Date.parse(v)), 'reminderAt must be an ISO date string') + .optional(), +}); + +export const ReassignMemoryItemSchema = z.object({ + newBrainId: z.string().min(1).max(128), +}); + +export const PatchMemoryItemSchema = z.object({ + action: z.enum(['mark_done', 'mark_undone', 'increment_nudge', 'set_reminder']), + reminderAt: z + .string() + .refine(v => !Number.isNaN(Date.parse(v)), 'reminderAt must be an ISO date string') + .optional(), +}); + +export type TriageResult = z.infer; + +export type MemoryItemDoc = { + id: string; + productId: string; + userId: string; + sourceType: z.infer; + captureSurface: z.infer; + rawContent: string; + triageResult: TriageResult; + brainIds: string[]; + reminderAt?: string; + actedOn: boolean; + actedOnAt: string | null; + nudgeCount: number; + userCorrection: string | null; + isSensitive: boolean; + createdAt: string; + updatedAt: string; +}; + diff --git a/services/platform-service/src/server.ts b/services/platform-service/src/server.ts index 7bc72cf1..2fe7113a 100644 --- a/services/platform-service/src/server.ts +++ b/services/platform-service/src/server.ts @@ -39,6 +39,7 @@ import { stripeRoutes } from './modules/stripe/routes.js'; import { itemRoutes } from './modules/items/routes.js'; import { commentRoutes } from './modules/comments/routes.js'; import { voteRoutes } from './modules/votes/routes.js'; +import { memoryRoutes } from './modules/memory/routes.js'; import { publicRoutes } from './modules/public/routes.js'; import { initCosmosIfNeeded } from './lib/cosmos-init.js'; import { config } from './lib/config.js'; @@ -97,6 +98,8 @@ await app.register(stripeRoutes, { prefix: '/api' }); await app.register(itemRoutes, { prefix: '/api' }); await app.register(commentRoutes, { prefix: '/api' }); await app.register(voteRoutes, { prefix: '/api' }); +// Mobile capture modules +await app.register(memoryRoutes, { prefix: '/api' }); // Public routes — no auth, registered at top level await app.register(publicRoutes, { prefix: '/api' });