import { z } from 'zod'; import { db } from '../db/client.js'; export const ERROR_LOG_TABLE = 'error_logs'; export const ERROR_LOG_VIEW_TOKEN = 'usr_7f3a9c2d8e1b4a6f'; export const createErrorLogSchema = z.object({ source: z.enum(['server', 'client', 'automation']).default('server'), sourceLabel: z.string().trim().max(80).optional().nullable(), errorType: z.string().trim().min(1).max(120), errorName: z.string().trim().max(255).optional().nullable(), errorMessage: z.string().trim().min(1).max(10000), detail: z.string().trim().max(50000).optional().nullable(), stackTrace: z.string().trim().max(50000).optional().nullable(), statusCode: z.number().int().min(100).max(599).optional().nullable(), requestMethod: z.string().trim().max(10).optional().nullable(), requestPath: z.string().trim().max(1000).optional().nullable(), relatedPlanId: z.number().int().positive().optional().nullable(), relatedWorkId: z.string().trim().max(120).optional().nullable(), context: z.record(z.string(), z.unknown()).optional().nullable(), }); export type ErrorLogPayload = z.infer; async function ensureErrorLogTable() { const hasTable = await db.schema.hasTable(ERROR_LOG_TABLE); if (!hasTable) { await db.schema.createTable(ERROR_LOG_TABLE, (table) => { table.increments('id').primary(); table.string('source', 20).notNullable().defaultTo('server'); table.string('source_label', 80).nullable(); table.string('error_type', 120).notNullable(); table.string('error_name', 255).nullable(); table.text('error_message').notNullable(); table.text('detail').nullable(); table.text('stack_trace').nullable(); table.integer('status_code').nullable(); table.string('request_method', 10).nullable(); table.string('request_path', 1000).nullable(); table.integer('related_plan_id').nullable(); table.string('related_work_id', 120).nullable(); table.jsonb('context_json').nullable(); table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); }); return; } const requiredColumns: Array<[string, (table: any) => void]> = [ ['source', (table) => table.string('source', 20).notNullable().defaultTo('server')], ['source_label', (table) => table.string('source_label', 80).nullable()], ['error_type', (table) => table.string('error_type', 120).notNullable().defaultTo('unknown')], ['error_name', (table) => table.string('error_name', 255).nullable()], ['error_message', (table) => table.text('error_message').notNullable().defaultTo('')], ['detail', (table) => table.text('detail').nullable()], ['stack_trace', (table) => table.text('stack_trace').nullable()], ['status_code', (table) => table.integer('status_code').nullable()], ['request_method', (table) => table.string('request_method', 10).nullable()], ['request_path', (table) => table.string('request_path', 1000).nullable()], ['related_plan_id', (table) => table.integer('related_plan_id').nullable()], ['related_work_id', (table) => table.string('related_work_id', 120).nullable()], ['context_json', (table) => table.jsonb('context_json').nullable()], ['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], ]; for (const [columnName, createColumn] of requiredColumns) { const hasColumn = await db.schema.hasColumn(ERROR_LOG_TABLE, columnName); if (!hasColumn) { await db.schema.alterTable(ERROR_LOG_TABLE, (table) => { createColumn(table); }); } } } function normalizePayload(payload: ErrorLogPayload) { const parsedPayload = createErrorLogSchema.parse(payload); const sourceLabel = parsedPayload.sourceLabel ?? (parsedPayload.source === 'client' ? '프론트엔드' : parsedPayload.source === 'automation' ? 'Plan 자동화' : '워크서버 API'); return { source: parsedPayload.source, source_label: sourceLabel, error_type: parsedPayload.errorType, error_name: parsedPayload.errorName ?? null, error_message: parsedPayload.errorMessage, detail: parsedPayload.detail ?? null, stack_trace: parsedPayload.stackTrace ?? null, status_code: parsedPayload.statusCode ?? null, request_method: parsedPayload.requestMethod ?? null, request_path: parsedPayload.requestPath ?? null, related_plan_id: parsedPayload.relatedPlanId ?? null, related_work_id: parsedPayload.relatedWorkId ?? null, context_json: parsedPayload.context ?? null, }; } function mapErrorLogRow(row: Record) { return { id: Number(row.id), source: String(row.source ?? 'server'), sourceLabel: row.source_label ? String(row.source_label) : null, errorType: String(row.error_type ?? ''), errorName: row.error_name ? String(row.error_name) : null, errorMessage: String(row.error_message ?? ''), detail: row.detail ? String(row.detail) : null, stackTrace: row.stack_trace ? String(row.stack_trace) : null, statusCode: typeof row.status_code === 'number' ? row.status_code : row.status_code ? Number(row.status_code) : null, requestMethod: row.request_method ? String(row.request_method) : null, requestPath: row.request_path ? String(row.request_path) : null, relatedPlanId: typeof row.related_plan_id === 'number' ? row.related_plan_id : row.related_plan_id ? Number(row.related_plan_id) : null, relatedWorkId: row.related_work_id ? String(row.related_work_id) : null, context: row.context_json && typeof row.context_json === 'object' ? (row.context_json as Record) : null, createdAt: row.created_at, }; } export async function setupErrorLogTable() { await ensureErrorLogTable(); return { ok: true, table: ERROR_LOG_TABLE, }; } export async function createErrorLog(payload: ErrorLogPayload) { await ensureErrorLogTable(); const [row] = await db(ERROR_LOG_TABLE) .insert({ ...normalizePayload(payload), created_at: db.fn.now(), }) .returning('*'); return mapErrorLogRow(row); } export async function listErrorLogs(limit = 50) { await ensureErrorLogTable(); const safeLimit = Number.isFinite(limit) ? Math.min(Math.max(Math.trunc(limit), 1), 200) : 50; const rows = await db(ERROR_LOG_TABLE).select('*').orderBy('created_at', 'desc').limit(safeLimit); return rows.map((row) => mapErrorLogRow(row)); } export function hasErrorLogViewAccessToken(token: string | string[] | undefined) { const normalizedToken = Array.isArray(token) ? token[0] : token; return String(normalizedToken ?? '').trim() === ERROR_LOG_VIEW_TOKEN; }