Phase 3 of Migration

This commit is contained in:
2026-04-17 15:39:29 +03:00
parent c7d5206d0c
commit 83361fad77
9 changed files with 481 additions and 198 deletions

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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())

View File

@@ -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))

View File

@@ -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)

View File

@@ -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,

View File

@@ -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.<page_key> 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"}

View File

@@ -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.