Plan 81 R11 - SekiroKenjii/Quizzivy GitHub Wiki
The last release of Phase R. It puts the deck's Landing on the apex quizzivy.com (D10), makes
the apex a second origin of the same app, moves join links and QR codes to it, and adds the
consultation form with consent, a privacy notice and an Admin list of requests (D18, DG-06,
DG-57). It then runs the hardening Phase 5 planned and never shipped: the rate-limit suite and
leak review (T-5.2), the accessibility pass (T-5.3), bundle budgets (T-5.4) and the native
Vietnamese review (T-5.8). The query review (T-5.5) shipped in R9. The Landing ships last
because its copy markets every module (70 Β§2 rule 8).
Screens are compared with docs/design/deck/Quizzivy Landing.dc.html. The privacy, status and
Admin consultation pages are not drawn and are built from deck parts (DG-06, DG-57, DG-100).
Branch work/redesign-r11 is cut from develop. External steps (Cloudflare, Google console) are
Thuong's, as in T-R0.8.
Deliverable: the Landing at https://quizzivy.com/ in Vietnamese and English, light and
dark, with join by code and a consultation form; the apex as a second app origin (credentialed
CORS, a session hint cookie, a second Google redirect URI); join links and QR codes on the apex;
leads stored with consent for 12 months, emailed to the centre without the phone number, and
listed in the Admin console; a privacy notice and a status page; the rate-limit suite, axe in CI,
bundle budgets, the reviewed Vietnamese copy; the spec at v1.0.
Exit criteria:
- The Landing matches the deck at 360, 720, 768, 960, 1024, 1280 and 1440, light and dark, vi and en (T-R11.15).
- An anonymous visit to
https://quizzivy.com/makes no request to the API. A signed-in visit lands on the visitor's console. Sign-in, Google sign-in and join work on both origins (apex.live.spec.ts). - Every open operation is driven past its limit by the suite, and the leak review of every public response is recorded.
- axe reports no serious or critical violation on the routes of T-R11.11. Lighthouse accessibility is 95 or more on the eight routes named there.
- Every route's initial JS is within
web/bundle-budgets.json, and the Landing's is under 200 KB gzip. - The native review is applied, and vi and en have the same keys.
- The five canaries pass untouched; every CI step of 70 Β§8 is green locally, including
pnpm e2e:liveandpnpm e2e:live-apex. - Released to production as
v1.0.0.
Schema changes (numbers assigned at merge):
| File | Task | Notes |
|---|---|---|
NNNNN_create_leads.sql |
T-R11.6 | new table |
NNNNN_add_organization_lead_recipients.sql |
T-R11.6 | R5's settings singleton; constant default |
Operations:
| Operation | x-permission |
Task |
|---|---|---|
POST /public/leads (submitLead, the thirteenth open operation, 70 Β§4.2) |
none: open (security: []), rate-limited, leak-reviewed |
T-R11.6 |
GET /admin/leads, GET, PATCH, DELETE /admin/leads/{id}
|
system.leads |
T-R11.6 |
R5's organization settings: gains leadRecipients
|
system.settings.write (unchanged) |
T-R11.6 |
R5's admin summary: gains newLeads
|
unchanged | T-R11.6 |
Login, Google sign-in, refresh, logout, R5's set-password completion: a second Set-Cookie
|
unchanged | T-R11.1 |
Order of work. The hint cookie (T-R11.1) and the leads backend (T-R11.6) come first, and the locale split (T-R11.12a) early, because every later landing string lands in its file. The root route (T-R11.3) needs T-R11.1. The landing sections, the form and the pages follow. The apex infrastructure (T-R11.2) is prepared in parallel and switched on at release. The hardening tasks (T-R11.10βT-R11.13) run once the product is feature-complete. At most four agents at a time.
Decisions taken in this file, worth a second look.
-
The apex serves the same SPA as
app.quizzivy.com, and differs only at/. On the host named byVITE_LANDING_HOST(quizzivy.com),/is the Landing. On the app host,/keeps today's redirect to the console or to/login. Every other route is identical on both. The Landing's links (/login,/join/:code,/privacy) therefore stay on the apex, and a student who opens a join QR finishes signing in and enrolling on the origin they started on. -
A signed-in visitor on the apex stays on the apex. Bouncing them to
app.quizzivy.comwould drop the in-memory access token mid-flow and split the join journey across two origins.localStorageis per origin, so theme, language and local answer drafts are kept separately on each; drafts are backed by server autosave, so nothing is lost. Console pages carryX-Robots-Tag: noindexon both hosts. -
"Signed in?" comes from a non-secret hint cookie, never from a refresh probe. The API sets
quizzivy_signed_in=1; Domain=quizzivy.com; Path=/; Secure; SameSite=Lax(notHttpOnly, holding no identity), with the refresh cookie's lifetime, whenever it sets the refresh cookie. It clears the hint on logout and on every refused refresh. The Landing calls/auth/refreshonly when the hint is present. Probing on every visit would wake Neon for every anonymous visitor and crawler, and rotate tokens against open app tabs. AlocalStorageflag cannot work, because it is per origin. The hint is a performance hint, never an authorisation signal: the refresh cookie still decides. -
The cross-origin refresh race is the residual R-06 already accepts. Two tabs on different
origins refreshing at the same moment can trip reuse detection, exactly as two tabs on one
origin can today. The hint keeps anonymous visits off
/auth/refresh, so the apex adds no new race for people who are not signed in. A server grace window would be a Β§5 change and is not made here. -
An anonymous Landing makes no API call at all. The contact block (phone, email, address)
reads build-time
VITE_ORG_*values, not R5's organization settings: R1'sVITE_ORG_NAMEandVITE_ORG_FRONT_DESK_PHONE, andVITE_ORG_EMAILandVITE_ORG_ADDRESS, which T-R11.4b adds. R5 keeps these build values as the Landing's source and the front door's fallback. A public read of those settings would reach the database on every visit, and a crawler would keep Neon awake. The cost is that editing the contact details in Admin settings reaches the Landing at the next build (O-R11.4). The consultation submit and the/statuspage are the only calls, each on an explicit action. - The app host keeps probing the session without a hint. Users signed in before v1.0 hold a refresh cookie but no hint until their next refresh. Gating the app host on the hint would sign them all out. Only the Landing route is gated.
-
Join links and QR codes move to the apex; nothing else does.
VITE_PUBLIC_ORIGIN(https://quizzivy.com) builds the class link and the QR payload. Oldapp.quizzivy.com/join/β¦links and printed QR codes keep working, because the route exists on both hosts. Email links (R5's set-password link, R7's notifications) stay on the app host, where the consoles have always lived. -
Testimonials render only with recorded consent, and never as placeholders. DG-100's interim
is "placeholder testimonials until consent". On a production page, a quote with a name that
nobody said is a fabricated review. The section reads a static list whose entries each carry a
consentRecordedAt, and it is hidden while the list is empty (O-R11.2). -
Marketing copy states what shipped. The deck's copy predates several releases. Features says
"Five question types", where R6 shipped more. FAQ 1 says students "create an account", but only
Google sign-in creates one. FAQ 5 describes a reset flow that R5 changed. The product mock's
address pill reads
quizzivy.com/adminfor a teacher dashboard. Each line is rewritten to match the product (vi first), and the PR lists every sentence that departs from the deck. -
A lead submission always answers
202 {received: true}. Nothing is echoed. Validation names only the field. Per-IP limits answer 429. Two cases are dropped silently and still answer 202: a honeypot field that was filled, and a phone number with three requests in the last 24 hours. A 429 there would tell anyone that a number had been used. There is no captcha: it would add a third party and bot detection to a form with a few requests a day. -
Consent is part of the contract. The request must carry
consent: trueand aprivacyNoticeVersionfrom an enum (the first is2026-10), so the schema refuses anything else. Both are stored with the lead. Changing the notice adds an enum value, and the stored version says what each person agreed to. -
The notification email carries no phone number. It gives the first name, the interests, who
the course is for, the best time to call, and a link to
/admin/leads/{id}, which needs an admin session. The person agreed to a call back, not to their number sitting in several inboxes (the same rule as R5's data-export link and D15). - A lead stores no IP address or user agent. The rate limiter already holds the IP, in memory only. The lead holds what the form asked for, the consent, and its handling. The retention sweep deletes leads after 12 months (D18), and an admin can delete one on request.
-
Consultation requests sit under the Admin console's People group, as "Consultation
requests", behind the hidden
system.leadskey (70 Β§4.1), with a count badge of new requests like the Users badge. DG-57 names no place for them. People is where the Admin console lists the other humans it tracks. -
Locale files split by surface, keys unchanged. Today both locales (about 260 KB of JSON)
sit in the entry chunk. That alone would break any honest Landing budget. The files split by
top-level key into
common,auth,landing,student,teacherandadmin.commonstays in the entry, and each tree loads its surface for the active language before rendering, merged into the onetranslationnamespace. Not()call site changes, and the copy-pinned tests load every surface in setup. The split is its own mechanical PR. -
Budgets are measured, then pinned; the Landing's has a ceiling. Each route's budget is its
R11 measurement plus 10%, in
web/bundle-budgets.json. Raising a budget is a reviewed change to that file. The Landing may not exceed 200 KB gzip whatever the measurement, because it is the page a stranger opens cold on a phone. -
The rate-limit suite is a Go end-to-end test over the contract, not a Playwright spec.
Limits are server behaviour. The suite reads
api/openapi.yaml, so a new open operation without a case fails the build. The web's 429 states are unit-tested with the screens that show them. -
/statusis a real page. The deck's footer links "Status" to the maintenance frame (DG-100)./statusreadsGET /public/status(R1) when visited, and says "All systems normal", shows the scheduled window, or shows R1's maintenance content.
Depends on: β
Touches: server/internal/modules/identity/http/{cookie.go,auth.go,google.go}, R5's set-password completion handler, server/internal/platform/config/config.go, server/internal/platform/config/tests/, server/internal/modules/identity/http/tests/cookie_test.go, api/openapi.yaml (the Set-Cookie descriptions on login, Google sign-in, refresh, logout and set-password), generated code, .env.example, fly.toml
Size: S
Done when:
- Config
SESSION_HINT_COOKIE_DOMAIN: a bare domain (no scheme, port or path; startup refuses anything else). Empty means a host-only cookie, which is what local development onlocalhostneeds..env.exampledocuments it, andfly.tomlsetsquizzivy.com. -
sessionHintCookiebuildsquizzivy_signed_in=1withPath=/, the configuredDomain,SecurefromREFRESH_COOKIE_SECURE,SameSite=Lax, noHttpOnly, andMax-Ageequal to the refresh TTL.clearSessionHintCookiesets the same attributes withMax-Age=-1. - Every response that sets the refresh cookie also sets the hint: login, Google sign-in, refresh, and R5's set-password completion when it signs the user in. Logout clears both. Every refused refresh (401) clears the hint.
- Two cookies need two
Set-Cookieheaders, which the generated single-header response types cannot write. Those responses use visitors that add both, asclearedSessiondoes for logout today. - Tests: the attributes; set on each path; cleared on logout and on each refusal outcome (unknown, expired, reused, revoked, disabled); host-only when the domain is empty; config refusal.
-
client.refresh.test.tsis untouched. No web code reads the hint before T-R11.3.
Depends on: T-R11.1
Touches: fly.toml (CORS_ALLOWED_ORIGINS, GOOGLE_REDIRECT_URI, SESSION_HINT_COOKIE_DOMAIN), server/internal/platform/httpx/tests/cors_test.go, web/public/{_headers,robots.txt (new),sitemap.xml (new)}, .github/workflows/deploy.yml (VITE_LANDING_HOST, VITE_PUBLIC_ORIGIN), .env.example, scripts/{verify-deployment.mjs,verify-google.sh}, web/playwright.config.ts (the live project ignores the apex spec), web/tests/support/apex/playwright.config.ts (new, the live-apex project), web/vite.config.ts (preview.allowedHosts), web/package.json (e2e:live-apex), .gitignore (dist-apex/), .github/workflows/ci.yml (the e2e-live job's second API and apex step), web/tests/e2e/apex.live.spec.ts (new), docs/setup/dns.md, docs/plan/00-overview.md Β§4.1, docs/plan/30-risks.md (R-07)
Size: M
Done when:
-
CORS_ALLOWED_ORIGINSis exactlyhttps://app.quizzivy.com,https://quizzivy.com: credentialed and exact-origin, ashttpx.CORSalready does.cors_test.gocovers both origins, preflights forPATCHandDELETE, and refuseshttps://www.quizzivy.comandhttp://quizzivy.com. -
GOOGLE_REDIRECT_URIlistshttps://app.quizzivy.com/auth/google/callbackandhttps://quizzivy.com/auth/google/callback.loadGooglealready splits the list, and exchange-time matching stays exact.verify-google.shchecks both against T-R0.8's Google console entry. - Cloudflare (Thuong), documented in
docs/setup/dns.md: -quizzivy.comadded as a custom domain of the existing Pages project -www.quizzivy.com301-redirected to the apex by a Redirect Rule - both proxied -
_headers: -https://app.quizzivy.com/*sendsX-Robots-Tag: noindex- the console, auth and join paths (/app/*,/teacher/*,/admin/*,/login,/auth/*,/join/*,/set-password,/change-password) send it on every host - the CSP is unchanged:connect-srcalready nameshttps://api.quizzivy.com -
robots.txtallows/and/privacyand disallows the console trees.sitemap.xmllistshttps://quizzivy.com/andhttps://quizzivy.com/privacy. -
verify-deployment.mjssmokes both origins: the SPA shell at/,/login, and a credentialed preflight to the API from each origin. -
apex.live.spec.tsruns in its own Playwright project,live-apex, never inlive. The existing build bakes inVITE_API_BASE_URL=http://localhost:8080and the existing API allows onlyhttp://localhost:4173, so the apex cannot share them without breaking the localhost live specs. Likee2e:content, it has its own config,web/tests/support/apex/playwright.config.ts: - its ownwebServer: a separate build (--outDir dist-apex) withVITE_API_BASE_URL=http://api.quizzivy.test:8081,VITE_LANDING_HOST=quizzivy.testandVITE_PUBLIC_ORIGIN=http://quizzivy.test:4174, previewed on port 4174 - Chromium with--host-resolver-rules="MAP quizzivy.test 127.0.0.1, MAP *.quizzivy.test 127.0.0.1"(.testnames, as AGENTS allows); the preview acceptsquizzivy.testandapp.quizzivy.test(Vitepreview.allowedHosts) -web/playwright.config.ts'sliveproject gainstestIgnoreforapex.live.spec.ts-pnpm e2e:live-apexruns it - A second API process serves the apex on
:8081, from the same binary and database, withAPI_PORT=8081,CORS_ALLOWED_ORIGINS=http://quizzivy.test:4174,http://app.quizzivy.test:4174,SESSION_HINT_COOKIE_DOMAIN=quizzivy.testandREFRESH_COOKIE_SECURE=false. Inci.yml'se2e-livejob, a step starts it after the first API, waits for its/healthz, and runspnpm e2e:live-apexafterpnpm e2e:live; the failure log prints both APIs' output. The:8080API and its env are unchanged. - The spec needs a reason under AGENTS' live rule, and has one: the claim is that the two
origins and the API meet. Cases:
- an anonymous apex
/shows the Landing with no API request - sign in on the app host, then open the apex/: the console opens on the apex - sign out on the apex, then open the apex/: the Landing, with the hint gone - the Landing's join box leads to/join/:codeon the apex, where sign-in and enrolment finish -
00-overview.mdΒ§4.1 describes both origins, the hint cookie and why the refresh cookie stays host-only. R-07's checklist is re-run on both origins, with the result in the PR.
Depends on: T-R11.1, T-R11.12a
Touches: web/src/app/RootRoute.tsx (new), web/src/app/router.tsx, web/src/app/guards/HomeRedirect.tsx, web/src/app/boot/ (R1), web/src/lib/session/hint.ts (new), web/src/features/landing/pages/LandingPage.tsx (new, frame only), web/tests/units/app/root-route.test.tsx (new), web/tests/e2e/landing.spec.ts (new), web/playwright.config.ts (the stubbed build's VITE_LANDING_HOST and the chromium project's host-resolver rule), web/vite.config.ts (preview.allowedHosts), web/tests/integration/router-chunks.test.ts
Size: S
Done when:
-
hint.tsreadsquizzivy_signed_infromdocument.cookie, inside try/catch. -
RootRouteat/, whenlocation.hostname === import.meta.env.VITE_LANDING_HOST: - hint absent: the lazy Landing, and R1's boot skips its session probe for this route - hint present: the boot's single-flight refresh (the same client promise); on success,navigate(homePathFor(principal), { replace: true }); on 401, the Landing (the API has cleared the hint)On any other host, `/` behaves as `HomeRedirect` does today. -
router-chunks.test.tsgainsLANDING = /\/features\/landing\//: - landing modules are out of the entry chunk - the landing chunk graph holds no teacher, admin, student or focus-layout module, and no@tiptap,prosemirror,parse5or@dnd-kitmodule - every existing assertion is kept -
landing.spec.ts(stubbed, chromium): the one stubbed build (web/playwright.config.ts'swebServerenv) setsVITE_LANDING_HOST=quizzivy.test, thechromiumproject mapsquizzivy.testto 127.0.0.1 with--host-resolver-rules, and the preview accepts that host. An anonymous visit tohttp://quizzivy.test:4173/issues no request to the API origin (page.on("request")), and with a stubbed hint and refresh it lands on/teacher. Other specs keeplocalhost, where/is unchanged, and the build'sVITE_API_BASE_URLis unchanged. -
Unit tests: each branch of
RootRoute, and the boot skipping its probe only on the Landing.
Depends on: T-R11.3
Touches: web/src/features/landing/ (pages/LandingPage.tsx, components/{LandingHeader,MobileMenu,Hero,JoinBlock,ProductMocks,Features,Roles}.tsx), web/src/index.css (landing type tokens), web/tests/units/styles/tokens.test.ts, web/src/lib/i18n/locales/{vi,en}/landing.json, web/tests/units/landing/ (new)
Reuses: R1's segmented control, theme preference, sheet, Brand; features/join/code.ts
Size: M
Done when:
- Frame: base type 15/1.55, a 1200px container, breakpoints under 720 (mobile) and from 960
(wide), and the h1 step at 1100. The Landing sizes R1 deferred join
@themeas tokens (h1 38/48/58, h2 28/38), pinned by the token test. Smooth scrolling applies only without reduced motion. - Header, sticky and 64px:
- the logo (colour or on-dark by theme) linking to
#top- from 960, the anchors Features, For you, Fair tests, FAQ - EN/VI through R1'ssetLocale, with Vietnamese the default (DG-100) - the theme toggle on R1's persisted preference - "Sign in" (outline, from 720) to/login- "Book a consultation" (primary) to#consult - Below 960, a menu button opens a sheet with the anchors and Sign in: focus trapped, Esc and any link close it (DG-100, extrapolated and listed).
- Hero:
- the eyebrow pill with a static lime dot, the h1 and the subtitle
- the join block: label; a 48px monospace upper-case input normalised by
code.tsto the Β§6 alphabet, capped at 8 and dashed after 4; the placeholderK7QM-2PXA(DG-02) - at 8 characters, the success border and the enabled "Join class", which opens/join/K7QM-2PXA(the deck drops the code; we pass it) - an incomplete submit shows "Enter all 8 characters." (DG-02's invalid state, extrapolated) - the helper line with Sign in and "book a free consultation" - Product mocks:
aria-hidden, drawn from tokens, keeping a light paper surface in dark mode (DG-35); the address pill readsquizzivy.com/teacher. - Features: six cards on the
sidebarband, with copy true to the product (this file's decision). - Roles: tabs Teachers, Students and Centre admins, following the WAI-ARIA tabs pattern
(arrow keys,
aria-selected), each panel as the deck draws it. - Unit tests: the join normalisation and route, the menu's focus and Esc, the tabs' keyboard, the language switch, the theme toggle persisting.
- Keys are vi first, with en from the deck where the copy is still true, all in
landing.json.
Depends on: T-R11.4a
Touches: web/src/features/landing/components/{Integrity,Testimonials,Faq,LandingFooter}.tsx, web/src/features/landing/content/testimonials.ts (new), web/src/features/landing/useLandingMeta.ts (new), web/index.html, web/public/og/quizzivy-og.png (new, 1200Γ630 from docs/design/brand/png/quizzivy-logo-horizontal-color-2400.png), web/src/lib/config.ts (VITE_ORG_EMAIL, VITE_ORG_ADDRESS), .env.example, .github/workflows/deploy.yml (repository vars), web/src/lib/i18n/locales/{vi,en}/landing.json, web/tests/units/landing/
Size: M
Done when:
-
lib/config.tsgainsorgEmail(VITE_ORG_EMAIL) andorgAddress(VITE_ORG_ADDRESS) beside R1'sorgNameandfrontDesk. The Landing's phone is R1's front-desk phone (VITE_ORG_FRONT_DESK_PHONE)..env.exampledocuments the two new values as public,deploy.ymlpasses them from repositoryvars, and an unset value hides its line (DG-41). - Integrity: the inverted band (
--primarybackground,--primary-fgtext), the eyebrow in lime, the h2, the subtitle, and the four cards (fullscreen and focus; no copy and paste; "Flags, not accusations"; a full timeline), worded per D8. - Testimonials: rendered from
testimonials.ts, whose entries each carryquote {vi, en},name,role {vi, en}andconsentRecordedAt. The section is hidden while no entry has consent (this file's decision). Initials follow DG-22. - FAQ: an 820px column and a single-open accordion with the first item open. Each question is
a button with
aria-expandedandaria-controls. Answers 1, 4 and 5 are rewritten to match the product (Google creates an account; the centre controls Google sign-in; R5's reset flow). - Footer:
- the logo and the about text
- Product (anchors), Get started (
/login,/join,#consult), and Help (/status,#faq, themailto:fromVITE_ORG_EMAIL) - a "Privacy" link to/privacy, which the deck lacks (D18, listed for DG-06) - the bottom bar with "Β© {year} Quizzivy" and the address fromVITE_ORG_ADDRESS- every line whose value is unset is hidden - Meta:
-
index.htmlkeeps product-level<title>and description for both hosts, and gains static Open Graph tags (og:title,og:description,og:image,og:url https://quizzivy.com/), since most scrapers do not run JavaScript -useLandingMetasetsdocument.title, the description,<link rel="canonical">andhtml[lang]per language while the Landing is mounted, and removes the canonical link on unmount - Unit tests: testimonials hidden without consent and shown with it; FAQ keyboard and single-open; footer lines hidden when unset; canonical link lifecycle.
Depends on: T-R11.2
Touches: web/src/features/join/code.ts, R4's class detail join-code card and QR dialog (web/src/features/classes/), R5's Admin classes join-code column and dialog, .env.example, .github/workflows/deploy.yml, web/tests/units/join/code.test.ts, web/tests/units/classes/
Size: S
Done when:
-
publicJoinUrl(code)returns${VITE_PUBLIC_ORIGIN}/join/${formatted}, falling back towindow.location.originwhen the variable is unset (development). It is the only builder of join links. A unit test grepsweb/srcfor another"/join/"concatenation. - Copy link, the QR payload and the QR download (R4's "for slides or Zalo") use it, in the Teacher and Admin consoles.
- Existing
app.quizzivy.com/join/β¦links keep resolving.join.spec.tscovers an old-host link. - Release notes say that new links use
quizzivy.comand that printed codes and old links still work.
Depends on: R5's email outbox, organization settings, retention sweep and data export
Touches: migrations/NNNNN_create_leads.sql, migrations/NNNNN_add_organization_lead_recipients.sql (both new), server/internal/modules/leads/ (new: domain/{lead,commands,errors,repository}.go, application/{app.go,command/{submit,update,delete}.go,query/{list,get}.go}, repositories/leads.go, http/{public,admin}.go), server/internal/core/wiring/leads.go (new), server/internal/core/router/{modules.go,server.go,ratelimits.go}, server/internal/core/router/tests/{auth_middleware_test.go,public_routes_test.go}, api/openapi.yaml (tags public, admin-leads), generated code, server/internal/platform/email/templates/{vi,en}/lead_received.tmpl (new), R5's retention sweep (server/internal/core/maintenance/{retention.go,tests/retention_test.go}), R5's organization settings and admin summary, R5's data export, server/internal/modules/audit/domain/{actions,categories}.go, web/src/lib/i18n/locales/{vi,en}/admin.json (audit.action.lead.*; {vi,en}.json if T-R11.12a has not landed, whose split puts them in admin.json), server/internal/platform/db/tests/{constraints,privileges}_test.go, docs/plan/20-data-model.md
Size: L
Done when:
-
create_leads,app.leads: -id uuid PK DEFAULT uuidv7()-full_name text(1β120, trimmed),phone_digits text CHECK (phone_digits ~ '^[0-9]{9,15}$')-interests text[] NOT NULL DEFAULT '{}'with a CHECK that it is contained in{ielts, toeic, communication, kids, business}-course_for text CHECK IN ('self','child','company'),call_time text CHECK IN ('any','morning','afternoon','evening'),locale text CHECK IN ('vi','en')-privacy_notice_version text NOT NULL,consented_at timestamptz NOT NULL-status text NOT NULL DEFAULT 'new' CHECK IN ('new','contacted','closed'),note text(β€ 1,000),handled_byFK usersSET NULL,handled_at-created_at,updated_at(trigger) - indexes(created_at DESC),(status, created_at DESC)and(phone_digits, created_at DESC)The app role gets `SELECT, INSERT, UPDATE, DELETE`. The maintenance role gets `SELECT, DELETE`: the sweep's `DELETE β¦ WHERE created_at < $1` reads the rows it deletes, so `DELETE` alone fails with 42501. This is R11's grant for the widened sweep (70 D16), and `privileges_test.go` pins both roles' grants on `leads`. Down drops the table. -
add_organization_lead_recipientsaddslead_recipients text[] NOT NULL DEFAULT '{}'withCHECK (cardinality(lead_recipients) <= 5)to R5's settings singleton. The constant default needs no fill trigger. Down drops the column. -
POST /public/leads(submitLead, tagpublic,security: [], nox-permission,x-max-body-bytes: 4096): - request:fullName(1β120),phone(^[0-9 +().-]{9,20}$),interests(unique enum items, β€ 5),courseFor,callTime,locale,consent(enum: [true]),privacyNoticeVersion(enum: ["2026-10"]), andwebsite(β€ 200, the honeypot) - response:202 {received: true}; validation errors name only fields - the handler normalises the phone to digits: 9β15 digits, else a field error; this rule is one the schema cannot express - a filled honeypot, or a third lead for the same digits within 24 hours, answers 202 without storing, and logslead_droppedwith the reason and no personal data - otherwise, one transaction inserts the lead and enqueues onelead_receivedemail per configured recipient (R5's outboxtemplateCHECK allows only[a-z_]) -
RateLimits()addsPOST /public/leadsat 3 a minute and 10 an hour per IP.AssertPublicRoutesLimitedpasses. The open-operation pin inauth_middleware_test.gogainssubmitLead(POST /public/leads), the thirteenth (70 Β§4.2: six at v0.6.0, seven after R1, eleven after R5, twelve after R7), with its one-line justification (the list's name follows its count). -
Leak review in the PR: the response is constant; the dropped paths run the same phone lookup and differ only by the skipped insert; no error reveals a known number, a recipient or the honeypot.
-
Email
lead_received(templates/{vi,en}/lead_received.tmpl), vi and en (the recipient's locale, else the organisation default): - subject "New consultation request Β· {first name}" - the interests, who the course is for, the best time to call and the received time - a link tohttps://app.quizzivy.com/admin/leads/{id}- never the phone number or the full name; a test asserts the outbox payload holds neither - no recipients means no email -
Admin operations (
system.leads): -GET /admin/leads?status=&q=&page=&limit=β{items: [{id, fullName, phone, interests, courseFor, callTime, locale, status, note, createdAt, handledBy: {id, name} | null, handledAt}], page, total};qmatches the name (accent-insensitive throughapp.immutable_unaccent(lower(β¦))) or the phone digits -GET /admin/leads/{id}-PATCH /admin/leads/{id} {status?, note?}, which setshandled_byandhandled_aton a status change -DELETE /admin/leads/{id}- updates and deletes are audited through the data-modifying CTE pattern (lead.updated,lead.deleted). The update's diff is{status: {old, new}, noteChanged: true}only, and the delete's holds the lead id only: never the note, the name or the phone, because an append-only row outlives the lead's 12 months (D16, D18) - addaudit.action.lead.updatedandaudit.action.lead.deletedkeys (vi first) inadmin.json, list both inaudit/domain/actions.go, and map the entityleadinaudit/domain/categories.goas an explicit All-only entry, like the organization and retention entries (leads are not accounts) -
R5's organization settings accept
leadRecipients(β€ 5,format: email). R5's admin summary gainsnewLeads, null withoutsystem.leads. -
Retention: R5's
retention-sweepgains a step that deletes leads created more than 12 months ago, asquizzivy_maintenance, and counts them in its System audit row and its dry run. The 12 months is D18's, not a setting. Scheduled runs apply only whileRETENTION_SWEEP_ENABLEDistrue(70 D16), so the release checklist verifies it (O-R11.5). R5's data export includesleads, formula-escaped. -
Tests: - unit: phone normalisation, honeypot, the repeat cap - integration: insert and
lead_receivedoutbox rows; no phone in the payload; the sweep, run asquizzivy_maintenance, deletes at 12 months and keeps younger leads; PATCH handling fields; DELETE audited; no audit row holds the note, name or phone - contract:consent: falseand an unknown notice version refused - access: a teacher gets 403 on every admin operation, and a custom role cannot be grantedsystem.leads(R2's hidden-key rule) -
20-data-model.mdgainsleadsand a deviation row for personal data held outsideusers, with its consent and retention.
Depends on: T-R11.6, T-R11.4a
Touches: web/src/features/landing/components/{Consultation,ConsultationForm,InterestChips,ConsultationSent}.tsx, web/src/features/landing/{api.ts,input.ts}, web/src/lib/i18n/locales/{vi,en}/landing.json, web/tests/units/landing/consultation.test.tsx (new)
Size: M
Done when:
- The
#consultband has two columns from 960. - Left: the eyebrow, h2 and subtitle; the contacts: phone (VITE_ORG_FRONT_DESK_PHONE), email (VITE_ORG_EMAIL) and address (VITE_ORG_ADDRESS), each hidden when unset. - Right: the form card. - Fields, as the deck draws them:
- Full name and Phone, side by side when there is room
- Interested in: chips as toggle buttons with
aria-pressed- The course is for: Myself, My child or My company (segmented, a radiogroup) - Best time to call: R1's select - A consent checkbox, unchecked, above the submit (DG-06, extrapolated and listed): "I agree
that {org} may use my name and phone number to call me back about courses, as described in
the privacy notice." The notice link opens
/privacyin a new tab (rel="noopener"). The deck's privacy line stays under the button. - The honeypot
websitefield is off-screen,tabindex="-1",autocomplete="off"andaria-hidden. - Validation as the deck: nothing until the first submit, then live. One error at a time, in a
role="alert"line: the name, then the phone (9 digits or more), then consent ("Please agree to be contacted before sending."). The invalid inputs get the danger border. - States (DG-101, extrapolated and listed): - submitting: the button disabled, reading "Sendingβ¦" - sent: "Thanks, we got it", "An adviser will call {name} within one working day.", and "Send another request", which resets everything - 429: "Too many requests from this connection. Try again in {n} minutes." - network or server error: "We couldn't send your request. Please try again, or call {phone}."
- The zod input carries
Expect<Equal<β¦>>against the generated request type, andprivacyNoticeVersionis taken from the generated enum. - Unit tests: the validation order; consent required; the payload, including a trimmed name and the chips; each state; the honeypot never rendered to assistive technology.
Depends on: T-R11.4b, T-R11.12a
Touches: web/src/features/landing/pages/PrivacyPage.tsx (new), web/src/features/landing/components/LandingLayout.tsx (new, the Landing's header and footer as a layout), web/src/app/pages/StatusPage.tsx (new), web/src/app/router.tsx, web/src/lib/i18n/locales/{vi,en}/{landing,common}.json, web/tests/units/landing/privacy.test.tsx, web/tests/units/errors/status.test.tsx (new)
Size: S
Done when:
-
/privacy: public, lazy (the landing chunk), on both hosts, insideLandingLayout, a 720px column, printable. Version2026-10and its date are shown. Vi is written first; Thuong reviews the text (O-R11.1). It covers: - who is responsible (the organisation's name, address and email fromVITE_ORG_NAME,VITE_ORG_ADDRESSandVITE_ORG_EMAIL) - what the form collects, and why: a call back about courses and a placement test - the legal basis: consent, under Decree 13/2023/ND-CP - who handles it: the centre's advisers and admins, and the processors (Fly.io, Neon, Cloudflare, Resend), with where they store it - retention: 12 months, then automatic deletion - rights: to access, correct or delete, and to withdraw consent, through the contact email - that nothing is collected about a child -
/status: public, onSystemFrame, readingGET /public/statuson mount and on "Check again": -maintenance: nullshows "All systems normal" (circle-check, success) - a scheduled window shows its times and "Tests that close during the update are extended." - an active window shows R1's maintenance content - an error shows "We couldn't check the status." with retryIt is the only page reachable from the Landing that calls the API, and only when visited. -
Unit tests: the version shown; the org lines hidden when unset; each status branch.
Depends on: T-R11.6, R5's AdminLayout
Touches: web/src/features/leads/ (new: pages/admin/LeadsPage.tsx, components/{LeadSheet,LeadStatusBadge}.tsx, api.ts), R5's admin nav definition, web/src/app/router.tsx, R5's Organization settings form, web/src/lib/i18n/locales/{vi,en}/admin.json, web/tests/units/leads/ (new)
Reuses: R5's Users table, card list, user sheet and badges; DataTable, Pager, SearchInput, ConfirmDialog
Size: M
Done when:
- Route
/admin/leads, lazy. The People group gains "Consultation requests" (phone-incoming) withsystem.leads, and a warning-soft badge counting new requests. The nav test passes. - "Consultation requests" / "People who asked us to call them back." Tabs New, Contacted, Closed and All, and a search.
- Columns: Name, Phone (a
tel:link), Interested in (chips), For, Best time, Received (relative, exact on hover), Status. Below 560 it becomes a card list. - A row opens a sheet with every field, the consent version and time, Status (New | Contacted | Closed), Note, and Save. Delete confirms "Delete this request? Use this when the person asks us to remove their details."
- Organization settings gains "New consultation requests go to" (up to five addresses), with the hint "They get an email without the phone number, and a link to this page."
- States (DG-83): skeleton; "No consultation requests yet."; error with retry.
- Built from deck parts and listed for DG-57 sign-off. Compared with the Admin Users patterns at the five widths, light and dark. Keys vi first and listed.
- Unit tests: tabs and search in the URL, the badge, the sheet's save, the delete confirm, the recipients field's cap.
Depends on: T-R11.6
Touches: server/tests/ratelimit_flow_test.go (new, build tag e2e), server/tests/harness_test.go, server/internal/platform/ratelimit/tests/limiter_test.go, server/internal/core/router/tests/public_routes_test.go, docs/plan/50-nfr-registry.md, docs/plan/15-phase-5.md
Size: M
Done when:
- The suite reads
api/openapi.yamland walks every open operation and every operation inRateLimits(): the credential-minting ones (resets, bulk reset, user creation and import, the docs session) and the heavy reads (R9's gradebook export). From one client IP (sent inCLIENT_IP_HEADER, as production does), it drives each past each window and asserts 429, withRetry-Afterbetween 1 and the window, and admission again after the window (a fake clock). - Keyed limits:
- per join code across several IPs (
/join/preview, Google join, the in-app join) - per email on login - R2's NAT budget: 30 students joining one class from one IP all succeed, while probing other codes from that IP is limited - An open operation missing from the registry fails the suite, beside the startup assertion.
- The limiter's memory is bounded: 50,000 distinct IPs leave the LRU at its capacity (unit test).
- The leak review of every public response body is recorded in the PR:
-
/join/preview: class name, teacher display name, schedule and room, never counts (R8) - the join failure modes cannot be told apart - login has one failure shape - refresh has its documented codes -/public/statusholds only the maintenance window - R5's set-password: a used, expired or unknown token look the same -/public/leadsanswers a constant 202 - NFR-S05 and NFR-S06 flip to β
.
15-phase-5.mdmarks T-5.2 as done in R11.
Depends on: every screen task of R1βR11
Touches: web/package.json (dev dependency @axe-core/playwright; reason: an automated WCAG scan of built routes in CI; nothing ships to users), web/tests/e2e/{a11y,keyboard}.spec.ts (new), web/src/** (fixes), docs/plan/50-nfr-registry.md, docs/plan/15-phase-5.md
Size: L
Done when:
-
a11y.spec.ts(stubbed, chromium) runs axe with the WCAG 2.2 A and AA rules and fails on serious or critical findings. Routes: - the Landing (vi and en, light and dark, 360 and 1280),/privacy,/status-/login,/join/:code,/change-password, and the 404, 403, error and maintenance pages -/app,/app/classes,/app/learn, the flashcards session,/app/grades, the intro, the engine, the result -/teacher, the wizard,/teacher/grading, the builder,/teacher/gradebook, a course and a word list -/admin,/admin/users,/admin/roles,/admin/leads -
keyboard.spec.tscovers: - a full attempt by keyboard: answers, the audio player, fill-in blanks, flag, review, submit - a flashcards session (Space, then 1β4) - the Landing's menu, tabs, FAQ and form - moving through the gradebook's cells with their tooltips on focus - A visible focus ring on every interactive element (R1's
--focus, DG-30); icon-only buttons labelled; decorative iconsaria-hidden; colour never the only signal (bands, status chips, flags, bars). - Reduced motion is honoured by the marquee, the live dot, the flip card, smooth scrolling and the drawer.
- Lighthouse accessibility of 95 or more, recorded in the PR (a manual run), on
/,/login,/join/:code,/app, the engine, the result,/teacherand/admin. - Small fixes land in this task. Larger ones become issues, and any serious or critical finding blocks the release.
-
15-phase-5.mdmarks T-5.3 as done in R11, and the NFR accessibility entries are updated.
Depends on: β
Touches: web/src/lib/i18n/locales/{vi,en}.json β web/src/lib/i18n/locales/{vi,en}/{common,auth,landing,student,teacher,admin}.json, web/src/lib/i18n/{index.ts,loadSurface.ts (new)}, web/src/app/router.tsx (each tree's loader), web/tests/support/setup.ts, web/tests/support/locales.ts (new), web/tests/units/i18n/parity.test.ts, web/tests/units/i18n/surfaces.test.ts (new), web/tests/units/imports/i18n.test.ts, web/tests/units/question-bank/structure-validation.test.ts, server/internal/modules/audit/domain/tests/catalog_test.go
Size: M
Done when:
- Two commits. First the file split, by top-level key, mechanical and nothing else. Then the loader.
- Every direct reader of
locales/{vi,en}.jsonmoves in the split commit, sopnpm typecheck,pnpm test:unitand the Go unit tests stay green: -web/tests/support/locales.tsmerges one language's surface files into one object -imports/i18n.test.tsandquestion-bank/structure-validation.test.tsimport that helper instead of@/lib/i18n/locales/{vi,en}.json- R5'scatalog_test.goreadsaudit.action.*fromlocales/{vi,en}/admin.json; theaudittree belongs to theadminsurface - a grep in the PR finds no other import or path naminglocales/vi.jsonoren.json -
commonis imported byindex.ts.loadSurface(surface)fetches the active language's file and merges it withaddResourceBundle(lng, "translation", data, true, true). Each tree'slazyawaits its surface before rendering: auth, landing, student (withFocusLayout), teacher and admin. Switching language loads the other language's already-loaded surfaces first, then switches. -
surfaces.test.tsmaps every literalt("β¦")key undersrc/features/**,src/layouts/**andsrc/app/**to the surface of the tree that renders it, and fails when the key is in neither that surface norcommon. A feature shared by two trees usescommon. - The parity test runs per surface. The test setup loads every surface, so copy-pinned tests are untouched.
- Measured before and after: the entry chunk's size, in the PR.
Depends on: T-R11.12a, T-R11.4b
Touches: web/tests/integration/bundle-budget.test.ts (new), web/bundle-budgets.json (new), web/tests/integration/router-chunks.test.ts, web/vite.config.ts (only if a split is needed), docs/plan/50-nfr-registry.md, docs/plan/15-phase-5.md
Size: M
Done when:
- The test builds once, as
router-chunks.test.tsdoes, and sums the gzip size of the entry and the statically reachable chunks for each route:/(the Landing),/login,/join/:code,/app, the engine, the flashcards session,/teacher, the builder and/admin. - Budgets are the R11 measurement plus 10%, in
bundle-budgets.json. The Landing is capped at 200 KB gzip whatever the measurement. A regression fails the build. Raising a budget is a reviewed diff of the file, with its reason. - The Landing preloads at most two font files (Be Vietnam Pro 400, latin and vietnamese
subsets). R1's "no Google Fonts URL in
dist/" test stays. -
router-chunks.test.tskeeps its assertions: the anonymous graph has no console, rich-editor or@dnd-kitmodule; the student graph has no teacher or admin module;@dnd-kitis only in the builder and course-outline graphs. - The engine is measured once under a throttled mobile profile (Playwright over CDP: 4Γ CPU, "Fast 3G"). Time to interactive is recorded in the PR; it does not gate.
- NFR-E05 and NFR-E06 flip to β
.
15-phase-5.mdmarks T-5.4 as done in R11.
Depends on: T-R11.12a, every copy-bearing task of R11
Touches: web/scripts/i18n-review-sheet.mjs (new), web/src/lib/i18n/locales/vi/*.json, R5's and R7's vi email templates, the tests that pin changed strings, docs/plan/15-phase-5.md
Size: M
Done when:
- The script exports every key as CSV (surface, key, vi, en, and the files that use it, from a scan). Cells are formula-escaped with the same rule as the product's exports.
- A native speaker (Thuong, or someone he names) reviews the sheet. Priority: - join, the integrity dialogs (D8's tone), the test intro and the result - the Landing, the consent text and the privacy notice - the email templates - flashcards and courses - the Admin console's permission labels
- The changes land in one copy-only PR, with no behaviour. Tests that pin a changed vi string change in the same PR.
- The glossary terms (DG-20) are used consistently. The priority screens are checked at 360 and 768 with the reviewed strings: nothing clips, and no label has a fixed width (DG-21).
- vi and en have the same keys, and no user-facing string is English-only (the lint rule and the parity test).
-
15-phase-5.mdmarks T-5.8's language half as done in R11.
Depends on: every other R11 task
Touches: docs/quizzivy-spec-v0.3.md (Β§2, Β§4, Β§5, Β§6.5, Β§9, Β§12, Β§13, Β§15, Β§16; version bumped to 1.0 per Β§18), AGENTS.md, README.md, docs/plan/{00-overview,15-phase-5,20-data-model,30-risks,40-open-items,50-nfr-registry,70-redesign-overview}.md, docs/setup/{dns,email}.md
Size: M
Done when:
- Spec:
- Β§2 and Β§4: the two origins,
VITE_LANDING_HOST,VITE_PUBLIC_ORIGINand the hint cookie - Β§5: the hint is not an authentication signal - Β§6.5:/public/leadsand its limits - Β§9: the Landing,/privacyand/status- Β§12: the Landing's type scale and breakpoints - Β§13 and Β§15:leadsand its operations - Β§16: the phases as shipped - the version reads 1.0, and every "Availability: R" line is resolved - AGENTS.md:
- the "Redesign in progress" note is removed
- the open-operation count becomes thirteen, with
submitLeadnamed (70 Β§4.2) - the live E2E section names thelive-apexsuite, its second API on:8081and its.testhosts (T-R11.2) - the apex facts: the Landing makes no API call; the hint cookie never authorises; join links go throughpublicJoinUrl- the repository map gainsmodules/leadsandfeatures/{landing,leads}- the locale surfaces are described -
15-phase-5.mdis marked superseded, with its disposition: - T-5.1 lives in R3's engine - T-5.2 β T-R11.10; T-5.3 β T-R11.11; T-5.4 β T-R11.12b - T-5.5 β R9 (T-R9.10, T-R9.11) - T-5.6 and T-5.7 were covered by every release's five-width comparison and DG-83 states, and by T-R11.15's sweep - T-5.8 β T-R11.13 and this task -
30-risks.md: R-06's residual names the two origins; R-07 records the re-run; PR-1β¦PR-10 get their final state. -
40-open-items.mdis reconciled: every item is answered, deferred with a reason, or on the post-1.0 list. -
50-nfr-registry.mdis reconciled. NFR-P04 now says personal data stays minimal and that leads hold a phone number, with consent and 12-month retention. -
70-redesign-overview.mdis checked against what R11 built, and any disagreement is corrected there: - Β§4.2's open-operation list namessubmitLeadas the thirteenth - Β§8.1's step list gainspnpm e2e:live-apexbesidepnpm e2e:live- class staff (D14): if DG-04 is still open, v1.0 ships with Assistant hidden from role pickers and class staff on the post-1.0 list (Β§2 rule 9, Β§10), and40-open-items.mdsays so - the train is marked complete -
README.mdnames both origins.
Depends on: T-R11.4a β¦ T-R11.9
Touches: seed/NN-dev-leads.sql (new, development only, numbered at merge)
Size: M
Done when:
- The Landing is compared with
design-deckon 5175 at 360, 720, 768, 960, 1024, 1280 and 1440, in light and dark and in vi and en: the header at both breakpoints, the hero's two-column switch, the mocks, the bands, the FAQ and the footer grid. Sizes are measured withgetBoundingClientRect. -
/privacy,/statusand/admin/leadsare checked at the five widths, light and dark, against the deck parts they are built from. - The v1.0 sweep: every route of every console at 360 and 1280, in light, with seeded data. It checks for horizontal scroll, clipping, and the loading, empty and error states. Findings are fixed in their task's branch or filed as issues, and a serious one blocks the release.
- The dev server is confirmed fresh. Vite and the API are stopped afterwards.
- Every task is ticked on
work/redesign-r11.developis merged into it, and verification is re-run after that merge. - Every CI step runs locally, in order, judged by exit code (70 Β§8.1):
-
make lint,make gen-check-make test-api(afterdocker compose up -d db minio, includingratelimit_flow_test.go), and goose up/down/up -pnpm lint,pnpm typecheck,pnpm format:check-pnpm test:unit,pnpm test:integration(withbundle-budget.test.ts),pnpm build-pnpm e2e(withlanding.spec.ts,a11y.spec.tsandkeyboard.spec.ts),pnpm e2e:content-pnpm e2e:live, required because auth and join changed, thenpnpm e2e:live-apex(apex.live.spec.ts) with the second API on:8081(T-R11.2) -node scripts/check-design-deck.mjs - The perf job is green on the release commit.
- The five canaries pass, and their files are untouched against
develop. - Security:
- the isolation suite and the escalation tests pass
- E2E 9 passes with its key list unchanged
- the leak review of
/public/leadsand the full public review are in their PRs - the rate-limit suite passes, and the open-operation pin names the new operation - Both migrations are rehearsed on a Neon branch of production.
- Before the deploy (Thuong):
-
SESSION_HINT_COOKIE_DOMAIN, the secondGOOGLE_REDIRECT_URIand the new CORS origin are infly.toml- the Google console lists both redirect URIs (verify-google.sh) -VITE_LANDING_HOSTandVITE_PUBLIC_ORIGINare set for the Pages build - the repository variablesVITE_ORG_EMAILandVITE_ORG_ADDRESSare set, and R1'sVITE_ORG_NAMEandVITE_ORG_FRONT_DESK_PHONEstill are (the Landing's contacts and the privacy notice's controller details; an unset line is hidden) - the repository variableRETENTION_SWEEP_ENABLEDistrue, and the last scheduledretention-sweeprun applied: without it, D18's 12-month lead deletion never runs - the Resend domain still verifies - Deploy and smoke, in this order:
- Thuong's go, then
release/1.0.0βmain- deploy the API (the release command migrates), then Pages - attachquizzivy.comto the Pages project and add thewwwredirect (T-R11.2), then wait for the certificate - smoke/healthz,/livez, and sign-in as each role on both origins - an anonymoushttps://quizzivy.com/makes no API request (DevTools) - submit one consultation request, and check the email and the Admin list - open an oldapp.quizzivy.com/join/β¦link and a new apex link - set the lead recipients in Organization settings - back-merge todevelop, and tagv1.0.0 - Release notes, vi first:
- Quizzivy 1.0
- the Landing at
quizzivy.com- new join links and QR codes usequizzivy.com, and old links and printed codes still work - the consultation form, the privacy notice, and how long requests are kept - accessibility and speed work - where to set who receives consultation emails - T-R11.14's docs are in the release. Neon compute hours are measured for the week after (PR-7), to confirm that the Landing adds none.
-
O-R11.1 β Legal review of the privacy notice. The notice is written against Decree
13/2023/ND-CP, and the data is stored by processors outside Vietnam. Whether the centre needs a
cross-border transfer dossier, and the final wording, are Thuong's call with whoever advises the
centre. Default: ship the notice as drafted, versioned
2026-10, and revise it through a new version. - O-R11.2 β Testimonials. No quote ships without the person's recorded consent. Default: the section is hidden until Thuong supplies consented quotes.
-
O-R11.3 β A privacy notice for learner accounts. D18 covers leads. Accounts, attempts and
integrity events have their own story (NFR-P04, P05). Default:
/privacycovers the site and the form; a notice for accounts is on the post-1.0 list. -
O-R11.4 β Contact details on the Landing come from the build. Editing them in Organization
settings does not change the Landing until the next build. Default: accept; a static file
regenerated on save is a post-1.0 option that still keeps the Landing off the API. The
Landing's phone is R1's front-desk phone (
VITE_ORG_FRONT_DESK_PHONE); a separate sales number would be one moreVITE_ORG_*value. Default: the front-desk phone. -
O-R11.5 β Lead deletion depends on the sweep's switch. D18's 12-month deletion runs in R5's
retention-sweep, whose scheduled runs apply only whileRETENTION_SWEEP_ENABLEDistrue(D16). Pausing the sweep also pauses lead deletion. Default: keep D16's single switch, and the release checklist verifies it istrue. Running the lead step regardless of the switch would change D16 and is Thuong's call. - DG-100. The mobile menu, the vi default, the SEO text and the Status target are built from the interim rules and listed for sign-off. Testimonials depart from the interim, as decided above.
- DG-101. The form's submitting, error and 429 states are extrapolated.
- DG-06. The consent checkbox, its wording and the privacy page are drawn by us from deck parts.
- DG-57. The Admin consultation list and the recipients field are built from the Users page's parts.
- DG-02. The Landing's invalid-code hint is extrapolated. The placeholder uses the Β§6 alphabet.
Generated from docs/plan/81-r11.md. Edit that file in the repository; changes made on this page are overwritten.