/** * 회의록 AI: PostgreSQL 또는 data/meeting-ai.json 폴백 */ const path = require("path"); const fs = require("fs/promises"); const { v4: uuidv4 } = require("uuid"); const { extractMeetingSummary } = require("./meeting-minutes-summary"); const { formatMeetingDateOnly } = require("./meeting-date-format"); const taskChecklistStore = require("./task-checklist-store"); const MEETING_AI_FILE = path.join(__dirname, "..", "data", "meeting-ai.json"); const defaultPromptRow = (email) => ({ user_email: email, include_title_line: true, include_attendees: true, include_summary: true, include_action_items: true, include_checklist: true, custom_instructions: null, }); let writeChain = Promise.resolve(); function withFileLock(fn) { const p = writeChain.then(() => fn()); writeChain = p.catch(() => {}); return p; } async function readFileStore() { try { const raw = await fs.readFile(MEETING_AI_FILE, "utf8"); const j = JSON.parse(raw); if (!j || typeof j !== "object") return { prompts: {}, meetings: [] }; if (!j.prompts || typeof j.prompts !== "object") j.prompts = {}; if (!Array.isArray(j.meetings)) j.meetings = []; return j; } catch { return { prompts: {}, meetings: [] }; } } async function writeFileStore(data) { const dir = path.dirname(MEETING_AI_FILE); await fs.mkdir(dir, { recursive: true }); const tmp = `${MEETING_AI_FILE}.${process.pid}.tmp`; await fs.writeFile(tmp, JSON.stringify(data, null, 2), "utf8"); await fs.rename(tmp, MEETING_AI_FILE); } function nowIso() { return new Date().toISOString(); } function rowToMeeting(row) { return { ...row, created_at: row.created_at || nowIso(), updated_at: row.updated_at || nowIso(), }; } /** * @param {import("pg").Pool | null} pgPool * @param {string} email */ async function ensureUserAndDefaultPrompt(pgPool, email) { if (pgPool) { await pgPool.query(`INSERT INTO meeting_ai_users (email) VALUES ($1) ON CONFLICT (email) DO NOTHING`, [email]); await pgPool.query( `INSERT INTO meeting_ai_prompts (user_email, include_title_line, include_attendees, include_summary, include_action_items, include_checklist) VALUES ($1, true, true, true, true, true) ON CONFLICT (user_email) DO NOTHING`, [email] ); return; } await withFileLock(async () => { const data = await readFileStore(); if (!data.prompts[email]) { data.prompts[email] = defaultPromptRow(email); await writeFileStore(data); } }); } /** * @param {import("pg").Pool | null} pgPool * @param {string} email */ async function getPromptRow(pgPool, email) { if (pgPool) { const r = await pgPool.query(`SELECT * FROM meeting_ai_prompts WHERE user_email = $1`, [email]); return r.rows?.[0] || null; } const data = await readFileStore(); const p = data.prompts[email]; if (!p) return null; return { id: p.id || null, user_email: email, include_title_line: p.include_title_line !== false, include_attendees: p.include_attendees !== false, include_summary: p.include_summary !== false, include_action_items: p.include_action_items !== false, include_checklist: true, custom_instructions: p.custom_instructions, created_at: p.created_at || null, updated_at: p.updated_at || null, }; } /** * @param {import("pg").Pool | null} pgPool */ async function upsertPrompt(pgPool, email, fields) { if (pgPool) { await pgPool.query( `INSERT INTO meeting_ai_prompts ( user_email, include_title_line, include_attendees, include_summary, include_action_items, include_checklist, custom_instructions ) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (user_email) DO UPDATE SET include_title_line = EXCLUDED.include_title_line, include_attendees = EXCLUDED.include_attendees, include_summary = EXCLUDED.include_summary, include_action_items = EXCLUDED.include_action_items, include_checklist = EXCLUDED.include_checklist, custom_instructions = EXCLUDED.custom_instructions, updated_at = NOW()`, [ email, fields.includeTitleLine, fields.includeAttendees, fields.includeSummary, fields.includeActionItems, true, fields.customInstructions || null, ] ); const r = await pgPool.query(`SELECT * FROM meeting_ai_prompts WHERE user_email = $1`, [email]); return r.rows?.[0] || null; } return withFileLock(async () => { const data = await readFileStore(); const t = nowIso(); const prev = data.prompts[email] || defaultPromptRow(email); data.prompts[email] = { ...prev, include_title_line: fields.includeTitleLine, include_attendees: fields.includeAttendees, include_summary: fields.includeSummary, include_action_items: fields.includeActionItems, include_checklist: true, custom_instructions: fields.customInstructions || null, updated_at: t, created_at: prev.created_at || t, }; await writeFileStore(data); return await getPromptRow(null, email); }); } /** * @param {import("pg").Pool | null} pgPool */ async function listMeetings(pgPool, email) { if (pgPool) { const r = await pgPool.query( `SELECT id, user_email, title, source_text, transcript_text, generated_minutes, summary_text, audio_file_path, audio_original_name, chat_model, transcription_model, meeting_date, created_at, updated_at FROM meeting_ai_meetings WHERE user_email = $1 ORDER BY created_at DESC LIMIT 200`, [email] ); return r.rows || []; } const data = await readFileStore(); return data.meetings .filter((m) => m.user_email === email) .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)) .slice(0, 200); } /** * @param {import("pg").Pool | null} pgPool */ async function getMeeting(pgPool, id, email) { if (pgPool) { const r = await pgPool.query(`SELECT * FROM meeting_ai_meetings WHERE id = $1::uuid AND user_email = $2`, [id, email]); return r.rows?.[0] || null; } const data = await readFileStore(); return data.meetings.find((m) => m.id === id && m.user_email === email) || null; } /** * @param {import("pg").Pool | null} pgPool * @returns {Promise<{ row: object | null, audio_file_path?: string }>} */ async function deleteMeeting(pgPool, id, email) { await taskChecklistStore.deleteItemsByMeetingId(pgPool, id, email); if (pgPool) { const prev = await pgPool.query( `SELECT audio_file_path FROM meeting_ai_meetings WHERE id = $1::uuid AND user_email = $2`, [id, email] ); const row = prev.rows?.[0]; const del = await pgPool.query(`DELETE FROM meeting_ai_meetings WHERE id = $1::uuid AND user_email = $2 RETURNING id`, [ id, email, ]); return { deleted: !!del.rowCount, audio_file_path: row?.audio_file_path }; } return withFileLock(async () => { const data = await readFileStore(); const idx = data.meetings.findIndex((m) => m.id === id && m.user_email === email); if (idx < 0) return { deleted: false, audio_file_path: null }; const audio = data.meetings[idx].audio_file_path; data.meetings.splice(idx, 1); await writeFileStore(data); return { deleted: true, audio_file_path: audio }; }); } /** * @param {import("pg").Pool | null} pgPool */ async function insertMeetingText(pgPool, { email, title, sourceText, generated, model, meetingDate }) { const summaryText = extractMeetingSummary(generated || "", 1200); if (pgPool) { const ins = await pgPool.query( `INSERT INTO meeting_ai_meetings (user_email, title, source_text, transcript_text, generated_minutes, summary_text, chat_model, meeting_date) VALUES ($1, $2, $3, NULL, $4, $5, $6, $7) RETURNING *`, [email, title || "제목 없음", sourceText, generated, summaryText || null, model, meetingDate || null] ); return ins.rows?.[0]; } return withFileLock(async () => { const data = await readFileStore(); const t = nowIso(); const row = rowToMeeting({ id: uuidv4(), user_email: email, title: title || "제목 없음", source_text: sourceText, transcript_text: null, generated_minutes: generated, summary_text: summaryText || null, audio_file_path: null, audio_original_name: null, chat_model: model, transcription_model: null, meeting_date: meetingDate || null, created_at: t, updated_at: t, }); data.meetings.push(row); await writeFileStore(data); return row; }); } /** * @param {import("pg").Pool | null} pgPool */ async function insertMeetingAudio(pgPool, { email, title, transcript, generated, relPath, originalName, model, whisperModel, meetingDate }) { const summaryText = extractMeetingSummary(generated || "", 1200); if (pgPool) { const ins = await pgPool.query( `INSERT INTO meeting_ai_meetings (user_email, title, source_text, transcript_text, generated_minutes, summary_text, audio_file_path, audio_original_name, chat_model, transcription_model, meeting_date) VALUES ($1, $2, NULL, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *`, [ email, title || "제목 없음", transcript, generated, summaryText || null, relPath, (originalName || "").slice(0, 500), model, whisperModel, meetingDate || null, ] ); return ins.rows?.[0]; } return withFileLock(async () => { const data = await readFileStore(); const t = nowIso(); const row = rowToMeeting({ id: uuidv4(), user_email: email, title: title || "제목 없음", source_text: null, transcript_text: transcript, generated_minutes: generated, summary_text: summaryText || null, audio_file_path: relPath, audio_original_name: (originalName || "").slice(0, 500), chat_model: model, transcription_model: whisperModel, meeting_date: meetingDate || null, created_at: t, updated_at: t, }); data.meetings.push(row); await writeFileStore(data); return row; }); } /** * 회의록에 체크리스트 추출 JSON 스냅샷 저장 (업무 체크리스트 자동 연동) * @param {import("pg").Pool | null} pgPool * @param {string} meetingId * @param {string} email * @param {object} snapshotObj */ function formatMeetingDateIso(d) { return formatMeetingDateOnly(d); } /** * 업무 체크리스트 툴팁용: 회의 제목·일자·요약 (회의 id 목록) * @param {import("pg").Pool | null} pgPool * @param {string} email * @param {string[]} ids * @returns {Promise>} */ async function getMeetingMetaForIds(pgPool, email, ids) { const uid = [...new Set(ids.map((x) => String(x).trim()).filter(Boolean))]; const map = new Map(); if (uid.length === 0) return map; if (pgPool) { const r = await pgPool.query( `SELECT id, title, meeting_date, summary_text, generated_minutes FROM meeting_ai_meetings WHERE user_email = $1 AND id = ANY($2::uuid[])`, [email, uid] ); for (const row of r.rows || []) { const id = String(row.id); const raw = row.summary_text && String(row.summary_text).trim(); const summary = raw || extractMeetingSummary(row.generated_minutes || "", 800); map.set(id, { meetingTitle: (row.title && String(row.title).trim()) || "제목 없음", meetingDate: formatMeetingDateIso(row.meeting_date), meetingSummary: summary || "", }); } return map; } const data = await readFileStore(); for (const m of data.meetings || []) { if (m.user_email !== email) continue; const id = String(m.id); if (!uid.includes(id)) continue; const raw = m.summary_text && String(m.summary_text).trim(); const summary = raw || extractMeetingSummary(m.generated_minutes || "", 800); map.set(id, { meetingTitle: (m.title && String(m.title).trim()) || "제목 없음", meetingDate: formatMeetingDateIso(m.meeting_date), meetingSummary: summary || "", }); } return map; } /** * 회의록·전사/원문 수동 수정 저장 (요약은 generated_minutes 기준 재계산) * @param {import("pg").Pool | null} pgPool * @param {string} meetingId * @param {string} email * @param {{ generatedMinutes: string, transcriptText?: string, sourceText?: string }} fields */ async function updateMeetingContent(pgPool, meetingId, email, fields) { const gen = (fields.generatedMinutes ?? "").toString(); const summaryText = extractMeetingSummary(gen, 1200); const hasT = Object.prototype.hasOwnProperty.call(fields, "transcriptText"); const hasS = Object.prototype.hasOwnProperty.call(fields, "sourceText"); if (pgPool) { const params = [meetingId, email, gen, summaryText || null]; let extra = ""; if (hasT) { params.push(fields.transcriptText ?? ""); extra += `, transcript_text = $${params.length}`; } if (hasS) { params.push(fields.sourceText ?? ""); extra += `, source_text = $${params.length}`; } const r = await pgPool.query( `UPDATE meeting_ai_meetings SET generated_minutes = $3, summary_text = $4${extra}, updated_at = NOW() WHERE id = $1::uuid AND user_email = $2 RETURNING *`, params ); return r.rows?.[0] || null; } return withFileLock(async () => { const data = await readFileStore(); const m = data.meetings.find((x) => x.id === meetingId && x.user_email === email); if (!m) return null; m.generated_minutes = gen; m.summary_text = summaryText || null; if (hasT) m.transcript_text = fields.transcriptText ?? ""; if (hasS) m.source_text = fields.sourceText ?? ""; m.updated_at = nowIso(); await writeFileStore(data); return m; }); } /** * @param {import("pg").Pool | null} pgPool * @param {string} meetingId * @param {string} email * @param {string} generatedMinutes */ async function updateMeetingGeneratedMinutes(pgPool, meetingId, email, generatedMinutes) { return updateMeetingContent(pgPool, meetingId, email, { generatedMinutes }); } async function updateMeetingChecklistSnapshot(pgPool, meetingId, email, snapshotObj) { if (pgPool) { await pgPool.query( `UPDATE meeting_ai_meetings SET checklist_snapshot = $3::jsonb, updated_at = NOW() WHERE id = $1::uuid AND user_email = $2`, [meetingId, email, JSON.stringify(snapshotObj)] ); return; } await withFileLock(async () => { const data = await readFileStore(); const m = data.meetings.find((x) => x.id === meetingId && x.user_email === email); if (m) { m.checklist_snapshot = snapshotObj; m.updated_at = nowIso(); } await writeFileStore(data); }); } module.exports = { MEETING_AI_FILE, ensureUserAndDefaultPrompt, getPromptRow, upsertPrompt, listMeetings, getMeeting, deleteMeeting, insertMeetingText, insertMeetingAudio, updateMeetingChecklistSnapshot, updateMeetingContent, updateMeetingGeneratedMinutes, getMeetingMetaForIds, };