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,280 @@
|
||||
# HLD — High-Level Design Diagrams
|
||||
|
||||
**Scope:** LynkedUpPro CRM — Full System
|
||||
**Renders in:** GitHub, VS Code (Mermaid extension), GitLab, Notion
|
||||
|
||||
---
|
||||
|
||||
## Diagram 1 — System Context
|
||||
|
||||
Who uses the system, what it does, and what external services it depends on.
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Users["👤 User Roles"]
|
||||
OWN([Owner])
|
||||
ADM([Admin])
|
||||
FA([Field Agent])
|
||||
CON([Contractor])
|
||||
SUB([Subcontractor])
|
||||
VEN([Vendor])
|
||||
CUS([Customer])
|
||||
end
|
||||
|
||||
subgraph LynkedUpPro["🏢 LynkedUpPro System"]
|
||||
FE["React 19 SPA\n(Vercel)"]
|
||||
BE["FastAPI Backend\n(Railway / Render)"]
|
||||
DB[(PostgreSQL 16)]
|
||||
end
|
||||
|
||||
subgraph External["☁️ External Services"]
|
||||
GROQ["Groq API\n(AI / LLM)"]
|
||||
S3["AWS S3\n(File Storage)"]
|
||||
SMTP["SendGrid\n(Email)"]
|
||||
end
|
||||
|
||||
OWN & ADM & FA & CON & SUB & VEN & CUS -->|"HTTPS browser"| FE
|
||||
FE -->|"REST /api/v1/ + JWT"| BE
|
||||
BE -->|"SQLAlchemy async"| DB
|
||||
BE -->|"httpx proxy"| GROQ
|
||||
BE -->|"boto3 SDK"| S3
|
||||
BE -->|"SMTP / SendGrid SDK"| SMTP
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 2 — System Components
|
||||
|
||||
Internal components of each layer and how they connect.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph Frontend["Frontend — React SPA (Vite)"]
|
||||
direction TB
|
||||
AC["AuthContext\n(login / logout)"]
|
||||
MS["mockStore → ApiProvider\n(data layer)"]
|
||||
GC["GamificationContext\n(XP / levels)"]
|
||||
PAGES["Pages\n(Owner / Admin / Agent\nContractor / Vendor / Customer)"]
|
||||
CHAT["Chatbot Component\n(AI assistant)"]
|
||||
MAP["Maps Component\n(Leaflet + Polygons)"]
|
||||
end
|
||||
|
||||
subgraph Backend["Backend — FastAPI"]
|
||||
direction TB
|
||||
MW["Middleware\n(CORS · Rate Limit · Logging)"]
|
||||
AUTH_R["Auth Router\n(/api/v1/auth)"]
|
||||
ROUTERS["Feature Routers\n(properties · meetings · vendors\nprojects · invoices · documents\nusers · chatbot)"]
|
||||
SERVICES["Service Layer\n(business logic)"]
|
||||
DEPS["Dependencies\nget_db · get_current_user\nrequire_role()"]
|
||||
end
|
||||
|
||||
subgraph Data["Data Layer"]
|
||||
PG[(PostgreSQL\n16 tables)]
|
||||
S3_B["AWS S3\n(documents / COIs)"]
|
||||
end
|
||||
|
||||
subgraph Ext["External APIs"]
|
||||
GROQ_A["Groq API\nqwen-32b"]
|
||||
SG["SendGrid\n(alerts)"]
|
||||
end
|
||||
|
||||
Frontend -->|"JWT Bearer / httpOnly cookie"| MW
|
||||
MW --> AUTH_R
|
||||
MW --> ROUTERS
|
||||
AUTH_R --> DEPS
|
||||
ROUTERS --> DEPS
|
||||
DEPS --> SERVICES
|
||||
SERVICES --> PG
|
||||
SERVICES --> S3_B
|
||||
SERVICES -->|"RBAC-gated proxy"| GROQ_A
|
||||
SERVICES -->|"async trigger"| SG
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 3 — Role Access Zones
|
||||
|
||||
Which roles can access which portals and routes. Each role logs in through a single `/login` page and is routed to their portal based on JWT claims.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
LOGIN["/login\nSingle Entry Point"]
|
||||
|
||||
LOGIN -->|"role = OWNER"| OWNER_ZONE
|
||||
LOGIN -->|"role = ADMIN"| ADMIN_ZONE
|
||||
LOGIN -->|"role = FIELD_AGENT"| FA_ZONE
|
||||
LOGIN -->|"role = CONTRACTOR"| CON_ZONE
|
||||
LOGIN -->|"role = VENDOR"| VEN_ZONE
|
||||
LOGIN -->|"role = SUBCONTRACTOR"| SUB_ZONE
|
||||
LOGIN -->|"role = CUSTOMER"| CUS_ZONE
|
||||
|
||||
subgraph OWNER_ZONE["👑 Owner Portal"]
|
||||
O1["/owner/snapshot\nBusiness Health Dashboard"]
|
||||
O2["/owner/projects\nProject Command Center"]
|
||||
O3["/owner/vendors\nVendor Directory"]
|
||||
O4["/owner/people\nPeople Directory"]
|
||||
O5["/owner/documents\nDocument Management"]
|
||||
O6["/owner/maps\nTerritory Map"]
|
||||
O7["/owner/pro-canvas\nTeam Leaderboard"]
|
||||
O8["/admin/*\nFull Admin Access"]
|
||||
end
|
||||
|
||||
subgraph ADMIN_ZONE["🏢 Admin Portal"]
|
||||
A1["/admin/dashboard\nCommand Center"]
|
||||
A2["/admin/schedule\nTeam Schedule"]
|
||||
A3["/admin/leaderboard\nSales Leaderboard"]
|
||||
A4["/admin/maps\nTerritory Map"]
|
||||
A5["/admin/pro-canvas\nTeam Performance"]
|
||||
end
|
||||
|
||||
subgraph FA_ZONE["🚶 Field Agent Portal"]
|
||||
FA1["/emp/fa/dashboard\nMy Dashboard"]
|
||||
FA2["/emp/fa/maps\nTerritory Map"]
|
||||
FA3["/emp/fa/pro-canvas\nMy Gamification Hub"]
|
||||
end
|
||||
|
||||
subgraph CON_ZONE["🔨 Contractor Portal"]
|
||||
C1["/contractor/dashboard\nMy Projects"]
|
||||
C2["/contractor/projects\nProject List"]
|
||||
end
|
||||
|
||||
subgraph VEN_ZONE["📦 Vendor Portal"]
|
||||
V1["/vendor/dashboard\nMy Orders & KPIs"]
|
||||
V2["/vendor/orders\nOrder Management"]
|
||||
end
|
||||
|
||||
subgraph SUB_ZONE["⚙️ Subcontractor Portal"]
|
||||
S1["/subcontractor/dashboard\nMy Work"]
|
||||
S2["/subcontractor/projects\nAssigned Projects"]
|
||||
end
|
||||
|
||||
subgraph CUS_ZONE["🏠 Customer Portal"]
|
||||
CU1["/portal/profile\nMy Property & Meetings"]
|
||||
end
|
||||
|
||||
SHARED["/chat-assistant\nAI Assistant\n(all roles)"]
|
||||
LOGIN --> SHARED
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 4 — High-Level Data Flow
|
||||
|
||||
How a user action travels from browser → API → database and back.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor User as 👤 User (any role)
|
||||
participant FE as React SPA
|
||||
participant AC as AuthContext
|
||||
participant API as FastAPI /api/v1
|
||||
participant DB as PostgreSQL
|
||||
|
||||
Note over User,DB: ── Initial Login ──
|
||||
User->>FE: Enter credentials
|
||||
FE->>AC: login(identifier, password, type)
|
||||
AC->>API: POST /auth/login
|
||||
API->>DB: SELECT user WHERE email/empId = ?
|
||||
DB-->>API: User row
|
||||
API-->>AC: { access_token, refresh_token, user }
|
||||
AC-->>FE: Set cookie + update user state
|
||||
FE-->>User: Redirect to role portal
|
||||
|
||||
Note over User,DB: ── Authenticated Data Request ──
|
||||
User->>FE: Navigate to page
|
||||
FE->>API: GET /properties?status=Hot+Lead\nAuthorization: Bearer <token>
|
||||
API->>API: Validate JWT → extract role
|
||||
API->>API: require_role(FIELD_AGENT, ADMIN, OWNER)
|
||||
API->>DB: SELECT * FROM properties WHERE deleted_at IS NULL
|
||||
DB-->>API: Rows
|
||||
API-->>FE: { data: [...], meta: { total, page } }
|
||||
FE-->>User: Render page with data
|
||||
|
||||
Note over User,DB: ── Write Action (e.g. assign agent) ──
|
||||
User->>FE: Click "Assign Agent"
|
||||
FE->>API: PATCH /properties/42\n{ assigned_agent_id: "uuid" }
|
||||
API->>API: require_role(ADMIN, OWNER)
|
||||
API->>DB: UPDATE properties SET assigned_agent_id = ?, updated_at = NOW()
|
||||
API->>DB: INSERT INTO audit_logs (actor_id, action, ...)
|
||||
DB-->>API: Updated row
|
||||
API-->>FE: { data: { ...updatedProperty } }
|
||||
FE-->>User: Toast "Agent assigned ✓"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 5 — Deployment Architecture
|
||||
|
||||
How the pieces are hosted and connected in production.
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Internet["🌐 Internet"]
|
||||
BROWSER["User Browser"]
|
||||
end
|
||||
|
||||
subgraph Vercel["Vercel (Frontend CDN)"]
|
||||
CDN["Static Assets\nReact SPA Bundle"]
|
||||
end
|
||||
|
||||
subgraph Railway["Railway / Render (Backend)"]
|
||||
UVICORN["uvicorn workers\nFastAPI app"]
|
||||
PG_DB[("PostgreSQL 16\nManaged DB")]
|
||||
end
|
||||
|
||||
subgraph AWS["AWS"]
|
||||
S3_BUCKET["S3 Bucket\nlynkeduppro-docs\n(COIs, W9s, Contracts)"]
|
||||
end
|
||||
|
||||
subgraph ThirdParty["Third-Party APIs"]
|
||||
GROQ_API["Groq API\nLLM inference"]
|
||||
SENDGRID_API["SendGrid\nTransactional email"]
|
||||
end
|
||||
|
||||
BROWSER -->|"HTTPS :443"| CDN
|
||||
CDN -->|"HTTPS /api/v1/*\nJWT in cookie"| UVICORN
|
||||
UVICORN <-->|"TCP :5432\nasyncpg"| PG_DB
|
||||
UVICORN -->|"HTTPS\npre-signed URLs"| S3_BUCKET
|
||||
UVICORN -->|"HTTPS\nAPI key"| GROQ_API
|
||||
UVICORN -->|"HTTPS\nAPI key"| SENDGRID_API
|
||||
BROWSER -->|"pre-signed URL\ndirect upload"| S3_BUCKET
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 6 — Role Permission Overview (RBAC Summary)
|
||||
|
||||
A simplified view of the data each role can read and write.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph ReadAll["📖 Read All Data"]
|
||||
OWNER_R["OWNER\nAll tables, all records"]
|
||||
ADMIN_R["ADMIN\nAll tables, all records"]
|
||||
end
|
||||
|
||||
subgraph ReadScoped["📖 Read Scoped Data"]
|
||||
FA_R["FIELD_AGENT\nOwn meetings + assigned properties"]
|
||||
CON_R["CONTRACTOR\nAssigned projects + own invoices"]
|
||||
SUB_R["SUBCONTRACTOR\nAssigned tasks + project scope"]
|
||||
VEN_R["VENDOR\nOwn orders + own compliance docs"]
|
||||
CUS_R["CUSTOMER\nOwn property + own meetings"]
|
||||
end
|
||||
|
||||
subgraph WriteAll["✏️ Write All Data"]
|
||||
OWNER_W["OWNER\nProjects · Vendors · Documents\nInvoice approval · Change orders"]
|
||||
ADMIN_W["ADMIN\nProperties · Meetings · Assignments"]
|
||||
end
|
||||
|
||||
subgraph WriteSelf["✏️ Write Own Data"]
|
||||
FA_W["FIELD_AGENT\nMeeting outcomes · Lead status"]
|
||||
VEN_W["VENDOR\nOrder acknowledgement"]
|
||||
CUS_W["CUSTOMER\nProfile info"]
|
||||
end
|
||||
|
||||
OWNER_R --- OWNER_W
|
||||
ADMIN_R --- ADMIN_W
|
||||
FA_R --- FA_W
|
||||
VEN_R --- VEN_W
|
||||
CUS_R --- CUS_W
|
||||
```
|
||||
@@ -0,0 +1,388 @@
|
||||
# LLD — Architecture (B0)
|
||||
|
||||
**Scope:** FastAPI backend — internal structure, request lifecycle, auth flows, module dependencies
|
||||
**Companion doc:** `docs/backend/00_project_overview.md`
|
||||
|
||||
---
|
||||
|
||||
## Diagram 1 — FastAPI Application Layer Structure
|
||||
|
||||
How the files inside `app/` relate to each other. Arrows show import/dependency direction.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph Entry["Entry Point"]
|
||||
MAIN["main.py\nFastAPI() instance\nMiddleware registration\nRouter registration"]
|
||||
end
|
||||
|
||||
subgraph Config["Configuration"]
|
||||
CFG["config.py\nSettings (pydantic-settings)\nReads .env"]
|
||||
DB_MOD["database.py\nasync engine\nAsyncSession factory\nget_db()"]
|
||||
end
|
||||
|
||||
subgraph Deps["Shared Dependencies"]
|
||||
DEPMOD["dependencies.py\nget_db()\nget_current_user()\nrequire_role(*roles)"]
|
||||
end
|
||||
|
||||
subgraph Routers["Routers — /api/v1/"]
|
||||
R_AUTH["auth.py\n/auth/login\n/auth/refresh\n/auth/logout"]
|
||||
R_USERS["users.py\n/users/me\n/users/{id}"]
|
||||
R_PROP["properties.py\n/properties\n/properties/{id}"]
|
||||
R_MEET["meetings.py\n/meetings\n/meetings/{id}"]
|
||||
R_VEND["vendors.py\n/vendors\n/vendors/{id}"]
|
||||
R_PROJ["projects.py\n/projects\n/projects/{id}/tasks"]
|
||||
R_INV["invoices.py\n/invoices\n/invoices/{id}/approve"]
|
||||
R_DOC["documents.py\n/documents\n/documents/{id}"]
|
||||
R_CHAT["chatbot.py\n/chatbot/message"]
|
||||
end
|
||||
|
||||
subgraph Services["Service Layer (Business Logic)"]
|
||||
S_AUTH["auth_service.py\nverify_password()\ncreate_tokens()\nrotate_refresh_token()"]
|
||||
S_PROP["property_service.py\nget_properties()\nassign_agent()\nmark_contacted()"]
|
||||
S_MEET["meeting_service.py\nschedule()\nchange_status()\nrequest_change()"]
|
||||
S_VEND["vendor_service.py\ncheck_compliance()\nupdate_coi()"]
|
||||
S_NOTIF["notification_service.py\nsend_email()\ncreate_notification()"]
|
||||
S_CHAT["chatbot_service.py\nbuild_context(role)\nproxy_to_groq()"]
|
||||
end
|
||||
|
||||
subgraph Models["ORM Models (SQLAlchemy)"]
|
||||
M_ALL["user.py · property.py\nmeeting.py · vendor.py\nproject.py · invoice.py\ndocument.py · audit_log.py"]
|
||||
end
|
||||
|
||||
subgraph Schemas["Pydantic Schemas (Request/Response)"]
|
||||
SCH_ALL["user.py · property.py\nmeeting.py · vendor.py\nauth.py · invoice.py"]
|
||||
end
|
||||
|
||||
subgraph Utils["Utilities"]
|
||||
U_SEC["security.py\njwt_encode / jwt_decode\nbcrypt helpers"]
|
||||
U_PERM["permissions.py\nrole_can_access()"]
|
||||
U_MASK["mask.py\nmask_ssn() · mask_bank()"]
|
||||
end
|
||||
|
||||
MAIN --> CFG
|
||||
MAIN --> DB_MOD
|
||||
MAIN --> DEPMOD
|
||||
MAIN --> R_AUTH & R_USERS & R_PROP & R_MEET & R_VEND & R_PROJ & R_INV & R_DOC & R_CHAT
|
||||
|
||||
R_AUTH --> DEPMOD --> S_AUTH
|
||||
R_PROP --> DEPMOD --> S_PROP
|
||||
R_MEET --> DEPMOD --> S_MEET
|
||||
R_VEND --> DEPMOD --> S_VEND
|
||||
R_CHAT --> DEPMOD --> S_CHAT
|
||||
S_NOTIF --> S_PROP & S_MEET & S_VEND
|
||||
|
||||
S_AUTH & S_PROP & S_MEET & S_VEND & S_PROJ --> M_ALL
|
||||
R_AUTH & R_PROP & R_MEET --> SCH_ALL
|
||||
S_AUTH --> U_SEC
|
||||
DEPMOD --> U_PERM
|
||||
R_USERS --> U_MASK
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 2 — HTTP Request Lifecycle
|
||||
|
||||
Every request follows this exact path through the backend. Each step is a named file/function.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client (React)
|
||||
participant CORS as CORS Middleware
|
||||
participant RL as Rate Limiter
|
||||
participant LOG as Request Logger
|
||||
participant RT as Router (e.g. properties.py)
|
||||
participant DEP as Dependencies
|
||||
participant SVC as Service Layer
|
||||
participant ORM as SQLAlchemy ORM
|
||||
participant DB as PostgreSQL
|
||||
|
||||
C->>CORS: HTTP Request
|
||||
Note over CORS: Check Origin header<br/>against ALLOWED_ORIGINS
|
||||
CORS-->>C: 403 if origin not allowed
|
||||
CORS->>RL: Pass through
|
||||
Note over RL: Check rate limit<br/>by IP / user ID
|
||||
RL-->>C: 429 if exceeded
|
||||
RL->>LOG: Pass through
|
||||
Note over LOG: Log: method, path,<br/>request_id, timestamp
|
||||
LOG->>RT: Route matched
|
||||
|
||||
Note over RT,DEP: FastAPI resolves Depends()
|
||||
RT->>DEP: get_db() → yields AsyncSession
|
||||
RT->>DEP: get_current_user(token)
|
||||
Note over DEP: Decode JWT<br/>Validate expiry<br/>Load user from DB
|
||||
DEP-->>RT: 401 if invalid token
|
||||
RT->>DEP: require_role("ADMIN","OWNER")
|
||||
DEP-->>RT: 403 if role mismatch
|
||||
DEP-->>RT: current_user injected
|
||||
|
||||
RT->>SVC: call service function(db, current_user, params)
|
||||
Note over SVC: Business logic<br/>Permission refinement<br/>Data transformation
|
||||
SVC->>ORM: db.execute(select(Property)...)
|
||||
ORM->>DB: SQL query
|
||||
DB-->>ORM: Rows
|
||||
ORM-->>SVC: Model instances
|
||||
SVC-->>RT: Result dict / model
|
||||
|
||||
Note over RT: Pydantic response schema<br/>serializes result
|
||||
RT-->>C: 200 { data: {...}, meta: {...} }
|
||||
Note over LOG: Log: status_code,<br/>duration_ms, response_size
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 3 — Authentication Flow
|
||||
|
||||
### 3a — Login
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor U as User
|
||||
participant FE as React AuthContext
|
||||
participant API as POST /auth/login
|
||||
participant SVC as auth_service.py
|
||||
participant DB as users table
|
||||
participant SEC as security.py
|
||||
|
||||
U->>FE: login(identifier, password, type)
|
||||
FE->>API: { identifier, password, type }
|
||||
API->>SVC: authenticate(identifier, password, type)
|
||||
|
||||
alt type == "customer"
|
||||
SVC->>DB: SELECT WHERE username = identifier
|
||||
else type == "employee" (FIELD_AGENT / ADMIN)
|
||||
SVC->>DB: SELECT WHERE emp_id = identifier
|
||||
else type == "employee" (OWNER / CONTRACTOR / VENDOR / SUBCONTRACTOR)
|
||||
SVC->>DB: SELECT WHERE username = identifier
|
||||
end
|
||||
|
||||
DB-->>SVC: User row (or None)
|
||||
|
||||
alt User not found
|
||||
SVC-->>API: None
|
||||
API-->>FE: 401 { detail: "Invalid credentials" }
|
||||
FE-->>U: Toast "Invalid credentials"
|
||||
else User found
|
||||
SVC->>SEC: verify_password(plain, hash)
|
||||
alt Password wrong
|
||||
SEC-->>SVC: False
|
||||
SVC-->>API: None
|
||||
API-->>FE: 401 { detail: "Invalid credentials" }
|
||||
else Password correct
|
||||
SEC-->>SVC: True
|
||||
SVC->>SEC: create_access_token(user.id, user.role)
|
||||
SVC->>SEC: create_refresh_token(user.id)
|
||||
SVC->>DB: UPDATE users SET refresh_token_hash = ?, last_login_at = NOW()
|
||||
SVC-->>API: { access_token, refresh_token, user }
|
||||
API-->>FE: Set httpOnly cookie (access + refresh)\n200 { user: { id, name, role, ... } }
|
||||
FE-->>U: Redirect to role portal
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### 3b — Authenticated Request
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant FE as React
|
||||
participant API as Any Protected Endpoint
|
||||
participant DEP as get_current_user()
|
||||
participant SEC as security.py
|
||||
participant DB as users table
|
||||
|
||||
FE->>API: GET /properties\nCookie: access_token=<jwt>
|
||||
|
||||
API->>DEP: Depends(get_current_user)
|
||||
DEP->>DEP: Extract token from cookie / Authorization header
|
||||
|
||||
alt No token present
|
||||
DEP-->>API: HTTPException 401
|
||||
API-->>FE: 401 UNAUTHORIZED
|
||||
end
|
||||
|
||||
DEP->>SEC: jwt_decode(token, SECRET_KEY)
|
||||
|
||||
alt Token expired
|
||||
SEC-->>DEP: JWTError (ExpiredSignatureError)
|
||||
DEP-->>API: HTTPException 401 "Token expired"
|
||||
API-->>FE: 401 → FE triggers refresh flow
|
||||
else Token invalid (tampered)
|
||||
SEC-->>DEP: JWTError
|
||||
DEP-->>API: HTTPException 401 "Invalid token"
|
||||
else Token valid
|
||||
SEC-->>DEP: { sub: user_id, role: "FIELD_AGENT", exp: ... }
|
||||
DEP->>DB: SELECT WHERE id = user_id AND deleted_at IS NULL
|
||||
DB-->>DEP: User row
|
||||
DEP-->>API: current_user injected
|
||||
API->>API: require_role check
|
||||
API-->>FE: 200 with data
|
||||
end
|
||||
```
|
||||
|
||||
### 3c — Token Refresh
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant FE as React (axios interceptor)
|
||||
participant API as POST /auth/refresh
|
||||
participant SVC as auth_service.py
|
||||
participant DB as users table
|
||||
participant SEC as security.py
|
||||
|
||||
Note over FE: Receives 401 "Token expired"
|
||||
FE->>API: POST /auth/refresh\nCookie: refresh_token=<jwt>
|
||||
|
||||
API->>SVC: refresh_access_token(refresh_token)
|
||||
SVC->>SEC: jwt_decode(refresh_token)
|
||||
|
||||
alt Refresh token invalid/expired
|
||||
SEC-->>SVC: JWTError
|
||||
SVC-->>API: Raise 401
|
||||
API-->>FE: 401 → FE calls logout(), redirect to /login
|
||||
else Valid
|
||||
SEC-->>SVC: { sub: user_id }
|
||||
SVC->>DB: SELECT refresh_token_hash WHERE id = user_id
|
||||
SVC->>SEC: verify(incoming_token, stored_hash)
|
||||
|
||||
alt Hash mismatch (token reuse attack)
|
||||
SEC-->>SVC: False
|
||||
SVC->>DB: UPDATE users SET refresh_token_hash = NULL (revoke all)
|
||||
SVC-->>API: 401 "Refresh token reuse detected"
|
||||
API-->>FE: 401 → Force logout
|
||||
else Hash matches
|
||||
SVC->>SEC: create_access_token(user_id, role)
|
||||
SVC->>SEC: create_refresh_token(user_id)
|
||||
SVC->>DB: UPDATE users SET refresh_token_hash = new_hash
|
||||
SVC-->>API: { new_access_token, new_refresh_token }
|
||||
API-->>FE: Set new cookies\n200 OK
|
||||
FE->>FE: Retry original request with new token
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 4 — RBAC Middleware Decision Tree
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
REQ["Incoming Request"] --> HAS_TOKEN{Token present\nin cookie\nor header?}
|
||||
|
||||
HAS_TOKEN -->|No| R401_A["401 UNAUTHORIZED\nNo authentication provided"]
|
||||
|
||||
HAS_TOKEN -->|Yes| DECODE{Decode JWT\nvalid signature?}
|
||||
DECODE -->|No / tampered| R401_B["401 UNAUTHORIZED\nInvalid token"]
|
||||
DECODE -->|Yes| EXPIRED{Token\nexpired?}
|
||||
|
||||
EXPIRED -->|Yes| R401_C["401 UNAUTHORIZED\nToken expired\n→ client should refresh"]
|
||||
|
||||
EXPIRED -->|No| LOAD_USER{User exists\nin DB and\nis_active?}
|
||||
LOAD_USER -->|No| R401_D["401 UNAUTHORIZED\nUser not found or deactivated"]
|
||||
|
||||
LOAD_USER -->|Yes| HAS_ROLE_CHECK{Route has\nrequire_role()?}
|
||||
HAS_ROLE_CHECK -->|No - public route| PASS["✅ Proceed to handler\ncurrent_user = None"]
|
||||
|
||||
HAS_ROLE_CHECK -->|Yes| ROLE_MATCH{user.role\nin allowed_roles?}
|
||||
ROLE_MATCH -->|No| R403["403 FORBIDDEN\nInsufficient permissions\nfor this resource"]
|
||||
ROLE_MATCH -->|Yes| HANDLER["✅ Proceed to handler\ncurrent_user = User object"]
|
||||
|
||||
HANDLER --> SERVICE["Service layer\nfurther scoping\n(e.g. agent only sees own meetings)"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 5 — Module Build Dependency Graph
|
||||
|
||||
The recommended order to build backend modules. Each module depends on the ones above it.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
B0["B0\nProject Overview\n& Conventions"]
|
||||
B2["B2\nDatabase Schema\n(All ORM models)"]
|
||||
B1["B1\nAuthentication\n(JWT, RBAC, all 6 roles)"]
|
||||
B3["B3\nProperties Module\n(CRUD, geospatial, leads)"]
|
||||
B4["B4\nUsers & People\n(profiles, masking)"]
|
||||
B5["B5\nMeetings & Scheduling\n(status machine, change requests)"]
|
||||
B6["B6\nVendors & Compliance\n(COI, W9, expiry alerts)"]
|
||||
B7["B7\nFinancials\n(invoices, AR/AP, payouts)"]
|
||||
B8["B8\nChatbot & AI\n(RBAC context, Groq proxy)"]
|
||||
B9["B9\nNotifications\n(email, in-app, alerts)"]
|
||||
|
||||
B0 --> B2
|
||||
B2 --> B1
|
||||
B1 --> B3
|
||||
B1 --> B4
|
||||
B3 --> B5
|
||||
B4 --> B5
|
||||
B5 --> B6
|
||||
B3 --> B7
|
||||
B6 --> B7
|
||||
B3 & B4 & B5 & B6 & B7 --> B8
|
||||
B5 & B6 & B7 --> B9
|
||||
|
||||
style B0 fill:#1e3a5f,color:#fff
|
||||
style B2 fill:#1e3a5f,color:#fff
|
||||
style B1 fill:#1e5f3a,color:#fff
|
||||
style B3 fill:#3a1e5f,color:#fff
|
||||
style B4 fill:#3a1e5f,color:#fff
|
||||
style B5 fill:#5f3a1e,color:#fff
|
||||
style B6 fill:#5f3a1e,color:#fff
|
||||
style B7 fill:#5f1e3a,color:#fff
|
||||
style B8 fill:#1e5f5f,color:#fff
|
||||
style B9 fill:#5f5f1e,color:#fff
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 6 — Chatbot RBAC Context Builder
|
||||
|
||||
How the chatbot service decides what data to inject into the LLM prompt based on the requesting user's role.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
REQ["POST /chatbot/message\n{ message, role }"] --> AUTH["Validate JWT\nExtract role"]
|
||||
|
||||
AUTH --> ROLE{user.role?}
|
||||
|
||||
ROLE -->|FIELD_AGENT| FA_CTX["Build Agent Context\n• Today's meetings\n• Assigned properties\n• Personal XP / streak\n• Top 5 hot leads in territory"]
|
||||
|
||||
ROLE -->|ADMIN| ADM_CTX["Build Admin Context\n• Unassigned leads count\n• Today's schedule (all agents)\n• Pending signatures\n• Revenue MTD"]
|
||||
|
||||
ROLE -->|OWNER| OWN_CTX["Build Owner Context\n• Project health scores\n• Overdue invoices\n• Compliance alerts\n• Revenue vs budget"]
|
||||
|
||||
ROLE -->|CONTRACTOR| CON_CTX["Build Contractor Context\n• Assigned projects\n• Pending tasks\n• Open change orders"]
|
||||
|
||||
ROLE -->|VENDOR| VEN_CTX["Build Vendor Context\n• Open orders\n• Compliance doc expiry\n• Payment status"]
|
||||
|
||||
ROLE -->|CUSTOMER| CUS_CTX["Build Customer Context\n• Own property details\n• Upcoming meetings\n• Service history"]
|
||||
|
||||
FA_CTX & ADM_CTX & OWN_CTX & CON_CTX & VEN_CTX & CUS_CTX --> PROMPT["Assemble system prompt\nRole context + user message"]
|
||||
|
||||
PROMPT --> GROQ["POST Groq API\nqwen-32b\nstream=true"]
|
||||
|
||||
GROQ --> AUDIT["Write audit_log\nactor_id, action=chatbot.query\nredacted message"]
|
||||
|
||||
AUDIT --> STREAM["Stream response\nback to client"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 7 — Environment Configuration Flow
|
||||
|
||||
How environment variables flow from `.env` into every part of the app.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
ENV_FILE[".env file\n(never committed)"]
|
||||
ENV_EXAMPLE[".env.example\n(committed template)"]
|
||||
|
||||
ENV_FILE -->|"pydantic-settings\nBaseSettings"| SETTINGS["config.py\nSettings singleton\nsettings.database_url\nsettings.secret_key\nsettings.groq_api_key\n..."]
|
||||
|
||||
SETTINGS -->|"settings.database_url"| DB_MOD["database.py\ncreate_async_engine(url)"]
|
||||
SETTINGS -->|"settings.secret_key"| SEC["security.py\njwt_encode/decode"]
|
||||
SETTINGS -->|"settings.groq_api_key"| CHAT_SVC["chatbot_service.py\nhttpx.post(headers={api_key})"]
|
||||
SETTINGS -->|"settings.backend_cors_origins"| CORS_MW["main.py\nCORSMiddleware(allow_origins=...)"]
|
||||
SETTINGS -->|"settings.aws_*"| S3_SVC["document upload\nboto3.client(s3)"]
|
||||
SETTINGS -->|"settings.sendgrid_api_key"| NOTIF_SVC["notification_service.py\nSendGrid client"]
|
||||
|
||||
ENV_EXAMPLE -.->|"documents expected vars\n(no real values)"| ENV_FILE
|
||||
```
|
||||
@@ -0,0 +1,385 @@
|
||||
# LLD — B1 Authentication Module
|
||||
|
||||
**Scope:** JWT auth, RBAC dependencies, cookie strategy, refresh token rotation, permissions, data masking
|
||||
**Companion doc:** `docs/backend/01_authentication_module.md`
|
||||
|
||||
---
|
||||
|
||||
## Diagram 1 — Auth Module File Dependency Graph
|
||||
|
||||
How the four auth files relate to each other and to the rest of the backend.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph AuthFiles["B1 — Files Created"]
|
||||
SEC["security.py\nhash_password()\nverify_password()\ncreate_access_token()\ncreate_refresh_token()\ndecode_token()\nhash_token()\nverify_token_hash()"]
|
||||
AUTH_SVC["auth_service.py\nauthenticate_user()\ncreate_token_pair()"]
|
||||
DEPS["dependencies.py\nget_db()\nget_current_user()\nrequire_role(*roles)"]
|
||||
AUTH_RT["routers/auth.py\nPOST /auth/login\nPOST /auth/refresh\nPOST /auth/logout\nGET /auth/me"]
|
||||
end
|
||||
|
||||
subgraph Utils["Utilities"]
|
||||
PERM["permissions.py\nROLE_PERMISSIONS\nhas_permission()"]
|
||||
MASK["mask.py\nmask_ssn()\nmask_bank_account()\nmask_ein()"]
|
||||
end
|
||||
|
||||
subgraph External["External Dependencies"]
|
||||
PASSLIB["passlib[bcrypt]\npwd_context"]
|
||||
JOSE["python-jose\njwt.encode/decode"]
|
||||
CFG["config.py\nsettings.secret_key\nsettings.algorithm\nsettings.access_token_expire_minutes\nsettings.refresh_token_expire_days"]
|
||||
USER_MODEL["models/user.py\nUser ORM model"]
|
||||
end
|
||||
|
||||
subgraph AllRouters["All Other Routers (B3–B8)"]
|
||||
R3["properties.py"]
|
||||
R4["users.py"]
|
||||
R5["meetings.py"]
|
||||
R6["vendors.py"]
|
||||
R7["invoices.py"]
|
||||
R8["chatbot.py"]
|
||||
end
|
||||
|
||||
SEC --> PASSLIB
|
||||
SEC --> JOSE
|
||||
SEC --> CFG
|
||||
AUTH_SVC --> SEC
|
||||
AUTH_SVC --> USER_MODEL
|
||||
DEPS --> SEC
|
||||
DEPS --> USER_MODEL
|
||||
AUTH_RT --> AUTH_SVC
|
||||
AUTH_RT --> DEPS
|
||||
AUTH_RT --> PERM
|
||||
|
||||
DEPS -->|"require_role() imported by"| R3 & R4 & R5 & R6 & R7 & R8
|
||||
MASK -->|"called by"| R4
|
||||
|
||||
style SEC fill:#1e3a5f,color:#fff
|
||||
style AUTH_SVC fill:#1e3a5f,color:#fff
|
||||
style DEPS fill:#1e5f3a,color:#fff
|
||||
style AUTH_RT fill:#3a1e5f,color:#fff
|
||||
style PERM fill:#5f3a1e,color:#fff
|
||||
style MASK fill:#5f3a1e,color:#fff
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 2 — Password Hashing Lifecycle
|
||||
|
||||
Two separate contexts where password functions are called: **user creation / seed** and **login verification**.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Creation["User Creation / Seed Script"]
|
||||
PLAIN1["plain: 'password'"]
|
||||
HASH_FN["hash_password(plain)\npwd_context.hash()"]
|
||||
DB_STORE["users.password_hash\n'$2b$12$...(60 chars)'"]
|
||||
|
||||
PLAIN1 --> HASH_FN --> DB_STORE
|
||||
end
|
||||
|
||||
subgraph Login["Login Verification"]
|
||||
PLAIN2["plain from request\nrequest.password"]
|
||||
DB_READ["users.password_hash\nfrom DB row"]
|
||||
VERIFY["verify_password(plain, hashed)\npwd_context.verify()"]
|
||||
TRUE["True → proceed"]
|
||||
FALSE["False → return None\n→ 401 Invalid credentials"]
|
||||
|
||||
PLAIN2 --> VERIFY
|
||||
DB_READ --> VERIFY
|
||||
VERIFY -->|match| TRUE
|
||||
VERIFY -->|no match| FALSE
|
||||
end
|
||||
|
||||
subgraph NeverDo["❌ Never Do This"]
|
||||
BAD1["store plain text\nin DB"]
|
||||
BAD2["compare strings directly\nplain == stored"]
|
||||
BAD3["use MD5 / SHA1\nfor passwords"]
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 3 — JWT Token Anatomy
|
||||
|
||||
Side-by-side view of what's inside each token type and where it lives.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph AccessToken["Access Token — 15 min"]
|
||||
direction TB
|
||||
AT_HDR["Header\n{ alg: HS256, typ: JWT }"]
|
||||
AT_PAY["Payload\n{\n sub: '3f8a1c2d-...(UUID)',\n role: 'FIELD_AGENT',\n legacy_id: 'e1',\n type: 'access',\n iat: 1740400000,\n exp: 1740400900\n}"]
|
||||
AT_SIG["Signature\nHMAC-SHA256(\n base64(header) + '.' + base64(payload),\n SECRET_KEY\n)"]
|
||||
AT_COOKIE["Cookie: access_token\nhttpOnly=true\nsecure=true\nsamesite=strict\npath=/\nmax_age=900s"]
|
||||
|
||||
AT_HDR --> AT_PAY --> AT_SIG --> AT_COOKIE
|
||||
end
|
||||
|
||||
subgraph RefreshToken["Refresh Token — 7 days"]
|
||||
direction TB
|
||||
RT_HDR["Header\n{ alg: HS256, typ: JWT }"]
|
||||
RT_PAY["Payload\n{\n sub: '3f8a1c2d-...(UUID)',\n type: 'refresh',\n iat: 1740400000,\n exp: 1741004800\n}"]
|
||||
RT_SIG["Signature\nHMAC-SHA256(...)"]
|
||||
RT_COOKIE["Cookie: refresh_token\nhttpOnly=true\nsecure=true\nsamesite=strict\npath=/api/v1/auth ← scoped\nmax_age=604800s"]
|
||||
RT_DB["DB: users.refresh_token_hash\nbcrypt hash of token string\n(not the token itself)"]
|
||||
|
||||
RT_HDR --> RT_PAY --> RT_SIG --> RT_COOKIE
|
||||
RT_SIG -.->|"hash stored in"| RT_DB
|
||||
end
|
||||
|
||||
note1["Access token has role + legacy_id\nso routers don't need extra DB lookup\nfor these common fields"]
|
||||
note2["Refresh token path scoped to /api/v1/auth\nso it is NOT sent with every API request —\nreduces exposure surface"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 4 — Login Type Decision Tree
|
||||
|
||||
How `authenticate_user()` maps the frontend's `loginType` to a DB query strategy.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
START["authenticate_user(\n db, identifier, password, login_type\n)"]
|
||||
|
||||
START --> LT{login_type?}
|
||||
|
||||
LT -->|"'customer'"| CUS["SELECT FROM users\nWHERE username = identifier\nAND role = 'CUSTOMER'\nAND deleted_at IS NULL\nAND is_active = true"]
|
||||
|
||||
LT -->|"'employee'"| EMP["SELECT FROM users\nWHERE emp_id = identifier\nAND role IN ('FIELD_AGENT','ADMIN')\nAND deleted_at IS NULL\nAND is_active = true"]
|
||||
|
||||
LT -->|"'owner'"| OWN["SELECT FROM users\nWHERE (username = identifier\n OR email = identifier)\nAND role = 'OWNER'\nAND deleted_at IS NULL\nAND is_active = true"]
|
||||
|
||||
LT -->|"'contractor'"| CON["SELECT FROM users\nWHERE (username = identifier\n OR email = identifier)\nAND role = 'CONTRACTOR'\nAND deleted_at IS NULL\nAND is_active = true"]
|
||||
|
||||
LT -->|"'subcontractor'"| SUB["SELECT FROM users\nWHERE (username = identifier\n OR email = identifier)\nAND role = 'SUBCONTRACTOR'\nAND deleted_at IS NULL\nAND is_active = true"]
|
||||
|
||||
LT -->|"'vendor'"| VEN["SELECT FROM users\nWHERE (username = identifier\n OR email = identifier)\nAND role = 'VENDOR'\nAND deleted_at IS NULL\nAND is_active = true"]
|
||||
|
||||
LT -->|"anything else"| NONE["return None\n→ 401"]
|
||||
|
||||
CUS & EMP & OWN & CON & SUB & VEN --> FOUND{user row\nreturned?}
|
||||
|
||||
FOUND -->|No| NONE2["return None → 401\n'Invalid credentials'"]
|
||||
FOUND -->|Yes| PWD["verify_password(\n request.password,\n user.password_hash\n)"]
|
||||
|
||||
PWD -->|False| NONE3["return None → 401\n'Invalid credentials'\n(same message — never\nreveal which field failed)"]
|
||||
PWD -->|True| RETURN["return User object\n→ create_token_pair()"]
|
||||
|
||||
subgraph SeedRef["Seed credentials reference"]
|
||||
S1["CUSTOMER: username='alice'"]
|
||||
S2["FIELD_AGENT: emp_id='FA001'–'FA005'"]
|
||||
S3["ADMIN: emp_id='ADM01'–'ADM03'"]
|
||||
S4["OWNER: username='justin'/'diana'"]
|
||||
S5["CONTRACTOR: username='mike'"]
|
||||
S6["SUBCONTRACTOR: username='carlos'"]
|
||||
S7["VENDOR: username='abc_supply'"]
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 5 — Refresh Token Rotation + Reuse Attack Detection
|
||||
|
||||
The full refresh flow, including the security branch when a reused refresh token is detected.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant FE as React (interceptors.js)
|
||||
participant API as POST /auth/refresh
|
||||
participant SVC as auth_service.py
|
||||
participant SEC as security.py
|
||||
participant DB as users table
|
||||
|
||||
Note over FE: Received 401 on any request
|
||||
Note over FE: isRefreshing flag set to true
|
||||
Note over FE: Other 401s queued in failedQueue
|
||||
|
||||
FE->>API: POST /auth/refresh\nCookie: refresh_token=<jwt>
|
||||
|
||||
API->>SEC: decode_token(refresh_token)
|
||||
|
||||
alt Token missing or expired
|
||||
SEC-->>API: JWTError
|
||||
API-->>FE: 401 "Invalid or expired refresh token"
|
||||
FE->>FE: dispatch('auth:logout-required')
|
||||
FE->>FE: redirect to /login
|
||||
else Token valid
|
||||
SEC-->>API: { sub: user_id, type: "refresh" }
|
||||
API->>DB: SELECT * FROM users WHERE id = user_id
|
||||
|
||||
alt User not found / deactivated / soft-deleted
|
||||
DB-->>API: None
|
||||
API-->>FE: 401
|
||||
else User found
|
||||
DB-->>API: User row (includes refresh_token_hash)
|
||||
|
||||
API->>SEC: verify_token_hash(\n incoming_token,\n user.refresh_token_hash\n)
|
||||
|
||||
alt Hash mismatch ← REUSE ATTACK
|
||||
Note over API,DB: Token was already rotated.\nSomeone is replaying an old token.
|
||||
API->>DB: UPDATE users\nSET refresh_token_hash = NULL\n(revoke ALL sessions)
|
||||
API-->>FE: 401 "Refresh token reuse detected"
|
||||
FE->>FE: dispatch('auth:logout-required')
|
||||
FE->>FE: force logout + redirect /login
|
||||
else Hash matches — normal rotation
|
||||
API->>SEC: create_access_token(user_id, role, legacy_id)
|
||||
API->>SEC: create_refresh_token(user_id)
|
||||
SEC-->>API: new_access, new_refresh
|
||||
API->>SEC: hash_token(new_refresh)
|
||||
SEC-->>API: new_hash
|
||||
API->>DB: UPDATE users\nSET refresh_token_hash = new_hash\n last_login_at = NOW()
|
||||
API-->>FE: 200 Set-Cookie: access_token=new\nSet-Cookie: refresh_token=new
|
||||
Note over FE: processQueue(null) — release all queued requests
|
||||
FE->>FE: Retry original request(s)
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 6 — Cookie Security Configuration
|
||||
|
||||
How the two cookies differ in path, scope, and lifetime, and why.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph AccessCookie["access_token Cookie"]
|
||||
AC1["key: 'access_token'"]
|
||||
AC2["httponly: True\n← JS cannot read it"]
|
||||
AC3["secure: True\n← HTTPS only"]
|
||||
AC4["samesite: 'strict'\n← blocks CSRF"]
|
||||
AC5["max_age: 900s (15 min)"]
|
||||
AC6["path: '/'\n← sent with ALL requests to backend"]
|
||||
AC1 --> AC2 --> AC3 --> AC4 --> AC5 --> AC6
|
||||
end
|
||||
|
||||
subgraph RefreshCookie["refresh_token Cookie"]
|
||||
RC1["key: 'refresh_token'"]
|
||||
RC2["httponly: True"]
|
||||
RC3["secure: True"]
|
||||
RC4["samesite: 'strict'"]
|
||||
RC5["max_age: 604800s (7 days)"]
|
||||
RC6["path: '/api/v1/auth'\n← only sent to /auth/* endpoints\nnot leaked with every API call"]
|
||||
RC1 --> RC2 --> RC3 --> RC4 --> RC5 --> RC6
|
||||
end
|
||||
|
||||
subgraph XSSProtection["Why httpOnly?"]
|
||||
XSS1["XSS attack injects script:\ndocument.cookie → blocked\nlocalStorage.getItem() → blocked\nfetch with credentials → blocked by samesite"]
|
||||
end
|
||||
|
||||
subgraph CSRFProtection["Why samesite=strict?"]
|
||||
CSRF1["Attacker tricks victim\ninto clicking evil.com link\nwhich sends request to api.lynkeduppro.com\nsamesite=strict: cookie NOT attached\nto cross-site navigation"]
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 7 — Permission Matrix Decision Flow
|
||||
|
||||
How `has_permission()` is used inside service functions (not routers) for fine-grained access control.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph RolePermTable["ROLE_PERMISSIONS — Full Matrix"]
|
||||
direction LR
|
||||
HDR["Permission → Role grants"]
|
||||
|
||||
VP["VIEW_ALL_PROJECTS\n→ OWNER ✅ ADMIN ✅"]
|
||||
VPE["VIEW_ALL_PERSONNEL\n→ OWNER ✅ ADMIN ✅"]
|
||||
VF["VIEW_FINANCIALS\n→ OWNER ✅ ADMIN ✅"]
|
||||
VS["VIEW_SENSITIVE_DATA\n→ OWNER ✅ only"]
|
||||
MU["MANAGE_USERS\n→ OWNER ✅ ADMIN ✅"]
|
||||
AD["APPROVE_DOCUMENTS\n→ OWNER ✅ ADMIN ✅"]
|
||||
VAP["VIEW_ASSIGNED_PROJECTS\n→ OWNER ✅ ADMIN ✅ CONTRACTOR ✅ SUBCONTRACTOR ✅"]
|
||||
UD["UPLOAD_DOCUMENTS\n→ OWNER ✅ ADMIN ✅ CONTRACTOR ✅ VENDOR ✅"]
|
||||
SI["SUBMIT_INVOICES\n→ OWNER ✅ ADMIN ✅ CONTRACTOR ✅ VENDOR ✅"]
|
||||
MC["MANAGE_OWN_CREW\n→ CONTRACTOR ✅ only"]
|
||||
VAT["VIEW_ASSIGNED_TASKS\n→ OWNER ✅ ADMIN ✅ CONTRACTOR ✅ SUBCONTRACTOR ✅"]
|
||||
UTS["UPDATE_TASK_STATUS\n→ OWNER ✅ ADMIN ✅ CONTRACTOR ✅ SUBCONTRACTOR ✅"]
|
||||
end
|
||||
|
||||
subgraph Usage["Service Layer Usage Pattern"]
|
||||
SVC["service function receives\ncurrent_user: User"]
|
||||
CHECK["has_permission(\n current_user.role,\n 'VIEW_SENSITIVE_DATA'\n)"]
|
||||
GRANT["Return full data\nincluding SSN, EIN,\nbank accounts"]
|
||||
DENY["Return masked data\nmask_ssn(user.ssn)\nmask_ein(user.ein)\nmask_bank_account(user.bank)"]
|
||||
|
||||
SVC --> CHECK
|
||||
CHECK -->|True — OWNER only| GRANT
|
||||
CHECK -->|False — all other roles| DENY
|
||||
end
|
||||
|
||||
subgraph RouterVsService["Router vs Service Responsibility"]
|
||||
ROUTER_CHECK["Router:\nrequire_role('ADMIN','OWNER')\n← coarse: is this role allowed at all?"]
|
||||
SERVICE_CHECK["Service:\nhas_permission(role, 'VIEW_FINANCIALS')\n← fine: which fields/operations?"]
|
||||
ROUTER_CHECK -->|"if pass"| SERVICE_CHECK
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 8 — Sensitive Data Masking Flow
|
||||
|
||||
When `mask.py` is invoked per endpoint and per role.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
REQ["GET /users/{id}\nor GET /users (list)"]
|
||||
AUTH["get_current_user() → User\nrequire_role('ADMIN','OWNER','CONTRACTOR',..."]
|
||||
|
||||
AUTH --> ROLE{current_user.role?}
|
||||
|
||||
ROLE -->|OWNER| FULL["Return FULL data\nssn: '123-45-6789'\nein: '12-3456789'\nbank_account: '9876543210'"]
|
||||
|
||||
ROLE -->|ADMIN\nCONTRACTOR\nFIELD_AGENT\nVENDOR\nSUBCONTRACTOR| MASKED["Apply mask.py\nssn → mask_ssn() → '***-**-6789'\nein → mask_ein() → '**-***6789'\nbank → mask_bank_account() → '******3210'"]
|
||||
|
||||
ROLE -->|CUSTOMER| OWN_ONLY["CUSTOMER sees only\ntheir own record\nwith masked sensitive fields"]
|
||||
|
||||
subgraph MaskFunctions["mask.py — Functions"]
|
||||
M1["mask_ssn('123-45-6789')\n→ '***-**-6789'\nlast 4 digits visible"]
|
||||
M2["mask_bank_account('9876543210')\n→ '******3210'\nlast 4 digits visible"]
|
||||
M3["mask_ein('12-3456789')\n→ '**-***6789'\nlast 4 digits visible"]
|
||||
end
|
||||
|
||||
MASKED --> MaskFunctions
|
||||
OWN_ONLY --> MaskFunctions
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 9 — `get_current_user()` Dependency Resolution
|
||||
|
||||
The exact path FastAPI takes to resolve the `get_current_user` dependency on every protected request.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
RT["Protected route handler called\nDepends(get_current_user)"]
|
||||
|
||||
RT --> COOKIE["Extract cookie:\naccess_token: str | None = Cookie(default=None)"]
|
||||
|
||||
COOKIE --> HAS{access_token\npresent?}
|
||||
HAS -->|No| E401A["raise HTTPException 401\n'Not authenticated'"]
|
||||
|
||||
HAS -->|Yes| DECODE["decode_token(access_token)\njwt.decode(token, SECRET_KEY, algorithms)"]
|
||||
|
||||
DECODE --> VALID{Valid\nsignature?}
|
||||
VALID -->|No / tampered| E401B["raise HTTPException 401\n'Not authenticated'"]
|
||||
|
||||
VALID -->|Yes| EXPIRED{Token\nexpired?}
|
||||
EXPIRED -->|Yes| E401C["raise HTTPException 401\n'Not authenticated'\n→ FE interceptor triggers /auth/refresh"]
|
||||
|
||||
EXPIRED -->|No| TYPE{"payload['type']\n== 'access'?"}
|
||||
TYPE -->|No (refresh token sent by mistake)| E401D["raise HTTPException 401"]
|
||||
|
||||
TYPE -->|Yes| SUB["user_id = payload['sub']\nrole = payload['role']"]
|
||||
|
||||
SUB --> DB["db.get(User, user_id)"]
|
||||
DB --> EXISTS{User found?\ndeleted_at IS NULL?\nis_active = True?}
|
||||
|
||||
EXISTS -->|No| E401E["raise HTTPException 401\n'Not authenticated'"]
|
||||
EXISTS -->|Yes| INJECT["return User object\n→ injected as current_user\ninto route handler"]
|
||||
|
||||
INJECT --> ROLE_CHECK["require_role() checker:\nif current_user.role not in allowed_roles\n→ 403 FORBIDDEN\nelse → proceed to handler"]
|
||||
```
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
# LLD — Database Schema (B2)
|
||||
|
||||
**Scope:** PostgreSQL schema — entity relationships, state machines, query patterns, data lifecycle
|
||||
**Companion doc:** `docs/backend/02_database_schema.md`
|
||||
|
||||
---
|
||||
|
||||
## Diagram 1 — Core Entity Relationship Diagram
|
||||
|
||||
All 16 tables and their foreign-key relationships. Key fields shown per entity.
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
USERS {
|
||||
uuid id PK
|
||||
varchar legacy_id
|
||||
varchar email
|
||||
varchar emp_id
|
||||
varchar username
|
||||
varchar password_hash
|
||||
varchar full_name
|
||||
user_role role
|
||||
int xp
|
||||
int streak_days
|
||||
jsonb achievements
|
||||
timestamptz deleted_at
|
||||
}
|
||||
|
||||
PROPERTIES {
|
||||
serial id PK
|
||||
varchar property_id
|
||||
varchar address
|
||||
decimal latitude
|
||||
decimal longitude
|
||||
jsonb polygon
|
||||
property_type property_type
|
||||
canvassing_status canvassing_status
|
||||
int estimated_market_value
|
||||
uuid assigned_agent_id FK
|
||||
bool pending_signature
|
||||
int proposal_value
|
||||
bool currently_rented
|
||||
timestamptz deleted_at
|
||||
}
|
||||
|
||||
PROPERTY_PHOTOS {
|
||||
serial id PK
|
||||
int property_id FK
|
||||
varchar url
|
||||
varchar caption
|
||||
smallint sort_order
|
||||
}
|
||||
|
||||
MEETINGS {
|
||||
uuid id PK
|
||||
varchar legacy_id
|
||||
uuid agent_id FK
|
||||
uuid customer_id FK
|
||||
int property_id FK
|
||||
date meeting_date
|
||||
time meeting_time
|
||||
meeting_status status
|
||||
int deal_value
|
||||
text notes
|
||||
timestamptz deleted_at
|
||||
}
|
||||
|
||||
MEETING_CHANGE_REQUESTS {
|
||||
uuid id PK
|
||||
uuid meeting_id FK
|
||||
uuid requested_by FK
|
||||
uuid reviewed_by FK
|
||||
date proposed_date
|
||||
change_request_status status
|
||||
}
|
||||
|
||||
PROJECTS {
|
||||
uuid id PK
|
||||
uuid owner_id FK
|
||||
int property_id FK
|
||||
varchar title
|
||||
project_status status
|
||||
smallint health_score
|
||||
int approved_budget_cents
|
||||
int actual_cost_cents
|
||||
smallint completion_pct
|
||||
timestamptz deleted_at
|
||||
}
|
||||
|
||||
PROJECT_TASKS {
|
||||
uuid id PK
|
||||
uuid project_id FK
|
||||
uuid assigned_to FK
|
||||
varchar title
|
||||
task_status status
|
||||
task_priority priority
|
||||
date due_date
|
||||
}
|
||||
|
||||
CHANGE_ORDERS {
|
||||
uuid id PK
|
||||
uuid project_id FK
|
||||
uuid requested_by FK
|
||||
uuid approved_by FK
|
||||
varchar title
|
||||
int cost_impact_cents
|
||||
change_order_status status
|
||||
}
|
||||
|
||||
VENDORS {
|
||||
uuid id PK
|
||||
varchar company_name
|
||||
varchar trade_type
|
||||
vendor_status status
|
||||
decimal on_time_delivery_rate
|
||||
date coi_expiry_date
|
||||
bool is_compliant
|
||||
int total_spend_cents
|
||||
timestamptz deleted_at
|
||||
}
|
||||
|
||||
VENDOR_COMPLIANCE_DOCS {
|
||||
uuid id PK
|
||||
uuid vendor_id FK
|
||||
compliance_doc_type doc_type
|
||||
compliance_doc_status status
|
||||
date expiry_date
|
||||
varchar file_url
|
||||
uuid uploaded_by FK
|
||||
}
|
||||
|
||||
VENDOR_ORDERS {
|
||||
uuid id PK
|
||||
uuid vendor_id FK
|
||||
uuid project_id FK
|
||||
varchar order_number
|
||||
int total_cents
|
||||
order_status status
|
||||
date expected_date
|
||||
date delivered_date
|
||||
timestamptz deleted_at
|
||||
}
|
||||
|
||||
INVOICES {
|
||||
uuid id PK
|
||||
varchar invoice_number
|
||||
invoice_type invoice_type
|
||||
uuid issued_by FK
|
||||
uuid billed_to_user FK
|
||||
uuid vendor_id FK
|
||||
uuid project_id FK
|
||||
int total_cents
|
||||
int amount_paid_cents
|
||||
int balance_cents
|
||||
invoice_status status
|
||||
date due_date
|
||||
uuid approved_by FK
|
||||
timestamptz deleted_at
|
||||
}
|
||||
|
||||
DOCUMENTS {
|
||||
uuid id PK
|
||||
varchar title
|
||||
document_category category
|
||||
document_review_status review_status
|
||||
uuid project_id FK
|
||||
uuid vendor_id FK
|
||||
uuid uploaded_by FK
|
||||
varchar file_url
|
||||
date expiry_date
|
||||
timestamptz deleted_at
|
||||
}
|
||||
|
||||
SALES_HISTORY {
|
||||
uuid id PK
|
||||
uuid agent_id FK
|
||||
int property_id FK
|
||||
uuid meeting_id FK
|
||||
date closed_date
|
||||
int amount_cents
|
||||
deal_status status
|
||||
}
|
||||
|
||||
NOTIFICATIONS {
|
||||
uuid id PK
|
||||
uuid user_id FK
|
||||
notification_type type
|
||||
varchar title
|
||||
bool is_read
|
||||
varchar deep_link
|
||||
jsonb metadata
|
||||
}
|
||||
|
||||
AUDIT_LOGS {
|
||||
bigserial id PK
|
||||
uuid actor_id FK
|
||||
varchar action
|
||||
varchar resource_type
|
||||
varchar resource_id
|
||||
jsonb old_value
|
||||
jsonb new_value
|
||||
inet ip_address
|
||||
}
|
||||
|
||||
USERS ||--o{ PROPERTIES : "assigned_agent_id"
|
||||
USERS ||--o{ MEETINGS : "agent_id"
|
||||
USERS ||--o{ MEETINGS : "customer_id"
|
||||
USERS ||--o{ PROJECTS : "owner_id"
|
||||
USERS ||--o{ PROJECT_TASKS : "assigned_to"
|
||||
USERS ||--o{ CHANGE_ORDERS : "requested_by"
|
||||
USERS ||--o{ INVOICES : "issued_by"
|
||||
USERS ||--o{ INVOICES : "billed_to_user"
|
||||
USERS ||--o{ SALES_HISTORY : "agent_id"
|
||||
USERS ||--o{ NOTIFICATIONS : "user_id"
|
||||
USERS ||--o{ AUDIT_LOGS : "actor_id"
|
||||
USERS ||--o{ VENDOR_COMPLIANCE_DOCS : "uploaded_by"
|
||||
|
||||
PROPERTIES ||--o{ PROPERTY_PHOTOS : "property_id"
|
||||
PROPERTIES ||--o{ MEETINGS : "property_id"
|
||||
PROPERTIES ||--o{ PROJECTS : "property_id"
|
||||
PROPERTIES ||--o{ SALES_HISTORY : "property_id"
|
||||
|
||||
MEETINGS ||--o{ MEETING_CHANGE_REQUESTS : "meeting_id"
|
||||
MEETINGS ||--o{ SALES_HISTORY : "meeting_id"
|
||||
|
||||
PROJECTS ||--o{ PROJECT_TASKS : "project_id"
|
||||
PROJECTS ||--o{ CHANGE_ORDERS : "project_id"
|
||||
PROJECTS ||--o{ VENDOR_ORDERS : "project_id"
|
||||
PROJECTS ||--o{ INVOICES : "project_id"
|
||||
PROJECTS ||--o{ DOCUMENTS : "project_id"
|
||||
|
||||
VENDORS ||--o{ VENDOR_COMPLIANCE_DOCS : "vendor_id"
|
||||
VENDORS ||--o{ VENDOR_ORDERS : "vendor_id"
|
||||
VENDORS ||--o{ INVOICES : "vendor_id"
|
||||
VENDORS ||--o{ DOCUMENTS : "vendor_id"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 2 — Meeting Status State Machine
|
||||
|
||||
A meeting moves through these states. Only ADMIN/OWNER can force-advance to any state. FIELD_AGENTs can only request changes.
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Scheduled : POST /meetings\n(ADMIN creates)
|
||||
|
||||
Scheduled --> Rescheduled : PATCH status=Rescheduled\n(ADMIN approves change request)
|
||||
Rescheduled --> Scheduled : PATCH status=Scheduled\n(new date confirmed)
|
||||
|
||||
Scheduled --> InProgress : PATCH status=In Progress\n(agent marks on-site)
|
||||
Rescheduled --> InProgress : PATCH status=In Progress
|
||||
|
||||
InProgress --> Completed : PATCH status=Completed\n+ outcome + notes
|
||||
InProgress --> Cancelled : PATCH status=Cancelled\n+ reason
|
||||
|
||||
Completed --> Converted : PATCH status=Converted\n+ deal_value (closes deal)
|
||||
|
||||
Scheduled --> Cancelled : PATCH status=Cancelled
|
||||
Rescheduled --> Cancelled : PATCH status=Cancelled
|
||||
|
||||
Converted --> [*] : Terminal state\n→ writes to sales_history
|
||||
Cancelled --> [*] : Terminal state
|
||||
|
||||
note right of Scheduled
|
||||
FIELD_AGENT submits
|
||||
MeetingChangeRequest
|
||||
(cannot directly edit)
|
||||
end note
|
||||
|
||||
note right of Converted
|
||||
Triggers:
|
||||
sales_history INSERT
|
||||
notification to ADMIN
|
||||
end note
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 3 — Invoice Status State Machine
|
||||
|
||||
Invoices move through a lifecycle depending on payments, approvals, and time-based triggers.
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Draft : POST /invoices\n(ADMIN / OWNER creates)
|
||||
|
||||
Draft --> Sent : PATCH status=Sent\n(email triggered to recipient)
|
||||
Draft --> Void : PATCH status=Void\n(cancel before sending)
|
||||
|
||||
Sent --> Viewed : Auto on first\nrecipient GET request
|
||||
|
||||
Viewed --> Partial : PATCH\namount_paid_cents > 0\nbut < total_cents
|
||||
Viewed --> Paid : PATCH\namount_paid_cents = total_cents
|
||||
|
||||
Partial --> Paid : PATCH\namount_paid_cents = total_cents
|
||||
Partial --> Overdue : Scheduler trigger\ndue_date < TODAY
|
||||
|
||||
Sent --> Overdue : Scheduler trigger\ndue_date < TODAY
|
||||
|
||||
Overdue --> Paid : PATCH\namount_paid_cents = total_cents
|
||||
|
||||
Viewed --> Disputed : PATCH status=Disputed\n+ dispute_reason
|
||||
Partial --> Disputed : PATCH status=Disputed
|
||||
Overdue --> Disputed : PATCH status=Disputed
|
||||
|
||||
Disputed --> Paid : OWNER resolves dispute
|
||||
Disputed --> Void : OWNER voids invoice
|
||||
|
||||
Paid --> [*] : Terminal state
|
||||
Void --> [*] : Terminal state
|
||||
|
||||
note right of Overdue
|
||||
Triggers:
|
||||
notification to OWNER + ADMIN
|
||||
SendGrid email to debtor
|
||||
end note
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 4 — Project Status State Machine
|
||||
|
||||
Projects managed through the Owner's Box.
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> active : POST /projects\n(OWNER creates)
|
||||
|
||||
active --> completed : PATCH status=completed\nall tasks done\nfinal invoice paid
|
||||
|
||||
active --> delayed : Auto-trigger:\ntarget_end_date passed\ncompletion_pct < 100
|
||||
|
||||
active --> on_hold : PATCH status=on_hold\n+ reason (OWNER decision)
|
||||
|
||||
on_hold --> active : PATCH status=active\n(OWNER resumes)
|
||||
|
||||
active --> disputed : PATCH status=disputed\n+ dispute details\n(OWNER or CONTRACTOR)
|
||||
|
||||
disputed --> active : Dispute resolved\n(OWNER approves)
|
||||
disputed --> cancelled : Escalated dispute\n(OWNER decision)
|
||||
|
||||
active --> cancelled : PATCH status=cancelled\n(OWNER decision)
|
||||
|
||||
delayed --> active : PATCH status=active\n(new timeline set)
|
||||
delayed --> cancelled : PATCH status=cancelled
|
||||
|
||||
completed --> [*] : Terminal state
|
||||
cancelled --> [*] : Terminal state
|
||||
|
||||
note right of delayed
|
||||
Triggers:
|
||||
notification to OWNER
|
||||
health_score recalculated
|
||||
end note
|
||||
|
||||
note right of disputed
|
||||
Triggers:
|
||||
notification to CONTRACTOR
|
||||
change_orders locked
|
||||
end note
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 5 — Vendor Compliance Status Machine
|
||||
|
||||
Compliance documents transition based on expiry dates. A scheduler runs daily to check all docs.
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Active : Document uploaded\n(expiry_date in future)
|
||||
|
||||
Active --> ExpiringSoon : Scheduler:\nexpiry_date ≤ TODAY + 30 days
|
||||
Active --> Expired : Scheduler:\nexpiry_date < TODAY
|
||||
Active --> Missing : Manual flag\nor doc deleted
|
||||
|
||||
ExpiringSoon --> Active : New document uploaded\nwith future expiry_date
|
||||
ExpiringSoon --> Expired : Scheduler:\nexpiry_date < TODAY
|
||||
|
||||
Expired --> Active : Replacement doc uploaded
|
||||
|
||||
Missing --> Active : Doc uploaded
|
||||
|
||||
note right of ExpiringSoon
|
||||
Triggers at 30, 14, 7 days:
|
||||
notification to OWNER
|
||||
email to vendor contact
|
||||
vendor.is_compliant = false
|
||||
end note
|
||||
|
||||
note right of Expired
|
||||
Triggers immediately:
|
||||
urgent notification to OWNER
|
||||
vendor.is_compliant = false
|
||||
vendor orders may be blocked
|
||||
end note
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 6 — Soft Delete Pattern
|
||||
|
||||
How "deletion" works across all tables. No row is ever hard-deleted in production.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
REQ["DELETE /properties/42\nor\nDELETE /vendors/uuid"] --> AUTH_CHECK["Auth: require_role\nADMIN or OWNER"]
|
||||
|
||||
AUTH_CHECK --> SOFT_DEL["UPDATE table\nSET deleted_at = NOW()\nWHERE id = ?"]
|
||||
|
||||
SOFT_DEL --> AUDIT["INSERT audit_logs\nactor_id, action=resource.deleted\nold_value = full row snapshot"]
|
||||
|
||||
SOFT_DEL --> NOTIFY["Trigger dependent cleanup:\n• Unassign agent from properties\n• Cancel pending meetings\n• Flag open invoices"]
|
||||
|
||||
subgraph AllQueries["All Application Queries"]
|
||||
Q1["SELECT * FROM properties\nWHERE deleted_at IS NULL"]
|
||||
Q2["SELECT * FROM meetings\nWHERE deleted_at IS NULL"]
|
||||
Q3["SELECT * FROM vendors\nWHERE deleted_at IS NULL"]
|
||||
end
|
||||
|
||||
SOFT_DEL -.->|"row hidden from"| AllQueries
|
||||
|
||||
subgraph AdminOnly["Admin / Recovery Queries"]
|
||||
Q_ADMIN["SELECT * FROM properties\nWHERE deleted_at IS NOT NULL\n(recovery / audit only)"]
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 7 — Audit Log Write Pattern
|
||||
|
||||
Which operations write to `audit_logs` and what data is captured.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Triggers["Write Operations That Trigger Audit Log"]
|
||||
P1["Property:\nassign_agent\nchange_status\npending_signature"]
|
||||
P2["Meeting:\nstatus change\nchange_request approved"]
|
||||
P3["Invoice:\ncreated\napproved\nvoided"]
|
||||
P4["User:\nrole changed\ndeactivated"]
|
||||
P5["Project:\ncreated\nstatus changed\nchange_order approved"]
|
||||
P6["Chatbot:\nquery with sensitive role context"]
|
||||
end
|
||||
|
||||
subgraph AuditLog["audit_logs INSERT"]
|
||||
AL["{\n actor_id: user.id,\n action: 'property.agent_assigned',\n resource_type: 'property',\n resource_id: '42',\n old_value: { assigned_agent_id: null },\n new_value: { assigned_agent_id: 'uuid' },\n ip_address: '1.2.3.4',\n created_at: NOW()\n}"]
|
||||
end
|
||||
|
||||
P1 & P2 & P3 & P4 & P5 & P6 --> AL
|
||||
|
||||
AL --> QUERY["OWNER / ADMIN\nGET /audit-logs?resource_type=property\n&resource_id=42"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 8 — Key Query Patterns by Role
|
||||
|
||||
How the ORM service layer scopes queries differently per role.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph PropertiesQuery["GET /properties — Role-Scoped Query"]
|
||||
PQ_START["Request arrives\nwith current_user"]
|
||||
PQ_START --> PQ_ROLE{current_user.role}
|
||||
|
||||
PQ_ROLE -->|"OWNER / ADMIN"| PQ_ALL["SELECT * FROM properties\nWHERE deleted_at IS NULL\n+ any filters"]
|
||||
|
||||
PQ_ROLE -->|"FIELD_AGENT"| PQ_AGENT["SELECT * FROM properties\nWHERE assigned_agent_id = current_user.id\nAND deleted_at IS NULL"]
|
||||
|
||||
PQ_ROLE -->|"CUSTOMER"| PQ_CUS["SELECT * FROM properties\nWHERE property_id = current_user.property_id\nAND deleted_at IS NULL\nLIMIT 1"]
|
||||
end
|
||||
|
||||
subgraph MeetingsQuery["GET /meetings — Role-Scoped Query"]
|
||||
MQ_START["Request arrives"]
|
||||
MQ_START --> MQ_ROLE{current_user.role}
|
||||
|
||||
MQ_ROLE -->|"OWNER / ADMIN"| MQ_ALL["SELECT * FROM meetings\nWHERE deleted_at IS NULL"]
|
||||
|
||||
MQ_ROLE -->|"FIELD_AGENT"| MQ_AGENT["SELECT * FROM meetings\nWHERE agent_id = current_user.id\nAND deleted_at IS NULL"]
|
||||
|
||||
MQ_ROLE -->|"CUSTOMER"| MQ_CUS["SELECT * FROM meetings\nWHERE customer_id = current_user.id\nAND deleted_at IS NULL"]
|
||||
end
|
||||
|
||||
subgraph InvoiceQuery["GET /invoices — Role-Scoped Query"]
|
||||
IQ_START["Request arrives"]
|
||||
IQ_START --> IQ_ROLE{current_user.role}
|
||||
|
||||
IQ_ROLE -->|"OWNER"| IQ_ALL["SELECT * FROM invoices\nWHERE deleted_at IS NULL\n(full financial view)"]
|
||||
|
||||
IQ_ROLE -->|"ADMIN"| IQ_ADM["SELECT * FROM invoices\nWHERE deleted_at IS NULL\nAND invoice_type != 'internal'"]
|
||||
|
||||
IQ_ROLE -->|"CONTRACTOR / SUBCONTRACTOR"| IQ_CON["SELECT * FROM invoices\nWHERE billed_to_user = current_user.id\nOR issued_by = current_user.id"]
|
||||
|
||||
IQ_ROLE -->|"VENDOR"| IQ_VEN["SELECT * FROM invoices\nWHERE vendor_id IN\n (SELECT id FROM vendors\n WHERE contact_user_id = current_user.id)"]
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 9 — Legacy ID Migration Path
|
||||
|
||||
How the `legacy_id` column bridges the mock frontend IDs (`'e1'`, `'own_001'`) to real UUIDs during the transition period.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant FE as React Frontend
|
||||
participant API as FastAPI
|
||||
participant DB as PostgreSQL
|
||||
|
||||
Note over FE,DB: ── Transition Phase (Integration Team replacing mock calls) ──
|
||||
|
||||
FE->>API: GET /users/by-legacy-id/e1
|
||||
API->>DB: SELECT * FROM users WHERE legacy_id = 'e1'
|
||||
DB-->>API: { id: "3f8a...(uuid)", legacy_id: "e1", ... }
|
||||
API-->>FE: User object with real UUID
|
||||
|
||||
Note over FE: Frontend stores UUID in state<br/>All subsequent calls use UUID
|
||||
|
||||
FE->>API: GET /meetings?agent_id=3f8a...(uuid)
|
||||
API->>DB: SELECT * FROM meetings WHERE agent_id = '3f8a...'
|
||||
DB-->>API: Meeting rows
|
||||
API-->>FE: Meetings data
|
||||
|
||||
Note over FE,DB: ── Post-Migration Phase (legacy_id no longer needed) ──
|
||||
Note over DB: legacy_id column remains<br/>for audit trail but<br/>no longer queried by FE
|
||||
```
|
||||
@@ -0,0 +1,353 @@
|
||||
# LLD — I0 Integration Overview
|
||||
|
||||
**Scope:** Frontend API layer architecture, 401 interceptor, mock-to-real migration, error handling, environment config
|
||||
**Companion doc:** `docs/integration/00_integration_overview.md`
|
||||
|
||||
---
|
||||
|
||||
## Diagram 1 — Frontend API Layer Architecture
|
||||
|
||||
How the files in `src/api/` relate to each other, to the React components, and to the backend.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph Components["React Components / Pages"]
|
||||
C1["OwnerSnapshot.jsx"]
|
||||
C2["AdminDashboard.jsx"]
|
||||
C3["AgentDashboard.jsx"]
|
||||
C4["Map.jsx"]
|
||||
C5["VendorDashboard.jsx"]
|
||||
C6["Chatbot.jsx"]
|
||||
end
|
||||
|
||||
subgraph Hooks["Custom Hooks (src/hooks/)"]
|
||||
H1["useProperties()"]
|
||||
H2["useMeetings()"]
|
||||
H3["useUsers()"]
|
||||
H4["useVendors()"]
|
||||
H5["useInvoices()"]
|
||||
end
|
||||
|
||||
subgraph ApiLayer["API Layer (src/api/)"]
|
||||
CLIENT["client.js\naxios.create({\n baseURL: VITE_API_URL,\n withCredentials: true\n})"]
|
||||
INTER["interceptors.js\nResponse interceptor:\n401 → refresh → retry\nfailure → logout event"]
|
||||
AUTH_API["auth.js\nlogin()\nlogout()\ngetMe()"]
|
||||
PROP_API["properties.js\ngetProperties()\ngetProperty()\nassignAgent()"]
|
||||
MEET_API["meetings.js\ngetMeetings()\ncreateMeeting()\nupdateMeeting()"]
|
||||
VEND_API["vendors.js\ngetVendors()\nuploadComplianceDoc()"]
|
||||
INV_API["invoices.js\ngetInvoices()\napproveInvoice()"]
|
||||
CHAT_API["chatbot.js\nsendMessage() ← streaming"]
|
||||
end
|
||||
|
||||
subgraph Config["Config (src/config/)"]
|
||||
ENV["env.js\nAPI_URL\nFEATURE_REAL_API\nGROQ_API_KEY"]
|
||||
end
|
||||
|
||||
subgraph Backend["FastAPI Backend"]
|
||||
BE["/api/v1/*"]
|
||||
end
|
||||
|
||||
C1 & C2 & C3 --> H1 & H2 & H3
|
||||
C4 --> H1
|
||||
C5 --> H4
|
||||
C6 --> CHAT_API
|
||||
|
||||
H1 --> PROP_API
|
||||
H2 --> MEET_API
|
||||
H3 --> AUTH_API
|
||||
H4 --> VEND_API
|
||||
H5 --> INV_API
|
||||
|
||||
AUTH_API & PROP_API & MEET_API & VEND_API & INV_API & CHAT_API --> CLIENT
|
||||
INTER -->|"wraps"| CLIENT
|
||||
CLIENT --> ENV
|
||||
|
||||
CLIENT -->|"HTTP + cookies"| BE
|
||||
|
||||
style CLIENT fill:#1e3a5f,color:#fff
|
||||
style INTER fill:#1e5f3a,color:#fff
|
||||
style ENV fill:#5f3a1e,color:#fff
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 2 — 401 Interceptor State Machine
|
||||
|
||||
The queue-based pattern that handles concurrent requests all expiring at the same time. Only one refresh call is ever made.
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Idle : App initialised\ninterceptors.js imported
|
||||
|
||||
Idle --> RequestInFlight : Any API call made
|
||||
|
||||
RequestInFlight --> Success200 : Response 2xx
|
||||
Success200 --> Idle : Return response to caller
|
||||
|
||||
RequestInFlight --> Error401 : Response 401
|
||||
|
||||
Error401 --> AlreadyRetried : originalRequest._retry == true
|
||||
AlreadyRetried --> PropagateError : Reject — do not loop
|
||||
|
||||
Error401 --> RefreshInProgress : isRefreshing == true\n(another request is already refreshing)
|
||||
RefreshInProgress --> Queued : Push {resolve, reject}\ninto failedQueue
|
||||
|
||||
Error401 --> StartRefresh : isRefreshing == false
|
||||
StartRefresh --> RefreshCall : Set _retry=true\nSet isRefreshing=true\nPOST /auth/refresh
|
||||
|
||||
RefreshCall --> RefreshSuccess : 200 — new cookies set\nby backend
|
||||
RefreshSuccess --> DrainQueue : processQueue(null)\nresume all queued requests
|
||||
DrainQueue --> RetryOriginal : apiClient(originalRequest)
|
||||
RetryOriginal --> Success200
|
||||
|
||||
RefreshCall --> RefreshFailed : 401 — refresh token\ninvalid/expired/reused
|
||||
RefreshFailed --> DrainQueueError : processQueue(error)\nreject all queued requests
|
||||
DrainQueueError --> DispatchLogout : window.dispatchEvent(\n'auth:logout-required'\n)
|
||||
DispatchLogout --> ForceLogout : AuthContext listener\ncalls logout()\nredirects /login
|
||||
ForceLogout --> [*]
|
||||
|
||||
note right of Queued
|
||||
Multiple components race
|
||||
to refresh. First one wins,
|
||||
others wait here.
|
||||
end note
|
||||
|
||||
note right of RefreshFailed
|
||||
Refresh token expired
|
||||
or reuse attack detected.
|
||||
User must log in again.
|
||||
end note
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 3 — Mock-to-Real Migration: Module Lifecycle
|
||||
|
||||
The three phases every integration module (I1–I8) goes through. No module skips phases.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Phase1["Phase 1 — Feature Flagged (Default OFF)"]
|
||||
P1_ENV["VITE_FEATURE_REAL_API=false\nin .env.local"]
|
||||
P1_HOOK["useProperties() hook created\nwith flag branch:\nif (!FEATURE_REAL_API)\n return mockStore data"]
|
||||
P1_TEST["All existing tests pass\nApp behaves identically\nto before integration started"]
|
||||
|
||||
P1_ENV --> P1_HOOK --> P1_TEST
|
||||
end
|
||||
|
||||
subgraph Phase2["Phase 2 — Real API Active"]
|
||||
P2_ENV["VITE_FEATURE_REAL_API=true\nin .env.local"]
|
||||
P2_BACKEND["Backend module running\n(e.g. B3 Properties)"]
|
||||
P2_TEST["QA: real data renders\nloading/error states work\nrole scoping correct"]
|
||||
|
||||
P2_ENV --> P2_BACKEND --> P2_TEST
|
||||
end
|
||||
|
||||
subgraph Phase3["Phase 3 — Mock Removed"]
|
||||
P3_CLEAN["Delete if (!FEATURE_REAL_API)\nbranch from hook"]
|
||||
P3_FLAG["Remove VITE_FEATURE_REAL_API\nfrom env files"]
|
||||
P3_MOCK["MockStoreProvider removal\nonly when ALL I1–I8 complete"]
|
||||
|
||||
P3_CLEAN --> P3_FLAG --> P3_MOCK
|
||||
end
|
||||
|
||||
Phase1 -->|"backend module\nbecomes available"| Phase2
|
||||
Phase2 -->|"QA signed off"| Phase3
|
||||
|
||||
style Phase1 fill:#1e3a5f,color:#fff
|
||||
style Phase2 fill:#1e5f3a,color:#fff
|
||||
style Phase3 fill:#5f1e3a,color:#fff
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 4 — Feature Flag Decision Tree (Inside Each Hook)
|
||||
|
||||
The exact conditional logic inside every `useXxx()` hook created by I1–I8.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
HOOK["useProperties(filters)\nor any useXxx() hook called"]
|
||||
|
||||
HOOK --> FLAG{FEATURE_REAL_API\n== true?}
|
||||
|
||||
FLAG -->|false — mock path| MOCK_STORE["useMockStore()\nRead from MockStoreProvider\nApply filters in JS"]
|
||||
MOCK_STORE --> MOCK_SHAPE["Return mock-shaped object:\n{ data: [...], loading: false, error: null }"]
|
||||
|
||||
FLAG -->|true — real API path| EFFECT["useEffect(() => {\n fetchData()\n return () => { cancelled = true }\n}, [deps])"]
|
||||
|
||||
EFFECT --> LOADING["setLoading(true)\nsetError(null)"]
|
||||
LOADING --> API_CALL["await getProperties(filters)\nsrc/api/properties.js"]
|
||||
|
||||
API_CALL --> OK{"Response\nok?"}
|
||||
OK -->|yes| SET_DATA["setData(result.data)\nsetLoading(false)"]
|
||||
OK -->|no| SET_ERROR["setError(\n err.response?.data?.detail\n ?? 'Failed to load'\n)\nsetLoading(false)"]
|
||||
|
||||
SET_DATA --> REAL_SHAPE["Return real object:\n{ data: [...], loading: false, error: null }"]
|
||||
SET_ERROR --> ERROR_SHAPE["Return error object:\n{ data: [], loading: false, error: 'message' }"]
|
||||
|
||||
subgraph ShapeContract["Shape Contract — Must Match"]
|
||||
MOCK_SHAPE2["Both paths return:\n{ data: T[], loading: boolean, error: string | null }\n\nComponents never know which path is active"]
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 5 — Frontend Request Lifecycle
|
||||
|
||||
The complete path from a user action in a component all the way to the backend and back, including what each layer is responsible for.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor User as 👤 User
|
||||
participant Comp as React Component
|
||||
participant Hook as useXxx() Hook
|
||||
participant ApiFile as src/api/properties.js
|
||||
participant Client as apiClient (client.js)
|
||||
participant Inter as interceptors.js
|
||||
participant BE as FastAPI /api/v1
|
||||
|
||||
User->>Comp: Interacts (navigate / click / filter)
|
||||
Comp->>Hook: useProperties({ status: 'Hot Lead' })
|
||||
Note over Hook: checks FEATURE_REAL_API flag
|
||||
|
||||
Hook->>ApiFile: getProperties({ status: 'Hot Lead' })
|
||||
ApiFile->>Client: apiClient.get('/properties', { params })
|
||||
Note over Client: Attaches withCredentials=true\nbrowser sends httpOnly cookie automatically
|
||||
|
||||
Client->>Inter: Request passes through interceptor
|
||||
Note over Inter: Response interceptor registered\n(not triggered yet — on response)
|
||||
|
||||
Client->>BE: GET /api/v1/properties?status=Hot+Lead\nCookie: access_token=<jwt>
|
||||
|
||||
Note over BE: Middleware: CORS → Rate Limit → Logger\nDependencies: get_current_user() → require_role()\nService: property_service.get_properties(role, filters)
|
||||
|
||||
BE-->>Client: 200 { data: [...], meta: { total, page, page_size } }
|
||||
Client-->>Inter: Response passes through interceptor\n(2xx → pass through unchanged)
|
||||
Inter-->>ApiFile: { data: [...], meta: {...} }
|
||||
|
||||
Note over ApiFile: Optional: transform snake_case → camelCase\nassigned_agent_id → agentId
|
||||
ApiFile-->>Hook: Transformed data object
|
||||
Hook-->>Comp: { data: [...], loading: false, error: null }
|
||||
Comp-->>User: Render updated UI
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 6 — HTTP Error Handling Decision Tree
|
||||
|
||||
How the frontend responds to each HTTP error code the backend can return.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
RES["API response received\nerror.response.status"]
|
||||
|
||||
RES --> S400{400\nBad Request}
|
||||
S400 --> H400["Validation error\n(bad request body)\nShow field-level errors\nfrom error.response.data.detail"]
|
||||
|
||||
RES --> S401{401\nUnauthorized}
|
||||
S401 --> RETRY{originalRequest\n._retry?}
|
||||
RETRY -->|false — first 401| REFRESH["Trigger token refresh\nPOST /auth/refresh\n(interceptors.js queue)"]
|
||||
REFRESH --> RFAIL{refresh\nsucceeded?}
|
||||
RFAIL -->|yes| RETR["Retry original request\nUser sees nothing"]
|
||||
RFAIL -->|no| LOGOUT["dispatch('auth:logout-required')\nAuthContext.logout()\nRedirect /login"]
|
||||
RETRY -->|true — already retried| LOGOUT
|
||||
|
||||
RES --> S403{403\nForbidden}
|
||||
S403 --> H403["User authenticated but wrong role\nShow 'Access denied' toast\nor redirect to own portal\n(do not retry)"]
|
||||
|
||||
RES --> S404{404\nNot Found}
|
||||
S404 --> H404["Resource does not exist\nShow empty state or\n'Not found' message"]
|
||||
|
||||
RES --> S422{422\nUnprocessable Entity}
|
||||
S422 --> H422["FastAPI schema validation failed\nShow detail array from\nerror.response.data.detail[]\nHighlight failing fields"]
|
||||
|
||||
RES --> S429{429\nRate Limited}
|
||||
S429 --> H429["Show toast 'Too many requests'\nBackoff — do not auto-retry\nUser must wait"]
|
||||
|
||||
RES --> S500{500\nServer Error}
|
||||
S500 --> H500["Show generic error toast\n'Something went wrong'\nLog error.response.data to console\n(dev only — never to user)"]
|
||||
|
||||
subgraph ErrorShape["All error bodies follow this shape"]
|
||||
ES["{\n detail: 'Human-readable message',\n code: 'MACHINE_CODE'\n}\n\n422 uses FastAPI default:\n{\n detail: [\n { loc: ['body','field'], msg: '...', type: '...' }\n ]\n}"]
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 7 — Frontend Environment Variable Flow
|
||||
|
||||
How `VITE_API_URL` travels from `.env.local` through the build system into every API call.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph Files["Source Files"]
|
||||
ENV_LOCAL[".env.local\n(never committed)\nVITE_API_URL=http://localhost:8000/api/v1"]
|
||||
ENV_PROD[".env.production\n(Vercel env vars)\nVITE_API_URL=https://api.lynkeduppro.com/api/v1"]
|
||||
ENV_EXAMPLE[".env.example\n(committed template)\nVITE_API_URL="]
|
||||
end
|
||||
|
||||
subgraph Build["Vite Build Process"]
|
||||
VITE["Vite reads .env.local\nDuring vite dev / vite build\nOnly VITE_ prefix exposed to bundle\nAll others stripped"]
|
||||
end
|
||||
|
||||
subgraph Runtime["Browser Runtime"]
|
||||
META["import.meta.env.VITE_API_URL\n(string | undefined)\nbaked into JS bundle at build time"]
|
||||
end
|
||||
|
||||
subgraph Config["src/config/env.js"]
|
||||
EXPORT["export const API_URL =\n import.meta.env.VITE_API_URL\n ?? 'http://localhost:8000/api/v1'\n\nexport const FEATURE_REAL_API =\n import.meta.env.VITE_FEATURE_REAL_API === 'true'\n\nexport const GROQ_API_KEY =\n import.meta.env.VITE_GROQ_API_KEY"]
|
||||
end
|
||||
|
||||
subgraph ApiClient["src/api/client.js"]
|
||||
CLIENT["axios.create({\n baseURL: API_URL,\n withCredentials: true\n})"]
|
||||
end
|
||||
|
||||
ENV_LOCAL -->|"vite dev"| VITE
|
||||
ENV_PROD -->|"vite build (Vercel)"| VITE
|
||||
ENV_EXAMPLE -.->|"template for"| ENV_LOCAL
|
||||
|
||||
VITE --> META
|
||||
META --> EXPORT
|
||||
EXPORT -->|"API_URL"| CLIENT
|
||||
EXPORT -->|"FEATURE_REAL_API"| HOOKS["All useXxx() hooks\nmock vs real branch"]
|
||||
EXPORT -->|"GROQ_API_KEY"| CHATBOT["Chatbot.jsx\n(pre-integration direct calls)"]
|
||||
|
||||
subgraph Warning["⚠️ Security Note"]
|
||||
W1["VITE_ vars are PUBLIC\nThey appear in the JS bundle\nNever put SECRET_KEY, DB passwords,\nor admin tokens here\nThose belong in backend .env only"]
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 8 — Axios `withCredentials` + CORS Handshake
|
||||
|
||||
Why `withCredentials: true` is required and what happens at the browser + server level.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Browser as Browser
|
||||
participant Axios as Axios (withCredentials: true)
|
||||
participant CORS as FastAPI CORS Middleware
|
||||
participant Handler as Route Handler
|
||||
|
||||
Note over Browser,Handler: Preflight (first cross-origin request)
|
||||
Browser->>CORS: OPTIONS /api/v1/properties\nOrigin: https://lynkeduppro.vercel.app\nAccess-Control-Request-Method: GET\nAccess-Control-Request-Headers: Content-Type
|
||||
|
||||
Note over CORS: Check ALLOWED_ORIGINS list:\n['https://lynkeduppro.vercel.app',\n 'http://localhost:5173']
|
||||
CORS-->>Browser: 200\nAccess-Control-Allow-Origin: https://lynkeduppro.vercel.app\nAccess-Control-Allow-Credentials: true ← REQUIRED\nAccess-Control-Allow-Methods: GET, POST, PATCH, DELETE\nAccess-Control-Allow-Headers: Content-Type
|
||||
|
||||
Note over Browser,Handler: Actual request (cookies attached automatically)
|
||||
Axios->>CORS: GET /api/v1/properties\nOrigin: https://lynkeduppro.vercel.app\nCookie: access_token=<jwt> ← browser attaches because withCredentials=true
|
||||
|
||||
CORS->>Handler: Pass through (origin allowed)
|
||||
Handler-->>CORS: 200 { data: [...] }
|
||||
CORS-->>Browser: 200 { data: [...] }\nAccess-Control-Allow-Origin: https://lynkeduppro.vercel.app\nAccess-Control-Allow-Credentials: true
|
||||
|
||||
Note over Browser: Without withCredentials: true\nthe Cookie header would not be sent\neven for same-site API on different port (localhost:8000)
|
||||
|
||||
subgraph BackendConfig["Backend: main.py CORS config"]
|
||||
BC["CORSMiddleware(\n allow_origins=settings.backend_cors_origins,\n allow_credentials=True, ← REQUIRED for cookies\n allow_methods=['*'],\n allow_headers=['*'],\n)"]
|
||||
end
|
||||
```
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
# LLD — I1 Auth Integration
|
||||
|
||||
**Scope:** Replacing mock AuthContext with real JWT flow — state machine, session restore, login/logout sequences, ProtectedRoute guard
|
||||
**Companion doc:** `docs/integration/01_auth_integration.md`
|
||||
|
||||
---
|
||||
|
||||
## Diagram 1 — Before vs After: AuthContext Architecture
|
||||
|
||||
A side-by-side view of what changes and what stays the same.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Before["BEFORE — Mock AuthContext"]
|
||||
direction TB
|
||||
B_MOCK["mockStore.users\n(17 in-memory users)"]
|
||||
B_LOGIN["login(id, pass, type)\nSynchronous\nusers.find() in JS"]
|
||||
B_STATE["useState:\nuser = null\nisAuthenticated = false\n(no isLoading)\n(no session restore)"]
|
||||
B_LOGOUT["logout()\nSynchronous\nsetUser(null)"]
|
||||
B_EXPORT["useAuth() exports:\nuser, isAuthenticated\nlogin, logout"]
|
||||
|
||||
B_MOCK --> B_LOGIN
|
||||
B_LOGIN --> B_STATE
|
||||
B_STATE --> B_LOGOUT
|
||||
B_LOGOUT --> B_EXPORT
|
||||
end
|
||||
|
||||
subgraph After["AFTER — Real JWT AuthContext"]
|
||||
direction TB
|
||||
A_API["src/api/auth.js\nloginApi()\ngetMeApi()\nlogoutApi()"]
|
||||
A_LOGIN["login(id, pass, type)\nAsync\nPOST /auth/login"]
|
||||
A_STATE["useState:\nuser = null\nisAuthenticated = false\nisLoading = true ← NEW"]
|
||||
A_MOUNT["useEffect (mount)\nGET /auth/me\n→ session restore"]
|
||||
A_FORCE["useEffect\nwindow 'auth:logout-required'\n← from interceptors.js"]
|
||||
A_LOGOUT["logout()\nAsync\nPOST /auth/logout\nthen setUser(null)"]
|
||||
A_EXPORT["useAuth() exports:\nuser, isAuthenticated\nisLoading ← NEW\nlogin, logout"]
|
||||
|
||||
A_API --> A_LOGIN
|
||||
A_LOGIN --> A_STATE
|
||||
A_STATE --> A_MOUNT
|
||||
A_STATE --> A_FORCE
|
||||
A_STATE --> A_LOGOUT
|
||||
A_LOGOUT --> A_EXPORT
|
||||
end
|
||||
|
||||
Before -->|"I1 migration"| After
|
||||
|
||||
subgraph Unchanged["Components — NO changes needed"]
|
||||
U1["All pages reading\nuseAuth().user\nuseAuth().isAuthenticated"]
|
||||
U2["fillDemo() buttons\nin Login.jsx"]
|
||||
U3["ROLES constant\n(same 7 values)"]
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 2 — AuthContext State Machine
|
||||
|
||||
The three states `AuthProvider` can be in, and the events that cause transitions.
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Loading : AuthProvider mounts\nisLoading = true
|
||||
|
||||
Loading --> Authenticated : GET /auth/me → 200\nOR refresh succeeded\nsetUser(data)\nsetIsAuthenticated(true)\nsetIsLoading(false)
|
||||
|
||||
Loading --> Unauthenticated : GET /auth/me → 401\nAND refresh failed\nOR no cookie present\nsetUser(null)\nsetIsAuthenticated(false)\nsetIsLoading(false)
|
||||
|
||||
Unauthenticated --> Authenticated : login() called\nPOST /auth/login → 200\nsetUser(data.user)\nsetIsAuthenticated(true)
|
||||
|
||||
Authenticated --> Unauthenticated : logout() called\nPOST /auth/logout\nsetUser(null)\nsetIsAuthenticated(false)
|
||||
|
||||
Authenticated --> Unauthenticated : 'auth:logout-required' event\ndispatched by interceptors.js\nwhen refresh token expired/invalid\nsetUser(null)\nsetIsAuthenticated(false)
|
||||
|
||||
note right of Loading
|
||||
ProtectedRoute renders
|
||||
a spinner while in
|
||||
this state — never
|
||||
redirects to /login
|
||||
until Loading exits.
|
||||
end note
|
||||
|
||||
note right of Authenticated
|
||||
user object available.
|
||||
Cookie present in browser.
|
||||
All API calls succeed.
|
||||
end note
|
||||
|
||||
note right of Unauthenticated
|
||||
user = null.
|
||||
No cookies.
|
||||
ProtectedRoute redirects
|
||||
to /login.
|
||||
end note
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 3 — Session Restore on Mount
|
||||
|
||||
The exact sequence when the app loads and finds an existing session cookie.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Browser as Browser
|
||||
participant App as App.jsx (React mount)
|
||||
participant Auth as AuthProvider (useEffect)
|
||||
participant API as GET /auth/me
|
||||
participant Inter as interceptors.js
|
||||
participant BE as FastAPI
|
||||
|
||||
Browser->>App: Page load / refresh
|
||||
App->>Auth: AuthProvider mounts\nisLoading = true
|
||||
|
||||
Auth->>API: getMeApi()\napiClient.get('/auth/me')
|
||||
Note over API: withCredentials=true\nbrowser attaches access_token cookie
|
||||
|
||||
API->>BE: GET /api/v1/auth/me\nCookie: access_token=<jwt>
|
||||
|
||||
alt Access token valid
|
||||
BE-->>API: 200 UserPublic { id, role, full_name, ... }
|
||||
API-->>Auth: data = UserPublic
|
||||
Auth->>Auth: setUser(data)\nsetIsAuthenticated(true)\nsetIsLoading(false)
|
||||
Note over App: ProtectedRoute:\nisLoading=false, isAuthenticated=true\n→ render page
|
||||
else Access token expired (401)
|
||||
BE-->>API: 401
|
||||
API-->>Inter: Response interceptor triggered
|
||||
Inter->>BE: POST /auth/refresh\nCookie: refresh_token=<jwt>
|
||||
alt Refresh succeeds
|
||||
BE-->>Inter: 200 new access_token cookie set
|
||||
Inter->>BE: Retry GET /auth/me (with new cookie)
|
||||
BE-->>Inter: 200 UserPublic
|
||||
Inter-->>Auth: data = UserPublic
|
||||
Auth->>Auth: setUser(data)\nsetIsAuthenticated(true)\nsetIsLoading(false)
|
||||
else Refresh also fails
|
||||
BE-->>Inter: 401
|
||||
Inter->>Inter: dispatch('auth:logout-required')
|
||||
Inter-->>Auth: Promise rejected
|
||||
Auth->>Auth: catch block:\nsetUser(null)\nsetIsAuthenticated(false)\nsetIsLoading(false)
|
||||
Note over App: ProtectedRoute:\nisLoading=false, isAuthenticated=false\n→ Navigate /login
|
||||
end
|
||||
else No cookie at all (first visit / after logout)
|
||||
BE-->>API: 401 (no cookie)
|
||||
Note over Inter: Interceptor checks _retry flag\nalready retried or no refresh cookie\n→ propagates error
|
||||
API-->>Auth: catch block:\nsetUser(null)\nsetIsAuthenticated(false)\nsetIsLoading(false)
|
||||
Note over App: ProtectedRoute → Navigate /login
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 4 — Login Flow (Async)
|
||||
|
||||
The new async login sequence from form submit to page redirect.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor User as 👤 User
|
||||
participant Form as Login.jsx form
|
||||
participant AuthCtx as AuthContext.login()
|
||||
participant AuthAPI as src/api/auth.js loginApi()
|
||||
participant Client as apiClient
|
||||
participant BE as POST /auth/login
|
||||
|
||||
User->>Form: Fill identifier + password\nselect loginType tab\nclick Sign In
|
||||
|
||||
Form->>Form: setIsSubmitting(true)\ndisable button
|
||||
|
||||
Form->>AuthCtx: await login(identifier, password, loginType)
|
||||
AuthCtx->>AuthAPI: loginApi(identifier, password, type)
|
||||
AuthAPI->>Client: apiClient.post('/auth/login',\n{ identifier, password, type })
|
||||
Client->>BE: POST /api/v1/auth/login\n{ identifier, password, type }
|
||||
|
||||
alt Credentials valid
|
||||
BE-->>Client: 200 { user: UserPublic, message: "Login successful" }\nSet-Cookie: access_token=<jwt>; httpOnly\nSet-Cookie: refresh_token=<jwt>; httpOnly; path=/api/v1/auth
|
||||
Client-->>AuthAPI: { data: { user, message } }
|
||||
AuthAPI-->>AuthCtx: data
|
||||
AuthCtx->>AuthCtx: setUser(data.user)\nsetIsAuthenticated(true)
|
||||
AuthCtx->>AuthCtx: toast.success('Welcome back, full_name!')
|
||||
AuthCtx-->>Form: { success: true, role: 'FIELD_AGENT' }
|
||||
Form->>Form: setIsSubmitting(false)
|
||||
Form->>Form: switch(result.role)\nnavigate('/emp/fa/dashboard')
|
||||
|
||||
else Invalid credentials
|
||||
BE-->>Client: 401 { detail: "Invalid credentials" }
|
||||
Client-->>AuthAPI: AxiosError (401)
|
||||
AuthAPI-->>AuthCtx: throws
|
||||
AuthCtx->>AuthCtx: message = error.response.data.detail\ntost.error('Invalid credentials')
|
||||
AuthCtx-->>Form: { success: false, message: "Invalid credentials" }
|
||||
Form->>Form: setIsSubmitting(false)\nsetError(result.message)
|
||||
Form-->>User: Error banner shown
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 5 — Role Redirect Decision Tree (Login.jsx)
|
||||
|
||||
What happens after a successful login response based on `result.role`.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
SUCCESS["login() returns\n{ success: true, role }"]
|
||||
|
||||
SUCCESS --> SW{result.role}
|
||||
|
||||
SW -->|CUSTOMER| R1["navigate('/portal/profile')\n← FIXED from '/' (bug)"]
|
||||
SW -->|OWNER| R2["navigate('/owner/snapshot')"]
|
||||
SW -->|ADMIN| R3["navigate('/admin/dashboard')\n← FIXED from default (bug)"]
|
||||
SW -->|CONTRACTOR| R4["navigate('/contractor/dashboard')"]
|
||||
SW -->|VENDOR| R5["navigate('/vendor/dashboard')"]
|
||||
SW -->|SUBCONTRACTOR| R6["navigate('/subcontractor/dashboard')"]
|
||||
SW -->|default FIELD_AGENT| R7["navigate('/emp/fa/dashboard')"]
|
||||
|
||||
subgraph Bugs["Two redirect bugs fixed by I1"]
|
||||
B1["CUSTOMER: '/' → Landing page\nShould be '/portal/profile'"]
|
||||
B2["ADMIN: fell to default → /emp/fa/dashboard\nShould be '/admin/dashboard'"]
|
||||
end
|
||||
|
||||
subgraph FillDemo["fillDemo() — unchanged"]
|
||||
FD["fillDemo('customer') sets loginType + identifier + password\nForm submits normally → handleLogin runs\nSame redirect logic applies"]
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 6 — Logout Flow
|
||||
|
||||
The new async logout — API call first, state clear regardless of outcome.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor User as 👤 User
|
||||
participant UI as Any component calling logout()
|
||||
participant AuthCtx as AuthContext.logout()
|
||||
participant AuthAPI as src/api/auth.js logoutApi()
|
||||
participant BE as POST /auth/logout
|
||||
|
||||
User->>UI: Click logout button
|
||||
UI->>AuthCtx: await logout()
|
||||
|
||||
AuthCtx->>AuthAPI: logoutApi()
|
||||
AuthAPI->>BE: POST /api/v1/auth/logout\nCookie: access_token=<jwt>
|
||||
|
||||
alt Logout API succeeds
|
||||
BE-->>AuthAPI: 200 { message: "Logged out successfully" }\nSet-Cookie: access_token=; max_age=0 (cleared)\nSet-Cookie: refresh_token=; max_age=0 (cleared)
|
||||
Note over BE: Also sets users.refresh_token_hash = NULL in DB
|
||||
AuthAPI-->>AuthCtx: success
|
||||
else Logout API fails (token already expired)
|
||||
BE-->>AuthAPI: 401
|
||||
AuthAPI-->>AuthCtx: error caught — logged as warning
|
||||
Note over AuthCtx: Still clears local state below
|
||||
end
|
||||
|
||||
AuthCtx->>AuthCtx: finally block:\nsetUser(null)\nsetIsAuthenticated(false)\ntoast.info('Logged out successfully')
|
||||
|
||||
AuthCtx-->>UI: resolved
|
||||
Note over UI: ProtectedRoute detects\nisAuthenticated=false\n→ Navigate to /login
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 7 — Forced Logout Event Flow
|
||||
|
||||
When the refresh token expires or a reuse attack is detected, the interceptor forces a logout without any user action.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Comp as Any React Component
|
||||
participant Client as apiClient
|
||||
participant Inter as interceptors.js
|
||||
participant BE_API as Any API endpoint
|
||||
participant BE_REF as POST /auth/refresh
|
||||
participant Auth as AuthContext
|
||||
|
||||
Comp->>Client: apiClient.get('/properties')
|
||||
Client->>BE_API: GET /api/v1/properties\n(access_token expired)
|
||||
BE_API-->>Client: 401
|
||||
|
||||
Client->>Inter: Response interceptor triggered
|
||||
Note over Inter: _retry = false → attempt refresh
|
||||
Inter->>BE_REF: POST /auth/refresh\nCookie: refresh_token=<jwt>
|
||||
|
||||
alt Refresh token also expired
|
||||
BE_REF-->>Inter: 401 "Invalid or expired refresh token"
|
||||
else Reuse attack detected
|
||||
BE_REF-->>Inter: 401 "Refresh token reuse detected"
|
||||
Note over BE_REF: DB: users.refresh_token_hash = NULL\nAll sessions revoked
|
||||
end
|
||||
|
||||
Inter->>Inter: processQueue(error)\nfailure on all queued requests
|
||||
Inter->>Inter: window.dispatchEvent(\n new CustomEvent('auth:logout-required')\n)
|
||||
Inter-->>Comp: Promise.reject(refreshError)
|
||||
|
||||
Note over Auth: 'auth:logout-required' event listener\n(registered in AuthProvider useEffect)
|
||||
Auth->>Auth: handleForceLogout()\nsetUser(null)\nsetIsAuthenticated(false)\ntoast.error('Session expired')
|
||||
|
||||
Note over Comp: ProtectedRoute detects\nisAuthenticated=false\n→ Navigate /login
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 8 — `ProtectedRoute` Decision Tree (With `isLoading`)
|
||||
|
||||
The updated guard logic in `App.jsx`. The `isLoading` check is the only change.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
MOUNT["ProtectedRoute renders\n(on every navigation + page refresh)"]
|
||||
|
||||
MOUNT --> LOADING{isLoading?}
|
||||
LOADING -->|true| SPINNER["Render full-screen spinner\n(session check in progress)\nDo NOT redirect yet"]
|
||||
|
||||
LOADING -->|false| AUTH{isAuthenticated?}
|
||||
|
||||
AUTH -->|false| REDIRECT_LOGIN["<Navigate to='/login'\nstate={{ from: location }}\nreplace />"]
|
||||
|
||||
AUTH -->|true| ROLES{allowedRoles\nprovided?}
|
||||
|
||||
ROLES -->|No — public-ish protected route| RENDER["Render children"]
|
||||
|
||||
ROLES -->|Yes| MATCH{user.role\nin allowedRoles?}
|
||||
MATCH -->|Yes| RENDER2["Render children"]
|
||||
MATCH -->|No — wrong role| REDIRECT_HOME["<Navigate to='/' replace />\n(redirects to Landing page)"]
|
||||
|
||||
subgraph WhyIsLoadingMatters["Why isLoading matters"]
|
||||
W1["Without isLoading guard:\nPage refresh → isAuthenticated=false (initial state)\n→ ProtectedRoute immediately redirects to /login\n→ Session restore completes 200ms later\n→ User sees login flash before their page loads"]
|
||||
W2["With isLoading guard:\nPage refresh → spinner shown\n→ Session restore completes\n→ isLoading=false, isAuthenticated=true\n→ Protected page renders normally"]
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagram 9 — User Object Shape Transformation
|
||||
|
||||
How the mock user fields map to real API fields, and which components are affected.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph MockShape["Mock Store User Shape (camelCase)"]
|
||||
M1["id: 'e1' (string, not UUID)"]
|
||||
M2["name: 'Alice Johnson'"]
|
||||
M3["role: 'CUSTOMER'"]
|
||||
M4["empId: 'FA001'"]
|
||||
M5["type: 'customer'"]
|
||||
M6["xp: 3400"]
|
||||
M7["streak: 7"]
|
||||
M8["company: 'ABC Corp'"]
|
||||
M9["password: 'password' ← in memory!"]
|
||||
end
|
||||
|
||||
subgraph RealShape["Real API UserPublic Shape (snake_case)"]
|
||||
R1["id: '3f8a1c2d-...' (UUID string)"]
|
||||
R2["full_name: 'Alice Johnson'"]
|
||||
R3["role: 'CUSTOMER'"]
|
||||
R4["emp_id: null (CUSTOMER has no emp_id)"]
|
||||
R5["legacy_id: 'cus_001'"]
|
||||
R6["xp: 3400"]
|
||||
R7["streak_days: 7"]
|
||||
R8["company_name: 'ABC Corp'"]
|
||||
R9["(password never returned)"]
|
||||
end
|
||||
|
||||
subgraph I1Fixes["Fixed in I1"]
|
||||
F1["AuthContext toast:\nuser.name → user.full_name"]
|
||||
F2["Layout.jsx sidebar:\nuser.name → user.full_name"]
|
||||
F3["CustomerProfile.jsx header:\nuser.name → user.full_name"]
|
||||
F4["Chatbot.jsx greeting:\nuser.name → user.full_name"]
|
||||
end
|
||||
|
||||
subgraph LaterFixes["Fixed in later modules"]
|
||||
L1["user.empId → user.emp_id\n(I4 — Users integration)"]
|
||||
L2["user.streak → user.streak_days\n(I4 — Users integration)"]
|
||||
L3["user.id from 'e1' → UUID\n(via legacy_id bridge — I2)"]
|
||||
end
|
||||
|
||||
MockShape -->|"I1 replaces"| RealShape
|
||||
RealShape --> I1Fixes
|
||||
RealShape --> LaterFixes
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user