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> = {
|
const CONTAINER_DEFS: Record<string, ContainerConfig> = {
|
||||||
products: { partitionKeyPath: '/id' },
|
products: { partitionKeyPath: '/id' },
|
||||||
users: { partitionKeyPath: '/id' },
|
users: { partitionKeyPath: '/id' },
|
||||||
|
settings: { partitionKeyPath: '/userId' },
|
||||||
devices: { partitionKeyPath: '/userId' },
|
devices: { partitionKeyPath: '/userId' },
|
||||||
notification_prefs: { partitionKeyPath: '/userId' },
|
notification_prefs: { partitionKeyPath: '/userId' },
|
||||||
audit_log: { partitionKeyPath: '/category', defaultTtl: 90 * 86400 },
|
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 { planRoutes } from './modules/plans/routes.js';
|
||||||
import { licenseRoutes } from './modules/licenses/routes.js';
|
import { licenseRoutes } from './modules/licenses/routes.js';
|
||||||
import { stripeRoutes } from './modules/stripe/routes.js';
|
import { stripeRoutes } from './modules/stripe/routes.js';
|
||||||
|
import { settingsRoutes } from './modules/settings/routes.js';
|
||||||
import { itemRoutes } from './modules/items/routes.js';
|
import { itemRoutes } from './modules/items/routes.js';
|
||||||
import { commentRoutes } from './modules/comments/routes.js';
|
import { commentRoutes } from './modules/comments/routes.js';
|
||||||
import { voteRoutes } from './modules/votes/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' });
|
await app.register(licenseRoutes, { prefix: '/api' });
|
||||||
// Stripe routes outside billing scope (webhook has its own signature verification)
|
// Stripe routes outside billing scope (webhook has its own signature verification)
|
||||||
await app.register(stripeRoutes, { prefix: '/api' });
|
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)
|
// Tracker modules (merged from tracker-service)
|
||||||
await app.register(itemRoutes, { prefix: '/api' });
|
await app.register(itemRoutes, { prefix: '/api' });
|
||||||
await app.register(commentRoutes, { prefix: '/api' });
|
await app.register(commentRoutes, { prefix: '/api' });
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user