Initial import
This commit is contained in:
220
etc/servers/work-server/src/routes/crud.ts
Executable file
220
etc/servers/work-server/src/routes/crud.ts
Executable file
@@ -0,0 +1,220 @@
|
||||
import type { Knex } from 'knex';
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { db } from '../db/client.js';
|
||||
import { assertIdentifier } from '../lib/identifier.js';
|
||||
|
||||
const filterSchema = z.object({
|
||||
field: z.string(),
|
||||
operator: z
|
||||
.enum(['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'like', 'in', 'null', 'notNull'])
|
||||
.default('eq'),
|
||||
value: z.any().optional(),
|
||||
});
|
||||
|
||||
const orderBySchema = z.object({
|
||||
field: z.string(),
|
||||
direction: z.enum(['asc', 'desc']).default('asc'),
|
||||
});
|
||||
|
||||
const selectSchema = z.object({
|
||||
columns: z.array(z.string()).optional(),
|
||||
where: z.array(filterSchema).optional(),
|
||||
orderBy: z.array(orderBySchema).optional(),
|
||||
limit: z.number().int().positive().max(500).optional(),
|
||||
offset: z.number().int().min(0).optional(),
|
||||
});
|
||||
|
||||
const insertSchema = z.object({
|
||||
data: z.record(z.string(), z.any()).or(z.array(z.record(z.string(), z.any()))),
|
||||
});
|
||||
|
||||
const updateSchema = z.object({
|
||||
data: z.record(z.string(), z.any()),
|
||||
where: z.array(filterSchema).default([]),
|
||||
});
|
||||
|
||||
const deleteSchema = z.object({
|
||||
where: z.array(filterSchema).default([]),
|
||||
});
|
||||
|
||||
const protectedBoardPostAutomationFields = new Set(['automation_plan_item_id', 'automation_received_at']);
|
||||
|
||||
function applyFilters(query: Knex.QueryBuilder, filters: z.infer<typeof filterSchema>[] = []) {
|
||||
filters.forEach((filter) => {
|
||||
const field = assertIdentifier(filter.field, 'field');
|
||||
|
||||
switch (filter.operator) {
|
||||
case 'eq':
|
||||
query.where(field, filter.value);
|
||||
break;
|
||||
case 'ne':
|
||||
query.whereNot(field, filter.value);
|
||||
break;
|
||||
case 'gt':
|
||||
query.where(field, '>', filter.value);
|
||||
break;
|
||||
case 'gte':
|
||||
query.where(field, '>=', filter.value);
|
||||
break;
|
||||
case 'lt':
|
||||
query.where(field, '<', filter.value);
|
||||
break;
|
||||
case 'lte':
|
||||
query.where(field, '<=', filter.value);
|
||||
break;
|
||||
case 'like':
|
||||
query.where(field, 'like', filter.value);
|
||||
break;
|
||||
case 'in':
|
||||
query.whereIn(field, Array.isArray(filter.value) ? filter.value : [filter.value]);
|
||||
break;
|
||||
case 'null':
|
||||
query.whereNull(field);
|
||||
break;
|
||||
case 'notNull':
|
||||
query.whereNotNull(field);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function registerCrudRoutes(app: FastifyInstance) {
|
||||
function getRequestTraceContext(request: FastifyRequest) {
|
||||
return {
|
||||
ip: request.ip,
|
||||
remoteAddress: request.raw.socket.remoteAddress,
|
||||
host: request.headers.host,
|
||||
origin: request.headers.origin,
|
||||
referer: request.headers.referer,
|
||||
userAgent: request.headers['user-agent'],
|
||||
clientId: request.headers['x-client-id'],
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeCrudUpdatePayload(table: string, payload: z.infer<typeof updateSchema>) {
|
||||
if (table !== 'board_posts') {
|
||||
return {
|
||||
dataKeys: Object.keys(payload.data),
|
||||
where: payload.where,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
dataKeys: Object.keys(payload.data),
|
||||
where: payload.where,
|
||||
automationPlanItemId: payload.data.automation_plan_item_id ?? null,
|
||||
automationReceivedAt: payload.data.automation_received_at ?? null,
|
||||
title: typeof payload.data.title === 'string' ? payload.data.title : undefined,
|
||||
contentLength: typeof payload.data.content === 'string' ? payload.data.content.length : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
app.post('/api/crud/:table/select', async (request) => {
|
||||
const table = assertIdentifier((request.params as { table: string }).table, 'table name');
|
||||
const payload = selectSchema.parse(request.body ?? {});
|
||||
const columns = payload.columns?.map((column) => assertIdentifier(column, 'column')) ?? ['*'];
|
||||
|
||||
const query = db(table).select(columns);
|
||||
applyFilters(query, payload.where);
|
||||
|
||||
payload.orderBy?.forEach((order) => {
|
||||
query.orderBy(assertIdentifier(order.field, 'order field'), order.direction);
|
||||
});
|
||||
|
||||
if (payload.limit) {
|
||||
query.limit(payload.limit);
|
||||
}
|
||||
|
||||
if (payload.offset) {
|
||||
query.offset(payload.offset);
|
||||
}
|
||||
|
||||
const rows = await query;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
table,
|
||||
count: rows.length,
|
||||
rows,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/crud/:table/insert', async (request) => {
|
||||
const table = assertIdentifier((request.params as { table: string }).table, 'table name');
|
||||
const payload = insertSchema.parse(request.body);
|
||||
const inserted = await db(table).insert(payload.data).returning('*');
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
table,
|
||||
rows: inserted,
|
||||
};
|
||||
});
|
||||
|
||||
app.patch('/api/crud/:table/update', async (request, reply) => {
|
||||
const table = assertIdentifier((request.params as { table: string }).table, 'table name');
|
||||
const payload = updateSchema.parse(request.body);
|
||||
const query = db(table);
|
||||
|
||||
applyFilters(query, payload.where);
|
||||
|
||||
if (table === 'board_posts') {
|
||||
request.log.warn(
|
||||
{
|
||||
table,
|
||||
payload: summarizeCrudUpdatePayload(table, payload),
|
||||
trace: getRequestTraceContext(request),
|
||||
},
|
||||
'Board post CRUD update requested',
|
||||
);
|
||||
|
||||
const protectedFields = Object.keys(payload.data).filter((field) => protectedBoardPostAutomationFields.has(field));
|
||||
|
||||
if (protectedFields.length) {
|
||||
request.log.warn(
|
||||
{
|
||||
table,
|
||||
protectedFields,
|
||||
payload: summarizeCrudUpdatePayload(table, payload),
|
||||
trace: getRequestTraceContext(request),
|
||||
},
|
||||
'Board post CRUD update blocked from changing automation link fields',
|
||||
);
|
||||
|
||||
return reply.code(409).send({
|
||||
message: '자동화 접수 연결 필드는 일반 CRUD 수정으로 변경할 수 없습니다.',
|
||||
protectedFields,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const rows = await query.update(payload.data).returning('*');
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
table,
|
||||
count: rows.length,
|
||||
rows,
|
||||
};
|
||||
});
|
||||
|
||||
app.delete('/api/crud/:table/delete', async (request) => {
|
||||
const table = assertIdentifier((request.params as { table: string }).table, 'table name');
|
||||
const payload = deleteSchema.parse(request.body ?? {});
|
||||
const query = db(table);
|
||||
|
||||
applyFilters(query, payload.where);
|
||||
|
||||
const rows = await query.delete().returning('*');
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
table,
|
||||
count: rows.length,
|
||||
rows,
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user