diff --git a/backend/migrate_add_client_portal.py b/backend/migrate_add_client_portal.py new file mode 100644 index 0000000..bc66f72 --- /dev/null +++ b/backend/migrate_add_client_portal.py @@ -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() diff --git a/backend/models.py b/backend/models.py index aae3039..8c5d5e9 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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