Initial import
This commit is contained in:
347
etc/servers/work-server/src/services/board-service.ts
Executable file
347
etc/servers/work-server/src/services/board-service.ts
Executable file
@@ -0,0 +1,347 @@
|
||||
import { z } from 'zod';
|
||||
import { db } from '../db/client.js';
|
||||
import {
|
||||
ensurePlanTable,
|
||||
normalizePlanAutomationType,
|
||||
PLAN_TABLE,
|
||||
planAutomationTypeSchema,
|
||||
} from './plan-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: normalizePlanAutomationType(row.automation_type),
|
||||
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) {
|
||||
const normalizedTitle = title.trim();
|
||||
const normalizedContent = content.trim();
|
||||
|
||||
return [
|
||||
'# 자동화 작업메모',
|
||||
'',
|
||||
`- 게시판 제목: ${normalizedTitle}`,
|
||||
'- 메모 출처: board_posts 자동화 접수',
|
||||
'',
|
||||
'## 요청 본문',
|
||||
normalizedContent,
|
||||
].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_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' });
|
||||
}
|
||||
|
||||
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 insertQuery = db(BOARD_POSTS_TABLE).insert({
|
||||
title: parsedPayload.title,
|
||||
content: parsedPayload.content,
|
||||
automation_type: parsedPayload.automationType,
|
||||
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 insertQuery = trx(PLAN_TABLE).insert({
|
||||
work_id: workId,
|
||||
note: buildBoardPostPlanNote(title, content),
|
||||
automation_type: normalizePlanAutomationType(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);
|
||||
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: parsedPayload.automationType,
|
||||
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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user