- Assigning a plan item notifies the person (bell + email); team names offered when assigning - Optional Odoo task from a calendar card (project + assignee, deadline = post date, brief in the task) - Library upload can pick its calendar item: inherits date and Odoo task, card moves to 'in production' - Approving the upload moves the card to 'ready'; changes requested notify the uploader - Calendar shows the uploaded work in place of its plan card (no duplicates) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
129 lines
7.8 KiB
JavaScript
129 lines
7.8 KiB
JavaScript
// Content plan: what each client will post and when, before it exists as finished work.
|
|
// Flow per item: idea → brief → production → ready. Once scheduled it lives in the queue (schedule.js);
|
|
// the calendar view merges plan items, library items with a date, and queued/published posts.
|
|
// Shared by the dashboard (calendar UI) and the MCP connector (weekly-plan skill adds items).
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import crypto from "node:crypto";
|
|
import { PROJECT_ROOT } from "./env.js";
|
|
import * as sched from "./schedule.js";
|
|
import * as lib from "./library.js";
|
|
|
|
const FILE = process.env.QUORLA_PLAN_FILE || path.join(PROJECT_ROOT, "data", "plan.json");
|
|
const CLIENTS = path.join(PROJECT_ROOT, "clients");
|
|
fs.mkdirSync(path.dirname(FILE), { recursive: true });
|
|
|
|
const read = () => { try { return JSON.parse(fs.readFileSync(FILE, "utf8")); } catch { return []; } };
|
|
const write = (items) => { const tmp = FILE + ".tmp"; fs.writeFileSync(tmp, JSON.stringify(items, null, 2)); fs.renameSync(tmp, FILE); };
|
|
const err = (msg, status = 400) => Object.assign(new Error(msg), { status });
|
|
const now = () => new Date().toISOString();
|
|
|
|
export const STAGES = ["idea", "brief", "production", "ready"];
|
|
export const FORMATS = ["IMAGE", "REELS", "CAROUSEL", "STORY", "TEXT"];
|
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
|
|
function checkClient(slug) {
|
|
if (!/^[a-z0-9][a-z0-9-]*$/.test(String(slug || "")) || !fs.existsSync(path.join(CLIENTS, slug))) throw err("Unknown client.", 404);
|
|
return slug;
|
|
}
|
|
function clean(patch) {
|
|
const out = {};
|
|
if ("title" in patch) { out.title = String(patch.title || "").trim().slice(0, 140); if (!out.title) throw err("Give the item a title."); }
|
|
if ("date" in patch) { if (!DATE_RE.test(String(patch.date))) throw err("Date must be YYYY-MM-DD."); out.date = patch.date; }
|
|
if ("time" in patch) { const t = String(patch.time || ""); if (t && !/^\d{2}:\d{2}$/.test(t)) throw err("Time must be HH:MM."); out.time = t; }
|
|
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", "taskRef", "libraryItem"]) if (k in patch) out[k] = String(patch[k] ?? "").slice(0, 5000);
|
|
return out;
|
|
}
|
|
|
|
export function listItems({ client, from, to } = {}) {
|
|
return read().filter((i) => (!client || i.client === client) && (!from || i.date >= from) && (!to || i.date <= to));
|
|
}
|
|
|
|
export function createItem(data, by = "team") {
|
|
const client = checkClient(data.client);
|
|
const fields = clean({ title: data.title, date: data.date, status: data.status || "idea", format: data.format || "IMAGE", platforms: data.platforms || [], time: data.time || "", brief: data.brief || "", caption: data.caption || "", pillar: data.pillar || "", assignee: data.assignee || "", notes: data.notes || "" });
|
|
const item = { id: crypto.randomUUID().slice(0, 8), client, ...fields, createdBy: by, createdAt: now(), updatedAt: now() };
|
|
const items = read(); items.push(item); write(items);
|
|
return item;
|
|
}
|
|
|
|
export function updateItem(id, patch, by = "team") {
|
|
const items = read(); const i = items.find((x) => x.id === id);
|
|
if (!i) throw err("Plan item not found.", 404);
|
|
Object.assign(i, clean(patch), { updatedBy: by, updatedAt: now() });
|
|
write(items);
|
|
return i;
|
|
}
|
|
|
|
export function removeItem(id) {
|
|
const items = read(); const n = items.length;
|
|
const kept = items.filter((x) => x.id !== id);
|
|
if (kept.length === n) throw err("Plan item not found.", 404);
|
|
write(kept);
|
|
}
|
|
|
|
/** Local calendar date (YYYY-MM-DD) and time of an instant in a time zone. */
|
|
function localParts(iso, tz) {
|
|
const p = new Intl.DateTimeFormat("en-CA", { timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false })
|
|
.formatToParts(new Date(iso)).reduce((o, x) => ((o[x.type] = x.value), o), {});
|
|
return { date: `${p.year}-${p.month}-${p.day}`, time: `${p.hour === "24" ? "00" : p.hour}:${p.minute}` };
|
|
}
|
|
|
|
/**
|
|
* Everything on a client's calendar between two dates (inclusive, client's local dates):
|
|
* plan items (kind "plan"), library items with a target date ("library"), and queued/published posts ("post").
|
|
* Each entry has a unified `stage`: idea | brief | production | ready | scheduled | published | failed.
|
|
*/
|
|
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 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 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: 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,
|
|
});
|
|
}
|
|
for (const p of sched.listPosts({ client })) {
|
|
if (p.status === "cancelled") continue;
|
|
const at = localParts(p.publishAt, tz);
|
|
if (at.date < from || at.date > to) continue;
|
|
const stage = p.status === "published" || p.status === "partial" ? "published" : p.status === "failed" ? "failed" : "scheduled";
|
|
out.push({
|
|
id: p.id, kind: "post", client, title: (p.options?.youtube?.title || p.caption || "").split("\n")[0].slice(0, 90) || "Untitled post",
|
|
date: at.date, time: at.time, format: p.type, platforms: p.platforms, stage,
|
|
detail: p.status === "draft" ? "Draft, waiting for approval" : p.status === "approved" ? "Approved, will publish" : p.status === "partial" ? "Partly published" : null,
|
|
caption: p.caption || "", files: p.media || [], editable: ["draft", "failed"].includes(p.status), postStatus: p.status,
|
|
});
|
|
}
|
|
const order = { published: 0, failed: 1, scheduled: 2, ready: 3, production: 4, brief: 5, idea: 6 };
|
|
return { timezone: tz, items: out.sort((a, b) => a.date.localeCompare(b.date) || (a.time || "99").localeCompare(b.time || "99") || order[a.stage] - order[b.stage]) };
|
|
}
|
|
|
|
/** Move anything on the calendar to another day (keeps the time). Posts can only move while they're drafts. */
|
|
export function moveEntry(kind, id, date, by = "team") {
|
|
if (!DATE_RE.test(String(date))) throw err("Date must be YYYY-MM-DD.");
|
|
if (kind === "plan") return updateItem(id, { date }, by);
|
|
if (kind === "library") return lib.updateItem(id, { forDate: date }, by);
|
|
if (kind === "post") {
|
|
const p = sched.getPost(id);
|
|
if (!["draft", "failed"].includes(p.status)) throw err("Only drafts can be moved. Cancel the approval first.");
|
|
const { time } = localParts(p.publishAt, sched.clientInfo(p.client).timezone);
|
|
return sched.updatePost(id, { localTime: `${date} ${time}` }, by);
|
|
}
|
|
throw err("Unknown calendar item.");
|
|
}
|