Skip to content

Ligren/continuum

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Continuum Demo

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.

What This Demonstrates

  • 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 any types

Architecture

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

Key Architectural Decisions

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.

Database Schema

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

Getting Started

Prerequisites

  • Node.js 20+
  • pnpm 10+
  • A Neon PostgreSQL database (free tier works)

Setup

# 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 dev

Local Subdomain Routing

For 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.

Demo Credentials

Rocky Mountain HVAC Supply (slug: rocky-mountain):

Email 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):

Email 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

Demo Script

Scene 1: Customer Submits a Return

  1. Open http://localhost:3000 and sign in as customer@rocky.test / demo1234
  2. You'll see My Returns with 4 existing RMAs in various states
  3. Click Start New Return
  4. Select order ORD-2025-0147 (the water heater order)
  5. Set the 50-Gal Gas Water Heater quantity to 1
  6. Click + Add photo, then click Analyze with AI
  7. Watch the AI analyze the "image" and auto-fill:
    • Reason: "Defective Product"
    • Details: damage description with technical analysis
    • Badges: "92% confidence", "Warranty Eligible"
  8. Click Review, then Submit Return Request
  9. Note the RMA number (e.g., RMA-2025-0005)
  10. Click Track Return to see the timeline view

Scene 2: CSR Processes the Return

  1. Open http://localhost:3001 and sign in as admin@rocky.test / demo1234
  2. The Overview dashboard shows KPI cards, recent RMAs, and a status distribution chart
  3. Click RMAs in the sidebar — see the filterable table with status badges and age indicators
  4. Find the new RMA (status: "Draft") and click into it
  5. The detail page shows:
    • Header with status badge and action buttons
    • Items with AI-Verified badges and photo indicators
    • Financial summary sidebar
  6. Click Start Review → confirm in the dialog
  7. Click Approve → confirm
  8. Click Mark ReceivedBegin Inspection
  9. In the items section, use the disposition dropdown:
    • Select Return to Vendor for the water heater
    • Note the warning: "will create a warranty claim"
  10. The activity timeline updates with each action, showing who did what and when

Scene 3: Financial Reconciliation

  1. Click Warranty Claims in the sidebar
  2. See the summary cards (Pending, Submitted to Vendor, etc.)
  3. Find the new claim — click the row to expand
  4. Click Submit to Vendor → enter a vendor reference number
  5. Click Vendor Approved → enter the vendor's reference
  6. Click Credit Received → enter the actual credit amount (e.g., 849.99)
  7. Navigate to Vendor Credits
  8. See the financial summary: Expected vs. Confirmed vs. Received vs. Outstanding
  9. Click the edit icon on any credit to update status, amount, or invoice reference
  10. Filter by manufacturer to see per-vendor totals

Technical Highlights

State Machine with Validated Transitions

// 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

Multi-Tenant Query Scoping

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.

End-to-End Type Safety

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<>.

Audit Trail Pattern

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.

AI-Assisted Image Analysis

The customer portal includes an AI image analysis feature for damage assessment:

  1. Customer uploads a photo of the damaged product
  2. Clicks "Analyze with AI" — calls /api/analyze-image
  3. The API returns: damage description, suggested reason, confidence level, warranty eligibility
  4. Form fields auto-fill from the analysis
  5. 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.

Email Notifications

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 });

Real-Time Status Updates

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

Tech Stack

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

Scripts

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

About

AI-powered B2B reverse logistics platform — multi-tenant SaaS with RMA management, warranty claims, and embedded AI assistant

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors