Initial import

This commit is contained in:
how2ice
2026-04-21 03:33:23 +09:00
commit 9e4b70f1f1
495 changed files with 94680 additions and 0 deletions

View File

@@ -0,0 +1,48 @@
import type { FastifyInstance } from 'fastify';
import { getAppConfig, upsertAppConfig } from '../services/app-config-service.js';
export async function registerAppConfigRoutes(app: FastifyInstance) {
app.get('/api/app-config', async () => {
const config = await getAppConfig();
return {
ok: true,
config: config ?? {},
};
});
app.put('/api/app-config', async (request, reply) => {
try {
let payload: unknown = request.body ?? {};
if (typeof payload === 'string') {
try {
payload = JSON.parse(payload);
} catch {
payload = {};
}
}
if (!payload || typeof payload !== 'object' || !('config' in payload)) {
throw new Error('저장할 설정 값이 비어 있습니다.');
}
const config = (payload as { config: unknown }).config;
if (!config || typeof config !== 'object') {
throw new Error('설정 값 형식이 올바르지 않습니다.');
}
const savedConfig = await upsertAppConfig(config as Record<string, unknown>);
return {
ok: true,
config: savedConfig,
};
} catch (error) {
return reply.code(409).send({
message: error instanceof Error ? error.message : '앱 설정 저장에 실패했습니다.',
});
}
});
}

View File

@@ -0,0 +1,172 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { hasErrorLogViewAccessToken } from '../services/error-log-service.js';
import {
BoardPostAutomationLockedError,
boardPostPayloadSchema,
createBoardPost,
deleteBoardPost,
ensureBoardPostsTable,
getBoardPost,
listBoardPosts,
receiveBoardPostAutomation,
updateBoardPost,
} from '../services/board-service.js';
export async function registerBoardRoutes(app: FastifyInstance) {
function isLoopbackAddress(value: string | null | undefined) {
const normalizedValue = String(value ?? '').trim();
return normalizedValue === '127.0.0.1' || normalizedValue === '::1' || normalizedValue === '::ffff:127.0.0.1';
}
function hasBoardAutomationAccess(request: { headers: Record<string, unknown>; ip?: string; socket?: { remoteAddress?: string | null }; raw?: { socket?: { remoteAddress?: string | null } } }) {
if (hasErrorLogViewAccessToken(request.headers['x-access-token'] as string | string[] | undefined)) {
return true;
}
return isLoopbackAddress(request.ip) || isLoopbackAddress(request.raw?.socket?.remoteAddress) || isLoopbackAddress(request.socket?.remoteAddress);
}
function requireBoardAutomationAccess(
request: { headers: Record<string, unknown>; ip?: string; socket?: { remoteAddress?: string | null }; raw?: { socket?: { remoteAddress?: string | null } } },
reply: Parameters<FastifyInstance['get']>[1] extends (request: any, reply: infer T) => any ? T : any,
) {
if (hasBoardAutomationAccess(request)) {
return true;
}
void reply.code(403).send({
message: '권한 토큰이 등록된 사용자만 자동화 접수를 할 수 있습니다.',
});
return false;
}
async function respondWithBoardSetup() {
await ensureBoardPostsTable();
return {
ok: true,
table: 'board_posts',
};
}
async function respondWithBoardPosts() {
const items = await listBoardPosts();
return {
ok: true,
items,
};
}
app.get('/api/board/setup', async () => respondWithBoardSetup());
app.post('/api/board/setup', async () => {
return respondWithBoardSetup();
});
app.get('/api/board/posts', async () => respondWithBoardPosts());
app.get('/api/board/items', async () => respondWithBoardPosts());
app.get('/api/board/posts/:id', async (request, reply) => {
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
const item = await getBoardPost(id);
if (!item) {
return reply.code(404).send({
message: '게시글을 찾을 수 없습니다.',
});
}
return {
ok: true,
item,
};
});
app.post('/api/board/posts', async (request) => {
const item = await createBoardPost(boardPostPayloadSchema.parse(request.body ?? {}));
return {
ok: true,
item,
};
});
app.post('/api/board/posts/:id/actions/automation-receive', async (request, reply) => {
if (!requireBoardAutomationAccess(request, reply)) {
return;
}
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
const result = await receiveBoardPostAutomation(id);
if (!result) {
return reply.code(404).send({
message: '자동화 접수할 게시글을 찾을 수 없습니다.',
});
}
return {
ok: true,
item: result.item,
planItemId: result.planItemId,
alreadyReceived: result.alreadyReceived,
};
});
app.patch('/api/board/posts/:id', async (request, reply) => {
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
let item;
try {
item = await updateBoardPost(id, boardPostPayloadSchema.parse(request.body ?? {}));
} catch (error) {
if (error instanceof BoardPostAutomationLockedError) {
return reply.code(409).send({
message: error.message,
});
}
throw error;
}
if (!item) {
return reply.code(404).send({
message: '수정할 게시글을 찾을 수 없습니다.',
});
}
return {
ok: true,
item,
};
});
app.delete('/api/board/posts/:id', async (request, reply) => {
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
let deleted;
try {
deleted = await deleteBoardPost(id);
} catch (error) {
if (error instanceof BoardPostAutomationLockedError) {
return reply.code(409).send({
message: error.message,
});
}
throw error;
}
if (!deleted) {
return reply.code(404).send({
message: '삭제할 게시글을 찾을 수 없습니다.',
});
}
return {
ok: true,
id,
};
});
}

View File

@@ -0,0 +1,500 @@
import { randomUUID } from 'node:crypto';
import { createReadStream } from 'node:fs';
import { access, mkdir, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import type { FastifyInstance, FastifyReply } from 'fastify';
import { z } from 'zod';
import { env } from '../config/env.js';
import { hasErrorLogViewAccessToken } from '../services/error-log-service.js';
import { getChatRuntimeController } from '../services/chat-service.js';
import {
createChatConversation,
deleteUnansweredChatConversationRequest,
deleteChatConversation,
ensureChatConversationTables,
getChatConversation,
listChatConversationActivityLogs,
listChatConversationMessages,
listChatConversationRequests,
listChatConversations,
markChatConversationResponsesRead,
updateChatConversationContext,
} from '../services/chat-room-service.js';
import { chatRuntimeService } from '../services/chat-runtime-service.js';
const CHAT_ATTACHMENT_ROUTE_BODY_LIMIT = 20 * 1024 * 1024;
const CHAT_ATTACHMENT_FILE_SIZE_LIMIT = 10 * 1024 * 1024;
const CHAT_PUBLIC_ROUTE_PREFIX = '/.codex_chat/';
const CHAT_API_RESOURCE_ROUTE_PREFIX = '/api/chat/resources';
function resolveStaticContentType(filePath: string) {
const extension = path.extname(filePath).toLowerCase();
switch (extension) {
case '.ts':
case '.tsx':
case '.js':
case '.jsx':
case '.mjs':
case '.cjs':
case '.json':
case '.css':
case '.html':
case '.md':
case '.txt':
case '.diff':
return 'text/plain; charset=utf-8';
case '.svg':
return 'image/svg+xml';
case '.png':
return 'image/png';
case '.jpg':
case '.jpeg':
return 'image/jpeg';
case '.gif':
return 'image/gif';
case '.webp':
return 'image/webp';
case '.ico':
return 'image/x-icon';
case '.pdf':
return 'application/pdf';
default:
return 'application/octet-stream';
}
}
function buildChatResourcePublicUrl(relativePath: string) {
const normalizedRelativePath = relativePath.replace(/^public\//, '').replace(/^\/+/, '');
return `${CHAT_API_RESOURCE_ROUTE_PREFIX}/${normalizedRelativePath
.split('/')
.filter(Boolean)
.map((segment) => encodeURIComponent(segment))
.join('/')}`;
}
function normalizeChatResourceWildcard(wildcard: string) {
const cleaned = wildcard.trim().replace(/^\/+/, '').replace(/^public\//, '');
if (!cleaned) {
return '';
}
if (cleaned.startsWith('.codex_chat/')) {
return cleaned;
}
return path.posix.join('.codex_chat', cleaned);
}
async function serveChatPublicResource(
repoPath: string,
wildcard: string,
reply: FastifyReply,
) {
const requestedRelativePath = normalizeChatResourceWildcard(wildcard);
if (!requestedRelativePath) {
return reply.code(404).send({
message: '채팅 리소스를 찾을 수 없습니다.',
});
}
const publicRoot = path.join(repoPath, 'public');
const absolutePath = path.resolve(publicRoot, requestedRelativePath);
if (!absolutePath.startsWith(`${publicRoot}${path.sep}`)) {
return reply.code(403).send({
message: '허용되지 않은 경로입니다.',
});
}
try {
await access(absolutePath);
const fileStat = await stat(absolutePath);
if (!fileStat.isFile()) {
return reply.code(404).send({
message: '채팅 리소스를 찾을 수 없습니다.',
});
}
} catch {
return reply.code(404).send({
message: '채팅 리소스를 찾을 수 없습니다.',
});
}
reply.header('Cache-Control', 'no-store');
reply.type(resolveStaticContentType(absolutePath));
return reply.send(createReadStream(absolutePath));
}
function sanitizeChatAttachmentFileName(fileName: string) {
const normalized = fileName.trim().replace(/[\\/:"*?<>|]+/g, '-').replace(/\s+/g, ' ');
const compact = normalized || 'attachment';
return compact.length > 120 ? compact.slice(-120) : compact;
}
function resolveChatAttachmentRepoPath() {
return path.resolve(env.PLAN_MAIN_PROJECT_REPO_PATH ?? env.PLAN_GIT_REPO_PATH);
}
function getClientIdHeader(request: { headers: Record<string, unknown> }) {
const raw = request.headers['x-client-id'];
return Array.isArray(raw) ? String(raw[0] ?? '').trim() : String(raw ?? '').trim();
}
function canViewAllConversations(request: { headers: Record<string, unknown> }) {
return hasErrorLogViewAccessToken(request.headers['x-access-token'] as string | string[] | undefined);
}
export async function registerChatRoutes(app: FastifyInstance) {
app.get(`${CHAT_PUBLIC_ROUTE_PREFIX}*`, async (request, reply) => {
const wildcard = String((request.params as { '*': string | undefined })['*'] ?? '').trim();
return serveChatPublicResource(resolveChatAttachmentRepoPath(), wildcard, reply);
});
app.get(`${CHAT_API_RESOURCE_ROUTE_PREFIX}/*`, async (request, reply) => {
const wildcard = String((request.params as { '*': string | undefined })['*'] ?? '').trim();
return serveChatPublicResource(resolveChatAttachmentRepoPath(), wildcard, reply);
});
app.get('/api/chat/setup', async () => {
await ensureChatConversationTables();
return {
ok: true,
tables: ['chat_conversations', 'chat_conversation_messages', 'chat_conversation_requests'],
};
});
app.get('/api/chat/conversations', async (request) => {
const query = z.object({
limit: z.coerce.number().int().min(1).max(200).optional(),
}).parse(request.query ?? {});
const viewerClientId = getClientIdHeader(request);
const clientId = canViewAllConversations(request) ? null : viewerClientId;
const items = await listChatConversations(clientId, query.limit ?? 50, viewerClientId || null);
return {
ok: true,
items,
};
});
app.get('/api/chat/runtime', async () => {
return {
ok: true,
item: chatRuntimeService.getSnapshot(),
};
});
app.post('/api/chat/attachments', { bodyLimit: CHAT_ATTACHMENT_ROUTE_BODY_LIMIT }, async (request, reply) => {
const payload = z.object({
sessionId: z.string().trim().min(1).max(120).regex(/^[A-Za-z0-9._:-]+$/),
fileName: z.string().trim().min(1).max(255),
mimeType: z.string().trim().max(200).optional(),
contentBase64: z.string().trim().min(1).max(CHAT_ATTACHMENT_ROUTE_BODY_LIMIT * 2),
}).parse(request.body ?? {});
const buffer = Buffer.from(payload.contentBase64, 'base64');
if (buffer.byteLength === 0) {
return reply.code(400).send({
message: '업로드할 파일 내용을 찾지 못했습니다.',
});
}
if (buffer.byteLength > CHAT_ATTACHMENT_FILE_SIZE_LIMIT) {
return reply.code(413).send({
message: '첨부 파일은 10MB 이하만 업로드할 수 있습니다.',
});
}
const safeFileName = sanitizeChatAttachmentFileName(payload.fileName);
const fileToken = `${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
const relativePath = path.posix.join(
'public',
'.codex_chat',
payload.sessionId,
'resource',
'uploads',
`${fileToken}-${safeFileName}`,
);
const absolutePath = path.join(resolveChatAttachmentRepoPath(), ...relativePath.split('/'));
await mkdir(path.dirname(absolutePath), { recursive: true });
await writeFile(absolutePath, buffer);
return {
ok: true,
item: {
id: randomUUID(),
name: payload.fileName,
path: relativePath,
publicUrl: buildChatResourcePublicUrl(relativePath),
size: buffer.byteLength,
mimeType: payload.mimeType?.trim() || 'application/octet-stream',
},
};
});
app.get('/api/chat/runtime/jobs/:requestId', async (request, reply) => {
const params = z.object({
requestId: z.string().trim().min(1).max(120),
}).parse(request.params ?? {});
const controller = getChatRuntimeController();
if (!controller) {
return reply.code(503).send({
message: '채팅 런타임 컨트롤러가 준비되지 않았습니다.',
});
}
return {
ok: true,
item: controller.getJobDetail(params.requestId),
};
});
app.post('/api/chat/runtime/jobs/:requestId/cancel', async (request, reply) => {
const params = z.object({
requestId: z.string().trim().min(1).max(120),
}).parse(request.params ?? {});
const controller = getChatRuntimeController();
if (!controller) {
return reply.code(503).send({
message: '채팅 런타임 컨트롤러가 준비되지 않았습니다.',
});
}
const cancelled = await controller.cancelJob(params.requestId);
if (!cancelled) {
return reply.code(404).send({
message: '취소할 실행 중 요청을 찾지 못했습니다.',
});
}
return {
ok: true,
cancelled: true,
};
});
app.post('/api/chat/runtime/jobs/:requestId/remove', async (request, reply) => {
const params = z.object({
requestId: z.string().trim().min(1).max(120),
}).parse(request.params ?? {});
const controller = getChatRuntimeController();
if (!controller) {
return reply.code(503).send({
message: '채팅 런타임 컨트롤러가 준비되지 않았습니다.',
});
}
const removed = await controller.removeQueuedJob(params.requestId);
if (!removed) {
return reply.code(404).send({
message: '제거할 대기 요청을 찾지 못했습니다.',
});
}
return {
ok: true,
removed: true,
};
});
app.post('/api/chat/conversations', async (request) => {
const payload = z.object({
sessionId: z.string().trim().min(1).max(120),
title: z.string().trim().max(200).optional(),
contextLabel: z.string().trim().max(200).optional(),
contextDescription: z.string().trim().max(2000).optional(),
notifyOffline: z.boolean().optional(),
}).parse(request.body ?? {});
const clientId = getClientIdHeader(request);
const item = await createChatConversation({
sessionId: payload.sessionId,
clientId: clientId || null,
title: payload.title ?? '새 대화',
contextLabel: payload.contextLabel ?? null,
contextDescription: payload.contextDescription ?? null,
notifyOffline: payload.notifyOffline ?? true,
});
return {
ok: true,
item,
};
});
app.get('/api/chat/conversations/:sessionId', async (request, reply) => {
const params = z.object({
sessionId: z.string().trim().min(1).max(120),
}).parse(request.params ?? {});
const query = z.object({
limit: z.coerce.number().int().min(1).max(500).optional(),
beforeMessageId: z.coerce.number().int().positive().optional(),
}).parse(request.query ?? {});
const clientId = canViewAllConversations(request) ? null : getClientIdHeader(request);
const item = await getChatConversation(params.sessionId, clientId || null);
if (!item) {
return reply.code(404).send({
message: '채팅방을 찾을 수 없습니다.',
});
}
const messageLimit = query.limit ?? 500;
const messages = await listChatConversationMessages(params.sessionId, {
limit: messageLimit,
beforeMessageId: query.beforeMessageId ?? null,
});
const requests = await listChatConversationRequests(params.sessionId, 500);
const activityLogs = await listChatConversationActivityLogs(params.sessionId, 500);
const oldestLoadedMessageId = messages[0]?.id ?? null;
const hasOlderMessages =
oldestLoadedMessageId != null
? (await listChatConversationMessages(params.sessionId, {
limit: 1,
beforeMessageId: oldestLoadedMessageId,
})).length > 0
: false;
return {
ok: true,
item,
messages,
requests,
activityLogs,
oldestLoadedMessageId,
hasOlderMessages,
};
});
app.post('/api/chat/conversations/:sessionId/read', async (request, reply) => {
const params = z.object({
sessionId: z.string().trim().min(1).max(120),
}).parse(request.params ?? {});
const clientId = getClientIdHeader(request);
if (!clientId) {
return reply.code(400).send({
message: '읽음 처리를 위한 clientId가 필요합니다.',
});
}
const result = await markChatConversationResponsesRead(params.sessionId, clientId);
if (!result) {
return reply.code(404).send({
message: '읽음 처리할 채팅방을 찾지 못했습니다.',
});
}
return {
ok: true,
...result,
};
});
app.delete('/api/chat/conversations/:sessionId/requests/:requestId', async (request, reply) => {
const params = z.object({
sessionId: z.string().trim().min(1).max(120),
requestId: z.string().trim().min(1).max(120),
}).parse(request.params ?? {});
const result = await deleteUnansweredChatConversationRequest(params.sessionId, params.requestId);
if (!result.deleted) {
if (result.reason === 'not_found') {
return reply.code(404).send({
message: '삭제할 요청을 찾지 못했습니다.',
});
}
if (result.reason === 'answered') {
return reply.code(409).send({
message: '이미 답변이 연결된 요청은 삭제할 수 없습니다.',
});
}
return reply.code(409).send({
message: '현재 처리 중인 요청은 삭제할 수 없습니다.',
});
}
return {
ok: true,
deleted: true,
sessionId: params.sessionId,
requestId: params.requestId,
};
});
app.patch('/api/chat/conversations/:sessionId', async (request, reply) => {
const params = z.object({
sessionId: z.string().trim().min(1).max(120),
}).parse(request.params ?? {});
const payload = z.object({
title: z.string().trim().min(1).max(200).optional(),
contextLabel: z.string().trim().max(200).optional().nullable(),
contextDescription: z.string().trim().max(2000).optional().nullable(),
notifyOffline: z.boolean().optional(),
}).parse(request.body ?? {});
const clientId = canViewAllConversations(request) ? null : getClientIdHeader(request);
const current = await getChatConversation(params.sessionId, clientId || null);
if (!current) {
return reply.code(404).send({
message: '수정할 채팅방을 찾을 수 없습니다.',
});
}
const item = await updateChatConversationContext(params.sessionId, {
title: payload.title ?? current.title,
clientId: current.clientId,
contextLabel: payload.contextLabel ?? current.contextLabel,
contextDescription: payload.contextDescription ?? current.contextDescription,
notifyOffline: payload.notifyOffline ?? current.notifyOffline,
});
return {
ok: true,
item,
};
});
app.delete('/api/chat/conversations/:sessionId', async (request, reply) => {
const params = z.object({
sessionId: z.string().trim().min(1).max(120),
}).parse(request.params ?? {});
const clientId = canViewAllConversations(request) ? null : getClientIdHeader(request);
const current = await getChatConversation(params.sessionId, clientId || null);
if (!current) {
return reply.code(404).send({
message: '삭제할 채팅방을 찾을 수 없습니다.',
});
}
const deleted = await deleteChatConversation(params.sessionId);
return {
ok: true,
deleted,
sessionId: params.sessionId,
};
});
}

View File

@@ -0,0 +1,91 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import Fastify from 'fastify';
import { registerJsonBodyParser } from '../json-body.js';
import { registerBoardRoutes } from './board.js';
import { registerNotificationRoutes } from './notification.js';
function createRouteRecorder() {
const routes: Array<{ method: string; path: string }> = [];
const record = (method: string) => (path: string) => {
routes.push({ method, path });
return undefined;
};
const app = {
get: record('GET'),
post: record('POST'),
put: record('PUT'),
patch: record('PATCH'),
delete: record('DELETE'),
};
return {
app: app as any,
routes,
};
}
test('registerJsonBodyParser treats empty json body as an empty object', async () => {
const app = Fastify();
registerJsonBodyParser(app);
app.post('/json', async (request) => ({
body: request.body,
}));
const response = await app.inject({
method: 'POST',
url: '/json',
headers: {
'content-type': 'application/json',
},
payload: '',
});
assert.equal(response.statusCode, 200);
assert.deepEqual(response.json(), {
body: {},
});
await app.close();
});
test('registerJsonBodyParser still rejects malformed json', async () => {
const app = Fastify();
registerJsonBodyParser(app);
app.post('/json', async (request) => ({
body: request.body,
}));
const response = await app.inject({
method: 'POST',
url: '/json',
headers: {
'content-type': 'application/json',
},
payload: '{',
});
assert.equal(response.statusCode, 400);
assert.match(response.body, /valid JSON/i);
await app.close();
});
test('registerBoardRoutes keeps setup and items compatibility routes', async () => {
const { app, routes } = createRouteRecorder();
await registerBoardRoutes(app);
assert.ok(routes.some((route) => route.method === 'GET' && route.path === '/api/board/setup'));
assert.ok(routes.some((route) => route.method === 'POST' && route.path === '/api/board/setup'));
assert.ok(routes.some((route) => route.method === 'GET' && route.path === '/api/board/posts'));
assert.ok(routes.some((route) => route.method === 'GET' && route.path === '/api/board/items'));
});
test('registerNotificationRoutes exposes notification message routes', async () => {
const { app, routes } = createRouteRecorder();
await registerNotificationRoutes(app);
assert.ok(routes.some((route) => route.method === 'GET' && route.path === '/api/notifications/messages'));
assert.ok(routes.some((route) => route.method === 'GET' && route.path === '/api/notifications/messages/:id'));
assert.ok(routes.some((route) => route.method === 'POST' && route.path === '/api/notifications/messages'));
assert.ok(routes.some((route) => route.method === 'PATCH' && route.path === '/api/notifications/messages/:id'));
assert.ok(routes.some((route) => route.method === 'DELETE' && route.path === '/api/notifications/messages/:id'));
});

View 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,
};
});
}

View File

@@ -0,0 +1,119 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { db } from '../db/client.js';
import { assertIdentifier } from '../lib/identifier.js';
const columnSchema = z.object({
name: z.string(),
type: z.string(),
nullable: z.boolean().optional(),
primary: z.boolean().optional(),
unique: z.boolean().optional(),
defaultTo: z.any().optional(),
});
const createTableSchema = z.object({
tableName: z.string(),
columns: z.array(columnSchema).min(1),
});
const dropTableSchema = z.object({
tableName: z.string(),
});
const addColumnSchema = z.object({
tableName: z.string(),
column: columnSchema,
});
const dropColumnSchema = z.object({
tableName: z.string(),
columnName: z.string(),
});
const rawDdlSchema = z.object({
sql: z.string().min(1),
});
function applyColumn(tableBuilder: any, column: z.infer<typeof columnSchema>) {
const name = assertIdentifier(column.name, 'column name');
const definition = tableBuilder.specificType(name, column.type);
if (column.nullable === false) {
definition.notNullable();
}
if (column.nullable === true) {
definition.nullable();
}
if (column.primary) {
definition.primary();
}
if (column.unique) {
definition.unique();
}
if (column.defaultTo !== undefined) {
definition.defaultTo(column.defaultTo);
}
}
export async function registerDdlRoutes(app: FastifyInstance) {
app.post('/api/ddl/create-table', async (request) => {
const payload = createTableSchema.parse(request.body);
const tableName = assertIdentifier(payload.tableName, 'table name');
await db.schema.createTable(tableName, (table) => {
payload.columns.forEach((column) => {
applyColumn(table, column);
});
});
return { ok: true, action: 'create-table', tableName };
});
app.post('/api/ddl/drop-table', async (request) => {
const payload = dropTableSchema.parse(request.body);
const tableName = assertIdentifier(payload.tableName, 'table name');
await db.schema.dropTableIfExists(tableName);
return { ok: true, action: 'drop-table', tableName };
});
app.post('/api/ddl/add-column', async (request) => {
const payload = addColumnSchema.parse(request.body);
const tableName = assertIdentifier(payload.tableName, 'table name');
await db.schema.alterTable(tableName, (table) => {
applyColumn(table, payload.column);
});
return { ok: true, action: 'add-column', tableName, column: payload.column.name };
});
app.post('/api/ddl/drop-column', async (request) => {
const payload = dropColumnSchema.parse(request.body);
const tableName = assertIdentifier(payload.tableName, 'table name');
const columnName = assertIdentifier(payload.columnName, 'column name');
await db.schema.alterTable(tableName, (table) => {
table.dropColumn(columnName);
});
return { ok: true, action: 'drop-column', tableName, columnName };
});
app.post('/api/ddl/raw', async (request) => {
const payload = rawDdlSchema.parse(request.body);
const result = await db.raw(payload.sql);
return {
ok: true,
action: 'raw',
result,
};
});
}

View File

@@ -0,0 +1,49 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import {
createErrorLog,
createErrorLogSchema,
hasErrorLogViewAccessToken,
listErrorLogs,
setupErrorLogTable,
} from '../services/error-log-service.js';
const errorLogListQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(200).optional(),
});
export async function registerErrorLogRoutes(app: FastifyInstance) {
app.post('/api/error-logs/setup', async () => {
return setupErrorLogTable();
});
app.get('/api/error-logs', async (request, reply) => {
const accessToken = request.headers['x-access-token'];
if (!hasErrorLogViewAccessToken(accessToken)) {
reply.status(403);
return {
ok: false,
message: '에러 로그 조회 권한이 없습니다.',
};
}
const query = errorLogListQuerySchema.parse(request.query ?? {});
const items = await listErrorLogs(query.limit ?? 50);
return {
ok: true,
items,
};
});
app.post('/api/error-logs/report', async (request) => {
const payload = createErrorLogSchema.parse(request.body);
const item = await createErrorLog(payload);
return {
ok: true,
item,
};
});
}

View File

@@ -0,0 +1,13 @@
import type { FastifyInstance } from 'fastify';
export async function registerHealthRoutes(app: FastifyInstance) {
const respondHealth = async () => ({
ok: true,
service: 'work-server',
timestamp: new Date().toISOString(),
});
app.get('/', respondHealth);
app.get('/api', respondHealth);
app.get('/health', respondHealth);
}

View File

@@ -0,0 +1,207 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import {
listIosNotificationTokens,
getAutomationNotificationPreference,
getWebPushConfig,
registerIosNotificationToken,
registerAutomationNotificationPreferenceSchema,
registerIosTokenSchema,
registerWebPushSubscription,
registerWebPushSubscriptionSchema,
sendNotifications,
sendIosNotificationSchema,
setupNotificationTables,
upsertAutomationNotificationPreference,
unregisterIosNotificationToken,
unregisterIosTokenSchema,
unregisterWebPushSubscription,
unregisterWebPushSubscriptionSchema,
} from '../services/notification-service.js';
import {
createNotificationMessage,
deleteNotificationMessage,
getNotificationMessage,
listNotificationMessages,
notificationMessageListQuerySchema,
notificationMessagePayloadSchema,
notificationMessageReadPayloadSchema,
updateNotificationMessageReadState,
} from '../services/notification-message-service.js';
const automationNotificationPreferenceQuerySchema = z.object({
targetKind: z.enum(['client', 'ios-token', 'ios-token-client', 'web-endpoint']).optional(),
targetId: z.string().trim().min(1).max(1000).optional(),
});
type AutomationNotificationPreferenceTargetKind = NonNullable<
z.infer<typeof automationNotificationPreferenceQuerySchema>['targetKind']
>;
function getClientIdHeader(request: { headers: Record<string, string | string[] | undefined> }) {
const rawClientId = request.headers['x-client-id'];
const clientId = Array.isArray(rawClientId) ? rawClientId[0] : rawClientId;
return clientId?.trim() ?? '';
}
export async function registerNotificationRoutes(app: FastifyInstance) {
app.post('/api/notifications/setup', async () => setupNotificationTables());
app.get('/api/notifications/tokens', async () => ({
items: await listIosNotificationTokens(),
}));
app.get('/api/notifications/webpush/config', async () => getWebPushConfig());
app.get('/api/notifications/messages', async (request) => {
const query = notificationMessageListQuerySchema.parse(request.query ?? {});
return {
ok: true,
...(await listNotificationMessages(query)),
};
});
app.get('/api/notifications/messages/:id', async (request, reply) => {
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
const item = await getNotificationMessage(id);
if (!item) {
return reply.code(404).send({
message: '알림 메시지를 찾을 수 없습니다.',
});
}
return {
ok: true,
item,
};
});
app.post('/api/notifications/messages', async (request) => {
const item = await createNotificationMessage(notificationMessagePayloadSchema.parse(request.body ?? {}));
return {
ok: true,
item,
};
});
app.patch('/api/notifications/messages/:id', async (request, reply) => {
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
const item = await updateNotificationMessageReadState(id, notificationMessageReadPayloadSchema.parse(request.body ?? {}));
if (!item) {
return reply.code(404).send({
message: '상태를 변경할 알림 메시지를 찾을 수 없습니다.',
});
}
return {
ok: true,
item,
};
});
app.delete('/api/notifications/messages/:id', async (request, reply) => {
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
const deleted = await deleteNotificationMessage(id);
if (!deleted) {
return reply.code(404).send({
message: '삭제할 알림 메시지를 찾을 수 없습니다.',
});
}
return {
ok: true,
deleted: true,
};
});
app.get('/api/notifications/preferences/automation', async (request) => {
const query = automationNotificationPreferenceQuerySchema.parse(request.query ?? {});
const targetId = query.targetId || getClientIdHeader(request);
const targetKind = query.targetId ? query.targetKind ?? 'client' : 'client';
return {
ok: true,
automation: await getAutomationNotificationPreferenceWithFallback(targetId, targetKind),
};
});
app.put('/api/notifications/preferences/automation', async (request, reply) => {
try {
const payload = registerAutomationNotificationPreferenceSchema.parse(request.body ?? {});
const targetId = payload.targetId || getClientIdHeader(request);
if (!targetId) {
throw new Error('알림 설정을 저장할 클라이언트 ID가 없습니다.');
}
return upsertAutomationNotificationPreference({
...payload,
targetId,
});
} catch (error) {
return reply.code(409).send({
message: error instanceof Error ? error.message : '알림 설정 저장에 실패했습니다.',
});
}
});
app.put('/api/notifications/tokens/ios', async (request) => {
const payload = registerIosTokenSchema.parse(request.body ?? {});
return registerIosNotificationToken(payload);
});
app.delete('/api/notifications/tokens/ios', async (request) => {
const payload = unregisterIosTokenSchema.parse(request.body ?? {});
return unregisterIosNotificationToken(payload.token);
});
app.put('/api/notifications/subscriptions/web', async (request) => {
const payload = registerWebPushSubscriptionSchema.parse(request.body ?? {});
return registerWebPushSubscription(payload);
});
app.delete('/api/notifications/subscriptions/web', async (request) => {
const payload = unregisterWebPushSubscriptionSchema.parse(request.body ?? {});
return unregisterWebPushSubscription(payload.endpoint);
});
app.post('/api/notifications/send', async (request) => {
const payload = sendIosNotificationSchema.parse(request.body ?? {});
return sendNotifications(payload);
});
app.post('/api/notifications/send-test', async (request) => {
const payload = sendIosNotificationSchema.parse(request.body ?? {});
return sendNotifications(payload);
});
}
async function getAutomationNotificationPreferenceWithFallback(
targetId: string,
targetKind: AutomationNotificationPreferenceTargetKind,
) {
const automation = await getAutomationNotificationPreference(targetId, targetKind);
if (automation || targetKind !== 'ios-token-client') {
return automation;
}
const [token, clientId] = targetId.split('::client::');
if (token?.trim()) {
const tokenAutomation = await getAutomationNotificationPreference(token.trim(), 'ios-token');
if (tokenAutomation) {
return tokenAutomation;
}
}
if (clientId?.trim()) {
return getAutomationNotificationPreference(clientId.trim(), 'client');
}
return null;
}

View File

@@ -0,0 +1,981 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { hasErrorLogViewAccessToken } from '../services/error-log-service.js';
import { notifyPlanEvent } from '../services/plan-notification-service.js';
import { shouldNotifyPlanRestart } from '../services/plan-notification-policy.js';
import {
PLAN_ACTION_TABLE,
PLAN_ISSUE_TABLE,
PLAN_RELEASE_REVIEW_TABLE,
PLAN_SOURCE_WORK_TABLE,
PLAN_TABLE,
appendLatestIssueAction,
cancelPlanRelease,
createPlanItem,
createPlanActionHistory,
createPlanSourceWorkHistory,
createPlanSchema,
deletePlanItem,
ensurePlanTable,
getPlanSourceWorkHistory,
getBoardPostLinkedToPlanItem,
getPlanItemById,
formatPlanNotificationLabel,
issueActionSchema,
listPlanActionHistories,
listPlanIssueHistories,
listPlanItems,
listPlanReleaseReviewBoardItems,
listPlanSourceWorkHistories,
listPlanQuerySchema,
mapPlanActionRow,
mapPlanIssueRow,
mapPlanSourceWorkRow,
markPlanAsStarted,
planStatuses,
markPlanAsCompleted,
markPlanAsDevelopmentComplete,
queuePlanRetryFromFailure,
queuePlanRetryFromIssueAction,
requestPlanMainMerge,
resumePlanDevelopmentFromRelease,
retryPlanBranch,
retryPlanWork,
retryPlanMerge,
setupSchema,
updatePlanReleaseReviewSchema,
upsertPlanReleaseReview,
updatePlanItem,
updatePlanItemJangsingProcessingRequired,
updatePlanJangsingProcessingSchema,
updatePlanSchema,
} from '../services/plan-service.js';
import { db } from '../db/client.js';
import { getEnv } from '../config/env.js';
import { recreateReleaseBranchFromMain } from '../services/git-service.js';
import { registerErrorLogBoardPosts } from '../services/error-log-plan-registration-service.js';
import {
PLAN_SCHEDULED_TASK_TABLE,
createPlanScheduledTask,
createPlanScheduledTaskSchema,
deletePlanScheduledTask,
ensurePlanScheduledTaskTable,
getPlanScheduledTaskById,
listPlanScheduledTasks,
mapPlanScheduledTaskRow,
registerPlanScheduledTaskNow,
updatePlanScheduledTask,
updatePlanScheduledTaskSchema,
} from '../services/plan-schedule-service.js';
import { getVisitorClientByClientId } from '../services/visitor-history-service.js';
const completeActionSchema = z.object({
note: z.string().trim().min(1).optional(),
});
const actionNoteSchema = z.object({
actionNote: z.string().trim().min(1),
actionType: z.string().trim().min(1).optional(),
});
const createSourceWorkSchema = z.object({
summary: z.string().trim().min(1),
branchName: z.string().trim().min(1),
commitHash: z.string().trim().min(1).nullable().optional(),
previewUrl: z.string().trim().url().nullable().optional(),
changedFiles: z.array(z.string()).default([]),
commandLog: z.string().nullable().optional(),
diffText: z.string().nullable().optional(),
sourceFiles: z
.array(
z.object({
path: z.string().trim().min(1),
previousPath: z.string().trim().min(1).nullable().optional(),
status: z.enum(['added', 'modified', 'deleted', 'renamed', 'binary', 'unknown']),
language: z.string().trim().min(1),
content: z.string(),
}),
)
.default([]),
});
export async function registerPlanRoutes(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 isLoopbackAddress(value: string | null | undefined) {
const normalizedValue = String(value ?? '').trim();
return normalizedValue === '127.0.0.1' || normalizedValue === '::1' || normalizedValue === '::ffff:127.0.0.1';
}
function hasPlanAccessToken(accessToken: string | string[] | undefined) {
return hasErrorLogViewAccessToken(accessToken);
}
function hasPlanAccess(request: { headers: Record<string, unknown>; ip?: string; socket?: { remoteAddress?: string | null }; raw?: { socket?: { remoteAddress?: string | null } } }) {
if (hasPlanAccessToken(request.headers['x-access-token'] as string | string[] | undefined)) {
return true;
}
return isLoopbackAddress(request.ip) || isLoopbackAddress(request.raw?.socket?.remoteAddress) || isLoopbackAddress(request.socket?.remoteAddress);
}
function requirePlanAccessToken(
request: { headers: Record<string, unknown>; ip?: string; socket?: { remoteAddress?: string | null }; raw?: { socket?: { remoteAddress?: string | null } } },
reply: Parameters<FastifyInstance['get']>[1] extends (request: any, reply: infer T) => any ? T : any,
) {
if (hasPlanAccess(request)) {
return true;
}
void reply.code(403).send({
message: '권한 토큰이 등록된 사용자만 수정할 수 있습니다.',
});
return false;
}
async function handleListPlanScheduledTasks(request: FastifyRequest, reply: FastifyReply) {
if (!hasPlanAccess(request)) {
return reply.code(403).send({
message: '권한 토큰이 등록된 사용자만 스케줄을 사용할 수 있습니다.',
});
}
const rows = await listPlanScheduledTasks();
return {
items: rows.map(mapPlanScheduledTaskRow),
};
}
async function handleCreatePlanScheduledTask(request: FastifyRequest, reply: FastifyReply) {
if (!requirePlanAccessToken(request, reply)) {
return;
}
const payload = createPlanScheduledTaskSchema.parse(request.body ?? {});
const row = await createPlanScheduledTask(payload);
const immediateRegistration = payload.enabled && payload.immediateRunEnabled ? await registerPlanScheduledTaskNow(Number(row.id)) : null;
return {
ok: true,
item: mapPlanScheduledTaskRow(row),
registeredPlan: immediateRegistration?.createdPlan ? await getPlanItemById(Number(immediateRegistration.createdPlan.id)) : null,
registeredBoardPosts: immediateRegistration?.createdBoardPosts ?? [],
};
}
async function handleGetPlanScheduledTask(request: FastifyRequest, reply: FastifyReply) {
if (!hasPlanAccess(request)) {
return reply.code(403).send({
message: '권한 토큰이 등록된 사용자만 스케줄을 사용할 수 있습니다.',
});
}
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
const row = await getPlanScheduledTaskById(id);
if (!row) {
return reply.code(404).send({
message: '스케줄을 찾을 수 없습니다.',
});
}
return {
item: mapPlanScheduledTaskRow(row),
};
}
async function handleUpdatePlanScheduledTask(request: FastifyRequest, reply: FastifyReply) {
if (!requirePlanAccessToken(request, reply)) {
return;
}
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
const payload = updatePlanScheduledTaskSchema.parse(request.body ?? {});
const row = await updatePlanScheduledTask(id, payload);
if (!row) {
return reply.code(404).send({
message: '수정할 스케줄을 찾을 수 없습니다.',
});
}
const shouldTriggerImmediateRegistration =
row &&
Boolean(row.enabled ?? true) &&
Boolean(row.immediate_run_enabled ?? true) &&
payload.enabled !== false;
const immediateRegistration = shouldTriggerImmediateRegistration ? await registerPlanScheduledTaskNow(id) : null;
return {
ok: true,
item: mapPlanScheduledTaskRow(row),
registeredPlan: immediateRegistration?.createdPlan ? await getPlanItemById(Number(immediateRegistration.createdPlan.id)) : null,
registeredBoardPosts: immediateRegistration?.createdBoardPosts ?? [],
};
}
app.post('/api/plan/registrations/error-logs', async (request, reply) => {
if (!requirePlanAccessToken(request, reply)) {
return;
}
const payload = z.object({
rangeStart: z.coerce.date().optional(),
rangeEnd: z.coerce.date().optional(),
maxGroups: z.coerce.number().int().min(1).max(24).optional(),
}).parse(request.body ?? {});
const result = await registerErrorLogBoardPosts(payload);
return {
ok: true,
rangeStart: result.rangeStart.toISOString(),
rangeEnd: result.rangeEnd.toISOString(),
recentLogCount: result.recentLogs.length,
candidateCount: result.candidates.length,
rawCandidateCount: result.rawCandidates.length,
createdBoardPosts: result.createdPosts,
skippedBoardPosts: result.skippedPosts,
};
});
async function handleDeletePlanScheduledTask(request: FastifyRequest, reply: FastifyReply) {
if (!requirePlanAccessToken(request, reply)) {
return;
}
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
const row = await deletePlanScheduledTask(id);
if (!row) {
return reply.code(404).send({
message: '삭제할 스케줄을 찾을 수 없습니다.',
});
}
return {
ok: true,
id,
};
}
app.get('/api/plan/statuses', async () => ({
items: planStatuses,
}));
app.post('/api/plan/setup', async (request) => {
const payload = setupSchema.parse(request.body ?? {});
if (payload.recreate) {
await db.schema.dropTableIfExists(PLAN_SCHEDULED_TASK_TABLE);
await db.schema.dropTableIfExists(PLAN_ACTION_TABLE);
await db.schema.dropTableIfExists(PLAN_ISSUE_TABLE);
await db.schema.dropTableIfExists(PLAN_SOURCE_WORK_TABLE);
await db.schema.dropTableIfExists(PLAN_TABLE);
}
await ensurePlanTable();
await ensurePlanScheduledTaskTable();
return {
ok: true,
table: PLAN_TABLE,
scheduleTable: PLAN_SCHEDULED_TASK_TABLE,
releaseReviewTable: PLAN_RELEASE_REVIEW_TABLE,
statuses: planStatuses,
};
});
app.get('/api/plan/release-reviews', async (request) => {
const items = await listPlanReleaseReviewBoardItems({
maskNote: !hasPlanAccess(request),
});
return {
items,
};
});
app.patch('/api/plan/release-reviews/:planItemId', async (request, reply) => {
if (!requirePlanAccessToken(request, reply)) {
return;
}
const planItemId = z.coerce.number().int().positive().parse((request.params as { planItemId: string }).planItemId);
const payload = updatePlanReleaseReviewSchema.parse(request.body ?? {});
const clientId = String(request.headers['x-client-id'] ?? '').trim();
const visitor = clientId ? await getVisitorClientByClientId(clientId) : null;
const review = await upsertPlanReleaseReview(planItemId, payload, {
clientId: clientId || null,
nickname: visitor?.nickname ?? null,
});
if (!review) {
return reply.code(404).send({
message: '검수 대상을 찾을 수 없습니다.',
});
}
return {
ok: true,
item: review,
};
});
app.get('/api/plan/scheduled-tasks', handleListPlanScheduledTasks);
app.get('/api/plan/schedule/tasks', handleListPlanScheduledTasks);
app.get('/api/plan/schedule', handleListPlanScheduledTasks);
app.get('/api/plan/schedules', handleListPlanScheduledTasks);
app.get('/api/plans/scheduled-tasks', handleListPlanScheduledTasks);
app.get('/api/plans/schedule/tasks', handleListPlanScheduledTasks);
app.get('/api/plans/schedule', handleListPlanScheduledTasks);
app.get('/api/plans/schedules', handleListPlanScheduledTasks);
app.get('/api/plan/scheduled-tasks/:id', handleGetPlanScheduledTask);
app.get('/api/plan/schedule/tasks/:id', handleGetPlanScheduledTask);
app.get('/api/plan/schedule/:id', handleGetPlanScheduledTask);
app.get('/api/plan/schedules/:id', handleGetPlanScheduledTask);
app.get('/api/plans/scheduled-tasks/:id', handleGetPlanScheduledTask);
app.get('/api/plans/schedule/tasks/:id', handleGetPlanScheduledTask);
app.get('/api/plans/schedule/:id', handleGetPlanScheduledTask);
app.get('/api/plans/schedules/:id', handleGetPlanScheduledTask);
app.post('/api/plan/scheduled-tasks', handleCreatePlanScheduledTask);
app.post('/api/plan/schedule/tasks', handleCreatePlanScheduledTask);
app.post('/api/plan/schedule', handleCreatePlanScheduledTask);
app.post('/api/plan/schedules', handleCreatePlanScheduledTask);
app.post('/api/plans/scheduled-tasks', handleCreatePlanScheduledTask);
app.post('/api/plans/schedule/tasks', handleCreatePlanScheduledTask);
app.post('/api/plans/schedule', handleCreatePlanScheduledTask);
app.post('/api/plans/schedules', handleCreatePlanScheduledTask);
app.patch('/api/plan/scheduled-tasks/:id', handleUpdatePlanScheduledTask);
app.patch('/api/plan/schedule/tasks/:id', handleUpdatePlanScheduledTask);
app.patch('/api/plan/schedule/:id', handleUpdatePlanScheduledTask);
app.patch('/api/plan/schedules/:id', handleUpdatePlanScheduledTask);
app.patch('/api/plans/scheduled-tasks/:id', handleUpdatePlanScheduledTask);
app.patch('/api/plans/schedule/tasks/:id', handleUpdatePlanScheduledTask);
app.patch('/api/plans/schedule/:id', handleUpdatePlanScheduledTask);
app.patch('/api/plans/schedules/:id', handleUpdatePlanScheduledTask);
app.delete('/api/plan/scheduled-tasks/:id', handleDeletePlanScheduledTask);
app.delete('/api/plan/schedule/tasks/:id', handleDeletePlanScheduledTask);
app.delete('/api/plan/schedule/:id', handleDeletePlanScheduledTask);
app.delete('/api/plan/schedules/:id', handleDeletePlanScheduledTask);
app.delete('/api/plans/scheduled-tasks/:id', handleDeletePlanScheduledTask);
app.delete('/api/plans/schedule/tasks/:id', handleDeletePlanScheduledTask);
app.delete('/api/plans/schedule/:id', handleDeletePlanScheduledTask);
app.delete('/api/plans/schedules/:id', handleDeletePlanScheduledTask);
app.get('/api/plan/items', async (request, reply) => {
const parsedQuery = listPlanQuerySchema.safeParse(request.query ?? {});
if (!parsedQuery.success) {
return reply.code(400).send({
message: '유효하지 않은 status 쿼리입니다.',
});
}
const query = parsedQuery.data;
const items = await listPlanItems(query.status, {
maskNote: !hasPlanAccess(request),
});
return {
items,
};
});
app.get('/api/plan/items/:id', async (request, reply) => {
const hasAccess = hasPlanAccess(request);
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
const row = await getPlanItemById(id, {
maskNote: !hasAccess,
});
if (!row) {
return reply.code(404).send({
message: '작업 항목을 찾을 수 없습니다.',
});
}
return {
item: row,
};
});
app.post('/api/plan/items', async (request, reply) => {
if (!requirePlanAccessToken(request, reply)) {
return;
}
try {
const payload = createPlanSchema.parse(request.body ?? {});
const createdRow = await createPlanItem(payload);
const row = await getPlanItemById(Number(createdRow.id));
return {
ok: true,
item: row,
};
} catch (error) {
return reply.code(409).send({
message: error instanceof Error ? error.message : '작업 항목 등록에 실패했습니다.',
});
}
});
app.patch('/api/plan/items/:id', async (request, reply) => {
if (!requirePlanAccessToken(request, reply)) {
return;
}
try {
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
const payload = updatePlanSchema.parse(request.body ?? {});
const updatedRow = await updatePlanItem(id, payload);
if (!updatedRow) {
return reply.code(404).send({
message: '수정할 작업 항목을 찾을 수 없습니다.',
});
}
const row = await getPlanItemById(id);
return {
ok: true,
item: row,
};
} catch (error) {
return reply.code(409).send({
message: error instanceof Error ? error.message : '작업 항목 수정에 실패했습니다.',
});
}
});
app.patch('/api/plan/items/:id/jangsing-processing', async (request, reply) => {
if (!requirePlanAccessToken(request, reply)) {
return;
}
try {
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
const payload = updatePlanJangsingProcessingSchema.parse(request.body ?? {});
const updatedRow = await updatePlanItemJangsingProcessingRequired(id, payload.jangsingProcessingRequired);
if (!updatedRow) {
return reply.code(404).send({
message: '수정할 작업 항목을 찾을 수 없습니다.',
});
}
return {
ok: true,
item: await getPlanItemById(id),
};
} catch (error) {
return reply.code(409).send({
message: error instanceof Error ? error.message : '기능동작확인 수정에 실패했습니다.',
});
}
});
app.delete('/api/plan/items/:id', async (request, reply) => {
if (!requirePlanAccessToken(request, reply)) {
return;
}
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
request.log.warn(
{
planItemId: id,
trace: getRequestTraceContext(request),
},
'Plan item delete requested',
);
const linkedBoardPost = await getBoardPostLinkedToPlanItem(id);
if (linkedBoardPost) {
request.log.warn(
{
planItemId: id,
boardPostId: linkedBoardPost.id,
boardPostTitle: linkedBoardPost.title,
trace: getRequestTraceContext(request),
},
'Plan item delete blocked because it is linked to a board post',
);
return reply.code(409).send({
message: `자동화 접수된 항목은 삭제할 수 없습니다. 연결 게시글 #${linkedBoardPost.id}`,
});
}
const row = await deletePlanItem(id);
if (!row) {
return reply.code(404).send({
message: '삭제할 작업 항목을 찾을 수 없습니다.',
});
}
return {
ok: true,
id,
};
});
app.post('/api/plan/items/:id/actions/complete-development', async (request, reply) => {
if (!requirePlanAccessToken(request, reply)) {
return;
}
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
const row = await markPlanAsDevelopmentComplete(id);
if (!row) {
return reply.code(404).send({
message: '작업 항목을 찾을 수 없습니다.',
});
}
const planLabel = formatPlanNotificationLabel(String(row.work_id), id);
await notifyPlanEvent(
id,
`[${planLabel}] release 반영 대기`,
'수동 작업완료로 release 반영 대기 상태가 되었습니다.',
'development-completed',
);
return {
ok: true,
item: await getPlanItemById(id),
};
});
app.post('/api/plan/items/:id/actions/complete', async (request, reply) => {
if (!requirePlanAccessToken(request, reply)) {
return;
}
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
const payload = completeActionSchema.parse(request.body ?? {});
const row = await markPlanAsCompleted(id, payload.note);
if (!row) {
return reply.code(404).send({
message: '작업 항목을 찾을 수 없습니다.',
});
}
const planLabel = formatPlanNotificationLabel(String(row.work_id), id);
await notifyPlanEvent(
id,
`[${planLabel}] 완료 처리`,
payload.note ?? '작업이 완료 처리되었습니다.',
'plan-completed',
);
return {
ok: true,
item: await getPlanItemById(id),
};
});
app.post('/api/plan/items/:id/actions/start-work', async (request, reply) => {
if (!requirePlanAccessToken(request, reply)) {
return;
}
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
const row = await markPlanAsStarted(id);
if (!row) {
return reply.code(404).send({
message: '작업 항목을 찾을 수 없습니다.',
});
}
const planLabel = formatPlanNotificationLabel(String(row.work_id), id);
await notifyPlanEvent(
id,
`[${planLabel}] 작업시작`,
'작업이 시작되었습니다.',
'work-started',
);
return {
ok: true,
item: await getPlanItemById(id),
};
});
app.post('/api/plan/items/:id/actions/retry-branch', async (request, reply) => {
if (!requirePlanAccessToken(request, reply)) {
return;
}
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
const row = await retryPlanBranch(id);
if (!row) {
return reply.code(404).send({
message: '작업 항목을 찾을 수 없습니다.',
});
}
const planLabel = formatPlanNotificationLabel(String(row.work_id), id);
await notifyPlanEvent(
id,
`[${planLabel}] 작업 재시작`,
'브랜치 재시도를 요청했습니다.',
'plan-restarted',
);
return {
ok: true,
item: await getPlanItemById(id),
};
});
app.post('/api/plan/items/:id/actions/retry-work', async (request, reply) => {
if (!requirePlanAccessToken(request, reply)) {
return;
}
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
const row = await retryPlanWork(id);
if (!row) {
return reply.code(404).send({
message: '작업 항목을 찾을 수 없습니다.',
});
}
const planLabel = formatPlanNotificationLabel(String(row.work_id), id);
await notifyPlanEvent(
id,
`[${planLabel}] 작업 재시작`,
'자동 작업 재처리를 요청했습니다.',
'plan-restarted',
);
return {
ok: true,
item: await getPlanItemById(id),
};
});
app.post('/api/plan/items/:id/actions/retry-merge', async (request, reply) => {
if (!requirePlanAccessToken(request, reply)) {
return;
}
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
const row = await retryPlanMerge(id);
if (!row) {
return reply.code(404).send({
message: '작업 항목을 찾을 수 없습니다.',
});
}
const planLabel = formatPlanNotificationLabel(String(row.work_id), id);
await notifyPlanEvent(
id,
`[${planLabel}] 작업 재시작`,
row.worker_status === 'main반영대기' ? 'main 반영 재시도를 요청했습니다.' : 'release 반영 재시도를 요청했습니다.',
'plan-restarted',
);
return {
ok: true,
item: await getPlanItemById(id),
};
});
app.post('/api/plan/items/:id/actions/cancel-release', async (request, reply) => {
if (!requirePlanAccessToken(request, reply)) {
return;
}
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
const item = await getPlanItemById(id);
if (!item) {
return reply.code(404).send({
message: '작업 항목을 찾을 수 없습니다.',
});
}
try {
const env = getEnv();
const isReleaseMergeFailure = item.status === '작업완료' && item.workerStatus === 'release반영실패';
if (!isReleaseMergeFailure) {
await recreateReleaseBranchFromMain(
{
repoPath: env.PLAN_GIT_REPO_PATH,
releaseBranch: env.PLAN_RELEASE_BRANCH,
mainBranch: env.PLAN_MAIN_BRANCH,
},
String(item.releaseTarget ?? env.PLAN_RELEASE_BRANCH),
);
}
const result = await cancelPlanRelease(id);
return {
ok: true,
item: result?.item ?? (await getPlanItemById(id)),
message: result?.message,
};
} catch (error) {
return reply.code(409).send({
message: error instanceof Error ? error.message : 'release 작업취소 처리에 실패했습니다.',
});
}
});
app.post('/api/plan/items/:id/actions/request-main-merge', async (request, reply) => {
if (!requirePlanAccessToken(request, reply)) {
return;
}
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
const result = await requestPlanMainMerge(id);
if (!result) {
return reply.code(404).send({
message: '작업 항목을 찾을 수 없습니다.',
});
}
return {
ok: true,
item: result.item,
message: result.message,
};
});
app.get('/api/plan/items/:id/issues', async (request, reply) => {
if (!hasPlanAccess(request)) {
return reply.code(403).send({
message: '상세 조회 권한이 없습니다.',
});
}
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
const item = await getPlanItemById(id);
if (!item) {
return reply.code(404).send({
message: '작업 항목을 찾을 수 없습니다.',
});
}
const rows = await listPlanIssueHistories(id);
return {
items: rows.map(mapPlanIssueRow),
};
});
app.post('/api/plan/items/:id/issues/action', async (request, reply) => {
if (!requirePlanAccessToken(request, reply)) {
return;
}
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
const payload = issueActionSchema.parse(request.body ?? {});
const item = await getPlanItemById(id);
if (!item) {
return reply.code(404).send({
message: '작업 항목을 찾을 수 없습니다.',
});
}
try {
const row = await appendLatestIssueAction(id, payload.actionNote, payload.resolve);
const retryResult = await queuePlanRetryFromIssueAction(id, payload.actionNote, payload.retry);
if (payload.resolve) {
const planLabel = formatPlanNotificationLabel(String(item.workId), id);
await notifyPlanEvent(
id,
`[${planLabel}] 이슈 해결 처리`,
`${row.issue_tag} 이슈가 해결 처리되었습니다.`,
'issue-resolved',
);
}
if (shouldNotifyPlanRestart(retryResult)) {
const planLabel = formatPlanNotificationLabel(String(item.workId), id);
await notifyPlanEvent(
id,
`[${planLabel}] 작업 재시작`,
retryResult?.message ?? '작업 재시작을 요청했습니다.',
'plan-restarted',
);
}
return {
ok: true,
item: mapPlanIssueRow(row),
planItem: retryResult?.item ?? (await getPlanItemById(id)),
message: retryResult?.message,
};
} catch (error) {
return reply.code(409).send({
message: error instanceof Error ? error.message : '이슈 조치 기록 저장에 실패했습니다.',
});
}
});
app.get('/api/plan/items/:id/actions', async (request, reply) => {
if (!hasPlanAccess(request)) {
return reply.code(403).send({
message: '상세 조회 권한이 없습니다.',
});
}
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
const item = await getPlanItemById(id);
if (!item) {
return reply.code(404).send({
message: '작업 항목을 찾을 수 없습니다.',
});
}
const rows = await listPlanActionHistories(id);
return {
items: rows.map(mapPlanActionRow),
};
});
app.get('/api/plan/items/:id/source-works', async (request, reply) => {
if (!hasPlanAccess(request)) {
return reply.code(403).send({
message: '상세 조회 권한이 없습니다.',
});
}
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
const item = await getPlanItemById(id);
if (!item) {
return reply.code(404).send({
message: '작업 항목을 찾을 수 없습니다.',
});
}
const rows = await listPlanSourceWorkHistories(id);
return {
items: rows.map(mapPlanSourceWorkRow),
};
});
app.get('/api/plan/items/:id/source-works/:sourceWorkId', async (request, reply) => {
if (!hasPlanAccess(request)) {
return reply.code(403).send({
message: '상세 조회 권한이 없습니다.',
});
}
const params = z.object({
id: z.coerce.number().int().positive(),
sourceWorkId: z.coerce.number().int().positive(),
}).parse(request.params);
const row = await getPlanSourceWorkHistory(params.id, params.sourceWorkId);
if (!row) {
return reply.code(404).send({
message: '소스 작업 이력을 찾을 수 없습니다.',
});
}
return {
item: mapPlanSourceWorkRow(row),
};
});
app.post('/api/plan/items/:id/source-works', async (request, reply) => {
if (!requirePlanAccessToken(request, reply)) {
return;
}
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
const payload = createSourceWorkSchema.parse(request.body ?? {});
const item = await getPlanItemById(id);
if (!item) {
return reply.code(404).send({
message: '작업 항목을 찾을 수 없습니다.',
});
}
const row = await createPlanSourceWorkHistory(id, payload);
return {
ok: true,
item: mapPlanSourceWorkRow(row),
};
});
app.post('/api/plan/items/:id/actions/note', async (request, reply) => {
if (!requirePlanAccessToken(request, reply)) {
return;
}
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
const payload = actionNoteSchema.parse(request.body ?? {});
const item = await getPlanItemById(id);
if (!item) {
return reply.code(404).send({
message: '작업 항목을 찾을 수 없습니다.',
});
}
if (!item.startedAt) {
return reply.code(409).send({
message: '작업시작 이후부터 조치 이력을 기록할 수 있습니다.',
});
}
const row = await createPlanActionHistory(id, payload.actionType ?? '추가조치', payload.actionNote);
const releaseResumeResult = await resumePlanDevelopmentFromRelease(id, payload.actionNote);
const retryResult = releaseResumeResult?.message
? releaseResumeResult
: await queuePlanRetryFromFailure(id, payload.actionNote);
if (shouldNotifyPlanRestart(retryResult)) {
const planLabel = formatPlanNotificationLabel(String(item.workId), id);
await notifyPlanEvent(
id,
`[${planLabel}] 작업 재시작`,
retryResult?.message ?? '작업 재시작을 요청했습니다.',
'plan-restarted',
);
}
return {
ok: true,
item: mapPlanActionRow(row),
planItem: retryResult?.item ?? (await getPlanItemById(id)),
message: retryResult?.message,
};
});
}

View File

@@ -0,0 +1,17 @@
import type { FastifyInstance } from 'fastify';
import { db } from '../db/client.js';
export async function registerSchemaRoutes(app: FastifyInstance) {
app.get('/api/schema/tables', async () => {
const tables = await db('information_schema.tables')
.select('table_name', 'table_schema')
.where('table_type', 'BASE TABLE')
.whereNotIn('table_schema', ['pg_catalog', 'information_schema'])
.orderBy('table_schema')
.orderBy('table_name');
return {
items: tables,
};
});
}

View File

@@ -0,0 +1,54 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { env } from '../config/env.js';
import { listServerCommands, restartServerCommand, serverCommandKeys } from '../services/server-command-service.js';
const serverCommandParamSchema = z.object({
key: z.enum(serverCommandKeys),
});
function getRequestAccessToken(request: FastifyRequest) {
const tokenHeader = request.headers['x-access-token'];
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
}
function ensureAuthorized(request: FastifyRequest, reply: FastifyReply) {
if (getRequestAccessToken(request) === env.SERVER_COMMAND_ACCESS_TOKEN) {
return true;
}
reply.status(403);
void reply.send({
message: '권한 토큰이 필요합니다.',
});
return false;
}
export async function registerServerCommandRoutes(app: FastifyInstance) {
app.get('/api/server-commands', async (request, reply) => {
if (!ensureAuthorized(request, reply)) {
return;
}
return {
ok: true,
items: await listServerCommands(),
};
});
app.post('/api/server-commands/:key/actions/restart', async (request, reply) => {
if (!ensureAuthorized(request, reply)) {
return;
}
const { key } = serverCommandParamSchema.parse(request.params);
const result = await restartServerCommand(key);
return {
ok: true,
item: result.server,
commandOutput: result.commandOutput,
restartState: result.restartState,
};
});
}

View File

@@ -0,0 +1,129 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { hasErrorLogViewAccessToken } from '../services/error-log-service.js';
import {
ensureVisitorHistoryTables,
getVisitorClientByClientId,
listVisitorClients,
listVisitorClientsQuerySchema,
listVisitorHistories,
trackVisit,
trackVisitSchema,
updateVisitorNickname,
updateVisitorNicknameSchema,
visitorHistoryQuerySchema,
} from '../services/visitor-history-service.js';
export async function registerVisitorHistoryRoutes(app: FastifyInstance) {
function requireHistoryAccessToken(
request: { headers: Record<string, unknown> },
reply: Parameters<FastifyInstance['get']>[1] extends (request: any, reply: infer T) => any ? T : any,
) {
if (hasErrorLogViewAccessToken(request.headers['x-access-token'] as string | string[] | undefined)) {
return true;
}
void reply.code(403).send({
message: '권한 토큰이 등록된 사용자만 방문 이력을 조회할 수 있습니다.',
});
return false;
}
app.post('/api/history/setup', async () => {
await ensureVisitorHistoryTables();
return {
ok: true,
};
});
app.post('/api/history/track', async (request) => {
const bodyPayload =
request.body && typeof request.body === 'object'
? (request.body as Record<string, unknown>)
: {};
const clientIdFromHeader = String(request.headers['x-client-id'] ?? '').trim();
const payload = trackVisitSchema.parse({
clientId: clientIdFromHeader || bodyPayload.clientId,
url: bodyPayload.url,
eventType: bodyPayload.eventType,
userAgent:
typeof bodyPayload.userAgent === 'string'
? bodyPayload.userAgent
: String(request.headers['user-agent'] ?? ''),
});
const client = await trackVisit(payload, request.ip);
return {
ok: true,
client,
};
});
app.get('/api/history/visitors', async (request, reply) => {
if (!requireHistoryAccessToken(request, reply)) {
return;
}
const query = listVisitorClientsQuerySchema.parse(request.query ?? {});
const items = await listVisitorClients(query.limit ?? 100, {
search: query.search,
clientId: query.clientId,
nickname: query.nickname,
path: query.path,
visitedFrom: query.visitedFrom,
visitedTo: query.visitedTo,
});
return {
ok: true,
items,
};
});
app.get('/api/history/visitors/:clientId', async (request, reply) => {
if (!requireHistoryAccessToken(request, reply)) {
return;
}
const clientId = z.string().trim().min(1).parse((request.params as { clientId: string }).clientId);
const query = visitorHistoryQuerySchema.parse(request.query ?? {});
const client = await getVisitorClientByClientId(clientId);
if (!client) {
return reply.code(404).send({
message: '방문자를 찾을 수 없습니다.',
});
}
const visits = await listVisitorHistories(clientId, query.limit ?? 200);
return {
ok: true,
client,
visits,
};
});
app.patch('/api/history/visitors/:clientId/nickname', async (request, reply) => {
if (!requireHistoryAccessToken(request, reply)) {
return;
}
const clientId = z.string().trim().min(1).parse((request.params as { clientId: string }).clientId);
const payload = updateVisitorNicknameSchema.parse(request.body ?? {});
const client = await updateVisitorNickname(clientId, payload.nickname);
if (!client) {
return reply.code(404).send({
message: '닉네임을 수정할 방문자를 찾을 수 없습니다.',
});
}
return {
ok: true,
client,
};
});
}