Ecopick

Ecopick — Complete Architecture Reference

Purpose: Self-contained reference for the entire Ecopick system. Covers every model, every API route, every permission rule, every state machine, and every data flow. Intended as context for LLM prompts — nothing is omitted.


1. Overview

Ecopick is a building operations orchestration platform (B2B2C) that streamlines the complete lifecycle: tenant/occupant request → triage → routing → dispatch → SLA enforcement → evidence → closure. It gives property owners operational oversight, tenants a single point of contact, and vendors structured job intake with tracked performance.

Multi-tenant isolation: Every business document is scoped to a propertyOwnerId. Users gain access via two independent mechanisms:

  • Membership — organizational role (building manager, vendor tech, tenant admin)
  • UserUnit — personal location (apartment, parking, assigned workspace)

A single user can hold both (e.g., a building manager who also lives in the building).


2. Tech Stack

LayerTechnologyVersion
LanguageTypeScript (strict mode)^5
FrameworkNext.js (App Router)16.1.4
UI libraryReact19.2.3
CSSTailwind CSS v4^4
Component kitshadcn/ui^3.7.0
Type-safe APItRPC11.12.0
State (client)Zustand5.0.11
Query cacheTanStack React Query5.90.21
AuthenticationCustom (Argon2id, HMAC, WebAuthn)
ValidationZod^3.25.76
AIAnthropic Claude Opus 4claude-opus-4-6
IconsLucide React0.544.0
Date utilsdate-fns + Luxon4.1.0 / 3.7.2
i18nnext-intl^4.3.7
ORMPrisma^6.3.1
DatabasePostgreSQL
MCP@modelcontextprotocol/sdk1.27.1
Cloud (AWS)S3, SES, SNS, Scheduler^3.978.0
PasskeysSimpleWebAuthn^13.2.2
Password hashargon2^0.44.0
TOTPotplib^13.1.1
Panelsreact-resizable-panels^4.6.4
TestingVitest + Testing Library^4.0.18
Themingnext-themes^0.4.6

Important Auth Note: Ecopick uses a custom production-grade auth system. Do not introduce NextAuth, Passport.js, or any auth library. Session cookies (ecopick_session

  • ecopick_refresh) are HMAC-signed and managed by the custom auth layer.

3. Project Structure

ecpk/
├── .claude/launch.json                           # Dev server config (port 4500)
├── package.json
├── tsconfig.json
├── next.config.ts
├── postcss.config.mjs
├── components.json                               # shadcn config
├── CLAUDE.md                                     # Claude Code auto-loaded instructions
├── docs/
│   ├── ARCHITECTURE.md                           # This file
│   ├── ecopick-architecture.md                   # Earlier architecture reference
│   └── ecopick-operations.md                     # Reconciled operations spec
├── prisma/
│   └── schema.prisma                             # PostgreSQL schema (64+ models)
├── proxy.ts                                      # Auth gate + i18n routing (replaces middleware.ts)
├── messages/                                     # i18n translation files (en, de, fr, it)
├── public/                                       # Static assets
├── app/
│   ├── layout.tsx                                # Root layout + providers
│   ├── (pages)/[locale]/                         # Locale-prefixed page routes
│   │   ├── layout.tsx                            # Dashboard shell
│   │   ├── page.tsx                              # Dashboard home
│   │   └── tickets/
│   │       ├── page.tsx                          # Ticket dashboard (stats + cards)
│   │       └── [ticketId]/page.tsx               # Ticket workspace (3-panel)
│   └── api/
│       ├── auth/                                 # 18 authentication endpoints
│       ├── tickets/                              # 7 ticket REST endpoints
│       ├── admin/                                # Approvals, audit, security monitoring
│       ├── vendors/                              # Vendor inbox + dispatch response
│       ├── dev/seed/route.ts                     # Dev seed (non-production)
│       ├── trpc/[trpc]/route.ts                  # tRPC HTTP handler
│       ├── events/[propertyOwnerId]/route.ts     # SSE endpoint
│       ├── ai/stream/route.ts                    # AI streaming endpoint
│       ├── mcp/route.ts                          # Model Context Protocol
│       └── ...                                   # Memberships, invitations, etc.
└── src/
    ├── client/
    │   ├── devSession.ts                         # Dev session (localStorage)
    │   ├── api.ts                                # REST fetch wrapper
    │   └── hooks/                                # useDevSessionState, useTickets
    ├── components/
    │   ├── tickets/workspace/                    # 3-panel ticket workspace
    │   │   ├── TicketWorkspace.tsx                # Main workspace component
    │   │   ├── TicketLeftPanel.tsx                # Metadata panel
    │   │   ├── TicketRightPanel.tsx               # Operations panel
    │   │   └── sections/                          # 12 lazy-loaded section components
    │   │       ├── SectionRenderer.tsx
    │   │       ├── OverviewSection.tsx
    │   │       ├── DetailsSection.tsx
    │   │       ├── ActivitySection.tsx
    │   │       ├── TeamSection.tsx
    │   │       ├── HistorySection.tsx
    │   │       ├── SLASection.tsx
    │   │       ├── AttachmentsSection.tsx
    │   │       ├── AISection.tsx
    │   │       ├── RelatedSection.tsx
    │   │       ├── BillingSection.tsx
    │   │       └── VendorSection.tsx
    │   └── ui/                                   # 42 shadcn/ui components
    ├── i18n/                                     # next-intl routing + navigation
    ├── lib/auth/                                 # Auth system (session, crypto, device, rate limit)
    ├── server/
    │   ├── ai/
    │   │   └── anthropicService.ts               # Anthropic Claude integration
    │   ├── auth/
    │   │   └── appAdmin.ts                       # App admin checks
    │   ├── db/
    │   │   └── prisma.ts                         # Prisma client singleton
    │   ├── errors.ts                             # AppError class
    │   ├── governance/
    │   │   ├── policies.ts                       # Policy-as-code (authorize())
    │   │   ├── permissions.ts                    # ResourcePermission service
    │   │   ├── auditGovernance.ts                # Audit functions
    │   │   ├── resourceActions.ts                # Action + ResourceType definitions
    │   │   └── index.ts                          # auditWrite, auditRead, diffFields
    │   ├── routing/
    │   │   └── routingService.ts                 # Auto-routing logic
    │   ├── tickets/
    │   │   └── ticketService.ts                  # Ticket business logic (623 lines)
    │   └── trpc/
    │       ├── init.ts                           # tRPC init + context + procedures
    │       ├── root.ts                           # Root appRouter (24 sub-routers)
    │       └── routers/                          # 24 router files
    ├── store/
    │   ├── ticketUIStore.ts                      # Active section, panel state (persisted)
    │   ├── uiStore.ts                            # Command palette
    │   ├── sessionStore.ts                       # Current user
    │   └── notificationStore.ts                  # Toast stack
    └── mcp/
        └── server.ts                             # Model Context Protocol server

4. Data Models (Prisma Schema — 64+ models)

4.1 Organizational Hierarchy

PropertyOwner

Top-level business entity — property management companies or landlords. Every building belongs to exactly one PropertyOwner.

id, name, ownerType (property_management | landlord), createdAt, updatedAt
Relations: buildings[], memberships[], routingRules[], slaPlans[], tickets[],
           vendorSubscriptions[], serviceCatalogs[], scheduledTasks[],
           vendorPerformances[], invoicesReceived[], teamTemplates[], auditEvents[]

TenantCompany

Corporate tenant leasing space in a building (e.g., "Bloomberg LP").

id, name, companyType (corporate | residential | retail), createdAt, updatedAt
Relations: leases[], memberships[], tickets[]

Lease

Links TenantCompany to Building + specific units.

id, tenantCompanyId, buildingId, unitIds[], startDate, endDate?, isActive, createdAt, updatedAt
Indexes: tenantCompanyId, buildingId, isActive

Building

Physical building managed by a PropertyOwner.

id, propertyOwnerId, name, address1?, address2?, city?, postalCode?, country?,
createdAt, updatedAt
Relations: units[], userUnits[], tickets[], routingRules[], memberships[],
           vendorSubscriptions[], scheduledTasks[], leases[]
Indexes: propertyOwnerId

Unit

Apartment, office, parking spot, or storage unit within a building.

id, buildingId, label, unitType (UnitType enum), floor?, description?, createdAt, updatedAt
Relations: userUnits[], tickets[], memberships[], sections[], routingRules[], scheduledTasks[]
Unique: [buildingId, label]
Indexes: buildingId, unitType

UnitSection

Specific area within a unit (kitchen, bathroom, corridor, etc.) for precise issue reporting.

id, unitId, name, sectionType (SectionType enum), floor?, roomNumber?,
description?, isActive, createdAt, updatedAt
Relations: tickets[]
Unique: [unitId, name]
Indexes: unitId, sectionType, isActive

4.2 Users & Access

User

Any person interacting with the platform. Dual-context access via Membership + UserUnit.

id, email (unique), emailVerified, name?, firstName?, lastName?, birthDate?, sex?,
language, isAppAdmin, passwordHash?, passwordUpdatedAt?, twoFactorEnabled,
totpSecretEnc?, twoFactorEnabledAt?, createdAt, updatedAt

Auth relations: authSessions[], authRefreshTokens[], authPendingLogins[],
  authRecoveryCodes[], authAuditEvents[], webauthnCredentials[],
  securityAlerts[], blockedSources[], rateLimitViolations[], rateLimitAttempts[]
Governance relations: accessEvents[], accessDecisions[], accessRequestsRequested[],
  accessRequestsToDecide[], accessRequestTasksToReview[],
  resourcePermissions[] (TO this user), grantedResourcePermissions[] (BY this user)
Business relations: memberships[], userUnits[], reportedTickets[], ticketStakeholders[],
  messages[], uploadedAttachments[], auditEvents[], onboardings[], notifications[],
  vendorOpportunityViews[], scheduledTasksAssigned[], invoicesApproved[],
  aiInsightsDismissed[], aiInsightsFlagged[]

UserUnit

Personal relationship between User and Unit (resident, owner, parking).

id, userId, unitId, buildingId, relationship, isPrimary, canRaiseTickets,
startDate, endDate?, isActive, createdAt, updatedAt
Unique: [userId, unitId]
Indexes: userId, unitId, buildingId, isActive

Membership

Organizational role linking User to PropertyOwner, TenantCompany, or VendorOrg.

id, userId, role (MembershipRole enum), propertyOwnerId?, tenantCompanyId?,
vendorOrgId?, buildingId?, unitId?, createdAt, updatedAt
Indexes: userId, propertyOwnerId, tenantCompanyId, vendorOrgId, buildingId, unitId

MembershipRole Enum

Property Owner staff:  PROPERTY_OWNER, BUILDING_MANAGER, BUILDING_MAINTENANCE
Tenant Company staff:  TENANT_ADMIN, TENANT_COORDINATOR
Occupants:             TENANT_OCCUPANT, RESIDENT
Legacy (compat):       BUILDING_ADMIN, CARETAKER
Vendors:               VENDOR_ADMIN, VENDOR_TECH

4.3 Tickets

Ticket

Core work request entity — the central object of the platform.

id, propertyOwnerId, tenantCompanyId?, buildingId, unitId?, unitSectionId?,
title, description?, category, priority (TicketPriority), status (TicketStatus),
reporterId, currentAssignmentId?, vendorOrgId?, slaDueAt?, slaBreachedAt?,
triagedAt?, resolvedAt?, closedAt?, cancelledAt?, openToVendors, vendorDeadline?,
createdAt, updatedAt
Relations: assignments[], attachments[], auditEvents[], messages[], slaEvents[],
  stakeholders[], vendorOpportunityViews[], accessRequests[], aiInsights[],
  vendorPerformances[], invoices[]
Indexes: [propertyOwnerId, status], [tenantCompanyId, status], buildingId,
         unitId, unitSectionId, vendorOrgId, [openToVendors, category]

TicketStatus Enum

NEW, TRIAGED, ASSIGNED, VENDOR_OPPORTUNITY, VENDOR_PENDING,
IN_PROGRESS, AWAITING_TENANT, AWAITING_VENDOR, RESOLVED, CLOSED, CANCELLED

TicketPriority Enum

LOW, MEDIUM, HIGH, URGENT

Assignment

Links a Ticket to a person or vendor for resolution.

id, ticketId, type (AssignmentType), state (AssignmentState),
assignedToUserId?, vendorOrgId?, createdAt, updatedAt
Indexes: ticketId, vendorOrgId, assignedToUserId

AssignmentType Enum

INTERNAL, VENDOR

AssignmentState Enum

PENDING, ACCEPTED, DECLINED, CANCELLED

Important: No COMPLETED value. Do not use AssignmentState.COMPLETED.

TicketStakeholder

Observer/participant on a ticket with fine-grained permissions. Prisma accessor: prisma.ticketStakeholder (not prisma.stakeholder).

id, ticketId, userId, role, canView, canComment, canEdit, canResolve,
addedById, addedAt, removedAt?
Unique: [ticketId, userId]
Indexes: ticketId, userId

Message

Comment or update on a ticket's activity feed. Important: Content field is body (not content).

id, ticketId, authorId, body, createdAt, updatedAt
Relations: attachments[]
Indexes: [ticketId, createdAt]

Attachment

File uploaded to a ticket (comment attachment or resolution evidence).

id, ticketId, messageId?, uploadedById, type (AttachmentType),
storageKey, fileName, contentType?, sizeBytes?, createdAt
Indexes: ticketId, messageId, type

AttachmentType Enum

COMMENT_ATTACHMENT, RESOLUTION_EVIDENCE

4.4 Vendors

VendorOrg

External service provider organization.

id, name, createdAt, updatedAt
Relations: assignments[], memberships[], routingRules[], tickets[], subscriptions[],
           opportunityViews[], serviceCatalogs[], scheduledTasks[],
           vendorPerformances[], billingRates[], invoicesSent[]

VendorSubscription

Categories + buildings a vendor wants to receive opportunities for.

id, vendorOrgId, buildingId?, propertyOwnerId, categories[], isActive,
notifyUrgent, notifyHigh, notifyMedium, notifyLow,
maxConcurrentJobs?, serviceRadiusMiles?, createdAt, updatedAt
Unique: [vendorOrgId, buildingId, propertyOwnerId]

VendorOpportunityView

Tracks when a vendor views an open ticket opportunity.

id, ticketId, vendorOrgId, viewedAt, viewedById
Unique: [ticketId, vendorOrgId]

VendorPerformance

Service quality metrics per completed ticket.

id, vendorOrgId, propertyOwnerId, ticketId, responseTimeMinutes?,
resolutionTimeMinutes?, rating? (1-5), ratingComment?, slaMetDeadline?, createdAt
Unique: [vendorOrgId, ticketId]

4.5 Routing & SLA

RoutingRule

Auto-assignment configuration per category/building.

id, propertyOwnerId, buildingId, unitId?, category,
defaultAssigneeUserId?, defaultVendorOrgId?, priority (int), isActive,
createdAt, updatedAt
Indexes: [propertyOwnerId, buildingId], [buildingId, unitId], category

SLAPlan

Response/resolution time targets per property owner.

id, propertyOwnerId, name, defaultMinutes (default 1440), createdAt, updatedAt

SLAEvent

SLA lifecycle events (started, paused, breached, met).

id, ticketId, planId?, type, occurredAt
Indexes: ticketId, occurredAt

4.6 Authentication

AuthSession

Active login session. Created on successful authentication.

id, userId, rememberMe, deviceFingerprint?, persistentDeviceId?,
userAgent?, ip?, location?, createdAt, expiresAt, lastSeenAt
Indexes: userId, deviceFingerprint, persistentDeviceId, expiresAt

AuthRefreshToken

Hashed refresh token for session rotation (1:1 with AuthSession).

id, sessionId (unique), userId, tokenHash, deviceFingerprint?,
persistentDeviceId?, createdAt, expiresAt, lastUsedAt, revokedAt?
Indexes: userId, deviceFingerprint, persistentDeviceId, expiresAt, revokedAt

AuthPendingLogin

In-progress 2FA challenge (TOTP, email code, passkey).

id, userId, method, codeSalt?, codeHash?, codeAttempts, codeSentAt?,
createdAt, expiresAt, consumedAt?, ip?, deviceFingerprint?
Indexes: userId, expiresAt

AuthEmailVerification

Email verification during registration or email change.

id, email, salt, codeHash, attempts, createdAt, expiresAt, verifiedAt?, consumedAt?
Indexes: email, expiresAt, verifiedAt

AuthRecoveryCode

One-time backup codes for 2FA recovery.

id, userId, salt, hash, createdAt, usedAt?
Indexes: userId, usedAt

WebAuthnCredential

Registered passkey for passwordless login.

id, userId, credentialId (unique, Bytes), publicKey (Bytes), counter,
transports[], deviceFingerprint?, persistentDeviceId?, createdAt, lastUsedAt?
Indexes: userId, deviceFingerprint, persistentDeviceId, createdAt

4.7 Security Monitoring

AuthAuditEvent

Immutable log of auth events (login, logout, 2FA, password change).

id, userId?, email?, action, outcome, reasonCode?, ip?, userAgent?,
method?, route?, requestId?, correlationId?, deviceFingerprint?, meta?, createdAt
Indexes: createdAt, [userId, createdAt]

BlockedSource

Blocked IP, device fingerprint, or user account.

id, type (ip | device_fingerprint | user), value, reason?, blockedUntil?,
permanent, archived, unblockedAt?, meta?, userId?, createdAt, updatedAt
Unique: [type, value]
Indexes: [type, value], blockedUntil, archived, userId

RateLimitViolation

Rate limit threshold exceeded — aggregated violation + ban state.

id, userId?, key?, field?, type, method?, route?, ip?, deviceFingerprint?,
persistentDeviceId?, count, lastViolation, attempts?, bannedUntil?, expireAt?,
createdAt, updatedAt
Indexes: userId, deviceFingerprint, [type, field], bannedUntil, expireAt

RateLimitAttempt

Individual API request tracked for rate limiting (write-heavy, periodically pruned).

id, userId?, type, field?, key?, route?, method?, ip?, deviceFingerprint?,
persistentDeviceId?, userAgent?, createdAt
Indexes: userId, deviceFingerprint, createdAt

SecurityAlert

Automated threat detection (brute force, credential stuffing, impossible travel).

id, alertCode (unique), userId?, sourceId?, sourceType?, sourceSnapshot?,
riskScore (default 20), type, category, severity, status (open | resolved),
detectedAt, resolvedAt?, field?, key?, count, deviceInfo?, deviceFingerprint?,
persistentDeviceId?, ip?, method?, route?, meta?, createdAt, updatedAt
Indexes: userId, status, sourceId, deviceFingerprint, updatedAt

4.8 Governance

AccessEvent

Read audit log (every significant data access).

id, userId, action (AccessAction), resourceType, resourceId,
contextOrgId?, ip?, userAgent?, method?, route?, requestId?,
correlationId?, metadata?, createdAt
Indexes: [userId, createdAt], [resourceType, resourceId, createdAt], [contextOrgId, createdAt]

AccessDecision

Policy check audit trail (every authorize() call).

id, userId, action, resourceType, resourceId?, granted, policyName?,
reason?, ip?, userAgent?, method?, route?, requestId?, correlationId?, createdAt
Indexes: [userId, createdAt], [resourceType, resourceId, createdAt], [granted, createdAt]

AccessRequest

Formal request for elevated access or sensitive action.

id, ticketId?, requestedById, approverId, action, status (AccessRequestStatus),
reason?, decisionNote?, metadata?, submittedAt, decidedAt?, closedAt?,
expiresAt?, createdAt, updatedAt
Indexes: ticketId, [approverId, status], requestedById, [status, expiresAt]

AccessRequestTask

Individual review step in approval workflow.

id, accessRequestId, reviewerUserId, taskType, status (AccessRequestTaskStatus),
orderIndex, dueAt?, decidedAt?, decisionNote?, metadata?, createdAt, updatedAt
Indexes: [accessRequestId, status], [reviewerUserId, status], taskType

ResourcePermission

Granular per-resource permission grant (2D matrix).

id, userId, resourceType, resourceId, permissions (JSON matrix),
full, needsApproval, emergency, grantedById?, grantedAt, expiresAt?, revokedAt?,
createdAt, updatedAt
Unique: [userId, resourceType, resourceId]
Indexes: [resourceType, resourceId], userId, expiresAt, revokedAt

Permission matrix format: { "view": ["*"], "edit": ["units", "routing"], "comment": ["*"] }

  • Keys = actions, Values = scopes (sub-resources / domains)
  • ["*"] = unrestricted scope for that action
  • full: true = god-mode bypass
  • needsApproval: true = requires approval workflow
  • emergency: true = override normal restrictions
  • expiresAt = temporary access
  • revokedAt = soft revocation (record persists for audit)

4.9 Audit Event

AuditEvent

Immutable log of business mutations on tickets. Scoped to PropertyOwner.

id, propertyOwnerId, ticketId?, actorId?, action (AuditAction),
ip?, userAgent?, method?, route?, requestId?, correlationId?,
meta?, changes? (JSON [{field, from, to}]), createdAt
Indexes: [propertyOwnerId, createdAt], [ticketId, createdAt], [actorId, createdAt]

4.10 AI

AIInsight

AI-generated analysis persisted per ticket.

id, ticketId, type (triage_suggestion | vendor_recommendation | priority_assessment | summary),
content (JSON), confidence? (0.0-1.0), status (AIInsightStatus),
dismissedById?, flaggedById?, modelId?, promptVersion?, createdAt, updatedAt
Indexes: [ticketId, type], status, createdAt

AIInsightStatus Enum

GENERATED, ACCEPTED, DISMISSED, FLAGGED

4.11 Billing

BillingRate

Vendor service rate card.

id, vendorOrgId, category, rateType (HOURLY | FIXED | PER_UNIT),
amount (Decimal 10,2), currency (EUR), isActive, createdAt, updatedAt
Indexes: vendorOrgId, category, isActive

Note: Prisma Decimal serializes as string over JSON. Always wrap with Number().

Invoice

Vendor invoice to property owner.

id, vendorOrgId, propertyOwnerId, ticketId?, invoiceNumber (unique),
status (InvoiceStatus), totalAmount (Decimal 12,2), currency (EUR),
dueDate?, lineItems (JSON), approvedById?, paidAt?, createdAt, updatedAt
Indexes: vendorOrgId, propertyOwnerId, ticketId, status, dueDate

InvoiceStatus Enum

DRAFT, SUBMITTED, APPROVED, PAID, DISPUTED, CANCELLED

RateType Enum

HOURLY, FIXED, PER_UNIT

4.12 Supporting Models

ServiceCatalog + ServiceItem

Vendor/internal service offerings with estimated duration and category mapping.

ServiceCatalog: id, propertyOwnerId?, vendorOrgId?, name, description?, isActive
ServiceItem: id, catalogId, name, description?, category, estimatedDurationMinutes?,
  requiresAccess, isActive

ScheduledTask

Recurring maintenance tasks using iCal RRULE with auto-ticket creation.

id, propertyOwnerId, buildingId, unitId?, title, description?, category,
rrule?, nextOccurrence?, assigneeUserId?, vendorOrgId?,
autoCreateTicket, lastTicketId?, isActive, createdAt, updatedAt

TeamTemplate

Reusable team composition templates with role requirements and permissions.

id, propertyOwnerId, name, description?, roles (JSON), isActive, createdAt, updatedAt

Notification + NotificationPreference

Multi-channel notifications (in_app, email, push) with per-user type preferences.

Notification: id, userId, type, title, body?, resourceType?, resourceId?,
  channel (in_app | email | push), readAt?, sentAt?, createdAt
NotificationPreference: id, userId, type, channels[], enabled, updatedAt
  Unique: [userId, type]

Invitation

Tokenized invite to join an org/building with a role.

id, token (unique), email, firstName?, lastName?, role, organizationType?,
organizationId?, buildingId?, unitId?, metadata?, status (pending | accepted | expired | revoked),
invitedBy?, createdAt, expiresAt, acceptedAt?, revokedAt?

UserOnboarding

Multi-step onboarding progress tracker (user setup + role-specific).

id, userId, type (user | role), role?, step, data?, startedAt, completedAt?, updatedAt
Unique: [userId, type]

4.13 Enums Summary

EnumValues
UnitTypeAPARTMENT, OFFICE, PARKING, STORAGE, RETAIL, COMMON_AREA, AMENITY, ROOFTOP, OTHER
SectionTypeKITCHEN, PANTRY, BATHROOM, BEDROOM, LIVING_ROOM, DINING_ROOM, CORRIDOR, ENTRANCE, BALCONY, CLOSET, LAUNDRY, CONFERENCE_ROOM, WORKSPACE, RECEPTION, BREAKROOM, RESTROOM, LOBBY, STAIRWELL, ELEVATOR, MECHANICAL, UTILITY, OTHER
MembershipRolePROPERTY_OWNER, BUILDING_MANAGER, BUILDING_MAINTENANCE, TENANT_ADMIN, TENANT_COORDINATOR, TENANT_OCCUPANT, RESIDENT, BUILDING_ADMIN, CARETAKER, VENDOR_ADMIN, VENDOR_TECH
TicketStatusNEW, TRIAGED, ASSIGNED, VENDOR_OPPORTUNITY, VENDOR_PENDING, IN_PROGRESS, AWAITING_TENANT, AWAITING_VENDOR, RESOLVED, CLOSED, CANCELLED
TicketPriorityLOW, MEDIUM, HIGH, URGENT
AssignmentTypeINTERNAL, VENDOR
AssignmentStatePENDING, ACCEPTED, DECLINED, CANCELLED
AttachmentTypeCOMMENT_ATTACHMENT, RESOLUTION_EVIDENCE
AccessActionVIEW, LIST, SEARCH, EXPORT, DOWNLOAD
AccessRequestStatusDRAFT, SUBMITTED, IN_REVIEW, APPROVED, REJECTED, CANCELLED, CLOSED, EXPIRED
AccessRequestTaskStatusPENDING, IN_PROGRESS, APPROVED, REJECTED, SKIPPED, CANCELLED
AuditActionTICKET_CREATED, STATUS_CHANGED, TRIAGED, ASSIGNED, DISPATCHED, VENDOR_ACCEPTED, VENDOR_DECLINED, MESSAGE_POSTED, ATTACHMENT_UPLOADED, RESOLVED, CLOSED, CANCELLED, STAKEHOLDER_ADDED, STAKEHOLDER_REMOVED
AIInsightStatusGENERATED, ACCEPTED, DISMISSED, FLAGGED
InvoiceStatusDRAFT, SUBMITTED, APPROVED, PAID, DISPUTED, CANCELLED
RateTypeHOURLY, FIXED, PER_UNIT

4.14 Data Deletion Philosophy

Operational data is never hard-deleted. All records transition through status-based lifecycles:

EntityStatus Transitions
TicketNEW → TRIAGED → ASSIGNED → IN_PROGRESS → RESOLVED → CLOSED / CANCELLED
AssignmentPENDING → ACCEPTED / DECLINED / CANCELLED
InvoiceDRAFT → SUBMITTED → APPROVED → PAID / DISPUTED / CANCELLED
SecurityAlertopen → resolved
Invitationpending → accepted / expired / revoked
AccessRequestDRAFT → SUBMITTED → IN_REVIEW → APPROVED / REJECTED / EXPIRED / CANCELLED
TicketStakeholderActive → removedAt set (soft remove)
ResourcePermissionActive → revokedAt set (soft revoke)
AuditEventImmutable — never deleted
AuthAuditEventImmutable — never deleted
AccessEventImmutable — never deleted

5. Permission Model (Policy-as-Code)

5.1 Architecture

Single enforcement point: authorize() in src/server/governance/policies.ts. Fail-closed design — unknown actions are denied. Every decision is audited.

authorize(action: string, ctx: PolicyContext): Promise<void>
// Throws AppError(403) if denied. Audits every decision (granted + denied).
// App admins bypass all checks.

5.2 Role Hierarchy

MANAGEMENT_ROLES:  PROPERTY_OWNER, BUILDING_MANAGER, BUILDING_ADMIN
OPERATIONS_ROLES:  MANAGEMENT_ROLES + BUILDING_MAINTENANCE + CARETAKER
VENDOR_ROLES:      VENDOR_ADMIN, VENDOR_TECH

5.3 Policy Registry (5 domains)

ticket:view

  1. Explicit ResourcePermission grant
  2. Operations role membership in property owner
  3. Ticket reporter
  4. Resident of ticket's unit (via UserUnit)
  5. Member of assigned vendor org
  6. Active stakeholder with canView=true

ticket:edit

  1. Explicit ResourcePermission grant
  2. Management role membership
  3. Active stakeholder with canEdit=true

ticket:resolve

  1. Explicit ResourcePermission grant
  2. Management role membership
  3. Active stakeholder with canResolve=true

ticket:assign

  1. Explicit ResourcePermission grant
  2. Management role membership only

ticket:triage

  1. Explicit ResourcePermission grant
  2. Operations role membership

ticket:export

  1. Explicit ResourcePermission grant
  2. Management role membership only

ticket:create

  1. Explicit ResourcePermission (raise_tickets on unit)
  2. Operations role membership
  3. Resident of target unit (via UserUnit)
  4. Tenant role membership (TENANT_OCCUPANT, TENANT_ADMIN, TENANT_COORDINATOR)

building:view

  1. Explicit ResourcePermission grant
  2. Any membership in property owner
  3. Active UserUnit in building

building:manage

  1. Explicit ResourcePermission grant
  2. Management role membership only

vendor:view_opportunities / vendor:respond_assignment

  1. Explicit ResourcePermission grant
  2. Vendor role membership in vendor org

audit:view — Management only

audit:export — PROPERTY_OWNER only

approval:view — Management only

approval:create — Operations roles

approval:decide — Management only

5.4 ResourcePermission (Granular Overrides)

2D matrix: { action: scope[] }. Works alongside Membership/UserUnit.

Functions: hasPermission, hasAllPermissions, hasAnyPermission, getPermissions, grantPermissions, setPermissions, revokePermissions, revokeScopes, grantPermissionsBulk, getAccessibleResources, getAllPermissionsForUser.


6. Authentication System

6.1 Session Cookies

CookiePurposeTTL
ecopick_sessionHMAC-signed session ID12h (default) or 30d (rememberMe)
ecopick_refreshStateful refresh token30d

6.2 Session Lifecycle

  1. Login (password/passkey) → issueSessionBundle() → session + refresh token
  2. Each request → verify signed cookie → getSessionUser()
  3. Session expired → rotateSessionBundleFromRefreshToken() → new session
  4. Refresh token rotation: old token invalidated, new one issued

6.3 Security Layers

  • HMAC-SHA256 cookie signing with timing-safe comparison
  • Argon2id password hashing
  • Device fingerprinting (non-invasive: UA + accept-language + platform)
  • Progressive rate limiting per email/IP/device fingerprint
  • Blocked source tracking (IP, device, user)
  • AES-256-GCM encryption for sensitive data (TOTP secrets)

6.4 2FA Methods

  • TOTP — Time-based one-time password (otplib)
  • Email OTP — Code sent via SES
  • WebAuthn/Passkey — Passwordless via SimpleWebAuthn

6.5 Dev Seed Auth

POST /api/dev/seed creates a real AuthSession and sets cookies for immediate dev access. In browser, call fetch('/api/dev/seed', {method: 'POST'}) to get cookies set automatically, then set localStorage ecopick_dev_session_v1 from the response for the tickets dashboard.

6.6 Proxy (Auth Gate)

proxy.ts uses Next.js 16's proxy API (replaces middleware.ts):

  • Public paths: /api/auth/*, /api/trpc/*, /api/dev/*, /_next/*, /favicon*
  • All other routes: verify session → redirect to login if no session
  • i18n routing: locale-prefixed paths (/en/tickets, /de/tickets)

7. API Layer (tRPC — 24 routers)

7.1 tRPC Setup

// src/server/trpc/init.ts
interface TRPCContext {
  user: SessionUser | null;
}
export const protectedProcedure = t.procedure.use(/* validates session */);

7.2 Root Router

export const appRouter = router({
  tickets,
  ai,
  approvals,
  adminAudit,
  security,
  vendors,
  routingRules,
  memberships,
  permissions,
  invitations,
  units,
  users,
  reports,
  onboarding,
  appCheck,
  auditIngest,
  public,
  aiInsights,
  serviceCatalog,
  scheduledTasks,
  vendorPerformance,
  billing,
  notifications,
  teamTemplates,
});

7.3 Router Procedures

ticketsRouter

ProcedureTypeInputDescription
listquerybuildingId?, status?List tickets for user's property owner
detailqueryticketIdFull ticket with all relations
createmutationbuildingId, title, description?, category, priority?, autoRoute?Create ticket + auto-route
updatemutationticketId, title?, description?, category?, priority?Update ticket fields
addMessagemutationticketId, bodyPost message on ticket
addAttachmentmutationticketId, messageId?, type, storageKey, fileName, contentType?, sizeBytes?Upload attachment
assignmutationticketId, type, assignedToUserId?, vendorOrgId?Assign to staff/vendor
triageTicketmutationticketIdMark ticket as triaged
transitionStatusmutationticketId, statusChange ticket state
exportTicketsquerybuildingId?, status?, format?Bulk export
listOrgMembersqueryUsers in caller's org (for assignment picker)
listVendorOrgsqueryVendors linked to caller's buildings

billingRouter

ProcedureTypeInputDescription
listRatesqueryvendorOrgId?View billing rates
listInvoicesqueryvendorOrgId?, status?, ticketId?List invoices (with ticket filter)
exportqueryinvoiceIdDownload invoice

vendorsRouter

ProcedureTypeInputDescription
inboxqueryView vendor ticket opportunities
respondmutationticketId, acceptAccept/decline assignment

membershipsRouter

ProcedureTypeInputDescription
listqueryGet user's memberships
deletemutationmembershipIdRemove membership
addBusinessRolemutationrole, orgId, buildingId?Request business role
addResidencemutationunitId, relationshipAdd unit access

invitationsRouter

ProcedureTypeInputDescription
createmutationemail, role, orgType?, orgId?, buildingId?, unitId?Send invitation
listqueryView pending invitations
acceptmutationtokenAccept invitation
resendmutationinvitationIdResend invitation

onboardingRouter (~1100 lines)

Multi-step setup flows: initOnboarding, createPropertyOwner, createBuilding, createUnit, inviteUser, and many more procedures for the complete setup wizard.

usersRouter

ProcedureTypeInputDescription
myUnitsquerycontext?, buildingId?, unitType?List units user has access to

approvalsRouter

ProcedureTypeInputDescription
listquerystatus?Pending approvals
createmutationticketId?, action, approverId, reason?Request approval
decidemutationapprovalId, decision, note?Approve/reject
cancelmutationapprovalIdWithdraw request

routingRulesRouter

ProcedureTypeInputDescription
listquerybuildingId?View routing rules
createmutationbuildingId, category, vendorOrgId?, assigneeUserId?, priority?Create rule
updatemutationruleId, ...Modify rule
deletemutationruleIdRemove rule

aiRouter

ProcedureTypeInputDescription
completemutationprompt (max 4000), systemPrompt?Generate AI text completion

aiInsightsRouter

ProcedureTypeInputDescription
listByTicketqueryticketIdGet AI insights for ticket
createmutationticketId, type, content, confidence?, modelId?Store insight
updateStatusmutationinsightId, statusMark dismissed/flagged

permissionsRouter

Grant, revoke, and query ResourcePermission records.

securityRouter

changePassword, enableTwoFactor, disableTwoFactor, listSessions, revokeSession.

notificationsRouter

list, markRead, updatePreferences.

adminAuditRouter

listAuditEvents, exportAuditData.

Other routers

scheduledTasksRouter, serviceCatalogRouter, teamTemplatesRouter, unitsRouter, reportsRouter, publicRouter, auditIngestRouter, appCheckRouter, vendorPerformanceRouter.


8. REST API Routes

8.1 Authentication (/api/auth/)

MethodPathDescription
POST/auth/loginPassword login (returns session cookie)
POST/auth/registerUser signup
POST/auth/register/send-codeSend email verification code
POST/auth/register/verify-codeValidate email code
POST/auth/logoutClear session
POST/auth/refreshRotate refresh token
GET/auth/meCurrent user info
POST/auth/password/changeChange password
POST/auth/password-reset/requestInitiate password reset
POST/auth/securitySecurity settings
POST/auth/verify-access-passwordVerify for sensitive ops
POST/auth/2fa/startBegin 2FA challenge
POST/auth/2fa/verifyComplete 2FA
GET/auth/email/existsCheck email availability
POST/auth/passkeys/register/optionsWebAuthn registration options
POST/auth/passkeys/register/verifyComplete passkey registration
POST/auth/passkeys/login/optionsWebAuthn login options
POST/auth/passkeys/login/verifyComplete passkey login
GET/auth/passkeys/availableList registered passkeys
DELETE/auth/passkeys/[passkeyId]Remove passkey
GET/auth/sessionsList active sessions
DELETE/auth/sessions/[sessionId]Logout specific session

8.2 Tickets (/api/tickets/)

MethodPathDescription
GET/ticketsList tickets with filters
POST/ticketsCreate ticket
GET/tickets/[ticketId]Get ticket detail
PUT/tickets/[ticketId]Update ticket
POST/tickets/assignAssign to vendor/staff
POST/tickets/statusChange status
POST/tickets/triageCategorize ticket
POST/tickets/messageAdd message
POST/tickets/attachmentUpload attachment

8.3 Other REST Routes

MethodPathDescription
GET/POST/membershipsList/create memberships
POST/memberships/add-business-roleRequest role
POST/memberships/add-residenceAdd unit access
GET/POST/invitationsList/send invitations
GET/invitations/[token]View invitation details
POST/invitations/[token]/acceptAccept invitation
GET/PUT/DELETE/routing-rules/[ruleId]Manage routing rules
GET/POST/routing-rulesList/create routing rules
GET/units/[unitId]Unit details
GET/units/[unitId]/sectionsUnit sections
GET/users/me/unitsUser's units
GET/POST/admin/approvalsApproval management
POST/admin/approvals/[approvalId]/decideDecide on approval
GET/admin/auditView audit logs
GET/POST/admin/security_and_monitoring/security_alertsSecurity alerts
GET/admin/security_and_monitoring/security_alerts/[alertId]Alert detail
POST/admin/security_and_monitoring/security_alerts/[alertId]/severity/updateUpdate severity
POST/admin/security_and_monitoring/security_alerts/[alertId]/status/updateUpdate status
GET/admin/security_and_monitoring/rate_limits_violationsRate limit violations
GET/public/buildings/[buildingId]/requestsPublic ticket form
POST/onboardingOnboarding flow
GET/vendors/inboxVendor inbox
POST/vendors/dispatch/respondRespond to dispatch
GET/app/check-blockCheck if user is blocked
POST/auditLog access event
GET/events/[propertyOwnerId]SSE event stream
GET/reports/overviewDashboard summary
POST/ai/streamStream AI completion
POST/mcpModel Context Protocol
GET/permissionsQuery permissions
POST/dev/seedDev seed (non-production only)

9. State Machines

9.1 Ticket Lifecycle

NEW → TRIAGED → ASSIGNED → IN_PROGRESS → RESOLVED → CLOSED
                    ↓
            VENDOR_OPPORTUNITY → VENDOR_PENDING
                                     ↓
                               AWAITING_VENDOR
       ↓ (any active state)
    AWAITING_TENANT
       ↓ (any active state)
    CANCELLED
  • Resolution requires RESOLUTION_EVIDENCE attachment
  • SLA due date computed at creation based on priority

9.2 Assignment Lifecycle

PENDING → ACCEPTED (vendor/staff confirmed)
        → DECLINED (vendor/staff refused → re-route)
        → CANCELLED (admin cancelled)

9.3 Invoice Lifecycle

DRAFT → SUBMITTED → APPROVED → PAID
                  → DISPUTED
                  → CANCELLED

9.4 Access Request Lifecycle

DRAFT → SUBMITTED → IN_REVIEW → APPROVED
                              → REJECTED
                              → EXPIRED
                  → CANCELLED

9.5 SLA Priority Targets

PriorityResponse Target
URGENT60 minutes
HIGH4 hours (240 min)
MEDIUM24 hours (1440 min)
LOW3 days (4320 min)

10. Ticket Service (Business Logic)

File: src/server/tickets/ticketService.ts (~623 lines)

createTicket

Validates input via Zod, computes SLA due date from priority, creates ticket, audits with field-level diffs, auto-routes if autoRoute !== false.

listTickets

Filtered by propertyOwnerId, buildingId, status. Max 200, newest first.

getTicketDetail

Full ticket with all relations: building, unit, unitSection, reporter, assignments (with vendorOrg), stakeholders, messages (with attachments + author), attachments, auditEvents (with actor).

triageTicket

Only NEW tickets. Sets status=TRIAGED, triagedAt timestamp. Audits with field diff { status: NEW → TRIAGED }.

assignTicket

Creates Assignment record. Updates ticket status to ASSIGNED. Validates not CLOSED/CANCELLED. Audits with metadata (assignee/vendor).

vendorRespondToAssignment

Accept/decline vendor dispatch. On decline: resets ticket to TRIAGED, triggers re-routing excluding the declined vendor.

addMessage

Creates message on ticket. Audits.

addAttachment

Creates attachment record. Audits.

transitionTicketStatus

Validates not CLOSED/CANCELLED. Requires RESOLUTION_EVIDENCE for RESOLVED. Sets corresponding timestamp (resolvedAt, closedAt, cancelledAt). Audits with field diff.

autoRouteAndDispatch

Queries RoutingRules by category/building/unit (highest priority first). If ticket is NEW, auto-triages. Assigns to default assignee (INTERNAL) or vendor (VENDOR). Supports exclusion lists for re-routing.

computeSlaBreachesNow

Finds overdue tickets (slaDueAt < now, not yet breached). Batch-marks slaBreachedAt timestamp.


11. Zustand Stores

ticketUIStore (persisted via localStorage)

activeSection: TicketSection;
// overview | details | activity | team | history | sla | attachments | ai | related | billing | vendor
leftCollapsed: boolean;
rightCollapsed: boolean;

uiStore

commandPaletteOpen: boolean;

sessionStore

user: { id, email, name, role } | null
isLoaded: boolean

notificationStore

notifications: Notification[]

12. Ticket Workspace (Component Architecture)

3-panel resizable layout using react-resizable-panels.

Left Panel (TicketLeftPanel)

Ticket metadata: info card, attributes, context (building/unit/section), stakeholders list.

Center Panel (SectionRenderer)

Active section content via React.lazy + Suspense. 11 sections:

SectionComponentKey Features
overviewOverviewSectionTicket summary, reporter info, timeline
detailsDetailsSectionEditable title, description, category, priority, status
activityActivitySectionMessage thread with composer
teamTeamSectionReporter, stakeholders, assignments with assign dialog
historyHistorySectionAudit event timeline with actor names and field diffs
slaSLASectionSLA tracking, breach status, time remaining
attachmentsAttachmentsSectionFile gallery with upload
aiAISectionAI insights (triage suggestions, vendor recommendations)
relatedRelatedSectionOther tickets in same building
billingBillingSectionInvoice table for this ticket
vendorVendorSectionVendor info + performance stats

Right Panel (TicketRightPanel)

Operations: next action suggestion, SLA window countdown, AI insights preview, quick note composer.

Section Navigation

Sections are navigated via ticketUIStore.setActiveSection() and rendered by SectionRenderer using React.lazy.


13. AI Integration

13.1 Anthropic Service

File: src/server/ai/anthropicService.ts

const MODEL = "claude-opus-4-6";
createCompletion(prompt, systemPrompt?): Promise<string>  // max_tokens: 1024
streamCompletion(prompt, systemPrompt?): AsyncIterable<string>  // max_tokens: 2048

13.2 AI Insights Pipeline

  1. Insight generated (triage_suggestion, vendor_recommendation, priority_assessment, summary)
  2. Stored as AIInsight with confidence score, model ID, prompt version
  3. Displayed in AI section + right panel preview
  4. Users can accept, dismiss, or flag insights

13.3 Streaming Endpoint

POST /api/ai/stream — Server-sent events for real-time AI text generation.


14. Audit Architecture

Four independent audit streams with cross-stream correlation:

HTTP Request
    │
    ├── Business mutation → AuditEvent       (requestId, correlationId)
    ├── Auth event       → AuthAuditEvent    (requestId, correlationId)
    ├── Read access      → AccessEvent       (requestId, correlationId)
    └── Policy decision  → AccessDecision    (requestId, correlationId)

Stream 1: AuditEvent (Business Mutations)

Every ticket lifecycle change: created, assigned, triaged, resolved, etc. Includes field-level change diffs via diffFields(). Scoped to PropertyOwner for multi-tenant isolation.

Stream 2: AuthAuditEvent (Authentication)

Login, logout, password change, 2FA enable/disable, passkey registration, failed attempts. Includes IP, device fingerprint, user agent.

Stream 3: AccessEvent (Read Access)

Every significant data access (VIEW, LIST, SEARCH, EXPORT, DOWNLOAD). Tracks which org context the user was operating in.

Stream 4: AccessDecision (Policy Audit)

Every authorize() call logged with policy name, grant/deny result, and reason.

Audit Functions

auditWrite(event): Promise<void>       // Business mutation audit
auditRead(event): Promise<void>        // Read access audit
auditAccessDecision(event): Promise<void>  // Policy decision audit
diffFields(before, after): FieldDiff[] // Field-level change detection
snapshotFields(record): Snapshot       // Pre-mutation snapshot

15. i18n (Internationalization)

Configuration

  • Library: next-intl v4
  • Locales: en, de, fr, it
  • Default: en
  • Routing: Locale-prefixed paths (/en/tickets, /de/tickets)
  • Files: /messages/{locale}.json

Implementation

  • proxy.ts handles locale routing via Next.js 16 proxy API
  • src/i18n/ contains routing and navigation configuration
  • Translation keys organized by feature area

16. Dev Seed

POST /api/dev/seed (non-production only):

  • Creates PropertyOwner, VendorOrg, Building, 3 Units
  • Creates 7 users across all major roles:
    • Admin (app admin + property owner)
    • Tenant admin
    • Vendor admin
    • Caretaker
    • Building supervisor
    • Reporter (resident)
    • Occupant
  • Creates Memberships across roles + UserUnit records
  • Creates RoutingRules for auto-dispatch
  • Creates 6 tickets at various lifecycle stages:
    • With messages, attachments, AI insights
    • Across different statuses and priorities
  • Creates vendor performance records, invoices
  • Issues AuthSession + sets ecopick_session and ecopick_refresh cookies

17. Configuration

SettingValue
Dev server port4500
Dev commandnpm run dev (→ next dev -p 4500)
Build commandnpm run build (→ next build)
Start commandnpm run start (→ next start -p 4500)
Typechecknpm run typecheck (→ tsc --noEmit)
Testnpm run test (→ vitest)
Prisma generatenpm run prisma:generate
Prisma migratenpm run prisma:migrate
Prisma studionpm run prisma:studio
AI keyANTHROPIC_API_KEY env var
DB URLDATABASE_URL env var
tRPC endpoint/api/trpc
SSE endpoint/api/events/[propertyOwnerId]
Session cookieecopick_session (12h / 30d with rememberMe)
Refresh cookieecopick_refresh (30d)
CSS engineTailwind v4 via @tailwindcss/postcss

18. Key Patterns & Gotchas

Model Name Gotchas

  • Use prisma.ticketStakeholder (not prisma.stakeholder)
  • Message field is body (not content)
  • AssignmentState enum: PENDING, ACCEPTED, DECLINED, CANCELLED — no COMPLETED
  • TicketStatus includes: VENDOR_OPPORTUNITY, VENDOR_PENDING, AWAITING_TENANT, AWAITING_VENDOR
  • Prisma Decimal serializes as string over JSON — always wrap with Number() when formatting

Auth Patterns

// REST route with auth
export const GET = withAuth(async (req, {user, session}) => {
  await authorize("ticket:view", {userId: user.id, resourceType: "Ticket", ...});
  // handler
});

// tRPC procedure (protectedProcedure already validates session)
myProcedure: protectedProcedure
  .input(z.object({ticketId: z.string()}))
  .query(async ({ctx, input}) => {
    // ctx.user available
  }),

Data Patterns

  • No hard deletes on operational data. Use status transitions (state machines).
  • Audit everything. Every mutation goes through auditWrite() → AuditEvent.
  • Validate with Zod. No raw any types. No unvalidated inputs.
  • Policy check first. Every protected handler calls authorize() before business logic.
  • Dual-context aware. Always consider both Membership (org) and UserUnit (personal) when checking access.

File Naming

  • kebab-case.ts for utility files
  • PascalCase.tsx for React components
  • Router files named after domain: ticketsRouter.ts, billingRouter.ts
  • Types and schemas colocated with server code

Event-Driven Updates

Every mutation that modifies operational data:

  1. Validates permission via authorize()
  2. Performs database operation
  3. Logs audit entry via auditWrite()
  4. (Future) Emits event for real-time updates via SSE

19. What This Platform Does NOT Do

  • Does not replace property management ERP software
  • Does not do procurement or contract management
  • Does not manage physical access control systems or CCTV
  • Does not build native mobile apps (responsive web covers the use case)
  • Does not process payments (tracks invoices, doesn't collect money)
  • Does not manage lease agreements (stores lease references for access control)

This document is auto-maintained. Last updated: 2026-03-10.

Loading…