- Reports: mc: blocks (score, KPIs, reach, funnel, followers, formats, rhythm, top posts, actions, screenshots, callouts) drawn as SVG from stored account data, frozen per report; same page in PDF and preview - Audit/weekly-report runs refresh account numbers first - Screenshots tab: monthly shot list, uploads, examples; audit reads them Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
114 lines
5.7 KiB
JavaScript
114 lines
5.7 KiB
JavaScript
// Monthly profile screenshots per client: the team captures a fixed shot list, the audit reads them.
|
||
// Files: clients/<slug>/screenshots/YYYY-MM/<shot>.<ext>; example shots (the "template") live in DATA_DIR.
|
||
import fs from "node:fs";
|
||
import path from "node:path";
|
||
import { DATA_DIR } from "./config.js";
|
||
import { safeSlug } from "./clients.js";
|
||
|
||
export const SHOTS = [
|
||
{ id: "ig-profile", platform: "Instagram", label: "Profile top", required: true,
|
||
how: "In a computer browser, open instagram.com/<handle> (logged out is fine). Capture the whole window: name, bio, link, follower count, highlights and the first row of posts." },
|
||
{ id: "ig-grid", platform: "Instagram", label: "Grid", required: true,
|
||
how: "Zoom the browser out to 50% and capture the latest 9–12 posts, so the overall look of the feed shows." },
|
||
{ id: "ig-reels", platform: "Instagram", label: "Reels tab", required: false,
|
||
how: "Click the Reels tab under the highlights. Capture the covers with their view counts." },
|
||
{ id: "fb-page", platform: "Facebook", label: "Page header", required: true,
|
||
how: "In a computer browser, open the Facebook Page. Capture the whole window: cover photo, profile picture, name, followers, category, buttons (Message, WhatsApp, Book…) and intro." },
|
||
{ id: "fb-reviews", platform: "Facebook", label: "Reviews", required: false,
|
||
how: "Open the Reviews / Recommendations tab. Capture the score and the newest reviews. Skip if the Page has no reviews tab." },
|
||
{ id: "gbp", platform: "Google", label: "Business Profile", required: true,
|
||
how: "In a computer browser, search the business name on google.com/maps and open the listing. Capture rating, review count, category, hours, buttons and photos." },
|
||
{ id: "gbp-reviews", platform: "Google", label: "Google reviews", required: false,
|
||
how: "Open Reviews, sort by Newest and capture the latest reviews, including whether the owner replied." },
|
||
{ id: "bio-link", platform: "Website", label: "Link in bio page", required: false,
|
||
how: "Open the link from the Instagram bio. Capture the first screen that opens." },
|
||
];
|
||
|
||
const EXT = /\.(png|jpe?g|webp)$/i;
|
||
const EXAMPLES_DIR = path.join(DATA_DIR, "screenshot-examples");
|
||
const MAX_BYTES = 15 * 1024 * 1024;
|
||
|
||
const fail = (message, status = 400) => Object.assign(new Error(message), { status });
|
||
const shotDef = (id) => SHOTS.find((s) => s.id === id) || (() => { throw fail("Unknown screenshot.", 404); })();
|
||
const monthOf = (m) => {
|
||
const month = m || new Date().toISOString().slice(0, 7);
|
||
if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(month)) throw fail("Month must look like 2026-10.");
|
||
return month;
|
||
};
|
||
const rootDir = (slug) => path.join(safeSlug(slug), "screenshots");
|
||
const findFile = (dir, id) => (fs.existsSync(dir) ? fs.readdirSync(dir).find((f) => f.startsWith(`${id}.`) && EXT.test(f)) : null);
|
||
const metaPath = (dir) => path.join(dir, "uploads.json");
|
||
const readMeta = (dir) => { try { return JSON.parse(fs.readFileSync(metaPath(dir), "utf8")); } catch { return {}; } };
|
||
|
||
/** Reject anything that isn't really a PNG, JPEG or WebP image. */
|
||
function checkImage(buf, name) {
|
||
if (!EXT.test(name || "")) throw fail("Upload a PNG, JPG or WebP screenshot.");
|
||
if (!buf?.length) throw fail("The file is empty.");
|
||
if (buf.length > MAX_BYTES) throw fail("Screenshots must be under 15 MB.");
|
||
const png = buf[0] === 0x89 && buf[1] === 0x50, jpg = buf[0] === 0xff && buf[1] === 0xd8, webp = buf.subarray(8, 12).toString() === "WEBP";
|
||
if (!png && !jpg && !webp) throw fail("That file isn't a PNG, JPG or WebP image.");
|
||
return png ? ".png" : jpg ? ".jpg" : ".webp";
|
||
}
|
||
|
||
function writeShot(dir, id, buf, ext) {
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
const old = findFile(dir, id);
|
||
if (old) fs.rmSync(path.join(dir, old));
|
||
fs.writeFileSync(path.join(dir, id + ext), buf);
|
||
}
|
||
|
||
export function list(slug, month) {
|
||
month = monthOf(month);
|
||
const root = rootDir(slug), dir = path.join(root, month), meta = readMeta(dir);
|
||
const months = fs.existsSync(root) ? fs.readdirSync(root).filter((m) => /^\d{4}-\d{2}$/.test(m)).sort().reverse() : [];
|
||
return {
|
||
month,
|
||
months: [...new Set([monthOf(), month, ...months])].sort().reverse(),
|
||
shots: SHOTS.map((s) => {
|
||
const file = findFile(dir, s.id);
|
||
return { ...s, example: !!findFile(EXAMPLES_DIR, s.id), file: file ? { name: file, size: fs.statSync(path.join(dir, file)).size, ...meta[s.id] } : null };
|
||
}),
|
||
};
|
||
}
|
||
|
||
export function save(slug, month, id, buf, name, by) {
|
||
shotDef(id);
|
||
const dir = path.join(rootDir(slug), monthOf(month));
|
||
writeShot(dir, id, buf, checkImage(buf, name));
|
||
const meta = readMeta(dir);
|
||
meta[id] = { by, at: new Date().toISOString() };
|
||
fs.writeFileSync(metaPath(dir), JSON.stringify(meta, null, 2));
|
||
return list(slug, month);
|
||
}
|
||
|
||
export function remove(slug, month, id) {
|
||
shotDef(id);
|
||
const dir = path.join(rootDir(slug), monthOf(month)), file = findFile(dir, id);
|
||
if (file) fs.rmSync(path.join(dir, file));
|
||
const meta = readMeta(dir);
|
||
delete meta[id];
|
||
if (fs.existsSync(dir)) fs.writeFileSync(metaPath(dir), JSON.stringify(meta, null, 2));
|
||
return list(slug, month);
|
||
}
|
||
|
||
export function filePath(slug, month, id) {
|
||
shotDef(id);
|
||
const dir = path.join(rootDir(slug), monthOf(month)), file = findFile(dir, id);
|
||
if (!file) throw fail("Screenshot not found.", 404);
|
||
return path.join(dir, file);
|
||
}
|
||
|
||
export function examplePath(id) {
|
||
shotDef(id);
|
||
const file = findFile(EXAMPLES_DIR, id);
|
||
if (!file) throw fail("No example yet.", 404);
|
||
return path.join(EXAMPLES_DIR, file);
|
||
}
|
||
|
||
/** Admin: make a client's upload the example every client sees for that shot. */
|
||
export function useAsExample(slug, month, id) {
|
||
const src = filePath(slug, month, id);
|
||
writeShot(EXAMPLES_DIR, id, fs.readFileSync(src), path.extname(src).toLowerCase());
|
||
return list(slug, month);
|
||
}
|