feat(platform-service): add settings module with device overrides

- Added settings module (types, repository, routes)
- Endpoints: GET/PUT /settings, GET/PUT/DELETE /settings/device/:deviceId
- Enforced userId from JWT and productId request scoping
- Added settings Cosmos container registration and route registration in server
- Added module tests for settings schemas and route export
- Verified: tsc --noEmit clean, 20 test files / 183 tests passing
This commit is contained in:
saravanakumardb1 2026-02-15 14:57:20 -08:00
parent 84681cbf75
commit 0c3c109bf1
6 changed files with 237 additions and 0 deletions

View File

@ -5,6 +5,7 @@ import { config } from './config.js';
const CONTAINER_DEFS: Record<string, ContainerConfig> = {
products: { partitionKeyPath: '/id' },
users: { partitionKeyPath: '/id' },
settings: { partitionKeyPath: '/userId' },
devices: { partitionKeyPath: '/userId' },
notification_prefs: { partitionKeyPath: '/userId' },
audit_log: { partitionKeyPath: '/category', defaultTtl: 90 * 86400 },

View File

@ -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<UserSettingsDoc | null> {
const id = getSettingsId(productId, userId);
try {
const { resource } = await container().item(id, userId).read<UserSettingsDoc>();
return resource ?? null;
} catch {
return null;
}
}
export async function upsert(doc: UserSettingsDoc): Promise<UserSettingsDoc> {
const { resource } = await container().items.upsert<UserSettingsDoc>(doc);
return resource!;
}

View File

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

View File

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

View File

@ -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<string, unknown>;
deviceOverrides: Record<string, Record<string, unknown>>;
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<typeof UpdateSettingsSchema>;
export type SetDeviceOverridesInput = z.infer<typeof SetDeviceOverridesSchema>;

View File

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