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:
parent
84681cbf75
commit
0c3c109bf1
@ -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 },
|
||||
|
||||
32
services/platform-service/src/modules/settings/repository.ts
Normal file
32
services/platform-service/src/modules/settings/repository.ts
Normal 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!;
|
||||
}
|
||||
126
services/platform-service/src/modules/settings/routes.ts
Normal file
126
services/platform-service/src/modules/settings/routes.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
26
services/platform-service/src/modules/settings/types.ts
Normal file
26
services/platform-service/src/modules/settings/types.ts
Normal 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>;
|
||||
@ -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' });
|
||||
|
||||
Loading…
Reference in New Issue
Block a user