// Monthly profile screenshots per client: the team captures a fixed shot list, the audit reads them. // Files: clients//screenshots/YYYY-MM/.; 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/ (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); }