/** * 업무 체크리스트: PostgreSQL 또는 data/meeting-ai-checklist.json 폴백 */ const path = require("path"); const fs = require("fs/promises"); const { v4: uuidv4 } = require("uuid"); const CHECKLIST_FILE = path.join(__dirname, "..", "data", "meeting-ai-checklist.json"); let writeChain = Promise.resolve(); function withFileLock(fn) { const p = writeChain.then(() => fn()); writeChain = p.catch(() => {}); return p; } function nowIso() { return new Date().toISOString(); } async function readFileStore() { try { const raw = await fs.readFile(CHECKLIST_FILE, "utf8"); const j = JSON.parse(raw); if (!j || typeof j !== "object") return { items: [] }; if (!Array.isArray(j.items)) j.items = []; return j; } catch { return { items: [] }; } } async function writeFileStore(data) { const dir = path.dirname(CHECKLIST_FILE); await fs.mkdir(dir, { recursive: true }); const tmp = `${CHECKLIST_FILE}.${process.pid}.tmp`; await fs.writeFile(tmp, JSON.stringify(data, null, 2), "utf8"); await fs.rename(tmp, CHECKLIST_FILE); } function rowToApi(row) { if (!row) return null; return { id: row.id, userEmail: row.user_email, meetingId: row.meeting_id || null, title: row.title || "", detail: row.detail || "", assignee: row.assignee || null, dueNote: row.due_note || null, completed: row.completed === true, completedAt: row.completed_at ? new Date(row.completed_at).toISOString() : null, completionNote: row.completion_note == null || !String(row.completion_note).trim() ? null : String(row.completion_note), sortOrder: Number(row.sort_order) || 0, source: row.source || "manual", createdAt: row.created_at ? new Date(row.created_at).toISOString() : nowIso(), updatedAt: row.updated_at ? new Date(row.updated_at).toISOString() : nowIso(), }; } /** * @param {import("pg").Pool | null} pgPool * @param {string} email * @param {{ completed?: boolean|null, meetingId?: string|null }} [filters] */ async function listItems(pgPool, email, filters = {}) { if (pgPool) { const params = [email]; let sql = `SELECT * FROM meeting_ai_checklist_items WHERE user_email = $1`; if (filters.completed === true) sql += ` AND completed = true`; else if (filters.completed === false) sql += ` AND completed = false`; if (filters.meetingId) { params.push(filters.meetingId); sql += ` AND meeting_id = $${params.length}::uuid`; } sql += ` ORDER BY completed ASC, sort_order ASC, updated_at DESC`; const r = await pgPool.query(sql, params); return (r.rows || []).map(rowToApi); } const data = await readFileStore(); let items = data.items.filter((x) => x.user_email === email); if (filters.completed === true) items = items.filter((x) => x.completed === true); else if (filters.completed === false) items = items.filter((x) => !x.completed); if (filters.meetingId) items = items.filter((x) => x.meeting_id === filters.meetingId); items.sort((a, b) => { if (a.completed !== b.completed) return a.completed ? 1 : -1; return new Date(b.updated_at) - new Date(a.updated_at); }); return items.map(rowToApi); } /** * @param {import("pg").Pool | null} pgPool */ async function insertItem(pgPool, email, fields) { const title = (fields.title || "").toString().trim().slice(0, 2000); if (!title) throw new Error("제목이 필요합니다."); const detail = (fields.detail || "").toString().slice(0, 8000); const assignee = fields.assignee != null ? String(fields.assignee).slice(0, 300) : null; const dueNote = fields.dueNote != null ? String(fields.dueNote).slice(0, 300) : null; const meetingId = fields.meetingId || null; const source = fields.source === "imported" ? "imported" : "manual"; const completed = fields.completed === true; const sortOrder = Number.isFinite(Number(fields.sortOrder)) ? Number(fields.sortOrder) : 0; const completionNote = fields.completionNote != null && String(fields.completionNote).trim() ? String(fields.completionNote).slice(0, 8000) : null; if (pgPool) { const r = await pgPool.query( `INSERT INTO meeting_ai_checklist_items ( user_email, meeting_id, title, detail, assignee, due_note, completed, completed_at, sort_order, source, completion_note ) VALUES ($1, $2::uuid, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`, [ email, meetingId, title, detail || null, assignee, dueNote, completed, completed ? new Date() : null, sortOrder, source, completionNote, ] ); return rowToApi(r.rows?.[0]); } return withFileLock(async () => { const data = await readFileStore(); const t = nowIso(); const row = { id: uuidv4(), user_email: email, meeting_id: meetingId, title, detail: detail || null, assignee, due_note: dueNote, completed, completed_at: completed ? t : null, completion_note: completionNote, sort_order: sortOrder, source, created_at: t, updated_at: t, }; data.items.push(row); await writeFileStore(data); return rowToApi(row); }); } /** * @param {import("pg").Pool | null} pgPool */ async function updateItem(pgPool, id, email, fields) { if (pgPool) { const cur = await pgPool.query( `SELECT * FROM meeting_ai_checklist_items WHERE id = $1::uuid AND user_email = $2`, [id, email] ); if (!cur.rows?.[0]) return null; const row = cur.rows[0]; const title = fields.title != null ? String(fields.title).trim().slice(0, 2000) : row.title; const detail = fields.detail !== undefined ? fields.detail != null && String(fields.detail).trim() ? String(fields.detail).slice(0, 8000) : null : row.detail; const assignee = fields.assignee !== undefined ? (fields.assignee ? String(fields.assignee).slice(0, 300) : null) : row.assignee; const dueNote = fields.dueNote !== undefined ? (fields.dueNote ? String(fields.dueNote).slice(0, 300) : null) : row.due_note; let completed = row.completed; let completedAt = row.completed_at; let completionNote = row.completion_note; if (fields.completed !== undefined) { completed = fields.completed === true; completedAt = completed ? new Date() : null; } if (fields.completionNote !== undefined) { completionNote = fields.completionNote != null && String(fields.completionNote).trim() ? String(fields.completionNote).slice(0, 8000) : null; } const r = await pgPool.query( `UPDATE meeting_ai_checklist_items SET title = $3, detail = $4, assignee = $5, due_note = $6, completed = $7, completed_at = $8, completion_note = $9, updated_at = NOW() WHERE id = $1::uuid AND user_email = $2 RETURNING *`, [id, email, title, detail, assignee, dueNote, completed, completedAt, completionNote] ); return rowToApi(r.rows?.[0]); } return withFileLock(async () => { const data = await readFileStore(); const idx = data.items.findIndex((x) => x.id === id && x.user_email === email); if (idx < 0) return null; const row = data.items[idx]; if (fields.title != null) row.title = String(fields.title).trim().slice(0, 2000); if (fields.detail !== undefined) { row.detail = fields.detail != null && String(fields.detail).trim() ? String(fields.detail).slice(0, 8000) : null; } if (fields.assignee !== undefined) row.assignee = fields.assignee ? String(fields.assignee).slice(0, 300) : null; if (fields.dueNote !== undefined) row.due_note = fields.dueNote ? String(fields.dueNote).slice(0, 300) : null; if (fields.completed !== undefined) { row.completed = fields.completed === true; row.completed_at = row.completed ? nowIso() : null; } if (fields.completionNote !== undefined) { row.completion_note = fields.completionNote != null && String(fields.completionNote).trim() ? String(fields.completionNote).slice(0, 8000) : null; } row.updated_at = nowIso(); await writeFileStore(data); return rowToApi(row); }); } /** * @param {import("pg").Pool | null} pgPool */ async function deleteItem(pgPool, id, email) { if (pgPool) { const r = await pgPool.query(`DELETE FROM meeting_ai_checklist_items WHERE id = $1::uuid AND user_email = $2 RETURNING id`, [ id, email, ]); return { deleted: !!r.rowCount }; } return withFileLock(async () => { const data = await readFileStore(); const idx = data.items.findIndex((x) => x.id === id && x.user_email === email); if (idx < 0) return { deleted: false }; data.items.splice(idx, 1); await writeFileStore(data); return { deleted: true }; }); } /** * 회의록 삭제 시 연동: 해당 회의에서 가져온 업무 체크리스트 항목 일괄 삭제 * @param {import("pg").Pool | null} pgPool * @param {string} meetingId * @param {string} email * @returns {Promise<{ removed: number }>} */ async function deleteItemsByMeetingId(pgPool, meetingId, email) { if (!meetingId) return { removed: 0 }; if (pgPool) { const r = await pgPool.query( `DELETE FROM meeting_ai_checklist_items WHERE meeting_id = $1::uuid AND user_email = $2`, [meetingId, email] ); return { removed: r.rowCount || 0 }; } return withFileLock(async () => { const data = await readFileStore(); const mid = String(meetingId); const before = data.items.length; data.items = data.items.filter((x) => !(String(x.meeting_id || "") === mid && x.user_email === email)); const removed = before - data.items.length; if (removed > 0) await writeFileStore(data); return { removed }; }); } /** * 회의록 본문에서 항목 파싱 후 삽입 (중복 title+meeting_id 스킵) * @param {import("pg").Pool | null} pgPool * @param {string} email * @param {string} meetingId * @param {Array<{ title: string, detail?: string, assignee?: string|null, due_note?: string|null, completed?: boolean }>} parsed */ async function insertImportedBatch(pgPool, email, meetingId, parsed) { const inserted = []; const existing = await listItems(pgPool, email, { meetingId }); const keys = new Set(existing.map((e) => `${(e.title || "").trim()}::${e.meetingId || ""}`)); let order = existing.length; for (const p of parsed) { const title = (p.title || "").trim(); if (!title) continue; const key = `${title}::${meetingId}`; if (keys.has(key)) continue; keys.add(key); const item = await insertItem(pgPool, email, { title, detail: p.detail || "", assignee: p.assignee || null, dueNote: p.due_note || null, meetingId, source: "imported", completed: !!p.completed, sortOrder: order++, }); inserted.push(item); } return inserted; } module.exports = { CHECKLIST_FILE, listItems, insertItem, updateItem, deleteItem, deleteItemsByMeetingId, insertImportedBatch, };