Skip to main content

Module Marketplace — Design Spec

Date: 2026-05-19 Owner: ssh Status: Approved — moving to implementation plan

1. 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.io exposure, 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:
[ requested ] → [ invoiced ] → [ paid ] → [ active ]

                                       [ cancel_requested ]

                                          [ cancelled ]
                       ─── rejected ──→ [ rejected ]   (from any pre-active state)
State transitions:
FromToTriggerSide-effects
(none)requestedTenant clicks Subscribe in marketplaceAudit + G5 superadmin alert (existing)
requestedinvoicedSuperadmin issues invoice via superadmin marketplace UIG3 “Invoice ready” email to tenant
invoicedpaidSuperadmin marks payment received (or future Stripe webhook)Internal log only — no email here
paidactiveSuperadmin clicks Approveclinic_modules row enabled/inserted, G4 “Live + quickstart” email
activecancel_requestedTenant clicks Cancel in marketplaceAudit + superadmin alert
cancel_requestedcancelledSuperadmin confirms cancellationclinic_modules row disabled, cancellation email
requested/invoicedrejectedSuperadmin rejectsExisting AddonRejectedEmail
Invariant — never bypass the ladder: activation only happens via 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.
columntypenotes
idtext PK= module key (e.g., dicom_imaging, mrn, storage, portal_seats). Matches AVAILABLE_MODULES.key or resource-addon key.
display_nametext”DICOM Imaging”
taglinetextOne-line hook (max 90 chars)
icon_kindtextlucide or upload
icon_valuetextLucide name (Scan) or R2 object key
hero_colortextTailwind color token (indigo, emerald, etc.) — drives card gradient
long_description_mdtextMarkdown, rendered with react-markdown
what_you_getjsonbstring[] of 3–6 bullets
screenshotsjsonbArray of { url, alt } — R2-hosted, signed URLs on read
faqjsonbArray of { q, a }
version_labeltext”v1.2.0” — cosmetic only
last_updated_attimestamptzUpdated whenever superadmin publishes a release
pricing_summarytextPlain string like “From PKR 5,000 / month” — display only, not authoritative
categorytextclinical / admin / infra / comms — drives section grouping in UI
security_badgesjsonbstring[] like ["HIPAA-aligned","Audit-logged","SOC2 in progress"]
statustextdraft / published / archived — only published rows visible to tenants
sort_orderintManual ordering within category
created_at, updated_at, updated_bystandard
Why not denormalize into 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.
columntypenotes
idtext PKuuid
listing_idtext FK → listings
version_labeltext”v1.2.0”
released_attimestamptz
is_majorbooleanIf true, triggers G1 “What’s new” email
summarytextOne-line for cards
body_mdtextMarkdown changelog body
created_bytextsuperadmin id
Index: (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 addonType to include module keys: add new values dicom_imaging, whatsapp_api, ipd, insurance, marketing, mrn.
  • Add status value cancel_requested, cancelled (existing has pending, approved, rejected).
  • Add nullable cancelledAt, cancelReason.
  • For module addons (non-resource), quantity = 1, unitPricePkr and totalPricePkr populated by superadmin at invoice-issue time (or via new module_addon_pricing if we want a default table — see §3.4).
Migration plan: additive only. No backfill. Existing storage/seats requests keep flowing.

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:
moduleAddonPricing: jsonb('module_addon_pricing').notNull().default(sql`'{}'::jsonb`)
// shape: { dicom_imaging: { pro: 8000, proPlus: 8000 }, whatsapp_api: { pro: 5000, ...}, ... }
Plan-tier price variation is optional; if a module is flat-priced, both keys hold the same value. Superadmin edits via existing addon-pricing CMS surface (extended).

3.5 No new permission system

Reuse existing billing.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.
Per “Feature persona rule” memory — locked in spec, will be reflected in permissions tree update.

4. Backend surface

4.1 Tenant-facing routes (server/src/routes/marketplace.ts, new)

All require verifyAuth + clinic context. No public, no anonymous.
methodpathpermpurpose
GET/marketplace/listingsmarketplace.viewList published listings + per-row subscriptionState (none / requested / invoiced / paid / active / cancel_requested)
GET/marketplace/listings/:keymarketplace.viewSingle listing + last 10 releases + screenshots (signed R2 URLs)
POST/marketplace/listings/:key/subscribemarketplace.requestCreate addonRequests row, status pending. Rate-limited: 1 per addon per clinic per hour.
POST/marketplace/listings/:key/cancelmarketplace.cancelCreate cancellation request (new request row of kind cancel)
GET/marketplace/requestsmarketplace.viewClinic’s own request history with statuses

4.2 Superadmin routes (server/src/routes/superadmin/marketplace.ts, new)

All require verifySuperAdmin.
methodpathpurpose
GET/superadmin/marketplace/listingsList all (incl. drafts/archived)
POST/superadmin/marketplace/listingsCreate listing
PATCH/superadmin/marketplace/listings/:idEdit metadata, change status
POST/superadmin/marketplace/listings/:id/screenshotsUpload to R2, append to screenshots
DELETE/superadmin/marketplace/listings/:id/screenshots/:idxRemove screenshot
POST/superadmin/marketplace/listings/:id/releasesPublish new release (optionally flag is_major → triggers G1 email)
GET/superadmin/marketplace/requestsAll pending + recent requests (extends superadmin/addon-requests)
POST/superadmin/marketplace/requests/:id/invoiceSet price, issue invoice, transition requested → invoiced, fire G3 email
POST/superadmin/marketplace/requests/:id/mark-paidTransition invoiced → paid
POST/superadmin/marketplace/requests/:id/approveTransition paid → active, flip clinic_modules, fire G4 email
POST/superadmin/marketplace/requests/:id/rejectTransition any pre-active → rejected, fire existing AddonRejectedEmail
POST/superadmin/marketplace/requests/:id/confirm-cancelTransition cancel_requested → cancelled, disable clinic_modules row
POST/superadmin/marketplace/broadcast/listing-launched/:idManual fire of G1 “new on marketplace”

4.3 Cron jobs (extend server/src/scheduled.ts)

  • marketplace-overdue-invoices — daily 09:00 PKT. Finds invoiced requests >7 days old, sends G3 overdue email (idempotent: tracked via last_overdue_email_at column on request).
  • marketplace-idle-nudge — weekly Monday 10:00 PKT. Finds active subscriptions 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.
Idle-nudge usage heuristics belong in 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).
Every superadmin route:
  • Requires verifySuperAdmin.
  • Writes an audit log via recordAuditLog for every state transition + listing edit.
Rate limits:
  • 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).
CSRF: existing middleware applies to all POST routes.

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_color gradient 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.
Detail page (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.
My subscriptions tab (MarketplaceRequests.tsx):
  • URL: /dashboard/marketplace/requests
  • Table of all this clinic’s marketplace requests with status timeline + per-row drill-in.
Sidebar entry:
  • Add to ui/src/lib/nav-registry.ts as a top-level item between Settings and Help, icon Store from lucide. Gated on marketplace.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_major checkbox), status dropdown.
  • “Publish” action goes from draftpublished. Confirm modal warns “Tenants will see this immediately. Send launch email?” → checkbox triggers G1 broadcast.
Requests tab:
  • 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.
Pricing tab:
  • Extends existing addon-pricing CMS to cover the new moduleAddonPricing jsonb. 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}-600 with the lucide icon, rounded-xl.
  • Typography: existing text-balance headlines, 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.
IDTemplateTrigger
G1aMarketplaceListingLaunchedEmail.tsxSuperadmin publishes listing + checks “Send launch email”
G1bMarketplaceReleaseEmail.tsxNew release row with is_major = true
G3aMarketplaceInvoiceReadyEmail.tsxrequested → invoiced
G3bMarketplaceInvoiceOverdueEmail.tsxCron: invoiced + 7d old, no payment
G4aMarketplaceAddonLiveEmail.tsxpaid → active
G4bMarketplaceAddonIdleEmail.tsxCron: active + no usage 30d
G5existing alertNewAddonRequestTenant submits request (extended to cover module addons)
Existing 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 on q.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 = true only writable from the approve route which requires verifySuperAdmin + state guard status === 'paid'.
  • Cron jobs idempotent (track last_overdue_email_at, last_idle_nudge_at on requests).

8. Migrations

Single migration file: server/drizzle/0034_marketplace.sql (or next sequence number — verify before writing). Additive only:
  1. CREATE TABLE marketplace_listings (...) per §3.1
  2. CREATE TABLE marketplace_releases (...) per §3.2 + index
  3. ALTER TABLE addon_requests ADD COLUMN cancelled_at timestamptz NULL
  4. ALTER TABLE addon_requests ADD COLUMN cancel_reason text NULL
  5. ALTER TABLE addon_requests ADD COLUMN last_overdue_email_at timestamptz NULL
  6. ALTER TABLE addon_requests ADD COLUMN last_idle_nudge_at timestamptz NULL
  7. ALTER TABLE addon_pricing ADD COLUMN module_addon_pricing jsonb NOT NULL DEFAULT '{}'::jsonb
  8. Seed marketplace_listings with 8 initial rows (6 module addons + storage + portal_seats) — status draft so nothing goes live until superadmin reviews.
No backfill of existing addon_requests rows needed. New statuses (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.tsx
  • server/src/emails/MarketplaceReleaseEmail.tsx
  • server/src/emails/MarketplaceInvoiceReadyEmail.tsx
  • server/src/emails/MarketplaceInvoiceOverdueEmail.tsx
  • server/src/emails/MarketplaceAddonLiveEmail.tsx
  • server/src/emails/MarketplaceAddonIdleEmail.tsx
  • server/drizzle/<next>_marketplace.sql (migration)
  • docs/api-reference.md (extend — per memory rule)
Frontend:
  • 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.tsx
  • ui/src/components/marketplace/ListingHero.tsx
  • ui/src/components/marketplace/ChangelogList.tsx
  • ui/src/components/marketplace/RequestStateBadge.tsx
  • ui/src/components/superadmin/MarketplaceListingEditor.tsx
  • ui/src/components/superadmin/MarketplaceRequestRow.tsx
  • ui/src/hooks/use-marketplace-listings.ts (TanStack Query)
  • ui/src/hooks/use-marketplace-requests.ts
  • ui/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.addonType is a text column 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.ts or similar — reuse, don’t reinvent.
  • Existing notification preference table has a marketplace_emails toggle 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).