metatroncubeswdev c4d8c6474c Team task flow: assign calendar items, link uploads, move cards through production
- 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>
2026-10-23 10:13:23 -04:00

117 lines
6.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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