A full-stack B2B reverse logistics platform built as a technical demonstration. Inspired by Continuum, this project models the complete lifecycle of product returns (RMAs), warranty claims, and vendor credit reconciliation for HVAC distributors.
Live demo credentials are pre-seeded. See Demo Script below.
- Multi-tenant SaaS — subdomain-based tenant isolation with shared database
- Complex domain modeling — 11 tables covering orders, returns, warranty claims, and vendor credits
- State machine workflows — RMA lifecycle with validated transitions and role-based actions
- Financial reconciliation — tracking credits owed by manufacturers through the full claim lifecycle
- AI-assisted workflows — image analysis for damage assessment and warranty eligibility
- End-to-end type safety — from database schema to API layer to UI, with zero
anytypes
continuum-demo/
├── apps/
│ ├── web/ # Customer portal (Next.js 15, port 3000)
│ │ ├── src/app/ # App Router pages
│ │ ├── src/components/ # UI components
│ │ ├── src/lib/ # Auth, DB, tRPC, tenant resolution
│ │ └── src/middleware.ts # Subdomain → tenant slug extraction
│ │
│ └── admin/ # Distributor dashboard (Next.js 15, port 3001)
│ ├── src/app/ # Dashboard, RMAs, Warranty, Credits
│ ├── src/components/ # Sidebar, Topbar, StatusBadge
│ ├── src/lib/ # Auth, DB, tRPC
│ └── src/middleware.ts # JWT auth + role gate (admin/csr only)
│
├── packages/
│ ├── api/ # tRPC v11 routers + Auth.js config
│ │ └── src/
│ │ ├── trpc.ts # Context, publicProcedure, tenantProcedure
│ │ ├── auth.ts # Auth.js v5 (Google OAuth + Credentials)
│ │ ├── root.ts # Merged appRouter
│ │ └── routers/ # rma, warranty, credit, product, order
│ │
│ ├── db/ # Drizzle ORM + Neon PostgreSQL
│ │ └── src/
│ │ ├── schema/ # 11 table definitions + enums
│ │ ├── index.ts # DB client factory
│ │ └── seed.ts # Realistic demo data
│ │
│ ├── shared/ # Types, validators, state machine
│ │ └── src/
│ │ ├── enums.ts # All domain enums
│ │ ├── rma-states.ts # State machine transitions + display helpers
│ │ ├── validators.ts # Zod schemas for all mutations
│ │ ├── constants.ts # Human-readable labels
│ │ └── types.ts # Inferred types from Zod schemas
│ │
│ └── tsconfig/ # Shared TypeScript configs
│ ├── base.json # Strict mode, ESNext
│ └── nextjs.json # Next.js-specific settings
│
├── turbo.json # Turborepo task pipeline
├── pnpm-workspace.yaml # Workspace configuration
└── .env.example # Environment variable template
Subdomain multi-tenancy with shared DB. Each distributor gets a subdomain (e.g., rocky-mountain.continuum.localhost). Middleware extracts the slug, resolves the tenant ID, and passes it via request headers. Every tRPC procedure scoped to a tenant uses tenantProcedure, which enforces tenantId is present. All tenant-scoped tables have a tenantId column with compound indexes.
tRPC for type-safe API layer. The router definitions in packages/api are shared between both Next.js apps. Server Components use createCallerFactory for direct server-side calls (no HTTP overhead). Client Components use @trpc/tanstack-react-query with React Query for mutations. The AppRouter type flows from API definition to every useTRPC() call — rename a field in the router and TypeScript catches every broken consumer.
Drizzle ORM for type-safe queries. Schema is defined once in packages/db/src/schema/ and used everywhere. The select() builder ensures only requested columns appear in the result type. Joins are explicit — no magic relations, no N+1 queries. The schema files serve as the single source of truth for the database structure.
State machine pattern for RMA lifecycle. The RMA_TRANSITIONS map in packages/shared/src/rma-states.ts defines every valid status transition. Both the API (canTransition() check before any update) and the UI (action buttons derived from getAvailableTransitions()) use the same map. Adding a new status or transition is a single-file change that propagates everywhere.
Activity log for audit trail. Every mutation (status change, disposition assignment, warranty claim creation) inserts a row into activity_log with the actor, action, and metadata. The RMA detail page renders this as a vertical timeline. This pattern is critical for B2B SaaS where accountability matters.
tenants ─────────────────────── The distributor organization
└── users ─────────────────── Staff and customers (role-based)
└── manufacturers ─────────── Vendors the distributor works with
└── products ──────────────── Products sold (linked to manufacturer)
└── orders ────────────────── Original sales orders
│ └── orderItems ──────── Line items with serial numbers
└── rmas ──────────────────── Return Merchandise Authorizations
│ └── rmaItems ────────── Individual items being returned
│ └── warrantyClaims Manufacturer warranty claims
│ └── vendorCredits Financial tracking
└── activityLog ───────────── Audit trail for all entities
- Node.js 20+
- pnpm 10+
- A Neon PostgreSQL database (free tier works)
# 1. Clone and install
git clone <repo-url> continuum-demo
cd continuum-demo
pnpm install
# 2. Configure environment
cp .env.example .env
# Edit .env with your Neon DATABASE_URL and a random AUTH_SECRET
# Example AUTH_SECRET: openssl rand -base64 32
# 3. Push schema to database
pnpm db:push
# 4. Seed demo data
pnpm db:seed
# 5. Start both apps
pnpm devFor the full multi-tenant experience, add these entries to /etc/hosts:
127.0.0.1 continuum.localhost
127.0.0.1 rocky-mountain.continuum.localhost
127.0.0.1 midwest-plumbing.continuum.localhost
127.0.0.1 admin.continuum.localhost
Then access:
- Rocky Mountain portal:
http://rocky-mountain.continuum.localhost:3000 - Midwest portal:
http://midwest-plumbing.continuum.localhost:3000 - Admin dashboard:
http://admin.continuum.localhost:3001
Without /etc/hosts changes: The app falls back to NEXT_PUBLIC_DEV_TENANT_SLUG=rocky-mountain in .env, so http://localhost:3000 works too. Change the env var to midwest-plumbing to switch tenants.
Rocky Mountain HVAC Supply (slug: rocky-mountain):
| Password | Role | App | |
|---|---|---|---|
admin@rocky.test |
demo1234 |
Admin | Admin dashboard |
csr@rocky.test |
demo1234 |
CSR | Admin dashboard |
customer@rocky.test |
demo1234 |
Customer | Customer portal |
Midwest Plumbing Distributors (slug: midwest-plumbing):
| Password | Role | App | |
|---|---|---|---|
admin@midwest.test |
demo1234 |
Admin | Admin dashboard |
csr@midwest.test |
demo1234 |
CSR | Admin dashboard |
customer1@midwest.test |
demo1234 |
Customer (Lakeside Mechanical) | Customer portal |
customer2@midwest.test |
demo1234 |
Customer (Prairie State Plumbing) | Customer portal |
- Open
http://localhost:3000and sign in ascustomer@rocky.test/demo1234 - You'll see My Returns with 4 existing RMAs in various states
- Click Start New Return
- Select order ORD-2025-0147 (the water heater order)
- Set the 50-Gal Gas Water Heater quantity to 1
- Click + Add photo, then click Analyze with AI
- Watch the AI analyze the "image" and auto-fill:
- Reason: "Defective Product"
- Details: damage description with technical analysis
- Badges: "92% confidence", "Warranty Eligible"
- Click Review, then Submit Return Request
- Note the RMA number (e.g.,
RMA-2025-0005) - Click Track Return to see the timeline view
- Open
http://localhost:3001and sign in asadmin@rocky.test/demo1234 - The Overview dashboard shows KPI cards, recent RMAs, and a status distribution chart
- Click RMAs in the sidebar — see the filterable table with status badges and age indicators
- Find the new RMA (status: "Draft") and click into it
- The detail page shows:
- Header with status badge and action buttons
- Items with AI-Verified badges and photo indicators
- Financial summary sidebar
- Click Start Review → confirm in the dialog
- Click Approve → confirm
- Click Mark Received → Begin Inspection
- In the items section, use the disposition dropdown:
- Select Return to Vendor for the water heater
- Note the warning: "will create a warranty claim"
- The activity timeline updates with each action, showing who did what and when
- Click Warranty Claims in the sidebar
- See the summary cards (Pending, Submitted to Vendor, etc.)
- Find the new claim — click the row to expand
- Click Submit to Vendor → enter a vendor reference number
- Click Vendor Approved → enter the vendor's reference
- Click Credit Received → enter the actual credit amount (e.g.,
849.99) - Navigate to Vendor Credits
- See the financial summary: Expected vs. Confirmed vs. Received vs. Outstanding
- Click the edit icon on any credit to update status, amount, or invoice reference
- Filter by manufacturer to see per-vendor totals
// packages/shared/src/rma-states.ts
export const RMA_TRANSITIONS: Record<RmaStatus, RmaStatus[]> = {
draft: ["submitted", "cancelled"],
submitted: ["under_review", "cancelled"],
under_review: ["approved", "rejected", "cancelled"],
approved: ["receiving", "cancelled"],
receiving: ["inspecting", "cancelled"],
inspecting: ["disposition_assigned", "cancelled"],
disposition_assigned: ["completed", "cancelled"],
completed: [],
rejected: [],
cancelled: [],
};The same map drives:
- API validation:
canTransition(from, to)checked before every status update - UI action buttons:
getAvailableTransitions(status)determines which buttons appear - Status badges:
getStatusColor(status)returns Tailwind classes - Timeline rendering: ordered step progression in the customer tracking view
Every tenant-scoped query goes through tenantProcedure, which guarantees tenantId is present in context:
// packages/api/src/trpc.ts
export const tenantProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.user) throw new TRPCError({ code: "UNAUTHORIZED" });
if (!ctx.tenantId) throw new TRPCError({ code: "UNAUTHORIZED" });
return next({ ctx: { ...ctx, user: ctx.user, tenantId: ctx.tenantId } });
});All queries include eq(table.tenantId, tenantId) in their WHERE clause. Compound indexes like (tenantId, rmaNumber) ensure efficient lookups.
Types flow from Drizzle schema → tRPC router → React component with no manual type definitions:
pgTable("rmas", { status: rmaStatusEnum("status") })
→ router.rma.list returns { status: "draft" | "submitted" | ... }
→ <StatusBadge status={rma.status} /> // TypeScript enforces valid status
Zod schemas in packages/shared/src/validators.ts validate all mutation inputs. The same schemas generate TypeScript types via z.infer<>.
Every mutation logs to activity_log:
await db.insert(activityLog).values({
tenantId,
entityType: "rma", // or "warranty_claim", "vendor_credit"
entityId: rma.id,
action: "status_changed",
performedBy: user.id,
metadata: { oldStatus: "submitted", newStatus: "under_review" },
});The admin UI renders this as a vertical timeline with actor names, actions, timestamps, and embedded notes.
The customer portal includes an AI image analysis feature for damage assessment:
- Customer uploads a photo of the damaged product
- Clicks "Analyze with AI" — calls
/api/analyze-image - The API returns: damage description, suggested reason, confidence level, warranty eligibility
- Form fields auto-fill from the analysis
- Admin sees "AI-Verified" badges on analyzed items
The demo uses a mock API with product-category-specific responses. In production, this would call a vision model (GPT-4o, Claude) with the actual image.
Every key status change triggers an email notification via packages/api/src/services/notifications.ts:
| Event | Recipient | Template |
|---|---|---|
| RMA submitted | Customer | Confirmation with item count, next steps |
| RMA approved | Customer | Approval notice with shipping instructions |
| RMA rejected | Customer | Rejection with reason from CSR notes |
| Disposition assigned | Customer | Per-item update (restock/repair/return to vendor) |
| Warranty claim approved | Customer | Vendor approval with credit amount |
| Credit received | Admin | Financial notification with amount and invoice ref |
The demo logs formatted emails to the server console. In production, swap sendEmail() for a real provider:
// With Resend
import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY);
await resend.emails.send({ from, to, subject, html });
// With SendGrid
import sgMail from '@sendgrid/mail';
sgMail.setApiKey(process.env.SENDGRID_API_KEY);
await sgMail.send({ from, to, subject, html });The customer portal uses tRPC polling (refetchInterval: 5000) on both the My Returns list and the tracking page. When the admin changes an RMA status, the customer sees:
- Status badge updates within 5 seconds
- Timeline stepper animates forward
- Toast notification slides in: "Your return RMA-2025-0001 has been approved!"
- A pulsing green "Live" indicator shows the page is actively polling
| Layer | Technology |
|---|---|
| Framework | Next.js 15 (App Router) |
| Language | TypeScript (strict mode) |
| Styling | Tailwind CSS v4 |
| API | tRPC v11 |
| Database | Neon PostgreSQL + Drizzle ORM |
| Auth | Auth.js v5 (JWT strategy) |
| Monorepo | Turborepo + pnpm workspaces |
| Validation | Zod |
| Command | Description |
|---|---|
pnpm dev |
Start all apps and packages in dev mode |
pnpm build |
Build everything |
pnpm lint |
Type-check all packages |
pnpm db:push |
Push schema to database |
pnpm db:generate |
Generate Drizzle migrations |
pnpm db:seed |
Seed demo data |