Initial commit: AI platform app (server, views, lib, data, deploy docs)
Made-with: Cursor
This commit is contained in:
323
lib/task-checklist-store.js
Normal file
323
lib/task-checklist-store.js
Normal file
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* 업무 체크리스트: 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,
|
||||
};
|
||||
Reference in New Issue
Block a user