- 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
200 lines
6.7 KiB
TypeScript
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' },
|
|
});
|
|
}
|
|
}
|