Compare commits

..

No commits in common. "main" and "feature/platforms" have entirely different histories.

107 changed files with 244 additions and 7540 deletions

View File

@ -7,12 +7,12 @@ description: Audit one client's Instagram and Facebook using live data and write
2. Pull data with the social-manager tools: 2. Pull data with the social-manager tools:
- `instagram_insights` (28 days), `instagram_recent_posts` (limit 20) - `instagram_insights` (28 days), `instagram_recent_posts` (limit 20)
- `facebook_page_insights` (28 days), `facebook_recent_posts` (limit 10) - `facebook_page_insights` (28 days), `facebook_recent_posts` (limit 10)
- Screenshots: the team uploads them in the dashboard (client → Screenshots) to `clients/<slug>/screenshots/YYYY-MM/`, one file per shot: `ig-profile`, `ig-grid`, `ig-reels`, `fb-page`, `fb-reviews`, `gbp`, `gbp-reviews`, `bio-link` (.png/.jpg/.webp). Read every image in the newest month folder (and `profile-check-YYYY-MM.md` if present). They override anything guessed from the API (bio link, highlights, visuals, Page buttons, reviews). List missing required shots (`ig-profile`, `ig-grid`, `fb-page`, `gbp`) under open questions instead of guessing. - If a `profile-check-YYYY-MM.md` or `screenshots/` exists for the client, read it. It overrides anything guessed from the API (bio link, highlights, visuals, Page buttons, reviews).
3. Analyse against `playbooks/instagram.md` and `playbooks/facebook.md`: 3. Analyse against `playbooks/instagram.md` and `playbooks/facebook.md`:
- Posting frequency and gaps; format mix (Reels vs carousel vs static). - Posting frequency and gaps; format mix (Reels vs carousel vs static).
- Top 3 and bottom 3 posts by reach and by shares+saves — what do the winners have in common? - Top 3 and bottom 3 posts by reach and by shares+saves — what do the winners have in common?
- Reach vs followers (is content leaving the follower bubble?). - Reach vs followers (is content leaving the follower bubble?).
- Profile: bio, link, highlights, consistency of visuals (from the screenshots; if missing, note it rather than asking mid-run). - Profile: bio, link, highlights, consistency of visuals (ask the user for a screenshot if needed).
4. Write **two** reports for the month: 4. Write **two** reports for the month:
**a) `clients/<slug>/audit-YYYY-MM-internal.md`**: for the agency team only. **a) `clients/<slug>/audit-YYYY-MM-internal.md`**: for the agency team only.
@ -28,25 +28,20 @@ description: Audit one client's Instagram and Facebook using live data and write
- The PDF carries Metatroncube branding (it's our private deliverable to the client). Inside the text, never mention other clients, team members, tools/APIs or internal file names. Write as "we". - The PDF carries Metatroncube branding (it's our private deliverable to the client). Inside the text, never mention other clients, team members, tools/APIs or internal file names. Write as "we".
- Structure: **Summary** (3–4 sentences) → **Your numbers this month** (short table, 5–7 metrics) → **What's working** → **What we're changing** (3–5 points framed as the plan, not blame) → **Next 30 days** → **What we need from you** (checklist). - Structure: **Summary** (3–4 sentences) → **Your numbers this month** (short table, 5–7 metrics) → **What's working** → **What we're changing** (3–5 points framed as the plan, not blame) → **Next 30 days** → **What we need from you** (checklist).
- Leave out: raw data caveats, internal compliance doubts (turn them into neutral questions in "What we need from you"), mistakes we're fixing quietly, cross-client notes, budgets we haven't discussed with them. - Leave out: raw data caveats, internal compliance doubts (turn them into neutral questions in "What we need from you"), mistakes we're fixing quietly, cross-client notes, budgets we haven't discussed with them.
- Short text, mostly visuals: 3–4 PDF pages. Before saving, check it doesn't contradict the internal version. - Keep it to roughly one page. Before saving, check it doesn't contradict the internal version.
- **Front matter:** start each file with it (no `#` H1; the builder draws the title block): - **Front matter for the branded PDF:** start each report file with front matter. The PDF builder turns it into the dark title block and 4 headline-number cards, so don't add a `#` H1:
``` ```
--- ---
client: <Client name> client: <Client name>
report: <Report type> · <Month YYYY> report: <Report type> · <Month YYYY>
period: <date range> period: <date range>
accounts: Instagram @handle · Facebook Page accounts: Instagram @handle · Facebook Page
prepared: <Month D, YYYY>
kpis:
- <value> | <label> | <short note>
--- ---
``` ```
- **Visual blocks** (rendered by `tools/report-pdf/blocks.mjs`; see `example-client.md` in this skill folder for a full example). Data blocks are empty fenced blocks; the charts are drawn from the stored account numbers, so never type numbers into them: Use exactly 4 KPIs. Client version: plain-language wins and gaps. Internal version: the 4 numbers that matter most for the team. Put the client summary in a `>` blockquote so it renders as a callout.
- ```` ```mc:kpis ```` headline number cards with sparklines; ```` ```mc:reach ```` daily reach with post days marked; ```` ```mc:funnel ```` seen → reached → engaged → website taps, biggest drop in red; ```` ```mc:followers ```` gained vs lost; ```` ```mc:formats ```` share of posts vs reach per format; ```` ```mc:rhythm ```` 8-week posting calendar; ```` ```mc:top-posts ```` best and weakest 3.
- ```` ```mc:score ```` one line per area: `Area | 0–10 | one-line reason` (Profile setup, Content, Reach, Consistency, Turning viewers into enquiries; add Google/reviews for local businesses). Scores are your judgement from the data; be honest.
- ```` ```mc:actions ```` one line per action: `Action | Owner (Us / You / Us + you) | When`. Use it for "Next 30 days".
- ```` ```mc:screenshot ```` first line a shot id (`ig-profile`, `ig-grid`, `fb-page`, `gbp`…), then one short note per line about what the screenshot shows. Skip it if that shot wasn't uploaded.
- Callouts: `> [!WIN] Title`, `> [!FIX] Title`, `> [!RISK] Title`, `> [!NOTE] Title`, then the text on the next line. Use them for the headline finding of each section, not every paragraph.
- Client order: Summary + `mc:score` → Your numbers (`mc:kpis`, `mc:reach`) → Where people drop off (`mc:funnel`, `mc:followers`) → What's working (`[!WIN]`, screenshot, `mc:formats`) → What we're changing (`mc:rhythm`, `mc:top-posts`, `[!FIX]`/`[!RISK]`) → Next 30 days (`mc:actions`) → What we need from you (checklist). The internal version uses the same blocks plus the internal sections.
- If `competitors` returns tracked accounts, add ```` ```mc:rivals ```` (leaderboard vs competitors) after "Your numbers". The other competitor blocks (`mc:rivals-map`, `-formats`, `-posts`, `-tags`, `-google`) belong in the `competitor-scan` report.
- Each data block can be used once per report. If a block shows "no account data stored yet", refresh the client's numbers in the dashboard and re-export rather than writing numbers by hand.
5. If brand.md is mostly empty, draft it from what you saw (tone, pillars, visuals) and mark it DRAFT for the user to confirm. 5. If brand.md is mostly empty, draft it from what you saw (tone, pillars, visuals) and mark it DRAFT for the user to confirm.
6. Append 2–3 dated lessons to `history.md`. 6. Append 2–3 dated lessons to `history.md`.
7. Export both as branded PDFs: `node tools/report-pdf/build.mjs clients/<slug>/audit-YYYY-MM-client.md clients/<slug>/audit-YYYY-MM-internal.md`. Open the client PDF with Read and check the layout before handing it over. 7. Export both as branded PDFs: `node tools/report-pdf/build.mjs clients/<slug>/audit-YYYY-MM-client.md clients/<slug>/audit-YYYY-MM-internal.md`. Open the client PDF with Read and check the layout before handing it over.

View File

@ -1,87 +0,0 @@
---
client: Metatroncube
report: Social media review · October 2026
period: 21 Sep – 19 Oct 2026
accounts: Instagram @metatron_digitalagency · Facebook Page
---
## Summary
The Instagram account has almost 2,900 followers, but posts reach about 50 people a month, and more people unfollowed than followed. The followers were built on older content for a different audience, so Instagram has stopped showing the posts. The fix is to treat Instagram as a fresh start aimed at Canadian small-business owners: people on camera, real questions, and one clear story across every channel.
```mc:score
Profile setup | 7 | Clear bio, links page and highlights. Facebook tells a different story.
Content | 4 | Polished templates, but no faces and no real results.
Reach | 2 | Posts reach about 2% of followers.
Consistency | 5 | About one post a week, with gaps of up to two weeks.
Turning viewers into enquiries | 2 | No website taps this month.
```
## Your numbers this month
```mc:kpis
```
```mc:reach
```
> [!FIX] Posting isn't moving the line yet
> Days with a post look the same as days without one. That usually means the posts aren't being shown beyond a few existing followers. Reels with a person on camera are the fastest way to change that.
## Where people drop off
```mc:funnel
```
```mc:followers
```
## What's working
> [!WIN] The profile is ready for new visitors
> A clear bio aimed at Canadian small businesses, a "Free Consult" call to action, a links page and five highlights (Services, Reviews, Tips, Portfolio, About). When reach comes back, visitors will know what you do.
```mc:screenshot
ig-profile
Bio says exactly who it's for: ERP and web systems for Canadian small businesses.
Links page leads to booking, WhatsApp and portfolio.
Highlights cover services, reviews and portfolio.
The grid is all designed graphics: no faces from the team and no real client results.
```
```mc:formats
```
## What we're changing
```mc:rhythm
```
```mc:top-posts
```
> [!RISK] Facebook tells a different story
> Instagram says "ERP and web systems for Canadian small businesses". The Facebook cover says "Social Media Marketing, SEO, Web Designing". A business owner who checks both won't know what you actually do.
```mc:screenshot
fb-page
The cover lists four unrelated services.
The bio is generic ("technology focused solutions").
WhatsApp and Message buttons are set up, which is good.
```
## Next 30 days
```mc:actions
Post 2 Reels a week with a team member on camera answering one real question, e.g. "What does an ERP cost a 10-person business?" | Us | Weekly from Nov 3
Use Trial Reels so new Reels are shown to non-followers first | Us | From Nov 3
New Facebook cover and bio that match the ERP + web story | Us | By Nov 7
Two anonymised results posts ("hours saved for a Kitchener clinic") | Us + you | By Nov 21
Ask 5 recent happy clients for a Google review | You | By Nov 30
```
## What we need from you
- [ ] 20 minutes a week on camera, or a team member who will be
- [ ] Two client results we can share without naming the client
- [ ] Names of 5 clients to ask for a Google review

View File

@ -1,42 +0,0 @@
---
name: competitor-scan
description: Find a client's real competitors (local rivals and niche benchmarks), check each on Instagram, compare them with the client and write a visual competitive report with the gaps the client can own. Use for a new client, quarterly, or "who is doing better than us and why".
---
Goal: tell the client who they're actually up against, who's winning and *why*, and the 3–5 openings nobody in their market is using. Numbers come from Instagram via the tools, never from memory or guesses.
**Always run the full scan, even if this month's report already exists.** Someone started this run on purpose (new data sources, tracked accounts changed, or the last report was wrong). Use the old report only as a starting point, then rewrite `competitors-YYYY-MM.md` in place and re-export the PDF. Re-suggesting an account doesn't duplicate it: `competitor_suggest` replaces the existing suggestion and skips tracked or dismissed accounts.
1. **Know the client.** Read `clients/<slug>/client.json`, `brand.md`, `goals.md`, `constraints.md` and any `audit-*` file. Note: niche, exact offer, city/area (and radius that matters: a clinic competes within ~10 km, an online brand nationally), price level, ideal customer. Call `competitors` to see who's already tracked, so you don't suggest them again.
2. **Search for candidates** (8–15 searches in total, no more):
- Local rivals, Google Maps first: `google_places` with query "<service> <city>" (2–4 variations, e.g. by neighbourhood or sub-service). It returns what customers see on Maps, with real ratings, review counts, website links and place ids. Keep each rival's `placeId`. If it says Google Places isn't set up, use WebSearch ("<service> <city>", Yelp / directory listings) and note any rating the result shows.
- Then WebSearch for rivals Maps misses (online-only businesses, "<service> <city> instagram").
- Benchmarks: accounts in the same niche (other cities or countries) known for strong content, e.g. "best <niche> instagram accounts", industry roundups.
- Find each business's Instagram handle from its own website or official listing (WebFetch the site's homepage and look for instagram.com links), benchmarks included; a handle seen only in a roundup article doesn't count. Never guess a handle.
- The client's own Google listing: find it with `google_places` (business name + city), check the name and address match, then save it with `client_google_listing`. Without Google Places: read `google` ({"rating": 4.8, "reviews": 120}) from `clients/<slug>/client.json`, or from the `gbp` screenshot in `clients/<slug>/screenshots/` and save it into client.json. If none of these works, list it under open questions.
- For the 3–5 strongest local rivals, `google_places` with place_id and reviews=true: note what customers praise and complain about (themes only; in the report, paraphrase and never quote reviewers by name).
- Aim for 6–10 local rivals and 3–5 benchmarks.
3. **Verify and save** with `competitor_suggest` (one call, up to 15 accounts): `kind` local or benchmark, a one-line `why` a business owner would understand ("Same street, same price range, 4.8★ from 310 reviews"), and the rival's `placeId` when you have it (the server then reads the real Google rating; pass `google` rating/reviews only when there's no place id). The tool checks each on Instagram and returns the real numbers; skipped ones (private, personal or wrong handle) are listed with the reason. Don't retry a skipped handle with variations.
4. **Analyse** using only what the tool returned (and `competitors` for already-tracked ones) plus the client's own numbers (`instagram_insights`, `instagram_recent_posts`):
- Who's winning: engagement rate (engagement per follower) matters more than follower count; a small account with high engagement beats a big quiet one. Posting rhythm. Follower growth where tracked.
- Why they win: format that works for them (`engagementByFormat`), what their top posts have in common (topic, hook, people on camera, offers, local references), hashtags their best posts use (`topTags`) that the client doesn't.
- Gaps: topics, formats or angles none of the rivals cover well, which fit the client's strengths. These are the most valuable part of the report. When `google_trends` is set up, check that each gap has real search demand in the client's region (one call, gap keywords together) and say whether it's growing.
- Local businesses: compare Google rating and review counts too; that's often where customers decide.
5. **Write** `clients/<slug>/competitors-YYYY-MM.md`, client-ready (plain language, no tools or file names, no other clients). Use the visual blocks described in `.claude/skills/account-audit/SKILL.md` (front matter, callouts, `mc:score`, `mc:actions`) plus these competitor blocks, all drawn from the verified data (empty fenced blocks, never type numbers in them):
- ```` ```mc:rivals ```` leaderboard: client vs every competitor, ranked by engagement rate.
- ```` ```mc:rivals-map ```` size vs engagement map: who punches above their weight.
- ```` ```mc:rivals-formats ```` format mix and what each format earns per post.
- ```` ```mc:rivals-tags ```` hashtags the winners use that the client doesn't.
- ```` ```mc:rivals-posts ```` the best posts in the market, as an idea bank.
- ```` ```mc:rivals-google ```` Google rating and reviews (local businesses).
- ```` ```mc:search-trends ```` search demand for the gaps: first line the exact keywords of one `google_trends` call, comma-separated; second line its location. Only when you called `google_trends`; put it right after the gaps.
Order: Summary (3 sentences: where the client stands, who's leading and why, the biggest opening) → `mc:rivals` → `mc:rivals-map` → **Who's winning and why** (one `> [!NOTE]` per top 2–3 rivals: what they do that works) → `mc:rivals-formats` → `mc:rivals-posts` → `mc:rivals-tags` → `mc:rivals-google` if local → **Openings nobody owns** (`> [!WIN]` per gap, 3–5) → **What we'll do** (`mc:actions`, 5 items, owner + when) → **Who to track** (list the suggested accounts and say the team will confirm them).
Never copy a rival's content or suggest misleading comparisons; learn the pattern, not the post.
6. Export the PDF: `node tools/report-pdf/build.mjs clients/<slug>/competitors-YYYY-MM.md` (from the dashboard, skip this: the dashboard exports it after the run). Append 1–2 dated lessons to `history.md`.
7. Final message: the PDF path, how many accounts were suggested vs skipped, and a reminder that someone must add or dismiss the suggestions on the Competitors page so they're tracked from now on.

View File

@ -14,9 +14,7 @@ Works only with official data: the `competitor_lookup` tool (Instagram Business
4. For each outlier note: format (Reel/carousel/image), first line of caption (the hook), topic, any visible trend (audio name if in caption, meme format, "POV", before/after, day-in-the-life, ranking, myth-vs-fact), length cues, CTA. Open the permalink in the browser only if the user wants frames reviewed. 4. For each outlier note: format (Reel/carousel/image), first line of caption (the hook), topic, any visible trend (audio name if in caption, meme format, "POV", before/after, day-in-the-life, ranking, myth-vs-fact), length cues, CTA. Open the permalink in the browser only if the user wants frames reviewed.
5. Group into 3–5 patterns. A pattern needs 2+ outliers from different accounts — one viral post is luck, not a trend. 5. Group into 3–5 patterns. A pattern needs 2+ outliers from different accounts — one viral post is luck, not a trend.
6. Check the client's own last 30 posts the same way (`instagram_recent_posts`) to show which of their posts were outliers and why. 6. Check the client's own last 30 posts the same way (`instagram_recent_posts`) to show which of their posts were outliers and why.
6b. **Hashtag top posts** (`hashtag_top_posts`, skip if it says it's switched off): 3–5 specific niche or local hashtags (from the client's best posts and rivals' `topTags`; never generic tags like #food), kind=top. Instagram allows 30 different hashtags per account per week, so reuse last week's tags where you can. Treat a Reel here as an outlier when it clearly beats the other posts under the same tag; group them with the account outliers in step 5. These posts come without a username: link them, don't credit or name the poster. 7. Optional: web search "<niche> Instagram trend <month year>" and Meta's own creator/trending pages for audio and formats this month; mark anything you could not verify.
7. **What people are searching for** (`google_trends`, skip if it says it isn't set up): 2–3 calls of up to 5 keywords each, in the client's region (location from `client.json`, e.g. "Ontario,Canada"), `past_12_months`. Use the words customers type ("emergency dentist", "odoo erp"), not industry jargon. Note keywords rising (positive `change`), the rising related searches (new questions to answer in content) and the peak months (post before the peak, not during it). Put these in their own "Search trends" section with one ```` ```mc:search-trends ```` block per call (first line the exact keywords, comma-separated; second line the location; the chart is drawn from the stored answer); they're Google searches, not Instagram trends.
8. Optional: web search "<niche> Instagram trend <month year>" and Meta's own creator/trending pages for audio and formats this month; mark anything you could not verify.
## Output → `clients/<slug>/trends/YYYY-MM-DD.md` ## Output → `clients/<slug>/trends/YYYY-MM-DD.md`
- Patterns table: pattern · example links · why it works · how this client does it (on-brand version). - Patterns table: pattern · example links · why it works · how this client does it (on-brand version).

View File

@ -12,9 +12,4 @@ For each client requested:
- At least one post that tests something new; say what it's testing. - 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`. 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). 6. Save to `clients/<slug>/calendar/YYYY-Www.md` (ISO week of the week being planned).
7. Put the week on the dashboard's Content calendar: first call `content_plan` for that week so nothing is doubled 7. Give the user a short summary and ask for approval before anything goes to the team or the client.
(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.

View File

@ -56,32 +56,3 @@ ANTHROPIC_API_KEY=
DASHBOARD_AI_MODEL=claude-opus-5-5 DASHBOARD_AI_MODEL=claude-opus-5-5
# Refresh every client's numbers at 03:00 server time # Refresh every client's numbers at 03:00 server time
DASHBOARD_NIGHTLY_REFRESH=true 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
# YouTube comments in the Inbox (public comments, read with an API key, not the channel's sign-in).
# Google Cloud (project Quorla) → APIs & Services → Credentials → Create API key → restrict to YouTube Data API v3.
# YOUTUBE_API_KEY=
# Google Places: real Google ratings/reviews for competitor scans. Google Cloud → enable Places API (New) →
# Create API key → restrict to Places API (New) + this server's IP. Can also be set in Server settings → Research.
# GOOGLE_PLACES_API_KEY=
# Google Trends via DataForSEO (dataforseo.com → API Access). Can also be set in Server settings → Research.
# DATAFORSEO_LOGIN=
# DATAFORSEO_PASSWORD=

9
.gitignore vendored
View File

@ -25,12 +25,3 @@ graphify-out/
# Personal Claude Code settings # Personal Claude Code settings
.claude/settings.local.json .claude/settings.local.json
# Local demo recordings
*.mp4
!docs/demo-assets/*.mp4
__pycache__/
# Hosted Quorla provisioning secrets
deploy/provision.env
deploy/tenant-shared.env

View File

@ -1,61 +0,0 @@
# Quorla: handoff (21 Oct 2026)
Where things stand and what's next. Live app: https://app.quorlasocial.com (HestiaCP server, `~/APPS/quorlasocial`,
Docker behind the Hestia nginx proxy on 127.0.0.1:4310). Hosting steps: `docs/hosting.html`.
## Live and working
- Dashboard on the server: team logins, admin can add teammates and reset passwords, scheduler runs there.
- Meta (Facebook + Instagram) through the system user. Infini is Instagram-only (no Facebook Page by choice).
- Connections page with quotas (`data/quota.json`; YouTube 10,000 units/day Pacific time, rolling 24 h for others).
- **TikTok:** Sandbox app "Quorla testing" connected (test user @metatron_digitalagency). Domain verified
(two DNS TXT records, both must stay). Details: `docs/tiktok-app.md`.
- **Google:** connecting works now.
- YouTube → Cloud project **Quorla**. GBP → project **GMB Audit** (gmb-audit-489406, GBP API already approved).
- Both are in Testing mode. Only test users can connect: dev@metatroncubesolutions.com and
metatroncubesoftwaresolutions@gmail.com. Add more under Google Auth Platform → Audience → Test users.
- Testing-mode tokens expire after 7 days, so reconnect weekly until the app is verified. Details: `docs/google-app.md`.
- Search Console: quorlasocial.com verified. Legal pages: /privacy and /terms.
## Deploy (server, as `dev`)
```bash
cd ~/APPS/quorlasocial && git pull && docker compose up -d --build
# Homepage for quorlasocial.com (replaces Hestia's "Coming Soon" page):
sudo cp -r ~/APPS/quorlasocial/site/. /home/user/web/quorlasocial.com/public_html/ && sudo chown -R user:user /home/user/web/quorlasocial.com/public_html
```
Google verification and TikTok review both check that quorlasocial.com explains the app and links to the privacy policy.
Request-access buttons email support@quorlasocial.com: make sure that mailbox exists (Hestia → Mail).
## To do next
1. **TikTok, YouTube and Google Business Profile are on the wrong client** (C2C Agricorp India). After the next
deploy, open Connections → C2C Agricorp India and click **Move** on each card, then pick
MetatronCube Software Solutions Canada. No reconnecting needed. Then choose the Metatroncube location for GBP.
2. First test posts (21 Oct): YouTube (private) and Google Business Profile posted fine. TikTok Direct Post is blocked
until TikTok reviews the app (public accounts); use **"Send to the TikTok app to finish"** in the TikTok options until
then (tested, works on the public business account). See `docs/tiktok-app.md`.
LinkedIn: app "Quorla" created and verified; Community Management API requested 21 Oct (watch info@ for
Microsoft Vetting Services). Keys are saved in Connections; Connect works once approved.
3. TikTok Production: record the demo video (script in `docs/tiktok-app.md`), fill in the listing, and submit for review.
4. Google: submit the OAuth app for verification (YouTube also needs its API audit before the quota goes above the default).
5. Still to set up: LinkedIn, Pinterest, Threads (Meta permissions) and Reddit apps (`docs/platforms.md`).
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.
- Keys and secrets live only in `.env` or the encrypted Connections vault, never in chat, Git, or docs.
- Tests: `node --test connector/test/platforms.test.mjs connector/test/quota.test.mjs`. On Windows, list the files by name;
passing the folder fails.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

View File

@ -192,194 +192,3 @@ input[type="checkbox"] {
border-radius: 3px; border-radius: 3px;
} }
input[type="checkbox"]:checked { background: var(--mc-blue); } input[type="checkbox"]:checked { background: var(--mc-blue); }
/* =========================================================
Visual blocks (tools/report-pdf/blocks.mjs)
========================================================= */
:root { --mc-good: #2e9e6b; --mc-warn: #d99a1e; --mc-bad: #d0543f; --mc-good-soft: #eaf6f0; --mc-warn-soft: #fdf5e4; --mc-bad-soft: #fcecea; }
/* Screen preview in the dashboard */
@media screen {
body.mc-screen { max-width: 860px; margin: 0 auto; padding: 28px 32px 48px; background: #fff; font-size: 10.5pt; }
}
.mc-nodata { font-size: 8pt; color: var(--mc-muted); font-style: italic; }
/* Cards */
.mc-card { margin: 12px 0; padding: 14px 16px 12px; border: 1px solid var(--mc-line); border-radius: 12px; background: #fff; break-inside: avoid; }
.mc-card figcaption { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; margin-bottom: 10px; }
.mc-card-title { font: 700 10.5pt var(--mc-head); color: var(--mc-charcoal); }
.mc-card-sub { font-size: 7.5pt; color: var(--mc-muted); text-align: right; }
.mc-card-foot { margin: 10px 0 0; padding-top: 8px; border-top: 1px dashed var(--mc-line); font-size: 8.5pt; color: var(--mc-gray); }
.mc-chart { display: block; width: 100%; height: auto; }
.mc-ax { font: 7pt var(--mc-body); fill: var(--mc-muted); }
/* KPI grid with sparklines */
.mc-kpigrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 12px 0; break-inside: avoid; }
.mc-kpi2 { position: relative; padding: 12px 14px 12px 16px; border-radius: 12px; background: var(--mc-tint); overflow: hidden; }
.mc-kpi2::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: var(--mc-blue); }
.mc-kpi2.is-bad::before { background: var(--mc-bad); }
.mc-kpi2.is-good::before { background: var(--mc-good); }
.mc-kpi2-top { display: flex; justify-content: space-between; align-items: flex-end; gap: 6px; }
.mc-kpi2-v { font: 800 20pt/1 var(--mc-head); color: var(--mc-charcoal); letter-spacing: -0.5px; }
.mc-kpi2-l { margin-top: 6px; font-size: 8pt; font-weight: 600; color: var(--mc-ink); }
.mc-kpi2-n { margin-top: 2px; font-size: 7.5pt; color: var(--mc-gray); }
.mc-kpi2.is-bad .mc-kpi2-n { color: var(--mc-bad); font-weight: 600; }
.mc-kpi2.is-good .mc-kpi2-n { color: var(--mc-good); font-weight: 600; }
.mc-spark { flex: none; }
/* Funnel */
.mc-fun { display: grid; gap: 4px; }
.mc-fun-conv { display: block; font-size: 7.5pt; color: var(--mc-muted); margin: 1px 0 2px 6px; }
.mc-fun-conv::before { content: "↓ "; }
.mc-fun-conv.is-bad { color: var(--mc-bad); font-weight: 700; }
.mc-fun-bar { min-width: 34px; height: 24px; border-radius: 6px; display: flex; align-items: center; padding: 0 8px; }
.mc-fun-bar span { font: 700 9pt var(--mc-head); color: #fff; }
.mc-fun-l { font-size: 8pt; color: var(--mc-gray); margin: 2px 0 0 2px; }
/* Followers gained vs lost */
.mc-flow { display: grid; grid-template-columns: 110px 1fr; gap: 16px; align-items: center; }
.mc-flow-net { text-align: center; padding: 12px 6px; border-radius: 12px; }
.mc-flow-net b { display: block; font: 800 24pt/1 var(--mc-head); }
.mc-flow-net span { font-size: 7.5pt; color: var(--mc-gray); }
.mc-flow-net.is-bad { background: var(--mc-bad-soft); color: var(--mc-bad); }
.mc-flow-net.is-good { background: var(--mc-good-soft); color: var(--mc-good); }
.mc-flow-row { display: grid; grid-template-columns: 70px 1fr 40px; align-items: center; gap: 8px; margin: 4px 0; font-size: 8.5pt; }
.mc-flow-track { height: 12px; border-radius: 6px; background: var(--mc-tint); overflow: hidden; }
.mc-flow-track div { height: 100%; border-radius: 6px; }
.mc-flow-v { font-weight: 700; text-align: right; }
.mc-flow-spark { display: flex; align-items: center; gap: 8px; margin-top: 6px; font-size: 7.5pt; color: var(--mc-muted); }
/* Formats: share of posts vs reach */
table.mc-pairs { margin: 0; font-size: 8.5pt; }
table.mc-pairs td { border-bottom: 1px solid var(--mc-line); padding: 6px; vertical-align: middle; }
table.mc-pairs tr:last-child td { border-bottom: 0; }
table.mc-pairs td:first-child { width: 80px; }
.mc-pair { position: relative; height: 20px; border-radius: 5px; background: var(--mc-tint); }
.mc-pair-bar { position: absolute; top: 0; bottom: 0; left: 0; border-radius: 5px; min-width: 3px; }
.mc-pair span { position: relative; line-height: 20px; padding-left: 8px; font-size: 7.8pt; font-weight: 600; color: var(--mc-charcoal); }
/* Posting rhythm */
.mc-rh { display: grid; grid-template-columns: auto 1fr; gap: 26px; align-items: center; }
.mc-rh-grid { display: grid; gap: 3px; }
.mc-rh-week { display: grid; grid-template-columns: 46px repeat(7, 14px); gap: 3px; align-items: center; }
.mc-rh-wl { font-size: 6.8pt; color: var(--mc-muted); }
.mc-rh-head b { font-size: 6.5pt; color: var(--mc-muted); text-align: center; font-weight: 600; }
.mc-rh-week i { width: 14px; height: 14px; border-radius: 3px; background: #edf0f5; }
.mc-rh-week i.is-on { background: var(--mc-blue); }
.mc-rh-week i.is-future { background: transparent; border: 1px dashed var(--mc-line); }
.mc-rh-stats { display: grid; gap: 10px; }
.mc-rh-stats div { display: flex; align-items: baseline; gap: 8px; }
.mc-rh-stats b { font: 800 18pt/1 var(--mc-head); color: var(--mc-charcoal); min-width: 44px; }
.mc-rh-stats span { font-size: 8.5pt; color: var(--mc-gray); }
/* Best / weakest posts */
.mc-posts { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
.mc-posts h4 { margin: 0 0 6px; font: 700 8pt var(--mc-head); letter-spacing: 0.8px; text-transform: uppercase; }
.mc-posts h4.is-good { color: var(--mc-good); }
.mc-posts h4.is-bad { color: var(--mc-bad); }
.mc-posts ol { list-style: none; padding: 0; margin: 0; }
.mc-posts li { padding: 7px 0; border-bottom: 1px solid var(--mc-line); }
.mc-posts li:last-child { border-bottom: 0; }
.mc-post-meta { font-size: 7.3pt; color: var(--mc-muted); }
.mc-chip { display: inline-block; margin-right: 6px; padding: 1px 6px; border-radius: 10px; background: var(--mc-tint); color: var(--mc-blue-deep); font-weight: 700; font-size: 6.8pt; text-transform: uppercase; letter-spacing: 0.4px; }
.mc-post-cap { margin: 3px 0 5px; font-size: 8.3pt; line-height: 1.4; color: var(--mc-ink); }
.mc-post-bar { height: 5px; border-radius: 3px; background: var(--mc-tint); overflow: hidden; }
.mc-post-bar div { height: 100%; background: linear-gradient(90deg, var(--mc-blue), var(--mc-sky)); border-radius: 3px; }
.mc-post-nums { margin-top: 3px; font-size: 7.5pt; color: var(--mc-gray); }
.mc-post-nums b { color: var(--mc-charcoal); }
/* Score */
.mc-score { display: grid; grid-template-columns: 130px 1fr; gap: 18px; align-items: center; margin: 12px 0; padding: 16px; border-radius: 14px; background: linear-gradient(135deg, var(--mc-tint), #fff); border: 1px solid var(--mc-line); break-inside: avoid; }
.mc-score-ring { text-align: center; font-size: 7.5pt; color: var(--mc-gray); }
.mc-score-ring svg { display: block; margin: 0 auto 4px; }
.mc-ring-num { font: 800 20px var(--mc-head); fill: var(--mc-charcoal); }
.mc-score-rows { display: grid; gap: 8px; }
.mc-score-head { display: flex; justify-content: space-between; font-size: 8.8pt; }
.mc-score-head span { font: 800 9pt var(--mc-head); }
.mc-score-track { height: 7px; margin: 3px 0; border-radius: 4px; background: #e8edf3; overflow: hidden; }
.mc-score-track div { height: 100%; border-radius: 4px; }
.mc-score-row p { margin: 0; font-size: 7.8pt; color: var(--mc-gray); line-height: 1.4; }
/* Actions */
ol.mc-actions { list-style: none; counter-reset: act; padding: 0; margin: 10px 0; display: grid; gap: 7px; }
ol.mc-actions li { counter-increment: act; display: grid; grid-template-columns: 28px 1fr auto; gap: 10px; align-items: center; padding: 10px 12px; border: 1px solid var(--mc-line); border-radius: 10px; break-inside: avoid; margin: 0; }
ol.mc-actions li::before { content: counter(act); display: grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; background: var(--mc-blue); color: #fff; font: 800 9pt var(--mc-head); }
.mc-act-what { font-size: 9pt; line-height: 1.45; }
.mc-act-meta { display: flex; flex-direction: column; align-items: flex-end; gap: 3px; white-space: nowrap; }
.mc-owner { padding: 2px 8px; border-radius: 10px; font-size: 7.3pt; font-weight: 700; }
.mc-owner.is-us { background: #e6f0fa; color: var(--mc-blue-deep); }
.mc-owner.is-client { background: var(--mc-warn-soft); color: #9a6a0c; }
.mc-when { font-size: 7.3pt; color: var(--mc-muted); }
/* Callouts */
.mc-alert { margin: 10px 0; padding: 11px 14px 11px 40px; border-radius: 10px; position: relative; break-inside: avoid; font-size: 9.2pt; }
.mc-alert::before { position: absolute; left: 13px; top: 11px; width: 18px; height: 18px; border-radius: 50%; display: grid; place-items: center; color: #fff; font: 800 9pt var(--mc-head); }
.mc-alert p { margin: 3px 0; }
.mc-alert-title { font: 700 9.8pt var(--mc-head); margin-bottom: 2px; }
.mc-alert.is-good { background: var(--mc-good-soft); }
.mc-alert.is-good::before { content: "✓"; background: var(--mc-good); }
.mc-alert.is-good .mc-alert-title { color: #1f7a51; }
.mc-alert.is-warn { background: var(--mc-warn-soft); }
.mc-alert.is-warn::before { content: "!"; background: var(--mc-warn); }
.mc-alert.is-warn .mc-alert-title { color: #8a5d06; }
.mc-alert.is-bad { background: var(--mc-bad-soft); }
.mc-alert.is-bad::before { content: "!"; background: var(--mc-bad); }
.mc-alert.is-bad .mc-alert-title { color: #a63a28; }
.mc-alert.is-info { background: var(--mc-tint); }
.mc-alert.is-info::before { content: "i"; background: var(--mc-blue); }
.mc-alert.is-info .mc-alert-title { color: var(--mc-blue-deep); }
/* Screenshot with numbered notes */
.mc-shot { display: grid; grid-template-columns: 58% 1fr; gap: 14px; margin: 12px 0; align-items: start; break-inside: avoid; }
.mc-shot.is-solo { grid-template-columns: 1fr; }
.mc-shot img { width: 100%; border-radius: 10px; border: 1px solid var(--mc-line); }
ol.mc-shot-notes { list-style: none; counter-reset: note; padding: 0; margin: 0; display: grid; gap: 8px; }
ol.mc-shot-notes li { counter-increment: note; position: relative; padding-left: 26px; font-size: 8.8pt; line-height: 1.45; margin: 0; }
/* Competitors */
:root { --mc-you: #fc6d45; --mc-you-soft: #fff1ec; }
.mc-chip.is-you { background: var(--mc-you); color: #fff; }
.mc-chip.is-bench { background: #e6f4fb; color: #1f7aa8; }
.mc-chip.is-new { background: var(--mc-warn-soft); color: #9a6a0c; }
table.mc-lb { margin: 0; font-size: 8.6pt; }
table.mc-lb th { background: transparent; border-bottom: 1px solid var(--mc-line); padding: 4px 8px; }
table.mc-lb td { padding: 7px 8px; vertical-align: middle; }
table.mc-lb td:first-child { font-weight: 800; color: var(--mc-muted); width: 24px; }
table.mc-lb td.num { white-space: nowrap; font-weight: 600; }
table.mc-lb tr.is-you td { background: var(--mc-you-soft); }
table.mc-lb tr.is-you td:first-child { color: var(--mc-you); border-left: 3px solid var(--mc-you); }
.mc-lb-name { font-weight: 650; color: var(--mc-charcoal); }
.mc-lb-name .mc-chip { margin: 0 0 0 4px; vertical-align: 1px; }
.mc-lb-handle, .mc-lb-sub { font-size: 7.3pt; color: var(--mc-muted); font-weight: 400; }
.mc-lb-sub.is-good { color: var(--mc-good); }
.mc-lb-sub.is-bad { color: var(--mc-bad); }
.mc-lb-bar { position: relative; height: 18px; min-width: 130px; border-radius: 5px; background: var(--mc-tint); }
.mc-lb-bar div { position: absolute; top: 0; bottom: 0; left: 0; border-radius: 5px; background: linear-gradient(90deg, var(--mc-blue), var(--mc-sky)); min-width: 3px; }
tr.is-you .mc-lb-bar div { background: var(--mc-you); }
.mc-lb-bar span { position: relative; padding-left: 7px; line-height: 18px; font-size: 7.8pt; font-weight: 700; color: var(--mc-charcoal); }
.mc-quad { font: 700 7.5px var(--mc-head); fill: #b3b9c4; letter-spacing: 0.6px; text-transform: uppercase; }
.mc-dot-l { font: 600 7.5px var(--mc-body); fill: var(--mc-gray); }
.mc-dot-l.is-you { fill: var(--mc-you); font-weight: 800; font-size: 9px; }
.mc-fm { display: grid; gap: 5px; }
.mc-fm-row { display: grid; grid-template-columns: 130px 1fr; gap: 10px; align-items: center; font-size: 8pt; }
.mc-fm-row.is-you .mc-fm-l { color: var(--mc-you); font-weight: 800; }
.mc-fm-l { color: var(--mc-gray); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mc-fm-bar { display: flex; height: 12px; border-radius: 6px; overflow: hidden; background: var(--mc-tint); }
.mc-fm-legend { display: flex; flex-wrap: wrap; gap: 6px 16px; margin-top: 10px; font-size: 7.6pt; color: var(--mc-gray); }
.mc-fm-legend i { display: inline-block; width: 9px; height: 9px; border-radius: 2px; margin-right: 5px; vertical-align: -1px; }
.mc-tags { display: flex; flex-wrap: wrap; gap: 6px; }
.mc-tag { padding: 4px 10px; border-radius: 14px; background: var(--mc-tint); color: var(--mc-blue-deep); font-size: 8pt; font-weight: 600; }
.mc-tag.is-hot { background: var(--mc-blue); color: #fff; }
.mc-tag b { margin-left: 6px; padding: 0 5px; border-radius: 8px; background: rgba(255, 255, 255, 0.25); font-size: 7pt; }
.mc-ideas { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.mc-idea { padding: 9px 11px; border-radius: 10px; background: var(--mc-tint); break-inside: avoid; }
.mc-gg { display: grid; gap: 6px; }
.mc-gg-row { display: grid; grid-template-columns: 150px 48px 1fr 70px; gap: 8px; align-items: center; font-size: 8.3pt; }
.mc-gg-row.is-you .mc-gg-l { color: var(--mc-you); font-weight: 800; }
.mc-gg-l { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mc-gg-star { color: #c98a00; font-weight: 700; }
.mc-gg-bar { height: 10px; border-radius: 5px; background: var(--mc-tint); overflow: hidden; }
.mc-gg-bar div { height: 100%; border-radius: 5px; background: #f2b42c; }
.mc-gg-n { font-size: 7.6pt; color: var(--mc-gray); text-align: right; }
ol.mc-shot-notes li::before { content: counter(note); position: absolute; left: 0; top: 0; width: 18px; height: 18px; border-radius: 50%; background: var(--mc-charcoal); color: #fff; display: grid; place-items: center; font: 700 8pt var(--mc-head); }

View File

@ -1,71 +0,0 @@
// 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;
}

View File

@ -1,256 +0,0 @@
// 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";
import * as places from "./places.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. */
export 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(mediaOf),
};
}
const tagsOf = (caption) => [...new Set((String(caption || "").match(/#[\p{L}\p{N}_]+/gu) || []).map((t) => t.toLowerCase()))].slice(0, 30);
const typeOf = (m) => (m.media_product_type === "REELS" ? "Reel" : m.media_type === "CAROUSEL_ALBUM" ? "Carousel" : m.media_type === "VIDEO" ? "Video" : "Image");
const mediaOf = (m) => ({ id: m.id, at: m.timestamp, likes: m.like_count ?? null, comments: m.comments_count ?? null, type: typeOf(m), permalink: m.permalink, caption: (m.caption || "").slice(0, 200), tags: tagsOf(m.caption) });
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 = "", kind = "local", google = null } = {}) {
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, kind: KINDS.includes(kind) ? kind : "local", note: String(note).slice(0, 300), google, 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}`); }
// Cached for 30 days, so this calls Google about once a month per account.
if (a.google?.placeId && places.enabled()) { try { a.google = await places.ratingOf(a.google.placeId); } catch {} }
}
}
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 avgEng = median(last12.map(engagement));
const mix = {}, byType = {};
for (const m of media.slice(0, 25)) { mix[m.type] = (mix[m.type] || 0) + 1; (byType[m.type] ||= []).push(engagement(m)); }
// Hashtags on the account's 8 best posts: what the rivals that win actually use.
const tags = {};
for (const m of [...media].sort((a, b) => engagement(b) - engagement(a)).slice(0, 8)) for (const t of m.tags || []) tags[t] = (tags[t] || 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,
engagementByFormat: Object.fromEntries(Object.entries(byType).map(([k, v]) => [k, median(v)])),
topTags: Object.entries(tags).sort((a, b) => b[1] - a[1]).slice(0, 12).map(([t]) => t),
allTags: [...new Set(media.flatMap((m) => m.tags || []))],
topPosts: [...media].sort((a, b) => engagement(b) - engagement(a)).slice(0, 3),
};
}
function median(xs) {
const s = [...xs].sort((x, y) => x - y);
return s.length ? Math.round(s.length % 2 ? s[(s.length - 1) / 2] : (s[s.length / 2 - 1] + s[s.length / 2]) / 2) : null;
}
/**
* 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, kind: a.kind || "local", google: a.google || null, 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(mediaOf);
you = { username: own.username, own: true, google: read()[client]?.self?.google || null, ...stats({ followers: own.followers ?? null, media, history: own.history || [] }), series: (own.history || []).filter((h) => h.followers != null) };
}
return { you, competitors: accounts };
}
// ---------- AI suggestions: accounts a run found; a person adds or dismisses each one ----------
const KINDS = ["local", "benchmark"];
const clip = (s, n) => String(s || "").slice(0, n);
const suggestionView = ({ snapshot, ...s }) => ({ ...s, ...stats({ followers: snapshot?.followers ?? null, media: snapshot?.media || [], history: [] }), bio: snapshot?.bio || "" });
/** Google rating for a candidate: checked through Places when a place id is given, else what the AI read on the web (unverified). */
async function googleOf(s) {
if (s.placeId && places.enabled()) { try { return await places.ratingOf(s.placeId); } catch {} }
return s.google && Number.isFinite(+s.google.rating) ? { rating: +s.google.rating, reviews: Number.isFinite(+s.google.reviews) ? +s.google.reviews : null, verified: false } : null;
}
/** The client's own Google listing (by place id), stored so reports can put the client in the Google comparison. */
export async function setClientPlace(client, placeId) {
checkClient(client);
const google = await places.ratingOf(placeId);
const db = read();
(db[client] ||= { accounts: [] }).self = { google };
write(db);
return google;
}
/**
* Check each candidate on Instagram and store the real ones as suggestions.
* list: [{ username, kind: "local" | "benchmark", why, placeId?, google?: { rating, reviews } }]
* Returns the verified numbers (so the report quotes Instagram, not guesses) and why the rest were skipped.
*/
export async function suggest(client, list, { by = "agent" } = {}) {
checkClient(client);
const igId = await viewerIgId(client);
const own = clientLink(client);
const known = read()[client] || {};
const checked = [], skipped = [];
for (const s of (list || []).slice(0, 15)) {
const u = handle(s.username);
if (!/^[a-z0-9._]{1,30}$/.test(u)) { skipped.push({ username: s.username, reason: "not an Instagram handle" }); continue; }
if (known.accounts?.some((a) => a.username === u)) { skipped.push({ username: u, reason: "already tracked" }); continue; }
if (known.suggestions?.some((x) => x.username === u && x.status === "dismissed")) { skipped.push({ username: u, reason: "the team dismissed it before" }); continue; }
try {
const b = await lookup(igId, u);
if (own.igId && String(b.id) === String(own.igId)) { skipped.push({ username: u, reason: "the client's own account" }); continue; }
const g = await googleOf(s);
checked.push({ username: u, name: b.name || null, kind: KINDS.includes(s.kind) ? s.kind : "local", why: clip(s.why, 300), google: g, by, at: new Date().toISOString(), status: "new", snapshot: snapshotOf(b) });
} catch (e) {
skipped.push({ username: u, reason: e.message });
}
}
// Re-read before writing: lookups take a while and the nightly refresh may have saved in between.
const db = read();
const c = (db[client] ||= { accounts: [] });
c.suggestions = [...(c.suggestions || []).filter((x) => !checked.some((n) => n.username === x.username)), ...checked];
write(db);
return { checked: checked.map(suggestionView).map(({ allTags, ...v }) => v), skipped };
}
export function listSuggestions(client) {
checkClient(client);
return (read()[client]?.suggestions || []).filter((s) => s.status === "new").map(suggestionView);
}
function setSuggestion(client, username, status) {
const db = read(), u = handle(username);
const s = db[client]?.suggestions?.find((x) => x.username === u);
if (!s) throw err("Suggestion not found.", 404);
s.status = status;
write(db);
return s;
}
export function dismissSuggestion(client, username) {
checkClient(client);
setSuggestion(client, username, "dismissed");
return { ok: true };
}
export async function acceptSuggestion(client, username, { by = "team" } = {}) {
checkClient(client);
const s = (read()[client]?.suggestions || []).find((x) => x.username === handle(username));
if (!s) throw err("Suggestion not found.", 404);
const added = await add(client, s.username, { by, source: "suggested", note: s.why, kind: s.kind, google: s.google });
setSuggestion(client, s.username, "added");
return added;
}

View File

@ -2,7 +2,6 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { open } from "./sealed.js";
const here = path.dirname(fileURLToPath(import.meta.url)); const here = path.dirname(fileURLToPath(import.meta.url));
export const PROJECT_ROOT = path.resolve(here, "..", ".."); export const PROJECT_ROOT = path.resolve(here, "..", "..");
@ -11,7 +10,7 @@ export function loadEnv() {
// Docker's env_file keeps stray spaces/tabs after "=", which breaks tokens. Trim everything we read. // Docker's env_file keeps stray spaces/tabs after "=", which breaks tokens. Trim everything we read.
for (const [k, v] of Object.entries(process.env)) if (/^[A-Z0-9_]+$/.test(k) && v !== v.trim()) process.env[k] = v.trim(); for (const [k, v] of Object.entries(process.env)) if (/^[A-Z0-9_]+$/.test(k) && v !== v.trim()) process.env[k] = v.trim();
const file = path.join(PROJECT_ROOT, ".env"); const file = path.join(PROJECT_ROOT, ".env");
if (!fs.existsSync(file)) return applySaved(); if (!fs.existsSync(file)) return;
for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) { for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/); const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
if (!m || line.trim().startsWith("#")) continue; if (!m || line.trim().startsWith("#")) continue;
@ -19,25 +18,4 @@ export function loadEnv() {
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1); if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
if (!(m[1] in process.env)) process.env[m[1]] = v; if (!(m[1] in process.env)) process.env[m[1]] = v;
} }
applySaved();
}
// Settings saved on the site (Settings → Server settings) override .env. Stored sealed in data/settings.json.
export const SETTINGS_FILE = process.env.QUORLA_SETTINGS_FILE || path.join(PROJECT_ROOT, "data", "settings.json"); // override for tests
/** What .env (or the container) said before a site setting replaced it, so clearing one falls back correctly. */
export const ENV_BASE = {};
export function savedSettings() {
try { return open(JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf8")).sealed) || {}; } catch { return {}; }
}
/** Set one variable from the site (or restore the .env value when value is null). */
export function applySetting(k, value) {
if (!(k in ENV_BASE)) ENV_BASE[k] = process.env[k];
const v = value ?? ENV_BASE[k];
if (v === undefined) delete process.env[k]; else process.env[k] = v;
}
function applySaved() {
for (const [k, v] of Object.entries(savedSettings())) if (/^[A-Z0-9_]+$/.test(k)) applySetting(k, String(v));
} }

View File

@ -1,77 +0,0 @@
// Instagram Hashtag Search: top and recent public posts under a hashtag, to spot Reels and formats taking off in a
// client's niche from accounts we don't track yet. Needs Meta's "Instagram Public Content Access" feature and the
// Server settings switch RESEARCH_IG_HASHTAGS=true. Instagram allows 30 different hashtags per Instagram account
// per 7 days; we count them here so a run never trips the limit, and keep answers for 24 hours.
import fs from "node:fs";
import path from "node:path";
import { PROJECT_ROOT } from "./env.js";
import * as meta from "./meta.js";
const FILE = path.join(PROJECT_ROOT, "data", "hashtag-search.json");
const WEEK = 7 * 86400000, DAY = 86400000, LIMIT = 30;
const FIELDS = "id,media_type,media_product_type,like_count,comments_count,permalink,caption,timestamp";
const err = (msg, status = 400) => Object.assign(new Error(msg), { status });
export const enabled = () => process.env.RESEARCH_IG_HASHTAGS === "true";
const read = () => { try { return JSON.parse(fs.readFileSync(FILE, "utf8")); } catch { return { used: {}, ids: {}, cache: {} }; } };
const write = (db) => { fs.mkdirSync(path.dirname(FILE), { recursive: true }); fs.writeFileSync(FILE + ".tmp", JSON.stringify(db)); fs.renameSync(FILE + ".tmp", FILE); };
const clean = (t) => String(t || "").trim().replace(/^#/, "").toLowerCase();
const typeOf = (m) => (m.media_product_type === "REELS" ? "Reel" : m.media_type === "CAROUSEL_ALBUM" ? "Carousel" : m.media_type === "VIDEO" ? "Video" : "Image");
/** Hashtags this Instagram account searched in the last 7 days (Instagram's own count). */
function usedThisWeek(db, igId) {
const now = Date.now();
return (db.used[igId] || []).filter((u) => now - u.at < WEEK);
}
export function quota(igId) {
const used = usedThisWeek(read(), igId);
return { used: used.length, left: Math.max(0, LIMIT - used.length), tags: used.map((u) => u.tag), resetsAt: used.length ? new Date(Math.min(...used.map((u) => u.at)) + WEEK).toISOString() : null };
}
/**
* Top (most engaged) or recent posts under one hashtag, searched through igId (the client's own Instagram).
* Returns posts with type, likes, comments, link and caption; Instagram doesn't say who posted them.
*/
export async function topPosts(igId, tag, { kind = "top", limit = 25 } = {}) {
if (!enabled()) throw err("Hashtag search is switched off. Turn it on in Server settings → Research once Meta has approved Instagram Public Content Access.");
const t = clean(tag);
if (!/^[\p{L}\p{N}_]{2,100}$/u.test(t)) throw err("Not a hashtag.");
const edge = kind === "recent" ? "recent_media" : "top_media";
let db = read();
const key = `${igId}:${t}:${edge}`;
const hit = db.cache[key];
if (hit && Date.now() - hit.at < DAY) return { ...hit.data, cached: true };
const used = usedThisWeek(db, igId);
if (!used.some((u) => u.tag === t) && used.length >= LIMIT)
throw err(`Instagram allows ${LIMIT} different hashtags per account per week and this account has used them all (${used.map((u) => "#" + u.tag).join(", ")}). Try again after ${new Date(Math.min(...used.map((u) => u.at)) + WEEK).toISOString().slice(0, 10)}, or reuse one of those.`, 429);
let id = db.ids[t];
if (!id) {
const r = await meta.graph("ig_hashtag_search", { user_id: igId, q: t });
id = r.data?.[0]?.id;
if (!id) throw err(`Instagram has no hashtag #${t}.`, 404);
}
const r = await meta.graph(`${id}/${edge}`, { user_id: igId, fields: FIELDS, limit: Math.min(Math.max(+limit || 25, 1), 50) });
const posts = (r.data || []).map((m) => ({
type: typeOf(m), likes: m.like_count ?? null, comments: m.comments_count ?? null, at: m.timestamp || null, link: m.permalink, caption: (m.caption || "").slice(0, 300),
}));
const reels = posts.filter((p) => p.type === "Reel");
const data = {
hashtag: t, kind: edge === "top_media" ? "top" : "recent", checkedAt: new Date().toISOString(), posts,
formatMix: Object.fromEntries(["Reel", "Carousel", "Image", "Video"].map((k) => [k, posts.filter((p) => p.type === k).length]).filter(([, n]) => n)),
note: "Instagram doesn't say who posted these. Likes can be hidden by the poster (shown as null).",
reelsShare: posts.length ? Math.round((reels.length / posts.length) * 100) : null,
};
db = read(); // re-read: another run may have searched meanwhile
db.ids[t] = id;
const list = usedThisWeek(db, igId);
if (!list.some((u) => u.tag === t)) list.push({ tag: t, at: Date.now() });
db.used[igId] = list;
db.cache[key] = { at: Date.now(), data };
for (const [k, v] of Object.entries(db.cache)) if (Date.now() - v.at > DAY) delete db.cache[k];
write(db);
return { ...data, quota: { used: list.length, left: LIMIT - list.length } };
}

View File

@ -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."); if (format === "CAROUSEL" && (files.length < 2 || files.length > 10)) throw err("A carousel needs 2–10 files.");
} }
export function createItem({ client, title, format = "IMAGE", files = [], captionIdea = "", notes = "", taskRef = "", forDate = "", uploadedBy = "team", planItem = "" }) { export function createItem({ client, title, format = "IMAGE", files = [], captionIdea = "", notes = "", taskRef = "", forDate = "", uploadedBy = "team" }) {
checkClient(client); checkClient(client);
if (!FORMATS.includes(format)) throw err("Unknown format."); if (!FORMATS.includes(format)) throw err("Unknown format.");
validateFiles(format, files); validateFiles(format, files);
const item = { const item = {
id: crypto.randomUUID().slice(0, 8), client, title: String(title || "").trim() || path.basename(files[0]).replace(/^\d+-\w{4}-/, ""), id: crypto.randomUUID().slice(0, 8), client, title: String(title || "").trim() || path.basename(files[0]).replace(/^\d+-\w{4}-/, ""),
format, files, captionIdea, notes, taskRef, forDate, planItem: String(planItem || ""), status: "submitted", version: 1, format, files, captionIdea, notes, taskRef, forDate, status: "submitted", version: 1,
uploadedBy, createdAt: now(), updatedAt: now(), history: [{ at: now(), by: uploadedBy, action: "submitted" }], postIds: [], uploadedBy, createdAt: now(), updatedAt: now(), history: [{ at: now(), by: uploadedBy, action: "submitted" }], postIds: [],
}; };
const all = read(); all.push(item); write(all); return item; const all = read(); all.push(item); write(all); return item;

View File

@ -1,33 +1,16 @@
// Thin Meta Graph API client for Facebook Pages + Instagram (via Facebook Login / System User token). // Thin Meta Graph API client for Facebook Pages + Instagram (via Facebook Login / System User token).
import { loadEnv } from "./env.js"; import { loadEnv } from "./env.js";
import * as vault from "./platforms/vault.js";
loadEnv(); loadEnv();
const VERSION = process.env.META_API_VERSION || "v23.0"; const VERSION = process.env.META_API_VERSION || "v23.0";
const BASE = `https://graph.facebook.com/${VERSION}`; 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() { function systemToken() {
const env = process.env.META_SYSTEM_USER_TOKEN; const t = process.env.META_SYSTEM_USER_TOKEN;
if (env && !env.startsWith("PASTE")) return env; if (!t || t.startsWith("PASTE")) {
const saved = vault.appCredentials("meta"); throw new Error("META_SYSTEM_USER_TOKEN is missing. Put it in the .env file in the project root.");
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).");
} }
/** Save the system user token from Settings; checks it works first. */ return t;
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 } = {}) { export async function graph(pathname, params = {}, { method = "GET", token } = {}) {
@ -143,7 +126,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 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 } = {}) { 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,profile_picture_url" }); const profile = await graph(igId, { fields: "id,username,name,biography,followers_count,follows_count,media_count,website" });
// total_value metrics allow max 30-day windows // 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 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"); const plain = metrics.filter((m) => m !== "follows_and_unfollows");
@ -165,7 +148,7 @@ export async function igAccountInsights(igId, { sinceDays = 28, metrics = IG_ACC
export async function igRecentMedia(igId, { limit = 12, withInsights = true } = {}) { export async function igRecentMedia(igId, { limit = 12, withInsights = true } = {}) {
const r = await graph(`${igId}/media`, { const r = await graph(`${igId}/media`, {
fields: "id,caption,media_type,media_product_type,media_url,thumbnail_url,permalink,timestamp,like_count,comments_count", fields: "id,caption,media_type,media_product_type,permalink,timestamp,like_count,comments_count",
limit, limit,
}); });
const media = r.data || []; const media = r.data || [];
@ -221,7 +204,7 @@ export async function pageRecentPosts(account, { limit = 10 } = {}) {
needPage(account); needPage(account);
const r = await graph( const r = await graph(
`${account.page_id}/posts`, `${account.page_id}/posts`,
{ fields: "id,message,created_time,permalink_url,full_picture,shares,reactions.summary(total_count).limit(0),comments.summary(total_count).limit(0)", limit }, { fields: "id,message,created_time,permalink_url,shares,reactions.summary(total_count).limit(0),comments.summary(total_count).limit(0)", limit },
{ token: account.page_token } { token: account.page_token }
); );
return (r.data || []).map((p) => ({ return (r.data || []).map((p) => ({
@ -229,7 +212,6 @@ export async function pageRecentPosts(account, { limit = 10 } = {}) {
created_time: p.created_time, created_time: p.created_time,
permalink_url: p.permalink_url, permalink_url: p.permalink_url,
message: p.message, message: p.message,
picture: p.full_picture || null,
reactions: p.reactions?.summary?.total_count ?? 0, reactions: p.reactions?.summary?.total_count ?? 0,
comments: p.comments?.summary?.total_count ?? 0, comments: p.comments?.summary?.total_count ?? 0,
shares: p.shares?.count ?? 0, shares: p.shares?.count ?? 0,

View File

@ -1,76 +0,0 @@
// Google Places API (New): real Google ratings, review counts and recent reviews for any public business.
// Key: GOOGLE_PLACES_API_KEY (Server settings → Research). Answers are cached for up to 30 days (Google's limit
// for keeping Places content), so re-running a scan or re-exporting a report doesn't call Google again.
import fs from "node:fs";
import path from "node:path";
import { PROJECT_ROOT } from "./env.js";
const API = "https://places.googleapis.com/v1";
const CACHE = path.join(PROJECT_ROOT, "data", "places-cache.json");
const KEEP_MS = 30 * 86400000;
const BASIC = ["id", "displayName", "formattedAddress", "rating", "userRatingCount", "websiteUri", "googleMapsUri", "primaryTypeDisplayName", "businessStatus"];
const REVIEWS = ["reviews.rating", "reviews.text", "reviews.relativePublishTimeDescription", "reviews.publishTime", "reviews.authorAttribution.displayName"];
const err = (msg, status = 400) => Object.assign(new Error(msg), { status });
export const enabled = () => Boolean(process.env.GOOGLE_PLACES_API_KEY) && process.env.RESEARCH_GOOGLE_PLACES !== "false";
function readCache() {
try {
const all = JSON.parse(fs.readFileSync(CACHE, "utf8")), now = Date.now();
return Object.fromEntries(Object.entries(all).filter(([, v]) => now - v.at < KEEP_MS));
} catch { return {}; }
}
function cached(key, fn) {
if (process.env.RESEARCH_GOOGLE_PLACES === "false") return Promise.reject(err("Google Places is switched off in Server settings → Research."));
const hit = readCache()[key];
if (hit) return Promise.resolve(hit.data);
return fn().then((data) => {
const all = readCache();
all[key] = { at: Date.now(), data };
fs.mkdirSync(path.dirname(CACHE), { recursive: true });
fs.writeFileSync(CACHE + ".tmp", JSON.stringify(all));
fs.renameSync(CACHE + ".tmp", CACHE);
return data;
});
}
async function call(url, fields, body) {
if (!enabled()) throw err("Google Places isn't set up: add the Google Places API key in Server settings → Research.");
const res = await fetch(url, {
method: body ? "POST" : "GET",
headers: { "X-Goog-Api-Key": process.env.GOOGLE_PLACES_API_KEY, "X-Goog-FieldMask": fields.join(","), ...(body ? { "Content-Type": "application/json" } : {}) },
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw err(`Google Places: ${data.error?.message || res.statusText}`, res.status === 429 ? 429 : 502);
return data;
}
const view = (p) => ({
placeId: p.id, name: p.displayName?.text || null, address: p.formattedAddress || null, type: p.primaryTypeDisplayName?.text || null,
rating: p.rating ?? null, reviews: p.userRatingCount ?? 0, website: p.websiteUri || null, mapsUrl: p.googleMapsUri || null,
closed: p.businessStatus && p.businessStatus !== "OPERATIONAL" ? p.businessStatus : undefined,
...(p.reviews ? { recentReviews: p.reviews.map((r) => ({ rating: r.rating, when: r.relativePublishTimeDescription, at: r.publishTime, author: r.authorAttribution?.displayName || null, text: (r.text?.text || "").slice(0, 600) })) } : {}),
});
/** Businesses matching a text search, e.g. "digital marketing agency Waterloo ON" (up to 20). */
export function search(query, { max = 10 } = {}) {
const q = String(query || "").trim();
if (!q) throw err("Search text is required.");
const n = Math.min(Math.max(+max || 10, 1), 20);
return cached(`s:${q.toLowerCase()}:${n}`, async () =>
((await call(`${API}/places:searchText`, BASIC.map((f) => `places.${f}`), { textQuery: q, pageSize: n })).places || []).map(view));
}
/** One business by its place id; reviews=true adds up to 5 recent reviews. */
export function details(placeId, { reviews = false } = {}) {
const id = String(placeId || "").trim().replace(/^places\//, "");
if (!/^[\w-]{10,}$/.test(id)) throw err("Not a Google place id.");
return cached(`d:${id}:${reviews ? 1 : 0}`, async () => view(await call(`${API}/places/${id}`, reviews ? [...BASIC, ...REVIEWS] : BASIC)));
}
/** Rating snapshot stored with a competitor: checked by the server, not typed by the AI. */
export async function ratingOf(placeId) {
const d = await details(placeId);
return { rating: d.rating, reviews: d.reviews, placeId: d.placeId, mapsUrl: d.mapsUrl, verified: true, checkedAt: new Date().toISOString().slice(0, 10) };
}

View File

@ -1,128 +0,0 @@
// 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.");
}

View File

@ -47,28 +47,6 @@ export default {
return out; 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 }) { async publish({ tokens, conn, post, opts, caption }) {
const location = conn.target?.id; const location = conn.target?.id;
if (!location) throw new Error("Google Business Profile: pick the business location on the Connections page first."); if (!location) throw new Error("Google Business Profile: pick the business location on the Connections page first.");

View File

@ -24,21 +24,11 @@ export async function request(platform, url, { method = "GET", headers = {}, jso
try { data = text ? JSON.parse(text) : null; } catch { data = text; } try { data = text ? JSON.parse(text) : null; } catch { data = text; }
const apiError = data && typeof data === "object" && (data.error?.message || data.error_description || (typeof data.error === "string" && data.error) || data.message && !res.ok && data.message); const apiError = data && typeof data === "object" && (data.error?.message || data.error_description || (typeof data.error === "string" && data.error) || data.message && !res.ok && data.message);
if (!res.ok || (data && typeof data === "object" && data.error && data.error.code && data.error.code !== "ok")) { if (!res.ok || (data && typeof data === "object" && data.error && data.error.code && data.error.code !== "ok")) {
const code = data && typeof data === "object" && typeof data.error?.code === "string" ? data.error.code : null; throw new PlatformError(platform, apiError || `${res.status} ${res.statusText}`, { status: res.status, body: data });
const msg = HINTS[code] || [apiError || `${res.status} ${res.statusText}`, code && `(${code})`].filter(Boolean).join(" ");
throw new PlatformError(platform, msg, { status: res.status, body: data });
} }
return { data, headers: res.headers, status: res.status }; return { data, headers: res.headers, status: res.status };
} }
// Plain-language fixes for error codes whose API message doesn't say what to do.
const HINTS = {
unaudited_client_can_only_post_to_private_accounts: "until TikTok approves the app, it can only post to private accounts. Set the TikTok account to Private (Settings and privacy → Privacy → Private account) and try again. (unaudited_client_can_only_post_to_private_accounts)",
spam_risk_too_many_posts: "TikTok's daily post limit for this account was reached. Try again tomorrow. (spam_risk_too_many_posts)",
spam_risk_user_banned_from_posting: "TikTok has blocked this account from posting. (spam_risk_user_banned_from_posting)",
privacy_level_option_mismatch: "the chosen audience isn't allowed for this account. Pick one of the options shown. (privacy_level_option_mismatch)",
};
export const fileName = (p) => path.basename(String(p)).replace(/^\d+-[a-z0-9]{4}-/i, ""); export const fileName = (p) => path.basename(String(p)).replace(/^\d+-[a-z0-9]{4}-/i, "");
export const isUrl = (s) => /^https?:\/\//i.test(String(s || "")); export const isUrl = (s) => /^https?:\/\//i.test(String(s || ""));
export const isVideo = (s) => /\.(mp4|mov|m4v|webm)$/i.test(String(s || "").split("?")[0]); export const isVideo = (s) => /\.(mp4|mov|m4v|webm)$/i.test(String(s || "").split("?")[0]);

View File

@ -97,45 +97,6 @@ export async function setTarget(connId, targetId) {
return vault.updateConnection(connId, { target: t }); 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 key = process.env.YOUTUBE_API_KEY;
if (!key) throw err("YouTube comments need YOUTUBE_API_KEY in .env (Google Cloud → Credentials → API key, restricted to YouTube Data API v3).");
quota.ensure("youtube", null, "read");
const out = await adapter("youtube").comments(key, 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. */ /** Re-read the account (name, followers) to check the connection still works. */
export async function checkConnection(connId) { export async function checkConnection(connId) {
const { conn, tokens } = await tokensFor(connId); const { conn, tokens } = await tokensFor(connId);

View File

@ -1,8 +1,5 @@
// LinkedIn: post to a person's profile or to a company Page they administer. // LinkedIn: post to a person's profile or to a company Page they administer.
// Company Pages need the Community Management API product on the LinkedIn app (approval required). // Company Pages need the Community Management API product on the LinkedIn app (approval required).
import fs from "node:fs";
import path from "node:path";
import { PROJECT_ROOT } from "../env.js";
import { request, bytesOf, isVideo, sizeOf } from "./http.js"; import { request, bytesOf, isVideo, sizeOf } from "./http.js";
const API = "https://api.linkedin.com"; const API = "https://api.linkedin.com";
@ -49,38 +46,6 @@ async function uploadVideo(token, owner, src) {
return video; return video;
} }
// Company Page names. LinkedIn caps lookups per app and member per day, so names are cached for a day and
// fetched in batches of 50, from organizationsLookup first and the organizations resource if that is capped.
// Kept on disk so restarts don't spend the day's lookups again.
const NAME_TTL = 30 * 24 * 3600 * 1000;
const NAME_FILE = path.join(PROJECT_ROOT, "data", "linkedin-pages.json");
const nameCache = new Map((() => { try { return Object.entries(JSON.parse(fs.readFileSync(NAME_FILE, "utf8"))); } catch { return []; } })());
const saveNames = () => { try { fs.writeFileSync(NAME_FILE, JSON.stringify(Object.fromEntries(nameCache))); } catch { /* cache only */ } };
async function pageNames(token, ids) {
const names = {};
const missing = [];
for (const id of ids) {
const hit = nameCache.get(id);
if (hit && Date.now() - hit.at < NAME_TTL) names[id] = hit.name; else missing.push(id);
}
for (let i = 0; i < missing.length; i += 50) {
const list = `List(${missing.slice(i, i + 50).join(",")})`;
for (const resource of ["organizationsLookup", "organizations"]) {
try {
const r = await request("LinkedIn", `${API}/rest/${resource}?ids=${list}`, rest(token));
for (const [id, o] of Object.entries(r.data.results || {})) {
if (!o.localizedName) continue;
names[id] = o.localizedName;
nameCache.set(id, { name: o.localizedName, at: Date.now() });
}
break;
} catch { /* capped or not allowed: try the next resource, else keep ids */ }
}
}
if (missing.length) saveNames();
return names;
}
export default { export default {
id: "linkedin", id: "linkedin",
name: "LinkedIn", name: "LinkedIn",
@ -89,28 +54,24 @@ export default {
console: "https://www.linkedin.com/developers/apps", console: "https://www.linkedin.com/developers/apps",
steps: [ steps: [
"Create an app at linkedin.com/developers/apps and link it to your company's LinkedIn Page (the Page admin must verify it).", "Create an app at linkedin.com/developers/apps and link it to your company's LinkedIn Page (the Page admin must verify it).",
"Products tab: request “Community Management API” for posting to clients' company Pages (LinkedIn reviews it; your company Page must be verified).", "Products tab: add “Sign In with LinkedIn using OpenID Connect” and “Share on LinkedIn” (instant).",
"For posting to clients' company Pages, also request “Community Management API” (LinkedIn reviews it; your company Page must be verified).",
"Auth tab: add the Redirect URL shown below, then copy the Client ID and Client Secret here.", "Auth tab: add the Redirect URL shown below, then copy the Client ID and Client Secret here.",
], ],
note: "Without Community Management API (personal profiles only): add “Sign In with LinkedIn using OpenID Connect” and “Share on LinkedIn”, and set Scopes to “openid profile w_member_social”.", note: "Until Community Management API is approved, set Scopes to “openid profile w_member_social” so you can post to personal profiles only.",
}, },
auth: { auth: {
authorizeUrl: "https://www.linkedin.com/oauth/v2/authorization", authorizeUrl: "https://www.linkedin.com/oauth/v2/authorization",
tokenUrl: "https://www.linkedin.com/oauth/v2/accessToken", tokenUrl: "https://www.linkedin.com/oauth/v2/accessToken",
// The scopes Community Management API grants (it can't be combined with the OpenID Connect product). scopes: ["openid", "profile", "w_member_social", "r_organization_admin", "w_organization_social", "r_organization_social"],
scopes: ["r_basicprofile", "w_member_social", "rw_organization_admin", "w_organization_social", "r_organization_social"],
}, },
formats: { TEXT: true, IMAGE: true, CAROUSEL: true, REELS: true }, formats: { TEXT: true, IMAGE: true, CAROUSEL: true, REELS: true },
limits: { caption: 3000, images: 20 }, limits: { caption: 3000, images: 20 },
targetLabel: "Post as", targetLabel: "Post as",
async identify(tokens) { async identify(tokens) {
if (/\bopenid\b/.test(tokens.scope || "")) {
const { data } = await request("LinkedIn", `${API}/v2/userinfo`, { token: tokens.accessToken }); const { data } = await request("LinkedIn", `${API}/v2/userinfo`, { token: tokens.accessToken });
return { id: data.sub, name: data.name, username: null, avatar: data.picture || null, url: null }; return { id: data.sub, name: data.name, username: null, avatar: data.picture || null, url: null };
}
const { data } = await request("LinkedIn", `${API}/v2/me`, { token: tokens.accessToken });
return { id: data.id, name: [data.localizedFirstName, data.localizedLastName].filter(Boolean).join(" ") || "LinkedIn member", username: null, avatar: null, url: null };
}, },
/** The person, plus every company Page they administer (needs Community Management API). */ /** The person, plus every company Page they administer (needs Community Management API). */
@ -118,9 +79,12 @@ export default {
const out = [{ id: `urn:li:person:${account.id}`, name: `${account.name} (personal profile)` }]; const out = [{ id: `urn:li:person:${account.id}`, name: `${account.name} (personal profile)` }];
try { try {
const { data } = await request("LinkedIn", `${API}/rest/organizationAcls`, { ...rest(tokens.accessToken), query: { q: "roleAssignee", role: "ADMINISTRATOR", state: "APPROVED" } }); const { data } = await request("LinkedIn", `${API}/rest/organizationAcls`, { ...rest(tokens.accessToken), query: { q: "roleAssignee", role: "ADMINISTRATOR", state: "APPROVED" } });
const ids = (data.elements || []).map((e) => String(e.organization).split(":").pop()); for (const e of data.elements || []) {
const names = await pageNames(tokens.accessToken, ids); const orgId = String(e.organization).split(":").pop();
for (const id of ids) out.push({ id: `urn:li:organization:${id}`, name: `${names[id] || `Company ${id}`} (company Page)` }); let name = `Company ${orgId}`;
try { name = (await request("LinkedIn", `${API}/rest/organizations/${orgId}`, rest(tokens.accessToken))).data.localizedName || name; } catch { /* keep id */ }
out.push({ id: e.organization, name: `${name} (company Page)` });
}
} catch { /* no organization scopes: personal profile only */ } } catch { /* no organization scopes: personal profile only */ }
return out; return out;
}, },

View File

@ -17,47 +17,13 @@ async function waitForPost(token, publishId) {
for (let i = 0; i < 60; i++) { for (let i = 0; i < 60; i++) {
const { data } = await request("TikTok", `${API}/post/publish/status/fetch/`, { token, method: "POST", json: { publish_id: publishId } }); const { data } = await request("TikTok", `${API}/post/publish/status/fetch/`, { token, method: "POST", json: { publish_id: publishId } });
const s = data.data?.status; const s = data.data?.status;
if (s === "PUBLISH_COMPLETE" || s === "SEND_TO_USER_INBOX") return data.data; if (s === "PUBLISH_COMPLETE") return data.data;
if (s === "FAILED") throw new Error(`TikTok rejected the post: ${data.data?.fail_reason || "unknown reason"}`); if (s === "FAILED") throw new Error(`TikTok rejected the post: ${data.data?.fail_reason || "unknown reason"}`);
await sleep(5000); await sleep(5000);
} }
return { status: "PROCESSING" }; // TikTok keeps processing; it appears on the profile when done return { status: "PROCESSING" }; // TikTok keeps processing; it appears on the profile when done
} }
/** Upload to the creator's TikTok inbox (video.upload). They finish and post it in the TikTok app. */
async function sendToInbox(token, post, opts, caption) {
const media = post.media || [];
let publishId;
if (post.type === "REELS" || isVideo(media[0])) {
const bytes = await bytesOf(media[0]);
const plan = chunkPlan(bytes.length);
const { data } = await request("TikTok", `${API}/post/publish/inbox/video/init/`, {
token, method: "POST",
json: { source_info: { source: "FILE_UPLOAD", video_size: bytes.length, chunk_size: plan.chunkSize, total_chunk_count: plan.count } },
});
publishId = data.data.publish_id;
for (let i = 0; i < plan.count; i++) {
const start = i * plan.chunkSize;
const end = i === plan.count - 1 ? bytes.length : start + plan.chunkSize;
const r = await fetch(data.data.upload_url, { method: "PUT", headers: { "Content-Type": "video/mp4", "Content-Range": `bytes ${start}-${end - 1}/${bytes.length}` }, body: bytes.subarray(start, end) });
if (!r.ok) throw new Error(`TikTok: video upload failed on part ${i + 1} (${r.status}).`);
}
} else {
const urls = media.slice(0, 35).map((m) => (/^https?:/.test(m) ? m : publicMediaUrl(m)));
const { data } = await request("TikTok", `${API}/post/publish/content/init/`, {
token, method: "POST",
json: {
post_info: { title: String(opts.title || "").slice(0, 90), description: String(caption || "").slice(0, 4000) },
source_info: { source: "PULL_FROM_URL", photo_cover_index: 0, photo_images: urls },
post_mode: "MEDIA_UPLOAD", media_type: "PHOTO",
},
});
publishId = data.data.publish_id;
}
const done = await waitForPost(token, publishId);
return { id: publishId, publishId, status: done.status, inbox: true, note: "Sent to the TikTok app. Open TikTok → inbox notification to finish and post it." };
}
export default { export default {
id: "tiktok", id: "tiktok",
name: "TikTok", name: "TikTok",
@ -83,8 +49,7 @@ export default {
formats: { REELS: true, IMAGE: true, CAROUSEL: true }, formats: { REELS: true, IMAGE: true, CAROUSEL: true },
limits: { caption: 2200, images: 35 }, limits: { caption: 2200, images: 35 },
fields: [ fields: [
{ key: "mode", label: "How to post", type: "select", options: [["direct", "Post directly"], ["inbox", "Send to the TikTok app to finish"]], default: "direct", hint: "“Send to the TikTok app” puts the video in the account's TikTok inbox; someone opens TikTok, edits if needed and taps Post." }, { key: "privacy", label: "Who can view", type: "select", options: PRIVACY, required: true, hint: "TikTok requires you to choose. Unaudited apps can only use “Only me”." },
{ key: "privacy", label: "Who can view", type: "select", options: PRIVACY, hint: "Required for direct posts. TikTok requires you to choose. Unaudited apps can only use “Only me”." },
{ key: "allowComments", label: "Allow comments", type: "checkbox", default: false }, { key: "allowComments", label: "Allow comments", type: "checkbox", default: false },
{ key: "allowDuet", label: "Allow Duet", type: "checkbox", default: false, onlyFor: ["REELS"] }, { key: "allowDuet", label: "Allow Duet", type: "checkbox", default: false, onlyFor: ["REELS"] },
{ key: "allowStitch", label: "Allow Stitch", type: "checkbox", default: false, onlyFor: ["REELS"] }, { key: "allowStitch", label: "Allow Stitch", type: "checkbox", default: false, onlyFor: ["REELS"] },
@ -107,7 +72,6 @@ export default {
/** TikTok's commercial-content rules, checked when the draft is saved and again before posting. */ /** TikTok's commercial-content rules, checked when the draft is saved and again before posting. */
checkOptions(opts) { checkOptions(opts) {
if (opts.mode !== "inbox" && !opts.privacy) throw new Error("TikTok: who can view is required.");
if (opts.discloseCommercial && !opts.brandOrganic && !opts.brandedContent) throw new Error("TikTok: you turned on commercial content disclosure. Choose “Your brand”, “Branded content” or both."); if (opts.discloseCommercial && !opts.brandOrganic && !opts.brandedContent) throw new Error("TikTok: you turned on commercial content disclosure. Choose “Your brand”, “Branded content” or both.");
if (opts.brandedContent && opts.privacy === "SELF_ONLY") throw new Error("TikTok: branded content can't be private (“Only me”). Choose another audience."); if (opts.brandedContent && opts.privacy === "SELF_ONLY") throw new Error("TikTok: branded content can't be private (“Only me”). Choose another audience.");
}, },
@ -120,8 +84,8 @@ export default {
async publish({ tokens, conn, post, opts, caption }) { async publish({ tokens, conn, post, opts, caption }) {
const token = tokens.accessToken; const token = tokens.accessToken;
if (!opts.privacy) throw new Error("TikTok: choose who can view the post.");
this.checkOptions(opts); this.checkOptions(opts);
if (opts.mode === "inbox") return sendToInbox(token, post, opts, caption);
const creator = (await request("TikTok", `${API}/post/publish/creator_info/query/`, { token, method: "POST", json: {} })).data.data || {}; const creator = (await request("TikTok", `${API}/post/publish/creator_info/query/`, { token, method: "POST", json: {} })).data.data || {};
const brand = { brand_content_toggle: Boolean(opts.discloseCommercial && opts.brandedContent), brand_organic_toggle: Boolean(opts.discloseCommercial && opts.brandOrganic) }; const brand = { brand_content_toggle: Boolean(opts.discloseCommercial && opts.brandedContent), brand_organic_toggle: Boolean(opts.discloseCommercial && opts.brandOrganic) };
const noComment = opts.allowComments !== true || Boolean(creator.comment_disabled); const noComment = opts.allowComments !== true || Boolean(creator.comment_disabled);
@ -162,8 +126,6 @@ export default {
const done = await waitForPost(token, publishId); const done = await waitForPost(token, publishId);
const postId = done.publicaly_available_post_id?.[0]; const postId = done.publicaly_available_post_id?.[0];
const user = conn.account?.username; const user = conn.account?.username;
// Private ("Only me") posts get no public post id from TikTok; link to the profile so the user can find it. return { id: postId ? String(postId) : publishId, publishId, status: done.status, permalink: postId && user ? `https://www.tiktok.com/@${user}/video/${postId}` : null };
const permalink = user ? (postId ? `https://www.tiktok.com/@${user}/video/${postId}` : `https://www.tiktok.com/@${user}`) : null;
return { id: postId ? String(postId) : publishId, publishId, status: done.status, permalink };
}, },
}; };

View File

@ -5,14 +5,32 @@ import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import crypto from "node:crypto"; import crypto from "node:crypto";
import { PROJECT_ROOT, loadEnv } from "../env.js"; import { PROJECT_ROOT, loadEnv } from "../env.js";
import { seal, open } from "../sealed.js";
loadEnv(); loadEnv();
const FILE = process.env.QUORLA_CONNECTIONS_FILE || path.join(PROJECT_ROOT, "data", "connections.json"); // override for tests const FILE = process.env.QUORLA_CONNECTIONS_FILE || path.join(PROJECT_ROOT, "data", "connections.json"); // override for tests
const err = (message, status = 400) => Object.assign(new Error(message), { status }); const err = (message, status = 400) => Object.assign(new Error(message), { status });
const now = () => new Date().toISOString(); const now = () => new Date().toISOString();
export { seal, open }; function key() {
const secret = process.env.QUORLA_SECRET_KEY || process.env.DASHBOARD_SECRET;
if (!secret) throw err("Set DASHBOARD_SECRET (or QUORLA_SECRET_KEY) in .env before connecting platforms: it encrypts the stored tokens.", 500);
return crypto.createHash("sha256").update(`quorla-vault:${secret}`).digest();
}
export function seal(value) {
const iv = crypto.randomBytes(12);
const c = crypto.createCipheriv("aes-256-gcm", key(), iv);
const body = Buffer.concat([c.update(JSON.stringify(value), "utf8"), c.final()]);
return `v1.${iv.toString("base64url")}.${c.getAuthTag().toString("base64url")}.${body.toString("base64url")}`;
}
export function open(sealed) {
const [v, iv, tag, body] = String(sealed || "").split(".");
if (v !== "v1") throw err("Unreadable secret.", 500);
const d = crypto.createDecipheriv("aes-256-gcm", key(), Buffer.from(iv, "base64url"));
d.setAuthTag(Buffer.from(tag, "base64url"));
return JSON.parse(Buffer.concat([d.update(Buffer.from(body, "base64url")), d.final()]).toString("utf8"));
}
function read() { function read() {
try { return JSON.parse(fs.readFileSync(FILE, "utf8")); } catch { return { apps: {}, connections: [] }; } try { return JSON.parse(fs.readFileSync(FILE, "utf8")); } catch { return { apps: {}, connections: [] }; }
@ -52,24 +70,6 @@ export function removeApp(platform) {
const db = read(); delete db.apps[platform]; write(db); const db = read(); delete db.apps[platform]; write(db);
} }
// ---------- workspace integrations (e.g. the agency's Odoo) ----------
/** Stored settings for an integration, decrypted, or null. */
export function integration(name) {
const rec = read().integrations?.[name];
if (!rec) return null;
try { return { ...open(rec.sealed), updatedAt: rec.updatedAt, updatedBy: rec.updatedBy }; } catch { return null; }
}
export function saveIntegration(name, value, by) {
const db = read();
db.integrations = { ...db.integrations, [name]: { sealed: seal(value), updatedAt: now(), updatedBy: by } };
write(db);
}
export function removeIntegration(name) {
const db = read(); if (db.integrations) delete db.integrations[name]; write(db);
}
// ---------- connected accounts ---------- // ---------- connected accounts ----------
const publicConn = (c) => { const publicConn = (c) => {
const { sealed, ...rest } = c; const { sealed, ...rest } = c;
@ -128,22 +128,6 @@ export function updateConnection(id, patch) {
return publicConn(c); return publicConn(c);
} }
/** Move a connection to another client (e.g. connected under the wrong one). Keeps tokens and target. */
export function moveConnection(id, client, by) {
const db = read();
const c = db.connections.find((x) => x.id === id);
if (!c) throw err("Connection not found.", 404);
if (c.client === client) return publicConn(c);
if (db.connections.some((x) => x.client === client && x.platform === c.platform)) {
throw err("That client already has an account connected on this platform. Disconnect it there first.");
}
c.client = client;
c.movedBy = by || null;
c.updatedAt = now();
write(db);
return publicConn(c);
}
export function removeConnection(id) { export function removeConnection(id) {
const db = read(); const db = read();
const before = db.connections.length; const before = db.connections.length;

View File

@ -32,26 +32,6 @@ export default {
}; };
}, },
/** Latest public comments across the channel's videos. Uses an API key, not the user's token: commentThreads.list
* needs youtube.force-ssl with OAuth, a scope we don't request. Reply on YouTube. */
async comments(apiKey, account, { max = 30 } = {}) {
const { data } = await request("YouTube", `${API}/commentThreads`, { query: { key: apiKey, 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 }) { async publish({ tokens, post, opts, caption }) {
const src = (post.media || [])[0]; const src = (post.media || [])[0];
if (!src || !isVideo(src)) throw new Error("YouTube needs one video file."); if (!src || !isVideo(src)) throw new Error("YouTube needs one video file.");

View File

@ -4,12 +4,10 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import crypto from "node:crypto"; import crypto from "node:crypto";
import { EventEmitter } from "node:events";
import { PROJECT_ROOT } from "./env.js"; import { PROJECT_ROOT } from "./env.js";
import * as meta from "./meta.js"; import * as meta from "./meta.js";
import * as extra from "./platforms/index.js"; import * as extra from "./platforms/index.js";
import * as quota from "./platforms/quota.js"; import * as quota from "./platforms/quota.js";
import * as brandkit from "./brandkit.js";
const META_PLATFORMS = ["instagram", "facebook"]; const META_PLATFORMS = ["instagram", "facebook"];
export const ALL_PLATFORMS = [...META_PLATFORMS, ...extra.PLATFORM_IDS]; export const ALL_PLATFORMS = [...META_PLATFORMS, ...extra.PLATFORM_IDS];
@ -23,9 +21,6 @@ fs.mkdirSync(DIR, { recursive: true });
const read = () => { try { return JSON.parse(fs.readFileSync(FILE, "utf8")); } catch { return []; } }; 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 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 log = (entry) => fs.appendFileSync(LOG, JSON.stringify({ at: new Date().toISOString(), ...entry }) + "\n");
const err = (msg, status = 400) => Object.assign(new Error(msg), { status }); const err = (msg, status = 400) => Object.assign(new Error(msg), { status });
@ -91,19 +86,6 @@ 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. */ /** 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) { function quotaWarnings(post, items) {
const at = new Date(post.publishAt).getTime(); const at = new Date(post.publishAt).getTime();
const out = []; const out = [];
@ -151,9 +133,8 @@ export function createPost({ client, platforms = ["instagram"], type = "IMAGE",
results: {}, errors: {}, results: {}, errors: {},
}; };
const items = read(); const items = read();
post.warnings = [...quotaWarnings(post, items), ...brandWarnings(post)]; post.warnings = quotaWarnings(post, items);
items.push(post); write(items); items.push(post); write(items);
emit("draft_created", post);
return post; return post;
} }
@ -165,33 +146,10 @@ export function updatePost(id, patch, by = "team") {
for (const pl of p.platforms) if (!META_PLATFORMS.includes(pl)) extra.validate(pl, p.client, p); 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); } 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.status = "draft"; p.updatedBy = by; p.updatedAt = new Date().toISOString();
p.warnings = [...quotaWarnings(p, items), ...brandWarnings(p)]; p.warnings = quotaWarnings(p, items);
// 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; 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. */ /** 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) { export async function approvePost(id, by) {
if (!by) throw err("Approval needs the name of the person approving."); if (!by) throw err("Approval needs the name of the person approving.");
@ -210,18 +168,6 @@ export async function approvePost(id, by) {
write(items); return p; write(items); return p;
} }
/** An admin retries the platforms that failed. Parts that already posted are skipped, so nothing posts twice. */
export function retryPost(id, by) {
if (!by) throw err("Retrying needs the name of the person approving.");
const items = read(); const p = items.find((x) => x.id === id);
if (!p) throw err("Post not found.", 404);
if (!["failed", "partial"].includes(p.status)) throw err(`Only failed posts can be retried; this one is ${p.status}.`);
p.status = "approved"; p.retriedBy = by; p.retriedAt = new Date().toISOString();
if (new Date(p.publishAt) < new Date()) p.publishAt = new Date().toISOString();
delete p.retryAt;
write(items); return p;
}
/** Quota use per platform for a client (Connections page): used / limit, and when it resets. */ /** Quota use per platform for a client (Connections page): used / limit, and when it resets. */
export function quotaSummary(client) { export function quotaSummary(client) {
const c = clientInfo(client); const c = clientInfo(client);
@ -296,7 +242,6 @@ export async function runDue({ now = Date.now() } = {}) {
if (p.retryAt && new Date(p.retryAt) > now) continue; // waiting for a platform quota to reset if (p.retryAt && new Date(p.retryAt) > now) continue; // waiting for a platform quota to reset
p.status = "publishing"; write(items); p.status = "publishing"; write(items);
await publishNow(p); 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 }); done.push({ id: p.id, client: p.client, status: p.status, errors: p.errors });
} }
} finally { fs.rmSync(LOCK, { force: true }); } } finally { fs.rmSync(LOCK, { force: true }); }

View File

@ -1,26 +0,0 @@
// AES-256-GCM sealing for secrets kept on disk (platform keys, tokens, site settings).
// The key comes from QUORLA_SECRET_KEY (or DASHBOARD_SECRET), which therefore stays in .env.
import crypto from "node:crypto";
const err = (message, status = 400) => Object.assign(new Error(message), { status });
function key() {
const secret = process.env.QUORLA_SECRET_KEY || process.env.DASHBOARD_SECRET;
if (!secret) throw err("Set DASHBOARD_SECRET (or QUORLA_SECRET_KEY) in .env before connecting platforms: it encrypts the stored tokens.", 500);
return crypto.createHash("sha256").update(`quorla-vault:${secret}`).digest();
}
export function seal(value) {
const iv = crypto.randomBytes(12);
const c = crypto.createCipheriv("aes-256-gcm", key(), iv);
const body = Buffer.concat([c.update(JSON.stringify(value), "utf8"), c.final()]);
return `v1.${iv.toString("base64url")}.${c.getAuthTag().toString("base64url")}.${body.toString("base64url")}`;
}
export function open(sealed) {
const [v, iv, tag, body] = String(sealed || "").split(".");
if (v !== "v1") throw err("Unreadable secret.", 500);
const d = crypto.createDecipheriv("aes-256-gcm", key(), Buffer.from(iv, "base64url"));
d.setAuthTag(Buffer.from(tag, "base64url"));
return JSON.parse(Buffer.concat([d.update(Buffer.from(body, "base64url")), d.final()]).toString("utf8"));
}

View File

@ -10,12 +10,6 @@ import { PROJECT_ROOT } from "./env.js";
import * as meta from "./meta.js"; import * as meta from "./meta.js";
import * as sched from "./schedule.js"; import * as sched from "./schedule.js";
import * as lib from "./library.js"; import * as lib from "./library.js";
import * as plan from "./plan.js";
import * as competitors from "./competitors.js";
import * as places from "./places.js";
import * as trends from "./trends.js";
import * as hashtags from "./hashtags.js";
import * as brandkit from "./brandkit.js";
import * as extra from "./platforms/index.js"; import * as extra from "./platforms/index.js";
const server = new McpServer({ name: "social-manager", version: "0.1.0" }); const server = new McpServer({ name: "social-manager", version: "0.1.0" });
@ -190,66 +184,4 @@ 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 }, 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"))); 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("competitor_suggest", "Suggest Instagram competitors for a client. Each handle is checked on Instagram (public Business/Creator only) and saved as a suggestion the team adds or dismisses on the Competitors page; nothing is tracked until a person adds it. Returns the real numbers for each (followers, posts/week, median engagement, engagement rate, format mix, engagement by format, top hashtags, top posts) and why any were skipped. kind: local = a business the client competes with for the same customers; benchmark = same niche elsewhere, worth learning from.",
{ client: z.string(), accounts: z.array(z.object({ username: z.string(), kind: z.enum(["local", "benchmark"]), why: z.string(),
placeId: z.string().optional().describe("Google place id from google_places; the server then reads the real Google rating and review count"),
google: z.object({ rating: z.number(), reviews: z.number().optional() }).optional().describe("Only when Google Places isn't set up: rating/reviews you read on the web (stored as unverified)") })).max(15) },
wrap(async ({ client, accounts }) => competitors.suggest(client, accounts, { by: "agent" })));
server.tool("google_places", "Google Maps businesses with their real Google rating and review count. Give query to search (e.g. \"digital marketing agency Waterloo ON\": the local rivals customers see on Maps, with website links to find their Instagram), or place_id for one business; reviews=true adds up to 5 recent reviews (for what customers praise or complain about). Answers are cached for 30 days.",
{ query: z.string().optional(), place_id: z.string().optional(), reviews: z.boolean().default(false), max: z.number().int().min(1).max(20).default(10) },
wrap(async ({ query, place_id, reviews, max }) => {
if (place_id) return places.details(place_id, { reviews });
if (!query) throw new Error("Give query or place_id.");
return places.search(query, { max });
}));
server.tool("google_trends", "Google search interest for 1–5 keywords in a region (Google Trends via DataForSEO): average and recent interest, % change (last quarter of the period vs the one before), peak period, and the rising and top related searches per keyword. Use it to find what people in the client's market are searching for now and what's coming up seasonally. It's Google search behaviour, not Instagram trends. Cached for 7 days.",
{ keywords: z.array(z.string()).min(1).max(5), location: z.string().default("Canada").describe("DataForSEO location name, e.g. \"Canada\", \"Ontario,Canada\", \"India\", \"Tamil Nadu,India\", \"New York,United States\""),
range: z.enum(trends.RANGES).default("past_12_months") },
wrap(async (a) => trends.explore(a)));
server.tool("hashtag_top_posts", "Top (most engaged) or recent public Instagram posts under a hashtag, searched through the client's own Instagram: type (Reel/Carousel/Image), likes, comments, link and caption, plus the format mix. Finds Reels taking off in the niche from accounts nobody tracks yet. Instagram doesn't say who posted them. Instagram allows 30 different hashtags per account per week, so pick 3–5 specific niche or local tags (#waterlooeats, not #food); the answer shows how many are left. Off unless switched on in Server settings.",
{ client: z.string(), hashtag: z.string(), kind: z.enum(["top", "recent"]).default("top"), limit: z.number().int().min(1).max(50).default(25) },
wrap(async ({ client, hashtag, kind, limit }) => hashtags.topPosts(await competitors.viewerIgId(client), hashtag, { kind, limit })));
server.tool("client_google_listing", "Save which Google Maps listing is the client's own (place id from google_places). The server stores its real rating and review count so reports can compare the client with rivals on Google.",
{ client: z.string(), place_id: z.string() },
wrap(async ({ client, place_id }) => competitors.setClientPlace(client, place_id)));
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()); await server.connect(new StdioServerTransport());

View File

@ -1,89 +0,0 @@
// Google Trends through DataForSEO (keywords_data/google_trends/explore/live): how search interest in a niche's
// keywords moves in a region, plus the rising and top related searches. Login: DATAFORSEO_LOGIN / DATAFORSEO_PASSWORD
// (Server settings → Research). Answers are cached for 7 days, so repeat runs in the same week cost nothing.
import fs from "node:fs";
import path from "node:path";
import { PROJECT_ROOT } from "./env.js";
const API = "https://api.dataforseo.com/v3/keywords_data/google_trends/explore/live";
const CACHE = path.join(PROJECT_ROOT, "data", "trends-cache.json");
const KEEP_MS = 7 * 86400000;
export const RANGES = ["past_30_days", "past_90_days", "past_12_months", "past_5_years"];
const err = (msg, status = 400) => Object.assign(new Error(msg), { status });
export const enabled = () => Boolean(process.env.DATAFORSEO_LOGIN && process.env.DATAFORSEO_PASSWORD) && process.env.RESEARCH_GOOGLE_TRENDS !== "false";
function readCache() {
try {
const all = JSON.parse(fs.readFileSync(CACHE, "utf8")), now = Date.now();
return Object.fromEntries(Object.entries(all).filter(([, v]) => now - v.at < KEEP_MS));
} catch { return {}; }
}
function remember(key, data) {
const all = readCache();
all[key] = { at: Date.now(), data };
fs.mkdirSync(path.dirname(CACHE), { recursive: true });
fs.writeFileSync(CACHE + ".tmp", JSON.stringify(all));
fs.renameSync(CACHE + ".tmp", CACHE);
}
const avg = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null);
const round = (n) => (n == null ? null : Math.round(n));
/** Per keyword: average interest, recent vs earlier (the trend), peak period; from DataForSEO's graph item. */
function summarise(graph, keywords) {
const points = (graph?.data || []).filter((p) => !p.missing_data);
return keywords.map((k, i) => {
const series = points.map((p) => ({ from: p.date_from, value: p.values?.[i] ?? null })).filter((p) => p.value != null);
const vals = series.map((p) => p.value), q = Math.max(1, Math.floor(vals.length / 4));
const recent = avg(vals.slice(-q)), before = avg(vals.slice(-2 * q, -q));
const peak = series.reduce((m, p) => (!m || p.value > m.value ? p : m), null);
return {
keyword: k, average: round(avg(vals)), recent: round(recent),
change: recent != null && before ? Math.round(((recent - before) / before) * 100) : null,
peak: peak && { from: peak.from, value: peak.value },
series: series.map((p) => [p.from, p.value]),
};
});
}
/**
* keywords: 1–5 search terms compared on one 0–100 scale. location: DataForSEO location name, e.g. "Canada",
* "Ontario,Canada", "India". range: one of RANGES.
*/
export async function explore({ keywords, location = "Canada", range = "past_12_months" } = {}) {
if (process.env.RESEARCH_GOOGLE_TRENDS === "false") throw err("Google Trends is switched off in Server settings → Research.");
const kws = [...new Set((keywords || []).map((k) => String(k).trim().toLowerCase()).filter(Boolean))].slice(0, 5);
if (!kws.length) throw err("Give 1–5 keywords.");
if (!RANGES.includes(range)) throw err(`range must be one of ${RANGES.join(", ")}.`);
const key = JSON.stringify([kws, location.toLowerCase(), range]);
const hit = readCache()[key];
if (hit) return { ...hit.data, cached: true };
if (!enabled()) throw err("Google Trends isn't set up: add the DataForSEO login and password in Server settings → Research.");
const res = await fetch(API, {
method: "POST",
headers: { Authorization: `Basic ${Buffer.from(`${process.env.DATAFORSEO_LOGIN}:${process.env.DATAFORSEO_PASSWORD}`).toString("base64")}`, "Content-Type": "application/json" },
body: JSON.stringify([{ keywords: kws, location_name: location, time_range: range, type: "web", item_types: ["google_trends_graph", "google_trends_queries_list"] }]),
});
const body = await res.json().catch(() => ({}));
const task = body.tasks?.[0];
if (!res.ok || body.status_code !== 20000 || !task || task.status_code !== 20000)
throw err(`Google Trends (DataForSEO): ${task?.status_message || body.status_message || res.statusText}`, 502);
const items = task.result?.[0]?.items || [];
const lists = items.filter((x) => x.type === "google_trends_queries_list");
const related = (k) => {
const l = lists.find((x) => (x.keywords || []).map((w) => w.toLowerCase()).includes(k)) || (kws.length === 1 ? lists[0] : null);
const pick = (a) => (a || []).slice(0, 10).map((r) => ({ query: r.query, value: r.value }));
return { rising: pick(l?.data?.rising), top: pick(l?.data?.top) };
};
const data = {
location, range, checkedAt: new Date().toISOString().slice(0, 10),
note: "Interest is relative (0–100, 100 = the busiest point for any of these keywords in this period), not a search count. Rising values are % growth; very large values mean 'breakout'.",
keywords: summarise(items.find((x) => x.type === "google_trends_graph"), kws).map((s) => ({ ...s, ...related(s.keyword) })),
cost: task.cost ?? body.cost ?? null,
};
remember(key, data);
return data;
}

View File

@ -1,24 +0,0 @@
// 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"] }), []);
});

View File

@ -1,33 +0,0 @@
// 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/);
});

View File

@ -1,64 +0,0 @@
// 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");
});

View File

@ -1,36 +0,0 @@
// Odoo settings from Connections: login checked before saving, stored encrypted, key never returned, env fallback.
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-odoo-"));
const FILE = path.join(TMP, "connections.json");
Object.assign(process.env, { QUORLA_CONNECTIONS_FILE: FILE, DASHBOARD_SECRET: "s".repeat(32), QUORLA_DASHBOARD_DATA: path.join(TMP, "dash"), ODOO_URL: "", ODOO_API_KEY: "" });
const odoo = await import("../../dashboard/server/odoo.js");
test("save checks the login, stores it sealed and hides the key; disconnect clears it", async () => {
const realFetch = globalThis.fetch;
const calls = [];
globalThis.fetch = async (url, init) => {
const { params } = JSON.parse(init.body);
calls.push({ url: String(url), params });
const ok = params.args[2] === "good-key";
return new Response(JSON.stringify({ result: params.method === "login" ? (ok ? 7 : false) : null }));
};
try {
assert.equal(odoo.settings().configured, false);
const form = { url: "https://acme.odoo.com/odoo/action-1", db: "acme", username: "ops@acme.test", allowWrites: true };
await assert.rejects(odoo.saveSettings({ ...form, apiKey: "bad-key" }, "Ana"), /rejected the login/);
await assert.rejects(odoo.saveSettings({ ...form, apiKey: "" }, "Ana"), /required/);
const s = await odoo.saveSettings({ ...form, apiKey: "good-key" }, "Ana");
assert.equal(calls.at(-1).url, "https://acme.odoo.com/jsonrpc", "address trimmed to the site root");
assert.deepEqual([s.configured, s.source, s.url, s.allowWrites, s.updatedBy], [true, "settings", "https://acme.odoo.com", true, "Ana"]);
assert.equal(JSON.stringify(s).includes("good-key"), false);
assert.equal(fs.readFileSync(FILE, "utf8").includes("good-key"), false, "key encrypted at rest");
const again = await odoo.saveSettings({ ...form, apiKey: "", allowWrites: false }, "Ana");
assert.equal(again.allowWrites, false, "empty key keeps the stored one");
assert.equal(odoo.removeSettings().configured, false);
} finally { globalThis.fetch = realFetch; }
});

View File

@ -1,37 +0,0 @@
// 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);
});

View File

@ -139,9 +139,9 @@ test("sign-in URLs carry the right client id, scopes and PKCE per platform", ()
test("LinkedIn: connect, list company Pages, post image to a company Page", async () => { test("LinkedIn: connect, list company Pages, post image to a company Page", async () => {
reset(); reset();
const { conn } = await connect("linkedin", "acme", { identify: [ const { conn } = await connect("linkedin", "acme", { identify: [
["GET", "https://api.linkedin.com/v2/me", json({ id: "abc", localizedFirstName: "Pat", localizedLastName: "Lee" })], ["GET", "https://api.linkedin.com/v2/userinfo", json({ sub: "abc", name: "Pat Lee", picture: "https://p" })],
["GET", "https://api.linkedin.com/rest/organizationAcls", json({ elements: [{ organization: "urn:li:organization:42" }] })], ["GET", "https://api.linkedin.com/rest/organizationAcls", json({ elements: [{ organization: "urn:li:organization:42" }] })],
["GET", "https://api.linkedin.com/rest/organizationsLookup?ids=List(42)", json({ results: { 42: { localizedName: "Acme Co" } } })], ["GET", "https://api.linkedin.com/rest/organizations/42", json({ localizedName: "Acme Co" })],
] }); ] });
assert.equal(conn.account.name, "Pat Lee"); assert.equal(conn.account.name, "Pat Lee");
const targets = await P.targetsFor(conn.id); const targets = await P.targetsFor(conn.id);
@ -315,65 +315,3 @@ test("expired tokens are refreshed and saved; failed refresh asks to reconnect",
test("posting to an unconnected platform is refused clearly", () => { test("posting to an unconnected platform is refused clearly", () => {
assert.throws(() => P.validate("linkedin", "nobody", { type: "TEXT", media: [], caption: "x", options: {} }), /isn't connected for this client/); assert.throws(() => P.validate("linkedin", "nobody", { type: "TEXT", media: [], caption: "x", options: {} }), /isn't connected for this client/);
}); });
test("vault: a connection can move to another client, but not onto an existing one", () => {
const tok = { accessToken: "a", refreshToken: "r" };
const a = vault.saveConnection({ client: "move-from", platform: "tiktok", account: { id: "u1", name: "One" }, tokens: tok, by: "t" });
vault.saveConnection({ client: "move-taken", platform: "tiktok", account: { id: "u2", name: "Two" }, tokens: tok, by: "t" });
assert.throws(() => vault.moveConnection(a.id, "move-taken"), /already has an account/);
const moved = vault.moveConnection(a.id, "move-to", "t");
assert.equal(moved.client, "move-to");
assert.equal(vault.connectionFor("move-from", "tiktok"), null);
assert.equal(vault.connectionFor("move-to", "tiktok", { withTokens: true }).tokens.accessToken, "a");
});
test("TikTok: inbox mode uploads to the creator's inbox with no audience settings", async () => {
reset();
route("POST", "https://open.tiktokapis.com/v2/post/publish/creator_info/query/", json({ data: { privacy_level_options: ["SELF_ONLY"] }, error: { code: "ok" } }));
route("POST", "https://open.tiktokapis.com/v2/post/publish/inbox/video/init/", json({ data: { publish_id: "inb1", upload_url: "https://upload.tiktok.test/i1" }, error: { code: "ok" } }));
route("PUT", "https://upload.tiktok.test/i1", new Response("", { status: 201 }));
route("POST", "https://open.tiktokapis.com/v2/post/publish/status/fetch/", json({ data: { status: "SEND_TO_USER_INBOX" }, error: { code: "ok" } }));
P.validate("tiktok", "acme", { type: "REELS", media: [VID], caption: "x", options: { tiktok: { mode: "inbox" } } });
const r = await P.publish("tiktok", "acme", { type: "REELS", media: [VID], caption: "x", options: { tiktok: { mode: "inbox" } } });
assert.equal(r.inbox, true);
assert.equal(sent(/\/video\/init\//).filter((c) => !c.url.includes("inbox")).length, 0, "no direct post");
assert.equal(bodyJson(sent(/inbox\/video\/init/)[0]).post_info, undefined);
});
test("platform errors include the code and a plain fix", async () => {
reset();
route("POST", "https://open.tiktokapis.com/v2/post/publish/creator_info/query/", json({ data: { privacy_level_options: ["SELF_ONLY"] }, error: { code: "ok" } }));
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();
delete process.env.YOUTUBE_API_KEY;
await assert.rejects(P.youtubeComments("acme"), /YOUTUBE_API_KEY/);
process.env.YOUTUBE_API_KEY = "yt-key";
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");
delete process.env.YOUTUBE_API_KEY;
assert.equal(r.comments[0].author, "Ana");
assert.equal(r.comments[0].url, "https://www.youtube.com/watch?v=v1&lc=t1");
const call = sent(/commentThreads/)[0];
assert.match(call.url, /allThreadsRelatedToChannelId=/);
assert.match(call.url, /key=yt-key/);
assert.ok(!call.headers?.Authorization, "comments are read with the API key, not the user's token");
});

View File

@ -46,13 +46,12 @@ test("YouTube's day runs midnight to midnight Pacific time, including the DST ch
test("YouTube allows 6 uploads (6 x 1,600 of 10,000 units), then waits until midnight Pacific", () => { test("YouTube allows 6 uploads (6 x 1,600 of 10,000 units), then waits until midnight Pacific", () => {
reset(); reset();
const at = Date.now(); // relative to now: stored entries older than the window are pruned on every write const at = Date.parse("2026-10-21T19:00:00Z");
const day = quota.windowAt(quota.limitFor("youtube"), at);
for (let i = 0; i < 6; i++) { quota.ensure("youtube", null, "publish", at); quota.record("youtube", null, "publish", at); } for (let i = 0; i < 6; i++) { quota.ensure("youtube", null, "publish", at); quota.record("youtube", null, "publish", at); }
const u = quota.usage("youtube", null, at); const u = quota.usage("youtube", null, at);
assert.deepEqual([u.used, u.remaining], [9600, 400]); assert.deepEqual([u.used, u.remaining], [9600, 400]);
assert.throws(() => quota.ensure("youtube", null, "publish", at), (e) => e.quota && e.resetAt === new Date(day.end).toISOString()); assert.throws(() => quota.ensure("youtube", null, "publish", at), (e) => e.quota && e.resetAt === "2026-10-22T07:00:00.000Z");
assert.doesNotThrow(() => quota.ensure("youtube", null, "publish", day.end + 1000), "new day, new budget"); assert.doesNotThrow(() => quota.ensure("youtube", null, "publish", Date.parse("2026-10-22T07:00:01Z")), "new day, new budget");
assert.doesNotThrow(() => quota.ensure("youtube", "someone-else", "read", at), "reads (1 unit) still fit"); assert.doesNotThrow(() => quota.ensure("youtube", "someone-else", "read", at), "reads (1 unit) still fit");
}); });
@ -68,7 +67,7 @@ test("YouTube's budget is shared by all clients; per-account platforms are separ
test("rolling 24 h windows free up when the oldest post ages out", () => { test("rolling 24 h windows free up when the oldest post ages out", () => {
reset(); reset();
const at = Date.now(); // relative to now: entries older than 24 h are pruned on every write const at = Date.parse("2026-10-21T12:00:00Z");
const first = at - 20 * H; const first = at - 20 * H;
quota.record("tiktok", "acct", "publish", first); quota.record("tiktok", "acct", "publish", first);
for (let i = 1; i < 15; i++) quota.record("tiktok", "acct", "publish", at - H); for (let i = 1; i < 15; i++) quota.record("tiktok", "acct", "publish", at - H);

View File

@ -1,51 +0,0 @@
// Server settings on the site: override .env live, fall back when cleared, secrets hidden, scopes per workspace type.
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-settings-"));
const FILE = path.join(TMP, "settings.json");
Object.assign(process.env, {
QUORLA_SETTINGS_FILE: FILE, QUORLA_DASHBOARD_DATA: path.join(TMP, "dash"), QUORLA_CONTROL_DIR: path.join(TMP, "control"),
DASHBOARD_SECRET: "s".repeat(32), SMTP_HOST: "env.mail.test", QUORLA_CONTROL: "", QUORLA_HOSTED_TENANT: "",
});
delete process.env.SMTP_PASS;
const settings = await import("../../dashboard/server/settings.js");
const field = (r, key) => r.groups.flatMap((g) => g.fields).find((f) => f.key === key);
test("saved values override .env, secrets stay hidden, clearing falls back", () => {
assert.equal(field(settings.list(), "SMTP_HOST").source, "env");
let r = settings.save({ values: { SMTP_HOST: "site.mail.test", SMTP_PASS: "hunter2", ALLOW_PUBLISH: "true" } });
assert.deepEqual(r.changed.sort(), ["ALLOW_PUBLISH", "SMTP_HOST", "SMTP_PASS"]);
assert.equal(process.env.SMTP_HOST, "site.mail.test", "applied live");
assert.equal(process.env.SMTP_PASS, "hunter2");
assert.deepEqual([field(r, "SMTP_PASS").value, field(r, "SMTP_PASS").set, field(r, "SMTP_PASS").source], ["", true, "site"]);
assert.equal(JSON.stringify(r).includes("hunter2"), false);
assert.equal(fs.readFileSync(FILE, "utf8").includes("hunter2"), false, "encrypted at rest");
r = settings.save({ values: { SMTP_PASS: "" } });
assert.equal(process.env.SMTP_PASS, "hunter2", "empty secret keeps the stored one");
r = settings.save({ clear: ["SMTP_HOST", "SMTP_PASS"] });
assert.equal(process.env.SMTP_HOST, "env.mail.test", "back to .env");
assert.equal(process.env.SMTP_PASS, undefined);
assert.equal(field(r, "SMTP_HOST").source, "env");
assert.throws(() => settings.save({ values: { SMTP_PORT: "abc" } }), /whole number/);
assert.throws(() => settings.save({ values: { DASHBOARD_SECRET: "x" } }), /can't be changed here/);
assert.deepEqual(settings.save({ values: { META_API_VERSION: "v24.0" } }).restart, ["META_API_VERSION"]);
});
test("billing only on the control centre; our email and platform keys hidden in hosted agency workspaces", () => {
const groups = () => settings.list().groups.map((g) => g.name);
assert.ok(!groups().includes("Billing & sign-ups"));
process.env.QUORLA_CONTROL = "true";
assert.ok(groups().includes("Billing & sign-ups"));
process.env.QUORLA_CONTROL = "";
process.env.QUORLA_HOSTED_TENANT = "true";
assert.ok(!groups().includes("Email (SMTP)") && !groups().includes("Platforms") && groups().includes("AI runs"));
assert.equal(field(settings.list(), "PUBLIC_URL"), undefined);
assert.throws(() => settings.save({ values: { SMTP_HOST: "x" } }), /can't be changed here/);
process.env.QUORLA_HOSTED_TENANT = "";
});

View File

@ -10,8 +10,7 @@
"dependencies": { "dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.296", "@anthropic-ai/claude-agent-sdk": "^0.3.296",
"express": "^4.21.1", "express": "^4.21.1",
"marked": "^12.0.2", "marked": "^12.0.2"
"nodemailer": "10.0.10"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.0.14", "@tailwindcss/vite": "^4.0.14",
@ -1330,6 +1329,23 @@
"url": "https://opencollective.com/express" "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": { "node_modules/@modelcontextprotocol/sdk/node_modules/send": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
@ -2254,73 +2270,44 @@
} }
}, },
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "1.20.9", "version": "1.20.3",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.9.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
"integrity": "sha512-FISIQxV5u91Y9gvuFq3q/tA/qTqLJn3AhRcEvyozyhJTh5OBFFtv827AdHU7Rbyct4r+Ba1/99oFa1Y3yuFsdw==", "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bytes": "~3.1.2", "bytes": "3.1.2",
"content-type": "~1.0.5", "content-type": "~1.0.5",
"debug": "2.6.9", "debug": "2.6.9",
"depd": "2.0.0", "depd": "2.0.0",
"destroy": "~1.2.0", "destroy": "1.2.0",
"http-errors": "~2.0.1", "http-errors": "2.0.0",
"iconv-lite": "~0.4.24", "iconv-lite": "0.4.24",
"on-finished": "~2.4.1", "on-finished": "2.4.1",
"qs": "~6.16.0", "qs": "6.13.0",
"raw-body": "~2.5.3", "raw-body": "2.5.2",
"type-is": "~1.6.18", "type-is": "~1.6.18",
"unpipe": "~1.0.0" "unpipe": "1.0.0"
}, },
"engines": { "engines": {
"node": ">= 0.8", "node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16" "npm": "1.2.8000 || >= 1.4.16"
} }
}, },
"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": {
"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": { "node_modules/body-parser/node_modules/raw-body": {
"version": "2.5.3", "version": "2.5.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bytes": "~3.1.2", "bytes": "3.1.2",
"http-errors": "~2.0.1", "http-errors": "2.0.0",
"iconv-lite": "~0.4.24", "iconv-lite": "0.4.24",
"unpipe": "~1.0.0" "unpipe": "1.0.0"
}, },
"engines": { "engines": {
"node": ">= 0.8" "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"
}
},
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.29.3", "version": "4.29.3",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz",
@ -2870,49 +2857,45 @@
} }
}, },
"node_modules/express": { "node_modules/express": {
"version": "4.22.3", "version": "4.21.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz",
"integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==", "integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"accepts": "~1.3.8", "accepts": "~1.3.8",
"array-flatten": "1.1.1", "array-flatten": "1.1.1",
"body-parser": "~1.20.5", "body-parser": "1.20.3",
"content-disposition": "~0.5.4", "content-disposition": "0.5.4",
"content-type": "~1.0.4", "content-type": "~1.0.4",
"cookie": "~0.7.1", "cookie": "0.7.1",
"cookie-signature": "~1.0.6", "cookie-signature": "1.0.6",
"debug": "2.6.9", "debug": "2.6.9",
"depd": "2.0.0", "depd": "2.0.0",
"encodeurl": "~2.0.0", "encodeurl": "~2.0.0",
"escape-html": "~1.0.3", "escape-html": "~1.0.3",
"etag": "~1.8.1", "etag": "~1.8.1",
"finalhandler": "~1.3.1", "finalhandler": "1.3.1",
"fresh": "~0.5.2", "fresh": "0.5.2",
"http-errors": "~2.0.0", "http-errors": "2.0.0",
"merge-descriptors": "1.0.3", "merge-descriptors": "1.0.3",
"methods": "~1.1.2", "methods": "~1.1.2",
"on-finished": "~2.4.1", "on-finished": "2.4.1",
"parseurl": "~1.3.3", "parseurl": "~1.3.3",
"path-to-regexp": "~0.1.13", "path-to-regexp": "0.1.10",
"proxy-addr": "~2.0.7", "proxy-addr": "~2.0.7",
"qs": "~6.16.0", "qs": "6.13.0",
"range-parser": "~1.2.1", "range-parser": "~1.2.1",
"safe-buffer": "5.2.1", "safe-buffer": "5.2.1",
"send": "~0.19.0", "send": "0.19.0",
"serve-static": "~1.16.2", "serve-static": "1.16.2",
"setprototypeof": "1.2.0", "setprototypeof": "1.2.0",
"statuses": "~2.0.1", "statuses": "2.0.1",
"type-is": "~1.6.18", "type-is": "~1.6.18",
"utils-merge": "1.0.1", "utils-merge": "1.0.1",
"vary": "~1.1.2" "vary": "~1.1.2"
}, },
"engines": { "engines": {
"node": ">= 0.10.0" "node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
} }
}, },
"node_modules/express-rate-limit": { "node_modules/express-rate-limit": {
@ -3723,15 +3706,6 @@
"node": ">=18" "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": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -3795,9 +3769,9 @@
} }
}, },
"node_modules/path-to-regexp": { "node_modules/path-to-regexp": {
"version": "0.1.13", "version": "0.1.10",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/picocolors": { "node_modules/picocolors": {
@ -3883,13 +3857,12 @@
} }
}, },
"node_modules/qs": { "node_modules/qs": {
"version": "6.16.1", "version": "6.13.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
"integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==", "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"es-define-property": "^1.0.1", "side-channel": "^1.0.6"
"side-channel": "^1.1.1"
}, },
"engines": { "engines": {
"node": ">=0.6" "node": ">=0.6"

View File

@ -19,8 +19,7 @@
"dependencies": { "dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.296", "@anthropic-ai/claude-agent-sdk": "^0.3.296",
"express": "^4.21.1", "express": "^4.21.1",
"marked": "^12.0.2", "marked": "^12.0.2"
"nodemailer": "10.0.10"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.0.14", "@tailwindcss/vite": "^4.0.14",

View File

@ -1,70 +0,0 @@
// 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);
}

View File

@ -68,13 +68,7 @@ export function login(req, res) {
/** First run only: create the first admin from the sign-in screen when no logins exist. */ /** First run only: create the first admin from the sign-in screen when no logins exist. */
export function setupFirstAdmin(req, res) { export function setupFirstAdmin(req, res) {
if (users.hasUsers()) return res.status(403).json({ error: "Setup is already done. Sign in instead." }); if (users.hasUsers()) return res.status(403).json({ error: "Setup is already done. Sign in instead." });
const { email, name, password, setupToken } = req.body || {}; const { email, name, password } = 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 { try {
if (!password) throw Object.assign(new Error("Choose a password."), { status: 400 }); if (!password) throw Object.assign(new Error("Choose a password."), { status: 400 });
const { user } = users.createUser({ email, name, password, role: "admin" }, "first-run setup"); const { user } = users.createUser({ email, name, password, role: "admin" }, "first-run setup");

View File

@ -1,116 +0,0 @@
// 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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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>`;
}

View File

@ -6,7 +6,7 @@ import { CLIENTS_DIR, TEMPLATE_DIR } from "./config.js";
import * as meta from "../../connector/src/meta.js"; import * as meta from "../../connector/src/meta.js";
const SKIP = new Set(["_template"]); const SKIP = new Set(["_template"]);
const DOC_DIRS = { reports: "reports", calendar: "calendar", trends: "trends" }; const DOC_DIRS = { reports: "reports", calendar: "calendar" };
export const slugify = (s) => export const slugify = (s) =>
String(s).toLowerCase().normalize("NFKD").replace(/[^\w\s-]/g, "").replace(/&/g, "and").trim().replace(/[\s_]+/g, "-").replace(/-+/g, "-"); String(s).toLowerCase().normalize("NFKD").replace(/[^\w\s-]/g, "").replace(/&/g, "and").trim().replace(/[\s_]+/g, "-").replace(/-+/g, "-");
@ -61,12 +61,11 @@ export function listDocs(slug) {
for (const f of fs.readdirSync(dir)) { for (const f of fs.readdirSync(dir)) {
if (/^audit-.*\.md$/.test(f)) add(f, "audit"); if (/^audit-.*\.md$/.test(f)) add(f, "audit");
else if (/^profile-check-.*\.md$/.test(f)) add(f, "profile-check"); else if (/^profile-check-.*\.md$/.test(f)) add(f, "profile-check");
else if (/^competitors-.*\.md$/.test(f)) add(f, "competitors");
} }
for (const [kind, sub] of Object.entries(DOC_DIRS)) { for (const [kind, sub] of Object.entries(DOC_DIRS)) {
const d = path.join(dir, sub); const d = path.join(dir, sub);
if (!fs.existsSync(d)) continue; if (!fs.existsSync(d)) continue;
for (const f of fs.readdirSync(d)) if (f.endsWith(".md")) add(path.join(sub, f), kind === "reports" ? (/-monthly\.md$/.test(f) ? "monthly" : "report") : kind); for (const f of fs.readdirSync(d)) if (f.endsWith(".md")) add(path.join(sub, f), kind === "reports" ? "report" : "calendar");
} }
for (const f of ["brand.md", "goals.md", "constraints.md", "history.md"]) if (fs.existsSync(path.join(dir, f))) add(f, "setup"); 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)); return docs.sort((a, b) => (a.kind === "setup") - (b.kind === "setup") || b.updatedAt.localeCompare(a.updatedAt));

View File

@ -10,7 +10,7 @@ loadEnv();
export { PROJECT_ROOT }; export { PROJECT_ROOT };
export const CLIENTS_DIR = path.join(PROJECT_ROOT, "clients"); export const CLIENTS_DIR = path.join(PROJECT_ROOT, "clients");
export const TEMPLATE_DIR = path.join(CLIENTS_DIR, "_template"); export const TEMPLATE_DIR = path.join(CLIENTS_DIR, "_template");
export const DATA_DIR = process.env.QUORLA_DASHBOARD_DATA || path.join(PROJECT_ROOT, "data", "dashboard"); // override for tests export const DATA_DIR = path.join(PROJECT_ROOT, "data", "dashboard");
export const BRANDING_DIR = path.join(PROJECT_ROOT, "branding"); export const BRANDING_DIR = path.join(PROJECT_ROOT, "branding");
export const WEB_DIST = path.join(PROJECT_ROOT, "dashboard", "web", "dist"); export const WEB_DIST = path.join(PROJECT_ROOT, "dashboard", "web", "dist");
@ -35,6 +35,6 @@ export const ODOO = {
// AI runs (audits, plans, reports) go through the Claude Agent SDK. // AI runs (audits, plans, reports) go through the Claude Agent SDK.
export const AI = { export const AI = {
get model() { return process.env.DASHBOARD_AI_MODEL || "claude-opus-5-5"; }, // read live: changeable in Settings model: process.env.DASHBOARD_AI_MODEL || "claude-opus-5-5",
get maxTurns() { return Number(process.env.DASHBOARD_AI_MAX_TURNS || 80); }, maxTurns: Number(process.env.DASHBOARD_AI_MAX_TURNS || 80),
}; };

View File

@ -1,212 +0,0 @@
// 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) {
// Don't make the script wait for the mail server; the outcome lands in the agency's log on Admin → Agencies.
sendWelcome(slug, t).catch(() => {});
}
return PUBLIC(read()[slug]);
}
async function sendWelcome(slug, t) {
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 sent;
}
/** 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.");
const sent = await sendWelcome(slug, t);
if (!sent.sent) throw err(`Welcome email not sent: ${sent.reason}`, 502);
return PUBLIC(read()[slug]);
}

View File

@ -1,34 +0,0 @@
<!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>

View File

@ -1,72 +0,0 @@
<!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>

View File

@ -3,8 +3,7 @@ import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { marked } from "marked"; import { marked } from "marked";
import { safeSlug } from "./clients.js"; import { safeSlug } from "./clients.js";
import { toPdf, buildHtml } from "../../tools/report-pdf/build.mjs"; import { toPdf } from "../../tools/report-pdf/build.mjs";
import { reportOptions } from "./workspace.js";
/** Resolve a document path inside one client's folder (blocks ../ escapes). */ /** Resolve a document path inside one client's folder (blocks ../ escapes). */
export function resolveDoc(slug, rel) { export function resolveDoc(slug, rel) {
@ -37,14 +36,12 @@ export function renderDoc(slug, rel) {
// Content is written by our own team/AI runs; links open in a new tab. // Content is written by our own team/AI runs; links open in a new tab.
const html = marked.parse(body, { gfm: true, breaks: true }).replace(/<a href=/g, '<a target="_blank" rel="noopener" href='); const html = marked.parse(body, { gfm: true, breaks: true }).replace(/<a href=/g, '<a target="_blank" rel="noopener" href=');
const pdf = full.replace(/\.md$/, ".pdf"); const pdf = full.replace(/\.md$/, ".pdf");
// Reports with visual blocks (```mc:…) preview as the same designed page the PDF uses. return { path: rel, meta, html, updatedAt: fs.statSync(full).mtime.toISOString(), hasPdf: fs.existsSync(pdf) };
const page = /^```mc:/m.test(body) ? buildHtml(full, { ...reportOptions(), screen: true }) : undefined;
return { path: rel, meta, html, page, updatedAt: fs.statSync(full).mtime.toISOString(), hasPdf: fs.existsSync(pdf) };
} }
export function exportPdf(slug, rel) { export function exportPdf(slug, rel) {
const full = resolveDoc(slug, rel); const full = resolveDoc(slug, rel);
if (!full.endsWith(".md")) throw Object.assign(new Error("Pick a markdown document."), { status: 400 }); if (!full.endsWith(".md")) throw Object.assign(new Error("Pick a markdown document."), { status: 400 });
const written = toPdf(full, reportOptions()); const written = toPdf(full);
return path.relative(safeSlug(slug), written).replace(/\\/g, "/"); return path.relative(safeSlug(slug), written).replace(/\\/g, "/");
} }

View File

@ -1,71 +0,0 @@
// 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));
const aiReady = Boolean(process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_BASE_URL);
add("AI runs", aiReady ? (jobFails.length ? "warn" : "ok") : "warn",
!aiReady ? "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]) };
}

View File

@ -8,46 +8,20 @@ import { login, logout, currentUser, requireAuth, requireAdmin, setupFirstAdmin,
import * as users from "./users.js"; import * as users from "./users.js";
import * as clients from "./clients.js"; import * as clients from "./clients.js";
import * as metrics from "./metrics.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 odoo from "./odoo.js";
import * as settings from "./settings.js";
import * as jobs from "./jobs.js"; import * as jobs from "./jobs.js";
import * as docs from "./docs.js"; import * as docs from "./docs.js";
import * as meta from "../../connector/src/meta.js"; import * as meta from "../../connector/src/meta.js";
import * as sched from "../../connector/src/schedule.js"; import * as sched from "../../connector/src/schedule.js";
import * as lib from "../../connector/src/library.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 hashtags from "../../connector/src/hashtags.js";
import * as brandkit from "../../connector/src/brandkit.js";
import * as extra from "../../connector/src/platforms/index.js"; import * as extra from "../../connector/src/platforms/index.js";
import { publicBaseUrl, resolveMediaToken } from "../../connector/src/platforms/media.js"; import { publicBaseUrl, resolveMediaToken } from "../../connector/src/platforms/media.js";
import { DATA_DIR as _DD } from "./config.js"; import { DATA_DIR as _DD } from "./config.js";
import * as shots from "./screenshots.js";
const app = express(); const app = express();
app.disable("x-powered-by"); app.disable("x-powered-by");
// Behind Caddy in Docker the proxy is on the private network: TRUST_PROXY=uniquelocal (see docker-compose.yml). // 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"); 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(express.json({ limit: "200kb" }));
app.use((_req, res, next) => { app.use((_req, res, next) => {
res.setHeader("X-Content-Type-Options", "nosniff"); res.setHeader("X-Content-Type-Options", "nosniff");
@ -72,14 +46,9 @@ const h = (fn) => async (req, res) => {
app.post("/api/auth/login", login); app.post("/api/auth/login", login);
app.post("/api/auth/logout", logout); app.post("/api/auth/logout", logout);
app.post("/api/auth/setup", setupFirstAdmin); app.post("/api/auth/setup", setupFirstAdmin);
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() }); }); app.get("/api/auth/me", (req, res) => res.json({ user: currentUser(req), agency: AGENCY_NAME, 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/logo.png", (_req, res) => res.sendFile(path.join(BRANDING_DIR, "metatroncube-logo.png")));
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.get("/brand/mark.png", (_req, res) => res.sendFile(path.join(BRANDING_DIR, "metatroncube-mark.png")));
// 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")));
app.get("/brand/mark.png", (_req, res) => res.sendFile(path.join(BRANDING_DIR, "quorla-mark-app.png")));
app.get("/brand/icon.png", (_req, res) => res.sendFile(path.join(BRANDING_DIR, "quorla-icon-1024.png")));
// Public legal pages (linked from platform app listings: TikTok, Google, LinkedIn, Pinterest, Meta). // Public legal pages (linked from platform app listings: TikTok, Google, LinkedIn, Pinterest, Meta).
const LEGAL_DIR = fileURLToPath(new URL("./legal/", import.meta.url)); const LEGAL_DIR = fileURLToPath(new URL("./legal/", import.meta.url));
@ -87,56 +56,6 @@ 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("/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"))); 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. // Signed, expiring media links that platforms (TikTok, Threads, Google Business Profile) fetch posts from.
app.get("/m/:token/:name", (req, res) => { app.get("/m/:token/:name", (req, res) => {
const f = resolveMediaToken(req.params.token); const f = resolveMediaToken(req.params.token);
@ -164,7 +83,6 @@ app.get("/oauth/:platform/callback", async (req, res) => {
// ---------- everything below needs a session ---------- // ---------- everything below needs a session ----------
app.use("/api", requireAuth); app.use("/api", requireAuth);
app.use("/api", activity.middleware);
// ---------- your account (everyone) ---------- // ---------- your account (everyone) ----------
app.patch("/api/me", h(async (req) => ({ user: users.renameSelf(req.user.id, req.body?.name) }))); app.patch("/api/me", h(async (req) => ({ user: users.renameSelf(req.user.id, req.body?.name) })));
@ -181,20 +99,6 @@ app.get("/api/platforms", h(async (req) => ({
}))); })));
app.put("/api/platforms/:id/app", requireAdmin, h(async (req) => { extra.adapter(req.params.id); extra.vault.saveApp(req.params.id, req.body || {}, req.user.name || req.user.email); return { ok: true }; })); app.put("/api/platforms/:id/app", requireAdmin, h(async (req) => { extra.adapter(req.params.id); extra.vault.saveApp(req.params.id, req.body || {}, req.user.name || req.user.email); return { ok: true }; }));
app.delete("/api/platforms/:id/app", requireAdmin, h(async (req) => { extra.adapter(req.params.id); extra.vault.removeApp(req.params.id); return { ok: true }; })); app.delete("/api/platforms/:id/app", requireAdmin, h(async (req) => { extra.adapter(req.params.id); extra.vault.removeApp(req.params.id); return { ok: true }; }));
app.get("/api/settings/server", requireAdmin, h(async () => settings.list()));
app.put("/api/settings/server", requireAdmin, h(async (req) => settings.save(req.body || {}, (keys) => {
if (keys.some((k) => k.startsWith("SMTP_"))) notify.resetMailer();
console.log(`Server settings changed by ${req.user.email}: ${keys.join(", ")}`);
})));
// Only inside Docker, where the container comes straight back up (restart policy).
app.post("/api/settings/restart", requireAdmin, h(async () => {
if (!fs.existsSync("/.dockerenv")) throw Object.assign(new Error("Restart the dashboard on the server yourself."), { status: 400 });
setTimeout(() => process.exit(0), 500);
return { ok: true };
}));
app.get("/api/integrations/odoo", requireAdmin, h(async () => ({ odoo: odoo.settings() })));
app.put("/api/integrations/odoo", requireAdmin, h(async (req) => ({ odoo: await odoo.saveSettings(req.body || {}, req.user.name || req.user.email) })));
app.delete("/api/integrations/odoo", requireAdmin, h(async () => ({ odoo: odoo.removeSettings() })));
app.get("/api/connections", h(async (req) => ({ connections: extra.vault.listConnections({ client: req.query.client || undefined }) }))); app.get("/api/connections", h(async (req) => ({ connections: extra.vault.listConnections({ client: req.query.client || undefined }) })));
app.get("/api/quota", h(async (req) => ({ quota: sched.quotaSummary(clients.safeSlug(req.query.client) && String(req.query.client)) }))); app.get("/api/quota", h(async (req) => ({ quota: sched.quotaSummary(clients.safeSlug(req.query.client) && String(req.query.client)) })));
app.get("/api/oauth/:platform/start", requireAdmin, h(async (req) => { app.get("/api/oauth/:platform/start", requireAdmin, h(async (req) => {
@ -205,14 +109,7 @@ app.get("/api/oauth/:platform/start", requireAdmin, h(async (req) => {
app.post("/api/connections/:id/check", requireAdmin, h(async (req) => ({ connection: await extra.checkConnection(req.params.id) }))); app.post("/api/connections/:id/check", requireAdmin, h(async (req) => ({ connection: await extra.checkConnection(req.params.id) })));
app.get("/api/connections/:id/creator-info", h(async (req) => ({ creator: await extra.creatorInfo(req.params.id) }))); app.get("/api/connections/:id/creator-info", h(async (req) => ({ creator: await extra.creatorInfo(req.params.id) })));
app.get("/api/connections/:id/targets", h(async (req) => ({ targets: await extra.targetsFor(req.params.id) }))); app.get("/api/connections/:id/targets", h(async (req) => ({ targets: await extra.targetsFor(req.params.id) })));
app.patch("/api/connections/:id", requireAdmin, h(async (req) => { app.patch("/api/connections/:id", requireAdmin, h(async (req) => ({ connection: await extra.setTarget(req.params.id, String(req.body?.targetId || "")) })));
if (req.body?.client !== undefined) {
const to = String(req.body.client || "");
if (!clients.getClient(to)) throw Object.assign(new Error("Pick a client to move it to."), { status: 400 });
return { connection: extra.vault.moveConnection(req.params.id, to, req.user.name || req.user.email) };
}
return { connection: await extra.setTarget(req.params.id, String(req.body?.targetId || "")) };
}));
app.delete("/api/connections/:id", requireAdmin, h(async (req) => { extra.vault.removeConnection(req.params.id); return { ok: true }; })); app.delete("/api/connections/:id", requireAdmin, h(async (req) => { extra.vault.removeConnection(req.params.id); return { ok: true }; }));
// ---------- team members (admins) ---------- // ---------- team members (admins) ----------
@ -233,7 +130,7 @@ app.get("/api/status", h(async () => {
return { return {
meta: metaStatus, meta: metaStatus,
odoo: await odoo.status(), odoo: await odoo.status(),
ai: { model: process.env.DASHBOARD_AI_MODEL || "claude-opus-5-5", apiKey: Boolean(process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_BASE_URL), server: process.env.ANTHROPIC_BASE_URL || null }, ai: { model: process.env.DASHBOARD_AI_MODEL || "claude-opus-5-5", apiKey: Boolean(process.env.ANTHROPIC_API_KEY) },
publishing: process.env.ALLOW_PUBLISH === "true", publishing: process.env.ALLOW_PUBLISH === "true",
}; };
})); }));
@ -324,15 +221,6 @@ app.get("/api/clients/:slug/file", h(async (req, res) => {
else res.sendFile(full); else res.sendFile(full);
})); }));
// ---------- monthly profile screenshots (the audit reads them) ----------
app.get("/api/clients/:slug/screenshots", h(async (req) => shots.list(req.params.slug, req.query.month)));
app.post("/api/clients/:slug/screenshots/:shot", express.raw({ type: "*/*", limit: "16mb" }), h(async (req) =>
shots.save(req.params.slug, req.query.month, req.params.shot, req.body, decodeURIComponent(String(req.headers["x-filename"] || "")), who(req))));
app.delete("/api/clients/:slug/screenshots/:shot", h(async (req) => shots.remove(req.params.slug, req.query.month, req.params.shot)));
app.get("/api/clients/:slug/screenshots/:shot/image", h(async (req, res) => res.sendFile(shots.filePath(req.params.slug, req.query.month, req.params.shot))));
app.post("/api/clients/:slug/screenshots/:shot/example", requireAdmin, h(async (req) => shots.useAsExample(req.params.slug, req.query.month, req.params.shot)));
app.get("/api/screenshot-examples/:shot", h(async (req, res) => res.sendFile(shots.examplePath(req.params.shot))));
// ---------- AI jobs ---------- // ---------- AI jobs ----------
app.get("/api/jobs", h(async (req) => ({ jobs: jobs.listJobs({ slug: req.query.slug }), types: Object.fromEntries(Object.entries(jobs.JOB_TYPES).map(([k, v]) => [k, { label: v.label, needsClient: v.needsClient }])) }))); app.get("/api/jobs", h(async (req) => ({ jobs: jobs.listJobs({ slug: req.query.slug }), types: Object.fromEntries(Object.entries(jobs.JOB_TYPES).map(([k, v]) => [k, { label: v.label, needsClient: v.needsClient }])) })));
app.get("/api/jobs/:id", h(async (req) => { app.get("/api/jobs/:id", h(async (req) => {
@ -370,11 +258,7 @@ 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 }); }; 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", 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) => { 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) }; }));
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) => { 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); 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 }); 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 });
@ -385,16 +269,6 @@ app.post("/api/schedule/upload", express.raw({ type: "*/*", limit: "500mb" }), h
app.post("/api/schedule", h(async (req) => ({ post: sched.createPost({ ...req.body, createdBy: req.user.name || req.user.email }) }))); app.post("/api/schedule", h(async (req) => ({ post: sched.createPost({ ...req.body, createdBy: req.user.name || req.user.email }) })));
app.patch("/api/schedule/:id", h(async (req) => ({ post: sched.updatePost(req.params.id, req.body || {}, req.user.name || req.user.email) }))); 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/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.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) => { app.get("/api/schedule/media", h(async (req, res) => {
const f = path.resolve(String(req.query.path || "")); const f = path.resolve(String(req.query.path || ""));
@ -403,115 +277,6 @@ 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);
const report = clients.listDocs(c).filter((d) => d.kind === "competitors").sort((a, b) => b.path.localeCompare(a.path))[0] || null;
return { ...competitors.compare(c, ownForCompare(c)), report: report && { path: report.path, title: report.title, updatedAt: report.updatedAt } };
}));
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.get("/api/competitors/:client/suggestions", h(async (req) => ({ suggestions: competitors.listSuggestions(req.params.client) })));
app.post("/api/competitors/:client/suggestions/:username/add", h(async (req) => ({ added: await competitors.acceptSuggestion(req.params.client, req.params.username, { by: who(req) }) })));
app.post("/api/competitors/:client/suggestions/:username/dismiss", h(async (req) => competitors.dismissSuggestion(req.params.client, req.params.username)));
app.get("/api/competitors/:client/hashtag", h(async (req) => {
const igId = await competitors.viewerIgId(req.params.client);
if (!req.query.tag) return { enabled: hashtags.enabled(), quota: hashtags.quota(igId) };
return hashtags.topPosts(igId, String(req.query.tag), { kind: req.query.kind === "recent" ? "recent" : "top" });
}));
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 ---------- // ---------- content library: team uploads finished work per client ----------
const who = (req) => req.user.name || req.user.email; const who = (req) => req.user.name || req.user.email;
const esc = (s) => String(s || "").replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c]); const esc = (s) => String(s || "").replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c]);
@ -522,10 +287,7 @@ app.post("/api/library/upload", h(async (req) => {
return { path: file, size: fs.statSync(file).size }; return { path: file, size: fs.statSync(file).size };
})); }));
app.post("/api/library", h(async (req) => { app.post("/api/library", h(async (req) => {
// Uploaded for a calendar item: inherit its date and Odoo task, and move the card to "in production". const item = lib.createItem({ ...req.body, uploadedBy: who(req) });
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" }); 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 }; return { item, odoo: odooResult };
})); }));
@ -537,36 +299,9 @@ app.patch("/api/library/:id", h(async (req) => {
app.post("/api/library/:id/review", requireAdmin, 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 item = lib.reviewItem(req.params.id, req.body || {}, who(req));
const ok = item.status === "approved"; 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" }); 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 }; 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/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.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) => { app.get("/api/library/file", h(async (req, res) => {
@ -576,9 +311,6 @@ app.get("/api/library/file", h(async (req, res) => {
})); }));
// ---------- engagement: comment inbox ---------- // ---------- 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) => { app.get("/api/inbox", h(async (req) => {
const slug = req.query.client; clients.safeSlug(slug); const slug = req.query.client; clients.safeSlug(slug);
return { comments: await meta.commentInbox(await clientAccount(slug), { days: Number(req.query.days || 14) }) }; return { comments: await meta.commentInbox(await clientAccount(slug), { days: Number(req.query.days || 14) }) };
@ -626,14 +358,10 @@ function scheduleNightly() {
if (next <= new Date()) next.setDate(next.getDate() + 1); if (next <= new Date()) next.setDate(next.getDate() + 1);
setTimeout(async () => { setTimeout(async () => {
try { console.log("Nightly refresh:", await metrics.refreshAll()); } catch (e) { console.error("Nightly refresh failed", e); } 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(); scheduleNightly();
}, next - new Date()); }, next - new Date());
} }
if (process.env.DASHBOARD_NIGHTLY_REFRESH !== "false") scheduleNightly(); 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. // Publish approved posts when they're due (each client's own time zone). Every 2 minutes.
if (process.env.SCHEDULER_ENABLED !== "false") { if (process.env.SCHEDULER_ENABLED !== "false") {
@ -642,7 +370,7 @@ if (process.env.SCHEDULER_ENABLED !== "false") {
const host = process.env.DASHBOARD_HOST || "127.0.0.1"; const host = process.env.DASHBOARD_HOST || "127.0.0.1";
app.listen(PORT, host, () => { app.listen(PORT, host, () => {
console.log(`${workspace.agencyName()} dashboard on http://${host === "0.0.0.0" ? "localhost" : host}:${PORT}`); console.log(`${AGENCY_NAME} 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 (!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."); if (!ODOO.url) console.log("Odoo not configured: the Team page will show setup steps.");
}); });

View File

@ -8,7 +8,6 @@ import { query } from "@anthropic-ai/claude-agent-sdk";
import { PROJECT_ROOT, DATA_DIR, AI } from "./config.js"; import { PROJECT_ROOT, DATA_DIR, AI } from "./config.js";
import { getClient, listDocs, safeSlug } from "./clients.js"; import { getClient, listDocs, safeSlug } from "./clients.js";
import { toPdf } from "../../tools/report-pdf/build.mjs"; import { toPdf } from "../../tools/report-pdf/build.mjs";
import { refreshSnapshot } from "./metrics.js";
const JOB_DIR = path.join(DATA_DIR, "jobs"); const JOB_DIR = path.join(DATA_DIR, "jobs");
fs.mkdirSync(JOB_DIR, { recursive: true }); fs.mkdirSync(JOB_DIR, { recursive: true });
@ -16,15 +15,7 @@ fs.mkdirSync(JOB_DIR, { recursive: true });
export const JOB_TYPES = { export const JOB_TYPES = {
"account-audit": { label: "Account audit", needsClient: true, prompt: (c) => `/account-audit ${c.name}` }, "account-audit": { label: "Account audit", needsClient: true, prompt: (c) => `/account-audit ${c.name}` },
"weekly-plan": { label: "Weekly content plan", needsClient: true, prompt: (c) => `/weekly-plan ${c.name}` }, "weekly-plan": { label: "Weekly content plan", needsClient: true, prompt: (c) => `/weekly-plan ${c.name}` },
"month-plan": { label: "Next month's content calendar", needsClient: true, prompt: (c) => {
const d = new Date(); const next = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1));
const month = next.toLocaleString("en-US", { month: "long", year: "numeric", timeZone: "UTC" });
return `/weekly-plan ${c.name}
Plan all of ${month}, not just next week: every week that starts in ${month}, one calendar file per week (clients/${c.slug}/calendar/YYYY-Www.md). Check content_plan first so nothing is doubled, and add every planned post to the dashboard Calendar with plan_add (status idea or brief).`;
} },
"weekly-report": { label: "Weekly report", needsClient: true, prompt: (c) => `/weekly-report ${c.name}` }, "weekly-report": { label: "Weekly report", needsClient: true, prompt: (c) => `/weekly-report ${c.name}` },
"competitor-scan": { label: "Find & compare competitors", needsClient: true, prompt: (c) => `/competitor-scan ${c.name}` },
"assign-tasks": { label: "Assign this week's tasks", needsClient: false, prompt: () => `/assign-tasks` }, "assign-tasks": { label: "Assign this week's tasks", needsClient: false, prompt: () => `/assign-tasks` },
custom: { label: "Custom request", needsClient: false, prompt: (c, text) => (c ? `Client: ${c.name} (clients/${c.slug}/).\n\n${text}` : text) }, custom: { label: "Custom request", needsClient: false, prompt: (c, text) => (c ? `Client: ${c.name} (clients/${c.slug}/).\n\n${text}` : text) },
}; };
@ -32,7 +23,7 @@ Plan all of ${month}, not just next week: every week that starts in ${month}, on
const HEADLESS_RULES = `You are running headlessly from the agency dashboard. Nobody can answer questions mid-run. const HEADLESS_RULES = `You are running headlessly from the agency dashboard. Nobody can answer questions mid-run.
- Don't stop to ask. Make sensible assumptions, finish the job, and list open questions for the team inside the files you write (and in your final message). - Don't stop to ask. Make sensible assumptions, finish the job, and list open questions for the team inside the files you write (and in your final message).
- Never publish, schedule or post anything, and never create or change records in Odoo from this run. - Never publish, schedule or post anything, and never create or change records in Odoo from this run.
- Only write inside clients/, team/ and reports/. Don't export PDFs or run Bash: when the run ends, the dashboard exports every changed report to PDF with fresh chart numbers. Never tell the team to export a PDF or delete .data.json files. - Only write inside clients/, team/ and reports/. Export finished client-facing reports to PDF with the project's PDF builder.
- End with a short summary: what you produced (file paths) and anything the team must check.`; - End with a short summary: what you produced (file paths) and anything the team must check.`;
const READ_TOOLS = [ const READ_TOOLS = [
@ -41,51 +32,9 @@ const READ_TOOLS = [
"Bash(node tools/report-pdf/build.mjs:*)", "Bash(node tools/report-pdf/build.mjs:*)",
"mcp__social-manager__list_accounts", "mcp__social-manager__instagram_insights", "mcp__social-manager__instagram_recent_posts", "mcp__social-manager__list_accounts", "mcp__social-manager__instagram_insights", "mcp__social-manager__instagram_recent_posts",
"mcp__social-manager__instagram_comments", "mcp__social-manager__facebook_page_insights", "mcp__social-manager__facebook_recent_posts", "mcp__social-manager__instagram_comments", "mcp__social-manager__facebook_page_insights", "mcp__social-manager__facebook_recent_posts",
"mcp__social-manager__audience_demographics", "mcp__social-manager__instagram_mentions", "mcp__social-manager__competitor_lookup", "mcp__social-manager__competitors", "mcp__social-manager__competitor_suggest",
"mcp__social-manager__google_places", "mcp__social-manager__client_google_listing", "mcp__social-manager__google_trends", "mcp__social-manager__hashtag_top_posts",
"mcp__social-manager__list_connections", "mcp__social-manager__suggested_post_times", "mcp__social-manager__content_library", "mcp__social-manager__brand_kit",
"mcp__social-manager__caption_check", "mcp__social-manager__content_plan",
// Calendar items are plans for the team (idea → brief → …), never posts: nothing is scheduled or published from a run.
"mcp__social-manager__plan_add", "mcp__social-manager__plan_update",
"mcp__odoo-mcp__search_records", "mcp__odoo-mcp__read_records", "mcp__odoo-mcp__read_record_by_id", "mcp__odoo-mcp__count_records", "mcp__odoo-mcp__search_records", "mcp__odoo-mcp__read_records", "mcp__odoo-mcp__read_record_by_id", "mcp__odoo-mcp__count_records",
]; ];
// Write tools a run must never touch. Blocking also drops them from the request, so the model never sees them. const BLOCKED_TOOLS = ["mcp__social-manager__instagram_publish", "mcp__social-manager__facebook_publish"];
const BLOCKED_TOOLS = [
"instagram_publish", "facebook_publish", "reply_comment", "hide_comment", "send_direct_message", "schedule_post",
"approve_scheduled", "cancel_scheduled", "schedule_from_library", "competitor_add", "comment_inbox", "direct_messages", "list_scheduled",
].map((t) => `mcp__social-manager__${t}`);
// Path-scoped rules like Write(clients/**) can miss (absolute paths, other spellings), so file writes are also
// checked here by real path. Everything else not on the allow-list is refused, with the reason in the job log.
const WRITE_DIRS = ["clients", "team", "reports"].map((d) => path.resolve(PROJECT_ROOT, d));
const FILE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
function permissionCheck(job) {
return async (tool, input) => {
const file = input.file_path || input.notebook_path;
if (FILE_TOOLS.has(tool) && file) {
const full = path.resolve(PROJECT_ROOT, file);
if (WRITE_DIRS.some((d) => full === d || full.startsWith(d + path.sep))) return { behavior: "allow", updatedInput: input };
log(job, "warn", `Refused ${tool} outside clients/, team/ and reports/: ${file}`);
return { behavior: "deny", message: `Only files inside clients/, team/ and reports/ can be written. Write to clients/<slug>/ instead.` };
}
log(job, "warn", `Refused tool not allowed in runs: ${tool}`);
return { behavior: "deny", message: `${tool} isn't available in dashboard runs.` };
};
}
// Our own model servers (Ollama on a laptop) break on very long requests, so those runs send only what the job needs:
// the built-in tools runs use, the job's own skill and the social-manager server (≈13k tokens instead of ≈25k).
const LEAN_TOOLS = ["Read", "Glob", "Grep", "Edit", "Write", "Bash", "Skill", "TodoWrite"];
function leanOptions(prompt) {
if (!process.env.ANTHROPIC_BASE_URL) return { skills: "all" };
const skill = prompt.match(/^\/([a-z0-9-]+)/)?.[1];
return {
skills: skill ? [skill] : "all",
tools: LEAN_TOOLS,
strictMcpConfig: true,
mcpServers: { "social-manager": { command: "node", args: [path.join(PROJECT_ROOT, "connector", "src", "server.js")] } },
};
}
const jobs = new Map(); const jobs = new Map();
const queue = []; const queue = [];
@ -170,46 +119,23 @@ function docStamp(slug) {
return new Map(listDocs(slug).map((d) => [d.path, d.updatedAt])); return new Map(listDocs(slug).map((d) => [d.path, d.updatedAt]));
} }
/** Runs on our own model server (Ollama etc.): every model slot points at the chosen model, and the Anthropic key stays home. */
function runEnv() {
if (!process.env.ANTHROPIC_BASE_URL) return undefined;
// Summarise at ~23k tokens (35% of the 64k window set on the Ollama model): a laptop reads ~100–150 tokens/s and the
// agent gives up on a request after 5 minutes, while the spare window leaves room for big tool results.
const env = {
...process.env, ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN || "ollama", CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
CLAUDE_CODE_AUTO_COMPACT_WINDOW: process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW || "65536",
CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE || "35",
CLAUDE_CODE_MAX_OUTPUT_TOKENS: process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS || "8000",
};
delete env.ANTHROPIC_API_KEY;
for (const k of ["ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL", "CLAUDE_CODE_SUBAGENT_MODEL"]) env[k] = AI.model;
return env;
}
async function run(job) { async function run(job) {
Object.assign(job, { status: "running", startedAt: new Date().toISOString() }); Object.assign(job, { status: "running", startedAt: new Date().toISOString() });
job.abort = new AbortController(); job.abort = new AbortController();
persist(job); persist(job);
const before = docStamp(job.slug); const before = docStamp(job.slug);
log(job, "info", `Started: ${job.prompt.split("\n")[0]}`); log(job, "info", `Started: ${job.prompt.split("\n")[0]}`);
// Report charts are drawn from the stored numbers, so bring them up to date first.
if (job.slug && /^\/(account-audit|weekly-report|competitor-scan)/.test(job.prompt)) {
try { await refreshSnapshot(job.slug); log(job, "info", "Refreshed the account numbers for the charts."); }
catch (e) { log(job, "warn", `Couldn't refresh account numbers (${e.message}); charts use the last stored ones.`); }
}
try { try {
for await (const msg of query({ for await (const msg of query({
prompt: job.prompt, prompt: job.prompt,
options: { options: {
cwd: PROJECT_ROOT, cwd: PROJECT_ROOT,
settingSources: ["project"], settingSources: ["project"],
...leanOptions(job.prompt), skills: "all",
model: AI.model, model: AI.model,
maxTurns: AI.maxTurns, maxTurns: AI.maxTurns,
env: runEnv(), permissionMode: "dontAsk",
permissionMode: "default",
allowedTools: READ_TOOLS, allowedTools: READ_TOOLS,
canUseTool: permissionCheck(job),
disallowedTools: BLOCKED_TOOLS, disallowedTools: BLOCKED_TOOLS,
abortController: job.abort, abortController: job.abort,
systemPrompt: { type: "preset", preset: "claude_code", append: HEADLESS_RULES }, systemPrompt: { type: "preset", preset: "claude_code", append: HEADLESS_RULES },
@ -224,7 +150,6 @@ async function run(job) {
} else if (msg.type === "result") { } else if (msg.type === "result") {
job.costUsd = msg.total_cost_usd ?? null; job.costUsd = msg.total_cost_usd ?? null;
job.turns = msg.num_turns ?? null; job.turns = msg.num_turns ?? null;
for (const d of msg.permission_denials || []) log(job, "warn", `Denied: ${d.tool_name} ${JSON.stringify(d.tool_input).slice(0, 200)}`);
if (msg.subtype === "success" && !msg.is_error) { if (msg.subtype === "success" && !msg.is_error) {
job.result = msg.result; job.result = msg.result;
job.status = "succeeded"; job.status = "succeeded";
@ -245,7 +170,7 @@ async function run(job) {
if (job.slug) { if (job.slug) {
const changed = listDocs(job.slug).filter((d) => d.kind !== "setup" && before.get(d.path) !== d.updatedAt); const changed = listDocs(job.slug).filter((d) => d.kind !== "setup" && before.get(d.path) !== d.updatedAt);
job.files = changed.map((d) => d.path); job.files = changed.map((d) => d.path);
for (const d of changed.filter((x) => (x.kind === "audit" || x.kind === "report" || x.kind === "competitors") && (!x.pdf || x.pdf.stale))) { for (const d of changed.filter((x) => (x.kind === "audit" || x.kind === "report") && (!x.pdf || x.pdf.stale))) {
try { try {
toPdf(path.join(safeSlug(job.slug), d.path)); toPdf(path.join(safeSlug(job.slug), d.path));
log(job, "info", `PDF exported: ${d.path.replace(/\.md$/, ".pdf")}`); log(job, "info", `PDF exported: ${d.path.replace(/\.md$/, ".pdf")}`);

View File

@ -1,194 +0,0 @@
// 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 };

View File

@ -1,124 +0,0 @@
// 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 };

View File

@ -1,112 +0,0 @@
// 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); }
/** Drop the cached connection after SMTP settings change. */
export function resetMailer() { transport = null; }
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,
connectionTimeout: 15000, greetingTimeout: 15000, socketTimeout: 30000, // fail with a reason instead of hanging for minutes
});
return transport;
}
// SMTP_FROM may be "support@x.com" or "Quorla <support@x.com>"; nodemailer needs the bare address here.
const fromAddress = () => { const f = process.env.SMTP_FROM || process.env.SMTP_USER || ""; return (f.match(/<([^>]+)>/)?.[1] || f).trim(); };
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[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);
}

View File

@ -1,23 +1,14 @@
// Odoo over JSON-RPC: team, projects and Project tasks. // Odoo (workplace.metatroncube.in) over JSON-RPC: team, projects and Project tasks.
// Settings come from Connections → Odoo (stored encrypted in the vault), else from .env (ODOO_*). // Reads always work when configured. Creating/moving tasks needs ODOO_ALLOW_WRITES=true.
// Reads always work when configured. Creating/moving tasks needs "allow writes". import { ODOO } from "./config.js";
import { ODOO as ENV_ODOO } from "./config.js";
import * as vault from "../../connector/src/platforms/vault.js";
let uidPromise = null; let uidPromise = null;
const cache = new Map(); const cache = new Map();
const bad = (message, status = 400) => Object.assign(new Error(message), { status });
function load() {
const v = vault.integration("odoo");
return v ? { ...v, source: "settings" } : { ...ENV_ODOO, source: ENV_ODOO.url ? "env" : null };
}
let ODOO = load();
export const configured = () => Boolean(ODOO.url && ODOO.db && ODOO.username && ODOO.secret); export const configured = () => Boolean(ODOO.url && ODOO.db && ODOO.username && ODOO.secret);
async function rpc(service, method, args, url = ODOO.url) { async function rpc(service, method, args) {
const res = await fetch(`${url}/jsonrpc`, { const res = await fetch(`${ODOO.url}/jsonrpc`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", method: "call", params: { service, method, args }, id: Date.now() }), body: JSON.stringify({ jsonrpc: "2.0", method: "call", params: { service, method, args }, id: Date.now() }),
@ -30,42 +21,14 @@ async function rpc(service, method, args, url = ODOO.url) {
} }
async function uid() { async function uid() {
if (!configured()) throw bad("Odoo isn't connected. An admin can connect it in Connections → Odoo.", 503); if (!configured()) throw Object.assign(new Error("Odoo isn't configured. Add ODOO_URL, ODOO_DATABASE, ODOO_USERNAME and ODOO_API_KEY to .env."), { status: 503 });
uidPromise ??= rpc("common", "login", [ODOO.db, ODOO.username, ODOO.secret]).then((id) => { uidPromise ??= rpc("common", "login", [ODOO.db, ODOO.username, ODOO.secret]).then((id) => {
if (!id) throw new Error("Odoo login failed — check the username and API key in Connections → Odoo."); if (!id) throw new Error("Odoo login failed — check the username and API key in .env.");
return id; return id;
}); });
try { return await uidPromise; } catch (e) { uidPromise = null; throw e; } try { return await uidPromise; } catch (e) { uidPromise = null; throw e; }
} }
function reset() { ODOO = load(); uidPromise = null; cache.clear(); }
/** What the Connections page shows (never the key). */
export function settings() {
return { configured: configured(), source: ODOO.source, url: ODOO.url, db: ODOO.db, username: ODOO.username, allowWrites: ODOO.allowWrites, updatedAt: ODOO.updatedAt || null, updatedBy: ODOO.updatedBy || null };
}
/** Check the login against Odoo, then store it encrypted. Empty key keeps the stored one. */
export async function saveSettings({ url, db, username, apiKey, allowWrites }, by) {
const next = {
url: String(url || "").trim().replace(/\/+$/, "").replace(/\/(web|odoo)(\/.*)?$/, ""),
db: String(db || "").trim(),
username: String(username || "").trim(),
secret: String(apiKey || "").trim() || (ODOO.source === "settings" ? ODOO.secret : ""),
allowWrites: Boolean(allowWrites),
};
if (!/^https?:\/\/[^\s/]+/.test(next.url)) throw bad("Enter the Odoo address, e.g. https://yourcompany.odoo.com");
if (!next.db || !next.username || !next.secret) throw bad("Database, username and API key are all required.");
let id;
try { id = await rpc("common", "login", [next.db, next.username, next.secret], next.url); } catch (e) { throw bad(`Couldn't reach Odoo: ${e.message}`); }
if (!id) throw bad("Odoo rejected the login. Check the database name, the username (login email) and the API key.");
vault.saveIntegration("odoo", next, by);
reset();
return settings();
}
export function removeSettings() { vault.removeIntegration("odoo"); reset(); return settings(); }
async function call(model, method, args = [], kwargs = {}) { async function call(model, method, args = [], kwargs = {}) {
return rpc("object", "execute_kw", [ODOO.db, await uid(), ODOO.secret, model, method, args, kwargs]); return rpc("object", "execute_kw", [ODOO.db, await uid(), ODOO.secret, model, method, args, kwargs]);
} }
@ -155,7 +118,7 @@ export async function workload() {
} }
function assertWritable() { function assertWritable() {
if (!ODOO.allowWrites) throw Object.assign(new Error("Task creation is turned off. Turn on “Allow creating and moving tasks” in Connections → Odoo."), { status: 403 }); if (!ODOO.allowWrites) throw Object.assign(new Error("Task creation is turned off. Set ODOO_ALLOW_WRITES=true in .env to allow it."), { status: 403 });
} }
export async function createTask({ projectId, name, description = "", userIds = [], deadline = null }) { export async function createTask({ projectId, name, description = "", userIds = [], deadline = null }) {
@ -184,7 +147,7 @@ export async function moveTask(taskId, stageId) {
export async function taskActivity(taskRef, { note, stageMatch } = {}) { export async function taskActivity(taskRef, { note, stageMatch } = {}) {
const id = Number(String(taskRef || "").replace(/\D/g, "")); const id = Number(String(taskRef || "").replace(/\D/g, ""));
if (!id || !configured()) return { skipped: "no task or Odoo not configured" }; if (!id || !configured()) return { skipped: "no task or Odoo not configured" };
if (!ODOO.allowWrites) return { skipped: "Odoo writes are off" }; if (!ODOO.allowWrites) return { skipped: "ODOO_ALLOW_WRITES is off" };
try { try {
if (note) await call("project.task", "message_post", [[id]], { body: note, message_type: "comment", subtype_xmlid: "mail.mt_note" }); if (note) await call("project.task", "message_post", [[id]], { body: note, message_type: "comment", subtype_xmlid: "mail.mt_note" });
if (stageMatch) { if (stageMatch) {

View File

@ -1,39 +0,0 @@
// "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() }));
}

View File

@ -1,83 +0,0 @@
// 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;
}

View File

@ -1,137 +0,0 @@
<!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>

View File

@ -1,154 +0,0 @@
// "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))] };
}

View File

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

View File

@ -1,126 +0,0 @@
// Server settings an admin can change on the site instead of in .env (Settings → Server settings).
// Saved values are sealed in data/settings.json and override .env in every process (see connector/src/env.js).
// Scopes: "all" = every workspace; "self" = hidden in hosted agency workspaces (we provide those);
// "control" = only on the control centre (app.quorlasocial.com). Secrets are never sent back to the browser.
import fs from "node:fs";
import path from "node:path";
import { SETTINGS_FILE, ENV_BASE, savedSettings, applySetting } from "../../connector/src/env.js";
import { seal } from "../../connector/src/sealed.js";
import * as control from "./control.js";
const bad = (message, status = 400) => Object.assign(new Error(message), { status });
const GROUPS = [
{ name: "General", fields: [
{ key: "PUBLIC_URL", label: "Public address", hint: "Where this dashboard is reached, e.g. https://social.youragency.com. Used in email links and for platforms that fetch media.", scope: "self", type: "url" },
{ key: "DASHBOARD_SESSION_HOURS", label: "Stay signed in (hours)", type: "number", placeholder: "12", restart: true },
] },
{ name: "Publishing", fields: [
{ key: "ALLOW_PUBLISH", label: "Allow publishing", hint: "Off: drafts can be prepared but nothing is posted. Every post still needs approval.", type: "bool", default: false },
{ key: "SCHEDULER_ENABLED", label: "Run the scheduler", hint: "Posts approved posts at their time.", type: "bool", default: true, restart: true },
{ key: "DASHBOARD_NIGHTLY_REFRESH", label: "Nightly numbers refresh and monthly reports", type: "bool", default: true, restart: true },
] },
{ name: "Meta", fields: [
{ key: "META_MEDIA_PAGE_ID", label: "Agency Facebook Page ID", hint: "Needed to post images for Instagram-only clients." },
{ key: "META_API_VERSION", label: "Graph API version", placeholder: "v23.0", scope: "self", restart: true },
] },
{ name: "AI runs", fields: [
{ key: "ANTHROPIC_API_KEY", label: "Anthropic API key", hint: "For audits, weekly plans and reports.", secret: true },
{ key: "DASHBOARD_AI_MODEL", label: "Model", placeholder: "claude-opus-5-5" },
{ key: "DASHBOARD_AI_MAX_TURNS", label: "Max steps per run", type: "number", placeholder: "80" },
{ key: "ANTHROPIC_BASE_URL", label: "Model server address", type: "url", placeholder: "http://172.17.0.1:11434", hint: "Leave empty to use Anthropic. For your own Ollama, enter its address and set Model to the Ollama model name (e.g. qwen3.6:35b-a3b)." },
{ key: "ANTHROPIC_AUTH_TOKEN", label: "Model server token", secret: true, hint: "Only with a model server address. Ollama accepts any text, e.g. ollama." },
] },
{ name: "Research", fields: [
{ key: "RESEARCH_GOOGLE_PLACES", label: "Google ratings & reviews (Places)", hint: "Competitor scans read real Google ratings and reviews. Needs the key below. Off: scans use what websites show (marked unverified).", type: "bool", default: true },
{ key: "RESEARCH_GOOGLE_TRENDS", label: "Google search trends (DataForSEO)", hint: "Trend scans and competitor scans check what people search for in the client's region. Needs the DataForSEO login below.", type: "bool", default: true },
{ key: "RESEARCH_IG_HASHTAGS", label: "Instagram hashtag top posts", hint: "Trend scans find top Reels under niche hashtags. Turn on only after Meta approves Instagram Public Content Access. 30 hashtags per client account per week.", type: "bool", default: false },
{ key: "GOOGLE_PLACES_API_KEY", label: "Google Places API key", hint: "Real Google ratings and reviews for clients and competitors. Restrict the key to Places API (New) and this server's IP.", secret: true },
{ key: "DATAFORSEO_LOGIN", label: "DataForSEO API login", hint: "For Google Trends (rising searches in a client's niche). DataForSEO dashboard → API Access." },
{ key: "DATAFORSEO_PASSWORD", label: "DataForSEO API password", secret: true },
] },
{ name: "Email (SMTP)", scope: "self", fields: [
{ key: "SMTP_HOST", label: "Host", placeholder: "mail.example.com" },
{ key: "SMTP_PORT", label: "Port", type: "number", placeholder: "587" },
{ key: "SMTP_USER", label: "Username" },
{ key: "SMTP_PASS", label: "Password", secret: true },
{ key: "SMTP_FROM", label: "From address", placeholder: "Quorla <support@example.com>" },
] },
{ name: "Platforms", scope: "self", fields: [
{ key: "YOUTUBE_API_KEY", label: "YouTube Data API key", hint: "Reads public comments for the Inbox.", secret: true },
{ key: "LINKEDIN_API_VERSION", label: "LinkedIn API version", placeholder: "202606" },
{ key: "PINTEREST_SANDBOX", label: "Pinterest sandbox (Trial access)", type: "bool", default: false },
{ key: "REDDIT_USER_AGENT", label: "Reddit user agent", placeholder: "web:com.youragency.app:1.0 (by /u/yourname)" },
] },
{ name: "Billing & sign-ups", scope: "control", fields: [
{ key: "STRIPE_SECRET_KEY", label: "Stripe secret key", secret: true },
{ key: "STRIPE_PRICE_ID", label: "Stripe price ID", placeholder: "price_…" },
{ key: "STRIPE_WEBHOOK_SECRET", label: "Stripe webhook secret", secret: true, hint: "Webhook URL: /control/stripe/webhook on this site." },
{ key: "STRIPE_TRIAL_DAYS", label: "Free trial (days)", type: "number", placeholder: "0" },
{ key: "TENANT_BASE_DOMAIN", label: "Agency workspace domain", placeholder: "quorlasocial.com" },
] },
];
const hosted = () => process.env.QUORLA_HOSTED_TENANT === "true";
const visible = (scope) => scope === "control" ? control.enabled() : scope === "self" ? !hosted() : true;
const visibleFields = () => GROUPS.flatMap((g) => (visible(g.scope) ? g.fields.filter((f) => visible(f.scope)) : []));
/** Everything the Settings page needs. Secrets come back only as set / not set. */
export function list() {
const saved = savedSettings();
return {
hosted: hosted(),
canRestart: fs.existsSync("/.dockerenv"),
groups: GROUPS.filter((g) => visible(g.scope)).map((g) => ({
name: g.name,
fields: g.fields.filter((f) => visible(f.scope)).map((f) => {
const value = process.env[f.key];
const fromEnv = (f.key in ENV_BASE ? ENV_BASE[f.key] : value) !== undefined;
return {
key: f.key, label: f.label, hint: f.hint || "", placeholder: f.placeholder || "", type: f.type || "text", secret: Boolean(f.secret), restart: Boolean(f.restart),
default: f.default ?? null,
source: f.key in saved ? "site" : fromEnv ? "env" : null,
set: Boolean(value),
value: f.secret ? "" : value ?? "",
};
}),
})),
};
}
function clean(f, raw) {
const v = String(raw ?? "").trim();
if (/[\r\n]/.test(v)) throw bad(`${f.label}: one line only.`);
if (f.type === "number" && !/^\d+$/.test(v)) throw bad(`${f.label} must be a whole number.`);
if (f.type === "bool" && !["true", "false"].includes(v)) throw bad(`${f.label} must be on or off.`);
if (f.type === "url" && !/^https?:\/\/[^\s/]+/.test(v)) throw bad(`${f.label} must start with https://`);
return f.type === "url" ? v.replace(/\/+$/, "") : v;
}
/**
* values: { KEY: "new value" } (an empty secret keeps the stored one); clear: [KEY] goes back to .env.
* Returns the fresh list plus which saved keys need a restart.
*/
export function save({ values = {}, clear = [] } = {}, onChange = () => {}) {
const fields = new Map(visibleFields().map((f) => [f.key, f]));
const next = savedSettings();
const changed = [];
for (const k of [...Object.keys(values), ...clear]) if (!fields.has(k)) throw bad(`${k} can't be changed here.`);
for (const [k, raw] of Object.entries(values)) {
const f = fields.get(k);
if (f.secret && !String(raw ?? "").trim()) continue;
if (!f.secret && !String(raw ?? "").trim()) { if (k in next) { delete next[k]; changed.push(k); } continue; }
const v = clean(f, raw);
if (next[k] !== v) { next[k] = v; changed.push(k); }
}
for (const k of clear) if (k in next) { delete next[k]; changed.push(k); }
if (changed.length) {
fs.mkdirSync(path.dirname(SETTINGS_FILE), { recursive: true });
const tmp = `${SETTINGS_FILE}.tmp`;
fs.writeFileSync(tmp, JSON.stringify({ sealed: seal(next), updatedAt: new Date().toISOString() }), { mode: 0o600 });
fs.renameSync(tmp, SETTINGS_FILE);
for (const k of changed) applySetting(k, next[k] ?? null);
onChange(changed);
}
return { ...list(), changed, restart: changed.filter((k) => fields.get(k).restart) };
}

View File

@ -1,53 +0,0 @@
// 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() } : {});

View File

@ -5,12 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex, nofollow" /> <meta name="robots" content="noindex, nofollow" />
<link rel="icon" type="image/png" href="/brand/mark.png" /> <link rel="icon" type="image/png" href="/brand/mark.png" />
<link rel="apple-touch-icon" href="/brand/icon.png" />
<meta name="theme-color" content="#242142" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Plus+Jakarta+Sans:wght@600;700;800&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Plus+Jakarta+Sans:wght@600;700;800&display=swap" rel="stylesheet" />
<title>Quorla</title> <title>Social Command · Metatroncube</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -1,6 +1,6 @@
import { createContext, useCallback, useContext, useEffect, useState } from "react"; import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { BrowserRouter, NavLink, Navigate, Route, Routes } from "react-router-dom"; import { BrowserRouter, NavLink, Navigate, Route, Routes } from "react-router-dom";
import { LayoutGrid, Users2, Sparkles, Settings2, LogOut, CalendarClock, MessageCircle, FolderUp, Plug, BarChart3, CalendarDays, Swords, Activity, History, Building2 } from "lucide-react"; import { LayoutGrid, Users2, Sparkles, Settings2, LogOut, CalendarClock, MessageCircle, FolderUp, Plug } from "lucide-react";
import { api } from "./api.js"; import { api } from "./api.js";
import { cx, Spinner } from "./ui.jsx"; import { cx, Spinner } from "./ui.jsx";
import Login from "./pages/Login.jsx"; import Login from "./pages/Login.jsx";
@ -10,11 +10,6 @@ import Team from "./pages/Team.jsx";
import Runs from "./pages/Runs.jsx"; import Runs from "./pages/Runs.jsx";
import Settings from "./pages/Settings.jsx"; import Settings from "./pages/Settings.jsx";
import Publishing from "./pages/Publishing.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 Inbox from "./pages/Inbox.jsx";
import Library from "./pages/Library.jsx"; import Library from "./pages/Library.jsx";
import Connections from "./pages/Connections.jsx"; import Connections from "./pages/Connections.jsx";
@ -22,50 +17,30 @@ import Connections from "./pages/Connections.jsx";
const AuthCtx = createContext(null); const AuthCtx = createContext(null);
export const useAuth = () => useContext(AuthCtx); 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 = [ const NAV = [
{ to: "/", label: "Clients", icon: LayoutGrid, end: true }, { to: "/", label: "Clients", icon: LayoutGrid, end: true },
{ to: "/calendar", label: "Content calendar", icon: CalendarDays },
{ to: "/library", label: "Content library", icon: FolderUp }, { to: "/library", label: "Content library", icon: FolderUp },
{ to: "/publishing", label: "Publishing", icon: CalendarClock }, { 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: "/inbox", label: "Inbox", icon: MessageCircle },
{ to: "/connections", label: "Connections", icon: Plug, group: "Setup" }, { to: "/connections", label: "Connections", icon: Plug },
{ to: "/team", label: "Team", icon: Users2 }, { to: "/team", label: "Team", icon: Users2 },
{ to: "/runs", label: "AI runs", icon: Sparkles }, { to: "/runs", label: "AI runs", icon: Sparkles },
{ to: "/admin/health", label: "System health", icon: Activity, admin: true, group: "Admin" }, { to: "/settings", label: "Settings", icon: Settings2 },
{ 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 }) { function Shell({ children }) {
const { user, signOut, agency, workspace, control } = useAuth(); const { user, signOut } = useAuth();
return ( return (
<div className="min-h-screen flex"> <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"> <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"> <div className="px-4 pt-4 pb-3">
<div className="flex items-center justify-between"> <div className="h-[46px] overflow-hidden">
{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" />} <img src="/brand/logo.png" alt="Agency logo" className="h-[64px] -mt-[7px]" />
<NotificationBell />
</div> </div>
{workspace?.hasLogo <div className="mt-2 text-[11px] font-semibold tracking-[0.08em] uppercase text-faint">Social command</div>
? <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> </div>
<nav className="px-2 mt-2 space-y-0.5 flex-1 overflow-y-auto" aria-label="Main"> <nav className="px-2 mt-2 space-y-0.5 flex-1">
{NAV.filter((n) => (!n.admin || user.role === "admin") && (!n.control || control)).map(({ to, label, icon: Icon, end, group }) => [ {NAV.map(({ to, label, icon: Icon, end }) => (
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 <NavLink
key={to} key={to}
to={to} to={to}
@ -79,8 +54,8 @@ function Shell({ children }) {
> >
<Icon className="size-4" strokeWidth={2} /> <Icon className="size-4" strokeWidth={2} />
{label} {label}
</NavLink>, </NavLink>
])} ))}
</nav> </nav>
<div className="border-t border-line px-3 py-3 flex items-center gap-2.5"> <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]"> <div className="size-8 rounded-full bg-brand-soft text-brand-deep grid place-items-center font-semibold text-[12px]">
@ -113,9 +88,8 @@ export default function App() {
const refresh = useCallback(() => { const refresh = useCallback(() => {
api("/api/auth/me") api("/api/auth/me")
.then((r) => { .then((r) => {
setState({ loading: false, user: r.user, setupNeeded: r.setupNeeded, agency: r.agency, workspace: r.workspace, control: r.control }); setState({ loading: false, user: r.user, setupNeeded: r.setupNeeded, agency: r.agency });
applyAccent(r.workspace?.accent); if (r.agency) document.title = `Social Command · ${r.agency}`;
document.title = r.agency ? `Quorla · ${r.agency}` : "Quorla";
}) })
.catch(() => setState({ loading: false, user: null })); .catch(() => setState({ loading: false, user: null }));
}, []); }, []);
@ -133,10 +107,10 @@ export default function App() {
}; };
if (state.loading) return <Spinner />; if (state.loading) return <Spinner />;
if (!state.user) return <Login setupNeeded={state.setupNeeded} agency={state.agency} hasLogo={state.workspace?.hasLogo} onSignedIn={refresh} />; if (!state.user) return <Login setupNeeded={state.setupNeeded} agency={state.agency} onSignedIn={refresh} />;
return ( return (
<AuthCtx.Provider value={{ user: state.user, agency: state.agency, workspace: state.workspace, control: state.control, signOut, refresh, isAdmin: state.user.role === "admin" }}> <AuthCtx.Provider value={{ user: state.user, signOut, refresh, isAdmin: state.user.role === "admin" }}>
<BrowserRouter> <BrowserRouter>
<Shell> <Shell>
<Routes> <Routes>
@ -144,12 +118,6 @@ export default function App() {
<Route path="/clients/:slug" element={<Client />} /> <Route path="/clients/:slug" element={<Client />} />
<Route path="/library" element={<Library />} /> <Route path="/library" element={<Library />} />
<Route path="/publishing" element={<Publishing />} /> <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="/inbox" element={<Inbox />} />
<Route path="/connections" element={<Connections />} /> <Route path="/connections" element={<Connections />} />
<Route path="/team" element={<Team />} /> <Route path="/team" element={<Team />} />

View File

@ -1,39 +0,0 @@
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>
);
}

View File

@ -1,60 +0,0 @@
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>
);
}

View File

@ -49,25 +49,12 @@ function TargetSelect({ conn, value, onChange, id }) {
const TT_PRIVACY = { PUBLIC_TO_EVERYONE: "Everyone", MUTUAL_FOLLOW_FRIENDS: "Friends", FOLLOWER_OF_CREATOR: "Followers", SELF_ONLY: "Only me" }; const TT_PRIVACY = { PUBLIC_TO_EVERYONE: "Everyone", MUTUAL_FOLLOW_FRIENDS: "Friends", FOLLOWER_OF_CREATOR: "Followers", SELF_ONLY: "Only me" };
const mediaSrc = (m) => (/^https?:/.test(m) ? m : `/api/schedule/media?path=${encodeURIComponent(m)}`);
const isVideoFile = (m) => /\.(mp4|mov|m4v|webm)$/i.test(String(m).split("?")[0]);
const fmtSecs = (s) => `${Math.floor(s / 60)} min${s % 60 ? ` ${Math.round(s % 60)} s` : ""}`;
/** TikTok's post screen, following its Content Posting API guidelines (creator info, no default audience, disclosure, consent). */ /** TikTok's post screen, following its Content Posting API guidelines (creator info, no default audience, disclosure, consent). */
function TikTokFields({ platform, type, options, setOption, caption, media = [] }) { function TikTokFields({ platform, type, options, setOption, caption }) {
const [creator, setCreator] = useState(null); const [creator, setCreator] = useState(null);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [videoSecs, setVideoSecs] = useState(null);
useEffect(() => { api(`/api/connections/${platform.conn.id}/creator-info`).then((r) => setCreator(r.creator)).catch(setError); }, [platform.conn.id]); useEffect(() => { api(`/api/connections/${platform.conn.id}/creator-info`).then((r) => setCreator(r.creator)).catch(setError); }, [platform.conn.id]);
const video = type === "REELS"; const video = type === "REELS";
const firstVideo = media.find(isVideoFile);
useEffect(() => { setVideoSecs(null); }, [firstVideo]);
const tooLong = Boolean(video && creator?.maxVideoSeconds && videoSecs && videoSecs > creator.maxVideoSeconds);
// Problems that must stop the post being saved (read by selectionFor through the _problem option).
const problem = error ? `TikTok says this account can't post right now: ${error.message}`
: tooLong ? `This video is ${fmtSecs(videoSecs)}; this TikTok account can post videos up to ${fmtSecs(creator.maxVideoSeconds)}. Trim it or untick TikTok.`
: undefined;
useEffect(() => { if ((options._problem || undefined) !== problem) setOption("_problem", problem); }, [problem]); // eslint-disable-line react-hooks/exhaustive-deps
const branded = Boolean(options.discloseCommercial && options.brandedContent); const branded = Boolean(options.discloseCommercial && options.brandedContent);
const organic = Boolean(options.discloseCommercial && options.brandOrganic); const organic = Boolean(options.discloseCommercial && options.brandOrganic);
const check = (key, label, disabled, note) => ( const check = (key, label, disabled, note) => (
@ -90,31 +77,9 @@ function TikTokFields({ platform, type, options, setOption, caption, media = []
<span className="font-medium">{creator.nickname}</span>{creator.username && <span className="text-muted">@{creator.username}</span>} <span className="font-medium">{creator.nickname}</span>{creator.username && <span className="text-muted">@{creator.username}</span>}
</>} </>}
</div> </div>
{error && <div className="text-[12.5px] text-bad">{problem} If it says the sign-in expired, reconnect TikTok on the Connections page.</div>} {error && <div className="text-[12.5px] text-bad">Couldn't load this TikTok account's settings: {error.message}. Reconnect TikTok on the Connections page.</div>}
{media.length > 0 && (
<div>
<div className="text-[12px] text-muted mb-1.5">Preview of what will be posted</div>
<div className="flex gap-2 overflow-x-auto">
{media.map((m) => isVideoFile(m)
? <video key={m} src={mediaSrc(m)} className="h-40 rounded-md bg-black" controls muted preload="metadata" onLoadedMetadata={(e) => { if (m === firstVideo) setVideoSecs(e.currentTarget.duration); }} />
: <img key={m} src={mediaSrc(m)} alt="" className="h-40 rounded-md object-cover" />)}
</div>
{tooLong && <div className="text-[12.5px] text-bad mt-1.5">{problem}</div>}
</div>
)}
{!creator && !error && <div className="text-[12.5px] text-muted">Loading the account's TikTok settings…</div>} {!creator && !error && <div className="text-[12.5px] text-muted">Loading the account's TikTok settings…</div>}
{creator && ( {creator && (
<Field label="How to post">
<select id="opt-tiktok-mode" className="input" value={options.mode || "direct"} onChange={(e) => setOption("mode", e.target.value)}>
<option value="direct">Post directly</option>
<option value="inbox">Send to the TikTok app to finish</option>
</select>
</Field>
)}
{creator && options.mode === "inbox" && (
<p className="text-[12.5px] text-ink-2">Quorla uploads the {type === "REELS" ? "video" : "photos"} to the account's TikTok inbox. Someone opens TikTok, chooses who can see it, edits if needed and taps Post there.</p>
)}
{creator && options.mode !== "inbox" && (
<> <>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Field label="Who can view this post" hint={branded ? "Branded content can't be private." : undefined}> <Field label="Who can view this post" hint={branded ? "Branded content can't be private." : undefined}>
@ -164,8 +129,8 @@ function TikTokFields({ platform, type, options, setOption, caption, media = []
); );
} }
function PlatformFields({ platform, type, options, setOption, caption, media }) { function PlatformFields({ platform, type, options, setOption, caption }) {
if (platform.id === "tiktok") return <TikTokFields platform={platform} type={type} options={options} setOption={setOption} caption={caption} media={media} />; if (platform.id === "tiktok") return <TikTokFields platform={platform} type={type} options={options} setOption={setOption} caption={caption} />;
const fields = platform.fields.filter((f) => !f.onlyFor || f.onlyFor.includes(type)); const fields = platform.fields.filter((f) => !f.onlyFor || f.onlyFor.includes(type));
const own = options.caption; const own = options.caption;
const limit = platform.limits?.caption; const limit = platform.limits?.caption;
@ -209,7 +174,7 @@ function PlatformFields({ platform, type, options, setOption, caption, media })
* value: { platforms: string[], options: { [platform]: {...} } }, onChange(next) * value: { platforms: string[], options: { [platform]: {...} } }, onChange(next)
* type: the post format (IMAGE, REELS, CAROUSEL, STORY, TEXT) * type: the post format (IMAGE, REELS, CAROUSEL, STORY, TEXT)
*/ */
export default function PlatformPicker({ client, type, caption, value, onChange, available, media = [] }) { export default function PlatformPicker({ client, type, caption, value, onChange, available }) {
const { loading, list } = available; const { loading, list } = available;
const byId = useMemo(() => Object.fromEntries(list.map((p) => [p.id, p])), [list]); const byId = useMemo(() => Object.fromEntries(list.map((p) => [p.id, p])), [list]);
const toggle = (id) => onChange({ ...value, platforms: value.platforms.includes(id) ? value.platforms.filter((x) => x !== id) : [...value.platforms, id] }); const toggle = (id) => onChange({ ...value, platforms: value.platforms.includes(id) ? value.platforms.filter((x) => x !== id) : [...value.platforms, id] });
@ -231,7 +196,7 @@ export default function PlatformPicker({ client, type, caption, value, onChange,
})} })}
</div> </div>
{value.platforms.filter((id) => byId[id] && !byId[id].meta && byId[id].formats.includes(type)).map((id) => ( {value.platforms.filter((id) => byId[id] && !byId[id].meta && byId[id].formats.includes(type)).map((id) => (
<PlatformFields key={id} platform={byId[id]} type={type} caption={caption} media={media} options={value.options[id] || {}} setOption={setOption(id)} /> <PlatformFields key={id} platform={byId[id]} type={type} caption={caption} options={value.options[id] || {}} setOption={setOption(id)} />
))} ))}
</div> </div>
); );
@ -241,9 +206,6 @@ export default function PlatformPicker({ client, type, caption, value, onChange,
export function selectionFor(value, available, type) { export function selectionFor(value, available, type) {
const ok = new Set(available.list.filter((p) => p.formats.includes(type)).map((p) => p.id)); const ok = new Set(available.list.filter((p) => p.formats.includes(type)).map((p) => p.id));
const platforms = value.platforms.filter((p) => ok.has(p)); const platforms = value.platforms.filter((p) => ok.has(p));
const problems = platforms.map((p) => value.options[p]?._problem).filter(Boolean); const options = Object.fromEntries(platforms.filter((p) => value.options[p]).map((p) => [p, value.options[p]]));
// Keys starting with "_" are screen-only state; don't send them. return { platforms, options };
const clean = (o) => Object.fromEntries(Object.entries(o).filter(([k]) => !k.startsWith("_")));
const options = Object.fromEntries(platforms.filter((p) => value.options[p]).map((p) => [p, clean(value.options[p])]));
return { platforms, options, problems };
} }

View File

@ -1,170 +0,0 @@
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>
);
}

View File

@ -1,91 +0,0 @@
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>
);
}

View File

@ -1,284 +0,0 @@
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>
);
}

View File

@ -1,21 +1,16 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom"; import { Link, useNavigate, useParams } from "react-router-dom";
import { Bar, BarChart, CartesianGrid, Cell, Area, AreaChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; 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 { ArrowLeft, ArrowUpRight, ClipboardCheck, CalendarRange, FileBarChart, FileDown, FileText, Eye, RefreshCw, Sparkles, Briefcase, Link2, Facebook, Instagram } from "lucide-react";
import { api } from "../api.js"; 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 Screenshots from "./Screenshots.jsx";
import { useAuth } from "../App.jsx"; import { useAuth } from "../App.jsx";
import { import {
Badge, Button, cx, Dialog, Empty, ErrorNote, fmt, fmtDate, fmtDateTime, Panel, Spinner, Stat, StatCell, StatRow, STATUS_TONE, Tabs, timeAgo, Badge, Button, cx, Dialog, Empty, ErrorNote, fmt, fmtDate, fmtDateTime, Panel, Spinner, Stat, StatCell, StatRow, STATUS_TONE, Tabs, timeAgo,
} from "../ui.jsx"; } from "../ui.jsx";
const FORMAT = { const FORMAT = {
reel: { label: "Reel", color: "#3b3778" }, reel: { label: "Reel", color: "#3a7bbf" },
carousel: { label: "Carousel", color: "#fc6d45" }, carousel: { label: "Carousel", color: "#52ace0" },
image: { label: "Image", color: "#a9afba" }, image: { label: "Image", color: "#a9afba" },
}; };
const formatOf = (p) => (p.media_product_type === "REELS" || p.media_type === "VIDEO" ? "reel" : p.media_type === "CAROUSEL_ALBUM" ? "carousel" : "image"); const formatOf = (p) => (p.media_product_type === "REELS" || p.media_type === "VIDEO" ? "reel" : p.media_type === "CAROUSEL_ALBUM" ? "carousel" : "image");
@ -31,8 +26,7 @@ export default function Client() {
const nav = useNavigate(); const nav = useNavigate();
const [data, setData] = useState(null); const [data, setData] = useState(null);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [params] = useSearchParams(); const [tab, setTab] = useState("performance");
const [tab, setTab] = useState(params.get("tab") || "performance");
const [runOpen, setRunOpen] = useState(false); const [runOpen, setRunOpen] = useState(false);
const [linkOpen, setLinkOpen] = useState(false); const [linkOpen, setLinkOpen] = useState(false);
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
@ -101,13 +95,8 @@ export default function Client() {
onChange={setTab} onChange={setTab}
tabs={[ tabs={[
{ value: "performance", label: "Performance" }, { value: "performance", label: "Performance" },
{ value: "docs", label: "Documents", count: data.docs.filter((d) => d.kind !== "setup" && d.kind !== "competitors").length }, { value: "docs", label: "Documents", count: data.docs.filter((d) => d.kind !== "setup").length },
{ value: "competitors", label: "Competitor analysis", count: data.docs.filter((d) => d.kind === "competitors").length },
{ value: "runs", label: "AI runs", count: data.jobs.length }, { value: "runs", label: "AI runs", count: data.jobs.length },
{ value: "shots", label: "Screenshots" },
{ value: "brand", label: "Brand kit" },
{ value: "bio", label: "Link in bio" },
{ value: "portal", label: "Client approval" },
]} ]}
/> />
@ -121,13 +110,8 @@ export default function Client() {
</Empty> </Empty>
</Panel> </Panel>
))} ))}
{tab === "docs" && <div className="space-y-5"><MonthlyReport slug={slug} onBuilt={load} /><Documents slug={slug} docs={data.docs.filter((d) => d.kind !== "competitors")} reload={load} openPath={params.get("doc")} /></div>} {tab === "docs" && <Documents slug={slug} docs={data.docs} reload={load} />}
{tab === "competitors" && <CompetitorReports slug={slug} docs={data.docs.filter((d) => d.kind === "competitors")} reload={load} openPath={params.get("doc")} onStarted={(job) => nav(`/runs/${job.id}`)} />}
{tab === "runs" && <ClientRuns jobs={data.jobs} />} {tab === "runs" && <ClientRuns jobs={data.jobs} />}
{tab === "shots" && <Screenshots slug={slug} clientName={client.name} />}
{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}`)} /> <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(); }} />} {linkOpen && <LinkPageDialog client={client} onClose={() => setLinkOpen(false)} onLinked={() => { setLinkOpen(false); load(); }} />}
@ -294,15 +278,15 @@ function Performance({ snapshot, history, refreshing, onRefresh }) {
<AreaChart data={ig.dailyReach.map((x) => ({ ...x, label: fmtDate(x.date) }))} margin={{ top: 4, right: 8, bottom: 0, left: 0 }}> <AreaChart data={ig.dailyReach.map((x) => ({ ...x, label: fmtDate(x.date) }))} margin={{ top: 4, right: 8, bottom: 0, left: 0 }}>
<defs> <defs>
<linearGradient id="reachFill" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="reachFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3b3778" stopOpacity={0.18} /> <stop offset="0%" stopColor="#3a7bbf" stopOpacity={0.22} />
<stop offset="100%" stopColor="#3b3778" stopOpacity={0} /> <stop offset="100%" stopColor="#3a7bbf" stopOpacity={0} />
</linearGradient> </linearGradient>
</defs> </defs>
<CartesianGrid vertical={false} stroke="#eef0f3" /> <CartesianGrid vertical={false} stroke="#eef0f3" />
<XAxis dataKey="label" tick={{ fontSize: 11, fill: "#8a8f99" }} tickLine={false} axisLine={{ stroke: "#e4e7ec" }} minTickGap={24} /> <XAxis dataKey="label" tick={{ fontSize: 11, fill: "#8a8f99" }} tickLine={false} axisLine={{ stroke: "#e4e7ec" }} minTickGap={24} />
<YAxis tick={{ fontSize: 11, fill: "#8a8f99" }} tickLine={false} axisLine={false} width={48} allowDecimals={false} tickFormatter={(v) => (v >= 10000 ? `${Math.round(v / 1000)}k` : v)} /> <YAxis tick={{ fontSize: 11, fill: "#8a8f99" }} tickLine={false} axisLine={false} width={48} allowDecimals={false} tickFormatter={(v) => (v >= 10000 ? `${Math.round(v / 1000)}k` : v)} />
<Tooltip contentStyle={{ fontSize: 12, borderRadius: 8, border: "1px solid #e4e7ec" }} formatter={(v) => [fmt(v), "Reached"]} /> <Tooltip contentStyle={{ fontSize: 12, borderRadius: 8, border: "1px solid #e4e7ec" }} formatter={(v) => [fmt(v), "Reached"]} />
<Area type="monotone" dataKey="value" stroke="#3b3778" strokeWidth={2} fill="url(#reachFill)" isAnimationActive={false} /> <Area type="monotone" dataKey="value" stroke="#3a7bbf" strokeWidth={2} fill="url(#reachFill)" isAnimationActive={false} />
</AreaChart> </AreaChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
@ -390,40 +374,9 @@ function FormatBars({ d }) {
} }
// ---------------------------------------------------------------- documents // ---------------------------------------------------------------- documents
const KIND_LABEL = { monthly: "Monthly reports", audit: "Audits", competitors: "Competitor analysis", trends: "Trend scans", report: "Weekly reports", calendar: "Content calendars", "profile-check": "Profile checks", setup: "Client setup files" }; const KIND_LABEL = { audit: "Audits", report: "Weekly reports", calendar: "Content calendars", "profile-check": "Profile checks", setup: "Client setup files" };
function CompetitorReports({ slug, docs, reload, openPath, onStarted }) { function Documents({ slug, docs, reload }) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
const scan = async () => {
setBusy(true);
setError(null);
try {
onStarted((await api("/api/jobs", { method: "POST", body: { type: "competitor-scan", slug } })).job);
} catch (e) {
setError(e);
setBusy(false);
}
};
return (
<div className="space-y-5">
<Panel
title="Find & compare competitors"
subtitle="Finds local rivals and niche benchmarks, checks them on Instagram and Google, and rewrites this month's report. Track or dismiss the accounts it suggests under Tracked accounts."
action={<div className="flex flex-wrap gap-2 shrink-0">
<Link to={`/competitors?client=${encodeURIComponent(slug)}`}><Button icon={ArrowUpRight}>Tracked accounts</Button></Link>
<Button variant="primary" icon={Sparkles} loading={busy} onClick={scan}>Find competitors</Button>
</div>}
>
{error && <div className="p-4"><ErrorNote error={error} /></div>}
</Panel>
<Documents slug={slug} docs={docs} reload={reload} openPath={openPath} labels={{ competitors: "Reports" }}
empty={<Empty icon={FileBarChart} title="No competitor reports yet">Click Find competitors to create the first one.</Empty>} />
</div>
);
}
function Documents({ slug, docs, reload, openPath, empty, labels }) {
const [preview, setPreview] = useState(null); const [preview, setPreview] = useState(null);
const [busy, setBusy] = useState(null); const [busy, setBusy] = useState(null);
const [error, setError] = useState(null); const [error, setError] = useState(null);
@ -437,11 +390,6 @@ function Documents({ slug, docs, reload, openPath, empty, labels }) {
setError(e); setError(e);
} }
}; };
// Deep link from other pages (e.g. Competitors → "Open report"): ?tab=docs&doc=<path>
useEffect(() => {
const doc = openPath && docs.find((d) => d.path === openPath);
if (doc) open(doc);
}, [openPath]); // eslint-disable-line react-hooks/exhaustive-deps
const exportPdf = async (doc) => { const exportPdf = async (doc) => {
setBusy(doc.path); setBusy(doc.path);
setError(null); setError(null);
@ -457,13 +405,13 @@ function Documents({ slug, docs, reload, openPath, empty, labels }) {
}; };
const groups = Object.keys(KIND_LABEL).map((k) => [k, docs.filter((d) => d.kind === k)]).filter(([, list]) => list.length); const groups = Object.keys(KIND_LABEL).map((k) => [k, docs.filter((d) => d.kind === k)]).filter(([, list]) => list.length);
if (!docs.length) return <Panel>{empty || <Empty icon={FileText} title="No documents yet">Run an account audit to create the first ones.</Empty>}</Panel>; if (!docs.length) return <Panel><Empty icon={FileText} title="No documents yet">Run an account audit to create the first ones.</Empty></Panel>;
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<ErrorNote error={error} /> <ErrorNote error={error} />
{groups.map(([kind, list]) => ( {groups.map(([kind, list]) => (
<Panel key={kind} title={labels?.[kind] || KIND_LABEL[kind]}> <Panel key={kind} title={KIND_LABEL[kind]}>
<table className="tbl"> <table className="tbl">
<tbody> <tbody>
{list.map((d) => ( {list.map((d) => (
@ -516,9 +464,7 @@ function Documents({ slug, docs, reload, openPath, empty, labels }) {
) )
} }
> >
{preview?.loading ? <Spinner /> : preview?.page ? ( {preview?.loading ? <Spinner /> : preview && (
<iframe title={preview.doc.title} srcDoc={preview.page} sandbox="allow-popups allow-popups-to-escape-sandbox" className="w-full h-[72vh] rounded-lg border border-line bg-white" />
) : preview && (
<> <>
{preview.meta?.client && ( {preview.meta?.client && (
<div className="rounded-lg bg-[#232327] text-white px-5 py-4 mb-4"> <div className="rounded-lg bg-[#232327] text-white px-5 py-4 mb-4">

View File

@ -1,66 +0,0 @@
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>
);
}

View File

@ -1,317 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { Bar, BarChart, CartesianGrid, Cell, LabelList, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { Check, ExternalLink, FileBarChart, Hash, Plus, RefreshCw, Sparkles, Swords, Trash2, X } 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>
);
}
/** Accounts an AI run found and checked on Instagram; nothing is tracked until a person adds it. */
function Suggestions({ items, busy, onAdd, onDismiss, full }) {
if (!items?.length) return null;
return (
<Panel title={`Suggested by the competitor scan (${items.length})`} subtitle="Checked on Instagram. Add the ones that really compete for this client's customers; dismissed accounts aren't suggested again.">
<ul className="grid md:grid-cols-2 xl:grid-cols-3 gap-3">
{items.map((s) => (
<li key={s.username} className="rounded-lg border border-line p-3.5 flex flex-col gap-2 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<a href={`https://www.instagram.com/${s.username}/`} target="_blank" rel="noreferrer" className="font-semibold text-ink hover:underline">@{s.username}</a>
{s.name && <div className="text-[12px] text-muted truncate">{s.name}</div>}
</div>
<Badge tone={s.kind === "benchmark" ? "neutral" : "brand"}>{s.kind === "benchmark" ? "Benchmark" : "Local rival"}</Badge>
</div>
{s.why && <p className="text-[12.5px] text-ink-2 leading-relaxed">{s.why}</p>}
<dl className="grid grid-cols-3 gap-2 text-[12px]">
<div><dt className="text-muted">Followers</dt><dd className="num font-semibold">{fmt(s.followers)}</dd></div>
<div><dt className="text-muted">Posts / wk</dt><dd className="num font-semibold">{s.postsPerWeek ?? "—"}</dd></div>
<div><dt className="text-muted">Engagement</dt><dd className="num font-semibold">{s.engagementRate == null ? "—" : `${s.engagementRate}%`}</dd></div>
</dl>
{s.google?.rating && <div className="text-[12px] text-muted">Google ★ {s.google.rating}{s.google.reviews != null ? ` · ${fmt(s.google.reviews)} reviews` : ""}</div>}
<div className="flex gap-2 mt-auto pt-1">
<Button size="sm" variant="primary" icon={Check} loading={busy === `add-${s.username}`} disabled={full} title={full ? "Tracking is full (10). Remove one first." : undefined} onClick={() => onAdd(s.username)}>Track</Button>
<Button size="sm" icon={X} loading={busy === `dis-${s.username}`} onClick={() => onDismiss(s.username)}>Dismiss</Button>
</div>
</li>
))}
</ul>
</Panel>
);
}
/** Top public posts under a hashtag (Instagram Hashtag Search). Hidden unless switched on in Server settings → Research. */
function HashtagPosts({ client }) {
const [status, setStatus] = useState(null);
const [tag, setTag] = useState("");
const [kind, setKind] = useState("top");
const [result, setResult] = useState(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
setStatus(null); setResult(null); setError(null);
if (client) api(`/api/competitors/${encodeURIComponent(client)}/hashtag`).then(setStatus).catch(() => setStatus(null));
}, [client]);
if (!status?.enabled) return null;
const search = async (e) => {
e.preventDefault();
setBusy(true); setError(null);
try {
const r = await api(`/api/competitors/${encodeURIComponent(client)}/hashtag?tag=${encodeURIComponent(tag.replace(/^#/, ""))}&kind=${kind}`);
setResult(r);
if (r.quota) setStatus((s) => ({ ...s, quota: { ...s.quota, used: r.quota.used, left: r.quota.left } }));
} catch (err) { setError(err); }
setBusy(false);
};
const posts = result ? [...result.posts].sort((a, b) => (b.likes ?? 0) + (b.comments ?? 0) - ((a.likes ?? 0) + (a.comments ?? 0))) : [];
return (
<Panel title="Hashtag top posts" subtitle={`What's getting attention under a niche hashtag, from any public account. ${status.quota.left} of 30 hashtags left this week for this client's Instagram.`}>
<form onSubmit={search} className="flex gap-2 flex-wrap items-center">
<input className="input h-9 w-60" aria-label="Hashtag" placeholder="#waterlooeats" value={tag} onChange={(e) => setTag(e.target.value)} />
<select className="input h-9 w-auto" aria-label="Which posts" value={kind} onChange={(e) => setKind(e.target.value)}>
<option value="top">Top posts</option>
<option value="recent">Recent posts</option>
</select>
<Button type="submit" variant="primary" icon={Hash} loading={busy} disabled={!tag.trim()}>Search</Button>
</form>
<ErrorNote error={error} />
{result && (
<div className="mt-3 space-y-2">
<p className="text-[12.5px] text-muted">
#{result.hashtag} · {result.posts.length} posts{result.reelsShare != null ? ` · ${result.reelsShare}% Reels` : ""}{result.cached ? " · saved from the last 24 hours" : ""}. Instagram doesn't show who posted them.
</p>
<ul className="grid md:grid-cols-2 xl:grid-cols-3 gap-3">
{posts.map((p) => (
<li key={p.link} className="rounded-lg border border-line p-3 flex flex-col gap-1.5 min-w-0">
<div className="flex items-center justify-between gap-2 text-[12px]">
<Badge tone={p.type === "Reel" ? "brand" : "neutral"}>{p.type}</Badge>
<span className="num text-muted">{p.likes == null ? "likes hidden" : `${fmt(p.likes)} likes`} · {fmt(p.comments)} comments</span>
</div>
{p.caption && <p className="text-[12.5px] text-ink-2 line-clamp-3">{p.caption}</p>}
<a href={p.link} target="_blank" rel="noreferrer" className="text-[12px] text-brand-deep hover:underline inline-flex items-center gap-1 mt-auto">Open on Instagram <ExternalLink size={12} /></a>
</li>
))}
</ul>
</div>
)}
</Panel>
);
}
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 [suggestions, setSuggestions] = useState([]);
const navigate = useNavigate();
const load = useCallback(() => (client ? Promise.all([
api(`/api/competitors?client=${encodeURIComponent(client)}`).then(setData),
api(`/api/competitors/${encodeURIComponent(client)}/suggestions`).then((r) => setSuggestions(r.suggestions)),
]).catch(setError) : Promise.resolve()), [client]);
useEffect(() => { setData(null); setSuggestions([]); setError(null); load(); }, [load]);
const scan = () => run("scan", async () => { const r = await api("/api/jobs", { method: "POST", body: { type: "competitor-scan", slug: client } }); navigate(`/runs/${r.job.id}`); });
const accept = (u) => run(`add-${u}`, () => api(`/api/competitors/${client}/suggestions/${encodeURIComponent(u)}/add`, { method: "POST" }));
const dismiss = (u) => run(`dis-${u}`, () => api(`/api/competitors/${client}/suggestions/${encodeURIComponent(u)}/dismiss`, { method: "POST" }));
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?.report && <Button icon={FileBarChart} title={`Competitor report, updated ${fmtDate(data.report.updatedAt)}`} onClick={() => navigate(`/clients/${client}?tab=competitors&doc=${encodeURIComponent(data.report.path)}`)}>Open report</Button>}
{data?.competitors.length > 0 && <Button icon={RefreshCw} loading={busy === "refresh"} onClick={refresh}>Refresh</Button>}
<Button icon={Sparkles} loading={busy === "scan"} onClick={scan} title="AI run: searches the market, checks each account on Instagram and writes a competitor report">Find competitors</Button>
<Button variant="primary" icon={Plus} onClick={() => setAdding(true)} disabled={data?.competitors.length >= 10}>Add competitor</Button>
</div>
</header>
<ErrorNote error={error} />
<Suggestions items={suggestions} busy={busy} onAdd={accept} onDismiss={dismiss} full={data?.competitors.length >= 10} />
<HashtagPosts client={client} />
{!data ? (!error && <Spinner />) : !data.competitors.length ? (
<div className="panel">
<Empty icon={Swords} title="No competitors tracked yet" action={<div className="flex gap-2 justify-center"><Button variant="primary" icon={Sparkles} loading={busy === "scan"} onClick={scan}>Find competitors</Button><Button icon={Plus} onClick={() => setAdding(true)}>Add by hand</Button></div>}>
Let the AI search this client's market, check every account on Instagram and write a competitor report, or add accounts you already know. 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>
);
}

View File

@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { ArrowRightLeft, Check, CircleAlert, Copy, ExternalLink, Plug, RefreshCw, Settings2, Unplug } from "lucide-react"; import { Check, CircleAlert, Copy, ExternalLink, Plug, RefreshCw, Settings2, Unplug } from "lucide-react";
import { api } from "../api.js"; import { api } from "../api.js";
import { useAuth } from "../App.jsx"; import { useAuth } from "../App.jsx";
import { Badge, Button, cx, Dialog, ErrorNote, Field, fmt, Panel, Spinner, timeAgo } from "../ui.jsx"; import { Badge, Button, cx, Dialog, ErrorNote, Field, fmt, Panel, Spinner, timeAgo } from "../ui.jsx";
@ -77,59 +77,8 @@ function AppDialog({ platform, onClose, onSaved }) {
); );
} }
/** The agency's own Odoo (team tasks, workload). One per workspace, not per client. */ function PlatformCard({ platform, conn, client, isAdmin, mediaLinks, onSetup, reload, setError, quota }) {
function OdooPanel() {
const [s, setS] = useState(null);
const [f, setF] = useState(null);
const [busy, setBusy] = useState(null); const [busy, setBusy] = useState(null);
const [error, setError] = useState(null);
const [saved, setSaved] = useState(false);
const fill = (o) => { setS(o); setF({ url: o.url || "", db: o.db || "", username: o.username || "", apiKey: "", allowWrites: Boolean(o.allowWrites) }); };
useEffect(() => { api("/api/integrations/odoo").then((r) => fill(r.odoo)).catch(setError); }, []);
const set = (k) => (e) => setF({ ...f, [k]: e.target.type === "checkbox" ? e.target.checked : e.target.value });
const save = async (e) => {
e.preventDefault(); setBusy("save"); setError(null); setSaved(false);
try { fill((await api("/api/integrations/odoo", { method: "PUT", body: f })).odoo); setSaved(true); } catch (err) { setError(err); } finally { setBusy(null); }
};
const remove = async () => {
setBusy("remove"); setError(null); setSaved(false);
try { fill((await api("/api/integrations/odoo", { method: "DELETE" })).odoo); } catch (err) { setError(err); } finally { setBusy(null); }
};
const fromSettings = s?.source === "settings";
return (
<Panel title="Odoo · team tasks" subtitle="Your agency's Odoo. Powers the Team page, task workload and tasks created from the calendar and library."
action={s && (s.configured ? <Badge tone="ok" dot>{fromSettings ? "Connected" : "From server .env"}</Badge> : <Badge>Not connected</Badge>)}>
{!f ? <div className="px-4 py-3">{error ? <ErrorNote error={error} /> : <Spinner />}</div> : (
<form onSubmit={save} className="px-4 py-3 space-y-3 text-[13.5px]">
<p className="text-ink-2">In Odoo: your avatar → <b>My Profile</b> (or Preferences) → <b>Account Security</b> → <b>New API Key</b>. Use the login of a user who can see your projects.</p>
<div className="grid gap-3 grid-cols-[repeat(auto-fill,minmax(240px,1fr))]">
<Field label="Odoo address"><input id="odoo-url" className="input" value={f.url} onChange={set("url")} placeholder="https://yourcompany.odoo.com" required /></Field>
<Field label="Database" hint="Shown at /web/database/selector, or ask your Odoo admin."><input id="odoo-db" className="input" value={f.db} onChange={set("db")} required autoComplete="off" /></Field>
<Field label="Username (login email)"><input id="odoo-user" className="input" value={f.username} onChange={set("username")} required autoComplete="off" /></Field>
<Field label="API key" hint={fromSettings ? "Leave empty to keep the stored key." : "Stored encrypted. Never shown again."}>
<input id="odoo-key" className="input" type="password" value={f.apiKey} onChange={set("apiKey")} required={!fromSettings} autoComplete="new-password" />
</Field>
</div>
<label className="flex items-start gap-2">
<input id="odoo-writes" type="checkbox" className="mt-0.5 accent-brand" checked={f.allowWrites} onChange={set("allowWrites")} />
<span>Allow creating and moving tasks <span className="text-muted">(off = read only)</span></span>
</label>
{s.source === "env" && <p className="text-muted text-[12.5px]">Currently using the server's .env. Saving here overrides it.</p>}
{saved && <p className="text-ok text-[13px] flex items-center gap-1"><Check className="size-4" />Logged in to Odoo and saved.</p>}
<ErrorNote error={error} />
<div className="flex gap-2">
<Button variant="primary" type="submit" loading={busy === "save"}>Test & save</Button>
{fromSettings && <Button variant="danger" type="button" loading={busy === "remove"} onClick={remove}>Disconnect</Button>}
</div>
</form>
)}
</Panel>
);
}
function PlatformCard({ platform, conn, client, clients, isAdmin, mediaLinks, onSetup, onMoved, reload, setError, quota }) {
const [busy, setBusy] = useState(null);
const [moving, setMoving] = useState(false);
const [targets, setTargets] = useState(null); const [targets, setTargets] = useState(null);
const needsMediaLinks = ["tiktok", "threads", "gbp"].includes(platform.id) && !mediaLinks; const needsMediaLinks = ["tiktok", "threads", "gbp"].includes(platform.id) && !mediaLinks;
@ -165,7 +114,7 @@ function PlatformCard({ platform, conn, client, clients, isAdmin, mediaLinks, on
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
{conn.account?.avatar && <img src={conn.account.avatar} alt="" className="size-6 rounded-full shrink-0" />} {conn.account?.avatar && <img src={conn.account.avatar} alt="" className="size-6 rounded-full shrink-0" />}
<span className="font-medium truncate">{conn.account?.name}</span> <span className="font-medium truncate">{conn.account?.name}</span>
{conn.account?.username && <span className="text-muted truncate">@{String(conn.account.username).replace(/^@+/, "")}</span>} {conn.account?.username && <span className="text-muted truncate">@{conn.account.username}</span>}
</div> </div>
<div className="text-[12px] text-muted"> <div className="text-[12px] text-muted">
{conn.account?.followers != null && <>{fmt(conn.account.followers)} {platform.id === "youtube" ? "subscribers" : platform.id === "reddit" ? "karma" : "followers"} · </>} {conn.account?.followers != null && <>{fmt(conn.account.followers)} {platform.id === "youtube" ? "subscribers" : platform.id === "reddit" ? "karma" : "followers"} · </>}
@ -189,18 +138,6 @@ function PlatformCard({ platform, conn, client, clients, isAdmin, mediaLinks, on
</Field> </Field>
)} )}
{isAdmin && conn && moving && (
<Field label="Move this account to">
<select id={`move-${platform.id}`} className="input" defaultValue="" disabled={busy === "move"}
onChange={(e) => { const to = e.target.value; if (!to) return; setBusy("move"); setError(null);
api(`/api/connections/${conn.id}`, { method: "PATCH", body: { client: to } })
.then(() => { setMoving(false); onMoved(to); }).catch((er) => setError(er)).finally(() => setBusy(null)); }}>
<option value="" disabled>Choose a client…</option>
{clients.filter((c) => c.slug !== client).map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}
</select>
</Field>
)}
{needsMediaLinks && <div className="text-[12px] text-warn">Image and video posts need PUBLIC_URL set on the server (e.g. https://app.quorlasocial.com).</div>} {needsMediaLinks && <div className="text-[12px] text-warn">Image and video posts need PUBLIC_URL set on the server (e.g. https://app.quorlasocial.com).</div>}
<div className="flex flex-wrap gap-2 mt-auto pt-1"> <div className="flex flex-wrap gap-2 mt-auto pt-1">
@ -208,7 +145,6 @@ function PlatformCard({ platform, conn, client, clients, isAdmin, mediaLinks, on
{isAdmin && platform.app.configured && !conn && <Button size="sm" variant="primary" icon={Plug} loading={busy === "connect"} onClick={connect}>Connect</Button>} {isAdmin && platform.app.configured && !conn && <Button size="sm" variant="primary" icon={Plug} loading={busy === "connect"} onClick={connect}>Connect</Button>}
{isAdmin && conn && <Button size="sm" icon={Plug} loading={busy === "connect"} onClick={connect}>Reconnect</Button>} {isAdmin && conn && <Button size="sm" icon={Plug} loading={busy === "connect"} onClick={connect}>Reconnect</Button>}
{isAdmin && conn && <Button size="sm" variant="ghost" icon={RefreshCw} loading={busy === "check"} onClick={() => run("check", () => api(`/api/connections/${conn.id}/check`, { method: "POST" }))}>Check</Button>} {isAdmin && conn && <Button size="sm" variant="ghost" icon={RefreshCw} loading={busy === "check"} onClick={() => run("check", () => api(`/api/connections/${conn.id}/check`, { method: "POST" }))}>Check</Button>}
{isAdmin && conn && <Button size="sm" variant="ghost" icon={ArrowRightLeft} onClick={() => setMoving(!moving)}>{moving ? "Cancel move" : "Move"}</Button>}
{isAdmin && conn && <Button size="sm" variant="ghost" icon={Unplug} loading={busy === "remove"} onClick={() => run("remove", () => api(`/api/connections/${conn.id}`, { method: "DELETE" }))}>Disconnect</Button>} {isAdmin && conn && <Button size="sm" variant="ghost" icon={Unplug} loading={busy === "remove"} onClick={() => run("remove", () => api(`/api/connections/${conn.id}`, { method: "DELETE" }))}>Disconnect</Button>}
{isAdmin && platform.app.configured && <Button size="sm" variant="ghost" icon={Settings2} onClick={onSetup} aria-label={`${platform.name} app settings`} />} {isAdmin && platform.app.configured && <Button size="sm" variant="ghost" icon={Settings2} onClick={onSetup} aria-label={`${platform.name} app settings`} />}
{!isAdmin && !conn && <span className="text-[12.5px] text-muted">An admin connects this.</span>} {!isAdmin && !conn && <span className="text-[12.5px] text-muted">An admin connects this.</span>}
@ -288,13 +224,11 @@ export default function Connections() {
<div className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(300px,1fr))]"> <div className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(300px,1fr))]">
{platforms.platforms.map((p) => ( {platforms.platforms.map((p) => (
<PlatformCard key={`${p.id}-${client}`} platform={p} conn={conns.find((c) => c.platform === p.id)} client={client} clients={clients} isAdmin={isAdmin} <PlatformCard key={`${p.id}-${client}`} platform={p} conn={conns.find((c) => c.platform === p.id)} client={client} isAdmin={isAdmin}
mediaLinks={platforms.mediaLinks} onSetup={() => setSetup(p)} onMoved={pickClient} reload={loadConns} setError={setError} quota={quota[p.id]} /> mediaLinks={platforms.mediaLinks} onSetup={() => setSetup(p)} reload={loadConns} setError={setError} quota={quota[p.id]} />
))} ))}
</div> </div>
{isAdmin && <OdooPanel />}
{setup && <AppDialog platform={setup} onClose={() => setSetup(null)} onSaved={() => { setSetup(null); loadPlatforms(); }} />} {setup && <AppDialog platform={setup} onClose={() => setSetup(null)} onSaved={() => { setSetup(null); loadPlatforms(); }} />}
</div> </div>
); );

View File

@ -1,36 +1,9 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { EyeOff, Eye, MessageCircle, RefreshCw, Send } from "lucide-react"; import { EyeOff, Eye, MessageCircle, RefreshCw, Send } from "lucide-react";
import { api } from "../api.js"; import { api } from "../api.js";
import { Badge, Button, cx, Empty, ErrorNote, Panel, Spinner, Tabs, timeAgo } from "../ui.jsx"; import { Badge, Button, cx, Empty, ErrorNote, Panel, Spinner, timeAgo } from "../ui.jsx";
import { GoogleReviews, YouTubeComments } from "./InboxMore.jsx";
export default function Inbox() { 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 [clients, setClients] = useState([]);
const [client, setClient] = useState(""); const [client, setClient] = useState("");
const [days, setDays] = useState(14); const [days, setDays] = useState(14);

View File

@ -1,120 +0,0 @@
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>
);
}

View File

@ -32,10 +32,7 @@ export default function Library() {
const [tab, setTab] = useState(isAdmin ? "submitted" : "changes"); const [tab, setTab] = useState(isAdmin ? "submitted" : "changes");
const [data, setData] = useState(null); const [data, setData] = useState(null);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [dialog, setDialog] = useState(null); // {kind:'upload'} | {kind:'review'|'schedule'|'version', item} | {kind:'bulk'} const [dialog, setDialog] = useState(null); // {kind:'upload'} | {kind:'review'|'schedule'|'version', item}
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); }, []); 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]); const load = useCallback(() => api(`/api/library${client ? `?client=${client}` : ""}`).then(setData).catch(setError), [client]);
@ -66,8 +63,6 @@ export default function Library() {
</button> </button>
))} ))}
<Button variant="ghost" size="sm" icon={RefreshCw} onClick={load}>Refresh</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> </div>
<ErrorNote error={error} /> <ErrorNote error={error} />
{!data ? <Spinner /> : !items.length ? ( {!data ? <Spinner /> : !items.length ? (
@ -75,12 +70,7 @@ export default function Library() {
) : ( ) : (
<div className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(260px,1fr))]"> <div className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(260px,1fr))]">
{items.map((i) => ( {items.map((i) => (
<div key={i.id} className={cx("panel overflow-hidden flex flex-col relative", picked.includes(i.id) && "ring-2 ring-brand")}> <div key={i.id} className="panel overflow-hidden flex flex-col">
{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} /> <Preview item={i} />
<div className="p-3.5 flex-1 flex flex-col gap-1.5"> <div className="p-3.5 flex-1 flex flex-col gap-1.5">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
@ -106,7 +96,6 @@ export default function Library() {
{dialog?.kind === "upload" && <UploadDialog clients={clients} defaultClient={client} onClose={() => setDialog(null)} onDone={() => { setTab("submitted"); done(); }} />} {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 === "version" && <VersionDialog item={dialog.item} onClose={() => setDialog(null)} onDone={done} />}
{dialog?.kind === "review" && <ReviewDialog 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} />} {dialog?.kind === "schedule" && <ScheduleDialog item={dialog.item} clientName={name(dialog.item.client)} onClose={() => setDialog(null)} onDone={done} />}
</div> </div>
); );
@ -140,23 +129,13 @@ function FilePicker({ files, setFiles, accept = "image/jpeg,image/png,video/mp4,
} }
function UploadDialog({ clients, defaultClient, onClose, onDone }) { function UploadDialog({ clients, defaultClient, onClose, onDone }) {
const [f, setF] = useState({ client: defaultClient || clients[0]?.slug || "", title: "", format: "IMAGE", captionIdea: "", notes: "", taskRef: "", forDate: "", planItem: "" }); const [f, setF] = useState({ client: defaultClient || clients[0]?.slug || "", title: "", format: "IMAGE", captionIdea: "", notes: "", taskRef: "", forDate: "" });
const [planned, setPlanned] = useState([]);
const [files, setFiles] = useState([]); const [files, setFiles] = useState([]);
const [tasks, setTasks] = useState([]); const [tasks, setTasks] = useState([]);
const [progress, setProgress] = useState(null); const [progress, setProgress] = useState(null);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const set = (k, v) => setF((s) => ({ ...s, [k]: v })); const set = (k, v) => setF((s) => ({ ...s, [k]: v }));
useEffect(() => { api("/api/team").then((r) => setTasks(r.tasks || [])).catch(() => {}); }, []); 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 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 () => { const submit = async () => {
@ -169,16 +148,8 @@ function UploadDialog({ clients, defaultClient, onClose, onDone }) {
}; };
return ( 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></>}> <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"> <div className="grid grid-cols-2 gap-4">
<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="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="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="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="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> <Field label="Planned for (optional)"><input type="date" className="input" value={f.forDate} onChange={(e) => set("forDate", e.target.value)} /></Field>
@ -258,7 +229,6 @@ function ScheduleDialog({ item, clientName, onClose, onDone }) {
const submit = async () => { const submit = async () => {
setBusy(true); setError(null); setBusy(true); setError(null);
try { try {
if (chosen.problems.length) throw new Error(chosen.problems[0]);
await api(`/api/library/${item.id}/schedule`, { method: "POST", body: { platforms: chosen.platforms, options: chosen.options, localTime: `${f.date} ${f.time}`, caption: f.caption, fbCaption: f.fbCaption || undefined, collaborators: f.collaborators.split(/[\s,]+/).map((x) => x.replace(/^@/, "")).filter(Boolean) } }); await api(`/api/library/${item.id}/schedule`, { method: "POST", body: { platforms: chosen.platforms, options: chosen.options, localTime: `${f.date} ${f.time}`, caption: f.caption, fbCaption: f.fbCaption || undefined, collaborators: f.collaborators.split(/[\s,]+/).map((x) => x.replace(/^@/, "")).filter(Boolean) } });
onDone(); onDone();
} catch (e) { setError(e); setBusy(false); } } catch (e) { setError(e); setBusy(false); }
@ -272,61 +242,10 @@ function ScheduleDialog({ item, clientName, onClose, onDone }) {
</Field> </Field>
</div> </div>
<Field label="Caption"><textarea className="input h-28" value={f.caption} onChange={(e) => set("caption", e.target.value)} /></Field> <Field label="Caption"><textarea className="input h-28" value={f.caption} onChange={(e) => set("caption", e.target.value)} /></Field>
<Field label="Post to"><PlatformPicker client={item.client} type={item.format} caption={f.caption} value={sel} onChange={setSel} available={available} media={item.files} /></Field> <Field label="Post to"><PlatformPicker client={item.client} type={item.format} caption={f.caption} value={sel} onChange={setSel} available={available} /></Field>
{sel.platforms.includes("facebook") && <Field label="Facebook caption (optional)"><textarea className="input h-16" value={f.fbCaption} onChange={(e) => set("fbCaption", e.target.value)} /></Field>} {sel.platforms.includes("facebook") && <Field label="Facebook caption (optional)"><textarea className="input h-16" value={f.fbCaption} onChange={(e) => set("fbCaption", e.target.value)} /></Field>}
{sel.platforms.includes("instagram") && item.format !== "STORY" && <Field label="Collab invites (optional)"><input className="input" placeholder="@username" value={f.collaborators} onChange={(e) => set("collaborators", e.target.value)} /></Field>} {sel.platforms.includes("instagram") && item.format !== "STORY" && <Field label="Collab invites (optional)"><input className="input" placeholder="@username" value={f.collaborators} onChange={(e) => set("collaborators", e.target.value)} /></Field>}
<ErrorNote error={error} /> <ErrorNote error={error} />
</Dialog> </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>
);
}

View File

@ -1,95 +0,0 @@
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>
);
}

View File

@ -2,7 +2,7 @@ import { useState } from "react";
import { api } from "../api.js"; import { api } from "../api.js";
import { Button, ErrorNote, Field } from "../ui.jsx"; import { Button, ErrorNote, Field } from "../ui.jsx";
export default function Login({ onSignedIn, setupNeeded, agency, hasLogo }) { export default function Login({ onSignedIn, setupNeeded, agency = "Metatroncube" }) {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
@ -17,7 +17,7 @@ export default function Login({ onSignedIn, setupNeeded, agency, hasLogo }) {
if (setupNeeded && password !== confirm) return setError(new Error("The two passwords don't match.")); if (setupNeeded && password !== confirm) return setError(new Error("The two passwords don't match."));
setBusy(true); setBusy(true);
try { try {
if (setupNeeded) await api("/api/auth/setup", { method: "POST", body: { name, email, password, setupToken: new URLSearchParams(location.search).get("setup") || "" } }); if (setupNeeded) await api("/api/auth/setup", { method: "POST", body: { name, email, password } });
else await api("/api/auth/login", { method: "POST", body: { email, password } }); else await api("/api/auth/login", { method: "POST", body: { email, password } });
onSignedIn(); onSignedIn();
} catch (err) { } catch (err) {
@ -28,21 +28,23 @@ export default function Login({ onSignedIn, setupNeeded, agency, hasLogo }) {
return ( return (
<div className="min-h-screen grid lg:grid-cols-[1fr_520px] bg-white"> <div className="min-h-screen grid lg:grid-cols-[1fr_520px] bg-white">
<div className="hidden lg:flex flex-col justify-between bg-brand-deep text-white p-12 relative overflow-hidden"> <div className="hidden lg:flex flex-col justify-between bg-[#1f2024] text-white p-12 relative overflow-hidden">
<div className="absolute -right-32 -top-32 size-[420px] rounded-full bg-[radial-gradient(circle,rgba(252,109,69,0.30),transparent_65%)]" /> <div className="absolute -right-32 -top-32 size-[420px] rounded-full bg-[radial-gradient(circle,rgba(82,172,224,0.28),transparent_65%)]" />
<img src="/brand/logo-white.png" alt="Quorla" className="h-[34px] w-auto self-start" /> <div className="text-[12px] font-semibold tracking-[0.12em] uppercase text-sky">{agency} · Social command</div>
<div className="max-w-md"> <div className="max-w-md">
<h1 className="text-white text-[34px] font-extrabold leading-[1.15]">Every client account, the team's work and the reports, in one place.</h1> <h1 className="text-white text-[34px] font-extrabold leading-[1.15]">Every client account, the team's work and the reports, in one place.</h1>
<p className="text-[#c9c7dd] mt-4 text-[14.5px] leading-relaxed"> <p className="text-[#b7bac3] mt-4 text-[14.5px] leading-relaxed">
Plan, approve and publish to Instagram, Facebook, TikTok, YouTube, LinkedIn and more, with live numbers, AI-run audits and weekly plans, and reports your clients can read. Live Instagram and Facebook numbers, AI-run audits and weekly plans, branded PDF reports and Odoo tasks.
</p> </p>
</div> </div>
<div className="text-[12px] text-[#9e9bbd]">{agency ? `${agency} workspace · ` : ""}client data is confidential</div> <div className="text-[12px] text-[#8b8f99]">Internal tool · client data is confidential</div>
</div> </div>
<div className="flex items-center justify-center p-8"> <div className="flex items-center justify-center p-8">
<form onSubmit={submit} className="w-full max-w-[340px]"> <form onSubmit={submit} className="w-full max-w-[340px]">
{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" />} <div className="h-[54px] overflow-hidden mb-8">
<img src="/brand/logo.png" alt={agency} className="h-[76px] -mt-[8px]" />
</div>
<h2 className="text-[20px] font-extrabold">{setupNeeded ? "Create the admin account" : "Sign in"}</h2> <h2 className="text-[20px] font-extrabold">{setupNeeded ? "Create the admin account" : "Sign in"}</h2>
<p className="text-muted text-[13px] mt-1 mb-6"> <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."} {setupNeeded ? "First run: this person can add the rest of the team from Settings." : "Use your agency email."}

View File

@ -1,44 +0,0 @@
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>
);
}

View File

@ -5,7 +5,6 @@ import { api } from "../api.js";
import { useAuth } from "../App.jsx"; import { useAuth } from "../App.jsx";
import { Badge, Button, cx, Empty, ErrorNote, fmt, fmtDate, Panel, Spark, Spinner, Stat, StatCell, StatRow, timeAgo } from "../ui.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 AddClientDialog from "./AddClientDialog.jsx";
import GettingStarted from "../GettingStarted.jsx";
const LEVEL = { const LEVEL = {
bad: { icon: CircleAlert, cls: "text-bad" }, bad: { icon: CircleAlert, cls: "text-bad" },
@ -65,7 +64,6 @@ export default function Overview() {
</header> </header>
<ErrorNote error={error} /> <ErrorNote error={error} />
<GettingStarted />
{t && ( {t && (
<StatRow className="grid-cols-5"> <StatRow className="grid-cols-5">

View File

@ -1,194 +0,0 @@
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>
);
}

View File

@ -1,12 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { CalendarClock, Check, Image as ImageIcon, Plus, RotateCcw, Upload, X, Send } from "lucide-react"; import { CalendarClock, Check, Image as ImageIcon, Plus, Upload, X } from "lucide-react";
import { api } from "../api.js"; import { api } from "../api.js";
import { useAuth } from "../App.jsx"; import { useAuth } from "../App.jsx";
import { Badge, Button, cx, Dialog, Empty, ErrorNote, Field, Panel, Spinner } from "../ui.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"; 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 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)"]]; const TYPES = [["IMAGE", "Single image"], ["REELS", "Reel / video"], ["CAROUSEL", "Carousel / multi-image"], ["STORY", "Story (Instagram, Facebook)"], ["TEXT", "Text only (no Instagram)"]];
@ -71,7 +69,6 @@ export default function Publishing() {
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-[13.5px]">{p.clientName}</span> <span className="font-semibold text-[13.5px]">{p.clientName}</span>
<Badge tone={STATUS[p.status]} dot>{p.status}</Badge> <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> <span className="text-[12px] text-muted">{p.type.toLowerCase()} · {p.platforms.join(" + ")}</span>
</div> </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> <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>
@ -79,18 +76,13 @@ export default function Publishing() {
{p.collaborators?.length > 0 && <div className="text-[12px] text-muted mt-1">Collab: {p.collaborators.map((c) => "@" + c).join(", ")}</div>} {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.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>)} {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>)} {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>)} {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>}
{p.results?.facebook?.scheduledOnFacebook && !p.results.facebook.cancelled && <div className="text-[12px] text-muted mt-1">Facebook part is scheduled in Meta Planner.</div>} {p.results?.facebook?.scheduledOnFacebook && !p.results.facebook.cancelled && <div className="text-[12px] text-muted mt-1">Facebook part is scheduled in Meta Planner.</div>}
<div className="text-[11.5px] text-faint mt-1">by {p.createdBy}{p.approvedBy ? ` · approved by ${p.approvedBy}` : ""}</div> <div className="text-[11.5px] text-faint mt-1">by {p.createdBy}{p.approvedBy ? ` · approved by ${p.approvedBy}` : ""}</div>
</div> </div>
<div className="flex gap-2 shrink-0"> <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>} {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>} {!["published", "cancelled", "publishing"].includes(p.status) && <Button size="sm" variant="danger" icon={X} loading={busy === p.id + "cancel"} onClick={() => act(p, "cancel")}>Cancel</Button>}
</div> </div>
</li> </li>
@ -143,8 +135,7 @@ function Compose({ clients, onClose, onSaved }) {
const save = async () => { const save = async () => {
setSaving(true); setError(null); setSaving(true); setError(null);
try { try {
const { platforms, options, problems } = selectionFor(sel, available, f.type); const { platforms, options } = selectionFor(sel, available, f.type);
if (problems.length) throw new Error(problems[0]);
await api("/api/schedule", { method: "POST", body: { await api("/api/schedule", { method: "POST", body: {
client: f.client, platforms, options, type: f.type, media: f.media, caption: f.caption, fbCaption: f.fbCaption || undefined, client: f.client, platforms, options, type: f.type, media: f.media, caption: f.caption, fbCaption: f.fbCaption || undefined,
collaborators: f.collaborators.split(/[\s,]+/).map((x) => x.replace(/^@/, "")).filter(Boolean), localTime: `${f.date} ${f.time}`, collaborators: f.collaborators.split(/[\s,]+/).map((x) => x.replace(/^@/, "")).filter(Boolean), localTime: `${f.date} ${f.time}`,
@ -167,7 +158,7 @@ function Compose({ clients, onClose, onSaved }) {
{TYPES.map(([v, l]) => <option key={v} value={v}>{l}</option>)} {TYPES.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select> </select>
</Field> </Field>
<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(", ")}`}> <Field label={`When (${tz?.timezone?.replace("_", " ") || "client time"})`} hint={tz ? `Good times: ${tz.times.join(", ")}` : null}>
<div className="flex gap-2"> <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-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)} /> <input id="c-time" type="time" className="input w-[120px]" value={f.time} onChange={(e) => set("time", e.target.value)} />
@ -177,15 +168,7 @@ function Compose({ clients, onClose, onSaved }) {
{f.type !== "TEXT" && ( {f.type !== "TEXT" && (
<Field label="Media" hint="JPG/PNG for images, MP4/MOV for Reels and video stories. Carousel: 2–10 files in order."> <Field label="Media" hint="JPG/PNG for images, MP4/MOV for Reels and video stories. Carousel: 2–10 files in order.">
<div className="flex flex-wrap gap-2 items-center"> <div className="flex flex-wrap gap-2 items-center">
{f.media.map((m, i) => ( {f.media.map((m, i) => <span key={m} className="text-[12px] bg-canvas border border-line rounded px-2 py-1">{i + 1}. {m.split(/[\\/]/).pop().replace(/^\d+-/, "")} <button className="text-bad ml-1" onClick={() => set("media", f.media.filter((x) => x !== m))}>×</button></span>)}
<span key={m} className="relative inline-flex flex-col items-center gap-1 text-[11.5px] text-muted">
{/\.(mp4|mov|m4v|webm)$/i.test(m)
? <video src={`/api/schedule/media?path=${encodeURIComponent(m)}`} className="h-24 rounded-md bg-black" muted preload="metadata" />
: <img src={`/api/schedule/media?path=${encodeURIComponent(m)}`} alt="" className="h-24 rounded-md object-cover" />}
<span>{i + 1}. {m.split(/[\\/]/).pop().replace(/^\d+-/, "").slice(0, 24)}</span>
<button type="button" aria-label="Remove file" className="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-white border border-line text-bad leading-none" onClick={() => set("media", f.media.filter((x) => x !== m))}>×</button>
</span>
))}
<label className="inline-flex items-center gap-1.5 h-8 px-3 rounded-md border border-line-strong text-[13px] cursor-pointer hover:bg-canvas"> <label className="inline-flex items-center gap-1.5 h-8 px-3 rounded-md border border-line-strong text-[13px] cursor-pointer hover:bg-canvas">
<Upload className="size-3.5" /> {uploading ? "Uploading…" : "Add files"} <Upload className="size-3.5" /> {uploading ? "Uploading…" : "Add files"}
<input id="c-files" type="file" className="hidden" multiple accept="image/jpeg,image/png,video/mp4,video/quicktime" onChange={(e) => upload([...e.target.files])} /> <input id="c-files" type="file" className="hidden" multiple accept="image/jpeg,image/png,video/mp4,video/quicktime" onChange={(e) => upload([...e.target.files])} />
@ -195,9 +178,8 @@ function Compose({ clients, onClose, onSaved }) {
)} )}
<Field label="Caption" hint="Plain text works best for search. Hashtags at the end."> <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)} /> <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>
<Field label="Post to"><PlatformPicker client={f.client} type={f.type} caption={f.caption} value={sel} onChange={setSel} available={available} media={f.media} /></Field> <Field label="Post to"><PlatformPicker client={f.client} type={f.type} caption={f.caption} value={sel} onChange={setSel} available={available} /></Field>
{sel.platforms.includes("facebook") && ( {sel.platforms.includes("facebook") && (
<Field label="Facebook caption (optional)" hint="Leave empty to reuse the Instagram caption."> <Field label="Facebook caption (optional)" hint="Leave empty to reuse the Instagram caption.">
<textarea id="c-fbcaption" className="input w-full h-20 py-2" value={f.fbCaption} onChange={(e) => set("fbCaption", e.target.value)} /> <textarea id="c-fbcaption" className="input w-full h-20 py-2" value={f.fbCaption} onChange={(e) => set("fbCaption", e.target.value)} />

View File

@ -1,142 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Copy, ImageUp, Star, Trash2 } from "lucide-react";
import { api } from "../api.js";
import { useAuth } from "../App.jsx";
import { Badge, Button, Dialog, ErrorNote, Panel, Spinner, timeAgo } from "../ui.jsx";
const monthLabel = (m) => new Date(`${m}-15`).toLocaleDateString("en-CA", { month: "long", year: "numeric" });
/** Client page tab: the monthly screenshot checklist the team fills and the audit reads. */
export default function Screenshots({ slug, clientName }) {
const { isAdmin } = useAuth();
const [month, setMonth] = useState(null);
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [busy, setBusy] = useState(null);
const [view, setView] = useState(null); // { title, src }
const [copied, setCopied] = useState(false);
const load = useCallback(() => api(`/api/clients/${slug}/screenshots${month ? `?month=${month}` : ""}`).then((d) => { setData(d); setError(null); }).catch(setError), [slug, month]);
useEffect(() => { load(); }, [load]);
const act = async (id, fn) => {
setBusy(id); setError(null);
try { setData(await fn()); } catch (e) { setError(e); }
setBusy(null);
};
const q = `?month=${data?.month}`;
const upload = (shot, file) => act(shot.id, async () => {
const res = await fetch(`/api/clients/${slug}/screenshots/${shot.id}${q}`, { method: "POST", credentials: "same-origin", headers: { "X-Filename": encodeURIComponent(file.name), "Content-Type": "application/octet-stream" }, body: file });
const out = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(out.error || `Upload failed (${res.status})`);
return out;
});
const copyChecklist = async () => {
const lines = data.shots.map((s, i) => `${i + 1}. ${s.platform}, ${s.label}${s.required ? "" : " (if it exists)"}: ${s.how}`);
await navigator.clipboard.writeText(`Screenshots for ${clientName}, ${monthLabel(data.month)}\nUpload them in Quorla → ${clientName} → Screenshots.\n\n${lines.join("\n")}`);
setCopied(true); setTimeout(() => setCopied(false), 2000);
};
if (!data) return error ? <ErrorNote error={error} /> : <Spinner />;
const required = data.shots.filter((s) => s.required);
const done = required.filter((s) => s.file).length;
return (
<div className="space-y-4">
<Panel
title="Monthly screenshots"
subtitle="Take these in a computer browser before the monthly audit (Windows: Win+Shift+S). The audit reads them for what the numbers can't show: bio, highlights, how the feed looks, buttons and reviews."
action={
<div className="flex items-center gap-2">
<label htmlFor="ss-month" className="sr-only">Month</label>
<select id="ss-month" className="input h-8 w-auto" value={data.month} onChange={(e) => setMonth(e.target.value)}>
{data.months.map((m) => <option key={m} value={m}>{monthLabel(m)}</option>)}
</select>
<Button size="sm" icon={Copy} onClick={copyChecklist}>{copied ? "Copied" : "Copy checklist"}</Button>
</div>
}
>
<p className="text-[13px] text-ink-2">
<span className="num font-semibold text-ink">{done} of {required.length}</span> required screenshots uploaded for {monthLabel(data.month)}.
{done === required.length && " Ready for the audit."}
</p>
</Panel>
<ErrorNote error={error} />
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{data.shots.map((s) => (
<ShotCard key={s.id} shot={s} slug={slug} q={q} busy={busy === s.id} isAdmin={isAdmin}
onUpload={(f) => upload(s, f)}
onRemove={() => act(s.id, () => api(`/api/clients/${slug}/screenshots/${s.id}${q}`, { method: "DELETE" }))}
onExample={() => act(s.id, () => api(`/api/clients/${slug}/screenshots/${s.id}/example${q}`, { method: "POST" }))}
onView={setView} />
))}
</div>
<Dialog open={!!view} onClose={() => setView(null)} title={view?.title} wide>
{view && <img src={view.src} alt={view.title} className="mx-auto max-h-[70vh] w-auto rounded-lg border border-line" />}
</Dialog>
</div>
);
}
function ShotCard({ shot, slug, q, busy, isAdmin, onUpload, onRemove, onExample, onView }) {
const input = useRef(null);
const [drag, setDrag] = useState(false);
const title = `${shot.platform} · ${shot.label}`;
const stamp = shot.file?.at ? `&v=${encodeURIComponent(shot.file.at)}` : "";
const src = `/api/clients/${slug}/screenshots/${shot.id}/image${q}${stamp}`;
const exampleSrc = `/api/screenshot-examples/${shot.id}`;
const pick = (files) => files?.[0] && onUpload(files[0]);
return (
<section className="rounded-xl border border-line bg-white flex flex-col">
<header className="px-4 pt-3.5 pb-2 flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-[11.5px] font-medium text-muted uppercase tracking-wide">{shot.platform}</p>
<h3 className="text-[14px] font-semibold text-ink">{shot.label}</h3>
</div>
{shot.file ? <Badge tone="ok" dot>Uploaded</Badge> : shot.required ? <Badge tone="warn">Required</Badge> : <Badge>Optional</Badge>}
</header>
<p className="px-4 text-[12.5px] text-ink-2 leading-relaxed">{shot.how}</p>
<div className="p-4 mt-auto">
{shot.file ? (
<button type="button" onClick={() => onView({ title, src })} className="block w-full h-56 rounded-lg border border-line bg-canvas overflow-hidden">
<img src={src} alt={`${title} screenshot`} className="h-full w-full object-contain" loading="lazy" />
</button>
) : (
<div
onDragOver={(e) => { e.preventDefault(); setDrag(true); }}
onDragLeave={() => setDrag(false)}
onDrop={(e) => { e.preventDefault(); setDrag(false); pick(e.dataTransfer.files); }}
className={`h-56 rounded-lg border border-dashed flex flex-col items-center justify-center gap-2 text-center px-3 ${drag ? "border-brand bg-brand/5" : "border-line bg-canvas"}`}
>
{busy ? <Spinner label="Uploading" /> : (
<>
<ImageUp className="size-6 text-faint" aria-hidden="true" />
<p className="text-[12.5px] text-muted">Drop the screenshot here</p>
<Button size="sm" onClick={() => input.current?.click()}>Choose file</Button>
</>
)}
</div>
)}
<input ref={input} type="file" accept="image/png,image/jpeg,image/webp" className="sr-only" aria-label={`Upload ${title}`} onChange={(e) => { pick(e.target.files); e.target.value = ""; }} />
<div className="mt-2.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-[12px] text-muted">
{shot.file && <span>{shot.file.by ? `${shot.file.by}, ` : ""}{timeAgo(shot.file.at)}</span>}
{shot.example && <button type="button" className="underline hover:text-ink" onClick={() => onView({ title: `Example: ${title}`, src: exampleSrc })}>See example</button>}
{shot.file && (
<span className="ml-auto flex items-center gap-1">
<Button size="sm" loading={busy} onClick={() => input.current?.click()}>Replace</Button>
{isAdmin && <Button size="sm" icon={Star} title="Show this shot as the example for every client" onClick={onExample}>Use as example</Button>}
<button type="button" onClick={onRemove} className="p-1.5 rounded hover:bg-bad-soft text-muted hover:text-bad" aria-label={`Remove ${title}`}><Trash2 className="size-4" /></button>
</span>
)}
</div>
</div>
</section>
);
}

View File

@ -212,181 +212,6 @@ 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 the public address under Server settings. 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 can set it up under Settings → Server settings → Email.</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>
);
}
const SOURCE = { site: ["Saved here", "brand"], env: ["From .env", "neutral"] };
/** Everything that used to live only in the server's .env. Super admins on the control centre also see billing. */
function ServerSettingsPanel() {
const [d, setD] = useState(null);
const [edits, setEdits] = useState({});
const [busy, setBusy] = useState(null);
const [msg, setMsg] = useState(null);
const [restart, setRestart] = useState([]);
useEffect(() => { api("/api/settings/server").then(setD).catch(setMsg); }, []);
if (!d) return msg ? <ErrorNote error={msg} /> : null;
const val = (f) => (f.key in edits ? edits[f.key] : f.type === "bool" ? String(f.value ? f.value === "true" : f.default) : f.value);
const edit = (k, v) => setEdits({ ...edits, [k]: v });
const send = async (body, what) => {
setBusy(what); setMsg(null);
try {
const r = await api("/api/settings/server", { method: "PUT", body });
setD(r); setEdits({});
if (r.restart.length) setRestart((x) => [...new Set([...x, ...r.restart])]);
setMsg(r.changed.length ? `Saved ${r.changed.length} setting${r.changed.length > 1 ? "s" : ""}.` : "Nothing changed.");
} catch (e) { setMsg(e); }
setBusy(null);
};
const restartNow = async () => {
setBusy("restart"); setMsg(null);
try { await api("/api/settings/restart", { method: "POST" }); setMsg("Restarting… reload this page in about 20 seconds."); setRestart([]); } catch (e) { setMsg(e); }
setBusy(null);
};
const dirty = Object.keys(edits).length > 0;
return (
<Panel title="Server settings" subtitle={d.hosted ? "Settings for your workspace. Email and platform keys are provided by Quorla." : "Everything that used to be set in the server's .env. Saved values override .env and are stored encrypted."} bodyClass="px-5 py-4 space-y-6">
{d.groups.map((g) => (
<section key={g.name} className="space-y-3">
<h3 className="text-[13px] font-semibold uppercase tracking-wide text-muted">{g.name}</h3>
<div className="grid sm:grid-cols-2 gap-3">
{g.fields.map((f) => {
const src = SOURCE[f.source];
const label = (
<span className="flex items-center gap-2 flex-wrap">
{f.label}
{src && <Badge tone={src[1]}>{src[0]}</Badge>}
{f.restart && <span className="text-[11.5px] text-muted font-normal">applies after restart</span>}
{f.source === "site" && <button type="button" className="text-[11.5px] underline text-muted font-normal" disabled={!!busy} onClick={() => send({ clear: [f.key] }, f.key)}>use .env</button>}
</span>
);
const id = `ss-${f.key}`;
if (f.type === "bool") return (
<label key={f.key} htmlFor={id} className="flex items-start gap-2.5 text-[13.5px] sm:col-span-2">
<input id={id} type="checkbox" className="mt-1" checked={val(f) === "true"} onChange={(e) => edit(f.key, String(e.target.checked))} />
<span><b className="font-medium">{label}</b>{f.hint && <span className="block text-muted text-[12.5px]">{f.hint}</span>}</span>
</label>
);
return (
<Field key={f.key} label={label} hint={f.secret ? (f.set ? "Set. Leave empty to keep it." : "Not set. Stored encrypted, never shown again.") + (f.hint ? ` ${f.hint}` : "") : f.hint}>
<input id={id} className="input" type={f.secret ? "password" : f.type === "number" ? "number" : "text"} autoComplete={f.secret ? "new-password" : "off"}
value={val(f)} placeholder={f.secret && f.set ? "••••••••" : f.placeholder} onChange={(e) => edit(f.key, e.target.value)} />
</Field>
);
})}
</div>
</section>
))}
{restart.length > 0 && (
<div className="text-[13px] rounded-md bg-warn-soft text-warn px-3 py-2 flex items-center gap-3 flex-wrap">
<span>{restart.join(", ")} take effect after the dashboard restarts.</span>
{d.canRestart ? <Button size="sm" loading={busy === "restart"} onClick={restartNow}>Restart now</Button> : <span>Restart it on the server.</span>}
</div>
)}
<div className="flex items-center gap-3 flex-wrap">
<Button variant="primary" disabled={!dirty} loading={busy === "save"} onClick={() => send({ values: edits }, "save")}>Save server settings</Button>
{dirty && <Button variant="ghost" onClick={() => setEdits({})}>Discard changes</Button>}
{msg && (typeof msg === "string" ? <span className="text-[12.5px] text-ok">{msg}</span> : <ErrorNote error={msg} />)}
</div>
<p className="text-[12px] text-muted">The secret that encrypts these, the port, and the setup and provisioning tokens stay in .env.</p>
</Panel>
);
}
export default function Settings() { export default function Settings() {
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const [s, setS] = useState(null); const [s, setS] = useState(null);
@ -399,14 +224,10 @@ export default function Settings() {
<h1 className="text-[22px] font-extrabold">Settings</h1> <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> <p className="text-muted mt-0.5">Your account{isAdmin ? ", the team's logins" : ""} and this dashboard's connections.</p>
</header> </header>
{isAdmin && <WorkspacePanel />}
{isAdmin && <MetaPanel />}
<AccountPanel /> <AccountPanel />
<NotificationsPanel />
{isAdmin && <TeamPanel />} {isAdmin && <TeamPanel />}
{isAdmin && <ServerSettingsPanel />}
<ErrorNote error={error} /> <ErrorNote error={error} />
<Panel title="Connections" subtitle="Current status. Change these under Server settings, Meta and Connections." bodyClass="divide-y divide-line"> <Panel title="Connections" subtitle="Changed in the server's .env for now." bodyClass="divide-y divide-line">
{!s ? <Spinner /> : ( {!s ? <Spinner /> : (
<> <>
<Row ok={s.meta.connected} title="Meta (Facebook & Instagram)"> <Row ok={s.meta.connected} title="Meta (Facebook & Instagram)">
@ -414,11 +235,11 @@ export default function Settings() {
</Row> </Row>
<Row ok={s.odoo.connected} warn={!s.odoo.configured} title="Odoo · team tasks"> <Row ok={s.odoo.connected} warn={!s.odoo.configured} title="Odoo · team tasks">
{s.odoo.connected {s.odoo.connected
? `Connected to ${s.odoo.url} (Odoo ${s.odoo.version?.split("-")[0]}). Task creation is ${s.odoo.allowWrites ? "on" : "off (turn it on in Connections → Odoo)"}.` ? `Connected to ${s.odoo.url} (Odoo ${s.odoo.version?.split("-")[0]}). Task creation is ${s.odoo.allowWrites ? "on" : "off (ODOO_ALLOW_WRITES)"}.`
: s.odoo.configured ? `Can't connect: ${s.odoo.error}` : "Not connected. Connect it in Connections → Odoo."} : s.odoo.configured ? `Can't connect: ${s.odoo.error}` : "Not configured. Add ODOO_URL, ODOO_DATABASE, ODOO_USERNAME and ODOO_API_KEY."}
</Row> </Row>
<Row ok warn={!s.ai.apiKey} title="Claude · AI runs"> <Row ok warn={!s.ai.apiKey} title="Claude · AI runs">
Model {s.ai.model}. {s.ai.server ? `Using the model server at ${s.ai.server}.` : s.ai.apiKey ? "Using the ANTHROPIC_API_KEY on this server." : "No ANTHROPIC_API_KEY set: runs use this machine's Claude Code login. A hosted server needs an API key."} Model {s.ai.model}. {s.ai.apiKey ? "Using the ANTHROPIC_API_KEY on this server." : "No ANTHROPIC_API_KEY set: runs use this machine's Claude Code login. A hosted server needs an API key."}
</Row> </Row>
<Row ok={s.publishing} warn={!s.publishing} title="Publishing"> <Row ok={s.publishing} warn={!s.publishing} title="Publishing">
{s.publishing {s.publishing

View File

@ -35,7 +35,7 @@ export default function Team() {
<h1 className="text-[22px] font-extrabold">Team</h1> <h1 className="text-[22px] font-extrabold">Team</h1>
<Panel> <Panel>
<Empty icon={Users2} title={data.status.configured ? "Can't reach Odoo" : "Connect Odoo to see the team"}> <Empty icon={Users2} title={data.status.configured ? "Can't reach Odoo" : "Connect Odoo to see the team"}>
{data.status.error || "An admin can connect it in Connections → Odoo (address, database, username and API key)."} {data.status.error || "Add ODOO_URL, ODOO_DATABASE, ODOO_USERNAME and ODOO_API_KEY to the project .env, then restart the dashboard."}
</Empty> </Empty>
</Panel> </Panel>
</div> </div>

View File

@ -1,17 +1,14 @@
@import "tailwindcss"; @import "tailwindcss";
/* Quorla app tokens (navy #242142, coral #FC6D45). Agency PDF reports keep their own styles in branding/report.css. */ /* Metatroncube design tokens — keep in sync with branding/report.css */
@theme { @theme {
--font-sans: "Inter", "Segoe UI", system-ui, sans-serif; --font-sans: "Inter", "Segoe UI", system-ui, sans-serif;
--font-display: "Plus Jakarta Sans", "Segoe UI", system-ui, sans-serif; --font-display: "Plus Jakarta Sans", "Segoe UI", system-ui, sans-serif;
--color-brand: #3b3778; --color-brand: #3a7bbf;
--color-brand-deep: #242142; --color-brand-deep: #2c5f96;
--color-brand-soft: #efeef7; --color-brand-soft: #eaf2fa;
--color-sky: #fc6d45; --color-sky: #52ace0;
--color-accent: #fc6d45;
--color-accent-deep: #c2461a;
--color-accent-soft: #fff0ea;
--color-ink: #1f2024; --color-ink: #1f2024;
--color-ink-2: #45474f; --color-ink-2: #45474f;
--color-muted: #6b6e78; --color-muted: #6b6e78;
@ -31,7 +28,7 @@
html { -webkit-font-smoothing: antialiased; } html { -webkit-font-smoothing: antialiased; }
body { @apply bg-canvas text-ink font-sans text-[13.5px] leading-[1.5]; } body { @apply bg-canvas text-ink font-sans text-[13.5px] leading-[1.5]; }
h1, h2, h3 { @apply font-display text-ink; letter-spacing: -0.01em; } h1, h2, h3 { @apply font-display text-ink; letter-spacing: -0.01em; }
::selection { background: #ffd9cb; } ::selection { background: #cfe2f5; }
:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; border-radius: 4px; } :focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; border-radius: 4px; }
} }

View File

@ -196,7 +196,7 @@ export function Spark({ values, width = 96, height = 26 }) {
<svg width={width} height={height} aria-hidden="true" className="block"> <svg width={width} height={height} aria-hidden="true" className="block">
{values.map((v, i) => { {values.map((v, i) => {
const h = Math.max(2, (v / max) * (height - 2)); const h = Math.max(2, (v / max) * (height - 2));
return <rect key={i} x={i * w + 1} y={height - h} width={Math.max(2, w - 2)} height={h} rx={1} fill={i === values.length - 1 ? "#fc6d45" : "#c9c7e0"} />; return <rect key={i} x={i * w + 1} y={height - h} width={Math.max(2, w - 2)} height={h} rx={1} fill={i === values.length - 1 ? "#3a7bbf" : "#b9d2ea"} />;
})} })}
</svg> </svg>
); );

View File

@ -1,109 +0,0 @@
#!/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 -e QUORLA_HOSTED_TENANT=true \
-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
}
fix_owner() { # dir: the app runs as "node" (uid 1000) inside the image; hand it the folders it writes (no sudo needed)
[ "$(stat -c %u "$1/data")" = "1000" ] && return 0
docker run --rm --user 0 --entrypoint chown -v "$1:/t" "$IMAGE" -R 1000:1000 /t/data /t/clients /t/team /t/reports /t/branding
}
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"
fix_owner "$dir"
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

View File

@ -1,9 +0,0 @@
# 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

View File

@ -19,7 +19,6 @@ services:
- ./team:/app/team - ./team:/app/team
- ./reports:/app/reports - ./reports:/app/reports
- ./branding:/app/branding - ./branding:/app/branding
- ./backups:/app/backups:ro
expose: expose:
- "4310" - "4310"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

View File

@ -1,66 +0,0 @@
# Demo videos for the Google and TikTok reviews
Two short screen recordings (about 2–3 minutes each) of the live app, app.quorlasocial.com.
## Before you start (once)
- **Recorder:** Windows **Snipping Tool** → Record (or Xbox Game Bar: Win + Alt + R). Record the **whole Chrome
window, including the address bar**: Google checks the `client_id` in the URL on its sign-in screen.
- Chrome at full screen width, zoom 100%, close other tabs, hide bookmarks bar (Ctrl+Shift+B).
- No voice needed. Move the mouse slowly and pause ~2 seconds on each important screen.
- Test files are in `docs/demo-assets/`: `quorla-test-9x16.mp4` (8 s vertical) and `gbp-test-1200x900.jpg`.
- Sign in to app.quorlasocial.com first, so the recording starts inside the app.
- Use the client **MetatronCube Software Solutions Canada** throughout.
---
## Video 1: Google (YouTube). Upload to YouTube as **Unlisted**
1. **Show the homepage.** Go to https://quorlasocial.com, scroll briefly, click **Privacy Policy** in the footer
(pause 2 s), go back.
2. **Show who's signed in.** Go to https://app.quorlasocial.com → **Connections**, pick
*MetatronCube Software Solutions Canada*.
3. **YouTube sign-in.** On the YouTube card click **Disconnect**, then **Connect**.
- Google account chooser → pick the channel's account.
- If Google shows "Google hasn't verified this app": pause 2 s on it (reviewers expect to see it), click
**Advanced → Go to quorlasocial.com (unsafe)**.
- **Consent screen:** pause 3 s so "Quorla" and both YouTube permissions are readable. Hover over the address bar
for 2 s (shows the client_id). Click **Continue / Allow**.
- Back in Quorla: the YouTube card shows the channel name, avatar and subscriber count. Pause 2 s.
4. **Upload a video.** Publishing → **New post** → client *MetatronCube…* → type **Reel/Video** → upload
`quorla-test-9x16.mp4` → caption "Publishing workflow test" → tick **YouTube** only → title
"Quorla demo upload", visibility **Private**, made for kids **No** → date/time 2 minutes from now →
**Save as draft**.
5. **Approve.** On the draft click **Approve**. Wait until the status turns **Published** (refresh after ~1 min),
click **View on youtube**, show the video page on YouTube (pause 3 s).
6. **Disconnect.** Connections → YouTube → **Disconnect** (shows tokens can be removed), then **Connect** again so
YouTube keeps working. End recording.
Business Profile (GMB Audit project) needs no video: its only Google scope, business.manage, is non-sensitive.
Then: upload the file to YouTube **as Unlisted** (any channel), copy the link, send it to Claude.
---
## Video 2: TikTok Direct Post. Upload the MP4 file in TikTok's review form
Needs a **personal TikTok account set to Private** (Business accounts can't be private, and unreviewed apps can only
Direct Post to private accounts). Claude adds it as a Sandbox target user first.
1. Connections → TikTok → **Disconnect** (if the business account is connected) → **Connect** → TikTok login with the
private test account → TikTok's consent screen (pause 3 s) → Authorize → back in Quorla, TikTok card shows the
account. Pause 2 s.
2. Publishing → New post → **Reel/Video** → upload `quorla-test-9x16.mp4` → caption → tick **TikTok** only.
3. In the TikTok panel, slowly show (pause on each):
- "Posting to" with the account **name and avatar**.
- **How to post: Post directly.**
- **Who can view this post**: open the dropdown to show there's no preselected option, choose **Only me**.
- **Allow comments / Duet / Stitch** checkboxes.
- Tick **Disclose commercial content**, show the "Your brand" / "Branded content" options and the label text,
then untick it again.
- The **Music Usage Confirmation** sentence at the bottom.
4. Time 2 min ahead → **Save as draft** → **Approve** → wait for **Published** → click **View on tiktok** (or open
the TikTok app/profile) and show the post. End recording.
5. Afterwards reconnect TikTok with the business account (Connections → TikTok → Disconnect → Connect).
Save as MP4 (Snipping Tool saves MP4). TikTok accepts MP4/MOV; keep it under 50 MB (trim if needed).

View File

@ -34,69 +34,3 @@ Two Google Cloud projects, both owned by dev@metatroncubesolutions.com:
3. After deploy: **publish the app** (Audience → Publish app) and submit **verification** (sensitive YouTube scopes): 3. After deploy: **publish the app** (Audience → Publish app) and submit **verification** (sensitive YouTube scopes):
Google asks for a demo video of the sign-in and how each scope is used. Google asks for a demo video of the sign-in and how each scope is used.
4. **YouTube API audit** (free form) to lift the 10,000 units/day quota and the private-only upload limit. 4. **YouTube API audit** (free form) to lift the 10,000 units/day quota and the private-only upload limit.
## Verification submission (paste-ready)
**App purpose (both projects):**
> Quorla (https://quorlasocial.com) is a social media management tool for marketing agencies. An agency admin connects
> a client's Google account on Quorla's Connections page. The team prepares posts in Quorla; an admin reviews and
> approves the exact post, and Quorla publishes it at the scheduled time. Nothing is posted without that approval.
> Tokens are encrypted at rest and deleted when the account is disconnected.
**Quorla project, scope justifications:**
- `youtube.upload`: Upload the video an agency admin approved in Quorla to the connected channel, with the title,
description, visibility and "made for kids" setting chosen on the post screen. Only approved posts are uploaded.
- `youtube.readonly`: Show which channel is connected (name, handle, avatar, subscriber count) on the Connections
page, so the agency posts to the right channel, and link to the uploaded video afterwards.
**Quorla project, Data Access → "How will the scopes be used?"** (one box, max 1000 characters; Google only saves it
together with the demo video link):
> Quorla (quorlasocial.com) is a social media management tool for marketing agencies. An agency admin connects a client's YouTube channel on Quorla's Connections page. youtube.upload: the team prepares a video post in Quorla; an admin reviews and approves the exact video, title, description, visibility and made-for-kids setting, and Quorla uploads it to the connected channel at the scheduled time. Nothing is uploaded without that approval. youtube.readonly: show which channel is connected (name, handle, avatar, subscriber count) so the agency posts to the right channel, and link to the uploaded video afterwards. More limited scopes are not sufficient: youtube.upload is the narrowest scope that allows uploading, and youtube.readonly is needed to read the channel identity, which upload alone does not return. Tokens are encrypted at rest and deleted when the channel is disconnected.
Status 24 Oct 2026: Google asked for a **new demo video** (first one didn't show the consent screen with both YouTube
scopes expanded, or every feature that uses them). New "How will the scopes be used?" text (replaces the one above):
> Quorla (quorlasocial.com) is a social media management tool for marketing agencies. An agency admin connects a client's YouTube channel on Quorla's Connections page. youtube.upload: an admin reviews and approves the exact video, title, description, visibility and made-for-kids setting, and Quorla uploads it to the connected channel (videos.insert). Nothing is uploaded without that approval. youtube.readonly is used for two read-only features: (1) Connections page: channels.list (mine=true) shows the connected channel's name, handle, avatar and subscriber count, so the agency posts to the right channel; (2) Published page: videos.list shows the views, likes and comment counts of the videos Quorla uploaded, so the agency can report results to the client. youtube.upload cannot read any of this and there is no narrower read scope. We do not request the broader youtube or youtube.force-ssl scopes. Tokens are encrypted at rest and deleted when the channel is disconnected.
v2 video recorded 25 Oct 2026: https://youtu.be/CHHreGKIpXE (unlisted). **Submitted 25 Oct 2026** (Data Access updated with
the text below and the new link; replied to Google's email from dev@). Waiting for Google's answer.
**Re-recorded demo video (v2)**, in this order, address bar visible, English:
1. Before recording: in Quorla disconnect YouTube; at https://myaccount.google.com/connections remove Quorla's access
for the test Google account (so the full consent screen shows). YouTube comments in the Inbox use an API key
(`YOUTUBE_API_KEY`), not these scopes, so they are not part of this video.
2. quorlasocial.com → Privacy policy → app.quorlasocial.com sign in.
3. Connections → client → YouTube → Connect → account chooser. **Pause on the consent screen**: zoom the address bar
(client_id and `scope=` with youtube.upload + youtube.readonly), click each scope's arrow / "Show all services" so
both descriptions are fully readable, tick both, Continue.
4. Back in Quorla: channel name, avatar, subscribers shown → say "youtube.readonly: channels.list".
5. Publishing → new post with a vertical video → tick YouTube → title, visibility, made for kids → save draft → Approve →
published → open on YouTube → say "youtube.upload: videos.insert".
6. Published page → refresh stats → the YouTube video's views / likes / comments → "youtube.readonly: videos.list".
7. Connections → Disconnect YouTube.
Status 22 Oct 2026 (evening): **Quorla project submitted for verification** (youtube.upload, youtube.readonly) with demo
video https://youtu.be/lB8FMGDbBOo (unlisted); Data access status "under review". Google replies by email to dev@.
Status 22 Oct 2026: GMB Audit (Business Profile) is **done**: In production, branding verified and published, and
"Verification is not required" for data access (business.manage is non-sensitive). Home page https://quorlasocial.com.
Any Google account can now connect Business Profile; reconnect connections made while it was in Testing once, so
their sign-in no longer expires after 7 days.
Status 21 Oct 2026: Quorla project published (In production), branding verified and published (home page changed to
https://quorlasocial.com). Data access verification waits for the demo video. business.manage counts as non-sensitive
on this project, so only the two YouTube scopes need justifying.
**GMB Audit project, scope justification:**
- `business.manage`: List the business locations the connected account manages so the admin can pick the client's
location, and publish approved "updates" (local posts with text, photo and an optional button) to that location.
**Demo video** (one recording, uploaded to YouTube as Unlisted; link it in both forms). Use English and show the
browser's address bar so the project's OAuth client ID is visible in the consent URL:
1. quorlasocial.com homepage → privacy policy link → Sign in at app.quorlasocial.com.
2. Connections → pick a client → YouTube → Connect → Google account chooser → consent screen showing **Quorla** and
the YouTube scopes → Allow → back in Quorla, channel shown as connected.
3. Publishing → New post → upload a vertical video → caption → tick YouTube → title, visibility → Save as draft →
Approve → post publishes → open the video on YouTube.
4. Connections → Google Business Profile → Connect → consent screen (business.manage) → choose the location.
5. New post with a photo → tick Google Business Profile → button "Learn more" → Approve → open the update on Google.
6. Connections → Disconnect, to show tokens can be removed.

View File

@ -1,99 +0,0 @@
# 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.

View File

@ -55,20 +55,3 @@ so these values are kept here until the demo video exists.
Use the Sandbox's own client key/secret in Quorla while testing. Add your own TikTok account (and a test client Use the Sandbox's own client key/secret in Quorla while testing. Add your own TikTok account (and a test client
account) as **target users**. Sandbox posts are private ("Only me") and accounts must be set to private. account) as **target users**. Sandbox posts are private ("Only me") and accounts must be set to private.
## What works before the review (tested 21 Oct 2026)
- **Direct Post** fails on public accounts with `unaudited_client_can_only_post_to_private_accounts`. TikTok Business
accounts can't be made private, so Direct Post can't be tested on them until the app is approved.
- **"Send to the TikTok app to finish"** (inbox upload, `video.upload`) works on public Business accounts. Status comes
back `SEND_TO_USER_INBOX`; the account owner gets a notification in TikTok, chooses the audience and taps Post.
Use this mode for clients until the review is approved.
## Review status
- **22 Oct 2026:** Production version submitted for review ("In review"). Config imported from the Sandbox (Login Kit,
Content Posting API with Direct Post, 5 scopes, redirect URI), icon `branding/quorla-icon-1024.png`, demo video
recorded on app.quorlasocial.com with the private Sandbox test account @gogreenpacks (YouTube copy, unlisted:
https://youtu.be/No3MvZO6jZg). The video shows Direct Post only; video.upload (inbox mode) is explained in the text.
- **After approval:** in Quorla → Connections → TikTok → app settings, switch to the **Production** client key/secret,
then reconnect each client's TikTok account. Direct Post then works on public accounts.

View File

@ -1,190 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Automatic agency subdomains</title>
<style>
:root { --navy:#242142; --coral:#FC6D45; --ink:#1f2024; --muted:#6b6e78; --line:#e4e7ec; --bg:#f5f6f8; --card:#fff; --code:#14132a; --ok:#1d7a46; --warn:#9a5b00; --warnbg:#fff6e5; }
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --ink:#e8e8ee; --muted:#a3a5b0; --line:#2c2b45; --bg:#12111f; --card:#1b1a2e; --code:#0b0a16; --warnbg:#2a2210; --warn:#f0b65a; --ok:#5fcf8f; } }
* { box-sizing: border-box; }
body { margin:0; background:var(--bg); color:var(--ink); font:15px/1.6 Inter, "Segoe UI", Arial, sans-serif; }
main { max-width: 860px; margin: 0 auto; padding: 32px 16px 64px; }
h1 { font-size: 26px; margin: 0 0 6px; color: var(--ink); }
h2 { font-size: 18px; margin: 0 0 6px; display:flex; gap:10px; align-items:center; }
.num { flex:none; width:28px; height:28px; border-radius:50%; background:var(--navy); color:#fff; display:grid; place-items:center; font-size:14px; }
.lede { color: var(--muted); margin: 0 0 24px; }
section { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 18px 20px; margin: 0 0 16px; }
p { margin: 8px 0; }
.why { color: var(--muted); font-size: 14px; }
pre { position: relative; background: var(--code); color: #e9e8ff; border-radius: 8px; padding: 14px 16px; overflow-x: auto; font: 13px/1.55 ui-monospace, Consolas, monospace; margin: 10px 0; }
pre .c { color: #8f8cb8; }
button.copy { position:absolute; top:8px; right:8px; background:#2f2c55; color:#fff; border:0; border-radius:6px; padding:4px 10px; font-size:12px; cursor:pointer; }
button.copy:hover { background: var(--coral); }
.check { border-left: 3px solid var(--ok); padding: 4px 0 4px 12px; margin: 10px 0; font-size: 14px; }
.warn { background: var(--warnbg); color: var(--warn); border-radius: 8px; padding: 10px 14px; font-size: 14px; margin: 10px 0; }
code { font: 13px ui-monospace, Consolas, monospace; background: rgba(127,127,160,.15); padding: 1px 5px; border-radius: 4px; }
ul { margin: 6px 0; padding-left: 20px; }
.meta { display:flex; flex-wrap:wrap; gap:8px 20px; font-size:13px; color:var(--muted); margin-bottom:20px; }
label.done { display:flex; gap:8px; align-items:center; font-size:14px; color:var(--muted); margin-top:10px; cursor:pointer; }
</style>
</head>
<body>
<main>
<h1>Automatic agency subdomains</h1>
<p class="lede">Do this once. After it, every approved agency gets <code>name.quorlasocial.com</code> with HTTPS automatically, without anyone touching Hestia.</p>
<div class="meta">
<span>Server: 72.62.169.43 (HestiaCP user <code>user</code>)</span>
<span>Run as: <code>dev</code>, with sudo where shown</span>
<span>Time: about 30 minutes</span>
</div>
<section>
<p><b>What this changes.</b> Today a new agency's workspace (container, data, port and welcome email) is created by the provisioning script, but its web address needs three manual steps in Hestia: DNS, SSL and nginx. This setup replaces those with:</p>
<ul>
<li>one wildcard DNS record</li>
<li>one wildcard certificate</li>
<li>one nginx rule that reads <code>nginx-tenants.map</code> (the script already keeps that file up to date)</li>
</ul>
<p>Nothing changes for <b>app.quorlasocial.com</b> or <b>clickstocart.quorlasocial.com</b>: nginx always prefers an exact server name over the wildcard.</p>
<div class="warn">If any command's output looks different from what this page expects, stop and paste the output to Claude before going on.</div>
</section>
<section>
<h2><span class="num">0</span>Check and back up</h2>
<pre><button class="copy">Copy</button>sudo nginx -t
nginx -v
sudo /usr/local/hestia/bin/v-list-web-domain user quorlasocial.com
sudo /usr/local/hestia/bin/v-list-dns-records user quorlasocial.com | grep -E '^ID|\*'
which nginx systemctl
sudo tar -czf ~/nginx-backup-$(date +%F).tar.gz /etc/nginx</pre>
<div class="check">
<b>Expect:</b>
<ul>
<li><code>nginx -t</code> says the syntax is ok.</li>
<li>The web-domain line tells you whether <code>quorlasocial.com</code> is a web domain on Hestia. Note the answer, because it decides step 2.</li>
<li>The DNS line tells you whether a <code>*</code> record already exists.</li>
<li>Note the two paths from <code>which</code>, usually <code>/usr/sbin/nginx</code> and <code>/usr/bin/systemctl</code>.</li>
</ul>
</div>
<label class="done"><input type="checkbox"> Done</label>
</section>
<section>
<h2><span class="num">1</span>Wildcard DNS</h2>
<p class="why">Every <code>anything.quorlasocial.com</code> then points at this server. Skip this step if step 0 already showed a <code>*</code> record.</p>
<pre><button class="copy">Copy</button>sudo /usr/local/hestia/bin/v-add-dns-record user quorlasocial.com '*' A 72.62.169.43
dig +short test123.quorlasocial.com @8.8.8.8</pre>
<div class="check"><b>Expect:</b> <code>72.62.169.43</code>. It can take a few minutes to appear.</div>
<label class="done"><input type="checkbox"> Done</label>
</section>
<section>
<h2><span class="num">2</span>Wildcard SSL certificate</h2>
<p class="why">Hestia can issue a <code>*.quorlasocial.com</code> certificate because the DNS zone is on this server, and it renews the certificate by itself.</p>
<p><b>Only if step 0 showed that quorlasocial.com is <u>not</u> a web domain yet</b>, add it first. Do this only if the bare domain isn't hosted somewhere else (for example a marketing site); if it is, ask Claude first.</p>
<pre><button class="copy">Copy</button>sudo /usr/local/hestia/bin/v-add-web-domain user quorlasocial.com</pre>
<p>Then add the wildcard alias and issue the certificate:</p>
<pre><button class="copy">Copy</button>sudo /usr/local/hestia/bin/v-add-web-domain-alias user quorlasocial.com '*.quorlasocial.com'
sudo /usr/local/hestia/bin/v-add-letsencrypt-domain user quorlasocial.com '*.quorlasocial.com'
sudo openssl x509 -in /home/user/conf/web/quorlasocial.com/ssl/quorlasocial.com.pem -noout -subject -ext subjectAltName -enddate</pre>
<div class="check"><b>Expect:</b> the certificate lists <code>DNS:*.quorlasocial.com</code> and an end date about 90 days away.</div>
<label class="done"><input type="checkbox"> Done</label>
</section>
<section>
<h2><span class="num">3</span>One nginx rule for every agency</h2>
<p class="why">This file lives outside Hestia's per-domain folders, so Hestia rebuilds don't overwrite it. It looks up the agency's port by the subdomain. Addresses that aren't in the map get a 404.</p>
<pre><button class="copy">Copy</button>touch /home/dev/APPS/quorla-tenants/nginx-tenants.map
sudo tee /etc/nginx/conf.d/quorla-tenants.conf &gt;/dev/null &lt;&lt;'EOF'
map $host $quorla_tenant {
default "";
include /home/dev/APPS/quorla-tenants/nginx-tenants.map;
}
server {
listen 72.62.169.43:80;
server_name *.quorlasocial.com;
return 301 https://$host$request_uri;
}
server {
listen 72.62.169.43:443 ssl;
server_name *.quorlasocial.com;
ssl_certificate /home/user/conf/web/quorlasocial.com/ssl/quorlasocial.com.pem;
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_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
EOF
sudo nginx -t &amp;&amp; sudo systemctl reload nginx</pre>
<div class="check">
<b>Expect:</b> <code>syntax is ok</code>. If nginx warns that <code>*.quorlasocial.com</code> is a "conflicting server name", the step 2 alias made Hestia's quorlasocial.com config claim the wildcard too. Paste the warning to Claude; the fix is a one-line change.
</div>
<label class="done"><input type="checkbox"> Done</label>
</section>
<section>
<h2><span class="num">4</span>Let the script reload nginx</h2>
<p class="why">The script runs as <code>dev</code>. It needs exactly two root commands and nothing else. Use the paths from step 0 if they differ.</p>
<pre><button class="copy">Copy</button>echo 'dev ALL=(root) NOPASSWD: /usr/sbin/nginx -t, /usr/bin/systemctl reload nginx' | sudo tee /etc/sudoers.d/quorla
sudo chmod 440 /etc/sudoers.d/quorla
sudo visudo -c
sudo -n nginx -t &amp;&amp; echo "sudo rule works"</pre>
<div class="check"><b>Expect:</b> <code>parsed OK</code>, then <code>sudo rule works</code>, without being asked for a password.</div>
<label class="done"><input type="checkbox"> Done</label>
</section>
<section>
<h2><span class="num">5</span>Run the script every 5 minutes</h2>
<p class="why">It picks up approved agencies, creates their workspaces, moves agencies onto new versions after a deploy, and refreshes the nginx map.</p>
<pre><button class="copy">Copy</button>bash /home/dev/APPS/quorlasocial/deploy/provision-tenants.sh
cat /home/dev/APPS/quorla-tenants/nginx-tenants.map
( crontab -l 2&gt;/dev/null | grep -v provision-tenants.sh; echo '*/5 * * * * /home/dev/APPS/quorlasocial/deploy/provision-tenants.sh &gt;&gt; /home/dev/APPS/quorla-tenants/provision.log 2&gt;&amp;1' ) | crontab -
crontab -l</pre>
<div class="check"><b>Expect:</b> the map contains <code>clickstocart.quorlasocial.com 127.0.0.1:4400;</code>, the script logs <code>nginx reloaded</code> the first time, and the cron line is listed.</div>
<label class="done"><input type="checkbox"> Done</label>
</section>
<section>
<h2><span class="num">6</span>Test</h2>
<pre><button class="copy">Copy</button>curl -sI https://app.quorlasocial.com | head -1
curl -sI https://clickstocart.quorlasocial.com | head -1
curl -sI https://nobody-here.quorlasocial.com | head -1</pre>
<div class="check"><b>Expect:</b> the first two return 200 (or a redirect), and the last returns 404 with a valid certificate (no SSL error).</div>
<p>Then do a real end-to-end test:</p>
<ul>
<li>Sign up a test agency at <code>https://app.quorlasocial.com/signup</code>.</li>
<li>Approve it in Admin → Agencies.</li>
<li>Wait up to 5 minutes, then open <code>https://&lt;test&gt;.quorlasocial.com</code>. The welcome email's link should work.</li>
<li>Remove the test agency in Agencies afterwards. Its data is archived.</li>
</ul>
<label class="done"><input type="checkbox"> Done</label>
</section>
<section>
<h2>Undo</h2>
<pre><button class="copy">Copy</button>sudo rm /etc/nginx/conf.d/quorla-tenants.conf
sudo nginx -t &amp;&amp; sudo systemctl reload nginx
crontab -l | grep -v provision-tenants.sh | crontab -</pre>
<p class="why">The DNS record and the certificate are harmless to leave in place. Existing workspaces keep running either way.</p>
</section>
</main>
<script>
document.querySelectorAll("button.copy").forEach((b) => b.addEventListener("click", async () => {
const text = b.parentElement.innerText.replace(/^Copy\n?/, "");
try { await navigator.clipboard.writeText(text); b.textContent = "Copied"; } catch { b.textContent = "Select & copy"; }
setTimeout(() => (b.textContent = "Copy"), 1500);
}));
</script>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More