Merge feature/internal-pages: internal pages, client portal, notifications, reports, hosted Quorla
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
4c78b76586
@ -12,4 +12,9 @@ For each client requested:
|
||||
- At least one post that tests something new; say what it's testing.
|
||||
5. For every post that needs creative work, write a brief using `playbooks/team-briefs.md`.
|
||||
6. Save to `clients/<slug>/calendar/YYYY-Www.md` (ISO week of the week being planned).
|
||||
7. Give the user a short summary and ask for approval before anything goes to the team or the client.
|
||||
7. Put the week on the dashboard's Content calendar: first call `content_plan` for that week so nothing is doubled
|
||||
(approved library items and queued posts already show there), then `plan_add` one item per new post with date,
|
||||
optional time, a short title, format, platforms, status `brief` (or `idea` if it still needs a concept), the brief
|
||||
and the draft caption. Items with finished, approved library work should be scheduled with `schedule_from_library`
|
||||
instead of added to the plan.
|
||||
8. Give the user a short summary and ask for approval before anything goes to the team or the client.
|
||||
|
||||
19
.env.example
19
.env.example
@ -56,3 +56,22 @@ ANTHROPIC_API_KEY=
|
||||
DASHBOARD_AI_MODEL=claude-opus-5-5
|
||||
# Refresh every client's numbers at 03:00 server time
|
||||
DASHBOARD_NIGHTLY_REFRESH=true
|
||||
|
||||
# ---------- Email (notifications, client approval emails, monthly reports) ----------
|
||||
# Use the support@ mailbox on the Hestia server (Hestia → Mail → quorlasocial.com → support → password).
|
||||
# SMTP_HOST=mail.quorlasocial.com
|
||||
# SMTP_PORT=587
|
||||
# SMTP_USER=support@quorlasocial.com
|
||||
# SMTP_PASS=
|
||||
# SMTP_FROM=support@quorlasocial.com
|
||||
|
||||
# ---------- Hosted Quorla control centre (only on app.quorlasocial.com) ----------
|
||||
# QUORLA_CONTROL=true
|
||||
# TENANT_BASE_DOMAIN=quorlasocial.com
|
||||
# PROVISION_TOKEN= # openssl rand -hex 32; same value in deploy/provision.env
|
||||
# STRIPE_SECRET_KEY= # sk_test_… while testing
|
||||
# STRIPE_PRICE_ID= # price_… of the hosted plan (monthly)
|
||||
# STRIPE_WEBHOOK_SECRET= # whsec_… from the webhook endpoint https://app.quorlasocial.com/control/stripe/webhook
|
||||
# STRIPE_TRIAL_DAYS=14
|
||||
# In a hosted agency workspace (set by the provisioning script):
|
||||
# SETUP_TOKEN= # only the emailed setup link can create the first admin
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@ -29,3 +29,8 @@ graphify-out/
|
||||
# Local demo recordings
|
||||
*.mp4
|
||||
!docs/demo-assets/*.mp4
|
||||
__pycache__/
|
||||
|
||||
# Hosted Quorla provisioning secrets
|
||||
deploy/provision.env
|
||||
deploy/tenant-shared.env
|
||||
|
||||
13
HANDOFF.md
13
HANDOFF.md
@ -40,6 +40,19 @@ Request-access buttons email support@quorlasocial.com: make sure that mailbox ex
|
||||
6. Later: marketing site, Stripe. (The dashboard now uses the Quorla logo and colours; PDF reports keep the agency branding.)
|
||||
7. Optional: on the server, run `git stash drop` (left over from the branding CRLF fix).
|
||||
|
||||
## Built on branch feature/internal-pages (23 Oct 2026)
|
||||
Published page (results per post) · Content calendar (plan → production → ready, drag to reschedule) · Competitors ·
|
||||
Client approval portal (/p/<token>) · Notifications (bell, email, 8:00 summary) · Monthly client report (auto + email) ·
|
||||
Team task flow (assign → notify/Odoo task → upload links to card) · Inbox: Google reviews + YouTube comments ·
|
||||
Best time to post · Brand kit + caption checks · Link-in-bio pages (/l/<address>) · Bulk scheduling ·
|
||||
Admin: system health + audit log · Getting-started checklist · White-label workspace · Hosted Quorla (sign-up, Stripe,
|
||||
one container per agency; docs/multi-tenant.md).
|
||||
|
||||
After merging and deploying:
|
||||
- Add SMTP_* to the server .env (support@ mailbox) so emails go out; check Admin → System health.
|
||||
- docker-compose now mounts ./backups read-only: run `docker compose up -d` once so health sees the backups.
|
||||
- Hosted plan stays off until QUORLA_CONTROL=true and the steps in docs/multi-tenant.md are done.
|
||||
|
||||
## Rules that don't change
|
||||
- Nothing publishes without a person approving the exact caption and media.
|
||||
- White-label: no Metatroncube mention on client posts.
|
||||
|
||||
71
connector/src/brandkit.js
Normal file
71
connector/src/brandkit.js
Normal file
@ -0,0 +1,71 @@
|
||||
// Brand kit per client: saved hashtag sets, approved phrases/CTAs, words to avoid.
|
||||
// checkCaption() flags problems before a post goes for approval (shared by the dashboard, the queue and the MCP tools).
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { PROJECT_ROOT } from "./env.js";
|
||||
|
||||
const FILE = process.env.QUORLA_BRANDKIT_FILE || path.join(PROJECT_ROOT, "data", "brandkit.json");
|
||||
const CLIENTS = path.join(PROJECT_ROOT, "clients");
|
||||
fs.mkdirSync(path.dirname(FILE), { recursive: true });
|
||||
const read = () => { try { return JSON.parse(fs.readFileSync(FILE, "utf8")); } catch { return {}; } };
|
||||
const err = (msg, status = 400) => Object.assign(new Error(msg), { status });
|
||||
|
||||
// Phrases that read as AI-written or generic (project writing rules).
|
||||
const GENERIC = ["elevate your", "unlock", "in today's fast-paced world", "look no further", "game-changer", "game changer", "take it to the next level", "dive into", "seamless", "revolutionize", "embark on"];
|
||||
// White-label: the agency must never appear in a client's public post.
|
||||
const AGENCY_WORDS = ["metatroncube", "metatron cube", "quorla"];
|
||||
const LIMITS = { instagram: 2200, facebook: 63206, tiktok: 2200, youtube: 5000, gbp: 1500, linkedin: 3000, threads: 500, pinterest: 500, reddit: 40000 };
|
||||
|
||||
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;
|
||||
}
|
||||
const tagList = (v) => [...new Set(String(Array.isArray(v) ? v.join(" ") : v || "").split(/[\s,]+/).map((t) => t.trim()).filter(Boolean).map((t) => `#${t.replace(/^#+/, "")}`).filter((t) => /^#[\p{L}\p{N}_]{1,60}$/u.test(t)))];
|
||||
const lines = (v) => (Array.isArray(v) ? v : String(v || "").split(/\n/)).map((x) => String(x).trim()).filter(Boolean).slice(0, 200);
|
||||
|
||||
export function get(client) {
|
||||
checkClient(client);
|
||||
const k = read()[client] || {};
|
||||
return { hashtagSets: k.hashtagSets || [], phrases: k.phrases || [], avoid: k.avoid || [], notes: k.notes || "", updatedAt: k.updatedAt || null, updatedBy: k.updatedBy || null };
|
||||
}
|
||||
|
||||
export function save(client, patch, by = "team") {
|
||||
checkClient(client);
|
||||
const db = read();
|
||||
const cur = get(client);
|
||||
const next = {
|
||||
hashtagSets: patch.hashtagSets !== undefined ? (patch.hashtagSets || []).slice(0, 30).map((s) => ({ name: String(s.name || "").trim().slice(0, 60) || "Set", tags: tagList(s.tags).slice(0, 30) })).filter((s) => s.tags.length) : cur.hashtagSets,
|
||||
phrases: patch.phrases !== undefined ? lines(patch.phrases).map((x) => x.slice(0, 300)) : cur.phrases,
|
||||
avoid: patch.avoid !== undefined ? lines(patch.avoid).map((x) => x.slice(0, 80)) : cur.avoid,
|
||||
notes: patch.notes !== undefined ? String(patch.notes || "").slice(0, 3000) : cur.notes,
|
||||
updatedAt: new Date().toISOString(), updatedBy: by,
|
||||
};
|
||||
db[client] = next;
|
||||
const tmp = FILE + ".tmp"; fs.writeFileSync(tmp, JSON.stringify(db, null, 1)); fs.renameSync(tmp, FILE);
|
||||
return next;
|
||||
}
|
||||
|
||||
const EMOJI = /\p{Extended_Pictographic}/gu;
|
||||
/**
|
||||
* Problems in a caption for this client: { level: "bad" | "warn", text }.
|
||||
* bad = must fix (agency named in a client post, over the platform limit); warn = worth a second look.
|
||||
*/
|
||||
export function checkCaption(client, caption, { platforms = [] } = {}) {
|
||||
const text = String(caption || "");
|
||||
const low = text.toLowerCase();
|
||||
const out = [];
|
||||
let kit = { avoid: [] };
|
||||
try { kit = get(client); } catch { /* unknown client: generic checks only */ }
|
||||
// The agency's own account (its client name contains the agency name) may of course mention itself.
|
||||
let ownName = "";
|
||||
try { ownName = JSON.parse(fs.readFileSync(path.join(CLIENTS, client, "client.json"), "utf8")).name || ""; } catch { /* no client.json */ }
|
||||
const isAgencyItself = AGENCY_WORDS.some((w) => `${ownName} ${client}`.toLowerCase().replace(/[-_]/g, " ").includes(w));
|
||||
if (!isAgencyItself) for (const w of AGENCY_WORDS) if (low.includes(w)) out.push({ level: "bad", text: `Mentions “${w}”. Client posts must never name the agency.` });
|
||||
for (const p of platforms) if (LIMITS[p] && text.length > LIMITS[p]) out.push({ level: "bad", text: `Too long for ${p}: ${text.length} of ${LIMITS[p]} characters.` });
|
||||
for (const w of kit.avoid) if (w && low.includes(w.toLowerCase())) out.push({ level: "warn", text: `Uses “${w}”, which is on this client's avoid list.` });
|
||||
for (const g of GENERIC) if (low.includes(g)) out.push({ level: "warn", text: `“${g}” sounds generic. Say it the way the client would.` });
|
||||
const tags = text.match(/#[\p{L}\p{N}_]+/gu) || [];
|
||||
if (tags.length > 10) out.push({ level: "warn", text: `${tags.length} hashtags. 3–8 relevant ones usually do better.` });
|
||||
if ((text.match(EMOJI) || []).length > 8) out.push({ level: "warn", text: "Lots of emoji. Keep a few that add meaning." });
|
||||
return out;
|
||||
}
|
||||
158
connector/src/competitors.js
Normal file
158
connector/src/competitors.js
Normal file
@ -0,0 +1,158 @@
|
||||
// Competitors per client: Instagram Business/Creator accounts the team tracks (added by hand or suggested by the AI).
|
||||
// Each refresh stores one snapshot per account per day (followers, posts, recent media), so growth can be charted.
|
||||
// Uses Instagram business_discovery through the client's (or the agency's) connected Instagram account.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { PROJECT_ROOT } from "./env.js";
|
||||
import * as meta from "./meta.js";
|
||||
|
||||
const FILE = process.env.QUORLA_COMPETITORS_FILE || path.join(PROJECT_ROOT, "data", "competitors.json");
|
||||
const CLIENTS = path.join(PROJECT_ROOT, "clients");
|
||||
const KEEP_DAYS = 180;
|
||||
fs.mkdirSync(path.dirname(FILE), { recursive: true });
|
||||
|
||||
const read = () => { try { return JSON.parse(fs.readFileSync(FILE, "utf8")); } catch { return {}; } };
|
||||
const write = (db) => { const tmp = FILE + ".tmp"; fs.writeFileSync(tmp, JSON.stringify(db)); fs.renameSync(tmp, FILE); };
|
||||
const err = (msg, status = 400) => Object.assign(new Error(msg), { status });
|
||||
const today = () => new Date().toISOString().slice(0, 10);
|
||||
const handle = (u) => String(u || "").trim().replace(/^@/, "").replace(/^https?:\/\/(www\.)?instagram\.com\//i, "").replace(/[/?#].*$/, "").toLowerCase();
|
||||
|
||||
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;
|
||||
}
|
||||
function clientLink(slug) { try { return JSON.parse(fs.readFileSync(path.join(CLIENTS, slug, "client.json"), "utf8")); } catch { return {}; } }
|
||||
|
||||
/** An Instagram account id to run business_discovery from: the client's own, else any the agency manages. */
|
||||
async function viewerIgId(slug) {
|
||||
const link = clientLink(slug);
|
||||
if (link.igId) return link.igId;
|
||||
const any = (await meta.listAccounts()).find((a) => a.instagram?.id);
|
||||
if (!any) throw err("No Instagram account is connected to the agency's Meta account, so competitors can't be looked up.");
|
||||
return any.instagram.id;
|
||||
}
|
||||
|
||||
async function lookup(igId, username) {
|
||||
try {
|
||||
const b = await meta.igBusinessDiscovery(igId, username, { posts: 25 });
|
||||
if (!b?.username) throw new Error("not found");
|
||||
return b;
|
||||
} catch (e) {
|
||||
throw err(`Couldn't read @${username}. Instagram only shares public Business and Creator accounts; check the handle and that the account is public and professional. (${e.message})`);
|
||||
}
|
||||
}
|
||||
|
||||
function snapshotOf(b) {
|
||||
return {
|
||||
date: today(), followers: b.followers_count ?? null, following: b.follows_count ?? null, mediaCount: b.media_count ?? null,
|
||||
name: b.name || null, bio: (b.biography || "").slice(0, 300),
|
||||
media: (b.media || []).map((m) => ({ id: m.id, at: m.timestamp, likes: m.like_count ?? null, comments: m.comments_count ?? null, type: m.media_product_type === "REELS" ? "Reel" : m.media_type === "CAROUSEL_ALBUM" ? "Carousel" : m.media_type === "VIDEO" ? "Video" : "Image", permalink: m.permalink, caption: (m.caption || "").slice(0, 200) })),
|
||||
};
|
||||
}
|
||||
function addSnapshot(entry, snap) {
|
||||
entry.snapshots = (entry.snapshots || []).filter((s) => s.date !== snap.date);
|
||||
// Keep full media only on the latest snapshot; older ones keep the counts for the growth chart.
|
||||
entry.snapshots = entry.snapshots.map(({ media, ...rest }) => rest);
|
||||
entry.snapshots.push(snap);
|
||||
const cutoff = new Date(Date.now() - KEEP_DAYS * 86400000).toISOString().slice(0, 10);
|
||||
entry.snapshots = entry.snapshots.filter((s) => s.date >= cutoff);
|
||||
}
|
||||
|
||||
export function listTracked(client) {
|
||||
checkClient(client);
|
||||
return (read()[client]?.accounts || []).map(({ snapshots, ...a }) => ({ ...a, lastChecked: snapshots?.at(-1)?.date || null }));
|
||||
}
|
||||
|
||||
export async function add(client, username, { by = "team", source = "manual", note = "" } = {}) {
|
||||
checkClient(client);
|
||||
const u = handle(username);
|
||||
if (!/^[a-z0-9._]{1,30}$/.test(u)) throw err("Enter an Instagram handle, like @bakery_name.");
|
||||
const db = read();
|
||||
const c = (db[client] ||= { accounts: [] });
|
||||
if (c.accounts.some((a) => a.username === u)) throw err(`@${u} is already tracked for this client.`);
|
||||
if (c.accounts.length >= 10) throw err("Track up to 10 competitors per client. Remove one first.");
|
||||
const own = clientLink(client);
|
||||
const b = await lookup(await viewerIgId(client), u);
|
||||
if (own.igId && String(b.id) === String(own.igId)) throw err("That's the client's own account.");
|
||||
const entry = { username: u, name: b.name || null, source, note: String(note).slice(0, 300), addedBy: by, addedAt: new Date().toISOString(), snapshots: [] };
|
||||
addSnapshot(entry, snapshotOf(b));
|
||||
c.accounts.push(entry);
|
||||
write(db);
|
||||
return { username: u, name: entry.name };
|
||||
}
|
||||
|
||||
export function remove(client, username) {
|
||||
checkClient(client);
|
||||
const db = read();
|
||||
const u = handle(username);
|
||||
const before = db[client]?.accounts?.length || 0;
|
||||
if (db[client]) db[client].accounts = db[client].accounts.filter((a) => a.username !== u);
|
||||
if ((db[client]?.accounts?.length || 0) === before) throw err("Not tracked.", 404);
|
||||
write(db);
|
||||
}
|
||||
|
||||
/** Refresh every tracked account for one client (or all clients). One snapshot per account per day. */
|
||||
export async function refresh(client) {
|
||||
const db = read();
|
||||
const slugs = client ? [checkClient(client)] : Object.keys(db);
|
||||
const errors = [];
|
||||
for (const slug of slugs) {
|
||||
const c = db[slug];
|
||||
if (!c?.accounts?.length) continue;
|
||||
let igId;
|
||||
try { igId = await viewerIgId(slug); } catch (e) { errors.push(`${slug}: ${e.message}`); continue; }
|
||||
for (const a of c.accounts) {
|
||||
try { const b = await lookup(igId, a.username); a.name = b.name || a.name; addSnapshot(a, snapshotOf(b)); }
|
||||
catch (e) { errors.push(`${slug} @${a.username}: ${e.message}`); }
|
||||
}
|
||||
}
|
||||
write(db);
|
||||
return { refreshed: slugs.length, errors };
|
||||
}
|
||||
|
||||
const engagement = (m) => (m.likes ?? 0) + (m.comments ?? 0);
|
||||
/** Numbers for one account from its latest posts and snapshot history. */
|
||||
function stats({ followers, media, history }) {
|
||||
const now = Date.now();
|
||||
const recent = media.filter((m) => m.at && now - new Date(m.at) <= 28 * 86400000);
|
||||
const last12 = media.slice(0, 12);
|
||||
// Median, not mean: one viral post would otherwise make a typical post look many times better than it is.
|
||||
const sorted = last12.map(engagement).sort((x, y) => x - y);
|
||||
const avgEng = sorted.length ? Math.round(sorted.length % 2 ? sorted[(sorted.length - 1) / 2] : (sorted[sorted.length / 2 - 1] + sorted[sorted.length / 2]) / 2) : null;
|
||||
const mix = {};
|
||||
for (const m of media.slice(0, 25)) mix[m.type] = (mix[m.type] || 0) + 1;
|
||||
const monthAgo = new Date(now - 30 * 86400000).toISOString().slice(0, 10);
|
||||
const base = history.find((h) => h.date >= monthAgo && h.followers != null) || null;
|
||||
return {
|
||||
followers,
|
||||
growth30: base && followers != null && base.date !== today() ? followers - base.followers : null,
|
||||
growthSince: base && base.date !== today() ? base.date : null,
|
||||
postsPerWeek: media.length ? Math.round((recent.length / 4) * 10) / 10 : null,
|
||||
avgEngagement: avgEng,
|
||||
engagementRate: avgEng != null && followers ? Math.round((avgEng / followers) * 10000) / 100 : null,
|
||||
formatMix: mix,
|
||||
topPosts: [...media].sort((a, b) => engagement(b) - engagement(a)).slice(0, 3),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Comparison for a client: their own account (from the dashboard snapshot passed in) plus every tracked competitor.
|
||||
* own: { username, followers, posts:[{id,timestamp,like_count,comments_count,media_type,media_product_type,permalink,caption}], history:[{date, followers}] }
|
||||
*/
|
||||
export function compare(client, own) {
|
||||
checkClient(client);
|
||||
const accounts = (read()[client]?.accounts || []).map((a) => {
|
||||
const latest = a.snapshots?.at(-1) || {};
|
||||
return {
|
||||
username: a.username, name: a.name, source: a.source, note: a.note, addedBy: a.addedBy, addedAt: a.addedAt, lastChecked: latest.date || null,
|
||||
...stats({ followers: latest.followers ?? null, media: latest.media || [], history: a.snapshots || [] }),
|
||||
series: (a.snapshots || []).filter((s) => s.followers != null).map((s) => ({ date: s.date, followers: s.followers })),
|
||||
};
|
||||
});
|
||||
let you = null;
|
||||
if (own) {
|
||||
const media = (own.posts || []).map((p) => ({ id: p.id, at: p.timestamp, likes: p.like_count ?? null, comments: p.comments_count ?? null, type: p.media_product_type === "REELS" ? "Reel" : p.media_type === "CAROUSEL_ALBUM" ? "Carousel" : p.media_type === "VIDEO" ? "Video" : "Image", permalink: p.permalink, caption: (p.caption || "").slice(0, 200) }));
|
||||
you = { username: own.username, own: true, ...stats({ followers: own.followers ?? null, media, history: own.history || [] }), series: (own.history || []).filter((h) => h.followers != null) };
|
||||
}
|
||||
return { you, competitors: accounts };
|
||||
}
|
||||
@ -53,13 +53,13 @@ function validateFiles(format, files) {
|
||||
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" }) {
|
||||
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, status: "submitted", version: 1,
|
||||
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;
|
||||
|
||||
@ -1,16 +1,33 @@
|
||||
// Thin Meta Graph API client for Facebook Pages + Instagram (via Facebook Login / System User token).
|
||||
import { loadEnv } from "./env.js";
|
||||
import * as vault from "./platforms/vault.js";
|
||||
loadEnv();
|
||||
|
||||
const VERSION = process.env.META_API_VERSION || "v23.0";
|
||||
const BASE = `https://graph.facebook.com/${VERSION}`;
|
||||
|
||||
// The agency's Meta system user token: from .env, or saved in Settings (encrypted in the vault) for hosted workspaces.
|
||||
function systemToken() {
|
||||
const t = process.env.META_SYSTEM_USER_TOKEN;
|
||||
if (!t || t.startsWith("PASTE")) {
|
||||
throw new Error("META_SYSTEM_USER_TOKEN is missing. Put it in the .env file in the project root.");
|
||||
const env = process.env.META_SYSTEM_USER_TOKEN;
|
||||
if (env && !env.startsWith("PASTE")) return env;
|
||||
const saved = vault.appCredentials("meta");
|
||||
if (saved?.clientSecret) return saved.clientSecret;
|
||||
throw new Error("Meta isn't connected. An admin adds the Meta system user token in Settings → Meta (or META_SYSTEM_USER_TOKEN in .env).");
|
||||
}
|
||||
return t;
|
||||
/** Save the system user token from Settings; checks it works first. */
|
||||
export async function saveSystemToken(token, by) {
|
||||
const t = String(token || "").trim();
|
||||
if (!/^EA[A-Za-z0-9]{20,}$/.test(t)) throw Object.assign(new Error("That doesn't look like a Meta access token (it starts with EA…)."), { status: 400 });
|
||||
const r = await fetch(`${BASE}/me?fields=id,name`, { headers: { Authorization: `Bearer ${t}` } });
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw Object.assign(new Error(`Meta didn't accept the token: ${j.error?.message || r.status}`), { status: 400 });
|
||||
vault.saveApp("meta", { clientId: j.name || j.id, clientSecret: t }, by);
|
||||
accountCache = null;
|
||||
return { name: j.name || j.id };
|
||||
}
|
||||
export function metaSource() {
|
||||
if (process.env.META_SYSTEM_USER_TOKEN && !process.env.META_SYSTEM_USER_TOKEN.startsWith("PASTE")) return "env";
|
||||
return vault.appCredentials("meta") ? "settings" : null;
|
||||
}
|
||||
|
||||
export async function graph(pathname, params = {}, { method = "GET", token } = {}) {
|
||||
@ -126,7 +143,7 @@ const days = (n) => Math.floor(Date.now() / 1000) - n * 86400;
|
||||
export const IG_ACCOUNT_METRICS = ["reach", "views", "accounts_engaged", "total_interactions", "likes", "comments", "shares", "saves", "follows_and_unfollows", "profile_links_taps"];
|
||||
|
||||
export async function igAccountInsights(igId, { sinceDays = 28, metrics = IG_ACCOUNT_METRICS } = {}) {
|
||||
const profile = await graph(igId, { fields: "id,username,name,biography,followers_count,follows_count,media_count,website" });
|
||||
const profile = await graph(igId, { fields: "id,username,name,biography,followers_count,follows_count,media_count,website,profile_picture_url" });
|
||||
// total_value metrics allow max 30-day windows
|
||||
const window = { period: "day", metric_type: "total_value", since: days(Math.min(sinceDays, 30)), until: days(0) };
|
||||
const plain = metrics.filter((m) => m !== "follows_and_unfollows");
|
||||
@ -148,7 +165,7 @@ export async function igAccountInsights(igId, { sinceDays = 28, metrics = IG_ACC
|
||||
|
||||
export async function igRecentMedia(igId, { limit = 12, withInsights = true } = {}) {
|
||||
const r = await graph(`${igId}/media`, {
|
||||
fields: "id,caption,media_type,media_product_type,permalink,timestamp,like_count,comments_count",
|
||||
fields: "id,caption,media_type,media_product_type,media_url,thumbnail_url,permalink,timestamp,like_count,comments_count",
|
||||
limit,
|
||||
});
|
||||
const media = r.data || [];
|
||||
@ -204,7 +221,7 @@ export async function pageRecentPosts(account, { limit = 10 } = {}) {
|
||||
needPage(account);
|
||||
const r = await graph(
|
||||
`${account.page_id}/posts`,
|
||||
{ fields: "id,message,created_time,permalink_url,shares,reactions.summary(total_count).limit(0),comments.summary(total_count).limit(0)", limit },
|
||||
{ fields: "id,message,created_time,permalink_url,full_picture,shares,reactions.summary(total_count).limit(0),comments.summary(total_count).limit(0)", limit },
|
||||
{ token: account.page_token }
|
||||
);
|
||||
return (r.data || []).map((p) => ({
|
||||
@ -212,6 +229,7 @@ export async function pageRecentPosts(account, { limit = 10 } = {}) {
|
||||
created_time: p.created_time,
|
||||
permalink_url: p.permalink_url,
|
||||
message: p.message,
|
||||
picture: p.full_picture || null,
|
||||
reactions: p.reactions?.summary?.total_count ?? 0,
|
||||
comments: p.comments?.summary?.total_count ?? 0,
|
||||
shares: p.shares?.count ?? 0,
|
||||
|
||||
128
connector/src/plan.js
Normal file
128
connector/src/plan.js
Normal file
@ -0,0 +1,128 @@
|
||||
// Content plan: what each client will post and when, before it exists as finished work.
|
||||
// Flow per item: idea → brief → production → ready. Once scheduled it lives in the queue (schedule.js);
|
||||
// the calendar view merges plan items, library items with a date, and queued/published posts.
|
||||
// Shared by the dashboard (calendar UI) and the MCP connector (weekly-plan skill adds items).
|
||||
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";
|
||||
import * as lib from "./library.js";
|
||||
|
||||
const FILE = process.env.QUORLA_PLAN_FILE || path.join(PROJECT_ROOT, "data", "plan.json");
|
||||
const CLIENTS = path.join(PROJECT_ROOT, "clients");
|
||||
fs.mkdirSync(path.dirname(FILE), { 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 STAGES = ["idea", "brief", "production", "ready"];
|
||||
export const FORMATS = ["IMAGE", "REELS", "CAROUSEL", "STORY", "TEXT"];
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
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;
|
||||
}
|
||||
function clean(patch) {
|
||||
const out = {};
|
||||
if ("title" in patch) { out.title = String(patch.title || "").trim().slice(0, 140); if (!out.title) throw err("Give the item a title."); }
|
||||
if ("date" in patch) { if (!DATE_RE.test(String(patch.date))) throw err("Date must be YYYY-MM-DD."); out.date = patch.date; }
|
||||
if ("time" in patch) { const t = String(patch.time || ""); if (t && !/^\d{2}:\d{2}$/.test(t)) throw err("Time must be HH:MM."); out.time = t; }
|
||||
if ("status" in patch) { if (!STAGES.includes(patch.status)) throw err(`Status must be one of: ${STAGES.join(", ")}.`); out.status = patch.status; }
|
||||
if ("format" in patch) { if (!FORMATS.includes(patch.format)) throw err(`Format must be one of: ${FORMATS.join(", ")}.`); out.format = patch.format; }
|
||||
if ("platforms" in patch) out.platforms = (patch.platforms || []).map(String).slice(0, 10);
|
||||
for (const k of ["brief", "caption", "pillar", "assignee", "notes", "taskRef", "libraryItem"]) if (k in patch) out[k] = String(patch[k] ?? "").slice(0, 5000);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function listItems({ client, from, to } = {}) {
|
||||
return read().filter((i) => (!client || i.client === client) && (!from || i.date >= from) && (!to || i.date <= to));
|
||||
}
|
||||
|
||||
export function createItem(data, by = "team") {
|
||||
const client = checkClient(data.client);
|
||||
const fields = clean({ title: data.title, date: data.date, status: data.status || "idea", format: data.format || "IMAGE", platforms: data.platforms || [], time: data.time || "", brief: data.brief || "", caption: data.caption || "", pillar: data.pillar || "", assignee: data.assignee || "", notes: data.notes || "" });
|
||||
const item = { id: crypto.randomUUID().slice(0, 8), client, ...fields, createdBy: by, createdAt: now(), updatedAt: now() };
|
||||
const items = read(); items.push(item); write(items);
|
||||
return item;
|
||||
}
|
||||
|
||||
export function updateItem(id, patch, by = "team") {
|
||||
const items = read(); const i = items.find((x) => x.id === id);
|
||||
if (!i) throw err("Plan item not found.", 404);
|
||||
Object.assign(i, clean(patch), { updatedBy: by, updatedAt: now() });
|
||||
write(items);
|
||||
return i;
|
||||
}
|
||||
|
||||
export function removeItem(id) {
|
||||
const items = read(); const n = items.length;
|
||||
const kept = items.filter((x) => x.id !== id);
|
||||
if (kept.length === n) throw err("Plan item not found.", 404);
|
||||
write(kept);
|
||||
}
|
||||
|
||||
/** Local calendar date (YYYY-MM-DD) and time of an instant in a time zone. */
|
||||
function localParts(iso, tz) {
|
||||
const p = new Intl.DateTimeFormat("en-CA", { timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false })
|
||||
.formatToParts(new Date(iso)).reduce((o, x) => ((o[x.type] = x.value), o), {});
|
||||
return { date: `${p.year}-${p.month}-${p.day}`, time: `${p.hour === "24" ? "00" : p.hour}:${p.minute}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything on a client's calendar between two dates (inclusive, client's local dates):
|
||||
* plan items (kind "plan"), library items with a target date ("library"), and queued/published posts ("post").
|
||||
* Each entry has a unified `stage`: idea | brief | production | ready | scheduled | published | failed.
|
||||
*/
|
||||
export function calendar({ client, from, to }) {
|
||||
checkClient(client);
|
||||
if (!DATE_RE.test(from) || !DATE_RE.test(to)) throw err("from and to must be YYYY-MM-DD.");
|
||||
const tz = sched.clientInfo(client).timezone;
|
||||
const libItems = lib.listItems({ client });
|
||||
const activeLib = new Set(libItems.filter((l) => l.status !== "archived").map((l) => l.id));
|
||||
// A plan item whose work was uploaded shows as the library card (with the plan's title) instead of twice.
|
||||
const plans = listItems({ client, from, to });
|
||||
const planById = Object.fromEntries(listItems({ client }).map((i) => [i.id, i]));
|
||||
const out = plans.filter((i) => !(i.libraryItem && activeLib.has(i.libraryItem))).map((i) => ({ ...i, kind: "plan", stage: i.status, editable: true }));
|
||||
|
||||
for (const l of libItems) {
|
||||
if (!l.forDate || l.forDate < from || l.forDate > to || ["scheduled", "archived"].includes(l.status)) continue;
|
||||
out.push({
|
||||
id: l.id, kind: "library", client, title: planById[l.planItem]?.title || l.title, date: l.forDate, time: planById[l.planItem]?.time || "", format: l.format, platforms: planById[l.planItem]?.platforms || [],
|
||||
assignee: planById[l.planItem]?.assignee || l.uploadedBy, planItem: l.planItem || null,
|
||||
stage: l.status === "approved" ? "ready" : "production", detail: l.status === "changes" ? "Changes requested" : l.status === "submitted" ? "Waiting for review" : "Approved in the library",
|
||||
caption: l.captionIdea || "", files: l.files || [], editable: true,
|
||||
});
|
||||
}
|
||||
for (const p of sched.listPosts({ client })) {
|
||||
if (p.status === "cancelled") continue;
|
||||
const at = localParts(p.publishAt, tz);
|
||||
if (at.date < from || at.date > to) continue;
|
||||
const stage = p.status === "published" || p.status === "partial" ? "published" : p.status === "failed" ? "failed" : "scheduled";
|
||||
out.push({
|
||||
id: p.id, kind: "post", client, title: (p.options?.youtube?.title || p.caption || "").split("\n")[0].slice(0, 90) || "Untitled post",
|
||||
date: at.date, time: at.time, format: p.type, platforms: p.platforms, stage,
|
||||
detail: p.status === "draft" ? "Draft, waiting for approval" : p.status === "approved" ? "Approved, will publish" : p.status === "partial" ? "Partly published" : null,
|
||||
caption: p.caption || "", files: p.media || [], editable: ["draft", "failed"].includes(p.status), postStatus: p.status,
|
||||
});
|
||||
}
|
||||
const order = { published: 0, failed: 1, scheduled: 2, ready: 3, production: 4, brief: 5, idea: 6 };
|
||||
return { timezone: tz, items: out.sort((a, b) => a.date.localeCompare(b.date) || (a.time || "99").localeCompare(b.time || "99") || order[a.stage] - order[b.stage]) };
|
||||
}
|
||||
|
||||
/** Move anything on the calendar to another day (keeps the time). Posts can only move while they're drafts. */
|
||||
export function moveEntry(kind, id, date, by = "team") {
|
||||
if (!DATE_RE.test(String(date))) throw err("Date must be YYYY-MM-DD.");
|
||||
if (kind === "plan") return updateItem(id, { date }, by);
|
||||
if (kind === "library") return lib.updateItem(id, { forDate: date }, by);
|
||||
if (kind === "post") {
|
||||
const p = sched.getPost(id);
|
||||
if (!["draft", "failed"].includes(p.status)) throw err("Only drafts can be moved. Cancel the approval first.");
|
||||
const { time } = localParts(p.publishAt, sched.clientInfo(p.client).timezone);
|
||||
return sched.updatePost(id, { localTime: `${date} ${time}` }, by);
|
||||
}
|
||||
throw err("Unknown calendar item.");
|
||||
}
|
||||
@ -47,6 +47,28 @@ export default {
|
||||
return out;
|
||||
},
|
||||
|
||||
/** Latest reviews on the chosen location (Business Profile API v4). */
|
||||
async reviews(tokens, target, { pageSize = 30 } = {}) {
|
||||
const STARS = { ONE: 1, TWO: 2, THREE: 3, FOUR: 4, FIVE: 5 };
|
||||
const { data } = await request("Google Business Profile", `https://mybusiness.googleapis.com/v4/${target.id}/reviews`, { token: tokens.accessToken, query: { pageSize, orderBy: "updateTime desc" } });
|
||||
return {
|
||||
average: data.averageRating ?? null, total: data.totalReviewCount ?? null,
|
||||
reviews: (data.reviews || []).map((r) => ({
|
||||
id: r.name, author: r.reviewer?.isAnonymous ? "A Google user" : r.reviewer?.displayName || "A Google user", avatar: r.reviewer?.profilePhotoUrl || null,
|
||||
stars: STARS[r.starRating] ?? null, text: r.comment || "", at: r.createTime, updatedAt: r.updateTime,
|
||||
reply: r.reviewReply ? { text: r.reviewReply.comment, at: r.reviewReply.updateTime } : null,
|
||||
})),
|
||||
};
|
||||
},
|
||||
|
||||
async replyReview(tokens, reviewName, comment) {
|
||||
const text = String(comment || "").trim();
|
||||
if (!text) throw new Error("Write a reply first.");
|
||||
if (text.length > 4096) throw new Error("Google allows replies up to 4,096 characters.");
|
||||
const { data } = await request("Google Business Profile", `https://mybusiness.googleapis.com/v4/${reviewName}/reply`, { token: tokens.accessToken, method: "PUT", json: { comment: text } });
|
||||
return { text: data.comment, at: data.updateTime };
|
||||
},
|
||||
|
||||
async publish({ tokens, conn, post, opts, caption }) {
|
||||
const location = conn.target?.id;
|
||||
if (!location) throw new Error("Google Business Profile: pick the business location on the Connections page first.");
|
||||
|
||||
@ -97,6 +97,44 @@ export async function setTarget(connId, targetId) {
|
||||
return vault.updateConnection(connId, { target: t });
|
||||
}
|
||||
|
||||
/** Per-post numbers for platforms whose adapter can read them (YouTube today). ids: the platform's post ids. */
|
||||
export async function postStats(platform, client, ids) {
|
||||
const a = adapter(platform);
|
||||
if (!a.stats || !ids.length) return {};
|
||||
const conn = vault.connectionFor(client, platform);
|
||||
if (!conn) return {};
|
||||
const { tokens } = await tokensFor(conn.id);
|
||||
if (platform === "youtube") quota.ensure("youtube", null, "read");
|
||||
const out = await a.stats(tokens, ids);
|
||||
if (platform === "youtube") quota.record("youtube", null, "read");
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Google reviews for a client's Business Profile location. */
|
||||
export async function gbpReviews(client) {
|
||||
const conn = vault.connectionFor(client, "gbp");
|
||||
if (!conn) throw err("Google Business Profile isn't connected for this client. Connect it on the Connections page.");
|
||||
if (!conn.target) throw err("Choose the business location on the Connections page first.");
|
||||
const { tokens } = await tokensFor(conn.id);
|
||||
return { location: conn.target.name, ...(await adapter("gbp").reviews(tokens, conn.target)) };
|
||||
}
|
||||
export async function gbpReply(client, reviewId, text) {
|
||||
const conn = vault.connectionFor(client, "gbp");
|
||||
if (!conn?.target || !String(reviewId).startsWith(`${conn.target.id}/reviews/`)) throw err("That review isn't on this client's location.");
|
||||
const { tokens } = await tokensFor(conn.id);
|
||||
return adapter("gbp").replyReview(tokens, reviewId, text);
|
||||
}
|
||||
/** Latest YouTube comments on the client's channel. */
|
||||
export async function youtubeComments(client) {
|
||||
const conn = vault.connectionFor(client, "youtube");
|
||||
if (!conn) throw err("YouTube isn't connected for this client.");
|
||||
const { tokens } = await tokensFor(conn.id);
|
||||
quota.ensure("youtube", null, "read");
|
||||
const out = await adapter("youtube").comments(tokens, conn.account);
|
||||
quota.record("youtube", null, "read");
|
||||
return { channel: conn.account?.name, comments: out };
|
||||
}
|
||||
|
||||
/** Re-read the account (name, followers) to check the connection still works. */
|
||||
export async function checkConnection(connId) {
|
||||
const { conn, tokens } = await tokensFor(connId);
|
||||
|
||||
@ -32,6 +32,25 @@ export default {
|
||||
};
|
||||
},
|
||||
|
||||
/** Latest comments across the channel's videos (read-only: replying needs a broader scope, so reply on YouTube). */
|
||||
async comments(tokens, account, { max = 30 } = {}) {
|
||||
const { data } = await request("YouTube", `${API}/commentThreads`, { token: tokens.accessToken, query: { part: "snippet", allThreadsRelatedToChannelId: account.id, maxResults: max, order: "time" } });
|
||||
return (data.items || []).map((t) => {
|
||||
const c = t.snippet?.topLevelComment?.snippet || {};
|
||||
return { id: t.id, author: c.authorDisplayName, avatar: c.authorProfileImageUrl || null, text: c.textOriginal || "", at: c.publishedAt, likes: c.likeCount ?? 0, replies: t.snippet?.totalReplyCount ?? 0, videoId: t.snippet?.videoId, url: `https://www.youtube.com/watch?v=${t.snippet?.videoId}&lc=${t.id}` };
|
||||
});
|
||||
},
|
||||
|
||||
/** View/like/comment counts for uploaded videos (1 quota unit per call, up to 50 ids). */
|
||||
async stats(tokens, ids) {
|
||||
if (!ids.length) return {};
|
||||
const { data } = await request("YouTube", `${API}/videos`, { token: tokens.accessToken, query: { part: "statistics,snippet", id: ids.slice(0, 50).join(",") } });
|
||||
return Object.fromEntries((data.items || []).map((v) => [v.id, {
|
||||
views: Number(v.statistics?.viewCount ?? 0), likes: Number(v.statistics?.likeCount ?? 0), comments: Number(v.statistics?.commentCount ?? 0),
|
||||
thumb: v.snippet?.thumbnails?.medium?.url || v.snippet?.thumbnails?.default?.url || null,
|
||||
}]));
|
||||
},
|
||||
|
||||
async publish({ tokens, post, opts, caption }) {
|
||||
const src = (post.media || [])[0];
|
||||
if (!src || !isVideo(src)) throw new Error("YouTube needs one video file.");
|
||||
|
||||
@ -4,10 +4,12 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { PROJECT_ROOT } from "./env.js";
|
||||
import * as meta from "./meta.js";
|
||||
import * as extra from "./platforms/index.js";
|
||||
import * as quota from "./platforms/quota.js";
|
||||
import * as brandkit from "./brandkit.js";
|
||||
|
||||
const META_PLATFORMS = ["instagram", "facebook"];
|
||||
export const ALL_PLATFORMS = [...META_PLATFORMS, ...extra.PLATFORM_IDS];
|
||||
@ -21,6 +23,9 @@ fs.mkdirSync(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); };
|
||||
/** Things that happened to posts, for notifications (the dashboard listens; the MCP process doesn't). */
|
||||
export const events = new EventEmitter();
|
||||
const emit = (type, post, extra = {}) => { try { events.emit("event", { type, post, ...extra }); } catch { /* listeners must not break publishing */ } };
|
||||
const log = (entry) => fs.appendFileSync(LOG, JSON.stringify({ at: new Date().toISOString(), ...entry }) + "\n");
|
||||
const err = (msg, status = 400) => Object.assign(new Error(msg), { status });
|
||||
|
||||
@ -86,6 +91,19 @@ function quotaAccount(client, platform) {
|
||||
}
|
||||
|
||||
/** Warnings (not errors) when a day's planned posts exceed a platform's limit; extra posts then wait for the reset. */
|
||||
/** Brand-kit and writing checks on every caption a post will use. Naming the agency is refused outright. */
|
||||
function brandWarnings(post) {
|
||||
const captions = [[null, post.caption], ["facebook", post.fbCaption], ...Object.entries(post.options || {}).map(([pl, o]) => [pl, o?.caption])].filter(([, c]) => c);
|
||||
const out = [];
|
||||
for (const [pl, c] of captions) {
|
||||
for (const w of brandkit.checkCaption(post.client, c, { platforms: pl ? [pl] : post.platforms })) {
|
||||
if (/never name the agency/.test(w.text)) throw err(`${pl ? `${pl} caption: ` : ""}${w.text}`);
|
||||
out.push(`${pl ? `${pl} caption: ` : ""}${w.text}`);
|
||||
}
|
||||
}
|
||||
return [...new Set(out)];
|
||||
}
|
||||
|
||||
function quotaWarnings(post, items) {
|
||||
const at = new Date(post.publishAt).getTime();
|
||||
const out = [];
|
||||
@ -133,8 +151,9 @@ export function createPost({ client, platforms = ["instagram"], type = "IMAGE",
|
||||
results: {}, errors: {},
|
||||
};
|
||||
const items = read();
|
||||
post.warnings = quotaWarnings(post, items);
|
||||
post.warnings = [...quotaWarnings(post, items), ...brandWarnings(post)];
|
||||
items.push(post); write(items);
|
||||
emit("draft_created", post);
|
||||
return post;
|
||||
}
|
||||
|
||||
@ -146,10 +165,33 @@ export function updatePost(id, patch, by = "team") {
|
||||
for (const pl of p.platforms) if (!META_PLATFORMS.includes(pl)) extra.validate(pl, p.client, p);
|
||||
if (patch.localTime) { const t = zonedToUtc(patch.localTime, p.timezone); p.publishAt = t.toISOString(); p.localTime = formatInTz(t, p.timezone); }
|
||||
p.status = "draft"; p.updatedBy = by; p.updatedAt = new Date().toISOString();
|
||||
p.warnings = quotaWarnings(p, items);
|
||||
p.warnings = [...quotaWarnings(p, items), ...brandWarnings(p)];
|
||||
// Changed after the client looked at it: ask them again.
|
||||
if (p.clientReview && p.clientReview.status !== "pending") p.clientReview = { ...p.clientReview, status: "pending", previous: p.clientReview.status, requestedAt: p.updatedAt, note: "", by: null };
|
||||
write(items); return p;
|
||||
}
|
||||
|
||||
/** Put a draft in front of the client (their approval link shows it). The admin's own approval is still needed to publish. */
|
||||
export function requestClientReview(id, by = "team") {
|
||||
const items = read(); const p = items.find((x) => x.id === id);
|
||||
if (!p) throw err("Post not found.", 404);
|
||||
if (p.status !== "draft") throw err("Only drafts can be sent to the client.");
|
||||
p.clientReview = { status: "pending", requestedBy: by, requestedAt: new Date().toISOString(), note: "", by: null, decidedAt: null };
|
||||
write(items); emit("client_review_requested", p); return p;
|
||||
}
|
||||
|
||||
/** The client's answer from their approval link: approved, or changes with a note. */
|
||||
export function clientDecision(id, client, { decision, note = "", name = "" }) {
|
||||
if (!["approved", "changes"].includes(decision)) throw err("Choose approve or request changes.");
|
||||
if (decision === "changes" && !String(note).trim()) throw err("Tell us what to change.");
|
||||
const items = read(); const p = items.find((x) => x.id === id && x.client === client);
|
||||
if (!p || !p.clientReview) throw err("Post not found.", 404);
|
||||
if (p.status !== "draft") throw err("This post has already moved on; ask your agency if something needs changing.");
|
||||
p.clientReview = { ...p.clientReview, status: decision, note: String(note).slice(0, 2000), by: String(name).slice(0, 80) || "Client", decidedAt: new Date().toISOString() };
|
||||
log({ action: `client_${decision}`, post: id, client, by: p.clientReview.by });
|
||||
write(items); emit("client_decision", p); return p;
|
||||
}
|
||||
|
||||
/** A person approves the post. Facebook parts more than 10 min away are handed to Facebook's own scheduler. */
|
||||
export async function approvePost(id, by) {
|
||||
if (!by) throw err("Approval needs the name of the person approving.");
|
||||
@ -254,6 +296,7 @@ export async function runDue({ now = Date.now() } = {}) {
|
||||
if (p.retryAt && new Date(p.retryAt) > now) continue; // waiting for a platform quota to reset
|
||||
p.status = "publishing"; write(items);
|
||||
await publishNow(p); write(items);
|
||||
emit(p.status === "published" ? "published" : p.status === "partial" ? "partly_published" : p.status === "failed" ? "failed" : "waiting", p);
|
||||
done.push({ id: p.id, client: p.client, status: p.status, errors: p.errors });
|
||||
}
|
||||
} finally { fs.rmSync(LOCK, { force: true }); }
|
||||
|
||||
@ -10,6 +10,9 @@ import { PROJECT_ROOT } from "./env.js";
|
||||
import * as meta from "./meta.js";
|
||||
import * as sched from "./schedule.js";
|
||||
import * as lib from "./library.js";
|
||||
import * as plan from "./plan.js";
|
||||
import * as competitors from "./competitors.js";
|
||||
import * as brandkit from "./brandkit.js";
|
||||
import * as extra from "./platforms/index.js";
|
||||
|
||||
const server = new McpServer({ name: "social-manager", version: "0.1.0" });
|
||||
@ -184,4 +187,39 @@ server.tool("schedule_from_library", "Turn an APPROVED library item into a DRAFT
|
||||
caption: z.string().optional(), fb_caption: z.string().optional(), collaborators: z.array(z.string()).default([]), options },
|
||||
wrap(async (a) => lib.scheduleItem(a.id, { platforms: a.platforms, localTime: a.local_time, caption: a.caption, fbCaption: a.fb_caption, collaborators: a.collaborators, options: a.options }, "agent")));
|
||||
|
||||
server.tool("content_plan", "A client's content calendar between two dates: plan items (idea/brief/production/ready), library items with a target date, and queued/published posts. Check this before planning so nothing is doubled.",
|
||||
{ client: z.string(), from: z.string().describe("YYYY-MM-DD"), to: z.string().describe("YYYY-MM-DD") },
|
||||
wrap(async ({ client, from, to }) => plan.calendar({ client, from, to })));
|
||||
|
||||
server.tool("plan_add", "Add items to a client's content calendar (the weekly-plan skill uses this). Each item is a planned post, not a scheduled one: it starts as an idea or brief for the team to produce. Never publishes.",
|
||||
{ client: z.string(), items: z.array(z.object({
|
||||
date: z.string().describe("YYYY-MM-DD in the client's time zone"), time: z.string().optional().describe("HH:MM, optional"),
|
||||
title: z.string().describe("Short working title shown on the calendar card"),
|
||||
format: z.enum(["IMAGE", "REELS", "CAROUSEL", "STORY", "TEXT"]).default("IMAGE"),
|
||||
platforms: z.array(z.string()).default([]), status: z.enum(["idea", "brief", "production", "ready"]).default("brief"),
|
||||
brief: z.string().default("").describe("Creative brief for the designer/editor"), caption: z.string().default("").describe("Draft caption"),
|
||||
pillar: z.string().default(""), assignee: z.string().default(""),
|
||||
})).min(1).max(30) },
|
||||
wrap(async ({ client, items }) => items.map((i) => plan.createItem({ client, ...i }, "agent"))));
|
||||
|
||||
server.tool("plan_update", "Update a content-plan item (status, date, brief, caption, assignee...).",
|
||||
{ id: z.string(), patch: z.object({ date: z.string().optional(), time: z.string().optional(), title: z.string().optional(), status: z.enum(["idea", "brief", "production", "ready"]).optional(),
|
||||
format: z.enum(["IMAGE", "REELS", "CAROUSEL", "STORY", "TEXT"]).optional(), platforms: z.array(z.string()).optional(), brief: z.string().optional(), caption: z.string().optional(), assignee: z.string().optional() }) },
|
||||
wrap(async ({ id, patch }) => plan.updateItem(id, patch, "agent")));
|
||||
|
||||
server.tool("competitors", "The Instagram competitors tracked for a client, with followers, growth, posts per week, average engagement, format mix and top posts (the dashboard's Competitors page). Use in audits instead of one-off lookups.",
|
||||
{ client: z.string() },
|
||||
wrap(async ({ client }) => competitors.compare(client, null)));
|
||||
|
||||
server.tool("competitor_add", "Start tracking an Instagram competitor for a client (public Business/Creator accounts only). Use for the 3–5 closest local competitors you identify in an audit; say why in note.",
|
||||
{ client: z.string(), username: z.string(), note: z.string().default("") },
|
||||
wrap(async ({ client, username, note }) => competitors.add(client, username, { by: "agent", source: "suggested", note })));
|
||||
|
||||
server.tool("brand_kit", "A client's saved hashtag sets, approved phrases/CTAs and words to avoid. Read it before writing captions for that client.",
|
||||
{ client: z.string() }, wrap(async ({ client }) => brandkit.get(client)));
|
||||
|
||||
server.tool("caption_check", "Check a caption against the client's brand kit and our writing rules before scheduling (agency name, avoid list, generic phrases, hashtag count, platform length).",
|
||||
{ client: z.string(), caption: z.string(), platforms: z.array(z.string()).default([]) },
|
||||
wrap(async ({ client, caption, platforms }) => brandkit.checkCaption(client, caption, { platforms })));
|
||||
|
||||
await server.connect(new StdioServerTransport());
|
||||
|
||||
24
connector/test/brandkit.test.mjs
Normal file
24
connector/test/brandkit.test.mjs
Normal file
@ -0,0 +1,24 @@
|
||||
// Brand kit and caption checks (offline, temp file).
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
process.env.QUORLA_BRANDKIT_FILE = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "quorla-bk-")), "bk.json");
|
||||
const bk = await import("../src/brandkit.js");
|
||||
const { PROJECT_ROOT } = await import("../src/env.js");
|
||||
const client = fs.readdirSync(path.join(PROJECT_ROOT, "clients")).find((d) => !d.startsWith("_") && !/metatron/i.test(d) && fs.existsSync(path.join(PROJECT_ROOT, "clients", d, "client.json")));
|
||||
|
||||
test("brand kit: saves clean hashtag sets and lists; caption checks", { skip: !client && "no client folder" }, () => {
|
||||
const k = bk.save(client, { hashtagSets: [{ name: "Local", tags: "waterloo, #kitchener #KW bad-tag!" }], phrases: "Order online\n\nBook a table", avoid: "cheap\nbest in town" });
|
||||
assert.deepEqual(k.hashtagSets[0].tags, ["#waterloo", "#kitchener", "#KW"]);
|
||||
assert.deepEqual(k.phrases, ["Order online", "Book a table"]);
|
||||
const w = bk.checkCaption(client, "The best in town, made by Quorla. Unlock flavour! " + "#a ".repeat(12), { platforms: ["threads"] }).map((x) => x.level + ":" + x.text.slice(0, 25));
|
||||
assert.ok(w.some((x) => x.startsWith("bad:Mentions “quorla”")));
|
||||
assert.ok(w.some((x) => x.startsWith("warn:Uses “best in town”")));
|
||||
assert.ok(w.some((x) => x.startsWith("warn:“unlock”")));
|
||||
assert.ok(w.some((x) => x.startsWith("warn:12 hashtags")));
|
||||
assert.equal(bk.checkCaption(client, "x".repeat(600), { platforms: ["threads"] })[0].level, "bad");
|
||||
assert.deepEqual(bk.checkCaption(client, "Fresh focaccia at 8. First 20 people get a free cortado.", { platforms: ["instagram"] }), []);
|
||||
});
|
||||
33
connector/test/competitors.test.mjs
Normal file
33
connector/test/competitors.test.mjs
Normal file
@ -0,0 +1,33 @@
|
||||
// Competitor comparison maths, offline (no Instagram calls): median engagement, rate, growth, format mix, top posts.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "quorla-comp-"));
|
||||
process.env.QUORLA_COMPETITORS_FILE = path.join(TMP, "competitors.json");
|
||||
const { PROJECT_ROOT } = await import("../src/env.js");
|
||||
const client = fs.readdirSync(path.join(PROJECT_ROOT, "clients")).find((d) => !d.startsWith("_") && fs.existsSync(path.join(PROJECT_ROOT, "clients", d, "client.json")));
|
||||
|
||||
const day = (n) => new Date(Date.now() - n * 86400000);
|
||||
const media = [5, 10, 20, 40, 1000].map((likes, i) => ({ id: `m${i}`, at: day(i * 3).toISOString(), likes, comments: 0, type: i % 2 ? "Reel" : "Image", permalink: `https://x/${i}` }));
|
||||
|
||||
test("compare: median engagement ignores one viral post; growth from history", { skip: !client && "no client folder" }, async () => {
|
||||
fs.writeFileSync(process.env.QUORLA_COMPETITORS_FILE, JSON.stringify({
|
||||
[client]: { accounts: [{ username: "rival", name: "Rival", source: "manual", snapshots: [
|
||||
{ date: day(20).toISOString().slice(0, 10), followers: 900 },
|
||||
{ date: day(0).toISOString().slice(0, 10), followers: 1000, media },
|
||||
] }] },
|
||||
}));
|
||||
const c = await import("../src/competitors.js");
|
||||
const r = c.compare(client, { username: "me", followers: 500, posts: [], history: [] }).competitors[0];
|
||||
assert.equal(r.avgEngagement, 20, "median of 5,10,20,40,1000");
|
||||
assert.equal(r.engagementRate, 2);
|
||||
assert.equal(r.growth30, 100);
|
||||
assert.equal(r.postsPerWeek, 1.3, "5 posts in 28 days");
|
||||
assert.deepEqual(r.formatMix, { Image: 3, Reel: 2 });
|
||||
assert.equal(r.topPosts[0].likes, 1000);
|
||||
assert.equal(c.listTracked(client)[0].username, "rival");
|
||||
await assert.rejects(c.add(client, "not a handle!!"), /Instagram handle/);
|
||||
});
|
||||
64
connector/test/control.test.mjs
Normal file
64
connector/test/control.test.mjs
Normal file
@ -0,0 +1,64 @@
|
||||
// Hosted control centre: Stripe webhook signatures, sign-up, subscription lifecycle, provisioning tasks.
|
||||
// Temp data folders only; Stripe is simulated.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "quorla-ctl-"));
|
||||
Object.assign(process.env, {
|
||||
QUORLA_CONTROL_DIR: path.join(TMP, "control"), QUORLA_DASHBOARD_DATA: path.join(TMP, "dash"), QUORLA_CONTROL: "true",
|
||||
STRIPE_SECRET_KEY: "sk_test_x", STRIPE_PRICE_ID: "price_x", STRIPE_WEBHOOK_SECRET: "whsec_test", PROVISION_TOKEN: "p".repeat(32), SMTP_HOST: "",
|
||||
});
|
||||
const control = await import("../../dashboard/server/control.js");
|
||||
|
||||
const sign = (body, secret = "whsec_test", t = Math.floor(Date.now() / 1000)) => `t=${t},v1=${crypto.createHmac("sha256", secret).update(`${t}.${body}`).digest("hex")}`;
|
||||
|
||||
test("webhook signatures: valid accepted; wrong secret, old or missing rejected", () => {
|
||||
const body = JSON.stringify({ type: "ping" });
|
||||
assert.equal(control.verifyWebhook(body, sign(body)).type, "ping");
|
||||
assert.throws(() => control.verifyWebhook(body, sign(body, "whsec_other")), /doesn't match/);
|
||||
assert.throws(() => control.verifyWebhook(body, sign(body, "whsec_test", Math.floor(Date.now() / 1000) - 1000)), /too old/);
|
||||
assert.throws(() => control.verifyWebhook(body, ""), /Bad signature/);
|
||||
});
|
||||
|
||||
test("sign-up → Stripe Checkout → paid → provisioning → running → billing states", async () => {
|
||||
const realFetch = globalThis.fetch;
|
||||
let sent;
|
||||
globalThis.fetch = async (url, init) => { sent = { url: String(url), body: String(init.body) }; return new Response(JSON.stringify({ url: "https://checkout.stripe.test/s1" }), { status: 200 }); };
|
||||
try {
|
||||
await assert.rejects(control.signup({ agencyName: "A", ownerEmail: "x", slug: "ab" }, "https://app.test"), /valid work email/);
|
||||
await assert.rejects(control.signup({ agencyName: "A", ownerEmail: "a@b.co", slug: "www" }, "https://app.test"), /Workspace address/);
|
||||
const r = await control.signup({ agencyName: "Northstar Digital", ownerName: "Ana", ownerEmail: "ana@northstar.test", slug: "northstar" }, "https://app.test");
|
||||
assert.equal(r.url, "https://checkout.stripe.test/s1");
|
||||
assert.match(sent.url, /checkout\/sessions$/);
|
||||
assert.match(decodeURIComponent(sent.body), /metadata\[slug\]=northstar/);
|
||||
} finally { globalThis.fetch = realFetch; }
|
||||
|
||||
assert.equal(control.list()[0].status, "pending_payment");
|
||||
assert.deepEqual(control.tasks(), [], "nothing to do before payment");
|
||||
|
||||
await control.onStripeEvent({ type: "checkout.session.completed", data: { object: { client_reference_id: "northstar", metadata: { slug: "northstar" }, customer: "cus_1", subscription: "sub_1" } } });
|
||||
const [task] = control.tasks();
|
||||
assert.equal(task.desired, "running");
|
||||
assert.equal(task.port, 4400);
|
||||
assert.ok(task.setupToken);
|
||||
|
||||
assert.throws(() => control.checkProvisionToken("Bearer nope"), /Not allowed/);
|
||||
control.checkProvisionToken(`Bearer ${"p".repeat(32)}`);
|
||||
const t = await control.report("northstar", { actual: "running" });
|
||||
assert.equal(t.status, "active");
|
||||
assert.equal(t.setupToken, undefined, "setup token never leaves the control centre in listings");
|
||||
assert.deepEqual(control.tasks(), []);
|
||||
|
||||
await control.onStripeEvent({ type: "invoice.payment_failed", data: { object: { subscription: "sub_1" } } });
|
||||
assert.equal(control.list()[0].status, "past_due");
|
||||
await control.onStripeEvent({ type: "customer.subscription.updated", data: { object: { id: "sub_1", status: "unpaid", metadata: { slug: "northstar" } } } });
|
||||
assert.equal(control.list()[0].status, "suspended");
|
||||
assert.equal(control.tasks()[0].desired, "stopped");
|
||||
await control.report("northstar", { actual: "stopped" });
|
||||
await control.onStripeEvent({ type: "customer.subscription.updated", data: { object: { id: "sub_1", status: "active", metadata: { slug: "northstar" } } } });
|
||||
assert.equal(control.tasks()[0].desired, "running", "paying again restarts it");
|
||||
});
|
||||
37
connector/test/plan.test.mjs
Normal file
37
connector/test/plan.test.mjs
Normal file
@ -0,0 +1,37 @@
|
||||
// Content plan: create, validate, show on the calendar, move between days, remove. Uses temp files only.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "quorla-plan-"));
|
||||
process.env.QUORLA_PLAN_FILE = path.join(TMP, "plan.json");
|
||||
process.env.QUORLA_SCHEDULE_DIR = path.join(TMP, "schedule");
|
||||
|
||||
const plan = await import("../src/plan.js");
|
||||
const { PROJECT_ROOT } = await import("../src/env.js");
|
||||
const client = fs.readdirSync(path.join(PROJECT_ROOT, "clients")).find((d) => !d.startsWith("_") && fs.existsSync(path.join(PROJECT_ROOT, "clients", d, "client.json")));
|
||||
|
||||
test("plan items: create, validate, calendar, move, remove", { skip: !client && "no client folder" }, () => {
|
||||
const a = plan.createItem({ client, title: "Behind the counter reel", date: "2031-03-04", format: "REELS", status: "brief", brief: "15s, morning prep" }, "t");
|
||||
assert.equal(a.status, "brief");
|
||||
assert.throws(() => plan.createItem({ client, title: "", date: "2031-03-04" }), /title/);
|
||||
assert.throws(() => plan.createItem({ client, title: "x", date: "4 March" }), /YYYY-MM-DD/);
|
||||
assert.throws(() => plan.updateItem(a.id, { status: "published" }), /Status must be/);
|
||||
assert.throws(() => plan.createItem({ client: "no-such-client", title: "x", date: "2031-03-04" }), /Unknown client/);
|
||||
|
||||
const cal = plan.calendar({ client, from: "2031-03-01", to: "2031-03-31" });
|
||||
const mine = cal.items.find((i) => i.id === a.id);
|
||||
assert.equal(mine.kind, "plan");
|
||||
assert.equal(mine.stage, "brief");
|
||||
|
||||
plan.moveEntry("plan", a.id, "2031-03-09");
|
||||
assert.equal(plan.calendar({ client, from: "2031-03-09", to: "2031-03-09" }).items.find((i) => i.id === a.id).date, "2031-03-09");
|
||||
assert.equal(plan.calendar({ client, from: "2031-03-01", to: "2031-03-08" }).items.some((i) => i.id === a.id), false);
|
||||
|
||||
plan.updateItem(a.id, { status: "ready", assignee: "Elena" });
|
||||
assert.equal(plan.listItems({ client }).find((i) => i.id === a.id).assignee, "Elena");
|
||||
plan.removeItem(a.id);
|
||||
assert.equal(plan.listItems({ client }).some((i) => i.id === a.id), false);
|
||||
});
|
||||
@ -346,3 +346,28 @@ test("platform errors include the code and a plain fix", async () => {
|
||||
route("POST", "https://open.tiktokapis.com/v2/post/publish/video/init/", json({ error: { code: "unaudited_client_can_only_post_to_private_accounts", message: "Please review our integration guidelines" } }, 403));
|
||||
await assert.rejects(P.publish("tiktok", "acme", { type: "REELS", media: [VID], caption: "x", options: { tiktok: { privacy: "SELF_ONLY" } } }), /Private account.*unaudited_client_can_only_post_to_private_accounts/);
|
||||
});
|
||||
|
||||
test("Google reviews: list with stars and replies, reply only to this location's reviews", async () => {
|
||||
reset();
|
||||
route("GET", "https://mybusiness.googleapis.com/v4/accounts/1/locations/5/reviews", json({ averageRating: 4.6, totalReviewCount: 52, reviews: [
|
||||
{ name: "accounts/1/locations/5/reviews/r1", reviewer: { displayName: "Sam" }, starRating: "FIVE", comment: "Best croissants", createTime: "2026-10-01T10:00:00Z" },
|
||||
{ name: "accounts/1/locations/5/reviews/r2", reviewer: { isAnonymous: true }, starRating: "TWO", createTime: "2026-10-02T10:00:00Z", reviewReply: { comment: "Sorry!", updateTime: "2026-10-03T10:00:00Z" } },
|
||||
] }));
|
||||
const r = await P.gbpReviews("acme");
|
||||
assert.equal(r.average, 4.6);
|
||||
assert.deepEqual(r.reviews.map((x) => [x.stars, x.author, Boolean(x.reply)]), [[5, "Sam", false], [2, "A Google user", true]]);
|
||||
route("PUT", "https://mybusiness.googleapis.com/v4/accounts/1/locations/5/reviews/r1/reply", json({ comment: "Thanks Sam!", updateTime: "2026-10-04T10:00:00Z" }));
|
||||
assert.equal((await P.gbpReply("acme", "accounts/1/locations/5/reviews/r1", "Thanks Sam!")).text, "Thanks Sam!");
|
||||
await assert.rejects(P.gbpReply("acme", "accounts/9/locations/9/reviews/x", "hi"), /isn't on this client's location/);
|
||||
await assert.rejects(P.gbpReply("acme", "accounts/1/locations/5/reviews/r1", " "), /Write a reply/);
|
||||
});
|
||||
|
||||
test("YouTube comments: latest threads on the channel with a link to reply", async () => {
|
||||
reset();
|
||||
route("POST", "https://oauth2.googleapis.com/token", json({ access_token: "yt-new", expires_in: 3600 }));
|
||||
route("GET", "https://www.googleapis.com/youtube/v3/commentThreads", json({ items: [{ id: "t1", snippet: { videoId: "v1", totalReplyCount: 2, topLevelComment: { snippet: { authorDisplayName: "Ana", textOriginal: "Love this", publishedAt: "2026-10-05T10:00:00Z", likeCount: 3 } } } }] }));
|
||||
const r = await P.youtubeComments("acme");
|
||||
assert.equal(r.comments[0].author, "Ana");
|
||||
assert.equal(r.comments[0].url, "https://www.youtube.com/watch?v=v1&lc=t1");
|
||||
assert.match(sent(/commentThreads/)[0].url, /allThreadsRelatedToChannelId=/);
|
||||
});
|
||||
|
||||
147
dashboard/package-lock.json
generated
147
dashboard/package-lock.json
generated
@ -10,7 +10,8 @@
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.296",
|
||||
"express": "^4.21.1",
|
||||
"marked": "^12.0.2"
|
||||
"marked": "^12.0.2",
|
||||
"nodemailer": "10.0.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.14",
|
||||
@ -1329,23 +1330,6 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/qs": {
|
||||
"version": "6.16.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
|
||||
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.1",
|
||||
"side-channel": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/send": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
|
||||
@ -2270,40 +2254,69 @@
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.3",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
|
||||
"integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
|
||||
"version": "1.20.9",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.9.tgz",
|
||||
"integrity": "sha512-FISIQxV5u91Y9gvuFq3q/tA/qTqLJn3AhRcEvyozyhJTh5OBFFtv827AdHU7Rbyct4r+Ba1/99oFa1Y3yuFsdw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.2",
|
||||
"bytes": "~3.1.2",
|
||||
"content-type": "~1.0.5",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "1.2.0",
|
||||
"http-errors": "2.0.0",
|
||||
"iconv-lite": "0.4.24",
|
||||
"on-finished": "2.4.1",
|
||||
"qs": "6.13.0",
|
||||
"raw-body": "2.5.2",
|
||||
"destroy": "~1.2.0",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"on-finished": "~2.4.1",
|
||||
"qs": "~6.16.0",
|
||||
"raw-body": "~2.5.3",
|
||||
"type-is": "~1.6.18",
|
||||
"unpipe": "1.0.0"
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser/node_modules/raw-body": {
|
||||
"version": "2.5.2",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
|
||||
"integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
|
||||
"node_modules/body-parser/node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.2",
|
||||
"http-errors": "2.0.0",
|
||||
"iconv-lite": "0.4.24",
|
||||
"unpipe": "1.0.0"
|
||||
"depd": "~2.0.0",
|
||||
"inherits": "~2.0.4",
|
||||
"setprototypeof": "~1.2.0",
|
||||
"statuses": "~2.0.2",
|
||||
"toidentifier": "~1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser/node_modules/raw-body": {
|
||||
"version": "2.5.3",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
|
||||
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser/node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@ -2857,45 +2870,49 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.21.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz",
|
||||
"integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==",
|
||||
"version": "4.22.3",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
|
||||
"integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "1.20.3",
|
||||
"content-disposition": "0.5.4",
|
||||
"body-parser": "~1.20.5",
|
||||
"content-disposition": "~0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "0.7.1",
|
||||
"cookie-signature": "1.0.6",
|
||||
"cookie": "~0.7.1",
|
||||
"cookie-signature": "~1.0.6",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"finalhandler": "1.3.1",
|
||||
"fresh": "0.5.2",
|
||||
"http-errors": "2.0.0",
|
||||
"finalhandler": "~1.3.1",
|
||||
"fresh": "~0.5.2",
|
||||
"http-errors": "~2.0.0",
|
||||
"merge-descriptors": "1.0.3",
|
||||
"methods": "~1.1.2",
|
||||
"on-finished": "2.4.1",
|
||||
"on-finished": "~2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "0.1.10",
|
||||
"path-to-regexp": "~0.1.13",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "6.13.0",
|
||||
"qs": "~6.16.0",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.2.1",
|
||||
"send": "0.19.0",
|
||||
"serve-static": "1.16.2",
|
||||
"send": "~0.19.0",
|
||||
"serve-static": "~1.16.2",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "2.0.1",
|
||||
"statuses": "~2.0.1",
|
||||
"type-is": "~1.6.18",
|
||||
"utils-merge": "1.0.1",
|
||||
"vary": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
@ -3706,6 +3723,15 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "10.0.10",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.10.tgz",
|
||||
"integrity": "sha512-He9XskOFms62SyAKkLu8CcGcYHAo+BSHSsORI8s4PJH8qZgZY4L4lDfvyN0twvmbpyG+eGrxpyBlskj9d9rJ8A==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@ -3769,9 +3795,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.10",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz",
|
||||
"integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==",
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
@ -3857,12 +3883,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.13.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
||||
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
|
||||
"version": "6.16.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz",
|
||||
"integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.0.6"
|
||||
"es-define-property": "^1.0.1",
|
||||
"side-channel": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
|
||||
@ -19,7 +19,8 @@
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.296",
|
||||
"express": "^4.21.1",
|
||||
"marked": "^12.0.2"
|
||||
"marked": "^12.0.2",
|
||||
"nodemailer": "10.0.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.14",
|
||||
|
||||
70
dashboard/server/activity.js
Normal file
70
dashboard/server/activity.js
Normal file
@ -0,0 +1,70 @@
|
||||
// Activity log: every change someone makes in the dashboard (POST/PUT/PATCH/DELETE that succeeded),
|
||||
// with secrets masked. Read together with the queue's publish log on the Audit page.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DATA_DIR } from "./config.js";
|
||||
import { PROJECT_ROOT } from "../../connector/src/env.js";
|
||||
|
||||
const FILE = path.join(DATA_DIR, "activity.jsonl");
|
||||
const PUBLISH_LOG = path.join(PROJECT_ROOT, "data", "publish-log.jsonl");
|
||||
const SECRET = /pass(word)?|secret|token|api[_-]?key|authorization|cookie/i;
|
||||
const MAX_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
function mask(v, depth = 0) {
|
||||
if (depth > 4 || v == null) return v;
|
||||
if (typeof v === "string") return v.length > 300 ? `${v.slice(0, 300)}…` : v;
|
||||
if (Array.isArray(v)) return v.slice(0, 20).map((x) => mask(x, depth + 1));
|
||||
if (typeof v === "object") return Object.fromEntries(Object.entries(v).slice(0, 40).map(([k, x]) => [k, SECRET.test(k) ? "•••" : mask(x, depth + 1)]));
|
||||
return v;
|
||||
}
|
||||
|
||||
/** Express middleware: log successful writes once the response is sent. */
|
||||
export function middleware(req, res, next) {
|
||||
if (req.method === "GET" || req.method === "HEAD" || req.method === "OPTIONS") return next();
|
||||
res.on("finish", () => {
|
||||
if (res.statusCode >= 400 || !req.user) return;
|
||||
// Skip noise: marking notifications read, uploads (body is the file).
|
||||
if (/\/api\/notifications\/read$|\/upload$/.test(req.path)) return;
|
||||
const entry = { at: new Date().toISOString(), by: req.user.name || req.user.email, userId: req.user.id, method: req.method, path: req.originalUrl.split("?")[0], body: mask(req.body && typeof req.body === "object" && !Buffer.isBuffer(req.body) ? req.body : undefined), status: res.statusCode };
|
||||
try {
|
||||
if (fs.existsSync(FILE) && fs.statSync(FILE).size > MAX_BYTES) fs.renameSync(FILE, FILE.replace(/\.jsonl$/, `-${Date.now()}.jsonl`));
|
||||
fs.appendFileSync(FILE, JSON.stringify(entry) + "\n");
|
||||
} catch (e) { console.error("Activity log failed", e.message); }
|
||||
});
|
||||
next();
|
||||
}
|
||||
|
||||
const readLines = (f) => { try { return fs.readFileSync(f, "utf8").split("\n").filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean); } catch { return []; } };
|
||||
|
||||
// Plain-English description of an API call.
|
||||
const WHAT = [
|
||||
[/^POST \/api\/schedule\/[^/]+\/approve$/, "approved a post"], [/^POST \/api\/schedule\/[^/]+\/cancel$/, "cancelled a post"],
|
||||
[/^POST \/api\/schedule\/[^/]+\/retry$/, "retried a post"], [/^POST \/api\/schedule\/[^/]+\/send-to-client$/, "sent a post to the client"],
|
||||
[/^POST \/api\/schedule$/, "created a draft post"], [/^PATCH \/api\/schedule\//, "edited a post"],
|
||||
[/^POST \/api\/library\/bulk-schedule$/, "bulk-scheduled library items"], [/^POST \/api\/library\/[^/]+\/review$/, "reviewed library work"],
|
||||
[/^POST \/api\/library\/[^/]+\/schedule$/, "scheduled library work"], [/^POST \/api\/library$/, "uploaded work to the library"], [/^POST \/api\/library\/[^/]+\/archive$/, "archived library work"],
|
||||
[/^(POST|PATCH|DELETE) \/api\/plan/, "changed the content plan"], [/^POST \/api\/calendar\/move$/, "moved a calendar item"],
|
||||
[/^(POST|PATCH|DELETE) \/api\/users/, "changed a team member"], [/^POST \/api\/me\/password$/, "changed their password"],
|
||||
[/^(PUT|DELETE) \/api\/platforms\//, "changed a platform app"], [/^(PATCH|DELETE) \/api\/connections\//, "changed a connection"], [/^POST \/api\/connections\/[^/]+\/check$/, "checked a connection"],
|
||||
[/^(POST|PATCH|DELETE) \/api\/portal\//, "changed a client approval link"], [/^POST \/api\/inbox\/(reviews\/)?reply$/, "replied to a comment or review"], [/^POST \/api\/inbox\/hide$/, "hid or unhid a comment"],
|
||||
[/^(POST|DELETE) \/api\/competitors/, "changed tracked competitors"], [/^PUT \/api\/brandkit\//, "edited a brand kit"], [/^PUT \/api\/bio\//, "edited a link-in-bio page"],
|
||||
[/^POST \/api\/clients\/[^/]+\/monthly\/email$/, "emailed a monthly report"], [/^POST \/api\/clients\/[^/]+\/monthly\/build$/, "created a monthly report"],
|
||||
[/^POST \/api\/jobs/, "started an AI run"], [/^POST \/api\/team\/tasks/, "changed an Odoo task"],
|
||||
];
|
||||
const describe = (e) => WHAT.find(([re]) => re.test(`${e.method} ${e.path}`))?.[1] || `${e.method} ${e.path.replace(/^\/api\//, "")}`;
|
||||
const clientOf = (e) => e.body?.client || e.path.match(/\/(?:clients|portal|brandkit|bio|competitors)\/([a-z0-9-]+)/)?.[1] || null;
|
||||
|
||||
/** Activity + publishing events, newest first. */
|
||||
export function list({ days = 30, user, client, q, limit = 300 } = {}) {
|
||||
const since = new Date(Date.now() - days * 86400000).toISOString();
|
||||
const acts = readLines(FILE).filter((e) => e.at >= since).map((e) => ({ at: e.at, by: e.by, kind: "action", text: describe(e), client: clientOf(e), detail: e.body ? JSON.stringify(e.body).slice(0, 400) : "" }));
|
||||
const pubs = readLines(PUBLISH_LOG).filter((e) => e.at >= since).map((e) => ({
|
||||
at: e.at, by: e.by || "Quorla", kind: e.action === "failed" ? "error" : "publish", client: e.client || null,
|
||||
text: e.action === "published" ? `published to ${e.platform}` : e.action === "failed" ? `couldn't publish to ${e.platform}` : e.action === "waiting_for_quota" ? `waiting for ${e.platform}'s daily limit` : e.action.replace(/_/g, " "),
|
||||
detail: e.error || e.result?.permalink || "",
|
||||
}));
|
||||
const needle = String(q || "").toLowerCase();
|
||||
return [...acts, ...pubs]
|
||||
.filter((e) => (!user || e.by === user) && (!client || e.client === client) && (!needle || `${e.text} ${e.detail} ${e.by}`.toLowerCase().includes(needle)))
|
||||
.sort((a, b) => b.at.localeCompare(a.at)).slice(0, limit);
|
||||
}
|
||||
@ -68,7 +68,13 @@ export function login(req, res) {
|
||||
/** First run only: create the first admin from the sign-in screen when no logins exist. */
|
||||
export function setupFirstAdmin(req, res) {
|
||||
if (users.hasUsers()) return res.status(403).json({ error: "Setup is already done. Sign in instead." });
|
||||
const { email, name, password } = req.body || {};
|
||||
const { email, name, password, setupToken } = req.body || {};
|
||||
// Hosted workspaces are created with SETUP_TOKEN: only the person with the emailed setup link can make the first admin.
|
||||
const need = process.env.SETUP_TOKEN || "";
|
||||
if (need) {
|
||||
const a = Buffer.from(String(setupToken || "")), b = Buffer.from(need);
|
||||
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.status(403).json({ error: "Use the setup link from your welcome email to create the first account." });
|
||||
}
|
||||
try {
|
||||
if (!password) throw Object.assign(new Error("Choose a password."), { status: 400 });
|
||||
const { user } = users.createUser({ email, name, password, role: "admin" }, "first-run setup");
|
||||
|
||||
116
dashboard/server/bio.js
Normal file
116
dashboard/server/bio.js
Normal file
@ -0,0 +1,116 @@
|
||||
// Link-in-bio page per client: a fast public page (/l/<path>) with the client's links and latest posts.
|
||||
// Every link goes through /l/<path>/go/<id>, which counts the click (per day) and adds UTM tags when enabled.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { DATA_DIR } from "./config.js";
|
||||
import * as clients from "./clients.js";
|
||||
import * as metrics from "./metrics.js";
|
||||
|
||||
const FILE = path.join(DATA_DIR, "bio.json");
|
||||
const CLICKS = path.join(DATA_DIR, "bio-clicks.json");
|
||||
const readJson = (f, d) => { try { return JSON.parse(fs.readFileSync(f, "utf8")); } catch { return d; } };
|
||||
const writeJson = (f, v) => { const tmp = f + ".tmp"; fs.writeFileSync(tmp, JSON.stringify(v, null, 1)); fs.renameSync(tmp, f); };
|
||||
const err = (msg, status = 400) => Object.assign(new Error(msg), { status });
|
||||
const RESERVED = new Set(["api", "go", "admin", "login", "quorla", "static", "assets"]);
|
||||
const HEX = /^#[0-9a-f]{6}$/i;
|
||||
|
||||
export function get(slug) {
|
||||
clients.safeSlug(slug);
|
||||
const c = readJson(FILE, {})[slug];
|
||||
return { enabled: false, path: slug, title: "", bio: "", links: [], showPosts: true, utm: true, theme: { bg: "#ffffff", fg: "#1f2024", button: "#242142", buttonText: "#ffffff" }, ...(c || {}) };
|
||||
}
|
||||
|
||||
export function save(slug, patch) {
|
||||
clients.safeSlug(slug);
|
||||
const db = readJson(FILE, {});
|
||||
const cur = get(slug);
|
||||
const p = patch.path !== undefined ? String(patch.path).toLowerCase().trim() : cur.path;
|
||||
if (!/^[a-z0-9][a-z0-9-]{1,40}$/.test(p) || RESERVED.has(p)) throw err("Page address: 2–40 lowercase letters, numbers or dashes.");
|
||||
if (Object.entries(db).some(([s, v]) => s !== slug && v.path === p)) throw err("Another client already uses that address.");
|
||||
const links = (patch.links ?? cur.links).slice(0, 20).map((l) => {
|
||||
const url = String(l.url || "").trim();
|
||||
if (!/^(https?:\/\/|mailto:|tel:)/i.test(url)) throw err(`“${l.title || url}”: links must start with https://, mailto: or tel:.`);
|
||||
return { id: l.id || crypto.randomUUID().slice(0, 6), title: String(l.title || "").trim().slice(0, 80) || url, url: url.slice(0, 1000) };
|
||||
});
|
||||
const theme = { ...cur.theme };
|
||||
for (const k of ["bg", "fg", "button", "buttonText"]) if (patch.theme?.[k] !== undefined) { if (!HEX.test(patch.theme[k])) throw err("Colours must be like #242142."); theme[k] = patch.theme[k]; }
|
||||
db[slug] = {
|
||||
enabled: patch.enabled !== undefined ? Boolean(patch.enabled) : cur.enabled, path: p,
|
||||
title: patch.title !== undefined ? String(patch.title).slice(0, 80) : cur.title, bio: patch.bio !== undefined ? String(patch.bio).slice(0, 300) : cur.bio,
|
||||
links, showPosts: patch.showPosts !== undefined ? Boolean(patch.showPosts) : cur.showPosts, utm: patch.utm !== undefined ? Boolean(patch.utm) : cur.utm, theme,
|
||||
};
|
||||
writeJson(FILE, db);
|
||||
return get(slug);
|
||||
}
|
||||
|
||||
export function byPath(p) {
|
||||
const hit = Object.entries(readJson(FILE, {})).find(([, v]) => v.path === String(p || "").toLowerCase() && v.enabled);
|
||||
return hit ? { slug: hit[0], ...get(hit[0]) } : null;
|
||||
}
|
||||
|
||||
function withUtm(url, slug) {
|
||||
if (!/^https?:/i.test(url)) return url;
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (!u.searchParams.has("utm_source")) { u.searchParams.set("utm_source", "instagram"); u.searchParams.set("utm_medium", "social"); u.searchParams.set("utm_campaign", "link_in_bio"); u.searchParams.set("utm_content", slug); }
|
||||
return u.toString();
|
||||
} catch { return url; }
|
||||
}
|
||||
|
||||
/** Where a click should go (counted). Null if unknown. */
|
||||
export function click(p, linkId) {
|
||||
const page = byPath(p);
|
||||
const link = page?.links.find((l) => l.id === linkId);
|
||||
if (!link) return null;
|
||||
const db = readJson(CLICKS, {});
|
||||
const day = new Date().toISOString().slice(0, 10);
|
||||
const c = ((db[page.slug] ||= {})[linkId] ||= {});
|
||||
c[day] = (c[day] || 0) + 1;
|
||||
// Keep 120 days.
|
||||
const cutoff = new Date(Date.now() - 120 * 86400000).toISOString().slice(0, 10);
|
||||
for (const k of Object.keys(c)) if (k < cutoff) delete c[k];
|
||||
writeJson(CLICKS, db);
|
||||
return page.utm ? withUtm(link.url, page.slug) : link.url;
|
||||
}
|
||||
|
||||
/** Clicks per link over the last N days. */
|
||||
export function stats(slug, days = 30) {
|
||||
const db = readJson(CLICKS, {})[slug] || {};
|
||||
const cutoff = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
|
||||
const page = get(slug);
|
||||
const per = page.links.map((l) => ({ id: l.id, title: l.title, clicks: Object.entries(db[l.id] || {}).filter(([d]) => d >= cutoff).reduce((s, [, n]) => s + n, 0) }));
|
||||
return { days, total: per.reduce((s, x) => s + x.clicks, 0), links: per };
|
||||
}
|
||||
|
||||
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
||||
|
||||
/** The public page as HTML (no JS needed). */
|
||||
export function render(page) {
|
||||
const snap = metrics.readSnapshot(page.slug);
|
||||
const prof = snap?.ig?.profile || {};
|
||||
const name = page.title || prof.name || clients.getClient(page.slug)?.name || "";
|
||||
const bio = page.bio || prof.biography || "";
|
||||
const avatar = prof.profile_picture_url || null;
|
||||
const posts = page.showPosts ? (snap?.ig?.posts || []).filter((p) => p.permalink && (p.thumbnail_url || p.media_url)).slice(0, 6) : [];
|
||||
const t = page.theme;
|
||||
return `<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${esc(name)}</title><meta name="description" content="${esc(bio.slice(0, 150))}">
|
||||
<meta property="og:title" content="${esc(name)}">${avatar ? `<meta property="og:image" content="${esc(avatar)}">` : ""}
|
||||
<style>
|
||||
*{box-sizing:border-box}body{margin:0;background:${t.bg};color:${t.fg};font:16px/1.5 Inter,"Segoe UI",system-ui,sans-serif;-webkit-font-smoothing:antialiased}
|
||||
main{max-width:520px;margin:0 auto;padding:40px 16px 48px;text-align:center}
|
||||
.av{width:88px;height:88px;border-radius:50%;object-fit:cover;margin:0 auto 12px;display:block;background:#ddd}
|
||||
h1{font-size:22px;margin:0 0 6px}p.bio{margin:0 auto 22px;max-width:420px;opacity:.85;white-space:pre-line}
|
||||
a.btn{display:block;background:${t.button};color:${t.buttonText};text-decoration:none;font-weight:600;padding:15px 18px;border-radius:12px;margin:0 0 12px;min-height:48px}
|
||||
a.btn:focus-visible{outline:3px solid ${t.fg};outline-offset:3px}
|
||||
.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin-top:22px}.grid a{display:block;aspect-ratio:1;overflow:hidden;border-radius:8px}
|
||||
.grid img{width:100%;height:100%;object-fit:cover;display:block}
|
||||
</style></head><body><main>
|
||||
${avatar ? `<img class="av" src="${esc(avatar)}" alt="">` : ""}
|
||||
<h1>${esc(name)}</h1>${bio ? `<p class="bio">${esc(bio)}</p>` : ""}
|
||||
${page.links.map((l) => `<a class="btn" href="/l/${esc(page.path)}/go/${esc(l.id)}" rel="nofollow">${esc(l.title)}</a>`).join("\n")}
|
||||
${posts.length ? `<div class="grid">${posts.map((p) => `<a href="${esc(p.permalink)}" rel="noopener" aria-label="${esc((p.caption || "Instagram post").slice(0, 80))}"><img src="${esc(p.thumbnail_url || p.media_url)}" alt="" loading="lazy"></a>`).join("")}</div>` : ""}
|
||||
</main></body></html>`;
|
||||
}
|
||||
@ -65,7 +65,7 @@ export function listDocs(slug) {
|
||||
for (const [kind, sub] of Object.entries(DOC_DIRS)) {
|
||||
const d = path.join(dir, sub);
|
||||
if (!fs.existsSync(d)) continue;
|
||||
for (const f of fs.readdirSync(d)) if (f.endsWith(".md")) add(path.join(sub, f), kind === "reports" ? "report" : "calendar");
|
||||
for (const f of fs.readdirSync(d)) if (f.endsWith(".md")) add(path.join(sub, f), kind === "reports" ? (/-monthly\.md$/.test(f) ? "monthly" : "report") : "calendar");
|
||||
}
|
||||
for (const f of ["brand.md", "goals.md", "constraints.md", "history.md"]) if (fs.existsSync(path.join(dir, f))) add(f, "setup");
|
||||
return docs.sort((a, b) => (a.kind === "setup") - (b.kind === "setup") || b.updatedAt.localeCompare(a.updatedAt));
|
||||
|
||||
@ -10,7 +10,7 @@ loadEnv();
|
||||
export { PROJECT_ROOT };
|
||||
export const CLIENTS_DIR = path.join(PROJECT_ROOT, "clients");
|
||||
export const TEMPLATE_DIR = path.join(CLIENTS_DIR, "_template");
|
||||
export const DATA_DIR = path.join(PROJECT_ROOT, "data", "dashboard");
|
||||
export const DATA_DIR = process.env.QUORLA_DASHBOARD_DATA || path.join(PROJECT_ROOT, "data", "dashboard"); // override for tests
|
||||
export const BRANDING_DIR = path.join(PROJECT_ROOT, "branding");
|
||||
export const WEB_DIST = path.join(PROJECT_ROOT, "dashboard", "web", "dist");
|
||||
|
||||
|
||||
207
dashboard/server/control.js
Normal file
207
dashboard/server/control.js
Normal file
@ -0,0 +1,207 @@
|
||||
// Control centre for hosted Quorla (only when QUORLA_CONTROL=true, i.e. on app.quorlasocial.com).
|
||||
// One Quorla per agency: its own container, data and address. This module keeps the list of agencies ("tenants"),
|
||||
// takes sign-ups through Stripe Checkout, follows the subscription through Stripe webhooks, and tells the
|
||||
// provisioning script on the server what each agency's container should be (running / stopped / removed).
|
||||
// The web app never runs Docker itself; deploy/provision-tenants.sh does, using PROVISION_TOKEN.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { PROJECT_ROOT } from "../../connector/src/env.js";
|
||||
import * as notify from "./notify.js";
|
||||
|
||||
const DIR = process.env.QUORLA_CONTROL_DIR || path.join(PROJECT_ROOT, "data", "control"); // override for tests
|
||||
const FILE = path.join(DIR, "tenants.json");
|
||||
fs.mkdirSync(DIR, { recursive: true });
|
||||
const read = () => { try { return JSON.parse(fs.readFileSync(FILE, "utf8")); } catch { return {}; } };
|
||||
const write = (db) => { const tmp = FILE + ".tmp"; fs.writeFileSync(tmp, JSON.stringify(db, null, 1)); fs.renameSync(tmp, FILE); };
|
||||
const err = (msg, status = 400) => Object.assign(new Error(msg), { status });
|
||||
const now = () => new Date().toISOString();
|
||||
|
||||
export const enabled = () => process.env.QUORLA_CONTROL === "true";
|
||||
const BASE_DOMAIN = () => process.env.TENANT_BASE_DOMAIN || "quorlasocial.com";
|
||||
const RESERVED = new Set(["app", "www", "api", "mail", "webmail", "admin", "status", "help", "docs", "blog", "billing", "control", "quorla", "test", "staging"]);
|
||||
const PUBLIC = (t) => { const { setupToken, ...rest } = t; return { ...rest, setupPending: Boolean(setupToken) }; };
|
||||
|
||||
export function list() { return Object.values(read()).sort((a, b) => b.createdAt.localeCompare(a.createdAt)).map(PUBLIC); }
|
||||
|
||||
// ---------- Stripe (REST, no SDK) ----------
|
||||
async function stripe(pathname, form) {
|
||||
const key = process.env.STRIPE_SECRET_KEY;
|
||||
if (!key) throw err("Stripe isn't set up (STRIPE_SECRET_KEY).", 503);
|
||||
const body = new URLSearchParams();
|
||||
const add = (k, v) => { if (v === undefined || v === null) return; if (typeof v === "object") for (const [kk, vv] of Object.entries(v)) add(`${k}[${kk}]`, vv); else body.append(k, String(v)); };
|
||||
for (const [k, v] of Object.entries(form || {})) add(k, v);
|
||||
const r = await fetch(`https://api.stripe.com/v1/${pathname}`, { method: "POST", headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/x-www-form-urlencoded" }, body });
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw err(`Stripe: ${j.error?.message || r.status}`, 502);
|
||||
return j;
|
||||
}
|
||||
|
||||
/** Verify a Stripe webhook: Stripe-Signature "t=…,v1=…" over `${t}.${rawBody}`, within 5 minutes. */
|
||||
export function verifyWebhook(rawBody, header) {
|
||||
const secret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
if (!secret) throw err("Webhook secret missing.", 503);
|
||||
const parts = Object.fromEntries(String(header || "").split(",").map((p) => p.split("=")).filter((p) => p.length === 2).map(([k, v]) => [k.trim(), v.trim()]));
|
||||
const sigs = String(header || "").split(",").filter((p) => p.trim().startsWith("v1=")).map((p) => p.trim().slice(3));
|
||||
const t = Number(parts.t);
|
||||
if (!t || !sigs.length) throw err("Bad signature header.", 400);
|
||||
if (Math.abs(Date.now() / 1000 - t) > 300) throw err("Webhook too old.", 400);
|
||||
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
|
||||
const ok = sigs.some((s) => s.length === expected.length && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
|
||||
if (!ok) throw err("Signature doesn't match.", 400);
|
||||
return JSON.parse(rawBody);
|
||||
}
|
||||
|
||||
// ---------- sign-up ----------
|
||||
function validate({ agencyName, ownerName, ownerEmail, slug }) {
|
||||
const s = String(slug || "").toLowerCase().trim();
|
||||
if (!String(agencyName || "").trim()) throw err("Enter your agency's name.");
|
||||
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(String(ownerEmail || "").trim())) throw err("Enter a valid work email.");
|
||||
if (!/^[a-z][a-z0-9-]{2,30}$/.test(s) || RESERVED.has(s)) throw err("Workspace address: 3–31 lowercase letters, numbers or dashes, starting with a letter.");
|
||||
const db = read();
|
||||
if (db[s] && !["pending_payment", "cancelled"].includes(db[s].status)) throw err("That workspace address is taken. Try another.");
|
||||
return { slug: s, agencyName: String(agencyName).trim().slice(0, 60), ownerName: String(ownerName || "").trim().slice(0, 60), ownerEmail: String(ownerEmail).trim().toLowerCase().slice(0, 120) };
|
||||
}
|
||||
|
||||
/** Public sign-up: creates the tenant record and returns where to send the person (Stripe Checkout or a thank-you). */
|
||||
export async function signup(input, origin) {
|
||||
const t = validate(input);
|
||||
const db = read();
|
||||
db[t.slug] = {
|
||||
...t, plan: "hosted", status: "pending_payment", desired: "none", actual: "none", port: null,
|
||||
url: `https://${t.slug}.${BASE_DOMAIN()}`, setupToken: crypto.randomBytes(18).toString("base64url"),
|
||||
createdAt: now(), updatedAt: now(), stripe: {}, history: [{ at: now(), event: "signed up" }],
|
||||
};
|
||||
write(db);
|
||||
if (!process.env.STRIPE_SECRET_KEY || !process.env.STRIPE_PRICE_ID) {
|
||||
// No Stripe yet: an admin approves the agency by hand on the Agencies page.
|
||||
await notify.notify({ type: "tenant_signup", title: `New agency sign-up: ${t.agencyName}`, body: `${t.ownerName} <${t.ownerEmail}> · ${t.slug}`, link: "/admin/agencies", to: "admins" });
|
||||
return { next: "manual" };
|
||||
}
|
||||
const trial = Number(process.env.STRIPE_TRIAL_DAYS || 0);
|
||||
const session = await stripe("checkout/sessions", {
|
||||
mode: "subscription", customer_email: t.ownerEmail, client_reference_id: t.slug,
|
||||
"line_items[0][price]": process.env.STRIPE_PRICE_ID, "line_items[0][quantity]": 1,
|
||||
success_url: `${origin}/signup/done?w=${t.slug}`, cancel_url: `${origin}/signup?cancelled=1`,
|
||||
metadata: { slug: t.slug }, subscription_data: { metadata: { slug: t.slug }, ...(trial > 0 ? { trial_period_days: trial } : {}) },
|
||||
allow_promotion_codes: "true",
|
||||
});
|
||||
return { next: "stripe", url: session.url };
|
||||
}
|
||||
|
||||
// ---------- subscription lifecycle ----------
|
||||
function update(slug, fn, event) {
|
||||
const db = read();
|
||||
const t = db[slug];
|
||||
if (!t) return null;
|
||||
fn(t);
|
||||
t.updatedAt = now();
|
||||
if (event) (t.history ||= []).push({ at: now(), event });
|
||||
t.history = t.history.slice(-100);
|
||||
write(db);
|
||||
return t;
|
||||
}
|
||||
const slugFromObj = (o) => o?.metadata?.slug || o?.client_reference_id || o?.subscription_details?.metadata?.slug || null;
|
||||
const bySubscription = (id) => Object.values(read()).find((t) => t.stripe?.subscription === id)?.slug || null;
|
||||
|
||||
/** Apply one Stripe event. Returns a short description for the log. */
|
||||
export async function onStripeEvent(evt) {
|
||||
const o = evt.data?.object || {};
|
||||
switch (evt.type) {
|
||||
case "checkout.session.completed": {
|
||||
const slug = slugFromObj(o);
|
||||
if (!slug) return "no slug";
|
||||
update(slug, (t) => { t.status = "provisioning"; t.desired = "running"; t.stripe = { customer: o.customer, subscription: o.subscription }; }, "paid; workspace queued");
|
||||
await notify.notify({ type: "tenant_paid", level: "ok", title: `New paying agency: ${read()[slug]?.agencyName}`, link: "/admin/agencies", to: "admins", email: true });
|
||||
return `provisioning ${slug}`;
|
||||
}
|
||||
case "customer.subscription.updated": {
|
||||
const slug = slugFromObj(o) || bySubscription(o.id);
|
||||
if (!slug) return "unknown subscription";
|
||||
update(slug, (t) => {
|
||||
if (["active", "trialing"].includes(o.status)) { if (t.status === "suspended" && t.suspendedFor === "billing") { t.desired = "running"; delete t.suspendedFor; } if (t.actual === "running") t.status = "active"; }
|
||||
else if (o.status === "past_due") t.status = "past_due";
|
||||
else if (["unpaid", "canceled", "incomplete_expired"].includes(o.status)) { t.status = "suspended"; t.suspendedFor = "billing"; t.desired = "stopped"; }
|
||||
}, `subscription ${o.status}`);
|
||||
return `${slug} ${o.status}`;
|
||||
}
|
||||
case "customer.subscription.deleted": {
|
||||
const slug = slugFromObj(o) || bySubscription(o.id);
|
||||
if (!slug) return "unknown subscription";
|
||||
update(slug, (t) => { t.status = "cancelled"; t.desired = "stopped"; }, "subscription cancelled; workspace stopped (data kept)");
|
||||
return `${slug} cancelled`;
|
||||
}
|
||||
case "invoice.payment_failed": {
|
||||
const slug = bySubscription(o.subscription);
|
||||
if (slug) update(slug, (t) => { if (t.status === "active") t.status = "past_due"; }, "payment failed");
|
||||
return `payment failed ${slug || ""}`;
|
||||
}
|
||||
default: return `ignored ${evt.type}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- admin actions ----------
|
||||
export function approve(slug) { return PUBLIC(update(slug, (t) => { t.status = "provisioning"; t.desired = "running"; }, "approved by admin") || (() => { throw err("Unknown agency.", 404); })()); }
|
||||
export function suspend(slug) { return PUBLIC(update(slug, (t) => { t.status = "suspended"; t.suspendedFor = "admin"; t.desired = "stopped"; }, "suspended by admin") || (() => { throw err("Unknown agency.", 404); })()); }
|
||||
export function resume(slug) { return PUBLIC(update(slug, (t) => { t.desired = "running"; delete t.suspendedFor; t.status = t.actual === "running" ? "active" : "provisioning"; }, "resumed by admin") || (() => { throw err("Unknown agency.", 404); })()); }
|
||||
/** Remove the container. Data stays on disk (the script archives it) until someone deletes it by hand. */
|
||||
export function remove(slug) { return PUBLIC(update(slug, (t) => { t.status = "removed"; t.desired = "removed"; }, "removal requested by admin") || (() => { throw err("Unknown agency.", 404); })()); }
|
||||
|
||||
export async function billingPortal(slug, returnUrl) {
|
||||
const t = read()[slug];
|
||||
if (!t?.stripe?.customer) throw err("This agency has no Stripe customer yet.");
|
||||
return (await stripe("billing_portal/sessions", { customer: t.stripe.customer, return_url: returnUrl })).url;
|
||||
}
|
||||
|
||||
// ---------- provisioning script API ----------
|
||||
export function checkProvisionToken(header) {
|
||||
const need = process.env.PROVISION_TOKEN || "";
|
||||
const got = String(header || "").replace(/^Bearer\s+/i, "");
|
||||
if (!need || got.length !== need.length || !crypto.timingSafeEqual(Buffer.from(got), Buffer.from(need))) throw err("Not allowed.", 401);
|
||||
}
|
||||
|
||||
/** What the script should do: every tenant whose container isn't in its desired state. Assigns ports. */
|
||||
export function tasks() {
|
||||
const db = read();
|
||||
const used = new Set(Object.values(db).map((t) => t.port).filter(Boolean));
|
||||
let changed = false;
|
||||
const out = [];
|
||||
for (const t of Object.values(db)) {
|
||||
if (t.desired === "none" || t.desired === t.actual) continue;
|
||||
if (t.desired === "running" && !t.port) { let p = 4400; while (used.has(p)) p++; t.port = p; used.add(p); changed = true; }
|
||||
out.push({ slug: t.slug, desired: t.desired, port: t.port, agencyName: t.agencyName, url: t.url, setupToken: t.setupToken || null });
|
||||
}
|
||||
if (changed) write(db);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The script reports a container's state. First time a workspace runs: email the owner their setup link. */
|
||||
export async function report(slug, { actual, error }) {
|
||||
const before = read()[slug];
|
||||
if (!before) throw err("Unknown agency.", 404);
|
||||
const t = update(slug, (x) => {
|
||||
if (error) { x.lastError = String(error).slice(0, 500); return; }
|
||||
x.actual = actual; delete x.lastError;
|
||||
if (actual === "running" && ["provisioning", "past_due"].includes(x.status)) x.status = "active";
|
||||
}, error ? `provisioning error: ${String(error).slice(0, 120)}` : `container ${actual}`);
|
||||
if (!error && actual === "running" && before.actual !== "running" && t.setupToken && !t.welcomedAt) {
|
||||
const sent = await notify.sendEmail({
|
||||
to: t.ownerEmail, subject: `Your Quorla workspace is ready`, heading: `Welcome to Quorla, ${t.ownerName || t.agencyName}`,
|
||||
lines: [`Your workspace for ${t.agencyName} is ready at ${t.url}.`, "Use the button to create your admin account. The link works once; after that, sign in with your email and password.", "Then follow the Getting started list: connect your clients' accounts, invite your team and schedule your first post."],
|
||||
button: { label: "Create my account", url: `${t.url}/?setup=${t.setupToken}` }, fromName: "Quorla",
|
||||
});
|
||||
update(slug, (x) => { if (sent.sent) x.welcomedAt = now(); }, sent.sent ? "welcome email sent" : `welcome email not sent: ${sent.reason}`);
|
||||
}
|
||||
return PUBLIC(read()[slug]);
|
||||
}
|
||||
|
||||
/** Send the welcome email again (the setup link only works until the first account exists). */
|
||||
export async function resendWelcome(slug) {
|
||||
const t = read()[slug];
|
||||
if (!t) throw err("Unknown agency.", 404);
|
||||
if (t.actual !== "running") throw err("The workspace isn't running yet.");
|
||||
update(slug, (x) => { delete x.welcomedAt; });
|
||||
await report(slug, { actual: "running" });
|
||||
update(slug, () => {}, "welcome email re-sent");
|
||||
return PUBLIC(read()[slug]);
|
||||
}
|
||||
34
dashboard/server/control/done.html
Normal file
34
dashboard/server/control/done.html
Normal file
@ -0,0 +1,34 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Your Quorla workspace is on its way</title>
|
||||
<link rel="icon" type="image/png" href="/brand/mark.png">
|
||||
<style>
|
||||
* { box-sizing:border-box } [hidden]{display:none!important}
|
||||
body { margin:0; background:#f6f5fb; color:#1f2024; font:16px/1.6 Inter,"Segoe UI",system-ui,sans-serif }
|
||||
main { max-width:520px; margin:0 auto; padding:56px 16px; text-align:center }
|
||||
img { height:34px; margin-bottom:28px } h1 { font-size:26px; color:#242142; margin:0 0 10px }
|
||||
p { color:#5d6070 } .box { background:#fff; border:1px solid #e6e5ef; border-radius:14px; padding:24px }
|
||||
a { color:#3b3778 }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<img src="/brand/logo.png" alt="Quorla">
|
||||
<div class="box">
|
||||
<h1>Thanks, you're in</h1>
|
||||
<p id="paid">We're setting up <b id="addr"></b> now. You'll get an email with your setup link within a few minutes.</p>
|
||||
<p id="manual" hidden>We've got your details. We'll set up <b id="addr2"></b> and email you the setup link shortly.</p>
|
||||
<p>Nothing in your inbox after 15 minutes? Check spam, or write to <a href="mailto:support@quorlasocial.com">support@quorlasocial.com</a>.</p>
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
const q = new URLSearchParams(location.search), w = (q.get("w") || "").replace(/[^a-z0-9-]/g, "");
|
||||
const host = `${w}.quorlasocial.com`;
|
||||
document.getElementById("addr").textContent = host; document.getElementById("addr2").textContent = host;
|
||||
if (q.get("manual")) { document.getElementById("paid").hidden = true; document.getElementById("manual").hidden = false; }
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
72
dashboard/server/control/signup.html
Normal file
72
dashboard/server/control/signup.html
Normal file
@ -0,0 +1,72 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Start your Quorla workspace</title>
|
||||
<meta name="description" content="Create a hosted Quorla workspace for your agency.">
|
||||
<link rel="icon" type="image/png" href="/brand/mark.png">
|
||||
<style>
|
||||
:root { --navy:#242142; --navy2:#3b3778; --coral:#fc6d45; --ink:#1f2024; --muted:#5d6070; --line:#e6e5ef; --soft:#f6f5fb; --bad:#b4372b; --bg:#fff; }
|
||||
* { box-sizing:border-box } [hidden]{display:none!important}
|
||||
body { margin:0; background:var(--soft); color:var(--ink); font:16px/1.55 Inter,"Segoe UI",system-ui,sans-serif; -webkit-font-smoothing:antialiased }
|
||||
main { max-width: 520px; margin: 0 auto; padding: 40px 16px 64px }
|
||||
.logo { height: 34px; display:block; margin-bottom: 26px }
|
||||
h1 { font-size: 28px; line-height:1.2; margin:0 0 6px; color:var(--navy) }
|
||||
p.sub { color:var(--muted); margin:0 0 22px }
|
||||
form { background:var(--bg); border:1px solid var(--line); border-radius:14px; padding:22px }
|
||||
label { display:block; font-size:13.5px; font-weight:600; margin:0 0 5px; color:var(--ink) }
|
||||
.f { margin-bottom: 15px }
|
||||
input { width:100%; font:inherit; padding:11px 12px; border:1px solid #d3d2e0; border-radius:8px; background:#fff; color:var(--ink) }
|
||||
input:focus-visible, button:focus-visible { outline:2px solid var(--coral); outline-offset:2px }
|
||||
.addr { display:flex; align-items:center; border:1px solid #d3d2e0; border-radius:8px; overflow:hidden }
|
||||
.addr input { border:0; border-radius:0 } .addr span { padding:0 12px; color:var(--muted); white-space:nowrap; font-size:14px; background:var(--soft); align-self:stretch; display:flex; align-items:center }
|
||||
.hint { font-size:12.5px; color:var(--muted); margin-top:4px }
|
||||
button { width:100%; font:600 16px/1 inherit; font-family:inherit; min-height:48px; border:0; border-radius:10px; background:var(--navy); color:#fff; cursor:pointer; margin-top:6px }
|
||||
button:hover { background:var(--navy2) } button[disabled]{ opacity:.6; cursor:default }
|
||||
.err { color:var(--bad); font-size:14px; margin-top:10px }
|
||||
.note { font-size:13px; color:var(--muted); margin-top:16px; text-align:center }
|
||||
.hp { position:absolute; left:-9999px }
|
||||
a { color:var(--navy2) }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<a href="https://quorlasocial.com"><img class="logo" src="/brand/logo.png" alt="Quorla"></a>
|
||||
<h1>Start your Quorla workspace</h1>
|
||||
<p class="sub">Your agency gets its own private workspace: your clients, your team, your data. Set up takes a few minutes.</p>
|
||||
<p class="err" id="cancelled" hidden>Checkout was cancelled. Your details are below if you'd like to try again.</p>
|
||||
<form id="f" novalidate>
|
||||
<div class="f"><label for="agencyName">Agency name</label><input id="agencyName" name="agencyName" required autocomplete="organization"></div>
|
||||
<div class="f"><label for="ownerName">Your name</label><input id="ownerName" name="ownerName" autocomplete="name"></div>
|
||||
<div class="f"><label for="ownerEmail">Work email</label><input id="ownerEmail" name="ownerEmail" type="email" required autocomplete="email"><div class="hint">We send your setup link here.</div></div>
|
||||
<div class="f">
|
||||
<label for="slug">Workspace address</label>
|
||||
<div class="addr"><input id="slug" name="slug" required pattern="[a-z][a-z0-9-]{2,30}" placeholder="youragency" autocapitalize="none" spellcheck="false"><span id="domain">.quorlasocial.com</span></div>
|
||||
<div class="hint">Lowercase letters, numbers and dashes.</div>
|
||||
</div>
|
||||
<input class="hp" name="website" tabindex="-1" autocomplete="off" aria-hidden="true">
|
||||
<button id="go" type="submit">Continue to payment</button>
|
||||
<div class="err" id="err" role="alert"></div>
|
||||
</form>
|
||||
<p class="note">By continuing you agree to the <a href="https://quorlasocial.com/terms">Terms</a> and <a href="https://quorlasocial.com/privacy">Privacy Policy</a>. Questions? <a href="mailto:support@quorlasocial.com">support@quorlasocial.com</a></p>
|
||||
</main>
|
||||
<script>
|
||||
const f = document.getElementById("f"), err = document.getElementById("err"), go = document.getElementById("go");
|
||||
if (new URLSearchParams(location.search).get("cancelled")) document.getElementById("cancelled").hidden = false;
|
||||
const slug = document.getElementById("slug"), agency = document.getElementById("agencyName");
|
||||
agency.addEventListener("input", () => { if (!slug.dataset.touched) slug.value = agency.value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/^[^a-z]+/, "").slice(0, 31); });
|
||||
slug.addEventListener("input", () => { slug.dataset.touched = "1"; slug.value = slug.value.toLowerCase().replace(/[^a-z0-9-]/g, ""); });
|
||||
f.addEventListener("submit", async (e) => {
|
||||
e.preventDefault(); err.textContent = ""; go.disabled = true; go.textContent = "One moment…";
|
||||
try {
|
||||
const r = await fetch("/control/signup", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(Object.fromEntries(new FormData(f))) });
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(j.error || "Something went wrong. Try again.");
|
||||
if (j.next === "stripe" && j.url) location.href = j.url;
|
||||
else location.href = `/signup/done?w=${encodeURIComponent(slug.value)}&manual=1`;
|
||||
} catch (ex) { err.textContent = ex.message; go.disabled = false; go.textContent = "Continue to payment"; }
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -4,6 +4,7 @@ import path from "node:path";
|
||||
import { marked } from "marked";
|
||||
import { safeSlug } from "./clients.js";
|
||||
import { toPdf } from "../../tools/report-pdf/build.mjs";
|
||||
import { reportOptions } from "./workspace.js";
|
||||
|
||||
/** Resolve a document path inside one client's folder (blocks ../ escapes). */
|
||||
export function resolveDoc(slug, rel) {
|
||||
@ -42,6 +43,6 @@ export function renderDoc(slug, rel) {
|
||||
export function exportPdf(slug, rel) {
|
||||
const full = resolveDoc(slug, rel);
|
||||
if (!full.endsWith(".md")) throw Object.assign(new Error("Pick a markdown document."), { status: 400 });
|
||||
const written = toPdf(full);
|
||||
const written = toPdf(full, reportOptions());
|
||||
return path.relative(safeSlug(slug), written).replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
70
dashboard/server/health.js
Normal file
70
dashboard/server/health.js
Normal file
@ -0,0 +1,70 @@
|
||||
// System health for admins: is everything that runs in the background actually running?
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { PROJECT_ROOT } from "../../connector/src/env.js";
|
||||
import * as clients from "./clients.js";
|
||||
import * as metrics from "./metrics.js";
|
||||
import * as jobs from "./jobs.js";
|
||||
import * as notify from "./notify.js";
|
||||
import * as sched from "../../connector/src/schedule.js";
|
||||
import * as extra from "../../connector/src/platforms/index.js";
|
||||
|
||||
const HOUR = 3600000;
|
||||
function dirSize(dir) {
|
||||
let total = 0;
|
||||
const walk = (d) => { for (const e of fs.readdirSync(d, { withFileTypes: true })) { const p = path.join(d, e.name); if (e.isDirectory()) walk(p); else { try { total += fs.statSync(p).size; } catch { /* gone */ } } } };
|
||||
try { walk(dir); } catch { return null; }
|
||||
return total;
|
||||
}
|
||||
|
||||
/** A list of checks: { area, status: ok|warn|bad, title, detail }. */
|
||||
export function checks() {
|
||||
const out = [];
|
||||
const add = (area, status, title, detail = "") => out.push({ area, status, title, detail });
|
||||
|
||||
// Publishing
|
||||
const pubOn = process.env.ALLOW_PUBLISH === "true", schedOn = process.env.SCHEDULER_ENABLED !== "false";
|
||||
add("Publishing", pubOn && schedOn ? "ok" : "warn", pubOn && schedOn ? "Publishing and the scheduler are on" : `Publishing ${pubOn ? "on" : "OFF (ALLOW_PUBLISH)"} · scheduler ${schedOn ? "on" : "OFF (SCHEDULER_ENABLED)"}`);
|
||||
const posts = sched.listPosts({});
|
||||
const stuck = posts.filter((p) => p.status === "approved" && !p.retryAt && Date.now() - new Date(p.publishAt) > 15 * 60000);
|
||||
add("Publishing", stuck.length ? "bad" : "ok", stuck.length ? `${stuck.length} approved post${stuck.length === 1 ? "" : "s"} past due and not sent` : "No overdue posts", stuck.slice(0, 5).map((p) => `${p.clientName || p.client} · ${p.localTime}`).join(" · "));
|
||||
const failed = posts.filter((p) => ["failed", "partial"].includes(p.status) && Date.now() - new Date(p.publishedAt || p.publishAt) < 7 * 24 * HOUR);
|
||||
add("Publishing", failed.length ? "warn" : "ok", failed.length ? `${failed.length} failed or partly published in the last 7 days` : "No failed posts in the last 7 days");
|
||||
|
||||
// Numbers refresh
|
||||
const all = clients.listClients().filter((c) => c.pageId || c.igId);
|
||||
const stale = all.filter((c) => { const s = metrics.readSnapshot(c.slug); return !s || Date.now() - new Date(s.fetchedAt) > 36 * HOUR; });
|
||||
add("Numbers", stale.length ? "warn" : "ok", stale.length ? `${stale.length} of ${all.length} clients not refreshed in 36 hours` : `All ${all.length} linked clients refreshed in the last 36 hours`, stale.map((c) => c.name).join(", "));
|
||||
const snapErr = all.map((c) => [c, metrics.readSnapshot(c.slug)]).filter(([, s]) => s?.errors?.length);
|
||||
if (snapErr.length) add("Numbers", "warn", `${snapErr.length} client${snapErr.length === 1 ? "" : "s"} had errors in the last refresh`, snapErr.map(([c, s]) => `${c.name}: ${s.errors[0]}`).join(" · ").slice(0, 400));
|
||||
|
||||
// Connections
|
||||
const conns = extra.vault.listConnections({});
|
||||
const broken = conns.filter((c) => c.status !== "connected");
|
||||
add("Connections", broken.length ? "bad" : "ok", broken.length ? `${broken.length} connection${broken.length === 1 ? "" : "s"} need reconnecting` : conns.length ? `${conns.length} platform connection${conns.length === 1 ? "" : "s"} working` : "No platform connections yet (Connections page)", broken.map((c) => `${c.client} ${c.platform}: ${c.lastError || c.status}`).join(" · ").slice(0, 400));
|
||||
const soon = conns.filter((c) => c.refreshExpiresAt && new Date(c.refreshExpiresAt) - Date.now() < 7 * 24 * HOUR);
|
||||
if (soon.length) add("Connections", "warn", `${soon.length} sign-in${soon.length === 1 ? "" : "s"} expire within 7 days`, soon.map((c) => `${c.client} ${c.platform} (${c.refreshExpiresAt.slice(0, 10)})`).join(" · "));
|
||||
|
||||
// Email
|
||||
add("Email", notify.emailConfigured() ? "ok" : "warn", notify.emailConfigured() ? `Email set up (${process.env.SMTP_HOST})` : "Email not set up: notifications only appear in the bell", notify.emailConfigured() ? "" : "Add SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM to .env.");
|
||||
|
||||
// AI runs
|
||||
const recent = jobs.listJobs({}).filter((j) => Date.now() - new Date(j.createdAt) < 7 * 24 * HOUR);
|
||||
const jobFails = recent.filter((j) => ["failed", "interrupted"].includes(j.status));
|
||||
add("AI runs", process.env.ANTHROPIC_API_KEY ? (jobFails.length ? "warn" : "ok") : "warn",
|
||||
!process.env.ANTHROPIC_API_KEY ? "No Anthropic API key: AI runs (audits, weekly plans) can't start" : jobFails.length ? `${jobFails.length} AI run${jobFails.length === 1 ? "" : "s"} failed this week` : `AI runs working (${recent.length} this week)`,
|
||||
jobFails.slice(0, 3).map((j) => `${j.type} ${j.slug || ""}: ${j.error || j.status}`).join(" · "));
|
||||
|
||||
// Backups and disk
|
||||
const bdir = path.join(PROJECT_ROOT, "backups");
|
||||
let latest = null;
|
||||
try { latest = fs.readdirSync(bdir).filter((f) => /^quorla-.*\.tar\.gz$/.test(f)).map((f) => ({ f, t: fs.statSync(path.join(bdir, f)).mtimeMs })).sort((a, b) => b.t - a.t)[0] || null; } catch { /* not mounted */ }
|
||||
add("Backups", !latest ? "warn" : Date.now() - latest.t > 30 * HOUR ? "bad" : "ok",
|
||||
!latest ? "Can't see any backups" : `Last backup ${Math.round((Date.now() - latest.t) / HOUR)} h ago`,
|
||||
!latest ? "Run deploy/backup.sh daily with cron and mount ./backups in docker-compose (read-only)." : latest.f);
|
||||
const size = dirSize(path.join(PROJECT_ROOT, "data"));
|
||||
if (size != null) add("Storage", size > 20 * 1024 ** 3 ? "warn" : "ok", `Data folder uses ${(size / 1024 ** 3).toFixed(2)} GB`, "Uploaded media lives here; old published media can be archived.");
|
||||
|
||||
const order = { bad: 0, warn: 1, ok: 2 };
|
||||
return { at: new Date().toISOString(), version: process.env.QUORLA_VERSION || null, checks: out.sort((a, b) => order[a.status] - order[b.status]) };
|
||||
}
|
||||
@ -8,12 +8,26 @@ import { login, logout, currentUser, requireAuth, requireAdmin, setupFirstAdmin,
|
||||
import * as users from "./users.js";
|
||||
import * as clients from "./clients.js";
|
||||
import * as metrics from "./metrics.js";
|
||||
import * as published from "./published.js";
|
||||
import * as portal from "./portal.js";
|
||||
import * as notify from "./notify.js";
|
||||
import * as notifyEvents from "./notify-events.js";
|
||||
import * as monthly from "./monthly.js";
|
||||
import * as bio from "./bio.js";
|
||||
import * as activity from "./activity.js";
|
||||
import * as health from "./health.js";
|
||||
import * as onboarding from "./onboarding.js";
|
||||
import * as workspace from "./workspace.js";
|
||||
import * as control from "./control.js";
|
||||
import * as odoo from "./odoo.js";
|
||||
import * as jobs from "./jobs.js";
|
||||
import * as docs from "./docs.js";
|
||||
import * as meta from "../../connector/src/meta.js";
|
||||
import * as sched from "../../connector/src/schedule.js";
|
||||
import * as lib from "../../connector/src/library.js";
|
||||
import * as plan from "../../connector/src/plan.js";
|
||||
import * as competitors from "../../connector/src/competitors.js";
|
||||
import * as brandkit from "../../connector/src/brandkit.js";
|
||||
import * as extra from "../../connector/src/platforms/index.js";
|
||||
import { publicBaseUrl, resolveMediaToken } from "../../connector/src/platforms/media.js";
|
||||
import { DATA_DIR as _DD } from "./config.js";
|
||||
@ -22,6 +36,15 @@ const app = express();
|
||||
app.disable("x-powered-by");
|
||||
// Behind Caddy in Docker the proxy is on the private network: TRUST_PROXY=uniquelocal (see docker-compose.yml).
|
||||
app.set("trust proxy", process.env.TRUST_PROXY || "loopback");
|
||||
// Stripe webhook needs the raw body to verify the signature, so it comes before the JSON parser.
|
||||
app.post("/control/stripe/webhook", express.raw({ type: "*/*", limit: "1mb" }), async (req, res) => {
|
||||
if (!control.enabled()) return res.status(404).end();
|
||||
try {
|
||||
const evt = control.verifyWebhook(req.body.toString("utf8"), req.headers["stripe-signature"]);
|
||||
console.log("Stripe:", evt.type, await control.onStripeEvent(evt));
|
||||
res.json({ received: true });
|
||||
} catch (e) { console.error("Stripe webhook:", e.message); res.status(e.status || 400).json({ error: e.message }); }
|
||||
});
|
||||
app.use(express.json({ limit: "200kb" }));
|
||||
app.use((_req, res, next) => {
|
||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||
@ -46,7 +69,9 @@ const h = (fn) => async (req, res) => {
|
||||
app.post("/api/auth/login", login);
|
||||
app.post("/api/auth/logout", logout);
|
||||
app.post("/api/auth/setup", setupFirstAdmin);
|
||||
app.get("/api/auth/me", (req, res) => res.json({ user: currentUser(req), agency: AGENCY_NAME, setupNeeded: !users.hasUsers() }));
|
||||
app.get("/api/auth/me", (req, res) => { const w = workspace.get(); res.json({ user: currentUser(req), agency: workspace.agencyName(), workspace: { accent: w.accent, hasLogo: w.hasLogo }, control: control.enabled(), setupNeeded: !users.hasUsers() }); });
|
||||
// The agency's own logo (white-label), public so the sign-in page and client pages can show it.
|
||||
app.get("/brand/agency-logo", (_req, res) => { const f = workspace.logoPath(); if (!f) return res.status(404).end(); res.setHeader("Cache-Control", "no-cache"); res.sendFile(f); });
|
||||
// App (Quorla) branding. Agency branding for PDF reports stays in branding/ and is used by tools/report-pdf.
|
||||
app.get("/brand/logo.png", (_req, res) => res.sendFile(path.join(BRANDING_DIR, "quorla-logo-app.png")));
|
||||
app.get("/brand/logo-white.png", (_req, res) => res.sendFile(path.join(BRANDING_DIR, "quorla-logo-app-white.png")));
|
||||
@ -59,6 +84,56 @@ app.get("/privacy", (_req, res) => res.sendFile(path.join(LEGAL_DIR, "privacy.ht
|
||||
app.get("/terms", (_req, res) => res.sendFile(path.join(LEGAL_DIR, "terms.html")));
|
||||
app.get("/legal/legal.css", (_req, res) => res.sendFile(path.join(LEGAL_DIR, "legal.css")));
|
||||
|
||||
// Client approval portal: a private link per client, no login. Read-only except approve / request changes.
|
||||
const PORTAL_PAGE = fileURLToPath(new URL("./portal/index.html", import.meta.url));
|
||||
const portalClient = (req, res) => { const c = portal.clientForToken(req.params.token); if (!c) res.status(404).json({ error: "This link isn't active." }); return c; };
|
||||
const noIndex = (_req, res, next) => { res.setHeader("X-Robots-Tag", "noindex, nofollow"); res.setHeader("Referrer-Policy", "no-referrer"); next(); };
|
||||
app.get("/p/:token", noIndex, (req, res) => (portal.clientForToken(req.params.token) ? res.sendFile(PORTAL_PAGE) : res.status(404).send("This link isn't active. Ask your agency for a new one.")));
|
||||
app.get("/p/:token/data", noIndex, (req, res) => { const c = portalClient(req, res); if (c) res.json(portal.view(c)); });
|
||||
app.get("/p/:token/media/:post/:i", noIndex, (req, res) => {
|
||||
const c = portalClient(req, res); if (!c) return;
|
||||
const f = portal.mediaFile(c, req.params.post, req.params.i);
|
||||
if (!f) return res.status(404).end();
|
||||
res.setHeader("Cache-Control", "private, max-age=600");
|
||||
res.sendFile(f);
|
||||
});
|
||||
app.post("/p/:token/decision/:post", noIndex, (req, res) => {
|
||||
const c = portalClient(req, res); if (!c) return;
|
||||
try { res.json({ post: sched.clientDecision(req.params.post, c, req.body || {}).id }); }
|
||||
catch (e) { res.status(e.status || 400).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Hosted Quorla control centre (QUORLA_CONTROL=true): public sign-up and the provisioning script's API.
|
||||
const CONTROL_DIR = fileURLToPath(new URL("./control/", import.meta.url));
|
||||
const signupHits = new Map();
|
||||
app.get("/signup", (req, res) => (control.enabled() ? res.sendFile(path.join(CONTROL_DIR, "signup.html")) : res.status(404).send("Not found")));
|
||||
app.get("/signup/done", (req, res) => (control.enabled() ? res.sendFile(path.join(CONTROL_DIR, "done.html")) : res.status(404).send("Not found")));
|
||||
app.post("/control/signup", async (req, res) => {
|
||||
if (!control.enabled()) return res.status(404).end();
|
||||
const ip = req.ip, hour = Date.now() - 3600000;
|
||||
const hits = (signupHits.get(ip) || []).filter((t) => t > hour);
|
||||
if (hits.length >= 10) return res.status(429).json({ error: "Too many sign-ups from here. Try again later or email support@quorlasocial.com." });
|
||||
signupHits.set(ip, [...hits, Date.now()]);
|
||||
if (String(req.body?.website || "")) return res.json({ next: "manual" }); // honeypot
|
||||
try { res.json(await control.signup(req.body || {}, `${req.protocol}://${req.get("host")}`)); } catch (e) { res.status(e.status || 400).json({ error: e.message }); }
|
||||
});
|
||||
app.get("/control/provision/tasks", (req, res) => { try { control.checkProvisionToken(req.headers.authorization); res.json({ tasks: control.tasks() }); } catch (e) { res.status(e.status || 400).json({ error: e.message }); } });
|
||||
app.post("/control/provision/:slug/report", async (req, res) => { try { control.checkProvisionToken(req.headers.authorization); res.json(await control.report(req.params.slug, req.body || {})); } catch (e) { res.status(e.status || 400).json({ error: e.message }); } });
|
||||
|
||||
// Link-in-bio pages (public): /l/<path> and counted redirects /l/<path>/go/<link id>.
|
||||
app.get("/l/:path", (req, res) => {
|
||||
const page = bio.byPath(req.params.path);
|
||||
if (!page) return res.status(404).send("Not found");
|
||||
res.setHeader("Cache-Control", "public, max-age=300");
|
||||
res.type("html").send(bio.render(page));
|
||||
});
|
||||
app.get("/l/:path/go/:id", (req, res) => {
|
||||
const to = bio.click(req.params.path, req.params.id);
|
||||
if (!to) return res.status(404).send("Not found");
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.redirect(302, to);
|
||||
});
|
||||
|
||||
// Signed, expiring media links that platforms (TikTok, Threads, Google Business Profile) fetch posts from.
|
||||
app.get("/m/:token/:name", (req, res) => {
|
||||
const f = resolveMediaToken(req.params.token);
|
||||
@ -86,6 +161,7 @@ app.get("/oauth/:platform/callback", async (req, res) => {
|
||||
|
||||
// ---------- everything below needs a session ----------
|
||||
app.use("/api", requireAuth);
|
||||
app.use("/api", activity.middleware);
|
||||
|
||||
// ---------- your account (everyone) ----------
|
||||
app.patch("/api/me", h(async (req) => ({ user: users.renameSelf(req.user.id, req.body?.name) })));
|
||||
@ -268,7 +344,11 @@ const clientAccount = async (slug) => {
|
||||
const needPublishing = () => { if (process.env.ALLOW_PUBLISH !== "true") throw Object.assign(new Error("Publishing is switched off. Set ALLOW_PUBLISH=true in .env and restart."), { status: 403 }); };
|
||||
|
||||
app.get("/api/schedule", h(async (req) => ({ posts: sched.listPosts({ client: req.query.client || undefined }), publishing: process.env.ALLOW_PUBLISH === "true" })));
|
||||
app.get("/api/schedule/suggest", h(async (req) => { const c = sched.clientInfo(clients.safeSlug(req.query.client) && req.query.client); return { timezone: c.timezone, times: sched.suggestedTimes(c.timezone) }; }));
|
||||
app.get("/api/schedule/suggest", h(async (req) => {
|
||||
const c = sched.clientInfo(clients.safeSlug(req.query.client) && req.query.client);
|
||||
const best = published.bestTimes(c.slug, c.timezone);
|
||||
return { timezone: c.timezone, times: best.times.length ? best.times : sched.suggestedTimes(c.timezone), best };
|
||||
}));
|
||||
app.post("/api/schedule/upload", express.raw({ type: "*/*", limit: "500mb" }), h(async (req) => {
|
||||
const name = String(req.headers["x-filename"] || "upload").replace(/[^\w.\-]+/g, "_").slice(-80);
|
||||
if (!/\.(jpe?g|png|mp4|mov|m4v)$/i.test(name)) throw Object.assign(new Error("Upload a JPG, PNG, MP4 or MOV file."), { status: 400 });
|
||||
@ -280,6 +360,15 @@ app.post("/api/schedule", h(async (req) => ({ post: sched.createPost({ ...req.bo
|
||||
app.patch("/api/schedule/:id", h(async (req) => ({ post: sched.updatePost(req.params.id, req.body || {}, req.user.name || req.user.email) })));
|
||||
app.post("/api/schedule/:id/approve", requireAdmin, h(async (req) => { needPublishing(); return { post: await sched.approvePost(req.params.id, req.user.name || req.user.email) }; }));
|
||||
app.post("/api/schedule/:id/retry", requireAdmin, h(async (req) => { needPublishing(); return { post: sched.retryPost(req.params.id, req.user.name || req.user.email) }; }));
|
||||
app.post("/api/schedule/:id/send-to-client", h(async (req) => {
|
||||
const p = sched.getPost(req.params.id);
|
||||
if (!portal.linkFor(p.client).enabled) throw Object.assign(new Error("Create this client's approval link first (client page → Client approval)."), { status: 400 });
|
||||
return { post: sched.requestClientReview(req.params.id, who(req)) };
|
||||
}));
|
||||
app.get("/api/portal/:client", h(async (req) => portal.linkFor(req.params.client)));
|
||||
app.post("/api/portal/:client", requireAdmin, h(async (req) => portal.issue(req.params.client, who(req), req.body || {})));
|
||||
app.patch("/api/portal/:client", requireAdmin, h(async (req) => portal.updateContact(req.params.client, req.body || {})));
|
||||
app.delete("/api/portal/:client", requireAdmin, h(async (req) => { portal.revoke(req.params.client); return { enabled: false }; }));
|
||||
app.post("/api/schedule/:id/cancel", h(async (req) => ({ post: await sched.cancelPost(req.params.id, req.user.name || req.user.email) })));
|
||||
app.get("/api/schedule/media", h(async (req, res) => {
|
||||
const f = path.resolve(String(req.query.path || ""));
|
||||
@ -288,6 +377,103 @@ app.get("/api/schedule/media", h(async (req, res) => {
|
||||
}));
|
||||
|
||||
|
||||
// ---------- monthly client report ----------
|
||||
app.get("/api/clients/:slug/monthly", h(async (req) => ({ settings: monthly.settingsFor(req.params.slug), lastMonth: monthly.lastMonth(), email: notify.emailConfigured() })));
|
||||
app.put("/api/clients/:slug/monthly", requireAdmin, h(async (req) => ({ settings: monthly.setSettings(req.params.slug, req.body || {}) })));
|
||||
app.post("/api/clients/:slug/monthly/build", h(async (req) => monthly.buildWithPdf(req.params.slug, String(req.body?.month || monthly.lastMonth()))));
|
||||
app.post("/api/clients/:slug/monthly/email", requireAdmin, h(async (req) => monthly.emailReport(req.params.slug, String(req.body?.month || monthly.lastMonth()))));
|
||||
|
||||
// ---------- brand kit: hashtag sets, phrases, words to avoid; caption checks ----------
|
||||
app.get("/api/brandkit/:client", h(async (req) => brandkit.get(req.params.client)));
|
||||
app.put("/api/brandkit/:client", h(async (req) => brandkit.save(req.params.client, req.body || {}, who(req))));
|
||||
app.post("/api/brandkit/:client/check", h(async (req) => ({ warnings: brandkit.checkCaption(req.params.client, req.body?.caption, { platforms: req.body?.platforms || [] }) })));
|
||||
|
||||
// ---------- link in bio ----------
|
||||
app.get("/api/bio/:client", h(async (req) => ({ page: bio.get(req.params.client), stats: bio.stats(req.params.client, Number(req.query.days) || 30) })));
|
||||
app.put("/api/bio/:client", requireAdmin, h(async (req) => ({ page: bio.save(req.params.client, req.body || {}), stats: bio.stats(req.params.client) })));
|
||||
|
||||
// ---------- Meta connection from Settings (hosted workspaces can't edit .env) ----------
|
||||
app.get("/api/meta/connection", requireAdmin, h(async () => ({ source: meta.metaSource() })));
|
||||
app.put("/api/meta/connection", requireAdmin, h(async (req) => ({ ...(await meta.saveSystemToken(req.body?.token, who(req))), source: meta.metaSource() })));
|
||||
|
||||
// ---------- workspace (white-label) ----------
|
||||
app.get("/api/workspace", h(async () => workspace.get()));
|
||||
app.put("/api/workspace", requireAdmin, h(async (req) => workspace.save(req.body || {}, who(req))));
|
||||
app.post("/api/workspace/logo", requireAdmin, express.raw({ type: "*/*", limit: "3mb" }), h(async (req) => workspace.saveLogo(req.body, req.headers["x-filename"])));
|
||||
app.delete("/api/workspace/logo", requireAdmin, h(async () => { workspace.removeLogo(); return workspace.get(); }));
|
||||
|
||||
// ---------- hosted agencies (control centre) ----------
|
||||
const needControl = (_req, res, next) => (control.enabled() ? next() : res.status(404).json({ error: "Not available." }));
|
||||
app.get("/api/control/tenants", requireAdmin, needControl, h(async () => ({ tenants: control.list(), stripe: Boolean(process.env.STRIPE_SECRET_KEY && process.env.STRIPE_PRICE_ID), provisioner: Boolean(process.env.PROVISION_TOKEN) })));
|
||||
for (const action of ["approve", "suspend", "resume", "remove"]) app.post(`/api/control/tenants/:slug/${action}`, requireAdmin, needControl, h(async (req) => ({ tenant: control[action](req.params.slug) })));
|
||||
app.post("/api/control/tenants/:slug/welcome", requireAdmin, needControl, h(async (req) => ({ tenant: await control.resendWelcome(req.params.slug) })));
|
||||
app.post("/api/control/tenants/:slug/portal", requireAdmin, needControl, h(async (req) => ({ url: await control.billingPortal(req.params.slug, `${req.protocol}://${req.get("host")}/admin/agencies`) })));
|
||||
|
||||
// ---------- getting started checklist ----------
|
||||
app.get("/api/onboarding", requireAdmin, h(async () => onboarding.steps()));
|
||||
app.post("/api/onboarding/dismiss", requireAdmin, h(async (req) => { onboarding.dismiss(req.body?.dismissed !== false); return onboarding.steps(); }));
|
||||
|
||||
// ---------- admin: audit log and system health ----------
|
||||
app.get("/api/admin/audit", requireAdmin, h(async (req) => ({ items: activity.list({ days: Number(req.query.days) || 30, user: req.query.user || undefined, client: req.query.client || undefined, q: req.query.q || undefined }) })));
|
||||
app.get("/api/admin/health", requireAdmin, h(async () => health.checks()));
|
||||
|
||||
// ---------- notifications ----------
|
||||
app.get("/api/notifications", h(async (req) => notify.listFor(req.user.id)));
|
||||
app.post("/api/notifications/read", h(async (req) => { notify.markRead(req.user.id, Array.isArray(req.body?.ids) ? req.body.ids : null); return notify.listFor(req.user.id); }));
|
||||
app.get("/api/me/notify", h(async (req) => ({ prefs: notify.prefsFor(req.user.id), email: notify.emailConfigured() })));
|
||||
app.put("/api/me/notify", h(async (req) => ({ prefs: notify.setPrefs(req.user.id, req.body || {}), email: notify.emailConfigured() })));
|
||||
app.post("/api/notify/test", h(async (req) => notify.sendEmail({ to: req.user.email, subject: "Quorla test email", heading: "Email from Quorla works", lines: ["You'll get notifications like this one. Change what you receive in Settings → Notifications."], button: { label: "Open Quorla", url: notify.appUrl("/settings") }, fromName: "Quorla" })));
|
||||
|
||||
// ---------- content calendar: plan items + library items with a date + queued/published posts ----------
|
||||
app.get("/api/calendar", h(async (req) => plan.calendar({ client: String(req.query.client || ""), from: String(req.query.from || ""), to: String(req.query.to || "") })));
|
||||
app.post("/api/calendar/move", h(async (req) => ({ item: plan.moveEntry(String(req.body?.kind || ""), String(req.body?.id || ""), String(req.body?.date || ""), who(req)) })));
|
||||
// Tell the person a plan item is assigned to (bell + email if they want it).
|
||||
async function notifyAssignee(item, before = null) {
|
||||
if (!item.assignee || (before && before.assignee === item.assignee) || !["idea", "brief", "production"].includes(item.status)) return;
|
||||
const c = clients.getClient(item.client);
|
||||
await notify.notify({
|
||||
type: "assigned", title: `New for you: ${c?.name || item.client} “${item.title}”`, to: [item.assignee],
|
||||
body: `Due ${item.date}${item.format ? ` · ${item.format.toLowerCase()}` : ""}${item.brief ? ` · ${item.brief.slice(0, 200)}` : ""}`,
|
||||
link: `/calendar?client=${item.client}&date=${item.date}`, client: item.client,
|
||||
});
|
||||
}
|
||||
app.post("/api/plan", h(async (req) => { const item = plan.createItem(req.body || {}, who(req)); await notifyAssignee(item); return { item }; }));
|
||||
app.patch("/api/plan/:id", h(async (req) => {
|
||||
const before = plan.listItems({}).find((i) => i.id === req.params.id);
|
||||
const item = plan.updateItem(req.params.id, req.body || {}, who(req));
|
||||
await notifyAssignee(item, before);
|
||||
return { item };
|
||||
}));
|
||||
app.get("/api/team/names", h(async () => ({ names: users.listUsers().filter((u) => u.active).map((u) => u.name || u.email) })));
|
||||
app.post("/api/plan/:id/odoo-task", requireAdmin, h(async (req) => {
|
||||
const item = plan.listItems({}).find((i) => i.id === req.params.id);
|
||||
if (!item) throw Object.assign(new Error("Plan item not found."), { status: 404 });
|
||||
const c = clients.getClient(item.client);
|
||||
const userIds = (req.body?.userId ? [req.body.userId] : []);
|
||||
const description = [item.brief && `Brief: ${item.brief}`, item.caption && `Draft caption: ${item.caption}`, item.platforms?.length && `Platforms: ${item.platforms.join(", ")}`, `Upload the finished work in Quorla → Content library and pick this calendar item.`].filter(Boolean).join("\n\n");
|
||||
const task = await odoo.createTask({ projectId: req.body?.projectId, name: `${c?.name || item.client}: ${item.title}`, description, userIds, deadline: item.date });
|
||||
return { item: plan.updateItem(item.id, { taskRef: String(task.id), status: item.status === "idea" ? "brief" : item.status }, who(req)), task };
|
||||
}));
|
||||
app.delete("/api/plan/:id", h(async (req) => { plan.removeItem(req.params.id); return { ok: true }; }));
|
||||
|
||||
// ---------- competitors per client (Instagram) ----------
|
||||
function ownForCompare(slug) {
|
||||
const snap = metrics.readSnapshot(slug);
|
||||
if (!snap?.ig) return null;
|
||||
return {
|
||||
username: snap.ig.profile?.username, followers: snap.ig.profile?.followers_count ?? null, posts: snap.ig.posts || [],
|
||||
history: metrics.readHistory(slug).map((h) => ({ date: h.date, followers: h.igFollowers })),
|
||||
};
|
||||
}
|
||||
app.get("/api/competitors", h(async (req) => { const c = String(req.query.client || ""); clients.safeSlug(c); return competitors.compare(c, ownForCompare(c)); }));
|
||||
app.post("/api/competitors", h(async (req) => ({ added: await competitors.add(String(req.body?.client || ""), req.body?.username, { by: who(req), note: req.body?.note || "" }) })));
|
||||
app.delete("/api/competitors/:client/:username", h(async (req) => { competitors.remove(req.params.client, req.params.username); return { ok: true }; }));
|
||||
app.post("/api/competitors/refresh", h(async (req) => competitors.refresh(String(req.body?.client || "") || undefined)));
|
||||
|
||||
// ---------- published posts and their numbers ----------
|
||||
app.get("/api/published", h(async (req) => published.feed({ client: req.query.client ? (clients.safeSlug(req.query.client), String(req.query.client)) : undefined, platform: req.query.platform ? String(req.query.platform) : undefined, days: Number(req.query.days) || 30 })));
|
||||
app.post("/api/published/refresh", h(async () => published.refreshStats()));
|
||||
|
||||
// ---------- content library: team uploads finished work per client ----------
|
||||
const who = (req) => req.user.name || req.user.email;
|
||||
const esc = (s) => String(s || "").replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]);
|
||||
@ -298,7 +484,10 @@ app.post("/api/library/upload", h(async (req) => {
|
||||
return { path: file, size: fs.statSync(file).size };
|
||||
}));
|
||||
app.post("/api/library", h(async (req) => {
|
||||
const item = lib.createItem({ ...req.body, uploadedBy: who(req) });
|
||||
// Uploaded for a calendar item: inherit its date and Odoo task, and move the card to "in production".
|
||||
const p = req.body?.planItem ? plan.listItems({ client: req.body.client }).find((i) => i.id === req.body.planItem) : null;
|
||||
const item = lib.createItem({ ...req.body, forDate: req.body?.forDate || p?.date || "", taskRef: req.body?.taskRef || p?.taskRef || "", planItem: p?.id || "", uploadedBy: who(req) });
|
||||
if (p) plan.updateItem(p.id, { libraryItem: item.id, ...(["idea", "brief"].includes(p.status) ? { status: "production" } : {}) }, who(req));
|
||||
const odooResult = await odoo.taskActivity(item.taskRef, { note: `<p>${esc(who(req))} uploaded <b>${esc(item.title)}</b> (${item.format.toLowerCase()}, ${item.files.length} file${item.files.length > 1 ? "s" : ""}) to the Social Manager library for review.</p>`, stageMatch: "review" });
|
||||
return { item, odoo: odooResult };
|
||||
}));
|
||||
@ -310,9 +499,36 @@ app.patch("/api/library/:id", h(async (req) => {
|
||||
app.post("/api/library/:id/review", requireAdmin, h(async (req) => {
|
||||
const item = lib.reviewItem(req.params.id, req.body || {}, who(req));
|
||||
const ok = item.status === "approved";
|
||||
if (item.planItem) { try { plan.updateItem(item.planItem, { status: ok ? "ready" : "production" }, who(req)); } catch { /* plan item removed */ } }
|
||||
if (!ok && item.uploadedBy) await notify.notify({ type: "library_changes", level: "warn", title: `Changes requested: ${item.title}`, body: item.reviewNote, to: [item.uploadedBy], link: `/library?client=${item.client}`, client: item.client });
|
||||
await odoo.taskActivity(item.taskRef, { note: ok ? `<p>Approved by ${esc(who(req))}: <b>${esc(item.title)}</b>.</p>` : `<p>Changes requested by ${esc(who(req))}: ${esc(item.reviewNote)}</p>`, stageMatch: ok ? "done" : "progress" });
|
||||
return { item };
|
||||
}));
|
||||
// Schedule several approved items at once: one per slot (start date, every N days, cycling through the times).
|
||||
// Items with their own planned date keep it. Creates drafts only; each still needs approval.
|
||||
app.post("/api/library/bulk-schedule", h(async (req) => {
|
||||
const { client, ids = [], startDate, everyDays = 1, times = [], platforms = [], options = {} } = req.body || {};
|
||||
clients.safeSlug(client);
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(startDate))) throw Object.assign(new Error("Pick a start date."), { status: 400 });
|
||||
const slots = (times.length ? times : ["10:00"]).filter((t) => /^\d{2}:\d{2}$/.test(t));
|
||||
if (!slots.length) throw Object.assign(new Error("Times must be like 10:00."), { status: 400 });
|
||||
if (!platforms.length) throw Object.assign(new Error("Choose at least one platform."), { status: 400 });
|
||||
const step = Math.min(Math.max(Number(everyDays) || 1, 1), 14);
|
||||
const items = ids.map((id) => lib.getItem(id)).filter((i) => i.client === client && i.status === "approved")
|
||||
.sort((a, b) => (a.forDate || "9999").localeCompare(b.forDate || "9999") || a.createdAt.localeCompare(b.createdAt));
|
||||
const day = (iso, n) => { const d = new Date(`${iso}T12:00:00Z`); d.setUTCDate(d.getUTCDate() + n); return d.toISOString().slice(0, 10); };
|
||||
const created = [], errors = [];
|
||||
let k = 0;
|
||||
for (const i of items) {
|
||||
const own = i.forDate && i.forDate >= startDate;
|
||||
const date = own ? i.forDate : day(startDate, k * step);
|
||||
const time = slots[(own ? created.length : k) % slots.length];
|
||||
if (!own) k += 1;
|
||||
try { const post = lib.scheduleItem(i.id, { platforms, localTime: `${date} ${time}`, options }, who(req)); created.push({ id: i.id, title: i.title, localTime: post.localTime, warnings: post.warnings }); }
|
||||
catch (e) { errors.push({ id: i.id, title: i.title, error: e.message }); }
|
||||
}
|
||||
return { created, errors };
|
||||
}));
|
||||
app.post("/api/library/:id/schedule", h(async (req) => ({ post: lib.scheduleItem(req.params.id, req.body || {}, who(req)), item: lib.getItem(req.params.id) })));
|
||||
app.post("/api/library/:id/archive", h(async (req) => ({ item: lib.archiveItem(req.params.id, who(req)) })));
|
||||
app.get("/api/library/file", h(async (req, res) => {
|
||||
@ -322,6 +538,9 @@ app.get("/api/library/file", h(async (req, res) => {
|
||||
}));
|
||||
|
||||
// ---------- engagement: comment inbox ----------
|
||||
app.get("/api/inbox/reviews", h(async (req) => extra.gbpReviews(String(req.query.client || ""))));
|
||||
app.post("/api/inbox/reviews/reply", h(async (req) => ({ reply: await extra.gbpReply(String(req.body?.client || ""), String(req.body?.reviewId || ""), req.body?.text) })));
|
||||
app.get("/api/inbox/youtube", h(async (req) => extra.youtubeComments(String(req.query.client || ""))));
|
||||
app.get("/api/inbox", h(async (req) => {
|
||||
const slug = req.query.client; clients.safeSlug(slug);
|
||||
return { comments: await meta.commentInbox(await clientAccount(slug), { days: Number(req.query.days || 14) }) };
|
||||
@ -369,10 +588,14 @@ function scheduleNightly() {
|
||||
if (next <= new Date()) next.setDate(next.getDate() + 1);
|
||||
setTimeout(async () => {
|
||||
try { console.log("Nightly refresh:", await metrics.refreshAll()); } catch (e) { console.error("Nightly refresh failed", e); }
|
||||
try { console.log("Post stats:", await published.refreshStats()); } catch (e) { console.error("Post stats failed", e); }
|
||||
try { console.log("Competitors:", await competitors.refresh()); } catch (e) { console.error("Competitors refresh failed", e); }
|
||||
scheduleNightly();
|
||||
}, next - new Date());
|
||||
}
|
||||
if (process.env.DASHBOARD_NIGHTLY_REFRESH !== "false") scheduleNightly();
|
||||
notifyEvents.start();
|
||||
if (process.env.DASHBOARD_NIGHTLY_REFRESH !== "false") monthly.startSchedule();
|
||||
|
||||
// Publish approved posts when they're due (each client's own time zone). Every 2 minutes.
|
||||
if (process.env.SCHEDULER_ENABLED !== "false") {
|
||||
@ -381,7 +604,7 @@ if (process.env.SCHEDULER_ENABLED !== "false") {
|
||||
|
||||
const host = process.env.DASHBOARD_HOST || "127.0.0.1";
|
||||
app.listen(PORT, host, () => {
|
||||
console.log(`${AGENCY_NAME} dashboard on http://${host === "0.0.0.0" ? "localhost" : host}:${PORT}`);
|
||||
console.log(`${workspace.agencyName()} dashboard on http://${host === "0.0.0.0" ? "localhost" : host}:${PORT}`);
|
||||
if (!users.hasUsers()) console.log("No logins yet. Open the dashboard to create the first admin.");
|
||||
if (!ODOO.url) console.log("Odoo not configured: the Team page will show setup steps.");
|
||||
});
|
||||
|
||||
194
dashboard/server/monthly.js
Normal file
194
dashboard/server/monthly.js
Normal file
@ -0,0 +1,194 @@
|
||||
// Monthly client report, built from real numbers (no AI needed): posts and results for the month, follower change,
|
||||
// best posts and formats, how the client compares with tracked competitors, and next month's plan.
|
||||
// Written as clients/<slug>/reports/YYYY-MM-monthly.md, exported to the branded PDF, optionally emailed to the client.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DATA_DIR } from "./config.js";
|
||||
import { agencyName, reportOptions } from "./workspace.js";
|
||||
import * as clients from "./clients.js";
|
||||
import * as metrics from "./metrics.js";
|
||||
import * as published from "./published.js";
|
||||
import * as portal from "./portal.js";
|
||||
import * as notify from "./notify.js";
|
||||
import * as plan from "../../connector/src/plan.js";
|
||||
import * as competitors from "../../connector/src/competitors.js";
|
||||
import { toPdf } from "../../tools/report-pdf/build.mjs";
|
||||
|
||||
const SETTINGS = path.join(DATA_DIR, "monthly.json");
|
||||
const readJson = (f, d) => { try { return JSON.parse(fs.readFileSync(f, "utf8")); } catch { return d; } };
|
||||
const err = (msg, status = 400) => Object.assign(new Error(msg), { status });
|
||||
const nf = new Intl.NumberFormat("en-CA");
|
||||
const n = (v) => (v == null ? "—" : nf.format(v));
|
||||
const PL = { instagram: "Instagram", facebook: "Facebook", tiktok: "TikTok", youtube: "YouTube", gbp: "Google Business Profile", linkedin: "LinkedIn", pinterest: "Pinterest", threads: "Threads" };
|
||||
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
|
||||
export function settingsFor(slug) { return { auto: false, email: false, ...(readJson(SETTINGS, {})[slug] || {}) }; }
|
||||
export function setSettings(slug, patch) {
|
||||
clients.safeSlug(slug);
|
||||
const all = readJson(SETTINGS, {});
|
||||
all[slug] = { ...settingsFor(slug), ...(patch.auto !== undefined ? { auto: Boolean(patch.auto) } : {}), ...(patch.email !== undefined ? { email: Boolean(patch.email) } : {}) };
|
||||
fs.writeFileSync(SETTINGS, JSON.stringify(all, null, 1));
|
||||
return all[slug];
|
||||
}
|
||||
|
||||
const monthLabel = (m) => new Date(`${m}-15T12:00:00Z`).toLocaleDateString("en-CA", { month: "long", year: "numeric", timeZone: "UTC" });
|
||||
const lastMonth = () => { const d = new Date(); d.setUTCDate(1); d.setUTCMonth(d.getUTCMonth() - 1); return d.toISOString().slice(0, 7); };
|
||||
const nextMonthOf = (m) => { const d = new Date(`${m}-15T12:00:00Z`); d.setUTCMonth(d.getUTCMonth() + 1); return d.toISOString().slice(0, 7); };
|
||||
const endOf = (m) => { const d = new Date(`${m}-01T12:00:00Z`); d.setUTCMonth(d.getUTCMonth() + 1); d.setUTCDate(0); return d.toISOString().slice(0, 10); };
|
||||
const day = (iso) => new Date(`${String(iso).slice(0, 10)}T12:00:00Z`).toLocaleDateString("en-CA", { month: "short", day: "numeric", timeZone: "UTC" });
|
||||
const clean = (s, max = 110) => { const t = String(s || "").replace(/\s+/g, " ").replace(/[|]/g, "/").trim(); return t.length > max ? `${t.slice(0, max - 1)}…` : t; };
|
||||
|
||||
/** Build the report for one client and month (YYYY-MM, default last month). Returns the .md path relative to the client folder. */
|
||||
export function build(slug, month = lastMonth()) {
|
||||
const dir = clients.safeSlug(slug);
|
||||
if (!/^\d{4}-\d{2}$/.test(month)) throw err("Month must be YYYY-MM.");
|
||||
const c = clients.getClient(slug);
|
||||
const first = `${month}-01`, last = endOf(month);
|
||||
const daysBack = Math.ceil((Date.now() - new Date(`${first}T00:00:00Z`)) / 86400000) + 1;
|
||||
const items = published.feed({ client: slug, days: daysBack }).items.filter((i) => i.at && i.at.slice(0, 10) >= first && i.at.slice(0, 10) <= last);
|
||||
const hist = metrics.readHistory(slug);
|
||||
const inMonth = hist.filter((h) => h.date >= first && h.date <= last);
|
||||
const before = [...hist].reverse().find((h) => h.date < first) || inMonth[0];
|
||||
const end = inMonth.at(-1);
|
||||
const ig0 = before?.igFollowers, ig1 = end?.igFollowers, fb0 = before?.fbFollowers, fb1 = end?.fbFollowers;
|
||||
const snap = metrics.readSnapshot(slug);
|
||||
|
||||
const reach = items.filter((i) => i.metrics.reach != null).reduce((s, i) => s + i.metrics.reach, 0);
|
||||
const inter = items.reduce((s, i) => s + (i.interactions || 0), 0);
|
||||
const byPlatform = {};
|
||||
for (const i of items) byPlatform[i.platform] = (byPlatform[i.platform] || 0) + 1;
|
||||
const top = [...items].filter((i) => i.interactions != null).sort((a, b) => b.interactions - a.interactions).slice(0, 3);
|
||||
const byFormat = {};
|
||||
for (const i of items.filter((x) => x.platform === "instagram")) (byFormat[i.format] ||= []).push(i.interactions || 0);
|
||||
const formatAvg = Object.entries(byFormat).map(([f, v]) => [f, Math.round(v.reduce((a, b) => a + b, 0) / v.length), v.length]).sort((a, b) => b[1] - a[1]);
|
||||
const byDay = {};
|
||||
for (const i of items) (byDay[new Date(i.at).getDay()] ||= []).push(i.interactions || 0);
|
||||
const bestDay = items.length < 6 ? null : Object.entries(byDay).filter(([, v]) => v.length >= 2).map(([d, v]) => [DAYS[d], v.reduce((a, b) => a + b, 0) / v.length]).sort((a, b) => b[1] - a[1])[0];
|
||||
|
||||
const own = snap?.ig ? { username: snap.ig.profile?.username, followers: snap.ig.profile?.followers_count, posts: snap.ig.posts || [], history: hist.map((h) => ({ date: h.date, followers: h.igFollowers })) } : null;
|
||||
const comp = competitors.compare(slug, own);
|
||||
const nm = nextMonthOf(month);
|
||||
const upcoming = plan.calendar({ client: slug, from: `${nm}-01`, to: endOf(nm) }).items;
|
||||
|
||||
const delta = (a, b) => (a == null || b == null ? null : b - a);
|
||||
const igD = delta(ig0, ig1), fbD = delta(fb0, fb1);
|
||||
const signed = (v) => (v == null ? "—" : `${v > 0 ? "+" : ""}${nf.format(v)}`);
|
||||
const accounts = [snap?.ig?.profile?.username && `Instagram @${snap.ig.profile.username}`, snap?.fb?.page?.name && `Facebook ${snap.fb.page.name}`].filter(Boolean).join(" · ");
|
||||
|
||||
const kpis = [
|
||||
`${n(items.length)} | Posts published | ${Object.entries(byPlatform).map(([p, k]) => `${k} ${PL[p] || p}`).join(", ") || "none this month"}`,
|
||||
`${n(reach)} | Accounts reached on Instagram | summed across this month's posts`,
|
||||
`${n(inter)} | Interactions | likes, comments, saves and shares`,
|
||||
`${signed(igD)} | Instagram followers | ${ig1 != null ? `${n(ig1)} at month end` : "no data yet"}`,
|
||||
];
|
||||
const lines = [];
|
||||
lines.push("---", `client: ${c?.name || slug}`, `report: Monthly social media report · ${monthLabel(month)}`, `period: ${day(first)} – ${day(last)}, ${month.slice(0, 4)}`);
|
||||
if (accounts) lines.push(`accounts: ${accounts}`);
|
||||
lines.push("kpis:", ...kpis.map((k) => ` - ${k}`), "---", "");
|
||||
|
||||
lines.push("## The month in short", "");
|
||||
const short = [];
|
||||
short.push(items.length ? `We published ${items.length} post${items.length === 1 ? "" : "s"}${Object.keys(byPlatform).length > 1 ? ` across ${Object.keys(byPlatform).length} platforms` : ""}.` : "No posts went out this month.");
|
||||
const fol = (k) => `${nf.format(k)} follower${k === 1 ? "" : "s"}`;
|
||||
if (igD != null) short.push(igD > 0 ? `Instagram grew by ${fol(igD)}.` : igD < 0 ? `Instagram lost ${fol(-igD)}.` : "Instagram followers held steady.");
|
||||
if (fbD != null && fbD !== 0) short.push(`Facebook ${fbD > 0 ? "gained" : "lost"} ${nf.format(Math.abs(fbD))} follower${Math.abs(fbD) === 1 ? "" : "s"}.`);
|
||||
if (formatAvg.filter(([, , k]) => k >= 2).length > 1 && formatAvg[0][2] >= 2) short.push(`${formatAvg[0][0]}s did best on Instagram, with about ${nf.format(formatAvg[0][1])} interactions each.`);
|
||||
if (bestDay) short.push(`${bestDay[0]} posts got the most engagement.`);
|
||||
lines.push(short.join(" "), "");
|
||||
|
||||
if (top.length) {
|
||||
lines.push("## Best posts", "", "| Date | Platform | Post | Reach | Interactions |", "|---|---|---|---|---|");
|
||||
for (const t of top) lines.push(`| ${day(t.at)} | ${PL[t.platform] || t.platform} | ${t.permalink ? `[${clean(t.caption, 70) || "View post"}](${t.permalink})` : clean(t.caption, 70)} | ${n(t.metrics.reach)} | ${n(t.interactions)} |`);
|
||||
lines.push("");
|
||||
}
|
||||
if (formatAvg.length) {
|
||||
lines.push("## What worked on Instagram", "", "| Format | Posts | Average interactions |", "|---|---|---|");
|
||||
for (const [f, avg, k] of formatAvg) lines.push(`| ${f} | ${k} | ${n(avg)} |`);
|
||||
lines.push("");
|
||||
}
|
||||
if (items.length) {
|
||||
lines.push("## Every post this month", "", "| Date | Platform | Format | Reach | Likes | Comments | Interactions |", "|---|---|---|---|---|---|---|");
|
||||
for (const i of [...items].sort((a, b) => a.at.localeCompare(b.at))) lines.push(`| ${day(i.at)} | ${PL[i.platform] || i.platform} | ${i.format} | ${n(i.metrics.reach)} | ${n(i.metrics.likes)} | ${n(i.metrics.comments)} | ${n(i.interactions)} |`);
|
||||
lines.push("");
|
||||
}
|
||||
if (comp.competitors.length) {
|
||||
lines.push("## How you compare", "", "Engagement rate is a typical post's likes and comments divided by followers, so accounts of different sizes compare fairly.", "",
|
||||
"| Account | Followers | Posts / week | Typical post | Engagement rate |", "|---|---|---|---|---|");
|
||||
for (const r of [...(comp.you ? [{ ...comp.you, username: comp.you.username || "you" }] : []), ...comp.competitors]) {
|
||||
lines.push(`| ${r.own ? "**" : ""}@${r.username}${r.own ? " (you)**" : ""} | ${n(r.followers)} | ${r.postsPerWeek ?? "—"} | ${n(r.avgEngagement)} | ${r.engagementRate == null ? "—" : `${r.engagementRate}%`} |`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
lines.push(`## Coming up in ${monthLabel(nm)}`, "");
|
||||
if (upcoming.length) {
|
||||
for (const u of upcoming.slice(0, 10)) lines.push(`- ${day(u.date)}: ${clean(u.title, 90)} (${u.stage === "published" ? "published" : u.stage === "scheduled" ? "scheduled" : "in planning"})`);
|
||||
if (upcoming.length > 10) lines.push(`- …and ${upcoming.length - 10} more`);
|
||||
} else lines.push("We'll share next month's plan with you for approval.");
|
||||
lines.push("", `*Prepared by ${agencyName()}. Numbers come from Instagram and Facebook insights; reach isn't available for every platform.*`, "");
|
||||
|
||||
const reportsDir = path.join(dir, "reports");
|
||||
fs.mkdirSync(reportsDir, { recursive: true });
|
||||
const file = path.join(reportsDir, `${month}-monthly.md`);
|
||||
fs.writeFileSync(file, lines.join("\n"));
|
||||
return path.relative(dir, file).replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
/** Build and export the PDF. Returns { md, pdf } relative paths. */
|
||||
export function buildWithPdf(slug, month) {
|
||||
const md = build(slug, month);
|
||||
const dir = clients.safeSlug(slug);
|
||||
const pdf = toPdf(path.join(dir, md), reportOptions());
|
||||
return { md, pdf: path.relative(dir, pdf).replace(/\\/g, "/") };
|
||||
}
|
||||
|
||||
/** Email the month's PDF to the reviewer saved on the client's approval link. */
|
||||
export async function emailReport(slug, month = lastMonth()) {
|
||||
const dir = clients.safeSlug(slug);
|
||||
const link = portal.linkFor(slug);
|
||||
if (!link.enabled || !link.contactEmail) throw err("Add the client's email first (client page → Client approval → Who reviews).");
|
||||
const pdf = path.join(dir, "reports", `${month}-monthly.pdf`);
|
||||
if (!fs.existsSync(pdf)) throw err("Create the report first.");
|
||||
const c = clients.getClient(slug);
|
||||
const r = await notify.sendEmail({
|
||||
to: link.contactEmail,
|
||||
subject: `${c?.name || "Your"} social media report · ${monthLabel(month)}`,
|
||||
heading: `Your ${monthLabel(month)} social media report`,
|
||||
lines: [`Hi${link.contactName ? ` ${link.contactName}` : ""}, attached is how ${c?.name || "your accounts"} did in ${monthLabel(month)}: what we posted, what worked, how you compare with competitors, and what's coming next month.`, "Reply to this email with any questions."],
|
||||
fromName: agencyName(),
|
||||
attachments: [{ filename: `${(c?.name || slug).replace(/[^\w-]+/g, "-")}-${month}-report.pdf`, path: pdf }],
|
||||
});
|
||||
if (!r.sent) throw err(`Couldn't send: ${r.reason}`);
|
||||
return { sent: true, to: link.contactEmail };
|
||||
}
|
||||
|
||||
/** 1st of the month, 07:00: build last month's report for clients with auto on; email it where email is on. */
|
||||
export async function runMonthly() {
|
||||
const month = lastMonth();
|
||||
const done = [];
|
||||
for (const c of clients.listClients()) {
|
||||
const s = settingsFor(c.slug);
|
||||
if (!s.auto) continue;
|
||||
try {
|
||||
buildWithPdf(c.slug, month);
|
||||
let sent = false;
|
||||
if (s.email) { try { sent = (await emailReport(c.slug, month)).sent; } catch (e) { console.error("Monthly email", c.slug, e.message); } }
|
||||
done.push(c.slug);
|
||||
await notify.notify({ type: "monthly_report", level: "ok", title: `${c.name}: ${monthLabel(month)} report ready${sent ? " and emailed" : ""}`, link: `/clients/${c.slug}`, client: c.slug, to: "admins", email: false });
|
||||
} catch (e) {
|
||||
await notify.notify({ type: "monthly_report", level: "bad", title: `${c.name}: monthly report failed`, body: e.message, link: `/clients/${c.slug}`, client: c.slug, to: "admins" });
|
||||
}
|
||||
}
|
||||
return { month, done };
|
||||
}
|
||||
|
||||
export function startSchedule() {
|
||||
const tick = () => {
|
||||
const next = new Date(); next.setDate(1); next.setHours(7, 0, 0, 0);
|
||||
if (next <= new Date()) { next.setMonth(next.getMonth() + 1); }
|
||||
const wait = Math.min(next - new Date(), 2 ** 31 - 1); // setTimeout limit (~24 days); re-check then
|
||||
setTimeout(async () => { if (new Date().getDate() === 1 && new Date().getHours() >= 7) { try { console.log("Monthly reports:", await runMonthly()); } catch (e) { console.error("Monthly reports failed", e); } } tick(); }, wait);
|
||||
};
|
||||
tick();
|
||||
}
|
||||
|
||||
export { lastMonth };
|
||||
124
dashboard/server/notify-events.js
Normal file
124
dashboard/server/notify-events.js
Normal file
@ -0,0 +1,124 @@
|
||||
// Who hears about what. Wired to the queue's events once at startup, plus a daily 08:00 summary email.
|
||||
import * as notify from "./notify.js";
|
||||
import * as portal from "./portal.js";
|
||||
import * as clients from "./clients.js";
|
||||
import { agencyName } from "./workspace.js";
|
||||
import * as sched from "../../connector/src/schedule.js";
|
||||
import * as plan from "../../connector/src/plan.js";
|
||||
import * as extra from "../../connector/src/platforms/index.js";
|
||||
|
||||
const PLATFORM = { instagram: "Instagram", facebook: "Facebook", tiktok: "TikTok", youtube: "YouTube", gbp: "Google Business Profile", linkedin: "LinkedIn", pinterest: "Pinterest", threads: "Threads", reddit: "Reddit" };
|
||||
const names = (pls) => pls.map((p) => PLATFORM[p] || p).join(" + ");
|
||||
const clientName = (slug) => clients.getClient(slug)?.name || slug;
|
||||
const short = (s, n = 90) => { const t = String(s || "").replace(/\s+/g, " ").trim(); return t.length > n ? `${t.slice(0, n - 1)}…` : t; };
|
||||
const people = (...xs) => [...new Set(xs.filter(Boolean))];
|
||||
|
||||
// Several posts sent to the same client within a few minutes → one email to them.
|
||||
const pendingClientMail = new Map();
|
||||
function queueClientEmail(slug) {
|
||||
clearTimeout(pendingClientMail.get(slug));
|
||||
pendingClientMail.set(slug, setTimeout(() => { pendingClientMail.delete(slug); emailClient(slug).catch((e) => console.error("Client email failed", e)); }, 5 * 60000));
|
||||
}
|
||||
async function emailClient(slug) {
|
||||
const link = portal.linkFor(slug);
|
||||
if (!link.enabled || !link.contactEmail) return;
|
||||
const waiting = sched.listPosts({ client: slug }).filter((p) => p.status === "draft" && p.clientReview?.status === "pending");
|
||||
if (!waiting.length) return;
|
||||
const n = waiting.length;
|
||||
await notify.sendEmail({
|
||||
to: link.contactEmail,
|
||||
subject: `${n} post${n === 1 ? "" : "s"} waiting for your approval`,
|
||||
heading: `Hi${link.contactName ? ` ${link.contactName}` : ""}, ${n === 1 ? "a post is" : `${n} posts are`} ready for you to review`,
|
||||
lines: [
|
||||
...waiting.slice(0, 5).map((p) => `${p.localTime} · ${names(p.platforms)}: ${short(p.caption, 80) || "(no caption)"}`),
|
||||
"Approve them or tell us what to change. Nothing is posted until our team gives the final go-ahead.",
|
||||
],
|
||||
button: { label: "Review posts", url: notify.appUrl(`/p/${link.token}`) },
|
||||
fromName: agencyName(),
|
||||
footer: `${agencyName()} · You're getting this because you review posts for ${clientName(slug)}.`,
|
||||
});
|
||||
}
|
||||
|
||||
async function onEvent({ type, post: p }) {
|
||||
const c = clientName(p.client);
|
||||
const link = "/publishing";
|
||||
switch (type) {
|
||||
case "draft_created":
|
||||
return notify.notify({ type, title: `New draft for ${c} needs approval`, body: `${p.localTime} · ${names(p.platforms)} · ${short(p.caption)}`, link, client: p.client, to: "admins", email: false });
|
||||
case "client_review_requested":
|
||||
queueClientEmail(p.client);
|
||||
return;
|
||||
case "client_decision": {
|
||||
const r = p.clientReview;
|
||||
const ok = r.status === "approved";
|
||||
return notify.notify({
|
||||
type, level: ok ? "ok" : "warn", link, client: p.client, to: people("admins", ...recipientsOf(p)),
|
||||
title: ok ? `${c} approved a post` : `${c} asked for changes`,
|
||||
body: ok ? `${r.by || "The client"} approved the ${p.localTime} post. It still needs your final approval to publish.` : `${r.by || "The client"}: “${short(r.note, 300)}”`,
|
||||
});
|
||||
}
|
||||
case "published":
|
||||
if (p.results?.tiktok?.inbox) {
|
||||
return notify.notify({ type: "tiktok_inbox", level: "warn", link, client: p.client, to: people("admins", ...recipientsOf(p)), title: `Finish ${c}'s TikTok post in the TikTok app`, body: "The video is in the account's TikTok inbox. Open TikTok, choose who can see it and tap Post." });
|
||||
}
|
||||
return notify.notify({ type, level: "ok", link: "/published", client: p.client, to: people(...recipientsOf(p)), title: `Published for ${c}`, body: `${names(p.platforms)} · ${short(p.caption)}`, email: false });
|
||||
case "partly_published":
|
||||
case "failed":
|
||||
return notify.notify({
|
||||
type, level: "bad", link, client: p.client, to: people("admins", ...recipientsOf(p)),
|
||||
title: type === "failed" ? `A post for ${c} failed` : `A post for ${c} only partly published`,
|
||||
body: Object.entries(p.errors || {}).map(([k, v]) => `${PLATFORM[k] || k}: ${short(v, 200)}`).join(" · ") || "Open Publishing to see why and retry.",
|
||||
});
|
||||
default:
|
||||
}
|
||||
}
|
||||
// "admins" is a group; everyone else by name. Flattened by notify.notify's recipients().
|
||||
function recipientsOf(p) { return [p.createdBy, p.approvedBy].filter((x) => x && x !== "agent"); }
|
||||
|
||||
/** The morning summary for one person, or null if there's nothing to say. */
|
||||
function digestFor(u) {
|
||||
const posts = sched.listPosts({});
|
||||
const isAdmin = u.role === "admin";
|
||||
const lines = [];
|
||||
if (isAdmin) {
|
||||
const drafts = posts.filter((p) => p.status === "draft");
|
||||
const clientOk = drafts.filter((p) => p.clientReview?.status === "approved");
|
||||
const changes = drafts.filter((p) => p.clientReview?.status === "changes");
|
||||
if (drafts.length) lines.push(`${drafts.length} draft${drafts.length === 1 ? "" : "s"} waiting for approval${clientOk.length ? `, ${clientOk.length} already approved by the client` : ""}.`);
|
||||
if (changes.length) lines.push(`${changes.length} post${changes.length === 1 ? "" : "s"} where the client asked for changes.`);
|
||||
const dayAgo = Date.now() - 86400000;
|
||||
const failed = posts.filter((p) => ["failed", "partial"].includes(p.status) && new Date(p.publishedAt || p.publishAt).getTime() >= dayAgo);
|
||||
if (failed.length) lines.push(`${failed.length} post${failed.length === 1 ? "" : "s"} failed in the last day.`);
|
||||
const reconnect = extra.vault.listConnections({}).filter((x) => x.status !== "connected");
|
||||
if (reconnect.length) lines.push(`Reconnect needed: ${reconnect.map((x) => `${clientName(x.client)} ${PLATFORM[x.platform] || x.platform}`).join(", ")}.`);
|
||||
}
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const soon = new Date(Date.now() + 2 * 86400000).toISOString().slice(0, 10);
|
||||
const mine = plan.listItems({ from: today, to: soon }).filter((i) => ["idea", "brief", "production"].includes(i.status) && i.assignee && [u.name, u.email].filter(Boolean).some((n) => i.assignee.toLowerCase().includes(String(n).toLowerCase().split(" ")[0])));
|
||||
if (mine.length) lines.push(`Due in the next two days for you: ${mine.map((i) => `${clientName(i.client)} “${short(i.title, 40)}” (${i.date})`).join("; ")}.`);
|
||||
return lines.length ? lines : null;
|
||||
}
|
||||
|
||||
async function sendDigests() {
|
||||
if (!notify.emailConfigured()) return;
|
||||
for (const u of (await import("./users.js")).listUsers().filter((x) => x.active && x.email)) {
|
||||
if (!notify.prefsFor(u.id).digest) continue;
|
||||
const lines = digestFor(u);
|
||||
if (!lines) continue;
|
||||
await notify.sendEmail({ to: u.email, subject: "Your Quorla morning summary", heading: `Good morning${u.name ? `, ${u.name.split(" ")[0]}` : ""}`, lines, button: { label: "Open Quorla", url: notify.appUrl("/publishing") }, fromName: "Quorla" });
|
||||
}
|
||||
}
|
||||
|
||||
let started = false;
|
||||
export function start() {
|
||||
if (started) return; started = true;
|
||||
sched.events.on("event", (e) => { onEvent(e).catch((err) => console.error("Notification failed", err)); });
|
||||
const scheduleDigest = () => {
|
||||
const next = new Date(); next.setHours(8, 0, 0, 0);
|
||||
if (next <= new Date()) next.setDate(next.getDate() + 1);
|
||||
setTimeout(async () => { try { await sendDigests(); } catch (e) { console.error("Digest failed", e); } scheduleDigest(); }, next - new Date());
|
||||
};
|
||||
scheduleDigest();
|
||||
}
|
||||
|
||||
export { digestFor };
|
||||
108
dashboard/server/notify.js
Normal file
108
dashboard/server/notify.js
Normal file
@ -0,0 +1,108 @@
|
||||
// Notifications: an in-app list for the team (bell in the sidebar) plus email when SMTP is set up.
|
||||
// Team emails go to people who keep email on in Settings; client emails (posts waiting for their approval)
|
||||
// go to the reviewer saved on the client's approval link, sent in the agency's name.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import nodemailer from "nodemailer";
|
||||
import { DATA_DIR } from "./config.js";
|
||||
import { agencyName } from "./workspace.js";
|
||||
import * as users from "./users.js";
|
||||
|
||||
const FILE = path.join(DATA_DIR, "notifications.json");
|
||||
const PREFS = path.join(DATA_DIR, "notify-prefs.json");
|
||||
const KEEP = 500;
|
||||
const readJson = (f, d) => { try { return JSON.parse(fs.readFileSync(f, "utf8")); } catch { return d; } };
|
||||
const writeJson = (f, v) => { const tmp = f + ".tmp"; fs.writeFileSync(tmp, JSON.stringify(v)); fs.renameSync(tmp, f); };
|
||||
|
||||
// ---------- email ----------
|
||||
let transport = null;
|
||||
export function emailConfigured() { return Boolean(process.env.SMTP_HOST); }
|
||||
function mailer() {
|
||||
if (!emailConfigured()) return null;
|
||||
transport ||= nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST, port: Number(process.env.SMTP_PORT || 587), secure: Number(process.env.SMTP_PORT) === 465,
|
||||
auth: process.env.SMTP_USER ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS || "" } : undefined,
|
||||
});
|
||||
return transport;
|
||||
}
|
||||
const fromAddress = () => process.env.SMTP_FROM || process.env.SMTP_USER || "";
|
||||
export const appUrl = (p = "") => `${(process.env.PUBLIC_URL || (process.env.APP_DOMAIN ? `https://${process.env.APP_DOMAIN}` : "http://localhost:4310")).replace(/\/+$/, "")}${p}`;
|
||||
|
||||
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
||||
function html({ heading, lines, button, footer }) {
|
||||
return `<!doctype html><html><body style="margin:0;background:#f5f6f8;font-family:Inter,Segoe UI,Arial,sans-serif;color:#1f2024">
|
||||
<div style="max-width:560px;margin:0 auto;padding:28px 16px"><div style="background:#fff;border:1px solid #e4e7ec;border-radius:12px;padding:24px">
|
||||
<h1 style="font-size:19px;margin:0 0 12px;color:#242142">${esc(heading)}</h1>
|
||||
${lines.map((l) => `<p style="font-size:14.5px;line-height:1.55;margin:0 0 10px">${esc(l)}</p>`).join("")}
|
||||
${button ? `<p style="margin:18px 0 4px"><a href="${esc(button.url)}" style="display:inline-block;background:#242142;color:#fff;text-decoration:none;font-weight:600;padding:11px 18px;border-radius:8px">${esc(button.label)}</a></p>` : ""}
|
||||
</div><p style="font-size:12px;color:#6b6e78;text-align:center;margin-top:14px">${esc(footer || agencyName())}</p></div></body></html>`;
|
||||
}
|
||||
|
||||
/** Send one email. Returns { sent } or { sent:false, reason }. Never throws (notifications mustn't break work). */
|
||||
export async function sendEmail({ to, subject, heading, lines, button, footer, fromName, replyTo, attachments }) {
|
||||
const m = mailer();
|
||||
if (!m) return { sent: false, reason: "Email isn't set up (SMTP_HOST in .env)." };
|
||||
if (!to) return { sent: false, reason: "No recipient." };
|
||||
try {
|
||||
await m.sendMail({
|
||||
from: { name: fromName || agencyName(), address: fromAddress() }, to, subject, replyTo,
|
||||
text: [heading, "", ...lines, ...(button ? ["", `${button.label}: ${button.url}`] : [])].join("\n"),
|
||||
html: html({ heading, lines, button, footer }),
|
||||
attachments,
|
||||
});
|
||||
return { sent: true };
|
||||
} catch (e) {
|
||||
console.error("Email failed:", e.message);
|
||||
return { sent: false, reason: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- preferences ----------
|
||||
const DEFAULT_PREFS = { email: true, digest: true };
|
||||
export function prefsFor(userId) { return { ...DEFAULT_PREFS, ...(readJson(PREFS, {})[userId] || {}) }; }
|
||||
export function setPrefs(userId, patch) {
|
||||
const all = readJson(PREFS, {});
|
||||
all[userId] = { ...prefsFor(userId), ...Object.fromEntries(Object.entries(patch).filter(([k]) => k in DEFAULT_PREFS).map(([k, v]) => [k, Boolean(v)])) };
|
||||
writeJson(PREFS, all);
|
||||
return all[userId];
|
||||
}
|
||||
|
||||
// ---------- in-app list ----------
|
||||
const activeUsers = () => users.listUsers().filter((u) => u.active);
|
||||
function recipients(to) {
|
||||
const all = activeUsers();
|
||||
if (to === "admins") return all.filter((u) => u.role === "admin");
|
||||
if (to === "all") return all;
|
||||
const want = new Set((to || []).map((x) => String(x).toLowerCase()));
|
||||
return all.filter((u) => (want.has("admins") && u.role === "admin") || want.has(u.id) || want.has(String(u.email).toLowerCase()) || want.has(String(u.name || "").toLowerCase()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a notification for some team members and email those who want email.
|
||||
* to: "admins" | "all" | [user ids / emails / names]. level: info | ok | warn | bad.
|
||||
*/
|
||||
export async function notify({ type, title, body = "", link = "", client = null, to = "admins", level = "info", email = true }) {
|
||||
const people = recipients(to);
|
||||
if (!people.length) return;
|
||||
const n = { id: crypto.randomUUID().slice(0, 10), at: new Date().toISOString(), type, title, body, link, client, level, for: people.map((u) => u.id), readBy: [] };
|
||||
const list = readJson(FILE, []);
|
||||
list.push(n);
|
||||
writeJson(FILE, list.slice(-KEEP));
|
||||
if (!email || !emailConfigured()) return;
|
||||
for (const u of people) {
|
||||
if (!prefsFor(u.id).email || !u.email) continue;
|
||||
await sendEmail({ to: u.email, subject: title, heading: title, lines: body ? [body] : [], button: link ? { label: "Open in Quorla", url: appUrl(link) } : null, fromName: "Quorla" });
|
||||
}
|
||||
}
|
||||
|
||||
export function listFor(userId, { limit = 50 } = {}) {
|
||||
const mine = readJson(FILE, []).filter((n) => n.for.includes(userId)).reverse();
|
||||
return { unread: mine.filter((n) => !n.readBy.includes(userId)).length, items: mine.slice(0, limit).map(({ for: _f, readBy, ...n }) => ({ ...n, read: readBy.includes(userId) })) };
|
||||
}
|
||||
|
||||
export function markRead(userId, ids) {
|
||||
const list = readJson(FILE, []);
|
||||
for (const n of list) if (n.for.includes(userId) && (!ids || ids.includes(n.id)) && !n.readBy.includes(userId)) n.readBy.push(userId);
|
||||
writeJson(FILE, list);
|
||||
}
|
||||
39
dashboard/server/onboarding.js
Normal file
39
dashboard/server/onboarding.js
Normal file
@ -0,0 +1,39 @@
|
||||
// "Getting started" checklist for admins: each step is checked against the real state, not ticked by hand.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DATA_DIR } from "./config.js";
|
||||
import * as users from "./users.js";
|
||||
import * as clients from "./clients.js";
|
||||
import * as notify from "./notify.js";
|
||||
import * as meta from "../../connector/src/meta.js";
|
||||
import * as sched from "../../connector/src/schedule.js";
|
||||
import * as lib from "../../connector/src/library.js";
|
||||
import * as extra from "../../connector/src/platforms/index.js";
|
||||
|
||||
const FILE = path.join(DATA_DIR, "onboarding.json");
|
||||
const read = () => { try { return JSON.parse(fs.readFileSync(FILE, "utf8")); } catch { return {}; } };
|
||||
|
||||
export async function steps() {
|
||||
let metaOk = false;
|
||||
try { metaOk = (await meta.listAccounts()).length > 0; } catch { /* not connected */ }
|
||||
const list = clients.listClients();
|
||||
const posts = sched.listPosts({});
|
||||
const portal = (() => { try { return Object.keys(JSON.parse(fs.readFileSync(path.join(DATA_DIR, "portal.json"), "utf8"))).length; } catch { return 0; } })();
|
||||
const workspace = (() => { try { return JSON.parse(fs.readFileSync(path.join(DATA_DIR, "workspace.json"), "utf8")); } catch { return {}; } })();
|
||||
const items = [
|
||||
{ id: "workspace", title: "Name your workspace", text: "Your agency's name and logo appear in the app, on client approval pages and in reports.", done: Boolean(workspace.name), link: "/settings#workspace" },
|
||||
{ id: "meta", title: "Connect Facebook and Instagram", text: "Add the Meta system user token to the server's .env (see the hosting guide). Every client's Page and Instagram account then shows up here.", done: metaOk, link: "/settings" },
|
||||
{ id: "client", title: "Add your first client", text: "Pick their Facebook Page or Instagram account from the list.", done: list.length > 0, link: "/" },
|
||||
{ id: "team", title: "Invite your team", text: "Add designers, editors and managers. Each gets their own login.", done: users.listUsers().length > 1, link: "/settings" },
|
||||
{ id: "platforms", title: "Connect another platform", text: "TikTok, YouTube, Google Business Profile, LinkedIn and more, per client.", done: extra.vault.listConnections({}).length > 0, link: "/connections" },
|
||||
{ id: "email", title: "Set up email", text: "So people get notified and clients get their approval emails and monthly reports.", done: notify.emailConfigured(), link: "/admin/health" },
|
||||
{ id: "library", title: "Upload finished work", text: "Designers upload to the Content library; an admin reviews it.", done: lib.listItems({}).length > 0, link: "/library" },
|
||||
{ id: "post", title: "Schedule and approve a post", text: "Create a draft, approve it, and Quorla publishes at the client's local time.", done: posts.some((p) => ["approved", "published", "partial"].includes(p.status)), link: "/publishing" },
|
||||
{ id: "portal", title: "Give a client their approval link", text: "They approve posts or ask for changes without a login.", done: portal > 0, link: "/" },
|
||||
];
|
||||
return { dismissed: Boolean(read().dismissed), done: items.filter((s) => s.done).length, total: items.length, items };
|
||||
}
|
||||
|
||||
export function dismiss(value = true) {
|
||||
fs.writeFileSync(FILE, JSON.stringify({ ...read(), dismissed: Boolean(value), at: new Date().toISOString() }));
|
||||
}
|
||||
83
dashboard/server/portal.js
Normal file
83
dashboard/server/portal.js
Normal file
@ -0,0 +1,83 @@
|
||||
// Client approval portal: one private link per client (no login). The client sees posts the team sent for review,
|
||||
// approves them or asks for changes. An admin still approves in Quorla before anything publishes.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { DATA_DIR } from "./config.js";
|
||||
import { agencyName, logoPath as logoPathFn } from "./workspace.js";
|
||||
import * as clients from "./clients.js";
|
||||
import * as sched from "../../connector/src/schedule.js";
|
||||
|
||||
const FILE = path.join(DATA_DIR, "portal.json");
|
||||
const read = () => { try { return JSON.parse(fs.readFileSync(FILE, "utf8")); } catch { return {}; } };
|
||||
const write = (db) => { const tmp = FILE + ".tmp"; fs.writeFileSync(tmp, JSON.stringify(db, null, 1)); fs.renameSync(tmp, FILE); };
|
||||
const err = (msg, status = 400) => Object.assign(new Error(msg), { status });
|
||||
|
||||
/** Link settings for a client (admin view). */
|
||||
export function linkFor(slug) {
|
||||
clients.safeSlug(slug);
|
||||
const e = read()[slug];
|
||||
return e?.token ? { enabled: true, token: e.token, createdAt: e.createdAt, createdBy: e.createdBy, contactName: e.contactName || "", contactEmail: e.contactEmail || "" } : { enabled: false };
|
||||
}
|
||||
|
||||
/** Create or replace the client's link (replacing makes the old one stop working). */
|
||||
export function issue(slug, by, { contactName = "", contactEmail = "" } = {}) {
|
||||
clients.safeSlug(slug);
|
||||
const db = read();
|
||||
db[slug] = { token: crypto.randomBytes(24).toString("base64url"), createdAt: new Date().toISOString(), createdBy: by, contactName: String(contactName).slice(0, 80), contactEmail: String(contactEmail).slice(0, 120) };
|
||||
write(db);
|
||||
return linkFor(slug);
|
||||
}
|
||||
|
||||
export function updateContact(slug, { contactName, contactEmail }) {
|
||||
const db = read();
|
||||
if (!db[slug]) throw err("Create the client's link first.");
|
||||
if (contactName !== undefined) db[slug].contactName = String(contactName).slice(0, 80);
|
||||
if (contactEmail !== undefined) {
|
||||
const e = String(contactEmail).trim();
|
||||
if (e && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(e)) throw err("That email address doesn't look right.");
|
||||
db[slug].contactEmail = e.slice(0, 120);
|
||||
}
|
||||
write(db);
|
||||
return linkFor(slug);
|
||||
}
|
||||
|
||||
export function revoke(slug) {
|
||||
const db = read(); delete db[slug]; write(db);
|
||||
}
|
||||
|
||||
/** Which client a token belongs to (constant-time compare), or null. */
|
||||
export function clientForToken(token) {
|
||||
const t = Buffer.from(String(token || ""));
|
||||
for (const [slug, e] of Object.entries(read())) {
|
||||
const k = Buffer.from(e.token || "");
|
||||
if (k.length === t.length && crypto.timingSafeEqual(k, t)) return slug;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const LIST_DAYS = 30;
|
||||
/** What the client sees: posts waiting for them, and their recent answers. */
|
||||
export function view(slug) {
|
||||
const c = clients.getClient(slug);
|
||||
const since = Date.now() - LIST_DAYS * 86400000;
|
||||
const posts = sched.listPosts({ client: slug })
|
||||
.filter((p) => p.clientReview && (p.clientReview.status === "pending" || new Date(p.clientReview.decidedAt || p.clientReview.requestedAt).getTime() >= since))
|
||||
.filter((p) => p.status !== "cancelled")
|
||||
.map((p) => ({
|
||||
id: p.id, type: p.type, platforms: p.platforms, localTime: p.localTime, timezone: p.timezone, status: p.status,
|
||||
caption: p.caption || "", captions: Object.fromEntries(Object.entries(p.options || {}).filter(([, o]) => o?.caption).map(([k, o]) => [k, o.caption])),
|
||||
fbCaption: p.fbCaption || null, title: p.options?.youtube?.title || null,
|
||||
media: (p.media || []).map((m, i) => ({ url: `media/${p.id}/${i}`, video: /\.(mp4|mov|m4v|webm)$/i.test(m) })),
|
||||
review: { status: p.clientReview.status, note: p.clientReview.note || "", by: p.clientReview.by || null, decidedAt: p.clientReview.decidedAt || null },
|
||||
}));
|
||||
return { client: c?.name || slug, agency: agencyName(), logo: Boolean(logoPathFn()), posts };
|
||||
}
|
||||
|
||||
/** A media file of one of this client's posts that is in review (no other files are reachable). */
|
||||
export function mediaFile(slug, postId, index) {
|
||||
const p = sched.listPosts({ client: slug }).find((x) => x.id === postId && x.clientReview);
|
||||
const f = p?.media?.[Number(index)];
|
||||
if (!f || /^https?:/i.test(f) || !fs.existsSync(f)) return null;
|
||||
return f;
|
||||
}
|
||||
137
dashboard/server/portal/index.html
Normal file
137
dashboard/server/portal/index.html
Normal file
@ -0,0 +1,137 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<title>Posts for your approval</title>
|
||||
<style>
|
||||
:root { --ink:#1f2024; --ink2:#45474f; --muted:#6b6e78; --line:#e4e7ec; --canvas:#f5f6f8; --navy:#242142; --navy2:#3b3778;
|
||||
--ok:#2f7d4f; --okSoft:#e8f4ec; --warn:#a86a12; --warnSoft:#fbf1e1; --bad:#b4372b; --badSoft:#fbeae8; --bg:#fff; }
|
||||
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --ink:#ecedf2; --ink2:#c9cad3; --muted:#9a9cab; --line:#2c2d38; --canvas:#15161c; --navy:#c9c7f2; --navy2:#a9a5f0; --bg:#1c1d25; --okSoft:#17301f; --warnSoft:#33270f; --badSoft:#3a1714; --ok:#7fcb9c; --warn:#e6b25f; --bad:#ef8a7f; } }
|
||||
* { box-sizing: border-box; }
|
||||
[hidden] { display: none !important; }
|
||||
.sr-only { position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap; }
|
||||
body { margin:0; background:var(--canvas); color:var(--ink); font:15px/1.55 Inter, "Segoe UI", system-ui, sans-serif; -webkit-font-smoothing:antialiased; }
|
||||
main { max-width: 760px; margin: 0 auto; padding: 28px 16px 64px; }
|
||||
h1 { font-size: 24px; line-height:1.2; margin:0; letter-spacing:-.01em; }
|
||||
h2 { font-size: 13px; text-transform: uppercase; letter-spacing:.08em; color:var(--muted); margin: 28px 0 10px; }
|
||||
.sub { color: var(--muted); margin: 6px 0 0; }
|
||||
.card { background:var(--bg); border:1px solid var(--line); border-radius:12px; padding:16px; margin-bottom:14px; }
|
||||
.meta { display:flex; flex-wrap:wrap; gap:6px 10px; align-items:center; font-size:13px; color:var(--ink2); }
|
||||
.chip { font-size:12px; font-weight:600; padding:2px 9px; border-radius:999px; border:1px solid var(--line); color:var(--ink2); }
|
||||
.status { font-size:12px; font-weight:600; padding:3px 10px; border-radius:999px; }
|
||||
.s-pending { background:var(--warnSoft); color:var(--warn); } .s-approved { background:var(--okSoft); color:var(--ok); } .s-changes { background:var(--badSoft); color:var(--bad); }
|
||||
.media { display:flex; gap:8px; overflow-x:auto; margin:12px 0; scroll-snap-type:x mandatory; }
|
||||
.media img, .media video { height: 280px; max-width: 100%; border-radius:8px; background:#000; object-fit: contain; scroll-snap-align:start; flex-shrink:0; }
|
||||
.caption { white-space: pre-line; color: var(--ink); margin: 8px 0 0; }
|
||||
.pc { margin-top:10px; font-size:13.5px; } .pc b { color: var(--ink2); }
|
||||
.actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:14px; }
|
||||
button { font: 600 14px/1 inherit; font-family: inherit; min-height: 44px; padding: 0 16px; border-radius: 8px; border: 1px solid var(--line); background: var(--bg); color: var(--ink); cursor:pointer; }
|
||||
button.primary { background: #242142; color: #fff; border-color: #242142; }
|
||||
button:focus-visible, textarea:focus-visible, input:focus-visible { outline: 2px solid #fc6d45; outline-offset: 2px; }
|
||||
button[disabled] { opacity: .6; cursor: default; }
|
||||
textarea, input { width:100%; font: inherit; color: var(--ink); background: var(--bg); border:1px solid var(--line); border-radius:8px; padding:10px 12px; }
|
||||
textarea { min-height: 90px; margin-top: 10px; }
|
||||
label { font-size: 13px; color: var(--ink2); display:block; margin-bottom: 4px; }
|
||||
.note { margin-top:10px; font-size:13.5px; background:var(--canvas); border-radius:8px; padding:10px 12px; }
|
||||
.empty { text-align:center; color:var(--muted); padding: 28px 12px; }
|
||||
.err { color: var(--bad); font-size: 13.5px; margin-top: 8px; }
|
||||
.who { background:var(--bg); border:1px solid var(--line); border-radius:12px; padding:14px 16px; margin-top:18px; }
|
||||
footer { color: var(--muted); font-size: 12.5px; margin-top: 32px; text-align:center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<img id="logo" src="/brand/agency-logo" alt="" hidden style="height:36px;width:auto;max-width:200px;object-fit:contain;margin-bottom:14px;display:block">
|
||||
<h1 id="title">Posts for your approval</h1>
|
||||
<p class="sub" id="sub">Loading…</p>
|
||||
</header>
|
||||
<div class="who">
|
||||
<label for="name">Your name (shown to the team with your answers)</label>
|
||||
<input id="name" autocomplete="name" placeholder="e.g. Priya">
|
||||
</div>
|
||||
<h2 id="h-waiting" hidden>Waiting for you</h2>
|
||||
<section id="waiting" aria-live="polite"></section>
|
||||
<h2 id="h-done" hidden>Recently reviewed</h2>
|
||||
<section id="done"></section>
|
||||
<footer id="foot"></footer>
|
||||
</main>
|
||||
<script>
|
||||
(() => {
|
||||
const base = location.pathname.replace(/\/+$/, "");
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const PL = { instagram: "Instagram", facebook: "Facebook", tiktok: "TikTok", youtube: "YouTube", gbp: "Google Business", linkedin: "LinkedIn", pinterest: "Pinterest", threads: "Threads", reddit: "Reddit" };
|
||||
const LABEL = { pending: "Waiting for you", approved: "Approved", changes: "Changes requested" };
|
||||
try { $("name").value = localStorage.getItem("portal-name") || ""; } catch {}
|
||||
$("name").addEventListener("change", () => { try { localStorage.setItem("portal-name", $("name").value.trim()); } catch {} });
|
||||
|
||||
const el = (tag, attrs = {}, ...kids) => {
|
||||
const e = document.createElement(tag);
|
||||
for (const [k, v] of Object.entries(attrs)) if (v !== null && v !== undefined && v !== false) (k === "class" ? (e.className = v) : k.startsWith("on") ? e.addEventListener(k.slice(2), v) : e.setAttribute(k, v === true ? "" : v));
|
||||
for (const k of kids.flat()) if (k !== null && k !== undefined && k !== false) e.append(k instanceof Node ? k : document.createTextNode(String(k)));
|
||||
return e;
|
||||
};
|
||||
|
||||
function card(p) {
|
||||
const box = el("article", { class: "card" });
|
||||
const err = el("div", { class: "err", role: "alert" });
|
||||
box.append(el("div", { class: "meta" },
|
||||
el("strong", {}, p.localTime), el("span", {}, `(${p.timezone.split("/").pop().replace("_", " ")} time)`),
|
||||
...p.platforms.map((x) => el("span", { class: "chip" }, PL[x] || x)),
|
||||
el("span", { class: `status s-${p.review.status}` }, LABEL[p.review.status])));
|
||||
if (p.title) box.append(el("div", { class: "pc" }, el("b", {}, "YouTube title: "), p.title));
|
||||
if (p.media.length) box.append(el("div", { class: "media" }, p.media.map((m, i) => m.video
|
||||
? el("video", { src: `${base}/${m.url}`, controls: true, preload: "metadata", "aria-label": `Video ${i + 1}` })
|
||||
: el("img", { src: `${base}/${m.url}`, alt: `Image ${i + 1} of ${p.media.length}`, loading: "lazy" }))));
|
||||
box.append(el("p", { class: "caption" }, p.caption || "(no caption)"));
|
||||
for (const [pl, cap] of Object.entries(p.captions || {})) if (cap && cap !== p.caption) box.append(el("div", { class: "pc" }, el("b", {}, `${PL[pl] || pl} version: `), cap));
|
||||
if (p.fbCaption && p.fbCaption !== p.caption) box.append(el("div", { class: "pc" }, el("b", {}, "Facebook version: "), p.fbCaption));
|
||||
if (p.review.status !== "pending") {
|
||||
box.append(el("div", { class: "note" }, `${LABEL[p.review.status]} by ${p.review.by || "you"}${p.review.decidedAt ? " on " + new Date(p.review.decidedAt).toLocaleDateString(undefined, { month: "short", day: "numeric" }) : ""}`, p.review.note ? el("div", {}, `“${p.review.note}”`) : null));
|
||||
return box;
|
||||
}
|
||||
const noteId = `n-${p.id}`;
|
||||
const note = el("textarea", { id: noteId, hidden: true, placeholder: "What should we change? Be as specific as you like." });
|
||||
const send = async (decision) => {
|
||||
err.textContent = "";
|
||||
if (decision === "changes" && note.hidden) { note.hidden = false; note.focus(); changes.textContent = "Send changes"; return; }
|
||||
for (const b of box.querySelectorAll("button")) b.disabled = true;
|
||||
try {
|
||||
const r = await fetch(`${base}/decision/${p.id}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ decision, note: note.value, name: $("name").value.trim() }) });
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(j.error || "Couldn't save your answer. Try again.");
|
||||
load();
|
||||
} catch (e) { err.textContent = e.message; for (const b of box.querySelectorAll("button")) b.disabled = false; }
|
||||
};
|
||||
const changes = el("button", { type: "button", onclick: () => send("changes") }, "Request changes");
|
||||
box.append(el("label", { for: noteId, class: "sr-only" }, "What should we change?"), note,
|
||||
el("div", { class: "actions" }, el("button", { type: "button", class: "primary", onclick: () => send("approved") }, "Approve"), changes), err);
|
||||
return box;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const r = await fetch(`${base}/data`, { cache: "no-store" });
|
||||
if (!r.ok) throw new Error(r.status === 404 ? "This link isn't active any more. Ask your agency for a new one." : "Couldn't load your posts. Refresh to try again.");
|
||||
const d = await r.json();
|
||||
document.title = `${d.client} · Posts for your approval`;
|
||||
$("title").textContent = d.client;
|
||||
$("sub").textContent = "Posts your social media team prepared. Approve them, or tell us what to change.";
|
||||
$("foot").textContent = `Prepared by ${d.agency}. Nothing is posted until your team gives the final go-ahead.`;
|
||||
if (d.logo) { $("logo").alt = d.agency; $("logo").hidden = false; }
|
||||
const waiting = d.posts.filter((p) => p.review.status === "pending");
|
||||
const done = d.posts.filter((p) => p.review.status !== "pending");
|
||||
$("waiting").replaceChildren(...(waiting.length ? waiting.map(card) : [el("div", { class: "card empty" }, "You're all caught up. Nothing is waiting for your approval.")]));
|
||||
$("h-waiting").hidden = false;
|
||||
$("h-done").hidden = !done.length;
|
||||
$("done").replaceChildren(...done.map(card));
|
||||
} catch (e) { $("sub").textContent = e.message; }
|
||||
}
|
||||
load();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
154
dashboard/server/published.js
Normal file
154
dashboard/server/published.js
Normal file
@ -0,0 +1,154 @@
|
||||
// "Published" feed: every post that went out on a client's accounts, with its numbers.
|
||||
// Instagram and Facebook come from the nightly snapshots (so posts made outside Quorla show too);
|
||||
// other platforms come from what Quorla published, with YouTube counts fetched on refresh.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DATA_DIR } from "./config.js";
|
||||
import * as clients from "./clients.js";
|
||||
import * as metrics from "./metrics.js";
|
||||
import * as sched from "../../connector/src/schedule.js";
|
||||
import * as extra from "../../connector/src/platforms/index.js";
|
||||
|
||||
const STATS_FILE = path.join(DATA_DIR, "post-stats.json");
|
||||
const readStats = () => { try { return JSON.parse(fs.readFileSync(STATS_FILE, "utf8")); } catch { return { fetchedAt: null, posts: {} }; } };
|
||||
|
||||
const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
|
||||
const sum = (...vs) => { const xs = vs.map(num).filter((x) => x !== null); return xs.length ? xs.reduce((a, b) => a + b, 0) : null; };
|
||||
|
||||
function igItem(client, p, viaQuorla) {
|
||||
const i = p.insights || {};
|
||||
const likes = num(p.like_count), comments = num(p.comments_count), saves = num(i.saved), shares = num(i.shares);
|
||||
return {
|
||||
key: `instagram:${p.id}`, client: client.slug, clientName: client.name, platform: "instagram",
|
||||
id: p.id, at: p.timestamp, caption: p.caption || "", permalink: p.permalink || null,
|
||||
format: p.media_product_type === "REELS" ? "Reel" : p.media_type === "CAROUSEL_ALBUM" ? "Carousel" : p.media_type === "VIDEO" ? "Video" : "Image",
|
||||
thumb: p.thumbnail_url || p.media_url || null,
|
||||
metrics: { reach: num(i.reach), views: num(i.views), likes, comments, saves, shares },
|
||||
interactions: num(i.total_interactions) ?? sum(likes, comments, saves, shares),
|
||||
viaQuorla,
|
||||
};
|
||||
}
|
||||
|
||||
function fbItem(client, p, viaQuorla) {
|
||||
return {
|
||||
key: `facebook:${p.id}`, client: client.slug, clientName: client.name, platform: "facebook",
|
||||
id: p.id, at: p.created_time, caption: p.message || "", permalink: p.permalink_url || null, format: "Post",
|
||||
thumb: p.picture || null,
|
||||
metrics: { reach: null, views: null, likes: num(p.reactions), comments: num(p.comments), saves: null, shares: num(p.shares) },
|
||||
interactions: sum(p.reactions, p.comments, p.shares),
|
||||
viaQuorla,
|
||||
};
|
||||
}
|
||||
|
||||
/** All published posts, newest first. Filters: client slug, platform id, days back. */
|
||||
export function feed({ client, platform, days = 30 } = {}) {
|
||||
const since = Date.now() - Number(days || 30) * 86400000;
|
||||
const all = clients.listClients().filter((c) => !client || c.slug === client);
|
||||
const quorla = sched.listPosts({}).filter((p) => ["published", "partial"].includes(p.status));
|
||||
const quorlaIds = new Set(quorla.flatMap((p) => Object.values(p.results || {}).map((r) => r?.id).filter(Boolean).map(String)));
|
||||
const stats = readStats().posts;
|
||||
const items = [];
|
||||
|
||||
for (const c of all) {
|
||||
const snap = metrics.readSnapshot(c.slug);
|
||||
for (const p of snap?.ig?.posts || []) items.push(igItem(c, p, quorlaIds.has(String(p.id))));
|
||||
for (const p of snap?.fb?.posts || []) items.push(fbItem(c, p, quorlaIds.has(String(p.id))));
|
||||
}
|
||||
const seen = new Set(items.map((i) => i.key));
|
||||
const byClient = Object.fromEntries(all.map((c) => [c.slug, c]));
|
||||
|
||||
// Platforms the snapshots don't cover, plus Quorla posts newer than the last snapshot.
|
||||
for (const p of quorla) {
|
||||
const c = byClient[p.client];
|
||||
if (!c) continue;
|
||||
for (const [pl, r] of Object.entries(p.results || {})) {
|
||||
if (!r?.id || r.cancelled) continue;
|
||||
const key = `${pl}:${r.id}`;
|
||||
if (seen.has(key)) continue;
|
||||
const s = stats[key] || {};
|
||||
items.push({
|
||||
key, client: c.slug, clientName: c.name, platform: pl, id: String(r.id),
|
||||
at: p.publishedAt || p.publishAt, caption: p.options?.[pl]?.caption ?? p.caption ?? "", permalink: r.permalink || null,
|
||||
format: p.type === "REELS" ? "Video" : p.type === "CAROUSEL" ? "Carousel" : p.type === "TEXT" ? "Text" : "Image",
|
||||
thumb: s.thumb || null, media: p.media?.[0] || null,
|
||||
metrics: { reach: null, views: num(s.views), likes: num(s.likes), comments: num(s.comments), saves: null, shares: null },
|
||||
interactions: sum(s.likes, s.comments),
|
||||
viaQuorla: true, note: r.inbox ? "Sent to the TikTok app to finish" : null,
|
||||
});
|
||||
seen.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
const list = items
|
||||
.filter((i) => (!platform || i.platform === platform) && i.at && new Date(i.at).getTime() >= since)
|
||||
.sort((a, b) => new Date(b.at) - new Date(a.at));
|
||||
const reachKnown = list.filter((i) => i.metrics.reach !== null);
|
||||
return {
|
||||
items: list,
|
||||
totals: {
|
||||
posts: list.length,
|
||||
viaQuorla: list.filter((i) => i.viaQuorla).length,
|
||||
reach: reachKnown.reduce((s, i) => s + i.metrics.reach, 0),
|
||||
interactions: list.reduce((s, i) => s + (i.interactions || 0), 0),
|
||||
avgInteractions: list.length ? Math.round(list.reduce((s, i) => s + (i.interactions || 0), 0) / list.length) : 0,
|
||||
},
|
||||
snapshotsAt: Object.fromEntries(all.map((c) => [c.slug, metrics.readSnapshot(c.slug)?.fetchedAt || null])),
|
||||
statsAt: readStats().fetchedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Fetch counts for Quorla-published posts on platforms with a stats reader (YouTube), last 90 days. */
|
||||
export async function refreshStats() {
|
||||
const since = Date.now() - 90 * 86400000;
|
||||
const want = {};
|
||||
for (const p of sched.listPosts({})) {
|
||||
if (!["published", "partial"].includes(p.status) || new Date(p.publishedAt || p.publishAt).getTime() < since) continue;
|
||||
for (const [pl, r] of Object.entries(p.results || {})) {
|
||||
if (pl !== "youtube" || !r?.id) continue;
|
||||
((want[p.client] ||= {})[pl] ||= []).push(String(r.id));
|
||||
}
|
||||
}
|
||||
const out = readStats();
|
||||
const errors = [];
|
||||
for (const [client, byPl] of Object.entries(want)) {
|
||||
for (const [pl, ids] of Object.entries(byPl)) {
|
||||
try {
|
||||
const s = await extra.postStats(pl, client, ids);
|
||||
for (const [id, v] of Object.entries(s)) out.posts[`${pl}:${id}`] = v;
|
||||
} catch (e) { errors.push(`${client} ${pl}: ${e.message}`); }
|
||||
}
|
||||
}
|
||||
out.fetchedAt = new Date().toISOString();
|
||||
fs.writeFileSync(STATS_FILE, JSON.stringify(out, null, 1));
|
||||
return { fetchedAt: out.fetchedAt, errors };
|
||||
}
|
||||
|
||||
const WINDOWS = [[6, 9], [9, 12], [12, 15], [15, 18], [18, 21], [21, 24]];
|
||||
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
const median = (xs) => { const s = [...xs].sort((a, b) => a - b); const m = s.length >> 1; return s.length ? (s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2) : null; };
|
||||
|
||||
/**
|
||||
* When this client's Instagram posts do best, from their own last 120 days: each post's reach divided by the
|
||||
* client's median reach, grouped by weekday and 3-hour window (client's local time). Needs 8+ posts with reach.
|
||||
*/
|
||||
export function bestTimes(slug, timezone) {
|
||||
const posts = feed({ client: slug, platform: "instagram", days: 120 }).items.filter((i) => i.metrics.reach != null && i.metrics.reach > 0);
|
||||
if (posts.length < 8) return { basedOn: posts.length, slots: [], times: [] };
|
||||
const base = median(posts.map((p) => p.metrics.reach));
|
||||
const fmt = new Intl.DateTimeFormat("en-CA", { timeZone: timezone, weekday: "short", hour: "2-digit", minute: "2-digit", hour12: false });
|
||||
const groups = {};
|
||||
for (const p of posts) {
|
||||
const parts = Object.fromEntries(fmt.formatToParts(new Date(p.at)).map((x) => [x.type, x.value]));
|
||||
const hour = Number(parts.hour) % 24, minute = Number(parts.minute);
|
||||
const w = WINDOWS.findIndex(([a, b]) => hour >= a && hour < b);
|
||||
if (w < 0) continue;
|
||||
const day = WEEKDAYS.indexOf(parts.weekday.slice(0, 3));
|
||||
(groups[`${day}-${w}`] ||= { day, w, ratios: [], minutes: [] }).ratios.push(p.metrics.reach / base);
|
||||
groups[`${day}-${w}`].minutes.push(hour * 60 + minute);
|
||||
}
|
||||
const hhmm = (m) => `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(Math.round(m % 60 / 15) * 15 % 60).padStart(2, "0")}`;
|
||||
const slots = Object.values(groups).filter((g) => g.ratios.length >= 2)
|
||||
.map((g) => ({ day: WEEKDAYS[g.day], window: `${String(WINDOWS[g.w][0]).padStart(2, "0")}:00–${String(WINDOWS[g.w][1] % 24).padStart(2, "0")}:00`, time: hhmm(median(g.minutes)), posts: g.ratios.length, lift: Math.round((median(g.ratios) - 1) * 100) }))
|
||||
.filter((s) => s.lift > 0).sort((a, b) => b.lift - a.lift).slice(0, 3);
|
||||
return { basedOn: posts.length, medianReach: base, slots, times: [...new Set(slots.map((s) => s.time))] };
|
||||
}
|
||||
53
dashboard/server/workspace.js
Normal file
53
dashboard/server/workspace.js
Normal file
@ -0,0 +1,53 @@
|
||||
// Workspace (white-label): the agency's name, logo, brand colour and support email.
|
||||
// Used in the app (sidebar, sign-in), client approval pages, emails and the PDF reports.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DATA_DIR, AGENCY_NAME } from "./config.js";
|
||||
|
||||
const FILE = path.join(DATA_DIR, "workspace.json");
|
||||
const LOGO = path.join(DATA_DIR, "workspace-logo");
|
||||
const HEX = /^#[0-9a-f]{6}$/i;
|
||||
const err = (msg, status = 400) => Object.assign(new Error(msg), { status });
|
||||
const read = () => { try { return JSON.parse(fs.readFileSync(FILE, "utf8")); } catch { return {}; } };
|
||||
|
||||
export function get() {
|
||||
const w = read();
|
||||
return { name: w.name || "", accent: w.accent || "", supportEmail: w.supportEmail || "", website: w.website || "", hasLogo: Boolean(logoPath()), updatedAt: w.updatedAt || null };
|
||||
}
|
||||
|
||||
/** The name shown to people: the workspace name, else AGENCY_NAME from .env. */
|
||||
export const agencyName = () => read().name || AGENCY_NAME;
|
||||
|
||||
export function save(patch, by) {
|
||||
const cur = read();
|
||||
const next = { ...cur };
|
||||
if (patch.name !== undefined) next.name = String(patch.name).trim().slice(0, 60);
|
||||
if (patch.accent !== undefined) { if (patch.accent && !HEX.test(patch.accent)) throw err("Brand colour must be like #242142."); next.accent = patch.accent || ""; }
|
||||
if (patch.supportEmail !== undefined) { const e = String(patch.supportEmail).trim(); if (e && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(e)) throw err("That email doesn't look right."); next.supportEmail = e; }
|
||||
if (patch.website !== undefined) { const u = String(patch.website).trim(); if (u && !/^https?:\/\//i.test(u)) throw err("Website must start with https://"); next.website = u; }
|
||||
next.updatedAt = new Date().toISOString(); next.updatedBy = by;
|
||||
fs.writeFileSync(FILE, JSON.stringify(next, null, 1));
|
||||
return get();
|
||||
}
|
||||
|
||||
export function logoPath() {
|
||||
for (const ext of [".png", ".jpg"]) if (fs.existsSync(LOGO + ext)) return LOGO + ext;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Save an uploaded logo (PNG or JPG, up to 2 MB). */
|
||||
export function saveLogo(buf, filename) {
|
||||
if (!Buffer.isBuffer(buf) || !buf.length) throw err("Choose an image.");
|
||||
if (buf.length > 2 * 1024 * 1024) throw err("Logo must be 2 MB or smaller.");
|
||||
const png = buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
|
||||
const jpg = buf[0] === 0xff && buf[1] === 0xd8;
|
||||
if (!png && !jpg) throw err("Logo must be a PNG or JPG.");
|
||||
removeLogo();
|
||||
fs.writeFileSync(LOGO + (png ? ".png" : ".jpg"), buf);
|
||||
return get();
|
||||
}
|
||||
|
||||
export function removeLogo() { for (const ext of [".png", ".jpg"]) fs.rmSync(LOGO + ext, { force: true }); }
|
||||
|
||||
/** Options for tools/report-pdf toPdf(): the agency's logo when one is set. */
|
||||
export const reportOptions = () => (logoPath() ? { logoPath: logoPath(), logoAlt: agencyName() } : {});
|
||||
@ -1,6 +1,6 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||
import { BrowserRouter, NavLink, Navigate, Route, Routes } from "react-router-dom";
|
||||
import { LayoutGrid, Users2, Sparkles, Settings2, LogOut, CalendarClock, MessageCircle, FolderUp, Plug } from "lucide-react";
|
||||
import { LayoutGrid, Users2, Sparkles, Settings2, LogOut, CalendarClock, MessageCircle, FolderUp, Plug, BarChart3, CalendarDays, Swords, Activity, History, Building2 } from "lucide-react";
|
||||
import { api } from "./api.js";
|
||||
import { cx, Spinner } from "./ui.jsx";
|
||||
import Login from "./pages/Login.jsx";
|
||||
@ -10,6 +10,11 @@ import Team from "./pages/Team.jsx";
|
||||
import Runs from "./pages/Runs.jsx";
|
||||
import Settings from "./pages/Settings.jsx";
|
||||
import Publishing from "./pages/Publishing.jsx";
|
||||
import Published from "./pages/Published.jsx";
|
||||
import Calendar from "./pages/Calendar.jsx";
|
||||
import Competitors from "./pages/Competitors.jsx";
|
||||
import NotificationBell from "./Notifications.jsx";
|
||||
import { Agencies, Audit, Health } from "./pages/Admin.jsx";
|
||||
import Inbox from "./pages/Inbox.jsx";
|
||||
import Library from "./pages/Library.jsx";
|
||||
import Connections from "./pages/Connections.jsx";
|
||||
@ -17,28 +22,50 @@ import Connections from "./pages/Connections.jsx";
|
||||
const AuthCtx = createContext(null);
|
||||
export const useAuth = () => useContext(AuthCtx);
|
||||
|
||||
/** White-label: the agency's brand colour replaces Quorla's navy for buttons, links and highlights. */
|
||||
function applyAccent(hex) {
|
||||
const root = document.documentElement.style;
|
||||
if (/^#[0-9a-f]{6}$/i.test(hex || "")) {
|
||||
root.setProperty("--color-brand", hex);
|
||||
root.setProperty("--color-brand-deep", `color-mix(in srgb, ${hex} 78%, black)`);
|
||||
root.setProperty("--color-brand-soft", `color-mix(in srgb, ${hex} 10%, white)`);
|
||||
} else for (const k of ["--color-brand", "--color-brand-deep", "--color-brand-soft"]) root.removeProperty(k);
|
||||
}
|
||||
|
||||
const NAV = [
|
||||
{ to: "/", label: "Clients", icon: LayoutGrid, end: true },
|
||||
{ to: "/calendar", label: "Content calendar", icon: CalendarDays },
|
||||
{ to: "/library", label: "Content library", icon: FolderUp },
|
||||
{ to: "/publishing", label: "Publishing", icon: CalendarClock },
|
||||
{ to: "/published", label: "Published", icon: BarChart3 },
|
||||
{ to: "/competitors", label: "Competitors", icon: Swords },
|
||||
{ to: "/inbox", label: "Inbox", icon: MessageCircle },
|
||||
{ to: "/connections", label: "Connections", icon: Plug },
|
||||
{ to: "/connections", label: "Connections", icon: Plug, group: "Setup" },
|
||||
{ to: "/team", label: "Team", icon: Users2 },
|
||||
{ to: "/runs", label: "AI runs", icon: Sparkles },
|
||||
{ to: "/settings", label: "Settings", icon: Settings2 },
|
||||
{ to: "/admin/health", label: "System health", icon: Activity, admin: true, group: "Admin" },
|
||||
{ to: "/admin/audit", label: "Audit log", icon: History, admin: true },
|
||||
{ to: "/admin/agencies", label: "Agencies", icon: Building2, admin: true, control: true },
|
||||
{ to: "/settings", label: "Settings", icon: Settings2, group: "" },
|
||||
];
|
||||
|
||||
function Shell({ children }) {
|
||||
const { user, signOut, agency } = useAuth();
|
||||
const { user, signOut, agency, workspace, control } = useAuth();
|
||||
return (
|
||||
<div className="min-h-screen flex">
|
||||
<aside className="w-[228px] shrink-0 bg-white border-r border-line flex flex-col fixed inset-y-0 left-0 z-10">
|
||||
<div className="px-4 pt-4 pb-3">
|
||||
<img src="/brand/logo.png" alt="Quorla" className="h-[30px] w-auto" />
|
||||
{agency && <div className="mt-2.5 text-[11px] font-semibold tracking-[0.08em] uppercase text-faint truncate" title={agency}>{agency}</div>}
|
||||
<div className="flex items-center justify-between">
|
||||
{workspace?.hasLogo ? <img src="/brand/agency-logo" alt={agency} className="h-[30px] w-auto max-w-[150px] object-contain" /> : <img src="/brand/logo.png" alt="Quorla" className="h-[30px] w-auto" />}
|
||||
<NotificationBell />
|
||||
</div>
|
||||
<nav className="px-2 mt-2 space-y-0.5 flex-1">
|
||||
{NAV.map(({ to, label, icon: Icon, end }) => (
|
||||
{workspace?.hasLogo
|
||||
? <div className="mt-2 text-[10.5px] text-faint">Powered by Quorla</div>
|
||||
: agency && <div className="mt-2.5 text-[11px] font-semibold tracking-[0.08em] uppercase text-faint truncate" title={agency}>{agency}</div>}
|
||||
</div>
|
||||
<nav className="px-2 mt-2 space-y-0.5 flex-1 overflow-y-auto" aria-label="Main">
|
||||
{NAV.filter((n) => (!n.admin || user.role === "admin") && (!n.control || control)).map(({ to, label, icon: Icon, end, group }) => [
|
||||
group !== undefined && (group ? <div key={`g-${group}`} className="eyebrow px-2.5 pt-4 pb-1 text-faint">{group}</div> : <div key="g-sep" className="pt-3" />),
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
@ -52,8 +79,8 @@ function Shell({ children }) {
|
||||
>
|
||||
<Icon className="size-4" strokeWidth={2} />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</NavLink>,
|
||||
])}
|
||||
</nav>
|
||||
<div className="border-t border-line px-3 py-3 flex items-center gap-2.5">
|
||||
<div className="size-8 rounded-full bg-brand-soft text-brand-deep grid place-items-center font-semibold text-[12px]">
|
||||
@ -86,7 +113,8 @@ export default function App() {
|
||||
const refresh = useCallback(() => {
|
||||
api("/api/auth/me")
|
||||
.then((r) => {
|
||||
setState({ loading: false, user: r.user, setupNeeded: r.setupNeeded, agency: r.agency });
|
||||
setState({ loading: false, user: r.user, setupNeeded: r.setupNeeded, agency: r.agency, workspace: r.workspace, control: r.control });
|
||||
applyAccent(r.workspace?.accent);
|
||||
document.title = r.agency ? `Quorla · ${r.agency}` : "Quorla";
|
||||
})
|
||||
.catch(() => setState({ loading: false, user: null }));
|
||||
@ -105,10 +133,10 @@ export default function App() {
|
||||
};
|
||||
|
||||
if (state.loading) return <Spinner />;
|
||||
if (!state.user) return <Login setupNeeded={state.setupNeeded} agency={state.agency} onSignedIn={refresh} />;
|
||||
if (!state.user) return <Login setupNeeded={state.setupNeeded} agency={state.agency} hasLogo={state.workspace?.hasLogo} onSignedIn={refresh} />;
|
||||
|
||||
return (
|
||||
<AuthCtx.Provider value={{ user: state.user, agency: state.agency, signOut, refresh, isAdmin: state.user.role === "admin" }}>
|
||||
<AuthCtx.Provider value={{ user: state.user, agency: state.agency, workspace: state.workspace, control: state.control, signOut, refresh, isAdmin: state.user.role === "admin" }}>
|
||||
<BrowserRouter>
|
||||
<Shell>
|
||||
<Routes>
|
||||
@ -116,6 +144,12 @@ export default function App() {
|
||||
<Route path="/clients/:slug" element={<Client />} />
|
||||
<Route path="/library" element={<Library />} />
|
||||
<Route path="/publishing" element={<Publishing />} />
|
||||
<Route path="/published" element={<Published />} />
|
||||
<Route path="/calendar" element={<Calendar />} />
|
||||
<Route path="/competitors" element={<Competitors />} />
|
||||
<Route path="/admin/health" element={<Health />} />
|
||||
<Route path="/admin/audit" element={<Audit />} />
|
||||
<Route path="/admin/agencies" element={<Agencies />} />
|
||||
<Route path="/inbox" element={<Inbox />} />
|
||||
<Route path="/connections" element={<Connections />} />
|
||||
<Route path="/team" element={<Team />} />
|
||||
|
||||
39
dashboard/web/src/GettingStarted.jsx
Normal file
39
dashboard/web/src/GettingStarted.jsx
Normal file
@ -0,0 +1,39 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { CircleCheck, Circle, X } from "lucide-react";
|
||||
import { api } from "./api.js";
|
||||
import { useAuth } from "./App.jsx";
|
||||
import { cx } from "./ui.jsx";
|
||||
|
||||
/** Admin checklist on the Clients page until every step is done or it's dismissed. */
|
||||
export default function GettingStarted() {
|
||||
const { isAdmin } = useAuth();
|
||||
const [d, setD] = useState(null);
|
||||
useEffect(() => { if (isAdmin) api("/api/onboarding").then(setD).catch(() => {}); }, [isAdmin]);
|
||||
if (!d || d.dismissed || d.done === d.total) return null;
|
||||
const next = d.items.find((s) => !s.done);
|
||||
return (
|
||||
<section className="panel p-4" aria-labelledby="gs-title">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 id="gs-title" className="text-[15px] font-bold">Getting started</h2>
|
||||
<p className="text-[12.5px] text-muted">{d.done} of {d.total} done. Next: <b className="text-ink-2">{next.title}</b>.</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => api("/api/onboarding/dismiss", { method: "POST", body: { dismissed: true } }).then(setD)} className="p-1.5 rounded hover:bg-canvas text-muted" aria-label="Hide the getting started list"><X className="size-4" /></button>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-canvas mt-3 overflow-hidden" aria-hidden="true"><div className="h-full bg-accent" style={{ width: `${(d.done / d.total) * 100}%` }} /></div>
|
||||
<ol className="mt-3 grid sm:grid-cols-2 lg:grid-cols-3 gap-x-5 gap-y-2.5">
|
||||
{d.items.map((s) => (
|
||||
<li key={s.id} className="flex gap-2.5 items-start">
|
||||
{s.done ? <CircleCheck className="size-[18px] text-ok shrink-0 mt-0.5" aria-label="Done" /> : <Circle className="size-[18px] text-faint shrink-0 mt-0.5" aria-label="To do" />}
|
||||
<div className="min-w-0">
|
||||
{s.done ? <span className="text-[13px] text-muted line-through">{s.title}</span>
|
||||
: <Link to={s.link} className={cx("text-[13px] font-medium hover:underline", s.id === next.id ? "text-brand-deep" : "text-ink")}>{s.title}</Link>}
|
||||
{!s.done && <p className="text-[12px] text-muted leading-snug">{s.text}</p>}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
60
dashboard/web/src/Notifications.jsx
Normal file
60
dashboard/web/src/Notifications.jsx
Normal file
@ -0,0 +1,60 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Bell, CheckCheck } from "lucide-react";
|
||||
import { api } from "./api.js";
|
||||
import { cx, timeAgo } from "./ui.jsx";
|
||||
|
||||
const DOT = { ok: "bg-ok", warn: "bg-warn", bad: "bg-bad", info: "bg-brand" };
|
||||
|
||||
/** Sidebar bell: unread count, a list of recent notifications, mark-as-read. Polls every 60 s. */
|
||||
export default function NotificationBell() {
|
||||
const nav = useNavigate();
|
||||
const [data, setData] = useState({ unread: 0, items: [] });
|
||||
const [open, setOpen] = useState(false);
|
||||
const box = useRef(null);
|
||||
const load = useCallback(() => api("/api/notifications").then(setData).catch(() => {}), []);
|
||||
useEffect(() => { load(); const t = setInterval(load, 60000); return () => clearInterval(t); }, [load]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const close = (e) => { if (!box.current?.contains(e.target)) setOpen(false); };
|
||||
const esc = (e) => e.key === "Escape" && setOpen(false);
|
||||
document.addEventListener("mousedown", close); document.addEventListener("keydown", esc);
|
||||
return () => { document.removeEventListener("mousedown", close); document.removeEventListener("keydown", esc); };
|
||||
}, [open]);
|
||||
|
||||
const read = (ids) => api("/api/notifications/read", { method: "POST", body: { ids } }).then(setData).catch(() => {});
|
||||
const go = (n) => { if (!n.read) read([n.id]); setOpen(false); if (n.link) nav(n.link); };
|
||||
|
||||
return (
|
||||
<div className="relative" ref={box}>
|
||||
<button type="button" onClick={() => setOpen((o) => !o)} aria-expanded={open} aria-label={`Notifications${data.unread ? `, ${data.unread} unread` : ""}`}
|
||||
className="relative p-1.5 rounded-md hover:bg-canvas text-ink-2">
|
||||
<Bell className="size-[18px]" />
|
||||
{data.unread > 0 && <span className="absolute -top-0.5 -right-0.5 min-w-4 h-4 px-1 rounded-full bg-accent text-[10px] font-bold text-white grid place-items-center num">{data.unread > 99 ? "99+" : data.unread}</span>}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute left-0 top-9 z-30 w-[340px] panel shadow-xl overflow-hidden" role="dialog" aria-label="Notifications">
|
||||
<div className="flex items-center justify-between px-3.5 py-2.5 border-b border-line">
|
||||
<span className="text-[13px] font-semibold">Notifications</span>
|
||||
{data.unread > 0 && <button type="button" onClick={() => read(null)} className="text-[12px] text-brand-deep hover:underline inline-flex items-center gap-1"><CheckCheck className="size-3.5" />Mark all read</button>}
|
||||
</div>
|
||||
<ul className="max-h-[420px] overflow-y-auto divide-y divide-line">
|
||||
{!data.items.length && <li className="px-3.5 py-6 text-center text-[13px] text-muted">Nothing yet. You'll see approvals, client answers and failed posts here.</li>}
|
||||
{data.items.map((n) => (
|
||||
<li key={n.id}>
|
||||
<button type="button" onClick={() => go(n)} className={cx("w-full text-left px-3.5 py-2.5 flex gap-2.5 hover:bg-canvas", !n.read && "bg-brand-soft/40")}>
|
||||
<span className={cx("mt-1.5 size-2 rounded-full shrink-0", n.read ? "bg-line-strong" : DOT[n.level] || "bg-brand")} aria-hidden="true" />
|
||||
<span className="min-w-0">
|
||||
<span className={cx("block text-[13px]", !n.read && "font-semibold")}>{n.title}</span>
|
||||
{n.body && <span className="block text-[12px] text-muted line-clamp-2">{n.body}</span>}
|
||||
<span className="block text-[11px] text-faint mt-0.5">{timeAgo(n.at)}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
170
dashboard/web/src/pages/Admin.jsx
Normal file
170
dashboard/web/src/pages/Admin.jsx
Normal file
@ -0,0 +1,170 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { CircleAlert, CircleCheck, History, RefreshCw, TriangleAlert } from "lucide-react";
|
||||
import { api } from "../api.js";
|
||||
import { Badge, Button, cx, Empty, ErrorNote, fmtDateTime, Panel, Spinner, timeAgo } from "../ui.jsx";
|
||||
|
||||
const ICON = { ok: CircleCheck, warn: TriangleAlert, bad: CircleAlert };
|
||||
const TONE = { ok: "text-ok", warn: "text-warn", bad: "text-bad" };
|
||||
|
||||
/** Admin → System health: everything that runs in the background, in one list. */
|
||||
export function Health() {
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const load = useCallback(() => { setBusy(true); return api("/api/admin/health").then(setData).catch(setError).finally(() => setBusy(false)); }, []);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
const counts = data ? { bad: data.checks.filter((c) => c.status === "bad").length, warn: data.checks.filter((c) => c.status === "warn").length } : null;
|
||||
return (
|
||||
<div className="space-y-5 max-w-[960px]">
|
||||
<header className="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-[22px] font-extrabold">System health</h1>
|
||||
<p className="text-muted mt-0.5">{!data ? "Checking…" : counts.bad ? `${counts.bad} problem${counts.bad === 1 ? "" : "s"} to fix` : counts.warn ? `All running, ${counts.warn} thing${counts.warn === 1 ? "" : "s"} to look at` : "Everything is running"}{data ? ` · checked ${timeAgo(data.at)}` : ""}</p>
|
||||
</div>
|
||||
<Button icon={RefreshCw} loading={busy} onClick={load}>Check again</Button>
|
||||
</header>
|
||||
<ErrorNote error={error} />
|
||||
{!data ? <Spinner /> : (
|
||||
<Panel bodyClass="divide-y divide-line">
|
||||
{data.checks.map((c, i) => {
|
||||
const Icon = ICON[c.status];
|
||||
return (
|
||||
<div key={i} className="px-4 py-3 flex gap-3 items-start">
|
||||
<Icon className={cx("size-[18px] mt-0.5 shrink-0", TONE[c.status])} aria-label={c.status === "ok" ? "OK" : c.status === "warn" ? "Warning" : "Problem"} />
|
||||
<div className="min-w-0">
|
||||
<div className="text-[13.5px]"><span className="text-muted text-[12px] uppercase tracking-wide mr-2">{c.area}</span>{c.title}</div>
|
||||
{c.detail && <div className="text-[12.5px] text-muted mt-0.5 break-words">{c.detail}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Panel>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Admin → Audit log: who did what, and what Quorla published. */
|
||||
export function Audit() {
|
||||
const [clients, setClients] = useState([]);
|
||||
const [f, setF] = useState({ days: 30, client: "", user: "", q: "" });
|
||||
const [items, setItems] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [shown, setShown] = useState(100);
|
||||
useEffect(() => { api("/api/overview").then((r) => setClients(r.clients)).catch(() => {}); }, []);
|
||||
const load = useCallback(() => {
|
||||
const q = new URLSearchParams({ days: String(f.days) });
|
||||
for (const k of ["client", "user", "q"]) if (f[k]) q.set(k, f[k]);
|
||||
setItems(null); setError(null);
|
||||
return api(`/api/admin/audit?${q}`).then((r) => setItems(r.items)).catch(setError);
|
||||
}, [f]);
|
||||
useEffect(() => { const t = setTimeout(load, 250); return () => clearTimeout(t); }, [load]);
|
||||
const users = [...new Set((items || []).map((i) => i.by))].sort();
|
||||
const name = (slug) => clients.find((c) => c.slug === slug)?.name || slug;
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<header>
|
||||
<h1 className="text-[22px] font-extrabold">Audit log</h1>
|
||||
<p className="text-muted mt-0.5">Every change made in Quorla and everything it published. Passwords and keys are never recorded.</p>
|
||||
</header>
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<select aria-label="Period" className="input h-9 w-auto" value={f.days} onChange={(e) => setF({ ...f, days: Number(e.target.value) })}>{[7, 30, 90].map((d) => <option key={d} value={d}>Last {d} days</option>)}</select>
|
||||
<select aria-label="Client" className="input h-9 w-auto" value={f.client} onChange={(e) => setF({ ...f, client: e.target.value })}><option value="">All clients</option>{clients.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}</select>
|
||||
<select aria-label="Person" className="input h-9 w-auto" value={f.user} onChange={(e) => setF({ ...f, user: e.target.value })}><option value="">Everyone</option>{[...new Set([f.user, ...users].filter(Boolean))].map((u) => <option key={u} value={u}>{u}</option>)}</select>
|
||||
<input aria-label="Search" className="input h-9 w-56" placeholder="Search…" value={f.q} onChange={(e) => setF({ ...f, q: e.target.value })} />
|
||||
</div>
|
||||
<ErrorNote error={error} />
|
||||
<Panel>
|
||||
{!items ? <Spinner /> : !items.length ? <Empty icon={History} title="Nothing in this view" /> : (
|
||||
<ul className="divide-y divide-line">
|
||||
{items.slice(0, shown).map((e, i) => (
|
||||
<li key={i} className="px-4 py-2.5 grid grid-cols-[150px_1fr] gap-3 text-[13px]">
|
||||
<span className="text-muted num" title={e.at}>{fmtDateTime(e.at)}</span>
|
||||
<div className="min-w-0">
|
||||
<span className="font-medium">{e.by}</span> {e.text}
|
||||
{e.client && <span className="text-muted"> · {name(e.client)}</span>}
|
||||
{e.kind === "error" && <Badge tone="bad" className="ml-2">error</Badge>}
|
||||
{e.detail && <div className="text-[12px] text-faint truncate" title={e.detail}>{e.detail}</div>}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{items && items.length > shown && <div className="border-t border-line p-3 text-center"><Button size="sm" onClick={() => setShown(shown + 100)}>Show more</Button></div>}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const T_TONE = { active: "ok", provisioning: "brand", pending_payment: "neutral", past_due: "warn", suspended: "bad", cancelled: "neutral", removed: "neutral" };
|
||||
const T_LABEL = { active: "Active", provisioning: "Setting up", pending_payment: "Awaiting payment", past_due: "Payment overdue", suspended: "Suspended", cancelled: "Cancelled", removed: "Removed" };
|
||||
|
||||
/** Admin → Agencies (control centre only): every hosted workspace, its subscription and container. */
|
||||
export function Agencies() {
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [busy, setBusy] = useState(null);
|
||||
const load = useCallback(() => api("/api/control/tenants").then(setData).catch(setError), []);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
const act = async (slug, action, confirmText) => {
|
||||
if (confirmText && !window.confirm(confirmText)) return;
|
||||
setBusy(`${slug}-${action}`); setError(null);
|
||||
try {
|
||||
const r = await api(`/api/control/tenants/${slug}/${action}`, { method: "POST" });
|
||||
if (r.url) window.open(r.url, "_blank", "noopener");
|
||||
await load();
|
||||
} catch (e) { setError(e); }
|
||||
setBusy(null);
|
||||
};
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<header className="flex items-end justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="text-[22px] font-extrabold">Agencies</h1>
|
||||
<p className="text-muted mt-0.5">Hosted Quorla workspaces: one private copy per agency. Sign-up page: <a className="text-brand-deep underline" href="/signup" target="_blank" rel="noreferrer">/signup</a></p>
|
||||
</div>
|
||||
<Button icon={RefreshCw} onClick={load}>Refresh</Button>
|
||||
</header>
|
||||
{data && (!data.stripe || !data.provisioner) && (
|
||||
<div className="rounded-md bg-warn-soft text-warn text-[13px] px-3 py-2">
|
||||
{!data.stripe && "Stripe isn't set up: sign-ups wait for you to approve them here. "}
|
||||
{!data.provisioner && "PROVISION_TOKEN isn't set: the server script can't create workspaces yet. "}
|
||||
See docs/multi-tenant.md.
|
||||
</div>
|
||||
)}
|
||||
<ErrorNote error={error} />
|
||||
<Panel>
|
||||
{!data ? <Spinner /> : !data.tenants.length ? <Empty icon={History} title="No agencies yet">When an agency signs up at /signup it appears here.</Empty> : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="tbl min-w-[900px]">
|
||||
<thead><tr><th>Agency</th><th>Status</th><th>Workspace</th><th>Owner</th><th>Signed up</th><th><span className="sr-only">Actions</span></th></tr></thead>
|
||||
<tbody>
|
||||
{data.tenants.map((t) => (
|
||||
<tr key={t.slug}>
|
||||
<td><div className="font-medium">{t.agencyName}</div><div className="text-[12px] text-muted">{t.slug}{t.port ? ` · port ${t.port}` : ""}</div></td>
|
||||
<td>
|
||||
<Badge tone={T_TONE[t.status] || "neutral"} dot>{T_LABEL[t.status] || t.status}</Badge>
|
||||
<div className="text-[11.5px] text-muted mt-0.5">container: {t.actual}{t.desired !== t.actual && t.desired !== "none" ? ` → ${t.desired}` : ""}</div>
|
||||
{t.lastError && <div className="text-[11.5px] text-bad mt-0.5">{t.lastError}</div>}
|
||||
</td>
|
||||
<td><a className="text-brand-deep underline text-[13px]" href={t.url} target="_blank" rel="noreferrer">{t.url.replace(/^https:\/\//, "")}</a>{t.setupPending && !t.welcomedAt && t.actual === "running" ? <div className="text-[11.5px] text-warn">welcome email not sent</div> : null}</td>
|
||||
<td className="text-[13px]">{t.ownerName}<div className="text-[12px] text-muted">{t.ownerEmail}</div></td>
|
||||
<td className="text-[13px] text-muted">{timeAgo(t.createdAt)}</td>
|
||||
<td className="text-right whitespace-nowrap space-x-1">
|
||||
{t.status === "pending_payment" && <Button size="sm" loading={busy === `${t.slug}-approve`} onClick={() => act(t.slug, "approve", `Create ${t.agencyName}'s workspace without payment?`)}>Approve</Button>}
|
||||
{["active", "past_due", "provisioning"].includes(t.status) && <Button size="sm" variant="ghost" loading={busy === `${t.slug}-suspend`} onClick={() => act(t.slug, "suspend", `Stop ${t.agencyName}'s workspace? Their data is kept.`)}>Suspend</Button>}
|
||||
{["suspended", "cancelled"].includes(t.status) && <Button size="sm" loading={busy === `${t.slug}-resume`} onClick={() => act(t.slug, "resume")}>Resume</Button>}
|
||||
{t.actual === "running" && <Button size="sm" variant="ghost" loading={busy === `${t.slug}-welcome`} onClick={() => act(t.slug, "welcome")}>Resend welcome</Button>}
|
||||
{t.stripe?.customer && <Button size="sm" variant="ghost" loading={busy === `${t.slug}-portal`} onClick={() => act(t.slug, "portal")}>Billing</Button>}
|
||||
{["suspended", "cancelled"].includes(t.status) && <Button size="sm" variant="danger" loading={busy === `${t.slug}-remove`} onClick={() => act(t.slug, "remove", `Remove ${t.agencyName}'s container? Their data folder is archived on the server, not deleted.`)}>Remove</Button>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
dashboard/web/src/pages/BrandKit.jsx
Normal file
91
dashboard/web/src/pages/BrandKit.jsx
Normal file
@ -0,0 +1,91 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Plus, Save, Trash2 } from "lucide-react";
|
||||
import { api } from "../api.js";
|
||||
import { Button, ErrorNote, Field, Panel, Spinner, timeAgo } from "../ui.jsx";
|
||||
|
||||
/** Client page tab: hashtag sets, approved phrases/CTAs, words to avoid, voice notes. */
|
||||
export default function BrandKit({ slug }) {
|
||||
const [kit, setKit] = useState(null);
|
||||
const [sets, setSets] = useState([]);
|
||||
const [phrases, setPhrases] = useState("");
|
||||
const [avoid, setAvoid] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const apply = (k) => { setKit(k); setSets(k.hashtagSets.map((s) => ({ name: s.name, tags: s.tags.join(" ") }))); setPhrases(k.phrases.join("\n")); setAvoid(k.avoid.join("\n")); setNotes(k.notes); };
|
||||
useEffect(() => { api(`/api/brandkit/${slug}`).then(apply).catch(setError); }, [slug]);
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true); setError(null); setSaved(false);
|
||||
try { apply(await api(`/api/brandkit/${slug}`, { method: "PUT", body: { hashtagSets: sets, phrases, avoid, notes } })); setSaved(true); } catch (e) { setError(e); }
|
||||
setBusy(false);
|
||||
};
|
||||
if (!kit) return error ? <ErrorNote error={error} /> : <Spinner />;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Panel title="Hashtag sets" subtitle="Saved groups the team can drop into a caption with one click. 3–8 relevant tags per post work best.">
|
||||
<div className="space-y-3">
|
||||
{sets.map((s, i) => (
|
||||
<div key={i} className="grid sm:grid-cols-[180px_1fr_auto] gap-2 items-start">
|
||||
<label htmlFor={`bk-n-${i}`} className="sr-only">Set name</label>
|
||||
<input id={`bk-n-${i}`} className="input" value={s.name} placeholder="e.g. Local" onChange={(e) => setSets(sets.map((x, j) => (j === i ? { ...x, name: e.target.value } : x)))} />
|
||||
<label htmlFor={`bk-t-${i}`} className="sr-only">Hashtags</label>
|
||||
<input id={`bk-t-${i}`} className="input" value={s.tags} placeholder="#waterloo #kitchenerwaterloo #kwbakery" onChange={(e) => setSets(sets.map((x, j) => (j === i ? { ...x, tags: e.target.value } : x)))} />
|
||||
<button type="button" onClick={() => setSets(sets.filter((_, j) => j !== i))} className="p-2 rounded hover:bg-bad-soft text-muted hover:text-bad" aria-label={`Remove set ${s.name || i + 1}`}><Trash2 className="size-4" /></button>
|
||||
</div>
|
||||
))}
|
||||
<Button size="sm" icon={Plus} onClick={() => setSets([...sets, { name: "", tags: "" }])}>Add a set</Button>
|
||||
</div>
|
||||
</Panel>
|
||||
<div className="grid lg:grid-cols-2 gap-4">
|
||||
<Panel title="Approved phrases and calls to action" subtitle="One per line. Offered while writing captions.">
|
||||
<label htmlFor="bk-phrases" className="sr-only">Phrases</label>
|
||||
<textarea id="bk-phrases" className="input h-40 py-2" value={phrases} onChange={(e) => setPhrases(e.target.value)} placeholder={"Order online at the link in bio\nBook your table: 519-…\nFresh every morning from 8 a.m."} />
|
||||
</Panel>
|
||||
<Panel title="Words to avoid" subtitle="One per line. Captions using them get a warning before approval.">
|
||||
<label htmlFor="bk-avoid" className="sr-only">Words to avoid</label>
|
||||
<textarea id="bk-avoid" className="input h-40 py-2" value={avoid} onChange={(e) => setAvoid(e.target.value)} placeholder={"cheap\nbest in town\ndiscount (say 'offer')"} />
|
||||
</Panel>
|
||||
</div>
|
||||
<Panel title="Voice notes" subtitle="Anything else about how this client sounds. The AI reads this with brand.md.">
|
||||
<label htmlFor="bk-notes" className="sr-only">Voice notes</label>
|
||||
<textarea id="bk-notes" className="input h-24 py-2" value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="Warm, a little playful. Always sign off with the owner's first name on Fridays." />
|
||||
</Panel>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="primary" icon={Save} loading={busy} onClick={save}>Save brand kit</Button>
|
||||
{saved && <span className="text-[12.5px] text-ok">Saved.</span>}
|
||||
{kit.updatedAt && !saved && <span className="text-[12px] text-muted">Last changed {timeAgo(kit.updatedAt)} by {kit.updatedBy}</span>}
|
||||
</div>
|
||||
<ErrorNote error={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Under a caption box: insert hashtag sets/phrases and show live brand checks. */
|
||||
export function CaptionHelper({ client, caption, platforms, onInsert }) {
|
||||
const [kit, setKit] = useState(null);
|
||||
const [warnings, setWarnings] = useState([]);
|
||||
const timer = useRef(null);
|
||||
useEffect(() => { setKit(null); if (client) api(`/api/brandkit/${client}`).then(setKit).catch(() => {}); }, [client]);
|
||||
useEffect(() => {
|
||||
clearTimeout(timer.current);
|
||||
if (!client || !caption?.trim()) { setWarnings([]); return; }
|
||||
timer.current = setTimeout(() => api(`/api/brandkit/${client}/check`, { method: "POST", body: { caption, platforms } }).then((r) => setWarnings(r.warnings)).catch(() => {}), 500);
|
||||
return () => clearTimeout(timer.current);
|
||||
}, [client, caption, platforms.join(",")]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5 mt-1.5">
|
||||
{kit && (kit.hashtagSets.length > 0 || kit.phrases.length > 0) && (
|
||||
<div className="flex flex-wrap gap-1.5 items-center text-[12px]">
|
||||
<span className="text-muted">Insert:</span>
|
||||
{kit.hashtagSets.map((s) => <button key={`h-${s.name}`} type="button" onClick={() => onInsert(`\n\n${s.tags.join(" ")}`)} className="h-7 px-2 rounded border border-line hover:bg-canvas" title={s.tags.join(" ")}># {s.name}</button>)}
|
||||
{kit.phrases.slice(0, 6).map((p) => <button key={`p-${p}`} type="button" onClick={() => onInsert(`\n${p}`)} className="h-7 px-2 rounded border border-line hover:bg-canvas max-w-[220px] truncate" title={p}>{p}</button>)}
|
||||
</div>
|
||||
)}
|
||||
{warnings.map((w) => <div key={w.text} className={w.level === "bad" ? "text-[12px] text-bad" : "text-[12px] text-warn"}>{w.level === "bad" ? "Fix: " : ""}{w.text}</div>)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
284
dashboard/web/src/pages/Calendar.jsx
Normal file
284
dashboard/web/src/pages/Calendar.jsx
Normal file
@ -0,0 +1,284 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { ChevronLeft, ChevronRight, Plus, Trash2 } from "lucide-react";
|
||||
import { api } from "../api.js";
|
||||
import { Badge, Button, cx, Dialog, ErrorNote, Field, Spinner } from "../ui.jsx";
|
||||
|
||||
// Stage → colour and label. Stages before "scheduled" are the team's plan; the rest come from the queue.
|
||||
const STAGES = {
|
||||
idea: { label: "Idea", color: "#9a9da6" },
|
||||
brief: { label: "Brief", color: "#6c63b5" },
|
||||
production: { label: "In production", color: "#a86a12" },
|
||||
ready: { label: "Ready", color: "#2f7d4f" },
|
||||
scheduled: { label: "Scheduled", color: "#3b3778" },
|
||||
published: { label: "Published", color: "#c2461a" },
|
||||
failed: { label: "Failed", color: "#b4372b" },
|
||||
};
|
||||
const PLAN_STAGES = ["idea", "brief", "production", "ready"];
|
||||
const FORMATS = { IMAGE: "Image", REELS: "Reel / video", CAROUSEL: "Carousel", STORY: "Story", TEXT: "Text" };
|
||||
const PLATFORMS = { instagram: "Instagram", facebook: "Facebook", tiktok: "TikTok", youtube: "YouTube", gbp: "Google Business", linkedin: "LinkedIn", pinterest: "Pinterest", threads: "Threads" };
|
||||
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
|
||||
const iso = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
const parse = (s) => { const [y, m, d] = s.split("-").map(Number); return new Date(y, m - 1, d); };
|
||||
const addDays = (d, n) => { const x = new Date(d); x.setDate(x.getDate() + n); return x; };
|
||||
const mondayOf = (d) => addDays(d, -((d.getDay() + 6) % 7));
|
||||
const todayIso = () => iso(new Date());
|
||||
|
||||
function range(view, anchor) {
|
||||
if (view === "week") { const start = mondayOf(anchor); return { start, days: 7 }; }
|
||||
const first = new Date(anchor.getFullYear(), anchor.getMonth(), 1);
|
||||
const start = mondayOf(first);
|
||||
const last = new Date(anchor.getFullYear(), anchor.getMonth() + 1, 0);
|
||||
const days = Math.ceil((last - start) / 86400000 / 7 + 1 / 7) * 7;
|
||||
return { start, days: Math.max(35, days) };
|
||||
}
|
||||
|
||||
function Card({ item, onOpen, compact }) {
|
||||
const st = STAGES[item.stage] || STAGES.idea;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
draggable={item.editable}
|
||||
onDragStart={(e) => { e.dataTransfer.setData("text/plain", JSON.stringify({ kind: item.kind, id: item.id })); e.dataTransfer.effectAllowed = "move"; }}
|
||||
onClick={() => onOpen(item)}
|
||||
className={cx("w-full text-left rounded-md bg-white border border-line hover:border-line-strong transition-colors px-2 py-1.5 border-l-[3px] focus-visible:outline-2", item.editable && "cursor-grab active:cursor-grabbing")}
|
||||
style={{ borderLeftColor: st.color }}
|
||||
title={`${st.label}: ${item.title}`}
|
||||
>
|
||||
<div className={cx("text-[12.5px] font-medium text-ink", compact ? "truncate" : "line-clamp-2")}>{item.title}</div>
|
||||
<div className="text-[11px] text-muted truncate">
|
||||
{item.time && <span className="num">{item.time} · </span>}{FORMATS[item.format] || item.format} · {st.label}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemDialog({ item, client, onClose, onSaved }) {
|
||||
const isNew = !item.id;
|
||||
const isPlan = isNew || item.kind === "plan";
|
||||
const [f, setF] = useState({
|
||||
title: item.title || "", date: item.date, time: item.time || "", status: item.stage && PLAN_STAGES.includes(item.stage) ? item.stage : "idea",
|
||||
format: item.format || "IMAGE", platforms: item.platforms || [], brief: item.brief || "", caption: item.caption || "", assignee: item.assignee || "", pillar: item.pillar || "",
|
||||
});
|
||||
const [busy, setBusy] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [names, setNames] = useState([]);
|
||||
const [odoo, setOdoo] = useState(null);
|
||||
const [task, setTask] = useState({ projectId: "", userId: "" });
|
||||
const set = (k, v) => setF((s) => ({ ...s, [k]: v }));
|
||||
useEffect(() => { api("/api/team/names").then((r) => setNames(r.names)).catch(() => {}); }, []);
|
||||
useEffect(() => { if (isPlan && !isNew && !item.taskRef) api("/api/team").then((r) => setOdoo(r.status?.connected && r.status?.allowWrites ? r : null)).catch(() => {}); }, [isPlan, isNew, item.taskRef]);
|
||||
const makeTask = () => run("task", () => api(`/api/plan/${item.id}/odoo-task`, { method: "POST", body: task }));
|
||||
const run = async (what, fn) => { setBusy(what); setError(null); try { await fn(); onSaved(); } catch (e) { setError(e); setBusy(null); } };
|
||||
|
||||
const save = () => run("save", () => (isPlan
|
||||
? (isNew ? api("/api/plan", { method: "POST", body: { client, ...f } }) : api(`/api/plan/${item.id}`, { method: "PATCH", body: f }))
|
||||
: api("/api/calendar/move", { method: "POST", body: { kind: item.kind, id: item.id, date: f.date } })));
|
||||
const remove = () => run("remove", () => api(`/api/plan/${item.id}`, { method: "DELETE" }));
|
||||
const st = STAGES[item.stage];
|
||||
|
||||
return (
|
||||
<Dialog open onClose={onClose} wide title={isNew ? "Add to the plan" : item.title}
|
||||
footer={<>
|
||||
{isPlan && !isNew && <Button variant="ghost" icon={Trash2} loading={busy === "remove"} onClick={remove} className="mr-auto">Remove</Button>}
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
{(isPlan || item.editable) && <Button variant="primary" loading={busy === "save"} onClick={save} disabled={isPlan && !f.title.trim()}>{isNew ? "Add" : "Save"}</Button>}
|
||||
</>}>
|
||||
<div className="space-y-4">
|
||||
{!isPlan && (
|
||||
<div className="rounded-md bg-canvas border border-line p-3 text-[13px] space-y-1">
|
||||
<div className="flex items-center gap-2"><Badge tone="neutral" dot>{st?.label}</Badge>{item.detail && <span className="text-muted">{item.detail}</span>}</div>
|
||||
{item.caption && <p className="text-ink-2 whitespace-pre-line line-clamp-6">{item.caption}</p>}
|
||||
<p className="text-muted">
|
||||
{item.kind === "library" ? <>Edit the work itself in the <Link className="text-brand-deep underline" to={`/library?client=${client}`}>Content library</Link>.</>
|
||||
: <>Edit or approve it in <Link className="text-brand-deep underline" to="/publishing">Publishing</Link>.</>}
|
||||
{!item.editable && " Approved and published posts can't be moved here."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{isPlan && (
|
||||
<Field label="Title">
|
||||
<input id="plan-title" className="input" value={f.title} maxLength={140} onChange={(e) => set("title", e.target.value)} placeholder="e.g. Saturday brunch reel" autoFocus={isNew} />
|
||||
</Field>
|
||||
)}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<Field label="Date">
|
||||
<input id="plan-date" type="date" className="input" value={f.date} disabled={!isPlan && !item.editable} onChange={(e) => set("date", e.target.value)} />
|
||||
</Field>
|
||||
{isPlan && <>
|
||||
<Field label="Time (optional)"><input id="plan-time" type="time" className="input" value={f.time} onChange={(e) => set("time", e.target.value)} /></Field>
|
||||
<Field label="Stage">
|
||||
<select id="plan-status" className="input" value={f.status} onChange={(e) => set("status", e.target.value)}>
|
||||
{PLAN_STAGES.map((s) => <option key={s} value={s}>{STAGES[s].label}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Format">
|
||||
<select id="plan-format" className="input" value={f.format} onChange={(e) => set("format", e.target.value)}>
|
||||
{Object.entries(FORMATS).map(([k, l]) => <option key={k} value={k}>{l}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
</>}
|
||||
</div>
|
||||
{isPlan && <>
|
||||
<Field label="Platforms">
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1.5 text-[13px]">
|
||||
{Object.entries(PLATFORMS).map(([id, name]) => (
|
||||
<label key={id} htmlFor={`plan-pl-${id}`} className="flex items-center gap-1.5">
|
||||
<input id={`plan-pl-${id}`} type="checkbox" checked={f.platforms.includes(id)} onChange={() => set("platforms", f.platforms.includes(id) ? f.platforms.filter((x) => x !== id) : [...f.platforms, id])} /> {name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Creative brief" hint="What the designer or editor needs: hook, shots, text on screen, length.">
|
||||
<textarea id="plan-brief" className="input h-24 py-2" value={f.brief} onChange={(e) => set("brief", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Draft caption">
|
||||
<textarea id="plan-caption" className="input h-20 py-2" value={f.caption} onChange={(e) => set("caption", e.target.value)} />
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Assigned to" hint="They're notified when you save.">
|
||||
<input id="plan-assignee" className="input" list="plan-team" value={f.assignee} onChange={(e) => set("assignee", e.target.value)} placeholder="Designer or editor" />
|
||||
<datalist id="plan-team">{names.map((n) => <option key={n} value={n} />)}</datalist>
|
||||
</Field>
|
||||
<Field label="Content pillar"><input id="plan-pillar" className="input" value={f.pillar} onChange={(e) => set("pillar", e.target.value)} placeholder="e.g. Behind the scenes" /></Field>
|
||||
</div>
|
||||
{item.taskRef && <p className="text-[12.5px] text-muted">Odoo task #{item.taskRef}. Uploads for this card add a note to it and move it to Review.</p>}
|
||||
{odoo && (
|
||||
<div className="rounded-md border border-line p-3 space-y-2">
|
||||
<div className="text-[13px] font-semibold">Create an Odoo task for this</div>
|
||||
<div className="grid sm:grid-cols-[1fr_1fr_auto] gap-2 items-end">
|
||||
<Field label="Project"><select id="plan-odoo-project" className="input" value={task.projectId} onChange={(e) => setTask({ ...task, projectId: e.target.value })}><option value="">Choose…</option>{odoo.projects.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}</select></Field>
|
||||
<Field label="Assignee in Odoo"><select id="plan-odoo-user" className="input" value={task.userId} onChange={(e) => setTask({ ...task, userId: e.target.value })}><option value="">Unassigned</option>{(odoo.workload || []).map((w) => <option key={w.id} value={w.id}>{w.name}</option>)}</select></Field>
|
||||
<Button loading={busy === "task"} disabled={!task.projectId} onClick={makeTask}>Create task</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>}
|
||||
<ErrorNote error={error} />
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Calendar() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const [clients, setClients] = useState(null);
|
||||
const [view, setView] = useState(params.get("view") === "week" ? "week" : "month");
|
||||
const [anchor, setAnchor] = useState(() => (params.get("date") ? parse(params.get("date")) : new Date()));
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [open, setOpen] = useState(null);
|
||||
const [dropDay, setDropDay] = useState(null);
|
||||
const [expanded, setExpanded] = useState({});
|
||||
const client = params.get("client") || "";
|
||||
|
||||
useEffect(() => { api("/api/overview").then((r) => setClients(r.clients)).catch(setError); }, []);
|
||||
useEffect(() => { if (!client && clients?.length) setParams({ client: clients[0].slug }, { replace: true }); }, [client, clients, setParams]);
|
||||
|
||||
const { start, days } = useMemo(() => range(view, anchor), [view, anchor]);
|
||||
const from = iso(start), to = iso(addDays(start, days - 1));
|
||||
const load = useCallback(() => {
|
||||
if (!client) return Promise.resolve();
|
||||
return api(`/api/calendar?client=${encodeURIComponent(client)}&from=${from}&to=${to}`).then(setData).catch(setError);
|
||||
}, [client, from, to]);
|
||||
useEffect(() => { setData(null); load(); }, [load]);
|
||||
|
||||
const byDay = useMemo(() => {
|
||||
const m = {};
|
||||
for (const i of data?.items || []) (m[i.date] ||= []).push(i);
|
||||
return m;
|
||||
}, [data]);
|
||||
|
||||
const step = (n) => setAnchor((a) => (view === "week" ? addDays(a, 7 * n) : new Date(a.getFullYear(), a.getMonth() + n, 1)));
|
||||
const move = async (payload, date) => {
|
||||
setError(null);
|
||||
try { await api("/api/calendar/move", { method: "POST", body: { ...payload, date } }); await load(); } catch (e) { setError(e); }
|
||||
};
|
||||
const counts = useMemo(() => Object.fromEntries(Object.keys(STAGES).map((s) => [s, (data?.items || []).filter((i) => i.stage === s).length])), [data]);
|
||||
const title = view === "week"
|
||||
? `${start.toLocaleDateString("en-CA", { month: "short", day: "numeric" })} – ${addDays(start, 6).toLocaleDateString("en-CA", { month: "short", day: "numeric", year: "numeric" })}`
|
||||
: anchor.toLocaleDateString("en-CA", { month: "long", year: "numeric" });
|
||||
const month = anchor.getMonth();
|
||||
const today = todayIso();
|
||||
|
||||
if (!clients) return error ? <ErrorNote error={error} /> : <Spinner />;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<header className="flex items-end justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="text-[22px] font-extrabold">Content calendar</h1>
|
||||
<p className="text-muted mt-0.5">Plan, production and the publishing queue in one view{data?.timezone ? ` · ${data.timezone.replace("_", " ")} time` : ""}. Drag a card to move it, or open it to change the date.</p>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center flex-wrap">
|
||||
<select id="cal-client" aria-label="Client" className="input h-9 w-auto" value={client} onChange={(e) => setParams({ client: e.target.value })}>
|
||||
{clients.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}
|
||||
</select>
|
||||
<Button variant="primary" icon={Plus} onClick={() => setOpen({ date: today < from || today > to ? from : today })}>Add to plan</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" icon={ChevronLeft} onClick={() => step(-1)} aria-label={view === "week" ? "Previous week" : "Previous month"} />
|
||||
<Button size="sm" onClick={() => setAnchor(new Date())}>Today</Button>
|
||||
<Button size="sm" icon={ChevronRight} onClick={() => step(1)} aria-label={view === "week" ? "Next week" : "Next month"} />
|
||||
<h2 className="text-[16px] font-bold ml-1">{title}</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<ul className="flex flex-wrap gap-x-3 gap-y-1 text-[12px] text-muted" aria-label="Stages">
|
||||
{Object.entries(STAGES).filter(([s]) => s !== "failed" || counts.failed).map(([s, v]) => (
|
||||
<li key={s} className="flex items-center gap-1.5"><span className="size-2.5 rounded-sm" style={{ background: v.color }} aria-hidden="true" />{v.label}{data ? <span className="num text-faint">{counts[s]}</span> : null}</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="inline-flex rounded-md border border-line-strong bg-white p-0.5" role="group" aria-label="View">
|
||||
{["month", "week"].map((v) => (
|
||||
<button key={v} type="button" aria-pressed={view === v} onClick={() => setView(v)}
|
||||
className={cx("h-8 px-3 rounded text-[13px] font-medium capitalize", view === v ? "bg-brand-soft text-brand-deep" : "text-ink-2 hover:bg-canvas")}>{v}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ErrorNote error={error} />
|
||||
|
||||
<div className="panel overflow-hidden">
|
||||
<div className="grid grid-cols-7 border-b border-line bg-[#fafbfc]">
|
||||
{DAYS.map((d) => <div key={d} className="eyebrow px-2 py-2">{d}</div>)}
|
||||
</div>
|
||||
{!data ? <Spinner /> : (
|
||||
<div className="grid grid-cols-7">
|
||||
{Array.from({ length: days }, (_, n) => {
|
||||
const d = addDays(start, n);
|
||||
const key = iso(d);
|
||||
const items = byDay[key] || [];
|
||||
const limit = view === "week" || expanded[key] ? items.length : 3;
|
||||
const outside = view === "month" && d.getMonth() !== month;
|
||||
return (
|
||||
<div key={key}
|
||||
onDragOver={(e) => { e.preventDefault(); setDropDay(key); }}
|
||||
onDragLeave={() => setDropDay((x) => (x === key ? null : x))}
|
||||
onDrop={(e) => { e.preventDefault(); setDropDay(null); try { move(JSON.parse(e.dataTransfer.getData("text/plain")), key); } catch { /* not ours */ } }}
|
||||
className={cx("group border-b border-r border-line p-1.5 flex flex-col gap-1", view === "week" ? "min-h-[420px]" : "min-h-[124px]",
|
||||
outside && "bg-[#fafbfc]", dropDay === key && "bg-brand-soft ring-2 ring-inset ring-brand/40")}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={cx("text-[12px] num font-semibold size-6 grid place-items-center rounded-full", key === today ? "bg-brand-deep text-white" : outside ? "text-faint" : "text-ink-2")}>{d.getDate()}</span>
|
||||
<button type="button" onClick={() => setOpen({ date: key })} aria-label={`Add to the plan on ${d.toLocaleDateString("en-CA", { month: "long", day: "numeric" })}`}
|
||||
className="opacity-0 group-hover:opacity-100 focus-visible:opacity-100 p-1 rounded hover:bg-canvas text-muted"><Plus className="size-3.5" /></button>
|
||||
</div>
|
||||
{items.slice(0, limit).map((i) => <Card key={`${i.kind}-${i.id}`} item={i} onOpen={setOpen} compact={view === "month"} />)}
|
||||
{items.length > limit && (
|
||||
<button type="button" className="text-[11.5px] text-brand-deep hover:underline text-left px-1" onClick={() => setExpanded((x) => ({ ...x, [key]: true }))}>+{items.length - limit} more</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{open && <ItemDialog item={open} client={client} onClose={() => setOpen(null)} onSaved={() => { setOpen(null); load(); }} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -3,6 +3,10 @@ import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { Bar, BarChart, CartesianGrid, Cell, Area, AreaChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
import { ArrowLeft, ArrowUpRight, ClipboardCheck, CalendarRange, FileBarChart, FileDown, FileText, Eye, RefreshCw, Sparkles, Briefcase, Link2, Facebook, Instagram } from "lucide-react";
|
||||
import { api } from "../api.js";
|
||||
import ClientPortal from "./ClientPortal.jsx";
|
||||
import MonthlyReport from "./MonthlyReport.jsx";
|
||||
import BrandKit from "./BrandKit.jsx";
|
||||
import LinkInBio from "./LinkInBio.jsx";
|
||||
import { useAuth } from "../App.jsx";
|
||||
import {
|
||||
Badge, Button, cx, Dialog, Empty, ErrorNote, fmt, fmtDate, fmtDateTime, Panel, Spinner, Stat, StatCell, StatRow, STATUS_TONE, Tabs, timeAgo,
|
||||
@ -97,6 +101,9 @@ export default function Client() {
|
||||
{ value: "performance", label: "Performance" },
|
||||
{ value: "docs", label: "Documents", count: data.docs.filter((d) => d.kind !== "setup").length },
|
||||
{ value: "runs", label: "AI runs", count: data.jobs.length },
|
||||
{ value: "brand", label: "Brand kit" },
|
||||
{ value: "bio", label: "Link in bio" },
|
||||
{ value: "portal", label: "Client approval" },
|
||||
]}
|
||||
/>
|
||||
|
||||
@ -110,8 +117,11 @@ export default function Client() {
|
||||
</Empty>
|
||||
</Panel>
|
||||
))}
|
||||
{tab === "docs" && <Documents slug={slug} docs={data.docs} reload={load} />}
|
||||
{tab === "docs" && <div className="space-y-5"><MonthlyReport slug={slug} onBuilt={load} /><Documents slug={slug} docs={data.docs} reload={load} /></div>}
|
||||
{tab === "runs" && <ClientRuns jobs={data.jobs} />}
|
||||
{tab === "brand" && <BrandKit slug={slug} />}
|
||||
{tab === "bio" && <LinkInBio slug={slug} />}
|
||||
{tab === "portal" && <ClientPortal slug={slug} clientName={client.name} />}
|
||||
|
||||
<RunDialog open={runOpen} onClose={() => setRunOpen(false)} client={client} onStarted={(job) => nav(`/runs/${job.id}`)} />
|
||||
{linkOpen && <LinkPageDialog client={client} onClose={() => setLinkOpen(false)} onLinked={() => { setLinkOpen(false); load(); }} />}
|
||||
@ -374,7 +384,7 @@ function FormatBars({ d }) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- documents
|
||||
const KIND_LABEL = { audit: "Audits", report: "Weekly reports", calendar: "Content calendars", "profile-check": "Profile checks", setup: "Client setup files" };
|
||||
const KIND_LABEL = { monthly: "Monthly reports", audit: "Audits", report: "Weekly reports", calendar: "Content calendars", "profile-check": "Profile checks", setup: "Client setup files" };
|
||||
|
||||
function Documents({ slug, docs, reload }) {
|
||||
const [preview, setPreview] = useState(null);
|
||||
|
||||
66
dashboard/web/src/pages/ClientPortal.jsx
Normal file
66
dashboard/web/src/pages/ClientPortal.jsx
Normal file
@ -0,0 +1,66 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Check, Copy, ExternalLink, Link2, RefreshCw, Unlink } from "lucide-react";
|
||||
import { api } from "../api.js";
|
||||
import { useAuth } from "../App.jsx";
|
||||
import { Button, Empty, ErrorNote, Field, Panel, Spinner, timeAgo } from "../ui.jsx";
|
||||
|
||||
/** Client page tab: the client's private approval link and who to notify. */
|
||||
export default function ClientPortal({ slug, clientName }) {
|
||||
const { isAdmin } = useAuth();
|
||||
const [link, setLink] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [busy, setBusy] = useState(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [contact, setContact] = useState({ contactName: "", contactEmail: "" });
|
||||
|
||||
const load = useCallback(() => api(`/api/portal/${slug}`).then((l) => { setLink(l); setContact({ contactName: l.contactName || "", contactEmail: l.contactEmail || "" }); }).catch(setError), [slug]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
const run = async (what, fn) => { setBusy(what); setError(null); try { const l = await fn(); if (l) { setLink(l); } } catch (e) { setError(e); } setBusy(null); };
|
||||
|
||||
if (!link) return error ? <ErrorNote error={error} /> : <Spinner />;
|
||||
const url = link.enabled ? `${location.origin}/p/${link.token}` : null;
|
||||
const copy = () => navigator.clipboard?.writeText(url).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1500); });
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ErrorNote error={error} />
|
||||
{!link.enabled ? (
|
||||
<Panel>
|
||||
<Empty icon={Link2} title="No approval link yet"
|
||||
action={isAdmin ? <Button variant="primary" icon={Link2} loading={busy === "issue"} onClick={() => run("issue", () => api(`/api/portal/${slug}`, { method: "POST", body: {} }))}>Create approval link</Button> : <span className="text-[12.5px] text-muted">An admin creates the link.</span>}>
|
||||
Give {clientName} a private link where they see the posts you send them, approve them or ask for changes. No login needed. Your team still gives the final approval before anything is posted.
|
||||
</Empty>
|
||||
</Panel>
|
||||
) : (
|
||||
<>
|
||||
<Panel title="Client approval link" subtitle={`Created ${timeAgo(link.createdAt)} by ${link.createdBy}. Anyone with the link can see and answer this client's posts in review, so share it only with the client.`}>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 rounded-md border border-line bg-canvas px-2.5 py-1.5">
|
||||
<code className="font-mono text-[12.5px] break-all flex-1 min-w-0">{url}</code>
|
||||
<button type="button" onClick={copy} className="p-1.5 rounded hover:bg-white text-muted" aria-label="Copy link">{copied ? <Check className="size-4 text-ok" /> : <Copy className="size-4" />}</button>
|
||||
<a href={url} target="_blank" rel="noreferrer" className="p-1.5 rounded hover:bg-white text-muted" aria-label="Open the client's page"><ExternalLink className="size-4" /></a>
|
||||
</div>
|
||||
<p className="text-[12.5px] text-muted">
|
||||
To send a post for review: <Link to="/publishing" className="text-brand-deep underline">Publishing</Link> → a draft → <b>Send to client</b>. Their answer shows on the draft.
|
||||
</p>
|
||||
{isAdmin && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" icon={RefreshCw} loading={busy === "issue"} onClick={() => run("issue", () => api(`/api/portal/${slug}`, { method: "POST", body: contact }))}>New link (old one stops working)</Button>
|
||||
<Button size="sm" variant="danger" icon={Unlink} loading={busy === "revoke"} onClick={() => run("revoke", () => api(`/api/portal/${slug}`, { method: "DELETE" }))}>Turn off</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
<Panel title="Who reviews" subtitle="Used for email notifications when posts are waiting for them.">
|
||||
<form className="grid sm:grid-cols-[1fr_1fr_auto] gap-3 items-end" onSubmit={(e) => { e.preventDefault(); run("contact", () => api(`/api/portal/${slug}`, { method: "PATCH", body: contact })); }}>
|
||||
<Field label="Name"><input id="portal-contact-name" className="input" value={contact.contactName} disabled={!isAdmin} onChange={(e) => setContact({ ...contact, contactName: e.target.value })} /></Field>
|
||||
<Field label="Email"><input id="portal-contact-email" type="email" className="input" value={contact.contactEmail} disabled={!isAdmin} onChange={(e) => setContact({ ...contact, contactEmail: e.target.value })} /></Field>
|
||||
{isAdmin && <Button type="submit" loading={busy === "contact"}>Save</Button>}
|
||||
</form>
|
||||
</Panel>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
212
dashboard/web/src/pages/Competitors.jsx
Normal file
212
dashboard/web/src/pages/Competitors.jsx
Normal file
@ -0,0 +1,212 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Bar, BarChart, CartesianGrid, Cell, LabelList, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
import { ExternalLink, Plus, RefreshCw, Swords, Trash2 } from "lucide-react";
|
||||
import { api } from "../api.js";
|
||||
import { Badge, Button, cx, Empty, ErrorNote, Field, fmt, fmtDate, Panel, Spinner, timeAgo } from "../ui.jsx";
|
||||
|
||||
const FORMAT_COLORS = { Reel: "#3b3778", Carousel: "#fc6d45", Image: "#a9afba", Video: "#6c63b5" };
|
||||
|
||||
/** Follower trend scaled to its own min/max, so small and large accounts both show their direction. */
|
||||
function Trend({ series }) {
|
||||
const vals = (series || []).map((s) => s.followers).filter((v) => v != null);
|
||||
if (vals.length < 2) return <span className="text-[12px] text-faint">builds daily</span>;
|
||||
const w = 96, h = 26, min = Math.min(...vals), max = Math.max(...vals), span = max - min || 1;
|
||||
const pts = vals.map((v, i) => `${(i / (vals.length - 1)) * (w - 2) + 1},${h - 1 - ((v - min) / span) * (h - 2)}`).join(" ");
|
||||
const up = vals.at(-1) >= vals[0];
|
||||
return (
|
||||
<svg width={w} height={h} className="block" role="img" aria-label={`Followers ${up ? "up" : "down"} from ${fmt(vals[0])} to ${fmt(vals.at(-1))}`}>
|
||||
<polyline points={pts} fill="none" stroke={up ? "#2f7d4f" : "#b4372b"} strokeWidth="1.75" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FormatMix({ mix }) {
|
||||
const entries = Object.entries(mix || {});
|
||||
const total = entries.reduce((s, [, n]) => s + n, 0);
|
||||
if (!total) return <span className="text-faint">—</span>;
|
||||
return (
|
||||
<div className="min-w-[140px]">
|
||||
<div className="flex h-2 rounded-full overflow-hidden bg-canvas" aria-hidden="true">
|
||||
{entries.map(([k, n]) => <span key={k} style={{ width: `${(n / total) * 100}%`, background: FORMAT_COLORS[k] || "#9a9da6" }} />)}
|
||||
</div>
|
||||
<div className="text-[11.5px] text-muted mt-1">{entries.sort((a, b) => b[1] - a[1]).map(([k, n]) => `${k} ${Math.round((n / total) * 100)}%`).join(" · ")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddDialog({ client, onClose, onAdded }) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [note, setNote] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const add = async (e) => {
|
||||
e.preventDefault(); setBusy(true); setError(null);
|
||||
try { await api("/api/competitors", { method: "POST", body: { client, username, note } }); onAdded(); }
|
||||
catch (er) { setError(er); setBusy(false); }
|
||||
};
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 bg-ink/30 grid place-items-center p-4" role="dialog" aria-modal="true" aria-labelledby="add-comp-title" onClick={onClose}>
|
||||
<form onSubmit={add} onClick={(e) => e.stopPropagation()} className="panel w-full max-w-[460px] p-5 space-y-4 shadow-xl">
|
||||
<h2 id="add-comp-title" className="text-[16px] font-bold">Track a competitor</h2>
|
||||
<Field label="Instagram handle" hint="Public Business or Creator accounts only. Instagram doesn't share personal accounts.">
|
||||
<input id="comp-username" className="input" value={username} onChange={(e) => setUsername(e.target.value)} placeholder="@bakery_down_the_street" autoFocus required />
|
||||
</Field>
|
||||
<Field label="Why track them? (optional)">
|
||||
<input id="comp-note" className="input" value={note} onChange={(e) => setNote(e.target.value)} placeholder="Same neighbourhood, similar menu" />
|
||||
</Field>
|
||||
<ErrorNote error={error} />
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" onClick={onClose}>Cancel</Button>
|
||||
<Button type="submit" variant="primary" loading={busy}>Add</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Competitors() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const [clients, setClients] = useState(null);
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [busy, setBusy] = useState(null);
|
||||
const client = params.get("client") || "";
|
||||
|
||||
useEffect(() => { api("/api/overview").then((r) => setClients(r.clients)).catch(setError); }, []);
|
||||
useEffect(() => { if (!client && clients?.length) setParams({ client: clients[0].slug }, { replace: true }); }, [client, clients, setParams]);
|
||||
const load = useCallback(() => (client ? api(`/api/competitors?client=${encodeURIComponent(client)}`).then(setData).catch(setError) : Promise.resolve()), [client]);
|
||||
useEffect(() => { setData(null); setError(null); load(); }, [load]);
|
||||
|
||||
const run = async (what, fn) => { setBusy(what); setError(null); try { await fn(); await load(); } catch (e) { setError(e); } setBusy(null); };
|
||||
const refresh = () => run("refresh", async () => { const r = await api("/api/competitors/refresh", { method: "POST", body: { client } }); if (r.errors?.length) throw new Error(r.errors.join(" · ")); });
|
||||
const remove = (u) => run(`rm-${u}`, () => api(`/api/competitors/${client}/${encodeURIComponent(u)}`, { method: "DELETE" }));
|
||||
|
||||
if (!clients) return error ? <ErrorNote error={error} /> : <Spinner />;
|
||||
const current = clients.find((c) => c.slug === client);
|
||||
const rows = data ? [...(data.you ? [{ ...data.you, username: data.you.username || current?.name }] : []), ...data.competitors] : [];
|
||||
const chart = rows.filter((r) => r.engagementRate != null).map((r) => ({ name: `@${r.username}`, rate: r.engagementRate, own: Boolean(r.own) }));
|
||||
const lastChecked = data?.competitors.map((c) => c.lastChecked).filter(Boolean).sort()[0];
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<header className="flex items-end justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="text-[22px] font-extrabold">Competitors</h1>
|
||||
<p className="text-muted mt-0.5">How each client's Instagram compares with the accounts they compete with{lastChecked ? ` · checked ${fmtDate(lastChecked)}` : ""}. Numbers refresh nightly.</p>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center flex-wrap">
|
||||
<select id="comp-client" aria-label="Client" className="input h-9 w-auto" value={client} onChange={(e) => setParams({ client: e.target.value })}>
|
||||
{clients.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}
|
||||
</select>
|
||||
{data?.competitors.length > 0 && <Button icon={RefreshCw} loading={busy === "refresh"} onClick={refresh}>Refresh</Button>}
|
||||
<Button variant="primary" icon={Plus} onClick={() => setAdding(true)} disabled={data?.competitors.length >= 10}>Add competitor</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ErrorNote error={error} />
|
||||
|
||||
{!data ? (!error && <Spinner />) : !data.competitors.length ? (
|
||||
<div className="panel">
|
||||
<Empty icon={Swords} title="No competitors tracked yet" action={<Button variant="primary" icon={Plus} onClick={() => setAdding(true)}>Add competitor</Button>}>
|
||||
Add 3–5 accounts this client actually competes with: same area, same customers. Quorla then tracks their followers, posting rhythm, engagement and best posts every night.
|
||||
</Empty>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="panel overflow-x-auto">
|
||||
<table className="tbl min-w-[900px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account</th>
|
||||
<th className="text-right">Followers</th>
|
||||
<th className="text-right">30-day change</th>
|
||||
<th>Trend</th>
|
||||
<th className="text-right">Posts / week</th>
|
||||
<th className="text-right" title="Median likes + comments over the last 12 posts">Typical post</th>
|
||||
<th className="text-right">Engagement rate</th>
|
||||
<th>Formats (last 25)</th>
|
||||
<th><span className="sr-only">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.username} className={cx(r.own && "bg-brand-soft/60")}>
|
||||
<td>
|
||||
<div className="font-medium text-ink flex items-center gap-2">
|
||||
@{r.username}{r.own && <Badge tone="brand">This client</Badge>}{r.source === "suggested" && <Badge tone="neutral">AI suggested</Badge>}
|
||||
</div>
|
||||
{r.name && !r.own && <div className="text-[12px] text-muted">{r.name}{r.note ? ` · ${r.note}` : ""}</div>}
|
||||
</td>
|
||||
<td className="text-right num">{fmt(r.followers)}</td>
|
||||
<td className={cx("text-right num", r.growth30 > 0 ? "text-ok" : r.growth30 < 0 ? "text-bad" : "")}>
|
||||
{r.growth30 == null ? <span className="text-faint" title="Needs at least a day of history">—</span> : `${r.growth30 > 0 ? "+" : ""}${fmt(r.growth30)}`}
|
||||
</td>
|
||||
<td><Trend series={r.series} /></td>
|
||||
<td className="text-right num">{r.postsPerWeek ?? "—"}</td>
|
||||
<td className="text-right num">{fmt(r.avgEngagement)}</td>
|
||||
<td className="text-right num font-semibold">{r.engagementRate == null ? "—" : `${r.engagementRate}%`}</td>
|
||||
<td><FormatMix mix={r.formatMix} /></td>
|
||||
<td className="text-right whitespace-nowrap">
|
||||
<a href={`https://www.instagram.com/${r.username}/`} target="_blank" rel="noreferrer" className="inline-flex p-1.5 rounded hover:bg-canvas text-muted" aria-label={`Open @${r.username} on Instagram`}><ExternalLink className="size-4" /></a>
|
||||
{!r.own && <button type="button" onClick={() => remove(r.username)} disabled={busy === `rm-${r.username}`} className="inline-flex p-1.5 rounded hover:bg-bad-soft text-muted hover:text-bad" aria-label={`Stop tracking @${r.username}`}><Trash2 className="size-4" /></button>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-[1fr_1.4fr] gap-5">
|
||||
<Panel title="Engagement rate" subtitle="Typical post's likes + comments ÷ followers. Compares accounts of different sizes fairly.">
|
||||
<div style={{ height: Math.max(140, chart.length * 44) }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chart} layout="vertical" margin={{ top: 4, right: 48, bottom: 4, left: 8 }}>
|
||||
<CartesianGrid horizontal={false} stroke="#e4e7ec" />
|
||||
<XAxis type="number" tickFormatter={(v) => `${v}%`} tick={{ fontSize: 11, fill: "#6b6e78" }} />
|
||||
<YAxis type="category" dataKey="name" width={170} tick={{ fontSize: 12, fill: "#1f2024" }} tickFormatter={(v) => (v.length > 22 ? `${v.slice(0, 21)}…` : v)} />
|
||||
<Tooltip formatter={(v) => [`${v}%`, "Engagement rate"]} />
|
||||
<Bar dataKey="rate" radius={[0, 3, 3, 0]} isAnimationActive={false}>
|
||||
{chart.map((d) => <Cell key={d.name} fill={d.own ? "#fc6d45" : "#3b3778"} />)}
|
||||
<LabelList dataKey="rate" position="right" formatter={(v) => `${v}%`} style={{ fontSize: 11, fill: "#1f2024" }} />
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<p className="text-[12px] text-muted mt-2">Coral is this client; navy are competitors.</p>
|
||||
</Panel>
|
||||
|
||||
<Panel title="What works for them" subtitle="Each competitor's three best recent posts by likes + comments.">
|
||||
<div className="space-y-4">
|
||||
{data.competitors.map((c) => (
|
||||
<div key={c.username}>
|
||||
<div className="text-[13px] font-semibold mb-1.5">@{c.username}</div>
|
||||
{!c.topPosts.length ? <div className="text-[12.5px] text-muted">No posts read yet.</div> : (
|
||||
<ul className="grid sm:grid-cols-3 gap-2">
|
||||
{c.topPosts.map((p) => (
|
||||
<li key={p.id} className="rounded-md border border-line p-2.5 text-[12.5px] min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Badge tone="neutral">{p.type}</Badge>
|
||||
{p.permalink && <a href={p.permalink} target="_blank" rel="noreferrer" className="text-muted hover:text-ink" aria-label="Open post on Instagram"><ExternalLink className="size-3.5" /></a>}
|
||||
</div>
|
||||
<div className="num font-semibold mt-1.5">{fmt(p.likes)} likes · {fmt(p.comments)} comments</div>
|
||||
<div className="text-muted mt-0.5">{fmtDate(p.at)}</div>
|
||||
{p.caption && <p className="text-ink-2 mt-1 line-clamp-2">{p.caption}</p>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
{!data.you && <p className="text-[12px] text-muted">This client's own Instagram isn't linked or hasn't been refreshed yet, so it isn't in the comparison.</p>}
|
||||
</>
|
||||
)}
|
||||
|
||||
{adding && <AddDialog client={client} onClose={() => setAdding(false)} onAdded={() => { setAdding(false); load(); }} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,9 +1,36 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { EyeOff, Eye, MessageCircle, RefreshCw, Send } from "lucide-react";
|
||||
import { api } from "../api.js";
|
||||
import { Badge, Button, cx, Empty, ErrorNote, Panel, Spinner, timeAgo } from "../ui.jsx";
|
||||
import { Badge, Button, cx, Empty, ErrorNote, Panel, Spinner, Tabs, timeAgo } from "../ui.jsx";
|
||||
import { GoogleReviews, YouTubeComments } from "./InboxMore.jsx";
|
||||
|
||||
export default function Inbox() {
|
||||
const [tab, setTab] = useState("comments");
|
||||
const [all, setAll] = useState([]);
|
||||
const [other, setOther] = useState("");
|
||||
useEffect(() => { api("/api/overview").then((r) => { setAll(r.clients); setOther((c) => c || r.clients[0]?.slug || ""); }).catch(() => {}); }, []);
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Tabs value={tab} onChange={setTab} tabs={[{ value: "comments", label: "Instagram & Facebook" }, { value: "reviews", label: "Google reviews" }, { value: "youtube", label: "YouTube comments" }]} />
|
||||
{tab === "comments" ? <MetaComments /> : (
|
||||
<>
|
||||
<header className="flex items-end justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="text-[22px] font-extrabold">{tab === "reviews" ? "Google reviews" : "YouTube comments"}</h1>
|
||||
<p className="text-muted mt-0.5">{tab === "reviews" ? "Reviews on the client's Business Profile. Replying to every review helps local ranking." : "Latest comments on the client's YouTube videos."}</p>
|
||||
</div>
|
||||
<select id="inbox2-client" aria-label="Client" className="input h-9 w-auto" value={other} onChange={(e) => setOther(e.target.value)}>
|
||||
{all.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}
|
||||
</select>
|
||||
</header>
|
||||
{tab === "reviews" ? <GoogleReviews client={other} /> : <YouTubeComments client={other} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaComments() {
|
||||
const [clients, setClients] = useState([]);
|
||||
const [client, setClient] = useState("");
|
||||
const [days, setDays] = useState(14);
|
||||
|
||||
120
dashboard/web/src/pages/InboxMore.jsx
Normal file
120
dashboard/web/src/pages/InboxMore.jsx
Normal file
@ -0,0 +1,120 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { ExternalLink, MessageSquareText, Send, Star } from "lucide-react";
|
||||
import { api } from "../api.js";
|
||||
import { Badge, Button, cx, Empty, ErrorNote, fmt, Panel, Spinner, timeAgo } from "../ui.jsx";
|
||||
|
||||
function Stars({ n }) {
|
||||
if (n == null) return null;
|
||||
return (
|
||||
<span className="inline-flex items-center gap-0.5" role="img" aria-label={`${n} out of 5 stars`}>
|
||||
{[1, 2, 3, 4, 5].map((i) => <Star key={i} className={cx("size-3.5", i <= n ? "fill-[#f5a623] text-[#f5a623]" : "text-line-strong")} aria-hidden="true" />)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function NotConnected({ platform, client, error }) {
|
||||
return (
|
||||
<Empty icon={MessageSquareText} title={`${platform} isn't ready for this client`}
|
||||
action={<Link to={`/connections?client=${client}`} className="text-brand-deep underline text-[13px]">Open Connections</Link>}>
|
||||
{error?.message || `Connect ${platform} on the Connections page first.`}
|
||||
</Empty>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewRow({ r, client, onReplied }) {
|
||||
const [text, setText] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const send = async () => {
|
||||
setBusy(true); setError(null);
|
||||
try { const { reply } = await api("/api/inbox/reviews/reply", { method: "POST", body: { client, reviewId: r.id, text } }); onReplied(reply); setOpen(false); setText(""); }
|
||||
catch (e) { setError(e); }
|
||||
setBusy(false);
|
||||
};
|
||||
return (
|
||||
<li className="px-4 py-3.5 space-y-1.5">
|
||||
<div className="flex items-center gap-2 flex-wrap text-[13px]">
|
||||
<span className="font-semibold">{r.author}</span><Stars n={r.stars} /><span className="text-muted text-[12px]">{timeAgo(r.at)}</span>
|
||||
{!r.reply && <Badge tone={r.stars != null && r.stars <= 3 ? "bad" : "warn"}>No reply yet</Badge>}
|
||||
</div>
|
||||
{r.text ? <p className="text-[13.5px] text-ink-2 whitespace-pre-line">{r.text}</p> : <p className="text-[12.5px] text-muted italic">Rating only, no text.</p>}
|
||||
{r.reply && <div className="text-[13px] rounded-md bg-canvas border border-line px-3 py-2"><span className="text-[12px] text-muted">Your reply · {timeAgo(r.reply.at)}</span><p className="whitespace-pre-line">{r.reply.text}</p></div>}
|
||||
{!open ? (
|
||||
<Button size="sm" variant={r.reply ? "ghost" : "secondary"} onClick={() => { setOpen(true); setText(r.reply?.text || ""); }}>{r.reply ? "Edit reply" : "Reply"}</Button>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<label htmlFor={`rv-${r.id}`} className="sr-only">Reply to {r.author}</label>
|
||||
<textarea id={`rv-${r.id}`} className="input h-24 py-2" value={text} onChange={(e) => setText(e.target.value)} placeholder={r.stars != null && r.stars <= 3 ? "Thank them, own the problem, and offer a way to make it right (phone or email)." : "Thank them by name and mention what they liked."} />
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="primary" icon={Send} loading={busy} disabled={!text.trim()} onClick={send}>Post reply on Google</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setOpen(false)}>Cancel</Button>
|
||||
</div>
|
||||
<p className="text-[12px] text-muted">Replies are public on Google Maps and Search, under the business name.</p>
|
||||
<ErrorNote error={error} />
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function GoogleReviews({ client }) {
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [view, setView] = useState("unreplied");
|
||||
const load = useCallback(() => { setData(null); setError(null); api(`/api/inbox/reviews?client=${client}`).then(setData).catch((e) => { setError(e); setData(false); }); }, [client]);
|
||||
useEffect(() => { if (client) load(); }, [load, client]);
|
||||
const list = useMemo(() => (data?.reviews || []).filter((r) => view === "all" || !r.reply), [data, view]);
|
||||
const patch = (id, reply) => setData((d) => ({ ...d, reviews: d.reviews.map((r) => (r.id === id ? { ...r, reply } : r)) }));
|
||||
|
||||
if (data === null) return <Panel><Spinner label="Reading Google reviews…" /></Panel>;
|
||||
if (data === false) return <Panel><NotConnected platform="Google Business Profile" client={client} error={error} /></Panel>;
|
||||
const unreplied = data.reviews.filter((r) => !r.reply).length;
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 flex-wrap text-[13px]">
|
||||
<span className="font-medium">{data.location}</span>
|
||||
{data.average != null && <span className="inline-flex items-center gap-1.5"><Stars n={Math.round(data.average)} /><b className="num">{data.average.toFixed(1)}</b><span className="text-muted">from {fmt(data.total)} reviews</span></span>}
|
||||
<span className="ml-auto flex gap-1">
|
||||
{[["unreplied", `Needs reply (${unreplied})`], ["all", "All"]].map(([k, l]) => (
|
||||
<button key={k} type="button" onClick={() => setView(k)} className={cx("h-8 px-3 rounded-md text-[13px] font-medium", view === k ? "bg-brand-soft text-brand-deep" : "text-ink-2 hover:bg-canvas")}>{l}</button>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
<Panel>
|
||||
{!list.length ? <Empty icon={Star} title={view === "unreplied" ? "Every review has a reply" : "No reviews yet"}>Replying to every review, good and bad, helps the business rank in Google Maps.</Empty>
|
||||
: <ul className="divide-y divide-line">{list.map((r) => <ReviewRow key={r.id} r={r} client={client} onReplied={(reply) => patch(r.id, reply)} />)}</ul>}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function YouTubeComments({ client }) {
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
useEffect(() => { if (!client) return; setData(null); setError(null); api(`/api/inbox/youtube?client=${client}`).then(setData).catch((e) => { setError(e); setData(false); }); }, [client]);
|
||||
if (data === null) return <Panel><Spinner label="Reading YouTube comments…" /></Panel>;
|
||||
if (data === false) return <Panel><NotConnected platform="YouTube" client={client} error={error} /></Panel>;
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-[13px] text-muted">Latest comments on {data.channel}'s videos. Reply on YouTube for now: the button opens the comment.</p>
|
||||
<Panel>
|
||||
{!data.comments.length ? <Empty icon={MessageSquareText} title="No comments yet" /> : (
|
||||
<ul className="divide-y divide-line">
|
||||
{data.comments.map((c) => (
|
||||
<li key={c.id} className="px-4 py-3 flex gap-3 items-start">
|
||||
{c.avatar ? <img src={c.avatar} alt="" className="size-8 rounded-full" /> : <span className="size-8 rounded-full bg-canvas" aria-hidden="true" />}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[13px]"><span className="font-semibold">{c.author}</span> <span className="text-muted text-[12px]">{timeAgo(c.at)}{c.replies ? ` · ${c.replies} repl${c.replies === 1 ? "y" : "ies"}` : ""}{c.likes ? ` · ${c.likes} likes` : ""}</span></div>
|
||||
<p className="text-[13.5px] text-ink-2 whitespace-pre-line">{c.text}</p>
|
||||
</div>
|
||||
<a href={c.url} target="_blank" rel="noreferrer" className="shrink-0 inline-flex items-center gap-1 text-[12.5px] text-brand-deep hover:underline">Reply on YouTube <ExternalLink className="size-3.5" /></a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -32,7 +32,10 @@ export default function Library() {
|
||||
const [tab, setTab] = useState(isAdmin ? "submitted" : "changes");
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [dialog, setDialog] = useState(null); // {kind:'upload'} | {kind:'review'|'schedule'|'version', item}
|
||||
const [dialog, setDialog] = useState(null); // {kind:'upload'} | {kind:'review'|'schedule'|'version', item} | {kind:'bulk'}
|
||||
const [picked, setPicked] = useState([]);
|
||||
const canBulk = tab === "approved" && Boolean(client);
|
||||
useEffect(() => { setPicked([]); }, [client, tab]);
|
||||
|
||||
useEffect(() => { api("/api/overview").then((r) => setClients(r.clients)).catch(setError); }, []);
|
||||
const load = useCallback(() => api(`/api/library${client ? `?client=${client}` : ""}`).then(setData).catch(setError), [client]);
|
||||
@ -63,6 +66,8 @@ export default function Library() {
|
||||
</button>
|
||||
))}
|
||||
<Button variant="ghost" size="sm" icon={RefreshCw} onClick={load}>Refresh</Button>
|
||||
{canBulk && <Button size="sm" variant="primary" icon={CalendarPlus} disabled={!picked.length} onClick={() => setDialog({ kind: "bulk" })} className="ml-auto">Schedule {picked.length || ""} selected</Button>}
|
||||
{tab === "approved" && !client && <span className="text-[12px] text-muted ml-auto">Pick a client to schedule several at once.</span>}
|
||||
</div>
|
||||
<ErrorNote error={error} />
|
||||
{!data ? <Spinner /> : !items.length ? (
|
||||
@ -70,7 +75,12 @@ export default function Library() {
|
||||
) : (
|
||||
<div className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(260px,1fr))]">
|
||||
{items.map((i) => (
|
||||
<div key={i.id} className="panel overflow-hidden flex flex-col">
|
||||
<div key={i.id} className={cx("panel overflow-hidden flex flex-col relative", picked.includes(i.id) && "ring-2 ring-brand")}>
|
||||
{canBulk && (
|
||||
<label className="absolute top-2 left-2 z-10 bg-white/95 rounded px-2 py-1 text-[12px] flex items-center gap-1.5 shadow-sm">
|
||||
<input type="checkbox" checked={picked.includes(i.id)} onChange={() => setPicked((xs) => (xs.includes(i.id) ? xs.filter((x) => x !== i.id) : [...xs, i.id]))} aria-label={`Select ${i.title}`} />Select
|
||||
</label>
|
||||
)}
|
||||
<Preview item={i} />
|
||||
<div className="p-3.5 flex-1 flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
@ -96,6 +106,7 @@ export default function Library() {
|
||||
{dialog?.kind === "upload" && <UploadDialog clients={clients} defaultClient={client} onClose={() => setDialog(null)} onDone={() => { setTab("submitted"); done(); }} />}
|
||||
{dialog?.kind === "version" && <VersionDialog item={dialog.item} onClose={() => setDialog(null)} onDone={done} />}
|
||||
{dialog?.kind === "review" && <ReviewDialog item={dialog.item} onClose={() => setDialog(null)} onDone={done} />}
|
||||
{dialog?.kind === "bulk" && <BulkDialog client={client} items={(data?.items || []).filter((i) => picked.includes(i.id))} onClose={() => setDialog(null)} onDone={() => { setPicked([]); done(); }} />}
|
||||
{dialog?.kind === "schedule" && <ScheduleDialog item={dialog.item} clientName={name(dialog.item.client)} onClose={() => setDialog(null)} onDone={done} />}
|
||||
</div>
|
||||
);
|
||||
@ -129,13 +140,23 @@ function FilePicker({ files, setFiles, accept = "image/jpeg,image/png,video/mp4,
|
||||
}
|
||||
|
||||
function UploadDialog({ clients, defaultClient, onClose, onDone }) {
|
||||
const [f, setF] = useState({ client: defaultClient || clients[0]?.slug || "", title: "", format: "IMAGE", captionIdea: "", notes: "", taskRef: "", forDate: "" });
|
||||
const [f, setF] = useState({ client: defaultClient || clients[0]?.slug || "", title: "", format: "IMAGE", captionIdea: "", notes: "", taskRef: "", forDate: "", planItem: "" });
|
||||
const [planned, setPlanned] = useState([]);
|
||||
const [files, setFiles] = useState([]);
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [progress, setProgress] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const set = (k, v) => setF((s) => ({ ...s, [k]: v }));
|
||||
useEffect(() => { api("/api/team").then((r) => setTasks(r.tasks || [])).catch(() => {}); }, []);
|
||||
useEffect(() => {
|
||||
if (!f.client) return;
|
||||
const from = new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10), to = new Date(Date.now() + 60 * 86400000).toISOString().slice(0, 10);
|
||||
api(`/api/calendar?client=${f.client}&from=${from}&to=${to}`).then((r) => setPlanned(r.items.filter((i) => i.kind === "plan" && ["idea", "brief", "production"].includes(i.stage)))).catch(() => setPlanned([]));
|
||||
}, [f.client]);
|
||||
const pickPlan = (id) => {
|
||||
const p = planned.find((x) => x.id === id);
|
||||
setF((s) => ({ ...s, planItem: id, ...(p ? { title: s.title || p.title, format: p.format === "TEXT" ? s.format : p.format, forDate: p.date, captionIdea: s.captionIdea || p.caption || "", taskRef: p.taskRef || s.taskRef } : {}) }));
|
||||
};
|
||||
useEffect(() => { if (files.length > 1 && f.format !== "CAROUSEL") set("format", "CAROUSEL"); else if (files.length === 1 && isVid(files[0].name) && f.format === "IMAGE") set("format", "REELS"); }, [files]); // eslint-disable-line
|
||||
|
||||
const submit = async () => {
|
||||
@ -148,8 +169,16 @@ function UploadDialog({ clients, defaultClient, onClose, onDone }) {
|
||||
};
|
||||
return (
|
||||
<Dialog open onClose={onClose} title="Upload finished work" wide footer={<><Button onClick={onClose}>Cancel</Button><Button variant="primary" loading={!!progress} disabled={!f.client || !files.length} onClick={submit}>Submit for review</Button></>}>
|
||||
{planned.length > 0 && (
|
||||
<Field label="For calendar item (optional)" hint="Links the upload to its card on the content calendar and fills in the details.">
|
||||
<select id="up-plan" className="input" value={f.planItem} onChange={(e) => pickPlan(e.target.value)}>
|
||||
<option value="">Not from the calendar</option>
|
||||
{planned.map((p) => <option key={p.id} value={p.id}>{p.date} · {p.title}{p.assignee ? ` (${p.assignee})` : ""}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="Client"><select className="input" value={f.client} onChange={(e) => set("client", e.target.value)}>{clients.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}</select></Field>
|
||||
<Field label="Client"><select className="input" value={f.client} onChange={(e) => { set("client", e.target.value); set("planItem", ""); }}>{clients.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}</select></Field>
|
||||
<Field label="Format"><select className="input" value={f.format} onChange={(e) => set("format", e.target.value)}>{FORMATS.map(([v, l]) => <option key={v} value={v}>{l}</option>)}</select></Field>
|
||||
<Field label="Title" hint="What is it? e.g. Diwali sweets box reel"><input className="input" value={f.title} onChange={(e) => set("title", e.target.value)} /></Field>
|
||||
<Field label="Planned for (optional)"><input type="date" className="input" value={f.forDate} onChange={(e) => set("forDate", e.target.value)} /></Field>
|
||||
@ -250,3 +279,54 @@ function ScheduleDialog({ item, clientName, onClose, onDone }) {
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const BULK_PLATFORMS = { instagram: "Instagram", facebook: "Facebook", gbp: "Google Business", linkedin: "LinkedIn", threads: "Threads" };
|
||||
|
||||
/** Schedule several approved items: one per slot from a start date, every N days, cycling through the times. Drafts only. */
|
||||
function BulkDialog({ client, items, onClose, onDone }) {
|
||||
const available = useClientPlatforms(client);
|
||||
const tomorrow = new Date(Date.now() + 86400000).toISOString().slice(0, 10);
|
||||
const [f, setF] = useState({ startDate: tomorrow, everyDays: 2, times: "", platforms: [] });
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [result, setResult] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const set = (k, v) => setF((s) => ({ ...s, [k]: v }));
|
||||
useEffect(() => { api(`/api/schedule/suggest?client=${client}`).then((r) => set("times", r.times.join(", "))).catch(() => set("times", "10:00")); }, [client]);
|
||||
const usable = (available.list || []).filter((p) => BULK_PLATFORMS[p.id]);
|
||||
useEffect(() => { if (!available.loading) set("platforms", usable.filter((p) => ["instagram", "facebook"].includes(p.id)).map((p) => p.id)); }, [available.loading]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
const go = async () => {
|
||||
setBusy(true); setError(null);
|
||||
try { setResult(await api("/api/library/bulk-schedule", { method: "POST", body: { client, ids: items.map((i) => i.id), startDate: f.startDate, everyDays: Number(f.everyDays), times: f.times.split(/[\s,]+/).filter(Boolean), platforms: f.platforms } })); }
|
||||
catch (e) { setError(e); }
|
||||
setBusy(false);
|
||||
};
|
||||
return (
|
||||
<Dialog open onClose={result ? onDone : onClose} title={`Schedule ${items.length} item${items.length === 1 ? "" : "s"}`} wide
|
||||
footer={result ? <Button variant="primary" onClick={onDone}>Done</Button> : <><Button onClick={onClose}>Cancel</Button><Button variant="primary" loading={busy} disabled={!f.platforms.length || !f.startDate} onClick={go}>Create {items.length} draft{items.length === 1 ? "" : "s"}</Button></>}>
|
||||
{result ? (
|
||||
<div className="space-y-2 text-[13px]">
|
||||
<p><b>{result.created.length}</b> draft{result.created.length === 1 ? "" : "s"} created. They're in Publishing, waiting for approval.</p>
|
||||
<ul className="space-y-1">{result.created.map((c) => <li key={c.id}>{c.localTime} · {c.title}{c.warnings?.length ? <span className="text-warn"> · {c.warnings.length} warning{c.warnings.length === 1 ? "" : "s"}</span> : null}</li>)}</ul>
|
||||
{result.errors.map((e) => <div key={e.id} className="text-bad">{e.title}: {e.error}</div>)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="grid sm:grid-cols-3 gap-3">
|
||||
<Field label="Start on"><input id="bulk-start" type="date" className="input" value={f.startDate} onChange={(e) => set("startDate", e.target.value)} /></Field>
|
||||
<Field label="One post every"><select id="bulk-every" className="input" value={f.everyDays} onChange={(e) => set("everyDays", e.target.value)}>{[1, 2, 3, 4, 7].map((n) => <option key={n} value={n}>{n === 1 ? "day" : n === 7 ? "week" : `${n} days`}</option>)}</select></Field>
|
||||
<Field label="At (client time)" hint="Comma-separated; used in turn. Pre-filled with this client's best times."><input id="bulk-times" className="input" value={f.times} onChange={(e) => set("times", e.target.value)} /></Field>
|
||||
</div>
|
||||
<Field label="Post to" hint="Platforms that need extra details per post (YouTube title, TikTok audience) are scheduled one at a time instead.">
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1.5 text-[13px]">
|
||||
{usable.length ? usable.map((p) => (
|
||||
<label key={p.id} htmlFor={`bulk-pl-${p.id}`} className="flex items-center gap-1.5"><input id={`bulk-pl-${p.id}`} type="checkbox" checked={f.platforms.includes(p.id)} onChange={() => set("platforms", f.platforms.includes(p.id) ? f.platforms.filter((x) => x !== p.id) : [...f.platforms, p.id])} />{BULK_PLATFORMS[p.id]}</label>
|
||||
)) : <span className="text-muted">No connected platforms for this client.</span>}
|
||||
</div>
|
||||
</Field>
|
||||
<p className="text-[12.5px] text-muted">Items with their own planned date keep it. Each draft uses the item's caption idea; edit captions in Publishing before approving.</p>
|
||||
<ErrorNote error={error} />
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
95
dashboard/web/src/pages/LinkInBio.jsx
Normal file
95
dashboard/web/src/pages/LinkInBio.jsx
Normal file
@ -0,0 +1,95 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ArrowDown, ArrowUp, Check, Copy, ExternalLink, Plus, Save, Trash2 } from "lucide-react";
|
||||
import { api } from "../api.js";
|
||||
import { useAuth } from "../App.jsx";
|
||||
import { Button, ErrorNote, Field, fmt, Panel, Spinner } from "../ui.jsx";
|
||||
|
||||
/** Client page tab: the client's link-in-bio page, its links and click counts. */
|
||||
export default function LinkInBio({ slug }) {
|
||||
const { isAdmin } = useAuth();
|
||||
const [page, setPage] = useState(null);
|
||||
const [stats, setStats] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
useEffect(() => { api(`/api/bio/${slug}`).then((r) => { setPage(r.page); setStats(r.stats); }).catch(setError); }, [slug]);
|
||||
if (!page) return error ? <ErrorNote error={error} /> : <Spinner />;
|
||||
|
||||
const set = (k, v) => { setSaved(false); setPage((p) => ({ ...p, [k]: v })); };
|
||||
const setLink = (i, k, v) => set("links", page.links.map((l, j) => (j === i ? { ...l, [k]: v } : l)));
|
||||
const move = (i, d) => { const ls = [...page.links]; const [x] = ls.splice(i, 1); ls.splice(i + d, 0, x); set("links", ls); };
|
||||
const save = async () => {
|
||||
setBusy(true); setError(null);
|
||||
try { const r = await api(`/api/bio/${slug}`, { method: "PUT", body: page }); setPage(r.page); setStats(r.stats); setSaved(true); } catch (e) { setError(e); }
|
||||
setBusy(false);
|
||||
};
|
||||
const url = `${location.origin}/l/${page.path}`;
|
||||
const clicks = Object.fromEntries((stats?.links || []).map((l) => [l.id, l.clicks]));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Panel title="Link-in-bio page" subtitle="One link for the Instagram or TikTok bio, with the client's important links and latest posts. Every click is counted.">
|
||||
<div className="space-y-4">
|
||||
<label htmlFor="bio-enabled" className="flex items-center gap-2 text-[13.5px] font-medium">
|
||||
<input id="bio-enabled" type="checkbox" checked={page.enabled} disabled={!isAdmin} onChange={(e) => set("enabled", e.target.checked)} /> Page is live
|
||||
</label>
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
<Field label="Page address" hint={url}>
|
||||
<input id="bio-path" className="input" value={page.path} disabled={!isAdmin} onChange={(e) => set("path", e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""))} />
|
||||
</Field>
|
||||
<Field label="Name shown (optional)" hint="Defaults to the Instagram name."><input id="bio-title" className="input" value={page.title} disabled={!isAdmin} onChange={(e) => set("title", e.target.value)} /></Field>
|
||||
</div>
|
||||
<Field label="Short bio (optional)" hint="Defaults to the Instagram bio."><textarea id="bio-bio" className="input h-16 py-2" maxLength={300} value={page.bio} disabled={!isAdmin} onChange={(e) => set("bio", e.target.value)} /></Field>
|
||||
{page.enabled && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-line bg-canvas px-2.5 py-1.5">
|
||||
<code className="font-mono text-[12.5px] flex-1 truncate">{url}</code>
|
||||
<button type="button" className="p-1.5 rounded hover:bg-white text-muted" aria-label="Copy page link" onClick={() => navigator.clipboard?.writeText(url).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1500); })}>{copied ? <Check className="size-4 text-ok" /> : <Copy className="size-4" />}</button>
|
||||
<a href={url} target="_blank" rel="noreferrer" className="p-1.5 rounded hover:bg-white text-muted" aria-label="Open the page"><ExternalLink className="size-4" /></a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Links" subtitle={stats ? `${fmt(stats.total)} clicks in the last ${stats.days} days.` : null}>
|
||||
<div className="space-y-2">
|
||||
{page.links.map((l, i) => (
|
||||
<div key={l.id || i} className="grid grid-cols-[auto_1fr_1.4fr_auto_auto] gap-2 items-center">
|
||||
<div className="flex flex-col">
|
||||
<button type="button" disabled={!isAdmin || i === 0} onClick={() => move(i, -1)} className="p-0.5 text-muted disabled:opacity-30" aria-label={`Move ${l.title || "link"} up`}><ArrowUp className="size-3.5" /></button>
|
||||
<button type="button" disabled={!isAdmin || i === page.links.length - 1} onClick={() => move(i, 1)} className="p-0.5 text-muted disabled:opacity-30" aria-label={`Move ${l.title || "link"} down`}><ArrowDown className="size-3.5" /></button>
|
||||
</div>
|
||||
<input aria-label="Button text" className="input" value={l.title} disabled={!isAdmin} placeholder="Order online" onChange={(e) => setLink(i, "title", e.target.value)} />
|
||||
<input aria-label="Link" className="input" value={l.url} disabled={!isAdmin} placeholder="https://…" onChange={(e) => setLink(i, "url", e.target.value)} />
|
||||
<span className="text-[12px] text-muted num w-16 text-right">{l.id ? `${fmt(clicks[l.id] || 0)} clicks` : "new"}</span>
|
||||
{isAdmin && <button type="button" onClick={() => set("links", page.links.filter((_, j) => j !== i))} className="p-1.5 rounded hover:bg-bad-soft text-muted hover:text-bad" aria-label={`Remove ${l.title || "link"}`}><Trash2 className="size-4" /></button>}
|
||||
</div>
|
||||
))}
|
||||
{isAdmin && <Button size="sm" icon={Plus} onClick={() => set("links", [...page.links, { title: "", url: "" }])}>Add a link</Button>}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Look and tracking">
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{[["bg", "Background"], ["fg", "Text"], ["button", "Buttons"], ["buttonText", "Button text"]].map(([k, l]) => (
|
||||
<label key={k} htmlFor={`bio-c-${k}`} className="flex items-center gap-2 text-[13px]">
|
||||
<input id={`bio-c-${k}`} type="color" className="size-8 rounded border border-line" value={page.theme[k]} disabled={!isAdmin} onChange={(e) => set("theme", { ...page.theme, [k]: e.target.value })} />{l}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<label htmlFor="bio-posts" className="flex items-center gap-2 text-[13px]"><input id="bio-posts" type="checkbox" checked={page.showPosts} disabled={!isAdmin} onChange={(e) => set("showPosts", e.target.checked)} />Show the latest 6 Instagram posts</label>
|
||||
<label htmlFor="bio-utm" className="flex items-center gap-2 text-[13px]"><input id="bio-utm" type="checkbox" checked={page.utm} disabled={!isAdmin} onChange={(e) => set("utm", e.target.checked)} />Add UTM tags to website links (so Google Analytics shows visits came from the bio)</label>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{isAdmin && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="primary" icon={Save} loading={busy} onClick={save}>Save</Button>
|
||||
{saved && <span className="text-[12.5px] text-ok">Saved.{page.enabled ? " The page is live." : ""}</span>}
|
||||
</div>
|
||||
)}
|
||||
<ErrorNote error={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -2,7 +2,7 @@ import { useState } from "react";
|
||||
import { api } from "../api.js";
|
||||
import { Button, ErrorNote, Field } from "../ui.jsx";
|
||||
|
||||
export default function Login({ onSignedIn, setupNeeded, agency }) {
|
||||
export default function Login({ onSignedIn, setupNeeded, agency, hasLogo }) {
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
@ -17,7 +17,7 @@ export default function Login({ onSignedIn, setupNeeded, agency }) {
|
||||
if (setupNeeded && password !== confirm) return setError(new Error("The two passwords don't match."));
|
||||
setBusy(true);
|
||||
try {
|
||||
if (setupNeeded) await api("/api/auth/setup", { method: "POST", body: { name, email, password } });
|
||||
if (setupNeeded) await api("/api/auth/setup", { method: "POST", body: { name, email, password, setupToken: new URLSearchParams(location.search).get("setup") || "" } });
|
||||
else await api("/api/auth/login", { method: "POST", body: { email, password } });
|
||||
onSignedIn();
|
||||
} catch (err) {
|
||||
@ -42,7 +42,7 @@ export default function Login({ onSignedIn, setupNeeded, agency }) {
|
||||
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<form onSubmit={submit} className="w-full max-w-[340px]">
|
||||
<img src="/brand/logo.png" alt="Quorla" className="h-[36px] w-auto mb-8" />
|
||||
{hasLogo ? <img src="/brand/agency-logo" alt={agency} className="h-[40px] w-auto max-w-[220px] object-contain mb-8" /> : <img src="/brand/logo.png" alt="Quorla" className="h-[36px] w-auto mb-8" />}
|
||||
<h2 className="text-[20px] font-extrabold">{setupNeeded ? "Create the admin account" : "Sign in"}</h2>
|
||||
<p className="text-muted text-[13px] mt-1 mb-6">
|
||||
{setupNeeded ? "First run: this person can add the rest of the team from Settings." : "Use your agency email."}
|
||||
|
||||
44
dashboard/web/src/pages/MonthlyReport.jsx
Normal file
44
dashboard/web/src/pages/MonthlyReport.jsx
Normal file
@ -0,0 +1,44 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { FileBarChart, Mail } from "lucide-react";
|
||||
import { api } from "../api.js";
|
||||
import { useAuth } from "../App.jsx";
|
||||
import { Button, ErrorNote, Field, Panel } from "../ui.jsx";
|
||||
|
||||
/** Client page → Documents: build the monthly report from real numbers, email it, or have it done automatically. */
|
||||
export default function MonthlyReport({ slug, onBuilt }) {
|
||||
const { isAdmin } = useAuth();
|
||||
const [info, setInfo] = useState(null);
|
||||
const [month, setMonth] = useState("");
|
||||
const [busy, setBusy] = useState(null);
|
||||
const [msg, setMsg] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => { api(`/api/clients/${slug}/monthly`).then((r) => { setInfo(r); setMonth(r.lastMonth); }).catch(setError); }, [slug]);
|
||||
if (!info) return null;
|
||||
|
||||
const run = async (what, fn) => { setBusy(what); setError(null); setMsg(null); try { await fn(); } catch (e) { setError(e); } setBusy(null); };
|
||||
const build = () => run("build", async () => { const r = await api(`/api/clients/${slug}/monthly/build`, { method: "POST", body: { month } }); setMsg(`Created ${r.pdf}. It's in the list below.`); onBuilt?.(r); });
|
||||
const email = () => run("email", async () => { const r = await api(`/api/clients/${slug}/monthly/email`, { method: "POST", body: { month } }); setMsg(`Emailed to ${r.to}.`); });
|
||||
const toggle = (k) => run(k, async () => { const r = await api(`/api/clients/${slug}/monthly`, { method: "PUT", body: { [k]: !info.settings[k] } }); setInfo({ ...info, settings: r.settings }); });
|
||||
|
||||
return (
|
||||
<Panel title="Monthly report" subtitle="Built from the client's real numbers: posts and results, follower change, best posts, competitors and next month's plan. Exported as the branded PDF.">
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<Field label="Month"><input id="mr-month" type="month" className="input w-auto" value={month} onChange={(e) => setMonth(e.target.value)} /></Field>
|
||||
<Button icon={FileBarChart} loading={busy === "build"} onClick={build} disabled={!month}>Create report</Button>
|
||||
{isAdmin && <Button icon={Mail} loading={busy === "email"} onClick={email} disabled={!month || !info.email}>Email to client</Button>}
|
||||
</div>
|
||||
{!info.email && isAdmin && <p className="text-[12.5px] text-muted">Emailing needs SMTP set up on the server (see Settings → Notifications).</p>}
|
||||
{isAdmin && (
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-2 text-[13px]">
|
||||
<label htmlFor="mr-auto" className="flex items-center gap-2"><input id="mr-auto" type="checkbox" checked={info.settings.auto} disabled={busy === "auto"} onChange={() => toggle("auto")} />Create it automatically on the 1st of each month</label>
|
||||
<label htmlFor="mr-email" className="flex items-center gap-2"><input id="mr-email" type="checkbox" checked={info.settings.email} disabled={!info.settings.auto || busy === "email"} onChange={() => toggle("email")} />…and email it to the client</label>
|
||||
</div>
|
||||
)}
|
||||
{msg && <div className="text-[12.5px] text-ok">{msg}</div>}
|
||||
<ErrorNote error={error} />
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@ -5,6 +5,7 @@ import { api } from "../api.js";
|
||||
import { useAuth } from "../App.jsx";
|
||||
import { Badge, Button, cx, Empty, ErrorNote, fmt, fmtDate, Panel, Spark, Spinner, Stat, StatCell, StatRow, timeAgo } from "../ui.jsx";
|
||||
import AddClientDialog from "./AddClientDialog.jsx";
|
||||
import GettingStarted from "../GettingStarted.jsx";
|
||||
|
||||
const LEVEL = {
|
||||
bad: { icon: CircleAlert, cls: "text-bad" },
|
||||
@ -64,6 +65,7 @@ export default function Overview() {
|
||||
</header>
|
||||
|
||||
<ErrorNote error={error} />
|
||||
<GettingStarted />
|
||||
|
||||
{t && (
|
||||
<StatRow className="grid-cols-5">
|
||||
|
||||
194
dashboard/web/src/pages/Published.jsx
Normal file
194
dashboard/web/src/pages/Published.jsx
Normal file
@ -0,0 +1,194 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ExternalLink, ImageOff, RefreshCw, Send } from "lucide-react";
|
||||
import { api } from "../api.js";
|
||||
import { useAuth } from "../App.jsx";
|
||||
import { Badge, Button, cx, Empty, ErrorNote, fmt, fmtDate, Spinner, Stat, StatCell, StatRow, timeAgo } from "../ui.jsx";
|
||||
|
||||
const PLATFORMS = {
|
||||
instagram: { name: "Instagram", color: "#d62976" },
|
||||
facebook: { name: "Facebook", color: "#1877f2" },
|
||||
youtube: { name: "YouTube", color: "#ff0000" },
|
||||
tiktok: { name: "TikTok", color: "#010101" },
|
||||
gbp: { name: "Google Business", color: "#4285f4" },
|
||||
linkedin: { name: "LinkedIn", color: "#0a66c2" },
|
||||
pinterest: { name: "Pinterest", color: "#e60023" },
|
||||
threads: { name: "Threads", color: "#000000" },
|
||||
reddit: { name: "Reddit", color: "#ff4500" },
|
||||
};
|
||||
const COLS = [["reach", "Reach"], ["views", "Views"], ["likes", "Likes"], ["comments", "Comments"], ["saves", "Saves"], ["shares", "Shares"]];
|
||||
const SORTS = { newest: "Newest first", interactions: "Most interactions", reach: "Most reach", views: "Most views" };
|
||||
const PAGE = 50;
|
||||
|
||||
const isVideo = (m) => /\.(mp4|mov|m4v|webm)$/i.test(String(m || ""));
|
||||
|
||||
function Thumb({ item }) {
|
||||
const [broken, setBroken] = useState(false);
|
||||
const local = item.media ? `/api/schedule/media?path=${encodeURIComponent(item.media)}` : null;
|
||||
const p = PLATFORMS[item.platform] || { name: item.platform, color: "#6b6e78" };
|
||||
const box = "size-14 rounded-md shrink-0 overflow-hidden bg-canvas border border-line";
|
||||
if (!broken && item.thumb) return <img src={item.thumb} alt="" loading="lazy" className={cx(box, "object-cover")} onError={() => setBroken(true)} />;
|
||||
if (local && isVideo(item.media)) return <video src={local} muted preload="metadata" className={cx(box, "object-cover bg-black")} />;
|
||||
if (local) return <img src={local} alt="" loading="lazy" className={cx(box, "object-cover")} />;
|
||||
return (
|
||||
<div className={cx(box, "grid place-items-center text-white font-bold text-[13px]")} style={{ background: p.color }} aria-hidden="true">
|
||||
{broken ? <ImageOff className="size-4 opacity-80" /> : p.name[0]}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Published() {
|
||||
const { isAdmin } = useAuth();
|
||||
const [clients, setClients] = useState([]);
|
||||
const [f, setF] = useState({ client: "", platform: "", days: 30, sort: "newest" });
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [shown, setShown] = useState(PAGE);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [best, setBest] = useState(null);
|
||||
const set = (k, v) => { setF((s) => ({ ...s, [k]: v })); setShown(PAGE); };
|
||||
|
||||
useEffect(() => { api("/api/overview").then((r) => setClients(r.clients)).catch(() => {}); }, []);
|
||||
const load = useCallback(() => {
|
||||
const q = new URLSearchParams({ days: String(f.days) });
|
||||
if (f.client) q.set("client", f.client);
|
||||
if (f.platform) q.set("platform", f.platform);
|
||||
setError(null);
|
||||
return api(`/api/published?${q}`).then(setData).catch(setError);
|
||||
}, [f.client, f.platform, f.days]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
useEffect(() => { setBest(null); if (f.client) api(`/api/schedule/suggest?client=${f.client}`).then(setBest).catch(() => {}); }, [f.client]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const list = [...(data?.items || [])];
|
||||
const key = f.sort === "interactions" ? (i) => i.interactions ?? -1 : f.sort === "reach" ? (i) => i.metrics.reach ?? -1 : f.sort === "views" ? (i) => i.metrics.views ?? -1 : null;
|
||||
if (key) list.sort((a, b) => key(b) - key(a));
|
||||
return list;
|
||||
}, [data, f.sort]);
|
||||
|
||||
const refreshStats = async () => {
|
||||
setRefreshing(true);
|
||||
try { await api("/api/published/refresh", { method: "POST" }); await load(); } catch (e) { setError(e); }
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
const t = data?.totals;
|
||||
const platformsInData = useMemo(() => [...new Set((data?.items || []).map((i) => i.platform))], [data]);
|
||||
const oldestSnap = data ? Object.values(data.snapshotsAt).filter(Boolean).sort()[0] : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<header className="flex items-end justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="text-[22px] font-extrabold">Published</h1>
|
||||
<p className="text-muted mt-0.5">
|
||||
Everything that went out on your clients' accounts, with its numbers
|
||||
{oldestSnap ? ` · Instagram and Facebook as of ${timeAgo(oldestSnap)}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
{isAdmin && <Button icon={RefreshCw} loading={refreshing} onClick={refreshStats}>Refresh YouTube numbers</Button>}
|
||||
</header>
|
||||
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<select id="pub-client" aria-label="Client" className="input h-9 w-auto" value={f.client} onChange={(e) => set("client", e.target.value)}>
|
||||
<option value="">All clients</option>
|
||||
{clients.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}
|
||||
</select>
|
||||
<select id="pub-platform" aria-label="Platform" className="input h-9 w-auto" value={f.platform} onChange={(e) => set("platform", e.target.value)}>
|
||||
<option value="">All platforms</option>
|
||||
{Object.entries(PLATFORMS).filter(([id]) => !data || platformsInData.includes(id) || f.platform === id).map(([id, p]) => <option key={id} value={id}>{p.name}</option>)}
|
||||
</select>
|
||||
<div className="inline-flex rounded-md border border-line-strong bg-white p-0.5" role="group" aria-label="Period">
|
||||
{[7, 30, 90].map((d) => (
|
||||
<button key={d} type="button" onClick={() => set("days", d)} aria-pressed={f.days === d}
|
||||
className={cx("h-8 px-3 rounded text-[13px] font-medium", f.days === d ? "bg-brand-soft text-brand-deep" : "text-ink-2 hover:bg-canvas")}>
|
||||
{d} days
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<select id="pub-sort" aria-label="Sort" className="input h-9 w-auto ml-auto" value={f.sort} onChange={(e) => set("sort", e.target.value)}>
|
||||
{Object.entries(SORTS).map(([k, l]) => <option key={k} value={k}>{l}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<ErrorNote error={error} />
|
||||
|
||||
{t && (
|
||||
<StatRow className="grid-cols-2 md:grid-cols-4">
|
||||
<StatCell><Stat label="Posts" value={fmt(t.posts)} note={`${fmt(t.viaQuorla)} sent through Quorla`} /></StatCell>
|
||||
<StatCell><Stat label="Accounts reached" value={fmt(t.reach)} note="Instagram, summed per post" /></StatCell>
|
||||
<StatCell><Stat label="Interactions" value={fmt(t.interactions)} note="likes, comments, saves, shares" /></StatCell>
|
||||
<StatCell><Stat label="Per post" value={fmt(t.avgInteractions)} note="average interactions" /></StatCell>
|
||||
</StatRow>
|
||||
)}
|
||||
|
||||
{best && (
|
||||
<div className="panel px-4 py-3 text-[13px] flex flex-wrap items-center gap-x-4 gap-y-1.5">
|
||||
<span className="font-semibold">Best times to post</span>
|
||||
{best.best?.slots?.length ? best.best.slots.map((s) => (
|
||||
<span key={`${s.day}${s.window}`} className="inline-flex items-center gap-1.5"><b>{s.day} {s.time}</b><span className="text-ok">+{s.lift}% reach</span><span className="text-faint">({s.posts} posts)</span></span>
|
||||
)) : <span className="text-muted">Not enough Instagram posts with reach yet (needs 8; has {best.best?.basedOn ?? 0}). Using general times: {best.times.join(", ")}.</span>}
|
||||
{best.best?.slots?.length > 0 && <span className="text-[12px] text-muted ml-auto">From the last 4 months of Instagram posts, {best.timezone.replace("_", " ")} time. An early signal, not a rule.</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!data && !error ? <Spinner /> : data && !rows.length ? (
|
||||
<div className="panel"><Empty icon={Send} title="Nothing published in this period">Try a longer period or another client. Instagram and Facebook posts appear after the next numbers refresh.</Empty></div>
|
||||
) : data && (
|
||||
<div className="panel overflow-x-auto">
|
||||
<table className="tbl min-w-[960px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Post</th>
|
||||
<th>Platform</th>
|
||||
{COLS.map(([k, l]) => <th key={k} className="text-right">{l}</th>)}
|
||||
<th className="text-right">Interactions</th>
|
||||
<th><span className="sr-only">Open</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.slice(0, shown).map((i) => {
|
||||
const p = PLATFORMS[i.platform] || { name: i.platform, color: "#6b6e78" };
|
||||
return (
|
||||
<tr key={i.key}>
|
||||
<td className="max-w-[420px]">
|
||||
<div className="flex gap-3 items-start">
|
||||
<Thumb item={i} />
|
||||
<div className="min-w-0">
|
||||
<div className="text-[13px] text-ink line-clamp-2">{i.caption || <span className="text-faint">No caption</span>}</div>
|
||||
<div className="text-[12px] text-muted mt-1 flex flex-wrap gap-x-2">
|
||||
<span className="font-medium text-ink-2">{i.clientName}</span>
|
||||
<span>{i.format}</span>
|
||||
<span>{fmtDate(i.at, { month: "short", day: "numeric", year: "numeric" })}</span>
|
||||
{i.viaQuorla && <Badge tone="brand">via Quorla</Badge>}
|
||||
</div>
|
||||
{i.note && <div className="text-[12px] text-muted mt-0.5">{i.note}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap">
|
||||
<span className="inline-flex items-center gap-1.5 text-[13px]"><span className="size-2 rounded-full" style={{ background: p.color }} aria-hidden="true" />{p.name}</span>
|
||||
</td>
|
||||
{COLS.map(([k]) => <td key={k} className="text-right num text-[13px]">{fmt(i.metrics[k])}</td>)}
|
||||
<td className="text-right num font-semibold">{fmt(i.interactions)}</td>
|
||||
<td className="text-right">
|
||||
{i.permalink && <a href={i.permalink} target="_blank" rel="noreferrer" className="inline-flex p-1.5 rounded hover:bg-canvas text-muted" aria-label={`Open on ${p.name}`}><ExternalLink className="size-4" /></a>}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{rows.length > shown && (
|
||||
<div className="border-t border-line p-3 text-center">
|
||||
<Button size="sm" onClick={() => setShown((n) => n + PAGE)}>Show {Math.min(PAGE, rows.length - shown)} more of {fmt(rows.length - shown)}</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[12px] text-muted">
|
||||
Instagram and Facebook numbers refresh nightly with each client's snapshot. Reach and saves aren't available for Facebook posts.
|
||||
YouTube counts refresh nightly{data?.statsAt ? ` (last ${timeAgo(data.statsAt)})` : ""}. TikTok and Google Business Profile don't share per-post numbers with Quorla yet.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,10 +1,12 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { CalendarClock, Check, Image as ImageIcon, Plus, RotateCcw, Upload, X } from "lucide-react";
|
||||
import { CalendarClock, Check, Image as ImageIcon, Plus, RotateCcw, Upload, X, Send } from "lucide-react";
|
||||
import { api } from "../api.js";
|
||||
import { useAuth } from "../App.jsx";
|
||||
import { Badge, Button, cx, Dialog, Empty, ErrorNote, Field, Panel, Spinner } from "../ui.jsx";
|
||||
import { CaptionHelper } from "./BrandKit.jsx";
|
||||
import PlatformPicker, { defaultPlatforms, selectionFor, useClientPlatforms } from "../PlatformPicker.jsx";
|
||||
|
||||
const REVIEW = { pending: { tone: "warn", label: "With client" }, approved: { tone: "ok", label: "Client approved" }, changes: { tone: "bad", label: "Client wants changes" } };
|
||||
const STATUS = { draft: "neutral", approved: "brand", publishing: "brand", published: "ok", partial: "warn", failed: "bad", cancelled: "neutral" };
|
||||
const TYPES = [["IMAGE", "Single image"], ["REELS", "Reel / video"], ["CAROUSEL", "Carousel / multi-image"], ["STORY", "Story (Instagram, Facebook)"], ["TEXT", "Text only (no Instagram)"]];
|
||||
|
||||
@ -69,6 +71,7 @@ export default function Publishing() {
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-[13.5px]">{p.clientName}</span>
|
||||
<Badge tone={STATUS[p.status]} dot>{p.status}</Badge>
|
||||
{p.clientReview && p.status === "draft" && <Badge tone={REVIEW[p.clientReview.status].tone}>{REVIEW[p.clientReview.status].label}</Badge>}
|
||||
<span className="text-[12px] text-muted">{p.type.toLowerCase()} · {p.platforms.join(" + ")}</span>
|
||||
</div>
|
||||
<div className="text-[12.5px] text-ink-2 mt-0.5"><b>{p.localTime}</b> <span className="text-muted">({p.timezone.split("/").pop().replace("_", " ")} time)</span></div>
|
||||
@ -76,6 +79,8 @@ export default function Publishing() {
|
||||
{p.collaborators?.length > 0 && <div className="text-[12px] text-muted mt-1">Collab: {p.collaborators.map((c) => "@" + c).join(", ")}</div>}
|
||||
{Object.entries(p.errors || {}).filter(([k]) => !p.waiting?.[k]).map(([k, v]) => <div key={k} className="text-[12px] text-bad mt-1">{k}: {v}</div>)}
|
||||
{Object.entries(p.waiting || {}).map(([k, until]) => <div key={k} className="text-[12px] text-warn mt-1">{k}: daily limit reached. Goes out automatically after {new Date(until).toLocaleString("en-CA", { weekday: "short", hour: "numeric", minute: "2-digit" })}.</div>)}
|
||||
{p.clientReview?.status === "changes" && p.status === "draft" && <div className="text-[12.5px] mt-1.5 rounded-md bg-bad-soft text-bad px-2.5 py-1.5"><b>{p.clientReview.by || "Client"}:</b> {p.clientReview.note}</div>}
|
||||
{p.clientReview?.status === "approved" && p.status === "draft" && <div className="text-[12px] text-ok mt-1">Client approved{p.clientReview.by ? ` (${p.clientReview.by})` : ""}. Ready for your final approval.</div>}
|
||||
{p.status === "draft" && (p.warnings || []).map((w) => <div key={w} className="text-[12px] text-warn mt-1">{w}</div>)}
|
||||
{Object.entries(p.results || {}).filter(([, v]) => v?.permalink).map(([k, v]) => <a key={k} href={v.permalink} target="_blank" rel="noreferrer" className="text-[12px] text-brand mr-3">View on {k}</a>)}
|
||||
{p.results?.tiktok?.inbox && <div className="text-[12px] text-muted mt-1">TikTok: sent to the account's TikTok inbox. Finish and post it in the TikTok app.</div>}
|
||||
@ -83,6 +88,7 @@ export default function Publishing() {
|
||||
<div className="text-[11.5px] text-faint mt-1">by {p.createdBy}{p.approvedBy ? ` · approved by ${p.approvedBy}` : ""}</div>
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
{p.status === "draft" && p.clientReview?.status !== "pending" && <Button size="sm" icon={Send} loading={busy === p.id + "send-to-client"} onClick={() => act(p, "send-to-client")}>{p.clientReview ? "Send again" : "Send to client"}</Button>}
|
||||
{p.status === "draft" && isAdmin && <Button size="sm" variant="primary" icon={Check} loading={busy === p.id + "approve"} disabled={!data.publishing} onClick={() => act(p, "approve")}>Approve</Button>}
|
||||
{["failed", "partial"].includes(p.status) && isAdmin && <Button size="sm" icon={RotateCcw} loading={busy === p.id + "retry"} disabled={!data.publishing} onClick={() => act(p, "retry")}>Retry failed</Button>}
|
||||
{!["published", "cancelled", "publishing"].includes(p.status) && <Button size="sm" variant="danger" icon={X} loading={busy === p.id + "cancel"} onClick={() => act(p, "cancel")}>Cancel</Button>}
|
||||
@ -161,7 +167,7 @@ function Compose({ clients, onClose, onSaved }) {
|
||||
{TYPES.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={`When (${tz?.timezone?.replace("_", " ") || "client time"})`} hint={tz ? `Good times: ${tz.times.join(", ")}` : null}>
|
||||
<Field label={`When (${tz?.timezone?.replace("_", " ") || "client time"})`} hint={!tz ? null : tz.best?.slots?.length ? `Best for this client: ${tz.best.slots.map((s) => `${s.day} ${s.time} (+${s.lift}% reach)`).join(", ")}` : `Good times: ${tz.times.join(", ")}`}>
|
||||
<div className="flex gap-2">
|
||||
<input id="c-date" type="date" className="input flex-1" value={f.date} onChange={(e) => set("date", e.target.value)} />
|
||||
<input id="c-time" type="time" className="input w-[120px]" value={f.time} onChange={(e) => set("time", e.target.value)} />
|
||||
@ -189,6 +195,7 @@ function Compose({ clients, onClose, onSaved }) {
|
||||
)}
|
||||
<Field label="Caption" hint="Plain text works best for search. Hashtags at the end.">
|
||||
<textarea id="c-caption" className="input w-full h-28 py-2" value={f.caption} onChange={(e) => set("caption", e.target.value)} />
|
||||
<CaptionHelper client={f.client} caption={f.caption} platforms={sel.platforms} onInsert={(t) => set("caption", `${f.caption.trimEnd()}${t}`)} />
|
||||
</Field>
|
||||
<Field label="Post to"><PlatformPicker client={f.client} type={f.type} caption={f.caption} value={sel} onChange={setSel} available={available} media={f.media} /></Field>
|
||||
{sel.platforms.includes("facebook") && (
|
||||
|
||||
@ -212,6 +212,103 @@ function TeamPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
function MetaPanel() {
|
||||
const [d, setD] = useState(null);
|
||||
const [token, setToken] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState(null);
|
||||
useEffect(() => { api("/api/meta/connection").then(setD).catch(() => {}); }, []);
|
||||
if (!d) return null;
|
||||
const save = async (e) => {
|
||||
e.preventDefault(); setBusy(true); setMsg(null);
|
||||
try { const r = await api("/api/meta/connection", { method: "PUT", body: { token } }); setD({ source: r.source }); setToken(""); setMsg(`Connected as ${r.name}. Refresh numbers on the Clients page to load every Page.`); } catch (er) { setMsg(er); }
|
||||
setBusy(false);
|
||||
};
|
||||
return (
|
||||
<Panel title="Meta (Facebook & Instagram)" subtitle="Quorla reads and posts through your Meta business's system user." bodyClass="px-5 py-4 space-y-3">
|
||||
{d.source === "env" ? <p className="text-[13px] text-muted">Connected through the server's .env file (META_SYSTEM_USER_TOKEN).</p> : (
|
||||
<form onSubmit={save} className="space-y-2">
|
||||
<p className="text-[13px] text-muted">{d.source === "settings" ? "A token is saved. Paste a new one to replace it." : "Not connected yet."} In Meta Business Settings → Users → System users, generate a token for your system user with pages_show_list, pages_read_engagement, pages_manage_posts, instagram_basic, instagram_content_publish, instagram_manage_insights and instagram_manage_comments, then paste it here. It's stored encrypted and never shown again.</p>
|
||||
<div className="flex gap-2">
|
||||
<label htmlFor="meta-token" className="sr-only">System user token</label>
|
||||
<input id="meta-token" type="password" autoComplete="off" className="input" value={token} onChange={(e) => setToken(e.target.value)} placeholder="EAA…" />
|
||||
<Button type="submit" variant="primary" loading={busy} disabled={!token.trim()}>Save</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
{msg && (typeof msg === "string" ? <div className="text-[12.5px] text-ok">{msg}</div> : <ErrorNote error={msg} />)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspacePanel() {
|
||||
const { refresh } = useAuth();
|
||||
const [w, setW] = useState(null);
|
||||
const [busy, setBusy] = useState(null);
|
||||
const [msg, setMsg] = useState(null);
|
||||
const [v, setV] = useState(0);
|
||||
useEffect(() => { api("/api/workspace").then(setW).catch(setMsg); }, []);
|
||||
if (!w) return null;
|
||||
const set = (k, val) => setW({ ...w, [k]: val });
|
||||
const run = async (what, fn) => { setBusy(what); setMsg(null); try { setW(await fn()); setV((x) => x + 1); refresh(); if (what === "save") setMsg("Saved."); } catch (e) { setMsg(e); } setBusy(null); };
|
||||
const upload = (file) => run("logo", () => fetch("/api/workspace/logo", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/octet-stream", "X-Filename": file.name }, body: file }).then(async (r) => { const j = await r.json(); if (!r.ok) throw new Error(j.error); return j; }));
|
||||
return (
|
||||
<Panel title="Workspace" subtitle="Your agency's name, logo and colour. Used in the app, on client approval pages, in emails and on PDF reports." bodyClass="px-5 py-4 space-y-4">
|
||||
<div id="workspace" className="grid sm:grid-cols-2 gap-3">
|
||||
<Field label="Agency name"><input id="ws-name" className="input" value={w.name} onChange={(e) => set("name", e.target.value)} placeholder="e.g. Northstar Digital" /></Field>
|
||||
<Field label="Brand colour" hint="Buttons, links and highlights. Leave empty for Quorla's navy.">
|
||||
<div className="flex gap-2 items-center">
|
||||
<input id="ws-accent" type="color" className="size-9 rounded border border-line" value={w.accent || "#3b3778"} onChange={(e) => set("accent", e.target.value)} aria-label="Brand colour" />
|
||||
<input className="input" value={w.accent} onChange={(e) => set("accent", e.target.value)} placeholder="#3b3778" aria-label="Brand colour hex" />
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Support email" hint="Shown to clients as the reply-to address."><input id="ws-email" type="email" className="input" value={w.supportEmail} onChange={(e) => set("supportEmail", e.target.value)} /></Field>
|
||||
<Field label="Website"><input id="ws-web" className="input" value={w.website} onChange={(e) => set("website", e.target.value)} placeholder="https://" /></Field>
|
||||
</div>
|
||||
<Field label="Logo" hint="PNG or JPG, up to 2 MB. A wide logo on a transparent background works best. Replaces the Quorla logo in the app and on reports.">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{w.hasLogo && <img src={`/brand/agency-logo?v=${v}`} alt="Current logo" className="h-10 w-auto max-w-[200px] object-contain border border-line rounded p-1 bg-white" />}
|
||||
<label className="inline-flex items-center h-8 px-3 rounded-md border border-line-strong text-[13px] cursor-pointer hover:bg-canvas">
|
||||
{busy === "logo" ? "Uploading…" : w.hasLogo ? "Replace logo" : "Upload logo"}
|
||||
<input type="file" accept="image/png,image/jpeg" className="sr-only" onChange={(e) => e.target.files[0] && upload(e.target.files[0])} />
|
||||
</label>
|
||||
{w.hasLogo && <Button size="sm" variant="ghost" loading={busy === "rmlogo"} onClick={() => run("rmlogo", () => api("/api/workspace/logo", { method: "DELETE" }))}>Remove</Button>}
|
||||
</div>
|
||||
</Field>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="primary" loading={busy === "save"} onClick={() => run("save", () => api("/api/workspace", { method: "PUT", body: { name: w.name, accent: w.accent, supportEmail: w.supportEmail, website: w.website } }))}>Save workspace</Button>
|
||||
{msg && (typeof msg === "string" ? <span className="text-[12.5px] text-ok">{msg}</span> : <ErrorNote error={msg} />)}
|
||||
</div>
|
||||
<p className="text-[12px] text-muted">Your own domain (like social.youragency.com): point the domain at the server and set PUBLIC_URL in .env. See the hosting guide.</p>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationsPanel() {
|
||||
const [d, setD] = useState(null);
|
||||
const [busy, setBusy] = useState(null);
|
||||
const [msg, setMsg] = useState(null);
|
||||
useEffect(() => { api("/api/me/notify").then(setD).catch(setMsg); }, []);
|
||||
if (!d) return null;
|
||||
const toggle = (k) => { setBusy(k); api("/api/me/notify", { method: "PUT", body: { [k]: !d.prefs[k] } }).then(setD).catch(setMsg).finally(() => setBusy(null)); };
|
||||
const test = async () => { setBusy("test"); setMsg(null); try { const r = await api("/api/notify/test", { method: "POST" }); setMsg(r.sent ? "Test email sent. Check your inbox (and spam)." : new Error(r.reason)); } catch (e) { setMsg(e); } setBusy(null); };
|
||||
return (
|
||||
<Panel title="Notifications" subtitle="The bell at the top of the sidebar always shows them. Choose what also comes by email." bodyClass="px-5 py-4 space-y-3">
|
||||
{!d.email && <div className="text-[13px] rounded-md bg-warn-soft text-warn px-3 py-2">Email isn't set up on this server yet, so notifications only appear in the bell. An admin adds SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS and SMTP_FROM to the server's .env.</div>}
|
||||
<label htmlFor="np-email" className="flex items-start gap-2.5 text-[13.5px]">
|
||||
<input id="np-email" type="checkbox" className="mt-1" checked={d.prefs.email} disabled={busy === "email"} onChange={() => toggle("email")} />
|
||||
<span><b>Email me when something needs me</b><br /><span className="text-muted text-[12.5px]">A client approved or asked for changes, a post failed, or a TikTok post is waiting in the TikTok app.</span></span>
|
||||
</label>
|
||||
<label htmlFor="np-digest" className="flex items-start gap-2.5 text-[13.5px]">
|
||||
<input id="np-digest" type="checkbox" className="mt-1" checked={d.prefs.digest} disabled={busy === "digest"} onChange={() => toggle("digest")} />
|
||||
<span><b>Morning summary at 8:00</b><br /><span className="text-muted text-[12.5px]">Drafts waiting for approval, client answers, failed posts, connections to reconnect, and your planned work due in the next two days. Only sent when there's something to say.</span></span>
|
||||
</label>
|
||||
{d.email && <Button size="sm" loading={busy === "test"} onClick={test}>Send me a test email</Button>}
|
||||
{msg && (typeof msg === "string" ? <div className="text-[12.5px] text-ok">{msg}</div> : <ErrorNote error={msg} />)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Settings() {
|
||||
const { isAdmin } = useAuth();
|
||||
const [s, setS] = useState(null);
|
||||
@ -224,7 +321,10 @@ export default function Settings() {
|
||||
<h1 className="text-[22px] font-extrabold">Settings</h1>
|
||||
<p className="text-muted mt-0.5">Your account{isAdmin ? ", the team's logins" : ""} and this dashboard's connections.</p>
|
||||
</header>
|
||||
{isAdmin && <WorkspacePanel />}
|
||||
{isAdmin && <MetaPanel />}
|
||||
<AccountPanel />
|
||||
<NotificationsPanel />
|
||||
{isAdmin && <TeamPanel />}
|
||||
<ErrorNote error={error} />
|
||||
<Panel title="Connections" subtitle="Changed in the server's .env for now." bodyClass="divide-y divide-line">
|
||||
|
||||
103
deploy/provision-tenants.sh
Executable file
103
deploy/provision-tenants.sh
Executable file
@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hosted Quorla: create / start / stop / archive one container per agency, as the control centre asks.
|
||||
# Runs on the server as the deploy user (needs docker; sudo only for reloading nginx). Schedule every 5 minutes:
|
||||
# */5 * * * * /home/dev/APPS/quorlasocial/deploy/provision-tenants.sh >> /home/dev/APPS/quorla-tenants/provision.log 2>&1
|
||||
# Settings come from deploy/provision.env (copy provision.env.example). See docs/multi-tenant.md.
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO="$(dirname "$HERE")"
|
||||
# shellcheck disable=SC1091
|
||||
source "$HERE/provision.env"
|
||||
: "${CONTROL_URL:?set CONTROL_URL in provision.env}" "${PROVISION_TOKEN:?set PROVISION_TOKEN}" "${TENANTS_DIR:?set TENANTS_DIR}"
|
||||
IMAGE="${IMAGE:-quorla:latest}"
|
||||
BASE_DOMAIN="${TENANT_BASE_DOMAIN:-quorlasocial.com}"
|
||||
SHARED_ENV="${SHARED_ENV:-$HERE/tenant-shared.env}" # SMTP_*, optional ANTHROPIC_API_KEY, platform app keys
|
||||
MAP="$TENANTS_DIR/nginx-tenants.map"
|
||||
mkdir -p "$TENANTS_DIR/_archive"
|
||||
log() { echo "$(date -u +%FT%TZ) $*"; }
|
||||
|
||||
json() { python3 -c "import json,sys; d=json.load(sys.stdin); $1"; }
|
||||
report() { # slug json
|
||||
curl -fsS -m 30 -X POST -H "Authorization: Bearer $PROVISION_TOKEN" -H "Content-Type: application/json" \
|
||||
--data "$2" "$CONTROL_URL/control/provision/$1/report" >/dev/null || log "report failed for $1"
|
||||
}
|
||||
|
||||
write_env() { # slug name setup_token
|
||||
local dir="$TENANTS_DIR/$1"
|
||||
[ -f "$dir/.env" ] && return 0
|
||||
umask 077
|
||||
{
|
||||
echo "DASHBOARD_SECRET=$(openssl rand -hex 32)"
|
||||
echo "AGENCY_NAME=$2"
|
||||
echo "PUBLIC_URL=https://$1.$BASE_DOMAIN"
|
||||
echo "APP_DOMAIN=$1.$BASE_DOMAIN"
|
||||
echo "SETUP_TOKEN=$3"
|
||||
echo "ALLOW_PUBLISH=true"
|
||||
echo "SCHEDULER_ENABLED=true"
|
||||
[ -f "$SHARED_ENV" ] && grep -E '^[A-Z0-9_]+=' "$SHARED_ENV"
|
||||
} > "$dir/.env"
|
||||
}
|
||||
|
||||
run_container() { # slug port
|
||||
local slug="$1" port="$2" dir="$TENANTS_DIR/$1"
|
||||
docker run -d --name "quorla-$slug" --restart unless-stopped \
|
||||
-p "127.0.0.1:$port:4310" --env-file "$dir/.env" \
|
||||
-e DASHBOARD_HOST=0.0.0.0 -e DASHBOARD_COOKIE_SECURE=true -e TRUST_PROXY=uniquelocal \
|
||||
-v "$dir/data:/app/data" -v "$dir/clients:/app/clients" -v "$dir/team:/app/team" \
|
||||
-v "$dir/reports:/app/reports" -v "$dir/branding:/app/branding" -v "$dir/backups:/app/backups:ro" \
|
||||
--label quorla.tenant="$slug" --label quorla.port="$port" "$IMAGE" >/dev/null
|
||||
}
|
||||
|
||||
wait_healthy() { # port
|
||||
for _ in $(seq 1 30); do curl -fsS -m 3 "http://127.0.0.1:$1/api/auth/me" >/dev/null 2>&1 && return 0; sleep 2; done
|
||||
return 1
|
||||
}
|
||||
|
||||
rebuild_map() {
|
||||
local tmp; tmp="$(mktemp)"
|
||||
docker ps --filter label=quorla.tenant --format '{{.Label "quorla.tenant"}} {{.Label "quorla.port"}}' \
|
||||
| while read -r slug port; do echo "$slug.$BASE_DOMAIN 127.0.0.1:$port;"; done | sort > "$tmp"
|
||||
if ! cmp -s "$tmp" "$MAP" 2>/dev/null; then
|
||||
mv "$tmp" "$MAP"
|
||||
if sudo -n nginx -t >/dev/null 2>&1; then sudo -n systemctl reload nginx && log "nginx reloaded"; else log "nginx -t failed or sudo not allowed; map updated but not reloaded"; fi
|
||||
else rm -f "$tmp"; fi
|
||||
}
|
||||
|
||||
tasks="$(curl -fsS -m 30 -H "Authorization: Bearer $PROVISION_TOKEN" "$CONTROL_URL/control/provision/tasks")"
|
||||
echo "$tasks" | json 'print("\n".join("\t".join([t["slug"], t["desired"], str(t.get("port") or ""), t.get("agencyName") or "", t.get("setupToken") or ""]) for t in d["tasks"]))' |
|
||||
while IFS=$'\t' read -r slug desired port name token; do
|
||||
[ -z "$slug" ] && continue
|
||||
dir="$TENANTS_DIR/$slug"
|
||||
case "$desired" in
|
||||
running)
|
||||
mkdir -p "$dir"/{data,clients,team,reports,backups}
|
||||
[ -d "$dir/branding" ] || cp -r "$REPO/branding" "$dir/branding"
|
||||
write_env "$slug" "$name" "$token"
|
||||
if docker inspect "quorla-$slug" >/dev/null 2>&1; then docker start "quorla-$slug" >/dev/null; else run_container "$slug" "$port"; fi
|
||||
if wait_healthy "$port"; then log "$slug running on $port"; report "$slug" '{"actual":"running"}';
|
||||
else log "$slug did not start"; report "$slug" "{\"error\":\"container did not become healthy on port $port\"}"; fi
|
||||
;;
|
||||
stopped)
|
||||
docker stop "quorla-$slug" >/dev/null 2>&1 || true
|
||||
log "$slug stopped"; report "$slug" '{"actual":"stopped"}'
|
||||
;;
|
||||
removed)
|
||||
docker rm -f "quorla-$slug" >/dev/null 2>&1 || true
|
||||
if [ -d "$dir" ]; then tar -czf "$TENANTS_DIR/_archive/$slug-$(date +%Y%m%d-%H%M).tar.gz" -C "$dir" . && rm -rf "$dir"; fi
|
||||
log "$slug removed (data archived)"; report "$slug" '{"actual":"removed"}'
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Upgrade: after `docker compose up -d --build` in the main app, move every agency onto the new image.
|
||||
current="$(docker image inspect -f '{{.Id}}' "$IMAGE")"
|
||||
docker ps -a --filter label=quorla.tenant --format '{{.Names}} {{.Image}} {{.Label "quorla.tenant"}} {{.Label "quorla.port"}} {{.State}}' |
|
||||
while read -r cname _ slug port state; do
|
||||
used="$(docker inspect -f '{{.Image}}' "$cname")"
|
||||
[ "$used" = "$current" ] && continue
|
||||
[ "$state" = "running" ] || continue
|
||||
docker rm -f "$cname" >/dev/null && run_container "$slug" "$port" && wait_healthy "$port" && log "$slug upgraded" || log "$slug upgrade FAILED"
|
||||
done
|
||||
|
||||
rebuild_map
|
||||
9
deploy/provision.env.example
Normal file
9
deploy/provision.env.example
Normal file
@ -0,0 +1,9 @@
|
||||
# Copy to deploy/provision.env (never commit it). Used by deploy/provision-tenants.sh.
|
||||
CONTROL_URL=https://app.quorlasocial.com
|
||||
# Same value as PROVISION_TOKEN in the main app's .env. Generate with: openssl rand -hex 32
|
||||
PROVISION_TOKEN=
|
||||
TENANTS_DIR=/home/dev/APPS/quorla-tenants
|
||||
IMAGE=quorla:latest
|
||||
TENANT_BASE_DOMAIN=quorlasocial.com
|
||||
# Settings every agency workspace gets (SMTP for emails; optional shared AI key and platform app keys).
|
||||
SHARED_ENV=/home/dev/APPS/quorlasocial/deploy/tenant-shared.env
|
||||
@ -19,6 +19,7 @@ services:
|
||||
- ./team:/app/team
|
||||
- ./reports:/app/reports
|
||||
- ./branding:/app/branding
|
||||
- ./backups:/app/backups:ro
|
||||
expose:
|
||||
- "4310"
|
||||
|
||||
|
||||
99
docs/multi-tenant.md
Normal file
99
docs/multi-tenant.md
Normal file
@ -0,0 +1,99 @@
|
||||
# Hosted Quorla: one workspace per agency
|
||||
|
||||
How the hosted plan works and how to switch it on. Self-hosted customers don't need any of this: they run one Quorla
|
||||
with `docker compose up -d --build` as in `docs/hosting.html`.
|
||||
|
||||
## How it fits together
|
||||
|
||||
```
|
||||
agency signs up at app.quorlasocial.com/signup ──► Stripe Checkout ──► Stripe webhook ──► app marks the agency "setting up"
|
||||
│
|
||||
cron every 5 min: deploy/provision-tenants.sh ◄── asks app what to do (PROVISION_TOKEN) ◄──────────┘
|
||||
│ creates ~/APPS/quorla-tenants/<agency>/ (own .env, data, clients…), runs container quorla-<agency> on 127.0.0.1:44xx,
|
||||
│ updates the nginx map so <agency>.quorlasocial.com reaches it, reports back
|
||||
▼
|
||||
app emails the owner a one-time setup link: https://<agency>.quorlasocial.com/?setup=<token>
|
||||
```
|
||||
|
||||
- Each agency is a **separate container with its own data folder**: no agency can see another's data.
|
||||
- The main app (app.quorlasocial.com) is the **control centre** (`QUORLA_CONTROL=true`): sign-up page, Stripe webhook,
|
||||
Admin → **Agencies** page (suspend, resume, billing portal, resend welcome, remove).
|
||||
- The web app never runs Docker. Only the provisioning script on the server does.
|
||||
- A new workspace's first admin can only be created with the emailed setup link (`SETUP_TOKEN`).
|
||||
- Payment fails: the agency shows "Payment overdue" (still running) until Stripe gives up, then it's stopped. Data is kept.
|
||||
- Subscription cancelled: workspace stopped, data kept. **Remove** archives the data to `quorla-tenants/_archive/` first.
|
||||
- Upgrades: after `git pull && docker compose up -d --build` in the main app, the next script run moves every agency
|
||||
onto the new image (data untouched).
|
||||
|
||||
## One-time setup (server, as `dev`; ~30 minutes)
|
||||
|
||||
1. **DNS:** add a wildcard record `*.quorlasocial.com A 72.62.169.43` (Hestia → DNS → quorlasocial.com → Add record).
|
||||
2. **Wildcard certificate** for `*.quorlasocial.com`. With DNS on Hestia:
|
||||
`sudo v-add-letsencrypt-domain user quorlasocial.com "*.quorlasocial.com"` (or certbot with the DNS challenge).
|
||||
3. **nginx** (Hestia includes `/etc/nginx/conf.d/*.conf`). Create `/etc/nginx/conf.d/quorla-tenants.conf`:
|
||||
```nginx
|
||||
map $host $quorla_tenant {
|
||||
default "";
|
||||
include /home/dev/APPS/quorla-tenants/nginx-tenants.map;
|
||||
}
|
||||
server {
|
||||
listen 72.62.169.43:443 ssl;
|
||||
http2 on;
|
||||
server_name *.quorlasocial.com;
|
||||
ssl_certificate /home/user/conf/web/quorlasocial.com/ssl/quorlasocial.com.pem; # adjust to the wildcard cert path
|
||||
ssl_certificate_key /home/user/conf/web/quorlasocial.com/ssl/quorlasocial.com.key;
|
||||
client_max_body_size 500m;
|
||||
proxy_read_timeout 600s;
|
||||
location / {
|
||||
if ($quorla_tenant = "") { return 404; }
|
||||
proxy_pass http://$quorla_tenant;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
}
|
||||
}
|
||||
```
|
||||
`touch /home/dev/APPS/quorla-tenants/nginx-tenants.map` first, then `sudo nginx -t && sudo systemctl reload nginx`.
|
||||
app.quorlasocial.com keeps its own Hestia domain; the exact name wins over the wildcard.
|
||||
4. **Let the script reload nginx** without a password: `sudo visudo -f /etc/sudoers.d/quorla` →
|
||||
`dev ALL=(root) NOPASSWD: /usr/sbin/nginx -t, /usr/bin/systemctl reload nginx`
|
||||
5. **Script settings:** `cp deploy/provision.env.example deploy/provision.env` and fill it in. Create
|
||||
`deploy/tenant-shared.env` with what every agency gets, at least email:
|
||||
```
|
||||
SMTP_HOST=mail.quorlasocial.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=support@quorlasocial.com
|
||||
SMTP_PASS=…
|
||||
SMTP_FROM=support@quorlasocial.com
|
||||
```
|
||||
6. **Main app .env:** `QUORLA_CONTROL=true`, `TENANT_BASE_DOMAIN=quorlasocial.com`, the same `PROVISION_TOKEN`, and the
|
||||
Stripe values below. Rebuild: `docker compose up -d --build`.
|
||||
7. **Cron:** `crontab -e` →
|
||||
`*/5 * * * * /home/dev/APPS/quorlasocial/deploy/provision-tenants.sh >> /home/dev/APPS/quorla-tenants/provision.log 2>&1`
|
||||
|
||||
## Stripe
|
||||
|
||||
1. Stripe Dashboard (start in **Test mode**) → Product catalog → **Add product** "Quorla hosted", recurring monthly price.
|
||||
Copy the price id (`price_…`) → `STRIPE_PRICE_ID`.
|
||||
2. Developers → API keys → secret key (`sk_test_…`) → `STRIPE_SECRET_KEY`.
|
||||
3. Developers → Webhooks → **Add endpoint** `https://app.quorlasocial.com/control/stripe/webhook`, events:
|
||||
`checkout.session.completed`, `customer.subscription.updated`, `customer.subscription.deleted`, `invoice.payment_failed`.
|
||||
Copy the signing secret (`whsec_…`) → `STRIPE_WEBHOOK_SECRET`.
|
||||
4. Settings → Billing → **Customer portal**: turn on cancel, update payment method, invoices (used by the Billing button).
|
||||
5. Optional `STRIPE_TRIAL_DAYS=14`. Test with card 4242 4242 4242 4242, then repeat in Live mode with live keys.
|
||||
|
||||
Without Stripe keys, sign-ups still work: they land on Admin → Agencies as "Awaiting payment" and you **Approve** them.
|
||||
|
||||
## What each agency sets up in their workspace
|
||||
|
||||
- **Meta:** Settings → Meta: paste their own system user token (their Business Manager, their Pages).
|
||||
- **Other platforms:** Connections → each platform → Set up with their own developer app keys, **or** you add their
|
||||
callback URL (`https://<agency>.quorlasocial.com/oauth/<platform>/callback`) to Quorla's TikTok/Google/LinkedIn apps and
|
||||
put those keys in `tenant-shared.env` (`TIKTOK_CLIENT_ID`… ). Platform apps need each exact callback URL listed.
|
||||
- **Workspace:** Settings → Workspace: their name, logo and colour (white-label).
|
||||
|
||||
## Check it works
|
||||
|
||||
- Admin → Agencies shows the agency move: Awaiting payment → Setting up → Active.
|
||||
- `tail -f ~/APPS/quorla-tenants/provision.log` shows `running on 44xx`.
|
||||
- The owner gets the welcome email and creates their account from the link; the link stops working afterwards.
|
||||
@ -79,11 +79,14 @@ function heroHtml(meta) {
|
||||
</section>${kpis}`;
|
||||
}
|
||||
|
||||
function buildHtml(mdPath) {
|
||||
// opts.logoPath / opts.logoAlt: an agency's own logo (white-label) instead of branding/metatroncube-logo.png.
|
||||
function buildHtml(mdPath, opts = {}) {
|
||||
const { meta, content } = parseFrontMatter(readFileSync(mdPath, "utf8"));
|
||||
const body = marked.parse(content, { gfm: true, breaks: true });
|
||||
const css = readFileSync(join(brandDir, "report.css"), "utf8");
|
||||
const logo = readFileSync(join(brandDir, "metatroncube-logo.png")).toString("base64");
|
||||
const custom = opts.logoPath && existsSync(opts.logoPath);
|
||||
const logo = readFileSync(custom ? opts.logoPath : join(brandDir, "metatroncube-logo.png")).toString("base64");
|
||||
const logoMime = custom && /\.jpe?g$/i.test(opts.logoPath) ? "image/jpeg" : "image/png";
|
||||
const h1 = content.match(/^#\s+(.+)$/m)?.[1];
|
||||
const title = (meta.client ? `${meta.client} — ${meta.report ?? "Report"}` : h1 ?? basename(mdPath, ".md")).replace(/[*_`]/g, "");
|
||||
const internal = /-internal/i.test(basename(mdPath));
|
||||
@ -97,7 +100,9 @@ function buildHtml(mdPath) {
|
||||
<style>${css}</style>
|
||||
</head><body>
|
||||
<header class="mc-topbar">
|
||||
<div class="mc-logo-crop"><img src="data:image/png;base64,${logo}" alt="Metatroncube — Digital Solutions Simplified"></div>
|
||||
${custom
|
||||
? `<div><img src="data:${logoMime};base64,${logo}" alt="${escapeHtml(opts.logoAlt || "")}" style="display:block;height:44px;width:auto;max-width:260px;object-fit:contain"></div>`
|
||||
: `<div class="mc-logo-crop"><img src="data:image/png;base64,${logo}" alt="Metatroncube — Digital Solutions Simplified"></div>`}
|
||||
<div class="mc-topmeta">
|
||||
Prepared ${escapeHtml(date)}
|
||||
${internal ? '<br><span class="mc-internal">INTERNAL — NOT FOR CLIENT</span>' : ""}
|
||||
@ -108,7 +113,7 @@ ${meta.client ? heroHtml(meta) : ""}
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
function toPdf(mdPath) {
|
||||
function toPdf(mdPath, opts = {}) {
|
||||
const abs = resolve(mdPath);
|
||||
const out = abs.replace(/\.md$/i, "") + ".pdf";
|
||||
const work = mkdtempSync(join(tmpdir(), "mc-report-"));
|
||||
@ -116,7 +121,7 @@ function toPdf(mdPath) {
|
||||
try {
|
||||
const htmlPath = join(work, "report.html");
|
||||
const tmpPdf = join(work, "report.pdf");
|
||||
writeFileSync(htmlPath, buildHtml(abs));
|
||||
writeFileSync(htmlPath, buildHtml(abs, opts));
|
||||
execFileSync(findBrowser(), [
|
||||
"--headless=new",
|
||||
"--disable-gpu",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user