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 22+ (or use the devcontainer)
- npm 10+
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 environment
cp .env.example .env
-
Setup database
npm run setup
-
Start development servers
npm run dev
Or run them separately:
npm run dev:client # Client on port 3000 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 packages/server/.env:
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.
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 modecd packages/client
npm run test:e2e # Run E2E tests
npm run test:e2e:watch # Run E2E tests in watch modeStorybook 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 more examples, see:
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