AGENTS.md 10 KB

Torqvoice, for coding agents

Torqvoice is workshop management for automotive (and marine) service businesses: customers, vehicles, work orders, quotes, invoices, inventory, inspections, messaging, bookkeeping sync. One codebase runs both the self-hosted install and Torqvoice Cloud, where each workshop is an organization. Next.js 16 App Router, TypeScript, PostgreSQL, Prisma 7, Better Auth, next-intl, Tailwind 4 with shadcn/ui, Biome, Vitest, Playwright. Licensed under the Elastic License 2.0.

User docs live at https://torqvoice.com/docs and are written by the maintainer, not in this repo. CONTRIBUTING.md has setup, the CLA and the pull request rules; this file is what you need while writing code and cannot see from the code itself.

Commands

Open the repo in the Dev Container (.devcontainer/) and everything below works as is: Node 22, Postgres 16 as the db service on 5432, pgAdmin on 8080, Biome formatting on save. Outside it you need Node 22 and your own Postgres (docker-compose up -d db), then cp .env.example .env.

npm run dev                 # http://localhost:3000, needs DATABASE_URL in .env
npm run check               # Biome lint + format check, what CI runs (check:fix writes)
npm test                    # Vitest, jsdom, Prisma mocked
npx vitest run src/__tests__/<path>.test.tsx     # one test file
NODE_OPTIONS=--max-old-space-size=6144 npx tsc --noEmit   # typecheck; without the flag it runs out of memory
npm run test:db             # the few tests that need Postgres; E2E_DATABASE_URL must contain "test" or "e2e"
npm run test:e2e            # Playwright against a built app and a real database, see e2e/README.md
npx prisma generate         # after a fresh clone or a schema change; output is src/generated (gitignored)
npx prisma migrate dev --name <change>   # schema change; commit the folder it creates

Formatting is Biome only. There is no ESLint or Prettier config; running Prettier rewrites whole files. Schema changes go through prisma migrate dev and a reviewed migration folder, never prisma migrate deploy or prisma db push.

Gotchas

  • npm test can end with every test passing and exit code 1: EnvironmentTeardownError: Closing rpc while "onUserConsoleLog" was pending. That is a Vitest worker race, not your change; rerun it.
  • A git worktree of this repo needs node_modules and src/generated symlinked from the main checkout before Vitest or tsc will run there.
  • .env.example sets DATABASE_URL to host db, which resolves inside the Dev Container. Outside it, change the host to localhost.

Where things are

Path What
src/app/(authenticated) Signed-in pages. Server components fetch with Prisma, then hand data to a client component.
src/app/(public), (designer), (presenter) Auth, portal and share links; the invoice and email designers; the work board for a wall screen.
src/app/api Route handlers: webhooks, PDFs, uploads, the technician app API under v1/tech, WebSockets.
src/features/<domain>/ Actions/ server actions, Components/ client components, Schema/ zod, Lib/ helpers.
src/components/ui shadcn primitives. Shared app components sit one level up.
src/lib Auth, database, permissions, money, dates, realtime, backup, cron.
src/integrations/<id>/ One connector per folder: manifest.ts + server.ts, listed in registry.ts. A test fails if a folder is missing from the registry.
prisma/schema/*.prisma The schema, split by area. prisma/migrations/ is history; never edit an applied one.
messages/<locale>/<namespace>.json Translations. English is the source; other locales may lag.
src/__tests__/ Vitest, mirroring src/. e2e/specs/ is Playwright.

The app directory

Every page.tsx and layout.tsx in src/app is a server component: it reads the session, fetches with Prisma or a feature's action, and renders. A page that needs interactivity hands its data to a client component. One that belongs only to that page may sit beside it as <route>-client.tsx; anything reused lives in src/features. Route groups set the shell:

src/app/
├── (authenticated)/          # signed-in workshop, layout.tsx has the sidebar and session
│   ├── page.tsx              # dashboard
│   ├── vehicles/             # list, [id], [id]/service/new and [serviceId]; a work order is a service record
│   ├── sales/                # counter sales: a work order without a vehicle
│   ├── settings/             # one folder per settings page, settings-nav.tsx lists them
│   └── admin/                # platform admin, super admins only
├── (public)/                 # nobody signed in, every page is token or code gated
│   ├── auth/                 # sign-in, sign-up, 2FA, email verification, password reset
│   ├── onboarding/  app-setup/
│   ├── portal/[orgId]/       # the customer portal
│   ├── share/                # invoice, quote, inspection, status-report, terms by link
│   ├── p/[token]             # a phone opened from a work order's "Add from phone" code
│   └── b/[token]             # an online booking
├── (designer)/               # invoice-designer and email-designer, full-screen canvas
├── (presenter)/work-board/   # the work board for a wall screen
├── api/
│   ├── protected/            # route handlers for the signed-in app: uploads, files, ws, AI, backup
│   ├── public/               # share links, portal, tracking pixels, images
│   ├── webhooks/             # stripe, paypal, vipps, email, sms, whatsapp, telegram
│   ├── integrations/[connector]/   # OAuth callbacks for connectors
│   ├── v1/                   # tech (the technician app), cron, health
│   └── system/               # backup-heartbeat
├── layout.tsx                # root: providers, theme, locale
└── globals.css               # theme tokens for light, dark and the colour themes

API routes are for what cannot be a server action: a browser upload, a webhook, a PDF, a WebSocket, the technician app. Everything else goes through an action.

How code is written here

Server actions are the only write path. Each lives in its own file under src/features/<domain>/Actions/, is wrapped with withAuth from src/lib/with-auth.ts, and returns ActionResult<T> ({ success, data?, error?, gated?, forbidden? }). withAuth hands you ctx.organizationId; every Prisma query is scoped by it, including lookups by id, because ids from the browser can belong to another workshop. Pass requiredPermissions when the action is not for everyone; src/lib/permissions.ts lists them. Validate input with zod before touching the database.

Four things an action may also need. An action that changes a document, money or access passes audit to withAuth (src/lib/audit.ts has the event shape; 75 actions do this). One that sends a message or email or calls a paid service starts with demoGuard() from src/lib/demo.ts, because the public demo runs with real provider credentials; a test lists every unguarded one and fails. One a cloud plan may refuse returns gated, and the screen passes the result to handleGated from src/components/upgrade-gate before showing an error. And wording that names the vehicle has two forms: useServiceType() from src/components/service-type-context says whether this workshop fixes cars or boats, and the strings for both live side by side (title and titleMarine).

Action names are an API. A tab opened before a deploy calls the new build with what the old page sends. Changing what an action takes, or what its input means, means a new action name; the old tab is then asked to reload instead of writing bad data.

Reads happen in server components with db from src/lib/db.ts. A client component that must fetch uses TanStack Query (src/lib/query-provider.tsx) and parses the answer with zod.

Every string the user sees goes through next-intl. Add the English under messages/en/ in the namespace that owns the screen and read it with useTranslations. Playwright specs and the docs find buttons by their English words, so changing copy is a visible change, not a cleanup.

Dates and money. The database holds UTC. Render in the workshop's time zone through src/lib/timezone.ts and src/lib/workshop-datetime.ts; no setHours or getDay on a raw Date on the server. Amounts carry a currency code (src/lib/money.ts), countries are ISO 3166-1, languages ISO 639-1.

Nothing country-specific by default. Registries, tax rules, payment providers and parts sources ship as connectors or settings a workshop turns on, never as behaviour everyone sees.

UI. Use the shadcn components and the theme tokens (bg-background, text-muted-foreground, border): the app has dark mode and six colour themes defined in src/app/globals.css, so a hard-coded colour is wrong in most of them. A dialog with a long form uses DialogHeader, DialogBody and DialogFooter from src/components/ui/dialog, so the buttons stay pinned while the fields scroll. Client components are "use client" files in src/features; pages in src/app stay server components.

Naming. src/components and src/lib are kebab-case files. Inside src/features the folders are Actions, Components, Schema, Lib and component files are PascalCase. Imports use @/.

Tests

Vitest tests assert what a user would notice, not implementation details, and mock Prisma; src/__tests__/setup.ts wires next-intl. Anything that only breaks with the pieces assembled (migrations, cookies, forms wired to actions, invoice numbering) is a Playwright spec. CI runs npm run check, npm test and the e2e suite in eight shards on every pull request.

Pull requests

One change per PR, imperative title (it becomes the release note), body from .github/PULL_REQUEST_TEMPLATE.md: Summary, Why, Testing. First-time contributors keep the CLA line; the maintainer deletes it. Screenshots for UI changes. Before opening: npm run check && npm test.