Compare commits

...

25 Commits

Author SHA1 Message Date
metatroncubeswdev
613727321d LinkedIn: keep company Page names on disk for 30 days
Restarts and reconnects no longer spend LinkedIn's small daily lookup limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 01:20:58 -04:00
metatroncubeswdev
db56b69ce6 LinkedIn: Page names from organizationsLookup, cached for a day
The organizations resource hit LinkedIn's app-wide daily cap; organizationsLookup
has its own. Falls back to organizations, then ids.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 01:19:40 -04:00
metatroncubeswdev
41da5b3968 LinkedIn: look up company Page names in one batch call
Single-Page lookups hit LinkedIn's daily per-member limit for admins of many Pages,
so the Page list fell back to ids.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 22:15:00 -04:00
metatroncubeswdev
1307146921 LinkedIn: default to the scopes Community Management API grants
The approved app has no OpenID Connect product, so openid/profile/r_organization_admin
would fail. Use r_basicprofile + rw_organization_admin and read the member from /v2/me.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 22:02:47 -04:00
metatroncubeswdev
3aff447011 Competitor analysis tab: description in the panel header, no repeated title
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 01:17:13 -04:00
metatroncubeswdev
ef574be75f Client page: Competitor analysis tab with its reports and a Find competitors button
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 00:26:36 -04:00
metatroncubeswdev
6317d89be0 Runs: tell the AI the dashboard exports PDFs after the run
Stops summaries that tell the team to export PDFs or delete .data.json.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 00:23:12 -04:00
metatroncubeswdev
c8ed9870d0 Runs: check file writes by real path and log every refused tool
Write(clients/**) rules alone let a competitor scan's report write be denied.
Writes inside clients/, team/ and reports/ are now allowed by resolved path;
anything refused is logged with the tool and path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 22:17:43 -04:00
metatroncubeswdev
2e0b28f2a2 Competitor scan always re-runs; report numbers refresh when the report is rewritten
- Skill: a "Find competitors" click always does the full scan and rewrites
  this month's report (the run had skipped because one already existed)
- Frozen <report>.data.json is reused only while the .md is older, so a new
  run's report gets fresh numbers without deleting files by hand

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:49:57 -04:00
metatroncubeswdev
9238b8c77a Competitor and trend reports in Documents, search-trends chart, Open report
- Documents tab lists competitor analyses and trend scans (they were listed
  by the server but hidden in the page)
- Competitors page: "Open report" jumps to the latest competitor report
- New report block mc:search-trends: interest over time per keyword,
  trend %, busiest month and rising searches, from stored Google Trends data

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:44:10 -04:00
metatroncubeswdev
c404ea2ef9 Research: Google Places ratings, Google Trends, Instagram hashtag top posts
- Places API (New): competitor scans find local rivals on Google Maps and the
  server reads real ratings/review counts by place id; client's own listing
  fills the "You" row; recent reviews for praise/complaint themes; 30-day cache
- Google Trends via DataForSEO: rising/top related searches and % change per
  keyword in the client's region; used by trend-scan and to check gap demand
- Instagram Hashtag Search: top/recent posts under a niche hashtag (tool +
  Competitors page panel), counts Instagram's 30 tags/week limit
- Server settings → Research: keys plus on/off switches for each source
  (hashtag search off until Meta approves Public Content Access)
- Reports mark website-sourced ratings as unverified; skill takes handles
  only from a business's own site

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:51:54 -04:00
metatroncubeswdev
f86bd9ce33 Competitor scan: AI finds rivals, Instagram verifies, visual report
- competitor-scan skill + run type: web search for local rivals and
  niche benchmarks, competitor_suggest checks each on Instagram
- Suggestions inbox on Competitors page (Track / Dismiss)
- Report blocks: leaderboard, size-vs-engagement map, format lift,
  market's best posts, hashtag gap, Google ratings
- Stats: engagement by format, top hashtags; audit adds leaderboard

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:44:37 -04:00
metatroncubeswdev
df3dbb63c4 Visual reports and client Screenshots tab
- Reports: mc: blocks (score, KPIs, reach, funnel, followers, formats,
  rhythm, top posts, actions, screenshots, callouts) drawn as SVG from
  stored account data, frozen per report; same page in PDF and preview
- Audit/weekly-report runs refresh account numbers first
- Screenshots tab: monthly shot list, uploads, examples; audit reads them

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 00:48:32 -04:00
metatroncubeswdev
d314a7ad7c Model-server runs: 64k window, summarise at ~23k tokens
Matches the laptop Ollama setup (num_ctx 65536 on the model): requests
stay readable within the agent's ~5-minute wait while big tool results
still fit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:20:30 -04:00
metatroncubeswdev
8170133a79 Runs on our own model server: smaller requests and earlier summarising
Local models (Ollama on a laptop GPU) break on requests over ~19k tokens.
Model-server runs now send only the tools and skill the job needs and
summarise the conversation at ~16k tokens. Publish, schedule, reply and
DM tools are removed from every run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 09:46:29 -04:00
metatroncubeswdev
32563e87f9 AI runs on our own model server (Ollama): address and token in Server settings
With a model server address set, runs send every model slot (opus/sonnet/haiku/subagent)
to the chosen model and never send the Anthropic key there. Tested locally with
qwen3.6:35b-a3b through the Agent SDK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 03:22:27 -04:00
metatroncubeswdev
dff014e69a Runs: next month's calendar run type; let plan runs add to the Calendar
Dashboard runs couldn't call plan_add, so weekly plans never reached the Calendar.
Adds the calendar tools (plan items only, nothing is scheduled) and the read tools
audits use (competitors, demographics, mentions, connections, library, brand kit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:49:03 -04:00
metatroncubeswdev
7c596ec6d5 Settings on the site instead of .env; Odoo in Connections
- Server settings (Settings page): SMTP, AI, publishing, Meta, platform keys and,
  on the control centre only, Stripe/billing. Stored sealed in data/settings.json and
  applied over .env in every process; hosted agency workspaces don't see our email,
  platform keys or billing.
- Odoo connection per workspace in Connections (login checked before saving).
- provision-tenants.sh marks agency containers with QUORLA_HOSTED_TENANT=true.
- docs/wildcard-subdomains.html: one-time setup for automatic agency subdomains.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:49:15 -04:00
metatroncubeswdev
e905236f41 Email: accept SMTP_FROM as "Name <address>"
The whole string was used as the address, so the mail server saw
"Quorla support"@domain and rejected the sender.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:38:42 -04:00
metatroncubeswdev
1e97bbd31f Control centre: don't block the provisioning report on email; fix resend
- The welcome email is sent in the background, so a slow mail server no
  longer makes provision-tenants.sh time out.
- SMTP connection/greeting/socket timeouts, so a bad host fails with a
  reason in the agency log instead of hanging.
- "Resend welcome" never sent anything (report() only emails on the
  change to running); it now sends directly and shows the error if any.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:15:45 -04:00
metatroncubeswdev
86f4f2c089 provision-tenants: give agency data folders to the container's node user
The app runs as uid 1000 inside the image; folders created by the deploy
user (another uid) made the container crash with EACCES on /app/data.
Fixed with a one-off root container, so the script still needs no sudo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:42:59 -04:00
metatroncubeswdev
2b667c655a Quota test: use today's YouTube day instead of a fixed date
The fixed 21 Oct timestamps got pruned as stale once that day passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:29:55 -04:00
metatroncubeswdev
15bc0b9bf2 YouTube comments: read with an API key instead of the channel token
commentThreads.list rejects youtube.readonly ("insufficient authentication
scopes"); it needs youtube.force-ssl, which we don't want to request.
Public comments are now read with YOUTUBE_API_KEY. Also records the
re-submitted Google verification (video v2, new scope justification).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:29:27 -04:00
metatroncubeswdev
ac7d6f3ed8 Quota test: use the current time instead of a fixed date
The rolling-window test broke once the hard-coded date was more than a day old,
because entries older than 24 h are pruned on every write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:13:51 -04:00
metatroncubeswdev
4c78b76586 Merge feature/internal-pages: internal pages, client portal, notifications, reports, hosted Quorla
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:13:23 -04:00
43 changed files with 2507 additions and 128 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)
- 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). - 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.
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 (ask the user for a screenshot if needed). - Profile: bio, link, highlights, consistency of visuals (from the screenshots; if missing, note it rather than asking mid-run).
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,20 +28,25 @@ 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.
- Keep it to roughly one page. Before saving, check it doesn't contradict the internal version. - Short text, mostly visuals: 3–4 PDF pages. Before saving, check it doesn't contradict the internal version.
- **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: - **Front matter:** start each file with it (no `#` H1; the builder draws the title block):
``` ```
--- ---
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>
--- ---
``` ```
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. - **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:
- ```` ```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

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

@ -0,0 +1,42 @@
---
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,7 +14,9 @@ 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.
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. 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. **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

@ -75,3 +75,13 @@ DASHBOARD_NIGHTLY_REFRESH=true
# STRIPE_TRIAL_DAYS=14 # STRIPE_TRIAL_DAYS=14
# In a hosted agency workspace (set by the provisioning script): # In a hosted agency workspace (set by the provisioning script):
# SETUP_TOKEN= # only the emailed setup link can create the first admin # 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=

View File

@ -192,3 +192,194 @@ 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

@ -5,6 +5,7 @@ import fs from "node:fs";
import path from "node:path"; import path from "node:path";
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 places from "./places.js";
const FILE = process.env.QUORLA_COMPETITORS_FILE || path.join(PROJECT_ROOT, "data", "competitors.json"); const FILE = process.env.QUORLA_COMPETITORS_FILE || path.join(PROJECT_ROOT, "data", "competitors.json");
const CLIENTS = path.join(PROJECT_ROOT, "clients"); const CLIENTS = path.join(PROJECT_ROOT, "clients");
@ -24,7 +25,7 @@ function checkClient(slug) {
function clientLink(slug) { try { return JSON.parse(fs.readFileSync(path.join(CLIENTS, slug, "client.json"), "utf8")); } catch { return {}; } } 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. */ /** An Instagram account id to run business_discovery from: the client's own, else any the agency manages. */
async function viewerIgId(slug) { export async function viewerIgId(slug) {
const link = clientLink(slug); const link = clientLink(slug);
if (link.igId) return link.igId; if (link.igId) return link.igId;
const any = (await meta.listAccounts()).find((a) => a.instagram?.id); const any = (await meta.listAccounts()).find((a) => a.instagram?.id);
@ -46,9 +47,12 @@ function snapshotOf(b) {
return { return {
date: today(), followers: b.followers_count ?? null, following: b.follows_count ?? null, mediaCount: b.media_count ?? null, 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), name: b.name || null, bio: (b.biography || "").slice(0, 300),
media: (b.media || []).map((m) => ({ id: m.id, at: m.timestamp, likes: m.like_count ?? null, comments: m.comments_count ?? null, type: m.media_product_type === "REELS" ? "Reel" : m.media_type === "CAROUSEL_ALBUM" ? "Carousel" : m.media_type === "VIDEO" ? "Video" : "Image", permalink: m.permalink, caption: (m.caption || "").slice(0, 200) })), 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) { function addSnapshot(entry, snap) {
entry.snapshots = (entry.snapshots || []).filter((s) => s.date !== snap.date); 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. // Keep full media only on the latest snapshot; older ones keep the counts for the growth chart.
@ -63,7 +67,7 @@ export function listTracked(client) {
return (read()[client]?.accounts || []).map(({ snapshots, ...a }) => ({ ...a, lastChecked: snapshots?.at(-1)?.date || null })); 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 = "" } = {}) { export async function add(client, username, { by = "team", source = "manual", note = "", kind = "local", google = null } = {}) {
checkClient(client); checkClient(client);
const u = handle(username); const u = handle(username);
if (!/^[a-z0-9._]{1,30}$/.test(u)) throw err("Enter an Instagram handle, like @bakery_name."); if (!/^[a-z0-9._]{1,30}$/.test(u)) throw err("Enter an Instagram handle, like @bakery_name.");
@ -74,7 +78,7 @@ export async function add(client, username, { by = "team", source = "manual", no
const own = clientLink(client); const own = clientLink(client);
const b = await lookup(await viewerIgId(client), u); 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."); if (own.igId && String(b.id) === String(own.igId)) throw err("That's the client's own account.");
const entry = { username: u, name: b.name || null, source, note: String(note).slice(0, 300), addedBy: by, addedAt: new Date().toISOString(), snapshots: [] }; 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)); addSnapshot(entry, snapshotOf(b));
c.accounts.push(entry); c.accounts.push(entry);
write(db); write(db);
@ -104,6 +108,8 @@ export async function refresh(client) {
for (const a of c.accounts) { for (const a of c.accounts) {
try { const b = await lookup(igId, a.username); a.name = b.name || a.name; addSnapshot(a, snapshotOf(b)); } 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}`); } 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); write(db);
@ -117,10 +123,12 @@ function stats({ followers, media, history }) {
const recent = media.filter((m) => m.at && now - new Date(m.at) <= 28 * 86400000); const recent = media.filter((m) => m.at && now - new Date(m.at) <= 28 * 86400000);
const last12 = media.slice(0, 12); 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. // Median, not mean: one viral post would otherwise make a typical post look many times better than it is.
const sorted = last12.map(engagement).sort((x, y) => x - y); const avgEng = median(last12.map(engagement));
const avgEng = sorted.length ? Math.round(sorted.length % 2 ? sorted[(sorted.length - 1) / 2] : (sorted[sorted.length / 2 - 1] + sorted[sorted.length / 2]) / 2) : null; const mix = {}, byType = {};
const mix = {}; for (const m of media.slice(0, 25)) { mix[m.type] = (mix[m.type] || 0) + 1; (byType[m.type] ||= []).push(engagement(m)); }
for (const m of media.slice(0, 25)) mix[m.type] = (mix[m.type] || 0) + 1; // 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 monthAgo = new Date(now - 30 * 86400000).toISOString().slice(0, 10);
const base = history.find((h) => h.date >= monthAgo && h.followers != null) || null; const base = history.find((h) => h.date >= monthAgo && h.followers != null) || null;
return { return {
@ -131,9 +139,16 @@ function stats({ followers, media, history }) {
avgEngagement: avgEng, avgEngagement: avgEng,
engagementRate: avgEng != null && followers ? Math.round((avgEng / followers) * 10000) / 100 : null, engagementRate: avgEng != null && followers ? Math.round((avgEng / followers) * 10000) / 100 : null,
formatMix: mix, 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), 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. * Comparison for a client: their own account (from the dashboard snapshot passed in) plus every tracked competitor.
@ -144,15 +159,98 @@ export function compare(client, own) {
const accounts = (read()[client]?.accounts || []).map((a) => { const accounts = (read()[client]?.accounts || []).map((a) => {
const latest = a.snapshots?.at(-1) || {}; const latest = a.snapshots?.at(-1) || {};
return { return {
username: a.username, name: a.name, source: a.source, note: a.note, addedBy: a.addedBy, addedAt: a.addedAt, lastChecked: latest.date || null, 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 || [] }), ...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 })), series: (a.snapshots || []).filter((s) => s.followers != null).map((s) => ({ date: s.date, followers: s.followers })),
}; };
}); });
let you = null; let you = null;
if (own) { if (own) {
const media = (own.posts || []).map((p) => ({ id: p.id, at: p.timestamp, likes: p.like_count ?? null, comments: p.comments_count ?? null, type: p.media_product_type === "REELS" ? "Reel" : p.media_type === "CAROUSEL_ALBUM" ? "Carousel" : p.media_type === "VIDEO" ? "Video" : "Image", permalink: p.permalink, caption: (p.caption || "").slice(0, 200) })); const media = (own.posts || []).map(mediaOf);
you = { username: own.username, own: true, ...stats({ followers: own.followers ?? null, media, history: own.history || [] }), series: (own.history || []).filter((h) => h.followers != null) }; 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 }; 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,6 +2,7 @@
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, "..", "..");
@ -10,7 +11,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; if (!fs.existsSync(file)) return applySaved();
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;
@ -18,4 +19,25 @@ 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));
} }

77
connector/src/hashtags.js Normal file
View File

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

76
connector/src/places.js Normal file
View File

@ -0,0 +1,76 @@
// 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

@ -128,9 +128,10 @@ export async function gbpReply(client, reviewId, text) {
export async function youtubeComments(client) { export async function youtubeComments(client) {
const conn = vault.connectionFor(client, "youtube"); const conn = vault.connectionFor(client, "youtube");
if (!conn) throw err("YouTube isn't connected for this client."); if (!conn) throw err("YouTube isn't connected for this client.");
const { tokens } = await tokensFor(conn.id); 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"); quota.ensure("youtube", null, "read");
const out = await adapter("youtube").comments(tokens, conn.account); const out = await adapter("youtube").comments(key, conn.account);
quota.record("youtube", null, "read"); quota.record("youtube", null, "read");
return { channel: conn.account?.name, comments: out }; return { channel: conn.account?.name, comments: out };
} }

View File

@ -1,5 +1,8 @@
// 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";
@ -46,6 +49,38 @@ 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",
@ -54,24 +89,28 @@ 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: add “Sign In with LinkedIn using OpenID Connect” and “Share on LinkedIn” (instant).", "Products tab: request “Community Management API” for posting to clients' company Pages (LinkedIn reviews it; your company Page must be verified).",
"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: "Until Community Management API is approved, set Scopes to “openid profile w_member_social” so you can post to personal profiles only.", 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”.",
}, },
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",
scopes: ["openid", "profile", "w_member_social", "r_organization_admin", "w_organization_social", "r_organization_social"], // The scopes Community Management API grants (it can't be combined with the OpenID Connect product).
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) {
const { data } = await request("LinkedIn", `${API}/v2/userinfo`, { token: tokens.accessToken }); if (/\bopenid\b/.test(tokens.scope || "")) {
return { id: data.sub, name: data.name, username: null, avatar: data.picture || null, url: null }; 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 };
}
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). */
@ -79,12 +118,9 @@ 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" } });
for (const e of data.elements || []) { const ids = (data.elements || []).map((e) => String(e.organization).split(":").pop());
const orgId = String(e.organization).split(":").pop(); const names = await pageNames(tokens.accessToken, ids);
let name = `Company ${orgId}`; for (const id of ids) out.push({ id: `urn:li:organization:${id}`, name: `${names[id] || `Company ${id}`} (company Page)` });
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

@ -5,32 +5,14 @@ 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();
function key() { export { seal, open };
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: [] }; }
@ -70,6 +52,24 @@ 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;

View File

@ -32,9 +32,10 @@ export default {
}; };
}, },
/** Latest comments across the channel's videos (read-only: replying needs a broader scope, so reply on YouTube). */ /** Latest public comments across the channel's videos. Uses an API key, not the user's token: commentThreads.list
async comments(tokens, account, { max = 30 } = {}) { * needs youtube.force-ssl with OAuth, a scope we don't request. Reply on YouTube. */
const { data } = await request("YouTube", `${API}/commentThreads`, { token: tokens.accessToken, query: { part: "snippet", allThreadsRelatedToChannelId: account.id, maxResults: max, order: "time" } }); 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) => { return (data.items || []).map((t) => {
const c = t.snippet?.topLevelComment?.snippet || {}; 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}` }; 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}` };

26
connector/src/sealed.js Normal file
View File

@ -0,0 +1,26 @@
// 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

@ -12,6 +12,9 @@ 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 plan from "./plan.js";
import * as competitors from "./competitors.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 brandkit from "./brandkit.js";
import * as extra from "./platforms/index.js"; import * as extra from "./platforms/index.js";
@ -215,6 +218,33 @@ server.tool("competitor_add", "Start tracking an Instagram competitor for a clie
{ client: z.string(), username: z.string(), note: z.string().default("") }, { client: z.string(), username: z.string(), note: z.string().default("") },
wrap(async ({ client, username, note }) => competitors.add(client, username, { by: "agent", source: "suggested", note }))); 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.", 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))); { client: z.string() }, wrap(async ({ client }) => brandkit.get(client)));

89
connector/src/trends.js Normal file
View File

@ -0,0 +1,89 @@
// 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

@ -0,0 +1,36 @@
// 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

@ -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/userinfo", json({ sub: "abc", name: "Pat Lee", picture: "https://p" })], ["GET", "https://api.linkedin.com/v2/me", json({ id: "abc", localizedFirstName: "Pat", localizedLastName: "Lee" })],
["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/organizations/42", json({ localizedName: "Acme Co" })], ["GET", "https://api.linkedin.com/rest/organizationsLookup?ids=List(42)", json({ results: { 42: { 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);
@ -364,10 +364,16 @@ test("Google reviews: list with stars and replies, reply only to this location's
test("YouTube comments: latest threads on the channel with a link to reply", async () => { test("YouTube comments: latest threads on the channel with a link to reply", async () => {
reset(); reset();
route("POST", "https://oauth2.googleapis.com/token", json({ access_token: "yt-new", expires_in: 3600 })); 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 } } } }] })); 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"); const r = await P.youtubeComments("acme");
delete process.env.YOUTUBE_API_KEY;
assert.equal(r.comments[0].author, "Ana"); assert.equal(r.comments[0].author, "Ana");
assert.equal(r.comments[0].url, "https://www.youtube.com/watch?v=v1&lc=t1"); assert.equal(r.comments[0].url, "https://www.youtube.com/watch?v=v1&lc=t1");
assert.match(sent(/commentThreads/)[0].url, /allThreadsRelatedToChannelId=/); 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,12 +46,13 @@ 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.parse("2026-10-21T19:00:00Z"); const at = Date.now(); // relative to now: stored entries older than the window are pruned on every write
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 === "2026-10-22T07:00:00.000Z"); assert.throws(() => quota.ensure("youtube", null, "publish", at), (e) => e.quota && e.resetAt === new Date(day.end).toISOString());
assert.doesNotThrow(() => quota.ensure("youtube", null, "publish", Date.parse("2026-10-22T07:00:01Z")), "new day, new budget"); assert.doesNotThrow(() => quota.ensure("youtube", null, "publish", day.end + 1000), "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");
}); });
@ -67,7 +68,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.parse("2026-10-21T12:00:00Z"); const at = Date.now(); // relative to now: entries older than 24 h are pruned on every write
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

@ -0,0 +1,51 @@
// 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

@ -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" }; const DOC_DIRS = { reports: "reports", calendar: "calendar", trends: "trends" };
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,11 +61,12 @@ 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") : "calendar"); for (const f of fs.readdirSync(d)) if (f.endsWith(".md")) add(path.join(sub, f), kind === "reports" ? (/-monthly\.md$/.test(f) ? "monthly" : "report") : kind);
} }
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

@ -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 = {
model: process.env.DASHBOARD_AI_MODEL || "claude-opus-5-5", get model() { return process.env.DASHBOARD_AI_MODEL || "claude-opus-5-5"; }, // read live: changeable in Settings
maxTurns: Number(process.env.DASHBOARD_AI_MAX_TURNS || 80), get maxTurns() { return Number(process.env.DASHBOARD_AI_MAX_TURNS || 80); },
}; };

View File

@ -185,23 +185,28 @@ export async function report(slug, { actual, error }) {
if (actual === "running" && ["provisioning", "past_due"].includes(x.status)) x.status = "active"; if (actual === "running" && ["provisioning", "past_due"].includes(x.status)) x.status = "active";
}, error ? `provisioning error: ${String(error).slice(0, 120)}` : `container ${actual}`); }, error ? `provisioning error: ${String(error).slice(0, 120)}` : `container ${actual}`);
if (!error && actual === "running" && before.actual !== "running" && t.setupToken && !t.welcomedAt) { if (!error && actual === "running" && before.actual !== "running" && t.setupToken && !t.welcomedAt) {
const sent = await notify.sendEmail({ // Don't make the script wait for the mail server; the outcome lands in the agency's log on Admin → Agencies.
to: t.ownerEmail, subject: `Your Quorla workspace is ready`, heading: `Welcome to Quorla, ${t.ownerName || t.agencyName}`, sendWelcome(slug, t).catch(() => {});
lines: [`Your workspace for ${t.agencyName} is ready at ${t.url}.`, "Use the button to create your admin account. The link works once; after that, sign in with your email and password.", "Then follow the Getting started list: connect your clients' accounts, invite your team and schedule your first post."],
button: { label: "Create my account", url: `${t.url}/?setup=${t.setupToken}` }, fromName: "Quorla",
});
update(slug, (x) => { if (sent.sent) x.welcomedAt = now(); }, sent.sent ? "welcome email sent" : `welcome email not sent: ${sent.reason}`);
} }
return PUBLIC(read()[slug]); 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). */ /** Send the welcome email again (the setup link only works until the first account exists). */
export async function resendWelcome(slug) { export async function resendWelcome(slug) {
const t = read()[slug]; const t = read()[slug];
if (!t) throw err("Unknown agency.", 404); if (!t) throw err("Unknown agency.", 404);
if (t.actual !== "running") throw err("The workspace isn't running yet."); if (t.actual !== "running") throw err("The workspace isn't running yet.");
update(slug, (x) => { delete x.welcomedAt; }); const sent = await sendWelcome(slug, t);
await report(slug, { actual: "running" }); if (!sent.sent) throw err(`Welcome email not sent: ${sent.reason}`, 502);
update(slug, () => {}, "welcome email re-sent");
return PUBLIC(read()[slug]); return PUBLIC(read()[slug]);
} }

View File

@ -3,7 +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 } from "../../tools/report-pdf/build.mjs"; import { toPdf, buildHtml } from "../../tools/report-pdf/build.mjs";
import { reportOptions } from "./workspace.js"; 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). */
@ -37,7 +37,9 @@ 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");
return { path: rel, meta, html, updatedAt: fs.statSync(full).mtime.toISOString(), hasPdf: fs.existsSync(pdf) }; // Reports with visual blocks (```mc:…) preview as the same designed page the PDF uses.
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) {

View File

@ -51,8 +51,9 @@ export function checks() {
// AI runs // AI runs
const recent = jobs.listJobs({}).filter((j) => Date.now() - new Date(j.createdAt) < 7 * 24 * HOUR); 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 jobFails = recent.filter((j) => ["failed", "interrupted"].includes(j.status));
add("AI runs", process.env.ANTHROPIC_API_KEY ? (jobFails.length ? "warn" : "ok") : "warn", const aiReady = Boolean(process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_BASE_URL);
!process.env.ANTHROPIC_API_KEY ? "No Anthropic API key: AI runs (audits, weekly plans) can't start" : jobFails.length ? `${jobFails.length} AI run${jobFails.length === 1 ? "" : "s"} failed this week` : `AI runs working (${recent.length} this week)`, 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(" · ")); jobFails.slice(0, 3).map((j) => `${j.type} ${j.slug || ""}: ${j.error || j.status}`).join(" · "));
// Backups and disk // Backups and disk

View File

@ -20,6 +20,7 @@ import * as onboarding from "./onboarding.js";
import * as workspace from "./workspace.js"; import * as workspace from "./workspace.js";
import * as control from "./control.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";
@ -27,10 +28,12 @@ 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 plan from "../../connector/src/plan.js";
import * as competitors from "../../connector/src/competitors.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 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");
@ -178,6 +181,20 @@ 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) => {
@ -216,7 +233,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) }, 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 },
publishing: process.env.ALLOW_PUBLISH === "true", publishing: process.env.ALLOW_PUBLISH === "true",
}; };
})); }));
@ -307,6 +324,15 @@ 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) => {
@ -465,9 +491,21 @@ function ownForCompare(slug) {
history: metrics.readHistory(slug).map((h) => ({ date: h.date, followers: h.igFollowers })), history: metrics.readHistory(slug).map((h) => ({ date: h.date, followers: h.igFollowers })),
}; };
} }
app.get("/api/competitors", h(async (req) => { const c = String(req.query.client || ""); clients.safeSlug(c); return competitors.compare(c, ownForCompare(c)); })); app.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.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.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))); app.post("/api/competitors/refresh", h(async (req) => competitors.refresh(String(req.body?.client || "") || undefined)));
// ---------- published posts and their numbers ---------- // ---------- published posts and their numbers ----------

View File

@ -8,6 +8,7 @@ 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 });
@ -15,7 +16,15 @@ 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) },
}; };
@ -23,7 +32,7 @@ export const JOB_TYPES = {
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/. Export finished client-facing reports to PDF with the project's PDF builder. - 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.
- 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 = [
@ -32,9 +41,51 @@ 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",
]; ];
const BLOCKED_TOOLS = ["mcp__social-manager__instagram_publish", "mcp__social-manager__facebook_publish"]; // Write tools a run must never touch. Blocking also drops them from the request, so the model never sees them.
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 = [];
@ -119,23 +170,46 @@ 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"],
skills: "all", ...leanOptions(job.prompt),
model: AI.model, model: AI.model,
maxTurns: AI.maxTurns, maxTurns: AI.maxTurns,
permissionMode: "dontAsk", env: runEnv(),
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 },
@ -150,6 +224,7 @@ 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";
@ -170,7 +245,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.pdf || x.pdf.stale))) { for (const d of changed.filter((x) => (x.kind === "audit" || x.kind === "report" || x.kind === "competitors") && (!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

@ -18,15 +18,19 @@ const writeJson = (f, v) => { const tmp = f + ".tmp"; fs.writeFileSync(tmp, JSON
// ---------- email ---------- // ---------- email ----------
let transport = null; let transport = null;
export function emailConfigured() { return Boolean(process.env.SMTP_HOST); } export function emailConfigured() { return Boolean(process.env.SMTP_HOST); }
/** Drop the cached connection after SMTP settings change. */
export function resetMailer() { transport = null; }
function mailer() { function mailer() {
if (!emailConfigured()) return null; if (!emailConfigured()) return null;
transport ||= nodemailer.createTransport({ transport ||= nodemailer.createTransport({
host: process.env.SMTP_HOST, port: Number(process.env.SMTP_PORT || 587), secure: Number(process.env.SMTP_PORT) === 465, 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, 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; return transport;
} }
const fromAddress = () => process.env.SMTP_FROM || process.env.SMTP_USER || ""; // 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}`; 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]); const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);

View File

@ -1,14 +1,23 @@
// Odoo (workplace.metatroncube.in) over JSON-RPC: team, projects and Project tasks. // Odoo over JSON-RPC: team, projects and Project tasks.
// Reads always work when configured. Creating/moving tasks needs ODOO_ALLOW_WRITES=true. // Settings come from Connections → Odoo (stored encrypted in the vault), else from .env (ODOO_*).
import { ODOO } from "./config.js"; // Reads always work when configured. Creating/moving tasks needs "allow writes".
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) { async function rpc(service, method, args, url = ODOO.url) {
const res = await fetch(`${ODOO.url}/jsonrpc`, { const res = await fetch(`${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() }),
@ -21,14 +30,42 @@ async function rpc(service, method, args) {
} }
async function uid() { async function uid() {
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 }); if (!configured()) throw bad("Odoo isn't connected. An admin can connect it in Connections → Odoo.", 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 .env."); if (!id) throw new Error("Odoo login failed — check the username and API key in Connections → Odoo.");
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]);
} }
@ -118,7 +155,7 @@ export async function workload() {
} }
function assertWritable() { function assertWritable() {
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 }); 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 });
} }
export async function createTask({ projectId, name, description = "", userIds = [], deadline = null }) { export async function createTask({ projectId, name, description = "", userIds = [], deadline = null }) {
@ -147,7 +184,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_ALLOW_WRITES is off" }; if (!ODOO.allowWrites) return { skipped: "Odoo writes are 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

@ -0,0 +1,113 @@
// 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

@ -0,0 +1,126 @@
// 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,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom"; import { Link, useNavigate, useParams, useSearchParams } 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";
@ -7,6 +7,7 @@ import ClientPortal from "./ClientPortal.jsx";
import MonthlyReport from "./MonthlyReport.jsx"; import MonthlyReport from "./MonthlyReport.jsx";
import BrandKit from "./BrandKit.jsx"; import BrandKit from "./BrandKit.jsx";
import LinkInBio from "./LinkInBio.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,
@ -30,7 +31,8 @@ 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 [tab, setTab] = useState("performance"); const [params] = useSearchParams();
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();
@ -99,8 +101,10 @@ 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").length }, { value: "docs", label: "Documents", count: data.docs.filter((d) => d.kind !== "setup" && d.kind !== "competitors").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: "brand", label: "Brand kit" },
{ value: "bio", label: "Link in bio" }, { value: "bio", label: "Link in bio" },
{ value: "portal", label: "Client approval" }, { value: "portal", label: "Client approval" },
@ -117,8 +121,10 @@ 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} reload={load} /></div>} {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 === "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 === "brand" && <BrandKit slug={slug} />}
{tab === "bio" && <LinkInBio slug={slug} />} {tab === "bio" && <LinkInBio slug={slug} />}
{tab === "portal" && <ClientPortal slug={slug} clientName={client.name} />} {tab === "portal" && <ClientPortal slug={slug} clientName={client.name} />}
@ -384,9 +390,40 @@ function FormatBars({ d }) {
} }
// ---------------------------------------------------------------- documents // ---------------------------------------------------------------- documents
const KIND_LABEL = { monthly: "Monthly reports", audit: "Audits", report: "Weekly reports", calendar: "Content calendars", "profile-check": "Profile checks", setup: "Client setup files" }; 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" };
function Documents({ slug, docs, reload }) { function CompetitorReports({ slug, docs, reload, openPath, onStarted }) {
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);
@ -400,6 +437,11 @@ function Documents({ slug, docs, reload }) {
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);
@ -415,13 +457,13 @@ function Documents({ slug, docs, reload }) {
}; };
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 icon={FileText} title="No documents yet">Run an account audit to create the first ones.</Empty></Panel>; if (!docs.length) return <Panel>{empty || <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={KIND_LABEL[kind]}> <Panel key={kind} title={labels?.[kind] || KIND_LABEL[kind]}>
<table className="tbl"> <table className="tbl">
<tbody> <tbody>
{list.map((d) => ( {list.map((d) => (
@ -474,7 +516,9 @@ function Documents({ slug, docs, reload }) {
) )
} }
> >
{preview?.loading ? <Spinner /> : preview && ( {preview?.loading ? <Spinner /> : preview?.page ? (
<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,7 +1,7 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import { Bar, BarChart, CartesianGrid, Cell, LabelList, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; import { Bar, BarChart, CartesianGrid, Cell, LabelList, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { ExternalLink, Plus, RefreshCw, Swords, Trash2 } from "lucide-react"; import { Check, ExternalLink, FileBarChart, Hash, Plus, RefreshCw, Sparkles, Swords, Trash2, X } from "lucide-react";
import { api } from "../api.js"; import { api } from "../api.js";
import { Badge, Button, cx, Empty, ErrorNote, Field, fmt, fmtDate, Panel, Spinner, timeAgo } from "../ui.jsx"; import { Badge, Button, cx, Empty, ErrorNote, Field, fmt, fmtDate, Panel, Spinner, timeAgo } from "../ui.jsx";
@ -65,6 +65,97 @@ function AddDialog({ client, onClose, onAdded }) {
); );
} }
/** 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() { export default function Competitors() {
const [params, setParams] = useSearchParams(); const [params, setParams] = useSearchParams();
const [clients, setClients] = useState(null); const [clients, setClients] = useState(null);
@ -76,8 +167,16 @@ export default function Competitors() {
useEffect(() => { api("/api/overview").then((r) => setClients(r.clients)).catch(setError); }, []); 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]); useEffect(() => { if (!client && clients?.length) setParams({ client: clients[0].slug }, { replace: true }); }, [client, clients, setParams]);
const load = useCallback(() => (client ? api(`/api/competitors?client=${encodeURIComponent(client)}`).then(setData).catch(setError) : Promise.resolve()), [client]); const [suggestions, setSuggestions] = useState([]);
useEffect(() => { setData(null); setError(null); load(); }, [load]); 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 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 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(" · ")); });
@ -100,17 +199,23 @@ export default function Competitors() {
<select id="comp-client" aria-label="Client" className="input h-9 w-auto" value={client} onChange={(e) => setParams({ client: e.target.value })}> <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>)} {clients.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}
</select> </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>} {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> <Button variant="primary" icon={Plus} onClick={() => setAdding(true)} disabled={data?.competitors.length >= 10}>Add competitor</Button>
</div> </div>
</header> </header>
<ErrorNote error={error} /> <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 ? ( {!data ? (!error && <Spinner />) : !data.competitors.length ? (
<div className="panel"> <div className="panel">
<Empty icon={Swords} title="No competitors tracked yet" action={<Button variant="primary" icon={Plus} onClick={() => setAdding(true)}>Add competitor</Button>}> <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>}>
Add 3–5 accounts this client actually competes with: same area, same customers. Quorla then tracks their followers, posting rhythm, engagement and best posts every night. 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> </Empty>
</div> </div>
) : ( ) : (

View File

@ -77,6 +77,56 @@ function AppDialog({ platform, onClose, onSaved }) {
); );
} }
/** The agency's own Odoo (team tasks, workload). One per workspace, not per client. */
function OdooPanel() {
const [s, setS] = useState(null);
const [f, setF] = 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 }) { function PlatformCard({ platform, conn, client, clients, isAdmin, mediaLinks, onSetup, onMoved, reload, setError, quota }) {
const [busy, setBusy] = useState(null); const [busy, setBusy] = useState(null);
const [moving, setMoving] = useState(false); const [moving, setMoving] = useState(false);
@ -243,6 +293,8 @@ export default function Connections() {
))} ))}
</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

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

@ -279,7 +279,7 @@ function WorkspacePanel() {
<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> <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} />)} {msg && (typeof msg === "string" ? <span className="text-[12.5px] text-ok">{msg}</span> : <ErrorNote error={msg} />)}
</div> </div>
<p className="text-[12px] text-muted">Your own domain (like social.youragency.com): point the domain at the server and set PUBLIC_URL in .env. See the hosting guide.</p> <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> </Panel>
); );
} }
@ -294,7 +294,7 @@ function NotificationsPanel() {
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); }; 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 ( 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"> <Panel title="Notifications" subtitle="The bell at the top of the sidebar always shows them. Choose what also comes by email." bodyClass="px-5 py-4 space-y-3">
{!d.email && <div className="text-[13px] rounded-md bg-warn-soft text-warn px-3 py-2">Email isn't set up on this server yet, so notifications only appear in the bell. An admin adds SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS and SMTP_FROM to the server's .env.</div>} {!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]"> <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")} /> <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> <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>
@ -309,6 +309,84 @@ function NotificationsPanel() {
); );
} }
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);
@ -326,8 +404,9 @@ export default function Settings() {
<AccountPanel /> <AccountPanel />
<NotificationsPanel /> <NotificationsPanel />
{isAdmin && <TeamPanel />} {isAdmin && <TeamPanel />}
{isAdmin && <ServerSettingsPanel />}
<ErrorNote error={error} /> <ErrorNote error={error} />
<Panel title="Connections" subtitle="Changed in the server's .env for now." bodyClass="divide-y divide-line"> <Panel title="Connections" subtitle="Current status. Change these under Server settings, Meta and Connections." 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)">
@ -335,11 +414,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 (ODOO_ALLOW_WRITES)"}.` ? `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)"}.`
: s.odoo.configured ? `Can't connect: ${s.odoo.error}` : "Not configured. Add ODOO_URL, ODOO_DATABASE, ODOO_USERNAME and ODOO_API_KEY."} : s.odoo.configured ? `Can't connect: ${s.odoo.error}` : "Not connected. Connect it in Connections → Odoo."}
</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.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.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."}
</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 || "Add ODOO_URL, ODOO_DATABASE, ODOO_USERNAME and ODOO_API_KEY to the project .env, then restart the dashboard."} {data.status.error || "An admin can connect it in Connections → Odoo (address, database, username and API key)."}
</Empty> </Empty>
</Panel> </Panel>
</div> </div>

View File

@ -43,12 +43,17 @@ run_container() { # slug port
local slug="$1" port="$2" dir="$TENANTS_DIR/$1" local slug="$1" port="$2" dir="$TENANTS_DIR/$1"
docker run -d --name "quorla-$slug" --restart unless-stopped \ docker run -d --name "quorla-$slug" --restart unless-stopped \
-p "127.0.0.1:$port:4310" --env-file "$dir/.env" \ -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 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/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" \ -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 --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 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 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 return 1
@ -74,6 +79,7 @@ while IFS=$'\t' read -r slug desired port name token; do
mkdir -p "$dir"/{data,clients,team,reports,backups} mkdir -p "$dir"/{data,clients,team,reports,backups}
[ -d "$dir/branding" ] || cp -r "$REPO/branding" "$dir/branding" [ -d "$dir/branding" ] || cp -r "$REPO/branding" "$dir/branding"
write_env "$slug" "$name" "$token" 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 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"}'; 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 else log "$slug did not start"; report "$slug" "{\"error\":\"container did not become healthy on port $port\"}"; fi

View File

@ -53,6 +53,27 @@ Two Google Cloud projects, both owned by dev@metatroncubesolutions.com:
together with the demo video link): 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. > 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 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@. video https://youtu.be/lB8FMGDbBOo (unlisted); Data access status "under review". Google replies by email to dev@.

View File

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

449
tools/report-pdf/blocks.mjs Normal file
View File

@ -0,0 +1,449 @@
// Visual report blocks: the AI writes small fenced markers, this file draws them as inline SVG/HTML.
// Charts are drawn from the stored account numbers (never from figures typed by the AI) and frozen
// next to the report as <report>.data.json the first time it renders, so a re-export shows the same numbers
// (until the .md is rewritten, which takes a fresh copy).
//
// Data blocks: ```mc:kpis ```mc:reach ```mc:funnel ```mc:followers ```mc:formats ```mc:rhythm ```mc:top-posts
// Written blocks: ```mc:score (Area | 6/10 | why) ```mc:actions (Action | Owner | When)
// ```mc:screenshot (first line: shot id, then one note per line)
// ```mc:search-trends (first line: keywords of one google_trends call, optional second: location)
// Callouts: > [!WIN] title / [!FIX] / [!RISK] / [!NOTE]
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from "node:fs";
import { dirname, extname, join, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { Marked } from "marked";
import { compare, listSuggestions } from "../../connector/src/competitors.js";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
const DATA_DIR = process.env.QUORLA_DASHBOARD_DATA || join(root, "data", "dashboard");
const DAY = 86400000;
const C = { blue: "#3a7bbf", sky: "#52ace0", ink: "#232327", gray: "#8a8f99", line: "#e3e7ee", good: "#2e9e6b", warn: "#d99a1e", bad: "#d0543f" };
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
const num = (n) => (n == null ? "—" : n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 10000 ? `${(n / 1000).toFixed(n >= 100000 ? 0 : 1)}k` : Math.round(n).toLocaleString("en-CA"));
const rate = (r) => (r.avgEngagement != null && r.followers ? (r.avgEngagement / r.followers) * 100 : null); // unrounded, for big accounts
const rateText = (v) => (v == null ? "—" : `${v >= 1 ? v.toFixed(1) : Number(v.toPrecision(2))}%`);
const pct = (a, b) => (b ? Math.round((a / b) * 100) : null);
const day = (d) => new Date(d).toLocaleDateString("en-CA", { month: "short", day: "numeric", timeZone: "UTC" });
const plain = (s) => String(s || "").normalize("NFKC").replace(/\s+/g, " ").trim(); // 𝐁𝐨𝐥𝐝 unicode → Bold
const readJson = (f) => { try { return JSON.parse(readFileSync(f, "utf8")); } catch { return null; } };
let uid = 0;
// ---------- data ----------
function slugFor(mdPath) {
const parts = resolve(mdPath).split(sep);
const i = parts.lastIndexOf("clients");
return i >= 0 ? parts[i + 1] : null;
}
function freshData(slug) {
const snap = readJson(join(DATA_DIR, "snapshots", `${slug}.json`));
if (!snap) return null;
const typeOf = (p) => (p.media_product_type === "REELS" || p.media_type === "VIDEO" ? "Reel" : p.media_type === "CAROUSEL_ALBUM" ? "Carousel" : "Image");
const ig = snap.ig && {
username: snap.ig.profile?.username,
followers: snap.ig.profile?.followers_count,
totals: snap.ig.totals || {},
dailyReach: snap.ig.dailyReach || [],
dailyFollowers: snap.ig.dailyFollowers || [],
derived: snap.ig.derived || {},
posts: (snap.ig.posts || []).map((p) => ({
at: p.timestamp, type: typeOf(p), caption: plain(p.caption).slice(0, 140), link: p.permalink,
reach: p.insights?.reach ?? null, views: p.insights?.views ?? null, likes: p.like_count ?? 0, comments: p.comments_count ?? 0,
saves: p.insights?.saved ?? 0, shares: p.insights?.shares ?? 0,
})),
};
const competition = competitionData(slug, snap);
const fb = snap.fb && { name: snap.fb.page?.name, followers: snap.fb.page?.followers_count, totals: snap.fb.totals || {}, posts: (snap.fb.posts || []).map((p) => ({ at: p.created_time })) };
return { slug, asOf: snap.fetchedAt, ig, fb, competition, trends: trendsData() };
}
/** Google Trends answers the runs fetched in the last week (connector/src/trends.js cache), frozen with the report. */
function trendsData() {
try {
return Object.values(readJson(join(root, "data", "trends-cache.json"))).map((v) => v.data).filter((t) => t?.keywords?.length);
} catch {
return [];
}
}
/** The client plus tracked and suggested competitors, with the numbers the rival blocks need. */
function competitionData(slug, snap) {
try {
const history = (readFileSync(join(DATA_DIR, "history", `${slug}.jsonl`), "utf8").trim().split("\n").filter(Boolean)).map((l) => JSON.parse(l));
const own = snap.ig && { username: snap.ig.profile?.username, followers: snap.ig.profile?.followers_count ?? null, posts: snap.ig.posts || [], history: history.map((h) => ({ date: h.date, followers: h.igFollowers })) };
const { you, competitors } = compare(slug, own);
const keep = (a, role) => ({
role, username: a.username, name: a.name || null, kind: a.kind || "local", why: a.why || a.note || "", google: a.google || null,
followers: a.followers, postsPerWeek: a.postsPerWeek, avgEngagement: a.avgEngagement, engagementRate: a.engagementRate, growth30: a.growth30 ?? null,
formatMix: a.formatMix || {}, engagementByFormat: a.engagementByFormat || {}, topTags: a.topTags || [], allTags: a.allTags || [],
topPosts: (a.topPosts || []).map((p) => ({ at: p.at, type: p.type, likes: p.likes, comments: p.comments, link: p.permalink, caption: plain(p.caption).slice(0, 140) })),
});
if (you && !you.google) {
try { you.google = JSON.parse(readFileSync(join(root, "clients", slug, "client.json"), "utf8")).google || null; } catch {}
}
const rows = [...(you ? [keep(you, "you")] : []), ...competitors.map((c) => keep(c, "tracked")), ...listSuggestions(slug).map((s) => keep(s, "suggested"))];
return rows.length > 1 ? rows : null;
} catch {
return null;
}
}
export function reportData(mdPath) {
const frozen = resolve(mdPath).replace(/\.md$/i, ".data.json");
// Frozen until the report itself is rewritten (a new run or an edit): then its text and numbers move together.
if (existsSync(frozen) && statSync(frozen).mtimeMs >= statSync(mdPath).mtimeMs) return readJson(frozen);
const slug = slugFor(mdPath);
const data = slug && freshData(slug);
if (data) try { writeFileSync(frozen, JSON.stringify(data)); } catch { /* read-only folder: render live */ }
return data;
}
// ---------- small SVG pieces ----------
function spark(values, color = C.blue, w = 96, h = 26) {
if (values.length < 2) return "";
const max = Math.max(...values, 1), step = w / (values.length - 1);
const pts = values.map((v, i) => `${(i * step).toFixed(1)},${(h - 2 - (v / max) * (h - 4)).toFixed(1)}`).join(" ");
return `<svg class="mc-spark" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" aria-hidden="true"><polyline points="${pts}" fill="none" stroke="${color}" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round"/></svg>`;
}
function ring(score, size = 92) {
const r = size / 2 - 7, c = 2 * Math.PI * r, f = Math.max(0, Math.min(1, score / 10));
const col = score < 4 ? C.bad : score < 7 ? C.warn : C.good;
return `<svg viewBox="0 0 ${size} ${size}" width="${size}" height="${size}" aria-hidden="true">
<circle cx="${size / 2}" cy="${size / 2}" r="${r}" fill="none" stroke="${C.line}" stroke-width="8"/>
<circle cx="${size / 2}" cy="${size / 2}" r="${r}" fill="none" stroke="${col}" stroke-width="8" stroke-linecap="round" stroke-dasharray="${(c * f).toFixed(1)} ${c.toFixed(1)}" transform="rotate(-90 ${size / 2} ${size / 2})"/>
<text x="50%" y="52%" text-anchor="middle" dominant-baseline="middle" class="mc-ring-num">${score.toFixed(1)}</text></svg>`;
}
const card = (cls, title, sub, body, foot = "") =>
`<figure class="mc-card ${cls}">${title ? `<figcaption><span class="mc-card-title">${esc(title)}</span>${sub ? `<span class="mc-card-sub">${sub}</span>` : ""}</figcaption>` : ""}${body}${foot ? `<p class="mc-card-foot">${foot}</p>` : ""}</figure>`;
const noData = (what) => `<p class="mc-nodata">${esc(what)}: no account data stored yet.</p>`;
// ---------- data blocks ----------
const BLOCKS = {
kpis(d) {
const ig = d?.ig;
if (!ig) return noData("Key numbers");
const t = ig.totals, net = (t.new_follows ?? 0) - (t.unfollows ?? 0);
const reachRatio = pct(t.reach, ig.followers);
const tiles = [
{ v: num(ig.followers), l: "Instagram followers", n: t.new_follows != null ? `${net >= 0 ? "▲" : "▼"} ${num(Math.abs(net))} net this month` : "", tone: net < 0 ? "bad" : "good" },
{ v: num(t.reach), l: "People reached", n: reachRatio != null ? `${reachRatio}% of followers` : "", tone: reachRatio != null && reachRatio < 20 ? "bad" : "", s: ig.dailyReach.map((x) => x.value) },
{ v: num(t.total_interactions), l: "Likes, comments, saves, shares", n: t.reach ? `${pct(t.total_interactions, t.reach)} per 100 reached` : "" },
{ v: num(ig.derived.postsLast28), l: "Posts in 28 days", n: ig.derived.daysSinceLastPost != null ? `last post ${ig.derived.daysSinceLastPost} days ago` : "" },
{ v: num(t.profile_links_taps), l: "Taps on the website link", n: t.profile_links_taps ? "" : "nobody reached the website", tone: t.profile_links_taps ? "" : "bad" },
...(d.fb ? [{ v: num(d.fb.followers), l: "Facebook followers", n: d.fb.totals.page_total_media_view_unique != null ? `${num(d.fb.totals.page_total_media_view_unique)} people saw posts` : "" }] : []),
];
return `<section class="mc-kpigrid">${tiles.map((k) => `<div class="mc-kpi2 ${k.tone ? `is-${k.tone}` : ""}"><div class="mc-kpi2-top"><span class="mc-kpi2-v">${esc(k.v)}</span>${k.s ? spark(k.s) : ""}</div><div class="mc-kpi2-l">${esc(k.l)}</div>${k.n ? `<div class="mc-kpi2-n">${esc(k.n)}</div>` : ""}</div>`).join("")}</section>`;
},
reach(d) {
const s = d?.ig?.dailyReach || [];
if (s.length < 2) return noData("Daily reach");
const W = 640, H = 180, L = 34, R = 10, T = 12, B = 34, id = `g${++uid}`;
const max = Math.max(...s.map((x) => x.value), 1), yMax = Math.ceil(max / 5) * 5 || 5;
const x = (i) => L + (i * (W - L - R)) / (s.length - 1), y = (v) => T + (H - T - B) * (1 - v / yMax);
const line = s.map((p, i) => `${x(i).toFixed(1)},${y(p.value).toFixed(1)}`).join(" ");
const grid = [0, 0.5, 1].map((f) => `<line x1="${L}" x2="${W - R}" y1="${y(yMax * f)}" y2="${y(yMax * f)}" stroke="${C.line}"/><text x="${L - 6}" y="${y(yMax * f) + 3}" text-anchor="end" class="mc-ax">${num(yMax * f)}</text>`).join("");
const dates = s.map((p) => p.date);
const postDays = new Set((d.ig.posts || []).map((p) => p.at.slice(0, 10)));
const marks = dates.map((dt, i) => (postDays.has(dt) ? `<path d="M${x(i)} ${H - B + 8} l4 6 h-8 z" fill="${C.blue}"/>` : "")).join("");
const labels = [0, Math.floor((s.length - 1) / 2), s.length - 1].map((i) => `<text x="${x(i)}" y="${H - 4}" text-anchor="${i === 0 ? "start" : i === s.length - 1 ? "end" : "middle"}" class="mc-ax">${day(dates[i])}</text>`).join("");
const best = s.reduce((a, b) => (b.value > a.value ? b : a));
const svg = `<svg viewBox="0 0 ${W} ${H}" class="mc-chart" role="img" aria-label="Daily people reached over the last ${s.length} days">
<defs><linearGradient id="${id}" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stop-color="${C.sky}" stop-opacity=".45"/><stop offset="1" stop-color="${C.sky}" stop-opacity="0"/></linearGradient></defs>
${grid}<polygon points="${x(0)},${y(0)} ${line} ${x(s.length - 1)},${y(0)}" fill="url(#${id})"/><polyline points="${line}" fill="none" stroke="${C.blue}" stroke-width="2" stroke-linejoin="round"/>${marks}${labels}</svg>`;
return card("", "People reached each day", `▲ marks a day with a post · busiest day ${day(best.date)} (${num(best.value)})`, svg);
},
funnel(d) {
const t = d?.ig?.totals;
if (!t) return noData("Funnel");
const steps = [["Saw a post or reel", t.views], ["Different people reached", t.reach], ["Liked, commented, saved or shared", t.accounts_engaged], ["Tapped the website link", t.profile_links_taps]].filter((s) => s[1] != null);
const max = Math.max(...steps.map((s) => s[1]), 1);
let leak = -1, worst = 101;
steps.forEach((s, i) => { if (i && steps[i - 1][1]) { const r = pct(s[1], steps[i - 1][1]); if (r < worst) { worst = r; leak = i; } } });
const rows = steps.map(([label, v], i) => {
const w = Math.max(2, Math.sqrt(v / max) * 100);
const conv = i && steps[i - 1][1] ? `<span class="mc-fun-conv ${i === leak ? "is-bad" : ""}">${pct(v, steps[i - 1][1])}% kept${i === leak ? " · biggest drop" : ""}</span>` : "";
return `<div class="mc-fun-row">${conv}<div class="mc-fun-bar" style="width:${w.toFixed(1)}%;background:${i === leak ? C.bad : i % 2 ? C.sky : C.blue}"><span>${num(v)}</span></div><div class="mc-fun-l">${esc(label)}</div></div>`;
}).join("");
return card("", "From seeing a post to visiting the website", "last 28 days, Instagram", `<div class="mc-fun">${rows}</div>`);
},
followers(d) {
const t = d?.ig?.totals;
if (t?.new_follows == null) return noData("Followers gained and lost");
const gained = t.new_follows, lost = t.unfollows ?? 0, net = gained - lost, max = Math.max(gained, lost, 1);
const bar = (v, col, label) => `<div class="mc-flow-row"><span class="mc-flow-l">${label}</span><div class="mc-flow-track"><div style="width:${((v / max) * 100).toFixed(1)}%;background:${col}"></div></div><span class="mc-flow-v">${num(v)}</span></div>`;
const daily = (d.ig.dailyFollowers || []).map((x) => x.value);
return card("", "Followers gained vs lost", "last 28 days", `<div class="mc-flow"><div class="mc-flow-net ${net < 0 ? "is-bad" : "is-good"}"><b>${net > 0 ? "+" : ""}${num(net)}</b><span>net change</span></div><div class="mc-flow-bars">${bar(gained, C.good, "New")}${bar(lost, C.bad, "Unfollowed")}${daily.length > 1 ? `<div class="mc-flow-spark">${spark(daily, C.good, 220, 30)}<span>new followers per day</span></div>` : ""}</div></div>`,
net < 0 ? "More people left than joined: the content isn't matching the audience the account has." : "");
},
formats(d) {
const posts = (d?.ig?.posts || []).filter((p) => p.reach != null && Date.parse(d.asOf) - Date.parse(p.at) > 2 * DAY);
if (!posts.length) return noData("Formats");
const types = ["Reel", "Carousel", "Image"].map((type) => {
const ps = posts.filter((p) => p.type === type);
const avg = ps.length ? ps.reduce((a, p) => a + p.reach, 0) / ps.length : 0;
const act = ps.length ? ps.reduce((a, p) => a + p.saves + p.shares, 0) / ps.length : 0;
return { type, n: ps.length, share: pct(ps.length, posts.length), avg, act };
}).filter((t) => t.n);
const maxAvg = Math.max(...types.map((t) => t.avg), 1);
const rows = types.map((t) => `<tr><td>${t.type}s</td>
<td><div class="mc-pair"><div class="mc-pair-bar" style="width:${t.share}%;background:#c9d6e6"></div><span>${t.share}% of posts</span></div></td>
<td><div class="mc-pair"><div class="mc-pair-bar" style="width:${((t.avg / maxAvg) * 100).toFixed(1)}%;background:${C.sky}"></div><span>${num(t.avg)} reached per post</span></div></td></tr>`).join("");
const best = types.reduce((a, b) => (b.avg > a.avg ? b : a)), most = types.reduce((a, b) => (b.n > a.n ? b : a));
const foot = best.type !== most.type && most.avg ? `${best.type}s reach ${(best.avg / most.avg).toFixed(1)}× more people per post than ${most.type.toLowerCase()}s, but ${most.type.toLowerCase()}s are ${most.share}% of what gets posted.` : "";
return card("", "What gets posted vs what reaches people", `last ${posts.length} posts`, `<table class="mc-pairs"><tbody>${rows}</tbody></table>`, foot);
},
rhythm(d) {
const posts = d?.ig?.posts?.length ? d.ig.posts : d?.fb?.posts || [];
if (!posts.length) return noData("Posting rhythm");
const end = new Date(d.asOf); end.setUTCHours(0, 0, 0, 0);
const monday = new Date(+end - (((end.getUTCDay() + 6) % 7) + 7 * 7) * DAY); // Monday, 8 weeks ago
const byDay = new Map();
posts.forEach((p) => { const k = p.at.slice(0, 10); byDay.set(k, (byDay.get(k) || 0) + 1); });
const weeks = [];
for (let w = monday; w <= end; w = new Date(+w + 7 * DAY)) weeks.push(w);
const cells = weeks.map((w) => `<div class="mc-rh-week"><span class="mc-rh-wl">${day(w)}</span>${[...Array(7)].map((_, i) => {
const dt = new Date(+w + i * DAY), k = dt.toISOString().slice(0, 10), n = byDay.get(k) || 0;
return `<i class="${dt > end ? "is-future" : n ? "is-on" : ""}" title="${k}${n ? `: ${n} post${n > 1 ? "s" : ""}` : ""}"></i>`;
}).join("")}</div>`).join("");
const inRange = posts.filter((p) => Date.parse(p.at) >= +monday).length;
const dv = d.ig?.derived || {};
return card("", "Posting rhythm, last 8 weeks", "one square per day · Mon → Sun",
`<div class="mc-rh"><div class="mc-rh-grid"><div class="mc-rh-week mc-rh-head"><span class="mc-rh-wl"></span>${"MTWTFSS".split("").map((c) => `<b>${c}</b>`).join("")}</div>${cells}</div>
<div class="mc-rh-stats"><div><b>${(inRange / weeks.length).toFixed(1)}</b><span>posts a week</span></div>${dv.longestGapDays != null ? `<div><b>${dv.longestGapDays}</b><span>days, longest gap</span></div>` : ""}${dv.daysSinceLastPost != null ? `<div><b>${dv.daysSinceLastPost}</b><span>days since last post</span></div>` : ""}</div></div>`);
},
"top-posts"(d) {
const posts = (d?.ig?.posts || []).filter((p) => p.reach != null && Date.parse(d.asOf) - Date.parse(p.at) > 2 * DAY);
if (posts.length < 2) return noData("Top posts");
const sorted = [...posts].sort((a, b) => b.reach - a.reach), max = sorted[0].reach || 1, n = Math.min(3, Math.floor(posts.length / 2));
const item = (p) => `<li><div class="mc-post-meta"><span class="mc-chip">${p.type}</span>${day(p.at)}${p.link ? ` · <a href="${esc(p.link)}">open</a>` : ""}</div>
<div class="mc-post-cap">${esc(p.caption.slice(0, 90) || "(no caption)")}${p.caption.length > 90 ? "…" : ""}</div>
<div class="mc-post-bar"><div style="width:${((p.reach / max) * 100).toFixed(1)}%"></div></div>
<div class="mc-post-nums"><b>${num(p.reach)}</b> reached · ${num(p.saves + p.shares)} saved or shared${p.reach ? ` (${((p.saves + p.shares) / p.reach * 100).toFixed(1)} per 100)` : ""}</div></li>`;
return card("mc-wide", "Best and weakest posts", `last ${posts.length} posts, by people reached`, `<div class="mc-posts"><div><h4 class="is-good">Best ${n}</h4><ol>${sorted.slice(0, n).map(item).join("")}</ol></div><div><h4 class="is-bad">Weakest ${n}</h4><ol>${sorted.slice(-n).reverse().map(item).join("")}</ol></div></div>`);
},
// ---------- competitors ----------
rivals(d) {
const rows = ranked(d);
if (!rows) return noData("Competitors");
const max = Math.max(...rows.map((r) => r.engagementRate || 0), 0.01);
const youAt = rows.findIndex((r) => r.role === "you");
const body = `<table class="mc-lb"><thead><tr><th>#</th><th>Account</th><th>Followers</th><th>Posts / week</th><th>Typical post</th><th>Engagement rate</th></tr></thead><tbody>${rows.map((r, i) => `
<tr class="${r.role === "you" ? "is-you" : ""}"><td class="mc-lb-rank">${i + 1}</td>
<td><div class="mc-lb-name">${esc(r.name || `@${r.username}`)} ${tag(r)}</div><div class="mc-lb-handle">@${esc(r.username)}</div></td>
<td class="num">${num(r.followers)}${r.growth30 != null ? `<div class="mc-lb-sub ${r.growth30 < 0 ? "is-bad" : "is-good"}">${r.growth30 >= 0 ? "▲" : "▼"} ${num(Math.abs(r.growth30))} / 30d</div>` : ""}</td>
<td class="num">${r.postsPerWeek ?? "—"}</td>
<td class="num">${num(r.avgEngagement)}<div class="mc-lb-sub">likes + comments</div></td>
<td><div class="mc-lb-bar"><div style="width:${(((r.engagementRate || 0) / max) * 100).toFixed(1)}%"></div><span>${rateText(r.engagementRate)}</span></div></td></tr>`).join("")}</tbody></table>`;
const foot = youAt >= 0 ? `You rank <b>${youAt + 1} of ${rows.length}</b> on engagement rate: likes and comments on a typical post, divided by followers. It's the fairest way to compare a small account with a big one.` : "";
return card("mc-wide", "Who's winning on Instagram", "ranked by engagement rate", body, foot);
},
"rivals-map"(d) {
const rows = (ranked(d) || []).filter((r) => r.followers > 0 && r.engagementRate != null);
if (rows.length < 3) return noData("Competitor map");
const W = 640, H = 300, L = 44, R = 16, T = 16, B = 34;
const lx = rows.map((r) => Math.log10(r.followers)), ys = rows.map((r) => r.engagementRate);
const x0 = Math.floor(Math.min(...lx) * 2) / 2, x1 = Math.ceil(Math.max(...lx) * 2) / 2 || x0 + 1, y1 = Math.max(...ys) * 1.15 || 1;
const X = (r) => L + ((Math.log10(r.followers) - x0) / (x1 - x0 || 1)) * (W - L - R), Y = (v) => T + (H - T - B) * (1 - v / y1);
const med = (xs) => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)];
const mx = L + ((med(lx) - x0) / (x1 - x0 || 1)) * (W - L - R), my = Y(med(ys));
const ticks = []; for (let t = Math.ceil(x0); t <= x1; t++) ticks.push(t);
const axis = ticks.map((t) => { const x = L + ((t - x0) / (x1 - x0 || 1)) * (W - L - R); return `<line x1="${x}" x2="${x}" y1="${T}" y2="${H - B}" stroke="${C.line}"/><text x="${x}" y="${H - B + 14}" text-anchor="middle" class="mc-ax">${num(10 ** t)}</text>`; }).join("");
const quad = (x, y, anchor, text) => `<text x="${x}" y="${y}" text-anchor="${anchor}" class="mc-quad">${text}</text>`;
const placed = []; // label positions, nudged down when two would overlap
const labelY = (x, y) => { while (placed.some((p) => Math.abs(p.x - x) < 90 && Math.abs(p.y - y) < 10)) y += 10; placed.push({ x, y }); return y; };
const dots = rows.map((r) => {
const rad = 5 + Math.min(10, (r.postsPerWeek || 0) * 1.6), you = r.role === "you";
const fill = you ? "#fc6d45" : r.kind === "benchmark" ? C.sky : C.blue;
const left = X(r) > W * 0.72; // label on the left near the right edge
return `<g><circle cx="${X(r).toFixed(1)}" cy="${Y(r.engagementRate).toFixed(1)}" r="${rad.toFixed(1)}" fill="${fill}" fill-opacity="${you ? 0.95 : 0.55}" stroke="#fff" stroke-width="1.5"/>
<text x="${(X(r) + (left ? -(rad + 3) : rad + 3)).toFixed(1)}" y="${labelY(X(r), Y(r.engagementRate) + 3).toFixed(1)}" text-anchor="${left ? "end" : "start"}" class="mc-dot-l${you ? " is-you" : ""}">${you ? "You" : `@${esc(r.username.slice(0, 18))}`}</text></g>`;
}).join("");
const svg = `<svg viewBox="0 0 ${W} ${H}" class="mc-chart" role="img" aria-label="Followers against engagement rate for the client and competitors">
${axis}<line x1="${mx}" x2="${mx}" y1="${T}" y2="${H - B}" stroke="${C.gray}" stroke-dasharray="3 4"/><line x1="${L}" x2="${W - R}" y1="${my}" y2="${my}" stroke="${C.gray}" stroke-dasharray="3 4"/>
${quad(L + 6, T + 10, "start", "Small but loved")}${quad(W - R - 4, T + 10, "end", "Big and engaged")}${quad(L + 6, H - B - 6, "start", "Small and quiet")}${quad(W - R - 4, H - B - 6, "end", "Big but quiet")}
${dots}<text x="${(L + W - R) / 2}" y="${H - 3}" text-anchor="middle" class="mc-ax">Followers →</text><text x="12" y="${(T + H - B) / 2}" transform="rotate(-90 12 ${(T + H - B) / 2})" text-anchor="middle" class="mc-ax">Engagement rate →</text></svg>`;
return card("mc-wide", "Size vs engagement", `bigger dot = posts more often · <span style="color:#fc6d45">●</span> you <span style="color:${C.blue}">●</span> local <span style="color:${C.sky}">●</span> benchmark`, svg,
"Accounts top-left get more attention per follower than their size suggests: their content is what to learn from.");
},
"rivals-formats"(d) {
const rows = ranked(d);
if (!rows) return noData("Formats");
const kinds = ["Reel", "Carousel", "Image", "Video"], col = { Reel: C.blue, Carousel: C.sky, Image: "#b9c3d1", Video: "#7a86c2" };
// Each format against the account's own typical post, so a 2M-follower brand doesn't drown out a local shop.
const market = {};
rows.filter((r) => r.role !== "you" && r.avgEngagement).forEach((r) => Object.entries(r.engagementByFormat).forEach(([k, v]) => (market[k] ||= []).push(v / r.avgEngagement)));
const med = (xs) => (xs?.length ? [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)] : null);
const you = rows.find((r) => r.role === "you");
const best = kinds.filter((k) => market[k]?.length).sort((a, b) => med(market[b]) - med(market[a]))[0];
const lines = rows.map((r) => {
const total = Object.values(r.formatMix).reduce((a, b) => a + b, 0) || 1;
return `<div class="mc-fm-row${r.role === "you" ? " is-you" : ""}"><span class="mc-fm-l">${r.role === "you" ? "You" : `@${esc(r.username.slice(0, 20))}`}</span><div class="mc-fm-bar">${kinds.filter((k) => r.formatMix[k]).map((k) => `<div style="width:${((r.formatMix[k] / total) * 100).toFixed(1)}%;background:${col[k]}" title="${k}"></div>`).join("")}</div></div>`;
}).join("");
const legend = kinds.map((k) => `<span><i style="background:${col[k]}"></i>${k}s${market[k]?.length ? ` · ${med(market[k]).toFixed(1)}× a typical post` : ""}</span>`).join("");
const foot = best && you ? `For your competitors, ${best.toLowerCase()}s do ${med(market[best]).toFixed(1)}× better than their typical post. ${you.formatMix[best] ? `They're ${Math.round((you.formatMix[best] / (Object.values(you.formatMix).reduce((a, b) => a + b, 0) || 1)) * 100)}% of what you post.` : "You don't post any yet."}` : "";
return card("mc-wide", "What everyone posts, and what earns engagement", "last 25 posts per account", `<div class="mc-fm">${lines}</div><div class="mc-fm-legend">${legend}</div>`, foot);
},
"rivals-tags"(d) {
const rows = ranked(d);
if (!rows) return noData("Hashtags");
const you = rows.find((r) => r.role === "you"), mine = new Set(you?.allTags || []);
const count = {};
rows.filter((r) => r.role !== "you").slice(0, 8).forEach((r) => r.topTags.forEach((t) => (count[t] = (count[t] || 0) + 1)));
const tags = Object.entries(count).filter(([t]) => !mine.has(t)).sort((a, b) => b[1] - a[1]).slice(0, 18);
if (!tags.length) return noData("Hashtags");
return card("", "Hashtags on the best posts in your market that you don't use", "from rivals' highest-engagement posts",
`<div class="mc-tags">${tags.map(([t, n]) => `<span class="mc-tag${n > 1 ? " is-hot" : ""}">${esc(t)}${n > 1 ? `<b>${n}</b>` : ""}</span>`).join("")}</div>`,
"A number means several competitors use it on their best posts. Use the ones that describe your area and service; skip anything that isn't you.");
},
"rivals-posts"(d) {
const rows = (ranked(d) || []).filter((r) => r.role !== "you" && r.followers);
const posts = rows.flatMap((r) => r.topPosts.map((p) => ({ ...p, r, eng: (p.likes || 0) + (p.comments || 0), rate: (((p.likes || 0) + (p.comments || 0)) / r.followers) * 100 })));
if (!posts.length) return noData("Competitor posts");
const top = posts.sort((a, b) => b.rate - a.rate).slice(0, 6);
return card("mc-wide", "Best posts in your market", "ranked by engagement for the account's size",
`<div class="mc-ideas">${top.map((p) => `<div class="mc-idea"><div class="mc-post-meta"><span class="mc-chip">${p.type}</span>@${esc(p.r.username)} · ${day(p.at)}${p.link ? ` · <a href="${esc(p.link)}">open</a>` : ""}</div>
<div class="mc-post-cap">${esc(p.caption.slice(0, 110) || "(no caption)")}${p.caption.length > 110 ? "…" : ""}</div>
<div class="mc-post-nums"><b>${num(p.eng)}</b> likes + comments · ${p.rate.toFixed(1)}% of their followers</div></div>`).join("")}</div>`);
},
"rivals-google"(d) {
const rows = (d?.competition || []).filter((r) => r.google?.rating);
if (!rows.length) return noData("Google ratings");
const sorted = [...rows].sort((a, b) => (b.google.reviews || 0) - (a.google.reviews || 0)), maxRev = Math.max(...rows.map((r) => r.google.reviews || 0), 1);
const unverified = rows.some((r) => r.google.verified === false);
return card("", "Google rating and reviews", unverified ? "where local customers often decide · * as shown on the business's own website" : "where local customers often decide · from Google Maps",
`<div class="mc-gg">${sorted.map((r) => `<div class="mc-gg-row${r.role === "you" ? " is-you" : ""}"><span class="mc-gg-l">${r.role === "you" ? "You" : esc(r.name || `@${r.username}`)}</span>
<span class="mc-gg-star">★ ${r.google.rating.toFixed(1)}${r.google.verified === false ? "*" : ""}</span><div class="mc-gg-bar"><div style="width:${(((r.google.reviews || 0) / maxRev) * 100).toFixed(1)}%"></div></div><span class="mc-gg-n">${r.google.reviews == null ? "—" : `${num(r.google.reviews)} reviews`}</span></div>`).join("")}</div>`);
},
};
/** Client + competitors sorted by engagement rate (highest first); null without competitors. */
function ranked(d) {
const rows = d?.competition;
if (!rows || rows.length < 2) return null;
return rows.map((r) => ({ ...r, engagementRate: rate(r) ?? r.engagementRate })).sort((a, b) => (b.engagementRate ?? -1) - (a.engagementRate ?? -1));
}
const tag = (r) => (r.role === "you" ? `<span class="mc-chip is-you">You</span>` : `<span class="mc-chip${r.kind === "benchmark" ? " is-bench" : ""}">${r.kind === "benchmark" ? "Benchmark" : "Local"}</span>${r.role === "suggested" ? `<span class="mc-chip is-new">New</span>` : ""}`);
// ---------- written blocks ----------
const rowsOf = (body) => body.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).map((l) => l.replace(/^[-*]\s+/, "").split("|").map((s) => s.trim()));
function scoreBlock(body) {
const rows = rowsOf(body).map(([area, score, why]) => ({ area, why, s: Math.max(0, Math.min(10, parseFloat(score) || 0)) }));
if (!rows.length) return "";
const avg = rows.reduce((a, r) => a + r.s, 0) / rows.length;
const tone = (s) => (s < 4 ? C.bad : s < 7 ? C.warn : C.good);
return `<section class="mc-score"><div class="mc-score-ring">${ring(avg)}<span>overall, out of 10</span></div><div class="mc-score-rows">${rows.map((r) => `
<div class="mc-score-row"><div class="mc-score-head"><b>${esc(r.area)}</b><span style="color:${tone(r.s)}">${r.s}/10</span></div>
<div class="mc-score-track"><div style="width:${r.s * 10}%;background:${tone(r.s)}"></div></div>${r.why ? `<p>${esc(r.why)}</p>` : ""}</div>`).join("")}</div></section>`;
}
function actionsBlock(body) {
const rows = rowsOf(body);
if (!rows.length) return "";
return `<ol class="mc-actions">${rows.map(([what, owner = "", when = ""]) => {
const who = /client|you/i.test(owner) ? "client" : "us";
return `<li><div class="mc-act-what">${esc(what).replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")}</div><div class="mc-act-meta">${owner ? `<span class="mc-owner is-${who}">${esc(owner)}</span>` : ""}${when ? `<span class="mc-when">${esc(when)}</span>` : ""}</div></li>`;
}).join("")}</ol>`;
}
const LEGACY_SHOTS = { "ig-profile": "instagram-profile", "fb-page": "facebook-page" };
/**
* ```mc:search-trends first line: the keywords of one google_trends call (comma-separated); optional second line: location.
* Draws interest over time per keyword plus the rising searches, from the stored answer (never typed numbers).
*/
const TREND_COLORS = [C.blue, "#fc6d45", C.good, C.warn, C.sky];
function trendsBlock(body, d) {
const [kwLine = "", locLine = ""] = String(body).split("\n").map((l) => l.trim()).filter(Boolean);
const want = kwLine.toLowerCase().split(",").map((k) => k.trim()).filter(Boolean);
const loc = locLine.toLowerCase();
const sets = (d?.trends || []).filter((t) => !loc || String(t.location).toLowerCase() === loc);
const t = sets.find((s) => want.every((k) => s.keywords.some((x) => x.keyword === k))) || (!want.length ? sets[0] : null);
if (!t) return `<p class="mc-nodata">Search trends${want.length ? ` for ${esc(want.join(", "))}` : ""}: no stored Google Trends answer.</p>`;
const kws = want.length ? t.keywords.filter((k) => want.includes(k.keyword)) : t.keywords;
const n = Math.max(...kws.map((k) => k.series.length));
if (n < 2) return `<p class="mc-nodata">Search trends: not enough data points.</p>`;
const W = 640, H = 190, L = 30, R = 10, T = 12, B = 30;
const x = (i) => L + (i * (W - L - R)) / (n - 1), y = (v) => T + (H - T - B) * (1 - v / 100);
const grid = [0, 50, 100].map((v) => `<line x1="${L}" x2="${W - R}" y1="${y(v)}" y2="${y(v)}" stroke="${C.line}"/><text x="${L - 6}" y="${y(v) + 3}" text-anchor="end" class="mc-ax">${v}</text>`).join("");
const lines = kws.map((k, i) => `<polyline points="${k.series.map(([, v], j) => `${x(j).toFixed(1)},${y(v).toFixed(1)}`).join(" ")}" fill="none" stroke="${TREND_COLORS[i % 5]}" stroke-width="2" stroke-linejoin="round"/>`).join("");
const dates = kws.find((k) => k.series.length === n).series.map(([from]) => from);
const labels = [0, Math.floor((n - 1) / 2), n - 1].map((i) => `<text x="${x(i)}" y="${H - 6}" text-anchor="${i === 0 ? "start" : i === n - 1 ? "end" : "middle"}" class="mc-ax">${new Date(dates[i]).toLocaleDateString("en-CA", { month: "short", year: "2-digit", timeZone: "UTC" })}</text>`).join("");
const svg = `<svg viewBox="0 0 ${W} ${H}" class="mc-chart" role="img" aria-label="Google search interest over time">${grid}${lines}${labels}</svg>`;
const month = (iso) => (iso ? new Date(iso).toLocaleDateString("en-CA", { month: "short", year: "numeric", timeZone: "UTC" }) : "—");
const rows = kws.map((k, i) => {
const ch = k.change == null ? "—" : `${k.change > 0 ? "▲" : k.change < 0 ? "▼" : ""} ${Math.abs(k.change)}%`;
const tone = k.change == null ? "" : k.change >= 10 ? ` style="color:${C.good}"` : k.change <= -10 ? ` style="color:${C.bad}"` : "";
const rising = (k.rising || []).slice(0, 5).map((r) => `<span class="mc-tag">${esc(r.query)}</span>`).join("");
return `<tr><td><span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:${TREND_COLORS[i % 5]};margin-right:6px"></span>${esc(k.keyword)}</td><td${tone}><b>${ch}</b></td><td>${month(k.peak?.from)}</td><td><div class="mc-tags">${rising || "—"}</div></td></tr>`;
}).join("");
const table = `<table><thead><tr><th>Search</th><th>Trend</th><th>Busiest</th><th>Rising related searches</th></tr></thead><tbody>${rows}</tbody></table>`;
return card("", "What people are searching for on Google", `${esc(String(t.location).replace(/,\s*/g, ", "))} · interest from 0 to 100, relative to the busiest point · trend = latest quarter of the period vs the one before`, svg + table);
}
function screenshotBlock(body, ctx) {
const [id = "", ...notes] = body.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
const dir = join(dirname(ctx.mdPath), "screenshots");
if (!existsSync(dir)) return "";
const months = readdirSync(dir).filter((m) => /^\d{4}-\d{2}$/.test(m)).sort().reverse();
const want = [id, LEGACY_SHOTS[id]].filter(Boolean);
const find = (folder) => existsSync(folder) && readdirSync(folder).find((f) => want.some((w) => f.startsWith(`${w}.`)) && /\.(png|jpe?g|webp)$/i.test(f));
let file = null;
for (const m of months) { const f = find(join(dir, m)); if (f) { file = join(dir, m, f); break; } }
if (!file) { const f = find(dir); file = f && join(dir, f); }
if (!file || statSync(file).size > 6 * 1024 * 1024) return ctx.internal ? `<p class="mc-nodata">Screenshot "${esc(id)}" not uploaded yet.</p>` : "";
const mime = { ".png": "image/png", ".webp": "image/webp" }[extname(file).toLowerCase()] || "image/jpeg";
const img = `<img src="data:${mime};base64,${readFileSync(file).toString("base64")}" alt="${esc(id)} screenshot">`;
return `<figure class="mc-shot ${notes.length ? "" : "is-solo"}">${img}${notes.length ? `<ol class="mc-shot-notes">${notes.map((n) => `<li>${esc(n.replace(/^\d+[.)]\s*|^[-*]\s+/, ""))}</li>`).join("")}</ol>` : ""}</figure>`;
}
// ---------- markdown ----------
const ALERTS = { WIN: ["good", "What's working"], FIX: ["warn", "To fix"], RISK: ["bad", "Risk"], NOTE: ["info", "Note"] };
export function renderMarkdown(md, ctx) {
const data = () => (ctx._data === undefined ? (ctx._data = reportData(ctx.mdPath)) : ctx._data);
const marked = new Marked({ gfm: true, breaks: true });
marked.use({
renderer: {
code(code, info) {
const kind = String(info || "").trim().match(/^mc:([\w-]+)/)?.[1];
if (!kind) return false;
if (BLOCKS[kind]) return BLOCKS[kind](data());
if (kind === "score") return scoreBlock(code);
if (kind === "actions") return actionsBlock(code);
if (kind === "screenshot") return screenshotBlock(code, ctx);
if (kind === "search-trends") return trendsBlock(code, data());
return ctx.internal ? `<p class="mc-nodata">Unknown block mc:${esc(kind)}</p>` : "";
},
blockquote(quote) {
const m = quote.match(/^\s*<p>\[!(WIN|FIX|RISK|NOTE)\]\s*([^<]*)(?:<br>)?/);
if (!m) return false;
const [tone, fallback] = ALERTS[m[1]];
const rest = quote.slice(m[0].length).replace(/^\s*<\/p>/, "");
return `<aside class="mc-alert is-${tone}"><div class="mc-alert-title">${m[2].trim() || fallback}</div>${rest.startsWith("<") ? rest : `<p>${rest}`}</aside>`;
},
},
});
return marked.parse(md);
}
export const asOfLabel = (mdPath) => {
const d = reportData(mdPath);
return d?.asOf ? `Account data as of ${new Date(d.asOf).toLocaleDateString("en-CA", { month: "long", day: "numeric", year: "numeric" })}` : "";
};

View File

@ -7,7 +7,7 @@ import { tmpdir } from "node:os";
import { basename, dirname, join, resolve } from "node:path"; import { basename, dirname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url"; import { fileURLToPath, pathToFileURL } from "node:url";
import { execFileSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { marked } from "marked"; import { renderMarkdown, asOfLabel } from "./blocks.mjs";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); const root = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
const brandDir = join(root, "branding"); const brandDir = join(root, "branding");
@ -80,16 +80,18 @@ function heroHtml(meta) {
} }
// opts.logoPath / opts.logoAlt: an agency's own logo (white-label) instead of branding/metatroncube-logo.png. // opts.logoPath / opts.logoAlt: an agency's own logo (white-label) instead of branding/metatroncube-logo.png.
// opts.screen: page for the dashboard preview instead of print.
function buildHtml(mdPath, opts = {}) { function buildHtml(mdPath, opts = {}) {
const { meta, content } = parseFrontMatter(readFileSync(mdPath, "utf8")); const { meta, content } = parseFrontMatter(readFileSync(mdPath, "utf8"));
const body = marked.parse(content, { gfm: true, breaks: true }); const internal = /-internal/i.test(basename(mdPath));
const body = renderMarkdown(content, { mdPath: resolve(mdPath), internal });
const asOf = /```mc:/.test(content) ? asOfLabel(mdPath) : "";
const css = readFileSync(join(brandDir, "report.css"), "utf8"); const css = readFileSync(join(brandDir, "report.css"), "utf8");
const custom = opts.logoPath && existsSync(opts.logoPath); const custom = opts.logoPath && existsSync(opts.logoPath);
const logo = readFileSync(custom ? opts.logoPath : join(brandDir, "metatroncube-logo.png")).toString("base64"); const logo = readFileSync(custom ? opts.logoPath : join(brandDir, "metatroncube-logo.png")).toString("base64");
const logoMime = custom && /\.jpe?g$/i.test(opts.logoPath) ? "image/jpeg" : "image/png"; const logoMime = custom && /\.jpe?g$/i.test(opts.logoPath) ? "image/jpeg" : "image/png";
const h1 = content.match(/^#\s+(.+)$/m)?.[1]; const h1 = content.match(/^#\s+(.+)$/m)?.[1];
const title = (meta.client ? `${meta.client} — ${meta.report ?? "Report"}` : h1 ?? basename(mdPath, ".md")).replace(/[*_`]/g, ""); const title = (meta.client ? `${meta.client} — ${meta.report ?? "Report"}` : h1 ?? basename(mdPath, ".md")).replace(/[*_`]/g, "");
const internal = /-internal/i.test(basename(mdPath));
const date = meta.prepared || new Date().toLocaleDateString("en-CA", { year: "numeric", month: "long", day: "numeric" }); const date = meta.prepared || new Date().toLocaleDateString("en-CA", { year: "numeric", month: "long", day: "numeric" });
return `<!doctype html> return `<!doctype html>
@ -98,13 +100,13 @@ function buildHtml(mdPath, opts = {}) {
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&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;700&family=Plus+Jakarta+Sans:wght@600;700;800&display=swap" rel="stylesheet">
<style>${css}</style> <style>${css}</style>
</head><body> </head><body${opts.screen ? ' class="mc-screen"' : ""}>
<header class="mc-topbar"> <header class="mc-topbar">
${custom ${custom
? `<div><img src="data:${logoMime};base64,${logo}" alt="${escapeHtml(opts.logoAlt || "")}" style="display:block;height:44px;width:auto;max-width:260px;object-fit:contain"></div>` ? `<div><img src="data:${logoMime};base64,${logo}" alt="${escapeHtml(opts.logoAlt || "")}" style="display:block;height:44px;width:auto;max-width:260px;object-fit:contain"></div>`
: `<div class="mc-logo-crop"><img src="data:image/png;base64,${logo}" alt="Metatroncube — Digital Solutions Simplified"></div>`} : `<div class="mc-logo-crop"><img src="data:image/png;base64,${logo}" alt="Metatroncube — Digital Solutions Simplified"></div>`}
<div class="mc-topmeta"> <div class="mc-topmeta">
Prepared ${escapeHtml(date)} Prepared ${escapeHtml(date)}${asOf ? `<br>${escapeHtml(asOf)}` : ""}
${internal ? '<br><span class="mc-internal">INTERNAL — NOT FOR CLIENT</span>' : ""} ${internal ? '<br><span class="mc-internal">INTERNAL — NOT FOR CLIENT</span>' : ""}
</div> </div>
</header> </header>