tellatribe

Enter the access code to read the project documents.

tellatribe Docs Status: All modules built and live · Oct 19: Creator Campaigns Part B shipped (briefs, applications, deals, verification → bounties, brand balance + Stripe funding, disputes, reports) on top of the tax-identity gate and annual statements · awaiting owner inputs: tellatribe Stripe account (payments + Connect + funding webhook), HealthyApp webhook secret, Ayrshare key

Requirements · build-plan/04-creator-campaigns.md

04 — Creator Campaigns: the build contract

Authority: PRD-Creator-Campaigns.md Part B, decisions 55–60 and 67–75 in DECISIONS-FOR-REVIEW.md, and migration packages/db/supabase/migrations/0017_campaigns.sql (already applied to the schema tests; do not edit it — a change is a new migration 0017+ and you must say so in your report).

Four streams build in parallel against this contract. The engine stream owns the signatures below; the admin, portal and API streams call them exactly as written. If a signature must change, the engine stream changes this file in its branch and names the change in its report; the integrator reconciles.

1. Package @affiliate-coach/campaigns (packages/campaigns)

Mirror packages/payouts in shape: src/index.ts exports, type: module, deps on @affiliate-coach/ledger (for Sql, cents), @affiliate-coach/core; devDeps @affiliate-coach/db (test harness), pglite, vitest. Tests run on PGlite via @affiliate-coach/db/test-harness + test-fixtures (the seed() there gives WS, product, program, sponsor/recruit, one conversion; add a campaignSeed(db) in the package's own test/support.ts that creates a disclosure block, an open brief with one Instagram reel deliverable, funding of $500 for the product, and a creator profile for the recruit).

All functions take (sql: Sql, input) and return plain objects. Money is bigint cents. Errors are CampaignError with a code (string literal union) and a message a screen can show. Every write that changes state writes an audit_log row (actor_kind 'admin' | 'affiliate' | 'system') with object_table and object_id, and sets the ledger trigger context (affiliate_coach.source = 'campaigns') when it writes commissions.

1.1 Pure (no SQL) — src/rules.ts

export type Tier = 'nano' | 'micro' | 'mid' | 'macro' | 'mega';
export function tierFor(followers: number): Tier;               // <10k nano · 10k–50k micro · 50k–500k mid · 500k–1M macro · ≥1M mega

export type Eligibility = {                                      // = campaigns.eligibility jsonb, every key optional
  platforms?: string[]; min_followers?: Record<string, number>; min_engagement_bp?: number; categories?: string[];
  countries?: string[]; languages?: string[]; min_quality_score?: number; requires_enrollment?: boolean;
  min_completed_deals?: number; min_wall_sales?: number;
};
export type CreatorFacts = {
  profiles: Array<{ platform: string; followers: number | null; engagement_bp: number | null; categories: string[]; country: string | null; language: string | null; quality_score: number | null; source: 'aggregator' | 'manual' }>;
  enrolledInProduct: boolean; completedDeals: number; wallSales: number;
  exclusivities: Array<{ categories: string[]; until: Date }>;    // from the creator's open deals' campaigns
};
export type EligibilityResult = { eligible: true } | { eligible: false; reason: string };  // ONE reason, plain words: "needs 1,000 followers on Instagram"
export function evaluateEligibility(e: Eligibility, facts: CreatorFacts, campaign: { exclusivity_categories: string[]; now: Date }): EligibilityResult;

export type QualityInput = { followers: number | null; engagement_bp: number | null; avg_reach: number | null; follower_growth_30d_pct?: number | null; comments_per_like?: number | null; country: string | null; audience_top_country?: string | null };
export type Quality = { score: number; word: 'good' | 'check' | 'weak'; reasons: string[] };
export function qualityScore(platform: string, q: QualityInput): Quality;   // PRD B.8 v1; benchmarks in A1.5 (IG nano 1.7–6 % etc.); null inputs reduce confidence, never crash; word: ≥70 good, 40–69 check, <40 weak

export function rateGuidance(platform: string, format: string, followers: number | null): { lowCents: bigint; highCents: bigint; sentence: string };  // "creators your size usually get $75–$200 for this" (Collabstr 2025 nano/micro $50–$300)

export function paysWords(c: { flat_fee_cents: bigint | null; bids_allowed: boolean; bid_cap_cents: bigint | null; program_rate: string | null; gifting: Record<string, unknown>; bonus: unknown[] }): string;  // "$150 + 25%" · "Bid up to $300" · "Gift · $89 kit"
export function asksWords(deliverables: Array<{ platform: string; format: string; count: number; post_window_end: Date | null }>): string;    // "1 Reel · by Nov 2"

export function sowMarkdown(input: { brand: string; creator: string; campaign: CampaignRow; deliverables: DeliverableRow[]; flatFeeCents: bigint; giftValueCents: bigint; giftDescription: string | null; platformFeeBp: number; autoApproveDrafts: boolean; disclosureShort: string }): string;  // the SOW text; includes decision 59's auto-approve clause, rights, exclusivity, FTC clauses (no sentiment requirement, no AI testimonials), payment timing
export function businessDaysAfter(from: Date, days: number): Date;     // for the 10-business-day auto-approve

1.2 Reads — src/read.ts

export function loadCampaign(sql, id): Promise<CampaignRow & { deliverables: DeliverableRow[]; product: { id; name; slug; mark_color } }>;
export function creatorFacts(sql, workspaceId, affiliateId): Promise<CreatorFacts>;
export function brandBalance(sql, workspaceId, productId): Promise<{ balanceCents: bigint; reservedCents: bigint; freeCents: bigint; fundedCents: bigint; paidOutCents: bigint; feesCents: bigint }>;

1.3 Brand side (admin) — src/brand.ts

export function createCampaign(sql, { workspaceId, actorUserId, productId, title }): Promise<{ campaignId }>;                 // draft
export function updateCampaign(sql, { workspaceId, actorUserId, campaignId, patch: Partial<CampaignEditable>, deliverables?: DeliverableInput[] }): Promise<void>;  // validates eligibility object keys, pairs (usage/exclusivity/bids); replaces deliverables when given
export function publishCampaign(sql, { workspaceId, actorUserId, campaignId }): Promise<void>;         // draft → open; CampaignError 'needs_disclosure' | 'needs_pay' | 'needs_deliverable'
export function closeCampaign(sql, { workspaceId, actorUserId, campaignId, archive?: boolean }): Promise<void>;
export function inviteCreators(sql, { workspaceId, actorUserId, campaignId, affiliateIds }): Promise<{ invited: number }>;
export function shortlist(sql, { workspaceId, actorUserId, applicationId }): Promise<void>;
export function counterOffer(sql, { workspaceId, actorUserId, applicationId, counter: { flat_fee_cents?: bigint; note?: string } }): Promise<void>;   // one round each way (counter_rounds)
export function decline(sql, { workspaceId, actorUserId, applicationId, reason }): Promise<void>;        // reason required; creator sees it
export function accept(sql, { workspaceId, actorUserId, applicationId, flatFeeCents?: bigint, giftValueCents?: bigint, giftDescription?: string }): Promise<{ dealId; status: 'funded' | 'unfunded' }>;
  // → application accepted; deal created with frozen sow + sow_md; tasks created (one per deliverable × count); affiliate enrolled in the campaign's program if any (insert affiliate_programs + terms acceptance as the portal's enroll does; the 0013 trigger mints the link);
  //   escrow: if free balance ≥ flat fee → brand_balance_entries escrow_hold and deal 'funded', else deal stays 'unfunded' (queue item). Decision 69.
export function fundWaitingDeals(sql, { workspaceId }): Promise<{ funded: string[] }>;                 // sweep: unfunded deals whose brand now has free balance → hold + funded
export function reviewDraft(sql, { workspaceId, actorUserId, taskId, decision: 'approve' | 'changes', note? }): Promise<void>;  // changes requires note; ≤2 rounds then 'approve' only
export function autoApproveStaleDrafts(sql, { workspaceId, now }): Promise<{ approved: string[] }>;   // decision 59: draft_submitted older than 10 business days on campaigns with auto_approve_drafts → draft_approved with task_reviews decision 'auto_approve'
export function approveUnverifiable(sql, { workspaceId, actorUserId, taskId, note? }): Promise<void>; // brand approval where verification is impossible → same as verifyTask
export function cancelDeal(sql, { workspaceId, actorUserId, dealId, reason }): Promise<void>;         // escrow_release; tasks left as they are; cannot cancel 'verified'/'paid'
export function decideDispute(sql, { workspaceId, actorUserId, disputeId, decision: 'release' | 'refund' | 'split', note, splitCents? }): Promise<void>;
export function recordFunding(sql, { workspaceId, productId, amountCents, stripeRef, pending?: boolean }): Promise<{ entryId: string | null; duplicate: boolean }>;  // idempotent on stripeRef; pending=true (ACH not settled) writes NO entry but returns duplicate:false — the API decides; after settle call again with pending false
export function refundFunding(sql, { workspaceId, actorUserId, productId, amountCents, stripeRef, reason }): Promise<void>;   // negative adjustment; refuses if free balance < amount

1.4 Creator side (portal) — src/creator.ts

export function upsertCreatorProfile(sql, { workspaceId, affiliateId, platform, handle?, followers?, engagement_bp?, avg_reach?, categories?, country?, language?, source: 'manual' | 'aggregator' }): Promise<void>;  // recomputes quality_score/word/reasons
export function apply(sql, { workspaceId, affiliateId, campaignId, pitch?, bidCents? }): Promise<{ applicationId }>;
  // CampaignError 'not_open' | 'not_invited' | 'ineligible' (message = the reason) | 'bid_not_allowed' | 'bid_over_cap' | 'already_applied' | 'exclusivity'; snapshot = profiles + facts at apply time
export function withdraw(sql, { workspaceId, affiliateId, applicationId }): Promise<void>;
export function respondToCounter(sql, { workspaceId, affiliateId, applicationId, accept: boolean }): Promise<void>;   // accept → application stays 'countered' until the brand accepts; semantics: creator agreement recorded in counter.creator_accepted_at
export function acceptSow(sql, { workspaceId, affiliateId, dealId }): Promise<void>;                   // sow_accepted_creator_at; funded deal → in_progress when both accepted
export function submitDraft(sql, { workspaceId, affiliateId, taskId, draftUrl?, storagePath?, note? }): Promise<void>;
export function acknowledgeDisclosure(sql, { workspaceId, affiliateId, taskId }): Promise<void>;
export function markPosted(sql, { workspaceId, affiliateId, taskId, postUrl, publishJobId? }): Promise<void>;   // requires disclosure_ack_at; inside post window (CampaignError 'outside_window')
export function openDispute(sql, { workspaceId, affiliateId?, dealId, openedBy: 'creator' | 'brand', reason }): Promise<{ disputeId }>;
export function appealDispute(sql, { workspaceId, affiliateId, disputeId, note }): Promise<void>;

1.5 Verification and money — src/verify.ts

export type PostLookup = {   // port; the API binds an aggregator adapter, tests a fake
  fetchPost(input: { affiliateId: string; platform: string; postUrl: string | null; publishJobId: string | null }): Promise<{ found: boolean; url: string | null; caption: string | null; paidPartnershipLabel: boolean | null; metrics: Record<string, number> | null } >;
};
export function verifyTask(sql, { workspaceId, taskId, by: 'worker' | 'brand', actorUserId?, found: { url; caption; label; metrics } | null, requiredTags: string[], requiredHandles: string[], disclosureShort: string }): Promise<{ outcome: 'verified' | 'failed_verification'; bountyCommissionId: string | null }>;
  // verified → task verified → bounty commission (kind 'bounty', amount = deal.flat_fee_cents / number of tasks, floored, remainder on the last task; born approved unless campaign.bounty_hold_days > 0 → pending with hold_until) → brand_balance bounty + platform_fee entries → rights_grants row if usage_rights ≠ none (starts_at = posted_at, expires_at = + usage_months) → task completed → when every task is completed: deal 'delivered' → 'verified' → 'paid' is set by the payout run? NO: deal 'paid' is set by `markDealsPaid` below.
  // missing label/disclosure → failed_verification with verification_note (fix-it step, not a penalty; PRD B.10)
export function verificationSweep(sql, lookup: PostLookup, { workspaceId, now }): Promise<{ checked: number; verified: number; failed: number }>;  // tasks in awaiting_post/posted_unverified inside or after their window
export function captureMetrics(sql, lookup, { workspaceId, now }): Promise<{ updated: number }>;      // 24h/72h/7d/30d buckets into deal_tasks.metrics
export function markDealsPaid(sql, { workspaceId }): Promise<{ paid: string[] }>;                     // deals 'verified' whose bounty rows are all state 'paid' → 'paid'
export function flagDeletedPosts(sql, lookup, { workspaceId, now }): Promise<{ flagged: string[] }>;  // completed tasks inside the usage window whose post is gone → fraud_flags kind 'manual' + bounty row flagged (PRD B.12); admin decides the reversal
export function rightsReminders(sql, { workspaceId, now }): Promise<{ due: Array<{ dealId; expiresAt }> }>;  // 14 days before expiry, once (reminder_sent_at)

1.6 Reputation — src/reputation.ts

export function creatorReputation(sql, affiliateId): Promise<{ deals: number; onTimePct: number | null; line: string }>;   // "4 deals · 100% on time"
export function brandReputation(sql, productId): Promise<{ deals: number; medianReviewHours: number | null; lateApprovals: number }>;

1.7 Engine stream notes (built Oct 19) — what differs from the text above

All signatures above are implemented as written; these are the additions and the places where the schema forced a call. Decisions 76–86.

  • Migration 0019_campaigns_vocabulary.sql (with packages/db/test/0019_campaigns_vocabulary.test.ts): commission_events.source admits campaigns; fraud_flags.kind admits post_deleted (used by flagDeletedPosts instead of manual, which 0008 removed); brand_balances.reserved_cents is redefined as holds − releases over open deals (decision 76). 0016 is untouched.
  • Balance semantics (decision 76): brandBalance() returns freeCents = Σ entries (the view's balance_cents), reservedCents from the view, balanceCents = free + reserved. The engine writes an escrow_release for the consumed hold alongside each bounty + platform_fee. Read freeCents for "can this brand pay"; the strip is free · reserved · funded as before.
  • Additive type changes: CreatorFacts.exclusivities[] gains optional brand?: string (the reason names it); creatorFacts(sql, workspaceId, affiliateId, productId?) takes an optional product to scope enrolledInProduct (apply passes the campaign's product; omitted = any program in the workspace); upsertCreatorProfile accepts optional follower_growth_30d_pct, comments_per_like, audience_top_country for aggregator refreshes.
  • New error codes beyond the ones listed: program_has_no_terms (accept), has_deals (updateCampaign replacing deliverables after a deal exists), invalid_pair, gift_needs_description, review_rounds_exhausted, needs_post_url, deal_disputed, split_needs_amount, split_not_possible, balance_short, sow_not_accepted, deal_not_active, draft_needs_content, draft_required, dispute_already_open, dispute_window_closed, dispute_not_appealable. Full union in src/errors.ts.
  • Also exported for the other streams: captionChecks, bountySplit, giftValueOf, usd, pct, shortDate, platformName, formatName, validateEligibility, loadDeal/loadTask/loadDealTasks/loadApplication/loadDeliverables, and the row types (CampaignRow, DealRow, TaskRow, ApplicationRow, …).
  • Audit rows on the creator path carry actor_kind 'affiliate' with actor null and the affiliate id inside after (there is no auth actor on the service path).

2. Admin (apps/admin) — the Campaigns section

Nav item Campaigns between Collateral and Payouts. Screens (same shell, same card/dense table idiom, same action-kit pattern gate → lib call → back()):

  1. /campaigns — list: title, product mark, status, applicants, hired/target, delivered, spend/budget, apply-until; Balance strip per brand (free · reserved · funded) with Add funds (opens Stripe Checkout via a server action using payoutStripeClient(); success_url /campaigns?funded=1, cancel_url /campaigns; metadata {workspace_id, product_id}; payment_method_types: ['card','us_bank_account']; minimum $100; the entry is written by the API webhook, so after success the strip shows "pending settlement" until the webhook lands) and "Brand pays the processing fee" line (decision 73).
  2. /campaigns/new and /campaigns/[id]/edit — the ten-minute builder in PRD B.7 order: who (eligibility, with a live eligible-creators count computed with evaluateEligibility over the workspace's creator_profiles), what (deliverables + collateral attached from the vault), how much (flat / bids+cap / commission program / gifting / bonus; the rateGuidance line), rights, dates, budget, publish or save. Preview as a creator (render the Wall tile words via paysWords/asksWords).
  3. /campaigns/[id] — detail: applicants ranked (fit = eligibility passed + quality word + snapshot followers + reputation), accept (with gift value/description fields) / decline (reason) / counter; bulk accept; deals table with status and next action; deliverables review queue (draft preview link, approve / request changes with note, round counter, auto-approve countdown); verification status + metrics; disputes; rights grants with expiry; per-campaign report (cost, CPE, CPV, attributed sales via existing attribution where program_id set, creator leaderboard) with CSV.
  4. Queue (lib/admin/queue.ts) gains kinds: dispute, failed_verification, unfunded_deal, weak_applicant (quality word weak on an open application). Drawer resolves each to the campaign detail.
  5. Reports — paidByRecipient adds a giftValueCents column (Σ deals.gift_value_cents for deals 'verified'/'paid' whose completed_at falls in the year) and the CSV/1099 total shows paid + gift (decision 67).

Tests in apps/admin/test/campaigns.test.ts on bootAdmin(): list/detail reads, Checkout session params (pure builder), queue kinds, report gift column. Typecheck clean.

3. Portal (apps/portal) — creator side

  1. Wall: an Open calls row above the brand rows when my_campaigns has rows. Tile = same 4:5 geometry (components/wall/Tile.tsx idiom) with the product tint; poster line = paysWords; foot chip = asksWords; dimmed (opacity .45, no focus ring) when evaluateEligibility fails, with the one reason under the title. Tap → Campaign sheet (Sheet component): brief, deliverables, rights in plain words, rate guidance, Apply (disabled with the reason when ineligible). Data: lib/wall/campaigns.ts reads my_campaigns, my_campaign_deliverables, my_creator_profiles, my_applications, my_deals; facts via service-role creatorFacts.
  2. Apply sheet: connected accounts (from my_social_connections) shown; Your numbers per platform (handle, followers, engagement %) editable → upsertCreatorProfile(source 'manual') with the "unverified until connected" note; pitch (≤300, optional); bid (when allowed, cap shown); disclosure + rights acknowledgement checkbox; Apply → apply(); confirmation "You'll hear back by {apply_until}". Errors are the CampaignError message.
  3. Studio → Deals (/studio/deals, segment or section in StudioScreen): one card per deal with the next action ("Accept the agreement", "Upload draft", "Post by Thursday", "We're checking the post", "Paid Nov 1", "Changes requested: …"); SOW sheet with Accept → acceptSow; draft upload (file to the collateral bucket at /{ws}/deals/{deal}/{task}/ via signed upload URL, or a link) → submitDraft; change requests inline from my_task_reviews; Post it reuses the Composer with the post window enforced (cannot schedule outside post_window_start..end; the composer's existing preflight gets a window arg) and the collateral/teleprompter one tap away (/studio/record/<asset> when the deliverable has collateral_asset_id); after posting → markPosted (post_url from the publish job's external_url when Post-for-me, typed when Copy & post); disclosure acknowledgement checkbox before Post it → acknowledgeDisclosure. Dispute: "Something wrong?" → openDispute (within 7 days of delivery) and appeal.
  4. Earnings: lib/earnings/ledger.ts learns kind bounty ("Reel for HealthyApp · $150 · verified Oct 20 · pays Nov 1"; the why-lines name the deal); gift shows on the Deals card as "gift · $89 · not cash". Tab count for Studio includes open deals.
  5. Reputation line on the profile/settings: creatorReputation.
  6. PortalContext is unchanged; read deals/campaign counts in the screens' loaders.

Tests in apps/portal/test/campaigns.test.ts: pure words/next-action mapping, window enforcement, ledger bounty words. Device: design and check at 390 first (clients are on mobile).

4. API (apps/api)

  1. POST /api/stripe/funding — own-account webhook on the payout Stripe account (STRIPE_FUNDING_WEBHOOK_SECRET). Verify signature; insert into stripe_webhook_events (endpoint 'funding' — add the value to the endpoint check if 0006 constrains it: that is a migration 0017; say so) first for idempotency; checkout.session.completed with payment_status = 'paid' → recordFunding (amount = amount_total, ref = session id, metadata workspace/product); payment_status = 'unpaid' (ACH) → recorded as pending, no entry; checkout.session.async_payment_succeeded → recordFunding; async_payment_failed → audit only; charge.refunded → audit (admin does refundFunding by hand); refuse events carrying account (those belong to /connect).
  2. GET|POST /api/cron/campaigns every 15 min (add to vercel.json): per workspace fundWaitingDeals, autoApproveStaleDrafts, verificationSweep + captureMetrics with the aggregator adapter (PostLookup over the publishing package's AyrshareClient.postStatus when the task has a publish_job_id; otherwise found from the typed post_url only — label unknown → null), markDealsPaid, flagDeletedPosts, rightsReminders (audit row only; mail is not wired). Honest no-key state: without AYRSHARE_API_KEY the sweep still verifies by URL presence and says so in the report.
  3. Wire through lib/handlers.ts the same way payouts are; tests in apps/api/test/campaigns.test.ts with a signed fake event (see packages/payouts/test/support.ts connectEvent/signedHeader for the pattern) and a fake PostLookup.
  4. .env.example: STRIPE_FUNDING_WEBHOOK_SECRET.

5. Rules for every stream

  • Don't edit 0016. New schema = 0017+ with a test, and say so.
  • Don't touch another stream's directories except the two shared files you must: apps/docs/docs.json (add your screens/docs under a campaigns group; keep JSON valid) and DECISIONS-FOR-REVIEW.md (append numbered decisions ≥ 76, one line each, under a ## Creator Campaigns build (Oct 19 → ) heading that already exists).
  • Floor, never round. workspace_id on every row. Composite FKs are in the schema; respect them in inserts.
  • Finish with npx vitest run green in your package/app and npx tsc --noEmit clean there. Report: what you built, what you changed in this contract, what is left.

Rendered from build-plan/04-creator-campaigns.md. Edit the source, not this page.