Initial import
This commit is contained in:
48
etc/servers/work-server/src/routes/app-config.ts
Executable file
48
etc/servers/work-server/src/routes/app-config.ts
Executable 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 : '앱 설정 저장에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
172
etc/servers/work-server/src/routes/board.ts
Executable file
172
etc/servers/work-server/src/routes/board.ts
Executable 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
500
etc/servers/work-server/src/routes/chat.ts
Executable file
500
etc/servers/work-server/src/routes/chat.ts
Executable 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
91
etc/servers/work-server/src/routes/compatibility.test.ts
Executable file
91
etc/servers/work-server/src/routes/compatibility.test.ts
Executable 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'));
|
||||
});
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
119
etc/servers/work-server/src/routes/ddl.ts
Executable file
119
etc/servers/work-server/src/routes/ddl.ts
Executable 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
49
etc/servers/work-server/src/routes/error-log.ts
Executable file
49
etc/servers/work-server/src/routes/error-log.ts
Executable 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
13
etc/servers/work-server/src/routes/health.ts
Executable file
13
etc/servers/work-server/src/routes/health.ts
Executable 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);
|
||||
}
|
||||
207
etc/servers/work-server/src/routes/notification.ts
Executable file
207
etc/servers/work-server/src/routes/notification.ts
Executable 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;
|
||||
}
|
||||
981
etc/servers/work-server/src/routes/plan.ts
Executable file
981
etc/servers/work-server/src/routes/plan.ts
Executable 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
17
etc/servers/work-server/src/routes/schema.ts
Executable file
17
etc/servers/work-server/src/routes/schema.ts
Executable 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
54
etc/servers/work-server/src/routes/server-command.ts
Executable file
54
etc/servers/work-server/src/routes/server-command.ts
Executable 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
129
etc/servers/work-server/src/routes/visitor-history.ts
Executable file
129
etc/servers/work-server/src/routes/visitor-history.ts
Executable 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user