Files
LynkedUpPro_CRM/docs/backend/02_database_schema.md
T
Satyam 7694788387 feat(ProCanvas): retro game overhaul with log actions, challenges & achievements modals
- Complete ProCanvas redesign with retro sports game aesthetic
- Card-game style photo frame with tilt, shimmer, corner star accents
- Daily Missions card with weekly challenge + individual quest progress bars
- Log Action card (Door Knocked, Lead Gained, Appointment Set, Client Meeting)
- Each log action opens themed modal with relevant input fields
- Challenges modal with 6 active challenges and progress tracking
- Achievements modal with all badges, unlock status, descriptions
- Nav bar tabs (Leaderboard, Challenges, Achievements) wired to modals
- Rewards & Checkpoints with named stages (Daily Grind to Legend Run)
- Smoother Hot Streak pulse animation (2.5s float + 3s pulse rings)
- Hit The Map button with smooth pulsing glow animation
- Grid pattern overlay in Leaderboard card
- Light mode support via dark: Tailwind variants throughout
- Top 3 badges displayed on profile card
- Fixed dropdown option visibility in dark mode
2026-02-26 01:36:36 +05:30

30 KiB
Raw Blame History

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.

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.

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

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.

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)

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

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.00100.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

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

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.

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

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

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.

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.

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.

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.

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.

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:

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:

# 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

# 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:

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.