From 4825aef262ba265d3a78772d57f455dee5a28fa1 Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 3 Apr 2026 16:06:11 +0200 Subject: [PATCH] feat(mana-auth): add /api/v1/settings endpoint for user settings sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unified web app calls auth.mana.how/api/v1/settings to sync theme, nav, locale, and device settings — but the endpoint was missing, causing 404 errors in production. Implements all 7 CRUD routes against the existing auth.user_settings table. Co-Authored-By: Claude Opus 4.6 (1M context) --- services/mana-auth/src/index.ts | 7 + services/mana-auth/src/routes/settings.ts | 218 ++++++++++++++++++++++ 2 files changed, 225 insertions(+) create mode 100644 services/mana-auth/src/routes/settings.ts diff --git a/services/mana-auth/src/index.ts b/services/mana-auth/src/index.ts index e9bbf8cd9..c9389df96 100644 --- a/services/mana-auth/src/index.ts +++ b/services/mana-auth/src/index.ts @@ -21,6 +21,7 @@ import { createAuthRoutes } from './routes/auth'; import { createGuildRoutes } from './routes/guilds'; import { createApiKeyRoutes, createApiKeyValidationRoute } from './routes/api-keys'; import { createMeRoutes } from './routes/me'; +import { createSettingsRoutes } from './routes/settings'; import { createAdminRoutes } from './routes/admin'; // ─── Bootstrap ────────────────────────────────────────────── @@ -82,6 +83,12 @@ app.route('/api/v1/api-keys', createApiKeyValidationRoute(apiKeysService)); app.use('/api/v1/me/*', jwtAuth(config.baseUrl)); app.route('/api/v1/me', createMeRoutes(userDataService)); +// ─── Settings ────────────────────────────────────────────── + +app.use('/api/v1/settings/*', jwtAuth(config.baseUrl)); +app.use('/api/v1/settings', jwtAuth(config.baseUrl)); +app.route('/api/v1/settings', createSettingsRoutes(db)); + // ─── Admin ────────────────────────────────────────────────── app.use('/api/v1/admin/*', jwtAuth(config.baseUrl)); diff --git a/services/mana-auth/src/routes/settings.ts b/services/mana-auth/src/routes/settings.ts new file mode 100644 index 000000000..fbf155288 --- /dev/null +++ b/services/mana-auth/src/routes/settings.ts @@ -0,0 +1,218 @@ +/** + * Settings routes — User settings CRUD (synced across all apps) + * + * GET / — Get all settings (global + app overrides + device settings) + * PATCH /global — Update global settings (deep merge) + * PATCH /app/:appId — Update app-specific override + * DELETE /app/:appId — Remove app override + * PATCH /device/:deviceId/:appId — Update device-specific app settings + * GET /devices — List all devices + * DELETE /device/:deviceId — Remove a device + */ + +import { Hono } from 'hono'; +import { eq } from 'drizzle-orm'; +import type { AuthUser } from '../middleware/jwt-auth'; +import type { Database } from '../db/connection'; +import { userSettings } from '../db/schema/auth'; + +type SettingsApp = Hono<{ Variables: { user: AuthUser } }>; + +/** + * Deep merge two objects (1 level of nesting for settings) + */ +function deepMerge( + target: Record, + source: Record +): Record { + const result = { ...target }; + for (const key of Object.keys(source)) { + if ( + source[key] !== null && + typeof source[key] === 'object' && + !Array.isArray(source[key]) && + typeof result[key] === 'object' && + result[key] !== null && + !Array.isArray(result[key]) + ) { + result[key] = deepMerge( + result[key] as Record, + source[key] as Record + ); + } else { + result[key] = source[key]; + } + } + return result; +} + +/** + * Get or create user settings row + */ +async function getOrCreateSettings(db: Database, userId: string) { + const [existing] = await db + .select() + .from(userSettings) + .where(eq(userSettings.userId, userId)) + .limit(1); + if (existing) return existing; + + const [created] = await db.insert(userSettings).values({ userId }).returning(); + return created; +} + +/** + * Return the standard response shape + */ +function settingsResponse(row: typeof userSettings.$inferSelect) { + return { + success: true, + globalSettings: row.globalSettings, + appOverrides: row.appOverrides, + deviceSettings: row.deviceSettings, + }; +} + +export function createSettingsRoutes(db: Database) { + const app: SettingsApp = new Hono(); + + // ─── GET / — Fetch all settings ──────────────────────────── + app.get('/', async (c) => { + const user = c.get('user'); + const row = await getOrCreateSettings(db, user.userId); + return c.json(settingsResponse(row)); + }); + + // ─── PATCH /global — Update global settings (deep merge) ─── + app.patch('/global', async (c) => { + const user = c.get('user'); + const body = await c.req.json(); + const row = await getOrCreateSettings(db, user.userId); + + const merged = deepMerge( + row.globalSettings as Record, + body as Record + ); + + const [updated] = await db + .update(userSettings) + .set({ globalSettings: merged, updatedAt: new Date() }) + .where(eq(userSettings.userId, user.userId)) + .returning(); + + return c.json(settingsResponse(updated)); + }); + + // ─── PATCH /app/:appId — Update app override ─────────────── + app.patch('/app/:appId', async (c) => { + const user = c.get('user'); + const appId = c.req.param('appId'); + const body = await c.req.json(); + const row = await getOrCreateSettings(db, user.userId); + + const overrides = (row.appOverrides as Record) || {}; + const existing = (overrides[appId] as Record) || {}; + overrides[appId] = deepMerge(existing, body as Record); + + const [updated] = await db + .update(userSettings) + .set({ appOverrides: overrides, updatedAt: new Date() }) + .where(eq(userSettings.userId, user.userId)) + .returning(); + + return c.json(settingsResponse(updated)); + }); + + // ─── DELETE /app/:appId — Remove app override ────────────── + app.delete('/app/:appId', async (c) => { + const user = c.get('user'); + const appId = c.req.param('appId'); + const row = await getOrCreateSettings(db, user.userId); + + const overrides = (row.appOverrides as Record) || {}; + delete overrides[appId]; + + const [updated] = await db + .update(userSettings) + .set({ appOverrides: overrides, updatedAt: new Date() }) + .where(eq(userSettings.userId, user.userId)) + .returning(); + + return c.json(settingsResponse(updated)); + }); + + // ─── PATCH /device/:deviceId/:appId — Update device app settings ── + app.patch('/device/:deviceId/:appId', async (c) => { + const user = c.get('user'); + const { deviceId, appId } = c.req.param(); + const body = await c.req.json<{ + deviceName?: string; + deviceType?: string; + settings?: Record; + }>(); + const row = await getOrCreateSettings(db, user.userId); + + const devices = (row.deviceSettings as Record>) || {}; + const device = devices[deviceId] || { + deviceName: body.deviceName || 'Unknown', + deviceType: body.deviceType || 'desktop', + lastSeen: new Date().toISOString(), + apps: {}, + }; + + device.lastSeen = new Date().toISOString(); + if (body.deviceName) device.deviceName = body.deviceName; + if (body.deviceType) device.deviceType = body.deviceType; + + const apps = (device.apps as Record) || {}; + const existingApp = (apps[appId] as Record) || {}; + apps[appId] = { ...existingApp, ...(body.settings || {}) }; + device.apps = apps; + devices[deviceId] = device; + + const [updated] = await db + .update(userSettings) + .set({ deviceSettings: devices, updatedAt: new Date() }) + .where(eq(userSettings.userId, user.userId)) + .returning(); + + return c.json(settingsResponse(updated)); + }); + + // ─── GET /devices — List all devices ─────────────────────── + app.get('/devices', async (c) => { + const user = c.get('user'); + const row = await getOrCreateSettings(db, user.userId); + const devices = (row.deviceSettings as Record>) || {}; + + const deviceList = Object.entries(devices).map(([id, d]) => ({ + deviceId: id, + deviceName: d.deviceName || 'Unknown', + deviceType: d.deviceType || 'desktop', + lastSeen: d.lastSeen || null, + appCount: Object.keys((d.apps as Record) || {}).length, + })); + + return c.json({ success: true, devices: deviceList }); + }); + + // ─── DELETE /device/:deviceId — Remove a device ──────────── + app.delete('/device/:deviceId', async (c) => { + const user = c.get('user'); + const deviceId = c.req.param('deviceId'); + const row = await getOrCreateSettings(db, user.userId); + + const devices = (row.deviceSettings as Record) || {}; + delete devices[deviceId]; + + const [updated] = await db + .update(userSettings) + .set({ deviceSettings: devices, updatedAt: new Date() }) + .where(eq(userSettings.userId, user.userId)) + .returning(); + + return c.json(settingsResponse(updated)); + }); + + return app; +}