"""Mandanten-Verwaltung: Firmen anlegen/auflisten inkl. Kennzahlen. Genutzt von: - routers/reseller.py – Reseller verwaltet seine EIGENEN Firmen - routers/tenants.py – SUPER_ADMIN verwaltet ALLE Firmen + Reseller RLS sorgt für die Sichtbarkeitsgrenze: ein Reseller (app.reseller_id) sieht via companies-/users-Policy automatisch nur seine eigenen Firmen. Das Anlegen einer Firma bootstrappt unter kurzzeitigem Bypass (privilegierte, auditierte Operation) und stellt danach den RLS-Kontext des Actors wieder her. """ import re from datetime import datetime, timedelta, timezone from uuid import UUID from fastapi import HTTPException from sqlalchemy import func, select, text from sqlalchemy.ext.asyncio import AsyncSession from app.core.security import generate_invite_token, hash_password from app.models.audit_log import AuditLog from app.models.company import Company from app.models.user import User, UserRole from app.schemas.reseller import TenantCreate, TenantOut def _slugify(name: str) -> str: return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")[:80] class TenantService: async def _unique_slug(self, name: str, db: AsyncSession) -> str: base = _slugify(name) or "firma" slug, counter = base, 1 while await db.scalar(select(Company).where(Company.slug == slug)): slug = f"{base}-{counter}" counter += 1 return slug async def create_tenant( self, data: TenantCreate, actor: User, db: AsyncSession, reseller_id: UUID | None, ) -> Company: # E-Mail global eindeutig (User.email ist unique) if await db.scalar(select(User).where(User.email == data.admin_email)): raise HTTPException(status_code=400, detail="E-Mail ist bereits vergeben.") slug = await self._unique_slug(data.name, db) # Bootstrap unter Bypass – neue, leere Firma; danach Kontext wiederherstellen. await db.execute(text("SET LOCAL app.bypass_rls = 'on'")) try: company = Company( name=data.name, slug=slug, plan=data.plan or "trial", country=data.country or "DE", reseller_id=reseller_id, is_active=True, ) db.add(company) await db.flush() raw_token, token_hash = generate_invite_token() admin = User( company_id=company.id, email=data.admin_email, first_name=data.admin_first_name, last_name=data.admin_last_name, role=UserRole.COMPANY_ADMIN, password_hash=hash_password(raw_token), # temporär, beim Accept überschrieben invite_token_hash=token_hash, invite_expires=datetime.now(timezone.utc) + timedelta(days=7), is_active=False, ) db.add(admin) await db.flush() from app.services.absence_service import absence_service await absence_service.create_defaults_for_company(company.id, db) db.add(AuditLog( company_id=company.id, user_id=actor.id, action="tenant_created", entity_type="company", entity_id=company.id, new_value={ "name": company.name, "reseller_id": str(reseller_id) if reseller_id else None, "admin_email": admin.email, }, )) await db.flush() from app.services.email_service import email_service await email_service.send_invite(admin, actor, raw_token, db) finally: # RLS-Kontext des Actors wiederherstellen if actor.role == UserRole.RESELLER: await db.execute(text("SET LOCAL app.bypass_rls = 'off'")) # SUPER_ADMIN: Bypass bleibt aktiv (Normalzustand) return company async def list_tenants(self, db: AsyncSession) -> list[TenantOut]: """Alle für den aktuellen RLS-Kontext sichtbaren Firmen + Kennzahlen.""" companies = (await db.scalars(select(Company).order_by(Company.name))).all() if not companies: return [] ids = [c.id for c in companies] # Nutzeranzahl pro Firma counts: dict[UUID, tuple[int, int]] = {} rows = await db.execute( select( User.company_id, func.count(User.id), func.count(User.id).filter(User.is_active.is_(True)), ).where(User.company_id.in_(ids)).group_by(User.company_id) ) for cid, total, active in rows: counts[cid] = (total, active) # Erst-Admin-E-Mail pro Firma admin_email: dict[UUID, str] = {} arows = await db.execute( select(User.company_id, User.email) .where(User.company_id.in_(ids), User.role == UserRole.COMPANY_ADMIN) .order_by(User.company_id, User.created_at) ) for cid, email in arows: admin_email.setdefault(cid, email) # Reseller-Namen reseller_ids = [c.reseller_id for c in companies if c.reseller_id] reseller_name: dict[UUID, str] = {} if reseller_ids: rrows = await db.execute( select(User.id, User.first_name, User.last_name).where(User.id.in_(reseller_ids)) ) for rid, fn, ln in rrows: reseller_name[rid] = f"{fn} {ln}".strip() out: list[TenantOut] = [] for c in companies: total, active = counts.get(c.id, (0, 0)) out.append(TenantOut( id=c.id, name=c.name, slug=c.slug, plan=c.plan, is_active=c.is_active, country=c.country, reseller_id=c.reseller_id, reseller_name=reseller_name.get(c.reseller_id) if c.reseller_id else None, user_count=total, active_user_count=active, admin_email=admin_email.get(c.id), )) return out tenant_service = TenantService()