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
numericarithmetic — 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 useString()when Drizzle infersstring. - Frontend inputs:
EditableDetailRowonChange returns strings — coerce withNumber()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, budgetConsumedPercentAlso: 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 scopingupdate_status— updates entity fields againstUPDATABLE_FIELDSallowlist in action-executor.ts: ticket (status, priority, assignedUserId), work_order (status, priority), invoice (status)send_notification— resolves recipients by userId or workspace role, insertsautomation_notifications, optionally emailsrun_workflow— starts Temporal workflow viacreateTemporalClient()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) → canceledPurchaseOrderService.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 product — trackInventory = 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.ts — resolvePreferredVendor(), 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 scopingget_labor_rate— workspace settings lookupgenerate_quote— creates quote + BOM atomicallyrevise_quote— new quote version with line modificationsget_agent_rules— admin rules + learned patternsget_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:
agentRulerouter (admin-only mutations, workspace-aware reads) - Patterns:
agentOutcomePatternrouter (system-managed viaQuoteOutcomeHandler) - Scope types:
global,account,category,project_type—scopeRefIdisvarchar(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 requiresgoogle_orchestrator_*permissions. - Personal (
connectionRole: 'personal'): Per-user. Connected in My Settings. Data visible ONLY to owning user (owning_user_id— not bypassable). Opt-inai_access_enabledgates AI access.
Resolution via GoogleConnectionResolver:
getOrchestratorConnection(workspaceId)— unique per workspacegetPersonalConnection(workspaceId, userId)— unique per user per workspacegetAIAccessibleConnections(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)
confirmResolutionvalidates workspace ownership, preserves opportunity linksgetRelevantMessagesreturns ≤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 sent → invoice.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)