- 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
30 KiB
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
DELETEin production. All tables havedeleted_at TIMESTAMPTZ NULL. Rows with a non-nulldeleted_atare hidden from all queries by default. - Audit trail: Every write-capable table has
created_at,updated_at, and the critical tables also write toaudit_logs. - Money in cents: All monetary columns are
INTEGER(cents). NeverDECIMALorFLOATfor money. - UUIDs for public-facing IDs: User-facing IDs use
UUIDto prevent enumeration attacks. Internal join keys useINTEGERsequences 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.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
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.