Initial commit: AI platform app (server, views, lib, data, deploy docs)
Made-with: Cursor
This commit is contained in:
458
lib/meeting-minutes-store.js
Normal file
458
lib/meeting-minutes-store.js
Normal file
@@ -0,0 +1,458 @@
|
||||
/**
|
||||
* 회의록 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<Map<string, { meetingTitle: string, meetingDate: string|null, meetingSummary: string }>>}
|
||||
*/
|
||||
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,
|
||||
};
|
||||
Reference in New Issue
Block a user