import { z } from 'zod'; import { db } from '../db/client.js'; import { ensurePlanTable, normalizePlanAutomationType, PLAN_TABLE, planAutomationTypeSchema, } from './plan-service.js'; import { resolveAutomationType, resolveStoredAutomationTypeId, type AutomationTypeRecord, } from './automation-type-config-service.js'; export const BOARD_POSTS_TABLE = 'board_posts'; export const boardPostPayloadSchema = z.object({ title: z.string().trim().min(1).max(200), content: z.string().min(1).max(200000), automationType: z.preprocess((value) => value, planAutomationTypeSchema.default('none')), }); export type BoardPostItem = { id: number; title: string; content: string; preview: string; automationType: z.infer['automationType']; automationPlanItemId: number | null; automationReceivedAt: string | null; createdAt: string; updatedAt: string; }; export class BoardPostAutomationLockedError extends Error { constructor(action: 'update' | 'delete') { super( action === 'delete' ? '자동화 접수된 작업메모는 삭제할 수 없습니다.' : '자동화 접수된 작업메모는 수정할 수 없습니다.', ); this.name = 'BoardPostAutomationLockedError'; } } function createPreview(content: string) { const normalized = content .replace(/```[\s\S]*?```/g, ' ') .replace(/!\[[^\]]*\]\([^)]+\)/g, ' ') .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') .replace(/[#>*_`~-]/g, ' ') .replace(/\s+/g, ' ') .trim(); return normalized.length > 140 ? `${normalized.slice(0, 137).trimEnd()}...` : normalized; } function mapBoardPostRow(row: Record): BoardPostItem { const content = String(row.content ?? ''); return { id: Number(row.id ?? 0), title: String(row.title ?? ''), content, preview: createPreview(content), automationType: resolveStoredAutomationTypeId(row), automationPlanItemId: row.automation_plan_item_id === null || row.automation_plan_item_id === undefined ? null : Number(row.automation_plan_item_id), automationReceivedAt: row.automation_received_at === null || row.automation_received_at === undefined ? null : String(row.automation_received_at), createdAt: String(row.created_at ?? ''), updatedAt: String(row.updated_at ?? ''), }; } function isBoardPostAutomationLocked(row: Record) { return Boolean(row.automation_received_at || row.automation_plan_item_id); } export function buildBoardPostPlanNote(title: string, content: string, automationType?: Pick | null) { const normalizedTitle = title.trim(); const normalizedContent = content.trim(); const normalizedAutomationTypeName = String(automationType?.name ?? '').trim(); const normalizedAutomationContext = String(automationType?.description ?? '').trim(); return [ '# 자동화 작업메모', '', `- 게시판 제목: ${normalizedTitle}`, '- 메모 출처: board_posts 자동화 접수', normalizedAutomationTypeName ? `- 선택 자동화 유형: ${normalizedAutomationTypeName}` : null, '- 자동화 처리 원칙: 아래 자동화 유형 context만 우선 참조하고, Codex Live 문맥은 섞지 않습니다.', '', '## 자동화 유형 context', normalizedAutomationContext || '선택된 자동화 유형 context 없음', '', '## 요청 본문', normalizedContent, ] .filter((line): line is string => line !== null) .join('\n'); } function resolveInsertedId(result: unknown): number | null { if (typeof result === 'number' && Number.isInteger(result) && result > 0) { return result; } if (Array.isArray(result)) { const first = result[0]; if (typeof first === 'number' && Number.isInteger(first) && first > 0) { return first; } if (first && typeof first === 'object' && 'id' in first) { const id = Number((first as { id?: unknown }).id); return Number.isInteger(id) && id > 0 ? id : null; } } if (result && typeof result === 'object' && 'id' in result) { const id = Number((result as { id?: unknown }).id); return Number.isInteger(id) && id > 0 ? id : null; } return null; } function supportsReturning() { const clientName = String(db.client.config.client ?? '').toLowerCase(); return ['pg', 'postgres', 'postgresql', 'sqlite3', 'better-sqlite3', 'oracledb', 'mssql'].includes(clientName); } function isDuplicateSchemaError(error: unknown, codes: string[], patterns: RegExp[]) { const candidate = error as { code?: unknown; message?: unknown }; const code = typeof candidate?.code === 'string' ? candidate.code : ''; const message = typeof candidate?.message === 'string' ? candidate.message : ''; return codes.includes(code) || patterns.some((pattern) => pattern.test(message)); } function isDuplicateTableError(error: unknown) { return isDuplicateSchemaError(error, ['42P07'], [/already exists/i]); } function isDuplicateColumnError(error: unknown) { return isDuplicateSchemaError(error, ['42701'], [/already exists/i, /duplicate column/i]); } export async function ensureBoardPostsTable() { const hasTable = await db.schema.hasTable(BOARD_POSTS_TABLE); if (!hasTable) { try { await db.schema.createTable(BOARD_POSTS_TABLE, (table) => { table.increments('id').primary(); table.string('title', 200).notNullable(); table.text('content').notNullable(); table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); }); } catch (error) { if (!isDuplicateTableError(error)) { throw error; } } } const requiredColumns: Array<[string, (table: any) => void]> = [ ['title', (table) => table.string('title', 200).notNullable().defaultTo('제목 없음')], ['content', (table) => table.text('content').notNullable().defaultTo('')], ['automation_type', (table) => table.string('automation_type', 40).notNullable().defaultTo('none')], ['automation_type_id', (table) => table.string('automation_type_id', 120).nullable()], ['automation_plan_item_id', (table) => table.integer('automation_plan_item_id').nullable()], ['automation_received_at', (table) => table.timestamp('automation_received_at', { useTz: true }).nullable()], ['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], ['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], ]; for (const [columnName, createColumn] of requiredColumns) { const hasColumn = await db.schema.hasColumn(BOARD_POSTS_TABLE, columnName); if (!hasColumn) { try { await db.schema.alterTable(BOARD_POSTS_TABLE, (table) => { createColumn(table); }); } catch (error) { if (!isDuplicateColumnError(error)) { throw error; } } } } await db(BOARD_POSTS_TABLE) .where({ automation_type: 'plan_registration' }) .update({ automation_type: 'plan' }); await db(BOARD_POSTS_TABLE) .where({ automation_type: 'general_development' }) .update({ automation_type: 'auto_worker' }); await db(BOARD_POSTS_TABLE) .whereNull('automation_type_id') .update({ automation_type_id: db.raw('automation_type'), }); } export async function listBoardPosts() { await ensureBoardPostsTable(); const rows = await db(BOARD_POSTS_TABLE).select('*').orderBy('updated_at', 'desc').orderBy('id', 'desc'); return rows.map((row) => mapBoardPostRow(row)); } export async function getBoardPost(id: number) { await ensureBoardPostsTable(); const row = await db(BOARD_POSTS_TABLE).where({ id }).first(); return row ? mapBoardPostRow(row) : null; } export async function createBoardPost(payload: z.infer) { await ensureBoardPostsTable(); const parsedPayload = boardPostPayloadSchema.parse(payload); const automationType = await resolveAutomationType(parsedPayload.automationType); const insertQuery = db(BOARD_POSTS_TABLE).insert({ title: parsedPayload.title, content: parsedPayload.content, automation_type: automationType.behaviorType, automation_type_id: automationType.id, created_at: db.fn.now(), updated_at: db.fn.now(), }); const insertResult = supportsReturning() ? await insertQuery.returning('id') : await insertQuery; const insertedId = resolveInsertedId(insertResult); if (!insertedId) { throw new Error('게시글 저장 후 ID를 확인하지 못했습니다.'); } const row = await db(BOARD_POSTS_TABLE).where({ id: insertedId }).first(); if (!row) { throw new Error('저장된 게시글을 다시 불러오지 못했습니다.'); } return mapBoardPostRow(row); } export async function receiveBoardPostAutomation(id: number) { await ensureBoardPostsTable(); await ensurePlanTable(); return db.transaction(async (trx) => { const currentRow = await trx(BOARD_POSTS_TABLE).where({ id }).forUpdate().first(); if (!currentRow) { return null; } if (currentRow.automation_received_at || currentRow.automation_plan_item_id) { return { item: mapBoardPostRow(currentRow), planItemId: currentRow.automation_plan_item_id === null || currentRow.automation_plan_item_id === undefined ? null : Number(currentRow.automation_plan_item_id), alreadyReceived: true, }; } const title = String(currentRow.title ?? '').trim(); const content = String(currentRow.content ?? '').trim(); const workId = `board-post-${id}`; const automationType = await resolveAutomationType(currentRow.automation_type_id ?? currentRow.automation_type); const insertQuery = trx(PLAN_TABLE).insert({ work_id: workId, note: buildBoardPostPlanNote(title, content, automationType), automation_type: normalizePlanAutomationType(currentRow.automation_type), automation_type_id: currentRow.automation_type_id ?? currentRow.automation_type, status: '등록', release_target: 'release', jangsing_processing_required: true, auto_deploy_to_main: false, worker_status: '대기', last_error: null, updated_at: trx.fn.now(), }); const insertResult = supportsReturning() ? await insertQuery.returning('id') : await insertQuery; const planItemId = resolveInsertedId(insertResult); if (!planItemId) { throw new Error('자동화 접수 후 Plan ID를 확인하지 못했습니다.'); } const updateQuery = trx(BOARD_POSTS_TABLE) .where({ id }) .update({ automation_plan_item_id: planItemId, automation_received_at: trx.fn.now(), updated_at: trx.fn.now(), }); const updatedRows = supportsReturning() ? await updateQuery.returning('*') : []; if (!supportsReturning()) { await updateQuery; } const updatedRow = updatedRows[0] ?? (await trx(BOARD_POSTS_TABLE).where({ id }).first()); if (!updatedRow) { throw new Error('자동화 접수된 게시글을 다시 불러오지 못했습니다.'); } return { item: mapBoardPostRow(updatedRow), planItemId, alreadyReceived: false, }; }); } export async function updateBoardPost(id: number, payload: z.infer) { await ensureBoardPostsTable(); const parsedPayload = boardPostPayloadSchema.parse(payload); const automationType = await resolveAutomationType(parsedPayload.automationType); return db.transaction(async (trx) => { const currentRow = await trx(BOARD_POSTS_TABLE).where({ id }).forUpdate().first(); if (!currentRow) { return null; } if (isBoardPostAutomationLocked(currentRow)) { throw new BoardPostAutomationLockedError('update'); } await trx(BOARD_POSTS_TABLE) .where({ id }) .update({ title: parsedPayload.title, content: parsedPayload.content, automation_type: automationType.behaviorType, automation_type_id: automationType.id, updated_at: trx.fn.now(), }); const row = await trx(BOARD_POSTS_TABLE).where({ id }).first(); return row ? mapBoardPostRow(row) : null; }); } export async function deleteBoardPost(id: number) { await ensureBoardPostsTable(); return db.transaction(async (trx) => { const currentRow = await trx(BOARD_POSTS_TABLE).where({ id }).forUpdate().first(); if (!currentRow) { return false; } if (isBoardPostAutomationLocked(currentRow)) { throw new BoardPostAutomationLockedError('delete'); } const deletedCount = await trx(BOARD_POSTS_TABLE).where({ id }).del(); return deletedCount > 0; }); }