Space 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)

Authorization (RBAC)

Hybrid role model. Nine seeded immutable system role templates in …/permissions/src/roles.ts, mirrored to DB by seedAuthzSystem; workspace-scoped custom roles in roles. Permissions: atomic entity:action keys in definitions.ts. Memberships→roles via membership_roles.

Enforcement is declarative:

someProcedure: workspaceAwareProcedure
  .meta({ permission: "invoice:approve" })
  .input(...).mutation(...);

A single enforceAuthz middleware validates meta against caller's effective permissions (@zrm/authz.resolveEffectivePermissions); deny-by-default on missing meta. resolveWorkspace hydrates ctx.user.{permissions,roles} so inline checks see workspace-scoped truth.

Platform superadmin is tightly-controlled users.is_platform_superadmin flag (only superadmins reach Master Workspace 00000000-0000-0000-0000-000000000001). Cross-workspace leakage is blocked by scopedFilter()/workspaceOrMaster() on every workspace-bound query + hand-written *-isolation.test.ts suites (no generated matrix—add one per new by-id procedure). Admin UIs: roles (/dashboard/admin/roles), sessions (/dashboard/me/sessions, /dashboard/admin/sessions), audit (/dashboard/admin/activity).

Four staff login paths—password, 15-min magic link (MagicLinkService) + workspace invites (InvitationService), Google sign-in, mobile bearer—all land on one sessions row + JWT sessionId. Every transient artifact (magic link, invite, password reset, OAuth/PKCE state, link-confirm, mobile refresh) rides the unified auth_tokens table (opaque, hashed, single-use, purpose-tagged). No self-signup: Google OAuth rejects emails not already in users (no_matching_user); first Google linking needs password/magic-link proof before user_identities(provider='google_oauth') is minted, via its own OAuth client + env (GOOGLE_LOGIN_CLIENT_ID/SECRET/REDIRECT_URI, distinct from GOOGLE_CLIENT_*). Mobile bearer: 1h access JWT + 30d single-use refresh—POST /auth/refresh rotates in one tx; replaying a consumed token revokes the session. Bearer parser rejects device_type='web', cookie reader ignores Authorization; clients opt in via deviceType: 'ios'|'android'|'api_client' on the consume/callback/accept mutations. Flags (off→404): ENABLE_MAGIC_LINK_LOGIN, ENABLE_GOOGLE_OAUTH_LOGIN (each + NEXT_PUBLIC_*), ENABLE_MOBILE_BEARER (server-only). Entry: Hono POST /auth/magic-link/{request,consume}, /invite/accept, GET /auth/oauth/google/{start,callback}, POST /auth/refresh; tRPC mirrors auth.magicLink.*/auth.googleLogin.*/invitation.*. Admin UI /dashboard/admin/members (workspace_membership:manage). Rate limits: docs/auth/{magic-link,google-oauth-login,mobile-bearer}-runbook.md; invariants in AGENTS.md § "Extend the auth/identity flow".

Router→Service Pattern

Routers are thin (validate input→call service→throw TRPCError). Services contain all business logic: DB queries, event publishing, search indexing, transactions. Worked example in services/api/CLAUDE.md.

Workspace Scoping (Multi-Tenancy)

Use scopedFilter(table.id, entityId, table.workspaceId, workspaceId) for getById/update/delete + .where(eq(table.workspaceId, workspaceId)) on list queries; the master workspace (UUID above) bypasses filtering for admin access. For shared/master-data queries (product catalog, categories), use workspaceOrMaster(products.workspaceId, workspaceId).

Monetary Field Convention

Monetary columns are PostgreSQL numeric (typically numeric(12,2)); Drizzle returns strings.

  • Read: services convert at return boundary via numericToNumber()/numericToNumberNN() (@zrm/db); API sends numbers.
  • Aggregate (totals, tax/discount) in SQL numeric—never sum money in JS loops; simple math (surcharge) may stay JS w/ Math.round(v*100)/100.
  • Write: Drizzle takes numbers or strings; pass Zod-validated numbers (String() when it infers string); null clears nullable field, undefined skips it.
  • Frontend: EditableDetailRow onChange returns strings—Number() before tRPC.

Quote→Project Conversion

quote.accepted triggers: (1) project w/ budget/scope/logistics; (2) payment-term milestones→project milestones; (3) draft invoice w/ BOM→line mapping (material→materials, labor→labor, recurring→service); (4) opportunity→won; (5) Temporal workflow for signed PDF + notifications. Idempotent via acceptedQuoteId/quoteId; milestoneType separates payment (auto) from project (manual).

Cost Roll-Up Cascades

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

bubbleTicketLaborHours sums WO labor to parent ticket; WO completion bumps totalServiceCount/failureCount on linked asset (ticket.relatedAssetId); PM schedule changes recompute plan metrics + nextServiceDue. All roll-ups include workspaceId to block cross-workspace FK manipulation.

Registry-Driven Detail Pages

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

<RegistrySection fields={entityFields} section="Identity" data={mergedData}
  editing={isEditing} onChange={handleFieldChange} variant="rows" collapsible />

All Phase 2+ entities use this.

SLA Templates & Auto-Resolution

Contract-based SLA w/ customer inheritance. sla_templates sets response/resolution times per priority; ticket creation resolves customer template (or workspace default)→slaResponseDueAt/slaResolutionDueAt. Tickets auto-resolve w/ breach detection when all WOs reach terminal status.

Monitoring Account Billing

automationSchedulerWorkflow daily: monitoring_accounts WHERE nextBillingDate <= CURRENT_DATE AND autoBilling AND status='active'→draft invoices w/ line items, advances nextBillingDate, in-app notifies owners/admins.

Automation Engine (Rules, Actions, Templates)

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

ActionExecutor dispatches by actionType: create_record (ticket/work_order/invoice); update_status (against UPDATABLE_FIELDS—ticket status/priority/assignedUserId, work_order status/priority, invoice status); send_notification (by userId/role, inserts automation_notifications, optional email); run_workflow (Temporal); create_reorder_po.

Template vars: {{event.field}}/{{payload.field}} via resolveTemplateVars() (unresolved→empty). Autonomy: auto_execute immediate; draft_for_review/agent_review write drafted trace, approval re-runs ActionExecutor.execute() w/ stored payloads. Templates: 14 static TS in …/automation/templates/; activateTemplate creates a disabled automation_rule.

Operations Dashboard

/dashboard real-time hub: KPIs, role-aware "My Work", revenue/pipeline/activity, agent stats + an Attention sidebar (SLA breaches, overdue invoices, stale opps, maintenance due, automation approvals). Data via dashboardOpsDashboardOpsService (parallel aggregates, no new tables); polling 30s–5min per panel.

Dashboards V2 (/dashboards/[slug])

Role-aware customizable dashboards in @zrm/dashboards (dashboard + widget defs, layout schemas, access gating) + @zrm/dashboard-snapshot (compute composables). Ten slugs: operations, executive, field, finance, sales, service, dispatch, projects, fleet, system. /dashboards resolves role-default slug; /dashboards/[slug] validates against registry. Gated by ENABLE_DASHBOARD_V2 (+ NEXT_PUBLIC_*) + workspace_settings.dashboard_v2_enabled. (Legacy /dashboard/mission-control, superseded by system slug.)

Data flow: 9 of 10 are snapshot-backed—a per-workspace Temporal workflow runs DashboardSnapshotService.compute<Dashboard>Payload() (composables under Promise.allSettled, keyed dashboard.widget) into dashboard_snapshots (PK workspace_id, dashboard_id); client reads via dashboardData.getSnapshot. Widgets w/ a live source also poll it, swapping in via placeholderData. The field ("My Day") dashboard is live per-userdashboardField.getSnapshot computes per request from ctx.user.id (input z.object({}).strict() rejects a spoofed userId); never snapshotted.

Customization: per-user layouts persist to user_dashboard_layouts (PK user_id, workspace_id, dashboard_id; layout_json + schema_version) via dashboardLayout.{get,save,reset}. Absent/invalid/stale rows fall back to DashboardDef.defaultLayout; saved layouts are server-filtered to strip inaccessible/off-dashboard widgets. Render/customize modes in apps/web/CLAUDE.md; authoring recipes in AGENTS.md.

Mobile Capture

Mobile bottom-nav Capture button (gated on agent_operatoruser.me hydrates roles). Two-step sheet picks a source (camera/file) then a destination over search_index (8 entity types). Artifacts PUT to S3 (presigned); capture.finalize writes a linked entity_attachments+media_artifacts pair for every destination—no per-entity split (validation map in capture-entity-tables.ts).

Daily Briefing (@zrm/briefing)

Morning brief: brief-pack.service.ts (open tickets, due maintenance, recent quotes, pipeline deltas)→narrated by Claude (narrator.service.ts)→dispatched via briefing/channels/* (in-app + Slack). Cron in automation_rules via Temporal. History /dashboard/me/briefings; AI spend /dashboard/admin/activity.

Points of Interest (POI)

Cross-entity flag/note on 10 detail pages. pois stores (entity_type, entity_id, flagType, note, status, workspaceId); poi-target-validator.ts guards against flagging a foreign-workspace entity. List at /dashboard/pois.

Secrets (Device & Software Credentials)

Encrypted login credentials (system/username/password/host/port/notes), distinct from credentials (physical badges). secrets attaches to exactly one asset XOR system (DB CHECK num_nonnulls = 1); create validates target's workspace (forged-FK guard). Passwords AES-256-GCM via token-encryption.service.ts; list/get strip ciphertext—plaintext only via secret.reveal (per-call, activity-logged; UI uses vanilla client, never useQuery). Roll-ups: listForEntity over customer/site/space/system/asset (space recursion, junction-aware). Perms secret:{read,reveal,manage}—field roles get reveal. Export CSV/xlsx/PDF (secret-export.service.tsx, plaintext, manage-gated, one secret.exported audit row). UI /dashboard/secrets + Secrets tab on 5 pages. Systems topology: systems belong to a customer, link to 0..n sites via system_sites; systems.siteId gone.

Quote Versioning

quotes.versionGroupId + versionNumber group revisions; revise_quote clones into same group, previous→superseded on new send. Numbers YYYYMM-#### per workspace/month. Portal renders latest; portalQuote.getDiff powers a per-line diff (added/removed/modified BOM lines + financial deltas). Quotes also carry editable narrative fields (summary, overview/type, est. dates, change-order terms) plus an ordered quote_scope_sections table (atomic quoteScopeSection.replace, cloned on revision, rendered as quote-PDF Scope sections).

Procurement (Vendors, POs, Receiving)

State transitions in procurement-helpers.ts (free-standing tx-taking fns).

PO state machine: draft→pending_approval→submitted→acknowledged→partially_received→received; reject/cancel→canceled. PurchaseOrderService.update() ignores status—transitions only via submitPo/approvePo/rejectPo/receivePoLine; editing past the threshold reverts to draft + clears the trail. Threshold: submitPo w/ po.totalAmount >= workspaceSettings.approvalRequiredAtAmountpending_approval (needs procurement:approve_po). Helpers SELECT ... FOR UPDATE the PO row.

Receiving→cost cascade: receivePoLine is the only receipt path—WO-linked POs auto-upsert work_order_materials keyed sourcePoLineItemId (idempotent)→recalculateWorkOrderTotalsactualTotalCost; non-WO just updates received_quantity. Vendor-Product: vendor_products m2m (pricing/lead-time/MOQ/SKU); partial unique caps ≤1 isPreferred per (productId, workspaceId), flip via setPreferredVendorProduct. Perms procurement:{manage_vendors,manage_purchase_orders,approve_po,receive_po}—admin all; sales_rep vendors+POs; technician receive_po.

Direction: purchase_orders.direction (outbound/inbound, default outbound, CHECK vendor/customer resp.)—above machine outbound-only; inbound starts draft, advances via advanceInboundPo: draft→received→in_fulfillment→invoiced→closed. Vendor Quotes: vendor_quotes/vendor_quote_lines (mirrors bom_lines) + presigned upload, vendorQuote router, vendor "Quotes" tab; Generate PO converts an accepted quote→outbound PO (BOM copied, quote→converted). purchaseOrder.generateInvoice: inbound PO→draft invoice (idempotent via sourcePurchaseOrderId, dual-perm gated).

Inventory (Warehouses, Stock Levels, Transactions)

Warehouse→bin hierarchy (one default warehouse/workspace + one default bin/warehouse, partial unique indexes). stock_levels is a materialized aggregate of immutable stock_transactions ledger, updated per insert in-tx; quantityAvailable = quantityOnHand - quantityReserved (query-time).

All mutations go through inventory-helpers.ts (receive/issue/adjust/transfer/return + isBelowReorderPoint): outbound ops lock stock_levels FOR UPDATE. Dual-path receiving: receivePoLine checks product.trackInventory—tracked→receive (PO warehouse→workspace default), WO-linked also issue; non-tracked unchanged. Reorder threshold COALESCEs product_stock_settings override→products.reorderPoint (both null = none); crossing fires product_stock.below_reorder_point with preferredVendorId. Opt-in (trackInventory=false default). Perms 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→draft POs (idempotent via purchase_orders.sourceQuoteId); products without preferred vendor skip with admin notification.

Reorder rules: create_reorder_po reacts to product_stock.below_reorder_point, resolves preferred vendor + qty (warehouse override→product default→reorderPoint × 2) via reorder-helpers.ts. Template auto_reorder_low_stock: 24h cooldown, draft_for_review. Batches REORDER-prefixed POs into an open same-vendor draft (< 24h) rather than minting new; quote-sourced (AUTO-prefix) always mints new. Dashboard: stock.createReorderPo/bulkCreateReorderPos.

AI Provider Settings & Usage Monitoring

Per-provider key (openai/anthropic/google; AES-GCM, write-only, masked-hint reads) + chat/ask model: /dashboard/settings/ai / /dashboard/admin/settings/ai (ai_settings:manage). @zrm/ai-settings resolveAiConfig resolves each key workspace→master→env (AI_PROVIDER/AI_MODEL/AI_API_KEY + ANTHROPIC_API_KEY/OPENAI_API_KEY/GOOGLE_AI_API_KEY); keyForProvider() picks one: Ask/agent/briefing (incl. scheduled) always use anthropicApiKey, embeddings (resolveEmbeddingConfig, master→env only) always use openaiApiKey; both ai_call-logged. Ask model is a select picker backed by MODEL_CATALOG (@zrm/ai/catalog), which also derives MODEL_PRICING; add or reprice a model there, never in pricing.ts. Chat/embedding model fields are free text (catalog's OpenAI/Google entries aren't authoritative): chat writes are unvalidated; embedding writes still check the 1536-dim allowlist. Superseded models stay as status: "legacy" because getPricing throws on unknown ids. askModel/chatModel are asymmetric in anthropicChatParams—don't collapse them: askModel (Ask, ask-actions) applies under any provider (picker + update() constrain it to Anthropic ids; resolver re-checks via modelProvider()); chatModel (briefings) only when the resolved provider is anthropic, as it legitimately holds a foreign model. MODEL_PRICING spans all providers, so catalog membership never proves a model is safe to send to Anthropic. aiSettings.checkProviderModels diffs the catalog against each provider's live list-models endpoint (chat-capable models only, paginated). @zrm/ai-usage sums ai_calls vs ai_usage_budgets (platform+override, warn_percent 80); evaluateWorkspace fires warn/breach once/period (dedup via ai_usage_alert_state) through the ~15min aiUsageAlertWorkflow (in-app/email/Slack). Perms ai_usage:{read,manage}; UI /dashboard/admin/ai-usage.

AI Tool Execution Context

AI tools do NOT take workspaceId/userId from LLM params—injected server-side via ToolExecutionContext (anti-prompt-injection). Flow: SpecialistExecutor.delegate()→ExecuteToolLoopFn→ToolUseExecutor→ToolRegistry.execute(). New tools: cast via InjectedContext, guard userId at runtime when audit trails need it.

Products & Sales Agent Tools

Workspace-scoped tools registered at startup via registerSalesAgentTools(db): search_products (workspace+master scoping), get_labor_rate, generate_quote (quote + BOM atomically), revise_quote (new version w/ line mods), get_agent_rules (rules + learned patterns), get_email_context (threads for account/opp).

Agent Rules & Outcome Patterns

Admin rules guide AI (pricing, product selection, labor estimation); patterns auto-learned from quote outcomes. agentRule router (admin mutations, workspace-aware reads); agentOutcomePattern router (system-managed via QuoteOutcomeHandler). Scopes global/account/category/project_typescopeRefId varchar(255) (non-UUID scopes).

Dual Google Workspace Integration

Two connections per workspace in external_connections (connectionRole enum): Orchestrator—the AI's business Google account, one/workspace, admin-connected via Workspace Settings, all scopes, AI-referenceable for any user; Personal—per-user via My Settings, visible ONLY to owning_user_id (not bypassable), opt-in ai_access_enabled gates AI.

GoogleConnectionResolver: getOrchestratorConnection(workspaceId), getPersonalConnection(workspaceId, userId), getAIAccessibleConnections(workspaceId, userId) (orchestrator + personal). Perms (packages/permissions/src/definitions.ts): google_orchestrator_{gmail,calendar,drive}:{read,write/send}, google_orchestrator:manage (admin default all); personal connections have NO perm checks—only owning_user_id === ctx.user.id.

Email Thread Processing

EmailThreadProcessorService resolves addresses to accounts: P1 exact contact email (0.95, case-insensitive), P2 domain match (0.80). confirmResolution validates workspace ownership + preserves opp links; getRelevantMessages returns ≤50 most recent (LLM-bounded).

Accounting Settings (Tax Agencies & Payment Terms)

Workspace-level 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; quotes inherit from linked site. Customer taxExempt zeros; quote-level override wins. Payment Term Templates: milestone schedules (label, percentage sum 100%, trigger on_acceptance/on_completion/net_days/milestone); one default/workspace, managed atomically. Customers/quotes FK to templates.

Quote Delivery & Engagement Tracking

quote.send validates guards (draft/changes_requested/sent, BOM lines, totals, linked site, primary contact w/ email)→quote.sent→Temporal workflow (magic link→PDF→branded email + PDF→SMTP). Tracking: 1×1 pixel (opens) + redirect endpoint (clicks) via signed JWT with the redirect URL inside the token (open-redirect defense); QuoteEngagementService logs email_{sent,open}/link_click/portal_view/pdf_download. PDF: QuotePdfService (@react-pdf/renderer), lazy-imported, attached multipart/mixed.

Invoice Delivery, Payments & Auto-Generation

Mirrors quote delivery: InvoiceSendService.send()invoice.sent→Temporal invoiceDeliveryWorkflow (PDF via InvoicePdfService, portal magic link, branded email + pixel). Guards draft/approved/sent, line items, totalAmount, contact email. Engagement: InvoiceEngagementService + invoice_engagements (incl. payment_{initiated,completed,failed}); routes /tracking/invoice/:token/{pixel.png,open}.

Auto-gen: QuoteAcceptedHandler creates the invoice in project tx (idempotent via quoteId); generateMonitoringInvoices drafts dueDate +30d. recalculateInvoiceTotals() on line create/delete sums totalPrice, preserves manual taxAmount, nullifies pdfS3Key. Stripe: InvoicePaymentService.createCheckoutSession()POST /api/webhooks/stripe handles checkout.session.{completed,expired} (keys in payment_settings, idempotent via stripeCheckoutSessionId); portal pay UI = hero card, Pay Now, PDF, due-date countdown.

Product Catalog UI

/dashboard/products category-grouped table w/ server filtering + inline create; detail at /dashboard/products/[id]. Product↔Asset: products.productType uses assetTypeEnum (camera/controller/…), carried over when installed as an asset. Cut sheet: S3 presigned—getCutSheetUploadUrl PUT→cutSheetS3KeygetCutSheetDownloadUrl; content-type restricted server-side, keys timestamped (no user filenames).

Categories: /dashboard/settings/products manages a shared Product/Asset hierarchy. Workspace admins can create nested categories and attach cropped WebP avatars; inherited master categories are read-only. Product creation falls back to a workspace-local Uncategorized category when none is selected. Category attributes: attributeSchema JSONB {name, label, type, options?} rendered as EditableDetailRow (text/number/boolean/enum).

Scheduling & Dispatch

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

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

Availability (AvailabilityService): layers (highest first) approved time-off→Google Calendar blocks→per-day overrides→tech shift assignments→workspace default shift. getForUserOnDate resolves every layer in parallel (shift via workspaceOrMaster); range version fans out per-day. Skills: skill_definitions workspace-scoped (master seeds), technician_skills maps users; TechnicianSkillService.assign idempotent, bulkAssign one tx w/ scope validation.

Auto time entry: handleVisitCompleted on visit.completed creates an auto_tracked row, publishes time_entry.created, drives recalculateWorkOrderTotals. Idempotent via (workOrderId, userId, startTime, endTime, entryType) fingerprint.

Dispatch UI: /dashboard/dispatch hour-grid board + unassigned queue, drag-to-assign (mechanics in apps/web/CLAUDE.md); tech pages /dashboard/me/schedule, /dashboard/visits. Status/scheduling/assignment transitions stay on the dispatch slide-over—authorizeVisitMutation (assignee OR scheduling:manage_dispatch) is the single enforcement point.

Google Calendar (Temporal): push visits/time-off to orchestrator + cron-pull per-tech calendars into external_calendar_events (ai_access_enabled=true only; client in services/workflows — see its CLAUDE.md). Events keyed (google_event_id, user_id, workspace_id). Permissions: 8 perms (view_dispatch_board, manage_{dispatch,shifts,skills}, {request,approve}_time_off, {self,others}_assign); field_supervisor = technician + dispatch + time-off approval.

Semantic Search (Ask)

Hybrid retrieval + Claude-grounded answers over search_index. Question-shaped queries surface an answer card above keyword results at /dashboard/search; any query opts in via ?ask=1.

Retrieval: HybridRetriever (@zrm/search) runs BM25 + pgvector (cosine clamp < 0.6) in parallel, fuses via RRF (k=60); query vector caller-provided. ACL: aclPredicate({workspaceId, userId}) gates both retrieval halves and citation-hydrate (search.getByIdForCitation)—two SQL gates plus two anti-exfil barriers (citation-id validation drops hallucinated IDs; XML-tagged context isolates retrieved text).

AskService (…/services/ask/): parallel budget+cache lookup→embed→retrieve→build context (2KB/row, 48K-char ≈ 12K-token cap)→two-block system prompt (cacheControl: "ephemeral")→generate→validate citations→log activity_log→cache if high-confidence + not owner-only. Cache ask_answer_cache keyed sha256(workspace_id::normalized_query), 5-min TTL, swept hourly by askCacheCleanupWorkflow. Budgets 60/user/hr, 500/workspace/day; breach→429. AskQueryResult.degradationReasonembedder_unavailable|no_retrieval|suppressed_hallucination.

Embedding worker: embeddingBackfillWorkflow (Temporal singleton) polls search_index WHERE embedding IS NULL every 30s, batch 200, continueAsNew every 120 iter; indexer nulls embedding on title|subtitle|body change. UX: AskUsageWidget at /dashboard/admin/activity; all calls log as ai_call/semantic_search_answer. AskAnswerCard auto-triggers on question-shaped queries (900ms debounce).

Google Artifact Auto-Linking

Persistent Google↔CRM links via hybrid classifier + per-customer inbound aliases at inbound.zrm.app. Each classified artifact writes one entity_google_link row; UI reads persisted links instead of re-matching.

Classifier tiers (…/linker/src/artifact-linker.service.ts): T0 inbound alias <token>@inbound.zrm.appcrm_customers.inbound_email_token (1.0). T1 exact contact-email, case-insensitive (0.95). T2a sender/recipient domain match (0.80, emails only). T2b embedding→account cosine-nearest ≥0.80. T2c embedding→opp/ticket in matched account: ≥0.75 auto, 0.60–0.75 suggested. Runs inline in outbox tx on email_message.indexed/external_calendar_event.indexed/contact.upserted (~50ms); googleLinkBackfillWorkflow is a per-workspace Temporal singleton (90-day window), auto-started by bootstrapWorkflows.

Inbound email: SES→S3→SNS HTTPS→POST /api/webhooks/inbound-email; RFC822 Message-ID de-duped vs Gmail copies via partial unique uniq_email_messages_ws_rfc822; SNS audit in inbound_email_events. Runbook: docs/deployment/inbound-email-setup.md.

Schema: entity_google_link (status auto/suggested/confirmed/corrected/rejected); google_link_feedback (future learner); email_messages.source (gmail|inbound) + rfc822_message_id; crm_customers.inbound_email_token (8-char Crockford base32). ArtifactLinkerService lives in @zrm/linker (breaks @zrm/workflows ↔ @zrm/api cycle). UI: LinkedArtifactsWidget on customer/opp/ticket; review queue /dashboard/admin/google-links.

Customer Notifications & SMS

Outbound customer notifications over email + SMS. CustomerNotificationService resolves recipients, applies opt-out/quiet-hours/dedup guards, dispatches each channel via customerNotificationDeliveryWorkflow (Temporal); sendCustomerNotificationSmsActivity composes via @zrm/sms composeSmsMessage(), sends through SmsService. Every attempt is audited in customer_notification_deliveries.

@zrm/sms: Twilio client + validateTwilioSignature, normalizeMobile/isValidE164, matchKeyword (STOP/HELP/CANCEL/END/QUIT/UNSUBSCRIBE), templates. Inbound: POST /api/webhooks/twilio/inbound verifies signature; an opt-out keyword flips contacts.smsNotificationsEnabled=false (publishes contact.sms_opted_out), or replies to HELP via TwiML. Portal opt-in: portalNotificationPrefs.{requestSmsCode,confirmSmsCode} hashed 6-digit check against sms_opt_in_codes (rate-limited per contact + IP), sets contacts.smsVerifiedAt.

Flags: ENABLE_CUSTOMER_NOTIFICATIONS (master, email+SMS) + independent ENABLE_CUSTOMER_SMS (gates Twilio; needs TWILIO_ACCOUNT_SID/AUTH_TOKEN/FROM_NUMBER), each w/ NEXT_PUBLIC_* mirrors. Off→pipeline inert.

Fleet Monitoring (GPS)

Vehicle GPS at /dashboard/fleet-monitoring; provider + service layer in @zrm/fleet (shared by API + worker). OneStepGpsProvider (ONESTEP_TOKEN; absent→demo) caches telemetry + history, reads live fleet.{trips,alerts,diagnostics} (demo has none→those sections hide); vehicles.syncFromProvider enumerates devices. Temporal schedules (setup-schedules.ts) keep caches warm (latest/vehicles/stops→vehicle_stops/health) so locations.latest is a pure read.

Trips ↔ map ↔ playback: the drawer's timeline + route polyline join by time, not index—provider segments + our own history rows only share the clock. Playback/drawer mechanics in apps/web/CLAUDE.md. Runbook: docs/fleet/fleet-monitoring-setup.md.

Alerts + engine faults (persisted, event-bearing): sync-fleet-activity (5m) runs FleetActivitySyncService per workspace→vehicle_alerts/vehicle_dtc_logs, publishing fleet.alert.received/fleet.dtc.detected for first-seen rows only (exactly-once via onConflictDoNothing().returning(), published from insert tx). Watermarks, not cursors: OneStep's feed is newest-first, so each run resumes from the to_time of its last non-failed sync log (minus 15m overlap); a saturated run logs partial_success w/ oldest row reached. alerts.list/diagnostics.list read persisted-first; activity.feed is persisted-only. Automation: create_fleet_ticket files a DTC ticket against workspace_settings.fleet_maintenance_{customer,site}_id—unset→skip + notify admins, never throw.

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;
});

On this page