287 lines
7.7 KiB
TypeScript
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);
|
|
}
|