Skip to content

Latest commit

 

History

271 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

commands:

  • docker-compose -f docker-compose.local.yaml up --build
  • cmd+shift+p -> Dev Containers: Reopen in Container
  • npm install -> npm run setup -> npm run dev

Carton Case Management

A modern case management application built with React, Node.js, tRPC, and Prisma.

Architecture

This application follows a monorepo structure using npm workspaces:

  • packages/client - React frontend with Vite, Tailwind CSS, and Shadcn UI
  • packages/server - Node.js backend with tRPC, Prisma, and SQLite
  • packages/shared - Shared types and utilities used by both client and server

Tech Stack

Frontend

  • React 18 with TypeScript
  • Vite as build tool
  • tRPC for type-safe API calls
  • Shadcn UI components
  • Tailwind CSS for styling
  • React Router for routing
  • Storybook for component development
  • Jest for unit testing
  • Playwright for E2E testing

Backend

  • Node.js with TypeScript
  • tRPC (JSON-RPC 2.0) for API endpoints
  • Prisma as ORM
  • SQLite as database
  • Express for HTTP server

Getting Started

Prerequisites

  • Node.js 24 - the version in .nvmrc, matching the Docker images (or use the devcontainer)
  • npm 10+

Ways to run this project

All of these are supported and produce the same result. Pick one — you do not need to combine them.

Mode Command Notes
Host (npm) npm run setup && npm run dev Creates .env automatically
Docker (one-shot) docker compose -f docker-compose.local.yaml up --build -d Client 5173, server 3001
Devcontainer / Codespaces "Reopen in Container" Backed by docker-compose.dev.yaml
Tests in Docker docker compose -f docker-compose.test.yaml up --build Uses a separate test.db
Production image docker compose up --build -d Serves the built UI + API on 3001

Each docker-compose*.yaml file has a header comment explaining its purpose.

Note on the production image. The root Dockerfile runs npm run build, so packages/client/dist exists and the server serves the UI and the API together on port 3001 - NODE_ENV=production, which docker-compose.yaml sets, is what switches static file serving on.

The AWS deployment uses the same image but serves the app differently: infra/ecs.tf points the ALB target group at container port 5173 and infra/alb.tf health-checks it, so there the Vite dev server serves the UI and proxies /trpc to Express on 3001. That is why the image's CMD still starts both processes. Moving the target group and health check to 3001 would let the deployment serve the built assets from Express too - a worthwhile follow-up, but it has to be one coordinated change.

Caveat on down -v. All four compose files share one project name (the directory name), so docker compose -f <any-file> down -v removes the named volumes belonging to all of them, including app-data and the node_modules volumes. Use plain down unless you mean it.

Mixing modes is fine — running the servers in Docker while running npm test or npm run lint on the host works, because every mode resolves the database to the same packages/server/db/dev.db.

Development with Devcontainer (Recommended)

The easiest way to get started is using the devcontainer:

  1. Open this folder in VS Code
  2. When prompted, click "Reopen in Container"
  3. Wait for the container to build and dependencies to install
  4. The application will automatically start at:

Local Development

If not using devcontainer:

  1. Install dependencies

    npm install
  2. Setup database

    npm run setup

    This creates .env from .env.example if you don't already have one, installs dependencies, then pushes and seeds the database. No manual cp .env.example .env step is needed.

  3. Start development servers

    npm run dev

    Or run them separately:

    npm run dev:client  # Client on port 5173
    npm run dev:server  # Server on port 3001

Authentication

This application uses a simplified authentication system for development purposes. There is no real backend authentication - instead, it automatically logs you in as a mock user.

Default User: Alex Morgan (alex.morgan@carton.com)

Testing as Different Users: To test the application as a different user, set the MOCK_USER_EMAIL environment variable in the .env file at the project root:

MOCK_USER_EMAIL=jordan.doe@carton.com

The available users are seeded in the database. You can view them by running npm run db:studio in the server package or checking the seed.ts file.

How It Works

The server uses an Express middleware (autoLogin.ts) that runs on every request:

  1. Checks for a userId cookie in the request
  2. If no cookie exists or the cookie's user email doesn't match MOCK_USER_EMAIL, it looks up the user by email in the database
  3. Sets a new userId cookie (HttpOnly, 7-day expiration)
  4. The cookie is automatically included in subsequent requests

When you change MOCK_USER_EMAIL and restart the server, the middleware detects the mismatch and issues a new cookie for the new user on the next request. The client doesn't need to do anything - it just sends the cookie automatically.

Available Scripts

Root Level

  • npm run dev - Start both client and server in development mode
  • npm run dev:client - Start only the client
  • npm run dev:server - Start only the server
  • npm run build - Build all packages
  • npm run test - Run tests in all packages
  • npm run lint - Lint all packages
  • npm run format - Format code with Prettier
  • npm run setup - Install dependencies and setup database
  • npm run storybook - Start Storybook

Client Package

cd packages/client
npm run dev           # Start Vite dev server
npm run build         # Build for production
npm run test          # Run Jest tests
npm run test:e2e      # Run Playwright tests
npm run storybook     # Start Storybook

Server Package

cd packages/server
npm run dev           # Start dev server with hot reload
npm run build         # Build TypeScript
npm run start         # Start production server
npm run db:studio     # Open Prisma Studio
npm run db:push       # Push schema changes to database
npm run db:seed       # Seed database with demo data

Shared Package

cd packages/shared
npm run test          # Run Jest tests
npm run lint          # Lint code

Project Structure

carton-case-management/
├── .devcontainer/          # Devcontainer configuration
│   ├── devcontainer.json
│   └── Dockerfile
├── packages/
│   ├── client/             # React frontend
│   │   ├── src/
│   │   │   ├── components/ # React components
│   │   │   ├── lib/        # Utilities and tRPC setup
│   │   │   ├── pages/      # Page components
│   │   │   └── main.tsx    # Entry point
│   │   ├── tests/          # Tests
│   │   │   ├── unit/       # Jest unit tests
│   │   │   └── e2e/        # Playwright E2E tests
│   │   ├── .storybook/     # Storybook config
│   │   └── package.json
│   ├── server/             # Node.js backend
│   │   ├── src/
│   │   │   ├── index.ts    # Server entry point
│   │   │   ├── router.ts   # tRPC router
│   │   │   ├── context.ts  # tRPC context
│   │   │   └── trpc.ts     # tRPC setup
│   │   ├── db/
│   │   │   ├── dev.db      # SQLite database
│   │   │   └── seed.ts     # Database seeding
│   │   └── package.json
│   └── shared/             # Shared code
│       ├── prisma/
│       │   └── schema.prisma # Prisma schema (single source of truth)
│       ├── src/
│       │   ├── types.ts    # Shared types
│       │   ├── generated/  # Auto-generated Zod schemas from Prisma
│       │   └── utils.ts    # Shared utilities
│       └── package.json
├── docker-compose.dev.yaml
├── .gitignore
├── .prettierrc
├── eslint.config.mjs
├── package.json            # Root package.json
├── tsconfig.json           # Root TypeScript config
└── README.md

Database

The application uses SQLite for simplicity. The database file is located at packages/server/db/dev.db. The Prisma schema is in packages/shared/prisma/schema.prisma.

DATABASE_URL is relative to the schema, not the project root

Prisma resolves relative file: paths against the directory containing the schema (packages/shared/prisma/) — not the project root and not the current working directory. So the correct value is:

DATABASE_URL="file:../../server/db/dev.db"   # -> packages/server/db/dev.db

Use this same value in every environment (host, devcontainer, Docker, CI). Because the path is relative to the schema file, it stays correct no matter what the absolute root is, so /workspaces/carton-case-management and /app both resolve identically.

Getting this wrong does not raise an error — SQLite just creates the file wherever the path lands. For example file:./packages/server/db/dev.db silently produces packages/shared/prisma/packages/server/db/dev.db. If you see an unexpected dev.db nested under packages/shared/prisma/, a DATABASE_URL is misconfigured.

When running in a container that persists data with a volume, mount it at <WORKDIR>/packages/server/db, and make sure <WORKDIR> matches the WORKDIR of the Dockerfile you are building.

Prisma Commands

cd packages/server

# Open Prisma Studio (database GUI)
npm run db:studio

# Push schema changes to database
npm run db:push

# Generate Prisma Client
npm run db:generate

# Seed database with demo data
npm run db:seed

# Reset database (clear + seed)
npm run db:setup

Testing

Unit Tests (Jest)

npm run test                 # Run all tests
npm run test:watch          # Run tests in watch mode

E2E Tests (Playwright)

Run these from the project root - they are root scripts, not client ones.

npm run test:e2e            # Run E2E tests
npm run test:e2e:watch      # Open the Playwright UI for interactive debugging
npm run test:e2e:ci         # Skip the browser check (browsers already installed)

The first two run playwright install chromium first, so a fresh clone downloads the browser (~511MB extracted) once and then skips it on every later run. test:e2e:ci omits that step; it exists for docker-compose.test.yaml, whose image already has the browser baked into a layer.

Browsers live in a per-user, per-Playwright-version cache (~/.cache/ms-playwright, or ~/Library/Caches/ms-playwright on macOS) shared by every project on the machine, and old revisions are never pruned. npx playwright install --list shows what is there and which Playwright versions reference it; npx playwright uninstall --all clears it.

Chromium also needs a set of OS libraries. Every environment in this repo installs them already (Dockerfile.test, .devcontainer/Dockerfile), and macOS and Windows need nothing, so the scripts deliberately do not pass --with-deps - it re-runs apt-get update on every single invocation. If you run the E2E tests directly on a bare Linux host and Chromium fails to launch with a missing library, install them once by hand:

npx playwright install --with-deps chromium

Storybook

Storybook is configured for developing and testing UI components in isolation:

npm run storybook           # Start Storybook on port 6006
npm run build-storybook     # Build static Storybook

Code Quality

Linting

npm run lint                # Lint all packages

Formatting

npm run format              # Format all code
npm run format:check        # Check formatting

API Documentation

The tRPC API provides type-safe endpoints. Key routes:

Data Caching with tRPC + React Query

This application uses tRPC with React Query for automatic request caching and optimistic updates. All API calls through tRPC are automatically cached, reducing redundant network requests and improving performance.

Cache Configuration

The default cache settings (configured in packages/client/src/lib/trpc.tsx):

  • Stale Time: 5 minutes - Data is considered fresh for 5 minutes after fetching
  • Garbage Collection Time: 10 minutes - Unused data is removed from cache after 10 minutes
  • Retry: 3 attempts - Failed requests retry up to 3 times before showing an error
  • Refetch on Window Focus: Enabled - Data refetches in the background when you return to the tab

Cache Behavior Example

// First render: Fetches from API (shows loading state)
const { data, isLoading } = trpc.case.list.useQuery();

// Navigate away and back within 5 minutes:
// - Returns cached data instantly (no loading state)
// - Displays data in <100ms

// After 5 minutes:
// - Returns cached data instantly (stale data)
// - Refetches in background to get fresh data

Using React Query DevTools

In development mode, React Query DevTools appear in the bottom-right corner:

  1. Click the devtools icon to open
  2. View all cached queries and their status
  3. Inspect query data, fetch status, and cache timings
  4. Manually invalidate or refetch queries for testing

Note: DevTools only appear in development mode (npm run dev), not in production builds.

Cache Invalidation

When you mutate data (create, update, delete), the cache automatically updates:

const utils = trpc.useUtils();

// After creating a case, invalidate the list query
const createCase = trpc.case.create.useMutation({
  onSuccess: () => {
    // This refetches the case list
    utils.case.list.invalidate();
  },
});

Performance Benefits

  • Instant navigation: Cached data appears in <100ms when navigating back to a page
  • Reduced server load: Queries within stale time (5 min) don't hit the server
  • Background updates: Stale data is updated transparently without loading states
  • Automatic deduplication: Multiple components using the same query share one network request

Data Fetching with tRPC + React Query

All examples below use the tRPC client configured with React Query for automatic caching and state management.

Basic Query Example

import { trpc } from '../lib/trpc';

function CaseList() {
  const { data, isLoading, error } = trpc.case.list.useQuery();

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <ul>
      {data.map((c) => (
        <li key={c.id}>{c.title}</li>
      ))}
    </ul>
  );
}

Query with Parameters

function CaseListByStatus({ status }: { status: string }) {
  const { data } = trpc.case.list.useQuery(
    { status },
    {
      // Custom options for this query
      staleTime: 1000 * 60, // Fresh for 1 minute
      enabled: !!status, // Only run if status is provided
    }
  );

  return <div>{/* ... */}</div>;
}

Mutation Example with Cache Invalidation

function CreateCaseForm() {
  const utils = trpc.useUtils();

  const createCase = trpc.case.create.useMutation({
    onSuccess: () => {
      // Refetch the case list to show new case
      utils.case.list.invalidate();
    },
    onError: (error) => {
      alert(`Failed to create case: ${error.message}`);
    },
  });

  const handleSubmit = (data: { title: string; description: string }) => {
    createCase.mutate({
      title: data.title,
      description: data.description,
      createdBy: currentUserId,
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* form fields */}
      <button type="submit" disabled={createCase.isLoading}>
        {createCase.isLoading ? 'Creating...' : 'Create Case'}
      </button>
    </form>
  );
}

Optimistic Updates

function UpdateCaseStatus({ caseId }: { caseId: string }) {
  const utils = trpc.useUtils();

  const updateStatus = trpc.case.update.useMutation({
    onMutate: async (newData) => {
      // Cancel outgoing refetches
      await utils.case.getById.cancel({ id: caseId });

      // Snapshot previous value
      const previousCase = utils.case.getById.getData({ id: caseId });

      // Optimistically update to the new value
      utils.case.getById.setData({ id: caseId }, (old) =>
        old ? { ...old, status: newData.status } : old
      );

      return { previousCase };
    },
    onError: (err, newData, context) => {
      // Rollback on error
      utils.case.getById.setData({ id: caseId }, context?.previousCase);
    },
    onSettled: () => {
      // Always refetch after error or success
      utils.case.getById.invalidate({ id: caseId });
    },
  });

  return (
    <button onClick={() => updateStatus.mutate({ id: caseId, status: 'CLOSED' })}>
      Close Case
    </button>
  );
}

Testing Patterns

When testing components that use tRPC queries, use the test utilities from src/test/utils.ts:

import { renderWithTrpc } from '../test/utils';
import { server } from '../vitest.setup';
import { http, HttpResponse } from 'msw';

test('displays cases from API', async () => {
  // Mock the API response
  server.use(
    http.post('http://localhost:3000/trpc/case.list', () => {
      return HttpResponse.json({
        result: {
          data: [{ id: '1', title: 'Test Case', description: 'Test', status: 'OPEN' }],
        },
      });
    })
  );

  // Render component with tRPC provider
  const { getByText } = renderWithTrpc(<CaseList />);

  // Wait for data to load
  await waitFor(() => {
    expect(getByText('Test Case')).toBeInTheDocument();
  });
});

For working examples in this repo, see:

Health

  • health.query() - Check API health

Users

  • user.list.query() - Get all users
  • user.getById.query({ id }) - Get user by ID

Cases

  • case.list.query({ status?, assignedTo? }) - Get cases with filters
  • case.getById.query({ id }) - Get case by ID
  • case.create.mutation({ title, description, createdBy, assignedTo? }) - Create case
  • case.update.mutation({ id, ...updates }) - Update case
  • case.delete.mutation({ id }) - Delete case

Contributing

  1. Create a feature branch
  2. Make your changes
  3. Run tests: npm run test
  4. Run linting: npm run lint
  5. Format code: npm run format
  6. Submit a pull request

License

MIT

About

An Open Source Case Management system designed to be maintained with coding agents

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages