Files
ai-code-app/etc/servers/work-server/src/services/text-memo-service.ts

287 lines
7.7 KiB
TypeScript

import { z } from 'zod';
import { db } from '../db/client.js';
const TEXT_MEMO_TABLE = 'text_memo_notes';
const MAX_NOTE_COUNT = 12;
const MAX_BODY_LENGTH = 1200;
const CLIENT_ID_MAX_LENGTH = 120;
const NOTE_ID_MAX_LENGTH = 120;
const memoBodySchema = z.string().trim().min(1).max(MAX_BODY_LENGTH);
const memoTimestampSchema = z.string().datetime({ offset: true }).optional();
export const textMemoNoteCreateSchema = z.object({
id: z.string().trim().min(1).max(NOTE_ID_MAX_LENGTH).optional(),
body: memoBodySchema,
createdAt: memoTimestampSchema,
updatedAt: memoTimestampSchema,
});
export const textMemoNoteImportSchema = z.object({
notes: z.array(
z.object({
id: z.string().trim().min(1).max(NOTE_ID_MAX_LENGTH),
body: memoBodySchema,
createdAt: z.string().datetime({ offset: true }),
}),
).max(MAX_NOTE_COUNT),
});
export const textMemoNoteUpdateSchema = z.object({
body: memoBodySchema,
});
export type TextMemoNoteItem = {
id: string;
body: string;
createdAt: string;
updatedAt: string;
};
function normalizeClientId(value: unknown) {
return String(value ?? '').trim().slice(0, CLIENT_ID_MAX_LENGTH);
}
function normalizeIsoDate(value: string | undefined, fallback: string) {
if (!value) {
return fallback;
}
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? fallback : new Date(parsed).toISOString();
}
function generateNoteId() {
return `memo-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
function mapTextMemoRow(row: Record<string, unknown>): TextMemoNoteItem {
return {
id: String(row.note_id ?? ''),
body: String(row.body ?? ''),
createdAt: new Date(String(row.created_at ?? '')).toISOString(),
updatedAt: new Date(String(row.updated_at ?? '')).toISOString(),
};
}
async function trimClientNotes(clientId: string) {
const rows = await db(TEXT_MEMO_TABLE)
.select('note_id')
.where({ client_id: clientId })
.orderBy('updated_at', 'desc')
.orderBy('id', 'desc')
.offset(MAX_NOTE_COUNT);
const deleteIds = rows
.map((row) => String(row.note_id ?? '').trim())
.filter(Boolean);
if (deleteIds.length === 0) {
return;
}
await db(TEXT_MEMO_TABLE)
.where({ client_id: clientId })
.whereIn('note_id', deleteIds)
.delete();
}
export async function ensureTextMemoTable() {
const hasTable = await db.schema.hasTable(TEXT_MEMO_TABLE);
if (!hasTable) {
await db.schema.createTable(TEXT_MEMO_TABLE, (table) => {
table.increments('id').primary();
table.string('client_id', CLIENT_ID_MAX_LENGTH).notNullable().index();
table.string('note_id', NOTE_ID_MAX_LENGTH).notNullable();
table.text('body').notNullable();
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
table.unique(['client_id', 'note_id']);
});
return;
}
const requiredColumns: Array<[string, (table: any) => void]> = [
['client_id', (table) => table.string('client_id', CLIENT_ID_MAX_LENGTH).notNullable().index()],
['note_id', (table) => table.string('note_id', NOTE_ID_MAX_LENGTH).notNullable()],
['body', (table) => table.text('body').notNullable().defaultTo('')],
['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(TEXT_MEMO_TABLE, columnName);
if (!hasColumn) {
await db.schema.alterTable(TEXT_MEMO_TABLE, (table) => {
createColumn(table);
});
}
}
}
export async function listTextMemoNotes(rawClientId: unknown) {
const clientId = normalizeClientId(rawClientId);
if (!clientId) {
throw new Error('메모를 조회할 clientId가 비어 있습니다.');
}
await ensureTextMemoTable();
const rows = await db(TEXT_MEMO_TABLE)
.select('*')
.where({ client_id: clientId })
.orderBy('updated_at', 'desc')
.orderBy('id', 'desc')
.limit(MAX_NOTE_COUNT);
return rows.map((row) => mapTextMemoRow(row));
}
export async function createTextMemoNote(rawClientId: unknown, payload: z.infer<typeof textMemoNoteCreateSchema>) {
const clientId = normalizeClientId(rawClientId);
if (!clientId) {
throw new Error('메모를 저장할 clientId가 비어 있습니다.');
}
await ensureTextMemoTable();
const now = new Date().toISOString();
const createdAt = normalizeIsoDate(payload.createdAt, now);
const updatedAt = normalizeIsoDate(payload.updatedAt, createdAt);
const noteId = payload.id?.trim() || generateNoteId();
await db(TEXT_MEMO_TABLE)
.insert({
client_id: clientId,
note_id: noteId,
body: payload.body,
created_at: createdAt,
updated_at: updatedAt,
});
await trimClientNotes(clientId);
const row = await db(TEXT_MEMO_TABLE)
.select('*')
.where({
client_id: clientId,
note_id: noteId,
})
.first();
if (!row) {
throw new Error('저장된 메모를 다시 불러오지 못했습니다.');
}
return mapTextMemoRow(row);
}
export async function updateTextMemoNote(
rawClientId: unknown,
rawNoteId: unknown,
payload: z.infer<typeof textMemoNoteUpdateSchema>,
) {
const clientId = normalizeClientId(rawClientId);
const noteId = String(rawNoteId ?? '').trim().slice(0, NOTE_ID_MAX_LENGTH);
if (!clientId || !noteId) {
throw new Error('수정할 메모 식별자가 올바르지 않습니다.');
}
await ensureTextMemoTable();
const affected = await db(TEXT_MEMO_TABLE)
.where({
client_id: clientId,
note_id: noteId,
})
.update({
body: payload.body,
updated_at: new Date().toISOString(),
});
if (!affected) {
return null;
}
const row = await db(TEXT_MEMO_TABLE)
.select('*')
.where({
client_id: clientId,
note_id: noteId,
})
.first();
return row ? mapTextMemoRow(row) : null;
}
export async function deleteTextMemoNote(rawClientId: unknown, rawNoteId: unknown) {
const clientId = normalizeClientId(rawClientId);
const noteId = String(rawNoteId ?? '').trim().slice(0, NOTE_ID_MAX_LENGTH);
if (!clientId || !noteId) {
throw new Error('삭제할 메모 식별자가 올바르지 않습니다.');
}
await ensureTextMemoTable();
const affected = await db(TEXT_MEMO_TABLE)
.where({
client_id: clientId,
note_id: noteId,
})
.delete();
return affected > 0;
}
export async function importTextMemoNotes(
rawClientId: unknown,
payload: z.infer<typeof textMemoNoteImportSchema>,
) {
const clientId = normalizeClientId(rawClientId);
if (!clientId) {
throw new Error('메모를 가져올 clientId가 비어 있습니다.');
}
await ensureTextMemoTable();
const existingCountRow = await db(TEXT_MEMO_TABLE)
.where({ client_id: clientId })
.count<{ count: number | string }[]>({ count: '*' })
.first();
const existingCount = Number(existingCountRow?.count ?? 0);
if (existingCount > 0) {
return listTextMemoNotes(clientId);
}
const sortedNotes = [...payload.notes]
.sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt))
.slice(0, MAX_NOTE_COUNT);
if (sortedNotes.length === 0) {
return [];
}
await db.transaction(async (trx) => {
for (const note of sortedNotes) {
await trx(TEXT_MEMO_TABLE).insert({
client_id: clientId,
note_id: note.id,
body: note.body,
created_at: note.createdAt,
updated_at: note.createdAt,
});
}
});
return listTextMemoNotes(clientId);
}