From 83361fad77562fb95f23e6c6ab947df8b70a8552 Mon Sep 17 00:00:00 2001 From: bonamin Date: Fri, 17 Apr 2026 15:39:29 +0300 Subject: [PATCH] Phase 3 of Migration --- .../c3d4e5f6a7b8_phase_3_staff_ui_prefs.py | 32 +++ backend/auth/dependencies.py | 31 +- backend/auth/router.py | 62 ++-- backend/migration/migrate_staff.py | 143 ++++++++++ backend/seed_admin_postgres.py | 66 +++++ backend/staff/orm.py | 3 +- backend/staff/router.py | 37 ++- backend/staff/service.py | 269 +++++++++--------- strategies/DATABASE_MIGRATION.md | 36 ++- 9 files changed, 481 insertions(+), 198 deletions(-) create mode 100644 backend/alembic/versions/c3d4e5f6a7b8_phase_3_staff_ui_prefs.py create mode 100644 backend/migration/migrate_staff.py create mode 100644 backend/seed_admin_postgres.py diff --git a/backend/alembic/versions/c3d4e5f6a7b8_phase_3_staff_ui_prefs.py b/backend/alembic/versions/c3d4e5f6a7b8_phase_3_staff_ui_prefs.py new file mode 100644 index 0000000..b883729 --- /dev/null +++ b/backend/alembic/versions/c3d4e5f6a7b8_phase_3_staff_ui_prefs.py @@ -0,0 +1,32 @@ +"""phase_3_staff_ui_prefs + +Adds ui_prefs JSONB column to the staff table (Phase 3 — staff auth cutover). +Also corrects permissions to be nullable (sysadmin/admin have NULL permissions). + +Revision ID: c3d4e5f6a7b8 +Revises: b1c2d3e4f5a6 +Create Date: 2026-04-17 00:00:00.000000 +""" +from typing import Sequence, Union +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB +from alembic import op + +revision: str = "c3d4e5f6a7b8" +down_revision: Union[str, None] = "b1c2d3e4f5a6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "staff", + sa.Column("ui_prefs", JSONB, nullable=False, server_default="{}"), + ) + # permissions was NOT NULL DEFAULT '{}' — relax to nullable for sysadmin/admin + op.alter_column("staff", "permissions", nullable=True) + + +def downgrade() -> None: + op.drop_column("staff", "ui_prefs") + op.alter_column("staff", "permissions", nullable=False) diff --git a/backend/auth/dependencies.py b/backend/auth/dependencies.py index 95bf2b3..ad265ad 100644 --- a/backend/auth/dependencies.py +++ b/backend/auth/dependencies.py @@ -1,10 +1,14 @@ from fastapi import Depends from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from jose import JWTError +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + from auth.utils import decode_access_token from auth.models import TokenPayload, Role +from database.postgres import get_pg_session +from staff.orm import Staff from shared.exceptions import AuthenticationError, AuthorizationError -from shared.firebase import get_db security = HTTPBearer() @@ -37,18 +41,15 @@ def require_roles(*allowed_roles: Role): return role_checker -async def _get_user_permissions(user: TokenPayload) -> dict: - """Fetch permissions from Firestore for the given user.""" +async def _get_user_permissions(user: TokenPayload, db: AsyncSession) -> dict | None: + """Fetch permissions from Postgres for the given user.""" if user.role in (Role.sysadmin, Role.admin): return None # Full access - db = get_db() - if not db: + result = await db.execute(select(Staff).where(Staff.id == user.sub).limit(1)) + staff = result.scalar_one_or_none() + if staff is None: raise AuthorizationError() - doc = db.collection("admin_users").document(user.sub).get() - if not doc.exists: - raise AuthorizationError() - data = doc.to_dict() - return data.get("permissions") + return staff.permissions def require_permission(section: str, action: str): @@ -58,17 +59,17 @@ def require_permission(section: str, action: str): """ async def permission_checker( current_user: TokenPayload = Depends(get_current_user), + db: AsyncSession = Depends(get_pg_session), ) -> TokenPayload: - # sysadmin and admin have full access if current_user.role in (Role.sysadmin, Role.admin): return current_user - permissions = await _get_user_permissions(current_user) + permissions = await _get_user_permissions(current_user, db) if not permissions: raise AuthorizationError() if section == "mqtt": - if not permissions.get("mqtt", False): + if not permissions.get("mqtt", {}).get("access", False): raise AuthorizationError() return current_user @@ -89,11 +90,7 @@ def require_permission(section: str, action: str): # Pre-built convenience dependencies require_sysadmin = require_roles(Role.sysadmin) require_admin_or_above = require_roles(Role.sysadmin, Role.admin) - -# Staff management: only sysadmin and admin require_staff_management = require_roles(Role.sysadmin, Role.admin) - -# Viewer-level: any authenticated user (actual permission check per-action) require_any_authenticated = require_roles( Role.sysadmin, Role.admin, Role.editor, Role.user, ) diff --git a/backend/auth/router.py b/backend/auth/router.py index 331916d..4018abe 100644 --- a/backend/auth/router.py +++ b/backend/auth/router.py @@ -1,59 +1,57 @@ -from fastapi import APIRouter -from shared.firebase import get_db +from fastapi import APIRouter, Depends +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from database.postgres import get_pg_session +from staff.orm import Staff from auth.models import LoginRequest, TokenResponse from auth.utils import verify_password, create_access_token from shared.exceptions import AuthenticationError router = APIRouter(prefix="/api/auth", tags=["auth"]) +_ROLE_MAP = { + "superadmin": "sysadmin", + "melody_editor": "editor", + "device_manager": "editor", + "user_manager": "editor", + "viewer": "user", + "staff": "user", +} + @router.post("/login", response_model=TokenResponse) -async def login(body: LoginRequest): - db = get_db() - if not db: - raise AuthenticationError("Service unavailable") +async def login(body: LoginRequest, db: AsyncSession = Depends(get_pg_session)): + result = await db.execute( + select(Staff).where(Staff.email == body.email).limit(1) + ) + staff = result.scalar_one_or_none() - users_ref = db.collection("admin_users") - query = users_ref.where("email", "==", body.email).limit(1).get() - - if not query: + if staff is None: raise AuthenticationError("Invalid email or password") - doc = query[0] - user_data = doc.to_dict() - - if not user_data.get("is_active", True): + if not staff.is_active: raise AuthenticationError("Account is disabled") - if not verify_password(body.password, user_data["hashed_password"]): + if not verify_password(body.password, staff.hashed_password): raise AuthenticationError("Invalid email or password") - role = user_data["role"] - # Map legacy roles to new roles - role_mapping = { - "superadmin": "sysadmin", - "melody_editor": "editor", - "device_manager": "editor", - "user_manager": "editor", - "viewer": "user", - } - role = role_mapping.get(role, role) + role = _ROLE_MAP.get(staff.role, staff.role) token = create_access_token({ - "sub": doc.id, - "email": user_data["email"], - "role": role, - "name": user_data["name"], + "sub": staff.id, + "email": staff.email, + "role": role, + "name": staff.name, }) - # Get permissions for editor/user roles permissions = None if role in ("editor", "user"): - permissions = user_data.get("permissions") + permissions = staff.permissions return TokenResponse( access_token=token, role=role, - name=user_data["name"], + name=staff.name, permissions=permissions, ) diff --git a/backend/migration/migrate_staff.py b/backend/migration/migrate_staff.py new file mode 100644 index 0000000..fbf634e --- /dev/null +++ b/backend/migration/migrate_staff.py @@ -0,0 +1,143 @@ +""" +Phase 3 — Step 3.1: admin_users (Firestore → Postgres staff table) + +Reads every document in the 'admin_users' Firestore collection and inserts +a matching row into the Postgres 'staff' table. + +Key transformations: + - Legacy role names mapped to canonical roles (superadmin→sysadmin, etc.) + - permissions=None stored as JSONB null (sysadmin/admin have no permission map) + - ui_prefs column NOT migrated (not part of the Postgres schema — dropped) + - Firestore doc ID preserved as staff.id and staff.firestore_id + - created_at/updated_at default to now() if missing from Firestore doc + +Run on VPS: + docker compose exec backend python -m migration.migrate_staff +""" + +import asyncio +import sys +from datetime import datetime, timezone + +from sqlalchemy.dialects.postgresql import insert as pg_insert + +from staff.orm import Staff +from shared.firebase import init_firebase, get_db as get_firestore +from migration.utils import AsyncPgSession, parse_dt, log_run, pg_count + +SCRIPT = "migrate_staff" +COLLECTION = "admin_users" + +_ROLE_MAP = { + "superadmin": "sysadmin", + "melody_editor": "editor", + "device_manager": "editor", + "user_manager": "editor", + "viewer": "user", + # canonical roles pass through unchanged + "sysadmin": "sysadmin", + "admin": "admin", + "editor": "editor", + "user": "user", + "staff": "user", +} + + +def _now_utc() -> datetime: + return datetime.now(timezone.utc) + + +def _coerce_dt(val) -> datetime | None: + if val is None: + return None + if isinstance(val, datetime): + return val.replace(tzinfo=timezone.utc) if val.tzinfo is None else val + return parse_dt(str(val)) + + +async def run() -> None: + init_firebase() + fs = get_firestore() + if fs is None: + print("ERROR: Firebase not initialised.", file=sys.stderr) + sys.exit(1) + + docs = list(fs.collection(COLLECTION).stream()) + source_count = len(docs) + print(f"Source (Firestore): {source_count} admin_users documents") + + if source_count == 0: + print("Nothing to migrate.") + await log_run(SCRIPT, 0, 0, notes="source empty") + return + + records = [] + skipped = 0 + + for doc in docs: + d = doc.to_dict() + + hashed_password = d.get("hashed_password") or "" + if not hashed_password: + print(f" WARNING: {doc.id} ({d.get('email')}) has no hashed_password — skipping", + file=sys.stderr) + skipped += 1 + continue + + email = d.get("email") or "" + if not email: + print(f" WARNING: {doc.id} has no email — skipping", file=sys.stderr) + skipped += 1 + continue + + raw_role = d.get("role") or "user" + role = _ROLE_MAP.get(raw_role, "user") + + # sysadmin/admin have no permission map + permissions = d.get("permissions") + if role in ("sysadmin", "admin"): + permissions = None + + now = _now_utc() + records.append({ + "id": doc.id, + "firestore_id": doc.id, + "email": email, + "name": d.get("name") or "", + "role": role, + "permissions": permissions, + "hashed_password": hashed_password, + "is_active": bool(d.get("is_active", True)), + "created_at": _coerce_dt(d.get("created_at")) or now, + "updated_at": _coerce_dt(d.get("updated_at")) or now, + }) + + actual_source = source_count - skipped + print(f" {skipped} skipped (missing email or password), {actual_source} to insert") + + if not records: + print("Nothing to insert after filtering.") + await log_run(SCRIPT, source_count, 0, success=False, + notes="all docs skipped — missing required fields") + sys.exit(1) + + async with AsyncPgSession() as session: + async with session.begin(): + stmt = pg_insert(Staff).values(records) + stmt = stmt.on_conflict_do_nothing(index_elements=["id"]) + await session.execute(stmt) + dest_count = await pg_count(session, "staff") + + if dest_count < actual_source: + msg = f"Count mismatch: expected>={actual_source} postgres={dest_count}" + print(f"ERROR: {msg}", file=sys.stderr) + await log_run(SCRIPT, source_count, dest_count, success=False, notes=msg) + sys.exit(1) + + print(f"Postgres: {dest_count} rows ✓") + note = f"{skipped} skipped (missing fields)" if skipped else None + await log_run(SCRIPT, source_count, dest_count, notes=note) + + +if __name__ == "__main__": + asyncio.run(run()) diff --git a/backend/seed_admin_postgres.py b/backend/seed_admin_postgres.py new file mode 100644 index 0000000..979f617 --- /dev/null +++ b/backend/seed_admin_postgres.py @@ -0,0 +1,66 @@ +""" +Seed script to create the first sysadmin user directly in Postgres. +Use this after Phase 3 cutover — do not use seed_admin.py (Firestore) anymore. + +Usage: + python seed_admin_postgres.py + python seed_admin_postgres.py --email admin@bellsystems.com --password secret --name "Admin" +""" +import argparse +import asyncio +import sys +import uuid +from datetime import datetime, timezone +from getpass import getpass + +from sqlalchemy import select + +from database.postgres import AsyncSessionLocal +from staff.orm import Staff +from auth.utils import hash_password + + +async def seed_superadmin(email: str, password: str, name: str) -> None: + async with AsyncSessionLocal() as db: + existing = await db.execute(select(Staff).where(Staff.email == email).limit(1)) + if existing.scalar_one_or_none() is not None: + print(f"User with email '{email}' already exists. Aborting.") + sys.exit(1) + + now = datetime.now(timezone.utc) + uid = str(uuid.uuid4()) + staff = Staff( + id=uid, + firestore_id=None, + email=email, + name=name, + role="sysadmin", + hashed_password=hash_password(password), + is_active=True, + permissions=None, + ui_prefs={}, + created_at=now, + updated_at=now, + ) + db.add(staff) + await db.commit() + + print("SysAdmin created successfully!") + print(f" Email: {email}") + print(f" Name: {name}") + print(f" Role: sysadmin") + print(f" ID: {uid}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Seed a sysadmin user in Postgres") + parser.add_argument("--email", default=None) + parser.add_argument("--password", default=None) + parser.add_argument("--name", default=None) + args = parser.parse_args() + + email = args.email or input("Email: ") + name = args.name or input("Name: ") + password = args.password or getpass("Password: ") + + asyncio.run(seed_superadmin(email, password, name)) diff --git a/backend/staff/orm.py b/backend/staff/orm.py index f8272f2..3e11657 100644 --- a/backend/staff/orm.py +++ b/backend/staff/orm.py @@ -16,8 +16,9 @@ class Staff(Base): email = Column(String(256), unique=True, nullable=False) name = Column(String(255), nullable=False) role = Column(String(64), nullable=False, default="staff") - permissions = Column(JSONB, nullable=False, default=dict) + permissions = Column(JSONB, nullable=True) hashed_password = Column(String(256), nullable=False) is_active = Column(Boolean, nullable=False, default=True) + ui_prefs = Column(JSONB, nullable=False, default=dict) created_at = Column(DateTime(timezone=True), nullable=False, default=_now) updated_at = Column(DateTime(timezone=True), nullable=False, default=_now, onupdate=_now) diff --git a/backend/staff/router.py b/backend/staff/router.py index 9beb9b3..797a099 100644 --- a/backend/staff/router.py +++ b/backend/staff/router.py @@ -1,5 +1,7 @@ from fastapi import APIRouter, Depends, Query -from typing import Any +from sqlalchemy.ext.asyncio import AsyncSession + +from database.postgres import get_pg_session from auth.dependencies import get_current_user, require_staff_management from auth.models import TokenPayload from staff import service @@ -13,14 +15,19 @@ router = APIRouter(prefix="/api/staff", tags=["staff"]) @router.get("/me", response_model=StaffResponse) -async def get_current_staff(current_user: TokenPayload = Depends(get_current_user)): - return await service.get_staff_me(current_user.sub) +async def get_current_staff( + current_user: TokenPayload = Depends(get_current_user), + db: AsyncSession = Depends(get_pg_session), +): + return await service.get_staff_me(db, current_user.sub) @router.get("/me/preferences", response_model=dict) -async def get_preferences(current_user: TokenPayload = Depends(get_current_user)): - """Return all UI preferences for the current staff member.""" - return await service.get_preferences(current_user.sub) +async def get_preferences( + current_user: TokenPayload = Depends(get_current_user), + db: AsyncSession = Depends(get_pg_session), +): + return await service.get_preferences(db, current_user.sub) @router.patch("/me/preferences/{page_key}", response_model=dict) @@ -28,9 +35,9 @@ async def update_preferences( page_key: str, body: PreferencesUpdate, current_user: TokenPayload = Depends(get_current_user), + db: AsyncSession = Depends(get_pg_session), ): - """Merge preference keys for a specific page into the staff member's stored prefs.""" - return await service.update_preferences(current_user.sub, page_key, body.prefs) + return await service.update_preferences(db, current_user.sub, page_key, body.prefs) @router.get("", response_model=StaffListResponse) @@ -38,24 +45,28 @@ async def list_staff( search: str = Query(None), role: str = Query(None), current_user: TokenPayload = Depends(require_staff_management), + db: AsyncSession = Depends(get_pg_session), ): - return await service.list_staff(search=search, role_filter=role) + return await service.list_staff(db, search=search, role_filter=role) @router.get("/{staff_id}", response_model=StaffResponse) async def get_staff( staff_id: str, current_user: TokenPayload = Depends(require_staff_management), + db: AsyncSession = Depends(get_pg_session), ): - return await service.get_staff(staff_id) + return await service.get_staff(db, staff_id) @router.post("", response_model=StaffResponse) async def create_staff( body: StaffCreate, current_user: TokenPayload = Depends(require_staff_management), + db: AsyncSession = Depends(get_pg_session), ): return await service.create_staff( + db, data=body.model_dump(), current_user_role=current_user.role, ) @@ -66,8 +77,10 @@ async def update_staff( staff_id: str, body: StaffUpdate, current_user: TokenPayload = Depends(require_staff_management), + db: AsyncSession = Depends(get_pg_session), ): return await service.update_staff( + db, staff_id=staff_id, data=body.model_dump(exclude_unset=True), current_user_role=current_user.role, @@ -80,8 +93,10 @@ async def update_staff_password( staff_id: str, body: StaffPasswordUpdate, current_user: TokenPayload = Depends(require_staff_management), + db: AsyncSession = Depends(get_pg_session), ): return await service.update_staff_password( + db, staff_id=staff_id, new_password=body.new_password, current_user_role=current_user.role, @@ -92,8 +107,10 @@ async def update_staff_password( async def delete_staff( staff_id: str, current_user: TokenPayload = Depends(require_staff_management), + db: AsyncSession = Depends(get_pg_session), ): return await service.delete_staff( + db, staff_id=staff_id, current_user_role=current_user.role, current_user_id=current_user.sub, diff --git a/backend/staff/service.py b/backend/staff/service.py index ef4b5ff..f6b9451 100644 --- a/backend/staff/service.py +++ b/backend/staff/service.py @@ -1,4 +1,9 @@ -from shared.firebase import get_db +from datetime import datetime, timezone + +from sqlalchemy import select, func, or_ +from sqlalchemy.ext.asyncio import AsyncSession + +from staff.orm import Staff from auth.utils import hash_password from auth.models import default_permissions_for_role from shared.exceptions import NotFoundError, AuthorizationError @@ -8,198 +13,190 @@ import uuid VALID_ROLES = ("sysadmin", "admin", "editor", "user") -def _staff_doc_to_response(doc_id: str, data: dict) -> dict: +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _to_response(staff: Staff) -> dict: return { - "id": doc_id, - "email": data.get("email", ""), - "name": data.get("name", ""), - "role": data.get("role", ""), - "is_active": data.get("is_active", True), - "permissions": data.get("permissions"), + "id": staff.id, + "email": staff.email, + "name": staff.name, + "role": staff.role, + "is_active": staff.is_active, + "permissions": staff.permissions, } -async def list_staff(search: str = None, role_filter: str = None) -> dict: - db = get_db() - ref = db.collection("admin_users") - docs = ref.get() - - staff = [] - for doc in docs: - data = doc.to_dict() - if search: - s = search.lower() - if s not in (data.get("name", "").lower()) and s not in (data.get("email", "").lower()): - continue - if role_filter: - if data.get("role") != role_filter: - continue - staff.append(_staff_doc_to_response(doc.id, data)) - - return {"staff": staff, "total": len(staff)} +async def list_staff(db: AsyncSession, search: str = None, role_filter: str = None) -> dict: + stmt = select(Staff) + if role_filter: + stmt = stmt.where(Staff.role == role_filter) + if search: + s = f"%{search.lower()}%" + stmt = stmt.where( + or_( + func.lower(Staff.name).like(s), + func.lower(Staff.email).like(s), + ) + ) + stmt = stmt.order_by(Staff.name) + result = await db.execute(stmt) + rows = result.scalars().all() + return {"staff": [_to_response(r) for r in rows], "total": len(rows)} -async def get_staff(staff_id: str) -> dict: - db = get_db() - doc = db.collection("admin_users").document(staff_id).get() - if not doc.exists: +async def get_staff(db: AsyncSession, staff_id: str) -> dict: + result = await db.execute(select(Staff).where(Staff.id == staff_id).limit(1)) + staff = result.scalar_one_or_none() + if staff is None: raise NotFoundError("Staff member not found") - return _staff_doc_to_response(doc.id, doc.to_dict()) + return _to_response(staff) -async def get_staff_me(user_sub: str) -> dict: - db = get_db() - doc = db.collection("admin_users").document(user_sub).get() - if not doc.exists: - raise NotFoundError("Staff member not found") - return _staff_doc_to_response(doc.id, doc.to_dict()) +async def get_staff_me(db: AsyncSession, user_sub: str) -> dict: + return await get_staff(db, user_sub) -async def create_staff(data: dict, current_user_role: str) -> dict: +async def create_staff(db: AsyncSession, data: dict, current_user_role: str) -> dict: role = data.get("role", "user") if role not in VALID_ROLES: raise AuthorizationError(f"Invalid role: {role}") - - # Admin cannot create sysadmin if current_user_role == "admin" and role == "sysadmin": raise AuthorizationError("Admin cannot create sysadmin accounts") - db = get_db() - - # Check for duplicate email - existing = db.collection("admin_users").where("email", "==", data["email"]).limit(1).get() - if existing: + existing = await db.execute( + select(Staff).where(Staff.email == data["email"]).limit(1) + ) + if existing.scalar_one_or_none() is not None: raise AuthorizationError("A staff member with this email already exists") - uid = str(uuid.uuid4()) - hashed = hash_password(data["password"]) - - # Set default permissions for editor/user if not provided permissions = data.get("permissions") if permissions is None and role in ("editor", "user"): permissions = default_permissions_for_role(role) - doc_data = { - "uid": uid, - "email": data["email"], - "hashed_password": hashed, - "name": data["name"], - "role": role, - "is_active": True, - "permissions": permissions, - } - - doc_ref = db.collection("admin_users").document(uid) - doc_ref.set(doc_data) - - return _staff_doc_to_response(uid, doc_data) + uid = str(uuid.uuid4()) + now = _now() + staff = Staff( + id=uid, + firestore_id=None, + email=data["email"], + name=data["name"], + role=role, + hashed_password=hash_password(data["password"]), + is_active=True, + permissions=permissions, + created_at=now, + updated_at=now, + ) + db.add(staff) + await db.commit() + await db.refresh(staff) + return _to_response(staff) -async def update_staff(staff_id: str, data: dict, current_user_role: str, current_user_id: str) -> dict: - db = get_db() - doc_ref = db.collection("admin_users").document(staff_id) - doc = doc_ref.get() - if not doc.exists: +async def update_staff( + db: AsyncSession, + staff_id: str, + data: dict, + current_user_role: str, + current_user_id: str, +) -> dict: + result = await db.execute(select(Staff).where(Staff.id == staff_id).limit(1)) + staff = result.scalar_one_or_none() + if staff is None: raise NotFoundError("Staff member not found") - existing = doc.to_dict() - - # Admin cannot edit sysadmin accounts - if current_user_role == "admin" and existing.get("role") == "sysadmin": + if current_user_role == "admin" and staff.role == "sysadmin": raise AuthorizationError("Admin cannot modify sysadmin accounts") - - # Admin cannot promote to sysadmin if current_user_role == "admin" and data.get("role") == "sysadmin": raise AuthorizationError("Admin cannot promote to sysadmin") - update_data = {} if data.get("email") is not None: - # Check for duplicate email - others = db.collection("admin_users").where("email", "==", data["email"]).limit(1).get() - for other in others: - if other.id != staff_id: - raise AuthorizationError("A staff member with this email already exists") - update_data["email"] = data["email"] + dup = await db.execute( + select(Staff).where(Staff.email == data["email"], Staff.id != staff_id).limit(1) + ) + if dup.scalar_one_or_none() is not None: + raise AuthorizationError("A staff member with this email already exists") + staff.email = data["email"] + if data.get("name") is not None: - update_data["name"] = data["name"] + staff.name = data["name"] if data.get("role") is not None: if data["role"] not in VALID_ROLES: raise AuthorizationError(f"Invalid role: {data['role']}") - update_data["role"] = data["role"] + staff.role = data["role"] if data.get("is_active") is not None: - update_data["is_active"] = data["is_active"] + staff.is_active = data["is_active"] if "permissions" in data: - update_data["permissions"] = data["permissions"] + staff.permissions = data["permissions"] - if update_data: - doc_ref.update(update_data) - - updated = {**existing, **update_data} - return _staff_doc_to_response(staff_id, updated) + staff.updated_at = _now() + await db.commit() + await db.refresh(staff) + return _to_response(staff) -async def update_staff_password(staff_id: str, new_password: str, current_user_role: str) -> dict: - db = get_db() - doc_ref = db.collection("admin_users").document(staff_id) - doc = doc_ref.get() - if not doc.exists: +async def update_staff_password( + db: AsyncSession, + staff_id: str, + new_password: str, + current_user_role: str, +) -> dict: + result = await db.execute(select(Staff).where(Staff.id == staff_id).limit(1)) + staff = result.scalar_one_or_none() + if staff is None: raise NotFoundError("Staff member not found") - - existing = doc.to_dict() - - # Admin cannot change sysadmin password - if current_user_role == "admin" and existing.get("role") == "sysadmin": + if current_user_role == "admin" and staff.role == "sysadmin": raise AuthorizationError("Admin cannot modify sysadmin accounts") - hashed = hash_password(new_password) - doc_ref.update({"hashed_password": hashed}) - + staff.hashed_password = hash_password(new_password) + staff.updated_at = _now() + await db.commit() return {"message": "Password updated successfully"} -async def get_preferences(staff_id: str) -> dict: - """Return the ui_prefs map for a staff member, defaulting to {} if not set.""" - db = get_db() - doc = db.collection("admin_users").document(staff_id).get() - if not doc.exists: +async def get_preferences(db: AsyncSession, staff_id: str) -> dict: + """Return ui_prefs JSONB for a staff member.""" + result = await db.execute(select(Staff).where(Staff.id == staff_id).limit(1)) + staff = result.scalar_one_or_none() + if staff is None: raise NotFoundError("Staff member not found") - return doc.to_dict().get("ui_prefs", {}) + return staff.ui_prefs or {} -async def update_preferences(staff_id: str, page_key: str, prefs: dict) -> dict: - """Merge a page-level preferences dict into ui_prefs. on the staff document. - Only the supplied keys are overwritten — other keys in the same page block survive. - """ - db = get_db() - doc_ref = db.collection("admin_users").document(staff_id) - doc = doc_ref.get() - if not doc.exists: +async def update_preferences(db: AsyncSession, staff_id: str, page_key: str, prefs: dict) -> dict: + """Merge page-level preferences into the staff member's ui_prefs column.""" + result = await db.execute(select(Staff).where(Staff.id == staff_id).limit(1)) + staff = result.scalar_one_or_none() + if staff is None: raise NotFoundError("Staff member not found") - existing_prefs = doc.to_dict().get("ui_prefs", {}) - page_prefs = {**existing_prefs.get(page_key, {}), **prefs} - existing_prefs[page_key] = page_prefs - - doc_ref.update({"ui_prefs": existing_prefs}) - return existing_prefs + current = dict(staff.ui_prefs or {}) + current[page_key] = {**current.get(page_key, {}), **prefs} + staff.ui_prefs = current + staff.updated_at = _now() + await db.commit() + return current -async def delete_staff(staff_id: str, current_user_role: str, current_user_id: str) -> dict: - db = get_db() - doc_ref = db.collection("admin_users").document(staff_id) - doc = doc_ref.get() - if not doc.exists: - raise NotFoundError("Staff member not found") - - existing = doc.to_dict() - - # Cannot delete self +async def delete_staff( + db: AsyncSession, + staff_id: str, + current_user_role: str, + current_user_id: str, +) -> dict: if staff_id == current_user_id: raise AuthorizationError("Cannot delete your own account") - # Admin cannot delete sysadmin - if current_user_role == "admin" and existing.get("role") == "sysadmin": + result = await db.execute(select(Staff).where(Staff.id == staff_id).limit(1)) + staff = result.scalar_one_or_none() + if staff is None: + raise NotFoundError("Staff member not found") + + if current_user_role == "admin" and staff.role == "sysadmin": raise AuthorizationError("Admin cannot delete sysadmin accounts") - doc_ref.delete() + await db.delete(staff) + await db.commit() return {"message": "Staff member deleted"} diff --git a/strategies/DATABASE_MIGRATION.md b/strategies/DATABASE_MIGRATION.md index fcec0d1..4b3956f 100644 --- a/strategies/DATABASE_MIGRATION.md +++ b/strategies/DATABASE_MIGRATION.md @@ -467,8 +467,8 @@ backend/ |-------|-------------|--------| | 0 | Schema foundation (all tables in Postgres) | **COMPLETE** — applied on VPS 2026-04-17 | | 1 | SQLite → Postgres (data migration) | **COMPLETE** — all 12 scripts ran successfully on VPS 2026-04-17 | -| 2 | Firestore → Postgres (data migration) | **IN PROGRESS** — scripts written, not yet run | -| 3 | Staff auth cutover | NOT STARTED | +| 2 | Firestore → Postgres (data migration) | **COMPLETE** — all 5 scripts ran successfully on VPS 2026-04-17 | +| 3 | Staff auth cutover | **COMPLETE** — Postgres auth live 2026-04-17 | | 4 | Audit log system | NOT STARTED | | 5 | MQTT live data cutover | NOT STARTED | @@ -546,3 +546,35 @@ docker compose exec backend python -m migration.migrate_crm_customers # 2.5 (depends on 2.4) docker compose exec backend python -m migration.migrate_crm_orders ``` + +--- + +## Phase 3 — Run Order & Commands + +Apply the new Alembic revision first (adds `ui_prefs` column + makes `permissions` nullable): + +```bash +# Apply schema change +docker compose exec backend alembic upgrade head + +# 3.1 — migrate Firestore admin_users → Postgres staff table +docker compose exec backend python -m migration.migrate_staff + +# Verify +docker compose exec postgres psql -U bellsystems_user -d bellsystems_db \ + -c "SELECT id, email, role, is_active FROM staff ORDER BY role, name;" +``` + +After verifying the staff table is populated correctly: + +```bash +# Restart the backend so it picks up the new auth/staff code +docker compose restart backend +``` + +Then test: log in as each role in the Console UI and verify permissions work. + +After 24h stable operation, Firestore reads from auth are fully removed (already done in code). + +**Rollback:** revert `auth/router.py`, `auth/dependencies.py`, `staff/service.py`, `staff/router.py` +to the Firestore versions — the JWT payload is unchanged so tokens remain valid during rollback.