#!/usr/bin/env python3 """ Org Blueprint — Multi-compte / Organisations Pro Sprint: HRT-82 Endpoints: POST /api/v1/org — créer une organisation (Pro only, 1 max par owner) GET /api/v1/org — infos org courante DELETE /api/v1/org — supprimer l'org (owner only) POST /api/v1/org/invite — inviter un membre par email (max 5 totaux) GET /api/v1/org/members — liste des membres DELETE /api/v1/org/members/ — retirer un membre (owner only) Plan enforcement: - Toutes les routes nécessitent plan=pro via plan_required('pro') - Limite : 1 org par owner, 5 membres max (owner inclus) """ import secrets import logging from datetime import datetime, timezone from flask import Blueprint, jsonify, request from saas_auth import require_auth as jwt_required_middleware from org_db import get_db, migrate_org_tables logger = logging.getLogger("turf_saas.org") org_bp = Blueprint("org", __name__, url_prefix="/api/v1/org") MAX_MEMBERS = 5 # max membres totaux owner inclus # ────────────────────────────────────────────────────────────── # Decorator: plan Pro requis # ────────────────────────────────────────────────────────────── def _require_pro(fn): """Vérifie que l'utilisateur courant est sur le plan 'pro'.""" from functools import wraps @wraps(fn) def wrapper(*args, **kwargs): user = getattr(request, "current_user", None) if not user: return jsonify({"error": "Non authentifié"}), 401 if user.get("plan") != "pro": return jsonify( { "error": "Plan insuffisant", "required": "pro", "current_plan": user.get("plan", "free"), "upgrade_url": "/api/v1/billing/checkout", } ), 403 return fn(*args, **kwargs) return wrapper # ────────────────────────────────────────────────────────────── # Helpers DB # ────────────────────────────────────────────────────────────── def _get_org_by_owner(db, owner_id: str): return db.execute( "SELECT * FROM organizations WHERE owner_id = ?", (owner_id,) ).fetchone() def _get_org_by_id(db, org_id: str): return db.execute("SELECT * FROM organizations WHERE id = ?", (org_id,)).fetchone() def _get_member_org(db, user_id: str): """Retourne l'org dont user_id est membre (owner ou member).""" row = db.execute( """SELECT o.* FROM organizations o JOIN org_members m ON m.org_id = o.id WHERE m.user_id = ? LIMIT 1""", (user_id,), ).fetchone() return row def _count_org_members(db, org_id: str) -> int: row = db.execute( "SELECT COUNT(*) AS cnt FROM org_members WHERE org_id = ?", (org_id,) ).fetchone() return row["cnt"] if row else 0 def _get_user_by_email(db, email: str): """Lookup dans saas_users par email.""" return db.execute( "SELECT * FROM saas_users WHERE email = ?", (email.lower().strip(),) ).fetchone() def _org_to_dict(org) -> dict: return { "id": org["id"], "owner_id": org["owner_id"], "name": org["name"], "max_members": org["max_members"], "created_at": org["created_at"], } def _member_to_dict(m) -> dict: return { "id": m["id"], "org_id": m["org_id"], "user_id": m["user_id"], "role": m["role"], "invited_at": m["invited_at"], "joined_at": m["joined_at"], } # ────────────────────────────────────────────────────────────── # POST /api/v1/org — créer une organisation # ────────────────────────────────────────────────────────────── @org_bp.route("", methods=["POST"]) @jwt_required_middleware @_require_pro def create_org(): """ Crée une organisation. --- tags: - Organisation security: - Bearer: [] requestBody: required: true content: application/json: schema: type: object required: [name] properties: name: type: string description: Nom de l'organisation (1-100 caractères) responses: 201: description: Organisation créée 400: description: Paramètre manquant ou invalide 403: description: Plan insuffisant 409: description: L'utilisateur possède déjà une organisation """ user = request.current_user owner_id = user["id"] data = request.get_json(silent=True) or {} name = (data.get("name") or "").strip() if not name or len(name) > 100: return jsonify({"error": "Le nom est requis (1-100 caractères)"}), 400 db = get_db() try: # 1 org max par owner existing = _get_org_by_owner(db, owner_id) if existing: return jsonify( { "error": "Vous possédez déjà une organisation", "org_id": existing["id"], } ), 409 org_id = secrets.token_hex(16) now = datetime.now(timezone.utc).isoformat() db.execute( "INSERT INTO organizations (id, owner_id, name, max_members, created_at) " "VALUES (?, ?, ?, ?, ?)", (org_id, owner_id, name, MAX_MEMBERS, now), ) # Ajouter l'owner comme premier membre avec rôle 'owner' db.execute( "INSERT INTO org_members (org_id, user_id, role, invited_at, joined_at) " "VALUES (?, ?, 'owner', ?, ?)", (org_id, owner_id, now, now), ) db.commit() org = _get_org_by_id(db, org_id) logger.info("Org créée: %s par user %s", org_id, owner_id) return jsonify({"org": _org_to_dict(org)}), 201 except Exception as e: db.rollback() logger.error("create_org error: %s", e) return jsonify({"error": "Erreur interne"}), 500 finally: db.close() # ────────────────────────────────────────────────────────────── # GET /api/v1/org — infos org courante # ────────────────────────────────────────────────────────────── @org_bp.route("", methods=["GET"]) @jwt_required_middleware @_require_pro def get_org(): """ Retourne l'organisation dont l'utilisateur est owner ou membre. --- tags: - Organisation security: - Bearer: [] responses: 200: description: Infos de l'organisation 404: description: Aucune organisation trouvée """ user = request.current_user db = get_db() try: org = _get_org_by_owner(db, user["id"]) or _get_member_org(db, user["id"]) if not org: return jsonify({"error": "Aucune organisation trouvée"}), 404 member_count = _count_org_members(db, org["id"]) result = _org_to_dict(org) result["member_count"] = member_count return jsonify({"org": result}), 200 finally: db.close() # ────────────────────────────────────────────────────────────── # DELETE /api/v1/org — supprimer l'organisation # ────────────────────────────────────────────────────────────── @org_bp.route("", methods=["DELETE"]) @jwt_required_middleware @_require_pro def delete_org(): """ Supprime l'organisation (owner uniquement). --- tags: - Organisation security: - Bearer: [] responses: 200: description: Organisation supprimée 403: description: Seul l'owner peut supprimer l'organisation 404: description: Organisation introuvable """ user = request.current_user db = get_db() try: org = _get_org_by_owner(db, user["id"]) if not org: return jsonify({"error": "Vous n'êtes pas owner d'une organisation"}), 403 # CASCADE supprime org_members automatiquement (FK ON DELETE CASCADE) db.execute("DELETE FROM organizations WHERE id = ?", (org["id"],)) db.commit() logger.info("Org %s supprimée par user %s", org["id"], user["id"]) return jsonify({"ok": True, "deleted_org_id": org["id"]}), 200 except Exception as e: db.rollback() logger.error("delete_org error: %s", e) return jsonify({"error": "Erreur interne"}), 500 finally: db.close() # ────────────────────────────────────────────────────────────── # POST /api/v1/org/invite — inviter un membre par email # ────────────────────────────────────────────────────────────── @org_bp.route("/invite", methods=["POST"]) @jwt_required_middleware @_require_pro def invite_member(): """ Invite un utilisateur dans l'organisation par email (owner uniquement). Limite : 5 membres totaux (owner inclus). --- tags: - Organisation security: - Bearer: [] requestBody: required: true content: application/json: schema: type: object required: [email] properties: email: type: string description: Email de l'utilisateur à inviter responses: 201: description: Membre ajouté 400: description: Paramètre manquant ou invalide 403: description: Seul l'owner peut inviter / limite de membres atteinte 404: description: Utilisateur introuvable ou organisation inexistante 409: description: L'utilisateur est déjà membre """ user = request.current_user data = request.get_json(silent=True) or {} email = (data.get("email") or "").strip().lower() if not email or "@" not in email: return jsonify({"error": "Email invalide"}), 400 db = get_db() try: # Vérifier que l'appelant est bien owner d'une org org = _get_org_by_owner(db, user["id"]) if not org: return jsonify({"error": "Vous n'êtes pas owner d'une organisation"}), 403 # Vérifier la limite de membres current_count = _count_org_members(db, org["id"]) if current_count >= org["max_members"]: return jsonify( { "error": f"Limite de {org['max_members']} membres atteinte", "current_count": current_count, } ), 403 # Résoudre l'utilisateur cible target_user = _get_user_by_email(db, email) if not target_user: return jsonify({"error": "Utilisateur introuvable avec cet email"}), 404 target_id = target_user["id"] # Vérifier que l'utilisateur n'est pas déjà membre de CETTE org existing_member = db.execute( "SELECT id FROM org_members WHERE org_id = ? AND user_id = ?", (org["id"], target_id), ).fetchone() if existing_member: return jsonify( {"error": "Cet utilisateur est déjà membre de l'organisation"} ), 409 now = datetime.now(timezone.utc).isoformat() db.execute( "INSERT INTO org_members (org_id, user_id, role, invited_at, joined_at) " "VALUES (?, ?, 'member', ?, ?)", (org["id"], target_id, now, now), ) db.commit() member_row = db.execute( "SELECT * FROM org_members WHERE org_id = ? AND user_id = ?", (org["id"], target_id), ).fetchone() logger.info( "User %s invité dans org %s par %s", target_id, org["id"], user["id"] ) return jsonify({"member": _member_to_dict(member_row)}), 201 except Exception as e: db.rollback() logger.error("invite_member error: %s", e) return jsonify({"error": "Erreur interne"}), 500 finally: db.close() # ────────────────────────────────────────────────────────────── # GET /api/v1/org/members — liste des membres # ────────────────────────────────────────────────────────────── @org_bp.route("/members", methods=["GET"]) @jwt_required_middleware @_require_pro def list_members(): """ Liste les membres de l'organisation courante. --- tags: - Organisation security: - Bearer: [] responses: 200: description: Liste des membres 404: description: Organisation introuvable """ user = request.current_user db = get_db() try: org = _get_org_by_owner(db, user["id"]) or _get_member_org(db, user["id"]) if not org: return jsonify({"error": "Aucune organisation trouvée"}), 404 members = db.execute( "SELECT m.*, u.email, u.firstname, u.lastname " "FROM org_members m " "LEFT JOIN saas_users u ON u.id = m.user_id " "WHERE m.org_id = ? " "ORDER BY m.invited_at ASC", (org["id"],), ).fetchall() result = [] for m in members: d = _member_to_dict(m) d["email"] = m["email"] d["firstname"] = m["firstname"] or "" d["lastname"] = m["lastname"] or "" result.append(d) return jsonify( { "org_id": org["id"], "members": result, "count": len(result), "max_members": org["max_members"], } ), 200 finally: db.close() # ────────────────────────────────────────────────────────────── # DELETE /api/v1/org/members/ — retirer un membre # ────────────────────────────────────────────────────────────── @org_bp.route("/members/", methods=["DELETE"]) @jwt_required_middleware @_require_pro def remove_member(target_user_id: str): """ Retire un membre de l'organisation (owner uniquement). L'owner ne peut pas se retirer lui-même. --- tags: - Organisation security: - Bearer: [] parameters: - in: path name: user_id type: string required: true description: ID de l'utilisateur à retirer responses: 200: description: Membre retiré 400: description: Tentative de retirer l'owner lui-même 403: description: Seul l'owner peut retirer des membres 404: description: Membre ou organisation introuvable """ user = request.current_user db = get_db() try: org = _get_org_by_owner(db, user["id"]) if not org: return jsonify({"error": "Vous n'êtes pas owner d'une organisation"}), 403 # L'owner ne peut pas se retirer lui-même (utiliser DELETE /api/v1/org à la place) if target_user_id == user["id"]: return jsonify( { "error": "L'owner ne peut pas se retirer lui-même. " "Utilisez DELETE /api/v1/org pour supprimer l'organisation." } ), 400 member = db.execute( "SELECT * FROM org_members WHERE org_id = ? AND user_id = ?", (org["id"], target_user_id), ).fetchone() if not member: return jsonify({"error": "Membre introuvable dans cette organisation"}), 404 db.execute( "DELETE FROM org_members WHERE org_id = ? AND user_id = ?", (org["id"], target_user_id), ) db.commit() logger.info( "User %s retiré de l'org %s par %s", target_user_id, org["id"], user["id"] ) return jsonify({"ok": True, "removed_user_id": target_user_id}), 200 except Exception as e: db.rollback() logger.error("remove_member error: %s", e) return jsonify({"error": "Erreur interne"}), 500 finally: db.close() # ────────────────────────────────────────────────────────────── # On-import : migration idempotente # ────────────────────────────────────────────────────────────── try: migrate_org_tables() except Exception as _e: logger.warning("org_db migration skipped (test env?): %s", _e)