A modern web application for managing the product service lifecycle. Register service intakes, manage status, control locations (branches), and track metrics such as wait times and costs. The live backend is PocketBase for authentication and data.
- Core Framework: Next.js 16 (Turbopack + App Router)
- Language: TypeScript
- UI: React 19
- Styles: Tailwind CSS v4
- Database & Authentication: PocketBase
- Containerization: Docker & Docker Compose (PocketBase + app)
- Icons: Lucide React
- Date Handling: date-fns
- Node
22,pnpm@11.1.1(enforced viapackageManager) - Docker + Docker Compose (for local PocketBase)
- Service management: Full CRUD for service tickets with inline validation (
sku,failureDescription,entryDaterequired), RUT check, and field-level errors. - Status workflow:
pending,ready,completed,cancelled(read-only aftercompleted); dashboard filter is exclusive single-status with right-side check (no pill). - Registro: Chronological
service_eventslog with filter strip and footer-only pagination;Nuevo servicioin the empty state navigates to plain/dashboard— no in-place modal, no duplicate header pager. - Location control: Branch management with movement history;
addressoptional onlocations(trim, max 200, blank → omitted). - Location metrics: Active =
pending/readyat currentlocationId; Completed = immutableoriginLocationId;cancelledcounts neither.originLocationIdis set on create and enforced read-only thereafter. - Search & Pagination: Filter by client, product, or invoice number with
LIKE(~) search and{ data, total, page, limit }envelope; exacttotalviatotalItemswith no arbitrary page cap. - Tenancy: Isolation by
userId+ collection rulesuserId = @request.auth.id; PocketBase-native 15-character ids.
-
Clone the repository
git clone <repository-url> cd ServiceFlow
-
Configure environment variables
Copy
.env.exampleto.envand adjust if needed:cp .env.example .env
POCKETBASE_URL=http://127.0.0.1:8090 APP_PORT=3000 POCKETBASE_PORT=8090 POCKETBASE_ADMIN_EMAIL=admin@local.test POCKETBASE_ADMIN_PASSWORD=admin123456
Variable Reader Default Notes POCKETBASE_URLNext.js app only http://127.0.0.1:8090(host) /http://pocketbase:8090(compose network)Only locator read by the app; validated as http/httpsabsolute URL.APP_PORT/POCKETBASE_PORTCompose host bindings 3000/8090Change to run multiple worktrees in parallel without collisions. POCKETBASE_ADMIN_*pocketbase+pocketbase-initonlyadmin@local.test/admin123456Local superuser for schema import; never read by Next.js; keep real .envgitignored.PB_SMTP_PASSWORDpocketbase-initonlyunset (skip SMTP) Resend API key. Unset/empty skips mail settings so default tests work; whitespace-only fails closed. Never mounted on the Next.js app. PB_META_APP_URLpocketbase-initonlyhttps://serviceflow.jonasotoaguilar.spaceOptional verification-link origin override. No admin credentials are baked into the app image.
-
Install dependencies
pnpm install --frozen-lockfile
-
Apply the schema
The versioned artifact is
pocketbase/v1.collections.json(collectionsusers,serviceswithoriginLocationId,locations,service_events; optionaladdress; requiredservice_events.userId; tenant rulesuserId = @request.auth.id, no business rows).- Local compose:
pocketbase-initimports the artifact automatically withPUT /api/collections/importanddeleteMissing:falseafter PocketBase is healthy — no manual step. - External / Dokploy instance: open the Admin UI (
http://127.0.0.1:8090/_/or the managed URL), importpocketbase/v1.collections.jsonif supported, otherwise transcribe fields, indexes, and rules manually. Update the existinguserscollection — do not create a second one. - Verify: 4 collections,
addressoptional,originLocationIdindexed,userIdrequired in logs, 0 business rows, tenant rules present,userscreate public and list/delete blocked. - Next.js never calls the admin API; only
pocketbase-init(local) or the operator uses it.
- Local compose:
pnpm devThe app is available at http://localhost:3000 (or http://localhost:${APP_PORT} if you overrode APP_PORT).
PocketBase stays in compose.yaml. The default app service is a production image (used by CI). For live reload of local code, overlay compose.dev.yaml so Next runs pnpm dev with the repo bind-mounted. PocketBase and its volume are unchanged.
# live reload (PocketBase + Next watching this checkout)
pnpm dev:stack
# equivalent:
docker compose -f compose.yaml -f compose.dev.yaml upRebuild the production-like app image (no live reload):
docker compose up --build -d --wait| Endpoint | URL |
|---|---|
| App | http://127.0.0.1:${APP_PORT:-3000} |
| PocketBase API | http://127.0.0.1:${POCKETBASE_PORT:-8090} |
| PocketBase Admin UI | http://127.0.0.1:${POCKETBASE_PORT:-8090}/_/ |
PocketBase uses the pinned community image adrianmusante/pocketbase:0.40.1 (digest-pinned, non-root 1001, state at /pocketbase, healthcheck GET /api/health). A one-shot pocketbase-init container imports pocketbase/v1.collections.json with deleteMissing:false. In the live-reload overlay the app reaches PocketBase at http://pocketbase:8090 on the compose network.
Worktree isolation
- Compose exposes
APP_PORTandPOCKETBASE_PORTvia${APP_PORT:-3000}/${POCKETBASE_PORT:-8090}so two worktrees can run side-by-side by setting different ports in each.env. - Volume
pocketbase-datais project-scoped (Compose project name, default = directory name). Previously fixed asserviceflow-pocketbase-local-data. To preserve existing local data, copy it once:wheredocker run --rm -v serviceflow-pocketbase-local-data:/from -v <project>_pocketbase-data:/to alpine cp -a /from/. /to/
<project>is yourCOMPOSE_PROJECT_NAME(directory name if unset). - Hooks and migrations use one canonical image path:
./pb_hooks:/pocketbase/hooks:roand./pb_migrations:/pocketbase/migrations:ro(not/pb/*). .dockerignoreexcludes worktree runtime dirs (.agents,.herdr,.codegraph,pb_data,.sdd) to keeppnpm buildfrom failing withENOENT.
Stop:
docker compose downpocketbase-data persists across restarts. Do not run down -v or prune unless you intend to wipe local data. Production/Dokploy remains out of scope for this local compose.
/app: Next.js routes and pages (App Router)./components: Reusable UI components./lib:pocketbase.ts(per-request client,pb_auth),pocketbase-filter.ts(templates{:param}+pb.filter),env.ts(POCKETBASE_URL+ Zod),auth.ts(getAuthUservalidated viaauthRefresh),storage.ts(service CRUD withoriginLocationId),schemas.ts(Zod),types.ts,format-date.ts./pocketbase: Artifactv1.collections.json./pb_hooks: PocketBase JSVM hooks (services.pb.js,locations.pb.js,backfill-origin.pb.js)./pb_migrations: PocketBase JS migrations (image-canonical/pocketbase/migrations)./tests: Vitest suite (mocked PocketBase, no network).
- Public registration: any user can register without an invite;
userscreate"". Registration creates an unverified account, callsrequestVerificationbest-effort, sets nopb_authcookie, and navigates to/login?registered=1where an info callout explains verification is required before sign-in. - Verified-only session: usable tokens are issued only to verified accounts (
usersauthRule: "verified = true"plus the app guard rejectingverified !== truefail-closed). Unknown, wrong-password, and unverified logins share the sameCredenciales inválidaserror with an always-visible enumeration-neutral resend; no extra unverified-only copy. - Verification callback:
/verify?token={TOKEN}is consumed server-side viaconfirmVerificationand redirects to/verify?status=ok|failwithout leaving the token in the URL or logs. Bare/verifyfails closed. - Session:
pb_authcookie withhttpOnly,sameSite=lax,path=/,securein production,expiresfrom JWTexp; value is never logged. Server validation viaauthRefreshbefore returning identity; forged/unreachable →null/401 fail-closed. - Tenancy: every list binds
userId = {:uid}and collection rules enforceuserId = @request.auth.id; a second tenant sees no foreign rows. - Verification mail (SMTP): Resend SMTP is applied by
pocketbase-initonly fromPB_SMTP_PASSWORD(required) plus optionalPB_META_APP_URL(defaulthttps://serviceflow.jonasotoaguilar.space); senderServiceFlow <no-reply@serviceflow.jonasotoaguilar.space>. Unset/empty password skips SMTP so default runs work; partial config fails closed. Secrets are never logged or baked into the app image. - Operator-only staging check: one
POST /api/settings/test/emailwith{ "template": "verification" }using operator user env. It is NOT part of the default suite —pnpm test:runandpnpm test:e2epass without SMTP and never send real mail.
-
Native ids: PocketBase generates native 15-character ids; no UUID pre-generation and no
$idpreservation. -
Pagination:
{ data, total, page, limit }viagetList(page, perPage, { filter, sort });totalfromtotalItems;LIKEsearch (~) onclientName,invoiceNumber,rut; status allowlistpending|ready|completed|cancelled. No fixed page cap; history pagination uses exacttotalItems/totalPages. -
Origination:
originLocationIdis set tolocationIdon create, immutable on update (hook + API guard), indexed inservices. Backfill resolves from earliestservice_events.kind = 'created'or falls back tolocationIdonly when nolocation_changedhistory exists; ambiguous rows stay unresolved. -
Locations:
addressoptional (trim, max 200, blank → omitted);isActivetoggle; delete blocked by history (services.locationId || originLocationIdorservice_events.fromLocationId || toLocationId); at least one active location per user is enforced at DB triggers, JS hooks, and UI. -
Registro metrics: Row counts per location derive from the same origin contract:
Metric Counts Source field Active pending+readycurrent services.locationIdCompleted completedimmutable services.originLocationIdCancelled neither — Guards prevent deleting a location with current or origin history via direct PocketBase writes.
-
Navigation:
Registroempty-state CTA pushes plain/dashboard; no?createService=1query, no in-place modal.