324 lines
11 KiB
JavaScript
324 lines
11 KiB
JavaScript
/**
|
|
* 업무 체크리스트: 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,
|
|
};
|