feat: persist text memo notes in work server
This commit is contained in:
@@ -12,6 +12,7 @@ import { registerNotificationRoutes } from './routes/notification.js';
|
||||
import { registerPlanRoutes } from './routes/plan.js';
|
||||
import { registerServerCommandRoutes } from './routes/server-command.js';
|
||||
import { registerSchemaRoutes } from './routes/schema.js';
|
||||
import { registerTextMemoRoutes } from './routes/text-memo.js';
|
||||
import { registerVisitorHistoryRoutes } from './routes/visitor-history.js';
|
||||
import { shouldPersistNotFoundErrorLog } from './not-found.js';
|
||||
import { createErrorLog } from './services/error-log-service.js';
|
||||
@@ -37,6 +38,7 @@ export function createApp() {
|
||||
app.register(registerNotificationRoutes);
|
||||
app.register(registerPlanRoutes);
|
||||
app.register(registerServerCommandRoutes);
|
||||
app.register(registerTextMemoRoutes);
|
||||
app.register(registerVisitorHistoryRoutes);
|
||||
|
||||
app.setNotFoundHandler(async (request, reply) => {
|
||||
|
||||
78
etc/servers/work-server/src/routes/text-memo.ts
Normal file
78
etc/servers/work-server/src/routes/text-memo.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
createTextMemoNote,
|
||||
deleteTextMemoNote,
|
||||
importTextMemoNotes,
|
||||
listTextMemoNotes,
|
||||
textMemoNoteCreateSchema,
|
||||
textMemoNoteImportSchema,
|
||||
textMemoNoteUpdateSchema,
|
||||
updateTextMemoNote,
|
||||
} from '../services/text-memo-service.js';
|
||||
|
||||
function resolveClientId(headers: Record<string, unknown>) {
|
||||
return String(headers['x-client-id'] ?? '').trim();
|
||||
}
|
||||
|
||||
export async function registerTextMemoRoutes(app: FastifyInstance) {
|
||||
app.get('/api/text-memo/notes', async (request) => {
|
||||
const items = await listTextMemoNotes(resolveClientId(request.headers));
|
||||
return {
|
||||
ok: true,
|
||||
items,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/text-memo/notes', async (request) => {
|
||||
const payload = textMemoNoteCreateSchema.parse(request.body ?? {});
|
||||
const item = await createTextMemoNote(resolveClientId(request.headers), payload);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/text-memo/notes/import', async (request) => {
|
||||
const payload = textMemoNoteImportSchema.parse(request.body ?? {});
|
||||
const items = await importTextMemoNotes(resolveClientId(request.headers), payload);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
items,
|
||||
};
|
||||
});
|
||||
|
||||
app.put('/api/text-memo/notes/:noteId', async (request, reply) => {
|
||||
const noteId = z.string().trim().min(1).parse((request.params as { noteId: string }).noteId);
|
||||
const payload = textMemoNoteUpdateSchema.parse(request.body ?? {});
|
||||
const item = await updateTextMemoNote(resolveClientId(request.headers), noteId, payload);
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({
|
||||
message: '수정할 메모를 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item,
|
||||
};
|
||||
});
|
||||
|
||||
app.delete('/api/text-memo/notes/:noteId', async (request, reply) => {
|
||||
const noteId = z.string().trim().min(1).parse((request.params as { noteId: string }).noteId);
|
||||
const deleted = await deleteTextMemoNote(resolveClientId(request.headers), noteId);
|
||||
|
||||
if (!deleted) {
|
||||
return reply.code(404).send({
|
||||
message: '삭제할 메모를 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
286
etc/servers/work-server/src/services/text-memo-service.ts
Normal file
286
etc/servers/work-server/src/services/text-memo-service.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user