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
A modern case management application built with React, Node.js, tRPC, and Prisma.
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
- 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
- Node.js with TypeScript
- tRPC (JSON-RPC 2.0) for API endpoints
- Prisma as ORM
- SQLite as database
- Express for HTTP server
- Node.js 24 - the version in
.nvmrc, matching the Docker images (or use the devcontainer) - npm 10+
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
Dockerfilerunsnpm run build, sopackages/client/distexists and the server serves the UI and the API together on port 3001 -NODE_ENV=production, whichdocker-compose.yamlsets, is what switches static file serving on.The AWS deployment uses the same image but serves the app differently:
infra/ecs.tfpoints the ALB target group at container port 5173 andinfra/alb.tfhealth-checks it, so there the Vite dev server serves the UI and proxies/trpcto Express on 3001. That is why the image'sCMDstill 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), sodocker compose -f <any-file> down -vremoves the named volumes belonging to all of them, includingapp-dataand thenode_modulesvolumes. Use plaindownunless 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.
The easiest way to get started is using the devcontainer:
- Open this folder in VS Code
- When prompted, click "Reopen in Container"
- Wait for the container to build and dependencies to install
- The application will automatically start at:
- Client: http://localhost:5173
- Server: http://localhost:3001
If not using devcontainer:
-
Install dependencies
npm install
-
Setup database
npm run setup
This creates
.envfrom.env.exampleif you don't already have one, installs dependencies, then pushes and seeds the database. No manualcp .env.example .envstep is needed. -
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
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.comThe 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.
The server uses an Express middleware (autoLogin.ts) that runs on every request:
- Checks for a
userIdcookie in the request - 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 - Sets a new
userIdcookie (HttpOnly, 7-day expiration) - 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.
npm run dev- Start both client and server in development modenpm run dev:client- Start only the clientnpm run dev:server- Start only the servernpm run build- Build all packagesnpm run test- Run tests in all packagesnpm run lint- Lint all packagesnpm run format- Format code with Prettiernpm run setup- Install dependencies and setup databasenpm run storybook- Start Storybook
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 Storybookcd 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 datacd packages/shared
npm run test # Run Jest tests
npm run lint # Lint codecarton-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
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.
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.dbUse 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.
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:setupnpm run test # Run all tests
npm run test:watch # Run tests in watch modeRun 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 chromiumStorybook is configured for developing and testing UI components in isolation:
npm run storybook # Start Storybook on port 6006
npm run build-storybook # Build static Storybooknpm run lint # Lint all packagesnpm run format # Format all code
npm run format:check # Check formattingThe tRPC API provides type-safe endpoints. Key routes:
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.
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
// 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 dataIn development mode, React Query DevTools appear in the bottom-right corner:
- Click the devtools icon to open
- View all cached queries and their status
- Inspect query data, fetch status, and cache timings
- Manually invalidate or refetch queries for testing
Note: DevTools only appear in development mode (npm run dev), not in production builds.
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();
},
});- 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
All examples below use the tRPC client configured with React Query for automatic caching and state management.
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>
);
}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>;
}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>
);
}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>
);
}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:
- CaseList.tsx - query usage in a component
- CaseList.test.tsx - testing a component that queries
- packages/client/CLAUDE.md - tRPC client conventions for this package
health.query()- Check API health
user.list.query()- Get all usersuser.getById.query({ id })- Get user by ID
case.list.query({ status?, assignedTo? })- Get cases with filterscase.getById.query({ id })- Get case by IDcase.create.mutation({ title, description, createdBy, assignedTo? })- Create casecase.update.mutation({ id, ...updates })- Update casecase.delete.mutation({ id })- Delete case
- Create a feature branch
- Make your changes
- Run tests:
npm run test - Run linting:
npm run lint - Format code:
npm run format - Submit a pull request
MIT