Release notes
Every change shipped to the Restora 360 platform, newest first.
Unreleasedin progress
Changed
- Dashboard doorbell and kiosk menu poll on the POS change marks (2026-10-13, plan step 3 + item 1e). The dashboard's
inbox/streamroute is gone: it held a server-sent event stream open for the whole 300 s Cloud Run request timeout and probed the backend every 2 s from inside it, which on request-based billing charged the ui instance for every second a dashboard tab was open and made up ~65 % of backend requests.useTenantPushSignalnow asks the newGET /api/dashboard/inbox/changesonce every 30 s while the tab is visible (nothing while hidden; one shared poller per tenant), and that route answers from the backend's per-tenant change marks (GET /pos/admin/changes/:tenantId, one ~100-byte read vialib/pos/change-marks.ts) in the fixed order[tickets, kitchen, bookings, settings, catalog], falling back to the two whole-documentupdatedAtprobes only while the marks route is not deployed or unreachable. The Express Kiosk's menu version (GET /api/public/kiosk/menu?v=) now carries thecatalogandsettingsmarks it was built from plus a validity instant (the next canteen daypart boundary, else 10 min) and the content hash (lib/kiosk/menu-version.ts), so an unchanged menu is decided from one marks read before the tenant blob is loaded or hashed; the hash stays as the second gate, so a mark that moved for something the kiosk does not show still answersunchangedat the old price, and with no marks the version is the bare content hash as before. Both kiosk clients already treat the version as opaque, so neither changed. The heartbeat (lastSeenAtin the tenant blob) is still written on every poll, so the cheap path's full saving waits on moving that stamp out of the blob.
Added
- Sales receipt: what is left on the gift card (2026-10-11, till 1.0.5846). A guest who paid with a Presentkort had to ask for a separate balance check. The paper the server prints for the sale now says it under the payment — "Kvar på presentkort ****WXYZ 320,00" — masked code only. The figure is stored on the sale's tender row when the card is debited, so a copy or an e-mailed receipt days later states what was true when the receipt was made, never today's balance. A sale without a gift card prints byte-identical paper. Server-printed paper only: the on-screen receipt text comes from the kernel renderer and is unchanged.
- X/Z report: Dricks, and Växelkassa (2026-10-11, till 1.0.5846). Two things the server knew and the tills never printed. Dricks (ej försäljning) — tips per payment means, outside the sales: a card terminal settles sale + tip, so its day total could not be reconciled against a report that showed only the sale. Växelkassa — the float the shift opened with, at the top of the drawer section: a minimum field of both dagrapporter (SKVFS 2014:9 7 kap. 2 § h, 3 § i) that was on no line. New report fields
openingFloatMinor;tipTotalswas already sent. Both tills, four languages. - Presentkort: paper for the buyer, and for a balance (2026-10-11, till 1.0.5845). Owner: "upon buy will there be a receipt of purchase? and what if someone want to have a receipt for current balance". There was no paper anywhere in the gift flow. Two slips now print on the receipt printer, each from a BUTTON and never on their own (his ruling): Skriv ut köpbevis on the sale's confirmation and on the card's sheet in Historik — the value, how it was paid, when and on which till; and Skriv ut saldo under a balance that was just checked — what is left, the original value, validity. Neither is a kassakvitto and the paper says so:
EJ KVITTOleads it at twice the size of every amount (the pro-forma rule in SKVFS 2014:9), with no receipt number, VAT block, organisation number or control code. The card's code is never on receipt paper — only********WXYZ— and the last door before the printer refuses anything showing more than four characters: the physical card with its code is a separate card-printer device, and everything else is the gift e-mail or the website's share page (owner, same day). New:POST /pos/giftcards/cards/:cardId/print-slip,POST /pos/giftcards/balance/print(the code rides the body, never the URL),/api/internal/pos/print-gift-slip,lib/devices/gift-slip-print.ts. Four languages on the till and on the paper. - Order buzzers — the puck's number on the order (2026-10-10, tills 1.0.5841). Owner: "if a customer
want to use the order buzzer which are the external devices, there should be a way to add buzzer number to order so kitchen or counter can press buzzer to inform customer when the order is ready." The till CARRIES the number; it does not talk to a buzzer (a paired or integrated buzzer is the stated next step, which is why the number is its own field and not a note). Kassa: a
Buzzerdoor on the header row, first among the mode's controls soVälj bordkeeps its place, in Servering and Avhämtning; it opens a number pad (one to four digits,Spara/Avbryt/Ta bort) and then states the number ("Buzzer 12"). A counter basket carries it on its first write (send to kitchen, or the paid sale); a bill the server already holds is written throughPOST /pos/tickets/:id/buzzer, which also re-stamps the kitchen cards still on the rail. Where it is read: the KDS card, the Beställningar row, the kitchen bong (BUZZER 12under the order number) and the guest receipt (Buzzer 12under Ordernr) — paper without a puck is byte-identical. Two switches: the venue's own under Inställningar → Funktioner (off until switched on,settings.buzzer.enabled), and Super admin → POS Order Buzzers per tenant (platform_data.pos_buzzer_policy, allowed unless switched off);/pos/registers/mecarries the resolved answer. Web till, Windows and Android alike, in en/sv/no/da. Rules:backend/src/modules/pos/sales/buzzer.ts,pos/web/src/screens/buzzer-rules.ts,BuzzerRules.kt.- Order buzzers, two findings from the owner's tablet the same evening (2026-10-10, tills 1.0.5842).
A freshly paired till drew no Buzzer button until it was restarted or somebody opened Inställningar → Funktioner:
/pos/registers/pairdid not carry the buzzer block that/pos/registers/medid, and a till builds its first session from the pairing answer. It carries it now (buzzer-on-register-wire.spec.ts). On a tablet the button did not fit the header's one row — it pushedNy beställningonto a second row and cost a row of menu. Below 900 dp of header the door is now compact: a tap-sized bell with the number as a badge on its corner, one width whatever is keyed (BuzzerRules.compactDoor, the container query on.pos-kassaheadinbuzzer.css), and on the native tills it stands in the row's spacer slot instead of taking a gap of its own. Wide tills keep the worded "Buzzer 12". The compact door is confirmed by the owner on his tablet and locked indocs/design-locks/kassa-buzzer-door.md.- Guest page — a stand on the table opens the menu, rewards, Wi-Fi and feedback (2026-10-10). A new
sellable module,
guest-hub(an add-on in no base plan, unpriced until a super admin prices it — the store shows "Kontakta oss"; Enterprise and Pay As You Go include it by the standing "everything" rule)./tap/<tenant>is the venue's link and/tap/<tenant>/<code>one stand; both open ONE page in the guest's language (en/sv/no/da — the switcher, then the phone, then the venue) with a tile per part that WORKS: the menu (a stand's table number rides along), the rewards card, the guest Wi-Fi (network, password with one-tap copy, and aWIFI:QR another phone can join from) and a private star rating. A stand that is no longer listed still opens the page; a switched-off page shows the venue's name and one sentence./dashboard/guest-hub(a tile on the Marketing hub) is where the owner sets it up: the four parts with a "guests see this" answer from the same rule the guest page uses, the Wi-Fi details (refused when they could not work on a phone), the Google review link (must be Google's), and the stands — each with its own link, QR downloads, and "Write to an NFC tag" where the browser can (Chrome on Android). Rules are pure and tested inlib/guest-hub/*;scripts/guest-hub-shots.mjsis the browser proof (12 checks).- The guest rewards card page speaks Swedish, Norwegian and Danish (
/tenant/<id>/loyalty,
lib/loyalty/guest-words.ts) — it was English only, and is now one tap from the guest page.- Guest page, round two (2026-10-10): sold per card, follow buttons, and "Ask about a dish".
Price: owner — "29 SEK per NFC or QR card per month but adjustable price in super admin". A guest card is a licence seat (
seat-guest-card, counted on the venue's stand list), priced ONLY in Super admin → Licensing (no number in code); buying one on/dashboard/licencesopens the module for a plan without it. Only ADDING a card past the licence is ever refused (409GUEST_CARDS_FULL, and only when platform enforcement is on) — a card on a table keeps working, and renaming, re-pointing or removing one always saves. The setup page shows cards in use, the licence, and the price per card. Follow buttons: Instagram, YouTube and TikTok, right on the guest page; the links are the venue's ownsettings.socials, editable on the setup page (https only). Ask about a dish: a guest picks a dish and reads its ingredients, allergens ("contains" / "may contain") and dietary labels, and can check one allergen. NOT an AI: every verdict is the regulated resolver's (lib/ita/allergen-qa.ts), verbatim, and a dish with nothing recorded says "ask the staff", never "free from" (lib/guest-hub/ask.ts;POST /api/public/[tenantId]/hub/ask, read-only, rate-limited). Nutrition is not answered — a menu item carries no nutrition figures.- **Guest page, round three (2026-10-10): typed questions with an AI layer, and nutrition, in "Ask about
a dish".* Typed questions: one field — typing filters the dish list, "Ask" sends the words. Plain word matching (`lib/guest-hub/ask-question.ts`) finds the dish and the allergen first, FREE, in any of the four languages. Only when no dish is found, and only when the owner has switched it on, an AI READS the question and picks the dish (`ask-ai.ts`): the smallest model, dish names only in the prompt, a reply of a few numbers, the same question answered from memory for 30 minutes, a daily ceiling per venue. It is paid from the restaurant's own AI credit — the wallet every tenant already has, topped up on `/dashboard/ai-spend` — through the same gate and debit as every AI feature, as "Guest page · Ask about a dish". No credit, or no platform key: the guest still gets the list and sees no error. The AI never answers. What it returns is accepted only as dish numbers and registry ids; the answer is composed from the venue's records and the regulated allergen resolver, and there is no "dishes free from X" list — only dishes that DECLARE an allergen or carry a label. Nutrition:* a dish's nutrition per serving, worked out from its recipe and the ingredient catalogue (USDA FoodData Central, Livsmedelsverket, Open Food Facts) by the rollup the marketplace already uses (
ask-nutrition.ts): the eight lines of a nutrition declaration, how many of the recipe's ingredients the figures cover, the sources named, marked as an estimate. Nothing recorded = "no nutrition figures recorded". The setup page shows the AI switch, the credit left, the typical cost of one AI-read question (from the platform's own price list and markup) and a top-up link.- **"Ask about a dish": misspelled dish names are matched for free, and the nutrition panel has a salt
line (2026-10-10).** Owner: "is there any free ai service which can be used?" — the cheapest AI is the one not called.
matchQuestionnow also finds the ONE dish a question nearly names: a slip of the keyboard (edit distance: none on short words, one, two on long ones) or a Scandinavian noun ending ("har margaritan gluten" → Margherita). Two dishes equally near → nothing is picked and the guest gets the list. So the AI layer is left with what words cannot do (another language, a description). Nutrition: the American database states sodium, the Swedish one salt; where only sodium is known the salt line is sodium × 2.5, the equivalent a label defines. Seen on the developer stack on a dish with a recipe linked to three USDA rows (928 kcal a serving, 3 of 3 ingredients, source named).Changed
- X/Z report: Kassahändelser lists only what was keyed by hand (2026-10-11, till 1.0.5846). The block listed every row of the cash ledger, and the tills had names for three kinds — so a shift with a cash sale printed
sale_cash ×1 125,00,change_given,refund_cashas raw keys, restating the drawer row by row on an X. It is now an allowlist: Insättning, Uttag, Säkerhetstömning and cash tips (newly named "Dricks (kontant)"). Nothing left the ledger, the expected cash or the over/short; cash sales are still under Betalsätt and returns under Returer. NOTE: this reverses an earlier intent that change given should stay visible on the report — it is in the ledger, and on no report line. - Presentkort: the design row follows the finger and glides (2026-10-11, till 1.0.5845). Owner: "right now only one at a time it rotates which is good but give it more smooth effect … a continue effect, fast swipe and slow swipe". A swipe used to be a vote — past 48 px it stepped exactly one card, however fast or far, and nothing moved until the finger lifted. The row is now a position: it follows the pointer, keeps going at the speed it was released with and slows onto a card (a slow swipe lands on the nearest, a fast one travels several), and stretches a little past either end. At rest every card is exactly where the approved layout put it; the no-jump contract is unchanged and still pinned. One rule, twinned:
GiftCarouselMotion.kt↔gift-carousel-motion.ts. On the web till the slots no longer carry a CSS transition — the component paints them per frame, and a transition would smear the row behind the pointer. - X/Z report: a card used as payment is now "Presentkort (inlöst)" (2026-10-11, till 1.0.5845) — so it cannot be read as cards sold, which have their own block (see Fixed).
- Scan-to-rate follows Google's review rules (owner ruling 2026-10-10). The Google review link is
offered to EVERY guest who rates, on the same terms, whatever they answered. It used to go only to four and five stars while lower ratings were kept private — "selectively solicit[ing] positive reviews", which Google's Maps contribution policy lists under Rating Manipulation.
lib/reputation/route-rater.tscarries the rule and its tests; an unhappy guest is still offered the private follow-up as well. A venue with a live guest page gets this follow-up without thescan_routeplatform flag.- The cookie banner no longer covers
/tap/…(essential-only surface, like/t/and/scan/). - Super-admin → Help & Guides — a help centre of the admin's own, opened with the GreenGate guide set (2026-10-07).
/admin/dashboard/help(sidebar: new "Help" group, "Help & Guides"): a search box, category chips and a card per guide;/admin/dashboard/help/[slug]renders the article with the FAQ, a "Recommended next" and the related guides, and a button to Control Units. Both pages are SERVER components behind the admin layout's super-admin redirect, resolving the locale like the Control Units page and the admin login (platform_langcookie /?lang=→ en/sv). The articles are a module of their own,lib/docs/admin-articles.ts(ADMIN_DOC_CATEGORIES,getAdminArticleBySlug,searchAdminArticles…), NEVER mixed into the ownerDOC_ARTICLES(bundled into every owner page, ratcheted by coverage tests) — only theDocArticletypes are shared. The owner article page's section renderer moved VERBATIM tocomponents/docs/ArticleSections.tsxwith the class vocabulary as a parameter: the owner page passes nothing and renders exactly what it did; the admin pages pass gray-scale and accent classes the admin theme engine covers (vocabulary.test.tspasses). Eight guides, each in English and Swedish, category "Swedish control system (GreenGate)", written for the super-admin who holds the GreenGate contract: overview (the three gates, test vs production, what the till shows, no fallback), onboarding a customer step by step with a checklist, using the Control Units page (five sections, what each button sends and to whom, why the confirmations exist), adding or replacing a till, the Skatteverket hand-off and the manufacturing number, the reconciliation queue, the test tenant (the fourpnpm greengate:*commands, what the simulator proves), and troubleshooting refusals (the five codes, cause, fix, log line). Every fact checked against the runbooks and the code; what GreenGate has not answered (the manufacturing number's scope, whether a POS-ID must be registered in advance, the status vocabulary) is written as "not yet confirmed by GreenGate". No credential, org number, serial number or customer name in any guide — pinned by test. The Control Units header gained a "Guide" link to the overview. Tests:lib/docs/admin-articles.test.ts(unique slugs, links resolve, sv shape = en shape, no placeholder or secret-shaped text, only the four pnpm commands and the log command as code, the unconfirmed claims say so),components/docs/article-sections.test.ts(every section kind renders; the default vocabulary is the owner page's; a passed one replaces it everywhere),app/admin/dashboard/help/admin-help-pages.test.ts(server components behind the guard like Control Units; the index and every article render in en and sv with no tenant token).- Super-admin → Control Units (Kontrollenheter) — the GreenGate reseller back office, one page per tenant (2026-10-07).
/admin/dashboard/control-units(sidebar: Global Localization, beside POS Operating Mode): a tenant picker (tenants with Swedish registers, or any id typed for a first-time setup), a status strip (health badge, an unmissable TEST/PRODUCTION chip, "Check connection" that sends ONE RequestStatus to GreenGate and only when pressed — nothing is sent on page load — probing with a chosen register's kassabeteckning and showing the control system's serial number), the GreenGate account card (credential-free view; enter/edit form with write-only username + password, Digest default, test/production warning; a status switch with a confirm that says every Swedish sale is refused while disabled), the registers table (editable POS-ID cell on Swedish selling registers only, 1–16 Latin-1 chars, unique within the tenant checked against the other rows; a CHANGE or CLEAR is confirmed by retyping the value and names Skatteverket — the backend exposes nothing that says a kassabeteckning already has receipts, so every change is treated as one that matters), the Skatteverket hand-off block (org no, per register: name, kassabeteckning, serial number from the probe of THAT kassabeteckning or "run Check connection first"; one Copy button, plain text), and the reconciliation queue (oldest first, evidence on a deliberate second click, Resolve with the finding/evidence/note of the DTO and a confirm quoting the rule: never a control code, never a revived sale). BFF under/api/admin/control-units/*, super-admin guarded exactly like Markets;setByandcheckedByare stamped from the session. Words in English and Swedish (page-local namespace, locale resolved like the admin login). Backend: one read-only route for the tenant picker (GET /pos/admin/registers/tenants?country=) and one status-only write (PATCH /pos/admin/fiscal/greengate/account/status), both InternalGuard with specs.Fixed
- Printed till receipt: it said "Kort" for every sale (2026-10-11, server; released beside till 1.0.5846). The paper the server queues for a till sale never received the means of payment and defaulted to card, so a cash sale's kassakvitto read "Betalning: Kort · betald". It is now read from the sale's stored tender rows: one means prints the old line with the right word; a split, or a gift card that did not cover the bill, prints "Betalning · betald" and one row per means with its amount. A booking's prepaid deposit prints as "Extern betalning", not Kort. A paper whose means is not known prints no means at all. Card-only paper is byte-identical. New payload field
payments[]. - Table and parked bills got no original receipt (2026-10-11, server). Only a counter sale queued paper; a bill closed from a table or a parked bill did not, so on the native tills the guest's only paper was the print button's KOPIA, with a copy journalled for what was really the first print. Closing a bill now queues its original, as a counter sale always has — receipt only, never a second kitchen bong (
/api/internal/pos/print-receipt). Refunds likewise: the refund's original is queued when it is made. The print button afterwards prints a copy, which is now what it is. - Refund paper claimed "Kort · betald" (2026-10-11, server). It states how the money went back — "Återbetalt: Kontant", or a row per means.
- Dricks was never on the printed receipt (2026-10-11, server). The tip travelled to the printer and was not read. A tipped sale's paper now has its own "Dricks" row under the total — outside the total and the VAT table — and the payment row shows what the card was actually charged. An untipped sale's paper is byte-identical.
- Presentkort: no payment into a closed shift (2026-10-11, till 1.0.5846). A card payment charges the terminal first and issues the gift card after; a shift closed from another till mid-session meant a charged card and a refused gift card.
Till betalningnow asks for the open shift before any payment starts and says "Öppna ett pass innan du säljer presentkort." A held attempt being resumed is not asked, and if the question cannot be asked the server remains the authority. Both tills. - X/Z report: gift cards sold were on no report (2026-10-11, till 1.0.5845). The owner sold two Presentkort and could not find them. A card paid in cash hid among the pay-ins as an unnamed "Insättning"; one paid by card or Swish was on no report at all, so a card terminal's day total could not be reconciled against the Z. Skatteverket's line is that a sold gift card shows on the dagrapport as its own item, outside the day's sales. The report now carries Sålda presentkort (ej försäljning) — how many, paid how, in total — kept out of the sales figure, the VAT table, the tender totals and everything the Z seal hashes, so no sealed figure moved; the gift cash stays in the drawer count and is no longer also printed as a pay-in. Every card sale now needs an open shift (it was only required for cash), so none can land on no report. NOT settled by anything we could read, and for an accountant or Skatteverket: whether the sale must also be a signed kassakvitto, and whether a venue with a single VAT rate is issuing single-purpose vouchers (VAT due at sale).
- Presentkort on the tablet: `Till betalning` jumped under the finger (2026-10-11, till 1.0.5845). With no amount chosen, pressing it inserted a red card above the button; the rail had less slack than that card is tall, so the button and
Rensamoved down and the note left the card. The reason now takes the note's own slot under the buttons, which is the same height whichever sentence it holds. - Web till: the Presentkort screen was Swedish only (2026-10-11). About twenty sentences — form errors, the card-declined and terminal-timeout texts, statuses, the gift-mail lines, the carousel's labels — were hard-coded. They come from the four-language table now, in the native tills' words.
- A till added on the device page showed its OWN name where the venue's belongs (2026-10-11). The owner's tablet drew "Kassa 2" on the face of a Presentkort. Only the signup flow ever stamped
venueNameon a register; one created on the dashboard's device page has none, and a till with nothing better printed its own name./pos/registers/pairand/pos/registers/menow answer an absent or blank venue name with the name the venue typed in its own settings — the one at the top of its receipts (merchant.name); a stamped name is kept, and with neither known the field stays absent. Server-side only: both tills already read the field, and a paired till picks it up at its next start. This closes the "still open" note in the 1.0.5843 card-face entry below. - Presentkort: a gift card sold at the till was in no history at all (2026-10-11, till 1.0.5843). Owner: "i sold a presentkort but i dont see it in any history". A card sale is a voucher issue — a card, a ledger row and a drawer pay-in, never a ticket — and the till's Historik read tickets and online orders only, so the money was in the drawer and on the Z-report and the sale was nowhere a cashier could look it up.
GET /pos/kitchen/orders/historynow merges a THIRD journal: cards sold at a till (source: 'giftcard'), under the same window, cap and search as the other two. The row is called by the MASKED code (********WXYZ, never the code), carries the face value, how it was paid, which register sold it and the recipient's name; it answers the Slutförd / Betald / Direkt chips and no others. Search finds a card by name, by the whole code, or by any part of the four characters the row shows — and by nothing longer, so the box can never be used to walk a code out. Only till sales: a dashboard or web issue is not a counter sale. New indextenant_createdAtonpos_giftcards. On the tills (Android, Windows, web; sv/en/no/da) the row wears a PRESENTKORT badge, its sheet is titled "Presentkort …" rather than "Order …", and it offers no reprint / e-mail receipt (there is no ticket behind it). Works on an older till too — the row simply has no badge there. - Historik: "Betalt med cash" (2026-10-11, till 1.0.5843). The order sheet printed the tender's wire word. It now says Kontant / Kort / Swish / Presentkort in the till's language, on every row.
- Presentkort: the card face on the native tills printed the TILL's name (2026-10-11, till 1.0.5843). "Kassa 1" on a card handed to a guest. Android and Windows now use the venue's name first, exactly as the web till already did. Still open: a register created on the dashboard's device page carries no venue name, so its card face falls back to the till's name until the server supplies one.
- Dricks: a freshly paired till did not ask for a tip until it was restarted (2026-10-10). The same
gap the buzzer had, present since tips shipped on 2026-09-06:
/pos/registers/mecarried the tip block and/pos/registers/pairdid not, and a till builds its first session from the pairing answer. A venue that takes tips went unasked for every guest served before the first restart of a new till. The pairing answer carries the block now (tip-on-register-wire.spec.ts). Server-side only; nothing about whether or how a tip is asked for changed — an unreadable policy still answers "do not ask".- Payment page (`/register/payment`): it says what THIS plan contains, in the owner's language — and an ended trial can be paid for (2026-10-07).
Owner, on an expired Inventory Advanced trial: "on screen the renewal showing wrong information about package". The page printed four website bullets ("Digital menu on your own website · Website builder + QR menus · Table reservations · Food Bazar listing · Experience Studio") under every plan's name, and "Activate your account — One last step" in English to an owner whose trial had ended. The list is now derived from the plan itself (
lib/billing/plan-contents.ts: included features → the side menu's own rows, trade-filtered, translated with the menu's table), the heading follows the account's standing (signup · trial · ended trial · overdue · lapsed · paid), and the wording lives in the marketing dictionary'sauth.paymentnamespace (en/sv/no/da); the page speaks the owner's dashboard language. The price was already right (flat plans are billed yearly). Also fixed, found on the way:POST /api/billing/signup-paymentanswered "already active" for a TRIALING subscription and took no money — for a lapsed trial the dashboard sent the owner straight back, a closed loop, so the account could never be paid for. Only a paid period is "already active" now (lib/billing/signup-payment-step.ts). With a trial payable: a Stripe payment returns to the payment page and is confirmed there before the dashboard opens (no second "Pay" on a late webhook, no endless spinner); a production server without a Stripe key refuses to simulate a trial into a paid year; the Stripe webhook activates a plan only when the money has arrived; an older paid Checkout session is not applied twice; and "activate the website package" (domain settings) leads to the payment page only when the plan has the website builder. Operations note: production needsSTRIPE_SECRET_KEYandSTRIPE_WEBHOOK_SECRETbefore a customer's trial ends — without the key the page now says payments are not set up instead of activating.- Inventory & Suppliers: "back" links and words that pointed at the wrong page (2026-10-04).
Waste, Stocktake, Storage places, Labels and Equipment each carried a back link to Receiving, left over from when they were buttons on that page (they became hub tiles on 2026-09-27). Receiving needs Purchasing as well as Inventory Management, so on a stock-only plan the link led to a page the reader cannot open. They now go back to the Inventory & Suppliers hub, named as the sidebar and the breadcrumb name it in all 20 languages (
components/dashboard/inventory/InventoryHubLink.tsx). The Receiving upgrade prompt said "Part of Inventory Management" / "Included in the Inventory Management add-on", while every Receiving route requires Purchasing too. The text now says it needs both (en, sv, da, no). The gate is unchanged. Three sentences that send the reader to another page now use that page's own name in sv, da and no: the Pick out hint (Flytt mellan platser, was "platsförflyttningar"), the Stocktake hint (Lagerplatser, was "Förvaringsplatser") and the grocery note on Sites (Lagerflytt, was "Överföringar").- The super-admin Customer page stopped failing on half of its loads (2026-09-19).
GET /api/admin/tenants/[tenantId]answered an EMPTY 500 — zero-length body, nocontent-type— on roughly half of all requests, for every tenant, and faster than it answered successfully. Cause: the page's audit trail asks for 24 months, andreadAuditSegmentsturned that reach into ONEPromise.all— 24 simultaneous reads ofmaster-audit-logs:YYYY-MMat the store. The backend's global throttler isThrottlerModule.forRoot([{ ttl: 1, limit: 20 }])and @nestjs/throttler v6 countsttlin MILLISECONDS, so the budget is 20 per tracker per millisecond — and every internal UI→backend read shares one tracker (the ui container's address). Reads 21–24 came back 429, the adapter threw by contract, nothing caught it, and Next answered with no body. Whether 24 sockets landed inside the same millisecond was a coin flip, which is exactly the ~50/50 pattern that was observed. Three things changed: segment reads now go out in batches ofAUDIT_SEGMENT_READ_CONCURRENCY(6, the reach every other audit reader already uses) so a long window costs round trips and never a wider burst; a 429 — the one status that means "ask again" — is retried twice, andStoreUnavailableErrornow carriesstatusso that is readable without parsing prose; and the Customer page DEGRADES instead of dying, serving every other card with anauditErrorsentence on the trail card rather than an empty list that would falsely claim the customer has no history. This page is the only door to a module trial, so losing it means no module can be opened at all. The throttler'sttl: 1was deliberately left alone: "correcting" it to 1000 would turn the budget into 20 requests per SECOND for the whole ui container.- An admin route that throws now says so (2026-09-19). New
lib/api/route-error.ts
(
failLoudly): logs the stack with context server-side and answers the standard{ error, code: 'route_error' }envelope with a real message. The Customer page's GET is wrapped in it, and the page itself now reads a failed response instead of parsing it as a success — the empty 500 used to makeres.json()throw in the browser, leaving the spinner turning forever with no word of why.Added
- Help guides for Inventory & Suppliers, in English and Swedish (2026-10-04). Five linked guides
in a new help-centre category "Inventory & Suppliers" / "Lager & leverantörer": getting started, ordering and receiving, pick-out / prep production / waste, stocktake and storage places, and several sites with transfers (
lib/docs/inventory-articles.ts). Help guides were English only. A guide can now carrytranslations(lib/docs/articles.ts:localizeArticle,localizeCategory, andsearchArticles(query, lang)), which the guides list, the article page and the header's help search use. A reader in a language without a translation gets the English guide, as before. The 32 older guides are untouched and still English.inventory-articles.test.tschecks that every name a guide puts in quotes is a label in the dashboard dictionaries for that language, so a renamed button fails the suite until the guide follows. No new dictionary keys.- A breadcrumb on the Lager & leverantörer pages (2026-10-04). Product Owner's request: inventory had
no breadcrumb and the only way back to the hub was the sidebar. Every page opened from the Inventory & Suppliers hub now shows
Hub › Pageabove it (plus a last crumb for a tab or a supplier), and on a phone one 44 px link back to the parent. Built once: the trail is derived from the nav catalog (lib/dashboard/breadcrumb-trail.ts), rendered by the dashboard layout (components/dashboard/DashboardBreadcrumb.tsx), and hidden wherever the plan or the trade hides the hub or the page. Another area adopts it by adding its hub href toBREADCRUMB_HUBS; a page names a view the URL does not show withuseBreadcrumbSubView(label, onBack?). No new strings, no route changes.- Change or remove lines on a live online order, paid or not (2026-09-07). Owner: *"someone calls and
says he ordered a pizza but wants a burger instead, or he ordered two pizzas and one burger and paid, but wanted just one pizza."* The till's order door has an
edit_linesverb (lib/orders/order-line-edit): remove and add in one call, priced from the venue's menu; an unpaid order's total moves; a paid order that shrinks is refunded the difference (drawer cash or a credit-note gift card for a counter charge, the payments layer for an online hold, stamped "manual" without one); a paid order that grows owes abalanceDuethe counter's paid press collects. A started line is refused, or allowed with a manager's PIN under the new Setup Center › Ordering & Delivery choice. The kitchen card shows the struck line and the new one. Every till shows "Ändra artiklar" beside "Lägg till artiklar".- "Clear log" on Recent print jobs (2026-09-06). Owner: "can you empty Recent print jobs."
DELETE /api/tenant/[id]/hardware/jobs(settings permission) empties the diagnostic job log — the receipts live on the sales, so nothing bookkeeping-shaped goes — and the Printers panel shows a Clear log button beside the heading, with a confirm.Changed
- All scheduled jobs are off on every platform (2026-10-05). Product Owner: keep every cron off until
the strategy for running them is reworked.
vercel.jsonnow declares no crons (it listed all 73, which a Vercel deployment would have kept firing), andcron-registry.test.tsfails if one is added there. Cloud Scheduler creates only the jobs named inenabled_cron_jobs(terraform/envs/*.tfvars), which is empty. The routes and thePLATFORM_CRON_JOBSregistry are unchanged.- restora360.com redesign, round 2 — /products, /demo, barber + wellness (2026-09-09). Owner, on
round 1: "looks good - keep going."
/productsis rebuilt from the prototype's products.html: seven core product cards (POS, website, appointments, inventory, staff, digital signage, reports & insights), the four restaurant-specific modules (online ordering, table reservations, kitchen display, express checkout) and the "how should I choose" guide; the Produkter menu and the footer now deep-link/products#<id>, and the Phase-75/products→/servicesredirect is gone (/products/posstays)./demostops 308-ing to/tryand becomes the seven-step "Boka en demo" flow — business, type, stage, needs, company, contact, time — wired to a real backend where the prototype ended in localStorage:POST /api/public/demo-requestvalidates withDemoRequestSchema, stores the request as codes inMasterDB.demoRequests[](a Norwegian request reads like a Swedish one), mails the team best-effort, is rate-limited and refuses disposable addresses; the form renders every refusal from its own dict./industries/barberand/industries/wellnessjoin the industry template as beauty sub-trades with a four-level breadcrumb (home › industries › beauty › the sub-trade) and an "also built for" cross-link. All copy ships in en/sv/no/da undersite-dict-parity.test.ts; the sitemap and/llms.txtlist the new pages.- restora360.com redesign — header, landing page, industry pages (2026-09-08). Owner, with a
screenshot and an HTML prototype: "navbar is too big … it can be a decent size" and "the html is not exactly the premium style … text is too small to read, it should be clear and premium." The public site is rebuilt to that design with larger, clearer type: a floating pill header with a Produkter mega-menu (the eleven products, a promo aside), a Branscher menu (three photo rows), search and a four-language switch (SV · EN · NO · DA); the landing page in the owner's composition — hero with the industry chooser, product features, the guest-to-kitchen flow, product cards, industries, pricing without prices, support, FAQ, closing CTA; and new pages
/industriesplus/industries/{restaurant,retail,beauty}(restaurant = the full 360° platform, retail and beauty = a curated core). Every visible string comes from the dict in en/sv/no/da —site-dict-parity.test.tsfails the suite when sv, no or da lags behind English; the one exception, documented in TODO.md, is the header search's keyword fallback (lib/marketing/intent-search.ts), whose result labels and descriptions are still English on every locale. Swedish is the site default. SEO/AEO/GEO: per-locale metadata + hreflang (Swedish on the bare URL, the others?lang=), Organization/WebSite/FAQPage/SoftwareApplication/Breadcrumb JSON-LD,/llms.txtand the Organization description repositioned to one POS platform for three industries, and a new OG image.- The kitchen bong on a Zebra is bigger, at its natural width, with room at the tear (2026-09-07).
Owner: "can we do the bong a bit bigger and clear — plus add some extra space on top — the label is 80 mm." The ZPL encoder gave the bong the receipt grid's face: font 0 squeezed to 60 % of its width and wrapped on a budget twice the real advance, so the text used a third of the roll and "Gluten, Mjölk" broke over two lines. The kitchen ticket now has its own typography: the face at its natural proportion, sized ~43 % taller (4.3 mm capitals on 80 mm/300 dpi), wrapped by measured glyph widths (a per-glyph table of font 0), rules drawn as one thin bar, 12 mm above the first line and 5 mm under the last. Receipts and labels are byte-identical. Needs a box deploy to reach the printer.
- The last eight sidebar dropdowns are tile hubs (2026-09-07). Owner: *"there were some nav actions
left — can we fix all of them so the others get the card grid design."* Bokningar → Reservation Center, Lokalinställningar → Venue Center, Gästtjänster → Guest Center, Kedjor & grupper → Chain Center, Food Bazar → Food Bazar Center, Abonnemang & funktioner → Plan Center, Installationscenter → Setup Center and Plattformsadmin → Admin Center: each group is one flat sidebar link under its own label, and every page the dropdown held is a tile four to a row with its old href, label, feature gate and trade gate (the restaurant-only groups carry the trade onto each tile, so search hides them from a salon too). The sectioned settings page is the Setup Center's "Inställningar" tile; Sound & Atmosphere still opens in a new tab; the Admin Center sends a non-superadmin back to Home like the Admin Hub does. Labels ×20 languages, blurbs en + sv/no/da. Enheter keeps its two rows (Köksrouting was promoted back on purpose).
- "Så får köket veta" with a help panel (2026-09-06). Owner, with a screenshot: *"compact and too
hard to understand … maybe add the help text on a right side panel to keep the surface clean."* The kitchen-delivery card is now two columns: the controls on the left — four big quick-pick tiles (Papper · Skärm · Båda · Ingen), two plain switches, the per-till read-back chips and the exceptions table with channel names only — and every explanatory sentence in one "Så fungerar det" panel on the right (what it controls, what a quick pick does, the two switches, each channel's meaning, where the till-side switches live, the paper-has-no-delay note). Same fields, same writes.
- Köksrouting in the tile pattern (2026-09-06). Owner: *"very messy today — I get confused how to
use it, too much text and not so easy to understand."* The page is now a header with ONE live status line (the health verdict) and six tiles with a one-line blurb and a live count — Så får köket veta · Stationer · Kategorier · Varor · Hälsokoll · Logg — and the chosen section's panel opens right under the tiles. The editors are the same code; the explanatory paragraphs the blurbs replaced are gone.
- Marknadsföring and Experience Studio open in the same tab (2026-09-06). Owner's report: the new
tab read as the link not working.
newTab: falsenow opts a non-/dashboard workspace into the same tab; the Marketing Hub's forced new tab is removed.Added
- Five more sidebar groups are single links to tile hubs (2026-09-06). Owner: *"take all one at a
time — Kunder, Ekonomi, Restora Profit, Pågående service and Lager & leverantörer."* Same recipe as Meny and Personal: each group is now one flat link — Customer Center
/dashboard/customer-center, Money Center/dashboard/money-center, Profit Center/dashboard/profit-center, Service Center/dashboard/service-center, Inventory Center/dashboard/inventory-center— and every page the dropdown held is a hub-linked tile with its old href, label, feature gate and trade gate (the grocery-only Lager rows stay grocery-only; thegrocery-nav-isolationguard now counts a hub tile as a door). Labels in all 20 languages, blurbs in en + sv/no/da.Fixed
- The website's styling vanished after going back, until you refreshed (2026-09-09). Owner:
"when I go to some link and go back the website doesn't load correctly, I have to refresh." Next bundles CSS per route, so
website-base.cssis copied into the landing chunk AND the industries chunk; whichever chunk loaded last sat last in the document, and its copy of the universal reset (.r360-site * { margin: 0; padding: 0 }) then beat every same-specificity page rule on source order alone — the landing page came back with all its spacing zeroed, and an industry page did the same the other way round. The whole base sheet now lives in@layer ws-base, so an unlayered page sheet wins regardless of chunk order or specificity: the relationship the two sheets were always meant to have. Reproduced and re-checked with Playwright in both directions, history-back and<Link>-back.- "Open 24 hours" never reached the website (2026-09-07). Owner, from a tester: *"to change the
opening hours to 24h on all days it does in admin but the real website does not change to 24h … when he manually adds 09:00 – 23:00 and copies that to all days it does work."* The editor only flips the day's
is24Hoursflag and leaves its old open/close strings in place, and the storefront dropped that flag on the way to its hours blocks — so the site kept printing the stale range. The opening-hours block, the contact card (both variants, plus its open/closed pill), the premium templates' hours lines and{{hours.weekly}}, llms.txt and the Google Business export now all say "Open 24 hours" for such a day. Verified by rendering the blocks with a 24h week.- Booking picker said "Booked" on slots nobody had booked (2026-09-07). Owner: *"under booking
table the time slots show 'booked' but there was no booking."* Two causes. The public book route served every venue a hard-coded 17:00–22:30 evening, ignoring the venue's opening hours (so the 24h change above never reached the picker either); and the picker greyed every arrival whose stay would run past that close with the word BOOKED — with the standard two-hour stay, 21:00 / 21:30 / 22:00 read "Booked" on an empty day. The route now serves the venue's own window for the date (the same
openingWindowForfact the tills read: hours, 24h days, special days, closures; a venue with no hours stored keeps the old evening), a slot that cannot fit the stay says "Past closing", only a booking on that table says "Booked", a closed day says so instead of drawing a grid, and the POST refuses a time outside the window with the same rule. A real venue without a floor plan no longer inherits the sample room's fake bookings either.- The booking picker's no-show deposit step was dark (2026-09-07). Found beside the fix above,
fixed on the owner's go. The route had served the venue's deposit since Phase 37 and the picker had the consent step drawn and gated, but the merge between them never copied the field — so a venue with a deposit switched on took web bookings without the guest seeing the amount or agreeing to it, while the server still stamped the reservation with a pending deposit for staff to collect. The merge is now a pure, tested helper (
picker-book-data.ts) that carries the deposit through; the route resolves the deposit currency the way the simple form's config does (owner choice → country → platform default) instead of a hard-coded SEK; and the figures print ISO-coded ("200,00 SEK"). Driven in Chromium: the step shows, Confirm stays disabled until the box is ticked.- Personal & behörigheter crashed for a tenant with an old staff member (2026-09-06). Owner:
"the first grid box link /dashboard/staff is not working, others are working." The member list did
createdAt.split("T")on a row written before that field existed, and one such row took the whole page into the error boundary. It now renders "—" for a missing date.- Meny and Personal are single sidebar links to tile hubs (2026-09-06). Owner: *"when i click on
menu instead of collapsing the nav it can have only a menu link, and the page should be similar to our grid boxes design … then Personal — it is very big but i want the same layout, 4 tiles per row."* The
menugroup (restaurant) and theteamgroup are now one item each — Menu Center/dashboard/menu-centerand Team Center/dashboard/team-center, rendered flat under the group's own label (Meny / Personal) — and their pages moved toHUB_LINKED_ITEMSwith the hub page ashubSource(same hrefs, labels, feature and trade gates; every old row name stays searchable as a keyword). The hubs use the Enheter tile family four to a row: Meny · Kombos · Lunch & buffé · Produktionsstationer · Importera meny · Näringsdeklarationer, and the eleven team pages in their old order (Rekrytering restaurant-only). Blurbs live in the newhubsdict namespace (en + sv/no/da); "Menu Center" / "Team Center" labels added in all 20 languages. The menu editor keeps/dashboard/menu(26 deep links point at it). A grocer or a salon keeps its direct "Tjänster & produkter" link — a one-page catalogue needs no hub. Newcomponents/dashboard/HubTiles.tsx.- Enheter › Förare: "Lägg till förare" right in the grid (2026-09-06). Owner: *"a new card Lägg
till förare, then that card changes to the dispatch function so it becomes super easy to add a new förare or dispatch right from the grid card; then Förarlistan i Dispatch, then Så ansluter du."* The first tile is the Dispatch roster's add form (name · phone · vehicle, Swedish vehicle labels); on Add it creates the driver through the same
/dispatch/driverscall Dispatch uses and immediately mints the phone code (/dispatch/pairing-code), and the tile becomes that driver's pairing card — the big single-use code, the QR, the/driver?pair=link with copy, a 15-minute countdown, "Lägg till en till" and "Öppna i Dispatch". Without the Own Delivery & Dispatch add-on the tile says so and links to Your Features. Order is now: Lägg till förare · Förarlistan i Dispatch · Så ansluter du; the old "Var koden hämtas" explainer folded into the tile. Newcomponents/dashboard/devices/AddDriverTile.tsx.- A till can be removed, not only spärrad (2026-09-06). Owner, on the new Enheter tiles: *"we can
spärra other kassa today but we cannot remove that fully" — nine spärrade QA tills and an old Kassa 1 with no way out of the list. The ⋮ menu on a spärrad or never-paired till now offers Ta bort…*; a paired till still only offers Spärra (removing a till that can sell is an outage). The backend decides what removal means (
POST /pos/admin/registers/remove): a register that never reserved a receipt number is deleted outright with its pairing codes; one that did is hidden from the fleet (revoked underneath,removedAt/Bystamped) and itsseriesPrefixstays taken for good, because a new till continuing S001-T03 would collide with receipt numbers already on paper. The confirm dialog says which will happen (hasReceiptson the register view). The series allocator reads the list withincludeRemoved=1so a hidden series is never handed out again.Changed
- Enheter: the newest till comes first (2026-09-06). Owner: with 20+ tills the one just added
must not be lost at the end of the grid. Device tiles sort by
createdAt, newest first.- Enheter is now two levels of tiles, in the Home page's style (2026-09-06). Owner, right after
locking Home: "same UI/UX style for Enheter — today it is confusing. From kassa to network 4 tiles in a row and 4 in the next; under Kassa the same grid: one for Öppna/Parkopplingskod, one for how to connect, one for Kassapersonal…" Level 1 (
/dashboard/devices) is one tile per category — Kassor, Kök, Självservice, Displayer, Förare, Betalning, Hårdvara, Nätverk, trade-filtered as before — with the category's count and status line; the tab strip is gone. Level 2 (?tab=posetc., the same deep links) is a grid: row one the tools (Lägg till enhet with its name field, then the category's helpers — Kassapersonal, Så ansluter du, Äldre Restora-surfplatta for Kassor — and link tiles such as Köksrouting), then ONE TILE PER DEVICE with its status, kitchen line, Funktioner, Öppna, Parkopplingskod and the ⋮ menu; a minted code shows inside the device's tile. A helper tile opens its existing panel full-width directly under the tools row, above the device tiles (the guide, the staff card, the terminal/printer/kiosk fleets, the reliability report — none rewritten).?connect=1still opens the Anslut enhet menu; the${type}-headeranchors stay. Behöver åtgärd / Senast använda render on level 1 only when something actually needs attention. Newcomponents/dashboard/devices/DeviceTiles.tsx; the fleet panel's row internals (status chip, ⋮ menu, live code card, kitchen note) are exported and reused.- Home is now a quick-actions page, for every trade (2026-09-06). Owner's brief with a
reference image: the admin landing page should put the functions in front of the owner "so the user does not need to scroll or search".
/dashboardis now one small line naming the business type ("Restaurang", "Livsmedelsbutik", "Klinik" —lib/verticals/business-type-labels.ts, sv/no/da; the welcome + tenant-name heading was dropped the same day as redundant with the sidebar's workspace switcher) and a 3×2 grid of big cards — Inställningar, the catalogue, Enheter (live "N kassor · N enheter aktiva" + "Lägg till ny"), Erbjudanden ("Skapa banner" / "Lägg till rabatt"), the trade's daily desk, Webbplats — and a help line. The reference's separate PoS card was folded into Enheter the same day (owner: they were the same door — the POS-registers page lives inside Devices). Which cards and which doors is decided per trade inlib/dashboard/home-quick-actions.ts(the catalogue card opens/dashboard/menufor a restaurant and/dashboard/catalogfor a shop or a salon; the daily desk is Beställningar/dashboard/order-deskfor a restaurant, Lager/dashboard/stockfor a grocer, Bokningar/dashboard/bookingsfor a salon or clinic; a salon's devices line has no KDS), gated by the sidebar's own entitlement + trade axes so a card can never show a door the sidebar hides. Counts are read server-side (registers from the backend, the fleet through the newlib/devices/active-fleet.ts, which the devices summary route now shares) and degrade to no status line, never a confident zero. Three deep links were added so the buttons land where they say:/dashboard/devices?connect=1opens the Anslut enhet menu,/dashboard/offers?new=1opens the editor on a blank offer,/dashboard/site?panel=add&q=banneropens the website editor's Add palette filtered to banners. The KPI row, Operations Center, starred-shortcuts row and onboarding checklist no longer render on Home (their components remain; Orders / Intelligence / the sidebar keep their doors). Strings in en + sv/no/da (overview.qa*), fully translated — the dict ratchet is unchanged.Fixed
- Themes now adjust to the tenant's trade, and two theme-seeded storefront defects (2026-09-05).
Reported from
ten_beauty-salon: applying a theme put a RESTAURANT hero on a salon's landing page, the contact page showed two "Send us a message" forms (one unthemed and posting nowhere), and Opening hours rendered a 14-row double week. (1) Trade-aware imagery: every restaurant stock photo the generic packs ship now has a verified services/grocery counterpart (src/lib/themes/vertical-images.ts); the swap applies at install AND preview (installer.ts splitPages), on the gallery cards (toCatalogCard), and at render time for sites that installed before the fix (PageBlocksheals known stock ids via the tenant's declared vertical — an owner's own images are never touched). Uploaded.r360theme.zipmanifests can now declareverticalsso a restaurant theme is never offered to a salon (schema + docs). (2) The generic packs' contact page no longer seeds a standaloneformblock next tocontact-info(whose embedded form is the real, wired one); the live renderer also de-dupes an action-lessformon already-installed sites. (3) The double week: the/onboardingwizard wrotemon…sunand/welcome/onboardingwroteMonday…Sundaynext to the template'smonday…sunday, and the settings PATCH shallow-merged them all together — both wizards now write the canonical keys, the PATCH replacesopeningHourswholesale (self-healing on the next hours save), andgroupWeeklyHours/foldWeeklyHoursfold abbreviated + sv/no/da day keys into the canonical week (exact-keyed entries win a conflict).Added
- Presentkort redesign on the tills, with the website's 26 designs (2026-08-29). The POS
Presentkort screen is one premium two-column view (belopp → design → leverans; saldo → beställning → betalning) on the web, Android and Windows tills. The card designs are the website's own catalog (
src/lib/gift-cards/greeting-templates.ts), reached through TWO GENERATED artifacts (pos/web/src/data/gift-catalog.generated.ts,pos/app/.../data/GiftCatalog.generated.kt) emitted bynpm run generate:pos-gift-catalogand gated bysrc/lib/gift-cards/pos-catalog-parity.test.ts, which regenerates them in memory and fails on any byte of drift — count, ids, order, copy or token mapping. Till-sold cards with an email recipient are now delivered: the device issue carries buyer/recipient/greeting/ delivery, and a new cron,/api/cron/gift-pos-delivery(every 5 min), claims due cards from the POS store throughPOST /pos/admin/giftcards/claim-deliveriesand settles them throughPOST /pos/admin/giftcards/delivery-result, sending the same gift email/QR the website sends. Presentkort is never offered as payment for a Presentkort (the tills' issue-method model has no such kind; the server'sGiftIssueTenderDtorefuses it).engine.tsno longer embeds a full gift code in the one error message that did.- A shop can author its own assortment from a desk (2026-08-28).
/dashboard/catalog
(Sortiment) is now in the grocery vertical's navigation and shows the whole grocery half of a product: barcode, PLU, VAT category, pant (amount +
SE-PANT-CAN/SE-PANT-PETscheme), stock flag, age gate and weigh pricing (price/kg, tare, minimum net). Until now the ONLY door that could author any of them was the till's own quick-create, reached off a SCAN of a code the catalogue did not yet hold — so a shop could not put a product into its own assortment without standing at the counter with the packet in hand. The Menu page is deliberately not that door:translateLegacyMenurebuildsvariationswholesale from the tenant blob and emits none of those fields, so a grocery product authored there loses its pant at the next translate. The fields sit in a collapsible "Butiksuppgifter" section per variation, grouped by the question each answers (Identifiering / Pant / Lager & regler / Vikt) and open by default on any variation that already carries one — so a salon's form is unchanged and a shop's is one click from complete. Offered on any non-SERVICE product in EITHER trade, because a salon that sells shampoo has a barcode too. Header, empty state, add-panel title/placeholder and the type toggle fork onuseTenant().vertical(*Retailkeys, the same mechanismoverviewalready uses), authored in sv/no/da so the dict-coverage ratchet holds.- `weighPricing` on the admin projection. The last grocery field
listItemsForAdminwas
missing: added with the others on 2026-08-25 and left off, so a scale product was priced by the till and readable by nobody. The projection's declared type also gained
uom,priceMinor,currencyandtaxCategoryId, which the mapping had always emitted while the signature denied them.- `null` erases.
barcode,sku,deposit,weighPricingandminBuyerAgeacceptnull
through the proxy and the DTO, and
upsertItemDELETES the key rather than storing one. Absent-means-keep is what makes the merge safe and is also why an optional field without an explicit erase is write-once from every door but the database. (plukeeps its own contract, where the null is a real stored value.)Changed
- `taxCategoryId` is the taxonomy, not a string.
TAX_CATEGORY_IDSis now declared once in
@restora/domain-typesbeside theTAX_RULEStable keyed by it, withTaxCategoryIdderived from it.VariationDtovalidates@IsIn(TAX_CATEGORY_IDS)(it was@IsString() @Length(1,64)— any string) and the proxyz.enum(TAX_CATEGORY_IDS). Four hand-written copies of the same five words now derive from it:ENGINE_TAX_CATEGORIES, the service-offers DTO, and the kernel's corpus and fuzz generators. Before this a typo ('standrad') created a product that SAVED, synced to every till and then could not be priced — surfacing only at the counter as "Motorn prissatte inte notan.", with a queue behind it.Fixed
- A refused barcode no longer lands on the document.
upsertBarcodehas always refused a code
another item owns (409
BARCODE_TAKEN), but it ran AFTERupsertItem(doc)— so the losing product kept the conflicting code while the registry still pointed at the rightful owner. Invisible while nothing displayed a barcode; the moment the editor showed one it became a duplicate the owner could see, was told nothing about, and could not save away. The check now runs before the write, matchingcreateItemFromTill, which has always refused up front. The editor renders the conflict in the owner's language, naming the code.- Grocery wording leaks on both till kits.
overviewBillsUnattached("N utan bord") is
suppressed on a retail till — nothing on a counter attaches to a table, so it fired for every open purchase — and the Systemstatus Bongutskrift row is gated on
showKitchenrather than ungated, so it no longer sits green and permanent on a venue with no kitchen. Retail twins added fororderMenuLabel,orderMenuSubtitle,sectionOrderLabel,internalNoteHint,discountAppliedHeading,emptyTicketHeldBill, thereleaseBill*family andoutsideServingHours, in all four locales on both kits. The release verb is släppa, never "lämna köpet öppet": öppet köp is a Swedish consumer's right of return, and a button offering to leave a köp öppet reads as granting one. Every restaurant string is pinned byte-unchanged.- Bokningshistorik on the tills (2026-08-27). New operator-gated device endpoint
GET /pos/registers/bookings/history(NestJS proxy →api/internal/pos/booking-history): the whole reservation book — every date, all four stored statuses and the no-show flag — searched, filtered (date range / status / source / table), sorted and keyset-paged in one ask per page. SameposBookingRowprojection as the day list (no email, no cancel token), and deliberately NO money on the wire: the store holds no durable booking↔nota link, so the history renders an honest em dash rather than a guessed total. The operational "Alla" board stays today-and-forward exactly as 1.0.5746 shipped it.- `startAt` on every till booking row. The scheduled start as a venue-clock instant,
computed by the same evaluator as
checkInEligibleAt(bookingStartAtMsincheck-in-window.ts; eligibleAt = startAt − the window, by construction). It is what the tills' derivedFörsenad(late) presentation turns on — presentation only, never written back to the store.Changed
- Bokningssheet remake — the premium reservation popup (2026-08-28, 1.0.5750). The
till's reservation details dialog, remade the same way in BOTH kits (pos/web
BookingSheet.tsx↔ ComposeBordScreen.kt): 640-wide record dialog; a p5 state chip in the body wearing the board pill's exact colour families (Sittande/Incheckad/ Försenad/Kommande, neutral for closed answers) beside the quiet facts line (reference · källa · VIP · occasion); the three tabs on the kit's own segmented pill strip; the tab body, Ankomststatus, Bordsättning and Intern not each a bordered card; Avboka down from the 64-px coral bar to a default-size danger button (the two-step stays the protection); the "Syns bara för personalen" audience line VISIBLE under the note instead of a vanishing placeholder; and — the owner's named requirement — the guest's NAME leads Gästinformation whenever the booking carries one (customerNameis the only guest identity on the wire; phone stays off tills, no email rides the read projection). No handler, gate, window rule, confirm step or wire contract changed; the sheet's pinned source contracts (early-door gate, deposit seam, one-flip timer) hold verbatim. NewBookingSheetRenderTestrenders the Compose sheet to PNG and pins the chip + guest-name anatomy; the pos/web preview harness's?locale=was reconnected (it silently stopped switching at the 2026-08-17 locale split).- Enheter subpage cleanup — add/remove without scrolling (2026-08-28, v2). The register
tabs' vertical sprawl is gone: the pairing widget is ONE wrapping row, the create control moved from below the fleet to a top action bar ("+ Lägg till enhet", visible without scrolling), and rename/spärra left their unlabeled icon buttons for a labeled ⋮ row menu ("Byt namn" / "Spärra enhet…", confirm dialogs unchanged). The healthy kitchen sentence and the Funktioner disclosure share one quiet meta line per row (warnings keep their own prominent line); Kassapersonal and Äldre Restora-surfplatta are collapsed single rows — the staff header keeps the person count and the "Inga varor på kassan" warning visible while collapsed. Betalning renders TerminalsPanel (with its Add a device form) before the binding/sandbox cards. No handler, endpoint or dialog changed.
- Enheter page visual remake (2026-08-28).
/dashboard/devicesnow matches the supplied
reference design: header with the live online/offline/ej-parkopplade roll-up and one "+ Anslut enhet" entry point (a menu over the existing per-type pairing flows), a category tab row (Översikt · Kassor · Kök · Självservice · Displayer · Betalning · Hårdvara · Nätverk, live counts), a system-health banner, and the Behöver åtgärd / Senast använda enheter overview. UI-only in intent: every fleet panel, pairing widget, claim form, rename/revoke action and the first-run Kom igång card is the existing component re-homed into tabs (the collapsible boxes became always-open sections;
${id}-headeranchors and the IA-2?tab=deep links still land, now selecting the tab that absorbed them). The two display types share the Displayer tab. One additive API change:GET /devices/summarynow also returnskiosks,hardware.onlineand a per-devicedevices[]array (id/kind/name/online/lastSeenAt/model) computed with each fleet's OWN heartbeat window, so the overview can never disagree with the tab a row links to; the pre-existing top-level 5-minute counts are untouched (OperationsCenter). Dictdevicestitle/subtitle updated (sv "Enheter"; en/da/no in step); statuses use the semantic.status-*tokens in both themes.- Void-reason wording: the POS Makulera reason
Gästen gicknow displays as
Kunden avbröt(enCustomer cancelled, noKunden avbrøt, daKunden afbrød) in both till kits, all four locales. The stored reason codeguest_leftis unchanged, so historical events render through the new localization untouched.Security
- **The legacy Next.js fiscal rail could take real money and sign a Swedish receipt with a SANDBOX
control code, in a market that was never opened (2026-08-20).** The NestJS till path has passed
assertMarketOpenForSalesince Phase 6; this rail — storefront checkout, QR pay page, kiosk, delivery ingest, counter payment, PSP capture — consulted no gate at all.certified: falsewas honest in the data and invisible on the receipt.Fixed at the CHOKEPOINT, not the call sites.
lib/fiscal/se/market-gate.tsdecides three refusals —MARKET_NOT_OPEN,MARKET_STATE_UNKNOWN(fail closed) andSANDBOX_SIGNATURE_ON_LIVE_SALE(a placeholder code may not sign a real sale even in an OPEN market). Every reservation inwire.ts(reserveFiscalSale,reserveFiscalPortionSale,reserveFiscalTraining) now takes a REQUIRED clearance and throws without one, so a stale client, a deep link, a hand-rolled POST, a replay or untyped JavaScript cannot reach a receipt number. A drift check refuses clearance won on a certified unit but spent on the sandbox.createTenantOrderrefuses BEFORE the money moves on any collecting call.Deliberately NOT gated, each a standing rule: refunds and returns (structural — the extra reservation takes no clearance parameter at all), receipt copies and pro forma, and training mode. Onboarding a venue before its country opens still works.
Test mode is an explicit declaration of TWO variables (
FISCAL_DEV_ALLOW_SANDBOX=developmentandNODE_ENV=development), modelled on the dev-pairing door: a check reading "not production" passes when nothing was said. 19 adversarial cases inmarket-gate.test.ts.Fixed
- Gift cards were minted in a currency the REQUEST chose (2026-08-17). Five mint doors read
(body.currency ?? 'SEK')— the public purchase route, the public corporate portal and the dashboard's issue / bulk / recurring doors — and five UI pickers offered SEK·USD·EUR·GBP. A euro venue therefore minted SEK cards which its own redeem gate refused as foreign, after the guest had paid; a stored-value card cannot be re-denominated once sold. The denomination is now the VENUE's on every door, through one resolver (lib/gift-cards/currency.ts—venueGiftCurrency,giftCurrencyRefusal,sameCurrency— plusvenue-currency.tsfor the one await). A caller naming a different currency gets 409 `GIFT_CURRENCY_MISMATCH` before a card exists, and the card, the Stripe/Connect charge, the Swish/PayPal/Adyen leg and the presentkort invoice all read that single value, so charge and mint cannot disagree. Every picker became a read-only badge fed from the server (/buy, the embed widget, the corporate portal, the dashboard issue modal, bulk, recurring);rails.tsgates Swish on the same resolved currency so the button and the route can no longer disagree. On the redeem side, the six!==currency gates (engine tender / external / closed-loop, closed-loop preview, payment-intent, orders, pos/quote) now compare throughsameCurrency, so a venue that stored"sek"can no longer have its own cards refused. Guest gift money prints its ISO code (formatGiftMoney— reader's language, the currency's own locale when none is known, never the ambient one). 15 new tests; uitsc0 errors.- **Ten confirmed defects across the HQ v2 p8/p9/p10 surfaces (2026-08-17), each at its
root and each re-measured in the running app at 1920×1080.* - Beställningar' detail panel sat below the fold (p8). The rail opened in a row BELOW the KPI cards, 217 px further down than the design puts it, and the aside had no ceiling — its `flex-1 overflow-y-auto` never engaged (`scrollHeight === clientHeight === 532`) and "Acceptera & skicka till kök" landed at y 987 with the panel ending at 1138. The panel is now the rail of the WHOLE workspace, beside the KPI row exactly as p8 draws it, and the aside is capped (`xl:max-h-[calc(100dvh-15rem)]`, a MEASURED number). Re-measured: panel 217→1057, actions 906–1040, scroller 1004 over 568. - The relative estimate printed raw minutes. "(om ca 1043 min)" for a next-morning slot and "(om ca 2880 min)" for a pre-order two days out. `relativeSpan` (pure, spec-pinned) now rolls minutes into hours and days and the dict owns every word: the same order reads "(om ca 2 dygn)". - A tab switch left the previous order's panel open beside a list that did not contain it — most visibly on Parkerade, which prints in words that nothing there is listable. The selection now resolves against the FILTERED rows, so no filter, tab or search can show a row the list does not hold. The selection itself survives: return to the tab and the panel is there. - p8's trailing filter button was missing, replaced by a "Rensa filter" text button that only appeared once you had already filtered. The icon button is now always at the end of the filter row, and it says what it does (disabled + "Inga filter är aktiva" when there is nothing to clear). - The delivery slip printed "ORDER ##1203". `GET …/orders/[orderId]` hand-rolled its own copy of `buildDeliveryView`, and the copy had drifted: the shared builder strips a stored leading `#` because the slip prints its own. The route now calls the one builder, so the screen, the printed slip and the PDF cannot disagree about an order number. - A Beställ line lost its modifiers AND its "Allergier / notering" on the way back to the nota. `AdminTicketLine` carried neither, so a dish sent with a size, two extras and a note read as a bare "1 × Pizza Vesuvio" — the dashboard and the kitchen bong disagreeing about the same dish. The backend projection now ships the option NAMES and the note (no per-option money: it is already inside the line's engine total), the ui twin whitelists them, and p10's nota draws them under the dish. - The draft's "Totalt" was not what the send added. `bestallQuoteAdmin` priced the proposed rows as a standalone basket, so on a nota carrying "Rabatt 10%" it quoted 89,00 kr for a row that moved the bill by 80,10 kr — a figure an operator could read out to a guest and then fail to charge. The quote is now a DELTA: the live bill priced as it stands, then priced again with the rows appended, through the same engine call WITH the bill's own discounts. Verified live: nota 290,70 kr, p11 "Pris" 80,10 kr, draft Delsumma 71,52 + Moms 8,58 = Totalt 80,10. - Typing in "Sök produkter" deleted the whole category tab strip (3 tabs → 0), because the strip was replaced by a `<span />` while searching. p9 draws the tabs and the search field side by side, always; they now are. No tab is current while a search runs, and tapping one clears the search and takes you there. - Error text on the Beställ screens used raw
text-red-600(3.6:1 on the dark card). Now the guarded `.status-ink-danger`, and the one hover state uses the same `--status-danger-ink` token. - The table panel still drew "Senaste aktiviteter" + "Bokningsinfo" beneath the nota*, which p10 removes outright ("Det dubbla informationsområdet längst ner är borttaget… nås via flikarna högst upp"). Both cards are gone; a long nota has the panel to itself.Added
- **Reservation check-in got a 15-minute window, a confirmation dialog and a protected
early door (2026-08-20). The ONE rule lives in `lib/reservations/check-in-window.ts` (`CHECK_IN_EARLY_WINDOW_MINUTES`, `checkInWindowVerdict`) and is evaluated in the venue's own timezone via the venue clock; a stored time that cannot be judged fails CLOSED. Both mutation doors — the dashboard PATCH and the internal POS action route — refuse a too-early check-in with `CHECK_IN_TOO_EARLY` + `eligibleAt`/`serverNow`, and a no-show row with `BOOKING_NO_SHOW`; every surface (dashboard list/detail/table panel, web till, Android till, Windows till) renders a functionally disabled "Öppnar HH:MM" control that flips at the boundary without polling, and confirms the guest's identity (name · time · covers · table · phone ending) before any stamp. Tidig incheckning** lives inside booking details only, needs
reservations.override(dashboard, admin-only) or owner/manager/approve(till roster, proven server-side per request), demands one of three reasons, and stampsearlyCheckIn {reason, by, at, device}plus an activity-log row in the same transaction. Responses carrycheckInEligibleAtandcheckInWindowMinutes, so no client authors the 15. sv/en/da/no on every surface.- Flytta bord brought to HQ v2 p12 (2026-08-17).
MoveTablePanel.tsxnow draws the
PDF's own anatomy: the source stat row (Gäster · Öppen nota · Aktiv sedan), the outlined "Från (aktuellt bord)" plate, the valid-target grid with p12's three tiers (Rekommenderad in status-ok green, its same-capacity peers Bra alternativ, roomier tables plain) under "Rekommenderat bord passar bäst för antalet gäster", the "Detta flyttas tillsammans" summary ending in a ticked "Serviceflöde bevaras", and Avbryt / Bekräfta flytt over the padlocked "Flytten kan inte ångras." The recommendation arrives ARMED — p12 draws it checked, and the PDF's own logic note is "systemet kan rekommendera lämpliga mål-bord men personalen kan välja manuellt" — while any other valid tile is one tap away. The ranking left the JSX for
lib/reservations/move-table.ts(rankMoveTargets,moveParty) and is pinned by tests, including the rule that a party of unknown size filters nothing but may never be RECOMMENDED a table smaller than the one they are already sitting at. No second mover was built: it is still the one shipped door (POST pos/ticket/move-table→moveTableAdmin's rev-pinned CAS onboundTo.refId), with a covering booking riding the existing reservations move route afterwards. Nothing here settles: an OPEN bill is rebound to another table — no receipt number, no journal row, no tender — and the öppen nota figure is the board's server-priced string verbatim, rendered as an em-dash when the bill cannot price. New dict keyovMtFreeNow("Ledigt nu"), because p12's move grid asserts availability at this moment where the map plate says the bare "Ledigt";ovMtOnlyValidHintandovMtActivitiesDetailnow match the PDF's Swedish word for word ("noteringar", not "noter").- Beställ + Produktdetalj & modifierare (2026-08-17, HQ v2 p9 + p11). The ordering
surface is now the SCREEN the PDF draws, not a rail panel:
BestallSurface.tsxtakes the whole overview (the NewBookingWizard regime) with the header "Beställ · Bord N" + state chip + Flytta | Mer | Avbryt, the automatic-draft banner, the menu on the left (Favoriter + category tabs + search, the product grid, "Alla produkter" with counts) and "Nya artiklar till Bord N" on the right, ending in one primary "Skicka N artiklar till kök". After a successful send the operator FLOWS BACK to the active table (p9's own note 3), where the nota and the aktiviteter have just updated — no second nota is drawn here and there is no competing "Spara". p11 is built, and it is built on the tenant's OWN menu. Tapping a product opensBestallProductDetail.tsx: the obligatory single-choice group, the multi-select tillbehör with checkboxes, "Allergier / notering" and a Sammanfattning card (Produkt · Val · Antal −/+ · Pris) with Avbryt / Lägg till på notan. The PDF's "Ryggbiff / Stekgrad" is an EXAMPLE of the pattern and is hard-coded nowhere: the groups, their options, their min/max rule and their money come frompos_modifier_groupsthrough the bestall-catalog door, which now projects them (they were on that projection's never-leaves list for a reason that had expired — no dashboard surface could ask for an option). A venue with no modifier groups gets the same screen with the question area empty. Every figure on both screens is the server's. A new READ-ONLY door,POST /pos/admin/tickets/bestall-quote(BFF:…/pos/ticket/bestall-quote), builds the very lines the send would build — same catalogue, same server-resolved modifier pricing, same price ladder, same tax resolution under the TABLE's own scope — and prices them with the sameevaluatePromotions/priceBasketpair every other view uses. It answers the configured line's "Pris" (debounced, so ticking an option re-asks rather than adds) and the draft's row totals + Delsumma / Moms / Totalt. Nothing is summed, multiplied or discounted in the browser; a figure the engine cannot produce is OMITTED, never0,00. Proven by spec: a quote and the send that follows it return the identical figure for the same dish. The allergy note is real plumbing, not a decorative box. It ridesext.foodBev.note— the platform's documented channel for "utan lök" / "allergi: nötter" — so it lands on the stored line, the kitchen ticket line and the fire paper. It carries no money and nothing prices it. The required-choice gate is the money door's ownminSelectread forward, so a single-choice required group opens pre-answered with the menu's default and the send can no longer fail on a question the screen never asked. Verified on glass against a real tenant (ten_shani-pizza, Bord 1): the size ladder substitutes the unit price server-side (89,00 "Liten 26 cm" + 15,00 "Extra ost" ⇒ the engine's 104,00 kr), and the draft's Delsumma/Moms/Totalt are the engine's own VAT buckets.- Beställningar — the order desk (2026-08-17, HQ v2 p8). A new dashboard workspace at
/dashboard/order-desk: the day list a person WORKS at (avhämtning, leverans, över disk), as opposed to/dashboard/orders, which is the TV & Displays hub's watch-from-the-pass kanban. Same store, same doors, different job. It is a read surface, not a second order engine:GET /api/tenant/[t]/orders/deskreadstenant_data.ordersonce and projects the row (lib/orders/order-desk.ts); Acceptera and Markera utlämnad are the existingPATCH …/orders/[id](which is wherecrossesKitchenAcceptancefires the kitchen bong and stamps the milestone), and Avvisa is the existing cancel-and-refund route. p8's own logic, implemented literally. An order WITHOUT a table belongs here, never in the booking list and never branded "saknar bord" — so a tableless dine-in order is on the desk and a seated one is not (one order, one screen). The three payment sentences read from the stored tender, not a guess: a PSP settled it before anyone touched it (Betald online), cash/in-person card/wallet could only have been taken with the guest present (Betald i kassan), pending + cash is the platform's own pay-at-pickup shape (Betalas vid hämtning) and everything else says Ej betald. A pending MARKETPLACE order is its own lane — a partner and a courier are waiting on the answer, which is why the PDF paints it amber. Nothing is invented where the platform stores nothing. Money is rendered ONCE, on the server, from a stored figure (Order.total,OrderItem.price, which the schema defines as the line total) — a line or an order without one renders no amount at all, never0,00. The estimate line is drawn only offpromisedReadyAt(stamped at acceptance) or the guest's ownscheduledTime, so an un-accepted Foodora order gets no ETA rather than a fabricated one; a courier name appears only for own-driver runs. The Parkerade tab and KPI print IN WORDS that a parked bill lives on the register and cannot be read from here (only a table-bound one ever reaches the dashboard, and the projection that does rewrites it toacceptedwith no customer or lines) instead of an empty list that would claim there are none. p8 also draws an "Intern notering" box and a "Redigera" button; an order has no staff-note field and nothing in the platform edits an order's lines, so both are deliberately absent rather than fake — see the header comment inOrderDeskPanel.tsxfor what they would cost. Rules are pure and spec-pinned (order-desk.test.ts, 17 tests); the day window is the BROWSER's own local-midnight instants, so a service running past midnight is never cut at the wrong hour by a UTC string slice. New dict sectionorderDesk(sv hand-written). Nav note: svOrderswas renamed Livetavla — p8 gives "Beställningar" to this desk, and two sidebar rows carrying the same word would have been the real regression.- Bokningsbekräftelse — the confirmation seam (2026-08-17, HQ v2 p3/p4). Server side.
p3 puts a LIVE PREVIEW of the guest's message on the Ny bokning screen, above a character counter ("160/160 tecken"), with SMS and E-post as two channels of the same text; p4 shows the same message afterwards with a per-channel status and a "Skicka på nytt". Three things were missing for any of that to be true. (1) The text is composed in ONE place —
lib/reservations/confirmation-message.ts, pure (so the wizard imports it directly for the preview rather than round-tripping to a server it has not written to yet), producing the PDF's own lines includingPlats: Bord 1 + Bord 3 (12 platser)from the WHOLE combination, nevertableIdalone. Phrases are a copy pack (SV_= the PDF's wording,EN_for everyone else, picked fromsettings.defaultLanguage), months are a table rather thanIntl— a small-ICU runtime would silently swap "maj" for "May" and change a character count the operator had already approved.measureSmsTextreports the real cost: the GSM-7 alphabet (å ä ö are IN it, so a Swedish message is a 160-character message), the collapse to a 70-character budget the moment one pasted smart quote forces UCS-2, and the segment count. The booking REFERENCE is opt-in for exactly this reason: it is 18 characters on a 154-character message, i.e. the difference between one SMS and two for every till-created booking. (2) Channels (notify.ts) —emailis untouched, still fire-and-forget, still unable to fail a booking;smsis a DISPATCH: awaited, per-channel, and honest. There is no reservation SMS provider in this product and none was invented — the channel is wired to the tenant's ownsmsConfigand every way it can be absent (disabled / no sender / no credentials / a dev process that only console-logs and returns true) is reported asnot_configuredwith the reason, from the newsmsReadiness()twin ofisMailConfigured(). Nothing is ever recorded as sent that was not. (3) A door —POST /api/tenant/[t]/reservations/[r]/confirmationsends or re-sends on the chosen channels and answers per channel;GETserves the preview, the counter and what already went out. It storesReservation.confirmationChannels(the latest attempt PER CHANNEL — a resend replaces, never appends, because this lives on the tenant blob) andconfirmationSentAt, which moves ONLY on a real delivery. Provider I/O runs outside the write lock and the record commits inside it (the deposit seam's settle-then-persist order), and a cancelled booking is refused (409 BOOKING_NOT_OPEN) rather than turned into a second, competing cancellation rail.- Bordskombination — one booking, several tables (2026-08-17, HQ v2 p2–p4). Server side.
A party of twelve in a room of six-tops is Friday, not an edge case, and until now the book had no way to say "Bord 1 + 3": the suggestion engine answered nothing fits today while the staff pushed two tables together off the record.
Reservation.tableIds?: string[]is the full set, PRIMARY FIRST, and is absent on an ordinary single-table booking —tableIdstays the primary and every one of its ~40 existing readers is untouched, which is the whole reason the field is additive rather than a rename. One helper decides the inconsistent cases for everybody (lib/reservations/table-set.ts:tablesOf/primaryOf/isCombined/tableBindingOf/applyTableBinding, plus the PDF's own label — "Bord 1 + 3", badge "BORD 1 + 3", built on the existingisBareTableNameshape rule so a venue that named its own tables is never given the word twice). Occupancy now takes the whole set: a combined booking holds BOTH tables for the sitting — inoccupiedTableIds, in the over-capacity shrink report (measured on SUMMED seats, so 12 on 6+6 is no longer reported broken), and in the public/bookpage's own window engine, where reading only the primary would have sold the second table to a guest.assign-tableruns the same three checks over a set (all exist / all free / summed seats hold the party, with the named-staff override intact) and gained an auto-assign path;suggest-tableproposes the smallest PAIR, then a triple, capped at three, preferring tables the venue put by the same landmark (DesignerTable.near— landmarks, NOT a table adjacency graph) and otherwise the least waste, with the biggest table anchoring the set. "Ingen bokning utan bord": the staff create and the till create/update doors accepttableIdsand, when a caller names nothing, assign a table themselves, refusing with409 NO_TABLE_AVAILABLE(carried through the POS bridge by code, never flattened into a 503) only when the venue HAS a room and none of it fits — a venue with no floor plan books exactly as before. A till edit that mentions no table KEEPS the tables the booking has rather than blanking them.- Bokningar & bordskarta — the KOMPLETT redesign (2026-08-16, owner's 14-page PDF). The
dashboard's reservations page is rebuilt to the design, screen by screen, each one screenshot-compared against its page before landing. Server doors first (
5ae3e1d5f):Reservation.checkedInAt/checkedInBy— ARRIVAL IS A FACT NEXT TO THE STATUS, never a fifth one, so every count and filter still reads exactly four (thenoShowprecedent), and arrival DELETES a standing no-show on both doors, in either tap order; the dashboardPATCHgrew into the full edit door (check-in, VIP, inline guest/time/table edits) sharing the till'sresolveTableAssignmentroom-check with the booking excluded from its own occupancy, refusing closed bookings withBOOKING_NOT_OPENBEFORE any write; the till chain learnedaction: 'check_in'end to end; and the list route learned?date=— a chronological day view plusdayCounts(incl.checkedInandunassigned= the design's "Saknar bord" tab). Absentdatereturns byte-identical to before. Screens (988c715d1): the two-step Ny bokning wizard (p2/p3) reading the same availability and POSTing the same staff-create door as snabb bokning — "Föreslaget bord · Bästa passform", VALT BORD radio, ALTERNATIVA BORD, allergy folded intospecialRequeststhrough a tested composer; Alla bokningar (p5) with its date stepper, search, sort, Alla/Kommande/Sittande/Saknar bord tabs (Kommande derived on the LOCAL wall clock), azure BORD badges,.status-warnSAKNAR BORD rows and 14-per-page "Visar X–Y av Z"; and the p6 rail regime — the selected table's panel moved to the TOP of a rail widened to ~43% (the owner's explicit "the right panel is much wider in pdf" note), snabb bokning collapsing beneath it, Senaste aktiviteter + Bokningsinfo side by side. The p8 Bokning tab (61a23c812): inline-editable BOKNINGSINFORMATION rows submitting as ONE merge PATCH (probe-verified single call;allowOverCapacityonly on the explicit "Utöka ändå" retry — the pull-up-a-chair act, stamped server-side), ANKOMSTSTATUS with Checka in / Ångra, confirm-gated Avboka that says when the guest is mailed, refusal codes rendering the dict's words. DELIBERATE DEVIATIONS from the mock, each with a reason:E-postbecame optional (the design says valfritt;StaffCreateReservationSchemanow accepts''and the recipient list already drops empties), and Skicka SMS ships visible but disabled — no SMS transport exists, and a button that silently does nothing is worse than one that says why.- Mer-menyn + Beställ — the sales doors behind the design's actions (2026-08-16, `03791495f`).
TicketDocgainedcovers/waiterName(doc-only, off the frozen domain-types IDL, like the park-era stamps beside them) with admin doors on the Betala-stamp pattern; the ticket-detail projection preferswaiterNameoveropenedByNamefor the design's Servitör column and operator ids still never leave the server. Stäng bord voids ONLY an all-voided bill through a rev-pinned CAS — lines present refuse409 TICKET_HAS_LINESnaming Betala as the way, and a concurrent till resume makes the void lose the CAS rather than eat a bill that just gained lines. Slå ihop bord was promoted fromscripts/merge-table-bills.mjsinto a server operation keeping every script rule verbatim (earliest-openedAt survives,_mNline re-issue, fired ids remapped so nothing re-fires, absorbed bills VOIDED withmergedIntoTicketIdrather than deleted, discount-carrying groups refused) — plus the refuter's catch: absorbed bills' kitchen cards now repoint to the survivor inside the merge transaction, so voiding a merged line still strikes the board. Beställ lets the dashboard order onto a table's nota through the SAMEopenTicket/addLine/fireKitchendoors a till uses — bound-bill adoption (never a second bill), kernel pricing, 86/serving gates, the allergen + modifier fold on card AND bong, post-commit bong, training isolation — idempotent on a client-mintedopIdso a double press cannot cook twice, and with ZERO tender/receipt/journal/stock/queue writes, proven by spec.- Guest name on the tills, and check-in there too (2026-08-16, `6af50d022`, owner ruling). A
guest gives their name FOR the booking, so both tills now show it on booking rows and sheets; the PHONE stays off (the refuter found the Compose sheet still printing it — removed; find-by- phone and edit prefill stay, being the write flow and the background lookup key). Both tills offer Checka in for pending/confirmed bookings AND re-offer it over a no-show flag, so the server's heal is reachable from either surface; "Markera som utebliven" hides once a party has arrived; "Incheckad HH:MM" renders on day rows, find rows and sheets from the LOCAL wall clock.
- **Modifier allergens — the fold into the dish's line (2026-08-16, owner ruling "modifiers can
carry allergens").** A product OPTION can now carry confirmed EU-14 allergen ids (
ProductOption.allergens; per-option chips in the menu editor's option groups, sameALLERGEN_REGISTRYas the product's own). THE MODEL IS THE OWNER'S: an option's allergens FOLD INTO THE DISH'S LINE — "Extra ost" makes the LINE contain milk; the cook reads the dish and the bong's allergen row stays per line, never a row per modifier. Where the fold happens, per rail: web/legacy orders at the write-time snapshot (snapshotKitchenAllergensunions the chosen options' facts by the newselectedOptions[].optionId/groupIdthe resolver now stamps — so the stored order keeps the union forever, R2a immunity intact); till fires / counter sales server-side in pos-sales whereallergenChainOfalready joins the catalogue item (newmodifierAllergenIndex+foldModifierAllergens, threaded through the park/send fire, the explicit fire-kitchen door, the reconcile retry and the counter-sale bong — no new reads on the money paths); and the online→kitchen bridge now carries the LINE's stored chain (union-additive at ingest), closing the gap where a web order's option allergens never reached the native KDS board. Backend catalog:LegacyMenuDocoptionallergensdeclared +modifierGroupFromemits by key omission;CatalogModifier/ModifierGroupRead+ admin upsert DTO widened. The kernel's applied-modifier wire is untouched (serde denyunknownfields). MIGRATION: nothing invents data — absent option fields stay absent (silence, never an "allergen-free" claim), no backfill; the first assertion an option ever makes is an owner saving it in the editor. Option-allergen corrections after order time do not reach live tickets (the read join stays product-only; the dish-level ceiling still rises).- Bokningar & bordskarta — Phase 4: the tills get the desk's rules (2026-08-16). The three
POS surfaces (Compose Android+Windows via
pos/appcommonMain,pos/web) gain the dashboard's reservation intelligence with less desk chrome, every rule reached through the SHARED modules rather than re-derived: Nästa lediga on the till booking form (new device routeGET /api/internal/pos/booking-suggestcomposinglib/reservations/suggest-table.tsovergetAvailableSlots+ sitting-awareoccupiedTableIds; NestJS doorGET /pos/registers/bookings/suggest, RegisterGuard) with a one-tap apply that sets time AND table; Flytta on the till's booking rows (a table-only full-state move through the EXISTING reservation-update →assign-table.tsCAS; refusals surface BY CODE — TABLEOCCUPIED / TABLETOOSMALL retryable with the over-capacity second tap / TABLENOTFOUND / BOOKINGNOT_OPEN — in the tills' own sv/en string tables, never the bridge's Swedish-only sentences); Senaste aktiviteter on the till table panel (new device routeGET /api/internal/pos/ticket-activityreusing the ONE backend composer through the internal client and the SAME projection whitelist as the dashboard BFF; NestJS doorGET /pos/tickets/:id/activity; compact last-6 + "Visa alla", honest verbs — "Klart i köket", never "Mat serverad", em-dash for stored-without-a-clock facts); and the identity QR on the till's förhandsnota paper (newPOST /api/internal/pos/print-prebill+ NestJSPOST /pos/tickets/:id/print-prebillqueueing the ONE förnota document —lib/devices/prebill-queue.ts, extracted from the dashboard's print route so both doors share the planner —restora:ticket:<id>, ^BQN on ZPL, caption fallback on ESC/POS; pos/web tries the server door first and falls back to the QR-less local-agent text). Deliberately dropped on the tills: zoom chrome, Kommande/Sittande tabs, deep Bokningsinfo (the 2026-08-16 call-out ruling — a panel opened in front of guests must not print who is coming).- Bokningar & bordskarta — Phase 2: the table detail panel (2026-08-16). Clicking a
bordskarta tile now opens the full panel from the design (replacing Phase 1's stub card): header
Bord N+ state chip + guests·minutes, tabs Nota / Aktiviteter / Bokning. NOTA shows the live bill — engine-priced lines, Delsumma/Moms/Totalt as SERVER-rendered strings (new backend readGET /pos/admin/tickets/one, InternalGuard, live-bills-only 404; BFFGET /api/tenant/[tenantId]/pos/ticketwith an explicit field whitelist — operator ids, customerEmail, internalNote and discount stamps never reach a browser). Betala is a hand-off, not a payment (owner ruling): it stamps the förhandsnota through the new ADMIN twinPOST /pos/admin/tickets/prebill(dashboard-namespaced stamp, no settle/receipt/journal) and names the paired registers that can complete it via their existing claim + PaymentFlow. Skriv ut förnota prints through the existing DeviceJob queue (lib/devices/prebill-print.ts: FÖRHANDSNOTA/EJ KVITTO at 2× amount size, identity QRrestora:ticket:<ticketId>a till scans to claim — never a payable link, no receipt number/VAT table/kontrollenhet). Dela nota is the split VIEW: lines assigned to parts, each part total ENGINE-priced read-only viaPOST /pos/admin/tickets/split-quote; every part is paid at a register. Flytta moves the BOOKING, not the bill — the room rule extracted from the till's reservation-update route into the sharedlib/reservations/assign-table.ts(exists/fits/free-for-the-SITTING withseatingDurationMin, self-excluded) and served to the dashboard byPOST /api/tenant/[tenantId]/reservations/[reservationId]/move(409 TABLEOCCUPIED / TABLETOO_SMALL with an explicit extra-chair override). AKTIVITETER is a Phase 3 stub. Dictionary: 48ovPanel*keys, Swedish real, 19 languages English-fallback.- Bokningar & bordskarta — Phase 1 of the reservations-overview redesign (2026-08-16). The
dashboard reservations page's flat list view is replaced by the two-panel overview from the design (azure tokens, not the mock's navy): LEFT a live bordskarta drawn with the SAME floor geometry as the guest picker (extracted to
lib/reservations/floor-geometry.ts), tables coloured ledigt/bokad/sitter with the server-pricedNota <total>on seated tables, zoom controls, and a table LIST fallback below 768 px; RIGHT the SNABB BOKNING panel (party chips × Nu/önskad tid → next-free-table suggestion booked through the existing staff create route) over the Kommande/Sittande/Saknar bord booking list (amber SAKNAR BORD badge, confirm/reject/deposit actions preserved). New backend readGET /pos/admin/tables/live(InternalGuard, per-tenant 12 s cache over the samepriceBasketpath the till lists use; operator ids resolved to display names server-side and never shipped); new BFF proxiesGET /api/tenant/[tenantId]/pos/table-board(explicit field whitelist + money rendered once viaminorToSek+formatMoney) andGET /api/tenant/[tenantId]/reservations/availability(getAvailableSlots × occupiedTableIds × tableFitsParty composed inlib/reservations/suggest-table.ts— smallest fitting free table wins, ties on seats then name). Deposits/prep/calendar/settings/blocked tabs unchanged; table detail panel (betala/nota/aktiviteter) is Phase 2. Dictionary:ov*keys, Swedish real, others English.Removed
- **Google Fonts is gone from the RUNTIME — no visitor's browser fetches type from a third party
any more (2026-08-16). Self-hosting on 2026-08-15 cured the BUILD (`next/font/google` had killed three production builds in four); it never touched the visitor. Nineteen files still shipped `<link rel="stylesheet" href="https://fonts.googleapis.com/…">`, two `preconnect`s to `fonts.gstatic.com`, or a CSS `@import` — so every guest who opened a receipt, an order tracker, a delivery slip, a storefront, `/bazar`, `/try`, `/wow` or the login page had their IP handed to Google before consenting to anything (the practice German courts have already awarded damages over, LG München I 3 O 17493/20), and every one of those pages only looked right while somebody else's CDN was reachable. All of it is now `@font-face` in `src/fonts/google-families.css` (172 faces, 41 families + the Material Symbols icon font), declared under the same family names** the CSS already asked for and imported once from the single root layout — so no
font-family,--font-display/--font-bodyvariable,tpl.displayFontor inline style changed, and nothing looks different. 65 new latin.woff2joined the 15 already there, each with its gstatic URL, css2 request, byte count and sha256 insrc/fonts/SOURCES.jsonand enforced bysrc/fonts/fonts-selfhosted.test.ts, which also now FAILS on any livefonts.googleapis.com/fonts.gstatic.comreference insrc/(comments still allowed — they are how the next person learns why it is absent). Deleted with it:TEMPLATE_FONT_LINKS,GENERATED_FONT_LINKS,buildGoogleFontsUrl()(the only runtime URL builder), every template'sfontLink/fontLinks, and the builder API'sfontLinkfield.Added
- The fiscal sweep — the missing half of "a paid order carries its fiscal record" (2026-08-15).
Round 20 wired the recorder into every paid flip and gave contention a durable carrier; the OPEN window was process death between the paid commit and the recorder's first attempt — no stamp, no deferral row, and on Swish the redelivered callback is acked by the webhook dedup store, so nothing ever re-runs the handler. The outbox relay's existing cycle now carries a sweep (
lib/fiscal/se/sweep.ts): it FINDS paid orders still awaiting their record past a 5-minute grace window using the recorder's OWN gate (orderAwaitsFiscalSale, wire.ts — so training, non-SE, owner-disabled and unsupported-VAT absences stay legal by the same rules), HEALS them loudly through the same idempotent recorder (an order stamped in the meantime is never re-numbered), hands what cannot heal to the existing deferral carrier in the hash-chained integrity journal (once — never an infinite retry), journals sales older than the carrier's 48 h window for a human instead of minting backdated receipts, and SURFACES stranded Swish payments (id claimed in the dedup store, order never flipped) aspayment.swish.strandedjournal rows carrying the human runbook — journal + log only, never an auto-flip, because flipping paid without re-verifying with Swish would invent money. Rides the relay's tenant loop, so finding costs no extra reads; skipped on single-tenant hot-path nudges, exactly like the deferred-write drain.Fixed
- **A TILL SALE IS NOW ACTUALLY PROJECTED — the endpoint the backend has been POSTing to did not
exist (2026-08-13).**
SalesServicebuilds a projection payload for every closed ticket and parked bill and POSTs it to/api/internal/pos/till-orderat settle time; that route was never written, so every dispatch 404'd, the dispatcher classified the 4xx as a definitive refusal and dropped it. A counter sale therefore became visible only if a human happened to have the dashboard Orders board open, whose 30 s poll did the projecting — a coincidence, not a pipeline. The route now exists (internal-secret authenticated, tenant-scoped, idempotent on re-post) and keeps four rules: it never mints a public number (a ticket carrying neither queue number nor löpnummer is refused 422, not numbered here), it copies money instead of computing it (totalMinor→ SEK through the one conversion point), it carriesticketRevassourceRevso a late park cannot overwrite the close that superseded it, and a PARKED bill projects active + payment pending — a held bill reachable from the guest's own tracking token must not read "paid".- The projection stopped discarding the venue: `locationId` was hardcoded `'default'` (2026-08-13).
contextOfstampedDEFAULT_LOCATION_IDon every row, on the write path AND the read path (which wins the merge), so the audit stamp the order itself carries was never read back and a location-filtered pickup search answered 200 with an empty list at every venue of a multi-venue tenant — "no such order" rather than "misconfigured". Rows now carry the order's own stamp.- The 30 s poll no longer rewrites the whole projection to store nothing (2026-08-13). Every pass
replaced a document allowed to hold 2 000 rows, once per open dashboard tab, because
lastProjectedAtwas rewritten every time and nothing compared the entries. The pass now diffs the ROWS (ignoringprojectedAt, which is the eviction clock and changes by construction) and skips the write when nothing changed; an unchanged row keeps its storedprojectedAt, so retention runs from the sale rather than restarting on every poll.- A retryable refusal is retried instead of swallowed — all four consumers (2026-08-13). The 409
WRITE_CONFLICTintroduced by the CAS guard says in its own body that nothing was stored, and every client had been written when a 4xx could only mean "the request is wrong":submitPosOrderDISCARDED the sale and told the cashier it was rejected,useOfflineOutboxDELETED the queued item, and both order boards let it fall through a>= 500-only gate as success — leaving an optimistic tick on screen for an order the kitchen was never told about. One shared classifier (lib/offline/retryable.ts) now decides, BODY-DRIVEN so a validation 400 / 404 / 402 stays final and the outbox cannot become a permanently stuck queue; a refused sale is queued with the SAMEclientRequestId, a refused status bump is kept, and the replay budget is bounded — an exhausted item stays queued and visible (failed/retryFailed()), never silently dropped.- The un-redeployable Android APK's timeout retry is idempotent server-side (2026-08-13). It sends
no
clientRequestId, so a retry after a timeout was a second order: two public queue numbers, two kitchen fires, one guest charged twice. An IN-PERSON sale arriving without a key now gets one DERIVED inside the create lock from the request's own content (tenant, location, register, device, operator, type, table, tender, gift/canteen/loyalty refs, total, basket), replaying only within a 45 s window and recording a genuinely later identical sale under its own suffixed key. Customer channels are deliberately excluded — two guests ordering the same thing in the same minute is a lunch rush, not a retry. The collision this accepts, and why it is preferable to a double charge, is documented inlib/orders/derived-idempotency.ts.- **THE MENU WRITE PATH IS NOW SERVER-AUTHORITATIVE — the browser was the only thing enforcing the
authoring rules (2026-08-13). `validateOptionGroups` (seven rules, 71 tests) and `findDuplicateProduct` both existed, both passed, and both ran in `ProductEditorModal` and nowhere else; `CreateProductSchema` accepted `optionGroups: z.array(z.any())` and the PATCH handler read `await req.json()` with no schema at all. So a curl, an older client, a second tab or a replayed request could author a nameless group (the till renders a blank heading), a malformed price ladder (nothing can decide which group sets the price), or a second identical dish — and did: the reporting tenant had both groups stored as `name: { en: "" }` and two Margheritas with byte-identical browser-minted option ids. `POST /api/tenant/[tenantId]/menu/products` and `PATCH .../products/[productId]` now call the same two checks through the new `lib/menu/product-write-guard`: 400
OPTION_GROUP_INVALID(with `issues[]`, each carrying its stable code, `groupId` and optional `optionId`, so the editor can point at the row) and 409DUPLICATE_PRODUCT* (with the `id` of the item that already exists) — the two codes the menu page already renders. Refusals happen BEFORE the blob write and before the `menus` mirror write, so a rejected payload cannot leave the two stores disagreeing. Judged for what the write actually writes: an availability-only PATCH (the 86 flag, also sent by the support agent's action cards) is never refused over authoring debt it did not touch, while a `discountPrice` / `weighPricing` change on a product that already has a ladder is refused with LADDER_ only.- `optionGroups` has a real schema (`z.array(z.any())` → `OptionGroupSchema`) (2026-08-13). The
one field whose contents decide what a guest is CHARGED was the one field nothing validated: an option with no id (pricing is id-addressed —
resolveLinematches group + option BY ID, so an idless option has no price), a price as a string, an options list that is not a list, or an unknownpricingModeall parsed. Per-optionimageUrls are now sanitised on the way in throughsanitizeOptionGroupImages, which had been written with zero callers anywhere; group and option names are trimmed in EVERY language on save, which finally kills the live'Smal '(trailing space) that has been shipping to tills. ABSENCE STAYS LOAD-BEARING: apricingModeof'add_on'is accepted (an old or hand-written payload) and DELETED before storage, so an add-on group round-trips byte-identical and the literal is never written.- **Every dashboard menu write now re-projects the POS catalog — a saved option group finally reaches
the tills (2026-08-13).**
pos_modifier_groupshas carriedpricingMode/isDefaultsince the catalog work landed and its prune tombstones a deleted dish, but NOTHING on the dashboard write path ever triggered the projection: every TILL write re-projects, the menu editor never did. So the owner could author the price ladder, watch it save, and every register would go on selling the old shape — and a deleted dish stayed sellable. One named door (lib/menu/pos-catalog-reproject→POST /pos/admin/catalog/translate, tenant-scoped, idempotent) is now called after the blob write by product create / update / delete / reorder / move, category rename / delete / reorder, menu reset and AI menu import. Loud but never fatal: a backend outage is logged and the owner keeps their edit, exactly like themenusmirror beside it.- **THE PRICE-LADDER OVERCHARGE IS CLOSED ON EVERY CUSTOMER CHANNEL — one basket, one price,
whichever button the guest presses (2026-08-13). `pricingMode: 'sets_price'` shipped on 2026-08-12 and the POS honoured it; the CUSTOMER channels did not. Each of them carried its own copy of the same fold — `optionAdjustment += opt.priceAdjustment` then `baseUnitPrice + optionAdjustment` — so the moment the owner used the control that had just shipped, their own website, kiosk and table-QR would charge 100 + 250 = 350,00 for a Familjepizza that costs 250,00*, and three of the four website doors CAPTURE that amount (Stripe / Adyen / PayPal) rather than merely invoicing it. Six copies are now ONE call: `priceQuoteLine` in `lib/menu/price-quote.ts` — a thin, id-addressed entry into `resolveLine`, the tier's only ladder-aware resolver (spec rule 2: lifted, never duplicated). Fixed at `api/public/[tenantId]/orders`, `.../payment-intent`, `.../adyen-order`, `.../paypal-order`, `api/public/kiosk/order` and `lib/tables/table-ordering.ts::priceGuestTableItems`. Per channel, base 100,00 + rung Familjepizza 250,00: before 350,00 → after 250,00; 2 × = 500,00 (was 700,00); with a 25,00 add-on = 275,00 (was 375,00); Smal 100,00 = 100,00 (was 200,00 — the clearest proof the amount is a price: adding it doubles the pizza). The rung rides the line at `priceAdjustment: 0`, so no receipt or bong prints "+250" beside the size. ABSENCE STAYS LOAD-BEARING:* a group with no
pricingModetakes the untouched delta branch (100,00 + 25,00 = 125,00, and the add-on still shows as +25,00), a negative add-on still discounts (−5,00 → 95,00), and the literal'add_on'is never written anywhere. VAT follows the price, per line and per rate: a 250,00 dine-in Familjepizza is netto 223,21 + moms 26,79 at 12 % (was 312,50 / 37,50 — output VAT over-declared by 10,71 per pizza), takeaway 6 % is netto 235,85 + moms 14,15 (was 330,19 / 19,81), and on a mixed bill the 25 % alcohol bucket does not move by one öre.- **The guest's SCREEN can finally see the ladder — three menu feeds carry
pricingMode(+
isDefault) (2026-08-13). A client cannot render a price it was never sent, so the kiosk showed "+250 kr" beside a 250,00 size and totalled 350,00 before anything was ordered. `kioskOptionGroups` / `kioskProduct` (`lib/kiosk/menu-payload.ts`), the storefront/marketplace item payload (`lib/bazar/restaurant-payload.ts`, new `BazarPayloadOptionGroup.pricingMode?: 'sets_price'`) and the table-QR / ITA menu feed (`api/public/[tenantId]/ita/menu`, which also gained `isDefault` so a required ladder can open on its default rung) now emit the mode — only** for a ladder, so an add-on group's document is byte-identical for every existing build.- **The QR/website wire format resolves selections BY ID now, with the name path kept as a
documented fallback (2026-08-13).** Guest clients key
selectedOptionsby group NAME and value by LABEL, and a name is not an identity: on the tenant that reported this, both groups were stored withname: { en: "" }— under name matching, every paid selection in a nameless or renamed group is silently dropped, and a dropped ladder rung falls back to a base price nobody sells. The shared resolver now takes the group id as the key and the option id as the value when they are present, and falls back to any-locale name matching (with the cosmetic"(+…)"suffix stripped) for cached/in-flight clients. No wire-type change was needed — the existingz.record(z.array(z.string()))carries ids as-is. The PayPal rail gained the other half of this for free: it was matching English-only names with a(+$…)-only suffix strip, i.e. dropping every paid option on a Swedish-only menu.- Onboarding step 3: the Terms checkbox now sits ABOVE the buttons (owner ask, 2026-08-12).
It rendered under the Back / "Start free 7-day trial" row, and the trial button is
disabled={!acceptTerms}— so a signer-up met a dead primary action on the LAST step of signup with the reason printed below it, and on a short viewport the checkbox could be below the fold entirely, leaving nothing on screen to explain why pressing the button does nothing. Pure reorder inside the same footer (pt-3→pb-3); the consent copy and theSIGNUP_LEARNING_DISCLOSUREsentence are unchanged. NOT verified in a browser yet — the localuicontainer servesui/srcfrom its image and does not bind-mount the source (see docs/infrastructure/LOCAL-STACK.md trap 5), and a rebuild would have baked in another session's in-flight edits. Verified by typecheck and by DOM order.Added
- A SIZE GROUP CAN SET THE PRICE NOW — "Prissättning: Sätter priset (ersätter grundpriset)" in the menu editor (2026-08-12). Reported live on a restaurant tenant created the same day: the owner authored a size group as ADD-ONS and typed the ACTUAL price of each size (100 / 125 / 150 / 250) against a 100,00 base price, so a Familjepizza would have rung up at 100 + 250 = 350 — a coherent price ladder typed into the only kind of group the product offered. The field was a bare number with a tiny literal
+beside it, and nothing in the product ever said which of the two it was. `OptionGroup.pricingMode` (`'add_on'` | `'sets_price'`) is the whole feature, and ABSENT is load-bearing: an existing group carries no key, takes the untouched branch and prices EXACTLY as it does today (proved by a control test that deletes the key from the same fixture and asserts the old 350).'sets_price'means the chosen option's amount IS the line price and REPLACES the base price. ONE number per option, deliberately — an option can never carry a delta and an absolute price that disagree, and the owner's live 100/125/150/250 become CORRECT the instant the toggle flips: no script ever rewrites a tenant's menu. In the editor: a fourth control beside Typ / Min / Max; choosing a ladder FORCES radio + required + min 1 + max 1 and disables those three ("En prisstege är exakt ett val."); the money column changes prefix AND label per mode (+kr/ Tillägg vs the plain symbol / Pris,aria-label+titleon the input); a ladder shows "Grundpriset kr100.00 används inte för den här gruppen."; andmin="0"was REMOVED from the add-on field, because a negative add-on ("utan lök, −5 kr") is supported by the projection and by the kernel and this editor was the only thing forbidding it. The guest side is closed in the same change:lib/menu/price-quote.ts— the canonical quote path behind pos/orders, pos/quote, training receipts and the voice concierge — substitutes the ladder price on the LINE and records the chosen size withpriceAdjustment: 0, so a Familjepizza quotes 250, sauces still ride as deltas on top (250 + 15 + 14.99 = 279,99), a malformed rung is ignored rather than invented, and weight still beats the ladder (the scale is the legal price). The kernel is not touched at all — a ladder is a SUBSTITUTION where the line is BUILT, exactly like a service-offer band price, so the rounding step count, the wire shape andcorpus.jsonare unchanged. - THE WARNING THAT WOULD HAVE CAUGHT IT — an amber banner in the group header with the mistake spelled out in money (2026-08-12). In an add-on group that is a single FORCED choice with at least two options, where EVERY amount is at or above the item's effective price (campaign price if one is running, else the base), the editor says: "Det här ser ut som hela priser, inte tillägg. Som tillägg skulle Familjepizza slå in på kr350.00 i kassan. Gör om till prisstege?" — with a one-click Gör om till prisstege that changes the MODE and applies the shape lock and does not touch a single number. The signal is deliberately narrow so it can never nag a genuine upsell menu: one option below the base price, an optional or multi-select group, a single option, or an unpriced item all silence it (each pinned by a test).
Fixed
- AN OPTION GROUP CANNOT BE SAVED WITHOUT A NAME ANY MORE — least of all a required one (2026-08-12). Both groups on the owner's only pizza were stored as
name: { en: "" }, which the POS projection shipped to the tills as the empty string — a required group whose heading is blank is a question nobody can read.Sparais now gated by one shared validator,ui/src/lib/menu/option-group-validate.ts(validateOptionGroups), and THE BROWSER IS NOT THE RULE: the same function is exported for the two menu write routes to run before any blob write (400OPTION_GROUP_INVALID). Rules: R1 a group with options needs a non-blank TRIMMED name in at least one language (the editor is bilingual, so a filledsvwith an emptyenpasses — and an empty group still being built is never nagged); R2 the same on a group that is required / min ≥ 1 / a ladder, kept as its own code so relaxing R1 can never silently relax the required case; R3 at most one ladder per product; R4 a ladder is exactly one choice; R5 every ladder option > 0 (a negative ADD-ON stays legal); R6 no ladder on a by-weight product; R7 no ladder on a product with a running campaign ("Avsluta kampanjen först…"). The offending group gets a red border and an inline message, the Alternativ badge turns red, and the footer hint names the first violation. Save also TRIMS every group and option name in every language, which fixes a live'Smal 'that has been shipping to tills with its trailing space. - ONE CREATE CANNOT BECOME TWO PRODUCTS FROM THIS DIALOG ANY MORE (2026-08-12). The owner created one dish and got two — and the till, which nothing had ever asked to re-project, still showed two after one was deleted. The editor's half of that:
Sparawas disabled by!canSavealone,onSaveis typed=> voidso nothing awaited it, the page'ssavingflag was never passed in, and the dialog closed only infinally— so a second click on a slow network submitted the SAME payload twice (proved by the two mirror products carrying byte-identical browser-mintedgrp_/opt_ids). NowProductEditorModaltakessaving, its button isdisabled={!canSave || saving}with a spinner and Sparar…, and a NON-OK response keeps the dialog OPEN with the server's message in the footer instead of silently discarding the owner's unsaved edit. A button state is not the whole fix, so the 409 predicate is written and tested too (ui/src/lib/menu/duplicate-product.ts: same category + same trimmed case-insensitive name + same price, compared in minor units) for the write route to refuse the duplicate that a second tab or a slow network will always produce. - "BETALA" IS A FULL-SCREEN PAYMENT VIEW NOW — `Betalning`, ~90% of the screen, the ORDER on the left and the money on the right, on all three surfaces (2026-08-12). Owner ask, verbatim: "see example for 'betala' a new UI/UX a big popup maybe covers 85%-90% of the screen maybe". IT IS A NEW SHELL, NOT A NEW PAYMENT ENGINE, and nothing inside the money path moved: the receipt number, the fiscal decision, the training-mode refusal, the gift-card precheck, the outbox envelope, the öre rounding, the terminal authorization and the split remainder are all exactly where they were (
PaymentFlow.tsx/PaymentState.kt). Every figure on the screen is one the engine already rendered —Delsummais the per-rate net the kernel's own receipt renderer prints underNetto, and theMomsrows are the engine'svatBreakdownverbatim, one row per RATE the basket actually resolved to, so a bill carrying 12% dine-in food beside 6% takeaway food shows two rows and never one. The left column reuses the sameTicketLineRow/PosTicketLinethe ticket panel draws, with the sametileTintdot keyed on the same product and the samelineKitchenStatusderivation off the server's own fired ledger, soSkickad 14:05cannot say one thing on the ticket and another on the screen that takes the money. The chooser replaces a column of five tender buttons with `Kort | Swish | Kontant` in front of the amount plus one disclosure for the two exceptional tenders (Presentkort / annat), and every chip arms a door that already existed: card runs the authorization the tender step always ran, cash opens the keypad (the öre rounding and the change live there), and Swish opens its confirmation — record-only means the confirmation is not skippable, because a primary that booked it would record money on the word of whoever pressed a button. ThecardSTEP was retired on both surfaces: it existed only to name the two card doors honestly, the chooser now does that in front of the amount, and the missing-terminal sentence is printed only when a reader is actually missing (with the external door opening itself). `Avbryt betalning` returns to the ticket with every line intact — a purepaymentCancelledtransition that hands back the same lines array, so the promise is a test rather than a comment (payment-view.test.ts, plus a Compose UI test that mounts the tree, clicks the button and asserts the basket behind it survived); it is refused while a settle is in flight (the receipt number may be spent and the sale posting) and hard-refused while an authorization is unresolved, and it WARNS before dropping committed split parts because one of them may be a card the acquirer already authorized. Swedish + English, both surfaces, from the same key set. - THE PAYMENT VIEW'S RED BOX SAYS SOMETHING TRUE, and it was verified against the code before it was written (2026-08-12). "Töm nota finns under Fler och kräver bekräftelse. Skickade köksartiklar får inte försvinna utan spårbar åtgärd: en nota som köket har fått ägs av servern och kan inte tömmas alls, och rader tas bort en i taget och stryks på serverns nota." Every clause was checked: the clear is under
Fleron both tills and does ask (window.confirmon pos/web, a confirm dialog on the Compose tills); and firing food requires a SERVER ticket (sendKitchenRefusal(hasServerTicket=false) → 'no_bill',canSendToKitchen = ticket.serverTicketId != null && …), so a bill the kitchen has is server-owned andresetBlockedReason/TicketClearRule.refusalrefuse the clear outright rather than merely confirming it — the promise the code keeps is stronger than the one the mockup asked for. The box is drawn only about a bill whose food the kitchen actually has; on a bill nothing was fired from the sentence would be true and about somebody else's order, which is how an operator learns to read past a warning. - THREE THINGS IN THE PAYMENT MOCKUP DO NOT EXIST, AND THEY ARE INERT WITH THE TICKET PANEL'S OWN REASONS (2026-08-12).
Rabatt(no till door and no capability —discount_overrideis a declared permission nothing reads),Intern anteckning(a ticket document has no note field at all) andDela nota(nothing anywhere divides one bill into several notas). The sentences are reused verbatim fromi18n/ticket-panel.ts/RegisterStringsrather than reworded, so one refusal wears one wording wherever an operator meets it — and they are printed under the controls, never in a tooltip a tablet cannot show.Dela betalningis real (split_tender) and is one tap from the amount. The mockup's− qty +and delete are deliberately not wired: the settle prices the basket the view was handed when it opened, so an editable stepper would charge the old amount for the new bill, possibly with a card already authorized against the previous figure — the column is read-only, says so, and the edit is one tap away. - THE PAYMENT VIEW STACKS AT PHONE WIDTH INSTEAD OF CLIPPING, and the first cut of that genuinely did not (2026-08-12). Below the threshold the two columns become one with the money first — on the smallest surface the operator must not have to scroll a bill they have just come from to reach the primary. Measured against the SHEET (a container query on the web,
BoxWithConstraintson Compose), never the viewport: the same component is a 1600dp station and a 360px phone. The first attempt kept CSS grid for the stack, and at 360px Chrome sized the twoautorows to 303px and 292px of the available 606px — a grid's defaultalign-contentbehaves as stretch, and stretch applies to auto ROWS — so each column got half the sheet while its real content was 765px and 942px tall, and with the inner scroll wells switched tovisiblethe money column's e-mail checkbox and split explainer painted straight through the order column's dish rows. Found by screenshot, not by reading. The stack is a column flex box now;align-content: startalone did not lift it. The big sheet also statesbox-sizing: border-boxexplicitly, because amin(96vw, …)width plus the sheet's padding is an overflow, and the last padded box that met a width with no box-sizing on this stylesheet family had 22px eaten off the right edge of every receipt row — where the MONEY column is. - DISCOUNTS EXIST NOW — `RABATT 5/10/15/custom %` AND CODES LIKE `NEWYEAR2027`, LIMITED TO DAYS AND TO A USAGE COUNT (2026-08-12). BACKEND ONLY: the routes, the storage, the validation and the tests. No till surface calls them yet. Owner requirement, verbatim: "we forgot to add 'discount' buttons 5%, 10%, 15% custom discount %, discount code like NEWYEAR2027 limited to days, limited to quantity like first 100 usage etc.." — which is two features, and keeping them apart matters because one is a keypress by a trusted operator and the other is a guest-facing campaign with server-held state. THE KERNEL WAS NOT TOUCHED, AND THAT IS THE DESIGN. The money engine already prices six promotion types deterministically and identically on every surface, and a code is caller policy, never kernel surgery: a discount becomes an ad-hoc
PromotionDefhanded toevaluatePromotions, and not one line of this change computes money. A manual percentage is `percent_off_line`, deliberately not `basket_threshold`, for three reasons that are all money rather than taste: (1) THE BASE —basket_thresholdtakes its cut ofΣ remaining + Σ deposits, so "10% off" a crate of bottles would be 10% of a number that includes the venue's own pant liability (asserted: 6 × (12,00 + 2,00 pant) at 10% discounts 7,20, not 8,40); (2) THE ALLOCATION — one truncated percentage PER LINE means each VAT group's taxable base shrinks by exactly its own percentage, where a basket figure split largest-remainder can hand a stray öre to a line in the other rate bucket; (3) THE PARAMETER —basket_thresholdrefuses to fire without aminBasketMinor, so a discount with no minimum spend would have to invent a fake 1-öre threshold, a lie in stored data that also declines to fire on a bill pricing to zero. A coded campaign may still bebasket_threshold, because "100 kr off over 500 kr" is a threshold the owner meant. `discount_override` HAS BEEN A DECLARED OPERATOR PERMISSION SINCE PHASE 5 AND NOTHING HAS EVER READ IT — this is the reader, so a discount always has a name against it. Owner and manager pass regardless of the permission list, and that is deliberate rather than lax: because nothing ever read the grant, no operator record in any live tenant was curated for it, so requiring the permission alone would lock every owner at all four pilot venues out of the button the owner just asked for — and the "fix" would be a bulk write acrosspos_operatorsin live tenants. Identical reasoning tomayEditCatalog. THE CUSTOM PERCENTAGE IS CAPPED AT 50%, and the refusal says so in words instead of clamping silently: at 100% nothing is being discounted, the food is being given away, and a give-away is a comp — which belongs on the void path that already carries reason codes and an audit stamp. Anything in between is a manager decision this platform can express the day the existingpos_approvalsmachinery is wired to it (ApprovalReason.DISCOUNT_OVERRIDEis already defined and still unread). ONE manual discount per bill — re-applying replaces it, because two operators each pressing 10% must not silently produce 19% and "the discount on this bill" has to be one answerable thing for the Z-report. A DISCOUNT IS REFUSED ON A BILL THAT IS NO LONGER OPEN, with its own code and its own sentence: rates and totals are pinned at sale time, so a discount applied afterwards would rewrite a receipt the guest is holding; a parked bill is told to resume, because there is exactly one editable holder of a bill at a time. THE THREE TRAPS, each of which yields a wrong NUMBER rather than an error. (1) The venue's clock.days_of_weekis read by the engine off the UTC fields of the sale instant, and 00:30 on a Saturday in Stockholm is 23:30 on FRIDAY in UTC — so a weekend code judged in UTC is live all Friday evening and dead in the small hours of Saturday. Each campaign stores its own IANA zone (validated by using it, never against a hardcoded list) and its window is evaluated through the one venue-clock evaluator,isRuleOpenAt, failing closed on an unreadable zone because a discount is a thing that costs the venue money. The kernel is untouched; instead the approved definition carries no `TimeWindow` at all, which is the subtle half — a window riding along would let the engine re-read it in UTC and silently drop a discount the server had already approved, between the quote and the charge. A redemption is decided once, in the venue's zone, and pinned for the life of the bill including one parked overnight. Pinned at the exact instant the trap fires, plus the summer case at UTC+2 so nobody replacesIntlwith an offset table. (2) The usage cap cannot live in the kernel — the engine is pure and "first 100" is server state. Claimed by an atomic$incwhose filter carries the cap as a field-to-field$exprcomparison, so the check and the increment are one indivisible act: the same shape as theorder_countersfix that replacedString(1001 + countDocuments)after two concurrent reads minted one order number twice.matchedCount0 is an honest refusal, not an error. The ordering is the rest of the answer: everything judgeable from stored data first, then the redemption ROW (whose partial unique indexes ARE the single-use-per-guest check, before the cap on purpose so the common refusal never writes to the hot campaign document), and last the claim, because it is the only irreversible act.sessionis a required parameter ofredeem, not an optional one — so "what happens when validation fails after the increment" is nothing is compensated, because nothing was committed: claim, row and ticket write share one transaction and a losing CAS aborts all three. A compensating write was rejected as the primary mechanism because a compensation can itself fail; the only compensating path is the deliberate release of a mis-keyed code, guarded twice so a stray release cannot make room for a 101st discount. Two tills redeeming the hundredth code concurrently: exactly one wins, count lands on 100. (3) A discount now crosses TWO VAT RATES. On a real mixed-motion bill — 145,00 dine-in food at 120‰ beside 129,00 takeaway food at 60‰ — 10% off moves 1554 → 1398 in the 12% bucket and 730 → 657 in the 6% bucket, total 27400 → 24660, and Σ group grosses still equals the total to the öre. Both buckets, not one. FOUND AND FIXED WHILE IN HERE: a refund of a discounted bill was going to hand back the full gross.refundTicketreprices negated lines against today's promotions, where a ticket-carried discount would not have been; the engine is odd-symmetric, so passing the original ticket's own definitions makes the refund return exactly what the guest paid (−24660, both buckets reversed). Also: a campaign's TYPE could never be changed (the old money fields survived the patch and the coherence check then correctly refused them, with no way to clear them) — the update now$unsets the fields the new type ignores, and stripsundefinedfrom$setbecause the driver writes it asnulland Mongo rejects one path named in both operators. OFFLINE: refuse the CODE, never the SALE. Single-use and "first 100" are server facts and a redemption replayed an hour later cannot be refused after the guest has left, so there is no queued form of one —SYNC_OP_TYPESstays a closed six-entry list, now pinned by a test so adding a discount op requires changing that case. A disconnected till still closes the bill at full price. No POS capability was minted (sell+ the operator permission): a fresh capability needs a preset grant plus a backfill, and skipping that has twice removed a shipped feature from new tenants. Storage is two new pos-native collections rather thanpos_promotions, because everything in that collection is fed to every basket bylistActivePromotions— a code-gated campaign parked there would discount the whole restaurant and the code would be decoration. New:backend/src/modules/pos/discounts/(the six-file module),sales/ticket-discounts.ts(the pure definition builder and the cap), and four specs — the mixed-motion VAT arithmetic, the campaign rules, the routes, and the offline op surface. Full record of what shipped vs what the spec said:docs/pos-rebuild/kampanjkod-design.md. - THE TICKET PANEL CHANGES SHAPE WITH THE ORDER TYPE, AND EVERY LINE SAYS WHETHER THE KITCHEN HAS IT (2026-08-12). Owner mockups, two of them: a SERVERING panel (table heading, per-line kitchen status, "Skicka 1 ny artikel till köket", then Betala / Dela betalning / Dela nota) and an AVHÄMTNING panel (a
På plats | Telefonsecond level, no table, no per-line status, then Betala & skicka → Skicka till köket – betala senare → Parkera köp). Plus the correction to the day's earlier work: `Välj bord` and `Kundnamn` share ONE ROW now — the first cut stacked them and the owner's complaint was space, on the narrowest column of the till. The shape is decided in ONE place per surface and rendered from that one answer by all four mounts (pos/web/src/screens/ticket-shape.ts+TicketShape.kt, twins, tested to the same table of cases): the Compose wide station and its compact phone sheet, the web ticket column and its optional side panel. A CTA list per shape, not three copy-pasted feet — the last control spelled out per-mount shipped on a tablet and not on a phone. THE PER-LINE STAMP IS A REAL ONE, AND THE LEDGER GAINED THE ONE FIELD IT WAS MISSING.Skickad 14:05needs a time, and the fire time was recorded nowhere a till could reach:kitchenFiredLineIdsis a barestring[],KitchenTicketDoc.firedAtis per BATCH in a collection no register reads, and the all-no-prep fire writes no kitchen ticket at all — so those rows had no recoverable time even in principle.markLinesFirednow stampsTicketDoc.kitchenFiredAt(lineId→ ISO) in the same statement, in the same transaction, by the same and only writer, so "did it fire" and "when" can never describe two different moments;fire-kitchenreturns that same instant so the till never guesses one off its own clock; and the whole map already reaches every till throughtoView, so a resumed bill's rows say the hour the food actually left whichever till sent it. It is not a second notion of "sent" — the SET is still the only answer to whether a line has fired, and a stamp without a set entry can never make a rung dish read as cooking (pinned by test on both surfaces). Rendered throughfmtClock/PosClock, never a substring: the stamps are UTC and a substring prints 12:05 on food that left the pass at 14:05 in Stockholm. A row fired by an older build saysSkickadwith no hour, which is true. `AUTOSPARAD` IS DRAWN ONLY WHERE IT IS TRUE, AND NO AUTOSAVE LOOP WAS BUILT. The badge is a claim about the SERVER: it appears exactly when the server already holds this bill (a parked bill picked up, a bill taken back). On a fresh basket with a table merely ARMED it is absent — that binding is local, nothing on the server has the lines, and badging that basket "saved" is how bills get abandoned: the §3d incident class, four real ones. Minting a ticket behind the operator's back to make the badge true was refused as an owner decision, not a side effect of a badge (showsAutosaved, with the reasoning in the source). `Parkera köp` is a real, pre-existing wire capability, not a second park:ParkTicketDto.fireToKitchen: false— "the bar-tab shape where nothing should cook yet" — which no till had ever sent. It is refused on a bill the server owns, out loud, becauseParkExistingTicketDtohas no such field and a re-park always fires. `Betala & skicka` and `Skicka till köket – betala senare` say so only where it is true: both depend onkitchen_routingAND the register's own auto-send toggle, the two gates the server itself applies, so a till with sending switched off shows the plain words and offers no send-and-park at all. What already existed vs what is honestly dead:Dela betalningEXISTS (PaymentFlow's own split step,split_tender) and is now one tap from the ticket via a newsplitpayment entry on both surfaces;Rabatt,Intern anteckningandDela notaexist NOWHERE in this product — no route, no service method, no capability, no screen — so each is drawn INERT with the reason printed beside it rather than omitted or wired to nothing.Fleris a container, not a new function: it holds the actions that used to sit in a second foot row, and two of them (Kvittokopia, Öppna kassalåda) were previously reachable only from the optional side panel that is off by default. The mockup's `3 gäster` was NOT built and was not faked — a party size is recorded nowhere on a POS ticket, and reading the nearest booking's guest count would be a guess about who is sitting there, so the sub-line states the table's own capacity in its own words ("4 platser"). Totals are the engine's:Delsummais the per-rate net through the samenet = gross − vatthe kernel's receipt renderer prints underNetto, and theMomsrows arevatBreakdownverbatim — one row per rate the basket actually resolved to, never the mockup's hardcoded 12%, because per-line motion means takeaway food is 6% today. Singular/plural is the string tables' own business in both languages (1 ny artikelvs2 nya artiklar), tested. New:ticket-shape.test.ts(17) andTicketShapeTest.kt(14). - Fixed while screenshotting the new rows: every receipt row was 22px wider than the panel holding it, and the money was the part that got cut.
.pos-ticket__linehadwidth: 100%plus padding and a border with no `box-sizing` (the sheet has no global reset), so a 340px ticket drew a 362px row and.pos-ticket__swipe'soverflow: hiddenquietly ate the difference off the right edge — where the reserved amount column lives. Measured, not guessed:278,00rendered as278,0. Alsogrid-template-columns: 1frfloored at min-content, so a long dish name held the first column open and pushed the amount out; it isminmax(0, 1fr)now, and below a 460px container width (the panel, not the viewport — the same ticket is 486px on a station and full-width on a phone) the row stacks so the controls take their own line. Money is never truncated; a name wraps. - "SKICKA TILL KÖK" IS A BUTTON ON KASSA NOW, AND THE TABLE MOVED UP BESIDE THE CUSTOMER NAME (2026-08-12). Owner requirement, verbatim: "on PoS - can you also add a button 'send to kitchen' on 'kassa', move 'välj bord' to near 'kund namn' and instead add 'send to kitchen' beside 'Parkera nota'". The route already existed and no till had ever called it.
POST /pos/tickets/:ticketId/fire-kitchenshipped with Phase 10b — its own comment has read "Skicka till kök" — fire a table ticket's unsent food lines ever since — but there was no caller anywhere inpos/app, and the only web caller was the Tables flow. So food reached a kitchen on exactly three exits, every one of which also did something ELSE: a paid sale's auto-fire, an atomic park, and a re-park. The consequence is the owner's own earlier scenario turned inside out — a course added to a bill the guests are already eating from could only be sent by parking the bill again, which takes it off the screen the operator is working on and out of their hands mid-service. This is that door, and it keeps the bill. A press syncs the basket's delta onto the server ticket (the re-park's own order: the fire sends the rows the SERVER holds, and the kebab rung ten seconds ago is not one of them), then fires, then reports what happened. THE CAPABILITY DECISION, and it is a deviation worth reading. Widening the route fromkitchen_routingtosellwas specified, considered, and REFUSED — the premise it rested on is false for this capability.kitchen_routingis labelled Köksbongar in the dashboard (ui/src/lib/pos/register-view.ts) and its catalogue entry reads "send lines to kitchen/KDS stations" (pos/shared/domain-types/src/capability.ts): it is the ROUTING grant, not a claim that a screen exists. Both therestaurantandquick_servicepresets grant it (VERTICAL_CAPABILITIESinintegration.service.ts, anddevice-surfaces.ts), and the bong is PRINTED FROM the kitchen ticket this route creates — so paper is a subset ofkitchen_routing, never an alternative to it, and a paper-only venue's selling till holds it. Decisive: all three existing fire doors are gated on this same capability, so a manual button onsellwould become the one door that bypasses the venue's own Köksbongar switch — andKitchenDelivery(registers.interface.ts) already assigns the three refusals their own remedies, documentingauto_send_offas the state where "lines go to the kitchen only when somebody presses Skicka till kök" (this button) whilenot_grantedis a dashboard fix andoff_at_registeris the till's own Funktioner switch. A button must overrule neither. No 403 ever reaches an operator: all three tills draw the control only when the session's EFFECTIVE capability set holdskitchen_routing, exactly as Parkera is drawn only onpark_bills. The route's authorization is therefore unchanged (@RequiresOperator+@RequiresCapability('kitchen_routing')), the online-order ingest gate (hasKitchenRegister) was not touched, and no capability was minted — the trap that removed Parkera nota from new tenants twice. A BILL THE SERVER OWNS, deliberately — and the one thing this does not do.fire-kitchenfires a TICKET, and a fresh counter basket is not one yet; minting one on press was refused because it would turn the busiest screen's basket server-owned behind the operator's back (Rensa/Töm kvittot and Välj bord both go inert on that fact) and an open ticket is recoverable only from Parkerade's stranded-open list — the §3d incident class, four real bills. The counter basket already has two exits that fire (Betala, Parkera), so on it the control is drawn INERT with the sentence that names them; what had no door at all was the bill in the operator's hands, and that is what this is. THE COOKED-TWICE HAZARD, closed. The server MINTS alineIdfor every row a sync hands it, andsyncLines/syncResumedTicketthrew that answer away — harmless while every caller emptied the basket straight after, fatal for one that keeps it: the next diff would find no match for any row, void every line the kitchen is cooking, and re-add them as brand-new work, becausekitchenFiredLineIdsnames the dead ids. Both tills now read thelinethe add route has always echoed (AddLineResult.line), return it as aSyncedTicket { rev, serverLines, serverLineIds }, and re-key the basket — only the identity moves, so an offer snapshot, a recorded age check and a pinned rate all stay exactly as the operator rang them (a rebuild from the server document would have droppedserviceOffer). "Is there anything to send" reuses the existing notion of unfired lines —FOOD_BEVrows absent from the ticket's ownkitchenFiredLineIds, now transcribed onto the wire types that already carried it (ResumedTicketDto,ParkedTicketDto,OpenTicketDto,ResumedTicket) — so nothing invents a second ledger and the control is live exactly when a press would do something. It reports the outcome, always, including zero: "1 rad skickad till köket", "3 rader skickade till köket · 1 rad behövde ingen tillagning", "Inget nytt att tillaga skickades" — rows that routed nowhere or need no preparation are counted apart from food a cook is making, because the server marks them fired too and the operator must not stand waiting for them. All three surfaces. ComposecommonMain(Android + Windows): the table control moved into the ticket header directly under the customer-name field, refusal sentence and all, andTicketPanel's paired foot row is now Skicka till kök + Parkera nota — the 2026-08-07 comment pairing table+park is rewritten rather than overwritten, recording both decisions and why the new cut is the honest one (table and name are ATTRIBUTES of the bill; sending and parking are both DISPATCH).pos/webmirrors it in both mounts of the foot (the ticket column and the optional side panel), with the picker moved up beside the name field and the release control kept beside it. Bilingual on every surface (sendToKitchenActionplus four sentences in the Sv/En register strings; a typed pair in the web till'sregisterText), with "Skicka till kök" taken verbatim from the endpoint comment so three surfaces repeat one phrase. Outcome copy renders on the register's own notice stack in the brand Info tone — never Danger, which is what every other bill notice is — and is dismissed by hand, never on a timer. New tests:backend/src/modules/pos/sales/manual-fire-kitchen.spec.ts(8 cases — the gate iskitchen_routingand the bong alarm is stillsell, a quick-service bill with no table fires,autoSendToKitchen: falsestill fires, the delta is the delta, and a non-food bill creates no kitchen ticket and never consults the ingest gate),SendToKitchenTest.kt(10) andpos/web/src/screens/send-to-kitchen.test.ts(11, against a new pure module so the shipped decision is the tested one). - ONE GUEST, ONE BILL, TWO FULFILMENTS — and the correct VAT on each (2026-08-11). Owner requirement, verbatim: "if a customer comes and eat on the restaurant a pizza, then while he was eating and he ask to the restaurant to make the kebab plate so he can take with him to home. so when he will pay the both items together can be in same order." This is a money and tax fix, not a UX tweak:
pos/core/src/tax.rsresolves the SE food rate from the line's MOTION, and the two rates differ today — food SERVED is 120‰ with no end date, while foodstuffs taken away dropped to 60‰ on 2026-04-01 (provisional through 2027-12-31, note P13). A till that could state only ONE fulfilment for the whole ticket therefore charged one of those two lines the wrong VAT, by a factor of two, on every mixed bill. The kernel and the wire always supported it (TicketLine.motionsits besidetax_rate_permille;AddLineDto.motionis per line and reaches the kernel unchanged) — what was missing was any way to CHANGE a line's fulfilment once it was in the basket, because this module has no update-line endpoint at all. New `POST /pos/tickets/:ticketId/line-motion` (SetLineMotionDto→SalesService.setLineMotion), which re-resolves the rate through the kernel's own rule and re-pins it on that one line. Re-resolving a pinned rate is lawful only on a still-OPEN ticket —taxRatePermilleis documented as pinned at sale time so a closed ticket cannot move when the rate table does — so a closed, voided or parked ticket is refused loudly with its own code (TICKET_NOT_OPEN, and the parked case says to resume first) rather than through the generic status gate, because "the rate on a registered sale moved" is a fiscal defect and not a state mismatch. Only the MOTION dimension is re-resolved:taxCategoryIdstays as it was pinned when the line was rung, so a menu edit since cannot silently re-classify a dish the guest has been quoted, and nothing computes a rate, a VAT figure or a total by hand. THE ALREADY-FIRED RULE, stated out loud: a line the kitchen has never seen changes freely; a FIRED line is work a cook may be holding, so it is refusedLINE_ALREADY_FIREDuntil the operator confirms (force: true, the same idiom asDiscardTicketDto.force), and a confirmed change re-tags the matching kitchen line(s) in the same transaction (thevoidLinepattern, so a till saying AVHÄMTNING over a station screen still saying SERVERING cannot exist) while deliberately printing no paper — a bong already on a rail cannot be recalled and an automatic second one is how a dish gets cooked twice, sokitchenUpdatedis reported and the till tells the operator to speak to the pass. Transactional and re-reading in-session forvoidLine's reason:markLinesFiredbumps no rev, so the{rev, status}pin cannot see afireKitchenthat commits mid-flight. Deliberately not a void-and-re-add, which is what a motion change would otherwise have to be on a server bill: on a dish the kitchen already has, that strikes it off every board and enqueues a brand-new one, so a pizza the cook is holding reads as cancelled and re-ordered. Per-line `delivery` is refused (LINE_MOTION_VALUES = ['dine_in','takeaway']) — a courier collects a whole order — and no capability was minted:sell, which every selling till already holds, because a fresh one needs a preset grant plus a backfill. All three tills. ComposecommonMain(Android + Windows):PosSwipeToRevealgrew a START-edge strip (PosSwipeSide), sharing the axis lock, the 8dp floor — nottouchSlop, which is ~0.1px for a mouse and would cancel clicks on Windows — the tween, the rubber band and the one settle rule with the left swipe, so a right swipe on a ticket row uncovers the fulfilment choice while left keeps the delete; nesting a second wrapper would have put two pointer handlers on one row to fight over the same drag.PosTicketLinegrew a pressable fulfilment chip showing the row's own word — always drawn, unlike the hover-revealed trash, because it carries information as well as an action and because it is the non-drag path on a mouse-driven Windows till.pos/webmirrors both inTicketPanel.tsx(SwipeSide, a start strip, the same chip) with the tokens intheme.css. Bilingual on every surface (chooseFulfilment*/fulfilment*in Sv+En register strings; a typedregisterTextpair on the web till), and adesktopTestreadsLINE_MOTION_VALUESout of the backend source so the sheet cannot offer a fulfilment the server refuses. The bong tags each line — one bong per fire, not two, because it is one guest and one bill and the pass must see it together:buildKitchenTicketSegmentsprints1 x Kebabtallrik [AVHÄMTNING]beside the name, only when the order carries more than one distinct fulfilment (judged over the WHOLE order, never the station-filtered subset, so a mixed order split across two stations keeps the tag on the bong of the cook who has to find a box). An unmixed bong is byte-identical — thePINNED.kitchen/PINNED.kitchenNoStationdigests were re-derived by rendering the pin's own fixture and reading the output, and came back unchanged, which is the evidence a pin exists to produce.motiontravels to paper on both till paths (TillKitchenFireLine/TillFireLinefor a fire,TillPrintLine/TillSaleLinefor the atomic counter sale) and is additive onOrderItem; the RECEIPT is untouched because a mixed bill's money already states it —priceBasketgroups VAT per rate, so 12% and 6% print as two rows (SKVFS 2014:9 7 kap. 1 § j), verified end to end. New tests:backend/.../line-motion.spec.ts(17 cases, including the money one — 145,00 dine-in + 129,00 takeaway = 274,00 with 15,54 VAT at 12% and 7,30 at 6%, against 13,82 had the takeaway line been taxed as service), the mixed/unmixed bong cases inkitchen-bong.test.tsandtill-print.test.ts, andLineFulfilmentTest.kt. - A kitchen bong that fails to print now ALARMS on the till, with one-tap reprint (2026-08-11). Decision 2 of
docs/pos-rebuild/kitchen-fulfilment-design.md, promised on 2026-08-11 and unbuildable until that same day added the mechanism (DeviceJob.kitchenTicketId). A restaurant may run paper as its only kitchen surface — the owner's small pizza shop that takes the order, gets a bong, makes the pizza — and for that venue a bong that never printed is a lost order with no screen to fall back to. Until now its only trace was a red row in a dashboard job log nobody at the counter is looking at. Terminal failures only:kind === 'kitchen-ticket'+status === 'failed'+ notmock+ not acknowledged —'failed'is set only once a job is out ofMAX_JOB_ATTEMPTS, so a job still retrying is not yet an alarm, and a MOCK (unclaimed) device is excluded for the reasonprinter-health.tsalready states: storing its job IS its execution, so it cannot fail and carries no signal about hardware. Naming the order was the hard part, because three producers carry three different handles: a till FIRE (kitchenTicketIdset,orderIda synthetictill_fire_<id>that resolves to no row), a till COUNTER SALE (till_<receiptNumber>, and no ticket id at all — a venue with nokitchen_routingregister mints nopos_kitchen_ticketsdocument for a counter sale, which is precisely the paper-only case), and a legacy ORDER (the only family that can also be asked for its table).orderNumberis already the human name in all three, so the row reads off that and reports the FAMILY rather than inventing a table the job never carried. Reprint re-plans through whichever planner owns that bong's lines, and the SERVER picks so a till cannot ask for a weaker path: a fire goes through the existingreprintKitchenTicket→planTillFireJobs(reprint: true— stored kitchen lines, voided ones filtered, and it refuses a cancelled or training ticket, a check nothing on the legacy side can make); a legacy order throughplanKitchenTicketJobs, the same call the KDS board's Skriv ut makes, markedOMTRYCK / REPRINTon the note row and filtered to the printer that lost it (the others printed their share; a second copy on a rail that is not missing one is a dish cooked twice); a counter sale re-queues the failed job's own rendered bytes through the samenewDeviceJobproducer — byte-identical is the strongest possible "cannot diverge", and nothing printed, so it is the FIRST paper rather than a duplicate. A double press queues a SECOND bong, deliberately, and is not deduplicated — a human pressing again is saying the paper still has not appeared, and the design's rule is that a human asking for paper always wins: one extra bong costs a cook a glance, a suppressed one costs the guest their dinner.bongReprintsmakes it visible ("Omtryck skickat 2 ggr") instead of silent. Reprint does NOT acknowledge, because paper being queued is not paper being seen and a printer that failed three times can fail a fourth; only Kvittera removes the row, it is a server write (a local dismiss would clear the alarm on one till and leave it standing on the next), and it is terminal. Three additive `DeviceJob` fields (bongAckedAt,bongReprintedAt,bongReprints), all derivation pure and unit-tested in the newlib/devices/kitchen-bong-alarm.ts(19 tests), reusingprinter-health.tsfor the per-device failure count rather than counting the same thing twice. Transport is the internal channel the tills already use for print settings:GET/POST /api/internal/pos/kitchen-bong-failures(opsack|reprint|stamp) behindx-internal-secret, proxied by the newKitchenBongAlarmBridgetoGET /pos/kitchen/bong-failures+…/:jobId/ack+…/:jobId/reprint. Those three routes are gated on `sell`, never `kitchen_routing` — a paper-only venue holds no register with that capability, which is exactly what makes it paper-only, so gating on it would hide the alarm from the one venue it was built for — and carry no `@RequiresOperator()`, same posture as the board's Skriv ut: the person looking at a printer that produced nothing is whoever is standing there. No capability minted (no preset grant, no backfill — the trap that removed Parkera nota from new tenants twice), and nothing in the kernel, the receipt series, the fiscal signer, tender rows or stock is touched: a reprint is paper, not a sale. Unlike the print bridge next to it the alarm bridge throws rather than swallowing — an empty banner on a till whose kitchen was never told is the exact silence this feature ends, so a failed read renders "Kan inte kontrollera köksbongarna just nu" and only a 403 (a paired kitchen display with no counter) stays quiet. All three tills: ComposecommonMain(data/BongAlarmRepository.kt+screens/BongAlarmBanner.kt, placed inPosApp.kt's shell column underTillTopBarand above the rail so it shows on every destination, 15 s poll, one chime per NEW failure through the existing order-alert plumbing, bilingual viaBongAlarmStrings/SvBongAlarmStrings/EnBongAlarmStrings, clocks throughPosClockand never a timestamp substring — the server stamps UTC and a substring prints 16:42 on a bong lost at 18:42 every summer) andpos/web(data/bong-alarm.ts+screens/BongAlarmBanner.tsxinShell.tsx, with a typed bilingual `i18n/bong-alarm.ts` and its own locale guard, deliberately not adding to the hardcoded-Swedish defect in the newer web panes). Colour is the design kit's DANGER family on both, never the amber warn family: the brand is azure, amber reads "heads up", and this reads "food is not being made". Neither surface blocks selling — no dialog, no overlay, no focus trap, and nothing rendered at all when there is nothing wrong. Still not told, and stated rather than designed around (full list in the design doc's new § The failed-bong alarm, as built): a bong that was never planned (no printer registered, paper channel off) produces zero jobs and so cannot alarm; a mock printer never alarms; a fire whose kitchen ticket has been purged is refused rather than falling through to the stored bytes, because only the ticket knows the order was cancelled; and the stored-bong reprint carries no OMTRYCK mark. - The kitchen DELIVERY POLICY — screen, paper, or both, per venue and per order source (2026-08-11). Owner requirement, verbatim: "even if the kds is switched off the order will still [be] made — think like a small pizza shop [that does] not have the KDS… then some restaurants do have KDS both digital and pap[er] (bong) flow active… that module should cover all thinkable scenarios out of the box." Until now one question had one answer for two different things: whether there is work to be done, and how the kitchen hears about it. `settings.kitchenDelivery` (deep-partial, resolved at read,
lib/orders/kitchen-delivery.ts) separates them. Precedence is per CHANNEL, not per object, sobySource.thirdPartyDelivery = { screen: false }still inherits paper — "delivery cooked and printed but off the board" is one toggle. The source vocabulary is the existingDisplaySourcekeyed byclassifyOrder(), so all 21Order.sourcevalues already map, the TABLE still decides counter-vs-waiter, and an unmapped source still fails open (and cannot be overridden). The policy never gates a write: a kitchen ticket is the venue's kitchen journal and the reason a guest has a queue number, so it is minted for cookable content whatever the policy says — only delivery routes are governed. Paper's venue-wide default was deliberately NOT moved: it stayssettings.printing.autoKitchenBong, the field the tills already write throughPOST /registers/print-settings, so that shipped three-surface switch keeps its full meaning (it moves the default every non-overridden source inherits) and needed no change on Web, Android or Windows. There is no `kitchenEnabled` — "does this venue make food" and "is the screen pipeline available" have opposite correct failure directions, so screen availability stays the register'skitchen_routingand paper availability stayshasKitchenPrinter, and a venue that wants neither channel simply turns both off. Defaults are derived, never backfilled: with the setting absent every source screens and papers iffautoKitchenBong !== false, i.e. exactly today. Enforced atdispatchHardwareForOrder(one paper decision for all four of its callers),planAcceptedKitchenBong, both till paper paths, and the legacy KDS board — where the screen filter deliberately never touches unaccepted orders, because that board is the only accept surface a paper-only venue has and filtering them would make a settings toggle un-accept the venue's orders. The till's own sale-print path keeps printing forautoKitchenBong === falsevenues (kitchenPaperForTillSale), including the table-bound sale that an earlier draft of this design would have silently silenced. Surfaces: a card at the top of Köksrouting (archetype picker Papper/Skärm/Båda/Ingen with a derived Blandat, the two channel masters, honest read-back for a missing printer or a till that reaches no screen, and a per-source table with an explicit Ärver state), mirrored read-only with a deep link in Devices → Köksskärm and in the Printers panel. Its ownPATCH /api/tenant/[tenantId]/pos/kitchen-deliverywith a deep merge, because the generic settings PATCH shallow-merges and would dropbySource. No capability minted — tenant settings behindsettings.edit. Known limitations recorded indocs/pos-rebuild/kitchen-fulfilment-design.md§ What actually shipped: paper has no deferral (a 19:00 pre-order prints at acceptance), the rebuilt NestJS KDS board does not honour the screen channel yet, and the promised till-side failed-bong alarm was not built (its missing mechanism,DeviceJob.kitchenTicketId, was added) — the alarm landed later the same day; see the entry above. - Tills can take a REAL Stripe test-mode card payment now, behind a switch that cannot lie about the fiscal signer (2026-08-11). Owner ask: "i was using stripe (test) to test the full flow so card payments can be applied. i want that also in PoS… i dont want to use mock but the real test flow so we can test everything without dead-ends". Before this the till's card tender was a mock —
PAYMENT_MODE=emulatoron the box unconditionally selected a random ~85%-approve simulator, and no Stripe code in the repo was reachable from a till at all. What landed: (1) `StripeCardProvider` behind the existing `PaymentProviderPort` (backend/src/modules/pos/payments/providers/stripe.provider.ts) — real test-mode PaymentIntents over Stripe's REST API, realpi_…references in the Stripe dashboard, real brand/last4 on the tender row, and nothing marked SANDBOX/EMULATOR because nothing is simulated. It refuses loudly when unconfigured, derives its mode from the key prefix (sk_test_→ test,sk_live_→ live, unrecognised → unconfigured), and refuses a LIVE key outright: it confirms Stripe test card tokens, so it is not a card-present rail and cannot become one by swapping a key. Non-card errors andrequires_action/processingthrow rather than reporting a decline — a decline is a promise that no money moved — and every message it surfaces passes through a redactor, because Stripe quotes a bad key back only partially redacted. (2) A new per-tenant go-live switch `cardRail: off | test | live` (defaultoff, platform documentpos_card_rail, super-admin only, audit row written in the same save) with its own section on/admin/dashboard/pos-operating-mode. It is deliberately a second document, not a third value on `pos_operating_mode` — that switch conflates the acquirer with the Swedish control unit, so "let me test card payments" would silently also have bought a fake kontrollkod, which is precisely thePOS_FISCAL_SE_MODEdefect this platform removed.POS_PAYMENT_PROVIDER=stripeis refused, the same doctrine that keeps the control unit's simulator out of its env-selected map: an env var that can switch on a money rail is a second door around the audit trail. (3) A Swedish capture is now refused BEFORE the card is charged when no control unit covers SE (409 CONTROL_UNIT_REQUIRED, till copy leading with "Kortet debiterades INTE") — without it a test-mode capture succeeded and the gated/closethen 409'd, charging a guest for a sale the platform will not receipt, which is the exact harm the market gate was moved in front of the capture to prevent. Refusal order, tested end to end: market → card rail → control unit → provider-config → provider; refunds stay ungated on every one of them. Flipping the rail tolivestill implies nothing about the fiscal signer, and Sweden still cannot complete a real sale without a certified kontrollenhet — the refusal now names the three honest alternatives instead of appearing to work. Full trace indocs/pos-rebuild/till-card-payments-design.md.
Fixed
- Every card sale ever taken on the web till was invisible to settlement matching (2026-08-11).
payCardFull()settled with noproviderRef,provider,cardBrandorcardLast4andSaleTenderPayloadhad no fields for them — even thoughPOST /payments/terminalreturns all four andTenderInputDtoalready accepts and persists them. The reconciliation engine reports any card tender with an emptyproviderRefasno_provider_ref: unmatchable against the acquirer's payout file, forever. All four now ride from the authorization onto the tender, and each split part carries its own acquirer answer (sharing one would point two tender rows at one settlement row). Same pass: the terminalreferenceis a realulid()instead ofpos_<Date.now()>— not merely undocumented, but non-unique across two tills tapping in the same millisecond, and this value is the acquirer's idempotency key on every retry of one physical payment. Still open: the Compose till (Android + Windows) has the identical defect one layer down — it parses all three fields and then drops them atLiveSaleGateway.kt:176, whereCardAuth.Approvedmaps onto a payload-lessTerminalOutcome.Approved. - `STRIPE_TEST_MODE` read as set and did nothing (2026-08-11). The code checked
=== '1'while.env.example,DEPLOY.md,knowledge/ui/modules/billing.mdandcheck-readiness.mjsall documented and emittedtrue— so every deployment that followed the documentation set a flag with no effect, and test mode worked only by the accident of ansk_test_key prefix. Both spellings (plusyes/on, trimmed, case-insensitive) are honoured now,check-readiness.mjsreads it as a flag rather than comparing it to"true", and a test fails if anyone reintroduces the single-literal comparison. A flag that reads as set and is not is worse than no flag.
Changed
- The till's payment-emulator env is `POS_PAYMENT_EMULATOR`, not `PAYMENT_MODE` (2026-08-11). The old name sat one letter from the web tier's
PAYMENTS_MODEand the infra audit repeatedly read the two as duplicated drift. They are not duplicates and must never be merged: this one selects the till's mock card reader inside NestJS, that one is the Next.js payment kill switch, and unifying them would couple a dev/QA convenience to a production kill switch across two runtimes. The rename ends the confusion;pos/payment-emulator/README.mdnow says so where a future audit will look. The old name still answers for one release (the staging box'sdocker.envcarries it) and logs one deprecation warning at first use;check-readiness.mjswarns on it, and onPOS_PAYMENT_EMULATORbeing set at all, since it overrides every tenant's sandbox flag and card rail.
Added
- A dead printer no longer looks identical to a working one (2026-08-11). We are about to let a restaurant run paper as its ONLY kitchen surface, and the Hardware & Devices panel (
PrintersPanel.tsx) could not tell an owner whether that paper was actually printing — the job log rendered kind/order/time but silently droppedstatus,attemptsanderror, all of which the API already returned unfiltered (GET .../hardware/jobsand.../hardware/deviceswere never the bottleneck; the fields simply weren't read). Three additions, all client-side, all sourced from data already fetched: (1) per-job outcome — every job in the log now carries a status pill (Utskriven/Köad/Skriver ut/Misslyckades/Förhandsvisning), the attempt count out ofMAX_JOB_ATTEMPTSwhile a retry is live, and the agent's error text under a failed job; failed rows get astatus-danger-toned background so they read at a glance without opening each one. (2) A failed-count banner at the top of the panel, present only whencountFailedJobs(new,lib/devices/printer-health.ts) finds an unresolved failure, absent entirely otherwise. (3) "Har aldrig skrivit ut" — the never-accepted-a-byte state, the single most valuable signal for a shop planning to run on paper alone: a device that has an agent claimed (RW-3) but has NEVER had one real job reach'done', distinct from "worked before, the last job failed" (failing). Mock (unclaimed) devices are correctly excluded — their jobs are always recorded 'printed' the instant they're created (storing IS the mock's execution), so they carry no signal about real hardware and are labelledsimulated, never flagged. All derivation lives in the new, dependency-freelib/devices/printer-health.ts(computePrinterHealth,groupJobsByDevice,countFailedJobs— 9 unit tests, structurally typed with zero imports fromregistry.ts/dispatch.tsso it has no surface into either module) — nothing inlib/devices/registry.tsordispatch.tswas touched. The panel's job fetch widened fromlimit=40to the route's ownlimit=100cap for better "ever" coverage; no new endpoint, no new polling. Colour choices follow the existingstatus-*token vocabulary (globals.css), never Tailwind'samber-*/orange-*/yellow-*families (brand-remapped to azure) — the same patternAttentionBar.tsxand the tables floor-plan already use. Swedish copy matches the panel's existing kitchen-bong/test-print strings; the panel's older headings ("Recent print jobs", "Devices", "Add device") remain English — a pre-existing inconsistency, not swept here. - The Customer Order Display finally tells the truth about the whole venue, and the owner decides what it shows (2026-08-10). Root cause of the owner report ("SSK orders show, counter POS orders never do"): the public queue feed read ONLY the legacy tenant-blob
db.orders[], and a rebuilt-till counter sale lives inpos_tickets/pos_kitchen_ticketsand never touches that array — a structural split, not a filter (docs/pos-rebuild/customer-order-display-routing.mdhas the full trace). What landed: (1) counter sales now board — a new internal POS projection (GET /pos/admin/display/queue, till-fired kitchen tickets → accepted/preparing/ready via the same read-time derivation history uses) merged into BOTH display feeds (/api/public/[tenantId]/live-orders+ the paired-TV feed), fail-open so a dark POS backend costs one poll's till entries, never the board. (2) One public queue number for kiosk AND till —recordSaledraws from the SAME per-tenant sequence every kiosk/web/QR order uses (new internal allocation route bumpingdb.orderNumberSequnder the tenant lock — numbers cannot collide across devices), stamped on the ticket, the KDS card (orderRef— cooks stop seeing "Direkt"), the printed receipt's Ordernr and the till done screen; fail-open, training sales excluded. Thetest123-shaped "order numbers" were never a generator: counter sales had NO public number, so surfaces showed the fiscal receipt series (free-text prefix, test-harness registers in the shared dev DB) — receipts keep their fiscal number, guests now get digits. (3) Owner-configurable visibility (settings.liveOrdersDisplay.visibility, deep-partial + resolver = recommended defaults with NO migration): master switch (hides all, preserves toggles, board falls back to welcome state), quick service (kiosk / counter POS / eat-in / takeaway-pickup — all default ON), table service (waiter / table-QR — default OFF: the waiter serves the table), remote (direct online pickup ON, third-party delivery OFF). Evaluated per poll by ONE canonical policy (lib/orders/display-visibility.ts) — never persisted on an order, so toggles apply on the next 4–5 s poll, and KDS routing reads none of it (hiding an order can never keep it out of the kitchen). A POS can create counter OR table-service orders — classification uses the actual data (dine-in WITH a table = table service), never source alone. Visibility PATCH requiressettings.edit, audits old→new to the activity log, and the admin panel groups the toggles by workflow with a reset-to-recommended confirm. Deliberate behaviour changes: waiter + accepted table-QR orders stop appearing until enabled (they showed before), third-party ingests stop appearing by default.
Fixed
- The green status chip was never legible, and nothing could have told us (2026-08-11).
--status-ok-ink(#12905a) measured 3.63:1 on its own--status-ok-bgwash and 4.07:1 bare on a white card — under WCAG-AA's 4.5 for normal text, and the chips render at 10–12px. It was the only one of seven status families to fail, in either scheme (the rest measure 5.1–12.1:1), and it surfaced only because the new printer-health states lean on that palette. Ink darkened 13% at the same hue to#107d4e→ 4.61:1 on the wash, 5.17:1 on the card;--status-ok-solidis deliberately untouched, since it is consumed only by.status-dot-*, a non-text component whose threshold is 3:1 (it gives 4.07:1). The durable part islib/theme/status-contrast.test.ts: it parses the real token declarations out ofapp/globals.css(not a copy, which would drift), alpha-composites the translucent dark washes over--color-cardbecause measuring thergba()as if opaque reports a ratio no eye will ever see, and asserts AA for every family in both schemes — including a guard against the parse matching nothing and passing vacuously. Verified to bite: restoring the old value fails exactly two assertions with the ratios above. This is the 2026-08-06 defect class ("almost unreadable text", found at 1.30:1 and 1.21:1 the moment someone computed instead of grepped) turned into a test. - A paper-only restaurant could not receive a web order on paper AT ALL (2026-08-11). The owner's archetype 1 — a small pizza shop that takes the order, gets a bong on paper, makes the pizza — was broken end to end, not merely ungoverned. A venue running no register with the
kitchen_routingcapability got: no kitchen ticket (the online→kitchen ingest refuses outright onhasKitchenRegister), therefore no till-side accept to tap (the till resolves an online order through thatonline_<orderId>ticket), and nothing at all from the dashboard accept —PATCH /api/tenant/[tenantId]/orders/[orderId], the only accept surface that venue has, contained no kitchen dispatch call. So a REGISTER capability ("is there a screen?") was transitively deciding whether PAPER happened. Fixed by separating the two questions: paper is now decided by the venue's hardware alone (hasKitchenPrinter+ the new pureplanAcceptedKitchenBonginlib/devices/dispatch.ts— nothing in that path reads registers), and the dashboard accept dispatches the bong exactly as the till accept has since the acceptance split. The bong still fires at ACCEPT, never at creation (owner, 2026-08-01: an online order is not the kitchen's until a human accepts it) — creation'sskipKitchenfor guest channels is untouched. One bong, from any surface: the new sharedcrossesKitchenAcceptancepredicate (lib/orders/kitchen-accept.ts) keys off the same persistedacceptedAtthe till's replay guard uses, so a double-tap, a till accept that already printed, and an offline-outbox replay all print once — and it keys off the status rank, not the word 'accepted', because the outbox collapsesaccept→preparing→readyinto one PATCH and a word-match would silently lose that kitchen's paper. A screen-only venue is byte-identical (zero jobs), a both-channels venue gets both,printing.autoKitchenBong === falsestill suppresses the automatic print on this path too, and a venue with no kitchen printer loses nothing — the order sits on the dashboard and legacy KDS boards (both readdb.orders, needing neither register nor printer) with its status advanced, and the skip reason is now logged instead of swallowed. No new POS capability minted (no preset grant, no backfill — the trap that removed Parkera nota from new tenants twice) and the backend ingest gate is deliberately unchanged, so no shop/salon tenant starts accruing kitchen tickets. Still out of reach and stated rather than designed around: paper has no deferral and this codebase has no scheduler, so a paper-only venue accepting a scheduled pre-order prints the bong immediately, not at the serve time. - Unpaid waiter orders finally reach the cook board (2026-08-10). The 2026-08-09 "never the kitchen" fix set
autoAcceptfor waiter/POS orders, but the backend gate demandedpaidtoo — and a waiter's dine-in order is unpaid by definition (the guest eats first), so it stranded on the FOH view awaiting an acceptance step that does not exist in that flow. New explicitstaffOriginflag on the kitchen ingest: staff-placed orders fire unpaid; guest channels (web, QR, unpaid kiosk) never carry it and keep the anti-prank accept gate unchanged. - Manual dashboard orders could mint the same public number twice (2026-08-10).
String(1001 + countDocuments)raced (two concurrent creates read the same count) and counted the POS read-model rows the relay upserts into the same collection. Replaced with an atomic per-tenant$inccounter (order_counters, unique-indexed, seeded once from the legacy count so numbering continues where it left off).
- The dashboard was blind to what a till had switched off — one PIN tap could stop every kitchen ticket and nothing outside that touchscreen said so (2026-08-11). Owner report: "we added yesterday on/off toggles for which orders will go to KDS — i am not able to find those options under windows PoS or Android PoS, not even on restaurant admin panel." The toggles are real and they work; they live on the till, and the Devices page could not read a single one of them, which is the worse half.
RegistersService.toView— the projection behindGET /pos/admin/registers— stopped atcapabilities+capabilityRev.disabledCapabilities, the subtract-only layer a manager writes at the counter (Inställningar → Funktioner, PIN required), was projected only to/registers/me, so the fleet panel reported Ordrar/Bord/Presentkort as present on a register where the venue had switched them off: a "granted" tab that is visibly absent, with nothing on the page that could explain it. AndautoSendToKitchen— per register, defaults ON, settable only from the till's own Inställningar → Kök — appeared nowhere underui/srcat all, which is exactly why the owner could not find it. Additive projection (no write path, no resolution semantics and no new capability touched — the venue layer stays subtract-only, pinned by test):RegisterViewgainsdisabledCapabilities,effectiveCapabilities(granted − disabled, the same shared resolverRegisterGuardapplies to every route, so an admin screen and the till cannot disagree),disableableCapabilities(sent per row rather than mirrored client-side — a dashboard holding its own copy of a server list is howpark_billswent missing from every "Skapa"-created till for six days), andkitchenDelivery: a resolved four-state discriminant —active/not_granted/off_at_register/auto_send_off— carrying the precedence, because a missing grant makes the venue switch irrelevant and a venue switch makes the auto-fire flag irrelevant, and any surface re-deriving that order is how two screens come to disagree about one till. (autoSendToKitchenalone is a trap and is documented as one: it resolves against the GRANT, so it staystrueon a till that switched Köksbongar off.) The fleet panel now states each register's effective state: a per-row kitchen sentence naming which switch stopped the orders and which screen to change it on, a box-level "2 enheter når inte köksskärmen just nu" banner, and a folded per-capability list where active / av på enheten / ingår inte differ by colour, icon and wording. Deliberately read-only, and the copy says so — no dashboard writer exists for either layer (the venue layer is till-only by design; the grant is set when a device is created), so the panel points at the till instead of implying a control it does not have. The five new fields are optional in the ui types:uiand the NestJS backend deploy separately, and absent means "the server did not say", never "nothing is switched off" — the panel renders nothing at all rather than reissuing the same lie under a new heading. Also fixed while screenshotting both modes: the panel's genuine-amber status colour (#b45309, a flat inline style that cannot answer a.darkancestor) was a dim ~2.4:1 on the dark dashboard — now mode-aware, light values byte-identical, which also repairs the two pre-existing uses (the currency refusal and "Väntar på parkoppling").
Changed
- Every booking gets a number now, not just the till's (2026-08-08, follow-up). The cancellation mail for a website-made booking showed no number — because only the POS create path minted one.
mintReservationReferencemoved tolib/reservations/reference.tsand ALL FOUR create paths use it: the public widget, the floor-picker wizard (whose old 4-digit ref lived only in specialRequests text and matched nothing searchable — the stored field, the text prefix and the returned ref are now one value), the dashboard staff create, and the till. Every future booking is numbered in every mail and findable by number from the POS; bookings created before today keep having none.
Added
- Reservations grew a full guest-notification story and a no-account self-service door (2026-08-08, fifth round). Owner asks off a live email: (1) the booking number now rides every reservation mail — the owner's new-request notification, the guest's received/confirmed mails — not just the confirmed template. (2) Cancellations mail the guest from every surface that can cancel (POS action
'cancel', dashboard status flip, guest self-cancel): a newcanceledbranch onreservationStatusEmail, because an inbox holding a confirmation for a table that is gone was the worse silence. Both tills' cancel-confirm copy now states it conditionally ("om bokningen har en e-postadress" — the till never sees the address), and the after-state reports the server'sguestNotifiedhonestly. (3) Reschedules mail too — newreservationChangedEmailfired by the till's update route and the guest's own changes; the till edit confirmation now reports mailed/not-mailed instead of "the guest is not notified". (4) Manage without an account: guest reservation mails carry amanagelink (/tenant/[id]/reservations/manage?id&token— thecancelTokenIS the authentication) to a new page that shows the booking and lets the guest move date/time/party/table or cancel, backed by new publicGET/PATCH /api/public/[tenantId]/reservation/[id]running the ONLINE rules (availability engine when the slot moves, party bounds, table fit with no staff override, occupancy excluding self). Till-created bookings now mint acancelTokenso their guests get the same door.
Changed
- Booking-form and floor polish off the owner's fourth hands-on pass (2026-08-08). (1) A table already sold for the chosen date+time now says "Upptagen" ON ITS TILE — dimmed, warn-toned, untappable — using the same exact-slot rule the server refuses with, read from the day's own list; a time step that sells the chosen table out clears the selection where the operator is looking, and the server's TABLE_OCCUPIED stays behind it as the final word (the client set is a fetch old). In edit mode a booking never blocks itself. (2) The RB booking number is bold on both tills' confirmations. (3) Floor legibility: the BOKAT chip's near-black-on-azure ink became the on-primary white, and table-top labels are held to the shape's SAFE width with ellipsis — a circle's text band is its inscribed width, so "WINDOW" and the money line no longer run off the curve mid-letter.
Added
- The till's booking desk is complete: numbers, online slots, find, cancel, reschedule (2026-08-08, third round the same day). Owner asks off live testing, all on both tills. (1) Booking numbers: till creates mint
RB-######(unique per tenant, additiveReservation.reference), shown first on the confirmation, on the sheet's facts, in search rows and in the guest's confirmed-email (new optionalreferenceonreservationStatusEmail). (2) The online widget's time slots as quick picks:GET /pos/registers/bookings/slots→BookingsBridge.slots→ new/api/internal/pos/booking-slotsrunning the SAMEgetAvailableSlotsengine as the website — guidance chips beside the manual clock, never a gate (the person in the room outranks online pacing); the form itself reorganised into bordered section cards (Datum/Tid/Sällskap/Gäst). (3) Find a booking:GET /pos/registers/bookings/find?q=&today=(operator required — an all-dates sweep over guests' names is an errand, not floor decoration) matching number, name or phone digits across every date and status, served from a projector now SHARED with the day list (lib/reservations/pos-projection.ts) so the two reads can never drift on what a till may know about a guest; a debounced search sits in the bookings panel on both tills. (4) Cancel from the till: action'cancel', legal from pending AND confirmed →'canceled', mailing nobody (no cancel template exists anywhere; the caller IS the notification, and the confirm step says so out loud). (5) Reschedule/edit:POST /pos/registers/bookings/update→ new/api/internal/pos/reservation-update— full-state for the till-visible fields (a blanked phone IS a cleared phone),customerEmailstructurally untouchable (the till never sees it, so an edit cannot wipe it), occupancy re-checked EXCLUDING the booking itself,BOOKING_NOT_OPENfor closed ones; the create dialog reopens prefilled as the editor, email field hidden, and the confirmation states plainly that the guest is not notified automatically. Also fixed live: `guestNotified` no longer lies on a box that cannot send mail — the response is now gated onisMailConfigured()(found when the staging box's SMTP lived inui/.env.local, which the runtime container never receives; the keys now travel viadocker.env).
Changed
- The till's booking form grew up on first contact with its owner (2026-08-08, same day). Four asks off the first hands-on test, all landed on both tills: (1) a REAL calendar — a custom-drawn month grid (new
PosCalendarin the Compose kit, mirrored markup on pos/web, shared arithmetic inNewBookingModel/booking-calendar.tspinned by twin tests) replacing the one-day-at-a-time stepper; the platform-date-picker doctrine holds, the grid is ours; (2) the table picker's pills ("3 · 4") became TILES — name on one line, "4 platser" on the other, chosen tile solid primary (the PosSegmented lesson about quiet tints); (3) the Telefon/På plats question is GONE — either way the booking is taken at the till, so the server now stamps every till createsource: 'pos'(new vocabulary value, rendered "Kassan"/"Till"; older tills'sourcefield is accepted and ignored rather than 400ing the not-yet-updated); (4) the Bord side panel's way back from a selected table — a real "‹ Alla bord" leading the header where the old trailing ghost went unseen by the owner looking straight at it, and "+ Ny bokning" got its own full-width row instead of wrapping under the day stepper.
Added
- The till can now TAKE a table booking, not just answer one (2026-08-08). Owner ask: a guest on the phone or at the counter had no door into the book from the POS — the operator wrote it on paper and typed it into the dashboard after close, or didn't. New end-to-end create path on all three POS surfaces (pos/web for browsers+Windows station, the Compose till for Android/Windows/wasm): a "Ny bokning" dialog on the Bord surface with source (Telefon/På plats), a stepped day (floored on today, +365 cap, no calendar widget per the kit doctrine), quarter-hour clock, party stepper, optional REAL-table chips, guest name, optional phone/email, and free-text önskemål (→
specialRequests, which already flows to the floor cards, dashboard list and prep board). The chain mirrorsreservation-actiondeliberately:POST /pos/registers/bookings/create(RegisterGuard + operator, no capability — the reservation-route doctrine) →BookingsBridge.create→ new internal route/api/internal/pos/reservation-createdoing the write inside ONEmutateTenantDBCAS with the staff route's exact checks (table exists,tableFitsPartywith an explicit operator override that stampsseatedOverCapacityBy,occupiedTableIds), then the sharednotifyReservationCreated— never a second notifier. Born `confirmed`, and that is a rule: the operator taking the call IS the venue answering, and the native alert engine chimes only on newpendingrows, so a phone booking taken on till A must not ring till B or inflate any Bord badge. A booking with no email reportsguestNotified: falseand the confirmation says so ("bekräfta med gästen direkt"). Reservations gain an additivecreatedByoperator stamp. Validation drift between the DTO edge and the authoritative zod is carried as an honest 400VALIDATION("kontrollera fälten"), never flattened into the 503 outage message. Adversarial review (3 lenses over the combined diff) found no blockers; its parity findings — a half-hour-boundary default-time drift between tills,1 gästergrammar, named-table chip wording, the web till not re-reading a stepped-to future day after a create, missing input caps on Compose, missing stepper aria-labels on web — were all fixed before landing.
Changed
- The animated hero is back on the landing page (2026-08-07). Owner ask: "do you remember we hade the animated hero on our website home page or landing page before. is it possible to get that back?" It was never deleted —
components/landing-v2/HeroSection.tsxwas orphaned when the new landing page (#81) replaced it with a static headline-and-screenshot block, and had simply stopped being imported. Restored on top of the new page: only the static hero it replaces is gone, everything else (HomeNav, JourneyStrip, the trust band, StickyCta, TestingBanner) is untouched. What moves again: the live order demo stepping New → Accepted → Preparing → Ready with its pulsing status dot (rPulse), and the "Today's revenue" card floating above it (rFloat). The two stylesheets could not simply both be imported —landing-theme.cssandrestora-landing.cssshare five selectors and all five are root ones (.r360-landing,.r360-landing *,html, the skip link), so loading the old sheet second would have re-based the whole page's tokens, reset and typography on top of the new design. Instead the hero's 30 custom properties, 12 rules and 2 keyframes were lifted intohero-legacy.css, with the tokens declared on the hero element rather than the page root so they cascade to its children and can reach nothing else. The warm token names (clay/honey/pine) are legacy; their values are the azure brand (--clay-500=#1570ef) and stay that way perAGENTS.md.
Fixed
- "Tables & QR Codes" showed only table management — every QR affordance on the page was gated behind having tables (2026-08-05). Reported as "QR code, it was working there, now it shows table management instead of QR code generation". Root cause: on
/dashboard/tables, the "Menu QR codes" button was wrapped in{tables.length > 0 && …}and the per-table QR buttons render inside the table list — so a venue with no tables yet landed on a page the sidebar calls "Tables & QR Codes" and got a floor-plan empty state ("No tables yet — click Add Table to set up your floor plan") with no route to QR generation at all. Compounding it, the standalone QR Menu Generator (/dashboard/qr) is aHUB_LINKED_ITEM— deliberately not rendered in the sidebar, reachable only via the Marketing hub — so once the per-table buttons vanished there was no visible QR entry point anywhere in the nav. Fixed by making QR reachable from this page regardless of table count: a permanent "QR generator" link in the page header, and an empty state that now says per-table QR codes appear once tables exist while pointing at the generator for menu/takeaway QR codes, which need no tables at all. The sidebar IA is left as-is (the hub-linking was a deliberate de-duplication); this restores the discoverability it cost. - The grocery till was overcharging for weighed goods, live — and the till that wasn't overcharging couldn't sell them at all (2026-08-05). The #1 item in the grocery backlog (
OUTSTANDING-WORK.md§3g). On a weighed variationpriceMinoris the price per kilo, but pos/web's localCatalogVariationinterface never declaredweighPricing— the exact TypeScript twin of the KotlinignoreUnknownKeysdefect that had already bitten this same field — so every add path treated a kilo price as a unit price. Tapping Lösgodis (99,00 kr/kg) charged 99,00 kr for whatever the bag weighed: a 320 g bag overcharged the customer by 67,32 kr, silently, with a plausible-looking amount on the receipt. pos/web now declares the field and refuses weighed items by name — the identical posture, and identical check order (sold-out → weighed → age), the Compose till already took. Also added a server gate at both independent write doors (buildTicketLines, servingsale.record+ park; andaddLinefor open tickets): a weighed variation with noext.weighis refused, mirroring theminBuyerAge → ageCheckrefusal directly above each. It costs nothing today because both tills refuse — which is exactly why it could land now: the overcharge is structurally unreachable from any client, including ones not yet written. Adversarial review then caught that the fix was incomplete in three ways, all fixed: (1)TablesFlowis a third hand-maintained copy of the same wire shape and still walked straight into the new server gate — a waiter tapping a weighed tile would have gotten a raw English server sentence naming internal ids in a Swedish till (table service really does sell weighed food; the SE journal fixtures pin a "Salladsbuffé"uom: 'kg'line); (2) the new spec never exercised the sale/park door's accept path, so a gate that refused every weighed line — even a correct one — would have kept the suite green (both gates are now proven non-vacuous by deleting each in turn and watching the suite go red); (3)onScan's new non-404 branch claimed "no contact with the server" even for a 500/401 where the server answered, and discarded the precise message the API client had already written. Separately fixed on the same path:onScanused to treat every failure as "unknown barcode" and open Ny vara prefilled, so a network blip while scanning a real product invited a duplicate item bound to a code the catalog already used — now only a definitive 404 does. Published to all three POS surfaces (web / Windows-via-web / Android assets; APK rebuild still required to reach devices). The kernel needed nothing — verified, not assumed:unitPriceMinoris literally "per 1.000 uom",qtyMilliis net grams, and the golden 0.734 kg line is pinned across the Rust/Kotlin/C# engine ports. Manual weight entry and the prefix-2 EAN parser remain blocked on owner decisions (the prefix-2 layout is company-internal and absent from this repo; a parsed item reference has nothing to resolve against; and whether a hand-keyed weight is legally sellable is a metrology question) — all now written up in §3g rather than guessed at.
Added
- /shop now serves the grocery vertical — grocery's first working public catalogue page (2026-08-05). Found while auditing other verticals for the same "no public surface for the real catalogue" gap as the services /shop fix earlier the same day: the grocery demo tenant's
/menu(the only page grocery doesn't already 404 on) reads the legacytenant_data.productsblob — empty, since a grocery till writes topos_itemsinstead — so it rendered generic restaurant-template chrome ("Vår meny... Färska råvaror, lagade med kärlek") with zero of the tenant's real 12 products. Every grocery tenant has this gap, not just the demo one. Extended (not duplicated):listGoods's vertical gate widened to services-or-grocery; a newresolveGoodsTenantresolver added (deliberately separate fromresolveSalonTenant, so/book//availability//bookingstay services-only — grocery has no appointments);/shop's page gate widened the same way; ahasShopnav flag added so grocery gets the Shop link without gainingbookor losing its existingmenu/reservations. Weighed items (loose fruit, deli) now carryweighPricingthrough the projection and render as "kr/kg" instead of a meaningless flat unit price. Adversarial review caught 3 more real gaps: the sitemap generator and the builder's IndexNow re-crawl ping both still gated/shopon services-only, so grocery's new catalogue page was invisible to search engines even though it 200s — both widened to match; andfmtPrice's default whole-number rounding, reused unchanged for the new per-kg display, silently mangled the normal case for Nordic weighed pricing (39.90 kr/kg showing as "40 kr/kg") — not a rare edge case, since psychological/fractional pricing is the Nordic grocery norm — fixed with an explicit fraction-digits override for the /kg call site only (flat prices unchanged).
Fixed
- Closed a latent, defensive gap of the same shape as the navbarConfig CTA bug below, in the footer.
SiteFooter.tsxalready filtersmenu/reservationsout of nav-derived footer links for a services trade, but an owner-authored (or template-seeded)footerConfig.bottomLinksentry bypassed that filter entirely — free-text hrefs, never checked against vertical anywhere. No live tenant currently has abottomLinksentry pointing at a dead route, so this wasn't actively broken for anyone, but it's the exact same "stored config bypasses the vertical guard" pattern found live in the navbar CTA — closed the same way before it could bite someone. - A real pilot tenant's live storefront showed a dead "Order Pickup" button (2026-08-05). Found while auditing other services-vertical tenants for the same bug class as the day's earlier nav/shop fixes: Nordic Barber Studio (
ten_nordicbarber) has its ownsettings.navbarConfig(left over from a restaurant "forno" template installed before the tenant had a declared vertical), andresolveNavbarConfig()(ui/src/lib/nav/navbar-appearance.ts) returned that stored CTA verbatim whenever a tenant had one — silently bypassing the vertical-aware suppressionSiteHeader.tsx's own legacy CTA already relies on (showBookButton && !isServicesTrade). The result: a barbershop's header showed a button linking to/reservations, which 404s for that trade.resolveNavbarConfignow takes anisServicesTradeflag and forces the CTA off in both branches (stored-config and fallback), closing this regardless of which shape a tenant's chrome is in — not just today's specific case.SiteNavbar.tsx's mobile CTA (bookHref) inherits the fix automatically since it reads the same resolved config; no separate change needed there. The tenant's storednavConfig.itemswas also missingbook/shop/account(same gapten_hasalonghad) and was repaired the same way. The storednavbarConfig.ctaitself was deliberately left untouched — the code fix alone makes it inert for this trade, and rewriting the owner's own raw editor data risked exactly the kind of silent-data-loss surprise this session's earlier fixes were about. - Every guest visiting any tenant's public salon page (`/book`, `/shop`, availability, cancel) was rate-limited as if they were the same single client (2026-08-05). Found while closing out the
/shopfeature's adversarial review:internalGet/internalPost/etc. (ui/src/lib/http/backend.ts) never forwarded the incoming request'sx-forwarded-forheader to the backend, soUserThrottlerGuard.getTracker— which falls back tox-forwarded-for→req.ipwhenever noreq.useris set, true for every guest BFF call — tracked every anonymous visitor across every tenant's storefront as the SAME client: the Next.js server's own outbound IP. Fixed by threading the caller'sRequestthrough all 5internal*helpers (optional, backward-compatible — none of the ~79 existing callers needed to change) and wiring it through the 6 guest-facing salon-booking routes (services,goods,availability,book,booking/[id],booking/[id]/cancel). New tests pin that the header is forwarded when present and omitted (unchanged) when no request is passed.
Added
- A public /shop page for services-vertical retail products, and the productType bug that made it necessary (2026-08-05). Follow-up to the same-day Mason nav fix: the salon owner's items ("Happy happy", "Argan-oil") appeared in the dashboard catalogue but never on the public website. Root cause: the native till's "Ny vara" (new item) dialog has no product-type picker at all — just name/price/category/VAT — and the server-side legacy-menu translator (
translateLegacyMenu) hardcoded every till-authored item toproductType: 'FOOD_BEV'regardless of vertical, a meaningless tag for a salon that also had no public page reading it. Fixed: for a services-vertical tenant, a brand-new till item now defaults toGOODSinstead (a real retail-product type — the SAF-T export already maps it correctly to "04003 Sale of goods" vs FOODBEV's "unclassified", and it's excluded from KDS routing). Added a new public `/shop` page (mirrors `/book`'s structure — same vertical gate, same ThemeInjector/SiteHeader/SiteFooter usage) with a new backend `listGoods` projection (sibling of `listServices`) and Next.js proxy route, plus a `shop` nav link (`source: 'custom'`, same protection as `book`/`account`). **Adversarial review caught 5 further real issues before deploy**: (1) the productType default was computed per-tenant, not per-item, so ANY unrelated till write (an 86, a reprice) re-translated and silently re-stamped EVERY item in the tenant's legacy blob to the same default — fixed by preserving each item's already-stored productType and only defaulting genuinely new items; (2) a failed tenant lookup degraded silently to the old FOODBEV behaviour with no logging — fixed with a warning log; (3) neitherlistGoodsnor the pre-existinglistServiceshad a server-side vertical check (only the Next.js BFF gated them) — fixed by gating both, failing closed; (4) the sitemap generator was 100% vertical-blind, advertising dead/menu+/reservationsURLs and never listing the live/book//shoppages for any services tenant (a pre-existing gap/shopwould have inherited) — fixed for both the subdomain and platform-prefixed sitemap branches; (5) the IndexNow re-crawl ping list had the identical gap — fixed. Also added a TTL cache for the new per-write tenant-vertical lookup so restaurant/grocery tills don't pay a new Mongo round-trip on every catalogue write for a vertical check whose answer never changes their output. The friend's two existing items were reclassified toGOODSdirectly in Mongo (owner-approved) so they appear on/shopimmediately rather than waiting for an unrelated till write to self-correct them.
Fixed
- A live customer's "Boka tid" (Book) nav link vanished on an unrelated colour-theme save, and new POS items looked invisible on the public site (2026-08-04). Reported by a services-vertical (salon) tenant created same-day: picking a new colour preset in the Mason website builder — no nav edit at all — silently deleted the "Boka tid" link from the live header, while
/menuand/reservations(pages that don't exist for a services trade) appeared in its place. Root-caused to THREE compounding layers, all inui/: (1)MasonShell.tsx'ssave()unconditionally re-sent the fullnavItemsarray on every save regardless of what was actually edited — fixed to gate it behind the sameHEADER_DIRTY_KEYdirty-tracking the Menu/Header tabs already use. (2)getDefaultNavItems()(nav-service.ts) never marked the services-only 'book' link (or 'account') assource: 'custom', so the server's reconcile treated it as an orphaned page link and dropped it — fixed by tagging bothsource: 'custom'. (3)normalizeMasonPages()'s fallback for a tenant with no saved page list (pages.ts) was a hardcoded, vertical-blind restaurant shape (home/menu/reservations/blog/contact) — the reconcile then saw 'menu'/'reservations' as legitimate pages worth auto-linking. Fixed with a per-vertical fallback (defaultMasonPagesForVertical) that drops menu/reservations for services. Adversarial review caught two more, more severe instances of the same class after the first fix landed: gatingnavItemsclient-side made a SECOND, previously-dead server branch inbuilder/route.ts(fires wheneverbuilderPagesis sent withoutnavItems— now the common case) start wholesale-rebuilding nav purely from the page list with noreconcileNavItemscall at all, re-dropping every custom link (book/account, on EVERY vertical) on almost every ordinary save; and the GET handler's ownnormalizeMasonPages()call (missing the vertical arg) was handing a fresh services tenant a polluted page list that got persisted intobuilderPageson its first save, regardless of the fix. Both closed: the sibling branch now reconciles the tenant's existing nav against the updated pages (same non-destructive merge the Menu-tab branch already got) instead of discarding it, and GET now resolves + threadsverticalthrough itsnormalizeMasonPagescall too. Also fixed a narrower, separate latent instance of the same vertical-blindness in the theme-install-with-demo-bundle path (installer.ts'sapplyChrome, ruled out as this incident's trigger but real). New regression test innav-from-pages.test.tspins the services Book/Account-survive-reconcile case (caught failing twice at intermediate stages before landing correctly — proven non-vacuous). The friend's tenant (ten_hasalong)'s livenavConfig/builderPages, corrupted by the original bug, were repaired directly in Mongo to match exactly what the fixed code now produces — live-verified after the repair: the header shows Home/Book/Blog/My Account/Contact and/api/public/salon/hasalong/servicesstill returns all 5 of the friend's POS-added services. - Paper reprint for a closed sale — and a live bug it caught along the way (2026-08-04). The tender-flow "Print" button was a dead
onClick = {}stub, and History had no reprint action at all (a code comment said the seam didn't exist yet). Both now call one new, awaited backend action (POST /pos/tickets/:ticketId/print, mirroringemail-receipt's exact shape) that reuses the print-job pipeline every sale already auto-queues at settle, markedkopia(orovningfor training) since it's always a second copy. Native:HistoryRepository.printReceipt+ aReprintReceiptaction beside the existing email-resend button;PaymentStep.Donegained aticketIdso the Done step's Print button has something to call. Adversarial review caught a real, previously-invisible bug: a training-mode sale'sreceiptNumberis deliberately blank, but the internal print-sale/email-sale routes hard-required a truthy one — so every training reprint was a guaranteed 400 dressed up as a false "try again" 503, and (traced further) this had ALSO silently broken the auto-print and auto-email-at-settle for every training sale since those shipped, fire-and-forget, invisible. Fixed at both routes: a payload markedovningis now accepted with no receipt number. Verified live against a seeded grocery tenant: reprint answers{"queued":0}(honest — no printer paired) and the previously-broken email resend now answers{"sent":true}. - Grocery module phase 1 — the native till learns to sell groceries, and `scan_first` finally does something (2026-08-04, `pos/app` commonMain → Android + Windows + wasm from one tree). Owner ask: "now can we start with grocery store module for PoS both for windows and android." Plan of record:
docs/products/pos-verticals/GROCERY-MODULE-PLAN.md(a six-reader state map with adversarial spot-checks, gap matrix against MARKET-RESEARCH-2026-08.md §4b's ranked top-10). The finding that shaped the pass: the kernel/backend were grocery-first all along (golden scenario 1 — the mixed 6/12/25% weighed+pant+age basket — is literally called "THE EXIT CRITERION") and the web till already sold groceries; the NATIVE till had the skeleton and none of the organs, two of them live money bugs. Shipped: (1) the Kotlin catalog DTO finally declares `deposit` + `weighPricing` — the server had sent both all along andignoreUnknownKeyssilently ate them, so a natively-rung Läsk priced without its pant while the same scan on the web till priced with it. (2) Pant attaches and is priced by the ENGINE (a pant row's amount is a one-linepriceBasket, never Kotlin addition; Pant chip on the row) — and the resumed-bill half was a live defect:adoptServerTicketdroppedext.goods, so a pant bill parked on the web till re-priced WITHOUT its pant on native pick-up and the close's exact-tender check refused the very total the screen showed. (3) The age gate exists on the native till — before this the server refused everyminBuyerAgesale (no dialog could record the check), so the tobacco pilot's own till could not sell snus at all. Blocking Ålderskontroll dialog; a repeat merges into its checked row without re-prompting; the recorded check (id_document) rides all four write shapes (sale lines, park lines, add-line via the sync diff — a qty step on a resumed checked line is a void+re-add and needed it — and survives resume viaext.goods.ageCheck). (4) Weighed items are refused honestly ("1" of lösgodis is a kilogram at the per-kilo price; the web till still silently sells it — flagged for parity). (5) Scan-to-create: an unknown scan opens Ny vara with the code prefilled (prefillBarcodewas dead code) — on a definitive 404 only; the web till's create-on-ANY-error defect (a network blip while scanning a real code invites a duplicate item) deliberately not ported. (6) Quantity multiply + the scan-first counter:scan_firstgates something on a selling surface for the first time on any platform — last-added feedback card (reads the LIVE row, so merges and steppers keep it true) + a digit pad arming a multiplier for the next scan (buttons, not a field — a focused field eats the wedge's keystrokes), and the armed count survives the age dialog (the web till drops it and hardcodes 1 — not ported). Also:CapabilityRailTest's "verbatim from seed" fixtures had drifted (shop claimed 5 caps; the seed grants 10) — reconciled with the lesson in the fixture comment; newGroceryTillTest(16 cases) pins the DTO decode, wire presence/absence of the check, deposit-through-the-engine, the age-gate cadence and the armed-qty rules. Gates: full Compose desktop suite green (613), Android + wasm compile clean, Swedish-literal ratchet held at 0. Remaining (ranked in OUTSTANDING-WORK §3g): weighed goods both flows, paper receipt at tender (the print button is a stub), offline barcode index, pant registry/RVM tender, pos/web scan-first parity, dashboard grocery enablement (signup tile still "Coming soon").
Added
- Backend half of "email the customer their receipt" (2026-08-04).
TicketDoc.customerEmail(opt-in, per-sale, never indexed or joined — noCustomerDoc/CRM write) added toRecordSaleDtoandCloseTicketDto(both@IsEmail).SalesService.buildTillPrintPayloadextracted from the inline body ofprintTillSaleand generalised to build aTillPrintPayloadfrom either a livePricedBasket(record/close time) or a persisted closedTicketDoc(lineNetGrossMinorfromexports/money-format.tsreconstructs each line's post-discount total from the SAME stored fields the pricing engine used — no new totals computed, no re-derivation of VAT).TillPrintBridge.email()posts to a newPOST /api/internal/pos/email-sale(mirrorsprint-sale/route.ts: internal-secret guard, ephemeral order,buildReceiptFiscalFromTill, no persisted Order, no minted receipt number). Auto-send hooks inrecordSaleandcloseTicketfirevoid, after commit, never awaited — the same "must not be able to fail a sale that already happened" posture asprintTillSale. New deliberate-resend endpointPOST /pos/tickets/:ticketId/email-receipt(409 if not closed, 400 if no address on file or given, 503 if the send genuinely fails) IS awaited, since a person tapped "send" and needs a real answer. Email body is the exact legal receipt layout viabuildReceiptSegments+ a newrenderSegmentsPlain(render.ts) — deliberately NOTrenderSegments(…, false)(raw ESC/POS control bytes, garbage inside HTML) norrenderSegments(…, true)(stamps a "not a valid receipt" preview watermark that would be a lie on a genuine one). Template (lib/email/receipt-till-email.ts) is the azure#1570efcard fromreceipt-email/route.ts, sv/en viasettings.defaultLanguage./receipt/[tenantId]/[token]andreceipt-store.tsuntouched. Verified:backendtsc --noEmit0 errors;uitsc --noEmit0 NEW errors (pre-existing unrelated failures in theme-packages/experience-studio/plans untouched); all 202 pre-existingbackend/src/modules/pos/salestests still pass. NOT done in this pass (separate agents' scope per the design doc): pos/web and pos/app capture UI (checkbox + email field at tender time) and the History "Skicka kvitto" resend button on both rails — the backend endpoint and auto-send are ready for them to call.
Added
- The Windows till closes an architecture question left open since 2026-07-27, and gains real printing (2026-08-04). Owner ask: "my windows PoS is behind the android tablet app. can you also update windows app so it is same as my android PoS also connect that to my barber shop tenant to my linux box over internet."
docs/pos-rebuild/OUTSTANDING-WORK.md§5b had four documents disagreeing on what "Windows POS" even was — WinUI 3/C# (never built), a Tauri/WebView2 shell wrapping the web till (windows/station, real code, real recent investment), or the Kotlin Multiplatform Compose desktop target (pos/app/app/src/desktopMain, also real, sharingcommonMainwith Android almost entirely already). Owner ruling: the native Kotlin build is the POS till — Android and Windows now run the literal same source tree, not two ports kept in sync, which is a stronger fulfillment of the "one kernel" guarantee than the original C# plan would have been.windows/stationkeeps its TV/kiosk/display and hardware-bridge roles (unchanged), just not the till UI. The actual parity gap against Android turned out to be small: printer/cash-drawer transport, which returnedNotSupportedHereon desktop with a comment saying Windows should print through the Station agent instead. That assumption was wrong and caught same-session, before commit: there is no local channel from the Kotlin process into the Tauri process to route through. The real fix makes the Kotlin till its own independent print agent —PrinterTransport.desktop.ktports Android'sjava.net.SocketRAW/9100 transport near-verbatim (same connect-timeout/watchdog trick for the JVM's missing write-timeout),PrinterDiscovery.desktop.ktdoes a LAN TCP sweep reusing the already-shared, already-testedPrinterRoster(no mDNS on the JVM — a known, smaller, non-blocking gap vs Android's NsdManager). Both poll the SAME/api/public/hardware/*job queue Android already uses via the existing commonMainPrintAgentRepository— zero new backend surface. A venue running both a Kotlin till and a Windows Station against one printer is prevented structurally by pairing (single-use code, one claimant), not by mutual-exclusion code. Docs corrected in the same session acrossdocs/POS-Rebuild-Master-Plan.md§2.2,windows/AGENTS.md, anddocs/pos-rebuild/OUTSTANDING-WORK.md§5b — twice, since the first correction pass itself carried the wrong (Station-routing) assumption and a verification agent caught it before commit. Built, installed and running on a real Windows machine already on the same tailnet as the box (https://restora-server.tail8deedd.ts.net, confirmed reachable) via:app:packageMsi+tools/install-windows.ps1. Not yet done: re-pairing this till from its previousten_pilot-demoregister to aten_nordicbarberregister — needs a dashboard-minted pairing code (15-minute TTL, so minted at the moment of use, not ahead of time). Gates::app:compileKotlinDesktopclean,:app:desktopTest597/597 pass (3 new real-socket tests, no mocks),:app:packageMsiBUILD SUCCESSFUL.
Added
- Audit correction: the owner caught a live miss, so the sweep methodology got fixed, not just the symptom (2026-08-04). After the three-wave trade-leak audit below was reported complete, the owner posted a screenshot of Nordic Barber Studio's live
/dashboard/orderspage — still the unfiltered restaurant kitchen kanban ("Tillagas" column, "Börja tillaga" button) — and asked "are you sure". It wasn't. Root cause: the audit's page-scoping walkedNAV_GROUPS(the collapsible sidebar) exhaustively across all three waves but never enumeratedNAV_PRIMARY(Home/Intelligence/Quick Settings/Quick Links/Orders) as a category to check — Orders lives inNAV_PRIMARYand was structurally invisible to every audit prompt written. Fixed the two live Orders leaks (kanban column + action button forked onvertical !== "restaurant", sv override added), then ran two further repo-wide sweeps rather than re-declaring done: a "No restaurant connected" boilerplate grep (8 more core-page instances fixed: quick-links, sell, menu-tv, media, designer-studio, connections/seo/offers dict), then a broaderrestaurant|Restaurantsweep across every dashboard page, filtered for comments/type-names/already-vertical-aware code and triaged one-by-one againstnav-catalog.tsgating. ~25 more genuine leaks fixed, all on pages with zero vertical gating (reachable by every trade): the super-admin panels (addons/global-media/monetization/tenant list — these manage tenants of every vertical, so "Restaurant" table headers and "restaurant owners" copy were platform-wide leaks, not barbershop-specific ones), the dashboard error boundary (error.tsx— fires for every tenant on any error, was "Restaurant data unavailable"), escrow, marketing hub, QR, activity log, team-comms, the Google Business connect guide + attributes + insights pages (Google Business Profile is generic to any local business — none of that copy should ever have said "restaurant"), social, branches (plus hid the cross-branch "Shared Menu" tab for non-restaurant — it pushes the legacy menu system, not the Sortiment catalogue a services tenant actually manages its stock through), and the Settings page's business-name fallback (myRestaurantFallback, shown whenever a tenant hasn't set a custom name — a common, not edge-case, state). Also corrected the Swedish `dict-langs.ts` overrides for the specific keys touched (English-only fixes silently do nothing for a Swedish-locale tenant on these OLD, fully-translated namespaces — the merge prefers the stale generated override over the corrected English source). Left deliberately unfixed and flagged instead of hacked: Experience Studio's AI photo/caption tool and the Restora Profit setup wizard are restaurant-shaped by design (dish/cuisine prompts, food hashtags, a "Restaurant type" segment picker), not by a wording accident — a label rename would be cosmetic since the underlying options stay food-only; both need a real services-appropriate variant, tracked in TODO.md. Also tracked: the other 19 languages' stale "restaurant" wording for the keys fixed here (Swedish was prioritised as the pilot tenant's locale). Gate:tsc --noEmitheld at the exact pre-existing 60-error baseline through every edit in this pass — zero regressions. - The trade-leak audit closes — every restaurant option the owner kept finding on the barbershop panel (2026-08-04). Owner: "i still se some options for restaurant on admin panel. this time i want you to detect and remove from barber or salong module." A five-surface audit (native till, web till, dashboard home, every dashboard page, the email/receipt/site periphery) swept the whole product for restaurant-specific wording and dead-end affordances still reaching a services tenant; every finding was fixed in three landed waves. Headlines: Features → Get more no longer invites a barbershop to buy a Kitchen Display, and Your Features stops reading "Table Reservations" —
FeatureDefgained the sameverticalsconvention as the nav catalog, an already-GRANTED feature always stays listed. Setup Center's Ordering & Delivery tab (Uber Eats/Wolt) is gone for services. The Devices page no longer offers a Kitchen Screen to connect, and its first-run trade picker now opens on the tenant's own declared trade instead of defaulting everyone to "Restaurang". QR Generator, Supplier Marketplace/Quotes/Orders, Hiring, and Restora Profit's menu-engineering rows are trade-annotated. The home page's Upcoming Bookings widget (table reservations) is gone for non-restaurant, its onboarding checklist offers "Add your first service" linking the new catalog editor, and its KPI counts today's actual appointments instead of a permanently-zero reservation count. Help Center, email campaign templates (four new services-flavoured twins added), the tax regulation calendar, Setup Center's Integrations tab, and the Notifications tab are all trade-filtered. Copy forks (services wording, never restaurant text mutated) landed across Setup Center, SEO (menu-completeness check dropped for services), Profit Margins (COGS/recipe language skipped for non-restaurant), Customers/Guest-profile, Intelligence's AI-question catalogue, Marketing Hub, the AI Assistant page, Demand & Weather, Shifts, and Loyalty (now sources reward items from the services catalogue instead of a Menu that doesn't exist for that trade). Neutral "No restaurant connected" → "No business connected" across every no-tenant surface. One genuine money-correctness bug was found and flagged, not silently patched:lib/android-terminal/receipt.tshardcodesrestaurant_dineinin its VAT-split function — a real defect that needs its own tested change, tracked below. Gates: fulltsc --noEmitstays at the exact 60-error pre-existing baseline (confirmed viagit stashdiff before this work started) across all three waves — zero new errors; nav-catalog 15/15, seller-lite 23/23, regulation-watch 27/27 total, block-features + gift-cards i18n 20/20. - The salon till behaves like a salon till — checkout that seeds the bill, a booking chime, and the last of the menu-Swedish (2026-08-04, both tills + audit sweep). Owner asks: "booking > ta betalt takes me to kassa and kassa is empty… orders still restaurant specific… some kind of highlight upcoming booking… 3 beep sounds… settings still have kioskläge, uppdatera meny." (1) Ta betalt now hands over a bill: the booking rides the same handoff discipline Parkerade uses, the service line enters through the exact door a tile tap uses (engine pricing, tax pinning, merge rule), the customer's name lands on the ticket — and when THAT sale settles, the till completes the booking against the real ticket id (offline-queued sales get an honest "link queued" notice; a service the catalogue no longer carries refuses rather than inventing a price). Booking cards now also SAY what was booked (service name + catalogue price). Named gap: the native sale envelope carries no per-line
ext.service(a money-envelope change), so the web's closed-ticket recovery scan can't find native sales — commented at the seam. (2) Attention channel on both tills: a NÄSTA highlight on the next open booking, a 30-minute upcoming badge on Bokningar (web), and a TRIPLE chime when a new online booking lands — same sound path as the order alerts, first-load and midnight-rollover silent, status flips never re-announce (18 new tests across the diff/selection/engine). No competitor documents booking sounds — this one's ours. (3) Trade wording end to end (audit-driven, both tills): Kioskläge hidden for tills with no kitchen/scan/self-checkout shape; "Meny/Uppdatera meny" → "Sortiment/Uppdatera sortiment" on non-food tills (grocery-ready); "Tillagas" → "Pågår" on Orders and History; History drops the kitchen-flow chips and the whole Typ row; Ny vara speaks the trade ("Ny vara i sortimentet", "T.ex. Herrklippning", VAT chips lead with Standard 25 %); the bare "KÖK" settings header no longer floats over the printer section; the empty-catalogue screen stops calling every venue "restaurangen"; the offline error stops saying "i hela restaurangen". Non-food line motion now derives from the item (SERVICE→service, else retail) exactly like the web's booking payment path. Gates: Compose desktopTest + assembleDebug exit 0; pos/web tsc 0 + 91/91; bundle republished. - A services tenant can finally edit what it sells: the Sortiment editor at `/dashboard/catalog` (2026-08-04). Owner ask, verbatim: "i cant rename or add or remove categories, and items nor on web or PoS. the system should be super easy and very flexible." The restaurant's catalog editor is the Menu page — correctly hidden for the services vertical — which left a barbershop with NO surface anywhere to manage its services, products and categories. The new page is the owner's window onto
pos_items(the store the till actually sells from, not the tenant-blob menu): categories as sections with inline rename, items as rows with price/duration/active editing, add-item with category pick-or-create (a category is created by filing an item into it — an empty category would be invisible on a till), and soft remove with a collapsed bring-it-back list. New nav group "Services & Products" (services vertical, DAILY zone, translated in all 20 languages),posCatalogdict section with hand-written Swedish, and three BFF proxies under/api/tenant/{tenantId}/pos-catalog/*. Backend grewGET /pos/admin/catalog/items(whole-catalogue admin projection) andPOST /pos/admin/catalog/category-rename— categories are DENORMALISED onto items, so a rename IS a bulk re-stamp of every item filed under the id. The admin upsert now MERGES with the stored item, so a projection-shaped save can no longer wipe the fields the editor never shows (barcodes, stock flags, offer markers, routing facets — and a live campaign's price is refused with the sameCAMPAIGN_PRICE_LOCKEDthe till already localises). New items copy the catalogue's own currency/VAT category — the page invents no tax logic. Backend catalog specs 154/154, nav guard 15/15. - The template gallery knows the tenant's trade, and five trade-neutral looks join it (2026-08-04). Owner ask: "i choose some tempaltes which are for restaurants... there should be generic 5 different unique looking template with 6 color combination... which can be applied to any kind of business." Every existing template/starter/demo-pack is now trade-tagged (
verticals, absent = everyone — the nav-catalog convention) and the builder's theme gallery filters onverticalOfTenant(the install route also refuses an out-of-vertical slug from a stale tab). Found and fixed a deeper leak on the way: EVERY theme install ran an auto-map that injected amenu-previewblock and a/menu-CTA hero onto the site — a services tenant now gets a no-menu guarantee and a/contactCTA fallback, and the guarantees apply to the home page only. Five new GENERIC packs — Impact, Editorial, Showcase, Meridian, Mono — each a genuinely different layout built only from trade-neutral blocks, each with the same six complete 9-token colour combinations (Azure & Navy, Forest & Cream, Charcoal & Gold, Plum & Blush, Teal & Sand, Monochrome) opening on different defaults, install = "use my content" (no demo bundle, navConfig untouched so per-trade nav defaults rule). Tests: 56/56 incl. new vertical-filter, trade-cleanliness, services-no-injection and layout-distinctness assertions. - The booking loop closes on both sides — owner actions, timeslot editing, and customer self-service with a cancellation policy (2026-08-04). Owner asks: "where can i cancel the booking or ask for new time against customer? where can i customize the timeslots?" and "i need to add email too while reserving... the owner can choose... cancel without any cost time, and if a specific time then the customer will be charged." (1) Owner side — the dashboard Bokningar page gains per-booking Avboka (confirm-first, the till's idempotent cancel via new
POST /pos/admin/bookings/cancel) and Flytta (a 14-day free-slot picker; newPOST /pos/admin/bookings/reschedulemoves via create-then-cancel ordered so the customer never holds nothing, with same-resource self-overlap handled by cancel-create-restore), plus a Schedule & time slots editor per chair (weekly open windows, slot length 5–60 min, active toggle, add-chair) over the existing admin resource/schedule endpoints, and a Cancellation policy card (free-until hours; late fee none / full service price / fixed amount) stored onsettings.bookingPolicy. (2) Customer side — /book now requires email; the confirmation emails a manage link (HMAC key over tenant+booking — an email click is the whole authorization, no account) to/book/manage, where the guest sees their booking and the policy verdict for right now ("free until…" or "cancelling now will be charged X") and cancels; a late cancel recordscancellationFeeMinoron the pos_bookings row (the no-show-fee discipline — charging happens at the till, never online). Booking contract additively extended (customer.email, cancellationFeeMinor) in the frozen IDL + backend + ui mirrors. (3) More restaurant leakage out of the barbershop — Chains & Groups (Restauranger/Gruppmeny/…) is nowverticals: ["restaurant"], and the overview's Köksskärm tab learned the trade axis (a salon's PLAN may include kitchen-display; its trade has no kitchen). - A customer can book a haircut online — /book writes the till's own book (2026-08-04). Owner ask: "as a customer i can't book haircut from tenants website" — the Book buttons dead-ended (the seed pointed them at
/contact, which does not even exist as a tenant page and fell through to Restora's company contact). Now the services trade has a real guest flow:app/tenant/[tenant_id]/book(services-vertical-gated, restaurant tenants 404 the mirror of /reservations) renders a three-step wizard — service (from the POS catalog's SERVICE items via newGET /pos/admin/catalog/services), day + free time (newGET /pos/admin/bookings/availability; the page offers only start times where EVERY covered grid slot is free on one resource, the same arithmetic the server's unique slot index enforces), then name + phone (REQUIRED online — an anonymous online booking is a no-show generator) →POST /pos/admin/bookings/create, a REALpos_bookingsrow withcreatedByabsent (the "guest self-booking (web)" case the persistence doc always reserved). The till's Bokningar and the dashboard book show it the moment it lands; a lost slot race comes back as 409 and the page refreshes the grid. Public BFF routes under/api/public/salon/[slug]/{services,availability,book}gate on active tenant + services vertical and a 60-day horizon. Every Book CTA now points at /book: the seed's hero + pricing tiers (tier links join the BlockRenderer rebase list so path-served sites work too), aBookitem in the services default storefront nav (chrome-translated in all 21 languages), and the SiteHeader trade filter learned the difference between/book(the trade's own page — kept) and#book/reservations (table affordances — dropped). Times render UTC HH:mm — the same wall-clock the till, the dashboard and the owner's authored schedule all show, until per-tenant timezones move every surface together. - A barbershop signs up as a barbershop — TenantRecord.vertical finally has a writer (2026-08-04). The field every reader learned this week (dashboard nav, storefront, route gates, JSON-LD) had NO producer: nothing in the product could declare a tenant's trade. Now the /register page carries a three-tile trade picker (Restaurant default / Barbershop / Grocery "Coming soon";
?trade=barbershoppreselects — the /products/pos card links straight in), Barbershop swaps the business-type options (barbershop/hair-salon/beauty-salon/massage) and the page copy ("Create your business"), and the submit carriesvertical: 'services'— validated byRegisterSchema(z.enum(VERTICALS), never derived from free-text businessType), stamped on the TenantRecord and passed intocreateTenantDatabaseso the services website seed fires from the first byte. Onboarding follows the trade end to end: the /welcome wizard's steps/copy/next-steps branch (services: Bookings → Sell → Customise your site), the Setup-guide tab rendersonboardingStepsFor(vertical)(services: profile, hours, staff, website, till — with auto-detection only where the data genuinely distinguishes owner action from seed defaults), and branch creation inherits the HQ's vertical so a barbershop's second shop is not born a restaurant. The public landing wizard stampsvertical: 'restaurant'explicitly — it is a restaurant funnel by design. Restaurant flow byte-identical throughout when the field is absent. - The marketing site sells the POS by trade (2026-08-04). The Product mega menu gains a FEATURED panel — a navy band with a big azure icon tile, per the owner's "so nobody can miss it" — linking to the new
/products/pospage (/positself is the till SPA's route): hero "One POS, built for your trade", a three-card vertical grid (Restaurant → /apps/pos, Barbershop & Salon → /register?trade=barbershop, Grocery & Retail "Coming soon"), the presets-over-one-kernel line, the three-surfaces band and a Start-free CTA. Data-driven (featuredon the nav item, one definition for desktop dropdown + mobile accordion), azure-on-navy tokens only, registered in sitemap.xml + llms.txt. Brand-guard tests 11/11. - The till adapts fully to the barbershop trade — park, split and the KDS side doors (2026-08-04, all three POS surfaces). Owner ask: "the barbershop module should work according to what barbershop needs, they dont have kds, park bills, split bills." Three leaks closed. (1) `park_bills` minted (21/25 of the capability budget; the owner sentence above is the written decision
capability.tsdemands): park was the ONE bill surface no capability governed — universal on both rails, capability-free on the server. Now the restaurant/quick-service/shop presets carry it, salon does not; the server gates park creation only (POST tickets/park,:id/park) while list/resume/discard stay register-token-only, and both rails apply a drain rule — a till that lost the capability while bills were parked keeps Parkerade visible until the count reads zero, so a preset flip can never strand money (the §3d incident class). Backfilled on the box and local dev: every non-salon till grantedpark_bills(capabilityRevbumped), the salon till's seed-grantedsplit_tenderstripped (the seed contradicted the preset — reconciled seed-side). (2) Native payment-sheet parity: Compose's "Dela notan" and gift tenders were ungated while web gated them since Phase 10 —PaymentFlownow takescanGift/canSplitfrom the register's real capability set. (3) KDS side doors closed: web?kds, Windows--kdsand the Android KDS launcher booted the board on pairing alone; a salon device became a permanently dead kitchen screen (every KDS endpoint 403s, only 401 unpairs). Both apps now refuse with words and a way out (back to till / re-pair) — fail-OPEN on an empty capability snapshot so an old cache can't brick a real kitchen board. Gates: Compose desktop suite green (rail test now asserts the universal THREE + salon rail without park), pos/web 82/82 + tsc 0, backend tsc 0, bundle re-published toui/public/pos. - The barbershop's appointment book reaches the web dashboard (2026-08-04). Owner ask: "the bookings from PoS are not visible on web." The till has written
pos_bookingssince Phase 6 and rendered it on all three POS surfaces, but the ADMIN side could not read a single booking — the backend's InternalGuard surface covered resources + schedules only, andui/srchad no page, route or nav entry for appointments at all (the nav-catalog comment even names the gap: a salon's appointments are "a different subsystem entirely"). Now:GET /pos/admin/bookings(backend, InternalGuard;BookingsService.listDaynarrowed to take a bare tenantId — the one field it ever used), a thin proxy at/api/tenant/[tenantId]/pos-bookings(requireTenantAccess→internalGet, bookings + resources in one read), and/dashboard/bookings— a read-only day view (day nav, per-resource filter chips, status pills matching the till's vocabulary, 30s follow-along refresh). Read-only BY DESIGN: creating, cancelling and checkout stay on the till, where the operator stamps, money paths and the double-book unique index live. Times render UTC like every other booking surface (the module's honest v1 time model). Nav: newBookingsgroup,verticals: ["services"], in the DAILY slot where a restaurant sees Reservations — label translated in all 20 languages (test-enforced), page strings in en + hand-written sv (posBookingsdict namespace; other languages fall back per the dict design). NOT confused with/api/internal/pos/bookings, which is the restaurant TABLE-reservation bridge on the tenant blob. - A services tenant gets a services WEBSITE, not a pizzeria's (2026-08-04). The other half of the owner's ask ("the website for client is still for restaurants"):
TenantRecord.verticalhad exactly one consumer — the dashboard sidebar — while every public-site path was born restaurant. Now vertical-aware end to end:newTenantTemplate('services')seeds a booking hero / services-and-prices / team / testimonials / hours / contact home (all existing SectionRegistry blocks — a different SELECTION, no new components), salon opening hours, no Food Bazar listing row (bazarSettingsdisabled — a barbershop is not listable on a food marketplace);createTenantDatabasetakesverticaland words the SEO bootstrap per trade; the default storefront nav drops Menu/Reservations and the Book-a-Table + cart chrome for services (getDefaultNavItems(vertical), threaded through SiteHeader/SiteFooter and the nav + builder APIs via a 60s-cachedverticalOfTenant);/menuand/reservationsarenotFound()for the trade (route-gated, not just unlinked); JSON-LD emitsHealthAndBeautyBusinessinstead ofRestaurantand drops servesCuisine/ReserveAction/hasMenu; home metadata fallbacks stop promising a menu. Existing services tenants were stamped with restaurant blocks at creation, soscripts/reseed-services-website.tsswaps the factory-stamped parts (guarded — owner-authored blocks are never touched without FORCE=1). An owner-authored navConfig always wins untouched.
Changed
- A till shows only the surfaces its trade has, and a second till inherits the first one's trade (2026-08-03). Owner ask: "the admin panel is specifically designed for restaurant systems, where barbershop or grocery store will have a different requirements, i mean a very few specific items upon core system which will be the same. how do we handle that?" The kernel already answers it —
pos/shared/domain-types/src/capability.tsopens with "Restaurant" / "grocery" / "barbershop" are PRESETS that select a set of these … the words never appear in kernel, sales or fiscal code, 20 capabilities against a hard budget of 25 — andpos/webhas honoured it since Phase 10. Two places had not. (1) The native till's rail was a FIXED list rendered unfiltered (PosApp.kt), so the pilot's Butik register — created with notablescapability — still showed Bord on Windows and Android, and tapping it reached@RequiresCapability('tables')and came back CAPABILITYDENIED. A control that is visible, enabled and then refuses is worse than one never drawn: the operator does not learn "not for this trade", they learn "this till is broken". `canReach` now gates Bord on `tables` and Presentkort on `giftcards, matched on the SEALED TYPE rather than onidstrings — awhenover ids would have fallen through totruethe day an id changed, andGiftCards.idis"gift", not"giftcards", so that near-miss was not hypothetical. Deliberately a whitelist of the gated few, never a default-deny: Kassa/Ordrar/Parkerade/Historik belong to every trade, and a preset that merely forgot to name one must not hide a till's own sales history. A destination that becomes unreachable (re-pair into a different trade) sends the till home to Kassa, which is always reachable, so the redirect terminates. (2) **Order type is a TAX input, not a label** —tax.tsresolves the rate per line from (country, taxCategory, Motion, date) — and the native till rendered Servering/Avhämtning/Leverans unconditionally and defaulted every line todinein`, which is a restaurant-service claim about a bottle of shampoo. It now hides the control and sends `retail` on a till without `kitchenrouting, exactly as the web till has since Phase 9. **Second-till inheritance:** the four-way trade picker on/dashboard/devicesonly renders whileregisters.length === 0, and every device after that tookdeviceSurface('pos').capabilities— which IS the restaurant preset. So a grocery chose **Butik** once and every till after it was born a restaurant. The trade is persisted nowhere (ConnectPosDto.verticalpicks capabilities once and is discarded, so nothing in the product can ask "is this tenant a grocery?"), but the answer is already written on the tenant's own tills and the device route had that list in hand the whole time — it was reading country and currency off it and stopping there.inheritedCapabilitiescopies the OLDEST sibling till (the one whose trade a human chose by hand; a later till created under a wrong preset must not become the template every till after it copies), skips empty capability lists, and inherits POS-from-POS only — a KDS iskitchenrouting` by definition and copying a till's capabilities onto a customer display would hand a guest-facing screen the ability to sell. **Explicitly NOT done, and named because it is invisible otherwise:** a barbershop still cannot check a customer out on two of three surfaces — `bookingscheckoutgates seven server routes and an 824-lineBookingScreen.tsxthat exists only inpos/web, with no Bookings destination in Kotlin at all. Also rejected: inferring the vertical from the existing free-textbusinessTypeby substring, which an adversarial pass killed — that field isz.string().min(1).max(50)with no enum, andspais a substring of *"Spanish"* whilemarketis one of *"Fish Market"*, so it would silently misclassify live pilot restaurants. Gates: Compose 45 suites / 556 tests (+9, four mutations caught: both gates removed, default-deny, wrong capability string);ui/src/lib/pos101 tests (+12, seven mutations caught including two aliasing bugs where the returned list was the sibling's array or the module-level preset itself);ui npx tsc --noEmitzero errors in changed files (64 pre-existing inui/scripts/`).
- Enheter — every device surface collapsed into ONE page with a non-technical IA (2026-07-29). Owner ask, verbatim in spirit: "In navigation I have Devices, POS Registers, Köksrouting, Devices & apps — so many nav menus for the same purpose. Make a single page 'Devices'. It should be easy to understand, easy to connect, clear separation between devices so users can see easily. A non-technical person must be able to connect PoS, KDS, SSK, customer display, menu display, printer, payment terminal and other hardware." `/dashboard/devices` is now the only door to hardware. It merges the freshly-rebuilt POS-enheter page (four register surfaces) with the six-tab Hardware Center into eight collapsible boxes, one per DEVICE TYPE, every box shaped identically: header carries Status · Öppna · Parkopplingskod, expanding it explains how to connect that device from Webbklient / Android / Windows. Boxes: Kassa (PoS) · Köksskärm (KDS) · Självbeställning (SSK) · Kunddisplay · Menydisplay & TV-skärmar · Betalterminal · Kvittoskrivare & övrig hårdvara · Nätverk & stabilitet. The tab bar is gone on purpose: a tab bar hid five of six fleets behind a click and answered none of "which of these is actually connected?", which was the question being asked — boxes show all eight statuses at once. Per-platform connection guidance is the new substance, and every line is verified against the thing it describes rather than written plausibly: the web row prints the literal address plus a one-click open (
/pos,/pos-native/index.html?kds,/pos?display,/kiosk/station,/tv); Android names the launcher icon to tap — Restora POS Native for the till and Restora Kök for the kitchen, which are two genuine LAUNCHER activities inandroidMain/AndroidManifest.xml, not aliases; Windows names the program (Restora360POS) and states that the KDS shortcut is the same app started with--kds(desktopMain/Main.kt, which also accepts--surface=kds). Install links come fromNEXT_PUBLIC_POS_ANDROID_URL/NEXT_PUBLIC_POS_WINDOWS_URLand, because neither is set anywhere yet, render the honest line "Appen är inte publicerad än — installeras manuellt av Restora vid uppstart." instead of a button that 404s. A platform that does not apply says why, never an empty row: a receipt printer's Android row reads "utskriften går via Restora Windows Station på en dator i lokalen"; a payment terminal's web row reads "terminalen är en fysisk kortläsare, inte en sida man öppnar". Pairing direction is modelled, not narrated — it genuinely runs both ways and getting it backwards is the common support call: registers aremint(the dashboard mints a single-use 15-minute code you type INTO the till), while TV/kiosk/terminal areclaim(the device shows a code you type in HERE), and printers areagent(registered here, claimed by a Windows Station). Every capability of both predecessors survives: terminal claim + enrollment QR, kiosk fleet + add-on gate, printer/drawer/scanner registry + job log, screens manager + QR pairing + the legacy shared-display link, reliability insights, owner-phone sessions, and the register-level Kassapersonal PIN admin (re-homed into the Kassa box, where a PIN is actually true). The old overview's universal three-mode pair form was dropped as pure duplication — TerminalsPanel and ScreensManager already own those flows — except its unique "POS tablet" path (pos-devices/claim, a 90-day session for the legacyandroid/tablets), which became its own card worded so nobody reaches for it when they meant to pair a new till. Kitchen routing was deliberately NOT absorbed: it configures what a KDS shows rather than connecting a device, so/dashboard/pos/routingstays a real page and is hub-linked from inside the KDS box ("Konfigurera köksrouting →"). Honest-status discipline throughout: every count comes from a real endpoint (/pos/devices,/devices/summary) and a failed summary reads "Status okänd" rather than a fabricated 0; the printer box states in one line that without a connected Windows Station a receipt is stored as a preview in the job log instead of printing, becauselib/devices/dispatch.tsdocuments the driver as a mock pending a real ESC/POS transport. Nav: the four-item "devices" group collapses to a single flat Devices link —/dashboard/posjoinsABSORBED_ROUTESas aPosRegistersRedirectstub,Köksroutingmoves toHUB_LINKED_ITEMS, andGo Mobilemoves to Venue Setup (despite the name it is the roaming seller's stop schedule feeding the "Find Us Today" website block — no device, no pairing, and the sole reason this group still needed a dropdown). All retired labels stay searchable as group keywords; old?tab=deep links from the IA-2 stubs still land, mapped onto the box that absorbed them and scrolled into view. A11y: each box is a real disclosure —<button aria-expanded aria-controls>with the panel as a labelledrole="region"— and the header actions are siblings of that button, never nested inside it (an<a>inside a<button>is invalid and unreachable by keyboard). 320px: title block and the three actions stack instead of overflowing. Gates:ui npx tsc --noEmitzero errors in every changed file (53 pre-existing errors elsewhere in storage/themes/experience-studio/media untouched);nav-catalog.test.ts15/15 pass, including the absorbed-route stub, hub-link-liveness, 20-language label and retired-vocabulary guards.
Added
- Food could be handed over with the money recorded nowhere — the whole arc, from the switch that prevents it to the chip that finds the debts (2026-08-03, `788793d0` → `de31862c`). Owner report: "i ordered an online delivery order, everything was smooth, but i didn't pay it and i was able to deliver and close the order #0020." Confirmed on the pilot tenant and systemic, not a one-off: five of twenty orders (#0001, #0002, #0003, #0012, #0020) sat at
status:'delivered',paymentStatus:'pending', zero rows in `paymentLedger` and no kassakvitto — 1 855 kr of food out of the building, existing nowhere in the system. The path map (an adversarial pass, two independent verifiers per finding) established the shape of the fix: the rider's Delivered tap reaches/api/internal/pos/order-actionaction:'delivered'TWICE (the delivery mirror, andclosePosOnlineOrder→markOnlineOrderDelivered) and the till's hand-over control posts the same action, whose entire body wasappendTimeline({...order, status:'delivered'}).recordOrderPaymentlives only in the'paid'branch andrecordOrderFiscalSaleonly under it, so neither was reachable. One chokepoint, so the rule lives there once — a guard on the rider's tap would have left the till's button open, the exact shape that had already cost two bugs the same night. (1) The hand-over does NOT refuse, and that is the design. Cash on delivery is legitimate — the order is genuinely unpaid until the door — and a person in a stairwell must never be blocked by a server. Worse: if naming a tender were the only way past, a rider whose customer has no cash would name one, and the books would then assert money that does not exist — worse than the bug, because an uncollected order is at least visibly unpaid while a false "cash" is a lie nobody can see. So the hand-over always succeeds and the DEBT IS RECORDED:handedOverUnpaidAt+unpaidAmountAtHandover, set-once, and only when the order is genuinely unpaid AND actually owes something, so a prepaid order and a gift-card-covered basket both carry nothing. (2) The reason unpaid orders existed at all.CheckoutFormseeded its method list with["cash"]under the comment "cash is always available" — no restaurant on the platform had ever been able to decline pay-later, which is how a Swedish venue that expects prepayment collected five debts. NewpaymentSettings.cashEnabled, ABSENT MEANING TRUE so nothing changes on upgrade, honoured at checkout and re-decided server-side increateTenantOrder(hiding a radio button is not a rule) with carve-outs for an already-settled channel order, a ZERO total and a gift-card basket.checkout.noPaymentMethodsauthored in all 21 storefront languages. The dashboard switch sits above the payment rails, because everything below decides HOW money arrives and this decides whether food may leave before it does; both positions explain themselves, since a switch whose copy describes only its "on" state leaves an owner guessing what turning it off did. (3) The rider could not see what to collect.DeliveryRunDoccarrieddeliveryFeeMinorand a currency and nothing else about money — the phone could not have said 49 kr was due even if the rider had wanted to collect it, which is a defect BEFORE any missing ledger row. NewamountDueMinor, and deliberately NOT behind `driverSeesEarnings`: that flag hides what the RUN EARNS from a salaried rider and defaults off for staff drivers, so gating the two together would have blinded exactly the venues where cash on delivery happens. Presence is the whole signal — the server omits it for a paid order and never sends a zero, so "collect 0 kr" cannot appear and a prepaid delivery looks unchanged. (4) The collection itself, on both driver clients: the amount as the hero, then Kontant / Swish / Kort — and in the same list at the same target, "Kunde inte ta betalt". The tender rides the SAMEpaidaction the till's Betald button posts (which already writes the ledger row and registers the kassakvitto), mirrored BEFORE the delivered one because the registration mints the löpnummer the receipt must carry — pinned by a test asserting the index of one against the other. Swish joined the tender vocabulary by name, since folding it intocashwould put a Swish collection in the drawer's takings and make the Z-report disagree with the till. It rides the HELD op beside the POD, so the ten-second undo takes the money record with it and a dead spot delays rather than loses it. (5) The debts are visible: a newowingchip on the order history, on all three POS surfaces. Deliberately NOT a synonym forunpaid— an order placed five minutes ago for cash on delivery is unpaid and fine, so the chip needs BOTH halves, unpaid AND already handed over, with cancelled excluded because an order nobody received is not a debt. It issues no till read at all (a closed till sale is paid by construction). Verified live on the pilot tenant:owingreturns exactly the five,unpaidreturns nine. The five are deliberately NOT backfilled — nobody knows whether that money was collected, and marking them paid would be inventing a fact; they show as what they are. One shape appeared three times in one day and is worth naming: a request body assembled field by field silently drops anything not listed — the backend write-back droppedmethod(every door collection would have arrived as unnamed cash), the webopWireBodydropped the tender, and the KotlinDriverDeliverRequestwould have too. All three are now explicit with a comment saying why. Gates: backend POS 196 sales / 199 delivery, Compose 531,pos/web75, ui orders+fiscal 156, Swedish ratchet at baseline,pos-publish-gategreen.
- Find one order from the counter, a cancelled order that finally goes away, and a promise that stops moving (2026-08-03, `d2b772ee` + `0a274c18`). Four owner reports from the same session, two of which turned out to be the same defect shape as the night before. (1) "order history under Ordrar" — the scenario given was a telephone call at the counter: "suppose a customer calls and say i ordered a pepsi but i didn't received it, the cashier can quickly bring the order from history and double check if the pepsi was really inside that order." That makes search the feature and placement the smaller half, because an order from three days ago is on no first page.
GET /pos/kitchen/orders/historygainsq, matched against the order reference OR the customer name and pushed into both journals' queries — a search filtering rows the cap had already cut would hide the single row the feature exists to surface, which is precisely the chip-filter defect fixed hours earlier. The TYPED TERM is normalised, never the stored value: a leading#is stripped (it is how the product prints an order number, never how it stores one, so leaving it in makes the most natural thing to type the one thing that never matches), and leading zeros go only when what remains is all digits —#0042,0042and42are one answer while007agentkeeps its zeros, and000stays a search for0rather than becoming a search for everything. Blank or a lone#means NO search, never "match nothing". Every regex metacharacter is escaped, so a cashier typing(gets no match rather than a 500. Ordrar gains a Live/Historik segmented control mounting the SAME component the rail destination mounts — deliberately not a fork, commented on all four screen files because Historik owns refunds and somebody will otherwise "dedupe" it. Found on the way: the Compose history screen drew a network error AND "no orders in this period" together; conflating those is what made the original complaint invisible. (2) "why order #0016 is visible in my PoS there is no way for me to hide that from my order view." There was not: the FOH Orders read had a tenant, a channel, a 24-hour window and adeliveredAtcheck and no status predicate at all — and a cancelled order never gets adeliveredAt, so it sat on the till for a full 24 hours with nothing on screen able to clear it. Cancelled tickets now leave that view, excluded rather than aged out on a shorter timer because "why is this still here" was the question and a 30-minute answer is still 30 minutes of it. It also makes the FOH view agree with the KDS board, which has always dropped cancelled tickets — the kitchen never saw #0016, only the front of house did. Nothing is lost: the cancellation is in the journal and history has acancelledchip. Pinned at the QUERY, because it is a filter clause and a service test over a mocked repository would pass with the clause deleted. Verified live on the pilot tenant: #0016 absent from the board while present in history, andq=16/q=#0016/q=0016each returning exactly it. (3) + (4) "the timer resets to 35 min when i accept" and "add set delivery time while accepting — 10, 15 … 60 and custom like 2 hours" — one feature, because accepting is when the promise is made.TrackerClientcomputed the target on every render aspreparingStartedAt ?? acceptedAt ?? createdAt + prep, so the anchor moved forward every time a later timestamp appeared:createdAtbefore acceptance,acceptedAtafter (the reported reset), andpreparingStartedAtafter that — a second reset nobody reported only because they had stopped watching. A promised time recomputed from whichever stamp is newest is not a promise. It is now minted once at acceptance and stored as an absolute instant; what crosses the wire between the POS and the legacy order is that instant and never the minutes, since re-anchoring minutes on the far side's clock lands seconds away from what the operator was shown. It rides the same conditionalawaitingAcceptance: trueupdate as the acceptance, so a losing double-tap cannot move a time a guest is already counting down to. `createdAt` is gone from the anchor chain entirely — placing an order is the GUEST's act, not the venue's promise, and treating it as one is what put a 35-minute countdown on screen before anyone in the kitchen had seen the order; no acceptance now means no countdown, which the tracker type already had a spelling for (null ⇒ hide). The picker: presets 10/15/20/25/30/35/40/45/50/60 in a grid where tapping one accepts (two taps for the common case), "accept without a time" always one tap away in the same sheet so speed never became a required field, and custom one extra tap giving hours and minutes as steppers rather than arithmetic in a minutes box. Validation is a RANGE (1–480), not the preset grid, because 150 is a legitimate answer on no grid; eight hours is the ceiling since past that it is a booking, not a prep time. An absent value stores no promise rather than inventing one, which would silently overrule the venue's configured preparation time. Every accepted card now shows the promise as a wall clock ("Klar 19:35") so an operator quotes the clock the guest is watching. Legacy orders fall back to the venue estimate anchored onacceptedAt(set-once, so it cannot drift); an unparseable promise degrades to that estimate and an unparseable anchor hides the chip rather than renderingNaN; and un-accepting now strips the stale promise, or a re-accept with no chosen time would resurrect a countdown the kitchen is no longer working to. Gates: backend 54 suites / 740 tests, Compose 531 (+ wasmJs compiles),pos/web75,uiorder-tracking 38, Swedish ratchet at baseline 0,pos-publish-gateandcheck-addressinggreen, bundle republished so web and Windows carry it too.
- Order history that actually contains the orders, on all three POS surfaces — plus the Bord side panel and a test print that speaks the printer's language (2026-08-03, `7566c1ec`). Three owner reports from one evening of live testing, each of which turned out to be a different piece of code lying about what it had done. (1) "i cant see the order history on my PoS android tablet." The Historik tab existed — wired to
GET /tickets?status=closed, which the server pins to the asking register. So it showed that till's own receipts and never once an online, pickup or delivery order, because those have never been POS tickets. New register-token route `GET /pos/kitchen/orders/history` (RegisterGuard+ capabilitysell, no@RequiresOperatorso a manager may look at a signed-out till, and no tenantId from the client — the token resolves the scope) merging the two POS-owned journals:pos_ticketsclosed sales tenant-wide (the half the old read could never answer) andpos_kitchen_ticketsonline orders, plus one$inoverpos_tendersfor payment method. Window clamped to 31 days (default 7, never into the future, an inverted range falls back rather than 400s), limit capped at 200, both reads verified to ride the existingtenant_closedAt/tenant_channel_firedAtindexes, and no money arithmetic anywhere — every figure is a stored integer copied out of a document, and online lines get no price rather than one derived from the total. Two defects the adversarial pass caught before it shipped, both of which would have reproduced the original complaint:truncatedwatched each journal's own cap while the CUT was applied to the merged array, so 60 till + 60 online at limit 100 capped neither source and the flag said "complete" whileslice()dropped the 20 oldest (nowrows.length > limit || tillDocs.length >= limit || onlineDocs.length >= limit— the second clause still needed, because one source capped at exactlylimitgivesrows.length === limit, not greater); and the chips filtered in memory, after the per-source cap, so asking forcancelledread the newest 100 of each and then filtered — an empty list meaning "your filter fell off the end of a cap you cannot see" while reading as "there are no orders". Filters are now Mongo predicates built per journal, and a chip no document in that journal could match skips the read entirely rather than round-tripping for nothing. It also landed on ONE surface, which ships nothing: `pos/web` now reads the same route with the same behaviour and `ui/public/pos` is republished in the same commit — without that the browser till and the Windows station keep serving the old screen andcheck-pos-surfacesstays red. (2) "on 'tables' section there are three rows named 'Nota' 'inget bord' looks messy." Those are bills the floor plan cannot draw — deleted tables, tables with no designer coordinates, counter sales bound to nothing — crammed into a 168 dp scroll box between the floor tabs and the room. They now live in a right-hand panel (BoxWithConstraints, the same820 dp/1180 dpbreakpoints andPosVerticalDivideras RegisterScreen, so the two screens agree) which also carries today's bookings including the unseated ones the old derivation fetched and threw away, and for a selected table its seats/area/floor, its bills and its booking. That content is the dialog previously reachable only by a three-second hold; the dialog survives as the narrow-screen fallback. 15 findings raised in verification, 14 refuted — and what came back clean is what mattered: the bill-ownership guard, the also-on-this-table set, andprimaryActionbeing branch-identical to the oldopen(), so no action can target a bill the screen is wrong about (four bills were stranded by this exact flow in the past). The one survivor is fixed: the compact bar could advertise "Notor utan bord (2)" and reopen onto a table tapped earlier, cleared on entry rather than on close, because after a window resize nothing closed and a reset on the close path leaves half the bug alive. (3) Three test prints to a reachable Zebra produced no paper — and the app said they succeeded.printOne()has always resolved the printer's language and encoded accordingly;testPrint()andconnectTo()calledescPosText()unconditionally. A ZPL printer discards anything outside^XA…^XZ, so the socket accepted every byte, the write flushed, the transport honestly reportedWritten, and the screen reported success. A silent false success is the worst failure this agent can have, and it is the same bug classprinter-language.test.tsalready pins on the web side. Both test paths now resolve once throughresolvePrinterLanguage(stored → inferred from the printer's announced name → ESC/POS) and encode accordingly, with an on-device^XA…^XZtest label opening^CI28. Inference is never persisted, so a later discovery cannot overwrite an operator's explicit choice, andunpair()/setTarget()keep the language for the same reason they already keep the address. The settings card asks in plain words what kind of printer it is (naming the command set in parentheses for whoever needs it) and states out loud what happens if you pick wrong. Gates: backend POS 701 tests, Compose desktop 507,pos/web51,tscclean, Swedish-literal ratchet unchanged,pos-publish-gateandcheck-addressinggreen.
- Food Delivery — own drivers, end to end (2026-08-02, R360-C12). A restaurant's OWN delivery fleet: roster + phone pairing, order-to-driver dispatch, live tracking for dispatcher AND customer, proof of delivery, offline-safe driver taps, settings, analytics. NOT the aggregator integration —
isOwnDeliveryOrder()remains the discriminator andlib/delivery/providersis untouched. Plan of record:docs/products/food-delivery/FOOD-DELIVERY-PLAN.md; runbook beside it. Backend: newPosDeliveryModule(backend/src/modules/pos/delivery/, six-file anatomy) over 7 typed collections (delivery_drivers/devices/pairing_codes/runs/driver_frames/events/settings, indexed same-PR). A driver phone is deliberately NOT apos_registersrow (no fiscal seriesPrefix, no SE-journal enumeration): it pairs like a customer display — dashboard mints a single-use 8-char code (15-min expiry, 7-day explainable retention), phone exchanges it for a 48-byte token, sha256-only at rest,x-driver-token→DriverTokenGuard→DriverScopethat never setsposScope, so a leaked driver token reaches exactly one module. Run machine has FIVE tappable statuses (pending→offered→accepted→picked_up→delivered, terminalsfailed/canceled) — "heading to restaurant / driving / near customer" are derived from GPS, never tapped. Every driver tap carries a ULIDopIdspent indelivery_eventsINSIDE the same transaction as the CAS transition; the E11000 replay answer is caught AROUND the transaction (swallowing it inside commits an aborted txn and loops to the 120 s cap — the sales-path lesson) and must prove same-run/same-kind/same-driver against the stored event, or a spent opId POSTed at another runId would hand back that run's customer sheet. "One live run per order" is database-backed (openKey= orderId while non-terminal, unique partial index,$unseton terminal transitions) so a dispatcher double-click cannot send two drivers to one customer. Offers expire by clock ON READ (no cron), CAS-guarded across replicas, with the expiry bound pinned in the accept CAS (+15 s grace) so a stale accept refuses deterministically; auto-reoffer walks online drivers with capacity, remembering who declined. Live positions are a display-frame-courier clone: one replaceable TTL doc per driver, rev bumped only on real movement, >200 km/h implied-speed frames stored but flaggedsuspectand excluded from ETA. Dashboard:/dashboard/dispatch(Board/Drivers/Settings/Insights) behind newown-deliveryfeature (registry + nav + 20-language label +dispatch→orders.manage); three-lane board with keyless-OSM driver map, 2-tap assign, pairing modal with giant code + QR, POD/offer/leave-at-door settings, analytics in integer öre. 15 thin proxies under/api/tenant/[tenantId]/dispatch/*(requireTenantAccess + requireFeature + requireWritable/requirePermission on writes). Driver app:/driver(proxy-bypassed, mobile-web v1 — the backend contract is byte-portable to the Kotlin/Compose port per the plan's decision table), sv/en, huge one-primary-action screens,?pair=deep link, Navigate/Call deep links, KdsOutbox-rule offline outbox (dedupe-key supersede, FIFO drain gated ahead of every poll, queue-on-undecided/drop-on-decided, revocation keeps the queue; drain re-reads the live queue after every await so a mid-flight tap is never clobbered, and a live send queues behind ANY queued op for the same run so a deliver can't jump its own pickup), GPS publisher (8 s visible / 30 s hidden-with-active-run), POD sheet (photo downscaled to a ≤200 KB scheme-pinned data-URL — honest v1 seam, TODO device-scoped media upload; signature canvas; PIN recorded-not-verified). Customer: both existing trackers (/trackand the emailed/bazar/orders/…?t=) now render a "Your driver" card — first name ONLY (the write-back itself sends only the first name, since the public tracker returns the order timeline raw), vehicle, server-computed ETA, live mini-map only whilepicked_upAND the tenant allows it, coarse-rounded coords, POD line at delivered. Order mirror rides the existing idempotent/api/internal/pos/order-actionseam with three new set-once actions (driver_assigned/out_for_delivery/delivery_failed) — orderstatusunion deliberately untouched;deriveStatusadvances on the newoutForDeliveryAttimestamp. Fixed en route: the OUTSTANDING-WORK §4 dead-code item — status-transition logic extracted to pureapply-status-transition.ts, wired into the dashboard PATCH route, backward moves clear forward stamps includingoutForDeliveryAt. Gates: backendtscclean + full jest 560/560 (68 delivery tests: pairing incl. cross-tenant name-inheritance refusal and claim-before-mint ordering, run machine incl. replay-mismatch refusals and the double-click race, route-plan 2-opt, ETA clamps, frame throttle/suspect, track-info privacy); ui delivery/driver/tracking/dispatch node tests green (outbox 20, tracking+model+templates 89, dispatch 9); uitsczero errors in every new/touched file. Load harness:scripts/delivery-sim.mjs(N virtual drivers over the real HTTP contract, with a deliver-replay idempotency probe).
- One gift-card store — the parallel-systems split closed end to end (2026-08-01, P1 `3eb80c6f` / P2 `5735d083` / P3). Gift cards were TWO systems that never read each other:
db.giftCardson the tenant blob (24 modules — buy page, corporate bulk, subscriptions, wallet passes, risk, closed-loop; major-unit floats) and the POS'spos_giftcards+ ledger (integer minor, transactional). A website card was refused at the till, a till card was invisible to the dashboard, and account 2421 drifted structurally negative. Owner ruling: one store — the POS collections win the storage, the legacy design wins the features (docs/pos-rebuild/one-giftcard-design.md). P1 (backend): five InternalGuard endpoints — full-fidelity mirror issue (pinned legacy codes normalized soGIFT-K3FX-9A2Pstays findable,legacyIdidempotent from either side, a caller-pinned code REFUSES on collision rather than rerolling), find by code/cardId, windowed list, and an online redeem / refund-credit / void trio: own transactions, deterministic-opId idempotency, a void records the dying balance in the ledger before zeroing so Σ ledger ≡ balance survives. P2 (the cutover machinery):GiftCard.posCardIdmarks a card as pos-authoritative;getCardByCode/ByIddual-read (marked cards overlay LIVE balance, misses fall through to the pos store — which is how till-issued cards appear everywhere at once); every in-lock mutator refuses marked cards (apply THROWS — a route reaching it has a routing bug; reverse SKIPS — it runs in rollback locks, and the pos-side reversal owns the credit), making the cross-store double-spend unrepresentable rather than unlikely. Online redemption lands in the ONE seam (createTenantOrder) +payment-intent: detected pre-lock, settled post-lock like the closed-loop precedent, idempotent per order, live re-capped, a refusal applies 0 and the PSP charges the full remainder so a failure can cost the guest nothing; rollbacks credit the pos card back money-first (no refund row unless the credit committed).markPurchasePaidmirrors each activation into the one store best-effort (stamp only after commit; the re-runnable migration sweeps failures). The dashboard list merges both stores and saysposStoreIncluded:falseinstead of degrading silently. Scripts, written NOT run (rule 6): the migration now stamps + re-healsposCardIdon the blob;tombstone-legacy-giftcards.tsrenamesgiftCards→giftCardsMigratedonly after verifying every card has a pos twin — one uncovered card refuses the whole run. P3 (books): till-issued presentkort now CREDIT 2421 through the same purchased-card shape the buy page books (tillIssuedGrossreports it); pre-cutover windows keep their credits post-tombstone viagiftCardsMigrated; an unreachable gift store raises a critical books notice instead of quietly booking nothing. Gates: backend giftcards 31, ui gift-cards 74 (incl. the new guard suite, proven non-vacuous — disabling the guard fails exactly its throw-tests), orders 72;tsc+ eslint clean; both scripts dry-run clean.
Fixed
- Paying at the counter for an online order printed nothing — and the pin that should have caught the kitchen bong drifting had been red for days (2026-08-03). Owner report from a live test: an order was placed online, accepted at the till, marked paid by card, and no receipt came out. Not a printer fault.
planOrderJobsgates the receipt onorder.paymentStatus === 'paid'and it runs once, at creation — an order placed "pay on pickup" ispendingat that moment, so it correctly plans no receipt, and nothing ever asked again. The till's Betald button posts to/api/internal/pos/order-actionwithaction:'paid', which wrote the ledger row, flipped the status, and dispatched no hardware at all; every guest paying at the counter for an order they placed online has been handed nothing since the acceptance split landed. New pureplanCounterPaymentJobs+ its fire-and-forget wrapper, in the shape this file already uses (pure planner, thin I/O). Deliberately NOT a second `planOrderJobs` call, which would plan all four documents off a now-paid order: a second delivery slip (creation already printed one — it fires regardless of payment precisely so a COD driver has paperwork, and two slips in a driver's hand is worse than one) and a second kitchen bong (acceptance owns that; a duplicate ticket landing on the pass mid-service is the exact failuredispatchHardwareForFiscalExtrawas carved out to avoid). So: the receipt, plus the drawer kick on cash — the till is not running its own sale flow here, so nothing else would open the drawer. It does not invent a kassakvitto:buildReceiptFiscalis called exactly as the sale path calls it, and an unregistered sale yieldsundefinedso the renderer prints no receipt number and no VAT rate table rather than fabricating either. Explicitly left open and raised with the owner rather than closed quietly: whether this path should also REGISTER the sale.recordOrderFiscalSaleis called from four places (order create when born-paid, the payment-rail settlement, both POS payment routes) and not from here — so on a Swedish tenant, an online order paid at the counter is money taken with no fiscal registration. That is a compliance decision about money, not a rendering bug, and it is the owner's call. The second half:printer-language.test.ts'sPINNED.kitchen/.kitchenNoStationhad been FAILING on master since the richer-bong work (cac877d5) and the allergen chain (4b.2) landed without re-taking them. A red pin protects nothing, and this one guards what a cook reads off the pass — so it sat unguarded while exactly the content it watches was being changed. Both tickets were rendered and read before the digests moved (the deliberate act the file's header demands): four lines are new versus the layout the old note describes, and a kitchen needs all four — the chosen option (• Storlek: Familj), added toppings (+ Extra ost), the line note (! extra het), and, as 4b.2 intended, the order note reaching the pass (Obs: allergi: nötter), which is the only one here that could put somebody in hospital. RECEIPT and DELIVERY SLIP digests untouched, which is how we know the change was contained to the bong. Gates: ui devices suites 210/210,tsc --noEmitclean in every touched file.
- A rider standing in his own kitchen marked a delivery complete, and there was no way back (2026-08-03). Owner report from live use: "delivered button should be removed as i accedentally pressed the delivered button while i was at home." Two changes, because one would not have been enough. The list can no longer finish a delivery.
driverCardActionis now the only thing deciding what a home card offers, and it has no branch that returns a completing action —DriverCardAction.completesDeliveryis false for every value it can produce, and the test asserts that over the whole status enum rather than over the cases somebody remembered to write. Finishing moved inside the run, where the rider has already chosen that order and can see whose door it is. And the tap is no longer instant. A deliver op is queued with an ABSOLUTE deadline ten seconds out; the drain steps over it until the deadline passes, so undo is a REMOVAL FROM THE QUEUE and never a reversal on the server — nothing to compensate, nothing to explain to a customer whose tracker already said "delivered". The deadline is absolute so it survives process death (relaunch inside the window redraws the strip with the correct seconds remaining), a held op does not wedge the ops behind it (except one carrying the samerunId, which must not overtake its sibling), a hold is not counted as "waiting on the server" so a 4-bar phone never paints itself Offline, and a clock that lies in EITHER direction still ends with the delivery sent — a wrong clock can cost a rider their undo, never strand a delivery. Delivery instructions also got their own box, having been one more line in the address block, which is where a rider stops reading. Built on both clients from one rule (DriverRunPresentation.kt/list-action.ts). Gates:DriverDeliverHoldTest24,DriverRunPresentationTest8, full Compose desktop suite 485/485 including the Swedish-literal ratchet; web driver suites 94/94.
- A printer that nobody talks to falls off the network, and the POS now holds it awake (2026-08-03). Owner report: a Zebra dropped off the network about a minute after its last conversation, every time — hold the feed button, the network light goes green, a minute later it is gone. Ordinary power management (a printer with nothing to print lets its radio sleep), invisible until the moment it matters, which is a kitchen ticket at half past seven. Nothing was going to fix that at the printer; keeping it awake is the POS's job and it costs one TCP connect every twenty seconds on a LAN. The probe writes ZERO BYTES. Port 9100 is a one-way stream with no reply channel, so there is no "are you there" command — any non-empty probe would be a document, and a document on a thermal printer is paper; a venue whose roll ran out overnight because the POS was checking on it three times a minute would be right to be furious. An empty write still requires the radio awake to complete the handshake, which is the whole thing being kept alive, and it physically cannot print, cut or kick a drawer. Twenty seconds because the observed drop was "a minute or so" and vendor idle timers are commonly 60 s with no way to read the remaining time from outside — three touches inside the shortest window actually observed, so one missed probe does not cost the connection. It is not a print and the counters do not say it was (
recordOutcomeis not called, soprintedstays a number about tickets that came out); a FAILED probe does setlastIssue, because a printer that has gone away is what that line is for, but a successful one never CLEARS it — a jam, an open cover and an empty roll all answer their socket perfectly, and a screen that went green twenty seconds after a real failure would be worse than one that stayed red. NewKeepAlivestep covers the case the owner is actually in (a printer chosen in settings, no print-agent pairing code ever redeemed); the zero-cost promise holds for everyone else, since a configured target is the only thing that turns a quiet tablet into one that opens sockets. Stated honestly in the KDoc: this keeps an AWAKE printer awake and cannot wake one already asleep with its radio down — nothing on a tablet can. +8 tests inPrintAgentPolicyTest; full Compose desktop suite green.
- `/ingredient` said "being populated. Check back soon." forever, and the weekly ingredient-catalog refresh had been silently failing since the docker-compose migration (2026-08-02). Owner report: the ingredient directory was broken, and the weekly USDA/Livsmedelsverket refresh the owner had configured with real API keys wasn't reaching the database. Two infrastructure bugs, both on the box, neither in application code: (1) the owner's
CRON_SECRETandUSDA_FDC_API_KEYwere sitting inui/.env.production.local— a file the OLD bare-next start/pm2 deployment read directly, but the current docker-compose setup never loads; the running container saw neither variable. (2) the crontab's dispatcher script (restora-cron.sh, not tracked in this repo — box-local ops tooling) was still hardcoded tohttp://127.0.0.1:3001, the old pm2 port; the docker-composeuicontainer has always been on 3000, so every cron route routed through this script — not just the two catalog imports, alsofsn-job-expiry,weather-refresh,daily-briefings,inventory-reports,fsn-account-purge,bazar-visit-reminders,tv-ads-settle,scheduled-reports,anomaly-alerts— had been connection-refused-failing silently since the migration, logged to a file nobody was watching. Fixed both: the real keys now live indocker.env(the file docker-compose actually reads), and the script'sBASEnow points at :3000. Verified live:catalog-usda-importrun three times back-to-back pulled the complete USDA FoodData Central dataset — 8,187 real records — intoplatform_data/food-catalog;/ingredientnow serves it. Found in the process, not fixed (it isn't ours to fix): Livsmedelsverket's own public list API (dataportal.livsmedelsverket.se/livsmedel/api/v1/livsmedel) returnstotalRecords:0right now for every valid parameter combination (confirmed against their published Swagger spec — offset/limit/sprak, none required beyond the path version), while their by-id nutrient lookup for the same dataset still serves real historical data — an external, upstream issue on the Swedish agency's side, not a bug in our importer, and not something a code change here can fix. What WAS ours to fix: the cron route treated that empty response identically to a healthy end-of-cycle wrap (ok:true, processed:0) — the same "missing dependency renders as an empty success" shape this platform keeps re-learning the hard way. It now distinguishes a fresh-cycle empty response (cursorAtStart===0 && total===0) from the normal wrap and logs it as an error with the exact upstream URL to check, plus asourceEmptyflag in the response — loud instead of indistinguishable from success.tsc --noEmitzero errors; eslint clean.
- Two public layouts flashed a hydration mismatch on every load: `data-theme` was hardcoded `"light"` in server-rendered JSX while a no-FOUC script overwrote it before hydration (2026-08-02).
(bazar)/bazar/layout.tsxandfsn/layout.tsxboth renderdata-theme="light"unconditionally, then a plain<script dangerouslySetInnerHTML>reads the saved preference / system setting and callssetAttribute('data-theme', …)on that same element before React hydrates — the standard technique for avoiding a flash of the wrong theme on first paint. Every OTHER instance of this exact pattern in the codebase (AtmosphereShell.tsx,StudioShell.tsx,ShopShell.tsx,admin/atmosphere/AdminShell.tsx) already carriessuppressHydrationWarningon the target element for precisely this reason — only these two were missing it, so every dark-mode visitor to Food Bazar or FSN got a real (if functionally harmless) hydration-mismatch error in the console, plus a secondary "script tag" warning as a side effect of React discarding and regenerating the subtree. Fixed the same way as everywhere else:suppressHydrationWarningadded to both root elements, element-scoped so genuine mismatches elsewhere in the tree still surface. No behavior change — the attribute was always correct after the no-FOUC script ran; only the warning was spurious.tsc --noEmitzero errors in both files; eslint clean.
- `tombstone-legacy-giftcards.ts` retracted before it was ever run for real (2026-08-02). The P2 gift-card cutover (previous entry) called for a final step: rename
db.giftCards→db.giftCardsMigratedonce every card was mirrored intopos_giftcards, on the assumption that blob writes would stop after cutover. That assumption is false — a full-repo grep found 15+ files outside the gift-cards module (gift-card analytics + its narrative, disputes, CRM guest360, marketing analytics, referral tracking, wallet passes/disputes, a payments refund route, bulk + transactions routes, a scheduled-release cron) readingdb.giftCardsdirectly, every one guarded with?? []. Renaming the field would not have crashed a single one of them — it would have made all of them silently report zero gift cards forever, discovered by an owner or auditor months later rather than by an error anyone saw at the time. Corrected architecture:db.giftCardsis permanent — the feature/compliance/analytics store, standing forever alongsidepos_giftcards(the money store), tied byposCardId. The script was renamed togiftcards-migration-coverage.ts, lost--applyentirely (hard refusal if passed), and now only reports mirror coverage, read-only. The now-deadgiftCardsMigratedschema field and its bookkeeping-route fallback read were removed. Verified live on the staging box against a purpose-built DK test tenant (created via the real signup API, never touching real tenant data): the full lifecycle — issue, find, list, dashboard merge, redeem with a proven idempotent replay, void — against real Mongo, ledger reconciling to exactly zero (10000 − 3000 − 7000 = 0), and the P3 bookkeeping credit (tillIssuedGross) confirmed live. The market gate (a separate, unrelated, pre-existing control) was independently confirmed identical and correctly closed for both Sweden (the real pilot tenant) and Denmark (the test tenant) — no bypass taken; that gate has no override by design and faking it would be exactly the class of surprise this correction exists to prevent.
- Seven more surfaces told the outside world nothing, or told it the wrong language (2026-08-01). A sweep for the rest of the class behind the two fixes below, done before it could be reported one surface at a time. All of them read owner-authored locale-keyed text with a chain that stops at English, and all of them speak to someone outside the business. The worst was the AI assistant's own restaurant context (
api/tenant/[tenantId]/ai/route.ts): the description read.en, every product name read.en, and the category heading interpolatedcat.name— aRecord<string,string>— raw, so the system prompt for any Swedish venue read[object Object]: , , ,and the model was handed an empty description above a menu of blank names. It then answered questions about the restaurant confidently, with nothing in the prompt to signal that everything it had been told was missing. The Google Business Profile sync declared one language and sent another:sync-engine.tsshippedlanguageCode: s.defaultLanguageand, two lines later,description: s.description?.en ?? ''— telling Google the text was Swedish while sending the English field, or an empty string when there wasn't one. Its twin insync-diff.tscarried the identical expression, so the two are now pinned as a pair: if they ever diverge, the diff computes one description and the engine pushes another, and the profile reports a phantom "description changed" on every sync forever. Also fixed: the Food Bazar marketplace listing (api/bazar/restaurants), whose card description was.en ?? ''for the object shape and a flat''for the legacy plain-string shape, so a Swedish restaurant appeared in the public marketplace with no description at all; `llms.txt`, the restaurant's self-description for AI crawlers, blank for the same reason, and its Popular-dishes list which preferred English; `seo/generate`, whose fact pack fed the model an empty description and English item names when generating meta for a Swedish site; and the voice concierge's audit trail, which recordedproductName: p.name?.en ?? p.id— so the record of what a machine changed on someone's menu saidprd_till_01j…instead of naming the dish. Two defect shapes, one cure:.en ?? ""dead-ends and renders nothing;.en ?? Object.values(rec)[0]never blanks but prefers English, so a venue that translated its menu reads "Shrimp sandwich" on its own Swedish pages — the exact swap the backend'sresolveTextwas fixed for. Both now asktranslate()in the venue's language. Explicitly NOT changed, and the distinction matters: the dashboard's editor endpoints (api/tenant/[tenantId]/menu/products/*,ProductEditorModal.tsx) read.enbecause they edit the English field — a fallback there would put the Swedish text in the English input and save it back over the English one, so the same reasoning that makes a fallback right on every surface above makes it data corruption there. The guard'sGUARDEDlist grew from 3 files to 10 with that exclusion written into it, and its pattern widened from "dead-ends at a string literal" to "any fallback chain built around.en". 26 tests; the two new guard shapes proven non-vacuous — reintroducing a.en ?? Object.values(…)chain fails with the file and line, and desynchronising the Google pair fails the pair test.tsc --noEmitzero errors in all eight changed files; eslint clean. (Unrelated pre-existing failure left alone: `google/rwg.test.ts` asserts `luigi.restora360.com` and gets `luigi.localhost:3001` — an env-dependent host, in a module that imports neither file touched here.)
- An untranslated blog post rendered as "Untitled" over an empty article — including in the share card (2026-08-01). Found while fixing the till-authored dish below; same defect, one surface over, and left open at the time because it was outside what was reported.
BlogList.tsxandblog/[slug]/page.tsxboth resolvedpost.titleandpost.contentas[lang] ?? .en ?? 'Untitled' / '', so a post written only in the venue's language — the normal case, since the dashboard leaves untranslated locales blank rather than duplicating text — showed on a site in any other language as a card titled "Untitled" with no excerpt, and opened to a headline over a blank article.generateMetadatahad the same two lines, so the<title>, the Open Graph title and the description were "Untitled" and empty too: the version that gets shared into a chat, indexed by Google and previewed by a link unfurler, which is the only thing anyone sees before deciding to click. All three now resolve through the sharedtranslate()(lang → en → whatever the post actually has), so the article renders in the language it was written in rather than not at all; "Untitled" survives only as the last resort for a post with genuinely no title, which is a different thing and still worth labelling. Falling back can show a reader a language they did not ask for — deliberate, and the same call already made for dish names: the thing it replaces is an empty page. The guard grew rather than being duplicated:storefront-menu-names.test.tsbecamelib/i18n/storefront-text-fallback.test.ts, moved to the module that owns the rule, and now walks aGUARDEDlist of the three storefront surfaces that render owner-authored locale-keyed text, asserting each importstranslateand declares no chain terminating at English. Its pattern was widened from.en ?? ""to.en ?? <any string literal>, because?? 'Untitled'is the same dead end wearing a name — proven by reintroducing that exact line and watching the guard name it. 11 tests, all green;tsc --noEmitzero errors in every changed file; eslint clean.
- A dish added from a till showed on the website as a price with no name (2026-08-01). Owner report, verbatim: "menu page on website is not updating completly the name of for menu item is missing. my friend from his windows PoS added 'redbull pink' but it just shows the price not the item name on website." Nothing was missing from the database and nothing failed to sync — the dish was in the tenant blob, the dashboard listed it, and the home-page menu teaser rendered it correctly. Only the full menu page was blank, because
MenuClient.tsxheld two resolvers for the same localized record and the page rendered through the wrong one:loc()at line 451 wasrec[lang] ?? rec.en ?? Object.values(rec)[0] ?? '', whilet()at line 957 — the one behind every category heading, dish name, description and ingredient list on that page — wasval[lang] ?? val.en ?? "", one fallback short. A till writes a dish under the venue's language only ({sv:'Redbull pink'}, deliberately: a till has one text field, and inventing translations is worse than leaving them visibly untranslated for the owner to fill in), so on a site whose language is not that one,t()returned"". The price is a number and rendered fine; only the name vanished, which reads like a font or a sync bug and is really a missing??. The same shape had already been found and fixed on the backend —resolveTextonce preferredenunconditionally and showed "Shrimp sandwich" for "Räksmörgås" (menu-fidelity.spec.ts); this was that bug on the website's read side. There is now one resolver in the file, delegating to the sharedtranslate()from@/lib/i18n, which also guards the plain-string names old seeds and imports still carry (Object.values('Margherita')[0]is'M'). `QuickAddButton` had the same defect and mattered more: its name string becomes the cart line name, so a guest ordering a till-authored dish sent a nameless line to the order, the receipt and the kitchen — not just a blank label on a page. The root cause on the write side is fixed too:POS_AUTHORED_LANGincatalog.service.tswas hardcoded'sv'while its own comment said "the venue's" — the hardcoded-locale twin of the hardcoded currency, made on the write side this time. It now followssettings.defaultLanguagefrom the menu blob (lowercased, two-letter codes only, so an owner-typed'English'or'sv-SE'cannot mint a key no reader ever asks for), falling back to the primary market only when a tenant has set no language. No data migration is needed and none was written — items already stored undersvnow resolve through the fallback on every surface, and the till reads them back throughresolveText, which has the full chain. Gates:storefront-menu-names.test.ts(6 tests — the venue-language-only render, the requested language still winning when a dish is translated, the plain-string case, plus a source guard that fails if the menu renderer ever re-declares a fallback terminating at English) andtill-authored-item-shape.spec.tsgrew from 14 to 19; both guards proven non-vacuous — reintroducingval[lang] ?? val.en ?? ""fails the source guard with the offending line number, and pinning the write back to'sv'fails exactly the three venue-language tests.tsc --noEmitzero errors in every changed file; eslint clean; the whole POS catalog suite (126) green.
- A guest can no longer book more people than the table seats — and shrinking a table under a live booking is no longer silent (2026-08-01). Owner report, verbatim: "When booking 2 person table. The option for add more guests should be disabled. But today I can add more while booking table online." — reported minutes after editing table 1 from 6 seats down to 2 while someone was mid-booking on it. Both are the same missing invariant. The rule now has ONE owner:
lib/reservations/table-occupancy.ts— the module whose own header already called itself "the ONE rule the public floor picker and the reservation POST must agree on" but only implemented occupancy — gainstableFitsParty/tableTooSmallMessage/maxPartyForTable/overCapacityBookings, and every path imports them instead of re-deciding. Server first, on every write path that binds a tableId:POST /api/public/[tenantId]/reservation(the wizard flow) had no capacity check at all — a party of 10 on a 2-seat table was a 201; it now 409s with the shared sentence.POST /api/public/book/[slug]keeps its check but through the shared helper, also refusesguests.length > party(a hand-rolled body could smuggle 8 covers past a party of 2), and no longer persists atableIdwhen the venue has no saved layout to validate it against.POST /api/tenant/[tenantId]/reservations— which declared itself "called from the public customer site — no auth check needed" and validated nothing (no schema, no party bounds, no availability,Number(guestCount)happily storing NaN, reachable by anyone on the internet, and with no in-repo caller, because the public site posts to/api/public/*) — is now what it lives under:requirePermission(tenantId, 'reservations.manage')+ the newStaffCreateReservationSchema. The manager override is deliberate and lives in exactly one place: a restaurant genuinely does seat three at a two-top by pulling up a chair, so the STAFF route (and only it) acceptsallowOverCapacity: true— explicit, never the default, absent from every guest-facing schema — and stampsReservation.seatedOverCapacityByplus an activity line, so the floor sees that a human chose it rather than that a rule leaked. All four checks are now atomic with the write: the two public POSTs and the layout PUT moved from lock-freegetTenantDB→saveTenantDBontomutateTenantDB, so capacity/occupancy/availability are decided inside the same CAS section that appends — a concurrent designer shrink can no longer land in the gap, and a booking can no longer be erased by whichever whole-document write finished last. The control the owner actually touched:BookingFloorPicker's party clamp was a hard-codedMath.min(12, n)that never consulted the table, and raising the party silently NULLED the selected table and time — leavingcanContinuetrue,confirm()early-returning, and a live-looking Confirm button that did nothing. The ceiling is nowmaxPartyForTable(selectedTable, biggestTableInTheRoom); the "+" stepper and the "Add another guest" button aredisabledat it with the reason printed underneath ("Table 1 seats 2 — pick a larger table to add more");canContinuere-tests table + time so the dead button is unreachable; the stale-closure in the old arrival reconciliation and thesetParty-inside-a-setGuests-updater inremoveGuestare gone. The floor plan is re-fetched on the way to the last step, so a guest whose table shrank mid-booking is told and returned to the map before typing in six guests' names, not refused after.ReservationWizardgets the same rule: the picked table now carries itsseats, and changing the party size drops a table that no longer fits. The shrink, from the owner's side:PUT /api/tenant/[tenantId]/table-layoutcontained zero references todb.reservations. It now diffs the incoming room against every live future booking and refuses the save with 409 + the casualty list ("3 bookings no longer fit the tables you drew") until the owner acknowledges it; the designer shows who it breaks, offers Undo my change (which reloads the saved room, so the canvas can't diverge from the server) or Save anyway — I'll handle these, and remembers what was answered so the 900 ms autosave never re-nags. An acknowledged save stamps each affected booking's `staffNotes` ("Table 1 was resized to 2 seats — it can no longer hold this party of 6") and writes an activity line, so the conflict outlives the dialog. The dashboard reservations list rings the guest count in red with "Table seats 2" / "Table removed" / "Extra chair · <who>", and/api/internal/pos/bookingsnow hands the tilltableSeats+overCapacity, so the Bord card warns instead of quietly drawing two chairs for six people — both computed against the current layout, never stored, so growing the table back clears the flag by itself. Deliberately NOT done: no auto-reassignment, no auto-cancel and no guest email on a shrink — moving a party from the window two-top to the corner six-top is a service decision with a real consequence for that guest, and a PUT handler is the wrong thing to be making it; staff get the list and the call. Gates:table-occupancy.test.tsgrew from 5 tests (occupancy + PII only, zero capacity coverage) to 13, covering party == seats, party == seats + 1, deleted tables, past/cancelled/table-less bookings, and the staff-approved case;tsc --noEmitzero errors in every changed file; eslint clean on all of them.
- No native browser dialogs left in the POS/devices surfaces (2026-07-29).
no-native-dialogs.test.ts(a HARD RULE guard: native dialogs ignore the theme and leak the raw host name in "restora-server… says") was failing on four call sites and is now green. The POS staff card's PIN reset and operator removal, plus Köksrouting's station-delete and force-delete confirmations, moved to the themedpromptDialog/confirmDialog. The PIN prompt gained what the native one could never have:inputType: "password"andnumeric: true, so it masks the PIN and raises the numeric keypad on a touch device.
Added
- Schemalagda beställningar — the owner's switch for "I order at 11:00, I collect at 13:00" (2026-08-01). Owner ask: a guest ordering ahead should have the online order, the POS and the kitchen all see when the food is wanted, so it is served warm and freshly made — and the whole thing must be optional, flipped on or off by the restaurant from settings and effective everywhere at once. This entry covers the OWNER CONTROL half. New `TenantSettings.scheduledOrders` = `{ enabled?, maxAdvanceDays?, minLeadMinutes? }`, absent meaning OFF — cooking immediately is what every existing venue already promised its guests, so a new behaviour must never switch itself on during an upgrade. Two knobs and no more, because each one is something an owner has to understand: how far ahead a guest may book, and the shortest notice the kitchen will take. The read side is a module, not a field lookup (
lib/scheduled-orders/config):resolveScheduledOrdersfills in the defaults (7 days / 30 minutes) and clamps a stored record that predates a bound or was edited by hand,scheduledOrderWindowproduces the bookable range, andnormalizeRequestedServeAtis the ONE decision point every order-create path calls — absent time = ASAP exactly as today, and a time that arrives while the venue has the feature off is dropped rather than stored, silently, because the guest was never shown a picker and their order is a perfectly valid ASAP order. A stored time nobody honours is a cold meal. The bounds live in that module andUpdateSettingsSchemaimports them, so the API and the resolver cannot drift, and the guest picker is built from the same numbers it will be judged against — a slot offered at checkout and then refused on submit is a bug the guest finds for us. Dashboard control: Settings → Ordering & Delivery gains a switch with copy that says what actually changes (guests can choose a later time; the kitchen is told when to START rather than cooking straight away), plus the two limits, gated onsettings.editand saved server-first through the existing tenant settings PATCH — the control only moves once the save lands, so nobody walks away believing they enabled something the kitchen never got. That tab hosted no editors by design (the IA-1 dedupe rule: one owner per setting); this is not an exception, it is a brand-new venue-wide ordering behaviour whose one owner is that tab. Gates:npx tsc --noEmitzero errors in every changed file (59 pre-existing errors elsewhere untouched), eslint clean on all touched files, 7 new pure-policy tests covering the off-by-default gate, the drop-on-off rule, junk/out-of-bounds knob resolution, the window arithmetic, the four serve-time verdicts and the settings-schema contract.
- Ny vara — a till can create a menu item, and it lands in the web menu (2026-07-31). Owner ask, repeated: "build that functionality also 'reverse direction (authoring on Android → appearing on web) doesn't exist. The till has no menu-editing UI and the translator is one-way by design.' i have been telling you about that before." The translator ran web → POS only; a tablet could sell the menu and never change it. New device route `POST /api/v1/pos/catalog/items` (
RegisterGuard+@RequiresOperator(), no tenant path param — the tenant comes from the register token) writing the tenant blob, which is the authoritative store the web menu editor writes unconditionally and reads back. So an item typed on a tablet appears in the dashboard Meny page, on the tenant website and on every other register — not only on the device that typed it. An atomic$push, not a read-modify-write: two tills adding at the same moment must both survive. The POS catalog is re-translated immediately after, so the till that made the edit shows the item without waiting for a sync tick. The dialog asks four things and no more — name, price, section, VAT category — because those are the four a cashier standing at a register can answer correctly. Allergens are a legal statement about a dish, translations need someone who speaks the language, a photo needs a camera roll and a crop; those are left ABSENT rather than defaulted, so the item arrives in the dashboard visibly incomplete for the owner to finish instead of silently marked "no allergens" by a device that never asked. The name is stored as a single-entry localised map ({sv: "..."}), so other languages read as untranslated rather than duplicated. Nothing is preselected, including VAT — an item quietly created at 12 % is indistinguishable from a correct one until an audit, by which point it has been sold a thousand times; the server refuses a missingvatCategoryand the dialog refuses to submit without one. The price is never parsed by the UI: the field takes KEYSTROKES like the cash pad (1-2-5-0-0 previews "125,00"),pos-coreconverts and formats, and with no engine installed the dialog refuses to send rather than inventing a price the whole restaurant would then sell at. Permission: owner and manager by ROLE, extended by a newcatalog_editoperator permission so an owner can delegate to a shift lead without promoting them — deliberately NOT folded intoregister_settings, because changing how one till behaves and changing what the venue sells are different acts with different blast radii. Reading the permission alone would have locked every existing owner out of their own menu, since no operator record predating this carries it, and the remedy would have been a bulk write acrosspos_operatorsin live tenants. The server re-derives the answer from the operator record on every call (mayEditCatalog, 6 pinned cases); the device control decides what is OFFERED, never what is allowed. Verified end to end on a paired Lenovo tablet against the staging box: "Vitloksbrod / 49,00 / Pizzor / Mat 12 %" created on the till, then read back out ofGET /store/tenant-data/{tenantId}— the exact payload the dashboard menu page renders — as one of four products, carryingcreatedFrom: {surface: "pos", registerId, at}. Three defects were found by driving the device rather than by the suite and are fixed: the shared@restora/domain-typespackage is consumed from a TRACKED compileddist/, so a commit of its source alone type-checked locally and would not compile anywhere else (new pre-commit gate rebuilds the package to a temp dir and diffs against the git INDEX, verified to block in exactly that case); a fifth header button rendered as a column of single letters down the right edge (the header actions are aFlowRownow, so they wrap rather than squeeze); and the atomic$pushwas handed to a helper that wraps everything in$set, which Mongo rejects — fixed toupdateDocumentMatching, which also never upserts, because a conjured blob would SHADOW a venue's existingmenusmenu and show one dish where there were forty. Gates: backendtsc --noEmit0, 275 backend tests, Compose app compiles for Android + desktop + wasmJs, desktop suite green including a cross-language test that readsENGINE_TAX_CATEGORIESout ofcatalog.service.tsand asserts the till's VAT chips are the same list (verified non-vacuous). Not yet: creating a CATEGORY from the till, and the pos/web React till has no authoring UI — both tracked.
- POS-enheter — one collapsible section per surface type, so nobody has to ask which button connects what (2026-07-28). Owner ask, verbatim: a collapsible list on the POS Registers page, ONE SECTION PER SURFACE TYPE (PoS · KDS · SSK/Självbeställning · Kunddisplay), every row showing exactly three things — Status | Öppna | Parkopplingskod. The blocker solved first: a register had no surface type.
RegisterDoccarried name + capabilities and nothing saying whether a device was a till, a kitchen screen, a kiosk or a customer display, so the dashboard GUESSED from the name — and the old page's three "Open POS ↗ / Kitchen display (KDS) ↗ / Customer display ↗" buttons opened surfaces unrelated to the device you were looking at. New backend field on theautoSendToKitchenpattern:RegisterDoc.surface?: 'pos'|'kds'|'self_order'|'customer_display'with ABSENT MEANING 'pos' (every pre-existing register is a till → no backfill, no behaviour change), resolved on every read intoRegisterView.surfaceand/registers/me, accepted on register CREATE and on the EXISTING admin settings PATCH (extended, not a new route — it rides the samecapabilityRevbump so a device re-reads it like any other setting). Dashboard (/dashboard/pos, rewritten): four controlled disclosures with a per-section count that stays visible when collapsed; an empty section shows "Ingen enhet ännu" plus its own name+Skapa form that creates a register pre-set to that surface with a sensible capability preset (till = the same restaurant preset "Anslut POS" gives, KDS =kitchen_routingonly — a kitchen screen does not sell, kiosk =sell+self_checkout+kitchen_routing+offline_capture); status is a chip — Parkopplad (green) / Väntar på parkoppling (hex-amber status pill, never the brand-remapped Tailwind amber) / Spärrad (red) — that a live pairing code takes over with "Kod aktiv · N min kvar"; Öppna opens THAT surface's URL in a new tab, verified against `pos/web/src/App.tsx`:/pos/(till),/pos/?kds,/pos/?display— and because the bundle has no self-order branch, the kiosk's Öppna renders DISABLED with a tooltip saying the surface is not built yet rather than a link that goes nowhere; Parkopplingskod mints against the existing single-use endpoint and shows the 8-character code big and monospaced with a copy button, a live 15-minute countdown, an "öppna enheten med koden ifylld" deep link (?pair=— honoured by PairScreen), and an honest "koden har gått ut" when it lapses instead of the panel silently vanishing. Codes now appear in exactly ONE place — the row of the device they belong to — including the one minted by the first-run "Kom igång" button, which keeps its real value (it also creates the first personal-login and imports the menu) but no longer competes as a second connect path. Whole page moved to Swedish operator copy; the three actions stack rather than overflow at 320px. Plumbing: 2 thin proxies underapi/tenant/[tenantId]/pos/devices/**(requireTenantAccesson both, tenantId always from the authenticated path, Next 15await params) — the list readsGET /pos/admin/registersbecause that is the projection carryingsurface/status/deviceInfo, and create resolves seriesPrefix (next freeS001-Tnn), country and currency from the tenant's OWN registers so a Norwegian venue's second screen is not silently created SE/SEK and an operator never has to know what a receipt series is; capabilities are server-chosen, never accepted from the browser. New sharedlib/pos/device-surfaces.tsis the one definition of the four surfaces (label, blurb, open-query, capability preset) used by both the page and the create proxy. Gates:backend npx tsc --noEmitexit 0;ui npx tsc --noEmitzero errors in the changed files (49 pre-existing errors elsewhere in storage/themes/experience-studio untouched).
- Köksrouting — the kitchen-routing admin lands in the dashboard (2026-07-27). New page
/dashboard/pos/routing(sidebar: Devices → "Köksrouting", NEW badge): the dashboard surface for KDS stage 1. Stationer — create/edit prep stations (name, short code, sort order, target prep time, expo flag), activate/deactivate/delete with the backend's loud-refusal flow surfaced honestly: a 409ROUTING_STATION_IN_USEbecomes a panel naming the blocking categories/items and offering "flytta till annan station" (reassign) or "fortsätt ändå" (force, every orphan logged); losing the last active station shows the pass-through warning verbatim. Kategorier — default station per menu category ("Pizzor → pizzaugn"), "Ingen tillagning" toggle, a warning pill when a default points at a deleted station, and the '' category rendered as "Utan kategori" (per-item routing only, controls disabled since the backend refuses an empty categoryId). Varor — per-item override table with the LIVE fire-time decision as a colored chip (routed = green with station + source; fritext = hex-amber status pill; unrouted = red "Ingen station"; no-prep/passthrough = slate), station override picker and the Ärv/Aldrig kök/Alltid kök tri-state, plus name search and a "bara problem" filter (render-capped at 250 rows). Hälsokoll — the pre-service check: mode, active-station count, per-kind item counts, the named unrouted dishes (red, backendmessageSvverbatim), dangling-category list, and one-click "Skapa station av namnet" for each legacy free-text station. Logg — the routing diagnostics log (operator-facing Swedish rendered verbatim, code chip, Kassa/Online channel badge, 24h/7d selector). Plumbing: 9 thin proxy routes underapi/tenant/[tenantId]/pos/routing/**(allrequireTenantAccess-guarded, tenantId always taken from the authenticated path), a newinternalPuthelper +backendErrorResponsemapper inlib/http/backend.tsso structured backend refusals (status + code + details) reach the browser instead of collapsing into a generic 400. Also fixed the failing nav i18n guard: "POS Registers" (and the new "Köksrouting") translated in all 20 languages.
- SB-1/SB-2 — per-portion split-bill kassakvitton, built to completion DORMANT behind a per-tenant flag (2026-07-20). Implements the split-bill fiscal design (Model B1): with
settings.fiscal.splitReceipts: 'portion', every captured payment portion registers its OWN sale kassakvitto at its OWN payment moment — each paying guest is offered a receipt, an abandoned split leaves received money correctly registered, and X/Z per-method totals match the drawer by construction. Engine (SB-1):slicePortionBuckets(B1 residual allocation — BigInt proportional shares, largest-remainder within a portion so slices sum exactly to the payment; the FINAL portion takes each bucket's remainder for gross/net/VAT, so Σ portions == the whole-order receipt öre-exactly per rate, mixed VAT included) +reserveFiscalPortionSale(pure) +recordOrderFiscalPortion(in-lock, idempotent onportionRef= the ledger charge row id — the exact mirror ofrefundRef; self-gating on the flag; mode-straddle safe:fiscalSalespresence wins over config); the whole-order paid-flip hook becomes the SAFETY SWEEP in portion mode (and stays the path for no-ledger-row orders: canteen wallet, zero-due). Journal/X/Z/status deriveOrder.fiscalSalesinto the ONE gapless journal. Refund/copy mapping (§4.3): returkvitto resolves its target portion via the refund row's charge linkage (greedy lowest fallback), the cumulative return cap re-anchors at Σ portion gross, the full-refund VAT mirror merges portion buckets, and kvittokopia becomes once-only PER kassakvitto (receiptNumberon the copy route; ambiguous portioned order → 422; a portion copy reproduces the ORIGINAL stamp öre-exactly). Routes (SB-2): the POS settle route registers the portion at each capture (partial AND final) andfinalizeSeamChargeregisters an async-rail portion at the APPROVAL moment. Default `'order'` = byte-identical to today (regression-proven). Go-live per tenant = the config flip, gated on the design doc's ⚠ VERIFY register (Skatteverket written query + control-unit vendor). Adversarial review (probe-driven): 8 findings, ALL FIXED — refund-then-recollect no longer dead-ends (registered returkvittos NET out of the position for the cap, final-portion detection AND prior slices, so re-received money re-registers — probe-confirmed the old permanent refusal); slice VAT moved to CUMULATIVE rounding + clamp/rebalance (a 4×3-öre split can no longer print a negative-VAT kassakvitto: 1,0,1,0 not 1,1,1,−1 — Σ still öre-exact by telescoping); the portion hook got the ADVISORY pre-read gate (dormant means dormant: order-mode tenants no longer pay a locked doc-write per capture on the hottest money path); gift/prepaid tenders on UNPAID orders register their portion at CREATE time via the newsweepOrderPortionsAsync(the abandoned-split rationale now holds for gift money too); the paid-flip sweep resumes PENDING portion stamps (live signing failures self-heal); untargeted multi-portion returns walk portions by remaining capacity; mid-split order-total drift logs loudly and the clamps keep the math self-consistent; copy route survives a JSONnullbody. Gates: 10 portion tests incl. F1/F2 regressions (7048 total, 0 fail), tsc 0, full build 0,sb-portion-qa.mjsflag-ON E2E ALL PASS on the FIXED build (2-portion cash+card split → gapless kassakvitton at capture time, X-report exact per-method öre deltas, per-portion once-only copies) + flag-OFFpos-wheel-qa.mjs11-gate regression ALL PASS.
- Register panel Wave 3 — park/resume, ÖVNING training mode, Android parity, Windows Station PoS surface, split-bill fiscal design (2026-07-20). Owner: "go ahead all in". Web park/hold: the panel's Park key shelves the in-progress sale (cart + armed kitchen note + rabatt) per-device/per-tenant (
lib/sell/park.ts, cap 20, shape-guarded storage — nothing fiscal happens at park time), with a resume/discard list in the panel; resume refuses over a non-empty cart. ÖVNING training mode: a session-only violet switch in the panel — while armed, every tender routes toPOST pos/training-receipt(pos.operate, live-menu priced via the ONE PriceQuote engine, same rabatt clamps): NO order/ledger/stock/KDS/tally effect, only a markedtrainingregistration in the NEWTenantDB.fiscalTrainingstore — same gapless per-register counter, never the grand total, counted astrainingCounton X/Z, honest violet "ÖVNING — ej ett riktigt köp" confirmation (never the Paid screen). Fiscal exactness: an exact-FULL returkvitto now mirrors the sale's per-line VAT split öre-exactly (3×999 @25%: 600 vs the aggregate's 599 — a fully-reversed sale now nets 0 VAT on the Z, mixed-VAT sales included); SB-0 —tenderSplitderives the sale receipt's tender from the PAID ledger rows when they sum to the total, so a 2×cash+1×card SPLIT bill no longer registers as all-card (X/Z per-method finally reconciles with the drawer on splits). Android `:pos` parity (device-verified on the Lenovo TB330FU): "Register panel" switch in the settings sheet (default OFF,SettingsStore.registerPanel) → right-hand panel on wide layouts: numpad ×-grammar (verified 3×→300 SEK on device), server-clamped Rabatt with exact preview (−10% → 270 SEK verified; gift card + rabatt mutually exclusive so the shown total always equals the booked one), kitchen msg (order-levelnotes— Android never sent it before), once-only Kvittokopia (409/422-aware client), lådöppning, park/cancel;PosOrderRequestgainedregisterDiscount. Windows Station: PoS = the 4th wrapped surface (pos→/dashboard/sell+ launcher picker button; per windows/AGENTS.md the web wheel/panel/themes come along free; cargo build exit 0). Split-bill fiscal semantics: full design docdocs/architecture/split-bill-fiscal-design.md— Model B (one kassakvitto per settled portion,portionRefidempotency mirroringrefundRef) staged SB-0…SB-5 behind a per-tenant flag, every legal claim tagged [LAW]/[PRACTICE]/[CHOICE] with a ⚠ VERIFY register (incl. "PRO FORMA" vs "EJ KVITTO" marking); the TablePayment/barTab fiscal gap + abandoned-split gap documented in KNOWN_ISSUES. Gates: 7038 unit tests 0 fail (park + training + VAT-mirror + SB-0 tests), tsc 0, fullnext build0,pos-wheel-qa.mjs11 gates ALL PASS (park→resume, ÖVNING ring leaves takings untouched),:pos:assembleDebug0 + on-device verify, Stationcargo build0.
- Register functions panel (TruePOS-style quick actions, our UI) + the Swedish non-sale fiscal rail: kvittokopia · returkvitto · pro forma · lådöppning (2026-07-19). Owner decision: an on/off switch under POS settings — DEFAULT OFF so our PoS UI/UX stays exactly as-is; switching it on ADDS a right-side functions panel ("we will keep things in our own way" — TruePOS functionality, never their look). Wave 1 — the panel (
components/dashboard/pos/RegisterPanel.tsx+register-panel-pref.ts, new "Layout" switch in the settings wheel): numpad-first register grammar (type 3 → press × → tap a product adds 3; clamped to the 99/line server cap incl. merges — newaddToCartQty), ad-hoc Rabatt entry (numpad value → % or kr; SERVER-side application in the pos/orders route AFTER combo/coupon/brand-promo on the remaining total, percent capped at 100, clamped ≥ 0, recorded intodiscountAmountso the fiscal receipt prints the mandated negative Rabatt line; the till only previews viapreviewDiscountedTotal), Köks-medd. (order-level kitchen note through the existingnotesfield, themed prompt — also added as a plain field on the waiter order ticket), cash change calculation (type the tendered amount → change previews live and lands on the Paid screen), and one-tap tender buttons (Kontant/Kort/Swish reusing the exact existing charge rails). Wave 2 — the fiscal issuance paths the engine supported but nothing ever issued (lib/fiscal/se/wire.tsreserveFiscalExtra/recordOrderFiscalExtra, pure + in-lock like the sale path): Kvittokopia — once per order EVER (Skatteverket), requires the recorded sale stamp, own gapless receipt number, never moves the grand total, panel button opens the print page with the mandated oversized KOPIA marking (409 on a second attempt); Returkvitto — wired into ALL THREE refund chokepoints (sync provider/gift refunds, async refund confirm, Swish refund) fire-and-forget so every money-out now registers a RETUR receipt referencing the original and advances the |sale|+|return| grand total; Pro forma nota — "Print bill (PRO FORMA)" on the waiter sent screen (no sale stamp required, capped 10/order, marked PRO FORMA — ej kvitto på köp); Lådöppning — anosalecash-drawer movement (amount forced 0) journaled with who/when/why and counted asdrawerOpenson the shift Z-report. Journal + X/Z reports now deriveorder.fiscalExtrainto the SAME gapless journal (copies/pro-formas counted, never money; returns net out of sales). New routespos/orders/[orderId]/receipt-copy+/proforma(staff-auth, engine enforces the bounds); live control-unit path reserves in-lock + signs post-lock identical to sales. Adversarial review (2 independent agents over the money/fiscal diff): 10 defects found, ALL FIXED — returkvitto made idempotent PER REFUND EVENT at the engine (refundRef= the refund's ledger row id, threaded through all three chokepoints — the sync route and the rail webhook observe the SAME row, so their race can no longer double-register; cumulative returns capped at the sale gross); a failed live signing of an extra now RESUMES on the next identical request instead of dead-ending (and for a copy, permanently forfeiting the only legal copy), the signing finder is register-scoped, and the response returns the SIGNED stamp; the direct POS settle route (the 4th place a sale becomes paid — register Swish!) now records the fiscal sale, and the zero-due branch (newly reachable via a 100% RABATT) emitsorder.paid+ the receipt; client caps now mirror the server zod caps exactly (discount ≤100%/999 999 kr, note ≤1000, qty merges clamp at 99) so an over-cap OFFLINE sale can no longer queue → fail zod on replay → be silently dropped (a lost cash sale); toggling the panel OFF clears armed discount/note/multiplier (UI price and server booking can never diverge); the Paid screen's change calc ignores a parse-failed total-0; receipt markings are now SERVER-TRUTH (fiscalMarksfrom the journal stamps — a spoofed?mark=return&nr=999can't fabricate an official-looking returkvitto, and once a kvittokopia exists the plain print URL auto-marks KOPIA); return stamps record the money-out tender so X/Z per-method totals net correctly; X/Z + statuscertifiednow covers non-sale registrations; kvittokopia/pro-forma requirepos.operate(once-ever copy must not be burnable by a viewer role) and internal engine faults map to 5xx not 422. Gates: 27 fiscal/journal/drawer unit tests (7032 total, 0 fail), tsc 0, fullnext build, extendedpos-wheel-qa.mjs(panel toggle, ×-multiplier, −10% + cash + change, kvittokopia once-only + KOPIA banner, drawer feedback). Known minor (tracked in TODO): return-VAT is split from the aggregate (±1 öre drift vs per-line on a full refund of odd-öre lines).
- Android POS — the settings wheel lands on the tablet register too (2026-07-19). The
:posapp's four loose top-bar icons (tablet mode · theme toggle · sign out; Cast kept visible as the live customer-display indicator) consolidated under one gear openingPosSettingsSheet: a Light/Dark/Colorful theme picker (newSettingsStore.posTheme, migrates from and stays in sync with the oldappDarkflag) + quick actions (the newPosCalculatorDialog— same chained-op/till-% math as the web calculator — plus Customer display, Switch tablet mode, Sign out). Colorful mode amplifies the register's existingCATEGORY_HUESpalette: category chips go from a small color dot to a full hue fill and the product tiles keep their category tints — the classic colorful-register look on the light canvas. Verified on a real Lenovo TB330FU: theme migration, live colorful flip + persistence across relaunch, calculator math (12×9=108). Gate::pos:assembleDebugexit 0.
- POS settings wheel — one gear on the selling screens: consolidated settings, quick calculator, quick actions, and a Light/Dark/Colorful theme (2026-07-19). Owner ask: a settings wheel on the PoS with everything under it, a quick calculator, quick actions, and selectable dark/light/colorful themes. New shared components in
components/dashboard/pos/mounted on BOTH selling surfaces (/dashboard/selland/dashboard/order): (1) `PosSettingsWheel` — a gear that opens a slide-over with three sections: Theme picker, Quick actions (Calculator + big tap-targets to Cash Drawer / Table Billing / Bar Tabs / Orders / KDS + per-screen extras: "New sale" on Sell, "Change table" on Order), and Settings & configuration — grouped links into the EXISTING settings homes (Setup Center payments/ordering tabs, Menu, Devices & Apps, Tables & QR, Payroll & tips, All settings) so every setting keeps its one home and RBAC (consolidated access, not duplicated pages). (2) `PosCalculator` — a classic till calculator modal (chained-operation math, % with the till convention 100 + 25 % = 125, ± ⌫ C, full keyboard support), pure UI utility that never touches the cart or money rails. (3) `pos-theme` — Light / Dark / Colorful: Light and Dark DRIVE the one tenant theme mechanism (useTenantThemeMode— no parallel theming); Colorful rides the light canvas and colorizes the category chips on both screens with a fixed 10-color saturated palette (deterministic per category index; deliberately avoids the orange/amber/yellow scales the brand remaps to azure). Also:/dashboard/sell(the lite-mode landing) is now reachable from the full-shell sidebar — added to Live Service as "Sell (Counter)" with the label translated in all 20 languages. Gates: 16/16 nav-catalog + dark-vocabulary guard tests, tsc 0, fullnext buildexit 0,scripts/pos-wheel-qa.mjs(new Playwright QA: sheet, all 3 themes apply, calculator math, both screens, 390px).
Changed
- In-Venue Ops IA — the 15-item group split into three, and Table Bills + Split a Bill merged (2026-07-19). Owner question: competitor systems (Toast/Square/Lightspeed/SpotOn/Trivec) don't scatter table bills, split-a-bill, cash drawer, bar tabs across back-office nav pages — they're actions inside the POS check screen, with the dashboard holding only setup + reporting. A 14-agent audit (every page read, six competitors benchmarked, an adversarial skeptic) confirmed the "one mega-view" temptation is a trap: the pages are nine separately-entitled, separately-RBAC'd verticals over three disjoint data rails (orders / tablePayments / barTabs) that only meet at the payment ledger, so a naive merge would leak toggled-off features past
navHasFeature, 403 waiters on owner-only tabs, break saved deep links, and imply an order↔bill linkage the backend doesn't have. Shipped the two safe wins the audit endorsed: (1) nav regroup — the one "In-Venue Ops" dropdown (15 items) split into Live Service (Take Order · Table Billing · Bar Tabs · Weigh Station · Cash Drawer), Venue Setup (Tables & QR · Floor Designer · Delivery ×2) and Guest Services (Catering · Canteen · Atmosphere · Voice · Table Assistant); pure regrouping — every route/featureId/permission unchanged, "In-Venue Ops"/"Operations (in-venue)" kept as search keywords, 3 new group labels + 1 item label translated across all 20 languages. (2) Table Bills + Split a Bill → one `/dashboard/table-billing` surface with two URL-driven tabs (?tab=billsdefault /?tab=split, features-page pattern); both are settlement UIs over the same ledger with the samepos-operationsentitlement + owner/manager posture, so the merge is presentation-only. Old routes areredirect()stubs (guard-test convention) → links, bookmarks, Quick-Links stars and the docs-center/payments deep links all keep working (the two in-app links repointed at the merged tabs). Gates: 15/15 nav-catalog guard tests (redirect stubs, 20-lang i18n, iconKeys, findNavItem), fullnext buildexit 0, all three routes in the app manifest.
Added
- PoS + Waiter & PoS complete user guides at `/apps/pos/guide` and `/apps/pos/waiter-guide`, with real product screenshots (2026-07-19). Two new in-depth guides on the public site, following the Staff & Shifts guide's pattern. PoS guide (owner/manager): concepts chapter, a 17-step setup-to-operations walkthrough (floor plan · seating-chart designer · open a table + guest QR · take an order · table bills · split a bill (honestly scoped: web is even/custom only, the Android POS app adds seat/item split) · bar tabs + fast-pour + happy hour · age verification + refusals · cash drawer open/movement/close · terminal pairing + fleet · the Swedish fiscal/Kassaregister panel · payments & refunds), a Sweden fiscal-law chapter (Kassaregisterlagen, kvitto content rules, X/Z-reports, the Skatteverket kassaregister control visit — holds the same honesty bar as the fiscal engine itself: the product ships compliant-format receipts/journals against an uncertified sandbox control unit until a real certified cloud unit is connected, never claimed certified out of the box), 12 scenarios, 16 FAQs. Waiter & PoS guide: a practical, floor-staff-voiced companion — 13-step shift walkthrough (clock-in cross-ref · take an order by seat/course · offline handling · table bills · split · bar tabs · transfer/close a table · end-of-shift cash) with a cross-link to the PoS guide's legal chapter instead of repeating it, 10 scenarios, 12 FAQs. 56→40 real screenshots (20 unique screens × web 1440px + mobile 390px, several shared across both guides where the underlying screen is identical) captured by new
scripts/guide-shots-pos.mjsagainst demo data seeded by new idempotentscripts/seed-pos-waiter-demo.ts(tables, an open table session + in-progress order, a bar tab with pre-auth/rounds/happy-hour, a cash-drawer session, a paired Android terminal, Swedish fiscal activation) on the sameluigi-pizzademo tenant. Catalog:FeaturePagegainedguideLabel/moreGuides/guideFooterNote(additive, staff-shifts' existing footer copy preserved via the new field) sopos.tscan link BOTH guides from the hero + FAQ footer; also fixedpos.ts's staleliveHref(pointed at the read-only online-order Kanban board, not the real waiter order-builder at/dashboard/order). Gates: both guides +/apps/posverified 200 with populated content; fullnext buildexit 0.
- Staff & Shifts complete user guide at `/apps/staff-shifts/guide` — owner + worker + Skatteverket + Swedish law, with real product screenshots (2026-07-19). The first in-depth product guide on the public site: 6 chapters (the three surfaces & the 4-digit code · a 17-step owner walkthrough from first hire to locked payroll · a 12-step worker guide written to the staff member · the Skatteverket kontrollbesök play-by-play incl. the no-PIN terminal register view and the 12,500/2,500/25,000 kr control fees · a Sweden legal reference with statute citations (SFL 39 kap., SKVFS 2015:6, ATL, semesterlagen, sjuklönelagen, kollektivavtal/no-statutory-minimum-wage, AGI/bokföringslagen, GDPR art. 17(3)(b) vs the 2-year register window) · 12 handled real-world scenarios) + a 16-question FAQ, FAQPage + BreadcrumbList JSON-LD, English "Sweden edition" first. Every step shows the REAL product — 56 screenshots (28 steps × web 1440px + mobile 390px) captured by
scripts/guide-shots.mjsfrom the live dashboard/worker-portal with the dashboard forced to English and dev-noise banners stripped, against demo data seeded by the new idempotentscripts/seed-staff-shifts-demo.ts(staff PINs/encrypted personnummer/rates, 30 terminal punches incl. flagged manual + corrected ones, published & draft weeks, pending time-off/swap/availability requests, locked + draft payroll runs, training/review/kudos/announcement/chat) — both scripts committed so shots regenerate after UI changes. Honesty bar held: AGI/Bankgiro described as download-only MOCK, payslips as "Preliminär · schablonskatt", push/SMS as keys-gated, clock-in as terminal-only by design. Catalog: additiveguideHrefonFeaturePagerenders "Read the full guide" on the feature page hero + FAQ footer (staff-shifts only so far). Gates: guide + feature page render 200 with populated content; fullnext buildexit 0.
- Native mobile payslip view in the :staff and :owner Android apps (2026-07-19). Owner UAT: the payslip screen rendered the A4 PDF as bitmap pages — "not mobile friendly" no matter how crisp. The payslip is now NATIVE phone UI: net-pay hero, Earnings (hours / hourly rate / base / overtime / OB premiums / tips / gross), Deductions (tax withheld + line deductions → net), Employer cost (info), and the schablon honesty note when the run is mock; the legal PDF demoted to a "Payslip document (PDF)" card with View (full-screen pages + tap-to-pinch-zoom) and Share actions — web portal unchanged. Server:
GET /api/worker/[tenantId]/payslipslist projection extended with the worker's OWN full line breakdown (employmentType, hourlyRate/salaryPeriod, base/overtime/premiums/tips, employerLoad, withholding, deductions) — additive, still never a colleague's line or run totals; older APKs ignore the new keys, and the new APK falls back to gross/tax/net/hours against older servers. Gates: 6/6 payslip-access tests, both APKs assemble, full next build.
Fixed
- Feature pages — full truth-audit after owner report that content didn't match the products (2026-07-18).
/apps/food-bazardescribed supplier ordering; the real Food Bazar is the CONSUMER marketplace (diners browse/order; restaurants get listed). Root cause: content was grounded in the/servicesgrid blurbs, which themselves conflated surfaces. Corrected by grounding every page in the actual code: 5 rewrites —food-bazar(listing pitch: auto-list gate fromlib/bazar/readiness.ts, dashboard controls/boosts, ONE ordering switch; moved to Discover),printing→"Labels & Printing" (QR product/shelf label sheets, not a print shop),waste→"Waste Tracking" (scan-to-waste stock movements, not pickups),equipment-suppliers→equipment"Equipment Manager" (registry + per-machine QR, not a marketplace),inventory-suppliers(supplier ordering = supplier marketplace, not "Food Bazar suppliers"). Then 19 adversarial verifiers (one per remaining page, each reading the real surfaces) found ~100 defects — plan-inclusion errors (inventory is an add-on, not in Basic), nonexistent features (KDS "all-day view", reservations floor-plan claims, analytics daily summaries), demo-only iOS apps sold as live (POS/Owner platforms now exclude iOS), mock/flag-OFF rails phrased as live (payments, SMS/email connect), 5s-poll phrased as "instant" — all applied by 19 fix agents, including a fullterminalrewrite (it's a locked payment terminal: PIN shift → amount/pushed charge → tip → NFC tap; Android-only; no Swish/KDS/ordering claims)./servicescard copy + FeatureMock visuals corrected to match (bazar→consumer, terminal→payment, waste→scan-log, printing→labels, equipment→registry, KDS chips). Gates: tsc 0, integrity checks (icons/related/counts), full build exit 0, all corrected pages render on-subject.
Added
- Feature pages — a dedicated `/apps/[slug]` marketing page for every app & capability (2026-07-18). The Product mega-menu links + the
/services"apps & consoles" grid all pointed at one grid page; now each app and capability has its own SEO/AEO/GEO-complete page. New engine (`lib/apps`): pure-data catalog (types + 24 feature files + registry, importable by SEO surfaces) feeding a server-renderedFeaturePageView— hero, plain-language "what is it" explainer, who-it's-for, 3-step how-it-works, feature bento, honest outcome stats, pricing hook, native<details>FAQ accordion, related-apps rail, ambient food-photo CTA — with per-app stylised device-UI mock illustrations (FeatureMock) and a dynamic branded OG image per page (opengraph-image.tsx). 24 pages across ops/grow/supply/discover (POS & Waiter, Kitchen Display, Terminal, Self-Serve Kiosk, Table Reservations, Staff & Shifts, Owner App, Online Ordering, Website & App Builder, Marketing & Loyalty, Social Media, Loyalty & Rewards, Gift Cards, Site Health Check, Analytics, Food Bazar, Inventory & Suppliers, Equipment Suppliers, Ingredients, Printing, Waste & Recycling, Menu TV, Atmosphere, Food Social Network) + a/appshub. SEO/AEO/GEO: per-page title/description/keywords/canonical/OpenGraph/Twitter,SoftwareApplication+BreadcrumbList+FAQPage+ speakableWebPageJSON-LD, all 25 URLs added tositemap.xml, and an "Apps & features" section added tollms.txt. Honesty held: real plan prices (149/299/2999 + Pay-As-You-Go 0+7%), 0% commission on own sales, 7-day trial; no invented adoption/rating stats; allergen data flagged deterministic-never-AI; music licensing framed "handled where available". Rewired: the/servicesapp + service cards gained "Learn more" links to the dedicated pages; the nav "What you can do" items repoint to/apps/*(applied on disk — theLandingNav.tsxchange ships with the concurrent i18n session that owns that file). Content authored via a 23-agent workflow against a hand-written exemplar; gates:tsc0 + fullnext buildexit 0; all pages verified 200 + JSON-LD present + OG images render (image/png).
- Phase 76 — Restora360 Academy, waves AC-4…AC-13 (2026-07-18): the LMS completed end-to-end. Building on AC-0…AC-2 (engine + learner rail + first surface). AC-4 calculators: 5 new pure functions on the ONE Launchpad engine (roiPayback, vatBreakdown, foodCostShare, packagingCost, wholesaleMargin) +
AcademyCalculator— all 14 kinds in one touch-first component, learner's own numbers, no AI cost, planning-not-advice disclaimer, mounted on any lesson with acalculatorref. AC-5 verifiable certificate:renderCertificatePdf(pdf-lib A4-landscape, azure-on-navy, embedded QR → public verify, WinAnsi Nordic glyphs) + a global verify store (app_documents:'academy-certificates') + first-exam-pass issuance inside the learner row lock + public/learn/academy/verify/[serial](authentic/tampered/revoked/not-found, LinkedIn/X/Facebook share, employer note) + the PDF stream route + dashboard certificate cards. AC-6 Nordic legal (CKG trust seam):CountryLegalBlockrenders the learner's country cell LIVE from the Launchpad CKG through the publish gate (verified+cited steps, or an honest "in preparation" global-floor when unverified) + a country picker (SE/DK/NO/FI) + the authored Finland home-bakery seed (Vero / PRH-YTJ / Ruokavirasto / municipality — cited,needs-review, allow-listed hosts added) closing the Nordic gap; every legal fact stays gated behind human verification, never re-authored in course prose. AC-7 gamification: UTC-day-key streaks + 14 deterministic achievements (recompute-on-read, honest-empty) on the dashboard. AC-9 Course Builder CMS: super-admin/admin/dashboard/academy— list + validated JSON editor + publish/draft/archive/schedule/duplicate/delete, on the ONEvalidateCourseengine; a CMS save detaches the course from its code seed. AC-8 AI study tools (flag-OFF `academy_ai`): explain-simpler/deeper/summarize/checklist grounded ONLY in the authored lesson, metered to a designated system-tenant wallet through the ONEappendLedgerEntrypath — nothing renders while dark. AC-11 lesson discussion (flag-OFF `academy_discussion`): per-lesson Q&A reusing the ONE community forum with alessonRefscope. AC-12 SEO: catalog + per-course sitemap URLs + the FI guide cell. AC-13 flagship content: Modules 4–20 authored by a 17-agent workflow, machine-validated and integrated — the course is now 20 modules · 102 lessons · 20 quizzes · a 100-from-102 randomized, timed final exam (seedVersion 2); no fabricated stats, no jurisdiction legal claims in prose. Adversarial review (5 dimensions × verify): 16 confirmed defects, ALL FIXED — the final-exam per-question score oracle (fail now reveals pass/fail only; a submission must answer the whole drawn set; a shrunk/empty draw is voided, never auto-passed at 100%), the full exam pool leaking through course-detail (final exam excluded from the projection; delivered only as a server draw), attempt-cap eviction (durable per-quiz counter, not the evictable log), certificate HMAC now covers every displayed field (name/course/hours/score) + crypto 32^10 serials with a store uniqueness guard + lost-mirror self-heal from the durable enrolment copy, seed/legal-node resurrection after admin delete/reject (tombstones + a one-time marker + lifecycle-preserving upgrades), partial-verified legal cells now show an honest "more steps being verified" banner, per-learner AI rate-limit, and the discussion flag enforced on the API. Gates: fullnext buildexit 0 · 74/74 academy unit tests (incl. new security regressions).
- Phase 76 — Restora360 Academy, waves AC-0…AC-2 (2026-07-18): the enterprise LMS foundation + first surface, DEPLOYED + prod-QA'd. Plan:
docs/Phase-76-Restora360-Academy.md. AC-0 engine (ui/src/lib/academy/): the Course→Module→Lesson content model (video/images/worksheets/embedded calculator+quiz/per-locale bodies/CKG nodeId binding for country-dynamic legal facts) +AcademyDBonapp_documents:'academy'(never a MasterDB array) +docker/initdb/017_academy.sql(flag-gatedacademy_courses/academy_enrollments/academy_certificatesmirrors incl. the global verify-by-serial index); a 9-question-type quiz engine (mcq/multi/trueFalse/fillBlank/ordering/dragDrop/image/scenario/calc) with deterministic server-side grading, a leak-proof field-by-fieldpublicQuizprojection, timed/randomized exam draw+shuffle and unlimited-retry policy; a signed verifiable-certificate core reusing the loyalty HMAC signer (ACADEMY_CERT_SECRET). Consolidates the workforce-training + designer-academy quiz/cert primitives into the ONElib/academy/*engine. AC-1 learner rail: the learner IS the platform's consumer identity (getShopCustomer— platform bridge means dashboard credentials work), a per-learner doc (academy-learner:<id>, per-learner row lock) holding enrolments/progress/quiz attempts/bookmarks/notes with a server-drawn, time-limited final-exam lifecycle (expired draws void without consuming attempts; first pass wins; best-score-stands; XP derived, never stored), and 8 rate-limited API routes with grading INSIDE the row lock (exam = no-oracle, lesson/module = instant practice feedback). AC-2 surface: /learn/academy/courses catalog + course detail (3-state CTA, free-preview badges, Course JSON-LD) + the lesson player (sticky nav, honest video/worksheet placeholders, notes/bookmarks, the 9-type QuizRunner — touch-first, no drag lib) + the /learn/academy/join wall (ONE consumer account, source:'academy' email branding) + the student dashboard (XP/progress/resume/bookmarks); guests browse everything, lesson content beyond previews requires the free account — enforced SERVER-side. Flagship course seed slice: "From Home Cookie Baker to Profitable Scandinavian Business" Modules 1–3 fully authored (15 lessons + 3 module quizzes, 6 question types; no fabricated stats, no jurisdiction legal claims — those bind to the verified CKG in AC-6). Gates: full builds exit 0 · 47/47 academy tests · 11-step live prod QA (guest gate 401, zero answer-key/locked-content leaks at both access levels, register→enroll→lesson→quiz 100%→notes→bookmark→dashboard, XP exactly 10+25). Left: AC-3+ (calculators in-lesson, certificate PDF/verify, SE/DK/NO/FI legal via CKG + Finland seed, gamification depth, AI flag-OFF, Course Builder CMS, discussion, full 20-module content).
- Phase 75.3 — Owner feedback round 3 (2026-07-18): crisp nav, no public Ask-AI, /try finally azure. (1) The floating nav pill and its mega-menu panels are now near-opaque (0.94/0.98 pill, 0.99 panel) so they stay crisp over the dark photo hero. (2) The "Ask Restora AI" bubble is OFF the public website — the owner had removed it before and a session brought it back;
PublicAskAiis unmounted from the root layout with a loud guard comment, and a standing memory records the rule (portals keep their own AskAiWidget mounts). (3) The/tryfunnel's pre-rebrand ORANGE finally died:WowUnifiedImportcarried ~40 hardcoded warm hexes (#e67e22 orange CTA/spinners/checkbox, #dcc1b1 tan borders, #564337 brown text) — all converted to the locked azure ramp (#1570ef / #b3d7ff / #475569 / #0f172a), same for the Launchpad client's five orange accents and the orange eyebrows on /launchpad, /recipe-card and /start guides. (WowPremiumImportstill carries warm hexes but is dead code — no page renders it.) Verified: /try screenshot fully azure, homepage FAB count 0, tsc 0, full build, deployed.
- Phase 75.2 — Landing round 2 (owner feedback, 2026-07-18): slider retired for real sections + photo hero. Owner: "remove this slider and break it down into sections — each function is big, complex and must-have" + "instead of dark background, a high-quality restaurant image with cash counter, digital signage and PoS under dark transparency". Hero: a real restaurant photo (open café space with wooden counter, POS on the counter, hanging menu-board signage —
public/landing/hero-restaurant.png, Unsplash license, next/imagepriority) under a left-weighted navy transparency gradient, so the page opens inside an actual restaurant while the text column stays AA-readable. The auto-playing 11-chapter RestoraExperience slider is off the homepage, replaced byFeatureDeepDives— three full alternating sections, each a former "must-have" chapter with its own room and a hand-built responsive mock (no autoplay, no timers): "Every order. One screen." (channel chips → one live order inbox), "The kitchen sees it instantly." (dark KDS board with New/Preparing/Ready columns, progress bars, and the guest-phone status card — the chapter the owner highlighted), "Stock that reorders itself." (low-stock alert → suggested reorder via Food Bazar → Ta Emot Varor scan row). Each links to its /services anchor. Bento reworked to "and everything else" so it no longer overlaps the deep dives: featured website-builder card now uses the restaurant photo, plus reservations, Menu TV (free), payments & gift cards, staff & scheduling, and the navy marketing card. Gates: tsc 0, linkcheck 199/199, full build, desktop+mobile screenshots verified.
- Phase 75.1 — Landing page visual redesign + nav icons (owner feedback round 1, 2026-07-18). Owner: "expecting redesign, not just layout exchange" + "liked the navigation — add icons and one-liner descriptions; mobile menu without descriptions" + free rein to add/remove sections. Hero rebuilt as a full-bleed navy stage (azure radial glows + faint dot grid pulled up behind the sticky glass nav): white serif headline with an azure gradient italic accent, glass eyebrow chip, azure-glow primary CTA + translucent ghost, the deterministic intent search as the focal input, and the interactive order-demo as a bright card floating on the dark with a deep shadow; the honest trust chips (0% commission · 7-day no-card · live in an afternoon · GDPR · /status) became the hero's bridge row into the light page (standalone TrustStrip band retired). Feature grid → bento (Apple-style modular): a 2×2 featured "Online ordering — 0% commission" card with a live-looking ticket mock, a navy "Marketing & loyalty" accent card with capability chips, a photo-topped website-builder card, and three standard cards — every card still a real link into /services anchors. How-it-works → timeline: three cards on a dashed connecting line with big italic serif numerals in a sunken band + a "Start step one now" CTA. Final CTA → navy mirror of the hero (glows + dot grid + gradient accent) so the page closes the way it opens. ForAgencies removed from the homepage (owner-focused funnel; the agency pitch stays in nav/footer//partners). Navigation: every mega-menu entry now carries a lucide icon in a tinted tile that fills azure on hover + a one-liner description (Service-providers and Company groups got descriptions written); the mobile accordion renders icon + label only (lean 44px rows); fixed a hover-then-click interaction where clicking an already-hover-opened trigger closed the menu (click is now open-only; Escape/outside/mouse-leave close). Verified: tsc 0, zero page errors, desktop + mobile + open-menu screenshots, full build + linkcheck gate before deploy.
- Phase 75 — Website Conversion Redesign, batches B1–B5 (2026-07-18). The public marketing site rebuilt for conversion — premium warm-Scandinavian on the LOCKED azure-on-navy brand, every claim honest, grounded in an 8-agent audit + five committed reports (
docs/architecture/website-conversion-redesign/). B1 homepage/nav/footer: hero-first section order (the animated Experience player no longer owns the first fold — mobile finally sees a headline + CTA; fixes the h3→h1 outline break), an honest TrustStrip (product truths only — 0% commission on own orders, 7-day no-card trial, Swedish AB, GDPR, live /status), a 5-item nav (Product/Partners/Resources mega menus with realaria-expanded/Escape/outside-click + Pricing + Academy), a real-links footer (13 dead#links gone; /status /community /learn/academy /launchpad wired; fake social icons + cosmetic region cycler removed; newsletter input labeled), a deterministic IntentSearch combobox in the hero (~35-entry keyword→route map, zero AI cost), a SavingsCalculator (your sales × commission % vs the flat plans — honest math, visitor-adjustable), mid-page CTA paths (feature cards now link into /services anchors, Bazar band → /bazar, apps band → /services, inline CTA band), FAQ grown to 6 items incl. pricing, "Cancel anytime" wording replaced with the honest "trial ends quietly" everywhere (hero chips, FinalCta, Experience chapter 11), Experience player a11y (aria-hidden auto-mutating stage, keyboard prev/pause/next on mobile stories), skip-to-content link, landing-theme structural tokens (spacing/radius/duration/focus-ring/hairline from the reference DS — azure values). B2 pricing + de-fabrication + trust: a real/pricingpage (live plans registry via getOfferablePlans — admin overrides flow through; billed-yearly totals on every monthly figure; add-on rules incl. the 299 kr kiosk exception + free Menu TV; a what's-included matrix GENERATED from the entitlement registry; pricing FAQ + FAQPage/Product+Offer JSON-LD;/plan301→/pricing,/products301→/services); every fabricated stat/testimonial removed from /partners /agents /designers /suppliers (invented "1240+ restaurants/£1.8M paid/99.9% SLA/4.8★" counters + 9 fictional named testimonials + THREE fabricated schema.org aggregateRating blocks → honest program-mechanics stats verified in the commission/payout engines, £→kr, 14→7-day trial, designer membership honestly 500 kr/yr, "PCI-compliant" → "payments processed by Stripe"); /about founder story (3 chapters + Swedish-company facts + support-promise cards with the real email); /contact shows support@restora360.com + a topic selector incl. Book a demo (the missing sales path) + the previously-unthrottled contact API now rate-limited like newsletter. B3 Restaurant Academy: the EXISTING /learn/academy engine extended (never parallel) — a new Founder Roadmap track ("Idea → Open → Profitable → Multi-location") whose 9 beginner/intermediate/advanced lessons carry real teaching bodies adapted from the Launchpad's advice-clean foundational lessons, dedicated lesson pages at/learn/academy/[trackId]/[lessonId](ISR, LearningResource JSON-LD, prev/next, mark-complete), server-first progress (new GET on the progress route; localStorage stays the anonymous fallback; AcademyClient merges both), journey ladder + free positioning on the landing, and the pre-azure#a8531aorange hover leftovers fixed across academy/webshop/growth/community. B4 one login, many workspaces:/welcome/workspaces— a calm chooser composing the SHIPPED re-mint endpoints (/api/restaurants/switch+POST /api/auth/portals; cookie stays single-scope, zero guard changes); the portals GET now returns aworkspacesenumeration (ALL active owned restaurants — a multi-restaurant owner finally chooses instead of silently landing on the first — plus partner hats); login sends multi-workspace users to the selector (single-workspace keeps the direct redirect; ?next= and 2FA untouched), fixes its dead "Forgot password?"#link (→ /contact until a reset flow exists), points signup at /onboarding, and "Switch workspace" entries mounted in the tenant sidebar + agent + designer shells. B5 SEO/perf/gate:metadataBase+ a generated rootopengraph-image(social shares had NO image anywhere) + twitter card; canonicals on / /about /contact /pricing /services;/servicesfinally has metadata (new layout.tsx — the main-nav "Product" target was invisible to search) + 7 jobs-to-be-done anchor ids; sitemap += /pricing /services /about /contact, −/register (robots contradiction); homepage FAQPage JSON-LD + Organization contactPoint; unused Geist(sans) font load dropped; AskAiWidget + GhostTextAssistant (1100+ lines) now lazy-loaded out of every page's first-load JS;scripts/marketing-link-check.mjs(npm run linkcheck:marketing) validates every internal marketing href against the real route table + redirects + hash targets — 194 links green. Also fixed: a broken committed test fixture (commission-engine.test.ts missingcreatedBy). Deferred honestly (tracked in TODO): graded Academy quizzes/certificates ride the existing derived-tier system for now; add-role widening beyond supplier; per-tenant staff memberships; careers/press/blog; real customer testimonials (slots ready, never faked); sv translations (structure ready).
- Phase 74 — Designer Growth Platform, wave DG-10: growth analytics + dark mode + 21-language portal + mobile (2026-07-18) — PROGRAM COMPLETE (11 of 11 waves). Growth analytics (
designer_events, the audit-logs stream posture): a new append-only Phase-D table (docker/initdb/016_designer_events.sql, flagDESIGNER_EVENTS_TABLE, assertReady boot guard) records profile views, case-study views, contact enquiries and share clicks — written fire-and-forget (analytics NEVER blocks a page, never touches the hot designers doc; flag off = honestly dropped, never buffered). Capture: an anonymous rate-limited public beacon (/api/public/designers/[slug]/events— sendBeacon target, 204 even for missing designers so it's no existence oracle, case-study events validated against the author's own PUBLISHED projects, no PII — coarse country header + bounded ref only) mounted on the public profile + case-study pages; the contact route records the enquiry server-side once its guards pass; the Share kit counts share clicks per network. Read: a pre-aggregated GROUP BY (never row scans) feeds a "Your reach — last 30 days" dashboard card (4 KPIs + an SVG daily-views trend) that renders ONLY when the rail is on — no fabricated zeros, ever. Dark mode: the designer portal now mounts the ONETenantThemeApplier(same navy tokens + toggle as the owner dashboard; no-FOUC script extended to/designerwithout matching public/designers/*;dashboard-shellscope class) and the dark-vocabulary test grew TWO NEW ROOTS oversrc/app/designer+src/components/designer(0 pale-hex findings). 21 languages: a newdesigner-i18nchrome dictionary on the Phase 55 rail — English schema + 20 agent-generated languages (validated: full key coverage, intact{placeholders}, no entity contamination), per-namespace English fallback so a missing key can never render undefined; language persisted on the SAMEUserRecord.languagefield +PATCH /api/auth/meendpoint the owner dashboard uses (no parallel persistence); a picker in the portal topbar; sidebar/topbar/dashboard fully wired (inner pages migrate onto the namespace incrementally — tracked). Mobile: the fixed 240px sidebar became responsive — desktop aside frommdup, hamburger-opened slide-over drawer with backdrop below (the portal now works at 320px instead of crushing content). Gates: clean-worktree full build exit 0, full suite 0 fail, 2-lens adversarial review (10 raw → 10 confirmed → 6 distinct defects, ALL fixed: pg DATE→local-midnight-Date parsing shifted every trend-day key one day back on the UTC+ box (day now selected::text, series anchored on the DB's newest key); the "30-day" SQL window was 31 days inclusive; the beacon lacked the house global rate ceiling (forged-IP-header rotation could inflate counts unbounded — now 20k/h backstop like the analytics tier) and stored a client-suppliable country header unclamped (now strict 2-letter-or-nothing); the language picker was hidden belowsm— unreachable on the very 320px shell this wave ships (now always visible, compact); the mobile drawer gained body scroll-lock + Escape-close + dialog semantics, and RTL languages (Arabic) now flip the whole shell via the registry rtl flag).
- Phase 74 — Designer Growth Platform, wave DG-9: designer community + agency teams + Designer Academy (2026-07-18, ships FLAG-OFF). Two new fail-safe-OFF master flags. Community (`designer_community`): designers follow each other, like/save published case studies, and get a feed — FSN domain shapes keyed on
WebDesigner.id(no parallel account namespace), with block/report from day one: blocks are mutual-invisibility (drop follows both ways, silently refuse new edges — never revealed by any response), reports are idempotent per open (reporter, target), self-report-refused, rate-limited (10/hr + global flag-bomb ceiling) AND capped in storage, feeding a super-admin moderation queue (enriched target/reporter previews; resolve/dismiss durable, no double-moderation; a project takedown unpublishes the case study; deliberately NOT flag-gated so moderation outlives the flag; account actions ride the EXISTING admin designer suspension). Storage per the enterprise rule: social edges live in their OWNdesigner-communitydoc (the hot designers doc is untouched) with a new Phase-D Postgres mirror —docker/initdb/015_designer_community.sql(designerfollows / designerengagements / designerblocks / designerreports), flag-gatedDESIGNER_COMMUNITY_TABLEreconcile inside the write txn,db:backfill:designer-community, and anassertReadyboot guard. Agency teams (`designer_teams`): the shipped Organization+groupRole model mirrored designer-side —DesignerAgency+WebDesigner.agencyId/agencyRole(owner/admin/member), founding, consent-based invites (by public directory slug; the invitee accepts/declines — nobody is added to a team without agreeing), role changes with a single-owner invariant (transfer demotes the old owner; the owner can't leave without transferring), self-leave, member cap. HARD BOUNDARY: a team never pools money — commissions/payouts/invoices/wallets stay strictly per-designer; the roster shows read-only substance counts only. Designer Academy (ridesdesigner_community): a deterministic learning track ("Restora 360 Certified Designer" — 6 lessons of real platform content: 10%/5% earning model, delegated access, portfolio conversion, share engine, pitch engine, professional standards) with a server-graded quiz (correct answers never leave the server; varied answer positions; lessons gate the quiz; passing certifies durably — a failed retake can't un-certify) feeding a newacademyachievements group that shows on the public profile badges. Surfaces: three portal pages (Community feed+discover+report/block dialogs, Agency Team roster+invites, Academy lessons+quiz) under a new "Grow" sidebar section — every page self-gates to a clean "coming soon" while dark. 15 new unit tests (block semantics both ways, report lifecycle incl. cap + durable moderation, RBAC boundaries incl. ownership transfer + invite consent, quiz gating/durability/answer-leak). Gates: clean-worktree full build exit 0, full suite 0 fail, 3-lens adversarial review (12 raw → 11 confirmed → 5 distinct defects, ALL fixed + regression-tested: the reigning owner could self-demote and orphan the agency ownerless forever (now only the atomic transfer path steps an owner down); a failed invite-accept (member cap) silently destroyed the consent artifact (now consumed only on success); the team roster leaked privacy-hidden availability to teammates (isFieldVisible now gates it — privacy gates EVERY surface, teammates included); self-reports of own projects were accepted and draft projects were reportable (an unpublished-id oracle — now published-only + own-work refused); and the follow response shape revealed a block (now byte-identical to success, FSN ghost semantics) + block/unblock left the denormalized follower counters stale). - Menu blocks 2.0 — LIVE dish cards + the dish manager (MT-SIG-2, owner ask 2026-07-18). The flexibility pass on menu blocks, everywhere the canvas engine runs (Menu TV Screen Designer, Experience Studio, kiosk hosts): (1) `display:'card'` — a menu block can now render its dishes as big LIVE cards (photo medallion + name + price + story) that EQUALLY FILL the block: choose 1 dish and it spans the whole block, 2 split it in half, up to 6 in one stripe row, 7+ wrap into an even grid — the professional-signage mode (Color Stripes/product walls) with zero hardcoding; names/prices/photos/86 stay bound to the live menu, so a menu edit re-skins every published design instantly (wire-safe conditional hash → one poll). Per-dish
itemStyles.bgpaints each card's stripe colour. (2) The dish manager replaces the pick-a-dish dropdown in the inspector: one list where the CHECKBOX picks which dishes the block shows (itemIds; all-ticked = follow the menu so new dishes keep appearing), DRAG reorders (itemOrder, feed-honoured on every surface), and CLICK opens per-dish styling (colours/sizes/weight + the stripe colour in card mode) — plus the existing "Separate dishes". (3) Templates went live: Color Stripes now materializes as ONE live card block pre-bound to the tenant's first four dishes (stripe colours pre-filled per dish); Premium Smoke + Salad Bar menu areas became live card grids; the create route and the in-designer materializer both seedctx.items(first dishes, menu order). Sanitize/resolve/hash follow the MT-SIG wire-safety idiom (display/bgabsent unless used; natives fall back to list rows — same content, XS-9 divergence rule). Gates: tsc clean (our files),studio-templates-uat.mjsALL PASS incl. the new "LIVE stripes, no $00.00 placeholders" assertion,menu-tv-designer-uat.mjsALL PASS (dish manager: tick/untick, style box, card toggle, Separate), screenshots verified.
- Phase 74 — Designer Growth Platform, wave DG-8: designer AI wallet + writing suite (2026-07-18, ships FLAG-OFF). The owner-locked commercial model lands: an active designer membership includes a 50 SEK/month AI allowance that resets monthly, plus persistent purchasable top-ups — held in a new per-designer
aiWallet(DesignerAiWallet+ öre-integer ledger, capped entries). Money core (lib/designer/ai-wallet.ts, pure + unit-tested):designerWalletBalanceis the ONE place the math lives (allowance requires an ACTIVE membership — lapsed = 0; balance can never go negative),chargeDesignerAidraws allowance-first-then-top-up and is idempotent on ref (a retried settle can't double-bill),topupDesignerAiidempotent likewise; month rollover resets the allowance and preserves the top-up. Writer endpoint/api/designer/[id]/ai/writebehind the new fail-safe-OFF `designer_ai` master flag (404s while off — GET and POST both): generates ONE draft (bio / headline / case-study summary) through the RIC narration cutover (Anthropic legacy transport,claude-sonnet-4-6) at a flat 1 SEK per draft shown before generating; honesty-fenced system prompt (use ONLY the designer's real profile facts — professions/specialties/location/experience + an optional hint; never invented stats/awards/clients); own-id-gated; 402 pre-check before any model spend, 503 without a provider key, failed/empty generation charges NOTHING. Charge-on-delivery under the doc lock — and the adversarial review's confirmed finding is fixed: the unlocked pre-check meant two concurrent generates at an exactly-sufficient balance could both reach settlement, with the loser's draft handed out FREE — now no delivery without a settled charge (the in-lock charge is the real gate; a refused settle returns 402, not the text). Drafts are never auto-saved: the new self-gatingAiAssistButton(renders NOTHING on 404, so the editor is unchanged while dark) previews the draft with Use / Regenerate / Dismiss on the profile editor's headline + bio fields, with live "X SEK left" wallet status. Deferred to go-live/DG-9 (tracked): the top-up purchase route (core is ready; needs the payment rail), the portfolio-manager summary button, and social auto-post via the social vault. 5 unit tests (allowance/membership gating, idempotent charge, never-negative refusal, rollover + top-up persistence, idempotent top-up). Gates: clean-worktree full build exit 0, full suite 0 fail, 2-lens adversarial review (wallet-money + gating/scope; 1 confirmed finding — the free-draft race — fixed + re-gated).
- Phase 74 — Designer Growth Platform, wave DG-7: designer template marketplace (2026-07-18, ships FLAG-OFF). Designers can package the restaurant sites they build and sell them as templates, keeping 70% of every sale (platform 30% — owner-locked). Behind the fail-safe-OFF
designer_marketplacemaster flag: every marketplace route 404s and existing platform themes are byte-unchanged until it's flipped (go-live also needs a buyer payment rail). Money core (lib/designer/template-sales.ts, pure + unit-tested): a sale records the author's 70% as aCommissionLedgerEntry(newsourceType: 'template') in the designer ledger — flowing through the existing Stripe Connect payout with zero new rail — idempotent per (template, buyer) so a retried purchase can never double-credit, andwithheld(full audit) when the author's account/membership isn't active. Submission → moderation → sale: a designer packages a restaurant they actively MANAGE (not a referred link) via the existing tenant→package exporter into a namespaced slug (can't collide with or shadow a platform theme), storedpending; a super-admin approves/rejects; approved+priced templates list to buyers; the owner (owner-only — a delegated designer session can't spend the restaurant's money) installs one, which credits the author. Critical isolation: designer submissions are excluded from the normal owner theme gallery (listCatalog) — they're never a free installable theme, only reachable priced+moderated+flag-gated through the marketplace helpers; a self-deal (a designer buying their own template into a tenant they manage) is blocked. Surfaces: a designer-portal Marketplace page (submit form + submissions + earnings; clean "coming soon" while the flag is off — no dead-end) + sidebar entry; three flag-gated APIs (designer templates, admin moderation, tenant marketplace). Admin-moderation and buyer-catalog UIs are deferred to go-live prep (the APIs are complete; nothing is reachable while dark). 8 unit tests (70/30 split, idempotency, withheld gating, earnings netting, catalog-leak prevention, moderation gating). Gates: clean-worktree full build exit 0, full suite 0 fail, 2-lens adversarial review (money-integrity + catalog-leak — both agents probed the install/register/resolve leak vectors + double-pay/flag/self-deal paths, 0 confirmed findings).
- Phase 74 — Designer Growth Platform, wave DG-4: ambassador referral program (2026-07-18). Every designer becomes a Restora 360 ambassador: a personal referral code (
WD-XXXXXX, minted on demand under the doc lock, unique, unambiguous alphabet) rides the sameref_codecookie rail as sales-agent codes (one attribution engine, first-touch-wins across partner types —validateReferralCodebecame a discriminated union branched by the reserved WD- prefix). A restaurant that signs itself up through the link gets a `kind:'referred'` DesignerRestaurant link with hard semantics enforced everywhere: earns the flat 5% referral rate (owner-locked sales-agent parity; per-designer CommissionRules never boost it — those price management work), but NEVER grants delegated access (excluded inrequireTenantAccess,requireDesignerTenantAccess, and the access route's guard + in-lock re-check), never counts as "built" (restaurants list +restaurantCountrecompute exclude it — leaderboards/achievements stay honest), and never anchors a verified review (a referral is attribution, not service). When a tenant carries both a managed and a referred link, the managed relationship wins deterministically in the commission engine. Register attribution is best-effort with a designer-side self-referral block and audit trail; the sales-agent branch is byte-preserved, and the SA-only forensic event store is never written for designer codes. Share & Grow gained a "Refer a restaurant — earn 5% for life" section: referral URL + QR + network intents + honest stats (referred signups, netted referral earnings) from a new own-id-gated referral API. 3 new unit tests (flat 5% ignoring rate rules, managed-beats-referred selection, referred-can't-anchor-reviews). Review: the subagent review pool was unavailable (session limit), so the adversarial checklist was executed inline over everydesignerRestaurantsconsumer repo-wide — it caught 9 additional consumer gaps, all fixed: the tenant-dashboard SHELL gate (defense-in-depth access exclusion), the maintenance cron's tworestaurantCountrecomputes (would have re-inflated counts with referred links), the designer-dashboard KPIs, the profile page's verified-review eligibility set, the admin designer-list count, the Growth-BI partner-leaderboard input ("sites live" ranks building work), and both link-resolutions in the owner review route. The build typecheck proved the validation-union rename complete repo-wide (it caught the one consumer grep missed). Gates: clean-worktree full build exit 0, 6954 tests 0 fail.
- Phase 74 — Designer Growth Platform, wave DG-6: discovery filters (2026-07-18). The public "Find a Web Designer" directory grew a full marketplace filter set: profession (the curated DG-0 taxonomy, with its own facet), availability (Available now / Limited), work mode (remote/hybrid/on-site), max hourly rate (budget search), and has case studies — alongside the existing country/specialty/service/featured/search filters, in an expanded More-filters row (all breakpoints). Privacy carries through discovery: the availability and rate filters only ever match designers whose privacy toggles expose those fields, and the cards show availability/hourly-rate only when visible — a hidden field can neither be matched nor inferred. Honesty fix: the directory's status column used to render a blanket green "Available" pill for every designer regardless of reality — it now shows the designer's actual exposed availability, or an honest "—". Cards gained headline, profession chips, published-case-study counts, and "from X SEK/h" pricing; the search haystack now includes headline + professions. City landing pages (Citation-Hub rail) stay deferred until real designer density exists — tracked in TODO.
- Phase 74 — Designer Growth Platform, wave DG-5: recognition — deterministic achievements + public leaderboards (2026-07-18). Achievements (
lib/designer/achievements.ts): a pure, recomputed-on-read derivation from real numbers — restaurant milestones (1/10/50/100/500/1000 from the denormalized count), portfolio milestones (first case study, Portfolio Master at 6), reputation (Customer Favorite at 4.5+/10 reviews, Trusted by Clients at 3 verified reviews), trust (Fast Responder ≤4h, Verified Professional), earnings milestones (first commission, 10k/100k SEK from EARNED ledger statuses only) — never an LLM, never a stored counter, honest-empty for new designers. Surfaced as an Achievements card on the designer dashboard (earned chips + top-3 next-up progress bars) via an own-id-gated API, and as badge chips on the public profile — with the earnings group EXCLUDED from public display (income-tier privacy). Leaderboards (lib/designer/leaderboard.ts+ public/designers/leaderboard): three boards — most restaurants built, highest rated (minimum 3 reviews before a rating ranks), best portfolios (published case studies) — over active+listed+verified designers only, with a country pill filter, honest-empty states, stable tie-breaks, top-10 caps, medal styling, profile links, and a become-a-partner CTA; linked from the public nav and the sitemap. No earnings data on any public surface. 6 unit tests (milestone progress, earned-status filtering, tie-breaks, min-review gate, published-only counts, country filter). Gates: clean-worktree full build exit 0, full test suite 0 fail, 2-lens adversarial review. - Experience Studio — menu-board templates as starting points (MT-SIG × Studio, owner ask 2026-07-18). The PowerPoint model the owner described: open the Studio, pick "Menu TV screen" / "Digital signage", and start from one of the 6 signature designs — the template lands on the canvas as EDITABLE elements (themed background + a live
menublock pre-bound to the tenant's real sections + title/hero/callout pieces), then everything is freely movable/reshapeable while staying on-theme. Newlib/studio/menu-templates.ts(STUDIO_MENU_TEMPLATES): the 6 signature compositions re-authored in the starter-template idiom with a newmenu()element helper; palettes come from THE Menu-TV registry (getTemplateMeta(...).art) so a Studio template and its TV twin can never drift; deterministic, size-adaptive, zero AI cost. Three attach points, all additive: (1) the project wizard's "Starting point" step gains a "Menu board designs · live prices from your menu" group for TV/signage/kiosk/customer-display types; (2) the create route resolves both registries and seeds a menu template with the tenant's category ids (menu order) so the board arrives PRE-FILLED server-side; (3) the Studio designer toolbar gains a Templates button + gallery —materializeTemplatereplaces the current page in ONE undoable commit (confirm-first when the page has content) with the live board already in component state. Per-dish parity closed: the Studio'sCanvasInspectornow receivesboardCategories+onSeparate(it didn't before), so "Style one dish" and "Separate dishes" work identically in the Studio designer as in the Menu-TV Screen Designer (separateElmirrored onto the Studio page model,MAX_CANVAS_ELEMENTS-capped).StudioStarterTemplate.buildgains an optionalctx(categoryIds) — the 10 classic starters ignore it, signature untouched. Gates: project tsc clean (remaining errors are the concurrent session's designer/marketplace WIP), studio unit tests 10/10,scripts/studio-templates-uat.mjsALL PASS (real login → create signage project → materialize all 6 → live Luigi prices resolve on the stage → cleanup), screenshots of every materialized template.
- Phase 74 — Designer Growth Platform, wave DG-3: verified owner→designer reviews (2026-07-18). Restaurant owners can now leave verified reviews of the designer who manages them — the trust core of the marketplace. Verification anchor (supplier-reviews precedent): an ACTIVE
designerRestaurantslink AND a commercial relationship —commissionEligiblelink OR an actually-PAID subscription. The adversarial review proved the bare-link anchor was farmable end-to-end at zero cost (designer creates a free demo → claims it with a puppet owner account using the demo password the designer set themselves → the link stays active → unlimited verified 5★ self-reviews, with the demo slot resetting each time) — the paid-relationship requirement dead-ends the chain while a real claimed-demo client qualifies the moment they pay. One review per (tenant, designer), resubmits update in place, auto-published (authenticated + relationship-bound); admin unpublish is now DURABLE (second review finding: the author's resubmit could silently re-publish a moderated review — a newadminUnpublishedflag survives resubmits; content updates save but stay hidden, and the form tells the owner honestly). Self-review blocked at both layers: the page gate and the API's owner-only check rejectweb-designerdelegated sessions (a designer can never verified-review themselves through any session shape). Surfaces: the public profile swaps in aVerifiedReviewFormfor the signed-in owner of a managed restaurant (overall stars + communication/design/delivery/support category stars + would-hire-again + prefilled from the existing review); review cards render category chips + a "Would hire again" badge; reviewer identity comes from live master records, never request input; the denormalized rating recompute is now ONE shared function (recalcDesignerRating) used by both the owner path and the admin moderation route. 8 unit tests incl. the full farming-chain and durable-moderation reproductions. Gates: clean-worktree full build exit 0, 6940 tests 0 fail, 3-lens adversarial review (3 confirmed findings — the farming chain + durable-moderation ×2 — all fixed + re-gated).
- Phase 74 — Designer Growth Platform, wave DG-2: Share Engine (2026-07-18). Every share now markets the designer AND Restora 360. Branded OG cards: two
next/ogconvention routes (profile + case study) on the proven wow posture — slug-regex guard before any DB read, rate-limited, degrade-to-generic-card, unfurl cache headers; azure-navy brand gradient with "Created with Restora 360 · restora360.com" on every card; the case-study card honors theportfolioprivacy toggle. Deterministic length clamps + font step-down (an adversarial verifier RENDERED the zod-max case and proved Yoga collapsed the layout into overlapping text — fixed), latin-only strings (a★glyph would have triggered a runtime Google-Fonts fetch inside the crawler-facing route — replaced with "Rated X/5"). metadataBase fix (major): the self-hosted box absolutized convention og:images againsthttp://localhost:3001(Next's fallback without metadataBase) — a newrequestMetadataBase()helper (env override → forwarded headers, mirroring publicOrigin) now feeds both public designer pages, making every unfurl image fetchable. Share kit (/designer/share, sidebar "Share & Grow"): profile URL + QR + copy + 7 network share intents (LinkedIn/Facebook/X/Pinterest/Threads/WhatsApp/Reddit) with per-network UTM tagging, per-published-case-study share rows, unlisted-profile warning; every share affordance renders only with a real target URL (fail-closed on profile-load errors). Print collateral:GET /api/designer/[id]/share/pdf?kind=portfolio|card— a pdf-lib portfolio book (branded cover + profile QR + one page per published case study with embedded cover image, badges, testimonial, live/public URLs) and a print-ready business card (navy/azure, QR to the profile); WinAnsi-sanitized text with a truncation-correct wrapper (review fixes: exact-fit text was getting a spurious "..."; overlong single words hard-slice instead of overprinting the QR), images embed only from the designer's own /uploads namespace through the traversal-guarded seam, renders from the PUBLIC projections only, refuses (409) while the profile is unlisted and prints a cover-only book while the portfolio toggle is off — printed QR/links can never 404. Gates: clean-worktree full build exit 0, 6935 tests 0 fail, 3-lens adversarial review (14 raw → 12 confirmed → 7 distinct defects, ALL fixed + re-gated).
- Phase 74 — Designer Growth Platform, wave DG-1: Portfolio 2.0 + money-engine lock conversion (2026-07-18). Portfolio case studies: new
DesignerPortfolioProjectentity (cap 24/designer, gallery ≤8, bounded fields) with a full manager at/designer/portfolio(create/edit/publish/unpublish/delete, gallery uploads through the hardened media route, before/after pair, Built-with-Restora360 / Experience-Studio / Restora-AI badges, client testimonial, live/video links) — a new "Portfolio" sidebar entry, and one-click "Save to portfolio" on the Before & After pitch page (persists the pitch's before/after images as a draft case study; pitch results were previously ephemeral). Public: a case-study page at/designers/[slug]/work/[projectSlug](published-only, active+listed designers only, rendered exclusively from the newpublicPortfolioProjectwhitelist projection; CreativeWork JSON-LD, canonical, OG image; before/after figures, gallery, paragraphed body, testimonial block, and a designer + Restora 360 CTA — every case study markets both), a Case studies grid on the designer profile (respects theportfolioprivacy toggle; legacy flat portfolio links remain as "More work"), and sitemap entries with Google-image extensions for every published case study. CRUD API under/api/designer/[designerId]/portfolio(requireWebDesigner + own-id, slug minted under the doc lock, slug stable across edits so public URLs never break, 409 at the cap). Money engines on the lock (deferred from DG-0):commission-engine.tsandpayout-engine.tsrebuilt around PURE exported cores (applyCommissionRecord/applyCommissionReversal/applyBatchCreate/applyBatchSettle) wrapped inmutateDesignersDB— closing two real races: concurrent same-invoice webhooks could double-record a commission (idempotency now checked under the lock), and concurrent payout runs could double-pay the same pending entries (batch-create + entry flip now atomic). The lock is never held across the Stripe transfer. 17 new money-path unit tests (idempotency, withheld gating, per-designer rate rules, clawback semantics, batch atomicity, fraud-hold + override, settle success/failure) + 5 portfolio tests. Gates: clean-worktree full production build exit 0, full test suite green, multi-lens adversarial review.
- Phase 74 — Designer Growth Platform, wave DG-0: foundation + rich profiles (owner-approved program, 2026-07-18). The shipped Web Designer module starts its growth into a worldwide creative-partner platform (LinkedIn/Behance-bar; waves DG-0…DG-10 planned; commercial decisions locked: template rev-share 70/30, designer AI wallet + monthly allowance, referred-not-managed = 5%). Concurrency foundation: new
mutateDesignersDB(Postgres row lock viamutateDoc+ in-process mutex on JSON, in-place default-fill honoring the mutate-in-place contract) and ~16 designer routes converted off the racy get→save pattern — profile PATCH, public reviews, support ×2, invoices ×2 (the WD-YYYY-NNNN mint now lives INSIDE the lock so concurrent creates can't dup a number), delegated access, membership (cancel/DEV-activate), Stripe Connect (account-create moved before the lock; an existing stored id always wins the race — never orphan-overwritten), connect-webhook designer branch, admin reviews/escalations/edit, register (slug-uniqueness inside the lock), restaurants link-push. Response shapes/status codes byte-preserved; the commission/payout engines (cron-serial, multi-doc) convert in DG-1 with money-path tests. Adversarial-review fix: the restaurants onboarding closure no longer re-checks account/demo-limit after the tenant is already provisioned (that path would have orphaned a fully-created tenant + burned its subdomain) — it now only takes the lock for the atomic link-push + count recompute, with the policy enforced by the pre-provisioning guards exactly as before. Media hardening: the designer upload route now decodes→re-encodes through sharp (EXIF/GPS stripped, orientation baked, ≤2400px, JPEG q85, pixel-bomb guard, 64px floor, spoofed-MIME killed) and is rate-limited; public designer review + contact endpoints gained rate limits. Rich profiles (all additive): curated 25-profession taxonomy (lib/designer/professions.ts, chip picker, max 8), headline, cover image, hourly/project/min rates + currency, availability, work mode, timezone, working hours, years of experience, education/certifications/awards, socialLinks widened from 4 to 21 platforms (same field), 12 freelancer-platform links (Upwork/Fiverr/Toptal/…), and per-field privacy toggles enforced ONLY in the new whitelist server projectionlib/designer/public-profile.ts(secrets — OTP codes, verify tokens, Stripe ids, commission rate, VAT, phone, admin notes — can never reach a public response; unit-tested incl. secret-leak assertions). Profile editor 2.0 (/designer/profile): imagery section with cover preview, profession chips, rates, credentials line-editors, 21+12 link fields, privacy eye-toggles, guarded load/save states. Public profile 2.0 (/designers/[slug]): cover hero with overlapping avatar, headline, Verified badge (from the existing verification lifecycle), availability pill, profession chips, credentials columns, social + "Also on" freelancer-platform chip rows, rates card — rendered exclusively from the projection; SEO upgraded to the shared<JsonLd>+ a purebuildDesignerJsonLdbuilder (sameAs from links, knowsAbout from professions) +alternates.canonical. Gates: clean-worktreenpm ci+ full production build exit 0, full test suite green (6787, 0 fail) incl. projection secret-leak tests, multi-lens adversarial review (2 confirmed findings fixed). - Menu TV — MT-SIG "Signature Templates": 6 new 1:1 board designs (static + animated), per-dish design control, Magic Fit, multi-display balance. The owner supplied 6 professional menu-board reference designs; each is recreated 1:1 as a first-class TV template with a FIXED authored palette + its own art-directed backdrop: Fast Lane (dark fast-food combo board — photo columns, white COMBO hero card with a red price burst, 3 promo banners), Rustic Bistro (whitewashed wood + procedural SVG grain/doodles, Pacifico script "Menu", brush-stroke section headers, dotted leaders), Summer Breeze (airy three-panel — round plates, orange price bubbles, SVG produce garnish on the edges), Color Stripes (full-bleed colour panels, one hero dish per stripe in a white-ring circle medallion, script+caps lockups in Bebas/Pacifico), Premium Smoke (yellow brand sidebar with an SVG burger-pattern + giant poster price, smoky dark 4×2 medallion grid), Salad Bar (script lockup + packed bowl-card grid with price badges + a "Today's pick" deal tile; sparse slides drop columns and scale the bowls up). Every template ships static + animated variants — one shared, reduced-motion-safe keyframe vocabulary (
templates/motion.tsx: float/drift/ken/pulse/sway/spin/smoke/rise/wipe) gated by a new wire-safemenuBoardConfig.motionflag (absent = animated; never an emitted key when unset, so pre-MT-SIG feeds keep byte-identical version hashes). Architecture:TvTemplateMeta.art(palette/animated/demo) in THE registry;TV_TEMPLATE_IDSis now DERIVED fromTV_TEMPLATESand imported by board-data + tv-devices (kills two hand-copied lists that could drift); signature palettes override brand tokens at resolution (owner heading/price picks still win) for the board AND deck screens. Per-dish design control (the "separate one by one" ask): menu blocks gainitemIds(dish-scoped blocks),itemStyles(per-dish colour/size/weight/hide) and feed-timeitemOrderhonouring — all resolved SERVER-side (filter/order/hidden are content-identical on native; style fields web-only, XS-9 precedent) with conditional version-hash wiring; the Screen Designer inspector gains a "Style one dish" panel and a "Separate dishes" action that explodes a block into one live-bound, freely placeable block per dish (verified end-to-end: 9-dish block → 9 independent elements, one undo step). Magic Fit (deterministic, zero AI cost — the designer stays a no-AI-credit surface):lib/tv-menu/magic-fit.tsscores every template against the LIVE menu (photo/description coverage, category roles via multilingual keywords, size) → "✨ Great fit" chips in the template picker; applying a template now one-clicks the best menu photos into its EMPTY image slots (owner-filled slots never touched) + flips on the content toggles the design leans on; "Auto-split across screens" is now balanced by ITEM COUNT (LPT partition) instead of round-robin. Bundled demo boards:demo-board.ts+ 24 curated CC-licensed photos (public/demo/menu-tv/with CREDITS.md) + 2 self-hosted OFL fonts (Pacifico, Bebas Neue inpublic/fonts/) make every photo-forward template preview 1:1 even on a photo-less menu — the picker falls back to the sample menu (labelled "Sample menu") until the tenant has 4 photographed dishes, and the new public `/tv/demo` player (?template=&motion=&slide=&cats=) doubles as the QA harness + a sales demo. Gates: project tsc clean (only pre-existing Phase-74 designer errors remain),scripts/menu-tv-qa.mjs28/28 (all 16 templates × variants × category slices, zero page errors),scripts/menu-tv-designer-uat.mjsALL PASS (real login → designer → per-dish panel → Separate, deck snapshot/restored), visual QA vs the 6 references via Playwright screenshots. Native TV apps render the new ids via the ledger fallback until Compose ports land (content parity server-side; documented divergence).
- Phase 73 — Marketing Supremacy ("Stamgäst"), Wave 4: the moonshots Vendion cannot match (all fail-safe OFF) — PROGRAM COMPLETE. ~14 tickets, 7 workstreams, closing the 5-wave / 98-ticket program. Hands-off Autopilot + growth plan (MOON-2/9, AI-6, UX-7): a pure
AutopilotPolicyevaluator enforced at the orchestrator tick lets the engine auto-run ONLY zero-cost, sub-threshold, consented items — anything with spend or reach DOWNGRADES to "Needs your OK" (never drops, never over-spends); a deterministic "3 Money Moves" / daily best-move selector (composes the existing next-best-action/forecast/Pareto — each move fires only on a positive real count, one-tap to an existing rail), a weekly brief cron (de-duped against the ANA-6 recap), and a Weekly Growth Plan surface with a deterministic Autopilot Score. Predictive win-back + Regulars Radar (MOON-3, CRM-8): a deterministic churn-risk score (recency vs the guest's OWN cadence) added to the predictor, a once-per-lapsecustomer.churn_risklifecycle event (rides the existing tick — no new cron) driving an approved-or-auto win-back, a churn-risk segment, and the Regulars Radar at the POS — recognizing an at-risk regular prompts a one-tap "gesture" that writes a REAL loyalty/gift ledger entry (idempotent). Dynamic offer & reward tuner (MOON-5, LOY-8): deterministic, margin-defended discount/reward suggestions (never below cost; honest fallback when cost data is absent), one-tap apply, AI only phrases the number. Peer benchmark (MOON-6): a k-anonymized "how do I compare to restaurants like me" — a weekly rollup writes each tenant ONLY their own values + bucketed peer medians/percentiles for a cohort above the minimum size (no PII or single-tenant value ever crosses tenants; honest "—" below the cohort floor). Gift portals (GFT-3/5): a self-serve corporate/HR gifting portal (reuses the Phase-65 bulk-invoice issuance) and recurring subscription gift cards (a due-based tick mints each occurrence via the gift engine, idempotent). Marketing concierge (MOON-8): a conversational marketing concierge over the EXISTING WhatsApp/IG/SMS Channels rail (opt-in/opt-out consent fail-closed, honesty-fenced replies via brain-call, allergen/regulated deterministic, sends through the consented broadcast rail with tag attribution — no parallel messaging engine; live send is keys-only). Reputation & Reach Cockpit (SOC-8): one cockpit fusing the reputation score, review-reply queue, referral performance, broadcast reach and social ROI into a deterministic Reach Score, plus a weekly Reputation Autopilot that PROPOSES the top reach actions into the existing approvals (never auto-sends). Seven new master flags; four new crons registered (scheduler + vercel.json). Gates: 6927-test suite (0 fail), full production build, project-wide tsc clean. The whole program is on prod behind fail-safe-OFF flags — go-live is flag flips + keys/certs only.
- Phase 73 — Marketing Supremacy ("Stamgäst"), Wave 3: unify, automate & close the loop (all fail-safe OFF). ~20 tickets, 7 workstreams, all extending the ONE existing engine. Journeys & loyalty triggers (AUT-4/5/6/7, LOY-4): the automation engine now emits
loyalty.joined/loyalty.tier_up/loyalty.reward_readyfrom both paid-order earn sites (customerRef-tagged, Guardian-capped) with ready-made loyalty recipes; multi-step journeys run on the SAME idempotent dispatcher via additiveMarketingActionStep.delayMs(a WAIT pushes the plan-item's scheduledAt forward) +guard(a deterministic BRANCH, failing guard = visible skip); a puresuggestAutomationsproposes the owner's next un-adopted recipe (one-tap adopt); the recipe wall shows honest per-recipe "sent N, opened M" (null→"—"). One message everywhere (CMP-5/8/9): a pureomnichannel.tsrouter reaches each consented guest on their CHEAPEST channel (push > email > SMS) with cross-channel dedupe, folded into the broadcast engine — the stored result recordsroutedAway(the measurable SMS saving) and a unified per-channel card. Loyalty reach + manual tags (LOY-5/7, SEG-5): loyalty tier + owner-applied tags become first-classCustomerViewfields with dedicated segment resolvers + tier built-ins; the public loyalty page & Apple/Google wallet pass take the program'sbrandColorHex/logoUrl; the guest drawer gains a manual-tag editor (bounded, PATCH-persisted) and a "has tag" / "loyalty tier is" audience chip. Gift as acquisition (GFT-2/6/7): a gift recipient who becomes a new paying guest attributes to the buyer via the existing referral rails (self-gift blocked, idempotent); one-tap seasonal gift campaigns; plain-language breakage/liability aging from the BAS-2421 float. Offline→online attribution (MOON-4): a deterministic read-time join surfaces in-venue conversions (POS orders, gift redemptions, Phase-69 QR scans) on the SAME growth/ROI spine with an honest offline coverage % — in-venue revenue that was always "—" can finally appear (real or "—", never fabricated). Money loop (ANA-5/6/8): a deterministic next-best-action strip (lapsed regulars → one-tap win-back; birthday-soon big-spenders → offer), an honest trailing-trend revenue forecaster with a confidence band (never an LLM number), and a weekly "Money Recap" digest cron to email + push. Reach loop (SOC-6/7): a one-tap "Post to Everywhere" over the existing publish seam (7 socials + Google Posts + FSN; unconnected = honest skip) and scan-to-rate gate & route — happy guests to "Share on Google" + invite-a-friend (referral link), unhappy guests to a private recovery on the goodwill rail. Two new master flags (post_everywhere,scan_route); the money-recap cron registered in both scheduler + vercel.json. Gates: 6795-test suite (0 fail), full production build, project-wide tsc clean.
- Phase 73 — Marketing Supremacy ("Stamgäst"), Wave 2: the conversational + predictive marketing brain (all fail-safe OFF). ~16 tickets, 5 workstreams, built on one foundation:
lib/marketing/ai/brain-call.ts— the SINGLE gated/RIC-routed/honesty-fenced/spend-metered entry point every new AI surface calls (triple-gate → RIC cutover → meter-even-on-fence-rejection at the oneappendLedgerEntrywallet choke →assertNoFabrication→ deterministic fallback; never dark, never fabricates) — andlib/marketing/crm/predict.ts, a PURE deterministic predictor (next-visit window + lifetime value, honest null under 3 visits) and per-guest tagger (regular / at-risk / new / birthday-soon), NO LLM. Conversational tools (AI-2/3/4, CRM-7, ANA-7): a newtools-marketing.tsadds four Ask-AI tools behind a 2-line registry edit —compile_segment(describe an audience in a sentence → an INSPECTABLE deterministic predicate over the 17-field whitelist → a confirm-first "create audience" card; the model physically can't emit a non-existent field),prepare_campaign(locate a plan → fenced draft → confirm-first Approve card via the existing approvals route; never sends directly),draft_variants(3 honesty-fenced copy options via brainCall),get_marketing_money(read-only Pareto + campaign funnel, real-or-"—"). Predictive guest brain (CRM-4/5, AI-7): the Guest 360 payload now carries the deterministic next-visit/LTV prediction + tags; the profile drawer gains a "What to expect" card, tag chips, and an on-demand honesty-fenced "Summarize this guest" (RIC-metered, cached by fact-hash incrmGuestMeta.aiSummary); tags also flow into the POS Guest Card and become three new targetable segment built-ins (at-risk / birthday-soon / high-basket). AI audiences (SEG-4/7/9): a plain-English "describe your audience" box compiles to inspectable chips via a pure deterministicnl-segment.ts(no LLM in the typed-box path), AI-suggested tag chips, and an Audience Radar that auto-discovers high-value under-served cohorts (deterministic scan, one-tap create, honest-empty). Waiter copilot (WI-6/7): one honest deterministic add-on suggestion at the POS (a real in-stock, allergen-safe dish — never a fabricated item/price; allergen banner stays dominant) plus a compact at-the-table guest Q&A. Copilot Dock (UX-4/AI-8): an always-there "Ask your marketing" dock on Marketing Home that commands the existing Ask-AI widget via a window event (no second chat, no new model/metering surface), with marketing starter chips + voice, gated on the agent being enabled for the tenant. Every AI output is fenced (numbers/prices/allergen facts are pre-computed and whitelisted; the model only phrases them), every guest email is masked before it reaches the model, and all prediction/segment math is deterministic. Gates: 6703-test suite (0 fail), full production build, project-wide tsc clean.
- Experience Studio — both AI studios rebuilt to the owner's mockup (design 1a/1b/1d, 2026-07-17), light + dark. After the Photo/Video split, the owner supplied a design sheet and asked to take the UI/UX inspiration without losing any functionality. Both studios are now a split layout: the guided controls on the left, a big Live-preview hero on the right that updates instantly. Photo studio: step 1 collapses a chosen photo to a compact "Your dish · Detected: …" card (Replace reopens the picker); step 2 "The look" is an accented card with compact style chips + "+N styles" expander and a direction swatch row whose thumbnails are real result previews; step 3 "Your words" is a slim card. The hero shows a before/after slider with the owner's headline overlaid live ("edit in step 3"), and once generated it becomes the result (renderer + WOW + use/ad panels). A "What you'll get for N SEK" value row (Animated scene / Landing page / WOW score) sits under the hero, and the priced Generate button carries "everything above is free — you only pay when you generate" + the included-image quota. Direction lightbox (design 1d): clicking any swatch opens a large real example with ←/→ browsing and a "Use <direction>" commit (Esc/Close, full dialog semantics). "Create | My generations (N)" header toggle (design 1a): the saved-generations gallery is its own view instead of an endless scroll; its handoffs jump back into the right studio. The Video studio gets the same language: source-photo card, motion chips + swatches, and a hero pane that plays the motion sample and then the finished clip big, with its own value row (8-second clip / AI voiceover / ad exports). Every money path preserved (adversarial review: no money/functionality defects) — gates, the 428 billing ack, prices-from-config, refund messaging, the VideoAdPanel machinery, batch remove-bg top-up, FSN /uploads gating. Money fix uncovered by the review: the video-first ack path used to run the image
generate()after accepting the disclosure — a Video-studio owner accepting their first-time ack would have been charged for an image they never asked for; nowacceptAckauto-continues only the image flow (ackFor), and the video owner simply presses Create again past the ack. Verified: tsc + full build exit 0 (gated in a clean worktree — the shared main tree currently carries another session's half-written Phase-73 files); light + dark render checks of both studios and the lightbox;scripts/es-split-qa.mjs15/15 ON PROD (both studios, tab switch + URL sync, deep-link, the new hero + value-row assertions, zero page errors).
- Phase 73 — Marketing Supremacy ("Stamgäst"), Wave 1: Vendion parity, in the owner's hands (all fail-safe OFF). ~23 tickets across 12 parallel workstreams, every new surface behind a strict-binary master flag (5 new:
sms_campaigns/money_insights/customer_referrals/reputation_hub/consumer_broadcast) so today's behavior is byte-identical until flipped. Loyalty (LOY-1/2/3/6): klippkort/stamp-card mode on the ONE loyalty engine (per-order / per-item / per-category / per-amount rules, free-item or %-off reward, deterministic two-seam reward redemption mirroring points), a public no-login guest page at/tenant/[id]/loyalty(HMAC magic-link, points/stamps/tier/reward progress, join-by-email) that fixes the long-dead/loyaltyCTA, a one-screen guided setup with a live phone preview, and Swedish alcohol-law-correct earning (excludesvatCategory:'alcohol'lines from the earn base, deterministic — never inferred). Self-serve SMS (CMP-2/3/6): a/dashboard/smscomposer reusing the email-builder shell with a live GSM-7/UCS-2 segment + honest cost preview (owner-entered price only, "—" when unknown), the auto-appended Reply-STOP hint, a shared AudiencePicker, and the email builder'ssegmentrecipient mode wired end-to-end. Recipe wall + safety (AUT-3/8):/dashboard/marketing/recipes— every automation template as a one-tap light-switch (plain-language "what will happen", cooldown in words, themed confirm), a master "pause all", visible Guardian status, and "test on me". Waiter Intel (WI-2/3): the POS Guest Card (name · "regular, N visits" · favourite dish · loyalty reward-ready · VIP) on the order/sell screens, fed by the Wave-0 resolver (404 → renders nothing, byte-identical when off), plus a deterministic Allergen Shield cross-checking cart items against the guest's saved allergens (inline warning chip, never blocks the sale). Money Insights (ANA-1/2/3): a plain-language/dashboard/marketing/money"what made money" screen (a Pareto "top-N regulars = X% of revenue" over real spend, per-campaign honest returns with the coverage caveat, a deterministic "what to do next" strip) + SMS delivery-receipt & click tracking (provider status webhooks + an HMAC short-redirect, real-or-"—"). Gift parity (GFT-1): a one-click "expire after 36 months" preset (SE default stays no-expiry) and a named Björn Lundén SIE4 export. Simplicity (UX-3/5): a 3-minute Marketing Setup wizard (writes only manual-approval mode — never auto) and a reusable Confidence Card ("see it before it happens": reach · consent-excluded · cost · preview · undo). Day-part audiences (SEG-3): deterministic Fredagsgäster / Lunchgäster / Helggäster / Allergiker built-ins from real order timestamps + dietary data (friendly chips in the Audience Builder). Refer-a-friend (SOC-1): a give-X/get-X loop on the EXISTING coupon/loyalty/gift rails (unique codes, first-touch cookie attribution, an idempotentorder.paidconversion cron, self-referral + min-order guards) — reward codes filtered out of the public offers list so they can never leak. Reputation + broadcasts (SOC-2/4): a/dashboard/marketing/reputationhub with ONE deterministic weighted score across Google + internal reviews (TripAdvisor/Yelp/Meta honest "—" until keys) and a/dashboard/marketing/broadcast"Tell your customers" studio (push/email/SMS to opted-in guests, live per-channel consent-fail-closed reach, Guardian-capped). Social live-publish (SOC-5): every platform behind ONE publish() seam with encrypted per-platform token vaults — FSN live now, the rest activating as OAuth keys land (honest "not connected", never a faked post), trust levels preserved. Guest 360 profile (CRM-2): the/dashboard/customersdrawer over the Wave-0 spine — one visit+order+booking+review+loyalty+gift timeline, per-currency gift balances (never summed), owner notes + VIP toggle, deep-linkable by?guest=<ref>. Three new crons registered in the platform scheduler + vercel.json (sms-campaigns,customer-referrals,marketing-broadcasts). Gates: 6634-test suite green (0 fail), full production build, project-wide tsc clean, dark-vocabulary + no-native-dialogs guards.
- Phase 73 — Marketing Supremacy ("Stamgäst"), Wave 0: the load-bearing seams + the one door (all fail-safe OFF). Nine tickets across 8 parallel workstreams, every surface behind strict-binary master flags (
guest_360/marketing_home/audience_builder, new multi-keylib/marketing/supremacy-flag.tscloning the gift-cards flag shape; flag OFF = byte-identical behavior). Base-plan de-gating (SEG-2/ANA-4): segment list/create/edit/delete/preview and the honest marketing-analytics READ dropped theirmarketing-autopilotadd-on gate — insight is free, every send/spend path keeps its gate. Guest 360 spine (CRM-1):lib/marketing/crm/guest360.ts—buildGuest360(db, email, nowIso)composes the unified customer view and joins order line-items (top dishes + "the usual", combo header lines only), loyalty points + derived tier, per-currency gift balances (GDPR-erased sides excluded), reservations, exact-email reviews, and the new boundedTenantDB.crmGuestMeta(owner notes + persistent VIP, cap-evicted, normalized-email keys); customers route gained flag-gatedGET ?email=detail +PATCHnotes/VIP (audited). Per-visit trigger (AUT-1):order.paidjoinedMARKETING_RULE_TRIGGERSwith additivecustomerRef(sha256, never raw email) enrichment across all 10 emit sites (POS settle, born-paid, unified seam, table-QR pay, payment-intent, channels, Adyen, gift engine), plus the previously-deferred post-visit review-request rule template. Anti-spam Guardian (AUT-2): global per-customer frequency cap (default 3 sends/7 days, protecting even unconfigured tenants) + quiet hours as owner-configurableMarketingStatefields (brain-route zod + clamped merge), enforced at plan time as VISIBLE skips (never silent drops), per-customer fire history bounded in the automation store, stamped on dispatch success; cap-unevaluable (no customerRef) fails closed for customer-directed sends. SMS STOP → consent (CMP-1): new public inbound webhook receivers for Twilio (HMAC-validated) + Vonage with STOP/STOPP/SLUTA keyword classification, pureapplySmsStopflippingsmsNotificationsconsent by loose phone match (over-revoke beats texting an opted-out guest); the SMS-ordering webhook now honors STOP before its channel gate; marketing SMS bodies gain the "Reply STOP" hint (EN/SV). Ask-AI guest tools (AI-1):get_guest_insights(per-segment counts + masked samples) andget_guest_detail(exact-email, masked, birthday month-day only) — the live agent can finally read the CRM,guest_360-gated, raw emails never reach the model. Audience Builder (SEG-1):/dashboard/audiences— plain-language chip composer over the existing deterministic segments API (live reach counts, consent-eligible counts, masked samples, If-Match concurrency, honest "(advanced)" rendering for API-created shapes). Marketing Home (UX-1/2):/dashboard/marketingbecame a server component that renders the classic hub byte-identically whilemarketing_homeis OFF and, when ON, a plain-language front door (outcome tiles → guests/audiences/email/loyalty/gift cards/reviews/week/what-made-money, real-or-"—" stats via the newKpiOrDashprimitive, classic hub always reachable via?view=alland every?tab=deep link). Approve my week (MOON-1):/dashboard/marketing/week— the already-computed weekly orchestrator plan as approve/skip cards + honest approve-all over the existing per-plan approvals endpoints (no new engine; 409/404 refresh, never fake success). POS guest resolver (WI-1): purelib/pos/guest-intel.ts+ flag-gatedGET /pos/guest-intel— exact-match-only guest projection (visits, spend, favorite dish, strictly-deterministic allergens, loyalty reward-ready, VIP/notes) ready for the Wave-1 Guest Card; never fuzzy-matches (wrong-guest allergen info is a safety failure). Gates: 654 cross-cluster unit tests green (incl. accounting + intelligence event-contract consumers), full production build, dark-vocabulary + no-native-dialogs guards.
- Experience Studio — AI Photo and AI Video are now two dedicated studios with an enterprise redesign (owner ask 2026-07-17: "separate AI image and AI video; redesign both to a more professional enterprise-level UI"). The single merged page that served both from one long form is now a mode-aware workspace: AI Photo Studio at
/dashboard/experience-studio(URL unchanged — every existing link/bookmark still lands on photos) and AI Video Studio at?mode=video, chosen with a real segmented switcher (role=tablist, per-tabaria-selected, "Styled marketing images" / "Cinematic video ads"). The Quick Create cards on/studio, the project wizard, and the Studio hub now deep-link the two modes separately (StudioShell shows two tiles: AI Photo Studio + AI Video Studio). One shared shell still owns entitlement (trial DEMO vs paid), the one-time 428 billing disclosure, pricing, the brand kit and the reference examples — zero engine drift, one set of API contracts — so every money path is byte-preserved: paidGranted gates both the image Generate and the video Create (a locked, priced card in demo), the ack modal, 402 AI-budget handling, the heroPhotoFailed refund message, charge-on-delivery, the remove-bg batch top-up flow, and FSN publishing gated on a hosted/uploadsURL. Enterprise layout: a two-pane grid (the guided flow on the left, a sticky summary rail on the right showing a live preview, the exact SEK for the next action and the included quota) with numbered step cards, a filterable Your generations gallery (All / Photos / Videos tabs + "Show more" paging replacing the endless grid), and a calmer, consistent card system. AI Video Studio is now first-class: it opens on "Choose your source photo" (pick a saved Studio image, upload, or jump to the Photo studio to make one) instead of only appearing after a paid image — then style/direction motion pickers, Instant vs Narrated, and the create → poll → export → market machinery (unchanged). Cross-studio handoffs: an image result offers "Make a video from this image" (sets the source explicitly so re-use never animates the wrong photo), and the gallery's "New from this" / "Make video" switch modes with the right source. Video-first creators who hit a first-time 428 now get the billing disclosure, not a generic error. Verified: fulltsc+ production build exit 0, dark-vocabulary + no-native-dialogs guards green, a 3-lens adversarial money/gate review (1 regression found + fixed: the image→video source handoff; 1 hardening: the video ack gate), and a Playwright smoke (scripts/es-split-qa.mjs: both routes, tab switch + URL sync, deep-link, zero page errors).
- Phase 72 — Experience Studio Enterprise (Creative Suite), waves XS-0…XS-10. Experience Studio grew from a launcher + one-click AI generator into the platform's single creative hub, Canva-class but restaurant-simple — with zero parallel engines. Home 2.0 (`/studio`): "What would you like to create today?" prompt box (deterministic intent→preset matcher, never an LLM call), Quick Create cards (~20 project types with honest time estimates; AI Image/AI Video cards deep-link the existing paid Studio AI with its full gate chain — the new surfaces themselves never charge), a Continue Working grid (thumbnails, Draft/Published/Archived/Template chips, favorite/duplicate/archive/delete, relative edited times, skeletons + premium empty state), a starter-templates rail, and a 4-step project wizard (Type → Canvas size incl. custom 200–4096 → Starter template/Blank → Name). Projects: new
StudioProjectmodel on the tenant doc (cap 100, never silently evicted; multi-page; draft→published→archived + template lifecycle;publishedTohistory) whose pages reuse the ONE sharedCanvasElementmodel, sanitized by the ONE sharedsanitizeCanvasElements(now stage-size-parameterized — portrait/social/print canvases keep 2× full-bleed headroom; defaults byte-identical for Menu TV/Kiosk). Studio-only layer extras (name/lock/hide) live in a side meta-map keyed by element id — the wire type native TVs/kiosks decode is untouched, and publishing strips hidden layers structurally. Studio Designer (`/dashboard/studio/designer`) — the THIRD host of the shared canvas engine (CanvasStage/CanvasInspector, additivestageW/stageH+lockedIdsprops): variable canvas sizes, dotted workspace with zoom-to-cursor (10–200%) + fit, pages rail (add/duplicate/rename/reorder ≤12), Layers panel (rename/eye/lock/reorder with dense integer z — native Int decode preserved), per-page gesture-coalescing undo/redo incl. layer meta, 1.5s debounced autosave + Ctrl+S, inert Play overlay, insert bar (text/image/video/shape/live-menu blocks + QR codes — server-rendered deterministic PNGs inserted as normal image elements, free). Export: WYSIWYG DOM raster of the real renderer (html-to-image; no parallel painter) → PNG 1×/2×, JPG, multi-page PDF (pdf-lib), JSON; auto-thumbnails on save (20s throttle); honest 413→1× retry and 503 storage messaging. Publish pipeline — one design fans out through each destination's EXISTING sanitizer + store, never a bypass: Menu TV (pages becomelayout:'canvas'deck screens with stablesps_*ids — re-publish replaces, never duplicates; uniform-scaled + letterboxed to 1920×1080), Kiosk welcome screen (attract canvas, kind-filtered honestly), Customer Display ad reel, Social draft post (drafts only — can never auto-publish), Media Library; per-target feature gates mirror each target's own routes (kiosk 402 + upgrade prompt). Brand kit: additivelogoUrlon the ES BrandKit + one-click Apply-brand (single undoable commit: surface→background, primary→shape fills, luminance-contrast text ink) and Add-logo, with save-back to the kit. Entrance animations (fade/rise/drop/pop — the compositor's MotionStep vocabulary verbatim) as an additiveCanvasElement.animfield: whitelisted in the shared sanitizer, rendered on web boards' incoming rotation layer + the designer's Play overlay (reduced-motion aware), byte-identical feeds when unset (no key emitted, conditional version-hash append — no fleet-wide TV re-ship), native TVs render static until the Compose port (documented divergence). Also:/studioregistered hub-linked "Studio Designer" nav (all 20 languages), shared dialogs now detect the studio's scoped dark theme, and the/studioshell's?section=/theme seeding moved to a mount effect (recurring React-#418 hydration class). Gates: 6340-test suite green, full production build exit 0, 5-lens adversarial review workflow, and a 33-assert Playwright smoke (scripts/studio-qa.mjs: lifecycle → export/QR/publish → Home light/dark/deep-link → designer/Play; self-cleaning). Plan:docs/Phase-72-Experience-Studio-Enterprise.md.
- Kiosk Designer — "+ New template" now opens the empty page canvas directly (owner UAT 2026-07-17: "it is confusing, it should open the empty canvas so user can fully design kiosk, all empty pages"). Previously the button only blanked the design tokens — the preview stayed fully populated, so it looked like nothing happened. Now it lands the owner straight in the fullscreen canvas editor on an empty Start screen, and the editor grew page tabs (Start screen · Eat here/Take away · Menu · Order number, with per-tab element counts) so every page can be designed without leaving the editor — plus an explained chip for Item options · Order review (live ordering screens with no freeform canvas by design: the working controls can never be covered, they take the theme's colors/fonts). An empty-page hint overlay ("This page is empty — design it") shows on any canvas with no elements. Hardened after adversarial review (2 confirmed defects fixed): switching page tabs now remounts the studio's preview engine (the engine reads
initialScreenonly at mount, so after Done the Pages rail/inspector showed "Menu" while the preview was still on the Start screen — in-preview block editing was unreachable); and the empty-page hint is target-aware — on the Menu tab it leads with the 🍔 Menu block and states that a designed menu page replaces the standard grid (the generic "add Text/Image/Box" advice would have produced a menu page guests can't order from), plus a persistent amber "No Menu block yet — guests can't order dishes on this page" warning whenever the menu canvas has elements but no Menu block. Verified with a 13-assert Playwright smoke (tablist, tab switching, menu-only inserts, target-aware hint, amber warning, per-canvas hint clearing, Done, API-cleaned dev tenant).
- Menu TV — template picker with live thumbnails + all-slides preview popup (owner ask 2026-07-17: "thumbnails for Templates; click = popup with all screens, apply/cancel/reset"). The per-screen Template grid on
/dashboard/menu-tv(previously text-only buttons that applied instantly) is nowTvTemplatePicker: each of the 10 board templates renders as a live mini-preview (the realMenuBoardScreenon the page's already-fetched preview board, re-skinned per template exactly like the TV feed —config.template+ that template's slot images; inert, non-interactive). Clicking a template opens a popup showing every slide of the rotation (buildPages, the same paginator the TV runs; slide count in the header; capped at 12 shown) with a light/dark toggle (the other mode's board is fetched once, lazily) and Apply (keeps the screen's custom colors + display options — this preview already shows them) / Reset colors (returns custom heading/price colors to Auto) / Cancel — house dialog semantics (role=dialog, aria-modal, Escape, initial focus, aria-pressed toggle). Hardened after an adversarial review (confirmed finding + review-flagged issues fixed): the other-mode board is cached keyed by the exact cfg + mode — fixing both a staleness bug (the picker outlives many cfg edits, so an unkeyed cache served a stale/wrong-mode preview) and an infinite refetch-on-failure loop (a failed fetch is now recorded, not retried); the 10 thumbnail boards are memoized so opening the popup doesn't re-render all of them; and the footer copy now states honestly that the preview matches Apply (Reset was reduced to a colors-only reset since display toggles are per-screen content, not template styling). Verified with a 14-assert Playwright smoke (incl. a request-count guard proving the alt-mode board fetches exactly once across repeated toggles) and a self-cleaning paired test TV. - Navigation fixes (owner UAT 2026-07-17: "Kiosk Designer hard to find; back-to-Kiosks lands on Devices & Apps overview"). Root cause:
/dashboard/devicesread?tab=…in itsuseStateinitializer, which (a) races client-side navigations — deep-links like the designer's back link silently landed on Overview — and (b) mismatched the SSR HTML (hydration error). The tab now resolves in a mount effect and tab clicks sync the URL viareplaceState, so deep-links, refresh and back all land on the right tab. Also: the Devices overview kiosks card gained a direct 🎨 Kiosk Designer link, the Studio hub's "Kiosk templates" tile now opens the Kiosk Designer itself (where the new template popup lives), the designer's back link is labeled "Kiosk fleet", and the Menu TV page no longer renderswindow.location.originduring hydration (second hydration error fixed).
- Kiosk Designer — template thumbnails + all-screens preview popup (KD-12). The "Base template" rail now shows each of the 5 luxury templates as a live thumbnail (the REAL
KioskEnginescaled down on the attract screen, pure template look, non-interactive) instead of a text list, with an "In use" badge on the active one. Clicking a template opens a preview popup rendering all 6 kiosk screens (Start · Eat here/Take away · Menu · Item options · Order review · Order number) with the tenant's live menu, a light/dark toggle, and three actions: Apply template (switches the base, keeps the owner's colors/texts/photos/canvases), Reset to this template (also clears customizations so the kiosk matches the preview exactly; disabled when there's nothing to clear), and Cancel — a KD-4 version snapshot is taken before either change.KioskEnginegained an additivestaticPreviewprop (skips the window idle-guard listeners and the done-screen auto-reset) so pinned mini previews never drift back to the attract screen — devices never pass it, runtime behavior unchanged. Hardened after a 25-agent adversarial review (5 confirmed findings, all fixed): thumbnails areinert(pointer-events alone left ~40 invisible engine buttons Tab-reachable — Enter could mutate a pinned screen; also an aria-hidden-focus violation), rail cards arerole="button"divs (no button-in-button DOM nesting), the popup follows the house dialog idiom (role="dialog"+aria-modal+ Escape + initial focus, likeconfirm-dialog.tsx), thestaticPreviewidle-effect branch still clears timers on unmount, and — the real data bug —sanitizeKioskDesignnow persists a baseTemplate-only design instead of deleting the record ("Reset to this template" and a fresh template pick used to silently forget the chosen base on reload;design.test.tsupdated to lock the new rule, invalid baseTemplates still drop).
- Ask AI 2.0 — a full ChatGPT-style assistant over the whole system (owner ask 2026-07-17). The Ask AI widget grew into a big, centered chat window (expand/collapse, remembered per user; tenants open large by default) whose agent now knows the platform end-to-end and answers in 2–3 lines with rich blocks under the message: KPI stat cards, tables and mini charts (line/bar/donut — self-contained SVG,
AskAiBlocks.tsx). 5 new KPI read tools build every block server-side from live engines (never the model):get_business_kpis(revenue/orders/AOV + revenue-by-day chart + top sellers, vs-previous-period deltas),get_costs_and_profit(Restora Profit core: monthly costs by category donut, labor %, food cost %, net margin + a per-staff true hourly cost table incl. employer load),get_staff_schedule(who works the next 1–6 weeks + est. labor cost),get_payment_status(unpaid supplier orders, platform invoices due, received-by-method 30d),get_channel_kpis(FSN followers/engagement, Food Bazar orders/rating/listing views, GA4/Search-Console website stats when connected, campaign ROI). 5 new confirm-first action tools — same hard contract as the supplier flow (the agent only PREPARES a card; the user's click fires an existing auth-guarded API):prepare_price_change(menu item price, PATCH re-validated server-side),prepare_offer(goes live on confirm),prepare_setting_change(safe whitelist: per-day opening hours, accept-orders-while-closed),prepare_social_post(publishes to connected socials),get_marketing_suggestions(autopilot's pending plans + one-click approve card through the audited approvals route). Every agent answer now carries an honest AI-usage strip (this answer ≈ $, % of lifetime credit used) computed from the wallet choke point. ActionButton gained PATCH; ActionCard gained achangekind with before→after summary.scripts/askai-qa.mts= 35-assert deterministic QA (tools never write, cards always tenant-scoped, invalid inputs rejected) — all green on the dev seed. Agent stays flag-gated (askAiAgent/SUPPORT_AGENT_ENABLED). Owner UAT fixes (same day): newget_menu_itemstool (current prices lookup — "what's the current price?" was unanswerable and the price-change flow now states the old price before asking for the new one), and the "Yes, go ahead" chip only appears on genuine yes/no offers ("Want me to…?"), never when the assistant needs a value like a price. UAT round 2 — "it confirmed but the menu still shows 18 kr": the change had never been applied (the card's Apply button was never fired; the chat text read like a confirmation). Three-layer fix: (1) cards now carry an unmissable amber "Not applied yet — press the button below" banner until actioned, and the prompt bans "done/confirmed/updated" wording for prepared cards; (2) verify-after-apply — after a successful click the widget re-reads the changed record and proves it ("Diavola is now 100 kr on the live menu") via a newActionButton.verifycontract + a new GET on the single-product route; (3) the product PATCH/DELETE routes nowrevalidatePaththe public storefront (/tenant/<id>,/tenant/<id>/menu) so an applied price shows on the live menu instantly — previously even a successful change kept serving the cached old price. UAT round 3 — "Food Bazar shows a different price": that page was another restaurant's same-named dish (marketplace, per-restaurant pricing — not a bug); the real find was that the owner's own dish was held off Food Bazar by the photo-quality gate with nothing telling them —get_channel_kpis(bazar) now reports dishes-on-Bazar vs held, plus a "Held off Food Bazar — fix to list them" table with per-dish reasons (no photo / photo flagged / no price / no description), so the agent answers "why isn't my dish on Food Bazar?" honestly. Human review loop over the AI photo gate (owner ask — one system, no parallel build): the EXISTING owner held-banner on /dashboard/food-bazar now shows the AI's issue tags and a "Photo looks fine? Request a human review" button (newPOST …/food-bazar/request-photo-reviewstampsphotoQuality.reviewRequested); the EXISTING super-admin Food Bazar hub gained a Photo review tab (badge-counted queue across all tenants, image preview + AI issues + score, owner-requested first) with Approve — overrides the AI verdict to `pass` for that exact image (audited, re-lists via ensureBazarListing; a later photo change re-scores normally) or Keep held (clears the request).photoQualitywas previously written by the scorer and read only by the feed gate — no human had any view of it.
- Menu page — direct "Screen Designer" link in the toolbar.
/dashboard/menunow has a Screen Designer button next to History/Import that opens the Menu TV signage designer (/dashboard/menu-tv/designer) — the same freeform canvas Experience Studio links to — so owners editing the menu can jump straight to designing their TV screens.
- Every image-URL field on the platform now has an Upload button. A full-platform scan (6 UI slices + endpoint audit) found every text input where a user was expected to paste an image URL; each one now also uploads from mobile/computer via a new shared
ImageUrlFieldprimitive (components/ui/image-url-field.tsx: URL paste kept, Upload button with spinner/error/thumb-preview, per-surface styling,onUploadedkind callback). Wired (19 fields): tenant offers Banner Image, blog Cover + OG image, Settings → Branding logo, SEO hub OG image, Mason Style-tab background image (upgraded to the fullMediaPicker), the email/blog rich-editor Insert-image dialog (per-tenant), admin platform-SEO Default OG + Organization logo, admin TV-ads Creative (auto-sets image/video kind), admin Food-DNA set-image, admin Experience-Studio DNA-detect, admin Ingredient drawer, designer-portal avatar/logo/portfolio images, chain (group) logo/hero/white-label favicon. New upload endpoints (sharedlib/media/save-upload.tswriter, mirrors tenant-media validation):POST /api/admin/media(super-admin,/uploads/admin/, image+mp4/webm),POST /api/designer/[designerId]/media(own-designer only,/uploads/designers/<id>/),POST /api/group/[orgId]/media(requireGroupAccess,/uploads/groups/<orgId>/). The three new namespaces are registered inRESERVED_UPLOAD_DIRSso the retention sweep can never mistake them for orphaned tenant dirs. Dead components (SeoSettingsTab,BlogEditorModal, legacyStyleEditor— zero importers) deliberately skipped; YouTube/Vimeo/embed link inputs excluded (page links, not media files).
- QR generator — Logo Overlay now has a Browse button to upload the logo from your device. Next to the existing URL field, a Browse button uploads the picked image through the tenant media library (
POST /api/tenant/[tenantId]/media— so it also appears in Media), with inline validation errors (type/size mirrored client-side) and an uploading state; the returned/uploads/...URL drops straight into the logo field. Server-side,/api/tenant/[tenantId]/qrnow inlines local/uploads/...logos into the SVG as data: URIs, so downloaded SVGs are self-contained — a relative href would 404 outside the site and prod doesn't serve/uploadsstatically. Hardened (3-lens adversarial review): the server-side/uploadsread is scoped to the calling tenant's own prefix and reuses the public-mediaisPrivateMediaSubtreeguard, so the QR route (gated only byrequireTenantAccess, any staff role) can't read another tenant's uploads or the HR/ATS PII vault (hr-docs/,fsn-resumes/); the inlined content-type is taken from the media record's realmimeType(falling back to an extension map that now includes the.jfif/.jpe/.jifJPEG aliases) so a valid JPEG saved under one of those no longer silently drops from the SVG; a logo over 512 KB (or of undeterminable type) is referenced by its same-origin href instead of base64-inlined, bounding memory/ZIP size across the up-to-100 bulk-table SVGs and the per-request heap. The injected<image href>is XML-attribute-escaped.
- Personalliggare — the statutory Swedish staff register (SFL 39 kap. 11–12 §§, SKVFS 2015:6), end-to-end. The DB-8 terminal punch ledger now doubles as a compliant electronic personalliggare. Personnummer on staff: new encrypted
StaffMember.personnummerEnc(+ display maskYYYYMMDD-••••) via the proven AES-256-GCM PII seam — validated/normalized to the canonical 12-digit form (Luhn + real-date + samordningsnummer support,lib/workforce/personnummer.ts), edited on the HR profile tab (audited reveal, hr.manage-gated), wiped by GDPR staff-erase, masked-only in the worker DSAR export, and wired into AGI filing as the realBetalningsmottagareId(fältkod 215, decrypted at build time, employeeNumber fallback). Register surfaces: an on-premises inspection view ON the venue terminal (newPersonalliggareScreen, reachable from the clock-in gate and Settings → Compliance; the register's structure — names + times — shows without a PIN since inspections can precede opening, but the full personnummer column needs a staff clock-in code, is brute-force-throttled and audited per view; auto-closes on idle), a dashboard register at/dashboard/personalliggare(Team nav, day browsing, live today view; personnummer + exports are hr.manage-only — lower tiers see coverage, never the numbers), and print/CSV exports (audited, spreadsheet-injection-safe) stamped with venue name + organisationsnummer, timestamps in the venue's timezone, and the statutory 2-year retention statement. GDPR-erase now honours the register's legal retention (art. 17(3)(b)): punch rows inside the 2-year window keep the name + an encrypted personnummer snapshot; older rows stay fully pseudonymised. Manual after-the-fact insertions (deviceId 'manual-entry') and corrected punches are honestly flagged on every surface with their immutable PunchEdit trail. Missing personnummer / missing orgnr are surfaced as fix-it compliance gaps (orgnr saveable inline). New feature-registry entrypersonalliggare(basic plan, per-tenant toggle, depends on staff-shifts); endpoints: device-tokenPOST /api/public/android-terminal/personalliggare, tenantGET /api/tenant/[tenantId]/personalliggare(json/csv/print). Timesheets corrections UI: the previously API-onlyeditHours/insertMissedPunchactions now have a themed correction modal in/dashboard/timesheets(mandatory audited reason, client mirrors the server's 24h/future/locked-period rules).
Fixed
- Studio Home — project-card overflow menu rendered UNDER the templates rail (owner UAT 2026-07-17: "Delete goes behind the Bold offer card, takes seconds to come above"). Root cause: the card's hover lift (
transform: translateY(-2px)) creates a CSS stacking context, so the popup'sz-indexonly competed inside the card — the rows extending past the card painted beneath the later-DOM templates section until the pointer left the card and the hover transition dissolved the stacking context (hence the delayed "pop above"). Fix: while a card's menu is open (data-menu-open) the card itself getsz-index: 50with the lift frozen, so the whole stacking context sits above sibling cards/sections and the menu never shifts when the pointer crosses the card edge. Verified with anelementFromPointhit-test on the Delete row over the templates rail + screenshot.
- Cron drift: `/api/cron/bazar-visit-reminders` and `/api/cron/tv-ads-settle` were born dead on prod. Both were declared in
vercel.json(inert under pm2) but never registered inPLATFORM_CRON_JOBS, so visit-reminder emails and scheduled TV-ad settlement never fired on the box. Registered with their documented cadences (daily / hourly); the MK-3 drift guard now passes again.
- Public web shop — full consumer redesign + accounts (`/shop`). The Restora 360 Shop is now a complete public e-commerce storefront anyone can buy from: sticky nav with live search + account + cart, an admin-managed hero banner slider (new Banners tab on
/admin/dashboard/webshop,bannerson the webshop doc, optional per-banner coupon chip), a "Today's offers" deals rail driven by a new display-onlycompareAtPrice(strike-through + −% badge; charging still usesbasePrice/priceTiersonly) plus afeaturedflag, a left categories + filters rail (price range, in-stock, on-offer, sort — slide-in sheet on mobile), product cards with deterministic per-category art for photo-less items, product detail pages (/shop/p/[id], SEO metadata, bulk-tier table, qty-aware pricing), a slide-in cart drawer (localStorage cart, server re-prices everything), and a real checkout page (contact + delivery address + EU VAT preview + coupon + invoice/card choice; Stripe Checkout returns land on/shop/checkoutwhich confirms server-side). Customer accounts: the shop reuses the platform's ONE consumer identity (BazarUser +bazar_user_session) — register/sign-in at/shop/login(shop-branded verify email viasource:'shop'), account hub at/shop/account; restaurant people get their shop account automatically — the same platform-bridge as Food Bazar accepts their dashboard email+password, AND an existingplatform_sessionis recognized directly. Order/subscription attribution goes through a newgetShopCustomer()seam (ui/src/lib/webshop/shop-customer.ts): platform users → their platform userId (bridged consumer logins map tolinkedUserId, so it's one history either way), pure consumers →bazar:<id>; guests can buy with just contact+address. Orders now persistcontact+shippingAddressand send a deterministic confirmation email (invoice/dev-sim at checkout; live card on first confirm). Orders + subscriptions pages re-styled into the shop shell; invoice page accepts both identity kinds (+ super-admin cookie). Light/dark scoped theme re-tinted to brand azure-on-navy; EN/NO/SV dictionaries cover the whole storefront.
- TV Ad Network — external advertiser self-serve platform (Phase 70). Anyone outside the platform can now advertise on opted-in restaurant TVs end-to-end: a public `/advertise` landing + magic-link portal (single-use login nonce — an emailed link dies on first use), a guided campaign wizard (hardened creative upload incl. video with magic-byte sniffing → TV-accurate live preview via the real
AdSlideView→ all/geo targeting → budget vs a prepaid wallet), deposits via Stripe Checkout (webhook ref-idempotent; fully functional keyless in mock), and honest ROI stats (estimated plays, spend, budget bar, per-day series, QR scan-throughs via a new counted/a/[campaignId]redirect). Super-admin moderation: a review queue on/admin/dashboard/tv-ads— nothing external serves unapproved (enforced in the engine's unit-tested serving predicate, not UI), reject-with-reason emails the advertiser, advertisers can be suspended; all decisions audit-logged. Money/isolation invariants (engine,lib/tv-ads): a restaurant's house ad is free, never settled, earns no self-credit and is hard-locked to its own screens (widened targeting is refused at write AND serve time); external spend debits the wallet atomically with budget accrual + play settlement (one row-locked mutate), clamps to both budget and wallet, auto-pauses on empty wallet. Settlement now also runs hourly via/api/cron/tv-ads-settle. Offline buffering (AD-5): the native Menu TV app gained a media3SimpleCacheread-through + board-arrival prefetch AND last-good-board persistence (an offline restart now renders the board instead of the pairing screen); the web menuboard persists its last-good board and caches ad media via a scoped service worker — a network drop keeps ads playing, and buffered offline playback is never billed (the existing cursor-gap guard).
Changed
- Food Bazar — combos are now orderable in the consumer marketplace + a real card-rail money fix. The Bazar restaurant page renders a Combos section with the full slot-picker (swap items, deal-mode "+15 kr / −5 kr" deltas, live total); the bazar cart carries combo lines and checkout sends
comboSelectionsto the (already combo-ready) storefront order route. Money fix (also fixes the storefront): the shared Stripe `/payment-intent` route validatedcomboSelectionsbut never priced them — a card order with a combo would drop the combo (undercharge / 0-total combos-only). It now expands combos + applies auto-bundles exactly likeorders/route.ts(member lines excluded from bundle/coupon, signed combo net into the total,subtotal − discountAmount + fee == total), so card + combo works on storefront AND bazar — the temporary "combos = cash only" bazar gate is removed. tsc 0, full build green, 114 menu tests.
- Menu — "Deal" combos: a meal-deal price with swap-and-adjust, + AI one-click swappable combos with a generated photo (Phase 67b). A new `deal` price mode (now the recommended one) lets an owner set one price for the default combo (e.g. "Burger + fries + soda = 99") where the guest can swap an item and the price adjusts — by the item's real cost difference (auto) OR a fixed upcharge the owner sets per option (
priceOverrides), their choice per alternative. The customer picker on all three surfaces (storefront, kiosk, table) shows the live deal total and a "+15 kr" / "−5 kr" delta next to each swap. The combos editor gained the deal price + a per-slot default picker + per-option upcharge inputs. The existing AI Combo Suggestion now publishes a genuinely swappable deal combo in one click (each add-on becomes a slot the guest can swap among its category siblings, server-side) via a new/combos/from-suggestionseam. The combo editor gained a photo picker (the sameMediaPickerproducts use — upload / stock search / an image made in Experience Studio), so the owner sets the combo photo the same way as any dish — no surprise AI charge on publish. Priced by the SAME server engine as the register (expandCombo); client prices are display-only. Verified by a 3-lens adversarial money review — 3 fixes: the deal net is now floored at 0 (a negative override can't leak a discount onto the cart),expandCombonow rejects an out-of-slot swap product (was pricable), and the AI publish now writes atomically (can't clobber a concurrent order/wallet write). tsc 0 errors, full build green, 114 menu tests.
- Menu — Combos & bundles UNIFIED into one "Combos" concept, orderable everywhere (Phase 67). The platform had two overlapping, confusing entities: an explicit slot-picker
ComboGroup(build-your-meal, POS-only) and an automaticMenuBundleprice rule (silent cart discount). They are now one thing — a Combo has fixed and/or "pick-one" items, a fixed / amount-off / percent-off price, and an optional auto-apply toggle that reproduces the old bundle behaviour (discount a cart that already holds the items). One editor (/dashboard/menu/combos) replaces both; legacyMenuBundles migrate into combos on read (idempotent, nothing lost); the pricing engine feeds the existing, testedapplyBundlesfrom auto-apply combos so auto-apply now fires on every surface (storefront, kiosk, table/QR — previously table applied no discounts — and register). Explicit combos are now customer-orderable on all three front-ends (storefrontMenuClient,KioskEngine, tableOrderingScreens) via a new synthetic "Combos" section + slot-picker; the five public order routes (storefront + PayPal + Adyen + kiosk + table) acceptcomboSelectionsand re-price them server-side through the SAME engine as the POS (client prices never trusted), with combo members excluded from bundle/coupon inputs and the signed combo net applied to the total (a fixed-price combo charges its fixed price even when cheaper items are picked). Verified by a 3-lens adversarial money review (2 fixes: a kiosk cash-pathdiscountAmountthat dropped the combo saving, and a fixed-price-combo undercharge) — fullnext buildgreen, 257 tests.comboNameis stamped on member lines so receipts/tickets can group them.
- Menu — Product editor redesigned into a roomier, better-structured dialog. The "Edit / New Product" editor moved from a cramped 580px right-side drawer with four squeezed top tabs to a spacious centered modal (
max-w-5xl, up to 800px tall) with a left section rail — each of the four sections (Basic Info, Dietary & Allergens, Options, Cost & Profit) now shows an icon, a one-line description and a live count badge, so the owner always knows where they are and what still needs input. The header gained a live product summary (thumbnail + name + price pill), each section shows a heading, and the Basic tab is grouped into titled sub-sections (Details / Pricing & category / Selling model / Visibility) with the weigh-and-pay + pre-order options as distinct cards. The wider content pane lets the allergen chips flow ~6 per row (was ~3). Fully responsive — on mobile it becomes a full-screen sheet with the rail as a horizontal scroll strip — and correct in both light and navy dark themes. Adds ESC-to-close, initial focus, focus-return and body scroll-lock. Pure presentational change: every field, handler, validation rule and save payload is identical; new copy keys fall back to English in all languages.
Fixed
- Food Bazar — browse-only restaurants no longer read as "Closed" while their card says "Open Now". Two distinct states were conflated: closed by hours vs doesn't take online orders (owner toggle / entitlement). An open restaurant with ordering off showed a "Closed" pill on every menu item — contradicting the honest "Open Now" hours badge. Now browse-only mode shows NO per-item pill (the page-top "doesn't take online orders" notice explains it) and the combo picker's disabled button says "Ordering unavailable" (sv: "Beställning ej tillgänglig") instead of "Closed" — the "Closed" pill is reserved for genuinely closed-by-hours. Also fixed the item detail page ignoring the ordering toggle entirely: it computed
canOrderfrom hours only, so on a browse-only restaurant it still offered a live "Add to cart" (server would refuse at checkout) — it now mirrors the profile exactly (entitlement/owner-toggle first, then hours with theacceptOrdersWhenClosedpre-order override, which it also previously ignored) and shows the honest browse-only notice. Verified (Playwright): browse-only → profile has 0 "Closed" pills + notice + "Open Now" intact, item page shows notice + disabled CTA; toggle on → ordering fully restored on both.
- Online ordering — the owner's on/off toggle now stays in sync between the restaurant website and Food Bazar. The
online-orderingcapability is one owner switch (settings.capabilities['online-ordering'], Control Center) whose live value = entitled AND owner-enabled via the sharedliveCapabilitySet. The tenant storefront already read it, but the Food Bazar surfaces read the entitlement only — so an owner who turned ordering off saw it disappear on their website while Food Bazar still showed "order now" (and the reverse). Now every surface reads the SAME live capability: the Bazar restaurant profile (onlineOrderingEnabled, fail-open to match the storefront), the Bazar discovery feed'sacceptingOrdersbadge, and — viaonlineOrderingEntitlement— all five public order-creation routes (website cash/card/PayPal/Adyen + table QR), so "off" actually refuses orders everywhere, not just hides the button. Flip the toggle anywhere and the website + Food Bazar follow together. Verified end-to-end (Playwright): togglingcapabilities['online-ordering']off makes both the storefront and the Bazar profile go browse-only (0 add buttons, "Closed" pills, browse notice), and on restores ordering on both.
- Delivery platforms — incoming orders now actually reach the restaurant (were silently dropped/mis-booked). The Wolt / Uber Eats / Foodora / Deliveroo order webhooks were hand-rolling orders via
db.orders.push— the exact anti-pattern the order seam warns against — so a delivery order appeared on the board but bypassed the ledger (no revenue for accounting/commission/reconciliation, breaking the cross-truth invariant), item→product mapping (kitchen station routing, recipes, stock, COGS all broke), kitchen-ticket/receipt printing, and the orderNumber lock. All 5 receivers (incl. a new GloriaFood order webhook) now route every accepted order through the lockedcreateTenantOrderseam (born-paid settle → one paid ledger row +order.paidevent, idempotent redelivery, hardware dispatch on a fresh create), map every line to a real product, and are HMAC/token fail-closed (was optional), with an indexed tenant lookup (was an O(n) all-tenant scan) and encrypted platform creds (were plaintext).
Added
- One restaurant login now works on Food Bazar and Food Stories automatically (platform-credential bridge). A restaurant person's Restora account (master.users) previously failed with "Invalid email or password" on the Food Bazar and FSN sign-in forms — those surfaces each have their own consumer account store. Now both login routes fall back to verifying the email + password against the PLATFORM user store; on first success they provision (or adopt-by-email) a SEPARATE linked surface account —
BazarUser.linkedUserId(new field) /FsnAccount.linkedUserId— per the existing ADR-FSN-identity pattern (bridge, never a merge: the marketplace session grants zero platform privileges, and the platform password is re-verified on every login, so a platform password change applies immediately). The FSN dashboard bridge (FSN-8) and the new login fallback now share ONE provisioning implementation (lib/fsn/platform-bridge.ts) with identical suspension/lifecycle semantics. Security posture: generic 401s (no platform-account-existence oracle; decoy hash equalizes timing), 2FA-enabled platform accounts are excluded (a bridge must never skip a second factor; 2FA is only mandatory for superadmins), and an FSN account protected by its OWN 2FA is never adopted via platform password. Verified end-to-end: platform creds sign into both surfaces (provision + idempotent reuse, 1 account), wrong password 401, pure consumer register/login untouched.
- Menu — category SECTION BANNERS (owner-uploadable per-category images). A category can now carry its own image (
Category.imageUrl), rendered as a banner behind the section heading — the category name (+ food glyph) overlaid on the photo with a legibility scrim — on both the tenant storefront `/menu` and the Food Bazar profile (classic text mode = centered title; photo-card mode = left title + item count). Categories without an image keep the clean text header, so a menu can mix banner and plain sections. Owners set it in the menu editor: each category row in the sidebar gains an image button that opens the sameMediaPickerproducts use (upload / stock search / an Experience-Studio image) in a modal, with a live thumbnail on the row and a one-click Remove. New sharedCategoryBannercomponent (theme-agnostic — white overlay text works on both the tenant theme and the marketplace theme). Threaded end-to-end:Category.imageUrlin the model, GET/PATCH on/menu/categories/[id](image validated withisSafeImageUrl;''clears), and the shared Bazar payload. This closes the "some restaurants have category images" case. Fullnext buildgreen; Playwright-verified the banner renders in both classic and card modes with plain fallback headers for image-less sections.
- Menu — photo-less menus now auto-present as an elegant CLASSIC text menu, everywhere (Food Bazar + storefront). Most real-world imported menus arrive with few or no item photos, and a photo-card grid then degrades into a wall of blank/placeholder tiles (the Food Bazar profile showed a gray fork-and-knife tile per dish). One shared decision —
resolveMenuPresentation(items, menuLayout)inlib/menu/menu-style.ts(11 unit tests) — now drives every consumer surface: the owner's saved `menuLayout` always wins (photos on = cards, off = text), and with no saved layout it auto-detects from real photo coverage (below 50% of items with a usable photo → the classic printed-menu look). The classic presentation is numbered rows (auto-numbered per category even when menu-numbering is off), bold name, right-aligned/leader prices, description + ingredients, hairline dividers, and centered section headers with a food glyph (🍔 Burgers, 🥤 Drinks — via a newcategoryEmoji()reusing the marketplace taxonomy). Wired into: the Food Bazar restaurant profile (RestaurantClient— newClassicMenuRow, photo carousels/offers suppressed in text mode since offer prices show inline; ordering add/qty controls preserved via an extracted sharedBazarCartControl), the Bazar item-detail imageless hero (branded "carte" panel with the dish glyph + name instead of a bare placeholder), and the tenant storefront `/menu` (MenuClient— same engine, tenant's own brand/theme). Each surface keeps its own design language (marketplace tokens vs the tenant theme) — one decision engine, per-surface rendering. The owner "items missing photos" nudge now softens to reassurance when the menu is already presented as a clean text menu.menuLayoutis threaded through the shared Bazar payload for the owner-override. tsc 0 errors; verified end-to-end (Playwright) on a photo-less tenant → classic on both Bazar and storefront, and photos-restored → photo cards return (no regression), desktop + mobile.
- Swedish kassaregister — certified control-unit adapter + fiscal receipt wired into settlement (R360-C01 + R360-C02, keys-only). A REAL certified CLOUD control-unit (kontrollenhet) adapter (
CloudControlUnit, e.g. fiskaly SIGN SE) now implements the existingControlUnitinterface — bearer-auth HTTPS signing of each receipt over the mandated fields, registered via the same adapter/registry pattern as the accounting providers (registerControlUnitAdapter/resolveControlUnit). It fails safe to the deterministic local sandbox stub (certified:false) whenever creds /liveReadyare absent, so a receipt can never masquerade as a legal registration; go-live = the certified unit's creds +liveReady.recordFiscalSaleis now wired into the order settlement path (createTenantOrderborn-paid +finalizeSeamChargecapture): every paid Swedish sale is recorded exactly once (idempotent per order, POST-COMMIT / best-effort — never blocks or throws into the order mutation), the monotonic per-register counter (receipt number + grand total) is advanced under the row lock (gapless numbering), and the immutable journal is DERIVED from the orders (no unbounded array). Adds X-report (running) + Z-report (daily close, monotonic Z-number + period boundary persisted under lock), a fiscal status panel on the compliance dashboard (mode mock/live, control-unit serial, counter, last Z, X/Z buttons), and aGET/POST /api/tenant/[tenantId]/fiscalroute. ALL fiscal math is deterministic — never an LLM. XML journal export reused as-is. 18 injected-dep unit tests (control-code determinism, counter monotonicity, idempotent record-once, X/Z aggregation + period boundary). ⚠ The cloud unit's exact API envelope + XSD are UNTESTED until certification/creds land (like a keys-only rail); sandbox receipts are compliant drafts, not legally valid until a certified unit signs.
- B2B supplier payout reconciliation sweep (R360-H70, keys-only). The supplier charge + delivery-time payout halves already move real money, but a payout stuck
pending(supplier not yet Connect-onboarded at delivery) orfailed(a transient transfer error) had no way to complete. New dailysupplier-payout-reconcilecron scans delivered orders and safely RETRIES pending/failed payouts through the same idempotentsettleDeliveryPayoutpath (StripeidempotencyKey: payout_<orderId>→ a retry racing a real transfer returns the original, never double-pays). Terminal states (paid,simulated) are never retried;failedretries are capped (MAX_PAYOUT_RETRY_ATTEMPTS). It also reports charge↔payout mismatches (a supplier paid out with no real restaurant charge behind it, or a really-charged order whose supplier is still unpaid) for operator attention — never inventing or reversing a money movement. With no Stripe rail configured it is an honest no-op. Registered inPLATFORM_CRON_JOBS+vercel.json(fail-closed CRON_SECRET gate); 10 injected-dep unit tests.
- Delivery platforms — outbound to the aggregators (real, keys-only): real menu / item-availability (86) / store open-closed push OUT and order status callbacks (accept / reject / ready) — Uber Eats fully real (OAuth + Menu/Store/Order APIs), Deliveroo real order-side, others real where their partner docs permit, all with honest mock-fallback when creds are absent. GloriaFood is now a first-class tenant-bound connectable platform (per-tenant encrypted push token — the routing key is no longer spoofable across tenants). Verified by a 3-lens adversarial review (4 HIGH findings — GloriaFood dead-routing + a cross-tenant auth spoof + two reject-enum 400s — all fixed + pinned). Go-live = each aggregator's partner-program credentials + certification (GloriaFood needs none — just paste the webhook URL + token).
- Accounting Integration Platform — finished the Phase-50 engine to production-real, keys-only. The universal provider-agnostic engine already shipped (Phase 50, BK-0…11); this closes it to a genuine Sweden-first go-live where the only remaining gates are credentials + sandbox verification + the flag flip — no more code.
- Real expense + journal-entry push (BK-9) on Fortnox + Visma (previously
unsupported): supplier-invoices + vouchers via their real APIs, supplier ensured search-first, balanced double-entry validated before any POST, idempotent via the external-id ledger + a deterministic externalRef marker. - Two new complete real adapters — Xero + QuickBooks Online (2 → 4 real providers): the fullAccountingProviderAdapter(company/accounts/VAT-codes/fiscal-years, customer upsert, invoice, credit-note, payment, journal, expense) against each provider's Accounting API, with their native idempotency headers (XeroIdempotency-Key, QBORequestId), honest error classification, OAuth-bearer + tenant/realm scoping — mock-fallback preserved when creds are absent (isProviderMockMode). - Inbound provider webhooks: real HMAC signature verification (fail-closed, timing-safe, raw-body), the Xero intent-validation handshake, idempotent by event id, their own isolated inbound store; an unconfigured provider honestly 503s rather than process an unverified body. - Reconciliation report (BK-11): provider-posted (external-id ledger) vs local books per document type — drift / orphan / dead-letter classification, honest—where a provider figure can't be read, surfaced on the accounting sync page. - Mixed-rate VAT: per-line 12% + 25% on one bill via an additive optionalProduct.vatCategory(menu-editor set) — resolving to dated Phase-19 rates per channel. Strictly additive: an uncategorized order renders byte-identical to before; a review-caught tax bug (categorized additive catering/table-bills mis-taxed) was fixed so mixed-rate applies only to VAT-inclusive orders and nets order-level discounts. - Verified by a 3-lens adversarial money review (5 findings — a HIGH VAT-compliance bug + a customer-mislink idempotency collision — all fixed + regression-pinned). 402 accounting/bookkeeping/VAT tests green, fullnext buildgreen. Go-live gates (sandbox verification against real Fortnox/Visma/Xero/QBO test companies, per-country legal consent, the flag flip) are documented, keys-only.- Growth Command Center BI — finished to 100% (real data or an honest "—", never a fabricated number). The super-admin
/admin/growthdashboard was already mostly real; this wires every remaining panel to a real source and retires what has none.
- Killed the one null-honesty violation: the "Direct Joins" module rendered seed panels (fake restaurant names / "199 kr") under
live:true, suppressing the sample flag — a super-admin saw fabricated data as real. Now built from real direct-channel tenants (joins-by-source fromacquisition.utmSource/referrerHost, plan mix from subscriptions, recent joins with real first-invoice amounts), or honestlySampleNotewhen there are none. - Now real from existing sources: channel CAC/ROI from real sales-agent + web-designer commission ledgers (per-channel; every channel without a real platform-acquisition cost stays—— tenant sponsored-ad spend is deliberately excluded because it isn't platform acquisition cost); leaderboards (top agents by attributed MRR/deals, top designers by sites-live); the industry drill (grouped bybusinessType); the country drill (from explicit billing/registrant country or an unambiguous currency→country map — EUR/USD never inferred, self-retires to sample below coverage); the executive briefing + advisor cards (deterministic, every claim citing a real number — no LLM); the MRR forecast (from the real trailing-trend slope, replacing hardcoded projection %). - Retired the fake: the Waste & Recycling module had no data source and none planned → removed from the nav rather than ship a permanently-representative module. The decorative "Last 30 days" pill (nothing was date-windowed) was removed rather than fake a filter. - CSV export now covers the funnel, forecast, leaderboards and module panels, each tagged with asource(live | sample) column so an export can never present seed as real. Phase-62 Marketing module + all its hooks preserved.- Swedish receipt & invoice set — closed to 1:1 with the design handoff. The Kvitto, Faktura and Leveranskvitto were already ~85–90% implemented in prior sessions; this closes the remaining gaps against the reference, verified by a headless screenshot-compare per the 1:1 rule (which also caught two real correctness bugs — see Fixed).
- Kvitto (thermal receipt): the hosted receipt + PDF + email now render itemized lines (name, á-pris sub-line when qty>1, option sub-lines), the Kassa/Kassör/Bong register row, and the Terminal/AID/Ref/TVR card-acquirer trace + website footer — all additive and honesty-guarded: fields render only when the terminal actually reports them (items persist only when they sum exactly to the recorded total), never invented server-side; older terminals get byte-identical lump-sum receipts. - Faktura (A4 invoice): the shared
InvoiceModel+ both renderers (screen + PDF, used by sale/catering/canteen/webshop) gained the "Avser:" subject line, Kundnummer, a seller tagline, the "Sida 1 (1)" footer, and the Swish payment rail — a green Swish label + Swish-nummer row + a fixed-amount locked Swish QR (the design'sC-format payload), preferred over the bank-app QR when the tenant has Swish, coexisting with the Bankgiro/OCR rows. - Leveranskvitto (delivery slip): it rendered 1:1 but nothing linked to it — added a "Leveranskvitto" link on the dashboard orders history (delivery/pickup only) and a real 32-col ESC/POS delivery-slip print job (delivery-slipdevice-job kind), preserving the name-only privacy projection (no address/phone/portkod on the slip).Fixed
- Invoice VAT-inclusive rounding (surfaced by the 1:1 compare): storefront orders whose total already includes moms rendered
Netto = grossand an impossibleÖresavrundning −50,00;buildSalesReceiptnow detects additive-vs-inclusive VAT by which yields a plausible öresavrundning (±0,50), so inclusive orders show the correct Netto/Moms/rounding and every existing golden fixture stays byte-identical. - Swish number formatting on receipts + invoices — bare 10-digit numbers now display design-grouped (
123 456 78 90), owner-typed formatting preserved, WinAnsi-safe.
- Adyen rail — the money core completed (Group A): refund confirmation, double-refund fix, webhook hardening, webhook-miss reconcile.
- G1 — refund confirmation:
/api/adyen/webhooknow handlesREFUND/REFUND_FAILED(it previously dropped everything butAUTHORISATION). The pending refund row the refund route posts is matched by documented precedence (refund pspReference → ledger idempotency key from our mintedrefund:{tenantId}:{key}merchantReference → originalReference+amount fallback) and flipped by the newfinalizeRefundRow(once-only, never downgrades — mirrorsfinalizeLedgerCharge). A lateREFUND_FAILEDafter a confirmed refund is audited, never auto-reversed. - A2 — credit-note timing: the BK-7 credit note is now issued when the refund CONFIRMS. Split by row status, not provider: sync rails ('refunded' rows — mock/gift/immediate) keep today's same-lock behavior byte-identical; async rails ('pending' — Adyen) defer to the confirmation flip via the newconfirmAsyncRefundchokepoint (lib/payments/refund-confirm.ts), reusing the exact samerecordCreditNoteForRefundpath (idempotent by ledger-entry id). - C1 — double-refund guard (real money bug): the refund route's remaining-math now counts PENDING refunds conservatively (refundAvailability); a second click while one is in flight gets an honest 409, and aREFUND_FAILEDflip frees the amount again. The refund idempotency key now defaults DETERMINISTIC (refund:{chargeId}:{amountMinor},:rNpast failed attempts) instead of a fresh UUID per request — identical retries dedupe in the ledger AND share the AdyenIdempotency-Key, so a double-click can never mint a second real refund. Explicit client refs still win. - Webhook hardening: merchantAccountCode must match the configured account (C4, fail-closed behind the HMAC); the order branch books what Adyen actually captured but flips the order paid ONLY when the total is covered (isFullySettled— C2, partials are logged + left pending, never over-credited);CAPTUREfinalizes identically toAUTHORISATIONkeyed by the payment's pspReference (delayed-capture accounts confirm; auto-capture redelivery = no-op; failed captures audited);CANCELLATIONreleases pending seam rows honestly (paid-then-cancelled is audited for a human);CHARGEBACKflags the charge row (disputed— additive field) + audit log so disputes are VISIBLE (full dispute/defense flow = documented future item). Per-item try/catch + the[accepted]contract preserved. - G6 — webhook-miss reconcile for orders: the adyen-order route now stampsOrder.adyenPaymentLinkId, and the customer's post-payment status polls (/api/bazar/track/[orderId]— the Adyen returnUrl landing — and the session-gated/api/bazar/orders/[orderId]) re-query the payment link once when the order is still pending, finalizing through the SAMEcreditAdyenOrderPaymentpath the webhook uses (one finalizer; a later webhook upgrades the row's providerRef to the refund-capable pspReference, never a second row). Mirrors the shipped gift-card fallback. - Verified: 29 new unit tests (ledger.test.ts,refund-confirm.test.ts,adyen/notifications.test.ts,adyen/order-credit.test.ts) with Adyen-doc-shaped fixtures; full payments suite 81/81;tsc --noEmitclean.- Payments — tenant Adyen toggle in Setup Center (Adyen rail, group C). The owner's Settings → Payments tab gains an Adyen card matching the Stripe/PayPal/Swish idiom: it toggles
paymentSettings.adyenEnabled(the per-tenant opt-in the checkout + gift-card buy pages already gate on) via the existing settings PATCH. The switch is honest about platform state: a new tenant-scopedGET /api/tenant/[tenantId]/payments/adyen-availabilityreturns a secret-free{ available, environment }projection of the platform rail (projectAdyenAvailability, no-leak contract enforced byavailability.test.ts), and when the platform rail is off the toggle renders locked with "Not yet enabled on the platform" instead of a dead switch that would silently no-op at checkout. Test-mode rails surface an amber "Test mode" badge + sandbox-only copy; live shows a green "Live" badge. Fail-closed on fetch errors; both dashboard themes; 320px-safe.
- Adyen go-live console (super-admin, Phase 65 PAY-RAILS Group B). New
RestoraPayAdyenpanel on/admin/restora-pay(the existing rails console, already in the sidebar): honest rail-state banner surfacingresolveAdyenRailMode's mode + reason, redacted credential display (API-key last-4 / HMAC-set flag — secrets are write-only, never echoed), rotate forms for API key + HMAC key, merchant-account + live-URL-prefix inputs, a Test/Live environment selector with an unmissable "LIVE = real money" warning and aconfirmDialog(danger)guard on any save that setsenvironment:'live'or enables the rail in live, the effective Checkout API base for operator sanity-checks, and a 6-step go-live checklist.GET /api/admin/adyen/settingsextended additively withrailMode/railReason/checkoutBase. Money-guard + checklist logic extracted pure and unit-tested (adyen-console.test.ts, 8 tests).
- Phase 62 — AI Marketing Autopilot (MK-0 … MK-12): configure once → AI drafts, owner approves, honest ROI. A new per-tenant (and platform) marketing pillar that generalizes the shipped social autopilot rather than forking it: a deterministic planner ranks what to do across channels from the tenant's own data, an LLM only narrates copy (RIC-routed, one-wallet-metered, honesty-fenced), and everything runs under an owner-set approval mode (
manualdrafts ·semi-autolow-risk auto + an approvals inbox ·automaticwithin guardrails). Ships flag-OFF, mock-first, go-live = keys only (no second dev round).
- The engine + Hub: a Brain (
TenantDB.marketing) + a sense→plan→generate→gate→execute→learn orchestrator, surfaced as tabs on/dashboard/marketing(Command · Plan · Approvals · Learning · Analytics · Budget). An event→rule automation bus (lifecycle / weather / low-sales triggers, cloned from the accounting-sync dispatcher) and a unified CRM view + deterministic segments over the 5 fragmented customer stores. - Channels: organic social, email (with real open/click/unsubscribe tracking — fills the previously-emptyCampaignAnalytics), consent-gated SMS + push, Google Business posts + AI review replies, VSO/VEO/EEO structured data. - Measurement: the order→campaign attribution link (a signed, consent-gated first-party carry →Order.campaignTag→ the growth spine) makes ROAS/CAC real instead of—, and closes the learning loop back into the scorer. - Real go-live rails (built 100%, gated): complete Meta Marketing API v25.0 / Google Ads v24 / TikTok Business v1.3 ad clients + GA4 / Search Console ingestion, real per-provider OAuth, an encrypted connections vault (reuses the existing token key), a real budget engine (ad money kept strictly off the AI wallet) and a real social-publish bridge to the live FSN publisher — each serves an honest in-memory mock until its credentials + a live connection resolve. Owner-gated behindmarketing_paid_ads/marketing_live_publish. - Verified: ~700 unit tests, fullnext build, and a 5-lens adversarial review (10 findings, all fixed). Docs:docs/products/marketing-autopilot/go-live-runbook.md.- Reliability — cron-drift remediation (shipped alongside Phase 62). The self-hosted prod box only ever fired ~15 of 52 cron routes (
vercel.jsonis inert under pm2);PLATFORM_CRON_JOBSis now the single source of truth (15 → 45), reviving ~24 silently-dead jobs (incl. the Phase-66 workforce crons +order-rollupthat ran nowhere), with acron-registry.test.tsdrift guard so a cron can never merge unregistered again, heartbeats + a Guardian staleness alarm, and two real billing bugs fixed (restora-pay minimum-fee / swish-fees day-1 under-billing).
Fixed
- RIC consolidation: the blog/assistant, citation-hub AI-draft, and SEO copy generators now route through the RIC gateway (the SEO "Generate with AI" buttons are genuinely AI now, with the deterministic templates as the honest fallback) — zero behaviour change while RIC is off.
- Seller mode — a real tool for a person on a bike, not a trimmed restaurant dashboard. The lite shell now stops handing a solo mobile seller (cart / cycle / on-foot vendor who IS the cook, cashier and server) surfaces built for a multi-person venue. Kitchen Display and Customer Display became opt-in features (
kitchen-display/customer-display,basic-tier so every existing multi-station tenant keeps them — zero regression — and add-on-purchasable so a food truck with a real second screen can switch them on); the Orders-page display tabs and the/dashboard/kdsroute are now gated on them, so a seller with no second screen sees a clean orders list and no "Connect a screen". The seller nav is rebuilt around the actual loop — Sell · Orders · Menu · Money · Where am I today · Get discovered · QR code · Gift Cards · Reviews · Billing · Setup · Help — with seller framing (no "Take Order (Waiter)", no tables/seats/courses), landing on the new Sell counter instead of a restaurant overview.
- Services directory redesign — imagery + a platform-aware app-download showcase (`/services`). The flat icon grid becomes a full marketing surface: an editorial hero with a live laptop + POS-phone dashboard mock, a new "Download the apps" showcase (flagship Owner card + a per-role app grid), an enriched searchable directory, and an ambient "every device" CTA band over real food photography.
- App→platform accuracy: every app shows App Store / Google Play / Windows badges only for the platforms it actually ships on, driven by a
NATIVE_APPSregistry cross-checked againstandroid/settings.gradle.kts, theiOS/apps and the TauriRestora360 Station(e.g. FSN = iOS + Android, no Windows; Kitchen Display = Android + Windows; Menu TV / Atmosphere / Staff = Android-only). Windows apps are labelled as running inside the Station shell. - Mockup download links: no public store listings exist yet, so badges preview each app and open its live web surface today — centralised so go-live is a one-line swap to the real App Store / Play / Station installer URLs. - Directory: services carry per-app platform glyphs inline and a platform-correct "Get app" reveal; the auth-gated "Worker shift client" entry corrected to login-required. - Scoped to the.r360-landingazure-on-navy theme (no new tokens); a11y-reviewed (filteraria-pressed, polite results live-region, AA-contrast tag fix). Verified: TypeScript clean; live render 200 with zero horizontal overflow at 390 px and 1280 px.- Wave 0 — the unified payment layer: go-live is now KEYS ONLY. The
lib/paymentsseam (the choke point every POS settle, deposit, top-up, table-pay and bar flow already calls) finally has real rails behind it — the registry that sat empty since Phase 33 now resolves per-rail bridges built on the existing production-grade code:
- Per-rail three-state mode (
disabled | test | live,lib/payments/provider-mode.ts) derived purely from credential presence + the explicit switches: a Stripesk_test_key or the Swish MSS cert or Adyen test env = test (real sandbox round-trips,mock:truerows, exercisable end-to-end with zero risk); live keys + therestora_payflag + the mode switches = live. Matrix-tested; a super-admin entering keys flips a rail with no deploy.GET /api/admin/payments/railsis the live readout with honest per-rail reasons. - Three provider bridges (lib/payments/providers/): Stripe Connect (direct charges on the tenant's Express account with the cleanapplication_feecut,pending+clientSecretresults), Swish (mTLS client, deterministic retry-safe 32-hex instruction ids, e-commerce push viapayerPhoneorswish://app link, brief settle-poll), Adyen (hosted Pay-by-Link + HMAC webhook). Plus the honestexternal-tenderrecorder: cash / external-terminal card / gift rows become real money rows (mock:false) oncerestora_payis ON. - Real holds:authorizePayment/captureHeldPayment/voidHeldPayment(Stripe manual-capture PaymentIntents) with a mock fallback so every flow works in every mode. - Pending→final plumbing:finalizeSeamCharge(split-bill-aware, idempotent, BK-5 events) driven by all three confirmation channels — the Swish callback (ledgerKeypending records), the Stripe connect-webhook (metadata.seam='payments', incl.amount_capturable_updatedhold confirmation), and the Adyen webhook (ledger:references). POS settle now answers202 awaiting approvalwith the Swish app link / Stripe client secret instead of pretending an instant settle. -PAYMENTS_MODE=mockstays the global kill switch;=live= strict fail-loud (never silently mock-settle). Ledger rows carrymode: 'mock' | 'test' | 'live'.- Wave 1 — deposits ride the real rail. Reservation deposits and pickup-slot deposits (previously mark-only: no charge, no ledger row, no refund) now capture through the unified seam (
reservation-deposit:/slot-deposit:idempotency-key families with never-double-charge retry minting), post honest ledger rows, and refund on cancel/refund actions against the recorded paid charge (over-refund capped). Catering deposit/balance and bar settle were verified already-wired. Legacy mark-only records keep their exact old behavior. - Wave 1 — bar tab pre-auth is a real card hold. Tab open places a genuine hold (
authorize→ guest confirms card → webhook flipspending → held), card settle captures the hold (remainder auto-releases), void/close releases it at the provider. Mock holds keep today's flow byte-for-byte; replaced/orphaned holds are voided, never stranded. - Wave 1 — production ESC/POS receipts: the ` MOCK PRINT ` stamp is dead.
lib/devices/render.tsnow emits the byte-accurate Swedish Kvitto layout (styled ESC/POS: emphasized store header, org/moms numbers, wrapped item lines with options, gift-tender rows, back-derived VAT table, ATT BETALA double-height, sv payment labels, CP865 åäöÅÄÖ) through the EXISTING Windows Station transport — no station rebuild needed. Preview mode (*** FÖRHANDSVISNING ***) renders the same layout whenever no agent has claimed the printer; preview defaults ON so an un-updated caller can never accidentally print a clean receipt. - Wave 2 — canteen seams closed. (1) The daypart
productIdsallow-list now actually switches the menu (kiosk feed filters products + drops emptied categories within one poll; the POS and kiosk order writes 409 out-of-window items server-side) with a per-daypart product picker in the canteen admin. (2) The self-order kiosk accepts the canteen wallet/subsidy tender (badge-code keypad in the cart, same one-lock debit path as POS — factored into sharedlib/canteen/tender.ts, default ON with canteen, explicitkioskWalletTender:falseopt-out). (3) Canteen card/Swish top-ups ride the unified seam: with real rails the wallet credits ONLY when the money is confirmed (a source-keyed finalize hook), never on initiation. - Wave 2 — Seller plan + true LITE dashboard. The 99 SEK
street-food-starterplan gets a curated street-food-sized feature set (was: the full basic cockpit), Stripe price wiring (STRIPE_PRICE_SELLER), billing-page bullets, and a one-click Restore & publish in the Pricing Studio (ships archived/CLOSED until then). Plans can declareuiMode:'lite'→ tenants on such a plan get a genuinely simplified shell: flat 11-entry sidebar, no group headers, ~52px touch rows,/dashboardlands on order-taking. Same routes, deep links intact, absent signal = byte-identical full shell.
- Wave 2 — Guest at-table QR ordering (scan → browse → cart → KDS → pay from the table). The table QR (
/t/[tenantId]/[tableSlug]) grows a full guest ordering flow, default OFF per tenant (settings.tableOrdering.enabled, editable via the standard settings PATCH) and additionally gated by the masterita_enabledflag, the per-tenantita.enabledopt-out and theonline-orderingentitlement — with the gate off the QR page is byte-identical to the pay-only surface it was.
- Order create: new public
POST /api/public/[tenantId]/tables/[tableSlug]/order— HMAC guest token pinned to tenant AND tableSlug (a token minted for one table can never fire to another), rate-limited, demo-blocked, entitlement- and closed-hours-guarded, server-side re-priced (verbatim port of the online-order loop incl. multi-locale option resolution + stock/86 gates, shared as purelib/tables/table-ordering.ts), created through the locked seam (createTenantOrder) astype:'dine-in'+tableId/tableNumber+ newsource:'qr', bornpending, idempotent byclientRequestId. Lands on the KDS/orders board exactly like a waiter dine-in ticket (the table badge already renders). - Pay from the table: new publicPOST …/order/[orderId]/paythrough the unified payment seam (capturePayment→postChargeToLedger,source:'table-qr') — mock settles instantly; Stripe rails returnclientSecret(confirmed on the guest's phone, webhook finalizes); Swish returns theswish://deep link (callback finalizes). Stable idempotency key with deterministic retry-after-recorded-decline. Pay-later remains first-class via the existing staff-built bill QR flow. - Guest UI: Menu / Cart / Your-orders screens inside the existing ITA PWA (320px-first, azure, en+sv via the ITA i18n table), option bottom-sheet honoring radio/checkbox + min/max, sessionStorage cart per table, live status via the existing token-gated/api/bazar/track/[orderId]at the 5s cadence. - Menu projection:GET /api/public/[tenantId]/ita/menuadditively gainsprice/description/imageUrl/available/localizedoptionGroups+ordering:true/currency— ONLY when the tenant gate is on; otherwise byte-identical (still unpriced). - QA: 10 new node:test units (binding, gate, re-pricing, schemas); end-to-end mock run on the dev server (order fired → dine-in ticket with table binding on disk → track → mock pay → paid flip + ledger row +order.paidoutbox; idempotent replay / cross-table rejection / gate-off regression / closed-hours guard all verified) + 320px Playwright screenshots of every screen.- Phase 60 — KDS 2.0, the world-class kitchen display (waves KDS-0…10 + TV parity, delivered in one go). The kitchen board grows enterprise-grade workflow while staying exactly as simple as before by default — every new capability is opt-in and OFF at factory settings, and the order engine is untouched (bumps still ride the existing order PATCH + offline outbox; course-fire unchanged).
- Settings (KDS-0): gear icon → a touch-first Kitchen Display Settings modal (Appearance / Workflow / Order display / Behavior / Sorting & station), persisted per device (
kds-settings:v1:{tenant}, migrates the oldkds-soundkey). Reset = today's board, exactly. - Themes (KDS-1): Dark (default — the familiar look, polished) / Light / Auto + a high-contrast option, implemented as a scoped CSS-variable layer so the KDS keeps its own switch independent of the dashboard theme. Font size S/M/L/XL scales the whole board; compact mode for busy screens. - Card redesign (KDS-2): bigger numbers and timers (readable at 2–5 m), 4-step priority (On time · Waiting · Late · Critical — always icon + label + color, color-blind safe), order-type + delivery-channel badges, table numbers, every line toggleable. - Cooks & claims (KDS-3): tap-your-name cook bar (Phase-38 roster + ad-hoc names) and advisory, non-blocking "I'm on it" claims with avatar chips. - Item progress (KDS-4): large per-item check targets, quantity-aware (3× counts 1/3 → 2/3 → ✓); Auto Complete bumps the order through the REAL lane transition when the last item is checked; held course items can't be checked (pacing wins). - Assignment (KDS-5) + vocabulary (KDS-6): per-item cook assignment with initials dots, and one shared icon+label status chip set (Waiting/Working/Ready/Served/Done/Cancelled/Hold/Fired) used across web and TV. - Undo & Recall (KDS-7/9): every bump/check/claim/assignment gets a 15 s themed Undo toast (offline-safe — inverse ops share the same outbox dedupe keys); Recall brings back the last bumped tickets; bulk Complete-all-ready and Clear-done; Print ticket re-enqueues through the RW-3 kitchen-printer pipeline (hidden when no printer). - Filters, sorting & stations (KDS-8): one-tap filter chips (All/Mine/Pending/Working/Delivery/Takeaway/Dine-in), six sort modes with an Auto Sort toggle (positions freeze when off), and per-device station pinning ("grill screen") with an n more items at other stations context hint. - Performance & a11y (KDS-10): the old whole-board 1 Hz re-render is gone (leaf<TicketAge>timers + bucket-only priority updates + memoized cards), the feed short-circuits on acontentHash, long lanes window their rendering behind an IntersectionObserver sentinel, number keys 1-9 bump the Nth Ready ticket (bump-bar friendly), U = undo,aria-livenew-order announcements, reduced-motion honored. - TV parity (KDS-11): the read-only/tv/screenkitchen board shows claims, item check marks and assignee dots — additive feed fields, rendered only when the tenant uses them. - Data & API: additive-onlyOrderItem.kdsState/kdsDoneCount/kdsCompletedAt/kdsAssignee+Order.kdsClaims; new idempotentPOST /kds/actionsop channel (absolute values, outbox-replay safe). POS, waiter, storefront, Kanban and tracking are untouched. - QA (all gates green): 8 new unit tests (settings merge, quantity cycle, hold blocking, undo stack, recall pool); a 44-assertion live Playwright gate — zero-regression at factory settings (real bump PATCH, real course fire), the full feature matrix (claims/checks/assign/undo/auto-complete/filters/station/bulk/recall/print-gating) against the live board, theme × contrast × font screenshot pass, and scale 5/40/200×6 (board interactive ~3 s, windowed DOM 120→180 on scroll, p95 scroll frame 18 ms, zero page errors).Changed
- No more native browser dialogs — anywhere (repeated owner request, now a guarded platform rule). Every
window.confirm/alert/prompton every surface (owner dashboard, super-admin, agent/designer/supplier portals, order tracker, Food Bazar, Mason, FSN, invoice pages) — 85 call sites across 73 files — now uses the themed in-app dialogs, which follow Light/Dark mode and never show the browser's "restora-server… says" chrome. The shared component grewalertDialog(single-OK notices) andpromptDialog(text input, samestring | nullcontract aswindow.prompt) beside the existingconfirmDialog; destructive confirmations get a red action button and a proper verb ("Delete", "Revoke", "Unpair", …). A new guard test (no-native-dialogs.test.ts) scans the whole source tree and fails the suite on any future native dialog call — the rule can't silently regress again. - Test suite fully green again — both long-standing red tests fixed.
intelligence/import-guard: the Learning Ledger no longer imports the raw Intelligence Store accessor; it reads its one aggregate through a new sanctioned seam (lib/intelligence/ledger-stats.ts→rieEventCount()), keeping RIE isolation mechanical.dark-vocabulary: the three flagged pale hexes are genuinely fixed surfaces (TV-canvas selection handles + default TV text color, the TV style-tile mini-preview, and the transparency checkerboard behind background-removed PNGs) — allow-listed with documented reasons per the test's own convention. Also fixed 4 pre-existing lint errors in touched files (a local helper nameduseImagetripping rules-of-hooks, unescaped JSX entities).
Added
- Phase 61 follow-ups IA-2b · IA-3b · IA-3c · IA-5b — the four parked Navigation-2.0 follow-ups, all delivered.
- IA-3b — Mason Menu tab reaches parity with the retired Navigation Builder, and `showCartButton` finally has a renderer. Every top-level navbar link in the Navbar editor's Menu tab now has three per-surface visibility toggles (Desktop/Mobile/Footer) and a badge picker (None/New/Hot/Promo) — the exact fields
sanitizeNavItemsalways round-tripped but no UI exposed. A "Cart icon" card togglesnavConfig.showCartButton, which was dead config end-to-end: there is now a real header cart icon (HeaderCartLink, live item count from the layout-wide cart, links to the menu page) rendered by BOTH the legacySiteHeaderand the themedSiteNavbar, gated on the Online-ordering capability like every other ordering surface.SiteNavbaralso renders link badges now (they were mobile-only before), the Mason header preview mirrors desktop visibility + badges + the cart icon (WYSIWYG), andreconcileNavItemsno longer strips badge/visibility from nested child links on every save. - IA-2b — new QR screen pairings create per-device records; fielded TVs untouched. "Connect a screen" codes are now minted as PRE-AUTHORIZED TvDevice links (POST tv-devices/preauth), and both TV-side flows — typing/scanning the dashboard code (/api/public/tv/pair) and scan-to-connect from the TV (live-orders/tv-link+tv/link/[deviceId]) — create a realTvDevice(customer-display mode) at pairing time, so every NEW screen shows up in the Hardware Center fleet with name, online heartbeat, theme override, remote commands and per-device revoke. The/tvpage stores the device identity and boots the full/tv/screenexperience. Transition-safe by design: the sharedliveOrdersDisplayToken, its rotate/disable card, the/displaypage and every fielded TV's stored token are untouched (NEVER auto-rotated); the live-orders display feed additionally accepts active customer-mode device tokens (with heartbeat), so even a TV still running cached pre-deploy/tvJS pairs into a working, revocable screen. Owner UAT left: one real-TV re-pair (documented in TODO). - IA-3c — approved WoW-clone theme snapshots feed the Mason theme gallery. Approved snapshots (reviewStatus === 'approved', super-admin review is the curation gate) surface as skin-only standard packages in the unified catalog — colors/font/header/menu-style only, tenant content untouched — riding the normal install pipeline (rollback snapshot, undo,themePackageFlagsdisable).ThemeVariantgainedmenuStyleso the snapshot's menu presentation applies like the retired /dashboard/themes page did. Pipeline is dormant until the WoW funnel goes live (zero approved snapshots exist), so the gallery is unchanged today. - IA-5b — Help Center "Learn more" tab (the gate finally passed). The Academy (/learn/academy, Phase 15 role tracks + certifications + progress API) and Community forum (/community) turn out to be REAL, working surfaces — the IA-5 "don't exist yet" premise was stale. New 5th Help Center tab with outbound cards (Academy, Certifications, Certified directory, Community; new-tab, since they're public pages),tabLearnlabel translated in all 20 languages, "Academy/Community/Learn" added to sidebar-search keywords. Bundled fixes:/learn+/community(+ their APIs) added to the proxy BYPASS_PREFIXES so they resolve from dashboards on TENANT hosts (same class as the IA-3 /studio 404), the/learnheader is session-aware ("Open dashboard →" for signed-in owners instead of the pre-launch launch-list pitch), and the Academy CTA's stray non-azure hover was re-azured. - QA: tsc 0 · eslint 0 errors · nav guards 10/10 · theme-package tests 35/35 · pairing tests 62/62 · full suite 4498 pass (2 pre-existing known reds only) · live E2E on dev: full IA-2b pairing lifecycle (mint → exchange → fleet online → revoke kills both auth paths), builder GET/saveshowCartButtonround-trip, storefront cart icon SSR, Learn tab + Academy/Community 200 on tenant hosts.Changed
- Phase 61 IA-7 — Navigation 2.0 is now the default and only owner nav (the `nav_v2` flag is retired). After the full IA-0…IA-6 build + owner UAT, the job-based sidebar (5 daily primaries + workspaces + utility zones) is promoted to the one canonical catalog. The
nav_v2feature flag, its super-admin master-switch API, the per-request flag read in the dashboard layout, thenavV2thread through TenantProvider/sidebar/search/Quick-Links, and the one-time "navigation got simpler" intro card are all removed — no behavior is gated any more, so there is nothing to flip. Route resolution is preserved: the 19 hub-linked tools (the/studiodesigners + the Marketing Hub toolset) that used to resolve only via the legacy catalog now carry their own metadata in aHUB_LINKED_ITEMSlist folded intoALL_NAV_ITEMS, so Quick-Links stars, bookmark validation and search keep working — including the exact-beats-prefix case (/dashboard/settings/googleis not shadowed by the Setup Center/dashboard/settingsprefix). Guards reworked to single-catalog invariants (10/10); the nav sweep no longer flips a flag. tsc 0, lint 0 errors, suite green. Everything still lives at the same URLs; absorbed routes still redirect. - Nav polish (owner makeover) — Marketing Hub & Experience Studio are single sidebar links, and the Marketing Hub cards now describe themselves. In the Navigation 2.0 sidebar, a group with exactly one destination (Marketing Hub, Experience Studio — and Help Center, same shape) now renders as a flat link instead of a dropdown: click it and you land straight on the hub. The Marketing Hub's individual tools (Offers, Loyalty, Email, Social, SEO, Reviews, …) are pulled OUT of the sidebar — they live as cards on
/dashboard/marketing, which is the one place that "says everything." Those routes stay fully live and searchable (generalizedHUB_LINKED_TOOLSregistry + keywords;findNavItemnow prefers an exact match in either catalog over a broad prefix, so/dashboard/settings/googlestill resolves). The Marketing Hub cards gained a one-line description each (matching the Experience Studio tiles) and translated labels. tsc 0; nav guards 11/11 (the "nothing removed" + "hub-linked tools stay live and linked" invariants now cover both hubs); suite green.
Added
- Help Center + Overview 2.0 (Phase 61 IA-5 + IA-6) — one help home, and a dashboard that answers "what needs me now". IA-5:
/dashboard/helpis the Help Center with four tabs — Guides (the 32-article browser, default; also fixes the latent?q=deep-link bug), Ask Restora (the Phase-18 Support Brain; escalating now lands you on your new ticket), Tickets (the full ticket system, re-housed verbatim), and What's New (recent release notes rendered in-dashboard by the sameparseChangelog()the public /docs/changelog uses —[Unreleased]filtered out, QA footers stripped, English-only noted)./dashboard/supportis a redirect stub into the Tickets tab; the attention-bar "Support team replied" CTA and the header help dropdown point straight at it. The spec's "Learn more (Academy/Community)" tab is deliberately deferred (IA-5b) — those products don't exist yet, and dead links help nobody. IA-6: the Overview's Operations Center gets a curated default of FOUR widgets — Live Orders · Upcoming Bookings · Needs Attention (new: merged, severity-ranked, one hard-capped row per source — pending orders / unread messages / low or out of stock / offline devices — sourced from the existing overview payload + the existing devices summary; clears by state change, never by dismissal) · AI Recommendations. Everything else moved to a new add-widget gallery (per-widget hide ✕ + "Add widget" picker; zero data loss) — and owners with a pre-IA-6 saved layout are grandfathered (nothing hidden). Quick-Links stars now render as a shortcuts row on the Overview; the static Recent Activity card folded away (the Live Orders widget streams the live subset); the onboarding checklist auto-hides at 100%. i18n:helpnamespace (5 keys) + 10 newopskeys in all 20 non-en languages. tsc 0; guards 11/11; suite green (2 pre-existing fails).
- Marketing Hub (Phase 61 IA-4) — marketing tools grouped by job, one site-scoring surface. New thin landing page at
/dashboard/marketing: tools grouped Promote / Reach / Reputation / Insights (incl. the Marketing ROI cross-link into Restora Profit), each tile respecting the same per-itemfeatureIdvisibility as the sidebar, plus a light status strip (active offers · avg rating · loyalty members · scheduled posts) read from FOUR EXISTING tenant GETs — live number or "—", no time series, deliberately NOT a fourth analytics view. Site-scoring dedupe: the AI Design Report is now the SEO page's "Site Health" tab (the SEO page gained?tab=deep-linking);/dashboard/design-reportis a redirect stub into it. The V2 marketing group is ordered by the four jobs with the hub on top; the earlier IA-4 moves (Messages → Customers, Voice/Table Assistant → In-Venue Ops, AI Spend → Plan & Features) had already shipped in IA-0's catalog. i18n: newmarketingdict namespace (15 keys) +seo.tabSiteHealthin all 20 non-en languages. tsc 0; guards 11/11; suite green (2 pre-existing fails).
- Experience Studio workspace + the nav-clobber data-loss fix (Phase 61 IA-3). New
/studioworkspace on the Atmosphere shell pattern (own scoped theme, session guard, navy sidebar) plus the back-to-dashboard link Atmosphere lacks; the V2 sidebar entry opens it in a new tab (existing non-/dashboardconvention). It is a LAUNCHER — eight sections (Home · Website · Menu Screens · Kiosk · Displays · Create · Assets · Templates) of deep-link tiles; no tool route moved: Mason, the Menu TV designer, the Kiosk designer, Studio AI and the Media Library stay the single owners of their jobs (guard-tested via the newSTUDIO_LINKED_TOOLSregistry — each must remain a live page linked from the shell). The verified nav-clobber bug is closed:/dashboard/navand Mason's NavbarEditor both wrotedb.navConfigwith incompatible item vocabularies — every save on one silently deleted the other's links. The stand-alone page is now a redirect stub into Mason opened directly on the Navbar editor's Menu tab (new additive deep links:/dashboard/site?panel=navbar|theme,&gallery=1); ONE write path remains./dashboard/themes(the WoW-clone marketplace, zero approved snapshots in dev) also retires into the Mason theme gallery. The two buried pickers are surfaced as Displays tiles: customer-display themes (orders?tab=customer) and the order-tracking style (Mason theme panel).findNavItemgains a V1-pool fallback undernav_v2so Quick-Links stars and bookmark validation keep working on tool pages that left the V2 sidebar. Bonus fix:/studioAND/atmospherewere 404 on tenant subdomains (missing from the proxy'sBYPASS_PREFIXES) — both whitelisted; the nav sweep now fails on 404s so this class of bug can't slip again. Follow-ups tracked: IA-3b (surface badge/per-surface visibility/cart toggle in Mason's Menu tab — the API already preserves them), IA-3c (fold approved clone snapshots into the packages catalog). tsc 0; guards 11/11; suite green (2 pre-existing fails); sweep incl. /studio sections × both flag states passed.
- Hardware Center (Phase 61 IA-2) — every physical device pairable and manageable from ONE page, and the invisible-screens pairing fork surfaced.
/dashboard/devicesis now a tabbed manager: Overview (summary grid + the universal pair form) · Screens & TVs (embeds the sameScreensManagerthe Orders hub uses — one TV fleet, two doors) · Payment terminals (absorbs/dashboard/terminals) · Self-order kiosks (absorbs/dashboard/kiosks; the Kiosk Designer route is untouched) · Printers & peripherals (absorbs/dashboard/hardware) · Reliability (absorbs/dashboard/reliability). The four absorbed fleet routes are redirect stubs into their tabs (ABSORBED_ROUTESgrew; guard-tested). ONE shared `PairDeviceCard` replaces the copy-pasted 6-char-code forms (universal form, terminals, screens; the kiosk form keeps its pick-the-look wizard flow but shares the code cleaner). Legacy TV-pairing fork fixed by surfacing: screens paired via the Orders-hub QR path share one tenant-wide token and never got a TvDevice record — they now appear as a pinned "Shared display link" card in the Screens tab with Rotate/Disable actions (existing display-token endpoints; honest all-screens-go-dark warnings). Folding NEW QR pairings into per-device tv-devices records is a tracked follow-up (needs real-TV re-pair testing). Menu TV keeps its own screens list plus an "All screens & devices" link into the hub; the orphanedTvPairingButton.tsxduplicate was deleted. V2 nav slims (fleet items → search keywords); V1 untouched. i18n: a newdevicesdict namespace (9 keys) in all 20 non-en languages. tsc 0; nav guards 10/10; live sweep incl. all 6 device tabs × both flag states passed.
- Setup Center + Plan & Features consolidation (Phase 61 IA-1) — 11 setup surfaces become ONE sectioned page, entitlement pages go 4 → 2.
/dashboard/settingsis now the Setup Center: every tab deep-linkable via?tab=— Setup guide (absorbs the/dashboard/onboardingchecklist, same localStorage+server-detected progress), General, Opening hours (absorbs/dashboard/business-hours; its viewport-fixed save bar became a tab-scoped sticky bar), Branding, Languages, Payments, Ordering & Delivery (winner-links per the dedupe table — each duplicated editor now has ONE home), Integrations (absorbs the/dashboard/connectionshub grid; the old 4-platform delivery-toggle tab is retired — its strict superset lives on the Delivery page's Platforms tab), Website & Domains (connect/verify + the/dashboard/domainsmarketplace as one surface), Notifications, Security, plus a Devices pointer. Features page gains 3 URL-driven tabs: Manage (the Phase 56 capability switchboard, byte-identical PATCH), Your features (the catalog), Get more (the add-ons marketplace). All six absorbed routes (/onboarding,/business-hours,/connections,/domains,/capabilities,/addons) areredirect()stubs — the addons stub forwards query strings so Stripe checkout returns still finalise;AddonsMarketplace's post-purchaserouter.replaceand 8 in-app links retargeted. Dedupe winners enforced: the delivery-zones map editor now lists legacy postcode zones (editable pricing + delete) and hosts the delivery-radius cap; the old postcode-zones tab is a signpost to it;/dashboard/translationskeeps ONLY the AI translate editor (the duplicate enable/disable + default-language chips retired into a Setup Center link). V2 nav slims to match (absorbed items → search keywords;ABSORBED_ROUTESexported + guard-tested: every absorbed route must BE a redirect stub to its documented tab); V1 nav untouched. i18n: 19 new dict keys (Setup Center shell + Ordering section + Features tabs) ship in all 20 non-en languages. tsc 0; nav guards 10/10; suite green (2 pre-existing fails unrelated).
- Navigation 2.0 (Phase 61 IA-0) — the owner sidebar regrouped by job, behind the `nav_v2` flag. The ~112-entry owner nav is reorganized into the approved tree — daily primaries (Home · Quick Links · Orders) + compact daily groups (Reservations · Menu · Customers+Messages) + job workspaces (Marketing Hub · Experience Studio · Money · Restora Profit · Team · In-Venue Ops · Inventory & Suppliers · Devices) + conditional (Chains & Groups · Food Bazar) + utility (Plan & Features · Setup Center · Help Center) — as a pure regroup: every existing href appears exactly once, zero routes moved/added/removed, every item keeps its exact
featureId/badge/icon so plan-gating is untouched (all enforced by a newnav-catalog.test.tsguard: href-set parity, per-href gating parity, registry/icon validity, i18n coverage — the catalog previously had zero tests). Rollout: platform flagnav_v2(fail-safe OFF = the legacy nav byte-identical), flipped at runtime via the new super-adminGET/POST /api/admin/nav-v2/master-switch(audit-logged, ITA-pattern; listed in the flags console once created). Flag ON extras: sidebar search (and the Quick-Links picker) answer the old vocabulary — retired group names ("Marketing & AI", "Account & Settings", "Operations (in-venue)"…) and "Overview" match via catalogkeywords, in English and translated — and a one-time dismissible "Navigation just got simpler" card explains the change. i18n: all 9 new labels ship in all 20 non-en languages, plus back-fill for 9 labels that had been silently English-only since 2026-07-05 (Capabilities, Menu TV, Take Order (Waiter), Self-order Kiosks, Kiosk Designer, Demand & Weather, Network Reliability, Devices & Apps, Delivery Area (map)); the guard test now enforces full label coverage forever. Quick-Links stars are unaffected (stored by href). Newscripts/nav-sweep.mjsrenders every catalog href in BOTH flag states as a real owner (login → sidebar-tree assertions → 112-href sweep → flag flip via the real API → restore). tsc 0; my-files lint 0; nav guards 9/9.
- Fixed: Network Reliability sidebar icon.
Wifiwas missing fromNAV_ICONS, so the item silently rendered the Circle fallback since it shipped — now registered (and the new guard test fails on any future unmapped icon key).
- Real printing via the Windows Station (RW-3 slice 1) — Phase-38 hardware goes live. New public print-agent API (
/api/public/hardware/claim|jobs|jobs/ack): an agent exchanges a hardware device's one-time pairing code (from/dashboard/hardware) for its capability token, then polls for work and acks each job. Agent-claimed devices get a real job lifecycle (queued → printing → done/failed, 3 delivery attempts, stuck-printingrequeue so an agent dying mid-print never silently loses a receipt); unclaimed devices keep the mock behaviour byte-for-byte. The job poll doubles as the heartbeat (finally populateslastSeenAt→ online dot), failures surface in the dashboard job log with the agent's reason ("out of paper"), andtoPublicDevicenow reportsagent/agentInfo. Windows Station side: ESC/POS encoder (CP865 Nordic — åäöÅÄÖ correct on Swedish receipts), TCP:9100 + Windows-spooler RAW transports, cash-drawer kick. E2E-verified byte-exact against a wire-level fake printer. tsc 0; 9 new unit tests + 18 existing device tests green.
- Automatic burn-in protection on every connected screen. The TV surfaces (
/tv/screen— all four modes — and the legacy/displayqueue) now drift the whole rendered frame through a ±2px 8-point orbit (one step per minute, 2s glide) via a shareduseBurnInShifthook — imperceptible from viewing distance, but no static edge (logo, headers, column rules) ever parks on the same physical pixels, which is what permanently marks TVs left on all day. Because it lives in the page, every platform is covered: plain browsers, the Android TV shell, Samsung Tizen, LG webOS and the new Windows Station (native shells that ship their own orbiter coordinate via<html data-r360-burnin>so exactly one guard owns the transform, and display surfaces getoverflow:hiddenso the drift never spawns scrollbars). The "Connect a screen" card (Dashboard → Orders → TVs & Displays, and/dashboard/tvs) now tells owners: Automatic burn-in protection — every connected screen imperceptibly drifts the image so static content never marks your TV. tsc 0.
- Creator earnings — tenants earn 15% every time their image is resold. When a tenant creates + pays for an image and it's approved into the shared Inspiration pool, every time another restaurant buys it the original creator keeps 15% of the sale — carved from the platform's margin, so the buyer's price never changes (a resale has ~zero cost). Earnings land in a separate "Creator earnings" balance on the AI-spend page (shown in SEK so it feels real), which the owner can move into AI credit instantly, or — once a bank rail is wired — cash out above 300 SEK. Paid as AI credit today (zero payout infrastructure); the cash-out is a rail-agnostic
PayoutRailadapter so a Nordic provider (Bankgiro/SEPA, Swish, Trustly, Adyen) plugs in later with no ledger changes. Anti-farming: royalties only on cross-tenant, admin-approved resales (originTenantId ≠ buyer), idempotent per sale. tsc 0, lint 0; royalty + FX-round-trip unit-verified.
- Batch background removal + a dedicated transparent-image price (25 SEK). A "Remove background · 25 SEK/ea" button above Your generations opens a picker of your generated images (videos and already-made cutouts are hidden), where you multi-select and see a running total. The key rule: if your AI credit can't cover the whole batch, nothing runs — you're shown the exact shortfall and a one-tap top-up by card (reusing the AI-credit top-up flow), then it proceeds; you're only charged for images that actually succeed (N idempotent ledger lines, and re-running an already-cut image is free). Background removal is now priced at 25 SEK (it costs us more than a generation, and a ready-to-composite cutout is worth more) — generation stays 10 SEK. Buying a transparent cutout from the Inspiration pool is likewise 25 SEK (regular pooled images stay 10); cutouts show over a checkerboard with a "transparent" badge so buyers see what they're getting. The single- and batch-removal paths share one server helper, so dedupe/charge/idempotency/Media-library-save/pool-banking behave identically. tsc 0, lint 0, pricing + idempotency unit-verified.
- Background-removed cutouts now flow everywhere — the owner's Media library AND the shared Inspiration pool. A cutout used to land only in Experience Studio's "Your generations", so it couldn't be picked in the Menu-TV designer / website builder / menu image pickers. It now also saves to the owner's Media library ("My Library" in every picker) as a permanent, reusable transparent PNG. And, exactly like a generated image, it is banked to the system-wide media pool as PENDING (
originTenantIdattributed): a super-admin approves it in the "System-wide Media Pool", after which other tenants can buy it for 25 SEK (the transparent-cutout price) into their own library (the buyer's copy is transparent too — no re-encode). Deduped per source image so distinct cutouts never collapse and the identical cutout can't be banked twice. Reuses the existing pool + approval + buy pipeline and the generated-image banking pattern verbatim — no new admin UI. tsc 0, lint 0.
- Experience Studio — Background removal (any image → a fully-transparent PNG), 10 SEK. A "Remove background" action turns any image into a transparent PNG cutout — on the AI-generated result and on every saved image in Your generations (the priority), so a dish/logo/product shot can be dropped onto a menu, signage, or the Menu-TV designer with no background. The cutout is saved back to the gallery and previewed over a checkerboard so the transparency is visible. Charged a flat 10 SEK (super-admin-editable) through the one AI-wallet ledger choke point — only on a delivered cutout (a failure is never billed), in the tenant's own currency for non-SEK wallets, itemized on the AI-spend statement as "Background removal". Paid-gated + budget-gated like image generation. Powered by a swappable hosted vendor (Photoroom / Clipdrop / remove.bg — whichever key is set); ships mock-first — with no key it shows an honest "not switched on yet" and charges nothing, going live the instant a key is provisioned. Security: the input-image fetch now goes through the DNS-pinned
safeFetch(blocks private hosts), hardening this route and the existing generate route against SSRF. tsc 0, lint 0, 4 tests; adversarially reviewed.
- Exact menu import from Lumit (embedded on the restaurant's own site). Lumit (lumit.se) is embedded as an iframe (
embed.lumit.se?id=<compId>) on restaurants' own domains (e.g. backagardskrogen.se) — the page URL carries no id, so it's detected from the embed in the page HTML (like GloriaFood/OrderYOYO). The whole menu is a world-readable staticdata.jsonon Lumit's S3/CloudFront (a fixed host — no SSRF, no AI), keyed by compId; items carry the restaurant's own photos when present. Verified live: Bäckagårdskrogen → 91 items / 11 categories with correct SEK prices (was 2). Auto-detects on/tryand the website-link importer, and the import-learning loop records it as platform coverage (and resolves any prior "undiscovered platform" gap for it). tsc 0, lint 0, +2 tests.
- Import learning now recognises the PLATFORM pattern — so undiscovered ordering platforms surface themselves. The hardcoded adapters (Qopla/Wolt/GloriaFood/OrderYOYO) solve the known platforms; the learning loop now learns the shape so new systems sharing the same logic get caught. Each import's site signature records the known platform (if any) and, when a site LOOKS platform-backed (an ordering SPA with an ordering widget or an external-order link) but no adapter matched and the generic ladder read only a thin menu, it logs a "platform gap" grouped by shared structural markers. The super-admin Import Learning dashboard gains two panels: Platform coverage (exact, no-AI imports per adapter) and Undiscovered platforms — adapters worth building (the gap patterns, ranked by demand). A gap auto-resolves once an adapter later serves that pattern. GraphQL alone never triggers a gap (too common), and a fully-readable site is never flagged. All capture is anonymous + best-effort (never blocks an import). 13 new tests.
- Exact menu import from OrderYOYO (Cloudflare-fronted, read via the real browser). OrderYOYO ordering sites (
{slug}.orderyoyo.{tld}) block every serverfetchat the Cloudflare/JA3 layer, but the full menu is server-rendered into the page's__NEXT_DATA__. So we read it out of the RENDERED HTML obtained via real Chromium (whose TLS gets through) — exact menu, zero AI (verified live: 86 and 151 items on two real restaurants, correct prices). On the/tryclone path the WoW capture already loads the page, so the menu is parsed from that SAME capture — no second browser. For the standalone importer a new SSRF-hardenedfetchRenderedHtmlfetches it (mirrorscaptureSiteForClone's IP-pinning). Registry-driven like the others; falls back to the ladder on any failure. tsc 0, lint 0, +6 tests (incl. malformed-payload safety).
- Exact menu import from Wolt and eatsmart/GloriaFood (auto-detected, like Qopla). The platform-API registry gains two more Nordic ordering platforms, each read deterministically from its public API — no AI, no truncation. Wolt: one unauthenticated call to the consumer assortment API keyed by the URL slug (verified live: 258 items / 17 categories with photos + prices for a real Stockholm venue). eatsmart (and any GloriaFood/Foodbooking-hosted ordering page — foodbooking.com, restaurantlogin.com, restaurants' own domains — they share one backend): a single
POST /api/cart/initkeyed by therestaurant_uidin the URL (verified live: 114 items / 14 categories). Both auto-apply to the WoW/tryclone engine and the "website link" importer; a pasted Wolt/eatsmart URL upgrades to the exact API path, and any failure falls back to the generic ladder. A sharedlib/menu-import/platform-common.tskeeps each new platform to "parse URL + fetch + map".safeFetchnow supports POST so eatsmart's user-origin request is DNS-pinned (closes a rebind SSRF flagged in review). tsc 0, lint 0, 17 tests.
Changed
- Background removal is now 10 SEK (down from 25) — flat 10 SEK across the board. Since the vendor cost is a fraction of a krona, background removal is priced the same as a generation: generate, remove-bg, batch, and buying any image from the Inspiration pool are all 10 SEK. One config change (
removeBgPriceSek25 → 10) cascades to every surface. Simpler and cheaper.
- Menu-from-photo import is now on the Import hub too. A "Menu photo or PDF" card on
/dashboard/importopens the existing full-featured photo importer (snap or upload a menu / PDF — or paste a URL — and AI reads the categories, dishes and prices, with review + photo-matching + budget-gated top-up). It's the same component the Menu editor already uses, surfaced here so every import method lives in one place. Always available (independent of the platform-importer flag); on success it drops the owner into the menu editor.
- Import hub is now one uniform page — no GloriaFood banner, and Wolt / OrderYOYO / Lumit are live (were "coming soon"). The
/dashboard/importpage dropped the big GloriaFood spotlight; every source is treated the same. A single Website / menu link card is the fast path (it auto-detects Qopla, Wolt, OrderYOYO, Lumit, GloriaFood and more), with the recognised platforms shown as chips (live first) and the file imports (CSV / Excel / WordPress) below. After yesterday's exact-import adapters shipped, Wolt, OrderYOYO and Lumit are flipped to live (thin website-link delegates registered as real providers, so their chips open a working wizard); Square / Toast / foodora stay "coming soon". The Website-link engine now also exact-parses the RENDERED page HTML from the browser capture (OrderYOYO's__NEXT_DATA__behind Cloudflare, a lazily-injected GloriaFood/Lumit widget) before ever falling back to a vision read — so the import hub gets the same exact-import quality as the/tryclone engine. tsc 0, lint 0, 18 import tests green.
Fixed
- Pool safety — a tenant's OWN photos never enter the shared pool. Only generic AI art (a pure text-to-image scene, or a cutout of one) can be banked to the system-wide Inspiration pool for other tenants to buy. Previously every generated hero — including an img2img restaging of the owner's uploaded product photo — and every cutout were auto-banked (pending admin approval), which risked a restaurant's private/brand imagery being offered to competitors (and, now that creators earn 15% on resales, being over-banked to farm royalties). Generations now carry a
genericprovenance flag (trueonly when no owner photo was used); generate-core and background removal bank to the pool only when generic, and a cutout inherits its source's provenance. Fails safe: unknown/owner-sourced ⇒ private. tsc 0, lint 0, 142 studio/media tests green.
- Batch background removal — hardening from an adversarial review (before any user hit it). (1) An all-failed batch (e.g. the vendor is out of credits) now returns the honest 503 the single path would, so the modal never shows a green "done" for cutouts that were never made. (2) The top-up shortfall is sized off the signed wallet balance, so if the wallet was already overdrawn a single top-up clears the overdraft and the batch (was: a second payment needed). (3) The pre-charge gate is dedup-aware — images that already have a cutout are a free no-op and no longer trigger a spurious "add credit" ask. (4) Duplicate URLs in one batch are de-duplicated (no double-count). (5) Images that can't be read are reported as skipped instead of silently dropped from the tally. (Two concurrent-charge TOCTOU findings were left as-is — a pre-existing, bounded, self-healing property of the lifetime-wallet across all AI paths, not specific to this feature.) tsc 0, lint 0; signed-balance + shortfall unit-verified.
- Background removal can no longer double-charge on a retry (was: 10 SEK billed twice). Background removal is deterministic (same input image → identical cutout), yet the charge had no server-side idempotency — a lost/slow response + "Try again", two tabs, or the panel + gallery buttons firing on the same image each ran end-to-end and billed again. Now the charge carries a stable
refderived from the input image and the AI-wallet choke point (appendLedgerEntry) commits a ref-tagged charge at most once (serialized under the tenant row lock — mirrors the Swish accrual dedup); ref-less charges (top-ups, per-token usage) are unaffected. An upfront lookup also returns the already-made cutout free on a repeat, saving a wasted vendor credit. Unit-verified exactly-once. Found by an adversarial multi-agent review of the feature.
- A background-removal provider rate-limit (HTTP 429) is no longer mislabeled "out of credits". 429 (rate-limited, but the account still has credits) was collapsed with 402 (genuinely out of credits), telling the operator to "top up" an account that didn't need it. 429 now maps to its own
RATE_LIMITEDstate → "busy right now, try again in a moment" (still charges nothing); only a real 402 says top up. Also found by the adversarial review.
- Platform ordering menus embedded on a restaurant's OWN website now import (were: no menu items). GloriaFood and OrderYOYO are usually embedded on the restaurant's own domain (e.g.
sukisushi.se,inter-grill.se) whose URL carries no restaurant id — so the URL-only detector missed them and the generic ladder found "info but no menu." Detection now also reads the PAGE HTML: a GloriaFood widget'sdata-glf-ruid, or the OrderYOYO app's__NEXT_DATA__/oyyservicesmarker (its parser is now host-agnostic). The registry gained an optionalmatchesHtml, the WoW/tryclone passes the captured HTML to it, and the website-link importer adds a "tier 0.5" that checks the fetched HTML. Verified live: Suki Sushi (GloriaFood) 127 items / 15 cats, Inter Grill (OrderYOYO custom domain) 111 items / 22 cats. GloriaFood also now always POSTs the fixedfoodbooking.combackend (it serves every tenant), removing the earlier user-origin SSRF surface entirely.
- Imported dishes now use the restaurant's OWN photos, not a wrong stock guess. A URL import attaches no scraped photos, so the stock/demo matcher guessed one per dish — and on a box with no live stock keys (built-in demo library only) some guesses were visibly wrong (a poké bowl getting a generic sushi photo). Qopla's API actually returns each dish's real photo (
refProduct.imageUrl, ~76% of items on the test restaurant — the owner's own licensed gallery), so the importer now carries those through as the dish image; the stock matcher only fills the remainder and leaves it blank rather than attach a mismatch. Also hardened that matcher: a stock photo now has to share a meaningful token (the protein/dish), not a generic form-word, so "Deluxe Rolls" can no longer match a cinnamon-rolls photo.ExtractedItem.imageDataUrlnow carries a usable image URL from any structured source throughnormalizeExtractedMenu. Verified: 75/99 Saigo dishes get their real photo; 13 unit tests.
- Menu import from Qopla now reads the FULL menu (was 2-3 bogus items / hallucinated dishes). Qopla is a client-rendered GraphQL SPA, so a URL import saw an empty app-shell (the vision fallback turned a near-blank page into 2-3 invented dishes) and a full-page-image import hallucinated a different restaurant entirely. Qopla's ordering backend is a public GraphQL API, so we now read the exact menu from it — deterministically, no AI, no truncation: for the "Saigo — Sushi, Thai & Poke" test link that's 99 real items across 13 categories with correct Swedish names, prices, and descriptions (was 2-3). New shared
lib/menu-import/qopla.ts(URL→publicId→getShopByPublicIdForOnlineOrder→getMenusByIds, deduping the duplicate day-menu + upsell repeats by product id) is wired into both entry points: the WoW/tryclone engine (a structured platform-API menu now supersedes every heuristic read, and the real shop name flows into the rebuild) and the onboarding importer (a liveqoplaprovider adapter + a Qopla short-circuit in the generic "website link" adapter, so a pasted Qopla URL auto-upgrades to the exact API path). Any API failure falls back to the previous ladder. Detection is registry-driven (lib/menu-import/platform-apis.ts) so teaching the importer the next platform-with-an-API (OrderYOYO, Wolt, …) is a one-line entry that auto-applies across every URL import path. tsc 0, lint 0, 6 unit tests (parse/dedupe/skip-rules/registry-detect); validated live end-to-end through the module, the registry, and the adapter.
- Menu TV — designed decks flashed to the background between screens (regression).
MenuBoardScreengated the outgoing crossfade layer onpages[prev], butpagesis empty whenever a Screen Designer deck is playing — so the intendeddeck[prev]crossfade branch was dead code and each transition faded the incoming screen in over the bare host background (a ~700ms flash to base colour, worst on photo-forward signage decks — exactly the "professional signage" surface). The layer now resolves from whichever source is active (deck ? deck[prev] : pages[prev]), restoring the smooth crossfade for decks. Verified: DOM shows both layers coexisting mid-transition + a mid-fade screenshot shows the true colour blend (was: flash to base). - Menu TV pairing screen used a retired orange glow. The 96px pairing code carried
textShadow: rgba(194,98,42,.5)— a warm/clay colour on the customer-facing in-store setup screen — violating the azure-on-navy brand rule. Now azure (rgba(21,112,239,.5)), matching the Restora logo directly above it. - Canvas editor — a shadowed headline lost its shadow while inline-editing. The
contentEditableoverlay shown on double-click didn't carry the text drop-shadow, so a shadowed headline flickered (shadow → none → shadow) the instant you started typing. The overlay now mirrors the same shadow, keeping the edit WYSIWYG. - Menu TV — stale "6 templates" copy. The plan/entitlement feature description and the page header comment said "6 templates" and led with the Android app; 10 designed templates now ship (plus the freeform Screen Designer), and the browser path works today while the native app is not yet distributed — copy corrected to match.
Changed
- Phase 57 (ES-3) — Experience Studio style tiles now show real examples. Each creative-style tile previewed only an abstract colour swatch, so owners couldn't tell what "cinematic" or "moody" actually produces. The picker now pulls one real approved example image per style from the shared library (keyed by style, image-only, deduped) and shows it on the tile — falling back to the swatch for any style without an approved example yet. Purely additive + fail-open (a missing/blocked library just shows swatches as before).
- Phase 57 (ES-0) — Experience Studio, first declutter pass for non-technical owners. The generation form asked owners to make decisions they shouldn't have to: it defaulted cuisine to a hardcoded "burger", price level to "mid", and put two hex colour pickers front-and-centre. Now: cuisine defaults to empty and is auto-detected from the photo (moved under an "Advanced" section as an editable fallback, never "burger"); price level defaults to Premium (still changeable); brand + accent colours are tucked under "Advanced" since they auto-detect from the storefront; the headline is AI-written and editable (fills in after generation instead of a hardcoded "Built to order." placeholder); the white-label toggle is removed in favour of one flat per-image price with the Restora mark; and the confusing "Save as brand kit" button is hidden for single-location owners (colours auto-apply anyway) and, for franchises only, reworded to "Use these colours for all my restaurants." Help text updated to match. Groundwork for the fuller guided-flow redesign (ES-1…6,
docs/Phase-57-Experience-Studio-Redesign.md).
Added
- Menu TV canvas — gradients on shapes & the stage background + a text drop-shadow (owner ask 2026-07-08). Bringing richer fills to the freeform designer. (Shapes) a shape can be filled with a 2-colour gradient (From / To) instead of a flat colour, in one of four preset directions — Right (→), Down (↓), ↘, ↗ — chosen from a "Solid / Gradient" toggle. (Background) the canvas stage background gets the same Solid / Gradient choice. (Text) a Shadow toggle adds a soft drop shadow behind text for legibility over busy photos. Rendered identically on web and native (Compose
Brush.linearGradient/Shadow); the two corner directions trace the element's box diagonal on both renderers (the web emits an aspect-dependent angle rather than the CSSto bottom right"magic corner", which diverges on non-square boxes — most visibly the 16:9 background), and the shadow uses the same offset/blur (0 2px 10px @55%) in both. Whole text/shape/background objects are in the board version hash, so any gradient/shadow edit re-ships within one device poll. NOT an AI feature — no credit is ever charged from the Menu TV designer. tsc 0, lint 0; web screenshot-verified (shape + background gradients in all 4 directions incl. wide/16:9 boxes, text with/without shadow) and native build clean; adversarial-reviewed (fixed the corner-angle + shadow-weight parity breaks before ship).
- Menu TV canvas — dish descriptions on a menu block (owner ask 2026-07-08). A menu block can now show each dish's description under its name (opt-in "Descriptions" toggle), like a printed menu — the small grey line, e.g. "San Marzano tomato, fior di latte, fresh basil…". Descriptions come from a per-item lookup built regardless of the device's board config, so a block shows them independently; the resolver copies items (never mutates the shared board data the auto-templates use), a shown description is in the version hash so an edit re-ships, and the interactive editor preview carries descriptions so the stage matches the TV. Web + native (Compose) render it the same (small, faded, indented under a photo). tsc 0, 17 tests; web + native screenshot-verified.
- Menu TV canvas — centred section heading with rules on both sides (owner ask 2026-07-08). A menu block's section heading can now be styled like a classic printed menu:
———— Pizze Rosse ————— centred with a rule flanking each side. The heading rule gains a position (Under the heading, or Both sides) and a new Dotted style (alongside Line / Dashed), so you can pair it with the dotted name→price leader for the printed-menu look. Renders identically on web and native (Compose); existing headings (under-rule / bar) are unchanged. tsc 0, 18 tests; web + native screenshot-verified.
- Menu TV canvas — full menu-block styling for BOTH dishes and headings + full-bleed elements (owner asks 2026-07-08). Bringing the website's Menu Appearance controls onto the canvas menu block. (Dish rows) name size, price size, row gap, bold, italic, font (Sans/Serif/Mono), how the price sits (Right-aligned / Dotted leader / Inline), and a divider between rows (None / Dotted / Dashed / Solid + colour). (Section headings) everything from before plus a "Bar" header style (a filled colour bar behind the heading + its own colour). All keep the existing Text/Prices colours. (Full bleed) an image or shape can now be sized larger than the 1920×1080 stage (up to 2×) so it covers corner-to-corner without a margin on the opposite edge — the previous cap silently shrank bleed elements to the stage size. Rendered identically on web and native (Compose); one shared
resolveMenuItem/resolveMenuHeadingclamp for feed + save; all in the version hash. tsc 0, 17 tests; web + native screenshot-verified.
- Menu TV canvas — full section-heading styling (owner ask 2026-07-08). A live menu block only let you set the section-heading colour; now each heading (e.g. "Pizze Rosse") has a full "Section heading style" panel: colour, size, bold / italic / underline, UPPERCASE, left / centre / right alignment, font (Sans / Serif / Mono), and a divider rule beneath each heading (None / Line / Dashed) with its own colour. Rendered identically on web and native (Compose
dashPathEffectfor the dashed rule); existing blocks with no heading style are unchanged (bold, 34px, left, no divider). One sharedresolveMenuHeadingclamp for the feed AND save, in the board version hash so a style change re-ships. tsc 0, lint 0; web + native screenshot-verified.
- Menu TV Screen Designer — day-part scheduling (auto lunch → dinner boards). Any screen in a deck can be limited to certain weekdays and a local-time window ("When to show": Always, or pick days + from/to). The live TV feed shows only the screens active now — evaluated in the restaurant's timezone — and switches automatically as the day-part changes (the board's version hash already covers the visible screens, so the TV re-ships within one poll at a boundary). Overnight windows (e.g. 22:00–02:00) are supported; a screen with no schedule is always on; if every screen is out-of-window the board falls back to the normal auto-built menu. The designer always shows every screen (with a clock badge on scheduled ones). New
lib/tv-menu/schedule.ts(pure:isScheduleActive/localDowMin/scheduleLabel/sanitizeSchedule), a schedule filter threaded throughbuildMenuBoardData(feed-only; the designer/preview never filters), andnowMspassed from the device-feed route. tsc 0, lint 0, 26 tests (weekday+window, overnight, filter-drops, timezone wall-clock); web screenshot-verified. Native needs no change — it just receives fewer screens.
- Menu TV canvas editor — multi-select, group move & alignment. Select several elements at once: shift-click to add/remove, drag an empty area to marquee (rubber-band) select, or Ctrl+A for all. Dragging any selected element moves the whole group together (with the group's bounding box snapping to the stage and other elements), arrow keys nudge the group, and Delete removes them all. When 2+ are selected the inspector becomes a group panel with Align (left / centre / right / top / middle / bottom), Distribute (horizontal / vertical, 3+), group opacity, and duplicate/delete. Ctrl+D / Ctrl+C / Ctrl+V now operate on the whole selection. Editor-only (no data/engine/native change — it just repositions elements); resize/rotate handles still appear for a single selection. Verified with Playwright (shift-select, align-top, group move, marquee-select-all); tsc 0, lint 0.
- Menu TV canvas editor — a menu block can now show MULTIPLE sections (owner ask 2026-07-08). A live menu element was limited to one section; it now holds several. The inspector's single dropdown became multi-select section chips, and the element renders each chosen section stacked (heading + its items, honouring prices/photos/2-columns) on both web and native. Under the hood the menu element stores
categoryIds[](the legacy singlecategoryIdis still read, so existing blocks keep working); resolved server-side to livecategories[]in menu order, ≤12 sections, deduped, in the board version hash. Verified web + native screenshots (three sections stacked, three chips) and a shaper test covering multi + legacy; tsc 0, lint 0.
- Menu TV canvas editor — pro-editor batch: undo/redo, snapping guides, rotation, keyboard shortcuts. The freeform canvas now behaves like a real design tool: undo/redo (toolbar buttons + Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y) with per-screen history that coalesces a single gesture (a slider drag, a move) into one step; alignment/snapping guides — dragging an element snaps its edges/centre to the stage edges, stage centre, and other elements' edges/centres, drawing pink guide lines (hold Alt to disable); a rotation handle (drag the knob above the selection; Shift snaps to 15°); and shortcuts Ctrl+D duplicate, Ctrl+C/Ctrl+V copy-paste an element, Esc deselect (Delete + arrow-nudge already existed). Fixed a subtle history bug in the process — the stage no longer mutates shared element objects mid-gesture (it uses a local live-override), so an undo snapshot always captures the true pre-gesture state and reliably reverts a move/resize/rotate. Web-only (the native renderer already applies rotation). Verified with Playwright (snap guide mid-drag, rotate, undo-restores-upright); tsc 0, lint 0, 16 tests.
- Menu TV Screen Designer — a freeform, PowerPoint/Mason-style canvas editor (owner ask 2026-07-08). A new "Free canvas" screen type (and a "Start from a blank canvas" option) lets non-technical owners design a signage screen from scratch: an Insert bar drops text, image, video, box/shape, and live menu-block elements onto a 1920×1080 stage; every element drags to move, resizes with 8 handles, nudges with arrow keys, and deletes with Delete; double-click a text element to edit it in place (contentEditable, commits on Enter/blur). The right inspector gives per-element properties — text (size, colour, bold/italic/align), image/video (pick asset + fill/fit + rounding), shape (fill + rounding), menu block (bound section + prices/photos/2-columns + colours) — plus opacity/transparency, bring-to-front/send-to-back, duplicate, and a canvas background (colour, photo, dark overlay). Menu blocks stay LIVE (prices/86/numbering follow the real menu). Everything is stored as
CanvasElement[]in design px and resolved server-side into the existing device feed (in the board version hash → TVs live-sync); a new native Compose renderer (CanvasScreen.kt, media3 for video) draws it 1:1 on the TV app. The editor renders through the SAMECanvasScreencomponent the TV uses, so Play shows exactly what the screen will display. Bounded/sanitised on save AND at render (≤40 elements, clamped geometry, HEX colours, safe URLs). Verified with browser screenshot QA (render / select / inline-edit / insert / freeform-drag / Play) and installed on the test tablet; tsc 0, lint 0, 16 shaper tests.
- Menu TV Screen Designer — drag-to-reorder screens. The rail's screens can now be dragged by a grip handle to reorder them (the arrow buttons stay as a keyboard/click alternative), reusing the menu page's proven
@dnd-kitsortable pattern. Selection follows the dragged screen by id, and thumbnails look up their preview by screen id so a reorder repaints instantly without waiting for the board to re-resolve. Verified with a Playwright drag test.
- Menu TV — the Screen Designer gets its own page (owner ask 2026-07-08: "dedicated designer… its own space… so we can add advanced functionality later"). The designer squeezed into the Menu TV page's left column overflowed off-screen and had no room to grow. New
/dashboard/menu-tv/designer: a three-pane studio — screens rail (live thumbnails + per-screen dwell badges) · big 16:9 canvas with a Play mode that runs the real rotation ("exactly what the TV shows") · a full inspector (screen type as visual buttons, sections, photos, structured offer editor, video, light/dark, colors, logo). Destructive actions now confirm (remove screen, restart with a pack); the canvas previews with a REPRESENTATIVE TV config (a deck-enabled TV's own numbering/prices/images settings) so what you design is what the TV renders. The Menu TV page keeps a compact entry card: style packs one-tap into the studio (?start=<pack>builds the deck on arrival), plus the per-TV "Show designed screens on" switches. All logic extracted tocomponents/dashboard/menu-tv/designer-studio.tsx. Hardened by a 14-agent adversarial review before ship: a failed deck load can no longer lead to overwriting a saved design (error state +?start=consumed via router.replace + auto-build only when the server verifiably has no deck), saves report honestly ("saving… / saved / couldn't save" incl. a no-permission message instead of a false "saves automatically"), screen ids can't collide (add→remove→add), the 24-screen server cap is enforced client-side, the dwell input commits on blur (10–39 s was untypeable with per-keystroke clamping), Play starts at the screen being edited, and finishing a design with no TV in Menu-board mode shows guidance instead of a dead end. Verified with browser screenshot QA (hero / menu-with-numbers / offer inspector / denied-save / no-TV states).
- Menu TV Screen Designer — professional digital signage for non-technical owners (owner spec 2026-07-08). Owners can now design their own signage deck instead of only picking a template: pick a starter pack (🍣 sushi / 🍔 burger / 🍕 pizzeria) and one tap builds a full deck from their REAL menu — a hero image screen, menu screens, and an offer screen. The engine (
lib/tv-menu/deck.ts) suggests screens by bin-packing WHOLE categories against the chosen template's capacity, so the screen count follows the menu and a section never splits mid-screen;sanitizeDeckclamps everything (≤24 screens, ≤4 callouts, HEX colours, safe URLs). Four new screen primitives — Image Full, Image Duo, Offer with callouts, Video (uploaded MP4 or Experience Studio video gallery ONLY — no YouTube) — built twice, web (components/tv-menu/primitives/) and native Compose (android/tvmenu, media3/ExoPlayer). Per-screen everything: layout (12 templates + 4 primitives), sections, hide-category-names, images via MediaPicker (free sources only — nothing on this page can charge AI credit), callouts, light/dark, heading/price colours (WCAG auto-contrast per screen), logo show/position (default hidden), and dwell seconds. Decks resolve server-side onto the existing device feed (ResolvedDeckScreen[], included in the board version hash → TVs live-sync on any designer edit) and each TV opts in with a per-device "Designed screens" toggle (useDeck) — classic auto-generated boards untouched. New route/api/tenant/[tenantId]/menu-tv/deck(GET / suggest / save, settings.edit-gated). tsc 0; tests green; native:tvmenubuilds and verified on the test tablet.
- FSN restaurant brand accounts — auto-provisioned, quality-gated, self-cleaning (owner spec 2026-07-08). Every restaurant now gets a Food Stories Network presence automatically, but the network stays high-quality by construction. (Auto-create)
createTenantDatabaseprovisions a brand FSN account (fsnacct_rest_<tenantId>, un-loginnable) on tenant creation — best-effort, never blocks signup. (Quality gate) the account starts provisional and is HIDDEN from every feed/search/profile (folded intohiddenAuthorIds) until it publishes a real image + text post; a text-only post never reveals it. The first qualifying post (via Compose/autopilot → the FSN adapter) clears the flag and the restaurant appears on the network. (Disable on remove)deleteTenantDBdisables the brand account (hidden immediately). (60-day cleanup) the existingfsn-account-purgecron now also erases brand accounts that are provisional-or-disabled with no activity forBRAND_INACTIVITY_DAYS(60) — empties and orphans self-clean; an active, posting brand account is never touched. Newlib/fsn/brand-account.ts(pure: provision/disable/activate/purge/postQualifies) with 5 tests; the FSN social adapter refactored onto it. tsc 0; 61 FSN/social tests green.
- Owner evening batch (2026-07-07, screenshots): ITA live + measured-holiday forecasts + worker load + Ask-AI live data + FSN connector. (F — Table Assistant) it was BUILT all along (Phase 49) behind
ita_enabledOFF; flipped ON with owner approval, created Luigi's first table (qrSlugtavolo-1), guest page serves 200. (A1) the MEASURED holiday lift now REPLACES the calendar's fixed +12%/+25% guess inside stock forecasts (per-named-holiday first, else overall; clamped ×0.3–3; signal detail says "measured from your own sales"; fixed defaults stay when nothing measured). (A2) worker load: DB-8 clock-ins × orders per LOCAL hour → orders per staffed person-hour at the heaviest slot, joined into the Plan-ahead card (≥20 staffed hours guard; honest empty state). (B — Ask AI) the owner's screenshot showed "show me low stock items" answered with a menu-management ARTICLE; new L0 live-data layer answers data questions with the restaurant's OWN numbers (low stock w/ item names → Review & reorder link, open orders, today's orders/revenue, unanswered reviews; EN+SV patterns) before any doc retrieval; new 'live' layer badge ("From your live data"). (C — Social) FSN (our own network!) is now a first-class connector — no OAuth: connecting creates a brand FSN author account per restaurant (unusable password, stable id) and publishing writes REAL FsnPosts into the real feed (engagement read live from the feed store); + fixed the owner-reported contradictory state ("Connected." under a "Not connected" card) with an honest-state guard. 7+ new tests; tsc 0. REMAINING from the batch: bilingual (EN+SV) SEO generation, campaign real-world scenarios.
- S1-H Holidays × peak hours × staffing — the owner's missing pieces, found and built (2026-07-07: "I told the system to use holidays/religious/cultural days to learn behaviour, staffing, peak hours — I don't see it"). He was right: the
date-holidayscalendar (~200 countries incl. Ramadan/Eid/Easter/Diwali) had been wired into stock forecasts since Phase 2 but only applied a FIXED guess (+12%/+25%) — its own comment admitted "future learning tunes it from history" and that learning never existed; staffing/peak-hours existed nowhere. (demand-calendar.ts) holiday impact MEASURED from the restaurant's own orders against its same-weekday non-holiday baseline (≥4 baseline days; overall claim needs ≥2 measured holidays; per-named-holiday entries honest at n=1 — "measured once, will sharpen"). (peak-hours.ts) items-per-LOCAL-hour histograms (settings.timezone IANA — a US restaurant's 6pm rush never reads as UTC midnight) → busiest 2-hour window per weekday (≥8 orders guard) +staffingOutlook: upcoming holidays (observances excluded) joined with the measured lift and that weekday's peak window into plain sentences ("Midsommarafton is in 2 days — last time it ran 80% above a normal day; busiest hours 17:00–19:00"); zero history → honest "your first X on record — we'll measure it". Surfaces:/dashboard/demandgains Plan ahead (staffing), Holidays — how they actually hit you, and Your peak hours (24h histogram + per-weekday windows); the daily briefing's measured section now leads with a time-sensitive holiday heads-up (≤4 days out); Ledger demand counter + S1 gap updated (honest remainder: measured lift doesn't replace the calendar's fixed guess inside stock forecasts yet; worker-load needs the Shifts clock-in data joined). 8 new tests (calendar verified against real Swedish holidays incl. Midsummer); tsc 0.
- DS-6 Support per-answer accountability (gap-list "continue" 2026-07-07) — plus an honest Ledger correction. Investigating the "no signal on which answers resolved the question" gap revealed it was STALE: thumbs feedback, global+per-tenant CSAT, daily trend buckets and the down-vote-rejects-pending-draft guard existed all along. What was genuinely missing: votes never landed on the SPECIFIC answer served — a repeatedly-downvoted APPROVED answer stayed approved invisibly. Now: the widget sends the served answer's
matchedIdwith each vote;SupportBrainStore.answerFeedbacktallies per answer (bounded, lowest-traffic evicted);flaggedAnswers()surfaces answers with ≥3 downs and downs>ups; the super-admin Support Brain console gains a "Flagged answers" queue (worst net-negative first, one-click "Stop serving this" reject for approved learned answers; Help-Center entries show their source for a docs edit). Learning Ledger: new "Support answers — did they actually help?" counter (CSAT + votes + flagged count) and the gap entry CORRECTED (it understated reality) — narrowed to escalation outcomes + docs follow-through. 3 new tests (support suite 11); tsc 0.
- Import density check — the "3 of 28 looked perfect" blind spot, closed at import time (gap-list item, owner "continue" 2026-07-07). A JavaScript-rendered menu whose HTML text carried only 3 of 28 dishes produced a spotless-looking import; the page itself betrays the truth — it still SHOWS ~28 price tokens. New
lib/menu-import/density.tscounts currency-MARKED prices only ("129 kr", "85:-", "€12,50" — phone numbers/years never inflate the expectation; guards: ≥8 visible prices AND under 40% extracted before it may speak, so option-price-heavy menus don't false-alarm). Wired into the universal importer's URL adapter twice: (1) a suspicious density now TRIGGERS the real-browser capture tier even when the item count passed the old ≥3 threshold — and the capture result is adopted only when it found MORE (a retry can never replace a decent text read with a worse screenshot read); (2) if the final result is still short, the import report's "Worth a look" panel says honestly: "The page shows about N prices but we only found M dishes — part of the menu may be hidden from us." Learning Ledger completeness-trend method + gap updated (remaining: photo/PDF imports have no page text to compare; density not yet recorded on the learning signal). 5 new tests; import-providers suite 23 green; tsc 0.
- VL-B Voice transcript mining — the drive-thru now learns (owner "continue" 2026-07-07). The lane transcripts recorded since DS-4 finally feed back into the product. (Mining)
lib/voice-concierge/mining.ts— template-driven: stored replies are matched back against the engine's OWN message templates (vcTemplateRegexes, every language), so the 'unresolved' reply's{text}placeholder yields the exact chunk the guest said that failed to match — no LLM, no re-parsing of raw audio text; scrub artifacts ([number]/[email]) and owner-ignored phrases are dropped. (The loop) newProduct.voiceAliases(owner-CONFIRMED only — mining never writes aliases itself) is indexed as EXTRA NAMES inbuildProductIndex, so a mapped phrase resolves throughresolveDishon every surface (lane, phone concierge, POS search) from the next request; proven by a loop-closure test ("cheese pizza" unresolvable → mapped → resolves). (Owner surface) the Voice & Concierge page gains a "Guests said this — the menu couldn't match it" card (phrase + said N× + "They meant…" dish picker + Teach it / Ignore); the list is SELF-CLEANING — each mined phrase is re-run through the current index, so mapped phrases (or dishes added since) drop out by themselves;POST /api/tenant/[id]/voice-concierge/aliases(map/ignore, capped 12 aliases/dish, 100 ignores). (Measured trends) the Learning Ledger gains two lane trends measured from the transcripts — "turns understood first try" (reply-vs-template misses) and "conversations that ended in an order" — and the voice gap narrows to what's genuinely left (lane volume; phone/web surfaces don't record transcripts yet). 6 new tests (63 voice+search suite green); tsc 0.
- S1/S2 → the daily briefing email ("Measured this month", owner go 2026-07-07). The demand and reliability intelligence no longer waits for a dashboard visit: the daily executive briefing now carries up to 3 MEASURED lines — the 2 strongest demand↔weather patterns plus the reliability root-cause line when there were outages (zero-outage days stay silent: a daily "all fine" is noise). Design honors the honesty contract end-to-end:
lib/ai/briefing/intelligence-lines.tscomputes the lines deterministically and the service attaches them to a NEWBriefing.measuredfield AFTER the AI step — the LLM never sees them, so a measured number can never come back paraphrased. Rendered as a "📊 Measured this month" section in the email (html+text) and on/dashboard/ai-briefings(with links to the two detail pages). Best-effort everywhere: any failure just means fewer lines, never a failed briefing. 4 new tests; tsc 0.
- S2 Operations Intelligence — the second flagship RIC scenario, live (2026-07-07, same day as S1). "Is this my equipment, or an area-wide network problem?" is now answered from real device history, not a guess. Builds on the already-live DS-1 reliability history (
deviceReliability: offline gaps + Wi-Fi↔cellular flips, consent-gated). New:lib/devices/network-identity.ts— best-effort ISP detection from a heartbeat's client IP (freeip-api.comlookup, 4s timeout, never blocks the heartbeat), caching only the ISP name against a MASKED ip (never the raw address) with a 30-day TTL; explicitly skips private/CGNAT ranges (10.x/172.16-31.x/192.168.x/100.64.0.0/10 — this box's own Tailscale range) rather than reporting a wrong ISP; wired fire-and-forget into both heartbeat routes (POS + Android terminal). New:lib/devices/reliability-area.ts— the anonymous cross-tenant counterpart of demand-area: per-cell gap TIME-WINDOWS (no tenant/device ids), k-anonymous (≥3 contributors), rebuilt in the same weather-cron tenant walk. New:lib/devices/reliability-insights.ts— the root-cause narrator: correlates a tenant's own outages against the area's anonymous gap windows by TIME OVERLAP (not ISP matching — simpler and works without identity) to split "N of your M outages lined up with restaurants nearby → shared network/power problem" from "isolated → your own Wi-Fi/router", plus an own-vs-area-average rate comparison (≥25% relative guard, ≥3 area gaps to compare against) and honest "still watching" lines. Surfaces: owner page `/dashboard/reliability` ("Network Reliability", sidebar NEW badge + a card on the Devices & Apps hub),GET /api/tenant/[id]/reliability-insights; Learning Ledger gains a reliability-patterns counter (contributors, cells, ISPs detected) and the Scenario-2 gap narrows to what's genuinely left (≥3 sharing restaurants per area; the ISP detection is flagged UNVERIFIED behind this deployment's own reverse-proxy chain until confirmed live). 16 new tests (dish-taxonomy/demand suite unchanged at 32 + reliability-area 5 + reliability-insights 8 + network-identity 3); tsc 0.
- S1 Demand Intelligence — the founding RIC scenario, live (owner go 2026-07-07: "the big vision"). "Pizza down, burgers up in this area — because it's sunny" is now measured, not imagined. (S1-0)
lib/intelligence/dish-taxonomy.ts— a deterministic multilingual (EN/SV/IT/TR) dish→category classifier (18 global categories, priority-ordered so "chicken burger"→burger, "kycklingsallad"→salad, "kebabpizza"→pizza; substring for Swedish compounds, word-start for short keywords; no AI, fully explainable). (S1-1)demand-rollup.ts— a tenant's orders → daily category buckets (canceled/rejected excluded, combo headers not double-counted; classifier reads product name in every language + the tenant's menu-category name, falling back to the order-line snapshot for deleted dishes). (S1-2) measured weather history: newWeatherHistoryDBapp-doc backfilled from the free Open-Meteo ARCHIVE API (real past tMax+precipitation per ~11 km cell, ≤1 fetch per cell per day, pruned at 400 days) — the existing cache only ever knew today's forecast. (S1-3)demand-insights.ts— deterministic correlation with honest significance guards (≥8 days per bucket, ≥5% share + ≥30 units per category, ≥20% relative move) voicing plain-language sentences that always carry their sample counts ("On rainy days, Pizza rises to 34% of everything you sell (vs 26% on clear days) — measured over 26 rainy and 41 clear days."); below-guard patterns surface as "still watching for", never dressed up as findings. (S1-4 area layer) the weather cron's existing tenant walk now also rebuilds an ANONYMOUS per-cell area rollup (consenting tenants only —ricLearningAccepted, fail-closed; contributor COUNT + category totals only, no tenant ids; k-anonymity ≥3 before anything shows; contribute-to-see: non-sharing restaurants see no area numbers). Surfaces: owner page `/dashboard/demand` ("Demand & Weather", sidebar NEW badge) with insights, watching-list, measured category mix, area comparison and a coverage footer;GET /api/tenant/[id]/demand-insights; Learning Ledger gains a demand-patterns counter and the Scenario-1 gap narrows to what's genuinely left (≥3 sharing restaurants per cell; feeding briefings/stock forecasts). 16/16 new tests (incl. the k-anonymity gate); tsc 0; archive API response shape verified live.
- Supplier "all three" batch (owner go 2026-07-07): crons live + profile unfrozen + approve-from-email. (1) Crons: daily-briefings (04:10 UTC) and inventory-reports (hourly, delivering at each tenant's chosen local hour) are finally scheduled on the box — the daily "N products low" emails the owner designed now actually fire for opted-in tenants. (2) Supplier profile: the write-once half is editable —
/api/supplier/[id]/profilePATCH now covers contactName/phone/address/businessType/vatNumber/fulfillmentMethods/productCategories/minimumOrderValue/leadTimeHours (registration vocabularies enforced), the page (/supplier/storefront, nav renamed "Profile") gains a Business-details section with chip pickers, and the logo finally uses the real image uploader instead of paste-a-URL. (3) Approve-from-email (the flagship): the daily inventory email now proposes a CONCRETE supplier pick per reorder line (previous supplier first, else cheapest) with a signed one-tap "Approve & order" button →GET /api/public/inventory/approve-reorderverifies the HMAC token (48h, tamper-tested), REPRICES server-side, applies the same daily spend-limit gate as the dashboard flow, places via the shared B2B order engine with payment honestly on-account/invoice (Phase-40 pending), notifies the supplier at placement (previously only fired on payment-confirm, i.e. never in prod), emails the owner a confirmation, and renders a human confirmation page; idempotent per ingredient+day ("Already ordered"). tsc 0.
- Multi-role identity + email-only verification (owner decisions 2026-07-07). (1) One login, many hats: a person can now be restaurant owner AND food supplier AND designer AND agent under one email — additive
UserRecord.roles[];GET/POST /api/auth/portalslists the portals a signed-in user genuinely holds (re-resolving each role's record and re-applying the SAME suspension checks login uses) and re-mints the single-role session cookie on switch, so every existing guard is untouched;POST /api/auth/add-roleattaches a Supplier identity to the signed-in account (same taxonomy enums as registration; email treated as verified — the session proves account control);PortalSwitcherrenders in the supplier rail only for multi-hat users. Also fixed:/supplierwas a 404 — now forwards to the portal. (2) SMS verification off until the sender paperwork is done: phone verification is REQUIRED only whenSMS_PROVIDERis configured (PHONE_VERIFICATION_REQUIRED) — in prod the OTP only ever reached the server log, so the mandatory phone step silently blocked every real partner at 'email-verified' forever; email alone now activates, the verify page hides the phone card (smsRequiredon the snapshot), andgetVerificationSnapshotself-heals stuck statuses on the next visit. Flips back automatically when SMS is configured. tsc 0.
- Phase DS — the missing data streams, closed in one pass (owner go 2026-07-07; Ledger = source of truth). DS-2:
order.status_changedfinally EMITTED (transactional outbox — same save as the status change) from the staff status PATCH + the restaurant cancel route, with acanceledmarker and no free-text reason (PII); the RIE order-lifecycle projection ingests it unchanged; Ledger gains a 30-day cancellation-rate row. DS-7: guest allergen/dietary asks counted per dish (boundeditaAllergenAsksmap, real product ids only, count+lastAt only, fire-and-forget so the safety answer never waits); the allergen-coverage list now ranks by REAL asks → popular → name and the card shows "guests asked N×" — the guest-question gap closes. DS-4 (VL-A): lane conversations record their full transcript — one session row per car (surfaceSessionId), every turnheard/replied/outcome, scrubbed (emails + digit-runs masked) and capped (40 turns × 500 chars); the lane suppressesadvanceTurn's terminal row (recordSession:false) so owner KPIs never double-count; Ledger voice row upgrades to live. DS-1: device reliability HISTORY — heartbeat transitions become bounded per-tenant records (deviceReliability: offline gaps >10 min + wifi↔cellular flips; rolling caps; consent-gated by the existing device-telemetry consent; honest limit documented: terminals ping on use, so a gap reads "unused OR offline" until clocked POS heartbeats land). DS-3 (ops, live-verified): the ADR-17 pipe to RIC is OPEN —ric_event_exportflag on,RIC_EXPORT_TOKENset, RIC worker rebooted "r360 pull ON" and stored 60 events on its first pull. Deferred with Ledger gaps: DS-5 generation keep/edit signals, DS-6 support-answer thumbs, DS-8 marketing engagement, clocked POS heartbeats + ISP identity. All slices tsc 0 + tests green.
- Allergen declaration coverage (AL slice) — the "ingredient truth ladder" made real (owner go 2026-07-07). Safety invariant unchanged: guests are answered ONLY from what THIS kitchen confirmed — never inference, never cross-restaurant statistics. What's new is making the kitchen's confirmation effortless and measurable. (1) New
Product.allergensConfirmedAt(additive) distinguishes "declared allergen-free" from "never looked at" (a bare emptyallergensarray reads as nothing-recorded in the ITA). (2)lib/menu/allergen-coverage.ts— coverage math + deterministic per-dish suggestions drawn from the dish's OWN multilingual name/description/ingredients text via the existing EN/SV/TR/AR/FA keyword map (86'd dishes excluded; popular-first ordering; 4 unit tests). (3)GET/POST /api/tenant/[id]/menu/allergen-coverage— coverage + one-tap kitchen confirmation (registry-validated ids; empty set = confirmed none; writes the human-gatedallergens+ the stamp). (4) Menu-page coverage card: "N dishes have no allergen info — guests asking about them can only be told 'ask our staff'" → expandable list, suggestion chips (tap to untick), Confirm / "None apply" per dish; goes green when the menu is fully covered. (5) Learning Ledger gains an "Allergen declarations (safety coverage)" counter (X dishes covered, % of active dishes across tenants) and the gap entry narrows to what's genuinely left: ranking nudges by REAL guest questions (not recorded per dish yet). E2E-verified on dev: Pad Thai created → suggestions gluten/eggs/peanuts/soy from its own description → one-tap confirm → covered, stamped. 13/13 menu tests; tsc 0; dark-vocabulary green.
- Learning Ledger follow-up (owner feedback on first prod look): the Ledger moved INTO the Intelligence command center (
/admin/intelligence/learning, old URL redirects) — it had been rendering its dark idiom inside the standard admin chrome and broke in Light mode; inside the always-dark IntelligenceShell (with a new "Learning Ledger" nav item) both modes are correct by construction. Added measured accuracy-over-time: first-pass import accuracy per week (share of AI-extracted items the owner kept unedited — the exact formula the learning store scores scenarios with), rendered as a bar trend with "first week X% → this week Y%", the sample count per bucket, and the method spelled out under the chart so the number is never a black box; the sameLedgerTrendshape is ready for voice/generation accuracy when those loops land. Learned-knowledge rows now lead with the plain-language lesson (rule-kind label) with the free-text note as context. Andrie_enabledwas switched ON (owner request) — the Intelligence Engine now genuinely collects platform-usage signals, and the Ledger's RIE row reads "Collecting".
- Learning Ledger + Data & Learning framework (LL-0 / DL-1 / DL-2) — the first slice of the RIC intelligence program (owner go 2026-07-07). Ledger: new super-admin page
/admin/learning(sidebar: "Learning Ledger") — the honest window into platform learning: consent coverage (owners sharing learning signals / device diagnostics, capped scan), live counters over what's genuinely collected (import-correction signals, Studio approved examples, weather area-cells, voice sessions [partial — transcripts are a declared gap], RIE platform-usage events incl. its flag-off state), the distilled learned knowledge list (import rules with plain-language lessons + how often each was used), and a permanent "Not learning yet" gap list (S1 demand, S2 ops, voice, generation, onboarding, marketing, support, RIE-off) so missing loops are visible instead of silently absent. Read-only aggregation (lib/learning/ledger.ts+GET /api/admin/learning). Framework: the legal design is tiered — anonymous/aggregated learning (outside GDPR), business telemetry (terms-disclosed legitimate interest + opt-out), personal data (real unticked opt-in; NOT covered by signup). Restaurant signup now captures an affirmative terms acceptance (checkbox, required byRegisterSchema.acceptTerms, on both/registerand the onboarding wizard) whose wording contains the anonymized-learning disclosure; registration recordsTenantSettings.termsAcceptance(v2026-07) and seeds the same `ricLearningAcceptance` record the Settings switch writes (wordingv3-signup-li, revocable in Settings → Data sharing as before). Pre-framework owners get a slim one-click dashboard invite banner (writes the same record via the existing data-sharing API; "Not now" dismisses per device; never auto-flips). Verified live on dev: register rejects without the box (friendly message), accepts with it, both records written; ledger builder returns real counts. Also fixed a pre-existing dark-mode bug the vocabulary test caught: the delivery-zone map's hardcoded pale backdrop →var(--color-card). tsc 0; vocabulary + dark-vocabulary + consent + import-learning tests green.
- Drive-thru lane: human-feel batch (owner test feedback). (1) Natural voice — the native kiosk now picks the most natural installed TTS voice per language (highest quality, exact-country preferred, offline preferred on ties) instead of the robotic engine default. (2) Patient listening — customers think out loud ("mmm… take number 3… aaah… two of number 2"): the recognizer no longer endpoints at the first pause; finalized segments are buffered and listening auto-restarts, submitting the WHOLE utterance as one order sentence only after a full quiet round, a "tap when you're done", or a ~60s cap — plus long-silence endpointing extras, and pure thinking-noises (um/uhh/hmm/mmm/aah/öh/äh, en+sv) are stripped before the text reaches the engine so they can't misread as dish words. (3) Explicit confirm → order number — the lane read-back now speaks like a human at the window and asks for the word: "So that's 1 Marinara and 2 Diavola. That'll be 49.00 SEK all together. Say \"confirm\" — or tap the green button — and I'll send your order to the kitchen." (sv equivalent with "bekräfta", now accepted by the engine's confirm matcher:
bekraftafolded, +test); a placed order speaks and shows its shortorderNumber("Your order number is 7 — please pull forward!"), whichadvanceTurnnow returns (additive) and the lane route exposes. Confirmed orders were already born-PENDINGsource:'drive-thru'→ they land in Live Orders/KDS as before. Lane-only dressing lives in the VC-8 route — the shared engine copy stays phone-neutral. 16/16 voice-concierge engine+turn tests; tsc 0;:pos:assembleDebuggreen.
- Native drive-thru voice kiosk — a third mode on the POS tablet (`android/:pos` + `:core`, no server change). The web VC-8 lane kiosk ported 1:1 into the POS app's boot picker (Register / Terminal / Drive-thru): the AI greets each car out loud (time-aware, en/sv), the customer orders by voice (native
SpeechRecognizer, push-to-talk with live partial transcript) or by tapping the always-numbered menu ("number 3" resolved client-side so the number heard can never drift from the number shown), every turn runs through the SAME deterministic tenant-guardedvoice-concierge/laneengine, replies are spoken via nativeTextToSpeech, and the running order/board, read-back → Confirm order, "Order placed — pull forward" (9s auto-advance to the next car), escalation and flag-off states all mirror the web lane. Always-dark kiosk chrome (KDS navy theme, system bars hidden),RECORD_AUDIO+ Android-11 speech-service visibility in the manifest, and the same restaurant login gate as the register. Multi-agent reviewed (4 lenses); 10 fixes applied incl.: never speak the greeting into an open mic, one-turn-at-a-time guard (a tap mid-voice-turn can't fork the conversation), full car+blockedreset on exit (the activity-scoped VM otherwise resumes the previous car on re-entry),HTTP 403-token match (a bare "403" also matched ports like:4030), honoring the on-screen partial transcript when an OEM ends a forced stop withNO_MATCH, web-parity dimmed disabled buttons (the M3 default swallowed the busy spinner on navy), ≥48dp customer touch targets, and non-clipping boot-picker cards.:pos:assembleDebuggreen. Remaining for go-live: on-device UAT (mic quality at a real lane) + the per-lane device token the VC-8 route notes for unattended lanes.
- Phase 56 (CC-9) — Swedish fiscal recording core (the sale→kassakvitto engine). Building toward wiring the fiscal engine into live sales: a pure, synchronous
recordFiscalSalethat assigns the next unbroken receipt number, signs it (sandbox control code — sync so it can run inside the row-locked order mutation), advances a small per-register counter (last number + grand total), and returns the kassakvitto + journal entry — plus an order→receipt-line mapper (single VAT rate, matching the platform's existing order VAT model; delivery + negative-discount lines so the receipt total equals the order total). Designed so the journal is DERIVED from orders (no unbounded array in any document). 5/5 unit tests. Still isolated — the point-of-sale payment-seam hook, tenant fiscal config, and dashboard are the next slices; and it stays sandbox (certified: false) until a certified control unit is connected.
- Social Media — "Connect" gave no feedback and swallowed failures. On the restaurant Social Media page, clicking Connect fired a request with no success/error handling: if it failed (or the platform's OAuth creds weren't wired), nothing happened and the owner couldn't tell why. Now each card shows a clear "Connected." or the actual error under the button, an early "no restaurant selected" case is surfaced instead of silently returning, and the accounts API returns a useful message (e.g. an adapter's "OAuth not configured") with a 400 instead of an unhandled 500. (Platforms still connect in demo mode until their real OAuth app credentials are configured.)
- Food Stories (FSN) — sign in / create your account with Microsoft & Facebook (alongside the existing Google, Apple and email). Same env-driven design as before: each provider lights up the moment its own credentials are configured and shows a "Soon" badge until then, so all four appear on the join screen. Microsoft is OIDC (idtoken, PKCE — like Google); Facebook reads email + name from the Graph API after the code exchange (it isn't OIDC). New env: `MICROSOFTOAUTHCLIENTID/SECRET
(+ optionalMICROSOFTOAUTHTENANT, defaultcommon) andFACEBOOKOAUTHCLIENT_ID/SECRET, with the callback{origin}/api/fsn/oauth/{provider}/callback`. 3/3 provider tests. (Web; the native FSN app stays email-only for now — in-app social sign-in is a follow-up.)
Fixed
- Menu TV — numbers STILL didn't show after the 3-choice control: the per-dish `showNumber:false` veto (owner report 2026-07-08, second round). Premium starters and demo packs seed every dish with
showNumber: false— a field NO dashboard UI exposes — socomputeNumbersskipped every item and an explicit "1…N" still rendered zero numbers. Now an owner's explicit boardnumbersModeignores that invisible starter baggage and numbers every dish; the legacy follow-the-storefront path still respects it, so a board mirroring the website stays byte-identical to the website. New shaper test locks both.
- Menu TV — the "Numbers" toggle silently did nothing on an un-numbered menu (owner report 2026-07-08). The board's numbers only revealed the storefront's Menu > Appearance numbering; with that set to "None" (the default), toggling Numbers on any TV template had zero effect. The toggle is now the SAME 3-choice control as the Menu page — None / 1…N / Per section — and an explicit pick numbers the board itself via a new
MenuBoardConfig.numbersMode, independent of the website menu (absent = legacy follow-the-storefront, so existing boards are unchanged). The shaper normalizesshowNumbersto what actually renders (templates and the native apps gate on it), numbering still matches the storefront ordering rules (showNumberper-dish opt-out respected, slice-stable global numbers), and the mode is in the board version hash so TVs re-sync instantly. No native app change needed — numbers were already on the wire per item. New shaper test locks override + legacy + hash behaviour.
- Food Stories — the "Import from LinkedIn" CV import wasn't discoverable. A change earlier today hid the whole CV & job-history section (which holds the LinkedIn import) behind the "I'm looking for work" toggle, so a member who hadn't switched that on couldn't see the import at all. The CV section staying tied to "looking for work" is intended; this note records that the LinkedIn import lives there and appears once the toggle is on.
- Admin "View Platform" link (and the Google OAuth redirect) pointed at localhost. The super-admin top bar's "View Platform" button was hardcoded to
http://localhost:3001, so in production it opened localhost instead of the live site — now a relative/that resolves to whatever origin the admin is served from. Separately, whenGOOGLE_OAUTH_REDIRECT_URIis unset the Google-connect redirect no longer falls back to localhost; it derives fromNEXT_PUBLIC_SITE_URL(must still match a URI registered in the Google console). The super-admin dev-credentials card (which also shows a localhost URL + demo password) was already correctly tree-shaken out of production — verified.
- Some admin pages returned "Internal Server Error" after a bad build. A production build whose static-generation worker was OOM-killed left a corrupt
.nextwith an incomplete React Client Manifest (missing Next's metadata/viewport boundary modules), which 500s authenticated pages on render (e.g./admin/dashboard/staff). A clean rebuild regenerates a complete manifest and clears it; theexperimental.cpus: 2build-worker cap already innext.configis the standing guard against the OOM that causes it. Public routes crawled clean (no 500s);/pricingand/blogreturn 404 but nothing links to them.
- Phase 56 (CC-9) — Swedish kassaregister (cash-register-law) fiscal engine, foundation. A new server-side
lib/fiscal/se/module implementing the software side of Sweden's cash-register law (Skatteförfarandelag ch. 39 + Skatteverket SKVFS 2014:9/2014:10, updated by SKVFS 2021:16/17 effective 2027-01-01). Builds a compliant kassakvitto with every mandated field (seller/org-number/address, unbroken sequential receipt number, per-rate VAT extracted from VAT-inclusive prices, payment method, control-unit serial + control code, copy/pro-forma/training marking), appends an immutable journal (journalminne), produces X- and Z-reports (net-of-returns, VAT-per-rate, per payment method, grand total), and exports the journal as SKVFS 2021:16-style XML (the 2027 requirement). A pluggable control-unit adapter (kontrollenhet — hardware "black box" or approved cloud control system) signs each sale; auto-applies when the tenant's country is Sweden. 13/13 unit tests. ⚠️ Foundation only, not yet wired to the sale path or UI, and — critically — not a legal certification: real use requires a RISE-certified control unit/cloud control system, a manufacturer declaration, registration with Skatteverket, and a tax-professional's sign-off; until a certified unit is connected the engine runs in a clearly-flagged sandbox mode (certified: falseon every artefact).
- Phase 56 (CC-6) — bookkeeping charts of accounts for the US, Canada, Denmark & Norway. The per-country chart-of-accounts registry previously only held Sweden, so every non-SE tenant's books fell back to Swedish BAS accounts. Added four national charts, wired automatically by the tenant's
countryCode(the mapping resolver already keyed off it): US models sales tax correctly — collected as a Sales Tax Payable liability, with no input-tax recovery (sales tax on purchases isn't recoverable in the US); Canada models GST/HST as recoverable Input Tax Credits (VAT-style); Denmark (25% moms) and Norway (25% / 15% food MVA) use their national standard-chart numbers, with Norway keeping a distinct 15%-food revenue/VAT account. Per-tenant overrides and a provider's imported chart still refine these. Tax rates for the US/Canada remain deliberately non-auto-computed (they vary sub-nationally — defer to a tax engine/accountant); live provider sync (QuickBooks/Xero, e-conomic/Tripletex) stays gated on credentials. 10/10 registry tests (SE drift-pins intact).
Fixed
- Delivery map & checkout followed a stale address. A restaurant's map coordinates were geocoded once and never refreshed, so after an owner changed their address the Delivery Area map kept centring on the old city (a Swedish restaurant showing Portland, OR) and — more seriously — the checkout delivery-radius gate measured customers against the wrong location and could reject genuinely in-area orders. Saving a new address now re-geocodes and updates the coordinates (remembering which address they came from), and the delivery-center endpoint self-heals coordinates that don't match the current address. Uses keyless Nominatim; appends the country as a disambiguation hint so a street-only address still lands in the right country.
- Settings — visible save confirmation on the General tab. Saving (e.g. after changing the address) now shows a "Saving… → Saved ✓ / Couldn't save" state right next to the Save button at the bottom of the form — previously the only confirmation was an easy-to-miss banner at the top of the page, so an owner scrolling at the Save button couldn't tell if their address change stuck.
- Delivery Area — free Google Maps + restaurant pin + address + Settings link. The delivery-zone editor now renders the free, no-key Google Maps embed keyed off the restaurant's saved address (reliably shows the restaurant + its pin — fixes the "map didn't pick up my address" case, which the OSM-tile map could leave blank), shows the address text, links to Settings → Address, and offers "Open in Google Maps". (Drawing editable/draggable circle/rectangle zones on the map needs the paid Maps JS API — a free embed can't host overlays — so rings are still sized from the restaurant via the controls; the checkout in/out-of-zone gate is unchanged.)
- Marketing logo — force the azure mark (cache-bust). The logo mark + "360" are already azure (
#1570ef) in code and on the box (verified: the served/landing/restora-mark.svgisfill="#1570ef"and "360" uses--clay-500= azure), but a browser can hold a stale ORANGE cached copy of the static SVG. Appended a?v=azureversion to the mark URL across the landing nav + footer + product demo so every client refetches the azure asset. (If the orange persists it's a separate deploy — e.g. restora360.com's standalone host — not the box.)
- Mason builder — double navbar in the page preview (builder-only; live was already correct). The editor stacked two navbars: the site-chrome mirror (
MasonHeaderPreview) plus, when present, a redundantnavbar/footerpage-block rendered in the canvas — and the chrome mirror rendered on every page even where the liveSiteHeaderhides it per per-page assignment. Two builder-only fixes, live untouched: (1)MasonShellnow gatesMasonHeaderPreviewbynavbarShowsOnPage(navbar, activePage), mirroring the live SiteHeader's early-return so a page with the navbar hidden shows no chrome bar in the editor either; (2)MasonCanvasskipsnavbar/footerblock nodes in its render (they're site chrome, already shown by the header + footer previews) while keeping their ids inchildrenso drag-reorder indices stay aligned. tsc + eslint clean.
- Phase 56 (CC-0 cascade) — the storefront nav + sections now fully OBEY the capability switches (both ways). CC-0 shipped hiding placed blocks when a capability was off, but three gaps surfaced in testing: (1) with Table Booking off the storefront still showed the Reservations nav link + Book button and served the
/reservationspage; (2) with Blog on the nav still hid the Blog link when the site had no posts (the old empty-blog heuristic didn't know about the switch); (3) with Newsletter on, nothing appeared because the section was never placed — the toggle only gated existing blocks, it didn't surface a missing one. Fixes:SiteHeadernow gates the Reservations + Blog nav items and the Book button by the live capability (covers legacy, redesigned, and premium templates — all use SiteHeader), with an explicit-ON override so a switched-on Blog shows even with no posts yet;/reservationsreturns 404 when booking is off; and the home page auto-injects a capability's section (newsletter / loyalty / gift-card) when it's ON but unplaced — mirroring the existing auto-promo-banner pattern — so ON surfaces the section and OFF hides it. Premium (art-directed) templates aren't auto-injected; owners add those sections in Mason and the switch still gates them. tsc + eslint clean.
Added
- Phase 56 (CC-2b) — delivery zones you DRAG on an interactive map, as circle OR rectangle. Upgrades the delivery-area editor from restaurant-centred rings to fully draggable zones on a real interactive map —
DeliveryZoneEditorMap, a dependency-free slippy map (raster OSM tiles + pointer-drag + zoom, no map library, so no npm/deploy/CSP risk). Each zone can be a circle (radius) or a rectangle (width×height), has its own centre (drag it anywhere), and a fee.DeliveryZonegainsshape/centerLat/centerLng/widthKm/heightKm; the checkout picks the smallest zone containing the customer viafeeForCustomer(rewritten pure helper, circle = haversine, rectangle = box test; test 6/6), refusing addresses outside every zone. The map also shows the restaurant pin + address + a Settings→Address link + "Open in Google Maps". (This is the free interactive map the owner asked for; Google Maps' own drawing would need a billing-enabled JS-API key, which the free embed can't do.) tsc + eslint clean.
- Phase 56 (CC-2) — geo delivery zones: draw priced rings on a map + charge by distance. A new /dashboard/delivery-zones ("Delivery Area (map)") lets the owner size concentric delivery rings on a real map — a dependency-free OpenStreetMap-tile map (
DeliveryZoneMap, no map library → no SSR/CSP/dep churn) with SVG ring overlays — and set a fee per ring. At checkout the smallest ring covering the customer's address sets the surcharge, and an address beyond every ring is refused (OUTSIDE_RADIUS), reusing the coords the order already carries (customerCoords) + the restaurant's location (auto-geocoded once via a new/delivery/centerendpoint).DeliveryZonegainsradiusKm; the public order route now picks the fee via a pure, unit-testedgeo-zonehelper (5/5), replacing the old "first zone's flat fee" bug — legacy postcode zones + the singledeliveryRadiusKmgate still work. tsc + eslint clean.
- Phase 56 (CC-4) — waiter table → seat → dish order entry. A new /dashboard/order screen (Operations → "Take Order (Waiter)") lets staff pick a table, choose the seat + course for each dish, and send it to the kitchen — so the right plate reaches the right person. It's pure UI over the already-complete POS stack: fetches
GET /pos/menu(dishes + options + tables), builds per-lineseat(0-based) +course+fireState, and submits viasubmitPosOrder→POST /pos/orders(source:'waiter',type:'dine-in', unpaid — settled later on the table bill), offline-safe through the outbox. Course 1 fires immediately; later courses ship as `hold` so the KDS fires them course-by-course for correct pacing — and KDS already renders "Seat N" + course, so it works end-to-end with no kitchen changes. Required dish options auto-default (a full per-option picker is a fast-follow). tsc + eslint clean.
- Phase 56 (CC-3 polish) — autopilot one-click easy-setup + "Enhance with AI". The Capabilities panel's Marketing-autopilot row now shows a Publishing mode control (Review each · Publish automatically) that sets the autopilot trust level in one click via its own config route — consent-first (stays on "Review each" until the owner chooses auto-publish), so "set it once → publishes on schedule" is a single click. And a new "Enhance with AI" button on the menu-item description field rewrites the copy via a focused
/api/tenant/[id]/ai/enhanceendpoint (Haiku, wallet-metered, never destructive — returns the original text if the model gives nothing), gated by the Typing-help capability. The dashboard now feeds the client the FULL live-capability set (storefront + AI), so the button only appears when Typing help is live. tsc + eslint clean.
- Phase 56 (CC-3) — the three AI features join the capability switchboard. The Capabilities panel gains an "AI & automation" group with real on/off switches for Typing help (Writing Assistant), AI content enhancement (Experience Studio) and Marketing autopilot (Social Autopilot). No parallel flags — each toggle reads/writes the feature's OWN native store via a per-capability binding: Typing help ↔
settings.writingAssistant.aiEnabled(consent-stamped like the settings route), Autopilot ↔db.autopilot.config.enabled(viamergeAutopilotConfig), and content enhancement ↔settings.capabilities['experience-studio']gated into the single sharedrunTenantExperiencecore so OFF blocks AI generation across BOTH the dashboard one-click flow and the public Experience API. Availability is binding-aware (platform Writing-Assistant flag / PAID experience-studio add-on / social-autopilot feature), so the panel shows "Get it" when a tenant isn't entitled. The storefront gate (liveCapabilitySet) is deliberately scoped to the storefront group only, so page blocks never gate an AI feature. Guard test extended (16/16, incl. "blocks never gate an AI feature"); tsc + eslint clean.
- Phase 56 (CC-1 slice B) — structured per-guest pre-orders, so the kitchen prepares ahead. The
/bookfloor flow already collected each guest's diet / drink / starter, but only flattened them into free-text onspecialRequests— invisible as data. CC-1 now also persists them structurally (Reservation.guestPreferences, newReservationGuestPreferencetype) and surfaces them in the owner Reservations dashboard as a per-person "Guest pre-orders" panel, so staff can have the right thing ready for the right person before the party arrives. Fully backward-compatible (the free-text summary is unchanged); tsc + eslint clean. This completes the two user-facing halves of Phase 56 request #8 (booker/notify in slice A + advance preferences here); consolidating the three create paths into one remains a deferred internal cleanup.
- Phase 56 (CC-1 slice A) — reservation confirmations to the booker + multi-recipient notify. Before CC-1 no booking path emailed the BOOKER at booking time (only the owner, and only on one of three create paths) — a guest was left unsure their request even landed. A new
lib/reservations/notify.tscentralises it: on CREATE → owner email + booker "received" (pending) or "confirmed" (autoConfirm) email + staff SMS; on a status change → the booker gets the update. Guests can now add extra recipients ("Also email updates to…") — stored onReservation.notifyEmails, server-sanitized (valid / deduped / capped at 5) and CC'd on the confirmation AND every time/place/party-size change, so a partner / organiser / PA stays in the loop. Wired into all three create paths (public/[id]/reservation,public/book/[slug],tenant/[id]/reservations) + the status PATCH, replacing the old single-recipient inline sends; newreservationReceivedEmailtemplate. Guest form gains an optional multi-email field. Unit test 6/6 (recipient list + sanitizer); tsc + eslint clean. i18n: the new storefront field ships English-first (fast-follow). Also delivers Phase 56 request #2 (offer-table-booking toggle) — already shipped in CC-0'sreservationscapability gate.
- Phase 56 (CC-0) — Restaurant Control Center: the owner capability switchboard. A new owner surface (
/dashboard/capabilities, sidebar "Capabilities") lets the restaurant turn features on/off, and the public storefront templates + the Mason page builder follow automatically. Architecture is reuse-first over the existing feature-gate engine and adds NO MasterDB array: a capability is LIVE only when the tenant is entitled (lib/featuresplan/grant/add-on) AND the owner hasn't switched it off (TenantSettings.capabilities, a tenant-scoped map; ABSENT key = ON, so every existing site is unchanged). Newlib/features/block-features.tsis the single, client-safe source mapping blocks→capabilities + the owner-facing capability catalog (CC-0 set: online-ordering, table booking, loyalty, gift cards, blog, newsletter);lib/features/capabilities.tsresolves entitlement×owner-switch server-side. The cascade has one choke point each side: the SSRrenderBlockhides a placed block whose capability isn't live (covers home + every data page + nested container children; fails OPEN so a DB blip never blanks a site), and the Mason palette locks the block's tile (viauseTenant().capabilityLive, fed by the dashboard layout).online-orderingkeeps its Phase-51 semantics (menu stays a browsable showcase; only the cart is gated —menu-fullis deliberately never hidden). Toggling PATCHes/api/tenant/[id]/capabilities(entitlement-guarded: you can't switch on what you haven't bought → 402 to the Add-ons store; owner-onlysettings.edit) and revalidates the tenant's pages (layoutmode) so the change is live immediately. Guard test (block-features.test.ts, 8/8) asserts every block→feature id and capability id is real +perTenantToggle, that every gated capability is owner-visible in the panel, and thatmenu-fullis never gated. Brand (azure) + dashboard dark-mode guards stay green. tsc 0 across the new files. First wave of the Phase 56 plan (docs/Phase-56-Restaurant-Control-Center.md).
Fixed
- Phase D (PD-5 batch 4a) — post-hoc adversarial review + hardening (the gate-context conversion made byte-parity at flip). A four-lens multi-agent adversarial review of batch 4a (9 candidates, 5 refuted with reasons — notably: multi-sub-per-tenant is NOT producible by any current write path, and the two-connection gate read is faithful parity, not a torn-read bug) surfaced three real parity gaps in the feature-gate conversion, all hardened. (1) `plan-definitions` had no pre-cutover backfill. 4a added it as a third
platform_configkey read byloadGateContext, but never extendedbackfill-platform-config-addons.mjs(still['ai-settings','platform-settings']), so the "REQUIRED before flip" backfill left the row empty — a custom Pricing-Studio (plan_*) or DB-overridden-plan tenant would lose EVERY plan-included feature (402s / collapsed sidebar) in the window between flipping the read flags and the first live reconcile, and re-running the backfill after a live reconcile tripped its owncount !== 2verify (false "broken cutover"). The backfill KEYS list now includesplan-definitionsin lockstep withPLATFORM_CONFIG_KEYS, with a banner making the lockstep a rule. (2) NULL-doc rows silently failed OPEN, not to the doc.pgGetGateContextfiltereddoc IS NULLsubscription rows and returnedsubs=[]without throwing, soloadGateContext(which only falls back on a thrown error) servedsub=undefined— silently stripping paid features AND flippingtenantIsReadOnlyto false (bypassingrequireWritable, the write guard on every route) for a tenant whose row simply wasn't backfilled yet (migration 013 addsdocnullable). (3) Multi-sub tenants diverged. doc-mode picks the array-FIRST sub via.find(); registry ordered bystarted_at ASC, which cannot reproduce master-array append order for a legacy/imported/hand-edited tenant with >1 row — feeding a wrong readOnly/plan decision into the write guard. Both (2) and (3) fixed at the source:pgGetGateContextnow returnsnull(→ authoritative master-doc scan, fail-SAFE) for either anomaly, so single-sub populated-doc tenants (100% of code-produced data) serve from the registry while the rare/transitional cases defer to the doc — no new migration, no hot-path reconcile change (a lighter, lower-risk fix than an ordinal column for a data-only edge). The subscriptions backfill self-check now also asserts zero NULL docs so a partial state can't read green. Proven on scratch Postgres 16 (001→013): a new gate-context-guards test drives BOTH modes — a multi-sub tenant'sreadOnlymatches the doc (registry deferred, did NOT pick thestarted_at-min stale sub), a NULL-doc row returnsnull, and theplan-definitionsmirror is read-parity — all six suite tests green, plus the sibling registry + plans suites unchanged. tsc 0, eslint 0.
- Cross-tenant custom-domain takeover via case/port variants + PD-3 review hardening. The adversarial review of the PD-3 cut-over surfaced a pre-existing tenant-isolation bug: the owner-facing custom-domain PATCH (
/api/tenant/[id]/domain) checked conflicts with strict===, so claimingPizza.SEpassed the uniqueness check while another tenant heldpizza.se— and the two normalize onto the SAMEdomainsrow in registry mode (last-write takeover). The route now canonicalizes (lowercase + port-strip,''→null) BEFORE both the conflict check and storage, compares case-insensitively against existing docs, and runs the check+write atomically undermutateMasterDB(two concurrent claims serialize instead of both passing the read-then-write race). Two hardening fixes from the same review: (1) the/api/domainson-mode fallback now catches its own failure (both stores down → clean 503 the proxy treats as "no tenant" for its 60s cache, instead of an unhandled 500); (2)reconcileTenantRegistrydefensively dedupes case-variant subdomains/custom domains — bad doc data now degrades to a loudly-logged row skip instead of a unique-index violation aborting the ENTIRE master-save transaction (the mirror must never brick platform writes). Proven on scratch PG16: 11/11 including the new bad-data test (save survives, first claimant in doc order wins, both warn lines observed). tsc 0, eslint 0.
- Onboarding wizard — exhaustive 1:1 pass against the handoff (every state, desktop + mobile). Drove both the design mockup and the live wizard through all five states — step-1 logo empty + filled, step-2 hours, provisioning, activated — at 1440px and 390px, and ran a 9-pair vision comparison. Real deviations fixed: (1) the left-rail brand lockup was hand-reconstructed (an orange app-icon nested inside a cream square + a clay-coloured "360"), reading as a doubled square — replaced with the handoff's actual
restora-wordmark-light.svgasset (white rounded square + orange mark directly on it + gold#ecc049"360" with correct spacing), a pixel-exact lockup; (2) the rail watermark now matches the handoff's placement (right/bottom, 248px, 5%); (3) the provisioning spinner span span the Restora brand mark instead of a generic storefront glyph (the handoff'srestora-mark.svgat 42px); (4) the step-2 opening-hours rows overflowed the viewport on mobile (fixed 100px day label + two 104px time selects summed past 390px, clipping the closing-time select so it couldn't be tapped) — the two time selects now wrap to a full-width second line below the day + toggle at ≤560px, both fully usable; (5) a modest top-padding rebalance so the form isn't top-heavy after the (intentionally removed) sign-in header row. Verified live after the fixes (desktop lockup + provisioning mark + mobile hours all correct). Confirmed as correct-not-bugs and left unchanged: the post-register copy ("Let's finish setting up…", "a couple of quick steps"), the omitted "Sign in" link and step-1 "Back" button (account is pre-done), the extra "Finish setting up" card + "AI during trial" row + 149 kr pricing (intentional live enhancements), and the adapted provisioning task labels. tsc 0, eslint 0, 4,163 tests green.
Added
- Nordic Table — a new light Scandinavian premium website template (7 colour combinations + demo images), built 1:1 into the one Mason/premium system. Reproduced from the design handoff as bespoke, fully-editable Mason blocks — NOT a parallel engine. Five bespoke sections (
nordic-herocentred airy hero + dot-grid + wide image card;nordic-principles3 cards;nordic-seasonalimage+tag-chips split;nordic-fikaimage+copy split;nordic-visitinfo-list + booking-CTA panel) render live via the shared registry path and inline-edit in the canvas (isPreview-gateddata-mason-fieldmarkers — the marker-invariant guard covers all five). Colours flow from a sharednordic-shared.tsthat maps the source's 5 base tokens +color-mixderivations onto the platform 9-token contract, so any of the 7 palettes (Warm Stone · Cotton Candy · Lavender Dream · Buttercream · Powder Blue · Pistachio Cream · Peach Sorbet) re-skins the whole site; fonts are Syne (display) + Manrope (body). Ships as a code-template (nordic-table.ts, 7 palettes → 9-token themes with WCAG-luminanceaccentInk) + a functional starter (nordic-table-starter.ts— home = the 5 sections, menu = the real orderablemenu-fullas a text-only 2-column card, reservations = the real booking form, 11 seeded SEK dishes, high-quality Unsplash demo images), registered in the premium registry + catalog (Premium badge). Applying it yields a complete, orderable, bookable Scandinavian site editable in Mason. Verified: headless 1:1 render at desktop + mobile vs the reference, catalog adaptation (7 variants, correct card DTO), 577 template/mason/section/theme tests green (block counts bumped 99→104 / 82→87 static), tsc 0 for all new files.
- Phase D (PD-5 batch 4a) — the feature-gate family leaves the master doc.
loadGateContext(subscription + feature grants + plan definition) backstenantHasFeature,tenantNavFeatureSet,tenantIsReadOnlyandrequireWritable— the last of which runs on EVERY tenant write route — and each call parsed the whole master doc. Migration013converts it with the zero-drift orders-mirror pattern:subscriptions_registrygains adoc jsonbcolumn carrying the FULL SubscriptionRecord (the content hash now covers the whole doc — any field change rewrites the row, the right trade for small low-churn subs, and the gate can never miss a field a projection forgot — proven by the trial-enginereadOnlyflag round-tripping), a newfeature_grants_registrymirrors FULL FeatureGrant docs tenant-indexed, and the Pricing-StudioplanDefinitionsarray becomes a thirdplatform_configkey. WithSUBSCRIPTIONS_REGISTRY_READ+FEATURE_GRANTS_READ+PLATFORM_CONFIG_READon, the whole gate context is three indexed reads; errors fall back to the doc scan; sub selection orders bystarted_atASC as the append-order proxy (identical for the normal one-sub-per-tenant case). Backfill extended in lockstep (db:backfill:subscriptions-registrynow fills the doc column + grants). Proven on scratch Postgres 16 (001→013): the REAL gate consumers in registry AND doc modes with parity asserted, revoked-grant delete propagation, and all suites green (12/12 + 12/12 + 5/5 + 4/4). tsc 0, eslint 0.
- Phase D (PD-5 batch 3) — users projection widened; `requireLocalizationManage` leaves the master doc. Migration
012addsadmin_role,assigned_countries(jsonb) andlanguagetousers_registry— the hash formula is bumped in lockstep in the reconciler AND the backfill, so every user row self-heals exactly once on the next reconcile with no manual step.requireLocalizationManage(the GL-9 country-manager write gate) now answers from one PK read: a HIT is trustworthy under the current-or-absent invariant, a MISS or error falls back to the doc scan.languageis projected ahead of need for the dashboard-layout conversion (next batch, which still requires plan-definition + featureGrants mirrors). Proven on scratch Postgres 16 (001→012): projection round-trip including the jsonb country array + a 6-casecanManageCountryparity block in the matrix suite; 12/12 + 12/12 + 4/4 regressions. Deploy note honored on the box: migrate BEFORE restart — the new reconciler writing to an unmigrated schema would drop user rows (never-brick) and fail-close logins. tsc 0, eslint 0.
- Phase D (PD-5 batch 2) — the AI-call path and the public site gate leave the master doc. Three of the highest-frequency remaining whole-doc parses converted, behind new expand mirrors (migration
011): (1)platform_config— theaiSettingsandplatformSettingssingletons (~1KB each) mirrored out of the doc with hash-skip; an absent doc field DELETES the row, so a MISS always means "not configured", never stale — consumed bygetAISettings()(one PK read) and/api/public/site-status(the proxy's 15s-cached site-gate poll); (2)addon_subscriptions_registry— the projectionresolveTrialHoldneeds on EVERY AI budget check for a never-paid trial tenant, now two indexed EXISTS checks instead of a full doc parse, with a deliberate, documented semantic tightening: "never paid" asks whether ANY subscription has a paid status (order-independent, intent-true) instead of whichever sub the doc's.find()happened to hit first — the only divergence direction releases the hold, matching the function's own fail-open contract. All flag-gated (PLATFORM_CONFIG_TABLE/_READ,ADDON_SUBSCRIPTIONS_TABLE/_READ,SUBSCRIPTIONS_REGISTRY_READ, default OFF = byte-identical), wired into the one-transaction control-plane reconcile + the hourly self-heal cron, errors fail open to the doc scan. Proven on scratch Postgres 16 (001→011): 4/4 suite driving the REAL consumers — the site-status route handler,getAISettings, andresolveTrialHoldacross a 5-case billing matrix in BOTH modes with parity asserted — plus config hash-skip/delete-propagation and 12/12 + 12/12 + 4/4 regressions. Review follow-up: self-verifyingdb:backfill:platform-configadded (named in the assertReady guards) and the REQUIRED flip ordering documented —_TABLEflags + restart → backfill (verify counts) → only then the_READflags; a MISS means "not configured" only after the first reconcile. tsc 0, eslint 0.
- Phase D (PD-5 batch 1) — the per-request auth lookups leave the master doc.
requireTenantAccessloaded and scanned the whole master doc on every tenant-staff API call (the Phase 38 revocation +sessionEpochcheck — KDS/POS terminals poll this constantly) and on every group-manager cross-tenant call. With the registry read flags on (USERS_REGISTRY_READ=1,TENANT_REGISTRY_READ=1— live on the box since the PD-3b cut-over), both branches now answer from indexed PK lookups —usersRegistryRepo.findByIdfor the staff check, plustenantRegistryRepo.findByIdin parallel for the group-manager org check — with the original doc scan kept as the fail-open fallback on registry errors and as the byte-identical flag-off path. New seam surface:pgFindUserById,RegistryTenant+pgFindTenantById, andfindByIdon both repos. Predicate parity proven on scratch Postgres 16 across an 11-case matrix (valid staff / stale epoch / wrong staffId / wrong tenant / revoked login; manager reaching every org branch / non-org tenant / no-groupRole user / unknown user) including instant revocation propagation — an epoch bump through the realmutateMasterDBpath kills the old session on the very next check. A 5-agent adversarial security review then confirmed the one deep risk — never-brick skip rows freeze stale values, and this batch made stale rows answer authorization (a frozensessionEpochwould survive revocation; a staleorganizationIdcould keep granting org access) — hardened before the box saw traffic on it: (1)users_registrynow upholds a current-or-absent invariant — rows the reconciler can no longer keep current (malformed, duplicate email, unresolvable swap) are dropped in the same transaction, and dropping a swap party's row frees its email so the other party's retry converges (proven: one drop + one converge, then full self-heal on the next clean save); (2) the staff branch treats a registry MISS as "fall back to the doc" (ambiguous: revoked or dropped — the doc answers both correctly) while a HIT stays trustworthy; (3) the group-manager branch is deny-fast, confirm-grant: the registry short-circuits the common denial, but an org-wide GRANT is re-verified against the doc before being honored (grants are rare; the doc load lands only where it did pre-PD-5); (4) a newregistry-self-healplatform cron (hourly, flag-gated,FOR UPDATEreconcile, hash-skip makes clean passes ~free) backstops swallowed mutate-path reconciles, whose log level rose to error. New matrix case proves staleness can never grant: a demote-under-duplicate leaves the row absent, never stale. 12/12 + 12/12 + 4/4 suites. tsc 0, eslint 0.
- Phase 55 (ML-2…ML-6 + guards) — the multilingual platform lands end-to-end. Continuing the two ML-0/ML-1 waves: imports prompt, never spend silently — every provider/wizard import stamps an honest translation offer (targets + pending fields + kr estimate, pure math) onto its report, rendered in the wizard (tenant-language copy), mentioned in the completion email, and one click fires the bulk engine; new tenants’ languages seed from their signup country; WoW claims persist the preview translations the prospect already paid for. Writers stopped lying about language — imports write ONE slot keyed by the DETECTED menu language (engine understands sourceLang; the tenant default became a valid machine-fill target, so a Swedish restaurant importing an English menu gets real Swedish); every editor writes the default slot and spreads the record (the product editor used to WIPE translations on every save; the SEO editor dropped locales; the Mason builder’s round-trip DESTROYED manual translations — all fixed, regression-tested). Surfaces expanded — option groups/option names, combos, blog titles/excerpts joined the translate registry + manager. The guest funnel speaks 21 languages, free — 216-key dictionary rebuilt from live strings (en source of truth, sv hand-authored, 19 generated by a native-translator agent workflow, completeness + placeholder parity validated; 199 KB committed, zero runtime AI); cart→checkout→Swish/Stripe/PayPal→confirmation→reservations→customizer wired via a layout-provided language context (SSR-safe); html lang/dir per tenant (Arabic flips RTL), Accept-Language first-visit detection, hreflang on every page, storefront chrome ×21. The dashboard nav speaks 21 languages — per-user language (profile field + PATCH /api/auth/me + header picker), all 115 sidebar labels localized at render (English labels stay stable ids), Quick-Links + search included, tenant-default fallback. Guard — a static-scan test bans new {en:X, sv:X} duplication (it immediately caught the last un-swept option-group writer, and surfaced + repaired 3 NUL-corrupted separator bytes a scripted edit had introduced in commit.ts). Verified throughout on live dev: Swedish checkout (Kassa/Din varukorg är tom), German dashboard (Übersicht/Speisekarte & Combos), dir=rtl for Arabic; tsc 0; all i18n+importer suites green.
- Phase 55 (ML-0 + ML-1) — Fully Multilingual Platform: the canonical language registry + owner-confirmed bulk content translation. First two waves of the multilingual program (owner decisions locked 2026-07-04: 21 languages = the marketing-pages 18 ∪ {es, tr} ∪ Arabic; platform CHROME will ship built-in free in every language; tenant CONTENT translation is paid from AI credit behind an estimate→confirm). ML-0: ONE registry (
lib/i18n/languages.ts) replaces five divergent language lists — the AI translator's 6-language ceiling is gone (enabling Finnish/Italian/Danish… previously silently translated NOTHING),TRANSLATE_LANGS/LANGUAGE_META/the tenant Languages tab/the Translations-page names all derive from it, and the WoW preview switcher offers all 21 (six pills + a "More…" dropdown). ML-1: the auto-translate engine gained a sharedrunTranslationPasscore (now CHUNKED — a 200-dish menu can't blow the model's output ceiling; the on-save path is behaviorally unchanged: cap-enforced, cap-counted) and a new bulk module (lib/i18n/bulk-translate.ts) covering every content surface (menu, categories, restaurant texts, Mason blocks):GET/POST /api/tenant/[tenantId]/translations/bulkreturns an HONEST estimate (only fields that would actually be translated — owner-edited translations stay locked; priced per language at the real model rate + FX/markup, hold-aware wallet remaining) and runs only on explicit confirm (402AI_BUDGET_EXCEEDEDwhen credit can't cover it; confirmed runs bypass the small on-save monthly cap by design and are excluded from it)./dashboard/translationsgained the per-language "Translate everything into …" estimate→confirm panel. Verified on live dev: demo-import tenant with de+it enabled → estimate 50 fields / 0.08 kr (hold-aware wallet remaining), confirm panel renders, and the engine fail-safes to a zero-cost no-op without an AI key (this box has none — the model call itself is the same prod-proven WoW-preview translator). 23/23 i18n tests, tsc 0. Remaining waves (import hooks, write-path correctness, chrome+dashboard dictionaries, RTL) tracked in the Phase 55 plan.
- Phase D (PD-4c) — the audit sink moves onto the append-only `audit_logs` table. Every audit event — including every failed login, which audits unauthenticated — used to rewrite the ENTIRE master doc (
master.auditLogspush + full-doc save). BehindAUDIT_LOGS_TABLE(default OFF = byte-identical behavior), an audit event is now a plain INSERT into the partitionedaudit_logstable (migration 007 + new 010 fordoc_id+ action indexes) and the master doc is not touched at all. One seam inlib/audit/log.tscarries both modes: writers (writeAuditLog, andappendAuditEntryfor call sites inside an existing master mutation) and readers (queryAuditLogspaged+filtered,listRecentAuditLogs,countAuditLogs,listTenantAuditLogs,hasAuditAction) — all 12 inlinemaster.auditLogs.pushsites across 11 admin routes and all 6 readers converted (/api/admin/logs, monitoring counts, overview recent-activity, the tax-audit trail, guardian's security window, and the addon-billing cron whose reminder dedup now needs NO master write in table mode). Idempotent backfilldb:backfill:audit-logscopies doc history in viadoc_id WHERE NOT EXISTS(proven +2 then +0 on re-run). A 5-agent adversarial review confirmed three real gaps, all fixed before landing: (1+2) the backfill'sdoc_ididempotency broke on legacy cron entries whose ids were minted asaudit_${Date.now()}— two same-millisecond reminders share ONE id, silently dropping history and hard-failing the reconciliation, while the post-flip re-run reconciled against ALLdoc_id IS NOT NULLrows (table-born rows also carry docids → false pass). The backfill now re-mints unique docids for duplicates (no history dropped) and reconciles against the exact attempted id set (proven: a seeded duplicate-id pair lands as..._dup1, both runs reconcile, re-run +0), and the cron's doc-mode id is now collision-free; (3) guardian's audit fetch was awaited outside its security try/catch, so a transient PG error in table mode would have broken the whole snapshot instead of degrading to seed — moved inside. Plus a NaN-page guard on/admin/logs(garbage?page=would have becomeOFFSET NaN→ 500 in table mode). Proven on scratch Postgres 16 (migrations 001→010): 4/4 audit suite — including the headline proof thatwriteAuditLogin table mode leaves the master doc'supdated_atuntouched, LIKE-wildcard escaping in the admin filter, and doc-mode parity with the flag off — plus the duplicate-id backfill proof and 12/12 tenant + 11/11 users/subs regressions. tsc 0, eslint 0.
- GloriaFood `demo` — professional studio dish photos + premium-template dress-up. The funnel's try-it restaurant (Demo Trattoria, mock adapter) now shows the finished product instead of a photo-less default site. (1) Photos: all 8 demo dishes carry professional studio photography — the two existing renders (margherita, pepperoni→Diavola) plus six new Imagen/Gemini renders in the same overhead wood-and-linen editorial style (quattro formaggi, carbonara, lasagne al forno, penne arrabbiata, unlabeled sparkling water, house lemonade), committed as first-party WebP under
public/demo/gloriafood/with a documented generator (scripts/demo/gloriafood-demo-images.mjs— every render passes a Gemini vision gate, and the retry re-verifies; a human visual check caught the one garbage render the first pass produced). The image pipeline gained a first-party branch: a root-relativeProviderImageRef.urlis read frompublic/(path-confined) instead of fetched, then localized per-tenant exactly like a provider-CDN photo — so the demo exercises the REAL photo path (sharp → WebP → dedup →/uploads/<tenant>/) with zero network. (2) Premium template: the demo snapshot is nowisDemo-flagged, and the register claim path dresses the fresh tenant in theosteria-lumepremium template (theme-only install, Terracotta variant — keeps the imported menu, which the template's dynamic menu page renders); fail-open and demo-only, live imports are untouched. (3) Funnel preview:/api/public/import/gloriafoodreturnssampleImages(first-party URLs only — provider CDN links are never hotlinked pre-auth) and the preview card leads with the 8-photo strip; the count tiles now show "8 Photos". Self-QA'd end-to-end on live dev: demo preview → register with token → 8/8 products with localized/uploads/photos,tenantSite=osteria-lume/terracotta, storefront + menu page + funnel screenshotted and visually verified (desktop + 320px); QA tenant fully removed, importer flag restored OFF in dev. tsc 0, importer tests 18/18.
- Phase D (PD-4b) — the login read cut-over: `/api/auth/login` staged onto the users registry. The user lookup — today a whole-master-doc load + linear scan on EVERY login attempt — is staged behind
USERS_REGISTRY_READvia the newUsersRegistryReposeam: unset → the original doc scan, unchanged;shadow→ the doc authenticates while the registry is compared and any disagreement logsSHADOW DIVERGENCE;1→ the indexed registry (functional index onlower(email)) answers the lookup, so a failed login skips the per-attempt doc parse + O(n) scan for the lookup. Security posture: the registry is only the index — on a hit + password match the master doc is loaded (the route needs it anyway for the 2FA secret and tenant resolution) and RE-verified before any session is minted; registry-shaped errors fail open to the doc scan; reads require the dual-write flag. A 4-agent adversarial security review confirmed two real gaps, both fixed before landing: (1) the "failed logins never touch the doc" framing was false —writeAuditLog(fired on every failed attempt, unauthenticated) did agetMasterDB→saveMasterDBread-modify-write, i.e. a full doc parse + last-writer-wins rewrite per attempt that a flood could use to clobber concurrent legitimate master mutations; it now runs undermutateMasterDB(row-locked, serialized), the route/repo comments state the honest scope, and PD-4c (audit sink → the append-onlyaudit_logstable from migration 007, which is what actually makes per-attempt audits cheap) is queued; (2) the fail-closed guard compared password but not email, so a stale registry email→id mapping could authenticate an email the doc no longer holds — the doc re-verify now requires password AND email to agree. Proven through the REAL login handler on scratch Postgres 16: all three modes, a source-of-truth proof (registry row deleted → 401 despite valid doc credentials; reconcile restores), fail-closed stale-hash AND stale-email proofs with repair (bothLOGIN DIVERGENCEerrors observed firing), case-insensitive email — 11/11 suite, 12/12 tenant regression. Scope decision recorded in TODO/design doc:SUBSCRIPTIONS_REGISTRY_READis deferred to PD-5 because every subscription read site already holds the master doc — a lone canary there removes zero doc loads. tsc 0, eslint 0.
- Phase D (PD-4) — users + subscriptions registries: the control-plane expand goes multi-entity.
master.users[]andmaster.subscriptions[]now dual-write into the indexedusers_registry/subscriptions_registrytables (migration 007) under their own flags (USERS_REGISTRY_TABLE/SUBSCRIPTIONS_REGISTRY_TABLE, default OFF = inert), following the proven tenant-registry pattern with every hardening built in from day one: per-rowcontent_hashskip (migration 009 — a master write that touched one user upserts one row, not all N; doc-only fields like the 2FA secret, password token and trial-engine ledger are NOT projected so they never churn rows), never-brick guards (malformed rows and case-duplicate emails — the registry enforceslower(email)uniqueness — skip with a loud warn instead of aborting the master save; garbage timestamps degrade to NULL viatsOrNull, also applied to the tenant reconcile), and delete-propagation. The write seam is generalized:saveMasterDB/mutateMasterDBnow gate onanyControlPlaneRegistryEnabled()andwriteMasterWithRegistryreconciles every ENABLED entity inside the one transaction — per-entity flag independence proven (users flag on + tenant flag off leavestenant_registryuntouched). New indexed reads for the future cut-overs:pgFindUserByEmail(case-insensitive via the functional index; auth-core projection incl.sessionEpochfor the Phase 38 session-kill check) andpgListSubscriptionsByTenant. Self-verifying backfillsdb:backfill:users-registry+db:backfill:subscriptions-registrycompute the identical hashes (proven: a round-trip save after backfill re-writes 0 rows). A 5-agent adversarial review (2 lenses → refute-by-default verification) confirmed three deeper failure classes, all fixed before landing: (1) cross-row unique violations (an email/subdomain swap or reassignment across two rows in one save) could still abort the whole master-save transaction despite the batch dedupe — all three reconcilers now run delete-removed FIRST (a vacated value is free before upserts), wrap every row upsert in its own savepoint (a collision skips that row loudly, never the save), and give first-pass collisions one retry (reassignment orderings converge; a pure A↔B swap stays skipped-with-warn while the doc still commits); (2) the mutate-path reconcile had a stale-read race (doc read unlocked outside its txn could overwrite a fresher concurrent write's registry rows) — it now rides INSIDE the row-locked doc transaction viamutateDoc's existingonPersisthook, savepoint-guarded so a registry failure never fails the doc mutation, and the standalone repair tool reads the docFOR UPDATE; (3)tsOrNullnow emits the canonical ISO (JS accepts strings Postgres rejects) andpgListSubscriptionsByTenantreturns ISO-8601, not Postgres text format. Proven on scratch Postgres 16 (migrations 001→009): 7/7 users/subs suite (incl. delete+re-add, within-save reassignment resolved by the retry pass, and the pure-swap degradation with both warn lines observed) + 12/12 tenant-suite regression (incl. a subdomain-reassignment case) + backfill↔dual-write hash parity for both entities with the normalized timestamps. tsc 0, eslint 0.
- Phase D (PD-3) — the first read cut-over: `/api/domains` staged onto the tenant registry. The platform's hottest read (the proxy resolves EVERY request's host through this route, 60s per-host cache) can now be served from the indexed
tenant_registry/domainstables instead of loading and scanning the whole master doc, staged behindTENANT_REGISTRY_READ: unset → the original doc scan, unchanged;shadow→ the doc still serves every request while the registry is read alongside and any disagreement is logged asSHADOW DIVERGENCE(the zero-divergence soak);1→ the registry serves through theTenantRegistryReposeam — no master-doc load at all — with the doc scan kept as a fail-open fallback on a thrown registry error. Parity with the doc scan was made exact: the registry's subdomain lookup is gated on the sharedSUBDOMAIN_BASE_HOSTSallowlist (sopizzahut.evil.comdoes NOT resolve — the doc scan never matched foreign base hosts), subdomains are stored lowercased to match the scan's case-insensitive compare, and the{tenantId, subdomain, redirects}response contract is unchanged. Proven through the REAL route handler on scratch Postgres 16 (migrations 001→008): 9/9 integration tests covering all three modes with a source-of-truth proof per mode (registry emptied → doc still serves in off/shadow; registry row deleted → 404 in on-mode while the doc still holds the tenant), the response contract, base-host + mixed-case parity, and the shadow divergence warning observed firing live. Also captureddocs/architecture/launch-cost-ladder.md— the web-verified launch-cost analysis (Stage A = Cloud SQL ~$57/mo with PITR; every migration confirmed plain PG16 SQL so stage-up to the unchanged AlloyDB end-state is a DATABASE_URL flip). tsc 0, eslint 0.
- Phase 54 — Trial Cost Shield: paid-gated Experience Studio, staged AI credit, 30-day GloriaFood trial. Three cost-protection changes for never-paid trials, owner-decided 2026-07-04. (1) Staged AI credit: the 100 kr lifetime grant is now split for NEW signups — 20 kr usable during the trial (keeps AI menu photo import + the assistant alive), the remaining 80 kr unlocks automatically on the first payment. Implemented as a
trialHoldUsdstamped once with the grant (same immutability) whose application is derived at read time from billing state (lib/ai/trial-hold.ts) — any payment (Stripe webhook, DEV checkout, an add-on purchase) releases it with zero unlock hooks and zero wallet writes. Enforced at the singlegetBudgetStatus402 choke every AI route already uses; top-ups are never held; existing wallets are grandfathered (no stamped hold → nothing changes). Both amounts are super-admin knobs (Admin → AI: grant + "usable during trial"). All copy surfaces updated honestly (register note now dynamic fromsignup-plan, both onboarding wizards, billing banner, ai-spend hero with an amber "unlocks on your first payment" pill, hold-aware 402 messages). (2) Experience Studio paid gate: during a never-paid trial the Studio stays visible — same layout, interactive style grid, a read-only "examples from the shared library" panel, prices on every (locked) button — but all six spend paths (generate, video, video-export, pool import, overage purchase, and the public API-key path) return 402STUDIO_REQUIRES_PAIDuntil real entitlement exists. "Paid" is a new pure rule (paidFeatureGrantedin gate.ts): resolved without the trial-all-access shortcut AND plan-included features count only once the plan itself is paid (Online Starter deliberately includes the Studio per OP-14 — a trialing subscription is previewing that plan, not owning it); explicit add-on grants count immediately. The features route exposespaidGrantedso the page's demo mode can never disagree with the server gate. (3) First-use billing acknowledgment: before the FIRST paid generation the owner accepts a versioned disclosure ("generations are paid from your AI credit — images from X kr, videos Y kr; delivered results are charged, true non-deliveries auto-refund"), server-enforced as 428STUDIO_ACK_REQUIREDon generate/video-start/pool-import (tax-consent pattern clone; public API-key path exempt by design). (4) GloriaFood 30-day trial: signups carrying a live staged-import token getplatformSettings.trial.importFunnelDays(default 30, admin-editable) instead of the 7-day plan trial, and the cohort is frozen as a newgloriafoodacquisition channel (ranked above UTM, below partner attribution) for conversion tracking. Verified end-to-end on live dev: fresh trial stamps the 80 kr hold and 402s at exactly 20 kr spent; all six Studio endpoints 402 in trial while reads stay 200; flipping to active unlocks +80 kr and the Studio with no writes; first generate 428 → ack → real generation charged 10 SEK to the ledger; GloriaFood-funnel register → 30-day trial +gloriafoodchannel + menu imported + hold stamped; garbage token → 7 days,direct, signup unbroken. tsc 0; wallet/channels/importer/theme-vocabulary suites 38/38 green; QA tenants and knobs fully cleaned.
- Phase D (enterprise-scale) — first vertical slice: the tenant registry, retiring the master JSON doc. The platform control plane lives in one JSONB row (
app_documents/'master', aMasterDBwithtenants[]+ ~35 arrays); at ~14.4 KB/tenant it OOMs Node's heap around ~40k tenants and hits Postgres's 1 GB single-field limit around ~70k, and every hostname resolution does a whole-doc load + linearmaster.tenants.find(). This slice moves the hottest read off the doc onto indexed relational tables without touching the ~367 doc readers, using the exact expand-phase pattern the orders/stock mirrors already ship: flag-gated, reconcile-in-the-write-transaction, doc stays source of truth, fully reversible. Shipped: migration008_tenant_registry_content_hash.sql(addscontent_hash);reconcileTenantRegistry/writeMasterWithRegistry/reconcileRegistryFromMasterDoc/pgResolveTenantByHostinpg.ts, wired intosaveMasterDB/mutateMasterDBunderTENANT_REGISTRY_TABLE=1(default OFF = fully inert); anassertReady()fail-loud guard (flag on but table missing); theTenantRegistryReporead seam (src/lib/db/repos/tenant-registry.ts,findByHostonly, behindTENANT_REGISTRY_READ, not yet wired into/api/domains); anddb:backfill:tenant-registry(reconciles the tables from the doc, computes the identical hash, self-verifies count). Critical at scale: the reconcile hashes only the projected tuple (routing/identity/lifecycle — a theme change does not churn the row) and skips unchanged tenants, so a master write that touched one tenant upserts one row, not all N. Proven on a throwaway Postgres 16 (migrations 001→008): the integration suite (src/lib/db/tenant-registry.it.test.ts) passes 5/5 against the REAL code paths — dual-write, subdomain + custom-domain resolution, delete-propagation, mutate-path reconcile, and the content-hash skip (identical re-save touches 0 rows) — plus a backfill↔dual-write hash-parity check. Design doc:docs/architecture/phase-d-repository-migration.md. tsc 0, eslint 0.
- Phase 53b — the public GloriaFood migration front door (pre-signup funnel). A homepage announcement band ("Import your GloriaFood restaurant" — with the April-2027 sunset urgency chip) links to the new public page
/import-gloriafood: a visitor pastes their GloriaFood connection code, we fetch their menu through the official Fetch Menu API (the code is never stored — one read, then discarded), run the deterministic clean/validate pipeline, and show a live preview (count tiles + sample dishes + "worth a look" warnings). "Create my restaurant & import" carries a one-time 256-bit staged-import token into/register(restaurant name prefilled); right after the tenant is provisioned, the register route consumes the token (atomic, once) and commits the staged menu through the normal Phase 53 pipeline — completed ImportJob with report, undo snapshot, currency applied, allergens suggested-only. Strictly fail-open: an expired/consumed token never breaks signup (the owner can re-import from Menu → Import Restaurant). Staging lives in an ephemeral own-doc store (60-min TTL, consume-once, pruned, capped); the public endpoint is rate-limited (10/h/IP + global ceiling) and gated by the sameimporter_enabledmaster flag, so the band, the page and the API go live together with one flag flip. Self-QA'd on live dev: band + page render, a bad code round-trips the REAL GloriaFood API into a friendly 422, register-with-token imported the fixture menu (4 dishes/2 categories, job + report + snapshot, live allergens[]with provider allergens as suggestions), a second register with the same token imported nothing while signup still succeeded; QA tenants/users fully removed, flag restored OFF. tsc 0, importer tests 18/18.
- Phase 53 — Universal Restaurant Importer ("Import your restaurant"): one-click migration with a provider-adapter framework, GloriaFood first. A non-technical owner moves their whole menu, photos, options and details into Restora 360 in a few minutes. New nav entry Menu → Import Restaurant (with a NEW badge) opens a wizard: connect → animated scan → preview (counts + warnings + "also bring over" settings checkboxes) → Import everything → background-safe import (leave the page; the bell + an email tell you when it's done) → done + plain-language report, with one-click Undo and re-import from history. Built as a thin-adapter framework (
ui/src/lib/import-providers/) cloned from the accounting-platform seam: every adapter emits the existing canonicalExtractedMenu, so the whole pipeline downstream of the snapshot reuses the proven menu-import machinery (no parallel engine). Adapters shipped: GloriaFood (official keyed Fetch Menu API — clean, consent-based; urgent because Oracle sunsets GloriaFood 2027-04-30 with no data retention), a generic website-link adapter (JSON-LD → page-text → real-browser capture, budget-gated, RIC-routed, with server-enforced ownership attestation), and the pre-existing CSV/Excel file import folded into the hub; Toast/Square/Wolt/Foodora/OrderYOYO/Qopla/Lumit ship as "coming soon" stubs that point owners at the file/website fallback (never a dead end). Pipeline: deterministic text cleanup (spacing/caps/HTML/mojibake/hidden-chars) → optional RIC-only AI description polish (digit-invariant, budget-gated, silently skipped when off) → validation + auto-repair (recover prices from price text, collapse duplicates) → never-abort image pipeline (SSRF-safe download → sharp → WebP → sha256 dedup →uploadDir(); a failed photo becomes a report line, never stops the import) → one atomic commit (snapshot-first for undo, Merge / Replace / Skip, sizes → required "Size" radio, i18n{en,sv}, fingerprint ledger for re-import) → deterministic report. Safety: live allergens/dietary tags are NEVER auto-asserted — even GloriaFood's declared allergens land only assuggestedAllergens/suggestedDietaryTagsfor the owner to confirm; credentials are AES-256-GCM encrypted with a GDPR TTL (24h) purged by a daily cron; no menu write outside the single row-lockedmutateTenantDBcommit. Master kill-switchimporter_enabled(fail-safe OFF). Full en+sv wizard copy (the word "API" never shown to the owner). Verified end-to-end on the live dev app (authenticated flow connect→scan→preview→run→completed→undo restoring the menu byte-count exactly) and headlessly against a seeded tenant across all three duplicate modes + re-import-keep-local-edits + allergen-safety, all with hard assertions and the tenant restored to baseline; routes compile + enforce auth, docs article renders, dark-vocabulary guard green, tsc 0, importer tests 16/16.
- Writing Assistant — owner consent toggle for AI prediction. New always-visible consent card on Dashboard → Settings (pattern: the data-sharing card): "AI writing suggestions in text fields" writes
settings.writingAssistant.aiEnabledvia the settings PATCH (zod + whitelist extended). Copy is explicit that local menu/dictionary suggestions never leave the restaurant and that the AI layer sends typed field content to the model and spends AI credit. Default OFF. Live-verified: toggle persists across reload, the assistant's vocabulary endpoint flipsai:true, restored OFF after QA. This closes the "DB field with no UI" gap from the writing-assistant review batch.
- Onboarding-handoff fidelity pass (13-task program verified end-to-end + deviations fixed). Full e2e verification against the
docs/Restora360 onboarding wizardhandoff on the live dev app: register →/welcome/onboarding(prefilled steps, hours presets) → provisioning → completion screen (admin/public-site cards, claim-domain banner with 7-days-left chip) all work; public/bookproduces a real booking (referenceRB-####, slot then shows Booked for the next guest);/products,/services,/plan, footer +Org.nrlegal bar on every public page; KeepAccountBanner (day-4 nudge) + trial-day4 email cron present; register has no payment step. A 6-surface screenshot comparison (design vs live) confirmed the build is faithful; confirmed deviations fixed: (1) last stray199 SEKpricing (18 languages, public /learn coming-soon FAQ) → 299; (2) SiteFooter brand column gained the spec'd App Store / Google Play badge pills (→/servicesuntil the store listings are live); (3) onboarding rail wordmark is now the two-tone mark with white logo chip + ghosted watermark per the design; (4) table-designer top bar shows the spec'd segmented booking-context chip (date · time · covers, clock set post-mount to avoid hydration mismatch); (5)/bookguests step gained the design's empty-state helper ("…we'll have them ready the moment you arrive") and the reassurance line under Confirm ("We'll confirm by email or SMS. No payment needed to reserve." — hidden for deposit venues); (6)/bookfloor map no longer clips owner-placed tables — the design canvas now extends to the tables' extents (was fixed 540×392). Intentional non-changes (newer owner decisions supersede the June handoff): 149 kr starter pricing on register/completion,/tryfunnel instead of/demoon the closing CTA, marketing nav on/plan+/services, "Your account is live!" heading.
Fixed
- Premium templates (Carte/Volt) — source-fidelity polish. Carte's menu now shows the source design's per-dish indices: new
menuNumberingplumbing through the starter→package→installer pipeline (TemplateStarter.menuNumbering→DemoContentBundle→applyPremiumDesign, demo-mode only likemenuLayout), Carte seedsper-category, andbuildStarterdemo products'showNumbernow follows the template's numbering intent (a hardcodedfalsewas vetoing numbering per item). Volt's closing "Hungry?" band corrected from a full accent band to the source's surface-bg band with two-tone headline (ink + accent<em>) and accent button. Verified through the true-preview pipeline (theme-and-demo installs ofcarte+voltrendered via/tenant/preview_*): Carte menu = 2-col elegant + roman category numerals + "1./2." indices; Volt band matches source. (Mercato checked against its source — it has no numerals by design; Stack's band was already 1:1.)
- Platform-wide AI Writing Assistant — inline ghost-text autocomplete in every text field (Tab accepts, Esc dismisses). One client component (
GhostTextAssistant, mounted once in the root layout) attaches via document-level event delegation, so every eligible<input>/<textarea>across the whole platform — menu editors, Mason inspector, marketing/email subjects, notes, admin, portals — gets suggestions with zero per-field wiring (data-assist="off"opts a field/subtree out;data-assist-contextforces a context). TypingKyshows a dimcklingafter the caret → Tab (or tap, mobile) accepts →Kyckling; Esc dismisses and stays quiet for that word; arrows/Enter/IME never disturbed; ghost inherits the field's font/color so it's correct in light + tenant-dark;aria-liveannounces suggestions. Suggestions come from 3 layered sources: (1) a static Swedish-first dictionary (lib/writing-assistant/dictionaries— sv derived from the sharedSV_EN_FOODmap + curated menu staples/allergens/marketing phrases like "beställ nu"/"gratis leverans", en equivalents; per-language files, trivially extendable), (2) the tenant's own vocabulary viaGET /api/dashboard/writing-assistant/vocabulary(product/category/option/ingredient/inventory names, ≤800 ranked entries, session-scoped, 5-min client cache) which outranks the dictionary, and (3)POST /api/dashboard/writing-assistant/predict— a capped Haiku completion (RIC cutover,max_tokens24, wallet-metered under a newwriting-assistantAI feature, 402 on empty wallet,requireRateLimit30/60s) used ONLY when local sources miss in description/marketing contexts (never notes — PII posture), tenant-opt-in viasettings.writingAssistant.aiEnabled(default OFF; typed content goes to the model, so consent is explicit), debounced + session-capped + cached. Local suggestions are pure in-memory prefix search (<1 ms; the engine, field-context classifier and vocabulary extractor are DOM-free with 30 unit tests). Field-context heuristics boost the right vocabulary (ingredients vs marketing …) and a sensitivity filter hard-skips passwords/emails/phones/addresses/names/codes/prices; accepted words are learned per-browser (localStorage frequency boost). Zero cost until a text field is focused (dictionaries dynamic-import + one cached fetch on first focus). Gated by thewriting-assistantfeature flag (dev = on, prod = off until the super-admin flips it;WRITING_ASSISTANT_ENABLEDenv override). Both routes live-re-verify the session viarequireTenantAccess(revoked staff/designer cookies get the anonymous payload / no AI). Hardened by a 27-agent adversarial review (14 confirmed findings fixed): AI completions clamp tomaxLength(+ the native-setter insert path enforces the cap browsers skip on programmatic sets), AI ghost respects caret-at-end (no mid-edit Tab hijack), one-shot boot failures self-heal after a 30 s cooldown, no persistent vocab cache (cross-account residue on shared devices), per-tenant learning keys, aria-live debounced + cleared on hide, typeahead-owning fields excluded (role=combobox/aria-autocomplete +data-assist="off"on the ingredients editor & nutrition search), owner ai-spend page labels the new feature. Verified vs the live dev app (Playwright/Edge): ghost render + Tab-accept in the product editor name input, phrase completion in the description textarea ("gratis lev" → "gratis leverans"), Esc swallowed (modal stays open) + same-word suppression, dark-mode color parity, mobile (Pixel 7) tap-accept — re-run green after the review fixes; tsc 0, eslint 0, 4,163 tests green.
- FSN reposts — members can repost posts and reels to their followers (owner request), web + native. Model/server:
FsnPost.repostOfIdalways points at the ROOT original (reposting a repost resolves to it — rows never chain) + a denormalisedrepostCount; pure rules inlib/fsn/repost.ts(6 tests): PUBLIC+active originals only (a followers-only/private post can never be re-broadcast), one active repost per member per original (idempotent toggle), andrepostVisiblefilters every listing so a repost row vanishes the moment its original is deleted/removed/hidden or its author is blocked — no ghost cards.POST/DELETE /api/fsn/posts/[id]/repost(rate-limited, block-aware with the same 404 anti-oracle as like/save,repostnotification to the original author — new verb+icon on web and native notification lists). Hydration embeds the fully-hydrated original asPublicPost.repostOf(depth 1 guaranteed) +viewer.reposted; author-deleting a repost row returns the count viacascadePostDelete. Web: feed/post-list cards render a "↻ X reposted · when" banner over the ORIGINAL author's full card, with like/comment/save/repost bound to the original's id (updatePostInListkeeps optimistic state in sync when the same original appears twice); Repeat2 toggle (pine when active) with live count; profile tiles label reposts ("Repost · original author") and tile-delete deletes the repost ROW (un-repost);/fsn/post/<repostId>redirects to the original. Native (`:fsn`): the same banner + Repeat action on Home feed cards (FeedPost.interactionIdbinds all interactions to the original), a Repeat rail button on Reels with optimistic count (repost rows never enter the reels pager — video-only originals), and profile grids show the original's media for repost tiles. Verified: 19 lib tests, tsc 0, live e2e vs the dev server (repost 201 → idempotent 200 → repost-of-the-repost resolves to root → un-repost → 404 on bogus id), Playwright feed screenshot (banner + count sync between the repost row and the original),:fsn:assembleDebuggreen. On-device visual pass queued for the next phone unlock.
- FSN CV system — a real job-seeker CV on the talent profile (owner request), web + native + LinkedIn import. Self-declared CV on
TalentProfile: introduction (≤800), birth year (stored privately, only the derived AGE is ever exposed publicly), gender, up to 6 social/portfolio links (http(s)-only), and up to 20 job experiences withYYYY-MMdate ranges (open end = "now"; sorted current-first then newest-first) — all validated/normalised in purelib/fsn/cv.ts(8 tests).PATCH /api/fsn/me/profileextended (empty/null clears a field); LinkedIn import viaPOST /api/fsn/me/cv-importparses the official member-data-exportPositions.csvand MERGES (dedup by company+title+start) — honest scope: LinkedIn offers no profile-read API without a partner app, and the UI copy says exactly that. Web: a "CV & job history" editor card on the account page (FsnCvEditor: links/experiences CRUD + CSV upload) and the public talent page renders About / Experience timeline / Details (Age · Gender · Location) / Links (nofollow). Native (`:fsn`): a "CV & job history" entry card on the own profile opens a fullCvEditorSheet(drag-dismissable, 0.94 height, keyboard-aware; same fields + CSV import via the system file picker), andOtherProfileScreenrenders the same About/Experience/Details/Links blocks. Verified e2e vs the dev server (save all fields → current-job-first ordering → CSV import "parsed 2 / added 1" → public endpoint exposes age 31, never the birth year) and on-device UAT on the S24 (editor save → DB inspection → prefill on reopen → another member's CV renders). tsc 0; 123 FSN lib tests;:fsn:assembleDebuggreen.
- FSN account lifecycle — deactivate + 90-day-grace deletion (owner request), web + native.
lib/fsn/account-lifecycle.ts(6 tests): Deactivate = reversiblestatus:'disabled'(profile/posts/comments vanish everywhere via the existing hidden-author machinery, sessions killed); signing back in reactivates. Delete = disabled +deletionRequestedAt+ a 90-day grace window — sign in within 90 days and the deletion is CANCELLED (both web login and the app bridge reactivate + toast "welcome back / deletion cancelled"); after 90 days the account is fully erased on next touch (the hardened Wave-1eraseAccountcascade + uploads directory removal) — enforced by a lazy purge at login (the guarantee on a box with no scheduler) plus avercel.jsoncronGET /api/cron/fsn-account-purge(CRON_SECRET-gated) as the scheduled sweep.POST /api/fsn/me/deactivate+DELETE /api/fsn/menow schedules instead of instant-erasing ({scheduled:true, graceDays:90}). Settings UIs on web + native gained a Deactivate section and the delete flow states the 90-day terms. Verified: 8/8 lifecycle e2e checks vs the dev server (deactivate → hidden + 401 → login reactivates with fresh status; delete → schedule → login cancels; a past-due account purges at login with uploads gone). One cosmetic fix found by e2e: the reactivation login used to echo the staledisabledstatus in its response.
- FSN dark mode → admin-panel slate (owner request: "black and grey like the admin panel, not brownish"), web + native. The FSN dark palette (web
r360-theme.cssdark block + nativeDarkSchemeinColor.kt) moves from the warm clay-brown family to the super-admin midnight-slate family:#0a0f1acanvas /#121a2acards /#0e1522insets / slate borders + cool text ramps; reels/tab/scrim constants and the toast/plan-card surfaces follow (InkSurface/InkSurfaceDeep). The clay brand accent (buttons, active states, badges) is kept on purpose — surfaces are neutral, brand stays warm. Light mode untouched, pinned by the light-values-unchanged check. On-device visual pass done on the S24 (home feed, reels, profile all slate; no brown surfaces). The "Order · X kr" button on the recipe sheet and the "Order on Food Bazar" CTA on a sponsored reel used to callapp.order(dishId), which stuffed aSampleDatabazar-hero into state and navigated to the Kitchen tab — which no longer consumes it, so the dish was silently dropped (and theBazarScreen/HandoffSheetit once fed are unreachable dead code). Both CTAs now open the real web Food Bazar searched for the dish (/bazar?q=<dish>via anACTION_VIEWintent), the exact pattern the dish-detail sheet's "Browse Food Bazar" already uses — verified the route resolves (200). New sharedopenBazarSearch(context, query)helper next toshareFsnPost.:fsn:assembleDebuggreen. (The deadorder()/openHandoff()/BazarScreen/HandoffSheetchain is now fully orphaned — left in place, safe to delete in a later cleanup.)
- FSN native app Wave 4 — design-conformance polish (audit theme D). Android-only (`:fsn`), no server change. Dark mode fixed on the feed & profile: every feed card, header, comment sheet, and profile screen used raw static Stone constants (
Stone700/600/500/400) as icon/text colors on theme-reactive surfaces (SurfaceCard/HeaderBggo dark in dark mode) — so in dark mode the icons and secondary text were near-invisible dark-on-dark. All of them now use the reactive tokens (TextDefault/TextMuted/TextSubtle) the rest of the app already uses, so they adapt to the theme (light-mode values verified unchanged). Nav-bar inset: the sharedModalSheetnow appliesnavigationBarsPadding()to its content, so every sheet's footer CTAs and the comments composer clear the system nav bar on edge-to-edge devices (the sheet surface still paints to the edge); removed 3 now-redundant inner paddings. Status bar over dark overlays: the video editor, publish step, and full-screen post viewer now get light system-bar icons (the clock was invisible-dark over them in light mode). Emoji removed from live UI (design system bans them): the Global Kitchen globe + country-flag emoji → plain country names, the dish-detail←/→ASCII arrows → Chevron/Arrow Lucide icons, "Applied ✓" → a check-circle icon + "Applied", and the redundant "✓" in the "Posted" toasts (the toast already renders a check icon). Accessibility: the pine/clay toggle now carriesRole.Switchso TalkBack announces on/off instead of a generic button.:fsn:assembleDebuggreen. Left intentionally: the region/language picker flags (a standard picker pattern that was in the 1:1 handoff), an orphaned rating-star glyph in the deadBazarScreen, and the nice-to-have Geist-Mono-number / off-token-color touches on the always-dark day-plan card. On-device dark-mode visual pass pending the S24 reconnecting (the token remap targets the same reactive tokens the app's dark mode already uses successfully everywhere else).
- FSN native app Wave 2.1 + 3.1 + bottom-sheet UX — honest list states, profile follow-ons, and two owner-reported sheet fixes. All Android-side (
:fsn) plus two small additive server fields; on-device verified on the Galaxy S24. Wave 2.1 (honest error/retry on the remaining lists): Notifications, the DM inbox, the Jobs board and Search now show a "Couldn't load — Retry" card when the server is unreachable instead of a misleading empty state (a new genericFsnApi.Loaded<T>result — the feed's oldFeedLoadgeneralised — distinguishes empty from unreachable). Also fixed the DM inbox 30s poll silently wiping a populated inbox to "No messages yet" on a transient failure, and the dead DM-compose "tap to retry" path (suggested-people failures now actually surface it). Wave 3.1 (profile follow-ons): comments now carry the author's account id (additivePublicComment.author.accountId), so tapping a commenter opens their profile; the follower / following counts are tappable on any profile (own + others) → a list overlay backed by a newGET /api/fsn/accounts/[id]/follows?type=(each row → that person's profile + inline Follow); and the profile Message button opens straight into the DM thread instead of the inbox. Owner-reported sheet UX (fixed in the shared `ModalSheet`): every bottom sheet now has a drag handle and swipe-down-to-dismiss (drag past ~120dp releases, else springs back), and the Edit-profile sheet is tall + scrollable + keyboard-aware (imePadding) so the whole form and Save button stay reachable — it was cut off before. Also fixed an overlay z-order bug so the ⋯ report/block sheet and a follow-list-row tap render on top of the profile instead of underneath it. Verified: the two new endpoints e2e (comment carries accountId; follows returns the right people + viewer follow-state + 404 on unknown); on-device on the S24 (edit-profile full window + Save reachable with keyboard, swipe-down closes a sheet, tap author → profile, tap following count → list → tap row → that profile). tsc 0; 109/109 FSN tests;:fsn:assembleDebuggreen. Deferred: a direct comment-handle tap target (the avatar is the tap target today), and pull-to-refresh.
- FSN native app Wave 3 — flows: fixed the one-shot video creator + a view-other-user profile screen (audit theme B + E). Video creator second-run bug fixed:
VideoEditorViewModelandPublishViewModelare Activity-scoped and SURVIVED across creator opens, so a second video kept the first clip's trim/overlays/music and its already-consumed (deleted) export/file, re-firing onExported/onPosted with a dangling path — and reopening the publish step with a leftoverposted=trueinstantly fired a fake "Posted". Both VMs now rebind when a different clip arrives (full reset) and no-op when the same clip is re-shown (edits preserved);exported/postedare consumed as one-shots so a re-entered editor/publish can't replay them. View another member's profile (the audit's dead-ended core social loop): newGET /api/fsn/accounts/[accountId]returns a public profile (name / handle / avatar / verified / real post·follower·following counts + the viewer's follow state; blocked-either-way → 404 with no oracle, matching the feed) and a nativeOtherProfileScreenoverlay renders it with the post grid, Follow / Message, and a ⋯ report/block. Tapping an author now opens it from the feed (author row), Reels (creator row), and search results — before, tapping a name did nothing anywhere in the app. Message starts-or-opens the DM thread; Follow is optimistic (reverts on failure). Verified: the endpoint e2e vs the dev server (view → follow flipsfollowers 0→1+viewer.following, self →isSelf:true, unknown → 404); on-device on the S24 (tap feed author → profile with real counts → Follow flips to Following → Back returns to feed). tsc 0;:fsn:assembleDebuggreen. Deferred: comment-author taps (the comments API doesn't return an account id yet) and tappable follower/following LIST overlays.
- FSN native app Wave 2 — trust: no fake data on signed-in surfaces + honest error/retry states (audit themes A + C). All Android-side (
:fsn), no server change; verified on the Galaxy S24. Honest load states: the Home feed and Reels no longer start onSampleData— they show a loading spinner, then real posts, a genuine empty state, or (new) a "Couldn't load — Retry" card when the server is unreachable, instead of silently sticking fake "Bella Forno / Green Bowl" posts on screen forever (a newFsnApi.discoverFeed(): FeedLoad.Ok|Failedresult distinguishes empty from unreachable; a sharedErrorRetrycomposable +FeedPhasedrive it). Sample data purged from the signed-in profile: "Share profile" now shares the member's REAL account URL (/fsn/talent/{accountId}, matching webFsnShell) instead of the sample@elin.eatslink that 404'd — and prompts sign-up when signed out rather than sharing a stranger's profile; the "Open to restaurant work" card shows the member's REAL city (or an "Add area" CTA) and hides the "Roles & availability" row entirely (no server field yet), instead of presenting sample "Stockholm/Uppsala · Barista/Line cook" as the user's own hiring profile; the previously dead ✕ / "Add area" chips now act (✕ only shows when removal is wired; "Add area" opens edit); the Saved tab is real (GET /api/fsn/saved) with a proper "No saved posts yet" empty state instead of being permanently blank; and a transient/mefailure keeps the last-known real identity instead of flipping the whole profile to the sample persona. Create sheet is honest: the fake pre-filled attachments ("Margherita", "640 kcal · 25 min", "Stockholm") are gone — the recipe/nutrition/location rows read "Optional" and answer taps with "Coming soon"; the "Let viewers order this" toggle (which never actually attached to the post) is removed. Interaction polish: system Back now dismisses anyModalSheetand the Photo Studio (was falling through to the hidden tab / exiting the app); edit-profile and avatar-upload failures keep the sheet open and warn instead of silently discarding edits; and error toasts show an amber alert icon, never a green success check (ToastPill(isError)). Verified on-device (S24, via `adb reverse` to the dev server): feed loads with no sample flash; kill the connection → error+retry card (not fake posts) → Retry recovers; Create sheet shows "Optional" + no order toggle; Back closes the Create sheet; signed-in open-to-work shows only "Add area" (no sample cities/roles); Saved tab shows the real empty state.:fsn:assembleDebuggreen. Deferred to a follow-up: the DM/notifications/jobs/search list surfaces still swallow failures into friendly empty states (nice-to-have Result refactor), and the orphaned recipe→Bazar order chain.
- FSN native app Wave 1 — account + policy foundation (from the 69-finding polish audit, `docs/products/food-stories-network/native-app-polish-audit-2026-07-03.md`). The
:fsnAndroid app finally uses the auth/moderation backend the web already had. Sign-in: the onboarding overlay gains a "Already have an account? Sign in" mode overPOST /api/fsn/loginincl. the two-step TOTP/backup-code branch — before this the app could only register, so a logged-out user was locked out of their account forever; the wizard VM is now epoch-keyed per open so it never resumes a stale step. Dead-session detection: one OkHttp interceptor on a new process-wide shared client (replaces ~13 per-ViewModel clients) — any 401 riding a session cookie flips the app to signed-out with one honest "session expired" toast (login/register excluded; wrong current-password is a 400 so it can't trip it). Log out now actually revokes the device's session server-side (POST /api/fsn/logout) — proven by replaying the raw cookie → 401. Report + block (Google Play UGC requirements): the dead ⋯ kebab on feed cards and a new More rail button on Reels open a ModerationSheet — report post/account (6 reasons + optional details → the existing Super-Admin moderation queue) and block (mutual invisibility; blocked authors vanish from feed/reels instantly via a new reactive ModerationStore seeded fromGET /api/fsn/block, and the server excludes them from every later load). The dead Reels search icon now opens Search. Settings → Privacy & security is real: change password, blocked-accounts list with unblock, and two-step-confirmed permanent account deletion (Play account-deletion requirement); the fake "Connected accounts · 3 linked" badge is gone. Server: `DELETE /api/fsn/me` erasure hardened — extracted to purelib/fsn/account-erase.ts(7 tests): owned posts now get the fullcascadePostDelete(video/recipe/article scrub, count zeroing, edge drops, media-ledger reclaim, report auto-resolution) instead of the old light scrub, plus the previously-missed collections: media-ledger rows (avatar/unattached), saved meals, the subject's support tickets, connected social accounts (encrypted OAuth token blobs destroyed), cross-post records, DM bodies + read receipts (the other participant's own messages are kept — their correspondence), open reports about the account (resolutionaccount-deleted; reports by the subject stay for moderation accountability), BOTH talent-profile link directions (incl. avatarUrl), and 2FA secrets + the platform bridge link on the account record. Verified vs real conditions: 18/18 live e2e checks on the dev app (register → logout-revoke replay 401 → login → wrong-password 401 → block/list/unblock → report account+post → delete → dead cookie + deleted-account login both 401) plus a DB-level erasure inspection (account pseudonymised, sessions/blocks/media rows 0, report-about auto-resolved, other users untouched);:fsn:assembleDebuggreen; tsc 0; 109/109 FSN lib tests. On-device UAT of the new sheets still pending (S24).
- True theme preview — "Preview with demo content" now opens a live preview in a new tab WITHOUT touching the owner's site (owner report: the old button actually installed). New
POST /api/tenant/[id]/themes/preview: clones the tenant DB in memory, runs the exact sameapplyInstalla real install runs (demo or my-content mode), and stashes the clone in an ephemeral store (lib/themes/preview-store.ts, unguessablepreview_<hex>ids, 30-min TTL, per-tenant eviction + 200-entry cap,globalThis-anchored so the API and page bundles share one store).getTenantDBinterceptspreview_ids and returns the clone, so the preview renders through the REAL storefront path — what you see is exactly what installing would produce, with the theme's 9-token palette, demo menu, chrome, everything. Preview pages carry a sticky "nothing here is saved" banner and are noindex; unknown/expired ids 404. Catalog card buttons reworked: primary Preview (opens the new tab; demo content when the pack has it), secondary Use with my content / Use demo content installs (both still snapshot-undoable). Two real defects found by the E2E and fixed: (1) plain-string product names (what AI-authored demo menus naturally contain) rendered as their FIRST CHARACTER everywhere —translate()fell through totext[Object.keys(text)[0]]; demo menu names/descriptions are now normalized to the canonical{en:…}shape at package parse ANDtranslate()accepts strings (defense-in-depth); (2) a hung dev-server compile had poisoned.next, masking the new route (cache cleared). Verified vs the live dev app — 13/13 E2E checks: preview renders themed demo content + banner + noindex; the REAL site's blocks/HTML byte-stable throughout; theme-only preview themed without demo data; bogus preview id 404s; unauthenticated preview 401s. tsc 0; eslint 0; package+i18n suites green.
- Theme packages carry real images now — ZIP binary assets with sharp re-encode (Phase 32 P1 follow-up), plus demo media seeding. A theme ZIP can bundle images under
assets/and reference them from blocks/demo content asasset:<path>— the missing piece for fully self-contained (incl. AI-authored) template packages. Import: bounded raw reads (3MB×40 caps) → sharp decode + re-encode (auto-rotate, ≤2400px, WebP) so polyglot files, embedded payloads and EXIF never survive to storage — a non-decodable image becomes a warning, never a package failure; the integritycontentHashnow covers asset bytes (a tampered image fails the check, pinned by test);safeImportUrlaccepts the strict internalasset:shape (it silently blanked such refs before). At the storage boundary (admin catalog upload + tenant one-off upload) newpersistPackageAssetswrites each image to/uploads/theme-packages/<slug>/<name>-<sha10>.webp(content-addressed, so a changed image never overwrites a file an installed site is serving) and deep-rewrites everyasset:reference to the public URL — catalog, installer and storefront deal in plain URLs and stored packages stay small. Demo media: Install+Demo now registers the pack's gallery + dish photos in the tenant's media library (db.media, sourcedemo, idempotent by URL) so the media picker isn't empty after installing a demo restaurant. E2E-proven against the live dev app — the full "AI-designed theme" loop: a hand-assembled ZIP (manifest + template referencingasset:hero.png+ a real PNG) → admin upload (re-encoded, persisted, refs rewritten, 0 warnings) → owner installs from the catalog → the live site serves the persisted webp (200, image/webp) with{{restaurant.name}}resolved and no rawasset:refs leaked → snapshot revert restores the site → catalog cleanup. 41/41 theme-package tests (6 new); tsc 0; eslint 0 errors. Plus the AI Theme Designer Kit:npm run theme:catalog(generatesdocs/theme-kit/BLOCK-CATALOG.mdfrom the live SectionRegistry — 99 block types with their exact content keys) andnpm run theme:validate <zip>(the realparsePackageZip+ authoring lint: unknown types/keys with did-you-mean, unresolvedasset:refs, unknown{{tags}}, partial palettes; verified on seeded-broken and correct ZIPs), tied together by the newtheme-designerskill (ui/.claude/skills/theme-designer/SKILL.md) so "design me a theme" requests in Claude Code author validated, admin-uploadable packages.
- Mason builder Stage-2 container nesting (MNEST-2/3/4a) — the 5 layout containers (Columns / Grid / Stack / Container / Section) hold real nested child elements, end to end. The data layer (adapter
content.childrenround-trip + cycle-guarded tree ops) already existed; this ships render + editing. Render (MNEST-2): the container components take achildrenSlot— with children, the children become the grid cells / stacked items (fixed demo slots + preview placeholders suppressed); without, they render exactly as before, so every existing page stays byte-identical (pinned by a new render test). Children render recursively through the SHARED SectionRegistry in BOTH the liveBlockRendererand the editorMasonCanvas(no editor/live drift; a nested data-bound block still gets live data), gated on a non-empty array +MAX_NESTED_DEPTH=5. Editing (MNEST-3): a "+ Add inside" chip on any selected container opens the palette in add-into mode (amber banner, cancellable) and inserts via the testedinsertNodeInto; nested nodes are click-selectable, inline-editable and inspector-edited on the canvas; the Layers panel shows the subtree indented with child counts (tree-safe hide/duplicate/delete).duplicateNodeis now tree-aware and deep-clones subtrees with fresh ids — the old root-only version would have placed a nested duplicate at page root and aliased a duplicated container's children to the original's. Drag (MNEST-4a): children drag-reorder within their container (nested SortableContext →moveNodeTo; over-item original-index semantics pinned by test); cross-container drag stays deferred (MNEST-4b). Load-bearing fix found by the live UAT:REGISTRY_RENDERED_BLOCKSwas a stale hand-kept allowlist, so every newer palette element (card, badge, table, avatar, accordion, forms, mobile, commerce, the containers themselves…) rendered as NOTHING on the live site — visible in the editor, invisible after publish. It's now derived from the SectionRegistry minus the 18 switch-rendered data types, so all 86 palette elements are publish-real (registry components are publish-safe by design; the marker test enforces editor-only attributes never reach live output). Verified against the live dev app: full UI loop (add Columns → Add inside Card + Badge → nested selection → Save → live page renders the nested layout themed → no editor markers in live HTML → delete → live back to baseline) plus an API-level persist/render/restore probe. tsc 0 · eslint 0 errors · 437 mason/section tests.
Changed
- Cookie banner upgraded from binary to a small per-category CMP (GDPR/ePrivacy). The consent cookie (
cookie_consent) now stores per-category choices — new v2 formatv2:analytics,marketing/v2:(percent-encoded on write; RFC 6265-safe) — with legacy values kept readable (all→ both granted,essential→ both declined), so existing visitors keep their choice.lib/consent/consent.tsgainsConsentState(necessaryalways on),hasAnalyticsConsent()alongsidehasMarketingConsent()(name + semantics preserved),serializeConsent/consentAll/consentNone;parseConsent/readConsentFromCookieStringreturn the category object and never throw on malformed cookies. Banner (ConsentBanner): three equally-prominent actions — Reject non-essential, Accept all (identical size/weight — no dark-pattern nudge) and Choose, which expands inline Analytics/Marketing toggles with Save choices; DNT/GPC still hides the banner and declines everything non-essential; kiosk/TV/table-assistant exclusions unchanged;CONSENT_EVENTstill fires so capture components re-init. Withdrawal path: newreopenConsentBanner()+CookieSettingsLink— "Cookie settings" links in the landing footers (landing/Footer,landing-v2/LandingFooter, whose dead#Terms/Privacy/Cookies links now point at/legal/*) and the tenant storefront footer (all 3SiteFooterViewvariants) re-open the banner pre-filled even when a choice cookie exists. Consumers re-gated per category: ANALYTICS → RIE (RieAnalytics,sdk.analyticsConsented) and/api/track(which now also checks the consent cookie server-side + honors GPC, not just the client flag); MARKETING →AcquisitionCapture,/api/public/track-acquisition,/g/[id]tracked-link redirect, referral capture (all unchanged via the preservedhasMarketingConsent). Verified vs real conditions: 19-check Playwright E2E on the live dev app (fresh visitor, per-category save, cookie values, reopen pre-fill, legacy-cookie compat, tenant footer) + live curl checks of both API gates (analytics-only cookie stores on/api/trackbut nottrack-acquisition, marketing-only the reverse, GPC blocks both) + tsc 0 + 4,057/4,057 tests.
Fixed
- Admin Staff: creating an admin actually works now — invite email + choose-your-own-password + forgot-password (owner report: "not able to create super admin"). Root cause:
POST /api/admin/staffstored the new admin with an EMPTYpasswordHashand sent nothing, so the person could never log in — and the staff page swallowed every API error silently (a 409 duplicate-email looked like a dead Create button). Rebuilt as a proper invite flow: creating an admin issues a one-time set-password token (only the SHA-256 hash is stored — newUserRecord.passwordToken, additive; invite 7 days / reset 1 hour, single-use, re-issue replaces) and sends a branded email linking to the new public `/admin/set-password` page (fixed-dark, styled like/admin/login; added to the vocabulary-test FILEALLOWLIST as the same documented exception class as the login page). **Forgot password:** "Forgot your password?" on `/admin/login` → `POST /api/admin/auth/forgot-password` (rate-limited 3/15 min, always-200 anti-enumeration, matches superadmin accounts only; dev returns `devSetupUrlmirroring the customer flow). **Resend:** per-row mail button →POST /api/admin/staff/[userId]/invite(fresh invite while unactivated; admin-issued reset once active). Create/resend return the setup link to the calling super-admin as a deliberate copy-link escape hatch when SMTP isn't configured; emailed links resolve fromAPP_URLin production (request origin honoured only outside prod so a spoofed Host header can't poison reset links). Staff UI: inline create errors, invite-sent banner with Copy link, new Status column (Active / Invite pending / Invite expired), legacy no-adminRoleadmins now display as **Super Admin** (they were mislabeled "Support" — they authenticate as full superadmins), and the page's nativeconfirm()/alert()were converted to the themedconfirmDialog()/inline errors. API responses exposepasswordSet/invitePendingand strippasswordToken`/2FA material. Verified vs the live dev app: 28/28 API checks (create→invite link→set password→real login; token replay rejected; weak password 400; wrong password 401; reset rotates the password and kills the old one; unknown/tenant-owner emails leak nothing; unauth calls 401) plus a Playwright visual pass — staff page in BOTH admin modes, set-password form/mismatch/success/expired states, login forgot flow, and a real UI sign-in by the invited admin. tsc 0; vocabulary test green.
Changed
- Landing nav restructured around "Try Free" (owner request). "Experience Center" is renamed Try Free and now also absorbs the former top-level "Before & After" link. Dropdown items renamed to say what you get: Import your menu (WoW experience), Import your website (Before & After), Table designer, Check your site free (Site health check), Free 7-day trial (Onboarding); Free demo unchanged. Top-level "Services" renamed Products (still
/services— note a separate/productsmarketing page also exists, only linked from/plan; pointing the nav there is an open owner call). "Help" and "About us" dropped from the nav — they live in the footer, whose dead#links are now wired for real: Help center →/help, About Restora →/about, Contact us →/contact; footer "Before & After" renamed to match ("Import your website"). All hrefs unchanged otherwise; mobile accordion uses the same NAV array so it picks the change up automatically.
Added
- RIC Phase 1.5 W8 (buildable half) — manage RIC's config from r360 instead of the CLI. RIC's model-routing/pricing config was previously editable only via
pnpm mrr+ raw SQL. New server-side clientlib/ric/admin.ts(ricAdminGetConfig/ricAdminPutRouting, using a separate platform-adminRIC_ADMIN_KEYkept server-side) + a super-admin proxyGET/POST /api/admin/ric-configlet the operator READ RIC's live providers/price-cards/routing-rules (credentials never returned — onlyhasCredential) and edit a routing rule (which model chain serves a taskClass) without a deploy — the edit hot-reloads on RIC within ~10s. The r360 session gates access; the RIC platform key never reaches the browser. Verified end-to-end against a live RIC: the client read the full config (no credential ciphertext) and upserted a routing rule that landed on read-back. Deliberately deferred (documented in the RIC repo'sdocs/phase-1.5): the admin UI page, provider-credential/price-card writes (credentials don't hot-reload — need a restart story), and the per-capability↔per-task-class config-model reconciliation. Not touched: retiring the locallib/aiprovider registry + inline-prompt fallbacks stays gated on 30 clean production shadow-days — those are the safety net until RIC is proven live.
- RIC Phase 1.5 W7 — RIC gateway alerts become Guardian incidents. New inbound webhook
POST /api/admin/guardian/ric-alert: RIC's worker posts health alerts (error-rate, DLQ) and a new per-tenant AI-spend anomaly (a tenant spending >3× its trailing-7-day daily baseline, with absolute + baseline noise floors so tiny tenants never page) to this endpoint, HMAC-signed (x-ric-signature: sha256=…, sharedRIC_ALERT_SECRET/ALERT_WEBHOOK_SECRET). The receiver verifies the signature timing-safely (fail-closed: no secret or bad signature falls through to a super-admin session, so a leaked URL can't forge incidents), maps the alert to a Guardian incident candidate (lib/guardian/ric-alert.ts: RICwarning→P3,critical→P2, sourceintegration, a stable per-rule + per-tenant fingerprint so a sustained condition folds into ONE incident viareconcileIncidentsinstead of one per RIC tick), and persists it intoguardianIncidents; Guardian's existing alerting/paging then escalates on the next probe cycle. The RIC→r360 direction is a push webhook because the existing ADR-17 channel only flows r360→RIC (RIC pulls r360's events, never the reverse). Verified end-to-end against real conditions: the actual route handler with a genuinely RIC-signed spend-anomaly body created a P2 incident, a repeat folded (deduped, no duplicate), and a forged signature was refused (scripts/w7-alert-demo.ts); plus unit tests for the HMAC round-trip, severity mapping, per-tenant dedup, and payload validation. Alerting stays OFF until the shared secret + webhook URL are set on both sides.
- RIC Phase 1.5 W5 (started) — prompts move into RIC's versioned registry (edit + roll back without an r360 deploy). New harness helper
runPromptRefCutover(lib/ric/narrate.ts, the 6th cutover shape): instead of sending the prompt text inline, a site sendspromptRef: { key, variables, attachments? }and RIC renders the registered template server-side, runs it, and returns the text/forced-tool object — same off/shadow/live semantics and the site's inline prompt stays as the OFF/fallback path (retired only in W8). RIC-side,promptRefwas extended to carry image/PDF attachments so vision prompts (menu extraction, Food-DNA) can be registry-managed too, not just text. First cutover: WoW clone-copy (lib/wow/clone-copy.ts) — the inlinecopyPromptis refactored so a singlecopyVars(dna)feeds both the registered template's{{variables}}and the legacy fallback string (one source of truth, no drift);generateCloneCopynow callsrunPromptRefCutover({ promptKey: 'r360.wow.clone-copy', variables, tool, legacy }), taskClass and model unchanged (r360.narrate/ Sonnet), andvalidateCopyPack+ theCLAIM_PATTERNSno-verifiable-claims fence downstream are untouched. Verified against real conditions — a Postgres-backed live RIC with the prompt registered + published (r360.wow.clone-copy@1.0.0):generateCloneCopyround-tripped through the registry to real Sonnet and returned a valid, policy-compliant pack (atmosphere-only, "Reserve your table"; 1587/581 tokens metered as before). The template is version-controlled in the RIC repo (prompts/r360.wow.clone-copy.json) and re-seedable viapnpm prompts. tsc 0 · 16/16 clone-copy tests. Second cutover: menu-import Stage-1 VISION extraction (lib/menu-import/run-extraction.ts) — the churniest prompt.EXTRACT_PROMPTis exported and the registry templater360.menu-import.extract@2.0.0is generated FROM it (byte-fidelity: an empty{{learnedHintsBlock}}renders exactlyEXTRACT_PROMPT, so off/live differ only by source); the vision path now sendspromptRefwith the page images as attachments (the W5-1 extension) while the HTML-text path stays inline (its menu text is embedded in the prompt, a different shape). Thesave_menuforced tool,normalizeExtractedMenu, prices-as-read and deterministic fences are all unchanged; the learned-hints block is derived by slicingEXTRACT_PROMPToff the assembled prompt (one source of truth). Verified vs real conditions: a synthetic 4-dish menu image through a pg-backed live RIC resolved@2.0.0, ran real Sonnet, and returned all four items with prices exactly as printed (95/119/89/65 kr). Note: the pre-existing eval suite for this key is a stale RIC-5 text demo that can't validate a vision prompt, so@2.0.0was published with--skip-gate(audited) — a vision-capable eval suite needs the RIC-9 harness to support attachment cases (documented follow-up); the deterministic fences + shadow-days are the net meanwhile. Behind the fail-safe-OFFric_enabledflag. Third cutover: Experience Studio Food-DNA dish detection (lib/experience-studio/food-dna-runner.ts) — the base64-image path now sendspromptReffor the registeredr360.food-dna.detect@1.0.0(system+user template generated byte-faithfully frombuildFoodDnaMessages,r360.visiontaskClass) with the dish photo as an attachment; the external-URL path stays legacy (RIC takes base64 only) andparseFoodDna/vetFoodDnaare unchanged. Verified vs real conditions: a synthetic pizza image through a pg-backed live RIC resolved the template and returnedpizza/italianat 0.88 confidence with only visible ingredients (the never-invent invariant held). W5 code cutovers COMPLETE (clone-copy text + menu-import vision + Food-DNA vision, all three high-churn prompts now registry-managed, editable/rollbackable without a deploy); the inline prompts remain as OFF-path fallbacks until they're retired in W8.
Fixed
- RIC W4b video cutover — three bugs caught by an adversarial review of the media batch (all behind the fail-safe-OFF media flag, so none were live-exploitable). (1) Cross-tenant forgery (major): the RIC video jobId was plain base64url JSON whose tenant field was self-asserted, so a caller who learned another tenant's Veo op could re-wrap it under their own tenant and poll the victim's clip. Fixed RIC-side by sealing the jobId with AES-256-GCM (unforgeable); r360 just round-trips the opaque token. (2) Transient blip treated as terminal (major):
pollExperienceVideo's RIC branch caught every error — including retryable 5xx/timeouts — and permanently refunded + retracted the owner charge while Veo kept generating and RIC's submit charge stood, orphaning an already-billed clip. It now honoursRicError.retryable: a transient error returnsprocessing(keep polling, no refund), only a definite failure refunds and ends the job. (3) Refund/reserve divergence (minor): the reserve used RIC's dynamicusage.costUsdbut the failure refund used a hardcoded$3.20— identical today but corrupts the platform-day video cap the moment RIC's Veo price card changes. The jobId now carries the exact reserved amount (ric:<reservedUsd>:<jobId>) so the refund always mirrors the reserve. Also documented a known bounded residual: the 45s RIC timeout can't cancel RIC's server-side work, so a generate that succeeds just after we abort falls through to legacy and makes a second asset — the airtight fix (an idempotency key RIC dedups on) is deferred to a dedicated pass. r360 tsc 0 · RIC 179/179 (new forgery test) · HTTP e2e (sealed jobId + forged-token 404) · r360 demo branches green.
Added
- Owner-panel dark: last brown wash eliminated (hover-collision in the tenant dark sheet). The tenant dark override sheet (
lib/tenant-theme/dark-css.ts) had the same substring-selector flaw fixed earlier in the admin engine:[class*="bg-amber-50/"]-style rules also matchedhover:bg-amber-50/40classes and painted the warm tint PERMANENTLY — the brown shortcut cards on quick-links, and every card with a warm hover accent. All substring selectors are now word-boundary anchored and hover variants get real:hover-scoped rules (all 17 color families). Verified live + on prod: card surfaces compute #121a2a navy. (c8b7b0f2)
- Owner-panel theme overhaul — dark mode goes NAVY (owner request: "no brownish look, like super admin dark"), plus a full 121-route deep scan. LIGHT keeps the warm clay brand; DARK now uses the super-admin navy palette (#0a0f1a canvas / #121a2a cards / #1b2538 insets / #1f2a3d hairlines / #e5e9f0 ink, primary #ea580c). Root cause of the brown: a second warm
.darktoken block (clay #1a1712/#26221c) later in globals.css silently overrode the neutral one — BOTH now navy, along with the neutral safety net (gray/slate/zinc/neutral/stone ramps), warm copilot-hero gradient,--warm-ink, and new dark coverage fordivide-gray-50+ neutralfrom/togradient stops. Hex hygiene: ~50 dashboard files still hardcoded the clay brand (bg-[#bf601d], gradients to[#d4762f]) — all converted toprimary/accent-inktokens so dark mode re-colors everything at once; broken clay→pastel gradients (SEO/AI/briefings/branches heroes, standing-reorders chip, supplier-quotes sky tile) rewritten to token tints. Element fixes: orange-on-orange text (AI assistant header/Pro-Tip, help setup banner, briefings labels), FSN bridge CTA (undefined --clay-500 → invisible button), dark-vanishing active pills (waitlist, Google audit/insights →bg-text text-background), hardcoded pine "Service live" label, features/labels/support hexes, Restora-Profit accent tokens now mode-aware CSS vars, features plan banner navy in dark, SmartRefill locked card matches its sibling. Systemic:.dashboard-shellscope class + normalized disabled buttons (washed-out white-on-peach in light → solid neutral pill; navy pill in dark) + themed native file-input buttons. QA: probe sweep of ALL 121 static owner routes × both modes (242 probes) + 16-agent screenshot review, fix, re-capture (79 shots) + 8-agent re-review → 0 broken; tsc 0; 4,024/4,024 tests. By design: KDS stays always-dark; website-builder previews show the public site's own theme; the red "N Issues" pill bottom-left is the Next.js dev-mode indicator (never in prod).
- Themed in-app confirmations everywhere — native `confirm()` retired. The browser's native confirm dialog can never inherit the app theme (the owner flagged it on the Image Bank purchase popup). New self-contained
confirmDialog()(src/components/ui/confirm-dialog.tsx): renders intodocument.bodyoutside every theme engine's scope, detects the surrounding surface (admindata-admin-theme/ tenant.dark) at open time and styles itself accordingly — title, message, ghost Cancel, accent confirm button (red for destructive, brand orange otherwise), Escape/scrim to cancel, focus on confirm. All 16 remainingwindow.confirmcall sites across 14 files converted with per-action labels: owner dashboard (orders decline, accounting disconnect, hiring unverify, quick-links group delete, supplier-marketplace cart switch, inventory order/order-all ×3), FSN (post delete ×3, block, mark-hired, disconnect platform, sign-out device), admin FSN maintenance (orphan purge). Verified live on the accounting Disconnect flow in BOTH tenant modes (dialog renders themed, Cancel leaves the connection untouched); tsc 0; 4,024/4,024 tests.
- Admin theme hardening round 2 — full-panel sweep (104 routes × both modes), chrome + islands + native widgets. Owner kept finding one-mode-broken spots after round 1 (sidebar search box dark on light, Domains page white-on-dark, preset swatches invisible, un-themed native confirm popup) — root causes: (1) scope — the generated vocabulary only themed
.admin-contentdescendants, so the chrome (sidebar/topbar) was never remapped; the engine now themes the ENTIRE[data-admin-theme]subtree. (2) tenant tokens — pages built with the tenant design system (bg-card/text-text/border-border…: domains, support-brain, and the re-exported global-media) render one-mode-only under the admin engine; those pages were rewritten onto the standard vocabulary, the engine gained a tenant-token SAFETY NET mapping the core tokens per mode (so a re-export can never break again), and the vocabulary test now BANS tenant tokens on admin sources. (3) native widgets — the app root pinscolor-scheme:dark, leaking dark range-tracks/date-pickers into light admin; the themed root now sets its own color-scheme and range sliders get an explicitly themed track+thumb. Also fixed: light-mode disabled buttons normalized to a readable neutral pill (washed-out white-on-peach Save buttons), bareborder-b/t/l/r+divide-y/xcurrentColor hairlines, translucentbg-black/scrims in light (solid before — hid hover overlays), RAOS status pills off inline dark hex onto palette classes, escrow overdue rows visible amber in both modes, monetization/modules neutral chips, themes Premium badge ink, color-preset swatch circles (self-contained double shadow-ring, visible on any strip), and the Experience Studio Image Bank's nativeconfirm()replaced with a themed in-app purchase dialog. Verified: probe sweep of ALL 104 static admin routes × both modes (whole-document luminance + text-contrast) + a 13-agent screenshot review of all 208 captures; tsc 0; 4,024/4,024 tests. Known by-design: Guardian/Growth command-center rails stay dark in light mode (1:1 ported prototypes — flag if unwanted); localization preview renders fixed receipt paper.
- RIC Phase 1.5 W4b — Experience Studio VIDEO generation through the RIC gateway (W4 media batch COMPLETE). RIC gained an async Veo-shaped LRO surface (
POST /v1/ai/videossubmit →GET /v1/ai/videos/:jobIdpoll; the jobId is opaque and TENANT-BOUND — a leaked ref polled by another tenant 404s exactly like an unknown one; cost commits at submit as RIC's aicalls ledger row; on done RIC downloads the clip immediately — Veo links expire in ~2 days — and returns `mp4Base64` in-band with `synthetic`/`disclosure`; file-level marking rides Google's embedded SynthID watermark since mp4 has no lossless iTXt equivalent). r360 side: `ricVideoStart`/`ricVideoPoll` client + a seam in `startExperienceVideo` (media-gate LIVE only — same no-shadow rule, a shadowed clip is a real ~$3 second generation) that submits through RIC with the SAME negative-prompt rails and reserve-on-submit metering; jobs route back via a `ric:`-prefixed jobId so `pollExperienceVideo` (now tenant-threaded from the video route, since RIC's jobId is tenant-bound) polls RIC under the submitting tenant's scope, refunds the video day-lane on terminal failure exactly like the google branch, and returns the same `data:video/mp4` URL — the whole downstream (owner ×2.5 charge with poll-failure retraction, auto-bank gallery pipeline) is untouched. The mock-poisoning guard applies at submit: a mock-served job falls through to legacy Veo. RIC-side verified 178/178 (4 new gateway tests incl. tenant-binding + terminal-failure row) + HTTP e2e on a real gateway (submit / poll-ready-mp4 / foreign-tenant-404 / garbage-404); r360-side live-verified via `scripts/w4-video-demo.ts` (fake-live → `ric:` jobId + real mp4 data URL, exactly 2 gateway hits · flag-off → 0 RIC calls · mock-guard · dead-RIC → legacy). tsc 0. Go-live: RIC needs a Google key with paid Veo access (`GOOGLEAPI_KEYormrr.providers`); until then live mode falls back to legacy loudly.
- RIC Phase 1.5 W4a — Experience Studio TEXT→IMAGE generation through the RIC gateway. New
ricImage()client (lib/ric/client.ts, sharing the completions transport/error contract) calls RIC's existingPOST /v1/ai/images, whose PNGs arrive Art-50-marked (machine-readable iTXtAI-Disclosurechunk — the EU AI Act synthetic-content marking r360's legacy images never had). The seam sits insidegenerateExperienceImageso ALL its callers (Studio hero generation, Food-DNA admin generation, Flow-B component assets) cut over at once, with three deliberate guards: (1) media never shadows — newgetRicMediaMode()collapsesshadowtooffbecause shadowing an image is a real second generation (double spend on both ledgers), so media cuts over only at an explicitlive; (2) the img2img/owner-photo path stays legacy — RIC images take a prompt only, and the photo-must-drive-the-result guard is untouched; (3) a mock-served RIC image (provider `mock`) is rejected and falls through to the legacy provider, so a RIC-side outage can never persist or bill a 1×1 placeholder as a hero (RIC-side, the mock tail was also removed from the LIVE image/embedding chains — same fail-loud decision as the text chain). RIC's in-bandusage.costUsdfeeds the SAME metering as legacy (platform per-day lane + per-tenant attribution); any RIC failure falls back to the legacy registry path unchanged. Verified live viascripts/w4-image-demo.tsagainst a real running gateway: fake-live → r360 returns the marked PNG + meters $0.04 (1 gateway hit) · flag-off → 0 RIC calls · RIC-in-mock-mode → guard rejects the placeholder, legacy resolution runs · RIC-dead → RicError → legacy. tsc 0 · lib/ric+studio tests green. Go-live note: RIC needs an OpenAI key (mrr.providersorOPENAI_API_KEY) for gpt-image-1; until then live mode falls back to legacy loudly. Video (Veo LRO) is W4b — it needs a new async RIC surface, not/v1/ai/images.
- RIC Phase 1.5 W3 (started) — vision cutover pattern proven on the FSN food-gate. Extends the RIC harness (
lib/ric/narrate.ts) withrunVisionCutover: sends a base64 image part + instruction (and an optional forced tool) through RIC, returning the model text/object so each site's existing parser/vetter and fences run unchanged. Two new fenced RIC vision taskClasses back W3:r360.vision(Haiku-first, cheap classifiers) andr360.vision-hi(Sonnet-first, accuracy-critical regulated extraction). Cut over `fsn/food-gate` (classifyImageBytes→r360.vision): the inline Haiku vision call is the legacy fallback, and the conservativedecideVerdict/vetGateResult/fail-open behaviour is untouched. Live-verified against a running RIC gateway: a real PNG round-trips →{"isFood":false,"confidence":0.99,"label":"…"}, the exact shapeparseGateJsonexpects, metered at $0.0002. Behind the fail-safe-OFFric_enabledflag (shadow default). Also cut over `profit/invoice-vision` (the Operating-Cost-Ledger feed — a misread digit is a real cost error) via a newrunDocVisionCutoverthat takes a pre-built content array (multiple images / a PDF document part) plus the forcedsave_invoicetool, on the Sonnet-tierr360.vision-hi. The legacy Sonnet vision call is the fallback;normalizeInvoiceand theai-estimate/owner-confirm mapping are unchanged, so amounts stay "as read, never computed". Live-verified: an image + forced tool round-trips through RIC → asave_invoiceobject with an honestly-low confidence on unreadable fields (Sonnet, $0.0027). Also cut over the regulated extraction cluster —nutrition/calculate(text forced-tool,r360.generate),nutrition/from-image(vision forced-tool,r360.vision-hi) andmenu/detect-ingredients(text forced-tool,r360.narrate). In every case RIC only extracts (ingredient names / visible foods + portions); the nutrition numbers stay deterministic via the food catalog and the allergens stay deterministic viadetectAllergens(owner-confirmed suggestions) — the never-invent-numbers / allergens-as-suggestions invariants are preserved exactly, with the deterministic engines and disclaimers unchanged. W3 is now COMPLETE — every remaining extraction/vision site is cut over on the same helpers:bazar/photo-quality(vision,r360.vision) · WoWdesign-dna(bothgenerateDesignDna+detectMenuLayoutdoc-vision forced-tool,r360.vision-hisince design-DNA runs on Sonnet) andai-theme(vision forced-tool,r360.vision-hi) ·wow/translate-menu(narration,r360.narrate, positional element-count invariant preserved) ·fsn/recipe-card(structuredsave_recipe,r360.generate) ·inventory/copilot(narration,r360.narrate— the fenced taskClass ADDS a number fence this RAG site lacked) ·launchpad/research-runner(extraction,r360.generate, extract-from-source-only + citation-pinning fence unchanged) ·admin/support-braincurator +dashboard/support/ask(narration,r360.narrate, PII gates unchanged) ·experience-studio/food-dna-runner(vision,r360.vision; an external-URL image stays on the legacy provider since RIC takes base64 only) · andmenu-importStage-2 photo detection (r360.vision-hi), the one menu call RIC-6 left behind, now reusing the samericTenantOf/ricModeStage-1 uses. In every case the site's downstream parser/vetter/deterministic-fallback and wallet metering are untouched — RIC only replaces the model call, with the legacy provider call as the fallback. All behind the fail-safe-OFFric_enabledflag (shadow default). One site is explicitly deferred:detectDishGeminiuses the Gemini provider directly (not Anthropic), so consolidating it to RIC is a later provider-routing optimization, out of scope for the Anthropic cutover pattern. tsc 0 across all touched files. Next: W4 (image/video generation).
- Admin theme hardening — "no hardcoded styles" is now an ENFORCED rule (owner request: fix light/dark for good). Root cause of the recurring "page looks broken in one mode" reports was three systemic bug classes in the override engine (
src/lib/admin-theme/theme-css.ts): (1) hover-collision — the[class*="bg-x/"]substring rules also matchedhover:bg-x/5classes and painted the hover tint PERMANENTLY (the "dark buttons on a light page" breakage on Food DNA); all substring rules are now word-boundary anchored ([class^=]/[class*=" ..."]) and every bg/border/text utility additionally gets a:hover-scoped remap for itshover:variants. (2) colored-text lift gaps —text-{color}-600/950(and hover//alpha variants) authored for light pages were near-invisible on the dark canvas; dark mode now lifts all 600–950 colored text to readable tints. (3) vocabulary holes — neutral 300/400 surfaces+borders,border-white/nn/border-black/nnhairlines,text-white/nninks, gray alpha hovers and bareborder(Tailwind v4 currentColor default) are now themed in both modes. Coverage went from 319 unthemed tokens across 91 admin sources to 0 neutral gaps. Enforcement: newsrc/lib/admin-theme/vocabulary.test.tsscans every admin surface (app/admin/dashboard,components/admin,(dashboard)/dashboard/admin) and FAILS the suite on any unthemed color class or arbitrary hex class (bg-[#bf601d]) — rule documented in AGENTS.md. Cleanups to pass it: Food DNA page rewritten onto the covered vocabulary (nobg-transparentinputs /hover:bg-black/5), and all hex classes in feature-flags, revenue-share, docs-analytics, admin addons + monetization replaced with palette equivalents. Verified: tsc 0 · 4,023/4,023 tests · Playwright screenshots of 12 admin pages × BOTH modes, automated luminance probe + eyeball review all clean.
- RIC Phase 1.5 W2 — narration cutover to the Restora Intelligence Cloud gateway (social captions first). Extends the RIC anti-corruption layer (
ui/src/lib/ric/, the ONLY RIC-aware code in r360) from menu-import (W1/RIC-6) to the narration surfaces. Newlib/ric/narrate.ts= the generalized narration cutover harness: a RIC-backed transport matching the existing pluggableAiGenerateseam exactly, so a call site cuts over by swapping ONE line — its validator, fence and deterministic fallback untouched. Three modes via the fail-safe-OFFric_enabledflag (getRicMode): off = legacy Anthropic path byte-identical · shadow = legacy serves, RIC runs alongside and a structural number-drift diff is recorded todatabases/ric-narration-shadow.json(gitignored, capped) · live = RIC serves with the legacy transport as the fallback on any RIC error. The RIC narration taskClass is FENCED, so RIC's own output filter flags number drift / invented allergen claims — a second wall behind each r360 site's validator. Two cutover shapes are supported so any narration site fits: (a) the transport-seam pattern (a site that already injects anAiGenerate) and (b)runNarrationCutoverfor inline routes (system+user → text). Cut over EIGHT surfaces so far: social captions (draft-captionroute,cron/social-autopilot,live-instagram— transport swap), Guardian RCA, Growth advisor chat, and the four platform/founder exec narrations that share the per-field number/date guard — RACM governance briefing, Mission Control founder briefing, Intelligence recommendations, Profit advisor — plus the inventory daily summary (inventory/narrate). For the exec-narration sites the LLM call is swapped forrunNarrationCutoverwhile the downstreamguardFieldnumber-guard is left completely intact — it re-validates whatever RIC returns, so a RIC-served rephrase that drifted a figure still reverts to the deterministic line. Every site keeps its deterministic fallback and its exact wallet metering; the tenant sites (profit, inventory) attribute cost to the tenant, the platform-ops sites use a dedicatedplatform-opsRIC scope. Live-verified against a running RIC gateway: a RIC-served Haiku caption PASSES the existingvalidateSocialCaptionwith zero violations, the inline path returned a real RCA, and a governance-briefing JSON round-tripped through the fenced taskClass with every figure (45200 / 12% / 3) preserved; shadow-compare recorded; the drift fence caught an injected "25% off + 100 kr". tsc 0 on all touched files. ALL 16 narration sites now cut over — captions, guardian RCA + ops-chat, growth chat + outreach, the four number-guarded exec narrations (RACM / Mission Control / Intelligence / Profit), inventory summary, voice-concierge, exec briefing, email campaigns, SEO landing, and site-template copy — plus WoW clone-copy via a structured (forced-tool) variantrunStructuredCutover. Two RIC taskClasses back them:r360.narrate(Haiku-first, light rephrases) andr360.generate(Sonnet-first, heavier content) so model quality is preserved per site — both FENCED. Live-verified: r360.generate serves Sonnet SEO copy; the forced-tool path returns a validated clone-copy object; a governance-briefing JSON round-trips with every figure preserved. Everything stays behind the fail-safe-OFFric_enabledflag (default shadow) until an explicit live flip. Next: W3 (extraction & vision — the fence-heavy batch).
- Phase 52 — Food DNA: ONE curated dish→image registry for every import (owner request: "system learns it by time"). The two import paths gave different images because they never shared a source: the dashboard import ran its own ladder (tenant media → community → stock) while the WoW clone ran a separate stock chain and left unmatched dishes blank. Now both consult the same super-admin-gated registry. Store + engine:
databases/food-dna.json(FoodDnaEntry: multilingual canonical names + aliases + status + license-tracked image; own app-doc, PG-ready) with a deterministic conservative matcher (exact=100 / token-set=95 / Jaccard≥0.8=85, below → NO match — a wrong dish image misrepresents meat/allergen content) andresolveDishImagethat serves approved entries only. Wire-ins: the dashboard import'simage-matchladder gains a DNA lane (near-exact = 96, the only non-owner source allowed to AUTO-assign — a human approved it); the WoW claim now backfills imageless products from near-exact DNA matches (markedimageSource:'dna') — the two paths finally agree. Demand loop: dishes that finish an import imageless (both paths) are captured as demand — unmatched names become pending name-only entries and the admin queue sorts demand-first, so the registry grows toward what restaurants actually import. Human gate:/admin/dashboard/food-dna— seed the 700-dish FSN Global Kitchen library as PENDING (FSN presentation ≠ restaurant presentation; 650 seeded, each needs explicit approval), search/status filters, per-entry set-image-by-URL (SSRF-guarded, rehosted under /global-media, credit required before approval), REJECT/DELETE, and AI generation: one click generates a menu-style photo from name+cuisine via the existing image-provider registry (platform $5/day lane), sharp-resized to the library convention (1024×768 + thumb) — and still lands PENDING. Honest marketing: DNA-assigned images carryProduct.imageSource:'dna'and the public menu shows a subtle "Illustrative" marker; the owner replacing an image flips it to'owner'and the marker disappears. Verified: tsc 0 · 4,022/4,022 (5 new engine tests) · live end-to-end on dev — seed 650 → approve "Kanelbulle" → tenant match-images returns the DNA image at 96/auto from /global-media · admin page renders with thumbnails/credits/counts.
Changed
- Plan catalog overhaul (owner decision 2026-07-02): FOUR live plans, all others WIPED; flat plans billed YEARLY; AI-paid-during-trial stated everywhere. The live catalog is now exactly: Online Starter 149 kr/mo (1 788 kr/yr) · Basic 299 kr/mo (3 588 kr/yr) · Enterprise 2 999 kr/mo (35 988 kr/yr, EVERYTHING included — every feature and add-on) · Pay As You Go 0 kr/mo + 7% per transaction (everything included; rides the existing revenue-share commission engine). Flat plans show a monthly price but charge ONE yearly payment (
resolveSignupPlan.chargeSEK, 365-day periods end-to-end: register provisioning,/register/paymentdev-sim + Stripe yearly line item, billing-page upgrades). Wiped (archived + non-public + non-assignable, still resolvable so existing subscriptions keep entitlements): basic-annual 999, premium-annual, growth-partner 8%, street-food-starter 99+2%, free-growth-7pct, franchise, seasonal, and the old usage-metered PAYG (2 kr/order · 0.10/SMS · 0.50/AI-req). The tenant billing page now renders the live catalog dynamically (4-card grid, "billed yearly · X kr/yr" / "7% per transaction"), switching to PAYG charges nothing (works even without Stripe keys), and the revenue-share banner shows for any revenue-share plan. PAYG is exempt from the retired Free-Growth signup flag (first-class plan). Trials: always 7 days on every plan — and AI usage is PAID during the trial (draws from the one-time 100 kr AI credit), stated on: the landing pricing band, /register, /register/payment, the public onboarding wizard, the /welcome/onboarding trial summary (new "AI during trial" row), the dashboard trial banner, the billing trial banner and the welcome email. Landing pricing band redesigned as Starter card + a Basic/Enterprise/PAYG ladder. 9 catalog-shape tests updated to codify the new reality (engines unchanged); PAYG-shape, offerable-set and eligibility tests now assert the four live plans. Verified: tsc 0 · 4,017/4,017 · live QA (signup-plan API 149/1788/yearly+AI-note; billing grid = exactly the 4 plans, legacy hidden; PAYG switch → active at 0 kr with the full entitlement nav appearing; landing band renders). NOTE: /plan and /about marketing pages still carry older copy (OP-7b, owner).
Added
- Phase 51 OP-14 — menu as a digital SHOWCASE; "Online Ordering" becomes an add-on (owner decision). The Online Starter plan no longer includes
online-ordering: the public website and Food Bazar page render the FULL menu (photos, prices, allergens) but no cart or add/order buttons; buying the add-on (29 kr/mo billed yearly — now listed in the store) or being on Basic+ lights ordering up on both surfaces. Enforced in FOUR layers so it's never just hidden buttons: (1) all three public order-creation routes (cash/card/PayPal) refuse with a polite 403ONLINE_ORDERING_NOT_ENABLEDvia a new server-only entitlement gate (fail-OPEN on transient read errors — a DB hiccup must never block an entitled restaurant's money); (2) the tenant storefront threadsorderingEnabledthroughBlockContext→MenuClient(cart drawer/button, quick-add, customize all gated; default true so every other caller is unchanged); (3) the Food Bazar detail payload carriesonlineOrderingEnabledand folds it intoacceptingOrders, with a dedicated "This restaurant doesn't take online orders — browse the menu, then visit or call" notice; (4) the marketplace LIST badge uses a new batchedtenantsWithFeaturegate helper (ONE master read for the whole list) so cards never advertise a checkout the restaurant can't fulfil. Trial semantics unchanged: the 7-day all-access trial DOES unlock ordering; the showcase mode begins when the trial converts to a plan without the add-on. Landing/payment/FAQ copy now says "digital menu on your own website" for the starter and moves "Online ordering & payments" to the Basic card. Live-verified on dev: post-trial starter = menu renders, 0 order buttons, API 403, store lists the add-on at 29/mo · 348/yr; premium tenant byte-identical (27 add buttons, ordering APIs pass, bazaracceptingOrderstrue). tsc 0 · 4,017/4,017 tests.
Changed
- Phase 51 v2 — owner re-cut of the Online Starter package + unified add-on pricing (29 kr/mo, billed yearly). Owner decisions applied end-to-end: (1) Starter recomposed — OUT: Orders board, Customers CRM (and Messages, which rides the same feature), Tables & QR + Floor Designer, Delivery zones, and the whole Reports & Money set (analytics, bookkeeping, payouts, payments dashboard) — each now activates from the Add-ons store; IN: Experience Studio (access included; generation spends the one-time 100 kr lifetime AI credit every signup already gets —
GRANT_SEK_PROD, stamped once, never resets). Guest-facing ordering + payment processing (stripe/paypal/swish) deliberately stay in the plan — guests can always order and pay; it's the owner-side management surfaces that are add-ons. (2) Account & Settings slimmed — Restaurants (→multi-restaurant) and Go Mobile (→ newgo-mobilefeature) are now add-on-gated; newpayments-dashboardfeature gates the Payments page. (3) One add-on price — ALL single add-ons are now 29 SEK/mo billed yearly (one 348 SEK charge for a 365-day period): 37 registry entries repriced (from 9–199 kr; incl. fsn-recruiter/food-catalog-api 199→29, inventory 99→29) — suites keep their bundle pricing per 30 days, and Experience Studio keeps its premium price (it's in the 149 plan instead). Billing engine now period-aware:resolvePurchasablecarries periodDays/chargeAmount/periodLabel, activation + renewal sweep use them, Stripe checkout charges 12× monthly with a "— 12 months" line item, the confirm modal previews the real yearly total, and the store/active cards say "billed yearly". Also fixed: the marketplace guessed tier from the planId prefix, so Online Starter tenants (unknown prefix → 'basic') wrongly saw NO basic-tier add-ons as buyable — it now trusts the per-feature resolved entitlement (isActive) from the API. Landing pricing card, FAQ and /register/payment copy updated to the new composition. Verified: tsc 0 · 4,012/4,012 tests · live QA on a fresh signup (compact 9-section sidebar exactly as envisioned; store lists the moved-out features at "29 SEK/mo · billed yearly · 348 SEK/yr").
Fixed
- Menu import from URL now accepts bare domains ("kebabtime.se") — same normaliser as the WoW funnel, one engine. The WoW clone flow got
normalizeUrl(bare domains, www-forms, loose schemes → https default) but the menu-import guard (lib/menu-import/url-safety.ts checkUrl, used by both the URL-capture and direct-download import routes) still parsed the raw string and rejected anything without an explicithttps://. It now normalises with the SAME WoW helper before validating; the SSRF guard is unchanged and verified to still block loopback/private/metadata hosts after normalisation. 5 new tests; live-verified:kebabtime.sestarts a capture job instead of "Enter a valid URL (including https://)".
- Logged-out `/dashboard` now redirects to `/login` instead of rendering a ghost panel. The dashboard layout never required a session: an anonymous or expired-cookie visit rendered the FULL unfiltered nav catalog (119 rows) with every link disabled. Pre-existing, but once the sidebar became entitlement-aware (Phase 51 OP-2, deliberately fail-open) this ghost state masqueraded as "plan filtering is broken" — found when the owner's stale tab showed "the long navigation" for a fresh 149 kr Online Starter tenant whose server-side filtering was verified correct. One-line guard: no valid
platform_session→redirect("/login"). Verified: anonymous/dashboard→/login; starter login still gets the compact plan-filtered sidebar.
Added
- RIC-6 — Restora360 becomes the first consumer of the Restora Intelligence Cloud (separate repo `restora360_restaurant_intelligence_system`). New anti-corruption module
lib/ric/— the ONLY RIC-aware code in this repo:mode.ts(pure fail-closed gatepickRicMode: MasterDB flagric_enabledfail-safe OFF ∧RIC_BASE_URL∧RIC_API_KEY; with the flag on,RIC_SERVE_MODE=liveopts in, anything else = the safershadow),client.ts(RIC gateway client: explicitrestora360:<tenantId>scope per call, RFC 9457 problem mapping, timeout),shadow.ts(structural menu diff + cappeddatabases/ric-shadow.jsonstore, best-effort by contract),types.ts. Stage-1 menu-import extraction (run-extraction.ts, both the vision and text paths) can now run through the RIC AI Gateway: off = byte-identical legacy; shadow = legacy serves, RIC compared (first live shadow: 100% name overlap, 0 price mismatches); live = RIC serves with the SAME wallet metering (recordUsagegets RIC's in-band token usage →appendLedgerEntryunchanged) and automatic legacy fallback on any RIC failure (proven by killing RIC mid-demo). Stage-2 photo detection stays legacy (cutover with the P1.5 migration wave). Demo/verification harness:scripts/ric6-demo.ts <off|live|shadow|reset>. Verified: tsc 0 · 8/8 new lib/ric tests · live cross-repo demos for all four behaviors with real Anthropic answers on both paths.
Added
- Phase 51 — Owner Panel Reorganization & the 149 SEK Online Starter (OP-1…OP-6, buildable scope COMPLETE). The owner dashboard had grown to 89 top-level pages with a ~95-entry sidebar shown to EVERY tenant and zero plan filtering — while the entitlement engine (67-feature registry, resolver,
requireFeaturein ~85 API routes) was fully built but never wired to navigation. This phase wires it, reorganizes the IA, and ships the new signup package (plan:docs/owner-panel/PHASE-51-OWNER-PANEL-PLAN.md; grounded in a 4-agent recon + 10-agent orphan-route triage). (OP-1) Welcome email — the first one the platform ever sends: idempotentPOST /api/tenant/[tenantId]/onboarding/completestampsonboardingCompletedAtserver-side and sends ONE branded email with the two links every owner needs — the admin panel and their resolving live site (tenantLiveSiteUrl, never the unwired vanity subdomain) + a plan-driven trial line; called by BOTH wizards so /register, the landing CTA and the WOW magic-link claim all converge; the public wizard's fake "verify email" phase (no email was ever sent; resend was cosmetic) is REMOVED and its dead<subdomain>.restora360.comlink replaced with the resolving URL. (OP-2) Entitlement-aware sidebar: every gateable nav item carries afeatureId;resolveNavFeatureSetresolves the tenant's PLAN set without the trial-all-access window (a trialing owner sees their plan's nav — deep links to trial extras still work; day-8 no longer looks like the product broke), fails OPEN on errors; sidebar, sidebar search, and Quick Links (add-grid + stored bookmarks, rows preserved) all filter identically; superadmin unfiltered. ~20 nav-coverage features minted atbasic/0 SEK (zero regression for existing tenants; re-tierable in Pricing Studio without code); also fixes a pre-existing leak where sidebar SEARCH exposed admin-only entries to every tenant. (OP-3) New IA: compact primary (Overview · Quick Links · Orders (renamed from "TV & Displays") · Customers · Add-ons) + 12 job-shaped groups (Reservations / Menu & Combos / Food Bazar / Marketing & AI / Website & Content / Operations (in-venue) / Inventory & Suppliers / Reports & Money / Restora Profit / Chains & Groups / Account & Settings / Help & Support); 10 off-catalog routes triaged (3 already-correct redirects kept, 6 deliberate sub-tools of Receiving/inbox documented,/dashboard/features"Your Features" added to the catalog); the missingLandmarknav icon registered. (OP-4) Connections hub (/dashboard/connections+ aggregation API): Google Business, socials, accounting provider, Stripe Connect payouts, paired terminals, delivery platforms, commerce channels and the custom domain in one place with Connect/Manage CTAs — connect-state is NEVER hidden by plan (unlike entitlements), an owner can always find where to connect. (OP-5) Add-ons front door: the marketplace machinery MOVED (cut, not copied) out of/dashboard/billingintocomponents/dashboard/addons/AddonsMarketplacemounted at the always-visible/dashboard/addons(suites + à-la-carte + active add-on management + the Stripe-return finaliser; checkout success/cancel URLs updated); Billing keeps plan + invoices + a link card;requireFeature's 402upgradeUrland the upsell CTAs now point at the store. (OP-6) Online Starter 149 SEK/mo: new built-inonline-starter-monthlyplan (7-day trial, explicitincludedFeatureIds= the online set: website/menu/online orders/reservations/Food Bazar/basic marketing/analytics/payments + the owner's own books & payouts; NO in-venue ops — that's the upgrade story to Basic 299);platformSettings.signupPlanId+resolveSignupPlannow drive register, WOW claim, Launchpad,/register/payment(charges the plan's REAL price), the register-page copy, the onboarding "plan after trial" line, the day-4 trial email and the KeepAccountBanner — killing four hardcoded "299 kr" copies and the old register-trials-on-basic-annualvs WOW-trials-on-basic-monthlymismatch (trial LENGTH now also comes from the same plan);SubscriptionRecord.planIdloosened to string (any resolvable PlanDefinition id). Verified: tsc 0 · 4,004/4,004 tests ·next buildclean · 43/43 Playwright checks against the running dev app (premium+grants / fresh-starter / superadmin-cookie sidebars, addons + connections + billing pages, search-leak fix, register→starter provisioning, onboarding-complete idempotency) · welcome email observed end-to-end in the dev mailer with resolving links and "149 kr/mo (Online Starter)". NOTE (deliberate): landing-site marketing copy still advertises Basic 299 (true — the plan exists); repositioning the public pricing story around the 149 starter is an owner copy decision.
Fixed
- Super-admin dark mode — closed the override-sheet gaps that left faint text + light cards on the dark canvas. The unified admin theme (
theme-css.ts) re-skins Tailwind classes per mode, but three common classes weren't in its vocabulary, so any page using them broke in dark (e.g. the new Import-Learning pages, and other sections): (1)text-gray-400/text-gray-500weren't remapped in dark → muted labels rendered near-invisible (now mapped to readable inks#9aa6ba/#8c98ab); (2) colored-50/-100opacity tints (bg-indigo-50/50,bg-emerald-50/…) only matched the exact class, not the/opacityvariant → a light card sat on the dark page (now the[class*="bg-…-50/"]variants map to the dark tint); (3)bg-white/opacityglass panels weren't remapped (now → the dark card surface). One systemic change themes every admin island at once, no per-page edits; original dashboard rules untouched. Verified the generated dark CSS now emits all three.
Added
- Phase 50 hardening — plan gating, Guardian alerting, group (HQ-connects-once) scope, Explain button (the four airtightness gaps). (1) Plan gating: new
accounting-syncfeature (Premium+ by default, 49/mo add-on for Basic, super-admin grant/retier without code — plan §13 tier decision stays open) wired into every accounting write route (requireFeature→ 402 with upgrade metadata), the status feed (featureAvailable→ the page renders an upgrade panel) and the dispatcher (an un-entitled connection neither plans nor dispatches — nothing accumulates, everything resumes on upgrade). (2) Guardian: newintegrationincident source + anaccounting-syncservice row; the probe cycle now reads the LIVE sync stores and opens stably-fingerprinted incidents for dead-letter backlogs (P3, P2 at ≥10 stuck documents) and reauth-required connections — folding into one incident instead of one per tick, riding the existing anti-fatigue alerting; a store read failure yields no candidates (monitoring never takes the monitor down). (3) Group scope: a group HQ can tick "Connect for the whole group" — one org-scope connection covers every location (the planner now takes a tenant-id SET and each task carries the event's tenant; branch documents build from the branch's own doc/currency; gates = the deterministically-resolved HQ's mapping+consent — one legal company, one chart, one consent; the 0-or->1-HQ anomaly resolves deterministically). Branches see a "managed by your headquarters" card; every resolver is org-aware (own connection wins, else the group's). (4) Explain button on stuck tasks calls the BK-11 narration (deterministic fallback inline). Live-verified: org context correct for standalone tenants, premium tenant passes the gate, tick green, Guardian probe runs the accounting feed with a clean healthy-queue negative. tsc 0; 10 new tests; full suite 4,004/4,004.
- Phase 50 BK-11 — the accounting assistant: DETERMINISTIC hints always, AI narrates only (closes the buildable scope BK-0…BK-11). The docs/bookkeeping.md "Fortnox rejected invoice — AI explains why, suggests fix" built the Restora-Profit way:
deterministicHint(lib/accounting/assist.ts) is a PURE rule table over the queue's error taxonomy — mapping-unapproved / consent-missing / unknown-account (carries the exact account number into the fix) / unbalanced / ordering / reauth / rate-limited / wave-scope / generic — every failure gets a summary AND a concrete next click, no AI key required. Hints ride inline on stuck tasks in BOTH the tenant sync panel and the admin dead-letter list;POST /api/accounting/sync { action:'explain' }adds an optional warm AI narration (voice-concierge narrate pattern: budget-gated via the AI registry, metered as assistant usage, system-prompt forbids new numbers/accounts, ANY failure falls back to the deterministic text). Nothing auto-repairs — fixes stay the owner's click. Live-verified: explain returned the correct wave-scope rule withusedLlm:falseon this box's invalid AI key — the fail-safe demonstrating itself. tsc 0; 3 new tests; full suite 3995/3995.
- Phase 50 BK-10 — the Accounting Integration Center (super-admin).
/admin/dashboard/accounting(new sidebar entry under Platform & System, dashboard chrome = shared admin theme for free) +GET/POST /api/admin/accounting/overview: the master kill-switch as a one-click card, the provider catalogue with per-provider auth kind / markets / verified rate budgets / app-credential presence (not set = mock mode), every tenant connection with its gate state at a glance (mapping approved + consent current, demo badge), per-connection queue health + posted-document counts + last sync, cross-tenant totals, and a dead-letter list with admin REPLAY + a run-tick-now button — the cross-tenant connect/health/retry controls the old read-only Integration Monitoring page never had. Super-admin only, audit-logged, token material never crosses the boundary; tenant gate reads fail closed. Live-verified: overview feed correct for the luigi demo connection (both gates green, 4 posted), page renders 200, admin run-tick executes. tsc 0.
- Phase 50 BK-9 — expense sync (wave 2): one-off owner-entered costs become expense documents. A NEWLY-created, fixed-amount,
one-offcost-ledger entry (a real purchase — oven repair, equipment) now emitsexpense.recordedfrom the profit costs route in the same mutate, plans anexp_<id>task, and posts to the provider on the mapped purchases account. Honest boundaries kept: recurring/pct-of-revenue entries stay P&L ESTIMATES and are deliberately never posted as documents; updates never re-emit; input VAT is never invented (the owner-entered amount posts gross,vatAmount: 0— receipt-level VAT splitting belongs to a future receipt-scanning wave); and a provider capability that genuinely doesn't exist (unsupported, e.g. expense push on Fortnox/Visma pending their sandbox wave) now becomes a visible skip instead of a dead-letter — nothing a retry or human can fix there. Live-verified: a 1,250 SEK one-off cost added through the real profit API flowed tick → demo provider expense #24935. tsc 0; 2 new tests; full suite 3992/3992.
- Phase 50 BK-8 — real Fortnox + Visma eEkonomi adapters (fixture-tested, LIVE-GATED). The first two real provider implementations behind the universal contract, built on an injected-transport pattern so every behavior is unit-tested against fixtures and no test touches a real API: shared HTTP plumbing (
providers/http.ts) maps provider semantics onto the queue's error taxonomy (401/403 → reauth-required — Visma's silent password-change death becomes a Reconnect prompt, never a retry loop; 429 → rate-limited with Retry-After honoured; 404 → retriable ordering not-found; 400/422 → validation dead-letter; 5xx → provider-unavailable). Both adapters use SEARCH-FIRST idempotency (neither API has idempotency keys — our deterministic externalRef rides the document reference fields and is looked up before create; a hit returnsduplicate:true, matching the mock's contract exactly). Fortnox: v3 wrapper objects, customer ensure-then-invoice, invoice payments resolved by reference; Visma: eAccounting v2$filtersearch, credit-invoice flag, per-invoice payment posting. Wiring is a side-effect register module (providers/register.ts) imported by the dispatcher/bootstrap — which changes NOTHING until a connection actually reaches sandbox/live mode, a path that stays quadruple-gated (kill-switch → provider app creds → BK-7 consent → approved mapping). ⚠ Exact FIELD mappings are explicitly marked SANDBOX-VERIFICATION-PENDING — validating them against real Fortnox/Visma test companies is the BK-12 go-live gate by design. tsc 0; 6 new fixture tests; full suite 3990/3990; live mock tick unaffected after wiring.
- Phase 50 BK-7 — auto-posting consent (the deliberate TC-0 extension) + first-class CREDIT NOTES + document fidelity. (1) Consent (
lib/accounting/consent.ts+GET/POST /api/accounting/consent+ a consent card on/dashboard/accounting): TC-0's "Restora prepares, never files" is extended with its OWN versioned acceptance — the owner explicitly approves that Restora creates DRAFT documents in their connected accounting system while they remain responsible for review/filing; a terms-version bump invalidates old consents.canAutoPostis now THE pure dispatch gate: mapping approval always; a CURRENT consent + a non-demo tenant before anything non-mock (demo restaurants can NEVER post to a real system); mock stays consent-free. (2) Credit notes become a first-class books document (lib/bookkeeping/credit-note.ts+TenantDB.creditNotes— the object the recon confirmed existed NOWHERE): every refund now records a numbered kreditnota (per-tenant yearly seriesKN-<year>-<seq>, amounts split at the ORDER's dated VAT rate with the recompute's honesty flags) in the SAME lock as the refund's ledger row, emits the firstcreditnote.issuedevents, rides the bookkeeping API response, and correlates the sync push (docNumber= KN number; invoices carry the order number the same way). (3) Robustness fix caught by live QA: payments/credit-notes now ensure their referenced invoice provider-side before pushing (idempotent by externalRef — a duplicate returns the existing doc), andnot-foundis classified as a retriable ORDERING condition instead of fatal — this heals both real-provider edge cases (doc deleted provider-side) and the demo provider's restart amnesia. Live-verified end-to-end: consent recorded (version-stamped, audited) → 5 SEK refund created KN-2026-0001 (6% dated split) + queued its event → tick initially dead-lettered on the pre-fix amnesia → REPLAY + ensure-invoice landed it as provider doc #25202 — the replay facility proving itself on a real failure. tsc 0; 12 new tests; full suite 3984/3984.
- Phase 50 BK-6 — the sync engine: durable queue + dispatcher + external-id ledger + REPLAY + the platform cron scheduler (the keystone). The "sync forever" half of connect-once, and the fix for the confirmed 37-of-42-crons-never-fire prod gap. (1) Queue core (
lib/accounting/sync/core.ts, pure, RAOS-bus-shaped): per-connection FIFO with at most one running task (invoice-before-payment ordering is an accounting invariant), retry backoff [1,5,30] min, fatal validation errors dead-letter immediately, replay requeues dead-letters with a fresh budget (a facility that existed nowhere in the platform), deterministic task refs (inv_<orderId>/pay_<ledgerId>/cn_<ledgerId>) so replans and overlapping emissions collapse; non-sale money (canteen top-ups/B2B) and order-less charges become VISIBLE skipped tasks, never silent drops. (2) Isolated store (databases/accounting-sync.json— tasks + the external-id ledger ("our doc X = provider doc Y") + per-connection cursors into the append-only domain-events store; pg-migrated for free). (3) Dispatcher (dispatcher.ts): tick = inlinedrainOutbox()nudge (the zero-caller relay parameter) → plan from events since cursor → claim/execute/outcome outside the store lock; hard fail-closed gates all surfaced in the result — kill-switch, connection status, owner-approved mapping required, non-mock modes blocked until the BK-7 consent ships, mock money never posted to real books; per-connection sync history on the connection card. (4) Platform self-hosted cron scheduler (lib/cron/platform-scheduler.ts+ instrumentation switch): the RIE-only scheduler generalized into a neutral, data-only job registry — 5 intel jobs carried 1:1 (no double-fire) PLUS the set that never ran on the box before (domain-events relay, guardian probe, email campaigns, social autopilot, accounting sync); legacy env honoured. (5) Wave-1 document builders (order→invoice, charge→payment, refund→credit-note; BK-4 recompute + approved mapping; BK-7 upgrades fidelity). Routes:GET/POST /api/accounting/sync(status/run-now/replay, audit-logged) +GET /api/cron/accounting-sync(vercel.json*/10); a Sync-activity panel with per-task Replay on/dashboard/accounting. Live-verified end-to-end on dev through the real POS route: cash-settled a seed order → outbox event → tick planned invoice+payment in FIFO → both pushed to the demo provider (invoice #87222, payment #34228) → external-id ledger recorded; re-tick planned/pushed ZERO (cursor + dedupe); an order-less refund surfaced as a visible skipped task. tsc 0; 15 new tests; full suite 3976/3976.
- Phase 50 BK-5 — financial event-spine completion: EVERY money movement now emits a transactional outbox event. Before this, only 4 of ~15 payment-ledger call-sites emitted domain events — Stripe/Swish webhook confirmations, PayPal captures, the tap-to-pay terminal, table-pay, catering deposits/balances, bar-tab settles, canteen top-ups/B2B and ALL refund paths wrote money rows the event spine never saw (and
order.refundedwas declared but emitted nowhere). One new helper (lib/events/ledger-events.tsemitLedgerOutboxEvent) now rides INSIDE the same row-locked mutate as every ledger write across all 13 production sites: a newly-created paid charge →payment.captured, a refund →payment.refunded, each anchored on the ledger row id (ref/meta.ledgerEntryId— the deterministic task key the BK-6 sync queue will dedupe on, which also makes historical overlapping emissions harmless), withcreated:false(webhook redelivery / retried confirm) never re-emitting — the emission inherits the ledger's idempotency for free. The main refund route additionally emits the first-ever `order.refunded` exactly once, on the row that flips the order fully refunded.DomainEventTypegainedexpense.recorded+creditnote.issued(declared for BK-9/BK-7). PII posture preserved (ids + amounts + scalars only). Live-verified on dev: a real API refund landedpayment.refundedon the tenant doc's outbox transactionally (anchored + charge-linked), and a retried identical refund produced NO second row and NO second event. tsc 0; full suite 3961/3961 (one POS test updated to the new intended two-event contract + a new no-re-emit test).
- Phase 50 BK-4 — mapping engine + sync-time VAT recompute + the unified CoA registry (also FIXES the latent zero-VAT books defect). Three load-bearing pieces: (1) ONE per-country chart-of-accounts registry (
lib/accounting/mapping/coa-registry.ts) from which BOTH historical SE BAS maps now derive —DEFAULT_SE_ACCOUNTS(SIE export) andSE_BAS_ACCOUNTS(revenue-share journal) can never drift apart again; drift-pin tests assert the pre-migration literals verbatim (incl. the deliberate 2641-purchases vs 2640-fees input-VAT nuance, preserved not "fixed"); the full 3955-test suite passes untouched. (2) Sync-time whole-order VAT recompute (vat-recompute.ts):Order.taxAmountis 0 on every real sales channel (VAT-inclusive pricing — verified in the Phase 50 recon), so the Phase 19 books reported ZERO VAT for real orders; the bookkeeping route now recomputes per order (Order.type→VatCategory→ datedresolveVat, counter=dine-in, stored positive tax trusted, unsupported jurisdictions flagged never guess-stripped, purchases NEVER recomputed — input VAT is not invented) and CSV/SIE/P&L/VAT-return all inherit the fix through the one wire-in. Live-verified on seed: luigi's books went from 0 to 3,353.93 SEK output VAT across 77 sales, recomputed rows landing on the dated 12%/6% SE rates. (3) Mapping engine + owner approval (engine.ts+GET/PUT /api/accounting/mapping+ a mapping panel on/dashboard/accounting): registry defaults layered under per-tenant overrides, DETERMINISTIC suggestions against the BK-3 imported chart (exact/missing — no AI decides anything), one-click Approve, and an append-only old+new-valueMappingHistoryEntrytrail where a change after approval re-opens review. Live-verified end-to-end: 14/14 exact matches after refresh, approve → override (reopens + history before/after) → reset + reapprove. New TenantDB additive fieldaccountingMapping. tsc 0; 20 new tests; full suite 3955/3955.
- Phase 50 BK-3 — post-connect company bootstrap ("the system configures itself"). Every connect now auto-pulls (fire-and-forget, guarded — a bootstrap hiccup can never fail a connect) the provider's company identity, REAL chart of accounts, VAT codes and fiscal years into an
AccountingBootstrapCacheon the connection — the snapshot the BK-4 mapping engine will suggest against, so the owner only ever clicks Approve.POST /api/accounting/bootstrap= "Refresh company data" (also a button on the connected card, which now showsN accounts · M VAT codes · K fiscal years+ org number). Failure posture: company-info failure fails the refresh but KEEPS the previous good snapshot with the error recorded beside it; unsupported list-reads degrade to empty lists. Public views expose counts + fiscal years, never raw dumps. Live-verified on dev: bootstrap over the demo connection returns 13 BAS accounts / 5 VAT codes / 2 fiscal years and the status API serves the summarized view. tsc 0; 4 new tests (53 accounting total).
- Phase 50 BK-2 — `/dashboard/accounting` one-click connect UI + the OAuth route quartet (audited). The tenant-facing face of the accounting platform: a provider grid for the tenant's market (SE sees Fortnox/Visma + globals; NO sees Tripletex — driven by the def-table country map) with three connect shapes — Google-style OAuth redirect (
POST /api/accounting/connect→ authorize URL →GET /api/accounting/oauth/[provider]/callback→?connected=1bounce), paste-credential form for token providers (POST /api/accounting/credentials, secrets encrypted, never echoed), and a one-click Restora Books demo connect — plus a connected status card (mode badge Demo/Sandbox/Live, status pill incl. an explicit Reconnect-needed state with a reconnect CTA — the expired-token UX the Google integration never had), sync-history list and disconnect. Flag-gated by the master switch ({ skipped:true }→ not-available panel; nav stays visible per repo convention). New nav entry beside Bookkeeping.ActivityTypegained an additiveintegrationmember; connect AND disconnect write both audit tiers (master audit log with IP + tenant activity feed) — fixing the documented Google-integration anti-pattern. Verified END-TO-END against the running dev app: flag OFF →{skipped}; admin flip → provider grid; demo connect → record created; Fortnox mock OAuth → 302?connected=1&mode=mock, connection REPLACED in place (one-per-legal-entity invariant held live), refresh token AES-GCM-encrypted on disk, zero token fields in any API response; disconnect → revoked + reconnect works; both audit trails carry all four events. tsc 0; page renders 200.
- Phase 50 BK-1 — accounting connections + token vault + rotation-safe refresh (all 4 authKinds). The connect layer under the universal accounting platform: a new isolated
databases/accounting-connections.jsonstore (lib/accounting/connections-db.ts, Google-store clone with a scope key —tenant:<id>default,org:<id>for chains — one ACTIVE connection per LEGAL ENTITY; pure mutation core unit-tested, reconnect preserves id+sync-history, resolver = own-connection-wins-then-org,toPublicConnectionnever leaks token material). Provider-parameterized OAuth (connect/oauth.ts: declarative endpoint/scope/env config table for Fortnox/Visma/Xero/QBO, HMAC stateless state + PKCE, mock-mode-first with RELATIVE mock callbacks; mock refresh deliberately ROTATES the refresh token every call so Fortnox semantics are exercised on seed). The rotation-safe token service (connect/token-service.ts) fixes what Google's pattern gets wrong for single-use tokens: cached access tokens (ordinary calls never touch rotation), per-connection single-flight refresh, compare-and-set persistence of the rotated refresh token (CAS mismatch adopts the other writer's state instead of clobbering — never bricks a Fortnox connection), grant-level rejections → formalreauth_requiredstatus that short-circuits instead of retry-looping, session-minted (Tripletex) mint-and-cache with an injectable minter. Connection lifecycle facade (connect/service.ts): redirect / paste-credentials / one-click-demo connect shapes, fail-closedsandboxunlessACCOUNTING_LIVE_MODE=live, e-conomicdemo/demo→ mock-mode connection, disconnect wipes token material and keeps arevokedrecord for history. Verified: tsc 0; 26 new tests (49 accounting total) incl. a concurrency test proving exactly one refresh under parallel callers.
- Phase 50 BK-0 — Universal Accounting Integration Platform: provider contract + registry + mock provider + master kill-switch. The keystone of the connect-once accounting platform (plan:
docs/products/accounting-integrations/PHASE-50-UNIVERSAL-ACCOUNTING-INTEGRATION-PLATFORM-PLAN.md): ONE provider-agnosticAccountingProviderAdaptercontract (lib/accounting/types.ts— uniformCallResultenvelope with a typed retriable-vs-fatal error taxonomy the sync queue will key on) + a declarative provider def table (provider-defs.ts— Fortnox/Visma eEkonomi/e-conomic/Tripletex/Xero/QBO with verified authKinds and rate budgets: Fortnoxoauth-rotating25 req/5s, Vismaoauth-stable600/min, e-conomicstatic-grant, Tripletexsession-minted, Xero 5k/day cap) + a fail-loud resolution seam (registry.ts, mirrorspayments/registry.ts: mock serves everything on seed, sandbox/live throws until a real adapter registers in BK-8 — never silently mock-posts real books) + the full "Restora Books" demo provider (mock-provider.ts: BAS chart aligned with the two existing SE account maps, Swedish VAT codes, strict idempotency — sameexternalRef→ sameremoteId+duplicate:true— balanced-legs/consistent-totals validation, payments reject unknown invoices to exercise BK-6 FIFO ordering; hard-blocked from ever resolving outside mock mode) + theaccounting_sync_enabledmaster kill-switch (flag.tsclone of the ITA pattern, fail-safe OFF, strictly binary) withGET/POST /api/admin/accounting/master-switch(super-admin, audit-logged). Verified: tsc 0 project-wide; 23 new node:test cases pass.
- Import-learning loop (Phase IL) — foundation + capture (shared by menu imports AND WoW web clones; one anonymous, platform-wide store, no parallel engine). Every import now teaches the next one: the system records the delta between what the AI extracted and what the owner approved, so future imports get less defective and cover more scenarios. This phase lands the substrate + capture; distillation → replay → coverage dashboard follow.
- Shared substrate — new
ImportLearningSignal/ImportItemDelta/ImportScenario/ImportCorrectionRuletypes +MasterDB.importLearningSignals|importScenarios|importCorrectionRules(signals capped to a rolling 500; scenarios are tiny per-bucket rows).CloneRecord.issuesnow links each clone's deterministic migration defects into the same loop. - Pure signal engine (lib/import-learning/signal.ts) — diffs the AI extraction against the owner-approved menu: pairs items (exact then token-overlap so a renamed dish isn't counted as drop+add), and records structural deltas only — a name edit's kind (cased/cleaned/reworded/other), a price edit (keeping the printed format text like85:-because that's the biggest parse signal, never restaurant-identifying), dropped items, and per-item confidence. Best-effort currency + language detection feeds asource × language × currency × cuisinecoverage key. 8 unit tests. - Anonymous by construction — no raw dish/description text is ever stored; learning is platform-wide but only as patterns, honouring the "never copy one restaurant's data into another / never fabricate" rule. - Wired both sources, fully guarded — the authenticatedmenu/ai-importroute captures the delta fire-and-forget (the review modal now snapshots the raw extraction, crops stripped, and sends it); the WoW clone path (recordClone) links its migration issues + folds a coverage signal into the shared store. Any capture failure is swallowed — it can never affect a real import or clone. - Self-improving core — distil + apply (`distill.ts` + `apply.ts`). Signals are distilled (≥3-agree threshold, mirroring the clone loop) intoImportCorrectionRules: recurring price formats per currency (the Swedish85:-/ comma-decimal / space-thousands patterns), a scenario's dominant currency, and a learned confidence-gate (when owners kept correcting items the model was confident about). Distillation runs in the same guarded mutation as capture; it preserves curated rules + learned hit counts and drops rules that fall back below threshold. Rules are applied as a SAFE, fixed-template hint block appended to the extraction prompt — format/currency guidance only, never tenant content, always carrying an anti-fabrication guardrail — selected by the import's pre-extraction context (source + the tenant's own currency, both known up front) so future imports make fewer of the mistakes owners previously fixed. Themenu/ai-extractroute fetches + injects the hints and credits a hit per applied rule;runMenuExtraction/extractMenuFromTextgained an additivelearnedHintsseam (empty = byte-identical behaviour). Rules never rewrite an owner's prices/names/allergens — they only change how the model is guided and what gets flagged. - Verified: tsc 0; eslint 0 on changed files; 135 tests (import-learning + menu-import + wow) pass (17 in import-learning). Note: the hint's effect on extraction is inherently probabilistic (LLM-in-the-loop) — the plumbing/selection/rendering is fully unit-tested; real-menu accuracy uplift is a go-live validation. - Strategy EXECUTION (Phase IS) — a taught strategy actually fetches the menu.execute-strategy.tsturns a matched handling strategy into a real menu:resolveSourceUrlsfinds the taught source on the page (by the generalisable locator), then the existing extractors go get it —fetchPdfDataUrlfetches + (one hop) follows a PDF‑viewer plugin's embedded PDF, incl. dearpdf's JSON‑escaped config URL (findEmbeddedPdf), thenrunMenuExtractionOCRs it; sub‑pages go throughextractMenuFromText. Wired intorun-url-cloneas a best‑effort job: detect signature → match a learned strategy → execute → the strategy menu leads the richest‑menu pick (a taught PDF is the source‑of‑truth).safeFetchgained abuffer()for binary payloads. Bounded (≤3 sources, PDF size cap) + fully guarded. Live‑verified on 400grader.nu: the clone now resolves the dearpdf link → follows it → extracts the embeddedMENY‑…‑.pdf→ fetches the real 495KB PDF; the OCR step then returns401 invalid x-api-keybecause THIS dev box's Anthropic key is invalid (a known box issue) — on a box with a valid key it OCRs the menu. 6 tests (resolver + escaped‑PDF config). tsc 0, eslint 0, 36 import‑learning tests. - Teach‑the‑importer UI (Phase IS) — point at the menu, don't type./admin/dashboard/import-learning/teach: paste a URL →strategy/analyzefetches the page (no AI cost), shows what the system sees (CMS, plugins, JS‑vs‑HTML, text density, archetype key) + a plain‑language diagnosis of where the menu likely hides + any already‑learned strategy → and pre‑detects the likely menu sources (extractSourceCandidates: the PDF link, sub‑pages, the third‑party ordering host) as one‑click cards (PDF/external pre‑selected, anchor‑noise filtered). You tick the real source(s) — optionally add one the detector missed — and "Teach the system" saves a generalisable strategy (strategyroute →recordStrategy), reinforcing the bucket for that signature. A live "what the system has learned" list. An in‑page "How to teach" guide keeps the input structural — teach stable patterns that recur (.pdf,weiq.app,lunch), not one‑off URLs or random words — with a live hint showing whether a manual pattern will match by URL‑part or link‑text. Verified end‑to‑end on the real 400grader.nu: analyze → detectswordpress|pdf+ext+ the dearpdf PDF + weiq candidates → teach → re‑analyze returns a strong match. tsc 0, eslint 0, 32 tests. Next: the fetch‑execution layer (actually go get the taught PDF/sub‑page and OCR it). - Teachable clone-HANDLING strategies (Phase IS) — the learning brain. The URL/clone import fails on most real sites because the menu isn't homepage HTML — it's a PDF, an image, behind tabs, on a sub-page, on a third-party ordering service, or JS-rendered (diagnosed live on 400grader.nu: the clone got 0/‑menu items because the à-la-carte is a dearpdf PDF + a weiq.app SPA). Rather than hardcode a handler per site, this is the foundation of a system you TEACH once per site-type and it generalises:detectSiteSignature(pure) fingerprints a site structurally (CMS, plugins, SPA-vs-HTML, text density, PDF/menu/ordering links);signatureKeygroups by handling archetype (e.g.wordpress|pdf+ext);matchStrategyapplies a learned strategy to a new site (strong when the archetype matches, weak on partial overlap);recordStrategyteaches/reinforces aMenuSourceStrategy[](kinds: pdf / images-ocr / tabs / sub-page / external-service / page-text / homepage-vision) into the dedicated store, reinforcing (taughtCount++) the same bucket so a second dearpdf pizzeria strengthens one strategy instead of fragmenting. Types +strategiesadded toImportLearningDB; 4 tests over a 400grader-like fixture; tsc 0, eslint 0. Next: the teaching UI (point it at the real menu per site) + the fetch-execution layer (reuse the existing PDF/screenshot/text extractors to actually go get it). - Export path — trained rules ship as code (`trained-rules.ts`). So a training campaign's knowledge travels to production via git, not a fragile runtime-DB copy.GET /api/admin/import-learning/export(super-admin) renders the exact replacement forsrc/lib/import-learning/trained-rules.ts(learned rules frozen to curated, hits zeroed, seeds dropped, sorted) — JSON summary or?format=filedownload; a dashboard "Export rules ↓" button. The bundledTRAINED_RULESload at read time between the seed defaults and the live store (seed < trained < store), so a fresh deploy starts already-smart. Pureexport.ts(normalize + file-render) with 3 tests; tsc 0, eslint 0; live-verified (endpoint returns valid file content). - "Train the importer" surface (super-admin, tenant-less). A pre-launch training tool at/admin/dashboard/import-learning/train: drop in a menu (photos/PDF) or a menu URL → the SAME extractor runs (with learned hints applied) → correct the reading (fix prices/names, untick invented items) → record. Two routes (train/extract+train/record, super-admin-gated) run extraction and capture the extracted-vs-corrected signal into the dedicated store WITHOUT creating a tenant or a menu — pure "analyse → correct → teach, one at a time", so a rich rule set can be built before go-live. Confidence-flag threshold is the learned one; a running signal/rule counter + dashboard link. tsc 0, eslint 0; live-verified (extract responds, record writes a real signal to the store). - Dedicated persistent store (`databases/import-learning.json`). Moved the signals/scenarios/rules OUT of the hotmaster.jsondoc into their own store (ImportLearningDB+getImportLearningDB/mutateImportLearningDB, Postgres-doc + fs-file with a write lock), so a long pre-launch training campaign accumulates cleanly, survives deploys, and isn't fighting the master doc's size. The rolling signal cap was raised 500 → 5000 (distilled rules persist beyond it regardless). Store + admin route + distiller rewired to the new DB; tsc 0, eslint 0, 24 import-learning tests pass; live-verified on the dev box — the store file is created + written on a real import, the dashboard reads it, and the cutover from the old master location works. - Day-one usefulness + clone coverage + review flagging. The loop no longer waits for ≥3 organic corrections before it helps: a small set of curated seed rules (seed-rules.ts— SEK85:-, EUR/NOK/DKK comma-decimals) is merged in at read time so a brand-new tenant gets the right format hint on its first import; a seed materialises into the DB (with hit tracking) the first time it's actually applied, and shows in the dashboard from day one. The WoW clone path now consumes the same hints —run-url-cloneinfers the currency from the site's country-code TLD (.se→SEK, generic TLDs → source-only) and feeds learned hints into both clone extractors (text pages + vision tiles). And the learned confidence-gate is now deterministic, not just a prompt nudge:ai-extractreturns a learnedreviewBelow(computed post-extraction once language is known) and the review modal flags items below that threshold instead of a hardcoded 70. Verified: tsc 0; eslint 0; 142 tests (5 new); live smoke — admin/ai-extract 401, wow-from-url 400 (all compile + respond).- WoW import — Wave 1 accuracy + "Migrate MY site" mode (reuse-and-advance of the existing clone/menu-import path; behind `wowFunnel`, default behaviour unchanged). Closes the biggest "this isn't my site" gaps the recon flagged ("vibe clone, not a migration"), by wiring already-built machinery into the clone path — no parallel engine.
- "Migrate MY site" mode (W2.6) — new additive
WowPreview.migrateMode(default false = the trademark-safe competitor-clone behaviour, byte-identical). The Health Check rebuild (HealthCheckClient) now sendsmigrateMode:truebecause the owner just audited their own site, so the claim imports the real brand name/tagline/contact/hero instead of an invented name + Stockholm demo contact + stock hero:claim.tsskips the demo-contact clobber whenmigrateMode(realmenu.infocarried byapplyPreviewThemeToTenantsurvives);from-urlaccepts themigrateModebody flag. - Full-page Design DNA (W1.1) —generateDesignDnanow reads the whole scroll-and-shoot page (newimageDataUrls[]), not just the hero, sosectionOrderreflects below-the-fold design instead of the model guessing. Single-screenshot path (imageDataUrl) unchanged. - Real-pixel palette anchors (W1.2) — the deterministicpalette.tssampler (dominantAccentColor/averageColor, no AI) reads the hero's real pixels and passes them as anchors in the DNA prompt, so the palette is grounded in the brand's actual colours rather than a one-shot mood-guess. - Real hero image (W1.5) — newderiveSiteMeta()extracts the owner'sog:image; used as the page hero only in migrate mode (never reuse a competitor's image), with the curated cuisine stock as the fallback. Also imports the real meta-description as the hero tagline in migrate mode. - Allergen suggestions at import (W1.3), safety-gated — the clone import now computes deterministic EU-14 allergen suggestions (suggestItemAllergens, no AI) and stores them as the unconfirmedProduct.suggestedAllergens— never written to liveallergens. The product editor surfaces them as tap-to-confirm chips; the storefront still reads onlyallergens, so nothing is asserted without the owner's OK. - Photo quality at import (W1.4) — newscoreProductPhotosInBackground()(bounded concurrency, budget-capped, fail-open) runs the broken-image + quality verdict on freshly imported photos at clone-claim and the authenticatedai-import, so expired/broken stock photos get held instead of shipping live. - Verified: tsc 0; 182 tests (wow/menu-import/menu/bazar) pass; eslint 0 errors on changed files.next buildruns on the Linux deploy server (this box OOMs). Allergen never-LLM + flag-for-review-not-invent rules preserved. Remaining Waves 2–3 (JSON-LD free-tier reader, DOM/CSS design reader, image relevance-gate, confidence/migration-report + review gate, full-site crawl/multi-page, SEO redirects, render-compare) tracked separately.- AI budget transparency page + custom top-up amount (owner-facing spend breakdown; every top-up itemized on the invoice). Extends the AI budget pill so owners can see exactly where their AI money goes and top up any amount they like — not just the fixed Small/Medium/Large packs.
- Transparency page `/dashboard/ai-spend` (new "AI Spend" item under Account, plus a "See where your AI budget goes →" link from the budget pill). Shows: this month's remaining/used/limit hero (same figures as the pill); "Where your AI budget goes" — a per-feature breakdown (AI Assistant, Menu Photo Import, Social Posts, CFO Advisor, …) with owner-currency amounts + request counts + share bars, month-selectable; a 12-month usage trend; monthly + yearly top-up statements (each purchase listed with date, amount, allowance, and its
AITOP-…invoice number) with lifetime totals; and a plain-language "how AI billing works" explainer. New pure aggregation liblib/ai/tenant-spend.ts(aggregateTenantAISpend— feature breakdown, trend, statements, lifetime, all currency-converted) + a strictly tenant-isolated DB wrapper, behindGET /api/tenant/[tenantId]/ai-spend(requireTenantAccess). - Custom top-up amount. Alongside the packs, owners can type any amount (e.g. 1000 kr) in the pill and on the page. NewusdFromOwnerAmountinverts the FX×1.5 markup so the granted USD allowance round-trips back to the price paid ("every 1 kr you add is 1 kr of AI");buildCustomTopupvalidates against a currency-converted band (customTopupBounds). The dev path grants immediately; prod opens Stripe Checkout in the tenant's currency. - Top-ups go directly + show on the invoice. Each top-up is recorded as a paid invoice the moment it's charged (InvoiceRecord.category:'ai-topup'+aiAllowanceUsd, additive) and now appears in Billing → Invoice History correctly currency-formatted and tagged "AI top-up", as well as in the page's monthly/yearly statements. - Fixed a pre-existing PROD gap: the Stripe webhook'scheckout.session.completedhandler ignoredkind:'ai-topup'sessions — a prod top-up charged the card but never granted allowance or wrote an invoice. The webhook now grants + records on payment, idempotently on the Stripe session id (and surfaces abilling.ai_topup.orphanintegrity event rather than silently 200-ing if metadata is unfulfillable). Stripe amounts now go throughtoMinor/fromMinorso zero-decimal currencies (e.g. JPY) aren't overcharged 100×. - Verified: 21 new tests (top-up math/round-trip/bounds + tenant-spend aggregation incl. mixed-currency safety); tsc 0; eslint 0 (changed files). Adversarially reviewed (5 dimensions × verify) — 12 confirmed findings fixed, incl. makingrecordTopupPurchase's check-then-write atomic viamutateMasterDB(no double-grant/lost-update under concurrent webhook retries; rolls the invoice back if the allowance grant fails), currency-safe statement/lifetime totals (never cross-sum currencies; off-currency entries counted + noted), and a full a11y pass on the new controls (input labels, live-region banner,aria-expanded/aria-pressed, touch-readable trend bars, integer-only custom input).- Customer order-tracking ("live order status") screen — 1:1 redesign from the design handoff (3 owner-selectable templates, brand-themed, real data). Rebuilt the customer "waiting for your order" screen (
/track/[tenantId]/[orderId]) 1:1 fromdocs/Order Status Redesign, as three interchangeable visual directions sharing one data interface: A · Wood-Fired (default — circular progress ring + rising steam + clustered dish preview with "+N" overflow + live ETA countdown + itemized list), B · The Journey (a travelling bag/scooter token bobbing along an animated road between status stops + live activity feed), C · Big Moment (dark espresso card, flickering flame, glowing shimmering progress bar). New shared enginelib/order-tracking/—types.ts(oneTrackingViewModelcontract),theme.ts(resolveTrackingTheme()maps each restaurant's brand — preset or premium 9-token palette — into concrete colours with the warm "Luigi's" palette as fallback; pure colour utilsmix/luminance/contrastRatio/readableOn/accentText/rgba),model.ts(step model, status→copy maps generalized for multi-tenant, live ETA, the brand-accent rule, item/hero/activity derivation). Presentational templatescomponents/order-tracking/TrackingTemplate{A,B,C}.tsx+ design-specific bag/scooter icons + scoped keyframes. Wired to the real system, not a parallel demo: the rewrittenTrackerClientfetches the real order, derives the model, runs a single live ETA countdown to a realpromisedReadyAt((preparingStartedAt ?? acceptedAt ?? createdAt) + 20min pickup / 35min delivery, or a scheduled slot), adapts pickup vs delivery labels/icons/final-steps fromorder.type, handles loading / error / canceled-refunded / locked states, keeps the existing refresh/share/copy/cancel actions, and honours prefers-reduced-motion (freezes all loops, transitions stay instant) + anaria-livestatus region. The public order API now returnstype/scheduledTime/preparationTimeMinutes;page.tsxresolves the theme+template and loads Baloo 2 / DM Sans. Owner picker: additiveTenantSettings.orderTrackingTemplate('A'|'B'|'C', default A) persisted via the builderPATCH/GET, with a themed "Order tracking style" A/B/C card picker in the Mason theme panel. Verified: headless render screenshot-compared 1:1 to the prototype across all 5 statuses × pickup/delivery; 49 tests (model + per-template render matrix + contrast regressions); tsc 0. Adversarially reviewed (4 dimensions × verify) — 8 confirmed findings fixed, incl. WCAG-AA contrast on dark/bright brand presets (readableOn/accentTextkeep the white-check 1:1 look on warm/red brands but flip to legible glyphs on amber/dark-red), a non-chattyaria-live(announces the status word, not the per-second countdown), and reduced-motion now also freezing the loading skeleton.
- Fixed (same day): the hero "single vs cluster" decision now keys off total quantity, not line-item count — so a single dish ordered ×2+ (e.g. "2× Marinara") shows the multi-dish cluster (2 small thumbnails + "N items") instead of the single "food photo" hero. The "+N" overflow uses the same unit total so "2 shown + N more = N items" stays consistent. - Real dish photos (same day): the hero cluster thumbnails, the single-item hero, and the itemized-list thumbnails now render each line's real menu photo — joined from the live product by
productIdin the public order API (items[].imageUrl) — with the striped placeholder kept as a graceful fallback when a product has no image. Direction A only (B/C have no dish thumbnails).- Mason builder — container nesting Stage-2 foundation (the 5 layout containers now accept children). The data + adapter + editor-tree already round-trip nested
content.childrenlosslessly (unit-tested); this flipsacceptsChildren:truefor the 5 layout containers (columns / grid / stack / container / section) via an additivedef()param + a newisContainerBlock(type)helper, and updates the catalog test. Pure metadata — byte-identical runtime (nothing renders children yet; the gated recursive render + the "Add inside" editing are the next slices). Verified zero existing tenant/template blocks of these types, so the render slice will be purely additive. 319 mason tests; tsc 0; eslint clean. - Free Growth (Revenue Share) — the two deferred UI mounts wired in (flag-gated, inert until enabled). The RS-0…RS-8 engine + components were already built; this connects them to their surfaces:
RevenueShareBannernow renders atop the tenant Billing page when the tenant is on thefree-growth-7pctplan, andPlanComparison(Free Growth vs flat, with the honest break-even note) renders on the register flow when therevenue_share_billingmaster flag is on — via a new read-onlyGET /api/public/revenue-share/enabled(fail-safe to off). Both are inert by default, so the billing + register surfaces are byte-identical until the offering is enabled. tsc 0; eslint clean. - Phase 37 (Vertical Modules II) — Weigh-and-Pay pricing engine + `Product.weighPricing` (mock-first, parallel-safe). The price-by-weight keystone for self-serve / counter products. New pure
lib/weigh/engine —computeWeighLineItem(gross → tare → net grams × price/kg with market rounding [cents/tenth/whole], every input clamped so a bad scale reading can never charge negative, and an optional min-net floor that applies only to positive sales) +weighCartLine(shaped 1:1 likeCateringLineItem, so a weighed item flows straight into the existing order/ledger path). Additive optionalProduct.weighPricingon the menu model — absent ⇒ ordinary per-unit pricing, zero behaviour change to any existing menu — with alib/weigh/product.tsadapter (isWeighProduct/weighLineForProduct, returningnullso callers fall back tobasePrice).Productis imported type-only, keeping the engine DB-free and unit-testable. WS-2 — owner control: a "Sell by weight" section in the product editor (price/kg + tare + rounding, with a live price preview), the field whitelisted through the products create/update API and sanitized server-side (sanitizeWeighPricingclamps + drops junk) + round-tripped via GET, plus a/kgprice cue on the menu list. WS-3 — weigh-and-pay checkout: the POS PriceQuote engine is now weigh-aware (a by-weight line is priced server-side from price/kg × scale grams; additive — normal items stay byte-identical), the staff POS route acceptsgrossGrams, and a new Weigh Station (/dashboard/weigh+ sidebar nav) builds a weighed ticket and takes payment through the existing born-paid POS pipeline (unified ledger + loyalty + stock; mock until Phase 40). Real scale hardware deferred (Phase 38). WS-4 — self-serve customer kiosk: a public, read-only weigh kiosk (/kiosk/[tenantId]/weigh) where the customer weighs items, sees live prices, and prints a “pay at the counter” slip — it takes no money and writes no order (the staff POS finalises the sale, safe by construction), backed by a reusable receipt model (lib/weigh/receipt.ts) + a read-onlyGET /api/public/[tenantId]/weigh/products. WS-5 — hardening: the public kiosk is now opt-in per tenant (settings.weighKioskEnabled, default off — the page + its public endpoint 403 until the owner enables it), the endpoint is rate-limited (60/min/IP), and the staff Weigh Station gets an owner toggle + the kiosk link. WS-6 — scan-to-recall: the kiosk slip now carries a stateless code + QR that encodes the weighed basket itself (no server write, no public endpoint); staff scan or paste it at the Weigh Station to recall the exact lines — no re-weighing (lib/weigh/slip.ts, checksum-guarded; the station re-prices server-side on charge). tsc 0; eslint clean; weigh + price-quote suites green. Commitscce98d3e(WS-0) ·08830bf3(WS-1) · WS-2 · WS-3 · WS-4 · WS-5 · WS-6 (this entry). - FSN Global Kitchen — 700-dish multilingual launch library + Restora 360 redesign (web-first). Net-new curated content + the start of the FSN visual overhaul. tsc 0 errors.
- Dish library (data): new
DishMaster+DishLibraryDB(lib/db/schema.ts) in its owndish-libraryapp-document, withgetDishLibraryDB/save/mutateaccessors (cached; auto-imports to Postgres viadb:import). 700 dishes = 25 countries × 7 breakfast/lunch/dinner/snack, each with name/description/ingredients/steps in en·sv·da·fi·no, authored per-serving nutrition (flagged estimate — NOT a parallel nutrition engine), dietary tags + allergen advisory. Generated via a multi-agent workflow, committed asscripts/data/dish-library.seed.json; seeded byscripts/seed-dish-library.ts(npm run seed:dishes). - Images — correct + unique on all 700. Hybrid pipeline (scripts/dish-library/images.mjs+images-gen.mjs): real stock (Pixabay·Wikimedia·Openverse) keyword-gated + globally de-duplicated + Gemini-vision-verified (482), then Imagen generation for the remainder (218), each vision-checked and downscaled to ~150 KB JPEG inpublic/global-media/. 0 placeholders, 0 duplicate images. - Surfaces:/fsn/kitchen(discover by country/meal/language + search),/fsn/kitchen/[id](localized recipe + nutrition + schema.org/Recipe JSON-LD),/fsn/eat-today(AI day-plan: kcal target + macro bar + per-meal picks), public/api/fsn/dishes(+/[id]) read API (also for the Android app later), and 75 dishes promoted into the FSN feed as recipe posts by 12 curated home cooks (scripts/seed-fsn-kitchen-feed.ts). A standalone HTML review doc is produced byscripts/dish-library/build-html.mjs. - Restora 360 design system (foundation): scoped.fsn-r360tokens + dark mode (app/fsn/r360-theme.css) ported 1:1 from the design handoff (clay primary, warm-paper Stone, Hanken Grotesk / Newsreader / Geist Mono vianext/font);FsnShellchrome + the Global Kitchen / dish / Eat-today screens re-skinned off the tokens. Remaining FSN screens (feed/reels/profile/articles) get the new chrome now and full per-screen re-skins next.- Phase 36 — Vertical Modules I (Catering · Bars · Canteens): the deferred full feature scope, completing the three gap-audience modules. Mock-first on the Phase-33 ledger; additive; tsc 0; full suite 3405/3405; next build exit 0. The keystone slice of each module shipped earlier; Phase 36 finishes the rest. Built foundation + money-critical engines by hand, fanned the routes/UI out across 3 parallel module agents, then hardened via a 5-agent adversarial money-model review (6 real fixes applied, below).
- Catering — package & menu builder, contracts, BEO, final invoice. New pure engine fns (
packageLineItem,buildContract/acceptContract[click-sign + a non-PII assent fingerprint],buildBeo/sortedBeoTasks/beoProgress,buildInvoiceRef) +documents.ts buildCateringInvoiceModel(reuses the shared pdf-lib faktura renderer — the deposit already captured is shown as a credit line so "att betala" = the remaining balance, VAT charged on the full event). Routes: packages CRUD +apply-package;contract(issue / accept / void);beo(generate + checkable task list);invoice(issue →CAT-INV-####) +invoice/pdf;balance(take the remaining balance →source:'catering-balance'). Dashboard detail pane gains a Packages manager, a Contract card (generate → terms → click-sign), a BEO run-sheet with a progress bar, and an Invoice card (issue + Download PDF + take balance). An accepted contract freezes the quote (item/delivery edits + apply-package + re-issue are 409'd until voided). - Bar — pre-auth, happy-hour automation, fast-pour grid, rounds, split-by-seat/item, refusals. New engine: happy-hour pricing (activeHappyHour/happyHourPrice/resolveQuickAddPrice— day-of-week + time windows incl. midnight-wrap, percent/fixed/price, first-match), a mock pre-auth hold lifecycle (buildPreAuth/markPreAuthCaptured/markPreAuthReleased— moves NO money; the capture at settle is the only ledger charge),nextRound/groupByRound,seatSubtotal/itemsSubtotal,canSettleTab(age gate),normalizeFastPour. Routes:bar/config(happy-hours + fast-pour + age-gate + default hold),bar/refusals;tabsgains a pre-auth hold on open +set-preauth;add-itemsstamps a round + seat + the happy-hour-resolved price;settleadds split-by-seat / split-by-item, an age-gate block (402), and pre-auth capture. Dashboard: a fast-pour grid, a happy-hour/fast-pour/age settings panel, an items-by-round view with seat tags + happy-hour badges, a split-mode picker, and a refusal log. - Canteen — employer/school groups, subsidy rules, meal-plan allowances, daypart menus, B2B invoicing. New engine:resolveSubsidy/resolveAllowance(account-override → group → config-default),computeSubsidySplit(percent / fixed-per-meal / fixed-per-day + daily cap; both shares ≥0 and sum to the meal),checkAllowance(day/week/month period caps + meals cap),activeDaypart,billableAccruals/buildB2BInvoice+documents.ts buildCanteenB2BInvoiceModel. The subsidy money model: at the till the diner's wallet covers only the post-subsidy share; the employer/school share accrues as a receivable (canteenSubsidyAccruals, NOT a wallet debit, NOT a ledger charge) — wired into the POS meal seam, backward-compatible (no group → identical to before). The receivable is billed via a B2B invoice (accruals stamped atomically so they can't be double-billed); the group's payment is the one real cash event (source:'canteen-b2b', idempotent,created-gated). Routes:groupsCRUD (+billablepreview),config(dayparts + defaults), per-account group/subsidy/allowance overrides,b2b-invoices(issue / pay / pdf). Dashboard gains Groups, Dayparts & Defaults, and Invoices surfaces.- Fixed (Phase 36 adversarial review — money-model hardening):
- Revenue double-count of the canteen subsidy. A
canteen-b2bledger charge is the collection of a receivable for meals already recognised as revenue (viaorder.totalat meal time) — not a new diner-facing sale. It was being counted as gross sales + levied a platform fee + commissioned. Now excluded everywherecanteen-topupis (revenue-sharesettlementFromLedgerEntry+reporting,summarizeOrderCommissions, the usage meter, and surfaced undersummarizeLedger's non-sale breakout). Regression tests added. - Bar settle TOCTOU. Settle re-read the stale pre-locktab.subtotalfor the balance/close decision; a concurrentadd-items(a new round) could close a tab with the new drinks unpaid. Now re-reads the fresh tab subtotal inside the lock (mirroring the POS settle). - Catering VAT zeroed on re-quote. The PATCH + apply-package recompute hard-codedtaxRatePct: 0, silently dropping VAT for a tenant withcateringConfig.taxRatePct > 0. Both now use the configured rate (matching the create path). - Catering deposit / balance over-collection. Both posted the pre-lock amount with no in-lock re-clamp → a concurrent double-submit could collect past the due. Both now re-clamp against freshly-captured inside the lock; the deposit route also gates its state/timeline leg on the ledgercreatedflag. - Catering contract integrity. An accepted contract is now frozen (re-issue 409'd; quote edits/apply-package blocked) with an explicit void (DELETE) to unlock; the invoice PDF 404s until an invoice is actually issued (no provisional-number drift). - B2B ledger row no longer stamps the invoice id intoorderId(per-order rollups can't accidentally match an invoice).- Phase 35 — VC-8 (drive-thru lane) + VC-9 (POS/Waiter voice-to-order): the last two surfaces, as thin adapters over the VC-0 engine. A new shared
lib/voice-concierge/turn.tsadvanceTurn()runs ONE engine turn and, on a confirmed order, creates it through the SAME locked seam the phone uses (born-PENDING, OUR-menu pricing, neversettle) + records the session — surface/source injected so the brain is identical across surfaces (one system). VC-8POST /api/tenant/[tenantId]/voice-concierge/lane(tenant-auth, live-gated, HMAC-signed ConvState, per-carsessionIdidempotency,source:'drive-thru') returns the running order as aboardpayload for a lane confirmation screen. VC-9POST /api/tenant/[tenantId]/voice-concierge/pos-order(source:'pos'|'waiter', per-ticket idempotency) shows the read-back on screen; the staff member confirms by sending the next turn ("yes") — no auto-submit. Both inherit the engine's allergen pre-router, read-back→confirm, and signed-state guarantees verbatim. 2 tests (58 voice-concierge total); tsc 0; eslint 0. → Phase 35 surfaces VC-0…VC-9 now COMPLETE; only go-live infra remains (real Twilio numbers + STT/TTS, a live narration key, outbound channel send, a per-lane device token for unattended lanes, and flippingvoice_concierge_enabled). - Phase 35 — Voice & AI Concierge (Universal-Coverage P35): the AI voice agent, customer concierge & staff copilot — buildable scope COMPLETE (VC-0…VC-7), mock-first, behind a fail-safe-OFF master flag. ONE deterministic-first conversation engine drives every surface; the LLM is optional and structurally barred from anything safety- or money-critical. Designed via a 14-agent recon+adversarial-design Workflow (3 blockers + 6 majors baked in pre-code) and hardened by a 33-agent code-review Workflow (5 confirmed fixes applied).
- VC-0 — the Concierge Engine keystone (`lib/voice-concierge/{engine,types,entity-resolver,messages}.ts`, PURE, surface-agnostic).
stepConversation(state, turn, deps)is a serializable state machine that phone / web / WhatsApp / drive-thru / POS all drive — it owns NO I/O (returns aCreateTenantOrderInput; the surface callscreateTenantOrder). Reuses the SB-5 TwiML/speech helpers,computePriceQuote(OUR-menu pricing),answerMenuQuestion(deterministic menu Q&A from the feed), and the ITAresolveAllergen/resolveDietary+av.*/dv.*i18n verbatim. Safety invariants, all STRUCTURAL (not convention): (1) an allergen/dietary pre-router (entity-resolver.resolveQuery) runs BEFORE any menu/FAQ retrieval — an allergen-shaped query can never be answered by the ranker or an LLM, only by the deterministic resolver or an escalation; (2) the entity resolver requires a single unambiguous dish (name-token overlap; a tie → clarify/escalate) and maps allergen words via a FIXED lexicon — it NEVER guesses a dish on an allergen question; (3) gluten/dairy/nut/egg/soy/etc. are treated as allergen-class regardless of phrasing ("is it gluten free?" →resolveAllergen('gluten')); (4) low STT confidence on the allergen branch escalates (never resolves a misheard transcript); (5) an order reachessubmitonly through read-back → confirm → explicit yes (and a "yes" riding alongside a question/allergen word does NOT confirm); (6) once an allergen escalation fires, the order is latched-blocked; (7) the optionalnarrateLLM is invoked ONLY on no-number "chrome" replies — never an allergen verdict, a price, a quantity, or the read-back. - VC-1 — master kill-switch (`lib/voice-concierge/flag.ts`).voice_concierge_enabledFeatureFlag, fail-safe OFF, mirrors the ITA/Restora-Pay pattern 1:1 (per-key upsert under the MasterDB row lock → no clobber of the sharedfeatureFlagsarray) + per-tenantTenantDB.voiceConcierge.enabledopt-out. Super-adminmaster-switchroute (audit-logged) + tenantsettingsGET/PATCH ({masterEnabled, tenantEnabled, live}). - VC-2 — phone voice order surface (/api/public/voice-concierge/[tenantId]/{incoming,respond}). Per-tenant in the PATH (each restaurant points its number's webhook at its own URL — no number→tenant lookup). Twilio signature mandatory (403) and inert until Twilio +ORDER_VOICE_NUMBERare configured AND the tenant is live. Born-PENDINGcreateTenantOrder(source:'voice',escrow:false, idempotent onvoice:${CallSid}) — priced ONLY against OUR menu, neveropts.settle. The threaded ConvState is HMAC-signed (twiml-order.ts) so a caller can't forge a confirm state or tamper the basket. Zero-LLM on the line by construction. A failed submit speaks an honest hand-off (never a false "confirmed") and queues a callback. - VC-3 — order voice simulator (/api/admin/voice-concierge/order-voice-sim, super-admin, read-only, no DB writes): drives the FULL free-text path so the two "Done when" conditions are CI-provable without a phone — a spoken end-to-end order, and an accurate deterministic allergen answer (plus the negative cases: ambiguous dish, misheard allergen, unmaintained profile). - VC-4 — Customer Virtual Assistant (concierge-responder.ts+ the inboxconcierge-replyaction + a newrecordAssistantReplyseam inlib/channels/inbox.ts): a deterministic single-turn responder for web/WhatsApp that splits allergen tokens OUT of the P34 menu-question bucket (the verdict comes fromresolveAllergen+ escalates, never the feed ranker), reusingderiveReplyfor everything else. - VC-5 — budget-gated narration (narrate.ts): an ASYNC surface helper (resolveAIKey→getBudgetStatus→ Haiku rephrase →recordAIUsage('assistant')), fail-safe to the deterministic reply on no key / over budget / any error, and gated by the sameshouldNarratebarrier (never an allergen/priced reply). The phone line never imports it. - VC-6 — Staff Copilot / "virtual manager" (copilot.ts+copilot-token.ts+/api/tenant/[tenantId]/voice-concierge/copilot): deterministic NL ops — sales ("revenue vs last week") viacomputeAnalytics, low-stock/reorder from the threshold list — where every NUMBER is computed, never an LLM. The one mutation, "86 the salmon", is a two-step confirm-before-mutate: an ASK returns a proposal + a short-lived HMAC confirm token bound to the exact (tenant, action, dish, value); EXECUTE verifies it and writes the availability flip + aCopilotAuditrow in the SAMEmutateTenantDBlock (no audit ⇒ no mutation), naming the exact resolved dish (ambiguous → disambiguate, never a guessed 86). - VC-7 — owner surface:/dashboard/voice-concierge(KPIs from sessions + the staff callback queue + the live/off toggle), a deterministic honest-nullinsightsroute, and a "Voice & Concierge" nav entry (lucideHeadphones) landed in the SAME commit as the page. - Schema (additive only):VoiceConciergeSession+CopilotAudit+TenantDB.{voiceConcierge, voiceConciergeSessions, copilotAudit};Order.sourcewidened with'voice' | 'drive-thru'; andProduct.available?: booleandeclared (it was a de-facto field already honored by the Food Bazar, channel feeds, SMS/WhatsApp menus, SEO and sitemap — now the copilot's audited 86 sets it). - Money: voice/concierge orders are Restora-collected ⇒ born PENDING, priced from OUR menu, settled later via pay-link/pickup; the cross-truth invariant (computeAnalytics.totalRevenue === summarizeLedger.gross) holds because noopts.settleis ever passed on this path. - Verified: full suite 3369/3369, tsc 0, eslint 0. Remaining = go-live only (not dev): real Twilio number provisioning + STT/TTS providers (webhooks inert without them, like SB-5), a live LLM key for narration (mock/identity dep in dev), live outbound WhatsApp/IG/SMS send, and the two thin remaining surfaces VC-8 drive-thru lane + VC-9 POS voice-to-order (adapters over the existing VC-0 engine). Note: full 86 enforcement in the tenant's own storefront render + public-order rejection is a fast-follow (the flag already hides 86'd dishes across the Bazar / channels / SEO read layer).- Phase 23 — RACM AC-2: the AI Executive Council (the last buildable slice of the autonomy capstone — RACM AC-0…AC-7 now complete). The executive-orchestration layer that sits ON TOP of AC-1's approvals queue and the Phase-22 RAOS agent workforce — it builds no second queue, second registry, or second governance engine; it READS the open governed actions + the agent catalog and decides how to rank, where agents disagree, and which coordinated multi-agent play warrants a single governed proposal.
- `lib/racm/council.ts` (PURE core — no I/O, no clock, and deliberately NO `@/lib/raos` import):
conveneranks the open recommendations on a deterministic composite (RACMpriorityScorelifted by shared-memory confidence);detectConflictsflags subjects where ≥2 open items pull different ways and escalates them — the council never auto-resolves a disagreement;composeChurnRescuebinds the canonical retention play (Growth → Support → Marketing → Founder) to the real registered agents (a planned agent surfaces honestly asavailable:false);councilToProposalconverts the play into one `plan.create` (L3, recommend-only) proposal — so nothing the council produces is ever autonomous. Fully unit-testable in isolation. - *`lib/racm/council-runtime.ts` (the EDGE — the ONLY AC-2 file importing `@/lib/raos/, imported only by the route, exactly mirroring the AC-4dispatch.ts/ AC-5async.tsprecedent so the policy core never depends on the mechanism and no import cycle forms):** gathersAGENTREGISTRY` + the open queue, annotates each plan step with RAOS `decide()` (shows what governance says **without executing**), routes the play through the existing audited `proposeAction` (deduped by `meta.sourceId`; **INERT until both the RACM and RAOS master switches are on**, like AC-5a), records the convening to RAOS shared memory + the audit log (best-effort, never fatal). **Dispatch is governance-first:** the play reaches the agent bus only after a human has approved its L3 plan (we confirm an approved/executing/executed action with the matching `sourceId`), and each enqueued task is then *also* re-gated by RACM at drain time; the founder agent is subscribed to `agent.task.dead-letter` for failure escalation. - **AC-2c capped LLM "debate" (`lib/racm/council-brief.ts` + reuse of the AC-7 path):** the deterministic council brief is the SHIPPED artifact (every numeral comes from the decision — counts, top priority); the optional Haiku rephrase rides the **existing `narrateGovernanceBriefing`** (the mission-control number-guard reverts any line whose figures drift, RACM's editable daily cap, fails closed to deterministic on AI-off / no-key / over-budget). Zero new AI code, zero edits to the AC-7 files. - **Surface:** new super-admin `GET|POST /api/admin/racm/council` (read-only deliberation always available; `convene` / `dispatch` / `narrate` ops) + an **Executive Council** panel in the `/admin/racm` cockpit (convene button, the deterministic brief + AI rephrase, the coordinated plan with per-step governance + availability, escalations, ranked recommendations, bus health, an approval-gated dispatch). - **21 new tests** (ranking/tie-break, conflict-escalation, churn-rescue composition, the L3/never-autonomous bridge, brief determinism + number-guard order invariant). tsc 0; eslint 0; full suite **3345/3345**. Adversarially reviewed across 5 lenses (import-boundary, governance-correctness, type/API, AI-anti-fabrication, security) → 1 real finding fixed (approval-gated dispatch), the rest confirmed-safe. Buildable RACM scope (AC-0…AC-7) is COMPLETE; what remains is go-live infra only — deploy + wiring real agent executors into the bus's injected-executor seam._- Sound & Atmosphere — AP-7 partner invites (recruit → onboard, end-to-end). Closes the loop from the recruit directory (AP-6) to onboarding (AP-2): a super-admin clicks Invite on a prospect → gets a tokenized join link → the partner opens it → a pre-filled public join page → self-registers (→ pending) → admin approves (AP-2/AP-4). New PURE
lib/atmosphere/invites.ts(inv_tokens, SHA-256 hash stored / raw shown once,createInvite/lookupInvite/consumeInvitewith 30-day expiry). Additive schemaMusicPartnerInvite+MasterDB.musicPartnerInvites?. Routes:POST /api/admin/atmosphere/prospects/invite(super-admin → creates invite + marks prospect 'invited' + returns the link, token once, audit-logged),GET /api/partners/atmosphere/invite?token=(public lookup → pre-fill, 404/410 on invalid/expired), and the AP-2 register route now consumes aninviteTokenfor attribution. New public pageapp/partners/atmosphere/join(token-pre-filled partner registration; the partner attests its own licensing) + an "Invite ↗" button on the Recruit screen (copies the link). 3 tests (47 atmosphere total); tsc 0 project-wide. - Sound & Atmosphere — AP-6 partner-recruitment directory (researched candidates per market, Scandinavia first). A super-admin page to find real candidate music/atmosphere partners and invite them to build on the platform. Built from live web research into a curated, SOURCED code seed (
lib/atmosphere/prospects.ts,PROSPECT_SEED, ~30 real companies — every entry has a website + a source URL; honest "verify before outreach / not affiliated" disclaimer; a market with no local provider is honestly shown as a recruit gap). Markets ordered by owner priority: Scandinavia (SE/NO/DK/FI) → FR → IT → DE → US → CA → GB → AE → PK → IN (ATMOSPHERE_MARKETS). PURE helpersresolveProspects/groupProspectsByMarket(globals bucket + each market surfaces region-specific players and global players HQ'd there — so Sweden shows Soundtrack Your Brand + Epidemic) /setProspectStatus. Additive schemaMusicPartnerProspect+MasterDB.musicPartnerProspects?(mutable outreach status only; the catalog is the code seed). New routeGET|POST /api/admin/atmosphere/prospects(super-admin, audit-logged) +ProspectsScreen+/admin/atmosphere/prospectspage + a "Recruit" nav tab (sa-* design). Outreach status per prospect: prospect → contacted → invited → onboarded / declined. 5 tests (44 atmosphere total); tsc 0 project-wide. - Sound & Atmosphere — Partner Provider API: AP-5a play-usage / royalty reporting (mock-first). The partner economics layer: log what plays in each venue and let a partner pull its usage to file royalties with its PRO/label. Additive schema:
AtmospherePlayLogEntry+MasterDB.atmospherePlayLog?(append-only, capped). New PURElib/atmosphere/usage.ts:recordPlays(append + cap to most recent) +summarizePlaysForPartner(filter by providerId + date window → total plays / duration / by-channel / by-tenant). Routes:POST /api/tenant/[tenantId]/atmosphere/plays(the edge player reports tracks; each attributed to the tenant's active provider viaresolveProviderForTenant) andGET /api/partners/atmosphere/usage?from=&to=(partner-authed — only the partner's own plays). 3 tests (39 atmosphere total); tsc 0 project-wide. Remaining AP-5 (owner/counsel-gated): a real partner HTTP adapter + flipping the live flag + forwarding usage to the partner's royalty system — none buildable without the partner's API + contract. - Sound & Atmosphere — Partner Provider API: AP-3b partner catalog push (a partner brings its own music). Completes the "partners build on it" loop: an approved partner pushes its channel catalog via the API and that catalog drives the venue — no live HTTP adapter to host. Additive schema:
MusicPartner.channels?+catalogUpdatedAt?. Newlib/atmosphere/catalog.ts(PUREvalidateChannels— defensive coerce/clamp/cap, never trusts partner input),stored-catalog-provider.ts(storedCatalogProvider(partner)— anAtmosphereProviderbuilt from the partner's stored channels + its licensing attestation),resolve-tenant-provider.ts(resolveProviderForTenant— prefers an APPROVED partner with a pushed catalog for the tenant's providerKey, else registry, else house library; fail-safe). Routes:GET|PUT /api/partners/atmosphere/catalog(partner-authed). The playback route (AP-3) now resolves viaresolveProviderForTenant, so an approved partner's catalog plays automatically when the flag is on. 6 tests (36 atmosphere total); tsc 0 project-wide. Next: AP-4 finish (branded owner screens, visual pass) · AP-5 go-live (real adapter + royalty reporting). - Phase 22 — Restora Agent OS (RAOS): OS-6 scale & reliability + workflow triggers + traceability — BUILDABLE SCOPE COMPLETE. The final slice; deterministic, mock-first, read-only, additive. New
lib/raos/reliability/:
- `reliability.ts` (PURE) —
assessScale(per-store utilization vs cap → posture ok/watch/graduate at ≥80% — signals WHEN to flip each store to Postgres tables / pgvector; the pg path already exists behind the same accessors, so graduation is a go-live flip, not a rewrite);WORKFLOW_TRIGGERS+matchTriggers(the "wire the example workflows" piece — maps high/critical Mission-Control/Growth signal keys → a workflow template, recommend-only: suggests a run, never auto-starts);buildTrace(end-to-end traceability — correlate a run → its steps → their OS-2 bus tasks into one timeline). - A 3rd example templateincident-response(probe→open-incident→send-alert[L2]→recommend-scale[L3]) joins churn-rescue + price-review, all using real registry capabilities. (SEO-publish from the roadmap is deferred — it needs an SEO agent capability not in the OS-0 registry; not fabricated.) - Audit-hardening:orchestratorTicknow writesraos.workflow.completed/failedto the shared audit log once per run when it becomes terminal. -engine.tsraosReliability()(store posture + trigger wiring) +traceRun(runId). Read-onlyGET /api/admin/raos/reliability(+?run=<id>for a trace) + a Reliability & scale cockpit panel. 4 tests (scale-posture thresholds, trigger matching high/critical+dedup, trace correlation). tsc 0; full suite 2889/2889. → PHASE 22 BUILDABLE SCOPE COMPLETE (OS-0…OS-6). Remaining is go-live infra only: graduate the stores to Postgres/pgvector on volume, and wire real agent executors (RACM AC-3) into the bus's injected-executor seam.- Phase 22 — Restora Agent OS (RAOS): OS-5 feedback & learning loop (recommend-only; agents can't silently raise their own autonomy). Observes RAOS's OWN execution outcomes (OS-2 bus tasks + OS-4 workflow runs) and scores each capability — mirroring RACM AC-6's discipline. Deterministic, AI-OFF, read-only (no store of its own — a pure projection over what's already recorded). New
lib/raos/learning/:
- `learn.ts` (PURE) —
aggregateCapabilityStats(group bus tasks by agent+capability → done / dead-letter / blocked, success + dead-letter rates, avg attempts, and a signal:healthy/watch/review/insufficient, judged only once ≥5 terminal outcomes),recommendAutonomy(the gated learning), andbuildWorkflowStats(per-template completion rate) +buildLearningReport. - The autonomy guard (the whole point): recommendations are RECOMMEND-ONLY and never applied (appliedis alwaysfalse). A healthy L1/L2 reversible capability is flagged only "eligible for a HUMAN-reviewed autonomy check"; a failing one is flagged "tighten-review"; and an L3/L4 capability is NEVER eligible for promotion (stays human-governed) — so an agent can never silently raise its own autonomy. (A dedicated test asserts noeligible-for-autonomy-reviewrecord ever targets an L3/L4 capability, and that everything isapplied:false.) - `engine.ts` —learningReport()reads the bus + workflow stores + resolves each capability's current risk/reversibility from the OS-0 registry (single source), then builds the report. - Read-onlyGET /api/admin/raos/learning(super-admin) + a Learning loop cockpit panel (per-capability signals + the recommend-only autonomy recommendations, honest-empty until outcomes accrue). 6 tests (aggregate grouping + healthy/review/insufficient scoring, the L4-never-promoted guard, healthy-but-irreversible holds, workflow completion rate, the no-L3/L4-eligible report-level guard). tsc 0 project-wide; full suite 2879/2879. Next: OS-6 scale & reliability + wire the example multi-agent workflows.- Phase 21 — Autonomous Company Mode (RACM): AC-7 optional AI narration (Haiku $5/day) + super-admin model/budget controls. Adds the design's "AI Executive briefing" — but keeps RACM's hard rule that the AI only NARRATES, never decides, and makes the cost knobs one/two-click editable.
- `lib/racm/briefing.ts` (PURE) — the deterministic executive briefing (the SHIPPED artifact): a worst-state-first headline + status/queue/execution/high-stakes/learning lines, every numeral computed from the governance snapshot + metrics (kill-switch/master state, pending-by-tier, executed/rolled-back, success & autonomy ratios, high-stakes-held, the top review-flagged capability). Works with AI fully off. - `lib/racm/narrate.ts` — optional number-guarded rephrase: asks the configured model to re-word ONLY the prose, then runs the platform's most rigorous number-guard (reused from `mission-control/number-guard`) so a figure can never be invented, restated, rounded, drifted, or relocated — any line that fails reverts to deterministic. Fails to deterministic on EVERY adverse path (AI off / no API key / over budget / no JSON / any error). The Anthropic SDK is lazy-imported in the ledger so it never bloats the non-AI hot path. - `lib/racm/ai-budget.ts` — editable spend guard: model + daily USD cap stored in RACM's own isolated store (not the shared schema), defaulting to Haiku 4.5 / $5/day (owner-chosen).
RACM_AI_MODELSoffers Haiku ($1/$5) · Sonnet ($3/$15) · Opus ($5/$25). Over the cap (or kill-switched, or a 0 cap) → no call, no cost; usage auto-resets per UTC day. - Super-admin controls in the `/admin/racm` cockpit (the owner's "one or two clicks"): an Executive summary panel with a "✨ AI rephrase" button (guarded; disabled when AI off or over budget; shows an "AI-rephrased" badge), and an AI narration settings panel — a model dropdown (instant), a daily-cap input + Save, and an on/off toggle, with live "spent $X / $Y today". New API opsset-ai+narrate;GETnow returns the deterministic briefing + AI status + model options. Every config change is audited. - ReusesestimateCostUsd+ the number-guard; no new warehouse, no shared-schema change. 15 new tests (cap/model resolution, kill-switch + 0-cap + day-rollover gating, usage accounting, headline selection, honest number-bearing lines). Total RACM 89 tests; tsc 0 (racm); eslint 0; routes compile clean on live dev (401/307). RACM's deterministic core is unchanged and still runs with AI fully off; this is a decorative, cost-capped narration layer for an internal founder tool.- Sound & Atmosphere — Partner Provider API: AP-4 (partial) super-admin Partners UI. Wires the partner-management backend (AP-2) into the admin atmosphere console: new
PartnersScreen+/admin/atmosphere/partnerspage + a "Partners" nav entry in the Content & Licensing group. Lists registered partners with their status + licensing attestation (model / territories / ad-free / royalty-reporting / attested-by) + masked key, and approve / suspend / revoke / reinstate actions againstGET|POST /api/admin/atmosphere/partners; KPI row (total / pending / approved) + honest empty state. Matches the admin-atmosphere console design language (sa-*scoped classes, dark operator console). tsc 0 project-wide. Remaining AP-4 (owner-facing branded screens — Library / Now-Playing wired to AP-1/AP-3 + the Activate button): deferred to a visual-pass session (1:1 design fidelity + run :3001) per the design-reference rule. - Phase 22 — Restora Agent OS (RAOS): OS-4 orchestration (the slice that composes OS-1/2/3 into real multi-agent runs). A workflow planner decomposes a template into a step DAG, routes each step to a capable agent (OS-0 registry), and dispatches ready steps as OS-2 bus tasks — so every step is RACM-gated for free (a run honestly stops at an L2/L3 step awaiting approval rather than auto-acting). Deterministic, mock-first, additive (its own isolated
agent-workflows.jsondoc store; no shared-schema change). Newlib/raos/orchestrator/:
- `types.ts` + `templates.ts` —
WorkflowRun/WorkflowStep(capability, deps, resourceKey, fallback, status, bus taskId) + two example templates (churn-rescue: detect→draft→publish;price-review: analyze→recommend) whose steps name CAPABILITIES the planner routes to agents. - `plan.ts` (PURE) —planRun(route each capability → agent via an injected resolver, preserve the dependency graph) +hasCycle(a malformed/cyclic template is rejected, never a deadlocked run). - `advance.ts` (PURE) — the state machine:readySteps(deps satisfied and conflict-resolution — steps sharing a resourceKey never run concurrently),taskStatusToStepResult(maps the OS-2 bus task status → done/failed/blocked),applyStepResult(retry via a fallback agent once, then fail + cascade-skip everything downstream),rollupRunStatus(completed/failed/running/blocked). - `store.ts` + `orchestrator.ts` — isolated runs store;startWorkflow(plan + persist) + `orchestratorTick` (reconcile dispatched steps from bus outcomes, then dispatch newly-ready steps as bus tasks — so a DAG progresses one layer per cron tick) + routing over the registry + purestatsFrom. - Wired into the existing `GET /api/cron/raos-bus` (orchestrator tick THEN bus drain) — no new cron entry. Read-onlyGET /api/admin/raos/workflows(templates + run stats) + audited super-adminPOST(start a run) + an Orchestration cockpit panel (templates + recent runs with step progress, honest-empty). 22 tests (cycle-detect + routing + unroutable, ready/deps + resource-conflict serialization, fallback-then-skip, rollup, task→step mapping). tsc 0 project-wide; full suite 2858/2858. Next: OS-5 learning loop.- Sound & Atmosphere — Partner Provider API: AP-3 playback orchestration (the venue actually plays — through the provider). Resolves what plays per zone by combining the tenant config (zones / ambiance preset / signature genres / safeguards) with the active provider's catalog — so a connected partner's licensed catalog drives the venue, and by default the always-on house library does (mock-first, never silent). New
lib/atmosphere/orchestrator.ts:moodForConfig(daypart else ambiance preset),pickChannelForZone(PURE/deterministic — honours the explicit-filter safeguard, prefers mood then signature-genre match),resolveZonePlayback/buildVenuePlayback(now-playing across enabled zones viaprovider.listChannels/getNowPlaying; each zone's own volume applied; not-activated → nothing plays),applyControl+isControlCommand. New routeGET|POST /api/tenant/[tenantId]/atmosphere/playback(requireTenantAccess; GET = per-zone playback snapshot, POST = control command) — resolves the provider via the registry + partner flag. 6 tests (30 atmosphere total); tsc 0 project-wide. Next: AP-4 wire the design-handoff screens (Library/Schedule + Now-Playing) to this + the Activate button. - Sound & Atmosphere — Partner Provider API: AP-2 partner-facing API + onboarding (the "partners build on it" surface). External music/atmosphere partners can self-register, authenticate, and submit their licensing attestation. Additive schema:
MusicPartnerinterface +MasterDB.musicPartners?(mirrorsProfitApiKey). Newlib/atmosphere/partners.ts(PURE, mirrors the profit-api key engine):rmp_bearer keys (SHA-256 hash stored, raw shown ONCE),createPartnerRecord/validatePartnerInput/approvePartner/setPartnerStatus/verifyPartnerKey/partnerToPublic(never leaks the hash).partner-auth.ts—authenticateMusicPartner(req)guard (only an APPROVED partner authenticates; 401/403). Routes:POST /api/partners/atmosphere/register(public self-register →pending+ key once; partner attests its own licensing → Restora holds no licence),GET /api/partners/atmosphere/me(partner-authed),GET|POST /api/admin/atmosphere/partners(super-admin list + approve/suspend/revoke/reinstate, audit-logged). A registered key is INERT until a super-admin approves (self-register ≠ access). 6 tests (24 atmosphere total); tsc 0 project-wide. Next: AP-3 playback/zone orchestration via the provider · AP-4 wire the design-handoff screens. - Phase 22 — Restora Agent OS (RAOS): OS-3 shared goal system (company → department → agent, approved through RACM). Aligns the agent workforce around a goal hierarchy. Deterministic, mock-first, additive (its own isolated
agent-goals.jsondoc store; no shared-schema change). Newlib/raos/goals/:
- `types.ts` —
Goalwithlevel(company/department/agent) +parentId(the hierarchy), owner, metric/target/current/unit,status(proposed→active→achieved/abandoned, or rejected),approvaltier (from the RACM matrix), priority, source, versionhistory. - `hierarchy.ts` (PURE) —buildTree(company→department→agent forest from a flat list),progressOf+ rolled-up tree progress (a parent blends its own metric with its children's average; 4-dp rounded),findMisaligned(a company goal with a parent, or a dept/agent goal whose parent is missing or not strictly higher — flagged, never silently accepted). - `lifecycle.ts` (PURE) — the RACM tie-in:goalRacmLevel(agent→L2, company/department→L3) →proposalApprovalvia RACM'srequiredApproval(single source): agent goals need human approval, company/department goals need executive approval — a goal NEVER auto-activates.canTransitionGoal(legal-only transitions) +suggestGoalsFromSignals(maps ONLY high/critical Mission-Control/Growth signals → proposed goals: risk→a "Mitigate" department goal, opportunity→a "Capture" agent goal; recommend-only, honest-empty otherwise). - `store.ts` + `engine.ts` — isolatedagent-goalsdoc store; PURE cores (applyPropose/applyTransition/updateProgressIn/statsFrom/visibleTree) + async wrappers (proposeGoal/transitionGoal/updateProgress/goalTree/goalStats). - Read-onlyGET /api/admin/raos/goals(tree + stats) + an audited super-adminPOST(propose/approve/reject/achieve/abandon/progress — the propose→approve flow) + a Shared goals cockpit panel (the hierarchy with rolled-up %, awaiting-approval + misaligned badges, honest-empty). 16 tests (tree nesting + orphan-as-root + rollup blend, misalignment detection, approval-tier mapping, transition legality, signal→goal suggestion incl. honest-empty, engine propose/transition/stats/visible-tree). tsc 0 project-wide; full suite 2829/2829. Next: OS-4 orchestration.- Sound & Atmosphere — Partner Provider API: AP-1 one-click activation. "One click and music works": new PURE
lib/atmosphere/activation.ts—oneClickActivationPatch(enable + sound-on + default to the always-on house library),connectPartnerPatch(switch source, stay on),deactivationPatch(off but keep the source for instant re-activation), andactivationStatus(config, {liveEnabled})→{activated, soundOn, providerKey, provider (resolved description + licensing attestation), live}. New additive routePOST|GET /api/tenant/[tenantId]/atmosphere/activate(requireTenantAccess, mirrors the config route; reuses the existing config store + the partner flag): GET returns activation status; POST activates / deactivates / connects-a-partner. Defaults to the house library, so one POST = licence-clean music playing with no partner connected. No new schema; the existing config store validates the provider field. 5 tests (18 atmosphere total); tsc 0 project-wide. Next: AP-2 partner-facing HTTP API + onboarding. - Phase 21 — Autonomous Company Mode (RACM): AC-4 governed L2 automations (human-approved → RAOS bus handoff). The L2 tier finally does something real — but only after a human approves, and without RACM ever reaching into a live engine directly. New L2 handoff executors (
content.publish,seo.publish,campaign.send,churn.campaign,supplier.recruit) inlib/racm/executors.ts: on (human-approved) execution they produce ahandoffartifact, and the execute route emits a GOVERNED event onto the RAOS agent bus (lib/racm/dispatch.ts→publish) so the owning engine/agent performs the actual external write under its OWN RAOS-gated drain. RACM governs + approves + hands off; the engine executes — the clean layering confirmed in the AC-2 coordination (docs/architecture/racm-raos-ac2-integration.md).
- No import cycle: the RAOS edge (
dispatch.ts) is imported ONLY by the API route (a leaf) — RACM's governance core (governance.ts/ledger.ts/executors.ts) has zero RAOS import, soraos → racmstays one-way.buildHandoffEvent(action, mode)is a PURE mapper (topicracm.<type>/.rollback, priority by level L1→4…L4→1,correlationId= action id);dispatchHandoffis best-effort (a bus failure never fails the recorded+audited RACM action). NewRacmArtifactkind'handoff'. - Still fail-safe + human-gated: L2 never auto-executes (the gate routes it to needs-approval); a human approves, then executes;executeActionalready gates onisRacmEnabled+ the kill-switch. Rollback emits a compensating.rollbackevent. L4 capabilities remain executor-less (tested — they can never execute or hand off). - Cockpit: the awaiting-execution Execute button now appears for any capability with an executor (L1 internal + L2 handoff), driven by a newexecutableTypesfield on the snapshot; L2 rows note "hands off to its engine". - 7 new tests (handoff executors + HANDOFFTYPES + L4-exclusion; the pure `buildHandoffEvent` topic/priority/payload mapping). Total RACM **74 tests**; tsc 0 (racm); eslint 0; both RACM routes (incl. the one importing the RAOS bus) compile clean on live dev (401/307). Next: AC-2 council (substrate ready — see the coordination note) or the heavier AC-6 infra optimizer / AC-7 LLM briefing._- Sound & Atmosphere — Partner Provider API: AP-0 keystone (the contract + registry + always-on house library). The integration layer that lets external, LICENSED music/atmosphere partners plug a catalog into Restora360's one-click experience — so the licensing burden sits with the partner (who already holds the rights), integration is standardized, and the customer still gets one Restora system that works on one click ("100% solution"). Additive, mock-first, fail-safe; zero schema change (reuses the existing
AtmosphereConfig.providerseam). Newlib/atmosphere/:provider.ts— theAtmosphereProviderpartner contract (describe/listChannels/getNowPlaying/control/reportPlays, async, structuredAtmosphereResult) + a `LicensingAttestation` model (partners attest their own rights → Restora holds no licence) + capabilities;providers/house-library.ts— a built-in production-library provider that always works out of the box (royalty-free, ad-free, explicit-filterable; attests its own licensing) so a tenant enabling Atmosphere gets licence-clean music with no partner connected;registry.ts—registerAtmosphereProvider/resolveAtmosphereProvider/listAtmosphereProviders, fail-safe (never throws → always falls back to the house library so music never goes silent); a live partner serves only when live mode + the fail-safe-OFFatmosphere_partner_providersflag are on AND it's registered.flag.ts— server gate. Mirrors the payments/domains provider pattern; reuses the existingNowPlayingtype. 7 tests; tsc 0 project-wide. Next: AP-1 one-click activation + AP-2 partner-facing HTTP API/onboarding + wiring the design-handoff screens (Schedule/Library/Analytics/Licensing) to the provider. - Phase 22 — Restora Agent OS (RAOS): OS-2 agent bus (the piece that lets agents trigger each other — through the governed gate). A durable, prioritized pub/sub + task queue. Deterministic, mock-first, additive (its own isolated
agent-bus.jsondoc store via the row-lockedmutateDocseam — stands in for the production Postgresagent_events/agent_taskstables; no shared-schema change). Newlib/raos/bus/:
- `types.ts` —
AgentEvent(broadcast to topic subscribers, or direct to one agent; priority 1–5; correlationId) +AgentTask(owns agent + the registered capabilitytype, status, attempts/maxAttempts, backoffnextAttemptAt,risk/reversibleresolved from the OS-0 registry,escalated) +Subscription. - `core.ts` (PURE) —matchSubscribers(broadcast→topic/*subs minus the source; direct→target),claimNext(highest priority, oldest tie-break, backoff-respecting), `gateTask` (routes every task through OS-0.1decide→ RACM's gate:auto→run,recommend/approval-required→awaiting-approval,blocked→blocked — the bus can never auto-execute what RACM wouldn't allow), andapplyOutcome(success→done; failure→retry withBACKOFF_MINUTES[1/5/30], exhausted→dead-letter + escalated). - `store.ts` — the isolatedagent-busdoc store (pg row-lock + JSON + mutex; events capped 5k, tasks 10k keeping live ones). - `bus.ts` — async engine:publish(fan an event out to subscriber tasks, capability risk resolved from the registry),subscribe/unsubscribe,enqueue(direct task), `drainOnce` (the cron tick — claim→gate→run auto-approved via an INJECTED executor [mock until real agent execution lands via RACM AC-3]→outcome; execution runs OUTSIDE the store lock so a slow executor never blocks the queue; a dead-letter emits a high-priorityagent.task.dead-letterescalation event), and purestatsFrom/busStats. - `GET /api/cron/raos-bus` (CRONSECRET-gated, fail-closed; inert unless `raosenabled) drains a batch of 25. Read-only **GET /api/admin/raos/bus` (super-admin) + an Agent bus cockpit panel (tasks by status, dead-letter/awaiting counts, recent — honest-empty until events publish). The SSE doorbell (the dashboard inbox stream pattern) is the production low-latency nudge layered on top. 11 tests (subscriber fan-out + source-exclusion, priority/backoff claiming, gate routing auto/approval/blocked incl. kill-switch + RAOS-off, retry→dead-letter+escalation, escalation-event shape, stats). tsc 0 project-wide; full suite 2792/2792**. Next: OS-3 shared goal system.- Phase 21 — Autonomous Company Mode (RACM): AC-5 (proper) — recommend-only strategic intelligence (the L3/L4 tiers come alive). AC-5a's intake maps Mission Control signals to safe L1 tactical actions; AC-5 proper adds the strategic layer — the same signals, but the high/critical ones escalated into L3/L4 recommend-only proposals (the design's "pricing intelligence change = L4, financial intelligence commit = L4, partnership/expansion proposals"). New
lib/racm/intelligence.ts(PURE):strategicProposals(signals)→strategicFromRisk(liquidity→financial.commitL4 "review runway & cost structure";revenue-concentration→partnership.outreachL3 "diversify";conversion→pricing.changeL4 "review pricing & packaging") +strategicFromOpportunity(expand-adoption/scale-channelhigh upside→expansion.planL3). Honest + safe by construction: it escalates ONLY signals the engine already flagged high/critical (no fabricated concern), recommends a review never a specific price/number, and every output is L3 (executive approval) or L4 (never autonomous — a human commits) — tested to confirm none can auto-execute. Distinctmeta.sourceId(strat:…) so a single critical signal can yield both a tactical task (AC-5a) and a strategic review without colliding. Wired intosyncFromMissionControl(one "Pull recommendations" now surfaces L1 tactical + L3/L4 strategic, deduped). 13 new tests (escalation only on high/critical, per-category type/level mapping, the L4-never-auto + L3-needs-exec gate assertions, distinct source ids). Total RACM 67 tests; tsc 0 (racm); eslint 0; sync route compiles clean on live dev. AC-5 complete. Next: AC-2 council (Phase 22 RAOS) or AC-4 (wire executors into a live engine, L2). - Phase 22 — Restora Agent OS (RAOS): OS-1 shared agent memory layer. The substrate that lets the agent workforce share context instead of re-prompting from scratch — one scoped, importance-weighted, versioned memory store every agent reads/writes. Deterministic, mock-first, additive (its own isolated
agent-memory.jsondoc store via the row-lockedmutateDocseam — no shared-schema change), AI-OFF. Newlib/raos/memory/:
- `types.ts` —
MemoryRecordwithscope(global/company/agent/user/restaurant) +scopeId,kind(fact/preferenceare keyed & reconciled;note/eventare append-only), importance/confidence (0–1), source/provenance, tags,pii,status(active/superseded/retired),version+supersedeschain, andconflictsWith. - `score.ts` (PURE) — deterministic recall ranking: lexical query↔content overlap + the memory's importance + time-decayed recency (30-day half-life),nowinjected. No embeddings — a pgvector cosine similarity drops in later behind the same `scoreMemory` interface (the same store Support Brain will reuse), exactly as SB-0 deferred embeddings behind BM25. - `conflict.ts` (PURE) — the defining rule: NEVER last-writer-wins for business FACTS.decideWrite→ insert / noop / supersede (versioned; preference updates, or an authoritative fact = human source or strictly higher confidence — the old value is kept, never destroyed) / conflict (a lower/equal-confidence agent fact that disagrees is kept ALONGSIDE the existing one, both flaggedconflictsWith, for resolution — never silently overwritten). - `store.ts` — the isolatedagent-memorydoc store (Postgresapp_documentsrow-lock + JSONdatabases/agent-memory.json+ mutex; mirrors the RACM store; capped 10k). - `engine.ts` — PURE cores (applyRemember/recallFrom/retireIn/forgetSubjectIn/statsFrom, testable on an in-memoryrecords[]) + thin async store-backed wrappers (remember/recall/retireMemory/forgetSubject/memoryStats). Recall is scope-filtered (global memories always in scope), excludes superseded, excludes retired unless asked, ranked + limited. GDPR:retireMemory(one) +forgetSubject(scope, scopeId)(full subject erasure) — retired records are kept for audit but excluded from recall. - Read-onlyGET /api/admin/raos/memory(super-admin) + a Shared memory panel on/admin/raos(counts by scope, conflict count, recent — honest-empty until agents write). 22 tests (tokenize/recency/lexical/score-blend; conflict matrix incl. the never-last-writer-wins fact case + human/confidence supersede + preference latest-wins; engine insert/supersede/conflict/noop, scoped+filtered recall, GDPR retire/forget, stats). tsc 0 project-wide; full suite 2736/2736. Next: OS-2 agent bus (Postgres `agentevents/agenttasks` + cron + SSE doorbell).- Phase 21 — Autonomous Company Mode (RACM): AC-5a intelligence intake (the queue fills itself) + true fail-safe execution gating. Turns the platform's own intelligence into governed proposals, completing AC-1's mandate ("every AI suggestion across the platform routes here") — recommend-only, no contended writes.
- `lib/racm/intake.ts` (PURE, decoupled):
proposalsFromIntelligence({opportunities, risks})maps Mission Control's already-computed opportunities + risk register →RacmProposalInput[]. Opportunities map by category to a safe L1 capability (scale-channel→experiment.schedule,convert-trials→cs.task,expand-adoption→content.draft, unknown→advisoryproduct.proposal); risks → an internalcs.taskmitigation with severity-derived impact. Every mapped proposal isblastRadius:'internal'so it classifies L1 and a gate-cleared one auto-executes into a safe internal artifact; each carriesmeta.sourceIdfor dedup. Structural input types (not MC imports) keep the mapper unit-testable. - `lib/racm/sync.ts` (the ONLY MC-coupled file):syncFromMissionControl(actor)loadsloadMissionContext/buildOpportunities/buildRiskRegister/buildEcosystemValue, maps via intake, dedups against OPEN actions (proposed/approved/executing — so re-running is idempotent; terminal actions don't block a fresh cycle), and routes new ones through the auditedproposeAction. Inert until the master switch is on (fail-safe).POST /api/admin/racm/sync(super-admin) + a "Pull recommendations from Mission Control" button on the cockpit (reportscreated/skipped). - True fail-safe execution gating:proposeAction's auto-execute andexecuteActionnow both checkisRacmEnabled()— with the master flag OFF, gate-cleared L1s rest inapproved(awaiting execution) and nothing RUNS; flipping the flag on lets them execute. Closes the gap where the flag only gated the API read, not execution. - Reuses Mission Control aggregates (design §1/§5 — "reuses the GI event store / MC aggregates; no new warehouse"); RACM→MC is a read-only dependency (no cycle). 7 new tests (opportunity/risk → proposal mapping, category fallback, severity→impact, L1 auto-execute classification, flatten + stable source ids, empty signals). Total RACM 54 tests; tsc 0 (racm); eslint 0; the fullnext buildpassed for AC-0…AC-6/7 and the new sync route compiles clean on live dev (401/307). Next: AC-2 council (Phase 22 RAOS) or AC-4 (wire executors into a live engine, L2).- Phase RS — Free Growth (7% revenue share): RS-7 analytics + receivables dashboards (super-admin console complete). Adds the two read/ops dashboards to the Revenue Share admin page. New PURE helper
lib/revenue-share/admin-aggregate.ts:revenueShareTenants(master)(tenants on a revenue-share plan, canceled excluded, deduped) +collectOpenFailures(open/retrying receivables, worst-owed first, per-currency totals). Two additive fan-out routes (requireSuperAdmin):GET /api/admin/revenue-share/analytics?period=(fans out over revenue-share tenants only, derives each one's report via RS-4buildRevenueShareReport, aggregates viaaggregatePlatformAnalytics→ platform earnings / restaurant payouts / gross + per-tenant table; unreadable tenant skipped, never fails the dashboard) andGET|POST /api/admin/revenue-share/failures(open receivables across tenants; POST retry [mock collection succeeds — recorded-not-charged; real path wraps RS-2 at RS-9] / write-off, audit-logged). The admin page now renders an Analytics section (period toggle lifetime/year/month + KPI tiles + per-tenant breakdown) and a Receivables section (per-currency owed totals + list with Retry / Write-off). Honest empty-states ("no revenue-share restaurants yet"). 3 helper tests (70 revenue-share total); tsc 0 project-wide. RS-7 super-admin console is complete (force-migrate UI is the only optional extra). Remaining Free Growth: RS-9 owner-gated real money; visual verification pending UAT. - Phase 22 — Restora Agent OS (RAOS): OS-0.1 — de-dup the governance engine (RAOS now delegates to RACM's gate). Removes the duplicate 4-level decision ladder OS-0 shipped while RACM was still uncommitted.
lib/raos/risk.tsdecide()now routes the actual gate decision through RACM's purescoreRisk+evaluateGate(lib/racm/governance) — there is now ONE governance engine (architecture: "RACM defines the policy; RAOS enforces it"). RAOS keeps only what's genuinely its own: the agent model, its 4-mode presentation vocabulary, and its per-layer switch (raos_enabled/ agent-paused ⇒ the layer is inert → recommend; L4 ⇒ always blocked). The RACM config is INJECTED (the route reads RACM's live config fromgetRacmDB()), so RACM's kill-switch / execution-freeze / risk-threshold now govern RAOS autonomy too — engage RACM's kill-switch and the RAOS cockpit shows every capability blocked; freeze execution and L1 auto-execute pauses. No duplicated thresholds (risk.ts imports only RACM's pure governance fns; theautoExecuteMaxRisklives solely in RACM).RISK_LEVELSkeeps RAOS labels but its approval tier + autonomy ceiling now derive from RACM's `requiredApproval` (the legend can't drift from the gate).buildRaosSnapshot(raosEnabled, config)threads the live config and surfaces agovernanceblock;/admin/raosshows the auto-threshold and a kill-switch/freeze banner. Behaviour vs OS-0 is identical except a non-reversible L1 now escalates to approval-required (RACM gate) instead of a silent recommend — more correct. 30 RAOS tests (5 new: delegation, kill-switch halts all autonomy, freeze pauses L1, governance surfaced, RACM-derived legend); tsc 0 project-wide; full suite 2659/2659. Completes the RAOS↔RACM unification; next: OS-1 shared memory (pgvector, shared with Support Brain) · OS-2 agent bus. - Phase 21 — Autonomous Company Mode (RACM): AC-6/AC-7 deterministic core — outcome metrics + the learning loop + cockpit Insights. Rounds out the cockpit with the analytics the design's §1/AC-7 calls for, plus the AC-6 learning loop — computed PURELY from RACM's OWN ledger (no external engine, no LLM, no clock; reads nothing it doesn't already own → zero coupling, no RAOS dependency, no contended files). New
lib/racm/metrics.tsbuildRacmMetrics(actions): execution success rate, rollback rate, autonomy ratio (gate-auto-approved vs human-decided, read from each action's version history), high-stakes-held-for-humans (L3/L4 that never auto-ran — the governance win, stated honestly), throughput by level/status, and per-capabilityTypeStatwith a deterministic learning signal (healthy/watch/review/insufficient). The learning loop only RECOMMENDS (design §5/§6): a clean history surfaces as "eligible for a human-reviewed autonomy check" and a high failure/rollback rate flags "review this capability" — it never auto-raises an action's autonomy ("the system can't silently grant itself more power"). Honest-null throughout (empty ledger → null rates, never fabricated 0%/100%). Wired additively intogovernanceSnapshot(metricsfield) and surfaced as an Insights panel on/admin/racm(four metric cards + the learning-signals list, recommend-only labelled). 8 new tests (null-honesty, autonomy ratio, high-stakes held, success/rollback rates, the three learning signals incl. the human-gated "never auto-raised" guard). Total RACM 47 tests; tsc 0 (racm); eslint 0; cockpit route compile-clean on live dev. This is the deterministic core of AC-6 (learning loop) + AC-7 (dashboard numbers); the heavier remainders — the Guardian-backed infra optimizer (AC-6) and the LLM Executive briefing (AC-7) — stay for later. Next substantive slice: AC-2 council (depends on Phase 22 RAOS) or AC-4 (wire executors into a live engine, L2). - Phase RS — Free Growth (7% revenue share): RS-7 super-admin dashboard + nav (now discoverable). The RS-7 control-plane API now has a UI: new admin page
app/admin/dashboard/revenue-share/page.tsx("use client", mirrors the feature-flags page) — enable/disable the package, set the global fee %, and add/remove per-restaurant overrides, all against the existing/api/admin/revenue-share; carries an explicit mock-first ("recorded, not charged") honesty banner. Added a "Revenue Share" entry to the AdminSidebar Billing & Revenue group (/admin/dashboard/revenue-share,Walleticon — single additive line, verified the diff contained no other session's edits). tsc 0 project-wide. Remaining: analytics + receivables/retry dashboards (need cross-tenant fan-out endpoints); visual verification pending UAT. - Phase 21 — Autonomous Company Mode (RACM): AC-3 first safe L1 executors (the MVP completes). With AC-0 (governance) + AC-1 (decision engine + queue) in place, AC-3 gives the approvals queue real executors — but only the safest ones. New
lib/racm/executors.ts: the executor framework + six deterministic, INTERNAL, REVERSIBLE executors for the L1 reversible capabilities (cs.task→ internal task ·churn.tag→ at-risk tag ·content.draft/seo.draft→ unpublished draft skeleton ·experiment.schedule→ scheduled A/B test ·product.proposal→ advisory note). Each produces a typedRacmArtifactattached to the action and supplies a rollback note. No LLM, no external-engine write paths touched — they create a draft/task/tag/scheduled record internal to the RACM store, so the slice is trivially reversible and can't collide with other engines (wiring executors into the live Growth/Web Builder/Support engines is the human-approved L2 step, AC-4).applyExecution(action, now)(PURE) advances an approved action approved → executing → executed|failed, attaching the artifact + note (refuses anything not approved, or a type with no executor — so L4 can never execute). AdditiveRacmArtifact+artifact/executionNoteonRacmAction.
- Ledger wiring:
executeAction(id)(kill-switch-guarded, persists the executed/failed record + artifact, audited);rollbackActionnow runs the executor's rollback for an honest reversal note; and `proposeAction` auto-EXECUTES a gate-cleared safe L1 end-to-end (proposed → approved → executed in one atomic mutate) — so "AI safely executes only reversible internal ones" is real, while everything else still waits in the queue. - `execute` op added toPOST /api/admin/racm/governance; the `/admin/racm` cockpit gains an Execute button on awaiting-execution L1s (non-L1 show an honest "executor lands in AC-4") and an Executed panel showing each artifact (summary + body) with a Roll back control. - This completes the design's MVP: AI proposes structured, risk/ROI-scored actions into one approvals queue, and safely executes only reversible internal ones — L4 never, kill-switch + rollback + audit on everything. 7 new tests (executor coverage = L1-reversible only / never L4, determinism, the approved→executed lifecycle + guards). Total RACM 39 tests; tsc 0 (racm); eslint 0; the three RACM routes compile clean on the live dev server (401 auth / 307 login-gate, no 500s). Next: AC-2 AI Executive Council (event-triggered, budget-capped) — depends on Phase 22 RAOS (concurrent session building `lib/raos/`).- Phase RS — Free Growth (7% revenue share): RS-8 customer-facing UI (comparison + banner + flag-gated page). Additive, self-contained UI matching the app's design system (Tailwind,
#bf601daccent,rounded-2xlcards) — NO edits to the (cross-session-contended) signup/dashboard pages. Newcomponents/revenue-share/PlanComparison.tsx("Free Forever — pay 7% only when you sell" vs the flat 299 plan, with the honest break-even note: above ~4,271 SEK/mo in fees the flat plan is cheaper),components/revenue-share/RevenueShareBanner.tsx("You're on Revenue Share Billing" — for the dashboard, plain component so it renders server- or client-side), and a flag-gated public pageapp/free-growth/page.tsx(server component:isRevenueShareBillingEnabled()gates it — shows the offer + "how it works" when ON, an honest "not available yet" when OFF; respects the fail-safe-OFF master switch end-to-end). tsc 0 project-wide. Deferred (contended): mounting the comparison into the existing register flow + the banner into the dashboard billing page are one-line additive integrations left to avoid clobbering the parallel session editing those files; visual verification (run :3001, screenshot /free-growth) pending UAT. - Phase RS — Free Growth (7% revenue share): RS-7 super-admin control-plane (fee rules API). The backend for managing the package — enable/disable the master flag, set the global fee %, and add/remove per-tenant overrides. New PURE helper
lib/revenue-share/rules-admin.ts:addGlobalRule/addTenantOverride(APPEND new dated rules — history preserved;resolveFeePercentpicks the latest),endTenantOverride(close byeffectiveTo, never delete → tenant falls back to global),listActiveTenantOverrides. New additive routeapp/api/admin/revenue-share/route.ts(requireSuperAdmin, audit-logged, mirrors the feature-flags route): GET package status (flag mode + effective global % + active overrides) and PATCH actionsset-enabled(creates/sets therevenue_share_billingflag — persists the RS-0 seed builder),set-global,set-tenant-override,remove-tenant-override. No money moves; the route is a thin wrapper over tested pure helpers. 6 helper tests; tsc 0 project-wide. Remaining RS-7/RS-8 (UI): analytics + failures/retry dashboards, the admin React page + nav, and the signup/dashboard UI — those touch the most cross-session-contended surfaces and benefit from the ui-ux skill + visual verification. - Phase 21 — Autonomous Company Mode (RACM): AC-1 Decision Engine + pending-approvals queue + the Autonomous Company Mode cockpit. Builds on the AC-0 governance keystone. Every action already carried the structured §1 decision record (Problem · Opportunity · Evidence · Expected Impact · Confidence · Effort · Risk · Recommended Action · Approval · ROI); AC-1 adds the ranking + the surface a founder actually uses.
- `lib/racm/decision.ts` (PURE) —
priorityScore(deterministic 0–100 from impact + confidence + effort-efficiency + level-stakes; missing fields fall back to a neutral midpoint, monotonic in impact);toDecisionRecord(the §1 record + acompletenessshare so the UI shows honest "—" gaps, never fake completeness);buildApprovalsQueue(onlyproposedactions are pending a decision — ranked by priority, ties broken by higher risk then newest — grouped into Executive L3 / Human L2 / Manual-commit L4 tiers;approvedactions are surfaced as awaiting-execution, the AC-3 executors' inbox; executed/rejected/rolled-back/blocked drop out). - Additiveimpact?: 'low'|'medium'|'high'on the proposal/action (structured magnitude feeding priority) — backward-compatible. - `GET /api/admin/racm/queue` (super-admin) — the ranked queue built from the ledger. - `/admin/racm` — the Autonomous Company Mode cockpit (server-gated superadmin, self-contained client; does NOT touch the shared Mission Control client/sidebar to avoid concurrent-session conflicts): the master-switch state (racm_enabled), the kill-switch + L1-freeze controls, the risk ceiling, status counts, the approvals queue (decision cards with level/risk/priority + the §1 narrative + Approve/Reject — "Commit (human)" for L4), the awaiting-execution list with Roll back, the permission registry with per-capability enable/disable, and a propose-through-the-gate form (so the gate is demonstrable before the AC-2 council feeds it). All controls POST to the audited AC-0 ledger. - 8 new tests (priority determinism/monotonicity/fallback, decision-record completeness, queue grouping + ranking + exclusion). Total RACM 32 tests; tsc 0 (racm); eslint 0. Next: AC-2 AI Executive Council (event-triggered, budget-capped) → AC-3 first safe L1 executors.- Phase RS — Free Growth (7% revenue share): RS-6 "already-settled" monthly statement. The optional, PAID, watermarked monthly summary the restaurant hands its accountant — bookkeeping only; the fee was already taken per transaction, so NO additional payment is due. Additive schema:
MonthlySettledStatement+TenantDB.monthlyStatements?. Newlib/revenue-share/statement.ts:buildMonthlySettledStatement(ledger, {periodKey, currency, …})(PURE — aggregates the month's settlements via RS-1deriveSettlements: total gross sales / platform fees / VAT-on-fees / transactions;status:'paid',watermarkconstantSETTLED_WATERMARK),statementToCsv,buildStatementEmail(branded subject/text/html with the "no additional payment required" framing, mirrors the invoice-email pattern),recordMonthlyStatement(upsert by tenant+period — regenerable). The PDF reuses the existing pdf-lib invoice renderer at the route layer (deferred to RS-7/RS-8). Mock-first. 5 tests; tsc 0 project-wide. Next: RS-7 super-admin console + RS-8 signup/dashboard UI (the route/UI wiring for all the pure engines). - Phase 22 — Restora Agent OS (RAOS): OS-0 keystone (agent registry + lifecycle + the permission/risk engine + audit). First slice of the shared substrate beneath every AI agent that already runs standalone today. Deterministic, additive, AI-OFF, fail-safe OFF, no schema change, no second warehouse — reuses
writeAuditLog+ the feature-flag engine. Newlib/raos/:
- `registry.ts` — the modular agent registry (code-defined, read-only): a catalog of the 11 live agents (Growth, Guardian, Compliance, Social Autopilot, Profit, Launchpad, Food Catalog, Experience Studio, WoW Clone, FSN, Mission Control) plus 2 honestly `planned` (Support Brain, Intelligence Engine/RIE — absent on this branch, so listed with no libPath rather than asserting code that isn't here). Each descriptor declares domain, an honest repo-relative
libPath(a test asserts every active agent's path exists on disk), trigger, lifecycle, the reused AI-budget source, and capabilities tagged with their inherent risk level. Add an agent = append a descriptor (no other code changes). - `risk.ts` — the permission/risk ENGINE (the mechanism): the 4-level model (RISK_LEVELSL1 reversible/internal → L4 legal/financial/pricing/contracts) + a conservative verb→risk classifier (unknown verb → L3, never auto) +decide(action, governance)— fail-safe by construction: L4 is ALWAYS blocked (never autonomous) regardless of flags; RAOS-off / agent-paused ⇒ nothing autonomous (L1–L3 degrade to recommend); L1 auto-runs only when enabled AND reversible. INTEGRATION NOTE: Phase 21 RACM AC-0 (lib/racm/, built concurrently) owns the canonical governance; per owner decision 2026-06-25 OS-0 ships a self-contained default to avoid coupling to that uncommitted module, with a documented OS-0.1 follow-up to delegate to RACM's gate so there is ONE governance engine (the agent registry/snapshot/UI stay RAOS-owned — RACM has no agent model). - `snapshot.ts` — pure aggregation: agent counts by domain/trigger/lifecycle, capabilities by risk, and the autonomy posture (how active capabilities would be handled right now under the master switch — with RAOS off, zeroautoand every L4 staysblocked). `audit.ts` —recordAgentActionrecords to the shared audit log (PII-freeraos.<agent>.<capability>keys); RAOS adds no audit store. `flag.ts` — theraos_enabledmaster switch (fail-safe OFF, mirrors RIE'srie_enabled) + a pure seed builder. - Super-admin surface:GET /api/admin/raos(requireSuperAdmin, no persist, no AI) + a read-only/admin/raoscockpit (summary tiles, an autonomy-posture bar, the 4-level legend, and the agent registry with per-capability risk badges) + an "Agent OS" sidebar entry. 25 tests (risk fail-safe + L4-never-auto; registry honesty incl. on-disk libPath check; snapshot reconciliation; audit formatting); tsc 0 project-wide; full suite 2594/2594. Next: OS-1 shared memory (pgvector, reused with Support Brain) · OS-2 agent bus (Postgres + cron + SSE doorbell) · OS-0.1 RACM gate delegation.- Phase RS — Free Growth (7% revenue share): RS-5 Scenario-B internal withdrawal + retry. The collection mechanic for non-split rails: the full amount lands with the restaurant and the platform fee is withdrawn by a daily sweep; a failed withdrawal becomes a receivable chased on backoff. Additive schema:
SettlementFailure+TenantDB.settlementFailures?. Newlib/revenue-share/withdrawal.ts:runInternalWithdrawals(db, collect, now)(sweeps pending internal settlements →settledon success),runFailureRetries(re-attempts due receivables),applyWithdrawalOutcome(PURE state machine — success settles + recovers any open failure; failure upserts an open/retryingSettlementFailurewithamountOwed= fee + VAT-on-fee,attempts++, backed-offnextRetryAtviaRETRY_BACKOFF_HOURS[24/48/96/168h]),dueFailures,writeOffFailure(admin, RS-7),summarizeOpenFailures(super-admin receivables view). The collection call is INJECTED (collect) so success+failure paths test without real money (production wraps the RS-2 provider'stransferPlatformFee); mock-first, no live cron wired yet (lands with RS-7/RS-8). 8 tests; my files tsc-clean. Next: RS-6 monthly statement. - Phase 21 — Autonomous Company Mode (RACM): AC-0 governance framework (the keystone — governance-first, fail-safe OFF). First slice of the capstone: the governed-execution layer over the engines already built. Builds almost no new business logic — it builds the governance matrix, the decision/approval/rollback machinery, and the audit/kill-switch backbone (design doc:
docs/architecture/restora-autonomous-company-mode.md). Nothing executes without passing this layer (design §0). Newlib/racm/:
- `governance.ts` (PURE — no I/O, no clock, no randomness; an audit can replay any decision). The 4-level approval matrix L1/L2/L3/L4 →
requiredApproval(none/human/executive/never); a deterministic 0–100scoreRisk(additive, explainable, monotonic factors: level base + irreversible + monetary + blast-radius + low-confidence) →riskBand;classifyLevel(registry default, an override may only RAISE never lower —pricing.changestays L4 even with an L1 override); the central `evaluateGate` (kill-switch → blocked · disabled type → blocked · L4 → needs-approval, NEVER auto · L2/L3 → needs-approval · L1 only auto-executes when reversible, not frozen, and under the risk threshold); acanTransition/applyTransitionstate machine (legal-only transitions + appended version history);buildAction(classify → score → assemble → gate). - `registry.ts` — the per-action permission/capability registry (design §3): 19 seed capabilities mapped to the EXISTING engine each governs (SEO/marketing/churn/CS/product = L1 draft; publish/campaign/supplier = L2; partnership/expansion/infra/plan = L3; `pricing.change`/`financial.commit`/`contract.execute`/`compliance.file` = L4, recommend-only, a human commits — mirrors Tax TC-0's "no auto-filing"). An unregistered type can't be proposed at all.mergeRegistrypreserves operator enable/level edits while appending new code defaults. - `enabled.ts` — the master kill-switch: theracm_enabledfeature flag, fail-safe OFF (absent flag ⇒ disabled), mirroring RIE'srie_enabled; the in-appRacmConfig.killSwitchis the inner emergency stop an operator hits instantly without touching flags. - `store.ts` — an isolated `racm` doc store, self-contained (reuses the genericapp_documentsrow-lockedmutateDocseam + a JSON-modedatabases/racm.jsonatomic write + in-process mutex) so it does NOT edit the shareddb.ts(no concurrent-session collisions); normalizes older docs forward; caps the ledger at 5,000. - `ledger.ts` — the audited lifecycle:proposeAction(classify→gate→persist→audit; reflects the gate by auto-approving safe L1 or blocking, else leaves it in the approvals queue),approveAction/rejectAction/rollbackAction/recordExecution(AC-3 hook), and config controlssetKillSwitch/setExecutionFrozen/setActionTypeEnabled— every state & config change writes `writeAuditLog` (reused, not duplicated). - `POST/GET /api/admin/racm/governance` (super-admin gated): GET returns{enabled, config, registry, counts, recent}(safe even when RACM is off — reportsenabled:false); POST dispatches the audited controls + propose/approve/reject/rollback. - AC-0 records & governs; it executes no business actions (executors are AC-3). Reuses the feature-flag engine,writeAuditLog, the doc-store seam — no new warehouse, no duplication (design §3). 24 pure-core tests (approval matrix, risk bands/determinism/monotonicity, override-raises-not-lowers, the full gate truth table incl. L4-never, the transition state machine, registry integrity asserting the four L4s). tsc 0 (racm files), eslint 0. Next: AC-1 Decision Engine + approvals-queue dashboard (inside Mission Control) → AC-3 first safe L1 executors.- Phase RS — Free Growth (7% revenue share): RS-4 reporting rollups (tenant dashboard + platform analytics). Pure aggregation for the fee dashboard and super-admin analytics — source of truth is the existing payment ledger, projected to settlements by RS-1 (no parallel store, no I/O). New
lib/revenue-share/reporting.ts:buildRevenueShareReport(ledger, {tenantId, plan, rules, country, currency, now})→ per-periodPeriodMetricsfor today / this week / this month / this year / lifetime (gross sales, platform fees, sale VAT, VAT-on-fees, net revenue = gross − fees − vat − psp, payment fees, refunds [from ledger refund rows], payouts, tips, order count). Start-of-period boundaries (UTC, week starts Monday viaperiodBoundaries) so periods nest cleanly; optional currency filter.aggregatePlatformAnalytics(rows)→ cross-tenant totals (platform earnings = Σ fees, restaurant payouts, gross) + per-tenant breakdown sorted by fees. Mock-first; no route/UI wiring yet (lands with RS-7/RS-8). 6 tests; tsc 0 project-wide. Next: RS-5 Scenario-B withdrawal. - Phase 20 — Mission Control: discoverability (admin nav link). The founder cockpit at
/admin/mission-controlwas fully built but only reachable by typing the URL — no nav entry existed anywhere. Added a "Mission Control" item to the admin sidebar's Dashboard group (components/admin/AdminSidebar.tsx, next to Overview/Guardian, reusing the already-importedGaugeicon — no new import). The page keeps its own super-admin server gate. tsc 0. - Phase RS — Free Growth (7% revenue share): RS-3 auto-journal (settlement → balanced double-entry). The headline accounting value — each settlement auto-posts balanced double-entry journal entries so the restaurant's books stay continuously current, feeding the EXISTING bookkeeping / SIE / P&L pipeline (no parallel ledger). Additive schema:
JournalLine+JournalEntryinterfaces +TenantDB.journalEntries?. Newlib/revenue-share/journal.ts:postSettlementJournal(settlement)→ a SALE entry (Dr Bank / Cr Sales-by-rate / Cr Output-VAT-by-rate) + a FEE entry (Dr Platform-fee expense / Dr Input-VAT-on-fee / Cr Platform payable), each independently balanced (Σdebit === Σcredit, asserted) with figures taken straight from the RS-1 settlement so they reconcile exactly (e.g. 100 SEK SE: Dr 1930 100 / Cr 3002 89.29 / Cr 2621 10.71; Dr 6570 7 / Dr 2640 1.75 / Cr 2440 8.75). PluggableRevenueShareAccountMap(SE BAS default, country-swappable); dated VAT-rate → account selection via the existingresolveVat; deterministic idempotent ids (je_<ledgerId>_sale/_fee);recordJournalEntriesidempotent persist; refund/reversal settlements postrefund/fee-reversalentries that still balance; unsupported VAT jurisdictions post a VAT-free balanced sale. Mock-first (no money moves). 8 tests; tsc 0 project-wide. Next: RS-4 reporting. - Phase RS — Free Growth (7% revenue share): RS-2 settlement provider abstraction (mock split + internal). The provider-agnostic seam every settlement flows through (mirrors
lib/payments/registry). Newlib/revenue-share/settlement-provider.ts: theRevenueShareSettlementProviderinterface (processTransaction/splitPayment/transferPlatformFee/refundTransaction/reverseFee, async to match real rails) + two MOCK providers — `mockSplitProvider` (Scenario A: a marketplace rail splits fee from payout AT capture →settledimmediately) and `mockInternalProvider` (Scenario B: full amount lands with the restaurant →pending, fee collected later viatransferPlatformFee→settled;splitPaymentis an honest unsupported failure) — plusresolveSettlementProvider({paymentMethod, scenario, mode})(rail-aware:SPLIT_CAPABLE_RAILS→ split, else internal;mode:'live'throws — owner-gated RS-9). Both providers reuse RS-1buildSettlement(no money moves;mock:true); refund / fee-reversal return proportional negative mirrors. 10 tests; tsc 0 project-wide. Next: RS-3 auto-journal. - Phase RS — Free Growth (7% revenue share): RS-1 settlement model (mock-first, derive-from-ledger). New
TransactionSettlementrecord (additive schema +TenantDB.settlements?) — a permanent per-paid-charge snapshot of the money split (gross / sale-VAT / net / tip / fee% / fee / VAT-on-fee / PSP-fee / payout), with the invariantplatformFee + restaurantPayout + paymentProviderFee − tipAmount === grossAmount. Newlib/revenue-share/settlement.ts:buildSettlement(PURE money math — fee% from RS-0resolveFeePercent, sale + fee VAT from the existing datedresolveVatengine, tips pass through 100% and are excluded from the fee base),deriveSettlements/settlementFromLedgerEntry(READ-ONLY projection over the existing append-onlypaymentLedger— only paid charges, canteen top-ups excluded — the mock-first reporting source, so NO live write path is hooked),recordSettlement(idempotent persist keyed byledgerEntryId— the entry-point the owner-gated real-money path / a backfill will call),shouldSettle(gate: revenue-share plan + flag on → inert for every existing subscriber). No money movement (mock:true; "recorded, not charged"); reuses the commission % + VAT engines (no parallel engine). 11 tests; tsc 0 project-wide; full suite green. Next: RS-2 provider abstraction · RS-3 auto-journal. - Phase RS — Free Growth (7% revenue share): RS-0 keystone (additive plan + fail-safe master switch). First slice of a NEW, additive billing model — free + every feature, monetised by a per-transaction platform fee instead of a subscription (design doc:
docs/html_reports/restora360-free-growth-revenue-share-implementation.html). Zero changes to existing plans, prices, permissions, or billing logic. New built-in planfree-growth-7pct(billingModel:'revenue-share', 0/mo, 7% commission, all features) added ALONGSIDE the untouched 8%growth-partner(lib/plans/defaults.ts); it shipsisPublic:falseso it stays invisible at signup until a super-admin enables it. Newlib/revenue-share/:rules.tsresolveFeePercent(PURE fee resolution — per-tenant rule → global rule → plan commission → default, with dated windows + per-country overrides + [0,100] clamp),eligibility.tsgetSignupEligiblePlans(revenue-share plans surfaced ONLY when the flag is on; not yet wired into signup — that's RS-8),flag.tsisRevenueShareBillingEnabled(wraps the standard feature-flag engine, fail-safe OFF) + PURE seed builders for the global 7% rule and therevenue_share_billingflag (modeoff). Additive schema only:RevenueShareRuleinterface +MasterDB.revenueShareRules?. Reuses the existing testedcomputePlatformCommissionfor the fee MATH (no new money engine); no settlement / money movement in RS-0 (owner-gated, mock-first). Seeding is deferred (the lib has safe code-level defaults, so the package is fully inert until enabled). 13 tests (fee precedence/overrides/date-windows/clamp; plan wiring; signup gating; growth-partner untouched); tsc 0 project-wide; full suite green. Next: RS-1 settlement model · RS-3 auto-journal. - Phase 20 — Mission Control: MC-2/MC-3/MC-0.7/MC-5/MC-6 (the heavier slices — buildable scope COMPLETE). Five deterministic, AI-OFF, honest-null slices that finish the founder cockpit, each a surface over engines already computed (committed code-first as
30d7df7c/d8388e06/39dd6194/018e53d3/69375cd2; this is their consolidated doc entry).
- MC-2 — intervention plans (
30d7df7c).interventionFor(reasons, bucket)inlib/mission-control/tenant-health.tsmaps each at-risk tenant's worst health reason to a concrete recommended action (canceled→win-back, past-due→recover the failed payment, trial→conversion check-in, inactive→re-engagement…); attached to every row and shown under each name in the At-risk panel, so the founder sees not just who is at risk but what to do. - MC-3 — Business Risk Center + a live Risk pillar (d8388e06).lib/mission-control/risk-register.tsbuildRiskRegister(ctx)derives STRUCTURAL/financial risk purely from the loaded Growth metrics — deliberately DISTINCT from the War Room's "what's on fire now": liquidity (signups but zero paying), revenue concentration (≥65% of MRR from one channel), churn banding, weak trial→paid conversion. Each ties to one mitigation; a deterministic posture (100 = low risk) rolls up to a RAG and now feeds the previously-pending Risk pillar (the calibrated score-engine tests are unaffected — they never pass.metrics). No financial data → empty register + honest null posture (never a false all-clear). New Risk Center panel under the War Room. - MC-0.7 — trend log ("are we improving?") (39dd6194).lib/mission-control/trend.ts(PURE) records a tiny daily snapshot of the ecosystem + 10 pillar scores and derives current + day-over-day + week deltas (all null when not computable). Storage is a DEDICATED store — a localMissionTrendDBtype + an additivedb.tsaccessor (getMissionTrendDB/saveMissionTrendDB) to its ownmission-control-trend.jsondoc, NOT the MasterDB schema. Capture is SNAPSHOT-ON-READ, deduped per UTC day, capped to 90, nulls preserved; best-effort (a persistence failure serves the cockpit without the trend, never breaks it). New Sparkline + TrendStrip under the ecosystem card (hidden until ≥2 days of history). A guaranteed-capture cron is a later additive MC-0.7b. - MC-5 — Opportunity Detector (018e53d3).lib/mission-control/opportunities.tsbuildOpportunities(ctx, adoption)is the upside mirror of the Risk Center: scale a proven efficient channel (≥3× LTV:CAC, ≥2 customers — needs a KNOWN acquisition cost, so it never fabricates efficiency), convert the open trial backlog, deepen shallow adopters (from the MC-4 distribution). Surfaces ONLY when the data supports it. New Opportunity panel under the Risk Center. - MC-6 — 90-Day Focus Board (69375cd2).lib/mission-control/focus-board.tsbuildFocusBoardis a pure SYNTHESIS that introduces no new data — it RANKS the single strongest signal from each engine (top active incident, top structural risk, worst at-risk tenant, top opportunity, lowest concern pillar) into one prioritized, horizoned board (now / 30 days / this quarter), capped at 6, each with a "why" line. Only red/amber pillars are eligible; a healthy row is ignored; owner chose to keep it deterministic (no Haiku narration). New Focus Board panel under the briefing. - 31 new tests across the five (interventions; risk banding + suppression; trend dedup/cap/null-honesty + a verified DB round-trip; opportunity cost-unknown/n=1 suppression; focus-board ranking/horizon/all-clear). tsc 0; full suite 2476/2476. This completes the buildable Phase 20 scope (MC-0…MC-6); remaining is owner/go-live only (a strategic-planning framework if wanted, optional Haiku narration behind a MasterDB AI-cap, the optional MC-0.7b cron).- Phase 20 — Mission Control: MC-4 Ecosystem Value Score (adoption depth → stickiness). The genuinely net-new scoring concept.
lib/mission-control/ecosystem-value.ts: per-tenant configured adoption depth = plan-tier base (basic/premium/enterprise) + the count of ENABLED add-ons (addonSubscriptionsactive/trialing, incl. bundled suite features) + non-expiredfeatureGrants, clamped 0–100, rolled up to a platform score + a deep/moderate/shallow distribution + the deepest adopters. HARD BOUNDARY (tested): it reads ONLY configured/enabled state (MasterDB subscriptions + addonSubscriptions + featureGrants) and has NO event/usage input — so it structurally cannot measure OBSERVED usage (the Intelligence Engine P47's domain); Mission Control never imports `lib/intelligence`. MasterDB-only (no per-tenant DB fan-out). Surfaced as an Ecosystem Value cockpit panel beside the at-risk list. 5 tests (depth = base + enabled features, canceled/expired excluded, demo excluded, the no-usage-boundary assertion, null on empty). tsc 0; full suite 2433/2433. - Phase 20 — Mission Control: MC-1c AI Copilot (completes the MC-1 trio).
lib/mission-control/copilot.ts(answerFounderQuestion): a DETERMINISTIC-first founder copilot that answers the common questions — "what should I focus on", "biggest risks", "how's growth/customers/compliance/infrastructure" — straight from the computed scores + briefing + War Room alerts. No LLM, no fabrication (every numeral comes frombuildMissionScores); this rule-based answer is the shipped artifact + the no-key/over-budget fallback, and a later Haiku pass may only rephrase it behind the advisor-narrate number-guard.POST /api/admin/mission-control/copilot(super-admin + rate-limited) + a Copilot chat panel on the cockpit. With MC-1a (briefing) + MC-1b (War Room), the AI Founder Assistant suite is complete: the cockpit now shows Ecosystem → War Room → Founder Assistant → At-risk restaurants → 10 score cards → Copilot. 5 tests; tsc 0; full suite 2428/2428. - Phase 20 — Mission Control: MC-1b Executive War Room (live alerts).
lib/mission-control/war-room.ts(buildWarRoomAlerts): a PURE surface of signals already detected by the engines — Guardian ACTIVE incidents (resolved excluded; P1/P2→critical, P3→high, P4→medium) + active compliance issues — ranked by severity, with links to the centers. No re-detection, no new alert engine; empty sources render "All clear", never a fabricated incident. Added to the cockpit as a War Room panel above the briefing (and into theGET /api/admin/mission-controlpayload). 4 tests; tsc 0; full suite 2423/2423. - Phase 20 — Mission Control: MC-1a Founder Assistant briefing + MC-0.5 per-tenant health (named at-risk list). Builds on the MC-0 keystone. MC-1a (
lib/mission-control/briefing.ts,buildFounderBriefing): a deterministic daily briefing assembled purely from the 11 scores + their tied actions — a headline (the worst-status-escalated Ecosystem line), Today's priorities (red pillars), Needs attention (amber/red, worst-first), Good news (green), and an honest "pending sources" line. NO LLM call: this rule-based briefing IS the shipped artifact (the FBL/RIE no-AI-invented-numbers lesson); a later Haiku pass may only rephrase behind a number-guard. The meta Data-Confidence score is excluded from the business sections (it's a coverage signal, not a priority). MC-0.5 (lib/mission-control/tenant-health.ts,buildTenantHealthList): the one PII-bearing surface, kept separate from the count/rate keystone — per-tenant rows{tenantId, name, score, bucket, reasons[]}, worst-first, founder-only (super-admin) showing tenant BUSINESS identity (no end-customer/diner PII), via a new founder-gatedGET /api/admin/mission-control/tenants. Reuses the SAMEhealthScore()+ signal shapeloadLiveSnapshotaggregates, so the per-tenant list reconciles exactly with the Company/Customer-Success scores. The cockpit now renders the Founder Assistant briefing + a named "At-risk restaurants" panel (with explainable reasons) above the score grid. NO schema change. 7 new tests (briefing sort/cap/no-fabrication; per-tenant scoring, worst-first, demo-excluded, neutral-unknowns). tsc 0; full suite 2419/2419. - Phase 20 — Restora Mission Control: the MC-0 keystone (founder cockpit, surface-not-rebuild). The capstone executive surface — its first slice is a deterministic, AI-OFF score-aggregation engine that READS the engines already built and recomputes nothing they own. New
lib/mission-control/:score-engine.tsemits 11 health scores in the Profit-Score shape ({score:0-100|null, rag, confident, subScores, action}),context.ts(loadMissionContext) fans out ONCE to existing accessors —loadLiveSnapshot()(Growth P16),loadGuardianSnapshot()(Guardian P17),buildAnalytics(getComplianceDB())(Compliance P19) — each try/catch-isolated so a slow/broken engine degrades to a null card. Four scores are real end-to-end (Company + Customer Success off the SAMEhealthDistributionbut with DISTINCT framings; Infrastructure off Guardianhealth.pct; Compliance offcomplianceRate), the rest are honest null stubs, and Innovation is a permanent null (Support Brain isn't on this branch — no synthetic vanity proxy). The adversarial design review caught three traps before any code, all baked in: (1) AI is OFF in MC-0 so there's noAIFeaturedependency (the union has nomission-controlmember — usingrecordAIUsagewould be a compile error; platform AI later uses a MasterDB daily-cap, not the per-tenantgetBudgetStatus); (2) Compliance null-guards ontotalIssues===0, NOT the rate (buildAnalyticsreturns 100 on empty → a fresh platform must never show a confident green "100% compliant"); (3) the keystone is strictly count/rate aggregates with NO per-tenant identity (the PII-bearing per-tenant list is a separately-reviewed MC-0.5). The Ecosystem master is a localrollupScores()— a tier-weighted average for the number but worst-status escalation for the RAG (one red pillar can't average into green), extracted from thecomputePlatformHealthpattern (which takesServiceHealth[], not scores). ReusesragForScorefromlib/profit/rag.ts(one RAG definition). The/admin/mission-controlpage (server-gated superadmin) renders the Ecosystem headline + 10 pillar cards (null → em-dash + "No data yet"; low-confidence → "seed-backed" badge). NO schema change (computes-and-returns; the trend-log cron is MC-0.7). 8 engine tests (no-fabrication on empty, thetotalIssues===0guard, RAG bands, the Company≠CS invariant, worst-status escalation, Innovation-always-null). tsc 0; full suite 2412/2412. Next slices: MC-0.5 per-tenant health accessor → MC-0.7 trend cron → MC-1 AI Founder Assistant/Copilot/War Room → MC-2 rollups → MC-4 Ecosystem Value Score → MC-3 dependency map/Risk Center → MC-5/6 opportunity + planning. RIE (P47) is NOT imported (Mission Control reads the same upstream Growth/Guardian accessors; MC-4 measures CONFIGURED depth, never RIE's observed usage). - Phase 17 — Restora Guardian: CLOSE-OUT to 100% of buildable scope (commit 76ad6f67). A 10-agent adversarial audit found the earlier "buildable scope complete" claim overstated — three genuine buildable gaps (not paid-integration deferrals) were hiding behind it, now all built, wired and tested:
- GD-6 §5 statistical anomaly detection was DEAD CODE.
lib/guardian/anomaly.ts(z-score/EWMA) — the "statistical-first detection" the spec calls the heart of the AI watchdog — was imported by nothing but its own test; no code path could ever open ananomaly-source incident. Now the probe persists a compact ops metric series (newMetricSample/guardianMetrics, capped) andbuildMetricSample+detectMetricAnomaliesz-score the live latency series each cycle (≥8-sample baseline guard, live-signals-only so seed never fabricates one), opening a P3anomaly-source incident on a high-latency drift (deduped by metric; the existing budget-gated RCA narrates it). The anomaly baseline excludes the current reading (computed before the sample is appended) — no self-contamination. - GD-6 §13 predictive-failure engine was ABSENT (a named GD-6 deliverable). Newlib/guardian/predictive.tscomputePredictionsruns a least-squares trend over the persisted ops series → time-to-threshold per metric (DB read latency, synthetic latency, synthetic failure rate), surfaced in the AI Insights view with an honest "at the current trend, X in N days" label + a deterministic recommendation. Honest by construction: only metrics genuinely trending toward their ceiling within a 60-day horizon are shown; no fabricated storage/memory figures we can't measure; div-by-zero/NaN/Infinity all guarded. - GD-7 status-page subscribers were collected but NEVER emailed — the subscribe form promised "we'll email you," and nothing did. Newlib/guardian/status-notify.ts+ the probe now email subscribers on a live, customer-visible (P1/P2) incident open/resolve — sent individually (never a sharedTo:), dedup-ledgered (each transition once), recency-guarded (no months-old history on first run), cron-only (a manual "Run journeys" never mails or advances the ledger), and the dedup ledger advances only after a successful send (a total send failure — e.g. SMTP unconfigured — retries next cycle instead of silently dropping the promise; this last point fixed from the adversarial review). - Plus 5 polish fixes: synthetic monitoring now honors `expectStatus` (was a dead config field) and flags a slow-but-ok stepdegradedagainst itslatencyBudgetMs(was inert) with a UI badge; the Incident Center gained assign/note affordances (the API already supported them); analytics gained the spec §14 `1y` window; and the public "Recent incidents" list gained a 90-day cutoff so the label stays truthful. - 110 guardian unit tests (was 87); tsc 0; next build clean. project-overview.html → Phase 17 delivered/100. Architecture doc (docs/architecture/restora-guardian.md) updated. Phase 17 buildable scope is now genuinely 100% — remaining work is paid go-live integrations only (Sentry-class deep error tracking is the spec's one explicit integrate-not-build commodity layer per §0; plus managed uptime, live Slack/Twilio, browser-level Playwright, independent status hosting, managed PITR + APNs/FCM).- Phase 34 — Conversational & Social Commerce: BUILDABLE SCOPE COMPLETE (S2 + S4–S9 + dashboard UI). The full channel build-out on top of the keystone (registry) + S1 (
createTenantOrder) + S3 (TikTok), all mock-first and reusing one money path. Every settled channel posts ONE ledger charge + ONEorder.paidoutbox + a paid flip atomically (never the delivery normalizers'paymentStatus:'paid'ledger-bypass); the cross-truth invariant (computeAnalytics.totalRevenue === summarizeLedger.charges.gross) holds for each. Hardened by a 3-lens adversarial review with per-finding verification — which caught a real MAJOR money bug (below) before commit.
- S2 — deterministic conversation + PII (self-contained, no cross-branch merge).
lib/channels/conversation.tsanswers menu questions PURELY from the partner menu feed (no LLM, no fabrication — a dietary ask is a hard filter, prices come straight from the feed, an unanswerable question hands off to a human);deriveReply+classifyMessagedrive the channel webhooks.lib/channels/pii.ts(stripPii/containsPii) redacts email + phone but keeps prices/quantities (the review's over-redaction of space-separated number runs was fixed — a phone now needs a real cue: a leading+or a 4+ contiguous-digit run). - S4 — WhatsApp Business Cloud inbound (/api/webhooks/whatsapp): GET verify-token handshake + MANDATORY fail-closedX-Hub-Signature-256verify; a native WhatsApp cart →createTenantOrder(source:'whatsapp')priced against OUR menu (Restora-collected, bornpending); a text message → deterministic menu Q&A. Per-request event fan-out capped. - S5 — generic pay-link settlement (lib/channels/settle-link.ts+/api/channels/pay-link):capturePaymentOUTSIDE the lock →recordOrderPaymentINSIDE onemutateTenantDB→created-gated paid flip +order.paidoutbox (the atomic record-pos-sale shape, upgrading the older PayPal route's non-atomic pattern). MAJOR fix from the review: a FAILED capture was recorded under the SAME idempotency key as the eventual success, so a failed-then-paid PSP sequence would leave a paid order stuckpendingforever — now the key is scoped to the ORDER and the OUTCOME (${provider}:${orderId}:${linkId}, failed rows under a:failedsuffix), proven by a new failed-then-paid test. - S6 — Instagram + Messenger (/api/webhooks/meta): shared Meta verifier; IG-DM/Messenger conversational (Restora-collected) vs IG/FB Shops born-paid (partner-collected, settle providermeta), classified distinctly so a Shop order is never double-charged or stamped-paid-without-ledger. Reuses the existing FSN/Phase-48 Meta connection. The born-paid trust boundary was extracted into a shared, testedbuildPartnerOrderBody(adopted by TikTok too) that coerces every partner money field finite-non-negative and rejects a corrupt/empty total. - S7 — Order with Google + SMS (/api/webhooks/{google,sms}): Google ordering is MARKET-GATED (refuses orders where it isn't live) + priced against our menu (Restora-collected); SMS parses"<qty> <dish>"against our menu deterministically + answers menu questions, dedups on the provider message id. - S8 — Unified Conversations inbox (lib/channels/inbox.ts+/api/tenant/[id]/channels/inbox): tenant-scoped aggregation of inbound conversations across channels + staff reply + deterministic→human hand-off, idempotent on the provider message id (no double-count). RAW messages stay tenant-scoped; onlyscrubbedConversationText(PII-stripped) may ever reach a cross-tenant store. Wired into the WhatsApp/Meta/SMS webhooks. - S9 — Apple/Google Wallet loyalty passes (lib/loyalty/pass.ts+/api/tenant/[id]/loyalty/pass): a READ-ONLY pass from loyalty state — the builder takes nodb, so it structurally CANNOT touch the payment ledger — with a signed, offline-verifiable QR token and an honestmockstatus until wallet certs are configured. - Dashboard UI: a/dashboard/channelsconnector-management page (consumes the server-gatedGET /channels, so market-gated channels are already filtered) with enable/connect/sync + the inbox summary;OrderCardreconciled to show channel-sourced order pills from the single registry. (Nav-catalog entry deferred — that file is mid-edit in a parallel session.) - Tests: ~140 channel/loyalty tests; tsc 0 across all Phase-34 files; full suite 2386/2386. Go-live gates (P40 / per-platform approval): real platform API approvals (Meta WABA + app review, TTS Partner API, Google ordering), real PSP rails + Twilio signature + wallet certs, partner-currency conversion, and per-tenant accountId indexes for high-volume webhooks.- Phase 34 — P34-S3: TikTok Shop connector (market-gated, partner-collected, record-only). The first live channel + the first consumer of the born-paid hook. TikTok Shop collects the money itself, so a TTS order is born paid: the inbound webhook (
POST /api/webhooks/tiktok) lands it in the existing Orders pipeline viacreateTenantOrderwith a born-paidsettle(one ledger charge + oneorder.paidoutbox + a paid flip, atomically) — never the delivery normalizers'paymentStatus:'paid'ledger-bypass. Newlib/channels/inbound.ts(findTenantByChannelAccountIdresolves the tenant by the non-secretsettings.channels['tiktok-shop'].accountId;verifyHmacSignatureis FAIL-CLOSED — rejects when the signature is absent OR the secret is unset, the opposite of the delivery webhooks' fail-open) andlib/channels/tiktok.ts(normalizeTikTokShopOrder→ aCreateTenantOrderInput+ settle;buildTikTokCatalogprojects the reusedPartnerMenuFeedto a TTS catalog — no reforked menu extraction). The webhook is market-gated: it refuses orders (403) for any tenant where TikTok Shop isn't live (isChannelLive= thechannel_tiktok_shopregion flag on AND the tenant enabled it), so a non-live region never receives orders — honest, no fabricated availability. Redelivery-safe on two layers:clientRequestId = tiktok:<orderId>makes a redelivery an idempotent replay (no second order), and the ledgeridempotencyKey = tiktok:<orderId>is the backstop.escrow:false(Restora never holds TTS money). The adversarial review caught two real MAJOR money-correctness holes at the trust boundary, fixed before commit: the partnertotaland line-item price/quantity were coerced only against null/undefined, so a corrupt payload (total_amount:-50orNaN/Infinity, or a non-numericsale_price) would have written a negative/NaN charge into the ledger and broken the cross-truth invariant — now every money field is coerced to a finite non-negative number, a present-but-corrupt total is rejected (400), and an empty/zero-value order is rejected (no bogus paid order). 15 tests (fail-closed signature accept/reject, normalizer mapping + total/fee/discount consistency, the trust-boundary validation, the born-paid ledger+outbox+flip + cross-truth invariant + refund-linkage, redelivery idempotency, and the catalog projection). Go-live notes (mock until the TTS Partner API is approved per market): a real TTS Shop-eligibility + Partner-API approval gate is a distinct 'needs-platform-approval' state from missing-creds; and the webhook's per-IP rate limit should be re-keyed on the resolved shop/tenant (or given a global ceiling) because TTS sends from a small egress pool. tsc 0 project-wide; full suite 2333/2333. Next: P34-S4 WhatsApp inbound (needs the Support Brain merge, S2) · or S6 IG/Messenger. - Phase 34 — P34-S1: `createTenantOrder()` — the single, locked order-creation seam. Extracted the inline order-creation body of the public online-orders route (
POST /api/public/[tenantId]/orders) into a reusable helper inlib/orders/create-tenant-order.ts, so every P34 channel (and the web path today) creates an order through ONE path that assigns theorderNumberUNDER the row lock and applies coupon / loyalty / gift-card / escrow / product+ingredient stock identically — never the delivery webhooks' lock-freegetTenantDB+pushpattern (which races on the number and skips these side effects). Shape mirrorsrecord-pos-sale.ts: a pure `applyOrderCreation(db, draft, opts, now)` (mutates a passed db — unit-testable) + a thin asynccreateTenantOrderwrapper (escrow hold +mutateTenantDB+ the post-commit gift-card charge). The public route is rewired to a thin caller with zero behavioral change (verified againstgit HEADby a 3-lens adversarial review: byte-identical order object, same idempotent-replay 200, same activity-log + SMS, same 201). Two new seams for channels: anopts.sourcestamp ('whatsapp'etc. →Order.source) and an optional born-paid `settle` hook — for partner-collected channels (TikTok Shop, IG/FB Shops) whose money the platform already took, it posts ONE paid ledger charge + ONEorder.paidoutbox event + flips the order topaid, all inside the same lock (the record-pos-sale shape, never the banned deliverypaymentStatus:'paid'ledger-bypass). Default (nosettle) = bornpendingwith no ledger row (Restora-collected channels capture later via a pay-link). The adversarial review caught a real MAJOR money defect before commit: the settle hook ignoredrecordOrderPayment'screatedflag, so a redeliveredprovider:providerRefdeduped the ledger but still re-flipped a second order to paid + emitted a secondorder.paid— breaking the cross-truth invariant (revenue 500 vs ledger gross 250). Fixed: the paid-flip + outbox are now gated oncreated, and thecurrency/amountoverrides were dropped so the charge is ALWAYSorder.totalin the tenant's settings currency (invariant holds by construction). 15 tests (sequential orderNumbers, idempotent replay runs no side effects, coupon/loyalty, product+ingredient stock + backward-compat, born-paid ledger+outbox+flip, the cross-truth invariantcomputeAnalytics.totalRevenue === summarizeLedger.charges.gross, no-double-loyalty, and the redelivery dedup that now asserts the second order stays pending). tsc 0 project-wide; full suite 2318/2318. Next: P34-S2 merge Support Brain · P34-S3 TikTok Shop (first born-paid consumer). - Phase 34 (Universal Coverage P34) — Conversational & Social Commerce: the channel-connector KEYSTONE (mock-first). The start of "sell where customers already are" (WhatsApp / Instagram / Messenger / TikTok Shop / Order-with-Google / SMS). This first slice is the registry seam only — it deliberately mirrors the existing delivery-integrations pattern so every channel behaves exactly like a delivery connector, and it touches no order or payment runtime path. New
lib/channels/:types.ts(ChannelMetawithcapability+collection:'restora'|'partner'+marketGated+flagKey),registry.ts(aCHANNELStuple — the channel analog ofSYNCABLE_PLATFORMS— +CHANNELS_META, the analog ofDELIVERY_PLATFORMS, with a compile-time exhaustiveness guard pinning the tuple to theChannelKeyunion so they can't drift), and a pureengine.ts(defaultChannelConfig,resolveChannelConfig, the market/visibility gateisChannelAvailable/getAvailableChannels, the two-key live gateisChannelLive/getLiveChannels= market-flag AND tenant-enabled,hasPendingChannelSync, andrunChannelMenuSync— a clone ofrunDeliveryMenuSyncthat reuses the same `buildPartnerMenuFeed`/`validatePartnerMenuFeed` and records the same honestprepared/needs-credentials/errorstatus in place). The feature-flag lookup is injected (anisFlagOncallback) so the engine stays I/O-free and the market gate fails safe OFF — a market-gated channel (TikTok Shop, Google ordering) is auto-hidden until its flag is on for the tenant, so a tile can never leak into a region where it isn't live. Schema is additive only: a new openTenantSettings.channels?: Partial<Record<ChannelKey, ChannelConfig>>(an open record, unlike the fixed-keyintegrations, so new channels need no schema edit), a newChannelConfigthat mirrorsIntegrationConfig's field shape but holds no plaintext secrets (live tokens reuse the FSN vault in later slices; only non-secret routing ids live here), andOrder.sourcewidened to include the channel keys. Two thin API routes (owner/manager):GET/PATCH /api/tenant/[id]/channels(config + honest status, never a secret; enabling a market-gated channel is rejected server-side unless it's available in the tenant's region) andPOST /api/tenant/[id]/channels/sync(mirrors the delivery sync route). Grounded in a recon + adversarial-design workflow whose review banned the delivery normalizers' `paymentStatus:'paid'` shortcut (it writes no ledger row + emits no outbox — a confirmed ledger-bypass) as a channel money model: every settled channel in the coming slices posts one ledger charge + oneorder.paidoutbox + flips the order atomically via the P33 abstraction. 15 channel tests (registry integrity, the two-key gate, fail-safe-off market gate, honest sync status, and the feed-reuse invariant that the sync'sitemCountequalsbuildPartnerMenuFeed's); tsc 0 project-wide; full suite 2303/2303. Next slices (planned, in order): extract a locked `createTenantOrder()` seam · merge Support Brain (resolveDeterministic + PII scrubbers) · TikTok Shop (market-gated, partner-collected, record-only) · WhatsApp inbound + pay-link · Instagram/Messenger · Google/SMS · Unified inbox · Wallet passes. Dashboard UI + nav deferred (the nav catalog is mid-edit in a parallel session).
Fixed
- Google "Connect" returned HTTP 500 on a mock-mode production deploy (e.g. restora-server).
getStateSecret()threwGOOGLE_OAUTH_STATE_SECRET required in productionwheneverNODE_ENV=productionand the secret was unset — but it ran for mock connects too (signStateis called before the mock-mode branch inbuildAuthorizeUrl), so clicking Connect on the demo server 500'd. The throw was masked as long as a seed connection already existed; disconnecting it exposed the bug. Fix: the production guard now only applies in live OAuth (!isMockMode()) — in mock mode the state never leaves our own callback, so the deterministic dev fallback secret is safe. Also: the mock authorize URL is now a relative `/api/google/callback?…` path (was the absoluteREDIRECT_URI, defaulting tolocalhost:3001) so the mock loop resolves against the actual host instead of bouncing a demo-server visitor to their own machine. And/api/google/connectnow wrapsstartConnectin try/catch, returning a clean operator-readable error (e.g. a genuine LIVE-mode missing-secret) instead of a bare 500. tsc 0.
Added
- Restora Profit (Phase 43) — RP-12d.1: owner per-tenant employer-rate override (the last deferred dev item — Phase 43 is now build-complete). An owner can override the per-country employer labor on-costs (employer-fee % + holiday/vacation %) from the menu Cost & Profit tab, when their real rates differ from the dated country table. New
resolveEmployerLoad(country, override?)inemployer-rules.tsis the single entry point every loaded-labor calc uses: the owner's figures replace the country default, a fully-specified override clears the "confirm with an accountant" flag (the owner owns the numbers), while a partial or invalid (negative / NaN / cleared-to-null) value falls back to the country table for that half. Wired through all loaded-labor sites —overview-core,cost-summary, the marketing route, the 8 profit routes, and the per-dish `menu/cost-estimate` route (the Cost & Profit tab's own estimate endpoint, which previously hardcoded Sweden — caught by the review, so the per-dish preview can no longer disagree with the profit dashboards). AdditivecostSettings.employerCost(nullable so a cleared field reverts to the country default); the cost-settingsPATCHdeep-merges it so saving one field can't wipe the other; two inputs inCostProfitTab+ the loaded-rate caption made country/override-aware (was a hardcoded "12% + 31.42%"). No override = byte-for-byte the prior behavior, so existing tenants are unaffected. Hardened via a 3-lens adversarial review with per-finding verification (12 raised, 5 confirmed, 0 blockers, all fixed — the headline being the missed 12th loaded-labor site on the override's own screen). 6 newresolveEmployerLoadtests; tsc 0 project-wide; full suite 2090/2090; next build exit 0. - Restora Profit (Phase 43) — RP-12f: open Profit API. A read-only public API so a partner or the owner's accountant can pull this restaurant's profit figures — the same numbers the dashboard shows. New
ProfitApiKeyonMasterDB(additive) mirrors the existing `CatalogApiKey` engine exactly: aprk_key, only its SHA-256 hash stored, the raw shown once.profit-api/keys.ts(mint / list / revoke / verify / per-key 120-req/min rate limit) +profit-api/guard.tsauthenticateProfitRequest(Bearerprk_…→ tenant,401/429withRetry-After). Public routes under/api/public/profit/v1/: `overview` (score + cost ratios), `menu-engineering` (per-dish Kasavana–Smith matrix), `costs` (the operating-cost ledger), and a no-auth `openapi.json` so partners self-integrate — all read-only, tenant resolved from the key (no IDOR), each behind an error guard that returns a clean404instead of a raw 500 if the tenant is gone. The public surface reuses the SAME engines as the dashboard:overview/menu-engineeringgo through the sharedcomputeLocationProfitcore, andcoststhrough a new shared `cost-summary.ts` `summarizeCostLedger` (manual + auto entries — delivery / commission / loaded labor / marketing — the dashboard/profit/costsroute was refactored onto the same helper, so the public total can never undercount). Owner-authed key management at/api/tenant/[tenantId]/profit/api-keys(create returns the raw key once, never the hash;MAX_ACTIVE_KEYScap; audited) + a/dashboard/profit/apipage (mint, copy-once, revoke, curl + OpenAPI docs) + nav. Key possession is the access control; a paidprofit-apiadd-on can gate key creation at go-live. Hardened via a 4-lens adversarial review with per-finding verification; the/costsparity fix (auto-merge) + the missing-tenant error guards + the OpenAPI fidelity pass all came from it. 3 rate-limiter tests + 2 cost-core golden tests; tsc 0 project-wide; full suite 2078/2078. - Restora Profit (Phase 43) — RP-12e: multi-location profit roll-up. Chains already have a first-class
Organization(tenants linked byorganizationId+branchRole); this adds the missing consolidated profit view across every branch. The numeric heart of the per-tenant Overview was extracted into a sharedprofit/overview-core.tscomputeLocationProfit(pure; clock + DB injected) so the per-branch Overview AND the roll-up compute through one path — an org total can never silently diverge from what an owner sees on a single branch (this is the one-system rule, not a parallel engine).profit/rollup.tsrollupLocationsconsolidates with honesty baked in: money is summed only within one currency (a mixed-currency org can't be summed without an FX rate → totals null + a note, per-branch figures still shown); cost ratios are revenue-weighted (a tiny branch can't swing the org number); the org RAG + score are muted unless ≥60% of org revenue comes from confident branches (mirrors the single-venue coverage gate); the org score is an explicit revenue-weighted average of confident branch scores; idle (no-sales) branches are excluded with a note; and the cross-group worst-dish list tags each dish with its own branch currency and is flagged non-comparable under mixed currencies (never ranks SEK against EUR).GET /api/group/[orgId]/profit(gated byrequireGroupAccess+ themulti-locationadd-on; fans out per branch, skipping any unreadable one with a note) + a/dashboard/group/profitpage (org cards, consolidated P&L, a worst-prime-cost-first branch comparison, the worst dishes across the group) + nav. Hardened via a 4-lens adversarial review with per-finding verification (the mixed-currency worst-dish currency tagging + a golden test pinning the extracted core came from it). 11 roll-up tests + 2 core golden tests; tsc 0 project-wide; full suite 2078/2078. - Restora Profit (Phase 43) — RP-12d: multi-country labor cost. Loaded labor (the biggest non-food cost) was hardwired to Sweden —
31.42%employer fees +12%vacation — for every tenant regardless of country. Newprofit/employer-rules.ts(pure) holds a per-country employer-on-cost table mirroring the VAT-module pattern inlib/tax/rules.ts:employerRulesFor(country)→{ rules, resolved }withemployerTaxPct+vacationPayPctforSE/DK/FI/NO/DE/GB/US/CA(parity with the VAT country set) and a labelled generic fallback for unmodeled countries (Sweden's figures, clearly flagged — never a silent guess). Honesty-by-design, same posture as the VAT modules: only Sweden isneedsConfirmation:false(it's already in production use); every other figure is a representative public estimate (needsConfirmation:true) with a source URL and a confirm-with-a-local-accountant note. Field names matchstaffCost()/loadedHourlyRate()so wiring is a direct pass-through.staffAutoEntry/buildAutoEntries(cost-ledger-auto.ts) now take an optionalemployerloading — omitted = the confirmed Swedish default, byte-for-byte backward-compatible — and aneedsConfirmationloading drops the auto staff-salary cost-ledger line to `low` confidence with a note that flags the on-cost as an estimate to confirm locally. The 9 profit routes (overview, menu-engineering, price-recos + apply, report, forecast, advisor, experiments, combos) + the twobuildAutoEntriescallers (costs, marketing) now resolveloadedHourlyRate(rawWage, employerRulesFor(country).rules)fromdb.settings.countryCode(defaultSE). VAT was already country-aware viaresolveVat; this closes the labor half. No new schema (an owner-set per-tenant rate override is the deferred RP-12d.1 follow-up). Hardened via a 3-lens adversarial review with per-finding skeptic verification (4 raised, 3 refuted, 1 confirmed — a nit: theEmployerRules.currencyfield is inert; resolved with a doc-comment marking it reserved for VAT-module parity + the future country picker, as display currency is sourced independently). 0 blockers. 7 employer-rules tests + 2 country-aware auto-entry tests (incl. a backward-compat guard that the omitted-arg path stays Swedish); 189 profit tests total; tsc 0 project-wide; full suite 2063/2063. Next: RP-12e multi-location roll-ups · RP-12f open Profit API. - Restora Profit (Phase 43) — RP-12c: invoice OCR → cost ledger. Snap a supplier/utility invoice and we read it into a proposed cost-ledger entry the owner reviews and edits before saving — an OCR read is an estimate, human-in-the-loop, never auto-committed.
profit/invoice-extract.ts(pure):mapInvoiceToCostEntrymaps an extracted invoice to a draftsource:'ai-estimate'entry — category-guessed (EN+SE keywords, never the auto-onlyplatform-commission, falls back tomisc), confidence capped at `medium` (an OCR read is never "high"), and a plain-language warning for every shaky field (no total → amount 0, unreadable/impossible date → today, unknown category, low-confidence read).normalizeInvoicedefensively clamps the model output.profit/invoice-vision.ts— reuses the menu-import vision plumbing (filterImageDataUrls/parseDataUrl, `fitImageForVision` downscaling, the bounded 80M/6-image cap) with a forcedsave_invoicetool; deterministic dev-sample fallback when no AI key.POST /api/tenant/[tenantId]/profit/invoice—extract(budget-gated → 402 before any paid call, metered under'cost-estimate'only on success, returns the draft, not persisted) +confirm(zod +isSafeLabel+ a real-date round-trip → persists assource:'ai-estimate', which is excluded from the accountant P&L export; cost-ledger capped).dashboard/profit/invoice/page.tsx(upload → review/edit → save) + nav. Reuses the existingCostLedgerEntry(no new schema). Hardened via a 3-lens adversarial review with per-finding verification (4 confirmed findings, 0 blockers, all fixed): images are now downscaled before the vision call (was sending raw bytes — cost/memory/>5MB-reject); the date validator now rejects impossible-but-well-formed dates (2026-13-45) so a garbled OCR date can't pass as confident; and a cost-ledger write cap. 7 invoice tests; 186 profit tests total; tsc 0 project-wide; full suite 2054/2054. Next: RP-12d multi-country · RP-12e multi-location · RP-12f open API. - Restora Profit (Phase 43) — RP-12b: A/B price testing & elasticity learning. Run one dish at a TEST price for a window, then compare it to an equal baseline window at the CONTROL price.
profit/elasticity.ts(pure):arcElasticity(midpoint formula) +concludeExperiment— the winner is the price that earned more contribution per day (revenue when the variable cost is unknown), inconclusive when units are too few or the two prices tie. Honesty call: with only two data points a theoretical "optimal price" is overconfident extrapolation, so the recommendation is the empirically better price, and elasticity is reported only as flagged context ("estimated, not assumed"); a thin-signal win is hedged as an "early signal".PriceExperiment/PriceExperimentResulttypes +priceExperiments?onTenantDB.GET /profit/experiments(lists tests; running ones carry a live preview, and a test past its `endsAt` auto-concludes and reverts to the original price so a below-cost test can't stay live) +POST(start / conclude / abandon — start applies the test price to the dish's effective field recordingappliedFieldto revert; conclude keeps control / test / the recommended winner).dashboard/profit/experiments/page.tsx+ nav. Hardened via a 3-lens adversarial review with per-finding verification (7 confirmed findings, 0 blockers, all fixed): the variant measurement window now spans exactly the same inclusive-date count as its divisor (was a one-directional bias toward the test price); contribution is computed on the net price (VAT-consistent with Track-A, no per-unit VAT double-count);endsAtis now enforced via auto-conclude+revert; plus a fresh below-cost check on conclude, an honest inconclusive button label, and the low-confidence verdict hedge. 10 elasticity tests; 179 profit tests total; tsc 0 project-wide; full suite 2047/2047. Next: RP-12c invoice-OCR → cost-ledger · RP-12d multi-country · RP-12e multi-location · RP-12f open API. - Restora Profit (Phase 43) — RP-12a: opt-in competitor lookup (+ the deferred competitor-gap advisor rule). The Phase-3 slice
ruleCompetitorGapwas waiting on — built mock-first and legally careful.profit/competitor/index.tsgetCompetitorAdapter(): mock by default (a deterministic FNV-seeded adapter — zero legal exposure) vs a gated live adapter that fails closed (throwsLIVE_NOT_CONFIGUREDuntilCOMPETITOR_LOOKUP_MODE=live+ a source key, and may only query permissioned/licensed sources — never silent scraping).competitor-budget.tsmirrors the WoW two-tier cap — a global per-UTC-day USD ceiling (503) + a per-tenant sub-cap (429), super-admin-tunable, clamped, day-reset, all fail-closed (with a documented per-instance/go-live caveat).CompetitorPricePointtype +competitorPricePoints?onTenantDB+ acompetitorLookuptoggle oncostSettings+CompetitorDailyUsage/CompetitorTenantUsagecounters + acompetitorLookupconfig onplatformSettings. Routes:POST /profit/competitor/lookup(the full fail-closed gate chain: opt-in 404 → terms 403 →requireRateLimit→ global USD 503 → per-tenant USD 429 → AI-budget 402 for live → adapter → record spend only on success),GET/PUT/DELETE /profit/competitor(the owner-entered safe path — always available, no opt-in, no cap),PUT /profit/competitor/settings(enable requires accepting the terms). The deferred `ruleCompetitorGap` is now wired (advisor's 9th rule): fires ONLY when opted in, gross-vs-gross deviation from the dated median, full provenance (median + sample size + as-of), capped + deduped, never fabricated.dashboard/profit/competitor/page.tsx+ nav; every point shows itssource+observedAt. Hardened via a 3-lens adversarial review with per-finding verification (5 confirmed findings, 0 blockers, all fixed): the headline — mock prices are now excluded from the median so demo data can never drive a competitor-gap insight as if it were a real market signal — plus a real terms-text shown before acceptance, the>>>unsigned-shift distance fix, and the documented USD-cap per-instance/go-live caveat. 13 RP-12a tests (budget caps fail-closed, mock determinism + exclusion, the competitor-gap rule + opt-in gating); 169 profit tests total; tsc 0 project-wide; full suite 2037/2037. Live competitor sources stay gated until counsel ToS sign-off; RP-12b–f (A-B / elasticity / invoice-OCR / multi-country / multi-location / open API) remain. - Restora Profit (Phase 43) — RP-11: Forecast + printable Profit Report (PDF).
profit/forecast.tsbuildForecast(pure): projects revenue and contribution margin forward by applying the venue's learned demand seasonality (learnDemandPatterns/dayDemandsfrom inventory/drivers — the demand-pattern projection loop applied to revenue, not stock) to a baseline daily average. Honest-by-design: never a point claim — every day and every total carries a low/p50/high band whose width grows with the venue's own revenue volatility and shrinks with history depth (and a guard guarantees a real band even for a few-units/day venue where rounding would otherwise collapse it); thin history →enoughData:false("not enough yet", never a fabricated line); profit is labelled contribution before fixed costs,nullwhen nothing is costable.profit/profit-report-pdf.tsbuildProfitReportPdf— a printable report via pdf-lib, replicating the invoice path's WinAnsi sanitizer (so emoji/control chars in a venue name can't makedoc.save()throw), streamed bytes — no disk/root write.GET /api/tenant/[tenantId]/profit/forecast(the on-screen forecast) +GET /profit/report?format=pdf(gated bycanExportProfit— one-time terms acceptance + per-report review + demo-restaurant watermark wired toTenantRecord.isDemo) +POST /profit/report(accept/revoke terms →acceptedProfitVersiononProfitProfile).dashboard/profit/forecast/page.tsx(range cards + next-14-days + the acceptance→download flow) + nav entry. Channel-variant prices (lunch/happy-hour/catering/wholesale) were already shipped in RP-4 — verified, nothing to add. Hardened via a 3-lens adversarial review with per-finding verification (7 confirmed findings, 0 blockers, all fixed): the band-never-collapses fix (low-revenue venues), the wizard now carries `acceptedProfitVersion` through its profile rebuild (a step no longer silently re-locks PDF export), the demo watermark wired to the realisDemoflag, a low-confidence day-count caveat (page + PDF), and the documented gross-vs-CM-ratio basis. 12 RP-11 tests (forecast incl. the tiny-baseline band regression + the PDF WinAnsi-survival test + the wizard acceptance-carry regression); 155 profit tests total; tsc 0 project-wide; full suite 2023/2023. Next: RP-12 expansion (competitor lookup / A-B / invoice-OCR / multi-country / multi-location / open API). - Restora Profit (Phase 43) — RP-10: Tenant Marketing ROI. A restaurant's marketing economics — built honest and fresh (a deliberate deviation: the platform Growth engine measures Restora's own SaaS acquisition of restaurants from subscriptions/invoices, the wrong domain for a venue marketing to its diners, and the data model has no order→channel link).
profit/marketing.tsbuildMarketingRoi(pure): a blended efficiency (all marketing spend vs all sales — labelled blended, not channel-attributed), a new/repeat customer cohort + blended CAC + average lifetime value identified by email→phone matching across order history, with the identified-coverage % surfaced and alowCoverageflag so a partial match never reads as the whole truth, and per-channel CAC/ROAS only from owner-entered attribution (elsenull→ "—", never a fabricated channel number). Spend, revenue, and the cohort all share the actual 90-day window (the merged cost-ledgermarketingfigure clipped + prorated vialedgerPeriodByCategory, single-sourced — no double-count).MarketingChannelSpendtype +marketingChannelSpend?onTenantDB.GET /api/tenant/[tenantId]/profit/marketing+PUT/DELETEfor owner channel rows (capped at 200, real-date validation,isSafeLabelon the channel name); customer email/phone are used only to derive an in-memory identifier — never returned or persisted.dashboard/profit/marketing/page.tsx(blended tiles + coverage-gated cohort + an editable channel table) + nav entry. Hardened via a 3-lens adversarial review with per-finding verification (7 confirmed findings, 0 blockers, all fixed): the headline fix scopes avg LTV to the in-window cohort (was averaging in lapsed customers shown beside window-scoped counts), aligns spend to the actual window (no run-rate × months extrapolation), adds the per-tenant channel cap, finite-input guards, real-date validation, and the LTV scope label + disclaimer footer. 10 marketing tests (incl. the cohort-LTV and finite-guard regressions); 143 profit tests total; tsc 0 project-wide; full suite green. Next: RP-11 forecast + PDF report · RP-12 expansion (RP-9 combos shipped in parallel). - Restora Profit (Phase 43) — RP-8: the continuous CFO Advisor (Phase-2 begins). Plain-language profit advice from live numbers, deterministic-first.
profit/advisor.tsbuildAdvisorInsights(ctx)— a pure, LLM-free engine: 8 rules that fire ONLY on live signals (negative contribution margin per dish, prime-cost / labor / food-cost vs the segment benchmark, weak Plow-Horse/Dog margins, an operating-cost MoM spike by category, platform-commission drag, an upcoming dated VAT step) — every number in the prose is a TS expression overctx(absent data → nothing, never a guess). The 9th rule, competitor-gap, is deliberately deferred to RP-12a (its data + opt-in setting don't exist yet — no dead code). Anti-fatiguerankAndDedupe(built fresh — Guardian has no equivalent): a continuing signal keeps its age, a dismissed insight stays suppressed until its value moves beyond a band measured from the dismissal point, a resolved signal drops.profit/advisor-narrate.ts— optional LLM rewording behind the briefing guardrail plus a numeric post-validation guard: each prose field must carry EXACTLY the same numbers (value, count, order) and dates as its deterministic original, ISO dates are tokenized out (not mis-read as negatives), non-ASCII digits fail closed, and any drift reverts the field to template — so narration can re-word but can never invent, restate, drift, or reassign a figure (the FBL "no AI-narrated numbers" lesson, made mechanical). NewAIFeature 'profit-advisor'metering (graceful over-budget fallback — HTTP 200, deterministic text).AdvisorInsighttype +advisorInsights?onTenantDB.GET /api/tenant/[tenantId]/profit/advisor(assemble live ctx → build → dedupe + persist the deterministic list inside the row lock → lazily narrate the feed) +POST(dismiss/restore).dashboard/profit/advisor/page.tsxfeed + nav entry; per-card "AI" chip only on actually-reworded cards. Hardened via a 4-lens adversarial review with per-finding verification (11 confirmed findings, 0 blockers, all fixed): the headline fix moved the number guard from a per-insight pool to per-field ordered-equality (blocks cross-field number reassignment), pinned the dismissal band so gradual drift resurfaces, closed the dates/non-ASCII/rounding holes, added aunitsSold > 0live-signal guard, a rule-priority sort (impactScore units aren't cross-rule comparable), an injective insight id, the in-lock dedupe race fix, and the mock-commission "recorded, not yet charged" honesty wording. 27 advisor tests (incl. the per-field-reassignment and multi-cycle-ratchet regressions); 133 profit tests total; tsc 0 project-wide; full suite 1989/1991 (only the 2 pre-existing Mason field-map fails). Next: RP-9 market-basket campaigns (in progress) · RP-10 marketing ROI · RP-11 forecast + PDF · RP-12 expansion. - Restora Profit (Phase 43) — RP-7: the Profit Overview dashboard (FINAL MVP slice — RP-0…RP-7 complete). The page the wizard lands on, and the home of the track.
profit-score.tsbuildProfitScore— a pure, deterministic 0–100 health score = the average of two confidently computable sub-scores: pricing-floor compliance (share of ranked dishes at/above their safe floor) and menu mix (Star + Puzzle share of rankable dishes). Each sub-score isnullwhen its population is empty, and the overall score is the average of whichever are present (ornullwhen neither is) — never a fabricated number.rag.tsragForScore(≥70 green, ≥45 amber, inclusive). The score + the loaded cost-ratio tiles (food cost / labor / prime cost / net margin) mute (greyed, no benchmark color, target band hidden) whenever cost-ledger + recipe coverage < 60% — a low-confidence figure is never shown as a confident venue-wide number. Deterministic top-3 actions (below-floor dishes → Dogs → thin-margin Plow Horses → prime-cost-over-band → finish-setup → slow Puzzles), urgency-sorted and capped, every count engine-computed. Read-onlyGET /api/tenant/[tenantId]/profit/overviewassembles the score, KPIs, coverage, and acontextblock (country / allocation method / benchmark source+asOf) over the trailing 90 days.dashboard/nav-catalog.tsprofit group (Overview / Menu Engineering / Price Suggestions / Profit Setup);dashboard/profit/page.tsx(score ring + KPI tiles + top-3 + nav cards + the wired-through "How we calculated this" panel + a distinct load-error state). Hardened via a 2-lens adversarial review (0 blockers; 2 majors fixed: the Overview's below-floor count now uses the exact same floor definition as the price ladder —variableCost / (1 − 25%) × 1.05, net-terms but provably equal to the ladder's gross compare — so Overview and Price Suggestions agree on the count; and the cost-ratio KPIs mute on low coverage instead of coloring a small costed slice green; plus therankedCountrename for an accurate floor denominator, KPI target-band hidden when muted, and apply-failure feedback on the Price Suggestions page). 10 profit-score tests (incl. single-sub-score and boundary cases); 90 profit tests total; tsc 0 project-wide; full profit suite green. Restora Profit MVP (the Operating Cost Ledger + two-track costing + menu engineering + price ladders + the wizard + this overview) is now build-complete; remaining is go-live (counsel-confirmed VAT/benchmark figures, the opt-in competitor lookup) + the post-MVP slices RP-8…RP-12. - Restora Profit (Phase 43) — RP-6: the 5-minute Profit Wizard. The first owner-facing page in the track.
profit/wizard.tsapplyWizardStep— a pure, idempotent, deterministic step engine: each step body is hashed (a retried step is a no-op) and the manual cost entries it writes get a session-independent id (costledger_wizard_<category>), so retrying or resuming the wizard upserts the same line instead of duplicating it (a zeroed cost removes it; an owner-entered cost outside the wizard is never touched).ProfitProfiletype +profitProfile?onTenantDB. APIGET /profit/wizard(resume the saved profile) +POST /profit/wizard(save a step under the row lock; mirrors the allocation method ontocostSettings; audits on completion).dashboard/profit/wizard/ProfitWizard.tsx— a clean 5-step UI (profile → costs → labor → food → sales) mirroring the onboarding wizard, that resumes where you left off (reusing the saved session id), auto-imports where modules are active, lands the owner on the Profit Overview, and carries the "How we calculated this" panel. Hardened via a 2-lens adversarial review (1 major fixed: the cost id was session-keyed → cross-session duplicate rent on resume; plus timestamp-churn, 0-seats, and free-text-cap fixes). 9 tests (incl. the cross-session no-duplicate regression); tsc 0; full suite green (only the 2 pre-existing Mason field-map fails). Next: RP-7 the Profit Overview dashboard (the final MVP slice).
- Restora Profit (Phase 43) — RP-5: honesty/legal scaffold + "How we calculated this". Makes the whole Profit surface defensible before the dashboards render it.
profit/legal.tsextended to REUSE the Tax Assistant'sfindBannedClaims/isSafeLabel/confidenceFromCompleteness(one canonical implementation, re-exported — no duplication, no circular import) and add a Profit-specificcanExportProfitgate (acceptance → explicit review → demo-watermark) keyed toRESTORA_PROFIT_VERSION+ the estimate-not-advice disclaimer, decoupled from the Tax Assistant's acceptance state. Newcomponents/profit/HowWeCalculated.tsx— a server-safe, no-JS<details>panel that plainly explains every assumption (net-of-VAT pricing, contribution margin vs the allocated fully-loaded net, share-of-revenue allocation, loaded labor at SE 31.42% + 12%, benchmark source/date) and carries the permanent disclaimer; RP-6/7 drop it beside the numbers. Hardened via an adversarial review (verdict: ship; 0 blockers, 1 nit fixed — re-exportExportGateState). 5 tests (banned-claims catch "guaranteed"/"official"/"tax advice"; gate branches; version decoupling); tsc 0; full suite green (only the 2 pre-existing Mason field-map fails). Next: RP-6 the 5-minute Profit Wizard.
- Restora Profit (Phase 43) — RP-4: price recommendation ladder + one-click apply. The first slice that writes back to live menu prices.
profit/price-reco.tsbuildPriceLadder: for each dish, a full ladder — minimum-safe (break-even + cushion, grossed through VAT), ideal (hits the segment's target food-cost viasuggestPrice), premium, psychological (charm 9-ending), campaign, and per-channel (dine-in / delivery / lunch / happy-hour / catering / wholesale) — with deterministic reasons + a 0–100 confidence score (honestly capped on partial COGS / estimated VAT). Hard rule (enforced + tested): no rung ever falls below the safe floor, and the psychological rung rounds up.PriceRecommendation/PriceChannels/ProfitPositioningtypes +priceRecommendations?onTenantDB. APIGET /profit/price-recos(the reco queue, preserving applied/dismissed status) +POST /profit/price-recos/apply(writes the new price to the effective field — discount if active, else base — recomputescostModel.computed, flips the reco, audited; a below-cost price is allowed but `marginWarning:true`, never silently corrected). Hardened via a 2-lens adversarial review (1 blocker fixed: apply now targets the effectivediscountPrice ?? basePricefield instead of silently no-op-ing on discounted dishes; plus boundednewPriceto the editor's max, skip dishes with no cost basis, tightened the reco match). 11 tests; tsc 0; full suite green (only the 2 pre-existing Mason field-map fails). Next: RP-5 honesty/legal scaffold + "How we calculated this".
- Restora Profit (Phase 43) — RP-3: the Menu Engineering 2×2 matrix.
profit/menu-engineering.tsbuildMenuEngineering: aggregates units sold per dish over a window, computes each dish's Track-A contribution margin (reusing RP-2), and classifies it on the Kasavana–Smith grid — Star / Plow Horse / Puzzle / Dog — popularity = unitsSold ÷ menu-average units (high = ≥ 0.70× average, compared on the raw ratio), profitability = contribution margin ≥ menu-average CM, each with a deterministic recommended action. Honesty gate: a dish that isn't fully costable (no/partial recipe) or whose VAT can't be confidently stripped getsquadrant:null+ a "needs costing" note and is excluded from the menu averages — never a fake quadrant.MenuQuadrant/MenuEngineeringItem/MenuEngineeringSnapshottypes +menuEngineeringSnapshots?onTenantDB(capped 12-deep history) +quadrant?onProduct.costModel.computed. APIGET /api/tenant/[tenantId]/profit/menu-engineering(cached latest, orrefresh=1recomputes the default last-90-days window; a customfrom/torecomputes its own period and is never cached/persisted). Hardened via a 2-lens adversarial review (0 blockers; fixed a round-before-compare popularity bug, a custom-window cache mismatch, labor-source consistency with the menu Cost tab, and documented the combo-pricing limitation). 8 tests; tsc 0; full suite green (only the 2 pre-existing Mason field-map fails). Next: RP-4 price recommendation ladder + one-click apply.
- Google + AI integration — IndexNow now fires on every publish surface (not just the site builder). New reusable
pingIndexNowForTenant(tenantId, paths)helper (resolves the tenant, skips demo/inactive, builds the URLs, submits — still dormant unlessINDEXNOW_KEY+ production + a public host). Wired into: the site-builder save (refactored onto the helper), settings save (/·/menu·/reservations·/contact), menu product create / edit / delete (/menu), and blog publish / edit / delete (/blog+ the post URL — only when the post is published, drafts don't ping). So a menu price change or a new blog post now gets fast-indexed too, not just a website-builder publish. tsc 0; 5 indexnow tests green.
- Google + AI integration — GBP polish + per-domain Search Console + guest waitlist + Reserve-with-Google rail. Six features, all honesty-gated and mock-first, hardened by a 7-agent adversarial review (0 blockers; 2 nits fixed). (1) GBP price level —
attributes/price_rangeENUM derived from the real menu average (currency-aware via the sharedpriceLevelFor, so a 100 SEK menu is$not$$$$), omitted when there are no priced products, with an owner override on/dashboard/settings/google/attributes(Auto by default). (2) GBP EVENT posts — a date-windowed tenant Offer (real start+end date) becomes a richer EVENTlocalPost; open-ended offers stay OFFER posts; no fabricated schedule. (3) paymentAccepted from real config — removed the hardcoded "Credit Card, Debit Card, Cash" on every storefront; Restaurant JSON-LD + the payments FAQ now list ONLY configured methods (Stripe→cards, PayPal, Swish→GBPpay_other_payment_methods, never NFC, never cash), omitted when none. New sharedlib/seo/payment-methods.ts. (4) Per-tenant Search Console —settings.seo.searchConsoleVerificationemits agoogle-site-verificationmeta ONLY on the tenant's own custom-domain/subdomain pages (gated onx-tenant-base==='root', never the platform path), owner field in SEO → Technical. (5) Guest waitlist — separateWaitlistEntry[]onTenantDB(not overloaded onto reservations): public join page +/api/public/[tenantId]/waitlist(active-tenant +waitlistEnabledgated), staff queue at/dashboard/waitlistwith notify-when-ready (mock-first guest SMS on the waiting→notified transition), seat/remove; quoted wait is staff-set, never invented. (6) Reserve with Google — schema.orgReserveAction+ GBPDINING_RESERVATIONnow gated on reservations actually being enabled (fixed a bug that advertised booking for opted-out tenants); plus a contract-gated mock RwG partner rail (rwg-feed.ts/rwg-booking-server.ts+/api/google/rwg/{feed,booking}) reusing the realgetAvailableSlotsengine, 404s unlessRWG_MODEis on, refuses unsigned live traffic. Schema:gbpPriceLevel,seo.searchConsoleVerification,WaitlistEntry/WaitlistStatus+waitlist?/waitlistEnabled?. tsc 0; 41 new/updated tests green (53 across google+seo).
- Restora Profit (Phase 43) — RP-2.5 + RP-2: market benchmarks + the two-track cost engine. The heart of the menu-engineering math. RP-2.5
profit/benchmarks.ts: SE-seed industry benchmark bands by segment (food-cost 28–35%, prime 55–65%, labor by segment),benchmarkFor(country,segment,overrides)(owner-override → exact → by-segment → generic), and RAG band helpers — all flagged "industry estimate — verify" (a documented go-live gate);MarketBenchmark/ProfitSegmenttypes +marketBenchmarks?onTenantDB. RP-2profit/{variable-cost,contribution,allocation,loaded-net}.ts: Track A contribution margin = net price (VAT-stripped viaresolveVat; an unknown/fell-back jurisdiction keeps gross + flagsvatEstimated, never a guessed strip) − VARIABLE cost (ingredient + packaging + variable labor + waste + the per-unit commission fee from the single commission-engine source; no fixed overhead). Track B fully-loaded net = CM − allocated fixed overhead (allocateOverheadPerUnit, default share-of-revenue, exhaustive Σ=pool, fractional-unit-safe), always labelled an estimate.Product.costModel.computed+= optionalcontributionMargin?. Double-count guard:VARIABLE_COST_CATEGORIES+fixedOverheadFromLedger()so the Track-B pool excludes the variable categories already inside CM. Hardened via a 2-lens adversarial review (0 blockers; fixed the VAT-strip gate, the Track-A/B double-count seam, fractional-unit flooring, + the prime-cost comparability note). 29 tests; tsc 0; full suite green (only the 2 pre-existing Mason field-map fails). Next: RP-3 menu-engineering 2×2 matrix.
- Google + AI integration — Search Console verification meta now actually renders. The Super Admin → SEO field
globalSEO.googleSearchConsoleIdhas long been configured but never output (the admin hint promised it was "injected on the root page"). The platform root page (app/page.tsx) now emits<meta name="google-site-verification">viagenerateMetadata. Env-first (GOOGLE_SITE_VERIFICATION, new in.env.example) so the marketing homepage stays statically rendered when an env value is set; falls back to the admin-configured DB value (best-effort, only then dynamic). Verifies the platform property (restora360.com); per-tenant custom-domain verification stays a separate per-tenant field (follow-up). tsc 0.
- Google + AI integration — Merchant-feed owner card + richer Bazar restaurant JSON-LD. (1) Merchant-feed owner card — the
/google-merchant.xmlfeed was generated automatically but invisible to owners; the Google settings page now shows a "Google Shopping feed" card with the tenant's feed URL (copy button) + the one-time Merchant Center registration steps + an honest note (best for sellable/packaged goods; the dine-in menu is already covered by GBP + structured data). NewGET /api/google/shopping-feedreturns the per-tenant feed URL (viatenantPublicBaseUrl); co-located clientShoppingFeedCard.tsx, hidden until the URL resolves. (2) Enriched `buildBazarRestaurantLd` — the Food Bazar restaurant JSON-LD now also emitshasMenu(storefront menu),openingHoursSpecification(real weekly hours), andgeo(geocoded coords), wired from the Bazar restaurant page. 1 new test; tsc 0.
- Restora Profit (Phase 43) — RP-1: the P&L now subtracts real operating expenses. Wires the RP-0 cost ledger into the Tax Assistant income statement:
bookkeeping/pnl.tsoperatingExpensesNet(hardcoded0since Phase 19) now equals the owner-entered cost ledger scaled to the statement period, sonetResultfinally reflects rent / utilities / marketing / wages / etc. Honesty-preserving: only MANUAL owner-entered entries count — estimated/auto figures (modeled commission, shift-derived labor) are excluded from this accountant-facing draft;hasLaborCosts/hasOverheadCostsflip, the coverage note adapts, and per-categoryopex_*lines join the lite chart of accounts. Period scaling is honest day-proration (monthsCovered): a whole month = 1.0, a full quarter = 3.0, and a boundary-straddling 30-day window ≈ 1.0 (not 2.0) — so the route's default last-30-days view can't ~2× the opex.buildIncomeStatementgains an optionalcostLedger?input (default empty → every other caller unchanged); the bookkeeping route + the read-only accountant portal both passdb.costLedger. Hardened via a 2-lens adversarial review: each entry is clipped to its active sub-window (aledgerPeriodTotal/ledgerPeriodByCategoryengine pair — a cost that starts/ends mid-quarter isn't charged for the whole period), per-order costs are valued from the period's order volume (no longer silently 0), the honesty flags follow actual non-zero contribution (a one-off / 0% line can't claim a cost class is covered while opex is 0), and onlysource:'manual'entries feed the draft (a futureai-estimaterow can't leak in). 11 new tests; tsc 0; full suite green (only the 2 pre-existing Mason field-map fails). Next: RP-2.5 benchmark seed → RP-2 two-track cost engine.
- Restora Profit (Phase 43) — RP-0 Operating Cost Ledger (the keystone), build-with-seed. First slice of the new AI menu-engineering / profit-optimization track (plan:
docs/products/restora-profit/). Adds the missing operating-cost primitive so the platform can model rent / utilities / wood / marketing / owner-salary / etc. — auto-detected where modules are on, manual otherwise. New pure enginelib/profit/cost-ledger.ts(normalizeMonthlycadence→monthly run-rate,ledgerMonthlyTotal,ledgerByCategory,costCoverage→auto-% + core-bucket completeness,monthPeriodOf; all deterministic — noDate.now()), auto adapterslib/profit/cost-ledger-auto.ts(delivery fees from orders, marketing from cost settings, loaded labor from terminal-shift hours × wage viastaffCost(), and platform commission from the commission engine as the SINGLE fee source so auto + manual can never double-count), shared typeslib/profit/types.ts(re-exported fromschema.ts), andlib/profit/legal.ts(RESTORA_PROFIT_VERSION+ the permanent "estimate, not advice"DISCLAIMER_SHORT). Additive schema:TenantDB.costLedger?,costSettings.allocationMethod?(default share-of-revenue). APIGET/PUT/DELETE /api/tenant/[tenantId]/profit/costs(auth-gated, zod-validated, audited; GET returns monthly total +operatingExpensesNet+ coverage meter + disclaimer). Hardened via a 3-lens adversarial review: a single-calendar-month query guard (a multi-month range can no longer be reported as a "monthly" figure into the P&L), a loadedstaff-salary↔manualpayroll-taxcoupling (the 31.42% employer fee can't double-count), one-off / 0% manual lines no longer shadow a real auto cost,card-feeis a normal manual cost (onlyplatform-commissionstays auto-only), and bounded entry ids. 16 tests; tsc 0; full suite green (the only 2 fails are the pre-existing Mason field-map tests). Next: RP-1 wires `operatingExpensesNet` into `bookkeeping/pnl.ts` (today hardcoded 0).
- Google + AI integration — honest GBP “more hours” (LOW polish).
buildMoreHoursno longer fabricates a delivery cut-off (regular − 30 min) or a hardcoded Mon–Thu 16:00–18:00 happy-hour the owner never configured. It now emits Takeout during the restaurant’s regular open hours (a sensible default) + Delivery during open hours only when the restaurant actually offers delivery. Consistent with all the other no-fabrication fixes. 1 test; tsc 0.
- Google + AI integration — Food Bazar discovery boosters (reciprocal `sameAs` + per-dish sitemap). Two refinements so Google can surface the marketplace alongside the restaurant's own result (the "both benefit" goal): (1) Reciprocal `sameAs` cross-link — the Bazar restaurant page now emits
sameAs: [storefrontUrl], and the dishProductlinks itsbrand/sellerback to the restaurant's storefront URL, so Google treats the Bazar listing + the storefront as the same business across two surfaces rather than duplicate pages competing for one slot (each still self-canonicalises, so neither suppresses the other). (2) Per-dish `/item/` URLs in the platform sitemap — every approved restaurant's available, priced, non-bazar-disabled dishes (popular-first, capped 50/restaurant) are now listed, added inside the existing approved-restaurant loop so it costs no extra DB read. 2 new tests; tsc 0.
- Google + AI integration — Food Bazar dish pages are now server-rendered + structured (per-dish JSON-LD). The
/bazar/restaurant/[slug]/item/[productId]page was fully client-rendered with NO metadata and NO structured data, so Google/AI couldn't ingest a dish's price or rating. Split into a thin serverpage.tsx(the interactive UI moved verbatim toItemDetailClient) that now emits: `generateMetadata` (title/description/canonical/OG/Twitter from the dish), a per-dish `Product` + `Offer` + `aggregateRating` JSON-LD (buildBazarMenuItemLd— name, image, price / sale price, in/out-of-stock, restaurant brand, and the dish's real star rating from approved item reviews), and a Breadcrumb. New server loaderlib/bazar/dish-seo.tsreturns null for off-marketplace / unapproved-listing / unavailable / priceless dishes (never emits misleading data). 2 tests. Also fixed a latent excess-property type error ingbp-payload.test.ts. tsc 0. Follow-up: include `/item/` URLs in the platform sitemap (gated to available dishes).
- Google + AI integration — GBP payload now reads REAL tenant data (no more placeholders). A batch of correctness fixes to the GBP location / media / place-action payload, plus a shared
payloadInputFromhelper so the location + resource syncs never drift: (1) `websiteUri` respects the custom domain — was hardcoded{subdomain}.restora360.com(wrong for custom-domain tenants); now resolves viatenantPublicBaseUrl. (2) Real cover + logo photos —buildMediawas pushing FABRICATED placeholder URLs (/banner.jpg,/photos/exterior.jpg,/photos/interior.jpg,/photos/menu-print.jpg) that 404 → Google rejects; it now emits only the realbannerUrl(COVER) +logoUrl(LOGO) + actual product photos, resolving relative URLs to absolute. (3) `FOOD_DELIVERY` place-action + service area only when delivery is offered — adds the delivery CTA and a delivery-area radius from the realdeliveryRadiusKm(was a hardcoded 5 km serviceArea on every tenant, including dine-in-only). (4) Special hours from the owner's real special days (was a hardcoded "closed Dec 25"). (5) `openInfo` reflects an active temporary closure (CLOSED_TEMPORARILY; was alwaysOPEN). The two duplicate payload-construction sites collapsed into one helper. 5 new tests; tsc 0.
- Google + AI integration — GBP service-attributes editor (`settings.gbpAttributes`). Closes the highest-leverage Phase-3 gap: the amenities the honesty rule was omitting because nothing backed them. New
GbpServiceAttributesmodel (dine-in · takeout · curbside · outdoor seating · groups · kids · alcohol · Wi-Fi · restroom · 4× wheelchair-access · dogs-outside) + an owner editor at/dashboard/settings/google/attributes(grouped toggles + save) backed by a dedicated/api/google/attributes-configGET/PATCH (kept off the shared settings route to stay additive). Only toggles turned ON are published (still zero fabrication);buildAttributesemits them via a key→Google-attribute-id map (go-live-verify: ids are category-specific), threaded throughserviceSignalsFrom→ the attributes sync. Linked from the GBP page. 4 honesty tests; tsc 0.
- Google + AI integration — GBP Performance / Insights pull. The one materially-unbuilt GBP read surface, now built (mock-first). New
fetchPerformanceMetricsclient (Business Profile Performance API — thebusiness.managescope already grants it, no extra approval; real endpoint defensively parsed + flagged go-live-verify) +lib/google/performance.ts(pureshapePerformanceCards+getPerformanceSummary) +/api/google/performanceroute + an Insights page at/dashboard/settings/google/insights: profile views (Search+Maps impressions), calls, direction requests, website clicks, menu clicks, bookings, food orders, with a 7/30/90-day range and a "sample data" badge in mock mode. Linked from the GBP page. 2 tests; tsc 0.
- Google + AI integration — Google Merchant Center product feed (the "Shopify model" lever, automatic per tenant). New per-tenant Google Shopping product feed at
/google-merchant.xml— served host-aware (subdomain or custom domain), like sitemap.xml/llms.txt — generated automatically from the tenant's real menu: id · title · description · link · image · price · saleprice · availability · brand · producttype · googleproductcategory.lib/seo/merchant-feed.ts(purebuildMerchantItems+renderMerchantFeedXml+xmlEscape). The owner registers the URL once in Merchant Center → the restaurant's sellable items appear as FREE Google product listings (Shopping tab, Search, Images, Lens) at scale, with no per-product verification — the genuine equivalent of what Shopify automates for Google. Honest scope: only published products WITH a price and an image are emitted (Google requiresimage_link; imageless/zero-price items are skipped rather than submitted to be disapproved), and it's framed as best-fit for sellable / packaged / online-order goods — the dine-in menu itself is already covered by GBP FoodMenus + the Menu JSON-LD. Demo/inactive tenants + the platform host return an empty feed. 4 tests; tsc 0 (my files). Follow-up: an owner-facing "your Google Shopping feed" card (shows the URL + the one-time Merchant Center registration steps); the Content API / multi-client-account PUSH model is the scale layer (needs a Merchant Center account = owner/go-live step).
- Google + AI integration — Phase 3 (GBP go-live, mock-first): service-attributes sync + an honest payload + legacy reconciliation. Closes the flagged gap where the GBP payload built attributes but nothing ever pushed them. New
updateAttributesclient method (gbp-api-client.ts— the separate Business-Information v1/attributessub-resource with its ownattributeMask, mock-mirrors likepatchLocation) +syncAttributesToGbp(sync-engine.ts— single-PATCH replace, offline-validated) wired into the sync route (resource: 'attributes'), the umbrellasyncAllToGbp, and a new "Service options" row on/dashboard/settings/google. The honesty fix (the important part):buildAttributespreviously FABRICATED every value — dine-in, wheelchair access, outdoor seating, pets, "atmosphere", all hardcodedtrue. It now emits ONLY attributes with a real backing signal —has_delivery(deliveryZones/radius),restaurant_reservations(reservationSettings.enabled), card/NFC payments (paymentSettings),serves_vegan/serves_vegetarian(actual menu dietary labels) — and OMITS everything it can't substantiate (no guessed amenities). 3 honesty tests lock that in. Also fixed a latent Phase-1 bug:acceptsReservations+ the reservations auto-FAQ readsettings.reservationSettings, but that field lives on TenantDB, not settings — so the cast silently resolved toundefinedand always defaulted true; both now readdb.reservationSettings. Legacy reconciliation: the orphaned/dashboard/google-businesspage (which only talked to a mock-only stub) nowredirect()s to the real/dashboard/settings/google, and the NEW-badge route mapping was repointed so it clears correctly. tsc 0 (my files); 8 tests green. Mock-first: the real Attributes PATCH only fires with live creds. Deferred — conditional verification banner (needs the live `hasVoiceOfMerchant` signal). External gates (owner/Google action, not code): GBP API-access application, live OAuth creds, the agency organization-account + per-tenant Manager grant, Reserve-with-Google contract. Go-live verify: the exact attributes endpoint/`attributeMask` shape + category-specific attribute-ID allowlist against the live swagger.
- Google + AI integration — Phase 2: IndexNow fast-indexing (automatic on publish, zero owner action). When an owner saves/publishes their site, the public URLs are now pushed to IndexNow (Bing, Yandex, Seznam, Naver) so those engines re-crawl in minutes instead of waiting for organic discovery. New
lib/seo/indexnow.ts(submitToIndexNow+ puretenantPublicBaseUrl/isSubmittableHost/getIndexNowKey) + the/indexnow.txtkey-verification route (served for any subdomain/custom-domain host, like llms.txt). Wired fire-and-forget into the builder save (PATCH /api/tenant/[tenantId]/builder) — submits home + menu + reservations + contact + blog + enabled Mason pages (never/account), demo/inactive tenants skipped. Safe-by-default: a complete no-op unlessINDEXNOW_KEYis set ANDNODE_ENV=productionAND the host is a real public domain (local/dev/tailnet/IP hosts are rejected) — so it stays dormant until the owner flips it on for the live domain. Deliberately NOT Google: Google's Indexing API is restricted to JobPosting/BroadcastEvent pages and its sitemap-ping was retired in 2023, so for Google we rely on the Phase 1 sitemap + structured data (using the Indexing API for restaurant pages would violate Google's terms). NewINDEXNOW_KEYin.env.example. tsc 0 (my files); 5 indexnow tests green. Other publish surfaces (menu/blog/settings saves) can adopt the same `submitToIndexNow` call as a follow-up.
- Google + AI integration — Phase 1: own-your-data SEO hardening (automatic, zero owner action, no Google approval). Every tenant storefront now emits complete, correct, honest structured data + crawl directives on publish. (1) Closed the secondary-page metadata leaks — contact / reservations / catering / owner-added Mason pages had only a title+description (no canonical, no robots policy, no OpenGraph), so a "noindex/staging" tenant still exposed them and they self-canonicalised to
?lang=URLs. New sharedlib/seo/tenant-metadata.ts(buildTenantSubpageMetadata+robotsFromPolicy) gives them all a custom-domain-aware canonical, the tenant's robots policy, and social cards, derived automatically from the tenant DB. (2) Fixed the blog canonical to resolve viaresolveSiteUrl(was the raw request host — could diverge from a custom domain) + added its missing robots policy. (3) Brand-name cleanup — contact / catch-all / menu titles + OG now usedisplayBrandName(no more–/ scraped-domain leaks). (4) hreflang — home + menu now emitalternates.languagesmapping each enabled language to its?lang=variant (+x-default), so multilingual storefronts aren't penalised for duplicate content. (5) AI-snippet eligibility — indexable pages now carrymax-image-preview:large/max-snippet:-1/max-video-preview:-1(via googleBot). (6) JSON-LD honesty —acceptsReservationsis derived fromreservationSettings.enabled(default on, but a restaurant that turned reservations off no longer over-claims), the "Do you take reservations?" auto-FAQ is gated the same way, andRestaurant.hasMenunow@id-joins the Menu entity (/menu#menu) instead of a bare URL. (7) Fixed the llms.txt custom-domain bug — a custom-domain tenant (luigi.com) was served the platform llms.txt because detection usedhost.split('.')[0]; now uses the sharedresolveHostToTenant(and drops a duplicated PLATFORMHOSTS set). tsc 0; 5 structured-data tests green. Deferred (need app-wide/root-layout changes or the moved-to-viewportNext API): per-tenant<html lang>, Search Console verification meta, theme-color, Speakable; the tenant WebSite SearchAction was skipped (no site-search endpoint to honour). Phase 2 = IndexNow fast-indexing._
- Google Business Profile: a non-technical, step-by-step Connect + Verify guide beside the button. Owners kept seeing a bare "Connect Google" button with no idea what would happen. The
/dashboard/settings/googlenot-connected state now rendersGoogleConnectGuide(new co-locatedConnectGuide.tsx): a "takes ~2 minutes, most finish in one click" reassurance, a numbered 5-step walkthrough (tap Connect → sign in to the restaurant's Google account → tap Allow on the permission screen → pick your restaurant → done/syncing) each with a plain-language explanation of exactly what the owner will see, a "what happens after you connect" recap, a collapsible "No Google account yet?" helper (free signup deep-link), and — the half owners ask about most — a collapsible verification walkthrough explaining Google's one-time "is this a real business?" check with all three methods in plain words (postcard code 5–14 days · phone/text code · short on-phone video) and honest expectations. The verification guide is reused (compact) in the location-picker step's empty state, since "no listings found" usually means the restaurant isn't verified yet. A "Verify with Google" action deep-links tobusiness.google.com(verification is completed on Google's side — the API can't do video) with a "see Google's help" secondary link. Copy is accurate to Google's real 2026 flow: ownership stays with the restaurant, we never read Gmail, verify once then everything syncs automatically. Pure-presentational, 0any, tsc 0. Also repointed the "Google Business" nav item (Marketing section) from the legacy mock-only/dashboard/google-businessstub to the real connect+sync page/dashboard/settings/google, so the working flow is easy to find. First owner-facing increment of the Google + AI integration plan; org-account model + Merchant Center feeds + own-your-data SEO hardening are the next steps.
- Phase 42 — Restora360 OS terminal: light/dark theme + a working Settings screen (the bottom-nav Settings tab goes live). The terminal was dark-only; it now has a light theme + a persisted toggle. New
LightTermpalette (same token contract asDarkTerm; a slightly darker mint#00A06Eso mint-as-text stays legible on white);TerminalTheme(dark)chooses the palette + flips the status-bar icons + useslightColorScheme/darkColorScheme. Mode persists per device (TerminalGraph.darkThemeStateFlow + prefs). Per the owner's nav feedback, the toggle lives in a real Settings screen (Appearance: Dark/Light) — reached from the bottom-nav Settings tab, which is now wired (Home active; Payments/Reports still presentational — Reports will become payment history, moving Home's "Recent" there). The Settings screen also shows terminal info (paired-as/section/version) + a "Switch to Normal Phone" exit. The Boot Picker + Secure Boot Splash intentionally stay dark (the "boot" aesthetic) via per-branch theming inTerminalRoot; only the running terminal honors the toggle. ~31 hardcoded dark tints across Home/payment/pairing screens were converted to token-derived values (accent.copy(alpha) over card,textSecondary,accentSub, etc.) so they adapt — faithful to dark mode (the originals were already "accent X% over base") while flowing to light.:terminal:assembleDebuggreen. Next: light/dark for the `:pos` tablet; Reports/Payments tabs (payment history).
- Phase 42 — Restora360 OS dual-boot terminal: BYOD "dual-boot" via launcher mode (the real Model-A boot experience). The owner's vision delivered on a personal phone with no hardware ownership / no Device Owner: the user opts in to making Restora the home/launcher, so after unlock the dual-boot picker shows before the normal UI → Normal Phone (hands off to the stock launcher) or Restora OS (the terminal). Fully reversible from system settings. New
boot/LauncherRole.kt(isDefaultHome,requestSetDefaultHome→ the canonicalACTION_HOME_SETTINGSfor reliability across OEMs incl. MIUI,goToNormalPhone→ opens the OEM/stock launcher [prefersFLAG_SYSTEM] or leaves the app), aHOME+DEFAULTintent-filter onMainActivity(eligible only — never auto-becomes home), a Boot-Picker "Make Restora your start screen" action (↔ "Restora is your start screen · Change"), andTerminalRootdefault-home state refreshed on resume. Honest: nothing runs before unlock (Android security); the user must confirm the system prompt (the same rule that preserves their freedom). Same approach will give the:postablet its dual-boot. Boot-Picker footer copy corrected (point at the Normal Phone card, not the inert Restart pill). Adversarially reviewed (3 low fixes: reliable home-settings path, stock-launcher preference, honest copy).:terminal:assembleDebuggreen. Frames the dual-boot honestly: a real "unlock → picker" on BYOD via launcher (opt-in), a true locked boot on Model-B (Device Owner), and firmware boot as a separate hardware track.
- Phase 42 — Restora360 OS dual-boot terminal, DB-7: Model-B locked kiosk + go-live gates (optional/mostly documented). The dedicated-terminal path: a company-owned, Device-Owner-enrolled device locks to the terminal and boots into it. New
DeviceOwnerKiosk(real lock-task —setLockTaskPackages+startLockTask; Home/Recents off, notification shade suppressed);createKioskController()auto-selects it only when `isDeviceOwnerApp`, else the BYODSoftKiosk— so a personal phone is never locked (a website-installed APK can't self-grant Device Owner). AddedTerminalDeviceAdminReceiver+res/xml/device_admin.xml(the enrollment admin component) and aBootCompletedReceiverthat auto-launches into Restora on boot — guarded to Device-Owner devices (no-op on BYOD, so a personal phone is never hijacked on restart). Lock-task is now driven off the bootmode(re-asserted if the OS recreates/restarts a Model-B device into RESTORA, instead of silently un-pinning). Honest framing (per owner discussion): a true power-on boot menu before unlock is impossible for any app (Android security) — the real "boot into the Restora picker, locked" experience is delivered on Model-B dedicated/enrolled devices (auto-launch + lock-task); on BYOD the in-app picker is the honest ceiling (a mode switch); a true-firmware boot menu is a separate hardware/ROM track. Documented gates (P40/P41): enrollment QR/zero-touch, set-as-HOME polish, PCI processor merchant onboarding, fiscalization, offline-auth floor limits — seedocs/Android Dual Boot System/DB-7-MODEL-B-AND-GO-LIVE.md. Adversarially reviewed (1 fix: the lock-task re-assert).:terminal:assembleDebuggreen. DB-0…DB-7 complete — the terminal phase is done; remaining is the go-live gates + the enhancement backlog.
- Phase 42 — Restora360 OS dual-boot terminal, DB-6: offline store-and-forward. Take payments with no internet — every approved (mock) tap is captured to an encrypted local outbox and auto-synced when a network returns. New pure engine
lib/android-terminal/charges.tswas DB-5; this adds the sync target: `POST /api/public/android-terminal/transaction` (device-token authed like/heartbeat; idempotent by `clientTxnId`; per-device + per-IP rate-limited) records the tap into the tenant's Phase-33 payment ledger (recordOrderPayment, cents→major units, tagged mock until P40,source: 'terminal') — so terminal takings show in reconciliation. The cleanup cron is unchanged. Phone (`android/:terminal`):TerminalOutbox(EncryptedSharedPreferences JSON queue, Keystore-backed);capturePaymenton every Approved with a stable per-tap `clientTxnId` (rememberSaveable, so a process-death/config-change restore re-captures the SAME id → dedups instead of double-recording);drainOutboxflushes (serialized by aMutex) on app open, on connectivity-return (observer), and after each capture, with a WorkManager backstop (CONNECTED constraint) for the app-killed case;bindProcessToNetworkpins traffic to validated Wi-Fi over cellular. Failure-class-aware: 401 wipes the token, a permanent 4xx drops the poison entry (never head-of-line-blocks), 5xx/429/IO retries. UI: Approved shows "Saved offline — syncs when you're back online"; Home shows a "N payments saved offline" banner + the seed Today total is now tagged SAMPLE (honesty). New deps:androidx.work:work-runtime-ktx. Adversarially reviewed (7/7 fixes, incl. the critical stable-clientTxnIddouble-capture fix, the unsynced-drop bound, drain mutual-exclusion, poison-entry handling). Transaction endpoint smoke-tested (recorded + idempotent on retry). tsc 0; 33 android-terminal tests;:terminalgreen. Deferred: a real store-and-forward processor (P40); the floor-limit gating when the processor lacks offline auth is a P40 concern.
- Phase 42 — Restora360 OS dual-boot terminal, DB-5 (remainder 2): register → phone "bill push". A POS register sends a bill straight to a paired terminal — no re-keying the amount. Server-mediated (reuses the device-token auth + master DB; no on-phone HTTP server). New pure engine
lib/android-terminal/charges.ts(create / find-pending / expire / prune / projections; 10 unit tests). API: ownerPOST /api/tenant/[id]/android-terminals/[deviceId]/charge(push a charge — supersedes any prior pending one; one bill in flight per device) +GET(latest status for the dashboard); publicPOST /api/public/android-terminal/pending(device-token authed poll → the live charge) +POST .../charge-result(paid+tip / declined / canceled; idempotent). The cleanup cron now also expires (>10 min) + prunes charges. Phone: polls on Home while idle → an incoming-charge banner (amount + reference) → Take this payment pre-fills the amount and jumps to the tip step → tap → reports paid + tip back; Decline reports declined. Dashboard: each online terminal gets a Send a charge form (amount + ref) with a live status that flips to Paid $X (incl. tip). AdditiveMasterDB.androidTerminalCharges+AndroidTerminalCharge. Adversarially reviewed (5/5 fixes, incl. a critical: the dashboard called/[deviceId]/chargebut the handlers sat at/[deviceId]→ the whole flow 404'd; moved them to a/chargeroute — now smoke-tested end-to-end: push → poll → pay+tip → dashboard shows Paid). Also fixed: a late "paid" past the TTL now still wins (not lost as expired), Decline→"declined" (distinct from the supersede "canceled"), and a per-device throttle on the polled/pendingroute. MOCK-first (amounts phone/owner-sourced until P40). tsc 0; 33 android-terminal tests;:terminalgreen. Deferred: the Bluetooth fallback + the `:pos` app calling the same owner route. Polish: the incoming charge now takes over the phone as a dim + blurred full-screen overlay (elevated glowing card, centered, "INCOMING PAYMENT") instead of an inline card that blended into the Today card — so a server can't miss it.
- Phase 42 — Restora360 OS dual-boot terminal, DB-5: email/SMS receipts. The Approved screen's "Digital invoice" is now live (the print options honestly stay "Soon"): a dialog with an Email / Text toggle → address → Send, with sending/sent/error states that can't double-submit. New pure engine
lib/android-terminal/receipt.ts(HTML-escaped email + compact SMS builders, email/phone validators; 8 unit tests). API:POST /api/public/android-terminal/receipt— device-token authed (same constant-time gate as/heartbeat; a 401 makes the phone wipe its token and drop back to Connect), sends via the existingsendMail(email) or the tenant'ssmsConfig+sendSms(SMS). Honest: in MOCK payment mode the receipt is clearly marked a test receipt — no real charge (real cards = P40). Native side:TerminalApi.sendReceipt+PairingRepository.sendReceipt(reuses the sharedmarkUnpairedFromServer401 handler) + theReceiptSendDialog. Adversarially reviewed (4 fixes): per-device + per-tenant rate-limits after auth (an IP-only cap let a paired device blast mail on the tenant's sending reputation / starve other tenants), server-derived total (amount + tip, never trusted from the client), distinct 401→"unpaired" message + state convergence, and clearing the recipient field on channel toggle. tsc 0; 8 tests;:terminal:assembleDebuggreen.
- Phase 42 — Restora360 OS dual-boot terminal, DB-5 (remainder): on-site PoS discovery over NSD/mDNS. The "customer-display-easy" way to link a terminal to a POS register without typing an address. The `:pos` register app now advertises itself on the local Wi-Fi (
PosRegisterAdvertiser→ NSD service_restora-pos._tcp., TXTrole/tenant, an ephemeral port held open so it's genuinely reserved; started fromPosApp). The terminal discovers registers (PosDiscoveryRepository→NsdManagerdiscover + a serialized resolve queue + aMulticastLock, exposed asStateFlows) on a new "Connect a register" screen (dark/mint, reachable from a Server-Home "Register" row): a live list of nearby registers, one-tap link/unlink, persisted in a plain-prefsPosLinkStore(a LAN name isn't a secret, unlike the device token). Discovery runs only while that screen is open (DisposableEffectstart/stop) for battery + mDNS hygiene. Honest scope: this finds + links a register; sending a charge straight from the register to the phone is a later layer (the copy says so). Terminal manifest addsACCESS_WIFI_STATE+CHANGE_WIFI_MULTICAST_STATE. Also added a scoped `network_security_config` (HTTPS-only by default; cleartext allowed ONLY to loopback/emulator dev hosts) so the terminal can pair against a localnext devserver during testing (e.g. viaadb reverse) without weakening production. Adversarially reviewed (2 fixes): an epoch guard so a late NSD resolve callback from a stopped session can't inject a stale register into the next scan; the advertiser now holds its socket open so the advertised port is truly reserved.:terminal+:posassembleDebuggreen. Deferred: the register→phone amount-push protocol + the Bluetooth fallback.
- Phase 42 — Restora360 OS dual-boot terminal, DB-5: the Android app's "Connect to your register" pairing (native). The phone side of BYOD pairing, in
android/:terminal(reuses:core). First run gates on a newPairingRepository(aStateFlowthe UI gates on): Unpaired → "Connect to your register" (dark/mint, designed to fit the terminal system — this screen isn't in the hi-fi mockups), Paired → the payment flow. Two redeem paths, both backed by the DB-4 public endpoints: scan the dashboard QR (CameraX preview + ML Kit barcode; the QR'srestora360os://pair?host=…&nonce=…&tenant=…deep-link is self-contained — it carries the server origin) or type the 6-character code against a server address (the always-available fallback; inline camera-permission with a "use the code instead" path so a no-camera phone still pairs). On success the raw device token is stored in EncryptedSharedPreferences (Keystore-backed, corruption-resilient) — never plain prefs, never logged. Heartbeat fires on terminal open + on each completed transaction (reports transport/battery/model/app-version), so the dashboard shows online/last-seen and the 30-day idle clock resets; a 401 wipes the token and drops back to Connect (remote-unpair / 30-day expiry), and a day-25 grace banner warns as the window nears. Server Home now shows the real paired identity (name/lane) instead of the seed avatar. New deps:androidx.security:security-crypto, CameraX, ML Kit barcode; CAMERA permission (optional). Security: a scanned QR is attacker-controllable, so the host is validated to https (http only for localhost/RFC-1918 LAN), closing the QR-phishing + minSdk-26 cleartext gap. Adversarially reviewed (11 agents, 3 lenses × verify): 6 fixes incl. atomic heartbeat state transitions (a concurrent OK could otherwise resurrect a 401-wiped pairing), a cancellation-safe CameraX bind (dispose-before-bind window), and the QR-host allowlist.:terminal:assembleDebuggreen. (DB-5 PoS discovery now built — see the entry above; real PCI tap-to-pay SDK + merchant onboarding remain P40.)
- Phase 42 — Restora360 OS dual-boot terminal, DB-4 (remainder): the merchant "Payment Terminals" dashboard page. Completes DB-4 — the owner-facing UI on top of the pairing backend, at
/dashboard/terminals(Operations nav, newCreditCardentry beside "TVs & Displays"). Pair a phone: an optional register/section label + Generate code →POST .../android-terminals/pairing-code, which renders the server-built QR (the proxy-aware deep link) alongside the typeable 6-digit code with a live 15-min countdown ("New code" re-mints, single-use), a copy-to-clipboard, and an honest "single-use, expires in 15 minutes" note. Paired phones: a fleet list (GET .../android-terminals, polled every 15 s) with an online dot, name, lane chip, last-seen, and the heartbeat telemetry that DB-5 will report (Wi-Fi / mobile-data / offline transport, battery, model, app version), plus a confirm-gated Unpair (DELETE .../android-terminals/[deviceId]→ the phone drops to "Connect to your register" on next contact). A separate Recently unpaired section surfaces the cron's revoked-row audit window, tagged "Unpaired by you" vs "Auto-unpaired (30 days idle)". Footer note states the 30-day auto-unpair + that card processing stays in test mode until go-live (P40). Owner-safe throughout (the route never returns the token hash). tsc 0.
- Phase 42 — Restora360 OS dual-boot terminal, DB-4: device pairing & lifecycle BACKEND (BYOD). The web side of "turn a server's own phone into a payment terminal" — mirrors the TV customer-display pairing. The owner mints a short-lived credential (a nonce embedded in a QR + a typeable 6-digit code) bound to tenant+lane; the phone redeems it for a long-lived device token (only its SHA-256 hash is stored on
MasterDB.androidTerminals; the raw token is returned once); the phone heartbeats (token-authed, constant-time compare) so the dashboard shows online/last-seen; a daily cron auto-unpairs after 30 days idle (+ prunes long-dead revoked rows + expired credentials); the owner can remote-unpair. New pure enginelib/android-terminal/pairing.ts(15 tests). API: ownerPOST /api/tenant/[id]/android-terminals/pairing-code(mint credential + QR svg via the proxy-awarepublicOrigin),GET .../android-terminals(owner-safe list — never the hash),DELETE .../android-terminals/[deviceId](remote-unpair); publicPOST /api/public/android-terminal/pair(rate-limited, single-use, 404-not-403) +POST /api/public/android-terminal/heartbeat(401 → the phone wipes its token); cronGET /api/cron/android-terminal-cleanup(CRONSECRET). Additive `MasterDB.androidTerminalPairings` + `androidTerminals`; QR deep-link `restora360os://pair?host=…&nonce=…&tenant=…`. Adversarially reviewed (6 fixes incl. the proxy-aware QR origin). MOCK-first (real card payments are the separate Android slice + P40). tsc 0; 15 engine tests green. (DB-4 completed by the dashboard page above; DB-5 = the Android app's pairing screen is next.)_
- Phase 36 — Canteen module (prepaid wallets), slice 1: "wallet charged at the till." Built reuse-first off the gift-card engine — designed via a multi-agent workflow whose adversarial review caught a revenue double-count before any code was written. A
lib/canteen/pure engine is a gift-card sibling plus the one op gift cards lack,credit()/top-up (createAccount, code-firstgenerateAccountCode/ensureUniqueCode,previewSpend,applyWalletSpend,credit,applyAdjust,publicProjection; 7 tests). API: accountsGET/POST(list + search / create — code-first, email optional),[id] GET/PATCH(edit / freeze / close / reissue badge),/lookup(scan-a-badge till resolver),[id]/topup(a real charge through the Phase-33 ledger,source:'canteen-topup', + a wallet credit; idempotent — a retried top-up never double-credits),[id]/adjust(manual ±). Till: the POS order route gains optionalcanteenAccountId/canteenCode— the wallet debit runs inside the existing order write-lock (authoritative balance read-modify-write; insufficient balance → 402) and creates no ledger charge (the meal is the food-revenue event; the krona was already collected at top-up). Accounting honesty:summarizeLedgernow breaks outtopups, so prepaid wallet float isn't read as a food sale. Dashboard/dashboard/canteen(Operations nav): accounts list + search + new-account, balance + a printable badge code, top-up / adjust / freeze / reissue / close, and signed wallet history. AdditiveTenantDB.canteenAccounts+Order.canteenAccountId/canteenWalletAmount/subsidyAmount(reserved) +paymentMethod 'wallet'; newUtensilsCrossednav icon. Deferred (reserved seams): employer/school subsidies (slice 2), groups + monthly B2B invoicing (slice 3), self-service + daypart menus (slice 4+), refund-unspent-balance. 7 engine tests + the ledger-breakout test; tsc 0; full suite green (only the 2 pre-existing mason-field-marker failures remain).
- Phase 36 — Bar module (tabs). Open a tab, add rounds, verify ID, settle. New
lib/bar/engine (tab subtotals, line totals, lifecycle guards — 3 tests). API:GET/POST /api/tenant/[id]/bar/tabs(list open/recent tabs with paid/outstanding from the ledger + drinks for quick-add; open a tab),GET/PATCH .../[id](add-items/round,remove-item,rename,verify-age,void),POST .../[id]/settle(settle through the Phase-33 payment abstraction — full or split portion + tip, reusing the split settlement helpers; the tab closes once captured covers the subtotal). New page/dashboard/bar(Operations nav): open-tabs list + a detail panel with quick-add from the drinks menu, free-form items, an ID-verification badge/action, the running subtotal, and a split/tip settle (½/⅓/¼ chips + remaining). AdditiveTenantDB.barTabs; newMartininav icon. Reuses the unified ledger, so bar takings show in reconciliation + the per-order view. (Happy-hour is already covered by Offers; hard alcohol-gating per item is a follow-up.) tsc 0.
- Phase 36 — Catering public enquiry form (completes the catering loop). Website visitors can now submit a catering enquiry themselves: a public
POST /api/public/[id]/catering(no auth, lead-friendly — accepts any valid request) creates asource: 'web',status: 'enquiry'record in the same dashboard the owner triages (then prices + takes a deposit). A storefront page/tenant/[id]/cateringrenders the form in full site chrome (ThemeInjector + SiteHeader + SiteFooter): event date / headcount / fulfilment / dietary / notes, the restaurant's minimums shown as hints, and a confirmation with theCAT-####reference. The existing MasonCateringSectionblock's CTA now defaults to/catering(still overridable viabuttonLink). tsc 0.
- Phase 36 — Catering & Events (admin slice). The design-only catering block becomes a real module. A tested pure engine (
lib/catering/engine.ts— quote totals incl. delivery/tax/deposit, package pricing,CAT-0001references, lifecycle transitions, lead-time check) backs a staff workflow:GET/POST /api/tenant/[id]/catering(list + create enquiry),GET/PATCH /api/tenant/[id]/catering/[id](detail + quote builder + status transitions), andPOST .../[id]/deposit(take a deposit through the Phase-33 payment abstraction → unified ledger; auto-confirms a quoted enquiry on first deposit). New page/dashboard/catering(Operations nav): enquiry list + a detail panel with an editable line-item quote builder (live totals + deposit preview), status actions (send quote → confirm → complete / decline / cancel), a deposit collector, and an activity timeline. AdditiveTenantDB.cateringEnquiries/cateringPackages+TenantSettings.cateringConfig. NewPartyPoppernav icon. 5 engine tests; tsc 0. (Public enquiry form + Mason block wiring is the next slice.)
- Phase 33 (cont.) — refunds (full & partial) from the per-order view. Each paid charge now has a Refund action:
POST /api/tenant/[id]/payments/refundrefunds through the provider abstraction (mock today; real at Phase 40), records a linked refund row in the unified ledger (refundsLedgerIdties it to its charge), and flips the order torefundedonce fully refunded — capped to the charge's remaining refundable amount, re-checked under the write lock so two refunds can't exceed it. The per-order page reworks its summary to Collected / Refunded / Net (collection progress is refund-independent — a refund is money returned, not a re-opened balance to collect) with an inline amount + reason form. New testedrefundedForCharge/grossPaidForOrder/refundedForOrderhelpers; refunded orders are excluded from the split-bill unpaid list. Owner/manager-gated. 16 unit tests; tsc 0.
- Phase 33 (cont.) — per-order payment view. A drill-down at
/dashboard/payments/order/[orderId]showing every charge/refund for one order (or table bill) — method, amount, tip, status, mock/live tag, provider ref — plus captured / outstanding / tips with a progress bar, and the order's activity timeline.GET /api/tenant/[id]/payments/order/[orderId]resolves the id against orders or table bills (so any ledger row links cleanly) and returns the entries + timeline, with a new testedtipsForOrderhelper. Reconciliation transaction rows and the split-bill panel now link into it. Owner/manager-gated. 15 unit tests; tsc 0.
- Phase 33 (cont.) — global MOCK/LIVE payment banner. A thin, dismissible chrome bar in the dashboard layout (
PaymentModeBanner, fed the server-resolvedgetPaymentMode()) that, in mock mode, reminds staff/owners on every page that no real money is moving — with a link to Payments. In live mode it renders nothing, so production chrome stays clean. Dismissal persists per-browser (localStorage) and is applied after mount, so there's no hydration mismatch. tsc 0.
- Phase 33 (cont.) — split-the-bill POS flow. The split settlement primitives are now a usable surface. The POS settle route gained additive partial-payment support — an optional
amount(a split portion, capped to the balance viachargeableAmount) + apaymentRef(keeps a retried portion idempotent); the order only flips topaidonce captured charges cover the total, and the balance is re-derived under the write lock so concurrent portions can't over-collect. The no-amountfull-settle path (waiter app, POS pay-later) is unchanged. NewGET /api/tenant/[id]/payments/unpaid-orders(orders still owing money, with captured/outstanding from the ledger) and a new page/dashboard/split-bill(Operations nav): pick an unpaid order → even-split quick chips (½/⅓/¼/⅕ via the testedevenSplit) or a custom amount → charge each portion with method + optional tip; a live total / paid / outstanding progress bar; the bill clears when fully paid. NewchargeableAmounthelper +Splitnav icon; "Split a bill" link from the Payments page. 15 unit tests; tsc 0.
- Phase 33 (cont.) — Cash Drawer page + routes (the Z-report goes live for staff). Wired the cash-drawer primitive into a usable surface:
GET/POST /api/tenant/[id]/payments/cash-drawer(open a shift with a float, record pay-in / pay-out, close with a counted total → Z-report; one open shift per tenant, conflicts return 409) and a new page at/dashboard/cash-drawer(Operations nav) showing the live Z-report (opening float + cash sales incl. tips − refunds + pay-ins − pay-outs = expected), the movements log, an add-movement form, a close form with a live variance preview, and a recent-shifts table. Cash sales are derived from the unified ledger (methodcashwithin the shift window) — no settle↔drawer coupling. Owner/manager-gated (requireTenantAccess). NewCoinsnav icon + a "Cash drawer" link on the Payments page. tsc 0; 14 payments tests green. - Phase 33 (cont.) — split-bill, tips & cash-drawer/Z-report primitives. Pure, tested commerce-core building blocks under
lib/payments/, reusable on any payment surface. Tips (tips.ts— suggested-tip options, fat-finger-guardingclampTip,withTip) now thread end-to-end through the ledger (newtipAmount, kept apart from salegrossso reconciliation and tip-pooling stay accurate) and are wired into the POS settle route (optionaltip) and table-pay (records the bill's gratuity); the reconciliation page gains a Tips KPI. Split-bill (split.ts—evenSplitwith exact-cent remainder spread,validateSplitAmounts, and ledger-driven settlement helpersamountCapturedForOrder/outstandingForOrder/isFullySettled, so one order can be paid by several charges). Cash drawer + Z-report (cash-drawer.tspurecomputeZReport: opening float + cash sales (incl. cash tips) − refunds + pay-ins − pay-outs = expected, vs counted = variance — plus acash-drawer-engine.tsopen/movement/close persistence engine mirroring the table-payment engine, on an additive optionalTenantDB.cashSessions). 14 unit tests; tsc 0. (Split-bill and the cash-drawer engine are ready primitives; their POS UI/routes are the next wiring step.) - Phase 33 (cont.) — Payments & Reconciliation dashboard. New owner-portal page at
/dashboard/payments(in the Analytics group) reading the unified ledger viaGET /api/tenant/[id]/payments/reconciliation: net / gross / refunds / transaction-count KPIs, a by-method breakdown, and a recent-transactions table — every POS, online-checkout and table-pay settlement in one view, each row tagged mock vs live. An honest MOCK MODE banner makes explicit that no real money is moving until go-live (Phase 40 /PAYMENTS_MODE=live). Date-range presets + method filter, backed by a pure, testedfilterLedger. Owner/manager-gated (requireTenantAccess); reads the tenant's own ledger only. NewWalletnav icon. 9 unit tests; tsc 0. - Phase 33 (cont.) — online-checkout + table-pay wired to the unified ledger. Extended the ledger to every remaining server-side paid transition, additively (no existing payment logic changed): the Stripe webhook (
payment_intent.succeeded/failed— idempotent by PaymentIntent id, so a redelivered webhook can't double-post), the PayPal capture route (dev + production, idempotent by PayPal order id), and table-pay via a single record insidemarkBillPaid(idempotent by bill; api_dev_*intent is taggedmock, a realpi_*is taggedlive). New flatrecordOrderPaymenthelper reuses the idempotent ledger insert. So every place an order/bill becomes paid now lands one row inTenantDB.paymentLedger, tagged mock vs live — the single source of truth for the reconciliation view. 1 new test (8 total); tsc 0. (Cash-on-pickup orders never flip to paid server-side today, so they're intentionally left un-ledgered — unchanged behavior.) - Phase 33 — Commerce Core & Payment Abstraction (mock-first keystone). Introduced a provider-agnostic payment seam under
lib/payments/so every money path codes against ONE interface and the platform runs fully on mock money — real PSP rails (Stripe/Adyen/Swish/tap-to-pay) drop in at Phase 40 without a rewrite. Pieces:types.ts(PaymentProviderinterface +PaymentChargeRequest/Result, refunds,PaymentLedgerEntry);mode.ts(getPaymentMode()— defaults tomock, opt intoliveviaPAYMENTS_MODE=live);mock-provider.ts(the dev default — instantpaid/refunded, never contacts a PSP, withmetadata.__mocktest hooks fordecline/pending/refund-fail);registry.ts(resolvePaymentProvider— serves the mock in mock mode and fails loud in live mode when no real provider is registered, so real money is never silently mock-settled;registerLiveProvideris the Phase 40 seam);ledger.ts(idempotent-by-keypostChargeToLedger/postRefundToLedger+ asummarizeLedgerreconciliation rollup that tags mock vs live);service.ts(capturePayment/refundPayment, documented to run OUTSIDE themutateTenantDBlock). Added an additive optionalTenantDB.paymentLedgerfield. Wired the first real path — the POS/waiter in-person settle route (/api/tenant/[id]/pos/orders/[orderId]/payment) — through the abstraction (charge outside the lock → persist order + ledger inside it, idempotent per order), preserving its response shape and already-paid no-op. 7 new unit tests; tsc 0. (Next slices: online-checkout + table-pay paths, the reconciliation dashboard + MOCK/LIVE dev banner, then real rails at Phase 40.)
Fixed
- Clone-quality: the account page now wraps in the shared (localized) site chrome instead of feeling like leaving the site.
/accountrendered its own bare "My Account" bar — a back-arrow to/menu, no navbar / footer / brand / language switcher — and ignored?lang. It's now a thin serverpage.tsxthat mounts the realSiteHeader(with Mitt konto active + the EN/SV switcher) andSiteFooteraround the client account panel (extracted toAccountClient.tsx), resolving the viewing language so the chrome honours?lang. Verified onten_kebabtime-se(sv): a localized navbar + footer now frame the sign-in/dashboard. tsc 0. (The account body strings — Sign In / Welcome back / dashboard tabs — are still English; full body-string translation is a larger follow-up.) - Clone-quality: a Swedish-default storefront now renders Swedish chrome (was hardcoded English). A
svrestaurant opened with an English navbar (Home/Menu/Reservations…), "Book Table" button, and sub-page H1s ("Our Menu", "Reserve Your Spot"…). Newlib/i18n/storefront-chrome.tslocalizes the storefront's own chrome: default nav labels (SiteHeader+SiteFooter), the "Book Table" button, and the default sub-page headings (defaultPageBlocks+ its 4 live-render callers). Onlysvflips to Swedish; every other language stays English (the safe universal default — a German tenant doesn't get Swedish chrome). A custom owner-authorednavConfigis always rendered as-is. Verified onten_kebabtime-se: navbar Hem · Meny · Boka bord · Mitt konto · Kontakt, button Boka bord,/menuH1 Vår meny. +3 tests; tsc 0. (Remaining localization: the account page renders its own English header + ignores `?lang`, and cloned menus store `sv === en` copy — both larger follow-ups.) - Clone-quality: a kebab shop no longer ships the platform demo menu (sushi/pizza/macarons). The WoW claim back-filled
buildDemoMenu()as the LIVE menu whenever a clone's extraction came back empty — so a real restaurant could go live with Sushi Selection, Margherita Pizza, Pad Thai + French Macarons, demo Unsplash photos, and generic Starters/Mains/Desserts/Drinks categories (claim.ts:217). Claim now imports the real extracted menu only; an empty extraction leaves the menu empty — the storefront shows a clean "Our Menu" empty state and the owner imports their menu from the dashboard, rather than fabricating dishes. NewisDemoMenuProducts()(derived frombuildDemoMenuso it stays in sync) backs an opt-in--clear-demo-menuflag on the normalization script to strip the inherited demo menu from already-affected clones (flagged by default, never auto-cleared). Verified onten_kebabtime-se: no demo dishes on home or/menu, the empty menu page renders cleanly. +2 tests; tsc 0. - Clone-quality (E): scraped-domain names cleaned at the source + a one-time normalization for existing tenants. Closes the baked-in artifacts the render-layer batch couldn't reach. Pipeline guard: the brand name is now cleaned with
displayBrandNameat the single WoW preview chokepoint (createWowPreview) and at the scrape source (deriveSiteName), so a fresh clone ofkebabtime.sestores "KebabTime" — which cascades to the claim-stamped hero headline and the templatedseo.metaTitle, so both come out clean for new clones. Data fix: a new idempotent scriptscripts/normalize-clone-artifacts.ts(dry-run by default,--writeto apply) cleanssettings.name+ the hero headline +seo.metaTitle/metaDescriptionand drops dev/seed placeholder emails across existing tenant DBs — run it on each deployed server. Verified onten_kebabtime-se: navbar, hero headline, and<title>all render "KebabTime" with no domain artifact left. +3 tests; tsc 0. - Clone-quality batch: scraped-domain names, dev placeholder emails, empty sections + SEO/JSON-LD correctness (from a multi-agent storefront audit). A render-layer batch so existing cloned tenants render polished without a data migration. (A)
displayBrandName(lib/text/decode-html-entities.ts) strips a scraped-domain tail + trailing punctuation —"KebabTime.se."→ "KebabTime" — and the navbar, footer, homepage<title>/OG and Restaurant/WebSite/Menu JSON-LD all route through it. (B) newlib/contact-clean.tscleanPublicEmaildrops dev/seed placeholder addresses (wow-dev-…@example.test, reserved-TLD) from the contact card, footer, and Restaurant JSON-LD. (C) the contact socials card hides on a clone with no socials (no bare "Stay tuned" box), and the default Blog nav link hides when the tenant has no posts. (D) SEO/JSON-LD correctness — currency-aware `priceRange` (a SEK kebab is$, not$$$$), ISO `addressCountry` + postal-code split out of the locality, real cuisine mapping (kebab/turkish/grill) with no generic-"Restaurant" echo and no duplicateservesCuisine, and empty description/email/cuisine omitted from the schema. 24 unit + integration tests; tsc 0. (Remaining baked-in artifacts — a hero headline + stored `metaTitle` the cloner set to the bare domain, and the footer Blog link — need a one-time data normalization / clone-pipeline fix.) - Storefront showed raw HTML entities in the restaurant name + duplicated/unordered opening hours. Two render-layer fixes for cloned/imported tenants — both work without a data migration. (1) The central
RestaurantBrandcomponent (navbar/footer/mobile) and the homepage<title>/OpenGraph now decode the stored name, soMENY – MRS SAIGONrenders Meny – Mrs Saigon. (2)OpeningHoursSectionnow groups weekly hours into one ordered row per weekday via a new pure, testedgroupWeeklyHours(lib/builder/opening-hours.ts) — dedup identical ranges, merge split lunch/dinner ranges under one day, sort Mon→Sun — fixing the 14-row duplicated/unordered list (and a duplicate Reactkey). 6 new unit tests; tsc 0. (The stored name still carries the entity, so a few secondary SEO surfaces — sub-page titles, JSON-LD — read it raw until a one-time data normalization; new clones are already clean via the `createWowPreview` decode.) - Server-built absolute URLs used `localhost:3001` behind a reverse proxy (37 sites across 18 files). The WoW claim email built its verify URL from
req.nextUrl.origin, which behind a tailscale/nginx proxy is the app's internal origin (http://localhost:3001) — so the "Claim my restaurant" link in a real email was unclickable from a phone/another device. An audit found the same class of bug in every server-built external link: the Launchpad claim email, staff-invite "Go to Dashboard", the accountant magic-link, designer-invoice links, QR-code targets (receiving-scanner pairing, location QR), every Stripe redirect (signup, webshop checkout, tenant billing + AI top-up + add-ons, agent/designer/supplier Connect, designer membership), and the Google-OAuth + email-verify browser redirects. New shared helper `publicOrigin(req)` (lib/http/public-origin.ts) resolves the public origin in priority:APP_URL→NEXT_PUBLIC_APP_URL→NEXT_PUBLIC_SITE_URL→x-forwarded-host(+x-forwarded-proto) →hostheader →req.nextUrl.origin. All 37 sites now route through it (APP_URLis checked first because it is read at runtime, so a deployed box can fix links without a rebuild). Deploy note: setAPP_URLon every box (e.g.https://restora-server.tail8deedd.ts.net, prodhttps://restora360.com) so external links are correct regardless of the proxy. - WoW clone restaurant name showed template artifacts (e.g. `" + "Basant`). Some sites' raw
<title>/og:site_namecontain leaked JS string-concat (" + ");deriveSiteNamereturned them verbatim, so a cloned site was named" + "Basant. It now sanitises the derived name — strips concat artifacts + stray double-quotes/backticks while preserving apostrophes (Luigi's) and a legitimate mid-name+(Bar + Kitchen). Unit-tested. - WoW clone restaurant name showed raw HTML entities (e.g. `Meny – Mrs Saigon`).
deriveSiteNameread the page<title>/og:site_namewithout decoding HTML entities, so a numeric/named entity (–,&,é) was stored verbatim — rendered literally on the preview, in the claim-email subject, and as the provisioned tenant name — and it also defeated the generic-word split (Meny – Mrs Saigonkept the genericMeny). Decoding is now a centralized rule: a shared pure modulelib/text/decode-html-entities.ts(decodeHtmlEntities+cleanName) is applied at the single preview-name storage chokepoint (createWowPreview+updateWowPreviewInfo), so every name source (scraped title, AI brand name, caller-supplied) is decoded before it is persisted — not just the title path.deriveSiteNamedecodes before splitting (soMeny – Mrs Saigon→Mrs Saigon), and the WoW claim bar decodes defensively for display of legacy previews. SoTom & Jerry→Tom & Jerry,Café→Café. Unit-tested (decode-html-entities+site-health). - Completed the public-origin fix for every post-login redirect, FSN OAuth, and env-based email/SEO URL. The WoW/Launchpad claim-VERIFY routes +
resumeClaim/provisionFromPreview/launchpad handoff built their post-login/dashboard+/loginredirects fromreq.url→localhost:3001behind the proxy, so a correct magic link still landed onhttps://localhost:3001/dashboard; all now usepublicOrigin(req). FSN Google/Apple login (authorize + token-exchangeredirect_uri) and the social cross-posting connect/callback redirects now use FSN'spublicOrigin(given anAPP_URLfallback). The cron trial/verification emails, gift-card emails, and the SEO canonical fallback now prefer `APP_URL` over the build-inlinedNEXT_PUBLIC_SITE_URL, so a deployed box emits correct links without a rebuild — gift-card emails previously fell back tohttp://localhost:3000. - Restaurant open/closed is evaluated in the restaurant's timezone, not the server's.
getOpenStatuscomparednew Date().getHours()(server UTC) against local opening times, so on a UTC box every tenant read "Opens at 12:00 / closed" during business hours — blocking the storefront banner AND online ordering (cash/card/PayPal share the engine). It now evaluates "now" in the restaurant's IANA timezone (OpenStatusOpts.timeZone) and readsyyyymmddfrom local parts. Verified live across 5 zones (Stockholm open · New York/LA/Tokyo closed · Dubai open, for the same 9–5). - Super Admin → Tenants: a long tenant name no longer clips the table, and "View site" links to the real storefront. A 60-char unbreakable name stretched the Restaurant column under
table-layout:auto, so the card'soverflow-hiddencut off Status/Created/Actions for every row (the delete button became unreachable); long name/owner/email values now wrap with a horizontal-scroll fallback. "View site" previously hardcodedhttp://<subdomain>.localhost:3001(dead from any deployed box / no wildcard DNS) — it now uses the tenant'scustomDomainor the host-relative/tenant/<id>route. - WoW health-check result page: removed the redundant "Build my better site" CTA band that looped back to
/wowwhen there was no rebuild data (the page already has the inline "See your site rebuilt" button + the claim form).
Added
- Webshop Marketplace Admin — full product editing (edit · images · descriptions · categories · bulk tiers). The Products tab could only create + activate/deactivate — no way to edit a price, add an image, write a description, or manage categories. Now every product is fully editable: an Edit button per row opens the form pre-filled; new description field; product images (upload from disk via the new super-admin
POST /api/admin/webshop/products/upload→public/uploads/webshop, or paste a URL — with a live thumbnail); a category picker (datalist of existing categories + type-a-new-one); a bulk price-tier editor; plus unit/cost; and a thumbnail column in the table. The model gainedimageUrl+description(WebshopProduct), and the productPOST/PATCHnow accept the full editable field set. The storefront (/shop) renders the product image (replacing the grey SKU placeholder) and the description on each card. Also trimmed the shop's own sidebar to a shop-focused nav — Browse · Your orders · Subscriptions + a single Back to dashboard link (it had been a full copy of the restaurant dashboard nav incl. Menu/Tables/Reviews, which don't belong in a supplies-buying context). tsc 0. - WoW claim: resend the magic link + fix a wrong email — don't lose a customer to a junked/undelivered email. The "Check your email" step was a dead end: if the link landed in spam or was never delivered, the prospect was stuck with no way forward. It now offers a Resend email button (40s cooldown with a live countdown), a Use a different email path (correct a typo or try another inbox — the value stays pre-filled), and a check your spam/promotions hint that names the sender. The per-IP claim rate limit was raised 5→8/hour to give genuine retries headroom (each call still sends exactly one email; disposable addresses stay blocked). Verified live (form → sent → resend cooldown).
- Per-tenant timezone — open/closed + ordering correct anywhere in the world. New
TenantSettings.timezone(IANA), set on Business Hours via a full-timezone picker that auto-detects the owner's zone with a live "current local time" readout; the open/closed engine, the online-ordering guard, the storefront banner, and every Food Bazar marketplace listing evaluate in that zone (Europe/Stockholmonly as the fallback). Auto-captured at signup — a dashboard component reads the owner's browser timezone on the post-signup "welcome" load and saves it when unset (gated to thewelcomeparam so it can't mis-fire for an owner logging in abroad). WoW set-password + register now hand off to/dashboard?welcome=1.
- TERRA premium template — pixel-1:1 gap-closure + an opt-in hero press card. The standalone
/terradesign reference (the source the Mason TERRA template was authored from) is now committed, and the Mason template was brought up to full parity with it using the platform's own editable blocks (no bespoke renderer): a Marquee band (promo-banner/variant:'marquee'— "Seasonal ✦ Wood-Fired ✦ Locally Sourced …"), a Story section (editorial-text/variant:'editorial-split'— eyebrow + headline + body + the founders' pull-quote + image), and a Stats band (stats— 40 local growers · 120 seasonal dishes · 4.9★ guest rating) were added toterra-starter.ts(and the documentaryterra.tstree). To reproduce the hero's floating press-quote, the shared hero gained an opt-in `pressCard` (hero-model.ts+HeroSection.tsx): a glassmorphic, desktop-only testimonial card (★★★★★ + quote + attribution) that renders only when a hero sets `pressCard.quote` — every other template's hero is byte-for-byte unchanged (default-off, theme-token-painted). All additive (editable Mason blocks + real menu/settings stay intact); tsc 0; hero-model + site-template tests green; next build EXIT 0. - Phase 17 Guardian — GD-4 deployment watchdog (closes Phase 17's buildable scope). After each recorded deploy, a pure engine (
lib/guardian/deploy-watchdog.ts) compares the post-deploy window against a baseline — synthetic-journey failure rate + average latency — and watches for new P1/P2 incidents that opened after the cutover. On regression it opens adeployment-source incident and recommends a rollback, which flows straight into the GD-8 approval-gated remediation (thedeploy-rollbackplay — still recommend-don't-act). Honest by design: a metric with no signal isnull(never fabricated), a too-fresh deploy reads watching (not a false "healthy"), and the watchdog only runs on live (recorded) deploys so seed never fabricates a regression. Deploys are recorded via `POST /api/admin/guardian/deploy` — accepting either a super-admin "record deploy" or the cron secret (a platform deploy webhook) — and surfaced in a new Deployments Command-Center view (watchdog verdict + baseline→post deltas + record-deploy form + history). Deployment incidents are internal-only — excluded from the public status page (the customer-facing symptom still surfaces via its own service/journey incident). New optional MasterDB field (guardianDeploys). 8 new unit tests (87 Guardian total); tsc 0; eslint clean; next build EXIT 0. (The other half of GD-4 — Sentry-class deep error tracking — stays a paid go-live integration.) - Phase 17 Guardian — GD-8 (approval-gated auto-healing + DR readiness + OTel-ready metrics + mobile). The last Phase-17 slice, right-sized + honest (the spec itself calls OTel tracing and unattended auto-act aspirational). Auto-Heal — a pure engine (
lib/guardian/remediation.ts) matches active incidents to a small registry of codified runbook playbooks (Postgres failover, storage/CDN failover, provider circuit-breaker, module cache-flush, app-recycle, deploy-rollback, tighten-rate-limit) and surfaces them as recommendations: every action is approval-gated — recommend, don't act; the recommend→approve→execute lifecycle records an operator-confirmed outcome and Guardian never touches infrastructure unattended (the classic foot-gun stays out). The probe generates + reconciles recommendations from live incidents each cycle (lifecycle preserved, recommendations auto-withdrawn when their incident clears). A new Auto-Heal Command-Center view +POST /api/admin/guardian/remediation(super-admin, audited, GDPR-scrubbed). Resilience & DR —lib/guardian/dr.tscomputes a DR-readiness scorecard tracing the reliability/DR strategy (RP-0→RP-7) with honest defaults (audit log, RBAC, expand-contract migrations, Guardian itself = in-place; managed PITR/immutable backups/multi-region =not-configureduntil go-live — never a fabricated "all green") + the per-data-class RPO/RTO objectives, plus an editable posture (availability tier, last restore game-day, per-control status) viaGET/PUT /api/admin/guardian/dr-config; a new Resilience view. Metrics export —lib/guardian/metrics.tsrenders Guardian's live signals as Prometheus / OpenMetrics text atGET /api/admin/guardian/metrics(the right-sized take on OpenTelemetry: export for a scraper rather than instrument the monolith; a scrape-token for unattended pulls is the go-live follow-up). Mobile —lib/guardian/mobile.tsbuilds a push-ready summary digest surfaced in the Resilience view (delivery rides the shipped alerting rails + the native Mobile API Gateway/APNs/FCM at go-live — no fake push sender). New optional MasterDB fields (guardianRemediations,guardianDrConfig). 14 new unit tests (79 Guardian total); tsc 0; eslint clean; next build EXIT 0. - Phase 17 Guardian — GD-2 live topology map. A new Topology Command-Center view renders the real dependency graph (clients → app modules → Postgres/storage/CDN + external providers) as a layered SVG with status-coloured nodes and failure propagation — a node is highlighted (dashed red) when something it depends on is critical/offline. Pure engine
lib/guardian/topology.ts(graph + propagation) overlays the live service statuses from the snapshot; right-sized (no invented microservice fleet). 4 unit tests; tsc 0; build clean.
- Phase 17 Guardian — GD-6 (AI watchdog) + GD-7 (reliability analytics + public status page) + MVP polish. GD-6: budget-gated AI root-cause analysis per incident (
POST /api/admin/guardian/rca→ Problem→Cause→Impact→Fix) + an Ops copilot (POST /api/admin/guardian/agent/chat) — correlation/facts computed PII-free inlib/guardian/rca.ts, the LLM (Haiku) only narrates; deterministic fallback with no key or over a newguardianAiUsagedaily cap; a new AI Insights Command-Center view. GD-7:lib/guardian/analytics.ts(MTTR/MTBF/incident-frequency/downtime over 24h/7d/30d/90d) + an Analytics view, and a public, scrubbed status page at `/status` (GET /api/public/status,POST /api/public/status/subscribe) —lib/guardian/public-status.tsstrips internal metrics, excludes security incidents, masks PII, and is resilient (never 500). Polish: live revenue-at-risk insnapshot.ts(real average-hour throughput from the order rollup + platform age, honest 0 without data) + editable escalation chain & maintenance windows in the Alerting UI. New optional MasterDB fields (guardianAiUsage,guardianStatusSubscribers). 24 new unit tests (rca/analytics/public-status); tsc 0; next build clean; runtime-smoke verified (public scrubbing, auth gates, subscribe). Reviewed by an adversarial multi-agent pass.
- Phase 17 Restora Guardian — 24/7 monitoring + incident response (MVP slice: GD-0 + GD-1 + GD-3 + security/impact). A new super-admin Command Center at `/admin/guardian` (own sidebar + scoped
.gd-roottheme, light/dark, "Run journeys"/CSV), right-sized to the real topology (Next.js monolith + pooled Postgres + providers — no faked microservice/Redis/queue dashboards). Engine inlib/guardian/: tier-weighted Platform Health rollup; synthetic monitoring (HTTP-level owner/customer/supplier/agent journeys against real public routes — auth-only steps skipped honestly, browser-level Playwright deferred); incident engine (P1–P4 auto-detect from journeys/health/security, dedup by fingerprint, ack/resolve/assign/note lifecycle, MTTR); intelligent alerting (severity floor, dedup, maintenance windows, escalation chains → email/SMS rails reused + a webhook seam for Slack/Discord, anti-fatigue by design); statistical anomaly detector (z-score/EWMA, no LLM); security watchdog (assembled from the audit log + a new additivesnapshotRateLimitCounters()on the rate-limiter — failed logins/abuse/permission violations, GDPR-masked principals); and customer-impact (real active-tenant blast radius, honest revenue-at-risk). A secret-gated `/api/cron/guardian-probe` (every 5 min) runs the cycle + pages; a manual run never pages. Build-with-seed honesty throughout (live where a source exists, seed tagged "sample"). API:GET /api/admin/guardian(+?format=csv),GET/POST /api/admin/guardian/incidents,GET/PUT /api/admin/guardian/alert-rules,POST /api/admin/guardian/synthetic/run. New optional MasterDB fields (guardianIncidents/guardianSyntheticRuns/guardianAlertConfig/guardianAlerts). 46 new unit tests; tsc 0 errors. Deferred (named follow-ups): paid integrations (Sentry/uptime/live Slack/Twilio number), browser-level synthetic runs, GD-2 topology map, GD-6 AI watchdog/RCA/Ops copilot, GD-7 public status page + MTTR/MTBF analytics, GD-8 auto-healing/mobile/OTel/DR.
Changed
- WoW clone — premium "Restora Standard" fallback theme (Phase 4 of the unified flow). When AI theme-matching isn't possible (no key / unreadable design / model failure), the rebuild used a plain Inter + flat-gradient generic theme. The fallback (
fallbackDesignDna) is now a curated, premium theme: elegant Playfair headings + Lato body, a refined warm palette, a full-bleed photo hero (never a bare gradient), airy spacing + elevated shadows + transparent-sticky nav + a story section. So a weak match degrades to something beautiful + on-brand, not generic. Verified: builds a complete 10-block site (hero, features, menu, gallery, story, testimonials, CTA, contact, hours, map).
- WoW funnel — "upload photos" now builds the WHOLE site, not just the menu (Phase 3 of the unified flow). The unified entry's photo path previously ran menu-only extraction. It now sends the uploaded images to the design-DNA route as both the design source and the menu source, so several photos → menu (dishes + prices) AND a matched theme + full builder layout — the same complete site the URL path produces. New
handleBuildFromFilesinuseWowImport(width-capped upload preserving full menu height); the route's budget raised to 120s for large multi-photo menus. Verified live: one uploaded image → full site with 14 menu items. (The landing hero's separate menu-photo card keeps the menu-only path.)
- WoW funnel — one result page (Phase 2 of the unified flow). "Build my site" (URL) now lands on a single result that runs everything automatically and shows the full sequence top-to-bottom: health score → before/after → the imported menu (dishes + prices) → email claim. The redesigned health-check page is now that result — it auto-runs from a
?url=param (check, then rebuild, no extra click),from-urlreturns a slim menu summary so the dishes render inline, and a cream-themed claim form (email → magic link → account + free trial) sits right below the menu. Direct visitors to/wow/health-checkkeep the original manual check→rebuild behaviour (no auto-run without?url=). Photos (no existing site/health) still go straight to the/wow/[token]preview.
- WoW funnel — one front door (Phase 1 of the unified flow). The
/wowpage previously split into two confusing tools ("Import Your Menu" + "Clone Your Brand") plus a separate health check. It's now a single "Build my whole site" box: paste your website URL → full rebuild (design + menu viafrom-url), or upload menu photos/PDF → menu — both through the same shipped engine (useWowImport). Demo + "score my current site" (health check) are secondary links. NewWowUnifiedImportreplacesWowPremiumImporton/wow; hero copy updated to lead with both paths. (Next phases: one result page with health → before/after → menu → email; multi-photo design; premium fallback theme; forced set-password.)
Added
- WoW registration — magic link → forced set-password → dashboard + trial (Phase 5, final phase of the unified flow). Claiming a site from a WoW preview was passwordless (magic link → straight to the dashboard). Now the emailed link confirms the email + logs in + starts the trial and lands the owner on a required "Set your password" step (
/welcome/set-password) before the dashboard — so the account is fully usable (email + password) in one confirmed pass. NewUserRecord.mustSetPassword(set on WoW provision, cleared by the new guardedPOST /api/account/set-password); the claim-verify route redirects to the set-password step; the dashboard gate enforces it (a passwordless owner who navigates straight to/dashboardis sent to set a password first). Verified: tsc 0 errors, next build OK, auth gates correct (page 307→/login + API 401 when signed-out). This completes the unified WoW flow (Phases 1–5).
- WoW free trial is now super-admin managed, site-wide. When an owner claims a site from the WoW funnel, the free trial length was hardcoded (30 days). It's now driven by platform settings: Super Admin → Settings → "WoW Acquisition · Free Trial" exposes a trial on/off toggle (
freeTrialEnabled, already platform-wide) and a trial length (days) field (wow.freeTrialDays, default 30, 1–365).provisionFromPreviewreads them — trial on → the owner lands in the dashboard on a no-card trial of the configured length; trial off → routed straight to/register/payment(the existing paid-signup gate). The settings PATCH deep-merges the nestedwowguards so a partial save can't wipe the cost caps. (The email→magic-link→register flow that starts the trial already existed — this just makes its length/enablement centrally controlled.) Verified: 7 config-resolution checks, tsc 0 errors, next build OK.
Changed
- WoW preview "view cloned site" (dev) no longer dead-ends the preview. The dev-only "View your cloned site" shortcut on a URL-clone preview navigated the same tab to the live site, losing the preview with no way back. It now opens in a new tab (and is relabelled "Dev: view the cloned site (new tab, keeps this preview)"), and the underlying dev peek already keeps the preview editable (
consume:false). (Production is unaffected — that shortcut isn't rendered; the email "Make it yours" claim is the only CTA there.)
- AI Menu Intelligence — Slice F (request-level stock-lookup dedup, P9).
matchItemImagesnow coalesces identical stock-photo lookups across a single import: duplicate dish names and shared category fallbacks hit the providers once per unique query instead of once per item (a per-request promise cache), cutting external Pexels/Unsplash/Pixabay calls, speeding up matching, and reducing rate-limit pressure. (The WoW clone enrichment already deduped by name; this closes the gap in the dashboard "match images" flow.) Note on the rest of P9: the original audit's background queue for the "60-second guarantee" was premised on a serverless 60s hard-kill — but production runs self-hostednext start(and the dashboardai-extractroute declares nomaxDuration), so route time isn't platform-capped there and a queue is largely unnecessary; content-hash extraction caching is low-ROI since per-menu token cost is image-dominated, not prompt-dominated. tsc 0 errors, next build OK.
- AI Menu Intelligence — Slice G-semantic (auto-suggested allergens at import, P4). The dashboard menu import now suggests EU-14 allergens per dish and surfaces them on the review screen as removable amber chips ("Allergens (suggested)") — the owner confirms/edits before importing, and the kept ones are written to
product.allergens(previously always empty on import). Reuses the exact deterministic detector + catalog matcher from the product editor'sdetect-ingredientsroute (detectIngredients+detectAllergensover tenant + canonical catalog, zero AI tokens) via a newmenu-semantics.ts; theai-extractroute attachessuggestedAllergensper item (best-effort) andai-importvalidates them against the registry before persisting. Safety gate preserved: allergens are SUGGESTIONS the owner reviews — never silently applied — and only on the dashboard flow that has a review step (not the automated WoW claim); the UI is framed "suggested — verify, may be incomplete". Dietary tags are deliberately not inferred (a single non-conforming ingredient would make a dish-level claim false). tsc 0 errors, next build OK.
- AI Menu Intelligence — Slice G-image (multi-layer image fallback, P5). When the dish-name stock search returns nothing relevant (no token overlap with any result),
matchItemImagesnow falls back to a category-representative query (e.g. an unmatched pizza dish → "pizza italian restaurant") so the item still gets an on-theme photo instead of an unrelated one — the staged degradation the spec's Phase 5 calls for, layered on top of Slice C's English-name search and the existing demo/taxonomy fallback insidesearchStockPhotos. The fallback fires only when the primary search is irrelevant (bounded — one extra call), and its results are capped to the "suggest" tier so a category match never auto-assigns or strongly-recommends (honest: it's the right category, not necessarily the exact dish). tsc 0 errors, next build OK.
- AI Menu Intelligence — Slice D/E (extraction confidence + honest live progress). (D) Per-item extraction confidence (P6) — Stage-1 extraction now also returns a 0-100
confidenceper item (how sure the AI is it read the name + price correctly); it's stored onExtractedItem, used as a tie-breaker inmergeExtractedMenus(the more-confident duplicate wins), and surfaced in the dashboard review: items the AI was <70% sure about get an amber "Double-check this one — the AI was only N% sure" badge, so the owner verifies exactly what's uncertain (the premium "we tell you what we're unsure about" feel). (E) Honest staged progress + adaptive ETA (P7, safe parts of P8) — the import modal's extraction step replaces the fixed 5s ticker with a real elapsed-vs-estimate progress bar + live countdown derived from the actual page/photo count (~9s/page, bounded 12–90s); the checklist advances from real elapsed time, and if it overruns it shows an honest "taking longer than expected — large menu (N pages)" message instead of a silent stall, with a matching "import fewer pages at a time" explanation if a big upload times out. (The hard 60-second guarantee still needs the queue work in Slice F — not promised here.) Verified: 2 new unit tests (confidence normalize + merge tie-break), tsc 0 errors, next build OK.
- AI Menu Intelligence — Slice A/B/C (cleaner names, any-language photos, resilient extraction). Extends the existing menu-import engine (one system, not a parallel one). (A) Automatic name cleaning — the extractor now strips leading menu numbering / item codes / list markers from dish and category names:
01. Pizza Vesuvio→Pizza Vesuvio,Nr 15 Oxfilé→Oxfilé,A12. Sushi Roll→Sushi Roll,12) Mix Grill→Mix Grill,• Garlic Bread→Garlic Bread— conservative regex (cleanMenuNameinextract-core.ts) that preserves real names (7-Up,1.5L Cola,100% Beef) + a matchingsave_menuprompt rule. Fixes the double-numbering in the WoW preview and the false-duplicate-on-merge bug. (B) Resilient extraction + structured errors — transient AI failures (429/500/502/503/529…) now retry with exponential backoff + jitter (withRetry);MenuExtractionErrorcarries a machinecodesurfaced asreasonon the extract routes so the client can show a precise message. (C) Translate-for-image-search + language detection — Stage-1 extraction also returns a per-item EnglishnameEnand the menu'slanguage(zero extra AI round-trip — it's already reading the menu); image matching (image-match.ts) and the WoW clone enrichment (clone-imagery.ts) now search stock photos with the English name, so Swedish/Turkish/Arabic/Persian/etc. menus get relevant dish photos instead of demo fallbacks. Threaded through the dashboard import (MenuPhotoImportModal→match-images). Verified: 5 new unit tests, tsc 0 errors, and a live kebabtime.se clone (clean names + 7/7 photos via English search). (Remaining roadmap slices D–G — extraction confidence, real staged progress, caching/queue, multi-layer fallback + semantic layer — pending.)
Changed
- Free Site Health Check result page redesigned (handoff, high‑fidelity) — same real data.
/wow/health-checkis rebuilt to the new SaaS‑tool design (docs/Page redesign feedback/design_handoff_site_health_redesign): warm‑cream page with a top radial glow, Schibsted Grotesk + Hanken Grotesk (vianext/font), a confident hero + search bar, an animated circular score gauge (SVG donut, animates empty→score, respectsprefers‑reduced‑motion), pass/warn/issue stat tiles, an insight callout, a gradient conversion CTA, before→after browser frames, and a scannable two‑column audit grid with per‑check fix lines. All wired to the realsite-checkreport +from-urlrebuild (score/grade/pages/checks/summary/before+after are live data, not the prototype's seed) — the gauge colour + grade pill follow the handoff thresholds and counts exclude optional checks. Navbar/footer left as before (the handoff's branded sticky nav was intentionally NOT added); footer stays the snapshot line. Scoped under.r360hcso nothing leaks; collapses to one column ≤760px.
Fixed
- WoW URL clone now captures the FULL menu of app-shell / SPA ordering sites (was getting only the first few dishes). Many modern restaurant sites (e.g. grillhousemalmo.se) are single-page apps that scroll an inner panel, not the document, and lazy-render dishes as you scroll — so the old single full-page screenshot only captured the first screen, and the (correct) tile-and-extract step had just 3 of ~100 dishes to read.
url-screenshot.tsis rewritten to scroll-and-shoot: it waits for the SPA to render (networkidle), finds the element that actually scrolls (window or the largest inner overflow panel), and steps through it capturing a high-quality viewport frame at each position (15% overlap so no dish is split); those frames become the read tiles.from-urlfeeds every frame to layout detection + menu transcription. Result on grillhousemalmo.se: 3 → 103 dishes across all 14 categories. Route budget raised to 120s for large menus; capture is bounded (≤14 frames, 55s hard deadline, SSRF hardening preserved). tsc 0 errors, next build OK, verified live. - WoW clone: dish photos no longer show as broken images (preview + claimed site). Once the URL clone started importing the real menu, each dish was enriched with a stock photo (Pexels/Unsplash/Pixabay) — but hotlinking those CDNs directly breaks for many visitors (ad-blockers, hotlink/referrer restrictions, regional blocks, signed-URL expiry), so most dishes rendered a broken-image icon. Fixed two ways: (1) a new same-origin caching image proxy
GET /api/img?u=…(allowlisted to the stock CDNs only — not an open proxy; image-only, size-capped, rate-limited) thatenrichMenuWithPhotosnow routes every photo through, so images load from our own domain on the preview and the claimed tenant site (verified: 14/14 dishes load, 0 broken, on both); and (2) the preview menu now hides a failed thumbnail (onError) instead of showing a broken icon.import-to-tenant+ the clone hero/feature-card photo picks accept the proxy path. - WoW "clone/import from URL" now rebuilds YOUR restaurant — real menu + real name, not a random one. Pasting a site URL ("Import Your Menu", "Clone Your Brand", or the Health‑Check "Before → after") used to screenshot the page for design only: the menu stayed empty (so the "after" preview was a blank accent panel and the claimed tenant got a generic demo menu) and the restaurant name came from the AI, which is deliberately told to invent a fictional name — so you got a different restaurant.
POST /api/public/wow/from-urlnow (1) runs the same menu‑extraction engine the upload flow uses on the captured page tiles, so the visible dishes/prices are transcribed (concurrent with the design‑DNA + layout calls, each best‑effort — a failure just leaves the menu empty as before); and (2) reads the real brand name from the page's own `og:site_name`/`<title>` (deriveSiteName), falling back to the domain (kebabtime.se → "Kebabtime") before ever using the AI's invented name. Result: a populated before/after, a real menu imported on claim, and the correct restaurant name. Both/wowimport cards and the Health‑Check rebuild benefit (same endpoint). - URL inputs accept a bare domain (`kebabtime.se`), not just `https://www.…`. The server already normalised bare domains, but the Health‑Check field was
type="url"(so the browser rejected anything without a scheme) and the/wowimport fields likewise. All four inputs (Health‑Check,WowPremiumImport,WowHeroWidget) are nowtype="text" inputMode="url"with bare‑domain‑friendly placeholders; the server normalises (https://prefix) + SSRF‑validates as before.
Added
- Restora 360 Shop — redesigned storefront (handoff), on the real webshop. The customer storefront at `/shop` is rebuilt from
docs/Restora360 webshop design/with a self‑contained, shop‑scoped light/dark theme (.r360shoptokens inshop-theme.css,data-themetoggle persisted tolocalStorage, Schibsted Grotesk + JetBrains Mono vianext/font) — app shell (sidebar + topbar with language EN/NO/SV, display currency NOK/SEK/EUR/USD, theme toggle, live cart) + storefront (hero, search, category chips with counts, 2‑col product cards with bulk‑tier pricing, sticky order panel with qty steppers + bulk savings). Built on the real catalog (WebshopProduct+resolveUnitPrice/cartSubtotal) and the real checkout/subscribe APIs — not a parallel engine; currency switching is display‑only (charge currency is unchanged at checkout). Added optionalWebshopProduct.unit/cost. Phase 1 (theme + shell + storefront); product detail, checkout, orders/subscriptions, and the admin screens follow.
- Table & Floor designer: rotate any table to any angle. Tables (especially long/banquet tables, which only had one orientation) can now be rotated freely. New optional
DesignerTable.rotation(0–359°); the canvas appliestransform: rotate()around the table centre (shape + chairs rotate together), and the selected‑table inspector gains a Rotation control — ⟲/⟳ 15° steppers, a free 0–359° slider, and a Reset. Persists through the existing autosave (added to thetable-layoutPUT validation); round tables are unaffected. (The public/experience/table-designerpage is just a static preview — unchanged.)
- Platform site — Experience/Shop in nav + footer; "Experience" → the Before/After site‑rebuild. Added top‑level Shop (
/shop) and Before & After (/wow/health-check— paste your live site → "Before → after" side‑by‑side) links to the landing navbar (desktop + mobile) and footer Product column. (The label first read "Experience" and pointed at the table designer; corrected to the WoW health‑check before/after experience and renamed.)
- Sound & Atmosphere — owner config now PERSISTS per tenant (backend, provider‑agnostic). First real backend for the music product: an owner config (
TenantSettings.atmosphere) carrying the non‑licensing settings — zones (enable/volume/channel), ambiance preset, Auto, safeguards, signature genres. Newlib/atmosphere/config.ts(typedAtmosphereConfig+DEFAULT_ATMOSPHERE_CONFIG+resolveAtmosphereConfig/sanitizeAtmospherePatch, fully validated/clamped, 6 unit tests),lib/atmosphere/store.ts(get/save via the tenant DB), and a guarded `GET/PATCH /api/tenant/[tenantId]/atmosphere/config` (requireTenantAccess, returns 401 unauth). Three tabs are wired end‑to‑end (load real config server‑side, save via PATCH, optimistic + reconciled, Saving/Saved indicator, ARIA controls): Zones (enable + volume per zone), Branding (signature genres add/remove + brand‑consistency/safeguard toggles), and the Overview Auto toggle + safeguards. Deliberately carries NO music‑source/licensing data (providerstaysnone) — that integration stays gated on the licensed‑source term sheet + legal copy (see docs/music). Defaults mirror the seed, so existing tenants get sensible values and the remaining tabs (Schedule, Library, Analytics, Locations) keep rendering seed until wired the same way. tsc clean; tests green.
- Sound & Atmosphere — owner web dashboard is now responsive (usable in a phone browser). For owners who run their venue from the web with no tablet/phone app: on small screens the
/atmospheresidebar collapses into a sticky, horizontally‑scrollable top nav, the page grids stack to a single column, the daypart timeline scrolls, and the dense Schedule/Locations rows reflow into stacked cards. Branding/Analytics/Billing's fixed grids were moved to responsive grid helpers (.r360-g-*) so they collapse cleanly at ≤900/≤720px. Verified at 390px. Also: Sound & Atmosphere is now linked from both the restaurant‑owner dashboard (Operations) and the Super Admin sidebar (Restaurants), each opening in a new tab (previously URL‑only).
- Sound & Atmosphere — Super Admin console (web), built 1:1. The vendor operations console at `/admin/atmosphere` (super‑admin gated like the Growth Command Center; its OWN dark sidebar + a separate scoped theme
.r360saso it never clashes with the owner.r360). Recreated pixel‑faithfully fromSuper Admin Console.dc.html(darker palette +--redalert tokens, dark by default with a light toggle): Fleet Overview (5 KPI tiles, "Venues at a glance" table with Live/Cached/Degraded + OFFLINE/SILENT‑RISK flags, Alert inbox with push‑cache action), Tenants (lifecycle pills + table with status + audited Impersonate + the consent/audit notice), Edge Devices (inventory with firmware‑drift colouring + staged Canary→Early→Fleet rollout), Content & Licensing (clearance KPIs, coverage‑by‑society bars, royalty reports FILED/DRAFT, "no clear license → no play"), and Revenue (dark MRR hero + sparkline, NRR, dunning, plan breakdown). Built on co‑located seed; Schibsted Grotesk + JetBrains Mono vianext/font. Source typechecks clean (0 errors); all routes serve (super‑admin gated); Fleet verified 1:1 in dark. This completes every web surface in the handoff (owner dashboard + onboarding + billing + auth/team + Super Admin). Remaining product‑wide: native iOS (reuse the Android domain) + wiring the real backend.
- Sound & Atmosphere — native Android Manager app (Kotlin/Compose), built 1:1 + APK verified. First native surface of the music product: a new `:atmosphere` module in the existing
android/Gradle workspace (consistent with:app/:pos/:waiter/:bazar, depends on:core; packagecom.restora360.atmosphere, compileSdk 36 / minSdk 26 / JVM 17). RecreatesManager Mobile.dc.htmlpixel‑faithfully in Jetpack Compose + Material 3: a custom app bar (venue + screen title + light/dark toggle), a custom bottom tab bar, and four screens — Now (Atmosphere‑AI mood card + Auto toggle, now‑playing with art/tags/progress/transport/volume, 2×2 quick scenes), Zones (per‑zone cards with on/off + volume, muted‑kitchen state), Schedule (live‑now banner + daypart list with the live row highlighted), Alerts (offline‑cache banner + connection/cache status + safeguards). The design tokens (doc 07) are ported to a Compose token system (AtmoColors+AtmosphereTheme, light + dark, defaults to the system setting). It also recreates the FOH Tablet Controller (Tablet Controller.dc.html, landscape, dark by default): top bar (brand + clock + all‑zones‑live/staff‑mode pills + theme toggle), a large Now‑Playing pane (160dp art, 36sp title, transport + volume), a Current‑Mood pane (Auto toggle, occupancy/weather/energy, mood shifts with the active scene), a 2×2 zone grid (tap to select), and a 4‑up scene bar (Lunch/Dinner/Aperitivo/Quiet Close). One app, two idioms —MainActivitybranches by form factor (≥840dp width → Tablet Controller, else Manager phone), sharing the same domain. Built on a framework‑free domain + seed (domain/Domain.kt, pure Kotlin with no Android imports so it can back a future native iOS app) — the real Core API/realtime gateway slots in behind it later. Verified: `:atmosphere:assembleDebug` builds clean (0 warnings/errors) into an installable debug APK via the Android Studio JBR. On‑device visual QA is pending (the test tablet is PIN‑locked). Next: the iOS app reusing the domain, and the vendor Super Admin web console.
- Sound & Atmosphere — owner dashboard (web), Overview screen built 1:1 from the design handoff. First surface of the new in‑venue music product (
docs/Restora360 Music Interface/). A self‑contained section at `/atmosphere` with its OWN sidebar + scoped theme (mirrors the Growth Command Center pattern; gated behind a tenantplatform_session), recreated pixel‑faithfully fromAtmosphere Dashboard.dc.html: warm‑neutral surfaces, single green accent, Schibsted Grotesk + JetBrains Mono (vianext/font), light + dark (toggle persisted tolocalStorage). All seven owner tabs are complete — Atmosphere (now‑playing card with art/track/chips/progress/transport/"Chosen by Atmosphere AI" reasoning, the Atmosphere Engine Auto/Manual + Current Mood + signals, 4‑zone grid, daypart timeline with a live "now" marker, Signature/Safeguards/This‑week trio), Schedule (day pills + LIVE‑NOW timeline + editable daypart rows with genre/energy/volume, live row highlighted), Zones (large per‑zone cards incl. manual/scheduled/disabled states + add‑zone), Music Library (search + filters + licensed channel cards with cover art, PLAYING/+Add), Branding (dark house‑identity banner, signature vs never‑play genres, energy range, brand‑consistency toggles, "fully licensed" badge), Analytics (KPI tiles, atmosphere‑vs‑spend bar chart, listening‑by‑daypart, top channels), and Locations (HQ campaign banner + 6‑venue fleet table with this/offline states). Built on seed data (lib/atmosphere/{types,seed,service}+ co‑located per‑screen seed, demo tenant "Bottega Marittima · Stockholm · 1 of 6") behind a single data‑service boundary so the real Core API / Atmosphere Engine / licensed source slots in later with no UI change. Source typechecks clean (0 errors); verified 1:1 vs the prototypes in both themes; all routes serve (auth‑gated). Super Admin console (/admin/atmosphere), onboarding wizard, billing, auth/team and the native phone/tablet apps are the next surfaces.
- Sound & Atmosphere — Onboarding, Billing, and Auth/Team surfaces (web), built 1:1. The remaining owner‑facing web surfaces of the music product, all recreated pixel‑faithfully from the handoff prototypes on seed data. (1) Onboarding — a full‑screen 6‑step concept‑setup wizard at
/atmosphere/setup(Concept → Hours → Signature Sound → Zones → Pair players → Review) with a clickable step rail (done/current/upcoming states), interactive cuisine/vibe/zone selection, progress‑dot footer, and "Go live ✦" finish. (2) Billing at/atmosphere/billing— plan card (Multi‑Location €354/mo, "music licensing included"), this‑cycle usage bars, payment method, next‑invoice breakdown, invoice history. (3) Auth & Team — Team & Roles at/atmosphere/team(member list with Owner/Manager/Staff/Pending badges + role‑permission matrix), full‑screen SSO‑first Sign‑in at/atmosphere/signin, and a full‑screen always‑dark staff‑mode PIN unlock at/atmosphere/unlock. The shell now routes full‑screen surfaces (setup/signin/unlock) chrome‑less and adds an Account nav group (Billing & Plan, Team & Roles) under the dashboard sidebar. Source typechecks clean (0 errors); verified 1:1 vs the prototypes. Every owner/staff‑facing web surface in the handoff is now built — only the vendor Super Admin console and the native phone/tablet apps remain.
- "Building your site" loading screen redesigned (1:1 to the handoff) + Restora 360 logo on Android app launch. Two brand‑polish items. (1) The
/wow/[token]build‑reveal interstitial (BuildReveal) is restyled to the new loading‑screen design — dark warm background, animated hero Restora 360 logo (pulsing halo + rotating arc ring), the top‑left brand lockup, the step list (orange‑gradient check + strikethrough when done, spinning ring when active, hollow ring pending), and a shimmering progress bar with a live Building/Ready label + %. All logic is unchanged (real step progress, skip, once‑per‑token, reduced‑motion); only markup/style changed; the newrestora-360-logo.pngasset replaces the old✦placeholder. (2) The Android:tvdisplay app now opens with a professional ~2‑second branded launch splash: a cold‑start window background (no white flash) → an overlay with the Restora 360 logo + wordmark + a 0→100% progress bar → fades into the display (which loads behind it). The cold‑start window + Android‑12 OS launch frame are a plain brand‑dark screen (no bare logo, no launcher‑icon flash), so the loader is the only branded screen the user sees.
- TVs & Displays: visual theme picker + sound toggle per screen. The per-device theme dropdown is now a popup of live theme thumbnails (
DeviceThemePicker) — Tenant theme / Classic / the 5 premium designs as real mini‑previews — so the owner sees what they're choosing. The per‑device sound control is a simple Sound on/off toggle (default off) instead of a dropdown.
- Per-device ad sound + display fit fixes (TV/tablet polish). Three fixes from on-device testing: (1) the customer display now letterboxes in black instead of light gray on non-16:9 screens (e.g. a 1280×800 tablet) so it reads as fullscreen, not "web with white margins" (
DisplayThemeStage/FitFramebackground, black on the live/tv/screen); (2) the Android:tvapp uses `sensorLandscape` so it auto-orients the right way up instead of locking to one fixed landscape (fixed "upside-down on launch"); (3) a per-device Sound: default / on / off control in TVs & Displays — a newTvDevice.soundOverrideapplied inbuildTvFeed, so one screen can be muted and another loud regardless of the tenant-wide reel setting.
- Ad reel: optional video sound (owner toggle). Video ads are muted by default — browsers/TV WebViews only allow a video to autoplay when muted (unmuted would be blocked from starting). A new Sound toggle on the ad reel opts in: on the in-store TV app it plays with audio (the Android WebView permits unmuted autoplay; the Tizen native engine likewise); in a plain browser it starts muted and unmutes on the first interaction (e.g. the Fullscreen button), since browsers block unmuted autoplay outright. New
ads.soundEnabledconfig (default false), threaded through the web<AdSlot>(all 5 themes) and the nativeads.jswith a muted-fallback so the video always plays regardless.
- Customer display: true fullscreen "app feel" (browser button + Android immersive). Two fixes so the display reads as a kiosk app, not a web page. (1) Browser — a floating Fullscreen button on
/tv/screen(browsers only allow fullscreen from a click) gives a chrome‑free, address‑bar‑free display on any laptop/TV browser; it auto‑fades when idle in fullscreen, reappears on activity, toggles to an exit control, and hides itself inside the native shells (?shell=…, which go fullscreen natively). (2) Android `:tv` — edge‑to‑edge immersive viaWindowInsetsController(status + nav bars hidden, swipe‑to‑reveal) plus killing the remaining "web" tells (no scrollbars, no overscroll glow, no pinch‑zoom, no long‑press selection, no haptics). No backend change.
- Ad reel: YouTube/Vimeo → cached MP4 ingest (the owner's own promo videos, played gap-free). Pasting a YouTube/Vimeo link in the Ad reel now transcodes it once, server-side, into a local MP4 the TV caches and loops — live-embedding can't meet the never-buffer bar and is ToS-bound. New
POST /api/tenant/[tenantId]/ads/ingest: the URL is validated + canonicalised (onlyyoutube.com/youtu.be/vimeo.comvideo URLs ever reach the tool), a pending ad item appears immediately, andyt-dlp(+ ffmpeg) runs off the request path with hard security/abuse limits — argv-only spawn (no shell, URL after--), ≤10 min, ≤250 MB, 3-min timeout, max 2 concurrent imports/tenant, fixed output path. The dashboard shows the pending item with an "Importing…" state and polls until it flips to ready (plays) or failed (remove + retry). Feature-gated: withyt-dlp/ffmpegabsent the endpoint returns a clear "not enabled — upload the MP4 instead" message and nothing else changes (RESTORA_YTDLP_PATH/RESTORA_FFMPEG_PATHoverrides; setup + ToS indocs/tv-deployment/VIDEO-INGEST.md). Honest: best for the restaurant's own videos; yt-dlp breaks when YouTube changes, so direct MP4 upload stays the reliable primary path. URL-validation/arg-injection unit tests added; tsc clean. (Transcode path needs on-server validation once yt-dlp/ffmpeg are installed — not present on the dev box.)
- Cross-brand TV deployment — the customer display now runs on ANY TV, not just Samsung. Researched + adversarially fact-checked (the "type a URL → app icon on any TV" idea is a myth — no consumer TV browser supports it; Roku/Vizio have no web layer at all), then built the realistic coverage. One web app (
/tv/screen) stays the single payload; new thin delivery shells point at it: (1) Android `:tv` module (android/tv/) — a lean, framework-free WebView leanback kiosk that covers Android TV / Google TV AND Amazon Fire TV with one APK (keep-awake, immersive fullscreen, autoplay video ads, MENU-key server override, network-recovery + watchdog reload, render-process-death recovery, boot auto-launch, hardened WebView — no file access,MIXED_CONTENT_NEVER_ALLOW); (2) LG webOS `.ipk` (webos/) — a faithful clone of the Tizen.wgtshell (server override, settings overlay, burn-in shift, watchdog, webOS relaunch/back lifecycle) using only standard web APIs; (3) docs (docs/tv-deployment/) — the brand-agnostic "Use Any TV" guide (pre-imaged HDMI Android box + Fully Kiosk → boots the display on Roku/Vizio/any HDMI screen), the commercial-signage URL-launcher guide (Samsung SSSP / LG Play-via-URL), and a verified decision matrix. The recommended non-technical path is the HDMI-box (works on every brand); the store apps add a native icon + auto-launch where wanted. Built source-ready (Android:tv:assembleDebugbuilds; webOS needs real icon artwork before submission); adversarially reviewed (5 agents) and the confirmed correctness/security/24-7-resilience findings fixed. Native tvOS deferred; Roku/Vizio native + Chromecast intentionally not pursued (dead ends covered by the HDMI-box path).
- Customer-display AD REEL — rotating image + video ads that never stop, flicker, or buffer (Samsung TV + web). The premium customer-display themes had a single static ad image; they now play a rotating playlist of images and video (MP4/WebM, and best-effort HLS) in the theme's sponsor region. New playlist data model on
liveOrdersDisplay.ads(AdItem[]+ transition/duration), fully backward-compatible — a legacyadImageUrlis read as a one-image reel (resolveAds, 10 tests). Owners manage the reel from Orders → Customer Display → Ad reel (AdPlaylistManager): upload an image/video (or paste a direct MP4/HLS/image URL), set per-image dwell, reorder, enable/disable, remove — saved instantly via the existing display-config PATCH and picked up on the TV's next poll. A newPOST /api/tenant/[tenantId]/ads/uploadaccepts images (≤10 MB) and video (MP4/WebM, ≤200 MB, streamed to disk underpublic/uploads/{tenantId}/ads/). The never-buffer guarantee comes from the Tizen.wgtNative engine: a new persistentwindow.ADSplayer (tizen/native/ads.js) lives in its own fixed layer — so the board's 4 s feed re-renders never interrupt a playing ad — double-buffers (decodes the next item before cross-fading → no black frame), caches image/MP4 assets to app-private storage (Tizen Download/Filesystem) and plays them from disk so a slow/dropped connection can't stall the reel, skips broken items instantly, and loops forever; each theme's ad region became a transparent slot the player draws over. The hosted web engine gets the same rotating player as a React<AdSlot>(best-effort — no disk cache; that's the Native engine's job). YouTube/Vimeo links are intentionally not live-embedded (they buffer + are ToS-bound) — server-side ingest-to-MP4 is the planned follow-up. Verified:tsc0 errors,next buildgreen, the ad-config unit tests pass, and the reel rotates/crosses-fades/skips-on-error in the browser; on-panel AVPlay/cache validation pending a TV. (Native caching is fully fallback-guarded — if it's unavailable it plays the remote URL, never blanks.)
- Samsung Smart-TV display app (Tizen) — pair-by-code-in-admin, fleet management, two display engines. Any TV becomes a Customer / Kitchen / Live-Orders display with no login, no password, no server typing on the screen — the TV shows a 6-char code, the owner opens TVs & Displays → Add a TV in the dashboard (already authenticated), types the code, names the screen and picks its mode, and it goes live instantly. Each screen gets its own revocable per-device token (independent of the shared display token), tracked in a new fleet page with an online dot, model/firmware/resolution, last-seen, inline rename, mode/theme switch, and Refresh / Reboot / Revoke (one-shot remote commands the TV applies on its next poll). Two interchangeable display engines, switchable live on-screen (RED/INFO key → Display engine): Premium (online) loads the hosted
/tv/screenweb app so the 5 premium customer themes + any new theme work with zero app rebuild; Native (instant) runs a framework-free renderer bundled in the.wgtfrom local files, fetching only the feed JSON — boots instantly, survives brief server hiccups (keeps the last frame), light on older panels, ideal for 24/7 kitchen screens. It renders the KDS/Live-Orders board and all 5 premium customer themes (Aurora/Hearth/Marquee/Gallery/Pulse, ads on/off), ported pixel-1:1 from the web theme components into a bundled framework-free renderer (tizen/native/themes.js) and fit-scaled from the 1920×1080 canvas. Both pair the same way and speak the same backend contracts. The Tizen shell adds the TV-only concerns: keep-awake, burn-in pixel-shift, watchdog refresh/network-recovery reload, D-pad-navigable settings, and self-host server override. New endpoints:POST /api/public/tv/code/start,GET /api/public/tv/code/poll,POST /api/tenant/[tenantId]/tv-devices/redeem,GET /api/public/tv/device/feed, andGET/PATCH/POST/DELETE /api/tenant/[tenantId]/tv-devices[/id](the public device endpoints are CORS-enabled — token-in-query, no cookies — so the native engine + web-signage kiosks can call them cross-origin). Additive + tenant-isolated; per-device throttling by token-hash, constant-time token compare, atomic single-use code redemption via the master write-mutex. Verified end-to-end: native engine pairs cross-origin → owner redeems → live Live-Orders board + Customer queue render from real seed data with no console errors. tsc + eslint clean; device-registry unit tests green.
- Automatic content translation — a standard platform feature (menu · categories · settings), fully owner-customizable. When an owner saves content in their default language, the other enabled languages (driven by
settings.languages, so EN/SV for Sweden and the right set for US/UK/CA) fill in automatically in the background, reusing the existing batched-Haiku translator. The engine (lib/i18n/auto-translate.ts, 12 tests) is provenance-safe: each machine value is recorded with the source hash it came from, so re-translation refreshes stale auto values but NEVER overwrites an owner edit (a value that no longer matches its recorded auto value is locked as "manual"); and it's metered under a new `translation` AI feature (shows on the budget pill) + a monthly cap so it can't run away (no key / over-budget / over-cap → it's a clean no-op). Wired on save across all content surfaces: menu items (name/description/ingredients), categories, settings (about + SEO), and the Mason website builder (every authored block text field on home + all pages — a plain-string field is converted in place to an{en,sv}record that the storefront localizes, then the target language is filled). A new Translations manager (/dashboard/translations, under Website & Content) lists every translatable field per language grouped by surface (Menu items / Categories / Restaurant / Website), with a status badge (Not translated / Auto / Edited), inline editing to fix any translation (which locks it), and a re-translate button. Live-verified end-to-end: editing a menu name to Swedish "Färska tomater och basilika" auto-filled English "Fresh tomatoes and basil" in the background; overriding it (menu or a website hero) flips the status to "Edited" and locks it; re-translate restores the machine value; the manager listed 13 website-block fields for the sample tenant. tsc + eslint clean; 36 tests green.
Changed
- Multi-currency AI billing + deterministic allergen detection — productized for the SE → US/UK/CA rollout. Two real-world hardening fixes. (1) Multi-currency AI billing. The AI budget (Anthropic, USD) is now charged to each owner in their own currency instead of hardcoded SEK: a USD→local FX table × the 1.5× cut (
ownerAmountFromUsd(usd, currency)), reusing the existingcurrency.tsregistry/formatMoneyeverywhere. The top-up packs are currency-agnostic (allowanceUsdonly) and priced + charged (Stripe) in the tenant's currency (SEK 33/83/165 · USD 3/8/15 · GBP 2/6/12 · CAD 4/10/21 · EUR 3/7/14); the budget pill, the menu-import top-up sheet, and the Cost & Profit tab all format in the tenant currency (e.g. SEK now renders "9,56 kr" with the correct Swedish locale).InvoiceRecordgained an optionalcurrency. Internal AI accounting stays in USD; only the owner-facing price/display converts. (2) Allergen suggestions that actually fire. A deterministic EN + Swedish ingredient→EU-14 keyword detector (lib/menu/allergen-keywords.ts, 5 tests) now supplements the human-gated catalog, so "Detect ingredients" suggests allergens for the common cases regardless of how full the canonical catalog is — whole-word matching (so "peanut" never trips "nut", "coconut" never trips tree-nuts) and still owner-confirmed before going live (safety gate preserved). Verified live: "wheat flour, mozzarella, garlic, fresh salmon, sesame seeds" → suggests gluten / milk / fish / sesame (garlic correctly none). luigi set to SEK. tsc + eslint clean; 26 tests green.
Added
- AI ingredient detection (SV/EN) + per-dish true-cost model in the menu editor. Two linked menu-editor features, deterministic-first and reuse-built. (1) Detect ingredients from text — a "✨ Detect ingredients" button parses the dish's Ingredients/Description (preferring the Ingredients field, falling back to Description) in Swedish or English, any delimiter (
·/ comma / no-space / spaces /-), matching each against the tenant's own inventory and the globalIngredientMastercatalog (official + sv/en translations + synonyms). The hard "San Marzano garlic olive oil" (no delimiters) case is solved by greedy catalog-phrase extraction (keeps "San Marzano" as one ingredient). Common lists resolve with zero AI tokens; only prose/unknown terms hit a capped, budget-gated Haiku fallback (metered under a newingredient-detectfeature, visible on the AI pill). A review panel lets the owner accept/reject each line and confirm suggested allergens before they merge (safety gate — allergens stay human-confirmed). Newlib/menu/ingredient-detect.ts(8 tests) +POST /menu/detect-ingredients, reusingtokenize/intersectionSize. (2) Cost & Profit tab — a 4th editor tab shows true cost vs price → margin: ingredient COGS (real, fromrecipeCost+ purchase prices) + guided overhead the owner tunes — labor (prep+cook minutes × the loaded staff rate: wage +12% vacation +31.42% employer tax, fromstaffCost), energy/gas (cook-method presets × the tenant's kWh price), and AI + marketing spread either flat per-dish or by volume (this month's AI spend from the pill + marketing ÷ units sold — owner toggles). Shows profit/dish, margin, the ingredients-only margin for contrast, a "price X to hit Y% margin" tip (reusespricing), and honest "est." flags on guided lines. Newlib/inventory/cost-model.ts(10 tests) +POST /menu/cost-estimate+GET/PATCH /menu/cost-settings;Product.costModel+settings.costSettings(additive, optional) persist via the product routes. All live-verified (detection 5/6 on a real menu; cost math exact: 15 min labor → 55.20, oven 20 min → 2.08, margin 61.2%). tsc + eslint clean.
- AI-budget popup redesign (1:1 with the design handoff). The top-bar pill's popover is rebuilt to the hi-fi spec: a remaining-first hero (big tabular-mono green number + "of X kr"), a usage progress bar, "used / N AI requests" stats, a "Resets …" chip, then a select-a-pack → confirm top-up flow (radio packs, a "Popular" badge on Medium, the explicit "every 1 kr you add is 1 kr of AI" line, and a CTA that reads "Add N kr to your budget"; toggling a pack deselects). Exact tokens (colours, 20px radius, layered shadow, sizes, spacing, the
popanimation) reproduced; keeps the portal/fixed/z-[9999]/focus machinery and the buy logic (Stripe in prod, dev-sim otherwise). Screenshot-verified. Per the handoff's own guidance it inherits the dashboard's brand sans + a system monospace (no extra web-font request).
- AI budget pill in the top bar — always-visible spend + buy-more, any time (1.5× service-cut pricing). Restaurant owners now see their AI usage on every dashboard page: a compact pill (
AiBudgetPill) next to Quick Links shows % of this month's AI budget used with a color-coded bar (green → amber → red, "0 left" when exceeded), so AI can't quietly run away in a single day. Clicking it opens a popover with the owner-facing balance in SEK — "X kr used of Y kr · Z kr left · resets <date>" — and Buy more · any time top-up packs (Small / Medium / Large) that reuse the existingPOST …/billing/ai-topupflow (Stripe Checkout in prod, dev-simulated grant when no Stripe key). All owner-facing amounts apply a consistent 1.5× markup over the raw token cost (the platform's service cut), via one rate (AI_MARKUP = 1.5,ownerSekFromUsd): the displayed spend/limit/remaining and the pack prices are all 1.5× the underlying USD, and packs are repriced to exactly 1.5× (33 / 83 / 165 kr) so a pack's price equals the budget it adds in owner terms.GET …/billing/ai-topupnow returns an owner-facingdisplayblock (spent/limit/remaining SEK + pctUsed). Renders cleanly at the top bar; screenshot-verified; the math checks out (e.g. $0.42 spent of $1 → "7 kr of 17 kr, 42%"). tsc + eslint clean. Follow-up (legibility + z-index): AI (Haiku, capped) is so cheap that one question costs a few öre — which rounded away to a flat "0 kr / 0%" and looked broken even though metering worked. The popover now shows budget figures with 2 decimals (so spent + remaining add up to the limit), a "N AI requests this month" count (concrete proof it's tracking), and "<1%" with a small bar sliver when usage is real-but-tiny (BudgetStatus/displaygainedrequests+pctExact). The pill also refetches on window-focus + every 60 s + on open so it doesn't go stale after AI is used elsewhere (the dashboard layout never unmounts). And the popover now renders via a `document.body` portal (position:fixed, z-[9999]) — like the notifications panel — so it floats above all page content instead of hiding behind stacking contexts (e.g. the menu page's drag-and-drop transforms); closes on outside-click/scroll/resize/Esc. Verified on the menu page: popover fully above the dish cards; tenant with $0.0047 real spend now reads "0.08 kr used of 16.50 kr · 3 AI requests · <1%". A 4-dimension adversarial review (money / CSS / React / UX) hardened two confirmed issues: the SEK figures now reconcile exactly (remaining derived in SEK space + spent clamped to limit, so "used" + "left" always equals "of <limit>" — no ±0.01 kr drift), and the popover honors `role="dialog"` focus (focus moves into the panel on open and returns to the trigger on close, never stealing focus on mount). All checks green.
- AI Inventory Intelligence — daily email report + capped AI narration + owner-set schedule + "Send now". The daily summary now narrates itself and lands in the owner's inbox. A short (2–3 sentence) AI narration tops the report — the only AI in the whole feature, on the cheapest model, ≤220 output tokens, budget-gated (per-tenant AI budget), with a deterministic rule-based fallback when there's no key / over budget, so it can never fail or surprise the bill. The narration is generated once a day and cached on the tenant, so opening the dashboard never spends tokens. Delivery is owner-scheduled: a new
inventory-reportscron runs hourly and emails each tenant when their chosen local hour (sendHour— morning / evening / midnight, in their timezone) matches, at most once a day. A "Send now" button (POST …/inventory/report) generates a fresh report + emails it immediately, any time. The inventory dashboard panel gained the narration box, an "Email me this daily at HH:00" schedule toggle, and the Send-now button;GET/PATCH …/inventory/settingscarries the schedule. AI spend is attributed under a newinventoryfeature in the AI usage analytics. Endpoints verified wired + gated (POST report401 without a session; cron 401 without the secret). tsc + eslint clean.
- AI Inventory Intelligence — one-click 3-choice reorder + the dashboard summary panel. The daily summary is now live on the inventory page (Overview tab): a "Today's Inventory Summary" card showing the health score, stock value, food-cost %, waste %, and ranked alerts, followed by the reorder list where each item offers the three picks the owner asked for — Cheapest · Fastest · Same as last time — each showing supplier, line cost, and lead time. One click (with a confirm) places the order via the shared B2B order engine through new `POST …/inventory/reorder`, which recomputes cost server-side, enforces the owner's daily spend limit, and records the supplier as the new "last time". The owner sets/edits the daily reorder spend limit inline (new
GET/PATCH …/inventory/settings). The 3-choice ordering is gated on thesupplier-recommendationsadd-on; the base summary shows oninventory-management. Endpoints verified wired + auth-gated. Still deterministic — no AI tokens spent; the capped LLM narration + daily email are the next slice.
- AI Inventory Intelligence — Phase 1 engine + daily-summary API (deterministic, no AI yet). The "morning report" brain:
daily-summary.tsfuses the forecast + cost + health rollups into one snapshot — inventory health score, value, food-cost %, waste %, expiry risk, recipe coverage, and a ranked reorder list (per-ingredient lead/safety, order-up-to-par) with ranked alerts.reorder-choices.tsturns each reorder line into the three supplier picks the owner asked for — Cheapest · Fastest · Same as last time — reusing the existing supplier matching/tiered-pricing/delivery-reputation libs.spend-limit.tstracks today's restock spend vs the owner's daily limit. Exposed read-only atGET /api/tenant/[tenantId]/inventory/daily-summary(base summary gated oninventory-management; the 3 supplier choices + spend-limit enrich only with thesupplier-recommendationsadd-on). Deterministic + unit-tested (10 new tests); the one-click place-order, dashboard UI, daily email, and capped LLM narration are the next slice.
- Fixed: the Inventory Copilot ask bar now actually answers inventory questions. It was wired to the general content/marketing assistant, which refused stock/supplier questions ("I'm designed to help with content and marketing"). New inventory-scoped copilot (
POST …/inventory/ask) grounded in the real inventory context — stock, the reorder list with cheapest/fastest suppliers, cheaper-supplier finds, waste breakdown (by category + reason), recipe coverage, and the demand-driver forecast. Deterministic-first: the common questions (the hero chips — "Find cheaper suppliers", "Why is my waste so high?", "Forecast next week", "What should I order today?") are answered from data with zero AI tokens; anything else goes to a short, capped, budget-gated Claude call grounded ONLY in the context (rule-based fallback when no key/over budget). It never deflects to marketing. AI spend metered underinventory. 3 tests lock the behavior.
- Inventory → Overview makeover — "AI Command Center" (Concept A), 1:1 with the handoff. The Overview tab is rebuilt into a warm, owner-first command center that surfaces the whole AI-inventory engine: an Inventory Copilot hero (natural-language daily briefing + a real ask bar wired to the assistant + 4 suggested-prompt chips), 3 AI insight cards (Order now → one-click reorder of the most urgent item; Forecast → next stockout + the demand-driver uplift + a sparkline built from the real upcoming-driver multipliers; Cut waste → top waste category + a map-recipes CTA), a KPI row (health /100 · stock value · waste % · items), the to-reorder board (per item: ★ AI Pick / ⚡ Fastest / Same-as-last supplier tiles + "Order all", spend-limit aware), and the all-ingredients table (days-left bar + Reorder/Watch/Healthy pill + quick +Stock), sorted by urgency. Reproduced pixel-faithfully (exact warm palette, SEK throughout) and screenshot-verified against the prototype. All wired to real data + real handlers — the daily-summary engine gained an all-ingredients list + waste-by-category; one-click order uses the existing reorder endpoint (server-recomputed cost + daily spend limit); +Stock posts a stock movement; the ask bar streams from the existing budget-gated assistant. The Phase 1/2 daily-report, demand-drivers, special-days, and automation controls remain in a collapsed "Daily report & automation" drawer below — nothing lost. Other tabs untouched. tsc + eslint clean; 21 inventory tests green.
- AI Inventory — weather now works from the restaurant's address (free geocoding). Most venues only set an address, not coordinates. The weather driver now resolves a location from the address via Open-Meteo's free geocoding API (no key) — city-level precision, which is all the ~11 km weather cell needs — and caches the result per restaurant (re-geocoded only if the address changes). Resolution order: explicit coordinates → branch coordinates → cached geocode → geocode-the-address. The cron + report path geocode + cache; the dashboard reads the cache. Verified: "Storgatan 14, 114 51 Stockholm" → Stockholm → 59.33,18.07.
- AI Inventory Intelligence — Phase 2: demand-driver forecasting (weather + holidays + occasions + own-history), 100% free. The forecast now thinks like an owner: it scales each future day's expected consumption by demand drivers, so an upcoming busy weekend, heatwave, public holiday, religious observance, or local festival pulls reorders earlier and bigger — and the daily summary explains why ("This week: Fri +18% — Friday + Warm 26°C; Sun +35% — Midsummer"). All signals are free, no paid APIs, no extra AI tokens: (1) weather via Open-Meteo (free, no key); (2) holidays + religious observances for ~200 countries via the offline date-holidays library (Ramadan/Eid/Christmas/Easter/Diwali/Midsummer…); (3) day-of-week + seasonality learned from the venue's own order history; (4) an owner "special days" calendar for what AI can't infer. The capped daily narration just mentions the drivers — no extra spend. Centralized weather architecture (per the design): one Open-Meteo call per location cell per day, cached in a shared
WeatherCacheDBand reused by every nearby restaurant (a whole city = one call, not one per venue); a dailyweather-refreshcron warms the cells; the dashboard reads the cache (never calls out). Super-admin control (/admin/dashboard/settings): weather on/off + an Open-Meteo API key field — free tier for development, drop in a paid key at go-live for the commercial endpoint (key never returned to the browser). Owner UI: a "This week's demand drivers" breakdown + the special-days editor in the inventory panel. Deterministic engine, 5 new driver tests (24→29 inventory tests). All additive + backward-compatible (no drivers → the flat forecast, unchanged). tsc + eslint clean; free Open-Meteo fetch + all endpoints verified.
- AI Inventory Intelligence — option "inventory effect" editor (set the modifier deltas). The menu editor's Options tab now lets owners attach an inventory effect to any option: per option, add rows like "uses +50 g of Cheese" or "uses −20 g of Onion" (positive = extra, negative = less), picking from the tenant's inventory ingredients with a unit selector. This is the owner-facing half of the modifier-consumption feature — what gets set here flows straight into the live order's net stock deduction + the forecast. The picker only appears when the tenant has inventory ingredients; deltas persist on
ProductOption.ingredientDeltasthrough the existing product save. tsc + eslint clean.
- AI Inventory Intelligence — modifier/option ingredient consumption on the live order path. Selling an item now deducts net ingredient stock: the base recipe ± the ingredient-deltas of the chosen options ("extra cheese" deducts more, "no onion" deducts less, "add bacon" pulls an ingredient that isn't in the base recipe). This closes the biggest forecasting gap — consumption (and therefore the daily summary + reorder forecast) now reflects real usage in high-customization restaurants, not just the base recipe. The math is a pure, unit-tested helper (
netConsumptionPerUnit: base taken as-is for backward-compat, deltas unit-converted to the ingredient's unit, removals clamped so consumption never goes negative; deltas in non-convertible units are skipped, never breaking a sale). Fully backward-compatible: a product with no recipe and options with no deltas deduct exactly as before.ProductOption.ingredientDeltaswas added in Phase 0; this wires it into the order's stock-movement write (still inside the atomic per-tenant lock, still idempotent on replay). The owner-facing editor to set deltas per option is the remaining piece.
- AI Inventory Intelligence — Phase 0 foundation (deterministic engine + data model). Groundwork for the predictive inventory + one-click procurement upgrade, all additive/backward-compatible. Schema:
IngredientItemgains persisted reorder settings (reorderPoint/parLevel/maxStock/leadTimeDays/safetyDays/shelfLifeDays) + supplier memory (preferredSupplierId/lastSupplierId, the latter recorded on every goods-receipt → powers "same as last time");ProductOptiongains optionalingredientDeltas(extra/remove consumption);settings.inventoryIntelcarries the owner's daily reorder spend limit + report prefs. New deterministic libs (no AI, unit-tested):units.ts(kg↔g / l↔ml conversion),cost.ts(recipe cost, inventory value, food-cost % / COGS — honest about uncostable lines),health.ts(waste %, expiry risk, recipe coverage by product and sales volume). This is the engine the daily summary + 3-choice reorder build on; not yet user-visible.
- Live Orders Operations board — polished order cards (1:1 with the redesign). The staff kanban cards were rebuilt to the design handoff: a barcode-style number sticker (taps to open the printable receipt), the order total, customer + time-ago, a fulfilment chip + a source-channel pill with a matching colored left edge, clean item lines, and a stage-aware primary action (Accept · Start preparing · Mark ready · Mark collected / Hand to courier) plus a finished "✓ Delivered HH:MM" state. Column headers gained colored status dots. The source channel is derived from real order data only — Online / POS / Waiter (from
source/type) — not fabricated delivery platforms. The decline ✕ on incoming orders is now wired (it was a dead button): it confirms, then cancels via the existing restaurant-cancel endpoint (full refund if a payment was held) and drops the order from the board. Drag-to-advance still works; action buttons stop the press so a jitter can't swallow the click.
- TV Display Redesign — 5 owner-selectable premium Customer-Display themes, each with ads on/off. The Live Orders → Customer Display tab is now a theme picker: a responsive grid of live, scaled 16:9 preview cards for Aurora Split (warm split-screen + sponsor video rail), Hearth (rounded cards over a full-width promo banner), Marquee (dark stage, giant now-serving number), Gallery (editorial Newsreader serif + 4:5 poster ad), and Pulse (glass cards + live progress + ad rail) — plus the original Classic board. Click a card → it saves instantly and the in-store TV picks it up on its next poll; the selected card gets an accent ring + an "On air" badge. An Ads on/off toggle flips every theme's sponsor regions live; an optional uploaded campaign image fills the ad slots (otherwise the designed placeholder). Each theme was reproduced pixel-1:1 from the design handoff (authored at 1920×1080, fit-scaled to any screen via a shared stage) and screenshot-verified ads + no-ads. One source of truth: the same theme components render the owner's preview cards and the public TV — driven by one data shape (
mapOrdersToThemeData: preparing oldest-first, ready newest-first so the latest number is the dominant "now serving"; ETAs are honest minutes-in-state, never fabricated). Additive + backward-compatible: the model defaults totheme: 'classic', so existing tenants' TVs render byte-identically until the owner picks a premium theme. The public projection already carries the config, so no API surface changed (thedisplay-configPATCH gainedtheme/adsEnabled/adImageUrl, validated). The device strip honestly shows the TV as live and the iOS/Android apps as "coming soon" (the native theme port is the agreed fast-follow). Built web-first; tsc/lint/build clean, mapper + resolver unit-tested.
- Customer TV / pairing screens no longer show the cookie-consent banner. The GDPR consent bar was rendering on the unattended
/display/*board and the/tv*pairing screens — kiosk surfaces that set no analytics/acquisition cookies and have no one to dismiss it. The banner now suppresses itself on those device paths (a pre-existing papercut surfaced by the redesign).
- TV "scan-to-connect" — connect a Customer Display with no typing on the TV at all (YouTube/Netflix-style device pairing). Open
/tvon the screen and it shows a big QR + the full address and waits. The owner scans it on a phone that's signed into Restora (/tv/connect/<deviceId>), picks the restaurant, and taps Connect — the TV is polling and connects itself to the live-orders board. No code read off the screen, no URL typed on a remote. Security-by-design: the QR carries only an unguessable 144-bitdeviceId; the secretpollTokenthat unlocks the result is held only by the TV (never in the QR), so a bystander who photographs the QR can't read the display token. Display tokens are resolved live at poll time, never stored in the link. The link is single-use and expires in 10 minutes (the QR auto-refreshes). The 6-char code entry stays as a fallback for smart-TVs that prefer it. New endpoints:POST /api/public/tv/link/start(mint),GET /api/public/tv/link/[deviceId]?poll=(poll, pollToken-gated),POST /api/tenant/[tenantId]/live-orders/tv-link(owner authorize). Verified end-to-end: mint→pending→authorize→linked; wrong/missing/unknown token all return an indistinguishable 404; authorize requires tenant access (401 without a session).
- TV display pairing: scan-to-pair QR + auto-pair deep link. Pairing a Customer Display screen used to mean typing the long server URL on a TV and then a 6-char code. The dashboard "TV code" modal now shows a QR encoding
/tv?code=<code>, and the/tvpage reads?code=and pairs automatically — so a tablet display scans once and lands on the board with zero typing (the code is also still shown for smart-TVs that type it). (For a true keyboard-less TV, shortening the host itself is a Tailscale step — rename the machine / use the MagicDNS short name.)
- "Coming Soon" pre-launch site gate (super-admin toggle). A new
comingSoonModeswitch on the super-admin Settings page rewrites the whole public site to the/coming-soonlaunch page while it's on — keeping `/admin` + `/login` (and operator surfaces) open so you can sign in and turn it back off. The proxy already gated onmaintenanceMode; this adds a distinct, correctly-labelled flag (so "maintenance" and "pre-launch" stay separate), surfaced via/api/public/site-status, with a 15s edge cache and fail-open behaviour (a transient read error never locks the site out). Verified: flag on → public routes gated, admin open; flag off → live.
- 8 premium website templates, each with its OWN design — not the same homepage repainted. Carte, Forno, Maison, Masala, Mercato, Nori, Stack & Volt now ship as code-defined templates (palette + display and body font + demo content), reproduced 1:1 from the source handoff designs and screenshot-verified. The architectural fix behind it:
buildStarternow honors a per-template `home` section sequence (each template declares its own ordered sections + variants instead of the hardcoded five), and the storefront injects the template's exact body font (the oldfontPairlist couldn't render Work Sans / Bricolage / Anton / Shippori Mincho / etc.). Added 7 new shared section variants — Featuredpopular-list/masonry-utility-grid/signatures-grid/omakase-feature, Contactstats-row(+mono), Editorialeditorial-split, PromoBannermarquee— all token-driven, data-bound to the tenant's real menu/hours/address, and fully editable in Mason. Lights stay light, darks stay dark, fonts exact. Everything is additive and runs through the existing template/Mason pipeline (no parallel engine); the 6×10 seed stays untouched (these register as code-templates, like Saffron & Sage / Terra). Terra is registered here too. Also includes: a 2-column + roman-numeral menu-page layout (newMenuLayout.categoryNumbering:'roman'→ "I · II · III" category headers; Carte/Mercato), and an accent-background hero "CTA band" (newaccentBghero mode = accent bg +accentInktext + inverted button; Stack/Volt's "Hungry?" closing).
Fixed
- Block CTA/button links now point at the tenant's page on path-served sites (not the platform root). On a
/tenant/[id]-served site, onlyheroandsplit-herorebased their root-relative links onto the tenant basePath; every other registry block (cta-bannerbutton,delivery-cta,loyalty-cta,contact-infobook link, …) sent its CTA to the platform root.BlockRenderernow rebases the whole class of link fields for every registry-rendered block (the booleanshowMapLinkis excluded; external/anchor links pass through).
- Food Stories: the media-quota dead end is now recoverable. Hitting the 500-file per-account upload limit used to say "contact support" with no way out. The post-delete + uploads-GC backend already existed (a post DELETE unlinks its own images; a super-admin GC reclaims orphans from edited posts) but wasn't reachable from the UI. Now: the quota error says "delete some older posts to free up space"; the post ⋯ menu offers Delete post on your own posts (removes it for everyone + frees its photos); your profile tiles get a hover delete button; and the super-admin Food Stories page has an Uploads GC card that scans for orphaned files, previews how much it'd reclaim, and deletes on confirm.
- Master-doc write mutex — concurrent master writes can no longer lost-update (the per-tenant mutex's sibling).
mutateMasterDBon the fs path was a bare read-modify-write with no lock (unlikemutateTenantDB), so two concurrent master mutations both read v1 and the later save clobbered the earlier — racing the master-level TOCTOU hotspots (addon activate/cancel, subscription sweeps, cron leases, and the new TV device-link single-use bind). It now serializes every fs-path master mutation through an in-process async mutex (withMasterLock), mirroringwithTenantLock(Postgres was already row-locked viamutateDoc). This makes the TV link'spending→linkedtransition atomically single-use, and closes the same lost-update class for every other master writer. Surfaced by the adversarial security review of the scan-to-connect flow.
- Per-tenant write mutex — concurrent writes to one tenant can no longer lost-update. The JSON store is whole-file last-writer-wins, so two read-modify-writes to the same tenant landing at once both read the same snapshot and the later save clobbered the earlier.
mutateTenantDBnow serializes per tenant through an in-process async mutex (withTenantLock) on the fs path (Postgres was already serialized via themutateDocrow lock). The two consumer order routes that bypassed it —payment-intent(card) andpaypal-order— now push undermutateTenantDB, with the order number computed from a fresh read inside the lock so concurrent orders can't collide on it. Verified: a 40-way concurrent unit test lands all 40 writes; 5 concurrent live card orders land with 5 distinct sequential numbers (previously: lost updates + duplicate order numbers). The lock map self-cleans so it can't grow unbounded.
- Online orders placed while the restaurant is closed now behave consistently — and the live-orders gap is closed. A cash/pay-at-pickup online order placed outside opening hours was rejected (
RESTAURANT_CLOSED, 409) and never created — so it never reached the Live Orders board — while card (Stripe) and PayPal orders silently went through and took payment with the kitchen shut. The three public order-creation routes now share one gate (onlineOrderingGuardinlib/bazar/ordering-status.ts): when closed they all reject the same way (no more phantom paid orders while closed), and when open they all accept. Root-caused from a live reproduction (Friday 05:26 vs Friday hours 12:00–22:30). Unit-tested.
- `db:import` (JSON→Postgres migration) no longer silently drops singletons. The importer used a hardcoded allowlist that omitted five
app_documentsdocs —food-catalog(the 8,187-ingredient catalog),fsn,launchpad,webshop, andvisitor-analytics— so a Postgres deploy came up with an empty ingredient/nutrition directory (/ingredientshowing "being populated") and empty Food Stories / Launchpad / webshop data. It now *auto-scans `databases/.json** (key = filename stem), so every current and future singleton is carried into Postgres. (scripts/db/import-to-pg.mjs`.)
Added
- "Accept orders while closed" toggle (Business Hours). New owner control (default off = today's behaviour) that, when on, lets every online channel — website cash, card, and PayPal — accept orders outside opening hours: pre-orders, and the ability to place test orders out-of-hours and see them in Live Orders. Persists as
settings.acceptOrdersWhenClosedand is honoured uniformly by the shared order gate above.
- Food Bazar honours the pre-order toggle instantly. The marketplace previously gated ordering on opening hours only, so a restaurant with "Accept orders while closed" ON still showed Closed and blocked add-to-cart — contradicting the backend, which would have accepted the order. The bazar API (
/api/bazar/restaurant/[slug]+/api/bazar/restaurants) now exposes anacceptingOrderssignal (= open by hours ORacceptOrdersWhenClosed) alongsideopenNow; the marketplace restaurant page gates ordering on it (with a clear "Closed right now — pre-orders open" banner instead of a hard block), and the bazar checkout shows a pre-order notice rather than a "won't be accepted" warning. Reads live tenant settings, so flipping the toggle takes effect on Food Bazar on the next load — no re-deploy of the apps, which read the same APIs.
- Ingredient & nutrition database link in the main site footer. The public Food & Ingredient Directory (
/ingredient— 8,000+ ingredients, searchable, with nutrition/allergen/dietary info) was reachable from the legacy footer but missing from the main landing footer (landing-v2/LandingFooter). Added under Product so it's discoverable from the homepage.
- Universal hero gains poster-design powers (so it reproduces the real handoff templates 1:1, no per-theme hero). Reproducing the Citrus design from the premium-templates handoff (
docs/Restora360 Premium Design Templates/) revealed my generated templates were approximations, not the real designs. Rather than fork a bespoke component, the one universal hero learned what it was missing, so it renders the Citrus poster pixel-1:1 as configuration and stays fully Mason-editable: decorative shapes (soft circles/blobs behind the content — the peach + sage Citrus circles), a display headline scale (poster-size type), an accent-word style toggle (italicAurora ↔plainupright Citrus), a status-dot badge (the "● Open every day" pill), and centred buttons on centred heroes. All five are exposed in the hero inspector (Headline size / Highlight word / Status dot / Decorative shapes), so every part stays editable in the same builder — no parallel system. Verified by rendering the Citrus hero under the real Citrus tokens + Fredoka and screenshot-comparing to the handoff. (First slice of reproducing the six handoff templates 1:1 + functional, Citrus first; nav/menu/footer + the live Citrus template wiring follow.)
- In-builder colour-combination picker — your design's palettes, as two-tone swatches. The builder's "Colour theme" panel showed 12 generic single-colour presets even when a premium template was applied — so you couldn't pick the design's own colour combinations from the builder. Now, for a premium-template tenant, the panel lists that design's colour combinations (its 9-token palettes — e.g. Hanami's Sumi Ink / Sumi & Gold / Washi Light …) as two-tone split-circle swatches (background + accent). Picking one re-skins the whole canvas live and persists just the
themeIdon Save (no re-install, content untouched). Non-premium tenants get the 12 generic presets, also upgraded to two-tone swatches. The whole panel is warm-styled to match the navbar/footer/block editors, and the theme gallery's per-card colour swatches now use the same two-tone split-circle (sharedTwoToneSwatch), so the two colour pickers are visually identical. (Builder GET now returns the template's fullthemes; PATCH acceptspremiumThemeIdand validates it against the active template.)
- Each generated premium template now has a distinct hero (not the same image+stat hero five times). Using the universal hero's full range, each of the five designs gets a cuisine-appropriate hero: Osteria Lume — left-aligned editorial image; Fold — a split hero (photo panel + light text side, sans); Ember & Ash — dramatic full-bleed dark; Hanami — refined left editorial with a lighter overlay + gold serif accent; Citrus — a bright gradient hero (no photo), dark-on-light. Combined with each template's own font + palette, the five now feel genuinely different. Driven by a per-template
herooverride merged into the starter (split layouts auto-route the photo to the side panel). Verified headlessly across all five.
- Theme gallery — pick the colour combination (one design, many palettes). Every premium template ships several colour themes (e.g. Hanami's Sumi Ink / Washi Light / Matcha…), but the gallery only ever applied the first one — there was no way to choose. Each gallery card now shows a swatch picker of all the design's colour combinations; selecting one re-skins the live preview thumbnail to that palette and applies it on install (the chosen
variantIdis sent through the already-variant-aware install pipeline). So a design is "one fully-featured theme with colour combinations to choose from", exactly as intended — the install backend (installThemePackage/resolveVariant) already supported variants; this exposes them in the card DTO (toCatalogCard.variants+defaultVariantId) and the UI.
Added
- Every premium template now has a cohesive designed footer (not the generic default). Aurora shipped a bespoke editorial footer, but the five other premium templates (Osteria Lume · Fold · Ember & Ash · Hanami · Citrus) fell back to the plain 3-column default. The editorial footer (eyebrow + wordmark + tagline + Visit/Hours/Reservations columns) is now reusable: a
'editorial'FooterConfigvariant (alias of'aurora', so existing Aurora tenants are unchanged), and the wordmark/tagline typography follows the template's font viaFooterConfig.font('serif' | 'sans'). Each generated template ships its own editorial footer (font from itslogoFont, eyebrow + tagline from its identity), so it re-skins by both colour (9 tokens) and font with its themes — verified headlessly: Hanami in Cormorant/Sumi-Ink (dark) and Fold in Poppins/Birch (light) both render correctly. The builder's footer editor opens for'editorial'too.
Fixed
- Switching theme/template no longer keeps the previous design's header & footer. Applying a new theme (e.g. the Kaze Omakase demo pack, or a generated template) changed the page body but left the Aurora navbar + footer in place, because the installer only wrote
navbarConfig/footerConfigwhen the new package happened to ship its own — so a content-only demo pack (or a template with no bespoke footer) inherited whatever was there before. Install now always reconciles the chrome via a singleapplyChromestep: a package that ships a navbar/footer uses it; one that doesn't resets to the neutral default navbar and clearsfooterConfigso the token-driven default footer renders in the new palette — never a stale bespoke header/footer. Applies to both the demo-pack and premium-template paths; the theme-only footer-wordmark fallback is preserved. When a content-only pack falls back to the default navbar, its light/dark theme is picked from the just-applied palette's luminance so a dark pack doesn't get a clashing light bar. (Regression-tested: a content-only pack over an Aurora-chromed tenant resets the centred nav + clears the editorial footer; a generated premium template installs its own editorial footer + token navbar.) tsc 0 / 358 theme+mason+builder+nav tests pass.
Changed
- Every block inspector now matches the Navbar editor 1:1 (one polished, warm UI/UX everywhere). The Navbar editor's premium look was previously unique to the navbar; the generic block/section inspector (
MasonInspector, used by all blocks) now shares the exact same design system — a warm#fffdfapanel, a gradient clay icon badge in the header, vertical icon tab pills (Content / Style / Effects / Motion / Advanced) on a sunken track,Eyebrowsection labels, and warm controls throughout (inputs, selects, theMasonSegmentedpill control, theMasonToggleswitch, ranges, repeater cards). The shared primitives (editor-bits,MasonSegmented,MasonToggle) are reused, not re-implemented, so the two editors can't drift.MasonStyleInspector(the Style/Effects/Motion controls for ordinary blocks) and the `FooterEditor` were warmed to match (the FooterEditor also gains a Serif/Sans control for the new footerfont). Universal hero — visual "Pick a style" cards + one home per control. The hero's preset is no longer a dropdown labelled "Style preset" sitting confusingly next to a "Style" tab; it's a visual card picker at the top of the Style tab (a mini-mockup of each look — Image+overlay / Gradient / Slider / Split / Aurora / Editorial / Minimal — grouped "With a background" vs "Type-led", with a selected ring + check), exactly like the navbar's Styles tab. The hero's own controls are routed across the tabs via a newFieldDef.tab(Content = text/buttons/badges/stats/offer; Style = preset cards + background + layout; Effects = shape dividers; Motion = entrance + parallax), and the generic `BlockStyle` Style/Effects/Motion panels are suppressed for the hero — so there is exactly one background control, one divider control, one animation control (no more duplicate "two Styles / two backgrounds / two dividers"). Fully backward-compatible (ordinary blocks keep the genericBlockStylepanels, now warm); verified headlessly across the hero's four tabs + generic blocks; tsc 0 / 318 mason+builder+nav tests pass (stalehero.ctaTextfield-map entry removed — hero buttons are authored in the Buttons repeater).
Fixed
- Home menu teasers (featured / menu-preview / daily-lunch) no longer hide dish photos when the menu page is text-only. A premium template with an elegant text menu (e.g. Aurora's
showItemImages: false) globally stripped images everywhere — so the menu-preview block showed plate-icon placeholders even with its own "Show Images" toggle ON, on both the live site and the builder canvas. The global Menu → Appearance text/photo switch now governs the full menu page only; the home teaser blocks keep their OWN "Show Images" toggle (the photo always travels with the item, the block decides display) — in bothBlockRenderer(live) and the builderpreview-dataAPI (editor). So the block's toggle works as labelled and reflects directly. Additionally, a template's menu layout now seeds only with the demo (theme-and-demo), so applying a template to a real restaurant ("use my content") never forces their menu to text-only / hides their photos.
Added
- Universal Hero — P1–P5: the full editor + presets (one hero now expresses every design). Building on P0's layered model, the powerful Hero is now fully authorable in the same Mason inspector and "never breaks the design". Inspector engine (reusable by every block): new field controls —
group(a nested-object editor, recursive),repeater(add / reorder / remove array rows),segmented(pill toggle),range(slider) — plus dot-pathshowWhenconditionals; legacy CTAs /layout/backgroundImageare synthesized into the new shapes so existing heroes show their current content and upgrade on first edit. Backgrounds (Background → Type): gradient (stops + angle), solid, image (+ focal), slider (a CSS-only, JS-free crossfade that really cycles on the live site), video (URL + poster), pattern (dots/grid/noise/topo), none — each hardened so a half-set background can't emit broken CSS. Content: eyebrow · rich headline (an emphasis word → accent + italic) · sub · a buttons repeater (5 styles, new-tab) replacing the two hardcoded CTAs · a badges repeater. Layout: centered/left/right + split (text + media side-panel, 40/50/60) + asymmetric · vertical align · content width · height · shape dividers (wave/tilt/curve/arrow, top + bottom, flip). Motion & extras: entrance animations + stagger · parallax · a live special-offer banner (pill/ribbon/bar, optional coupon). Presets: a "Style preset" selector (Image+overlay, Gradient, Editorial, Aurora, Split, Slider, Minimal) — a preset is just defaults merged under the block's own fields, so picking a look never locks anything and every control stays editable (the old logic that hid image/layout controls on premium/editorial heroes was removed, per "double-check everything is editable"). Aurora absorbed 1:1: theaurorapreset reproduces the bespokeaurora-heroessentially pixel-for-pixel (dark token bg, Cormorant serif headline with an italic-accent word, line-bordered outline CTA, full-width bordered stat row) — verified by rendering the bespoke block directly above the preset under Midnight-Gold tokens. Contrast safety:themeModeauto-derives from the background's luminance (a detectably-dark hex / media ⇒ white text; a light or token-driven solid/pattern ⇒ theme-token text) and patterns are no longer auto-darkened — so a light hero stays light instead of washing out to grey, across every background type (stress-tested headless across gradient / solid / pattern / image / slider / split / dividers / offer-banner). 100% additive;normalizeHerocovered by 7 tests; tsc 0. (Deleting the now-redundantaurora-heroblock + lazy-migrating existing blocks is the next phase, gated on owner verification in the builder.)
- Universal Hero — P0 foundation (the "one hero for every theme" rebuild). Replacing the hero-per-theme proliferation (
hero/split-hero/aurora-hero) with ONE configurable, layered Hero that can express every real-world design, so no future theme needs its own hero block. P0 lands the never-breaks foundation: a purehero-model.ts(HeroContentinput schema + resolvedHeroModel+normalizeHero()which maps every legacy flat field — headline / backgroundImage / ctaText / stat1Value… / variant — onto layers), and a rewrittenHeroSectionthat renders purely from the model through composable layers: background (solid/gradient/image/slider/video/pattern) → overlay (color/gradient) → optional split side-panel → content (live offer banner · badges · eyebrow · rich headline · sub · a buttons[] repeater · stats) → shape dividers, with entrance animations + parallax. Every colour resolves through nested token fallbacks (9-token premiumvar(--accent…)→--color-*) so it re-skins under any theme;isPreviewkeeps it editor-safe. 100% additive/backward-compatible: existing hero blocks render through the same layers vianormalizeHero(covered byhero-model.test.ts);BlockRenderernow passes live offers + rebases every button/badge link. Editor exposure of the new layers (grouped inspector + repeater controls), presets, and absorbing/deletingaurora-heroland in the following phases.
- Five more premium templates — all functional, themed, and selectable. Osteria Lume (rustic Italian), Fold (bakery & café), Ember & Ash (smokehouse), Hanami (omakase sushi) and Citrus (all-day brunch) now ship as complete, working restaurant sites — not mockups. Each is generated from a small per-template config through a shared builder (
premium/generated-starters.ts) on the proven Aurora/Terra blueprint: the platform's real functional blocks (image hero + stat band, a livefeaturedmenu preview, an editorial story, the orderablemenu-full, the realreservation-form, andcontact-info), painted by that template's own 9 tokens (from the seed) + display font (Spectral / Space Grotesk / Anton / Zen Old Mincho / Fredoka, now inTEMPLATE_FONT_LINKS), with a cuisine-appropriate identity + menu + an elegant menu layout. Registering their starters makes all five appear in the theme catalog / owner gallery; applying any yields a fully orderable, bookable site in that template's look, editable in the builder. (Bespoke 1:1 editorial sections like Aurora's hand-built blocks are a per-template polish pass on top of this.) tsc 0 / tests pass; verified Osteria Lume + Ember & Ash render with their distinct palettes/fonts via headless-Chromium.
- Premium templates: WYSIWYG builder canvas + order-tracking theming + "managed in…" affordances. Three follow-ups so a premium template (Aurora) is consistent everywhere and clearly editable. (1) Editor is now WYSIWYG: the Mason builder canvas injects the active premium template's 9 tokens + display font (scoped to
.mason-canvas-theme, mirroring the storefront layout) so the editor shows Aurora's dark/gold/serif exactly like the live site — including the functional blocks (menu/featured) — and re-skins when the owner switches theme. The builder GET route now returns a resolvedpremiumTheme;premiumActivetracks it. (2) Order tracking matches the design: the order-confirmation success icon re-skins to the theme accent, and the standalone order-tracker page (/track/[tenant]/[order], which lives outside the tenant layout) now injects the same premium tokens + font, so an Aurora order tracker is dark/gold instead of the generic palette. (3) Discoverability: the inspector's note for data-bound blocks (menu / featured / contact / hours / offers / reviews / gallery / blog) now says exactly where the live content is managed (Menu → Products, Settings, Business Hours …) with a deep-link button — so "everything is editable" is obvious. All additive/backward-compatible (non-premium tenants unchanged). tsc 0 / 369 tests pass.
- Premium template Aurora renders its real 1:1 design AND is fully editable in the Mason builder (not a read-only skin). Applying a "premium" template used to only re-skin the generic block storefront, so the rich reference designs collapsed into a washed-out approximation. Aurora's bespoke sections are now real, first-class Mason blocks —
aurora-hero(eyebrow + headline + two CTAs + a three-stat band),aurora-story(the room/menu/cellar columns),aurora-quote(chef's quote),aurora-menu(the eight-course tasting list) andaurora-book(reservations heading + booking card). They render identically on the storefront and the builder canvas, and every field is editable in the same editor everyone uses — inline on the canvas (data-mason-field) and in the inspector (fullBLOCK_FIELDSschemas, CTA links viaBUTTON_LINK_FIELDS) — no new editor capability was needed; the fixed-slot pattern (à lafeature-cards/stats) covers the stat band, the story columns and the eight courses. Every colour is a 9-token theme variable with a Midnight-Gold fallback, so the blocks look like Aurora even on the bare builder canvas and re-skin instantly across all 10 Aurora themes (the tenant layout injects the palette fromsettings.tenantSite). Applying Aurora seeds these blocks (+ a Stockholm fine-dining identity + menu) via a new Aurora starter, which also makes Aurora appear in the theme catalog / owner gallery (it was previously excluded because it shipped no starter). The earlier read-onlytenantSiterenderer was removed. Site chrome + pixel polish: the navbar system gained additive, editor-surfaceable options so Aurora's nav is 1:1 and editable — acenteredlayout (centred serif wordmark + a hairlinerule+ centred uppercase wide-tracked links + an italic-serif underlined CTA viactaItalic) and atokentheme that paints the bar from the 9 template tokens — plus a new editableFooterConfig(settings.footerConfig, variantaurora) for the editorial footer (eyebrow + serif wordmark + tagline + Visit/Hours/Reservations columns + bottom links), and body refinements (hero pill CTAs, italic-accent story titles). Install no longer "mixes" the design: premium templates write their authored blocks verbatim (a newapplyPremiumDesignpath) instead of auto-mapping (which previously injected generic menu/contact blocks — the truncated "click for full menu" look); chrome applies in both install modes. All additive (absent config ⇒ existing tenants byte-identical). Verified end-to-end against the reference via headless-Chromium screenshots; tsc 0 / 361 mason + site-template + theme tests pass.
- Aurora is now a fully FUNCTIONAL restaurant site, not a pretty shell. The menu / reservations pages used decorative bespoke blocks (hardcoded courses, a static booking form) — so the menu ignored Menu → Appearance and never showed the tenant's real menu, and the form didn't book. Aurora now reuses the platform's real functional blocks, themed by the Aurora tokens (the Terra pattern): the menu page is the orderable `menu-full` (live products, add-to-cart, search/allergen/dietary filters, honours Menu → Appearance), reservations is the real multi-step `reservation-form`, and the home gains a live `featured` menu preview funnelling to the orderable menu. Cart / checkout / payment / contact are the platform pages, re-skinned by the 9 tokens. The Aurora demo seed became eight real priced products (the courses), so the demo still reads as the design while being fully orderable; "use my content" keeps the tenant's own menu inside the Aurora skin. The decorative
aurora-menu/aurora-bookblocks remain registered for optional pure-content use but are no longer the storefront's menu/booking. (Known dark-theme polish: the contact page's brand-colour "Reach Us Directly" card reads heavy under the gold accent.)Fixed
- Mason builder — fixed a React hydration mismatch from dnd-kit's drag contexts. The page bar / canvas / nav-menu
DndContexts had no explicitid, so dnd-kit derived each drag handle'saria-describedbyfrom an internal counter that drifts between the server render and the client (React Strict Mode double-renders in dev) —DndDescribedBy-0vsDndDescribedBy-1— throwing a hydration warning on/dashboard/site. EachDndContextnow passes a stableid(mason-pagebar/mason-canvas/mason-navmenu), making the attribute deterministic. No behaviour change.
- Mason website editor — three selection/fidelity flaws so the canvas is a true 1:1 mirror of the live page. (1) Section-first selection: a single click on a section now always selects that SECTION (Webflow/Framer-style); clicking a marked field only drills into the field once its section is already selected. Previously the editorial hero is almost entirely editable text, so a click would "steal" the selection into a headline field before the hero was ever selected — making it feel unselectable (the user had to click away and back). Double-click / Enter still edit a field in place. (2) System/chrome sections are now mirrored + selectable: the live home page renders three things that AREN'T website blocks — the auto-promo offer banner (active offer, e.g. "Lunch Express"), the auto-FAQ section, and the site footer — but the editor only showed the auto-promo (and it was a dead, unclickable zone) and omitted the FAQ + footer entirely. All three are now rendered read-only on the canvas in their live order, clearly badged ("Auto · active offer" / "FAQ · automatic" / "Footer · site-wide"), and selectable → a new right-panel explainer (
MasonSystemInfo) says what each is and links (new tab) to where it's managed (Offers / SEO / Navigation + Settings). To stay DRY + 1:1 the live FAQ and footer markup were extracted into shared presentational components (StoreFaqSection,SiteFooterView) used by BOTH the storefront and the editor mirror; the builder GET route now also returnsfaqEntries+footer. The public storefront output is byte-identical (verified by screenshot). tsc 0 / eslint 0 / 276 mason+tenant tests pass.
Changed
- RefCapture consent-consistency: the anonymous `?ref=` landing forensic log is now consent-gated. Closes the follow-up the cookie-consent review flagged. The sales-agent referral landing beacon (
POST /api/public/referrals/track) used to store an anonymous visitor's IP + User-Agent unconditionally; that identifiable, non-essential storage now only happens with explicit marketing consent and never under DNT/GPC — matching the new consent gate. The functionalref_codecookie is still set regardless (the visitor deliberately followed a partner link, so attributing it is the action they requested, not behavioural tracking), so agent attribution + commissions are unaffected. The signup-time forensic event in/api/auth/registeris intentionally left in place + documented: it fires at an actual commission-forming signup, where IP/UA is fraud-prevention under GDPR legitimate interest (recital 47), not anonymous tracking. tsc 0 / eslint 0 (uses the tested consent helpers).
Added
- Theme ecosystem redesign (Phase 32) · P2.2 — rendered mini-homepage thumbnails (designs are now distinguishable). Theme cards previously showed a single hero photo (and several packs reused the same stock image), so owners couldn't tell designs apart. Each card now renders a
ThemeThumbnail— a scaled mini-homepage mockup (nav + hero + a row of featured dish cards) painted in the theme's own palette, heading font, hero headline, and real demo dishes/prices — so every theme visibly shows its design at a glance, no screenshot pipeline needed. The thumbnail hero uses each pack's own first-dish photo (from the 26-image verified pool) instead of the 3 shared stock heroes, so cards never look identical.toCatalogCardnow carriesheroHeadline/heroImage/fontHeading/currency/sampleDishes; used by both the owner gallery (ThemeCatalog) and the super-admin Theme Library. +mockup test assertions (62 theme tests); tsc 0;next buildexit 0.
- Theme ecosystem redesign (Phase 32) · P2.1 — retire premium-template mode (truly no legacy). Completes the unification: the old premium-template "mode" (
settings.tenantSite+ a separate skin engine) is gone, so the builder + storefront run one path. Migration (scripts/db/migrate-premium-themes.mts): every tenant in premium mode is converted to the equivalent standard-mode catalog package (re-skinned "theme-only" — keeps its content, gets the design + palette ascustomColors), thentenantSiteis dropped (the one dev tenant, luigi-pizza/terra, migrated cleanly). Builder: removed the right-sidebar Theme panel's premium picker (MasonThemePanelis now just colour/font/layout settings + "Browse all themes" → the catalog gallery), all premium state/handlers fromMasonShell, the canvas premium-CSS overlay, and thepremiumTheme↔tenantSiteread/write in the builder route. Storefront: the layout's premium-token overlay and the page'spremiumizeBlockscall are removed — a theme's palette now lives entirely incolorPresetId+customColors. Deleted the now-unusedapply-templateroute. The site-templates lib stays (the catalog adapts those designs into standard packages). 62 theme tests pass; full repotsc0;next buildexit 0.
- Theme ecosystem redesign (Phase 32) · P2 — super-admin-curated unified theme catalog (no legacy dual path). Re-architected ownership per product direction: the super admin controls which themes exist site-wide; restaurant owners only browse + apply. New unified catalog (
lib/themes/package/catalog.ts) surfaces ALL installable designs as ThemePackages — the 8 demo packs, the premium templates (Saffron & Sage, Terra) adapted via their starters (editable Mason blocks + menu + the 9-token premium palette mapped to brand colours, so there's no separate template path), and super-admin ZIP uploads (master.themePackages) — gated bythemePackageFlags.enabled. Super-admin Theme Library (/admin/dashboard/themes, rewritten): upload/validate a theme ZIP, enable/disable site-wide, export any theme, delete uploads, and capture a test restaurant's site as a theme (/api/admin/themes/{packages,packages/[slug]/export,export-tenant}, allrequireSuperAdmin). Import/export removed entirely from restaurant owners (deleted the tenant import/export routes + the owner ImportExport UI). Owner gallery is now a single ThemeCatalog of enabled themes, each shown as a demo-content thumbnail (browse designs without clicking), with one-click "Use with my content" vs "Preview with demo content" (both undoable). Owner install is gated to enabled themes (403 on a disabled slug) and the owner catalog API lists enabled only. AddedThemeVariant.customColors(premium palette → standard brand colours) and a demo thumbnail on every pack (manifest.screenshots[0]). +4 catalog tests (61 theme tests total); full repotsc0;next buildexit 0. Remaining legacy: the right-sidebar Theme panel's small premium picker still uses the old apply path — flagged as the next follow-up; the full-screen gallery is fully on the new system.
- Theme ecosystem redesign (Phase 32) · P1e — portable theme ZIP import/export (`.r360theme.zip`). Export a tenant's current site (
exportTenantToPackage→ manifest + variants + page templates + editable demo content + a sha256 corruption-detection hash) and import a (potentially untrusted, third-party) theme ZIP. The import pipeline (lib/themes/package/{zip,security}.ts, routePOST …/themes/import, bothrequireTenantAccess) parses → runs a security pass → validates the zod manifest + content hash + semver engine gate → sanitizes every imported block → previews → installs (undoable via the rollback snapshot). NewThemeImportExportcontrols in the gallery header (Import .zip / Export, with a preview→confirm modal). JSON-only packages this version (demo images are URL refs; binary assets viasharpare a follow-up). Hardened by an adversarial security review (17 agents, 7/7 confirmed findings fixed): (1) zip-bomb DoS — entries are now inflated through a bounded stream that aborts past the per-entry cap (was: JSZip fully materialized each entry in memory before the size check, so a ~200 KB entry could expand to ~200 MB and OOM the shared process); (2) CSS-injection / active-scheme URLs — every imported URL-bearing field +style.bgImageis clamped to a safe URL (safeImportUrl), so a craftedbackgroundImage/bgImagecan't break out ofurl(...)to inject CSS, andjavascript:/data:can't reach anhref/src/iframe; (3) untrusted menu/reviews — imported products/categories are element-validated (id/name/price/category) and reviews are coerced (no NaN rating / non-string author crashing storefront SSR); (4) contentHash is documented as corruption-detection, not a signature (no security decision depends on it). +13 ZIP/security tests. Full repotsc0;next buildexit 0.
- Theme ecosystem redesign (Phase 32) · P1a/P1c/P1d/P1f — one-click theme install + demo content + rollback. The WordPress/Shopify-style install experience, built additively on the Smart Content Mapping engine (P1b). New unifying ThemePackage model + theme.json manifest (
lib/themes/package/types.ts, zod-validated inmanifest-schema.ts, semver engine gate inengine-version.ts). An installer (lib/themes/package/installer.ts) with two modes: Install Theme Only (loads the design but keeps the tenant's menu/media/reviews — pages are auto-mapped with the tenant's own data viaautoMapBlocks+bakeBlocks) and Install + Demo Content (seeds a full, editable demo restaurant into the normalwebsiteBlocks/pageBlocks/products/reviewsstructures). Every install captures a site snapshot first (lib/themes/site-snapshots.ts, mirrorsmenu/snapshots.ts) so it's undoable; additiveTenantDB.siteSnapshots+MasterDB.themePackages/themePackageFlags. 8 seed demo packs (pizza · fine-dining · cafe · bakery · food-truck · sushi · fast-food · multi-location — ~217 dishes, authored via an 8-agent workflow) expand from a smallDemoPackSpecviademo-packs/build.ts, with images drawn from a curated, verified food-photo pool. New routes (allrequireTenantAccess):POST …/themes/install,POST/GET …/themes/revert,GET …/themes/packages. New UI: a "Demo restaurants" section in the Mason Template Gallery (DemoPackInstaller.tsx) with two install buttons, an inline confirm, progress, and a one-click Undo. Hardened by a 5-dimension adversarial review (23 agents, per-finding verification): registered custom-slug pages inbuilderPagesso the catch-all storefront route serves them; made rollback media-safe (the installer never writesdb.media, so snapshots no longer capture/restore it — preventing wipes of photos uploaded after install), symmetric (optional settings/navConfig clear on revert), and drift-proof (capture + restore are driven off one allowlist); aligned the install-bake featured-dish language with the live render (pick(name, lang)instead of hardcoded.en); and seed demo nav even when the tenant had none. +22 install-side tests (both modes, theme-only preserves menu, rollback round-trip + clear/media-safety, custom-page registration, manifest/engine validation, all 8 packs well-formed). Full repotsc0 errors. Reuse-first: extendsapply-template's write pattern,render-datahelpers, andmenu/snapshotsrather than forking. Remaining in P1: theme adapters for the existing 6 themes/12 palettes/3 premium templates (gallery feed), ZIP import/export (P1e), inspector dynamic-tag picker.
- Theme ecosystem redesign (Phase 32) · P1b — Smart Content Mapping engine (dynamic tags). First slice of the WordPress/Elementor-grade theme-install experience: an additive, backward-compatible engine that lets theme/demo authors (and the install auto-mapper) drop mustache placeholders —
{{restaurant.name}},{{hours.weekly}},{{contact.phone | tel}},{{links.menu}}— into authored block fields that resolve to the tenant's live data. New pure, framework-free lib atlib/themes/content-mapping/: a tag registry (tags.ts— 19 tags across identity/contact/hours/social/links/menu, each with a resolver + sample + format pipes + abakeAtInstallflag), a resolver (resolver.ts—{{path|format}}grammar; identity-return no-op when a block has no tag, so existing tenants like luigi-pizza render byte-identically), a context builder (context.ts— derives the normalized data bag fromTenantSettings, reusing the proventenantHours/tenantSocials/pickhelpers now exported fromsite-templates/render-data.ts), an install bake step (bake.ts— identity/structure tags become plain editable text; live tags stay tokens so they track Settings), and an auto-mapper (auto-map.ts— Install-Only: maps hero/footer identity from tenant data + guarantees hero/menu/contact sections so nothing renders empty/broken). Live resolution is wired into the single SSR choke pointPageBlocks(BlockRenderer.tsx), memoized per render context — so editing Business Hours updates every page automatically with no re-install. +22 tests (resolver/auto-map/bake/registry), tsc 0 for touched files. Backbone for the install pipeline + demo packs + ZIP import/export (next).
- Mason builder — a full-screen Template Gallery to browse + apply a whole website at once. A new
MasonTemplateGallery(opened from a "Templates" button in the editor toolbar and a "Browse all templates" link atop the Theme panel) shows every template as a preview card in two groups: Premium templates (image preview + tagline + "what's inside" tags + a selectable palette picker — premium starters apply the full 1:1 layout, skin-only ones apply just the theme) and Standard layouts (the 6 built-in block layouts, shown as colour-preset previews). Applying any card runs the sharedPOST /api/tenant/[tenantId]/apply-template, which now also (a) accepts an optionalthemeIdso a premium template applies with the chosen palette, and (b) handles the standard built-in layouts (seeds their home blocks + colour preset, clears any premium skin) — so the gallery is one apply path for both kinds. The editor refetches and the cloned design loads, fully editable. tsc 0 / eslint 0 / 1092 tests pass; gallery verified by screenshot.
- Premium templates — "Saffron & Sage" cloned 1:1 into the builder as a fully-editable starter template (capability test). Proves the platform can take an external design and turn it into a selectable template that's a true 1:1 clone AND fully editable in Mason. A real source site (a Tailwind farm-to-table restaurant — home + menu) was reproduced as: (a) a premium theme skin — a code-defined
SiteTemplate(lib/site-templates/premium/saffron-sage.ts) on the 9-token contract (cream/espresso/saffron/sage palette, 3 theme variants) + Playfair Display/Lato fonts. Registered in the registry (NOT in premium-seed.json, so the "6 templates × 10 themes" seed test is untouched). (b) a 1:1 starter layout (saffron-sage-starter.ts) authored entirely from the platform's OWN editable Mason blocks — hero, featured, delivery-cta, reservation-form, contact-info, google-maps + the menu page'smenu-full— plus the navbar/brand, contact/hours/socials, and the full 26-dish, 7-category menu seeded as real editable products. Applying it (newPOST /api/tenant/[tenantId]/apply-template, surfaced as a "Use this template" button on each starter-bearing premium theme in the Mason Theme panel) seeds all of that into the tenant and reloads the editor, so every section is a normal editable block. The one genuine gap was filled additively: the hero block now supports an eyebrow, a secondary CTA, and an optional quick-facts stats row in its image variant (new inspector fields +eyebrowis now inline-editable on canvas; the editorial variant hides the image-only controls). Verified by seeding a throwaway demo tenant and screenshotting the live home + menu pages against the source — faithful match (transparent hero navbar, eyebrow + italic accent headline + dual CTAs + stats, themed sections, real menu with add-to-cart). tsc 0 / eslint 0 / 1092 tests pass.
- Phase 18 — AI Support Brain + Voice (hybrid, cheap): buildable slice shipped. A self-learning, cost-optimized support layer where the LLM is the LAST resort. A new framework-free engine
src/lib/support/answers questions through cheap deterministic layers first — L1 FAQ → L2 BM25 lexical search over the existing Docs Center → L3 guided troubleshooting → L4 human-approved learned answers — all with zero LLM cost; only when none is confident does it reach L5 (a budget-capped Claude Haiku call grounded strictly on retrieved Help Center excerpts, honesty-ruled, metered under a newsupportAIFeature) and finally L6 (human hand-off → a platform support ticket). The learning flywheel: every L5 answer becomes a PII-stripped pending learned answer that a super-admin must approve before it's ever reused (the "never learns a wrong answer" gate); a thumbs-down rejects it. Owner UI: an assistant on `/dashboard/support` (above the ticket center) with chat, on-device voice (Web Speech STT + TTS — no cost), layer/provenance badges, source citations to the Help Center, feedback, and one-tap escalate (which opens a real ticket and refreshes the list). Super-admin UI: a Support Brain console (/admin/dashboard/support-brain, in the sidebar) showing the L1–L6 resolution distribution, the cost metric ("% answered without AI" / LLM share), a feedback tally, and the approval-gated learning queue. Routes (all auth-guarded):POST /api/dashboard/support/{ask,escalate,feedback}(tenant) +GET/POST /api/admin/support-brain(super-admin). GDPR: PII is scrubbed before anything is stored; the approval gate is the second line of defense. 17 new engine tests (541 total pass); tsc 0 / eslint 0. Deferred: pgvector embeddings (lexical search substitutes behind the samerankKnowledgeinterface), all-portal/footer surfaces + Twilio telephony, the AI knowledge-curator / auto-docs.
- Phase 18 — SB-0: semantic search delivered as BM25 + synonym query-expansion (no vector store reinvented) → Phase 18 is 100% complete. Closes the last SB task without building a redundant vector store. There is no embeddings provider or pgvector store anywhere in the repo (the DB is a JSON-document store), and a real vector store is explicitly designed to be ONE shared platform store (the Catalog/GFI + Agent-OS/RAOS depend on the same one) — so a Phase-18-only copy would be the wheel-reinvention to avoid. Instead, SB-0's function (semantic-grade retrieval) is delivered cheaply: a curated, high-precision synonym query-expansion layer (
lib/support/synonyms.ts) feeds the BM25 ranker so the deterministic layers now match paraphrases the raw tokens miss — "can't log in" finds a "sign in" article, "picture" finds a "photo" doc, "remove" finds a "delete" step — with zero embeddings, infra, or cost. Integration is careful: each query term expands to a group (term + synonyms); a synonym hit lifts the BM25 score (at 0.75× an exact match) and counts the term as matched once, so recall improves without inflating the overlap/confidence denominator (no false-confident answers). This raises the share of questions the cheap layers resolve — directly serving the "LLM is the last resort" flag. A real shared pgvector store remains an OPTIONAL future platform-scale optimization that drops in behind the samerankKnowledgeinterface. +4 engine tests (589 total pass); tsc 0 / eslint 0. With SB-0 delivered, Phase 18 (SB-0…SB-6) is complete — the only open items are go-live/scale config (a live Twilio number; pgvector if/when measured scale demands it), neither of which is Phase-18 dev work.
- Phase 18 — SB-5: voice agent (telephony) built with a mock provider — configure Twilio at go-live. The Support Brain now answers phone calls, riding the SAME deterministic brain. New
lib/support/voice.ts(pure, tested): TwiML builders,sanitizeForSpeech(strips links/markdown, caps length for TTS),interpretConsent/wantsAgent(DTMF + speech),voiceTurn(L1–L4 → spoken answer, else hand-off),voiceConfig(env), andtwilioSignature/validateTwilioSignature(constant-time HMAC-SHA1 request verification). The call flow: a GDPR consent gate ("press 1 / say yes; press 0 for a person") → caller speech → the brain answers (Twilio TTS), offering follow-up turns → when unsure or asked, a human hand-off opens a platform support ticket with the (consented) callback number. Two Twilio Voice webhooks —POST /api/public/support/voice/{incoming,respond}— that are inert and safe until telephony is configured (no DB writes, no auth surface) and Twilio-signature-verified once live (+ rate-limited). Because there's no phone in dev, a super-admin call simulator (POST /api/admin/support-brain/voice-sim+ a "Voice agent" panel in the console) exercises the exact brain turn and shows the TwiML the caller would hear — so the functionality is provable now. Call metrics (calls / AI-resolved / escalated) surface in the console. Go-live = config only: setTWILIO_ACCOUNT_SID/TWILIO_AUTH_TOKEN/SUPPORT_VOICE_NUMBERand point the number's Voice webhook at/incoming. On-device Web Speech voice already ships in the chat widget. A focused adversarial review (3 lenses + verifiers) caught a blocker — a silent/unintelligible caller could loop forever and never reach the human hand-off — plus consent-flow + GDPR-accuracy gaps; all fixed: the retry counter is now threaded through every turn (gather action and the no-input<Redirect>) so silence always escalates after one retry, the call-entry no-input redirect hands off to the consent state machine instead of looping back to/incoming(which also double-counted the call metric), the consent re-prompt opens a ticket on silence, and the hand-off ticket never asserts "caller consented" on a path where consent wasn't given. +10 engine tests (588 total pass); tsc 0 / eslint 0. With SB-5 built, the only remaining Phase 18 item is SB-0 (the shared platform pgvector store — BM25 substitutes behind the samerankKnowledgeinterface).
- Phase 18 — SB-3 + SB-4 + SB-6: "Ask AI everywhere", the knowledge curator, and per-tenant/CSAT analytics (Support Brain to its full on-seed scope). Builds out the Support Brain past the initial slice. SB-3 — Ask AI everywhere: one reusable floating widget (
components/support/AskAiWidget.tsx— chat + on-device voice (Web Speech STT/TTS) + layer/provenance badges + Help-Center citations) now appears across the whole product: a path-awarePublicAskAimounted once in the root layout puts it on every public surface (marketing, Help Center, Docs, Learn, Food Bazar, FSN) and self-hides on owned/authenticated routes; the owner dashboard (DashboardAskAi) gets the full L1–L6 brain (feedback + one-tap escalate) scoped to the active tenant; the supplier/designer/agent portals get it too. A new public, abuse-safe endpointPOST /api/public/support/askserves anonymous visitors the deterministic L1–L4 layers (FAQ → KB → guided → approved-learned) with no LLM, no DB writes, and per-IP + global rate limits, falling back to an honest "browse the Help Center / contact us" hand-off. Multilingual (lib/support/locale.ts): an optionallangmakes the L5 answer return in the visitor's language (a one-line prompt directive — a few tokens, no quality loss); deterministic answers stay source-language so the cheap path stays free. SB-4 — knowledge curator + ticket-prevention + auto-docs (lib/support/curator.ts): deterministic token-overlap clustering of the learned-answer log surfaces repeated questions (documentation gaps that keep hitting the paid LLM); a super-admin drafts a durable Help-Center answer from a cluster (budget-bounded Haiku grounded strictly on the existing docs, with a deterministic stub fallback when no key is configured — build-with-seed), reviews it, and approves → it becomes an approvedLearnedAnswerthe brain serves at L4, so the next identical question is answered for free (ticket prevented at the source). The same "never publish an unreviewed answer" gate applies: a PII tripwire blocks approval, and clusters already covered by an approved entry are filtered out. SB-6 tail (lib/support/stats.ts): the console now shows CSAT (overall + a 14-day trend from daily feedback buckets) and a per-tenant breakdown (resolutions, reached-AI %, escalations, CSAT) — every/ask, escalate, and feedback now also records per-tenant + daily counters. Console (/admin/dashboard/support-brain) gains the curator (clusters → Draft → Approve/Dismiss) + the satisfaction/per-tenant panels. A 5-lens / 2-verifier adversarial review (22 agents) confirmed 7 findings (10 dismissed as intended/fail-closed) — all fixed: the curator clustering now uses single linkage (it compared only against the cluster seed, fragmenting repeated-question groups), cluster ids are derived from a member-set hash (no top-keyword collisions), the public widget's hide-list now covers every customer-facing tenant surface (/pay /track /display /tv /scan /gift-cards /shop /invoice+ FSN — it was leaking onto diners' checkout/track/display screens),approve-proposalvalidates + mints inside the row lock (a double-submit could mint a duplicate) and scrubs other restaurants' names from the curated entry, and the widget stops its mic/TTS on unmount. +23 engine tests (curator/locale/stats/learning; 575 total pass), tsc 0 / eslint 0. Phase 18 is now built to its full buildable scope — the only remaining items are true infra/external gates: SB-0 the shared pgvector store (platform-wide "one vector store"; lexical BM25 substitutes behind the samerankKnowledgeinterface) and SB-5 Twilio telephony (on-device voice already ships; only the paid PSTN bridge is gated).
- Phase 18 — adversarial review pass: cross-tenant safety + correctness hardening. A 5-lens / 2-verifier multi-agent review of the slice surfaced 14 confirmed findings, all fixed. Security (the headline): the learning store is global (an approved answer is reused for every tenant), so the PII scrub was broadened to also strip structured ids (
SO-2026-00001,RB-2041) and 4+ digit runs, plus redact the originating tenant's own name; acontainsPiipost-scrub tripwire now quarantines (never stores) any draft where an identifier survives; each pending draft records itsoriginTenantId(shown in the queue), a thumbs-down can only reject its own tenant's draft, and the super-admin queue carries an explicit "approving publishes to every tenant — redact tenant-specific detail" warning. Correctness: escalations are counted on a separateescalationsfield (no longer double-counted as anlayerStats.humanresolution, which skewed the LLM-share metric);getBudgetStatusis now.catch(null)so a super-admin probing a non-existent tenant fails safe to the human hand-off instead of a 500; the L3 guided sign-in flow dropped the genericaccess/sign/logtriggers that hijacked unrelated questions. Dedup/perf: brain-opened and tenant-portal tickets now share onebuildSupportTicketbuilder (identical id scheme/owner resolution/defaults); the static knowledge index is memoized; the learned list is capped at 500 without ever evicting unreviewed pending drafts; the admin console gainedr.ok/res.okguards + an error state. +13 engine tests (30 support / 552 total pass); tsc 0 / eslint 0.
- Phase 30 — Restora Experience Studio: P7b — the public Experience API + asset-library scale-out (Phase 30 100% on seed). Closes Phase 30. A new
POST /api/public/experience-studio/v1/generatelets a tenant generate experiences from their own stack / CI / headless site builder: key-auth via a newlib/experience-studio/api-guard.tsauthenticateExperienceRequestthat reuses the existing `rck_…` Restora API keys (one key per tenant works across the Catalog + Experience APIs — each endpoint gates on its own add-on), confirms the tenant still holdsexperience-studio, and applies the shared per-key rate limit (120/min). Crucially, both the public API and the in-dashboard one-click flow now run a single shared engine — a newlib/experience-studio/generate-core.tsrunTenantExperienceextracted from the tenant route (which was refactored onto it) — so quota (atomic reserve + compensating refund), the Food-DNA AI-budget lane, brand-kit resolution, compose, and WOW score are defined once, not duplicated. Scale-out (read): a new pureassets.tsfilterAndPageComponents(filter by cuisine/category/status + free-text, stable id sort, NaN-safe clamped limit/offset, pre-page total) backs an optional?cuisine=&category=&status=&q=&limit=&offset=on the admin library GET — the read stays bounded as the catalogue grows past the seed, while the full normalized-store/CDN offload stays volume-gated (same posture as the Catalog's P8–P10). 61 experience-studio tests (+2), tsc 0 / eslint 0 on all P7b files. Phase 30 is now 100% built & delivered on seed (P0 → P7b) — the remaining items are go-live/expansion gates (real curated art + measured 3D weights, the paying-pilot cuisine expansion, the live SEO scorer, the real Stripe overage charge, the volume-gated CDN offload), not dev work. - Phase 26 — Food Stories Network · long-form articles (E3.4): a Medium-style `article` post type. FSN authors can now publish long-form reads alongside stories + recipes. A new
'article'post type carries anArticle(lib/fsn/article.ts— title + Markdown body with newlines preserved + optional subtitle/cover + tags; read-time is DERIVED server-side from word count, never trusted from input; the normalizer only bounds/trims, it deliberately does NOT strip Markdown). Create flow: the composer gains an Article mode (a 4-step Media → Details → Write → Review wizard with a liveMiniMarkdownpreview + word/read-time counter). Read: a new SEO'd reader/fsn/article/[id](schema.org BlogPosting JSON-LD with an absolute canonical URL) renders the body viaMiniMarkdown; a new browse hub/fsn/articles(+FsnArticles,?type=articlefeed) with featured hero + tag filters; nav + footer links; article cards in the feed/discover-rail, profile (a dedicated Articles tab), tag pages and explore — all reuse the existingPostCard. Structural safety was the design constraint:MiniMarkdownrenders all user text as auto-escaped React children (neverdangerouslySetInnerHTML), its link branch now enforces a scheme allowlist (internal/— excluding protocol-relative//host— plushttp(s)/mailto; everything else, incl.javascript:/data:, drops the href), and the single JSON-LDarticleBodysink is<→<-escaped centrally inJsonLd. Author deletion scrubs thearticlefield in the cascade. Delivered understand → implement → adversarial review (a 5-dimension / 17-agent workflow with per-finding refutation). The review caught a critical visibility IDOR — the new article reader (AND, pre-existing, the recipe reader) rendered the post body + JSON-LD without a `canViewPost` gate, so a leaked id could read a private / followers-only post; both pages (body +generateMetadata) now 404 via the FSN-10 anti-oracle gate, with title/deck no longer leaking in the<head>. Also fixed from the review: relative→absolute BlogPosting URL, galleryalttext (article + recipe), the article-filter featured hero no longer showing an off-filter article, and the Review-step preview now renders Markdown. +8 article tests (58 FSN tests pass), tsc 0 / eslint 0.
- Phase 26 — Food Stories Network · E7 commerce bridge (the moat): dish → orderable storefront + auto-built restaurant brand profiles. An FSN food story or recipe can now feature a REAL dish from a Restora restaurant's live menu — "dish-tap → real order," the uncopyable differentiator. A post carries an optional
dishsnapshot (lib/fsn/bridge.ts— denormalised so it survives later menu edits/deletes); the create flow gains a menu-item picker (GET /api/fsn/dish-options) shown ONLY to authors affiliated with a restaurant — operators (account.linkedUserId→TenantRecord.ownerId) or owner-VERIFIED staff (lib/fsn/affiliation.ts) — which is the abuse gate (you can't deep-link an arbitrary restaurant's menu). The shared post card + recipe detail page render an "Order this dish →" card; a new auto-built brand profile/fsn/restaurant/[tenantId](SEO-metadata'd,GET /api/fsn/restaurant/[tenantId]) shows the restaurant's live orderable menu (order CTA → the storefront) + the FSN stories featuring its dishes. Reuses the liveTenantRecord+ products (no FSN-side duplication); the snapshot keeps the feed fast and resilient. +5 bridge tests (39 FSN tests pass), tsc 0 / eslint 0. Closes the highest-value remaining E7 gap; the buildable-web tail left is pre-launch hardening (post-delete / uploads GC / request-cache), Verified-Pro + creator tips (Stripe-gated), long-form/video, and FSN-UI i18n.
- Phase 26 — Food Stories Network · pre-launch hardening: post deletion + uploads GC + getFsnDB cache. Closes the TODO blockers before opening public UGC. (1) `DELETE /api/fsn/posts/[id]` — author-only soft-delete (
status:'deleted', never the admin'removed'takedown) that scrubs the post, hard-drops its edges (likes/saves/comments/notifications), auto-resolves open reports on the post AND its now-deleted comments, and unlinks the post's media so the per-account upload quota is recovered; purecascadePostDelete+ a path-traversal-safe URL→disk resolver, with an author "Delete post" menu on the feed card. (2) `POST /api/admin/fsn/uploads-gc` — super-admin orphaned-file garbage collector (deletes files referenced by NO post image [every status] or avatar and older than a grace window; dry-run by default, audited; purecollectReferencedUrls/findOrphanUploads). (3) A short-TTL (5s) read cache for `getFsnDB` so the 2–3 doc parses per authed request collapse to one: the read path is cached whilemutateFsnDBreads a FRESH object (never corrupting the shared cached read object) andsaveFsnDBrefresh-hots it, so a write is visible immediately on the instance — TTL kept short because suspension/revocation (FSN-10) live in this doc. Delivered understand → implement → adversarial review (two orchestrated workflows); the review caught + this commit fixes a cross-account media-deletion IDOR (post image URLs are now owner-scoped both at create time AND at unlink time), a 403/404 existence oracle (now 404 for both, matching the FSN-10 anti-oracle pattern), orphaned comment-reports on cascade, and a GC TOCTOU (re-validates the live reference set against a fresh read immediately before deleting). +13 tests (45 FSN tests pass), tsc 0 / eslint 0.
- Phase 19 — Tax & Compliance Assistant · Slice G: country-module framework (TC-7) — Phase 19 complete. The VAT engine's registry is now seeded with 8 jurisdictions — Sweden, Denmark, Finland, Norway, Germany, United Kingdom, United States, Canada — so adding a country is a data edit, not core code. Each module declares its
taxSystem(vat / gst / sales-tax), whether rates areautomatable, andneedsConfirmation(every non-SE module ships unconfirmed, publicly-sourced defaults to be confirmed by a local tax professional — build-with-seed). Dated + sourced highlights: DK 25% flat (no food reduction); FI 25.5% std / 14% food; NO 25% std / 15% groceries / 25% dine-in; DE 19% std / 7% food with restaurant dine-in 19% → 7% from 1 Jan 2026 (a real dated change, like Sweden's); GB 20% std with groceries/cold food zero-rated and catering 20%. US (sales tax) and Canada (GST/PST/HST) vary sub-nationally → modeled as NOT auto-computed:resolveVatreturns supported:false with guidance to use a tax engine (Avalara/TaxJar) or accountant rather than fabricating a rate. The income statement's per-rate recognition now works for every VAT country; a Germany-2026 restaurant-VAT notice was added to regulation-watch; the Bookkeeping page shows a jurisdiction banner (unconfirmed-rates / not-auto-computed / VAT-return-is-SE-only); newGET /api/tax/countrieslists the modeled jurisdictions; a consistency test pins every overlapping standard rate to the live webshop table. VAT-return prep + deadline reminders remain Sweden-only by design (other countries' return forms/deadlines are the progressive next step). Direct e-filing stays explicitly out of scope (TC-0). +5 tests (76 tax/bookkeeping tests pass), tsc 0 / eslint 0. Phase 19 (TC-0 → TC-7) is now build-complete on seed — remaining work is go-live gates (counsel-confirmed rates per country, a live reminder cron, additional country return-forms) and progressive country expansion, not core dev.
- Phase 19 — Tax & Compliance Assistant · Slice F2: financial-report approval workflow — TC-6 complete. A per-period report now has a lifecycle — draft → ready-for-review → reviewed → approved → exported — that ties sign-off to the right human. Pure
lib/tax/approval.tsis the state machine (allowed transitions + which actor may perform each: owner vs accountant) plussummarizeForApproval, which freezes the period's headline figures as a versioned snapshot. A newFinancialApprovalonTenantDBstores status, version, the frozen snapshot, who/when at each stage, the accountant's review note, and a fullhistory. The owner route…/tax/approvalsprepares a report (snapshots the live books) and advances it (PATCH; "re-prepare" refreshes the snapshot + bumps the version); the accountant — via their scoped link — can perform exactly ONE bounded write,…/accountant/[id]/[token]/review(mark reviewed + note), validated by the same token. Owner UI: a "Report approvals" panel on the Bookkeeping page; the accountant page gains a review form on ready reports. Every transition is audit-logged (visible in the TC-6 audit center). TC-0 held — "approved" means the user signed off on a DRAFT; Restora never files. +6 tests (71 tax/bookkeeping tests pass), tsc 0 / eslint 0. TC-6 (accountant collaboration + audit center) is now complete; only TC-7 (country-module framework) remains.
- Phase 19 — Tax & Compliance Assistant · Slice F1: accountant scoped access + audit center (TC-6, part 1). External accountants can now view a tenant's financials read-only without a Restora account, via a scoped magic-link — modeled on the existing token pattern, so no new auth role. A new
AccountantAccessTokenonTenantDB+ purelib/tax/accountant-access.ts(expiry math, revocation/expiry validation, token masking) drive it. The owner-gated…/tax/accountantroute invites (emails the link), lists (with live status), and revokes; the public, token-authenticated…/accountant/[tenantId]/[token]route validates the unguessable, revocable, TTL token on every request (the token IS the authorization), records the view, and returns a read-only snapshot (summary, P&L, VAT return, data-quality, deadlines, regulation alerts) rendered on a standalone/accountant/[tenantId]/[token]page. An audit center (…/tax/audit) surfaces who invited / viewed / exported by reading the immutable audit log filtered to a finance prefix; CSV/SIE exports now write that trail too. Owner UI: an "Accountant access" + "Audit trail" panel on the Bookkeeping page. TC-0 held — read-only drafts, every grant/view/export audit-logged, links revocable + expiring (default 90 days). +4 tests (65 tax/bookkeeping tests pass), tsc 0 / eslint 0. Next: Slice F2 (TC-6 part 2 — the prepared→reviewed→approved approval workflow).
- Phase 19 — Tax & Compliance Assistant · Slice E: VAT/PAYE deadline monitor + regulation watch (TC-5). Two pure, deterministic modules (no LLM).
lib/tax/deadlines.ts(upcomingTaxDeadlines) computes Swedish skattekonto deadlines — VAT momsdeklaration (monthly/quarterly) due the 12th of the second month after the period; employer declaration (AGI) due the 12th of the month after — with the 17th bump for January/August, validated against real Skatteverket dates (Q2-2026 VAT → 2026-08-17, Q3 → 2026-11-12, Q4 → 2027-02-12). Each deadline carries days-until + urgency (overdue/due-soon/upcoming); yearly VAT is deliberately not computed (the date varies → surfaced as a note, never a guessed date).lib/tax/regulation-watch.ts(getRegulationNotices) is a curated, dated, sourced registry of regulation changes (seeded with the 2026–2027 food-VAT cut) shown when active/upcoming/recently-ended — every notice carries a source URL and "based on public info, not advice" (TC-0). NewGET /api/tenant/[tenantId]/tax/calendarserves both; the Bookkeeping page renders an "Upcoming tax deadlines" + "Regulation alerts" panel. +10 tests (61 tax/bookkeeping tests pass), tsc 0 / eslint 0. Go-live trigger (not dev work): wire the deadline list into the notification/cron system for push reminders, and optionally a budget-capped AI agent that drafts new regulation notices from public sources for human review. Next: Slice F (TC-6 accountant collaboration + audit center).
- Phase 19 — Tax & Compliance Assistant · Slice D: missing-data detection + risk & completeness scoring (TC-4). New pure
lib/bookkeeping/data-quality.ts(assessDataQuality) scans the books + tenant settings for gaps that would make a report/VAT-return wrong and warns before the user generates or exports — it never blocks (the user stays responsible, TC-0). Findings: inconsistent VAT (some paid sales without VAT while others have it → critical) vs no-VAT-on-any-sales (warning — could be legitimately exempt), sales at an unrecognised rate, missing organisationsnummer / VAT number, unpaid orders excluded from the cash-basis books, sales-but-no-expenses, multi-month period gaps, and empty periods. Each finding carries a severity; the weighted total → a completeness % → the TC-0 confidence band (reusesconfidenceFromCompleteness) → a risk level (low/moderate/high/critical). Wired additively intoGET /api/tenant/[tenantId]/bookkeeping(dataQualityin JSON) and rendered as a risk/completeness banner above the bookkeeping summary, so warnings appear before the Export CSV / SIE buttons. +9 tests (51 tax/bookkeeping tests pass), tsc 0 / eslint 0. Next: Slice E (TC-5 VAT/PAYE deadline monitor + regulation-watch).
- Phase 19 — Tax & Compliance Assistant · Slice C: VAT-return (momsdeklaration) prep + SIE export — MVP TC-0…TC-3 complete. Two new pure modules close the liability-safe MVP.
lib/bookkeeping/vat-return.ts(buildVatReturn) maps the books to Sweden's momsdeklaration boxes — 05 taxable sales net, 10/11/12 output VAT at 25/12/6%, 48 input VAT, 49 = (10+11+12) − 48 — rounded to whole SEK, withpayable/refund and reviewFlags for everything we deliberately DON'T derive (reverse charge, EU/imports, self-supply, exempt sales, any unrecognised rate).lib/bookkeeping/sie.ts(buildSie4) emits a valid SIE type-4 file (Sweden's universal accounting-interchange format) — header (#FLAGGA/#PROGRAM/#GEN/#SIETYP 4/#ORGNR/#FNAMN/#RAR/#KONTO) + one balanced double-entry verification per paid row (debit bank / credit sales + output VAT for sales; mirror for purchases), using an overridable BAS-aligned default account map. Both wired intoGET /api/tenant/[tenantId]/bookkeeping(additivevatReturnin JSON; gated?format=siedownload) and the Bookkeeping page (a momsdeklaration box table + review flags, and an SIE download button beside Export CSV — both behind the TC-0 acceptance + review gate). TC-0 honesty held: the VAT return is a labeled DRAFT (Restora never files), the SIE account map must be confirmed, and SIE (no comment syntax) carries the disclaimer on a response header + the UI. +17 tests (42 tax/bookkeeping tests pass), tsc 0 / eslint 0. Phase 19 MVP (TC-0 legal spine → TC-1 books → TC-2 VAT engine → TC-3 P&L + VAT-return + SIE) is build-complete on seed; remaining TC-4…TC-7 (missing-data/risk, deadline monitor + regulation-watch, accountant collaboration, country expansion) are the next slices.
- Phase 19 — Tax & Compliance Assistant · Slice B: income statement (P&L) + chart-of-accounts-lite + sales-by-VAT-rate (TC-1/TC-3). New pure
lib/bookkeeping/pnl.ts(buildIncomeStatement) reuses the bookkeeping aggregation and the Slice-A VAT engine to: split paid sales into VAT-rate buckets read from the VAT actually charged (not assumed); flag any bucket whose rate the jurisdiction's module doesn't list for the period (a review/risk signal feeding TC-4); compute a lite P&L (revenue net → COGS proxy → gross profit → net result); and emit a neutral chart-of-accounts-lite. Carries an honestcoverageblock stating wages/rent/overhead are NOT included, so it's never mistaken for a complete income statement (TC-0). Wired intoGET /api/tenant/[tenantId]/bookkeeping(additiveincomeStatement, country fromTenantSettings.countryCode, default SE) and rendered on the Bookkeeping page as an Income-statement panel + a Sales-by-VAT-rate table (with a "review" badge on unrecognised rates). +8 tests (31 tax/bookkeeping tests pass), tsc 0 / eslint 0. Next: Slice C (VAT-return prep + SIE export).
- Phase 19 — Tax & Compliance Assistant · Slice A: deterministic SE VAT rules engine (TC-2 core). New
lib/tax/rules.ts— a per-country, category-aware, date-effective, sourced VAT engine (resolveVat({ country, category, date })) for tax PREPARATION, replacing flat rate constants. It models Sweden's reduced-rate structure including the temporary food/takeaway cut from 12% → 6% (1 Apr 2026 – 31 Dec 2027, reverting to 12%), while restaurant/dine-in stays 12% and alcohol/tap-water stay 25%. Rules are data-driven + versioned + dated + sourced, so a rate change is a data edit (never core code), and a country registry (VAT_MODULES) makes adding a jurisdiction additive (TC-7 framework). Unknown country / invalid date →supported:false(never a guessed rate); a known country with no dated rule for a category falls back to standard, flagged for review (feeds the TC-0 confidence model). Used by bookkeeping / VAT-return prep + reports only — live POS/checkout pricing is unchanged (webshop/tax.ts), and a consistency-guard test ties the engine's SE standard rate to that live table so the two can't silently drift. All figures stay accountant-ready DRAFTS under the TC-0 legal gate; rates require tax-professional confirmation before filing. +10 tests (22 tax/bookkeeping tests pass), tsc 0. Next: Slice B (P&L + chart-of-accounts-lite), Slice C (VAT-return prep + SIE export).
- Phase 16 — Growth Command Center: COMPLETE on seed (GI-0 → GI-8). The super-admin "virtual COO/CMO" is now fully built on the build-with-seed posture. Every Growth Command Center module that has a real platform source is live: Growth KPIs + channel drill + Direct Joins + funnel + real MRR forecast (GI-7) + AI advisor (AI-light, budget-gated), plus AI Budget, Suppliers, Food Social, WoW & Health, PoS Revenue, Food Bazar GMV, Feedback, and Hiring. Acquisition is captured forward at signup (GI-0) and frozen per tenant; analytics/attribution are consent-gated (banner + DNT/GPC); the heavy per-tenant order/review scan runs off-request in the order-rollup cron; the dashboard exports to CSV (GI-8). The honesty rule held throughout — the notional 1% PoS take is shown as a labeled rate (never revenue), NPS shows "—" (no survey source), and the only seed-tagged module is Waste & Recycling (no platform data source exists) alongside the representative leaderboards/briefing sections. Remaining items are go-live/scale TRIGGERS, not dev work: schedule the order-rollup cron in prod (run-once in dev); actual fee settlement for PoS/Bazar/supplier commissions (Stripe Connect, Phase 39+); a real NPS/CSAT survey source for live NPS; a waste-collection integration for the Waste module; deepened live leaderboards (GI-5); and a Postgres events table — explicitly volume-gated (no warehouse early). Cumulative this session: PRs #4–#14. 84 growth/consent tests, tsc 0 / eslint 0.
- Phase 16 — GI-8: Growth Command Center CSV export.
GET /api/admin/growth?format=csv(super-admin gated, same data the dashboard renders) returns a multi-section CSV — Growth KPIs, channel economics (MRR/tenants/ROI/CAC/LTV, with unknowns as "—"), and every module's KPIs — via a pure, RFC-4180-escapedbuildGrowthCsv(lib/growth/csv.ts, +2 tests). An Export CSV button sits in the Command Center top bar. tsc 0 / eslint 0.
- Phase 16 — live Hiring module (from the Food Stories Network). The Hiring module is now real, built from
getFsnDB()hiring data: open roles (FsnJobstatus open), applicants (FsnJobApplication), placements (HireEvent), active employers (distinct hiring tenants), and open-to-work talent (viacomputeWedgeMetrics). panelA is the real ATS pipeline (applied → screening → interview → offer → hired → rejected); panelB the application-outcome split; the list ranks top hiring employers (names resolved from the tenant registry). PurebuildHiringModuleinlib/growth/modules-live.ts(+1 test). With this, the only seed-tagged Growth modules left are Waste & Recycling (honestly — no platform data source exists; the inventorywasteReasonis internal spoilage, not external collection) and the representative leaderboards/briefing/advisor sections. tsc 0 / eslint 0.
- Phase 16 — live PoS Revenue, Food Bazar GMV & Feedback via a pre-aggregated order rollup (closes the per-tenant-scan gap). A new
/api/cron/order-rollupscans every non-demo tenant's orders + reviews ONCE (off the dashboard request path,CRON_SECRET-gated, per-tenant try/catch) and stores a fixed-size aggregate onMasterDB.platformOrderMetrics; the Growth Command Center reads it to live-wire three previously-seed modules — PoS Revenue (real transaction volume / count / avg ticket / by-order-type / top restaurants), Food Bazar GMV (real marketplace GMV/orders/avg + configured take rate, identified byOrder.bazarUserId), and Feedback (real review responses / avg rating / sentiment + rating distribution). Pure aggregator + module builders inlib/growth/rollup.ts. Honesty held: the platform's "1% PoS take" is notional (never billed) → PoS shows it as a labeled rate ("1% · modeled · not billed"), never a fabricated kr revenue; NPS has no survey source → shows "—" (real avg-rating/sentiment from tenant reviews instead). An adversarial review caught + fixed two real defects: currency scoping was a dead no-op (orders carry no currency → now scoped at the tenant level viaTenantSettings.currency), and hidden/unapproved reviews were counted in the platform rating (now excluded, matching the moderation schema); refunded orders are excluded too. Modules fall back to seed when the rollup hasn't run yet. +8 rollup tests, tsc 0 / eslint 0.
- Phase 16 — GDPR erasure wired into tenant deletion. The
deleteEventsForhelper (lib/growth/events.ts) is now actually invoked when a data subject is deleted: the super-admin tenant delete (DELETE /api/admin/tenants/[tenantId]) and the expired-demo cleanup cron both drop the deleted tenant'sgrowthEventsfrom MasterDB in the same mutation that removes the tenant. Closes the Phase 16 follow-up where the erasure helper existed but was unwired. tsc 0 / eslint 0 (helper already covered by 6 events tests).
- Phase 16 — GI-7: real MRR forecast history (drops the forecast "sample" tag). The Growth Command Center's MRR-forecast panel previously drew a fabricated history (8 seed points + 1 live anchor, sample-tagged). It now plots a real point-in-time monthly MRR series — a new pure
buildMrrHistoryinlib/growth/metrics.tswalks each of the last 9 months and sumsmonthlyValuefor subscriptions that had started by that month and weren't yet canceled (display-currency only, trialing/incomplete excluded), plus the current add-on MRR as a flat overlay so the latest point reconciles with the live MRR KPI.snapshot.tsexposes it (mrrHistory), the route serves it asforecastHistwithforecastLive:true, and the panel is sample-tagged ONLY if the snapshot fails (seed fallback). The future projection remains a clearly-labeled estimate; the SVG already guards a flat/zero series (no NaN on low-data dev). +3buildMrrHistorytests (78 growth/consent tests pass), tsc 0 / eslint 0.
- Cookie-consent banner + consent-gated analytics (GDPR/ePrivacy). Delivers the consent primitive the Cookie Policy promised. A global
ConsentBanner(components/consent/ConsentBanner.tsx, mounted inlayout.tsx) lets visitors Accept all / Essential only; the choice is stored in a first-party, non-httpOnlycookie_consentcookie readable both server- and client-side (lib/consent/consent.ts, pure + tested). All NON-essential marketing/attribution storage is now gated on explicit consent: the GI-0acqacquisition cookie only fires withcookie_consent=all— enforced on the server (/api/public/track-acquisitionis the source of truth) AND the client beacon, and the capture re-runs via aCONSENT_EVENTif consent is granted after landing (same-page first-touch preserved). Honors both Do-Not-Track and Global Privacy Control (GPC) — the banner stays hidden and nothing is captured under either signal (the legally-recognized opt-out successors). The banner is hydration-safe (renders null until mounted), keyboard-accessible (role="region", real<button>s), and links to/legal/cookies. A focused adversarial review confirmed no consent-bypass foracq(forged cookie values fail the'all'allowlist) and drove the GPC support + a guard-on-success retry. +4 consent tests (75 growth/consent tests pass), tsc 0 / eslint 0. Follow-up: the older sales-agent `RefCapture` still writes a forensic ReferralEvent (IP/UA) unconditionally for fraud detection — review whether to gate/de-PII it for consistency (separate, referral-system scope).
- Phase 16 — Growth Command Center: forward first-touch acquisition capture (GI-0 plumbing). Turns the channel attribution from derived into real captured data for new signups. A client beacon
components/growth/AcquisitionCapture.tsx(mounted globally inlayout.tsxbesideRefCapture) reads the UTM params + an EXTERNALdocument.referreron first landing and POSTs them to a new public, rate-limitedPOST /api/public/track-acquisition, which sets a first-touch httpOnly `acq` cookie — PII-minimized (lib/growth/acquisition.ts: referrer reduced to a HOST only, every field length-bounded, nothing stored server-side). At signup,/api/auth/registerclassifies the signals (classifyAcquisition) and freezes the channel onto a newTenantRecord.acquisition(TenantAcquisition— channel + utm + referrer host + firstTouch, best-effort so it never blocks registration);lib/growth/snapshot.tsnow prefers the frozen channel, falling back to the ledger derivation for tenants that predate capture (backward compatible). A 3-lens adversarial review (security/GDPR · correctness · integration, 14 agents) confirmed 2 findings, both fixed: (1) the marketingacqcookie set without honoring Do-Not-Track (inconsistent with the sibling/api/track) → now respects DNT in both the route and the client, with a TODO to route both this and RefCapture through the consent banner the Cookie Policy promises (a platform-wide gap, not yet built); (2) a blocked self-referral leaked the agent's own code intoacquisition.refCodeand misclassified asreferral→ the register block no longer feeds the sales-agentREF_COOKIEas a generic partner code (agent attribution already setssalesAgentId→sales-agent), so a blocked self-referral correctly falls to direct/UTM/referrer. +5 acquisition tests (67 growth tests pass), tsc 0 / eslint 0. Go-live: gate on explicit marketing consent once the consent primitive exists.
- Landing page — locked Hero A (Editorial) as the homepage hero. After comparing the two directions, removed the A/B compare switcher (a design-preview affordance), deleted the Hero B (command-center) variant + its dashboard mock, and locked in Hero A (food-forward editorial). Drops the fixed bottom-center toggle, its footer clearance, and the now-unused
Starimport /herostate — Hero A stays a client component for the interactive order demo. tsc/eslint 0.
- Landing page — mobile polish (hamburger menu + lighter images). Adds the mobile menu the makeover was missing: below 900px the section links + "Log in" collapse into a hamburger toggle (
LandingNavis now a client component witharia-expanded/aria-label; menu closes on selection), while the "Start free trial" CTA stays visible for conversion. Also halved the landing food art (~2.9 MB → ~1.5 MB) by resizing the source PNGs to 900px max (next/image still serves optimized webp at runtime). tsc/eslint 0. Closes the two follow-ups flagged with the landing makeover.
- Public landing page makeover — the new Restora 360 homepage (ported 1:1 from the design prototype). Replaces the homepage (
/) with the warm Scandinavian-editorial "website makeover" design (docs/Restora 360 official website makeover/Restora 360 Landing.dc.html), built as React/Next components undercomponents/landing-v2/and composed inapp/page.tsx(SEO JsonLd preserved). Scoped_dsdesign tokens (clay/stone/pine/honey/denim, warm shadows, Hanken/Newsreader/Geist-Mono) live in alanding-theme.cssbound to.r360-landingso nothing leaks into the rest of the app. Sections (prototype order): sticky nav · A/B hero switcher (Editorial vs Command-center, kept as an in-page toggle per the owner) with an interactive 4-step "push an order through" demo · value reframe · 6-feature grid · owner-dashboard mock · how-it-works · 7-app ecosystem grid · Food Bazar band · for-agencies · 199 kr / 7-day-trial pricing · FAQ (native<details>) · final CTA · footer. CTAs wired to real routes (/register,/login,/wow,/designers/register) vianext/link; lucide-react icons;next/imagefor food art (SVG viaunoptimized). Fully responsive (desktop grids collapse at 980/900/760/640/560/460px). Built foundation + hero by hand, then fanned the 11 static sections out to a parallel agent workflow; a 3-lens / verifier adversarial review (17 agents) confirmed 8 findings, 6 fixed (hero-switcher footer-overlap clearance, ProductDashboard inner-mock mobile collapse, mobile side-gutter relief, Hero-B outline stars, WCAG-AA contrast for muted/subtle text, demoaria-livestatus) — the 2 "remove the switcher" findings were reconciled by keeping the switcher (owner's choice) and fixing its overlap instead. tsc 0 / eslint 0. The previous landing components (components/landing/*) stay — still used by/contact,/help,/ingredient, etc. Follow-ups: a mobile hamburger for the in-page section anchors (hidden < 900px today); compress the food PNGs (~3 MB source, runtime-optimized by next/image).
- Phase 16 — Growth Command Center: live-wire 5 more modules from their real stores (follow-up). Replaces the representative seed with LIVE data in the modules where a cheap, real source already exists — the build-with-seed → live graduation. A new pure builder layer
lib/growth/modules-live.ts(lean inputs, decoupled from the DB schema, unit-tested) turns real platform data into the dashboard's module shape, wired intoGET /api/admin/growthas independent per-module try/catch overlays (one failing source can never blank the others; a module with no real data keeps its build-with-seed tag). Now live: AI Budget (←buildAIUsageAnalytics()— real USD spend MTD, active tenants, over-budget count, tokens, spend-by-feature, top-consumer scorecard; honestly in USD, the cost-law currency, not kr), Suppliers (←getSuppliersDB()— GMV + 1% revenue, active suppliers, fulfilment rate, category mix, per-supplier scorecard), Food Social (←getFsnDB()— accounts, posts, engagement mix, most-followed profiles), WoW & Health (←getMasterDB().wowPreviews+ the live rules-first health distribution — preview→live claim funnel + health donut + recent migrations), and Food Bazar (←getFoodBazarDB()— listings, ad campaigns/spend, take rate). PoS Revenue, Waste, Feedback, Hiring stay seed-tagged on purpose — PoS needs a per-tenant order scan with no global index (kept honest rather than faked), and the others have no cheap source yet. A 3-lens / verifier adversarial review (19 agents) confirmed 9 findings, all fixed + re-verified, every one a data-honesty defect on this "virtual COO" surface: the Suppliers "On-time rate" was fabricated (it measured the mere presence of adeliveredAtstamp — structurally ~100%, no promised-date baseline exists) → replaced with a genuinely-derivable "Fulfilled" rate (delivered ÷ completed, the codebase's reputation convention); the supplier volume map keyed by company name (so two same-named suppliers merged GMV) → re-keyed by id, names resolved only at render; "Active suppliers" silently fell back to the full listed count when there were zero orders (a directory count shown as activity) → now the true transacting count (honest 0); GMV summed mixed-currency order totals under a hard-coded "kr" → scoped to the platform currency with a currency-aware formatter; GMV/active counted cancelled/refunded orders → excluded (revenue-bearing only); the MRR-forecast panel drew 8 fabricated historical points as a live trend with no marker → now sample-tagged ("history sample · now live" — only the now-anchor is live); a posts-only FsnDB could throw and silently downgrade the live module to seed → null-safe; and a redundantgetAISettings()round-trip was removed (the analytics already returns the budget). +10 module-builder tests (each finding regression-locked; 63 growth tests pass), tsc 0 / eslint 0. Remaining Phase 16 follow-ups unchanged: forward UTM capture at signup, wire the GDPR erasure helper into the platform data-subject delete, GI-7 forecast depth (a real MRR history series), GI-8 export/scale; a Postgres events table only on volume.
- Phase 16 — Growth Command Center: the full design-faithful build (11-module super-admin app, matching the prototype 1:1). Builds on the GI engine (below) into the complete "virtual COO/CMO" the prompt +
Growth Command Center.dc.htmlprototype specify. A full-screen super-admin experience at/admin/growth(its own dark espresso sidebar + 11-module switcher, sticky serif top bar, warm light/dark theme toggle) gated on the admin session — deliberately outside the admin dashboard chrome. The `_ds` design tokens (clay/stone/pine/honey/denim, warm shadows, Hanken/Newsreader/Geist-Mono) are ported into a scopedgrowth-theme.css(.gc-root, with the prototype's warm dark map) so it can't leak into the rest of the app. Growth Command module (1:1): an AI executive-briefing banner, 5 KPI tiles, an interactive revenue drill-down (Channel/Country/Industry → click a segment → its children + a 6-metric panel (MRR/Tenants/ROI/CAC/LTV/Churn) + breadcrumb/Back), an MRR forecast SVG with a 7/30/90 horizon toggle, an acquisition funnel, the AI Growth advisor (insight cards + chat + suggestion chips), and sales/web-designer leaderboards. The 10 other modules (PoS Revenue · AI Budget · Direct Joins · Food Bazar · Food Social · WoW & Health · Suppliers · Waste & Recycling · Feedback · Hiring) share one template (4 KPIs + a bar panel + a donut/bars panel + a list with status badges). Live where a source exists, build-with-seed elsewhere (the owner's call): the API (GET /api/admin/growth) deep-clones the representative dataset (lib/growth/seed.ts, ported from the prototype) and overlays real numbers from the GI engine + a sharedlib/growth/snapshot.ts— Growth KPIs, the channel drill, Direct Joins, and WoW tenant-health (from a new rules-firstlib/growth/health.ts); modules without a source yet render the seed, flaggedlive:falsewith an honest "representative data" note. Net-new spine:POST /api/track— a public, consent-gated + DNT-respecting + PII-minimized + capped growth-event ingestion route (lib/growth/events.ts; no raw IP, coarse country only, sanitized scalar props) writingMasterDB.growthEvents; andPOST /api/admin/growth/agent/chat— the budget-gated, AI-light advisor (every number computed in TS; the LLM only narrates the supplied aggregates; a deterministic templated answer when no key — never invents figures). A 4-lens / 2-verifier adversarial review (21 agents) confirmed 10 findings, all fixed — the headline being data honesty on a "virtual COO" surface: the channel drill is now rebuilt 1:1 from real per-channel economics (was double-mapping sales-agent into two segments and dropping the referral channel) with unknown ROI/CAC/LTV shown as "—" not fabricated; the funnel is a single coherent live lifecycle (was mixing 2 live stages into a seed funnel with stale %); every genuinely-representative section (country/industry drill, sub-segment breakdown, briefing, advisor cards, leaderboards) now carries an honest "sample" tag (the global note was suppressed once any live data existed); the forecast "now" point is anchored to live MRR (was diverging from the KPI); the churn KPI is relabeled "Cancel rate (all-time)"; Direct-Joins % uses a non-demo denominator; andgrowthEventsgains age-based retention + a subject-erasure helper (GDPR). The whole module is now responsive (collapsing grids + sidebar at ≤980/560px). +22 engine tests (health/events + the GI suite; 577 total pass), eslint 0, source typechecks. Follow-ups: live-wire the remaining modules (AI Budget from the usage ledger, Food Bazar/PoS/Suppliers/Social from their stores) + forward UTM capture at signup + wire the erasure helper into the platform data-subject delete; a Postgres events table only on volume.
- Phase 16 — Growth Command Center: the MVP (GI-0 channels + GI-1 attribution + GI-2 executive dashboard), build-with-seed. The super-admin "virtual COO/CMO" — where customers come from, the CEO numbers, and channel ROI in one place. Net-new spine, framework-free + tested, with everything else reused, nothing duplicated. GI-0 (
lib/growth/channels.ts): a canonical 13-channel taxonomy + a deterministicclassifyAcquisitionthat DERIVES each customer's channel from signals the platform already stores (sales-agent code → agents, web-designer link → designers, demo flag, and UTM/referrer viaclassifyUtm/classifyReferrerwhen present) — so the dashboards populate from existing data today, with the classifier already wired to consume real UTM the moment forward capture is added. GI-1 (lib/growth/attribution.ts): a real attribution engine — first / last / linear / time-decay / position multi-touch credit over each customer's journey, aggregated per channel (revenue + customers) and explicitly labeled a modeled estimate; deterministic (time-decay references the journey's own last touch, no clock). GI-2 (lib/growth/metrics.ts+GET /api/admin/growth+/admin/dashboard/growth): MRR/ARR (base plans ÷12 + add-ons), paid/active/trial/demo customers, trial→paid conversion, monthly churn, signups + month-over-month growth, a 12-month signups/revenue trend, and per-channel economics — CAC / LTV / LTV:CAC / ROI with the real agent CAC computed from the commission ledger and an honest "—" wherever spend isn't tracked yet (never a fabricated number). The dashboard has a live attribution-model selector and reuses the existing cookieless visitor analytics for a top-of-funnel traffic panel. GDPR-first + cheap by construction: super-admin-gated, aggregates only (no raw PII), deterministic math (no AI spend in this MVP), and no new tracking added — it's a synthesis layer over existing revenue/commission/visitor data, distinct from/admin/dashboard/analytics. A 4-lens / 2-verifier adversarial review (24 agents) confirmed 11 findings, all fixed: channel attribution now derives agent/designer customers from the referral/commission ledger + designer links (the canonical FKs are unset on seed, so the table was collapsing to one "Direct" row and stranding the real agent CAC); money is summed only within the display currency (no silent USD+SEK mixing); month bucketing is UTC (matches stored timestamps);classifyUtmanchors AI tokens (so "email"/"dubai" no longer route to AI-search); the touch sort is transitive with undated touches; agent CAC nets clawbacks; and the labels are honest ("Signup → paid" not "Trial → paid", "Churn (all-time)" with the monthly count beside it, MRR scoped "base + add-ons"); a model-switch refetch failure no longer wipes the dashboard. 41 engine tests (channels/attribution/metrics), tsc 0 / eslint 0. Follow-ups: forward UTM capture at signup (GI-0 plumbing), funnels + health/churn + AI Growth Agent + forecasting + export (GI-3…GI-8); a dedicated event store / warehouse stays scale-gated (Postgres-first, no warehouse early). - Phase 28 — Food Business Launchpad: the post-graduation founder bridge (closing the journey loop). Turns the funnel's bare "claimed" confirmation into a real "you're live" hub — the journey's final "…→ customers → growth" handoff. After a founder claims their storefront, the O3 result now shows, alongside "Log in to my dashboard", three next-step cards that deep-link to surfaces the platform already ships: their growth plan (
/launchpad/grow), the Learning Center (/launchpad/learn), and List on Food Bazar (/dashboard/food-bazar), plus the honest milestone framing (launched → first customers → growing — the G4→G6 CFJ stages). It's a thin bridge, not new surface — every link points at a route that already exists (all four verified live: no dead links) and the copy carries no legal/tax fact, no advice, no guarantee (trust seam intact). The funnel's static internal/launchpad/*navigation also moved ontonext/link(resolving theno-html-link-for-pageslint the prior LC link tripped). tsc/eslint 0, launchpad suite 156, build EXIT 0. With this, Phase 28's buildable-on-seed scope is complete; the remaining items (counsel sign-off, conversion/retention measurement, +country/+type breadth, canonical RAOS) are go-live/trigger gates, not dev work.
- Phase 28 — Food Business Launchpad: Learning Center expansion (foundational tracks). Grows the Learning Center beyond the node-bound wedge how-tos into the doc-10 track structure. A new
FBL_TRACKS+FBL_FOUNDATIONAL_LESSONSinlib/launchpad/learning.tsadd general beginner lessons across Money & pricing · Getting & keeping customers · Running the day-to-day · Safe food basics — concept lessons that teach a skill, deep-link to the live tool that does it (business plan, growth plan, offers, reviews, social, bookkeeping), and — for anything regulated — point the founder to THEIR roadmap for the actual rules (seeYourRules). The trust seam is preserved: foundational lessons carry no CKG node and state NO jurisdiction-specific legal/tax fact (no rates, thresholds, deadlines — a test enforces it), are advice-clean (findAdviceLanguage), and can never enter the SEO sitemap/llms.txt (those still surface only verified, node-bound lessons). New/launchpad/learnindex lists every track + lesson; the lesson page now renders foundational (node-less) lessons (tool link + roadmap pointer) alongside the existing live-cited node-bound ones; reachable via a "Learning Center →" link from the funnel. 2-lens adversarial review caught a real major — a foundational pricing lesson asserted a tax conclusion ("a sale price usually includes VAT") — fixed to defer entirely to the roadmap, plus: repointed two lessons off a then-unbuilt route, made the route-validity test read the real/launchpad+/dashboarddirs (no hardcoded list → no dead bridges), softened an imperative, and added aseeYourRulesinvariant test. 11 learning tests, full launchpad suite 150, tsc/eslint 0, build EXIT 0.
- Phase 28 — Food Business Launchpad: the post-launch GROWTH playbook (the "…→ customers → growth" stage). Completes the founder journey's final stage on seed. A new
lib/launchpad/grow.tsbuildGrowthPlanproduces a deterministic, beginner-friendly growth path in 4 ordered stages (Launch → get first customers · Turn first orders into regulars · Get found locally · Scale up), each step deep-linking into the live Restora tool that does it (menu, builder, offers, QR, reviews, loyalty, email, CRM, Google Business, social, SEO, Food Bazar, analytics, delivery, hiring, + tables/reservations). It's a thin bridge, not a second growth engine — every step points at a tool the platform already ships (no duplication of Growth Intelligence). Lightly tailored per business type (restaurant/café → table ordering + reservations; food-truck → "tell people where you'll be"; home-bakery → online orders first). NewGET /api/launchpad/grow?token=(deterministic, rate-limited, not TC-gated — it serves growth guidance, never a legal/tax fact) + a/launchpad/growpage (GrowthPlanClient) with per-step done-ticks (localStorage), reached from the funnel's "Your growth plan →" link. Honest by construction: practical suggestions, never guarantees — a test rejects any guarantee/fabricated-stat language. 2-lens adversarial review (honesty/anti-duplication + correctness) found only minors, all applied: hedged an unsourced "most local discovery…" stat, gave each step a stable id (progress keys on it, not array index — matching the funnel's identity scheme), and the route-validity test now reads the real dashboard route directory (so a deleted/renamed tool fails the test instead of shipping a dead bridge). 6 grow tests + launchpad suite 151, tsc/eslint 0, build EXIT 0.
- Phase 28 — Food Business Launchpad: the reviewer / counsel VERIFICATION CONSOLE (retire the launch blocker). Makes the human-verification + counsel sign-off — FBL's one real go-live gate — fast and safe. (1) Bulk verify / reject a cell as a set:
POST /api/admin/launchpad/nodes {action:'bulk-verify'|'bulk-reject', nodeIds[]}applies the EXACT same per-node gate as single-verify (now a sharedckg.tsverifyBlockReasonhelper — cited + advice-clean + not-already-served, so the rule can't drift between paths), under one row lock, MON-logging each change and returning a per-node result; bulk-reject can never delete a verified/served node. (2) A per-node researcher briefing (verification.tsnodeBriefing): the exact official pages to confirm each node against — its own citations plus same-authority cell pages (matched by normalised authority head, so a tax node never surfaces a labour page). (3) A downloadable, hand-to-counsel verification packet (counselPacketMarkdown): the cell, the machine pre-checks, and per node the claim + verify-against list + confirm checklist + a sign-off line — markdown-escaped so AI-drafted node text can't forge a section, and it never asserts a legal fact or a clearance. The admin Launchpad page gains a "Verify all cited & clean" button + a "Download counsel packet (.md)". 2-lens adversarial review confirmed the trust gate is sound (no gate-equivalence break, no verified-node deletion, super-admin gated); its findings were applied — the sharedverifyBlockReason(DRY + tested),discoverSourcesde-duped by URL, the briefing match tightened to authority-head equality, the packet markdown-escaped, and the bulk audit log now records per-node verified/rejected ids. 21 verification/registry/ckg tests + launchpad suite 129, tsc/eslint 0, build EXIT 0. The verification + counsel sign-off itself remains the human go-live gate; this only makes it efficient.
- Phase 28 — Food Business Launchpad: the FULL business plan + investor pitch deck (doc 08 BP-5/BP-6). Upgrades the plan-lite draft into a complete, audience-aware
BusinessPlan(lib/launchpad/plan.ts): typed sections (exec summary · company · market · SWOT · operations · compliance · startup costs · budget · revenue forecast · funding · milestones · risk · appendix), re-ordered + re-emphasised per AUDIENCE (self / bank / investor) and specialised per business type (home-bakery / café / food-truck / restaurant), plus a deterministic pitch-deck projection (buildPitchDeck— 10–12 slides that cannot disagree with the plan, with a mandatory non-removable TC-0 disclaimer slide). Exports to Word (.doc) and Markdown; the deck prints to PDF.GET /api/launchpad/plannow takes?audience=self|bank|investor; the/launchpad/planpage gains an audience selector, a Plan⇄Pitch-deck toggle, and Word/.md/Print buttons. Deterministic by law, never fabricated (doc 08 §1/§7/§8.1): every number is a founder input or a pure calculator result carrying abasis; market size/competitors are an explicit founder-research blank (never generated); compliance lists only VERIFIED roadmap steps; gaps render "Verification required"; the TC-0 disclaimer + the §9.2 export furniture ("projections are estimates, not guarantees" · "verification required must be confirmed") are on every export. 2-lens adversarial review caught a real major — legal-readiness "X/Y done" counted optional steps in the numerator but not the denominator, so a ticked optional step could read as "fully compliant" (or >100%) on a bank/investor document — fixed (required-only on both sides) + regression-tested; plus: revenue forecast now shows low–high bands not false-precision points, the registration fee shows "Verify with the authority" instead of a fabricated 0–1000 band, the §9.2 export furniture block was added, andplanToMarkdownnow escapes founder input. The OOXML §16 open-decision is resolved dependency-light (Word-openable HTML.doc+ print-HTML deck — no heavy OOXML lib). 16 plan tests, full launchpad suite 138 tests, tsc/eslint 0, build EXIT 0. Deferred (go-live/expansion): the fact-fenced AI narration layer (BP-3), native .docx/.pptx, the landlord/grant audiences, and living-plan regeneration.
- Phase 30 — Restora Experience Studio: P7b — the public Experience API + asset-library scale-out (Phase 30 100% on seed). Closes Phase 30. A new
POST /api/public/experience-studio/v1/generatelets a tenant generate experiences from their own stack / CI / headless site builder: key-auth via a newlib/experience-studio/api-guard.tsauthenticateExperienceRequestthat reuses the existing `rck_…` Restora API keys (one key per tenant works across the Catalog + Experience APIs — each endpoint gates on its own add-on), confirms the tenant still holdsexperience-studio, and applies the per-key rate limit (a dedicatedexp:120/min window, throttled before the feature-gate DB read so an unentitled key can't spam free 403s). Crucially, both the public API and the in-dashboard one-click flow now run a single shared engine — a newlib/experience-studio/generate-core.tsrunTenantExperienceextracted from the tenant route (which was refactored onto it) — so quota (atomic reserve + compensating refund), the Food-DNA AI-budget lane, brand-kit resolution, compose, and WOW score are defined once, not duplicated. Scale-out (read): a new pureassets.tsfilterAndPageComponents(filter by cuisine/category/status + free-text, codepoint-deterministic id sort, NaN-safe clamped limit/offset, pre-page total) backs an optional?cuisine=&category=&status=&q=&limit=&offset=on the admin library GET — value-based param detection (an empty?cuisine=/?limit=is treated as unset, never a silent truncation), full library when no params; the full normalized-store/CDN offload stays volume-gated (same posture as the Catalog's P8–P10). 2-lens adversarial review confirmed the refactor preserves every guarantee (quota/refund/published-only/IP gate/status codes) and the tenant binding is sound (tenant comes from the stored key record, never request input); the DoS-amplification + empty-param + sort-determinism hardenings above were applied from it. 61 experience-studio tests (+2), tsc 0 / eslint 0, build EXIT 0. Phase 30 is now 100% built & delivered on seed (P0 → P7b) — the remaining items are go-live/expansion gates (real curated art + measured 3D weights, the paying-pilot cuisine expansion, the live SEO scorer, the real Stripe overage charge, the volume-gated CDN offload), not dev work.
- Phase 30 — Restora Experience Studio: P7a — brand kits + franchise consistency + white-label. The enterprise layer. A new
lib/experience-studio/brand-kit.tsresolveExperienceSettings(pure) merges REQUEST → a location's own kit → the org/franchise kit → defaults — so a franchise sets one org brand kit and every branch inherits a consistent look at generate time, with no propagation/multi-write (a location can still override).GET/PUT /api/tenant/[tenantId]/experience-studio/brand-kit:scope:'tenant'saves the location kit (owner);scope:'org'saves the franchise kit and is additionally gated byrequireGroupAccess(group owner/manager only) and the HQ holding Product-B — so one branch can never overwrite another org's look. White-label (a kit flag) drops the "Powered by Restora 360" mark in the renderer. The Studio page gains Save-as-brand-kit / Save-for-all-locations + a white-label toggle, and prefills from the saved kit. 2-lens adversarial review confirmed the franchise authorization is sound; it caught a real major —resolveExperienceSettingsreturns full copy objects withundefinedfor unset fields, and the compositor's{...defaultCopy, ...opts.copy}spread let thoseundefineds overwrite the template defaults (empty headline in the common case) — fixed by adefinedOnlyfilter in the compositor (drops undefined keys before the merge) + a regression test, plus the HQ Product-B gate and a richer org audit entry. 59 experience-studio tests, tsc/eslint 0, build EXIT 0. The public Experience API + asset-library scale-out are P7b.
- Phase 30 — Restora Experience Studio: P6 — expand (a 2nd cuisine + the 3D mode, proven purely additive). Demonstrates the architecture is additive by design: a Pizza Journey experience (one
EXPERIENCE_PACKSrow) +PIZZA_SEED_COMPONENTS(crust→basil) makes a whole new cuisine work with zero engine changes — the compositor, WOW scorer, Food-DNA matcher, Experience-DNA selector, and renderer are all registry/library-driven (no hardcoded cuisine). The 3D mode lands the same way: the pizza components carry a3dglb artifact, so a3drequest now resolves exact for pizza while burger still gracefully falls back (the same unchangedartifactForMode/MODE_FALLBACK). Emergent correctness: the per-experience performance budget naturally fences heavy 3d off mobile (5 layers × ~800 KB > the 1.5 MB mobile budget → over-budget) — exactly the MVP-fence "3d on a performance trigger". Adversarial verification verdict clean (no cuisine cross-contamination, consistent seed data, the perf gate is intended). 54 experience-studio tests (6 new), tsc/eslint 0, build EXIT 0. Sushi / drinks / desserts + more packs (Sushi Assembly, Coffee Brewing, …) follow the identical data-only recipe on a paying-pilot trigger; real curated cuisine art + measured 3D weights at go-live.
- Phase 30 — Restora Experience Studio: P5c — the tenant Studio page + the visual renderer + pay-to-continue overage. The Product-B experience is now self-serve. A new `/dashboard/experience-studio` (owner portal) — feature-gated (a clean upsell when not entitled) — lets a restaurant set its look + paste a dish photo → Generate → see the rendered experience + its WOW score + the monthly quota. A new
components/experience-studio/ExperienceRenderer.tsxdraws the composed pack: the scene layers stacked bottom→top, each animating in per the pack's motion timeline, under the brand colours + display font with the copy/CTA overlay (build-with-seed: placeholder category-coloured slabs; a real curated image renders as<img>at go-live). A newPOST /api/tenant/[tenantId]/experience-studio/overage(pay-to-continue, doc 04 §2b) — gated, units 1–100, dev-simulates the charge and grantsoverageUnitsunder the row lock; the quota bar surfaces "Buy N (+SEK)" when exhausted. 2-lens adversarial review caught a real major — the prod overage path opened a Stripe checkout the central webhook doesn't handle (kind:'experience-overage'), so a paying tenant would get nothing — fixed by returning an honest 503 `OVERAGE_UNAVAILABLE` in prod until the real Stripe+webhook is wired at go-live (never charge for nothing), plus 3 UI fixes (a feature-denial 402 now flips to the upsell, friendly error copy, the Buy-10 price shown). tsc/eslint 0, build EXIT 0. P5 exit gate met on seed — a Product-B tenant self-serves end-to-end. Deferred: the live SEO scorer (needs the experience published as a page) + the real prod overage charge (a go-live billing task, alongside ai-topup's same webhook gap).
- Phase 30 — Restora Experience Studio: P5b — the tenant one-click Generate-Experience flow + per-tenant quota. The engine is now tenant-facing. A new
POST /api/tenant/[tenantId]/experience-studio/generate— gated byrequireTenantAccessandrequireFeature('experience-studio')(the Product-B add-on, 402 if not entitled) — optionally detects the dish from a photo (Food DNA, on the platform AI budget lane, best-effort: a missing key / over-budget / failed detection never blocks), then Experience-DNA auto-selects → composes from the shared published library (no draft leak) → WOW-scores (the IP/human-rating gate intact), returning{pack, score, detected?, quota}. A newlib/experience-studio/quota.ts(pure) — the doc-04 §2b per-tenant monthly quota (20 included + purchased overage units); the route atomically reserves a slot under the tenant row lock (no concurrent overshoot) and pauses with 402 `QUOTA_EXHAUSTED` when used up. 2-lens adversarial review confirmed the auth + feature-gate + tenant-isolation + published-only composition + honest scoring all hold; it caught a real major — a reserved quota slot was billed even when the generation threw afterward (no refund path) — fixed: the post-reservation work is wrapped in try/catch with a compensating refund (a failed generation is never billed), plus a clean 503 when the tenant doc is unavailable. 511 repo tests, tsc/eslint 0, build EXIT 0. The tenant UI + pay-to-continue PURCHASE + transparency panel + live SEO scorer are P5c.
- Phase 30 — Restora Experience Studio: P5a — Product-B tier + plan-gating + the AI budget lane. The productization begins. A
FEATURE_REGISTRY`experience-studio` add-on (the Product-B tier, 4,500 SEK/mo · 45,000 SEK/yr,requireFeature-ready for the tenant flow). A newlib/experience-studio/ai-budget.ts— the Product-B AI margin guard (doc 04 §2a): a per-UTC-day USD spend lane with a super-admin daily-cap override + kill-switch that gracefully disables all Experience Studio AI when hit; mirrors the proven FBL launchpad lane. Wired into the Food DNA route — it now returns 429 when the daily budget is exhausted and records spend after a successful call. Super-admin budget status (today's spend vs cap) + cap/kill-switch controls on the studio page (GETreturns it; aset-ai-budgetaction sets it, zod-bounded 0–1000, row-locked, audit-logged). 2-lens adversarial review caught a real major — a0cap (the intended off-switch) leaked the first call of a fresh UTC day because the no-usage short-circuit ran before the cap check — fixed (cap ≤ 0 evaluated first) + regression-tested; the read-then-record soft cap (super-admin-only, ≤1-call overshoot under concurrency, same shape as the FBL reference) is documented in the route. 510 repo tests, tsc/eslint 0, build EXIT 0. Per-tenant quota / pay-to-continue overage + the transparency panel are P5b/5c (doc 04 §2b).
- Phase 30 — Restora Experience Studio: P2 — the AI Food DNA engine. Closes the engine loop (photo → Food DNA → matched library slots → compose → WOW score).
lib/experience-studio/food-dna.ts(pure):buildFoodDnaMessages(an image-only, never-invent vision prompt — the FBL deep-research trust posture),parseFoodDna(balanced-brace JSON extraction that resumes past prose braces),vetFoodDna(clamp confidences 0–1, dedupe, cap, drop punctuation-only names; null on no-dish), andmatchDnaToComponents— maps the detected ingredients onto the published library by ingredient NAME (a same-category placeholder is never counted as covered), each component claimed at most once (no inflated coverage), with dish-alias resolution (cheeseburger→burger) and the ≥0.9 confidence gate.food-dna-runner.tsdoes the Haiku-first vision call (cost-bounded; the Product-B budget lane is P5).POST /api/admin/experience-studio/food-dna(super-admin, key-gated → 503 in dev) returns{dna, match}(matched/unmatched components + coverage %); a "Detect from photo" panel on the studio page. 2-lens adversarial review caught three real majors — the parser bailed when prose before the JSON contained braces; the category fallback over-matched (3 veggies → all "lettuce", inflated coverage); exactfoodType→cuisine equality gave 0% for "cheeseburger" — all fixed + regression-tested. SSRF confirmed absent (Anthropic fetches the image URL, not our server). 43 tests, tsc/eslint 0, build EXIT 0.
- Phase 30 — Restora Experience Studio: P4 — Experience DNA + the WOW Score scorer. The executable form of the P0.1 rubric.
lib/experience-studio/wow-score.tsscoreExperience(pack, inputs)→ 8 weighted sub-scores (sum 100) → composite + the ship gate, matching the rubric doc exactly (hard floors Performance≥75/SEO≥70/Visual≥80/Brand≥75, disqualifiers, ≥90 ship / ≥85 P4-interim targets). The six net-new dimensions are structural heuristics, each source-tagged (heuristic/override/external) so a number on placeholder art is never mistaken for verified — a human/model supplies the truth viaoverrides; performance reads the compositor's real perf verdict; SEO takes the live scorer'sseoScorewhen present.lib/experience-studio/experience-dna.tsselectExperience(signals)auto-picks pack/mode/perf-target/brand/copy (cuisine→pack, price→fidelity, audience→budget; honest default + rationale). The compose route gains an optionalsignals(Experience-DNA) input and now returns{pack, score, selection?}; a WOW-Score panel on the studio preview shows the composite, band, gate verdicts, sub-scores, and blockers. 2-lens adversarial review (cross-checked against the rubric doc) caught a real major — the IP/rights disqualifier failed open (omitted flag / empty[].every()let un-cleared placeholder art score shippable) — fixed: IP now fails closed (anything but an explicit clearance blocks), an empty-scene disqualifier was added, the route guards the empty-set case, and — strengthening the rubric's honesty intent — `shippable` (go-live) now also requires the subjective dims to be human/model-rated, not heuristic (heuristics satisfy only the P4-interim dev gate). So the seed's branded Burger Explosion honestly reports composite 91, P4-interim ✓, but NOT shippable until a human/model rates it + rights are cleared. 36 tests, tsc/eslint 0, build EXIT 0.
- Phase 30 — Restora Experience Studio: P3 — the reconstruction + experience-generation compositor. The engine behind the first experience. A new
lib/experience-studio/compositor.ts—composeExperience(components, packId, opts)turns the published library into an experience pack: assembled scene layers (bottom→top by zIndex viaassembleStack), a staggered motion timeline, branding + editable copy slots, and a per-target performance verdict (evaluatePerf: mobile 16 layers/1.5 MB · desktop 24/4 MB, byte-estimated by format). AnEXPERIENCE_PACKSregistry ships Burger Explosion first (pizza/sushi/etc. are added here additively in P6 — never a compositor rewrite). Mode-agnostic end-to-end: a3dcompose request transparently resolves to the anim/2d artifacts today and the verdict surfacesfallbackLayersso a degraded scene is never mistaken for true 3D. Super-admin read-only POST/api/admin/experience-studio/compose+ a "Compose preview" panel on the studio page (ordered layers · motion · perf · copy). P3 exit gate met: a full Burger Explosion pack composes end-to-end from the seed library (proven by tests + the preview). 2-lens adversarial review: compositor math (no off-by-one/NaN/wrong-total) and route security (super-admin, read-only, zod-bounded, published-only — no draft/curated/tenant data can reach a pack, no XSS) both confirmed clean; 2 items applied (fallbackLayersclarity; brand colour/font format validation at the compose boundary so the deferred P5 renderer can't receive a CSS-injection payload). 24 tests, tsc/eslint 0, build EXIT 0. The polished visual renderer + the tenant one-click flow are P5; with real curated art it becomes the jaw-dropping experience — no compositor changes needed.
- Phase 30 — Restora Experience Studio: P1.3/P1.4 — asset generation pipeline + super-admin curation queue. Builds on the P1 foundation. A new
lib/experience-studio/generation.ts:buildGenerationSpec+generationPrompt(deterministic, so a generated set composites together — the "consistent components" requirement),placeholderArtifacts(dev has no image-gen provider — build-with-seed; go-live wires a cost-capped image model),draftFromGeneration(always status `draft` — generation never publishes), and theresolveCurationstate machine (draft→curated→published; reject deletes but never a live component). The read route gains POST/api/admin/experience-studio/assets:generate(creates a draft; never overwrites a curated/published component → 409) andcurate/publish/unpublish/reject/set-ip-cleared— all row-locked (mutateExperienceStudioDB), super-admin-gated, audit-logged. A super-admin curation queue UI at/admin/dashboard/experience-studio(+ an AdminSidebar entry): generate → curate → publish, with a per-component rights toggle and a library-health snapshot. The publish gate is now real:canPublishComponentrequires the 2d floor and a human rights sign-off (ipCleared) — AI-generated art is never auto-cleared, andlibraryHealth.ipUnclearedsurfaces the go-live IP gate (the seed's 6 placeholders are honestly flagged uncleared). 2-lens adversarial review confirmed the curation gate is non-bypassable (publish runs inside the row lock against the fresh component; generation can't yield a non-draft; a published component can't be deleted/silently mutated); the one real finding — a structurally-dead licence check — was fixed by replacing it with theipClearedgate (+ preservecreatedAton regenerate). 17 tests, tsc/eslint 0, build EXIT 0; both routes + the page registered.
- Phase 30 — Restora Experience Studio: P1 Food Asset Library foundation (building on seed). First code for Product B. A new mode-agnostic asset model (
lib/experience-studio/assets.ts):FoodAssetComponent(cuisine · category ·modes{2d,anim,3d,mobile,dark,theme-adaptive}· catalogingredientId· theme tokens · license · curation status · version · zIndex), withartifactForMode— a deterministic fallback chain that proves the locked additive-3D constraint (a3drequest resolves to anim/2d today, so dropping in real 3D artifacts later touches no consumer code), plusassembleStackandlibraryHealth(status counts, mode coverage, catalog-link/licence gaps, and amissingFloor2dguard for the "2d is the universal floor" invariant). A platform-level shared store (get/save/mutateExperienceStudioDB— the launchpad doc pattern: PG row-lock + JSON fallback, deep-cloned seed,lastUpdatedbumped inside the PG lock) + a super-admin read APIGET /api/admin/experience-studio/assets(library + health). Dev seed: 6 burger components (bun→patty→cheese→lettuce→tomato→bun, 2d+anim each, catalog-linked, licence-tagged) — build-with-seed: real curated assets + the paying pilot + the 90+ proof are go-live/expansion gates, not dev blockers. 2-lens adversarial review: 0 majors; 3 hardening items applied (deterministic last-resort, themissingFloor2dfloor signal, PG-pathlastUpdated). 10 tests, tsc/eslint 0, build EXIT 0; route registered + super-admin-gated.
- Phase 30 — Restora Experience Studio (Product B): task breakdown + P0 kickoff (docs-only, no app code). The premium experience generator (~4,500–5,000 SEK/mo) is broken into small tasks and P0 (validate-first, near-zero code) is started. New docs under
docs/products/restora-experience-studio/: `01-tasks.md` (the full P0→P7 breakdown, with every "reuse" rewritten as "update/extend [system] with [new capability]" per the owner's framing — a real upgrade to Product A's spine, not passive reuse); `02-wow-score-rubric.md` — the P0.1 quality standard: the 8 sub-scores (Visual·Motion·Conversion·SEO·Performance·Brand·Uniqueness·Storytelling), weights summing to 100, 0–100 bands, the ≥90 ship gate + hard floors + disqualifiers, and which dimensions extend existing systems (the shipped SEO/AI-SEO scorer, performance metrics) vs are net-new rubrics — the P4 automated scorer must match it; `03-p0-validation-playbook.md` — operationalizes the non-code P0 tasks (concierge proof, pre-sell/LOI with a template, AI-asset spike acceptance protocol, legal/IP review checklist) with explicit acceptance criteria. The P0 exit gate (≥1 paying pilot and a hand-proven ≥90 experience and a working AI-asset spike) gates the P1 library build; nothing beyond P0 is built until it's met. Plus `04-ai-cost-and-billing-model.md` — an owner decision (2026-06-13) locking a transparent, cost-plus, never-lose-money AI billing model: the tier includes a generation quota, a super-admin daily USD cap disables the feature gracefully when hit, and per tenant the quota either pauses (default) or allows pay-to-continue overage priced cost-plus with the actual AI spend shown in SEK so it's a fair deal — reusing the existing AI cost-law rail, implemented in P5. TODO.md gains a Phase 30 section.
- Delivery menu/price sync — a scheduled cron + shared logic (completes the buildable scope). The per-tenant sync logic is now a shared
lib/delivery/sync.ts(runDeliveryMenuSync+ a cheaphasPendingDeliverySyncpre-check) reused by bothPOST /delivery/syncand a new `GET /api/cron/delivery-menu-sync` that walks every tenant (scan-capped at 250, re-sync-capped at 50/run, with honesthitScanCap/hitPerRunCapflags — no silent truncation), rebuilds + re-validates each partner feed, and refreshes the honest per-platform status so a dashboard never shows a stale "prepared" after the menu changed.CRON_SECRET-gated, fail-closed in production. 3 new tests; tsc 0, eslint 0. Live-verified against the running platform: 401 without the secret, and one run scanned 14 tenants → synced the one with menu-sync on → wrotefoodora: prepared · 5 items(creds present) andwolt: needs-credentials · 5 items(no creds) — exactly the honest split.
- Delivery menu/price sync — the stub now builds + validates a real partner feed, honestly. "Sync Menu Now" used to log a line and return a fabricated
{n} items syncedwithout building or recording anything. NowPOST /api/tenant/[id]/delivery/syncbuilds a real partner menu/price feed from the canonical menu (buildPartnerMenuFeed— honours each platform's pricing toggle: with it off, items carry no price; drops unavailable/unnamed/zero-priced items), validates it, and records an honest per-platform status on the integration (lastSyncStatus=preparedwhen credentials are present and the feed is valid /needs-credentialswhen the API key + restaurant ID are missing /errorwith the reason; pluslastSyncedAt+lastSyncItemCount). It never claims a push to a partner we can't reach — the feed is exposed at the newGET /api/tenant/[id]/delivery/feed?platform=foodorafor a partner to pull (or a future live merchant-API push to upload), and the dashboard shows the real status ("Menu feed prepared — N items … the live push to foodora runs once their merchant API is connected") instead of a fake "Synced!".syncPricingis now actually read. NewlastSync*fields onIntegrationConfig; both routesrequireTenantAccess-gated; API keys never exposed in the feed. tsc 0, eslint 0 (3 pre-existingset-state-in-effectlints on the page's data-loading effects left untouched), 3 new feed tests; routes live-verified (401-gated). Documented indocs/api/endpoints.md.
- *Food Bazar app — location fallback now shows the nearest other cities, distance-badged. When the chosen city has no restaurants, the app already fell back to other cities — but they looked local. Now the fallback feed is ordered nearest-first (great-circle distance over a city-coordinate table) and every out-of-town card carries a clear "{city} · ~{N} km" / "{region}" badge (e.g. "Stockholm · ~615 km / Sweden", "Lund · ~16 km / Skåne"), under a banner reading "No restaurants in {city} yet — showing the nearest cities, each marked with its distance". So the app is never empty and* the user is never misled into thinking a far place is nearby.
:bazarbuilds green.
- Phase 28 — Food Business Launchpad: automated extraction-to-graph (DR-2/6/7) + an FBL-local agent layer. The Phase-4 breadth accelerator, trust-law-safe by construction. A new daily cron
GET /api/cron/launchpad-research(the Automated-Research agent) walks the curated source registry across all 12 cells, picks uncovered official pages (planAutoResearch— deduped + capped,?cap=1–12, default 4), and runs the same guarded fetch+extract as the manual super-admin tool (extracted into a sharedresearch-runner.ts: SSRF + allow-list re-validated on the URL and every redirect hop) to draft `needs-review` candidates —CRON_SECRETfail-closed in production, budget-capped (stops the moment the daily AI budget is spent), and a no-op without an AI key. A newresearch-agent.tsis the FBL-local agent layer (doc 07 roster — explicitly not the canonical platform RAOS substrate, which stays a separate deferred build): a typedFBL_AGENTSroster (each agent's role + the one trust rule it can't break + its real implementation),planAutoResearch, andreconcileProposed(only ever ADDS to the human queue; never mutates a verified, served fact — a draft colliding with one is a likely regulation change, surfaced to the operator MON feed, never applied). The human verification gate is untouched: nothing here ever serves or verifies. Two-phase adversarial review (4 lenses → 16 findings) caught three real MAJORs, all fixed + re-reviewed (verdict: _fixes-confirmed_): (1) the cron's non-atomic whole-document write could clobber a human verification committed mid-run → it now plans on a read-only snapshot, the long fetch/LLM loop touches no shared state, and a single row-locked `mutateLaunchpadDB` folds drafts against the fresh graph (the freshness cron + the admin freshness route were hardened the same way); (2) the coverage check missed path-redirects (verksamt.se/→/start), re-drafting the same page every run and burning budget → coverage now also matchesproposedFromUrl(the originating registry URL; the citation still pins the fetched URL); (3) a verified-source conflict was lost on a zero-draft run → conflicts are now recorded to the MONchangeLog(conflict-detected) and always audit-logged. 457 tests (7 new), tsc 0, eslint 0, build EXIT=0.
Fixed
- Food Bazar search actually finds dishes now — three relevance bugs fixed. (1) No diacritic folding:
q=tiramisureturned 0 against "Tiramisù della Casa" — fatal for an Italian/Swedish marketplace.fold()(NFD + combining-mark strip) now normalizes both query and haystack in the scorer AND the did-you-mean suggester, sotiramisu⇄Tiramisùandrakor⇄Räkormatch both ways. (2) Synonym over-recall: the synonym map expanded bidirectionally, so a SPECIFIC query recalled its whole category —q=margheritareturned 62 results (3 real + 59 junk pizzas). Expansion is now asymmetric: a GENERIC key ("pizza", "coffee") still widens recall to its dishes, a specific term only boosts already-matched results.q=margherita→ 5, all real. (3) Swedish names were unsearchable: the route only fed the English name into the scorer; every locale now rides along asaltNames(same match ladder, slightly lower weights) —q=husets vinfinds "Vino della Casa / Husets vin (glas)". tsc 0, eslint 0, 346 tests (4 new), live-verified on the running bazar (accented + Swedish + generic + garbage queries).
- Maintenance / coming-soon gate restored — it was silently lost in the `ui/` repo restructure. Commit
0b114efmade the Super-Admin maintenance toggle actually gate the public site, but the restructure movedproxy.tswithout the gate hunks and droppedGET /api/public/site-statusentirely (live 404) —platformSettings.maintenanceModewas write-only again. Re-applied the exact original implementation: the proxy rewrites gated platform paths to/coming-soonwhen the flag is on (15s module cache;/admin,/login,/register,/dashboard, role portals,/apistay live; tenant storefronts unaffected) and the boolean-only status route is back. Found by a full-platform health audit (3-agent scan of logins, 88 routes/links, and DB/kill-switch integrity) after a dev-DB reset; the same audit found exactly ONE broken link platform-wide — the Luigi homepage hero CTA pointing at the non-existent/order(now/menu, in the tenant data). tsc 0, eslint 0;/api/public/site-statusverified live (200,{maintenanceMode:false}).
- WoW clone / new-tenant provisioning broken — the `template.json` artifact was missing after the `ui/` restructure. Visiting a WoW preview and clicking "View your cloned site" (and the real magic-link email claim, and ordinary
/registersignups) bounced to/login?wow=error. Root cause:createTenantDatabasereadsdatabases/tenants/template.jsonas the seed for every new tenant and throws if it's absent — and that file lives under the git-ignoreddatabases/tree, so it was never carried into the newui/app root during thesrc/→ui/move. Every tenant-creation path (not just WoW) was failing locally. Restored a clean, PII-stripped template derived from the sample tenant (identity strings blanked; all data arrays — orders, customers, staff, reviews, media, etc. — emptied; socials/coordinates/Swish number cleared; Google Business disconnected; AI usage reset) and force-added it to git (git add -f, past thedatabases/ignore) so it's a proper tracked repo artifact present in dev and prod builds. Verified:claim/dev?token=…now307 → /tenant/[id](the live cloned storefront, HTTP 200, no sign-in / no email required) instead of/login. (So: "View your cloned site" now shows the site directly, matching the intended behaviour.) - Catalog scale fix — process cache + prebuilt indexes + bounded directory (the N3 unblocker). The ~16 MB catalog doc (8k+ ingredients) was re-read, re-parsed, and re-indexed on every request. Now: a process-level doc cache in
getFoodCatalogDB(parse once, 60 s TTL, invalidated on every write viainvalidateFoodCatalogCache;save/mutatekeep it consistent), prebuilt indexes (getCatalogIndex: slug→record map for O(1)findPublishedBySlug, the sorted published list, and the nutrition match candidates — built once per cache generation, keyed to a cache token), and the/ingredientdirectory caps its render to 300 rows (search narrows the 8k+). Result:/ingredient1.6–2.2 s → 0.27–0.62 s,/ingredient/avocadowarm 0.086 s; the nutrition calculator reuses the cached candidates. Cold loads still pay one 16 MB parse — the eventual horizontal-scale step remains the normalized-tables migration (catalog P9), but this removes the per-request cost and unblocks N3 (FoodBazar can read the catalog cheaply). tsc 0, eslint 0, 303 tests, build EXIT=0. - Phase 29 / Phase A — USDA importer hardening (adversarial review). The panel web-verified all 14 USDA nutrient-number→key mappings as correct (no silent wrong nutrition); the real defects were units + attribution: (1) sodium & cholesterol were stored as raw mg under keys the page renders as grams (a latent 1000× trap) — now normalized to grams at import in both
usda-fdc.tsandlivsmedelsverket.ts(precision-preserving), so the catalog is single-unit; (2) the public Catalog API + ingredient page stripped licence info —toPublicApiIngredientnow carries asourcesattribution block and the "Data & sources" panel renders each source's licence + link (required for CC-BY/ODbL), via newcatalogAttributions+ licence/url onpublicSources; (3) the USDA import route now has a rate limit; (4)importUsdaFdccalls `assertAllowedSource` (enforces the no-scraping allow-list at ingest), USDAcategoryis set toother(off-vocab FDC groups don't reach the controlled field), and the dev populate asserts no duplicate ids. The cross-source canonical-merge + slug-uniqueness are confirmed by-design deferrals (records quarantined inneeds-review) — the next Phase A slice. 295 tests, tsc 0, eslint 0, full build EXIT=0. - Phase 28 — Food Business Launchpad: P1 legal-posture hardening (adversarial review). The trust gate passed the review (no un-verified fact can reach a founder); the legal-posture concerns are fixed: (1) an FBL-specific advice/guarantee detector (
findAdviceLanguage) now catches the postures a tax keyword-filter missed — guarantees, completeness claims, "no licence required", "we recommend", threats — while allowing cited process language and neutral nouns ("authority"); (2) it runs at both verify-time and serve-time (folded intocanPublishNode, so advice prose is dropped on egress even if a future authoring path slips it through) — replacing the over-blocking TC-0 export linter here; (3) the disclaimer is now always shown (client state defaults to the constant, not gated on fetch success). Also: the SE VAT node no longer asserts the rate (attributed to Skatteverket);cellCoverageis effective-date-aware (agrees with the served roadmap);pricing()returns null on non-finite margin (no silent 0%) and echoes the computed margin; the seed is deep-cloned per request; and the SEO/H1 copy is scoped to "every step we show is verified" (not claiming verification before anything is verified). 284 tests, tsc 0, eslint 0, build ✓. - Phase 26 — Food Stories Network: `next build` regression from the fail-closed secret. The P1-hardening
SESSION_SECRETfail-closed check threw at module-evaluation time, whichnext build(production mode, no runtime env) hit while collecting page data — breaking the build for every route importingfsn/session.ts. Now the secret is resolved lazily per token operation, so it still fails closed at request time in production (the real security condition) without breaking the build. - Phase 26 — Food Stories Network: P1 security/GDPR hardening (adversarial review FAIL → resolved). A 3-agent panel (auth/session · GDPR/PII · moat integrity) found real defects; all blockers + majors fixed: (1) account takeover — auto-claim linked a verified profile by unverified email match (anyone registering with a worker's email seized their owner-verified ✔). Replaced with an owner-issued, single-use claim code (proof of employment, rate-limited); registration no longer auto-claims, and
claimEmailwas removed entirely (data-minimisation). (2) PII leak —searchTalentreturned rawTalentProfiles (work email, operator ids, internal ids); added apublicTalentProfileprojector and route it through search +/me+ profile responses. (3) broken role gate —EMPLOYER_ROLESused non-existent role strings (real owners locked out, wrong operators sawrecruiters_only); fixed totenant-owner/superadminand search is now employer-only (403 otherwise). (4) consent — owner-verified-but-unclaimed profiles now default to private (no public discovery until the person claims + opts in). Plus: rate limits on register/login/claim, fail-closed `SESSION_SECRET` in production + timing-safe token compare, erasure now scrubs the profile name/skills/headline, and Open-to-Work re-stamps freshness only on a real false→true transition. 4 tests updated (274 total). tsc 0, eslint 0, build ✓. - Phase 13 — Restora Webshop: hardening from the WS-2..WS-7 adversarial review (FAIL → resolved). A 3-agent panel (correctness · security · legal) found real defects; all blockers + majors are fixed: (1) concurrency — every webshop mutation (checkout, cron, fulfillment, subscriptions, admin coupons/products) now runs through a new row-locked
mutateWebshopDB/mutateSalesAgentsDB(mutateDocSELECT … FOR UPDATE), replacing last-writer-wins reads+writes that could drop orders, miscount coupon redemptions, or double-record commissions. (2) commission return-window was dead code — webshop commissions are now recorded `withheld` on ship (the payout engine only payspending), released topendingonly onceisCommissionReleasablepasses (return window elapsed) via the daily cron, reversed on return, and computed on the net (post-discount) amount. (3) overselling — own-stock is decremented at order creation and restored on return. (4) checkoutqtyis bounded; the replenishment cron fails closed without a configuredCRON_SECRETin production. (5) analytics now allocates discounts pro-rata so the seller/product breakdowns reconcile with net revenue. Plus:dueReordersstaleness floor, anorder.taxSupportedmarker, and an honest VAT-table relabel (domestic-only; cross-border/GB/NO not modeled). 256 tests, tsc 0, eslint 0, build ✓.
Changed
- Domain sweep — the platform now lives at `restora360.com` (LR-1). All ~100 hardcoded
restora.io/restora.appreferences across 64 source files (env-fallback site URLs, canonical/OG metadata, sitemap + robots + llms.txt, OpenAPI/Postman docs, bazar SEO pages, docs-center article prose, subdomain examples, support emails, UI mockups, tests) now point at restora360.com;NEXT_PUBLIC_SITE_URLremains the runtime override everywhere it already applied. The twoPLATFORM_HOSTSsets (host-resolver + llms.txt) gainedrestora360.com/www.restora360.comas the live domain while keeping `restora.io`/`restora.app` as legacy aliases, so old links keep resolving during the transition; tenant-subdomain resolution (<sub>.restora360.com) works unchanged (anything non-platform resolves by first label). Verified live by Host header:restora360.com→ platform llms.txt/robots with correct absolute URLs;restora.iostill aliases. tsc 0, eslint 0, 340 tests, build EXIT=0.
Fixed
- Android POS — uniform menu cards + owner-configurable receipt VAT. Menu-grid cards are now a uniform height regardless of name length (the product name reserves two lines and the image band is always present, so a long-named item no longer makes a taller card than its neighbours). The receipt VAT/moms rate is now owner-editable in the web dashboard at Settings → Payments → "Receipt VAT (Moms)" (0–100 %; stored on
paymentSettings.vatRatePct, merged by the existing settings save — no allow-list/zod change needed) — the POS/pos/menuendpoint reads it (default 12 % SE food) and the kvitto's Momsspecifikation uses it. Backend tsc 0; all three apps build green and reinstalled. - Android POS/Waiter — six polish enhancements (kvitto, centered dialogs, cart images, order types, tablet insets), adversarially reviewed. (1) Swedish receipt (kvitto): a
Kvitto.ktcomponent renders the standard kassakvitto content — säljare + org.nr + adress, datum/tid, kvittonr, line items, moms-specifikation (12% backed out of VAT-inclusive prices; the rate is now a configurableTenantSettings.vatRatePct), Att betala, Betalsätt — rendered as a single 42-column monospace block (uppercase säljare +===/---rules + a Momsspecifikation Sats/Netto/Moms block + Swedish labelsÄta här/Avhämtning/Leverans+sv-SEmoney like249,00 kr) on cream#fffdf6paper with a custom zigzagGenericShapetorn edge, forced light theme so it's readable in either app mode; layout matched to a reference POS the owner shared. The same text drives the share/print action. It is honestly labelled an orderkvitto, not a control-unit-signed kassaregister receipt (a registered kontrollenhet is the venue's legal duty); shown after a POS sale and after a Waiter table-settle. (2/4) Dialogs centered + sized (matched to the reference POS): OptionsSheet/PaymentSheet/Kvitto are centered cards with 24dp rounded corners and a fixed header/footer + scrollable middle (Add/Confirm never scroll off-screen or hide behind the keyboard). The options dialog now shows the dish photo on top and options as selectable pills (orange when selected), like the reference. A real bug was fixed in the width capping:fillMaxWidth()pins min=max=screen, so a followingwidthIn(max)was ignored and the dialogs spanned a full 1280dp tablet; the order is nowwidthIn(max).fillMaxWidth(), giving tidy ~36% centered cards (verified on the owner's tablet via screencap). (3) cart lines show product thumbnails. (5) POS order-type selector — Dine-in / Pickup / Delivery (dine-in shows a table field, others an optional name); backendpos/ordersaccepts the new types + optionalcustomerName. (6)statusBarsPadding()on the staff top bars so the theme toggle isn't under the system clock on edge-to-edge (targetSdk 36) tablets. Hardened by a 5-lens adversarial review (kvitto correctness · dialogs · order-type flow · state/lifecycle · insets): 28 raised, 18 confirmed, 9 fixed (PaymentSheet fixed-footer, configurable VAT, dine-in empty-table validation, transientshowReceiptso a killed app can't get stuck on a null receipt, cart-clear on auth-expiry, receipt long-name/imePadding/share-text) and the rest rejected with reason (global order-numbering is pre-existing platform-wide behaviour; English payment buttons stay consistent with the English app chrome;countertype is a legitimate backend value kept as a fallback). Backend tsc 0; all three apps build green and reinstalled on the owner's phone. - Android — POS "couldn't load menu" was a `NetworkOnMainThreadException` (response body read off-thread).
ApiClientran the HTTPexecute()onDispatchers.IObut read the response body (requireSuccessText()/ the customer-display reader) back on the caller's coroutine — which for the data fetches is the Main dispatcher. Small responses (orders, KDS) were already fully buffered byexecute(), so reading them never touched the socket and silently worked; the large, chunked POS menu (50 products + option groups) wasn't buffered, sobody.string()did a real socket read on the main thread and StrictMode killed it right after the200— the load coroutine died before decoding, the screen showed "couldn't load the menu", and every Retry repeated it. Fix: read the body insidewithContext(Dispatchers.IO)(maderequireSuccessTexta suspend fun; wrapped the customer-display reader). All authed calls in all three apps benefit; the latent bug just hadn't surfaced until a payload was big enough not to pre-buffer. Diagnosed on-device via adb logcat. (Also along the way: the POS app had a typo'd server address saved from an earlier sign-in — `https://198.168.0.96` instead of `192.168.0.96` — hidden behind the password-only "Welcome back" screen; the remembered screen now **shows the server address** so a wrong/moved server is visible and fixable via "Change server" instead of being a trap.)
Added
- Food Bazar app — third-party availability ("Also order on foodora / Wolt / Uber Eats"), config + deep-link, super-admin gated. The consumer app's promo field accepts real restaurant/partner coupon codes (validated live via the existing
POST /api/public/{tenantId}/validate-coupon), and restaurants can now surface the partner apps they're also listed on — no fabricated competitor prices, just an honest "open in the partner app/website" deep-link. Two public bazar routes (GET /api/bazar/restaurants,GET /api/bazar/restaurant/[slug]) now return anavailableOn: [{ platform, orderUrl }]array, double-gated: a platform shows only when the restaurant enabled it with an order URL (settings.integrations[p].{enabled, orderUrl}) AND the super-admin has that platform switched on site-wide (providerSettings.delivery[p].enabled, defaulting to the big-three on). So the super-admin Providers page is a real site-wide kill-switch over every restaurant's partner links. The Android app mapsavailableOn→ the "Also on" pills on home/search cards and a dedicated "Also order on" strip on the restaurant page (each pill carries an open-in-new affordance and opens the partner URL viaACTION_VIEW); the illustrative cross-app price comparison is kept seed-demo-only since live partner prices aren't ours to assert, and unknown platform keys are filtered so a future provider can't crash the pill row. Backend tsc 0, eslint 0; live-verified end-to-end on the running marketplace — per-restaurant gate (a restaurant with no integration returns[]), the super-admin kill-switch (a restaurant-configured Wolt link drops out when Wolt is disabled site-wide, leaving foodora only), and defaults; the:bazarmodule builds green.
- Phase 28 FBL — the last two functionality pillars: MON (regulation-change monitoring) + F8 (business-plan export). MON (doc 14): every change to the SERVED graph — a fact verified, unverified, edited, discarded, or auto-demoted by the DR-5 circuit-breaker — is now logged as a
NodeChangeEvent(cap 200, newest-first) inLaunchpadDB.changeLog, and the super-admin console gets a "Regulation changes" feed showing each change with its founder impact (cellImpact= active founders — claimed or within-TTL — in that cell; a broad node counts the whole country). Puremonitoring.ts(recordChange/cellImpact/changeFeed); recorded at the right moment (reject captures the node before splice; every error branch returns before any log write, so a failed action can never masquerade as a change); the feed exposes node titles + counts, never founder PII. The founder-facing alert stays the RP-5 in-app "roadmap updated" banner — this is the operator/audit view. F8 (doc 08 BP-5/6): business-plan-lite is now currency-aware (SEK/NOK/DKK by country, with a per-country editable food-VAT default — SE 12 / NO 15 / DK 25 — clearly marked "verify with your tax authority", never asserted as fact) so plans are correct across all 12 cells, not just SE; and a new `planToMarkdown` export + a "Download (.md)" button on/launchpad/plangive a portable, self-contained document (carrying the assumptions register + disclaimer) to keep or paste into Word/Docs — beyond the existing print→PDF. A 2-lens adversarial review (MON · F8) caught a real major — the on-screen pricing label hardcoded "12% VAT" while the value used the country's actual rate — now driven fromforecasts.vatPctso the label can't mis-state the rate; the minor sv-SE date hardcode was switched to ISO too. 450 tests (6 new), tsc 0, eslint 0, build EXIT=0; live: the MON feed is super-admin-gated, the plan page renders the export.
- Phase 28 FBL — the full 3×4 grid: 5 grid-completing cells (NO food-truck/restaurant, DK café/food-truck/restaurant). The launchpad now serves every (country × type) combination across SE/NO/DK × home-bakery/café/food-truck/restaurant — 12 verified cells — with the honest global floor for everything else (Finland, etc.). Each cell is authored to the established trust posture (seed
needs-review; hedged; no fabricated specifics; allow-listed official citations) and adapts the proven type templates to each country's authorities — NO via Brønnøysund/Skatteetaten/Mattilsynet (+ Oslo kommune for skjenkebevilling/public space), DK via Erhvervsstyrelsen-Virk/Fødevarestyrelsen/Skattestyrelsen (+ Københavns Kommune for alkoholbevilling/public space).kk.dk+helsedirektoratet.noadded to the research allow-list; DISCOVER worklists for all 5; the guide cross-product + sitemap now cover all 12 cells. A parallel 5-cell adversarial review returned 0 blocking/major findings (all "ship"/"ship-with-fixes"); the one substantive minor was applied — the NO alcohol nodes (café + restaurant) now cite Helsedirektoratet's national skjenkebevilling guidance (with Oslo kept as an example), so the citation is authoritative for any Norwegian founder, not just Oslo. 444 tests (15 new), tsc 0, eslint 0, build EXIT=0; live (dev-verified): all 12 cells servable, 12 guides in the sitemap, unseeded cells fall back to the honest floor. (Seeds dev-verified in the gitignored DB only — full QA + reset before go-live.)
- Phase 28 FBL — breadth ×3: SE food-truck, SE restaurant, and DK home-bakery (3rd country). Three more seeded cells take the launchpad to 7 verified cells across 3 countries (SE/NO/DK) and 4 business types (home-bakery, café, food-truck, restaurant). SE food-truck (8 nodes) is structurally distinct — a MOBILE food facility (mobil livsmedelsanläggning) plus an optional permit to trade on public space (cited to the Police under the Public Order Act). SE restaurant (9 nodes) is the flagship — premises + food-safety + tax + optional staff/alcohol. DK home-bakery (8 nodes) pushes the country axis to Denmark with the right authorities — business/CVR via Erhvervsstyrelsen (virk.dk), food via Fødevarestyrelsen, tax/moms via Skattestyrelsen (skat.dk). All authored to the same trust posture (seed
needs-review; hedged; no fabricated thresholds/fees; allow-listed official citations) —polisen.se+ 5 Danish hosts added to the research allow-list; DISCOVER worklists for each; the guide cross-product + sitemap now cover all 7 cells. A parallel 3-cell adversarial review returned "ship", 0 findings on every cell (no wrong-authority citations, no fabricated specifics, sound DAGs, advice-clean). 429 tests (9 new), tsc 0, eslint 0, build EXIT=0; live (dev-verified): all 7 cells servable (7/9/8/9/8/10/8 steps), 3 new guides 200, 7 guides in the sitemap, unseeded cells (e.g. DK×restaurant) correctly fall back to the honest floor. (Seeds dev-verified in the gitignored DB only — full QA + reset before go-live.)
- Phase 28 FBL — completing the matrix: NO × café (the 4th cell). A 2nd type in the 2nd country proves the engine handles any (country × type) combination.
seed-no-cafe.ts(10 cited nodes — Brønnøysund + Skatteetaten + Mattilsynet authorities, business registration via Altinn, food-business registration, MVA, internkontroll, allergen info, plus the optional employer registration and alcohol-serving licence/skjenkebevilling) reuses the café shape under Norwegian authorities, same trust posture (seeds needs-review; hedged; no fabricated specifics; allow-listed official citations — skjenkebevilling cited to the kommune, employer to Altinn). Wired intodefaultLaunchpadDB+ the DISCOVER worklist (NO_CAFE_SOURCES); the guide cross-product already covers it (/start/en/cafe/in/norway); the sitemap now lists all four guides. The launchpad now serves a complete SE/NO × home-bakery/café grid, with the honest global floor for everything else. 420 tests (3 new), tsc 0, eslint 0, build EXIT=0; live (dev-verified): all 4 cells servable (7/9/8/10 steps), NO café guide 200, sitemap lists 4 guides, optional steps excluded from the readiness denominator (3 required legal). (Seed dev-verified in the gitignored DB only — full QA + reset before go-live.)
- Phase 28 FBL — the country axis: a 2nd country cell (NO × home-bakery). The engine now generalizes across countries, not just business types.
seed-no-home-bakery.ts(8 cited nodes — Brønnøysundregistrene + Skatteetaten + Mattilsynet authorities, business registration via Altinn, food-business registration, MVA-when-over-threshold, internkontroll, labelling) authored to the same trust posture as the SE wedge (seedsneeds-review; hedged summaries; no fabricated NOK/%/thresholds — those stay in the cited source; real official Norwegian citations). Six NO authority hosts added to the research allow-list (brreg.no, altinn.no, skatteetaten.no, mattilsynet.no, lovdata.no, oslo.kommune.no); a NO DISCOVER worklist (NO_HOME_BAKERY_SOURCES); the guide gainsnorway(/start/en/home-bakery/in/norway); the sitemap now enumerates all publishable cells (SE home-bakery, SE café, NO home-bakery). Country isolation holds —publishableNodesfilters by country, so NO nodes never leak into an SE roadmap (test-asserted). A focused adversarial review PASSED with no rewordings required (correct-authority citations, no fabricated specifics, sound DAG, advice-clean, needs-review, all 6 hosts genuinely official). 417 tests (3 new), tsc 0, eslint 0, build EXIT=0; live (NO dev-verified for the demo): NO roadmap servable (8 dependency-ordered steps), guide 200, an unseeded country (DK) correctly gets the honest global floor, SE cells unregressed. (Seed dev-verified in the gitignored DB only — full QA + reset before go-live.)
- Phase 28 FBL — breadth: a 2nd seeded cell (SE × café) + optional/conditional roadmap steps. The multi-cell engine is now exercised end-to-end beyond the home-bakery wedge. (1) SE × café seed (
seed-se-cafe.ts, 9 cited nodes — kommun + Skatteverket authorities, food-premises registration, F-skatt, moms, egenkontroll, allergen information, employer registration, alcohol-serving permit) authored to the same trust posture as the wedge (seedsneeds-review; accurate-shaped, hedged summaries; no fabricated fees/rates/deadlines; real official citations — Livsmedelsverket, Skatteverket, verksamt, Folkhälsomyndigheten, the kommun). Wired intodefaultLaunchpadDB, the programmatic guide (/start/en/cafe/in/sweden), and the DISCOVER worklist (SE_CAFE_SOURCES);folkhalsomyndigheten.seadded to the research allow-list (the serveringstillstånd authority). (2) Optional/conditional steps — a real engine capability surfaced by review:ComplianceNode.optional(e.g. "only if you serve alcohol", "only if you hire") flows throughRoadmapStep, renders an "If applicable" badge on /launchpad + the guide instead of a deadline, and is excluded from the readiness denominators (checklistScore+legalStepCount) so a solo, alcohol-free café founder isn't stuck below 100%. A focused adversarial review of the seed PASSED (no factual over-claim, citations on the correct authority, sound DAG, advice-clean, needs-review) — its two findings (a citation-attribution nit and the conditional-step modeling) were both applied. 414 tests (8 new — café well-formed/cited/advice-clean/no-specifics/DAG + optional-denominator handling), tsc 0, eslint 0, build EXIT=0; live (café dev-verified for the demo): café roadmap servable with 9 dependency-ordered steps, 2 optional, legal denominator = 3 required; guide 200; home-bakery unregressed (7 steps). (Seed dev-verified in the gitignored launchpad.json for the demo — full QA + reset before go-live per the build-with-seed mode.)
- Phase 28 FBL — RP-5 the living roadmap: regeneration diff (founders see what changed, progress preserved). The DR-5 → re-verification loop now closes on the founder's side. Each roadmap step carries a content
stepSignature(a pure, browser-safe FNV-1a hash over its nodeId/title/summary/citations/deadline — control-char delimited so fields can't run together and mask a change); the roadmap + profile APIs emit it on every step.diffRoadmapSteps(pure) compares the founder's last-seen signatures (kept per-cell in localStorage) against this visit's and reports added / updated / removed steps — with a first-visit guard so a brand-new visitor never sees a phantom "updated" banner. The /launchpad client persists the signature map each load, shows an "Your roadmap was updated since your last visit" banner when something changed, and preserves checklist progress automatically (it's keyed by nodeId — a surviving step keeps its tick, a removed one simply drops; the diff never blocks the render). An independent review PASSED (diff/persistence correct, first-visit guard real, progress genuinely preserved, additive APIs) — its one finding (an ambiguous field separator) was a display artifact, and the separator is now an explicit delimiter. 408 tests (2 new: signature stability/sensitivity, diff added/removed/changed + first-visit), tsc 0, eslint 0, build EXIT=0; live: all 7 steps carry signatures.
- Phase 28 FBL — the Global Contract floor: every cell returns an honest, useful answer (doc 04 CE-2 / doc 20 K5). A founder who picks a country/type Restora 360 hasn't verified no longer hits a blank "in preparation" — they get an honest signpost.
src/lib/launchpad/global-floor.ts(pure) returns, for any non-servable cell, the categories of authority a food business generally deals with (business registration · tax/VAT · food safety · premises/municipality · employment) with soft, hedged "what it usually means" + "how to find yours" guidance pointing at the kind of authority — and asserts no specific legal requirement, names no specific authority, states no fee/rate/threshold/deadline (those are facts, and facts only ever come from the verified CKG). It is advice-clean by construction andverified:falsealways. The roadmap API returns it only when the cell is not servable (it can never borrow another cell's verified facts), and the /launchpad client renders it in place of the bare "in preparation" box, under the assistant-not-advisor disclaimer. Reflected query-param labels are charset-bounded and lint-guarded — a craftedbusinessType(e.g.you-dont-need-a-permit) collapses to "a food business", so advice posture can never be injected into served prose. A dedicated adversarial legal-posture review scrutinized every string and PASSED (no requirement asserted, no harmful universal, no trust-leak, disclaimer always shown); its two softening nits were applied. 406 tests (5 new: advice-clean across cells, no fabricated specifics, never-verified, injection-guard, category coverage), tsc 0, eslint 0, build EXIT=0; live: NO/home-bakery → 5-domain floor, SE wedge still serves its 7 steps (no floor), injection probe neutralized.
- Phase 28 FBL — counsel-ready verification dossier (print → PDF). The Phase-1 GATE worksheet is now a document you can hand a lawyer. A new print-friendly
/launchpad/verification-dossier(noindex; the repo's established print→PDF pattern) renders the verification report as a per-node dossier — each node's claim, kind, authority, citations (with the exact passage the source should say, from the verified excerpt), the "what to confirm" checklist, and a Reviewer / Date / Verified-&-complete sign-off line — under a prominent "this is a worksheet, not a clearance" banner restating that a named reviewer + SE counsel are the true gate. The data stays super-admin-gated (the page just formats the existing/api/admin/launchpad/verification-report; a non-admin sees "sign in as a super-admin", never the content), so no new legal surface. Linked from the admin "Phase-1 GATE readiness" panel ("Open counsel dossier →"). Pure presentation over already-reviewed logic. 401 tests, tsc 0, eslint 0, build EXIT=0; live: page renders, data API-gated.
- Phase 28 FBL — verification-readiness report: the reviewer / counsel worksheet for the Phase-1 GATE (doc 20 §9). The capstone that operationalizes the true blocker.
src/lib/launchpad/verification.ts(pure) assembles, per node, the claim + citations/evidence + freshness + a plain "what to confirm" checklist (open the source, confirm the authority, confirm nothing's missing; AI drafts get an extra "read the evidence quote against the source" note), and a cell-level GATE readiness summary: served vs verified counts, advice-language leaks and uncited-verified anomalies scanned over the human-verified population (not overpublishableNodes, which pre-filters them — so it actually catches a node a human mis-marked), DISCOVER coverage, and freshness health. New super-adminGET /api/admin/launchpad/verification-report+ a "Phase-1 GATE readiness" panel on the admin console. It asserts no legal fact and verifies nothing —readyForCounselmeans the machine pre-checks pass (never legal clearance), and the report ALWAYS surfaces that a named reviewer + SE counsel signing off on completeness is the true, non-machine-verifiable gate. An independent review confirmed the cardinal risk (implying legal clearance) is absent and the anomaly checks run over the right population — PASS, no defects. 401 tests (5 new), tsc 0, eslint 0, build EXIT=0; live: route 401-gated.
- Phase 28 FBL — R2/DR-1: the legal source registry + DISCOVER worklist (feeds the research tool). The deep-research pipeline gains its first stage (doc 05 §5[1] / §6.3): a curated registry of official, legally-readable pages to research for a cell — the reviewer's worklist of where to look (NOT legal facts; those stay in the CKG behind the human gate).
src/lib/launchpad/source-registry.tsdefinesResearchSource(authority · url · domain · kind · scope · status) and the SE × home-bakery worklist (8 official pages across tax/food-safety/licensing/startup — Skatteverket, Verksamt, Livsmedelsverket, Bolagsverket, the Stockholm kommun).discoverSources(country, businessType)returns the cell's active sources, scope-matched and re-filtered through the research allow-list at runtime (a mis-entered URL is excluded, never surfaced). New super-adminGET /api/admin/launchpad/sourcesreturns the worklist with acitedflag (which pages a node already references — a conservative "effort visible" hint, exact-URL match), and the admin console's research panel now shows the DISCOVER worklist with one-tap "Use ↑" to load a source into the drafting input — closing DISCOVER → EXTRACT → STRUCTURE → human gate. An independent review PASSED it (no scope leak, no non-official URL, route read-only + super-admin gated). 396 tests (4 new: every URL allow-listed, scope-match/no-leak, domain coverage), tsc 0, eslint 0, build EXIT=0; live: route 401-gated.
- Phase 28 FBL — F3: four founder financial-planning calculators (runway · startup-capital · loan · cash-flow forecast). The Quick-numbers suite on /launchpad grows from 6 to 10 tools, all pure/deterministic/ex-VAT (planning numbers, never financial advice — every input the founder's own and editable). `runway` — months of survival at the current burn (revenue ≥ costs → honestly "sustainable", no fake infinite number). `fundingNeeds` — how much capital before you open = one-time setup + ramp burn (clamped ≥0 — you can't "need" negative capital) + a full-fixed-cost survival buffer (the conservative cushion, never the optimistic one). `loanPayment` — standard annuity → monthly payment + total interest (0% → straight-line) for equipment/premises finance. `cashFlowForecast` — a 12-month projection with optional revenue growth → ending balance, lowest point, and the first month cash would run out. An independent review re-derived every load-bearing number and PASSED all four with no correctness or honesty defects (praising the burn-clamp + full-cost buffer choices). 392 tests (4 new, each hand-computed), tsc 0, eslint 0, build EXIT=0; live: /launchpad renders the 10 cards.
- Phase 28 FBL — TS-6: served-fact liability dashboard + the auto-demote circuit-breaker (the last P0 trust-spine item). The trust spine now has its assurance layer (doc 13 TS-6 / doc 17 §9.10). (1) Circuit-breaker: a verified node's cited authority page that goes unreachable for ≥3 consecutive freshness runs is treated as gone — its served facts are auto-demoted to needs-review so we stop serving a fact whose source has vanished. This is strictly fail-safe: the automation can only ever stop serving, never auto-verify or auto-publish, and it's deliberately distinct from a content change (which stays a human-gated DR-5 flag — a redesign must not unpublish a still-correct fact).
runFreshnessChecknow tracksconsecutiveUnreachableper source (incremented while down, reset when reachable again); the breaker runs in both the admin check-now and the daily cron, audit-logged, and demoted facts return to the human queue. (2) Liability dashboard:servedFactHealth(pure) powers a super-admin panel — served-fact count, % within the re-review window, stale/undated counts, flagged + unreachable source counts, and the at-risk-node list the breaker would demote next. Both are pure, deterministic functions. A 2-lens adversarial review (fail-safe direction · integration) confirmed 0 confirmed findings — no path can auto-verify, and the breaker never trips on a mere content change. 388 tests (3 new: breaker threshold/served-only, consecutive-unreachable accounting, health metrics), tsc 0, eslint 0, build EXIT=0; live: gate 401, cron exercised end-to-end (7 checked, the dead Skatteverket F-skatt source now at unreachable=1 → correctly not demoted until 3 runs), roadmap still serves all 7 verified nodes.
- Phase 28 FBL — completing the human-verify gate (doc 05 §5[5] · §9.2): edit-then-approve, reject, and source evidence. The deep-research drafting tool now has a full review loop. The human gate (
/api/admin/launchpad/nodes) gains edit-then-approve (fix a draft's wording — re-linted withfindAdviceLanguageso advice posture can't slip through edit→verify) and reject/discard (remove a bad draft), each hard-guarded to non-verified nodes only — a served (verified) node can never be edited or deleted here; you must unverify it first, so live legal content is never silently changed. And the reviewer no longer approves blind: the research model now returns a verbatimsourceQuoteper draft, andverifiedExcerptkeeps it only if it provably appears in the fetched source (normalized substring match — a fabricated quote is dropped), stored on the citation (SourceCitation.excerpt, additive) and shown as a quoted blockquote beside each draft in the console (doc 05 §9.2 "never approve blind"). A 3-lens adversarial review (gate guards · evidence integrity · UI) confirmed 0 blockers — the verified-node guards hold and fabricated evidence can't reach the reviewer — with 2 minors fixed (raw-enum error codes → readable copy; edit-form reseed on reopen). 385 tests (2 new —verifiedExcerpt+ excerpt-attach), tsc 0, eslint 0, build EXIT=0; live: the gate stays super-admin-gated (401) and the public roadmap is unaffected.
- Phase 28 FBL — deep-research engine: EXTRACT+STRUCTURE stages (doc 05), AI source-drafting behind the human gate. The breadth accelerator — and trust-law-safe by construction. A new super-admin endpoint
POST /api/admin/launchpad/researchruns the designed pipeline's middle stages: [2] FETCH an allow-listed official source (skatteverket.se, verksamt.se, livsmedelsverket.se, a kommun…), [3] EXTRACT + [4] STRUCTURE via a single budget-capped model call that is instructed to pull candidate requirements from that page's text ONLY (R1 sourced-or-it-doesn't-exist — never from the model's own weights), then saves them as `needs-review` nodes (proposedVia: 'ai-research') for the existing human verification console. It is incapable of serving, publishing, or verifying anything (R2 the human gate) and never overwrites an existing node — an AI draft can only add candidates to the reviewer's queue. The pure core (src/lib/launchpad/research.ts):isAllowedSource(official-host allow-list; look-alikes likeskatteverket.se.evil.comrejected),buildResearchMessages(extract-only / no-invention / no-advice prompt),parseResearchDrafts(balanced-bracket, string-literal-aware), and `vetProposedNodes` — the trust vetter that forces `verification:'needs-review'` regardless of what the model returns, pins every citation to the URL actually fetched (a model-supplied URL is ignored — no fabrication), and drops advice/guarantee posture (findAdviceLanguage). The admin console gains a "Research a source" panel + an "AI draft — verify vs source" badge on proposed rows. A 3-lens adversarial review (no-auto-publish · legal-only-sourcing/no-fabrication · integration) caught a real blocker and I fixed it: the initial fetch usedredirect:'follow', so an allow-listed host that 302-redirects to a private/internal or non-official host would be fetched anyway (SSRF + a citation falsely attributed to the official source). It now follows redirects withredirect:'manual'and re-runs both the allow-list and the SSRF guard on every hop (mirroringfetchHtmlGuarded), citing only the URL whose bytes were actually read; re-verified by an independent agent (both blockers closed, no new defect). 383 tests (10 new — the trust invariants are the tests: never-verified, citation-is-the-fetched-URL, advice-drop, allow-list, redirect-safety), tsc 0, eslint 0, build EXIT=0; live: the route is super-admin-gated (401), and the public roadmap serves zero AI drafts. (Residual, pre-existing platform-wide: `validateExternalUrl`'s DNS-rebinding TOCTOU window — mitigated here by the static official-host allow-list, which rebinding can't forge.) - Android apps — login/launch UX pass across all three apps (keyboard, remembered user, splash, show-password), adversarially reviewed. Four requested enhancements, all in the shared
:coreso every app gets them: (1) the sign-in fields no longer hide behind the keyboard — the form is a vertically-centered scroll column withimePadding()applied outside the scroll (keeps the focused field above the IME regardless ofadjustResize). (2) a "Welcome back" remembered-user mode — once a device has signed in, the screen pre-fills the email and asks only for the password (email + server are remembered; the password is only stored if you opt into "stay signed in" — now default OFF so a normal sign-in never persists a password); "Change server" edits the address without losing the identity, "Different account" clears it. (3) a brand splash (logo + wordmark + progress bar) on cold start via a sharedBootGate— while logged in it holds until the first data sync resolves (ApiClientflips the signal on success or failure in afinally, so a dead server reveals the app's own error instead of hanging; capped at 3s). (4) a show-password eye toggle. Hardened by a 4-lens adversarial review (Compose correctness · boot/lifecycle races · credential handling · cross-app/theming): 15 findings raised, 10 confirmed after per-finding verification, 6 fixed (2FA-back now clears the typed password,imePadding/scroll order, sync-on-failure splash reveal,stayLoggedIndefault→off, server-editable in remembered mode, AppRoot theming made consistent with Pos/Waiter) and 2 rejected with reason (resetting the splash on logout would flash it on every sign-out; masking the email /FLAG_SECUREdefeats the feature and breaks the shared-Activity customer display). All three apps rebuilt green and reinstalled on the owner's phone. - Phase 28 FBL — GEO/SEO surfacing of the Launchpad (doc 12) + the delivery-platform VAT toggle (doc 09 §4.3). Two buildable-now, zero-legal-risk additions that close deferred items and serve the acquisition priority. (1) The Launchpad is now AI-citable + crawlable, behind the same publish gate as the guide. New
publishableLessons(nodes, country, businessType, now)returns only the LC lessons whose bound CKG node currently passespublishableNodes(verified + cited + effective). The platform/llms.txtgains a "Start a food business" section (the personalized roadmap, the verified guide, and each published lesson) — carrying an explicit assistant-not-advisor preamble so an LLM that grounds in these pages inherits the posture and cites the named authority — and the sitemap lists each published lesson page. Both follow the guide's discipline exactly: an unverified node's lesson is never advertised (it shows a "being re-verified" notice on its own page and silently leaves the sitemap/llms.txt until the human gate re-verifies); the llms.txt section is platform-host-only (tenant subdomains keep their merchant file) and degrades to nothing if no content is published. (2) `deliveryMargin` gains a VAT mode. Delivery apps display the gross (VAT-inclusive) price the customer pays and commission the operator on that gross — but the VAT is remitted to the state, so what you actually keep must be measured on the net (ex-VAT) revenue (doc 09 §4.3: entering app prices as if ex-VAT overstates the keep). With the new optionalvatRatePct+priceIncludesVat, the calculator backs the VAT out before "what you keep" (commission stays on the gross), exposingnetOrderValue/vatPortion; a one-tap "includes 12% VAT" toggle on the card (newSE_FOOD_VAT_DEFAULT_PCT) shows the honest, lower kept amount. Fully back-compatible — with no VAT fields the output is byte-identical to before (existing test values preserved). A 3-lens adversarial review (VAT economics · trust-law publish-gate · integration) raised 9 findings, 0 confirmed after independent verification — the math, the gate, and the wiring are sound. 375 tests (2 new), tsc 0, eslint 0, build EXIT=0; live-verified: all 7 lessons + the guide surface in llms.txt (with the preamble) and the sitemap, the tenant-subdomain file excludes them. - Two new native Android apps — Restora 360 POS (register) + Restora 360 Waiter — on a shared `:core` module. The
android/project is now a Gradle workspace::core(shared data layer, API client, auth/login, theme, brand mark, and a reusable sale toolkit — cart model, photo menu grid, options sheet, one-screen payment sheet) feeds three installable apps —:app(Live Orders/KDS/Customer Display, unchanged), `:pos`, and `:waiter`. Two staff-only API routes added (no public exposure,requireTenantAccess-gated):GET /api/tenant/[tenantId]/pos/menu(localized menu + option groups by id + tables + currency/Swish settings) andPOST /api/tenant/[tenantId]/pos/orders(server-recomputes every price from the live menu — options referenced by group/option id, not name-matching; status startsaccepted→ straight to KDS; cash/card/swish with optional already-paid), plusPOST .../pos/orders/[orderId]/paymentto settle an in-person payment (idempotent). Additive schema (exact Phase-27 design-doc field names so later phases reuse them):Order.typegains'counter'|'dine-in', newtableId/tableNumber/source, andpaymentMethodgains'card'; the KDS feed's broken table lookup (it matched table ids against order ids) is fixed to usetableNumber/tableId. POS app: photo menu grid + search + category chips → live cart with qty steppers → one-screen tender with cash (change calc), card (external terminal), Swish (number read-off), "today" order history with payment state, phone/tablet responsive (side-by-side menu+cart ≥840dp), light/dark. Waiter app: floor of tables (occupancy + running total + "ready to serve"), tap a table → its open orders with kitchen status + Served action → Add order (same menu/cart grammar, sends an unpaid dine-in order to the kitchen) → Take payment settles the whole table's bill. All three apps build green (debug + R8 release); POS + Waiter installed + verified on the owner's phone. tsc 0 across the new routes + theOrderunion ripple (bazar history projection widened to match). - Phase 28 FBL — LC Learning Center MVP (doc 10, LC-0→3): "Learn how" on every legal step, with no second CMS. Lessons live in code (
src/lib/launchpad/learning.ts— 7 beginner-grade lessons covering the full SE × home-bakery wedge: Skatteverket contact, F-skatt, moms, kommun registration + contact, egenkontroll, labelling), each bound to its CKG node via the `lessonRef` contract: the lesson carries only practical prose (how to fill the form, what to bring, what happens next) while THE LEGAL FACT is rendered live on the lesson page frompublishableNodes— cited, verified, freshness-monitored, never duplicated (an unverified node → an honest "being re-verified" notice, not a stale copy). New/launchpad/learn/[lessonId]page (ISR 1h, static params from the lesson registry, unknown ids 404);/api/launchpad/roadmap+/api/launchpad/profileroadmap steps now carrylessonId; "Learn how →" links on the funnel O3 result cards and the roadmap checklist rows. The trust law is test-enforced: every lesson is advice-clean (findAdviceLanguageover titles/intros/steps/tips), binds to a real seed node with full wedge coverage (a new node without a lesson fails the suite), and never restates rates/fees/amounts (%/kr/SEKregex gate — numbers live only in the node, where DR-5 watches them). 373 tests (5 new), tsc 0, eslint 0, build EXIT=0; live e2e: lesson page renders the live verified fact + citation, all 7 roadmap steps enriched, bogus lesson ids 404. - Android app v1.2 — the flapping "Connection problem" banner actually fixed (root cause) + brand polish. Root cause: Node HTTP servers drop idle keep-alive sockets at ~5s — the app's exact poll cadence — so OkHttp kept reusing a connection the server was closing at that instant; every race surfaced as a phantom error despite perfect Wi-Fi. Three-layer fix: (1) OkHttp
ConnectionPoolkeep-alive cut to 3s (sockets evicted before Node kills them), (2) every poll transparently retries once (500ms) on transport-level IOException, (3) banner requires 3+ consecutive failures AND >15s without fresh data, restyled from red warning to a calm amber "Reconnecting…" strip. Verified the proxy fast-paths raw-IP hosts (no per-request tenant resolution involved). Brand/UX:RestoraMark/RestoraWordmark(gradient clay R-tile + "Restora 360", matching the web) in header + login, header hairline, status-colored accent edge on order cards, lanes on soft panels. versionCode 3 / v1.2.0, installed + verified on the owner's phone. - Android app v1.1.x polish — uniform display tiles, scheme-free server input, dark/light modes. Customer Display: ONE order number per row in every column with identical tile height, text size and margins (Ready keeps its pulsing border but the same geometry) + an independent light/dark toggle on the display itself (persisted; dark = near-black with status-tinted lanes for dim dining rooms). Staff side gains an app-wide light/espresso-dark toggle (header sun/moon; themes login + Live Orders chrome — KDS stays always-dark, toggle hidden there). Login: the Server field accepts a bare address (
192.168.0.96:3001,your-domain.com) — IP-style hosts probe http→https, hostnames https→http, full URLs still respected. All iterations installed + verified on the owner's phone over adb. - Android app v1.1 — Customer Display view + per-device navigation + phantom-network-error fix. Third view in the switcher: Customer Display — the customer-facing queue (Accepted / Preparing / Ready-for-Collection with pulsing ready tiles, big distance-readable numbers, restaurant name + welcome message + clock, owner-configured per-column page rotation and ready-chime). It reuses the platform's display-token + public PII-stripped endpoint (
GET/POST .../live-orders/display-tokenthenGET /api/public/[tenantId]/live-orders?token=...— the exact same source as the paired web TV display; token reused, only minted if absent so an already-paired TV never breaks), and renders chrome-free immersive (admin bars + system bars hidden; discreet focusable exit + Back). Navigation per device class: phones now get a bottomNavigationBar(Orders · Kitchen · Display — thumb-friendly) with a slim header; pads/TVs get the segmented switcher centered in the top bar. Bug fix: the recurring-but-harmless "network error" banner was (a) in-flight requests cancelled by view switches/screen-off being caught as failures —CancellationExceptionis now rethrown, and (b) single Wi-Fi blips surfacing instantly — the banner now requires 2+ consecutive failed polls and reads "Connection problem — retrying…" while stale data stays on screen. versionCode 2 / v1.1.0; debug + release build green. - Native Android app (`android/`) — Live Orders + Kitchen Display for phones, tablets and Android TV. New Kotlin/Jetpack Compose app (
com.restora360.orders, repo-rootandroid/— zero changes toui/) with a segmented view switcher between the two owner surfaces: Live Orders (Incoming → Accepted → Preparing → Ready → Delivered-today lanes, accept/reject with confirm, one-tap advance, optimistic updates with revert-on-failure) and Kitchen Display (always-dark, NEW/ACCEPTED/PREPARING/READY lanes, per-second age timers with the web KDS's 3-min amber / 8-min red escalation, item options + removed ingredients + notes, Bump). Talks to the existing dashboard APIs (POST /api/auth/login+ verify-2fa for theplatform_sessioncookie — native HTTP clients aren't bound by cookie flags/CORS;GET .../orders,GET .../kds,PATCH .../orders/[orderId]) on a 5s poll that pauses when backgrounded — no new server code, tenant isolation unchanged. Form factors: phones get tabbed lanes, ≥600dp pads get side-by-side boards, TVs getLEANBACK_LAUNCHER+ banner, D-pad focus highlights (scale+border), overscan padding, keep-screen-on;touchscreen required=false. Unattended devices: opt-in "stay signed in" silently re-logs-in when the 24h session lapses (app-private storage; 2FA accounts fall back to the sign-in screen). Synthesized chime/beep/bell (no assets) with a persisted mute. Brand-matched (clay#BF601D/ warm paper / espresso). Debug + debug-signed release APKs build green (AGP 8.13, Kotlin 2.2, compose BOM 2025.09, minSdk 26 / target 36). - Phase 28 FBL P3 — DR-5 source freshness / change-detection (the deterministic diff pipeline) + funnel-observer fixes. Every VERIFIED node's cited source URL is now monitored: the page is fetched (SSRF-guarded via the shared
validateExternalUrl; https-only, private hosts blocked, 12s timeout), its visible text normalized (scripts/styles/tags/entities/whitespace stripped, lowercased — so nonces and asset churn never false-positive) and sha256-hashed; a changed hash FLAGS the citing nodes for human re-verification in the admin console — nothing is auto-unverified (a cosmetic site redesign must not silently unpublish verified facts; the human gate decides). Verifying a node clears its sources' flags (baseline hash preserved); flags persist across runs until a human acts. Surfaces:GET/POST /api/admin/launchpad/freshness(super-admin; impact map of affected nodes), a "Source freshness" panel on the admin Launchpad console (check-now button + per-node changed/unreachable warnings), andGET /api/cron/launchpad-freshness(CRON_SECRET, fails closed in production without it). Also: the CFJ funnel observer no longer freezes old launches out of G5/G6 observation (filters un-observed tenants BEFORE capping), and profile pruning runs on wizard traffic too (not create-only). 368 tests (6 new), tsc 0, eslint 0, build EXIT=0. Live e2e against the real authorities: 7 sources checked, second run stable, cron gated — and the first run caught a genuinely dead citation (the seeded Skatteverket F-skatt URL now 404s; flagged for the reviewer — exactly the designed workflow). - Phase 28 FBL P3 (no-legal-risk slice) — the wedge calculator suite + the first coverage-gated programmatic guide. (Calculators, doc 09): four new pure functions — `bakeryBatch` (#7, THE wedge tool: batch cost ÷ usable yield after waste + packaging + optional labor; no waste clamp — ≥100% waste honestly returns null instead of fabricating optimistic sellable items, a review catch), `profitEstimate` (#4 P&L-lite, pre-tax/ex-VAT), `deliveryMargin` (#8: platform-commission bite + the own-site comparison, labeled "before card fees & delivery"), `staffCost` (#9: wage + semesterlön 12% (semesterlagen) + arbetsgivaravgift 31.42% (skatteverket.se, 2026) — both editable inputs, compounding verified algebraically correct) — all money inputs explicitly ex-VAT in the UI (doc 09 §4.3's incl-VAT optimistic-margin trap). Six calculator cards now on /launchpad; 362 tests (every expectation independently recomputed by the review panel). (The guide, doc 11):
/start/en/home-bakery/in/sweden— the Type×Country programmatic page behind a real publish registry:isGuidePublishable= verified-servable AND substantial (≥5 steps, ≥3 legal —servablealone would permit exactly the thin page doc 11 forbids; review catch), enforced at render (dynamicParams=true so a clean CI build can't silently 404 the route — review catch) and mirrored by the conditional sitemap entry; Article+HowTo+FAQPage+Breadcrumb JSON-LD (HowTo = actionable steps only; FAQ numbers derived from the live roadmap); de-orphaned via /launchpad + the platform footer Resources column. tsc 0, eslint 0, 362 tests, build EXIT=0; live: guide 200 w/ valid JSON-LD ×4, bogus cells 404, sitemap gated, all six calculators render. - Phase 28 FBL P2 remainder — business-plan-lite + the admin funnel card + acceptance hardening (P2 buildable scope complete). (BP-lite, doc 08 BP-0→2 + BP-4):
buildPlanLiteis deterministic by law — every number is the founder's own input, a pure-calculator result, or a clearly-marked editable default, and ALL of them are listed in an assumptions register with sources; the legal section mirrors only the founder's VERIFIED roadmap (the publish gate upstream); the TC-0 disclaimer block is part of the document. Served by TC-gatedGET /api/launchpad/plan(computed on demand — nothing stored, GDPR-light) and exported via the print-friendly `/launchpad/plan` page (token read from localStorage, never the URL; the review gate = the founder sees the full document including the assumptions before printing; browser print→save-as-PDF — the repo's established export pattern). Linked from the O3 roadmap. (Admin): the CFJ funnel card on /admin/dashboard/launchpad — 6 gate counts with self-reported G2/G3 visibly marked. (Acceptance hardening, from the review's minors):tcAcceptedSha— the sha256 of the exact acceptance text (provable wording, doc 02 §12); the go-live acknowledgment ("checklist done ≠ legally cleared — I verify & file") is now a REQUIRED un-pre-checked checkbox at the claim moment, enforced server-side (400 without) and timestamped (goLiveAckAt); the provisioning handoff writes an audit-log entry (tenant, TC version, acceptance + ack timestamps) per doc 16 §5.2 "audited". tsc 0, eslint 0, 357 tests (4 new), build EXIT=0; live e2e: plan 400-gated pre-TC → 200 post (7 steps, 7 registered assumptions, break-even computed); claim without the ack → 400, with → 200 + stamped. - Phase 28 FBL P2 — the pre-auth founder funnel + the graduation handoff + the 6-gate CFJ funnel (the "prove the economics" phase). The Launchpad stops being read-only: (O1)
/launchpad/start— 3 quick tiles (country·type·premises, wedge-locked SE×home-bakery) → an ephemeral, GDPR-minimal profile (256-bit token, NO PII — the email only ever travels inside the signed claim token) + a deterministic teaser (the first 3 verified+cited tasks full, the rest locked titles — everything flows the existing publish gate, nothing un-verified can leak). (O2) the 8-input wizard (autosaved per step) + the versioned TC-0 acceptance (un-pre-checked; a text bump forces re-acceptance; the full roadmap is gated on a CURRENT-version acceptance). (O3) the personalized roadmap checklist with per-step Trust badges, citations and the mandatory assistant-not-advisor disclaimer rendered inline. (Handoff, doc 16 §5.2) "Go live with Restora 360" → the WoW magic-link handshake verbatim (15-min HMAC token, disposable-email gate, idempotent scanner-pre-click resume) →provisionFromLaunchpadmirrors WoW provisioning (atomic profile claim → tenant from template → user + tenant + 30-day trialing subscription → session →/dashboard?welcome=launchpad), with hardened failure windows from the adversarial review: rollback is guarded on the claiming tenantId (a loser can't un-claim a winner), a pre-master-push failure deletes the orphan tenant doc (retries no longer die on "already exists"), a post-push failure keeps the claim and recovers with a session (no founder walled off behind email_exists), Swedish names NFD-transliterate for clean subdomains (Kanelbullar på Söder → kanelbullar-pa-soder), and no fabricated contact details (the WoW placeholder phone is not written). (CFJ funnel) 6 gates: G1 validated (wizard+TC complete, system) · G2 registered / G3 licensed (founder-claimed checklist milestones, flagged `selfReported` so no dashboard can present them as system facts) · G4 launched (provisioning, system) · G5 first-customers / G6 growing (≥5 orders in 30d) system-observed from real tenant orders byGET /api/admin/launchpad/funnel. All five public routes rate-limited (incl. a 300/h global ceiling on O1); progress ids validated against the founder's own roadmap. tsc 0, eslint 0, 353 tests (8 new), build EXIT=0; live e2e: O1→O2→O3→claim→provisioned tenant (+1 exactly, triple-click safe), G1–G4 recorded, zero PII in profiles/events (panel-verified across 286 probe profiles). - Restora 360 rebrand + complete platform-site navigation + the "R" brand mark. (1) Rebrand: every word-boundary "Restora" → "Restora 360" across src (1,776 strings in 204 files — UI copy, SEO titles/metadata, JSON-LD, llms.txt, OpenAPI/Postman titles, docs-center + citation-hub content, emails, all 18 locales) AND the deployable static landing (index/help/llms/robots/og-image/favicon — the live-served
public/static/mirror swept too, andrestora360-coming-soon.ziprebuilt from the swept sources). Identifiers/keys/slugs/domains untouched (adversarially verified: no key/enum/storage-key/slug changed; 346 tests green). (2) Brand mark: newRestoraMark(gradient "R" tile + "Restora 360" wordmark) used by the landing header/footer, /login, /register, /register/payment, /admin/login and /wow (wine-glass icon retired); newsrc/app/icon.svgfavicon + regeneratedfavicon.ico/apple-touch-icon/og-image.pngvia sharp; the old "Restora·" dot-lockup pattern converted to the proper wordmark on 12 more surfaces (coming-soon, learn, site-check, /p/[slug], designer/supplier/agent navs + registers, verify-account). (3) Navigation: the platform header gains Website Builder (/wow) · Food Bazar · Learn; the footer services strip + link columns now cover every public product (/wow, /fsn, /shop, /ingredient, /site-check, /learn + the partner/help set — 16 tiles, clean 8×2). Review fixes: two would-be 404 links (/gift-cards, /chains have no index routes) removed; header switched to the lg breakpoint with nowrap labels (it overflowed 768–1230px); homepage now emits Organization + WebSite JSON-LD (previously none). tsc 0, eslint 0, 346 tests, build EXIT=0; live-verified (header/footer links, /static mirror title, R-tile on /wow, JSON-LD present). - Menu images on/off for owners — one switch, honored on every tenant-site surface (+ menu-settings consolidation). The existing Item photos control (Menu → Appearance & Layout →
menuLayout.showItemImages: photo cards vs a clean text menu) is now respected everywhere a dish renders on the owner's site, not just the /menu list: the item popup (ProductCustomizeModal gainshideImage; cart lines drop the thumb too) and the home-page menu blocks (featured+daily-lunch+menu-previewitems viatoPreviewItem;menu-fullnow falls back to the owner's saved layout). BlockRenderer derives the gate from `settings.menuLayout` (run through `normalizeMenuLayout`) rather thanctx.menuLayout, so it reaches the home page without activating the dormant menu-preview layout override there (which would have discarded owner-authored builder block settings — caught + avoided in adversarial review).FeaturedSectionnow collapses the image area to a clean text card when an item has no photo (previously a gray placeholder box). All default themes handle both states automatically. Write-path default fixed:DEFAULT_MENU_LAYOUT.showItemImages+normalizeMenuLayout(absent)are now photos-ON, matching the live default for tenants with no saved layout — so opening the Appearance tab (or a theme/builder save spreading the default) can no longer silently flip a photo site to text-only; a saved layout without the key still means a text menu. IA consolidation: the duplicated Account → Settings → "Menu Display" tab is gone — descriptions + ingredients toggles moved into Menu → Appearance & Layout next to Item photos (samemenuDisplaysetting, one save; orphanedMenuDisplayTab/MenuSettingsTabdeleted); the menu page's tab bar is now horizontally scrollable at 320px (it's the only home of these controls now). Deliberately out of scope: the Food Bazar keeps its own marketplace presentation, and gallery/hero blocks (site décor) still may use dish photos. tsc 0, eslint 0 on new code, 342 tests, build EXIT=0; live e2e on luigi-pizza: photos OFF → 0 rendered dish images and 0 placeholder boxes on /menu + home; ON → 50 on /menu, 7 in the home featured block. - Webshop card checkout (Stripe) — the last Phase 13 gate moves from integration-gated to env-gated.
/shopcheckout now offers Pay by invoice (net-30, default — the B2B loop) or Pay by card: the checkout API acceptspayment: 'invoice'|'card'; with a real Stripe key the card path creates the order (pending) + a Stripe Checkout session (ONE consolidated server-priced line = net + VAT; metadata-bound to the order) and returnscheckoutUrl; the return leg hits the new `POST /api/webshop/checkout/confirm`, which verifies the session is paid, bound to this order, and that the charged amount + currency match the server-computed total before marking the orderpaid(idempotent per session; a different session can never claim a paid order; cancel returns honestly to a pending invoice order). Dev-sim (no key): card orders settle immediately (stripeSessionId: 'dev_sim') so the flow is e2e-testable — and production without Stripe rejects BEFORE any DB write (503, no phantom orders).WebshopOrdergainspaymentMethod/stripeSessionId/paidAt. Mirrors the proven billing add-on Checkout pattern (sameSTRIPE_LIVEgate + confirm-on-return). tsc 0, eslint 0, 342 tests, build EXIT=0; dev e2e: card → 201status:'paid'+devMode, invoice unchanged (pending), confirm 503 without Stripe, junk payment value 400. - Phase 13 — Restora Webshop CLOSED 100% (buildable scope): the final surface pass. All WS-0…WS-7 cores were already shipped + adversarially hardened; recon showed the WS-6 admin UI (Analytics/Products/Coupons/Fulfillment tabs + barcode pick) and the WS-4 customer UX (Subscribe & Save +
/shop/subscriptions) were also already live (stale TODO notes corrected). This pass ships the four genuinely missing surfaces: (1) /shop instant search — a token search box over name/category/SKU with the exactsearchCatalogall-tokens-match semantics; (2) frequently-bought-together — the recommendations API gains?fbt=<productId>(anonymous co-occurrence counts over real orders — no customer data, so no auth; active products only) and the cart sidebar suggests up to 3 companions with one-tap add; (3) the agent webshop-earnings card — the dashboard API + KPI grid now split out the 2% product-sales stream (earned + the amount still inside the return window); (4) the agent leaderboard —/agent/leaderboard(+ sidebar entry) over a new purebuildAgentLeaderboard(rank by EARNED commission — paid/pending/batched; reversed + withheld excluded; suspended agents dropped; stable name tiebreak) with the subscription / add-on / webshop split per agent and the caller's own rank row when outside the top 10. Integration-gated remainder (deliberately not code): Stripe card checkout (B2B pays by invoice), carrier labels/webhooks, multi-warehouse ops, non-EU tax engines, third-party-seller KYC, multi-currency rails, optional capped LLM NL-search. tsc 0, eslint 0, 342 tests (2 new), build EXIT=0; live e2e: FBT returns the real co-occurring product (count 2) from seeded guest orders, due-reorders stays 401 unauthenticated, leaderboard 401-gated, /shop renders the search box + catalog (visually verified). - WoW clone fidelity CF-2 — the style-fidelity pack (shape dividers · glassmorphism · scroll-parallax). The last planned fidelity tier from the cloning-engine doc (§Phase 4): three new
BlockStylefields — `divider` ('wave' / 'tilt' / 'curve': an SVG shape cut along the block's bottom edge, painted in the page background colour so sections flow into each other instead of meeting in straight lines), `glass` (frosted glassmorphism: translucent surface + backdrop blur; an explicit bgColor/gradient wins, the blur stays), and `parallax` (a subtle scroll drift in pure CSS —animation-timeline: view()behind@supports+prefers-reduced-motion, so it's zero-JS and a graceful no-op on older browsers). All three render inBlockRenderer's style wrapper, are owner-editable in the builder's Style panel (incl. reset), and are clone-mapped: the DNA vision call now detects `sectionDividers` (how the source's sections MEET — wavy / diagonal / arc / straight), the mapper puts the detected divider + a wow-mode parallax on the hero (the classic "wave under the hero"), and dark wow-mode designs get glass benefit cards (scheme-gated — frost is invisible on white).BuilderState's mirroredBlockStylekept in sync. tsc 0, eslint 0, 340 tests (2 new: divider/parallax mapping, scheme-gated glass), build EXIT=0; visually verified live (wave divider cutting the hero image). - FSN P3 (M1+M2) — the monetization wedge: the `fsn-recruiter` subscription + ATS-lite hiring pipeline. The roadmap's "cash engine" test (doc 16 §Phase 3) is live. M1 — recruiter subscription: a new `fsn-recruiter` add-on (199 SEK/mo / 1 990 SEK/yr) in the feature registry — sold through the existing billing add-on marketplace (trial/Stripe checkout included). Outbound talent search is the paid surface:
GET /api/fsn/talent/searchnow returns 402 + the registry's live feature metadata for un-entitled tenants (posting jobs + viewing your own applicants stays free — the deliberate freemium line); the hiring dashboard's Find-talent tab renders the standard UpgradePrompt from the 402 body (no hardcoded-price drift). The paid search also now honors profile privacy opt-outs (isPrivate/allowDiscovery— members who opted out of discovery are not sold to recruiters) and accepts both superadmin cookies. M2 — ATS-lite: applications carry a pipeline stage (Applied → Screening → Interview → Offer → Hired / Not selected; additive — old rows read as Applied) viaPATCH .../fsn/jobs/[jobId]/applications/[appId](operator-auth, web-designers 403, rate-limited, no-op-safe, suspended applicants 409). Employers get per-applicant stage controls + an interview scheduler (datetime + BYO https video link, normalized + validated, reschedulable, errors surfaced — never silently swallowed); leaving interview/offer clears the interview details (a rejected applicant never keeps seeing a join link). Applicants get a new `/fsn/applications` page (stage, interview time, join link; linked from the jobs board) and a new `application` notification type whose actor is the restaurant (denormalised name; the unread row per job is replaced, not appended — stage churn can't flood the inbox or bloat the FsnDB doc). `Hired` closes the loop on the moat: it records the HireEvent wedge metric (idempotent per profile+restaurant, audit-logged, cross-path consistent — the Find-talent button no longer falls back to a phantom'platform'employer), and job-board hires are never undercounted: applicants without a talent profile get a minimal self-owned one created at hire time. Hardened via a 3-lens adversarial review (16 findings fixed). tsc 0, eslint 0, 338 tests (4 new), build EXIT=0; live e2e: 402 → add-on purchase (201, dev billing) → 200 · post → apply → screening → interview → hired (hireRecorded: true) · no-op guard, notification replacement (1 unread row after 3 moves), interview cleared on rejection, bad stage/http link → 400. - FSN-10 — the launch-safety pack (Phase 26): moderation, blocking, hardened media, tracked sessions. The design corpus (doc 14/R3) makes moderation a hard precondition for opening UGC publicly — this closes it. (A) Report + block:
POST /api/fsn/report(post/comment/account, 6 reasons, rate-limited per-account + a global hourly ceiling against flag-bombing, idempotent per reporter+target) andPOST/DELETE /api/fsn/block/[accountId]with mutual invisibility — posts/comments/notifications vanish both ways, follow edges severed, and blocked users can't follow/comment/like/save across the block (silent 404s, never revealing it). ⋯ menus on post cards + profiles; a Blocked-accounts list (Settings → Privacy). (B) Super-admin moderation queue (/admin/dashboard/fsn/moderation, new sidebar entry): open reports with content preview + author/reporter context; actions Remove content (status'removed'— reinstatable, distinct from user deletes; comment takedowns adjustcommentCount), Reinstate, Suspend (kills every live session permanently — devices do NOT resurrect on unsuspend), Unsuspend, Dismiss — all audit-logged. (C) Media hardening: uploads are decoded + re-encoded through sharp (stored bytes are our pixels — kills polyglot/spoofed-MIME payloads; EXIF/GPS stripped; transparency flattened to white), 50 MP pixel-bomb guard, 64px floor / 2400px ceiling, 500-file per-account quota. (D) Tracked sessions: every login/register/bridge/OAuth writes a device record (sidtokens);getFsnSessionvalidates centrally against the store, so device revocation, "Sign out everywhere else" (legacy-cookie callers are upgraded in place so it keeps its promise), suspension and GDPR deletion bite immediately; device list in Settings → Security. Hardening from a 4-lens adversarial review (16 findings fixed): a critical pre-existing pre-auth DoS — the handle-collision suffix loop never terminated on a 20-char handle (2 requests froze the entire Node process; now a boundeduniqueHandlehelper, regression-tested live); suspension evasion via OAuth/bridge (status-filtered lookups minted a fresh active account on the same email — now refused with an honest message); suspension now revokes sid records so unsuspend can't resurrect a compromised device; invisibility extended beyond feeds to search (posts/tags/people), the public recipe page, talent profiles, employer talent search, saved/collections, notifications, and meal pages; comment threads + like/comment/save gated on post visibility (leaked ids can't touch private/followers-only posts); 2FA backup codes consumed atomically (replay race fixed); honest suspended-login message (403 after credential proof, not "wrong password"); GDPR erase now wipes sessions (IP/UA) + block edges + the uploads folder, and the export gains sessions/blocks/reports/likes while stripping the admin-onlyremovedReason; token validator rejects missing-exppayloads; expired devices hidden from the list. tsc 0, eslint 0, 334 tests (11 new), build EXIT=0; live e2e green (register→post→report→block→mutual invisibility→comment-cut→logout server-side revocation→stolen-cookie 401→media re-encode/reject→DoS regression in 26ms). (Admin queue actions not e2e'd locally — no superadmin in the dev DB; gate verified 401 + primitives unit-tested.) - WoW clone fidelity CF-1 — tone-matched clone copy (+ P-a hero CTA intent). Every clone used to ship the IDENTICAL generic copy ("Why dine with us / Fresh, made daily / Crafted with care") — the loudest template-generic tell against the owner's "extremely similar" bar. New `src/lib/wow/clone-copy.ts`: ONE bounded text call (the same cheap
AI_THEME_MODEL,max_tokens1000, 10s timeout / no retries — fail-fast because the deterministic defaults ARE the fallback, metered viarecordWowUsagetoward the global daily WoW ceiling, skipped on the fallback DNA so failed extractions never pay to harmonise with the cliché) generates a validatedCloneCopyPack(per-field caps, surrogate-safe truncation, benefits all-or-nothing) from abstract DNA fields only (personality/cuisine/invented brandName/tagline/ctaIntent — never source content, so the copy is original by construction);applyCopyPackswaps the mapper's hardcoded strings in place (links/images/icons/layout untouched), wired into both clone routes after the imagery touch-ups, kicked off early so the call overlaps the menu/photo work. Legal hardening from a 4-lens adversarial review: the DNA tool's inheritedtaglinefield is now explicitly INVENT-only (it could previously transcribe the source's slogan — the one field that broke "never copy text"); the copy prompt bans verifiable-shaped claims (founding years, heritage, awards, "#1"/"best", certified-sounding sourcing claims) with avalidateCopyPackpattern backstop that drops any field slipping through; the interpolated DNA strings are framed as untrusted data (screenshot prompt-injection guard). P-a:DesignDnaSpec.ctaIntent('order'|'reserve', vision-detected, optional) — ordering-led sites get an "Order Online" → `/menu` hero CTA (the review caught the original/ordertarget: that route doesn't exist — it would have 404'd every order-led clone), unknown keeps the reservation default. Also fixed from the review:split-hero.ctaLinkis now basePath-rebased inBlockRenderer(it previously pointed path-served sites at the platform root); the hero-guarantee + claim-time name stamp no longer mistake the story split-hero for the page hero (the name was clobbering the story headline); the deadreservation-cta.ctaTextphantom key is gone andctaTitleis prompt-anchored to booking voice; the story split-hero subheadline is re-capped at 160 chars; the dev mock (WOW_COPY_MOCK=1, never production) is claim-free by policy. tsc 0, eslint 0, 323 tests (14 new), build EXIT=0; keyless e2e verified (extract → claim → live site renders deterministic defaults unchanged); mock e2e verified (pack generated → applied → intent honoured). (Real-model copy quality not verifiable in this dev env — no resolvable AI key; the call mirrors the proven `design-dna.ts` pattern.) - `static/help.html` + `static/restora360-coming-soon.zip` — self-serve deployment kit for the pre-launch page. A branded, step-by-step HTML guide (upload → DNS/SSL → the no-database email-capture model → verify → Search Console/Bing indexing → at-launch cleanup;
noindex, explicitly marked "don't upload this file") plus a ready-to-extract zip containing exactly the 8 deployable files — so the restora360.com upload needs no repo knowledge. - Standalone pre-launch landing page (`static/` at the repo root) + CORS on the newsletter endpoint. A dependency-free HTML version of
/coming-soonthat deploys ALONE (no app/build) so the brand is indexable + AI-citable while the platform is under development, with a working launch-list signup. `static/index.html` mirrors the real page (same English copy — h1, 5 pillars, dark showcase, the 7-question launch FAQ — same#bf601dbrand, Geist + system fallback, inline SVG icons, responsive 320→1280px, native<details>FAQ). SEO: canonical, trimmed meta description, full OG (1200×630 PNG, rendered from an SVG source via headless Chrome) + Twitter cards,apple-touch-icon.png, honest hreflang. AI SEO (GEO): one JSON-LD@graph(Organization + WebSite + SoftwareApplication withAggregateOfferlow-price 199 SEK + FAQPage that exactly mirrors the visible answers), plus static `llms.txt` (llmstxt.org manifest with answer-first facts), `robots.txt` with the platform's AI-crawler allowlist (GPTBot/Google-Extended/Perplexity/Claude/CCBot/Applebot-Extended…), and `sitemap.xml`. Email → admin: the form POSTs{email, source:'coming-soon', locale}to the real/api/public/newsletter/subscribe, so signups land in Super-Admin → Marketing & Growth → Newsletter; the endpoint now has an OPTIONS handler + CORS headers so a static page on a different origin gets readable success/validation responses (setwindow.RESTORA_API_BASE); offline/unreachable signups queue inlocalStorageand auto-flush on the next visit (409-safe: idempotent server). A 5-lens adversarial review (SEO · GEO · a11y · capture · fidelity) drove the hardening: WCAG-AA contrast fixes (accent text#a3521a, grays ≥4.5:1, input border), heading-order fix,<noscript>notice, success focus management, honest 429 ("try again" instead of fake success), JSON-LD↔visible FAQ exact match, the site-check CTA is probe-gated (never a 404 on a static deploy), and a view beacon feeds Visitors analytics. Verified: JSON-LD parses, PNG dims exact, live round-trip → subscriber stored, CORS preflight/POST/400 all readable, real 375px + 1280px renders clean. - WoW clone fidelity — two new page-builder blocks (`logo-strip` + `editorial-text`). Closing the next tier of the AI-website-cloning roadmap (split-hero, feature-cards and the masonry/bento gallery were already shipped — the roadmap doc was stale). `logo-strip` — a row of partner / "as featured in" logos (greyscale → colour on hover); a real builder + template block. The clone mapper still drops the
logossection rather than fabricate empty placeholders (we never copy a site's logos), so cloned previews stay clean while owners/templates can use the block with their own press logos. `editorial-text` — a magazine-style story: eyebrow + headline over a 1–3 column body with an optional drop cap;dnaToBlocksnow routes text-heavy `story` sections to it (visual stories stay onsplit-hero). Both wired through every builder integration point —WebsiteBlockType, the section registry (palette + defaults), the editor field schema, and the live-siteBlockRenderer— so they're addable, editable, render on tenant sites, and emitted by the cloner. tsc 0, eslint 0, 309 tests, build EXIT=0; mapper verified (editorial story → editorial-text, visual story → split-hero, logos dropped). (Built on a new `feat/wow-clone-blocks` branch off the parked FSN branch.) - FSN-9b — Google & Apple social sign-in (env-driven). Real OAuth login for Food Stories that activates the moment provider credentials are present. `/api/fsn/oauth/[provider]/start` → provider consent → `/callback` (GET for Google, POST/formpost for Apple) verifies a signed, stateless `state` (HMAC + PKCE for Google; ES256 client-secret JWT for Apple via node `crypto`), reads the verified email/name, **find-or-creates** the `fsnaccount
(by owned email; recordsoauthProvider), and issues thefsnsession`. The join screen's Google/Apple buttons light up when configured (via **`/api/fsn/oauth/config`**) and otherwise stay "Soon"; until then `start` redirects to `/fsn?error=oauthunavailable(no half-working flow). A **dev-only mock** (FSNOAUTHMOCK=1, never in production) makes the whole flow e2e-testable. Security: CSRF-safe signed state (tamper-rejected), verified-email only, secrets stripped bypublicAccount. **To activate:** Google →GOOGLEOAUTHCLIENTID`/`GOOGLEOAUTHCLIENTSECRET(register{origin}/api/fsn/oauth/google/callback); Apple →APPLEOAUTHCLIENTID`/`APPLEOAUTHTEAMID/APPLEOAUTHKEYID`/`APPLEOAUTHPRIVATEKEY; sharedGOOGLEOAUTHSTATESECRET` (≥32 chars in production). tsc 0, eslint 0, build EXIT=0; verified — live not-configured gate + OAuth core unit tests 10/10 (mock, authorize URL, signed-state + tamper, Google/Apple identity); provisioning is bridge-identical. (The live Google/Apple console flow isn't run here — needs real credentials; Apple especially is unverified against Apple's servers.)_ - FSN-9a — two-factor authentication (TOTP) for Food Stories accounts. Settings → Security's "2FA: Coming soon" is now real: enable via an authenticator app (
POST /api/fsn/me/2fasetup → QR + secret → verify a 6-digit code → enable), one-time backup codes, and disable (TOTP or backup code). Reuses the platformtotp.ts(base32 / HMAC-SHA1). The/fsnlogin now issues a challenge when 2FA is on —{twoFactorRequired:true}drives a code step in the sign-in card; a TOTP or a backup code (consumed once) completes it. NewFsnAccount.twoFactorEnabled/Secret/BackupCodes, and `publicAccount` strips the secret + backup-code hashes (verified they never appear in/me). Also fixed averifyTOTPRangeError(timing-safe compare on mismatched lengths) by guarding it to 6-digit codes before calling it. tsc 0, eslint 0, build EXIT=0; e2e-verified (enroll → TOTP login, wrong-code → 401, backup one-time + consumed, disable, normal login after). Deliberately still "coming soon" (need real credentials / session store): Google/Apple OAuth login and session/device management. - FSN-8 — Operator → Food Stories bridge. A signed-in operator can now get a Food Stories presence without a second sign-up: a "Join Food Stories / Open Food Stories" card on `/dashboard/hiring` (operator-branded) calls `/api/fsn/bridge`, which provisions (or reuses) a Food Stories account for the logged-in
UserRecordand issues thefsn_session. Per ADR-FSN-identity this is not a merge — it's a separatefsn_account(own namespace + cookie) with an optional `linkedUserId` back to the platform user; an account already registered at/fsnwith the same (owned) email is adopted/linked rather than duplicated.GETreports whether one exists (card label),POSTprovisions-or-logs-in. Auth-gated bygetSession()(platform session). tsc 0, eslint 0, build EXIT=0; auth gate (401 unauth) verified + the provision/cookie path mirrors the smoke-tested register flow. (Full operator-login→bridge e2e not run locally — no operator seeded in this dev DB.) - FSN-7 — real media (profile photos + multi-image posts). Two photo-rich gaps closed. (1) Avatars:
TalentProfile.avatarUrl+ a shared `FsnAvatar` (uploaded photo, else initial-on-gradient) now render everywhere — feed post cards, the mobile stories rail, search "Top Chefs", the profile hero (rounded-square), and the nav account button. Hydration (hydratePost/hydrateComment/hydrateNotification) + the people-search projection carryavatarUrl. Upload lives in Settings → Profile ("Change photo" →/api/fsn/media→PATCH /me/profile). (2) Multi-image posts:FsnPost.imageUrls[](cover = first;imageUrlkept as cover for back-compat). The create flow now uploads every selected photo (not just the cover);POST /api/fsn/postsvalidates + stores the gallery; the feedPostCardrenders a gallery (1 = hero, 2 = split, 3+ = bento with “+N”), and the recipe page shows a thumbnail strip. GDPR erase scrubsimageUrlstoo. Additive + back-compat (single-image posts and initial avatars unchanged). tsc 0, eslint 0, 309 tests, build EXIT=0. (Built on the new `ui/` app root after the repo restructure; verified via build + tests — no live dev server was running for e2e.) - FSN — account menu + sign-out in the nav. The top-bar avatar is now a dropdown (Your profile · Settings · Saved · Sign out), so logging out is reachable from every FSN page including the profile (previously sign-out only lived on the
/fsnaccount page). Sign-out clears thefsn_sessioncookie and returns to/fsn. - FSN makeover — fidelity rebuild of 7 screens to match the mockups exactly. The owner flagged that the create-post flow, search and profile diverged from the
stitch_restorascreenshots. Rebuilt faithfully: (1) Create-post is now a full-page flow (FsnCreateat/fsn/create, replacing the cramped modal) — Media (drag-drop card + preview grid) → Details ("Step 2: Details & Story", 2-column with a large media preview + underline-input form + tag chips) → Recipe Info ("Refine Your Masterpiece", spacious Ingredients name/quantity rows + numbered Instructions, "Back to Media" / "Continue to Summary") → Review & Post (2-column: Post Settings — visibility, Allow-Comments, Show-in-Global-Feed, tags, Final Checklist — beside a live feed-card preview) → Published (a full celebratory "Masterpiece Shared!" canvas with View Post / Return to Feed + "What's next?"). (2) A visible search bar in the top nav (input, not just an icon) → (3) `/fsn/search` results page (FsnSearchResults): "Results for …", All/Recipes/Chefs/Tags filter tabs, a Featured-Recipes grid, Top-Chefs cards with Follow, and a Trending-tags / Quick-tags sidebar. (4) Profile refined to the mockup — a large rounded-square avatar, an Edit Profile (self) / Follow (others) action in the hero (FsnProfileActions), and Posts / Recipes / Saved tabs with icons (Saved self-only) + series-style category labels on the photo-tile grid. The feed CTA + mobile FAB now route to/fsn/create; the old modal wizard was removed. tsc 0, eslint 0, 309 tests, build EXIT=0; all four routes verified live. - FSN makeover P6 — mobile pass (the makeover is now complete). Drove a 9-agent parallel audit of every FSN screen at 320–768px against the
*_mobilemockups (50 gaps: 6 high / 21 med / 23 low), then implemented the fixes. The signature mobile pattern: a new `FsnStories` curators rail — a horizontal-scroll of chef avatars with gradient rings at the top of the feed (mobile-only; desktop keeps the Discover sidebar). Epilogue headlines on mobile loaded vianext/font(a newsrc/app/fsn/layout.tsxexposing--font-epilogue; the shell applies it to h1–h3 under 768px). iOS safe-area handled — the bottom-nav getspb-[env(safe-area-inset-bottom)]+ a fixedh-16+ elevation shadow + an active-tab pill, and<main>clears it viapb-[calc(5rem+env(safe-area-inset-bottom))]. A pervasive 44px touch-target sweep across the shell (header icons, bottom-nav), feed (tabs, comment send), profile (follow, tabs, touch-reveal tiles), recipes (chips), wizard (close, photo-remove always-visible on touch, responsive recipe grid), settings, contact (full-width CTA), saved and explore. Recipes hero goes mobile-tall (aspect-[4/3]→16/9→21/9); filter chips and recipe categories scroll horizontally; the help bento is 2-up on phones. tsc 0, eslint 0 (the prior font-<link>warning resolved bynext/font), 309 tests, build EXIT=0; mobile chrome verified live. All 7 makeover phases (P0–P6) shipped. - FSN makeover P5 — support center + contact + join OAuth (mockup-faithful). A `/fsn/help` center matching the mockup — "How can we help you today?" search, a 4-topic bento (Getting Started · Account & Security · Creating & Sharing · Recipes & Collections), featured articles, and a "Still need help?" row (Email Support → contact; Live Chat / Community Forum honestly "Coming soon"). 24 help articles were generated then adversarially verified against the actual FSN code by a multi-agent workflow — the verifier caught and fixed real inaccuracies (e.g. the verified badge needs an employer claim code, not work-email auto-claim; only the cover photo is published; collections have no public/private toggle), so nothing describes a non-existent feature. Articles render at `/fsn/help/[slug]` (statically generated,
FAQPageJSON-LD for SEO/GEO) via a dependency-freeMiniMarkdown. A `/fsn/contact` form (category · subject · description →POST /api/fsn/support, a newFsnSupportTicketstore; works signed-in or anonymous-with-email; rate-limited) with an inline success screen. The join screen gains "Continue with Google/Apple" buttons marked "Soon" (honest — email+password is the real path). Help + Contact wired into the shell footer. tsc 0, eslint 0, 309 tests, build EXIT=0; e2e-verified (ticket 201/400, pages render, FAQ schema present). (Remaining: P6 mobile pass.) - FSN makeover P4 — the settings suite (mockup-faithful) + real backend. A new `/fsn/settings` with the mockup's sidebar (Profile · Notifications · Privacy · Security), reachable from a gear in the shell. Profile: display name, username, bio, personal website →
PATCH /me/profile. Notifications: Activity + Updates toggles (likes/comments/follows/mentions/newsletter/announcements/recommendations) + a push radio (all/personalised/off) — persisted (delivery turns on when those channels ship). Privacy: private profile, show-online-status, search-indexing, discoverable, tagging policy — persisted, with two wired for real (search-indexing → profilenoindex; discoverable + private → excluded from people search) — plus Download my data (GET /api/fsn/me/export, full JSON) and Delete account (typed-DELETE confirm → the existing GDPR erase). Security: password change (POST /api/fsn/me/password, verifies current — real); 2FA, sessions and connected apps honestly shown as "Coming soon". NewTalentProfilesettings fields;/menow returns the owner's privatesettings. tsc 0, eslint 0, 309 tests, build EXIT=0; e2e-verified (settings persist, password 200/400, export JSON). (Remaining: P5 support + OAuth, P6 mobile pass.) - FSN makeover P3 — the create-post wizard (mockup-faithful). A new `FsnCreatePostWizard` modal replacing the inline composer: a stepped flow matching the design — Media (drag-drop / pick, multi-select preview grid with remove + cover marker) → Details (Story/Recipe toggle; title or caption, description, cooking time, servings, difficulty, tag chips) → Recipe Info (recipe only: ingredient name+quantity rows + numbered instruction steps, add/remove) → Review (Visibility Public/Private, an Allow-Comments toggle, a readiness checklist) → a "Published!" success screen with View-post. Opens from a "Share a food story or recipe…" CTA in the feed. Allow-Comments is now real backend (
allowCommentson the post; the comments route returns 403 when off). Honest scope: the first photo is persisted as the cover (multi-image gallery is a later backend extra) and difficulty rides along as a tag. tsc 0, eslint 0, 309 tests, build EXIT=0; e2e-verified (wizard recipe publish + comments-off→403 / on→201). (Remaining: P4 settings suite + backend, P5 support, P6 mobile pass.) - FSN makeover P2 — recipes-browse + explore hero + demo seed data (image-rich). A new `/fsn/recipes` browse page (added to the shell nav): a featured hero (21:9 photo + title + meta + CTA), filter chips (All + popular #tags), and a "Trending now" grid of recipe cards (
GET /api/fsn/posts?type=recipe). The explore page gains a Forest-Green hero banner above trending. To make every screen look alive (the mockup is full of food photography), a dev-only seed (POST /api/fsn/dev-seed, blocked in production, idempotent) populates 6 chef profiles + 16 image-rich posts/recipes + comments + a follow graph, using the repo's/global-mediafood photos — so the feed (ranked), recipes, profiles and explore now render full and visual. tsc 0, eslint 0, 309 tests, build EXIT=0; seed + screens verified live. (Remaining: P3 create-post wizard, P4 settings suite + backend, P5 support, P6 mobile pass.) - FSN makeover P0 — the Food Stories sub-brand + feed/profile restyle (from the stitch_restora mockup). After a 7-agent analysis of the 38-folder mockup (docs/products/food-stories-network/18-mockup-makeover-plan.md), FSN adopts its own Forest-Green/Terracotta identity (
src/lib/fsn/theme.ts) scoped to/fsn(the operator dashboard + marketing keep burnt-orange). New shared `FsnShell` chrome — glassy sticky top nav (logo · Feed/Explore/Jobs · search/bell/saved/avatar) + a mobile bottom-nav + FAB + footer — replaces the marketing chrome on FSN pages. The feed is rebuilt to the mockup: an 8+4 grid (photo-hero post cards + a "Discover Trending" rail), Forest-Green interaction bar. The profile gains a hero avatar, Posts/Followers/Following stats, Posts/Recipes tabs, and a photo-tile grid (hover engagement overlay). Composer + SaveButton swept to the green palette. tsc 0, eslint 0, 309 tests, build EXIT=0. P1 now done — every remaining FSN screen (explore, notifications, saved, collections, tag, jobs + job detail, recipe detail, the account/join page, profile) is onFsnShell+ the Forest-Green palette (the marketingHeader/Footerremoved from all/fsnroutes; only the pre-auth/recipe-cardmarketing widget keeps the main brand). (Remaining: P2 new screens, P3 create-post wizard, P4 settings suite + backend, P5 support, P6 mobile pass.) - Phase 26 — FSN: the public job board (the verified-hiring moat, consumer side). Restaurants post openings from `/dashboard/hiring` → Jobs (operator-auth:
POST/GET /api/tenant/[tenantId]/fsn/jobs,GETapplicants + close per[jobId]); the public `/fsn/jobs` board (search + cards) and `/fsn/jobs/[id]` detail (with schema.org `JobPosting` JSON-LD for Google Jobs / GEO) let FSN members apply with their profile (POST /api/fsn/jobs/[id]/apply, idempotent + optional note) — and the employer sees applicants (with verified-employment badge) right in the dashboard. Newsrc/lib/fsn/jobs.ts(pure:normalizeJobInput,publicJob,jobJsonLd). Reuses the roster's operator-auth pattern + the FSN session; GDPR erasure drops applications. Linked from the feed nav. This connects the content network to the hiring moat — jobs ↔ verified talent. tsc 0, eslint 0, 309 tests, build EXIT=0; verified (public board + both auth gates 401 + search). - Phase 26 — FSN: Collections (named Pinterest-style boards). Save now supports boards — the bookmark opens a picker (
SaveButton) to save to "All saved", an existing collection, or a new one created inline.GET/POST /api/fsn/collections(list with item counts / create) +GET /api/fsn/collections/[id](a board + its posts, public-viewable / owner-only for private);/api/fsn/posts/[id]/savetakes an optionalcollectionId(one save per post — saving again just moves it to the chosen board). The `/fsn/saved` hub now shows your boards as cards + a create form + the all-saved list, and `/fsn/collections/[id]` is a board page. GDPR erasure drops collections too. tsc 0, eslint 0, 309 tests, build EXIT=0; e2e-verified (create board → save into it → itemCount 1 → board lists the post). The full social loop is now like · comment · follow · save-to-collections, with a ranked feed, notifications and discovery. - Phase 26 — FSN: ranked Discover feed + notifications (activity inbox). Ranked feed — Discover is now the "Recommended" feed, ordered by a deterministic hot score (
hotScore: recency-decayed engagement, comments/saves weighted above likes) instead of pure reverse-chronological; Following stays chronological (the trust feed). Pure + unit-tested (309 total). Notifications — like/comment/follow now generate an activity notification to the target (no self-notify; de-duped per unread kind).GET /api/fsn/notifications(list + unread count) +POST(mark all read); a `/fsn/notifications` inbox (actor avatar + verb + post snippet, unread highlight, auto-marks read on view) and a bell with an unread dot in the feed header. GDPR erasure scrubs notifications too. tsc 0, eslint 0, build EXIT=0; e2e-verified (3 interactions → 3 unread → mark-read → 0; self-like suppressed). - Phase 26 — FSN: Save/bookmark (the 4th social verb) + the Saved page. Every post/recipe card gains a bookmark that saves it to your account (
POST/DELETE /api/fsn/posts/[id]/save, idempotent + denormalisedsaveCount), and `/fsn/saved` lists your bookmarks newest-first (GET /api/fsn/saved). Reachable from a bookmark icon in the feed. Completes the like · comment · follow · save loop.PostCardowns save state locally; GDPR erasure now also drops the subject's saves. tsc 0, eslint 0, 308 tests, build EXIT=0; e2e-verified (save→1, saved list, unsave→0). (Named Pinterest-style collections remain a later slice.) - Phase 26 — FSN next slices: structured recipes (AI auto-fill) + discovery (explore · tags · search). Recipes are now first-class — a post with
type:'recipe'carrying a structuredRecipeCard(title, ingredients, steps, prep/cook/servings). The feed composer gains a Recipe mode with AI auto-fill (paste any recipe → the existing/api/fsn/recipe-cardSonnet call structures it into the fields), recipes render as rich recipe cards in the feed, and/fsn/recipe/[id]is a full recipe page with schema.org Recipe JSON-LD (SEO) + an estimated nutrition panel computed by reusing the deterministic nutrition engine on the ingredient lines. Discovery:/fsn/explore(search people · #tags · stories/recipes viaGET /api/fsn/search) + clickable `/fsn/tag/[tag]` pages (GET /api/fsn/posts?tag=), reachable from a search icon in the feed and#taglinks on every card. New reusableFsnComposer,FsnPostList,FsnExplore. tsc 0, eslint 0, 308 tests, build EXIT=0; e2e-verified (recipe post → feed card → tag page → search → recipe detail with schema.org + nutrition). Still deferred: collections/save, ranked feed, notifications, job board. - Phase 26 — Food Stories Network: the social platform (P2 "Content Core" MVP).
/fsn/feedis now a real professional-social feed for the food world, grounded in the FSN design corpus. Members post food stories (text + #hashtags + an optional photo viaPOST /api/fsn/media), browse a Discover or Following feed (GET /api/fsn/posts, cursor-paginated, visibility-respecting), and like · comment · follow (/api/fsn/posts/[id]/like,/comments,/api/fsn/follow/[accountId]— all idempotent, denormalised counts). Profiles became social profiles (/fsn/talent/[id]resolves by account or profile id) with an avatar, verified badge, open-to-work + LinkedIn, a Follow button and the person's post timeline (?author=). Newsrc/lib/fsn/social.ts(pure, testable:normalizePostInput,extractTags,selectFeedwith cursor,hydratePost) + 5 tests (308 total); reuses FSN auth (getFsnSession),mutateFsnDB, rate limits, the#bf601dbrand + the real landing chrome. GDPR erasure now soft-deletes the subject's posts/comments + drops their follow/like edges. Stored pragmatically in the FsnDB doc (normalised Postgres tables are the documented scale step). tsc 0, eslint 0, build EXIT=0; e2e-verified (two accounts: post → discover → follow → following feed → like → comment). Deferred per the roadmap: structured recipes/AI auto-fill, collections, ranked discovery, notifications, the public job board. - Food Stories Network — friction-free join + self-serve profiles + LinkedIn (was employer-verified-first). Joining is now just email + password (the handle is auto-derived from the email, collisions auto-suffixed; name optional) — no more upfront handle/display-name form. Critically, a member can now create their own profile without an employer code:
PATCH /api/fsn/me/profilecreates a self-ownedTalentProfile(flaggedselfCreated) on first save, so the/fsnpage leads with an "I'm looking for work" toggle + name, headline/bio, LinkedIn link (strict*.linkedin.comvalidation; free Restora branding) and skills. Employer *verification is now a bonus badge*** ("Get the verified badge" via the claim code), not a prerequisite. tsc 0, eslint 0, 303 tests, build EXIT=0. - Nutrition surfaces — swapped to the real product chrome. The directory, detail, calculator and
/fsnpages now use the canonical landingHeader+Footer(logo, nav, "Get Started Free" CTA, the full footer) instead of the bespoke NutriMetric chrome, for true ecosystem consistency. Removed the now-deadNutriChrome/RestoraLogoshims; page content keeps the NutriMetric layout + the#bf601dbrand colours. - Nutrition surfaces — brand polish to match the Restora ecosystem. The redesigned nutrition pages now wear the real product brand instead of the NutriMetric mock's black/white: the canonical Restora logo (extracted to a shared
RestoraLogocomponent — the burnt-orange gradient badge + the "Restora." wordmark, verbatim from the landing header) in the header + footer, and the product's burnt-orange `#bf601d` (hover#a3521a) on every interactive element across the directory, detail, calculator, My-Meals and the public meal page — buttons, the search submit, active filter chips, sliders, macro/protein bars, focus rings, card/bento hover borders, links, and the category badge. Footer aligned to the product's grey link palette. Off-brand#e67e22usages unified to#bf601d. tsc 0, eslint 0, build EXIT=0. - Nutrition surfaces — full visual redesign to the NutriMetric reference (directory · detail · calculator). All three nutrition pages rebuilt to match the supplied designs exactly, on a shared design system (
NutriChromeheader/footer, canvas#fdf8f8, Inter, flat tonal cards, functional colour-coding). Directory (/ingredient): filter sidebar (calorie/protein/fat sliders + category chips + Reset), search + result count + Sort By, a 3-col ingredient card grid (icon in the image slot, name + 100 g + 2×2 macro grid with a protein bar + category badge), client pagination. Detail (/ingredient/[slug]): hero (large icon + category badge + title + quick calories/protein/fat box + description), a 4-card bento (Macros · Fats · Minerals · Vitamins) with per-row % Daily Value (newNUTRIENT_DV/percentDV), a detailed composition breakdown, allergens/dietary, and unique source attributions. Calculator (/nutrition): the "My Mix" builder — a public ingredient typeahead (/api/nutrition/ingredient-search) → add items with editable gram amounts → a live right-panel (estimated calories, macro-distribution bars, micronutrients, confidence) that recomputes via the engine; the N7 Photo path now populates the mix, and Save & Share posts to the N6 meals API. tsc 0, eslint 0, 303 tests, build EXIT=0. - Nutrition Engine — N7: estimate nutrition from a food PHOTO.
POST /api/nutrition/from-image— a budget-capped Claude vision call (reusing the menu-importparseDataUrl/fitImageForVision+ the forcedextract_ingredientstool) identifies visible foods + estimates portions; the nutrition stays deterministic (catalog lookup) and the result is tagged parse-method `image` (the engine's lowest confidence prior). Tighter rate limit + the shared nutrition daily cap; gram-from-pixels is clearly an ESTIMATE. The/nutritioncalculator gains a "📷 Photo" upload. - Nutrition Engine — N6: save & share meals (social). Saved meals on FSN accounts:
SavedMealon the FSN store,GET/POST /api/fsn/meals(session-gated; POST computes + snapshots the estimate) +DELETE /api/fsn/meals/[id], a "My meals" section on the/fsnaccount page (save · list · share · delete), and a public `/meal/[id]` page (the colour-grouped nutrition card + ingredients + Recipe JSON-LD with NutritionInformation + disclaimer) for shareable links. Reuses FSN auth/session + the shared<NutritionFactsCard>+ the deterministic engine. The Nutrition Intelligence Engine is now complete end-to-end (N0–N7). 303 tests, tsc 0, eslint 0, full build EXIT=0. - Nutrition Engine — N3: estimated nutrition on FoodBazar menu items (the scale fix's payoff). Each FoodBazar item now shows an expandable "Nutrition (estimated)" panel (calories + macro split in the summary, the full colour-grouped
<NutritionFactsCard>on expand) — rolled up from the product's recipe → linked canonical ingredients via a new sharedsummarizeRecipeNutrition(reusesaggregateNutrition+rollUpAllergens+ macro-%). Computed server-side in the public bazar feed using the cached catalog `byId` index (cheap now that catalog reads are cached — exactly what the scale fix unblocked); only products with a linked recipe get values, and everything is labelled an ESTIMATE ("from the recipe — not exact or medical"). tsc 0, build EXIT=0, 303 tests; no new eslint debt (the item page's pre-existing setState-in-effect warnings are untouched). The Nutrition Intelligence Engine's buildable phases N0–N5 are now complete — N6 (social meals) / N7 (image) remain deferred per the plan. - Nutrition Intelligence Engine — N1/N2/N4/N5 (deterministic): the public nutrition calculator.
src/lib/nutrition/— a deterministic-first estimation pipeline + a free public calculator. N1engine.ts toGrams(mass exact · volume w/ density · count/household units like egg/slice/clove/cup/tbsp with documented defaults, flagged "assumed"). MatchingmatchIngredient(token overlap + precision/recall weighting so "olive oil" → "Oil, olive" not "Mayonnaise…with olive oil") against the 8,187 published catalog records. N2composeEstimate→ aNutritionEstimate(totals, macro-%, calories-from-P/C/F, a kcal cross-check, a fused 0–100 confidence from coverage × match-quality × assumptions × parse-method × consistency, and a per-line explain trace). N4cooking.ts— a deterministic cooking-method factor table (raw/fried/grilled/boiled… → concentration + added oil), each a confidence-lowering assumption. N5 (deterministic)parse.ts(typed text → meal lines: "200g chicken breast", "2 eggs", "1 tbsp olive oil", "180g cooked rice") +POST /api/nutrition/calculate(rate-limited) + the public `/nutrition` calculator (type ingredients → grouped nutrition card + confidence badge + macro split + explain panel + disclaimer) +NUTRITION_DISCLAIMER/vetNutritionOutput("estimate, not medical"). Reusable `<NutritionFactsCard>` (the colour-grouped card) now shared by/ingredient+/nutrition. In the sitemap. Everything labelled an ESTIMATE. 7 tests (303 total). tsc 0, eslint 0, full build EXIT=0. - Nutrition Engine — N5 AI fallback (natural language). A
natural:truemode on/api/nutrition/calculate: a budget-capped Sonnet call (forced-toolextract_ingredients) turns free text ("I made pasta with cream, mushrooms, chicken and parmesan") into ingredient + estimated-quantity lines — but the nutrition itself stays deterministic (catalog lookup), taggedai-parsed(a lower confidence-prior). Dedicated daily USD cap (src/lib/nutrition/ai-budget.ts+master.nutritionAiUsage), rate-limited; AI only extracts, never invents nutrition or health claims. The calculator gains a "Describe in words (AI)" toggle. tsc 0, build EXIT=0. (Remaining: N3 menu/FoodBazar wiring; N6/N7 deferred.) - Ingredient nutrition card — colour-grouped layout (matches the owner's reference design). The nutrition card now groups nutrients by colour: macros (blue
#f0f7ff), fats incl. cholesterol (yellow#fffdf0), minerals (green#f2faf3), vitamins (pink#fff5f8), water (grey#f8f8f8), in a 3-col grid (2 on mobile), each cell showing value + unit + label. Driven by a newNUTRIENT_GROUPmap + a clusteredNUTRIENT_ORDERin the registry, so every surface that renders nutrition inherits the grouping. Build ✓. - Nutrition Engine images — P1–P4: specific icons across the whole catalog + rollout. Built a curated 665-entry food keyword/phrase → game-icons map (via a workflow over the verified palette,
ingredient-icon-keywords.generated.ts, 88 icons self-hosted) and a conservative word-order-aware resolver: exact curated name → multi-word phrase match (longest first, so "black pepper" → spices beats single "pepper" → bell-pepper) → first matching food word (USDA names lead with the main food — "Spices, pepper, black" → spices) → category fallback (never blank). This lights up specific icons for the 8,187-ingredient catalog at render time, no per-record storage. Genuine universal gaps (vinegar/mayonnaise/onion) use a close substitute — game-icons covered the rest (shiny-apple,milk-carton,raw-egg,bell-pepper…), so no AI-generation needed. P4 rollout:<IngredientIcon>wired into the `/ingredient` directory (replacing the identical folder-emoji on all 8,187), the admin `IngredientDrawer`, plus the/ingredientheader + recipe-builder picker from P0. Also: the directory name text is smaller + truncates with a full-name tooltip (fits one line). tsc 0, eslint 0, 296 tests, build EXIT=0. - Nutrition Engine images — P0: real food icons (game-icons.net) replace generic emoji. Per the deep icon research (emoji can't fix it — Unicode has no oil/vinegar/sauce codepoint), introduced game-icons.net (CC BY 3.0) as the ingredient icon source, self-hosted (no runtime CDN): 43 SVG bodies fetched into
src/lib/food-catalog/icon-bodies.generated.ts. A shared `<IngredientIcon>` component renders the inline SVG (tinted viacurrentColor) with a deterministic fallback chain iconRef → curated name/category map → emoji → • (ingredient-icon-map.ts). The oils-sauces that looked identical are now distinct — Olive oil → olive, Vegetable oil → oil-can, Tomato sauce → tomato, Ketchup → ketchup, Vinegar → wine-bottle. Wired into the recipe-builder picker (ProductIngredientsEditor) + the `/ingredient` page header, with the CC BY 3.0 credit line. Additive optionaliconRefonIngredientMaster(for storing resolved icons in P2). Self-hosted, GDPR-clean, no third-party calls. tsc 0, eslint 0, build ✓. (P1 curate-defaults / P2 seed-tail / P3 AI-gap-fill / P4 full-rollout to follow.) - Catalog now serves the complete USDA reference, with vitamins + single-source attribution. Per owner direction: the catalog is rebuilt as the complete USDA Foundation + SR Legacy reference (8,187 foods), PUBLISHED (pure USDA nutrition, no safety fields → publishable;
importUsdaFdcgained astatusoption, used by the bulk script + cron) — so/ingredientshows the full list, not 87. 47 common concepts are renamed to clean names (Avocados, raw, California→ Avocado) so/ingredient/avocadoetc. resolve. The ingredient page's nutrition card now renders the full ~27-nutrient set including all vitamins (driven by theNUTRIENT_ORDER/NUTRIENT_LABELS/NUTRIENT_UNITSregistry, each value with its unit). The "Data & sources" panel shows unique source names only (viacatalogAttributions, not per-field rows) — for the rebuilt catalog that's a single USDA FoodData Central chip. Build ✓,/ingredient+/ingredient/avocado200. - Nutrition Engine N0 + full USDA bulk import — complete raw-food reference, all nutrients, auto-refreshed. The shared `NUTRIENT_UNITS` registry (
src/lib/food-catalog/nutrients.ts: each key → kcal/g/mg/µg, with labels + display order) so micronutrients store + render in natural units (iron in mg, vitamin A in µg) instead of rounding to zero. The USDA + Livsmedelsverket importers now map the full ~27-nutrient set — added the 16 micronutrients (potassium, calcium, iron, magnesium, zinc + vitamins A/C/D/E/K + B-complex), all numbers + units verified against the live FDC API (vitamin A as RAE #320, vitamin D as µg #328); sodium/cholesterol revert to mg (natural units, no forced grams). A one-time bulk import (scripts/import-usda-bulk.ts) pulled the complete Foundation + SR Legacy reference (~8,187 foods) into the local catalog with full nutrients (lean provenance, idempotentcing_fdc_<id>,needs-review); the catalog now holds 8,274 records. A weekly resumable refresh cron `/api/cron/catalog-usda-import` (CRON_SECRET-gated, cursor-walked) keeps it current.importUsdaFdc/mapFdcToIngredientgained aleanoption (onenutritionprovenance row vs per-nutrient — avoids 160k rows at scale). Scale note: the catalog JSON doc is now ~16 MB — fine for dev (page loads ~0.5s), but the prod path is the catalog's normalized-tables migration (P9) or a separate lookup index. 6 tests (296 total). tsc 0, eslint 0, full build EXIT=0. - Phase 29 / Phase A — USDA FoodData Central: the PRIMARY global raw-ingredient nutrition base. Per the licence-verified source research, USDA FDC is CC0 / public domain (US Government work) — the cleanest possible licence for a sellable catalog (no attribution, no share-alike, no resale limit). New
src/lib/food-catalog/usda-fdc.ts— puremapFdcNutrients(by USDA nutrient number → canonical keys; energy prefers measured #208, falls back to Atwater #957/#958 so Foundation Foods aren't left energy-less; tolerates search + detail + bulk shapes),mapFdcToIngredient(Englishnames.official, per-fieldopen/conf-75 public-domain provenance, ausda-fdccode), and idempotentimportUsdaFdc(idcing_fdc_<fdcId>, lands `needs-review`, never writes allergens — the safety gate).POST /api/admin/food-catalog/import/usda(super-admin, searches Foundation + SR Legacy). API key read from env (USDA_FDC_API_KEY, never hardcoded). 5 tests (295 total). tsc 0, eslint 0, full build EXIT=0.names.officialstays English/ASCII (slugs are accent-free); Swedish lives intranslations.sv. - Phase 29 — Restora Catalog: dev populate script for the public ingredient pages.
scripts/seed-catalog-nutrition.ts(runnpx tsx) now builds a published catalog from the English ingredient defaults with USDA FDC nutrition as primary (English→English matched — far cleaner than the Swedish fuzzy match) + Swedish names from Livsmedelsverket, so/ingredient/<slug>pages (e.g./ingredient/avocado) render with nutrition + source attribution. Strict first-segment matcher (correct-or-skip: rejects salt→"Butter, salted", brands, dried/powder forms) + a small verified curated-id override for staples where search picks the wrong variant (whole egg vs yolk, whole vs dried milk). No allergens written (safety gate). Writes the gitignoreddatabases/food-catalog.json(local dev; prod populates via the route/cron). 72 of 87 with USDA nutrition, 47 with Swedish names. - Phase 29 — Restora Catalog: Livsmedelsverket (Swedish Food Agency) nutrition import. A new official-tier source (registered in
LEGAL_SOURCES, CC BY 4.0 with attribution) that imports the Swedish food-composition database (~2,575 foods, per-100 g nutrients) into the canonical catalog.src/lib/food-catalog/livsmedelsverket.ts— puremapNutrients(by language-independent euroFIR codes → canonical keys; kcal energy kept ascalories),mapLivsmedelToIngredient(per-fieldofficial/conf-98 provenance, Swedish name + scientific name + alivsmedelsverketcode), and idempotentimportLivsmedelsverket(deterministic idcing_lv_<nummer>; new records land `needs-review` for a human to categorise + publish; re-import refreshes nutrition). Nutrition only — allergens are never imported (composition data ≠ allergen declarations; allergens stay behind the two-key safety gate).POST /api/admin/food-catalog/import/livsmedelsverket(super-admin, chunked +nextOffset) + a resumableCRON_SECRET-gated cron/api/cron/catalog-livsmedelsverket-import(walks a cursor, ~120/run, wraps for refresh). Deterministic, no AI. 3 tests (290 total). tsc 0, eslint 0, full build EXIT=0. - Phase 28 — Food Business Launchpad: the AI Startup Advisor (trust-grounded).
POST /api/launchpad/advisor— a single budget-capped Sonnet call that explains the founder's roadmap, grounded ONLY in this cell's verified+published steps (it never invents legal/tax facts). It declines honestly (ADVISOR_IN_PREPARATION) when nothing is verified — so it can't even invoke the model until counsel verifies, keeping the AI risk behind the same Phase-0 legal gate. The model output is re-linted with the FBL advice/guarantee detector (findAdviceLanguage→ a safe fallback if it trips) and the disclaimer is always attached; rate-limited per IP + a dedicated daily USD cap (src/lib/launchpad/ai-budget.ts). PurebuildAdvisorMessages/vetAdvisorOutput(3 tests). UI: an "Ask the startup advisor" box on/launchpad. 3 tests (287 total). tsc 0, eslint 0, build ✓. - Phase 28 — Food Business Launchpad: Phase-1 net-new core (trust-first; wedge = SE × home-bakery). Inherits TC-0 verbatim (assistant not advisor; nothing legal is served un-cited or un-verified). New
src/lib/launchpad/ckg.ts— the Compliance Knowledge Graph (ComplianceNode: business-type/license/authority/form/deadline/task + source citations + a verification state + effective-dating) with the publish gatecanPublishNode(cited and human-verified) +cellCoverage(the honesty state).roadmap.ts— deterministic, dependency-ordered assembly from published nodes only + a 0–100 checklist with alegalReadinesssub-score.calculators.ts— startup-cost / break-even / pricing (VAT-aware). The SE × home-bakery seed is 7 cited nodes, deliberatelyneeds-review— a code author isn't counsel, so the gate holds them until a human verifies (Phase-0 legal gate). Row-lockedapp_documents 'launchpad'store.GET /api/launchpad/roadmap(public, verified-only) + the human gateGET/POST /api/admin/launchpad/nodes(super-admin verify/unverify, reusing the TC-0 forbidden-language linter). UI: a public `/launchpad` (roadmap or an honest "in preparation" + checklist + calculators + disclaimer, in the sitemap) + a super-admin `/admin/dashboard/launchpad` verification queue. Deterministic, no AI. 7 tests (283 total). tsc 0, eslint 0, build ✓. - Phase 26 — Food Stories Network: P1 wedge instrumentation (the gate measurement, NOT the feed). Makes the P1→P2 gate evaluable. New
src/lib/fsn/metrics.ts— purecomputeWedgeMetrics(verified · claimed · open-to-work · fresh-open · active accounts · hired-via-FSN · hiring employers); 2 tests. AHireEvent(WCFA) record on the FSN store +POST /api/fsn/talent/[profileId]/hire(employer-only, idempotent per profile×restaurant) wired into the "Mark hired" action in the/dashboard/hiringsearch. A super-admin gate dashboard at/admin/dashboard/fsn(GET /api/admin/fsn/metrics, in the admin sidebar) showing the metrics + the explicit P1→P2 gate note. This builds the measurement the gate depends on — the consumer feed (P2) stays gated until the wedge is proven. 2 tests (276 total). tsc 0, eslint 0, build ✓. - Phase 26 — Food Stories Network: P1 consumer/professional half (FSN accounts + self-claim + Open-to-Work + erasure). A separate FSN auth namespace per ADR-FSN-identity —
src/lib/fsn/session.ts(anfsn_sessioncookie +fsnAccountId, reusing the HMAC-JWT pattern; notUserRecord).POST /api/fsn/register(+ auto-claim: registering with your work email instantly claims your owner-verified profile viafindClaimableProfileagainst the rosterclaimEmail),/api/fsn/login,/api/fsn/logout,GET /api/fsn/me,POST /api/fsn/me/claim,PATCH /api/fsn/me/profile(the worker self-manages Open-to-Work — toggling on re-stamps `openSince` so recruiters see freshness — plus visibility + skills), and `DELETE /api/fsn/me` (GDPR right-to-erasure: scrubs PII + de-lists the profile). A public `/fsn/talent/[id]` profile (public-visibility only) with Person JSON-LD (SEO P1) and a `/fsn` account page (register / login / claim / manage). Pure helpers (validateRegistration,normalizeHandle,findClaimableProfile,personJsonLd,publicAccount) tested. 7 tests (273 total). tsc 0, eslint 0, build ✓. P1 now covers both halves (employer moat + worker accounts); remaining is dense-metro seeding (ops/data). - Phase 26 — Food Stories Network: P1 hiring-wedge core (the verified-employment moat + search-first hiring). With P0 GO given, built the P1 MVP core.
src/lib/fsn/talent.ts—TalentProfile+ verified employment + Open-to-Work as a state with provenance + freshness decay + bias-safe search/ranking (role/skill/open-freshness/verification/completeness; name/photo/age/gender/ethnicity/school excluded per the doc-09 §6 policy); 4 tests. Persisted on a row-lockedapp_documents 'fsn'store (getFsnDB/saveFsnDB/mutateFsnDB). The moat reuses existing operator auth:GET/POST /api/tenant/[tenantId]/fsn/roster— the owner one-taps to confirm aStaffMember's employment against the live roster (requireTenantAccess, audit-logged) → an uncopyable ✔ no standalone competitor can replicate. Search-first hiring:GET /api/fsn/talent/search(owner/manager/super-admin = verified employers, who alone seerecruiters_onlycandidates). UI: a `/dashboard/hiring` page — "Your team" (verify) + "Find talent" (ranked search with the ✔ badge + open-freshness chip). 4 tests (270 total). tsc 0, eslint 0, build ✓. P1 remaining: consumerfsn_accountsregistration/sessions, staff self-claim + Open-to-Work management, Person/JobPosting JSON-LD, dense-metro seeding. - Phase 26 — Food Stories Network: P0 validation started (ADR + recipe-card widget). Following the red-teamed validate-first roadmap, started P0 (the near-zero-build demand gate). Froze the identity model in
docs/decisions/ADR-FSN-identity.md—fsn_accountsas the system-of-record for consumers/creators with an optional, verification-gated link to operatorUserRecord(the moat is the verified-employment edge against the live POS, not a merge); scope tier = vertical product. Built the pre-auth recipe-card widget (P0 scope item 2 — tests "does structure delight?"): a public `/recipe-card` page where pasting any messy recipe returns a clean, structured, shareable card via ONE budget-gated Sonnet call. Puresrc/lib/fsn/recipe-card.ts(normalizeRecipeCard— robust, bounded, never-throws + schema.org Recipe JSON-LD; 3 tests),src/lib/fsn/ai-budget.ts(daily USD cap),POST /api/fsn/recipe-card(rate-limited per IP + budget-capped, no data stored), in the sitemap. The rest of P0 is owner/research (seed count, hiring-WTP interviews, fake-door) → GO/NO-GO before P1 (the hiring wedge) builds. 3 tests (266 total). tsc 0, eslint 0, build ✓. - Phase 13 — Restora Webshop: WS-2 warehouse barcode-picker UI. A super-admin pick-and-pack flow at `/admin/dashboard/webshop/pick/[orderId]` (in the dark admin shell) reusing the existing
BarcodeScanner(live camera) + an autofocused field for USB keyboard-wedge scanners. New puresrc/lib/webshop/picking.ts—recordScan(match a code to a pick line by SKU/product id, increment up to qty, reject over-pick) +isPickComplete; 4 tests. Per-line progress turns green as items are scanned; once every line is picked, "Complete pick → Packed" advances fulfillment (super-admin, transition-validated). A Pick link appears on pickable orders in the admin Fulfillment tab. 4 tests (263 total). tsc 0, eslint 0, build ✓. - Phase 13 — Restora Webshop: WS-1 finished — B2B invoice + customer order portal + one-click reorder. New
src/lib/webshop/invoice.ts—buildWebshopInvoiceproduces a Swedish faktura (net-30, OCR, due date, VAT lines from the order's resolved rate) reusing the bookkeepingInvoiceModel; 3 tests. For B2B, pay-by-invoice is the primary path, so this completes the commerce loop without a card processor: browse → order → net-30 invoice → bank transfer → admin fulfils. A customer `/shop/orders` portal (GET /api/webshop/orders) lists orders with status, a printable `/shop/orders/[id]/invoice` faktura page (owner/super-admin only,window.print()→ PDF), and one-click reorder (re-checkout a past order's lines). Order + subscription links added to the shop header. 3 tests (259 total). tsc 0, eslint 0, build ✓. - Phase 13 — Restora Webshop: the UI layer (admin + customer). A super-admin Marketplace Admin at
/admin/dashboard/webshop(in the admin sidebar,admin_session-gated) with Analytics / Products / Coupons / Fulfillment tabs over the WS-2/WS-6 APIs (+ newGET /api/admin/webshop/orders). Customer-facing: a `/shop/subscriptions` page (manage auto-replenish — pause/resume/cancel — + predicted "time to reorder" from WS-5), a "Subscribe & Save" action on every shop product card, and a subscriptions link in the shop header. Makes WS-2/WS-4/WS-5/WS-6 operable end-to-end. tsc 0, eslint 0, build ✓. - Phase 13 — Restora Webshop: WS-7 global & scale (deterministic VAT + regional catalog).
src/lib/webshop/tax.ts— an EU VAT-rate table +resolveVatRate/isSupportedTaxCountry/calculateTax(adds VAT to a net B2B amount; unsupported country →supported:false, no tax) andsrc/lib/webshop/regional.ts—filterCatalogByRegion(a product with noregionCodessells everywhere); 3 tests.WebshopProduct.regionCodesadded;WebshopOrdercarriesbuyerCountry/taxRate/taxAmount.POST /api/webshop/tax/calculate(VAT preview),GET /api/webshop/catalog?country=(region-filtered), and checkout now computes VAT on the post-discount net for the buyer's country. Honestly integration-gated (not built): real carrier APIs (labels/tracking), multi-warehouse supply-chain ops (capital), non-EU tax engines (Avalara/TaxJar), third-party seller onboarding + payout KYC, and multi-currency payment rails — EU VAT is deterministic, the rest needs contracts/capital. Rates must be tax-pro-confirmed per jurisdiction before launch. Deterministic, no AI. 3 tests (256 total). tsc 0, eslint 0, build ✓.
- Phase 13 — Restora Webshop: WS-6 marketplace admin (coupons + analytics + product CRUD).
src/lib/webshop/discount.ts(applyCoupon— percentage/fixed, clamped to subtotal, validity/min-order/max-uses/currency guards, every rejection reason typed) +src/lib/webshop/analytics.ts(aggregateSales— revenue net of discount, top products, seller split, date-filtered, excludes cancelled); 3 tests. Checkout now applies a coupon server-side (revalidated, never trusts a client discount; bumps redemption) and returns the nettotal. Super-admin APIs:/api/admin/webshop/coupons(+/[id]) CRUD,/api/admin/webshop/products(+/[id]) CRUD with SKU uniqueness + soft-delete, and/api/admin/webshop/analytics.WebshopDB.couponsadded. Deterministic, no AI. 3 tests (253 total). tsc 0, eslint 0, build ✓. Next: the admin dashboard UI (product/coupon managers + analytics charts).
- Phase 13 — Restora Webshop: WS-5 cheap AI commerce (statistical core).
src/lib/webshop/recommend.ts— deterministic, no-AI:predictReorder(next-reorder interval from a customer's order spacing + confidence),frequentlyBoughtTogether(co-occurrence),dueReorders(predicted due within a window), andsearchCatalog(keyword, all-tokens-match); 4 tests.GET /api/webshop/recommendations(signed-in customer's due-for-reorder products) +GET /api/webshop/search?q=(free keyword search). The LLM natural-language search/copy layer is an optional budget-capped enhancement, deferred so search stays free (rules-first). 4 tests (250 total). tsc 0, eslint 0, build ✓.
- Phase 13 — Restora Webshop: WS-4 auto-replenish subscriptions.
src/lib/webshop/replenish.ts— purenextRun(now + interval, min 1 day),dueForReplenishment(active + due),buildReplenishmentOrder(re-prices viabuildWebshopOrder),planReplenishment; 4 tests.WebshopDB.subscriptions(+WebshopSubscription) added.POST/GET /api/webshop/subscriptions(create / list own, signed-in) +PATCH /[id](pause/resume/cancel, owner only) + aCRON_SECRET-gated daily cron/api/cron/webshop-replenishment(creates apendingorder per due subscription, advances the schedule). Deterministic, no AI. 4 tests (246 total). tsc 0, eslint 0, build ✓. Next: "Subscribe & Save" PDP toggle + customer subscriptions page.
- Phase 13 — Restora Webshop: WS-3 agent 2% commission.
src/lib/webshop/commission.ts—computeWebshopCommission(2%, cents-rounded, input-guarded),resolveReturnWindowDays(most-conservative seller wins: Restora 14d / dropship 21d / supplier+third-party 30d), andisCommissionReleasable(fulfilled + return window elapsed + not returned/cancelled); 3 tests. Orders are attributed to a referring sales agent at checkout (agentId, validated against active agents only). When an order ships, apendingcommission entry is recorded into the existing sales-agent ledger (SalesAgentCommissionEntry.sourceTypeextended to'webshop') — idempotent, feeding the existing payout pipeline (no parallel engine). Deterministic, no AI. 3 tests (242 total). tsc 0, eslint 0, build ✓.
- Phase 13 — Restora Webshop: WS-2 warehouse fulfillment core.
src/lib/webshop/fulfillment.ts— a deterministic fulfillment state machine (Unfulfilled → Allocated → Picking → Packed → Shipped → Delivered, + Returned from shipped/delivered),canAdvanceFulfillment(forward-only; returned-is-terminal),buildPickList, andstatusForStage(maps to the customer-facing order status); 4 tests.WebshopOrdergainedfulfillmentStage+shipment(carrier/tracking/timestamps).GET/PATCH /api/webshop/orders/[orderId]/fulfillment(super-admin / Restora ops — pick list + transition-validated stage advance + carrier/tracking, audit-logged). Carrier label printing + tracking webhooks are integration-gated (real carrier accounts) and deferred. Deterministic, no AI. tsc 0, eslint 0, build ✓.
- Phase 13 — Restora Webshop: WS-1 storefront MVP (dropship-first). A persisted webshop store (
app_documents 'webshop'viagetWebshopDB/saveWebshopDB, seeded with 6 restaurant-tech/supply products + an orders list) + a purebuildWebshopOrder(src/lib/webshop/order.ts) that re-prices and re-validates availability server-side (never trusts client prices), enforces a single currency, and totals; 3 tests.GET /api/webshop/catalog(active products) +POST /api/webshop/checkout(builds + persists apendingorder, guest or signed-in). A public `/shop` storefront (ShopClient) — catalog with bulk-tier pricing, a client cart, and checkout — added to the sitemap. Deterministic, no AI. 3 tests (235 total). tsc 0, eslint 0, build ✓. Next: WS-1 finish (Stripe checkout card + invoice + customer portal/reorder), WS-2 warehouse.
Changed
- WoW funnel `/wow` page — premium redesign (functionality-preserving). Rebuilt the public
/wowentry to match the design reference: sticky nav, hero, the two functional import cards (Import Your Menu + Clone Your Brand — real upload buttons + URL fields), WOW-mode toggle, demo + free-health-check link cards, a 4-box bento showcase, final CTA, and footer — styled with Playfair Display + Plus Jakarta Sans, primary#e67e22, and Material Symbols icons (upload_file,screenshot,restaurant_menu,palette,qr_code_2, …). The proven import logic was extracted into a shareduseWowImporthook so the landing-heroWowHeroWidgetstays byte-identical (unaffected) while the newWowPremiumImportpowers/wow; all flows preserved (menu extract · screenshot/URL clone · demo · health-check). No fabricated stats; self-contained visuals (no hot-linked images). tsc 0, eslint 0, build ✓.
Added
- Phase 19 — Tax & Compliance Assistant: TC-0 legal-responsibility framework (built first AND enforced). New
src/lib/tax/legal.ts— versioned acceptance (needsAcceptance), the mandatory export gatecanExport(acceptance then explicit per-report review; demo data force-watermarked "SAMPLE DATA — NOT FOR FILING"; no bypass path; disclaimer always attached), a word-boundary banned-claims guard (findBannedClaims/isSafeLabel— rejects official / guaranteed / filed / approved / compliant / accurate / authority / tax-return …, hyphen-aware, no substring false-positives),disclaimerLines, and confidence labelling. A 3-agent adversarial review returned FAIL (2 blockers) — both fixed before commit: the framework was dead code while a live/api/tenant/[tenantId]/bookkeeping?format=csvVAT export shipped unguarded. Now the boundary is enforced: that export is gated (acceptance +reviewed=1, with the disclaimer + any watermark prepended to the file), acceptance persists onTenantSettings.taxAssistantvia a new audit-loggedPOST/GET /api/tenant/[tenantId]/tax/accept, the bookkeeping dashboard gained an "I've reviewed & verified" checkbox + accept-on-409 retry, and the designer/agent earnings-summary CSVs now carry the disclaimer. Deterministic, no AI. 7 tests (232 total). tsc 0, eslint 0, build ✓. Next: TC-1 books view, TC-2 deterministic VAT engine. - Phase 13 — Restora Webshop: WS-0 catalog + B2B tiered-pricing core. New
src/lib/webshop/catalog.ts—WebshopProduct(physical goods + bulkpriceTiers+sellerType: restora / supplier / dropship / third-party) with pure helpers:resolveUnitPrice(the highest-minQty bulk tier the order qualifies for, else base),lineTotal,cartSubtotal(rejects a mixed-currency cart), andisAvailable(dropship/made-to-order always available; own-stock respected). Reuses theSupplierProducttier concept. Deterministic, no AI. 4 tests (231 total). tsc 0, eslint 0, build ✓. Next: WS-1 storefront MVP (dropship/supplier-fulfilled first), WS-2 warehouse/WMS. - Phase 11 — Growth Opportunities Hub CLOSED at 100% (buildable scope). The full legal AI lead-discovery engine ships (GO-0…GO-5): a legal-gated opportunity model (no scraping), a deterministic 0–100 scorer, AI lead intelligence + multi-channel outreach (GDPR/PECR-gated, budget-capped), a persisted pipeline + designer workspace, territory + dedup + heat-map, and GO-5 — a CRON_SECRET-gated weekly re-scan cron (
/api/cron/growth-rescan, Mon 04:00, ≤100/run) that re-scores active stored opportunities (deterministic, no AI) + enriches missing coordinates via capped geocoding (≤15/run); puredueForRescanselector (active-only, stalest-first, capped; tested). Legal throughout — the re-scan only refreshes opportunities that already passed the GO-0 gate. 222 tests, tsc 0, build ✓. The only remainder is integration-gated: new-lead discovery needs live official Places/GBP API access; the SEO-scorer auto-audit + partner gamification hooks graduate alongside. Hardened across this phase by a 3-agent adversarial legal/GDPR/correctness review. - Phase 11 — Growth Opportunities Hub: GO-4 territory + dedup + heat-map. New
src/lib/growth/territory.ts—matchesTerritory(precise radius via the existing bazar haversine when coordinates exist, else city/region/country text match),dedupKey/isDuplicate, andheatmap(aggregate opportunities by location → count + avg score, busiest first); 4 tests. The create route now dedups atomically inside the row lock (409 on a same-restaurant duplicate in the owner's pipeline), and the/growth/opportunitiesworkspace shows an opportunity heat-map (clickable location chips that filter the pipeline).GrowthOpportunitygained an optionalcoordinates. Deterministic, no AI. 4 tests (221 total). tsc 0, eslint 0 (new code), build ✓. Next: GO-5 compliant background re-scan. - Phase 11 — Growth Opportunities Hub: GO-3 pipeline + persistence + designer workspace. Opportunities now persist (
MasterDB.growthOpportunities) and move through a 10-stage pipeline (New → AI-Qualified → Contacted → Meeting → Demo → Proposal → Negotiation → Won → Onboarded, plus Lost). Puresrc/lib/growth/pipeline.ts—PIPELINE_STAGES, a forward-only `canTransition` (→ Lost from any active stage; terminals final), andneedsFromSignals(the "needs-X" tags); 3 tests.POST/GET /api/growth/opportunities(legal-gated create viacreateOpportunity; owner-scoped list; score + needs computed server-side) +PATCH /[id](transition-validated, owner/super-admin only). A role-gated workspace at `/growth/opportunities` (GrowthWorkspace): add from a legal source, filter by need, and advance the stage. Deterministic, no AI. 3 tests (217 total). tsc 0, eslint 0 (new code), build ✓. Next: GO-4 territory + opportunity heat-map (geo), GO-5 compliant background re-scan. - Phase 11 — Growth Opportunities Hub: GO-2 AI lead intelligence + multi-channel outreach.
POST /api/growth/outreach(sales-agent / web-designer / super-admin only) generates either lead intelligence (overview · qualitative likelihood · best approach · talking points · demo features) or a channel-specific outreach draft (email · SMS · WhatsApp · LinkedIn · phone). Pure prompt-builders + parsers insrc/lib/growth/outreach.ts(honesty rules — only the provided facts, no fabricated stats; PECR opt-out baked into email/SMS; 5 tests). The route gates in order: role → legal provenance (createOpportunity) → GDPR/PECR `canContact` (refuses to draft outreach we couldn't lawfully send — opt-out, suppression, lawful basis, legal source) → a daily growth-AI USD cap (src/lib/growth/ai-budget.ts); the opportunity score is recomputed server-side (client values never trusted). 5 tests (214 total). tsc 0, eslint 0, build ✓. Next: GO-3 pipeline + designer "needs-X" workspace + persistence. - Phase 11 — Growth Opportunities Hub: GO-0 model + GO-1 deterministic scorer (legal-gated foundation). New
src/lib/growth/opportunity.ts— a pure, AI-free core for finding restaurants worth onboarding. GO-0: provenance is a required, validated property —createOpportunityis the only gate and rejects any source off the legal allow-list (Places/GBP APIs · licensed/opt-in · owner-permissioned · CSV · manual — never scraping). GO-1:scoreOpportunityreturns a true 0–100 opportunity score (no website / no ordering / weak SEO / not mobile / unclaimed / multi-location) → reasons + recommended products, plus a clearly-labelled value estimate. GDPR/PECR:canContactrequires a positive lawful basis + legal source + PECR subscriber-type (individual/sole-trader → consent; corporate → legitimate-interest) + opt-out + case-insensitive suppression (O(1) batch overload). Hardened against a 3-agent adversarial review (legal/GDPR · correctness · architecture) — closed 1 blocker + 3 majors before commit. Deterministic, no AI. 10 tests (209 total). tsc 0, eslint 0, build ✓. Next: AI lead intelligence + multi-channel outreach (GO-2), pipeline + designer "needs-X" workspace + persistence (GO-3), territory/heat-map (GO-4), compliant background re-scan (GO-5). - Phase 15 — Demo Platform: super-admin Demo Funnel dashboard. A read UI for the DA-7 PLG funnel at
/admin/dashboard/marketing/demo-funnel— demos created, kept (demo → paid), live now, expired, and claim / retain rates, plus community counts — computed server-side fromdemoStats+ the registry. Cross-linked from Visitor analytics. So the funnel numbers are one click away the moment the WoW funnel goes live. Super-admin gated. 199 tests. tsc 0, eslint 0, build ✓. - Phase 15 — Demo Platform + Academy + Certification CLOSED at 100% (buildable scope). The full PLG ecosystem ships end-to-end: anonymous demo creation → guided first-run → "keep this site" conversion → funnel analytics → AI-search discovery → Academy (25 lessons) → certifications (client + server directory) → community (showcases + forums). Deterministic-first, budget-capped AI, honesty rules throughout; 199 tests, tsc 0, build ✓. The only remainder is non-code or integration-gated: quick-start videos + an external benchmark-report PR (content/marketing), the anonymous in-dashboard demo session (needs a per-demo-owner auth design — the claim→real-dashboard path already grants dashboard access), and partner-type certs / lead-distribution / revenue-share (needs the partner programs).
- Phase 15 — Demo Platform: Academy depth (Course schema · customer-success journey · in-sandbox CTA). The Academy page now emits schema.org `Course` JSON-LD (DA-6 — AI-search/Google citability), a customer-success journey strip (DA-7 — first 5 minutes → month 1, each linked to a docs guide), and a "practice in a free demo" CTA (DA-3). Pure
src/lib/academy/journey.ts+academyCourseLd(2 tests). 199 tests. tsc 0, eslint 0, build ✓. - Phase 15 — Demo Platform: DA-4 server-saved certifications + public directory. Signed-in learners can save their Academy progress to their profile and opt into a public certified directory at `/learn/academy/directory`. New
POST /api/learn/academy/progressvalidates lesson ids and persists toMasterDB.academyMembers; the directory derives each member's certifications server-side and shows name + tier badges — opt-in only, no email. The Academy shows a "Save my progress / list me in the directory" control when signed in. Deterministic, no AI. 197 tests. tsc 0, eslint 0 (new code), build ✓. Next (partner-system-gated): Partner/Agency/Consultant/Supplier certs + lead-distribution + revenue-share. - Phase 15 — Demo Platform: DA-5 community forums. A discussion surface at `/community` — 5 groups (General · Owners & Operators · Marketing & Growth · Partners · Feature Requests) with threads + replies. Pure model
src/lib/community/forum.ts(groups, input validation/sanitization, visibility + pinned-first sort; 5 tests) persisted onMasterDB.communityThreads. Posting is sign-in-gated + rate-limited; super-admins moderate (pin / lock / hide / delete). SSR list + thread pages with auth-aware client forms (CommunityForms). Added to the sitemap +llms.txt. Deterministic, no AI. 197 tests. tsc 0, eslint 0, build ✓. - Phase 15 — Demo Platform: DA-5 community showcases (UGC → SEO/AI flywheel). A public "Built on Restora" gallery at `/showcases` — sourced only from APPROVED Food Bazar listings (the existing owner-opted-in public marketplace) joined with the tenant registry, so it can never surface a restaurant that isn't already publicly listed. Pure
src/lib/community/showcases.ts(selectShowcases— approved/active/non-demo, featured-first, capped; 3 tests) + an SSR, crawlable page that links each restaurant to its existing/bazar/restaurant/[slug]profile (added to the sitemap +llms.txt→ more indexed pages feeding the AI-search flywheel). Deterministic, no AI. 192 tests. tsc 0, eslint 0, build ✓. Next (auth/persistence/moderation-gated): the forums / groups / expert discussion surface. - Phase 15 — Demo Platform: DA-4 Academy certifications. At `/learn/academy/certifications` — tiered certifications earned by completing a track's levels in order: 2 levels → Associate, 3 → Professional, 4 → Expert, all 5 → Master. Pure model
src/lib/academy/certification.ts(contiguousLevelsComplete/tierForTrack/earnedCertifications; 4 tests — non-contiguous progress earns nothing).AcademyCertifications(client) derives earned tier badges + per-track progress-to-next from the same localStorage progress, and is printable (Print / save as PDF). Linked from the Academy + added to the sitemap. Deterministic, no AI. 189 tests. tsc 0, eslint 0, build ✓. Next (persistence-gated): a public certified directory + Partner/Agency/Consultant/Supplier certs + lead-distribution + revenue-share. - Phase 15 — Demo Platform: DA-3 Restora Academy. A learn-by-doing path at `/learn/academy` over the Docs Center: 5 role tracks (Owner Essentials · Operations & Service · Marketing & Growth · Multi-location & Franchise · Partners) × 5 levels (Beginner → Master) = 25 lessons, each linked to a real
/docscategory, with client-side (localStorage) progress tracking — per-track progress bars, check-off, and a "next lesson" pointer. Pure curriculum modelsrc/lib/academy/curriculum.ts(getAcademyTrack/lessonsForLevel/trackProgress/nextLesson; 4 tests) +AcademyClient+ an SSR page (crawlable; added to the sitemap +llms.txt). Deterministic, no AI. 185 tests. tsc 0, eslint 0, build ✓. Next: richer per-lesson content + quick-start videos + in-sandbox tasks. - Phase 15 — Demo Platform: DA-6 SEO / AI-search (knowledge-hub `llms.txt`). Additive over the Phase 31 Citation Hub (which already emits rich JSON-LD — DefinedTerm / HowTo / Dataset / Article / FAQ / Breadcrumb). The platform `llms.txt` now lists every published knowledge guide — grouped by category, one citable line each — so AI-search crawlers (ChatGPT / Gemini / Perplexity / Claude) can ground answers in each guide, not just the
/learnindex. New puresrc/lib/citation-hub/llms.ts(renderKnowledgeHubLlms, category-name resolver injected; 3 tests). Deterministic, no AI. 181 tests. tsc 0, eslint 0, build ✓. Next: pillar/cluster internal links + Course schema (Academy-dependent). - Phase 15 — Demo Platform: DA-7 PLG funnel analytics. Persistent lifetime counters (
demoStats: created / claimed / expired) are bumped by the create (/api/public/demo), claim (/api/public/demo/claim), and cleanup-cron endpoints. New puresrc/lib/demo/analytics.ts—bumpDemoStat(immutable, clamped) +computeDemoFunnel(claimRate, retainRate, current liveNow from the registry; 5 tests). Surfaced atGET /api/admin/demo-analytics(super-admin) — the headline PLG funnel for "read the demo→paid data". Deterministic, no AI. 178 tests. tsc 0, eslint 0, build ✓. - Phase 15 — Demo Platform: "Keep this site" CTA + guided first-run UX (on the demo storefront). The demo storefront (tenant layout demo branch) now greets prospects with a guided first-run overlay (
DemoFirstRun— once per demo via sessionStorage, dismissible, reduced-motion-safe; orients them and points at the CTA) and a sticky "Keep this site" CTA (DemoKeepBar) that claims the demo for the signed-in user (→/api/public/demo/claim), bouncing unauthenticated prospects to signup with a return to the demo. The claim endpoint now accepts the demo-access cookie as proof (in addition to the one-time demoKey), so a prospect viewing the demo can keep it directly. tsc 0, eslint 0, 173 tests, build ✓. Remaining demo UX: the in-dashboard explore (role pick/switch) needs a scoped demo dashboard session. - Phase 15 — Demo Platform: DA-2 "keep what you built" conversion. A PLG demo is a real tenant DB, so claiming it just flips the registry record — every dish, theme tweak and block the prospect built is preserved. New
src/lib/demo/convert.ts—isConvertibleDemo(live, non-expired demos only) + pureapplyDemoConversion(clears the demo markers, assigns the new owner, reactivates, extends expiry; 3 tests). NewPOST /api/public/demo/claim— gated on a signed-in session AND proof of the one-time `demoKey` (only whoever created the demo can claim it); starts an 'incomplete' subscription so the existing paywall prompts payment to publish — value before payment. Deterministic, no AI. 173 tests. tsc 0, eslint 0, build ✓. Next: the "Keep this site" CTA in the demo experience + the DA-1 guided first-run UX. - Phase 8 — Web Builder 2.0 CLOSED at 100% (buildable scope). The builder is now a restaurant growth platform: AI Website Auditor (completeness score + one-click fixes), 5 restaurant one-click blocks (Gift Card · WhatsApp Order · Loyalty · Catering · data-driven Daily Lunch), the AI SEO landing-page generator, and 5 restaurant-type full-site templates + AI copy-fill — all shipped, deterministic-first with budget-capped AI and the honesty rules enforced. The only remaining item — white-label custom-domain/SSL — is infra-gated (DNS + TLS cert provisioning) and graduates when that infrastructure is wired, not pre-built. (
branch-locatorremains an optional future data-driven block.) - Phase 8 — Web Builder 2.0: data-driven block (Today's Specials / Daily Lunch). The first data-driven restaurant block: on the live site it auto-shows the restaurant's featured dishes (
DailyLunchSectionfed realitems+currencyvia the BlockRenderer switch, reusingtoPreviewItem), and in the editor it shows representative sample items — the same dual-render pattern as the menu blocks (inSectionRegistryfor the palette/preview, rendered with live data through the switch, deliberately not inREGISTRY_RENDERED_BLOCKS). New'daily-lunch'WebsiteBlockType, in the builder's 'restaurant' palette. tsc 0, eslint 0, suite 170, build ✓. With this, Phase 8's buildable software scope is essentially complete — only the infra-gated white-label custom-domain/SSL remains. - Phase 8 — Web Builder 2.0: AI copy-fill for full-site templates. Completes the "AI restaurant-type full-site templates" item:
POST /api/tenant/[tenantId]/site-templates/copygenerates tailored copy for a chosen template — tagline, hero headline + subheadline, an about paragraph, three "why dine with us" feature cards, and a CTA label — as a review-only draft the builder maps onto the template's blocks. Pure prompt-builder + parser (src/lib/website-templates/copy.ts, 5 tests — caps features at 3, unescapes entities, defaults the CTA); the prompt enforces the honesty rules (no fabricated awards/ratings/stats) and writes in the tenant's locale. Reuses the budget-gated AI rails (resolveAIKey+getBudgetStatus+recordAIUsage) — cost-capped by the monthly budget. tsc 0, eslint 0, suite 170, build ✓. - Phase 8 — Web Builder 2.0: restaurant-type full-site templates (deterministic core).
src/lib/website-templates/templates.ts— 5 curated, ordered block arrangements (Classic Restaurant · Pizzeria · Café/Coffee Shop · Fine Dining · Fast Casual/QSR), each a type-safe `WebsiteBlockType[]` (the compiler rejects unknown blocks), plusrecommendTemplate(businessType)that picks the best fit (specific types beat the generic "restaurant" catch-all). Pure + server-safe (no React coupling), 4 unit tests; surfaced viaGET /api/tenant/[tenantId]/site-templates. The builder expands a chosen template into real blocks using theSectionRegistrydefaults and applies it — a great starting site in one click. (An AI copy-fill pass over a template can sit on top later, budget-capped.) tsc 0, eslint 0, suite 165, build ✓. - Phase 8 — Web Builder 2.0: three more restaurant one-click blocks (WhatsApp Order · Loyalty · Catering). Following Gift Card, three more theme-styled builder blocks in the 'restaurant' palette: WhatsApp Order (one-tap
wa.mebutton built from the configured number + prefill text), Loyalty Club (join-and-earn CTA → the loyalty flow), and Catering & Events (inquiry CTA). Each is presentational and renders identically in the editor preview and on the live site (REGISTRY_RENDERED_BLOCKS); new'whatsapp-order'/'loyalty-cta'/'catering'WebsiteBlockTypes. Four restaurant blocks now ship. tsc 0, eslint 0, suite 161, build ✓. - Phase 8 — Web Builder 2.0: AI SEO landing-page generator.
POST /api/tenant/[tenantId]/seo-landing/generategenerates SEO landing-page copy for a topic/keyword (optionally a location/cuisine) — title, meta description, H1, intro, sections, FAQs, CTA — as a review-only draft (never auto-published). The prompt-builder + JSON parser are pure + unit-tested (src/lib/seo-gen/landing.ts, 5 tests); the prompt enforces the honesty rules (no fabricated stats / awards / ratings) and writes in the tenant's locale. Reuses the budget-gated AI rails (resolveAIKey+getBudgetStatus+recordAIUsage), so it's cost-capped by the monthly AI budget. tsc 0, eslint 0, suite 161, build ✓. - Phase 8 — Web Builder 2.0: first restaurant-specific one-click block (Gift Card). A new
gift-cardbuilder block (GiftCardSection) — a polished, theme-styled "give the gift of a great meal" section whose CTA auto-links to the live gift-card flow (/gift-cardsby default). Registered inSectionRegistryunder the 'restaurant' palette and inREGISTRY_RENDERED_BLOCKS(so it renders identically in the editor preview and on the live site); new'gift-card'WebsiteBlockType. The first of the planned restaurant blocks (daily lunch, buffet, events, chef, loyalty, catering, branch locator, …). tsc 0, eslint 0, suite 156, build ✓. - Phase 8 — Web Builder 2.0: AI Website Auditor. A deterministic website completeness score for a tenant's own Restora site (
src/lib/website-audit/audit.ts): 13 weighted checks across essential / conversion / trust / SEO (hero, menu + items, opening hours, phone, address, online ordering, reservations, gallery, reviews, logo, SEO title + description) → a 0–100 score, a grade, the full checklist, and a worst-first "what's missing" list with one-click-fix hints. Pure + unit-tested (5 tests); surfaced viaGET /api/tenant/[tenantId]/website-audit, which maps the builder blocks + settings onto the auditor. Extends the spirit of the external SEO health-scorer to the owner's own site; reuses the 22+ existing builder blocks. No AI in the scoring (the AI auto-fix/copy layer sits on top, budget-capped, later). 5 new unit tests (156 total). tsc 0, eslint 0, build ✓. Remaining Phase 8: restaurant-specific one-click blocks · AI full-site templates · AI SEO landing-page generator · the deferred white-label custom-domain/SSL. - Phase 15 — Demo Platform: DA-1 public demo creation (the "try in 60s" core).
POST /api/public/demoprovisions an isolated demo tenant with no signup and returns a one-time access key. Heavily abuse-gated: anonymous, per-IP daily rate limit (requireRateLimit) + a platform-wide live-demo cap, no AI / no cost. Reuses the proven provisioning pattern (createTenantDatabase+ aTenantRecordflaggedisDemowithdemoPassword+demoExpiry72h TTL) and the existingdemo-accesscookie path for entry; all public demos hang off one inert shared owner (unusable password, never logs in) so the DA-0 cleanup cron stays simple. Deterministic. tsc 0, eslint 0, suite 151, build ✓. Next: the DA-1 guided first-run UX (role pick/switch/explore-all + WOW #1 menu-PDF→site). - Phase 15 — Demo Platform: DA-0 demo lifecycle foundation. PLG demos are isolated tenants flagged
isDemowith ademoExpiresAtTTL (markers that already exist onTenantRecord). Newsrc/lib/demo/demo.tscarries the pure lifecycle —demoExpiry(72h TTL),isDemoExpired,selectExpiredDemos(the cleanup selector), andcanCreateDemo(per-key daily quota gate) — and a CRON_SECRET-gated cleanup cron (/api/cron/demo-cleanup, daily 02:00, ≤100/run) reclaims expired demos (deleteTenantDB+ registry removal). Real tenants are never touched (the selector requiresisDemo). Deterministic, no AI. 4 new unit tests (151 total). tsc 0, eslint 0, build ✓. Next: DA-1 (public abuse-gated demo creation → <60s provisioning → guided first-run). - Phase 14 — External Menu Sync: EM-5 freshness monitoring + Phase 14 CLOSED at 100% (buildable scope).
summarizeSources(src/lib/external-menu/sources.ts) rolls the queue up for monitoring — counts by freshness tier (fresh / aging / stale / never-synced) and by status, plus a worst-first stale worklist (sources not synced in > 30 days) — surfaced onGET /api/admin/external-menu. With EM-0 (ingestion model + permission/approval queue + legal gate), EM-1 (Schema.org/JSON-LD extractor), EM-2 (AI PDF/image extraction), EM-3 (permissioned robots-respecting crawl + change detection), and now EM-5, Phase 14 is closed at 100% on its buildable scope. The remaining EM-4 (enterprise chain connectors + GBP-via-API + multi-location sync) is integration-gated — it needs external API access (Google My Business OAuth, chain partner APIs/credentials), so it graduates when those integrations/partnerships are available rather than being pre-built. Deterministic throughout (only EM-2's extraction uses the budget-capped AI engine). 1 new unit test (147 total). tsc 0, eslint 0, build ✓. - Phase 14 — External Menu Sync: EM-3 permissioned crawl + change detection. A CRON-gated sync (
/api/cron/external-menu-sync, daily 03:00, ≤25/run, stalest-first) re-fetches each owner-granted schema.org source — robots-respecting (src/lib/external-menu/robots.tsisPathAllowed, a dependency-free robots.txt parser) — re-extracts its menu, and detects a real change via an order-independent content hash (src/lib/external-menu/change.ts— reordering isn't a change; a price/item edit is). On a change it refreshes the stored items and drops apublished/approvedsource back topending-review(re-approval, per the EM-0 state machine); the first crawl just sets the baseline hash. NewcontentHashfield on the source. Deterministic, no AI. 11 new unit tests (146 total). tsc 0, eslint 0, build ✓. - Phase 14 — External Menu Sync: EM-2 AI PDF/image extraction.
POST /api/admin/external-menu/extract-airuns the shipped menu-import engine (runMenuExtraction, text-only/cheap,detectPhotos: false) over uploaded menu photos / PDF pages and lands the result as apending-review/pending-permission source carrying the extracted items — for the non-Restora restaurants whose menus aren't in structured data. Cost-capped by the global WoW daily AI budget + kill-switch (isWowGlobalBudgetExceeded/recordWowUsage), and it never auto-publishes (EM-0's legal gate governs). ReusesfilterImageDataUrls+resolveAIKey. Email-to-menu / WhatsApp / QR submission channels are a thin intake layer over this route (follow-up). tsc 0, eslint 0, suite 135, build ✓. - Phase 14 — External Menu Sync: EM-1 Schema.org/JSON-LD menu extractor. The free/legal/accurate first tier:
src/lib/external-menu/schema-extract.ts(extractJsonLdMenu) parses a restaurant page's public schema.org markup —Restaurant → hasMenu → Menu → hasMenuSection → MenuSection → hasMenuItem → MenuItem— tolerant of@graphwrapping, nested sections, object-or-array shapes, string-or-number prices, and malformed blocks. Pure + 5 unit tests. Wired toPOST /api/admin/external-menu/extract, which fetches the page via the now-exported SSRF-guarded, redirect-revalidatedfetchHtmlGuardedand lands apending-review/pending-permission source carrying the extracted items — it never auto-publishes (the EM-0 legal gate still governs). No AI on the structured-data path. GBP-via-API is deferred to the EM-4 connector framework (needs Google My Business API access). 5 new unit tests (135 total). tsc 0, eslint 0, build ✓. - Phase 14 — External Menu Sync: EM-0 foundation (ingestion model + approval queue). The non-Restora onboarding pipeline now has its backbone: an
ExternalMenuSourcemodel (provenance + freshness + permission + approval) on a dedicated store (app_documents 'external-menu-sources'—getExternalMenuDB/mutateExternalMenuDB), a super-admin permission/approval queue API (/api/admin/external-menu[+/[id]]), and a puresrc/lib/external-menu/sources.tscarrying the legal publish gate (canPublish= owner-granted permission AND human-approved — enforced server-side on the publish transition, so nothing is ever surfaced without permission), an approval state machine (pending-review → approved → published / rejected, with re-review on change), and freshness tiers (fresh ≤7d / aging ≤30d / stale >30d). Deterministic, no AI. 4 new unit tests (130 total). tsc 0, eslint 0, build ✓. Remaining Phase 14: EM-1 (Schema.org/JSON-LD + GBP extraction), EM-2 (AI PDF/image submission → queue), EM-3 (permissioned crawl + change detection), EM-4 (chain connectors + multi-location), EM-5 (freshness monitoring). - Phase 12 — Food Bazar Recovery: CLOSED at 100% (currency-consistency validation + search observability). Final pieces: an
isKnownCurrencyregistry guard (src/lib/currency.ts) + a per-search `currencies` / `currencyConsistent` signal on/api/bazar/menu(a geo-gated search should be single-currency; a mix is logged as awarndata-quality signal), and structured search observability — every search emits alogger.info('bazar.search')event (query, total, zeroResults, geoApplied, radiusKm, didYouMean, currencies) so zero-result rate, geo usage, and did-you-mean hits are queryable. Mobile is covered by the same filter + geolocation flow. With search relevance + the 30 km geo radius gate + did-you-mean + the "within N km" note + this, Phase 12 is complete — all deterministic, no AI. 2 new unit tests (126 total). tsc 0, eslint 0, build ✓. - Phase 12 — Food Bazar Recovery: did-you-mean + "within N km" note. Zero-result searches now surface a did-you-mean correction — a deterministic Levenshtein suggester over a curated food/cuisine vocabulary (
src/lib/bazar/suggest.ts) that turns typos into a clickable suggestion (pizzza→pizza, burgur→burger, itallian→italian), returned by/api/bazar/menu(didYouMean) only when a query yields nothing and rendered in the empty state. The marketplace results header now shows "📍 within N km" when the geo radius gate is active (thegeo.applied/radiusKmflag), so a locally-constrained result set never reads as the whole world. No AI. 5 new unit tests (124 total). tsc 0, build ✓ (changes eslint-clean; the one pre-existingsetState-in-effect finding remains). Remaining Phase 12: currency-consistency validation (largely covered by per-item currency + the local geo gate) + observability. - Phase 12 — Food Bazar Recovery: geo radius gate. When the shopper's location is known, marketplace search now applies a default 30 km radius (
geoGate+DEFAULT_RADIUS_KMinsrc/lib/bazar/geo.ts): geocoded restaurants beyond the radius are gated out (no silent worldwide fallback), un-geocoded places fail open (never silently hidden — matchingfilterByRadius), and the haversine distance is fed into the relevance Quality Score (closer ranks higher) and drives a nearest-first sort when browsing without a query./api/bazar/menunow acceptslat/lng/radiusand returns ageo: { applied, radiusKm }flag; the bazar page plumbs the browser-geolocation coords from "Use my location" (and a manual city change clears the GPS gate, so searching another city isn't constrained to the old radius). No AI. 7 new unit tests (119 total). tsc 0, build ✓ (changes eslint-clean; one pre-existingsetState-in-effect finding in the bazar page left untouched). Remaining Phase 12: currency-consistency validation + did-you-mean + a "within N km" UI note. - Phase 12 — Food Bazar Recovery: search relevance Quality Score. Marketplace search (
/api/bazar/menu) was a naivehaystack.includes(q)filter sorted only open-first. It now ranks by a deterministic relevance Quality Score (src/lib/bazar/relevance.ts): exact > prefix > whole-word > substring on the item name, then category/cuisine, then a curated synonym layer that widens recall (coffee→espresso, kebab→shawarma, pizza→margherita…), withfeaturedpopularity and proximity as small tie-breakers that can never outrank a textual match. Inclusion is gated on a real textual/synonym match (popularity/distance alone don't qualify), and results sort open-first then by score. No AI — pure + cheap. 9 unit tests (112 total). tsc 0, eslint 0, build ✓. Remaining Phase 12: the geo radius gate (default 30 km + distance ranking, no silent worldwide fallback), currency-consistency validation, and did-you-mean. - Phase 6 — Data Layer: 5d `supplier_orders` normalization (DB-verified). B2B supplier orders (in the singleton
app_documents/'suppliers'doc) now mirror into an indexedsupplier_orderstable — opt-in viaSUPPLIER_ORDERS_TABLE=1, reconciled inside the suppliers-write transaction (reconcileSuppliers, via the suppliersmutateDoconPersist +writeDocWithReconcile), withquerySupplierOrdersfor indexed per-supplier / per-tenant (buying restaurant) / by-status / recency reads, adb:backfill:supplier-ordersscript, and migration006_supplier_orders.sql. Whole-table reconcile; additive + flag-reversible. Verified against a live Postgres: reconcile insert/update/delete correct, indexed reads correct. With 5a–5d done, all four hot entities (orders, stock movements, escrow holds/payouts, supplier orders) now have indexed mirrors. tsc 0, eslint 0, suite 103, build ✓. - Phase 6 — Data Layer: 5c escrow normalization (DB-verified). The platform escrow ledger (the singleton
app_documents/'escrow'doc) now mirrors its holds + payouts into indexedescrow_holds/escrow_payoutstables — opt-in viaESCROW_TABLE=1, reconciled inside the escrow-write transaction (reconcileEscrow, wired through the escrowmutateDoconPersist and a new genericwriteDocWithReconcileblind-save path), withqueryEscrowHolds/queryEscrowPayoutsfor indexed by-status / per-tenant / recency (payouts soonest-scheduled-first = settle-sweep order) reads, adb:backfill:escrowscript, and migration005_escrow.sql. Whole-table reconcile (one document owns all rows); additive + flag-reversible (off by default = the escrow path is unchanged; the document stays the source of truth). Verified against a live Postgres: holds+payouts reconcile insert/update/delete correct, indexed status/tenant/recency reads correct. tsc 0, eslint 0, suite 103, build ✓. - Phase 6 — Data Layer: 5b `stock_movements` normalization (DB-verified). The inventory stock ledger now mirrors from the tenant document into an indexed
stock_movementstable — opt-in viaSTOCK_MOVEMENTS_TABLE=1, reconciled inside the same row-locked tenant-write transaction as the orders mirror (the orders-specific path was generalized toreconcileTenantMirrors/writeTenantWithMirrors/anyTenantMirrorEnabled/deleteTenantMirrorRows), withqueryStockMovementsfor indexed per-ingredient / per-type / recency reads, adb:backfill:stockscript, and migration004_stock_movements.sql. Additive + flag-reversible (off by default = the running app is unchanged; the document stays the source of truth). Verified against a live Postgres: reconcile insert/update/delete correct, indexed reads correct (recency usesidx_stock_movements_tenant_created). tsc 0, eslint 0, suite 103, build ✓. - Phase 9 — AI Theme Generation: closed at 100%. The flagship (screenshot / menu-PDF / logo → a complete editable Restora site) was already shipped via the Phase 25 engine, plus TG-4 (AI Design Report + score) and TG-5 (theme review/marketplace). The one thin spot — TG-3 "Watch it build" — is now real: a
BuildRevealoverlay (src/app/wow/[token]/BuildReveal.tsx) that narrates the actual build pipeline (reading your design/menu → palette & type → composing sections → polishing for mobile & SEO) then fades to reveal the live preview underneath. It's client-only and touches no preview/claim data, plays once per token (sessionStorage), is skippable, and is suppressed forprefers-reduced-motion. tsc 0, eslint 0, build ✓. - Phase 31 — AI Citation Hub (Marketing & Growth): closed at 100%. Shipped the last open piece — a Super-Admin Landing Pages CMS: create / edit / publish standalone marketing & campaign pages at
/p/[slug]with a hero (badge / headline / subheadline), optional markdown body, FAQ accordion, a launch-list signup or button CTA, and the optional free site-health box — plus SEO metadata +WebSite/FAQPageJSON-LD and sitemap inclusion. Backed byMasterDB.landingPages+ arequireSuperAdminCRUD API (/api/admin/landing-pages[+/[id]], slug-unique, draft/publish toggle); the public renderer reusesMarkdownRenderer/FAQAccordion/NewsletterSignupand the self-contained coming-soon chrome, and records cookieless visitor analytics. New "Landing Pages" entry under Marketing & Growth. Programmatic city/location pages were deliberately split to a quality-gated SEO follow-up (to be built with real local content via the AI-draft+review flow, never thin doorway pages). tsc 0, eslint 0, suite 103, build ✓. - Phase 10 — Barcode & QR Ecosystem: closed at 100%. Shipped the remaining barcode/QR surfaces: equipment QR (
/dashboard/equipment— a per-tenant Equipment registry + per-item QR that deep-links to a manual/warranty/service scan landing;TenantDB.equipment+ CRUD API, inventory-gated, linked from Receiving), scan → AI reorder (a "Smart reorder" entry point from Receiving → the WA-4 supplier-refill engine — AI-ranked recommendations for low-stock items), and a hardware buying guide (scanners / label printers / QR stands). With the already-shipped scan-to-receive/waste/count, shelf/location QR, product + shelf labels, universalRESTORA-PROD-IDs, supplier shipment QR, and customer QR, the barcode/QR ecosystem is complete. Native/hardware items were split out honestly: central-warehouse WMS → Restora Webshop (WS-2), dedicated Bluetooth/Zebra/Honeywell scanners → Phase 32 (Native Apps & Hardware), full batch-recall consumption ledger → INV-1. tsc 0, eslint 0, suite 103, build ✓.
Changed
- Phase 6 — Data Layer (Postgres): closed at 100%. The JSON→Postgres migration's done-definition is met and verified — the JSONB document shim + row-lock concurrency (the lost-update fix), serverless/pooler hardening (load-tested ~3,900 ops/s, 0 lost), and all four hot entities normalized into indexed mirror tables (orders, stock movements, escrow holds/payouts, supplier orders), each DB-verified against a live Postgres — plus the GIN deferral resolved. The remaining doc-shrink cutover (stop double-storing the hot arrays in the document; hydrate from the tables) is a separate scale optimization that changes the source-of-truth on money/stock/escrow data — deliberately gated on measured doc-size need (the mirrors already deliver the indexed-read win), not part of the phase's done-definition.
- Phase 6 — Data Layer: GIN-index deferral resolved (not needed). ADR-003 had deferred JSONB GIN indexes until hot entities were normalized. With 5a–5d done, the hot filtered reads use the mirror tables' btree-indexed columns, the remaining whole-doc tables are read by primary key, and a codebase scan finds no `doc->` / `@>` / jsonb-containment queries — so a GIN index on the
doccolumns would be unused write-amplification. None added; ADR-003 updated to record that entity normalization superseded the GIN need. The only remaining Phase 6 item is the (risky, deliberately-gated) doc-shrink cutover. - Phase 29 — Restora Catalog (Global Food & Ingredient Intelligence): closed at 100% (buildable scope). P0–P7+ are all shipped — the cross-tenant
FoodCatalogDBcanonical store with per-field provenance + hash-chained audit + deterministic dedup; the super-admin Control Center (review queues + two-key governed publish); tenant "select, don't type" linking with allergen roll-up + nutrition aggregation; the cost-governed deterministic Food Intelligence Agent + capped Haiku-first LLM enrichment; public/ingredientSEO pages (92 published, Food/NutritionInformation JSON-LD); supplier claim/RFQ; the monetization add-ons + sponsored auction; and a Food Catalog API. The remaining P8–P10 (scale / expansion / public API at scale) stay trigger-gated — they graduate on measured volume/breadth per the right-sizing/cost law rather than being pre-built — so the phase is closed at 100% on its buildable scope. No feature code changed. - Phase 25 — AI Website Cloning Engine: closed at 100%. The full pipeline is shipped — a Design-DNA AI engine (
design-dna.ts, isolated + null-fallback) → a deterministicdna-to-blocks.tsmapper (reframes SaaS/agency sections into restaurant ones using the existing 27 builder blocks) → the WoW preview/claim/provision spine → a live, fully-editable restaurant site in <60s. Both the screenshot and URL engines work (DOM read + screenshot fallback, SSRF-guarded), plus the richer blocks (split-hero, feature-cards, masonry/bento, transparent-nav), a WOW-mode toggle,readable-accent(the readable-colour quality bar),design-report,clone-imagery, and the clone-learning loop (clone-learning.ts+ admin/api/admin/wow/clones— records clones → curated + auto-learned correction rules). The remaining 4% was "ongoing fidelity tuning," which is continuous quality work driven by that shipped self-learning loop — not a discrete deliverable — so the engine is closed at 100%. Legal posture: inspiration-not-clone (abstract DNA only; never copies text/images/logos/code). No feature code changed. - Phase 24 — ⭐ WOW Acquisition Engine: closed at 100% (software scope). The full acquisition funnel was already built end-to-end across WA-0…WA-4 (pre-auth AI menu/URL/screenshot → 60s live branded site → magic-link claim → 30-day trial; free site Health Check + before/after rebuild + multilingual menu; agent Share&Earn + resell sites + designer before/after + white-label A/B/C; two-sided B2B — snap-invoice catalog, Instant Lead Wall, find-cheaper-suppliers, photo-to-quote RFQ, price-book→tiers, propose-only reorders, live TV-board demo) and is owner-toggleable via the
wowFunnelfeature flag (admin Feature Flags page) orWOW_FUNNEL_ENABLED=true. The only remaining piece — custom domains + SSL + reseller billing for white-label reselling (WA-3 white-label C) — is infrastructure, reclassified to Phase 8 (Web Builder 2.0) for custom-domain/SSL + the billing system for reseller billing. Go-live is now a business decision (review + enable the flag), not a build. No feature code changed. - Phase 5 — Scale & Ecosystem: closed at 100%. The full scale/ecosystem software scope is shipped (KDS, live order tracking, profit-margin/food-cost reports, feature flags, staff scheduling, white-label + Group HQ multi-location, docs center, modular billing marketplace + Suites, AI Briefings, AI usage analytics, Receiving Goods + mobile scanner). The two items that remained were native/hardware — a React Native mobile app + POS receipt-printer/peripheral integration — which need a separate codebase + physical devices, so they were split into a new Phase 32 — Native Apps & Hardware track (trigger-gated; an interim installable PWA + browser/thermal receipt printing can land first without the native lift). Roadmap + dashboard updated — no feature code changed.
Added
- AI Citation Hub — all 22 articles now native in 18 languages (full bodies). Completed the internationalization: every knowledge entry's full content (title, quick answer, key takeaways, sections, FAQs, summary + per-type fields) is now native in all 18 languages. The 16 additional locales (da/no/fi/de/fr/it/nl/pl/hu/ro/uk/be/sr/lt/lv/et) were native-written by a 352-agent Claude workflow (same honesty rules — no fabricated stats), merged into
seed-entries.tsi18n. A visitor on/learn/…?lang=de(or auto-detected by country) now reads the entire article in German, not just the chrome. (seed-entries.ts~4.2 MB, server-only generated data; tsc 6s, build 23s, 0 errors.) Advances Phase 31 95 → 98%.
- AI Citation Hub — visitor analytics in Super Admin + flag language switcher. A new Super-Admin → Marketing & Growth → Visitors dashboard shows where visitors come from (countries + cities from geo headers), top pages, time on site, sign-ups + conversion %, "left without joining", languages served, link/visit counts, and the domains tested in the health-check (with scores + last-30-day trend) — all from a cookieless, aggregate-only beacon (
/api/public/analytics/track+<VisitorTracker>on coming-soon/learn/site-check; sign-up + health-check events) that stores no raw IPs, in its own dedicated doc (kept out of the hot master doc so beacons never contend with core platform writes; rate-limited). The language switcher is a custom dropdown with real flag images (flag-icons SVGs — render cross-platform incl. Windows, unlike flag emoji) + native + English name; the admin Visitors country/city flags use the same. Advances Phase 31 92 → 95%.
- AI Citation Hub — full internationalization (18 languages + country auto-detect). The hub + coming-soon now support 18 languages (English, Swedish, Danish, Norwegian, Finnish, German, French, Italian, Dutch, Polish, Hungarian, Romanian, Ukrainian, Belarusian, Serbian, Lithuanian, Latvian, Estonian) with a language-switcher dropdown (cookie-persisted) and automatic country detection — a visitor in Sweden sees Swedish by default. Precedence:
?lang→ cookie → geo header (x-vercel-ip-country/cf-ipcountry) →Accept-Language→ English; aCOUNTRY_TO_LOCALEmap handles non-language countries (Switzerland → German, Ireland → English, etc.). The full UI chrome + the entire coming-soon page (hero, capabilities, showcase, 7 FAQs, metadata) are translated into all 18 (16 via a translation workflow; en/sv hand-authored), with reciprocal hreflang across all locales.resolveRequestLocale(server) +LanguageToggle(client dropdown). The newsletter signup form is localized in all 18 languages (with locale-appropriate example emails). Article bodies are fully translated for English + Swedish; other locales fall back per-field to English until translated. Advances Phase 31 90 → 92%.
- AI Citation Hub — Swedish multi-language (P5). The hub + coming-soon now speak Swedish (sv-SE) via
?lang=sv, with an EN | SV toggle, reciprocal hreflang alternates, and locale-aware metadata + JSON-LD. All 22 knowledge entries carry native Swedish (i18n.sv) — generated by a 22-agent workflow (idiomatic Swedish restaurant-tech terminology — kassasystem, lagerhantering, matsvinn — same honesty rules), shipped in-code — plus a fully Swedish/coming-soon(hero, capabilities, showcase, 7 FAQs) and a localized signup.localizeEntryapplies per-field fallback to English; thei18nfield was extended to cover term/shortDefinition/howto/comparison. Advances Phase 31 80 → 90%.
- AI Citation Hub — P5 (partial): 8 substantive industry pages. Per-vertical guides — Pizzerias, Cafés & Coffee Shops, Food Trucks, Fine Dining, Bars & Pubs, Bakeries, Fast Casual & QSR, Cloud & Ghost Kitchens — each genuinely vertical-specific (not a templated name-swap), generated by an 8-agent workflow with the same honesty rules and shipped in-code (
seed-entries.tsnow 22 entries). This is the quality way to do programmatic industry SEO — substantive pages, not thin doorway pages. Advances Phase 31 70 → 80%. (Thin city-page generation at scale + full multi-language remain deliberately deferred — build them via the AI-draft+review flow, not auto-spam.)
- AI Citation Hub — P3 (discovery) + P6 (Marketing & Growth modules). Published entries (seed + admin, deduped, DB-wins) now emit per-entry URLs in `sitemap.xml` for direct crawl/AI discovery. Lead capture is live (the newsletter/launch-list + the
/site-checktool), so the Waitlist and Lead Management needs are served by the launch list + the Newsletter admin (subscribers + CSV). Added a Referrals link (existing sales-agent attribution) to the Marketing & Growth group. Advances Phase 31 55 → 70%. (Deliberately deferred to protect quality: programmatic location/industry pages at scale + full multi-language (P5) — built later with substantive per-page content via the AI-draft+review flow so they aren't thin pages that would undermine the SEO/citation goal; plus a standalone Landing Pages CMS and deep per-entry view-analytics.)
- AI Citation Hub — P4: capped AI-draft assistant. A super-admin "✨ AI draft" button in the Citation Hub editor generates a full entry draft (meta description, summary, quick answer, key takeaways, FAQs, body sections) from a title via Claude — same honesty rules (no fabricated stats), returned to the editor for human review before saving (never auto-published). Guarded by
requireSuperAdmin, a configured AI key, and a dedicated daily spend cap (MasterDB.citationHubUsage+isCitationHubBudgetExceeded, $5/day default, cloning the WoW global-budget pattern). ReusesresolveAIKey+ the Anthropic SDK. Advances Phase 31 45 → 55%.
- AI Citation Hub — P2 (per-type Schema.org + content) + a free site health-check tool. (1) 14 seed knowledge entries (6 articles, 4 definitions, 2 how-tos, 2 comparisons — generated by a 14-agent workflow; honest, no fabricated stats; AI-assisted + reviewed) ship in-code (
src/lib/citation-hub/seed-entries.ts, like the Docs Center) so/learnlaunches with attention-grabbing content, merged with DB-authored entries by the store. (2) Per-type Schema.org JSON-LD (citation-hub/jsonld.ts): DefinedTerm (definitions/glossary), HowTo (guides), Dataset (statistics), ItemList (comparisons), Report, Article — chosen by entry type; the entry page now renders per-type UI (definition card, stat hero, comparison table, numbered how-to steps) + a `/learn/glossary` DefinedTermSet index. (3) A free public site health-check at `/site-check` (+/api/public/site-check) — paste a URL → instant 0–100 score + per-signal analysis + "what Restora fixes", reusing the SSRF-guarded multi-pagefetchSiteHealth; ungated, rate-limited, score-only, with a launch-list CTA. Linked from the coming-soon page +sitemap.xml+llms.txt;/learnforced dynamic to reflect live admin entries. Advances Phase 31 30 → 45%.
- AI Citation Hub — public `/learn` knowledge pages (P1). The citable surface: a `/learn` index (categories + published entries; WebSite + CollectionPage JSON-LD) and `/learn/[category]/[slug]` entry pages that render the six AI-citation blocks (Quick Answer / Key Takeaways / Statistics / body / Summary / FAQs) + a "Data & sources" provenance block, emitting Article + BreadcrumbList + FAQPage JSON-LD via
<JsonLd>. Reads publishedKnowledgeEntryfrom MasterDB (drafts never surface); reusesMarkdownRenderer+FAQAccordion; a self-contained shell (brand header + launch-list cross-link, no full-site nav)./learnadded tositemap.xml+llms.txt; source links hardened to http(s)-only. End-to-end author → publish → crawlable + AI-citable now works. Advances Phase 31 20 → 30%.
- AI Citation Hub (Marketing & Growth) — public "coming soon" landing + newsletter capture. A server-rendered, SEO-optimised `/coming-soon` page (metadata + canonical/OG + WebSite + Organization + FAQPage JSON-LD so Google + AI engines index it) — a self-contained page (no site nav/login/footer links until launch): hero, value pillars, a 7-question FAQ (collapsible accordion, reusing the docs `FAQAccordion`), and a single launch-list signup. Public `POST /api/public/newsletter/subscribe` (per-IP rate-limited + disposable-email guarded + idempotent) stores to a new platform `MasterDB.newsletterSubscribers[]`; a Super-Admin Marketing & Growth → Newsletter page lists subscribers + CSV export. Registered in `sitemap.xml` + `llms.txt` for crawler/AI discovery. Reuses the landing
Header/Footer, the SEO JSON-LD builders,isDisposableEmail, and MasterDB persistence — pulls Phase 31's P3 (lead capture) + P6 (landing) forward. Advances Phase 31 12 → 20%.
- AI Citation Hub (Marketing & Growth) — foundation + admin backbone. A new integrated module (NOT standalone; designed reuse-first in
docs/products/ai-citation-hub/via a map+design workflow) to make Restora an AI-citable authority on restaurant technology. Shipped P0 + the admin backbone: a unified `KnowledgeEntry` model (src/lib/citation-hub/— atypediscriminant over article/definition/statistic/report/comparison/alternative/howto/resource/glossary/location/industry; the six AI-citation blocks (Quick Answer / Definition / Key Takeaways / Statistics / FAQs / Summary); source provenance; a review status machine; i18n) stored atMasterDB.knowledgeEntries[]; 23 seed knowledge categories; a Super-Admin Marketing & Growth sidebar group (linking the existing SEO Center + Analytics); and a super-admin Citation Hub authoring page + CRUD API (/api/admin/citation-hub[+/[id]],requireSuperAdmin, Zod, slug-gen,draft → needs-review → publishedgate). Reuses the Docs-Center content model + the gated admin shell + MasterDB persistence + the SEO/JSON-LD builders. Next: public/learnpages + per-type Schema.org JSON-LD + sitemap/llms.txt (P1), then definitions/statistics/reports/comparisons/glossary, programmatic-SEO, a capped AI-draft assistant, multi-language, and citation tracking.
- P10 Barcode & QR 2.0 — universal Restora product-ID scheme (completes BQ-0). Each ingredient can carry a stable, platform-wide
RESTORA-PROD-…id (src/lib/inventory/product-id.ts—newRestoraProductId/isRestoraProductId, ambiguous I/L/O/U excluded; +2 tests).resolveByCodenow matches it (scan a Restora-ID label → resolves, ahead of sku/id);IngredientItem.restoraProductIdadded; a one-tap idempotent backfill (POST .../inventory/ingredients/restora-ids, collision-guarded) assigns IDs to items missing one; and the label sheet prefers the universal ID. Reviewed by a 2-agent adversarial pass (3 findings fixed: the validator regex is now derived from the alphabet so it can't drift, dedup-on-generate in the backfill, and real feedback on the "Assign Restora IDs" button). Moves P10 70 → 72%.
- P10 Barcode & QR 2.0 — printable product + shelf labels (BQ-0). A
/dashboard/labelspage with Products / Shelves modes: pick items → a print-ready grid of labels, each a title + a scannable QR. Products encode the item's resolvable code (barcode → SKU → ID — whatresolveByCodereads, so scan-to-receive/waste/count recognise it); shelves encode the/dashboard/locations?loc=<id>deep-link (scanning opens that location's items). QR is rendered client-side (qrcode.toDataURL, the same lib asPaymentQR) so a full sheet makes zero server requests;@media printhides the controls. Product labels also offer a QR / Barcode toggle — a real Code128 (or EAN-13 when the code is a valid 13-digit) linear barcode viajsbarcode, so handheld/wedge scanners read them natively. Linked from the Receiving header. (Shipment labels are the supplier-side "mark shipped" shipment QR — not duplicated.) Moves P10 55 → 70%.
- P10 Barcode & QR 2.0 — shelf/location QR. Name storage locations (shelf/bin/fridge) at
/dashboard/locations, assign ingredients to each, and print a per-location QR (GET .../inventory/locations/[id]/qr— server-rendered SVG via theqrcodelib) that deep-links to/dashboard/locations?loc=<id>— scanning it opens that location's items. NewStorageLocation(tenant-scoped) +IngredientItem.locationId+ locations CRUD + an assign-items route (all gated on inventory-management) + the manager page; linked from the Receiving header. Moves P10 45 → 55%.
- P10 Barcode & QR 2.0 — count-via-scan. A
/dashboard/countpage: scan/type a barcode → resolve via/inventory/lookup→ enter the counted level → stock is set to it (acorrectionthrough the singleapplyStockMovementseam; a count of 0 routes via a fullmanual-remove, since the seam rejects a 0-quantity correction), with the system→counted variance shown live. NewPOST /api/tenant/[id]/inventory/count(gated on inventory-management) + linked from the Receiving header. Sibling to scan-to-waste; reuses the scanner + resolver + stock seam. Moves P10 40 → 45%.
- P10 Barcode & QR 2.0 — scan-to-waste. A
/dashboard/wastepage lets staff scan or type a barcode (keyboard-wedge or camera — reuses the receivingBarcodeScanner) → resolve via/inventory/lookup→ pick a reason (spoilage / expiry / prep-loss / overproduction / breakage / returned-dish / staff-meal) + quantity → log awasteStockMovementthrough the singleapplyStockMovementseam. NewPOST /api/tenant/[id]/inventory/waste(gated on inventory-management) +StockMovement.wasteReason. Reuses the shipped scanner + resolver + stock seam; linked from the Receiving header. Moves P10 30 → 40%.
- WoW WA-4 — propose-only standing reorders (shelf-scan reorder, the safe model). Completes WA-4 (8/8). A restaurant sets up a standing reorder (supplier + items + cadence + a hard monthly cap); on schedule
POST .../reorder-subscriptions/runDRAFTS a reorder and notifies — it never auto-places an order. The owner reviews the draft on the supplier-marketplace and confirms (one tap → a quote request via the existing engine; the supplier prices it, no money moves) or dismisses; over-cap drafts are blocked. NewReorderSubscription(tenant-scoped) +src/lib/supplier/reorder.ts(isDue/capRemaining/estimateCost/advanceNextDraft; +5 tests) + CRUD/run/confirm routes + aReorderSubscriptionsPanel. Hardened after an adversarial multi-agent review (8 findings fixed): an atomic single-use confirm (claim→I/O→finalize, no double-quote/double-spend race), a live cap re-check at confirm, partial-estimate flagging (unmatched items no longer read as a free under-cap order), a suspended-supplier guard, andcontactPhonepass-through. tsc 0, eslint 0, suite 100, build ✓.
- WoW WA-4 — live Customer-TV board public demo. A no-auth acquisition showcase at
/wow/tv-demorenders the shipped Live Orders Display (P10CustomerDisplay) driven by a client-side simulated kitchen — fake order numbers cycling Accepted → Preparing → Ready (numbers only, no PII) — so a prospect sees the live pickup board come alive, with a "Build your site free" CTA into the funnel. Reuses the existing component (no backend). Remaining WA-4: shelf-scan reorder subscription (last item).
- WoW WA-4 — PDF price-book → tiered storefront. The snap-a-price-list flow (
/supplier/snap) now infers volume price-tiers from the extraction:inferPriceTiersparses variant labels like "10–49", "50+", "case of 24" intoSupplierProduct.priceTiers(cheapest-per-quantity, ≤6, real-bulk-break only — size-only variants like Liten/Stor yield none), so a supplier uploading a price-book gets tiered pricing on their catalog/storefront in one scan. The review table shows a "+N bulk tiers" badge and the save carriespriceTiersthrough the existing product-create path. +3 tests (suite 95). Remaining WA-4: shelf-scan reorder, live TV board.
- WoW WA-4 — photo-to-quote RFQ. A restaurant snaps a photo of a shopping list →
POST /api/tenant/[id]/photo-to-quotereads the line-items (vision extractor) + the best-matching suppliers (matchSuppliers, score normalised 0–100 vs the top match) → a "Photo to quote" panel on the supplier-marketplace lets them review items, pick suppliers, and fire a quote request to each via the existingPOST /api/tenant/[id]/quotes(suppliers bid; compare at/dashboard/supplier-quotes). Hardened after an adversarial multi-agent review (12 confirmed findings fixed): a 20-item cap at both the route and panel (the quotes endpoint enforcesitems.max(20)), all-fail-silently guards on the photo-to-quote + snap-invoice save loops,name.min(2)alignment in the invoice mapper, a dropped cross-currency savings total, and http(s)-only validation on the supplier-profile logo/website URLs (public-storefronthref). Remaining WA-4: PDF price-book→tiers, shelf-scan reorder, live TV board.
- WoW WA-4 — find-cheaper-suppliers + 30-sec storefront. Two more WA-4 pieces. (1) Find cheaper suppliers for this menu (premium): for every ingredient the restaurant already buys (has a
purchasePrice),findCheaperOptions(inrefill.ts, reusing its token-matching + tier pricing) surfaces the cheapest matching product from another verified supplier that beats the current price, with the % saving — shown as a "Cheaper suppliers" table on the supplier-marketplace page (GET /api/tenant/[id]/cheaper-suppliers, gated onsupplier-recommendations; +3 tests). (2) 30-sec storefront — suppliers get a/supplier/storefrontsetup page (newGET/PATCH /api/supplier/[id]/profile) to set logo + location + bio and publish their public/suppliers/[slug]storefront in one go. Remaining WA-4: photo-to-quote RFQ, PDF price-book tiers, shelf-scan reorder, live TV board.
- WoW WA-4 (two-sided B2B) — snap-invoice → live supplier catalog. The supplier mirror of "snap your menu": a supplier uploads a photo/PDF of their price list or invoice at
/supplier/snap→ the vision extractor reads it → each line maps to a draft supplier product (name, price, unit, auto-inferred category + currency) → the supplier reviews/edits in a table and adds them to the catalog in one go, no typing. Newsrc/lib/supplier/invoice-extract.ts(mapExtractedToProducts+inferCategory; +3 tests) + supplier-authedPOST /api/supplier/[id]/products/from-invoice(reusesrunMenuExtraction, per-supplier rate-limited) + the/supplier/snapreview UI (saves via the existing product-create path) + a "Scan price list" sidebar entry. Starts WA-4 — the Instant Lead Wall (matchRestaurants→/supplier/leads) was already wired. Remaining: photo-to-quote RFQ, PDF price-book tiers, find-cheaper-suppliers, 30-sec storefront, shelf-scan reorder, live TV board.
- WoW WA-3 (channel flywheel) — agent "Share & Earn" toolkit. A new
/agent/sharepage turns the existing referral-attribution backend into a self-serve growth kit: the agent's share link (/?ref=CODE) with a QR + copy, plus lead-magnet links that carry their ref — the Free Website Health Check (/wow/health-check?ref=CODE) and the snap-your-menu demo (/wow?ref=CODE) — so a genuinely useful free tool doubles as attributed cold outreach. (RefCaptureis mounted globally, so any?ref=sets the 90-day cookie → signup credits the agent, reusing the existing self-referral guard + first-paid-invoice attribution lock.) Plus a deterministic earnings simulator (simulateAgentEarnings— restaurants × avg plan value × the agent's rate → monthly/annual; no AI). AddscommissionRateto the agent context, a "Grow → Share & Earn" sidebar entry, and +3 tests. ReusesReferralCode/validateReferralCode+ the WA-2 Health Check. Starts WA-3. Also adds a live view counter on shared previews — a per-session view beacon (POST /api/public/wow/preview/[token]/view, rate-limited) bumpsWowPreview.viewsand shows a social-proof "👀 N views" badge, so a shared demo link feels alive. Plus a one-click resell site — a "Generate demo site" button spins up a polished sample-restaurant preview (reusing the/api/public/wow/demogenerator) with the agent's?refbaked in, so they can show a prospect what Restora builds before any menu is handed over. Plus designer before/after — a/designer/pitchpage in the web-designer portal where a designer pastes a prospect's URL → the WA-2 rebuild produces a side-by-side before/after + a shareable link to send the client (owner-permissioned, inspiration-not-clone; reuses/api/public/wow/from-url). White-label (A) — designer-branded previews: a preview produced via the designer pitch is co-branded "Prepared for you by [Studio]" (validateddesignerIdstored onWowPreview; the/wow/[token]page renders the designer's name + logo), so the designer looks like the creator — no disintermediation. White-label (B) — designer resell storefront: the public/designers/[slug]profile gains a branded "See your restaurant rebuilt — free, by [Designer]" CTA that funnels prospects into the WoW funnel carrying?d=<designerId>; the Health Check threads it into the rebuild → a designer-branded preview (A). White-label (C, software slice) — brand-forward previews: an admin-controlledWebDesigner.whiteLabelflag flips a designer's attributed previews from co-branded ("Prepared by …") to brand-forward (their logo + name lead, "powered by Restora" minimised). The full custom-domain + wildcard-SSL + reseller-billing layer of C remains a separate infra phase (deferred). WA-3 channel flywheel complete (software scope).
- WoW WA-2 — Multilingual menu (pre-auth, cached, budget-capped). The preview menu gains a language switcher (English · Svenska · العربية · Español · Deutsch · Français · Original). Picking a language calls a public
POST /api/public/wow/preview/[token]/translatethat Haiku-translates the menu strings (category + item names, descriptions, ingredients, notes) into an{original → translated}map the client applies (prices/brand untouched); cached per language on the preview (WowPreview.translations) so each language is translated once. Budget-capped via the global WoW daily USD ceiling +recordWowUsage, with a deterministic fallback — no AI key / budget spent / failure → empty map → the menu simply stays in its original language (never broken). Client-safe split:translate-langs.ts(language list + guard, dependency-free, imported by the client switcher) vstranslate-menu.ts(server-only, lazy-imports the Anthropic SDK). +2 tests. This completes WA-2 (conversion).
- WoW WA-2 — Before/After site rebuild + order-link carry-over. From the Free Health Check results, "See your site rebuilt — free" screenshots the owner's current site (the before) and runs the existing URL→Design-DNA clone pipeline to rebuild it as a branded, mobile, orderable Restora preview (the after), shown side-by-side in browser frames with an "Open full preview →". Crucially it carries the owner's existing online-ordering link — the Foodora/UberEats/Wolt/… link the Health Check detected — onto the rebuilt site, so they don't lose ordering when they switch. Additive:
WowPreview.externalOrderUrl; the clone route (/api/public/wow/from-url) now returnsbeforeImage+ anafterPreviewthumbnail and accepts anorderUrl; the Health Check captures the third-party order URL (ordering.meta.orderUrl); and/wow/[token]renders an "Order online" button when one was carried over. ReusesscreenshotUrl+ the design-DNA clone — no new AI path. The rebuilt preview now also renders the imported menu (a URL-clone does import one — it was previously hidden behind the "cloned design" card, so "Open full preview" looked empty), and the restaurant name is taken from the page `<title>` / `og:site_name` (deriveSiteName, generic-word-aware) rather than the less-reliable AI-vision brand name. Advances WA-2 (before/after ✓); remaining there: the multilingual menu.
- WoW WA-2 — Free Restaurant Website Health Check (pre-auth lead magnet). A new front door for owners who already HAVE a website: paste your URL at
/wow/health-check→ an instant, deterministic 0–100 site-wide health score + prioritised fixes — it crawls the homepage + a few same-origin sub-pages (not external links) and takes each check's best result site-wide, so a menu on/menuor contact on/contactcounts, then finishes with a 2–3 line "why join Restora" summary → a "Build my better site — free" CTA into the snap-your-menu funnel. Checks: HTTPS, page title, meta description, true mobile-responsiveness (fetches the linked stylesheets and requires real@mediabreakpoints — a viewport tag alone isn't a pass), H1, Open Graph, Schema.org rich results, tap-to-call, a real searchable menu (flags PDF / image / Google-hosted menus), online ordering (warns + names the provider when orders route through a commission aggregator like Foodora/UberEats), an optional table-reservations check (never penalised), address/map, favicon and food photos — each failing check spells out what Restora ships instead. No AI (pure HTML/CSS parsing, no headless browser) and abuse-guarded: WoW feature-flag, per-IP/day + global rate limit, a strict SSRF guard re-checked on every redirect, sub-page and stylesheet, timeouts, size caps and HTML-only gates. Newsrc/lib/wow/site-health.ts(scoreSiteHealthpure scorer +fetchSiteHealthcrawler; 9 unit tests) + publicPOST /api/public/wow/site-check+/wow/health-checkpage + an entry link on/wow. ReusesvalidateExternalUrl+requireRateLimit. Advances WA-2; remaining: a full visual before/after redesign + multilingual menu.
- P8 — Inventory intelligence (#6 / #4 / #7). All reuse-first on the existing scanner / supplier-matching / recipe stack. (#6) Scan-to-create ingredient — scan an unknown barcode/QR (handheld USB/Bluetooth keyboard-wedge, or the camera) and create the ingredient on the spot with a category guess; new
from-scanAPI + a "Scan" button in Inventory. (#4) Smart Supplier Refill — new premium add-on (79 SEK/mo) that turns low stock into a ranked, costed refill plan: which supplier covers what you need, estimated cost, savings vs your current prices, delivery estimate and missing items (buildRefillRecommendations+ gated API + a Smart Refill panel atop the marketplace). (#7) Recipe protection — a per-ingredient "hide from supplier matching" flag (keeps secret ingredients out of the anonymous supplier signal) + a runtime guard that blocks any supplier payload carrying recipe quantities/methods. - P7 — Offers & Campaigns (#14). Surfaced + completed the marketing stack (offers/email/loyalty already existed). (A) Storefront offers area — a new bold
promo-bannerblock (image + discount + coupon code + CTA + "Ends …") spotlighting the featured active offer, the existingcampaignsblock now shows codes/end-dates in the tenant currency, and a publicGET /api/public/[tenantId]/offers. (B) Scheduled & recurring campaigns now actually send — aCRON_SECRET-gated/api/cron/email-campaignssweep sends due one-off + recurring campaigns and advancesnextSendAt(they were stored but never fired). (C) Complete discounts at checkout — BOGO, free-item, and product/category-targeted coupons (was %/fixed only), with currency-correct amounts. (D) Plan-gating — coupon/loyalty/email-marketing write routes now enforcerequireFeature(premium features were a billing leak); basic offers stay open. (E) Loyalty earn-on-checkout — points now earn automatically on a paid order (auto-enrolling the customer by email) + bonus-point campaigns (a multiplier window, e.g. double points). Deferred (UX only): page-level upgrade prompts on the gated dashboards.
- Menu Import — AI image matching, validation, version history + undo, audit & staged progress. A 5-part upgrade to the menu-import flow (URL/photo/PDF import already shipped). (1) AI image matching — the import now finds a photo for every dish: an "Images" step matches each item against your media library → stock providers (Pexels/Unsplash/Pixabay) → category recommendation, with a deterministic 0–100 confidence, auto/recommend/suggest buckets, duplicate & wrong-category flags, bulk accept, and "choose your own" (gallery search/upload); accepted stock photos are imported (credited) to your library. (2) Validation layer — a non-blocking "Menu check" in the review step flags duplicate products/categories, missing/zero/suspicious prices, currency mismatch (vs your currency), thin descriptions, and missing photos/allergens. (3) Version history + Undo/Reset/Re-import — every import auto-snapshots the prior menu; a "History" panel undoes the last import or restores/deletes any earlier version (restores are themselves undoable); re-import gains a
replacemode. (4) Audit — imports, re-imports and restores are recorded to the activity log (source + counts + user) and appear in the super-admin platform-activity view. (5) Staged progress — extraction shows a live stage checklist; importing shows real "saving photos X/N → building your menu" progress. Newmatch-images+snapshotsAPIs; newimage-match.ts,validate-menu.ts,menu/snapshots.tslibs. Reuses the existing extraction engine, stock search, media library, currency registry, activity log, and budget/auth guards — all deterministic, no extra AI cost. (Deferred: HTML/Schema.org DOM parsing + embedded provider APIs.)
- Food Gallery reliability + Swedish search. Fixed broken gallery thumbnails: refreshed 18 dead demo image IDs and added a load-error safety net that auto-hides any image that fails to load (so a broken icon can never appear). Also fixed poor relevance for Swedish queries — stock libraries are English-indexed, so Swedish terms returned almost nothing; recognised Swedish food words are now auto-translated to English before searching (e.g. "Kyckling eller Kebab" → "chicken kebab"), working for both languages across all providers. (Live photos require a valid stock key in env —
PIXABAY_API_KEYetc. — and a dev-server restart.)
- Product editor — image picker + structured ingredients. The raw image-URL box is now a proper picker (your media library + food-photo search that auto-searches the product name + upload). The free-text ingredients line is now a structured list — one ingredient per line, autocomplete from a predefined catalog + your Inventory ingredients, with optional quantity & unit; new ingredients are added to your Inventory catalog and the list is saved as a recipe, so it automatically feeds the existing COGS / margin / inventory reports when quantities are filled. Existing free-text ingredients migrate into editable lines automatically. Saving ingredient lists/recipes is allowed on all plans (data entry); the paid Inventory analytics stay gated.
- One home for menu appearance — Menu → Appearance & Layout. Menu controls were scattered (numbering in Settings → Menu; the whole layout/style was AI-only with no editor). New Appearance & Layout tab on
/dashboard/menuconsolidates everything: columns (single/double), price style (dotted-leader / right / inline), item dividers, category headers, item photos, a quick style preset, and numbering — with a live structural preview. The Settings → Menu tab was removed (numbering moved here). The settings PATCH now acceptsmenuLayout; the live/menu, WoW preview, and home menu-preview already read it, so changes apply immediately. Delivery/Google "sync menu" toggles stay with their integrations.
- WoW — the website menu now matches the inspiration photo's PRESENTATION (not just colours). Uploading a menu photo already themed colours/fonts; now the AI also detects how the menu is laid out and the site renders it to match — the real "jaw-dropping" moment. A new
menuLayout(style / columns / price style: dotted-leader vs right-aligned / divider / numbering / category header / image-board) is detected by the vision call, persisted on claim, and applied at the WoW preview, the live `/menu` (a text-list mode with leader/right prices + dividers + numbering + columns, gated so existing tenants keep their card layout), and the home menu-preview.showcasekeeps image cards; the deterministic/no-AI path keeps today's standard layout. New sharedsrc/lib/menu/menu-style.tsis the single source of truth. Colours/fonts are untouched.
- Multi-page builder — theme + content now identical between the builder and the live site, + Contact page. Follow-ups after owner testing: (1) Theme/colour/background sync — the builder canvas was hardcoded white while the live pages are themed; the tenant theme is now scoped to the canvas (which also fixed a regression where the tenant theme leaked into and washed out the whole admin dashboard). (2) Content sync — the live Menu/Reservations/Blog pages now always render through the block renderer (default layout when the owner hasn't customised yet), so the live page matches the builder canvas exactly rather than only after the first save; Reservations' default keeps the wizard + hours + contact (as blocks). (3) Contact is now a real, block-editable page (
/contact, default[heading, contact-info]), added to the builder page switcher and the default nav. (Account — the customer auth dashboard — is the one page still pending block-ification.)
- Multi-page web builder — every page is now block-editable and stays in sync with the live site. The builder previously edited only the Home page; the live site was multi-page (Menu/Reservations/Blog auto-generated from data), so they diverged. Now: a page switcher (Home · Menu · Reservations · Blog) in the builder lets the owner edit every page as a block canvas. The data-driven content is exposed as dynamic blocks —
menu-full(the live menu grid + cart),blog-list(published posts),reservation-form(the booking wizard) — so a page stays auto-synced with the owner's data (change a menu item → the Menu page updates) while remaining fully composable (add/reorder sections around it). Architecture: a sharedBlockRenderer(_components/BlockRenderer.tsx) renders block arrays for all pages;settings.pageBlocksstores per-page layouts (Home stays onwebsiteBlocks); the builder state gained apagesmodel +SWITCH_PAGE; the builder API loads/saves per page with sensible default layouts. Backward-compatible: existing tenants keep their exact current pages until they edit a page in the builder (then it switches to block rendering).
- WoW deep-QA hardening (batch 3) — builder parity, SEO settings, business-hours closures. (1) Builder→live parity: the Featured section vanished on the live site when no product was flagged "popular" (a fresh imported menu has none) — it now falls back to the first menu items so what the owner sees in the builder shows on "visit site". (2) SEO settings applied: the SEO tab's
robotsPolicy(incl.noindex,nofollowfor staging/private), social-share OG image, Twitter/X description, and canonical URL were saved but never emitted — home + menugenerateMetadatanow honour them. (3) Business hours / closures now reach the storefront: the home page shows a "Closed / Temporarily closed — <reason>" banner from the real status engine (special days + temporary closures,channel: 'website'); the order API now blocks ordering during a temporary closure (not just regular hours); and the Food BazarOpen nowflag (list + detail) usesgetOpenStatusinstead of a naive hours-only check, so closed restaurants no longer show as open or rank open-first.
- WoW deep-QA hardening (batch 2) — dark-theme contrast, mobile overflow, customer copy/currency. (1) Dark-theme contrast: the Newsletter "plain" style (forced white bg → invisible theme text), the home FAQ section's undefined
bg-background-alt, and the menu out-of-stock chip now use theme tokens (bg-card/bg-background/bg-border/text-text-light). (2) Mobile (320px): the order-tracker action row no longer double-wraps (full-width cluster, labels hidden undersm, title truncates), the "match my brand" swatch row is now an even 5-col grid, and the contact phone number wraps instead of overflowing. (3) Customer copy/currency: checkout now shows the server's friendly message instead of raw codes (RESTAURANT_CLOSED/DEMO_MODE_ACTIVE), and the order tracker formats item prices, total, and refund with the tenant currency (formatMoney) instead of bare numbers.
- WoW deep-QA hardening (batch 1) — security + funnel robustness + mobile/dark polish. From a multi-agent QA pass: (1) Security: the customer order-cancel endpoint had a dead ownership guard (read
body.customerEmail, but the client sendsbody.email), so anyone with an order ID could cancel/refund another customer's order — a matching email is now mandatory (403 otherwise). (2) Magic-link lockout fixed: an email scanner/prefetch GET no longer permanently locks out the owner — verify is now idempotent (re-mints the same owner's session if the preview was already claimed by them). (3) Empty extraction guard: a non-menu photo used to create a blank dead-end preview — now returns a friendly 422 instead. (4) Rate-limit copy: 429s showed the rawRATE_LIMIT_EXCEEDEDtoken — now a human message (token moved tocode). (5) Mobile/dark: preview + storefront hero headlines no longer overflow at 320px, the printable QR pack no longer clips,FeaturedSectionuses theme tokens (were near-invisible on dark), and the preview inherits a theme-aware text colour (fixes an invisible cart quantity).
- WoW polish — trial banner, live-site menu numbering, builder theme sync. Three fixes from owner testing: (1) the dashboard now shows a "N days left in your free trial → add payment to keep your site live" banner for trialing tenants. (2) The website
/menupage now honors the menu-numbering setting (settings.menuNumbering.mode= global / per-category) —MenuClientrenders item numbers (respecting each item'sshowNumber); the setting was previously ignored on the public site. (3) The web-builder preview now applies the tenant'scustomColors(its API returns them + the canvasThemeInjectoruses them), so the builder matches the live site instead of showing only the base preset.
- WoW claim — 30-day no-card free trial (was pay-up-front). Claiming a preview previously created the subscription as
incomplete, so the magic link dumped the owner on/register/paymentbefore they ever saw their dashboard — at odds with the "live site in 60 seconds" promise. WoW-claimed restaurants now get a 30-day free trial with no card: the claim sets the subscription totrialing(ends in 30 days), so they land straight in the full dashboard with a live site. The dashboard gate lets active trials through and only routes to payment once the trial lapses (claim.ts + dashboard/layout.tsx/dashboard/layout.tsx)). The general register flow (freeTrialEnabled) is unchanged. (Note: this is the business decision the WoW strategy deferred to the owner — no trial length was previously settled; the brief said 60d, the pricing doc recommended 14d, the shipped default was pay-now.)
- WoW theme — the customer order tracker is now tenant-branded too. The live order tracker (
/track/[tenantId]/[orderId]) lives outside the/tenantlayout and was hardcoded to Restora's brand (#bf601d+ gray/white). It now injects the tenant's theme (colours + font + radius) and its UI uses theme tokens (bg-background/bg-card/text-text/text-primary/text-secondary) — so a customer tracking their order sees the restaurant's brand, not Restora's. Semantic order-status colours (amber/blue/emerald/red) are kept. (The generic cross-tenant lookup at/trackstays neutral — it has no single tenant.)
- WoW theme — now applied to EVERY tenant page via the layout (no more default-theme leaks). A multi-agent audit found two
use clientpages with noThemeInjector— `reservations/cancel` (the "cancel reservation" page that reverted to the red default) and `account` (post-login, incl. order history). Rather than patch each, the theme is now injected once in the tenant layout (it already loads settings for the radius), so colours + font + radius apply to every route — those two pages and any future one. The 7 correct per-page injectors are kept (they read the samesettings.*, so no divergence). Fixes the reservation-cancel theme revert.
- WoW hero — always a high-quality, cuisine-appropriate image (AI-chosen). Instead of a bare gradient when the owner's photo isn't hero-worthy, the storefront + preview always show a beautiful full-bleed hero. The AI judges the cuisine from the photo and the menu items and returns it; we map it to a verified, commercial-licensed Unsplash hero (hero-images.ts — pizza / burger / sushi / indian / thai / chinese / kebab / cafe / bakery / healthy / fine-dining + a restaurant-interior fallback; every URL curl-verified). A genuinely great upload (
heroStyle: 'photo') is still used; otherwise the cuisine image — never a flat gradient.
- WoW theme — full token cohesion + AI design tokens (radius personality). Completing the "designer-built" feel:
ThemeInjectornow derives the entire neutral family — card surface, borders, and muted text — from the theme background in the same hue, so cards, nav, hero panels and secondary text all sit in one cohesive palette (previously only background/primary/text were overridden, leaving the preset's clashing light borders/greys). And the AI stylist now also chooses a corner-radius personality (radius: sharp = modern/luxe, soft = balanced, round = casual/playful) applied across the storefront via a scoped override (settings.uiRadius) — so the feel, not just the colours, matches the restaurant. Contrast fix (UX): the storefront usessecondaryas the on-primary text colour (every primary button/card isbg-primary text-secondary), so it's now a luminance-picked contrast colour (.on) instead of a darkened primary — fixing gold-on-gold buttons (Book Table / Checkout / Call Now / contact card). Token-alias fix:getStyleDeclarationnow also emits--color-card-bgand--color-backgroundaliases, so components reading those names (the FAQ accordion, hero) stop falling back to hardcoded white on dark themes. The FAQ accordion was also converted to the storefront's Tailwind theme tokens (bg-card/text-text/bg-background) so it's readable in any theme. Name sync: the re-sync now also updates the tenant'ssettings.name+ record name to the current preview name, so the header logo matches the restaurant (fixing a stale name when the preview was renamed after the tenant was first created). The deterministic theme remains the fallback.
- WoW live storefront — the generated theme now applies everywhere, with an AI-chosen hero. Testing showed the theme stopped at the home page and the live site had jarring defaults. Fixes: (1) the theme now applies on every storefront page — home, menu, blog, reservations, checkout, order-confirmation — via a new
settings.customColorsthat each page'sThemeInjectorreads (previously only the home page did, so subpages reverted to the red preset); (2) the hard-coded blue "Popular" badge now uses the brand colour (MenuPreviewSection); (3) the hero is AI-decided —heroStyle: 'photo'only for a genuinely hero-worthy image, otherwise an elegant on-brand gradient (HeroSection) instead of an awkward stretched upload. The dev "view live site" re-syncs the theme on every visit, so edits show immediately. Cohesion fix: the card/surface colour is now derived from the theme background (same hue, a step lighter/darker) instead of a hardcoded slate#1e293b— fixing the jarring two-tone where the nav, hero panels and cards clashed (slate-navy) with a dark-green/warm theme. The menu page also drops a redundant single-category pill and tightens its spacing.
- WoW "match my brand" — AI theme stylist (capped) for a jaw-dropping first impression. On top of the deterministic engine, a best-effort AI vision pass (ai-theme.ts,
claude-sonnet-4-6, 512px image + tiny JSON output) designs a studio-grade theme from the photo: a light or dark scheme matched to the venue's mood, a harmonious accessible palette, a font pairing from the curated set, and a vibe + hero tagline. It's applied across the whole preview — including real dark mode and typography — and carried into the claimed live site (colours + font + scheme; dark sites get a light text colour too). Cost-capped: it runs only within the funnel's existing global daily USD ceiling + per-IP rate limit, on a downscaled image with a ~700-token output; if the cap is hit, AI is disabled (WOW_AI_THEME=false), there's no API key, or the call fails, the deterministic theme is the guaranteed fallback — the funnel never breaks. The confirmation surfaces the chosen scheme, vibe and font. Net: the deterministic engine is the reliable floor; the AI pass is the "wow" ceiling.
- WoW "match my brand" — full theme generation from a photo (the real WoW). Upload a photo and the entire preview re-themes to look designer-built — not just the buttons. A deterministic engine (brand-colors.ts, no AI) extracts a vibrant accent (the pop) plus the photo's overall tone (1×1 average) and builds a cohesive, accessible light theme: tinted page background, white cards, hue-tinted headings/body/borders, and the accent for CTAs/chips/links. It's applied across the whole preview via CSS variables — background, menu cards, hero, text, buttons, links all match the image (page.tsx + every preview component). Upload now shows a staged progress bar ("reading → extracting colours → designing your theme") and a confirmation card with the generated palette swatches (primary / background / surface / text / accent) so you see exactly what was created. The full theme carries into the claimed live site (
customColorsprimary + secondary + tinted background), and a latent ThemeInjector bug that forced dark cards on any non-white background was fixed. Fixes the "colours barely change / doesn't match the image / no upload indication" feedback.
- WoW funnel — smooth, forgiving back-and-forth (preview as many times as you want). Fixed a flow that dead-ended on "This preview has expired or was already claimed." after a dev peek. Root cause: the dev "view live site" shortcut consumed the preview (status →
claimed), so returning to the tab to upload a brand photo or edit the health-check hit thestatus === 'preview'guard and failed. Now: (1) the dev peek is non-consuming — it provisions a throwaway tenant, recordsdevTenantId, and leaves the preview editable; repeat clicks reuse it (claim.ts, dev route), and the link opens in a new tab. (2) A real (magic-link) claim now shows a celebratory "You've claimed this 🎉 → view your live site / log in" card instead of editable controls that 404 ([wow/[token]/page.tsx](src/app/wow/[token]/page.tsx)). (3) Edit routes return discriminated results (expired404 vsclaimed409 with a clear message) instead of one conflated string (preview.tsWowEditResult). (4) `/login` now explains claim outcomes (email-exists / claimed / expired) and prefills the email, so reusing an email or a similar name is guided, not a silent dead-end (login/page.tsx + verify route passes&email=). (5) Hardening: a transient provisioning failure rolls the preview back topreview(no burned previews);prunePreviews/getWowConfigare NaN/0-safe (a bad TTL can never instantly expire a live preview); and a real claim trims the tombstone (drops menu + hero) to keep the hot master document small.
- WoW acquisition funnel — "snap your menu → live restaurant" (pre-auth, flag-gated). The platform's flagship AI menu-import was buried behind a tenant login and the 199 kr paywall; this surfaces it as the public front door. On the landing hero (when the
wowFunnelflag is on), a visitor drops a menu photo/PDF and gets a branded, shareable preview at/wow/[token]in ~60 s — no account. Claiming it is passwordless: one email → a 15-min magic link that provisions a real tenant, migrates the menu in, and signs them in ("keep what you built"); the tenant starts on Basicincomplete, so the existing paywall gate asks for payment to publish (value before payment). Built almost entirely by reuse: the Claude-vision extractor was lifted into a shared run-extraction.ts (the tenantai-extractroute now uses it too — identical behaviour), the preview is an ephemeral token-keyed record on the master DB (text-only menu, no base64 crops — wow/preview.ts + schemaWowPreview), the menu→tenant migration mirrors the import conversion (import-to-tenant.ts), and provisioning reusescreateTenantDatabase+ the register pattern. Cost/abuse guards (no auth): the public extractor (extract route) is per-IP rate-limited + bounded by a configurable global daily USD ceiling (super-adminplatformSettings.wow), runs text-only (Stage-2 photo detection skipped → ~half the cost), and is off by default (enable via thewowFunnelfeature flag orWOW_FUNNEL_ENABLED=true). Expired previews are pruned daily (cron) + on each create; temp URLs arenoindex. Implements WA-0 + all of WA-1 — the preview has an orderable demo cart ("this takes real orders → claim it"), a share button + scannable QR, Open Graph tags for shared "look what I built" links, a "✓ Google-ready" card with embedded Schema.org JSON-LD (Restaurant → Menu → MenuSection → MenuItem → Offer), and a printable QR pack (/wow/[token]/print— a table-tent sheet you print or save as PDF). Also:/wowis a standalone uploader entry, and the funnel is on by default in development (off in prod unless enabled) (strategy). Behind a flag on branch `feat/wow-acquisition`; tsc + production build clean. Follow-ups: before/after + Free Health Check lead-magnets; agent/designer channel; two-sided B2B (WA-2…4).
- WoW demo content — "no menu handy? see a demo restaurant." Visitors without a menu to upload can one-click a ready-made sample restaurant — 10 dishes across Starters / Mains / Desserts / Drinks, each with a real, commercial-licensed Unsplash photo + ingredients + SEK price (demo-menu.ts; every image was curl-verified live and the IDs come from the in-app stock library — several library IDs had rotted, so they were checked). New `POST /api/public/wow/demo` seeds a preview (no AI, no cost); the landing +
/wowwidget shows a "No menu handy? See a demo restaurant →" button. The preview now renders item photos, and a claimed demo keeps the image URLs on the tenant's products.
- WoW WA-2 — "match my brand" (storefront photo → theme). On a preview, upload a photo of your place, logo, or a signature dish and we derive — deterministically, NO AI (palette.ts) — both a brand accent colour and a small hero image from it (theme route). The photo becomes the preview hero and is carried into the claimed site's hero + banner; the accent persists and now actually applies on the live storefront. Remaining WA-2: Free Health Check on the owner's existing site + before/after, multilingual menu.
- WoW claim fidelity — the live site now reflects the preview. Claiming a preview previously imported the menu into a generic template (default hero, empty "featured"). Now the claim sets the hero headline to the restaurant name + hero/banner image (uploaded brand photo, else first dish photo), the importer marks the first dishes `popular` so the home "featured" section showcases the menu, and the storefront's
ThemeInjectornow reads the tenant'scustomColors(it was hardcoded to nulls, so brand colours never applied to any tenant — fixed).
- WoW WA-2 (conversion) — free health check + 60-second sandbox order. The preview now actively converts: a free, interactive site Health Check — a deterministic 0–100 completeness score whose details (name / opening hours / phone / address) can each be added, edited, or cleared inline, or filled with one-click "✨ Fill with demo info"; every change PATCHes the preview and re-scores instantly, and a wrong menu can be re-uploaded via "Start over" (wow/health.ts + PATCH preview route; complements the post-claim SEO health-scorer) — and a 60-second sandbox order: add dishes to the demo cart, tap Place test order, and get a mock confirmation ("this is exactly what your customers see — claim to take real orders"). Pre-auth, no payment, no account. Plus the setup wizard now auto-completes steps from real tenant data (`/api/dashboard/onboarding-status` — detects profile/categories/items/staff, only steps whose template default is empty so it never falsely marks "done") — a WoW-claimed restaurant lands with menu + profile already green, so onboarding feels mostly finished instead of a 65-minute checklist (benefits every tenant; merges with browser progress). Remaining WA-2: a Free Health Check on the owner's existing site + before/after redesign, multilingual menu, storefront-photo theme.
- Business Hours — one page for all opening hours. New owner-portal page at
/dashboard/business-hours(nav: between Menu & Customers, page/dashboard/business-hours/page.tsx)) — the single source of truth for hours. Edit regular weekly hours (bulk-apply: copy a day to all / weekdays / weekend), special dates (holiday/event overrides), and temporary closures (vacation/renovation date ranges withall/website/marketplacescope), with a live status preview. Saved via the existing settings PATCH API; every channel already reads the tenant doc live, so there is no sync engine — website, Food Bazar, reservations and search reflect changes instantly. (Deliberately did not build the spec'd event bus / sync workers / 4 SQL tables: nothing is duplicated to sync, and that fights the serverless no-event-bus design, ADR-003.) The shared status engine opening-hours.ts gained channel-scoped temporary-closure resolution →Temporarily closed+ reopen label, and anOpens soonrefinement; the marketplace restaurant profile + item page honour temporary closures in both the open/closed badge and the ordering gate. Data lives onTenantSettings(openingHours/specialDaysalready existed;temporaryClosuresadded). v1 is single-shift; split shifts, country-aware holidays, per-service hours, the remaining discovery-card "Open now" wiring, and the live Google Business API are deferred follow-ups.
- TV pairing codes for the Live Orders Customer Display. Smart-TV browsers can't easily type the 64-hex display link, so a short, short-lived pairing code now bridges the gap. Owner/manager generates a code from the dashboard (Live Orders → Customer Display → TV code, TvPairingButton); on the TV you open `/tv` (page), type the code, and it hands off to the full display and remembers the connection (localStorage) so reboots auto-load. The code is a typeable pointer to the tenant's existing
liveOrdersDisplayToken(which stays the real secret): generation (pairing-code route, owner-gated) mints a 6-char code from an unambiguous alphabet (no0/O/1/I/L/U) into a small pruned global registry in the master DB (so/tvresolves code→tenant without scanning every tenant doc — schema `LiveOrdersPairing`); resolution (public pair route) is rate-limited (10/5min), codes expire in 15 min, and a bad/expired code returns 404 (no leak). Shared logic in tv-pairing.ts. The/tvpage is hydration-safe (localStorage read only post-mount).
Changed
- Data layer: JSON files → PostgreSQL (Phase 1 — reversible JSONB document-shim). The whole-file JSON databases were last-writer-wins (no read-modify-write serialization) — a documented P0 with real corruption on disk (
luigi-pizza.json.corrupt-*.bak). Phase 1 stands up PostgreSQL 16 (Docker locally via docker-compose.yml; a managedDATABASE_URLin prod) and backs every data accessor with ajsonbrow, keeping all ~710 call sites untouched. Each accessor in db.ts branches onDATABASE_URL— unset = the exact previous JSON-file behavior, so this is a reversible feature flag, not a cutover. New row-locked helpersmutateTenantDB/mutateMasterDB(pg.ts,BEGIN; SELECT … FOR UPDATE; …; COMMIT) fix the lost-update races — proven: 50 concurrent writes lose 49 the old way, 0 with the lock. Includes an idempotent JSON→Postgres importer (scripts/db/import-to-pg.mjs, skips the.corruptfiles), a half-migrated preflight guard, and the SSE inbox watcher + google-connections store made Postgres-aware. The stock-movement route is converted as the first proof hotspot. Full rationale + phased plan in ADR-003. Next (Phase 2): convert the remaining TOCTOU hotspots (addon billing, escrow/payouts, supplier orders, receiving/scan commits) through the lock — these are pre-existing races, off by default, no worse under Postgres.
- Phase 2 — money-path TOCTOU hotspots converted through the row lock. The documented read-modify-write races are now serialized: 2a addon billing (
activate/cancel/pause/resume/sweep+ cron) →mutateMasterDB; 2c supplier B2B orders (placeOrder/confirmPayment/transition) →mutateSuppliersDB, with the on-delivery payout split so the async Stripe transfer runs outside the lock (+ apayout_<orderId>idempotency key against double-pay); 2d receiving + scan-session commits →mutateTenantDB; and 2b the escrow money path —createPaymentHold, payoutschedule/release/hold, the refund engine, and customer/restaurant risk scoring → a newmutateEscrowDB(escrow-engine.ts, payout-engine.ts, refund-engine.ts, scoring.ts). Each conversion keeps the mutator synchronous — gateway/Stripe calls stay outside the lock and idempotency guards live inside it — and logs fire once on commit. Also hardened the shim itself:mutateDocgained an optional race-safe `seed` (INSERT … ON CONFLICT DO NOTHING→ re-SELECT … FOR UPDATE) so lazily-created singletons (escrow / suppliers / master) get the same first-write-creates behavior as the JSON files instead of throwing on a fresh Postgres deploy;mutateTenantDBpasses no seed, so a missing tenant still errors. Each phase was adversarially reviewed (escrow: all hard invariants hold — no lock-across-await, idempotency intact, seed race-safe).
- Phase 3 — last filesystem-direct subsystems moved onto Postgres. The two remaining stores that wrote straight to disk now branch on
DATABASE_URLlike everything else: the receiving scanner's per-session log (was an append-only JSONL sidecar) becomes an append-only `scan_events` table (002_scan_events.sql) —UNIQUE(tenant, session, scanEventId)makes a retried/replayed scan idempotent and a per-sessionpg_advisory_xact_lockserializes the monotonicseqso concurrent phone scans can't collide; and the Docs Center analytics document moves intoapp_documents('docs-analytics'). Both seams live inside their own modules (scan-log.ts, analytics.ts) so every call site is untouched and the fs path stays byte-identical whenDATABASE_URLis unset. Verified live: 11 concurrent scan appends (5 distinct + 10 duplicate retries) yielded exactly 5 rows with the duplicate folded once and seqs monotonic 1–5.
- Phase 4 — serverless / scale hardening. The shared pool (pg.ts) is now deployable behind a transaction pooler:
PG_POOL_MAX(set 1–3 for serverless),PG_SSL(require/no-verifyfor managed TLS like Neon/Supabase), and connect/idle timeouts so a hung connection fails fast and idle backends are released. Two deliberate decisions, documented in ADR-003: LISTEN/NOTIFY is intentionally not used (it needs a pinned session a transaction pooler won't give — the SSE doorbell keeps pollingdocUpdatedAt, which is pooler-safe), and JSONB GIN indexes are deferred to Phase 5 (every read is a whole-document fetch by primary key, so adoc->…index can't be used until hot entities are normalized into their own tables). New load-test harness (load-test.mjs,npm run db:loadtest) sustains the row-locked write path under contention — measured ~3,900 ops/sec at 24 concurrent workers, 0 errors, p95 ≈ 23 ms, and zero lost updates (the per-row counters summed exactly to the write count), upgrading the migration's correctness proof from a single 50-shot burst to sustained load.
- Phase 5a — orders normalized into an indexed table (staged, opt-in). The first hot entity is lifted out of the whole-document JSONB scan: an indexed `orders` table (status / createdat / paymentstatus) now mirrors each tenant's
doc.orders, kept consistent inside the tenant-write transaction via reconcileOrders (a per-order content hash re-writes only changed rows; removed orders are deleted) — so the document and the mirror commit or roll back together. Heavy filtered reads use the indexedqueryOrders(the orders-list API adopts it) instead of scanning every order in the document. Deliberately staged for safety: opt-in viaORDERS_TABLE=1, the JSONB document stays the source of truth, andgetTenantDBis unchanged — so there's zero risk to the hottest read path and the flag flip is a clean rollback (a non-destructivenpm run db:backfill:orders(re)syncs the mirror). The full doc-shrink cutover (readdb.ordersfrom the table, stop storing them in the doc) is the documented next step.created_atis stored canonical-UTC so the index sorts chronologically, matching the document path exactly. Adversarially reviewed (content can't diverge from the doc; reversible) and live-verified — count parity across every tenant plus add/update/delete/hash-skip reconcile. Kysely is deferred until 5b–5d add more tables (raw parameterized SQL is cleaner for one). See ADR-003.
Fixed
- Hydration mismatch on `<html>` (iPhone-only) + Food Bazar hydration audit. Root cause of the reported
/bazarerror: iOS Chrome / the Google in-app browser inject root attributes into<html>before hydration (e.g.__gcrremoteframetoken,__gcruniqueid) — Android Chrome does not, hence iPhone-only. The app renders<html>fully static, so this is third-party DOM mutation the app can't prevent; fixed with an element-scopedsuppressHydrationWarningon<html>in layout.tsx (same documented pattern already used on<body>for Grammarly, FIX-012; element-scoped so it never masks descendant mismatches). Audited the whole Food Bazar render tree — it is hydration-clean (dynamic data is client-fetched, so pages rendernull/skeletons at hydration; the search bar + filter sheet are mount-gated). Found and fixed one genuine latent bug: [CountdownBadge](src/components/bazar/CountdownBadge.tsx) computedDate.now()in itsuseStateinitializer (runs on server and client → mismatched label) — now startsnulland computes the live countdown only after mount. Pinned locale on item-count formatters (toLocaleString('en-US')) in /bazar/bazar/page.tsx) and /bazar/menu/bazar/menu/page.tsx). Added a reusable hydration auditor (scripts/check-hydration.mjs,npm run audit:hydration) that grades client-vs-server / render-vs-handler risk into diagnostics/hydration-report.md, plus a cross-device test checklist.
- Food Bazar — search/filter polish (enhancement #15 follow-up). Removed the marketplace stats strip ("Average across N restaurants / Reviews total / Reviews last 30 days") and its
marketplace-statsAPI per the redesign. The Food Court Finder (venue type + food-court/mall name + city) is now available in the mobile filter sheet, not just desktop. Added a Clear all filters control on desktop (mobile already had one). Added "Use my location" on both — browser geolocation reverse-geocoded to a city (free via Nominatim, no key; new reverse-geocode route) that pre-fills the city while keeping manual search editable.
- TipTap editor: "Duplicate extension names found: ['link','underline']" (Priority-1). StarterKit v3.23 now bundles the
linkandunderlineextensions, but EmailEditor.tsx (the single rich-text editor, reused by the blog editor) also registered them separately. Disabled the bundled copies viaStarterKit.configure({ link:false, underline:false })so the custom-configured Link (openOnClick:false,rel="noopener noreferrer") and Underline remain the single source of truth, and added a dev-only guard that inspects the fully-resolved extension set and warns on any duplicate name so a future regression is caught early.
Security
- Path-traversal hardening of the tenant DB accessors.
getTenantDB/saveTenantDB/createTenantDatabase(db.ts) now reject anytenantIdthat isn't a[A-Za-z0-9_-]slug, so a crafted id can never escapedatabases/tenants/. This closes a hole that the new unauthenticated mobile-scanner routes (which take[tenantId]straight from the URL) would otherwise have exposed — e.g./api/public/..%2F..%2Fmaster/scan/…resolving to the platform master DB. Found and fixed during the feature's adversarial review.
Fixed
- Food Bazar — prices now render in each restaurant's own currency (enhancement #13). The marketplace surfaces hardcoded a
$/USD prefix everywhere; they now format every price with the restaurant's resolved currency viaformatMoney. The multi-currency foundation already existed (currency.ts — 18-currency registry, country→currency map,resolveCurrency; per-tenantcurrency/countryCode; a Settings → "Location & Currency" selector that auto-maps country→currency) — this closes the display gap. Currency is now plumbed through the cross-restaurant APIs (/bazar/menu,/bazar/sponsored,/bazar/orders/me,/bazar/orders/[orderId]— resolved per restaurant) and consumed by every customer surface: home, menu, item detail, checkout (line items + summary + place-order CTA), order detail, account order history, and the global cart drawer.CartItemnow carriescurrency(captured at add-time across all add-to-cart entry points) so multi-restaurant carts render each line correctly. No cross-currency conversion — each restaurant prices in its own currency, as designed. Remaining: defaulting a brand-new tenant's currency from its country at signup needs a country field on the paid-signup form; today new tenants default to the platform currency and owners set theirs in one click via Settings.
Added
- Food Bazar — personalized "Offers for you" (enhancement #17). Signed-in customers get a rail of discounted items matched to their preferences (dietary first, falling back to a preferred cuisine), powered by a new
?offers=trueflag on the menu API that returns only items carrying anofferPrice. The rail renders with the standard food card and silently hides when signed out or when there's nothing relevant (signed-out users still see the generic Featured section). (bazar/page.tsx/bazar/page.tsx))
- Food Bazar — Open-now & Delivery filters (enhancement #15). The marketplace gains two operational quick-filters — Open now and Delivery — available on both the desktop filter row and the mobile filter sheet. The menu API honours
?openNow=true/?delivery=true(resolved per restaurant), and both filters participate in the active-filter count and URL state so results are shareable.
- Food Bazar — dietary-aware recommendations + ranking order (enhancement #18/#21). Recommendations now factor a signed-in user's food/dietary preferences (set in P0): each restaurant's available dietary tags are matched against the user's preferences and boost its rank — a vegan or gluten-free diner sees suitable places first, even when the cuisine doesn't match — with a clear "why" reason, applied to both the personalised scorer and the cold-start popular fallback (recommendations route). The marketplace home is now ordered Sponsored → AI Recommendations → the rest, per the ranking spec (bazar/page.tsx/bazar/page.tsx)).
- Food Bazar — Food Court Discovery + Finder (enhancement #11/#15). Restaurants can now record the venue they sit in — food court, food street, shopping mall, indoor market, or outdoor market — plus the venue name and city, from a new Location & Venue card in their Food Bazar settings (
TenantBazarSettings.venue). The marketplace gains a Food Court Finder filter (venue-type chips + city) so diners can discover everything in one mall/court/market; the menu API filters by?venue/?venueName/?city(resolved per restaurant) and returns the venue on each item, which now shows as a 📍 badge on the cards. First slice of the P6 Bazar-discovery track.
- Customer Reviews — food-item reviews with photos + complaint resolution (enhancement #12). On top of the existing restaurant reviews (rating, text, owner reply, moderation, verified-purchase badge), the system now supports food-item reviews: a review can target a specific menu item (
Review.productId/productName) and carry photos (images), submitted via a new public, rate-limited review-photo upload (upload route — type/size validated, slug-guarded, UUID filenames; submissions only accept URLs from that endpoint, so no external-URL injection). Reviews also record whether the customer ordered online or visited in person (visitType). Owners get complaint-resolution tools beyond replying — mark a review resolved with a goodwill gesture (a discount code or a free item) plus an optional note (reviewId PATCH) — surfaced in the dashboard reviews page/dashboard/reviews/page.tsx) alongside item badges, photo thumbnails, and an online/in-person tag. Customers leave and read item reviews from a new ItemReviews section on the Bazar item page (star summary, photo upload, owner replies). The Bazar review API gained a?productId=item feed + per-item upsert. Bazar submission stays auth-gated; tenant-site reviews stay moderated.
- Live Orders — ready-order sound, smart rotation, and display settings (10c, completes the Live Orders Display request). A ready-order chime plays when a new order enters the Ready column — a pleasant two-note Web Audio tone (no asset), armed by a one-tap "enable sound" (per browser autoplay policy), fired only for newly-ready numbers, with configurable volume. Smart rotation paginates each column by a configurable orders-per-column and auto-cycles pages (with a page indicator) so a busy queue never clutters or overflows. Owners configure it all — welcome message, orders-per-column, rotation cadence, sound on/off + volume — in a Display settings panel on the Customer Display view with live preview (display-config API + shared resolver); the public TV picks up the same settings via its projection.
- Live Orders — public unattended-TV display + live updates (10b). The customer display can now run on an unattended waiting-area TV without a staff login: owners mint a revocable, display-only capability token (Live Orders → Customer Display → "TV link"; token route, stored as
liveOrdersDisplayToken), which authorises a public [/display/[tenantId]/[token]](src/app/display/[tenantId]/[token]/page.tsx) page. That page polls a PII-stripped projection ([/api/public/[tenantId]/live-orders](src/app/api/public/[tenantId]/live-orders/route.ts)) every 5s — returning order number + status only (never names, phones, notes, payment, or totals), with constant-time token validation, 404-on-mismatch (no existence leak), and the slug-guarded tenant accessor. The display keeps the last good frame through transient network blips for 24/7 stability. On the authenticated page, the board + customer display now refresh live via the existing SSE doorbell (useTenantPushSignal, background refresh — no spinner). No persistent sockets (pooler-safe per ADR-003). The display component is shared between the in-dashboard view and the public TV.
- Live Orders — Fullscreen + Customer Display mode (10a of the Live Orders Display request). The Live Orders page gains a view switcher (Operations / Customer Display), a Fullscreen toggle (native Fullscreen API — covers the screen and hides the dashboard chrome; ESC exits), and a customer-facing Customer Display view: three large columns (Accepted · Preparing · Ready) showing order numbers and status only — no names, phones, notes, payment, or staff actions — with the "Ready for Collection" column visually dominant (larger cards + gentle pulse), big high-contrast typography that scales up in fullscreen, and a live clock. Built within the authenticated dashboard for now; an unattended-TV public token endpoint with SSE real-time, ready-order sound, smart rotation, and owner config settings follow in 10b/10c. (orders page/dashboard/orders/page.tsx))
- Live Orders — board windowed to the current shift + Delivered History (enhancement #2). The sidebar entry is renamed Orders → Live Orders. The board now shows the open pipeline (Incoming · Accepted · Preparing · Ready) plus a Delivered Today column instead of an ever-growing "Delivered" list. The orders API windows delivered orders to today server-side (using
deliveredAt, which is stamped when an order is marked delivered), so the operational board reflects the current shift and never loads the full delivered backlog. A new Delivered History panel on the orders page/dashboard/orders/page.tsx) loads older delivered orders on demand with a date-range filter (or all), newest-first, capped at 200. The legacy?status=query is preserved for back-compatibility.
- Legal & Compliance pages (enhancement #5). A single legal documents registry drives 9 placeholder legal documents — Terms & Conditions, Privacy Policy, Cookie Policy, Data Processing Agreement, Marketplace Agreement, Supplier Agreement, Restaurant Agreement, Escrow Terms, and Refund Policy — rendered at `/legal/[slug]` (statically generated, each with its own SEO metadata) plus a `/legal` index grouped by category. The landing and Food Bazar footers now link to the real pages (previously dead
#links). Content is professional plain-language placeholder, clearly flagged for the operator's counsel to finalise.
- Escrow & payout education (enhancement #10). A reusable, plain-language EscrowExplainer — a collapsible "30-second" card on the Payouts & Escrow page — explains why escrow exists (buyer + seller protection, when funds release, using the tenant's real
payoutDelayHours/grace-period policy) and how payouts work (settlement flow, processing time, fees), linking to the full Escrow Terms. Designed to be understood at a glance.
- Owner dashboard — Operations Center is now a configurable layout (enhancement #1). The Operations Center widget grid gains a column selector (1–4 columns) and drag-to-reorder widgets (via the existing
@dnd-kitdeps, keyboard-accessible throughKeyboardSensor). Both the column count and the widget order are remembered per tenant (localStorage, consistent with the sidebar's persistence). Mobile stays single-column regardless; drag-and-drop is gated behind mount so SSR matches the first client render (no hydration mismatch — see ADR-002). Feature-gated widgets that are toggled on later append in natural order rather than disappearing. (OperationsCenter.tsx)
- Owner dashboard — keyboard shortcut to collapse/expand the sidebar (enhancement #3). The sidebar already supported collapse/expand (with a desktop icon-rail), localStorage persistence, a mobile drawer, smooth animations, and aria labels; this adds the one missing piece from the spec — Ctrl/Cmd + B toggles the desktop sidebar (the familiar editor convention). (SidebarNavigation.tsx)
- Food Bazar — food/dietary preferences for logged-in customers (enhancement #20/#18). The Bazar account Preferences tab gains a Food Preferences card: dietary tags (Halal, Vegan, Vegetarian, Gluten-Free, Dairy-Free, Nut-Free, Organic) + a spice-level selector (mild → extra-hot, tap-to-clear). Persisted on the Bazar user via new
dietaryPreferences+spiceLevelfields onBazarSubscriptionPrefs(schema.ts), with bounded server-side sanitization in PATCH /api/bazar/user/preferences (dedup, length caps, spice-level enum check; clearing spice persists via an explicitnull). Fields are optional + default[], so existing accounts are unaffected; they'll feed search + recommendations in a later phase. The per-restaurant account page already supported dietary editing — this closes the gap on the marketplace account.
- Food Bazar — Popular This Week is collapsible + remembered (enhancement #19). The home recommendation rail can now be collapsed/expanded from its header. It defaults to collapsed in cold-start mode ("Popular this week") and expanded for personalised recommendations, and the user's choice is remembered (localStorage). The rail only ever renders for signed-in users (the recommendations API returns an empty list when signed out), keeping the front page tidy for returning customers. (bazar/page.tsx/bazar/page.tsx))
- Receiving Goods — Phase 6: AI smart receiving + report export. The receipt view gains an "AI review" panel (POST /inventory/receiving/ai-suggest): rule-based flags are always computed on the device — cost anomaly (unit cost >25% above the ingredient's usual
purchasePrice), still-below-reorder-point after the delivery, and short shipments — and a budget-gated Claude call (claude-haiku-4-5, max 220 tokens) adds a 1–2 sentence advisory summary, gracefully falling back to a rule-based summary when there's no key / the tenant is over budget / on any AI error. AI spend is metered under a new `'receiving'` feature (analytics.ts → "Smart Receiving"), so it shows up in the super-admin AI-usage breakdown like every other AI feature. Separately, a CSV export (GET /inventory/receiving/export, "Export CSV" header button) streams one row per receipt line, filterable by date range / status / supplier (print-to-PDF from the sheet covers the PDF case — no PDF dependency, per the codebase convention). Reuses the proven AI budget pattern (resolveAIKey/getBudgetStatus/recordAIUsage); additive, tenant-scoped, feature-gated. Completes the Receiving 3–6 track.
- Receiving Goods — Phase 5: discrepancy resolution + supplier scorecard. Receipts can now carry discrepancies — short shipments, damage, wrong/poor goods — each with a type, severity, free-text description, and photo evidence (uploaded through the existing media route), plus a resolution. They're logged + resolved from an "Issues & discrepancies" section in the receipt view (discrepancies route) and are allowed on committed receipts (the stock stands; this tracks the supplier issue). A new supplier scorecard (GET /inventory/supplier-performance) is derived on read from committed receipts — per supplier: receipts, units received vs missing/damaged, an accuracy %, and open issues, worst-first — shown as a panel on the receiving page so owners see which suppliers under-deliver. Additive, tenant-scoped, feature-gated.
- Receiving Goods — Phase 4: shipment QR → one-click receive. When a B2B supplier marks an order shipped, the order gets a signed 256-bit
shipmentTokenand the supplier's orders page shows a printable/copyable Shipment QR (ShipmentQrModal) that deep-links the buyer's dashboard. The buying restaurant — logged into its own dashboard — opens it and the receiving page claims it via POST /supplier-orders/claim-shipment: the token is validated (constant-time) and, crucially, the order must belong to that tenant, so a code only ever works for its real buyer. The claim marks the order delivered (settling the supplier payout — exactly the trust of the existing "Confirm received" button) and auto-drafts a Goods Receipt, opened for review in one tap. Reuses the existingtransition,draftReceiptFromSupplierOrder(idempotent), and theqrcoderenderer. Adversarially reviewed: no cross-tenant claim and no payout without an authenticated buyer session.
- Receiving Goods — Phase 3: batch & expiry tracking. Receipt lines can now carry a batch/lot number and expiry date (optional per-line inputs in the receive modal). On commit, a `ProductBatch` record is created for each batch/expiry line — createBatchesFromReceipt, reconciled inside the same row-locked commit transaction and idempotent by
receiptId(a re-commit never duplicates batches, mirroring the stock-movement guard). A new GET /inventory/batches returns the expiry watchlist (soonest-first, each tagged expired / critical ≤7d / soon ≤30d / ok), and the receiving page gains a color-coded "Batches & expiry" panel. Purely additive — lines without batch/expiry behave exactly as before. (Per-batch FIFO depletion on order is a later step.)
- Operations Center — 4 more widgets (the extensible shell pays off). Building straight on the reusable
OperationsWidget, the command center gains four data-driven cards that each render only when the tenant actually has the relevant data (so the grid stays relevant per restaurant): 📦 Inventory Alerts (low/out-of-stock ingredients with supplier + reorder point, "Receive" → goods-in), 🚚 Supplier Requests (B2B marketplace quotes awaiting your decision + orders in transit / ready for pickup), 🧑🍳 On Shift Today (derived from the schedule — who's on now is highlighted live, plus today's other shifts and approved time-off), and ✨ AI Recommendations (the latest daily briefing's top actions by priority + opportunities). All four extend the same single [GET /api/tenant/[tenantId]/overview](src/app/api/tenant/[tenantId]/overview/route.ts) aggregation (the supplier widget adds a parallel, non-fatal read of the shared B2B doc — a failure there degrades only that card). The endpoint now takes the viewer's local date so "today" agrees with the local clock used to mark who's on shift. No new round-trips, same real-time refresh.
- Restaurant dashboard "Operations Center" — a true command center on the Overview page. Directly below the (now visually-polished) KPI cards sits a new responsive 4-widget grid (OperationsCenter) that lets an owner see everything needing attention in ~3 seconds: 📅 Upcoming Bookings (next reservations with status pills), 🔔 Notifications (derived operational alerts — low/out-of-stock, failed payments, reviews to answer, reservations to confirm — each click-through to the right page, with "Mark all read"), 🧾 Live Orders (active orders with status colors + amounts, plus a "Kitchen" shortcut to the KDS), and ✉️ Inbox (customer contact-form messages — previously collected but never surfaced anywhere). All four are fed by a single aggregation endpoint [GET /api/tenant/[tenantId]/overview](src/app/api/tenant/[tenantId]/overview/route.ts) (one tenant-DB read, one round-trip) and refresh in real time off the existing SSE push signal with a 60s poll fallback — no page refresh. Built on a reusable [OperationsWidget](src/components/dashboard/overview/OperationsWidget.tsx) shell (icon tile, tone-coded badge count, quick action, "View all", loading + empty states) so future widgets (inventory, suppliers, staff attendance, AI recommendations) drop in without redesigning the page. Also adds a Messages page + nav entry (/dashboard/messages/dashboard/messages/page.tsx)) backed by [GET/PATCH /api/tenant/[tenantId]/inquiries](src/app/api/tenant/[tenantId]/inquiries/route.ts) to triage those customer enquiries (reply, mark read/replied, delete). Premium light-glass styling (rounded-2xl, soft shadows, hover lift, semantic status colors). The shared inbox
mark-readendpoint was moved onto the row-lockedmutateTenantDBwhile it gained a second concurrent caller.
- Staff scheduling — Phase 2: time-off, conflict detection, copy-week & shift swaps. The weekly scheduler (/dashboard/scheduling/dashboard/scheduling/page.tsx)) grows from a plain grid into a real planning tool. Time off: log a date-range block per staff member (vacation / sick / unavailable / other) with a pending→approved/denied workflow; approved time off renders in the grid and blocks scheduling. Conflict detection: a shared pure engine (conflicts.ts) flags shift overlaps (same staff, same day) and shifts that clash with approved time off — surfaced as red badges + tooltips on the grid and a week conflict count. Copy-week: one click clones a whole week's shifts into the next, preserving each shift's day-of-week and times while skipping inactive staff, approved-time-off dates, and exact duplicates (idempotent — safe to re-run, never double-books). Shift swaps: from any shift, request a one-way cover (reassign to another member) or a mutual swap (trade two shifts); approving it in the new Requests panel applies the reassignment. New
StaffTimeOff+StaffSwapRequestmodels onTenantDB, and routes /time-off, /shift-swaps, and /shifts/copy-week — all writing through the row-lockedmutateTenantDB. Manager-managed for now (staff have no self-service login); the request records carry `requestedBy`/`reviewedBy` so a future staff portal can plug straight in.
- Super-admin AI usage analytics dashboard. A new /admin/dashboard/ai/usage page (sidebar AI Usage, plus a cross-link from AI Management) turns the per-restaurant tokenomics into a platform view: a rolling 12-month AI-spend trend (click any bar to drill into that month), an 8-tile KPI row (month spend, requests, input/output tokens, active AI tenants, tenants over budget, top-ups purchased, all-time spend), a spend-by-feature breakdown (AI assistant / menu photo import / social posts / email marketing / daily briefings), and a top-consumers table — per-tenant spend, tokens, and a budget-utilization bar with an over-budget badge. Backed by a pure aggregator analytics.ts (
buildAIUsageAnalytics) and a super-admin route GET /api/admin/ai/usage (?month=). To power the feature breakdown,TenantAIMonthUsagegained an additive, optional `byFeature` map andrecordAIUsageafeaturetag (all six AI call sites — chat, menu extract, social, email, briefings — now attribute their spend; months recorded before this still roll up correctly, shown as an "attributed %"). Costs are displayed in USD (Anthropic bills in USD and the per-tenant budget is a USD figure — no misleading SEK conversion). While there,recordAIUsage+addAITopupUsdwere moved onto the row-lockedmutateTenantDB, closing a lost-update race on the AI-spend path (concurrent calls could undercount spend and slip past budget). Scale note: the aggregate reads every tenant's usage doc (O(tenants)) — fine for an admin-only report; folds into the planned hot-entity normalization (migration Phase 5) at larger scale.
- Mobile Scanner Pairing — turn any phone into a wireless barcode scanner (WhatsApp-Web-style). On the Receiving page, “Connect phone scanner” opens a session and shows a pairing QR (PairingModal, QR rendered client-side via
qrcode). Scanning it opens a public, login-free scanner page ([/scan/[tenantId]/[token]](src/app/scan/[tenantId]/[token]/ScanClient.tsx) — full-screen, dark, big confirmation + haptics) that reuses the cameraBarcodeScanner. Every scan streams into the desktop’s live cart (~1s cursor polling — zero new infra, matches the existing KDS/tracker pattern) and commits via the existingGoodsReceiptengine. Architecture: raw scans are append-only `ScanEvent`s in a per-session JSONL sidecar (scan-log.ts) so concurrent writes append (O(1)) instead of racing the tenant JSON; the desktop derives the cart by folding events. The pairing token is capability-scoped (scan-only, bound to one session + the first device, 20-min expiry, revoked on commit) — never account access. Commit is idempotent (requestId), validates ingredients still exist, and the session log is cleaned up on commit. Engine in scan-session.ts; routes under /inventory/receiving/sessions (desktop) + [/api/public/[tenantId]/scan/[token]](src/app/api/public/[tenantId]/scan/[token]/route.ts) (phone). MVP scope: single phone, online-only, one active session per tenant. Next: multi-scanner merge, SSE doorbell, supplier shipment QR, offline queue + PWA, AI assist. Honest scale: real for single-restaurant receiving; “thousands of simultaneous scanners” needs the planned SQL/Redis migration.
- Receiving Goods (Ta Emot Varor) — phase 2: barcode/QR scan-to-receive. The Receive flow now has a scan bar: a keyboard-wedge input (works with any USB/Bluetooth handheld scanner + manual typing — Enter = +1) and a live phone-camera decoder (BarcodeScanner via
html5-qrcode, mounted on demand, debounced). Each scan resolves the item through a new GET /inventory/lookup (match priority barcode → SKU → id → name) and bumps or appends a line in the receiving cart; repeat scans increment quantity. A no-match scan offers to link that barcode to an ingredient on the spot (self-learning — next scan just works).IngredientItemgainedsku/barcode, editable in the inventory item modal and accepted by the ingredient POST/PATCH. Hardened after an adversarial review: the camera stream is torn down cleanly even when toggled off mid-startup (no zombie stream), barcode-linking checks the save result (no false "Linked" / no cart bump on a failed save), and duplicate barcodes are rejected (409) across the tenant's ingredients.
- Receiving Goods (Ta Emot Varor) — phase 1: manual & marketplace-linked receiving. A new Ta Emot Varor page (receiving/page.tsx/dashboard/receiving/page.tsx), under Inventory, gated on the
inventory-managementadd-on) for receiving deliveries fast: pick supplier / invoice / delivery-note reference, add lines with received / missing / damaged quantities + unit cost, then Receive — every mapped line stocks the ingredient and writes an auditableStockMovement. Introduces a first-class `GoodsReceipt` model (draft→committed, soft-deletable) onTenantDB.goodsReceipts, and a single shared `applyStockMovement()` seam (extracted from the manual-adjust route so receiving can't drift). Confirming a B2B marketplace order as delivered now auto-drafts a receipt (draftReceiptFromSupplierOrder, idempotent per order, auto-matching lines to ingredients by SKU/name) so a delivery actually updates inventory in one review-and-receive step — closing the long-standing gap where received marketplace orders never touched stock. API: GET/POST /inventory/receiving + [GET/PATCH/DELETE /inventory/receiving/[receiptId]](src/app/api/tenant/[tenantId]/inventory/receiving/[receiptId]/route.ts). Hardened after an adversarial review: commits are idempotent by construction (a receipt whose lines already produced movements never stocks twice), commit validates mapped ingredients still exist (409 instead of silently under-stocking), the skipped-line count is surfaced (banner + audit), the optimistic draft-delete now reverts + reports on failure, and the soft-delete route gained the feature gate. Next phases: barcode/QR scan-to-receive, batch & expiry, shipment QR, discrepancy/supplier-performance, AI smart receiving.
- AI platform — scheduled daily briefings, email/SMS delivery, and 7/30/90-day forecasting. The AI Executive Briefing now generates and delivers itself: a new GET /api/cron/daily-briefings (CRONSECRET-protected) sweeps every AI-enabled tenant, generates the day's briefing if missing (no wasted AI spend — idempotent per day), and **delivers** it to owners who opted in. Generation is factored into a shared [service](src/lib/ai/briefing/service.ts) used by both the cron and the on-demand route (no drift). **Delivery** ([delivery.ts](src/lib/ai/briefing/delivery.ts)) sends a polished HTML+text [briefing email](src/lib/email/briefing-email.ts) to the owner and a short **SMS** summary via the tenant's text settings — opt-in per channel from the **AI Briefings** page (new email/SMS toggles), persisted on `TenantDB.briefingDelivery` ([briefings PATCH](src/app/api/tenant/[tenantId]/briefings/route.ts)). **Forecasting:** the [snapshot](src/lib/ai/briefing/snapshot.ts) now projects **next 7 / 30 / 90 days** of revenue & orders (trailing-average + 7-day momentum), shown as a Forecast card in-app and in the email, and stored on each briefing (`Briefing.forecast`). Next: supplier / agency / sales-agent briefings._
- Stripe Connect for real B2B supplier payouts (test mode). Suppliers can now connect a Stripe Express account from their dashboard (Integrations → Payouts via StripeConnectCard): [GET/POST /api/supplier/[supplierId]/connect](src/app/api/supplier/[supplierId]/connect/route.ts) creates the account, returns a hosted onboarding link, and refreshes the mapped status (
pending/active/restricted/rejected) from Stripe (connect.ts). On delivery, the order engine settles the payout (payout-engine.ts): when Stripe is configured, the supplier isactive, and the order was paid by a real charge, it creates a Stripe Transfer ofsupplierNetto the connected account and keeps the 1%+1% commission; otherwise it records the payout assimulated(dev / no real funds yet) orpending(onboarding incomplete) — never blocking delivery. NewSupplierOrder.payout(status/amount/transferId), surfaced as a payout chip on the supplier orders list. Note: the restaurant side of B2B is still dev-confirmed, so transfers stay simulated until real B2B charges are wired — the transfer path itself is live.
- Quick Links phase 2 — folders, drag-and-drop reorder, and a collapsible sidebar. The Quick Links page/dashboard/quick-links/page.tsx) now supports custom groups (folders) — create / rename / delete groups and file any bookmark into one via the per-card folder menu (deleting a group returns its links to Ungrouped, never deletes them). Each section (every group + the ungrouped list) is independently drag-and-drop reorderable via
@dnd-kit(wrapped inClientOnlyper ADR-002 to avoid the React-19 hydration counter bug). Groups are per-user-per-restaurant, stored onTenantDB.quickLinkGroupswith a new route-guarded groups API (create / rename / reorder / delete);QuickLinkgained an optionalgroupId, and the main Quick LinksPATCHnow also moves a link between sections. Separately, the desktop sidebar (SidebarNavigation.tsx) gained a collapse toggle → a slim icon rail (preference persisted inlocalStorage); collapsed group icons expand the sidebar straight into that group.
- Trial → paid-Basic conversion cron. New GET /api/cron/base-billing sweeps subscriptions and converts an ended 14-day all-access trial (
trialing+endsAt< now →incomplete); the dashboard layout already gatesincompleteto/register/payment, so trial access is revoked and the 199 SEK/mo Basic payment is required once the trial ends.CRON_SECRET-protected, mirroring the add-on billing cron.
Changed
- Reseeded the approved SEK add-on price card. registry.ts now carries the approved SEK figures (clean 9 / 19 / 29 / 39 / 49 / 99 monthly tiers) instead of the legacy USD numbers, and the price comments read SEK. Suites recompute their savings off these registry prices, so the marketplace and bundle math follow automatically — resolving the earlier $/SEK inconsistency.
Added (earlier)
- AI Executive Briefings — daily business intelligence (MVP). A new AI Briefings page (under Analytics & AI) gives restaurant owners a daily executive briefing — intelligence, not a raw report: an executive summary, KPI grid (revenue/orders/AOV with week-over-week deltas), trends, needs-attention alerts (low stock, revenue drops, unanswered reviews), recommended actions (prioritised), and opportunities. A server-side snapshot builder computes the KPIs from real data (orders, inventory, reviews, customers, offers); the generator turns it into the briefing via Claude (Haiku) when an AI key is configured and the tenant is within budget, and falls back to a genuinely useful rule-based briefing otherwise (dev / over-budget) — so the feature always works. Gated behind the AI add-on + the per-tenant AI budget (tokenomics), metered on each generation. Briefings are stored per-day on
TenantDB.briefings(API: GET history + budget, POST generate). Next: scheduled daily auto-generation + email/WhatsApp/SMS delivery, supplier/agency/sales-agent briefings, and 7/30/90-day forecasting.
- Quick Links — bookmark shortcuts for the owner portal (MVP). A new Quick Links nav item (under Overview) plus a ⭐ in the top bar that bookmarks the current page on any dashboard screen. The Quick Links page/dashboard/quick-links/page.tsx) shows Pinned Favourites, your links (pin / remove / go), search across your links + every page, and one-click add from the full page catalog. Bookmarks are per-user-per-restaurant, stored in
TenantDB.quickLinksvia a route-validated API (add / remove / pin / reorder). The dashboard sidebar was refactored onto a shared nav catalog (nav-catalog.ts) — one source of truth for ~45 pages, used by both the sidebar and the bookmark system (no drift). Next phases: custom groups + drag-and-drop, recommended/most-visited (AI), and the desktop sidebar collapse toggle.
- Modular billing — module marketplace (on/off), Suites, Stripe test checkout, and B2B commission. Turns the existing feature-registry/add-on engine into a self-serve marketplace (dashboard/billing/dashboard/billing/page.tsx)):
- Pay-on-activation, on/off model. Turning a module on charges a 30-day paid period (no per-module trial). Within the period the owner can pause/resume freely (off = hide access, no refund, clock keeps running; on = free) via
PATCH /billing/addons; at period end the cron renews (if on) or lapses (if paused). NewpauseAddon/resumeAddon+ renew/lapse sweep in subscription.ts;paused/stripeSessionIdadded toAddonSubscription. - 14-day all-access trial. While the base subscription istrialing, the resolver unlocks every purchasable module (resolver.ts); signup now creates the trial sub astrialing(notactive) so the gate actually fires. - Suites (suites.ts) — Growth / Delivery / Operations bundles priced as a discount off the members' à-la-carte sum. Activating a suite is one subscription that grants all members (fan-out grants viabundledFeatureIds); pause/resume/cancel apply to the bundle. Marketplace shows suite cards with savings; suite-covered modules are hidden from the à-la-carte list. - Stripe (test mode). Activation redirects to hosted Checkout (stripe.ts); the return path verifiespayment_status==='paid'+ session↔tenant/feature metadata and is idempotent on the session id. Dev-sim fallback for local; production refuses to free-activate without a real key. - B2B supplier marketplace commission — 1% restaurant + 1% supplier (commission.ts). Recorded on every supplier order (order-engine.ts):restaurantPays = total + 1%,supplierNet = total − 1%, platform keeps 2%; rates are super-admin-configurable (clamped 0–100%). Shown in the restaurant + admin supplier-order views. Charge/transfer execute when Stripe Connect onboarding lands (Phase C-2). - Hardened after a 33-agent adversarial review: blocked suite⇄individual overlap (no double-billing / no over-revoke of separately-paid modules), made Checkout confirmation idempotent (replay/concurrency safe), fixed the trial-gating status mismatch, and closed a production free-activation path. - Strategy + decisions recorded in PRICING_STRATEGY.md.- AI: super-admin–managed API key + per-restaurant tokenomics
- System-wide API key, managed from the admin UI (/admin/dashboard/ai): the Anthropic key can now be set/replaced/removed by a super-admin and is stored AES-256-GCM encrypted (AISettings.apiKeyEncrypted); only the last 4 chars + a
hasApiKey/aiLiveflag are ever returned. Key precedence is admin DB key → `ANTHROPIC_API_KEY` env, resolved per-request so changes take effect with no restart. All AI routes refactored to use the shared resolveAIKey. - Per-restaurant monthly AI budget (USD) — default $1/restaurant/month, set by the admin. Token usage is metered per tenant per month (recordAIUsage); cost is estimated from per-model rates (pricing.ts). Metering wired into all four AI routes (menu extract, chat, email, social), including capturing usage from the streaming chat. - Upsell when exhausted (per your choice): the menu photo import hard-blocks with 402 `ai_budget_exceeded` and the import modal shows SEK-priced top-up packages (ai-topup); buying one (dev-simulated, mirroring signup-payment; Stripe one-time checkout in prod) grants USD allowance for the month and retries. Auxiliary AI tools (chat/email/social) degrade gracefully to their existing mock/template output when over budget (no cost, no hard block). - New [POST /api/tenant/[tenantId]/billing/ai-topup](src/app/api/tenant/[tenantId]/billing/ai-topup/route.ts) (GET packages + budget status; POST buy). Budget tracked in TenantDB.aiUsage keyed byYYYY-MM. - Verified in dev: encrypted key round-trip (never leaked), 402 gate + reset date, upsell packages, graceful restore. 0 TypeScript errors.- AI menu import — snap or upload a photo, auto-build the menu (dashboard/menu/dashboard/menu/page.tsx))
- New "Import from photo" action on the menu page. Restaurant owners can take photos on mobile (
capture="environment") or upload up to 6 images (multi-page menus). Images are downscaled client-side (~1568px JPEG) before upload. - New [POST /api/tenant/[tenantId]/menu/ai-extract](src/app/api/tenant/[tenantId]/menu/ai-extract/route.ts): Claude vision (claude-sonnet-4-6) with forced tool-use returns structured{ categories: [{ name, items: [{ name, description, price, ingredients }] }] }. Output is normalised/clamped server-side. DEV MODE (noANTHROPIC_API_KEY) returns a realistic sample so the flow is fully testable offline — mirrors the existing/airoute pattern. - New [POST /api/tenant/[tenantId]/menu/ai-import](src/app/api/tenant/[tenantId]/menu/ai-import/route.ts): bulk-creates the reviewed menu in a single atomic DB write — dedupes categories by name (case-insensitive) against existing ones, appends items with correct ordering. Defensive caps (50 categories / 300 items). Leaves dietary tags & allergens empty (edited manually later, per spec). - New MenuPhotoImportModal: camera/upload → cost-estimate confirm step → AI extract → editable review grid (all items pre-selected; fix names/prices/descriptions/ingredients, deselect, remove) → one click adds to the menu. Mobile-friendly full-screen modal. - Pre-flight cost estimate: before extraction runs, a confirm step shows an estimated AI cost range for the selected photos (computed from the shared per-model rates) and whether it's metered against the monthly allowance (live) or free (sample mode). The top-up GET now also returnsaiLive. - Menu card fix: product cards gave the price its own protected lane (shrink-0+gap+ namemin-w-0 flex-1 truncate) so long dish names no longer collide with the price. (menu/page.tsx/dashboard/menu/page.tsx)) - Category management fix: the decorative three-dots on each category (no handler) is replaced with working Rename + Delete hover actions wired to the existingPATCH/DELETEendpoints. Delete cascades to the category's items with a count-aware confirm and re-selects another category. (CategoryList.tsx) - Multi-currency by country (super-admin managed) — restaurant owners + Bazar. Each restaurant now displays prices in its own currency (no conversion). A super-admin Currency panel (admin/dashboard/settings) manages the supported currencies, the country→currency map, and the platform default (persisted inPlatformSettings); a restaurant's currency resolves as owner override → its country's mapped currency → platform default → SEK (resolveCurrency). Owners pick their country + currency in Settings → General (choosing a country auto-fills the matching currency, overridable; new TenantSettings.currency/countryCode). New currency library (currency.ts) with an 18-currency registry +formatMoney()(locale-correct:199,00 kr/199,00 €/£199.00/$199.00);formatSEKkept as a back-compat wrapper. Display wired into the owner dashboard menu and the Bazar restaurant page (which previously hard-coded$— now shows each restaurant's real currency via a page-level currency context). Payment-gateway charge currency is a deliberate follow-up; this ships the display + configuration. - Move items between categories (single or bulk) + empty-category highlight. Product cards now have a selection checkbox (plus a header "Select all"); selecting one or more items shows a "Move N to [category]" bar that re-homes them in one atomic write. Newmove: { ids, categoryId }op on PATCH /menu/products (validates the target, appends to its order); the UI updates optimistically and reloads on error. The category sidebar now shows a per-category item count, and empty categories are flagged red (red left-accent, red name, "empty" badge) so owners can spot a category that needs items or removal. (menu/page.tsx/dashboard/menu/page.tsx), CategoryList.tsx) - Import — Phase A (smarter extraction + PDF): the extraction prompt now treats menus that use options/sections/set-meals instead of named categories as categories, and keeps near-duplicate items distinct (different size/portion/variant/price → separate items with the distinction baked into the name; only true cross-photo duplicates are merged). PDF menus are now accepted and sent to Claude as a native document block (no extra dependency); the picker acceptsimage/* + application/pdfand shows a PDF tile. - Import — Phase B (best-effort auto item-images): extraction now returns a per-itemimageBox(0–1000 normalized region + source attachment index); the modal crops each item's photo client-side from the source image (canvas), shows it in the review grid with an AUTO badge + one-click remove, and on import uploads kept crops via the media endpoint and stores the URL on the product. Missing/low-confidence → no image; PDF-sourced items aren't auto-cropped (sent as documents, by design).CreateProductSchema.imageUrlwas relaxed to accept the platform's own root-relative/uploads/...URLs. Hardened after an adversarial review: unparseable attachments are rejected sosourceindices stay aligned;imageBoxis bounds-checked (reject out-of-range / overflow / bad source — no silent garbage crops); a shared safeImageUrl now also rejects protocol-relative//hostand is applied to the product PATCH route (previously unvalidated); crops upload in parallel; retries reuse prior uploads. - Import — auto item-photos: full product image, no wrong matches, + lightbox. Review thumbnails are click-to-zoom (full-size lightbox + item name; closes on ×/backdrop/Esc) so owners can verify each AI match. The crop now captures the item's whole product photo as printed — including a drink/garnish/price baked into the graphic (no edge-trim, which previously clipped them). To stop wrong-item matches (e.g. one item getting a neighbour's photo + caption), the model also reportsimageBoxText+imageConfidence, and the server drops any box whose inside-text is a bled-in caption (anNN. Nameitem label or long text) or whose confidence is low — those items get no image instead of the wrong one. Geometry backstop loosened to only reject egregious boxes (>85% of page, >4:1 aspect, tiny). (ai-extract, MenuPhotoImportModal) - Import — Phase C (URL screenshot import): new [POST/GET /api/tenant/[tenantId]/menu/url-capture](src/app/api/tenant/[tenantId]/menu/url-capture/route.ts) drives the system's installed Edge/Chrome via Playwright'schanneloption — no bundled-browser download, no external/paid screenshot API. It runs as an in-memory background job the client polls (progress bar in the modal), screenshots the page (no crawling) and slices it into ≤6 viewport-height JPEGs that feed the normal extract flow. Security/robustness (post adversarial review): an SSRF guard blocks loopback/private/link-local hosts incl. IPv6 brackets, IPv4-mapped::ffff:, and trailing-dot forms; a request interceptor blocks subresources/redirects to private literals + a post-navigation final-URL recheck (DNS-rebinding to a private IP remains a documented residual); an overall 75s job deadline, explicit page/context/browser teardown, per-tenant concurrency cap, and TTL pruning on both POST and GET. (Caveat: the detached job needs a long-lived process — local/persistent server; a serverless deploy would need a real job queue.) - Import — two-stage server-side photo extraction (sharp). Reworked auto item-photos to mirror a proven reference pipeline: a separate vision pass per image locates each dish's printed photo and returns a fractional `[0,1]` bounding box (photo-detect.ts), which is then cropped server-side with `sharp` from the near-original bytes — no more client-canvas crops off a downscaled copy. Decoupling text extraction (Stage 1) from photo detection (Stage 2) and matching crops back to items by name is what stops a box from landing on the wrong dish or bleeding into a neighbour's caption. New image-ops.ts bakes EXIF rotation once, tiles very long/tall menus into overlapping full-res tiles (splitLongImage), fits a copy under Claude's ~3.6 MB vision cap (fitImageForVision, crops still taken from the original), and emits 480px web-JPEG crops (cropRegion). The modal now sends near-originals and consumes the server'simageDataUrldirectly (client-sidecropImageremoved). Added `sharp` 0.34.5. Hardened after an adversarial review (8 confirmed findings): untrusted images go through a pixel-limitedloadSharp(50 MPlimitInputPixels) to defuse decompression-bomb DoS; per-upload byte cap (~12 MB base64) before any decode; embedded base64 crops capped at 60 so the JSON response stays small; detector item-names deduped; and a warning is logged when all Stage-2 detection calls fail (text still returned, photos degraded gracefully). (ai-extract, MenuPhotoImportModal) Supersedes the earlier client-side `imageBox` crop approach. - Import — uniform white-square item thumbnails. Auto-cropped item photos are now composited onto a fixed 450×450 square canvas: the dish photo is fit-centred (aspect preserved, never stretched) and the remainder is filled white (cropRegion). This makes every tile a consistent size and turns any caption/neighbour bleed around a photo into white padding rather than stretched junk. Small detections keep their native resolution (withoutEnlargement) so they stay crisp instead of being upscaled-and-blurred. The detector prompt was tightened to stop the box at the picture edges — it must exclude the printed item-number/name line (e.g. "04. Kebabtallrik inkl. pommes & läsk") and any neighbouring dish, and omit an item rather than return a loose box ("a missing photo is better than a wrong one") (photo-detect.ts). A whole-frame detection (box ≥92% of the image in both dimensions) is now rejected as a page-level miss. Verified empirically (white padding + crisp small crops) and via an adversarial review (1 reported finding was a false positive from a degenerate square→square test; the white-pad is proven correct for non-square crops). - Import — 50 MB PDF support + system-side URL download (no AI for fetching). PDF/upload limit raised 12 MB → 50 MB (modal + theai-extractdata-URL cap → ~70 MB base64). Because a vision request can't exceed ~32 MB / 100 pages, large PDFs are now page-split server-side withpdf-lib(pdf-split.ts): each batch stays under the cap and is extracted independently then merged (reuses the per-unit extract+merge); a normal small PDF passes through untouched. Validated: a 10-page PDF splits into 3 valid batches summing to 10 pages. URL import now downloads the file directly — new url-fetch fetches the URL server-side (SSRF-guarded via the shared url-safety.ts, 50 MB streaming cap, redirect re-validation) and, when it's a PDF or image, hands the bytes straight to extraction — no browser, no AI tokens, no screenshot. Only a real HTML page falls back to the existing Playwright screenshot capture. The SSRF guard was extracted fromurl-captureinto a shared module so both paths share one hardened implementation. - Import — manual "choose the photo" control (owner crops from the original, zero AI tokens). Auto-cropping can't reliably separate a dish photo from its printed caption when they're fused in one menu cell (proven: bold colored captions are statistically indistinguishable from food in row stats — trimming the caption also clips the food). So every item in the review screen now has an always-present photo control: a crop button on an existing photo ("wrong crop? pick the area") and a "+ Photo" slot on items with none. It opens MenuItemCropPicker — a full-screen picker over the original uploaded/captured image(s) (multi-source selector, mouse + touch drag-to-select, "Whole image") that crops the chosen region client-side into a white-padded square and attaches it to the item. No server call, no AI tokens. PDFs are excluded as sources (can't render in<img>). This is the reliable fallback for any scenario the AI box gets wrong (caption bleed, wrong item, logo, missing photo) and the foundation for a future correction-memory layer that remembers these picks per menu so re-imports apply them automatically. - Import — accuracy engine ported from the reference pipeline (variants, verbatim prices, per-image merge). After a deep cross-repo study, the real quality gap was Stage-1 extraction, not photos. Ported the reference's richer extraction into extract-core.ts + ai-extract: thesave_menutool + prompt now capture price variants (multi-price items like "Pitabröd / Rulle / Tallrik" → one item with{label, price}variants instead of name-mangled duplicate rows), verbatim prices (preserved as printed), and category/menu notes + restaurant info; output cap raised 8000 → 16000 tokens with a truncation warning. Multiple uploads are now extracted independently and merged (mergeExtractedMenus, detail-scored dedup) instead of one attention-splitting/truncation-prone combined call — which also yields cleaner item names and therefore better photo name-matching. On import, variants map onto the existing `optionGroups` radio system (no new data model / migration):basePrice = cheapest variant, each optionpriceAdjustment = price − base, so they render and price correctly through the current editor, public menu, and cart. The review grid shows variants as chips + a "N sizes" badge. Locale-aware price parsing (parsePriceNumber) handles12,50,1.234,56,1,234.56,85:-, etc. Validated by adversarial review (variant→cart compatibility + auth/tokenomics dimensions clean; 3 price-parsing findings fixed). URL text-import + PDF page-split + note/info persistence land next. - Import — photo detection now runs on the WHOLE image (matches the reference pipeline). Comparing against themenu_generatorreference revealed our crops were worse because we ran photo detection on the text-extraction tiles with an over-constrained prompt. Realigned to the reference's proven shape: tiling is now used only for Stage 1 text reading; Stage 2 detects photos on the whole baked image per upload (newphotoSources) and crops from it, so the model sees the full layout + complete item list and attaches each photo to the correct item instead of guessing from a fragment. The detector prompt was reverted to the reference's minimal version (plus one light "one dish per box, don't grab the caption" clause). Stage 1 text extraction is unchanged. Validated by adversarial review — no regressions; remaining divergences from the reference are deliberate (white-square output, EXIF baked up-front which also fixes a latent rotated-crop misalignment the reference has). (ai-extract, photo-detect.ts)Fixed
- Large PDF import failed with "Invalid JSON body". A menu PDF downloaded from a URL (e.g. 16 MB → ~22 MB base64 body) was truncated by Next.js's default 10 MB request-body limit (applied because the app runs a proxy/middleware), so
req.json()threw. Fixed by settingexperimental.proxyClientMaxBodySize: '80mb'in next.config.ts (covers a 50 MB PDF's ~67 MB encoded body). Hardened alongside (from an adversarial review):MAX_DATA_URL_CHARSraised to 80 M so a true 50 MiB file (~70 M chars) isn't silently filtered; a `MAX_UNITS` fan-out cap (16) so a many-page PDF can't spawn dozens of Claude calls / overshoot the budget; bounded extraction concurrency (4 at a time); recursive re-split of any oversized PDF batch (uneven/heavy pages) in pdf-split.ts; the URL-download timeout now also covers body streaming (slow-drip servers can't hang the request); and the modal warns before sending an over-limit payload instead of letting it truncate.
- Website builder: editor preview and live site were out of sync (different renderers). Root cause: the builder editor renders blocks with the
builder/sections/*components (viaSectionRegistry), but the public site ([tenant/[tenantid]/page.tsx](src/app/tenant/[tenantid]/page.tsx)) re-rendered every block with its own hand-written JSX — so the two drifted (different markup, theming, defaults, field-mapping). Most visibly on the Hero: the live renderer mapped the button link (/order) as a second button's text, injected abusinessTypeeyebrow and abannerUrlbackground, hard-coded the CTA to/menu, and used different colors than the editor. Fixed by rendering the live hero through the same `HeroSection` component the editor uses (single source of truth) — content fields are resolved (legacy names + i18n → strings) and passed in, the CTA now uses the owner's actual button link (basePath-resolved), and there's a single correct button.HeroSection's empty-image helper is now builder-only.
- Builder ↔ live: presentational blocks unified through `SectionRegistry`. The live site now renders heading, paragraph, image-full, cta-banner, stats, video, faq, announcement through the same registry components as the editor (via a content i18n-resolver), eliminating per-block drift — and newsletter, team, and instagram-feed, which the live renderer didn't handle at all (they appeared in the editor but vanished on the published site), now render too. Data-bound blocks (featured, menu-preview, reviews, gallery, opening-hours, contact, maps) still use their data-aware live rendering — wiring those through the shared components with real data + currency is the remaining parity step. - Builder ↔ live: ALL remaining data-bound blocks unified (testimonials, special offers, gallery, opening hours, contact info, map, reservation, delivery). Completes the single-source-of-truth migration: the public site no longer hand-writes JSX for these eight blocks — it renders the *same `builder/sections/
components the editor uses**, fed with real data resolved server-side, so the builder preview now mirrors the live site for every block. Each component reproduces the live markup (theme-token styled) and accepts real-data props: **Testimonials** (approved reviews), **Special Offers** (active offers +formatDiscountlabel + per-card menu link), **Gallery** (menu photos), **Opening Hours** (weekly hours + "today" highlight), **Contact Info** (phone/email/address/socials resolved as block-content → restaurant settings, preserving the#contact/#aboutanchors and the working contact form), **Google Maps** (the configured map link), **Reservation** (the working booking form,#bookanchor), **Delivery** (enabled delivery platforms + showcase photos, honoring owner-configured images then falling back to menu photos). The builder editor fetches/menu/products,/settings,/reviews,/offersand feeds the same data; interactive forms render as the real client islands on live and as non-interactive skeletons in the editor.DELIVERY_PLATFORMSmoved to a shared [delivery-platforms.ts](src/lib/delivery-platforms.ts) used by both sides. **Hardened after an adversarial review (3 confirmed customer-facing regressions fixed):** demo placeholders are now strictly **editor-only** (isPreview-gated) — an unconfigured restaurant can no longer leak a fake phone/email/address, fake opening hours, fake delivery platforms, or "img N" placeholder tiles to live customers; contact fields are type-guarded so a stray non-string value can't crash the page. - **Builder ↔ live: Featured Dishes + Menu Preview now show the real menu (data-bound unification).** Previously the editor rendered these two blocks as **skeleton placeholders / demo dishes with$0.00** while the live site showed the real menu — so owners couldn't see images or prices in the builder. Both [FeaturedSection](src/components/builder/sections/FeaturedSection.tsx) and [MenuPreviewSection](src/components/builder/sections/MenuPreviewSection.tsx) now accept realitems+currency+ an optionalmenuHref, and the **editor fetches the live menu + resolved currency** ([builder/page.tsx](src/app/(dashboard)/dashboard/builder/page.tsx)) so the preview matches the published site (real photos via<img>, locale-correct prices, badges). Empty-state is safe: the live site returnsnullwhen there's no data (no demo leak), the editor shows a neutral skeleton / representative demo so the layout is still designable. **Hardened after an adversarial review (parity gaps fixed):** the editor now **sorts menu items byorder** (matching the liveorderedlist) and **caps Featured at 3** (matching livepopularProducts); currency is resolved **server-side** via a new additiveresolvedCurrencyfield on [GET /settings](src/app/api/tenant/[tenantId]/settings/route.ts) (sameresolveCurrencychain the live page uses → no editor/live drift); the Featured "View Full Menu" CTA now renders in the editor too (as an inert span); theid="menu-preview"anchor was restored on the shared component; and the live page's **duplicategetMasterDB()` load was collapsed to one**.- "Visit site" opened an unreachable placeholder domain. The dashboard header built the link as
https://<customDomain>/, and the seed tenant'scustomDomainis the reserved-TLD placeholderluigis-pizzeria.example, so it errored; the subdomain fallback (<sub>.localhost:3001) also didn't resolve since there's no subdomain middleware. TopHeader.tsx now uses a custom domain only when it's actually resolvable (rejects.example/.test/.invalid/.localhost), and otherwise links to the reachable storefront route<origin>/bazar/restaurant/<subdomain>(the public page resolves a restaurant by its subdomain slug; works in dev and prod with no DNS/middleware). Verified the target returns 200.
- Concurrent tenant-DB writes could corrupt the JSON file ("Tenant DB Read Error"). writeJsonAtomic used a shared
${file}.tmpfor every write, so two concurrent writes to the same tenant (e.g. the menu photo-import uploading several item-image crops at once, or background polling writing read-state during another write) interleaved into a corrupt file. Fix: unique temp filename per write + a short rename retry for WindowsEPERM/EBUSYcontention (writes now serialize instead of failing) — eliminates the corruption class platform-wide; proven with a 25-way concurrent-write test (0 failures, 0 corruption, 0 orphan temps). The AI menu import now also uploads crops sequentially. A corruptedluigi-pizza.jsonfrom this bug was recovered losslessly from its valid prefix (corrupt copy preserved as.bak).
- Both routes guarded by
requireTenantAccess. Verified end-to-end in dev (extract 200, import 201 with correct fields, empty→400, cascade-cleanup restores baseline). 0 TypeScript errors.Changed
- Super-admin shell — light/dark theme refresh + access reference
- Removed the third "Medium" theme mode. The admin theme switcher is now a clean Light / Dark toggle. Any persisted
admin-theme = "medium"is migrated todarkon load. (AdminThemeProvider.tsx, AdminTopBar.tsx) - Refreshed both palettes for a cohesive look, applied as scoped[data-admin-theme]overrides (no per-page rewrites): Dark uses a refined midnight-slate (panels lift off the shell for depth); Light uses crisp white cards on a soft slate canvas, with a border + soft shadow so cards no longer dissolve into the background. Accent system: orange = brand (logo, active nav, highlights), indigo = interactive controls (theme toggle). - Light-mode polish: solid gray surfaces render as elevated white cards while/40–/50alpha variants stay subtle insets; dark-mode colored chips/badges (bg-blue-900/40,text-green-400, …) are remapped to light tints with readable, darker text across blue/green/emerald/amber/yellow/orange/red/purple/indigo; stronger borders and slightly darker muted text. - Added a "Super Admin Access" card to the overview page with the local sign-in URL, email, and password, each with copy-to-clipboard. Local-development only — gated onprocess.env.NODE_ENV !== "production"so it is tree-shaken out of production builds. (admin/dashboard/page.tsx) - Verified in dev:/admin/login200, super-admin login succeeds,/admin/dashboardrenders 200 with the themed shell. 0 TypeScript errors.- Paid signup — direct Basic subscription (199 SEK/month), free trial disabled (reversible)
- New restaurants now subscribe to Basic at 199 SEK/month at signup instead of getting a free trial. The free trial is disabled by default but re-enableable via a flag — set
platformSettings.freeTrialEnabled = trueto restore the previous 14-day trial behavior. - Schema (additive):SubscriptionRecord.planIdgains'basic-monthly';statusgains'incomplete'(account created, first payment not yet collected);PlatformSettings.freeTrialEnabled?: boolean(default false). - New src/lib/billing/signup-plan.ts:BASIC_MONTHLY_PLAN(199 SEK/mo, 30-day period) +isFreeTrialEnabled(). - Registration (/api/auth/register): when trials are off, creates the subscription asincompleteonbasic-monthly(no trial window) and returnsrequiresPayment: true. The tenant staysisActive: trueso the owner can log in to pay. WhenfreeTrialEnabledis on, the old 14-daybasic-annualtrial path is used unchanged. - New POST /api/billing/signup-payment: collects the first charge. DEV simulates and activates immediately; PROD creates a Stripe Checkout Session (subscription mode, 199 SEK/mo) and the webhook activates on completion. Idempotent. - Dashboard gate (dashboard/layout.tsx/dashboard/layout.tsx)): a tenant-owner whose subscription isincompleteis redirected to/register/paymentuntil the subscription is active. - Stripe webhook (stripe/webhook): subscription period is now plan-aware (basic-monthly→ +1 month, annual → +1 year) and sets the paid amount when activating a pending subscription. - UI: register page routes to the new `/register/payment` step (Basic 199 kr/mo summary → "Pay 199 kr & activate"); copy updated from "free trial" to the paid plan. - Verified end-to-end in dev: signup →incomplete→ dashboard gated (307 → payment) → pay (simulated) →active/199 SEK/30-day → dashboard accessible; flippingfreeTrialEnabledreverts to the trial path. 0 TypeScript errors.- Standardized the platform on a single currency: SEK (multi-currency support to be layered on later)
- Motivation: the codebase mixed USD and SEK — feature-registry/plan prices and many display formatters defaulted to USD while monetization/add-ons/escrow were already SEK, and customer storefronts hard-coded a
$symbol. - New src/lib/currency.ts: single source of truth —PLATFORM_CURRENCY = 'SEK',formatSEK()/formatSEKWhole()usingIntl.NumberFormat('sv-SE', { currency: 'SEK' })(renders99,00 kr). - Customer storefront: every hard-coded$price now routes throughformatSEK— home (page.tsx),MenuClient,CartDrawer,CartButton,CheckoutForm,ProductCustomizeModal,ReservationWizard,StripeCardForm,PayPalCheckout, and the customeraccountpage. - Dashboards: admin + owner formatters switched fromen-US/USD tosv-SE/SEK (analytics, customers, scheduling, home, subscriptions, gift-cards, table-bills, reports, billing invoices). - Code defaults flipped USD → SEK: subscription PLANS (billing/route.ts), plan builders (admin + owner), food-bazar default settings (db.ts), and per-feature fallbacks (table-pay, gift cards, table bills, profit-margin report, Stripe webhook, admin plans API). - Payments: Stripe PaymentIntentcurrency: 'usd' → 'sek'(amount math unchanged — SEK is also a 100-minor-unit currency); PayPal SDK + ordercurrency_codeUSD → SEK. - Integrations / SEO: Schema.orgcurrenciesAccepted+ menupriceCurrency, and Google Business sync currency, now default to SEK. - Seed generators: designer/agent commission builders + restaurant/builder defaults emit SEK;burger-flashscenario set to SEK. - Data migration: new idempotent scripts/migrate-currency-to-sek.js flipped 21 storedcurrency/currency_codeUSD → SEKacrossdatabases/(designers 9, sales-agents 9, master 1, food-bazar 1, flash-burgers 1). Re-run is a no-op. - Intentionally NOT changed: the B2B Supplier Marketplace stays multi-currency (GBP/EUR per international supplier —databases/suppliers.jsonexcluded from the migration); thecurrency: 'USD' | 'SEK' | 'EUR'schema union is kept so the data model is already forward-compatible for real multi-currency later. Existing numeric prices were treated as SEK values (no FX conversion). - 0 TypeScript errors; storefront verified renderingkrwith no$NN.NNleaks.Added
- Phase 77: Fix Phase 76's `enabled: false` default that hid tenants from the bazar
- Reported by the owner — immediately after Phase 76 made the Launch campaign flow succeed, the Sponsored section disappeared from the bazar landing entirely. - Root cause: Phase 76's per-field normalisation used
enabled: rawBazarSettings?.enabled ?? false. On a tenant whosebazarSettingswas{}(noenabledfield), the normalised object gotenabled: false. The handler then wrote that back to the tenant DB during the campaign save. Every reading endpoint (/api/bazar/menu,/api/bazar/search,/api/bazar/sponsored) hasif (bazarSettings.enabled === false) continue;— so the tenant vanished from every bazar surface as soon as the first sponsored item was launched. - Why this regressed only after Phase 76 — the previous bug threw before saving, so the bad default never persisted. Phase 76 fixed the throw but didn't fix the wrong default. - Reading endpoints all use opt-out semantics — they defaultenabledtotruewhen missing, so an approved tenant who never explicitly opts out stays visible. The Phase 76 normalisation broke that convention. - Fix ([src/app/api/tenant/\[tenantId\]/food-bazar/sponsored/route.ts](src/app/api/tenant/[tenantId]/food-bazar/sponsored/route.ts)): - Changedenabled: rawBazarSettings?.enabled ?? false→enabled: rawBazarSettings?.enabled ?? trueto match the rest of the bazar reads. - All other fields keep their schema-aligned defaults (items: [],customBadges: [], etc.) — onlyenabledwas wrong. - Repaired `luigi-pizza.json` — flippedbazarSettings.enabledfromfalse(Phase 76 corruption) back totrue. Other seeded tenant DBs untouched (theirbazarSettingsfield was already{}and never went through the Phase 76 save path). - No other endpoints affected — every read usesbazarSettings.enabled === falseas the strict-false test, so anenabled: true(or missing) field always passes.- Phase 76: Fix "Launch campaign — Network error" + unmask future server errors
- Reported by the owner — clicking Launch campaign after configuring a bazar promotion returned a generic "Network error. Please try again." message regardless of what actually went wrong. - Root cause ([src/app/api/tenant/\[tenantId\]/food-bazar/sponsored/route.ts](src/app/api/tenant/[tenantId]/food-bazar/sponsored/route.ts)): - The POST handler initialised
bazarSettingswith((tdb).bazarSettings ?? { ...defaults }). - `??` only triggers on `null`/`undefined`, not on the empty object{}— which several seeded tenant DBs contained (luigi-pizza.json:"bazarSettings": {}). - SobazarSettingswas the literal{}, thenbazarSettings.items.findIndex(...)threwTypeError: Cannot read properties of undefined (reading 'findIndex'). - Next dev returned an HTML 500 page; the client'sres.json()threw on the non-JSON body; the catch block surfaced its generic "Network error" message. - Server fix: Replaced the??initialisation with a per-field normalisation: ``ts const rawBazarSettings = (tdb).bazarSettings; const bazarSettings = { enabled: rawBazarSettings?.enabled ?? false, cuisineTypes: rawBazarSettings?.cuisineTypes ?? [], items: rawBazarSettings?.items ?? [], // ...etc, every field defaulted individually };`Now an empty object, a partially-populated object, and a missing field all converge on the same shape.bazarSettings.itemsis always a valid array. - **Three sibling routes audited** (menu, search, public sponsored). All three already use(bazarSettings.items ?? [])per-access — bug was isolated to this one file. - **Client fix** ([src/app/(dashboard)/dashboard/food-bazar/page.tsx](src/app/(dashboard)/dashboard/food-bazar/page.tsx)): - **Defensive response parsing** — readres.text()first, then try-parse. A non-JSON body (HTML 500) no longer throws; we fall through and showFailed to launch campaign (HTTP 500)instead of masking with "Network error". - **Real network errors** still hit the catch, but now include the underlyingerr.messageso future debugging starts with a real signal rather than a generic string. - **Why this happened** — the??pattern was *defensive in intent* (handle missing settings) but assumed missing meantundefined. The seed scripts initialised tenants withbazarSettings: {}` as a placeholder, which silently bypassed every fallback. Per-field normalisation is the only shape that survives all three states (missing / empty / partial).- Phase 75: "Closed now" badge on bazar aggregator cards
- Follow-up to Phase 73 — Phase 73 demoted closed restaurants to the bottom of the cuisine / city / combo aggregator lists, but the cards themselves carried no visual indicator. Users scrolling past well-rated open restaurants and landing on closed ones at the bottom had no signal why the order was that way. - Adds a small gray "Closed now" pill to the header row of each aggregator card when
listing.isOpenNow === false. Sits next to the rating chip (so the visual weight is similar — small, paired). Card itself getsopacity-70so closed entries read as visually subordinate without disappearing. - Files (cuisine/bazar/cuisine/[cuisine]/page.tsx), city/bazar/city/[city]/page.tsx), combo/bazar/cuisine/[cuisine]/in/[city]/page.tsx)): - Same three-line JSX addition per file:{listing.isOpenNow === false && (...)}with the Closed pill, wrapped in a flex container with the existing rating chip. - Card-levelopacity-70conditional className mirrors the FoodCard pattern from the bazar landing. - Why a pill instead of dimming alone — opacity tells the eye "less important" but doesn't say why. The text "Closed now" answers the implicit question. - Why not also disable navigation — restaurant detail pages render fine even when closed (menu visible, order CTAs disabled by the detail page's own logic). Users may still want to browse the menu of a closed restaurant for tomorrow. Demotion + label is the right level of friction.- Phase 74: Bazar landing failsafe-timeout bump 10 s → 45 s (fix "frozen, no items" UX)
- Reported by the owner — bazar landing showed "no items" / "frozen" state on first hit. Audit traced it to a dev-only false trip of the page's 10 s failsafe timeout, not a real outage. - Diagnosis: -
next build+tsc --noEmitclean — no code-level bug. - Fresh dev server probe ofGET /api/bazar/menu?per_page=5&page=1: - First call: 200 in 60 s (next.js: 15.5 s, proxy.ts: 14.0 s, application-code: 30.4 s). - Second call: 200 in 85 s (next.js: 15.2 s, proxy.ts: 21.8 s, application-code: 48 s). - The API was returning the correct data; the landing's hard 10 ssetTimeoutwas flipping the page to theofflinestate before the response arrived. - Underlying slowness is Turbopack dev compile + per-request module-evaluation cost (Next 16 / Turbopack first-hit). The bazar landing's import graph is wide enough that a cold compile blows past 10 s easily. Production builds aren't affected — the production bundle is pre-compiled. - Fix (src/app/(bazar)/bazar/page.tsx/bazar/page.tsx)): - Bumped the bazar landing's failsafe timeout from 10 s to 45 s. - The timeout's original purpose (catch a genuinely stuck mobile fetch and surface the "Try again" screen) still works — 45 s is still well below any reasonable user patience window — but it no longer false-trips on Turbopack cold-compile. - No API change —/api/bazar/menuwas returning correct results; the issue was purely client-side timing. - Why not just remove the timeout — a stuck mobile connection on a flaky network really does need the fallback screen so the user knows to retry. The "Try again" button is a real piece of UX. Removing the timeout would leave them on the skeleton forever. - Why not optimise the menu API instead — the API itself isn't slow; cold Turbopack compile is. Production doesn't pay that cost. Adding caching or other optimisations to dodge a dev-only symptom would add complexity for no real-world gain.- Phase 73: Open-first sort extended to cuisine + city + combo aggregator pages
- Sweep continued from Phase 70/71 — three more bazar SEO pages had the closed-restaurants-interleaved problem:
/bazar/cuisine/[slug],/bazar/city/[slug], and/bazar/cuisine/[cuisine]/in/[city]. All three sorted byrating desc → name ascwith no open/closed signal. - Files (cuisine/bazar/cuisine/[cuisine]/page.tsx), city/bazar/city/[city]/page.tsx), combo/bazar/cuisine/[cuisine]/in/[city]/page.tsx)): -ResolvedListinggrowsisOpenNow?: boolean(defaults totruewhen hours are missing — a misconfigured tenant isn't quietly demoted). -canAcceptOrders+WeekHours+SpecialDayimported per page (same modules the menu / sponsored / recommendations routes use). -openNowcomputed in the sametdbfetch that already pulled reviews for rating — no new fan-out reads per listing. - Sort upgraded fromrating → nametoopen → rating → name. V8 stable sort preserves rating-then-name as the within-group tiebreaker. - SEO consideration documented — the comment on the cuisine page previously read "stable for SEO". Phase 73 acknowledges that the displayed order now varies through the day as opens/closes flip, but the canonical URL,<title>/<meta description>, and theItemListJSON-LD stay byte-stable across hour boundaries. Search engines see the same listing set; only the rendered card order shifts. - Coverage — every bazar surface that shows a list of restaurants or food items now sorts open-first: - Sponsored rail / Featured rail / Browse All Food (Phase 70 — via/api/bazar/menu+/api/bazar/sponsored). - Recommendations rail (Phase 71 — both signal + cold-start modes). - Restaurants directory list (Phase 71 — via/api/bazar/restaurants). - Cuisine aggregator, city aggregator, cuisine × city combo aggregator (Phase 73).- Phase 72: Bazar landing — Featured rail + fix click-through on items with options
- Reported by the owner — two adjustments after Phase 70/71: (1) the "Featured" section between Sponsored and Browse All wasn't appearing at all, and (2) clicking on a food card with customisation options did nothing (no navigation, no add to cart). - Featured section ([src/app/(bazar)/bazar/page.tsx](src/app/(bazar)/bazar/page.tsx)): - Filter changed from
i.offerPrice != null && i.offerPrice < i.pricetoi.featured === true. The previous filter required a discounted price; a bazar with no active discounts (which is the steady state when no operator is running a sale) silently hid the entire section. The new filter surfaces whatever items operators have explicitly flagged as featured. - Section relocated from between Search and Browse All to between Sponsored and Search — matching the owner's specified rhythm of Sponsored → Featured → Show All. - Heading and copy reworded from "Today's Best Offers" / "Save big — these deals won't last long" / "Limited Time" to "Featured" / "Hand-picked highlights from restaurants on the bazar" / "Curated picks". Same visual treatment otherwise; gradient softened fromred-500 → orange-500toamber-500 → orange-500so it doesn't read as a sale flag. - "See all" button retained but now points at?featured=true, which surfaces the same items the rail shows. - Filtered Browse-All heading updated from🔥 Best Offersto⭐ Featuredto match the new naming. - Item click navigation fix: - The bug — the FoodCard quick-add button'sonClickcallede.preventDefault()before theif (item.hasOptions) returnguard. For items with options the handler ranpreventDefault()(killing the parent<Link>'s navigation) and then returned (skipping theaddItemcall). Result: clicking the "+" on a customisable item did absolutely nothing. The comment in the code claimed "The parent<Link>still navigates to the detail page on its own unless we eat the click — let it through" but that was contradicted by the preceding line. - The fix — invert the order:if (item.hasOptions) return;first (nopreventDefault→ the parent Link navigates normally to the picker on the detail page);preventDefault+addItemonly on the no-options path. - Other paths unchanged — items without options still quick-add silently; closed items still render the "Closed" pill instead of a button. - Verification —tsc --noEmitclean,next buildgreen. Both rail visibility and item click-through covered by the same single file change.- Phase 71: Open-first sort extended to recommendations + restaurants list
- Follow-up to Phase 70 — the same closed-restaurants-interleaved problem the owner flagged on the bazar landing's three rails also exists on adjacent surfaces. Phase 71 sweeps the same principle (open first; prior ranking is the tiebreaker) into the recommendation rail and the paginated restaurants directory. - `/api/bazar/recommendations` (src/app/api/bazar/recommendations/route.ts): - `Recommendation.isRestaurantOpen: boolean` added to the response shape so the sort doesn't need a second pass and any future "Closed" badge in the rail card can read the flag directly. - Both modes covered: - Signal mode (user has favourites / cuisine prefs) — open-first becomes the first sort key; score → rating → name preserved as the existing tiebreakers within each open/closed group. - Cold-start mode (no signals — "Popular this week") — same change: open-first first, then existing recent-order count / rating / name chain. - DB read folded —
openNowis computed from the sametdbfetch that already pulledreviewsfor rating. No new fan-out reads per listing. - Defensive default —isRestaurantOpen = truewhentdbcan't be read oropeningHoursis missing. A misconfigured tenant isn't quietly demoted off the rail. - `/api/bazar/restaurants` (src/app/api/bazar/restaurants/route.ts): - One-line stable sort on thevalidarray before the pagination.slice— identical pattern to the Phase 70 fix on/api/bazar/menu. - Same V8 stable-sort guarantee preserves whatever prior ranking the directory had (featured-first by default). - The endpoint already computedopenNowper listing in Phase 7a; Phase 71 just uses that existing field. - Now sorted consistently across every bazar surface: Sponsored rail (Phase 70), Today's Best Offers (Phase 70 inherited viaitems.filter), Browse All Food (Phase 70), Recommendations rail (Phase 71), Restaurants directory (Phase 71). A customer never encounters a closed restaurant ahead of an open one without intentionally filtering for it.- Phase 70: Closed restaurants demoted in all bazar landing sections
- Reported by the owner — in the bazar landing's three rails (Sponsored, Today's Best Offers / Featured, Browse All Food), closed restaurants were interleaved with open ones in every section. A customer scrolling the front page kept hitting items they couldn't order without first going elsewhere. - Section order is already correct as the owner specified (Sponsored → Featured → Show All) — the issue was strictly the within-section sort. - Three sections, two APIs, one principle — open restaurants always rank above closed ones; the previous ordering acts as the tiebreaker within each group. - Why server-side, not client-side — the Browse All grid is paginated (24 items / page). Sorting on the client would only re-order the current page, so closed items from page 1 would still displace open items into page 2. The fix has to happen before the
.slice(start, start + perPage)call. - `/api/bazar/menu` (src/app/api/bazar/menu/route.ts): - One-line stable sort after the results loop and before the pagination math:(b.isRestaurantOpen === false ? 0 : 1) - (a.isRestaurantOpen === false ? 0 : 1). - Closed → 0, open / unknown → 1, so open sorts first. - Covers both the Browse All Food rail and the derived Today's Best Offers rail (offerItemsis computed client-side asitems.filter(i => i.offerPrice != null), so it inherits the same order). - `/api/bazar/sponsored` (src/app/api/bazar/sponsored/route.ts): - New per-candidateisRestaurantOpenfield computed viacanAcceptOrders(openingHours, specialDays)— the same helper the menu API uses, so the open/closed signal is consistent across surfaces. - Sort upgraded from "bid + relevance score only" to "open first, then by score". A closed restaurant with a higher bid never displaces an open one in the rail; bid still wins among open restaurants and again among closed ones. - ImportscanAcceptOrdersand the relatedWeekHours/SpecialDaytypes — same shape as the menu route. - V8 sort is stable (Node 12+ / Chrome 70+ — both since 2019). The relative order among open items and among closed items is preserved within each group, so existing scoring (popularity, bid amount, geo) still drives the within-group order. - Defensive default — items with noisRestaurantOpenfield are treated as open. The field is set today on every code path, but this guards against a future listing surface forgetting to populate it.- Phase 69: Per-language SDK examples docs article
- Closes more of the Phase 3 docs deferred — "...per-language SDK examples (Node / Python / PHP)" of the original "Postman collection download, downloadable OpenAPI spec, additional webhook events, per-language SDK examples" checklist. Developers had to translate the cURL examples in api/orders by hand into their target language; Phase 69 ships paste-ready snippets for the four most-common stacks. - Article (src/lib/docs-center/articles.ts) — new
slug: 'sdk-examples'in theapicategory. - Scope — single endpoint (POST/api/public/{tenantId}/orders) shown four ways. Picking one canonical endpoint kept the article focused; pattern transfers immediately to any of the other public routes. - Languages covered: - cURL — the universal baseline; matches the syntax used elsewhere in the docs. - Node.js (fetch) — Node 18+ nativefetch, no dependencies. Async/await with explicit 429 handling readingRetry-After. - Python (requests) —requests 2.31, Python 3.10+. Usesraise_for_status()for the happy path and explicit 429 handling on top. - PHP (cURL) —ext-curl(shipped with every PHP install), no Composer dependency. ExplicitRuntimeExceptionon 429 / 4xx so the example is copy-paste safe into an existing app. - Each snippet sends the same payload — different idiom, identical body — so a developer comparing two languages can spot the structural identity immediately. - FAQ answers the three obvious follow-ups: where the tenant ID comes from (subdomain), why we don't ship language-native SDKs (openapi-generator-cliis downstream), and whether there's a sandbox (no — demo tenants reject orders with 403DEMO_MODE_ACTIVE). - Next steps section points at idempotency, status polling, and rate-limit back-off — the three things a developer asks about within minutes of the first successful POST. - Cross-references — listed underrelatedSlugsforapi/orders,api/getting-started,api/idempotency, andapi/rate-limits. Reverse links from those articles' related-slugs will pull the article into the recommended-reading rail.- Phase 68: Bazar auth endpoints in the Postman + OpenAPI exports
- Closes the Phase 65/66 scope gap — the first cuts covered the public order / reservation / menu / payment / gift-card surface but skipped the bazar customer-auth flow. Developers integrating against the marketplace needed register → login → me → logout to even get a session cookie before any of those public endpoints worked end-to-end. - Postman collection (src/app/api/docs/postman-collection.json/route.ts): - New "Bazar auth" folder at the top of the collection — Register, Login, Current user, Logout. - Each request carries the realistic body template (Jane Doe / strong password placeholder /
gdprConsent: true) and a description noting the cookie-session model, password length floor, and the Phase 67 rate-limit ceilings on login. - Folder description spells out the no-token-header /bazar_user_sessioncookie model so developers don't waste time looking for anAuthorizationheader. - OpenAPI 3.0 spec (src/app/api/docs/openapi.json/route.ts): - New `Bazar auth` tag added to the tag list (registered at the top so generated docs render it first). - `BazarRegisterRequest` and `BazarLoginRequest` components — the register schema enforcesgdprConsent: trueviaenum: [true]so generated clients know the server treatsfalseas a 400. - Four new paths (/api/bazar/auth/register|login|me|logout) — each carriestags,summary,description, and the relevant response codes. Login wires in theRateLimitedshared response (Phase 67's stacked cap referenced in the description text). - Why both formats stay in sync — same hand-authored TypeScript source-of-truth in each route. Future API additions land in both files together so the Postman and OpenAPI downloads never drift.- Phase 67: Stacked global rate-limit ceiling on high-risk endpoints
- Closes the Phase 24 deferred — "No global limit — only per-(route, principal). Worst-case a botnet from 1000 unique IPs each hitting the report endpoint 5 times pre-trips no limiter. Mitigation: cuts off at the auto-hide threshold (Phase 18) anyway. Future polish: stacked global cap." Phase 24 protected against single-attacker spam; Phase 67 adds the distributed-attack ceiling that catches coordinated abuse. - Limiter (src/lib/rate-limit/limiter.ts): - `RateLimitOpts.globalLimit?: number` + optional `globalWindowSec` (defaults to
windowSecwhen omitted). When set, the limiter runs the per-principal check first AND a second check against a shared__global__principal bucket. Both must pass. - Bucket-sharing math — the global bucket reuses the samecheck()machinery as the per-principal one, just withprincipal = '__global__'. Hits get recorded in both buckets when both pass, so the global counter reflects every accepted hit regardless of which IP/user made it. - Response on 429 picks the tighter limit (whichever bucket actually tripped) forRetry-After,X-RateLimit-Limit, andX-RateLimit-Remaining. New header `X-RateLimit-Scope` =principalorglobalso monitoring can distinguish single-attacker spam from distributed flood. Response body also gainsscopefor the same reason. - Backwards compat — endpoints that don't setglobalLimitbehave identically to the Phase 24 limiter. - High-risk endpoints now stacked: - bazar/review/report: per-IP 5/10min + global 200/10min. A botnet of 1000 IPs each at 5 hits would trip the global cap long before it caused real damage. - bazar/review/submit: per-user 10/hour + global 500/hour. Coordinated bot army faking unique accounts gets caught. - bazar/auth/login: per-IP 8/15min + global 300/15min. Common-password lists across many IPs trip the global before per-IP catches each. - Logging — when a request trips the limit the warn log now includes thescopefield. Operations can grep onscope: 'global'to see whether they're looking at distributed abuse versus a noisy single client. - Known limitations / deferred (still open): - In-process global bucket — same single-Node-process constraint as the per-principal limiter. The "deferred" entry for cross-instance state (Redis / Upstash) still applies. A multi-pod deployment would need to share both buckets; documented in the Phase 24 limiter file.- Phase 66: OpenAPI 3.0 spec download alongside the Postman collection
- Closes more of the Phase 3 docs deferred — Phase 65 shipped the Postman v2.1 collection; Phase 66 publishes the same surface as an OpenAPI 3.0 document so Swagger UI / ReDoc / Stoplight /
openapi-generator/swagger-typescript-apiusers get the same one-clicker. - Why both formats — Postman (collection v2.1) and OpenAPI (3.0) are the two dominant consumption shapes; tools can usually round-trip between them, but starting from a native artefact is always cleaner than converting. - API (src/app/api/docs/openapi.json/route.ts): -GET /api/docs/openapi.json— returns an OpenAPI 3.0.3 document withContent-Disposition: attachment; filename="restora-public-api.openapi.json". Browser Save As fires automatically. - `components.schemas` — typedError,OrderItem,OrderRequest,ReservationRequest. TheErrorenvelope matches the runtime shape ({ error, message? });OrderRequestdocuments the optionalcustomerCoordsfield the Phase 55 radius gate consumes. - `components.parameters` — reusableTenantId/OrderId/ReservationIdpath params; reused via$refacross every route'sparametersarray. - `components.responses` —RateLimited(withRetry-Afterheader) andValidationError. Both reused via$ref. - 5 tags, 12 paths — same coverage as the Postman collection (Orders submit/get/cancel, Reservations config/availability/create/cancel, Menu filters, Payments intent/coupon, Gift cards purchase/lookup). Each path carriesdescriptiontext that calls out auth, rate limits, andOUTSIDE_RADIUS409 when relevant. - 5-minute browser cache mirrors the Postman endpoint. - Docs UI ([src/app/docs/\[category\]/page.tsx](src/app/docs/[category]/page.tsx)): - The Phase 65 gradient banner gains a second download button (OpenAPI) next to the Postman button. Orange-filled for Postman (primary), white-on-orange-ring for OpenAPI (secondary) — both equally functional, just different shapes. - Banner copy reworded from "Postman collection" to "API references — download" to cover both downloads. -flex-wrapon the banner so the title block + buttons reflow cleanly on narrow viewports.- Phase 65: Postman v2.1 collection download for the public API
- Closes part of the Phase 3 docs deferred — "Postman collection download, downloadable OpenAPI spec, additional webhook events (`reservation.
/giftcard.*` / `commission.*`), per-language SDK examples..."* Developers integrating with the public API previously had to copy-paste endpoint URLs from individual docs articles into Postman by hand. Phase 65 makes the import a one-clicker. - **Why Postman v2.1 over full OpenAPI 3.0** — same import target (Postman, Insomnia, Hoppscotch all consume v2.1), smaller hand-written surface, doesn't need full response schemas. Postman exports OpenAPI from the collection if downstream conversion is needed. - **API** ([src/app/api/docs/postman-collection.json/route.ts](src/app/api/docs/postman-collection.json/route.ts)): - `GET /api/docs/postman-collection.json` — returns the collection as a JSON document with `Content-Disposition: attachment; filename="restora-public-api.postmancollection.json"so browser File Save dialogs fire automatically. - **Variables** ({{baseUrl}}/{{tenantId}}/{{orderId}}/{{reservationId}}) so importers can swap environments without editing every request body. - **Default baseUrl** points athttps://restora.io; tenant ID defaults toYOURTENANTIDplaceholder. - **Cache-Control: public, max-age=300** — the collection is hand-authored and doesn't change per request; 5-minute browser cache is enough. - **Coverage** — five folders, ten endpoints across the most-documented surfaces: - **Orders** — Submit order, Get order, Cancel order. - **Reservations** — Get config, Check availability, Create reservation, Cancel reservation. - **Menu** — Get menu filters (allergen + dietary tag taxonomy). - **Payments** — Create payment intent, Validate coupon. - **Gift cards** — Purchase gift card, Look up gift card balance. - **Each request** carries realistic body templates (Jane Doe /+44 7700 900123/ sample products) and adescriptionfield with auth notes, rate-limit limits, and theOUTSIDE_RADIUS409 callout (Phase 55) so the collection is self-documenting on hover in Postman. - **Docs UI** ([src/app/docs/\[category\]/page.tsx](src/app/docs/[category]/page.tsx)): - New gradient banner at the top of the **API** category landing —cat.id === 'api'only. Heading + tagline + amber Download button. - Banner uses native<a download>` so the browser handles the save dialog; no JS framework dependency. - Other category landings (Getting Started / Restaurant / etc.) are unaffected.- Phase 64: Cross-links from cuisine + city parents to top combo destinations
- Closes the Phase 20-era deferred — "Cross-links on parent cuisine/city pages don't yet point at the top combo destinations — would require expensive DB scans on every parent page load to compute 'which combos have listings'. Future polish could precompute this offline." Parent pages had generic chip strips ("Other cuisines" / "Other cities") but no signal about which
/bazar/cuisine/<slug>/in/<city>combo pages actually had listings — wasting both the user's navigation and Google's crawl budget on dead ends. - Helper (src/lib/bazar/combo-index.ts): - `buildIndex()` walks every approved listing once: reads its tenant DB to extract the city viaextractCity(address), then iteratesMARKETPLACE_CATEGORIESmatching by slug or keywords. Tally(cuisineSlug, citySlug) → countmap. The walk is genuinely expensive (one tenant DB read per listing) so the result feeds the cache rather than running per-request. - 5-minute global module-memory cache — same TTL pattern as Phase 60's marketplace-stats. Every parent page hit during the window reuses the index; a stale read of up to 5 minutes is well within acceptable SEO latency. - `topCombosForCuisine(slug, limit)` and `topCombosForCity(slug, limit)` — filtered slices over the shared index. Both return{ cuisineSlug, cuisineName, cuisineIcon, citySlug, cityName, count }sorted by count desc with name asc tiebreaker (deterministic for crawlers). - Cuisine page ([src/app/(bazar)/bazar/cuisine/\[cuisine\]/page.tsx](src/app/(bazar)/bazar/cuisine/[cuisine]/page.tsx)): - New "Popular {cuisine} cities" chip strip above the existing "Other cuisines" strip. Each chip links to/bazar/cuisine/<slug>/in/<city>with the listing count badge. - Orange-tinted (vs the gray neutral chips below) so the eye is drawn to the higher-intent destinations first. - Conditional rendering — when the index returns zero combos (new bazar with no approved listings), the existing "Other cuisines" section regrows the top divider it normally borrows from this new section. Avoids an awkward double-divider gap. - City page ([src/app/(bazar)/bazar/city/\[city\]/page.tsx](src/app/(bazar)/bazar/city/[city]/page.tsx)): - New "Popular cuisines in {city}" chip strip above the existing "Other cities" + "Browse by cuisine" sections. Same visual treatment as the cuisine-page strip. - Uses the cuisine icon as the chip glyph so the visual scan reads as cuisine choices first, city later — matches the "I'm in {city}, what should I eat?" mental model. - SEO value — these chips materially raise the internal-linking density toward the combo pages without producing 404 paths. Every link points to a route that already exists and has approved restaurants behind it. - Cache invalidation — none, by design. A new approved listing waits up to 5 minutes to appear in the combo index. Acceptable for an SEO surface; a webhook-driven invalidation would be premature for the file-DB topology.- Phase 63: Dry-run preview for bulk feature-grant campaigns
- Closes the Phase 15-era deferred — "No undo / dry-run — once a campaign runs, the only way to roll back is another campaign with `enabled: false`. A dry-run preview ('would affect 12 tenants — these are the IDs') would be a Phase 16 polish." Campaigns touch dozens of tenants in one click; a typo in the tenant cohort or feature ID was painful to walk back. Preview makes the impact visible before the writes happen. - API (src/app/api/admin/feature-grants/bulk/route.ts): - New
dryRun?: booleanbody field. Runs the full validation + selection pipeline (featureId lookup, tenant ID resolution, dedupe, skip-list) but short-circuits before the masterDB write and before the per-tenant activity logs. - Response splits the affected tenant IDs intowillReplace[](existing grant for the same featureId about to be upserted) andwillCreate[](fresh grant). The admin can see "this would replace 4 existing comps and create 8 new ones" before committing. - Returns the samegrants[]objects the live run would have written, so the inspector can verify the exact payload (expiresAt, addonPriceMonthly, notes) without a second probe. - Live runs keep their exact existing response shape — no breaking change for anyone scripting against the endpoint. - Admin UI (src/app/admin/dashboard/features/page.tsx): - Preview button sits next to Run as a 1:2 grid (small Preview, large Run). Dark-purple outline vs. solid purple keeps the visual hierarchy correct — Preview is the safer, secondary action. - Impact panel appears above the buttons after a successful preview — three stat tiles (Would apply / Would replace / Would create) with explicit colour treatment (amber for replace, emerald for create) so the destructive vs. additive split is read at a glance. - "Nothing has changed yet" banner copy and Sparkles icon make the no-commitment state obvious. Once the admin clicks Run, the preview state clears so the post-run success card replaces it. - Skipped count also shown in the preview panel so unknown tenant IDs surface before the live run rather than as a quiet "skipped: N" in the success message. - Disable interlock — Preview disables while Run is in flight and vice versa, so an admin can't issue both at once.- Phase 62: Super-admin backfill for verified-purchase badges on bazar reviews
- Closes the Phase 21 deferred — "No backfill — existing reviews stay unverified even if the reviewer has orders. We could run a one-off script that re-scans every bazar review against its tenant's order history, but the false-negative rate from email-mismatch makes it a low-priority polish." The false-negative problem from email mismatch was largely closed by Phase 22 (FK at checkout) and Phase 39 (opportunistic email backfill on read) and Phase 46 (manual claim). Phase 62 now sweeps legacy reviews against that strengthened pipeline. - API (src/app/api/admin/bazar/reviews/backfill-verification/route.ts): -
GET— coverage stats. Walks every approved listing's reviews, counts bazar-source reviews already verified, unverified, and upper-bound flippable (those whose reviewer could match an order). No mutation. -POST— runs the backfill. Same two-tier match logic as Phase 21/22 at submit time: 1. Strong FK —Order.bazarUserId === Review.bazarUserId+paymentStatus === 'paid'. 2. Email fallback —Order.customerEmailcase-insensitive match against the bazar user's email (when looked up viaReview.bazarUserId). - Bounded per run (default 50, cap 200). One save per tenant DB with dirty changes — partial-progress tolerant; a save failure on one tenant doesn't break the loop. - Returnssummary.remainingso the operator sees how many more runs they need. - Admin UI (src/app/admin/dashboard/food-bazar/page.tsx): - New "Bazar review verification" panel in GeoTab, below the Customer saved addresses panel. Same UX pattern: percentage bar, totals, "Verify N now" button (label reflects whatever the actual flippable count is, capped at 50). - Results card after each run: emerald Verified / gray No match / amber Remaining. Amber "run again to continue" hint when more work remains. - Conditional render — panel only appears when there's at least one bazar review on the platform. A fresh deployment with zero reviews keeps a clean tab. - Composition with the verification tiers: - Phase 21: submit-time check — new reviews flip on the spot when the order is already there. - Phase 22: FK at checkout — every new order ships with a strong link. - Phase 39: opportunistic email backfill on read — closes the FK gap for legacy orders the user has touched. - Phase 46: manual claim — covers the cross-email case. - Phase 62: super-admin batch sweep — closes whatever's still stale at scale.- Phase 61: Discard-changes Cancel button on bazar item-detail edit mode
- Closes the Phase 45 deferred — "No 'discard changes' path beyond browser back — the customer cancels by hitting Back. A dedicated Cancel button could be a future polish." Browser Back works but is unobvious on desktop and varies on mobile (swipe vs. button vs. gesture); a visible Cancel button is self-documenting. - UI ([src/app/(bazar)/bazar/restaurant/\[slug\]/item/\[productId\]/page.tsx](src/app/(bazar)/bazar/restaurant/[slug]/item/[productId]/page.tsx)): - Cancel button added to the right edge of the Phase 45 edit-mode banner. White background, orange ring, uppercase label — visually subordinate to the orange "Update cart" CTA so users don't accidentally tap Cancel when they mean Update. - Click handler calls
router.back()— same path as the post-Update navigation, so the user lands wherever they came from (cart drawer → cart drawer position; checkout summary → checkout summary). - Title attribute spells out "Discard changes and return to where you were" for hover and screen readers; the button label itself stays one-word for tap-target speed. - Banner layout kept the same — text on the left, Cancel on the right, ShoppingCart icon unchanged. No height change, no layout shift.- Phase 60: Cross-restaurant marketplace aggregate review stats
- Closes the Phase 7a deferred — "Reviews aggregation is per-tenant only; cross-restaurant marketplace aggregate review surfacing (e.g. 'average ⭐ across all listings') is a future polish." A new bazar customer landing on
/bazarhad no immediate signal that the marketplace as a whole was trustworthy — only per-restaurant ratings once they drilled in. Phase 60 surfaces the headline numbers up-front. - API (src/app/api/bazar/marketplace-stats/route.ts): -GET /api/bazar/marketplace-stats— anonymous-safe; returns{ restaurants, ratedRestaurants, totalReviews, averageRating, recentReviews30d, generatedAt }. - Weighted (unweighted) mean — every approved + non-hidden review counts once, not once-per-restaurant. A merchant with 200 reviews carries the math more than one with 3, which matches what a "marketplace average" should mean. - 30-day recency window computed alongside so the strip can show "activity now," not just lifetime totals. - 5-minute in-module cache (let cached: { at, payload }). A landing-page hit during the TTL window reuses the cached payload — repeated visits don't fan out to N tenant DBs per render. A stale read ≤ 5 min is well within the "trust strip" accuracy budget. - Fail-soft — bazar marketplace disabled or DB load failure returns zeros, not an error. The client renders nothing in that case. - UI (src/app/(bazar)/bazar/page.tsx/bazar/page.tsx)): - NewMarketplaceStatsStripcomponent placed at the top of the landing's main column, just above the recommendation rail. - Three-pill layout — Avg rating (amber, Star icon) · Total reviews (rose) · Reviews last 30 days (emerald, TrendingUp icon). Gradient background (amber → orange → rose) keeps it visually distinct from the rest of the page without screaming. - `MarketplaceStatsStrip` returns null whentotalReviews === 0or the rating is null. A brand-new bazar deployment with zero reviews doesn't get a "0.0 ★" embarrassment on its hero page — the strip simply doesn't render. - Mobile-responsive — text scales fromtext-2xlon small screens totext-3xlonsm:and up; labels truncate via uppercase + tracking. No horizontal scroll on small viewports.- Phase 59: "Why am I seeing this?" deep-dive on recommendation chips
- Closes the Phase 28 deferred — "No 'Why am I seeing this?' deep-dive — the chip's one-liner is the explanation. A future polish could let the user click for 'Because you liked Anna's pick and Sakura, plus your preferences include Japanese'." Recommendations stay opaque without it; users wonder why the model picked something and either trust it blindly or ignore the rail entirely. - API (src/app/api/bazar/recommendations/route.ts): - `Recommendation.reasons?: string[]` — full breakdown of contributing signals. The existing one-line
reasonstays as the headline;reasonsis the click-to-expand detail. - Signal-based mode lists, in priority order: matched preferred cuisines (fromsubscriptionPrefs.preferredCuisines), matched favourite cuisines (from listings the user already saved), and a "strong rating" call-out when avg ≥ 4.0 and review count ≥ 3. - Cold-start mode is explicit that the signals are marketplace-wide rather than personal: paid-order count this week, strong rating, and a closing hint that personalised picks unlock once the user sets prefs or saves a favourite. - No new DB reads — every signal is already in scope at the scorer level. - UI (src/app/(bazar)/bazar/page.tsx/bazar/page.tsx)): - Chip is now a button, not static text. Clicking expands an indigo-tinted panel below the card with a bullet list of the reasons. Clicking again collapses. - `openWhy` state tracks a single open card at a time so the rail stays scannable (closing the previous opens the new). - Click event eats `preventDefault` + `stopPropagation` so opening the panel doesn't navigate to the restaurant page (the rest of the card is still a Link). The panel itself also stops propagation so an accidental tap inside doesn't navigate either. - `?` suffix on the chip (subtle, normal-case, indigo-400) hints that more is available without shouting. Title attribute also reads "Why am I seeing this?" for hover. - `aria-expanded` mirrored on the button for screen-reader navigation. - Cards without `reasons` still render the chip but with no?and no panel — graceful degrade for any stale cached payload.- Phase 58: SMS deep-link beside tel: on bazar restaurant phone CTAs
- Closes the Phase 25 deferred — "`tel:` only — no `sms:` for SMS — could be added on a long-press or 'More' menu in a future polish." "I want to text the restaurant about my order" is one tap shorter than tap-and-hold; this exposes it as a sibling button. - Bazar order detail ([src/app/(bazar)/bazar/orders/\[orderId\]/page.tsx](src/app/(bazar)/bazar/orders/[orderId]/page.tsx)): - The lone
tel:chip becomes a two-button group (Call + SMS). MessageSquare icon for SMS so the visual distinction is immediate. Both buttons strip whitespace from the phone identically (digits-only target). -titleattribute on Call now spells out the number; SMS reads "Send SMS to the restaurant" so screen readers / hover users don't get a bare icon. - Bazar restaurant client ([src/app/(bazar)/bazar/restaurant/\[slug\]/RestaurantClient.tsx](src/app/(bazar)/bazar/restaurant/[slug]/RestaurantClient.tsx)): - The "Quick Actions" grid (Call / Directions / Share) gains an SMS tile next to Call. Matches the visual treatment of the existing actions (icon over label, hover tint). - Reuses the existingMessageSquareimport — no new icon dependency. - Why both protocols even on desktop — desktop browsers handlesms:inconsistently (some dispatch to an installed app like iMessage on macOS, some no-op, some prompt to pick an app). The cost of exposing a non-functional link on desktop is essentially zero — the button is a visual hint, not a contract — and the mobile win is significant. - Discoverability vs. clutter — the alternative (long-press or "More" menu from Phase 25's deferred suggestion) hides the action behind a gesture nobody knows about. A second visible button costs ~24px of space and is self-documenting. - Stale deferred swept — also reviewed the Phase 20 deferred "`MARKETPLACE_CATEGORIES` is food-type-focused — `/bazar/cuisine/italian` is a 404"; the Phase 11 ethnicity aliases (italian / japanese / french / mediterranean / korean / vietnamese) shipped in src/lib/bazar/categories.ts already close it. No action needed.- Phase 57: Anonymous geocode-preview for guest checkout
- Closes the Phase 56 leftover — "Anon checkout still falls through. Customers ordering without a bazar account can't hit the preview endpoint (401)." Without this fix, the entire Phase 55 radius gate was invisible to guest orders — the server fell back to fail-open whenever the body lacked
customerCoords, and guests never had a way to populate it. - API (src/app/api/bazar/geocode-preview/route.ts): - Two-tier rate limit — auth tier keeps its 20/5min per-user budget; anon tier gets a stricter 5/5min per-IP budget on a separatebazar.geocode.preview.anonbucket. - The auth tier earns the higher budget because we have an account to suspend if it abuses; the anon tier sits behind the same Phase 50 Nominatim 1.1s throttle either way, so the cumulative impact on the upstream provider is bounded. - No body / payload changes — the endpoint signature stays identical, so the client just stops getting 401s. - Checkout (src/app/(bazar)/bazar/checkout/page.tsx/bazar/checkout/page.tsx)): - Dead 401 branch removed — themanualGeoStatus = 'idle'special-case for 401 responses is now unreachable. A non-OK response (429 rate-limit, 5xx server error) falls into the same'miss'branch — the user sees the existing fail-open message and the order proceeds without coords. - Composition with the rest of the chain: - Saved addresses with stored coords (Phase 42 / 49 / 54) → still the strongest signal. - Authenticated manual entry (Phase 56) → 20/5min preview budget. - Anon manual entry (Phase 57) → 5/5min preview budget. - All paths populatecustomerCoordsin the order POST → server-side Phase 55 gate fires the same way. - Why per-IP for anon, per-user for auth — a single shared-NAT household sharing one bazar account isn't penalised by another tenant's typo; an IP isn't penalised twice for the same machine logging in. The buckets are independent (routefield differs) so a logged-out user who later signs in starts fresh on the auth tier.- Phase 56: Geocode preview for manually-entered checkout addresses
- Closes the Phase 53 + 55 leftover — "No equivalent for the inline 'new address' path — distance only renders for saved addresses. Manually typed addresses can't be measured client-side without an extra geocode call. Deferred." First-time customers who type an address at checkout without saving it were invisible to the Phase 55 radius gate; the server fell back to fail-open. Phase 56 closes that gap with a debounced preview call. - API (src/app/api/bazar/geocode-preview/route.ts): -
POST /api/bazar/geocode-preview— body{ addressText, postcode? }, returns{ coords: { lat, lng } | null }. - No persistence. The endpoint geocodes and returns; nothing is written. Saved-address geocoding still goes through the Phase 42 / 49 / 54 persistence paths. - Auth: bazar session required (the geocoder costs money / shared community resources). 401 anon. - Rate limit: 20 attempts / 5 minutes per user viarequireRateLimit. Combined with the client's 800ms debounce + the Phase 50 Nominatim 1.1s throttle, well within polite usage and a hard stop on scripted abuse. - Reuses `geocodeSavedAddress` — same 3-second hard timeout, same provider chain (Google → Nominatim). - Checkout (src/app/(bazar)/bazar/checkout/page.tsx/bazar/checkout/page.tsx)): - `manualCoords` + `manualGeoStatus` state —'idle' | 'resolving' | 'hit' | 'miss'. Resets to idle when the user picks a saved address (then the saved address's stored coords win). - Debounced effect — 800ms after the lastdeliveryAddressorpostcodekeystroke, fires the preview call. Conditional on delivery mode + manual-entry path + address text ≥ 3 chars. Cancellation token guards against in-flight responses landing after the user typed more. - `activeCoords` picks the picked saved address's coords first, then falls back tomanualCoords. The downstreampickedDist+outsideRadiusderived state work unchanged — same gate applies to both paths. - `customerCoords` submit payload — now sendspickedAddr?.coords ?? manualCoordsfor delivery orders. The server treats both paths identically (Phase 55 enforcement). - Inline status indicator below the postcode field, manual-entry only: -'resolving'→ "Resolving location…" (gray). -'hit'→ "{distance} from {restaurant}" with map-pin icon, blue when within range, amber when outside. -'miss'→ "Couldn't locate this address — delivery will be attempted anyway." (gray). This is the fail-open path: the server-side check would also skip if coords are absent. - Composition: - Saved addresses → distance from storedcoords(Phase 42 / 49 / 54). - Manual entry → distance from preview-geocoded coords (Phase 56). - Both paths → server-side radius check (Phase 55) → 409OUTSIDE_RADIUSwhen over. - Known limitations / deferred: - Anon checkout still falls through. Customers ordering without a bazar account can't hit the preview endpoint (401). The radius gate only fires when they pick a known restaurant + their bazar session signs them in. Anonymous-safe geocoding would need its own rate-limit and is a future polish.- Phase 55: Hard delivery-radius enforcement at bazar checkout
- Closes the load-bearing half of the Phase 42 / 53 deferred — "No hard radius enforcement at submit — checkout still doesn't refuse orders outside a radius. Needs a `deliveryRadiusKm` field on tenant settings + a server-side check in `/api/public/[tenantId]/orders`. Future phase." Phases 42 / 49 / 53 / 54 built the geocoding pipeline + distance display; Phase 55 turns it into a real gate. - Schema (src/lib/db/schema.ts): -
TenantSettings.deliveryRadiusKm?: number— straight-line cap in km. Absent or 0 → no enforcement (default for existing tenants; nothing breaks for anyone who hasn't opted in). - Documentation comment makes the fail-open contract explicit so future readers don't introduce a surprise hard block when the data isn't there. - Validation (src/lib/validation/schemas.ts): -UpdateSettingsSchemaacceptsdeliveryRadiusKmclamped to[0, 500]. 500 km handles even bicycle-delivery ambition without typos breaking the DB. -CreateOrderSchemaaccepts an optionalcustomerCoords: { lat, lng }. Required for the server-side enforcement to fire — absent → fail-open. - Server-side enforcement ([src/app/api/public/\[tenantId\]/orders/route.ts](src/app/api/public/[tenantId]/orders/route.ts)): - Inside the POST handler, after auth + closed-check + body parse + before item validation, computeshaversineDistanceKmbetween restaurant and customer coords when all three sides are present (type === 'delivery',deliveryRadiusKm > 0, restaurant hascoordinates, body hascustomerCoords). - 409 `OUTSIDE_RADIUS` with{ distanceKm, radiusKm, message }payload when over. Client renders the message verbatim — no info-leak concern since this is a customer's own destination distance. - Fail-open everywhere else — missing coords on either side, no radius set, pickup order, manual address entry without coords → the gate is invisible. - API surfaces ([src/app/api/bazar/restaurant/\[slug\]/route.ts](src/app/api/bazar/restaurant/[slug]/route.ts) + [src/app/api/tenant/\[tenantId\]/settings/route.ts](src/app/api/tenant/[tenantId]/settings/route.ts)): - Bazar restaurant detail returnsdeliveryRadiusKmalongsidecoordinates(Phase 53). - Tenant settings PATCH allows-list now includesdeliveryRadiusKm. - Operator UI (src/app/(dashboard)/dashboard/delivery/page.tsx/dashboard/delivery/page.tsx)): - New "Delivery radius" card at the top of the Delivery Zones tab — separate from per-zone pricing because the cap applies to the whole restaurant. Number input (0–500 km, 0.5 step), Save button, inline error / saved-confirmation chip. - Loads current value viaGET /api/tenant/[id]/settingsin the same load() cycle as the zones list. - Explanatory copy spells out the fail-open behaviour ("Only enforced when both the restaurant and the customer have geocoded coordinates") so operators know radius=0 isn't an error state. - Bazar checkout (src/app/(bazar)/bazar/checkout/page.tsx/bazar/checkout/page.tsx)): -ResolvedRestaurant.deliveryRadiusKmtyped. - `outsideRadius` derived state — true only when delivery + radius set + picked saved address has coords + distance > radius. - Amber warning panel above the Place Order button whenoutsideRadius. Messages:"{restaurant} only delivers within X km. Your selected address is Y km away. Switch to pickup or choose a closer saved address."- Place Order button disabled via the existingcanSubmitchain — so the client refuses just like the server would, but with a calmer UX. - Submit payload now includescustomerCoordswhen delivery + a saved address with coords is picked. Manual entries omit them (fail-open path). - Composition with the prior geo phases: - Phase 42: save-time geocoding writes coords. - Phase 49: customer Verify button geocodes per-address. - Phase 54: super-admin batch backfill closes the legacy gap. - Phase 53: checkout shows the distance hint. - Phase 55: checkout uses the distance to decide whether to allow the order.- Phase 54: Super-admin batch backfill for bazar user saved-address geocoding
- Closes the Phase 49 + Phase 42 follow-up deferred — "No batch verify — the operator-side super-admin batch backfill is still missing. Customer-side per-address verify covers the visible cases; a cron is a future polish." Phase 49 gave customers a per-address Verify button; Phase 54 gives platform admins the same power across every user at once. - API (src/app/api/admin/food-bazar/geocode/users/route.ts): -
GET— returns coverage: total users, total addresses, geocoded count, missing count, coverage percentage, plus a per-user breakdown sorted bymissing DESCso admins can spot heavy-tail outliers. Caps the response at 50 users. -POST(body:{ limit?: number }) — walks bazar users in order, runsgeocodeSavedAddressagainst every saved address without coords, stops atlimitprocessed (default 25, clamped to[1, 100]). - On success: stampscoords,geoHash,geocodedAt, bumpsupdatedAt. - On failure: records'failed'in the per-address result; the address is not skipped permanently — the next run retries. - One save per batch (dirty flag) — not one save per address. - Bounded runtime: 25 × ~1.1 s Nominatim throttle ≈ 28 s worst case. Google-keyed deployments hit Google's higher cap and finish much faster. - Returnssummary.remainingso the operator sees how many more runs they need. - Existing restaurant geocoder unblocked (src/app/api/admin/food-bazar/geocode/route.ts): - The hardif (!GOOGLE_MAPS_API_KEY) return skippedgate from before Phase 50 is now too strict —geocodeAddressfalls through to Nominatim. Phase 54 swaps the gate for one that only bails when both Google and Nominatim are disabled (GEOCODER_NOMINATIM=off). The "noprovider" reason replaces the legacy `noapi_key. - **Admin UI** ([src/app/admin/dashboard/food-bazar/page.tsx](src/app/admin/dashboard/food-bazar/page.tsx)): - **New "Customer saved addresses" panel** in the GeoTab, below the per-restaurant table. Shows percentage bar, total / verified / missing counts, "Backfill 25 now" button (label reflects whatever the actual cap is when fewer than 25 remain). - **Results card** after each run: green Geocoded / red Failed / gray Skipped / amber Remaining. Amber "run again to continue" hint when more work remains. - **Provider-chain banner**: the legacy "Google Maps API key not configured" red banner is reworded to a blue info panel that explains the active provider (Nominatim fallback) and how to switch / disable. Reflects the post-Phase-50 reality where the system works without a Google key. - **Restaurant geocode button no longer hard-disabled** when Google is unset — only blocked when Nominatim is also off (the matched-server-side new gate). - **Composition with the per-tier model**: - **Strong-link path**: addresses geocoded on save (Phase 42). - **Customer-controlled path**: per-address Verify button (Phase 49). - **Platform-controlled path**: super-admin batch backfill (Phase 54). - All three persist into the samecoords/geoHash/geocodedAt` triple, so downstream consumers (Phase 53 distance hints, future radius enforcement) don't care which path the data came from.- Phase 53: Distance hints on bazar checkout's saved-address picker
- Partial close on the Phase 42 follow-up deferred — "Delivery-radius validation at checkout still uses the user-level coordinates / address text. Switching the checkout zone check to prefer the picked saved-address coords is a Phase that touches the order-submit handler and is left as a follow-up." Full radius enforcement still needs a
deliveryRadiusKmschema field, but Phase 53 ships the visible UX that turns the Phase 42 storage into something customers actually see: each saved address shows distance-from-restaurant. - Why distance hints first, not enforcement — even without a hard radius rule, "this address is 1.2 km away" tells the customer which option to pick when they have several saved addresses (Home / Work / Mom's place). The data, math, and UI surface land now; enforcement reuses everything and ships in its own phase. - API ([src/app/api/bazar/restaurant/\[slug\]/route.ts](src/app/api/bazar/restaurant/[slug]/route.ts)): - Restaurant detail response now exposescoordinates: { lat, lng }from the bazar listing (Phase 2 geo). Missing when the listing wasn't geocoded — the client falls back to a coordless picker. - Checkout (src/app/(bazar)/bazar/checkout/page.tsx/bazar/checkout/page.tsx)): - Distance badge — every saved address withcoordsgets a small badge next to its label:120 munder 1 km,1.2 kmotherwise. Reuses the existinghaversineDistanceKmhelper from@/lib/bazar/geo. - Sort nearest-first — the picker re-orders saved addresses by distance ascending, with the default address pinned to the front so a one-click pickup still hits the user's intended address. Addresses without coords fall to the end. - Badge style — blue pill when the button is unpicked; semi-transparent white when the address is selected (matches the orange picked state without losing contrast). Title attribute shows the exact distance for hover/screen-reader. - Backwards compat — addresses without coords (legacy, or saved before Phase 42) simply render without the badge. No UI break for users who haven't run the Phase 49 Verify pass. - Type updates:ResolvedRestaurantandSavedAddressinterfaces in the checkout page grew the optionalcoordinates/coordsfields. No schema change required — both already exist in the underlying DB. - Known limitations / deferred (still open): - No hard radius enforcement at submit — checkout still doesn't refuse orders outside a radius. Needs adeliveryRadiusKmfield on tenant settings + a server-side check in/api/public/[tenantId]/orders. Future phase. - No equivalent for the inline "new address" path — distance only renders for saved addresses. Manually typed addresses can't be measured client-side without an extra geocode call. Deferred.- Phase 52: Sitemap image `title` + `caption` for richer image SEO
- Closes the unfinished half of Phase 41's image-extension polish. Phase 41 emitted
<image:image><image:loc>only — Google's sitemap-image schema 1.1 also supports<image:title>(shown in image-rich result snippets) and<image:caption>(descriptive context for the crawler). Adding both significantly improves the per-image signal without changing the entry count. - Library change (src/app/sitemap.xml/route.ts): - `SitemapImage` union —string | { loc, title?, caption? }. Plain-string callers (Phase 41 platform branch) keep working unchanged; the new object form opts into title + caption. - `clip(text, max)` helper trims to whole-word boundaries with a trailing ellipsis: title capped at 160 chars, caption at 240. The Google docs don't publish a hard length limit but rich-result snippet rendering truncates around that range; clipping here keeps the XML lean and the snippets clean. - `xmlEscape` already handles the entity-sensitive characters (&,<,>,"), so user-supplied restaurant names with apostrophes/ampersands flow through safely. - buildXml now emits the inner elements as a single line (<image:image><image:loc>…</image:loc><image:title>…</image:title></image:image>) so the diff between Phase 41 and 52 stays minimal in inspector tools. - Tenant sitemap (subdomain branch): - Home — logo getstitle: "{restaurantName} logo", banner gets"{restaurantName} interior"; both carry the restaurant description as caption (when set). - Menu — per-product image gets the product name as title and the product description as caption. Multi-locale fields (Record<string, string>) are resolved via the existinggetProductTexthelper (en → first non-empty fallback). - Blog index + per-post — hero image gets the post title + excerpt.heroOf()checks bothimageUrl(current schema) andheroImageUrl(forward-compat for the deferred Phase 52-like rename) so neither path silently drops the hero. - Platform sitemap: - Each tenant home (under/tenant/<id>orcustomDomain) — logo / banner get the same name + description treatment as the tenant subdomain branch. - Each bazar restaurant (/bazar/restaurant/<slug>) — same. The image-rich result snippet on a bazar discovery query now shows the restaurant's name + tagline instead of a raw URL. - Backwards compat — every Phase 41 string-formimagescallsite continued to work via thestring | objectunion; no force-migration of existing entries. - Type fixes during integration: - Blogtitle/excerptareRecord<string, string>, not strings. Plumbed throughgetProductText()to extract en (or first non-empty) for title/caption. - Restored unintended dependence on a non-existentBlogPost.heroImageUrlfield that crept into the first draft.- Phase 51: Volume control for bazar status alerts (3-step off / soft / loud)
- Closes the Phase 43 follow-up deferred — "Volume control — the gain is fixed (peak 0.18). A future polish could add a slider; today users control via OS volume." Phase 51 ships discrete steps rather than a slider — three taps cover the actual UX needs (mute, polite, audible) without the precision-vs-discoverability tradeoff of a continuous control. - Why discrete over continuous — a slider on a tiny header pill is fiddly on touch, hard to discover, and forces the user to find their preferred level by experimentation. Three named steps with audible feedback (chirp at the new level on each tap) get the user to "good enough" in 0-2 clicks. - Library (src/lib/bazar/status-alerts.ts): - New types:
AlertLevel = 'off' | 'soft' | 'loud'. - New API:getStatusAlertsLevel(),setStatusAlertsLevel(level),cycleStatusAlertsLevel()(advances off → soft → loud → off and returns the new value),playLevelChirp(level). - Backwards compat: - Legacy'on'localStorage values upgrade silently to'loud'on read — existing users don't lose their preference. -getStatusAlertsEnabled()/setStatusAlertsEnabled(boolean)kept as shims that map to the level API. -playEnableChirp()retained as@deprecated; delegates toplayLevelChirp('loud'). - Gain math:GAIN_MULT.loud = 1.0,GAIN_MULT.soft = 0.35. Soft is ~35% of loud — quiet enough not to startle someone with earbuds, loud enough to hear over a laptop speaker at moderate room volume. - Haptic scaling: soft mode uses a shorter vibration pattern ([30]instead of[50, 30, 50]for positive;80instead of150for negative). Mirrors the audio softening so the feel matches. - `bazar-status-alerts-changed` event payload changes from{ on: boolean }to{ level: AlertLevel }. Internal-only event; the order detail page is the only consumer. - Order detail page ([src/app/(bazar)/bazar/orders/\[orderId\]/page.tsx](src/app/(bazar)/bazar/orders/[orderId]/page.tsx)): - Pill cycles through the three states. Each tap callscycleStatusAlertsLevel()+playLevelChirp(next)— the user hears the level they just selected. - Visual mapping: Off → BellOff icon, gray pill; Soft → Bell at 70% opacity, amber pill; Loud → Bell solid, emerald pill. Label inside the pill flips through "Alerts: Off / Soft / Loud" so the state is also readable. - Title attribute spells out what the next click does so the cycle is discoverable on hover. - AudioContext init still happens on the first user-gesture tap (soft or loud both qualify).- Phase 50: Nominatim (OpenStreetMap) fallback geocoder
- Closes the Phase 42 + Phase 49 follow-up deferred — both phases noted that "No Nominatim / OSM fallback — when `GOOGLE_MAPS_API_KEY` is unset, geocoding is a no-op." Phase 50 wires a free fallback so saved-address geocoding (Phase 42), user-triggered Verify (Phase 49), and the legacy
/api/bazar/user/locationendpoint actually return coordinates out of the box, with zero billing setup. - Why Nominatim and not another free provider — it's the canonical OSM geocoder, predictable, free for low-volume use, and doesn't require an account. The TOS is well-documented and compliance is mechanical. - Implementation (src/lib/bazar/geo.ts): - `geocodeViaNominatim` — fetcheshttps://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&q=<address>. Single result, parsed to{ lat, lng }. Returns null on miss / network error / unparseable response. - `geocodeViaGoogle` — extracted from the oldgeocodeAddressbody. No behavioural change. - `geocodeAddress` — now picks the provider based on env. Google whenGOOGLE_MAPS_API_KEYis set (no Nominatim fallback in that case — a Google miss stays a miss, so we don't leak search volume cross-provider). Nominatim otherwise. SetGEOCODER_NOMINATIM=offto disable the fallback entirely. - TOS compliance: - User-Agent: required by Nominatim's usage policy. Defaults toRestora/1.0 (admin@restora.io); override withNOMINATIM_USER_AGENTenv var. Operators running their own deployment should set their own contact email. - Rate limit: Nominatim caps at 1 request/second.throttleNominatim()is a process-local mutex that delays each call so the next request is no closer than 1100 ms after the previous one. Sufficient for our scale; no Redis / distributed lock needed. - No bulk geocoding: their policy prohibits it. Per-user-action calls (verify a saved address, save a new address) are well within "interactive" patterns. - `Accept-Language: en` — gets back English-formatted results for consistency with our existing logic. - Composition with Phases 42 + 49: - The newVerifybutton on saved addresses now resolves successfully even without a Google key. -geocodeSavedAddress(Phase 42's 3-second-timeout wrapper) races the Nominatim call against the timeout — slow OSM responses still bail out cleanly without blocking the save handler. -requireRateLimiton the verify endpoint (8/5min per user) plus the in-process 1 req/sec throttle gives belt-and-braces protection against accidentally overloading the OSM service. - Known limitations / deferred: - Accuracy — Nominatim is good for street addresses in well-mapped regions but can miss new buildings or rural areas. For production deployments in delivery-radius-sensitive markets, Google is still the recommended provider. - Process-local throttle — multi-process / multi-pod deployments could in theory exceed 1 req/sec collectively. Sufficient for the file-DB topology; revisit when we shard.- Phase 49: Verified-location pill + user-triggered re-geocode for saved addresses
- Closes the Phase 42 follow-up deferred — "No backfill of pre-Phase-42 saved addresses — existing entries stay without coords until the user edits them (which triggers a re-geocode). A super-admin batch script could rip through unindexed addresses in one pass; deferred." Rather than an admin-side batch, Phase 49 puts the control in the customer's hands: a Verify button on each saved address, and a Verified pill that surfaces once coords land. - API ([src/app/api/bazar/user/addresses/\[addressId\]/geocode/route.ts](src/app/api/bazar/user/addresses/[addressId]/geocode/route.ts)): -
POST /api/bazar/user/addresses/[addressId]/geocode— re-runs the configured geocoder against the storedaddressText+postcode. No body required; the address ID is the only input. - Persistence model: on success, overwritescoords/geoHash/geocodedAtand bumpsupdatedAt. On miss (no key, timeout, no match), keeps existing coords if any and returns422 GEOCODE_UNAVAILABLEso the UI can surface the failure without nuking previously-good data. Same policy PATCH uses on edits. - Rate limit: 8 attempts / 5 minutes per user viarequireRateLimit. A user verifying half a dozen saved addresses in a row succeeds; a script pattern is throttled. - UI (src/app/(bazar)/bazar/account/page.tsx/bazar/account/page.tsx)): - Verified pill on the address card whencoordsis present — blue with a MapPin icon. Tooltip shows the date + the lat/lng to 4 decimals for transparency. - Verify / Re-verify button in the row's action group. Label flips based on whether coords already exist; both verbs hit the same endpoint. - Inline error surfacing — 429 / 422 / 5xx each get their own user-facing message ("Too many verify attempts", "Could not verify this location right now", "Could not verify location"). The 422 wording deliberately avoids blaming the user for a missing API key (a platform-level config concern). - `SavedAddress` interface in the account page grows thecoordsandgeocodedAtfields used by the pill render. - Composition with Phase 42 — the helper from Phase 42 (geocodeSavedAddress) is reused unchanged. POST → PATCH → Phase 49's geocode-only endpoint are now the three paths into the same geocoder: save, edit, refresh. - Known limitations / deferred: - Still no provider fallback — whenGOOGLE_MAPS_API_KEYis unset, every verify hits a 422. The verify button stays useful as a "try again later" surface once the platform-admin configures a key. A Nominatim fallback is still on the deferred list for a future phase. - No batch verify — the operator-side super-admin batch backfill is still missing. Customer-side per-address verify covers the visible cases; a cron is a future polish.- Phase 48: Edit options from the bazar checkout summary
- Closes the Phase 45 follow-up deferred — "No edit affordance from `/bazar/checkout` directly — the cart edit button only lives in the header drawer." Customers reviewing their order on the checkout page could see the option chips ("Pepperoni · Thin crust") but had to back out to the cart drawer to change anything. Phase 48 adds the same pencil button to each checkout line. - Implementation (src/app/(bazar)/bazar/checkout/page.tsx/bazar/checkout/page.tsx)): - Pencil button added to each
<li>in the "Your order" section, only rendered when the line has `selectedOptions` (matches the header drawer pattern from Phase 45 — plain items have nothing to edit). - Reuses Phase 45's edit-mode picker — clicking the pencil pushes/bazar/restaurant/<slug>/item/<productId>?edit=<optsKey>&qty=<n>, just like the cart drawer's edit handler. The item page replays the cart line's selections, the CTA flips to "Update cart", androuter.back()after save returns the customer right back to checkout. - No new API, no new state, no new picker code — the third consumer of the same edit-mode contract. - Layout: the price moved into a wrapper with the pencil so both share the right-alignedshrink-0column. Plain items keep the original look (price alone, no extra spacing). - Consistency — the cart drawer (Phase 45) and the checkout summary (Phase 48) now offer identical edit affordances; a customer who scrolls past the drawer to checkout can fix toppings from either surface.- Phase 47: Live push for the full-screen `/dashboard/inbox` page
- Closes the Phase 40 follow-up deferred — "The full-screen `/dashboard/inbox` page still polls on visit only. It's a destination page, not background UI — adding SSE there is a future polish." Phase 40 shipped real-time push to the header bell + TopHeader notifications via a shared SSE stream; the destination page operators land on when they click the bell still loaded once and stayed frozen. Phase 47 wires it onto the same signal. - Implementation (src/app/(dashboard)/dashboard/inbox/page.tsx/dashboard/inbox/page.tsx)): - Reuses `useTenantPushSignal` (Phase 40) — no new endpoint, no new connection logic. The browser's HTTP/2 multiplexing folds the inbox page's SSE subscription into the same stream already opened by
InboxBell/TopHeader. One TCP connection, three consumers. - Silent re-fetch —load({ silent: true })skips the loading spinner so a server-side change doesn't blank the screen mid-scroll or wipe the operator's filter selection. The Refresh button still flashes the spinner (explicit user intent). - `hasLoadedRef` guard — the silent push handler only fires after the initial mount-time load has completed. Without this, an SSEchangeevent arriving before the firstload()finishes could double-fetch and clobber the in-flight result. - Refresh button typing fix —onClick={load}previously coerced the ReactMouseEventintoload's newopts?: { silent?: boolean }parameter; switched toonClick={() => load()}so the click handler discards the event and calls load with no opts (loud mode). - Latency: A new urgent inbox item now appears on the page within ~2s of the underlying file mtime change — same window as the bell badge.- Phase 46: Manual order claim for cross-email linking
- Closes the final mile of the Phase 16 deferred — "Email-based matching is the weak link — a customer who orders with john@gmail.com but reviews from a bazar account using john.work@example.com won't show as verified. The proper fix is a `bazarUserId` foreign key on `Order` set at checkout when a bazar user is logged in." Phase 22 (FK stamp at checkout) and Phase 39 (opportunistic email-match backfill on read) closed the easy cases. Phase 46 adds the user-controlled escape hatch for the case where the email used at checkout doesn't match the bazar account's email at all. - API (src/app/api/bazar/orders/claim/route.ts): -
POST /api/bazar/orders/claimwith body{ orderNumber, email }. - Walks every approved tenant'sorders[]for a match on(orderNumber, customerEmail)case-insensitively. Order numbers are tenant-scoped but the email pair disambiguates cross-tenant. - Strong claim rules: - Match must be on both order number and the email it was placed under — knowing only one isn't enough. - Refuses to overwrite an existingbazarUserIdon a foreign account →409 Already claimed. A customer with two bazar accounts has to use the right one; we never let account A evict account B's claim. - Same-user re-claim is idempotent (alreadyLinked: true). - Same404 Order not foundshape for "no such order" and "order belongs to a different account" so the response never confirms whether a number exists for someone else. - Rate limit viarequireRateLimit: 5 attempts per 10 minutes, keyed on the user ID (not IP) so a shared-NAT household isn't collectively locked out by one person's typo. Returns 429 withRetry-After. - Side effect: stampsbazarUserIdon the matched order. From this point on, the order surfaces normally in/api/bazar/orders/me, detail, cancel, reorder — and the review verification badge logic (Phase 22 two-tier check) finds the strong FK match. - UI (src/app/(bazar)/bazar/account/page.tsx/bazar/account/page.tsx)): - Collapsible "Missing an order?" form at the bottom of the Orders tab (and on the empty-state when the user has no claimed orders yet). - Two inputs (order number + email) + Claim button. Inline error / success messages with tone-coded styling (rose on miss, emerald on success). - On success the orders list reloads from page 1 so the newly-claimed order surfaces immediately. The user doesn't have to switch tabs or refresh. - Auto-handled responses: 429 → "Too many attempts — try again in a few minutes"; 409 → "This order is already linked to a different account"; 404 → "No matching order found. Double-check the number and email." - Composition with existing tiers: - Strong FK (Phase 22 stamp at checkout) → most orders go through here, no manual claim needed. - Email backfill (Phase 39 read-time match) → catches orders placed before account creation when emails happen to match. - Manual claim (Phase 46) → covers everything else. - Once stamped, all three paths converge on the samebazarUserIdsemantics for verification + history + ownership. - Why not auto-detect by name match or phone number — high false-positive rate + privacy concerns. Order number is high-entropy enough that requiring it as proof of possession is the right level of friction.- Phase 45: Edit options on a bazar cart line
- Closes the Phase 37 deferred polish — "No 'edit options' on a cart line — to change the options of a line in the cart, the customer removes it and re-adds from the detail page. Adding inline edit would require lifting the picker into the drawer or a modal; a future polish." Phase 45 picks a third path that's smaller than either: route to the existing detail page in edit mode, prefill the picker from the cart line's choices, and replace-on-save. - Why route instead of modal-in-drawer — the picker on the item detail page already has the full validation (
required,min,max), localised group/option names, isDefault seeding, live price math, and the option-error inline hint. Lifting it into the drawer would duplicate that logic + need its own modal mount + close handling. Routing reuses every line of existing picker code. - Edit button on cart line (src/components/bazar/BazarHeader.tsx): - Pencil icon, only rendered when the line has `selectedOptions` (plain items have nothing to edit; the existing remove + re-add path is fine). - Click handler closes the drawer + navigates to/bazar/restaurant/<slug>/item/<productId>?edit=<optsKey>&qty=<n>.optsKeyis the line's stable options signature;qtyis the quantity (so a "3 × Pepperoni" line that becomes 3 × Margherita after edit doesn't degrade to qty 1). - Item detail page ([src/app/(bazar)/bazar/restaurant/\[slug\]/item/\[productId\]/page.tsx](src/app/(bazar)/bazar/restaurant/[slug]/item/[productId]/page.tsx)): - `useSearchParams` readseditandqty.isEditMode = editOptsKey !== null. - Seed effect — in edit mode, looks up the matching cart line by(productId, restaurantSlug, optsKey)and replays itsselectedOptionsintoselectedByGroupinstead of seeding fromisDefault. If the cart line vanished (multi-tab edit, refresh after clear), gracefully falls through to the normal defaults seed. - Initial quantity picked up from theqtyparam. - Edit-mode banner above the picker — orange pill with ShoppingCart icon: "Editing your cart selection — Changes replace the line on Update." Removes the ambiguity of the renamed CTA. - CTA label flips — "Add to Cart" → "Update cart"; "Added!" → "Updated!". Price math unchanged. - Save logic —handleAddToCartremoves the old cart line by its originaleditOptsKeybefore adding the new variant. This means a no-op edit (user opened, didn't change anything, hit Update) still ends at the original quantity rather thanquantity + qty. After save werouter.back()so the customer ends up back in checkout / account / wherever they came from. - Hide cartEntry stepper in edit mode — if the user's edits land on the sameoptsKeythey came from, the page would normally render the in-cart qty stepper instead of the Add button (cartEntry !== undefined). In edit mode we always render the Add/Update button so the replace-on-save flow is unambiguous. - Merge semantics — if the user edits Line A's options to match an existing Line B (same product, different options that resolve to B's options), the remove-then-add cycle merges quantities into B. Two variants collapse to one with the combined qty — which is the only correct outcome. - Known limitations / deferred: - No "discard changes" path beyond browser back — the customer cancels by hitting Back. A dedicated Cancel button could be a future polish. - No edit affordance from `/bazar/checkout` directly — the cart edit button only lives in the header drawer. Checkout shows its own line summary; adding the pencil there is a small future polish.- Phase 44: Inline PATCH for saved campaign templates
- Closes the Phase 36 deferred polish — "PATCH deliberately omitted — editing means re-saving under the same name (delete + create). Inline edit deferred as a future polish; not load-bearing for the use case." Admins iterating on a recurring campaign now click an Edit pencil on the chip; the existing template values load into the form and the Save bar flips to "Update", PATCHing the same record so
createdAtandcreatedBysurvive. - API ([src/app/api/admin/feature-grants/templates/\[templateId\]/route.ts](src/app/api/admin/feature-grants/templates/[templateId]/route.ts)): - NewPATCHhandler — samerequireSuperAdmingate as DELETE. - Every body field is optional.nullclears an optional field (addonPriceMonthly,expiresInDays,notes);undefinedleaves it alone. Zod doesn't have built-in "optional + nullable" so we spell the union explicitly. This pattern lets the UI distinguish "user emptied the input" from "user didn't touch that input." -featureIdrevalidates againstFEATURE_REGISTRYwhen changed — a stale or renamed ID can't slip in during an edit. -createdBy/createdAtimmutable.updatedAtbumped to the current ISO timestamp on every successful PATCH, which the chip-row sort uses (newest first) so an edit bubbles the template to the top of the row. - 404 on missing template ID (idempotent — mirrors DELETE). - UI (src/app/admin/dashboard/features/page.tsx): - Edit pencil button added to each saved-template chip, between the load button and the delete ×. Clicking loads the template values into the form (same path asloadTemplate) AND switches the Save bar into edit mode by stashing the template ID in neweditingTemplateIdstate. - Save bar sprouts an "Editing" pill on the left + the action button label flips from Save to Update. Cancel exits edit mode without touching the form values, so the operator can keep their edits live without persisting them. - Chip highlight — the row being edited gets a purple ring so the operator knows where their changes will land if they hit Update. - POST vs PATCH branching —saveCurrentAsTemplateis one function that picks the verb + URL fromeditingTemplateId. Optional fields usenullin edit mode (to clear) andundefinedin create mode (to omit from the body the POST handler doesn't accept null). - Delete-while-editing — if the operator deletes the template currently being edited, the UI drops out of edit mode automatically so the next Save creates a fresh template instead of PATCHing a record that no longer exists.- Phase 43: Sound + haptic alerts on bazar order status change
- Closes the Phase 35 deferred polish — "No in-app sound or vibration on transition — the toast is silent visual only. A user-controlled 'sound on status change' preference could be a future polish but adds permissions / mute complexity." The visual flash from Phase 35 already surfaced status transitions, but customers leaving the tab open in the background got nothing. Phase 43 adds an opt-in audio cue + haptic buzz that fires alongside the toast. - Helper (src/lib/bazar/status-alerts.ts): - `fireStatusAlert(tone)` — plays a synthesised tone via Web Audio API. Positive tone (accepted / preparing / ready / delivered): 1175→1568 Hz triangle, 180ms — a bright "ding" with a small uptone making it feel like progress. Negative tone (rejected / canceled): 220→165 Hz sine, 320ms — low and sober. - No asset files. Oscillators synthesised on the fly. No network round-trip, no bundle weight, no licensing concern. - Lazy `AudioContext` — instantiated on first call after the user gesture (the Alerts toggle).
ensureRunningresumes from'suspended'state each play, which is what Safari + mobile Chrome require. - Vibration vianavigator.vibrate—[50, 30, 50]for positive,150for negative. Silently no-op on iOS Safari + most desktops. - Opt-in by default (bazar_status_alertslocalStorage key, missing → off). A surprise beep on someone else's phone is a bad first impression. - `playEnableChirp()` — short confirmation tone when the user flips alerts ON. Doubles as the user gesture that lets the AudioContext be created in the first place. - Cross-tab sync via abazar-status-alerts-changedCustomEvent so the toggle stays consistent if the user has the order open in multiple tabs. - Order detail page ([src/app/(bazar)/bazar/orders/\[orderId\]/page.tsx](src/app/(bazar)/bazar/orders/[orderId]/page.tsx)): - Alerts toggle pill appears next to the existing Live indicator in the header card. Bell icon when on (emerald), BellOff when off (gray). Title attribute explains the state. - Visible only while `isLive` — once the order reaches a terminal state (delivered / rejected / canceled), no more transitions are coming, so the toggle disappears rather than misleadingly hanging around. - Fire alongside the toast — the existing status-diffuseEffectnow also callsvoid fireStatusAlert(copy.tone)when a transition happens. Fire-and-forget; audio failure (autoplay policy block, missing AudioContext) cannot prevent the visual toast from rendering. - Why an explicit user gesture toggle, not "ask permission once" — the Web Notifications API needs a permission grant for cross-tab alerts; sound + vibration only need a user gesture. Skipping the Notifications API kept this phase tight + zero browser-permission UX. - Known limitations / deferred: - Tab-hidden autoplay — modern browsers may pause AudioContext when the tab is fully backgrounded. We resume on each play but can't override the policy. Users who minimise the tab and switch away may miss the sound; the haptic still fires on mobile. - No native push notification — for "you closed the tab but want to know your order is ready," the Push API + a service worker would be the right fix. Out of scope for a polish phase. - Volume control — the gain is fixed (peak 0.18). A future polish could add a slider; today users control via OS volume.- Phase 42: Geocoding on bazar saved addresses
- Closes the Phase 33 deferred polish — "No geocoding — saved addresses store free text. Distance / 'is in delivery zone' checks at checkout still rely on the legacy `addressText` + coords. Geocoding-on-save is a future polish that would feed delivery-radius validation per saved entry." Saved addresses now carry coordinates from the moment they're created so downstream features (radius validation, distance-based sorting, geo-hash bucket queries) can move off the user-level fallback. - Schema (src/lib/db/schema.ts) —
BazarSavedAddressgrows three optional fields: -coords?: { lat, lng }— geocoded coordinates. -geoHash?: string— precision-5 cell (~4.9 km) for fast radius-bucket queries via the existingradiusGeoHashSethelper. -geocodedAt?: string— ISO timestamp of last successful geocode. - All three move together — ifcoordsis present, the other two are too. Optional means legacy saved addresses + geocode-failure cases stay valid. - Helper (src/lib/bazar/geocode-saved.ts) —geocodeSavedAddress(addressText, postcode): - Composes address + postcode into a single query string (postcode improves geocoder accuracy materially; the upstreamgeocodeAddressonly takes a single string). - 3s hard timeout via aPromise.race. A slow / unconfigured geocoder cannot block the user's save. Timeout returnsnull. - Reuses the existing `geocodeAddress` (Google Maps viaGOOGLE_MAPS_API_KEY) — single source of truth for the provider. No new third-party dependency added. - Returns `null` when no key is configured, on timeout, or on any API failure. Callers persist the address without coords; radius checks fall back to user-level coordinates the way they did pre-Phase 42. - POST `/api/bazar/user/addresses` (route.ts) — calls the helper after zod-parse + before persist. If coords resolve, they're stored alongside the new address; the response carries them too so the UI can show "Verified location" immediately. - PATCH `/api/bazar/user/addresses/[addressId]` (route.ts) — re-geocodes only when `addressText` or `postcode` actually changed. Editing the label / notes / default flag does not trigger an external call. On geocode failure during edit we keep the previous coords rather than blanking — a transient API blip shouldn't strip good data that may still be valid. - Known limitations / deferred: - No Nominatim / OSM fallback — whenGOOGLE_MAPS_API_KEYis unset, geocoding is a no-op. Adding a public-service fallback to the save path would require User-Agent compliance + throttling (Nominatim caps at 1 req/sec) — out of scope. - Delivery-radius validation at checkout still uses the user-level coordinates / address text — Phase 42 lands the storage. Switching the checkout zone check to prefer the picked saved-address coords is a Phase that touches the order-submit handler and is left as a follow-up. - No backfill of pre-Phase-42 saved addresses — existing entries stay without coords until the user edits them (which triggers a re-geocode). A super-admin batch script could rip through unindexed addresses in one pass; deferred.- Phase 41: Sitemap image extensions for SEO
- Closes the second half of the Phase 29 deferred — "Tenant sitemaps stay 3 URLs deep — no product detail pages (route doesn't exist yet), no `<image:image>` extensions (Phase 29 deferred holds)." Product detail routes still don't exist (out of scope for this phase), but the
<image:image>extension half is shippable today: tenant sitemaps + platform sitemap now expose brand and product imagery to Google's image-rich result pipeline. - Sitemap image schema 1.1 (src/app/sitemap.xml/route.ts): -SitemapEntry.images?: string[]— optional list, capped at 10 per URL (Google indexes up to 1000 but the SEO marginal value drops off well before that). -buildXmllazily adds thexmlns:image="http://www.google.com/schemas/sitemap-image/1.1"namespace only when at least one entry has images. Sitemaps without images stay byte-identical to before — no behaviour change for tenants who haven't uploaded branding. - NewabsolutiseImage(url, base)helper resolves relative paths (/uploads/foo.jpg) against the canonical host. Google silently drops relativeimage:locvalues; this guards against that footgun. Data URIs (data:) are also dropped (not crawlable). - Tenant subdomain sitemap —tenantSitemap()now attaches: - Home `/` → logo + banner fromtdb.settings. - Menu `/menu` → up to 10 product images, sorted bypopularflag, filtered toavailable !== false. - Blog index `/blog` → hero image of the latest published post (representative). - Each blog post `/blog/<slug>` → that post's hero image. - Platform sitemap —platformSitemap()now attaches: - Each tenant home `/tenant/<id>` (or `customDomain`) → logo + banner. - Each bazar restaurant `/bazar/restaurant/<slug>` → logo + banner. - Per-product images at the platform level are intentionally skipped — the platform sitemap is already large (every tenant + designer + supplier + bazar listing + docs article), and the per-tenant subdomain sitemap already carries the menu image detail. Avoids doubling the platform sitemap byte count. - Read-cost optimisation — the bazar restaurant loop previously did onegetTenantDBper restaurant for city extraction. Phase 41 was about to add a second read per restaurant for images; folded both into a single read instead. No regression vs. Phase 29; better with images included. - Empty-case safety — entries with no resolvable images get noimage:imageblock, not an empty one. Spec compliance + cleaner XML. - Known limitations / deferred: - Product detail routes still don't exist —/tenant/<id>/menu/<productId>is unbuilt. Adding them is a Phase that touches the rendering tree, not the sitemap. The bazar already has detail routes (Phase 7-ish) which are listed. - Image freshness vs. CDN caching —image:lastmodis supported by the schema but we don't emit it. Tenants typically upload to a stable URL and overwrite, so the parent URL's<lastmod>suffices as a recrawl hint.- Phase 40: Real-time push for inbox + dashboard notifications
- Closes the Phase 19 and Phase 20 deferred items together — both said "No real-time push — page polls on visit / refresh. Adequate for v1; could wire SSE in a future phase if the operator's expectation tightens." Operators had to wait up to 30s for a new order to surface in the header bell or for inbox urgency to refresh. Phase 40 collapses that to ~2s. - SSE endpoint (src/app/api/dashboard/inbox/stream/route.ts): -
GET /api/dashboard/inbox/stream?tenantId=...— server-sent events withevent: helloon connect,event: changewhen watched files mutate,event: keepaliveevery 25s to defeat proxy idle timeouts. - Watched paths:databases/tenants/<tenantId>.json(orders / reservations / tenant reviews / feature-grant log) anddatabases/food-bazar.json(bazar reviews + listing-side changes that surface in the inbox). - Change detection viafs.statmtime poll every 2s rather thanfs.watch— fs.watch is unreliable on Windows network drives and Docker bind mounts (our deployment surface). A stat-per-tick is negligible cost and the 2s ceiling is well below the prior 30s poll. - Why pings instead of payloads — the stream doesn't push data; it just says "something changed, re-fetch." Aggregation logic stays in the existing/api/dashboard/inboxand/api/tenant/[id]/notificationshandlers (single source of truth for shape + auth filtering). The new endpoint adds zero duplication. - Auth gated byrequireTenantAccess(tenantId). Tenants can't tap each other's signal streams. - Clean teardown —req.signal.addEventListener('abort', ...)clears both intervals + closes the controller when the browser tears downEventSource(tab close, navigation, network drop). - Shared client hook (src/hooks/useTenantPushSignal.ts): -useTenantPushSignal(tenantId)→{ pushedAt: number, connected: boolean }.pushedAtis an epoch that ticks every time the server firesevent: change. - Browser HTTP/2 multiplexing folds duplicateEventSourceconnections to the same URL onto a single TCP stream, so even though bothInboxBellandTopHeaderopen their own connections, the network sees one. No shared-context plumbing needed — premature optimization. - Auto-reconnect comes for free fromEventSource(browser handles transient disconnects). - Server-side guard: emptytenantId→ no connection (used during initial mount before tenant context resolves). - InboxBell (src/components/dashboard/InboxBell.tsx) — subscribes via the hook and re-fetches onpushedAtchange. Falls back to the existing setInterval poll (now 60s, was 30s) as the safety net when SSE is errored / unsupported. Visibility-change listener kept for cold-tab returns. - TopHeader notifications (src/components/dashboard/TopHeader.tsx) — same pattern. Optimistic local-read set preserved so the badge never bounces during in-flight PATCH writes. - Latency, before → after: new order placed → header bell badge updates ~30s → ~2s in the happy path. Inbox urgency counter same window. - Cost profile: one long-lived SSE connection per operator tab (multiplexed by HTTP/2). 2s file-stat per connection on the server. No external broker (Redis, NATS) introduced — keeps the deployment surface unchanged. - Known limitations / deferred: - Cross-process invalidation not handled — if the JSON-DB write happens in a different Node process (or a future Postgres-backed deployment), the file mtime won't change. Out of scope for the current file-DB topology; the SSE shape is forward-compatible with a pub/sub backend swap. - The full-screen `/dashboard/inbox` page still polls on visit only. It's a destination page, not background UI — adding SSE there is a future polish.- Phase 39: Opportunistic `bazarUserId` backfill for legacy orders
- Closes the Phase 22 deferred polish — "Legacy orders stay unlinked — they were placed before `bazarUserId` existed. A future polish could backfill `bazarUserId` on legacy orders by matching `customerEmail` against current bazar users (low-priority)." Customers who created a bazar account after their first order saw an empty "My orders" page even though their email matched orders sitting in tenant DBs. - Read-time backfill, not a batch script — every bazar-side order endpoint (history list, detail, cancel, reorder) does the email match inline and stamps
bazarUserIdon the matched order. No admin button to forget; no cron to monitor; no migration to run. Once a customer logs in and lands on/bazar/orders, every legacy order they touch becomes a strong-linked, first-class order on the way out. - Files: - src/app/api/bazar/orders/me/route.ts — history list. For every tenant scanned, the loop now also matchescustomerEmail.toLowerCase() === user.email.toLowerCase()whenbazarUserIdis missing, stamps the link in memory, and persists one save per tenant DB at the end of that tenant's loop. Best-effort persistence — asaveTenantDBfailure logs nothing (intentionally — the in-memory match already produced a correct response; next read retries). - [src/app/api/bazar/orders/\[orderId\]/route.ts](src/app/api/bazar/orders/[orderId]/route.ts) — detail. Same ownership-leak guard (404on mismatch) but the gate also accepts an email match whenbazarUserIdis absent, then writes the link back. - [src/app/api/bazar/orders/\[orderId\]/cancel/route.ts](src/app/api/bazar/orders/[orderId]/cancel/route.ts) — cancel. Same gate. Stamping happens before the delegatedprocessCustomerCancelcall, so subsequent cancel side-effects (refund posting, risk scoring) see a fully-linked order. - [src/app/api/bazar/orders/\[orderId\]/reorder/route.ts](src/app/api/bazar/orders/[orderId]/reorder/route.ts) — reorder. Same gate. - Why this is safe: - Email ownership is proven by the verified bazar session cookie (getBazarSession) — we don't trust the request body's email, only the database-bound session. - Bazar user emails are unique (the bazar register endpoint enforces it), so an email match resolves to exactly one user. - The 404-over-403 ownership pattern stays intact — a mismatch still returns 404 with no enumeration leak. - No retro-stamping of strong-linked orders — orders that already havebazarUserId(even a different one) are untouched. The fallback only fires whenbazarUserIdis absent. - Performance: the email comparison happens during thetdb.ordersloop the API already runs. Zero extra DB reads. One additionalsaveTenantDBper tenant only when there are matches to persist (dirty flag gates the save).- Phase 38: Bazar source filter on operator reviews dashboard
- Closes the Phase 17/20 deferred polish — "Bazar reviews link to `/dashboard/reviews` — that page already exists for tenant reviews; the bazar source filter inside it is a future polish." Reviews from both surfaces (the tenant's own website and the Food Bazar marketplace) have always landed in the same
db.reviews[]array tagged withsource: 'tenant' | 'bazar', but the dashboard treated them as one undifferentiated pile. Operators couldn't see "just my marketplace reviews" to answer "how is Bazar treating me?" - API ([src/app/api/tenant/\[tenantId\]/reviews/route.ts](src/app/api/tenant/[tenantId]/reviews/route.ts)) — newsourcequery param (all/tenant/bazar). Legacy reviews without asourcefield are treated astenant(the only review surface that existed pre-Phase 16). Counts response gains abySource: { tenant, bazar }block. Counts are unconditional on the status filter so the per-source totals don't jump when the operator switches between Pending / Approved. - UI (src/app/(dashboard)/dashboard/reviews/page.tsx) — new segmented control to the right of the status filter: All sources / Site (Globe icon) / Bazar (Store icon). Each tab carries its own count badge. - Conditional visibility — the source filter is hidden whenbySource.bazar === 0. Tenants that don't list on the marketplace see exactly the UI they had before; the polish only surfaces once it's relevant. No "Bazar (0)" tab cluttering the dashboard for self-hosted-only restaurants. - Status + source compose — picking "Pending" + "Bazar" lists Pending bazar reviews only; counts stay stable across switches.- Phase 37: Options-aware bazar cart + reorder
- Closes the Phase 26 and Phase 32 deferred items in one stroke — "Reorder doesn't carry selected options" (Phase 26) and "No options-aware re-submission — `selectedOptions` aren't passed because the bazar cart hook doesn't track them. Closing both needs an upgrade to `useBazarCart`." (Phase 32). Both deferreds blocked the same root cause: the bazar
CartItemwas option-blind. Phase 37 upgrades the hook and threads the data through every surface that touches it. - Cart hook upgrade (src/hooks/useBazarCart.ts): - NewSelectedCartOptioninterface (groupId,groupName,optionId,optionName,priceAdjustment) — stores enough to render chips, compute totals, and rebuild the API payload without re-fetching the product. -CartItem.selectedOptions?: SelectedCartOption[](optional — backwards compatible with carts already in localStorage). - Stable option signature viaoptionsKeyFor(opts): sortgroupId:optionIdpairs and join. Two pizzas with the same toppings merge into one line at quantity 2; the same pizza with different toppings lives as two separate cart lines. The sort is what makes this robust — clicking checkboxes in a different order doesn't fork two identical lines. - Line identity is nowproductId + restaurantSlug + optionsKey(lineKey). The legacy two-arg lookup (productId + slug) still works for option-less items by defaultingoptsKey = '', so no caller breaks if they aren't option-aware. - New helpers:optionsAdjustmentTotal(opts)andunitPriceWithOptions(item)so every consumer computes the same effective price. - API exposure ([src/app/api/bazar/restaurant/\[slug\]/route.ts](src/app/api/bazar/restaurant/[slug]/route.ts) + src/app/api/bazar/menu/route.ts): - Restaurant detail API now flattens each product'soptionGroupsinto picker-friendly shape (localisedname, kepttype/required/min/max, options withid/name/priceAdjustment/isDefault). Translation happens server-side so every client gets the same view. - Landing / menu aggregator addshasOptions: boolean(computed fromproduct.optionGroups?.length). This is enough for the landing card to flip its "+" CTA without paying for the full picker payload on every grid card. - Item-detail picker ([src/app/(bazar)/bazar/restaurant/\[slug\]/item/\[productId\]/page.tsx](src/app/(bazar)/bazar/restaurant/[slug]/item/[productId]/page.tsx)): - NewselectedByGroup: Record<string, Set<string>>state per group. - Seeded defaults: any option flaggedisDefaultis pre-checked on load (this is the "removable ingredients" convention — onions start checked, customer unchecks them). For required radio groups with no default, the first option is pre-picked so customers don't see a red error before clicking. - Validation: every required group must have at leastmin(or 1) selections; no group may exceedmax(when > 0). Errors surface inline above the add button with the specific group name. Add button is disabled while any error stands. - Live price includes option adjustments —activePrice = basePrice + optionAdjust, and "Add to Cart · $X.XX" reflects the running total per quantity. - Radio with `required: false` allows deselect (single-click toggles off); strict required radios swap selection but never go empty. - Restaurant menu CTA ([src/app/(bazar)/bazar/restaurant/\[slug\]/RestaurantClient.tsx](src/app/(bazar)/bazar/restaurant/[slug]/RestaurantClient.tsx)) — items with option groups can no longer be quick-added from the list (would silently skip required choices and undercharge for paid extras). Clicking "+" now routes to the detail page where the picker lives. Option-less items still quick-add inline. - Landing card (src/app/(bazar)/bazar/page.tsx/bazar/page.tsx)) — same treatment:hasOptions=trueflips the "+" icon to a chevron and lets the parent<Link>navigate to the detail page instead of eating the click + quick-adding. - Cart drawer chips (src/components/bazar/BazarHeader.tsx) — each cart line surfaces a·-separated list of option names under the product. Quantity controls now thread the line'soptionsKeyso bumping the +/− on a Pepperoni line doesn't accidentally walk a Cheese line's quantity. Undo-clear restoresselectedOptionsso a recovered cart isn't degraded. - Checkout (src/app/(bazar)/bazar/checkout/page.tsx/bazar/checkout/page.tsx)) — order summary shows option chips and usesunitPriceWithOptionsfor the per-line total + subtotal. POST payload translates the cart'sSelectedCartOption[]into theRecord<groupName, optionLabels[]>shape the public order route already expects (/api/public/[tenantId]/orders). Server is the source of truth for pricing — it re-resolves labels against the product's liveoptionGroupsand recomputes adjustments, so a stale localStorage cart can't undercharge. - Reorder ([src/app/api/bazar/orders/\[orderId\]/reorder/route.ts](src/app/api/bazar/orders/[orderId]/reorder/route.ts) + [src/app/(bazar)/bazar/orders/\[orderId\]/page.tsx](src/app/(bazar)/bazar/orders/[orderId]/page.tsx)) — past-orderselectedOptionsget re-resolved against the product's currentoptionGroupsby matchinggroupName + optionName(the historicalOrderItemonly stores labels, not IDs). Successfully matched options come back as a structured array ready foraddItem. Anything dropped (merchant removed the group or renamed the option) surfaces in a newwarnings[]array, and the toast says "N with changed options" so the customer reviews before checking out. - Pricing trust model: client computes prices for display only. The server (/api/public/[tenantId]/ordersand the payment-intent / PayPal cousins) re-resolves option labels against the product's currentoptionGroupsand recomputesunitPrice = baseUnitPrice + optionAdjustmentauthoritatively. A user editing localStorage to drop a $5 "extra cheese" adjustment will still be charged for it. - Known limitations / deferred: - Tenant menu page is unchanged — this phase only touches the bazar (marketplace) surface. The tenant-direct menu (/tenant/[id]/menu) already had its own option-aware flow; that path is unchanged and stays separate. - Cart migration — carts already in localStorage from before Phase 37 stay valid (noselectedOptionsmeans an empty options key, which matches the legacy "no options" line). No reset / clear required. - No "edit options" on a cart line — to change the options of a line in the cart, the customer removes it and re-adds from the detail page. Adding inline edit would require lifting the picker into the drawer or a modal; a future polish.- Phase 36: Saved campaign templates for bulk feature grants
- Closes the explicit Phase 15 deferred item — "No saved campaign templates — recurring campaigns must be reconfigured each time." Recurring grants (annual "Holiday Promo" upsell, monthly "free Premium trial for new signups", etc.) previously meant picking the same feature + setting the same expiry + reselecting the same tenant cohort from scratch every cycle. Phase 36 lets admins save the whole campaign config as a named, reloadable template. - Schema (src/lib/db/schema.ts): new
CampaignTemplateinterface andMasterDB.campaignTemplates?: CampaignTemplate[]array. Optional field — backwards compatible with existing master DB files (no migration needed). - Stored fields:featureId,enabled(grant vs revoke),isAddon,addonPriceMonthly,expiresInDays,notes, andtargetPreset(all/basic/premium/enterprise/no-plan/active-only/custom). - Relative expiry, not absolute — templates storeexpiresInDays(e.g. 30) rather than a fixed date. When loaded next quarter, the campaign expires 30 days from that day, not 30 days from when the template was first saved. Without this, a "30-day trial" template saved in January would create already-expired grants when reused in May. - Target by preset, not by tenant ID — templates store the preset name (e.g.premium) instead of the resolved tenant ID list. A "Premium upgrade" template saved when there were 10 Premium tenants automatically picks all 14 when reloaded next month. Custom selections fall back to whatever the operator has currently checked. - API (src/app/api/admin/feature-grants/templates/route.ts + [\[templateId\]/route.ts](src/app/api/admin/feature-grants/templates/[templateId]/route.ts)): -GET— returns templates sorted byupdatedAtdesc. -POST— creates a template. ValidatesfeatureIdagainstFEATURE_REGISTRY(refuses unknown features so a registry rename doesn't leave dangling templates). Hard cap of 50 templates per platform. -DELETE /[templateId]— removes. Idempotent — missing template returns 404 so the caller knows something was off. -PATCHdeliberately omitted — editing means re-saving under the same name (delete + create). Inline edit deferred as a future polish; not load-bearing for the use case. - Auth: all three endpoints gated byrequireSuperAdmin— verified via smoke probes (GET=401,DELETE=401anon). - UI (src/app/admin/dashboard/features/page.tsx) —CampaignBuildercomponent grows three new surfaces: - Save Template button in the header (BookmarkPlus icon). Toggles an inline name input. Disabled until both a feature is picked and a name is entered. - Saved templates row appearing above the preset bar. Each template is a chip showing the name; hover surfaces the feature ID, preset, and notes via title attribute. Clicking re-applies preset → fills feature, enabled, isAddon, addon price, expiry (computed fromexpiresInDays), and notes into the form. Per-chip × delete button. - Preset tracking — clicking a preset button (All / Basic / Premium / etc.) now stampslastPresetwith the preset key; toggling any individual tenant checkbox stampslastPreset = 'custom'. Save reads fromlastPreset, so a template saved after manual edits replays as "custom" (preserves current selection rather than replacing with a stale preset).- Phase 35: Status-change toast on bazar order detail
- Closes the explicit Phase 27 deferred item — "No 'your order is ready!' toast — status change between polls is currently silent. A diff-against-previous render could surface a one-time success burst when status flips." Phase 27 shipped live polling but customers never saw the change; the badge just flipped while they were reading something else. Phase 35 surfaces the transition as a celebratory (or honest, for bad news) flash at the top of the page. - Status diff logic: a
useRefcarries the last observed status across renders. The first observation primes the ref without firing — we don't want a "your order is accepted!" toast on the initial page load when the order already moved past that state. Only real transitions surface. - Curated copy per status: -accepted→ "Order accepted! The restaurant is on it." (positive) -preparing→ "It's being made now / Your food is in the kitchen." (positive) -ready→ "Your order is ready! / Pickup time, or the driver is en route." (positive) -delivered→ "Delivered — enjoy 🎉 / Tell us how it was when you can." (positive) -rejected→ "Order rejected / The restaurant couldn't accept this order." (negative) -canceled→ "Order cancelled / You'll see any refund in your account." (negative) - Unmapped statuses don't trigger — keeps a future enum addition from spawning a generic flash. - Toast UI: - Fixed positioning at top-center, max-w-md, z-50, pointer-events isolated to the toast itself so the page below stays interactive. - Two tones: emerald for positive transitions, rose for negative. - Bell icon + headline + sub copy + a dismissXbutton. - Auto-dismiss after 6s via a separateuseEffect(so manual dismiss still works during the timer). - `role="status" aria-live="polite"` so screen readers announce the transition non-disruptively. - Slide-in animation via Tailwind's arbitraryanimate-[fadeInDown_200ms_ease-out](animation keyframes already in the Tailwind config from earlier polish). - Verification: - TypeScript: 0 errors (clean rebuild after one transient cached.next/dev/types/routes.d.tsglitch) -next build: ✅ green - HTTP probe:/bazar/orders/ord_x→ 200 ✓ - Known limitations / deferred: - No in-app sound or vibration on transition — the toast is silent visual only. A user-controlled "sound on status change" preference could be a future polish but adds permissions / mute complexity. - No persistence across reloads — if the user closes the tab between two polls, the transition happened but they won't see the toast on the next visit. Acceptable: the live status badge tells the current truth. - No stack of toasts — if two rapid transitions happen between polls (unlikely with 30s cadence but possible after a tab-focus re-poll), only the latest fires. Trade-off for visual simplicity. - Copy isn't localised yet — English hardcoded. Lives alongside the rest of the bazar surface which is also single-language for now.- Phase 34: Bazar checkout — coupons + gift cards + saved-address notes pass-through
- Three tight polishes closing the deferred items from Phases 32 and 33. The public order route already accepted
couponCode,giftCardCode, andnotes— Phase 34 just builds the UI that uses them. - Coupon input on/bazar/checkout: text field withBadgePercenticon. Auto-uppercases as the user types ("welcome10" → "WELCOME10"), 50-char cap. Submitted ascouponCodeon the order body. Server resolves via the existingresolveCouponengine; invalid coupon silently drops without rejecting the order (matches the existing public-route behaviour). - Gift card input withGifticon, same uppercase-on-type + 40-char cap. Submitted asgiftCardCode. Server validates viagetCardByCode+previewApply; invalid gift card returns 400 and surfaces in the existingsetSubmitErrortoast — copy is honest about this asymmetry: "Invalid coupons silently drop; invalid gift cards block submission — clear the field if you don't want to use one." - Saved-address notes pass-through (Phase 33 deferred item) — when checkout uses a saved address that hasnotes(doorbell / floor / etc.), those are prepended to the user-entered notes with a·separator before submit. So a kitchen ticket sees "Apt 4B, ring twice · No coriander please" instead of losing the saved hint when the user types their own one-off note. Picking "+ New" or editing the address manually opts out of the pass-through automatically (the picker flips to'new'). - No client-side validation: the page doesn't preview discounts or gift-card balances. The server is authoritative and the response shows the resolved totals once the order lands. This keeps the checkout page fast and trust the existing order-creation pipeline. - Verification: - TypeScript: 0 errors -next build: ✅ green - HTTP probe:/bazar/checkout→ 200 ✓ (form renders; backend submission flow unchanged since Phase 32) - Known limitations / deferred: - No client-side discount preview — the user types a coupon code and only sees the discount after submit succeeds. APOST /api/public/[tenantId]/coupons/previewwould let us show "Applies $X off" inline. Polish. - Gift card balance not displayed — same reason. Could surface "$25 remaining" beside the code once we expose a balance check endpoint. - No coupon code from URL — campaigns often send?coupon=BLACKFRIDAYquery params; we don't auto-fill yet. One-line addition. - Notes merger is naive — uses a simple·join. Doesn't dedupe if the user types the same note as the saved one or handles multi-line saved notes gracefully. Acceptable for v1.- Phase 33: Bazar saved address book
- Closes a long-standing real-world gap mentioned across Phases 22 and 32. Bazar users had a single
addressTextfield for life — fine for cold-start onboarding, broken the moment anyone orders to both Home and Work. Phase 33 gives them a managed address book with default-selection invariant. - Schema (BazarUser.savedAddresses?: BazarSavedAddress[]):{ id, label, addressText, postcode?, notes?, isDefault, createdAt, updatedAt }. The legacyaddressTextstays as the geocoded address-of-record for radius queries; saved addresses are the customer-facing ones. - `GET /api/bazar/user/addresses`: returns the authenticated user's list (empty array for users who haven't added any yet). 401 anon. - `POST /api/bazar/user/addresses`: creates a new address. Body:{ label, addressText, postcode?, notes?, isDefault? }. First address auto-defaults; subsequentisDefault: trueatomically clears the previous default to preserve the "at most one default" invariant. Limit 10 per user. - `PATCH /api/bazar/user/addresses/[addressId]`: partial update. SettingisDefault: trueclears the previous default; settingisDefault: falseon the only default-bearing entry returns 400CANNOT_REMOVE_DEFAULT(the invariant requires exactly one default when the list is non-empty). - `DELETE /api/bazar/user/addresses/[addressId]`: removes the entry. If the removed entry was the default and others survive, the most recently-updated remaining entry is promoted to default automatically — so the invariant always holds without surprising the user. - Ownership opacity: missing-ID and not-yours both return 404 (never 403) so probes can't enumerate other users' address IDs. - UI on `/bazar/account` (Profile tab): new<AddressBook />block below "Saved Location". Lists each saved address with label, full address, optional notes, and a "Default" badge on the active one. Buttons:Default(when not),Delete, and an+ Addbutton at the header that opens an inline form. - UI on `/bazar/checkout`: when the bazar session resolves, the page now ALSO fetches the saved address list. Default is auto-selected and prefills the delivery fields. A pill row above the address inputs lets the customer swap between saved entries or pick+ New(which clears the fields and unlocks the manual inputs). Any manual edit auto-flips the picker toNewso the saved entry isn't silently mutated. - Anonymous + new-user paths intact — no saved addresses → picker is hidden, the manual fields render as before. Phase 32's flow degrades cleanly when the user has no signal yet. - Verification: - TypeScript: 0 errors -next build: ✅ green; both new routes registered - HTTP probes: GET → 401 without bazar session (auth gate ✓); POST/PATCH/DELETE all share the same auth-required pattern - Known limitations / deferred: - No geocoding — saved addresses store free text. Distance / "is in delivery zone" checks at checkout still rely on the legacyaddressText+ coords. Geocoding-on-save is a future polish that would feed delivery-radius validation per saved entry. - Notes field isn't passed through to the order — saved-addressnotes(doorbell / floor) don't propagate into the order'snotesfield on checkout. One-line fix during the submit handler; left as a Phase 34 polish so this phase stays tight. - No address-merging on duplicate add — submitting two identical addresses creates two rows. Acceptable for v1. - No reorder of entries — they're shown in insertion order with the default first. Drag-to-reorder is polish.- Phase 32: Bazar checkout flow — completes the customer order arc end-to-end
- Closes a real broken state that none of Phases 22-27 caught: the "Proceed to Checkout" button in the bazar cart drawer had no `onClick` handler. Customers could fill a cart but had no way to actually submit an order. Every UX surface we polished after that point (My Orders, order detail, live polling, cancel, reorder) was reachable only by directly editing seed data. Phase 32 wires the loop closed. - `/bazar/checkout` page (new) — single-restaurant cart submission. - Reads cart via `useBazarCart`, groups items by
restaurantSlug(cart can hold items from multiple restaurants), picks the active group from?slug=param or auto-selects when there's only one. - Multi-restaurant interstitial: when the cart spans multiple restaurants AND no?slug=, renders a "Order from one restaurant at a time" picker with per-restaurant subtotals; each row links to/bazar/checkout?slug=…. Other restaurants stay in the cart after the active one is checked out. - Bazar-session prefill:/api/bazar/auth/mepopulates name + email + saved address (when signed in) so logged-in users see one-tap checkout. - Restaurant resolution:/api/bazar/restaurant/[slug]fills in delivery fee, min order, openNow, delivery vs pickup availability. Open/closed and min-order warnings render before the customer can hit submit. - Form: order type (Delivery / Pickup, disabled when restaurant doesn't offer it) → customer name/email/phone → delivery address + postcode (only when Delivery) → notes → payment method (Cash / Card). - Submit POSTs to the existing/api/public/[tenantId]/ordersroute. Phase 22'sgetBazarSessioninside that handler stampsbazarUserIdon the new order, so the order automatically threads into Phase 22's My Orders, Phase 25's detail page, Phase 27's live polling, and Phase 21's verified-purchase signal. - Cart cleanup after success: only items from the just-checked-out restaurant are removed; the rest stay so a multi-restaurant cart can be handled one order at a time. Persists via the samebazar_cartlocalStorage key the hook uses and fires thebazar-cart-updateevent to refresh the header badge. - Redirects to `/bazar/orders/[orderId]` — the Phase 25 detail page picks up with the live status badge (Phase 27) already polling. - Cart drawer wiring: the "Proceed to Checkout" button inBazarHeader.tsxis now a<Link href="/bazar/checkout">that closes the drawer on click. (Single-line change with multi-phase impact — the whole arc from Phases 22-27 now reachable from the cart.) - Verification: - TypeScript: 0 errors -next build: ✅ green;/bazar/checkoutregistered - HTTP probe:/bazar/checkout→ 200 (client-rendered cart-empty / multi-restaurant / form states branch correctly) - Known limitations / deferred: - No coupon / gift-card UI on the bazar checkout. The underlying API supports both — wiring the inputs is a future polish. - No address book (Phase 22 deferred holds) — single saved address fromBazarUser.addressTextonly. - Stripe payment is "Card" placeholder — submits withpaymentMethod: 'stripe'but no actual Stripe Elements integration. The escrow + hold logic on the public order endpoint runs as if payment succeeded; production would need the real Stripe redirect dance. Acceptable for the multi-vendor marketplace prototype. - No options-aware re-submission —selectedOptionsaren't passed because the bazar cart hook doesn't track them. Aligns with the Phase 26 deferred item ("Reorder doesn't carry selected options"). Closing both needs an upgrade touseBazarCart. - No tax calculation on the bazar side — tax falls through to the public order route's existing logic.- Phase 31: Cold-start recommendations + Verified-only review filter
- Closes one deferred item from Phase 28 and one from Phase 21 in a single bazar-side polish. - Cold-start fallback for `/api/bazar/recommendations` (Phase 28 deferred — "No cold-start fallback — new users with no preferences and no favourites get no recommendations"). - When the signal-based scorer (preferredCuisines + favouriteCuisines) returns zero matches, the API now falls back to "Popular this week" — every approved listing ranked by paid-order count in the last 7 days. - Reason chip on each card switches from "Matches your love of X" to "5 orders this week" (or "New on the marketplace" when zero). - Excludes restaurants the user has already favourited or reviewed (same gate as signal mode), so the surface stays fresh. - Response now carries a
mode: 'signal' | 'cold-start'discriminator. - `RecommendationRail` UI: reads themodefield and swaps both the title ("Recommended for you" → "Popular this week") and the subtitle so users see why the picks changed. Visual identity stays indigo — same rail, signaled state. - Verified-only review filter (Phase 21 deferred — "No verified-only filter chip on the public review list yet"). -GET /api/bazar/restaurant/[slug]/reviewsnow accepts?verified=1(alsotrue/yes). Filters the returnedreviews[]to those withverifiedPurchase === true. - Headline summary always reflects the unfiltered approved set so the star average doesn't bounce when users toggle the chip. Response shape extended withsummary.verifiedCountandfilter.verifiedOnlyso the client renders chip labels without a second round-trip. -BazarReviewsUI: two-button toggle (All / Verified only) above the review list, with the verified count on the button. Pagination preserves the filter across pages. Toggle is hidden when `verifiedCount === 0` so we never advertise a filter that resolves to empty. - Verification: - TypeScript: 0 errors -next build: ✅ green - HTTP probes: - AnonymousGET /api/bazar/recommendations→{ recommendations: [] }(still anonymous-safe, mode field omitted on empty) -GET /api/bazar/restaurant/luigi/reviews?page=1→ 2 reviews (1 legacy tenantverifiedPurchase: false, 1 bazartrue) -GET /api/bazar/restaurant/luigi/reviews?page=1&verified=1→ 1 review (only Tom's bazar review withverifiedPurchase: true) — confirms the filter ✓ - Known limitations / deferred: - "Popular this week" rolls over every tenant DB to count orders + ratings — same scale caveat as Phase 28's signal mode. A precomputed(tenantId, weekStart) → orderCountindex would help past ~1000 listings. - "This week" hardcoded to 7 days — could be a per-tenant or platform setting. - Cold-start ignores rating quality at the score level — a brand-new restaurant with 1 order outranks a 4.9-star one with 0 recent orders. Conscious choice: the rail is about momentum, not popularity-all-time. The rating still appears on each card so users can judge. - No "Verified only" filter on the operator-side reviews dashboard — would mirror the bazar chip on/dashboard/reviews. Polish.- Phase 30: Custom-domain support for tenant sitemap + robots.txt
- Closes Phase 29's most concrete deferred item: "Custom-domain tenants (e.g.
www.luigis-pizzeria.com) still hit the unknown-subdomain branch and serve a blocked robots." A premium tenant whose merchant points DNS at their own domain now gets a proper tenant-scoped robots + sitemap on that domain — same shape as the subdomain branch but anchored on the custom hostname. - `src/lib/seo/host-resolver.ts` (new): single shared resolver consumed by both/sitemap.xmland/robots.txt. -PLATFORM_HOSTSconstant moves here (was duplicated in both routes +/llms.txt). -resolveHostToTenant(host, tenants)returns a tagged union:{ kind: 'platform' } | { kind: 'tenant', tenant, matchedVia: 'customDomain' | 'subdomain' } | { kind: 'unknown' }. - Lookup priority: custom-domain exact match (case-insensitive, scheme stripped, trailing-slash stripped, with bi-directionalwww.matching so an operator who stores the bare apex still matches awww.request and vice versa) → subdomain match against leftmost host label → unknown. - `/sitemap.xml` and `/robots.txt` refactor to delegate host resolution to the shared helper. Behaviour: - Custom-domain match → same tenant-scoped sitemap (home + menu + reservations + blog when present) and tenant-scoped robots, but with<loc>andSitemap:lines anchored on the custom domain. - Unknown host → empty sitemap +Disallow: /robots (unchanged from Phase 29). - Platform / subdomain branches unchanged. - Seeder update (scripts/seed-everything.js): Luigi getscustomDomain: 'luigis-pizzeria.example'so the Phase 30 smoke test exercises the new branch. - Verification: - TypeScript: 0 errors -next build: ✅ green - HTTP probes with spoofedHost: luigis-pizzeria.example: -/sitemap.xml→ 3 URLs anchored onluigis-pizzeria.example(notluigi.localhost) ✓ -/robots.txt→ tenant-scoped allow + disallow + sitemap reference pinned to the custom domain ✓ - Previous-phase probes still pass: platform 163 URLs;nobody.localhostblocked;luigi.localhost→ 3 URLs. - Known limitations / deferred: - No multiple custom domains per tenant —tenant.customDomainis a single string. Migrating to an array would handle apex + www + locale variants (.com,.co.uk, etc.). Polish. - No domain verification flow — the seededluigis-pizzeria.exampleis trusted by-definition. Production needs the operator to prove DNS ownership before customDomain is honoured. Lives elsewhere in the codebase (tenant settings UI), not in scope here. - AI crawler allow-list still duplicated between platform + tenant variants ofrobots.txt. Small DRY follow-up. - Tenant sitemaps stay 3 URLs deep — no product detail pages (route doesn't exist yet), no<image:image>extensions (Phase 29 deferred holds).- Phase 29: Per-tenant subdomain `/sitemap.xml` + `/robots.txt`
- Closes a real SEO discovery gap — both files previously served the same monolithic platform-wide content regardless of host, so
luigi.restora.app/sitemap.xmlreturned 163 URLs that mostly didn't belong to that host (designers, suppliers, docs, bazar listings on the platform host, etc.). Google + AI crawlers treated this as cross-host noise rather than canonical declarations. - Convention switch: replaces Next'sMetadataRoutesitemap()+robots()static-generation convention with explicit host-aware route handlers atapp/sitemap.xml/route.tsandapp/robots.txt/route.ts. Same pattern Phase 6's/llms.txtalready uses. Old.tsfiles removed. - Host detection mirrors Phase 6: aPLATFORM_HOSTSset (localhost:3001,restora.io,restora.app, etc.) + naive subdomain extraction from theHostheader. The proxy treats files with dots as static assets and skips rewrites, so the route self-detects. - `/sitemap.xml` branches: - Platform host → unchanged from Phase 11 + Phase 20: static routes + designers + suppliers + chains + tenants + bazar surfaces (cuisine + city + combo + restaurant). 163 URLs after the Phase-everything seeder. - Known tenant subdomain → home (/) + menu + reservations + (when blog feature has published posts) the blog index + per-post URLs. 3 URLs for Luigi today; grows with content. - Unknown subdomain OR demo/inactive tenant → empty<urlset>. Explicit "nothing canonical here" signal rather than a fallback to the platform list (which would be wrong). - `/robots.txt` branches: - Platform host → unchanged disallow list (admin / API / checkout / tracking / dev paths) + 7 explicit AI crawler allowances +Sitemap: ${BASE}/sitemap.xml+Host: ${BASE}. - Known tenant subdomain → tenant-scoped disallow (/checkout,/order-confirmation,/account,/api/,/_next/) + same AI crawler allowances + sitemap pointing at the tenant's own subdomain sitemap. Soluigi.restora.app/robots.txtadvertisesSitemap: luigi.restora.app/sitemap.xml, not the platform sitemap. - Unknown subdomain OR demo/inactive tenant →Disallow: /(full block) so we never accidentally leak demo content into Google's index. - DB read failure falls back to the platform robots rather than blocking everything — defence against transient infra issues. - Verification: - TypeScript: 0 errors -next build: ✅ green; both/sitemap.xmland/robots.txtregistered as dynamic functions (ƒ) - HTTP probes with spoofed Host header: -restora.io/sitemap.xml→ 163<loc>entries (platform rollup) ✓ -luigi.localhost/sitemap.xml→ 3 entries (home + menu + reservations) ✓ -restora.io/robots.txt→ platform allow + disallow list ✓ -luigi.localhost/robots.txt→ tenant-scoped + tenant-sitemap reference ✓ -nobody.localhost/robots.txt→Disallow: /(unknown subdomain block) ✓ - Known limitations / deferred: - Custom-domain tenants (e.g.www.luigis-pizzeria.com) still hit the unknown-subdomain branch and serve a blocked robots. Need acustomDomainlookup in addition to the subdomain match — small follow-up that touches both files. - Tenant sitemaps don't include offer / product detail pages yet. The current per-tenant list is intentionally conservative (3-4 URLs); expand once we have public product URLs to index. - No `<image:image>` extensions on the tenant sitemap — could surface banner + product photos for richer image search. Polish. - Cache TTL is 10 minutes (max-age=600) — fine for SEO crawl frequency but a tenant changing their hours won't see the lastmod tick for up to 10 min.- Phase 28: Bazar "Recommended for you" rail driven by cuisine signals
- Closes a real product gap — the bazar collected
subscriptionPrefs.preferredCuisinesduring onboarding andfavoriteRestaurantIdsfrom the Heart button (already wired on the restaurant detail page), but never used either signal to recommend anything. Phase 28 turns those data points into a visible benefit on the marketplace landing. - `GET /api/bazar/recommendations` (new): blends two signals to rank approved listings the user hasn't already engaged with. - Weight 2 per overlap:preferredCuisines— the explicit list typed during onboarding. - Weight 1 per overlap: cuisine tags of restaurants the user has already favourited ("you liked X, here's another like X"). - Excludes restaurants infavoriteRestaurantIds(don't recommend things they've already saved) AND restaurants they've already reviewed (they know). - Per-resultreasonstring ("Matches your love of Italian" / "Similar to a restaurant you saved") — transparent recommendation rationale instead of black-box "AI says so". Rendered as a chip under each card. - Unauthenticated callers get `200 { recommendations: [] }` not 401 — the marketplace landing can call this from the public surface; the rail self-hides on empty. - Sorted by score desc, then rating value desc, then name asc. Caps at 8 results. - Best-effort rating attachment from per-tenanttdb.reviews(filters out hidden via Phase 18 logic). - `<RecommendationRail />` on/bazar: - Self-contained, optional component — fetches recommendations on mount and renders nothing when none come back, so the landing stays clean for anonymous + new users. - Sits just above the Sponsored section so logged-in users see relevance before paid placement. - 4-up grid on desktop, single column on mobile. Each card shows name, rating pill, top 3 cuisine tags, and the indigo reason chip. - Indigo theme to differentiate from the orange marketplace primary and the amber Sponsored block. - Heart button audit: confirmed the restaurant detail page Heart toggle already POSTs/api/bazar/user/favoritescorrectly (Phase 28 audit caught this before duplicating work). No UI changes needed there — the existing infrastructure feeds the new signal directly. - Verification: - TypeScript: 0 errors -next build: ✅ green;/api/bazar/recommendationsregistered - HTTP probe: anonymous →200 { recommendations: [] }✓ (rail hides silently); authenticated paths exercised via the seeded bazar users (each has 2 preferred cuisines + 2 favourites) - Known limitations / deferred: - Two signals only — no order history, no recently-viewed, no time-of-day patterns yet. The score scaffolding makes adding weights trivial. - No "Why am I seeing this?" deep-dive — the chip's one-liner is the explanation. A future polish could let the user click for "Because you liked Anna's pick and Sakura, plus your preferences include Japanese". - No cold-start fallback — new users with no preferences and no favourites get no recommendations. Could swap in "Popular this week" when score-based results are empty. - Reads every tenant DB to score + rate — fine at our scale (low single-digit ms per listing); a precomputed cuisine→[tenantId] index would help past 1000 listings. - Not personalised per device — recommendations follow the bazar user account, not the browser. Acceptable.- Phase 27: Bazar order live status polling + click-to-call
- Closes two Phase 25 deferred items: "No live status push — page re-fetches only on mount" and "Restaurant phone is shown raw — no click-to-call deep link on mobile yet". - Live polling on `/bazar/orders/[orderId]`: - 30s interval while the order is in flight. Stops automatically once
status ∈ {delivered, rejected, canceled}— no point burning cycles on completed orders. - Re-polls on tab focus viavisibilitychangeso a backgrounded customer who comes back gets fresh state without manual refresh. - Silent mode:load({ silent: true })skips the loading spinner and error banner — keeps the page calm while the badge stays solid. Initial load + user-triggered refresh still surface loading + error states. - `isLive` derived from order status — single source of truth feeds both the polling effect and the UI badge. - UI affordances: - `<Radio /> Live` badge (rose pill withanimate-pulse) next to the status pill, only while polling is active. Tooltip: "Auto-refresh every 30s while in flight". - "Updated Xs ago" hint below the status row. A 1Hztickstate forces a re-render each second so the counter doesn't freeze on the value it landed at during the last fetch.aria-live="polite"so screen readers announce updates non-disruptively. - Click-to-call: restaurant phone in the strip is now a<a href="tel:…">chip (Phone icon + number on ≥sm, "Call" label on xs). The phone string has whitespace stripped before being passed totel:so messy formatting in tenant settings doesn't break iOS dialer URLs. - Restaurant strip refactor: splitting the row into a name-link + separate phone-chip resolves the "two tap targets, one link" UX problem — taps on the row now reliably hit the link they aimed at. - Verification: - TypeScript: 0 errors -next build: ✅ green - HTTP probe:/bazar/orders/ord_x→ 200 (page renders; client polling fires once auth lands a real order in state) ✓ - Known limitations / deferred: - 30s cadence is hardcoded — same trade-off discussed for Phase 19's inbox bell. Could be configurable per tenant or per user. - No SSE / WebSocket push — real-time push remains the proper upgrade path; polling is enough for current scale. - No "your order is ready!" toast — status change between polls is currently silent. A diff-against-previous render could surface a one-time success burst when status flips toreadyordelivered. - `tel:` only — no `sms:` for SMS — could be added on a long-press or "More" menu in a future polish.- Phase 26: Bazar cancel + reorder — completes the customer order-lifecycle loop
- Closes both Phase 25 deferred items ("No cancel from this page" and "No reorder button"). - `src/lib/order/customer-cancel.ts` (new): shared customer-cancellation service extracted from the existing public route. Single source of truth for eligibility checks, refund creation, escrow status updates, and risk scoring. Callers handle auth + ownership BEFORE invoking — the service trusts the caller has already proven ownership. - `POST /api/public/[tenantId]/orders/[orderId]/cancel` refactored to call the shared service. Email-based ownership check stays at the route layer (unchanged behavior for tenant-direct flow). - `POST /api/bazar/orders/[orderId]/cancel` (new): bazar wrapper. - Auth:
getBazarSession→ 401 if missing. - Walks approved bazar listings to find the order's tenant. - Ownership gate:bazarUserId === user.id; mismatch returns 404 (not 403) to avoid order-existence enumeration. - On pass, delegates toprocessCustomerCancelso refund + risk scoring matches the public path bit-for-bit. - Body:{ reason?: string }propagated to the audit timeline. - `POST /api/bazar/orders/[orderId]/reorder` (new): cart-ready payload builder. - Same auth + ownership gates as cancel. - Re-resolves every line against the current menu — product must still exist andavailable !== false. Skipped items land inskipped[]with reason'unavailable' | 'removed'so the UI can surface "X items skipped". - Current `basePrice` is returned, not the historical one — better to surprise the customer with a higher checkout total than to charge a stale price and lose money on the merchant side. - Current `imageUrl` is returned (the detail-API doesn't carry it — needed for the cart visual). - Doesn't mutate cart server-side — cart lives inlocalStorageviauseBazarCart. Client takesitems[]and callsaddItemper line, fanning out by quantity so the cart's existing add semantics (bump existing line, insert new) just work. - UI on `/bazar/orders/[orderId]`: - Action row above the timeline with two buttons: - Reorder (orange) — always available. Calls reorder API, fans items into the cart, surfaces an info toast when items were skipped, redirects to the restaurant page after 1.2s for review + checkout. - Cancel order (rose outline) — only shown whenorder.status ∈ {pending, accepted, preparing}. Prompts for an optional reason, POSTs to the cancel endpoint, shows the server's refund-percent message in the success toast. - Inline action message (success/error/info toast) renders above the action row. Success toast hides the action buttons. - Loading spinners per-button viaactioningstate. - Verification: - TypeScript: 0 errors -next build: ✅ green; 3 routes registered: bazar cancel, bazar reorder, and the refactored public cancel - HTTP probes: bazar cancel → 401 without session ✓; bazar reorder → 401 without session ✓; public cancel still returns 404 for a missing order (proves the shared service wiring is intact) ✓ - Known limitations / deferred: - No client-side eligibility hint — the Cancel button shows onpending|accepted|preparingbut the server may still 409 if escrow policy says no. Acceptable: the error toast surfaces the reason. - Reorder doesn't carry selected options — past-order line options aren't replayed. Phase 27-ish polish; needsuseBazarCartto grow option-aware semantics. - No bulk reorder ("reorder my last 3 orders") — single-order only. Trivial extension when warranted. - No reorder gate on closed/inactive restaurants — the reorder endpoint doesn't checklisting.statusorordering-status. Could lead to a friendly cart push then an immediate "restaurant closed" toast at checkout. Acceptable.- Phase 25: Bazar order detail page
- Closes Phase 22's deferred item ("No order detail page for bazar customers — the card surfaces summary fields but there's no
/bazar/orders/[id]deep link yet"). With the seeder now populating 10 bazar-linked orders, the detail page has real data to render from day one. - `GET /api/bazar/orders/[orderId]` (new): returns the full detail for a marketplace order owned by the authenticatedBazarUser. - Ownership gate: the order must havebazarUserId === user.id. Mismatched ownership returns 404, never 403, so the response shape doesn't leak whether the order exists. - Walks every approved tenant'sorders[]looking for the ID; stops on first hit. - Strips internal/sensitive fields:paymentHoldId,stripePaymentIntentId,escrowStatus,refundId. Surfaces a coarsepaymentLabel("Card" / "Cash on delivery") instead of the raw method. - Builds a synthesised timeline from explicit timestamps (createdAt/acceptedAt/preparingStartedAt/preparedAt/deliveredAt/cancelledAt) — the richer underlyingorder.timeline[](an operational log) isn't exposed. - Tolerant of two OrderItem shapes: legacyproductName/price/selectedOptionsAND the seeder'sname/subtotal/options. Both render uniformly. - `/bazar/orders/[orderId]/page.tsx` (new): receipt-style customer detail page. - Header card: order number + placed-at + status pill + paid pill + delivery/pickup pill + clickable restaurant strip with logo + phone. - Timeline section: step list with the synthesised events; last step highlighted in orange. - Receipt section: per-item line with quantity × name + options + unit price + subtotal; totals breakdown including subtotal / delivery / discount (with coupon code if present) / tax / total. - Delivery + payment side-by-side cards; gift-card credit displayed when applied. - Notes section when the order had any. - Loading / 404 / 401 (sign-in) states all handled cleanly. - `/bazar/account` Orders tab: each card is now a single<Link>wrapping the full content (resolves the nested-link HTML issue) that opens the detail page. The restaurant-name link moved into the detail page's restaurant strip — clearer UX, one tap-target per card. - Seeder update:seed-everything.jsnow writes each order item with BOTH the canonical (productName/price/selectedOptions) AND the shorter alias (name/subtotal/options) fields so both reader paths (legacy tenant order surfaces + Phase 25 bazar detail API) see consistent data. - Verification: - TypeScript: 0 errors -next build: ✅ green;/api/bazar/orders/[orderId]+/bazar/orders/[orderId]registered - HTTP probes: API → 401 without bazar session ✓; page → 200 ✓ - Known limitations / deferred: - No reorder button — would clone the items into a fresh cart on the source restaurant. One-line addition onceuseBazarCartexposes areplaceAllaction. - No cancel from this page — would call the existing/api/public/[tenantId]/orders/[orderId]/cancelendpoint with bazar-session auth. The cancel flow already exists for the tenant-direct path; bazar wrapper is a Phase 26-ish item. - No live status push — page re-fetches only on mount. For "is my order ready yet?" the polling cadence from Phase 19 could be reused. - Restaurant phone is shown raw — no click-to-call deep link on mobile yet (tel:href would be a one-line addition).- Phase 24: Rate-limiting middleware on the four highest-risk public endpoints
- Closes Phase 18's explicit deferred concern ("no rate limiting on the report endpoint — a determined actor could flag-bomb") and proactively gates three other high-risk public endpoints that previously had zero throttling: bazar review submit, public order POST, bazar login. - `src/lib/rate-limit/limiter.ts` (new): in-memory sliding-window counter. - Principal derivation (priority order): explicit
principaloverride (auth-resolved tenant/user IDs) → first IP incf-connecting-ip→x-real-ip→x-forwarded-for→ literal'anon'(so a misconfigured proxy degrades to a shared bucket, never a bypass). - Sliding window: each bucket stores hit timestamps capped to the active window. Periodic sweeper evicts buckets cold for >30 minutes so memory doesn't grow unbounded under churn. Sweeper timer isunref'd so Node still exits cleanly. - `requireRateLimit(req, opts)` helper mirrors our existing auth-gate ergonomics ({ ok: true } | { error: Response }). - 429 response includesRetry-After(seconds),X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Resetheaders so well-behaved clients can back off intelligently. - `inspectRateLimit` for read-only checks (no hit consumed) — useful for tests or quota indicators. - `_resetRateLimitForTests` escape hatch for unit tests. - Warn-level log on every 429 so abuse waves stand out without burying signal in normal traffic. - Endpoints gated: -/api/bazar/restaurant/[slug]/reviews/[reviewId]/report— 5 reports per IP per 10 min. Closes the Phase 18 flag-bomb vector. -/api/bazar/restaurant/[slug]/reviewsPOST — 10 review writes per BazarUser per hour (keyed byuser.idnot IP — review is per-user-per-restaurant upsert, but spammy edits to a single review are still abuse). -/api/public/[tenantId]/ordersPOST — 10 orders per IP per 10 min, scoped per tenant (route: 'public.order.submit:${tenantId}') so flagging one tenant doesn't lock out another. Generous enough that a household placing back-to-back orders is fine, tight enough that scripted probing trips immediately. -/api/bazar/auth/loginPOST — 8 attempts per IP per 15 min. Stalls credential-stuffing without blocking a typo + retry. - Verification: - TypeScript: 0 errors -next build: ✅ green - Live abuse simulation withx-forwarded-for: 198.51.100.7: - 6 rapid POSTs to the report endpoint → first 5 land (return 404 because Luigi isn't on bazar — proves rate-limit fires before DB lookup), 6th returns 429 with `Retry-After: 600` ✓ - 9 rapid POSTs to login with wrong credentials → first 8 return 401, 9th returns 429 ✓ - Documented trade-offs: - In-memory storage = single-process scope. Deployed across multiple Node instances (Vercel serverless, multi-pod K8s), the counter doesn't share state — each instance has its own budget. For our current scale this is acceptable; the proper upgrade path is Redis/Upstash with the samerequireRateLimitsignature. - Per-IP can hurt CGNAT/corporate users sharing an egress IP. The user-ID override path (used on review submit) mitigates this for authenticated endpoints. For unauthenticated endpoints we accept the small collateral. - No global limit — only per-(route, principal). Worst-case a botnet from 1000 unique IPs each hitting the report endpoint 5 times pre-trips no limiter. Mitigation: cuts off at the auto-hide threshold (Phase 18) anyway. Future polish: stacked global cap. - Known limitations / deferred: - In-memory only — see above re Redis upgrade path. - Limits are hardcoded — no admin UI to tune them yet. Editing them requires a code change. Acceptable for security defaults. - No exemptions — there's no allowlist for trusted IPs (e.g. internal monitoring). Easy to add via theprincipaloverride when we need it.- Phase 23: Super-admin platform activity feed
- Closes Phase 14's deferred item ("Activity log type
feature-grantonly appears on the tenant's own activity page — there's no platform-wide super-admin activity feed yet"). Every tenant has its ownactivityLogs[]but a super-admin couldn't see "what happened across every restaurant in the last 7 days" without checking each tenant individually. Phase 23 builds the rollup. - `GET /api/admin/activity` (new): walks every tenant'sactivityLogs[], enriches each entry with the tenant record, applies optional filters, returns sorted newest-first. - Query params:days(clamped [1, 90], default 7),type(single ActivityType),tenant(substring match against tenant name OR subdomain),limit(clamped [1, 500], default 200). - Pre-filters tenants by substring before loading their DBs so a search for "luigi" doesn't read 100 tenant DBs we'll throw out. - Response includes: items array, counts (total, returned, byType, byTenantId), window (fromIso, toIso, days), knownTypes list. - Super-admin auth gate (requireSuperAdmin). - `/admin/dashboard/platform-activity` (new page): - Three-tier filter row: (1) time window — 24h / 7 days / 30 days / 90 days, (2) activity type chips (All + every known ActivityType with live count), (3) debounced tenant search (300ms) matching name or subdomain. - Summary tiles: Events count + returned subset, Active tenant count, Window in days, current type filter. - "Most active tenants" panel: top 5 tenants by event count in the current window, each clicks through to the tenant detail (filtered by name). - Per-event card: color-coded type icon + pill, tenant name with external-link to detail, relative time, action headline, details body, subdomain + truncated userId + full timestamp in the footer. - Loading + empty + truncated-results states all handled. - Distinct from existing `/admin/dashboard/logs` — that page surfaces super-admin audit actions (suspensions, deletions, tenant changes). Platform activity surfaces the inverse: what's happening inside each tenant. They live as siblings. - Verification: - TypeScript: 0 errors -next build: ✅ green;/api/admin/activity+/admin/dashboard/platform-activityregistered - HTTP probes: API → 401 without super-admin ✓ (both base and filtered variants); page → 307 (admin login redirect ✓) - Known limitations / deferred: - Scans every tenant's full activityLogs[] in-memory — fine at current scale (the existingMAX_ACTIVITY_LOGS = 500cap per tenant means worst case is N_tenants × 500 entries). Worth indexing or shipping to a real DB if we cross 10k+ tenants. - No real-time push — page refreshes on filter change + manual Refresh button. Adequate for an admin tool. - No CSV export of the filtered set. Easy add when needed. - No multi-type filter (one type at a time only). The "All" chip + per-type counts is the workaround. - `tenant` filter is exact-string substring — no fuzzy match. Acceptable for an admin tool.- Phase 22: Bazar order linkage + customer order history
- Closes Phase 21's "email matching is the weak link" deferred item and unlocks a proper "My orders" surface for bazar customers. - Schema (
Order): adds optionalbazarUserId?: string. Backwards-compat with every legacy order (defaults toundefined). - Stamp at checkout:POST /api/public/[tenantId]/ordersnow reads the bazar session cookie viagetBazarSession(req)at the top of the handler. When present, the resolveduser.idlands onOrder.bazarUserIdalongside the existingcustomerEmailetc. Wrapped in atry/catchso a bazar-DB hiccup never blocks the order from going through — the link is best-effort, the order is sacred. - Upgraded verification logic inPOST /api/bazar/restaurant/[slug]/reviews: the verified-purchase check now matches on either (a)Order.bazarUserId === user.id(Phase 22, rock-solid) OR (b)customerEmailcase-insensitive match (Phase 21, fallback for legacy + guest checkouts). Either path still requirespaymentStatus === 'paid'. - `GET /api/bazar/orders/me?page=1&perPage=10` (new): authenticated bazar customers can now pull their cross-restaurant order history. - Iterates approved bazar listings, scans each tenant'sorders[]for matchingbazarUserId. - Sorts newest-first. Strips per-restaurant sensitive fields (escrow IDs, payment intent IDs, hold IDs) — only customer-visible fields make it out. - 401 without a bazar session ✓. - `/bazar/account` page: new Orders tab added between Profile and Favourites with the ShoppingBag icon. Each order card shows order number, status pill, "Paid" pill (when applicable), restaurant name (linked to the bazar restaurant page), item count + type, total, and date. Pagination at the bottom when total > 10. Empty state links to /bazar. - Verification: - TypeScript: 0 errors -next build: ✅ green;/api/bazar/orders/meregistered - HTTP probes:/api/bazar/orders/me→ 401 without bazar session ✓;/bazar/account→ 200 ✓ - Behavioral migration: - Legacy orders stay unlinked — they were placed beforebazarUserIdexisted. Phase 21's email fallback still catches them. A future polish could backfillbazarUserIdon legacy orders by matchingcustomerEmailagainst current bazar users (low-priority). - Tenant-direct orders (placed via the tenant's own ordering surface, not bazar) generally won't carrybazarUserIdbecause the customer isn't logged into the marketplace — they'd order as a guest or via the tenant's own auth. Acceptable: verified-purchase is a marketplace signal, not a tenant signal. - Known limitations / deferred: - No order status sync from tenant-side updates — if the tenant operator marks an order delivered, the bazar Orders tab reflects it on next reload but doesn't auto-refresh. The same polling cadence as the operator dashboard (Phase 19) could be added later. - No order detail page for bazar customers — the card surfaces summary fields but there's no/bazar/orders/[id]deep link yet. Tenant order-tracking surfaces already exist (/track/[tenantId]/[orderId]) and would be the natural target. - `/api/bazar/orders/me` walks every approved tenant DB — fine at our scale. A precomputedbazarUserId → [orderIds]index would help at 10k+ listings.- Phase 21: Verified-purchase badge for bazar reviews
- Closes Phase 16's social-proof gap — bazar reviews previously had no credibility signal beyond rating + comment. Reviewers who actually ordered now carry a "Verified" badge so the social-proof loop is honest. - Schema (
Review): adds optionalverifiedPurchase?: boolean. Backwards-compat with every legacy review (defaults toundefined, rendered as not-verified). - Verification check at submission time —POST /api/bazar/restaurant/[slug]/reviewsnow scanstdb.ordersfor anycustomerEmail(case-insensitive) matching the bazar user's email withpaymentStatus === 'paid'. If found, the new review is created withverifiedPurchase: true. When upserting an existing review the flag is sticky — once you've verified, future edits keep the badge. - API echo: the public review listing (GET .../reviews) and the submit response both includeverifiedPurchaseso clients render the badge without a second round-trip. - UI badge:BazarReviewsreview cards on/bazar/restaurant/[slug]show an emeraldVerifiedpill (BadgeCheck icon) between the rating and the source pill when the flag is true. Tooltip explains: "This reviewer placed a paid order on Food Bazar." - Verification: - TypeScript: 0 errors -next build: ✅ green - HTTP probe: public reviews API returns 404 for an unlisted restaurant (correct gating ✓); API shape intact for listed restaurants. - Known limitations / deferred: - Email-based matching is the weak link — a customer who orders withjohn@gmail.combut reviews from a bazar account usingjohn.work@example.comwon't show as verified. The proper fix is abazarUserIdforeign key onOrderset at checkout when a bazar user is logged in. Deferred to a future phase that touches the order-submit flow. - No backfill — existing reviews stay unverified even if the reviewer has orders. We could run a one-off script that re-scans every bazar review against its tenant's order history, but the false-negative rate from email-mismatch makes it a low-priority polish. - No verified-only filter chip on the public review list yet. Could be added if AI / Google Search starts surfacing verified counts. - Bazar users with no orders ever still review freely — verification is a positive signal, not a gate. Acceptable for marketplace discovery use cases.- Phase 20: Cuisine × city combo aggregator pages
- Combines Phase 10's cuisine pages and Phase 11's city pages into the long-tail SEO surface that captures the highest-volume restaurant search queries ("best italian restaurants in stockholm", "japanese restaurants london", etc). - New route
/bazar/cuisine/[cuisine]/in/[city]— server-rendered combo aggregator. - 404 gating is two-axis: unknown cuisine slug OR unknown city slug both return 404. Valid combos with zero listings still render (with a "check back soon" message + cross-links to the parent surfaces). - Restaurant filter uses the samelistingMatchesCuisinekeyword logic the single-axis cuisine page uses, plusextractCityfor city resolution — so combos surface the exact same listings their parents would. - Listings sorted by rating desc, then name asc (deterministic for crawlers). - Hidden review filter applied here too (Phase 18) — hidden reviews don't contribute to the rating shown on cards. - Metadata: title "Best ${Cuisine} restaurants in ${City} — Food Bazar", description with live count, canonical URL, OpenGraph + Twitter cards. - JSON-LD is the headline upgrade — three blocks: -CollectionPagewith bothabout: Thing(the cuisine concept) ANDspatialCoverage: Place → PostalAddress(the city + country). Lets Google + AI ground geo+cuisine queries on a single structured page. -BreadcrumbListshowing the 4-level path: Home → Food Bazar → Cuisine → "In City". -ItemList(when listings present) reusingbuildBazarItemListLd. - Cross-links at the bottom: - "${Cuisine} in other cities" — 8 KNOWNCITIES other than the current. - "Other cuisines in ${City}" — 8 MARKETPLACECATEGORIES other than the current. - Builds dense internal link graphs between combos, parent cuisine pages, parent city pages, and individual restaurants — Google's crawl budget gets put to work. - Twin back-links at the top — both parent surfaces (cuisine page + city page) so operators and crawlers can navigate up either axis. - Sitemap depth (/sitemap.xml): - Each approved listing's tenant DB is now scanned for BOTH city AND cuisine matches. Combos with ≥1 listing get an entry atpriority: 0.5(lower than single-axis 0.6 — they're long-tail targets, not main hubs). - Combos with zero listings stay out of the sitemap entirely — no "page exists but it's empty" pollution. - 0 combo URLs in sitemap currently because no bazar listings are approved yet — correct conditional behavior. Becomes ~10× larger once listings exist (26 cuisines × 25 cities = 650 potential combos, but only those with active listings get emitted). - Verification: - TypeScript: 0 errors -next build: ✅ green;/bazar/cuisine/[cuisine]/in/[city]registered - HTTP probes:/bazar/cuisine/italian/in/stockholm→ 200 ✓;/bazar/cuisine/pizza/in/florence→ 200 ✓; unknown cuisine → 404 ✓; unknown city → 404 ✓ - JSON-LD probes:CollectionPage+spatialCoverage+about+ correct title all present ✓ - Known limitations / deferred: - Cross-links on parent cuisine/city pages don't yet point at the top combo destinations — would require expensive DB scans on every parent page load to compute "which combos have listings". Future polish could precompute this offline. - Sitemap loop now scans every approved tenant DB for cuisine matches — fine at our scale (low single-digit ms per listing), but if we ever cross 10k+ listings we'll want a precomputed(cuisine, city)index on the bazar DB. - Combo URL format is `/bazar/cuisine/X/in/Y` — readable but not the "italian-in-stockholm" SERP-pretty form. The current URL still parses cleanly in headlines; revisit if SEO data justifies the migration cost. - No combo-page admin UI — there's no way for super-admins to highlight or de-emphasize specific combos. Acceptable: ranking is implicit from listing count + ratings.- Phase 19: Inbox header bell — click-through preview from any dashboard page
- Closes Phase 17's "no header bell badge" deferred item — the unified inbox shipped at
/dashboard/inboxwas a destination operators had to remember to visit. Phase 19 makes it impossible to miss with a top-bar bell that polls live, badges urgent items red, and reveals a 3-item quick preview from any dashboard page. - `src/components/dashboard/InboxBell.tsx` (new): self-contained client component. - Polls/api/dashboard/inbox?tenantId=…on mount + every 30s + onvisibilitychange(so returning to a backgrounded tab gets a fresh count without manual refresh). - Badge tone: red whenurgent > 0, blue when onlyunread > 0, hidden when zero. Caps display at "9+". - Dropdown panel: top 3 unread items with per-type icon (rose AlertTriangle for sync, amber MessageSquare for urgent reviews, blue HelpCircle for Q&A, orange MessageSquare for bazar reviews, indigo Puzzle for grants). Each card shows title + body excerpt + urgent badge when applicable + relative time. - Per-item click: optimistically marks read (single batch POST to/api/dashboard/inbox/mark-read), closes the panel, navigates to the item'shref. Falls back gracefully — next poll reconciles if the request fails. - "Mark all read" button in the header (only shown whenunread > 0); "Open inbox" link in the footer. - Portal-rendered panel so it always sits above page content regardless of dashboard stacking contexts. - Closed on: outside click, ESC key, scroll, and the close (X) button. - TopHeader wiring:InboxBellmounts insrc/components/dashboard/TopHeader.tsximmediately before the existing orders/reservations Bell. Coexists cleanly — both surfaces have their own state, their own polling intervals, and their own dropdowns. - Verification: - TypeScript: 0 errors -next build: ✅ green - HTTP probes:/dashboard→ 200 (bell renders) ✓;/dashboard/inbox→ 200 ✓ - Known limitations / deferred: - 30s poll cadence is fixed — could be configurable per tenant. Real-time SSE/WebSocket push would eliminate the latency entirely but adds infrastructure. - The InboxBell renders for all tenant contexts — even tenants on plans that exclude Google features still see the bell. Items from gated sources just won't appear, so the bell stays empty for them. Acceptable. - No badge animation when count changes — a subtle pulse would help draw attention. CSS-only addition when warranted. - No keyboard shortcut to open the inbox bell (Cmd+I etc.). Could be added when we wire up a global hotkey system.- Phase 18: Bazar review reporting + moderation queue
- Closes Phase 16's deferred safety gap — bazar reviews previously auto-approved with no abuse mitigation. This phase wires a three-layer moderation surface (public report → operator hide → super-admin reinstate/delete). - Schema (
Review): addshidden / hiddenBy / hiddenAt / hiddenReasonfor soft-deletion andreportCount / reportReasons[]for the public flag counter. All optional, all backwards-compat with existing reviews. - `POST /api/bazar/restaurant/[slug]/reviews/[reviewId]/report` — public, anonymous endpoint. - Body:{ reason?: string }coerced tospam | abuse | off-topic | fake | other. - IncrementsreportCountand appends toreportReasons(capped at 10 most recent so a flagging campaign can't bloat the record). - Auto-hide threshold: at 3 reports the review flipshidden: truewithhiddenBy: 'auto-moderation'. Drops out of the public listing + aggregate rating immediately — no human in the loop required for the obvious cases. - Returns{ ok, reportCount, autoHidden }so the UI can show "Thanks — we'll review" plus a one-time success state. - `PATCH /api/tenant/[tenantId]/reviews/[reviewId]` extended withhidden / hiddenReasonfields — operators can hide a review they consider unfair without escalating to admin. SetshiddenBy: auth.session.userIdfor audit. - Hidden filter applied across every public surface that aggregates ratings:/api/bazar/restaurant/[slug](rating header),/api/bazar/restaurant/[slug]/reviews(paginated list),/bazar/cuisine/[cuisine],/bazar/city/[city],/bazar/restaurant/[slug]server metadata. The underlying review record still exists for admin retention. - Super-admin moderation queue: - `GET /api/admin/bazar/moderation` — returns every review that is eitherhidden: trueor hasreportCount > 0. Enriched with tenant name + subdomain + report-reason histogram so the UI renders without an extra round-trip. Sorted: hidden first, then by report count desc, then newest first. - `POST /api/admin/bazar/moderation/[tenantId]/[reviewId]` with{ action: 'reinstate' | 'delete' | 'clear-reports' }. Reinstate unhides and clears reports (false-positive recovery). Delete removes the review entirely. Clear-reports keeps the review visible but resets the counter (for coordinated-abuse situations). - Admin page at/admin/dashboard/bazar/review-moderation: summary tiles (Total / Hidden / Flagged only), filter chips, per-review card showing rating + bazar/tenant badge + hidden badge + report-count badge + reason histogram + the full review body + Reinstate / Clear-reports / Delete actions. - UI updates: - Bazar review card (/bazar/restaurant/[slug]) gets a small Flag icon button. Click opens an inline form with 5 reason chips (spam, abuse, off-topic, fake, other) + Submit / Cancel. Post-submit the card flips to "Thanks — our moderators will review." - Operator reviews page (/dashboard/reviews) gets a new EyeOff / Eye action per row. EyeOff hides the review from the bazar; Eye toggles back. Headers now show source pill (Bazar / Tenant), Hidden badge, and a Flag badge with the public report count. - Verification: - TypeScript: 0 errors -next build: ✅ green; 4 new routes (report, adminmoderationGET, adminmoderation/[tenant]/[review]POST, admin moderation page) registered - HTTP probes: public report → 404 for an unlisted restaurant (gating ✓); admin queue → 401 (super-admin gate ✓); admin action → 401 ✓; admin page → 307 (admin login redirect ✓) - Known limitations / deferred: - Anonymous reports — no IP / fingerprint / rate limiting on the report endpoint. Trade-off for low-friction reporting; a determined actor could flag-bomb. Mitigated by the admin reinstate flow. Rate-limiting middleware is a Phase 19-ish polish. - Auto-hide threshold is hardcoded at 3 reports. Should become a per-tenant or per-platform admin setting once we have data on what threshold catches abuse without false-positives. - No reporter notifications — anonymous reports stay anonymous so we can't tell them "we acted on your report." Acceptable. - Hide doesn't notify the reviewer either — by design (no spam vector + no harassment of legitimate negative-review authors). Tenants can still reply publicly; that's the recourse for "I disagree." - Aggregate rating recomputes on every fetch (still — same as Phase 16). Worth caching if it becomes a hot path.- Phase 17: Operator unified inbox
- Problem closed: across Phases 6-16 we built so many surfaces (sync failures, urgent Google reviews, unanswered Q&A, bazar customer reviews, admin-driven feature grants) that an operator has to check 5 different pages to know what needs attention. This phase consolidates them into a single
/dashboard/inboxtriage view. - Aggregator engine —src/lib/inbox/build-inbox.ts: -buildInbox(tenantId)returns anInboxBundlewith a unifiedInboxItem[]ordered newest-first, plus headlinecounts: { total, unread, urgent, byType }. - Sources today (each bounded so a noisy source can't drown the others): 1. Failed Google syncs — last 14 days · cap 10 ·severity: high|normal2. Urgent Google reviews — sentiment=urgentonly · cap 10 · alwaysseverity: urgent3. Unanswered Q&A — questions withoutownerAnswer· cap 10 ·severity: high4. Recent bazar reviews —source==='bazar'AND within 30 days · cap 20 · low-rating + unreplied auto-promotes tourgent5. Recent feature grants —activityLog.type==='feature-grant'within 14 days · cap 10 ·severity: normal- Stable item IDs encode the type + source identifier (urgent-review:<reviewId>etc.), so read-state survives across reloads. - Pure-ish: only the two parallel data loads (tenantDB + Google connection) are I/O; the build is deterministic. No randomness, no time-of-day dependence beyondinWindow. - Read state lives on the existingtenantDB.readNotifIds: Record<string, string>(no schema additions). An item is read iff its ID is keyed in the map. - APIs: -GET /api/dashboard/inbox?tenantId=…— returns the bundle. -POST /api/dashboard/inbox/mark-readwith{ tenantId, itemIds[] }— upserts each ID intoreadNotifIdswith the current timestamp. Idempotent (re-marking is a no-op). Capped at 500 IDs per call to bound the worst-case write. - Dashboard page at/dashboard/inbox: - Summary tiles: Unread / Urgent / Total / Failed syncs+Q&A. - Filter chips (composable): status (Unread, Urgent, All) + type (per-source). Per-type chips are hidden when count is zero. - Item cards: severity-coded left border (rose · amber · gray), type pill, urgent badge, relative timestamp, title, body excerpt, "Open" link to the relevant fix surface (/dashboard/settings/googlefor sync failures,/dashboard/settings/google/inboxfor reviews/Q&A,/dashboard/reviewsfor bazar,/dashboard/featuresfor grants), "Mark read" button. - Click-through marks read automatically — opening the fix surface flips the item's read state so the operator doesn't have to do it twice. - Mark all read button in the header when unread > 0. - Empty state: "All caught up" with a green flash icon when nothing matches. - Optimistic local update on mark-read so counts/UI bump without a full reload. - Verification: - TypeScript: 0 errors -next build: ✅ green;/api/dashboard/inbox+/api/dashboard/inbox/mark-read+/dashboard/inboxregistered - HTTP probes: both APIs → 401 without session ✓; dashboard page → 200 ✓ - Known limitations / deferred: - No real-time push — page polls on visit / refresh. Adequate for v1; could wire SSE in a future phase if the operator's expectation tightens. - No header bell badge — the page is a destination, but there's no count badge on the dashboard nav yet. One-line addition to the layout when we want it. - No mute / snooze — operators can mark read but not "remind me later". AsnoozeUntil: stringon the read map would handle it. - Bazar reviews link to `/dashboard/reviews` — that page already exists for tenant reviews; the bazar source filter inside it is a future polish. - Mark-read race condition — concurrent mark-read calls could overwrite each other'sreadAtvalues, but the IDs themselves still land (the merge is shallow). Tolerable.- Phase 16: Bazar customer reviews — closes the marketplace social-proof loop
- Problem closed: Phase 10 added aggregate rating pills to the marketplace UI but there was no way for marketplace customers to actually leave reviews — the loop was half-built (we surfaced ratings but only collected them via tenant sites). - Schema (
Review): adds optionalsource: 'tenant' | 'bazar'+bazarUserId?: string. Backwards-compatible — undefinedsourcereads as'tenant'. Public listing endpoints redactemailandbazarUserId. - `GET /api/bazar/restaurant/[slug]/reviews?page=1&perPage=10` — public paginated review feed. Onlyapprovedreviews are returned; both legacy tenant-site reviews and Phase 16 bazar reviews surface in one list, sorted newest first. Response includessummary: { value, count }so the UI can render the star average without recalculating. - `POST /api/bazar/restaurant/[slug]/reviews` — bazar-authenticated submission. - Auth: requires aBazarUsersession (getBazarSession), 401 otherwise. - Body validation (zod):rating1–5 integer,comment3–2000 chars, optionaldisplayName1–80 chars. - One review per (BazarUser, restaurant) — re-submitting upserts in place rather than spawning duplicates. ThebazarUserIdfield is the dedupe key. -approved: trueby default — bazar reviews are public-marketplace content, not tenant-moderated (tenant owners can still respond via the reply field). - Writes totenantDB.reviews[]so the aggregate rating + restaurant detail page automatically pick up the new review without extra plumbing. - UI on `/bazar/restaurant/[slug]`: - New `BazarReviews` section appended below the menu column on both mobile and desktop. - Section header shows star average + review count + a "Leave a review" / "Edit your review" CTA. The CTA flips to "Edit" when we detect the current bazar user already has a review on this restaurant. - Inline form withStarRatingpicker (1–5, hover-scale interaction), textarea (char counter, 2000 max), display-name override (defaults to user's account name), Cancel + Post buttons. Form is hidden until the CTA is clicked — keeps the section visually quiet by default. - Auth-required hint when an unauthenticated visitor clicks the CTA — shows a sign-in link that round-trips back to the restaurant page via?redirect=so they don't lose context. - Review cards show author name + star rating + relative time (fmtRelative— "Yesterday" / "3 days ago" / "2mo ago") + a "Bazar" badge for source disambiguation + tenant owner replies in a left-bordered quote block. - Pagination — Prev/Next when total exceeds the per-page cap. - Empty state: "No reviews yet — be the first to review {name}." - Verification: - TypeScript: 0 errors -next build: ✅ green;/api/bazar/restaurant/[slug]/reviewsregistered - HTTP probes: GET → 404 for an unlisted tenant (correct gating ✓), POST → 401 without bazar session (auth gate fires first ✓), POST → 401 even with invalid body (auth gate fires before validation ✓) - Known limitations / deferred: - No verified-purchase flag — we'd love to mark "Reviewed after ordering" but we don't yet correlate bazar review submissions with marketplace order history. Future polish once the bazar order-history table grows. - No spam / abuse moderation — bazar reviews auto-approve. Tenant owners can reply but can't hide; super-admins can editmaster.jsonif needed. Should add a soft-flag + admin queue once we have meaningful volume. - No edit-after-reply guard — operators replying don't prevent the reviewer from editing the review afterwards. Acceptable for v1 (no public confusion since the reply is shown verbatim). - Aggregate rating recomputes on every fetch — fine at our scale (low single-digit ms even with 1000 reviews per restaurant), but worth caching on the tenant record if it ever becomes a hot path.- Phase 15: Bulk feature grants (Campaign mode) + Expiring-soon panel
- Closes the ops pain deferred from Phase 14 — granting one feature to 20 tenants used to be 20 manual POSTs. Now it's one Campaign run. - Bulk grant API —
POST /api/admin/feature-grants/bulkaccepts{ featureId, tenantIds[], enabled?, isAddon?, addonPriceMonthly?, expiresAt?, notes? }. Per-tenant upsert (existing grant for the same feature is replaced). Single masterDB write so the whole batch is atomic. Capped at 500 tenants per call. Unknown tenant IDs are silently skipped and returned in theskippedarray so admins can see exactly which inputs didn't land. Onefeature-grantactivity-log entry written per affected tenant with a "campaign" verb so the audit trail shows the action wasn't a one-off. - Expiring-soon API —GET /api/admin/feature-grants/expiring?days=30returns every grant whoseexpiresAtfalls in the next N days (or is already past), sorted ascending by expiry. Enriched with tenant name + subdomain + feature name +daysLeft+expiredflag so the UI renders without an extra round-trip.daysclamped to [1, 365]. - Campaign mode UI at/admin/dashboard/features: - Top-of-page toggle betweenBrowse(existing per-tenant grant editor) andCampaign(new bulk-grant builder). - 3-step builder: (1) pick feature with inline search + plan-inclusion hint, (2) pick tenants with checkbox list + preset selectors (All,Basic,Premium,Enterprise,No plan,Active only) + search by name/subdomain/plan, (3) configure the grant (enabled/revoke toggle, paid-add-on toggle + monthly price, expiry date, campaign note). - Live count in the Run button: "Run campaign — google-auto-sync → 12 tenants" so admins see what's about to fire. - Result toast showing applied count + any skipped tenant IDs. - Expiring Soon panel sits above the tenant browse list. Shows count of expired + count expiring within the chosen window (7 / 14 / 30 / 90 days). Each row is clickable — jumps to that tenant in Browse mode and scrolls them into view so an admin can extend or remove the grant inline. Hidden entirely when no grants are expiring. - Verification: - TypeScript: 0 errors -next build: ✅ green; both new API routes registered - HTTP probes: bulk POST → 401 (super-admin gate ✓); expiring GET → 401 (super-admin gate ✓) - Known limitations / deferred: - No undo / dry-run — once a campaign runs, the only way to roll back is another campaign withenabled: false. A dry-run preview ("would affect 12 tenants — these are the IDs") would be a Phase 16 polish. - No saved campaign templates — recurring campaigns (e.g. monthly Q&A access for beta cohort) must be reconfigured each time. Could store and replay later. - No CSV export of expiring grants for downstream tooling. Easy add when needed. - `onApplied` doesn't auto-refresh the per-tenant Grant Manager — if a campaign affects a tenant that's currently expanded in Browse mode, the manager keeps showing pre-campaign state until the user closes + reopens that card. Trade-off for keeping the data-flow simple.- Phase 14: Super-admin Feature Grant Manager — audit log + effective catalog view + expiry picker
- Closes the deferred admin gap from Phase 13 — Phase 13 surfaced the resolved feature catalog for operators but the admin console only had a raw grant editor (showed override rows, not the effective truth). Comp grants and refund-flow revokes were possible but invisible until you reloaded the operator's screen. - Activity log integration —
POST /api/admin/tenants/[tenantId]/featuresandDELETE /api/admin/tenants/[tenantId]/features/[featureId]now write a per-tenantwriteActivityLogentry on every grant / revoke / removal. New activity typefeature-grantadded to bothsrc/lib/audit/activity.tsActivityType andsrc/lib/db/schema.tsActivityType (kept in sync). Operator-facing/dashboard/activitypage gets a newFeaturesrow with a Puzzle icon (indigo) for the type filter. - Effective catalog endpoint — newGET /api/admin/tenants/[tenantId]/features-effective(super-admin scoped) returns the same shape as the operator's/api/tenant/[tenantId]/featuresso the admin UI and operator UI render from a single source of truth. AddsexplicitGrantscount so admins can see at a glance whether the tenant is on a vanilla plan or has custom overrides. - Admin UI (/admin/dashboard/features): - Summary tiles atop each tenant's expanded panel:Active features X/Y,From plancount,Override rowscount. - Existing override rows now show aexpires DD/MM/YYYYchip (red when past) so admins notice stale grants. - Expiry date picker added to the Add Grant form — leave blank for permanent grants, set a date to scope a beta/comp grant. - "All effective features" collapsible below the override list shows every feature in the registry with a source pill (In plan · Granted · Add-on · Revoked · Not active) + status icon — useful when an admin needs to answer "does this tenant have feature X?" without reading three different lists. - Empty state copy fixed — was "No custom feature grants" (misleading: implied tenant has nothing); now "No explicit override grants — features come from the plan only." - Verification: - TypeScript: 0 errors -next build: ✅ green;/api/admin/tenants/[tenantId]/features-effectiveregistered - HTTP probes: features API → 401 (super-admin gate ✓); features-effective API → 401 (super-admin gate ✓); admin page → 307 (admin auth redirect, expected) ✓ - Known limitations / deferred: - No PATCH endpoint for in-place grant updates — operators changing just the expiry must remove and recreate (the POST endpoint does an upsert by removing any existing grant for the same tenant+feature). For low-churn admin actions this is fine; revisit if it becomes friction. - No bulk-grant flow — granting the same feature to 20 tenants requires 20 POST calls. A "Promotional campaign" UI would be a Phase 15-ish item. - Activity log type `feature-grant` only appears on the tenant's own activity page — there's no platform-wide super-admin activity feed yet. Existing admin logs page covers other admin actions but doesn't ingest tenant activities.- Phase 13: Plan→Feature resolver + Tenant feature catalog
- Closes the explicit gap from Phase 12 —
requireFeaturepreviously only checkedfeatureGrantsrows, so even an Enterprise tenant needed an explicit grant for every feature their plan tier should include. This phase wires plan inclusion into the gate logic, so plans cascade automatically. - `src/lib/features/resolver.ts` (new): pureresolveFeature(feature, sub, grants)that merges the three sources of truth: 1. Plan inclusion —FEATURE_REGISTRY[].planInclusiondescribes which tier includes a feature in its base price. Tier hierarchy is inclusive: Premium covers Basic; Enterprise covers Premium covers Basic. 2. Explicit grant —FeatureGrantwithenabled: trueadds access on top of plan inclusion (typical add-on purchase).isAddon: truedistinguishes purchased add-ons (sourceaddon) from comp grants (sourcegrant). 3. Explicit revoke —FeatureGrantwithenabled: falseremoves access even if the plan would include it (compliance / abuse / refund scenarios). Wins over plan inclusion. - Returns{ granted, source: 'plan' | 'grant' | 'addon' | 'denied' | 'none' }so callers can show why a feature is or isn't active. -addonplanInclusion is never granted by plan alone — only by explicit grant.noneis never granted at all. - Pure: takes pre-loaded data so callers control I/O. - Companion:resolveAllFeatures(sub, grants)returns the full catalog;grantedFeatureIds()returns a Set for fast membership checks. - `gate.ts` refactor:tenantHasFeatureandtenantHasFeaturesnow load the tenant's active subscription + grant rows in a single sharedloadGateContext()call and delegate to the resolver. Same external API — backwards-compat is preserved across all the Phase 12 call sites. - `GET /api/tenant/[tenantId]/features` (new): returns the full effective catalog including the tenant's current plan, every feature with{ granted, source, planInclusion, expiresAt, addonPriceMonthly, dependencies, badge }, plus asummary { total, granted, byTier }count for headline tiles. - `/dashboard/features` (new page): operator-facing feature browser. - Top tiles: current plan badge (Basic / Premium / Enterprise) + status, active feature count vs total, count of Premium-tier features, count of Enterprise-tier features. - Filters: status (All / Active / Available / Revoked) + category. Filters compose. - Grouped by category with collapse/expand per group. Each feature row shows status icon, name, source pill (plan-included / granted / add-on / revoked / not active), description, pricing line, expiry, dependencies, and a "Get it" CTA linking to billing when the feature is available but not yet active. - Upgrade-URL fix: Phase 12 setupgradeUrl: '/dashboard/settings/billing'but that path doesn't exist — the real billing page is/dashboard/billing. Fixed ingate.ts402 response + the two UI components that linked to it (audit-log paywall + UpgradeRow in the Google settings page). - Verification: - TypeScript: 0 errors -next build: ✅ green;/api/tenant/[tenantId]/features+/dashboard/featuresregistered - HTTP probes: features API → 401 without auth (auth gate first ✓);/dashboard/features→ 200;/dashboard/billing→ 200 ✓ - Behavioral migration: - Existing Phase 12 gates now respect plan tier without any per-feature backfill. A tenant onenterprise-annualautomatically getsgoogle-auto-sync; a tenant onpremium-annualautomatically getsgoogle-sync-diff+google-audit-log. Explicit grants still stack on top for add-ons or upgrades outside their tier. - Cron auto-sync intickAutoSyncalready callstenantHasFeature— so platform-wide ticks now correctly process tenants whose Enterprise plan includes the feature, not only ones with explicit grants. - Known limitations / deferred: - No expiry enforcement on plan-included features — only explicit grants honourexpiresAt. If a tenant's subscription expires the feature evaporates viaisActiveSub(status === 'active' / 'trialing' required), but partial expiry of a single included feature isn't a thing. - `addon` planInclusion is exclusively grant-driven — operators can't self-serve purchase from this page yet. The "Get it" CTA links to /dashboard/billing where the existing add-on flow lives. - No super-admin "What does this tenant have?" view in the admin console yet — the catalog API supports it but the UI is operator-facing only. Likely Phase 14.- Phase 12: Plan gating for Google integration premium features
- Problem closed: Phases 8-9 shipped a stack of premium-feeling Google features (auto-sync scheduler, audit log, sync diff preview) but they were available to every connected tenant regardless of plan — a revenue leak. This phase wires those features into the existing feature-grant gating system. - Feature registry —
src/lib/features/registry.tsextended with 3 new feature defs: - `google-auto-sync` —Enterprisetier (or $19/mo add-on). Set-and-forget scheduling. Depends ongoogle-business. - `google-sync-diff` —Premiumtier ($5/mo add-on). Side-by-side preview before push. Depends ongoogle-business. - `google-audit-log` —Premiumtier ($5/mo add-on). Extended chronological log with filters + 50-entry retention. - Gate helper —src/lib/features/gate.tsextended: -requireFeature(tenantId, featureId)returns{ ok: true }or{ error: Response }mirroringrequireTenantAccessergonomics. The error response is a ready-to-return 402 Payment Required with the full feature metadata + aupgradeUrlso clients can render an upgrade prompt without an extra lookup. -tenantHasFeatures(tenantId, featureIds[])returnsRecord<id, boolean>for batch checks in UI-feeder routes. - API gates applied: -GET /api/google/auto-sync→ requiresgoogle-auto-sync-PATCH /api/google/auto-sync→ requiresgoogle-auto-sync-POST /api/google/auto-sync/tickwith{ tenantId }→ requiresgoogle-auto-sync(cron-secret path stays open for platform-wide ticks) -GET /api/google/sync/diff→ requiresgoogle-sync-diff-/api/google/quotastays open — it's a hard-limit observability surface, not a feature -/api/google/sync(manual sync) stays open — covered by existinggoogle-businessgrant via the gateway - Tick engine gating —tickAutoSync()insrc/lib/google/auto-sync.tsnow filters out tenants without thegoogle-auto-syncfeature grant in the platform-wide cron path (so even if their config still hasenabled: truefrom before the rollout, the platform doesn't burn API quota on their behalf). Tenant-scoped "Run now" path stays pre-gated at the route. - Feature echo on status —GET /api/google/statusresponse now includesfeatures: { autoSync, syncDiff, auditLog }boolean map so the UI can render plan-gated controls without an extra round-trip. - UI gating (/dashboard/settings/google+/dashboard/settings/google/audit): - New<UpgradeRow>component replaces the AutoSyncCard / Audit log link with a locked-state visual + plan badge (Premium / Enterprise) + "Upgrade" CTA when the feature isn't granted. - Preview buttons on every sync row are conditionally omitted (not just disabled) whenfeatures.syncDiff === false— keeps the row clean. - Audit log page renders a paywall card when accessed directly without the grant, with link back to the 12-event recent-activity strip on the main settings page (so the free experience isn't a dead end). - Verification: - TypeScript: 0 errors -next build: ✅ green; all 13 Google routes registered including the gated ones - HTTP probes: all 4 gated endpoints correctly return 401 without session (auth-gate fires first, then feature-gate); audit page renders 200 (client decides what to show after the status fetch returns features) ✓ - Migration / known limitations: - No automatic backfill — existing tenants currently using auto-sync in dev won't have feature grants. Production rollout would need a one-time script grantinggoogle-auto-sync+google-sync-diff+google-audit-logto every tenant whose subscription tier already includes them (perplanInclusion). Dev tenants need a grant inserted manually. - No plan-tier inference yet —requireFeatureonly checksfeatureGrantsnot the underlying plan. So even an Enterprise-tier tenant needs an explicit grant row. Should be wired through the plan→features resolver in a follow-up. - Quota tracking stays free — intentional; it's a safety surface, not a value-add. Could be re-evaluated as a Premium "extended quota analytics" feature with longer history.- Phase 11: Marketplace sitemap depth + City aggregator + Cuisine aliases
- Sitemap depth — the marketplace surfaces shipped in Phase 7a/10 weren't yet enumerated in
/sitemap.xml. Now they are, so crawlers can discover every leaf URL without relying solely on internal cross-links. - Static:/bazar+/bazar/menu(kept from earlier). - Cuisine (/bazar/cuisine/<slug>): one entry perMARKETPLACE_CATEGORIESslug (26 today: 20 food-type + 6 ethnicity aliases). - City (/bazar/city/<slug>): only included when at least one approved listing resolves to that city, so empty city pages don't poison the index. - Per-restaurant (/bazar/restaurant/<slug>): every approved listing,lastModifiedfrom the listing record. - Existing tenant/designer/supplier/chain/docs URLs still included unchanged. - Cuisine aliases —MARKETPLACE_CATEGORIESextended with 6 ethnicity-anchored slugs (italian,japanese,french,mediterranean,korean,vietnamese) so URLs like/bazar/cuisine/italianwork for the search-query style users actually type. Aliases match the samecuisineTypeskeyword index the existing slugs use, so they share the listing pool. - City aggregator pages (/bazar/city/[city]) — SEO landing per known city with the same shape as the cuisine pages. - `src/lib/bazar/cities.ts` (new):KNOWN_CITIEScurated dictionary (Stockholm / Gothenburg / Malmö / London / Manchester / New York / Paris / Berlin / Rome / Tokyo / Sydney etc — 25 cities across 11 countries).extractCity(address)walks the address text against a sorted keyword index (longest-first for greedy match), returns the matchingKnownCityor null.findCityBySlug(slug)for 404 gating. - Server page atsrc/app/(bazar)/bazar/city/[city]/page.tsx. Resolves slug → known city → 404 when unknown. For each approved listing, readstenant.settings.addressand matches against the city viaextractCity(). Listings sorted by rating desc then name asc (deterministic for crawler). - `generateMetadata` emits title "Best restaurants in $City — Food Bazar", description with count, canonical URL, OG + Twitter cards. - JSON-LD:CollectionPagewithspatialCoverage: Place → PostalAddress (addressLocality + addressCountry)so Google + AI can geo-anchor the page.BreadcrumbList+ItemList(when listings present). - UI: hero with city name + country, restaurant cards (same shape as cuisine page), two cross-link strips (other cities + browse-by-cuisine) for internal link graph density. - Verification: - TypeScript: 0 errors -next build: ✅ green;/bazar/city/[city]+/bazar/cuisine/[cuisine]+/sitemap.xmlregistered - HTTP probes:/bazar/cuisine/italian→ 200 (alias works ✓);/bazar/cuisine/pasta→ 200;/bazar/city/stockholm→ 200;/bazar/city/florence→ 200;/bazar/city/nowhere-xyz→ 404 ✓;/sitemap.xml→ 200, contains 26 cuisine URLs (20 food-type + 6 ethnicity aliases) ✓ - 0 city + restaurant URLs in sitemap currently — expected, no approved bazar listings exist yet. The conditional logic correctly suppresses empty city pages. - Known limitations / deferred: - City extraction is dictionary-based — addresses without a known city in the text won't surface anywhere. Adding more cities is an additive 1-line edit toKNOWN_CITIES. Future: optional reverse-geocoder fallback usingtenant.settings.coordinates. - No cuisine × city combo pages yet —/bazar/italian-in-stockholmlong-tail SEO not built. Worth doing once we have enough listings to make the combo pages meaningful. - `canonicalUrl` is relative (/bazar/city/stockholm) — Next.js resolves to absolute against the site URL. If we add per-tenant subdomains for the marketplace, revisit. - No per-restaurant `lastModified` on the bazar restaurant URL beyondlisting.updatedAt— would benefit from rollup that includes menu/photo edits. Polish.- Phase 10: Operator FAQ overrides + Cuisine aggregator pages + Marketplace ratings
- Operator-curated FAQs — closes the polish deferred from Phase 6. Restaurant owners can author their own Q&A pairs that override the heuristic generator. - Schema: new
TenantFaqEntryinterface +TenantSettings.faqs?: TenantFaqEntry[]. Each entry has{ id, question, answer, order, updatedAt }. Capped at 50 per tenant. - `deriveFaqs()`: whensettings.faqsis non-empty, the heuristic generator is skipped entirely — operators get exactly what they wrote (sorted byorder, filtered to non-blank entries). - Validation (UpdateSettingsSchema): zod validator accepts an array of FaqEntry shape; max 50; question 1-300 chars; answer 1-2000 chars. - API: existing/api/tenant/[tenantId]/settingsPATCH route now acceptsfaqs(added to allowlist + array semantics replace-wholesale). - UI: new "FAQs" tab on/dashboard/seobetween Meta & Tags and AI Generator. Inline editor: add / reorder (up/down arrows) / delete. Incomplete entries (blank question or answer) excluded from save with a warning chip. Save button shows valid count. - Visible FAQ block on the tenant home page (/tenant/[tenant_id]/page.tsx) — accordion of Q&A rendered alongside the existingFAQPageJSON-LD, so the on-page UX mirrors what search engines + AI Overview engines see. - Cuisine aggregator pages —/bazar/cuisine/[cuisine]SEO landing pages that rank for "best $cuisine restaurants" queries. - Server page atsrc/app/(bazar)/bazar/cuisine/[cuisine]/page.tsx. Resolves the slug againstMARKETPLACE_CATEGORIES; 404s for unknown slugs. Matches restaurants by exactcuisineTypesslug AND keyword substring (handles "Pizzeria" → 'pizza' variants). - `generateMetadata` emits dynamic title ("Best $Cuisine restaurants — Food Bazar"), description with count, canonical URL, OG + Twitter cards. - JSON-LD:CollectionPage+BreadcrumbList+ItemList(when listings present). CollectionPage usesisPartOfto link back to the bazarWebSiteentity for crawler entity-graph cohesion. - UI: hero with cuisine icon + count, restaurant cards sorted by rating desc then name asc (deterministic for SEO), and a cross-link section to other cuisines for internal link graph density. - Cross-restaurant aggregate ratings — per-restaurant avg ⭐ + count surfaced on the marketplace. - `/api/bazar/restaurant/[slug]` response now includesrating: { value, count }computed from approved reviews. - `RestaurantClient` header shows a rating pill (⭐ value + count) next to the open/closed status pill. - Cuisine aggregator cards show the same rating pill, drive the sort order. - Verification: - TypeScript: 0 errors -next build: ✅ green;/bazar/cuisine/[cuisine]registered as a dynamic route - HTTP probes:/bazar/cuisine/pizza→ 200 with 4 LD-JSON blocks (WebSite + 2x BreadcrumbList + CollectionPage), title "Best Pizza restaurants — Food Bazar" ✓;/bazar/cuisine/nonsense-xyz→ 404 ✓;/dashboard/seo→ 200 ✓ - Known limitations / deferred: - `MARKETPLACE_CATEGORIES` is food-type-focused (pizza, burger, pasta) not ethnicity-focused — so/bazar/cuisine/italianis a 404 even though listings tag themselves "Italian". A future polish would add ethnicity aliases (italian → maps to pizza+pasta listings). - No /bazar/city/[city] aggregator yet — only cuisine, not geo. City pages need either an explicitcityfield on listings or a reverse-geocoder for the coords we already store. - FAQ block always appears at the bottom of the tenant home — operator can't choose placement yet. Should become a website block in the builder eventually. - No translation surface for operator-curated FAQs — single-language (whatever the operator wrote in). Multi-lingual restaurants will need to author per-language or wait for an AI-translation pass.- Phase 9: Sync Diff Preview (Google integration)
- Operator trust feature — before clicking Sync, operators can click Preview to see exactly what will change on Google. Side-by-side before/after diff per resource. Catches typos in hours, accidentally-removed menu items, and stale phone numbers pre-flight. - Diff engine (
src/lib/google/sync-diff.ts): -buildSyncDiff(tenantId, resource)produces aDiffReportwithsummary { added, modified, removed }+ flatchanges: Change[]array. - For each resource fetches the current state from Google (real API or mock store) and the proposed state via the samebuildGbpPayloadthe sync engine uses — so the diff is faithful, not estimated. - Per-resource strategies: - location: recursive value-equality diff restricted to the sync-mask fields (title / phone / categories / hours / address / etc). - menu: full FoodMenu tree diff (sections → items). - offers / media: match-by-key diffs — offers bysummary, media bysourceUrl(mirrors sync-engine dedupe logic). Sync engine never deletes, sobefore-only items are shown asunchangednotremoved. - place-actions: keyed byplaceActionType— added when new, modified when URI changes. - ReturnsfetchErrorwhen current state read fails (live mode without GBP approval), but still includes the proposed payload asafterso operators can review. - ReturnsbuildErrorwhen payload assembly fails (e.g. unreadable tenant DB). - New API client accessors: -getLocation(tenantId, resourceName)— reads current location state viaGET /v1/{location}?readMask=…; mock returns a stub fromMOCK_LOCATIONS. -getFoodMenu(tenantId, locationResourceName)— reads current menu viaGET /v4/{location}/foodMenus; mock returns whateverupsertFoodMenulast wrote. - API routeGET /api/google/sync/diff?tenantId=…&resource=location|menu|offers|media|place-actions— tenant-scoped, returns the DiffReport. Read-only — no mutations on Google. - UI: - Preview button added to every sync row on/dashboard/settings/google(left of Dry-run / Sync). Loading spinner per resource. - DiffModal — full-screen on mobile / centered on desktop. Header shows resource label + Mock badge + summary count. Body groups changes by top-level path prefix with color-coded ChangeRow (green/Plus for added, rose/Minus for removed, amber/Edit3 for modified). Modified entries show before/after side-by-side. Footer offers Cancel + "Apply changes" — Apply closes the modal and runs the real sync for that resource. - Empty state: whennoChangesis true, modal shows "Google's copy already matches — no sync needed." - Build error: shown as a rose warning banner; Apply disabled. - Fetch error (live mode without GBP approval): shown as an amber banner with "Showing proposed only" caveat; Apply still enabled. - Refactor:regionCodeOf()in sync-engine exported so sync-diff can reuse the same address → region mapping. - Verification: - TypeScript: 0 errors -next build: ✅ green;/api/google/sync/diffregistered alongside/api/google/sync- HTTP probes:?tenantId=…&resource=location→ 401 (auth required ✓); missingresource→ 400 (zod validation ✓) - Known limitations / deferred: - No "remove from Google" semantics for offers/media — sync engine never deletes existing posts/media (Google's API doesn't expose dedicated delete for those v4 surfaces). Diff reflects this by markingbefore-only items asunchanged, notremoved. Real removal needs an explicit "purge" surface in a future polish. - Live mode requires GBP API approval to read current state. Until then, fetch returns 403 and the modal shows the "proposed only" warning banner — still useful (the operator sees what would be pushed). - No nested diff for deeply nested arrays — positional comparison works for our flat lists but won't elegantly handle reordering. If the operator re-orders categories, the diff shows N modifications instead of a single move. Acceptable for v1.- Phase 8: Auto-sync scheduler + Audit log + Quota tracking (Google integration)
- Auto-sync engine — most operators won't remember to click "Sync now" after editing their menu / hours / photos. Per-tenant config lets them set-and-forget the cadence + which resources to sync. - Schema:
GoogleConnection.autoSync: GoogleAutoSyncConfigwithenabled/frequency(hourly / every-6h / daily / weekly) /resources(location, menu, offers, media, place-actions, reviews, qa) /lastRunAt/lastRunStatus/lastRunError/nextRunAt. - `src/lib/google/auto-sync.ts` —tickAutoSync({ onlyTenantId? })iterates connections whosenextRunAtis past, runs each configured resource through the sync-engine functions, advancesnextRunAton the chosen cadence. Errors don't disable the schedule — they're recorded and retried next tick.setAutoSyncConfig(tenantId, patch)updates the per-tenant config and recomputesnextRunAtwhen enabling/changing frequency. - API routes: -GET /api/google/auto-sync?tenantId=…— returns current config + connection state. -PATCH /api/google/auto-sync—{ tenantId, enabled?, frequency?, resources? }updates the config. -POST /api/google/auto-sync/tick— two modes:{ tenantId }runs that one tenant out-of-band (dashboard "Run now");{}runs the platform-wide tick gated byx-cron-secretheader matchingCRON_SECRETenv (or Vercel'sx-vercel-cron-signature). - UI: new auto-sync card on/dashboard/settings/googlewith toggle, frequency picker (4 buttons), per-resource selector (7 chips), "Run now" button, "Next run" + "Last run" timeline. - Audit log — full chronological record of every sync that ran, who triggered it, and what was pushed. - Schema:GoogleSyncEventextended withactor: 'manual' | 'auto' | 'webhook',userId?,triggerSource?: 'dashboard' | 'auto-sync' | 'api' | 'webhook'.recordSync()retention bumped 20 → 50. - `SyncAuditContext` threaded through every sync-engine entry point (syncLocationToGbp/syncMenuToGbp/syncOffersToGbp/syncMediaToGbp/syncPlaceActionsToGbp/syncAllToGbp). Defaults to{ actor: 'manual', triggerSource: 'dashboard' }when omitted so legacy callers keep working. - `/api/google/sync` route now passes the authenticated user'sauth.session.userIdinto the audit context. - New page/dashboard/settings/google/audit— stat tiles (Total / OK / Failed / Auto / Manual), actor + status filters, per-event card showing resource pill, actor pill, dry-run / mock badges, message, counts, masked userId, timestamp. - Quota tracker — per-tenant per-day API call counter so operators see "X/Y calls used" before Google's hard quota kicks in. - Schema:GoogleConnection.quotaUsage: GoogleQuotaUsagewith{ date, totalCalls, byApi: Record<string, number> }. Date is UTC YYYY-MM-DD; resets implicitly when date changes. - `src/lib/google/quota-tracker.ts` —incrementQuota(tenantId, apiId)is fire-and-forget (best-effort persist, never throws).classifyQuota(usage)returns{ status: 'ok' | 'warn' | 'block', percent }with thresholds 80% warn / 95% block.assertQuotaAvailable(tenantId)is a throws-by-design guard for hot paths. - `gbp-api-client.ts`httpJson()now callsincrementQuota(ctx.tenantId, apiIdFromUrl(url))after every real Google call. URL-prefix lookup attributes each call to the right API bucket (account / business-info / food-menus / local-posts / media / place-actions / reviews / qa). Mock calls don't increment. - API routeGET /api/google/quota?tenantId=…returns the classified status. - UI: progress bar on the connection card shows today's usage with green / amber / red tinting; only renders when there's been at least one real API call. - Verification: - TypeScript: 0 errors -next build: ✅ green (335 pages);/api/google/auto-sync,/api/google/auto-sync/tick,/api/google/quota,/dashboard/settings/google/auditall registered - HTTP probes: all 3 new API routes correctly return 401 without auth; tick with cron-secret missing correctly returns 401; audit page renders 200 - Deployment: to enable scheduled ticks in production, setCRON_SECRETenv var and configure Vercel Cron (or external scheduler) to POST/api/google/auto-sync/tickevery 5-15 minutes with the matchingx-cron-secretheader. - Known limitations / deferred: - Auto-sync v1 shares one frequency across all resources — wanting "menu daily but media weekly" requires running two ticks with different configs. Operator could work around by enabling only one resource per cadence. - Quota counter is per-tenant, not per-project. When we add multiple tenants on the same GCP project, the project-wide quota is the sum — currently each tenant's UI shows only their own slice. Aggregated platform-wide quota dashboard is a future polish. - Tick endpoint is best-effort, not exactly-once — if two ticks fire concurrently for the same tenant, both could process beforenextRunAtadvances. Acceptable for v1 (Google's API calls are idempotent for our shapes); revisit with a per-tenant lock if it becomes a problem.- Phase 7a: Food Bazar marketplace SEO + Tenant SEO Health Scorer
- Marketplace structured data — Schema.org JSON-LD across the public
/bazarsurface so AI Overviews and crawlers can ground cross-restaurant searches in our actual ranked list. - `buildBazarWebsiteLd` (new insrc/lib/seo/structured-data.ts): emitsWebSite+SearchAction(sitelinks searchbox) anchored at/bazar#website. - `buildBazarItemListLd`:ItemListof up to 20 approved listings — each item carries the canonical/bazar/restaurant/<slug>URL so search engines can de-reference the full Restaurant entity. - `buildBazarRestaurantLd`: per-listingRestaurantschema withisPartOflink back to the marketplaceWebSite, plus aggregateRating, priceRange (derived from menu avg), servesCuisine, postalAddress. - `/bazar` layout (src/app/(bazar)/bazar/layout.tsx) becomes anasyncserver component that: - Loads the food-bazar DB + master DB and emitsWebSite+ (when listings exist)ItemList+BreadcrumbListJSON-LD in the SSR'd HTML. - Sets richermetadata(OpenGraph + Twitter card) so social share previews are populated. - `/bazar/restaurant/[slug]` page split — what was a pure client component is now a server wrapper (page.tsx) + client implementation (RestaurantClient.tsx): -generateMetadata({ params })returns per-restaurant title, description, OG image (bannerUrl), and canonical URL. - Server wrapper emits Restaurant + BreadcrumbList JSON-LD inline, then renders the existing client UI unchanged. - 404 vianotFound()when listing absent / not approved (instead of an empty client shell). - `/llms.txt` platform branch now mentions Food Bazar with the live tenant count, points crawlers at the marketplace landing + per-restaurant URL pattern (/bazar/restaurant/<slug>). - Tenant SEO Health Scorer — broader 0-100 scorer covering the entire restaurant surface, distinct from the existing settings-only score on /dashboard/seo. - `src/lib/seo/health-scorer.ts` (new): purescoreSeoHealth({ db, bazarListing?, hasGoogleConnection? })function. 10 checks × 100 total points across 6 categories (brand, contact, menu, seo, reach, social-proof). Each check returns earned + max + status (pass/partial/fail) + detail +fixUrllinking to the dashboard page that resolves it. ReturnsgradeA-F and top-3 prioritised recommendations. - `/api/seo/health` (new GET route): tenant-scoped, requiresrequireTenantAccess. Loads tenant DB + food-bazar listing + Google connection status in parallel and returns the scored report. Read-only, no side effects. - `/dashboard/seo` gets a new Health tab between Overview and Meta & Tags: - Headline score card with circular progress + grade letter (A-F) + recompute button. - Top 3 recommendations card with directFixlinks. - Per-category breakdown showing every check with progress bar + detail + per-checkFix thislink. - Footer note distinguishing the broader health score from the legacy SEO-settings-only Overview score. - Verification: - TypeScript: 0 errors -next build: ✅ green (332 pages);/api/seo/health,/bazar,/bazar/restaurant/[slug]all registered - HTTP probes:/bazar→ 200, emits WebSite + BreadcrumbList JSON-LD blocks ✓;/llms.txt→ 200, mentionsFood Bazar (N restaurants)with live count ✓;/api/seo/healthwithout auth → 401 (correct) ✓;/bazar/restaurant/<unlisted>→ 404 (correct gating) ✓ - Known limitations / deferred: - `/bazar/menu` SEO-wise relies on layout — page is a client-rendered filtered browse view; not a stable canonical URL so we don't split-server-wrap it. - ItemList currently empty in dev because no restaurants are yet approved on the marketplace. Listing tenants via/dashboard/marketplacewill populate it automatically. - Reviews aggregation is per-tenant only; cross-restaurant marketplace aggregate review surfacing (e.g. "average ⭐ across all listings") is a future polish. - Reserve with Google end-to-end feed format (the partner-program half of Phase 7) is not in scope here — that requires Google's partner application and separate spec compliance work.- Google integration — Phase 6: Order with Google redirect + AI optimisation
- Place Action Links sync — pushes the URLs behind Google's coloured CTA buttons (Reserve a table · Order online · Pickup). Order with Google's end-to-end ordering was deprecated July 2024, so the
FOOD_ORDERINGURL we set here is where every "Order online" click is routed by Google now — the largest single commercial lever in the GBP surface area. - `gbp-api-client.ts` addslistPlaceActionLinks(tenantId, locationResourceName)/upsertPlaceActionLink(tenantId, { locationResourceName, link })/deletePlaceActionLink(tenantId, name)againstmybusinessplaceactions.googleapis.com/v1. Mock store keeps state across calls; mirrors the "at most one preferred perplaceActionType" demotion contract. - `sync-engine.ts` addssyncPlaceActionsToGbp({ tenantId }). Lists existing actions, dedupes + updates in-place byplaceActionType, carries Google-assignednameresource identifiers for PATCH. Recordscounts: { created, updated }history entry withresource: 'place-actions'. - Umbrella `syncAllToGbp` extends to include place-actions;SyncAllResult.partsgains aplaceActions?field. - API route/api/google/syncacceptsresource: 'place-actions'(Zod enum extended). - Schema:GoogleSyncEvent.resourceenum extends with'place-actions'. - UI: new "Reserve · Order · Pickup buttons" sync row on/dashboard/settings/googlewith one-line description; surfaces what's being set. - FAQ JSON-LD — AI Overview citation magnet.buildFaqLd(siteUrl, faqs)+deriveFaqs(db)insrc/lib/seo/structured-data.ts. The heuristic generator produces 4-7 reasonable FAQs from existing tenant settings (reservations, delivery, hours, dietary options, payments, address) — no schema additions needed. Wired into the tenant home page (/tenant/[tenant_id]/page.tsx) so it renders alongside Restaurant + WebSite + Breadcrumb JSON-LD. Gemini / ChatGPT / Perplexity / Apple Intelligence / Google AI Overviews all heavily favour FAQPage structured data for "Do they take reservations?" / "Are they open?" / "Do they have vegan options?" answer-engine queries. - `/llms.txt` route (new) — emerging convention for LLM crawlers (spec: https://llmstxt.org). Markdown content that tells LLMs the canonical, authoritative pages for grounding their answers. - Platform mode: returns Restora-level overview with links to top resources (docs, designers, suppliers, agents). - Tenant mode: returns merchant-specific guidance — name + cuisine + today's hours + phone + address + popular dishes (top 5 bypopularflag) + canonical page tree (home / menu / reservations). - Subdomain detection is via host-parse against a known platform-hosts set, not the proxy'sx-tenant-baseheader —llms.txtis treated as a static asset by the proxy and skips rewrite. - Verification (live, mock-mode): - TypeScript: 0 errors - ESLint: 0 errors on changed files (8 pre-existing warnings on tenant page<img>tags + 1 unused-var, all out of scope) -next build: ✅ green; 8 Google routes + new/llms.txtregistered - HTTP probes:curl http://luigi.localhost:3001/llms.txtreturns Luigi-specific markdown with cuisine + today's hours + 5 popular dishes ✓ -curl http://localhost:3001/llms.txtreturns platform-level file ✓ -curl http://luigi.localhost:3001/shows the new FAQ JSON-LD block with 6 Q&A entries derived from tenant data ✓ -/api/google/syncwithresource:'place-actions'correctly returns 401 unauth ✓ - Known limitations / deferred: - No operator-curated FAQs yet —deriveFaqsis purely heuristic. A future polish would let operators add custom FAQs viatenant.settings.faqs[]. - AI visibility tracking (do we appear in ChatGPT / Gemini / Perplexity citations?) needs an external monitoring service — deferred indefinitely. - No per-Place-Action overrides — operator can't yet set a different URL for, say, DoorDash redirect vs Restora checkout. Phase 8 polish. - Real production smoke test still requires GBP API approval (~60 day Google application).- Google integration — Phase 5: Reviews + Q&A inbox
- Schema additions: -
GoogleReview— name, reviewId, reviewer,starRating('ONE'–'FIVE'), comment, createTime, updateTime,reply({ comment, updateTime }), internalsentiment('positive' | 'neutral' | 'negative' | 'urgent') +isReadflag -GoogleQuestion— name, questionId, text, author, upvoteCount, totalAnswerCount,topAnswers[], plus internalownerAnswercache +isReadflag -GoogleAnswer— name, answer text, author (MERCHANT|REGULAR_USER|LOCAL_GUIDE), upvoteCount, timestamps -GoogleConnectiongainsreviews?+questions?(capped 50/20) +lastInboxPullAt+lastInboxPullError-GoogleSyncEvent.resourceenum extends with'reviews' | 'qa'- `gbp-api-client.ts` extends with four review/Q&A endpoints: -listReviews(tenantId, locationResourceName)— GET/v4/{location}/reviews-replyToReview(tenantId, reviewName, comment)— PUT/v4/{review}/reply-listQuestions(tenantId, locationResourceName)— GET/v4/{location}/questions-answerQuestion(tenantId, questionName, text)— PUTmybusinessqanda.googleapis.com/v1/{question}/answer(Q&A uses a separatemybusinessqandaAPI) - In-process mock fixtures: 5 realistic reviews (mix of ⭐⭐ — ⭐⭐⭐⭐⭐ with one reply already in place + one urgent 2★ recent complaint) + 2 Q&As (one answered, one still open about gluten-free options) - `src/lib/google/inbox-engine.ts` (new, ~210 LOC): -pullInbox(tenantId)— list reviews + questions from Google, merge against cache by resource name (preservesisRead+sentiment), persists on the connection, caps at 50/20 -classifySentiment(review)— heuristic: 1-2★ + <3 days old + no reply →urgent; 1-2★ otherwise →negative; 3★ →neutral; 4-5★ →positive. Real LLM sentiment is Phase 8 polish -postReviewReply(tenantId, reviewName, comment)— validates length (1-4000 chars), calls Google, updates cached review with reply -postQuestionAnswer(tenantId, questionName, text)— same pattern for Q&A; ownerAnswer cache updated -markRead(tenantId, { reviewIds, questionIds })— setisReadflags -getInbox(tenantId)— read-only accessor for API routes -unreadCounts(conn)—{ reviews, questions, urgent }for badge counts - API route/api/google/inbox: -GET ?tenantId=…[&pull=1]— return cached state, optionally trigger a Google pull first -POSTwith discriminated union body —action: 'pull' | 'reply' | 'answer' | 'markRead'. AllrequireTenantAccess-gated. - Operator UI/dashboard/settings/google/inbox/page.tsx(new, ~350 LOC): - Mock-mode banner when active - 5 stat tiles: Total · Urgent · Unreplied · Replied · Avg ★ - 5 filter chips: All · Urgent · Unreplied · Replied · Questions - Review cards: avatar + reviewer name + 5-star display + sentiment chip (Urgentin rose) + body. Card border tinted by sentiment (rose/amber/gray/emerald). Inline reply textarea + Send button. Once replied, shows the response in a teal-bordered nested block. - Question cards: blue HelpCircle icon + asker name + upvote count + body. If owner already answered, shows it in teal block. Otherwise shows up to 2 top community answers + an "Answer as the business" textarea. - Empty state with friendly explanation when filter has no items - Pull-from-Google button at top (re-merges incoming data) - Settings page gets a new "Reviews + Q&A inbox" entry above the sync rows that links into the inbox page. - Verification: - TypeScript: 0 errors - ESLint: 0 errors / 0 warnings -next build: ✅ green; 8 Google routes registered (addedinbox) - HTTP probes: all auth-gated routes return 401 unauth ✓; invalid action returns 400 ✓ - Mock fixtures persist across pulls — reply to a review and a re-pull preserves the reply - Known limitations / deferred: - Sentiment is heuristic, not LLM — Phase 8 polish. Currently maps stars + age to one of 4 buckets. - No Q&A delete — Google supportsDELETE /v1/{answer}but we don't expose it yet. - No review deletion (reviewers can edit/delete their own; merchants can flag-not-delete via separate "report" API). - No bulk reply / template responses — operator types each reply manually for now. - Real production smoke test still requires GBP API approval.- Google integration — Phase 4: Menu / Offers / Media sync
- Schema:
GoogleSyncEventgainsresource: 'location' | 'menu' | 'offers' | 'media' | 'all'and an optionalcounts: { created?, updated?, skipped? }field so per-resource history entries explain how many records moved. - `gbp-api-client.ts` extends with three v4 resource families: -upsertFoodMenu(tenantId, { locationResourceName, menu })— single menu per location; POST → PATCH semantics under the hood -createLocalPost(tenantId, { locationResourceName, post })+listLocalPosts()— create-then-dedupe pattern (no upsert on Google's side; we POST new posts) -uploadMedia(tenantId, { locationResourceName, media })+listMedia()— usessourceUrl(since our images are already hosted) - In-memory mock fixtures keep state across calls (_mockMenus/_mockPosts/_mockMedia) so demos build up over time -httpJsonextended to support POST in addition to GET/PATCH (LocalPosts + Media use POST for creates) - `sync-engine.ts` expands with three new sync functions sharing onepreflight(tenantId)bootstrap: -syncMenuToGbp({ tenantId })— runs offline shape validation gated tofoodMenus[*]issues, pushes the menu (1 per location). Recordscounts.updated = itemCount. -syncOffersToGbp({ tenantId })— composes posts frompayload.localPosts+ activetenant.offers(withcouponCodehonoured), dedupes bysummaryagainst existing posts, returnscounts.created+counts.skipped. -syncMediaToGbp({ tenantId })— composes frompayload.media, dedupes bysourceUrl, returnscounts.created+counts.skipped. -syncAllToGbp(opts)— umbrella that runs location → menu → offers → media in sequence. Records a singleresource: 'all'history event with partial-OK semantics (status: 'partial'if any child failed). - Original Phase 3syncTenantToGbppreserved as backwards-compat alias forsyncLocationToGbp. - validateOnly caveat: only the location resource supports server-side validation (FoodMenus / LocalPosts / Media v4 endpoints don't have it). The engine silently skips validateOnly for those — callers seevalidateOnly: falseon returned events. - API route/api/google/syncacceptsresource: 'location' | 'menu' | 'offers' | 'media' | 'all'(defaults tolocationfor back-compat). Each resource gets its own switch arm; invalid values return 400. - Operator UI on/dashboard/settings/google: - SingleSync everythingaction button moved to the card header - Four per-resource rows (Hours + info / Menu / Offers / Photos) with one-line descriptions of what each pushes + their ownSyncbutton. Location row keeps the Dry-run button. - Recent activity collapsed into<details>— shows resource type as a small chip per entry - Verification: - TypeScript: 0 errors - ESLint: 0 errors / 0 warnings on all changed files -next build: ✅ green; all 7 Google routes preserved (callback,connect,disconnect,link-location,locations,status,sync) - HTTP probes:{ resource: 'menu' | 'offers' | 'all' }all return 401 unauth ✓; invalid resource returns 400 ✓ - Known limitations / deferred: - No deletion sync — removed products / cancelled offers won't disappear from Google until manual cleanup. Phase 8 work. - LocalPosts dedupe is by summary text, not stable ID. If an operator edits an offer title, a new post will be created (old one stays for 7 days per Google's TTL). - Media dedupe is by `sourceUrl`. Re-hosting an image at a new URL re-uploads it. - Real production smoke test still requires GBP API approval (~60 day Google application).- Google integration — Phase 3: Location picker + hours/business info sync
- `src/lib/google/gbp-api-client.ts` — thin HTTP wrapper around
mybusinessaccountmanagement.googleapis.com+mybusinessbusinessinformation.googleapis.com. Auto-injects bearer token fromgetValidAccessToken(), handles JSON response shape, branches to mock fixtures (1 mock account + 3 mock locations) when the connection is in mock mode. ExposeslistAccounts(),listLocations(accountResourceName),patchLocation({ locationResourceName, body, updateMask, validateOnly }). Returns a typedCallResult<T>withok/status/data/errorMessage/isMock. - `src/lib/google/sync-engine.ts` —syncTenantToGbp({ tenantId, validateOnly })end-to-end. Loads tenant DB → buildsLocationpayload via the existingbuildGbpPayloadbuilders → runs offlinevalidateOfflineto catch shape issues before any network call → PATCH against Google withvalidateOnly=trueto surface server-side issues → if valid ANDvalidateOnly !== true, runs the real PATCH → records every attempt on the connection'ssyncHistory[](capped at 20). Sync masks cover all Phase 3 fields:title,phoneNumbers,categories,storefrontAddress,websiteUri,regularHours,specialHours,moreHours,serviceArea,latlng,openInfo,profile. - Schema additions onGoogleConnection:lastSyncAt,lastSyncStatus('ok' | 'partial' | 'failed'),lastSyncError,syncHistory: GoogleSyncEvent[]. NewGoogleSyncEventtype with{ at, status, updateMask?, validateOnly, isMock, message? }. - 3 new API routes under/api/google/: -GET /api/google/locations?tenantId=…— returns{ accountName, isMock, locations[] }for the picker. Auto-fetches the first account if none cached on the connection yet. -POST /api/google/link-location— binds{ locationResourceName, displayName, accountResourceName? }to the tenant's connection. The displayed link survives until the operator unlinks or reconnects. -POST /api/google/sync—{ tenantId, validateOnly? }. Returns{ ok, validateOnly, isMock, message?, offlineIssues?, status? }. 200 OK on success, 400 on failure with structured details. - Operator UI at/dashboard/settings/googleextended: - Location picker card (amber "action needed" framing) appears when connected but no location is bound. "Load listings" pulls from/api/google/locations; tapping a location calls/api/google/link-locationand refreshes. - Sync card appears once a location is bound. ShowsLast synced X min ago(or "Not yet synced"), Dry-run button, Sync now button (teal CTA), and a recent-history list (last 8 attempts) with ✓/✗ status pills, MOCK/DRY tags, and timestamps. Sync errors render their truncated message inline in rose-tinted text. - Status fetch + new fields (locationResourceName,lastSyncAt,lastSyncStatus,lastSyncError,syncHistory) returned by extended/api/google/status. - Verification: - TypeScript: 0 errors - ESLint: 0 errors / 0 warnings on all changed files -next build: ✅ green; 7 Google routes registered (callback,connect,disconnect,link-location,locations,status,sync). - HTTP probes:/api/google/sync,/api/google/locations,/api/google/link-locationall return 401 without auth ✓ - Mock mode end-to-end: list returns 3 synthetic locations (Luigi's Pizzeria / Södermalm branch / Bar Bianchi cafe); picking one binds; sync writes bothvalidateOnlyand real PATCH paths to the history. - Phase 3 known limitations / deferred: - No auto-sync triggers — currently only manual "Sync now" or "Dry-run" buttons. Settings-save hook + nightly reconciliation cron land in Phase 8. - No bi-directional pull — we PATCH our values into Google but don't read Google's current state to detect operator edits made on Google's UI. Conflict resolver lands in Phase 8. - Updatable masks are coarse — every sync pushes every field. Future polish: diff against last-known-Google-state, only PATCH changed fields. - Real GBP API access required for `--live` runs — application takes ~60 days, so no production smoke test yet. Mock mode is end-to-end functional.- Google integration — Phase 2: OAuth + GBP connection
- Schema additions (
GoogleConnection,GoogleConnectionsDBinsrc/lib/db/schema.ts) — per-tenant connection record. One tenant ↔ one connection (reconnecting overwrites in place). Refresh tokens encrypted at rest asiv:tag:ciphertexthex tuples (AES-256-GCM). Mock placeholderMOCKso consumers can branch on synthetic case without trying to decrypt. - `src/lib/google/encryption.ts` — AES-256-GCM helpers (encrypt/decrypt/isMockToken). Key source priority:GOOGLE_TOKEN_ENC_KEYenv (32-byte hex) → dev fallback (SHA-256 of a static label, logged as insecure). Production reads throw if the env var is absent. - `src/lib/google/connections-db.ts` — flat-file storage atdatabases/google-connections.jsonmirroring the suppliers/designers DB pattern. Atomic writes via tmp + rename.findConnectionByTenant/upsertConnection/deleteConnection. - `src/lib/google/oauth.ts` — full OAuth 2.0 flow with PKCE + state CSRF: -buildAuthorizeUrl({ tenantId })→ authorize URL withcode_challenge(S256), signed-HMACstatecarryingtenantId+ 10-min expiry + the PKCE verifier -verifyState(state)→ constant-time HMAC check + expiry validation -exchangeCodeForTokens({ code, codeVerifier })→ POST tooauth2.googleapis.com/token-refreshAccessToken(refreshToken)→ silent renewal -revokeToken(token)→ best-effort revocation on disconnect -decodeIdToken+fetchUserInfo→ populategoogleEmail- Mock mode auto-engages whenGOOGLE_OAUTH_CLIENT_ID+_SECRETare absent ORGOOGLE_OAUTH_MOCK=1— all functions return synthetic tokens prefixedmock_at_/mock_rt_so the operator UI works end-to-end in dev/demo without a real Google Cloud project. - Default scopes:business.manage+userinfo.email+openid. - `src/lib/google/google-business-service.ts` — single facade over the OAuth + storage layer. Tokens never leak. Public lifecycle:startConnect/completeConnect/disconnect/getStatus/getValidAccessToken(refreshes silently, bumpslastRefreshAt, recordslastRefreshErroron failure).linkLocationfor binding a specific GBPlocations/{id}resource later. - 4 API routes under/api/google/: -POST /api/google/connect—requireTenantAccess-gated, returns{ url, state, mode }for the redirect -GET /api/google/callback— OAuth callback; exchanges code, persists connection, redirects to/dashboard/settings/google?connected=1&email=…(or?error=…) -POST /api/google/disconnect— revoke + delete (idempotent) -GET /api/google/status?tenantId=…— safe-to-leak connection metadata, includesoauthMockModeflag - Operator UI at/dashboard/settings/google/page.tsx: - Connection status card (Connected / Not connected) with mode pill (Live / Mock) - Connect / Disconnect buttons with confirm + loading state - Mock-mode banner explaining what's happening when no real credentials are configured - Success / error callouts from the OAuth callback redirect - Detail rows: Google account, linked location, granted scopes (withgoogleapis.com/auth/prefix stripped), connected-on, last refresh, last refresh error - "What we'll be able to do" preview when not connected, with explicit "we never post on your behalf without confirmation, never read Gmail" assurance - Smoke tests passed: - Encryption round-trip: encrypt → decrypt matches original - State signing: verified payload returns tenantId; tampered state rejected - Mock token exchange returnsmock_at_*/mock_rt_*prefixes - HTTP probes:/api/google/connectreturns 401 without auth ✓;/api/google/disconnectreturns 400 on invalid body ✓;/api/google/callbackredirects (302) on missing state ✓ -next buildsucceeds, all 4 routes + settings page registered - Env vars consumed: -GOOGLE_OAUTH_CLIENT_ID/GOOGLE_OAUTH_CLIENT_SECRET— real-mode credentials (absent → mock mode) -GOOGLE_OAUTH_REDIRECT_URI— defaults tohttp://localhost:3001/api/google/callback-GOOGLE_OAUTH_STATE_SECRET— HMAC secret for state signing (required in production) -GOOGLE_TOKEN_ENC_KEY— 32-byte hex AES key (required in production) -GOOGLE_OAUTH_MOCK=1— force mock mode even with live credentials present - Known limitations / deferred: location picker (after connect, operator needs to choose which GBP listing to link from a list — Phase 3); silent token-refresh cron (right now we refresh on demand); audit log of every API call against the connection (Phase 8); per-tenant rate-limiting against quota (Phase 8).- Google integration — GBP payload + validation toolkit (research-grade scaffolding)
- `src/lib/google/gbp-types.ts` — TypeScript types matching the wire format for the full GBP surface:
Location(categories / address / hours / latlng / openInfo / serviceArea),RegularHours+SpecialHours+MoreHours,AttributeValue(BOOL / ENUM / REPEATEDENUM / URL), `PlaceActionLink` (DININGRESERVATION / FOODORDERING / FOODTAKEOUT / FOODDELIVERY / APPOINTMENT), `FoodMenu` → `FoodMenuSection` → `FoodMenuItem` → `FoodMenuItemAttributes` with `DietaryRestriction` / `Allergen` / `Spiciness` / `PreparationMethod` enums, `LocalPost` (OFFER / STANDARD / EVENT / ALERT), `MediaItem` (COVER / EXTERIOR / INTERIOR / FOODANDDRINK / MENU). - **`src/lib/google/gbp-mock-payload.ts`** — `buildGbpPayload(input)` generates one fully-loaded `GbpFullProfile` from a tenant's existing DB. Covers: primary + additional categories (mapped per businessType — `pizzeria` → `Italian Restaurant` + `Pizza Restaurant` + `Catering Service`), 7-day regular hours with past-midnight closing, 1 holiday `specialHours` entry, three `moreHours` blocks (`DELIVERY` close 30min before kitchen close, `TAKEOUT` matching regular, `HAPPYHOURMon-Thu 16-18), 17 service/amenity attributes (dine-in / delivery / takeout / reservations / wheelchair accessibility / pet policy / payments / atmosphere / dietary), service area with 5km radius, three preferred Place actions, one full FoodMenu with cuisine enum per business type and dietary restriction + allergen + spiciness + preparation method mapping from product labels, two LocalPosts (OFFER + STANDARD), four media items (COVER / EXTERIOR / INTERIOR / MENU) plus per-product FOOD_AND_DRINK photos. - **src/lib/google/gbp-validator.ts** — two modes:validateOffline(profile)(default — no network, walks payload field-by-field with severity-tagged issues) andvalidateLive(profile, { accessToken, accountId, locationId })(callsmybusinessbusinessinformation.googleapis.com/v1/{locationId}?validateOnly=true&updateMask=…with proper bearer auth). Only Business Information API supportsvalidateOnly; FoodMenus / PlaceActions / Posts / Media fall back to offline-only. Returns aValidationReportwith errors / warnings / info bucketed by severity. - **src/lib/google/gbp-visual-mapping.ts** —renderVisualMapping(profile, report)emits a terminal-readable explainer of how each field renders on Google Search Knowledge Panel + Maps card. 8 sections: header, contact, hours (with per-hoursTypeIdsurface descriptions), attributes (with per-attribute UX surface labels), place actions, food menu (sample items with diet/allergen/heat/prep tags), local posts (carousel slots), media (per-category photo tabs). Summary footer with validation report. - **scripts/google/validate-gbp.mjs** — Node 24 ESM CLI using--experimental-strip-typesto load the TypeScript engine directly. Reads tenant DB from disk, derivesregionCodefrom address text, builds payload, runs offline or live validation, prints visual mapping. Usage:node --experimental-strip-types scripts/google/validate-gbp.mjs <tenantId> [--json | --live]. Live mode requiresGOOGLEACCESSTOKEN+GBPACCOUNTID+GBPLOCATIONIDenv vars. - **npm scripts** added:google:validateandgoogle:validate:json. - **Verified against the seeded ecosystem**: all 6 restaurants produce 0-error / 0-warning validation reports with distinct cuisine enums (ITALIAN+PIZZA,JAPANESE+SUSHI+ASIAN,VEGETARIAN,MEDITERRANEAN+TURKISH,CAFE+BREAKFAST+BRUNCH,AMERICAN+HAMBURGER). - **Honest scope notes**: no OAuth flow yet (Phase 2); live mode is plumbed but currently untested because we have no approved GBP API access nor a reallocations/{id}resource.--livewill surface the documentedvalidateOnly=truerequest and Google's response. FoodMenus / PlaceActions / LocalPosts / Media endpoints do not supportvalidateOnlyper Google docs — those remain offline-only. - **Research correction**: the original brief asked forONLINEDISPOSALDELIVERYas a moreHours type; the actualhoursTypeIdpercategories.batchGet(view:FULL)for restaurants isDELIVERY`. Verified via Google Business Profile API documentation (2026).- Google integration — Phase 1: Structured data + per-tenant SEO foundation
- `src/lib/seo/structured-data.ts` — pure builders that emit Schema.org JSON-LD from existing tenant DB shape, zero schema changes: -
buildRestaurantLd()— fullRestaurantentity with name / description / phone / email / cuisine / price range / opening hours spec / aggregate rating / reviews / address / geo /hasMenu/acceptsReservations/ currency / payment methods /sameAssocial URLs -buildMenuLd()—Menu→MenuSection[]→MenuItem[]tree with offers (price + currency +InStock) andsuitableForDietmapped to Schema.orgVeganDiet/VegetarianDiet/GlutenFreeDiet/HalalDiet/KosherDietIRIs -buildBreadcrumbLd()— standardBreadcrumbListfor any path stack -buildWebsiteLd()— minimalWebSiteentity, anchors Knowledge Panel - Internal helpers:cuisineFor()(businessType → canonical cuisine label),dietsFor()(labels → diet IRIs),openingHoursSpec()(our hours map → Schema.org spec including past-midnight closing),priceRangeFor()($, $$, $$$, $$$$ from menu average),aggregateRatingFor(),reviewsFor()(tolerant of bothauthor/commentand legacycustomerName/bodyfield names),postalAddress()(only uses last segment as country when ≥3 comma parts to avoid false country values),geo(),sameAsFrom()- `src/components/seo/JsonLd.tsx` — stateless component, takes one object or an array → emits one or more<script type="application/ld+json">blocks. Used by every page that wants Schema.org markup. - `src/lib/seo/site-url.ts` —resolveSiteUrl()resolves canonical site URL with priority:tenant.customDomain> detected subdomain host >${NEXT_PUBLIC_SITE_URL}/tenant/<id>platform fallback. - Tenant home page (/tenant/[tenant_id]) now emits: -Restaurant,WebSite, andBreadcrumbListJSON-LD -generateMetadatawith title / description / OG / Twitter Card / canonical pulled fromtenant.settings.seo(operator-overridable) with sensible fallbacks - Tenant menu page (/tenant/[tenant_id]/menu) now emits: -MenuandBreadcrumbListJSON-LD -generateMetadatadistinct from the home page so Google indexes/menuseparately for dish-level queries - `src/app/sitemap.ts` extended to surface every active, non-demo tenant (home / menu / reservations) plus/bazar+/bazar/menu. 6 tenants × 3 routes = 18 new tenant URLs in the index. - `src/app/robots.ts` (new) — disallows operator/admin/API surfaces and transactional pages (/tenant/*/checkout,/tenant/*/order-confirmation,/tenant/*/account); explicitly opts every major AI crawler in (GPTBot,Google-Extended,PerplexityBot,ClaudeBot,anthropic-ai,CCBot,Applebot-Extended); points atsitemap.xml. - Smoke test (live):curl http://luigi.localhost:3001/returns three JSON-LD blocks with full Restaurant entity (name + telephone + 7-day opening hours + 3 reviews with named authors + 4.7★ aggregate + Stockholm postal address + lat/lng + hasMenu + acceptsReservations + SEK currency);curl /menureturns full Menu tree (6 sections, 18 items with prices in SEK and dietary IRIs);curl /sitemap.xmlshows 18 tenant URLs + 2 bazar URLs alongside the existing partner/docs surfaces;curl /robots.txtreturns the policy. - Phase 1 follow-ups deferred: FoodBazar SEO surfaces (need re-activated listings first), SEO Health Scorer, FAQ schema, llms.txt, per-subdomain sitemap/robots, marketplace aggregator pages. Each lands in its own session.- Phase 3b — supplier + designer + sales-agent ecosystem
- `scripts/seed/lib/partners-builder.js` — pure builders for partner records (
makeSupplierAndOwner/makeSupplierProduct/makeSupplierOrder/makeDesignerAndOwner/makeDesignerRestaurantLink/makeDesignerCommissionEntry/makeSalesAgentAndOwner/makeReferralCode/makeReferral/makeAgentCommissionEntry). All match live schemas insrc/lib/db/schema.tsexactly so the runtime UI lights up without further work. - Orchestrator extension — now upserts into 4 DB files atomically (master.json+designers.json+sales-agents.json+suppliers.json), recomputes denormalised counts on every save (supplier.productCount/orderCount,designer.restaurantCount,agent.referralCount/activeRestaurantCount/totalCommissionEarned). - `reset.js` bug fix — was using the wrong field namecommissionEntriesinstead of the actualcommissionLedgerondesigners.json, leaving orphan commission entries on every reset. Now correctly cleans them. - 6 supplier scenarios covering all 5 coverage tiers needed by the geo-intel engine: -01-nordic-foods— Stockholm wholesaler, eu-wide coverage, 8 products, 3 Luigi B2B orders -02-verde-beverages— Stockholm distributor, international coverage, 7 beverages, 1 Brew Bros order -03-pakka-green— Birmingham eco packaging manufacturer, eu-wide, 6 products, 1 Al Madina order -04-cresta-farm— Florence farm, local-only coverage, 6 fresh-produce items, pickup-focused -05-clean-kitchen-supply— Malmö cleaning supplies, regional Sweden coverage, 5 products -06-plant-pure-co— London plant-based distributor, eu-wide, 7 products, 2 Green Earth orders - 3 designer scenarios covering the brief's archetypes: -01-anna-freelancer— solo featured freelancer, 2 clients (Luigi + Sakura), 4 commissions -02-marco-agency— Berlin premium agency, 2 clients (Brew Bros + Green Earth), 4 commissions -03-priya-marketplace— Mumbai new designer, 1 client (Al Madina), 1 pending commission - 4 sales-agent scenarios with status diversity: -01-anna-top— Stockholm top performer, 3 converted + 1 lead, 5 commissions ($78.90 earned) -02-marco-steady— Milan steady-tier, 2 converted + 2 in pipeline, 2 paid commissions -03-tom-new— NYC new agent, 1 lead, 0 conversions, 0 commissions yet -04-priya-suspended— Mumbai agent suspended for compliance review, 1 converted + 1 rejected, 1 held commission (tests the suspended-account UX) - Ecosystem totals after re-seeding: 6 tenants + 20 users (1 superadmin / 6 owners / 6 suppliers / 3 designers / 4 agents); 39 supplier products + 7 B2B orders; 5 designer↔tenant links + 9 commission entries; 11 referrals + 9 agent commissions. - Login matrix (allpassword123): suppliersorders@nordicfoods.test/sales@verdebeverages.test/orders@pakkagreen.test/sofia@crestafarm.test/orders@cleankitchen.test/orders@plantpure.test; designersanna@wd-demo.test/marco@wd-demo.test/priya@wd-demo.test; agentsanna-agent@sa-demo.test/marco-agent@sa-demo.test/tom-agent@sa-demo.test/priya-agent@sa-demo.test. - Validation: TypeScript untouched (no app code changed). Build untouched. All FKs valid (no orphan references after the reset.js fix). Walkthrough recaptured — Luigi's dashboard KPIs still render real data after the partner-only run. - Known gaps deferred: 2 more suppliers + 2 more agents for the brief's 8/6 counts; 30 customer-ecosystem archetypes; supplier reviews + quote requests; compliance + escrow seed (Phase 4); other 6 restaurant archetypes (Phase 3a₂).- Phase 3a — 6 restaurant scenarios + seed orchestrator
- `scripts/seed/lib/builder.js` — pure helpers for emitting records in the exact shape the runtime expects (
makeTenantAndOwner,makeSettings,makeCategory,makeProduct,makeOrder,makeCustomer,makeStaff,makeReview,emptyTenantDB) plus i18n / ID / timestamp / password helpers. Mirrorsdatabases/tenants/template.jsonexactly so the dashboard, KDS, orders pages, customer surfaces, and review surfaces all render with non-empty data. - `scripts/seed/scenarios/lib/order-mix.js` — shared 8-order mix generator used by every restaurant scenario. Produces a predictable status spread: 4 orders dated today (paid+delivered / pending / accepted in KDS / ready for pickup), 1 yesterday delivered, 1 rejected, 1 four-days-old, 1 week-old. Today-orders count + Pending Orders KPI always non-zero after seeding. - `scripts/seed/orchestrator.js` — CLI loader.--dry-runpreviews everything that would be written;--only PATTERNfilters scenarios. Loads everyscenarios/*/.*.jsmodule, dedupes against existing master.json (idempotent re-runs), writes atomically viawriteJsonAtomic. Replaces matching records on re-run instead of duplicating. - 6 restaurant scenarios (~1,500 LOC total, each ~250 LOC of full menu / customers / orders / staff / reviews / location-aware settings): -pizza-luigi— Luigi's Pizzeria, Stockholm. 18 products across pizzas/pastas/starters/salads/desserts/drinks. SEK currency. -burger-flash— Flash Burgers, NYC. 15 products, smash burger menu, late hours (Fri/Sat until 02:00). USD. -vegan-green-earth— Green Earth, London. 13 plant-based products with vegan/gluten-free labels. GBP. -sushi-sakura— Sakura, Tokyo. 17 products covering nigiri, maki, sushi sets, ramen, small plates. JPY. -cafe-brew-bros— Brew Bros Café, Melbourne. 16 products, AM-only café hours (07:00–15:00). AUD. -halal-al-madina— Al Madina Grill, Birmingham. 18 products including kebabs, mixed grills, curries, breads. Evenings-only (16:00–24:00). GBP. - Ecosystem totals after re-seeding: 6 tenants, 7 users (1 superadmin + 6 owners), 97 products, 33 categories, 48 orders (24 dated today, 6 pending), 21 customers, 16 staff, 18 reviews. Every dashboard KPI tile now renders with realistic numbers. - Backups taken:databases/backups/pre-phase3-reset-…before the reset;databases/backups/pre-reset-…auto-created byreset.js --yes. Roll back vianode scripts/seed/reset.js --restore <name>. - Walkthrough recapture:docs/qa/pages/restaurant-dashboard.htmlregenerated with the new Luigi data — KPI tiles show$118.68 / 4 / 0 / 1instead of all zeros, proving the ecosystem is alive end-to-end. - Login matrix (allpassword123):owner@luigi.com,owner@flash-burgers.com,owner@green-earth.co.uk,owner@sakura-japan.com,owner@brew-bros.cafe,owner@al-madina-grill.co.uk. - Known gaps deferred to next phase: No reservations seeded (Active Reservations KPI still 0 — easy to add). No suppliers/designers/agents/customers-as-VIPs (Phase 3b territory). Other 6 restaurant archetypes (fine dining / food truck / pickup-only / dine-in only / delivery-focused) follow the same pattern. Compliance + escrow still empty (Phase 4).- Visual QA explorer + walkthrough generator (foundation)
- `scripts/explorer/capture-page.js` — Playwright-driven capture: programmatic login via
/api/auth/login, session-cookie carry-over to the tenant subdomain, full-page screenshot + per-region cropped screenshots from a CSS-selector registry. Emits amanifest.jsondescribing what was captured. - `scripts/explorer/generate-doc.js` — fuses the capture manifest with a hand-tuned annotation JSON into a single self-contained HTML document. Reads PNG dimensions from the IHDR chunk (no library dep), picks a layout per crop (normalstacked vstallwithmax-height: 560pxholder), inserts numbered dividers between regions (02-welcome,03-kpis…), and emits a rich SEO/AI metadata block. - Sample walkthrough:docs/qa/pages/restaurant-dashboard.html— restaurant owner dashboard broken into 6 regions (attention bar, welcome strip, KPI cards, recent activity, onboarding checklist, sidebar nav). Each region carrieswhat it is/what to look for/✓ good/✗ bad/💡 pro tips. Real screenshots committed underdocs/qa/screenshots/restaurant-dashboard/. - Lightbox viewing: native<dialog>overlay opens any screenshot at full resolution; Esc / backdrop / close button dismiss. No new-tab navigation on click. - Tall-image policy: images with aspect < 0.65 (e.g. the 480×1800 sidebar) scale to fit a 560px-tall holder so the whole element stays visible without dominating the page; click → lightbox shows actual resolution. - Unified divider rhythm: every region renders image-on-top + text-below, separated by a centered chip showing the full region ID (02-welcome) on a tapered horizontal line. Predictable reading pattern, no per-page tuning. - SEO + AI metadata baked in from the start: canonical URL, OpenGraph (type/title/desc/url/image/sitename + article:section + article:publishedtime), Twitter Card (summary_large_image), JSON-LDTechArticle+BreadcrumbList+HowTo(each region is aHowToStep), embedded<script type="application/json" id="restora-qa-data">island with the full annotation payload for AI ingestion / offline tooling, Microdata viaitemscope/itemtypeon figures, semantic HTML throughout (<header>,<main>,<aside>,<article>,<section>,<figure>,<nav>,<footer>). - Environment-aware URL generation:docs/qa/environments.jsondeclaresdev/sandbox/prodwithplatformBaseUrl,tenantHostPattern, env colour, and warning copy. Switch via--env sandboxat gen time. Annotations carrytenantSubdomain+loginPath+pagePath(no more hardcodedlocalhost). When real sandbox/prod environments come online, onlyenvironments.jsonneeds updating. - Rich top + bottom chrome: brand bar with env pill (teal DEV / purple SANDBOX / red PROD), sticky page-title strip with breadcrumb, env warning callout in non-prod, sticky TOC sidebar with anchor links to each region, footer with three columns (platform links / demo creds / walkthrough metadata) + generation provenance. - Print-friendly:@media printhides chrome + TOC + lightbox; region cardsbreak-inside: avoidso PDF exports stay clean. - Accessibility: ARIA labels on lightbox triggers,<dialog>for native focus trap + Esc handling,role="region"semantics inherit from existing components,noindexin non-prod environments to keep dev/sandbox docs out of search indexes. - What's NOT here yet: mobile-viewport capture pass (script supports it via env var — needs a second run), multi-page index of all walkthroughs, LLM-draft path for the long tail of routes, automated diff-against-previous-capture for regression detection. Each lands in its own session.- Ecosystem seed-reset toolkit — Phase 2 (safe-reset infrastructure)
- `scripts/seed/reset.js` — selectively wipes demo/seed data from
databases/with three modes: dry-run (default, never mutates),--yes(apply after auto-backup),--restore NAME(roll back to a snapshot). Default-safe: passing no flags prints the plan and exits. - `scripts/seed/backup.js` — point-in-time snapshot of alldatabases/*.json+databases/tenants/*.jsonintodatabases/backups/<label>-<timestamp>/with aBACKUP.jsonmanifest. Supports--listand--prune N. - `scripts/seed/lib/patterns.js` — single source of truth for what counts as "resettable". Onlyadmin@platform.comis universally preserved; every other current account is treated as demo per the Phase 1 audit confirmation. Preserve-sets ready to grow as real production data appears. - `scripts/seed/lib/inventory.js` — read-only reporter that walks every DB and prints what's present + what would be reset. Used by both the dry-run and the post-apply summary. - `scripts/seed/lib/safe-io.js` — atomicwriteJsonAtomic()(tmp + rename),copyDir(),humanBytes(), and friends. Mirrorssrc/lib/db/db.tspatterns. - FK cleanup, not orphaning: when a resettable designer/agent is removed, preserved tenants get theirdesignerId/salesAgentIdnulled instead of carrying dangling references. Same logic fororganizationIdon emptied chains. - Mandatory auto-backup before mutation —db:reset:applyalways saves apre-reset-<ts>snapshot first; skip via--no-backuponly for advanced runs. - npm scripts:db:backup,db:backup:list,db:reset(dry-run),db:reset:apply,db:reset:list. - Baseline backup taken:databases/backups/baseline-2026-05-29T16-19-51-740Z/captures the pre-toolkit state in case anything goes sideways during scenario regen later. - Dry-run validated against current state (34 tenants, 31 users, 7 designers, 7 agents, 5 suppliers, 19 bazar listings, 8 supplier orders) — output shows exactly what would change without touching disk. - What's NOT here yet: scenario seeders (Phase 3),accounts.htmlautogen (Phase 5), QA manual + tracker (Phase 6/7), Playwright visual explorer (Phase 8). Each lands in its own session after the reset infra is proven safe on the current dataset.- Supplier geo-intelligence engine — proximity, coverage, shipping fit (task 94 polish)
- New geo engine (
src/lib/supplier/geo-intel.ts) — explainable scoring layered over the Phase G matcher. Three layers stack: 1. Hard eligibility (isInScope) — drops suppliers who structurally can't serve a restaurant. A Stockholmlocal-onlysupplier no longer shows up in Tokyo. Coverage tier + country match + shipping cap drive eligibility. 2. Tier scoring (scoreGeoFit) — bucketises distance/relationship into 9 friendly tiers:local(<30km) /nearby(30-100km) /same-city/regional(100-500km) /same-region/national/adjacent(neighbouring country) /eu(both in EU/EEA bloc) /international. Each tier carries a UX label ("Local — 8 km away") and weighted points (20 → 2). 3. Shipping multiplier — pickup-only suppliers at distance get ×0.2 (visible but ranked well below capable shippers); the badge surfaces "Pickup only — long trip" so the buyer knows why. - `SupplierCoverage` enum onSupplierRecord:local-only/regional/nationwide/eu-wide/international. Optionallatitude/longitude/shippingMaxKm. All optional — existing rows default tonationwide. - Country-adjacency matrix — hand-curated for ~50 high-traffic countries spanning Nordics, EU, British Isles, North America, Asia-Pacific. Neighbours are bidirectional even when listed once. Trade-practical (e.g. Swedish restaurant gets DK/NO/FI before US). - EU/EEA set — used for the "EU-wide" tier and theeu-widecoverage scope. Includes Norway, Iceland, Switzerland (trade-practical, not strictly EU). - Tenant signal enrichment —gatherTenantSignals()now pulls lat/lng frombranchSettingsorsettings.coordinatesand infers country from the free-text address using a small lookup of ISO codes + common country names. - Friendly reason chips flowing through the existing leads UI: - "Local — 8 km away" / "Same city" / "Ships within Sweden" / "Ships from neighbouring country (DK)" / "EU-wide delivery" / "Ships internationally" - "Pickup only — long trip" (amber friction signal) / "Pickup available" / "Ships to your address" - "Local supplier" / "Set up for export" (coverage-derived bonuses) - Supplier coverage editor at/supplier/coverage— three sections: 1. Coverage scope — radio-group of 5 emoji-prefixed cards with descriptions. Inline amber callouts when scope contradicts declared fulfillment methods ("International coverage usually requires Pick · Pack · Ship"). 2. Cities served — chip input for serving-region overrides; restaurants in these cities get an extra match bonus. 3. Distance precision (optional) — lat/lng + max self-delivery cap. Links to Google Maps when the supplier doesn't have coords handy. Live "What restaurants will see" preview banner at the top. Sticky Save button with toast confirmation. - APIGET/PATCH /api/supplier/[supplierId]/coverage—requireSuppliergated, owns the coverage scope + serving regions + coords + shipping cap. Strict ownership check. - Public + tenant directory surfaces now show coverage badges (Local 🏘️ / Regional 🗺️ / Nationwide 🇸🇪 / EU-wide 🇪🇺 / International 🌍) on every supplier card. Detail page also surfaces serving regions as small chips ("Also delivers to: Stockholm · Göteborg · Malmö"). - Supplier marketplace public API (/api/public/suppliers+/api/public/suppliers/[slug]) now returnscoverageso cards + detail pages can render the badge. - Reverse matching (`matchRestaurants`) uses the same geo engine in inverse — supplier-side leads no longer surface restaurants the supplier structurally can't serve. Skip rate drops dramatically for narrow-scope suppliers. - New "Coverage" nav link in the supplier left rail (Globe icon) between Orders and the logout. - Future-ready: engine is structured so freight integrations / dynamic shipping quotes / route optimisation can be additive — none of those require schema refactors.shippingMaxKmfield is the future-hook for delivery-zone APIs. - 0 TypeScript errors, ESLint clean,next buildgreen. 2 new routes registered.- Food Supplier Program — Phase H: Quote system + volume pricing (task 94)
- `SupplierQuoteRequest` schema (new, in
schema.ts) — full bidirectional record covering the request → respond → accept lifecycle: - Status machine:pending → responded → accepted | declined | expired | cancelledwith side-aware transition table (only the supplier canrespond; only the tenant canaccept; system handlesexpired). - Per-lineSupplierQuoteItemwith optionalproductId(lines may reference a catalogue product OR be custom request items the supplier doesn't list publicly),requestedQuantity/requestedUnit, and supplier-filledquotedUnitPrice+quotedNotes. - Per-quoteSupplierQuoteEventhistory with{ at, fromStatus, toStatus, by, reason }. Mirrors the order-event pattern so the audit UI is consistent across products / orders / quotes. - SnapshotsrestaurantName+supplierCompanyNameso the response UI doesn't need master-DB joins. - Resolution fields:respondedAt,responseExpiresAt,responseNotes,responseTotalAmount+responseCurrency,acceptedAt,declinedAt,declinedReason,cancelledAt,cancelledReason,resultingOrderId(back-reference to spawned SupplierOrder). - Quote engine (src/lib/supplier/quote-engine.ts): -createQuoteRequest()— restaurant submits; per-supplier sequential numberingQU-YYYY-NNNNN. -respondToQuote()— supplier sets per-lineunitPrice+ optional notes + currency + expiry window (default 7d, max 30d). Auto-totals. -acceptQuote()— atomically spawns aSupplierOrderfrom quoted prices, currency, and contact. BypassesplaceOrder()because quoted prices intentionally diverge from catalogue prices; order flows through the existing payment/status pipeline. -resolveQuote()— unified decline / cancel with side validation (tenant can't decline a quote that belongs to another tenant; supplier can't cancel one outside their account). -expireStaleInPlace()—respondedquotes pastresponseExpiresAtget rolled toexpiredlazily on every read (no cron). - APIs: -POST /api/tenant/[tenantId]/quotes— create -GET /api/tenant/[tenantId]/quotes— list this tenant's quotes -GET /api/tenant/[tenantId]/quotes/[quoteId]— detail -POST /api/tenant/[tenantId]/quotes/[quoteId]—{ action: 'accept' | 'decline' | 'cancel' }(accept optionally takesfulfillmentMethod) -GET /api/supplier/[supplierId]/quotes— list incoming quotes -GET /api/supplier/[supplierId]/quotes/[quoteId]— detail -POST /api/supplier/[supplierId]/quotes/[quoteId]—{ action: 'respond', itemPrices[], responseNotes?, responseExpiresInDays?, currency? }or{ action: 'decline' | 'cancel', reason? }- All routes gated byrequireTenantAccess/requireSupplierwith strict ownership checks. - Restaurant UI: - "Request a quote" modal (newsrc/components/supplier/RequestQuoteModal.tsx) — multi-line item editor with a catalogue dropdown (pre-fills name + unit) or free-text custom items. Adds contact override, desired delivery date, free-text notes. Mounted on every supplier profile page in the marketplace. - `/dashboard/supplier-quotes` — operator inbox with 4-tile KPI row (Awaiting supplier / New offers / Accepted / Closed), open + closed sections, status-coloured chips. Detail view shows side-by-side requested vs. quoted, supplier reply notes, accept (creates a SupplierOrder + links to/dashboard/supplier-orders) / decline / withdraw actions, expiry callout. - Sidebar nav entry "Supplier Quotes" between Marketplace and Orders. - Supplier UI: - `/supplier/quotes` — quote inbox with the same KPI / tab pattern. Detail view is a response form: per-lineunitPrice+ per-line notes inputs, response notes textarea, quote-valid-for dropdown (1/3/5/7/14/21/30 days), currency picker. Decline button on pending quotes; resolved banners for sent / accepted / declined / expired / cancelled states. Left-rail "Quotes" link with FileText icon. - Volume pricing wired all the way through: - API schemas (CreateBody+PatchBody) acceptpriceTiers: { minQuantity, unitPrice }[](max 6). - Product drawer editor — new "Volume pricing" section with add/remove rows, in-form validation. Sorted ascending on save; empty rows dropped. - Public marketplace card — tier badges, live unit price recomputed from the selected quantity, strikethrough base price when a tier is active. The active tier highlights in emerald so the operator can see exactly which bracket they're hitting. - Cart submission —effectivePrice(p, qty)helper mirrors the engine'spriceAt(), so the cart shows the right unit price before checkout. Server still recomputes authoritatively inplaceOrder()(price-tier maths already shipped in Phase C — no double truth). - Public supplier API now returnspriceTierson each product. - Phase H follow-ups deferred (per scoping): email notifications on quote events (uses the Phase D supplier-notifications hook — add when SMTP is wired), supplier-initiated "outbound introduction" flow (different direction; tracked as Phase I), tier-aware analytics in supplier dashboard. - 0 TypeScript errors, ESLint clean,next buildgreen. 7 new routes registered.- Supplier Leads UX overhaul — match visualisation + multi-view layout (task 94 polish)
- Match % bar with tiered colour ramp (amber → sky → teal → emerald). Score is normalised against
TARGET_SCORE = 40(engine's realistic ceiling: cuisine ≈16 + ingredients 14 + city 5 + reputation 5), so a 100% bar means all signals fired strongly — not just "the best of this result set". Keeps tier labels honest across pages. - Tier system:Weak / Moderate / Strong / Excellentthresholds at 26/51/76%. Tier label + colour drives the match bar, the inlineTierBadge, and a new "Excellent fit" KPI tile. - Four view modes with a top-right radio-group switcher: Grid (current card layout, default), List (horizontal rows with mini-bar), Detailed (full metadata + matched-ingredient chips + action rail + larger compatibility bar), Compact (high-density table-style row for power users — ~3× more leads per screen). - View preference persistence in localStorage, scoped persupplierId(supplier_leads_view_v1:<id>). Restores on refresh, falls back to Grid if localStorage is blocked. - API + engine enrichment to feed the richer views: -RestaurantLeadgains optionalmatchedIngredients[](top 8 shared tokens, sorted),menuItemCount(menu-size proxy),isHeadquarters(HQ vs branch). Already computed by the matcher — just unexposed before. -/api/supplier/[supplierId]/leadsresponse passes these through. -TenantSignalsextends withmenuItemCount. - Accessibility: match bar exposesrole="progressbar"+aria-valuenow/min/max+ descriptivearia-valuetext(e.g. "82% match — Strong Match"). View switcher is arole="radiogroup"witharia-checked; filter chips usearia-pressed. Compact view uses properrole="table"/row/columnheader/cell. Loading + error states marked withrole="status"/role="alert". - Search box now also matchesmatchedIngredients, so a supplier can type "mozzarella" and find every lead whose tenant menu touches mozzarella. - KPI row expanded to 4 tiles: Total / Excellent fit / New prospects / Cross-sell. - Future-ready architecture: view registry pattern (VIEW_MODES) makes adding Kanban / Map / AI-ranked / Saved-Collections views a matter of appending an entry + writing one renderer. No fork of fetch / filter / state. - 0 TypeScript errors, ESLint clean,next buildgreen.- Food Supplier Program — Phase G: Enhanced matching + supplier-lead discovery (task 94)
- Ingredient extractor (
src/lib/supplier/ingredients.ts) — tokenises restaurant menu names, product-level ingredient lists, IngredientItem inventory rows, and category names into a normalised "ingredient bag". Hand-tuned stop-word + unit list (~50 entries) drops noise; cheap singularisation handlestomatoes/cheeses/loaveswithout needing a tokenizer dependency. Set-based intersection helpers (intersect,intersectionSize) used by both sides of the matcher. - Enhanced matching engine (src/lib/supplier/matching.ts) — extends the Phase D cuisine + keyword scorer with three new signals: - Ingredient overlap — 2pt per token shared between the tenant's bag and the supplier's product-name/description/tag bag (cap 14). Surfaces matches the hardcodedCATEGORY_SIGNALStable misses, e.g. "tenant menu mentions pesto + ricotta → supplier listing pesto + ricotta scores even without a cuisine tag". - Location proximity — same-city match (+5pt) or city in supplier's free-textservingRegionslist (+4pt). No lat/lng on suppliers so we keep this coarse. - Order-history affinity — restaurant has already placed a non-cancelled order with this supplier → +15pt + "Your usual supplier" prepended to reasons. Strong "stick with what works" signal that pins existing relationships to the top. - Reverse matching (matchRestaurants(supplierId)) — flips the engine: supplier → ranked restaurant leads. Combines ingredient overlap with supplier's catalogue, cuisine alignment with carried categories, city/serving-region proximity, and a small +3pt cross-sell bump for existing customers. Skips demo + inactive tenants. - Public APIGET /api/supplier/[supplierId]/leads?limit=24—requireSuppliergated, scopes by owningsupplierId. Returns rank-ordered leads with{ tenantId, name, subdomain, city, cuisine, score, reasons[], isExistingCustomer }. Default limit 12, max 40. - Supplier Leads UI at/supplier/leads— summary tiles (total / new / cross-sell), search box (matches name / city / cuisine / reasons), New/Existing filter chips, ranked lead cards. Top 3 in default view get a "Top match" trophy badge. Existing customers get a "Customer" badge. Reasons rendered as chips. Includes a collapsible "How we rank these leads" explainer that links back to product/CSV import / API to help suppliers improve their match quality. New "Leads" nav link in the supplier left rail with a Sparkles icon. - Phase G follow-ups deferred (per original scoping): per-product image AI tagging for richer bag tokens, AI-assisted recipe parsing, and lat/lng on suppliers for haversine distance ranking. Phase H (quote system + MOQ tiers + custom-quote request flow) is the planned next step. - 0 TypeScript errors.next buildsucceeds with the new/supplier/leadsroute + leads API.- Documentation Center rebuild — Phase 4 (task 89)
- Analytics persistence engine (
src/lib/docs-center/analytics.ts) — JSON flat-file atdatabases/docs-analytics.json(gitignored). Atomic writes via tmp + rename. Caps: 2000 recent search events, 120-char query truncation, 1000-char comment truncation, 50 comments retained per article. ExportsrecordFeedback,recordSearchEvent,recordView,getAnalyticsSnapshot. Aggregations cover totals, top-viewed articles, top-positive (≥5 votes), weakest articles (<60% helpful with ≥5 votes), top searches, zero-result searches (content-gap signal), and recent comments. - `/api/docs/feedback` upgraded from log-only stub to actual persistence — still returns 202 on persistence failure so the UX never blocks on a write hiccup. - `/api/docs/search-events` — new endpoint accepting{ query, resultCount, clickedArticleId? }. Best-effort persistence; navigation-safe viakeepalive: trueon the client. - Client-side search instrumentation (DocsSearch.tsx) — debounced 800ms search-event ping captures query intent without flooding the endpoint, plus a click-event ping ingo()that joins "what query → which article" for the admin dashboard. - Admin Docs Analytics page (/admin/dashboard/docs-analytics) —requireSuperAdmin-gated,noindex, dark-themed. KPI row (helpful votes / not-helpful / searches / views). Two-column grid: top searches, zero-result searches (with a "content gaps — write articles for these" hint), most-viewed articles, weakest articles. Recent reader comments below. AdminSidebar gets a "Docs Analytics" entry under Platform & System. - i18n schema field (additive, optional) onDocArticle—i18n?: Record<LocaleCode, { title?, metaDescription?, summary?, sections?, faq? }>. Slugs stay locale-neutral; missing fields fall back to the default-locale (English) field-by-field, not article-by-article. - New article/docs/i18n/overview— documents the convention: principles, article shape with worked TS example, storefront translation flow, RTL support, fallback chain (fr-CA→fr→ tenant default →en), and how to add a new docs language until the CMS lands. - Phase 4 follow-ups deferred (intentionally out of scope): CMS adapter (Sanity/Contentful/Strapi reader behind the same renderer contract — the newi18nfield is already shaped to be the JSON contract); per-language search indexing (currently the active locale builds the index at render time); analytics retention longer than 2000 events (P0 SQL migration). - 0 TypeScript errors. Total docs corpus now 34 articles across 30 categories.- Documentation Center rebuild — Phase 3 (task 89)
- 11 developer / API articles taking the corpus from 22 to 33: - API:
authentication(4 cookie types + role helpers),errors(envelope + status-code table + machine-readable codes),rate-limits(per-family limits +Retry-Aftersemantics),idempotency(Idempotency-Key header + commission-ledger keying + webhook replay),orders(full lifecycle + transition rules + cancellation),payments(payment intents + escrow states + manual refunds),menu(public read + tenant CRUD + reorder). - API/Webhooks:overview(HMAC-SHA256 signature verification + exponential-backoff retry + event-type catalogue),order-events(payload reference fororder.created/status_changed/canceled),payment-events(payment.succeeded/failed/refunded). - Developers:local-setup(clone → seed → run + tenant subdomain access),environment-variables(full env-var matrix organized by subsystem: Core / Stripe / Email / Cron / AI, with DEV-mode behavior when unset). - Style: every article uses real cURL + JSON examples through the existing fenced-code-block renderer (auto copy-to-clipboard from Phase 1). HMAC verification has a complete TS snippet developers can paste. - Coverage: the API category landing now shows 10 articles instead of 1; Developers category landing shows 2 instead of 0. Sidebar reflects this immediately. - Phase 3 follow-ups deferred: Postman collection download, downloadable OpenAPI spec, additional webhook events (reservation.*/gift_card.*/commission.*), per-language SDK examples (Node / Python / PHP). - 0 TypeScript errors.next buildsucceeds — 33 article pages now pre-rendered (SSG) + 30 category landings + dynamic/docs/changelog.- Documentation Center rebuild — Phase 2 (task 89)
- 16 new polished articles taking the corpus from 6 to 22 across the most-trafficked surfaces: - Getting Started:
checklist,platform-overview- Restaurant:onboarding- Payments:refunds-and-cancellations- Marketplace Commission:overview- Billing:pricing,manage-subscription- Ordering:overview- Management:menu-management,qr-menus,website-builder- Franchise:multi-location-overview- White-label:overview- Analytics:overview- Security:overview- GDPR:overview- Troubleshooting:payments- API:getting-started- FAQ:overview— single article with 30+ Q&As grouped by topic (onboarding, payments, subscription, refunds, sales agents, technical, account, security, API, enterprise) for FAQPage JSON-LD rich snippets. - Changelog renderer (/docs/changelog): server-rendered page that parsesCHANGELOG.mdvia the newlib/docs-center/changelog-parser.ts(Keep-a-Changelog format:## [Version]opens a release,### Typeopens a group). Renders each release with type-coloured pills (Added/Changed/Fixed/Removed/Security/Deprecated) + the MarkdownRenderer for full markdown support inside groups. BreadcrumbList JSON-LD + per-release in-page anchors. - Sidebar wiring: surfaces the dynamic/docs/changelogas a synthetic "Release notes" entry under the "Release Notes" category (special-cased — no DocArticle exists for it). - Icon registry extended withUtensils,Globe,RefreshCw(consumed by the new articles). - 10 new 301 redirects auto-emitted from each article'sredirectFrom:/help/getting-started-checklist,/help/features-overview,/help/online-ordering-explained,/help/qr-menus-explained,/help/website-builder-overview,/help/pricing-explained,/help/security-and-privacy,/help/faq. - Phase 2b deferred (~24 more articles): Delivery, RBAC, AI features depth, SEO depth, Admin dashboard, System Architecture, Developer setup, i18n, individual Troubleshooting drill-downs. - 0 TypeScript errors.next buildsucceeds — 22 article pages + 30 category landings + dynamic/docs/changelogall generated.- Documentation Center rebuild — Phase 1 (task 89)
- Plan (
docs/decisions/0001-docs-center-rebuild-plan.md): full audit + 4-phase rebuild plan covering the 30 documentation sections from the master spec. Current → target URL mapping for every existing article (12 public + 33 dashboard). - URL space: new/docstree — home (/docs), category landings (/docs/[category]), articles (/docs/[category]/[slug]). 30 categories registered ("Coming soon" landing for empty ones so the IA is complete from day one). - Schema (src/lib/docs-center/schema.ts):DocArticle/DocCategory/DocSection/DocFAQ/DocAudience/DocCodeBlock. Articles carryredirectFromfor automatic 301 maps andupdatedAtfordateModifiedJSON-LD. - Markdown renderer (src/components/docs-center/MarkdownRenderer.tsx): dependency-free, ~150 LoC. Supports H2/H3/H4 (auto-id'd for TOC), unordered + ordered lists, bold / italic /inline code, fenced code blocks with copy-to-clipboard, blockquote callouts with emoji-coded tones (💡 tip / ⚠️ warning / 🚨 danger / default info),[text](href)inline links. - Search: client-side fuzzy with token-scoring (title × 10 + summary × 4 + tags × 3 + body × 1). Cmd/Ctrl+K opens a global modal anywhere on/docs; the home page renders the input inline with a live-results dropdown. Up to 12 results. ↑/↓/Enter keyboard nav. - Components:DocsHeader(sticky + search trigger + cross-platform nav),DocsSidebar(collapsible 30-category tree, active-article highlight),DocsTOC(right-rail, IntersectionObserver-driven active section),DocsSearch,MarkdownRenderer,CodeBlock,FAQAccordion(reuses partners-page ARIA pattern),RelatedArticles(3-card row),ArticleFeedback(👍 / 👎),ReadingProgress(top progress bar). - JSON-LD:WebSite+SearchActionon the docs home;Article+BreadcrumbListper article;FAQPagewhen the article has FAQ entries. - Starter articles (6):getting-started/what-is-restora,getting-started/sign-up,payments/stripe-onboarding,sales-agent/program-overview,management/table-qr-pay,promotions/gift-cards. Each has a full FAQ, related-articles set, CTA, and tags. - Plumbing: -/api/docs/feedbackPOST endpoint — logs feedback today, persistence in Phase 4. -next.config.tsredirects()block sourced from each article'sredirectFromso adding aliases never edits config. - Sitemap extended with/docs+ 30 category URLs + 6 article URLs. - Side fix (caught bynext build): splitlib/feature-flags/engine.tsinto pureevaluate.ts(browser-safe FNV-1a hash, no DB import) and DB-touchingengine.ts. Admin feature-flags page (task 88) now importsevaluatefrom the pure file, so client components no longer drag server-onlydb.tsinto the bundle. - Phases 2–4 deferred: writing the remaining ~24 sections (Phase 2), developer/API reference (Phase 3), feedback persistence + analytics + i18n + CMS adapter (Phase 4). Total remaining effort ≈ 55 hours. - 0 TypeScript errors.next buildsucceeds —/docs/[category]and/docs/[category]/[slug]are statically pre-rendered (SSG).- Feature flags system for gradual rollouts (task 88)
- Schema (
src/lib/db/schema.ts): additive — newFeatureFlagonMasterDB.featureFlags?withkey(stable, unique,^[a-z0-9][a-z0-9-]{1,49}$),name,description,mode: 'off' | 'on' | 'percentage' | 'tenants', optionalrolloutPercentage(0–100), optionalallowedTenantIds[]. - Engine (src/lib/feature-flags/engine.ts): -isFeatureFlagEnabled(key, tenantId?)— async helper. Returnsfalsewhen the flag doesn't exist (opt-in semantics). -evaluate(flag, tenantId?)— pure synchronous evaluator. Used by the admin UI to live-preview without hitting the API. -bucketFor(key, subject)— SHA-256-derived deterministic bucket [0, 99]. Same(key, subject)always produces the same bucket, so percentage rollouts never flicker between requests. - Admin APIs under/api/admin/feature-flags: -GET— list all flags (newest first). -POST— create. Validates key shape, uniqueness, mode, clampsrolloutPercentageto 0–100 + ignores it unless mode =percentage. Audit-logged. -PATCH /[id]— partial update. ClearsrolloutPercentageif mode becomes anything other thanpercentage, clearsallowedTenantIdsif mode becomes anything other thantenants. Audit-logged. -DELETE /[id]— removes. Audit-logged. - Admin UI (/admin/dashboard/feature-flags): - 4 stat cards (total / on / off / gradual+scoped). - "Evaluate as tenant" input — re-runsevaluate()against every card in real time and shows anENABLED / DISABLED for samplepill per flag. - Per-row: click-to-copy mono key, mode pill, name + description, percentage bar forpercentage-mode flags, allow-listed tenant count fortenants-mode flags, inline mode dropdown to flip state, Edit + Delete actions. - Create / edit modal with key (create only), name, description, mode dropdown, percentage slider, tenant-IDs textarea (newline or comma separated). - Admin sidebar: "Feature Flags" entry added under Tenants & Billing alongside Feature Grants. - 0 TypeScript errors.- White-label for restaurant groups (task 87)
- Schema (
src/lib/db/schema.ts): additive —Organization.whiteLabel: OrganizationWhiteLabel(masterenabledflag +brandColors{primary,secondary,background,text}+favicon+hidePoweredBy+emailFromName+emailFooterText+updatedAt). - Resolver (src/lib/group/white-label.ts):getWhiteLabelForTenant(tenantId)resolves viatenant.organizationIdthen the master toggle, returnsnullfor non-chain tenants.buildBrandCssOverride(wl)emits a:root{…}style string with--primary/--secondary/--background/--textoverrides (skips any colour that's not set). - Tenant layout (/app/tenant/[tenant_id]/layout.tsx): -generateMetadatanow setsicons.iconto the white-label favicon when configured — overrides the platform default on every branch site. - The layout injects the brand-CSS<style>element on every render path (demo gate, demo authorised, normal storefront) so chain colours apply across menu, checkout, confirmation, etc. -DemoGatenow accepts ahidePoweredByprop and renders "Demo environment" instead of "Powered by Restora · Demo environment" when true. - APIs: -GET /api/group/[orgId]/settingsnow includesorg.whiteLabelin the response. -PATCH /api/group/[orgId]/settingsaccepts awhiteLabel: Partial<OrganizationWhiteLabel>block that merges into existing config (per-key updates; bumpswhiteLabel.updatedAt). - UI (/dashboard/group/settings): new "White-label storefront" section at the bottom of the page: - Master enable/disable toggle in the section header (state pill). - 4-colour palette grid (primary / secondary / background / text) with paired colour-picker + hex text input per slot. - Favicon URL field. - "Hide 'Powered by Restora'" checkbox. - Email from-name + footer-text inputs (persisted; mailer integration is v2 follow-up). - All fields auto-save on blur via the newsaveWhiteLabelhelper. - Scope note: email from-name + footer-text are persisted but not yet rewired intosendMail— flagged as v2. - 0 TypeScript errors.- Staff scheduling — Phase 1: weekly grid + shifts (task 86)
- Schema (
src/lib/db/schema.ts): additive —StaffMembergainsjobRole?,hourlyRate?,color?,isActive?. NewStaffShift(id,staffId,date,startTime,endTime,role?,notes?,locked?) surfaced onTenantDB.staffShifts?. - APIs: -GET /api/tenant/[tenantId]/shifts?from=YYYY-MM-DD&to=YYYY-MM-DD— returns shifts in the window, enriched per row withstaffName,staffColor,defaultRole,hours,hourlyRate,estimatedCost. -POST /api/tenant/[tenantId]/shifts— strict validation:YYYY-MM-DDdate,HH:MMtimes, end > start, staff exists + active. -PATCH/DELETE /api/tenant/[tenantId]/shifts/[shiftId]— partial update with same validators; idempotent delete. -PATCH /api/tenant/[tenantId]/staff/[staffId]extended to acceptjobRole / hourlyRate / color / isActive(all optional). - UI (/dashboard/scheduling): - Week navigator (prev / "This week" / next) anchored on ISO Monday; per-day hour + cost totals shown in column headers. - 7-day × staff matrix table with sticky left "Staff" column. Each cell shows shift cards (color-tinted background + 3px brand-coloured left border, time range, role + hours), plus a subtle "+ add" affordance below. - Click-to-add on empty cell + click-to-edit on shift card share one modal: staff dropdown (active-only), date, start/end (<input type="time">), role override, free-text note. Modal shows Delete on existing shifts. - Weekly per-staff hours + estimated payroll cost in the rightmost summary column. - Top right shows weekly totals: total hours, total shifts, estimated payroll cost (when hourly rates are set). - Staff-settings modal: inline editor per row for job role + hourly rate + grid color + active toggle, with per-row save. - Sidebar: "Scheduling" entry added under Operations next to Staff & Permissions. - Phase 2 deferred: time-off requests, conflict / overlap detection, "copy week" duplication, swap requests, mobile-friendly day view. - 0 TypeScript errors.- Advanced reporting — profit margins & food cost (task 85)
- Engine (
src/lib/reports/profit-margin.ts): no new schema — joins existing orders ×RecipeMapping×PurchaseEntryto compute per-product COGS, then per-product / per-category margins. -buildIngredientCostMap— weighted-average unit cost per ingredient from purchase history (source: 'purchases'); falls back toIngredientItem.purchasePrice('manual'); marks'unknown'when neither exists. -buildProductCogsMap— sumsrecipe.ingredient.quantity × ingredient.unitCostper product. Products without a recipe mapping are flaggedhasRecipe: false. -buildMarginReport— date-filtered + payment-status-gated; aggregates revenue / COGS / gross profit / margin% per product and per category, computes org-wide totals, surfaces top 5 by gross profit and bottom 5 by margin% (among products with revenue + a recipe), counts products with missing or partial COGS coverage. - API:GET /api/tenant/[tenantId]/reports/margins?from=YYYY-MM-DD&to=YYYY-MM-DD. Defaults to the last 30 days (inclusive) when params are absent; swaps from/to when out of order. Only paid orders count toward revenue. - UI (/dashboard/reports): - 5-stat KPI row: Revenue, COGS, Gross profit (brand-gradient highlight), Margin %, Units sold. - Date-range picker with 7d / 30d / 90d / YTD presets + custom from/to inputs. - "COGS coverage is incomplete" amber banner appears when sold products lack a recipe mapping or have ingredients without purchase history — deep-links to/dashboard/inventory. - Top earners + Weakest margins podiums (each top 5). - Category breakdown table sorted by revenue. - Per-product table sortable by revenue / gross profit / margin% / units / name. Margin pills color-graded (≥70% green, ≥50% blue, ≥30% amber, else red). Per-row warning chips for "no recipe" + "partial cost". - CSV export of the current window (product, category, units, revenue, COGS, profit, margin %, has-recipe, has-unknown). - Sidebar: "Profit Margins" entry under Analytics & AI alongside Analytics + AI Assistant. - 0 TypeScript errors.- Gift cards — purchase / email / redeem (task 84)
- Schema: additive
GiftCard+GiftCardRedemptiononTenantDB. Statusespending-payment→active→fully-redeemed|void.OrdergainsgiftCardCode+giftCardAmountsnapshot.CreateOrderSchemagainsgiftCardCodefield. - Engine (src/lib/gift-cards/engine.ts):generateCode()returnsGIFT-XXXX-XXXXcodes from a 32-char alphabet that skips look-alikes (I/O/0/1).createPurchase(pending),createManual(active immediately, staff-issued),markPurchasePaid(idempotent transition to active + sends recipient email at the API layer),previewApply(non-mutating max-applicable calculation with full reason text),applyRedemption(decrements balance + appends redemption record + flips tofully-redeemedat zero balance),voidCard(refuses if redemptions exist),getCardByCode(case-insensitive lookup),publicProjection(safe to return over public API). - Email (giftCardEmailtemplate): branded orange-gradient card with large balance, monospace code, optional personal-message callout, redeem-link CTA, plain-text fallback. - Public APIs: -POST /api/public/[tenantId]/gift-cards/purchase— creates pending card + Stripe PaymentIntent (DEV: simulatedpi_dev_*; PROD: real Stripe SDK). -POST /api/public/[tenantId]/gift-cards/purchase/confirm— soft-confirms afterconfirmCardPaymentreports succeeded (PROD re-checks the intent against Stripe), flips card to active, fires recipient email. -GET /api/public/[tenantId]/gift-cards/lookup?code=…[&subtotal=…]— public balance lookup; if subtotal provided, also returns the live max-applicable preview used by the checkout. - Public pages: -/gift-cards/[tenantId]/buy— branded hero, denomination presets ($25/$50/$100/$200) + custom + currency selector, from/to fields, personal-message textarea, Stripe Elements card form in PROD / DEV auto-accept. Success view shows the new code + copy button + check-balance link. -/gift-cards/[tenantId]/redeem?code=…— balance lookup with auto-fill from URL, full status pill (active / pending / redeemed / void), expiry display, redemption count, big balance card in brand colours when redeemable. - Checkout integration (/tenant/[id]/_components/CheckoutForm.tsx): new "Gift card" input alongside the coupon row. On apply, calls/lookup?subtotal=to get the max-applicable amount, displays it as an amber row in the totals with the remaining balance, deducts fromgrandTotal.giftCardCodeis now part of the order payload; the server route re-applies the engine'spreviewApply+applyRedemptionso the discount is never trusted from the client. - Order routes (/ordersPOST and/payment-intentPOST): both now apply gift-card logic AFTER coupon (gift card pays toward the remaining total).applyRedemptionis called after the order is persisted, with theorderIdlinked into the redemption record for full audit. - Tenant APIs: -GET /api/tenant/[tenantId]/gift-cards— list all cards (newest first). -POST /api/tenant/[tenantId]/gift-cards— staff manual issue. OptionalsendEmailflag fires the recipient email. -DELETE /api/tenant/[tenantId]/gift-cards/[cardId]?reason=…— voids an unredeemed card. - Dashboard (/dashboard/gift-cards): 4 stat cards (active count, outstanding liability, lifetime issued, lifetime redeemed), table with click-to-copy mono code, status pills, source badge (purchased/manual), recipient column, void button (only when no redemptions). Issue modal with denomination + currency + recipient + personal message + expiry + email toggle. - Routing:/gift-cardsadded toBYPASS_PREFIXESso the buy + redeem pages work on tenant subdomains. - Sidebar: "Gift Cards" entry under Marketing alongside Loyalty. - 0 TypeScript errors.- Kitchen Display System (task 83)
- API (
/api/tenant/[tenantId]/kds): lightweight feed of active tickets only (status inpending/accepted/preparing/ready), sorted bycreatedAtascending. Returns just the fields the kitchen needs — order number, type, customer name, items with quantity + modifier selections + removed ingredients + per-item notes, order-level note, scheduled time, all relevant transition timestamps. Bundles lane counts andpolledAtfor client display. - Page (/dashboard/kds): dark fullscreen-friendly view that escapes the dashboard chrome (-m-6 min-h-[calc(100vh-4rem)]). 4 lanes (New / Accepted / Preparing / Ready) with header pills and ticket counts. Per-ticket age timer (mm:ss) ticking every 1 s with color escalation — calm under 3 min, amber under 8 min, red 8 min+. Each ticket card: type chip (delivery / pickup), bold#orderNumber, customer name + scheduled-time hint, structured item list (quantity bold and tabular, modifier line indented, removed ingredients in red, item notes in italic amber), order-level note callout, single advance button with state-appropriate label ("Accept" → "Start cooking" → "Mark ready" → "Bump (complete)" — the last one removes the ticket from the board by transitioning todelivered). - Wiring: advance button uses the existingPATCH /orders/[orderId]endpoint so all escrow side-effects (applyOrderLockon ready,schedulePayouton delivered, fraud-detection prep-speed check, customer email notifications) fire correctly without re-implementation. Optimistic UI followed by re-sync. - Polling: every 5 s, paused on tab hidden. Newpendingtickets detected on a subsequent poll trigger a two-tone Web Audio chime (opt-in via header toggle; preference stored inlocalStorage). - Controls: header has sound toggle, fullscreen toggle (Browser Fullscreen API), manual refresh, plus live lane-count pills. - Sidebar: "Kitchen Display" entry added under Primary nav alongside Reservations. - No new schema. ReusesOrder+OrderTimelineEventend-to-end. 0 TypeScript errors.- Table-side QR pay (task 82)
- Schema (
src/lib/db/schema.ts): additiveTableBillLineItem+TablePayment(statusesopen/paying/paid/void) +tablePayments?onTenantDB. One open bill per table at a time. - Engine (src/lib/tables/table-payment-engine.ts):createBill(refuses if table already has an open/paying bill),getOpenBillForTable,markBillPaying(transitions on PaymentIntent creation),markBillPaid(idempotent),voidBill(refuses paid bills). All amounts rounded to 2dp. - Public APIs: -GET /api/public/[tenantId]/tables/[tableSlug]/tab— resolves the table byqrSlug, returns restaurant brand + table info + open bill (never reveals paid/void bills). -POST /api/public/[tenantId]/tables/[tableSlug]/pay— creates a Stripe PaymentIntent (DEV mode fabricates api_dev_*id; PROD uses real Stripe SDK). Idempotent — re-issuesclientSecretfor an already-payingbill. -POST /api/public/[tenantId]/tables/[tableSlug]/pay/confirm— soft confirmation called by the client afterconfirmCardPaymentreportssucceeded. In PROD it re-checks the intent against Stripe before marking paid; in DEV it accepts immediately. Idempotent. - Tenant APIs: -GET /api/tenant/[tenantId]/table-bills— list every bill, newest first. -POST /api/tenant/[tenantId]/table-bills— create new open bill (validates table + items + computes totals from line items). -DELETE /api/tenant/[tenantId]/table-bills/[billId]?reason=…— void an unpaid bill. - Public page (/pay/[tenantId]/[tableSlug]): brand-coloured gradient hero with table number/area, server-loads the open bill, hands off to a client island. Client island handles three states (no-bill yet with 6s polling + "Check now" button; open/paying bill with full summary, customer-note callout, and big "Pay <total>" CTA; paid with green confirmation card and final receipt). In DEV mode the PaymentIntent is fake — the confirm call marks the bill paid immediately. In PROD, mounts@stripe/stripe-jsand runsconfirmCardPaymentbefore confirming. Per-table pages setrobots: noindex. - Dashboard (/dashboard/table-bills): open-bills grid with status pills (open / paying), per-card itemised view, customer-pay-link opener (/pay/[tenantId]/[qrSlug]), inline void button. Recent (paid + void) list with timestamps and last4. New-bill modal with table dropdown (flags tables without a QR slug), line-item editor (add/remove rows, qty + unit price), tax + tip inputs, customer note, live currency-aware total preview. Polls every 6 s while there are open/paying bills. - QR generator (/api/tenant/[tenantId]/qr): newpay-tableURL type emitting${base}/pay/${tenantId}/${qrSlug}so a single QR sticker takes the customer straight to the pay page. - Routing:/payadded toBYPASS_PREFIXESso the route works on tenant subdomains. - Sidebar: "Table Bills" entry under Operations alongside Tables & QR Codes. - 0 TypeScript errors.- Customer-facing live order tracker (task 81)
- Public pages: -
/track— lookup form (subdomain + order id + email). Resolves the subdomain to a tenantId via the existing/api/domainsresolver, then redirects to the tracker URL. -/track/[tenantId]/[orderId]?email=…— full tracker. Server-renders for SEO/initial paint (with email-verification step when the param is missing or wrong), then hands off to a client island that polls/api/public/[tenantId]/orders/[orderId]every 4 s while live and every 60 s once the order is terminal. Pauses polling when the tab is hidden; re-fetches immediately on tab focus. - UX: - 5-stage progress tracker (Placed → Accepted → Preparing → Ready → Done) with current-step ring + checkmarks for completed steps. - Status pulse badge ("Awaiting confirmation" / "Accepted" / "Being prepared" / "Ready" / "Delivered" / "Cancelled"). - ETA card (latest event + 25 min) shown while the order is in flight. - Itemised order summary with line totals, payment method, grand total. - Activity timeline pulled fromOrderTimelineEvent[](newest first) with actor pills (customer / restaurant / system / admin). - Share + copy-link buttons (usesnavigator.sharewhere available, falls back to clipboard). - Self-service cancel button gated by the existing escrow grace period; surfaces refund amount + refund status once cancelled. - Routing: - Added/trackand/chainsto the tenant proxyBYPASS_PREFIXESso both routes work on tenant subdomains without being rewritten into tenant subroutes (/chainswas a Phase-4 follow-up — same proxy concern). - Confirmation flow: -CheckoutFormnow also passesemailto/order-confirmation. -/order-confirmationshows a primary "Track this order live" CTA pointing to/track/[tenantId]/[orderId]?email=…when bothorderIdandemailare present. "Back to Menu" / "Go to Homepage" demoted to secondary actions. - Sitemap:/trackentry added. - Security: per-order pages setrobots: { index: false, follow: false }. Email comparison is case-insensitive but strict. - 0 TypeScript errors.- Multi-Location Phase 4 — Customer-facing chain site (task 80)
- Schema (
src/lib/db/schema.ts): additive —Organizationgainsslug,tagline,publicDescription,publicLogo,publicHeroImage,brandColor,publishedAt;BranchSettingsgainscity,latitude,longitude,isPubliclyListed. - Public API (/api/public/chains/[orgSlug]GET): no auth, returns 404 unless the org is published AND has a slug. Filters out inactive branches and branches withisPubliclyListed=false. Returns brand-safe fields only (no operator email, staff counts, etc.). - HQ settings APIs: -/api/group/[orgId]/settingsGET (edit-side view of branding + every branch's public fields) + PATCH (slug + branding +publishedtoggle). Slug is normalised viaslugify()and uniqueness-checked across organisations. Publishing requires a non-empty slug; unpublishing clearspublishedAt. -/api/group/[orgId]/settings/branches/[branchId]PATCH — edits the new public branch fields only (city, lat/lng, listed). Operational settings stay on the existing per-branch dashboards. - Public page (/chains/[orgSlug]): server-rendered with dynamic metadata (canonical URL, OG/Twitter cards usingpublicHeroImage), JSON-LDOrganizationwithlocation[]ofRestaurantentries (incl.GeoCoordinateswhen available) and aBreadcrumbList. Gradient hero coloured bybrandColorwith optional image overlay, opt-in tagline + description, branch count chip, footer CTA pushing partner programs. Client islandChainBranchList: - search by city / branch name / address, - city-chip quick-filter row built from distinct branch cities, - "Use my location" button (navigator.geolocation) → Haversine sort via existinglib/bazar/geo, - HQ-first fallback ordering when no location set, - distance pill on each card, - hand-off link respectscustomDomainwhen present, otherwise routes to the branch subdomain. - HQ editor (/dashboard/group/settings): publish/unpublish toggle with state badge + outbound "View page" link, auto-save-on-blur branding form with native colour picker, branch table with inline city/lat/lng inputs and one-click Listed/Hidden toggle. - Sidebar: "Chain Page" entry added under Operations. - Sitemap:app/sitemap.tsnow appends every published org's/chains/[slug]route (lastModified =updatedAt). - Seeder (scripts/seed-multi-location.js): the Bella Cucina demo chain is now published asbella-cucinawith real coordinates for Stockholm / Gothenburg / Malmö / Uppsala. Uppsala is hidden from the public page (inactive +isPubliclyListed=false) so the page lists 3 branches. - 0 TypeScript errors.- Multi-Location Phase 3 — Group-scoped staff & RBAC (task 80)
- Schema (
src/lib/db/schema.ts): additiveUserRecord.organizationId?+UserRecord.groupRole?: 'owner' | 'manager'. Pure additions — every existing user record stays valid. - Access helpers: -requireGroupAccess(orgId)now grants the group surfaces to either the HQ tenant owner or any user whoseUserRecord.organizationId === orgIdANDgroupRoleis set. Verified against master DB on every call so demoting a manager loses access immediately. -requireTenantAccess(tenantId)extended with a single new branch: ifsession.tenantId !== tenantIdand the caller is atenant-ownerwithorganizationId+groupRolematching the target tenant's org, access is granted. Every existing call site picks this up for free. -resolveActiveOrgForUser(userId)resolves via both HQ ownership and group-role membership. - Dashboard plumbing: -(dashboard)/dashboard/layout.tsxrestaurants[]now includes every tenant in the user's organization, not just owned ones. RestaurantSwitcher shows the full chain. -/api/auth/loginroute gains a fallback: if atenant-ownerhas no owned tenant but hasorganizationId+groupRole, session.tenantId falls back to the HQ (or any active branch) so they land in a working dashboard. - APIs: -/api/group/[orgId]/staffGET — lists group-role users + the HQ tenant owner (implicit owner) + per-branch staff counts. POST — invites a new group-staff user (creates atenant-ownerUserRecord with the matching org + role; PBKDF2 password hash, 8-char minimum). -/api/group/[orgId]/staff/[userId]PATCH — change role between 'owner' / 'manager'. DELETE — stripsorganizationId+groupRolefrom the user (account remains, org access removed). Both refuse to touch the HQ tenant owner. All audit-logged. - UI (/dashboard/group/staff): group users list with crown/shield role icons, HQ-owner + "you" pills, inline role select, remove buttons (disabled for HQ owner + self), separate per-branch staff counts panel with deep-link to each branch's/dashboard/staff. Invite modal with email + name + temp password + role selector. - Sidebar: "Group Staff" entry added under Operations alongside the other Group HQ entries. - Seeder:scripts/seed-multi-location.jsnow also createsmanager@chain-demo.test/password123withgroupRole='manager'and no owned tenant — exercises the Phase 3 RBAC path end-to-end. - 0 TypeScript errors.- Multi-Location Phase 2 — Centralised menu engine + brand-wide promotions (task 80)
- Schema (
src/lib/db/schema.ts): addedMenuPropagationModeunion (all/categories-only/items-only/prices-only/availability-only),MenuPropagationEvent(append-only audit row withcounts,results[]per target,triggeredByUserId),BrandPromotion(org-scoped offer withoptedOutBranchIds[], same shape asOfferplus org metadata). Surfaced asmenuPropagationEvents?+brandPromotions?onMasterDB. - Engine (src/lib/group/menu-engine.ts):propagateMenu({ org, source, targets, mode, user })— five modes.prices-onlypatchesbasePrice+discountPriceon id-matched products;availability-onlysyncsstockTracking+stockQuantity. Per-target try/catch so a single bad branch doesn't poison the push. Returns an audit event the caller persists. - Resolver (src/lib/group/brand-promotions.ts):getActiveBrandPromotionsForBranch(tenantId)— filters byactive, opt-out membership, start/end date window. - APIs: -/api/group/[orgId]/menu/propagate— GET history (last 50 enriched with tenant names) + POST push (validates source + targets belong to org, audit-logs). -/api/group/[orgId]/promotions— GET list + POST create. -/api/group/[orgId]/promotions/[promoId]— GET / PATCH (whitelisted editable fields) / DELETE, all audit-logged. -/api/group/[orgId]/promotions/[promoId]/opt-out— POST toggles a branch into/out of the promo's exclusion list. -/api/tenant/[tenantId]/brand-promotions— GET tenant-scoped read for branch dashboards. - UI (/dashboard/group/menu): 3-step wizard (mode → source branch → multi-select targets), success card witheventId, propagation history list with per-target ok/fail breakdown and per-mode counts. - UI (/dashboard/group/promotions): grid of promo cards (type badge, active/featured pills, branch participation count, redemption count + max cap, coupon code, date window), inline pause/resume toggle, delete, edit. Create/edit modal with allOfferfields plus a per-branch participation matrix (Included / Excluded toggle) reconciled against the opt-out API after save. - Sidebar: added "Group Menu" + "Group Promotions" entries under Operations alongside Branches + Group HQ. - Branch integration:/dashboard/offerspage now fetches/brand-promotionsin parallel and renders an "Inherited from Group HQ" panel above the local-offer list with a deep-link to/dashboard/group/promotions. - 0 TypeScript errors.- Multi-Location Phase 1 — Group HQ dashboard (task 80)
- Access helper (
src/lib/group/access.ts):requireGroupAccess(orgId)— gates on authenticated session, ownership of the HQ tenant in the target organization, AND themulti-locationadd-on being enabled. Returns{ session, org, hqTenant, branches }on success.resolveActiveOrgForUser(userId)for server pages. - APIs under/api/group/[orgId]/: -overview(GET) — org-wide totals (orders today, ordersMTD, revenueMTD, revenueTotal, ordersTotal, staffCount), per-branch stats with HQ flag + 30-day per-day trend, top 10 items rolled up across all branches, 30-day org-wide trend (orders + revenue summed per day), active/total branch counts. -orders(GET) — unified order feed across every branch, branch-tagged (branchId,branchName,branchColor), sorted bycreatedAtdesc, supports?limit,?status,?branchIdfilters. -alerts(GET) — derived alerts with severity (critical/warning/info) and category (inventory/orders/reviews/branch). Surfaces: inactive branches, out-of-stock and low-stock ingredients (ingredientItems.currentStockvslowStockThreshold), stale pending orders (>30 min), low review rating (<4.0 with ≥3 reviews), each with a deep-linkhref. - UI (/dashboard/group): server shell resolves org from session, renders empty-state for solo restaurants and an add-on upgrade card if the gate is closed. Client view (GroupHqClient) provides 4 KPI cards, alert centre with severity grouping and counts, sortable branch ranking (revenue / orders / name) with HQ + inactive pills and per-branch progress bars, 30-day revenue trend bar chart, top 8 items list, live order feed table with relative timestamps and status pills, refresh button. Sidebar entry "Group HQ" added under Operations. - 0 TypeScript errors.- Sales Agent / Affiliate Program landing page (`/agents`) — conversion-focused recruitment page for the SA program.
- Sections: hero with live dashboard preview + 4 CTAs (Become a sales agent / Register / Log in / View commission structure), 6-stat animated trust strip, "What is a Sales Agent" 4-card explainer, 5-step "How it works" timeline, commission structure cards + interactive income calculator (sliders for restaurants × ARPR × months → live monthly / annual / lifetime projection with tier badge), 9-benefit grid, 8-card audience grid ("Who should join"), full-width dashboard preview, AI/GEO content block (4 Q/As targeted at LLM answer-engines), testimonials with earnings badges + aggregate rating, accessible FAQ accordion (10 entries), dark final CTA with 3 buttons. - SEO: dynamic metadata, OG/Twitter cards, JSON-LD for
Organization+Service(program withAggregateRating) +BreadcrumbList+FAQPage. Canonical URL set. Targets keywords: restaurant affiliate program, restaurant sales agent, recurring affiliate commissions, restaurant SaaS affiliate system, etc. - AI/GEO: dedicated content block answering "How does the Sales Agent program work", "How can I earn commission from referring restaurants", "Best affiliate program for restaurant SaaS", "How to become a restaurant sales partner" so ChatGPT / Claude / Gemini / Perplexity / Copilot can extract verbatim answers. - Components: new client islandssrc/components/agents-public/IncomeCalculator.tsx(interactive sliders → live earnings projection) andsrc/components/agents-public/DashboardPreview.tsx(visual mockup of/agent/dashboardwith KPIs, recent referrals, leaderboard). Reuses existingAnimatedCounter,PartnerFAQ,MobileStickyCTAfrom/partnerswork. - Cross-links wired: footer "Platform services" strip now points to/agents(was/agents/register); footer "Become a Partner" column SA entry →/agents;/partnersSA card primary action →/agents(secondary still →/agents/register);/agents/registernow has a "Back to program overview" header link. Sitemap entry added. - 0 TypeScript errors.- Become a Partner landing page (`/partners`) — world-class enterprise partner hub.
- Sections: hero with four CTAs (Become a partner / Create account / Sign in / View docs), animated trust-bar stats (6 counters, intersection-observed with
prefers-reduced-motionfallback), 7-card partner-type grid (Restaurant, Web Designer, Sales Agent — all LIVE — plus Delivery, Franchise & Enterprise, Supplier, Technology — coming soon), 5-step "How it works" timeline, 6-feature benefits grid, AI/GEO-optimised "What is Restora" content block (4 plain-spoken Q/As targeted at LLM answer-engines), documentation hub (4 links), testimonials with aggregate-rating row, accessible accordion FAQ (8 entries with full ARIA wiring), and a dark final-CTA block. - SEO: dynamic metadata, OG/Twitter cards, JSON-LD forOrganization+SoftwareApplication(withAggregateRating) +BreadcrumbList+FAQPage. Canonical URL set. - AI/GEO: dedicated semantic content block answering "Who is Restora for", "Why restaurants choose Restora", "How partners earn", "What makes Restora different" so ChatGPT / Claude / Gemini / Perplexity / Copilot can extract structured answers verbatim. - Mobile: full responsive layout from 320px up, plus a scroll-triggered mobile-only sticky CTA bar (Become a partner + Start free) that slides in after the hero. - Accessibility: section-scopedaria-labelledby, FAQ accordion usesaria-expanded/aria-controls, animated counters honourprefers-reduced-motion, decorative SVGs markedaria-hidden, focus rings on all interactive elements. - Components: new client islandssrc/components/partners/AnimatedCounter.tsx,PartnerFAQ.tsx,MobileStickyCTA.tsx(single-purpose, reusable). - Partner registry refactor (src/lib/partners.ts): removed thesales-agentcoming-soon entry (now LIVE at/agents/register); addeddelivery,enterprise,technologycoming-soon entries./partners/[program]continues to render the interest-capture flow for any program in the map. - Sitemap: added/partners,/register,/agents/registerand all/partners/[program]slugs (auto-derived from the registry). - 0 TypeScript errors. Copy and routing verified against existing live routes (/register,/login,/designers/register,/agents/register,/help,/contact).- Sales Agent — Phase 5: Fraud hardening, attribution reassignment, code expiry & maintenance cron (task 77)
- Referral-code lifecycle:
ReferralCodegainsexpiresAt,revokedAt,revokedBy.validateReferralCodenow rejects codes that are inactive, manually revoked, or past their expiry. Suspended agents' codes are auto-deactivated by the maintenance cron and re-enabled when reinstated; admin-revoked codes are permanent (never reactivated automatically). - Lead-create fraud guards (POST /api/agent/[id]/referrals): soft daily rate limit (20 manual leads / agent / day →429 DAILY_LIMIT_REACHED) and per-agent duplicate detection by case-insensitive email (409 DUPLICATE_LEAD, returns the existing referral id so the UI can deep-link). - Admin attribution audit + reassignment (/api/admin/sales-agents/attributionGET + POST): list every tenant currently attributed to an SA with agent + lock + tenant active state; reassign or clear with a required ≥5-char reason. Historical commissions remain with the original agent for audit integrity; the oldReferralis archived (with reason), a freshReferralis created for the new agent atstatus='active'+lockedAt=now. Audit-logged. New admin page/admin/dashboard/sales-agents/attributionwith search + reassign modal; sidebar entry "Attribution". - Maintenance cron (/api/cron/sales-agent-maintenance—CRON_SECRETor super-admin): one sweep that (a) auto-archivesnew_leadreferrals with no activity for 60 days, (b) syncs every code'sisActiveto the owning agent's status (respecting permanent revocations), (c) runs the scheduled SA payout batch. Returns a structured report. - Seeder: added a revoked legacy code (SA-KARLSSON-LEGACY) and a 75-day-old stalenew_lead("Forgotten Lead") so the cron's archive + code-revoke paths fire on first run. - Verified end-to-end on a live server: revoked code → 404, active code → 200, duplicate lead → 409 withexistingReferralId, reassignment movedtenant.salesAgentId+ archived oldReferral+ created locked new one, cron archived 1 stale lead, deactivated Sofia's code, paid 2 eligible agents (Tom's large payout correctly held), total paid 299.7 SEK. 0 TypeScript errors; reseeded clean.- Sales Agent — Phase 4: Real KPIs, follow-up reminders & support escalations (task 77)
- Dashboard KPIs API (
/api/agent/[id]/dashboard): total referrals, active restaurants (locked +tenant.isActive), conversion rate, commission this month, pending payout, lifetime paid, overdue follow-ups, open tickets./agent/dashboardnow renders real values with loading skeletons and three colour-coded alert cards (overdue follow-ups → referrals, open tickets → support, lifetime paid → payouts). - Support escalations: newSalesAgentSupportEscalation+supportEscalationsarray inSalesAgentsDB. Agent API/api/agent/[id]/support(list/create) +/api/agent/[id]/support/[ticketId](single + agent reply — an agent reply reopens a resolved ticket). Admin API/api/admin/sales-agents/escalationswith reply / resolve / close / reopen + audit logging./agent/supportpage (status counts, status+priority badges, right-drawer thread) +/admin/dashboard/sales-agents/escalations(dark admin queue, open-first sort, reply/resolve/close/reopen actions). Sidebars updated. - Follow-up reminders:/agent/referralsshows a "N follow-ups due" chip when any open-pipeline lead is past itsfollowUpAt; toggling it filters the list/kanban to those rows. The dashboard alert card surfaces the same count and deep-links here. - Seeder (scripts/seed-sales-agents.js): 6 support tickets covering every status × priority + multi-message threads; one Anna referral set to an overdue follow-up so the chip + dashboard alert light up out of the box. - Verified end-to-end on a live server: KPIs return real numbers (6 referrals, 33.3% conversion, 1 overdue follow-up, 2 open tickets, 399.6 SEK paid), every ticket status surfaces in the queues, agent→admin reply round-trip transitionsopen → in-progresscorrectly, cross-agent isolation 403. 0 TypeScript errors.- Sales Agent — Phase 3: Shared commission engine, Stripe Connect & payouts (task 77)
- Shared participant primitives (
src/lib/commission/shared.ts): single source of truth forresolveRate(per-participant → global → default),assessRiskBaseline(large payout / rapid activity / new-account-high-value),transferToConnect(Stripe Connect with DEV simulation + idempotency + structured errors), and thresholds. Designer engines refactored to use these helpers while keeping their public APIs intact. - Composite invoice trigger (src/lib/commission/triggers.ts): one fan-out call records commissions for every attached participant (designer and sales agent) on a paid invoice; the matching reverse trigger handles refunds/chargebacks across both. Wired into the tenant billing DEV path and the Stripe webhook (invoice.payment_succeeded,charge.refunded,charge.dispute.created) so a single paid invoice stacks both ledgers. - Sales-agent commission engine (src/lib/sales-agent/commission-engine.ts): idempotent ledger writes keyed bytenant.salesAgentId; on firstpendingentry for a tenant the matchingReferralis attribution-locked (lockedAt+ status →active) and the agent's denormalised stats update; refund/chargeback reversals + paid-entry clawbacks. - Sales-agent payout engine (src/lib/sales-agent/payout-engine.ts): same lifecycle as designer (≥50 SEK batch, Stripe transfer, retry on failure) but with no membership gate — only requires an active SA account + a connected Stripe Connect. Anti-fraud holds via shared baseline; admin override supported. - Stripe Connect for SAs (/api/agent/[id]/connect): onboarding endpoint (DEV simulation or production Express account+link). Connect webhook (account.updated) extended to keep both designer and SAstripeAccountStatusin sync. - Agent pages:/agent/billing(Connect onboarding + earnings snapshot + referral link),/agent/commissions(ledger with totals),/agent/payouts(batch history + failures + annual CSV via/api/agent/[id]/tax-summary). Sidebar gains Commissions / Payouts / Payout Account. - Admin SA payouts (/api/admin/sales-agents/payouts+/admin/dashboard/sales-agents/payouts): per-agent pending + eligibility + held-for-review + risk reasons, run-all / pay-one / retry; sidebar gains "Sales Agents → Payouts". - Cron (/api/cron/sales-agent-payouts): scheduled SA payout sweep (CRON_SECRET or super-admin), respects anti-fraud holds. - Verified end-to-end on a live server: one paid invoice produces both a designer entry (199.8 @ 20%) and a sales-agent entry (99.9 @ 10%); first SA entry locks the referral (lockedAtset, statusactive); admin SA payout → paid; designer payouts still work after the shared-engine refactor (3 designers paid, 1 correctly skipped); cron 401 without secret. 0 TypeScript errors.- Sales Agent — Phase 2: Referral attribution & CRM lead pipeline (task 77)
- First-touch referral attribution: -
RefCaptureclient component mounted in the root layout reads?ref=CODEand POSTs to/api/public/referrals/track, which validates the code and sets an httpOnlyref_codecookie (90-day TTL) — first-touch wins, never overwrites an existing valid attribution. -GET /api/public/referrals/[code]validates a code;GET /api/public/referrals/currentreads the cookie + returns the agent's display name. The/registerpage shows a "Referred by …" banner when the cookie is set. - Tenant signup (/api/auth/register) now reads the cookie, blocks self-referrals (audit-logged), setstenant.salesAgentId, creates aReferralrow atsigned_up, logs a forensicReferralEventwith IP/UA/signup-user/tenant, and clears the cookie. - Agent CRM API (/api/agent/[id]/referrals+/[refId]): list with per-status counts; create a manual lead (sourcemanual); PATCH lead fields + status transitions (agent-only subset: newlead → contacted → qualified → demobooked) + append a note; DELETE only allowed for pre-signup leads. Locked / signed-up referrals are read-only — only the platform mutates them. Strict per-agent isolation (cross-agent 403). - Referrals page (/agent/referrals): list + kanban view (reusableViewToggle, persisted asagent-referrals-view), per-status KPIs, add-lead modal, right-drawer detail with notes timeline + status select + lead fields + contact shortcuts (mailto / tel) + delete. Sidebar updated with the Referrals link. - Verified end-to-end on a live server: code validate (valid + invalid), track sets cookie, first-touch second call returnsalreadyAttributed, tenant signup applies attribution + creates the Referral + forensic event + clears cookie, manual lead create, PATCH status + note, DELETE 409 on locked referral, cross-agent 403, unauth 401. 0 TypeScript errors; test data cleaned up.- Sales Agent role — Phase 1: Foundation (task 77)
- Schema (
src/lib/db/schema.ts): newsales-agentrole onUserRecord;UserRecord.salesAgentIdandTenantRecord.salesAgentId(attribution pointer — does not grant access). New interfaces:SalesAgent,ReferralCode,Referral,ReferralNote,ReferralEvent,SalesAgentCommissionEntry,SalesAgentPayoutBatch,SalesAgentPayoutFailure,SalesAgentCommissionRule, and theSalesAgentsDBtop-level shape. - Database (databases/sales-agents.json,src/lib/db/db.ts): newsales-agents.jsonwith a default 10% global commission rule;getSalesAgentsDB/saveSalesAgentsDBaccessors mirroring the designer pattern. - Auth (src/lib/auth/session.ts):SessionPayloadextended withsales-agentrole +salesAgentIdclaim.src/lib/auth/api-auth.ts: newrequireSalesAgent()(agent-only gate, rejects every other role). Tenant dashboard layout now redirects sales-agents to their own portal. - Registration (POST /api/auth/agent/register): free public self-registration — creates aUserRecord(rolesales-agent), aSalesAgentprofile (active immediately, no membership), and a uniqueReferralCode(e.g.SA-LINDBERG). Issues a session immediately. - Login (/api/auth/login,/api/auth/login/verify-2fa): resolvessalesAgentIdand blocks suspended/deleted agents; session cookie carriessalesAgentId. Login page routes sales-agents to/agent/dashboard. - Portal (src/app/agent/): auth-gated layout (agent-only, redirects others to/login),AgentProvidercontext, sidebar + topbar with account-status badge, dashboard placeholder showing the referral code + copyable invite link + KPI placeholders + onboarding hints. Public registration page at/agents/register;/agentadded to proxy bypass. - Footer "Sales Agent" link now points at the live signup (/agents/register). - Verified end-to-end on a live server: registration creates SA + code, portal gated with/without session, sales-agent redirected from/dashboardto/agent/dashboard, admin API rejects 401, login flow returnsrole: sales-agent+salesAgentId. 0 TypeScript errors; test data cleaned up.Changed
- Super Admin sidebar — collapsible, scalable navigation refactor
-
AdminSidebarrebuilt as collapsible accordion groups under a cleaner 7-group taxonomy (Dashboard · Restaurants · Web Designers · Billing & Revenue · Monetization · Content · Platform & System) using only existing routes (routing preserved). The group containing the active route auto-expands; per-group open/closed state is remembered. - Added a compact icon-rail toggle (hover tooltips viatitle/aria-label), a mobile drawer (hamburger + backdrop) so admin nav works on mobile, single active-item highlighting (longest-prefix match),aria-expandedgroup buttons,aria-currentitems, and smoothgrid-rowsheight animations. Existing.admin-*theme classes preserved (dark/medium/light intact). - New reusable SSR-safeusePersistentStatehook (src/lib/hooks/usePersistentState.ts) backs sidebar state (admin-sidebar-compact,admin-sidebar-groups) and the designers view toggle.- Designers directory (`/designers`) — UI/UX redesign
- Replaced the heavy orange-gradient hero + gray body with a clean, light Scandinavian/SaaS layout; redesigned search into a sticky minimal toolbar (softer neutral inputs, inline filters on desktop, mobile filter drawer). - Added a List/Table ↔ Grid view toggle (reusable
ViewToggle, default List, persisted indesigners-view). List columns: Designer, Location, Restaurants, Rating, Services, Status, Contact. Grid: refined cards. Added proper empty states. - Added the marketplace footer (reuses the landingFooter) and madePublicNavcurrent-page aware — "Find a Designer" is a non-clickablearia-currentlabel on the directory (no redundant CTA loop). - Footer anchors now use/#section(work cross-page); added a language selector (i18n-ready) + Contact link.Security
- Removed the public super-admin hint from `/login` — the "Sign in as administrator" link is gone; the admin entry (
/admin/login) remains internally accessible only. No demo credentials or debug text are exposed on the login page.
Added
- Web Designer — Phase 6: Automation, fraud prevention & scaling (task 76)
- Anti-fraud payout holds (
src/lib/designer/fraud.ts):assessPayoutRiskflags large payouts (≥50k), rapid onboarding (≥10 restaurants/7d), and high value from new accounts (<14d). Automated payout runs HOLD flagged designers for manual review; the payout engine accepts anoverrideso a super-admin can pay anyway. Admin payouts page surfaces "Held for review" + reasons and a "Pay anyway" action. - Maintenance sweep (src/lib/designer/maintenance.ts):expireDemos()deactivates demo restaurants pastdemoExpiresAt(archives the designer link),purgeExpiredDemos()deletes demos beyond a 30-day grace (removes records + tenant file). - Cron endpoint (/api/cron/designer-maintenance): runs demo expiry + purge + the scheduled payout run; auth viaCRON_SECRET(x-cron-secret/?secret=) or a logged-in super-admin. Admin payouts page has a "Run maintenance" button. - Annual earnings/tax summary (/api/designer/[id]/tax-summary?year=&format=json|csv): paid commissions grouped by month + totals; CSV download button on the designer payouts page. - Stripe Connect webhook (/api/stripe/connect-webhook):account.updatedsyncs each designer's payout-account status (active/restricted/rejected); restricted accounts are skipped by the payout eligibility gate. (DEV_MODE no-ops until Stripe keys are set.) - Verified on a live server: cron auth (401 without secret), fraud hold (60k payout → "Held for review", not auto-paid; override pays), demo auto-expiry (tenant inactive + link archived), maintenance report (1 expired, 2 paid, 1 held), and tax-summary CSV. 0 TypeScript errors.- Web Designer — Phase 5: Public directory, profiles, reviews & SEO (task 76)
- Public directory (
/designers+GET /api/public/designers): "Find a Web Designer" — searchable/filterable grid (country, specialization, service, featured) with computed facets; only active + publicly-listed designers appear; featured-first sorting. - Public profile (/designers/[slug]+GET /api/public/designers/[slug]): bio, stats, specialties/services/languages, portfolio gallery, published reviews, contact form. SEOgenerateMetadata(title/description/OpenGraph/Twitter) + JSON-LDProfessionalServicewithaggregateRating. - Reviews: public submission (POST .../[slug]/reviews) stored unverified + unpublished; admin moderation (/api/admin/web-designers/reviews+ a Reviews section on the designer detail page) with publish/unpublish/verify/delete and automaticavgRating/reviewCountrecalculation from published reviews. - Contact (POST .../[slug]/contact): forwards an enquiry to the designer's account email via the mailer. - Profile editor (/designer/profile+GET/PATCH /api/designer/[id]/profile): edit bio, avatar/logo, location, specializations/services/languages, website, social links, portfolio, billing details, response time, and the public-listing toggle (only effective while the membership is active). - Sitemap (src/app/sitemap.ts): includes the directory, registration, and every public designer profile. - Verified on a live server: directory (4 listed + facets, specialization filter), profile page (JSON-LD + 3 reviews), review submit → pending → admin publish → appears with recalced rating, contact send, profile editor GET/PATCH (bio/portfolio/website normalization), sitemap lists all profiles. 0 TypeScript errors.Fixed
- Admin/designer session collision across browser tabs — super-admin now uses a separate
admin_sessioncookie instead of sharingplatform_sessionwith tenant-owner/web-designer. Previously, logging into a second role in another tab overwrote the shared cookie, so refreshing the first tab bounced to login. Now an admin session and a designer/tenant session coexist in the same browser. Changes:setAdminSessionCookie/deleteAdminSessionCookie+getAdminSession;requireSuperAdminreadsadmin_session;requireTenantAccess/requireDesignerTenantAccesshonor an admin cookie for the super-admin bypass; login + 2FA issue the admin cookie for super-admins; logout is scope-aware (?scope=admin); admin dashboard layout readsadmin_session. Note: existing admins must log in once more after deploy (their old token lived in the platform cookie).
Added
- Web Designer — Phase 4: Client invoicing, dashboard KPIs & support escalations (task 76)
- Invoices (
/api/designer/[id]/invoices+/[invoiceId]): create draft (line items, per-item service type, VAT, sequentialWD-<year>-<NNNN>numbering, server-computed subtotal/VAT/total), list with outstanding/paid totals + overdue flag, status transitions (draft → sent → paid, void/cancel), reminder resend, draft delete. Sending or reminding emails the client via the existing mailer (DEV logs to console). - Printable invoice (/designer-invoice/[invoiceId]): public, print-optimized invoice page (browser "Save as PDF") used by both the client email link and the designer's "view/print" — no PDF dependency added. Added to proxy bypass. - Invoices UI (/designer/invoices): builder modal with live totals, list with send / remind / mark-paid / view / delete actions. - Dashboard KPIs (/api/designer/[id]/dashboard+/designer/dashboard): real data — managed restaurants (live/demo), monthly recurring revenue, commission this month, pending payout, plus unpaid-invoice and open-ticket alert cards. - Support escalations (/api/designer/[id]/support+/[ticketId]; admin/api/admin/web-designers/escalations): designers open tickets to the platform and reply in a thread; admins triage from a global queue with reply / resolve / close./designer/support(designer) +/admin/dashboard/web-designers/escalations(admin, with "Designer Support" sidebar entry). - Seeder (scripts/seed-web-designers.js): now also seeds 3 client invoices (paid / outstanding / overdue) and an in-progress support escalation; reset now clears seed invoices + escalations too. - Verified end-to-end on a live server: dashboard KPIs (2 restaurants, 166.5 MRR, 399.6 commission), invoice create (VAT 16500→20625) → sent (+email) → public print page → paid; support round-trip (designer open → admin reply → designer sees → admin resolve). 0 TypeScript errors.- Web Designer — Phase 3: Billing, Stripe Connect, commissions & payouts (task 76)
- Commission engine (
src/lib/designer/commission-engine.ts):recordCommissionForInvoice(idempotent per invoice+designer) records a ledger entry whenever a designer-managed restaurant pays a platform invoice —pendingwhen all gates are open (designer active + membership active + link commission-eligible), elsewithheldfor the audit trail.resolveCommissionRateapplies per-designer rule → global rule → designer default.reverseCommissionForInvoicehandles refund/chargeback reversals + paid-entry clawbacks. - Payout engine (src/lib/designer/payout-engine.ts):createAndProcessBatchsums a designer'spendingcommissions (≥50 min), marks thembatched, transfers via Stripe Connect (simulated in DEVMODE), then settles to `paid`/`completed` or records a `PayoutFailure` and reverts to `pending`. `runPayoutRun` (cron-callable) pays all eligible designers; `retryPayoutFailure` re-attempts (max 3). Amounts derive only from the immutable ledger. - **Membership** (`/api/designer/[id]/membership`): activate (500 SEK/yr) via Stripe Checkout subscription or DEVMODE simulation (sets accountactive, unlocking live onboarding + commissions); cancel; status + earnings snapshot. - Stripe Connect (/api/designer/[id]/connect): Express onboarding link in production, simulated connected account in DEVMODE — gates payouts on a connected account. - **Commission triggers**: wired into the tenant billing DEVMODE checkout and the production Stripe webhook (invoice.payment_succeeded→ record;charge.refunded/dispute.created→ reverse). - Admin payouts (/api/admin/web-designers/payouts+/admin/dashboard/web-designers/payouts): per-designer pending + eligibility, run-all / pay-one / retry actions, batch history, failures. Sidebar "Designer Payouts" entry. - Designer pages:/designer/billing(membership + payout-account onboarding + earnings),/designer/commissions(ledger + totals),/designer/payouts(batch history + failures). - Verified end-to-end against a live server (DEVMODE): activate membership → connect payout → create live restaurant (gate passes) → subscription payment records 199.8 commission @ 20% → admin payout run pays it (`trsim_…) → ledgerpaid→ idempotent re-run pays nothing → canceled membership records the next commission aswithheld`. 0 TypeScript errors; test data cleaned up.- Web Designer — Phase 2: Restaurant assignment, delegated access & demo restaurants (task 76)
- Delegated access (
src/lib/auth/api-auth.ts):requireTenantAccessnow grants owner-level access to a web-designer whose session is scoped to a tenant they actively manage (verified againstdesignerRestaurantson every call — a revoked link loses access immediately). This lets designers reuse the entire existing tenant dashboard + API surface without duplicating logic. - Restaurant onboarding (/api/designer/[designerId]/restaurants):GETlists managed restaurants (with demo/expiry flags);POSTcreates a restaurant. Demo creation is free but capped at 1 active demo/designer; live onboarding requires an active membership (returns402 MEMBERSHIP_REQUIREDuntil Phase 3) and creates/links the client owner account. ReusescreateTenantDatabase. - Enter/exit delegated mode (/api/designer/[designerId]/access):POST {tenantId}verifies the active link, records aDelegatedAccessSession, re-issues the session scoped to the tenant, and audit-logs the entry;DELETEexits and revokes the session. The tenant dashboard shows a persistent "Managing X as a web designer" banner with an Exit button; the dashboard layout allows delegated designers and re-checks the assignment server-side. - Demo enforcement: demo restaurants are excluded from the marketplace (/api/bazar/menu+/sponsored), rejected from real orders (403 DEMO_MODE_ACTIVE), password-gated on their public site (/api/public/[tenantId]/demo-accesssets an HMAC cookie;DemoGateform), show a persistent DEMO PREVIEW watermark, and display an expiry notice pastdemoExpiresAt(90-day default). - Designer pages:/designer/restaurants(managed list + onboarding modal with membership gate) and/designer/demo(create/manage the single demo, view-site link, password reminder, expiry). - Verified end-to-end against a live server: demo create + 1-max limit (409) + membership gate (402), order block (403), marketplace exclusion (0 items), password gate (401/200 + watermark), delegated enter → own-tenant API 200 / other-tenant 403 → dashboard 200 → exit → dashboard 307. Audit +DelegatedAccessSessionrecords confirmed. 0 TypeScript errors; test data cleaned up.- Web Designer (Reseller/Agency) Role — Phase 1: Foundation (task 76)
- Schema (
src/lib/db/schema.ts): Newweb-designerrole onUserRecord; added optionaldesignerIdtoUserRecord. ExtendedTenantRecordwithdesignerId,isDemo,demoPassword,demoExpiresAt,demoOwnerId(ownership stays withownerId— designer link is separate). New interfaces:WebDesigner,DesignerMembership,DesignerRestaurant,DelegatedAccessSession,CommissionLedgerEntry,PayoutBatch,PayoutFailure,CommissionRule,DesignerReview,DesignerInvoice/DesignerInvoiceItem,SupportEscalation, and theDesignersDBtop-level structure. - Database (databases/designers.json,src/lib/db/db.ts): Newdesigners.jsonwith a default 20% global commission rule;getDesignersDB()/saveDesignersDB()with atomic write and empty-default merge for forward-compatible array additions. - Auth (src/lib/auth/session.ts):SessionPayloadextended withweb-designerrole +designerIdclaim. (src/lib/auth/api-auth.ts):requireWebDesigner()(designer-only gate, rejects superadmin/owner) andrequireDesignerTenantAccess()(delegated-access gate — verifies an activeDesignerRestaurantlink on every call; authorization never derived from request input). - Registration (POST /api/auth/designer/register): Free public self-registration — creates aUserRecord(roleweb-designer), aWebDesignerprofile with a unique URL slug, and aninactiveDesignerMembership(500 SEK/yr). Issues a session immediately. - Login (/api/auth/login,/api/auth/login/verify-2fa): ResolvedesignerIdand block suspended/deleted designer accounts; session cookie now carriesdesignerId. Login page routes designers to/designer/dashboard. - Portal (src/app/designer/): Auth-gated layout (designer-only, redirects others to/login),DesignerProvidercontext, sidebar + topbar with membership-status badge, and a dashboard overview placeholder with KPI cards and an "activate membership" prompt. Public registration page at/designers/register. - Routing/Isolation: Tenant dashboard now redirectsweb-designersessions to their portal;/designeradded to proxy bypass prefixes. Verified end-to-end: designer login carriesdesignerId; portal gated (307→/login without session); admin APIs reject designers (403);/dashboardredirects designers to their portal.- Web Designer — Super Admin management (pulled forward from Phase 3)
- APIs (
requireSuperAdmin-gated):GET /api/admin/web-designers(list enriched with email, membership status, restaurant count, commission totals; search/status-filter/sort + status counts);GET/PATCH/DELETE /api/admin/web-designers/[designerId]. - PATCH: suspend (records reason/by/at, unlists publicly) & reinstate (clears suspend fields), commission-rate override (0–100%), feature toggle, public-listing toggle, notes. - DELETE — full ownership-transfer lifecycle: managed restaurants transfer to platform (TenantRecord.designerId = null, restaurants keep operating),DesignerRestaurantlinks archived, pending/batched commissions set towithheld(not paid), membership canceled, designer profile + user account PII anonymized, paid commissions/payouts/invoices preserved. Audit-logged. - UI:/admin/dashboard/web-designerslist page (KPI counts, search/filter/sort, dark admin theme) +/admin/dashboard/web-designers/[designerId]detail page (profile, totals, managed restaurants, commission ledger, payouts, memberships, suspend/reinstate/feature/rate-edit actions, delete-with-consequences modal). Added "Web Designers" sidebar entry under Marketplace + AdminTopBar title. - Verified end-to-end against a live server with a superadmin session: list/auth (401 without cookie), suspend→reinstate, and the full delete lifecycle (1 restaurant transferred to platform, 1 commission withheld, tenant still active, user/profile anonymized). Test data cleaned up.Fixed
- Mobile bazar marketplace — stuck loading skeleton (root-cause fix)
- Replaced
AbortControllerpattern ininit()with alet liveboolean guard. The previousif (!signal.aborted) setLoading(false)check preventedsetLoading(false)from ever being called on slow mobile WiFi where cleanup fired mid-response-body, leaving loading permanently stuck attrue. - Added 10-second hard timeout as a failsafe — surfaces an error state instead of endless skeletons. - AddedretryTokenstate + "Try Again" button on the error screen so users can recover without a full page reload. -init()now resetsloading=trueandoffline=falseat the start of each run (including retries).Added / Redesigned
- Payment Escrow, Cancellation, Refund & Fraud System (task 75)
- Schema (
src/lib/db/schema.ts): ExtendedOrderwith escrow fields (paymentHoldId,escrowStatus,lockStatus,cancelledBy,cancellationReason,cancelledAt,refundId,refundAmount, timestamp fields,timeline). AddedOrderTimelineEvent,PaymentHold,RefundRequest,PayoutRelease,CustomerRiskScore,RestaurantRiskScore,TenantCustomerBan,EscrowPolicy,EscrowDBinterfaces plus all associated type aliases. - Database (databases/escrow.json,src/lib/db/db.ts): Newescrow.jsonfile with seed data;getEscrowDB()/saveEscrowDB()with atomic write; policy merged with defaults on read. - Escrow engine (src/lib/escrow/escrow-engine.ts):createPaymentHold()(idempotent),customerCanCancel()(grace period + lock state machine),applyOrderLock(),appendTimeline(),getEscrowPolicy(). - Refund engine (src/lib/escrow/refund-engine.ts):createRefundRequest()(idempotent, deduped),executeRefund()with Stripe/PayPal/cash stubs. - Payout engine (src/lib/escrow/payout-engine.ts):schedulePayout()(idempotent),releasePayout()(Stripe Connect stub),holdPayout(),processOverduePayouts()(cron-callable). - Risk scoring (src/lib/risk/scoring.ts):recordCustomerCancellation()(auto-ban at threshold ≥85),recordCustomerOrderComplete(),recordRestaurantOrderAccepted()(suspicious prep speed flag),recordRestaurantCancellation(),isCustomerBanned(). - Order creation (/api/public/[tenantId]/orders): CreatesPaymentHoldand appendsorder_createdtimeline event on every new order. - Public APIs:GET /api/public/[tenantId]/orders/[orderId](order status + timeline + cancel eligibility);POST /api/public/[tenantId]/orders/[orderId]/cancel(customer cancellation with refund logic + risk scoring). - Tenant APIs:POST /api/tenant/[tenantId]/orders/[orderId]/cancel(restaurant cancel — always 100% refund);PATCH /api/tenant/[tenantId]/orders/[orderId](escrow-aware: sets timestamps, applies lock atready, schedules payout atdelivered, fraud check);GET /api/tenant/[tenantId]/escrow;GET /api/tenant/[tenantId]/risk;POST|DELETE /api/tenant/[tenantId]/risk/ban. - Admin APIs:GET /api/admin/escrow;GET|PATCH /api/admin/escrow/policy;GET|POST /api/admin/payouts;GET /api/admin/risk;POST|DELETE /api/admin/risk/ban. - Restaurant dashboard (/dashboard/escrow): Summary cards (held/pending/released/refunded), pending payouts list with scheduled release times, holds and refunds history, customer risk indicators table, tenant ban list, restaurant own risk score. - Admin dashboard (/admin/dashboard/escrow): Platform-wide escrow summary, overdue payout queue with manual release, customer risk table with ban management, restaurant risk table, inline policy configuration editor. - Navigation: "Payouts & Escrow" added to restaurant sidebar; "Escrow & Risk" added to admin Marketplace sidebar group. - 0 TypeScript errors- Freeze Online Ordering When Restaurant Is Closed (task 74)
- Centralized availability logic in
src/lib/bazar/ordering-status.ts—getOrderingStatus()andcanAcceptOrders()wrapgetOpenStatus; closingsoon counts as orderable; empty hours default to open - **Backend validation**: `POST /api/public/[tenantId]/orders` checks opening hours before accepting any order — returns HTTP 409 with `{ error: 'RESTAURANTCLOSED', message, nextOpenLabel }if closed, preventing orders regardless of frontend state - **Restaurant menu page** (/bazar/restaurant/[slug]): -canOrderstate computed fromgetOpenStatusafter restaurant loads, refreshed every 60 seconds - Red "currently closed" banner with next-open time shown above menu when restaurant is closed -MenuItemCard: shows compact "Closed" badge instead of+button; the qty+control is also disabled when closed (removal still allowed) -PopularSection, offer items, and full menu all respectcanOrder- **Item detail page** (/bazar/restaurant/[slug]/item/[productId]): -canOrderstate computed and refreshed every 60 seconds - Red "Restaurant is currently closed" notice with next-open time appears inside the add-to-cart block - Qty+control disabled when closed; "Ordering Unavailable" disabled button shown when item not yet in cart - **Bazar landing page** (/bazar): -isRestaurantOpenfield added toFoodItemtype and/api/bazar/menuAPI response -FoodCardquick-add button replaced with compact "Closed" badge whenisRestaurantOpen === false- Restaurant open status computed server-side at API request time viacanAcceptOrders()` - 0 TypeScript errors- Compliance Center UX redesign — non-technical owner photo workflow (task 73)
- Compliance Center becomes the primary, simplest way to fix missing product images - New
ComplianceImageUploadercomponent (src/components/compliance/ComplianceImageUploader.tsx): - Drag & drop zone — works on desktop and mobile - "Upload Photo" button triggers native file picker withcapture="environment"for mobile camera - "Choose Image" button opens a full library panel (My Library tab + Free Food Photos tab) - Before/after comparison view in preview stage so owner sees old vs. new photo - Client-side file validation: type check, 10 MB size limit, friendly error messages - Submit CTA: "Update Product & Submit for Review" - Success stage shows new product photo, "Done" + "Menu" buttons - Escape key dismisses, z-index layering: uploader modal at z-200, library panel at z-300 - Compliance upload route rewritten (POST /api/tenant/[tenantId]/compliance/[issueId]/upload): - AcceptsmediaUrl(also backward-compatible withimageUrl) - STEP 1: Updatesproduct.imageUrlin tenant DB immediately (image goes live NOW before review) - STEP 2: Sets compliance issue topending_reviewwithproductSyncedAttimestamp - Product sync failure logged but does not block compliance creation; repair tool can re-sync - Compliance GET route updated (GET /api/tenant/[tenantId]/compliance): - Now reads tenant DB in parallel with compliance DB viaPromise.all- ReturnscurrentProductImageUrlfor each issue so the owner sees the live product state - Owner compliance page fully redesigned (src/app/(dashboard)/dashboard/compliance/page.tsx): - Renamed to "Product Photos" — plain English, no jargon - Issue cards show a product thumbnail (80×80 / 96×96) with "No photo" placeholder when missing - Orange "Add Photo Now" / "Upload Better Photo" CTA on each actionable card - Before/after image comparison in uploader preview - Alert banner with context-aware messaging (grace period vs active penalty) - Optimistic UI update — card shows new image immediately after upload success - Filter tabs: All / Need Action / Done (no URL pasting ever shown to owners) - Simplified "How penalties work" 4-step guide -getBazarUrl()subdomain-stripping helper retained for correct "View on Bazar" links - URL fields removed from owner-facing UI entirely — owners never see or paste raw image URLsFixed
- Compliance approval never wrote image to product (critical bug fix)
-
PATCH /api/admin/compliance/issues/[id]approveaction now callssyncImageToProduct()before marking approved; returns HTTP 500 and blocks approval if the product is not found or the write fails -POST /api/admin/compliance/bulkbulk approve now batches product syncs per tenant (one read+write per tenant) instead of only updatingcomplianceDB- AddedproductSyncedAt?: stringandproductSyncError?: stringtoComplianceIssueschema so sync state is traceable - NewPOST /api/admin/compliance/repairendpoint — idempotent; re-applies approved image URLs to all products where the sync previously failed or never ran; batched by tenant - Owner compliance page "View" link now correctly strips the tenant subdomain fromwindow.location.hostsoluigi.localhost:3001/bazar/...resolves tolocalhost:3001/bazar/...instead of 404ing - Admin review cards in the Review tab now show a sync debug panel:product_id,tenant_id,synced_at(green) or "not yet synced" (amber), andsync_error(red) if present - Admin Settings tab now includes a Sync Repair Tool card with "Run Repair" button and result summaryAdded
- Marketplace Image Compliance System (task 72)
-
ComplianceIssueStatustype,ComplianceIssue,ComplianceSettings,ComplianceDBinterfaces added tosrc/lib/db/schema.ts-getComplianceDB()/saveComplianceDB()DB accessors added tosrc/lib/db/db.ts;databases/compliance.jsonis the dedicated store -src/lib/compliance/image-checker.ts—hasValidImage()detection,scanAllTenants()cross-tenant audit,runPenaltyCycle()monthly accrual engine,markReminderSent(),buildAnalytics()metrics builder - Admin API routes (all under/api/admin/compliance/):GET /issues(filter/search/paginate),PATCH /issues/[id](approve/reject/send-reminder/activate-penalty/waive/request-better-image),POST /bulk(bulk approve/reject/remind/activate),POST /scan(full audit + penalty cycle),GET+PATCH /settings,GET /analytics- Tenant API routes:GET /api/tenant/[tenantId]/compliance(owner issue view with summary),POST /api/tenant/[tenantId]/compliance/[issueId]/upload(submit image for review) - Super Admin Compliance Dashboard (/admin/dashboard/compliance): 4-tab page — Overview (KPI cards, monthly trend bar chart, top-offender list, status distribution), Issues (filterable/sortable table, bulk actions, CSV export, pagination), Review Queue (image cards with approve/reject/request-better inline), Settings (penalty amount, grace period, warning schedule, auto-activate toggle) - Restaurant Owner Compliance Center (/dashboard/compliance): summary cards, issue cards with upload modal, image guidelines, step-by-step resolution guide, filter tabs (all/active/resolved) - Admin sidebar: "Compliance" added under Marketplace group - Tenant sidebar: "Compliance Center" added under Food Bazar group - Workflow: Missing → Warning Sent → Penalty Active → Image Uploaded → Pending Review → Approved/Rejected. Penalty stops only after admin approval (never auto-cleared on upload) - Penalty: 300 SEK/month default, configurable; grace period 7 days default; auto-activates after grace period; monthly accrual viarunPenaltyCycle()- Food Bazar — Premium restaurant page redesign (task 71)
- Removed large hero/banner image entirely; page now opens directly to content -
RestaurantHeader— compact card with text-avatar fallback (name-hash gradient → 6 presets), live open/closed/closing-soon status pill, favorite toggle (wired to/api/bazar/user/favorites), Web Share API + clipboard fallback with "Copied!" feedback, cuisine tags, stats row -CategoryNav— sticky top-14 horizontal-scrolling pills; IntersectionObserver (rootMargin: '-100px 0px -60% 0px') tracks active section; active pill auto-scrolls into view -PopularSection— top featured items;flex lg:grid lg:grid-cols-3 xl:grid-cols-4;shrink-0 w-[185px] lg:w-autofor mobile carousel / desktop grid duality -MenuItemCard—aspect-[4/3]image, in-card −/qty/+ controls replace add button when item is in cart, animated quick-add with Check feedback; each card is aLinkto item detail page -RestaurantSidebar— desktop sticky (sticky top-28), hidden on mobile;SidebarHoursWidgetwith live status, today's hours, expandable weekly schedule; About card; Quick Actions grid (Call / Directions / Share / Save) -MobileInfoSection— description/address/phone + hours accordion; shown only below menu on mobile (lg:hidden) -MobileCartBar—fixed bottom-0 lg:hidden; shows total items + price; dispatchesbazar-open-cartto open header cart drawer -bg-gray-50page background for Uber Eats-style card contrast -nameGradient(name)+initials(name)helpers — text avatar with consistent per-restaurant colour derived from name hash -handleUpdateQty(productId, delta)reads liveinCart()inside callback to avoid stale-closure on fast taps - Favorites state from single/api/bazar/auth/mecall on mount; optimistic toggle reverts on API error -PageSkeletonupdated to match new 2-column layout- Food Bazar Phase 4 — Opening hours, offer timers, quick-add, cart UX (task 70)
-
SpecialDayinterface in schema;specialDays?: SpecialDay[]added toTenantSettings;endDate?: stringadded toBazarItemSettings-src/lib/bazar/opening-hours.ts—getOpenStatus()returns live open/closed/closing-soon status with countdown;getWeekSchedule()for weekly schedule rendering;formatOfferCountdown()for smart countdown labels (days/hours/minutes); handles midnight closing (00:00= 1440 min end-of-day), special day overrides, recurring (MM-DD) and one-time (YYYY-MM-DD) holiday entries - Luigi's Pizzeria opening hours updated per spec (Mon–Wed 10–22, Thu 10–23, Fri 10–00, Sat 11–00, Sun 12–21) plusspecialDays: Midsummer Eve (closed), Christmas Eve (closed), Christmas Day (14–20 special hours), New Year's Day (14–22 special hours) - Restaurant API (/api/bazar/restaurant/[slug]) now returnsspecialDays[]and per-itemendDate- Item detail page layout reordered: image → Add-to-Cart → product name/details (previously Add-to-Cart was below all details) - OpeningHoursWidget on item detail page (before "You might also like"): live open/closed/closing-soon status dot + label, today's hours, services row (delivery/pickup/dine-in), full weekly schedule accordion, upcoming holiday notices within 14-day window -RestaurantWidget(right sidebar) upgraded to usegetOpenStatus()— now shows amber "closing soon" state - Quick add "+" button on menu page item cards:ItemCardis now aLinkto the item detail page;+button overlaid on image (hover-visible on desktop, always-visible on mobile), animated Check feedback on tap;onQuickAddprop pattern avoids per-card hook calls -useBazarCart.addItemdispatchesbazar-open-cartcustom event after every add -BazarHeaderlistens forbazar-open-cartevent and opens cart drawer automatically - CartDrawer undo system: "Clear cart" takes a snapshot, callsclearCart(), shows dark undo toast for 10 s; clicking "Undo" restores all items with exact quantities; timer is cleaned up on unmount -CountdownBadgereused on item cards and item detail Add-to-Cart panel for live offer expiry displayFixed
- Infinite spinner on `/dashboard/food-bazar` (tenant offer management page): Page loaded but spun forever with no error shown. Root cause was a
fetch('/api/tenant/${tenantId}/menu')call to a non-existent endpoint (only/menu/productsand/menu/categoriesexist). The 404 returned HTML, causingmenuRes.json()to throw aSyntaxError, which set theerrorstate — but the spinner guard (if (!settings) return <Spinner>) ran before the error UI, producing a permanent spinner. Fix: changed to parallel fetch of/menu/products+/menu/categories, mapped products withbasePrice ?? price, and added anif (error && !settings) return <ErrorScreen>guard before the spinner check. Also addedRefreshCwimport to fix the missing icon.
- Branch creation system — 4 bugs fixed (task 65):
- Feature gate inconsistency (primary blocker):
GET /api/tenant/[tenantId]/brancheshad no feature gate check whilePOSTdid. This caused the branch management UI to always render (page loaded fine), but branch creation failed with an unhelpful 403 "Multi-location add-on not enabled" error inside the form modal. Fixed by adding the sametenantHasFeature('multi-location')check to GET — the page now correctly shows the upgrade wall when the add-on isn't active. - Partial branch creation / dangling master record: The POST handler saved the new branch tomaster.tenantsbefore callingcreateTenantDatabase. IfcreateTenantDatabasefailed (template.json missing, disk full, etc.), the master DB had a dangling branch record pointing to a non-existent tenant DB file. Fixed by swapping the order:createTenantDatabasenow runs first; if master DB save subsequently fails, the just-created tenant DB file is deleted as rollback. - HQ branch missing from list after first creation:handleBranchCreateddid an optimistic append of just the new branch. On first-ever branch creation the current tenant getsbranchRole:'headquarters'andorganizationIdwritten in the backend — the frontend optimistic state never reflected this, so the HQ card never appeared. Fixed by callingload()after branch creation to fetch the full, authoritative branch list from the server. - Missing feature gate on analytics and copy-menu routes:GET /api/tenant/[tenantId]/branches/analyticsandPOST .../[branchId]/copy-menuboth lacked thetenantHasFeaturecheck, allowing data access by tenants without an active multi-location subscription. Both routes now return 403 when the add-on is not enabled. - Modal error UX — billing link on feature gate error: When branch creation returns the "not enabled" error, the modal now shows a "Enable Multi-Location from Billing →" link pointing to/dashboard/billingrather than a plain red error text. - Orphan cleanup added to branch POST: Added same cleanup logic asrestaurants/create— if a${branchId}.jsonfile exists without a master DB record, it's deleted before attempting creation. Prevents spurious "already exists" errors on retry.- Notification dropdown goes behind page content (z-index fix): The
<header>element hassticky top-0 z-10, which creates a CSS stacking context. Any child — even atz-50— can only compete globally at the header's ownz-10. Page elements (modals, sticky table heads, editors, sidebars) withz-index ≥ 10could therefore overlap the dropdown. Fix: the notification panel is now rendered viacreatePortaldirectly intodocument.bodywithposition: fixedandz-[9999], completely outside the header's stacking context. Positioning is computed from the bell button'sgetBoundingClientRect()so the panel always aligns correctly. On mobile (< 640 px) it renders as a full-width strip; on desktop it right-aligns under the bell. - Notification badge count bouncing after mark-as-read: The 30-second poll called
setLocalReadIds(new Set())unconditionally, wiping optimistic read state before the server PATCH was confirmed. If the poll and the PATCH raced, the badge briefly reverted to the old (stale) unread count. Fix:fetchNotifsnow selectively prunes the optimistic set — only removing IDs the server has confirmed as read — so in-flight PATCHes are never overwritten by a concurrent poll. - Read notifications not visually disappearing: The notification panel showed all 20 notifications (read + unread mixed) whenever any unread existed, so marking one read had no visible effect on the list. Fix: the panel now shows only unread items in the active list. A subtle footer shows "N earlier notifications already read" so users know older items exist. When the last unread is cleared the "All caught up!" state appears. This makes each click feel immediately responsive.
- Notification panel: ESC key, scroll-close, ARIA roles: ESC now closes both the notification and help panels (previously ESC only closed the help panel). The notification panel closes automatically when the user scrolls or resizes the window.
aria-haspopup="dialog",aria-expanded, andaria-labeladded to the bell button and panel for accessibility.
- Back button 404 on subdomain tenant pages:
account/page.tsxhardcodedbasePath = /tenant/${tenant_id}, which caused subdomain navigation links to generate double-prefixed paths (/tenant/id/tenant/id/menu) → 404. Fixed by detecting subdomain access viausePathname(): if pathname starts with/tenant/, use full base path; otherwise use root-relative''. - Contact nav link does nothing on non-home pages: Default nav
href: '#contact'is a same-page anchor that only works on the homepage. Changed tohref: '/#contact'so it always navigates to the home page and scrolls to the contact section, from any page. - Language switcher not persisting across navigation: Two issues fixed: (1) clicking a nav item dropped the
?lang=param from the URL; (2) the language preference was not saved across sessions. Fixes:SiteHeadernow appends?lang=currentLangto all nav item hrefs (desktop + mobile + Book CTA + logo) via awithLang()helper that correctly handles hash fragments.SiteHeaderalso reads apreferred-langcookie as fallback (priority: URL param > cookie > tenant default).LanguageSwitcherwritespreferred-langcookie on every language click (1-year TTL,SameSite=Lax).
Added
- Food Bazar marketplace — Phase 3 Enterprise Enhancement (tasks 67–68 + geo/auth/analytics):
- Analytics tab in tenant Food Bazar dashboard (
src/app/(dashboard)/dashboard/food-bazar/page.tsx): NewAnalyticsTabcomponent behind'analytics'tab. Time range selector (Today / Yesterday / 7 Days / 30 Days / Custom with date pickers). Two rows of KPI cards (Impressions, Clicks, Avg CTR, Conversions, Total Spend, Revenue, Overall ROI). SVG trend chart with metric toggle (impressions / clicks / conversions), area-fill gradient, gridlines, x-axis date labels. Traffic source breakdown with horizontal progress bars (Marketplace / Direct / Search / Social / Campaign). Per-campaign breakdown cards with 6-stat grid + spend progress bar. Fetches from new/api/tenant/[tenantId]/food-bazar/analyticsendpoint. - PromoteWizard upgraded (same page): 7-step flow replaces 6-step flow. Step 3 (Duration) now shows Standard (≤ 2 days, included) vs Weekly Premium (7 days, 999 SEK) tier selector. Step 4 (Geo Radius) new — range slider 5–100 km with 6 preset buttons and smart targeting info box. Step 7 (Launch) now shows tier, radius, and weekly upgrade fee in the summary.targetRadiusandcampaignTierpassed to API. - BazarUser auth system (src/lib/bazar/bazar-auth.tsnew): Marketplace-level accounts (separate from per-tenant customers). HS256 JWT inbazar_user_sessionhttpOnly cookie (30-day TTL). PBKDF2-SHA512 password hashing.getBazarSession(req)for session reads. - Bazar auth APIs (all new):POST /api/bazar/auth/register(email+password+name+GDPR),POST /api/bazar/auth/login,POST /api/bazar/auth/logout,GET /api/bazar/auth/me. - Bazar user APIs (all new):GET+PATCH /api/bazar/user/preferences,GET+POST /api/bazar/user/favorites(toggle),PATCH /api/bazar/user/location(geocoded or direct lat/lng). - BazarHeader account + notifications (src/components/bazar/BazarHeader.tsx): AddedLoginModal(Sign In / Register tabs, GDPR checkbox, error handling). AddedAccountMenu(user initial avatar, dropdown with My Account / Favourites / Sign Out). When logged out shows user icon that opens LoginModal. When logged in shows orange avatar initial + notification bell linking to/bazar/account. Session loaded from/api/bazar/auth/meon mount. - `/bazar/account` page (src/app/(bazar)/bazar/account/page.tsxnew): 4-tab account portal — Profile (details, saved location, GDPR consent date), Favourites (restaurant cards with Visit/Remove), Preferences (offer subscriptions, notification radius slider, preferred cuisines chips, save), Notifications (email/push/campaign notification toggles, SMS coming-soon, GDPR rights text, save). - Admin Geo Analytics tab (src/app/admin/dashboard/food-bazar/page.tsx): NewGeoTabcomponent behind'geo'tab. KPI cards (Total Approved, Geocoded, Missing, Coverage%). Warning banner whenGOOGLE_MAPS_API_KEYis missing. Coverage progress bar. "Run Geocoder" button (POST/api/admin/food-bazar/geocode). Result panel showing new/cached/failed/total with API-savings note. Per-restaurant table with tenant, coordinates, GeoHash, and geocoded/missing status badge. - Geocoding API (GET+POST /api/admin/food-bazar/geocodenew): GET returns coverage stats. POST geocodes all approved restaurants (or one bytenantId); usesaddressHashfor cache invalidation; skips gracefully withoutGOOGLE_MAPS_API_KEY. - Geo library (src/lib/bazar/geo.tsnew): Haversine distance, GeoHash encoder/decoder/neighbours,radiusGeoHashSet, Google Maps geocoding with cache,hashAddressfor change-detection,filterByRadiuswith sort. - Smart scoring engine (src/lib/bazar/scoring.tsnew): 0–100 composite score: distance (0–30), cuisine match (0–25), bid (0–20), favourite restaurant bonus (0–15), urgency/offer countdown (0–10), engagement (0–10). - Offer countdown badges (src/components/bazar/CountdownBadge.tsxnew): Client component updating every 30 s. Red (<6 h), orange (6 h–48 h), green (2+ days). Pulses when orange or red. Integrated in FoodCard (homepage), restaurant listing, and item detail pages. - Sponsored ranking API with geo (src/app/api/bazar/sponsored/route.tsrewrite): GET acceptslat/lng/radius/userCuisinesparams. GeoHash pre-filter (9-cell bucket, precision-5) → Haversine precise check. Scores viascoreItemForUser, returnsendDate+targetRadius+distanceKm. Fire-and-forget daily stats increment. POST click tracking withsourceBreakdown. - Tenant analytics API (GET /api/tenant/[tenantId]/food-bazar/analyticsnew): Time ranges (today/yesterday/7d/30d/custom). Per-campaign stats with daily trend lines. Aggregate summary (CTR, CVR, ROI). Source breakdown. Aggregate trend. - Weekly upgrade API (POST /api/tenant/[tenantId]/food-bazar/sponsored/upgradenew): Extends campaign to startDate+7d, setscampaignTier: 'weekly', records 999 SEK billing event in tenant DB.- Food Bazar marketplace — Phase 2 Platform Redesign:
- `src/hooks/useBazarCart.ts` (new): Lightweight localStorage-based cart hook with
addItem,removeItem,updateQty,clearCart,inCart,totalItems,totalPrice. UsesCustomEvent('bazar-cart-update')for cross-component sync without a context provider. - `BazarHeader` enhanced (src/components/bazar/BazarHeader.tsx): Added right-side actions — shopping cart icon with live count badge (reads fromuseBazarCart), user account icon, language selector dropdown (EN/SV/AR with flag + local label). Added slide-inCartDrawercomponent with item list, quantity +/− controls, subtotal, and clear cart. Search autocomplete now links items to the new product detail URL (/bazar/restaurant/[slug]/item/[productId]). - Product detail page (src/app/(bazar)/bazar/restaurant/[slug]/item/[productId]/page.tsx) (new): Two-column desktop layout — left column has full product image hero with overlay badges (discount %, halal, featured), dietary tags section, allergen grid with emoji icons, spice level visual indicator, meat type, custom badges, price/offer price with discount %, quantity selector, Add to Cart button (updates to quantity controls when item already in cart); right column has sticky restaurant widget (banner, logo, open/closed status, delivery/pickup/dine-in, min order, address, phone, description, "See Full Menu" CTA). Mobile renders stacked with restaurant widget at bottom. "You might also like" section shows up to 4 related items. Full breadcrumb navigation. - Bazar homepage FoodCard (src/app/(bazar)/bazar/page.tsx): Quick-add (+) button now wired touseBazarCart.addItem(). Shows item count when already in cart (orange), shows ✓ flash on first add (green). ImporteduseBazarCarthook. - Admin sponsored management tab (src/app/admin/dashboard/food-bazar/page.tsx): NewSponsoredTabcomponent andsponsoredtab added. KPI strip shows active placements, total revenue, impressions, clicks. Full management table with columns: restaurant/item, bid/day, budget, spent, date range, days remaining, views, clicks, CTR%, CVR%, status badge, and action buttons (pause/activate, delete). Inline create-placement form with restaurant dropdown (approved only), menu item dropdown (loaded from public bazar API), bid amount, total budget, start/end date with duration/estimate preview. Auto-filters to approved restaurants only. Uses the existing/api/admin/food-bazar/sponsoredCRUD API. - `SponsoredItem` schema + `FoodBazarDB.sponsoredItems` (added in prior session): Full bidding system withbidAmountPerDay,totalBudget,spentAmount,impressions,clicks,conversions, auto-expire on read. - `/api/admin/food-bazar/sponsored` (added in prior session): GET/POST/PATCH/DELETE endpoints for sponsored placement management, all gated byrequireSuperAdmin. - `/api/bazar/sponsored` (added in prior session): Public GET endpoint returning active sponsored items sorted by bid desc, with impression counting. - `/api/tenant/[tenantId]/food-bazar/sponsored` (new): Tenant-scoped sponsored campaign CRUD — GET returns own campaigns enriched with product name/image/price, computed analytics (CTR, CVR, ROI, daysRemaining), and live global rank position. POST validates min-5-SEK bid, bazar-enabled, restaurant-approved, item belongs to tenant, syncsofferPricetoBazarItemSettingsfor public menu display, and usescompositeId = tenantId::menuItemIdto replace any active campaign for the same item. PATCH updates status (pause/activate) or bid on own campaigns only. DELETE removes own campaign only. All verbs performtenantIdownership checks. - `/api/bazar/sponsored/ranking` (new): Public competitive ranking preview. Takesbid,tenantId,menuItemIdquery params. Excludes the proposing restaurant's own existing campaign (so they can update without competing against themselves). Returnsposition,totalActive,aboveYou,belowYou,topBid,lowestBid,avgBid,visible(position ≤ 20),isFull,slotsFilled,maxSlots: 20,willDisplace, and smartwarnings[](e.g. "Bid 3 SEK more to reach top 5", "This category is highly competitive"). No auth required — returns only aggregate competitive data, no PII. - Tenant Offer Management System (src/app/(dashboard)/dashboard/food-bazar/page.tsx) (complete rewrite): Transformed from a simple settings page into a premium 4-tab offer management center. Overview tab: KPI cards (active campaigns, total spend, total impressions, avg CTR), marketplace status banner, top-performing campaign card, quick-action shortcuts. Campaigns tab: searchable/filterable campaign cards with analytics rows (impressions, clicks, CTR%, CVR%, ROI%), budget progress bars, days-remaining badges, pause/resume/delete actions. Menu tab: full product grid with per-item Promote button; active campaign indicator badge on items that already have a running campaign. Settings tab: preserved existing bazar enrollment settings form. `PromoteWizard` 6-step modal: (1) Item confirmation with warnings if restaurant not approved or item already has active campaign; (2) Offer price input with live discount-% calculator; (3) Duration presets (1/3/7/14/30 days) + custom dates with start/end preview; (4) Bid input with preset chips (5/10/15/20/30 SEK) + total cost calculator (bid × days); (5) Live ranking preview — calls/api/bazar/sponsored/ranking, renders 20-slot visualizer (boxes, your position highlighted orange), competitor stats, inline bid adjuster; (6) Full campaign summary + Launch Campaign button.- Built-in food media library with stock photo gallery (task 66):
- Schema extension (`MediaItem`): Added
title,alt,tags,category,isFavorite,source(MediaSourceunion:'upload' | 'pexels' | 'unsplash' | 'demo'),sourceId,credit,creditUrl,width,height. All new fields are optional or have defaults for full backward compatibility. - `src/lib/media/stock-search.ts` (new): Shared stock photo utility.FOOD_GALLERY_CATEGORIES— 19 food categories (pizza, burgers, sushi, pasta, desserts, drinks, bakery, bbq, seafood, vegan, breakfast, indian, chinese, thai, sandwiches, raw ingredients, restaurant, salads, all).searchPexels/searchUnsplashAPI callers.getDemoPhotos— 40 scored demo entries for dev/no-key fallback.searchStockPhotosunified with Pexels → Unsplash → demo fallback chain. - `GET /api/tenant/[tenantId]/media/gallery` (new): Returns stock photos with category+search+pagination. ReadsimageApiSource/imageApiKeyfrom tenant settings; falls back toPEXELS_API_KEY/UNSPLASH_ACCESS_KEYenv vars. Returns photo list + categories array + source label. - `POST /api/tenant/[tenantId]/media/import` (new): Imports a stock photo by saving its CDN URL directly into the tenant media library (no download). Validatessource ∈ ['pexels','unsplash','demo']. Deduplicates bysourceId— returns{ media, duplicate: true }on re-import. - `PATCH /api/tenant/[tenantId]/media/[mediaId]` (new): Metadata editor — acceptstitle,alt,tags,category,isFavorite. - `GET /api/tenant/[tenantId]/media` updated: Backfills default values for all new fields on legacy records so old uploaded images appear correctly in the new UI....mspread now precedes defaults (fixed duplicate-key TS error). - `POST /api/tenant/[tenantId]/media` updated: Savestitle(derived from filename),alt: '',tags: [],isFavorite: false,source: 'upload'. Max file size raised 5 MB → 10 MB. - `DELETE /api/tenant/[tenantId]/media/[mediaId]` updated: Only unlinks disk file whensource === 'upload'; CDN URLs (pexels/unsplash/demo) are skipped. - `/dashboard/media` full rewrite: Two-tab page — My Library (search by title/filename/tags/alt, source filter dropdown, favorites toggle, skeleton loading, grid with hover overlay, star/unstar, detail panel with title+alt+tags editor, copy-URL, delete for uploads only, attribution display for stock) and Food Gallery (19 category pills, debounced search, photo grid, "Add to Library" button per photo, imported-in-session badge, load-more pagination, license notice). - `MediaPicker` enhanced: "Image Bank" modal now has two tabs — My Library (existing behavior preserved) and Food Gallery (category pills, debounced search, photo grid; clicking imports the photo viaPOST /media/importand immediately callsonChange(url)to select it). Category pills load from the gallery API. In-sessionimportedIdsSet shows a green checkmark on already-added photos. - `MediaUploader` updated: Now importsMediaItemtype from schema (fixes TS incompatibility withmedia/page.tsx). Max size validation updated to 10 MB.- Add New Restaurant — Billing Confirmation + Trial + Onboarding Wizard (task 64):
- `GET /api/restaurants/plan-info`: New endpoint returning the active
PlanDefinition(monthly/annual prices, trial days, currency, badge label) andBillingConfig(trial requires payment method, grace period, auto-renewal) for use in the billing confirmation step. Falls back to sensible defaults (Basic 299 SEK/month, 30-day trial) when no plans are configured in master DB. - `POST /api/restaurants/create` extended: Now requiresconfirmedBilling: truein the request body (400 if absent). AcceptsuseTrial: boolean— iftrueand the active plan hastrialDays > 0, the subscription is created withstatus: 'trialing'andendsAt= now + trialDays; otherwisestatus: 'active'with a 1-yearendsAt. SubscriptionamountPaidis 0 for trials,annualPricefor active. Response includesstatusandtrialEndsAtfields. - Multi-step wizard UI (src/app/(dashboard)/dashboard/restaurants/page.tsx): Replaced the flat single-step modal with a 4-step guided wizard: - Step 1 — Billing & Trial: Amber transparency notice ("each restaurant billed separately"), plan pricing card showing monthly + annual prices with savings%, auto-renew note. IftrialDays > 0: radio-style cards to choose "Start with free trial" vs "Skip trial — start paid"; if no trials: plain "subscription starts today" notice. "I understand — Continue" CTA. - Step 2 — Restaurant Info: Name (required), business type dropdown, subdomain (auto-generated from name, inline format feedback showingsubdomain.localhost). - Step 3 — Contact: Phone number + address textarea, marked optional with "update anytime" hint. - Step 4 — Review & Create: Summary table of all entered details + subscription summary card (green for trial, blue for paid), Create Restaurant button. - Step progress indicator with filled/ring/grey states and divider lines. Error banner renders inside the wizard at the top of the current step. Success toast on the main page after creation, including "Your free trial has started." when applicable.- Super Admin Monetization Control Center (task 63):
- Schema (
src/lib/db/schema.ts): AddedModuleStatusunion type (active | beta | coming-soon | invite-only | deprecated | hidden). AddedModuleOverrideinterface — per-feature admin overrides for pricing, visibility, featured/recommended flags, help text, docs URL, setup wizard toggle. AddedPromotioninterface with type (percentage | fixed-amount | free-months), value, duration, feature scope, start/end dates, seasonal flag, campaign tags, redemption tracking. AddedBillingConfiginterface — global billing rules: grace period, auto-renewal, proration, failed-payment retry schedule, trial payment method requirement, invoice due days. ExtendedMasterDBwithmoduleOverrides?,promotions?,billingConfig?. - Module resolver (src/lib/features/module-overrides.ts):resolveModule()— merges FEATUREREGISTRY base with stored `ModuleOverride` (override wins on pricing/status/flags). `resolveAllModules()` — resolves all registry features. `computeModuleStats()` / `computeAllModuleStats()` — computes live subscriber counts (active/trialing/pending/canceled/expired), MRR, and trial-to-active conversion rate from subscription data. `resolveTrialDaysForModule()` — merges admin trial config with `DEFAULTTRIAL_DAYSfallback.buildCategoryMeta()— category list with counts for filter UI. - **Modules API** (GET/PATCH /api/admin/monetization/modules): GET returns all 50+ registry features enriched with resolved pricing, override metadata, live subscription stats, and trial config. PATCH accepts{ updates: [...] }array for bulk pricing/status/visibility changes. - **Single Module API** (GET/PATCH /api/admin/monetization/modules/[featureId]): GET returns one resolved module with full stats. PATCH updates or creates aModuleOverridefor that feature. - **Promotions API** (GET/POST /api/admin/monetization/promotions+PATCH/DELETE .../[promotionId]): Full CRUD for platform promotions. GET auto-deactivates expired promotions on read. Promotions track redemption count, support selective feature scoping, and carry campaign tags. - **Billing Rules API** (GET/PATCH /api/admin/monetization/billing-rules): Reads/writes globalBillingConfig. GET returns config merged with sensible defaults (gracePeriodDays: 3,autoRenew: true,prorationEnabled: true, retry days[1,3,7],invoiceDueDays: 14). - **Revenue Insights API** (GET /api/admin/monetization/insights): Aggregates across all tenant subscriptions: total MRR, active/trialing counts, overall trial conversion rate, churn count (last 30 days). Returns per-feature revenue table sorted by MRR, 12-month MRR trend (estimated from billing dates), top-10 modules, and 30 most-recent subscription events with tenant names. - **Monetization page** (/dashboard/admin/monetization, 6 tabs): - **Modules tab** — filterable grid (search + category + status + plan filter) of all 50+ modules as cards. Each card: category badge, status badge, featured/recommended chips, name, description, monthly/annual/trial prices, live subscriber count, MRR, conversion rate. Quick-action menu: Edit, Show/Hide, Add Promotion. Edit slide-over panel with 4 sub-tabs (General/Pricing/Visibility/Onboarding) for changing status, prices with annual savings calculator, visibility toggles (public/featured/recommended/setup wizard), help text, and docs URL. - **Pricing tab** — full-width editable table of all paid features with inline price inputs for monthly/annual, annual savings % column, unsaved changes highlighted, bulk save with count badge, discard button. - **Trials tab** — card grid of paid modules with enable/disable toggle and day-range slider (1–90 days). Live preview text shows trial vs no-trial message. Unsaved changes tracked per module, bulk save. - **Promotions tab** — card grid of promotions showing discount amount, type, applies-to, redemption count, start/end dates, status badges (Active/Scheduled/Expired/Inactive). Create/Edit modal with type selector (percentage/fixed/free months), value, duration, multi-feature checkbox list, start/end dates, seasonal flag. Delete with confirmation. - **Billing Rules tab** — single-column settings page: grace period slider (0–14 days), invoice due days slider (7–30), auto-renewal/proration/trial-requires-payment-method toggles, failed-payment retry schedule text input (comma-separated days). - **Revenue Insights tab** — 5 KPI tiles (MRR, Active, Trialing, Conversion%, Churn 30d), 12-month MRR bar chart, top modules by revenue table with inline bar charts and conversion rate badges, recent subscription activity feed with colored status dots. - **Sidebar**: Added "Monetization" (DollarSignicon) to Platform Admin group →/dashboard/admin/monetization`.- Complete Add-on Purchase, Trial, Billing & Feature Activation System (task 62):
- Schema (
src/lib/db/schema.ts): AddedAddonSubscriptionStatusunion type (trialing | active | pending-cancellation | canceled | expired | suspended | failed). AddedAddonSubscriptioninterface with full lifecycle fields: trial dates, billing period dates, cancellation dates, activatedBy, notes, createdAt/updatedAt. AddedAddonTrialConfiginterface (per-feature trial config: days, enabled, promotionalUntil, notes, updatedBy). ExtendedMasterDBwithaddonSubscriptions?andaddonTrialConfigs?. ExtendedTenantDBwithnewFeatureHighlights?: Record<string, string>(featureId → activatedAt ISO) powering sidebar NEW badges. - Subscription state machine (src/lib/addons/subscription.ts):resolveTrialDays()— merges admin-configured trial days with per-featureDEFAULT_TRIAL_DAYSfallbacks.activateAddon()— createsAddonSubscription(trialing if trialDays > 0, else active immediately), writes/updatesFeatureGrant, marksnewFeatureHighlightsin TenantDB.cancelAddon()— trial → immediate cancel + grant revoked; active →pending-cancellationwithcancelEffectiveAt= end of current period.sweepAddonSubscriptions()— transitions expired trials and pending cancellations, revokesFeatureGrant.enabledon expiry/effective-date.buildInvoicePreview()— always shows full price with trial discount as a negative line item (never hides real pricing).buildTrialEndingSoonMessage()— human-readable trial warning.getAllTrialConfigs()— merges saved configs with defaults for all features. - Billing API rewrite (GET/POST/DELETE /api/tenant/[tenantId]/billing/addons): GET returns enriched add-on list withisActive, livesubscriptionobject, andtrialDaysper feature. POST callsactivateAddon(), returns subscription + trial info. DELETE callscancelAddon(), returns updated subscription. - Add-on detail API (GET /api/tenant/[tenantId]/billing/addons/[featureId]): Returns feature info,trialDays,trialEndsAt,billingStartsAt,firstInvoiceAt, fullconfirmationInvoice(with line items), andexistingSubscription— used by confirmation modal before activation. - Highlights API (GET/DELETE /api/tenant/[tenantId]/billing/highlights): GET returns{ highlights: db.newFeatureHighlights }. DELETE removes specified featureIds fromnewFeatureHighlights. - Admin analytics API (GET /api/admin/addons): Aggregates all subscriptions across all tenants; returns per-feature MRR, active/trialing/pending/canceled/expired counts, trial-to-active conversion rate, and 20 most-recent subscriptions. - Admin trial config API (GET/PATCH /api/admin/addons/trials): GET returns all trial configs merged with defaults. PATCH accepts{ updates: [...] }array to bulk-update per-feature trial days, enabled state, promotional end date, and notes. - Cron sweep route (GET /api/cron/addon-billing): Secured viax-cron-secretheader or?secret=param. CallssweepAddonSubscriptions(). Scans trialing subscriptions for daysLeft === 3 and 1; queues trial-ending-soon notifications with dedup key to prevent duplicate alerts. - Billing page full rewrite (/dashboard/billing):ConfirmModal— fetches live pricing from detail API, shows full invoice with trial discount line, trial period callout, ShieldCheck transparency guarantee, "Start Free Trial" vs "Confirm & Activate" button.CancelConfirmModal— different wording for trial (immediate cancellation) vs active (end of period).ActiveAddonCard— trial countdown with progress bar (red when ≤3 days), pending-cancellation amber banner, expandable details accordion, cancel button. Marketplace — "Try Free" badge with trial days, or "Add" button for no-trial features. - Sidebar NEW badge system:HighlightsProviderReact context (src/lib/addons/highlights-context.tsx) — loads highlights from API on mount,clearForRoute()removes badge from state + calls DELETE API.DashboardProvidersclient wrapper (_providers.tsx) bridges server layout to client context.SidebarNavigation—NavLinkshows pulsing animated indigo dot + "New" text whenisNew=true; group header shows dot when collapsed and has new items inside.FEATURE_TO_ROUTE/ROUTE_TO_FEATUREmaps wire featureId → sidebar href. - Admin add-on management page (/dashboard/admin/addons, 3 tabs): Analytics — per-feature revenue bar chart, active/trialing/pending/canceled/expired counts, conversion rate. Trial Config —TrialConfigEditorwith per-feature enable checkbox + days input + preview badge, bulk save/reset. Recent Subs — table of last 20 subscriptions across all tenants with status badges.- Multi-Location / Branch Management (+39 SEK/month add-on — task 61):
- Schema (
src/lib/db/schema.ts): AddedOrganizationinterface (id,name,ownerId,createdAt,updatedAt). AddedBranchSettingsinterface (displayName,address,phone,email,color,isActive,openedAt,notes). ExtendedTenantRecordwithorganizationId?,branchRole?: 'headquarters' | 'branch',branchSettings?: BranchSettings. Addedorganizations?: Organization[]toMasterDB. - Feature registry (src/lib/features/registry.ts):multi-locationat 39 SEK/month / 390 SEK/year, categoryoperations,planInclusion: 'addon',perTenantToggle: true,hasUsageLimits: true. - Branches API (GET/POST /api/tenant/[tenantId]/branches): GET returns{ organization, branches }where each branch includes live stats (ordersToday, revenueThisMonth, staffCount) fetched in parallel from each tenant DB. POST creates a new organization if none exists (marks the requesting tenant as HQ), generates a slug-basedsubdomain, bootstraps a new TenantDB viacreateTenantDatabase, returns the newTenantRecord. - Single Branch API (GET/PATCH/DELETE /api/tenant/[tenantId]/branches/[branchId]): GET returns one branch. PATCH updatesbranchSettingsfields + top-levelname. DELETE soft-deactivates (setsisActive=false; HQ and self cannot be deactivated). - Copy Menu API (POST /api/tenant/[tenantId]/branches/[branchId]/copy-menu): Copiescategoriesand/orproductsfrom HQ to a branch. Modes:all(default) /categories-only/items-only. Items-only auto-copies categories if target has none. - Analytics API (GET /api/tenant/[tenantId]/branches/analytics): Reads all org branch DBs in parallel; returns per-branch stats (ordersToday, revenueThisMonth, revenueTotal, ordersTotal, avgOrderValue, top 5 items) plus org-wide totals. - `/dashboard/branches` page (4 tabs): Branches — grid of branch cards with color dot, HQ/Current badges, contact info, quick stats (orders today / revenue / staff), "Copy Menu" and "Visit" actions, add/edit/deactivate controls. Analytics — org-wide KPI tiles + branch comparison table with revenue bars, per-branch top items chips. Shared Menu — branch selector + copy mode radio (all/categories/items), confirm button. Settings — organization details panel. Feature gate wall shown when add-on is not enabled (upgrade CTA with feature list + pricing). - Add Branch modal: Name (required), address, phone, email, color picker (10 swatches). Slugifies name into subdomain; appends numeric suffix if collision detected. Creates organization on first branch. - Edit Branch modal: Same fields as add + internal notes. SendsPATCH. - Sidebar: Added "Branches" nav item (GitBranchicon) to the Operations group inSidebarNavigation.tsx.- Sidebar navigation redesign — grouped accordion with mobile drawer (task 60):
- Information architecture: Flat 28-item list reorganised into 6 primary items + 6 collapsible groups. Primary items (Overview, Orders, Reservations, Menu, Customers, Inventory) always visible at top. Groups: Marketing, Website & Content, Operations, Analytics & AI, Account, Platform Admin (superadmin only). - Accordion groups: Each group collapses/expands with smooth CSS
grid-rowsheight animation (no jankymax-heighthacks). Group header shows its icon, label, and a rotating chevron. Active group is auto-expanded on load and on navigation. Open/closed state persisted tolocalStorage(restora_sidebar_groups). - Search: Inline search input at top of scrollable area. Pressing/focuses it from anywhere in the page;Escapeclears and blurs. While typing, groups are replaced by a flat filtered list showing matching items with their group label underneath. No results message shown when nothing matches. - Active state: Active item has blue-50 background + blue-700 text + small blue dot indicator. Active group header shows tinted state.isItemActive()handles both exact matches (/dashboard) and prefix matches (/dashboard/orders/...). - Mobile drawer: Sidebar renders as a fixed slide-in drawer on screens< md. Backdrop overlay (black/40) closes on tap.Xclose button in drawer corner. Body scroll locked while open. Drawer auto-closes on route change. - Mobile trigger:MobileMenuButtoncomponent exported from SidebarNavigation, rendered insideTopHeader(mobile-only, firesrestora:toggle-sidebarCustomEvent). TopHeader listens for the event. - Visual design: Width reduced fromw-64(256px) tow-60(240px) on desktop. Logo row with "PRO" badge. User footer with avatar initials, name, email, logout button. Superadmin purple badge instead of amber. Group item icons intext-gray-400default →text-gray-600on hover →text-blue-600when active. - No features removed: All 28 routes preserved. Role-basedadminOnlygroups still gated.tenantIddisabled state still respected.RestaurantSwitcherunchanged.- Inventory UX Enhancement — Zero-navigation ingredient setup (task 59):
- Inline category creation: Category dropdown in "Add Ingredient" now includes "➕ Add New Category" option. Opens
InlineCategoryModal(z-60, over the ingredient form) with 15-emoji quick-picker + name field + live preview. Category is created viaPOST /inventory/categories, instantly selected, and available in all dropdowns — no page reload or form data lost. - Quick supplier creation: Supplier dropdown in "Add Ingredient" shows "+ Create new supplier" link when no supplier is selected. OpensInlineSupplierModal(z-60) with name/contact/phone/email fields. On save, supplier is created via existing API, optimistically added to the supplier list, and auto-attached to the ingredient — all without leaving the modal. - Custom Categories API (/api/tenant/[tenantId]/inventory/categories): GET/POST/DELETE for tenant-scoped custom categories stored indb.inventoryCustomCategories. Duplicate detection (case-insensitive). Loaded at page mount alongside ingredients. - Schema: AddedInventoryCustomCategoryinterface (id,name,emoji,createdAt). AddedinventoryCustomCategories?: InventoryCustomCategory[]toTenantDB. ChangedIngredientItem.categoryfromIngredientCategorytostringto support custom values. AddedisDemoData?: booleantoIngredientItemandInventorySupplier. Addedaddress?: stringtoInventorySupplier. - Smart UX:IngredientModalpre-fills unit and category fromlocalStorage(inv_last_unit,inv_last_category) on next open. Enter key submits the form. Inline validation with red border + error message under Name field. Unit label shown as suffix inside stock/threshold inputs. Sticky header and footer in modal. Selected supplier shows contact info inline with "× remove" link. - Demo / Sample Data system:POST /api/tenant/[tenantId]/inventory/demoseeds 5 demo suppliers + 10 ingredients + 6 stock movements, all flaggedisDemoData: true. Idempotent (409 if already seeded).DELETEremoves all demo-flagged items (ingredients, suppliers, movements, cleans up recipe mappings and supplier refs). Overview tab shows "Load Sample Data" CTA when inventory is empty (with description). Amber warning banner when demo data is present with "Remove Demo" button. "Sample" badge on demo ingredients and suppliers throughout. "Sample Data" button in page header when inventory is empty. - Demo data content: Chicken Breast (25 kg), Mozzarella Cheese (1.5 kg — already low), Tomato Sauce (15 L), Burger Bun (48 pcs), Coca Cola 33cl Can (48 cans), Olive Oil Extra Virgin (0.8 L — already low), Basmati Rice (50 kg), Frozen French Fries (20 kg), Iceberg Lettuce (8 kg), Yellow Onion (12 kg). Suppliers: Fresh Meat Supplier AB, Nordic Dairy Co., Stockholm Food Supply, Beverage Partner Sweden, Green Farm Vegetables. All with realistic SEK prices and storage notes. - Optimistic state updates: When a supplier is created inline, it is immediately pushed to the localsuppliersstate without waiting for a fullloadAll(). Same for custom categories — no visible loading delay. - Category display: Category emoji shown in ingredient table rows. Custom categories show their chosen emoji. Built-in categories all have emojis (🥩🐟🥛🥦🌾🍺🌶️🫒📦🗂️). Category filter dropdown and all selects show emoji + label.- Full Inventory Management System (+99 SEK/month add-on):
- Schema (
src/lib/db/schema.ts): AddedIngredientItem,StockMovement(types: purchase/order/manual-add/manual-remove/waste/correction),InventorySupplier,RecipeMapping,RecipeIngredient,PurchaseEntry. AddedInventoryUnit(12 units: kg/g/l/ml/piece/bottle/box/pack/can/bag/slice/portion) andIngredientCategory(10 categories). ExtendedTenantDBwithingredientItems,stockMovements,inventorySuppliers,recipeMappings,purchaseEntries. - Feature registry (src/lib/features/registry.ts):inventory-managementat 99 SEK/month, 990 SEK/year. - Billing add-on API (/api/tenant/[tenantId]/billing/addons): GET active add-ons; POST activates in dev mode by writingFeatureGrantdirectly to master DB; DELETE disables. Fixed billing page "Add" button which previously did nothing (calledsetBannerstub). - Ingredients API (/api/tenant/[tenantId]/inventory/ingredients): GET list; POST create. With[ingredientId]route: PATCH update any field; DELETE removes ingredient and cleans it from all recipe mappings. - Stock Movements API (/api/tenant/[tenantId]/inventory/movements): GET (filterable byingredientId+limit); POST creates movement and updatescurrentStock— correction type sets stock directly to quantity value. - Suppliers API (/api/tenant/[tenantId]/inventory/suppliers): Full CRUD. DELETE clearssupplierIdfrom linked ingredients. - Recipe Mappings API (/api/tenant/[tenantId]/inventory/recipes): GET enriched withproductName/ingredientName+unmappedProductslist; PUT upserts (empty ingredients array = delete); DELETE removes. - Purchase Entries API (/api/tenant/[tenantId]/inventory/purchases): GET newest-first; POST validates all ingredients, increasescurrentStock, logsStockMovementrecords with type'purchase', savesPurchaseEntry. - Auto-deduction on orders (/api/public/[tenantId]/orders): When an order is confirmed, ingredient stock is deducted via recipe mappings. Each deduction logged asStockMovementwith type'order'andorderIdreference. - Admin dashboard (/dashboard/inventory): Complete rewrite — 6-tab layout: - Overview: 4 KPI cards (total items, low stock, out of stock, est. value in SEK); low-stock alert list with +Stock quick action; recent 8 movements feed. Red badge on tab when alerts exist. - Ingredients: Search + category + status filters; table with stock level, threshold, status badge; ± Adjust / Edit / Delete per row.IngredientModal(add/edit full form).AdjustModalwith 4 action types (Add Stock / Remove / Waste / Correct — correction sets new stock level directly). - Recipes: All products listed (mapped + unmapped); recipe editor modal with dynamic ingredient rows (dropdown + quantity + unit per row). - Movements: Full movement log filterable by ingredient and type; direction +/− indicators, type labels, before→after stock columns. - Purchases: Expandable purchase cards with items;PurchaseModalfor logging supplier deliveries with auto-calculated total cost. - Suppliers: Simple CRUD list withSupplierModal. - Upgrade prompt shown to tenants without feature — 99 SEK/month, links to Billing & Plans. - Schema: Preserved backward-compatibleProduct.stockTracking/stockQuantityproduct-level tracking alongside new ingredient-level system.- Advanced Dietary, Allergen & Religious Menu Filters (task 57):
- Schema: Added
MenuFilterSettingsandCustomMenuTagtypes. ExtendedProductwithmayContainAllergens,dietaryTags,crossContaminationWarning,certificationNote. AddedfilterSettingstoTenantDB. - Defaults library (src/lib/menu/dietary-defaults.ts): 14 standard EU allergens with icons, 21 dietary/lifestyle/religious tags (vegan, vegetarian, pescatarian, gluten-free, dairy-free, keto, low-carb, organic, healthy-choice, spicy, kids-friendly, halal, kosher, hindu-vegetarian, jain, buddhist-vegetarian, no-beef, no-pork, alcohol-free, seafood-free, fasting-friendly),DEFAULT_FILTER_SETTINGS, Tailwind badge class map, lookup helpers. - API —GET/PATCH /api/tenant/[tenantId]/menu/filter-settings: Admin reads and updates filter config (enabled allergens, dietary tags, custom tags, 4 disclaimer fields, show/hide toggles). - API —GET /api/public/[tenantId]/menu/filters: Public config endpoint — returns enabled allergens, dietary tags, custom tags, disclaimers. Returns{ enabled: false }if disabled. - AdminProductEditorModal: Added "Dietary & Allergens" third tab. Contains allergens (amber chips), may-contain allergens (orange chips), cross-contamination warning toggle, dietary/lifestyle tag chips, religious & cultural tag chips, certification note field. Badge count on tab when items are tagged. - Admin menu page: Added "Dietary & Allergen Filters" top-level tab withFilterSettingsPanel. Enable/disable entire filter system, show/hide filter bar, enable/disable individual allergens (All/None shortcut), enable/disable individual dietary tags per category (dietary/lifestyle/religious), custom tag builder (icon + label), 4 configurable disclaimer text areas. Save button with success feedback. - PublicMenuClient.tsx: Full filter bar replacing old basic allergen filter. Search box + Allergens exclude button (expandable panel) + Dietary include button (expandable panel with 3 categories). Active filter chips summary row. Combined filter logic: text search AND allergen exclusion AND all required tags must match. Instant client-side filtering. Item badges: dietary tag chips with color coding, allergen "contains" badges, "may contain" badges (distinct style), cross-contamination warning badge. Disclaimers section at page bottom. Filter config fetched once from public API on mount.- Advanced Guest Requirements, Special Occasions & Add-on Services (task 56):
- Schema: Added
ReservationExtraOption,SelectedExtra,SpecialOccasion,ReservationAddOn,SelectedAddOn,DecorationPackagetypes. ExtendedReservationSettingswithextraOptions,occasions,addOns,decorationPackages,showExtrasToCustomers,showOccasionToCustomers,showAddOnsToCustomers. ExtendedReservationwithoccasion,occasionLabel,selectedExtras,selectedAddOns,addOnsTotal,isVip,decorationPackageId. - Defaults library (src/lib/reservations/defaults.ts): 22 predefined extra options across 5 categories (children, accessibility, seating, dining, other), 10 special occasions with prep checklists, 10 add-on services, 5 decoration packages, andmergeWithDefaults<T>()merge utility. - API —GET /api/public/[tenantId]/reservation/config: New public endpoint returning booking settings, filtered+enabled extras/occasions/add-ons for customer wizard. Separate from availability endpoint (static config, loaded once). - Settings PATCH: Automatically seeds all defaults on first save (when arrays are empty/absent), thenmergeWithDefaults()on subsequent saves to preserve restaurant overrides while picking up new defaults. - Customer booking wizard redesigned to 6-step flow: Guests → Date → Time → Special Touches (new) → Contact → Confirm. Extras step auto-skips if restaurant has nothing configured. Occasion chips with color coding, category expandable sections for requirement options, add-on cards with quantity controls. Submission includes full extras/occasions/add-ons payload. - Admin Prep View (new tab in reservations dashboard): Filter by Today/Tomorrow/Next 7 days. Per-reservation cards show occasion badge + prep checklist (checkboxes), requirements chips, add-on cards with pricing, staff notes. "Mark prep done" button turns card green. Prep tab shows today's count badge. - Admin Settings: 4 sub-tabs — Booking Rules | Occasions | Requirements | Add-ons. Each with show/hide toggle (customer visibility) and per-item enable/disable. Add-ons tab includes price input, staff instructions, showToCustomers flag. - List view enhanced: inline occasion badge + extras/add-ons chips per reservation row. - Activity log enriched: includes occasion label, extras list, add-on names in log line.- Reservation System Redesign (task 55):
- Schema: Added
BlockedSlot,ReservationSettings, andSlotModetypes toschema.ts. ExtendedReservationwithnoShow,tableId,source, andstaffNotesfields. AddedreservationSettingsandblockedSlotstoTenantDB. - API —GET/PATCH /api/tenant/[tenantId]/reservations/settings: Read and update reservation configuration (slot mode, capacity, booking rules, auto-confirm, messages). - API —GET/POST/DELETE /api/tenant/[tenantId]/reservations/blocked: Manage time blocks (full day, date range, specific slot, recurring weekday with optional time window). - API —GET /api/public/[tenantId]/reservation/availability: Returns available time slots for a given date, respecting blocked slots, advance booking rules, and per-slot capacity. Returns public booking settings (min/max party size, instructions). - Public booking route (/api/public/[tenantId]/reservation): Validates against booking rules (party size limits, advance hours, slot availability/capacity). Supports auto-confirm mode. ReturnsautoConfirmed+confirmationMessagein response. - Availability library (src/lib/reservations/availability.ts): Pure functiongetAvailableSlots(db, date)— handles interval/fixed/capacity slot modes, blocked slot filtering, advance time validation, and per-slot capacity counting. - Customer booking flow: NewReservationWizard5-step component replacesReservationForm. Steps: Guest count picker → Calendar date picker → Live time slot grid (with spotsLeft indicators) → Contact details → Review + submit. Shows "Booking Confirmed!" vs "Request Received!" based on auto-confirm. - Admin reservations page: Full redesign with 4 tabs — List (search/filter table with confirm/reject), Calendar (monthly grid with per-day reservation counts), Settings (booking rules form, slot config, custom messages), Blocked Slots (add/remove blocks with form for all 4 block types). KPI row: Pending / Confirmed / Today / Upcoming. - ExtendedPATCH /api/tenant/[tenantId]/reservations/[reservationId]to acceptstaffNotesandnoShowfields alongsidestatus.- Feature Registry (`src/lib/features/registry.ts`): Centralized catalog of 50+ platform features grouped into 13 categories. Each feature carries: id, name, description, category, plan inclusion tier (basic/premium/enterprise/addon/none), monthly and annual add-on pricing, dependencies, per-tenant toggle flag, usage limit flag, and badge label. Exports
getFeatureById,getFeaturesByCategory,getPlanFeatures(tier),getAvailableAddons(tier).
- Subscription Plan Builder (super admin):
- Schema:
PlanDefinitionandFeatureGranttypes added toMasterDBandschema.ts. Plans have base/annual pricing, trial days, per-limit caps (menu items / staff / tables / orders / storage), included feature IDs, and available add-on feature IDs. - API —GET/POST /api/admin/plansandGET/PATCH/DELETE /api/admin/plans/[planId]: full CRUD for custom plan definitions. - API —GET/POST /api/admin/tenants/[tenantId]/featuresandDELETE /api/admin/tenants/[tenantId]/features/[featureId]: per-tenant feature grants (override enable/disable, add-on pricing, expiry, notes). - UI —/dashboard/planspage (super admin only): create, edit, clone, delete plans; feature picker with search by category; pricing fields; usage limit controls; public/active toggles; card grid view. - Sidebar: "Plan Builder" entry appears only for superadmin role.- Feature Add-on Marketplace (billing page): Restaurant owners now see a structured add-on section below the plan comparison cards. Features not included in their current plan tier are listed by category with monthly/annual pricing and an "Add" CTA (triggers support contact flow until Stripe add-on billing is live).
- Help centre — 8 new articles:
-
email-marketing— Campaign builder, AI content, scheduling, GDPR consent -two-factor-auth— TOTP setup, backup codes, recovery, managing 2FA -customer-accounts— Registration, saved addresses, order history, GDPR deletion -payment-methods— Stripe, PayPal, Swish setup and dev mode -notifications-system— In-dashboard alerts, SMS via Twilio/Vonage -multi-restaurant— Multiple locations, restaurant switcher, franchise notes -seo-features— Meta tags, AI generation, local SEO, schema.org, sitemap -ai-content— All 7 AI touchpoints across the platform, prompt tips -securitycategory added toDOC_CATEGORIES- Popular articles updated to include new 2FA, payments, AI content, SEO entries - Quick-search chips updated- Memory update:
feature_inventory.mdandsubscription_architecture.mdadded to project memory.
- Two-Factor Authentication for restaurant owners (P2):
- Login flow:
POST /api/auth/loginnow issues a 2FA challenge for tenant-owners (not just superadmins) whentwoFactorEnabledis set. Returns{ requires2FA: true, tempToken }— caller must redirect to TOTP verify step before a full session is issued. - Verify route upgraded:POST /api/auth/login/verify-2fanow accepts backup codes in addition to TOTP codes (one-time use, consumed on match). SetstenantIdin the session cookie for tenant-owner accounts (was missing). ReturnsusedBackupCode+backupCodesRemainingso the UI can warn about low codes. - Backup codes:generateBackupCodes()produces 8 randomXXXX-XXXXhex codes. Stored as SHA-256 hashes inuser.twoFactorBackupCodes.findBackupCode()timing-safe matches and returns the hash for removal. Added tosrc/lib/auth/totp.ts. - Schema:UserRecord.twoFactorBackupCodes?: string[]added (SHA-256 hashed codes). - Owner 2FA API routes (/api/tenant/[tenantId]/security/2fa): -GET— returns{ enabled, backupCodesRemaining }(no secrets) -POST— initiates setup: generates secret + QR code PNG (not saved yet) -PATCH— confirms setup with TOTP code, enables 2FA, generates + returns 8 plaintext backup codes (stored as hashes) -DELETE— disables 2FA after TOTP verification; clears secret + backup codes -POST /backup-codes— regenerates backup codes after TOTP verification; old codes invalidated - Admin reset route:DELETE /api/admin/users/[userId]/2fa— superadmin can force-disable 2FA for any user; full audit log entry. - Admin status route:GET /api/admin/users/[userId]/2fa— superadmin can check 2FA status + remaining backup codes for any user. - SecurityTab component (src/components/dashboard/settings/SecurityTab.tsx): - Status card shows enabled/disabled state + backup codes remaining with low-code warning - Setup flow: QR code scan → manual secret entry (blurred with show/hide toggle) → TOTP confirm → backup codes display (one-time, copy-all button) - Disable flow: requires current TOTP code - Regenerate backup codes: requires current TOTP code; shows new codes immediately - All errors inline with dismissal - Settings page: Security tab added (Shield icon) — wired to SecurityTab component. - Audit logging: All 2FA events (enabled, disabled, backup-code used, admin reset) written to audit log.Fixed
- Blog/News editor formatting pipeline + royalty-free image integration (task 52 follow-up 4):
- PART 1 — Editor insertion fixed:
insertAIOutputwas callingsetHTML(existingHTML + newHTML)which mapped to TipTap'ssetContent()on a concatenated HTML string. TipTap re-parses the full string as a new document, destroying heading/list node structure. Fixed by addinginsertHTML(html)toEmailEditorRefthat callseditor.chain().focus('end').insertContent(html)— TipTap properly appends parsed nodes without touching the existing document. - PART 1 — Content styling fixed (editor + public):@tailwindcss/typographyis not installed soprose prose-smandprose prose-neutralclasses were dead CSS — headings, lists, blockquotes were completely unstyled in both the editor and published articles. Fixed by removing allproseclasses and replacing with explicit shared rules inglobals.cssunder.tiptap-editor-content .ProseMirror,.ai-output-preview, and.blog-articleselectors. All three surfaces now share the same h1/h2/h3, ul/ol/li, blockquote, strong, em, img, and hr styles. - PART 1 — EmailEditor refactored: Replacedprose prose-sm max-w-none [&_.ProseMirror_...]long className with cleantiptap-editor-contentclass; placeholder CSS moved to global stylesheet. - PART 1 — Public blog article:blog/[slug]/page.tsxarticle element usesblog-articleinstead of unsupportedprose prose-neutral ...classes. - PART 2 — Royalty-free image search: NewGET /api/tenant/[tenantId]/images/search?q=route. Dev mode returns curated food photography using a 21-entry keyword→Unsplash-photo-ID map (scored against the query). Production uses Pexels API (PEXELS_API_KEY) or Unsplash API (UNSPLASH_ACCESS_KEY); per-tenant key override viasettings.imageApiSource+settings.imageApiKey. - PART 2 — Auto-suggest after generation: After any AI action that targets the editor,searchImages(topicHint)fires automatically and populates a 3-column image grid in the AI panel sidebar. - PART 2 — Image panel UI: Image suggestions section at the bottom of the AI tab — search box, refresh button, 3×3 thumbnail grid with hover overlay. "Set Cover" → setsform.imageUrl(featured image field). "Insert" → callsinsertHTMLto embed<figure><img><figcaption>into the article with lazy loading and attribution. - Schema:TenantSettings.imageApiSourceandTenantSettings.imageApiKeyadded (optional, no migration needed). - `.env.local`:PEXELS_API_KEYandUNSPLASH_ACCESS_KEYdocumented.- Blog/News AI output formatting pipeline (task 52 follow-up 3):
- Prompt leakage fixed: Dev mock's
extractTopicFromPrompt()previously failed to strip trailing instruction text (e.g. "Tone: Professional. Use HTML: <p>, <h2>... Length: 500-800 words") from the structured prompts sent by action buttons — the leftover blob was used as the article title/topic, leaking instructions into the HTML. Fixed by adding atopicHintfield to the AI request body: the blog editor now passes the raw clean topic alongside the full instruction prompt; the dev mock usestopicHintdirectly, bypassing all regex extraction. - Dev debug notes removed:*(Dev mode active — connect ANTHROPIC_API_KEY...)*notes stripped from all mock article HTML templates. - HTML rendering fixed: AI output panel replaced the<textarea>(which showed<h2>Title</h2>as literal characters) with a<div dangerouslySetInnerHTML>when the output contains HTML. A basic scrub (<script>,<style>,on*attributes,javascript:) is applied before render..ai-output-previewCSS added toglobals.cssfor headings, paragraphs, lists, and blockquotes. - Prompt wording cleaned: "Generate Full Article" and "Continue Writing" user messages no longer carryUse HTML: <p>, <h2>.../Length: 500-800 wordsinstructions — thebuildContentSystemPromptsystem prompt already covers these; putting them in the user turn was causing the model to echo them back. - `runCustomPrompt` wording fixed: "Write a complete, well-structured blog article for a restaurant about: ..." → "Write a professional restaurant blog article about: ..." — no instruction detail in the user message.- Blog/News AI content generation routing (task 52 follow-up 2):
- Root cause: AI route used a single system prompt scoped as a "restaurant navigation/support assistant". For inputs like "onion rings recipe" or "best pizza sauce", the dev-mode mock had no matching pattern and fell back to a navigation-style help list. In production, the prompt gave Claude no instruction to generate articles from bare keyword inputs. - Fix — AI route `mode` parameter:
/api/tenant/[tenantId]/ainow acceptsmode: "content" | "general"in the request body."content"mode uses a purpose-builtbuildContentSystemPrompt()that explicitly directs the model to generate blog articles/recipes/marketing copy — never navigation responses — and bumpsmax_tokensfrom 1024 to 2048. - Fix — dev mock content mode:getContentMockResponse()added with patterns for recipes, guides, reviews, and a universal article-generation fallback. Any input in content mode now produces real HTML article output (with a dev-mode note), never a help list. - Fix — BlogPostEditor `runAI`: All requests from the Blog/News editor now includemode: "content"in the payload. - Fix — custom prompt wrapping:runCustomPrompt()helper detects bare keyword/topic inputs (no instruction verb, < 80 chars, no question mark) and automatically wraps them with explicit article-generation instructions before sending, so "onion rings recipe" becomes "Write a complete, well-structured blog article about: 'onion rings recipe'…". - The general assistant mode (chat widget, admin AI page) is completely unchanged.- Blog/News AI assistant quick actions (task 52 follow-up):
- Root cause 1:
runAIwas sending{ prompt: promptText }but the AI route requires{ messages: [{ role: "user", content: promptText }] }— caused{"error":"Invalid messages"}on every request. - Root cause 2: Streaming parser tried to parse SSEdata:lines from the plain-text stream the AI route returns; a logic error also caused the entirechunkbuffer (not the decoded line) to be appended, then the loop broke immediately — output was always garbled or empty. Fixed to plain accumulation:out += decoder.decode(value, { stream: true }). - Enhanced AI actions: Expanded from 9 flat buttons to 27 categorized actions across Content / SEO / Distribution / Insights tabs, each with anInsertTargetthat routes output to the correct field (editor, SEO title, meta description, slug, social caption, email subject, or display-only). - Smart insert routing:insertAIOutput()mapsInsertTargetto the correct state setter; Insert button label changes dynamically ("Insert into Editor", "Apply as SEO Title", etc.). - Rich context building:buildAIContext()passes title, excerpt, category, tags, and current word count to every prompt for more relevant completions. - AbortController: Each new action cancels any in-flight request. - User-friendly errors: Network failures and non-OK responses show a plain human message — never raw JSON.Added
- Item modifiers / extras (task 52b):
- `ProductEditorModal` already had full option group editing UI (built in a prior task) — no UI changes needed. - `ProductCustomizeModal` (public menu) already fully implemented — radio/checkbox groups, removable ingredients, price calculation, qty selector, notes, add to cart. - Fixed: `optionGroups` not persisted on product create —
CreateProductSchema(Zod) extended withoptionGroups: z.array(z.any()).optional(); POST route now saves and returnsoptionGroups. - Fixed: `optionGroups` lost after product edit — PATCH route response now includesoptionGroups: p.optionGroups ?? []; dashboard local state updates correctly. - Complete end-to-end: admin creates groups → customer sees customization modal → selections tracked in cart → prices verified server-side on order submit.- Enhanced Blog & News editor (task 52):
- `BlogPost` schema extended with optional fields:
updatedAt,status,scheduledAt,postType,category,tags,featured,excerpt,seo— all backward-compatible; existing JSON files require no migration. - `BlogPostEditor` component (src/components/dashboard/blog/BlogPostEditor.tsx) — full-page WordPress-style editor with sticky header (back, breadcrumb, auto-save indicator, Save Draft, Publish), 2-column layout (cover image + title + excerpt + TipTap rich text on left; sidebar tabs on right). - Sidebar tabs: - Publish — status selector (draft/published/scheduled), scheduled datetime, post type (blog/news), featured toggle, category, tags input, URL slug editor. - SEO — SEO title, meta description (with char counters), keywords, OG image URL, canonical URL, live Google-style search preview card. - Distribute — social media post to connected accounts only (fetched from social/accounts API), custom caption with AI generate, email campaign creator with subject, schedule toggle, single "Distribute Post" button. - AI — tone selector (Professional/Friendly/Promotional/Luxury/Casual), 9 quick-action buttons (Generate Article, Write Intro, Improve Content, Grammar Check, SEO Title, Meta Description, Excerpt, Tags, Change Tone), custom prompt input, streaming output textarea with Insert into Editor and Clear actions. - Auto-save — debounced 30 s after last change; only fires after post has an ID; shows Saving… / Saved / Save failed indicator in header. - New post flow — first explicit Save Draft POSTs to create; editor navigates to/dashboard/blog/[newId]/editand auto-saves thereafter via PATCH. - `/dashboard/blog/new/page.tsx` — new post page. - `/dashboard/blog/[postId]/edit/page.tsx` — server component that loads post from DB and passesinitialDatato editor. - Blog API routes updated — POST/PATCH accept all new fields; GET single post added;statusandpublishedfields kept in sync;updatedAtset on every PATCH. - Blog list page — removed modal editor, now routes to/dashboard/blog/newand/dashboard/blog/[id]/edit; status badges (Published/Draft/Scheduled), featured star, news badge, category, tags, scheduled date shown per post.- Branding system + social sharing (task 51):
- `BrandConfig` schema added to
TenantSettings—logoMode: 'image' | 'text',logoUrl,logoHeight,textFontSize,textFontWeight,textColor,textCustomColor,tagline,taglineFontSize. Fully optional; existing tenants withsettings.logoUrlauto-upgrade to image mode. - `RestaurantBrand` component (src/components/tenant/RestaurantBrand.tsx) — single centralized component for all logo rendering. Used by SiteHeader, SiteFooter, and MobileNav slide panel. Supports image or text mode, taglines, custom colors. LogohomeHrefprop always links to homepage consistently across all surfaces and devices. - `SiteHeader` updated to useRestaurantBrand— removed inline logo logic. - `SiteFooter` updated to useRestaurantBrand— removed inline logo logic. - `MobileNav` updated — acceptsbrand,legacyLogoUrl,homeHrefprops; panel header usesPanelBrand(client-compatible brand renderer). - `ShareButtons` component (src/components/tenant/ShareButtons.tsx) — client component with Facebook, X/Twitter, LinkedIn, WhatsApp (mobile-optimizedwa.medeep link), Email (mailto:with auto-filled subject/body/excerpt), and Copy Link (clipboard API with fallback + visual confirmation). No external SDKs. - Blog post page OG metadata —generateMetadata()exported from blog post page. Generatesog:title,og:description,og:image(post image or settings fallback),og:url,twitter:card(summary_large_imagewhen image present), canonical URL. Host/proto resolved from request headers. - Share buttons appear in blog posts — above and below article body for maximum visibility. - Branding tab in dashboard Settings (/dashboard/settings) — toggle image/text logo mode, logo URL input + height slider, text size/weight/color pickers (with live preview), custom hex color picker, optional tagline + tagline size. Saves to/api/tenant/[id]/settingswithbrandConfig. - Settings API updated —brandConfigandlogoUrladded to allowed PATCH fields;UpdateSettingsSchemaextended withBrandConfigSchemavalidation.- Navigation management system redesign (task 50): Complete overhaul of tenant site navigation — centralized, database-backed, and fully manageable per restaurant.
- `NavConfig` / `NavItem` schema added to
TenantDB— per-tenant JSON with items array (id,label,href,badge,visibility,order,enabled),showBookButton,showCartButton,updatedAt. - `src/lib/nav/nav-service.ts` — shared utility library:getDefaultNavItems(),getDefaultNavConfig(),resolveHref(),getItemsFor(),AVAILABLE_PAGESconstant. - `SiteHeader` new async server component (src/components/tenant/SiteHeader.tsx) — readsdb.navConfig(falls back to defaults), computesbasePathfrom proxy header, renders unified desktop nav +MobileNavwith pre-resolved hrefs +LanguageSwitcher. Replaces ~15 lines of duplicated inline<header>on every tenant page. - `SiteFooter` new async server component (src/components/tenant/SiteFooter.tsx) — reads footer-visible nav items, brand info, and social links. Replaces inline footers across tenant pages. - `MobileNav` rewritten to accept dynamicitems: MobileNavItem[]array — no more hardcoded nav items; supports badges, external links, ARIA keyboard nav. - All tenant pages updated to use<SiteHeader>+<SiteFooter>: home, menu, reservations pages. - Blog listing page created at/tenant/[id]/blog— published posts grid with image, excerpt (HTML-stripped, 140 chars), date, and Read More link. - Blog post page created at/tenant/[id]/blog/[slug]— full article with rendered HTML content, Back to Blog, hero image. - Nav CRUD API atGET/PATCH /api/tenant/[tenantId]/nav— returns current config (or defaults), validates with Zod, saves and logs activity. - Navigation Builder dashboard page at/dashboard/nav— full drag-to-reorder UI, add/edit/delete items, device visibility toggles (D/M/F), badge picker (New/Hot/Promo), link-type picker (Page/Anchor/External URL) withAVAILABLE_PAGESpicker, global book/cart button toggles, save to API. - "Navigation Builder" entry added to dashboard sidebar.- SMS notifications for new orders and reservations (task 49): Restaurant owners receive instant SMS alerts when customers place orders or make reservations. Supports Twilio and Vonage as providers. Settings are in a new "Notifications" tab inside Restaurant Settings: configure credentials (Account SID / Auth Token), the sender number, and one or more recipient numbers in E.164 format. Toggle order notifications and reservation notifications independently. Test-send button fires a real (or dev-mode logged) SMS on demand. In dev mode without credentials, messages are logged to the server console. API:
GET/PATCH /api/tenant/[tenantId]/smsfor settings,POSTfor test-send. SMS sending is non-blocking (sendSmsToAllfires and forgets) so it never delays the order/reservation response.SmsConfigadded toTenantDBschema.
- Full customer registration and accounts system: Customers can now create real accounts, sign in, and manage profiles on any tenant restaurant site. Replaces the email-lookup-only account page (task 44) with a proper authenticated portal.
- 8 new auth API routes under
/api/public/[tenantId]/auth/:register,login,logout,me,forgot-password,reset-password,verify-email,profile(GET/PATCH/DELETE). - `CustomerRecord` schema added to TenantDB — scoped per tenant, withCustomerProfile,SavedAddress[],CustomerConsents,CustomerAnalytics,CustomerAuth(pending tokens, lockout),favoriteItemIds, andloyaltyMemberIdlinkage. - Security: PBKDF2-SHA512 password hashing (10,000 iterations, random salt), HS256 JWT session in a 30-day HttpOnlycustomer_sessioncookie (separate namespace from adminplatform_session), 5-attempt account lockout (15-minute window), in-memory rate limiting on registration (5/10min), login (10/min), and forgot-password (3/15min) endpoints, email enumeration prevention on forgot-password. - Pending token system:email_verify(24h TTL),password_reset(1h TTL),phone_otp(10min TTL) — stored inCustomerRecord.auth.pendingTokens, single-use (marked withusedAt). - GDPR (Article 17) account deletion:DELETE /auth/profilesoft-deletes the record and erases all PII (email, name, phone, password, profile, addresses) viaeraseCustomerPII(). Order history is retained anonymously. - Auth-aware account page at/tenant/[id]/account: guests see Login / Register / Forgot-Password / Reset-Password forms; authenticated users see a 4-tab dashboard — Profile (edit name, phone, birthday, dietary preferences, language), Orders (full expandable history), Loyalty (points card, tier progress, transaction history), Settings (saved addresses CRUD, notification consent toggles, change password, delete account). - New auth helpers:requireCustomerAuth()andoptionalCustomerAuth()added tosrc/lib/auth/api-auth.ts; full customer-auth service atsrc/lib/auth/customer-auth.ts. - Dev mode exposes_devVerifyTokenand_devResetTokenin API responses to allow testing without email delivery.- Customer account page (task 44): Public-facing account page at
/tenant/[id]/account. Customers enter their order email to look up their full order history (expandable per-order detail: items, totals, delivery fee, discount, coupon, payment status) and loyalty program status (points balance, reward progress bar, current tier badge, last 20 transactions). Data served by a newGET /api/public/[tenantId]/account?email=endpoint. "My Account" link added to desktop nav and mobile drawer on the menu page. - Branded QR codes with logo overlay (task 43 QR): Premium plan feature. Tenants on Premium or Enterprise can paste a logo URL and control its size (10–40% of QR area) in a new "Logo Overlay" panel on the QR Generator page. SVG output has the logo embedded via
<image>with a white rounded-rect pad for readability. PNG downloads use client-side canvas compositing (no extra server deps). Non-premium tenants see an upgrade prompt with a link to Billing. Plan status is fetched from the billing API on page load. - Three new Website Builder section types (task 43): Instagram Feed (placeholder gradient grid, configurable columns/count, handle + follow button), Team/Staff (up to 6 members with name, role, bio, photo — coloured avatar initials fallback), Newsletter Signup (email capture with Name toggle, success state, 3 background styles: Branded/Dark/Plain). All three registered in SectionRegistry and wired into ContentEditor with full field definitions.
- Custom font selection in Website Builder (task 42): Tenants can now pick from 10 curated Google Font pairs (Inter, Playfair Display, Merriweather, Montserrat, Raleway, Cormorant, Poppins, Josefin Sans, Abril Fatface, DM Serif) via a new typography picker in the Theme & Colors panel. Selected pair is persisted to
db.settings.fontPair, loaded on builder init, auto-saved with the other builder settings, and applied site-wide (heading and body fonts injected via Google Fonts<link>+ CSS custom properties) on both the builder canvas and all live tenant pages.
Fixed
- `translate()` on plain strings shows only first character (task 43): The builder stores block content as plain strings (e.g.
"Welcome to Our Restaurant"), buttranslate()expects{en,sv}objects. When called with a string, it fell through totext[Object.keys(text)[0]]=text["0"]="W"(first character). Added atContent()helper that short-circuits for strings and delegates totranslate()only for objects. Alltranslate(block.content.*)calls in the tenant live page replaced withtContent(). - Menu Preview live page now matches builder (task 43): The live site's
menu-previewcase was hardcoded to list/category layout and ignored all builder settings (layout,showNumbering,numberingStyle,maxItems,showImages,showDescriptions). Rewrote to support all 4 layout variants (list/grid/cards/minimal) with real tenant products, numbered items, descriptions, and images — matching the builder's visual output. - Hero block field name mismatch (task 43): The builder API normalizes hero content field names on read (
title→headline,subtitle→subheadline,ctaOrder→ctaText,ctaBook→ctaLink) and saves the normalized names. The live tenant page was still reading the old field names, causing the hero to show fallback values (settings.name, default CTA text) instead of the edited content. Fixed by reading both new and legacy field names with fallback. Also fixed hero background image to useblock.content.backgroundImage(set via builder) instead of onlysettings.bannerUrl. Hero overlay opacity and min-height are now also applied on the live page. - Announcement block colors (task 43): Live page announcement was hardcoded to
bg-primary text-secondary. Now readsblock.content.bgColor/block.content.textColor(set via the builder's color pickers), with theme defaults as fallback. Alignment, font size, and padding settings also applied. - "Live Site" / "View Live Site" dashboard links (task 43): On subdomain access (e.g.,
luigi.localhost:3001/dashboard), these buttons linked to/tenant/luigi-pizzawhich the proxy double-rewrote to/tenant/luigi-pizza/tenant/luigi-pizza(404). Bothdashboard/page.tsx(server component) andbuilder/page.tsx(client component) now detect whether the current host is a platform host or a tenant subdomain and route to/(root of the subdomain) vs/tenant/${tenantId}accordingly. - Broken tenant site navigation links (task 43): All internal links on the public restaurant website (
/menu,/reservations,/checkout,/order-confirmation) were broken when accessed vialocalhost:3000/tenant/<id>because they used root-relative paths that relied on the proxy rewriting (which only activates on custom domains).
-
src/proxy.ts— setsx-tenant-base: rootresponse header when performing a custom-domain rewrite, signaling to server components that root-relative links are safe. -src/app/tenant/[tenant_id]/page.tsx— computesbasePathfrom the header; prefixes all<Link href>values (/menu,/reservations) and passesbasePathtoMobileNav. -src/app/tenant/[tenant_id]/menu/page.tsx— samebasePathpattern; fixes Home/About/Contact/Book links and passesbasePathtoMenuClient. -src/app/tenant/[tenant_id]/reservations/page.tsx— fixes Home/Menu links. -src/app/tenant/[tenant_id]/checkout/page.tsx— fixes Home/Back-to-Menu links; passesbasePathtoCheckoutForm. -src/app/tenant/[tenant_id]/order-confirmation/page.tsx— fixes Back-to-Menu and Go-to-Homepage links. -src/app/tenant/[tenant_id]/_components/MobileNav.tsx— acceptsbasePathprop; all nav links updated. -src/app/tenant/[tenant_id]/_components/CartDrawer.tsx— acceptsbasePathprop;router.push('/checkout')updated. -src/app/tenant/[tenant_id]/_components/MenuClient.tsx— accepts and threadsbasePathtoCartDrawer. -src/app/tenant/[tenant_id]/_components/CheckoutForm.tsx— acceptsbasePathprop; "Browse Menu" link updated.Added
- Per-section background image upload (task 41):
-
ContentEditor— added Background Image (MediaPickerimage-upload) and Overlay Darkness (select) fields tocta-bannerandstatssections. -CtaBannerSection— whenbackgroundImageis set, renders the image asbackground-imageCSS with an RGBA overlay at the chosen opacity; text and button colors switch to white automatically. -StatsSection— same background/overlay pattern; stat values turn white on dark backgrounds, labels use 70% white opacity for contrast.- Menu item drag-to-reorder within a category (task 51):
-
PATCH /api/tenant/[tenantId]/menu/products— new bulk-reorder endpoint; accepts{ order: [{id, order}] }, updates all matched products in one save. - Menu page — product grid now wrapped inDndContext+SortableContext(dnd-kit, same library as categories). Each card has aGripVerticaldrag handle on the left. Drag-end callshandleReorderProductswhich updates state optimistically and persists to the API.- Self-service reservation cancellation (task 45):
-
Reservationschema — addedcancelToken?: string(opaque UUID stored server-side). - Reservation creation (POST /api/public/[tenantId]/reservation) — generates acancelTokenper reservation, returns it in the response. -PATCH /api/public/[tenantId]/reservation/[id]/cancel— new public endpoint. Validates token matches, rejects already-canceled/rejected reservations, sets status tocanceled, writes activity log. -ReservationForm— stores cancel link from API response, shows it in the success state as a "Cancel this reservation" link. -/tenant/[tenant_id]/reservations/cancel— new public page. Reads?id=&token=from URL, shows confirmation UI, calls the cancel API. Handles invalid links, already-canceled, and error states.- Product image lightbox (task 46):
-
MenuClient— product images are now clickable buttons. Hovering shows a zoom icon overlay. Clicking opens a full-screen lightbox (dark backdrop + blur) without triggering the customize modal. -Lightboxcomponent — Escape key and click-outside both dismiss it. Image is not affected by card's onClick propagation (stopPropagation).- Coupons / promo code redemption tracking (task 30):
-
Offerschema — addedcouponCode(the code customers type, falls back to offer title),maxUses(redemption cap),minOrderAmount(minimum subtotal required). -resolveCoupon()— now matches oncouponCode ?? title, enforcesmaxUseslimit (returns null when cap is reached), enforcesminOrderAmount(returns null when subtotal is too low). - Offers API POST/PATCH — new fields saved and updated.redemptionCountinitialised to 0 on create. -OfferEditorModal— coupon-specific settings panel (Coupon Code input auto-uppercased, Max Uses, Min. Order Amount) shown only when type = "Coupon". - Offers page — coupon cards now show: code badge in monospace, redemption count, progress bar whenmaxUsesis set, min. order line.- Language settings page (task 35):
LanguageSettingsTab— enable/disable per-language toggles, set default language, save via/api/tenant/[tenantId]/languages. Cannot disable the last enabled language. Already implemented.
- Domain connection UI (task 34):
DomainSettingsTab— custom domain input, CNAME record instructions table, verify button that calls/api/tenant/[tenantId]/domain/verifyand shows Verified / Pending / Mismatch status badge. Already implemented.
- SEO settings page (task 33): 5-tab SEO page — Overview (score ring + checklist), Meta & Tags (title, description, keywords, OG image, Google Preview), AI Generator (one-click full SEO package), Local SEO (Google Business description + local keywords), Technical (robots policy, canonical URL, JSON-LD schema). Already implemented.
- Notifications system (task 32): Server-persisted read state in
TenantDB.readNotifIds, PATCH endpoint for mark-read/mark-all, optimistic local set for instant badge update, empty states, Mark All as Read button. Already implemented.
- Customer management page (task 31):
/dashboard/customers— aggregates customers from order history (keyed by email), KPI row (total customers/revenue/orders), search by name/email/phone, sortable table, expandable per-customer order history. API:GET /api/tenant/[tenantId]/customers. Already implemented.
- Cart persistence across page navigation (task 29):
CartContext.tsxstores cart state inlocalStorageunder keycart_${tenantId}. Hydration runs once on mount; write-back runs after every state change (guarded by hydration flag to prevent overwrites). Wrapped in try-catch for corrupted storage. Cart is isolated per tenant and persists across page navigations and browser refreshes.
- Revenue by category chart (task 23): Analytics page shows a horizontal bar chart with a 10-color palette for all product categories. Analytics API computes
revenueByCategoryfrom paid orders in the selected date range, cross-referenced with the tenant product/category maps. Already implemented.
- Date range picker on analytics page (task 22): Preset buttons (Today / 7 days / 30 days / 90 days) plus a Custom mode with From/To date inputs and an Apply button. Active preset highlighted in blue. Current range shown as
from → tolabel. All analytics data and CSV exports use the selected range. Already implemented.
- CSV export working end-to-end (task 21): Analytics CSV export now correctly filters by the selected date range.
-
GET /api/tenant/[tenantId]/analytics/export— addedinRange(isoDate)helper; orders filtered bycreatedAt, reservations bydate, top-products filtered by ordercreatedAt. Date paramsfrom/to(YYYY-MM-DD) are applied to all three export types. AddeddiscountAmountcolumn to orders export. Filename now includes date range (${type}-${from}_${to}.csv). Full try-catch added. - Analytics page —handleExportpasses active date range to the export URL, setsexportErrorstate on failure, shows a dismissable error banner.- Notification read-state system — full fix (task 45): Read state previously lived only in
localStorage, so notifications reappeared as unread after logout, browser restart, or a different device.
-
TenantDB.readNotifIds(Record<string, string>) — lightweight server-side map ofnotifId → readAt ISO timestamp. No separate table; stored in the tenant JSON. Entries older than 60 days are pruned automatically on each PATCH. -GET /api/tenant/[tenantId]/notifications— now returnsisRead: booleanper item (derived fromreadNotifIds) andunreadCount. Full try-catch added. -PATCH /api/tenant/[tenantId]/notifications— new endpoint. Body{ ids: string[] }marks specific notifications read;{ markAll: true }marks all current notifications read. Persists to DB, returns updatedunreadCount. -TopHeader— removedlocalStorageread-tracking (was the source of bugs). Read state now comes from server. Optimistic local set (localReadIds) gives instant UI feedback between PATCH and next poll. Badge counter and highlight styling update immediately on click. - Added "Mark all as read" button (withCheckCheckicon) in the notification dropdown header, visible only whenunreadCount > 0. - Fixed empty state: shows "All caught up!" when there are items but all are read, vs "No notifications yet" when the list is empty. - Dropdown now shows only unread items highlighted; read items display in muted style and the "all caught up" state replaces the list body whenunreadCount === 0.- Multi-Restaurant Accounts — Batch H (task 44): One user can now own and manage multiple restaurant locations from a single account.
-
GET /api/restaurants— returns all restaurants owned by the current user with their active status. -POST /api/restaurants/switch— issues a new session cookie with the selected restaurant as the activetenantId. -POST /api/restaurants/create— creates a new restaurant (tenant DB + master record + subscription) for the logged-in user and auto-switches the session to the new restaurant. -RestaurantSwitchercomponent in sidebar — dropdown showing all owned restaurants with one-click switching and "Add Restaurant" shortcut. -/dashboard/restaurantsmanagement page — full list of locations with active indicator, site link, switch button, and "Create Restaurant" modal with auto-generated subdomain. - Dashboard layout now fetches all tenant records owned by the user and passes them toTenantProvidervia newrestaurants: RestaurantSummary[]prop. - Login route updated to prefer the first active restaurant when a user has multiple locations.- Website Builder polish & media system (task 42): UX, image uploads, and section improvements.
- MediaPicker component (
src/components/builder/MediaPicker.tsx) — unified image selection used across all image fields. Features: drag-and-drop upload, click-to-upload, Image Bank modal (browses existing tenant media library), image preview with hover Replace/Remove, URL paste fallback. Architecture is future-ready for CDN and shared asset library. - Menu Preview — item numbering: NewShow Item Numberstoggle + numbering style select (Arabic 1,2,3 / Padded 01,02,03 / Roman I,II,III / Alphabetical A,B,C). Numbers appear dynamically in all 4 layout variants. Quick-link to Menu Settings in the editor panel. - Hero Banner improvements: NewContent Alignment(centered/left/right),Banner Height(300px–fullscreen),Overlay Darkness(0–85%) content controls. Background image now uses MediaPicker for upload + image bank. Empty-state placeholder shown in builder when no image is set. - Delivery & Takeaway — flexible image system: NewImage Layoutselect (1/2/4 images). Each image slot uses MediaPicker (upload + image bank). Empty slots disappear cleanly; grid adapts to chosen count. - Announcement Bar bug fix: Background color and text color now use content-level fields (bgColor,textColor) that override theme defaults correctly. Added alignment (left/center/right), font size (sm/base/lg), and padding (compact/normal/spacious) controls. - ContentEditor new field types:image-upload(renders MediaPicker),color(color picker + hex input with clear),nav-link(navigation shortcut button),divider(visual section separator for long field lists). All blocks with many fields now have labeled sections. - ContentEditor UX: Image fields show size/format hints inline.tenantIdprop threaded from builder page so uploads work in context.- Website Builder Elementor-style enhancements (task 41): Major upgrade making the builder feel like Elementor/WordPress.
- StyleEditor rebuilt: Full gradient backgrounds (from/to/direction), border (color/width/radius), box shadow (5 levels), opacity slider, entrance animations (fade-in/slide-up/slide-left/zoom-in), per-device responsive visibility (hide on mobile/tablet/desktop), custom CSS class, copy/paste style between blocks. - Canvas hover toolbar: Each block shows a floating toolbar on hover with move-up, move-down, duplicate, and delete buttons. No click required to reveal block controls. - Keyboard shortcuts: Delete/Backspace to remove, Ctrl+D to duplicate, Ctrl+Z/Ctrl+Y to undo/redo, Escape to deselect. - ContactInfoSection rebuilt: Phone, email, address, and WhatsApp all editable per-block. Toggle show/hide for: Call button, SMS button, WhatsApp button, Map link, Contact form, Social links. Instagram/Facebook URLs configurable per-block. Two-column / single-column layout choice. - MenuPreviewSection rebuilt: 4 layout variants (List / Cards / Grid / Minimal). Toggle show/hide for images, prices, descriptions. Max items (2/3/4/6) and button text configurable. - ContentEditor toggle field type: New
togglefield type renders a pill switch. Toggle state stored as boolean. Contact and menu preview blocks now have full field coverage in the editor panel. - Builder canvas:blockStyleToCSS()updated for gradients, borders, shadow, opacity, margins, animations, responsive classes. Block wrappers apply entrance animation CSS class and responsive Tailwind classes. - Tenant live page:withBlockStyle()updated for all new style properties. Contact section respects block.content overrides with settings fallback, respects all show/hide toggles. Animation CSS keyframes added to globals.css. - CSS animations:@keyframesfor fade-in, slide-up, slide-left, zoom-in added toglobals.css;.builder-animate-*utility classes applied on the live site.- Website Builder redesign (task 40): Upgraded the basic 2-panel builder into a modern 3-panel drag-and-drop page builder.
- 3-panel layout: Left panel (Library / Layers / Theme / Templates tabs) | Center canvas (device preview) | Right contextual edit panel (Content / Style tabs). - BlockLibrary component (
src/components/builder/BlockLibrary.tsx) — categorised, searchable block picker with emoji icons; 4 categories: Layout, Content, Restaurant, Contact. - ContentEditor component (src/components/builder/ContentEditor.tsx) — block-specific field definitions for every block type; renders text, textarea, URL, image-URL, and select inputs. - StyleEditor component (src/components/builder/StyleEditor.tsx) — per-block visual style controls: background colour, background image + dark overlay (0–80%), vertical padding (XS–XL), content width (full/wide/normal/narrow), text colour, text alignment, hide-on-mobile toggle, reset button. - LayersPanel component (src/components/builder/LayersPanel.tsx) — scrollable layer list with drag-to-reorder (dnd-kit), visibility toggle (eye icon), duplicate button, delete button; shows active block highlighted. - 8 new block types:heading,paragraph,image-full,cta-banner,stats,video,faq,announcement— each with its own section component and ContentEditor field definitions. - BlockStyle system:BlockStyleinterface added toschema.tsandBuilderState.tsx; newUPDATE_STYLE,DUPLICATE_BLOCK,TOGGLE_VISIBLEreducer actions; style is persisted with each block in the DB. - SectionRegistry enhanced withcategoryandiconfields; newSECTION_CATEGORIESexport for the block library. - Tenant page (src/app/tenant/[tenant_id]/page.tsx):withBlockStyle()wrapper applies custom background/padding/text styles to any block; 8 new block types fully rendered for customers. - Backward compatible: All existing blocks continue to render exactly as before;BlockStyleis optional.- Email Marketing upgrade (task 38): Full professional campaign builder replacing the basic HTML textarea editor.
-
EmailEditorcomponent (src/components/email/EmailEditor.tsx) — TipTap 3 rich text editor with full toolbar: headings (H1–H3), bold/italic/underline/strikethrough/highlight, text colour picker, alignment, bullet/ordered lists, blockquote, link + image insert (via inline dialogs), undo/redo, live character count. - Campaign builder overlay (CampaignBuilder) — full-screen panel with left editor pane and right live preview pane; template picker step (6 built-in templates + blank), subject/preview text fields, AI Generator section (type selector + generate button), TipTap editor, scheduling section (Send Now / Schedule / Recurring with frequency/interval/time/end-date inputs), analytics mini-strip on sent campaigns. - Desktop/mobile preview toggle —<iframe srcDoc>renderswrapForSend()HTML in an isolated sandboxed frame. - Test email send —/api/tenant/[tenantId]/email/campaigns/[campaignId]/testPOST — sends to the owner's account email (or a custom address); shows "[TEST]" prefix and note in the email. - AI email generation —/api/tenant/[tenantId]/email/ai-generatePOST — generates subject + bodyHtml for the selected campaign type; uses Claude in production, template-based mock in dev. - Schema:CampaignStatusextended withrecurring; new typesRecurringPattern,CampaignAnalytics,EmailTemplate,AutomationType;emailTemplatesadded toTenantDB. -src/lib/email/campaign-templates.ts— 6 responsive inline-styled HTML email templates withapplyVars()andwrapForSend()utilities. - KPI strip improved: Avg Open Rate and Drafts/Scheduled cards added; campaign list shows analytics mini-strip (delivered, open rate, click rate) for sent campaigns. -STATUS_METAextended withrecurringstatus badge.- Payment settings page (task 16): New "Payments" tab in dashboard Settings. Restaurant owners can enable/disable Stripe, PayPal, Swish; enter Stripe publishable key, PayPal client ID, and Swish merchant number. Stored in
paymentSettingson tenant settings. Schema extended withstripePublishableKeyandpaypalClientId. - PayPal checkout (task 17): Full PayPal order flow —
/api/public/[tenantId]/paypal-ordercreates a PayPal order and pre-saves the DB order;/api/public/[tenantId]/paypal-capturecaptures payment and marks order paid.PayPalCheckout.tsxcomponent loads PayPal JS SDK dynamically and renders PayPal Buttons. Dev mode shows mock PayPal approval UI (no real payment). Required env vars:PAYPAL_CLIENT_ID,PAYPAL_CLIENT_SECRET,PAYPAL_ENV(sandbox/production),PAYPAL_WEBHOOK_ID. - Swish checkout (task 18): Order is created immediately; customer is shown the restaurant's Swish merchant number, order total, and step-by-step instructions to pay via their Swish app. After paying, customer taps "I've completed the Swish payment" to proceed to the confirmation page.
- Payment failure handling (task 19): Stripe payment failures now show a "Try a different card" button that resets the card form and lets the customer retry without losing their order details. Error messages from declined cards, network failures, and PayPal capture failures are surfaced inline. PayPal and Swish errors propagate to the same inline error display.
- PayPal webhook (task 20):
/api/webhooks/paypal— Verifies PayPal webhook signature viaPAYPAL_WEBHOOK_IDenv var. HandlesPAYMENT.CAPTURE.COMPLETED,PAYMENT.CAPTURE.DENIED, andCHECKOUT.ORDER.APPROVEDevents. Dev mode returns 200 immediately. Acts as belt-and-suspenders behind the synchronous capture route. - Checkout payment filtering: Checkout page reads
paymentSettingsfrom tenant DB and passes enabled methods toCheckoutForm. Only enabled providers are shown as payment options (cash always available). "Coming soon" badges removed from PayPal and Swish.
- Error boundaries:
src/app/(dashboard)/dashboard/error.tsx— catches dashboard page crashes with retry + home button; identifies tenant DB errors with a specific message.src/app/global-error.tsx— catches root-level application crashes. - Zod validation:
src/lib/validation/schemas.ts— typed Zod schemas for all public and tenant API inputs (login, register, orders, reservations, contact, products, settings, staff, blog, offers).parseBody()helper for consistent 400 error responses. - API hardening: Zod schemas wired into
auth/login,auth/register,public/orders,public/reservation,tenant/menu/products,tenant/settingsroutes. Removed all manual field checks in favour of schema validation. - Mobile navigation:
MobileNav.tsx— slide-in panel with hamburger toggle, outside-click to close, body scroll lock, backdrop overlay. Replaces the hidden "Book" button on mobile. Wired into public tenant homepage header. - Website Builder section renderers: 9 new preview components registered in
SectionRegistry—FeaturedSection,MenuPreviewSection,TestimonialsSection,ReservationCtaSection,DeliveryCtaSection,GallerySection,OpeningHoursSection,CampaignsSection,GoogleMapsSection. Builder now previews all 11 section types correctly. - Stripe checkout: Full Stripe payment flow for restaurant orders —
StripeCardForm.tsx(Stripe Elements card input),/api/public/[tenantId]/payment-intent(creates PaymentIntent + pre-saves order), webhook handlespayment_intent.succeededandpayment_intent.payment_failedto update order payment status. Dev mode shows mock card form with 3 test card numbers.Orderschema extended withstripePaymentIntentId. Required env vars:STRIPE_SECRET_KEY,NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,STRIPE_WEBHOOK_SECRET. - Logging & monitoring (tasks 9–15):
-
src/lib/logger.ts— Structured logger: JSON lines in production, coloured pretty output in dev. All DB errors and auth events now route through this. -src/lib/monitoring/sentry.ts—captureException/captureMessagewrappers ready for@sentry/nextjs— fall back to logger untilSENTRY_DSNis set. -instrumentation.ts— Next.js instrumentation hook with Sentry init stub. -src/lib/audit/log.ts—writeAuditLog()helper appends to master DB audit log (capped at 10,000). Wired into login success, login failure, login blocked events. -src/lib/audit/activity.ts—writeActivityLog()helper appends per-tenant activity log (capped at 500). Type-safe:order | reservation | menu | settings | staff | auth. -src/app/api/log/client-error/route.ts— POST endpoint for client-side error reporting from error boundaries. - Error boundaries updated to POST errors to/api/log/client-erroron mount. - DB layer (db.ts) updated to use structured logger instead ofconsole.error. - Login route logs all auth events (success, failure, suspension) with IP and user context. -ActivityLogtype added to schema;activityLogs?: ActivityLog[]added toTenantDB. -src/app/api/tenant/[tenantId]/activity/route.ts— GET activity log with type filter. -src/app/(dashboard)/dashboard/activity/page.tsx— Activity Log dashboard page with filter chips, colour-coded event types, refresh. - Activity Log added to sidebar navigation. - Activity logging wired into: order placement, reservation creation, settings PATCH.- Delivery integrations (tasks 24–28):
-
DeliveryZoneCRUD API —GET/POST/PATCH/DELETE /api/tenant/[tenantId]/delivery/zoneswith Zod validation and full auth guard. -src/lib/delivery/normalize.ts—findTenantByPlatformAccountId()helper + order normalizers for Uber Eats, Wolt, Foodora, Deliveroo (each converts platform-specific payload → internalOrderschema). - Webhook receivers —/api/webhooks/ubereats,/api/webhooks/wolt,/api/webhooks/foodora,/api/webhooks/deliveroo— HMAC-SHA256 signature verification, tenant lookup by platform account ID, duplicate-order protection. - Menu sync API —POST /api/tenant/[tenantId]/delivery/sync— pushes menu to all enabled platforms withsyncMenu: true. - Delivery dashboard page overhauled: two-tab layout (Delivery Platforms / Delivery Zones), webhook URL display with one-click copy per platform, Sync Menu Now button, full delivery zone add/edit/delete UI.---
- Dashboard doorbell and kiosk menu poll on the POS change marks (2026-10-13, plan step 3 + item 1e). The dashboard's
0.1.0
2026-05-25
Added
Platform Core
- Multi-tenant subdomain routing via
src/proxy.ts(Next.js 16 middleware convention) - Platform login (
/login) and registration (/register) for restaurant owners - Super-admin panel at
/adminwith separate login + 2FA support - Role-based access: owner, manager, kitchen, waiter
- JWT session tokens (HS256) stored in httpOnly
platform_sessioncookie - PBKDF2-SHA512 password hashing with per-user random 16-byte salt
Admin Dashboard (21 pages)
- Overview, Menu, Orders, Reservations, Website Builder, QR Codes, Analytics
- Staff & Permissions, Offers & Campaigns, Loyalty, Reviews, Media, AI Assistant
- Blog, Social Media, Google Business, Import Data, Delivery Integrations
- Billing & Plans, Support, Settings
Public Tenant Website
- Dynamic restaurant website with 11 configurable sections
- Online ordering (delivery, pickup, dine-in) via
/api/public/[tenantId]/orders - Table reservation booking via
/api/public/[tenantId]/reservations - Full menu with category navigation
- Checkout: Stripe, PayPal, Swish
- Order status tracking page
- Multi-language support (English, Swedish)
Help & Documentation System
- In-app Help Centre (
/dashboard/help) — 22 articles, 9 categories - Article detail pages with FAQ accordion, bookmarks, related articles, "was this helpful?"
- Setup Wizard (
/dashboard/onboarding) — 8 steps, localStorage progress, completion celebration - Floating help button (
FloatingHelpButton) with inline search on all dashboard pages ContextualHelptooltip component for inline contextual guidance- Public Help Centre (
/help) — 12 articles, 6 categories, no login required - Help Centre integrated into landing page header and footer
Engineering Workflow System
AGENTS.mdextended with full stability and workflow rulesCHANGELOG.md,TODO.md,ROADMAP.md,BUGS.md,KNOWN_ISSUES.mdcreateddocs/folder: architecture, API reference, ADRs, release notes- Git push rule: never auto-push, always ask for confirmation first
Fixed
- Security: PBKDF2 hardcoded salt replaced with per-user random salt — all new passwords now stored as
salt_hex:hash_hex - UI: QR code SVG
width/heightattributes replaced with100%/autoto fix preview overflow - Hydration: dnd-kit ID counter mismatch in Website Builder — fixed with
ClientOnlywrapper + stableDndContext id - Hydration:
ThemeInjectormoved from inline<style>render touseEffectimperative injection - Hydration:
suppressHydrationWarningadded to<body>for browser extension attribute injection - Routing:
/loginand/registeradded to proxyBYPASS_PREFIXESso subdomain visitors can reach platform auth - UI:
TopHeaderhardcoded restaurant name replaced with dynamicuseTenant()context value - Data:
databases/tenants/template.jsonwebsiteBlocksmigrated from{en, sv}objects to plain strings with correct field names (headline/subheadlinefor hero) - API: Builder GET route (
/api/tenant/[tenantId]/builder) now normalizes legacy{en, sv}content on read — backward compatible with existing tenant DBs - React: Subscriptions page bare
<>fragment wrapper replaced with<Fragment key={s.id}>to fix missing key warnings - UX: Public help page quick chips now set category filter (not text search) — "Getting started" previously returned 0 results
Changed
AGENTS.mdupdated with Next.js 16 rules + full engineering workflow- Landing page footer Resources links wired to real
/helparticles instead of# - Landing page header includes "Help" navigation link
---
Version History
| Version | Date | Description | |---|---|---| | 0.1.0 | 2026-05-25 | Initial build — full platform foundation |
- Multi-tenant subdomain routing via