diff --git a/connector/src/library.js b/connector/src/library.js index c1561a8..eb1eb70 100644 --- a/connector/src/library.js +++ b/connector/src/library.js @@ -53,13 +53,13 @@ function validateFiles(format, files) { if (format === "CAROUSEL" && (files.length < 2 || files.length > 10)) throw err("A carousel needs 2–10 files."); } -export function createItem({ client, title, format = "IMAGE", files = [], captionIdea = "", notes = "", taskRef = "", forDate = "", uploadedBy = "team" }) { +export function createItem({ client, title, format = "IMAGE", files = [], captionIdea = "", notes = "", taskRef = "", forDate = "", uploadedBy = "team", planItem = "" }) { checkClient(client); if (!FORMATS.includes(format)) throw err("Unknown format."); validateFiles(format, files); const item = { id: crypto.randomUUID().slice(0, 8), client, title: String(title || "").trim() || path.basename(files[0]).replace(/^\d+-\w{4}-/, ""), - format, files, captionIdea, notes, taskRef, forDate, status: "submitted", version: 1, + format, files, captionIdea, notes, taskRef, forDate, planItem: String(planItem || ""), status: "submitted", version: 1, uploadedBy, createdAt: now(), updatedAt: now(), history: [{ at: now(), by: uploadedBy, action: "submitted" }], postIds: [], }; const all = read(); all.push(item); write(all); return item; diff --git a/connector/src/plan.js b/connector/src/plan.js index 98b4eea..56e39bd 100644 --- a/connector/src/plan.js +++ b/connector/src/plan.js @@ -34,7 +34,7 @@ function clean(patch) { if ("status" in patch) { if (!STAGES.includes(patch.status)) throw err(`Status must be one of: ${STAGES.join(", ")}.`); out.status = patch.status; } if ("format" in patch) { if (!FORMATS.includes(patch.format)) throw err(`Format must be one of: ${FORMATS.join(", ")}.`); out.format = patch.format; } if ("platforms" in patch) out.platforms = (patch.platforms || []).map(String).slice(0, 10); - for (const k of ["brief", "caption", "pillar", "assignee", "notes"]) if (k in patch) out[k] = String(patch[k] ?? "").slice(0, 5000); + for (const k of ["brief", "caption", "pillar", "assignee", "notes", "taskRef", "libraryItem"]) if (k in patch) out[k] = String(patch[k] ?? "").slice(0, 5000); return out; } @@ -81,12 +81,18 @@ export function calendar({ client, from, to }) { checkClient(client); if (!DATE_RE.test(from) || !DATE_RE.test(to)) throw err("from and to must be YYYY-MM-DD."); const tz = sched.clientInfo(client).timezone; - const out = listItems({ client, from, to }).map((i) => ({ ...i, kind: "plan", stage: i.status, editable: true })); + const libItems = lib.listItems({ client }); + const activeLib = new Set(libItems.filter((l) => l.status !== "archived").map((l) => l.id)); + // A plan item whose work was uploaded shows as the library card (with the plan's title) instead of twice. + const plans = listItems({ client, from, to }); + const planById = Object.fromEntries(listItems({ client }).map((i) => [i.id, i])); + const out = plans.filter((i) => !(i.libraryItem && activeLib.has(i.libraryItem))).map((i) => ({ ...i, kind: "plan", stage: i.status, editable: true })); - for (const l of lib.listItems({ client })) { + for (const l of libItems) { if (!l.forDate || l.forDate < from || l.forDate > to || ["scheduled", "archived"].includes(l.status)) continue; out.push({ - id: l.id, kind: "library", client, title: l.title, date: l.forDate, time: "", format: l.format, platforms: [], + id: l.id, kind: "library", client, title: planById[l.planItem]?.title || l.title, date: l.forDate, time: planById[l.planItem]?.time || "", format: l.format, platforms: planById[l.planItem]?.platforms || [], + assignee: planById[l.planItem]?.assignee || l.uploadedBy, planItem: l.planItem || null, stage: l.status === "approved" ? "ready" : "production", detail: l.status === "changes" ? "Changes requested" : l.status === "submitted" ? "Waiting for review" : "Approved in the library", caption: l.captionIdea || "", files: l.files || [], editable: true, }); diff --git a/dashboard/server/index.js b/dashboard/server/index.js index f4862c0..04f1e98 100644 --- a/dashboard/server/index.js +++ b/dashboard/server/index.js @@ -339,8 +339,33 @@ app.post("/api/notify/test", h(async (req) => notify.sendEmail({ to: req.user.em // ---------- content calendar: plan items + library items with a date + queued/published posts ---------- app.get("/api/calendar", h(async (req) => plan.calendar({ client: String(req.query.client || ""), from: String(req.query.from || ""), to: String(req.query.to || "") }))); app.post("/api/calendar/move", h(async (req) => ({ item: plan.moveEntry(String(req.body?.kind || ""), String(req.body?.id || ""), String(req.body?.date || ""), who(req)) }))); -app.post("/api/plan", h(async (req) => ({ item: plan.createItem(req.body || {}, who(req)) }))); -app.patch("/api/plan/:id", h(async (req) => ({ item: plan.updateItem(req.params.id, req.body || {}, who(req)) }))); +// Tell the person a plan item is assigned to (bell + email if they want it). +async function notifyAssignee(item, before = null) { + if (!item.assignee || (before && before.assignee === item.assignee) || !["idea", "brief", "production"].includes(item.status)) return; + const c = clients.getClient(item.client); + await notify.notify({ + type: "assigned", title: `New for you: ${c?.name || item.client} “${item.title}”`, to: [item.assignee], + body: `Due ${item.date}${item.format ? ` · ${item.format.toLowerCase()}` : ""}${item.brief ? ` · ${item.brief.slice(0, 200)}` : ""}`, + link: `/calendar?client=${item.client}&date=${item.date}`, client: item.client, + }); +} +app.post("/api/plan", h(async (req) => { const item = plan.createItem(req.body || {}, who(req)); await notifyAssignee(item); return { item }; })); +app.patch("/api/plan/:id", h(async (req) => { + const before = plan.listItems({}).find((i) => i.id === req.params.id); + const item = plan.updateItem(req.params.id, req.body || {}, who(req)); + await notifyAssignee(item, before); + return { item }; +})); +app.get("/api/team/names", h(async () => ({ names: users.listUsers().filter((u) => u.active).map((u) => u.name || u.email) }))); +app.post("/api/plan/:id/odoo-task", requireAdmin, h(async (req) => { + const item = plan.listItems({}).find((i) => i.id === req.params.id); + if (!item) throw Object.assign(new Error("Plan item not found."), { status: 404 }); + const c = clients.getClient(item.client); + const userIds = (req.body?.userId ? [req.body.userId] : []); + const description = [item.brief && `Brief: ${item.brief}`, item.caption && `Draft caption: ${item.caption}`, item.platforms?.length && `Platforms: ${item.platforms.join(", ")}`, `Upload the finished work in Quorla → Content library and pick this calendar item.`].filter(Boolean).join("\n\n"); + const task = await odoo.createTask({ projectId: req.body?.projectId, name: `${c?.name || item.client}: ${item.title}`, description, userIds, deadline: item.date }); + return { item: plan.updateItem(item.id, { taskRef: String(task.id), status: item.status === "idea" ? "brief" : item.status }, who(req)), task }; +})); app.delete("/api/plan/:id", h(async (req) => { plan.removeItem(req.params.id); return { ok: true }; })); // ---------- competitors per client (Instagram) ---------- @@ -371,7 +396,10 @@ app.post("/api/library/upload", h(async (req) => { return { path: file, size: fs.statSync(file).size }; })); app.post("/api/library", h(async (req) => { - const item = lib.createItem({ ...req.body, uploadedBy: who(req) }); + // Uploaded for a calendar item: inherit its date and Odoo task, and move the card to "in production". + const p = req.body?.planItem ? plan.listItems({ client: req.body.client }).find((i) => i.id === req.body.planItem) : null; + const item = lib.createItem({ ...req.body, forDate: req.body?.forDate || p?.date || "", taskRef: req.body?.taskRef || p?.taskRef || "", planItem: p?.id || "", uploadedBy: who(req) }); + if (p) plan.updateItem(p.id, { libraryItem: item.id, ...(["idea", "brief"].includes(p.status) ? { status: "production" } : {}) }, who(req)); const odooResult = await odoo.taskActivity(item.taskRef, { note: `
${esc(who(req))} uploaded ${esc(item.title)} (${item.format.toLowerCase()}, ${item.files.length} file${item.files.length > 1 ? "s" : ""}) to the Social Manager library for review.
`, stageMatch: "review" }); return { item, odoo: odooResult }; })); @@ -383,6 +411,8 @@ app.patch("/api/library/:id", h(async (req) => { app.post("/api/library/:id/review", requireAdmin, h(async (req) => { const item = lib.reviewItem(req.params.id, req.body || {}, who(req)); const ok = item.status === "approved"; + if (item.planItem) { try { plan.updateItem(item.planItem, { status: ok ? "ready" : "production" }, who(req)); } catch { /* plan item removed */ } } + if (!ok && item.uploadedBy) await notify.notify({ type: "library_changes", level: "warn", title: `Changes requested: ${item.title}`, body: item.reviewNote, to: [item.uploadedBy], link: `/library?client=${item.client}`, client: item.client }); await odoo.taskActivity(item.taskRef, { note: ok ? `Approved by ${esc(who(req))}: ${esc(item.title)}.
` : `Changes requested by ${esc(who(req))}: ${esc(item.reviewNote)}
`, stageMatch: ok ? "done" : "progress" }); return { item }; })); diff --git a/dashboard/web/src/pages/Calendar.jsx b/dashboard/web/src/pages/Calendar.jsx index 692b47c..037bb76 100644 --- a/dashboard/web/src/pages/Calendar.jsx +++ b/dashboard/web/src/pages/Calendar.jsx @@ -63,7 +63,13 @@ function ItemDialog({ item, client, onClose, onSaved }) { }); const [busy, setBusy] = useState(null); const [error, setError] = useState(null); + const [names, setNames] = useState([]); + const [odoo, setOdoo] = useState(null); + const [task, setTask] = useState({ projectId: "", userId: "" }); const set = (k, v) => setF((s) => ({ ...s, [k]: v })); + useEffect(() => { api("/api/team/names").then((r) => setNames(r.names)).catch(() => {}); }, []); + useEffect(() => { if (isPlan && !isNew && !item.taskRef) api("/api/team").then((r) => setOdoo(r.status?.connected && r.status?.allowWrites ? r : null)).catch(() => {}); }, [isPlan, isNew, item.taskRef]); + const makeTask = () => run("task", () => api(`/api/plan/${item.id}/odoo-task`, { method: "POST", body: task })); const run = async (what, fn) => { setBusy(what); setError(null); try { await fn(); onSaved(); } catch (e) { setError(e); setBusy(null); } }; const save = () => run("save", () => (isPlan @@ -131,9 +137,23 @@ function ItemDialog({ item, client, onClose, onSaved }) {