diff --git a/services/platform-service/src/lib/cosmos-init.ts b/services/platform-service/src/lib/cosmos-init.ts index d1f1bb5f..aad7d158 100644 --- a/services/platform-service/src/lib/cosmos-init.ts +++ b/services/platform-service/src/lib/cosmos-init.ts @@ -5,6 +5,7 @@ import { config } from './config.js'; const CONTAINER_DEFS: Record = { products: { partitionKeyPath: '/id' }, users: { partitionKeyPath: '/id' }, + settings: { partitionKeyPath: '/userId' }, devices: { partitionKeyPath: '/userId' }, notification_prefs: { partitionKeyPath: '/userId' }, audit_log: { partitionKeyPath: '/category', defaultTtl: 90 * 86400 }, diff --git a/services/platform-service/src/modules/settings/repository.ts b/services/platform-service/src/modules/settings/repository.ts new file mode 100644 index 00000000..ea7ba268 --- /dev/null +++ b/services/platform-service/src/modules/settings/repository.ts @@ -0,0 +1,32 @@ +/** + * User settings repository — Cosmos DB CRUD for settings + device overrides. + */ + +import { getContainer } from '../../lib/cosmos.js'; +import type { UserSettingsDoc } from './types.js'; + +function container() { + return getContainer('settings'); +} + +export function getSettingsId(productId: string, userId: string): string { + return `set_${productId}_${userId}`; +} + +export async function getByUserId( + userId: string, + productId: string +): Promise { + const id = getSettingsId(productId, userId); + try { + const { resource } = await container().item(id, userId).read(); + return resource ?? null; + } catch { + return null; + } +} + +export async function upsert(doc: UserSettingsDoc): Promise { + const { resource } = await container().items.upsert(doc); + return resource!; +} diff --git a/services/platform-service/src/modules/settings/routes.ts b/services/platform-service/src/modules/settings/routes.ts new file mode 100644 index 00000000..afb4f5fb --- /dev/null +++ b/services/platform-service/src/modules/settings/routes.ts @@ -0,0 +1,126 @@ +/** + * User settings REST endpoints. + * + * GET /settings — get user settings + * PUT /settings — merge global settings + * GET /settings/device/:deviceId — get resolved settings for a device + * PUT /settings/device/:deviceId — set per-device overrides + * DELETE /settings/device/:deviceId — clear per-device overrides + */ + +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import { getRequestProductId } from '../../lib/request-context.js'; +import { BadRequestError, UnauthorizedError } from '../../lib/errors.js'; +import * as repo from './repository.js'; +import { UpdateSettingsSchema, SetDeviceOverridesSchema, type UserSettingsDoc } from './types.js'; + +function getRequestUserId(req: FastifyRequest): string { + const userId = req.jwtPayload?.sub; + if (!userId) throw new UnauthorizedError('Authentication required'); + return userId; +} + +function buildDefaultDoc(productId: string, userId: string): UserSettingsDoc { + const now = new Date().toISOString(); + return { + id: repo.getSettingsId(productId, userId), + productId, + userId, + settings: {}, + deviceOverrides: {}, + createdAt: now, + updatedAt: now, + }; +} + +export async function settingsRoutes(app: FastifyInstance) { + app.get('/settings', async req => { + const productId = getRequestProductId(req); + const userId = getRequestUserId(req); + + const existing = await repo.getByUserId(userId, productId); + return existing ?? buildDefaultDoc(productId, userId); + }); + + app.put('/settings', async req => { + const parsed = UpdateSettingsSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + const productId = getRequestProductId(req); + const userId = getRequestUserId(req); + const existing = await repo.getByUserId(userId, productId); + const now = new Date().toISOString(); + + const doc: UserSettingsDoc = { + ...(existing ?? buildDefaultDoc(productId, userId)), + settings: { ...(existing?.settings ?? {}), ...parsed.data.settings }, + updatedAt: now, + }; + + return repo.upsert(doc); + }); + + app.get('/settings/device/:deviceId', async req => { + const { deviceId } = req.params as { deviceId: string }; + const productId = getRequestProductId(req); + const userId = getRequestUserId(req); + + const existing = await repo.getByUserId(userId, productId); + const doc = existing ?? buildDefaultDoc(productId, userId); + const overrides = doc.deviceOverrides[deviceId] ?? {}; + + return { + deviceId, + settings: { ...doc.settings, ...overrides }, + hasOverrides: Object.keys(overrides).length > 0, + }; + }); + + app.put('/settings/device/:deviceId', async req => { + const { deviceId } = req.params as { deviceId: string }; + const parsed = SetDeviceOverridesSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + const productId = getRequestProductId(req); + const userId = getRequestUserId(req); + const existing = await repo.getByUserId(userId, productId); + const now = new Date().toISOString(); + + const doc: UserSettingsDoc = { + ...(existing ?? buildDefaultDoc(productId, userId)), + deviceOverrides: { + ...(existing?.deviceOverrides ?? {}), + [deviceId]: parsed.data.overrides, + }, + updatedAt: now, + }; + + await repo.upsert(doc); + return { deviceId, overrides: doc.deviceOverrides[deviceId] }; + }); + + app.delete('/settings/device/:deviceId', async req => { + const { deviceId } = req.params as { deviceId: string }; + const productId = getRequestProductId(req); + const userId = getRequestUserId(req); + const existing = await repo.getByUserId(userId, productId); + + if (!existing) return { success: true, deviceId }; + + const now = new Date().toISOString(); + const nextOverrides = { ...existing.deviceOverrides }; + delete nextOverrides[deviceId]; + + await repo.upsert({ + ...existing, + deviceOverrides: nextOverrides, + updatedAt: now, + }); + + return { success: true, deviceId }; + }); +} diff --git a/services/platform-service/src/modules/settings/settings.test.ts b/services/platform-service/src/modules/settings/settings.test.ts new file mode 100644 index 00000000..d23e74f6 --- /dev/null +++ b/services/platform-service/src/modules/settings/settings.test.ts @@ -0,0 +1,49 @@ +/** + * Unit tests for settings module — types + validation. + */ + +import { describe, it, expect } from 'vitest'; +import { SetDeviceOverridesSchema, UpdateSettingsSchema } from './types.js'; + +describe('UpdateSettingsSchema', () => { + it('accepts generic settings payload', () => { + const result = UpdateSettingsSchema.safeParse({ + settings: { + hotkey: 'fn', + language: 'en-US', + cleanup_enabled: true, + custom_vocabulary: ['alpha', 'beta'], + }, + }); + expect(result.success).toBe(true); + }); + + it('rejects missing settings object', () => { + const result = UpdateSettingsSchema.safeParse({}); + expect(result.success).toBe(false); + }); +}); + +describe('SetDeviceOverridesSchema', () => { + it('accepts per-device overrides payload', () => { + const result = SetDeviceOverridesSchema.safeParse({ + overrides: { + language: 'en-GB', + sound_enabled: false, + }, + }); + expect(result.success).toBe(true); + }); + + it('rejects missing overrides object', () => { + const result = SetDeviceOverridesSchema.safeParse({}); + expect(result.success).toBe(false); + }); +}); + +describe('settingsRoutes export', () => { + it('exports settingsRoutes function', async () => { + const mod = await import('./routes.js'); + expect(typeof mod.settingsRoutes).toBe('function'); + }); +}); diff --git a/services/platform-service/src/modules/settings/types.ts b/services/platform-service/src/modules/settings/types.ts new file mode 100644 index 00000000..410ee4dc --- /dev/null +++ b/services/platform-service/src/modules/settings/types.ts @@ -0,0 +1,26 @@ +/** + * User settings types — product-scoped settings with per-device overrides. + */ + +import { z } from 'zod'; + +export interface UserSettingsDoc { + id: string; + productId: string; + userId: string; + settings: Record; + deviceOverrides: Record>; + createdAt: string; + updatedAt: string; +} + +export const UpdateSettingsSchema = z.object({ + settings: z.record(z.unknown()), +}); + +export const SetDeviceOverridesSchema = z.object({ + overrides: z.record(z.unknown()), +}); + +export type UpdateSettingsInput = z.infer; +export type SetDeviceOverridesInput = z.infer; diff --git a/services/platform-service/src/server.ts b/services/platform-service/src/server.ts index 75daebe8..0264519c 100644 --- a/services/platform-service/src/server.ts +++ b/services/platform-service/src/server.ts @@ -37,6 +37,7 @@ import { usageRoutes } from './modules/usage/routes.js'; import { planRoutes } from './modules/plans/routes.js'; import { licenseRoutes } from './modules/licenses/routes.js'; import { stripeRoutes } from './modules/stripe/routes.js'; +import { settingsRoutes } from './modules/settings/routes.js'; import { itemRoutes } from './modules/items/routes.js'; import { commentRoutes } from './modules/comments/routes.js'; import { voteRoutes } from './modules/votes/routes.js'; @@ -98,6 +99,8 @@ await app.register(planRoutes, { prefix: '/api' }); await app.register(licenseRoutes, { prefix: '/api' }); // Stripe routes outside billing scope (webhook has its own signature verification) await app.register(stripeRoutes, { prefix: '/api' }); +// Settings module (user-level global settings + per-device overrides) +await app.register(settingsRoutes, { prefix: '/api' }); // Tracker modules (merged from tracker-service) await app.register(itemRoutes, { prefix: '/api' }); await app.register(commentRoutes, { prefix: '/api' });