From 2d0d9c0391dffb1a12cc7012aebfccdb0740b883 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 17 Oct 2026 21:30:47 +0000 Subject: [PATCH] Promotion campaigns: promote a campaign's businesses, measure each for 30 days, then draft outreach with the results (0.5.0) - Import a complete OutreachGraph campaign snapshot (scripts/outreachgraph-campaign-export.sh): every source row gets one disposition, leads at one domain merge into one business, re-imports are idempotent, only a complete snapshot can mark a business as having left. - Members launch only when upstream outreach is held (suppressed, or only in approval campaigns). - Verified members of a running campaign come first in /pros, in SQL before pagination. - /roundup/, a posting calendar of spotlights handed to myna by `pit promo worker`, which reconciles against myna history/queue by tracking id before posting and verifies through the networks' public APIs. Organic only: ad-buying networks are refused, paid budget defaults to zero. - Measurement without cookies or stored IPs: listing views, website clicks (/b//go redirect to the listing's own site), phone taps (beacon), quote requests; bots, previews, prefetches, internal and marked test traffic excluded; day-salted visitor hash. Roundup views stay campaign-level. - Per-business 30-day windows (listing live + tracking tested + verified placement), hourly pass, one review job per window, versioned reports (HTML/Markdown/JSON, shareable /r/), CSV comparison, report-based drafts (introduction vs follow-up from recorded contact), sending off until authorized and re-checked at delivery. - API /api/v1/promotion, /admin/promo dashboard, `pit promo` CLI + TUI, promo_* MCP tools. Co-Authored-By: Claude Opus 5.5 --- README.md | 15 + package.json | 2 +- public/listing.js | 9 + scripts/outreachgraph-campaign-export.sh | 71 ++ server.json | 4 +- src/cli.ts | 11 + src/client.ts | 24 + src/html.ts | 2 + src/listings-html.ts | 26 +- src/listings-routes.ts | 30 +- src/listings.ts | 23 +- src/mcp.ts | 50 +- src/promotion-cli.ts | 242 +++++ src/promotion-html.ts | 176 ++++ src/promotion-routes.ts | 256 +++++ src/promotion-schema.ts | 62 ++ src/promotion-tui.ts | 130 +++ src/promotion-worker.ts | 183 ++++ src/promotion.ts | 1189 ++++++++++++++++++++++ src/server.ts | 37 +- test/listings.test.ts | 7 +- test/promotion.test.ts | 529 ++++++++++ 22 files changed, 3057 insertions(+), 21 deletions(-) create mode 100644 public/listing.js create mode 100755 scripts/outreachgraph-campaign-export.sh create mode 100644 src/promotion-cli.ts create mode 100644 src/promotion-html.ts create mode 100644 src/promotion-routes.ts create mode 100644 src/promotion-schema.ts create mode 100644 src/promotion-tui.ts create mode 100644 src/promotion-worker.ts create mode 100644 src/promotion.ts create mode 100644 test/promotion.test.ts diff --git a/README.md b/README.md index f378510..95db0e9 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,21 @@ No key is needed. `PIT_URL` points it at another pit.world instance (default `ht - **The pit.world ring (`/ring`).** The core sites (pit.world, seo.rank, family.ancestry) and then every claimed listing, in the order they joined: **claiming is joining**, and owners can leave from the edit page. Hops: `/ring/next|prev|random?from=` (also `?host=`, `?url=`, `?via=`, or the Referer), `302` with `no-store`; `/api/ring` for members that draw the bar themselves; `/ring/opml` for every member's feed. The verified and edit pages show the links to paste. - **Mirrored to rssamplifier.com.** pit.world reads each claimed site's homepage for `` RSS/Atom on claim and daily, and keeps the ring `pit-world` on [rssamplifier.com/ring/pit-world](https://rssamplifier.com/ring/pit-world) in step: submit new feeds, add members, remove ones that left. The sync is a diff (idempotent), runs a few seconds after a claim or edit and once a day, and needs `RSSAMPLIFIER_API_KEY` (an rssamplifier API key of the account that owns the ring; off without it). Moshpit-only names are not mirrored, since rssamplifier cannot reach them. New claims are also a feed: `/b/feed.xml`. +## Promotion campaigns: promote first, then show the results + +A promotion campaign lists and promotes the businesses an [OutreachGraph](https://outreachgraph.com) campaign targets, measures what that did for each one over its own 30-day window, and only then writes to them, with the numbers. It is how the Los Gatos builders campaign runs ([pit.world/roundup/los-gatos-builders](https://pit.world/roundup/los-gatos-builders)). + +- **Import.** `scripts/outreachgraph-campaign-export.sh cmp_...` writes a complete, read-only snapshot of the OutreachGraph campaign (OutreachGraph has no API that lists every member with its addresses). `pit promo import ID snapshot.json` gives every source row exactly one disposition (matched, merged into the same business, unresolved, excluded) and the totals must add up. Leads at one domain are one business with all their lead ids. Re-importing the same snapshot changes nothing; only a complete snapshot can mark a business as having left (its listing stays, its priority goes). Businesses without social profiles are kept. +- **The roster first.** `pit promo roster ID`: lead ids, business, website, listing, verification, planned action. A member is publishable when its listing is in the directory with a name, category, place, description and a checked source (a license checked at the issuer, or an owner claim). +- **Outreach hold.** A member only launches when every way the upstream could send to it is held: each lead suppressed in OutreachGraph (the `los-gatos-promotion-hold` suppress list) or only in campaigns that wait for approval. Otherwise it is blocked with the reason; an operator can attest a hold (`pit promo set ID DOMAIN --attest TEXT`). +- **Priority.** Verified members of a running campaign come first in `/pros`, in SQL before pagination, after filters, so pages, the API and the mobile view agree. Turning priority off or stopping the campaign leaves the listing alone. Campaign membership is never shown publicly. +- **Roundup and spotlights.** `pit promo preview ID` shows every planned post with its text and time and a version; `pit promo launch ID --version V` publishes `/roundup/` and creates the calendar: a roundup announcement now, then one spotlight per business across the configured accounts, `perAccountPerDay` each, from `postHour` local time. `pit promo worker ID` (run where myna is logged in) posts due ones with `myna post --front`, reconciles every post against myna's history and queue by its tracking id (`?via=plc_...`) before posting so a crash never posts twice, and verifies each through the network's public API (Bluesky `getPostThread`, Mastodon `statuses`). Scheduled is not published, published is not verified; only verified posts count. LinkedIn has no public read, so its posts never count. Accounts on networks where myna buys a CrawlProof ad per post are refused: promotion is organic, and a paid budget defaults to zero. +- **Measurement.** Listing views, website clicks (`/b//go`, a redirect to the listing's own site only), phone taps (a beacon from `/listing.js`, deduplicated by its id), quote requests, and roundup views (campaign-level, never credited to each business). Crawlers, link previews, prefetches, `PIT_INTERNAL_IPS` and marked tests (`?pt=test`) are stored as excluded. No cookie, no IP, no user agent: a visitor is a hash under a salt deleted when its UTC day ends, so "visitor-days" exist and unique visitors over a window do not (reported as unavailable). Only businesses in a campaign are counted. +- **Windows.** Each business's window starts when its listing is live, its tracking passed a marked test (`pit promo test-tracking ID`) and a placement naming it was verified, and lasts `periodDays` (30) elapsed days. The hourly pass marks ended windows due and makes one review job per window (a restart never makes a second), which writes a versioned report and a draft. Late additions get their own window; gaps are recorded (`pit promo gap`), never hidden by moving dates. +- **Reports.** Private and frozen per version (`pit promo report ID --md`); shareable at `/r/` (HTML, `.md`, `.json`) without the operator's costs. Unavailable is not zero. Late data makes a new version and voids unsent drafts that quoted the old one. `pit promo csv ID` compares the campaign. +- **Drafts and sending.** Each report gets one draft from its own numbers: an introduction unless a contact is on record (then a follow-up), an honest note when nothing was measured, blocked when there is no address. Sending is off until `sendAuthorized`; `deliver` re-checks the window, the report version and upstream suppression at send time (unknown means no) and keeps one delivery per recipient, message version and window. +- **Surfaces.** API `/api/v1/promotion/*` (Bearer `PIT_ADMIN_TOKEN`), dashboard `/admin/promo` (a signed-in pit.world admin), `pit promo ...`, `pit promo tui ID`, and `promo_*` MCP tools when `PIT_ADMIN_TOKEN` is set. + ## One account for every pit.world site: social. and bbs. Every Moshpit name gets two more addresses, with nothing to set up: diff --git a/package.json b/package.json index 6efa2c2..0e6e0b6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pitworld", - "version": "0.4.0", + "version": "0.5.0", "mcpName": "world.pit/pit", "description": "pit.world MCP server: private web search and Moshpit name lookups for AI agents (stdio, no API key). Also a web app, API, CLI and TUI.", "license": "MIT", diff --git a/public/listing.js b/public/listing.js new file mode 100644 index 0000000..260c17b --- /dev/null +++ b/public/listing.js @@ -0,0 +1,9 @@ +// Counts taps on a listing's phone link (for promotion campaigns): one beacon per tap, with an id so a +// retried beacon is counted once. Sends the listing's domain and the campaign link it came from, nothing else. +document.addEventListener('click', (event) => { + const a = event.target instanceof Element ? event.target.closest('a[data-ev="phone"]') : null + if (!a || !navigator.sendBeacon) return + const id = (crypto.randomUUID ? crypto.randomUUID() : String(Math.random()).slice(2) + Date.now()).replace(/[^A-Za-z0-9_-]/g, '') + const body = JSON.stringify({ type: 'phone_click', id, via: a.dataset.via || null }) + navigator.sendBeacon(`/b/${encodeURIComponent(a.dataset.domain)}/e`, new Blob([body], { type: 'application/json' })) +}) diff --git a/scripts/outreachgraph-campaign-export.sh b/scripts/outreachgraph-campaign-export.sh new file mode 100755 index 0000000..bee80f4 --- /dev/null +++ b/scripts/outreachgraph-campaign-export.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# outreachgraph-campaign-export.sh: a complete, read-only snapshot of one OutreachGraph campaign's +# members, as the JSON `pit promo import` takes. OutreachGraph has no API that lists every member of +# a campaign (GET /campaigns/:id/accounts leaves out people without a company, and carries no +# addresses), so this reads its Postgres directly, in one SELECT: nothing is written. +# +# scripts/outreachgraph-campaign-export.sh cmp_... > snapshot.json +# +# DATABASE_URL comes from the environment, else from OG_APP_ENV (default ~/www/outreachgraph.com/app.env, +# only its DATABASE_URL line). Runs psql inside the Supabase container on dev2 (PSQL_CONTAINER). +# +# Every member row carries what the promotion hold needs to be checked against the upstream state: +# the other campaigns the person is in and how each one sends, whether they are suppressed, and their +# contact history (interactions, by direction). Rows keep OutreachGraph's own order (discovered_at, id). +set -euo pipefail + +CAMPAIGN="${1:?usage: $0 }" +[[ "$CAMPAIGN" =~ ^cmp_[a-z0-9]+$ ]] || { echo "not a campaign id: $CAMPAIGN" >&2; exit 2; } +CONTAINER="${PSQL_CONTAINER:-logicsrc-com-supabase-db}" +if [ -z "${DATABASE_URL:-}" ]; then + DATABASE_URL="$(grep '^DATABASE_URL=' "${OG_APP_ENV:-$HOME/www/outreachgraph.com/app.env}" | cut -d= -f2-)" +fi + +docker exec -i "$CONTAINER" psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -At -v campaign="$CAMPAIGN" <<'SQL' +WITH c AS (SELECT * FROM campaigns WHERE id = :'campaign'), +members AS ( + SELECT cp.*, row_number() OVER (ORDER BY cp.discovered_at, cp.person_id) AS ord + FROM campaign_people cp JOIN c ON c.id = cp.campaign_id +) +SELECT json_build_object( + 'source', 'outreachgraph', + 'campaignId', (SELECT id FROM c), + 'campaignName', (SELECT name FROM c), + 'workspaceId', (SELECT workspace_id FROM c), + 'approvalMode', (SELECT approval_mode FROM c), + 'snapshotAt', to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'), + 'complete', true, + 'total', (SELECT count(*) FROM members), + 'rows', COALESCE((SELECT json_agg(json_build_object( + 'leadId', m.person_id, + 'order', m.ord, + 'memberStatus', m.status, + 'interactionState', m.interaction_state, + 'name', p.display_name, + 'kind', p.kind, + 'personStatus', p.status, + 'title', p.current_title, + 'company', co.name, + 'companyId', co.id, + 'domain', co.domain, + 'location', COALESCE(co.location, p.location), + 'emails', COALESCE((SELECT json_agg(e.address ORDER BY e.address) FROM person_emails e WHERE e.person_id = p.id), '[]'::json), + 'socials', COALESCE((SELECT json_agg(json_build_object('network', s.network, 'url', s.profile_url)) FROM social_identities s WHERE s.person_id = p.id), '[]'::json), + 'suppressed', p.status = 'suppressed' OR m.status = 'suppressed' OR EXISTS ( + SELECT 1 FROM suppression_keys k + WHERE k.match_key IN (SELECT 'person:' || p.id UNION SELECT 'email:' || lower(e.address) FROM person_emails e WHERE e.person_id = p.id) + AND (k.scope = 'global' OR k.workspace_id = m.workspace_id)), + 'suppressLists', COALESCE((SELECT json_agg(DISTINCT se.name) FROM suppression_keys k JOIN suppression_entries se ON se.id = k.suppression_id + WHERE se.name IS NOT NULL AND (k.scope = 'global' OR k.workspace_id = m.workspace_id) + AND k.match_key IN (SELECT 'person:' || p.id UNION SELECT 'email:' || lower(e.address) FROM person_emails e WHERE e.person_id = p.id)), '[]'::json), + 'campaigns', COALESCE((SELECT json_agg(json_build_object('id', oc.id, 'name', oc.name, 'approvalMode', oc.approval_mode, 'status', oc.status, 'memberStatus', ocp.status)) + FROM campaign_people ocp JOIN campaigns oc ON oc.id = ocp.campaign_id WHERE ocp.person_id = p.id), '[]'::json), + 'contacts', json_build_object( + 'outbound', (SELECT count(*) FROM interactions i WHERE i.person_id = p.id AND i.direction = 'outbound'), + 'inbound', (SELECT count(*) FROM interactions i WHERE i.person_id = p.id AND i.direction = 'inbound'), + 'lastAt', (SELECT max(i.occurred_at) FROM interactions i WHERE i.person_id = p.id)), + 'discoveredAt', m.discovered_at + ) ORDER BY m.ord) + FROM members m JOIN people p ON p.id = m.person_id LEFT JOIN companies co ON co.id = p.current_company_id), '[]'::json) +); +SQL diff --git a/server.json b/server.json index 2c3ce26..5438e2b 100644 --- a/server.json +++ b/server.json @@ -8,13 +8,13 @@ "url": "https://github.com/moshcoder/pit.world", "source": "github" }, - "version": "0.4.0", + "version": "0.5.0", "packages": [ { "registryType": "npm", "registryBaseUrl": "https://registry.npmjs.org", "identifier": "pitworld", - "version": "0.4.0", + "version": "0.5.0", "runtimeHint": "bunx", "transport": { "type": "stdio" diff --git a/src/cli.ts b/src/cli.ts index 69cb5c4..4ab642e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -45,6 +45,7 @@ const HELP = `pit: private search, and the Moshpit network (https://pit.world) pit social NAME | pit bbs NAME the social feed or board of a Moshpit name pit subscribe EMAIL get the pit.world newsletter (double opt-in) pit open WORDS open the results page in your browser + pit promo ... promotion campaigns, for operators (pit promo help) pit mcp MCP server on stdio: claude mcp add pit -- pit mcp pit serve [--port N] run your own instance (SERPER_API_KEY etc.) @@ -264,6 +265,16 @@ export async function main(argv: string[], out: Out = { log: (t) => console.log( if (!json) spawn(argv[0]!, argv.slice(1), { detached: true, stdio: 'ignore' }).on('error', () => {}).unref() return 0 } + case 'promo': { + const { promoMain } = await import('./promotion-cli.js') + return await promoMain(rest.slice(1), out, client, json).catch((error) => { + if (error?.usage) { + out.error(error.message) + return 64 + } + throw error + }) + } case 'mcp': await (await import('./mcp.js')).runMcp() // Serve until the client hangs up: returning would let bin/pit exit mid-session. diff --git a/src/client.ts b/src/client.ts index e8341c8..4b4a986 100644 --- a/src/client.ts +++ b/src/client.ts @@ -118,6 +118,30 @@ export class PitClient { return { ok: Boolean(data?.ok), status: String(data?.status ?? `HTTP ${response.status}`) } } + /** + * The promotion API (/api/v1/promotion), for operators: PIT_ADMIN_TOKEN is the bearer. Text responses + * (CSV, Markdown) come back as strings. + */ + async promo(method: 'GET' | 'POST' | 'PATCH', path: string, body?: unknown, headers: Record = {}): Promise { + const token = process.env.PIT_ADMIN_TOKEN + if (!token) throw new Error('set PIT_ADMIN_TOKEN (the pit.world admin token) for promotion commands') + const response = await this.fetchImpl(`${this.base}/api/v1/promotion${path}`, { + method, + headers: { accept: 'application/json', authorization: `Bearer ${token}`, 'x-pit-actor': process.env.PIT_ACTOR ?? 'cli', ...(body !== undefined ? { 'content-type': 'application/json' } : {}), ...headers }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + signal: AbortSignal.timeout(60_000), + }) + const type = response.headers.get('content-type') ?? '' + if (!type.includes('json')) { + const text = await response.text() + if (!response.ok) throw new Error(`HTTP ${response.status}`) + return text as T + } + const data = (await response.json().catch(() => ({}))) as any + if (!response.ok) throw new Error(`${data?.error ?? `HTTP ${response.status}`}${data?.code ? ` (${data.code})` : ''}`) + return data as T + } + /** Start a newsletter signup; the address only joins after the emailed link is clicked. */ async subscribe(email: string): Promise<{ ok: boolean; status: string }> { const response = await this.fetchImpl(`${this.base}/api/subscribe`, { diff --git a/src/html.ts b/src/html.ts index 306d85d..91e111b 100644 --- a/src/html.ts +++ b/src/html.ts @@ -329,6 +329,8 @@ export function privacyPage(): string {

!gh hqtui sends you to GitHub's own search. From that moment you are on their site, under their privacy policy; pit.world only redirects you there without a referrer.

The newsletter, only if you ask

The newsletter is the one thing pit.world keeps, and only after you ask twice. When you sign up, pit.world stores your address beside a fingerprint of a one-time link and emails you that link. Nothing else about you: no IP address, no browser, no referrer. Click it and your address joins the list; don't, and both are deleted within 48 hours. Issues are sent by our newsletter tool through our email provider, and every one has a one-click unsubscribe that takes you off for good.

+

Business listings: what we count

+

For businesses pit.world is featuring in a promotion (like a local roundup), pit.world counts visits to their listing page, clicks on the link to their website, taps on their phone number and quote requests, so it can tell each business honestly what the listing did for them. Search is never counted. A visit is stored as a type, a time, the listing, which of our posts linked you there (if one did) and the site you came from (its name only, never the full address). To count distinct visitors in a day, pit.world keeps a hash of your IP address and browser under a random key that is deleted when that day ends: nothing that can be turned back into an address, and nothing that links two days. No cookie is set, and nothing from a third party is loaded. The phone-tap count comes from a few lines of pit.world's own script on the listing page.

Your account, only if you sign in

One pit.world account signs you in on every social.<name>.pit.world feed and bbs.<name>.pit.world board, so you never make an account per site. You sign in with CoinPay (OAuth 2.1). We keep your CoinPay id, the name and email CoinPay gives us, a session cookie for pit.world only, and the list of pit.world sites you signed in to, which you can see and clear on your account page. A site gets your CoinPay id and name, nothing else. Search never reads the cookie. Delete the account from the same page and it is gone; what you posted on a board or feed stays there until you delete it on that site.

Source

diff --git a/src/listings-html.ts b/src/listings-html.ts index 8290981..59bab6e 100644 --- a/src/listings-html.ts +++ b/src/listings-html.ts @@ -39,7 +39,8 @@ export function place(l: Listing): string { return [l.city, l.region, l.country].filter(Boolean).join(', ') } -function details(l: Listing): string { +function details(l: Listing, track?: { domain: string; via: string }): string { + const beacon = track ? ` data-ev="phone" data-domain="${esc(track.domain)}"${track.via ? ` data-via="${esc(track.via)}"` : ''}` : '' const where = place(l) const operator = l.operator ? (operatorHref(l.operator) ? ext(l.operator, bare(l.operator)) : `${esc(l.operator)}`) : '' const rows = [ @@ -52,7 +53,7 @@ function details(l: Listing): string { l.hours && `
  • Hours ${esc(l.hours)}
  • `, l.address && `
  • Address ${esc(l.address)}
  • `, licenseRow(l), - l.phone && `
  • Phone ${esc(l.phone)}
  • `, + l.phone && `
  • Phone ${esc(l.phone)}
  • `, l.moshpit && `
  • On Moshpit ${ext(`https://${l.moshpit}/`, l.moshpit)} · ${ext(proxyUrl(l.moshpit), `${l.moshpit}.pit.world`)} (any browser)
  • `, l.feed && `
  • Feed ${ext(l.feed, bare(l.feed))}
  • `, ...l.links.filter((u) => u !== siteUrl(l)).map((u) => `
  • ${ext(u, bare(u))}
  • `), @@ -103,8 +104,18 @@ ${state ? `

    ${esc(QUOTE_NOTES[state])}

    ` : ''} ` } -export function listingPage(l: Listing, options: { quote?: QuoteState } = {}): string { +/** + * A directory listing's website link goes through /b//go, which counts the click (for promotion + * campaigns only; see src/promotion-routes.ts) and redirects to the site. A bare unclaimed page links direct. + */ +function websiteHref(l: Listing, via: string): string { + if (!(l.claimed || l.seeded) || !l.listed) return siteUrl(l) + return `/b/${p(l.domain)}/go${via ? `?via=${p(via)}` : ''}` +} + +export function listingPage(l: Listing, options: { quote?: QuoteState; via?: string } = {}): string { const title = l.name || l.domain + const via = options.via ?? '' const claim = l.claimed ? `

    Claimed by the owner of ${esc(l.domain)}.${l.ring ? ` In the pit.world ring.` : ''} Owner? Edit this listing

    ` : l.seeded @@ -125,13 +136,14 @@ export function listingPage(l: Listing, options: { quote?: QuoteState } = {}): s noindex: !l.claimed && !l.seeded, body: `

    Business listing${l.claimed ? ' · ✓ verified owner' : l.seeded ? ' · listed by pit.world · unclaimed' : ' · unclaimed'}

    ${esc(title)}

    -

    ${esc(l.domain)}

    +

    ${esc(l.domain)}

    ${l.description ? `

    ${esc(l.description)}

    ` : l.claimed ? '' : '

    Nothing here yet beyond the name.

    '} -${details(l)} +${details(l, { domain: l.domain, via })} ${services(l)} ${l.claimed ? quoteForm(l, options.quote) : ''} -${l.claimed ? '' : l.seeded ? `

    Written by pit.world from public information, not by the business. Details can go out of date: check with ${esc(l.domain)} before you hire.

    ` : `

    Until it is claimed, this listing shows what ${esc(l.domain)}'s own homepage says about itself.

    `} -${claim}`, +${l.claimed ? '' : l.seeded ? `

    Written by pit.world from public information, not by the business. Details can go out of date: check with ${esc(l.domain)} before you hire. Something wrong? The owner can claim this page to correct it, or take it down.

    ` : `

    Until it is claimed, this listing shows what ${esc(l.domain)}'s own homepage says about itself.

    `} +${claim} +${l.listed && l.phone ? '' : ''}`, }) } diff --git a/src/listings-routes.ts b/src/listings-routes.ts index 5875228..c64ba9d 100644 --- a/src/listings-routes.ts +++ b/src/listings-routes.ts @@ -1,7 +1,7 @@ /** Routes for business listings (/b, /pros, /api/listing, /api/pros). Mounted by createApp when a Listings store is given. */ import type { Context, Hono } from 'hono' import { - claimPage, contactConfirmedPage, editPage, listingPage, listingsFeed, listingsIndexPage, prosPage, verifiedPage, verifyFailedPage, type QuoteState, + claimPage, contactConfirmedPage, editPage, listingPage, listingsFeed, listingsIndexPage, prosPage, siteUrl, verifiedPage, verifyFailedPage, type QuoteState, } from './listings-html.js' import { EDIT_FIELDS, normalizeDomain, type ClaimStart, type ContactResult, type ListingEdit, type Listings } from './listings.js' import { CATEGORIES, categoryOf, PRICES } from './pros.js' @@ -12,6 +12,18 @@ type Env = { Bindings: { ip?: string } } export type ListingHooks = { /** Something about ring membership may have changed (a claim, an edit). */ ringChanged?: () => void + /** A listing page was served (promotion measurement; src/promotion-routes.ts decides what counts). */ + viewed?: (c: Context, domain: string) => void + /** A quote request went to the owner. */ + quoted?: (c: Context, domain: string) => void + /** Someone followed a listing's website link. */ + clicked?: (c: Context, domain: string) => void +} + +/** A campaign's tracking id (?via=plc_...) carried from the page to its website link and phone beacon. */ +const viaOf = (c: Context) => { + const via = c.req.query('via') ?? '' + return /^plc_[A-Za-z0-9_-]{6,20}$/.test(via) ? via : '' } function rebuild(listings: Listings, claimId: string): ClaimStart | null { @@ -93,7 +105,19 @@ export function mountListings( if (!listing) return c.notFound() if (listing.domain !== c.req.param('domain')) return c.redirect(`/b/${encodeURIComponent(listing.domain)}`, 301) if (c.req.method === 'GET' && isPerson(c.req.header('user-agent'))) listings.view(listing.domain) - return c.html(listingPage(listing)) + if (c.req.method === 'GET') hooks.viewed?.(c, listing.domain) + return c.html(listingPage(listing, { via: viaOf(c) })) + }) + + // A directory listing's website link. The destination is the listing's own site, never a parameter, + // so this cannot be used as an open redirect. + app.get('/b/:domain/go', (c) => { + const listing = listings.toPublic(c.req.param('domain')) + if (!listing) return c.notFound() + hooks.clicked?.(c, listing.domain) + c.header('cache-control', 'no-store') + c.header('x-robots-tag', 'noindex, nofollow') + return c.redirect(siteUrl(listing), 302) }) app.post('/b/:domain/claim', (c) => { @@ -155,6 +179,7 @@ export function mountListings( // The honeypot: a field people never see. A bot that fills it is told it worked, and nothing is sent. if (typeof form.website === 'string' && form.website !== '') return show('sent') const result = await listings.requestQuote(listing.domain, form) + if (result === 'sent') hooks.quoted?.(c, listing.domain) return show(result, result === 'sent' ? 200 : result === 'limited' ? 429 : result === 'failed' ? 503 : 400) }) @@ -216,6 +241,7 @@ export function mountListings( const body = (await c.req.json().catch(() => ({}))) as Record if (typeof body.website === 'string' && body.website !== '') return c.json({ ok: true, status: 'sent' }) const result = await listings.requestQuote(body.domain, body) + if (result === 'sent' && typeof body.domain === 'string') hooks.quoted?.(c, normalizeDomain(body.domain) ?? body.domain) const status = { sent: 200, invalid: 400, undeliverable: 400, unavailable: 404, limited: 429, failed: 503 }[result] as 200 | 400 | 404 | 429 | 503 return c.json({ ok: result === 'sent', status: result }, status) }) diff --git a/src/listings.ts b/src/listings.ts index 0bbdb7f..30d476d 100644 --- a/src/listings.ts +++ b/src/listings.ts @@ -14,6 +14,7 @@ import { mkdirSync } from 'node:fs' import { dirname } from 'node:path' import { dnsMx, normalizeEmail, type Mailer, type MxLookup } from './newsletter.js' import { PRICES, categoryOf, cleanServices, normalizeOperator, normalizeWebUrl } from './pros.js' +import { ensurePromotionSchema } from './promotion-schema.js' export type Listing = { domain: string @@ -316,7 +317,8 @@ const escHtml = (value: unknown) => String(value ?? '').replace(/[&<>"']/g, (c) const words = (value: unknown) => clean(value, 120).toLowerCase().split(/\s+/).filter(Boolean) export class Listings { - private db: Database + /** Shared with the promotion store (src/promotion.ts), whose membership the directory orders by. */ + readonly db: Database private mailer: Mailer | null private mx: MxLookup private site: string @@ -346,6 +348,7 @@ export class Listings { // Quote requests are counted, never kept: a domain and a time, so the per-listing limit survives a restart. this.db.run('CREATE TABLE IF NOT EXISTS quote_log (domain TEXT NOT NULL, at INTEGER NOT NULL)') this.db.run('CREATE INDEX IF NOT EXISTS quote_log_domain_at ON quote_log (domain, at)') + ensurePromotionSchema(this.db) } /** Quote requests need somewhere to send from. */ @@ -411,6 +414,13 @@ export class Listings { return this.toListing(row, domain) } + /** A listing that is in the directory, without touching the network; null otherwise. */ + toPublic(input: unknown): Listing | null { + const domain = normalizeDomain(input) + const row = domain ? this.row(domain) : null + return row && (row.claimed_at || row.seeded_at) && !row.directory_opt_out ? this.toListing(row, domain!) : null + } + /** Claimed listings only, without touching the network: the search results card. */ claimedOnly(input: unknown): Listing | null { const domain = normalizeDomain(input) @@ -613,13 +623,18 @@ export class Listings { const category = categoryOf(query.category)?.slug ?? '' const near = words(query.near) const terms = words(query.q) - // Featured first (an editorial pick, never paid), then owner-verified listings by latest edit, then the - // ones pit.world listed itself. claimedOnly leaves those out (the feed of claims). + // Businesses a running promotion campaign features come first (in the campaign's order, then featured, + // then by name); then featured (an editorial pick, never paid), owner-verified listings by latest edit, + // and the ones pit.world listed itself. Filters below keep this order and pagination slices after them, + // so every page, the API and the mobile view agree. claimedOnly leaves seeded ones out (the feed of claims). const rows = this.db .query( query.claimedOnly ? 'SELECT * FROM listings WHERE claimed_at IS NOT NULL ORDER BY updated_at DESC' - : 'SELECT * FROM listings WHERE (claimed_at IS NOT NULL OR seeded_at IS NOT NULL) AND directory_opt_out = 0 ORDER BY featured DESC, claimed_at IS NULL, updated_at DESC, domain', + : `SELECT *, (SELECT MIN(COALESCE(m.display_order, 1000000)) FROM promo_members m JOIN promo_campaigns pc ON pc.id = m.campaign_id + WHERE m.domain = listings.domain AND m.priority = 1 AND m.state = 'verified' AND pc.state IN ('active', 'paused')) AS promo_rank + FROM listings WHERE (claimed_at IS NOT NULL OR seeded_at IS NOT NULL) AND directory_opt_out = 0 + ORDER BY promo_rank IS NULL, promo_rank, featured DESC, CASE WHEN promo_rank IS NULL THEN '' ELSE lower(name) END, claimed_at IS NULL, updated_at DESC, domain`, ) .all() as any[] const matched = rows diff --git a/src/mcp.ts b/src/mcp.ts index cd32b5a..6945c8c 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -13,7 +13,7 @@ import { CATEGORIES, PRICES } from './pros.js' export function createMcpServer(client = new PitClient()): McpServer { // Typed loosely: the SDK's generic tool() signature recurses past TypeScript's // instantiation depth with zod shapes. The shapes are still validated at runtime. - const server: any = new McpServer({ name: 'pit.world', version: '0.4.0' }) + const server: any = new McpServer({ name: 'pit.world', version: '0.5.0' }) server.tool( 'search', @@ -93,9 +93,57 @@ export function createMcpServer(client = new PitClient()): McpServer { content: [{ type: 'text', text: JSON.stringify(await client.network(), null, 2) }], })) + // Promotion campaigns, for operators: the same API (and so the same timing rules, budgets and + // recipient checks) as the CLI and the dashboard. Only offered when PIT_ADMIN_TOKEN is in the environment. + if (process.env.PIT_ADMIN_TOKEN) registerPromoTools(server, client) return server } +function registerPromoTools(server: any, client: PitClient): void { + const promo = (method: 'GET' | 'POST' | 'PATCH', path: string, body?: unknown, headers?: Record) => + client.promo(method, path, body, headers).then((r) => ({ content: [{ type: 'text', text: typeof r === 'string' ? r : JSON.stringify(r, null, 2) }] })) + const e = encodeURIComponent + const cid = { campaign: z.string().min(3).max(80).describe('promotion campaign id (pcm_...) or its OutreachGraph campaign id') } + const member = { ...cid, domain: z.string().min(3).max(253) } + server.tool('promo_campaigns', 'Promotion campaigns (operators): list them.', {}, () => promo('GET', '/campaigns')) + server.tool('promo_overview', 'A promotion campaign at a glance: members by state, outreach holds, placements by state, measurement windows, reports, drafts, spend.', cid, ({ campaign }: any) => promo('GET', `/campaigns/${e(campaign)}`)) + server.tool('promo_roster', 'The campaign roster: every business, its source lead ids, website, listing URL, verification status and planned action, plus unresolved leads. Private (contains lead ids).', { ...cid, limit: z.number().int().min(1).max(500).optional(), offset: z.number().int().min(0).optional() }, ({ campaign, limit, offset }: any) => + promo('GET', `/campaigns/${e(campaign)}/businesses?limit=${limit ?? 500}&offset=${offset ?? 0}`), + ) + server.tool('promo_import', 'Import a complete or partial campaign snapshot (the JSON scripts/outreachgraph-campaign-export.sh writes). dryRun shows every row disposition without writing. Re-importing the same snapshot changes nothing.', { ...cid, snapshot: z.any(), dryRun: z.boolean().optional() }, ({ campaign, snapshot, dryRun }: any) => + promo('POST', `/campaigns/${e(campaign)}/imports${dryRun ? '?dryRun=1' : ''}`, { snapshot }, dryRun ? {} : { 'idempotency-key': `import-${snapshot?.snapshotAt ?? Date.now()}` }), + ) + server.tool('promo_member', 'One campaign business: leads, outreach hold, verification, windows.', member, ({ campaign, domain }: any) => promo('GET', `/campaign-businesses/${e(`${campaign}:${domain}`)}`)) + server.tool( + 'promo_update_member', + 'Resolve a campaign business: displayOrder (campaign order, null to clear), priority (false removes its directory priority without touching the listing), priorContact (a documented earlier contact, which makes the draft a follow-up), holdAttestation (an operator attests upstream outreach is held).', + { ...member, displayOrder: z.number().int().nullable().optional(), priority: z.boolean().optional(), priorContact: z.string().max(500).optional(), holdAttestation: z.string().max(500).optional() }, + ({ campaign, domain, ...changes }: any) => promo('PATCH', `/campaign-businesses/${e(`${campaign}:${domain}`)}`, changes), + ) + server.tool('promo_exclude', 'Exclude a business (domain) or a lead (lead id) from the campaign, with a reason.', { ...cid, subject: z.string().min(3), reason: z.string().min(3) }, ({ campaign, subject, reason }: any) => promo('POST', `/campaigns/${e(campaign)}/exclusions`, { subject, reason })) + server.tool('promo_preview', 'Preview a launch: which businesses launch, which are blocked and why, every planned post with its text and time, and the version launch needs.', cid, ({ campaign }: any) => promo('POST', `/campaigns/${e(campaign)}/preview`, {})) + server.tool('promo_launch', 'Launch with the version from promo_preview: publishes the roundup and creates the posting calendar. Refuses a stale preview.', { ...cid, version: z.string().min(8) }, ({ campaign, version }: any) => + promo('POST', `/campaigns/${e(campaign)}/launch`, { version }, { 'idempotency-key': `launch-${version}` }), + ) + server.tool('promo_pause', 'Pause (no new posts or sends; reports and listings stay), resume (active) or stop a campaign.', { ...cid, state: z.enum(['paused', 'active', 'stopped']) }, ({ campaign, state }: any) => promo('POST', `/campaigns/${e(campaign)}/pause`, { state })) + server.tool('promo_placements', 'Posts and pages of a campaign with state (scheduled, published, verified, failed, uncertain...), public URL, evidence and error.', { ...cid, state: z.string().optional(), domain: z.string().optional() }, ({ campaign, state, domain }: any) => { + const q = new URLSearchParams({ limit: '500' }) + if (state) q.set('state', state) + if (domain) q.set('domain', domain) + return promo('GET', `/campaigns/${e(campaign)}/placements?${q}`) + }) + server.tool('promo_retry_placement', 'Put a failed placement back in line (at most three attempts).', { placement: z.string().min(6) }, ({ placement }: any) => promo('POST', `/placements/${e(placement)}/retry`, {})) + server.tool('promo_results', 'One business: its measurement windows and the numbers so far (views, website clicks, phone taps, views from campaign links), with the latest report.', member, ({ campaign, domain }: any) => promo('GET', `/campaign-businesses/${e(`${campaign}:${domain}`)}/results`)) + server.tool('promo_reports', 'Reports of a campaign; generate=true first runs the hourly pass and writes reports for every window that has ended.', { ...cid, generate: z.boolean().optional() }, async ({ campaign, generate }: any) => { + if (generate) await client.promo('POST', `/campaigns/${e(campaign)}/reports`, {}) + return promo('GET', `/campaigns/${e(campaign)}/reports`) + }) + server.tool('promo_report', 'One report (frozen snapshot, versioned) as JSON or Markdown.', { report: z.string().min(6), format: z.enum(['json', 'md']).optional() }, ({ report, format }: any) => promo('GET', `/reports/${e(report)}${format === 'md' ? '.md' : ''}`)) + server.tool('promo_drafts', 'Outreach drafts of a campaign (written from reports after windows end; never sent unless sending is authorized).', cid, ({ campaign }: any) => promo('GET', `/campaigns/${e(campaign)}/drafts`)) + server.tool('promo_create_draft', 'Write the outreach draft for a report (one per report version).', { report: z.string().min(6) }, ({ report }: any) => promo('POST', `/reports/${e(report)}/outreach-draft`, {})) + server.tool('promo_deliver', 'Send one draft. Refused unless the campaign authorizes sending, the window has ended, the report is current and the recipient is not suppressed upstream.', { draft: z.string().min(6) }, ({ draft }: any) => promo('POST', `/drafts/${e(draft)}/deliver`, {})) +} + export async function runMcp(): Promise { await createMcpServer().connect(new StdioServerTransport()) } diff --git a/src/promotion-cli.ts b/src/promotion-cli.ts new file mode 100644 index 0000000..4baa452 --- /dev/null +++ b/src/promotion-cli.ts @@ -0,0 +1,242 @@ +/** + * `pit promo ...`: promotion campaigns from the terminal. Every command calls the promotion API with + * PIT_ADMIN_TOKEN, the same methods the web dashboard and the MCP tools use. + */ +import { readFileSync } from 'node:fs' +import type { PitClient } from './client.js' +import { localMyna, workOnce } from './promotion-worker.js' + +export const PROMO_HELP = `pit promo: promotion campaigns (operators; needs PIT_ADMIN_TOKEN) + + pit promo campaigns every campaign + pit promo create --source-ref cmp_... --name N --slug S --title T [--intro TEXT] + pit promo show ID overview: members, holds, posts, windows, reports + pit promo configure ID --json '{"spotlightAccounts":[...]}' + pit promo import ID SNAPSHOT.json [--dry-run] + a complete OutreachGraph snapshot + (scripts/outreachgraph-campaign-export.sh) + pit promo imports ID import runs and their totals + pit promo rows IMPORT_ID every source row and its disposition + pit promo roster ID [--limit N --offset N] + the businesses, first: lead ids, site, listing, action + pit promo member ID DOMAIN one business: leads, hold, windows + pit promo set ID DOMAIN [--order N] [--priority on|off] [--prior-contact TEXT] [--attest TEXT] + pit promo exclude ID DOMAIN|LEAD --reason TEXT + pit promo reverify ID re-check members against their listings + pit promo preview ID what launch would do, and its version + pit promo launch ID --version V create the roundup and the calendar + pit promo pause|resume|stop ID + pit promo placements ID [--state S] [--domain D] + pit promo retry PLACEMENT_ID + pit promo test-tracking ID [DOMAIN ...] marked test view + click per business, then check + pit promo worker ID [--once] [--every 10m] + post due spotlights with myna and verify them + pit promo tick run the hourly pass now + pit promo periods ID measurement windows + pit promo results ID DOMAIN one business's numbers so far + pit promo reports ID [--generate] reports (generate due ones) + pit promo report REPORT_ID [--md|--html] + pit promo csv ID the comparison, as CSV + pit promo drafts ID outreach drafts + pit promo draft REPORT_ID make the draft for a report + pit promo deliver DRAFT_ID send it (only if sending is authorized) + pit promo gap ID --kind K --from ISO [--to ISO] [--domain D] [--note TEXT] + pit promo audit ID + pit promo tui ID browse it in the terminal +` + +type Out = { log(text: string): void; error(text: string): void } + +function flags(args: string[]): { f: Record; rest: string[] } { + const f: Record = {} + const rest: string[] = [] + for (let i = 0; i < args.length; i++) { + const m = /^--([a-z][a-z-]*)(?:=(.*))?$/.exec(args[i]!) + if (!m) rest.push(args[i]!) + else if (m[2] !== undefined) f[m[1]!] = m[2] + else if (args[i + 1] === undefined || args[i + 1]!.startsWith('--')) f[m[1]!] = 'true' + else f[m[1]!] = args[++i]! + } + return { f, rest } +} + +const e = encodeURIComponent +const day = (ms: number | null) => (ms ? new Date(ms).toISOString().replace('T', ' ').slice(0, 16) : '-') + +export async function promoMain(args: string[], out: Out, client: PitClient, json: boolean): Promise { + const [command, ...more] = args + const { f, rest } = flags(more) + const id = rest[0] ?? '' + const show = (value: unknown, text?: () => void) => { + if (json || !text) out.log(typeof value === 'string' ? value : JSON.stringify(value, null, 2)) + else text() + return 0 + } + const need = (ok: unknown, usage: string) => { + if (!ok) throw Object.assign(new Error(`usage: pit promo ${usage}`), { usage: true }) + } + switch (command) { + case undefined: + case 'help': + out.log(PROMO_HELP) + return 0 + case 'campaigns': { + const r = await client.promo('GET', '/campaigns') + return show(r, () => r.campaigns.forEach((c: any) => out.log(`${c.id} ${c.state.padEnd(8)} ${c.name} (${c.source}, /roundup/${c.slug})`))) + } + case 'create': { + need(f['source-ref'] && f.name && f.slug && f.title, 'create --source-ref cmp_... --name N --slug S --title T [--intro TEXT]') + return show(await client.promo('POST', '/campaigns', { name: f.name, source: f.source ?? 'outreachgraph', sourceRef: f['source-ref'], config: { slug: f.slug, title: f.title, intro: f.intro ?? '' } })) + } + case 'show': { + need(id, 'show ID') + const o = await client.promo('GET', `/campaigns/${e(id)}`) + return show(o, () => { + out.log(`${o.campaign.name} ${o.campaign.state} ${o.campaign.source}`) + for (const k of ['members', 'holds', 'placements', 'periods', 'drafts']) out.log(` ${k.padEnd(11)} ${Object.entries(o[k]).map(([a, n]) => `${a} ${n}`).join(' · ') || 'none'}`) + out.log(` tracking ${o.trackingTested} tested · ${o.events} counted events · ${o.reports} reports`) + }) + } + case 'configure': { + need(id && f.json, `configure ID --json '{...}'`) + return show(await client.promo('PATCH', `/campaigns/${e(id)}`, { config: JSON.parse(f.json!) })) + } + case 'import': { + need(id && rest[1], 'import ID SNAPSHOT.json [--dry-run]') + const snapshot = JSON.parse(readFileSync(rest[1]!, 'utf8')) + const r = await client.promo('POST', `/campaigns/${e(id)}/imports${f['dry-run'] ? '?dryRun=1' : ''}`, { snapshot }, f['dry-run'] ? {} : { 'idempotency-key': `import-${snapshot.snapshotAt}` }) + return show(r, () => out.log(`${r.repeated ? 'already imported' : f['dry-run'] ? 'dry run' : `imported ${r.importId}`}: ${r.sourceRows} rows = ${r.matched} matched + ${r.merged} merged into the same business + ${r.unresolved} unresolved + ${r.excluded} excluded; ${r.businesses} businesses (${r.verified} verified, ${r.unverified} need a listing fix), ${r.holdConflicts} hold conflicts. ${r.complete ? 'Complete snapshot.' : 'Partial snapshot: nothing removed.'}`)) + } + case 'imports': + need(id, 'imports ID') + return show(await client.promo('GET', `/campaigns/${e(id)}/imports`)) + case 'rows': + need(id, 'rows IMPORT_ID') + return show(await client.promo('GET', `/imports/${e(id)}/rows`)) + case 'roster': { + need(id, 'roster ID') + const r = await client.promo('GET', `/campaigns/${e(id)}/businesses?limit=${f.limit ?? 500}&offset=${f.offset ?? 0}`) + return show(r, () => { + out.log('Source lead IDs | Business | Website | Listing | Verification | Planned action') + for (const b of r.businesses) out.log(`${b.leadIds.join(' ')} | ${b.business} | ${b.website} | ${b.listingUrl ?? '(none)'} | ${b.verification}${b.reason ? ` (${b.reason})` : ''} | ${b.plannedAction}`) + for (const u of r.unresolvedLeads) out.log(`${u.leadId} | ${u.name} | - | - | unresolved (${u.reason}) | resolve identity first`) + out.log(`${r.total} businesses${r.unresolvedLeads.length ? ` + ${r.unresolvedLeads.length} unresolved leads` : ''}`) + }) + } + case 'member': + need(id && rest[1], 'member ID DOMAIN') + return show(await client.promo('GET', `/campaign-businesses/${e(`${id}:${rest[1]}`)}`)) + case 'set': { + need(id && rest[1], 'set ID DOMAIN [--order N] [--priority on|off] [--prior-contact TEXT] [--attest TEXT]') + const changes: Record = {} + if (f.order !== undefined) changes.displayOrder = f.order === 'none' ? null : Number(f.order) + if (f.priority !== undefined) changes.priority = f.priority === 'on' + if (f['prior-contact'] !== undefined) changes.priorContact = f['prior-contact'] + if (f.attest !== undefined) changes.holdAttestation = f.attest + return show(await client.promo('PATCH', `/campaign-businesses/${e(`${id}:${rest[1]}`)}`, changes)) + } + case 'exclude': + need(id && rest[1] && f.reason, 'exclude ID DOMAIN|LEAD --reason TEXT') + return show(await client.promo('POST', `/campaigns/${e(id)}/exclusions`, { subject: rest[1], reason: f.reason })) + case 'reverify': + need(id, 'reverify ID') + return show(await client.promo('POST', `/campaigns/${e(id)}/reverify`, {})) + case 'preview': { + need(id, 'preview ID') + const p = await client.promo('POST', `/campaigns/${e(id)}/preview`, {}) + return show(p, () => { + out.log(`version ${p.version}: ${p.launchable.length} businesses launch, ${p.blocked.length} blocked, ${p.placements.length} placements`) + for (const b of p.blocked) out.log(` blocked ${b.domain}: ${b.reason}`) + for (const x of p.placements.slice(0, 8)) out.log(` ${x.plannedAt} ${x.kind.padEnd(12)} ${x.account}\n ${x.body.replace(/\n+/g, ' / ')}`) + if (p.placements.length > 8) out.log(` ... ${p.placements.length - 8} more (--json for all)`) + out.log(`launch: pit promo launch ${id} --version ${p.version}`) + }) + } + case 'launch': + need(id && f.version, 'launch ID --version V') + return show(await client.promo('POST', `/campaigns/${e(id)}/launch`, { version: f.version }, { 'idempotency-key': `launch-${f.version}` })) + case 'pause': + case 'resume': + case 'stop': + need(id, `${command} ID`) + return show(await client.promo('POST', `/campaigns/${e(id)}/pause`, { state: command === 'resume' ? 'active' : command === 'stop' ? 'stopped' : 'paused' })) + case 'placements': { + need(id, 'placements ID [--state S] [--domain D]') + const q = new URLSearchParams({ limit: f.limit ?? '500' }) + if (f.state) q.set('state', f.state) + if (f.domain) q.set('domain', f.domain) + const r = await client.promo('GET', `/campaigns/${e(id)}/placements?${q}`) + return show(r, () => r.placements.forEach((p: any) => out.log(`${p.id} ${day(p.plannedAt)} ${p.state.padEnd(10)} ${p.kind.padEnd(12)} ${p.account} ${p.publicUrl || ''}${p.error ? ` ! ${p.error}` : ''}`))) + } + case 'retry': + need(id, 'retry PLACEMENT_ID') + return show(await client.promo('POST', `/placements/${e(id)}/retry`, {})) + case 'test-tracking': { + need(id, 'test-tracking ID [DOMAIN ...]') + let domains = rest.slice(1) + if (!domains.length) domains = (await client.promo('GET', `/campaigns/${e(id)}/businesses?limit=500`)).businesses.filter((b: any) => b.verification === 'verified').map((b: any) => new URL(b.website).hostname) + const ua = 'Mozilla/5.0 (X11; Linux x86_64) pit-promo-tracking-test' + for (const d of domains) { + await fetch(`${client.base}/b/${e(d)}?pt=test`, { headers: { 'user-agent': ua, 'x-pit-test': '1' } }) + await fetch(`${client.base}/b/${e(d)}/go?pt=test`, { headers: { 'user-agent': ua, 'x-pit-test': '1' }, redirect: 'manual' }) + } + const r = await client.promo('POST', `/campaigns/${e(id)}/tracking-check`, { domains }) + return show(r, () => { + out.log(`tracking test passed for ${r.passed.length} of ${domains.length}`) + for (const x of r.failed) out.log(` failed ${x.domain}: missing ${x.missing.join(', ')}`) + }) || (r.failed.length ? 1 : 0) + } + case 'worker': { + need(id, 'worker ID [--once] [--every 10m]') + const myna = localMyna() + const every = Math.max(60, Number(/^(\d+)m$/.exec(f.every ?? '10m')?.[1] ?? 10) * 60) * 1000 + for (;;) { + const results = await workOnce(client, id, myna) + for (const r of results) out.log(`${new Date().toISOString()} ${r.placement} ${r.action}${r.state ? ` -> ${r.state}` : ''}${r.detail ? ` ${r.detail}` : ''}`) + if (f.once) return results.some((r) => r.action === 'error') ? 1 : 0 + await new Promise((resolve) => setTimeout(resolve, every)) + } + } + case 'tick': + return show(await client.promo('POST', '/tick', {})) + case 'periods': + need(id, 'periods ID') + return show(await client.promo('GET', `/campaigns/${e(id)}/periods`)) + case 'results': + need(id && rest[1], 'results ID DOMAIN') + return show(await client.promo('GET', `/campaign-businesses/${e(`${id}:${rest[1]}`)}/results`)) + case 'reports': + need(id, 'reports ID [--generate]') + if (f.generate) await client.promo('POST', `/campaigns/${e(id)}/reports`, {}) + return show(await client.promo('GET', `/campaigns/${e(id)}/reports`)) + case 'report': + need(id, 'report REPORT_ID [--md|--html]') + return show(await client.promo('GET', `/reports/${e(id)}${f.md ? '.md' : f.html ? '.html' : ''}`)) + case 'csv': + need(id, 'csv ID') + out.log(await client.promo('GET', `/campaigns/${e(id)}/reports.csv`)) + return 0 + case 'drafts': + need(id, 'drafts ID') + return show(await client.promo('GET', `/campaigns/${e(id)}/drafts`)) + case 'draft': + need(id, 'draft REPORT_ID') + return show(await client.promo('POST', `/reports/${e(id)}/outreach-draft`, {})) + case 'deliver': + need(id, 'deliver DRAFT_ID') + return show(await client.promo('POST', `/drafts/${e(id)}/deliver`, {})) + case 'gap': + need(id && f.kind && f.from, 'gap ID --kind K --from ISO [--to ISO] [--domain D] [--note TEXT]') + return show(await client.promo('POST', `/campaigns/${e(id)}/gaps`, { kind: f.kind, from: Date.parse(f.from!), ...(f.to ? { to: Date.parse(f.to) } : {}), ...(f.domain ? { domain: f.domain } : {}), note: f.note ?? '' })) + case 'audit': + need(id, 'audit ID') + return show(await client.promo('GET', `/campaigns/${e(id)}/audit`)) + case 'tui': + need(id, 'tui ID') + await (await import('./promotion-tui.js')).runPromoTui(client, id) + return 0 + default: + out.error(`pit promo: unknown command ${command}\n\n${PROMO_HELP}`) + return 64 + } +} diff --git a/src/promotion-html.ts b/src/promotion-html.ts new file mode 100644 index 0000000..9564786 --- /dev/null +++ b/src/promotion-html.ts @@ -0,0 +1,176 @@ +/** Pages for promotion campaigns: the public roundup, a business's shareable report, and the operator's dashboard. */ +import { docPage, esc } from './html.js' +import type { Listing } from './listings.js' +import { place } from './listings-html.js' +import type { Metric, Promotion, ReportData } from './promotion.js' + +const p = (s: string) => encodeURIComponent(s) +const when = (ms: number | null | undefined, tz = 'America/Los_Angeles') => + ms ? new Intl.DateTimeFormat('en-US', { timeZone: tz, month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', timeZoneName: 'short' }).format(new Date(ms)) : '-' + +/** The roundup: one section per business, each linking to its listing with the roundup's tracking id. */ +export function roundupPage(options: { title: string; intro: string; slug: string; placementId: string; listings: Listing[]; updatedAt: number | null }): string { + const sections = options.listings + .map((l) => { + const where = [l.area, place(l)].filter(Boolean).join(' · ') + return `
    +

    ${esc(l.name || l.domain)}

    + ${where ? `

    ${esc(where)}

    ` : ''} + ${l.description ? `

    ${esc(l.description)}

    ` : ''} + ${l.services.length ? `

    ${esc(l.services.slice(0, 6).join(' · '))}

    ` : ''} + ${l.license ? `

    ${esc(l.license)}${l.licenseCheckedAt ? `, active when pit.world checked on ${esc(new Date(l.licenseCheckedAt).toISOString().slice(0, 10))}` : ''}

    ` : ''} +

    Listing and contact →

    +
    ` + }) + .join('\n') + return docPage({ + title: `${options.title} · pit.world`, + description: `${options.listings.length} local contractors, each with a license pit.world checked. No paid placement.`, + path: `/roundup/${p(options.slug)}`, + body: `

    Roundup · pro directory

    +

    ${esc(options.title)}

    +

    ${esc(options.intro)}

    +

    ${options.listings.length} businesses. Nobody paid to be here and none of them asked: pit.world wrote these from each business's own website and its state license record. Order is pit.world's, not a ranking. Something wrong about yours? Use "Claim this listing" on its page to correct it, or to take it down.

    +${sections} +

    More pros: pit.world/pros/construction.

    `, + }) +} + +const metricRow = (label: string, m: Metric | undefined) => + m ? `${esc(label)}${m.value === null ? 'unavailable' : esc(String(m.value))}${esc(m.definition)}${m.note ? `. ${esc(m.note)}` : ''} (${esc(m.source)})` : '' + +const METRIC_LABELS: Array<[string, string]> = [ + ['listingViews', 'Listing views'], + ['visitorDays', 'Visitor-days'], + ['uniqueVisitors', 'Unique visitors'], + ['websiteClicks', 'Website clicks'], + ['phoneClicks', 'Phone taps'], + ['emailClicks', 'Email taps'], + ['verifiedInquiries', 'Verified inquiries'], + ['verifiedPlacements', 'Verified placements'], + ['platformImpressions', 'Platform impressions'], +] + +/** A report as HTML. `shared` leaves out what is for the operator only (spend, effort). */ +export function reportPage(d: ReportData, version: number, shared: boolean): string { + return docPage({ + title: `${d.business.name}: results ${d.period.startLocal} to ${d.period.endLocal} · pit.world`, + description: `Listing results for ${d.business.name}.`, + path: '/r', + noindex: true, + body: `

    Results · report v${version}

    +

    ${esc(d.business.name)}

    +

    ${esc(d.business.listingUrl)}

    +

    ${esc(d.period.startLocal)} to ${esc(d.period.endLocal)} (${esc(d.period.timezone)}): ${esc(d.period.startedAt)} to ${esc(d.period.endsAt)} UTC.

    +

    Where it was promoted

    +${d.placements.length ? `
      ${d.placements.map((x) => `
    • ${esc(x.kind)} on ${esc(x.channel === 'pit.world' ? 'pit.world' : x.account)}: ${esc(x.url)}${x.sharedWith > 1 ? ` (shared with ${x.sharedWith - 1} other businesses; its audience is not counted as yours)` : ''}
    • `).join('')}
    ` : '

    No verified placement.

    '} +

    What was measured

    +${METRIC_LABELS.map(([k, label]) => metricRow(label, d.metrics[k])).join('')}
    +

    Promotion versus everything else

    +

    Views that came from one of the campaign's own links: ${d.referrals.promotionViews}. Clicks to your website from those visits: ${d.referrals.promotionClicks}. Views and clicks we cannot attribute (search, direct, other sites): ${d.referrals.unattributedViews} and ${d.referrals.unattributedClicks}. We do not claim the promotion caused the rest.

    +

    Left out, and gaps

    +

    ${Object.entries(d.exclusions).length ? Object.entries(d.exclusions).map(([k, n]) => `${esc(k)}: ${n}`).join(' · ') : 'Nothing was excluded.'} Crawlers, link previews, prefetches, our own traffic and marked tests are not counted; this filter is imperfect and what remains is not proven to be people.

    +${d.gaps.length ? `
      ${d.gaps.map((g) => `
    • ${esc(g.kind)} from ${esc(g.from)} to ${esc(g.to ?? 'ongoing')}: ${esc(g.note)}
    • `).join('')}
    ` : '

    No recorded downtime or tracking gaps.

    '} +${shared ? '' : `

    Costs (operator only)

    Paid media: $${(d.economics.paidCents / 100).toFixed(2)} · AI: $${(d.economics.aiCents / 100).toFixed(2)} · operator time: ${d.economics.operatorMinutes ?? 'not recorded'}

    `} +

    Next step

    +

    ${esc(d.nextStep)}

    +

    Generated ${esc(d.cutoff)} under rules ${esc(d.rulesVersion)}. Numbers come from pit.world's own records: no cookies, no IP addresses kept.

    `, + }) +} + +export function reportMarkdown(d: ReportData, version: number, shared: boolean): string { + const rows = METRIC_LABELS.filter(([k]) => d.metrics[k]).map(([k, label]) => { + const m = d.metrics[k]! + return `| ${label} | ${m.value === null ? 'unavailable' : m.value} | ${m.definition}${m.note ? `. ${m.note}` : ''} (${m.source}) |` + }) + return [ + `# ${d.business.name}: results, report v${version}`, + '', + `Listing: ${d.business.listingUrl}`, + `Window: ${d.period.startLocal} to ${d.period.endLocal} (${d.period.timezone}); ${d.period.startedAt} to ${d.period.endsAt} UTC`, + '', + '## Placements', + ...(d.placements.length ? d.placements.map((x) => `- ${x.kind} on ${x.channel === 'pit.world' ? 'pit.world' : x.account}: ${x.url}${x.sharedWith > 1 ? ` (shared with ${x.sharedWith - 1} others)` : ''}`) : ['- none verified']), + '', + '## Metrics', + '| Metric | Value | Definition |', + '| --- | --- | --- |', + ...rows, + '', + `Promotion-referred views ${d.referrals.promotionViews}, clicks ${d.referrals.promotionClicks}; unattributed views ${d.referrals.unattributedViews}, clicks ${d.referrals.unattributedClicks}.`, + `Excluded: ${Object.entries(d.exclusions).map(([k, n]) => `${k} ${n}`).join(', ') || 'none'}. Gaps: ${d.gaps.map((g) => `${g.kind} ${g.from} to ${g.to ?? 'ongoing'} (${g.note})`).join('; ') || 'none recorded'}.`, + ...(shared ? [] : ['', `Costs (operator only): paid $${(d.economics.paidCents / 100).toFixed(2)}, AI $${(d.economics.aiCents / 100).toFixed(2)}, operator time ${d.economics.operatorMinutes ?? 'not recorded'}.`]), + '', + `Next step: ${d.nextStep}`, + '', + `Generated ${d.cutoff}, rules ${d.rulesVersion}.`, + '', + ].join('\n') +} + +/** What a shared report may carry: everything but the operator's economics. */ +export function sharedReport(d: ReportData): Omit { + const { economics: _economics, ...rest } = d + return rest +} + +/** The operator dashboard: the actual businesses first, then the calendar, windows, reports and drafts. */ +export function adminPage(promo: Promotion, campaignId: string, message = ''): string { + const o = promo.overview(campaignId) + const c = promo.campaign(campaignId) + const tz = c.config.timezone + const roster = promo.roster(campaignId).businesses + const placements = promo.placements(campaignId, { limit: 500 }).placements + const periods = promo.periods(campaignId) + const drafts = promo.drafts(campaignId) + const counts = (obj: Record) => Object.entries(obj).map(([k, n]) => `${esc(k)} ${n}`).join(' · ') || 'none' + const action = (name: string, label: string, extra = '') => `
    ${extra}
    ` + return docPage({ + title: `${c.name} · promotion · pit.world`, + description: 'Promotion campaign dashboard.', + path: `/admin/promo/${p(c.id)}`, + noindex: true, + body: `

    Promotion campaign · ${esc(o.campaign.state)}

    +

    ${esc(c.name)}

    +${message ? `

    ${esc(message)}

    ` : ''} +

    Source ${esc(o.campaign.source)} · launched ${esc(when(c.launchedAt, tz))} · roundup /roundup/${esc(c.config.slug)}

    +

    ${c.state === 'active' ? action('pause', 'Pause campaign') : c.state === 'paused' ? action('resume', 'Resume') : ''} ${action('tick', 'Run the hourly pass now')} ${action('reports', 'Generate due reports')}

    +

    Businesses (${roster.length})

    +

    Members: ${counts(o.members)} · outreach holds: ${counts(o.holds)} · tracking tested ${o.trackingTested}

    +
    +${roster.map((b) => ``).join('')} +
    BusinessStatusHoldAction
    ${b.listingUrl ? `${esc(b.business)}` : esc(b.business)}
    ${esc(b.website)} · ${b.leadIds.length} lead${b.leadIds.length === 1 ? '' : 's'}
    ${esc(b.verification)}
    ${esc(b.reason)}
    ${esc(b.hold)}${esc(b.plannedAction)}
    +

    Promotion calendar (${placements.length})

    +

    ${counts(o.placements)}. Scheduled, failed and uncertain posts are not counted as published; only verified ones start a window.

    +
    +${placements.map((x) => ``).join('')} +
    WhenWhatStateLink
    ${esc(when(x.plannedAt, tz))}${esc(x.kind)} · ${esc(x.account)}
    ${esc(x.businesses.length > 3 ? `${x.businesses.length} businesses` : x.businesses.join(', '))}
    ${esc(x.state)}${x.error ? `
    ${esc(x.error)}` : ''}${x.state === 'failed' ? action(`placements/${p(x.id)}/retry`, 'Retry') : ''}
    ${x.publicUrl ? `${esc(x.publicUrl.replace(/^https?:\/\//, '').slice(0, 60))}` : '-'}
    +

    Measurement windows (${periods.length})

    +

    ${counts(o.periods)}. ${c.config.periodDays} days each, from the moment the listing was live, tracking had passed a test, and a placement naming the business was verified.

    +
    +${periods.map((x: any) => ``).join('')} +
    BusinessFromToState
    ${esc(x.domain)}${esc(when(x.started_at, tz))}${esc(when(x.ends_at, tz))}${esc(x.state)}
    +

    Reports and drafts

    +

    ${o.reports} current reports · drafts: ${counts(o.drafts)} · sending ${c.config.sendAuthorized ? 'authorized' : 'off (drafts only)'} · spend: paid $${(o.spend.paidCents / 100).toFixed(2)}, AI $${(o.spend.aiCents / 100).toFixed(2)} · comparison CSV

    +${drafts.length ? `
      ${drafts.map((d) => `
    • ${esc(d.domain)} · ${esc(d.kind)} · ${esc(d.state)}${d.reason ? ` (${esc(d.reason)})` : ''} · to ${esc(d.recipientName || '-')}
    • `).join('')}
    ` : '

    No drafts yet: they are written when a window ends.

    '}`, + }) +} + +export function adminMemberPage(promo: Promotion, campaignId: string, domain: string): string { + const c = promo.campaign(campaignId) + const r = promo.results(campaignId, domain) + const placements = promo.placements(campaignId, { domain }).placements + return docPage({ + title: `${r.name || domain} · promotion · pit.world`, + description: 'Promotion results for one business.', + path: `/admin/promo/${p(c.id)}/b/${p(domain)}`, + noindex: true, + body: `

    ${esc(c.name)}

    +

    ${esc(r.name || domain)}

    +

    ${esc(r.listingUrl)} · ${esc(r.state)} · hold ${esc(r.hold)} (${esc(r.holdDetail)})

    +

    Placements

    +
      ${placements.map((x) => `
    • ${esc(x.kind)} · ${esc(x.account)} · ${esc(x.state)} · ${esc(when(x.plannedAt, c.config.timezone))}${x.publicUrl ? ` · post` : ''}${x.error ? ` · ${esc(x.error)}` : ''}
    • `).join('')}
    +

    Windows

    +${r.windows.length ? r.windows.map((w: any) => `

    ${esc(when(w.started_at, c.config.timezone))} to ${esc(when(w.ends_at, c.config.timezone))} · ${esc(w.state)} · so far: ${w.soFar.listingViews} views, ${w.soFar.websiteClicks} website clicks, ${w.soFar.phoneClicks} phone taps, ${w.soFar.promotionViews} views from campaign links${w.report ? ` · report v${w.report.version}` : ''}

    `).join('') : '

    No window yet.

    '}`, + }) +} diff --git a/src/promotion-routes.ts b/src/promotion-routes.ts new file mode 100644 index 0000000..6123c4f --- /dev/null +++ b/src/promotion-routes.ts @@ -0,0 +1,256 @@ +/** + * Routes for promotion campaigns. + * + * Public: /roundup/, /b//go (the website link, counted once, then a redirect to the + * listing's own site and nowhere else), POST /b//e (the phone-tap beacon) and /r/ (a + * business's shared report, also .md and .json). + * + * Operator: /api/v1/promotion/* with Authorization: Bearer $PIT_ADMIN_TOKEN, and /admin/promo/* for a + * signed-in pit.world admin. The CLI, the TUI and the MCP tools call the same API. + */ +import type { Context, Hono } from 'hono' +import { normalizeDomain, type Listings } from './listings.js' +import { isPerson } from './listings-routes.js' +import { adminAuthorized } from './newsletter.js' +import { adminMemberPage, adminPage, reportMarkdown, reportPage, roundupPage, sharedReport } from './promotion-html.js' +import { PromoError, type Promotion } from './promotion.js' +import { RateLimiter } from './ratelimit.js' + +type Env = { Bindings: { ip?: string } } + +export type PromotionDeps = { + promo: Promotion + listings: Listings + /** Bearer token for the API; unset means the API answers 401. */ + adminToken?: string + /** A signed-in pit.world admin (for the web dashboard). */ + isAdmin?: (c: Context) => boolean + /** Addresses whose traffic is ours (PIT_INTERNAL_IPS): recorded as excluded. */ + internalIps?: string[] +} + +/** Why an event does not count, if it does not: the request itself is never stored. */ +export function exclusionOf(c: Context, internal: string[]): string { + const ua = c.req.header('user-agent') + if (!isPerson(ua)) return 'bot' + const purpose = `${c.req.header('sec-purpose') ?? ''} ${c.req.header('purpose') ?? ''} ${c.req.header('x-moz') ?? ''}`.toLowerCase() + if (purpose.includes('prefetch') || purpose.includes('prerender')) return 'prefetch' + if (c.env?.ip && internal.includes(c.env.ip)) return 'internal' + return '' +} + +const isTest = (c: Context) => c.req.query('pt') === 'test' || c.req.header('x-pit-test') === '1' + +export function mountPromotion(app: Hono, d: PromotionDeps) { + const { promo, listings } = d + const internal = d.internalIps ?? [] + const beaconLimiter = new RateLimiter(30, 0.5) + + /** Record an event for this request, if the business is in a campaign. Never throws: measurement must not break a page. */ + const track = (c: Context, type: Parameters[0]['type'], domain: string | null, extra: { campaignId?: string; id?: string; via?: string | null } = {}) => { + try { + promo.record({ + type, + domain, + via: extra.via ?? c.req.query('via') ?? null, + referer: c.req.header('referer') ?? null, + ip: c.env?.ip ?? '', + userAgent: c.req.header('user-agent') ?? '', + test: isTest(c), + excluded: exclusionOf(c, internal), + ...(extra.campaignId ? { campaignId: extra.campaignId } : {}), + ...(extra.id ? { id: extra.id } : {}), + }) + } catch {} + } + + // --- public --------------------------------------------------------------------------- + app.get('/roundup/:slug', (c) => { + const r = promo.roundup(c.req.param('slug')) + if (!r) return c.notFound() + if (c.req.method === 'GET') track(c, 'roundup_view', null, { campaignId: r.campaign.id }) + return c.html(roundupPage({ title: r.campaign.config.title, intro: r.campaign.config.intro, slug: r.campaign.config.slug, placementId: r.placementId, listings: r.listings, updatedAt: r.campaign.launchedAt })) + }) + + // Taps on the phone link, from public/listing.js. Only this event type; the client's id deduplicates retries. + app.post('/b/:domain/e', async (c) => { + if (!beaconLimiter.take(c.env?.ip || 'unknown')) return c.body(null, 204) + const listing = listings.toPublic(c.req.param('domain')) + const body = (await c.req.json().catch(() => ({}))) as { type?: unknown; id?: unknown; via?: unknown } + if (listing && body.type === 'phone_click') track(c, 'phone_click', listing.domain, { id: typeof body.id === 'string' ? body.id : undefined, via: typeof body.via === 'string' ? body.via : null }) + return c.body(null, 204) + }) + + app.get('/r/:token', (c) => { + const raw = c.req.param('token') + const [token, ext] = raw.includes('.') ? [raw.slice(0, raw.lastIndexOf('.')), raw.slice(raw.lastIndexOf('.') + 1)] : [raw, 'html'] + const report = promo.reportByToken(token!) + if (!report) return c.notFound() + c.header('cache-control', 'no-store') + c.header('x-robots-tag', 'noindex') + if (ext === 'json') return c.json({ version: report.version, superseded: report.superseded, ...sharedReport(report.data) }) + if (ext === 'md') return c.body(reportMarkdown(report.data, report.version, true), 200, { 'content-type': 'text/markdown; charset=utf-8' }) + return c.html(reportPage(report.data, report.version, true)) + }) + + // --- API ------------------------------------------------------------------------------ + const authed = (c: Context) => { + if (adminAuthorized(c.req.header('authorization'), d.adminToken)) return true + return d.isAdmin?.(c) ?? false + } + const api = (handler: (c: Context) => unknown | Promise) => async (c: Context) => { + if (!authed(c)) return c.json({ error: 'unauthorized: Authorization: Bearer ' }, 401) + try { + const out = await handler(c) + return out instanceof Response ? out : c.json(out as object) + } catch (error) { + if (error instanceof PromoError) return c.json({ error: error.message, code: error.code }, error.status) + throw error + } + } + const body = async (c: Context) => ((await c.req.json().catch(() => ({}))) as Record) + const actor = (c: Context) => (c.req.header('x-pit-actor') ?? 'api').slice(0, 60) + const page = (c: Context) => { + const limit = Math.min(500, Math.max(1, Number(c.req.query('limit') ?? 100) || 100)) + const offset = Math.max(0, Number(c.req.query('offset') ?? 0) || 0) + return { limit, offset } + } + /** `:`, the id of a campaign business. */ + const member = (c: Context) => { + const raw = c.req.param('id') ?? '' + const at = raw.indexOf(':') + if (at < 0) throw new PromoError(400, 'bad_id', 'a campaign business id is :') + return { campaignId: raw.slice(0, at), domain: normalizeDomain(raw.slice(at + 1)) ?? '' } + } + const V = '/api/v1/promotion' + + app.get(`${V}/campaigns`, api(() => ({ campaigns: promo.campaigns().map((x) => ({ id: x.id, name: x.name, state: x.state, source: `${x.source}:${x.sourceRef}`, slug: x.config.slug })) }))) + app.post(`${V}/campaigns`, api(async (c) => { + const b = await body(c) + return { id: promo.createCampaign({ name: String(b.name ?? ''), source: String(b.source ?? 'outreachgraph'), sourceRef: String(b.sourceRef ?? ''), config: b.config ?? {} }, actor(c)) } + })) + app.get(`${V}/campaigns/:id`, api((c) => promo.overview(c.req.param('id')!))) + app.patch(`${V}/campaigns/:id`, api(async (c) => ({ config: promo.configure(c.req.param('id')!, (await body(c)).config ?? {}, actor(c)) }))) + app.post(`${V}/campaigns/:id/imports`, api(async (c) => { + const b = await body(c) + const dryRun = c.req.query('dryRun') === '1' || b.dryRun === true + const snapshot = b.snapshot ?? b + if (dryRun) return promo.import(c.req.param('id')!, snapshot, { dryRun: true }) + const job = promo.job('import', `import:${c.req.header('idempotency-key') ?? crypto.randomUUID()}`, c.req.param('id')!) + const result = promo.idempotent(c.req.header('idempotency-key'), `import:${c.req.param('id')}`, () => promo.import(c.req.param('id')!, snapshot, { actor: actor(c) })) + promo.db.run("UPDATE promo_jobs SET state = 'done', result = ?, updated_at = ? WHERE id = ?", [JSON.stringify(result), Date.now(), job.id]) + return { jobId: job.id, status: 'done', ...result } + })) + app.get(`${V}/campaigns/:id/imports`, api((c) => ({ imports: promo.imports(c.req.param('id')!) }))) + app.get(`${V}/imports/:id/rows`, api((c) => ({ rows: promo.importRows(c.req.param('id')!) }))) + app.get(`${V}/campaigns/:id/businesses`, api((c) => promo.roster(c.req.param('id')!, page(c)))) + app.post(`${V}/campaigns/:id/exclusions`, api(async (c) => { + const b = await body(c) + promo.exclude(c.req.param('id')!, String(b.subject ?? ''), String(b.reason ?? ''), actor(c)) + return { ok: true } + })) + app.post(`${V}/campaigns/:id/reverify`, api((c) => promo.reverify(c.req.param('id')!, actor(c)))) + app.get(`${V}/campaign-businesses/:id`, api((c) => { + const m = member(c) + return promo.member(m.campaignId, m.domain) + })) + app.patch(`${V}/campaign-businesses/:id`, api(async (c) => { + const m = member(c) + return promo.updateMember(m.campaignId, m.domain, await body(c), actor(c)) + })) + app.get(`${V}/campaign-businesses/:id/results`, api((c) => { + const m = member(c) + return promo.results(m.campaignId, m.domain) + })) + app.post(`${V}/campaign-businesses/:id/restart`, api(async (c) => { + const m = member(c) + return { periodId: promo.restartPeriod(m.campaignId, m.domain, String((await body(c)).reason ?? ''), actor(c)) } + })) + app.post(`${V}/campaigns/:id/preview`, api((c) => promo.preview(c.req.param('id')!))) + app.post(`${V}/campaigns/:id/launch`, api(async (c) => { + const b = await body(c) + if (!c.req.header('idempotency-key')) throw new PromoError(400, 'idempotency_key', 'launch needs an Idempotency-Key header') + return promo.idempotent(c.req.header('idempotency-key'), `launch:${c.req.param('id')}`, () => promo.launch(c.req.param('id')!, String(b.version ?? ''), actor(c))) + })) + app.post(`${V}/campaigns/:id/pause`, api(async (c) => { + const state = String((await body(c)).state ?? 'paused') + if (!['paused', 'active', 'stopped'].includes(state)) throw new PromoError(400, 'bad_state', 'state is paused, active or stopped') + return { state: promo.setState(c.req.param('id')!, state as 'paused', actor(c)) } + })) + app.get(`${V}/campaigns/:id/placements`, api((c) => promo.placements(c.req.param('id')!, { ...page(c), ...(c.req.query('state') ? { state: c.req.query('state')! } : {}), ...(c.req.query('domain') ? { domain: c.req.query('domain')! } : {}) }))) + app.get(`${V}/placements/:id`, api((c) => promo.placement(c.req.param('id')!))) + app.post(`${V}/campaigns/:id/lease`, api(async (c) => { + const b = await body(c) + return promo.lease(c.req.param('id')!, String(b.owner ?? 'worker').slice(0, 60), Math.min(20, Number(b.max ?? 5) || 5)) + })) + app.post(`${V}/placements/:id/report`, api(async (c) => promo.report(c.req.param('id')!, (await body(c)) as any, actor(c)))) + app.post(`${V}/placements/:id/retry`, api((c) => promo.retry(c.req.param('id')!, actor(c)))) + app.post(`${V}/campaigns/:id/tracking-check`, api(async (c) => promo.checkTracking(c.req.param('id')!, (await body(c)).domains, actor(c)))) + app.post(`${V}/campaigns/:id/gaps`, api(async (c) => ({ id: promo.recordGap(c.req.param('id')!, (await body(c)) as any, actor(c)) }))) + app.get(`${V}/campaigns/:id/periods`, api((c) => ({ periods: promo.periods(c.req.param('id')!) }))) + app.post(`${V}/campaigns/:id/reports`, api((c) => { + promo.tick() + return promo.generateDue(c.req.param('id')!) + })) + app.get(`${V}/campaigns/:id/reports`, api((c) => ({ reports: promo.reports(c.req.param('id')!).map((r) => ({ id: r.id, domain: r.domain, version: r.version, superseded: r.superseded, shareUrl: r.shareUrl })) }))) + app.get(`${V}/campaigns/:id/reports.csv`, api((c) => c.body(promo.comparisonCsv(c.req.param('id')!), 200, { 'content-type': 'text/csv; charset=utf-8' }))) + app.get(`${V}/reports/:id`, api((c) => { + const raw = c.req.param('id')! + const [rid, ext] = raw.includes('.') ? [raw.slice(0, raw.lastIndexOf('.')), raw.slice(raw.lastIndexOf('.') + 1)] : [raw, 'json'] + const r = promo.reportById(rid!) + if (ext === 'md') return c.body(reportMarkdown(r.data, r.version, false), 200, { 'content-type': 'text/markdown; charset=utf-8' }) + if (ext === 'html') return c.html(reportPage(r.data, r.version, false)) + return r + })) + app.post(`${V}/reports/:id/outreach-draft`, api((c) => promo.createDraft(c.req.param('id')!))) + app.get(`${V}/campaigns/:id/drafts`, api((c) => ({ drafts: promo.drafts(c.req.param('id')!) }))) + app.post(`${V}/drafts/:id/deliver`, api((c) => promo.deliver(c.req.param('id')!, actor(c)))) + app.post(`${V}/tick`, api(() => promo.tick())) + app.get(`${V}/jobs/:id`, api((c) => promo.jobStatus(c.req.param('id')!))) + app.get(`${V}/campaigns/:id/audit`, api((c) => ({ audit: promo.auditLog(c.req.param('id')!, page(c).limit) }))) + + // --- web dashboard ------------------------------------------------------------------------- + /** Forms post only from our own pages: the browser says so, or the Origin is ours. */ + const sameOrigin = (c: Context) => { + const site = c.req.header('sec-fetch-site') + if (site) return site === 'same-origin' + const origin = c.req.header('origin') + return Boolean(origin) && origin === new URL(promo.site).origin + } + const guard = (c: Context) => (d.isAdmin?.(c) ? null : c.html('Sign in

    Sign in as a pit.world admin: sign in.

    ', 401)) + app.get('/admin/promo', (c) => { + const denied = guard(c) + if (denied) return denied + const list = promo.campaigns() + if (list.length === 1) return c.redirect(`/admin/promo/${encodeURIComponent(list[0]!.id)}`, 302) + return c.html(`Promotion campaigns

    Promotion campaigns

    `) + }) + app.get('/admin/promo/:id', (c) => guard(c) ?? c.html(adminPage(promo, c.req.param('id')))) + app.get('/admin/promo/:id/b/:domain', (c) => guard(c) ?? c.html(adminMemberPage(promo, c.req.param('id'), normalizeDomain(c.req.param('domain')) ?? ''))) + const post = (path: string, run: (c: Context) => string) => + app.post(path, (c) => { + const denied = guard(c) + if (denied) return denied + if (!sameOrigin(c)) return c.text('Forms post only from pit.world pages.', 403) + try { + return c.html(adminPage(promo, c.req.param('id')!, run(c))) + } catch (error) { + return c.html(adminPage(promo, c.req.param('id')!, error instanceof Error ? error.message : String(error)), 400) + } + }) + post('/admin/promo/:id/pause', (c) => `Campaign ${promo.setState(c.req.param('id')!, 'paused', 'web')}.`) + post('/admin/promo/:id/resume', (c) => `Campaign ${promo.setState(c.req.param('id')!, 'active', 'web')}.`) + post('/admin/promo/:id/tick', () => { + const t = promo.tick() + return `Hourly pass: ${t.started} windows started, ${t.due} due, ${t.reviewed} reviewed.` + }) + post('/admin/promo/:id/reports', (c) => `${promo.generateDue(c.req.param('id')!).reports.length} reports current.`) + post('/admin/promo/:id/placements/:pid/retry', (c) => `Placement ${promo.retry(c.req.param('pid')!, 'web').state}.`) + + // What the listing routes call: a page view and a sent quote request. + return { + viewed: (c: Context, domain: string) => track(c, 'listing_view', domain), + clicked: (c: Context, domain: string) => track(c, 'website_click', domain), + quoted: (c: Context, domain: string) => track(c, 'inquiry_sent', domain, { via: null }), + } +} diff --git a/src/promotion-schema.ts b/src/promotion-schema.ts new file mode 100644 index 0000000..78ba0f9 --- /dev/null +++ b/src/promotion-schema.ts @@ -0,0 +1,62 @@ +/** The promotion tables (src/promotion.ts), created with listings.db so the directory can order by membership in SQL. No imports: listings.ts loads this. */ +import type { Database } from 'bun:sqlite' + +/** Created with the listings table, so the directory can order by membership in SQL. */ +export function ensurePromotionSchema(db: Database): void { + db.run(`CREATE TABLE IF NOT EXISTS promo_campaigns ( + id TEXT PRIMARY KEY, name TEXT NOT NULL, source TEXT NOT NULL, source_ref TEXT NOT NULL, state TEXT NOT NULL DEFAULT 'draft', + config TEXT NOT NULL DEFAULT '{}', launched_at INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)`) + db.run(`CREATE TABLE IF NOT EXISTS promo_imports ( + id TEXT PRIMARY KEY, campaign_id TEXT NOT NULL, snapshot_at TEXT NOT NULL, complete INTEGER NOT NULL, checksum TEXT NOT NULL, + rows_total INTEGER NOT NULL, totals TEXT NOT NULL, created_at INTEGER NOT NULL, UNIQUE (campaign_id, checksum))`) + db.run(`CREATE TABLE IF NOT EXISTS promo_import_rows ( + import_id TEXT NOT NULL, row_index INTEGER NOT NULL, lead_id TEXT NOT NULL, name TEXT NOT NULL DEFAULT '', domain TEXT, + disposition TEXT NOT NULL, reason TEXT NOT NULL DEFAULT '', PRIMARY KEY (import_id, row_index))`) + db.run(`CREATE TABLE IF NOT EXISTS promo_members ( + campaign_id TEXT NOT NULL, domain TEXT NOT NULL, state TEXT NOT NULL, reason TEXT NOT NULL DEFAULT '', priority INTEGER NOT NULL DEFAULT 1, + display_order INTEGER, hold TEXT NOT NULL DEFAULT 'unknown', hold_detail TEXT NOT NULL DEFAULT '', verification TEXT NOT NULL DEFAULT '{}', + tracking_tested_at INTEGER, prior_contact TEXT NOT NULL DEFAULT '', joined_at INTEGER NOT NULL, left_at INTEGER, updated_at INTEGER NOT NULL, + PRIMARY KEY (campaign_id, domain))`) + db.run(`CREATE TABLE IF NOT EXISTS promo_leads ( + campaign_id TEXT NOT NULL, lead_id TEXT NOT NULL, domain TEXT, name TEXT NOT NULL DEFAULT '', title TEXT NOT NULL DEFAULT '', kind TEXT NOT NULL DEFAULT '', + emails TEXT NOT NULL DEFAULT '[]', suppressed INTEGER NOT NULL DEFAULT 0, contacts_out INTEGER NOT NULL DEFAULT 0, contacts_in INTEGER NOT NULL DEFAULT 0, + last_contact_at TEXT, upstream TEXT NOT NULL DEFAULT '[]', source_order INTEGER, seen_import TEXT, PRIMARY KEY (campaign_id, lead_id))`) + db.run(`CREATE TABLE IF NOT EXISTS promo_exclusions (campaign_id TEXT NOT NULL, subject TEXT NOT NULL, reason TEXT NOT NULL, at INTEGER NOT NULL, PRIMARY KEY (campaign_id, subject))`) + db.run(`CREATE TABLE IF NOT EXISTS promo_placements ( + id TEXT PRIMARY KEY, campaign_id TEXT NOT NULL, kind TEXT NOT NULL, channel TEXT NOT NULL, account TEXT NOT NULL DEFAULT '', body TEXT NOT NULL DEFAULT '', + link TEXT NOT NULL DEFAULT '', content_version TEXT NOT NULL DEFAULT '', planned_at INTEGER NOT NULL, state TEXT NOT NULL DEFAULT 'ready', + provider_id TEXT NOT NULL DEFAULT '', public_url TEXT NOT NULL DEFAULT '', queue_id TEXT NOT NULL DEFAULT '', published_at INTEGER, verified_at INTEGER, + evidence TEXT NOT NULL DEFAULT '', attempts INTEGER NOT NULL DEFAULT 0, lease_owner TEXT, lease_until INTEGER, error TEXT NOT NULL DEFAULT '', + paid INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)`) + db.run('CREATE INDEX IF NOT EXISTS promo_placements_due ON promo_placements (campaign_id, state, planned_at)') + db.run('CREATE TABLE IF NOT EXISTS promo_placement_members (placement_id TEXT NOT NULL, domain TEXT NOT NULL, PRIMARY KEY (placement_id, domain))') + db.run(`CREATE TABLE IF NOT EXISTS promo_events ( + id TEXT PRIMARY KEY, at INTEGER NOT NULL, type TEXT NOT NULL, domain TEXT, campaign_id TEXT, placement_id TEXT, source TEXT NOT NULL DEFAULT '', + test INTEGER NOT NULL DEFAULT 0, excluded TEXT NOT NULL DEFAULT '', visitor TEXT NOT NULL DEFAULT '')`) + db.run('CREATE INDEX IF NOT EXISTS promo_events_domain_at ON promo_events (domain, at)') + db.run('CREATE INDEX IF NOT EXISTS promo_events_campaign_at ON promo_events (campaign_id, at)') + db.run('CREATE TABLE IF NOT EXISTS promo_salts (day TEXT PRIMARY KEY, salt TEXT NOT NULL)') + db.run(`CREATE TABLE IF NOT EXISTS promo_periods ( + id TEXT PRIMARY KEY, campaign_id TEXT NOT NULL, domain TEXT NOT NULL, seq INTEGER NOT NULL, started_at INTEGER NOT NULL, ends_at INTEGER NOT NULL, + state TEXT NOT NULL DEFAULT 'active', start_evidence TEXT NOT NULL DEFAULT '{}', created_at INTEGER NOT NULL, UNIQUE (campaign_id, domain, seq))`) + db.run('CREATE TABLE IF NOT EXISTS promo_gaps (id TEXT PRIMARY KEY, campaign_id TEXT NOT NULL, domain TEXT, kind TEXT NOT NULL, from_at INTEGER NOT NULL, to_at INTEGER, note TEXT NOT NULL DEFAULT \'\')') + db.run(`CREATE TABLE IF NOT EXISTS promo_reports ( + id TEXT PRIMARY KEY, period_id TEXT NOT NULL, campaign_id TEXT NOT NULL, domain TEXT NOT NULL, version INTEGER NOT NULL, generated_at INTEGER NOT NULL, + cutoff INTEGER NOT NULL, rules_version TEXT NOT NULL, data TEXT NOT NULL, digest TEXT NOT NULL, share_token TEXT NOT NULL, superseded_at INTEGER, + UNIQUE (period_id, version))`) + db.run(`CREATE TABLE IF NOT EXISTS promo_drafts ( + id TEXT PRIMARY KEY, report_id TEXT NOT NULL, campaign_id TEXT NOT NULL, domain TEXT NOT NULL, kind TEXT NOT NULL, recipient_name TEXT NOT NULL DEFAULT '', + recipient_email TEXT NOT NULL DEFAULT '', lead_id TEXT NOT NULL DEFAULT '', subject TEXT NOT NULL, body TEXT NOT NULL, version TEXT NOT NULL, + state TEXT NOT NULL, reason TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, UNIQUE (report_id))`) + db.run(`CREATE TABLE IF NOT EXISTS promo_deliveries ( + id TEXT PRIMARY KEY, draft_id TEXT NOT NULL, recipient TEXT NOT NULL, message_version TEXT NOT NULL, period_id TEXT NOT NULL, state TEXT NOT NULL, + provider_id TEXT NOT NULL DEFAULT '', error TEXT NOT NULL DEFAULT '', at INTEGER NOT NULL, UNIQUE (recipient, message_version, period_id))`) + db.run(`CREATE TABLE IF NOT EXISTS promo_spend (id TEXT PRIMARY KEY, campaign_id TEXT NOT NULL, kind TEXT NOT NULL, cents INTEGER NOT NULL, state TEXT NOT NULL, ref TEXT NOT NULL DEFAULT '', at INTEGER NOT NULL)`) + db.run(`CREATE TABLE IF NOT EXISTS promo_jobs ( + id TEXT PRIMARY KEY, key TEXT NOT NULL UNIQUE, kind TEXT NOT NULL, campaign_id TEXT, state TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, + lease_until INTEGER, result TEXT NOT NULL DEFAULT '', error TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)`) + db.run('CREATE TABLE IF NOT EXISTS promo_idempotency (key TEXT PRIMARY KEY, operation TEXT NOT NULL, response TEXT NOT NULL, at INTEGER NOT NULL)') + db.run('CREATE TABLE IF NOT EXISTS promo_audit (at INTEGER NOT NULL, actor TEXT NOT NULL, campaign_id TEXT, action TEXT NOT NULL, subject TEXT NOT NULL DEFAULT \'\', detail TEXT NOT NULL DEFAULT \'{}\')') +} + + diff --git a/src/promotion-tui.ts b/src/promotion-tui.ts new file mode 100644 index 0000000..ade2d73 --- /dev/null +++ b/src/promotion-tui.ts @@ -0,0 +1,130 @@ +/** + * `pit promo tui ID`: a campaign in the terminal. Tab switches between businesses and posts, ↑/↓ move, + * Enter opens the detail (a business's leads, hold and windows; a post's state, error and evidence), + * p pauses or resumes, r retries a failed post, t runs the hourly pass, g refreshes, q quits. + */ +import { createApp } from '@profullstack/hqtui' +import type { PitClient } from './client.js' + +export type PromoTuiState = { + tab: 'businesses' | 'posts' + selected: number + detail: unknown | null + overview: any | null + businesses: any[] + posts: any[] + message: string + busy: boolean +} + +export const initialPromoState = (): PromoTuiState => ({ tab: 'businesses', selected: 0, detail: null, overview: null, businesses: [], posts: [], message: '', busy: false }) + +/** Pure key handling, testable without a terminal. */ +export function onPromoKey(s: PromoTuiState, key: { name: string; key: string; char?: string }): 'quit' | 'open' | 'pause' | 'retry' | 'tick' | 'refresh' | null { + if (key.key === 'ctrl+c' || key.char === 'q') return 'quit' + if (s.detail) { + if (key.name === 'escape' || key.name === 'backspace' || key.name === 'enter') s.detail = null + return null + } + const count = s.tab === 'businesses' ? s.businesses.length : s.posts.length + if (key.name === 'tab') { + s.tab = s.tab === 'businesses' ? 'posts' : 'businesses' + s.selected = 0 + } else if (key.name === 'down' || key.char === 'j') s.selected = Math.min(Math.max(0, count - 1), s.selected + 1) + else if (key.name === 'up' || key.char === 'k') s.selected = Math.max(0, s.selected - 1) + else if (key.name === 'enter') return count ? 'open' : null + else if (key.char === 'p') return 'pause' + else if (key.char === 'r') return s.tab === 'posts' ? 'retry' : null + else if (key.char === 't') return 'tick' + else if (key.char === 'g') return 'refresh' + return null +} + +export async function runPromoTui(client: PitClient, campaignId: string): Promise { + const s = initialPromoState() + const app = await createApp({ title: 'pit promo', quitKeys: [] }) + const id = encodeURIComponent(campaignId) + const run = async (work: () => Promise) => { + s.busy = true + app.invalidate() + try { + s.message = (await work()) || '' + } catch (error) { + s.message = error instanceof Error ? error.message : String(error) + } finally { + s.busy = false + app.invalidate() + } + } + const refresh = () => + run(async () => { + s.overview = await client.promo('GET', `/campaigns/${id}`) + s.businesses = (await client.promo('GET', `/campaigns/${id}/businesses?limit=500`)).businesses + s.posts = (await client.promo('GET', `/campaigns/${id}/placements?limit=500`)).placements + }) + + app.on('key', (event: any) => { + const action = onPromoKey(s, event) + if (action === 'quit') app.quit() + else if (action === 'refresh') void refresh() + else if (action === 'open') + void run(async () => { + if (s.tab === 'businesses') { + const b = s.businesses[s.selected] + s.detail = await client.promo('GET', `/campaign-businesses/${encodeURIComponent(`${s.overview.campaign.id}:${new URL(b.website).hostname}`)}/results`) + } else s.detail = s.posts[s.selected] + }) + else if (action === 'pause') + void run(async () => { + const state = s.overview?.campaign.state === 'active' ? 'paused' : 'active' + await client.promo('POST', `/campaigns/${id}/pause`, { state }) + await refresh() + return `campaign ${state}` + }) + else if (action === 'retry') + void run(async () => { + const p = s.posts[s.selected] + await client.promo('POST', `/placements/${encodeURIComponent(p.id)}/retry`, {}) + await refresh() + return `retrying ${p.id}` + }) + else if (action === 'tick') + void run(async () => { + const t = await client.promo('POST', '/tick', {}) + await refresh() + return `hourly pass: ${t.started} started, ${t.due} due, ${t.reviewed} reviewed` + }) + app.invalidate() + }) + + app.render(({ ui, theme }: any) => { + const o = s.overview + ui.row({ size: 1 }, (h: any) => { + h.text(' pit promo', { fg: theme.accent, bold: true, size: 11 }) + h.text(o ? `${o.campaign.name} · ${o.campaign.state}` : 'loading…', { fg: theme.muted }) + h.text(s.busy ? 'working… ' : `${s.message} `, { fg: theme.muted, align: 'right' }) + }) + if (o) { + const line = (k: string) => `${k}: ${Object.entries(o[k]).map(([a, n]) => `${a} ${n}`).join(' · ') || 'none'}` + ui.panel({ title: 'Campaign', size: 4 }, (p: any) => p.text(`${line('members')} ${line('holds')}\n${line('placements')} ${line('periods')} reports ${o.reports}`, { fg: theme.text })) + } + if (s.detail) { + ui.panel({ title: 'Detail (Esc to go back)' }, (p: any) => p.text(JSON.stringify(s.detail, null, 2), { fg: theme.text })) + } else if (s.tab === 'businesses') { + ui.panel({ title: `Businesses (${s.businesses.length}) · Tab: posts` }, (p: any) => + p.list({ items: s.businesses.map((b) => ({ label: `${b.verification.padEnd(10)} ${b.hold.padEnd(8)} ${b.business} ${b.plannedAction}` })), selected: s.selected, followSelection: true, scrollbar: true }), + ) + } else { + ui.panel({ title: `Posts (${s.posts.length}) · Tab: businesses` }, (p: any) => + p.list({ items: s.posts.map((x) => ({ label: `${x.state.padEnd(10)} ${new Date(x.plannedAt).toISOString().slice(0, 16)} ${x.kind.padEnd(12)} ${x.account}${x.error ? ` ! ${x.error}` : ''}` })), selected: s.selected, followSelection: true, scrollbar: true }), + ) + } + ui.statusBar({ + items: [{ key: 'Tab', label: 'switch' }, { key: 'Enter', label: 'detail' }, { key: 'p', label: 'pause/resume' }, { key: 'r', label: 'retry post' }, { key: 't', label: 'hourly pass' }, { key: 'g', label: 'refresh' }, { key: 'q', label: 'quit' }], + right: [{ label: 'operators only' }], + }) + }) + + void refresh() + await app.start() +} diff --git a/src/promotion-worker.ts b/src/promotion-worker.ts new file mode 100644 index 0000000..8d9ea2e --- /dev/null +++ b/src/promotion-worker.ts @@ -0,0 +1,183 @@ +/** + * The publishing worker: hands a campaign's due posts to myna and proves they are live. + * + * pit promo worker [--once] + * + * It runs where myna is logged in (the operator's account), not inside pit.world, and talks to pit.world + * only through the promotion API. Each pass leases a few placements and, for each one: + * + * 1. Reconciles first. Every campaign link carries its placement id (?via=plc_...), so myna's history + * and queue say whether this post already went out or is waiting. A worker that died mid-post, or + * an uncertain answer, therefore never leads to a second post. + * 2. Posts only what is in neither: `myna post --front`, text on stdin. Automated promotion + * is paced by myna (never --now); --front keeps it on the campaign's calendar behind myna's gap. + * 3. Verifies what myna says went out against the network's public API (Bluesky getPostThread, + * Mastodon statuses, or the page itself for pit.world). A provider record alone is not proof. + * LinkedIn has no public read: its posts stay "published", never "verified", and never count. + * + * Organic only: accounts on networks where myna's CrawlProof plugin buys an ad are refused. + */ +import { spawn } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { homedir } from 'node:os' +import type { PitClient } from './client.js' +import { AD_NETWORKS } from './promotion.js' + +export type Placement = { + id: string + kind: string + channel: string + account: string + state: string + body: string + link: string + publicUrl: string + providerId: string + queueId: string + attempts: number + businesses: string[] +} + +export type MynaEntry = { at: string; accountId: string; text: string; ok: boolean; postId?: string; url?: string; error?: string } +export type MynaQueued = { id: string; targets: string[]; text: string; status: string; scheduledFor: string } + +export type Myna = { + history(): MynaEntry[] + queue(): MynaQueued[] + post(account: string, text: string): Promise<{ code: number; output: string }> +} + +/** myna on this machine: its own files for reading, its CLI for posting. */ +export function localMyna(dir = process.env.MYNA_CONFIG_DIR ?? `${homedir()}/.config/myna`, bin = process.env.MYNA_BIN ?? 'myna'): Myna { + const read = (file: string, key: string): T[] => { + try { + const data = JSON.parse(readFileSync(`${dir}/${file}`, 'utf8')) + return (Array.isArray(data) ? data : (data[key] ?? [])) as T[] + } catch { + return [] + } + } + return { + history: () => read('history.json', 'entries'), + queue: () => read('queue.json', 'posts'), + post: (account, text) => + new Promise((resolve) => { + const child = spawn(bin, ['post', account, '--front'], { stdio: ['pipe', 'pipe', 'pipe'] }) + let output = '' + child.stdout.on('data', (d) => (output += d)) + child.stderr.on('data', (d) => (output += d)) + const timer = setTimeout(() => child.kill('SIGTERM'), 120_000) + child.on('close', (code) => { + clearTimeout(timer) + resolve({ code: code ?? 1, output: output.slice(-2000) }) + }) + child.on('error', (e) => resolve({ code: 127, output: String(e) })) + child.stdin.end(text) + }), + } +} + +/** Where this post stands in myna, found by its tracking id. */ +export function findInMyna(myna: Myna, p: Placement): { history?: MynaEntry; queued?: MynaQueued } { + const marker = `via=${p.id}` + const history = myna + .history() + .filter((e) => e.accountId === p.account && e.text?.includes(marker)) + .sort((a, b) => (a.ok === b.ok ? 0 : a.ok ? -1 : 1))[0] + const queued = myna.queue().find((q) => q.status === 'pending' && q.targets?.includes(p.account) && q.text?.includes(marker)) + return { ...(history ? { history } : {}), ...(queued ? { queued } : {}) } +} + +/** Does the public post exist? Evidence for "verified", or null when the network cannot be read without a login. */ +export async function verifyPublic(p: Placement, fetchImpl: typeof fetch = fetch): Promise<{ ok: boolean; evidence: Record } | null> { + const network = p.channel === 'pit.world' ? 'pit.world' : p.account.split(':')[0] + const at = new Date().toISOString() + const get = async (url: string) => { + const r = await fetchImpl(url, { headers: { accept: 'application/json, text/html', 'user-agent': 'pit.world promotion verifier (+https://pit.world)' }, signal: AbortSignal.timeout(15_000) }) + return { status: r.status, text: await r.text().catch(() => '') } + } + if (network === 'pit.world') { + const r = await get(p.publicUrl) + return { ok: r.status === 200 && r.text.includes('

    '), evidence: { method: 'GET public page', url: p.publicUrl, status: r.status, checkedAt: at } } + } + if (network === 'bluesky') { + const uri = p.providerId.split('|')[0] + if (!uri?.startsWith('at://')) return { ok: false, evidence: { error: 'no at:// uri', checkedAt: at } } + const url = `https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=${encodeURIComponent(uri)}&depth=0` + const r = await get(url) + return { ok: r.status === 200 && r.text.includes(p.id), evidence: { method: 'bluesky getPostThread', url, status: r.status, containsLink: r.text.includes(p.id), publicUrl: p.publicUrl, checkedAt: at } } + } + if (network === 'mastodon') { + const u = new URL(p.publicUrl) + const id = u.pathname.split('/').pop() + const url = `${u.origin}/api/v1/statuses/${id}` + const r = await get(url) + return { ok: r.status === 200 && r.text.includes(p.id), evidence: { method: 'mastodon statuses', url, status: r.status, containsLink: r.text.includes(p.id), publicUrl: p.publicUrl, checkedAt: at } } + } + return null +} + +export type WorkerResult = { placement: string; action: string; state?: string; detail?: string } + +/** One pass over what the lease hands out. Returns what it did, per placement. */ +export async function workOnce(client: PitClient, campaignId: string, myna: Myna, options: { owner?: string; max?: number; fetch?: typeof fetch } = {}): Promise { + const { placements, reason } = await client.promo<{ placements: Placement[]; reason?: string }>('POST', `/campaigns/${encodeURIComponent(campaignId)}/lease`, { owner: options.owner ?? 'myna-worker', max: options.max ?? 5 }) + if (reason) return [{ placement: '-', action: 'idle', detail: reason }] + const out: WorkerResult[] = [] + const report = async (p: Placement, body: Record, action: string) => { + const r = await client.promo('POST', `/placements/${encodeURIComponent(p.id)}/report`, body) + out.push({ placement: p.id, action, state: r.state, ...(body.error ? { detail: String(body.error) } : {}) }) + } + for (const p of placements) { + try { + if (p.state === 'published') { + const v = await verifyPublic(p, options.fetch) + if (!v) out.push({ placement: p.id, action: 'unverifiable', state: p.state, detail: `${p.account.split(':')[0]} has no public read` }) + else if (v.ok) await report(p, { state: 'verified', evidence: v.evidence }, 'verified') + else await report(p, { state: 'published', error: `not visible yet: ${JSON.stringify(v.evidence)}` }, 'not-yet-visible') + continue + } + if (p.channel !== 'myna') continue + if (AD_NETWORKS.has(p.account.split(':')[0]!)) { + await report(p, { state: 'failed', error: `refused: ${p.account} would buy a CrawlProof ad and this is organic promotion` }, 'refused') + continue + } + const found = findInMyna(myna, p) + if (found.history?.ok) { + await report(p, { state: 'published', providerId: found.history.postId ?? '', publicUrl: found.history.url ?? '', at: Date.parse(found.history.at) }, 'reconciled-published') + continue + } + if (found.queued) { + if (p.state !== 'scheduled' || p.queueId !== found.queued.id) await report(p, { state: 'scheduled', queueId: found.queued.id }, 'reconciled-scheduled') + else out.push({ placement: p.id, action: 'waiting', state: 'scheduled', detail: `myna ${found.queued.id} at ${found.queued.scheduledFor}` }) + continue + } + if (found.history && !found.history.ok) { + await report(p, { state: 'failed', error: `myna: ${found.history.error ?? 'post failed'}` }, 'reconciled-failed') + continue + } + if (p.state === 'scheduled') { + await report(p, { state: 'failed', error: 'no longer in myna queue or history (cancelled?)' }, 'lost') + continue + } + if (p.state === 'uncertain') { + // Reconciled: it is in neither place, so it never went. Back in line for a normal post. + await report(p, { state: 'ready', error: '' }, 'reconciled-not-sent') + continue + } + if (!p.body.includes(`via=${p.id}`)) { + await report(p, { state: 'failed', error: 'post text lost its tracking link; not posting it' }, 'refused') + continue + } + const r = await myna.post(p.account, p.body) + const after = findInMyna(myna, p) + if (after.history?.ok) await report(p, { state: 'published', providerId: after.history.postId ?? '', publicUrl: after.history.url ?? '', at: Date.parse(after.history.at) }, 'posted') + else if (after.queued) await report(p, { state: 'scheduled', queueId: after.queued.id }, 'queued') + else if (after.history && !after.history.ok) await report(p, { state: 'failed', error: `myna: ${after.history.error ?? r.output.slice(-300)}` }, 'post-failed') + else await report(p, { state: 'uncertain', error: `myna exited ${r.code} and left no record: ${r.output.slice(-300)}` }, 'uncertain') + } catch (error) { + out.push({ placement: p.id, action: 'error', detail: error instanceof Error ? error.message : String(error) }) + } + } + return out +} diff --git a/src/promotion.ts b/src/promotion.ts new file mode 100644 index 0000000..92b1e67 --- /dev/null +++ b/src/promotion.ts @@ -0,0 +1,1189 @@ +/** + * Promotion campaigns: list and promote the businesses an outreach campaign targets, measure what that + * did for each one over its own window, and only then write to them, with the numbers. + * + * The source of a campaign is a complete snapshot of an OutreachGraph campaign (see + * scripts/outreachgraph-campaign-export.sh). Every source row ends in exactly one disposition; leads + * that are the same business (same domain) become one member with all their lead ids. Members are + * pit.world listings: a member never gets a second page, and leaving the campaign only takes away its + * place at the top of the directory. + * + * Everything here is deterministic: when a window starts and ends, what a metric counts, whether a + * budget allows a job. Copy is written from the listing's own facts by templates, not by a model. + * + * Privacy: lead names and addresses live in promo_leads and are never in a public page, an event or a + * URL. Events carry no IP and no cookie; a visitor is a hash of (address, browser, a salt that is + * thrown away when its UTC day ends), so pit.world can count distinct visitors in a day and never + * across days. + */ +import type { Database } from 'bun:sqlite' +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto' +import { normalizeDomain, type Listing, type Listings } from './listings.js' +import { ensurePromotionSchema } from './promotion-schema.js' + +export { ensurePromotionSchema } from './promotion-schema.js' + +export const RULES_VERSION = 'promo-metrics/1' +const DAY = 24 * 3600 * 1000 +const FREEMAIL = new Set(['gmail.com', 'yahoo.com', 'hotmail.com', 'outlook.com', 'aol.com', 'icloud.com', 'comcast.net', 'sbcglobal.net', 'att.net', 'msn.com', 'live.com', 'me.com', 'ymail.com', 'pacbell.net', 'earthlink.net', 'mac.com', 'protonmail.com', 'verizon.net']) +/** Networks where myna's CrawlProof plugin buys an ad after each post: organic jobs never go there. */ +export const AD_NETWORKS = new Set(['gitblog', 'htmlblog', 'bl0ggers', 'autoblog', 'devto']) + +export const EVENT_TYPES = ['listing_view', 'website_click', 'phone_click', 'inquiry_sent', 'roundup_view'] as const +export type EventType = (typeof EVENT_TYPES)[number] +export const PLACEMENT_STATES = ['draft', 'ready', 'scheduled', 'publishing', 'published', 'verified', 'failed', 'uncertain', 'removed'] as const +export type PlacementState = (typeof PLACEMENT_STATES)[number] + +/** One row of an OutreachGraph campaign snapshot, as the exporter writes it. */ +export type SnapshotRow = { + leadId: string + order?: number + name?: string | null + kind?: string | null + title?: string | null + company?: string | null + domain?: string | null + location?: string | null + emails?: string[] + socials?: Array<{ network: string; url: string | null }> + suppressed?: boolean + suppressLists?: string[] | null + campaigns?: Array<{ id: string; name?: string; approvalMode: string; status: string; memberStatus?: string }> + contacts?: { outbound: number; inbound: number; lastAt: string | null } +} +export type Snapshot = { source: string; campaignId: string; campaignName?: string; snapshotAt: string; complete: boolean; total?: number; rows: SnapshotRow[] } + +export type CampaignConfig = { + /** Where the roundup lives: /roundup/. */ + slug: string + title: string + /** Shown above the roundup: how these businesses were chosen. */ + intro: string + timezone: string + periodDays: number + /** Spotlight accounts (myna account ids), taking turns; each posts at most perAccountPerDay. */ + spotlightAccounts: string[] + perAccountPerDay: number + /** Accounts that announce the roundup itself. */ + announceAccounts: string[] + /** Local hour (campaign timezone) the first post of a day is handed to myna. */ + postHour: number + budget: { paidCents: number; aiCents: number } + sender: { name: string; company: string; email: string; footer: string } | null + /** Sending stays off until an operator turns it on. Drafts are always made. */ + sendAuthorized: boolean +} + +export const DEFAULT_CONFIG: CampaignConfig = { + slug: '', + title: '', + intro: '', + timezone: 'America/Los_Angeles', + periodDays: 30, + spotlightAccounts: [], + perAccountPerDay: 1, + announceAccounts: [], + postHour: 10, + budget: { paidCents: 0, aiCents: 0 }, + sender: null, + sendAuthorized: false, +} + +export type Disposition = 'matched' | 'merged' | 'unresolved' | 'excluded' +export type MemberState = 'verified' | 'unverified' | 'unresolved' | 'excluded' | 'left' +export type HoldState = 'held' | 'conflict' | 'unknown' + +export class PromoError extends Error { + constructor(readonly status: 400 | 404 | 409 | 422 | 423, readonly code: string, message: string) { + super(message) + } +} + +const sha = (value: string) => createHash('sha256').update(value).digest('hex') +const id = (prefix: string) => `${prefix}_${randomBytes(9).toString('base64url')}` +const json = (value: string | null | undefined, fallback: T): T => { + try { + return value ? (JSON.parse(value) as T) : fallback + } catch { + return fallback + } +} + +/** The domain a lead's business lives at: the company's, else a business (not freemail) address's. */ +export function leadDomain(row: SnapshotRow): string | null { + const own = normalizeDomain(row.domain ?? '') + if (own && !FREEMAIL.has(own)) return own + for (const email of row.emails ?? []) { + const d = normalizeDomain(email.split('@')[1] ?? '') + if (d && !FREEMAIL.has(d)) return d + } + return null +} + +/** Whether every way the upstream could send to this lead is held: suppressed, or only in campaigns that wait for approval. */ +export function holdOf(rows: SnapshotRow[]): { hold: HoldState; detail: string } { + if (!rows.length) return { hold: 'unknown', detail: 'no source lead' } + const live: string[] = [] + for (const row of rows) { + if (row.suppressed) continue + if (!row.campaigns) return { hold: 'unknown', detail: `lead ${row.leadId}: campaign state not in the snapshot` } + for (const c of row.campaigns) if (c.status === 'active' && c.approvalMode === 'trusted_automation') live.push(`${row.leadId} in ${c.id} (autopilot)`) + } + if (live.length) return { hold: 'conflict', detail: `auto-sending upstream: ${live.join('; ')}` } + const lists = [...new Set(rows.flatMap((r) => r.suppressLists ?? []))] + return { hold: 'held', detail: rows.every((r) => r.suppressed) ? `suppressed upstream${lists.length ? ` (${lists.join(', ')})` : ''}` : 'only in campaigns that wait for approval' } +} + +/** A short, factual spotlight from the listing's own words. Fits Bluesky's 300 characters with the link. */ +export function spotlightText(l: Listing, link: string, max = 300): string { + const place = l.city ? `${l.city}${l.region ? `, ${l.region}` : ''}` : l.area + const first = (l.description.match(/^[^.!?]*[.!?]/)?.[0] ?? l.description).trim() + const license = /^CSLB #\d+/.exec(l.license)?.[0] + const head = `Local builders, one at a time: ${l.name}${place ? ` (${place})` : ''}.` + const tail = `${license ? ` ${license.replace('CSLB #', 'CSLB license ')}, active when we checked.` : ''}\n\n${link}` + const room = max - head.length - tail.length - 1 + const middle = first && room > 40 ? ` ${first.length > room ? `${first.slice(0, room - 1).replace(/\s+\S*$/, '')}…` : first}` : '' + return `${head}${middle}${tail}` +} + +export function roundupAnnouncement(config: CampaignConfig, count: number, link: string, max = 300): string { + const text = `${config.title}: ${count} contractors who pulled permits in town, each with a license we checked and a page of their own. No paid placement, nobody asked to be on it.\n\n${link}` + return text.length <= max ? text : `${config.title}: ${count} local contractors, licenses checked, no paid placement.\n\n${link}` +} + +const fmtDay = (ms: number, tz: string) => new Intl.DateTimeFormat('en-US', { timeZone: tz, year: 'numeric', month: 'long', day: 'numeric' }).format(new Date(ms)) +/** Milliseconds since the epoch of `hour`:00 on the local day of `ms` in `tz`. */ +export function localHour(ms: number, tz: string, hour: number): number { + const parts = Object.fromEntries(new Intl.DateTimeFormat('en-US', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).formatToParts(new Date(ms)).map((p) => [p.type, p.value])) + const guess = Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day), hour) + // The offset at the target instant (DST changes between now and then are why this is computed twice). + const offsetAt = (t: number) => { + const p = Object.fromEntries(new Intl.DateTimeFormat('en-US', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).formatToParts(new Date(t)).map((x) => [x.type, x.value])) + return Date.UTC(Number(p.year), Number(p.month) - 1, Number(p.day), Number(p.hour), Number(p.minute)) - t + } + return guess - offsetAt(guess - offsetAt(guess)) +} + +export type Metric = { value: number | null; definition: string; source: string; note?: string } +export type ReportData = { + campaign: { id: string; name: string } + business: { domain: string; name: string; listingUrl: string } + period: { id: string; seq: number; startedAt: string; endsAt: string; startLocal: string; endLocal: string; timezone: string } + placements: Array<{ id: string; kind: string; channel: string; account: string; url: string; verifiedAt: string | null; sharedWith: number }> + metrics: Record + referrals: { byPlacement: Array<{ placementId: string; views: number; clicks: number }>; promotionViews: number; promotionClicks: number; unattributedViews: number; unattributedClicks: number } + exclusions: Record + gaps: Array<{ kind: string; from: string; to: string | null; note: string }> + economics: { paidCents: number; aiCents: number; operatorMinutes: number | null } + nextStep: string + cutoff: string + rulesVersion: string +} + +type Deliverer = { + /** Upstream suppression, opt-out and bounce state at the moment of sending. null = cannot tell. */ + suppressed(email: string): Promise + send(message: { to: string; subject: string; text: string; idempotencyKey: string }): Promise<{ id: string }> +} + +export class Promotion { + readonly db: Database + + constructor( + readonly listings: Listings, + readonly site = 'https://pit.world', + private now = () => Date.now(), + private deliverer: Deliverer | null = null, + ) { + this.db = listings.db + ensurePromotionSchema(this.db) + } + + // --- audit, jobs, idempotency ------------------------------------------------------ + audit(actor: string, action: string, campaignId: string | null, subject = '', detail: unknown = {}): void { + this.db.run('INSERT INTO promo_audit (at, actor, campaign_id, action, subject, detail) VALUES (?, ?, ?, ?, ?, ?)', [this.now(), actor, campaignId, action, subject, JSON.stringify(detail)]) + } + + auditLog(campaignId: string, limit = 100): Array<{ at: number; actor: string; action: string; subject: string; detail: unknown }> { + return (this.db.query('SELECT * FROM promo_audit WHERE campaign_id = ? ORDER BY at DESC LIMIT ?').all(campaignId, limit) as any[]).map((r) => ({ ...r, detail: json(r.detail, {}) })) + } + + /** Run once per key: a second call with the same key returns the first answer. */ + idempotent(key: string | undefined, operation: string, run: () => T): T { + if (!key) return run() + const k = `${operation}:${key}`.slice(0, 300) + const hit = this.db.query('SELECT response FROM promo_idempotency WHERE key = ?').get(k) as { response: string } | null + if (hit) return JSON.parse(hit.response) as T + const result = run() + this.db.run('INSERT OR IGNORE INTO promo_idempotency (key, operation, response, at) VALUES (?, ?, ?, ?)', [k, operation, JSON.stringify(result), this.now()]) + return result + } + + /** A durable job: created once per key (a restarted worker finds it, never makes a second). */ + job(kind: string, key: string, campaignId: string | null): { id: string; created: boolean; state: string } { + const existing = this.db.query('SELECT id, state FROM promo_jobs WHERE key = ?').get(key) as { id: string; state: string } | null + if (existing) return { id: existing.id, created: false, state: existing.state } + const jobId = id('job') + this.db.run('INSERT OR IGNORE INTO promo_jobs (id, key, kind, campaign_id, state, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)', [jobId, key, kind, campaignId, 'queued', this.now(), this.now()]) + const row = this.db.query('SELECT id, state FROM promo_jobs WHERE key = ?').get(key) as { id: string; state: string } + return { id: row.id, created: row.id === jobId, state: row.state } + } + + jobStatus(jobId: string): unknown { + const row = this.db.query('SELECT * FROM promo_jobs WHERE id = ?').get(jobId) as any + if (!row) throw new PromoError(404, 'not_found', 'no such job') + return { ...row, result: json(row.result, row.result) } + } + + private finishJob(jobId: string, state: 'done' | 'failed', result: unknown, error = ''): void { + this.db.run('UPDATE promo_jobs SET state = ?, result = ?, error = ?, attempts = attempts + 1, lease_until = NULL, updated_at = ? WHERE id = ?', [state, JSON.stringify(result ?? null), error, this.now(), jobId]) + } + + // --- campaigns ----------------------------------------------------------------------- + createCampaign(input: { name: string; source: string; sourceRef: string; config?: Partial }, actor = 'operator'): string { + const existing = this.db.query('SELECT id FROM promo_campaigns WHERE source = ? AND source_ref = ?').get(input.source, input.sourceRef) as { id: string } | null + if (existing) return existing.id + const config = { ...DEFAULT_CONFIG, ...input.config } + if (!/^[a-z0-9-]{3,60}$/.test(config.slug)) throw new PromoError(400, 'bad_slug', 'config.slug must be 3-60 lowercase letters, digits or dashes') + const cid = id('pcm') + this.db.run('INSERT INTO promo_campaigns (id, name, source, source_ref, state, config, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [cid, input.name, input.source, input.sourceRef, 'draft', JSON.stringify(config), this.now(), this.now()]) + this.audit(actor, 'campaign.create', cid, input.sourceRef, { config }) + return cid + } + + campaign(campaignId: string): { id: string; name: string; source: string; sourceRef: string; state: string; config: CampaignConfig; launchedAt: number | null } { + const row = this.db.query('SELECT * FROM promo_campaigns WHERE id = ? OR source_ref = ?').get(campaignId, campaignId) as any + if (!row) throw new PromoError(404, 'not_found', 'no such campaign') + return { id: row.id, name: row.name, source: row.source, sourceRef: row.source_ref, state: row.state, config: { ...DEFAULT_CONFIG, ...json(row.config, {}) }, launchedAt: row.launched_at } + } + + campaigns(): Array> { + return (this.db.query('SELECT id FROM promo_campaigns ORDER BY created_at').all() as Array<{ id: string }>).map((r) => this.campaign(r.id)) + } + + bySlug(slug: string): ReturnType | null { + for (const c of this.campaigns()) if (c.config.slug === slug && c.state !== 'draft') return c + return null + } + + configure(campaignId: string, changes: Partial, actor = 'operator'): CampaignConfig { + const c = this.campaign(campaignId) + const next = { ...c.config, ...changes, budget: { ...c.config.budget, ...(changes.budget ?? {}) } } + for (const account of next.spotlightAccounts) { + if (AD_NETWORKS.has(account.split(':')[0]!)) throw new PromoError(422, 'ad_network', `${account}: posts there buy a CrawlProof ad; organic spotlights cannot use it`) + } + this.db.run('UPDATE promo_campaigns SET config = ?, updated_at = ? WHERE id = ?', [JSON.stringify(next), this.now(), c.id]) + this.audit(actor, 'campaign.configure', c.id, '', { changes }) + return next + } + + /** Pause stops new posts and sends; reports, priority and every record stay. */ + setState(campaignId: string, state: 'active' | 'paused' | 'stopped', actor = 'operator'): string { + const c = this.campaign(campaignId) + if (state === 'active' && !c.launchedAt) throw new PromoError(409, 'not_launched', 'launch the campaign first') + this.db.run('UPDATE promo_campaigns SET state = ?, updated_at = ? WHERE id = ?', [state, this.now(), c.id]) + if (state !== 'active') this.db.run("UPDATE promo_placements SET lease_owner = NULL, lease_until = NULL, state = 'ready' WHERE campaign_id = ? AND state = 'publishing' AND attempts = 0", [c.id]) + this.audit(actor, `campaign.${state}`, c.id) + return state + } + + // --- import ---------------------------------------------------------------------------- + /** + * Import a snapshot. The same snapshot twice changes nothing (same checksum, same run). A partial + * snapshot only adds and updates; only a complete one can tell that a lead left the campaign. + */ + import(campaignId: string, snapshot: Snapshot, options: { dryRun?: boolean; actor?: string } = {}) { + const c = this.campaign(campaignId) + if (snapshot.source !== c.source || snapshot.campaignId !== c.sourceRef) throw new PromoError(422, 'wrong_source', `snapshot is for ${snapshot.source} ${snapshot.campaignId}, not ${c.source} ${c.sourceRef}`) + if (!Array.isArray(snapshot.rows)) throw new PromoError(422, 'bad_snapshot', 'rows missing') + if (snapshot.complete && typeof snapshot.total === 'number' && snapshot.total !== snapshot.rows.length) throw new PromoError(422, 'incomplete', `snapshot says ${snapshot.total} rows and has ${snapshot.rows.length}`) + const leadIds = new Set() + for (const row of snapshot.rows) { + if (typeof row?.leadId !== 'string' || !row.leadId) throw new PromoError(422, 'bad_row', 'every row needs a leadId') + if (leadIds.has(row.leadId)) throw new PromoError(422, 'duplicate_lead', `lead ${row.leadId} appears twice`) + leadIds.add(row.leadId) + } + const checksum = sha(JSON.stringify(snapshot.rows)) + const previous = this.db.query('SELECT id, totals FROM promo_imports WHERE campaign_id = ? AND checksum = ?').get(c.id, checksum) as { id: string; totals: string } | null + if (previous && !options.dryRun) return { importId: previous.id, repeated: true, checksum, ...json(previous.totals, {}) } + + const exclusions = new Map((this.db.query('SELECT subject, reason FROM promo_exclusions WHERE campaign_id = ?').all(c.id) as Array<{ subject: string; reason: string }>).map((r) => [r.subject, r.reason])) + const groups = new Map() + const rows: Array<{ index: number; row: SnapshotRow; domain: string | null; disposition: Disposition; reason: string }> = [] + snapshot.rows.forEach((row, index) => { + const domain = leadDomain(row) + const excluded = exclusions.get(row.leadId) ?? (domain ? exclusions.get(domain) : undefined) + if (excluded) return rows.push({ index, row, domain, disposition: 'excluded', reason: excluded }) + if (!domain) return rows.push({ index, row, domain, disposition: 'unresolved', reason: 'no business website: neither a company domain nor a non-freemail address' }) + const first = !groups.has(domain) + groups.set(domain, [...(groups.get(domain) ?? []), row]) + rows.push({ index, row, domain, disposition: first ? 'matched' : 'merged', reason: first ? '' : `same business as an earlier lead (${domain})` }) + }) + const members: Array<{ domain: string; state: MemberState; reason: string; hold: ReturnType; verification: unknown }> = [] + for (const [domain, leads] of groups) { + const listing = this.listingRow(domain) + const check = this.verifyListing(listing) + members.push({ domain, state: check.ok ? 'verified' : 'unverified', reason: check.reason, hold: holdOf(leads), verification: check.evidence }) + } + const totals = { + sourceRows: snapshot.rows.length, + businesses: groups.size, + matched: rows.filter((r) => r.disposition === 'matched').length, + merged: rows.filter((r) => r.disposition === 'merged').length, + unresolved: rows.filter((r) => r.disposition === 'unresolved').length, + excluded: rows.filter((r) => r.disposition === 'excluded').length, + verified: members.filter((m) => m.state === 'verified').length, + unverified: members.filter((m) => m.state === 'unverified').length, + holdConflicts: members.filter((m) => m.hold.hold !== 'held').length, + complete: snapshot.complete, + } + if (totals.matched + totals.merged + totals.unresolved + totals.excluded !== totals.sourceRows) throw new Error('import reconciliation does not add up') + if (options.dryRun) return { importId: null, repeated: false, checksum, ...totals, rows: rows.map((r) => ({ leadId: r.row.leadId, domain: r.domain, disposition: r.disposition, reason: r.reason })) } + + const importId = id('imp') + const stamp = this.now() + const tx = this.db.transaction(() => { + this.db.run('INSERT INTO promo_imports (id, campaign_id, snapshot_at, complete, checksum, rows_total, totals, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [importId, c.id, snapshot.snapshotAt, snapshot.complete ? 1 : 0, checksum, snapshot.rows.length, JSON.stringify(totals), stamp]) + for (const r of rows) { + this.db.run('INSERT INTO promo_import_rows (import_id, row_index, lead_id, name, domain, disposition, reason) VALUES (?, ?, ?, ?, ?, ?, ?)', [importId, r.index, r.row.leadId, String(r.row.company || r.row.name || ''), r.domain, r.disposition, r.reason]) + this.db.run( + `INSERT INTO promo_leads (campaign_id, lead_id, domain, name, title, kind, emails, suppressed, contacts_out, contacts_in, last_contact_at, upstream, source_order, seen_import) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (campaign_id, lead_id) DO UPDATE SET domain = excluded.domain, name = excluded.name, title = excluded.title, kind = excluded.kind, emails = excluded.emails, + suppressed = excluded.suppressed, contacts_out = excluded.contacts_out, contacts_in = excluded.contacts_in, last_contact_at = excluded.last_contact_at, + upstream = excluded.upstream, source_order = excluded.source_order, seen_import = excluded.seen_import`, + [ + c.id, r.row.leadId, r.disposition === 'matched' || r.disposition === 'merged' ? r.domain : null, String(r.row.name ?? ''), String(r.row.title ?? ''), String(r.row.kind ?? ''), + JSON.stringify((r.row.emails ?? []).map((e) => String(e).toLowerCase())), r.row.suppressed ? 1 : 0, r.row.contacts?.outbound ?? 0, r.row.contacts?.inbound ?? 0, + r.row.contacts?.lastAt ?? null, JSON.stringify(r.row.campaigns ?? []), r.row.order ?? r.index + 1, importId, + ], + ) + } + for (const m of members) { + this.db.run( + `INSERT INTO promo_members (campaign_id, domain, state, reason, hold, hold_detail, verification, joined_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (campaign_id, domain) DO UPDATE SET state = CASE WHEN promo_members.state = 'excluded' THEN 'excluded' ELSE excluded.state END, + reason = CASE WHEN promo_members.state = 'excluded' THEN promo_members.reason ELSE excluded.reason END, + hold = excluded.hold, hold_detail = excluded.hold_detail, verification = excluded.verification, left_at = NULL, updated_at = excluded.updated_at`, + [c.id, m.domain, m.state, m.reason, m.hold.hold, m.hold.detail, JSON.stringify(m.verification), stamp, stamp], + ) + } + // Excluded businesses are recorded as members too, so the roster shows every one of them. + for (const r of rows.filter((x) => x.disposition === 'excluded' && x.domain)) { + this.db.run( + `INSERT INTO promo_members (campaign_id, domain, state, reason, joined_at, updated_at) VALUES (?, ?, 'excluded', ?, ?, ?) + ON CONFLICT (campaign_id, domain) DO UPDATE SET state = 'excluded', reason = excluded.reason, updated_at = excluded.updated_at`, + [c.id, r.domain, r.reason, stamp, stamp], + ) + } + if (snapshot.complete) { + const present = new Set([...groups.keys(), ...rows.filter((r) => r.domain).map((r) => r.domain!)]) + for (const m of this.db.query("SELECT domain FROM promo_members WHERE campaign_id = ? AND state <> 'left'").all(c.id) as Array<{ domain: string }>) { + if (!present.has(m.domain)) this.db.run("UPDATE promo_members SET state = 'left', reason = 'no longer in the source campaign', left_at = ?, updated_at = ? WHERE campaign_id = ? AND domain = ?", [stamp, stamp, c.id, m.domain]) + } + } + }) + tx() + this.audit(options.actor ?? 'operator', 'import', c.id, importId, totals) + return { importId, repeated: false, checksum, ...totals } + } + + /** A business pit.world can publish: a listing in the directory, with a name, a category, a place, a description and a checked source. */ + verifyListing(row: any): { ok: boolean; reason: string; evidence: Record } { + if (!row) return { ok: false, reason: 'no pit.world listing yet: seed one from verified public records', evidence: {} } + const listed = (row.claimed_at || row.seeded_at) && !row.directory_opt_out + const missing = [!listed && 'not in the directory', !row.name && 'name', !row.description && 'description', !(row.area || row.city) && 'service area', !row.category && 'category'].filter(Boolean) + const checked = row.claimed_at ? 'owner-verified (claimed)' : row.license && row.license_checked_at ? `${row.license}, checked ${new Date(row.license_checked_at).toISOString().slice(0, 10)}` : '' + if (!checked) missing.push('a checked source (license check or owner claim)') + const evidence = { listing: `${this.site}/b/${row.domain}`, website: `https://${row.domain}/`, license: row.license || null, licenseCheckedAt: row.license_checked_at ? new Date(row.license_checked_at).toISOString() : null, claimed: Boolean(row.claimed_at), checkedAt: new Date(this.now()).toISOString() } + return missing.length ? { ok: false, reason: `missing: ${missing.join(', ')}`, evidence } : { ok: true, reason: checked, evidence } + } + + private listingRow(domain: string): any { + return this.db.query('SELECT * FROM listings WHERE domain = ?').get(domain) + } + + /** Re-check members against their listings (after an operator seeded or fixed one). */ + reverify(campaignId: string, actor = 'operator'): { verified: number; unverified: number } { + const c = this.campaign(campaignId) + let verified = 0 + let unverified = 0 + for (const m of this.db.query("SELECT domain FROM promo_members WHERE campaign_id = ? AND state IN ('verified', 'unverified')").all(c.id) as Array<{ domain: string }>) { + const check = this.verifyListing(this.listingRow(m.domain)) + this.db.run('UPDATE promo_members SET state = ?, reason = ?, verification = ?, updated_at = ? WHERE campaign_id = ? AND domain = ?', [check.ok ? 'verified' : 'unverified', check.reason, JSON.stringify(check.evidence), this.now(), c.id, m.domain]) + check.ok ? verified++ : unverified++ + } + this.audit(actor, 'members.reverify', c.id, '', { verified, unverified }) + return { verified, unverified } + } + + exclude(campaignId: string, subject: string, reason: string, actor = 'operator'): void { + const c = this.campaign(campaignId) + if (!reason.trim()) throw new PromoError(400, 'reason_required', 'say why') + const domain = normalizeDomain(subject) ?? subject + this.db.run('INSERT INTO promo_exclusions (campaign_id, subject, reason, at) VALUES (?, ?, ?, ?) ON CONFLICT (campaign_id, subject) DO UPDATE SET reason = excluded.reason', [c.id, domain, reason, this.now()]) + this.db.run("UPDATE promo_members SET state = 'excluded', reason = ?, updated_at = ? WHERE campaign_id = ? AND domain = ?", [reason, this.now(), c.id, domain]) + this.audit(actor, 'member.exclude', c.id, domain, { reason }) + } + + /** Resolve a member: its order in the campaign, whether it keeps priority, a recorded prior contact, or an operator's hold attestation. */ + updateMember(campaignId: string, domain: string, changes: { displayOrder?: number | null; priority?: boolean; priorContact?: string; holdAttestation?: string }, actor = 'operator') { + const c = this.campaign(campaignId) + const d = normalizeDomain(domain) + const row = d ? (this.db.query('SELECT * FROM promo_members WHERE campaign_id = ? AND domain = ?').get(c.id, d) as any) : null + if (!row) throw new PromoError(404, 'not_found', 'not a member of this campaign') + if (changes.displayOrder !== undefined) this.db.run('UPDATE promo_members SET display_order = ? WHERE campaign_id = ? AND domain = ?', [changes.displayOrder, c.id, d]) + if (changes.priority !== undefined) this.db.run('UPDATE promo_members SET priority = ? WHERE campaign_id = ? AND domain = ?', [changes.priority ? 1 : 0, c.id, d]) + if (changes.priorContact !== undefined) this.db.run('UPDATE promo_members SET prior_contact = ? WHERE campaign_id = ? AND domain = ?', [changes.priorContact, c.id, d]) + if (changes.holdAttestation) this.db.run("UPDATE promo_members SET hold = 'held', hold_detail = ? WHERE campaign_id = ? AND domain = ?", [`operator attestation: ${changes.holdAttestation}`, c.id, d]) + this.db.run('UPDATE promo_members SET updated_at = ? WHERE campaign_id = ? AND domain = ?', [this.now(), c.id, d]) + this.audit(actor, 'member.update', c.id, d!, changes) + return this.member(c.id, d!) + } + + member(campaignId: string, domain: string) { + const r = this.db.query('SELECT * FROM promo_members WHERE campaign_id = ? AND domain = ?').get(campaignId, domain) as any + if (!r) throw new PromoError(404, 'not_found', 'not a member of this campaign') + const listing = this.listingRow(domain) + const leads = this.db.query('SELECT lead_id, name, title, kind, emails, suppressed, contacts_out, contacts_in, source_order FROM promo_leads WHERE campaign_id = ? AND domain = ? ORDER BY source_order').all(campaignId, domain) as any[] + const periods = this.db.query('SELECT id, seq, started_at, ends_at, state FROM promo_periods WHERE campaign_id = ? AND domain = ? ORDER BY seq').all(campaignId, domain) as any[] + return { + domain, + name: listing?.name ?? '', + listingUrl: `${this.site}/b/${domain}`, + state: r.state as MemberState, + reason: r.reason, + priority: Boolean(r.priority), + displayOrder: r.display_order, + hold: r.hold as HoldState, + holdDetail: r.hold_detail, + trackingTestedAt: r.tracking_tested_at, + priorContact: r.prior_contact, + verification: json(r.verification, {}), + leads: leads.map((l) => ({ leadId: l.lead_id, name: l.name, title: l.title, kind: l.kind, emails: json(l.emails, []), suppressed: Boolean(l.suppressed), contactsOut: l.contacts_out, contactsIn: l.contacts_in })), + periods, + firstVerifiedPlacementAt: this.firstVerifiedPlacement(campaignId, domain), + } + } + + /** The roster: every business, in source order, with what will happen to it. Private: lead names and addresses included. */ + roster(campaignId: string, options: { offset?: number; limit?: number } = {}) { + const c = this.campaign(campaignId) + const all = this.db + .query(`SELECT m.domain, MIN(l.source_order) AS first_order FROM promo_members m LEFT JOIN promo_leads l ON l.campaign_id = m.campaign_id AND l.domain = m.domain + WHERE m.campaign_id = ? GROUP BY m.domain ORDER BY first_order IS NULL, first_order, m.domain`) + .all(c.id) as Array<{ domain: string }> + const lastImport = this.db.query('SELECT id FROM promo_imports WHERE campaign_id = ? ORDER BY created_at DESC LIMIT 1').get(c.id) as { id: string } | null + const unresolved = lastImport ? (this.db.query("SELECT lead_id, name, reason FROM promo_import_rows WHERE import_id = ? AND disposition IN ('unresolved') ORDER BY row_index").all(lastImport.id) as any[]) : [] + const offset = Math.max(0, options.offset ?? 0) + const limit = Math.min(500, Math.max(1, options.limit ?? 500)) + const businesses = all.slice(offset, offset + limit).map(({ domain }) => { + const m = this.member(c.id, domain) + return { + leadIds: m.leads.map((l) => l.leadId), + business: m.name || domain, + website: `https://${domain}/`, + listingUrl: this.listingRow(domain) ? m.listingUrl : null, + verification: m.state, + reason: m.reason, + hold: m.hold, + plannedAction: plannedAction(m.state, m.hold, Boolean(this.listingRow(domain))), + } + }) + return { campaignId: c.id, total: all.length, offset, limit, businesses, unresolvedLeads: unresolved.map((u) => ({ leadId: u.lead_id, name: u.name, reason: u.reason })) } + } + + imports(campaignId: string) { + return (this.db.query('SELECT * FROM promo_imports WHERE campaign_id = ? ORDER BY created_at DESC').all(this.campaign(campaignId).id) as any[]).map((r) => ({ id: r.id, snapshotAt: r.snapshot_at, complete: Boolean(r.complete), checksum: r.checksum, rows: r.rows_total, totals: json(r.totals, {}), createdAt: r.created_at })) + } + + importRows(importId: string) { + return this.db.query('SELECT row_index, lead_id, name, domain, disposition, reason FROM promo_import_rows WHERE import_id = ? ORDER BY row_index').all(importId) + } + + // --- launch ------------------------------------------------------------------------------ + /** Members that can launch, in campaign order; and why the others cannot. */ + private launchable(campaignId: string) { + const rows = this.db + .query(`SELECT m.*, l.name, l.featured FROM promo_members m JOIN listings l ON l.domain = m.domain WHERE m.campaign_id = ? AND m.state = 'verified' + ORDER BY COALESCE(m.display_order, 1000000), l.featured DESC, lower(l.name), m.domain`) + .all(campaignId) as any[] + return { ready: rows.filter((r) => r.hold === 'held'), blocked: rows.filter((r) => r.hold !== 'held').map((r) => ({ domain: r.domain, reason: `outreach hold: ${r.hold} (${r.hold_detail})` })) } + } + + /** What launch would do, bound to a version: launch refuses a preview whose inputs changed since. */ + preview(campaignId: string) { + const c = this.campaign(campaignId) + if (!c.config.slug || !c.config.title) throw new PromoError(422, 'unconfigured', 'set config.slug and config.title') + const { ready, blocked } = this.launchable(c.id) + const start = this.now() + const plan = this.planPlacements(c, ready.map((r) => r.domain), start) + const version = sha(JSON.stringify({ config: c.config, ready: ready.map((r) => [r.domain, r.updated_at]), plan: plan.map((p) => [p.kind, p.account, p.content_version, p.domains]) })) + return { campaignId: c.id, version, launchable: ready.map((r) => r.domain), blocked, placements: plan.map((p) => ({ kind: p.kind, channel: p.channel, account: p.account, plannedAt: new Date(p.planned_at).toISOString(), businesses: p.domains, body: p.body })) } + } + + private planPlacements(c: ReturnType, domains: string[], start: number, after = 0) { + const cfg = c.config + const out: Array<{ kind: string; channel: string; account: string; body: string; link: string; content_version: string; planned_at: number; domains: string[] }> = [] + // Previewed copy carries a stand-in tracking id; launch writes the real one. + const add = (p: Omit<(typeof out)[number], 'content_version' | 'body' | 'link'>) => { + const content = this.content(c, p.kind, p.account, p.domains, 'plc_PREVIEW') + out.push({ ...p, ...content, content_version: sha(content.body.replace('plc_PREVIEW', '')).slice(0, 16) }) + } + if (!after) { + add({ kind: 'roundup', channel: 'pit.world', account: 'pit.world', planned_at: start, domains }) + for (const account of cfg.announceAccounts) add({ kind: 'announcement', channel: 'myna', account, planned_at: start, domains }) + } + const accounts = cfg.spotlightAccounts + const perDay = Math.max(1, accounts.length * Math.max(1, cfg.perAccountPerDay)) + const firstDay = localHour(start + DAY, cfg.timezone, cfg.postHour) + domains.forEach((domain, i) => { + if (!accounts.length) return + const slot = after + i + const dayIndex = Math.floor(slot / perDay) + const within = slot % perDay + // Spread a day's posts two hours apart, starting at postHour local time. + const at = localHour(firstDay + dayIndex * DAY + 12 * 3600 * 1000, cfg.timezone, cfg.postHour) + within * 2 * 3600 * 1000 + add({ kind: 'spotlight', channel: 'myna', account: accounts[slot % accounts.length]!, planned_at: at, domains: [domain] }) + }) + return out + } + + /** Text and link for a placement, with its tracking id in the link (?via=). */ + private content(c: ReturnType, kind: string, account: string, domains: string[], placementId: string): { body: string; link: string } { + const network = account.split(':')[0] + const max = network === 'mastodon' ? 500 : network === 'linkedin' ? 3000 : 300 + if (kind === 'roundup') return { body: c.config.title, link: `${this.site}/roundup/${c.config.slug}` } + if (kind === 'announcement') { + const link = `${this.site}/roundup/${c.config.slug}?via=${placementId}` + return { body: roundupAnnouncement(c.config, domains.length, link, max), link } + } + const listing = this.listings.toPublic(domains[0]!) + const link = `${this.site}/b/${domains[0]}?via=${placementId}` + return { body: listing ? spotlightText(listing, link, max) : '', link } + } + + /** + * Launch: create the roundup and the calendar of posts. Idempotent per preview version; a member + * added later gets the next free spotlight slot (launch again with a fresh preview). + */ + launch(campaignId: string, version: string, actor = 'operator') { + const c = this.campaign(campaignId) + const preview = this.preview(c.id) + if (preview.version !== version) throw new PromoError(409, 'stale_preview', 'the campaign changed since that preview; preview again') + if (c.state === 'stopped') throw new PromoError(409, 'stopped', 'this campaign is stopped') + const already = new Set((this.db.query('SELECT pm.domain FROM promo_placement_members pm JOIN promo_placements p ON p.id = pm.placement_id WHERE p.campaign_id = ? AND p.kind = ?').all(c.id, 'spotlight') as Array<{ domain: string }>).map((r) => r.domain)) + const hasRoundup = Boolean(this.db.query("SELECT 1 FROM promo_placements WHERE campaign_id = ? AND kind = 'roundup'").get(c.id)) + const fresh = preview.launchable.filter((d) => !already.has(d)) + const lastSpot = (this.db.query("SELECT COUNT(*) AS n FROM promo_placements WHERE campaign_id = ? AND kind = 'spotlight'").get(c.id) as { n: number }).n + const start = this.now() + const plan = hasRoundup ? this.planPlacements(c, fresh, c.launchedAt ?? start, lastSpot) : this.planPlacements(c, fresh, start) + // A late spotlight never lands in the past: shift its slot to now if the calendar already passed it. + const tx = this.db.transaction(() => { + for (const p of plan) { + const pid = id('plc') + const planned = Math.max(p.planned_at, start) + const placement = { ...p, planned_at: planned } + this.db.run( + 'INSERT INTO promo_placements (id, campaign_id, kind, channel, account, body, link, content_version, planned_at, state, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [pid, c.id, placement.kind, placement.channel, placement.account, '', '', placement.content_version, placement.planned_at, 'ready', start, start], + ) + for (const d of placement.domains) this.db.run('INSERT OR IGNORE INTO promo_placement_members (placement_id, domain) VALUES (?, ?)', [pid, d]) + this.setPlacementContent(pid) + } + this.db.run("UPDATE promo_campaigns SET state = CASE WHEN state = 'draft' THEN 'active' ELSE state END, launched_at = COALESCE(launched_at, ?), updated_at = ? WHERE id = ?", [start, start, c.id]) + // The roundup is pit.world's own page: it is live the moment the campaign is. + this.db.run("UPDATE promo_placements SET state = 'published', public_url = link, published_at = ?, updated_at = ? WHERE campaign_id = ? AND kind = 'roundup' AND state = 'ready'", [start, start, c.id]) + }) + tx() + this.audit(actor, 'campaign.launch', c.id, version, { placements: plan.length, businesses: fresh.length }) + return { campaignId: c.id, placementsCreated: plan.length, businessesAdded: fresh.length, blocked: preview.blocked } + } + + private setPlacementContent(placementId: string): void { + const p = this.db.query('SELECT * FROM promo_placements WHERE id = ?').get(placementId) as any + const domains = (this.db.query('SELECT domain FROM promo_placement_members WHERE placement_id = ? ORDER BY domain').all(placementId) as Array<{ domain: string }>).map((r) => r.domain) + const { body, link } = this.content(this.campaign(p.campaign_id), p.kind, p.account, domains, placementId) + this.db.run('UPDATE promo_placements SET body = ?, link = ?, content_version = ?, updated_at = ? WHERE id = ?', [body, link, sha(body.replace(placementId, '')).slice(0, 16), this.now(), placementId]) + } + + // --- placements ---------------------------------------------------------------------------- + placements(campaignId: string, options: { state?: string; domain?: string; offset?: number; limit?: number } = {}) { + const c = this.campaign(campaignId) + // Optional filters as bound parameters: NULL means "any". + const filters = [c.id, options.state ?? null, options.state ?? null, options.domain ?? null, options.domain ?? null] + const total = (this.db.query('SELECT COUNT(*) AS n FROM promo_placements p WHERE p.campaign_id = ? AND (? IS NULL OR p.state = ?) AND (? IS NULL OR p.id IN (SELECT placement_id FROM promo_placement_members WHERE domain = ?))').get(...filters) as { n: number }).n + const rows = this.db + .query('SELECT p.* FROM promo_placements p WHERE p.campaign_id = ? AND (? IS NULL OR p.state = ?) AND (? IS NULL OR p.id IN (SELECT placement_id FROM promo_placement_members WHERE domain = ?)) ORDER BY p.planned_at, p.id LIMIT ? OFFSET ?') + .all(...filters, Math.min(500, options.limit ?? 100), options.offset ?? 0) as any[] + return { total, placements: rows.map((r) => this.placementOut(r)) } + } + + private placementOut(r: any) { + const domains = (this.db.query('SELECT domain FROM promo_placement_members WHERE placement_id = ? ORDER BY domain').all(r.id) as Array<{ domain: string }>).map((x) => x.domain) + return { + id: r.id, kind: r.kind, channel: r.channel, account: r.account, state: r.state as PlacementState, plannedAt: r.planned_at, body: r.body, link: r.link, + contentVersion: r.content_version, providerId: r.provider_id, publicUrl: r.public_url, queueId: r.queue_id, publishedAt: r.published_at, verifiedAt: r.verified_at, + evidence: json(r.evidence, r.evidence), attempts: r.attempts, error: r.error, paid: Boolean(r.paid), businesses: domains, + } + } + + placement(placementId: string) { + const r = this.db.query('SELECT * FROM promo_placements WHERE id = ?').get(placementId) as any + if (!r) throw new PromoError(404, 'not_found', 'no such placement') + return this.placementOut(r) + } + + /** + * The publishing worker's lease: due placements for myna, plus scheduled/uncertain/unverified ones to + * reconcile. A lease lasts 15 minutes; a worker that dies leaves the placement to the next one, which + * reconciles against myna's history before it posts anything. + */ + lease(campaignId: string, owner: string, max = 5) { + const c = this.campaign(campaignId) + if (c.state !== 'active') return { placements: [], reason: `campaign is ${c.state}` } + const now = this.now() + const rows = this.db + .query(`SELECT * FROM promo_placements WHERE campaign_id = ? AND (lease_until IS NULL OR lease_until < ?) + AND ((channel = 'myna' AND state IN ('ready', 'publishing') AND planned_at <= ?) OR state IN ('scheduled', 'uncertain') + OR (state = 'published' AND account NOT LIKE 'linkedin:%')) + ORDER BY planned_at LIMIT ?`) + .all(c.id, now, now, max) as any[] + const leased = [] + for (const r of rows) { + // Paid distribution needs a budget; with none (the default) a paid placement never leaves. + if (r.paid && c.config.budget.paidCents <= 0) continue + const ok = this.db.run('UPDATE promo_placements SET lease_owner = ?, lease_until = ?, state = CASE WHEN state = \'ready\' THEN \'publishing\' ELSE state END, updated_at = ? WHERE id = ? AND (lease_until IS NULL OR lease_until < ?)', [owner, now + 15 * 60 * 1000, now, r.id, now]).changes + if (ok) leased.push(this.placementOut({ ...r, state: r.state === 'ready' ? 'publishing' : r.state })) + } + return { placements: leased } + } + + /** + * What the worker found. `published` needs a provider id and a public URL; `verified` needs evidence + * that the public URL answers (a provider record is not that evidence). Every report is audited. + */ + report(placementId: string, outcome: { state: PlacementState; providerId?: string; publicUrl?: string; queueId?: string; evidence?: unknown; error?: string; at?: number }, actor = 'worker') { + const p = this.placement(placementId) + const allowed: Record = { + ready: ['publishing', 'failed', 'removed'], + publishing: ['scheduled', 'published', 'failed', 'uncertain', 'ready'], + scheduled: ['scheduled', 'published', 'failed', 'uncertain', 'removed'], + uncertain: ['scheduled', 'published', 'failed', 'ready', 'uncertain'], + published: ['verified', 'published', 'removed', 'uncertain'], + verified: ['verified', 'removed'], + failed: ['ready', 'removed'], + removed: [], + draft: ['ready'], + } + if (!allowed[p.state]?.includes(outcome.state)) throw new PromoError(409, 'bad_transition', `${p.state} -> ${outcome.state}`) + if (outcome.state === 'published' && !(outcome.publicUrl || p.publicUrl)) throw new PromoError(422, 'no_url', 'published needs the public URL') + if (outcome.state === 'verified' && !outcome.evidence) throw new PromoError(422, 'no_evidence', 'verified needs evidence that the public URL answers') + const now = this.now() + this.db.run( + `UPDATE promo_placements SET state = ?, provider_id = COALESCE(NULLIF(?, ''), provider_id), public_url = COALESCE(NULLIF(?, ''), public_url), queue_id = COALESCE(NULLIF(?, ''), queue_id), + published_at = CASE WHEN ? = 'published' THEN COALESCE(published_at, ?) ELSE published_at END, + verified_at = CASE WHEN ? = 'verified' THEN COALESCE(verified_at, ?) ELSE verified_at END, + evidence = CASE WHEN ? <> '' THEN ? ELSE evidence END, error = ?, attempts = attempts + CASE WHEN ? IN ('failed', 'uncertain') THEN 1 ELSE 0 END, + lease_owner = NULL, lease_until = NULL, updated_at = ? WHERE id = ?`, + [ + outcome.state, outcome.providerId ?? '', outcome.publicUrl ?? '', outcome.queueId ?? '', outcome.state, outcome.at ?? now, outcome.state, now, + outcome.evidence ? 'x' : '', outcome.evidence ? JSON.stringify(outcome.evidence) : '', outcome.error ?? '', outcome.state, now, placementId, + ], + ) + this.audit(actor, `placement.${outcome.state}`, null, placementId, outcome) + return this.placement(placementId) + } + + /** Put a failed placement back in line (bounded: three tries). */ + retry(placementId: string, actor = 'operator') { + const p = this.placement(placementId) + if (p.state !== 'failed') throw new PromoError(409, 'not_failed', 'only a failed placement is retried') + if (p.attempts >= 3) throw new PromoError(409, 'exhausted', 'three attempts already; fix the cause and recreate it') + return this.report(placementId, { state: 'ready' }, actor) + } + + private firstVerifiedPlacement(campaignId: string, domain: string): number | null { + const r = this.db + .query("SELECT MIN(p.verified_at) AS at FROM promo_placements p JOIN promo_placement_members pm ON pm.placement_id = p.id WHERE p.campaign_id = ? AND pm.domain = ? AND p.state = 'verified'") + .get(campaignId, domain) as { at: number | null } + return r.at ?? null + } + + // --- budgets --------------------------------------------------------------------------------- + /** Reserve spend atomically across concurrent jobs. A budget that was never set is zero. */ + reserve(campaignId: string, kind: 'paid' | 'ai', cents: number, ref: string): boolean { + const c = this.campaign(campaignId) + const limit = kind === 'paid' ? c.config.budget.paidCents : c.config.budget.aiCents + let ok = false + this.db.transaction(() => { + const used = (this.db.query("SELECT COALESCE(SUM(cents), 0) AS n FROM promo_spend WHERE campaign_id = ? AND kind = ? AND state IN ('reserved', 'spent')").get(c.id, kind) as { n: number }).n + if (limit > 0 && used + cents <= limit) { + this.db.run('INSERT INTO promo_spend (id, campaign_id, kind, cents, state, ref, at) VALUES (?, ?, ?, ?, ?, ?, ?)', [id('spd'), c.id, kind, cents, 'reserved', ref, this.now()]) + ok = true + } + }).immediate() + return ok + } + + settle(campaignId: string, ref: string, actualCents: number): void { + this.db.run("UPDATE promo_spend SET state = 'spent', cents = ? WHERE campaign_id = ? AND ref = ? AND state = 'reserved'", [actualCents, campaignId, ref]) + } + + spend(campaignId: string): { paidCents: number; aiCents: number } { + const sum = (kind: string) => (this.db.query("SELECT COALESCE(SUM(cents), 0) AS n FROM promo_spend WHERE campaign_id = ? AND kind = ? AND state = 'spent'").get(campaignId, kind) as { n: number }).n + return { paidCents: sum('paid'), aiCents: sum('ai') } + } + + // --- measurement ------------------------------------------------------------------------------ + private visitorHash(ip: string, ua: string, at: number): string { + const day = new Date(at).toISOString().slice(0, 10) + let row = this.db.query('SELECT salt FROM promo_salts WHERE day = ?').get(day) as { salt: string } | null + if (!row) { + this.db.run('INSERT OR IGNORE INTO promo_salts (day, salt) VALUES (?, ?)', [day, randomBytes(16).toString('hex')]) + row = this.db.query('SELECT salt FROM promo_salts WHERE day = ?').get(day) as { salt: string } + } + return sha(`${row.salt}|${ip}|${ua}`).slice(0, 20) + } + + /** + * Record one event. `id` is the deduplication key: a beacon sends its own, a server event gets a + * random one. Nothing personal is kept: no IP, no user agent, only the day-salted visitor hash and + * the referring host. + */ + record(input: { id?: string; type: EventType; domain?: string | null; campaignId?: string; via?: string | null; referer?: string | null; ip?: string; userAgent?: string; test?: boolean; excluded?: string; at?: number }): string | null { + if (!EVENT_TYPES.includes(input.type)) return null + const at = input.at ?? this.now() + const eventId = input.id && /^[A-Za-z0-9_-]{8,64}$/.test(input.id) ? `c_${input.id}` : id('evt') + const domain = input.domain ? normalizeDomain(input.domain) : null + const via = input.via && /^plc_[A-Za-z0-9_-]{6,20}$/.test(input.via) ? (this.db.query('SELECT id, campaign_id FROM promo_placements WHERE id = ?').get(input.via) as { id: string; campaign_id: string } | null) : null + const member = domain ? (this.db.query("SELECT m.campaign_id FROM promo_members m JOIN promo_campaigns c ON c.id = m.campaign_id WHERE m.domain = ? AND c.state <> 'draft' ORDER BY m.joined_at LIMIT 1").get(domain) as { campaign_id: string } | null) : null + const campaignId = via?.campaign_id ?? member?.campaign_id ?? input.campaignId ?? null + // Only events that matter to a campaign are kept: pit.world does not track the rest of its traffic. + if (!campaignId) return null + let source = '' + try { + source = input.referer ? new URL(input.referer).hostname.slice(0, 120) : '' + } catch {} + const visitor = input.ip && input.userAgent ? this.visitorHash(input.ip, input.userAgent, at) : '' + const result = this.db.run('INSERT OR IGNORE INTO promo_events (id, at, type, domain, campaign_id, placement_id, source, test, excluded, visitor) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [ + eventId, at, input.type, domain, campaignId, via?.id ?? null, source, input.test ? 1 : 0, input.excluded ?? '', visitor, + ]) + return result.changes ? eventId : null + } + + /** Marked test traffic for a member: a test view and a test click, recorded within the last hour, pass. */ + checkTracking(campaignId: string, domains?: string[], actor = 'operator') { + const c = this.campaign(campaignId) + const since = this.now() - 3600 * 1000 + const targets = domains?.length ? domains : (this.db.query("SELECT domain FROM promo_members WHERE campaign_id = ? AND state = 'verified'").all(c.id) as Array<{ domain: string }>).map((r) => r.domain) + const passed: string[] = [] + const failed: Array<{ domain: string; missing: string[] }> = [] + for (const domain of targets) { + const has = (type: string) => Boolean(this.db.query('SELECT 1 FROM promo_events WHERE domain = ? AND type = ? AND test = 1 AND at >= ?').get(domain, type, since)) + const missing = ['listing_view', 'website_click'].filter((t) => !has(t)) + if (missing.length) failed.push({ domain, missing }) + else { + this.db.run('UPDATE promo_members SET tracking_tested_at = COALESCE(tracking_tested_at, ?) WHERE campaign_id = ? AND domain = ?', [this.now(), c.id, domain]) + passed.push(domain) + } + } + this.audit(actor, 'tracking.check', c.id, '', { passed: passed.length, failed: failed.length }) + return { passed, failed } + } + + recordGap(campaignId: string, gap: { domain?: string; kind: string; from: number; to?: number | null; note?: string }, actor = 'operator'): string { + const c = this.campaign(campaignId) + const gid = id('gap') + this.db.run('INSERT INTO promo_gaps (id, campaign_id, domain, kind, from_at, to_at, note) VALUES (?, ?, ?, ?, ?, ?, ?)', [gid, c.id, gap.domain ?? null, gap.kind, gap.from, gap.to ?? null, gap.note ?? '']) + this.audit(actor, 'gap.record', c.id, gid, gap) + return gid + } + + // --- measurement windows -------------------------------------------------------------------------- + /** + * The hourly pass. Starts a window for every member whose listing is live, whose tracking passed a + * marked test and who has a verified placement; marks windows that ended as due and makes one review + * job per window (never a second, however often this runs or restarts). Sends nothing. + */ + tick(): { started: number; due: number; reviewed: number } { + let started = 0 + let due = 0 + let reviewed = 0 + const now = this.now() + for (const c of this.campaigns().filter((x) => x.launchedAt)) { + const members = this.db.query("SELECT m.domain, m.tracking_tested_at FROM promo_members m WHERE m.campaign_id = ? AND m.state = 'verified' AND m.hold = 'held' AND m.tracking_tested_at IS NOT NULL").all(c.id) as any[] + for (const m of members) { + if (this.db.query('SELECT 1 FROM promo_periods WHERE campaign_id = ? AND domain = ?').get(c.id, m.domain)) continue + const listing = this.listingRow(m.domain) + if (!listing || !(listing.claimed_at || listing.seeded_at) || listing.directory_opt_out) continue + const placed = this.firstVerifiedPlacement(c.id, m.domain) + if (!placed) continue + const live = listing.claimed_at ?? listing.seeded_at + const begin = Math.max(live, m.tracking_tested_at, placed) + const evidence = { listingLiveAt: new Date(live).toISOString(), trackingTestedAt: new Date(m.tracking_tested_at).toISOString(), firstVerifiedPlacementAt: new Date(placed).toISOString() } + const r = this.db.run('INSERT OR IGNORE INTO promo_periods (id, campaign_id, domain, seq, started_at, ends_at, state, start_evidence, created_at) VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?)', [ + id('per'), c.id, m.domain, begin, begin + c.config.periodDays * DAY, 'active', JSON.stringify(evidence), now, + ]) + started += r.changes + } + for (const p of this.db.query("SELECT id FROM promo_periods WHERE campaign_id = ? AND state = 'active' AND ends_at <= ?").all(c.id, now) as Array<{ id: string }>) { + this.db.run("UPDATE promo_periods SET state = 'due' WHERE id = ? AND state = 'active'", [p.id]) + this.job('review', `review:${p.id}`, c.id) + due++ + } + } + // Review jobs: one report and one draft per window. A lease keeps two processes off the same job. + for (const j of this.db.query("SELECT id, key FROM promo_jobs WHERE kind = 'review' AND state IN ('queued', 'running') AND (lease_until IS NULL OR lease_until < ?)").all(now) as Array<{ id: string; key: string }>) { + if (!this.db.run("UPDATE promo_jobs SET state = 'running', lease_until = ? WHERE id = ? AND (lease_until IS NULL OR lease_until < ?)", [now + 10 * 60 * 1000, j.id, now]).changes) continue + try { + const periodId = j.key.slice('review:'.length) + const report = this.generateReport(periodId) + const draft = this.createDraft(report.id) + this.db.run("UPDATE promo_periods SET state = 'reported' WHERE id = ?", [periodId]) + this.finishJob(j.id, 'done', { reportId: report.id, draftId: draft.id, draftState: draft.state }) + reviewed++ + } catch (error) { + this.finishJob(j.id, 'failed', null, error instanceof Error ? error.message : String(error)) + } + } + // Yesterday's salts go: after that, nobody can tie two visits together. + this.db.run('DELETE FROM promo_salts WHERE day < ?', [new Date(now).toISOString().slice(0, 10)]) + this.db.run("DELETE FROM promo_events WHERE excluded <> '' AND at < ?", [now - 90 * DAY]) + return { started, due, reviewed } + } + + /** A deliberate restart: a new window after the old one, which is kept as it was. */ + restartPeriod(campaignId: string, domain: string, reason: string, actor = 'operator') { + const c = this.campaign(campaignId) + const last = this.db.query('SELECT * FROM promo_periods WHERE campaign_id = ? AND domain = ? ORDER BY seq DESC LIMIT 1').get(c.id, domain) as any + if (!last) throw new PromoError(404, 'no_period', 'this business has no window to restart') + const now = this.now() + if (last.state === 'active') this.db.run("UPDATE promo_periods SET state = 'superseded', ends_at = ? WHERE id = ?", [now, last.id]) + const pid = id('per') + this.db.run('INSERT INTO promo_periods (id, campaign_id, domain, seq, started_at, ends_at, state, start_evidence, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', [pid, c.id, domain, last.seq + 1, now, now + c.config.periodDays * DAY, 'active', JSON.stringify({ restart: reason, previous: last.id }), now]) + this.audit(actor, 'period.restart', c.id, domain, { reason, previous: last.id, period: pid }) + return pid + } + + periods(campaignId: string) { + return (this.db.query('SELECT * FROM promo_periods WHERE campaign_id = ? ORDER BY started_at, domain').all(this.campaign(campaignId).id) as any[]).map((p) => ({ ...p, start_evidence: json(p.start_evidence, {}) })) + } + + // --- reports ---------------------------------------------------------------------------------------- + /** The metrics for one business over [from, to): deterministic, from the event ledger. */ + metrics(campaignId: string, domain: string, from: number, to: number) { + const rows = this.db.query('SELECT type, placement_id, test, excluded, visitor, at FROM promo_events WHERE domain = ? AND at >= ? AND at < ?').all(domain, from, to) as any[] + const counted = rows.filter((r) => !r.test && !r.excluded) + const exclusions: Record = {} + for (const r of rows) { + const why = r.test ? 'test' : r.excluded + if (why) exclusions[why] = (exclusions[why] ?? 0) + 1 + } + const of = (type: string) => counted.filter((r) => r.type === type) + const days = new Set(of('listing_view').filter((r) => r.visitor).map((r) => `${new Date(r.at).toISOString().slice(0, 10)}|${r.visitor}`)) + const placements = new Map() + for (const r of counted) { + if (!r.placement_id || (r.type !== 'listing_view' && r.type !== 'website_click')) continue + const e = placements.get(r.placement_id) ?? { views: 0, clicks: 0 } + if (r.type === 'listing_view') e.views++ + else e.clicks++ + placements.set(r.placement_id, e) + } + const views = of('listing_view').length + const clicks = of('website_click').length + const promoViews = of('listing_view').filter((r) => r.placement_id).length + const promoClicks = of('website_click').filter((r) => r.placement_id).length + return { views, clicks, phone: of('phone_click').length, inquiries: of('inquiry_sent').length, visitorDays: days.size, placements, promoViews, promoClicks, exclusions, unattributedViews: views - promoViews, unattributedClicks: clicks - promoClicks } + } + + /** Generate (or re-generate) the report for a window. Unchanged data keeps the current version. */ + generateReport(periodId: string, cutoff = this.now()) { + const period = this.db.query('SELECT * FROM promo_periods WHERE id = ?').get(periodId) as any + if (!period) throw new PromoError(404, 'not_found', 'no such window') + const c = this.campaign(period.campaign_id) + const listing = this.listingRow(period.domain) + const end = Math.min(period.ends_at, cutoff) + const m = this.metrics(c.id, period.domain, period.started_at, end) + const tz = c.config.timezone + const placements = this.db + .query(`SELECT p.*, (SELECT COUNT(*) FROM promo_placement_members x WHERE x.placement_id = p.id) AS shared FROM promo_placements p JOIN promo_placement_members pm ON pm.placement_id = p.id + WHERE pm.domain = ? AND p.campaign_id = ? AND p.state = 'verified' AND p.verified_at < ? ORDER BY p.verified_at`) + .all(period.domain, c.id, period.ends_at) as any[] + const claimedQuotes = Boolean(listing?.claimed_at && listing?.contact_email) + const metrics: Record = { + listingViews: { value: m.views, definition: 'GET requests for the listing page by a browser, excluding crawlers, link previews, prefetches, internal and marked test traffic', source: 'pit.world event ledger' }, + visitorDays: { value: m.visitorDays, definition: 'distinct visitors per UTC day, added up over the window. A person who came back on three days counts three times. pit.world keeps no identifier past a day, so a count of distinct people over the whole window does not exist', source: 'pit.world event ledger (day-salted hash, no cookies)' }, + uniqueVisitors: { value: null, definition: 'distinct visitors over the whole window', source: 'unavailable', note: 'not measured: pit.world does not keep identifiers across days' }, + websiteClicks: { value: m.clicks, definition: "clicks on the listing's website link (counted by the redirect, once per click), same exclusions as views", source: 'pit.world event ledger' }, + phoneClicks: { value: m.phone, definition: "taps on the phone link, sent by the page's script; browsers without script are not counted. A tap is not a completed call", source: 'pit.world event ledger (beacon)' }, + emailClicks: { value: null, definition: 'taps on an email link', source: 'unavailable', note: 'the listing shows no email address' }, + verifiedInquiries: claimedQuotes + ? { value: m.inquiries, definition: 'quote requests sent through the listing form', source: 'pit.world event ledger' } + : { value: null, definition: 'quote requests sent through the listing form', source: 'unavailable', note: 'the form opens only once the owner claims the listing' }, + verifiedPlacements: { value: placements.length, definition: 'campaign posts and pages naming this business whose public URL was checked live', source: 'placement records' }, + platformImpressions: { value: null, definition: 'impressions reported by the social networks', source: 'unavailable', note: 'myna does not read impression counts from these networks' }, + } + const gaps = this.db.query('SELECT * FROM promo_gaps WHERE campaign_id = ? AND (domain IS NULL OR domain = ?) AND from_at < ? AND (to_at IS NULL OR to_at > ?)').all(c.id, period.domain, period.ends_at, period.started_at) as any[] + const spend = this.db.query("SELECT kind, COALESCE(SUM(cents), 0) AS n FROM promo_spend WHERE campaign_id = ? AND state = 'spent' GROUP BY kind").all(c.id) as Array<{ kind: string; n: number }> + const data: ReportData = { + campaign: { id: c.id, name: c.name }, + business: { domain: period.domain, name: listing?.name ?? period.domain, listingUrl: `${this.site}/b/${period.domain}` }, + period: { id: period.id, seq: period.seq, startedAt: new Date(period.started_at).toISOString(), endsAt: new Date(period.ends_at).toISOString(), startLocal: fmtDay(period.started_at, tz), endLocal: fmtDay(period.ends_at, tz), timezone: tz }, + placements: placements.map((p) => ({ id: p.id, kind: p.kind, channel: p.channel, account: p.account, url: p.public_url, verifiedAt: p.verified_at ? new Date(p.verified_at).toISOString() : null, sharedWith: p.shared })), + metrics, + referrals: { byPlacement: [...m.placements].map(([placementId, v]) => ({ placementId, ...v })), promotionViews: m.promoViews, promotionClicks: m.promoClicks, unattributedViews: m.unattributedViews, unattributedClicks: m.unattributedClicks }, + exclusions: m.exclusions, + gaps: gaps.map((g) => ({ kind: g.kind, from: new Date(g.from_at).toISOString(), to: g.to_at ? new Date(g.to_at).toISOString() : null, note: g.note })), + economics: { paidCents: spend.find((s) => s.kind === 'paid')?.n ?? 0, aiCents: spend.find((s) => s.kind === 'ai')?.n ?? 0, operatorMinutes: null }, + nextStep: m.views + m.clicks === 0 ? 'Review the listing together and decide whether wider distribution is worth trying.' : 'A short call to go over these results and whether to keep promoting the listing.', + cutoff: new Date(cutoff).toISOString(), + rulesVersion: RULES_VERSION, + } + const digest = sha(JSON.stringify({ ...data, cutoff: null })) + const latest = this.db.query('SELECT id, version, digest FROM promo_reports WHERE period_id = ? AND superseded_at IS NULL ORDER BY version DESC LIMIT 1').get(periodId) as { id: string; version: number; digest: string } | null + if (latest && latest.digest.length === digest.length && timingSafeEqual(Buffer.from(latest.digest), Buffer.from(digest))) return this.reportById(latest.id) + const rid = id('rpt') + const now = this.now() + this.db.transaction(() => { + if (latest) { + this.db.run('UPDATE promo_reports SET superseded_at = ? WHERE id = ?', [now, latest.id]) + // Unsent drafts quoting the old numbers are void. + this.db.run("UPDATE promo_drafts SET state = 'invalidated', reason = 'the report it quotes was superseded', updated_at = ? WHERE report_id = ? AND state IN ('draft', 'blocked')", [now, latest.id]) + } + this.db.run('INSERT INTO promo_reports (id, period_id, campaign_id, domain, version, generated_at, cutoff, rules_version, data, digest, share_token) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [ + rid, periodId, c.id, period.domain, (latest?.version ?? 0) + 1, now, cutoff, RULES_VERSION, JSON.stringify(data), digest, randomBytes(18).toString('base64url'), + ]) + })() + this.audit('system', 'report.generate', c.id, rid, { period: periodId, version: (latest?.version ?? 0) + 1 }) + return this.reportById(rid) + } + + reportById(reportId: string) { + const r = this.db.query('SELECT * FROM promo_reports WHERE id = ?').get(reportId) as any + if (!r) throw new PromoError(404, 'not_found', 'no such report') + return { id: r.id as string, periodId: r.period_id, campaignId: r.campaign_id, domain: r.domain, version: r.version as number, generatedAt: r.generated_at, superseded: Boolean(r.superseded_at), shareUrl: `${this.site}/r/${r.share_token}`, data: JSON.parse(r.data) as ReportData } + } + + reportByToken(token: string) { + if (!/^[A-Za-z0-9_-]{20,40}$/.test(token)) return null + const r = this.db.query('SELECT id FROM promo_reports WHERE share_token = ?').get(token) as { id: string } | null + return r ? this.reportById(r.id) : null + } + + reports(campaignId: string) { + return (this.db.query('SELECT id FROM promo_reports WHERE campaign_id = ? ORDER BY generated_at').all(this.campaign(campaignId).id) as Array<{ id: string }>).map((r) => this.reportById(r.id)) + } + + /** Generate reports for every window that has ended and has none yet. */ + generateDue(campaignId: string) { + const c = this.campaign(campaignId) + const out = [] + for (const p of this.db.query("SELECT id FROM promo_periods WHERE campaign_id = ? AND state IN ('due', 'reported', 'superseded')").all(c.id) as Array<{ id: string }>) out.push(this.generateReport(p.id).id) + return { reports: out } + } + + /** The comparison across the campaign, for a spreadsheet. */ + comparisonCsv(campaignId: string): string { + const head = ['domain', 'business', 'period_start', 'period_end', 'report_version', 'listing_views', 'visitor_days', 'website_clicks', 'phone_clicks', 'verified_inquiries', 'verified_placements', 'promotion_views', 'promotion_clicks', 'unattributed_views'] + const cell = (v: unknown) => (v === null || v === undefined ? 'unavailable' : /[",\n]/.test(String(v)) ? `"${String(v).replace(/"/g, '""')}"` : String(v)) + const lines = [head.join(',')] + for (const r of this.reports(campaignId).filter((x) => !x.superseded)) { + const d = r.data + lines.push([d.business.domain, d.business.name, d.period.startedAt, d.period.endsAt, r.version, d.metrics.listingViews?.value, d.metrics.visitorDays?.value, d.metrics.websiteClicks?.value, d.metrics.phoneClicks?.value, d.metrics.verifiedInquiries?.value, d.metrics.verifiedPlacements?.value, d.referrals.promotionViews, d.referrals.promotionClicks, d.referrals.unattributedViews].map(cell).join(',')) + } + return `${lines.join('\n')}\n` + } + + // --- outreach drafts ------------------------------------------------------------------------------ + /** One draft per report version, from the report's frozen numbers and the member's recorded contact history. */ + createDraft(reportId: string) { + const existing = this.db.query('SELECT id FROM promo_drafts WHERE report_id = ?').get(reportId) as { id: string } | null + if (existing) return this.draft(existing.id) + const report = this.reportById(reportId) + if (report.superseded) throw new PromoError(409, 'superseded', 'that report has a newer version') + const c = this.campaign(report.campaignId) + const member = this.member(c.id, report.domain) + const leads = member.leads.filter((l) => !l.suppressed || l.emails.length) + // A named person first, then a role address; never an address we only guessed at is preferred over a published one (the order is the source's). + const pick = leads.find((l) => l.kind === 'person' && l.emails.length && /\s/.test(l.name) && !/[|,]/.test(l.name)) ?? leads.find((l) => l.emails.length) + const followUp = member.leads.some((l) => l.contactsOut > 0) || Boolean(member.priorContact) + const { subject, body } = draftText(report.data, c.config, pick ? firstName(pick.name) : '', followUp, report.shareUrl) + let state = 'draft' + let reason = '' + if (!pick) { + state = 'blocked' + reason = 'no verified email address for this business (phone only): call instead' + } else if (!c.config.sender) { + state = 'blocked' + reason = 'campaign sender is not configured' + } + const did = id('drf') + const now = this.now() + this.db.run('INSERT OR IGNORE INTO promo_drafts (id, report_id, campaign_id, domain, kind, recipient_name, recipient_email, lead_id, subject, body, version, state, reason, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [ + did, reportId, c.id, report.domain, followUp ? 'follow_up' : 'introduction', pick?.name ?? '', pick?.emails[0] ?? '', pick?.leadId ?? '', subject, body, sha(subject + body).slice(0, 16), state, reason, now, now, + ]) + this.audit('system', 'draft.create', c.id, did, { report: reportId, state, reason }) + return this.draft((this.db.query('SELECT id FROM promo_drafts WHERE report_id = ?').get(reportId) as { id: string }).id) + } + + draft(draftId: string) { + const r = this.db.query('SELECT * FROM promo_drafts WHERE id = ?').get(draftId) as any + if (!r) throw new PromoError(404, 'not_found', 'no such draft') + return { id: r.id as string, reportId: r.report_id, domain: r.domain, kind: r.kind, recipientName: r.recipient_name, recipientEmail: r.recipient_email, leadId: r.lead_id, subject: r.subject, body: r.body, version: r.version, state: r.state as string, reason: r.reason } + } + + drafts(campaignId: string) { + return (this.db.query('SELECT id FROM promo_drafts WHERE campaign_id = ? ORDER BY created_at').all(this.campaign(campaignId).id) as Array<{ id: string }>).map((r) => this.draft(r.id)) + } + + /** + * Send one draft through the configured deliverer. Checked here, at the moment of sending: the + * campaign is running and sending is authorized, the window is over, the report is current, and the + * upstream says the address is not suppressed (unknown is a no). One delivery per recipient, message + * version and window, so a retry after a crash never sends twice. + */ + async deliver(draftId: string, actor = 'operator') { + const d = this.draft(draftId) + const report = this.reportById(d.reportId) + const c = this.campaign(report.campaignId) + const refuse = (code: string, message: string): never => { + this.audit(actor, 'delivery.refused', c.id, draftId, { code, message }) + throw new PromoError(423, code, message) + } + if (c.state !== 'active') refuse('campaign_not_active', `campaign is ${c.state}`) + if (!c.config.sendAuthorized || !c.config.sender) refuse('not_authorized', 'sending is not authorized for this campaign') + if (d.state !== 'draft') refuse('draft_not_sendable', `draft is ${d.state}${d.reason ? `: ${d.reason}` : ''}`) + if (report.superseded) refuse('report_superseded', 'the report this draft quotes has a newer version') + const period = this.db.query('SELECT * FROM promo_periods WHERE id = ?').get(report.periodId) as any + if (!period || period.ends_at > this.now()) refuse('window_open', 'the measurement window has not ended') + if (!this.deliverer) refuse('no_deliverer', 'no delivery route is configured') + const suppressed = await this.deliverer!.suppressed(d.recipientEmail) + if (suppressed !== false) refuse('suppressed', suppressed === null ? 'suppression state unknown: not sending' : 'the recipient is suppressed upstream') + const deliveryId = id('dlv') + const inserted = this.db.run('INSERT OR IGNORE INTO promo_deliveries (id, draft_id, recipient, message_version, period_id, state, at) VALUES (?, ?, ?, ?, ?, ?, ?)', [deliveryId, d.id, d.recipientEmail, d.version, report.periodId, 'sending', this.now()]).changes + if (!inserted) refuse('already_delivered', 'this message already has a delivery record; reconcile it instead of sending again') + try { + const sent = await this.deliverer!.send({ to: d.recipientEmail, subject: d.subject, text: d.body, idempotencyKey: deliveryId }) + this.db.run("UPDATE promo_deliveries SET state = 'sent', provider_id = ? WHERE id = ?", [sent.id, deliveryId]) + this.db.run("UPDATE promo_drafts SET state = 'sent', updated_at = ? WHERE id = ?", [this.now(), d.id]) + this.audit(actor, 'delivery.sent', c.id, d.id, { delivery: deliveryId }) + return { deliveryId, state: 'sent' } + } catch (error) { + // We do not know whether it went: uncertain until someone checks the provider. + this.db.run("UPDATE promo_deliveries SET state = 'uncertain', error = ? WHERE id = ?", [error instanceof Error ? error.message : String(error), deliveryId]) + this.audit(actor, 'delivery.uncertain', c.id, d.id, { delivery: deliveryId }) + return { deliveryId, state: 'uncertain' } + } + } + + // --- public views ----------------------------------------------------------------------------------- + /** The roundup: members in campaign order, the public facts only. */ + roundup(slug: string) { + const c = this.bySlug(slug) + if (!c) return null + const roundup = this.db.query("SELECT id, state FROM promo_placements WHERE campaign_id = ? AND kind = 'roundup'").get(c.id) as { id: string; state: string } | null + if (!roundup || roundup.state === 'removed') return null + const domains = (this.db + .query(`SELECT m.domain FROM promo_members m JOIN listings l ON l.domain = m.domain WHERE m.campaign_id = ? AND m.state = 'verified' AND m.hold = 'held' + AND m.domain IN (SELECT domain FROM promo_placement_members WHERE placement_id = ?) + ORDER BY COALESCE(m.display_order, 1000000), l.featured DESC, lower(l.name), m.domain`) + .all(c.id, roundup.id) as Array<{ domain: string }>).map((r) => r.domain) + return { campaign: c, placementId: roundup.id, listings: domains.map((d) => this.listings.toPublic(d)).filter((l): l is Listing => Boolean(l)) } + } + + /** Overview counts for the dashboard. */ + overview(campaignId: string) { + const c = this.campaign(campaignId) + const count = (sql: string, ...args: unknown[]) => (this.db.query(sql).get(...(args as any[])) as { n: number }).n + const states = Object.fromEntries((this.db.query('SELECT state, COUNT(*) AS n FROM promo_members WHERE campaign_id = ? GROUP BY state').all(c.id) as any[]).map((r) => [r.state, r.n])) + const placements = Object.fromEntries((this.db.query('SELECT state, COUNT(*) AS n FROM promo_placements WHERE campaign_id = ? GROUP BY state').all(c.id) as any[]).map((r) => [r.state, r.n])) + return { + campaign: { id: c.id, name: c.name, state: c.state, source: `${c.source}:${c.sourceRef}`, launchedAt: c.launchedAt, config: c.config }, + members: states, + holds: Object.fromEntries((this.db.query('SELECT hold, COUNT(*) AS n FROM promo_members WHERE campaign_id = ? GROUP BY hold').all(c.id) as any[]).map((r) => [r.hold, r.n])), + placements, + periods: Object.fromEntries((this.db.query('SELECT state, COUNT(*) AS n FROM promo_periods WHERE campaign_id = ? GROUP BY state').all(c.id) as any[]).map((r) => [r.state, r.n])), + trackingTested: count('SELECT COUNT(*) AS n FROM promo_members WHERE campaign_id = ? AND tracking_tested_at IS NOT NULL', c.id), + events: count("SELECT COUNT(*) AS n FROM promo_events WHERE campaign_id = ? AND test = 0 AND excluded = ''", c.id), + reports: count('SELECT COUNT(*) AS n FROM promo_reports WHERE campaign_id = ? AND superseded_at IS NULL', c.id), + drafts: Object.fromEntries((this.db.query('SELECT state, COUNT(*) AS n FROM promo_drafts WHERE campaign_id = ? GROUP BY state').all(c.id) as any[]).map((r) => [r.state, r.n])), + spend: this.spend(c.id), + } + } + + /** Measured results for one member: its windows and, per window, the latest report or live numbers so far. */ + results(campaignId: string, domain: string) { + const c = this.campaign(campaignId) + const member = this.member(c.id, domain) + return { + ...member, + leads: undefined, + windows: member.periods.map((p: any) => { + const latest = this.db.query('SELECT id FROM promo_reports WHERE period_id = ? AND superseded_at IS NULL ORDER BY version DESC LIMIT 1').get(p.id) as { id: string } | null + const live = this.metrics(c.id, domain, p.started_at, Math.min(p.ends_at, this.now())) + return { ...p, report: latest ? this.reportById(latest.id) : null, soFar: { listingViews: live.views, websiteClicks: live.clicks, phoneClicks: live.phone, promotionViews: live.promoViews } } + }), + } + } +} + +function plannedAction(state: MemberState, hold: HoldState, listed: boolean): string { + if (state === 'excluded') return 'none: excluded' + if (state === 'left') return 'priority removed: left the campaign (listing kept)' + if (state === 'unresolved') return 'resolve identity first' + if (!listed) return 'seed a verified listing, then promote' + if (state === 'unverified') return 'complete the listing, then promote' + if (hold !== 'held') return 'blocked until upstream outreach is held' + return 'priority placement, roundup, spotlight, 30-day measurement' +} + +function firstName(name: string): string { + const first = name.trim().split(/\s+/)[0] ?? '' + return /^[A-Z][a-z'-]+$/.test(first) ? first : '' +} + +/** The email, from report figures only. Unavailable metrics are left out, zero is said as zero, and nothing implies a conversation that never happened. */ +export function draftText(d: ReportData, config: CampaignConfig, first: string, followUp: boolean, reportUrl: string): { subject: string; body: string } { + const sender = config.sender ?? { name: 'Anthony', company: 'Profullstack, Inc.', email: '', footer: '' } + const channels = [...new Set(d.placements.map((p) => (p.channel === 'pit.world' ? 'the pit.world roundup' : p.account.split(':')[0] === 'bluesky' ? 'Bluesky' : p.account.split(':')[0] === 'mastodon' ? 'Mastodon' : p.account.split(':')[0] === 'linkedin' ? 'LinkedIn' : p.account)))] + const views = d.metrics.listingViews?.value + const clicks = d.metrics.websiteClicks?.value + const phone = d.metrics.phoneClicks?.value + const hello = `Hi ${first || 'there'},` + const opener = followUp + ? `Following up on my earlier note: since then we featured ${d.business.name} in our Los Gatos builders directory and promoted the listing through ${list(channels)} between ${d.period.startLocal} and ${d.period.endLocal}.` + : `We featured ${d.business.name} in our Los Gatos builders directory and promoted the listing through ${list(channels)} between ${d.period.startLocal} and ${d.period.endLocal}. Nobody asked us to; we list local builders with an active license.` + const counted = (views ?? 0) + (clicks ?? 0) + (phone ?? 0) + const results = + counted === 0 + ? 'In that time the listing recorded no measurable visits or clicks. That is a real result too: this audience did not find it on its own, and wider distribution is the open question.' + : `During that period, your listing received ${plural(views ?? 0, 'view')} and ${plural(clicks ?? 0, 'click')} through to your website${phone ? `, and ${plural(phone, 'tap')} on your phone number` : ''}.${d.referrals.promotionViews ? ` ${d.referrals.promotionViews} of those views came straight from our posts.` : ''}` + const ask = counted === 0 ? 'Would a short look at the listing, and at where else it could run, be useful?' : 'Would you be interested in a brief conversation about the results and continued promotion?' + const body = [ + hello, + '', + opener, + '', + results, + '', + `Listing: ${d.business.listingUrl}`, + `Results: ${reportUrl}`, + '', + `I'm ${sender.name.split(' ')[0]} with ${sender.company.replace(/, Inc\.$/, '')}. ${ask}`, + '', + sender.name, + sender.company, + sender.footer, + ].join('\n') + return { subject: `Your feature in our Los Gatos builders directory`, body } +} + +function list(items: string[]): string { + if (items.length <= 1) return items[0] ?? 'the directory' + return `${items.slice(0, -1).join(', ')} and ${items.at(-1)}` +} + +function plural(n: number, word: string): string { + return `${n} ${word}${n === 1 ? '' : 's'}` +} diff --git a/src/server.ts b/src/server.ts index e3ffbec..9d0bd99 100644 --- a/src/server.ts +++ b/src/server.ts @@ -19,7 +19,10 @@ import { mountListings } from './listings-routes.js' import { liveVerifier, Listings } from './listings.js' import { readFileSync } from 'node:fs' import { Accounts, serviceHost } from './accounts.js' -import { COOKIE_PATHS, mountAccounts, type AccountDeps } from './accounts-routes.js' +import { COOKIE_PATHS, mountAccounts, parseCookies, type AccountDeps } from './accounts-routes.js' +import { SESSION_COOKIE } from './accounts.js' +import { Promotion } from './promotion.js' +import { mountPromotion } from './promotion-routes.js' import { Boards, serviceRequest } from './services.js' import { Cache, normalizeQuery, search, SearchUnavailable, type Keys, type SearchResult } from './search.js' import { parseVertical, searchMedia, thumbAllowed, type MediaResult, type Vertical } from './verticals.js' @@ -42,6 +45,10 @@ export type Deps = { ringSync?: RingSync /** pit.world accounts (sign in with CoinPay, sign-in for *.pit.world sites); unset means off. */ accounts?: AccountDeps + /** Promotion campaigns over the listings (needs listings); unset means off. */ + promotion?: Promotion + /** Addresses whose visits are ours, excluded from promotion measurement. */ + internalIps?: string[] } /** The visitor's address arrives as a binding from serve() (or a test), never from a header the visitor controls. */ @@ -273,7 +280,21 @@ export function createApp(deps: Deps) { ) if (deps.accounts) mountAccounts(app, deps.accounts) - if (deps.listings) mountListings(app, deps.listings, limited, looksLikeName, { ringChanged: () => deps.ringSync?.soon(ringSites) }) + const promoHooks = + deps.listings && deps.promotion + ? mountPromotion(app, { + promo: deps.promotion, + listings: deps.listings, + ...(deps.adminToken ? { adminToken: deps.adminToken } : {}), + ...(deps.internalIps ? { internalIps: deps.internalIps } : {}), + isAdmin: (c) => { + const a = deps.accounts + const me = a ? a.accounts.sessionAccount(parseCookies(c.req.header('cookie'))[SESSION_COOKIE]) : null + return Boolean(me && (a!.admins ?? []).includes(me.sub)) + }, + }) + : null + if (deps.listings) mountListings(app, deps.listings, limited, looksLikeName, { ringChanged: () => deps.ringSync?.soon(ringSites), ...(promoHooks ?? {}) }) app.notFound((c) => c.req.path.startsWith('/api/') @@ -318,7 +339,17 @@ export function serve(options: { port?: number; hostname?: string } = {}) { extraClients: (env.PIT_BRIDGE_CLIENTS ?? '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean), admins: (env.PIT_ADMIN_SUBS ?? '').split(',').map((s) => s.trim()).filter(Boolean), } - const app = createApp({ keys: keysFromEnv(), newsletter, listings, accounts, ...(ringSync ? { ringSync } : {}), ...(env.PIT_ADMIN_TOKEN ? { adminToken: env.PIT_ADMIN_TOKEN } : {}) }) + const promotion = new Promotion(listings, site) + const internalIps = (env.PIT_INTERNAL_IPS ?? '').split(',').map((s) => s.trim()).filter(Boolean) + const app = createApp({ keys: keysFromEnv(), newsletter, listings, accounts, promotion, internalIps, ...(ringSync ? { ringSync } : {}), ...(env.PIT_ADMIN_TOKEN ? { adminToken: env.PIT_ADMIN_TOKEN } : {}) }) + // Every hour: start measurement windows that are ready, mark ended ones due and write their reports and drafts. Sends nothing. + const hourly = () => { + try { + promotion.tick() + } catch {} + } + setTimeout(hourly, 90_000).unref() + setInterval(hourly, 3600 * 1000).unref() // Once a day: re-read every claimed site (its feed, whether it has an ICANN address), then mirror the ring. const daily = async () => { await listings.checkSites().catch(() => 0) diff --git a/test/listings.test.ts b/test/listings.test.ts index 74e3f1d..c0a249c 100644 --- a/test/listings.test.ts +++ b/test/listings.test.ts @@ -246,8 +246,13 @@ describe('listings: website link', () => { const h = harness() h.listings.seed({ domain: 'old-builders.com', name: 'Old Builders', links: ['http://www.old-builders.com/', 'https://houzz.com/pro/old'] }) const page = await (await h.request('/b/old-builders.com')).text() - expect(page).toContain('href="http://www.old-builders.com/"') + // The page links /go, which redirects to the site's own www/http address and nowhere else. + expect(page).toContain('href="/b/old-builders.com/go"') expect(page).not.toContain('href="https://old-builders.com/"') expect(page).toContain('houzz.com/pro/old') + const go = await h.request('/b/old-builders.com/go?to=https://evil.example/') + expect(go.status).toBe(302) + expect(go.headers.get('location')).toBe('http://www.old-builders.com/') + expect((await h.request('/b/not-listed.example/go')).status).toBe(404) }) }) diff --git a/test/promotion.test.ts b/test/promotion.test.ts new file mode 100644 index 0000000..be8818e --- /dev/null +++ b/test/promotion.test.ts @@ -0,0 +1,529 @@ +import { afterAll, describe, expect, it } from 'bun:test' +import { PitClient } from '../src/client.ts' +import { Listings, type Verifier } from '../src/listings.ts' +import { draftText, holdOf, leadDomain, localHour, Promotion, spotlightText, type Snapshot } from '../src/promotion.ts' +import { findInMyna, workOnce, type Myna, type MynaEntry, type MynaQueued } from '../src/promotion-worker.ts' +import { createApp } from '../src/server.ts' + +const DAY = 24 * 3600 * 1000 +const BROWSER = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 Safari/605.1.15' +const verifier: Verifier = { txt: async () => [], homepage: async () => null } + +function setup(start = Date.parse('2026-10-20T17:00:00Z')) { + const clock = { t: start } + const listings = new Listings(':memory:', verifier, () => clock.t) + const seed = (domain: string, name: string, extra: Record = {}) => + listings.seed({ domain, name, description: `${name} builds custom homes and remodels. Second sentence.`, phone: '(408) 555-0100', category: 'construction', area: 'Los Gatos, CA and the South Bay', city: 'Los Gatos', region: 'CA', country: 'USA', services: ['Remodels'], license: 'CSLB #100 (B - General Building)', licenseCheckedAt: start - DAY, ...extra }) + seed('alpha-build.com', 'Alpha Build') + seed('bravo-homes.com', 'Bravo Homes') + seed('zulu-remodel.com', 'Zulu Remodel', { featured: 0 }) + seed('charlie-co.com', 'Charlie Co', { license: '' }) + seed('outsider.com', 'Aardvark Outsider') + const deliveries: Array<{ to: string }> = [] + const upstream = { suppressed: false as boolean | null } + const promo = new Promotion(listings, 'https://pit.world', () => clock.t, { + suppressed: async () => upstream.suppressed, + send: async (m) => { + deliveries.push({ to: m.to }) + return { id: `msg_${deliveries.length}` } + }, + }) + const campaign = promo.createCampaign({ + name: 'Building contractors in Los Gatos, CA', + source: 'outreachgraph', + sourceRef: 'cmp_test', + config: { slug: 'los-gatos-builders', title: 'Los Gatos builders', intro: 'Contractors with permits in town.', spotlightAccounts: ['bluesky:a.bsky.social', 'mastodon:b@defcon.social'], announceAccounts: ['bluesky:a.bsky.social'], sender: { name: 'Anthony Ettinger', company: 'Profullstack, Inc.', email: 'anthony@profullstack.com', footer: 'Profullstack, Inc., 16375 Bonnie Lane, Los Gatos, CA 95032' } }, + }) + const app = createApp({ keys: {}, listings, promotion: promo, adminToken: 'admintoken', internalIps: ['198.51.100.7'] }) + const request = (path: string, init: RequestInit = {}, ip = '203.0.113.9') => app.request(path, init, { ip }) + const api = (method: string, path: string, body?: unknown, headers: Record = {}) => + request(`/api/v1/promotion${path}`, { method, headers: { authorization: 'Bearer admintoken', 'content-type': 'application/json', ...headers }, ...(body !== undefined ? { body: JSON.stringify(body) } : {}) }) + return { clock, listings, promo, campaign, app, request, api, deliveries, upstream } +} + +const held = [{ id: 'cmp_test', approvalMode: 'draft_and_approve', status: 'active' }] +function snapshot(overrides: Partial = {}): Snapshot { + return { + source: 'outreachgraph', + campaignId: 'cmp_test', + snapshotAt: '2026-10-20T16:00:00.000Z', + complete: true, + total: 7, + rows: [ + { leadId: 'per_1', order: 1, name: 'Ann Alpha', kind: 'person', company: 'Alpha Build', domain: 'alpha-build.com', emails: ['ann@alpha-build.com'], socials: [{ network: 'bluesky', url: 'https://bsky.app/x' }], suppressed: true, campaigns: held, contacts: { outbound: 0, inbound: 0, lastAt: null } }, + { leadId: 'per_2', order: 2, name: 'Alpha office', kind: 'person', company: 'Alpha Build', domain: 'www.alpha-build.com', emails: ['info@alpha-build.com'], suppressed: true, campaigns: held }, + { leadId: 'per_3', order: 3, name: 'Bo Bravo', kind: 'person', company: null, domain: null, emails: ['bo@bravo-homes.com'], socials: [], suppressed: true, campaigns: held }, + { leadId: 'per_4', order: 4, name: 'Harm Gmail', kind: 'person', domain: null, emails: ['harm@gmail.com'], suppressed: true, campaigns: held }, + { leadId: 'per_5', order: 5, name: 'Charlie', kind: 'person', domain: 'charlie-co.com', emails: [], suppressed: true, campaigns: held }, + { leadId: 'per_6', order: 6, name: 'Zed', kind: 'person', domain: 'zulu-remodel.com', emails: ['zed@zulu-remodel.com'], suppressed: false, campaigns: [...held, { id: 'cmp_other', approvalMode: 'trusted_automation', status: 'active' }] }, + { leadId: 'per_7', order: 7, name: 'Gone', kind: 'person', domain: 'dead-site.com', emails: ['x@dead-site.com'], suppressed: true, campaigns: held }, + ], + ...overrides, + } +} + +/** Import, exclude the dead site, launch, pass the tracking test and verify the roundup: windows start. */ +async function launched(h: ReturnType) { + h.promo.exclude(h.campaign, 'dead-site.com', 'website does not respond') + h.promo.import(h.campaign, snapshot()) + const preview = h.promo.preview(h.campaign) + h.promo.launch(h.campaign, preview.version) + for (const d of ['alpha-build.com', 'bravo-homes.com']) { + await h.request(`/b/${d}?pt=test`, { headers: { 'user-agent': BROWSER } }) + await h.request(`/b/${d}/go?pt=test`, { headers: { 'user-agent': BROWSER } }) + } + h.promo.checkTracking(h.campaign) + const roundup = h.promo.placements(h.campaign, { state: 'published' }).placements.find((p) => p.kind === 'roundup')! + h.promo.report(roundup.id, { state: 'verified', evidence: { status: 200 } }) + h.promo.tick() + return { preview, roundup } +} + +describe('promotion: import and reconciliation', () => { + it('gives every source row exactly one disposition, and the totals add up (AC-01, AC-04, AC-05)', () => { + const h = setup() + h.promo.exclude(h.campaign, 'dead-site.com', 'website does not respond') + const r = h.promo.import(h.campaign, snapshot()) as any + expect(r.sourceRows).toBe(7) + expect(r.matched + r.merged + r.unresolved + r.excluded).toBe(7) + expect([r.matched, r.merged, r.unresolved, r.excluded]).toEqual([4, 1, 1, 1]) + expect(r.businesses).toBe(4) + const rows = h.promo.importRows(r.importId) as any[] + expect(rows.find((x) => x.lead_id === 'per_2').disposition).toBe('merged') + expect(rows.find((x) => x.lead_id === 'per_4').reason).toContain('no business website') + // Bravo has no social profile and no company domain: kept, matched by its address's domain. + expect(rows.find((x) => x.lead_id === 'per_3')).toMatchObject({ disposition: 'matched', domain: 'bravo-homes.com' }) + const alpha = h.promo.member(h.campaign, 'alpha-build.com') + expect(alpha.leads.map((l) => l.leadId)).toEqual(['per_1', 'per_2']) + }) + + it('re-importing the same snapshot changes nothing (AC-03)', () => { + const h = setup() + const first = h.promo.import(h.campaign, snapshot()) as any + const again = h.promo.import(h.campaign, snapshot()) as any + expect(again.repeated).toBe(true) + expect(again.importId).toBe(first.importId) + expect(h.promo.roster(h.campaign).total).toBe(first.businesses) + }) + + it('refuses a snapshot whose row count does not match, or for another campaign', () => { + const h = setup() + expect(() => h.promo.import(h.campaign, snapshot({ total: 99 }))).toThrow('99 rows') + expect(() => h.promo.import(h.campaign, snapshot({ campaignId: 'cmp_else' }))).toThrow('not outreachgraph cmp_test') + }) + + it('a partial snapshot never removes members; a complete one marks the missing ones as left, keeping the listing', () => { + const h = setup() + h.promo.import(h.campaign, snapshot()) + const partial = snapshot({ complete: false, total: undefined, snapshotAt: 'x', rows: snapshot().rows.slice(0, 1) }) + h.promo.import(h.campaign, partial) + expect(h.promo.member(h.campaign, 'bravo-homes.com').state).toBe('verified') + const complete = snapshot({ total: 6, rows: snapshot().rows.filter((r) => r.leadId !== 'per_3') }) + h.promo.import(h.campaign, complete) + expect(h.promo.member(h.campaign, 'bravo-homes.com').state).toBe('left') + expect(h.listings.toPublic('bravo-homes.com')).not.toBeNull() + }) + + it('reads a business domain from the company, or from a non-freemail address', () => { + expect(leadDomain({ leadId: 'a', domain: 'https://www.Acme.com/x' })).toBe('acme.com') + expect(leadDomain({ leadId: 'a', domain: null, emails: ['me@gmail.com', 'me@acme.com'] })).toBe('acme.com') + expect(leadDomain({ leadId: 'a', domain: 'gmail.com', emails: ['me@gmail.com'] })).toBeNull() + }) + + it('holds only when every upstream path is suppressed or waits for approval', () => { + expect(holdOf([{ leadId: 'a', suppressed: true, campaigns: [] }]).hold).toBe('held') + expect(holdOf([{ leadId: 'a', suppressed: false, campaigns: [{ id: 'c', approvalMode: 'trusted_automation', status: 'active' }] }]).hold).toBe('conflict') + expect(holdOf([{ leadId: 'a', suppressed: false }]).hold).toBe('unknown') + }) +}) + +describe('promotion: launch, priority and the roundup', () => { + it('blocks conflicts and unverified listings, launches the rest (AC-06)', () => { + const h = setup() + h.promo.import(h.campaign, snapshot()) + const p = h.promo.preview(h.campaign) + expect(p.launchable).toEqual(['alpha-build.com', 'bravo-homes.com']) + expect(p.blocked).toEqual([{ domain: 'zulu-remodel.com', reason: expect.stringContaining('conflict') }]) + expect(h.promo.member(h.campaign, 'charlie-co.com')).toMatchObject({ state: 'unverified', reason: expect.stringContaining('checked source') }) + expect(() => h.promo.launch(h.campaign, 'stale')).toThrow('preview again') + const r = h.promo.launch(h.campaign, p.version) + // roundup + one announcement + two spotlights + expect(r.placementsCreated).toBe(4) + expect(h.promo.launch(h.campaign, h.promo.preview(h.campaign).version).placementsCreated).toBe(0) + }) + + it('puts eligible campaign businesses first across filters, pages and the API (AC-07)', async () => { + const h = setup() + h.promo.import(h.campaign, snapshot()) + h.promo.launch(h.campaign, h.promo.preview(h.campaign).version) + const all = h.listings.directory({ limit: 100 }).listings.map((l) => l.domain) + expect(all.slice(0, 3)).toEqual(['alpha-build.com', 'bravo-homes.com', 'zulu-remodel.com']) + // Page 2 of 1 continues the same order. + expect(h.listings.directory({ limit: 1, offset: 1 }).listings[0]!.domain).toBe('bravo-homes.com') + // A filter is applied first: priority cannot bring in a business that does not match. + expect(h.listings.directory({ q: 'aardvark' }).listings.map((l) => l.domain)).toEqual(['outsider.com']) + const api = (await (await h.request('/api/pros?n=2&page=1')).json()) as any + expect(api.results.map((l: any) => l.domain)).toEqual(['alpha-build.com', 'bravo-homes.com']) + expect(JSON.stringify(api)).not.toContain('promo') + // Taking priority away leaves the listing in place. + h.promo.updateMember(h.campaign, 'alpha-build.com', { priority: false }) + expect(h.listings.directory({ limit: 100 }).listings[0]!.domain).toBe('bravo-homes.com') + expect(h.listings.directory({ limit: 100 }).listings.map((l) => l.domain)).toContain('alpha-build.com') + // Stopping the campaign removes priority for all. + h.promo.setState(h.campaign, 'stopped') + const after = h.listings.directory({ limit: 100 }).listings.map((l) => l.domain) + expect(after.indexOf('zulu-remodel.com')).toBeGreaterThan(after.indexOf('outsider.com')) + }) + + it('serves the roundup with tracked links, and its views stay campaign-level (AC-12)', async () => { + const h = setup() + const { roundup } = await launched(h) + const page = await (await h.request('/roundup/los-gatos-builders', { headers: { 'user-agent': BROWSER } })).text() + expect(page).toContain(`/b/alpha-build.com?via=${roundup.id}`) + expect(page).not.toContain('zulu-remodel.com') + expect(page).not.toContain('ann@alpha-build.com') + const m = h.promo.metrics(h.campaign, 'alpha-build.com', 0, h.clock.t + DAY) + expect(m.views).toBe(0) + const campaignViews = h.promo.db.query("SELECT COUNT(*) AS n FROM promo_events WHERE type = 'roundup_view' AND domain IS NULL").get() as { n: number } + expect(campaignViews.n).toBe(1) + }) + + it('writes spotlight text from the listing, with the tracking link last and within 300 characters', () => { + const h = setup() + const text = spotlightText(h.listings.toPublic('alpha-build.com')!, 'https://pit.world/b/alpha-build.com?via=plc_abcdefgh') + expect(text.length).toBeLessThanOrEqual(300) + expect(text.endsWith('https://pit.world/b/alpha-build.com?via=plc_abcdefgh')).toBe(true) + expect(text).toContain('CSLB license 100') + expect(text).not.toMatch(/[—–“”]/) + }) + + it('refuses spotlight accounts on networks that buy an ad per post (AC-20)', () => { + const h = setup() + expect(() => h.promo.configure(h.campaign, { spotlightAccounts: ['htmlblog:dev.profullstack.com/~anthony/blog'] })).toThrow('CrawlProof ad') + }) +}) + +describe('promotion: measurement', () => { + it('starts a window only when the listing is live, tracking passed a marked test and a placement was verified (AC-08)', async () => { + const h = setup() + h.promo.import(h.campaign, snapshot()) + h.promo.launch(h.campaign, h.promo.preview(h.campaign).version) + expect(h.promo.tick().started).toBe(0) + await h.request('/b/alpha-build.com?pt=test', { headers: { 'user-agent': BROWSER } }) + await h.request('/b/alpha-build.com/go?pt=test', { headers: { 'user-agent': BROWSER } }) + expect(h.promo.checkTracking(h.campaign).passed).toEqual(['alpha-build.com']) + expect(h.promo.tick().started).toBe(0) + const roundup = h.promo.placements(h.campaign).placements.find((p) => p.kind === 'roundup')! + h.clock.t += 3600 * 1000 + h.promo.report(roundup.id, { state: 'verified', evidence: { status: 200 } }) + expect(h.promo.tick().started).toBe(1) + const [period] = h.promo.periods(h.campaign) + expect(period.started_at).toBe(h.clock.t) + expect(period.ends_at - period.started_at).toBe(30 * DAY) + }) + + it('counts views and clicks, deduplicates beacons and excludes tests, bots, previews and internal traffic (AC-11)', async () => { + const h = setup() + const { roundup } = await launched(h) + const announce = h.promo.placements(h.campaign).placements.find((p) => p.kind === 'announcement')! + await h.request(`/b/alpha-build.com?via=${roundup.id}`, { headers: { 'user-agent': BROWSER } }) + await h.request(`/b/alpha-build.com/go?via=${roundup.id}`, { headers: { 'user-agent': BROWSER } }) + await h.request('/b/alpha-build.com', { headers: { 'user-agent': BROWSER, referer: 'https://www.google.com/search?q=x' } }) + await h.request('/b/alpha-build.com', { headers: { 'user-agent': 'Googlebot/2.1' } }) + await h.request('/b/alpha-build.com', { headers: { 'user-agent': 'facebookexternalhit/1.1' } }) + await h.request('/b/alpha-build.com', { headers: { 'user-agent': BROWSER, 'sec-purpose': 'prefetch' } }) + await h.request('/b/alpha-build.com', { headers: { 'user-agent': BROWSER } }, '198.51.100.7') + await h.request('/b/alpha-build.com?pt=test', { headers: { 'user-agent': BROWSER } }) + const beacon = { method: 'POST', headers: { 'user-agent': BROWSER, 'content-type': 'application/json' }, body: JSON.stringify({ type: 'phone_click', id: 'abcdef123456', via: announce.id }) } + await h.request('/b/alpha-build.com/e', beacon) + await h.request('/b/alpha-build.com/e', beacon) + // Only phone taps come from a beacon. + await h.request('/b/alpha-build.com/e', { ...beacon, body: JSON.stringify({ type: 'website_click', id: 'zzzzzz123456' }) }) + const m = h.promo.metrics(h.campaign, 'alpha-build.com', 0, h.clock.t + DAY) + expect(m).toMatchObject({ views: 2, clicks: 1, phone: 1, promoViews: 1, promoClicks: 1, unattributedViews: 1 }) + expect(m.exclusions).toMatchObject({ bot: 2, prefetch: 1, internal: 1 }) + expect(m.exclusions.test).toBeGreaterThanOrEqual(3) + const stored = JSON.stringify(h.promo.db.query('SELECT * FROM promo_events').all()) + expect(stored).not.toContain('203.0.113.9') + expect(stored).not.toContain('Macintosh') + expect(stored).toContain('www.google.com') + expect(stored).not.toContain('search?q') + }) + + it('does not track businesses outside a campaign', async () => { + const h = setup() + await launched(h) + await h.request('/b/outsider.com', { headers: { 'user-agent': BROWSER } }) + expect((h.promo.db.query("SELECT COUNT(*) AS n FROM promo_events WHERE domain = 'outsider.com'").get() as { n: number }).n).toBe(0) + }) + + it('runs a whole window on simulated time across the DST change, with one review per window (AC-14, AC-15, AC-17)', async () => { + const h = setup() + await launched(h) + const periods = h.promo.periods(h.campaign) + expect(periods).toHaveLength(2) + const ends = periods[0].ends_at + // 2026-11-01 is the end of daylight time in Los Angeles: the window is still 30 x 24 hours. + expect(ends - periods[0].started_at).toBe(30 * DAY) + h.clock.t = ends - 1000 + expect(h.promo.tick().due).toBe(0) + h.clock.t = ends + 1000 + const first = h.promo.tick() + expect(first).toMatchObject({ due: 2, reviewed: 2 }) + // A restarted process runs the pass again: no second review task, no second report. + expect(h.promo.tick()).toMatchObject({ due: 0, reviewed: 0 }) + expect(h.promo.reports(h.campaign)).toHaveLength(2) + expect((h.promo.db.query("SELECT COUNT(*) AS n FROM promo_jobs WHERE kind = 'review'").get() as { n: number }).n).toBe(2) + const report = h.promo.reports(h.campaign).find((r) => r.domain === 'bravo-homes.com')! + expect(report.data.metrics.listingViews!.value).toBe(0) + expect(report.data.metrics.platformImpressions!.value).toBeNull() + expect(report.data.metrics.verifiedInquiries!.value).toBeNull() + expect(report.data.period.timezone).toBe('America/Los_Angeles') + }) + + it('gives a late addition its own full window', async () => { + const h = setup() + await launched(h) + h.clock.t += 5 * DAY + h.listings.seed({ domain: 'zulu-remodel.com', name: 'Zulu Remodel', description: 'Remodels.', category: 'construction', city: 'Los Gatos', license: 'CSLB #3', licenseCheckedAt: h.clock.t }) + h.promo.updateMember(h.campaign, 'zulu-remodel.com', { holdAttestation: 'cmp_other paused by hand 2026-10-25' }) + h.promo.launch(h.campaign, h.promo.preview(h.campaign).version) + const spot = h.promo.placements(h.campaign, { domain: 'zulu-remodel.com' }).placements.find((p) => p.kind === 'spotlight')! + expect(spot.plannedAt).toBeGreaterThanOrEqual(h.clock.t) + await h.request('/b/zulu-remodel.com?pt=test', { headers: { 'user-agent': BROWSER } }) + await h.request('/b/zulu-remodel.com/go?pt=test', { headers: { 'user-agent': BROWSER } }) + h.promo.checkTracking(h.campaign, ['zulu-remodel.com']) + h.promo.report(spot.id, { state: 'publishing' } as any) + h.promo.report(spot.id, { state: 'published', providerId: 'x', publicUrl: 'https://mastodon.example/@b/1' }) + h.promo.report(spot.id, { state: 'verified', evidence: { status: 200 } }) + h.promo.tick() + const z = h.promo.periods(h.campaign).find((p: any) => p.domain === 'zulu-remodel.com') + expect(z.started_at).toBe(h.clock.t) + expect(z.ends_at).toBe(h.clock.t + 30 * DAY) + }) + + it('keeps scheduled, failed and uncertain posts out of the verified count (AC-10)', async () => { + const h = setup() + await launched(h) + const [spot] = h.promo.placements(h.campaign).placements.filter((p) => p.kind === 'spotlight') + h.clock.t = spot!.plannedAt + 1 + h.promo.lease(h.campaign, 'w') + h.promo.report(spot!.id, { state: 'scheduled', queueId: 'q1' }) + expect(() => h.promo.report(spot!.id, { state: 'verified' })).toThrow() + h.promo.report(spot!.id, { state: 'uncertain', error: 'timeout' }) + expect(h.promo.placements(h.campaign, { state: 'verified' }).placements.map((p) => p.kind)).toEqual(['roundup']) + }) + + it('records gaps and reports them', async () => { + const h = setup() + await launched(h) + h.promo.recordGap(h.campaign, { kind: 'downtime', from: h.clock.t + DAY, to: h.clock.t + DAY + 3600 * 1000, note: 'deploy' }) + h.clock.t += 31 * DAY + h.promo.tick() + expect(h.promo.reports(h.campaign)[0]!.data.gaps).toEqual([expect.objectContaining({ kind: 'downtime', note: 'deploy' })]) + }) +}) + +describe('promotion: reports and outreach', () => { + async function ended(h: ReturnType) { + await launched(h) + await h.request('/b/alpha-build.com', { headers: { 'user-agent': BROWSER } }) + await h.request('/b/alpha-build.com/go', { headers: { 'user-agent': BROWSER } }) + h.clock.t += 31 * DAY + h.promo.tick() + } + + it('quotes the report version in the draft, says zero as zero and supersedes on late data (AC-13, AC-18)', async () => { + const h = setup() + await ended(h) + const drafts = h.promo.drafts(h.campaign) + const alpha = drafts.find((d) => d.domain === 'alpha-build.com')! + expect(alpha.kind).toBe('introduction') + expect(alpha.recipientEmail).toBe('ann@alpha-build.com') + expect(alpha.body).toContain('Hi Ann,') + expect(alpha.body).toContain('1 view and 1 click') + expect(alpha.body).toContain('https://pit.world/r/') + expect(alpha.body).not.toContain('{{') + expect(alpha.body).not.toMatch(/[—–]/) + expect(alpha.body).not.toMatch(/following up|earlier note/i) + const bravo = drafts.find((d) => d.domain === 'bravo-homes.com')! + expect(bravo.body).toContain('no measurable visits or clicks') + // Late data: a corrected event lands inside the window, and the report gets a new version. + const period = h.promo.periods(h.campaign).find((p: any) => p.domain === 'alpha-build.com') + h.promo.record({ type: 'listing_view', domain: 'alpha-build.com', ip: '192.0.2.1', userAgent: BROWSER, at: period.started_at + DAY }) + const v2 = h.promo.generateReport(period.id) + expect(v2.version).toBe(2) + expect(h.promo.draft(alpha.id).state).toBe('invalidated') + expect(h.promo.createDraft(v2.id).body).toContain('2 views') + }) + + it('writes a follow-up only when contact is on record', () => { + const d = { business: { name: 'X', listingUrl: 'u' }, period: { startLocal: 'a', endLocal: 'b' }, placements: [], metrics: { listingViews: { value: 3 }, websiteClicks: { value: 1 }, phoneClicks: { value: 0 } }, referrals: { promotionViews: 0 } } as any + expect(draftText(d, { sender: null } as any, '', false, 'r').body).not.toContain('Following up') + expect(draftText(d, { sender: null } as any, '', true, 'r').body).toContain('Following up on my earlier note') + }) + + it('blocks a draft without an address, and never sends before the window ends or without authorization (AC-16, AC-19)', async () => { + const h = setup() + await launched(h) + // Mid-window: a report can be made by hand, but delivery refuses. + const period = h.promo.periods(h.campaign).find((p: any) => p.domain === 'alpha-build.com') + const early = h.promo.generateReport(period.id) + const draft = h.promo.createDraft(early.id) + await expect(h.promo.deliver(draft.id)).rejects.toThrow('not authorized') + h.promo.configure(h.campaign, { sendAuthorized: true }) + await expect(h.promo.deliver(draft.id)).rejects.toThrow('has not ended') + h.clock.t += 31 * DAY + h.promo.tick() + const final = h.promo.drafts(h.campaign).find((d) => d.domain === 'alpha-build.com' && d.state === 'draft')! + h.upstream.suppressed = null + await expect(h.promo.deliver(final.id)).rejects.toThrow('unknown') + h.upstream.suppressed = true + await expect(h.promo.deliver(final.id)).rejects.toThrow('suppressed') + h.upstream.suppressed = false + expect(await h.promo.deliver(final.id)).toMatchObject({ state: 'sent' }) + await expect(h.promo.deliver(final.id)).rejects.toThrow() + expect(h.deliveries).toHaveLength(1) + }) + + it('serves a shared report without the operator economics, as HTML, Markdown and JSON', async () => { + const h = setup() + await ended(h) + const r = h.promo.reports(h.campaign)[0]! + const token = r.shareUrl.split('/r/')[1] + const html = await (await h.request(`/r/${token}`)).text() + expect(html).toContain('Listing views') + expect(html).not.toContain('operator only') + expect(await (await h.request(`/r/${token}.md`)).text()).toContain('| Listing views |') + const json = (await (await h.request(`/r/${token}.json`)).json()) as any + expect(json.economics).toBeUndefined() + expect((await h.request('/r/not-a-real-token-at-all-xx')).status).toBe(404) + const csv = await (await h.api('GET', `/campaigns/${h.campaign}/reports.csv`)).text() + expect(csv.split('\n')[0]).toContain('listing_views') + expect(csv).toContain('alpha-build.com') + }) +}) + +describe('promotion: controls', () => { + it('pause stops new external actions and keeps everything else (AC-22)', async () => { + const h = setup() + await launched(h) + h.clock.t += 3 * DAY + h.promo.setState(h.campaign, 'paused') + expect(h.promo.lease(h.campaign, 'w').placements).toEqual([]) + expect(h.listings.directory({ limit: 1 }).listings[0]!.domain).toBe('alpha-build.com') + expect(h.promo.overview(h.campaign).periods).toEqual({ active: 2 }) + h.promo.setState(h.campaign, 'active') + expect(h.promo.lease(h.campaign, 'w').placements.length).toBeGreaterThan(0) + }) + + it('never lets concurrent reservations pass the budget, and a missing budget is zero (AC-21)', async () => { + const h = setup() + expect(h.promo.reserve(h.campaign, 'paid', 1, 'a')).toBe(false) + h.promo.configure(h.campaign, { budget: { paidCents: 1000, aiCents: 0 } }) + const results = await Promise.all(Array.from({ length: 20 }, (_, i) => Promise.resolve().then(() => h.promo.reserve(h.campaign, 'paid', 100, `job${i}`)))) + expect(results.filter(Boolean)).toHaveLength(10) + expect(h.promo.reserve(h.campaign, 'ai', 1, 'x')).toBe(false) + }) + + it('answers the API only with the admin token, and launch needs an idempotency key', async () => { + const h = setup() + expect((await h.request(`/api/v1/promotion/campaigns`)).status).toBe(401) + expect((await h.request(`/api/v1/promotion/campaigns`, { headers: { authorization: 'Bearer nope' } })).status).toBe(401) + expect((await h.api('GET', '/campaigns')).status).toBe(200) + const imported = (await (await h.api('POST', `/campaigns/${h.campaign}/imports`, { snapshot: snapshot() }, { 'idempotency-key': 'k1' })).json()) as any + expect(imported.jobId).toMatch(/^job_/) + const preview = (await (await h.api('POST', `/campaigns/${h.campaign}/preview`, {})).json()) as any + expect((await h.api('POST', `/campaigns/${h.campaign}/launch`, { version: preview.version })).status).toBe(400) + const a = (await (await h.api('POST', `/campaigns/${h.campaign}/launch`, { version: preview.version }, { 'idempotency-key': 'L' })).json()) as any + const b = (await (await h.api('POST', `/campaigns/${h.campaign}/launch`, { version: preview.version }, { 'idempotency-key': 'L' })).json()) as any + expect(a).toEqual(b) + const roster = (await (await h.api('GET', `/campaigns/${h.campaign}/businesses?limit=1`)).json()) as any + expect(roster.businesses).toHaveLength(1) + // Not excluded here, so the dead site is a member too (without a listing: "seed a verified listing"). + expect(roster.total).toBe(5) + expect((await h.request('/admin/promo')).status).toBe(401) + }) + + it('computes local posting hours in the campaign timezone', () => { + // 10:00 in Los Angeles is 17:00 UTC in daylight time and 18:00 UTC after it ends. + expect(new Date(localHour(Date.parse('2026-10-20T12:00:00Z'), 'America/Los_Angeles', 10)).toISOString()).toBe('2026-10-20T17:00:00.000Z') + expect(new Date(localHour(Date.parse('2026-11-05T12:00:00Z'), 'America/Los_Angeles', 10)).toISOString()).toBe('2026-11-05T18:00:00.000Z') + }) +}) + +describe('promotion: the myna worker', () => { + function fakeMyna() { + const state = { history: [] as MynaEntry[], queue: [] as MynaQueued[], posts: 0, mode: 'now' as 'now' | 'queue' | 'crash' } + const myna: Myna = { + history: () => state.history, + queue: () => state.queue, + post: async (account, text) => { + state.posts++ + if (state.mode === 'queue') state.queue.push({ id: `q${state.posts}`, targets: [account], text, status: 'pending', scheduledFor: 'later' }) + else if (state.mode === 'now') state.history.push({ at: new Date().toISOString(), accountId: account, text, ok: true, postId: `at://did:plc:x/app.bsky.feed.post/${state.posts}`, url: `https://bsky.app/profile/a/post/${state.posts}` }) + return { code: state.mode === 'crash' ? 1 : 0, output: '' } + }, + } + return { state, myna } + } + + function clientFor(h: ReturnType) { + process.env.PIT_ADMIN_TOKEN = 'admintoken' + return new PitClient('https://pit.world', (async (input: any, init: any) => h.app.request(String(input).replace('https://pit.world', ''), init, { ip: '127.0.0.1' })) as any) + } + + it('posts each due placement once, reconciles instead of re-posting, and verifies through the public API (AC-09, AC-15)', async () => { + const h = setup() + await launched(h) + const { state, myna } = fakeMyna() + const client = clientFor(h) + // The network's public API answers with the post, whose text carries the tracking link. + const verify = (async () => new Response(JSON.stringify({ thread: { post: { record: { text: state.history.map((x) => x.text).join(' ') } } } }), { status: 200 })) as any + // The announcement is due now; spotlights are on later days. + await workOnce(client, h.campaign, myna, { fetch: verify }) + expect(state.posts).toBe(1) + const announce = h.promo.placements(h.campaign).placements.find((p) => p.kind === 'announcement')! + expect(announce.state).toBe('published') + expect(announce.publicUrl).toContain('bsky.app') + // A second pass verifies it and never posts it again. + await workOnce(client, h.campaign, myna, { fetch: verify }) + expect(state.posts).toBe(1) + expect(h.promo.placement(announce.id).state).toBe('verified') + // A crash after myna recorded the post (lease lost, row still "publishing"): reconciled, not re-posted. + const spot = h.promo.placements(h.campaign).placements.find((p) => p.kind === 'spotlight')! + h.clock.t = spot.plannedAt + 1 + h.promo.lease(h.campaign, 'dead-worker', 1) + state.history.push({ at: new Date().toISOString(), accountId: spot.account, text: spot.body, ok: true, postId: '1', url: 'https://defcon.social/@b/1' }) + h.clock.t += 16 * 60 * 1000 + await workOnce(client, h.campaign, myna, { fetch: verify }) + expect(state.posts).toBe(1) + expect(h.promo.placement(spot.id).state).toMatch(/published|verified/) + }) + + it('keeps a queued post scheduled, and an unexplained exit uncertain until reconciled', async () => { + const h = setup() + await launched(h) + const { state, myna } = fakeMyna() + const client = clientFor(h) + state.mode = 'queue' + await workOnce(client, h.campaign, myna) + const announce = h.promo.placements(h.campaign).placements.find((p) => p.kind === 'announcement')! + expect(announce.state).toBe('scheduled') + expect(findInMyna(myna, announce).queued?.id).toBe('q1') + await workOnce(client, h.campaign, myna) + expect(state.posts).toBe(1) + const spot = h.promo.placements(h.campaign).placements.find((p) => p.kind === 'spotlight')! + h.clock.t = spot.plannedAt + 1 + state.mode = 'crash' + await workOnce(client, h.campaign, myna) + expect(h.promo.placement(spot.id).state).toBe('uncertain') + // Reconciled against history and queue: it never went, so it goes back in line rather than being re-sent blindly. + await workOnce(client, h.campaign, myna) + expect(h.promo.placement(spot.id).state).toBe('ready') + }) +}) + +afterAll(() => { + delete process.env.PIT_ADMIN_TOKEN +})