Initial import

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

View File

@@ -0,0 +1,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;
});
}