Files
ai_platform/lib/task-checklist-store.js

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,
};