# B2 — Database Schema **Module:** B2 **Depends on:** B0 (Project Overview) **Read before:** B1, B3, B4, B5, B6, B7 (every other module references tables defined here) --- ## 1. Overview This document defines the full **PostgreSQL 16** schema for LynkedUpPro. Every table, column, type, constraint, and relationship is specified here. This is the single source of truth — all ORM models, migrations, and API schemas must match this document. ### Design Principles - **Soft deletes everywhere:** No `DELETE` in production. All tables have `deleted_at TIMESTAMPTZ NULL`. Rows with a non-null `deleted_at` are hidden from all queries by default. - **Audit trail:** Every write-capable table has `created_at`, `updated_at`, and the critical tables also write to `audit_logs`. - **Money in cents:** All monetary columns are `INTEGER` (cents). Never `DECIMAL` or `FLOAT` for money. - **UUIDs for public-facing IDs:** User-facing IDs use `UUID` to prevent enumeration attacks. Internal join keys use `INTEGER` sequences for performance. - **JSONB for flexible data:** Fields that are truly schema-less (insurance metadata, gamification achievements) use `JSONB`. --- ## 2. Entity Relationship Summary ``` users ──────────────────────────────────────────────────────────────┐ │ │ ├─── properties (assigned_agent_id → users.id) │ │ └─── property_photos │ │ │ ├─── meetings (agent_id → users.id, customer_id → users.id) │ │ └─── meeting_change_requests │ │ │ ├─── projects (owner_id → users.id) │ │ ├─── project_tasks (assigned_to → users.id) │ │ ├─── project_contractors (contractor_id → users.id) │ │ └─── change_orders │ │ │ ├─── vendors (managed by OWNER users) │ │ ├─── vendor_compliance_docs │ │ └─── vendor_orders (project_id → projects.id) │ │ │ ├─── invoices (issued_by → users.id, approved_by → users.id) │ │ │ ├─── documents (uploaded_by → users.id) │ │ │ ├─── notifications (user_id → users.id) │ │ │ └─── audit_logs (actor_id → users.id) │ │ sales_history (agent_id → users.id) ◄──────────────────────────────┘ ``` --- ## 3. Table Definitions ### 3.1 `users` The central identity table. All roles share this table, differentiated by the `role` column. ```sql CREATE TYPE user_role AS ENUM ( 'OWNER', 'ADMIN', 'FIELD_AGENT', 'CONTRACTOR', 'SUBCONTRACTOR', 'VENDOR', 'CUSTOMER' ); CREATE TYPE user_type AS ENUM ( 'employee', 'owner', 'contractor', 'subcontractor', 'vendor', 'customer' ); CREATE TABLE users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Identity legacy_id VARCHAR(20) UNIQUE, -- Preserves mock IDs ('e1', 'own_001', etc.) during migration email VARCHAR(255) UNIQUE NOT NULL, username VARCHAR(100) UNIQUE, -- For customers + non-employee login emp_id VARCHAR(20) UNIQUE, -- For FIELD_AGENT and ADMIN ('FA001', 'ADM01') password_hash VARCHAR(255) NOT NULL, -- bcrypt hash -- Profile full_name VARCHAR(255) NOT NULL, phone VARCHAR(30), role user_role NOT NULL, user_type user_type NOT NULL, is_active BOOLEAN NOT NULL DEFAULT TRUE, -- Role-specific optional fields company_name VARCHAR(255), -- OWNER, CONTRACTOR, SUBCONTRACTOR, VENDOR license_number VARCHAR(100), -- CONTRACTOR (e.g. 'TX-GC-123456') trade_type VARCHAR(100), -- SUBCONTRACTOR ('electrical'), VENDOR ('materials') -- Customer-specific property_id VARCHAR(50), -- Links customer to their property (pre-migration) -- Gamification (FIELD_AGENT only — others NULL) xp INTEGER DEFAULT 0, doors_knocked INTEGER DEFAULT 0, leads_gained INTEGER DEFAULT 0, appointments_set INTEGER DEFAULT 0, streak_days INTEGER DEFAULT 0, achievements JSONB DEFAULT '[]', -- ["Hot Spot Hunter", "Storm Chaser"] -- Auth last_login_at TIMESTAMPTZ, refresh_token_hash VARCHAR(255), -- Hashed refresh token for rotation -- Timestamps created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), deleted_at TIMESTAMPTZ -- Soft delete ); CREATE INDEX idx_users_role ON users(role) WHERE deleted_at IS NULL; CREATE INDEX idx_users_email ON users(email) WHERE deleted_at IS NULL; ``` --- ### 3.2 `properties` The core geospatial asset table. Each row is one property in Plano, TX. ```sql CREATE TYPE property_type AS ENUM ('Residential', 'Commercial', 'Apartments'); CREATE TYPE canvassing_status AS ENUM ( 'Hot Lead', 'Customer', 'Neutral', 'Not Interested', 'Renovated' ); CREATE TYPE roof_condition AS ENUM ('Excellent', 'Good', 'Fair', 'Needs Repair'); CREATE TABLE properties ( id SERIAL PRIMARY KEY, property_id VARCHAR(20) UNIQUE NOT NULL, -- 'P-2600', 'P-2612', etc. -- Location address VARCHAR(500) NOT NULL, city VARCHAR(100) NOT NULL DEFAULT 'Plano', state VARCHAR(10) NOT NULL DEFAULT 'TX', zip_code VARCHAR(10), latitude DECIMAL(10, 8) NOT NULL, longitude DECIMAL(11, 8) NOT NULL, polygon JSONB, -- [[lat,lng], [lat,lng], [lat,lng], [lat,lng]] -- Classification property_type property_type NOT NULL, canvassing_status canvassing_status NOT NULL DEFAULT 'Neutral', -- Physical attributes total_sqft INTEGER, year_built INTEGER, bedrooms SMALLINT, bathrooms DECIMAL(3,1), parking_spaces SMALLINT, lot_size VARCHAR(50), -- '0.22 Acres' -- Roof roof_condition roof_condition, last_roof_repair_date DATE, last_renovation_date DATE, renovation_description TEXT, renovation_cost INTEGER, -- Cents -- Financials estimated_market_value INTEGER, -- Cents tax_assessment_value INTEGER, -- Cents latest_purchase_price INTEGER, -- Cents latest_purchase_date DATE, asking_price INTEGER, -- Cents (if listed) listing_date DATE, -- CRM fields assigned_agent_id UUID REFERENCES users(id) ON DELETE SET NULL, last_contact_date TIMESTAMPTZ, pending_signature BOOLEAN NOT NULL DEFAULT FALSE, proposal_sent_date DATE, proposal_value INTEGER DEFAULT 0, -- Cents closed_date DATE, -- Owner info (denormalized for fast access) owner_name VARCHAR(255), owner_phone VARCHAR(30), owner_email VARCHAR(255), owner_occupation VARCHAR(255), owner_employer VARCHAR(255), owner_annual_income VARCHAR(50), -- '$80k - $120k' owner_credit_range VARCHAR(50), -- '720+' willing_to_sell BOOLEAN DEFAULT FALSE, desired_selling_price INTEGER, -- Cents min_selling_price INTEGER, -- Cents willing_to_rent BOOLEAN DEFAULT FALSE, desired_monthly_rent INTEGER, -- Cents -- Rental / tenant info currently_rented BOOLEAN NOT NULL DEFAULT FALSE, tenant_name VARCHAR(255), tenant_phone VARCHAR(30), tenant_email VARCHAR(255), tenant_occupation VARCHAR(255), tenant_employer VARCHAR(255), tenant_annual_income VARCHAR(50), living_status VARCHAR(50), -- 'Family', 'Single' lease_type VARCHAR(100), -- 'Residential Standard', 'Commercial NNN' monthly_rent_amount INTEGER, -- Cents lease_start_date DATE, lease_end_date DATE, lease_signed_date DATE, -- Insurance insurance_company VARCHAR(255), claim_filed BOOLEAN DEFAULT FALSE, claim_number VARCHAR(50), date_of_loss DATE, damage_location TEXT, adjuster_name VARCHAR(255), adjuster_phone VARCHAR(30), adjuster_type VARCHAR(100), has_paperwork BOOLEAN DEFAULT FALSE, insurance_meta JSONB DEFAULT '{}', -- Any additional insurance fields -- Misc school_district VARCHAR(100) DEFAULT 'Plano ISD', neighborhood_rating SMALLINT CHECK (neighborhood_rating BETWEEN 1 AND 10), crime_rate_index VARCHAR(50) DEFAULT 'Low', notes TEXT, condition_rating SMALLINT CHECK (condition_rating BETWEEN 1 AND 5), -- Timestamps created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), deleted_at TIMESTAMPTZ ); CREATE INDEX idx_properties_status ON properties(canvassing_status) WHERE deleted_at IS NULL; CREATE INDEX idx_properties_agent ON properties(assigned_agent_id) WHERE deleted_at IS NULL; CREATE INDEX idx_properties_location ON properties USING GIST (point(longitude, latitude)); ``` **`property_photos`** ```sql CREATE TABLE property_photos ( id SERIAL PRIMARY KEY, property_id INTEGER NOT NULL REFERENCES properties(id) ON DELETE CASCADE, url VARCHAR(1000) NOT NULL, caption VARCHAR(255), sort_order SMALLINT DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); ``` --- ### 3.3 `meetings` Scheduled inspections/consultations between agents and customers. ```sql CREATE TYPE meeting_status AS ENUM ( 'Scheduled', 'Rescheduled', 'In Progress', 'Completed', 'Converted', 'Cancelled' ); CREATE TABLE meetings ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), legacy_id VARCHAR(20) UNIQUE, -- Preserves 'm1', 'm-alice-1', etc. -- Participants agent_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, customer_id UUID REFERENCES users(id) ON DELETE SET NULL, customer_name VARCHAR(255), -- Denormalized for guests without accounts -- Location property_id INTEGER REFERENCES properties(id) ON DELETE SET NULL, property_address VARCHAR(500), -- Snapshot at time of booking -- Scheduling meeting_date DATE NOT NULL, meeting_time TIME NOT NULL, status meeting_status NOT NULL DEFAULT 'Scheduled', -- Content issue_description TEXT, customer_comments TEXT, notes TEXT, outcome VARCHAR(100), -- 'Signed Contract', 'Quote Sent', 'Pending' deal_value INTEGER, -- Cents; populated on Completed/Converted -- Timestamps created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), deleted_at TIMESTAMPTZ ); CREATE INDEX idx_meetings_agent ON meetings(agent_id, meeting_date) WHERE deleted_at IS NULL; CREATE INDEX idx_meetings_status ON meetings(status) WHERE deleted_at IS NULL; ``` **`meeting_change_requests`** *(Phase 9 — Meeting Governance)* ```sql CREATE TYPE change_request_status AS ENUM ('Pending', 'Approved', 'Rejected'); CREATE TABLE meeting_change_requests ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), meeting_id UUID NOT NULL REFERENCES meetings(id) ON DELETE CASCADE, requested_by UUID NOT NULL REFERENCES users(id), reviewed_by UUID REFERENCES users(id), -- Proposed changes proposed_date DATE, proposed_time TIME, proposed_status meeting_status, reason TEXT NOT NULL, -- Resolution status change_request_status NOT NULL DEFAULT 'Pending', reviewer_note TEXT, resolved_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); ``` --- ### 3.4 `vendors` External companies providing materials or services (not users; managed by OWNERs). ```sql CREATE TYPE vendor_status AS ENUM ('Active', 'Inactive', 'Pending Review', 'Suspended'); CREATE TABLE vendors ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), legacy_id VARCHAR(20) UNIQUE, -- 'v1', 'v3', etc. company_name VARCHAR(255) NOT NULL, contact_name VARCHAR(255), email VARCHAR(255), phone VARCHAR(30), website VARCHAR(500), trade_type VARCHAR(100), -- 'materials', 'electrical', 'roofing', etc. status vendor_status NOT NULL DEFAULT 'Active', -- Performance (updated by scheduler/trigger) on_time_delivery_rate DECIMAL(5,2), -- Percentage 0.00–100.00 defect_rate DECIMAL(5,2), avg_response_hours DECIMAL(6,2), total_spend_cents INTEGER DEFAULT 0, total_orders INTEGER DEFAULT 0, -- Compliance snapshot (updated from vendor_compliance_docs) coi_expiry_date DATE, w9_on_file BOOLEAN DEFAULT FALSE, license_expiry_date DATE, is_compliant BOOLEAN DEFAULT FALSE, -- Computed from compliance docs notes TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), deleted_at TIMESTAMPTZ ); ``` **`vendor_compliance_docs`** ```sql CREATE TYPE compliance_doc_type AS ENUM ('COI', 'W9', 'LICENSE', 'CONTRACT', 'OTHER'); CREATE TYPE compliance_doc_status AS ENUM ('Active', 'Expiring Soon', 'Expired', 'Missing'); CREATE TABLE vendor_compliance_docs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), vendor_id UUID NOT NULL REFERENCES vendors(id) ON DELETE CASCADE, doc_type compliance_doc_type NOT NULL, file_url VARCHAR(1000), -- S3 URL file_name VARCHAR(255), status compliance_doc_status NOT NULL DEFAULT 'Active', issue_date DATE, expiry_date DATE, notes TEXT, uploaded_by UUID REFERENCES users(id), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_compliance_expiry ON vendor_compliance_docs(expiry_date) WHERE expiry_date IS NOT NULL; ``` **`vendor_orders`** ```sql CREATE TYPE order_status AS ENUM ('Pending', 'Confirmed', 'Shipped', 'Delivered', 'Cancelled', 'Disputed'); CREATE TABLE vendor_orders ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), vendor_id UUID NOT NULL REFERENCES vendors(id), project_id UUID REFERENCES projects(id), order_number VARCHAR(100) UNIQUE NOT NULL, description TEXT, quantity INTEGER, unit_price_cents INTEGER, -- Per unit, cents total_cents INTEGER NOT NULL, -- Quantity * unit_price status order_status NOT NULL DEFAULT 'Pending', ordered_date DATE, expected_date DATE, delivered_date DATE, notes TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), deleted_at TIMESTAMPTZ ); ``` --- ### 3.5 `projects` Construction/roofing projects managed through the Owner's Box. ```sql CREATE TYPE project_status AS ENUM ( 'active', 'completed', 'delayed', 'on_hold', 'disputed', 'cancelled' ); CREATE TABLE projects ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), legacy_id VARCHAR(20) UNIQUE, owner_id UUID NOT NULL REFERENCES users(id), -- Must have role=OWNER property_id INTEGER REFERENCES properties(id), title VARCHAR(500) NOT NULL, description TEXT, status project_status NOT NULL DEFAULT 'active', health_score SMALLINT CHECK (health_score BETWEEN 0 AND 100), -- Budget approved_budget_cents INTEGER NOT NULL DEFAULT 0, actual_cost_cents INTEGER NOT NULL DEFAULT 0, -- Dates start_date DATE, target_end_date DATE, actual_end_date DATE, -- Progress completion_pct SMALLINT DEFAULT 0 CHECK (completion_pct BETWEEN 0 AND 100), total_tasks INTEGER DEFAULT 0, completed_tasks INTEGER DEFAULT 0, notes TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), deleted_at TIMESTAMPTZ ); CREATE INDEX idx_projects_owner ON projects(owner_id) WHERE deleted_at IS NULL; CREATE INDEX idx_projects_status ON projects(status) WHERE deleted_at IS NULL; ``` **`project_tasks`** ```sql CREATE TYPE task_status AS ENUM ('pending', 'in_progress', 'completed', 'blocked'); CREATE TYPE task_priority AS ENUM ('low', 'medium', 'high', 'critical'); CREATE TABLE project_tasks ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, assigned_to UUID REFERENCES users(id), title VARCHAR(500) NOT NULL, description TEXT, status task_status NOT NULL DEFAULT 'pending', priority task_priority NOT NULL DEFAULT 'medium', due_date DATE, completed_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); ``` **`change_orders`** ```sql CREATE TYPE change_order_status AS ENUM ('Pending', 'Approved', 'Rejected', 'Implemented'); CREATE TABLE change_orders ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, requested_by UUID NOT NULL REFERENCES users(id), approved_by UUID REFERENCES users(id), title VARCHAR(500) NOT NULL, description TEXT, cost_impact_cents INTEGER NOT NULL DEFAULT 0, -- Can be negative (savings) schedule_impact_days INTEGER DEFAULT 0, status change_order_status NOT NULL DEFAULT 'Pending', approved_at TIMESTAMPTZ, notes TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); ``` --- ### 3.6 `invoices` Tracks all financial transactions: customer billing, vendor payments, subcontractor payouts. ```sql CREATE TYPE invoice_type AS ENUM ('customer', 'vendor', 'subcontractor', 'internal'); CREATE TYPE invoice_status AS ENUM ( 'Draft', 'Sent', 'Viewed', 'Partial', 'Paid', 'Overdue', 'Disputed', 'Void' ); CREATE TABLE invoices ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), invoice_number VARCHAR(50) UNIQUE NOT NULL, -- 'INV-2026-0042' invoice_type invoice_type NOT NULL, -- Parties issued_by UUID NOT NULL REFERENCES users(id), billed_to_user UUID REFERENCES users(id), -- For customer invoices vendor_id UUID REFERENCES vendors(id), -- For vendor invoices project_id UUID REFERENCES projects(id), -- Amounts (all cents) subtotal_cents INTEGER NOT NULL DEFAULT 0, tax_cents INTEGER NOT NULL DEFAULT 0, discount_cents INTEGER NOT NULL DEFAULT 0, total_cents INTEGER NOT NULL DEFAULT 0, -- subtotal + tax - discount amount_paid_cents INTEGER NOT NULL DEFAULT 0, balance_cents INTEGER GENERATED ALWAYS AS (total_cents - amount_paid_cents) STORED, -- Dates issue_date DATE NOT NULL DEFAULT CURRENT_DATE, due_date DATE, paid_date DATE, status invoice_status NOT NULL DEFAULT 'Draft', line_items JSONB DEFAULT '[]', -- Array of {description, qty, unit_price_cents, total_cents} notes TEXT, file_url VARCHAR(1000), -- S3 URL for PDF copy -- Approval workflow (for payout invoices) approved_by UUID REFERENCES users(id), approved_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), deleted_at TIMESTAMPTZ ); CREATE INDEX idx_invoices_project ON invoices(project_id) WHERE deleted_at IS NULL; CREATE INDEX idx_invoices_status ON invoices(status) WHERE deleted_at IS NULL; CREATE INDEX idx_invoices_due ON invoices(due_date) WHERE status NOT IN ('Paid', 'Void') AND deleted_at IS NULL; ``` --- ### 3.7 `documents` Contract PDFs, compliance files, inspection reports — all role-accessible files. ```sql CREATE TYPE document_category AS ENUM ( 'contract', 'invoice', 'compliance', 'inspection', 'proposal', 'permit', 'insurance', 'other' ); CREATE TYPE document_review_status AS ENUM ( 'Pending Review', 'Under Review', 'Approved', 'Rejected', 'Expired' ); CREATE TABLE documents ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), title VARCHAR(500) NOT NULL, category document_category NOT NULL, review_status document_review_status NOT NULL DEFAULT 'Pending Review', -- Associations (any combination can be set) project_id UUID REFERENCES projects(id), vendor_id UUID REFERENCES vendors(id), property_id INTEGER REFERENCES properties(id), related_user_id UUID REFERENCES users(id), -- File file_url VARCHAR(1000) NOT NULL, -- S3 URL file_name VARCHAR(255) NOT NULL, file_size_bytes INTEGER, mime_type VARCHAR(100), -- Metadata uploaded_by UUID NOT NULL REFERENCES users(id), reviewed_by UUID REFERENCES users(id), review_notes TEXT, expiry_date DATE, tags JSONB DEFAULT '[]', -- ['W9', '2026', 'vendor'] created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), deleted_at TIMESTAMPTZ ); ``` --- ### 3.8 `sales_history` Closed deal records used for leaderboard and revenue reporting. ```sql CREATE TYPE deal_status AS ENUM ('closed_won', 'closed_lost'); CREATE TABLE sales_history ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), legacy_id VARCHAR(20) UNIQUE, -- 'tx_1', etc. agent_id UUID NOT NULL REFERENCES users(id), property_id INTEGER REFERENCES properties(id), meeting_id UUID REFERENCES meetings(id), closed_date DATE NOT NULL, amount_cents INTEGER NOT NULL DEFAULT 0, status deal_status NOT NULL, notes TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_sales_agent ON sales_history(agent_id, closed_date); CREATE INDEX idx_sales_date ON sales_history(closed_date); ``` --- ### 3.9 `notifications` In-app notification records for all roles. ```sql CREATE TYPE notification_type AS ENUM ( 'lead_assigned', 'meeting_reminder', 'compliance_expiring', 'compliance_expired', 'invoice_due', 'invoice_overdue', 'payout_approved', 'payout_rejected', 'change_order_submitted', 'change_order_approved', 'document_uploaded', 'system' ); CREATE TABLE notifications ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, type notification_type NOT NULL, title VARCHAR(255) NOT NULL, body TEXT, is_read BOOLEAN NOT NULL DEFAULT FALSE, deep_link VARCHAR(500), -- Frontend route, e.g. '/owner/projects/uuid' metadata JSONB DEFAULT '{}', -- Contextual data for rendering created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_notifications_user ON notifications(user_id, is_read, created_at DESC); ``` --- ### 3.10 `audit_logs` Immutable record of all write operations on sensitive data. ```sql CREATE TABLE audit_logs ( id BIGSERIAL PRIMARY KEY, -- High-volume; use BIGSERIAL not UUID actor_id UUID REFERENCES users(id), -- NULL if system action action VARCHAR(100) NOT NULL, -- 'property.assigned', 'invoice.approved', etc. resource_type VARCHAR(100) NOT NULL, -- 'property', 'invoice', 'user' resource_id VARCHAR(100) NOT NULL, -- UUID or legacy ID as string old_value JSONB, -- Snapshot before change new_value JSONB, -- Snapshot after change ip_address INET, user_agent TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_audit_resource ON audit_logs(resource_type, resource_id); CREATE INDEX idx_audit_actor ON audit_logs(actor_id); CREATE INDEX idx_audit_created ON audit_logs(created_at DESC); ``` --- ## 4. Shared Triggers & Conventions ### Auto-update `updated_at` Apply to every table that has `updated_at`: ```sql CREATE OR REPLACE FUNCTION set_updated_at() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; -- Apply to each table: CREATE TRIGGER trg_users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION set_updated_at(); -- (repeat for properties, meetings, vendors, etc.) ``` ### Soft Delete Filter All application queries **must** include `WHERE deleted_at IS NULL`. Use a SQLAlchemy default query filter on all models: ```python # app/models/base.py from sqlalchemy.orm import DeclarativeBase, declared_attr from sqlalchemy import Column, TIMESTAMP, func class Base(DeclarativeBase): pass class SoftDeleteMixin: deleted_at = Column(TIMESTAMP(timezone=True), nullable=True) @classmethod def active(cls): """Returns a filter expression for non-deleted rows.""" return cls.deleted_at.is_(None) ``` --- ## 5. Alembic Migration Strategy ### Initial Migration ```bash # After defining all SQLAlchemy models: alembic revision --autogenerate -m "initial_schema" alembic upgrade head ``` ### Naming Convention ``` YYYYMMDD_HHMM_short_description.py Examples: 20260224_1430_initial_schema.py 20260301_0900_add_vendor_orders.py 20260310_1100_meeting_change_requests.py ``` ### Safe Migration Rules - **Never** rename a column in production — add a new one, backfill, drop the old one in a follow-up migration. - **Never** drop a table — soft-delete all rows first, then drop in a follow-up after verification. - **Always** make migrations backwards-compatible so a rollback doesn't break the running app. --- ## 6. Seed Data Strategy The `app/scripts/seed_dev.py` script creates development data that exactly matches the frontend mock data: ```python SEED_USERS = [ # Customers {"legacy_id": "c1", "email": "alice@example.com", "username": "alice", "full_name": "Alice Customer", "role": "CUSTOMER", "user_type": "customer", "password": "password"}, # Will be bcrypt-hashed on seed # Field Agents {"legacy_id": "e1", "email": "agent1@plano.com", "emp_id": "FA001", "full_name": "Frank Agent", "role": "FIELD_AGENT", "user_type": "employee", "xp": 12450, "doors_knocked": 342, "streak_days": 5, "achievements": ["Hot Spot Hunter", "Storm Chaser"], "password": "password"}, # Admins {"legacy_id": "a1", "email": "admin@plano.com", "emp_id": "ADM01", "full_name": "Adam Admin", "role": "ADMIN", "user_type": "employee", "password": "password"}, # Owners {"legacy_id": "own_001", "email": "justin@johnsondev.com", "username": "justin", "full_name": "Justin Johnson", "role": "OWNER", "user_type": "owner", "company_name": "Johnson Development Group", "password": "password"}, # Contractor {"legacy_id": "con_001", "email": "mike@texasbuilders.com", "username": "mike", "full_name": "Mike Contractor", "role": "CONTRACTOR", "user_type": "contractor", "company_name": "Texas Builders LLC", "license_number": "TX-GC-123456", "password": "password"}, # Vendor {"legacy_id": "ven_001", "email": "sales@abcsupply.com", "username": "abc_supply", "full_name": "ABC Supply Co.", "role": "VENDOR", "user_type": "vendor", "company_name": "ABC Supply", "trade_type": "materials", "password": "password"}, # (and so on for all 17 mock users) ] ``` **Why `legacy_id` matters:** During the transition period, the frontend will still reference IDs like `'e1'`, `'own_001'`. The backend stores these in `legacy_id` and the Integration Team uses them as lookup keys until the frontend is fully migrated to UUIDs. --- ## 7. Role-to-Table Access Matrix This is the **policy contract** for the backend services layer. Routers enforce this via `require_role()`. | Table | OWNER | ADMIN | FIELD_AGENT | CONTRACTOR | SUBCONTRACTOR | VENDOR | CUSTOMER | |-------|-------|-------|-------------|------------|---------------|--------|----------| | `users` | Read all | Read all, Write own team | Read own | Read own | Read own | Read own | Read own | | `properties` | Read/Write all | Read/Write all | Read assigned, Write status | None | None | None | Read own | | `meetings` | Read all | Read/Write all | Read/Write own | None | None | None | Read own | | `projects` | Read/Write own | Read all | None | Read assigned | Read assigned | None | None | | `vendors` | Read/Write all | Read all | None | None | None | Read own | None | | `vendor_orders` | Read/Write all | Read all | None | None | None | Read own | None | | `invoices` | Read/Write all | Read all, Write | None | Read own | Read own | Read own | Read own | | `documents` | Read/Write all | Read/Write all | Read assigned | Read assigned | Read assigned | Read own | Read own | | `notifications` | Read own | Read own | Read own | Read own | Read own | Read own | Read own | | `audit_logs` | Read all | Read all | None | None | None | None | None | --- *Next: Read `01_authentication_module.md` to build the auth system on top of this schema.*