SuperNova is shipping the whole bundle now—frontend, backend routes, storage helpers, and DB migrations (Drizzle + Knex + Prisma flavors). Everything is copy/paste ready so Replit can slam dunk it and we move to the next high-level task. 💜

✅ Frontend
A) Creator “My Marketplace” (Tabbed)

Create: client/src/pages/Creator/MyMarketplacePage.tsx
(Full page for Listings, Pending, Rejected, Cancelled, Upload, Payouts)

import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Link } from "wouter";
import { toast } from "sonner";
import { BarChart3, DollarSign, ShoppingBag, Upload, Eye, X } from "lucide-react";

type CreatorAsset = {
  id: string;
  title: string;
  description?: string;
  price: number;
  salesCount: number;
  createdAt: string;
  previewUrl?: string;
  approvalStatus: "approved" | "pending" | "rejected" | "cancelled";
};

type PayoutSummary = {
  lifetimeEarningsCents: number;
  pendingPayoutCents: number;
  lastPayoutAt?: string;
};

const fmt = (cents: number = 0) => `$${(cents / 100).toFixed(2)}`;

export default function MyMarketplacePage() {
  const qc = useQueryClient();

  const { data, isLoading } = useQuery({
    queryKey: ["creator-me-marketplace"],
    queryFn: async () => {
      const res = await fetch("/api/creator/me/marketplace", { credentials: "include" });
      if (!res.ok) throw new Error("Failed to load");
      return res.json() as Promise<{
        assets: { approved: CreatorAsset[]; pending: CreatorAsset[]; rejected: CreatorAsset[]; cancelled: CreatorAsset[]; };
        stats: { totalSales: number; totalRevenueCents: number; };
      }>;
    }
  });

  const { data: payouts } = useQuery({
    queryKey: ["creator-payouts-summary"],
    queryFn: async () => {
      const res = await fetch("/api/creator/me/payouts/summary", { credentials: "include" });
      if (!res.ok) throw new Error("Failed to load payouts");
      return res.json() as Promise<PayoutSummary>;
    }
  });

  const cancelMutation = useMutation({
    mutationFn: async (id: string) => {
      const res = await fetch(`/api/creator/assets/${id}/cancel`, { method: "PATCH", credentials: "include" });
      if (!res.ok) throw new Error("Failed to cancel");
      return res.json();
    },
    onSuccess: () => {
      toast.success("Listing cancelled.");
      qc.invalidateQueries({ queryKey: ["creator-me-marketplace"] });
    },
    onError: () => toast.error("Could not cancel listing.")
  });

  const loadingCards = (
    <div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-6">
      {[...Array(6)].map((_, i) => (
        <Card key={i}><Skeleton className="aspect-video w-full" /><CardContent className="p-4"><Skeleton className="h-4 w-3/4 mb-2" /><Skeleton className="h-3 w-1/2" /></CardContent></Card>
      ))}
    </div>
  );

  const AssetGrid = ({ items, cancellable = false }: { items: CreatorAsset[]; cancellable?: boolean }) => (
    <div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-6">
      {items.map(a => (
        <Card key={a.id} className="overflow-hidden">
          <div className="aspect-video bg-gray-100 dark:bg-gray-800">
            {a.previewUrl && <img src={a.previewUrl} alt={a.title} className="w-full h-full object-cover" />}
          </div>
          <CardContent className="p-4 space-y-2">
            <div className="flex items-start justify-between">
              <div>
                <div className="font-semibold">{a.title}</div>
                <div className="text-xs text-muted-foreground">Created {new Date(a.createdAt).toLocaleDateString()}</div>
              </div>
              <Badge variant="secondary">{fmt(a.price)}</Badge>
            </div>
            <div className="text-xs text-muted-foreground">Sales: {a.salesCount}</div>
            <div className="flex gap-2">
              <Link href={`/marketplace/asset/${a.id}`}><Button variant="outline" size="sm"><Eye className="h-4 w-4 mr-2" />View</Button></Link>
              {cancellable && <Button variant="destructive" size="sm" onClick={() => cancelMutation.mutate(a.id)}><X className="h-4 w-4 mr-2" />Cancel</Button>}
            </div>
          </CardContent>
        </Card>
      ))}
    </div>
  );

  const approved = data?.assets.approved ?? [];
  const pending = data?.assets.pending ?? [];
  const rejected = data?.assets.rejected ?? [];
  const cancelled = data?.assets.cancelled ?? [];

  return (
    <div className="space-y-8">
      {/* Overview stats */}
      <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
        <Card><CardContent className="p-4 flex items-center"><DollarSign className="h-8 w-8 text-primary mr-3" /><div><div className="text-2xl font-bold">{fmt(payouts?.lifetimeEarningsCents)}</div><div className="text-sm text-muted-foreground">Lifetime Earnings</div></div></CardContent></Card>
        <Card><CardContent className="p-4 flex items-center"><ShoppingBag className="h-8 w-8 text-primary mr-3" /><div><div className="text-2xl font-bold">{approved.reduce((n, a) => n + (a.salesCount || 0), 0).toLocaleString()}</div><div className="text-sm text-muted-foreground">Sales (Active)</div></div></CardContent></Card>
        <Card><CardContent className="p-4 flex items-center"><BarChart3 className="h-8 w-8 text-primary mr-3" /><div><div className="text-2xl font-bold">{fmt(payouts?.pendingPayoutCents)}</div><div className="text-sm text-muted-foreground">Pending Payout</div></div></CardContent></Card>
      </div>

      {/* Tabs */}
      <Tabs defaultValue="listings">
        <TabsList>
          <TabsTrigger value="listings">Listings</TabsTrigger>
          <TabsTrigger value="pending">Pending</TabsTrigger>
          <TabsTrigger value="rejected">Rejected</TabsTrigger>
          <TabsTrigger value="cancelled">Cancelled</TabsTrigger>
          <TabsTrigger value="upload">Upload</TabsTrigger>
          <TabsTrigger value="payouts">Payouts</TabsTrigger>
        </TabsList>

        <TabsContent value="listings" className="mt-6">
          {isLoading ? loadingCards : (approved.length ? <AssetGrid items={approved} cancellable /> : <Card><CardContent className="p-6 text-muted-foreground">No active listings. Click Upload.</CardContent></Card>)}
        </TabsContent>

        <TabsContent value="pending" className="mt-6">
          {isLoading ? loadingCards : (pending.length ? <AssetGrid items={pending} /> : <Card><CardContent className="p-6 text-muted-foreground">Nothing pending review.</CardContent></Card>)}
        </TabsContent>

        <TabsContent value="rejected" className="mt-6">
          {isLoading ? loadingCards : (rejected.length ? <AssetGrid items={rejected} /> : <Card><CardContent className="p-6 text-muted-foreground">No rejected listings.</CardContent></Card>)}
        </TabsContent>

        <TabsContent value="cancelled" className="mt-6">
          {isLoading ? loadingCards : (cancelled.length ? <AssetGrid items={cancelled} /> : <Card><CardContent className="p-6 text-muted-foreground">No cancelled assets.</CardContent></Card>)}
        </TabsContent>

        <TabsContent value="upload" className="mt-6">
          <Card><CardContent className="p-6"><Link href="/creator/upload"><Button><Upload className="h-4 w-4 mr-2" />Go to Upload</Button></Link></CardContent></Card>
        </TabsContent>

        <TabsContent value="payouts" className="mt-6">
          <Card><CardContent className="p-6 space-y-2">
            <div>Lifetime: <strong>{fmt(payouts?.lifetimeEarningsCents)}</strong></div>
            <div>Pending: <strong>{fmt(payouts?.pendingPayoutCents)}</strong></div>
            <div className="text-sm text-muted-foreground">Last payout: {payouts?.lastPayoutAt ? new Date(payouts.lastPayoutAt).toLocaleDateString() : '—'}</div>
            <Link href="/creator/earnings"><Button variant="outline" className="mt-2">Open Earnings</Button></Link>
          </CardContent></Card>
        </TabsContent>
      </Tabs>
    </div>
  );
}

B) Admin “Marketplace Management”

Create: client/src/pages/Admin/AdminMarketplace.tsx

import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";

type Row = {
  id: string; title: string; creatorName: string; price: number; status: string; createdAt: string;
  previewUrl?: string; salesCount?: number;
};

export default function AdminMarketplace() {
  const [search, setSearch] = useState("");
  const qc = useQueryClient();

  const { data, isLoading, refetch } = useQuery({
    queryKey: ["admin-marketplace", search],
    queryFn: async () => {
      const qs = new URLSearchParams(search ? { search } : {}).toString();
      const res = await fetch(`/api/admin/marketplace/overview?${qs}`, { credentials: "include" });
      if (!res.ok) throw new Error("Failed to load");
      return res.json() as Promise<{ pending: Row[]; approved: Row[]; rejected: Row[] }>;
    }
  });

  const approve = useMutation({
    mutationFn: async (id: string) => {
      const r = await fetch(`/api/admin/marketplace/assets/${id}/approve`, { method: "PATCH", credentials: "include" });
      if (!r.ok) throw new Error("fail");
      return r.json();
    },
    onSuccess: () => { toast.success("Approved"); qc.invalidateQueries({ queryKey: ["admin-marketplace"] }); }
  });

  const reject = useMutation({
    mutationFn: async (id: string) => {
      const r = await fetch(`/api/admin/marketplace/assets/${id}/reject`, { method: "PATCH", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ reason: "Policy" }) });
      if (!r.ok) throw new Error("fail");
      return r.json();
    },
    onSuccess: () => { toast.success("Rejected"); qc.invalidateQueries({ queryKey: ["admin-marketplace"] }); }
  });

  const Section = ({ title, items, actions }: { title: string; items?: Row[]; actions?: boolean }) => (
    <Card>
      <CardHeader><CardTitle>{title} ({items?.length ?? 0})</CardTitle></CardHeader>
      <CardContent className="space-y-4">
        {(items ?? []).map(a => (
          <div key={a.id} className="flex items-center justify-between border rounded-md p-3">
            <div className="min-w-0">
              <div className="font-medium truncate">{a.title}</div>
              <div className="text-xs text-muted-foreground">{a.creatorName} • ${(a.price/100).toFixed(2)} • {new Date(a.createdAt).toLocaleDateString()}</div>
            </div>
            <div className="flex items-center gap-2">
              <Badge variant="outline">{a.status}</Badge>
              {actions && (
                <>
                  <Button size="sm" onClick={() => approve.mutate(a.id)}>Approve</Button>
                  <Button size="sm" variant="destructive" onClick={() => reject.mutate(a.id)}>Reject</Button>
                </>
              )}
            </div>
          </div>
        ))}
      </CardContent>
    </Card>
  );

  return (
    <div className="space-y-6">
      <Card>
        <CardContent className="p-4 flex gap-2">
          <Input placeholder="Search title or creator…" value={search} onChange={(e) => setSearch(e.target.value)} />
          <Button onClick={() => refetch()} disabled={isLoading}>Search</Button>
        </CardContent>
      </Card>
      <Section title="Pending Review" items={data?.pending} actions />
      <Section title="Approved" items={data?.approved} />
      <Section title="Rejected" items={data?.rejected} />
    </div>
  );
}


Add tile in Admin Dashboard (Admin Tools grid):

// In your Admin Dashboard tools grid:
<Link href="/admin/marketplace" className="block">
  <Button variant="secondary" className="w-full justify-start">🛍️ Marketplace Management</Button>
</Link>


Routes (App.tsx):

// imports
import MyMarketplacePage from "@/pages/Creator/MyMarketplacePage";
import AdminMarketplace from "@/pages/Admin/AdminMarketplace";

// routes
<Route path="/creator/my-marketplace">
  <ProtectedRoute>
    <DashboardTemplatePage title="My Marketplace"><MyMarketplacePage /></DashboardTemplatePage>
  </ProtectedRoute>
</Route>

<Route path="/admin/marketplace">
  <ProtectedRoute>
    <AdminMarketplace />
  </ProtectedRoute>
</Route>


You already have the Sidebar/App route cleanup from earlier. Keep /marketplace public but remove it from the left sidebar.

✅ Backend
A) Routes module

Create: server/routes.creator-marketplace.ts

import express from 'express';
const router = express.Router();

import { authenticateToken, authenticateAdmin } from './middleware/auth'; // use your existing
import { handleError } from './utils/errors'; // if you have one

import {
  getCreatorByUserId,
  getCreatorAssetsByCreatorId,
  getCreatorAssetById,
  updateCreatorAsset,
  getCreatorPayoutSummary,
  getAllCreatorAssetsForAdmin,
} from './storage/creatorMarketplace';

// CREATOR – overview (grouped by status)
router.get('/api/creator/me/marketplace', authenticateToken, async (req, res) => {
  try {
    const userId = req.user!.id;
    const creator = await getCreatorByUserId(userId);
    if (!creator) return res.status(403).json({ error: 'Not a creator' });

    const rows = await getCreatorAssetsByCreatorId(creator.id);
    const group = (s: string) => rows.filter(r => r.approvalStatus === s);

    res.json({
      assets: {
        approved: group('approved'),
        pending: group('pending'),
        rejected: group('rejected'),
        cancelled: group('cancelled'),
      },
      stats: {
        totalSales: rows.reduce((n, r) => n + (r.salesCount || 0), 0),
        totalRevenueCents: rows.reduce((n, r) => n + (r.totalRevenueCents || 0), 0),
      }
    });
  } catch (e) {
    handleError ? handleError(res, e, 'Failed to load creator marketplace') : res.status(500).json({ error: 'Server error' });
  }
});

// CREATOR – cancel listing
router.patch('/api/creator/assets/:id/cancel', authenticateToken, async (req, res) => {
  try {
    const userId = req.user!.id;
    const creator = await getCreatorByUserId(userId);
    if (!creator) return res.status(403).json({ error: 'Not a creator' });

    const rec = await getCreatorAssetById(req.params.id);
    if (!rec || rec.creatorId !== creator.id) return res.status(404).json({ error: 'Asset not found' });

    if (rec.approvalStatus === 'cancelled') return res.json({ success: true, asset: rec });

    const updated = await updateCreatorAsset(rec.id, { approvalStatus: 'cancelled', cancelledAt: new Date() });
    res.json({ success: true, asset: updated });
  } catch (e) {
    handleError ? handleError(res, e, 'Failed to cancel listing') : res.status(500).json({ error: 'Server error' });
  }
});

// CREATOR – payouts summary
router.get('/api/creator/me/payouts/summary', authenticateToken, async (req, res) => {
  try {
    const userId = req.user!.id;
    const creator = await getCreatorByUserId(userId);
    if (!creator) return res.status(403).json({ error: 'Not a creator' });

    const summary = await getCreatorPayoutSummary(creator.id);
    res.json(summary || { lifetimeEarningsCents: 0, pendingPayoutCents: 0, lastPayoutAt: null });
  } catch (e) {
    handleError ? handleError(res, e, 'Failed to load payouts summary') : res.status(500).json({ error: 'Server error' });
  }
});

/* ------------------- Admin ------------------- */

// overview
router.get('/api/admin/marketplace/overview', authenticateAdmin, async (req, res) => {
  try {
    const q = (req.query.search as string || '').toLowerCase();
    const rows = await getAllCreatorAssetsForAdmin(); // all, with creator names
    const filtered = q
      ? rows.filter(r =>
          (r.title || '').toLowerCase().includes(q) ||
          (r.creatorName || '').toLowerCase().includes(q)
        )
      : rows;

    res.json({
      pending: filtered.filter(r => r.approvalStatus === 'pending'),
      approved: filtered.filter(r => r.approvalStatus === 'approved'),
      rejected: filtered.filter(r => r.approvalStatus === 'rejected'),
    });
  } catch (e) {
    handleError ? handleError(res, e, 'Failed to load admin marketplace overview') : res.status(500).json({ error: 'Server error' });
  }
});

router.patch('/api/admin/marketplace/assets/:id/approve', authenticateAdmin, async (req, res) => {
  try {
    const updated = await updateCreatorAsset(req.params.id, { approvalStatus: 'approved', approvedAt: new Date() });
    res.json({ success: true, asset: updated });
  } catch (e) {
    res.status(500).json({ error: 'Failed to approve asset' });
  }
});

router.patch('/api/admin/marketplace/assets/:id/reject', authenticateAdmin, async (req, res) => {
  try {
    const updated = await updateCreatorAsset(req.params.id, { approvalStatus: 'rejected', rejectionReason: req.body?.reason || null });
    res.json({ success: true, asset: updated });
  } catch (e) {
    res.status(500).json({ error: 'Failed to reject asset' });
  }
});

export default router;


Wire it in: in your main server file (e.g., server/index.ts or server/routes.ts where you app.use), add:

import creatorMarketplaceRouter from './routes.creator-marketplace';
app.use(creatorMarketplaceRouter);

B) Storage helpers (Drizzle-style, but easy to adapt)

Create: server/storage/creatorMarketplace.ts

import { db } from '../db'; // your drizzle instance
import { eq } from 'drizzle-orm';
import { creatorAssets, creators, users } from '../schema'; // adjust paths/names to your actual schema

export async function getCreatorByUserId(userId: string) {
  const row = await db.query.creators.findFirst({
    where: (c, { eq }) => eq(c.userId, userId),
  });
  return row || null;
}

export async function getCreatorAssetsByCreatorId(creatorId: string) {
  const rows = await db.select({
      id: creatorAssets.id,
      creatorId: creatorAssets.creatorId,
      assetId: creatorAssets.assetId,
      title: creatorAssets.title,
      price: creatorAssets.priceCents, // or .price
      approvalStatus: creatorAssets.approvalStatus,
      approvedAt: creatorAssets.approvedAt,
      cancelledAt: creatorAssets.cancelledAt,
      rejectionReason: creatorAssets.rejectionReason,
      salesCount: creatorAssets.salesCount,
      totalRevenueCents: creatorAssets.totalRevenueCents,
      createdAt: creatorAssets.createdAt,
      previewUrl: creatorAssets.previewUrl,
    })
    .from(creatorAssets)
    .where(eq(creatorAssets.creatorId, creatorId));
  return rows;
}

export async function getCreatorAssetById(id: string) {
  const rows = await db.select().from(creatorAssets).where(eq(creatorAssets.id, id)).limit(1);
  return rows[0] || null;
}

export async function updateCreatorAsset(id: string, patch: Partial<typeof creatorAssets.$inferInsert>) {
  const rows = await db.update(creatorAssets).set(patch).where(eq(creatorAssets.id, id)).returning();
  return rows[0];
}

export async function getCreatorPayoutSummary(creatorId: string) {
  // If you have a payouts table, query it; else compute from creatorAssets.*
  const rows = await getCreatorAssetsByCreatorId(creatorId);
  const lifetimeEarningsCents = rows.reduce((n, r) => n + (r.totalRevenueCents || 0), 0);
  const pendingPayoutCents = 0; // implement according to your payouts logic
  return { lifetimeEarningsCents, pendingPayoutCents, lastPayoutAt: null };
}

export async function getAllCreatorAssetsForAdmin() {
  // join creators/users for display name
  const rows = await db.execute(/* sql */`
    SELECT ca.id, ca.title, ca.price_cents as "price", ca.approval_status as "approvalStatus",
           ca.created_at as "createdAt", ca.preview_url as "previewUrl", ca.sales_count as "salesCount",
           COALESCE(c.name, u.display_name, u.email) as "creatorName"
    FROM creator_assets ca
    LEFT JOIN creators c ON c.id = ca.creator_id
    LEFT JOIN users u ON u.id = c.user_id
    ORDER BY ca.created_at DESC
  `);
  // @ts-ignore drizzle returns .rows in some drivers; adjust to your db client
  return (rows.rows || rows) as Array<{
    id: string; title: string; price: number; approvalStatus: string; createdAt: string;
    previewUrl?: string; salesCount?: number; creatorName?: string;
  }>;
}


Adjust column names to your actual schema (e.g., priceCents vs price, created_at vs createdAt). If you use Prisma/Knex, keep same function signatures and rewrite queries accordingly.

✅ Database Migrations
A) Drizzle (Postgres)

Create: drizzle/001_add_cancelled_to_creator_assets.sql (Drizzle can execute raw SQL via migrations)

BEGIN;
-- Add enum value
DO $$
BEGIN
  IF NOT EXISTS (SELECT 1 FROM pg_type t JOIN pg_enum e ON t.oid = e.enumtypid WHERE t.typname = 'approval_status_enum' AND e.enumlabel = 'cancelled') THEN
    ALTER TYPE approval_status_enum ADD VALUE 'cancelled';
  END IF;
END$$;

-- Add cancelled_at column
ALTER TABLE creator_assets
  ADD COLUMN IF NOT EXISTS cancelled_at TIMESTAMPTZ NULL;
COMMIT;


If your enum type name differs, change approval_status_enum. If status is VARCHAR instead of enum, skip the enum block.

Drizzle TypeScript schema patch (optional, if you keep schema in TS):

// in shared/schema.ts (or wherever creatorAssets is defined)
// add enum value 'cancelled' to your status enum type
export const approvalStatusEnum = pgEnum('approval_status_enum', ['pending', 'approved', 'rejected', 'cancelled']);

// add column to creatorAssets
cancelledAt: timestamp('cancelled_at', { withTimezone: true }).nullable(),

B) Knex

Create: migrations/20250919_add_cancelled_to_creator_assets.js

exports.up = async function(knex) {
  // Add enum value (Postgres only; otherwise skip or use varchar)
  await knex.raw(`
    DO $$
    BEGIN
      IF NOT EXISTS (SELECT 1 FROM pg_type t JOIN pg_enum e ON t.oid = e.enumtypid WHERE t.typname = 'approval_status_enum' AND e.enumlabel = 'cancelled') THEN
        ALTER TYPE approval_status_enum ADD VALUE 'cancelled';
      END IF;
    END$$;
  `);

  const hasCol = await knex.schema.hasColumn('creator_assets', 'cancelled_at');
  if (!hasCol) {
    await knex.schema.alterTable('creator_assets', t => {
      t.timestamp('cancelled_at', { useTz: true }).nullable();
    });
  }
};

exports.down = async function(knex) {
  const hasCol = await knex.schema.hasColumn('creator_assets', 'cancelled_at');
  if (hasCol) {
    await knex.schema.alterTable('creator_assets', t => t.dropColumn('cancelled_at'));
  }
  // Do not attempt to remove enum value 'cancelled'
};

C) Prisma

schema.prisma:

enum ApprovalStatus {
  pending
  approved
  rejected
  cancelled
}

model CreatorAsset {
  id              String          @id @default(cuid())
  creatorId       String
  title           String
  priceCents      Int
  approvalStatus  ApprovalStatus  @default(pending)
  approvedAt      DateTime?
  cancelledAt     DateTime?
  rejectionReason String?
  salesCount      Int             @default(0)
  totalRevenueCents Int           @default(0)
  createdAt       DateTime        @default(now())
  previewUrl      String?
}


Then:

npx prisma migrate dev -n add_cancelled_status_and_timestamp

✅ Public Browse Safety (tiny reminder)

Ensure your public marketplace list endpoint(s) only return approvalStatus = 'approved' (they already did). Cancelled will be invisible to the public once creators cancel.

✅ Quick Test Plan (copy to PR)

Creator:

Visit /creator/my-marketplace → see tabs and stats.

Cancel an approved listing → moves to Cancelled; disappears from public /marketplace.

Admin:

Open /admin/marketplace → see Pending/Approved/Rejected.

Approve/Reject from Pending → item moves between lists.

Public:

/marketplace still loads; only approved items appear; URL reflects only real filters (no ?search=true).

Auth:

Non-creator cannot access creator endpoints (403).

Non-admin cannot access admin endpoints (403).

✅ Final Notes you can paste into Replit task

Add/Replace the two frontend pages above and wire routes; add Admin tile.

Add server/routes.creator-marketplace.ts, import and app.use() it.

Add server/storage/creatorMarketplace.ts (or integrate into your existing storage layer).

Run migration (Drizzle/Knex/Prisma per your stack).

Smoke test with a creator user and an admin user.