# Freebee docs > Freebee is a measured-audience mall-screen advertising platform (DOOH): advertisers book screens and run video, image or game ads; ops run the screens; shoppers play on the wall and win coupons. These documents describe what the product does, where it stands, and the contracts the code is built against. Site: https://freebeeads.com · Full text of every doc in one file: https://freebeeads.com/llms-full.txt ## Product docs - [Status board](https://freebeeads.com/docs/md/status.md): What is done, what is half done and what is still to do across the whole platform, with a priority on each line. Start here before planning any work. - [End-to-end flow](https://freebeeads.com/docs/md/flow.md): Who does what, in what order, from an advertiser signing up to the next-morning report — plus links, metrics, booking, bidding, failures, OTP, AI and the native player. - [UI/UX analysis and plan](https://freebeeads.com/docs/md/ui-plan.md): Where the three web surfaces stand, what the reference dashboards do better, the new information architecture, and the page-by-page restructure with its rollout. - [All diagrams](https://freebeeads.com/docs/diagrams): every drawing from the product docs, each with a one-line reading (HTML page; the markdown files describe each diagram in place). ## Engineering docs - [API contract](https://freebeeads.com/docs/md/repo/api-contract.md): The v1 HTTP contract both the FastAPI backend and the web app are built against. - [Cricket design](https://freebeeads.com/docs/md/repo/cricket-design.md): Hit for Six — the cricket wall, the kiosk, the code, the dice and the prize bands. - [Cricket storyboards](https://freebeeads.com/docs/md/repo/cricket-storyboards.md): Every ball on the wall as a three-beat scene, and what the commentator says. - [Deploy](https://freebeeads.com/docs/md/repo/deploy.md): How the backend (Railway) and the web app (Vercel) ship from one GitHub repo. - [Design doc](https://freebeeads.com/docs/md/repo/design-doc.md): The locked premises and the system design the platform was built on. - [Mobile spec](https://freebeeads.com/docs/md/repo/mobile-spec.md): One Flutter app for both advertisers and ops, role-gated at runtime. - [Native player plan](https://freebeeads.com/docs/md/repo/native-player-plan.md): The software installed on the screen device: shells per platform, registration, offline, updates, hardware and the build order. - [Reference sites](https://freebeeads.com/docs/md/repo/reference-sites.md): Copy and design inventory of the sites the marketing site is modelled on. - [Secrets rotation](https://freebeeads.com/docs/md/repo/secrets-rotation.md): How to rotate the Railway secrets without losing wallet phones or locking anyone out, in the right order. - [Support and incidents](https://freebeeads.com/docs/md/repo/support-and-incidents.md): How advertisers reach us, and the numbered runbooks for a screen down, a wrong ad playing, an OTP outage, a hosting outage and an erasure request. - [Backups](https://freebeeads.com/docs/md/repo/backups.md): Manual Postgres and media-volume backups on Railway, where to store them, the monthly restore drill and the retention rule. - [Player setup](https://freebeeads.com/docs/md/repo/player-setup.md): How to make a Windows mini PC, a Mac mini or an Android box open the wall on boot, what the Devices page shows, and the remote fixes. - [Testing and CI](https://freebeeads.com/docs/md/repo/testing.md): How to run the backend tests, the web build and the Playwright journeys locally, and what GitHub Actions runs on every push. - [Uptime and error tracking](https://freebeeads.com/docs/md/repo/uptime.md): The two monitors to create, what to alert on, and how Sentry is wired behind environment variables with no personal data. - [Product analytics](https://freebeeads.com/docs/md/repo/analytics.md): The events we track on ourselves, the sign-up to first-booking funnel, and days to first booking. - [UI standard](https://freebeeads.com/docs/md/repo/ui-standard.md): The one light system: tokens, type scale, registers, and the app shell. --- # Freebee status board Last updated Oct 26, 2026 · @praveen · Page: https://freebeeads.com/docs/status · Markdown: https://freebeeads.com/docs/md/status.md · All docs: https://freebeeads.com/llms-full.txt One table for the whole platform as of today: 61 things are done and verified (25 built on 24 October and 12 on 26 October), 3 are half done, 4 are not started, and a short list at the end needs the founder. Status and priority are dropdowns, so the team can keep this current. "Done" means built, tested in the browser and live on Vercel or Railway; "half done" means it works in a test mode or with a manual step; "to do" means not started. ## Done Built, checked in the browser, and live. The priority column says how soon the missing piece next to it matters. ### Built earlier | Area | Item | Status | What exists | What is missing | Priority | | --- | --- | --- | --- | --- | --- | | Advertiser | Sign up, sign in, onboarding | Done | Email and password, business name, profile; password reset and team roles added 24 Oct; remove a member and change roles added 26 Oct | — | Later | | Advertiser | Home with the four metric bands | Done | Delivery, Audience, Engagement, Value with deltas, sample numbers, schedule, live screens, checklist, glossary; 14-day plays line added 26 Oct | — | Later | | Advertiser | Campaigns list and campaign report | Done | Status tabs, search, report with bands, funnels, tables, rail, CSV and proof-of-play downloads; PDF report added 26 Oct | — | Later | | Advertiser | Creatives: upload a video or image | Done | Format checks, grid with previews and thumbnails, rename, replace, delete, used-in count; conversion, trim, fit, enhance and preview clip added | Switch ffmpeg on in the Railway image (one line) | Next | | Advertiser | Create a game ad (5-step editor) | Done | Pick a game with live samples, brand, images with examples and one-click enhance, prizes with stock and validity, AI copy suggestions and name guard, review and send | Anthropic key to switch the AI buttons on | Later | | Advertiser | Book a screen with hour windows | Done | Stepper, summary rail, hour grid with clashes, next-free-window hint, success card; bid step for busy windows | — | Later | | Advertiser | Reports page | Done | Every CSV and PDF export in one place; weekly email report toggle | — | Later | | Advertiser | Per-screen view | Done | Playing now, today's timeline, bands for my plays | — | Later | | Ops | Today page | Done | Alerts, network bands, schedule timeline, approvals queue, screens needing attention, background jobs card, waitlist count, mall deck link, glossary | Push alerts | Next | | Ops | Screens: health, screen list, metrics | Done | Last check-in, held batches, add and edit screens with hours, rates, sound; 7-day sparklines; remote restart | — | Later | | Ops | Bookings list and week calendar | Done | Grouped by advertiser, phase tabs, calendar blocks by hour window, mark paid with method; Bidding tab | — | Later | | Ops | Approvals board | Done | Waiting, approved, rejected; slide-over with play test, preview clip, footage overrides, three-tick sign-off, AI name flags; advertiser message on decision | — | Later | | Ops | Footage library (three levels) | Done | Library, per creative, per screen; thumbnails per clip; cricket stadium set and spin effects | — | Later | | Ops | Pair a device | Done | Six-character code, claim, wait for the player; Devices page with health | — | Later | | Screen | Browser player (the wall) | Done | Loop, schedule by hour window, assets, play records, pairing, offline cache, remote commands, people counting, sound setting | Android build of the native shell | Next | | Screen | Hit for Six cricket wall | Done | Two-shot footage, Freebee dice with bee faces, packs on the shelf, prizes, ad break, idle loop, fits small frames, three-language instructions, sound cues | Outcome clips re-shot from the pitch camera | Later | | Screen | Kiosk and phone join page | Done | Code pad, name with block list and AI check, avatars, prize ladder, scan beacon; English, Telugu and Hindi | — | Later | | Screen | Spin, wheel and catch games | Done | Engines, coupons, offline fallback reward, sound cues | — | Later | | Data | Play record and metrics pipeline | Done | Plays, camera counts, engagement, cost per play, held batches, IST days; proof of play v2 | — | Later | | Data | Shopper wallet and coupons | Done | Claim link, OTP flow, coupon list, redeem once, erase wallet; three languages | Real OTP delivery (AiSensy account) | Now | | Site | Landing page | Done | Seven sections, live game wall in the hero, pricing, FAQ, real product screenshots, early-access waitlist | Case study | Later | | Site | Team docs at /docs | Done | Status board, flow doc, UI plan, nine diagrams, 20+ engineering runbooks, every doc as markdown, llms.txt | — | Later | | Infra | Backend on Railway, frontend on Vercel, demo data seeded | Done | Postgres, volume for media, auto-deploys, migrations on deploy, built-in scheduler, 630 backend tests, CI green | Restore drill, staging environment | Next | | Infra | Plain-English copy and glossary | Done | Every advertiser and ops string reviewed; shopper pages in three languages | Native-speaker check of the Telugu and Hindi | Next | ### Built on 24 October | Area | Item | Status | What exists | What is missing | Priority | | --- | --- | --- | --- | --- | --- | | Payments | Billing and invoices | Done | GST invoices, Billing page, printable invoice, ops mark paid with method, void and credit notes; Razorpay wired behind env vars | Razorpay account and keys; seller GSTIN and bank details in env | Now | | Advertiser | Password reset | Done | Forgot and reset pages, 30-minute single-use links, email provider (log or Resend) | Resend account and sender domain | Now | | Advertiser | Team roles | Done | Invite by email, owner, editor and viewer, data shared across the business, viewer writes refused; remove and change role (26 Oct) | — | Later | | Advertiser | Messages to advertisers | Done | In-app inbox and bell for every event; same text by email once a provider is set; weekly report (26 Oct) | WhatsApp channel | Next | | Screen | Device health and remote fixes | Done | Health in every check-in, Devices page with sick screens first, Reload, Restart the loop, Clear cache, Refetch schedule | — | Later | | Screen | Offline play and queued records | Done | Keeps looping from cache without wifi, overlay shows the offline state, records flush in order with no double counts | — | Later | | Data | Proof of play | Done | Signed CSV or JSON of every play record; v2 binds the booking, screens and dates; public verify endpoint | — | Later | | Legal | Terms, privacy, camera notice, content policy | Done | Four public pages with DPDP wording, consent links in the wallet and play pages, sign-up consent line | Lawyer review; company details | Now | | Ops | Mall sign-off in approvals | Done | Three required ticks plus a mall reference, enforced by the API | — | Later | | Ops | Support and incident runbook | Done | Channels, severities, five numbered runbooks | Create the support address and WhatsApp number | Now | | Infra | Backups | Done | Backup script plus runbook for Postgres and media on Railway | First manual backup, private bucket, monthly restore drill | Now | | Infra | Secrets rotation tooling | Done | Script re-keys every wallet phone to a new SECRET_KEY; OPS_PASSWORD change rotates the login | Run the rotation after the testing round | Now | | Quality | Tests in CI | Done | GitHub Actions on Node 24: backend tests, type check, web build, Playwright journeys; green on main | — | Later | | Quality | Playwright journey suite | Done | 60+ tests: every journey plus a 40-page smoke | Run the read-only smoke against production weekly | Later | | Infra | Error tracking and analytics | Done | Sentry and PostHog wired behind env vars, personal data scrubbed, health endpoint for monitors | Sentry and PostHog accounts, two uptime monitors | Next | | Site | Product screenshots on the landing page | Done | Six real screenshots in an Inside the product section | Re-shoot after the next design pass | Later | | Screen | Native player plan and desktop shell | Done | Full plan on /docs; Electron kiosk shell for Mac and Windows; Android project skeleton | Android build (needs the SDK licence accepted); signing; bench hardware | Next | | Ops | Prize fulfilment | Done | Stock per tier, expiry reminders, per-phone daily limits, ops fulfilment card | Approve the reminder template in AiSensy | Next | | Screen | Upload conversion, trim and fit | Done | MOV, WebM, MKV and AVI converted to H.264 MP4, trim and fit-to-screen edits | Switch ffmpeg on in nixpacks.toml | Next | | Screen | AI name check | Done | Second opinion after the block list, 1.5 second timeout, fails open, cached per name | Anthropic API key | Later | | Site | Docs as markdown for AI tools | Done | llms.txt, llms-full.txt, every doc at /docs/md | — | Later | | Wallet | WhatsApp sender | Done | AiSensy client for the OTP, cricket code and coupon reminders | AiSensy account, three approved templates, API key | Now | | Quality | Security review | Done | OWASP pass over every route with 144 tests: critical payment-confirm hole, viewer gaps, throttles, headers, dependency bumps; full CSP added 26 Oct | — | Later | | Quality | QA pass on the combined build | Done | Journeys green, CI green, 13 bugs fixed | — | Later | | Advertiser | Bidding for busy windows | Done | Ops mark busy windows with a floor and close time; sealed bids, top bids win and pay their bid, losers told; closes itself every 5 minutes | Decide first-price or second-price; whether losers get a credit | Next | ### Built on 26 October | Area | Item | Status | What exists | What is missing | Priority | | --- | --- | --- | --- | --- | --- | | Infra | Built-in scheduler | Done | Runs bidding close, daily messages, coupon reminders and the weekly report on its own; job log and Run now on ops Today; verified on Railway | — | Later | | Security | Content security policy and session hardening | Done | Per-request nonce CSP, proof v2, viewers cannot redeem, owners manage members | Test Razorpay checkout against the CSP on staging before switching payments on | Next | | Screen | Telugu and Hindi on shopper pages | Done | Phone, kiosk, wallet, claim, reward pages and the wall; switch remembered; QR can preset the language | Native-speaker check of the marked strings | Next | | Data | People counting (audience measurement) | Done | Counts people walking past as well as faces; modes people, faces, off; calibration page with a walk-past test; about 13% of one core | Walk-past test on a real screen against a hand count | Next | | Advertiser | AI helpers | Done | Copy suggestions, name guard, one-click image enhance, preview clip from an image; all explained, advertiser decides | Anthropic key for copy and the guard; ffmpeg for clips | Later | | Site | Mall pitch deck | Done | Ten slides at /deck with presenter notes, live example calculator, print to PDF | Fill in your contact details in the deck | Now | | Screen | Sound on the wall | Done | Six soft generated cues, off by default per screen, mute on the player | Replace generated clips with recorded ones if wanted | Later | | Site | Waitlist | Done | Early-access form on the landing page, ops list with CSV | — | Later | | Advertiser | PDF report and weekly email | Done | Print-ready report page; Monday email with the four headline numbers; toggle on Reports | Resend account for delivery | Next | | Ops | Clip thumbnails and sparklines | Done | Poster frames for every clip; 7-day plays per screen | ffmpeg on Railway for thumbnails | Next | | Ops | Mall partner side and partner API | Done | Mall login and dashboard with the four bands, monthly revenue-share statements, holiday hours and maintenance mode with a Back soon card on the wall, partner API keys with three read-only endpoints, venue branding on the shopper pages; 37 tests | Decide statement month basis (paid date or flight dates) and whether share changes touch unpaid months | Next | | Screen | Native player on Android | Done | Debug and release APKs build on this Mac (Gradle 8.11, AGP 8.7, Kotlin 2.0); watchdog, boot start and device-owner setup fixed to match the desktop shell; APKs in ~/Desktop/freebee-player-android | Real release keystore; a test box; device-owner provisioning; first soak test | Next | ## Half done Works in a test mode or with a manual step, or is being merged right now. | Area | Item | Status | What exists | What is missing | Priority | | --- | --- | --- | --- | --- | --- | | Infra | Secrets and launch switches | Half done | Rotation tooling and runbook ready | Rotate the three secrets; real domain; unset WALLET_OTP_DEV | Now | | Wallet | OTP delivery | Half done | Sender built and tested; code shown on screen in test mode | AiSensy account, templates and key; switch test mode off | Now | | Screen | Cricket footage | Half done | Pitch-camera delivery clip, Indian cast, right-handed batter | Outcome clips still use the older crease cast | Later | ## To do Not started. Two of these are big enough to plan together before building. | Area | Item | Status | What exists | What is missing | Priority | | --- | --- | --- | --- | --- | --- | | Infra | Staging environment | To do | — | A second Railway and Vercel environment for testing before main | Next | | Ops | Playlists, layout zones, widgets | To do | One loop per screen | Playlists per screen, zones, weather and offer widgets; plan together before building | Later | | Infra | Multi-city and time zones | To do | IST everywhere | Regions, currency, screen groups, per-screen time zone; plan together | Later | | Site | Case study | To do | — | Needs a real pilot; nothing invented | Later | ## Needs you Accounts, keys and decisions only the founder can supply. Each line names what it unblocks. | What | Where | Unblocks | | --- | --- | --- | | AiSensy account, sender number, three templates (freebee_otp, freebee_cricket_code, freebee_coupon_reminder), API key | aisensy.com | OTP delivery, cricket code, coupon reminders | | Resend account and a verified sender domain | resend.com | Password reset, team invites, messages, weekly report | | Razorpay account, three keys, webhook URL | razorpay.com | Online payment on the Billing page | | Sentry and PostHog accounts, two uptime monitors | sentry.io, posthog.com | Error alerts, the sign-up funnel | | Anthropic API key, then AI_PROVIDER=anthropic | console.anthropic.com | AI name check, copy suggestions, name guard | | A business email on freebeeads.com (the domain is live: site at freebeeads.com, API at api.freebeeads.com, set up 26 Oct) | Cloudflare email routing or Google Workspace | Resend sender, support address, every account sign-up | | Rotate SECRET_KEY, OPS_PASSWORD and the Postgres password | /docs/repo/secrets-rotation | Launch | | Uncomment ffmpeg in backend/nixpacks.toml | repo | Video conversion, trim, fit, thumbnails, preview clips | | Lawyer review of the four legal pages; native-speaker check of the Telugu and Hindi | /terms, /privacy, /camera-notice, /content-policy, docs/LANGUAGES.md | Launch | | Seller GSTIN, address, state code and bank details in Railway env | Railway variables | Correct invoices | | Create the real Android release keystore (keytool command in player-android/README.md) and back it up twice; bench hardware (about ₹50,000) | /docs/repo/native-player-plan | Native player on Android | | Fill DECK_CONTACT in web/components/deck/slides.tsx | repo | Mall pitch deck | | Decisions: first-price or second-price bidding; losers credit; statement month basis; editors paying invoices | status board | Bidding, billing, mall statements | --- # Freebee end-to-end flow Last updated Oct 23, 2026 · @praveen · Page: https://freebeeads.com/docs/flow · Markdown: https://freebeeads.com/docs/md/flow.md · All docs: https://freebeeads.com/llms-full.txt Who does what, in what order, from an advertiser signing up to the report the next morning. After the main flow: the links and logins, the metrics we collect, the booking flow, bidding for busy windows, what happens when something fails, OTP and coupons, upload rules, where AI fits, the native player for Android, Windows and Mac, and how we talk to the advertiser. Anything marked proposed is not built yet. ## The flow > **Diagram: End-to-end flow** (end-to-end flow · 4 lanes, 13 steps). Ops can add and pair screens at any time; an ad only reaches the wall once it is approved and booked. Drawing: https://freebeeads.com/docs/diagrams#flow Ops can add and pair screens at any time; an ad only reaches the wall once it is approved and booked. The highlighted boxes are the Hit for Six loop in the mall: the wall shows a QR code, the shopper gets a code on their phone, enters it at the kiosk, rolls the dice ball by ball, and a prize lands in their phone wallet. The camera and the play record count everything, and both the advertiser's report and the ops Today page fill in the next morning. ## Links Everything runs at https://freebeeads.com. The demo campaigns are booked on the screen "Escalator Court — North Portrait". | Page | Link | Who | | --- | --- | --- | | Sign in | https://freebeeads.com/login | everyone | | Sign up with your own email | https://freebeeads.com/signup | advertisers | | Advertiser Home, Campaigns, Creatives, Reports, Book | /dashboard · /campaigns · /creatives · /reports · /book | advertisers | | Create a game ad | https://freebeeads.com/creatives/game/new | advertisers | | Ops Today, Screens, Bookings, Approvals, Footage, Devices | /ops · /ops/screens · /ops/bookings · /ops/approvals · /ops/footage · /ops/pair | ops | | The wall (player, shows a pairing code) | https://freebeeads.com/player | one laptop or TV per screen | | Kiosk beside the wall | https://freebeeads.com/kiosk/ba1be657-dab3-4281-94ac-29a3c512e084 | a tablet | | Phone join page (what the wall's QR opens) | https://freebeeads.com/play/ba1be657-dab3-4281-94ac-29a3c512e084 | any phone | | Shopper's coupon wallet | https://freebeeads.com/w | any phone | Logins: demo advertiser `demo@dooh.local / demodev123`; ops `ops@dooh.local` with the ops password shared one to one. Friends can also sign up with their own email (password of 8 characters or more, plus a business name). There is no password reset yet. ## Metrics we collect Every number comes from one of three sources: the play record written by the screen, the camera on the screen, or the game itself. Nobody is identified; the camera keeps counts only and the phone number of a wallet user is stored encrypted and never shown to an advertiser. | Metric | Comes from | Shown where | When it is missing | | --- | --- | --- | --- | | Plays | the screen writes a play record when an ad finishes | Delivery band on Home, campaign report, ops Today and Metrics | screen off or unpaired: plays stop; the Delivery band shows 0 and ops Today raises an "offline" alert | | Opportunity to see (walked past) | camera on the screen, person count while the ad played | Audience band, attention funnel | camera off: the band shows clearly labelled sample numbers and says the screen is not counting yet | | Watchers | camera, faces turned to the screen for 1 s or more | Audience band, watchers by day and by hour | same as above; a face-only camera counts watchers but not passers-by, so the attention rate shows a dash with a note | | Attention (seconds) | camera, how long each watcher kept looking | Audience band, by-creative table | same as above | | Age bands and gender | camera, estimated on the screen itself, counts only | campaign report | same as above | | Scans, codes, innings, balls, runs, prizes | the cricket game: phone page, kiosk, wall | Engagement band, Hit for Six funnel, innings CSV | no game ad in the range: the band collapses to one line with "Create a game ad" | | Rounds, completions, QR code scans | spin, wheel and catch games | Engagement band, game funnel | same | | Coupons issued, claimed, redeemed | the phone wallet | Engagement band, ops Wallet page | none issued: dashes | | Spend and revenue booked | bookings | Value band, ops Value band, Bookings | none: ₹0 | | Last check-in, held batches | the screen's status call and the play record upload | ops Screens health, ops Today alerts | not reported: "Not seen lately", then "Offline" | Numbers land in the dashboards the next morning for the previous day; Live right now and Playing now update every 5 seconds. ## Booking flow > **Diagram: Booking flow** (booking flow · 7 steps, 2 decisions). There is no bidding: every screen has a fixed rate per slot per day set by ops, and an hour window costs its share of the day. Drawing: https://freebeeads.com/docs/diagrams#booking There is no bidding: every screen has a fixed rate per slot per day set by ops, and an hour window costs its share of the day. A game ad holds its window exclusively (one brand on the wall at a time); a video or image ad shares the loop. The booking can be cancelled before it starts and future dates are freed; a booking that has already played cannot be cancelled from the portal. ## Bidding for busy windows > **Diagram: Bidding for busy windows** (bidding for busy windows · proposed). Today every window is first come, first served at the fixed rate, which is right for most screens and most days. Drawing: https://freebeeads.com/docs/diagrams#bidding Today every window is first come, first served at the fixed rate, which is right for most screens and most days. On a main screen on a busy day (weekends, Diwali week, a mall event) several brands will want the same evening hour, and the first click should not decide it. The proposal keeps the fixed rate as the floor and adds a short, sealed bidding round only where ops turn it on. | Rule | Proposed setting | Why | | --- | --- | --- | | Where it applies | ops flag a screen plus a date range as "busy"; one toggle on the screen's page | keeps the rest of the network simple | | Floor | the screen's fixed rate for that window | nobody pays less than today | | Bids | one per advertiser per window, at or above the floor, in steps of 10%; an optional maximum that auto-raises in steps when outbid | fair, no bid wars in real time | | Visibility | the current highest bid is shown; who placed it is not | pressure without poaching | | Close | 48 hours before the window starts; late entries are not accepted | leaves time for approval and a make-good | | Award | highest bid wins and pays its bid; a tie goes to the earlier bid; the ad must already be approved, otherwise the next bidder wins | no empty wall on the busiest hour | | Everyone else | pays nothing; offered the next free window on that screen or the same hour on another screen in one tap | a loss still ends in a booking | | Video and image ads | a bid buys extra slots in the loop (share of voice) instead of exclusivity, since loop ads share the hour | the wall is only exclusive for game ads | | Guard rails | one brand may hold at most three consecutive busy windows on a screen; ops can cancel a round and fall back to the fixed rate | protects variety for shoppers and the mall | What the advertiser sees: the hour grid marks busy windows with the floor and the current high bid, and the booking summary rail shows "Bid ₹X, closes Fri 18:00" instead of a fixed price until the round closes. Ops see the open rounds on the Bookings calendar with the bidder count. Messages on close go out through the channels in "How we talk to the advertiser". ## When things fail > **Diagram: When things fail** (failures · 4 cases, who sees what). The rule behind all four rows: a number that was never measured shows as a dash or a labelled sample, never as zero, and a play that was never recorded is never billed. Drawing: https://freebeeads.com/docs/diagrams#failures The rule behind all four rows: a number that was never measured shows as a dash or a labelled sample, never as zero, and a play that was never recorded is never billed. The gap today is reach, not detection: ops only learn about an offline screen when they open Today, and the advertiser only when they open Home. The proposed push messages in "How we talk to the advertiser" close that gap. ## OTP and coupons > **Diagram: OTP and coupons** (OTP and coupons · 8 steps). The one-time code is the only moment we ask a shopper for anything personal, and only because the coupon has to live somewhere they can find again. Drawing: https://freebeeads.com/docs/diagrams#otp The one-time code is the only moment we ask a shopper for anything personal, and only because the coupon has to live somewhere they can find again. The number is kept as a hash plus an encrypted copy, used for nothing else, and a shopper can erase their wallet from the /w page. Codes are minted on the server and expire in minutes. **What is not connected yet**: the SMS or WhatsApp sender. On the test site the code is shown on the page itself so testers can continue. Before launch, delivery goes through WhatsApp Cloud API with SMS as the fallback, and the test shortcut is switched off. Daily prize budgets per tier ("max wins per day") already cap how many coupons a campaign can hand out. ## Upload rules A file is checked the moment it is uploaded, before anyone sees it. A wrong file is refused with one plain sentence and nothing is saved; a file that passes goes to our team for approval. | What | We accept | What we say when it is wrong (built today) | | --- | --- | --- | | Video ad | mp4 (H.264), up to 50 MB, up to 10 seconds, portrait or square preferred | "video exceeds 50MB limit" · "video duration 14.0s exceeds 10s limit" · landscape is accepted with the warning "landscape orientation; portrait or square preferred" | | Image ad | jpg or png, up to 10 MB | "image exceeds 10MB limit" | | Anything else (pdf, gif, mov, webm, pptx) | refused | "unsupported file type '.mov': allowed jpg/png images, mp4 video" | | Game ad images (logo, product shot, symbols, packs) | png or jpg, up to 5 MB; png with transparency for logos and symbols | "Image exceeds 5MB." on the slot; the slot shows an example of a good file | | Game ad clips (background loop, win clip, ad break) | mp4, up to 20 MB | "Video exceeds 20MB." on the slot | **Proposed, not built**: convert common mistakes instead of refusing them. A .mov or .webm is transcoded to mp4; a 15 s video is offered a 10 s trim with a preview; a landscape video is offered a portrait crop; an oversized image is resized. The message then reads "We converted your file to the screen's format; check the preview" with a side-by-side before and after. ## AI in the flow > **Diagram: AI in the flow** (AI in the flow · 2 lanes, built vs proposed). What AI would do for an advertiser: take a phone photo of a product and return a clean, upright, 9:16 image with the background removed; turn a 10 second video into a mock-up of how it looks on the wall; suggest three headlines from the brand name and the product. Drawing: https://freebeeads.com/docs/diagrams#ai What AI would do for an advertiser: take a phone photo of a product and return a clean, upright, 9:16 image with the background removed; turn a 10 second video into a mock-up of how it looks on the wall; suggest three headlines from the brand name and the product. Each suggestion is shown beside the original and the advertiser picks; nothing is applied silently, and our team still approves the final ad. What AI would do for the wall: catch the names the block list misses, such as slang spelt in English letters, a brand or a real person's name used as a joke, or a mixed Telugu and English phrase. The block list stays as the first, instant check; the AI check runs in under a second behind it. A blocked name always gets the same message as today, and nothing typed is kept beyond the innings. The game footage itself (bowling, batting, the dice) is generated once by us and shared by every cricket campaign; sponsors add their packs and logo on top. A proposed extension is a per-brand pack render, so a new sponsor's pack appears on the shelf without a designer. ## Native player instead of a browser > **Diagram: Native player** (native player · app on the device, cloud unchanged). A browser tab works for a demo but fails in a mall: it needs someone to open it, it can be closed or navigated away, it cannot start on boot, it loses the camera permission after a reboot, and a wifi drop stops the loop. Drawing: https://freebeeads.com/docs/diagrams#native A browser tab works for a demo but fails in a mall: it needs someone to open it, it can be closed or navigated away, it cannot start on boot, it loses the camera permission after a reboot, and a wifi drop stops the loop. The app wraps the same web engine (one codebase, the same play record format), so the server and dashboards do not change and the browser player keeps working for tests. | Platform | Device in the mall | Packaging | Camera | Notes | | --- | --- | --- | --- | --- | | Android | Android TV box or a signage box behind the wall, or an Android tablet as the kiosk | APK, side-loaded or via a managed store; device-owner kiosk mode | USB or built-in camera; counts on the device | cheapest hardware; first to build | | Windows | mini PC driving the LED wall | MSI installer, runs as a service, assigned-access kiosk mode | USB webcam | common for LED controllers | | Mac | Mac mini driving the wall | DMG, launch agent, auto-login | USB webcam | your test setup today | What the app does that the browser cannot: start itself after power loss, keep playing from its cache for hours offline and upload the queued play records afterwards, restart the engine if it stalls, pull engine updates in the night, and report its health (temperature, disk, camera) in the check-in. Pairing stays the same six-character code shown on the screen and entered in Ops, so nothing changes for the ops person. Build order: Android first (one app covers the wall box and the kiosk tablet), then Windows, then Mac. ## How we talk to the advertiser Today every message lives inside the dashboard: a status badge, an alert strip, or a note under a number. Nothing is sent to the advertiser's phone or email yet, so an advertiser who does not open the site does not hear about an approval or an outage. The proposed channels add WhatsApp first (the market's default) and email as the record. | Event | Built today (in the dashboard) | Proposed (push) | | --- | --- | --- | | Ad approved or rejected | badge on Creatives and Campaigns; rejection reason on the card | WhatsApp: "Your ad Diwali Sale is approved and will play from 2 Nov" or the rejection reason with a link to fix it | | Booking confirmed | success card with three next steps | WhatsApp plus an email receipt with the invoice | | Payment pending or received | badge on the campaign | WhatsApp reminder two days before the start; receipt on payment | | Screen offline during your window | Live right now shows "Nothing playing"; ops Today raises the alert | WhatsApp to ops immediately; to the advertiser after 30 minutes with the make-good (plays credited or extended) | | Camera not counting | Audience band shows sample numbers with a note | nothing to the advertiser; ops alert only | | Your campaign started, ended, report ready | badges; report fills next morning | morning WhatsApp with the four headline numbers and a link | | Prize claimed by a shopper | counts in the Engagement band | none (shopper gets the coupon; see OTP and coupons) | Ops are told the same way today: alerts on Today and badges on Approvals. The proposed ops channel is a WhatsApp group fed by the same alerts, with offline screens first. ## What is still missing The flows above are the product. A mall-screen business needs the pieces below too, and most of them are not started. Ordered by what blocks a real paying launch first. | Area | Gap | Status | Needed for | | --- | --- | --- | --- | | Payments | no online payment; ops marks bookings paid by hand; no invoice with GST; no refund or credit for cancelled or missed plays | Not started | first paid campaign | | Secrets and launch switches | rotate the Railway database password, secret key and ops password; switch off the test OTP mode; set a real domain and email sender | Not started | launch | | Account basics | no password reset, no team members or roles, one ops account for everyone, no company profile beyond a name | Not started | friends testing, first customers | | Advertiser messages | approvals, confirmations, outages and reports are shown only inside the site; WhatsApp and email sending not built | Proposed | first customers | | Legal and privacy | no terms, privacy policy or camera notice in the mall; DPDP consent wording for the wallet; data retention and delete-my-data | Not started | launch | | Content policy | no written rules for what ads are refused (alcohol, tobacco, political, medical claims) and no mall sign-off step | Not started | first customers | | Prize fulfilment | prizes are coupons; no stock count, no expiry reminder, no check that the counter honoured the coupon, no fraud limits per phone | Proposed | first game campaign | | Proof of play | no camera snapshot or signed log an advertiser can audit; counts are trusted from the screen | Not started | larger advertisers | | Mall partner side | no mall login, revenue share report, holiday hours or maintenance mode per screen | Not started | second mall | | Device reliability | browser player only; no auto-start, offline cache, remote restart or health report (see Native player) | Proposed | any unattended screen | | Support and incidents | no support inbox, no runbook for a screen down at 7 pm, no uptime alerting on Railway or Vercel | Not started | launch | | Languages | portal and phone page are English only; Telugu and Hindi for the shopper pages first | Not started | shoppers | | Accessibility and sound | no audio on the wall yet; kiosk has no large-text or colour-blind mode | Proposed | later | | Scale | one time zone, one currency, one city; screens have no group or region | Not started | second city | | Analytics on ourselves | no funnel on sign-up to first booking; no error tracking in the browser | Not started | growth | | Backups | Railway Postgres backups and media-volume backups not confirmed; no restore drill | Not started | launch | --- # Freebee UI/UX analysis and restructure plan Last updated Oct 22, 2026 · @praveen · Page: https://freebeeads.com/docs/ui-plan · Markdown: https://freebeeads.com/docs/md/ui-plan.md · All docs: https://freebeeads.com/llms-full.txt The product works end to end, but the three web surfaces still look like an engineer's admin panel: a top bar, flat lists, grey text, no sense of where you are or what to do next. The plan below moves the advertiser portal and the ops portal onto one app shell (sidebar, greeting header, KPI cards with change versus last period, schedule and timeline widgets) in the soft-palette style of the four reference dashboards, and reshapes the marketing site to match. ## Where it stands today All three surfaces are functionally complete but visually flat. Scored against what the four references do well (Shopeers, the finance dashboard, Crextio, Hipoka), the gaps are the same on every page: no persistent navigation, no greeting or context header, numbers without comparison, and a single grey-on-white card style. | Surface | What works | What is weak | Score vs references (1-5) | | --- | --- | --- | --- | | Marketing site (`/`) | Strong copy ("Ads people actually watch"), real dashboard components as the demo, 14 sections, FAQ | Text-heavy, one accent colour, no product screenshots of the new game wall, pricing is a table, the for-malls pitch is buried at the bottom, two wordmark styles | 3 | | Advertiser portal (`/dashboard`, `/book`, `/creatives`, `/campaigns/:id`) | Tabs, sortable tables, real charts, campaign report is deep | Top bar only, no sidebar; the dashboard opens on six skeleton cards with no greeting; stat deltas exist but are hidden on most cards; booking wizard lives outside the dashboard chrome; no "what to do next"; empty states say nothing about the game wall | 2 | | Ops portal (`/ops/*`) | Screens merged into Health / Registry / Metrics tabs, Bookings grouped by advertiser, Approvals with play-test, Footage at three levels | Six flat top-nav links, no ops home or today view, no alerts panel, health table has no sparkline or trend, approvals queue has no count badge in nav, no schedule view of who is on which screen today | 2 | | Device surfaces (wall, kiosk, phone) | Already redesigned in this cycle, consistent dark theme | Hand-coded colours outside the token system (kiosk 17 hex values, phone 8), not part of this plan | 4 | The inventory also found 10 concrete inconsistencies that any redesign has to clear first: two identical shells (`AdvertiserShell`, `DashboardShell`), muted text written four ways, eight arbitrary letter-spacing values, hand-rolled accent buttons on six pages, raw inputs and selects outside the shared components on seven pages, and a Tailwind class built at runtime in the filters grid that never renders (`lg:grid-cols-N`). ## Navigation map > **Diagram: Navigation map (before the restructure)** (navigation map · 3 lanes, 21 pages, 4 dead ends). Every page can be reached, but the paths are linear lists: Dashboard to Campaigns tab to report, Screens to screen detail. Drawing: https://freebeeads.com/docs/diagrams#nav-map Every page can be reached, but the paths are linear lists: Dashboard to Campaigns tab to report, Screens to screen detail. Four pages are dead ends. The advertiser's per-screen view is only reachable from the Live now tiles and the campaign report never links to it. The "Booked" page ends the wizard with no next step. Ops Wallet is a stub of four counts. Pair device is a utility that sits in the main nav as if it were a daily task. Neither role has a true home: ops land on a health table, advertisers on six empty stat cards. ## What the references do that we do not The four references share one skeleton: a left sidebar, a header that greets the person and sets the date range, a row of KPI cards with change versus the previous period, then two or three wide widgets (trend, schedule, list). Everything is on a soft tinted background with white cards, 16 to 20 px radius, and one bright accent used sparingly. | Pattern | Reference | What it gives the reader | Freebee today | | --- | --- | --- | --- | | Left sidebar grouped by job (Overview, Campaigns, Creatives, Book, Billing, Help) with the user card at the bottom | Shopeers, Crextio, Hipoka | Always know where you are and what else exists; room for badges (3 awaiting approval) | Top bar with 3 or 6 pills, no badges | | Greeting header: "Welcome back, Priya" plus today's date and a range picker | Crextio, Hipoka, finance dashboard | The page feels addressed to a person; range applies to every number below | "Dashboard" as a bare h1, range picker only on the campaign report | | KPI cards with the delta versus last period and a tiny sparkline | Shopeers | The number means something without reading a chart | StatCard supports delta but the overview never passes one | | Progress bars and gauges (budget used, slots filled, attention rate) | Crextio, Hipoka | Parts of a whole read at a glance | Attention rate is a bare percentage | | Schedule timeline: who is on which screen at what hour today | Hipoka, Crextio | Ops see the day's bookings as a Gantt row per screen; advertisers see their slot in context | Ops Bookings is a grouped list; nothing shows the hour windows visually | | Task list / onboarding checklist | Crextio | New advertisers are walked from "upload a creative" to "first report" | Nothing; an empty dashboard with six dashes | | Add-widget panel and AI assistant | Shopeers | Personalise the home page; ask "how did my Saturday go?" | Not in scope for this cycle; noted as phase 4 | | Soft palette: tinted page background, white cards, pastel category colours, one accent | All four | Hierarchy without borders; the page feels designed, not generated | Paper background and black-on-white cards; accent orange on almost every control | ## New information architecture Both portals move to one app shell: a 240 px sidebar on the left, a header with greeting, date and global range, and the page in a card grid. Each role gets a real home page that answers "how is it going and what should I do now" before anything else. **Advertiser sidebar** (top to bottom): Home, Campaigns, Creatives, Book a screen (primary button style), then a divider, Reports, Billing, Help, and the account card at the bottom with company name and sign-out. | Advertiser page | Replaces | What it shows | | --- | --- | --- | | Home (`/dashboard`) | Dashboard Overview tab | Greeting with the date; four KPI cards with delta versus the previous range (Watchers, Plays, Attention rate, Spend); "Live right now" strip of screens with a pulse; "This week on your screens" schedule row; onboarding checklist for new accounts; three latest campaigns as cards | | Campaigns (`/campaigns`) | Dashboard Campaigns tab | The existing sortable table with status tabs, plus a card view toggle; each row opens the report | | Campaign report (`/campaigns/:id`) | unchanged route | Same data, re-laid out: KPI row with deltas, game funnel beside attention funnel, by-day chart full width, screens and creatives tables in two columns, actions moved into a right rail | | Creatives (`/creatives`) | unchanged route | Card grid with preview thumbnails, status pill, "used in N campaigns"; the game editor opens as a full page with a sticky preview | | Book a screen (`/book`) | unchanged route | Same three steps inside the shell, with a left summary rail that fills in as you go (creative, screens, slot, price) | | Reports | new | List of exportable reports per campaign and per month; the CSV buttons that are today buried in the report header | | Billing | new, lightweight | Invoices and payment status per booking; a placeholder page until payments ship | **Ops sidebar**: Today, Screens, Bookings, Approvals (with a count badge), Footage, then a divider, Wallet, Devices (was Pair device), Settings, and the account card. | Ops page | Replaces | What it shows | | --- | --- | --- | | Today (`/ops`) | redirect to Screens | Greeting; alerts strip (offline screens, creatives waiting more than 24 h, bookings starting today with no approved creative); KPI cards for the network (screens online, plays today, watchers today, revenue booked this month) with deltas; a schedule timeline, one row per screen, showing today's hourly windows and which advertiser holds each; the approvals queue as a short list | | Screens (`/ops/screens`) | unchanged | Health, Registry and Metrics tabs stay; the health table gains a 7-day sparkline per screen and the metrics tab gains a per-screen comparison chart | | Screen detail | unchanged | Adds a day timeline of the screen's bookings and a "what is playing now" card | | Bookings | unchanged | Grouped list stays; gains a calendar view toggle (week grid, one column per screen) | | Approvals | unchanged | Queue becomes a kanban-style three-column board: waiting, approved, rejected; the preview opens in a side panel | | Footage | unchanged | Library cards gain a thumbnail per clip and a "used by N creatives" count | | Devices | Pair device | Pairing plus a list of paired devices with last-seen | The landing page after login becomes Home or Today. The old `/ops/fleet`, `/ops/metrics` and `/dashboard?tab=campaigns` links keep redirecting so nothing bookmarked breaks. ## Design system changes The token file already has the right bones (one accent, four data colours, a 6-step type scale, 16 px cards). What changes is the surface: a tinted page background, white cards without hard borders, pastel tints for categories, and a sidebar. Everything below lands in `packages/shared/design-tokens/tokens.json` and `tailwind.config.ts` so every page picks it up. | Area | Today | Change | | --- | --- | --- | | Page background | Paper `#F7F6F3` with bordered white cards | Soft tint `#F3F4F8` (cool grey-blue, as in the finance and Hipoka references); cards white with a 1 px `ink/6` border and a 2 px soft shadow; sidebar on `#FFFFFF` | | Accent | Orange `#D85A30` on every primary button, link and chip | Keep orange for the one primary action per page; add four pastel category tints (blue, green, amber, violet at 12 percent) for KPI card icons, status pills and the schedule bars | | Cards | One `Card` with title prop | Add `KpiCard` (label, value, delta with arrow and colour, sparkline, icon tint), `WidgetCard` (title, subtitle, action link, body), `ProgressBar`, `Gauge` (attention rate, slots filled), `TimelineRow` (hour axis 10 to 22 with coloured spans) | | Header | `PageTitle` h1 only | `PageHeader` with greeting ("Good morning, Priya"), the date, a global `DateRangePicker` in the header, and the page's one primary action on the right | | Navigation | Pill links in a top bar; two identical shells | One `AppShell` with `sidebar` items, badges and the account card; collapses to an icon rail under 1024 px and a bottom bar on phones | | Typography | Instrument Sans 400/500, eight arbitrary tracking values | Add weight 600 for KPI values and headings; replace all `tracking-[…]` with `tracking-wider` or `tracking-widest`; retire `text-[9px]` to `text-[11px]` in favour of `text-scale-6` | | Muted text | `text-muted`, `text-ink/55`, `text-ink/45` | `text-muted` only (one sweep across 27 uses in the booking page and the auth pages) | | Buttons and inputs | Six hand-rolled accent pills, seven pages with raw inputs or selects | `Button`, `Input`, `Select` everywhere; fix the runtime `lg:grid-cols-N` class in the filters grid with a static map | | Empty states | Dashed card with icon | Keep, but each one names the next action and links to it ("Upload your first creative") and the new-account checklist replaces the empty home | | Charts | recharts with mono labels | Keep recharts; add a 40 px sparkline variant for KPI cards and a horizontal bar for "by screen" comparisons; colours from the four data tokens | The device surfaces (wall, kiosk, phone) keep their dark theme and are not touched in this cycle. ## Page by page redesign Each page below is described as the reader sees it top to bottom. The data behind every widget already exists in the API; nothing here needs a new backend endpoint except the two marked. **Advertiser Home** 1. Header: "Good morning, {first name}", today's date, range picker (default last 7 days), primary button "Book a screen". 2. KPI row, four cards: Watchers, Plays, Attention rate (as a gauge), Spend booked. Each shows the delta versus the previous range and a sparkline from `by_day`. 3. Two-column band: left "Watchers by day" trend (two thirds), right "Live right now" list of screens with a green pulse, the creative playing and a link to the screen view. 4. "Your week" schedule: one row per booked screen, hour axis 10:00 to 22:00, your windows as coloured spans, other advertisers' windows greyed. Needs one new endpoint: bookings for my screens this week (new). 5. New accounts only: an onboarding checklist card (upload a creative, get it approved, book a screen, see your first report) with ticks driven by real state. 6. "Recent campaigns" as three cards with status pill, watchers and a "View report" link; "See all" to Campaigns. **Campaign report** 1. Header: campaign name, status pill, the creative thumbnail, range picker; actions (Update the ad, Change dates, Rebook, Cancel) move into a right rail so the top stays calm. 2. KPI row with deltas versus the previous range, then the game funnel and the attention funnel side by side. 3. Full-width watchers-by-day chart; hour heatmap and gender/age splits in a three-column row. 4. By-screen and by-creative tables in two columns, each row linking to the screen view (fixes the dead end). 5. Export links (CSV, QR scans, cricket) grouped in the rail under "Downloads". **Booking wizard** 1. Stays three steps, now inside the shell. A left summary rail fills in as the advertiser chooses: creative thumbnail, screens, date, slot, price. The rail is the confirm card on step 3. 2. The hour grid shows taken windows with the advertiser's own colour and a "next free window" hint when a day is full. 3. "Booked" becomes a success card with three next steps: open the report, book another screen, upload a preview. **Creatives** 1. Card grid with the preview frame (the existing CreativePreviewTile), status pill, "used in N campaigns", and a kebab menu for rename, replace, delete. 2. The game editor opens as a full page with a sticky phone-sized preview on the right. **Ops Today** (new) 1. Header: "Good morning, ops", date, range picker, primary button "Approve creatives (N)" when the queue is not empty. 2. Alerts strip: offline screens, creatives waiting over 24 h, bookings starting today without an approved creative, screens with no booking today. Each alert links to its page. 3. KPI row: Screens online of total (gauge), Plays today, Watchers today, Revenue booked this month, each with the delta. 4. Network schedule: one row per screen, hour axis, every window coloured by advertiser, now-line. Needs one new endpoint: all bookings for a date across screens (new, a thin wrapper over the existing ops bookings list). 5. Two-column band: approvals queue (five newest with preview thumbnails) and "Screens needing attention" (offline or stale with last seen). **Ops Screens, Bookings, Approvals, Footage** - Screens health table gains a 7-day plays sparkline and the metrics tab gains a horizontal bar chart comparing screens on the chosen metric. - Screen detail adds the day timeline and a "Playing now" card with the creative and advertiser. - Bookings keeps the grouped list and adds a week calendar toggle: columns per screen, rows per hour, a block per booking in the advertiser's colour. - Approvals becomes a three-column board (waiting, approved, rejected) with the preview and reason form in a slide-over panel instead of a modal. - Footage cards show a thumbnail frame of each clip and a "used by N creatives" count; per-creative and per-screen overrides are reached from the approval panel and the screen detail, as today. ## Marketing site The copy is already strong; the page is too long and too grey, and it never shows the thing that makes Freebee different: the game wall. Fourteen sections become seven, and the product is shown rather than described. | Section (new order) | What changes | | --- | --- | | Hero | Keep "Ads people actually watch." Replace the abstract visual with a looping 8 s clip of the wall mid-game (dice tumble, SIX takeover) beside a phone showing the join screen. Two buttons: "Book a screen" and "Watch the wall". Same wordmark as the app (icon plus name). | | How it works | Three steps as cards with the real product screenshots: upload and tag a creative, pick a screen and an hour window on the grid, read the report next morning. | | The game wall | New section. Explains Hit for Six in three sentences, the sponsor pack shelf, the prize bands, and that every scan and roll is counted. One short clip, one phone screenshot. | | Measurement | Merge the dashboard showcase, trust and proof bands into one: the live sample KPI cards with deltas, the attention funnel, the sentence about camera-based watcher counts and no personal data stored. | | Screens and pricing | Merge the two placement cards with the rate table; each placement card carries its rate per slot-day and the hour-window option. | | For malls | Move up from the bottom; two sentences and the partner mailto. | | FAQ and footer | Keep the FAQ as `
`; footer gains the app links and a privacy line. | The landing nav keeps its anchors but uses the shared `Button` for Sign in and Book a screen, and the same wordmark as the app so the jump into the portal does not feel like a different product. ## Rollout > **Diagram: Rollout phases** (rollout · 4 phases, 4 gates). Phases 1 and 2 shipped on 22 October 2026 (commit df692c4 on main, auto-deploying to Vercel): the sidebar shell, the soft canvas, the advertiser Home, the ops Today page, and the new Campaigns and Reports pages, verified in the browser at desktop and phone width. Drawing: https://freebeeads.com/docs/diagrams#rollout Phases 1 and 2 shipped on 22 October 2026 (commit df692c4 on main, auto-deploying to Vercel): the sidebar shell, the soft canvas, the advertiser Home, the ops Today page, and the new Campaigns and Reports pages, verified in the browser at desktop and phone width. Phases 3 and 4 shipped the same day (commits 09b7230 to 720d2ce): the campaign report re-layout with KPI deltas and a sticky action rail, the ops approvals board with a slide-over, the bookings list and week calendar, the creatives card grid, the booking wizard stepper with a summary rail, a Playing now card plus day timeline on every screen page, screens-compared bars on the metrics tab, and the seven-section landing page. Left for later: Billing page, per-screen sparklines in the health table, the add-widget panel and AI assistant. The backend is untouched except for two thin read endpoints (my week's bookings, and the network schedule for a date). ## Update Oct 23, 2026: what shipped since, and what is next All four phases of this plan are live on main and on Vercel. Beyond the plan, three rounds of feedback changed the product: the metric cards became four colour-coded bands (Delivery, Audience, Engagement, Value) with plain-English definitions and sample numbers where nothing is measured yet; the game-ad editor became a guided five-step flow with live sample games; and every advertiser and ops string went through a plain-English pass with a glossary. The production database now has the demo advertiser, the four sample game ads and their bookings. The end-to-end flows, the metrics list, booking and bidding rules, failure handling, OTP and coupons, upload rules, the AI pipeline, the native player and the advertiser messaging plan live in Freebee end-to-end flow. Its last section, "What is still missing", is the honest list of what blocks a paid launch: payments and invoices, secret rotation and launch switches, password reset and roles, WhatsApp and email messages, legal pages and the camera notice, a content policy, prize fulfilment, proof of play, the mall partner side, device reliability, support and incident handling, languages, backups. Still open from this plan: the Billing page, per-screen sparklines in the health table, and the add-widget panel and AI assistant from the Shopeers reference. --- # API Contract (v1) — backend and web are both built against THIS file Last updated Oct 31, 2026 · Page: https://freebeeads.com/docs/repo/api-contract · Markdown: https://freebeeads.com/docs/md/repo/api-contract.md · All docs: https://freebeeads.com/llms-full.txt Base URL: `http://localhost:8000`. JSON everywhere. Auth: `Authorization: Bearer ` for users, `Authorization: Device ` for devices. Errors: `{"detail": str}` with proper status codes. Dates are `YYYY-MM-DD`; timestamps are ISO-8601 UTC. ## Auth & profile - `POST /v1/auth/signup {email, password, business_name}` → `{token, user:{id,email,role}}` (role=advertiser) - `POST /v1/auth/login {email, password}` → `{token, user}` - `GET /v1/me` → `{id, email, role, profile:{business_name, phone?, gstin?}}` - `PATCH /v1/me/profile {business_name?, phone?, gstin?}` → profile - Seeded ops user: `ops@dooh.local` / env `OPS_PASSWORD` (default `opsdev123`), role=ops. ## Creatives (advertiser) - `POST /v1/creatives` multipart: `file`, `name` → `{id, name, kind, duration_s, status:"pending", validation:{...}}` Validation (automatic, on upload): image jpg/png ≤10MB; video mp4 h264 ≤50MB ≤10s; portrait or square preferred (warn, don't block). Reject → 422 with reasons. - `GET /v1/creatives` → list. `GET /v1/creatives/{id}/file` → the media (also used by devices). - `PATCH /v1/creatives/{id} {name}` → rename (owner only). - `POST /v1/creatives/{id}/file` multipart `file` → REPLACE the media: revalidates (same rules as upload), updates kind/sha256/duration, and RESETS status to "pending" — a replaced file never plays until ops re-approves. Existing schedule entries stay booked but are excluded from device schedules while the creative is not approved (they resume on re-approval). - `DELETE /v1/creatives/{id}` → 204; 409 if any booking references it. - **Approval gate at serve time:** `GET /v1/device/schedule` only returns entries whose creative is currently approved (a re-pending creative's slots serve as idle until re-approved). ## Screens & availability (advertiser browse) - `GET /v1/screens` → `[{id, venue_name, zone_name, label, orientation, loop_slots, slot_seconds, rate_per_slot_day}]` - `GET /v1/screens/{id}/availability?start=YYYY-MM-DD&end=YYYY-MM-DD` → `[{date, slots_total, slots_booked, slots_free}]` ## Bookings - `POST /v1/bookings {campaign_name, creative_id, items:[{screen_id, slot_count, start_date, end_date}]}` → `{id, price_total, payment_status:"pending", status:"confirmed", items:[...]}` 409 if any screen-date lacks free slots. Confirm materializes `schedule_entries` (creative must be approved; if creative still pending → booking status `awaiting_creative`, schedule materializes on approval). - `GET /v1/bookings` → list with per-item delivery counts. `POST /v1/bookings/{id}/cancel` (future dates only). ## Metrics (advertiser) - `GET /v1/metrics/campaigns/{booking_id}?from&to` → `{plays, ots, watchers, attention_ms_avg, attention_rate, age_bands:{"<18":n,"18-24":n,"25-34":n,"35-44":n,"45+":n}, gender:{male,female}, by_day:[{date, plays, ots, watchers}], by_screen:[{screen_id, label, plays, ots, watchers}], by_creative:[{creative_id, name, plays, watchers, attention_ms_avg}], by_hour:[{hour, watchers}]}` - `GET /v1/metrics/campaigns/{booking_id}/export.csv` → ledger rows (one row per play with audience summary), then — after a blank line — a `qr_scans_by_creative` section (`creative_id,creative_name,qr_scanned`). Scans attach to a creative, never to a single play row, so they live in their own section rather than as a fabricated per-row column; the ledger rows above are byte-identical to the plays table. - `GET /v1/metrics/live` → `[{screen_id, label, now_playing:{creative_name?, booking_id?}, watchers_now, updated_at}]` - `GET /v1/metrics/overview?days=14` → portfolio at-a-glance across all the advertiser's campaigns: `{window_days, plays, ots, watchers, attention_ms_avg, attention_rate|null, has_audience_data, screens_used, active_campaigns, by_day:[{date, plays, ots, watchers}]}` - `GET /v1/metrics/screens/{screen_id}?from&to` → one screen, scoped to the advertiser's own plays: `{screen:{id,label,zone_name,venue_name,orientation,loop_slots,slot_seconds,rate_per_slot_day}, device:{status,last_seen_at,watchers_now}|null, occupancy_today:{slots_sold,slots_total}, plays, ots, watchers, attention_ms_avg, attention_rate|null, has_audience_data, by_hour, campaigns:[{booking_id,campaign_name,plays,watchers}], recent_plays:[≤20]}` - **Null semantics:** `attention_rate` is `null` (never 0.0) when ots=0 — no audience data is not the same as measured-zero attention. Clients must render "—/no audience data yet", not "0.0%". Booking rows now carry top-level `delivered_plays` (sum of items' plays_delivered). - **Audience-metric semantics (v1.2):** metric responses carry `plays_with_audience` (plays whose audience summary is non-zero); `attention_ms_avg` averages over those plays only (camera-off plays never dilute it). `watchers` are WATCH EVENTS — per-play counts; the same person watching N plays counts N times (no cross-play identification, by design). Face-only sensors (browser/ iPad player) report `ots == watchers` — they cannot see non-lookers and must not invent OTS; clients treat `ots == watchers > 0` as "OTS not measured" and suppress attention_rate. ## Ops (role=ops) - `GET /v1/ops/creatives?status=pending` · `POST /v1/ops/creatives/{id}/approve {checklist, mall_signoff_ref?}` (see Review checklist, 2026-10-24) · `.../reject {reason}` - `GET /v1/ops/venues` → `[{id, name, city, zones:[{id, name}]}]` - `PATCH /v1/ops/venues/{id} {name?, city?}` · `PATCH /v1/ops/zones/{id} {name}` → renamed object. - `DELETE /v1/ops/venues/{id}` / `DELETE /v1/ops/zones/{id}` → 204; 409 unless empty (no zones / no screens). - `POST /v1/ops/venues {name, city}` · `POST /v1/ops/zones {venue_id, name}` · `POST /v1/ops/screens {zone_id, label, orientation, loop_slots?, slot_seconds?, rate_per_slot_day}` - `GET /v1/ops/screens` → registry incl. `device:{id, status, last_seen_at}|null` - `PATCH /v1/ops/screens/{id} {label?, orientation?, loop_slots?, slot_seconds?, rate_per_slot_day?}` → updated screen; 409 if loop_slots would shrink below the busiest future day's booked slots - `POST /v1/ops/devices/claim {code, screen_id}` → binds pairing code to screen - `POST /v1/ops/devices/{id}/revoke` - `GET /v1/ops/fleet` → `[{screen_id, label, device_status, last_seen_at, last_batch_at, quarantined_batches}]` - `GET /v1/ops/bookings` (all) · `POST /v1/ops/bookings/{id}/mark_paid` ## Device API (Authorization: Device <token>, except pairing) - `POST /v1/device/pairing/start {device_uid}` → `{code, poll_token}` (code TTL 10 min) - `POST /v1/device/pairing/poll {device_uid, poll_token}` → 202 pending | `{device_token, screen:{...}}` - `GET /v1/device/schedule?date=YYYY-MM-DD` → `{screen:{id,label,loop_slots,slot_seconds}, entries:[{slot_index, creative:{id, kind, url, duration_s, sha256}}], generated_at}` (device caches this + media files; url is `/v1/creatives/{id}/file`) - `POST /v1/device/events {batch_seq, device_sent_at, plays:[{client_play_id, creative_id, slot_index, started_at, duration_ms, audience:{ots, watchers, attention_ms_total, age_bands:{...}, gender:{...}}}]}` → `{state:"recorded"|"duplicate"|"rejected", reason?}` — idempotent on (device, batch_seq). **Clock-drift rule (v1.1):** `device_sent_at` = the device's current clock at upload time. Reject the whole batch (reason="clock_drift", body stored for review) iff `|device_sent_at − server_now| > 60s` OR any play has `started_at > device_sent_at + 60s`. Plays that are merely OLD are fine — late upload after offline periods (hours/days) is the normal contract (CLAUDE.md rule 5) and must be recorded, never quarantined for age. - `POST /v1/device/heartbeat {playing_creative_id?, watchers_now?}` → 204 (feeds /v1/metrics/live) ## Conventions - Pagination not needed in v1 (small N). All list endpoints return newest-first. - Money: integer rupees. IDs: UUID strings. ## Interop clarifications (binding, agreed during build) - Pairing poll with expired/unknown code → HTTP **410** (device restarts pairing, shows new code). - `pairing/start` is **idempotent**: if the device already has an outstanding (unconsumed, unexpired) request — e.g. the player reloaded between the ops claim and its next poll — it returns the SAME code + poll_token (HTTP 200) instead of invalidating the claim. A fresh code is minted only when nothing usable is outstanding, so the on-screen code is stable until it truly expires. - Duplicate events batch → HTTP **200** `{"state":"duplicate"}` (never an error status). - `rejected` is terminal: server stores the batch body for review; devices never retry a rejected batch. A re-sent already-terminal seq answers `duplicate`. - One batch per play; `batch_seq` strictly increasing per device, assigned at queue time, survives device restarts. Uploads arrive in seq order but possibly long after the plays. - `duration_ms` = slot occupancy (`slot_seconds × 1000`), not creative runtime. - Devices refetch schedule every 300s and on UTC date rollover; 401 anywhere → device drops its token and returns to pairing (queued batches preserved). - Multi-item bookings: one booking row, `items[]` children. `attention_ms_avg` = Σattention_ms_total / plays; `attention_rate` = watchers/ots. ## Interactive (game) creatives — v1.3 A creative with `kind:"game"` is a playable ad. It has no single media file: it has a JSON `config` (template + copy + rules + reward) and a set of image **assets** keyed by name. It goes through the same approval gate as image/video and books slots like any creative. ### Config shape (template `catch`, the only template in v1.3) ``` { "template": "catch", "mode": "basket" | "tap" | "camera", // basket = drag a basket (touch); tap = tap items; camera = face steers basket "brand": "Fizz Cola", "colors": {"bg":"#160b0d","accent":"#d6202c"}, "headline": "Catch the fizz.", "slogan": "Every cap counts.", "items": [ {"key":"item","points":1,"weight":7,"size":0.13}, // asset key "item_"... see assets {"key":"bonus","points":3,"weight":1,"size":0.13}, {"key":"decoy","points":-2,"weight":2,"size":0.11} ], "round_s": 25, // one round of play "slot_s": 30, // loop occupancy when this creative's slot comes up (20–120) "reward": {"title":"10% off","code":"FIZZ10","url":"https://…","note":"Show at counter"} } ``` Asset keys: `logo`, `hero`, and `item_` for every entry in `items`. All png/jpg ≤5MB. Validation (422 on failure): mode ∈ set, 1–6 items, points −10..10, weight ≥1, round_s 10–60, slot_s 20–120, reward.url must be http(s), strings ≤ 255. ### Endpoints - `POST /v1/creatives/game {name, config}` → creative (kind:"game", status:"pending", `config`, `assets:{}`) — 201. `duration_s` = config.slot_s. - `PUT /v1/creatives/{id}/game {name?, config}` → creative; RESETS status to "pending". - `POST /v1/creatives/{id}/assets/{key}` multipart `file` → creative; upserts the asset, RESETS status to "pending". 422 on bad key/type/size. - `DELETE /v1/creatives/{id}/assets/{key}` → creative (status → pending). - `GET /v1/creatives/{id}/assets/{key}` → the image (user or device auth). - `GET /v1/creatives/{id}/game` → `{id, name, config, assets:{key: "/v1/creatives/{id}/assets/{key}"}}` (user or device auth). This is what the player and the portal preview load. - `GET /v1/creatives` / creative dicts: game creatives carry `config` and `assets` (key→url); `sha256` is a hash over config + asset hashes (changes whenever anything changes). - Approval: a game creative may be approved only when `logo`, `hero` and every `item_` asset exist (409 otherwise). ### Player contract - `GET /v1/device/schedule` entries for game creatives: `creative:{id, kind:"game", url:"/v1/creatives/{id}/game", duration_s: config.slot_s, sha256}`. The device fetches the JSON and every asset with its device token and caches them like media. - A game slot occupies `slot_s` seconds (not `screen.slot_seconds`). If a round is in progress when the slot ends, the device may extend the slot until the round ends (≤ +60 s). The play's `duration_ms` is the ACTUAL occupancy. - `PlayIn.engagement` (optional, game plays only): `{sessions, completed, score_sum, best_score, qr_shown, starts_face, starts_tap}` → stored append-only in `engagement_summaries`. - Metrics: campaign/screen/overview responses add `engagement:{sessions, completed, completion_rate, avg_score, best_score, qr_shown, qr_scanned, starts_face, starts_tap}|null` (null when no game plays). `qr_scanned` counts anonymous opens of the reward claim page for the same creatives in range. `starts_face` / `starts_tap` sum how sessions began (camera vs touch). - Overview responses add `total_spend` (integer rupees of non-cancelled bookings created in the window) and `spend_by_day:[{date, spend}]`. ### QR claim flow — v1.4 - The engine's reward QR points at the web claim page `/r/{creative_id}?s=&n=` (parent passes `creativeId` in the `gody:config` embed message; without it the QR falls back to the self-contained demo claim view). - `POST /v1/qr/scans {creative_id, score?}` → `{ok:true}` — 201. Anonymous (no auth): one append-only row per phone that opens the claim page. Counts only, no personal data. 404 unless the creative exists and is a game. - `GET /v1/qr/reward/{creative_id}` → `{creative_id, brand, reward:{title, code, url, note}}` — anonymous. Served only for **approved** game creatives; 404 otherwise (the claim page then explains the offer isn't live). - `qr_scans` is an append-only event log (same immutability discipline as the ledger). ### Immediate push — v1.3 - `screens.schedule_version` (int) is bumped whenever the screen's playable loop changes: schedule materialization, booking cancel, creative approve/reject/replace/config/asset change (for every screen that has schedule entries for that creative, today or later), screen PATCH. - `GET /v1/device/schedule` adds `schedule_version`. - `POST /v1/device/heartbeat` now returns **200** `{"schedule_version": n}` (was 204). Devices compare with the cached version and refetch at once when it differs; the refreshed loop is applied at the NEXT slot boundary (not the loop wrap). Clients must tolerate 204 from older servers. ### Optional premium layers (v1.3.1) Two optional asset keys accept **mp4 ≤ 20MB** (sniffed by `ftyp`): `background` (looping clip behind the whole screen, played muted at reduced opacity) and `hero_video` (the product animated on a pure black background; the engine screen-blends it so the black disappears). Never required for approval; the engine falls back to the static hero when absent. ## Spin template + wallet — v1.5 ### Template `spin` (game creative config) ``` { "template":"spin", "brand", "colors":{bg,accent}, "headline", "slogan", "symbols":[{"key":"hero","label":"Hero","weight":5}, …], // 4–8, images symbol_ "wilds":[{"key":"maker","label":"The Maker","multiplier":10}, …], // 0–3, images wild_; a wild matches any symbol "spin_s":6, "spins_per_session":3, "slot_s":45, // spin 4–10 s; 1–5 spins; slot 20–120 s "prizes":[ {"tier":"big", "title":"Free movie ticket", "odds_one_in":200, "daily_budget":20}, {"tier":"mid", "title":"₹100 off popcorn", "odds_one_in":15, "daily_budget":200}, {"tier":"small", "title":"10% off merch", "odds_one_in":1, "daily_budget":null} ], // 1–3 tiers; exactly one with odds 1 (everyone wins) "reward":{...} } // legacy fallback (offline / preview) as in v1.3 ``` Assets: `logo`, `hero`, `symbol_` for every symbol, `wild_` for every wild (all required for approval); optional `win_clip` (mp4 ≤20MB, plays on a big win), `background`, `hero_video`. Validation: tiers unique; odds_one_in ≥1; daily_budget ≥1 or null; symbol/wild keys `^[a-z0-9_]{1,24}$` unique. ### Server-decided spins (device auth) - `POST /v1/device/spins {creative_id, booking_id?, client_spin_id}` → `{spin_id, outcome:{tier:"big"|"mid"|"small", title, symbol_key, wild_key|null, multiplier|null}, token, claim_url:"/w/claim/{token}", expires_at}` — idempotent on (device, client_spin_id). The server draws the tier: for tiers in order big→mid, win iff `random < 1/odds_one_in` AND today's wins for (booking or creative) < `daily_budget`; else the odds-1 tier. It mints a `win_tokens` row (48 h expiry). The engine renders reels to match the outcome (promotional spins are outcome-first). Offline: the engine shows the small tier with the legacy `reward` QR (`/r/{creative_id}`), no token. - `win_tokens`: id, creative_id, booking_id?, device_id, spin_id, tier, title, created_at, expires_at, claimed_at?, coupon_id?. Insert-only except claimed_at/coupon_id set once. ### Wallet (public, phone-based — NEW personal-data class, see CLAUDE.md rule 3a) - `POST /v1/wallet/otp/start {phone}` → 204. Indian mobile (`^\+91[6-9]\d{9}$`), 6-digit code, 5 min TTL, max 5 sends/phone/hour, 5 attempts/code. Delivery via `services/sms.py: send_otp(phone, code)` — v1 provider is a log stub; with env `WALLET_OTP_DEV=1` the response is 200 `{"dev_code": "…"}`. - `POST /v1/wallet/otp/verify {phone, code, consent:true, consent_version:"2026-09"}` → `{wallet_token, user:{id, phone_masked:"+91••••••1234", consent_at}}` (JWT role=wallet, 90 days). Consent is required (422 without). Stores `wallet_users{id, phone_hash (HMAC-SHA256, secret), phone_enc (Fernet), consent_at, consent_version, created_at, last_seen_at, erased_at?}`. - `GET /v1/wallet/claim/{token}` (public) → `{brand, tier, title, expires_at, status:"open"|"claimed"|"expired"}`. - `POST /v1/wallet/claim {token}` (wallet auth) → coupon (201). Rules: 410 expired; 409 claimed by another user; same user re-claim returns the existing coupon (200); ONE coupon per (user, booking) per day → 409 `{detail, coupon}` with the existing one. Coupon code `"{BRAND4}-{XXXX}-{XXXX}"`. - `GET /v1/wallet/me` → `{phone_masked, coupons:[{id, brand, title, tier, code, status, campaign_name, issued_at, expires_at, redeemed_at}]}` newest first. Coupons expire 30 days after issue. - `DELETE /v1/wallet/me` → 204: erases phone_hash/phone_enc (right to erasure), coupons stay anonymised (wallet_user_id kept, user row marked erased_at). - `coupons`: id, wallet_user_id, win_token_id, creative_id, booking_id?, tier, title, code (unique), status issued|redeemed|expired, issued_at, expires_at, redeemed_at?, redeemed_by_user_id?. ### Redemption (advertiser owning the creative, or ops) - `POST /v1/redeem {code}` → `{ok:true, coupon:{brand, title, tier, status:"redeemed", redeemed_at}}`; 409 already redeemed / expired; 404 unknown or not yours. ### Metrics - `engagement` gains `spins, wins, claims, redemptions` (spin creatives; 0 otherwise). Campaign CSV gains a `coupons_by_tier` section (`tier,wins,claims,redemptions`). Phone numbers NEVER appear in any advertiser/ops response; ops gets `GET /v1/ops/wallet/stats → {users, coupons, redeemed}` only. ### Embed protocol additions (engine ⇄ parent) - iframe → parent `{type:"gody:spin", requestId}`; parent → iframe `{type:"gody:spin:result", requestId, outcome, claim_url|null}`. The player answers by calling `POST /v1/device/spins` with its device token; the portal preview / ops play-test answers locally with a simulated outcome and `claim_url:null` (engine falls back to the legacy reward QR). ### Spin grid (v1.5.1) `config.reels` (3–5, default 3) × `config.rows` (3–5, default 3). The winning line is the middle row; on square grids both diagonals are also paylines (never used for a granted prize). A 5×5 grid needs 6+ symbols to read well; the editor says so. `config.layout` (v1.5.1): `cabinet` (default — one tall stack; centred 9:16 column with blurred art side-fill on wide screens) | `split` (art left, reels right on wide screens) | `backdrop` (key art full-bleed behind everything). `/cabinet.html` is a convenience entry that pins `layout=cabinet`. `config.bonus` (v1.5.2, spin only): `{enabled, reels 3–5 (default 5), rows 3–5 (default 4), title}` or null. When enabled the asset `bonus_symbol` is required for approval and a mid/big outcome is staged in two acts (base reels land R BONUS symbols → the stage opens → the top reels spin for the prize). Budget `slot_s` ≥ 90 for bonus ads. ## Wheel template — v1.6 A prize wheel (reference: casino "wicked wheel" toppers). One tap spins; the wheel lands on the server-decided tier (outcome-first, same `POST /v1/device/spins`; `spinnable templates = spin, wheel`). ``` { "template":"wheel", "brand", "colors":{bg,accent}, "headline", "slogan", "segments": 12, // 8–16 wheel segments "spin_s": 7, // 5–12 s of spinning "slot_s": 45, "prizes":[ {"tier":"big","title":"…","odds_one_in":40,"daily_budget":20,"segments":1}, {"tier":"mid","title":"…","odds_one_in":6,"daily_budget":200,"segments":3}, {"tier":"small","title":"…","odds_one_in":1,"daily_budget":null,"segments":8} ], "reward":{...} } // legacy fallback as in v1.3 ``` `prizes[].segments` is VISUAL ONLY (how many wedges show that tier; must sum to `segments`); the server draws by `odds_one_in` + `daily_budget` exactly as for spin. One spin per session. Assets: `logo`, `hero` required; `wheel_center` (png, the hub), `background`, `hero_video`, `win_clip` optional. Metrics/wallet/claims identical to spin (engagement.spins counts wheel spins). Embed: GameFrame loads `/wheel.html?embed=1` for template wheel; same gody:* protocol incl. gody:spin. Spin bonus (v1.5.2 note): when `bonus.reels/rows` are omitted the engine defaults the top grid to the base reel count × (rows+1), so a 3-reel base opens a 3-reel top and a 5-reel base a 5-reel top. `config.bonus.kind` (v1.6.1, spin): `"reels"` (the Wall: bigger reels on top) | `"wheel"` (the prize wheel on top, `bonus.segments` 8–16 wedges). `bonus.reels/rows` null → follow the base grid. Optional keyed fx clips (mp4 on pure black, luma-keyed by the engine): `fx_ring` (loops behind the wheel), `fx_burst` (trigger + grand win), `fx_coins` (wins). `hero_video` on spin is the "living poster" (played as the stage art, not keyed). ### v1.6.2 — wheel characters + reveal (additive) `wheel` config gains optional `symbols[]` (0–8 `{key, label}`, keys `^[a-z0-9_]{1,24}$`, unique) and `reveal` (`{key, label}` or null). Once listed, `symbol_` (one portrait per character) and `wild_` (the reveal portrait) are REQUIRED for approval, exactly like spin symbols/wilds; keys not in the config are rejected with 422. Duplicate keys across symbols + reveal → 422. Wheel also accepts the optional fx clips `fx_ring` / `fx_burst` / `fx_coins` (mp4). Engine (`/wheel.html`): portraits cycle around the wedges; the reveal takes the grand wedge(s); `hero_video` plays as the living poster on the stage above the wheel; on a grand the reveal pops on the stage, `fx_burst` + `fx_coins` fire, then `win_clip` plays before the QR results. `validation.checks.symbols` = count. ## Cricket wall template — v1.7 ("Hit for Six") A floor-size portrait wall; phones are the controllers. Design: `docs/CRICKET_DESIGN.md`. ``` { "template":"cricket", "brand", "colors":{bg,accent}, "headline", "slogan", "packs": 20, // 12–30 sponsor packs on the wall "balls": 6, // 3–10 packs each player picks "bag": {"0":4,"1":5,"2":4,"4":3,"6":2,"out":2}, // sums to packs, out >= 1; reshuffled per innings "prizes":[ {"tier":"big","title":"…","min_runs":24,"daily_budget":10}, {"tier":"mid","title":"…","min_runs":12,"daily_budget":300}, {"tier":"small","title":"…","min_runs":0,"daily_budget":null} ], // exactly one min_runs 0 "play_url": "", // display-only short URL under the QR "turn_timeout_s": 25, // 10–120 s to tap Play once called "slot_s": 300, // 60–600: the wall holds the slot (takeover) "reward": {...} } // optional legacy fallback ``` Assets: `logo`, `hero`, `pack` required. Optional images `board`, `scoreboard_logo`, `helmet_logo`, `jersey_logo`, `bat_sticker`, `batter`, `stadium`; optional mp4 `clip_six`, `clip_four`, `clip_out`, `clip_dot`, `clip_single`, `clip_double`, `clip_walkin`. Engine: `/cricket.html?embed=1`. `clip_bowl` is the shared delivery (run-up to release, 2–4 s): when present the wall plays it before every outcome clip and hard-cuts to the outcome, so outcome clips should start at the crease with the ball already in the air. Without it, outcome clips play alone. **Phone (public, per-session secret; CORS open):** - `GET /v1/play/{screen_id}` → `{screen:{id,label}, creative_id, config:{brand,headline,slogan,colors,packs,balls,prizes[{tier,title,min_runs}],turn_timeout_s}, assets:{key: "/v1/play/{screen}/assets/{key}"}, queue}`. 404 when no approved cricket creative is booked on the screen today. - `GET /v1/play/{screen_id}/assets/{key}` → the active creative's `logo|hero|pack|board|batter|stadium` (never clips). - `POST /v1/play/{screen_id}/join {name, avatar}` → 201 `{session_id, secret, …state}`. 422 with a sentence when the name fails moderation; 429 when the queue is full. - `POST /v1/play/sessions/{id}/picks {secret, picks[]}` → state. Exactly `balls` distinct indexes in `[0, packs)`; 409 once sealed. - `GET /v1/play/sessions/{id}?secret=` → `{id,status,name,avatar,position,eta_s,picks,has_picks,turn_timeout_s,ready_at}` + when done `{balls,runs,balls_faced,out,rank,claim_url}`. - `POST /v1/play/sessions/{id}/start {secret}` → ready → playing (409 otherwise). - `GET /v1/play/sessions/{id}/card.png?secret=` → 1080×1350 share card (409 until done). - `GET /v1/play/{screen_id}/board` → `{rows:[{id,name,avatar,runs,balls,out}], queue}` (today, top 10). `status`: `queued | ready | playing | done | skipped | expired`. Only players with sealed picks are promoted to `ready`; `ready` without Play within `turn_timeout_s` → `skipped`; `playing` without a wall report within 180 s → `expired`. **Wall (device token):** - `GET /v1/device/cricket/{creative_id}/poll` → `{queue:{now,next[],waiting}, turn:{session:{id,name,avatar}, balls:[{pack,value}]}|null, board[]}`. Runs housekeeping (timeouts + promotion). The wall polls every 2 s and forwards each as an engine event; a `turn` is forwarded once per session id. - `POST /v1/device/cricket/sessions/{id}/done {runs, balls, out}` → `{id,status,runs,balls,out,rank,claim_url}`. Idempotent. Runs are re-derived server-side from the sealed balls; a prize is minted through the spin ledger (`spins.symbol_key = "cricket"`, a `win_tokens` row) by the highest `prizes[]` band the runs reach with daily budget left. **Embed additions:** `gody:config` carries `screenId`; host → engine `gody:cricket:event {event}` with `event.type ∈ queue | turn | board`; engine → host `gody:cricket:turn_done {sessionId, runs, balls, out}` plus the usual `gody:round` / `gody:stats` (`innings`, `sixes`, `outs` counters). **Tables:** `cricket_sessions` (migration `a1c7e9b2d4f6`): display name + emoji only (rule 3). ### v1.7.1 — the code + kiosk flow (replaces the phone picker) The phone only receives a **4-digit code**; the **kiosk** (a tablet beside the wall, `/kiosk/{screen_id}`) takes the code and the player picks **one pack at a time**. Config defaults change to `packs: 10`, `bag: {"0":2,"1":2,"2":2,"4":2,"6":1,"out":1}` (`packs` now 6–30). New optional `tagline` (≤ 60) is written onto the stadium's scoreboard in the footage. Migration `b2d8f4a6c1e3` adds `code`, `kiosk_key_hash`, `bag_order`, `shown` to `cricket_sessions`. **Phone:** `POST /v1/play/{screen_id}/join {name, avatar, phone?}` → 201 `{session_id, secret, code, status:"queued", expires_in_s, packs, balls_per_over, …}`. `phone` is optional and never stored: when `WHATSAPP_PROVIDER=aisensy` the code is also sent on WhatsApp through AiSensy (template `freebee_cricket_code`; a failed send is logged, never surfaced). `GET /v1/play/sessions/{id}?secret=` returns `code` only while `queued`; while `playing` it mirrors `balls`, `runs`, `balls_faced`; when `done` adds `rank` and `claim_url`. A ticket expires after 30 min unused (`status: expired`). `picks` and `start` are gone. **Kiosk (public; the code is the auth, then a per-innings key):** - `GET /v1/kiosk/{screen_id}` → same shape as `GET /v1/play/{screen_id}` (`assets` now include `ball`, `ball_gold`). - `POST /v1/kiosk/{screen_id}/claim {code}` → `{session_id, key, status:"playing", packs, balls_per_over, balls:[], wall_ready:true, …}`. 404 unknown/used/expired code; 409 `"<name> is batting"` while another innings is live (one innings per wall). Claiming seals the innings' bag (`bag_order`). - `POST /v1/kiosk/sessions/{id}/ball {key, pack}` → the innings plus `ball:{pack,value}`. 409 if the wall has not finished showing the previous ball (`wall_ready:false`), if the pack is already open, or if the innings is over; 422 for an out-of-range pack. The innings closes itself at `balls` picks or the first out and mints the prize through the spin ledger (`claim_url`). - `GET /v1/kiosk/sessions/{id}?key=` → innings + `shown` + `wall_ready`. An innings idle for 90 s closes as it stands (or expires if no ball was played). **Wall (device token):** `GET /v1/device/cricket/{creative_id}/poll` → `turn` now carries `status` and `shown` and grows ball by ball; after the innings closes it keeps returning until the wall has shown every ball. `POST /v1/device/cricket/sessions/{id}/shown {shown}` — the wall reports how many balls it has finished showing (this is what unlocks the kiosk's next pick). `…/done` is kept as an idempotent "shown everything" mark. **Embed:** the host forwards `turn` whenever `(session, balls.length, status)` changes; the engine plays only the balls it has not shown and posts `gody:cricket:shown {sessionId, shown}` after each. Big moments take the whole wall; in attract the wall shows the big QR ("scan for your code · enter it at the kiosk · pick a ball at a time"). ## v1.8 — the Freebee dice (2026-10-21) `CricketConfig.mode` is `"bag"` (v1.7.1 behaviour, the default for old rows) or `"dice"`. In dice mode every ball is an independent roll of the six-faced Freebee dice, server side, from `odds` (percent per value, must sum to 100, `out` > 0; default 1 → 14, 2 → 14, 0 → 14, 4 → 20, out → 18, 6 → 20). `bonus_on_six` (default true) adds one ball to the innings for every six, up to `max_balls` (default 12, ≥ `balls`). `packs` and `bag` are ignored in dice mode and not validated. - `POST /v1/kiosk/sessions/{id}/ball` body `{key}` — `pack` is optional and ignored in dice mode. Each ball carries `face` (1 single, 2 double, 3 dot, 4 four, 5 out, 6 six) and `pack` is the ball index. - Kiosk and phone views carry `mode` and `limit` (balls this innings may face, growing by one per six). The wall poll's `turn` carries `limit` too, so the wall can draw the bonus slots. - The innings ends at `limit` balls or the first out, as before. - The wall (`/cricket.html`) in dice mode: the 3D dice tumbles in the stage and lands on the result — the bee umpire signals six, four and out; pips for 1, 2 and 3 with the sponsor's logo in the corner — then the delivery and outcome footage play and the over tracker fills; a gold +1 slot pops in on a six. The kiosk shows a single ROLL button instead of the packs. ### v1.8 hardening (2026-10-21, after the end-to-end test) - **Report**: `GET /v1/metrics/campaigns/{booking}` → `engagement.cricket` block (`scans`, `claimed`, `innings`, `expired`, `claim_rate`, `avg_innings_s`, `total_play_s`, `balls`, `avg_balls`, `avg_runs`, `best_runs`, `sixes`, `fours`, `dots`, `outs`, `not_out`, `bonus_balls`, `runs_distribution`, `prizes{big,mid,small}`, `by_hour[]`); null unless a ticket was issued in range. `GET /v1/metrics/campaigns/{booking}/cricket.csv` = one anonymous row per ticket (never a name, code or phone). Days and hours in all reports are now IST. Cricket prizes appear in `coupons.csv` like spin prizes. - **Kiosk safety**: `GET /v1/kiosk/{screen}` `tickets[]` no longer carries `code` (who is waiting, never their code). `POST /v1/kiosk/{screen}/claim` returns 429 after 5 wrong codes on a screen within 2 minutes (60 s lock). - **Wall stall**: a ball the wall has not reported `shown` for 20 s stops blocking the next ball (`WALL_STALL_S`); the kiosk is never held hostage. - **Idle innings** (90 s without a roll) are closed with their prize minted. - **Names**: the block list is normalised like the input (doubled letters and leet cannot slip through); only an unambiguous subset matches inside a name, so Anusha, Gandhi, Nazia, Suleman, Dhanush pass; names in Telugu / Hindi / Urdu script are accepted; Cyrillic look-alikes are folded. `avatar` must be one of the twelve phone avatars. - **Bookings**: a second cricket game on the same screen and overlapping dates is refused with 409 (the phone and kiosk can only serve one). - **Wall reports** `shown` / `done` accept up to 30 balls (bonus balls). - **WhatsApp**: `config.whatsapp` is true only with `WHATSAPP_PROVIDER=aisensy`; the phone page then stops asking for a number nothing would use. - Phone, kiosk and campaign pages: offline auto-retry, prize ladder on the phone, dice copy, kiosk wake lock and keyboard entry, report defaults to the campaign's own dates. - Script: `backend/scripts/e2e_cricket.py` runs the whole API flow against a server (`--base`, `--screen`), exit 1 on any failure. - **Prizes are optional (v1.8)**: `CricketConfig.prizes` is 0–3 bands; at most one band may need 0 runs. With no bands (or runs below every band) the innings completes with `claim_url: null` and the phone says thanks for playing. - **Ad break (v1.8)**: optional video asset `ad_break` (a 10–15 s spot) plus `CricketConfig.ad_break_every_s` (15–600, default 45). While nobody is batting the wall plays it full screen every N seconds with the scan QR kept in the corner, muted like all wall footage. Cut off the moment a code is claimed. - **Page opens**: the phone play page beacons `POST /v1/qr/scans` on open; `GET /v1/play/{screen}` now returns `booking_id` for that. The report's cricket block carries `opens` (≥ `scans`) so the funnel reads opened → got a code → entered the kiosk → finished → prize. ## v1.9 — hourly windows: advertisers share a screen by the hour (2026-10-22) - `BookingItemIn` gains optional `start_time` / `end_time` ("HH:MM", local IST). Windows start and end on the hour (`WINDOW_STEP_MIN`, 60 by default, 30 allowed) inside the mall day (`MALL_OPEN_HOUR`=10 → `MALL_CLOSE_HOUR`=22). A cricket wall game MUST have a window (422 otherwise); loop ads ignore it. - Clash rule: a cricket window is refused with 409 if it overlaps ANY cricket window already booked on that screen on ANY shared day (partial overlaps included; a legacy all-day cricket booking blocks the whole day). Two overlapping items in one request are refused with 422. A window that has already ended today is refused with 422. - Price of a windowed item = rate × slots × days × (window hours ÷ mall hours), rounded, minimum ₹1. Items carry `start_time`/`end_time` in booking JSON. - `GET /v1/screens/{id}/cricket-hours?start&end` → `{open_min, close_min, step_min, steps[{start_min,end_min,label,free,taken_by}], taken[]}`: a step is free only if free on every day of the range. - Resolution by the clock: `/v1/play/{screen}`, join, kiosk and board pick the cricket game whose window contains the local time now; a whole-mall-day window counts as all day. Outside every window the 404 says when the next game starts. Grace: the kiosk home and claims keep working 5 minutes after a window ends (a live innings finishes; a code typed a minute late works); images stay served for 3 hours. A code issued for an earlier game is refused at the kiosk with 410 and expired. - `GET /v1/device/schedule` entries carry `window {start_min,end_min}` or null; the player treats a slot outside its window as idle, so the wall switches games on the hour without a schedule push. - Migration `c3e5a7b9d1f2` adds `booking_items.start_min/end_min`. - **Per-screen hours (ops)**: `Screen.open_min/close_min/window_step_min` (migration `d5a7c9e1f3b4`). `POST/PATCH /v1/ops/screens[/{id}]` take `open_time`, `close_time` ("HH:MM") and `window_step_min` (30 or 60); 422 if closing ≤ opening or times are off the step; 409 if the new hours no longer contain a window a future booking already holds. Public `GET /v1/screens` carries `open_time`, `close_time`, `window_step_min`. - **Windows for every ad type**: any `BookingItemIn` may carry a window; loop ads then play only inside it (device schedule `window`) and pay for that share of the screen's day. Only cricket windows are exclusive. - `GET /v1/screens/{id}/hours?start&end` is the generic grid (`cricket-hours` remains as an alias). - **Ops metrics (v1.9)**: `GET /v1/ops/metrics/network?from&to` → totals + one row per screen (plays, OTS, watchers, attention, bookings, revenue booked, advertisers, cricket scans/innings, device, occupancy today). `GET /v1/ops/metrics/screens/{id}?from&to` = the screen detail across every advertiser plus `by_advertiser[]`, `bookings_in_range`, `revenue_booked` and the `cricket` funnel. Ops may read any campaign's `GET /v1/metrics/campaigns/{booking}` (the advertiser's own report). `GET /v1/ops/bookings` rows carry `advertiser_email`, `advertiser_id`, `phase`. - **Preview asset (v1.9)**: optional `preview` key on every game creative — an mp4 recording or a png/jpg poster of the creative on the wall. Uploaded by the advertiser (`POST /v1/creatives/{id}/assets/preview`) or by ops (`POST /v1/ops/creatives/{id}/assets/preview`). Unlike every other asset it does NOT reset approval; it only feeds the booking picker's preview tile. - **Footage library (v1.9)**: `platform_assets` (migration `e6b8d0f2a4c6`). Ops: `GET /v1/ops/library` (every platform key with label, hint, video flag, url, size, updated_at), `POST /v1/ops/library/{key}` (mp4 for clips, png/jpg for stills), `DELETE /v1/ops/library/{key}`. Public `GET /v1/library/{key}` serves the file. Platform keys (`CRICKET_PLATFORM_KEYS`): the ten stadium clips plus stadium, batter, ball, ball_gold, trophy. A cricket creative's `assets` (bundle, creative dict, kiosk/phone assets) include every platform key it does not carry itself, pointing at the library; the sponsor's own upload of a key wins. Advertisers upload only brand keys (`CRICKET_BRAND_KEYS`): logo, hero, pack, board, scoreboard_logo, helmet_logo, jersey_logo, bat_sticker, ad_break, preview — the editor no longer offers footage uploads. - **Footage at three levels (v1.9)**: a cricket wall resolves each platform key as SCREEN override → the creative's own file → the library. Ops endpoints: `POST/DELETE /v1/ops/creatives/{id}/assets/{key}` (any key; never touches approval), `GET /v1/ops/screens/{id}/assets` (each key with its `source`), `POST/DELETE /v1/ops/screens/{id}/assets/{key}` (migration `f7c9e1a3b5d7`, `screen_assets`). Public `GET /v1/screens/{id}/assets/{key}`. The wall's `GET /v1/creatives/{id}/game` resolves for the calling device's screen; the portal preview (a user) resolves without a screen. The device schedule's `sha256` for a cricket creative is a digest of the creative, the library and the screen's overrides, so walls refetch when any footage changes. - **Library per template (v1.9)**: `PLATFORM_KEYS_BY_TEMPLATE` — cricket: the stadium set; spin and wheel: `fx_ring`, `fx_burst`, `fx_coins`. `/v1/ops/library` items carry `group` and `templates`. Spin and wheel creatives inherit the effect clips the same way cricket inherits the stadium (screen > creative > library); their editors no longer offer effect uploads. Symbols, wilds, bonus, win film, background and living poster stay the brand's. ## v1.8 — WhatsApp delivery through AiSensy (2026-10-24) Every message to a shopper's phone goes through one module, `app/services/aisensy.py` (AiSensy campaign API; one POST per message). `WHATSAPP_PROVIDER` = `log` (default, delivers nothing) | `aisensy`. With `aisensy`, `AISENSY_API_KEY` is required and the backend refuses to boot without it in production. - **Wallet OTP** `POST /v1/wallet/otp/start` → 204 after AiSensy accepts the authentication template `freebee_otp` ({{1}} = code, copy-code button = code). If AiSensy refuses or is unreachable → **502** `{"detail": "We could not send the code right now. Try again in a minute."}` and nothing is minted: the attempt does not count against the 5-per-hour allowance. `WALLET_OTP_DEV=1` still returns `{dev_code}` with 200 and must be unset at launch. - **Cricket join code**: template `freebee_cricket_code` ({{1}} code, {{2}} brand, {{3}} screen label); failures are logged and swallowed because the phone page shows the code anyway. - Env: `WHATSAPP_PROVIDER`, `AISENSY_API_KEY`, `AISENSY_OTP_CAMPAIGN`, `AISENSY_CRICKET_CAMPAIGN` (campaign = the template name approved in AiSensy), `AISENSY_API_URL`, `AISENSY_TIMEOUT_SECONDS`, `AISENSY_OTP_COPY_CODE_BUTTON`. - Privacy unchanged: phone leaves the server only in the AiSensy request body; logs carry `+91••••••1234`; the code is never logged and never in an error. ## v1.8 — AI second opinion on wall names (2026-10-24) `POST /v1/play/{screen_id}/join` and the kiosk name entry run the display name through the block list as before; when `MODERATION_PROVIDER=anthropic` (needs `ANTHROPIC_API_KEY`) a name that passes the list is also shown to Claude Haiku with one question (fine on a mall screen seen by families, in any Indian language or script?). BLOCK → the same 422 as the list, reason "That name can't go on the big screen. Please use your real name, without foul language." Timeouts (1.5 s) and errors fail open and log one warning; decisions are cached in memory per name. Only the name is sent, nothing else. ## Billing — invoices, Razorpay behind env, void + credit (2026-10-24) An invoice is issued the moment a booking becomes **confirmed** (creative already approved at booking time, or ops approving the creative of an `awaiting_creative` booking). Numbers are `FB--<6 digits>`, sequential per year, never reused (a voided invoice keeps its number). Money fields are **integer paise**; GST is added on top at `gst_rate_bp` (default 1800 = 18%), rounded half-up to the paise. Bookings keep `price_total` in integer rupees as before; `subtotal_paise = price_total × 100`. Provider: `PAYMENTS_PROVIDER` = `manual` (default) | `razorpay`. With `manual` nothing online exists: `/pay` and `/confirm` answer 409, the web hides "Pay now" and says "Pay by bank transfer; details are on the invoice", ops record payments with mark_paid. With `razorpay` the three `RAZORPAY_*` env vars are required (the backend refuses to boot in production without them). ### Advertiser (ops may read every invoice) - `GET /v1/billing/config` → `{provider, online_payments, key_id|null, bank_details:[str], due_days}`. Never the secret. - `GET /v1/invoices` → newest first: `[{id, number, kind:"invoice"|"credit_note", credit_for_invoice_id, booking_id, campaign_name, creative_name, issued_at, due_at, status:"draft"|"issued"|"paid"|"void", subtotal_paise, gst_rate_bp, gst_paise, total_paise, currency:"INR", paid_at, payment_method: "razorpay"|"bank"|"cash"|"other"|null, razorpay_order_id, notes}]`. Credit notes carry negative amounts. - `GET /v1/invoices/{id}` → the row above plus `seller:{name, gstin, address, state_code, bank_details}`, `buyer:{name, gstin, address_line1, address_line2, city, pincode, state_code}`, `items:[{screen_label, venue_name, slot_count, days, start_date, end_date, window, amount_paise}]`, `credit_for_number`, `gst_split:"cgst_sgst"|"igst"` (same state code on both sides → CGST+SGST), `provider`. 404 for another advertiser's invoice. - `POST /v1/invoices/{id}/pay` → `{order_id, amount, currency, key_id}` for Razorpay Checkout (amount in paise; a second call reuses the same order). 409 when the provider is manual or the invoice is not `issued`; 502 when Razorpay cannot be reached. - `POST /v1/invoices/{id}/confirm {order_id, payment_id, signature}` → verifies HMAC-SHA256(`order_id|payment_id`, key secret) and marks the invoice (and its booking) paid with method `razorpay`. 400 bad signature or order mismatch; 409 already paid by a different payment. Re-confirming the same payment id is a 200 no-op. - `POST /v1/webhooks/razorpay` (no user auth; header `X-Razorpay-Signature` = HMAC-SHA256 of the raw body with `RAZORPAY_WEBHOOK_SECRET`). Handles `payment.captured`: finds the invoice by `order_id`, checks the amount, marks paid. Idempotent: the same payment twice → `{"status":"duplicate"}`, another payment on a paid invoice → `already_paid`, nothing changes either way. Other events → `ignored`. 400 bad signature, 503 when no webhook secret is configured. ### Ops - `POST /v1/ops/bookings/{id}/mark_paid {method?: "razorpay"|"bank"|"cash"|"other", notes?}` (body optional, default `bank`) → booking dict. Marks the booking's invoice paid with that method (issues one first if the booking somehow has none). 409 if already paid or cancelled. - `POST /v1/ops/invoices/{id}/void {reason}` → voids an **unpaid** invoice (409 if paid or already void). The number is kept. - `POST /v1/ops/invoices/{id}/credit {amount_paise, reason}` → 201 credit note row (`kind: "credit_note"`, negative subtotal/gst/total, `credit_for_invoice_id`, its own sequential number). `amount_paise` is the amount **before GST**; GST is credited at the invoice's rate. 409 if the credit would exceed what is left on the invoice or the invoice is void. No money moves: Razorpay refunds are done by hand in the Razorpay dashboard for now. ### Booking dict addition - `GET /v1/bookings`, `POST /v1/bookings`, `GET /v1/ops/bookings`, mark_paid now include `invoice: {id, number, status, total_paise, due_at, payment_method} | null` (null until the booking is confirmed). ### Profile (billing details) - `GET /v1/me` profile and `PATCH /v1/me/profile` gain `address_line1, address_line2, city, pincode, state_code` (two-digit GST state code, `^\d{2}$`, 422 otherwise). GSTIN is upper-cased. An empty string clears an optional field. Nothing beyond what a GST invoice needs is collected. ### Env `PAYMENTS_PROVIDER`, `RAZORPAY_KEY_ID`, `RAZORPAY_KEY_SECRET`, `RAZORPAY_WEBHOOK_SECRET`, `INVOICE_DUE_DAYS` (7), `INVOICE_GST_RATE_BP` (1800), `BILLING_SELLER_NAME`, `BILLING_SELLER_GSTIN`, `BILLING_SELLER_ADDRESS`, `BILLING_SELLER_STATE_CODE`, `BILLING_BANK_DETAILS` (details separated by ` | `, printed on unpaid invoices). Migration `b1ll1n6a0001` (`billing_invoices.py`): tables `invoices`, `invoice_sequences`; five nullable columns on `advertiser_profiles`. ## Review checklist (mall sign-off) — 2026-10-24 - `POST /v1/ops/creatives/{id}/approve` now REQUIRES a body: `{checklist:{likeness_rights:true, prize_terms_clear:true, mall_rules_fit:true}, mall_signoff_ref?: str≤200}`. Any tick missing or false → **422** `{"detail": "Tick all three checks before approving: …"}` (plain sentence, not a pydantic error list). The 409s (already rejected, missing game assets) are unchanged and are checked after the ticks. - On success the ticks, the trimmed reference (`null` when blank) and `checked_at` are stored on `creatives.review_checklist` (JSON, nullable; migration `a9d2f4c6e8b1`, file `legal_review_checklist.py`) and returned as `review_checklist` on every **ops** creative response (`GET /v1/ops/creatives`, approve, reject). Advertiser responses (`GET /v1/creatives`) never carry it. - Reject needs no checklist. Re-approval after a replace / config change asks for the ticks again and overwrites the stored checklist. - Wallet consent: the web now sends `consent_version:"2026-10"` with the reworded consent text (`web/lib/wallet.ts`); the server stores whatever version the client sends, as before. ## Accounts — password reset, messages, team roles (2026-10-24) Migration `a9b1c3d5e7f9` (`accounts_reset_team_notifications.py`): `users.business_id` (backfilled to the user's own id) + `users.team_role` (owner | editor | viewer, default owner); new tables `password_resets`, `team_invites`, `notifications`. **Email provider** (`app/services/email.py`, mirrors aisensy.py): `EMAIL_PROVIDER` = `log` (default; logs subject + masked recipient `p••••@brite.in`, never the body) | `resend` (`RESEND_API_KEY`, `EMAIL_FROM`). `WEB_URL` is the base of every link in an email. `EMAIL_DEV_ECHO=1` (dev only, with `log`; production refuses to boot) makes the two link-minting endpoints below return the link in JSON. - `POST /v1/auth/forgot {email}` → **204** always (never says whether the email exists). Mints a single-use token (SHA-256 stored, 30 min, `PASSWORD_RESET_TTL_MINUTES`) and emails "Reset your Freebee password" with `${WEB_URL}/reset?token=…`. With dev echo: 200 `{reset_url}` (`null` for unknown emails). - `POST /v1/auth/reset {token, password}` → 204; sets the password and burns every outstanding token for that user. Invalid, used or expired → **410** `"This reset link has expired or was already used. Request a new one."` - `GET /v1/me` and the auth responses' `user` now carry `team_role` and `business_id` (null for ops). **Notifications** (one row per member of the business; the same text is emailed to each member when `EMAIL_PROVIDER` is not `log`; email failures are logged and swallowed): - Emitted on: creative approved ("Your ad Diwali Sale is approved and will play from 2 Nov."), creative rejected (with the reason), booking confirmed (at booking time when the ad is already approved, or when approval confirms an awaiting booking), booking marked paid (once), campaign starts today, campaign ended yesterday + report ready. `kind` ∈ creative_approved | creative_rejected | booking_confirmed | booking_paid | campaign_started | campaign_ended. `href` is a portal path (`/creatives`, `/campaigns/{booking_id}`). - `GET /v1/notifications?unread=0|1&limit=100` → `{unread, items[{id, kind, title, body, href, created_at, read_at}]}` newest first. - `POST /v1/notifications/{id}/read` → the row (404 if not yours). `POST /v1/notifications/read-all` → `{marked, unread: 0}`. - `POST /v1/ops/notifications/run-daily` (ops) → `{date, started, ended}`. Idempotent per booking per user; there is no in-process scheduler, so call it once a day (IST date). **Team roles (thin slice).** A business is keyed by its first user's id; creatives, campaigns, bookings, reports and overview metrics are scoped by `User.business_key` (`business_id` or own id), so every teammate sees the same account. Viewers get **403** `"Your role on this team is view only…"` on every write to creatives and bookings (upload, game create/update, assets, rename, replace, delete, book, cancel). - `GET /v1/team` (advertisers) → `{business_id, business_name, my_role, roles[{role, explains}], members[{id, email, role, joined_at}], invites[{id, email, role, created_at, expires_at}]}` (pending, unexpired invites only). - `POST /v1/team/invites {email, role: owner|editor|viewer}` (owners only, else 403) → 201 invite + `emailed` (+ `invite_url` with dev echo). Emails "<Business> invited you to Freebee" with `${WEB_URL}/signup?invite=` (7 days, `TEAM_INVITE_TTL_DAYS`). 409 if the address is already on the team or has an account with another business; a re-invite replaces the earlier link. - `DELETE /v1/team/invites/{id}` (owners) → 204. - `POST /v1/auth/invite/lookup {token}` (public; a POST so the token never sits in an access-log path) → `{email, role, business_name}` or 410. - `POST /v1/auth/signup` accepts `invite_token` (then `business_name` is optional and ignored): the new user joins the inviter's business with the invited role; 403 if the email differs from the invited one, 410 if the invite is used or expired. Without an invite, `business_name` is required (422). ## Prize fulfilment + upload rules (2026-10-24) ### Prize tiers: stock and coupon life (additive, every game template) `SpinPrize` / `WheelPrize` / `CricketPrize` gain `stock_total` (int ≥1 | null, default null = unlimited) and `expires_days` (1–365 | null, default null = `COUPON_TTL_DAYS`, 30). - **Stock**: `issued` = win tokens ever minted at that tier for the booking (or the unbooked creative), the same bucket the daily cap uses. Once `issued >= stock_total` the draw skips the tier and falls through to the next one down (spin/wheel: big → mid → everyone-wins; cricket: down the run bands), exactly like a spent daily cap. - **Expiry**: a coupon of that tier expires `expires_days` after it is claimed. - `GET /v1/metrics/campaigns/{booking}` gains top-level `prize_stock: [{tier, title, stock_total, issued, stock_left}]` (all-time for the booking, not range-filtered; `[]` for non-game creatives; `stock_left` null when unlimited). ### Per-phone fraud limits (claim time, keyed on the phone HASH) Settings `PRIZE_MAX_PER_PHONE_PER_CAMPAIGN_DAY` (2) and `PRIZE_MAX_PER_PHONE_PER_DAY` (5). A "prize" here is a coupon above its game's everyone-wins tier; everyone-wins coupons never count and are never limited. The count joins `coupons → wallet_users.phone_hash` (UTC day), so a re-registered phone is the same shopper. When either limit is reached, `POST /v1/wallet/claim` still returns 201 but the coupon is minted at the everyone-wins tier with `limited: true`, `message: "You have claimed today's prizes, come back tomorrow"`, `original_tier` kept on the row. Every coupon dict (claim, `/wallet/me`, 409 payload) now carries `limited` (bool) and `message` (string | null). The older one-coupon-per-(user, campaign)-per-day 409 rule is unchanged and applies first; the per-campaign limit therefore matters once that rule is relaxed. Double redemption of a code was already impossible (`POST /v1/redeem` → 409 "already redeemed"); `tests/test_prizes_uploads.py::test_same_code_cannot_be_redeemed_twice` proves it. ### Expiry reminders `coupons` gains `reminded_at`, `limited`, `original_tier` (migration `prizes_coupon_fulfilment`). `remind_expiring_coupons()` (`app/services/prizes.py`) selects coupons with status `issued`, `reminded_at` null and `now < expires_at <= now + COUPON_REMINDER_WINDOW_HOURS` (24), stamps `reminded_at` and, with `WHATSAPP_PROVIDER=aisensy`, sends template `AISENSY_REMINDER_CAMPAIGN` = `freebee_coupon_reminder` ({{1}} prize title, {{2}} brand, {{3}} expiry date `DD Mon YYYY`); with `log` it logs the masked phone only. Erased wallets are stamped and skipped. Codes are never logged. - `POST /v1/ops/coupons/run-reminders` (ops) → `{reminded, sent, skipped, failed}`; idempotent per coupon, safe to call from a daily scheduler. ### Ops fulfilment - `GET /v1/ops/wallet/fulfilment?days=7` (ops, 1–90) → `{days, issued, redeemed, expiring_soon, blocked_by_limit, campaigns:[{booking_id, creative_id, campaign, brand, tier, title, issued, redeemed, stock_total, stock_left}]}`. Counts only: never a phone, never a code. ### Upload rules: conversion and edits - `POST /v1/creatives` (and `POST /v1/creatives/{id}/file`) accept `.mov .webm .mkv .avi .m4v` **when ffmpeg/ffprobe are on the host** (`app/services/transcode.py`; Railway: see `docs/DEPLOY.md`). The upload is probed; anything that is not already H.264 yuv420p + AAC in an mp4 is re-encoded to H.264/AAC mp4, yuv420p, faststart, 30 fps, scaled to fit 1080×1920 (portrait, default) or 1920×1080 (sources wider than tall), padded black, cut at `VIDEO_MAX_DURATION_S`. The original is removed; the stored file is the mp4. Size limits are checked on the original as before. - Creative dict gains `converted: bool` and `editable: bool` (images always; videos only with ffmpeg). `validation.conversion = {from:{filename, ext, codec, container, width, height, rotation, duration_s}, to:{ext, codec, audio, canvas, mode}, capped_to_s}` when converted. - Without ffmpeg the foreign containers are refused: 422 `{"reasons": ["unsupported file type '.mov': Our server cannot convert this format yet; export as H.264 MP4"]}`. mp4 behaves exactly as before (including unreadable files: probe skipped, recorded in checks). - `POST /v1/creatives/{id}/edit` (owner) body `{trim: {start_s, end_s}}` and/or `{crop: "fit" | "crop"}` → creative dict. Re-encodes into a NEW file (old removed), appends `validation.edits[] = {op:"trim", start_s, end_s, mode} | {op:"crop", mode, canvas?}`, keeps `validation.conversion`, RESETS status to `pending` and bumps the schedule version (same semantics as replacing the file). Trimmed length must be ≤ `VIDEO_MAX_DURATION_S` (422). Images: `crop` only (Pillow fit = black letterbox / crop = centre fill to the matching canvas); `trim` on an image → 422. Video without ffmpeg → 422 with the reason above. Game creatives → 409. ## Device reliability + proof of play (2026-10-24) ### Check-in health (additive, every field optional) - `POST /v1/device/heartbeat` accepts, besides `playing_creative_id` and `watchers_now`: `player_version` (≤40 chars), `uptime_s`, `last_error` (≤200 chars), `last_error_at`, `cache:{cached,total}`, `storage:{usage_bytes?,quota_bytes?}`, `camera: off|no_permission|counting`, `screen:{width,height}`, `online` (bool), `queued_events`. When any of them is present the whole health part is stored as the device's latest snapshot (`devices.health`, plus `reported_at`); a check-in without them keeps the previous snapshot. The browser player reports `player_version = "web-1.0.0"`. - The answer is now `{"schedule_version": n, "commands": [{id, kind}]}` — `commands` are the device's pending remote commands, oldest first (empty for players that never get any). Older players ignore the extra key. ### Remote commands - Table `device_commands` (id, device_id, kind, created_by, created_at, acked_at, result). Kinds: `reload` | `restart_engine` | `clear_cache` | `refetch_schedule`. - Ops: `POST /v1/ops/devices/{device_id}/commands {kind}` → 201 command; `GET /v1/ops/devices/{device_id}/commands` (last 20, newest first). Ops role required (403 otherwise); 404 for an unknown or revoked device. - Device: `POST /v1/device/commands/{id}/ack {result?}` → `{id, kind, acked_at, result}`. Device token only; 404 when the command belongs to another device; idempotent (a second ack keeps the first result). The player executes: reload = page reload; restart_engine = tear down and rebuild the loop in place; clear_cache = drop Cache Storage + refetch; refetch_schedule. - `GET /v1/ops/devices` → one card per active device: `{id, device_uid, status, screen:{id,label,zone,venue}|null, last_seen_at, playing_creative_id, watchers_now, health, attention:[str], last_command, pending_commands}`. `attention` is the server's plain-English list of reasons the device needs a look (no check-in for > 2 min, player reports offline, cache incomplete, camera permission denied, ≥ 10 queued records, recent error); the list is sorted most-attention first. ### Offline cache + dedupe (unchanged contract, confirmed) - Play records already carry `client_play_id`; the server ignores a play whose (device, client_play_id) was recorded before even when it arrives under a new `batch_seq`, and a re-sent `batch_seq` answers `duplicate`. A retry therefore never double-counts. ### Proof of play - `GET /v1/bookings/{id}/proof?format=csv|json` (advertiser owning the booking, or ops; 404 otherwise). Every play record of the booking, oldest first: play_id, screen_id, screen, creative_id, creative, started_at, duration_ms, watchers (null/blank when the screen had no working sensor). CSV ends with `#proof booking_id=… records=n first= last= signature=`; JSON carries `proof:{records, first, last, signature}`. Signature = HMAC-SHA256 with a key derived from `SECRET_KEY` (`HMAC(SECRET_KEY, "proof-of-play")`) over the CSV content before the footer, or over the canonical JSON (`sort_keys`, no spaces) of `records`. - `POST /v1/proof/verify` (no auth; multipart field `file` or the file as the raw body) → `{valid, records, first, last, reason?}`. Any edit to a row, a removed row or a changed count gives `valid: false`; a non-proof file gives `reason: "not a proof file"`. ## Bidding for busy windows (2026-10-24) Every window stays first come, first served at the fixed rate, except **busy windows** that ops mark on screens with bidding switched on. Sealed bids, first price: winners pay their own bid. Migration `bidding_busy_windows` (`bidding_busy_windows.py`): `screens.bidding_enabled`, table `bidding_rounds`, and `bookings.bid_round_id / bid_per_slot_day / bid_at / cancel_reason`. **Money unit.** A bid and a floor are integer rupees **per slot per day for that window** (an hour window's share of the day is already inside the number). Price of a bid = bid × slots × days. The default floor is the fixed price of one slot for one day of that window (`rate × window hours ÷ screen hours`, rounded, min ₹1). ### Ops (role=ops) - `PATCH /v1/ops/screens/{id} {bidding_enabled: bool}` (with the other screen fields). Switching off while the screen has open rounds → 409. Screen dicts (ops and public `GET /v1/screens`) carry `bidding_enabled`. - `POST /v1/ops/screens/{id}/bidding/rounds {start_date, end_date, start_time?, end_time?, floor_per_slot_day?, closes_at?, slots_on_offer?, note?}` → 201 round (ops shape below). Times are "HH:MM" IST on the screen's step (both omitted = all day). Defaults: floor as above, `slots_on_offer` = the screen's `loop_slots` (1…loop_slots), `closes_at` = `BIDDING_CLOSE_LEAD_HOURS` (48) before the window first starts (ISO; a value without a zone is read as UTC). 409 bidding off / overlaps another open round on the screen; 422 window not in the future, close not before the window start, default close already past ("pick a close time"). - `GET /v1/ops/screens/{id}/bidding` → `{bidding_enabled, rate_per_slot_day, loop_slots, rounds[]}` (open first). Ops round = public round + `{note, created_at, closed_at, result:{winners, losers, slots_offered, slots_sold, winning_booking_ids}|null, bids:[{booking_id, campaign_name, advertiser_email, bid_per_slot_day, slot_count, bid_at, status, cancel_reason}]}` (best first). Ops run the auction, so they see every amount. - `DELETE /v1/ops/bidding/rounds/{id}` → 204 only while open with no bids ever placed; else 409. - `POST /v1/ops/bidding/rounds/{id}/close` → `{result, round}`: force-close one round now. - `POST /v1/ops/bidding/close-due` → `{closed: n, rounds:[result]}`: closes every open round whose `closes_at` has passed. **Idempotent** (a closed round is never decided twice; a second call closes 0); there is no in-process scheduler, so call it every few minutes from a cron. - `GET /v1/ops/bookings` `phase` gains `bidding`; `POST /v1/ops/bookings/{id}/mark_paid` on a bidding booking → 409. ### Advertiser - `GET /v1/screens/{id}/bidding?start&end` (any signed-in user; dates optional, both or neither) → `{bidding_enabled, rounds:[{id, screen_id, screen_label, start_date, end_date, start_time, end_time, floor_per_slot_day, slots_on_offer, closes_at, status, bidders}]}` — open rounds only. **Sealed:** floor, bidder count (distinct businesses with a live bid) and close time; never an amount. - `POST /v1/bookings` gains optional `bid_per_slot_day` (int ≥ 1). - Without it, any item whose dates **and** hours touch an open round (an all-day item touches every round on those days) → **409** with a sentence that explains bidding, the floor and the close time. Items outside busy hours book as before. - With it: exactly one item (422 otherwise), matching the round's dates and hours exactly (422), `slot_count ≤ slots_on_offer` (422), bid ≥ floor (422 "below the floor"), round open and not past `closes_at` (409), one bid per business per round, even after withdrawing (409 "raise it instead"), not a cricket game (422). → 201 booking with `status:"bidding"`, no invoice, holds no capacity, `price_total` = bid × slots × days. Viewers → 403 as for any booking. - `POST /v1/bookings/{id}/bid {bid_per_slot_day}` (editor/owner) → booking dict. Raise only: an amount ≤ the current bid → 422; round closed or past its close time → 409; viewers 403; another business's booking 404. A raise moves `bid_at` (ties go to whoever reached the amount first). - `POST /v1/bookings/{id}/cancel` on an open bid withdraws it (`cancel_reason:"withdrawn"`); the business cannot bid again in that round. - Booking dicts gain `cancel_reason` (`"outbid"` | `"withdrawn"` | null) and `bid: null | {per_slot_day, bid_at, outcome:"open"|"won"|"outbid"|"withdrawn", round:, next_window:{screen_id, start_date, end_date, start_time, end_time}|null}` — the caller's own bid only. `next_window` (outbid only) is the first run of the same length and hours after the busy window with enough free slots and no open round (60-day horizon); the web links it to `/book?screen&start&end&from&to`. - Bidding bookings are left out of spend totals, screen revenue, cricket resolution and window clashes. ### Close rule At close: `offer = min(slots_on_offer, free slots on the window's busiest day)` (fixed bookings made before the window was marked busy keep theirs). Live bids are ranked by amount desc, then `bid_at` asc, then booking time; each bid wins if its `slot_count` still fits, otherwise loses (a smaller bid further down may still fill the remainder). Winners: price stays at their bid; ad approved → `confirmed`, schedule materialized and the invoice issued through the normal billing path (integer paise = price × 100 + GST); ad still in review → `awaiting_creative` (confirms + invoices on approval like any booking). Notification `bid_won`. Losers: `cancelled`, `cancel_reason:"outbid"`, nothing charged, notification `bid_lost` naming the next free window. Notification kinds gain `bid_won`, `bid_lost` (href `/campaigns/{booking_id}`). Env: `BIDDING_CLOSE_LEAD_HOURS` (48). ## Security review (2026-10-24, branch fix/security-review) Behaviour changes a client can observe; nothing else in this contract moved. - `POST /v1/invoices/{id}/confirm` → **400** unless the invoice already has the Razorpay order that `/pay` created for it AND `order_id` equals it (the old check was skipped when `/pay` had never run, so another order's valid signature could mark the invoice paid). - Viewers now get **403** on `POST /v1/creatives/{id}/edit` and `PATCH /v1/me/profile` too. - **429** `{detail}` with `Retry-After` (seconds) from the throttles in `app/core/ratelimit.py` (in-process windows, client = right-most `X-Forwarded-For`) and DB counts: `POST /v1/auth/login` 30 attempts/IP/5 min and 20 wrong passwords/account/15 min (then even the right password waits); `/auth/signup` 20/IP/h; `/auth/forgot` 10/IP/h; `/auth/reset` + `/auth/invite/lookup` 30/IP/15 min; `POST /v1/team/invites` 30/business/24 h; `POST /v1/wallet/otp/start` 60/IP/h across phones (the 5/phone/h rule is unchanged); `POST /v1/proof/verify` 30/IP/min. Every number is an env var (`LOGIN_MAX_ATTEMPTS_PER_IP`, `LOGIN_MAX_FAILURES_PER_EMAIL`, `SIGNUP_MAX_PER_IP`, `FORGOT_MAX_PER_IP`, `FORGOT_MAX_PER_USER`, `TOKEN_LOOKUPS_MAX_PER_IP`, `TEAM_INVITES_MAX_PER_BUSINESS`, `WALLET_OTP_MAX_STARTS_PER_IP`, `CRICKET_WHATSAPP_MAX_PER_IP`, `PROOF_VERIFY_MAX_PER_IP`); `RATE_LIMITS_ENABLED=0` turns the in-process ones off. - `POST /v1/auth/forgot` still answers 204 for everything; beyond 3 links per account per hour it sends nothing. The email now goes out after the response. - `POST /v1/play/{screen}/join` with `phone`: past 10 WhatsApp sends per IP per 10 min the ticket is still issued (201, code on screen) but no WhatsApp goes out. - User JWTs carry `pv` (keyed fingerprint of the password hash). After a password reset (or an `OPS_PASSWORD` rotation) every earlier token answers **401** "Your password was changed. Sign in again." Tokens minted before this release carry no `pv` and expire normally. - Creative uploads: a `.png/.jpg` must start with PNG/JPEG magic bytes and an `.mp4` with an ISO-BMFF `ftyp` box, else **422** `{"reasons": ["the file is named .png but is not a png or jpg image"]}`; the served Content-Type follows the bytes. ffmpeg/ffprobe open uploads with `-protocol_whitelist file -format_whitelist mov,matroska,avi` only. - Every API response carries `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer`. - Web `/player/api/*` proxies only `v1/device/**`, `v1/creatives/{id}/(file|game|assets/{key})`, `v1/library/{key}`, `v1/screens/{id}/assets/{key}` (404 otherwise) and forwards only a `Device …` Authorization header. ## Platform jobs and hardening (2026-10-26, branch feat/jobs-hardening) ### Built-in scheduler `app/core/scheduler.py`: a daemon thread inside the API process runs the platform's background jobs, so no external cron is needed. It starts in the FastAPI lifespan when `SCHEDULER_ENABLED` is on (unset = on with `ENVIRONMENT=production`, off everywhere else, including tests). Every `SCHEDULER_TICK_SECONDS` (30) it reads the IST wall clock and runs what is due: | Job | When | What | |---|---|---| | `bidding.close_due` | every 5 minutes | decides every bidding round past its close time | | `notifications.run_daily` | daily 06:30 IST | campaign starts today / ended yesterday messages | | `prizes.remind_expiring_coupons` | daily 10:00 IST | WhatsApp reminder for coupons expiring within a day | | `reports.send_weekly` | Mondays 08:00 IST | weekly report email + notification (below) | The due rule reads `job_runs`, so it survives restarts: a daily/weekly job is due when its most recent slot has passed and no run has started since; a late boot catches up within 12 h (24 h for the weekly report), otherwise it waits for the next slot. Every job was already idempotent, so a catch-up or a manual run next to a scheduled one changes nothing twice. A job that raises is rolled back, recorded with `ok:false` and logged once; the thread keeps going. Railway runs one uvicorn process, so one scheduler runs; with more replicas a DB lock is needed first (TODO in the code). The ops endpoints `POST /v1/ops/bidding/close-due`, `/v1/ops/notifications/run-daily` and `/v1/ops/coupons/run-reminders` remain and behave as before (they do not write `job_runs`). Table `job_runs` (migration `jobs_job_runs`): `id, job, trigger ("schedule"|"manual"), started_at, finished_at, ok (null while running), detail` (a short scrubbed summary such as `{"closed": 0}` or `RuntimeError: …`; never a code, token or phone). - `GET /v1/ops/jobs` (ops) → `{scheduler:{enabled, running}, jobs:[{job, label, explains, schedule, next_run_at|null, running, last_run:{id, job, trigger, started_at, finished_at, ok, detail}|null}]}` in the table's order. `next_run_at` is null while the scheduler is off. - `POST /v1/ops/jobs/{job}/run` (ops) → the run row (`trigger:"manual"`; `ok:false` with `detail` when the job failed, still 200). 404 unknown job; 409 while the same job is already running. - Web: ops Today page, "Background jobs" card: last run per job and a Run now button. ### Proof of play v2 Downloads are now version 2: the signature binds the booking id, the booking's screen ids, its date range and the generation time as well as the rows. - CSV: first line `#proof version=2`, then the header and rows as before, then the footer `#proof booking_id=<id> screens= from=<YYYY-MM-DD> to=<YYYY-MM-DD> generated_at=<iso> records=<n> first=<iso> last=<iso> signature=<hex>`. The signature covers every byte of the file up to (not including) ` signature=`. - JSON: `{version:2, booking_id, screen_ids[], start_date, end_date, generated_at, records[], proof:{records, first, last, signature}}`; signature over the canonical JSON (sort_keys, no spaces) of every top-level field except `proof`. - `POST /v1/proof/verify` → `{valid, version: 2|1|null, records, first, last, …}`. v2 adds the bound `booking_id, screen_ids, start_date, end_date, generated_at`. Version 1 files (rows-only signature, no version line) still verify and answer `version: 1` plus `warning: "This is a version 1 proof file. Its signature covers the play rows only, not the booking, screens or dates, so check those against your booking yourself."` (no `booking_id` echoed). Removing the version line or field from a v2 file does not turn it into a valid v1 file. Non-proof input: `version: null, reason: "not a proof file"`. ### Roles - `POST /v1/redeem` now needs an owner or editor (viewers **403**, the usual view-only sentence); ops unchanged. - `PATCH /v1/team/members/{user_id} {role: owner|editor|viewer}` (owners only, 403 otherwise) → member `{id, email, role, joined_at}`. 404 when the user is not on your team. **409** "A team needs at least one owner. Make someone else an owner first." when it would demote the only owner (an owner may change their own role while another owner exists). Takes effect on the next request. - `DELETE /v1/team/members/{user_id}` (owners only) → 204. 409 for yourself ("You cannot remove yourself…"), for the last owner, and for the person who opened the account (every row of the business is keyed on their id: "Change their role instead, or ask our team to move the account"). 404 when not on your team. The removed person keeps their sign-in but becomes the owner of an empty account of their own (`business_id` = own id, profile business name cleared); their copies of the team's notifications are deleted. Access ends on their next request. - Web: Team page members list gains a role select and a Remove button with an inline confirm (owners only; no Remove on yourself or on the person who opened the account). ### Weekly report email `app/services/reports.py` `send_weekly(db)` (the Monday job): for every business with activity in the last Monday-to-Sunday IST week (plays, reward QR scans, or spend), each member gets one email "Your weekly Freebee report" with plays, watchers (or "not measured on these screens" when no sensor counted anyone), QR code scans and spend (integer rupees of bookings made that week, not cancelled, not an open bid) and a link to `${WEB_URL}/reports`, through `app/services/email.py` (`EMAIL_PROVIDER=log` sends nothing and logs the masked address). It is also recorded as a notification, kind `weekly_report`, title "Your weekly report is ready", href `/reports?week=`. Idempotent per member per week; an email failure is logged and swallowed. Result `{week_start, week_end, businesses, notified, emailed, skipped, failed}`. ### Web content security policy The frame-ancestors-only policy is replaced by a full CSP (`web/lib/csp.mjs`): - Next pages: `middleware.ts` sets a fresh nonce per request; `script-src 'self' 'nonce-…'` plus `*.posthog.com` and `checkout.razorpay.com`. The root layout reads `headers()`, so every page is rendered per request (a page built ahead of time cannot carry a nonce). - Game engine pages (`/catch|spin|wheel|cricket|fortune|cabinet|clock.html`, static files with inline scripts): `script-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com` (qrcodejs). The trade-off is documented in `lib/csp.mjs`. - Both: `connect-src` self, the API origin from `NEXT_PUBLIC_API_URL`, `*.posthog.com`, the Sentry ingest origin (from `NEXT_PUBLIC_SENTRY_DSN`, else `*.sentry.io`), `api.razorpay.com`, `lumberjack.razorpay.com`; `img-src`/`media-src` self, the API, `data:`, `blob:`; `style-src` self, `'unsafe-inline'`, Google Fonts; `font-src` self, `data:`, `fonts.gstatic.com`; `frame-src` self, `api.razorpay.com`, `checkout.razorpay.com`; `worker-src` self, `blob:`; `frame-ancestors`, `form-action`, `base-uri` self; `object-src 'none'`. `next dev` adds `'unsafe-eval'` and `ws:`. ## Dashboard extras (2026-10-26, branch feat/dashboard-extras) Additive only; nothing above changes. Migration `extras_weekly_report_waitlist` (`extras_weekly_report_waitlist.py`): `users.weekly_report` (bool, default true) and table `waitlist`. ### Home weekly line - No API change: `GET /v1/metrics/overview?days=14` already returns `by_day:[{date, plays, ots, watchers}]` (14 rows, oldest first, UTC days, zeros included). Home draws plays per day and, when any day has watchers, watchers as a second line. ### Screen sparklines (ops) - `GET /v1/ops/metrics/screens/sparklines?days=7` (ops; `days` 2–31, default 7) → `{days, dates:[YYYY-MM-DD], screens:[{screen_id, label, plays:[n per day], total}]}`. One call for the whole network, IST days, oldest first, today last; every screen gets a row (zeros when idle). Declared before `/v1/ops/metrics/screens/{screen_id}`, which is unchanged. ### Video thumbnails (poster frames) - With ffmpeg on the host (`transcode.available()`), every stored video gets a JPEG poster frame next to its file (`.thumb.jpg`, frame at 1 s, or the first frame of a shorter clip, 480 px wide). Generated on `POST /v1/creatives`, `POST /v1/creatives/{id}/file`, `POST /v1/creatives/{id}/edit`, `POST /v1/ops/library/{key}` and `POST /v1/ops/screens/{id}/assets/{key}`; removed with the file. Never required: without ffmpeg, or when ffmpeg fails, nothing is stored and `thumbnail_url` is `null` (the web keeps its `