ZRM Docs
Guides

tRPC Procedure Hierarchy

publicProcedure                    # No auth
├── authedProcedure                # Requires session.user
│   ├── adminProcedure             # Requires admin role
│   ├── workspaceAwareProcedure    # Auth + workspace scoping (most common)
│   └── workspaceAdminProcedure    # Admin + workspace scoping
├── portalPublicProcedure          # No auth (magic link requests)
└── portalProcedure                # Portal session auth (cookie-based)

Router → Service Pattern

Routers are thin (validate input → call service → throw TRPCError). Services contain all business logic: DB queries, event publishing, search indexing, transactions.

// Router
export const accountRouter = router({
  create: workspaceAwareProcedure
    .input(createAccountSchema)
    .mutation(async ({ ctx, input }) => new AccountService(ctx.db).create(input, ctx.workspaceId)),
});

Workspace Scoping (Multi-Tenancy)

All data queries use scopedFilter() to enforce workspace isolation. The master workspace (00000000-0000-0000-0000-000000000001) bypasses filtering for admin access.

scopedFilter(table.id, entityId, table.workspaceId, workspaceId)

For queries that include shared/master workspace data (product catalog, categories), use workspaceOrMaster(products.workspaceId, workspaceId).

Always use scopedFilter for getById/update/delete, and .where(eq(table.workspaceId, workspaceId)) on list queries.

Monetary Field Convention

All monetary columns use PostgreSQL numeric (typically numeric(12,2)). Drizzle returns these as strings.

  • Services convert to JS numbers at the return boundary via numericToNumber() / numericToNumberNN() from @zrm/db. API contract sends numbers.
  • Aggregation (line totals, tax/discount) runs in SQL using numeric arithmetic — never accumulate money in JS loops.
  • Simple math (surcharge, weighted value) can stay in JS with Math.round(value * 100) / 100.
  • Writes: Drizzle accepts numbers or strings for numeric. Services pass Zod-validated numbers directly; computed values may use String() when Drizzle infers string.
  • Frontend inputs: EditableDetailRow onChange returns strings — coerce with Number() before tRPC calls.

Quote → Project Conversion

quote.accepted domain event triggers: (1) Project created with budget/scope/logistics from quote. (2) Payment term milestones mapped to project milestones (type payment). (3) Draft invoice created with BOM→line-item mapping (material→materials, labor→labor, recurring→service). (4) Opportunity advanced to won. (5) Temporal workflow for signed PDF + notifications.

Idempotent via acceptedQuoteId and quoteId lookups. Milestone types: payment auto-generated, project manually created — same table, milestoneType discriminator.

Cost Roll-Up Cascades

time_entry CRUD
  → recalculateWorkOrderTotals(workOrderId, tx, laborRate)
    → actualLaborHours/Cost, actualMaterialCost, totalCost, laborEfficiency, costVariance
    → if wo.projectId: recalculateProjectCosts → actualTotalCost, budgetVariance, budgetConsumedPercent

Also: bubbleTicketLaborHours sums WO labor to parent ticket. WO completion increments totalServiceCount/failureCount on linked asset (via ticket.relatedAssetId). PM schedule changes recompute maintenance plan metrics and nextServiceDue on covered assets.

All roll-ups include workspaceId in WHERE clause (prevent cross-workspace manipulation via user-controlled FK fields).

Registry-Driven Detail Pages

Entity detail pages use RegistrySection from @zrm/ui driven by FieldDefinition[] arrays from packages/domain-model/src/field-registry/:

const mergedData = useMemo(() => ({ ...(entity.data ?? {}), ...editData }), [entity.data, editData]);
<RegistrySection fields={entityFields} section="Identity" data={mergedData}
  editing={isEditing} onChange={handleFieldChange} variant="rows" collapsible />

All Phase 2+ entities use this: customer, contact, opportunity, quote, ticket, work order, project, asset, maintenance plan, monitoring account.

SLA Templates & Auto-Resolution

Contract-based SLA with customer inheritance. sla_templates defines response/resolution times per priority. Ticket creation resolves customer SLA template (or workspace default) and computes slaResponseDueAt/slaResolutionDueAt. When all WOs reach terminal status, the ticket auto-resolves with SLA breach detection.

Monitoring Account Billing

automationSchedulerWorkflow runs daily, queries monitoring_accounts where nextBillingDate <= CURRENT_DATE AND autoBilling = true AND status = 'active', generates draft invoices with line items, advances nextBillingDate, and sends in-app notifications to workspace owners/admins.

Automation Engine (Rules, Actions, Templates)

AutomationEventHandler subscribes via subscriber.onAll(), matches enabled rules by triggerConfig.eventType, evaluates filters + cooldown, then executes or drafts actions.

ActionExecutor dispatches by actionType:

  • create_record — inserts into ticket/work_order/invoice with workspace scoping
  • update_status — updates entity fields against UPDATABLE_FIELDS allowlist in action-executor.ts: ticket (status, priority, assignedUserId), work_order (status, priority), invoice (status)
  • send_notification — resolves recipients by userId or workspace role, inserts automation_notifications, optionally emails
  • run_workflow — starts Temporal workflow via createTemporalClient()
  • create_reorder_po — see Procurement Automation

Template variables: {{event.fieldName}} / {{payload.fieldName}} resolved by resolveTemplateVars(). Unresolved → empty string.

Autonomy: auto_execute runs immediately; draft_for_review and agent_review create a drafted trace for human approval. Approving runs ActionExecutor.execute() with stored actionPayload/triggerPayload.

Templates: 14 static TS templates in services/api/src/automation/templates/ (not DB rows). activateTemplate creates a disabled automation_rule. Categories: ticket, work_order, sales, billing.

Notification bell: Polls notification.unreadCount every 30s.

Operations Dashboard

/dashboard — real-time hub. Main (~75%): 6 KPI cards, role-aware "My Work", revenue chart, pipeline, activity feed, agent stats. Attention sidebar (~25%): collapsible categories — SLA breaches, overdue invoices, stale opps, maintenance due, automation approvals. Mobile: sidebar hidden, floating badge opens slide-over.

Data via dashboardOps tRPC router → DashboardOpsService (parallel aggregates against existing tables, no new DB tables). Polling: KPIs 60s, attention 30s, activity 30s, automations 5min. "My Work" adapts by role.

Procurement (Vendors, POs, Receiving)

Cross-entity state transitions live in services/api/src/services/procurement-helpers.ts as free-standing functions taking a Drizzle transaction.

PO state machine:

draft → pending_approval → submitted → acknowledged → partially_received → received
     (reject anywhere) / (cancel anywhere) → canceled

PurchaseOrderService.update() ignores status — transitions only via submitPo/approvePo/rejectPo/receivePoLine. System-initiated exception: editing an in-flight PO that crosses the approval threshold auto-reverts to draft and clears approval trail.

Approval threshold: workspaceSettings.approvalRequiredAtAmount (nullable). On submitPo, if po.totalAmount >= threshold, transition to pending_approval. Requires procurement:approve_po.

Receiving → cost cascade: receivePoLine is the only receipt path. If PO is WO-linked, auto-creates/upserts a work_order_materials row keyed by sourcePoLineItemId (idempotency). recalculateWorkOrderTotals cascade flows costs to actualTotalCost. Non-WO POs just update received_quantity.

Vendor-Product junction: vendor_products m2m with vendor-specific pricing/lead time/MOQ/SKU. Partial unique index enforces ≤1 isPreferred=true per (productId, workspaceId). Use setPreferredVendorProduct to flip atomically.

Concurrency: All four state helpers use SELECT ... FOR UPDATE on the PO row. Canonical pattern for high-stakes procurement transitions.

Permissions: procurement:manage_vendors, manage_purchase_orders, approve_po, receive_po. Admin all; sales_rep manage_vendors+manage_purchase_orders; technician receive_po.

Inventory (Warehouses, Stock Levels, Transactions)

Warehouse → bin hierarchy with one default warehouse per workspace and one default bin per warehouse (partial unique indexes). Stock levels are a materialized aggregate of the immutable stock_transactions ledger — stock_levels.quantityOnHand updated transactionally alongside every transaction insert. quantityAvailable = quantityOnHand - quantityReserved (computed at query time).

All stock mutations go through inventory-helpers.ts. Services open a tx, call a helper, then publish events. Helpers: receiveStock, issueStock, adjustStock, transferStock, returnToVendor, returnFromWorkOrder, isBelowReorderPoint. Outbound ops (issueStock, transferStock source, adjustStock) use SELECT ... FOR UPDATE on stock_levels.

Dual-path receiving: receivePoLine checks product.trackInventory. Tracked: creates receive transaction (PO explicit → workspace default); WO-linked POs immediately create issue (auto-issue to WO). Non-tracked: unchanged.

Reorder threshold COALESCE: product_stock_settings per-warehouse override → products.reorderPoint. Both null = no threshold. product_stock.below_reorder_point event fires on crossing; includes preferredVendorId.

Inventory opt-in per producttrackInventory = false (default) is untouched.

Permissions: inventory:manage_warehouses, manage_stock, adjust_stock, transfer_stock, issue_stock. Admin all; technician issue_stock only.

Procurement Automation (Auto-Draft POs, Reorder Rules)

Auto-draft POs from BOM: On quote acceptance with workspace_settings.autoDraftPosOnQuoteAcceptance=true, QuoteAcceptedHandler groups material BOM lines by preferred vendor and creates draft POs. Idempotent via purchase_orders.sourceQuoteId. Products without preferred vendor are skipped with admin notification.

Reorder rules: create_reorder_po action reacts to product_stock.below_reorder_point. Resolves preferred vendor, effective qty (warehouse override → product default → reorderPoint × 2), calls shared helper. auto_reorder_low_stock template: 24h cooldown, draft_for_review.

Shared helper: reorder-helpers.tsresolvePreferredVendor(), resolveReorderQuantity(), createReorderPo(). Batches REORDER-prefixed POs — appends to an open draft for same vendor (< 24h) instead of a new PO. Quote-sourced (AUTO-prefix) always create new per vendor.

Dashboard mutations: stock.createReorderPo / bulkCreateReorderPos for one-click reorder from sidebar/overview.

AI Tool Execution Context

AI tools do NOT receive workspaceId/userId from LLM params — these are injected server-side via ToolExecutionContext (prevents prompt injection). Flow: SpecialistExecutor.delegate() → ExecuteToolLoopFn → ToolUseExecutor → ToolRegistry.execute().

New tools needing workspace/user context: omit from Zod schema, cast via InjectedContext, add runtime guards on userId when required for audit trails.

Products & Sales Agent Tools

Workspace-scoped tools registered at startup via registerSalesAgentTools(db):

  • search_products — catalog search with workspace + master scoping
  • get_labor_rate — workspace settings lookup
  • generate_quote — creates quote + BOM atomically
  • revise_quote — new quote version with line modifications
  • get_agent_rules — admin rules + learned patterns
  • get_email_context — email threads for account/opportunity

Agent Rules & Outcome Patterns

Admin-managed rules guide AI behavior (pricing, product selection, labor estimation). Patterns auto-learned from quote outcomes.

  • Rules: agentRule router (admin-only mutations, workspace-aware reads)
  • Patterns: agentOutcomePattern router (system-managed via QuoteOutcomeHandler)
  • Scope types: global, account, category, project_typescopeRefId is varchar(255) to support non-UUID scopes

Dual Google Workspace Integration

Two connections per workspace in external_connections with connectionRole enum:

  • Orchestrator (connectionRole: 'orchestrator'): AI agent's business Google account. One per workspace. Admin-connected under Workspace Settings. All scopes. AI references for any user. Browsing requires google_orchestrator_* permissions.
  • Personal (connectionRole: 'personal'): Per-user. Connected in My Settings. Data visible ONLY to owning user (owning_user_id — not bypassable). Opt-in ai_access_enabled gates AI access.

Resolution via GoogleConnectionResolver:

  • getOrchestratorConnection(workspaceId) — unique per workspace
  • getPersonalConnection(workspaceId, userId) — unique per user per workspace
  • getAIAccessibleConnections(workspaceId, userId) — orchestrator + optionally personal

Permissions (packages/permissions/src/definitions.ts): google_orchestrator_{gmail,calendar,drive}:{read,write/send}, google_orchestrator:manage. Admin default all. Personal connections have NO permission checks — only owning_user_id === ctx.user.id.

UI: Purple + "AI Orchestrator" badge vs Blue + "Personal" badge.

Email Thread Processing

EmailThreadProcessorService resolves addresses to CRM accounts with confidence:

  • P1: Exact contact email match (0.95, case-insensitive via lower())
  • P2: Domain match (0.80)
  • confirmResolution validates workspace ownership, preserves opportunity links
  • getRelevantMessages returns ≤50 most recent (bounded for LLM context)

Accounting Settings (Tax Agencies & Payment Terms)

Workspace-level settings at /dashboard/settings/accounting. Gated by accounting:manage (admin default).

Tax Agencies: Per-jurisdiction rates (state/county/city/special_district). Assigned per-site via site_tax_agencies (not per-customer — different sites, different jurisdictions). Quotes inherit from linked site. Customer-level taxExempt zeros taxes; quote-level override takes precedence.

Payment Term Templates: Milestone-based schedules (e.g., "Net 30", "50/50 Split"). Milestones: label, percentage (must sum to 100%), trigger (on_acceptance/on_completion/net_days/milestone). One default per workspace. Milestones managed atomically (delete + re-insert on update). Customers/quotes FK to templates.

ACCOUNTING_MANAGE gates all mutations; all authed workspace users can read.

Quote Delivery & Engagement Tracking

quote.send validates guards → transitions status → publishes quote.sent → Temporal workflow handles delivery (magic link → PDF render → branded HTML email with PDF attachment → SMTP).

Send guards: draft/changes_requested/sent status, BOM lines, computed totals, linked site, primary contact with email.

Tracking: 1×1 pixel (opens) + redirect endpoint (clicks) via signed JWT. Redirect URL embedded inside signed token — never accept redirects from query params (open-redirect risk).

Engagement: QuoteEngagementService logs email_sent/email_open/link_click/portal_view/pdf_download. Summary aggregates counts + first-viewed.

PDF: QuotePdfService uses @react-pdf/renderer. Temporal workflow renders directly via lazy import (not HTTP). PDF attached as multipart/mixed.

Portal: Quote list + detail with BOM, financial summary, PDF download. Scoped via opportunity join.

Invoice Delivery, Payments & Auto-Generation

Mirrors quote delivery. InvoiceSendService.send() validates → status sentinvoice.sent event → Temporal invoiceDeliveryWorkflow (parallel: fetch context, generate PDF via InvoicePdfService, create portal magic link, send branded email with tracking pixel). Activities in services/workflows/src/activities/invoice-delivery.ts with inlined SMTP.

Send guards: draft/approved/sent status, line items, computed totalAmount, contact with email.

Engagement: InvoiceEngagementService + invoice_engagements. Types include payment_initiated/completed/failed. Routes: /tracking/invoice/:token/{pixel.png,open}.

Auto-generation: QuoteAcceptedHandler creates invoice in same tx as project (idempotent via quoteId). generateMonitoringInvoices activity creates drafts with dueDate (+30d).

Line item cascade: recalculateInvoiceTotals() runs on line item create/delete — sums totalPrice, preserves manual taxAmount, nullifies pdfS3Key (forces regen).

Stripe: InvoicePaymentService.createCheckoutSession() creates Checkout Sessions. POST /api/webhooks/stripe handles checkout.session.completed/expired. Uses payment_settings for keys. Idempotent via stripeCheckoutSessionId.

Portal: Dark mode with hero payment card, Pay Now (Stripe redirect), PDF, progress bar, due date countdown.

Product Catalog UI

/dashboard/products — category-grouped table, server filtering (search/category/status/manufacturer), inline create. Detail /dashboard/products/[id] — dense 2-col grid, inline editing, tabs.

Product ↔ Asset type sharing: products.productType uses assetTypeEnum (camera, controller, reader, sensor...). Type carries over when product installed as asset.

Cut sheet upload: PDF in S3 via presigned URLs. Flow: getCutSheetUploadUrl (PUT) → upload → save cutSheetS3Key. Download via getCutSheetDownloadUrl (4-min staleTime). Content type restricted server-side. Keys use timestamp (no user filenames).

Category attributes: attributeSchema JSONB {name, label, type, options?} rendered dynamically as EditableDetailRow (text/number/boolean/enum).

Scheduling & Dispatch (Phase 7)

Visit-centric model in schedule_visits drives dispatch board, auto time-entry, and Google Calendar sync.

Status machine: scheduled → en_route → in_progress → completed, plus canceled (escape) and no_show (terminal). VisitService.updateStatus uses status-gated UPDATE (WHERE id = ? AND status = <expected>) — losing concurrent caller gets CONFLICT. Transitions emit visit.scheduled/rescheduled/reassigned/completed/canceled.

Availability (AvailabilityService): layers (highest first) approved time-off → external Google Calendar blocks → per-day overrides → tech shift assignments → workspace default shift. getForUserOnDate runs 5 reads in one Promise.all, resolves shift with workspaceOrMaster. Range version fans out per-day.

Skills: skill_definitions workspace-scoped (seeds in master). technician_skills maps users. TechnicianSkillService.assign idempotent (ON CONFLICT DO UPDATE ... RETURNING). bulkAssign batches adds/removes in one tx with workspace-scope validation.

Auto time entry: handleVisitCompleted reacts to visit.completed, creates auto_tracked row, publishes time_entry.created, drives recalculateWorkOrderTotals. Idempotent via (workOrderId, userId, startTime, endTime, entryType) fingerprint — event redelivery doesn't duplicate costs.

Dispatch UI (/dashboard/dispatch): 6 KPIs, hour-grid day view (per-tech rows, abs-positioned blocks), week view, unassigned queue. DnD: queue→row (create), within row (reschedule), row→row (reassign). Drag payload travels via module-level ref in apps/web/src/components/dispatch/drag-state.ts because browsers hide dataTransfer.getData during dragover — receiving row fires dispatch.getSkillMatch for emerald/amber/rose drop coloring.

Tech pages: /dashboard/me/schedule — tech "my work" + time-off. /dashboard/visits — workspace visit list via scheduleVisitFields/RegistrySection. Detail page restricts inline editing to Notes; status/scheduling/assignment transitions stay on dispatch slide-over so the status machine + authorizeVisitMutation (assignee OR scheduling:manage_dispatch) remain the single enforcement point.

Google Calendar (Temporal): Three workflows push visits/time-off to orchestrator (visit.*, time_off.approved) and pull per-tech personal calendars into external_calendar_events (cron, ai_access_enabled=true only). HTTP client inlined at services/workflows/src/lib/google-calendar-client.ts with process-local token cache + refreshInFlight dedup — @zrm/workflows can't import @zrm/api (cycle). Requires GOOGLE_TOKEN_ENCRYPTION_KEY. External events keyed on (google_event_id, user_id, workspace_id) — user in multiple workspaces is attributed independently per workspace.

Permissions: 8 scheduling perms (view_dispatch_board, manage_dispatch, manage_shifts, manage_skills, request_time_off, approve_time_off, self_assign, assign_others). field_supervisor = technician + dispatch + time-off approval.

Domain Events

Published inside DB transactions via publishEvent(). Polling dispatcher delivers asynchronously.

await this.db.transaction(async (tx) => {
  const [record] = await tx.insert(table).values(data).returning();
  await publishEvent(tx, {
    eventType: "entity.created",
    aggregateType: "entity",
    aggregateId: record.id,
    payload: record,
    metadata: { userId },
  });
  return record;
});

Fonts

Sans: "Outfit". Mono: "JetBrains Mono". (Google Fonts)

On this page