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
This commit is contained in:
@@ -0,0 +1,433 @@
|
||||
# B0 — Backend Project Overview & API Conventions
|
||||
|
||||
**Module:** B0
|
||||
**Depends on:** Nothing (foundation document)
|
||||
**Read before:** All other backend modules
|
||||
|
||||
---
|
||||
|
||||
## 1. What We're Building
|
||||
|
||||
The LynkedUpPro frontend is a fully-functional React 19 SPA that currently runs entirely on a client-side mock data layer (`src/data/mockStore.jsx`). There is no real server, no database, and no persistent state.
|
||||
|
||||
This backend will replace that mock layer with a production-grade API server. The frontend **does not need to be rewritten** — the Integration Team will replace mock calls with real API calls, component by component.
|
||||
|
||||
### Topology
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐ ┌────────────────────────────────┐
|
||||
│ Vercel (Frontend) │ │ Backend Host (Railway etc.) │
|
||||
│ │ │ │
|
||||
│ React 19 SPA │ HTTPS │ FastAPI (Python) │
|
||||
│ └─ AuthContext.jsx │◄──────►│ └─ /api/v1/... │
|
||||
│ └─ mockStore.jsx ──────────┼──────► │ └─ PostgreSQL (via SQLAlchemy)│
|
||||
│ (will be replaced) │ │ └─ Alembic (migrations) │
|
||||
└─────────────────────────────┘ └────────────────────────────────┘
|
||||
│
|
||||
┌───────────┴───────────┐
|
||||
│ External Services │
|
||||
│ - Groq API (chatbot) │
|
||||
│ - SMTP (email alerts) │
|
||||
│ - AWS S3 (file upload)│
|
||||
└───────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Technology Stack
|
||||
|
||||
| Layer | Technology | Version | Reason |
|
||||
|-------|------------|---------|--------|
|
||||
| Language | Python | 3.12+ | Type hints, async support, ecosystem |
|
||||
| Framework | FastAPI | 0.115+ | Async, auto OpenAPI docs, Pydantic v2 |
|
||||
| ORM | SQLAlchemy | 2.0 (async) | Native async, type-safe, PostgreSQL support |
|
||||
| Migrations | Alembic | 1.13+ | Paired with SQLAlchemy, revision history |
|
||||
| Database | PostgreSQL | 16+ | JSONB, full-text search, PostGIS optional |
|
||||
| Auth | python-jose + passlib | latest | JWT tokens + bcrypt hashing |
|
||||
| Validation | Pydantic v2 | 2.x | Request/response schemas, coercion |
|
||||
| HTTP Client | httpx | 0.27+ | Async requests to Groq API |
|
||||
| File Storage | boto3 (AWS S3) | latest | COI/W9/document uploads |
|
||||
| Email | smtplib / sendgrid-python | latest | Compliance alerts, meeting reminders |
|
||||
| Testing | pytest + pytest-asyncio | latest | Async test support |
|
||||
| Linting | ruff + mypy | latest | Fast linting + static type checking |
|
||||
|
||||
---
|
||||
|
||||
## 3. Backend Project Structure
|
||||
|
||||
```
|
||||
lynkeduppro-api/
|
||||
├── alembic/
|
||||
│ ├── env.py
|
||||
│ ├── script.py.mako
|
||||
│ └── versions/ # One file per migration
|
||||
├── app/
|
||||
│ ├── __init__.py
|
||||
│ ├── main.py # FastAPI app instantiation, middleware, CORS
|
||||
│ ├── config.py # Settings via pydantic-settings (reads .env)
|
||||
│ ├── database.py # SQLAlchemy async engine + session factory
|
||||
│ ├── dependencies.py # Shared FastAPI Depends() (get_db, get_current_user, require_role)
|
||||
│ │
|
||||
│ ├── models/ # SQLAlchemy ORM models (one file per entity)
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── user.py
|
||||
│ │ ├── property.py
|
||||
│ │ ├── meeting.py
|
||||
│ │ ├── vendor.py
|
||||
│ │ ├── document.py
|
||||
│ │ ├── invoice.py
|
||||
│ │ ├── project.py
|
||||
│ │ ├── compliance.py
|
||||
│ │ └── audit_log.py
|
||||
│ │
|
||||
│ ├── schemas/ # Pydantic request/response schemas
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── user.py
|
||||
│ │ ├── property.py
|
||||
│ │ ├── meeting.py
|
||||
│ │ ├── vendor.py
|
||||
│ │ ├── document.py
|
||||
│ │ ├── invoice.py
|
||||
│ │ ├── project.py
|
||||
│ │ └── auth.py
|
||||
│ │
|
||||
│ ├── routers/ # FastAPI routers (one file per module)
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── auth.py # /api/v1/auth/*
|
||||
│ │ ├── users.py # /api/v1/users/*
|
||||
│ │ ├── properties.py # /api/v1/properties/*
|
||||
│ │ ├── meetings.py # /api/v1/meetings/*
|
||||
│ │ ├── vendors.py # /api/v1/vendors/*
|
||||
│ │ ├── documents.py # /api/v1/documents/*
|
||||
│ │ ├── invoices.py # /api/v1/invoices/*
|
||||
│ │ ├── projects.py # /api/v1/projects/*
|
||||
│ │ └── chatbot.py # /api/v1/chatbot/*
|
||||
│ │
|
||||
│ ├── services/ # Business logic (no direct DB calls, no HTTP)
|
||||
│ │ ├── auth_service.py
|
||||
│ │ ├── property_service.py
|
||||
│ │ ├── meeting_service.py
|
||||
│ │ ├── vendor_service.py
|
||||
│ │ ├── notification_service.py
|
||||
│ │ └── chatbot_service.py
|
||||
│ │
|
||||
│ └── utils/
|
||||
│ ├── security.py # JWT encode/decode, bcrypt helpers
|
||||
│ ├── permissions.py # Role-based permission checks
|
||||
│ └── mask.py # Sensitive data masking (SSN, bank, EIN)
|
||||
│
|
||||
├── tests/
|
||||
│ ├── conftest.py # Test DB setup, auth fixtures
|
||||
│ ├── test_auth.py
|
||||
│ ├── test_properties.py
|
||||
│ ├── test_meetings.py
|
||||
│ └── ...
|
||||
│
|
||||
├── .env # Local secrets (never committed)
|
||||
├── .env.example # Committed template (no real values)
|
||||
├── alembic.ini
|
||||
├── pyproject.toml # Project metadata + dependencies (uv or poetry)
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. API Conventions
|
||||
|
||||
### Base URL
|
||||
|
||||
| Environment | Base URL |
|
||||
|-------------|----------|
|
||||
| Development | `http://localhost:8000/api/v1` |
|
||||
| Production | `https://api.lynkeduppro.com/api/v1` |
|
||||
|
||||
### URL Pattern
|
||||
|
||||
```
|
||||
/api/v1/{resource}/{id?}/{sub-resource?}
|
||||
|
||||
Examples:
|
||||
GET /api/v1/properties ← list
|
||||
GET /api/v1/properties/42 ← single item
|
||||
POST /api/v1/properties ← create
|
||||
PATCH /api/v1/properties/42 ← partial update
|
||||
DELETE /api/v1/properties/42 ← delete
|
||||
GET /api/v1/properties/42/meetings ← nested resource
|
||||
```
|
||||
|
||||
### HTTP Methods
|
||||
|
||||
| Method | Use Case |
|
||||
|--------|----------|
|
||||
| `GET` | Read (no side effects) |
|
||||
| `POST` | Create new resource |
|
||||
| `PATCH` | Partial update (preferred over PUT) |
|
||||
| `DELETE` | Soft delete (sets `deleted_at`, never hard deletes) |
|
||||
|
||||
### Response Shape
|
||||
|
||||
**Success (single object):**
|
||||
```json
|
||||
{
|
||||
"data": { ... },
|
||||
"meta": null
|
||||
}
|
||||
```
|
||||
|
||||
**Success (paginated list):**
|
||||
```json
|
||||
{
|
||||
"data": [ ... ],
|
||||
"meta": {
|
||||
"total": 142,
|
||||
"page": 1,
|
||||
"per_page": 25,
|
||||
"pages": 6
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Error:**
|
||||
```json
|
||||
{
|
||||
"detail": "You do not have permission to view this resource.",
|
||||
"code": "FORBIDDEN"
|
||||
}
|
||||
```
|
||||
|
||||
**Error codes used across the API:**
|
||||
|
||||
| Code | HTTP Status | Meaning |
|
||||
|------|-------------|---------|
|
||||
| `UNAUTHORIZED` | 401 | No valid token |
|
||||
| `FORBIDDEN` | 403 | Valid token, wrong role |
|
||||
| `NOT_FOUND` | 404 | Resource doesn't exist |
|
||||
| `VALIDATION_ERROR` | 422 | Request body fails Pydantic validation |
|
||||
| `CONFLICT` | 409 | Duplicate (e.g., email already registered) |
|
||||
| `RATE_LIMITED` | 429 | Too many requests |
|
||||
| `INTERNAL_ERROR` | 500 | Unexpected server error |
|
||||
|
||||
### Pagination
|
||||
|
||||
All list endpoints support:
|
||||
```
|
||||
GET /api/v1/properties?page=1&per_page=25&sort=created_at&order=desc
|
||||
```
|
||||
|
||||
| Param | Default | Max | Description |
|
||||
|-------|---------|-----|-------------|
|
||||
| `page` | 1 | — | Page number (1-indexed) |
|
||||
| `per_page` | 25 | 100 | Results per page |
|
||||
| `sort` | `created_at` | — | Field to sort by |
|
||||
| `order` | `desc` | — | `asc` or `desc` |
|
||||
|
||||
### Filtering
|
||||
|
||||
Filters are passed as query params with double-underscore notation:
|
||||
```
|
||||
GET /api/v1/properties?status__eq=Hot+Lead&year_built__lt=2000
|
||||
GET /api/v1/meetings?agent_id__eq=e1&date__gte=2026-02-01
|
||||
```
|
||||
|
||||
### Money Convention
|
||||
|
||||
All monetary values travel as **integers in cents** between backend and frontend.
|
||||
|
||||
```python
|
||||
# Backend stores and returns: 450000 (cents)
|
||||
# Frontend displays: $4,500.00
|
||||
```
|
||||
|
||||
The frontend divides by 100 for display. This avoids all floating-point precision issues.
|
||||
|
||||
### Date/Time Convention
|
||||
|
||||
All timestamps are **ISO 8601 UTC** strings:
|
||||
```json
|
||||
"created_at": "2026-02-24T14:30:00Z"
|
||||
"date_of_loss": "2025-11-15"
|
||||
```
|
||||
Date-only fields (no time component) use `YYYY-MM-DD`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Authentication Overview
|
||||
|
||||
*(Full detail in `01_authentication_module.md`)*
|
||||
|
||||
- **Mechanism:** JWT Bearer tokens
|
||||
- **Access token lifetime:** 15 minutes
|
||||
- **Refresh token lifetime:** 7 days
|
||||
- **Storage:** `httpOnly` cookie (preferred for XSS safety) with `SameSite=Strict`
|
||||
- **Header (alternative):** `Authorization: Bearer <token>`
|
||||
|
||||
Every protected endpoint uses the `get_current_user` dependency:
|
||||
|
||||
```python
|
||||
# app/dependencies.py
|
||||
async def get_current_user(token: str = Depends(oauth2_scheme), db: AsyncSession = Depends(get_db)) -> User:
|
||||
...
|
||||
|
||||
async def require_role(*roles: str):
|
||||
async def checker(current_user: User = Depends(get_current_user)):
|
||||
if current_user.role not in roles:
|
||||
raise HTTPException(status_code=403, detail="Forbidden", headers={"WWW-Authenticate": "Bearer"})
|
||||
return current_user
|
||||
return checker
|
||||
```
|
||||
|
||||
Usage in a router:
|
||||
|
||||
```python
|
||||
@router.get("/admin/stats")
|
||||
async def get_admin_stats(
|
||||
current_user: User = Depends(require_role("ADMIN", "OWNER"))
|
||||
):
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. CORS Configuration
|
||||
|
||||
The frontend is hosted on `https://lynkeduppro-crm.vercel.app`. CORS must allow:
|
||||
- Credentials (for cookie-based auth)
|
||||
- All standard methods
|
||||
- `Authorization` and `Content-Type` headers
|
||||
|
||||
```python
|
||||
# app/main.py
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
ALLOWED_ORIGINS = [
|
||||
"http://localhost:5173", # Vite dev server
|
||||
"https://lynkeduppro-crm.vercel.app", # Production frontend
|
||||
]
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=ALLOWED_ORIGINS,
|
||||
allow_credentials=True, # Required for httpOnly cookies
|
||||
allow_methods=["*"],
|
||||
allow_headers=["Authorization", "Content-Type", "X-Request-ID"],
|
||||
expose_headers=["X-Total-Count"],
|
||||
)
|
||||
```
|
||||
|
||||
**Important:** `allow_credentials=True` cannot be combined with `allow_origins=["*"]`. The origins list must be explicit.
|
||||
|
||||
---
|
||||
|
||||
## 7. Environment Variables
|
||||
|
||||
**`.env.example`** (commit this; never commit `.env`):
|
||||
|
||||
```dotenv
|
||||
# Database
|
||||
DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/lynkeduppro
|
||||
|
||||
# JWT
|
||||
SECRET_KEY=CHANGE_ME_generate_with_openssl_rand_hex_32
|
||||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=15
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||
|
||||
# Groq AI
|
||||
GROQ_API_KEY=gsk_...
|
||||
|
||||
# AWS S3 (document storage)
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_S3_BUCKET=lynkeduppro-docs
|
||||
AWS_REGION=us-east-1
|
||||
|
||||
# Email (SendGrid)
|
||||
SENDGRID_API_KEY=SG...
|
||||
EMAIL_FROM=noreply@lynkeduppro.com
|
||||
|
||||
# App
|
||||
ENVIRONMENT=development # development | staging | production
|
||||
DEBUG=true
|
||||
BACKEND_CORS_ORIGINS=http://localhost:5173,https://lynkeduppro-crm.vercel.app
|
||||
```
|
||||
|
||||
Loaded in FastAPI via `pydantic-settings`:
|
||||
|
||||
```python
|
||||
# app/config.py
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
class Settings(BaseSettings):
|
||||
database_url: str
|
||||
secret_key: str
|
||||
algorithm: str = "HS256"
|
||||
access_token_expire_minutes: int = 15
|
||||
refresh_token_expire_days: int = 7
|
||||
groq_api_key: str
|
||||
environment: str = "development"
|
||||
debug: bool = False
|
||||
backend_cors_origins: list[str] = []
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
settings = Settings()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Database Setup
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt # or: uv sync
|
||||
|
||||
# Create database
|
||||
createdb lynkeduppro
|
||||
|
||||
# Run migrations
|
||||
alembic upgrade head
|
||||
|
||||
# Seed development data
|
||||
python -m app.scripts.seed_dev
|
||||
```
|
||||
|
||||
The `seed_dev` script creates:
|
||||
- All 17 mock users from the frontend with real bcrypt-hashed passwords (password: `password`)
|
||||
- A representative set of properties, meetings, vendors, and projects
|
||||
- Matches the IDs used in the frontend (`c1`, `e1`, `own_001`, etc.) for a smooth integration transition
|
||||
|
||||
---
|
||||
|
||||
## 9. Running Locally
|
||||
|
||||
```bash
|
||||
# Start the API server
|
||||
uvicorn app.main:app --reload --port 8000
|
||||
|
||||
# OpenAPI docs (auto-generated)
|
||||
http://localhost:8000/docs # Swagger UI
|
||||
http://localhost:8000/redoc # ReDoc
|
||||
|
||||
# Health check
|
||||
GET http://localhost:8000/health
|
||||
→ { "status": "ok", "version": "1.0.0", "environment": "development" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Module Build Order (Recommended)
|
||||
|
||||
Build modules in this sequence to avoid dependency blockers:
|
||||
|
||||
```
|
||||
B2 (Schema) → B1 (Auth) → B3 (Properties) → B4 (Users)
|
||||
→ B5 (Meetings) → B6 (Vendors/Compliance)
|
||||
→ B7 (Financials) → B8 (Chatbot) → B9 (Notifications)
|
||||
```
|
||||
|
||||
**Why schema first:** Every other module's router depends on the ORM models being defined. Define models and run `alembic revision --autogenerate` before writing a single router.
|
||||
|
||||
---
|
||||
|
||||
*Next: Read `02_database_schema.md` before writing any ORM models.*
|
||||
@@ -0,0 +1,686 @@
|
||||
# B1 — Authentication Module
|
||||
|
||||
**Module:** B1
|
||||
**Depends on:** B0 (Project Overview), B2 (Database Schema — `users` table)
|
||||
**Read before:** B3, B4, B5, B6, B7, B8 (every router imports `require_role` from here)
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
This module defines the complete authentication and authorization system for LynkedUpPro. It covers:
|
||||
|
||||
- Password hashing (bcrypt via `passlib`)
|
||||
- JWT access + refresh token lifecycle
|
||||
- `httpOnly` cookie storage strategy
|
||||
- All 7 login flows across 7 roles
|
||||
- The `get_current_user()` and `require_role()` FastAPI dependencies used by every other router
|
||||
- The permission system derived from `src/utils/permissions.js`
|
||||
- All auth endpoints (`/auth/login`, `/auth/refresh`, `/auth/logout`, `/auth/me`)
|
||||
|
||||
**No other module should re-implement any of this.** All routers import and use the dependencies defined here.
|
||||
|
||||
---
|
||||
|
||||
## 2. Files to Create
|
||||
|
||||
```
|
||||
app/
|
||||
├── routers/auth.py ← All /api/v1/auth/* endpoints
|
||||
├── services/auth_service.py ← Business logic: verify password, create tokens, rotate refresh
|
||||
├── utils/security.py ← JWT encode/decode, bcrypt helpers (no business logic)
|
||||
└── dependencies.py ← get_db(), get_current_user(), require_role() — used everywhere
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Password Hashing
|
||||
|
||||
Use `passlib` with `bcrypt` scheme. Never store or compare plain text passwords.
|
||||
|
||||
```python
|
||||
# app/utils/security.py
|
||||
from passlib.context import CryptContext
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
def hash_password(plain: str) -> str:
|
||||
"""Hash a plain-text password. Use during user creation and seed."""
|
||||
return pwd_context.hash(plain)
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
"""Verify plain text against stored bcrypt hash. Use during login."""
|
||||
return pwd_context.verify(plain, hashed)
|
||||
```
|
||||
|
||||
**Seed note:** All 17 mock users have `password: "password"`. The seed script must bcrypt-hash this before inserting. The plain text `"password"` must never appear in the database.
|
||||
|
||||
---
|
||||
|
||||
## 4. JWT Tokens
|
||||
|
||||
### Token Types
|
||||
|
||||
| Type | Lifetime | Stored In | Purpose |
|
||||
|------|----------|-----------|---------|
|
||||
| Access token | 15 minutes | `httpOnly` cookie (`access_token`) | Authorizes API requests |
|
||||
| Refresh token | 7 days | `httpOnly` cookie (`refresh_token`) | Issues new access tokens silently |
|
||||
|
||||
### JWT Payload (Claims)
|
||||
|
||||
**Access token:**
|
||||
```json
|
||||
{
|
||||
"sub": "3f8a1c2d-...(user UUID)",
|
||||
"role": "FIELD_AGENT",
|
||||
"legacy_id": "e1",
|
||||
"type": "access",
|
||||
"iat": 1740400000,
|
||||
"exp": 1740400900
|
||||
}
|
||||
```
|
||||
|
||||
**Refresh token:**
|
||||
```json
|
||||
{
|
||||
"sub": "3f8a1c2d-...(user UUID)",
|
||||
"type": "refresh",
|
||||
"iat": 1740400000,
|
||||
"exp": 1741004800
|
||||
}
|
||||
```
|
||||
|
||||
**Why `legacy_id` in the access token:** During the transition period, some frontend components still look up users by their mock ID (`e1`, `own_001`). Including it in the token avoids an extra DB lookup per request.
|
||||
|
||||
### Token Creation
|
||||
|
||||
```python
|
||||
# app/utils/security.py
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from jose import JWTError, jwt
|
||||
from app.config import settings
|
||||
|
||||
def create_access_token(user_id: str, role: str, legacy_id: str | None = None) -> str:
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
|
||||
payload = {
|
||||
"sub": str(user_id),
|
||||
"role": role,
|
||||
"type": "access",
|
||||
"exp": expire,
|
||||
}
|
||||
if legacy_id:
|
||||
payload["legacy_id"] = legacy_id
|
||||
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
|
||||
|
||||
def create_refresh_token(user_id: str) -> str:
|
||||
expire = datetime.now(timezone.utc) + timedelta(days=settings.refresh_token_expire_days)
|
||||
payload = {
|
||||
"sub": str(user_id),
|
||||
"type": "refresh",
|
||||
"exp": expire,
|
||||
}
|
||||
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
|
||||
|
||||
def decode_token(token: str) -> dict:
|
||||
"""Raises JWTError if invalid or expired."""
|
||||
return jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
|
||||
```
|
||||
|
||||
### Refresh Token Rotation Security
|
||||
|
||||
Refresh tokens are rotated on every use. The hashed value of the current refresh token is stored in `users.refresh_token_hash`. On refresh:
|
||||
|
||||
1. Decode the incoming refresh token (verify not expired)
|
||||
2. Load user from DB, get `refresh_token_hash`
|
||||
3. `verify_password(incoming_token, stored_hash)` — if mismatch, **revoke all tokens** (possible reuse attack)
|
||||
4. If match: issue new access + refresh token pair, update `refresh_token_hash`
|
||||
|
||||
```python
|
||||
# app/utils/security.py
|
||||
def hash_token(token: str) -> str:
|
||||
"""Hash a refresh token for safe storage. Uses same bcrypt context."""
|
||||
return pwd_context.hash(token)
|
||||
|
||||
def verify_token_hash(token: str, hashed: str) -> bool:
|
||||
return pwd_context.verify(token, hashed)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Cookie Configuration
|
||||
|
||||
All tokens are delivered as `httpOnly` cookies. This prevents XSS attacks from stealing tokens via `document.cookie` or `localStorage`.
|
||||
|
||||
```python
|
||||
# Shared cookie settings used in auth router
|
||||
ACCESS_COOKIE_PARAMS = {
|
||||
"key": "access_token",
|
||||
"httponly": True,
|
||||
"secure": True, # HTTPS only in production; set False in dev
|
||||
"samesite": "strict", # Prevents CSRF
|
||||
"max_age": 60 * 15, # 15 minutes in seconds
|
||||
"path": "/",
|
||||
}
|
||||
|
||||
REFRESH_COOKIE_PARAMS = {
|
||||
"key": "refresh_token",
|
||||
"httponly": True,
|
||||
"secure": True,
|
||||
"samesite": "strict",
|
||||
"max_age": 60 * 60 * 24 * 7, # 7 days in seconds
|
||||
"path": "/api/v1/auth", # Scoped — only sent to auth endpoints
|
||||
}
|
||||
```
|
||||
|
||||
**Why `path="/api/v1/auth"` for the refresh token:** Limits the refresh token cookie to auth endpoints only. It won't be sent with every API request, reducing exposure.
|
||||
|
||||
---
|
||||
|
||||
## 6. FastAPI Dependencies
|
||||
|
||||
These are the three shared dependencies imported by every other router.
|
||||
|
||||
```python
|
||||
# app/dependencies.py
|
||||
from fastapi import Cookie, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from jose import JWTError
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.utils.security import decode_token
|
||||
|
||||
async def get_current_user(
|
||||
access_token: str | None = Cookie(default=None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
"""
|
||||
Extracts and validates the JWT from the httpOnly cookie.
|
||||
Raises 401 if missing, expired, or invalid.
|
||||
"""
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Not authenticated",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
if not access_token:
|
||||
raise credentials_exception
|
||||
|
||||
try:
|
||||
payload = decode_token(access_token)
|
||||
if payload.get("type") != "access":
|
||||
raise credentials_exception
|
||||
user_id: str = payload.get("sub")
|
||||
if not user_id:
|
||||
raise credentials_exception
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
user = await db.get(User, user_id)
|
||||
if user is None or user.deleted_at is not None or not user.is_active:
|
||||
raise credentials_exception
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def require_role(*roles: str):
|
||||
"""
|
||||
Returns a dependency that enforces role-based access.
|
||||
Usage: current_user: User = Depends(require_role("ADMIN", "OWNER"))
|
||||
"""
|
||||
async def checker(current_user: User = Depends(get_current_user)) -> User:
|
||||
if current_user.role not in roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Access denied. Required roles: {list(roles)}",
|
||||
)
|
||||
return current_user
|
||||
return checker
|
||||
```
|
||||
|
||||
**Usage example in any router:**
|
||||
```python
|
||||
from app.dependencies import require_role
|
||||
|
||||
@router.get("/admin/stats")
|
||||
async def get_admin_stats(
|
||||
current_user: User = Depends(require_role("ADMIN", "OWNER")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Login Flows by Role
|
||||
|
||||
The frontend sends different `type` values from `Login.jsx`. This table maps each UI login type to the DB lookup strategy.
|
||||
|
||||
| Frontend `loginType` | Backend strategy | DB column matched | Roles possible |
|
||||
|---------------------|-----------------|-------------------|----------------|
|
||||
| `customer` | `username` + `password` | `users.username` | `CUSTOMER` |
|
||||
| `employee` | `emp_id` + `password` | `users.emp_id` | `FIELD_AGENT`, `ADMIN` |
|
||||
| `owner` | `username` or `email` + `password` | `users.username` or `users.email` | `OWNER` |
|
||||
| `contractor` | `username` or `email` + `password` | `users.username` or `users.email` | `CONTRACTOR` |
|
||||
| `subcontractor` | `username` or `email` + `password` | `users.username` or `users.email` | `SUBCONTRACTOR` |
|
||||
| `vendor` | `username` or `email` + `password` | `users.username` or `users.email` | `VENDOR` |
|
||||
|
||||
**Note on `employee` type:** The login form sends `type: "employee"` for both `FIELD_AGENT` and `ADMIN` (they use emp_id like `FA001`, `ADM01`). All other non-customer roles send their own type string and match on `username`.
|
||||
|
||||
### Auth Service Implementation
|
||||
|
||||
```python
|
||||
# app/services/auth_service.py
|
||||
from sqlalchemy import select, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.user import User
|
||||
from app.utils.security import verify_password, create_access_token, create_refresh_token, hash_token
|
||||
|
||||
async def authenticate_user(
|
||||
db: AsyncSession,
|
||||
identifier: str,
|
||||
password: str,
|
||||
login_type: str,
|
||||
) -> User | None:
|
||||
"""
|
||||
Finds and verifies a user. Returns User on success, None on failure.
|
||||
Never raises — caller decides how to handle None.
|
||||
"""
|
||||
stmt = None
|
||||
|
||||
if login_type == "customer":
|
||||
stmt = select(User).where(
|
||||
User.username == identifier,
|
||||
User.role == "CUSTOMER",
|
||||
User.deleted_at.is_(None),
|
||||
User.is_active.is_(True),
|
||||
)
|
||||
elif login_type == "employee":
|
||||
stmt = select(User).where(
|
||||
User.emp_id == identifier,
|
||||
User.role.in_(["FIELD_AGENT", "ADMIN"]),
|
||||
User.deleted_at.is_(None),
|
||||
User.is_active.is_(True),
|
||||
)
|
||||
elif login_type in ("owner", "contractor", "subcontractor", "vendor"):
|
||||
role_map = {
|
||||
"owner": "OWNER",
|
||||
"contractor": "CONTRACTOR",
|
||||
"subcontractor": "SUBCONTRACTOR",
|
||||
"vendor": "VENDOR",
|
||||
}
|
||||
stmt = select(User).where(
|
||||
or_(User.username == identifier, User.email == identifier),
|
||||
User.role == role_map[login_type],
|
||||
User.deleted_at.is_(None),
|
||||
User.is_active.is_(True),
|
||||
)
|
||||
else:
|
||||
return None
|
||||
|
||||
result = await db.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
return None
|
||||
if not verify_password(password, user.password_hash):
|
||||
return None
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def create_token_pair(db: AsyncSession, user: User) -> tuple[str, str]:
|
||||
"""
|
||||
Creates access + refresh tokens and persists the refresh token hash.
|
||||
Returns (access_token, refresh_token).
|
||||
"""
|
||||
access_token = create_access_token(
|
||||
user_id=str(user.id),
|
||||
role=user.role,
|
||||
legacy_id=user.legacy_id,
|
||||
)
|
||||
refresh_token = create_refresh_token(user_id=str(user.id))
|
||||
|
||||
# Store hashed refresh token for rotation validation
|
||||
user.refresh_token_hash = hash_token(refresh_token)
|
||||
user.last_login_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
|
||||
return access_token, refresh_token
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. API Endpoints
|
||||
|
||||
### 8.1 `POST /api/v1/auth/login`
|
||||
|
||||
**Purpose:** Authenticate user, issue tokens as `httpOnly` cookies.
|
||||
|
||||
**Request Body:**
|
||||
```python
|
||||
# app/schemas/auth.py
|
||||
class LoginRequest(BaseModel):
|
||||
identifier: str # username, emp_id, or email depending on type
|
||||
password: str
|
||||
type: Literal[
|
||||
"customer", "employee", "owner",
|
||||
"contractor", "subcontractor", "vendor"
|
||||
]
|
||||
```
|
||||
|
||||
**Response Body (200):**
|
||||
```python
|
||||
class UserPublic(BaseModel):
|
||||
id: str # UUID as string
|
||||
legacy_id: str | None
|
||||
full_name: str
|
||||
email: str
|
||||
role: str # 'OWNER', 'ADMIN', 'FIELD_AGENT', etc.
|
||||
company_name: str | None
|
||||
xp: int | None
|
||||
streak_days: int | None
|
||||
achievements: list[str]
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
user: UserPublic
|
||||
message: str = "Login successful"
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
- `401` — Invalid credentials (always generic — never reveal which field was wrong)
|
||||
- `403` — Account deactivated (`is_active = false`)
|
||||
- `422` — Missing or invalid request body fields
|
||||
|
||||
**Router Implementation:**
|
||||
```python
|
||||
# app/routers/auth.py
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
async def login(
|
||||
request: LoginRequest,
|
||||
response: Response,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
user = await authenticate_user(db, request.identifier, request.password, request.type)
|
||||
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid credentials",
|
||||
)
|
||||
|
||||
access_token, refresh_token = await create_token_pair(db, user)
|
||||
|
||||
# Set httpOnly cookies
|
||||
response.set_cookie(value=access_token, **ACCESS_COOKIE_PARAMS)
|
||||
response.set_cookie(value=refresh_token, **REFRESH_COOKIE_PARAMS)
|
||||
|
||||
# Write audit log
|
||||
await write_audit_log(db, actor_id=user.id, action="auth.login", resource_type="user", resource_id=str(user.id))
|
||||
|
||||
return LoginResponse(user=UserPublic.model_validate(user))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8.2 `POST /api/v1/auth/refresh`
|
||||
|
||||
**Purpose:** Issue a new access token using the refresh token cookie.
|
||||
|
||||
**Request:** No body. Reads `refresh_token` from `httpOnly` cookie automatically.
|
||||
|
||||
**Response (200):**
|
||||
```python
|
||||
class RefreshResponse(BaseModel):
|
||||
message: str = "Token refreshed"
|
||||
# New access_token set as cookie; not in body for security
|
||||
```
|
||||
|
||||
**Router Implementation:**
|
||||
```python
|
||||
@router.post("/refresh", response_model=RefreshResponse)
|
||||
async def refresh_token(
|
||||
response: Response,
|
||||
refresh_token: str | None = Cookie(default=None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired refresh token",
|
||||
)
|
||||
|
||||
if not refresh_token:
|
||||
raise credentials_exception
|
||||
|
||||
try:
|
||||
payload = decode_token(refresh_token)
|
||||
if payload.get("type") != "refresh":
|
||||
raise credentials_exception
|
||||
user_id = payload.get("sub")
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
user = await db.get(User, user_id)
|
||||
if user is None or not user.is_active or user.deleted_at is not None:
|
||||
raise credentials_exception
|
||||
|
||||
# Validate token hash (detect reuse attacks)
|
||||
if not user.refresh_token_hash or not verify_token_hash(refresh_token, user.refresh_token_hash):
|
||||
# Possible token reuse — revoke all sessions
|
||||
user.refresh_token_hash = None
|
||||
await db.commit()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Refresh token reuse detected. Please log in again.",
|
||||
)
|
||||
|
||||
# Issue new token pair
|
||||
new_access, new_refresh = await create_token_pair(db, user)
|
||||
response.set_cookie(value=new_access, **ACCESS_COOKIE_PARAMS)
|
||||
response.set_cookie(value=new_refresh, **REFRESH_COOKIE_PARAMS)
|
||||
|
||||
return RefreshResponse()
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
- `401` — No cookie, expired token, tampered token, or reuse detected
|
||||
|
||||
---
|
||||
|
||||
### 8.3 `POST /api/v1/auth/logout`
|
||||
|
||||
**Purpose:** Clear auth cookies and invalidate refresh token in DB.
|
||||
|
||||
**Request:** No body. User must be authenticated.
|
||||
|
||||
**Response (200):**
|
||||
```python
|
||||
class LogoutResponse(BaseModel):
|
||||
message: str = "Logged out successfully"
|
||||
```
|
||||
|
||||
**Router Implementation:**
|
||||
```python
|
||||
@router.post("/logout", response_model=LogoutResponse)
|
||||
async def logout(
|
||||
response: Response,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
# Invalidate refresh token in DB
|
||||
current_user.refresh_token_hash = None
|
||||
await db.commit()
|
||||
|
||||
# Clear both cookies
|
||||
response.delete_cookie("access_token", path="/")
|
||||
response.delete_cookie("refresh_token", path="/api/v1/auth")
|
||||
|
||||
await write_audit_log(db, actor_id=current_user.id, action="auth.logout", resource_type="user", resource_id=str(current_user.id))
|
||||
|
||||
return LogoutResponse()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8.4 `GET /api/v1/auth/me`
|
||||
|
||||
**Purpose:** Return the current user's profile. Called by the frontend on app load to restore session after page refresh.
|
||||
|
||||
**Request:** No body. Reads `access_token` cookie.
|
||||
|
||||
**Response (200):** `UserPublic` schema (same as login response)
|
||||
|
||||
**Router Implementation:**
|
||||
```python
|
||||
@router.get("/me", response_model=UserPublic)
|
||||
async def get_me(current_user: User = Depends(get_current_user)):
|
||||
return UserPublic.model_validate(current_user)
|
||||
```
|
||||
|
||||
**Why this matters for the frontend:** Currently, the frontend has no session persistence — `user` state is lost on page refresh. Once `GET /auth/me` is implemented, `AuthContext` can call it on mount and restore the user from the cookie automatically.
|
||||
|
||||
---
|
||||
|
||||
## 9. Role → Post-Login Redirect Map
|
||||
|
||||
The frontend (`Login.jsx`) redirects to a role-specific page after successful login. The backend `LoginResponse` includes `role` so the frontend can make this decision. This table is the authoritative mapping:
|
||||
|
||||
| Role | Post-login Redirect |
|
||||
|------|-------------------|
|
||||
| `CUSTOMER` | `/portal/profile` |
|
||||
| `OWNER` | `/owner/snapshot` |
|
||||
| `ADMIN` | `/emp/fa/dashboard` |
|
||||
| `FIELD_AGENT` | `/emp/fa/dashboard` |
|
||||
| `CONTRACTOR` | `/contractor/dashboard` |
|
||||
| `VENDOR` | `/vendor/dashboard` |
|
||||
| `SUBCONTRACTOR` | `/subcontractor/dashboard` |
|
||||
|
||||
---
|
||||
|
||||
## 10. Permission System
|
||||
|
||||
The frontend's `src/utils/permissions.js` defines a `PERMISSIONS` object and a `ROLE_PERMISSIONS` map. The backend must enforce the same rules at the API layer. The canonical permission matrix:
|
||||
|
||||
| Permission | OWNER | ADMIN | CONTRACTOR | SUBCONTRACTOR | FIELD_AGENT | VENDOR | CUSTOMER |
|
||||
|------------|-------|-------|------------|---------------|-------------|--------|----------|
|
||||
| `VIEW_ALL_PROJECTS` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| `VIEW_ALL_PERSONNEL` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| `VIEW_FINANCIALS` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| `VIEW_SENSITIVE_DATA` | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| `MANAGE_USERS` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| `APPROVE_DOCUMENTS` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| `VIEW_ASSIGNED_PROJECTS` | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| `UPLOAD_DOCUMENTS` | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ |
|
||||
| `SUBMIT_INVOICES` | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ |
|
||||
| `MANAGE_OWN_CREW` | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ |
|
||||
| `VIEW_ASSIGNED_TASKS` | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| `UPDATE_TASK_STATUS` | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
|
||||
Implement as a utility used inside service functions (not in routers — routers only check role, services check fine-grained permissions):
|
||||
|
||||
```python
|
||||
# app/utils/permissions.py
|
||||
ROLE_PERMISSIONS: dict[str, list[str]] = {
|
||||
"OWNER": [
|
||||
"VIEW_ALL_PROJECTS", "VIEW_ALL_PERSONNEL", "VIEW_FINANCIALS",
|
||||
"VIEW_SENSITIVE_DATA", "MANAGE_USERS", "APPROVE_DOCUMENTS",
|
||||
"VIEW_ASSIGNED_PROJECTS", "UPLOAD_DOCUMENTS", "SUBMIT_INVOICES",
|
||||
"VIEW_ASSIGNED_TASKS", "UPDATE_TASK_STATUS",
|
||||
],
|
||||
"ADMIN": [
|
||||
"VIEW_ALL_PROJECTS", "VIEW_ALL_PERSONNEL", "VIEW_FINANCIALS",
|
||||
"MANAGE_USERS", "APPROVE_DOCUMENTS", "VIEW_ASSIGNED_PROJECTS",
|
||||
"UPLOAD_DOCUMENTS", "SUBMIT_INVOICES", "VIEW_ASSIGNED_TASKS", "UPDATE_TASK_STATUS",
|
||||
],
|
||||
"CONTRACTOR": [
|
||||
"VIEW_ASSIGNED_PROJECTS", "UPLOAD_DOCUMENTS",
|
||||
"SUBMIT_INVOICES", "MANAGE_OWN_CREW", "VIEW_ASSIGNED_TASKS", "UPDATE_TASK_STATUS",
|
||||
],
|
||||
"SUBCONTRACTOR": ["VIEW_ASSIGNED_TASKS", "UPDATE_TASK_STATUS"],
|
||||
"FIELD_AGENT": [],
|
||||
"VENDOR": ["UPLOAD_DOCUMENTS", "SUBMIT_INVOICES"],
|
||||
"CUSTOMER": [],
|
||||
}
|
||||
|
||||
def has_permission(role: str, permission: str) -> bool:
|
||||
return permission in ROLE_PERMISSIONS.get(role, [])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Sensitive Data Masking
|
||||
|
||||
`VIEW_SENSITIVE_DATA` is `OWNER`-only. Any endpoint that returns sensitive fields (SSN, EIN, bank account numbers) must mask them for all other roles.
|
||||
|
||||
```python
|
||||
# app/utils/mask.py
|
||||
|
||||
def mask_ssn(ssn: str | None) -> str:
|
||||
"""'123-45-6789' → '***-**-6789'"""
|
||||
if not ssn:
|
||||
return ""
|
||||
return f"***-**-{ssn[-4:]}"
|
||||
|
||||
def mask_bank_account(account: str | None) -> str:
|
||||
"""'9876543210' → '******3210'"""
|
||||
if not account:
|
||||
return ""
|
||||
return f"{'*' * (len(account) - 4)}{account[-4:]}"
|
||||
|
||||
def mask_ein(ein: str | None) -> str:
|
||||
"""'12-3456789' → '**-***6789'"""
|
||||
if not ein:
|
||||
return ""
|
||||
return f"**-***{ein[-4:]}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Seed Credentials
|
||||
|
||||
All 17 mock users are seeded with `password = "password"` (bcrypt-hashed). Use these during development and integration testing:
|
||||
|
||||
| Role | Login Type (UI) | Identifier | Portal |
|
||||
|------|----------------|------------|--------|
|
||||
| `CUSTOMER` | `customer` | `alice` | `/portal/profile` |
|
||||
| `FIELD_AGENT` | `employee` | `FA001` | `/emp/fa/dashboard` |
|
||||
| `FIELD_AGENT` | `employee` | `FA002` | `/emp/fa/dashboard` |
|
||||
| `FIELD_AGENT` | `employee` | `FA003` | `/emp/fa/dashboard` |
|
||||
| `FIELD_AGENT` | `employee` | `FA004` | `/emp/fa/dashboard` |
|
||||
| `FIELD_AGENT` | `employee` | `FA005` | `/emp/fa/dashboard` |
|
||||
| `ADMIN` | `employee` | `ADM01` | `/emp/fa/dashboard` |
|
||||
| `ADMIN` | `employee` | `ADM02` | `/emp/fa/dashboard` |
|
||||
| `ADMIN` | `employee` | `ADM03` | `/emp/fa/dashboard` |
|
||||
| `OWNER` | `owner` | `justin` | `/owner/snapshot` |
|
||||
| `OWNER` | `owner` | `diana` | `/owner/snapshot` |
|
||||
| `CONTRACTOR` | `contractor` | `mike` | `/contractor/dashboard` |
|
||||
| `SUBCONTRACTOR` | `subcontractor` | `carlos` | `/subcontractor/dashboard` |
|
||||
| `VENDOR` | `vendor` | `abc_supply` | `/vendor/dashboard` |
|
||||
|
||||
---
|
||||
|
||||
## 13. Security Checklist
|
||||
|
||||
Before shipping auth to production:
|
||||
|
||||
- [ ] `SECRET_KEY` is generated with `openssl rand -hex 32` — not a guessable string
|
||||
- [ ] `secure=True` on cookies (HTTPS only)
|
||||
- [ ] `samesite="strict"` on cookies
|
||||
- [ ] Refresh token path scoped to `/api/v1/auth`
|
||||
- [ ] Login endpoint has rate limiting (e.g. 10 attempts / minute per IP)
|
||||
- [ ] Failed login attempts do NOT reveal which field was wrong
|
||||
- [ ] Refresh token rotation implemented (reuse = revoke all)
|
||||
- [ ] Logout clears both cookies AND invalidates DB token hash
|
||||
- [ ] `GET /auth/me` is the only session-restore mechanism (no `localStorage` token storage)
|
||||
- [ ] Audit log entry written for every login and logout
|
||||
|
||||
---
|
||||
|
||||
*Next: Read `03_properties_module.md` or `04_users_people_module.md` — both depend on `require_role()` defined here.*
|
||||
@@ -0,0 +1,836 @@
|
||||
# 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.*
|
||||
Reference in New Issue
Block a user