feat(portal): M1 data model — Client, ClientAccessToken, Project.client_id
Client (customer org), ClientAccessToken (interim hashed magic-URL gate), and an authoritative Project.client_id FK (client_name kept for display). New tables auto-create via create_all; migrate_add_client_portal.py adds projects.client_id. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,56 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Database migration: Client Portal (M1).
|
||||||
|
|
||||||
|
Adds the authoritative client link to projects:
|
||||||
|
- projects.client_id (TEXT, nullable) -> clients.id
|
||||||
|
|
||||||
|
The `clients` and `client_access_tokens` tables are created automatically by
|
||||||
|
SQLAlchemy `create_all` at app startup (they're brand-new tables), so this
|
||||||
|
migration only handles the column that create_all won't add to an existing
|
||||||
|
`projects` table.
|
||||||
|
|
||||||
|
Run once per database:
|
||||||
|
docker exec terra-view-terra-view-1 python3 backend/migrate_add_client_portal.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def migrate():
|
||||||
|
possible_paths = [
|
||||||
|
Path("data/seismo_fleet.db"),
|
||||||
|
Path("data/sfm.db"),
|
||||||
|
Path("data/seismo.db"),
|
||||||
|
]
|
||||||
|
db_path = next((p for p in possible_paths if p.exists()), None)
|
||||||
|
if db_path is None:
|
||||||
|
print(f"Database not found in any of: {[str(p) for p in possible_paths]}")
|
||||||
|
print("A fresh DB created via models.py will include projects.client_id automatically.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Using database: {db_path}")
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("PRAGMA table_info(projects)")
|
||||||
|
existing = {row[1] for row in cursor.fetchall()}
|
||||||
|
|
||||||
|
if "client_id" not in existing:
|
||||||
|
try:
|
||||||
|
cursor.execute("ALTER TABLE projects ADD COLUMN client_id TEXT")
|
||||||
|
print("✓ Added column: projects.client_id (TEXT)")
|
||||||
|
except sqlite3.OperationalError as e:
|
||||||
|
print(f"✗ Failed to add projects.client_id: {e}")
|
||||||
|
else:
|
||||||
|
print("○ Column already exists: projects.client_id")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print("\n✓ Client-portal migration complete.")
|
||||||
|
print(" Note: `clients` + `client_access_tokens` tables auto-create on app startup.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate()
|
||||||
@@ -192,6 +192,7 @@ class Project(Base):
|
|||||||
|
|
||||||
# Project metadata
|
# Project metadata
|
||||||
client_name = Column(String, nullable=True, index=True) # Client name (e.g., "PJ Dick")
|
client_name = Column(String, nullable=True, index=True) # Client name (e.g., "PJ Dick")
|
||||||
|
client_id = Column(String, nullable=True, index=True) # FK -> clients.id; authoritative portal link (client_name kept for display)
|
||||||
site_address = Column(String, nullable=True)
|
site_address = Column(String, nullable=True)
|
||||||
site_coordinates = Column(String, nullable=True) # "lat,lon"
|
site_coordinates = Column(String, nullable=True) # "lat,lon"
|
||||||
start_date = Column(Date, nullable=True)
|
start_date = Column(Date, nullable=True)
|
||||||
@@ -704,3 +705,37 @@ class PendingDeployment(Base):
|
|||||||
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# CLIENT PORTAL — read-only, scoped client access (see docs/CLIENT_PORTAL.md)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class Client(Base):
|
||||||
|
"""A portal client (customer org). Owns one or more Projects via
|
||||||
|
Project.client_id; their portal surfaces only those projects' locations.
|
||||||
|
Read-only — clients never control devices."""
|
||||||
|
__tablename__ = "clients"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, index=True) # UUID
|
||||||
|
name = Column(String, nullable=False) # display name, e.g. "PJ Dick"
|
||||||
|
slug = Column(String, nullable=False, unique=True, index=True) # URL-safe handle
|
||||||
|
contact_email = Column(String, nullable=True) # for M4 magic-link
|
||||||
|
active = Column(Boolean, default=True) # False = portal access off
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class ClientAccessToken(Base):
|
||||||
|
"""Interim 'magic URL' gate (M1-M3). The raw secret lives in the link and is
|
||||||
|
shown once on creation; only its sha256 is stored here. Revoke by setting
|
||||||
|
revoked_at. In M4 this is replaced behind get_current_client() without
|
||||||
|
touching routes/templates."""
|
||||||
|
__tablename__ = "client_access_tokens"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, index=True) # UUID
|
||||||
|
client_id = Column(String, nullable=False, index=True) # FK -> clients.id
|
||||||
|
token_hash = Column(String, nullable=False, index=True) # sha256 hex of the secret
|
||||||
|
label = Column(String, nullable=True) # e.g. "Dave's link"
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
last_used_at = Column(DateTime, nullable=True)
|
||||||
|
revoked_at = Column(DateTime, nullable=True) # set = link no longer works
|
||||||
|
|||||||
Reference in New Issue
Block a user