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