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:
2026-06-10 21:32:09 +00:00
parent a64c9ced65
commit 80a8470b55
2 changed files with 91 additions and 0 deletions
+56
View File
@@ -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()
+35
View File
@@ -192,6 +192,7 @@ class Project(Base):
# Project metadata
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_coordinates = Column(String, nullable=True) # "lat,lon"
start_date = Column(Date, nullable=True)
@@ -704,3 +705,37 @@ class PendingDeployment(Base):
created_at = Column(DateTime, default=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