374 lines
13 KiB
TypeScript
Executable File
374 lines
13 KiB
TypeScript
Executable File
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<typeof boardPostPayloadSchema>['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<string, unknown>): 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<string, unknown>) {
|
|
return Boolean(row.automation_received_at || row.automation_plan_item_id);
|
|
}
|
|
|
|
export function buildBoardPostPlanNote(title: string, content: string, automationType?: Pick<AutomationTypeRecord, 'name' | 'description'> | 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<typeof boardPostPayloadSchema>) {
|
|
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<typeof boardPostPayloadSchema>) {
|
|
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;
|
|
});
|
|
}
|