openreader/src/app/api/user/state/preferences/route.ts
Richard R 358a56cc9a refactor: implement modular monorepo packages
- Extract database schema and migrations into @openreader/database

- Extract startup and orchestration scripts into @openreader/bootstrap

- Move compute-worker into packages/compute-worker

- Remove runtime dependency on drizzle-kit

- Update Dockerfile to deploy isolated packages without merging node_modules
2026-06-13 13:51:18 -06:00

200 lines
6.7 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { db } from '@openreader/database';
import { userPreferences } from '@openreader/database/schema';
import { type SyncedPreferencesPatch } from '@/types/user-state';
import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope';
import { coerceTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps';
import { listAdminProviders } from '@/lib/server/admin/providers';
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response';
import {
sanitizePreferencesPatch,
type PreferenceNormalizationContext,
} from '@/lib/server/user/preferences-normalize';
import {
deserializeUserPreferencesPayload,
extractUserPreferencesMeta,
withUserPreferencesMeta,
type UserPreferencesMeta,
} from '@/lib/server/user/preferences-payload';
export const dynamic = 'force-dynamic';
function serializePreferencesForDb(payload: Record<string, unknown>): Record<string, unknown> | string {
if (process.env.POSTGRES_URL) return payload;
return JSON.stringify(payload);
}
async function loadPreferenceNormalizationContext(): Promise<PreferenceNormalizationContext> {
const [runtimeConfig, providers] = await Promise.all([
getResolvedRuntimeConfig(),
listAdminProviders(),
]);
return {
showAllProviderModels: runtimeConfig.showAllProviderModels,
restrictUserApiKeys: runtimeConfig.restrictUserApiKeys,
sharedProviders: providers
.filter((entry) => entry.enabled)
.map((entry) => ({
slug: entry.slug,
providerType: entry.providerType,
defaultModel: entry.defaultModel,
defaultInstructions: entry.defaultInstructions,
})),
};
}
function parseStoredPreferences(
value: unknown,
context: PreferenceNormalizationContext,
): { patch: SyncedPreferencesPatch; migrated: boolean; meta: UserPreferencesMeta } {
const payload = deserializeUserPreferencesPayload(value);
const meta = extractUserPreferencesMeta(payload);
const sanitized = sanitizePreferencesPatch(payload, context, { fillMissingProvider: true });
return { ...sanitized, meta };
}
function normalizeClientUpdatedAtMs(value: unknown): number {
const normalized = coerceTimestampMs(value, nowTimestampMs());
if (normalized <= 0) return nowTimestampMs();
return normalized;
}
export async function GET(req: NextRequest) {
try {
const normalizationContext = await loadPreferenceNormalizationContext();
const scope = await resolveUserStateScope(req);
if (scope instanceof Response) return scope;
const rows = await db
.select({
dataJson: userPreferences.dataJson,
clientUpdatedAtMs: userPreferences.clientUpdatedAtMs,
})
.from(userPreferences)
.where(eq(userPreferences.userId, scope.ownerUserId))
.limit(1);
const row = rows[0];
const stored = parseStoredPreferences(row?.dataJson, normalizationContext);
const storedPatch = stored.patch;
const storedPayload = withUserPreferencesMeta(storedPatch, stored.meta);
const clientUpdatedAtMs = Number(row?.clientUpdatedAtMs ?? 0);
if (row && stored.migrated) {
const updatedAt = nowTimestampMs();
await db
.insert(userPreferences)
.values({
userId: scope.ownerUserId,
dataJson: serializePreferencesForDb(storedPayload),
clientUpdatedAtMs: clientUpdatedAtMs > 0 ? clientUpdatedAtMs : updatedAt,
updatedAt,
})
.onConflictDoUpdate({
target: [userPreferences.userId],
set: {
dataJson: serializePreferencesForDb(storedPayload),
updatedAt,
},
});
}
return NextResponse.json({
preferences: storedPatch,
clientUpdatedAtMs,
hasStoredPreferences: Boolean(row),
});
} catch (error) {
serverLogger.error({
event: 'user.preferences.load.failed',
error: errorToLog(error),
}, 'Failed to load user preferences');
return errorResponse(error, {
apiErrorMessage: 'Failed to load user preferences',
normalize: { code: 'USER_PREFERENCES_LOAD_FAILED', errorClass: 'db' },
});
}
}
export async function PUT(req: NextRequest) {
try {
const normalizationContext = await loadPreferenceNormalizationContext();
const scope = await resolveUserStateScope(req);
if (scope instanceof Response) return scope;
const body = (await req.json().catch(() => null)) as
| { patch?: unknown; clientUpdatedAtMs?: unknown }
| null;
const patch = sanitizePreferencesPatch(
body?.patch,
normalizationContext,
{ fillMissingProvider: false },
).patch;
const clientUpdatedAtMs = normalizeClientUpdatedAtMs(body?.clientUpdatedAtMs);
if (Object.keys(patch).length === 0) {
return NextResponse.json({ error: 'No valid preferences provided' }, { status: 400 });
}
const existingRows = await db
.select({
dataJson: userPreferences.dataJson,
clientUpdatedAtMs: userPreferences.clientUpdatedAtMs,
})
.from(userPreferences)
.where(eq(userPreferences.userId, scope.ownerUserId))
.limit(1);
const existing = existingRows[0];
const existingUpdated = Number(existing?.clientUpdatedAtMs ?? 0);
const existingStored = parseStoredPreferences(existing?.dataJson, normalizationContext);
const existingPatch = existingStored.patch;
if (existing && clientUpdatedAtMs < existingUpdated) {
return NextResponse.json({
preferences: existingPatch,
clientUpdatedAtMs: existingUpdated,
applied: false,
});
}
const mergedPatch = { ...existingPatch, ...patch };
const payloadWithMeta = withUserPreferencesMeta(mergedPatch, existingStored.meta);
const dataJson = serializePreferencesForDb(payloadWithMeta);
const updatedAt = nowTimestampMs();
await db
.insert(userPreferences)
.values({
userId: scope.ownerUserId,
dataJson,
clientUpdatedAtMs,
updatedAt,
})
.onConflictDoUpdate({
target: [userPreferences.userId],
set: {
dataJson,
clientUpdatedAtMs,
updatedAt,
},
});
return NextResponse.json({
preferences: mergedPatch,
clientUpdatedAtMs,
applied: true,
});
} catch (error) {
serverLogger.error({
event: 'user.preferences.update.failed',
error: errorToLog(error),
}, 'Failed to update user preferences');
return errorResponse(error, {
apiErrorMessage: 'Failed to update user preferences',
normalize: { code: 'USER_PREFERENCES_UPDATE_FAILED', errorClass: 'db' },
});
}
}