Initial import
This commit is contained in:
164
etc/servers/work-server/src/services/error-log-service.ts
Executable file
164
etc/servers/work-server/src/services/error-log-service.ts
Executable file
@@ -0,0 +1,164 @@
|
||||
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<typeof createErrorLogSchema>;
|
||||
|
||||
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<string, unknown>) {
|
||||
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<string, unknown>) : 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;
|
||||
}
|
||||
Reference in New Issue
Block a user