Module Marketplace — Design Spec
Date: 2026-05-19 Owner: ssh Status: Approved — moving to implementation plan1. Goal
Replace the silent “addon request” flow with a first-class, app-store-quality Marketplace surface inside/dashboard/marketplace. Tenants browse priced add-ons, read release notes, request a subscription, get invoiced, pay, and the module activates after superadmin approval. The marketplace is the discovery + commerce front-end on top of the existing manual-approval lifecycle.
Non-goals (out of scope, do not implement):
- Self-serve / instant activation. The manual approval step is the cash gate.
- Per-tenant version pinning. Every clinic always runs latest.
- Public unauthenticated views. No
portal.odontox.ioexposure, no anonymous browsing, no SEO surface. - Plan-bundled module listings (Patients, Appointments, etc.). The catalog is addons only.
- Smart usage-threshold recommendation engine (G2 emails) — deferred to v2.
- Free trials on addons.
2. Lifecycle (authoritative)
A subscription request walks one ladder, end-to-end:| From | To | Trigger | Side-effects |
|---|---|---|---|
| (none) | requested | Tenant clicks Subscribe in marketplace | Audit + G5 superadmin alert (existing) |
requested | invoiced | Superadmin issues invoice via superadmin marketplace UI | G3 “Invoice ready” email to tenant |
invoiced | paid | Superadmin marks payment received (or future Stripe webhook) | Internal log only — no email here |
paid | active | Superadmin clicks Approve | clinic_modules row enabled/inserted, G4 “Live + quickstart” email |
active | cancel_requested | Tenant clicks Cancel in marketplace | Audit + superadmin alert |
cancel_requested | cancelled | Superadmin confirms cancellation | clinic_modules row disabled, cancellation email |
requested/invoiced | rejected | Superadmin rejects | Existing AddonRejectedEmail |
paid → active. There is no code path that flips clinic_modules.isEnabled = true from a request without superadmin approval. The marketplace UI shows paid as “Awaiting activation” — neutral, no internal billing copy exposed.
Idle nudges (G4) and invoice overdue (G3): cron-driven, side-effect-only emails. Do not advance state.
3. Schema
Three new tables, one extended.3.1 marketplace_listings (new)
The CMS-managed catalog row. One row per addon. Source of truth for everything tenants see.
| column | type | notes |
|---|---|---|
id | text PK | = module key (e.g., dicom_imaging, mrn, storage, portal_seats). Matches AVAILABLE_MODULES.key or resource-addon key. |
display_name | text | ”DICOM Imaging” |
tagline | text | One-line hook (max 90 chars) |
icon_kind | text | lucide or upload |
icon_value | text | Lucide name (Scan) or R2 object key |
hero_color | text | Tailwind color token (indigo, emerald, etc.) — drives card gradient |
long_description_md | text | Markdown, rendered with react-markdown |
what_you_get | jsonb | string[] of 3–6 bullets |
screenshots | jsonb | Array of { url, alt } — R2-hosted, signed URLs on read |
faq | jsonb | Array of { q, a } |
version_label | text | ”v1.2.0” — cosmetic only |
last_updated_at | timestamptz | Updated whenever superadmin publishes a release |
pricing_summary | text | Plain string like “From PKR 5,000 / month” — display only, not authoritative |
category | text | clinical / admin / infra / comms — drives section grouping in UI |
security_badges | jsonb | string[] like ["HIPAA-aligned","Audit-logged","SOC2 in progress"] |
status | text | draft / published / archived — only published rows visible to tenants |
sort_order | int | Manual ordering within category |
created_at, updated_at, updated_by | standard |
AVAILABLE_MODULES? That constant stays the entitlement source of truth (what’s installable). marketplace_listings is the marketing surface — different mutability profile (changes weekly), different write authority (superadmin via UI vs. code-deploy).
3.2 marketplace_releases (new)
The changelog. One row per release.
| column | type | notes |
|---|---|---|
id | text PK | uuid |
listing_id | text FK → listings | |
version_label | text | ”v1.2.0” |
released_at | timestamptz | |
is_major | boolean | If true, triggers G1 “What’s new” email |
summary | text | One-line for cards |
body_md | text | Markdown changelog body |
created_by | text | superadmin id |
(listing_id, released_at desc).
3.3 addon_requests (extend existing)
Today’s addonRequests only handles storage and portal_seats. Extend, do not replace:
- Broaden
addonTypeto include module keys: add new valuesdicom_imaging,whatsapp_api,ipd,insurance,marketing,mrn. - Add
statusvaluecancel_requested,cancelled(existing haspending,approved,rejected). - Add nullable
cancelledAt,cancelReason. - For module addons (non-resource),
quantity = 1,unitPricePkrandtotalPricePkrpopulated by superadmin at invoice-issue time (or via newmodule_addon_pricingif we want a default table — see §3.4).
3.4 addon_pricing (extend existing)
Currently holds only storage tiers + portal seat pack prices. Add per-module addon default prices for the 6 module addons. Schema change:
3.5 No new permission system
Reuse existingbilling.request_addon (already exists for storage/seats). Add three keys to the permissions tree:
marketplace.view— list addons, see detail pages. Granted by default to all roles except portal/patient.marketplace.request— submit subscribe requests. Owner + clinic_admin only by default.marketplace.cancel— submit cancellation requests. Owner + clinic_admin only by default.
4. Backend surface
4.1 Tenant-facing routes (server/src/routes/marketplace.ts, new)
All require verifyAuth + clinic context. No public, no anonymous.
| method | path | perm | purpose |
|---|---|---|---|
| GET | /marketplace/listings | marketplace.view | List published listings + per-row subscriptionState (none / requested / invoiced / paid / active / cancel_requested) |
| GET | /marketplace/listings/:key | marketplace.view | Single listing + last 10 releases + screenshots (signed R2 URLs) |
| POST | /marketplace/listings/:key/subscribe | marketplace.request | Create addonRequests row, status pending. Rate-limited: 1 per addon per clinic per hour. |
| POST | /marketplace/listings/:key/cancel | marketplace.cancel | Create cancellation request (new request row of kind cancel) |
| GET | /marketplace/requests | marketplace.view | Clinic’s own request history with statuses |
4.2 Superadmin routes (server/src/routes/superadmin/marketplace.ts, new)
All require verifySuperAdmin.
| method | path | purpose |
|---|---|---|
| GET | /superadmin/marketplace/listings | List all (incl. drafts/archived) |
| POST | /superadmin/marketplace/listings | Create listing |
| PATCH | /superadmin/marketplace/listings/:id | Edit metadata, change status |
| POST | /superadmin/marketplace/listings/:id/screenshots | Upload to R2, append to screenshots |
| DELETE | /superadmin/marketplace/listings/:id/screenshots/:idx | Remove screenshot |
| POST | /superadmin/marketplace/listings/:id/releases | Publish new release (optionally flag is_major → triggers G1 email) |
| GET | /superadmin/marketplace/requests | All pending + recent requests (extends superadmin/addon-requests) |
| POST | /superadmin/marketplace/requests/:id/invoice | Set price, issue invoice, transition requested → invoiced, fire G3 email |
| POST | /superadmin/marketplace/requests/:id/mark-paid | Transition invoiced → paid |
| POST | /superadmin/marketplace/requests/:id/approve | Transition paid → active, flip clinic_modules, fire G4 email |
| POST | /superadmin/marketplace/requests/:id/reject | Transition any pre-active → rejected, fire existing AddonRejectedEmail |
| POST | /superadmin/marketplace/requests/:id/confirm-cancel | Transition cancel_requested → cancelled, disable clinic_modules row |
| POST | /superadmin/marketplace/broadcast/listing-launched/:id | Manual fire of G1 “new on marketplace” |
4.3 Cron jobs (extend server/src/scheduled.ts)
marketplace-overdue-invoices— daily 09:00 PKT. Findsinvoicedrequests >7 days old, sends G3 overdue email (idempotent: tracked vialast_overdue_email_atcolumn on request).marketplace-idle-nudge— weekly Monday 10:00 PKT. Findsactivesubscriptions where the module hasn’t been used in 30+ days (heuristic per-addon:dicom_imaging→ no DICOM uploads,whatsapp_api→ no outbound messages, etc.), sends G4 idle nudge. Capped 1 per addon per clinic per 60 days.
server/src/lib/marketplace-usage.ts (new). One function per addon: lastUsedAt(clinicId, addonKey) → Date | null. No new tables — read from existing usage signals.
4.4 Egress + security
Every listing list endpoint:- Returns only the fields the tenant UI renders. No
updated_by, no draft/archived rows, no superadmin internal notes. - Screenshot URLs are R2 signed URLs with 1-hour TTL, generated per request.
- Listings cached short-lived (5 min) in worker memory; busts on superadmin PATCH (event hook).
- Requires
verifySuperAdmin. - Writes an audit log via
recordAuditLogfor every state transition + listing edit.
- Tenant subscribe: 1 per (clinic, listing) per hour.
- Tenant cancel: 1 per (clinic, listing) per hour.
- Listing detail: 60/min/clinic (prevents scrape if creds leak).
5. Frontend surface
Design parity with the global dashboard (/dashboard/*). Same shell, same typography, same ui/src/components/ui/* primitives, same useDashboardView hook patterns.
5.1 Tenant — /dashboard/marketplace
Index page (MarketplaceIndex.tsx):
- Hero band: “Extend OdontoX — apps built for your practice.” No marketing fluff for non-tenants since there are no non-tenants here.
- Search input + category chips (
All / Clinical / Admin / Comms / Infra). - Grid of listing cards (responsive: 1col mobile, 2col tablet, 3col desktop).
- Card anatomy: icon tile (32×32 with
hero_colorgradient bg), display_name, tagline, version badge, “v1.2.0 · Updated 2d ago”, pricing_summary, state pill (none → “Subscribe” button, requested → “Pending review”, invoiced → “Invoice issued”, paid → “Awaiting activation”, active → “Installed” check, cancel_requested → “Cancellation pending”). - Hover: subtle scale + ring transition. No shadow noise.
- Empty state: 0 published listings → “No apps available yet” with illustration.
MarketplaceDetail.tsx):
- URL:
/dashboard/marketplace/:key - Hero: large icon + display_name + tagline + state pill + primary CTA (Subscribe / Cancel / view request status).
- Tabs: Overview (long_description_md + what_you_get + security_badges), Screenshots (carousel), What’s new (releases list, newest first, each expandable), FAQ (accordion), Pricing (pricing_summary + “Final pricing confirmed at invoice”).
- Subscribe action: confirm modal — “We’ll review your request and email an invoice. Module activates after payment.” No internal mention of superadmin.
- Cancel action (active state only): confirm modal — “Cancellation takes effect after review.”
- Request history strip at bottom: “Your requests for this app” with status timeline.
MarketplaceRequests.tsx):
- URL:
/dashboard/marketplace/requests - Table of all this clinic’s marketplace requests with status timeline + per-row drill-in.
- Add to
ui/src/lib/nav-registry.tsas a top-level item between Settings and Help, iconStorefrom lucide. Gated onmarketplace.view.
5.2 Superadmin — /superadmin/marketplace
Two-pane CMS. Matches existing /superadmin/tenants redesign aesthetic (TanStack Query, deep-linked tabs).
Listings tab:
- List of all listings (draft/published/archived) with status filter.
- Click row → side drawer editor.
- Editor: form for all metadata, markdown preview pane, screenshot upload (drag-drop to R2), release manager (add release with
is_majorcheckbox), status dropdown. - “Publish” action goes from
draft→published. Confirm modal warns “Tenants will see this immediately. Send launch email?” → checkbox triggers G1 broadcast.
- Inbox view of all in-flight requests (extends existing
/superadmin/addon-requests). - Filter by status. Default =
requested + invoiced + paid + cancel_requested. - Per-row actions: Issue invoice (price + email), Mark paid, Approve, Reject, Confirm cancel. Each action requires confirmation; each writes audit + fires the matching email.
- Extends existing addon-pricing CMS to cover the new
moduleAddonPricingjsonb. Per-module per-tier fields.
5.3 Visual direction
- No new design system. Reuse the same Tailwind tokens, fonts, and shadcn primitives already in
ui/src/components/ui/. - Card gradient =
bg-gradient-to-br from-{hero_color}-50 to-{hero_color}-100 dark:from-{hero_color}-950/20 dark:to-{hero_color}-900/10. - Icon tile:
bg-{hero_color}-500/10 text-{hero_color}-600with the lucide icon, rounded-xl. - Typography: existing
text-balanceheadlines, mono for version badges. - No icons of dollars (memory: “no DollarSign / use Banknote”).
6. Emails (v1)
All rendered via existing@react-email/render pipeline + sendEmailViaZepto. Templates live in server/src/emails/. Respect existing clinic notification prefs + per-user opt-out.
| ID | Template | Trigger |
|---|---|---|
| G1a | MarketplaceListingLaunchedEmail.tsx | Superadmin publishes listing + checks “Send launch email” |
| G1b | MarketplaceReleaseEmail.tsx | New release row with is_major = true |
| G3a | MarketplaceInvoiceReadyEmail.tsx | requested → invoiced |
| G3b | MarketplaceInvoiceOverdueEmail.tsx | Cron: invoiced + 7d old, no payment |
| G4a | MarketplaceAddonLiveEmail.tsx | paid → active |
| G4b | MarketplaceAddonIdleEmail.tsx | Cron: active + no usage 30d |
| G5 | existing alertNewAddonRequest | Tenant submits request (extended to cover module addons) |
AddonApprovedEmail / AddonRejectedEmail continue to work; we adapt copy in AddonApprovedEmail to use marketplace-neutral language.
Marketing copy discipline: No internal mechanics. Tenant-facing strings never say “superadmin”, “manual approval”, “we will invoice you for”. Use: “Submitted for review”, “Invoice ready”, “Activated”, “Pending activation”.
7. Security checklist (top-priority per user)
- Every tenant route requires
verifyAuth+ clinic context. No anonymous access anywhere. - No public marketplace surface. Not on
portal.odontox.io, not onq.odontox.io. - Every superadmin route requires
verifySuperAdmin. - Permission split:
marketplace.view/marketplace.request/marketplace.cancel. Owner + clinic_admin only get request/cancel by default. - Rate limits on subscribe/cancel POST (1/hr per clinic per listing).
- CSRF token required on all POST/PATCH/DELETE routes (existing middleware).
- R2 screenshot URLs are short-lived signed URLs, not public.
- Tenant API responses strip superadmin-internal fields (
updated_by, draft/archived rows, internal notes). - Audit log entry on every state transition + every listing edit.
- No tenant or addon internal UUIDs exposed in user-facing URLs — use module key (
dicom_imaging) which is already public knowledge. - Email templates never include billing internals or superadmin role names.
- No “instant activation” code path;
clinic_modules.isEnabled = trueonly writable from theapproveroute which requiresverifySuperAdmin+ state guardstatus === 'paid'. - Cron jobs idempotent (track
last_overdue_email_at,last_idle_nudge_aton requests).
8. Migrations
Single migration file:server/drizzle/0034_marketplace.sql (or next sequence number — verify before writing). Additive only:
CREATE TABLE marketplace_listings (...)per §3.1CREATE TABLE marketplace_releases (...)per §3.2 + indexALTER TABLE addon_requests ADD COLUMN cancelled_at timestamptz NULLALTER TABLE addon_requests ADD COLUMN cancel_reason text NULLALTER TABLE addon_requests ADD COLUMN last_overdue_email_at timestamptz NULLALTER TABLE addon_requests ADD COLUMN last_idle_nudge_at timestamptz NULLALTER TABLE addon_pricing ADD COLUMN module_addon_pricing jsonb NOT NULL DEFAULT '{}'::jsonb- Seed
marketplace_listingswith 8 initial rows (6 module addons + storage + portal_seats) — statusdraftso nothing goes live until superadmin reviews.
cancel_requested, cancelled) are added by code-side enum widening only.
No live tenant DB execution without per-action confirmation (memory rule). Migration must be reviewed and applied with explicit OK.
9. File-level deliverables
Backend:server/src/schema/marketplace_listings.ts(new)server/src/schema/marketplace_releases.ts(new)server/src/schema/addon_requests.ts(extend — verify path)server/src/schema/addon_pricing.ts(extend)server/src/routes/marketplace.ts(new — tenant)server/src/routes/superadmin/marketplace.ts(new — CMS + request inbox)server/src/lib/marketplace-usage.ts(new — idle heuristics)server/src/scheduled.ts(extend — 2 new crons)server/src/emails/MarketplaceListingLaunchedEmail.tsxserver/src/emails/MarketplaceReleaseEmail.tsxserver/src/emails/MarketplaceInvoiceReadyEmail.tsxserver/src/emails/MarketplaceInvoiceOverdueEmail.tsxserver/src/emails/MarketplaceAddonLiveEmail.tsxserver/src/emails/MarketplaceAddonIdleEmail.tsxserver/drizzle/<next>_marketplace.sql(migration)docs/api-reference.md(extend — per memory rule)
ui/src/pages/marketplace/MarketplaceIndex.tsx(new)ui/src/pages/marketplace/MarketplaceDetail.tsx(new)ui/src/pages/marketplace/MarketplaceRequests.tsx(new)ui/src/pages/superadmin/SuperadminMarketplace.tsx(new — listings + requests + pricing tabs)ui/src/components/marketplace/ListingCard.tsxui/src/components/marketplace/ListingHero.tsxui/src/components/marketplace/ChangelogList.tsxui/src/components/marketplace/RequestStateBadge.tsxui/src/components/superadmin/MarketplaceListingEditor.tsxui/src/components/superadmin/MarketplaceRequestRow.tsxui/src/hooks/use-marketplace-listings.ts(TanStack Query)ui/src/hooks/use-marketplace-requests.tsui/src/lib/queryKeys.ts(extend)ui/src/lib/nav-registry.ts(extend — add Marketplace entry)ui/src/lib/permissions-keys.ts(extend — 3 new perms)ui/src/lib/permissions.ts(extend — default role grants)ui/src/App.tsx(extend — routes)
10. Out-of-scope follow-ups (v2 backlog)
- G2 smart usage-threshold recommendation emails (needs per-addon trigger rules engine).
- In-marketplace payment (Stripe Checkout / saved PM). Today payment is offline.
- Per-tenant version pinning + version-gated code paths.
- Public marketing surface on
q.odontox.io(explicitly excluded by user — “no public views allowed”). - Per-addon free trial windows.
- Tenant-to-tenant referral discounts.
11. Open assumptions (assumed true, flag if wrong)
- Existing
addonRequests.addonTypeis atextcolumn not a strict enum, so widening values is a code-side change only. Verify in migration plan. - R2 signed URL helper exists in
server/src/lib/r2.tsor similar — reuse, don’t reinvent. - Existing notification preference table has a
marketplace_emailstoggle slot or accepts a new key. Otherwise add one in the migration. - Sidebar nav and permission tree changes will not require touching the mobile codebase (mobile is patient-only — confirmed in memory).

