quorlasocial/dashboard/server/screenshots.js
metatroncubeswdev df3dbb63c4 Visual reports and client Screenshots tab
- 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>
2026-10-27 00:48:32 -04:00

114 lines
5.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.

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