openreader/src/app/api/documents/[id]/settings/route.ts
Richard R 59d0101c0c feat(server): implement unified structured logging with pino and enforce usage
Introduce `serverLogger` utility based on pino for consistent, structured logging across all server and API modules. Replace direct console logging with `serverLogger` and add request-scoped logging helpers. Update environment variable handling, documentation, and deployment guides to reflect new logging configuration (`LOG_FORMAT`, `LOG_LEVEL`, `COMPUTE_LOG_LEVEL`). Enforce no-console in server code via ESLint and add pino/pino-pretty dependencies.

This change standardizes log output, improves observability, and prepares the codebase for ingestion by log platforms.
2026-05-27 04:20:17 -06:00

168 lines
5.7 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { and, eq, inArray } from 'drizzle-orm';
import { db } from '@/db';
import { documentSettings, documents } from '@/db/schema';
import { requireAuthContext } from '@/lib/server/auth/auth';
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
import { mergeDocumentSettings } from '@/lib/shared/document-settings';
import { DEFAULT_DOCUMENT_SETTINGS, type DocumentSettings } from '@/types/document-settings';
import { coerceTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps';
import { serverLogger } from '@/lib/server/logger';
export const dynamic = 'force-dynamic';
function serializeForDb(payload: Record<string, unknown>): Record<string, unknown> | string {
if (process.env.POSTGRES_URL) return payload;
return JSON.stringify(payload);
}
function normalizeClientUpdatedAtMs(value: unknown): number {
const normalized = coerceTimestampMs(value, nowTimestampMs());
if (normalized <= 0) return nowTimestampMs();
return normalized;
}
function parseStored(value: unknown): DocumentSettings {
if (typeof value === 'string') {
try {
return mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, JSON.parse(value));
} catch {
return mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, null);
}
}
return mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, value);
}
async function resolveDocumentAccess(req: NextRequest, documentId: string): Promise<
| { ownerUserId: string; allowedUserIds: string[] }
| Response
> {
const authCtxOrRes = await requireAuthContext(req);
if (authCtxOrRes instanceof Response) return authCtxOrRes;
const testNamespace = getOpenReaderTestNamespace(req.headers);
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
const storageUserId = authCtxOrRes.userId ?? unclaimedUserId;
const allowedUserIds = authCtxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId];
const rows = await db
.select({ userId: documents.userId })
.from(documents)
.where(and(eq(documents.id, documentId), inArray(documents.userId, allowedUserIds)))
.limit(1);
if (!rows[0]) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
return {
ownerUserId: rows[0].userId,
allowedUserIds,
};
}
export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
try {
const { id } = await ctx.params;
const documentId = (id || '').trim().toLowerCase();
if (!documentId) {
return NextResponse.json({ error: 'Invalid document id' }, { status: 400 });
}
const scope = await resolveDocumentAccess(req, documentId);
if (scope instanceof Response) return scope;
const rows = await db
.select({
dataJson: documentSettings.dataJson,
clientUpdatedAtMs: documentSettings.clientUpdatedAtMs,
})
.from(documentSettings)
.where(and(
eq(documentSettings.documentId, documentId),
eq(documentSettings.userId, scope.ownerUserId),
))
.limit(1);
const row = rows[0];
const settings = parseStored(row?.dataJson);
return NextResponse.json({
settings,
clientUpdatedAtMs: Number(row?.clientUpdatedAtMs ?? 0),
hasStoredSettings: Boolean(row),
});
} catch (error) {
serverLogger.error({ err: error }, 'Error loading document settings:');
return NextResponse.json({ error: 'Failed to load document settings' }, { status: 500 });
}
}
export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
try {
const { id } = await ctx.params;
const documentId = (id || '').trim().toLowerCase();
if (!documentId) {
return NextResponse.json({ error: 'Invalid document id' }, { status: 400 });
}
const scope = await resolveDocumentAccess(req, documentId);
if (scope instanceof Response) return scope;
const body = (await req.json().catch(() => null)) as { settings?: unknown; clientUpdatedAtMs?: unknown } | null;
const incoming = mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, body?.settings ?? null);
const clientUpdatedAtMs = normalizeClientUpdatedAtMs(body?.clientUpdatedAtMs);
const existingRows = await db
.select({
dataJson: documentSettings.dataJson,
clientUpdatedAtMs: documentSettings.clientUpdatedAtMs,
})
.from(documentSettings)
.where(and(
eq(documentSettings.documentId, documentId),
eq(documentSettings.userId, scope.ownerUserId),
))
.limit(1);
const existing = existingRows[0];
const existingUpdatedAt = Number(existing?.clientUpdatedAtMs ?? 0);
if (existing && clientUpdatedAtMs < existingUpdatedAt) {
return NextResponse.json({
settings: parseStored(existing.dataJson),
clientUpdatedAtMs: existingUpdatedAt,
applied: false,
});
}
const updatedAt = nowTimestampMs();
const payload = serializeForDb(incoming as unknown as Record<string, unknown>);
await db
.insert(documentSettings)
.values({
documentId,
userId: scope.ownerUserId,
dataJson: payload as never,
clientUpdatedAtMs,
updatedAt,
})
.onConflictDoUpdate({
target: [documentSettings.documentId, documentSettings.userId],
set: {
dataJson: payload as never,
clientUpdatedAtMs,
updatedAt,
},
});
return NextResponse.json({
settings: incoming,
clientUpdatedAtMs,
applied: true,
});
} catch (error) {
serverLogger.error({ err: error }, 'Error updating document settings:');
return NextResponse.json({ error: 'Failed to update document settings' }, { status: 500 });
}
}