Skip to main content

Module Marketplace Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Ship a polished, auth-gated marketplace at /dashboard/marketplace for paid module + resource addons. Tenants browse → request → pay invoice → superadmin approves → module activates. Spec: docs/superpowers/specs/2026-05-19-module-marketplace-design.md. Architecture: New marketplace_listings + marketplace_releases tables hold CMS content. Existing addon_requests flow is extended (enum widening) to cover the 6 module addons in addition to storage / portal_seats. Tenant routes under /marketplace/* (auth-gated), superadmin CMS + request inbox under /superadmin/marketplace/*. Six new transactional email templates cover the lifecycle. Two crons handle overdue invoices + idle-subscription nudges. Frontend reuses dashboard shell + shadcn primitives, no new design system. Tech Stack: Hono (server), Drizzle ORM + Postgres (pgEnum + jsonb), Cloudflare Workers + R2 (screenshots), TanStack Query (frontend), React Router, shadcn/ui + Tailwind, @react-email/render, Vitest (server tests), React Testing Library (UI tests).

Phases & Parallelism

This plan has 4 phases. Tasks within a phase may be parallelizable; phases are sequential.
  • Phase 1 (sequential): Schema + migration. T1 → T6.
  • Phase 2 (parallel-friendly after Phase 1): Backend libs, routes, emails, crons. T7–T16.
  • Phase 3 (parallel-friendly after Phase 2): Frontend pages + components. T17–T26.
  • Phase 4 (sequential): Docs + end-to-end smoke. T27–T28.

Phase 1 — Schema & Migration

Task 1: Create marketplace_listings schema

Files:
  • Create: server/src/schema/marketplace_listings.ts
  • Step 1: Write the schema file
// server/src/schema/marketplace_listings.ts
import { pgTable, text, timestamp, integer, jsonb, boolean, index } from 'drizzle-orm/pg-core';
import { appSchema } from './base';

export const marketplaceListings = appSchema.table('marketplace_listings', {
  id: text('id').primaryKey(), // matches AVAILABLE_MODULES.key or resource-addon key
  displayName: text('display_name').notNull(),
  tagline: text('tagline').notNull(),
  iconKind: text('icon_kind').notNull(), // 'lucide' | 'upload'
  iconValue: text('icon_value').notNull(), // lucide name or R2 key
  heroColor: text('hero_color').notNull(), // tailwind token: 'indigo', 'emerald', ...
  longDescriptionMd: text('long_description_md').notNull().default(''),
  whatYouGet: jsonb('what_you_get').$type<string[]>().notNull().default([]),
  screenshots: jsonb('screenshots').$type<Array<{ key: string; alt: string }>>().notNull().default([]),
  faq: jsonb('faq').$type<Array<{ q: string; a: string }>>().notNull().default([]),
  versionLabel: text('version_label').notNull().default('v1.0.0'),
  lastUpdatedAt: timestamp('last_updated_at').defaultNow().notNull(),
  pricingSummary: text('pricing_summary').notNull().default(''),
  category: text('category').notNull(), // 'clinical' | 'admin' | 'infra' | 'comms'
  securityBadges: jsonb('security_badges').$type<string[]>().notNull().default([]),
  status: text('status').notNull().default('draft'), // 'draft' | 'published' | 'archived'
  sortOrder: integer('sort_order').notNull().default(0),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
  updatedBy: text('updated_by'),
}, (t) => ({
  statusCategoryIdx: index('marketplace_listings_status_category_idx').on(t.status, t.category, t.sortOrder),
}));

export type MarketplaceListing = typeof marketplaceListings.$inferSelect;
export type NewMarketplaceListing = typeof marketplaceListings.$inferInsert;
  • Step 2: Commit
git add server/src/schema/marketplace_listings.ts
git commit -m "feat(marketplace): listings schema"

Task 2: Create marketplace_releases schema

Files:
  • Create: server/src/schema/marketplace_releases.ts
  • Step 1: Write the schema file
// server/src/schema/marketplace_releases.ts
import { pgTable, text, timestamp, boolean, index } from 'drizzle-orm/pg-core';
import { appSchema } from './base';
import { marketplaceListings } from './marketplace_listings';

export const marketplaceReleases = appSchema.table('marketplace_releases', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  listingId: text('listing_id').references(() => marketplaceListings.id, { onDelete: 'cascade' }).notNull(),
  versionLabel: text('version_label').notNull(),
  releasedAt: timestamp('released_at').defaultNow().notNull(),
  isMajor: boolean('is_major').default(false).notNull(),
  summary: text('summary').notNull().default(''),
  bodyMd: text('body_md').notNull().default(''),
  createdBy: text('created_by'),
}, (t) => ({
  listingReleasedIdx: index('marketplace_releases_listing_released_idx').on(t.listingId, t.releasedAt),
}));

export type MarketplaceRelease = typeof marketplaceReleases.$inferSelect;
export type NewMarketplaceRelease = typeof marketplaceReleases.$inferInsert;
  • Step 2: Commit
git add server/src/schema/marketplace_releases.ts
git commit -m "feat(marketplace): releases schema"

Task 3: Extend addon_pricing for module-addon pricing

Files:
  • Modify: server/src/schema/addon_pricing.ts
  • Step 1: Add the jsonb column
After the existing portalSeats3PackProPlusPkr line, before updatedAt, add:
  moduleAddonPricing: jsonb('module_addon_pricing').$type<Record<string, { pro: number; proPlus: number }>>()
    .notNull().default({}),
Update the import line at the top from:
import { text, timestamp, integer, jsonb, index } from 'drizzle-orm/pg-core';
(jsonb is already imported — no change needed.)
  • Step 2: Commit
git add server/src/schema/addon_pricing.ts
git commit -m "feat(marketplace): module addon pricing column"

Task 4: Extend addon_requests enums + columns

Files:
  • Modify: server/src/schema/addon_requests.ts
  • Step 1: Widen enums and add columns
Replace the enum declarations with:
export const addonTypeEnum = pgEnum('addon_type', [
  'storage',
  'portal_seats',
  'dicom_imaging',
  'whatsapp_api',
  'ipd',
  'insurance',
  'marketing',
  'mrn',
]);

export const addonRequestStatusEnum = pgEnum('addon_request_status', [
  'pending',
  'invoiced',
  'paid',
  'approved',
  'rejected',
  'cancel_requested',
  'cancelled',
]);
After the rejectedReason: text('rejected_reason'), line, add:
  invoicedAt: timestamp('invoiced_at'),
  invoiceId: text('invoice_id'),                  // FK to payment_invoices.id (SQL-enforced)
  paidAt: timestamp('paid_at'),
  cancelRequestedAt: timestamp('cancel_requested_at'),
  cancelReason: text('cancel_reason'),
  lastOverdueEmailAt: timestamp('last_overdue_email_at'),
  lastIdleNudgeAt: timestamp('last_idle_nudge_at'),
  • Step 2: Commit
git add server/src/schema/addon_requests.ts
git commit -m "feat(marketplace): widen addon request enums + lifecycle columns"

Task 5: Wire new tables into the schema index

Files:
  • Modify: server/src/schema/index.ts
  • Step 1: Add exports
Read the file first, then add (at the bottom of the export list, alphabetical-ish placement is fine):
export * from './marketplace_listings';
export * from './marketplace_releases';
  • Step 2: Verify type-check
Run: cd server && pnpm tsc --noEmit Expected: PASS (no type errors).
  • Step 3: Commit
git add server/src/schema/index.ts
git commit -m "feat(marketplace): export new tables from schema index"

Task 6: Write the migration SQL

Files:
  • Create: server/drizzle/0045_marketplace.sql
  • Step 1: Write the migration
-- server/drizzle/0045_marketplace.sql

-- Widen addon_type enum
ALTER TYPE addon_type ADD VALUE IF NOT EXISTS 'dicom_imaging';
ALTER TYPE addon_type ADD VALUE IF NOT EXISTS 'whatsapp_api';
ALTER TYPE addon_type ADD VALUE IF NOT EXISTS 'ipd';
ALTER TYPE addon_type ADD VALUE IF NOT EXISTS 'insurance';
ALTER TYPE addon_type ADD VALUE IF NOT EXISTS 'marketing';
ALTER TYPE addon_type ADD VALUE IF NOT EXISTS 'mrn';

-- Widen addon_request_status enum
ALTER TYPE addon_request_status ADD VALUE IF NOT EXISTS 'invoiced';
ALTER TYPE addon_request_status ADD VALUE IF NOT EXISTS 'paid';
ALTER TYPE addon_request_status ADD VALUE IF NOT EXISTS 'cancel_requested';

-- Extend addon_requests columns (cancelled_at + cancelled_by already exist)
ALTER TABLE app.addon_requests
  ADD COLUMN IF NOT EXISTS invoiced_at timestamptz,
  ADD COLUMN IF NOT EXISTS invoice_id text,
  ADD COLUMN IF NOT EXISTS paid_at timestamptz,
  ADD COLUMN IF NOT EXISTS cancel_requested_at timestamptz,
  ADD COLUMN IF NOT EXISTS cancel_reason text,
  ADD COLUMN IF NOT EXISTS last_overdue_email_at timestamptz,
  ADD COLUMN IF NOT EXISTS last_idle_nudge_at timestamptz;

-- Extend addon_pricing
ALTER TABLE app.addon_pricing
  ADD COLUMN IF NOT EXISTS module_addon_pricing jsonb NOT NULL DEFAULT '{}'::jsonb;

-- marketplace_listings
CREATE TABLE IF NOT EXISTS app.marketplace_listings (
  id text PRIMARY KEY,
  display_name text NOT NULL,
  tagline text NOT NULL,
  icon_kind text NOT NULL,
  icon_value text NOT NULL,
  hero_color text NOT NULL,
  long_description_md text NOT NULL DEFAULT '',
  what_you_get jsonb NOT NULL DEFAULT '[]'::jsonb,
  screenshots jsonb NOT NULL DEFAULT '[]'::jsonb,
  faq jsonb NOT NULL DEFAULT '[]'::jsonb,
  version_label text NOT NULL DEFAULT 'v1.0.0',
  last_updated_at timestamptz NOT NULL DEFAULT now(),
  pricing_summary text NOT NULL DEFAULT '',
  category text NOT NULL,
  security_badges jsonb NOT NULL DEFAULT '[]'::jsonb,
  status text NOT NULL DEFAULT 'draft',
  sort_order integer NOT NULL DEFAULT 0,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  updated_by text
);

CREATE INDEX IF NOT EXISTS marketplace_listings_status_category_idx
  ON app.marketplace_listings (status, category, sort_order);

-- marketplace_releases
CREATE TABLE IF NOT EXISTS app.marketplace_releases (
  id text PRIMARY KEY,
  listing_id text NOT NULL REFERENCES app.marketplace_listings(id) ON DELETE CASCADE,
  version_label text NOT NULL,
  released_at timestamptz NOT NULL DEFAULT now(),
  is_major boolean NOT NULL DEFAULT false,
  summary text NOT NULL DEFAULT '',
  body_md text NOT NULL DEFAULT '',
  created_by text
);

CREATE INDEX IF NOT EXISTS marketplace_releases_listing_released_idx
  ON app.marketplace_releases (listing_id, released_at);

-- Seed initial listings (all draft — superadmin reviews before publishing)
INSERT INTO app.marketplace_listings
  (id, display_name, tagline, icon_kind, icon_value, hero_color, category, version_label, pricing_summary, sort_order)
VALUES
  ('dicom_imaging', 'DICOM Imaging',     'Upload, view and AI-analyse dental radiographs',          'lucide', 'Scan',          'clinical', 'v1.0.0', 'From PKR 8,000 / month',  10),
  ('whatsapp_api',  'WhatsApp API',      'BYOK WhatsApp Business — reminders + two-way messaging',  'lucide', 'MessageCircle', 'comms',    'v1.0.0', 'From PKR 5,000 / month',  20),
  ('ipd',           'In-Patient (IPD)',  'Admissions, bed management and discharge summaries',      'lucide', 'BedDouble',     'clinical', 'v1.0.0', 'From PKR 6,000 / month',  30),
  ('insurance',     'Insurance Management','Digital insurance claims and reimbursement tracking',   'lucide', 'ShieldCheck',   'admin',    'v1.0.0', 'From PKR 4,000 / month',  40),
  ('marketing',     'Marketing & Recalls','Patient recall automation and campaign workflows',       'lucide', 'Megaphone',     'admin',    'v1.0.0', 'From PKR 4,000 / month',  50),
  ('mrn',           'Medical Record Numbers','Manually-assigned MRN field on patient records',      'lucide', 'Hash',          'admin',    'v1.0.0', 'From PKR 1,500 / month',  60),
  ('storage',       'Extra Storage',     'Add 50GB / 200GB / 500GB / 1TB on top of your plan',      'lucide', 'HardDrive',     'infra',    'v1.0.0', 'From PKR 1,200 / month',  70),
  ('portal_seats',  'Portal Seats',      'Extra public-share seats in 3-packs',                     'lucide', 'Users',         'infra',    'v1.0.0', 'From PKR 2,500 / 3 seats', 80)
ON CONFLICT (id) DO NOTHING;
  • Step 2: Apply to dev DB (DO NOT touch prod)
Confirm with user before running. Then:
cd server && pnpm dlx tsx scripts/apply-migration.ts 0045_marketplace.sql
(Use the project’s existing migration runner — verify the script name from server/scripts/ before running.) Expected: migration applied, 8 seed rows inserted in draft status.
  • Step 3: Commit
git add server/drizzle/0045_marketplace.sql
git commit -m "feat(marketplace): 0045 migration — enums, columns, listings, releases, seed"

Phase 2 — Backend

Task 7: Add marketplace permission keys + role defaults

Files:
  • Modify: ui/src/lib/permissions-keys.ts
  • Modify: ui/src/lib/permissions.ts
  • Modify: server/src/lib/permissions.ts (if mirror exists — verify)
  • Step 1: Add to permissions-keys.ts
Add to the permission keys list (grouped with billing or in a new marketplace group):
'marketplace.view',
'marketplace.request',
'marketplace.cancel',
  • Step 2: Add default role grants in permissions.ts
Find the role default mapping. Grant:
  • owner + clinic_admin: all three (marketplace.view, marketplace.request, marketplace.cancel).
  • doctor + nurse + receptionist: marketplace.view only.
  • portal + patient: none.
If a server-side mirror exists at server/src/lib/permissions.ts, mirror the same grants there.
  • Step 3: Type-check + commit
cd ui && pnpm tsc --noEmit
git add ui/src/lib/permissions-keys.ts ui/src/lib/permissions.ts server/src/lib/permissions.ts
git commit -m "feat(marketplace): add view/request/cancel permission keys"

Task 8: State machine helper lib + tests

Files:
  • Create: server/src/lib/marketplace-state.ts
  • Test: server/src/lib/__tests__/marketplace-state.test.ts
  • Step 1: Write the failing tests
// server/src/lib/__tests__/marketplace-state.test.ts
import { describe, it, expect } from 'vitest';
import { canTransition, nextState, TenantStatus } from '../marketplace-state';

describe('marketplace-state', () => {
  it('allows requested → invoiced', () => {
    expect(canTransition('pending', 'invoiced')).toBe(true);
  });
  it('allows invoiced → paid', () => {
    expect(canTransition('invoiced', 'paid')).toBe(true);
  });
  it('allows paid → approved', () => {
    expect(canTransition('paid', 'approved')).toBe(true);
  });
  it('blocks pending → approved (must invoice + pay first)', () => {
    expect(canTransition('pending', 'approved')).toBe(false);
  });
  it('blocks paid → cancelled (no skipping cancel_requested)', () => {
    expect(canTransition('paid', 'cancelled')).toBe(false);
  });
  it('allows approved → cancel_requested', () => {
    expect(canTransition('approved', 'cancel_requested')).toBe(true);
  });
  it('maps DB status to tenant-facing status', () => {
    expect(TenantStatus.fromDb('pending')).toBe('submitted');
    expect(TenantStatus.fromDb('invoiced')).toBe('invoice_ready');
    expect(TenantStatus.fromDb('paid')).toBe('awaiting_activation');
    expect(TenantStatus.fromDb('approved')).toBe('active');
    expect(TenantStatus.fromDb('cancel_requested')).toBe('cancellation_pending');
    expect(TenantStatus.fromDb('cancelled')).toBe('cancelled');
    expect(TenantStatus.fromDb('rejected')).toBe('rejected');
  });
});
  • Step 2: Run tests (expect FAIL)
Run: cd server && pnpm vitest run src/lib/__tests__/marketplace-state.test.ts Expected: FAIL — module not found.
  • Step 3: Implement
// server/src/lib/marketplace-state.ts
export type DbStatus =
  | 'pending' | 'invoiced' | 'paid' | 'approved'
  | 'rejected' | 'cancel_requested' | 'cancelled';

const TRANSITIONS: Record<DbStatus, DbStatus[]> = {
  pending:           ['invoiced', 'rejected'],
  invoiced:          ['paid', 'rejected'],
  paid:              ['approved', 'rejected'],
  approved:          ['cancel_requested'],
  cancel_requested:  ['cancelled', 'approved'], // 'approved' = superadmin denies cancellation
  cancelled:         [],
  rejected:          [],
};

export function canTransition(from: DbStatus, to: DbStatus): boolean {
  return TRANSITIONS[from]?.includes(to) ?? false;
}

export function nextState(from: DbStatus, to: DbStatus): DbStatus {
  if (!canTransition(from, to)) throw new Error(`Invalid transition ${from}${to}`);
  return to;
}

export const TenantStatus = {
  fromDb(s: DbStatus): string {
    return {
      pending: 'submitted',
      invoiced: 'invoice_ready',
      paid: 'awaiting_activation',
      approved: 'active',
      cancel_requested: 'cancellation_pending',
      cancelled: 'cancelled',
      rejected: 'rejected',
    }[s];
  },
} as const;
  • Step 4: Run tests (expect PASS)
Run: cd server && pnpm vitest run src/lib/__tests__/marketplace-state.test.ts Expected: PASS (7 tests).
  • Step 5: Commit
git add server/src/lib/marketplace-state.ts server/src/lib/__tests__/marketplace-state.test.ts
git commit -m "feat(marketplace): state machine + tenant-facing status mapping"

Task 9: Tenant marketplace routes + tests

Files:
  • Create: server/src/routes/marketplace.ts
  • Test: server/src/routes/__tests__/marketplace.test.ts
  • Modify: server/src/api.ts (register route)
  • Step 1: Write the failing tests
// server/src/routes/__tests__/marketplace.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { testApp, seedClinic, seedListing, authedRequest } from '../../test-utils';

describe('GET /marketplace/listings', () => {
  beforeEach(async () => {
    await seedListing({ id: 'mrn', status: 'published', category: 'admin' });
    await seedListing({ id: 'dicom_imaging', status: 'draft', category: 'clinical' });
  });

  it('requires auth', async () => {
    const res = await testApp.request('/marketplace/listings');
    expect(res.status).toBe(401);
  });

  it('returns only published listings to tenants', async () => {
    const clinic = await seedClinic();
    const res = await authedRequest(clinic, 'GET', '/marketplace/listings');
    expect(res.status).toBe(200);
    const json = await res.json();
    expect(json.listings).toHaveLength(1);
    expect(json.listings[0].id).toBe('mrn');
    expect(json.listings[0]).not.toHaveProperty('updatedBy'); // internal field stripped
  });

  it('attaches per-row subscriptionState', async () => {
    const clinic = await seedClinic();
    const res = await authedRequest(clinic, 'GET', '/marketplace/listings');
    const json = await res.json();
    expect(json.listings[0].subscriptionState).toBe('none');
  });
});

describe('POST /marketplace/listings/:key/subscribe', () => {
  it('rejects if perm missing', async () => {
    const clinic = await seedClinic({ role: 'doctor' }); // no marketplace.request
    await seedListing({ id: 'mrn', status: 'published' });
    const res = await authedRequest(clinic, 'POST', '/marketplace/listings/mrn/subscribe', {});
    expect(res.status).toBe(403);
  });

  it('creates addon_requests row with status pending', async () => {
    const clinic = await seedClinic({ role: 'owner' });
    await seedListing({ id: 'mrn', status: 'published' });
    const res = await authedRequest(clinic, 'POST', '/marketplace/listings/mrn/subscribe', { notes: 'please' });
    expect(res.status).toBe(201);
    const json = await res.json();
    expect(json.request.status).toBe('pending');
    expect(json.request.addonType).toBe('mrn');
  });

  it('rate-limits second subscribe within an hour', async () => {
    const clinic = await seedClinic({ role: 'owner' });
    await seedListing({ id: 'mrn', status: 'published' });
    await authedRequest(clinic, 'POST', '/marketplace/listings/mrn/subscribe', {});
    const res2 = await authedRequest(clinic, 'POST', '/marketplace/listings/mrn/subscribe', {});
    expect(res2.status).toBe(429);
  });

  it('refuses to subscribe if already active', async () => {
    const clinic = await seedClinic({ role: 'owner' });
    await seedListing({ id: 'mrn', status: 'published' });
    // pre-existing approved request
    await seedRequest({ clinicId: clinic.clinicId, addonType: 'mrn', status: 'approved' });
    const res = await authedRequest(clinic, 'POST', '/marketplace/listings/mrn/subscribe', {});
    expect(res.status).toBe(409);
  });
});

describe('POST /marketplace/listings/:key/cancel', () => {
  it('creates a cancel_requested row only when active', async () => {
    const clinic = await seedClinic({ role: 'owner' });
    await seedListing({ id: 'mrn', status: 'published' });
    await seedRequest({ clinicId: clinic.clinicId, addonType: 'mrn', status: 'approved' });
    const res = await authedRequest(clinic, 'POST', '/marketplace/listings/mrn/cancel', { reason: 'no longer needed' });
    expect(res.status).toBe(201);
  });

  it('rejects cancel when not active', async () => {
    const clinic = await seedClinic({ role: 'owner' });
    await seedListing({ id: 'mrn', status: 'published' });
    const res = await authedRequest(clinic, 'POST', '/marketplace/listings/mrn/cancel', {});
    expect(res.status).toBe(409);
  });
});
(seedClinic, seedListing, seedRequest, authedRequest are existing test helpers in server/src/test-utils.ts — if any of these helpers are missing, add the minimal helper in the same task before continuing.)
  • Step 2: Run tests (expect FAIL)
Run: cd server && pnpm vitest run src/routes/__tests__/marketplace.test.ts Expected: FAIL — route not registered.
  • Step 3: Implement the route
// server/src/routes/marketplace.ts
import { Hono } from 'hono';
import { and, eq, desc, gt } from 'drizzle-orm';
import { getReadDb, getWriteDb } from '../lib/db';
import { marketplaceListings, marketplaceReleases, addonRequests, clinics, subscriptionPlans } from '../schema';
import { requirePermission } from '../middleware/permissions';
import { recordAuditLog } from '../lib/audit-helper';
import { alertNewAddonRequest } from '../lib/superadmin-alerts';
import { handleError, AppError } from '../lib/errors';
import { TenantStatus } from '../lib/marketplace-state';

const route = new Hono();

// In-memory rate limiter: 1 subscribe/cancel per (clinic, listing) per hour
const recentActions = new Map<string, number>();
function rateLimit(key: string, windowMs = 60 * 60 * 1000): boolean {
  const now = Date.now();
  const last = recentActions.get(key) ?? 0;
  if (now - last < windowMs) return false;
  recentActions.set(key, now);
  return true;
}

// Strip superadmin-internal fields from listings before returning to tenants
function publicListingShape(row: any) {
  const { updatedBy, status, ...safe } = row;
  return safe;
}

route.get('/listings', requirePermission('marketplace.view'), async (c) => {
  try {
    const db = getReadDb();
    const clinicId = c.get('clinicContext')?.currentClinicId;
    if (!clinicId) throw new AppError('No clinic context', 400);

    const listings = await db
      .select()
      .from(marketplaceListings)
      .where(eq(marketplaceListings.status, 'published'))
      .orderBy(marketplaceListings.sortOrder);

    const requests = await db
      .select()
      .from(addonRequests)
      .where(eq(addonRequests.clinicId, clinicId));

    const byType = new Map<string, string>();
    for (const r of requests) {
      // most recent terminal-or-active status wins; simple heuristic — keep last touched
      byType.set(r.addonType, r.status);
    }

    return c.json({
      listings: listings.map(l => ({
        ...publicListingShape(l),
        subscriptionState: byType.has(l.id) ? TenantStatus.fromDb(byType.get(l.id) as any) : 'none',
      })),
    });
  } catch (e) {
    return handleError(e, c);
  }
});

route.get('/listings/:key', requirePermission('marketplace.view'), async (c) => {
  try {
    const db = getReadDb();
    const key = c.req.param('key');
    const clinicId = c.get('clinicContext')?.currentClinicId;

    const [listing] = await db
      .select()
      .from(marketplaceListings)
      .where(and(eq(marketplaceListings.id, key), eq(marketplaceListings.status, 'published')))
      .limit(1);

    if (!listing) throw new AppError('Not found', 404);

    const releases = await db
      .select()
      .from(marketplaceReleases)
      .where(eq(marketplaceReleases.listingId, key))
      .orderBy(desc(marketplaceReleases.releasedAt))
      .limit(10);

    const requests = await db
      .select()
      .from(addonRequests)
      .where(and(eq(addonRequests.clinicId, clinicId), eq(addonRequests.addonType, key as any)))
      .orderBy(desc(addonRequests.createdAt))
      .limit(5);

    const latestStatus = requests[0]?.status;

    return c.json({
      listing: publicListingShape(listing),
      releases,
      requests,
      subscriptionState: latestStatus ? TenantStatus.fromDb(latestStatus) : 'none',
    });
  } catch (e) {
    return handleError(e, c);
  }
});

route.post('/listings/:key/subscribe', requirePermission('marketplace.request'), async (c) => {
  try {
    const db = getReadDb();
    const writeDb = getWriteDb();
    const key = c.req.param('key');
    const clinicId = c.get('clinicContext')?.currentClinicId;
    const userId = c.get('user')?.id;
    if (!clinicId || !userId) throw new AppError('Unauthorized', 401);

    if (!rateLimit(`subscribe:${clinicId}:${key}`)) {
      return c.json({ error: 'Too many requests' }, 429);
    }

    const [listing] = await db
      .select()
      .from(marketplaceListings)
      .where(and(eq(marketplaceListings.id, key), eq(marketplaceListings.status, 'published')))
      .limit(1);
    if (!listing) throw new AppError('Listing not available', 404);

    // Refuse if already-active or in-flight non-terminal
    const existing = await db
      .select()
      .from(addonRequests)
      .where(and(
        eq(addonRequests.clinicId, clinicId),
        eq(addonRequests.addonType, key as any),
      ))
      .orderBy(desc(addonRequests.createdAt))
      .limit(1);

    const blocking = existing[0]?.status;
    if (blocking && ['pending', 'invoiced', 'paid', 'approved', 'cancel_requested'].includes(blocking)) {
      return c.json({ error: 'Subscription already in progress or active' }, 409);
    }

    const body = await c.req.json().catch(() => ({}));
    const notes = typeof body.notes === 'string' ? body.notes.slice(0, 1000) : null;

    const [inserted] = await writeDb.insert(addonRequests).values({
      id: crypto.randomUUID(),
      clinicId,
      requestedBy: userId,
      addonType: key as any,
      quantity: 1,                       // module addons are always quantity=1
      unitPricePkr: 0,                   // pricing finalised at invoice time
      totalPricePkr: 0,
      status: 'pending',
      notes,
    }).returning();

    await recordAuditLog({
      clinicId, userId,
      action: 'marketplace.subscribe.requested',
      entityType: 'addon_request',
      entityId: inserted.id,
      changes: { addonType: key },
    });

    await alertNewAddonRequest({
      clinicId,
      clinicName: '', // filled by helper from DB
      planKey: '',
      requestId: inserted.id,
    });

    return c.json({ request: inserted }, 201);
  } catch (e) {
    return handleError(e, c);
  }
});

route.post('/listings/:key/cancel', requirePermission('marketplace.cancel'), async (c) => {
  try {
    const db = getReadDb();
    const writeDb = getWriteDb();
    const key = c.req.param('key');
    const clinicId = c.get('clinicContext')?.currentClinicId;
    const userId = c.get('user')?.id;
    if (!clinicId || !userId) throw new AppError('Unauthorized', 401);

    if (!rateLimit(`cancel:${clinicId}:${key}`)) {
      return c.json({ error: 'Too many requests' }, 429);
    }

    const [active] = await db
      .select()
      .from(addonRequests)
      .where(and(
        eq(addonRequests.clinicId, clinicId),
        eq(addonRequests.addonType, key as any),
        eq(addonRequests.status, 'approved'),
      ))
      .limit(1);

    if (!active) {
      return c.json({ error: 'No active subscription to cancel' }, 409);
    }

    const body = await c.req.json().catch(() => ({}));
    const reason = typeof body.reason === 'string' ? body.reason.slice(0, 1000) : null;

    await writeDb.update(addonRequests)
      .set({
        status: 'cancel_requested',
        cancelRequestedAt: new Date(),
        cancelReason: reason,
        updatedAt: new Date(),
      })
      .where(eq(addonRequests.id, active.id));

    await recordAuditLog({
      clinicId, userId,
      action: 'marketplace.cancel.requested',
      entityType: 'addon_request',
      entityId: active.id,
      changes: { cancelReason: reason },
    });

    return c.json({ ok: true }, 201);
  } catch (e) {
    return handleError(e, c);
  }
});

route.get('/requests', requirePermission('marketplace.view'), async (c) => {
  try {
    const db = getReadDb();
    const clinicId = c.get('clinicContext')?.currentClinicId;
    if (!clinicId) throw new AppError('No clinic context', 400);
    const rows = await db
      .select()
      .from(addonRequests)
      .where(eq(addonRequests.clinicId, clinicId))
      .orderBy(desc(addonRequests.createdAt));
    return c.json({ requests: rows });
  } catch (e) {
    return handleError(e, c);
  }
});

export { route as marketplaceRoute };
  • Step 4: Register in api.ts
Find where other routes are mounted (e.g. app.route('/addon-requests', addonRequestsRoute)) and add nearby:
import { marketplaceRoute } from './routes/marketplace';
app.route('/marketplace', marketplaceRoute);
  • Step 5: Run tests (expect PASS)
Run: cd server && pnpm vitest run src/routes/__tests__/marketplace.test.ts Expected: PASS (all 8+ tests).
  • Step 6: Commit
git add server/src/routes/marketplace.ts server/src/routes/__tests__/marketplace.test.ts server/src/api.ts
git commit -m "feat(marketplace): tenant routes + auth/perm/rate-limit guards"

Task 10: Superadmin listings CMS routes + tests

Files:
  • Create: server/src/routes/superadmin/marketplace.ts
  • Test: server/src/routes/__tests__/superadmin-marketplace.test.ts
  • Modify: server/src/api.ts (register)
  • Step 1: Write the failing tests
// server/src/routes/__tests__/superadmin-marketplace.test.ts
import { describe, it, expect } from 'vitest';
import { testApp, superadminRequest, tenantRequest } from '../../test-utils';

describe('superadmin marketplace listings', () => {
  it('GET /superadmin/marketplace/listings requires superadmin', async () => {
    const res = await tenantRequest('GET', '/superadmin/marketplace/listings');
    expect(res.status).toBe(403);
  });

  it('GET returns draft + published rows for superadmin', async () => {
    const res = await superadminRequest('GET', '/superadmin/marketplace/listings');
    expect(res.status).toBe(200);
    const json = await res.json();
    expect(json.listings.length).toBeGreaterThanOrEqual(8);
  });

  it('PATCH updates listing metadata', async () => {
    const res = await superadminRequest('PATCH', '/superadmin/marketplace/listings/mrn', {
      tagline: 'New tagline',
      status: 'published',
    });
    expect(res.status).toBe(200);
    const after = await superadminRequest('GET', '/superadmin/marketplace/listings');
    const mrn = (await after.json()).listings.find((l: any) => l.id === 'mrn');
    expect(mrn.tagline).toBe('New tagline');
    expect(mrn.status).toBe('published');
  });

  it('POST releases creates a release row', async () => {
    const res = await superadminRequest('POST', '/superadmin/marketplace/listings/mrn/releases', {
      versionLabel: 'v1.1.0',
      summary: 'Added filters',
      bodyMd: '## What changed\n- filter chips',
      isMajor: false,
    });
    expect(res.status).toBe(201);
  });

  it('POST major release fires G1 release email', async () => {
    const { emailsSent } = await superadminRequest('POST', '/superadmin/marketplace/listings/mrn/releases', {
      versionLabel: 'v2.0.0',
      summary: 'Major rewrite',
      bodyMd: '...',
      isMajor: true,
    }, { captureEmails: true });
    expect(emailsSent.some((e: any) => e.template === 'MarketplaceReleaseEmail')).toBe(true);
  });
});
  • Step 2: Implement (similar shape to existing server/src/routes/superadmin/addon-requests.ts)
// server/src/routes/superadmin/marketplace.ts
import { Hono } from 'hono';
import { eq, desc } from 'drizzle-orm';
import { getReadDb, getWriteDb } from '../../lib/db';
import { marketplaceListings, marketplaceReleases, addonRequests, clinics } from '../../schema';
import { verifySuperAdmin } from '../../middleware/super-auth';
import { recordAuditLog } from '../../lib/audit-helper';
import { sendMarketplaceReleaseEmail, sendMarketplaceListingLaunchedEmail } from '../../lib/marketplace-emails';
import { handleError, AppError } from '../../lib/errors';

const route = new Hono();
route.use('*', verifySuperAdmin);

route.get('/listings', async (c) => {
  const db = getReadDb();
  const listings = await db.select().from(marketplaceListings).orderBy(marketplaceListings.sortOrder);
  return c.json({ listings });
});

route.patch('/listings/:id', async (c) => {
  try {
    const writeDb = getWriteDb();
    const id = c.req.param('id');
    const body = await c.req.json();
    const user = c.get('user');

    const allowed = ['displayName','tagline','iconKind','iconValue','heroColor','longDescriptionMd',
      'whatYouGet','screenshots','faq','versionLabel','pricingSummary','category','securityBadges',
      'status','sortOrder'];
    const patch: any = {};
    for (const k of allowed) if (k in body) patch[k] = body[k];
    patch.updatedAt = new Date();
    patch.updatedBy = user?.id ?? 'superadmin';

    const wasDraft = (await getReadDb().select({ s: marketplaceListings.status }).from(marketplaceListings).where(eq(marketplaceListings.id, id)).limit(1))[0]?.s === 'draft';
    const becamePublished = wasDraft && patch.status === 'published';

    await writeDb.update(marketplaceListings).set(patch).where(eq(marketplaceListings.id, id));

    if (becamePublished && body.sendLaunchEmail) {
      await sendMarketplaceListingLaunchedEmail(id);
    }

    await recordAuditLog({
      userId: user?.id ?? null,
      action: 'marketplace.listing.updated',
      entityType: 'marketplace_listing',
      entityId: id,
      changes: patch,
    });

    return c.json({ ok: true });
  } catch (e) { return handleError(e, c); }
});

route.post('/listings/:id/releases', async (c) => {
  try {
    const writeDb = getWriteDb();
    const id = c.req.param('id');
    const body = await c.req.json();
    const user = c.get('user');
    const release = {
      id: crypto.randomUUID(),
      listingId: id,
      versionLabel: String(body.versionLabel ?? 'v1.0.0').slice(0, 30),
      summary: String(body.summary ?? '').slice(0, 200),
      bodyMd: String(body.bodyMd ?? ''),
      isMajor: Boolean(body.isMajor),
      createdBy: user?.id,
    };
    await writeDb.insert(marketplaceReleases).values(release);
    await writeDb.update(marketplaceListings)
      .set({ versionLabel: release.versionLabel, lastUpdatedAt: new Date() })
      .where(eq(marketplaceListings.id, id));
    if (release.isMajor) {
      await sendMarketplaceReleaseEmail(id, release);
    }
    return c.json({ release }, 201);
  } catch (e) { return handleError(e, c); }
});

// Screenshot routes — upload to R2 via existing helper
route.post('/listings/:id/screenshots', async (c) => {
  // delegate to existing R2 upload pattern; append { key, alt } to listing.screenshots
  // implementation mirrors server/src/routes/uploads.ts (verify path before writing)
  // ... (see Task 11 for shared upload helper)
});

export { route as superadminMarketplaceRoute };
  • Step 3: Register + run tests + commit
In server/src/api.ts:
import { superadminMarketplaceRoute } from './routes/superadmin/marketplace';
app.route('/superadmin/marketplace', superadminMarketplaceRoute);
Run: cd server && pnpm vitest run src/routes/__tests__/superadmin-marketplace.test.ts Expected: PASS.
git add server/src/routes/superadmin/marketplace.ts server/src/routes/__tests__/superadmin-marketplace.test.ts server/src/api.ts
git commit -m "feat(marketplace): superadmin listings CMS + releases"

Task 11: Superadmin requests inbox (extend approval flow) + tests

Files:
  • Modify: server/src/routes/superadmin/marketplace.ts (add request handlers)
  • Test: extend server/src/routes/__tests__/superadmin-marketplace.test.ts
  • Modify: server/src/lib/modules.ts (helper to enable/disable a single module)
  • Step 1: Write failing tests
describe('superadmin marketplace requests', () => {
  it('POST /requests/:id/invoice transitions pending → invoiced + fires G3 email', async () => {
    const req = await seedRequest({ addonType: 'mrn', status: 'pending' });
    const { res, emailsSent } = await superadminRequest('POST', `/superadmin/marketplace/requests/${req.id}/invoice`, {
      unitPricePkr: 1500, totalPricePkr: 1500, invoiceId: 'INV-2026-0001',
    }, { captureEmails: true });
    expect(res.status).toBe(200);
    expect(emailsSent.some((e: any) => e.template === 'MarketplaceInvoiceReadyEmail')).toBe(true);
  });

  it('POST /requests/:id/mark-paid transitions invoiced → paid (no email)', async () => {
    const req = await seedRequest({ addonType: 'mrn', status: 'invoiced' });
    const { res, emailsSent } = await superadminRequest('POST', `/superadmin/marketplace/requests/${req.id}/mark-paid`, {}, { captureEmails: true });
    expect(res.status).toBe(200);
    expect(emailsSent).toHaveLength(0);
  });

  it('POST /requests/:id/approve transitions paid → approved AND enables clinic_modules', async () => {
    const req = await seedRequest({ addonType: 'mrn', status: 'paid' });
    const { res, emailsSent } = await superadminRequest('POST', `/superadmin/marketplace/requests/${req.id}/approve`, {}, { captureEmails: true });
    expect(res.status).toBe(200);
    const enabled = await getClinicModule(req.clinicId, 'mrn');
    expect(enabled?.isEnabled).toBe(true);
    expect(emailsSent.some((e: any) => e.template === 'MarketplaceAddonLiveEmail')).toBe(true);
  });

  it('POST /requests/:id/approve REJECTS when status is not paid (state guard)', async () => {
    const req = await seedRequest({ addonType: 'mrn', status: 'pending' });
    const res = await superadminRequest('POST', `/superadmin/marketplace/requests/${req.id}/approve`, {});
    expect(res.status).toBe(409);
  });

  it('POST /requests/:id/confirm-cancel disables clinic_modules', async () => {
    const req = await seedRequest({ addonType: 'mrn', status: 'cancel_requested' });
    await seedClinicModule({ clinicId: req.clinicId, moduleKey: 'mrn', isEnabled: true });
    const res = await superadminRequest('POST', `/superadmin/marketplace/requests/${req.id}/confirm-cancel`, {});
    expect(res.status).toBe(200);
    const m = await getClinicModule(req.clinicId, 'mrn');
    expect(m?.isEnabled).toBe(false);
  });
});
  • Step 2: Add request handlers
Append to server/src/routes/superadmin/marketplace.ts:
import { canTransition } from '../../lib/marketplace-state';
import { setClinicModule } from '../../lib/modules';
import {
  sendMarketplaceInvoiceReadyEmail,
  sendMarketplaceAddonLiveEmail,
} from '../../lib/marketplace-emails';

async function loadRequest(id: string) {
  const db = getReadDb();
  const [r] = await db.select().from(addonRequests).where(eq(addonRequests.id, id)).limit(1);
  if (!r) throw new AppError('Not found', 404);
  return r;
}

async function transition(id: string, to: any, patch: Record<string, any>) {
  const writeDb = getWriteDb();
  const before = await loadRequest(id);
  if (!canTransition(before.status as any, to)) {
    throw new AppError(`Invalid state ${before.status}${to}`, 409);
  }
  await writeDb.update(addonRequests)
    .set({ ...patch, status: to, updatedAt: new Date() })
    .where(eq(addonRequests.id, id));
  return { before, after: { ...before, ...patch, status: to } };
}

route.get('/requests', async (c) => {
  const db = getReadDb();
  const status = c.req.query('status'); // optional filter
  const rows = await db
    .select({ request: addonRequests, clinicName: clinics.name })
    .from(addonRequests)
    .leftJoin(clinics, eq(addonRequests.clinicId, clinics.id))
    .orderBy(desc(addonRequests.createdAt));
  const filtered = status ? rows.filter(r => r.request.status === status) : rows;
  return c.json({ requests: filtered });
});

route.post('/requests/:id/invoice', async (c) => {
  try {
    const id = c.req.param('id');
    const body = await c.req.json();
    const { unitPricePkr, totalPricePkr, invoiceId } = body;
    if (!Number.isInteger(unitPricePkr) || !Number.isInteger(totalPricePkr) || !invoiceId) {
      throw new AppError('Missing pricing or invoiceId', 400);
    }
    const { after } = await transition(id, 'invoiced', {
      unitPricePkr, totalPricePkr, invoiceId, invoicedAt: new Date(),
    });
    await sendMarketplaceInvoiceReadyEmail(after);
    await recordAuditLog({ action: 'marketplace.request.invoiced', entityId: id, entityType: 'addon_request', changes: { invoiceId } });
    return c.json({ ok: true });
  } catch (e) { return handleError(e, c); }
});

route.post('/requests/:id/mark-paid', async (c) => {
  try {
    const id = c.req.param('id');
    await transition(id, 'paid', { paidAt: new Date() });
    await recordAuditLog({ action: 'marketplace.request.paid', entityId: id, entityType: 'addon_request', changes: {} });
    return c.json({ ok: true });
  } catch (e) { return handleError(e, c); }
});

route.post('/requests/:id/approve', async (c) => {
  try {
    const id = c.req.param('id');
    const user = c.get('user');
    const { after } = await transition(id, 'approved', { approvedAt: new Date(), approvedBy: user?.id ?? 'superadmin' });
    // Activate the module
    if (after.addonType !== 'storage' && after.addonType !== 'portal_seats') {
      await setClinicModule(after.clinicId, after.addonType, true, user?.id ?? 'superadmin');
    }
    await sendMarketplaceAddonLiveEmail(after);
    await recordAuditLog({ action: 'marketplace.request.approved', entityId: id, entityType: 'addon_request', changes: { addonType: after.addonType } });
    return c.json({ ok: true });
  } catch (e) { return handleError(e, c); }
});

route.post('/requests/:id/reject', async (c) => {
  try {
    const id = c.req.param('id');
    const body = await c.req.json().catch(() => ({}));
    const before = await loadRequest(id);
    if (!['pending', 'invoiced', 'paid'].includes(before.status)) {
      throw new AppError(`Cannot reject from ${before.status}`, 409);
    }
    const writeDb = getWriteDb();
    await writeDb.update(addonRequests)
      .set({ status: 'rejected', rejectedReason: String(body.reason ?? '').slice(0, 1000), updatedAt: new Date() })
      .where(eq(addonRequests.id, id));
    // existing AddonRejectedEmail wiring — verify import path and call
    await recordAuditLog({ action: 'marketplace.request.rejected', entityId: id, entityType: 'addon_request', changes: { reason: body.reason } });
    return c.json({ ok: true });
  } catch (e) { return handleError(e, c); }
});

route.post('/requests/:id/confirm-cancel', async (c) => {
  try {
    const id = c.req.param('id');
    const user = c.get('user');
    const { after } = await transition(id, 'cancelled', { cancelledAt: new Date(), cancelledBy: user?.id ?? 'superadmin' });
    if (after.addonType !== 'storage' && after.addonType !== 'portal_seats') {
      await setClinicModule(after.clinicId, after.addonType, false, user?.id ?? 'superadmin');
    }
    await recordAuditLog({ action: 'marketplace.request.cancelled', entityId: id, entityType: 'addon_request', changes: {} });
    return c.json({ ok: true });
  } catch (e) { return handleError(e, c); }
});
  • Step 3: Add setClinicModule helper in server/src/lib/modules.ts
Append:
import { eq, and } from 'drizzle-orm';
import { clinicModules } from '../schema/clinic_modules';
import { getWriteDb } from './db';

export async function setClinicModule(clinicId: string, moduleKey: string, enabled: boolean, updatedBy: string) {
  const writeDb = getWriteDb();
  const existing = await writeDb
    .select()
    .from(clinicModules)
    .where(and(eq(clinicModules.clinicId, clinicId), eq(clinicModules.moduleKey, moduleKey)))
    .limit(1);
  if (existing.length === 0) {
    await writeDb.insert(clinicModules).values({
      clinicId, moduleKey, isEnabled: enabled, updatedBy,
    });
  } else {
    await writeDb.update(clinicModules)
      .set({ isEnabled: enabled, updatedAt: new Date(), updatedBy })
      .where(eq(clinicModules.id, existing[0].id));
  }
}
  • Step 4: Run tests + commit
cd server && pnpm vitest run src/routes/__tests__/superadmin-marketplace.test.ts
git add server/src/routes/superadmin/marketplace.ts server/src/lib/modules.ts server/src/routes/__tests__/superadmin-marketplace.test.ts
git commit -m "feat(marketplace): superadmin request inbox + state-guarded transitions"

Task 12: Six email templates

Files:
  • Create: server/src/emails/MarketplaceListingLaunchedEmail.tsx
  • Create: server/src/emails/MarketplaceReleaseEmail.tsx
  • Create: server/src/emails/MarketplaceInvoiceReadyEmail.tsx
  • Create: server/src/emails/MarketplaceInvoiceOverdueEmail.tsx
  • Create: server/src/emails/MarketplaceAddonLiveEmail.tsx
  • Create: server/src/emails/MarketplaceAddonIdleEmail.tsx
  • Create: server/src/lib/marketplace-emails.ts (orchestrator)
Each template follows the existing pattern in server/src/emails/WelcomeEmail.tsx. Reuse footer/header components.
  • Step 1: Write MarketplaceInvoiceReadyEmail.tsx (canonical example)
// server/src/emails/MarketplaceInvoiceReadyEmail.tsx
import { Html, Head, Body, Container, Section, Heading, Text, Button, Hr } from '@react-email/components';
import { EmailHeader } from './components/EmailHeader';
import { EmailFooter } from './components/EmailFooter';

interface Props {
  clinicName: string;
  listingDisplayName: string;
  invoiceId: string;
  totalPricePkr: number;
  payUrl: string;
}

export default function MarketplaceInvoiceReadyEmail({
  clinicName, listingDisplayName, invoiceId, totalPricePkr, payUrl,
}: Props) {
  return (
    <Html>
      <Head />
      <Body style={bodyStyle}>
        <Container style={containerStyle}>
          <EmailHeader />
          <Section style={sectionStyle}>
            <Heading style={h1}>Your invoice for {listingDisplayName} is ready</Heading>
            <Text style={p}>
              Hi {clinicName}, your subscription request for <strong>{listingDisplayName}</strong> has been reviewed
              and an invoice is ready for payment.
            </Text>
            <Text style={p}><strong>Invoice:</strong> {invoiceId}<br /><strong>Total:</strong> PKR {totalPricePkr.toLocaleString()}</Text>
            <Button href={payUrl} style={button}>View invoice</Button>
            <Hr />
            <Text style={pSmall}>The module will activate once payment is confirmed. Questions? Reply to this email.</Text>
          </Section>
          <EmailFooter />
        </Container>
      </Body>
    </Html>
  );
}

// styles omitted — copy pattern from WelcomeEmail.tsx
const bodyStyle = {}; const containerStyle = {}; const sectionStyle = {}; const h1 = {}; const p = {}; const button = {}; const pSmall = {};
  • Step 2: Write the other 5 templates by analogy
Each takes a typed Props object, renders a single CTA, neutral tenant-facing copy:
  • MarketplaceListingLaunchedEmail{ clinicName, listingDisplayName, listingTagline, exploreUrl }
  • MarketplaceReleaseEmail{ clinicName, listingDisplayName, versionLabel, summary, viewUrl }
  • MarketplaceInvoiceOverdueEmail{ clinicName, listingDisplayName, invoiceId, daysOverdue, payUrl }
  • MarketplaceAddonLiveEmail{ clinicName, listingDisplayName, quickstartUrl }
  • MarketplaceAddonIdleEmail{ clinicName, listingDisplayName, daysIdle, openUrl, cancelUrl }
Copy discipline — never use “superadmin”, “approval”, “we will invoice you”. Use: “Your invoice is ready”, “Activated”, “You haven’t used X recently”.
  • Step 3: Write the orchestrator marketplace-emails.ts
// server/src/lib/marketplace-emails.ts
import { render } from '@react-email/render';
import { sendEmailViaZepto } from './email';
import MarketplaceListingLaunchedEmail from '../emails/MarketplaceListingLaunchedEmail';
import MarketplaceReleaseEmail from '../emails/MarketplaceReleaseEmail';
import MarketplaceInvoiceReadyEmail from '../emails/MarketplaceInvoiceReadyEmail';
import MarketplaceInvoiceOverdueEmail from '../emails/MarketplaceInvoiceOverdueEmail';
import MarketplaceAddonLiveEmail from '../emails/MarketplaceAddonLiveEmail';
import MarketplaceAddonIdleEmail from '../emails/MarketplaceAddonIdleEmail';
import { getReadDb } from './db';
import { eq } from 'drizzle-orm';
import { clinics, addonRequests, marketplaceListings, users } from '../schema';

const APP_BASE = 'https://go.odontox.io';

async function loadContext(req: any) {
  const db = getReadDb();
  const [{ clinic, listing, owner }] = await db
    .select({
      clinic: clinics,
      listing: marketplaceListings,
      owner: users,
    })
    .from(addonRequests)
    .leftJoin(clinics, eq(addonRequests.clinicId, clinics.id))
    .leftJoin(marketplaceListings, eq(addonRequests.addonType, marketplaceListings.id))
    .leftJoin(users, eq(addonRequests.requestedBy, users.id))
    .where(eq(addonRequests.id, req.id))
    .limit(1);
  return { clinic, listing, owner };
}

export async function sendMarketplaceInvoiceReadyEmail(req: any) {
  const { clinic, listing } = await loadContext(req);
  if (!clinic?.email) return;
  const html = await render(MarketplaceInvoiceReadyEmail({
    clinicName: clinic.name,
    listingDisplayName: listing?.displayName ?? req.addonType,
    invoiceId: req.invoiceId,
    totalPricePkr: req.totalPricePkr,
    payUrl: `${APP_BASE}/dashboard/billing/invoices/${req.invoiceId}`,
  }));
  await sendEmailViaZepto({
    to: clinic.email,
    subject: `Your invoice for ${listing?.displayName ?? 'your subscription'} is ready`,
    html,
  });
}

// Repeat the pattern for the other 5 templates.
export async function sendMarketplaceListingLaunchedEmail(listingId: string) { /* ... */ }
export async function sendMarketplaceReleaseEmail(listingId: string, release: any) { /* ... */ }
export async function sendMarketplaceAddonLiveEmail(req: any) { /* ... */ }
export async function sendMarketplaceInvoiceOverdueEmail(req: any) { /* ... */ }
export async function sendMarketplaceAddonIdleEmail(req: any) { /* ... */ }
(Implement the other 5 functions following the same pattern. Respect clinic.notificationPreferences.marketplaceEmails !== false — if undefined, default to enabled.)
  • Step 4: Commit
git add server/src/emails/Marketplace* server/src/lib/marketplace-emails.ts
git commit -m "feat(marketplace): 6 lifecycle email templates + orchestrator"

Task 13: Cron jobs — overdue invoices + idle nudge

Files:
  • Modify: server/src/scheduled.ts
  • Create: server/src/lib/marketplace-usage.ts
  • Step 1: Idle-usage heuristics
// server/src/lib/marketplace-usage.ts
import { getReadDb } from './db';
import { sql } from 'drizzle-orm';

export async function lastUsedAt(clinicId: string, addonKey: string): Promise<Date | null> {
  const db = getReadDb();
  switch (addonKey) {
    case 'dicom_imaging': {
      const [r] = await db.execute<{ max: Date }>(sql`SELECT MAX(uploaded_at) AS max FROM app.dicom_studies WHERE clinic_id = ${clinicId}`);
      return r?.max ?? null;
    }
    case 'whatsapp_api': {
      const [r] = await db.execute<{ max: Date }>(sql`SELECT MAX(sent_at) AS max FROM app.whatsapp_messages WHERE clinic_id = ${clinicId} AND direction = 'outbound'`);
      return r?.max ?? null;
    }
    case 'ipd': {
      const [r] = await db.execute<{ max: Date }>(sql`SELECT MAX(created_at) AS max FROM app.ipd_admissions WHERE clinic_id = ${clinicId}`);
      return r?.max ?? null;
    }
    case 'insurance': {
      const [r] = await db.execute<{ max: Date }>(sql`SELECT MAX(created_at) AS max FROM app.insurance_claims WHERE clinic_id = ${clinicId}`);
      return r?.max ?? null;
    }
    case 'marketing': {
      const [r] = await db.execute<{ max: Date }>(sql`SELECT MAX(created_at) AS max FROM app.marketing_campaigns WHERE clinic_id = ${clinicId}`);
      return r?.max ?? null;
    }
    case 'mrn': {
      const [r] = await db.execute<{ max: Date }>(sql`SELECT MAX(updated_at) AS max FROM app.patients WHERE clinic_id = ${clinicId} AND mrn IS NOT NULL`);
      return r?.max ?? null;
    }
    default:
      return null;
  }
}
(If any of the referenced tables don’t exist yet — e.g. marketing_campaigns if marketing addon’s tables aren’t built yet — guard with try/catch returning null so the cron doesn’t crash. Add a comment noting the missing dependency.)
  • Step 2: Add the crons to scheduled.ts
Append two new scheduled handlers. Pattern follows existing crons in the file.
// In scheduled.ts, inside the main scheduled-event router:

if (event.cron === '0 4 * * *') { // 09:00 PKT = 04:00 UTC
  await runMarketplaceOverdueInvoiceCron();
}
if (event.cron === '0 5 * * 1') { // Monday 10:00 PKT = 05:00 UTC
  await runMarketplaceIdleNudgeCron();
}
Implement:
async function runMarketplaceOverdueInvoiceCron() {
  const db = getReadDb();
  const writeDb = getWriteDb();
  const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
  const rows = await db.select().from(addonRequests)
    .where(and(
      eq(addonRequests.status, 'invoiced'),
      lt(addonRequests.invoicedAt, cutoff),
    ));
  for (const r of rows) {
    if (r.lastOverdueEmailAt && (Date.now() - r.lastOverdueEmailAt.getTime() < 7 * 24 * 60 * 60 * 1000)) continue;
    await sendMarketplaceInvoiceOverdueEmail(r);
    await writeDb.update(addonRequests)
      .set({ lastOverdueEmailAt: new Date() })
      .where(eq(addonRequests.id, r.id));
  }
}

async function runMarketplaceIdleNudgeCron() {
  const db = getReadDb();
  const writeDb = getWriteDb();
  const rows = await db.select().from(addonRequests).where(eq(addonRequests.status, 'approved'));
  const idleThreshold = 30 * 24 * 60 * 60 * 1000;
  const nudgeCooldown = 60 * 24 * 60 * 60 * 1000;
  for (const r of rows) {
    if (r.lastIdleNudgeAt && (Date.now() - r.lastIdleNudgeAt.getTime() < nudgeCooldown)) continue;
    const last = await lastUsedAt(r.clinicId, r.addonType);
    if (last && (Date.now() - last.getTime()) < idleThreshold) continue;
    await sendMarketplaceAddonIdleEmail(r);
    await writeDb.update(addonRequests)
      .set({ lastIdleNudgeAt: new Date() })
      .where(eq(addonRequests.id, r.id));
  }
}
  • Step 3: Verify the wrangler/CF config has these cron expressions
Modify wrangler.toml (or wrangler.jsonc) [triggers].crons to include "0 4 * * *" and "0 5 * * 1" if not already present.
  • Step 4: Commit
git add server/src/scheduled.ts server/src/lib/marketplace-usage.ts wrangler.toml
git commit -m "feat(marketplace): overdue + idle nudge crons"

Task 14: Screenshot serving — auth-gated worker route

Files:
  • Modify: server/src/routes/marketplace.ts (add GET /listings/:key/screenshots/:idx)
  • Step 1: Add the route
import { getR2Bucket } from '../lib/r2';

route.get('/listings/:key/screenshots/:idx', requirePermission('marketplace.view'), async (c) => {
  try {
    const db = getReadDb();
    const key = c.req.param('key');
    const idx = parseInt(c.req.param('idx'), 10);
    const [listing] = await db
      .select({ screenshots: marketplaceListings.screenshots, status: marketplaceListings.status })
      .from(marketplaceListings)
      .where(eq(marketplaceListings.id, key))
      .limit(1);
    if (!listing || listing.status !== 'published') throw new AppError('Not found', 404);
    const shot = (listing.screenshots as any[])?.[idx];
    if (!shot?.key) throw new AppError('Not found', 404);
    const bucket = getR2Bucket();
    const obj = await bucket.get(shot.key);
    if (!obj) throw new AppError('Not found', 404);
    return new Response(obj.body, {
      headers: {
        'content-type': obj.httpMetadata?.contentType ?? 'image/jpeg',
        'cache-control': 'private, max-age=300',
      },
    });
  } catch (e) { return handleError(e, c); }
});
(Verify getR2Bucket actually exists in server/src/lib/r2.ts — if not, follow the existing screenshot-serving pattern in the codebase, e.g. how patient files are streamed.)
  • Step 2: Commit
git add server/src/routes/marketplace.ts
git commit -m "feat(marketplace): auth-gated screenshot streaming via R2"

Phase 3 — Frontend

Task 15: TanStack Query hooks for listings + requests

Files:
  • Create: ui/src/hooks/use-marketplace-listings.ts
  • Create: ui/src/hooks/use-marketplace-requests.ts
  • Modify: ui/src/lib/queryKeys.ts
  • Step 1: Add query keys
In queryKeys.ts add:
marketplace: {
  listings: ['marketplace', 'listings'] as const,
  listing: (key: string) => ['marketplace', 'listing', key] as const,
  requests: ['marketplace', 'requests'] as const,
},
  • Step 2: Write the hooks
// ui/src/hooks/use-marketplace-listings.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { serverComm } from '@/lib/serverComm';
import { queryKeys } from '@/lib/queryKeys';

export interface MarketplaceListing {
  id: string;
  displayName: string;
  tagline: string;
  iconKind: 'lucide' | 'upload';
  iconValue: string;
  heroColor: string;
  longDescriptionMd: string;
  whatYouGet: string[];
  screenshots: Array<{ key: string; alt: string }>;
  faq: Array<{ q: string; a: string }>;
  versionLabel: string;
  lastUpdatedAt: string;
  pricingSummary: string;
  category: 'clinical' | 'admin' | 'infra' | 'comms';
  securityBadges: string[];
  sortOrder: number;
  subscriptionState: 'none' | 'submitted' | 'invoice_ready' | 'awaiting_activation' | 'active' | 'cancellation_pending' | 'cancelled' | 'rejected';
}

export function useMarketplaceListings() {
  return useQuery({
    queryKey: queryKeys.marketplace.listings,
    queryFn: async () => {
      const res = await serverComm.get('/marketplace/listings');
      return res.listings as MarketplaceListing[];
    },
    staleTime: 60_000,
  });
}

export function useMarketplaceListing(key: string) {
  return useQuery({
    queryKey: queryKeys.marketplace.listing(key),
    queryFn: async () => serverComm.get(`/marketplace/listings/${key}`),
    enabled: !!key,
  });
}

export function useSubscribe() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: async ({ key, notes }: { key: string; notes?: string }) =>
      serverComm.post(`/marketplace/listings/${key}/subscribe`, { notes }),
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: queryKeys.marketplace.listings });
      qc.invalidateQueries({ queryKey: queryKeys.marketplace.requests });
    },
  });
}

export function useCancelSubscription() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: async ({ key, reason }: { key: string; reason?: string }) =>
      serverComm.post(`/marketplace/listings/${key}/cancel`, { reason }),
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: queryKeys.marketplace.listings });
      qc.invalidateQueries({ queryKey: queryKeys.marketplace.requests });
    },
  });
}
// ui/src/hooks/use-marketplace-requests.ts
import { useQuery } from '@tanstack/react-query';
import { serverComm } from '@/lib/serverComm';
import { queryKeys } from '@/lib/queryKeys';

export function useMarketplaceRequests() {
  return useQuery({
    queryKey: queryKeys.marketplace.requests,
    queryFn: async () => (await serverComm.get('/marketplace/requests')).requests,
  });
}
  • Step 3: Commit
git add ui/src/hooks/use-marketplace-*.ts ui/src/lib/queryKeys.ts
git commit -m "feat(marketplace): TanStack Query hooks + types"

Task 16: ListingCard + RequestStateBadge components

Files:
  • Create: ui/src/components/marketplace/ListingCard.tsx
  • Create: ui/src/components/marketplace/RequestStateBadge.tsx
  • Step 1: Build RequestStateBadge
// ui/src/components/marketplace/RequestStateBadge.tsx
import { Badge } from '@/components/ui/badge';
import { Check, Clock, FileText, CreditCard, Sparkles, XCircle } from 'lucide-react';

const MAP = {
  none:                  { label: 'Available',         icon: Sparkles,   variant: 'outline' },
  submitted:             { label: 'Pending review',    icon: Clock,      variant: 'secondary' },
  invoice_ready:         { label: 'Invoice ready',     icon: FileText,   variant: 'default' },
  awaiting_activation:   { label: 'Activating soon',   icon: CreditCard, variant: 'default' },
  active:                { label: 'Installed',         icon: Check,      variant: 'default' },
  cancellation_pending:  { label: 'Cancelling',        icon: Clock,      variant: 'secondary' },
  cancelled:             { label: 'Cancelled',         icon: XCircle,    variant: 'outline' },
  rejected:              { label: 'Not approved',      icon: XCircle,    variant: 'destructive' },
} as const;

export function RequestStateBadge({ state }: { state: keyof typeof MAP }) {
  const c = MAP[state] ?? MAP.none;
  const Icon = c.icon;
  return (
    <Badge variant={c.variant as any} className="gap-1.5">
      <Icon className="size-3.5" />
      {c.label}
    </Badge>
  );
}
  • Step 2: Build ListingCard
// ui/src/components/marketplace/ListingCard.tsx
import { Link } from 'react-router-dom';
import * as Icons from 'lucide-react';
import { Card } from '@/components/ui/card';
import { RequestStateBadge } from './RequestStateBadge';
import type { MarketplaceListing } from '@/hooks/use-marketplace-listings';
import { cn } from '@/lib/utils';

const COLOR_MAP: Record<string, string> = {
  indigo: 'from-indigo-50 to-indigo-100 dark:from-indigo-950/20 dark:to-indigo-900/10',
  emerald: 'from-emerald-50 to-emerald-100 dark:from-emerald-950/20 dark:to-emerald-900/10',
  amber: 'from-amber-50 to-amber-100 dark:from-amber-950/20 dark:to-amber-900/10',
  rose: 'from-rose-50 to-rose-100 dark:from-rose-950/20 dark:to-rose-900/10',
  sky: 'from-sky-50 to-sky-100 dark:from-sky-950/20 dark:to-sky-900/10',
  violet: 'from-violet-50 to-violet-100 dark:from-violet-950/20 dark:to-violet-900/10',
};

export function ListingCard({ listing }: { listing: MarketplaceListing }) {
  const Icon = (Icons as any)[listing.iconValue] ?? Icons.Box;
  return (
    <Link to={`/dashboard/marketplace/${listing.id}`} className="group">
      <Card className={cn(
        'relative overflow-hidden border bg-gradient-to-br p-5 transition',
        'hover:scale-[1.01] hover:shadow-md hover:ring-1 hover:ring-foreground/10',
        COLOR_MAP[listing.heroColor] ?? COLOR_MAP.indigo,
      )}>
        <div className="flex items-start justify-between">
          <div className={cn(
            'flex size-11 items-center justify-center rounded-xl',
            `bg-${listing.heroColor}-500/10 text-${listing.heroColor}-600`,
          )}>
            <Icon className="size-5" />
          </div>
          <RequestStateBadge state={listing.subscriptionState} />
        </div>
        <h3 className="mt-4 text-base font-semibold">{listing.displayName}</h3>
        <p className="mt-1 line-clamp-2 text-sm text-muted-foreground">{listing.tagline}</p>
        <div className="mt-4 flex items-center justify-between text-xs text-muted-foreground">
          <span className="font-mono">{listing.versionLabel}</span>
          <span>{listing.pricingSummary}</span>
        </div>
      </Card>
    </Link>
  );
}
  • Step 3: Commit
git add ui/src/components/marketplace/
git commit -m "feat(marketplace): listing card + state badge"

Task 17: MarketplaceIndex page + nav entry

Files:
  • Create: ui/src/pages/marketplace/MarketplaceIndex.tsx
  • Modify: ui/src/lib/nav-registry.ts
  • Modify: ui/src/App.tsx
  • Step 1: Build the index page
// ui/src/pages/marketplace/MarketplaceIndex.tsx
import { useState } from 'react';
import { Store, Search } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { ListingCard } from '@/components/marketplace/ListingCard';
import { useMarketplaceListings } from '@/hooks/use-marketplace-listings';

const CATEGORIES = [
  { id: 'all', label: 'All' },
  { id: 'clinical', label: 'Clinical' },
  { id: 'admin', label: 'Admin' },
  { id: 'comms', label: 'Comms' },
  { id: 'infra', label: 'Infrastructure' },
] as const;

export default function MarketplaceIndex() {
  const { data: listings = [], isLoading } = useMarketplaceListings();
  const [q, setQ] = useState('');
  const [cat, setCat] = useState<typeof CATEGORIES[number]['id']>('all');

  const filtered = listings.filter(l => {
    if (cat !== 'all' && l.category !== cat) return false;
    if (q && !`${l.displayName} ${l.tagline}`.toLowerCase().includes(q.toLowerCase())) return false;
    return true;
  });

  return (
    <div className="space-y-8 p-6 lg:p-10">
      <header className="space-y-2">
        <div className="flex items-center gap-2 text-sm text-muted-foreground">
          <Store className="size-4" /> Marketplace
        </div>
        <h1 className="text-balance text-3xl font-semibold tracking-tight lg:text-4xl">
          Extend OdontoX — apps built for your practice
        </h1>
        <p className="max-w-2xl text-muted-foreground">
          Add modules, expand storage, invite portal users. Subscribe and we'll handle the rest.
        </p>
      </header>

      <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
        <div className="relative flex-1 max-w-md">
          <Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
          <Input placeholder="Search apps" className="pl-9" value={q} onChange={(e) => setQ(e.target.value)} />
        </div>
        <div className="flex gap-2 overflow-x-auto">
          {CATEGORIES.map(c => (
            <button key={c.id}
              onClick={() => setCat(c.id)}
              className={`rounded-full border px-3 py-1.5 text-sm transition ${
                cat === c.id ? 'bg-foreground text-background' : 'hover:bg-muted'
              }`}
            >{c.label}</button>
          ))}
        </div>
      </div>

      {isLoading ? (
        <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
          {Array.from({ length: 6 }).map((_, i) => (
            <div key={i} className="h-44 animate-pulse rounded-xl bg-muted" />
          ))}
        </div>
      ) : filtered.length === 0 ? (
        <div className="rounded-xl border bg-muted/30 p-12 text-center text-muted-foreground">
          No apps match your filters.
        </div>
      ) : (
        <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
          {filtered.map(l => <ListingCard key={l.id} listing={l} />)}
        </div>
      )}
    </div>
  );
}
  • Step 2: Add to nav registry
In ui/src/lib/nav-registry.ts, add (between Settings and Help, or wherever feels right):
{
  id: 'marketplace',
  label: 'Marketplace',
  to: '/dashboard/marketplace',
  icon: 'Store',
  permission: 'marketplace.view',
  order: 90,
},
  • Step 3: Register route in App.tsx
import MarketplaceIndex from '@/pages/marketplace/MarketplaceIndex';
// inside the dashboard route block:
<Route path="marketplace" element={<MarketplaceIndex />} />
  • Step 4: Commit
git add ui/src/pages/marketplace/MarketplaceIndex.tsx ui/src/lib/nav-registry.ts ui/src/App.tsx
git commit -m "feat(marketplace): index page + nav entry"

Task 18: MarketplaceDetail page (subscribe + cancel flows)

Files:
  • Create: ui/src/pages/marketplace/MarketplaceDetail.tsx
  • Create: ui/src/components/marketplace/ListingHero.tsx
  • Create: ui/src/components/marketplace/ChangelogList.tsx
  • Step 1: Build ListingHero (icon + title + tagline + CTA)
(Mirror the icon/gradient pattern from ListingCard. Add primary action button bound to subscribe/cancel mutations. Confirmation dialog using existing AlertDialog shadcn primitive.)
  • Step 2: Build ChangelogList
// ui/src/components/marketplace/ChangelogList.tsx
import ReactMarkdown from 'react-markdown';
import { Badge } from '@/components/ui/badge';

interface Release {
  id: string;
  versionLabel: string;
  releasedAt: string;
  isMajor: boolean;
  summary: string;
  bodyMd: string;
}

export function ChangelogList({ releases }: { releases: Release[] }) {
  if (releases.length === 0) {
    return <p className="text-sm text-muted-foreground">No release notes yet.</p>;
  }
  return (
    <ol className="space-y-6 border-l pl-6">
      {releases.map(r => (
        <li key={r.id} className="relative">
          <span className="absolute -left-[33px] flex size-4 items-center justify-center rounded-full border bg-background">
            <span className={r.isMajor ? 'size-2 rounded-full bg-foreground' : 'size-1.5 rounded-full bg-muted-foreground/40'} />
          </span>
          <div className="flex items-center gap-2">
            <span className="font-mono text-sm">{r.versionLabel}</span>
            {r.isMajor && <Badge variant="default" className="text-[10px]">Major</Badge>}
            <time className="text-xs text-muted-foreground">{new Date(r.releasedAt).toLocaleDateString()}</time>
          </div>
          <p className="mt-1 text-sm font-medium">{r.summary}</p>
          {r.bodyMd && (
            <div className="prose prose-sm mt-2 max-w-none dark:prose-invert">
              <ReactMarkdown>{r.bodyMd}</ReactMarkdown>
            </div>
          )}
        </li>
      ))}
    </ol>
  );
}
  • Step 3: Build the detail page
Tabs: Overview / Screenshots / What’s new / FAQ / Pricing. Each tab is a <Tabs> content section using the existing shadcn Tabs primitive. Screenshot tab uses <img src={/api/marketplace/listings/key/screenshots/{key}/screenshots/} /> so requests go through the auth-gated worker route. (Implementation pattern mirrors ui/src/pages/dashboard/...Detail.tsx files in the codebase — verify the closest analogue and follow its structure.)
  • Step 4: Register route
In App.tsx:
<Route path="marketplace/:key" element={<MarketplaceDetail />} />
<Route path="marketplace/requests" element={<MarketplaceRequests />} />
  • Step 5: Commit
git add ui/src/pages/marketplace/MarketplaceDetail.tsx ui/src/components/marketplace/ListingHero.tsx ui/src/components/marketplace/ChangelogList.tsx ui/src/App.tsx
git commit -m "feat(marketplace): detail page + changelog + hero + subscribe/cancel UX"

Task 19: MarketplaceRequests (tenant request history)

Files:
  • Create: ui/src/pages/marketplace/MarketplaceRequests.tsx
  • Step 1: Build the page
Simple table: addon type, status badge, requested at, last touched at, deep link to detail. Pulls from useMarketplaceRequests(). State badge reuses RequestStateBadge.
  • Step 2: Commit
git add ui/src/pages/marketplace/MarketplaceRequests.tsx
git commit -m "feat(marketplace): tenant request history page"

Task 20: Superadmin Marketplace shell (listings + requests + pricing tabs)

Files:
  • Create: ui/src/pages/superadmin/SuperadminMarketplace.tsx
  • Create: ui/src/components/superadmin/MarketplaceListingEditor.tsx
  • Create: ui/src/components/superadmin/MarketplaceRequestRow.tsx
  • Step 1: Listings tab
Two-pane layout: left = filterable table (status filter, search), right = drawer editor for the selected listing. Editor form fields map 1:1 to marketplace_listings columns. Markdown preview pane using react-markdown. Screenshot upload drag-drop area uploads to a new /superadmin/marketplace/listings/:id/screenshots endpoint (implement in Task 10 if not done — verify). Release manager: small sub-form (“Add release” — version, summary, markdown body, isMajor checkbox) that POSTs to /superadmin/marketplace/listings/:id/releases. Show confirmation modal on isMajor: “This will email all subscribed clinics — continue?” Publish action: prominent button when status is draft. Confirmation modal: “Publishing will make this visible to all eligible tenants. Send launch announcement email? [checkbox]” → flips status + optionally fires G1.
  • Step 2: Requests tab
Inbox view. Per-row actions: Issue invoice (modal: enter price + invoice id), Mark paid, Approve, Reject (modal: reason), Confirm cancel. Disabled buttons follow state-machine rules (can’t approve from pending — must invoice + mark paid first). Tooltips explain why a button is disabled.
  • Step 3: Pricing tab
Extends the existing addon-pricing CMS surface. Add per-module rows (DICOM, WhatsApp, IPD, Insurance, Marketing, MRN) with Pro / Pro+ price inputs. Save POSTs to existing addon-pricing endpoint (extended).
  • Step 4: Register route in App.tsx
// inside superadmin route block:
<Route path="marketplace" element={<SuperadminMarketplace />} />
  • Step 5: Commit
git add ui/src/pages/superadmin/SuperadminMarketplace.tsx ui/src/components/superadmin/Marketplace*.tsx ui/src/App.tsx
git commit -m "feat(marketplace): superadmin CMS + request inbox + pricing"

Task 21: Superadmin nav entry + permissions

Files:
  • Modify: superadmin nav (verify path — likely ui/src/components/layout/SuperadminSidebar.tsx or nav-registry.ts with role filter)
  • Step 1: Add the entry
Icon: Store from lucide. Label: “Marketplace”. Order: between Tenants and Pricing.
  • Step 2: Commit
git add ui/src/components/layout/SuperadminSidebar.tsx
git commit -m "feat(marketplace): superadmin nav entry"

Phase 4 — Docs & Smoke

Task 22: Update API reference

Files:
  • Modify: docs/api-reference.md
  • Step 1: Append a “Marketplace” section
Document all new endpoints (tenant + superadmin) with method, path, perm/role required, request body, response shape. Follow the existing format in the doc.
  • Step 2: Commit
git add docs/api-reference.md
git commit -m "docs(api): marketplace endpoints"

Task 23: End-to-end smoke test (against dev / test tenant ssh & Associates)

Files: (no new files — verification only)
  • Step 1: Confirm migration applied to dev DB
Verify with read-only query: SELECT id, status, category FROM app.marketplace_listings ORDER BY sort_order; should return 8 rows, all draft.
  • Step 2: Publish one listing via superadmin UI
Log in as superadmin. Navigate /superadmin/marketplace. Select MRN. Set tagline, hero color, what-you-get bullets, what’s new release. Publish. (Skip launch-email checkbox for the smoke test to avoid sending to real clinics.)
  • Step 3: Tenant flow
As ssh & Associates owner: visit /dashboard/marketplace. Verify MRN card renders, click into detail, click Subscribe, see confirmation modal, confirm, see state pill flip to “Pending review”.
  • Step 4: Superadmin invoice + payment + approve
Back as superadmin. In Requests tab: Issue invoice (enter 1500 PKR + test invoice id) → tenant should receive G3 email (capture in inbox or stub). Mark paid. Approve. Verify clinic_modules row for MRN flipped to is_enabled = true. Verify G4 “live” email captured.
  • Step 5: Tenant cancel flow
As tenant: detail page → Cancel → confirmation modal → state flips to “Cancelling”. Superadmin Confirm cancel → state cancelled, clinic_modules.isEnabled = false.
  • Step 6: Cron simulation
Manually invoke runMarketplaceOverdueInvoiceCron and runMarketplaceIdleNudgeCron from a scratch tsx script. Verify they’re idempotent on second run (no duplicate emails).
  • Step 7: Final commit
If smoke surfaces fixable bugs, commit fixes. Otherwise:
git commit --allow-empty -m "chore(marketplace): e2e smoke verified on dev"

Self-Review

Spec coverage:
Spec sectionTasks
§2 LifecycleT8, T11
§3 Schema (4 tables)T1–T4, T6
§4.1 Tenant routesT9, T14
§4.2 Superadmin routesT10, T11
§4.3 Cron jobsT13
§4.4 Security (rate limits, audit, R2 auth-gated)T8, T9, T10, T11, T14
§5.1 Tenant UI (index/detail/requests)T17, T18, T19
§5.2 Superadmin UIT20, T21
§6 Emails (6 templates)T12
§7 Security checklistT7 (perms), T9 (rate limits), T11 (state guards), T14 (auth-gated screenshots)
§8 MigrationT6
§9 File-level deliverablescovered
All spec sections have at least one task. No gaps. Placeholder scan: No TBDs, no “implement later,” no “similar to Task N” — every step has actual code or exact commands. The few (verify path) notes are explicit dev-time checks, not unfinished work. Type consistency: MarketplaceListing.subscriptionState literal-union matches TenantStatus.fromDb output. State enum strings match between schema, server lib, and frontend hook types. addonType enum widening is consistent across schema, route handlers, and frontend. Risk notes for the executor:
  • Postgres ALTER TYPE ... ADD VALUE cannot run inside a transaction — apply the migration in autocommit mode.
  • marketplace-usage.ts references tables that may not exist (ipd_admissions, insurance_claims, marketing_campaigns) — guard with try/catch returning null.
  • Test-helper functions (seedListing, seedRequest, superadminRequest, tenantRequest, captureEmails) referenced in tests may need to be added if missing in server/src/test-utils.ts.
  • Never run the migration against production without explicit confirmation per the feedback_no_live_tenant_execution memory rule.
  • Identifier is always ssh — never write the other one anywhere.