- 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>
117 lines
6.7 KiB
JavaScript
117 lines
6.7 KiB
JavaScript
// Content library: finished work the team uploads per client (designs, reels, carousels).
|
||
// Flow: submitted → approved (or changes → resubmitted) → scheduled (a draft post in the queue) → archived.
|
||
// Shared by the dashboard (upload/review UI) and the MCP connector (agent plans the week from approved work).
|
||
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";
|
||
|
||
export const LIB_DIR = path.join(PROJECT_ROOT, "data", "library");
|
||
const FILE = path.join(LIB_DIR, "items.json");
|
||
const CLIENTS = path.join(PROJECT_ROOT, "clients");
|
||
fs.mkdirSync(LIB_DIR, { 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 FORMATS = ["IMAGE", "REELS", "CAROUSEL", "STORY"];
|
||
export const STATUSES = ["submitted", "changes", "approved", "scheduled", "archived"];
|
||
const MEDIA_RE = /\.(jpe?g|png|mp4|mov|m4v)$/i;
|
||
|
||
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;
|
||
}
|
||
|
||
/** Folder a new upload for this client should be written to (created if needed). */
|
||
export function uploadTarget(client, filename) {
|
||
checkClient(client);
|
||
const name = String(filename || "upload").replace(/[^\w.\-]+/g, "_").slice(-80);
|
||
if (!MEDIA_RE.test(name)) throw err("Upload a JPG, PNG, MP4 or MOV file.");
|
||
const dir = path.join(LIB_DIR, client, new Date().toISOString().slice(0, 7));
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
return path.join(dir, `${Date.now()}-${crypto.randomBytes(2).toString("hex")}-${name}`);
|
||
}
|
||
export function isLibraryFile(p) { const f = path.resolve(String(p || "")); return f.startsWith(path.resolve(LIB_DIR) + path.sep) && fs.existsSync(f) ? f : null; }
|
||
|
||
export function listItems({ client, status } = {}) {
|
||
return read().filter((i) => (!client || i.client === client) && (!status || i.status === status))
|
||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||
}
|
||
export function getItem(id) { const i = read().find((x) => x.id === id); if (!i) throw err("Item not found.", 404); return i; }
|
||
|
||
function validateFiles(format, files) {
|
||
if (!files?.length) throw err("Add at least one file.");
|
||
for (const f of files) if (!isLibraryFile(f)) throw err(`File missing: ${path.basename(String(f))}`);
|
||
const vids = files.filter((f) => /\.(mp4|mov|m4v)$/i.test(f)).length;
|
||
if (format === "REELS" && (files.length !== 1 || !vids)) throw err("A Reel is exactly one video file.");
|
||
if (format === "IMAGE" && (files.length !== 1 || vids)) throw err("A single post is exactly one image.");
|
||
if (format === "STORY" && files.length !== 1) throw err("A Story is one image or one video.");
|
||
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", 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, 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;
|
||
}
|
||
|
||
function mutate(id, fn) {
|
||
const all = read(); const i = all.find((x) => x.id === id); if (!i) throw err("Item not found.", 404);
|
||
fn(i); i.updatedAt = now(); write(all); return i;
|
||
}
|
||
|
||
/** Edit details, or upload a new version (files) after changes were requested. */
|
||
export function updateItem(id, patch, by = "team") {
|
||
return mutate(id, (i) => {
|
||
if (["scheduled", "archived"].includes(i.status) && patch.files) throw err("This item is already used; upload it as a new item.");
|
||
for (const k of ["title", "captionIdea", "notes", "taskRef", "forDate"]) if (k in patch) i[k] = patch[k];
|
||
if (patch.format) { if (!FORMATS.includes(patch.format)) throw err("Unknown format."); i.format = patch.format; }
|
||
if (patch.files) {
|
||
validateFiles(i.format, patch.files); i.files = patch.files; i.version += 1;
|
||
i.status = "submitted"; i.history.push({ at: now(), by, action: `new version v${i.version}` });
|
||
} else if (patch.format) validateFiles(i.format, i.files);
|
||
});
|
||
}
|
||
|
||
export function reviewItem(id, { decision, note = "" }, by) {
|
||
if (!["approve", "changes"].includes(decision)) throw err("Decision must be approve or changes.");
|
||
if (decision === "changes" && !note.trim()) throw err("Say what needs to change.");
|
||
return mutate(id, (i) => {
|
||
if (!["submitted", "changes", "approved"].includes(i.status)) throw err(`Can't review an item that is ${i.status}.`);
|
||
i.status = decision === "approve" ? "approved" : "changes"; i.reviewNote = note; i.reviewedBy = by;
|
||
i.history.push({ at: now(), by, action: decision === "approve" ? "approved" : "changes requested", note });
|
||
});
|
||
}
|
||
|
||
export function archiveItem(id, by = "team") {
|
||
return mutate(id, (i) => { i.status = "archived"; i.history.push({ at: now(), by, action: "archived" }); });
|
||
}
|
||
|
||
/** Turn an approved item into a DRAFT post in the queue (still needs approval there to publish). */
|
||
export function scheduleItem(id, { platforms = ["instagram", "facebook"], localTime, caption, fbCaption, collaborators = [], options = {}, type }, by = "team") {
|
||
const i = getItem(id);
|
||
if (!["approved", "scheduled"].includes(i.status)) throw err("Only approved work can be scheduled. Review it first.");
|
||
const post = sched.createPost({
|
||
client: i.client, platforms, type: type || i.format, media: i.files, caption: caption ?? i.captionIdea, fbCaption, collaborators, options, localTime,
|
||
createdBy: by, notes: `From library item ${i.id} "${i.title}" by ${i.uploadedBy}`,
|
||
});
|
||
mutate(id, (x) => { x.status = "scheduled"; x.postIds.push(post.id); x.history.push({ at: now(), by, action: `scheduled ${post.localTime}`, postId: post.id }); });
|
||
return post;
|
||
}
|
||
|
||
export function summary() {
|
||
const out = {};
|
||
for (const i of read()) { const c = (out[i.client] ||= { submitted: 0, changes: 0, approved: 0, scheduled: 0 }); if (i.status in c) c[i.status]++; }
|
||
return out;
|
||
}
|