from __future__ import annotations import re, secrets from datetime import datetime, timezone, timedelta from sqlalchemy import select from sqlalchemy.orm import Session from app.core.security.passwords import hash_password from app.core.settings import get_settings from app.modules.clients.models import Client from app.modules.core.iam.invite_service import issue_invite_token from app.modules.core.iam.models import User from app.modules.core.rbac.models import Role, UserRole from app.modules.email_integration.services import send_user_invite_email from .models import ClientIdentityAuditLog, ClientPortalIdentity, ClientPortalInvitation PAN_RE = re.compile(r"^[A-Z]{5}[0-9]{4}[A-Z]$") def normalize_pan(value: str | None) -> str: pan = re.sub(r"\s+", "", (value or "").upper()) if not PAN_RE.fullmatch(pan): raise ValueError("A valid 10-character PAN is required for client portal identity.") return pan def normalize_email(value: str | None) -> str: email = (value or "").strip().lower() if not email or "@" not in email or len(email) > 255: raise ValueError("A valid email address is required.") return email def placeholder_email(tenant_id: int, pan: str) -> str: return f"client+{tenant_id}.{pan.lower()}@invalid.local" def _audit(db: Session, identity: ClientPortalIdentity, actor_user_id: int | None, action: str, summary: str): db.add(ClientIdentityAuditLog(tenant_id=identity.tenant_id, client_id=identity.client_id, identity_id=identity.id, actor_user_id=actor_user_id, action=action, summary=summary)) def _ensure_client_role(db: Session, user_id: int): role = db.execute(select(Role).where(Role.name == "Client")).scalar_one_or_none() if not role: raise ValueError("Client role is not configured in RBAC.") exists = db.execute(select(UserRole).where(UserRole.user_id == user_id, UserRole.role_id == role.id)).scalar_one_or_none() if not exists: db.add(UserRole(user_id=user_id, role_id=role.id)) def get_identity(db: Session, client_id: int) -> ClientPortalIdentity | None: return db.execute(select(ClientPortalIdentity).where(ClientPortalIdentity.client_id == client_id)).scalar_one_or_none() def ensure_identity(db: Session, *, client: Client, actor_user_id: int | None, requested_email: str | None = None) -> ClientPortalIdentity: pan = normalize_pan(client.pan) existing_pan = db.execute(select(ClientPortalIdentity).where(ClientPortalIdentity.tenant_id == client.tenant_id, ClientPortalIdentity.normalized_pan == pan, ClientPortalIdentity.client_id != client.id)).scalar_one_or_none() if existing_pan: raise ValueError("This PAN is already linked to another client portal identity in the firm.") identity = get_identity(db, client.id) real_email = (requested_email or client.email or "").strip().lower() is_placeholder = not bool(real_email) email = placeholder_email(client.tenant_id, pan) if is_placeholder else normalize_email(real_email) other_user = db.execute(select(User).where(User.email == email)).scalar_one_or_none() if other_user and (not identity or identity.user_id != other_user.id) and client.portal_user_id != other_user.id: raise ValueError("This email address is already used by another ERP user.") if identity: identity.normalized_pan = pan identity.updated_by_user_id = actor_user_id if identity.user_id: user = db.get(User, identity.user_id) if user and user.email != email: user.email = email identity.login_email = email identity.email_is_placeholder = is_placeholder else: user = db.get(User, client.portal_user_id) if client.portal_user_id else None if not user: user = User(email=email, full_name=client.client_name, password_hash=hash_password(secrets.token_urlsafe(48)), tenant_id=client.tenant_id, branch_id=client.branch_id, is_active=True, allow_login=False, must_change_password=True) db.add(user); db.flush() _ensure_client_role(db, user.id) client.portal_user_id = user.id identity = ClientPortalIdentity(tenant_id=client.tenant_id, branch_id=client.branch_id, client_id=client.id, user_id=user.id, normalized_pan=pan, login_email=email, email_is_placeholder=is_placeholder, status="not_invited", created_by_user_id=actor_user_id, updated_by_user_id=actor_user_id) db.add(identity); db.flush() _audit(db, identity, actor_user_id, "identity_created", "PAN-linked client portal identity created.") db.commit(); db.refresh(identity) return identity def update_login_email(db: Session, *, identity: ClientPortalIdentity, email: str, actor_user_id: int | None): new_email = normalize_email(email) other = db.execute(select(User).where(User.email == new_email, User.id != identity.user_id)).scalar_one_or_none() if other: raise ValueError("This email address is already used by another ERP user.") client = db.get(Client, identity.client_id); user = db.get(User, identity.user_id) if identity.user_id else None old = identity.login_email identity.login_email = new_email; identity.email_is_placeholder = False; identity.updated_by_user_id = actor_user_id if client: client.email = new_email if user: user.email = new_email _audit(db, identity, actor_user_id, "email_changed", f"Portal login email changed from {old} to {new_email}.") db.commit() def issue_client_invitation(db: Session, *, identity: ClientPortalIdentity, actor_user_id: int | None, send_email: bool = True) -> tuple[str, ClientPortalInvitation]: user = db.get(User, identity.user_id) if identity.user_id else None if not user: raise ValueError("Client portal user is not linked.") token = issue_invite_token(db, user) now = datetime.now(timezone.utc) user.allow_login = False identity.status = "invited"; identity.invited_at_utc = identity.invited_at_utc or now; identity.last_invited_at_utc = now; identity.updated_by_user_id = actor_user_id mode = "manual_link" if identity.email_is_placeholder or not send_email else "email" row = ClientPortalInvitation(identity_id=identity.id, user_id=user.id, delivery_mode=mode, recipient_email=None if identity.email_is_placeholder else identity.login_email, status="issued", issued_by_user_id=actor_user_id, issued_at_utc=now, expires_at_utc=now + timedelta(hours=get_settings().INVITE_TOKEN_HOURS)) db.add(row); _audit(db, identity, actor_user_id, "invite_issued", f"Client portal invitation issued using {mode}.") if mode == "email": try: send_user_invite_email(db, user=user, invite_token=token) except Exception as exc: row.note = f"Email delivery failed: {exc}"; row.delivery_mode = "manual_link" db.commit(); db.refresh(row) return token, row def resolve_login_user(db: Session, identifier: str, bound_tenant_id: int | None = None) -> User | None: value = (identifier or "").strip() if "@" in value: return db.execute(select(User).where(User.email == value.lower())).scalar_one_or_none() try: pan = normalize_pan(value) except ValueError: return None q = select(ClientPortalIdentity).where(ClientPortalIdentity.normalized_pan == pan) if bound_tenant_id is not None: q = q.where(ClientPortalIdentity.tenant_id == bound_tenant_id) rows = list(db.execute(q).scalars().all()) if len(rows) != 1: return None return db.get(User, rows[0].user_id) if rows[0].user_id else None def mark_identity_activated(db: Session, user_id: int): identity = db.execute(select(ClientPortalIdentity).where(ClientPortalIdentity.user_id == user_id)).scalar_one_or_none() if identity and identity.status != "active": now = datetime.now(timezone.utc); identity.status="active"; identity.activated_at_utc=now inv = db.execute(select(ClientPortalInvitation).where(ClientPortalInvitation.identity_id==identity.id, ClientPortalInvitation.status=="issued").order_by(ClientPortalInvitation.id.desc())).scalars().first() if inv: inv.status="accepted"; inv.accepted_at_utc=now _audit(db, identity, user_id, "identity_activated", "Client accepted invitation and activated portal identity.") db.commit()