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): 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) { 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, ) { 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, ) { 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); }