from __future__ import annotations import base64 import binascii import hashlib import json from datetime import UTC, datetime from typing import Literal from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import and_, or_ from sqlalchemy.orm import Session from govoplan_campaign.backend.db.models import ( Campaign, CampaignCollaborationEntry, CampaignJob, CampaignShare, CampaignVersion, ) from govoplan_campaign.backend.path_security import _attachment_rules from govoplan_campaign.backend.route_support import ( _access_directory, _get_campaign_for_principal, _require_permission, ) from govoplan_campaign.backend.runtime import get_registry from govoplan_campaign.backend.schemas import ( CampaignCollaborationCreateRequest, CampaignCollaborationEntryResponse, CampaignCollaborationListResponse, CampaignCollaborationModerationRequest, CampaignCollaborationReferenceInput, CampaignCollaborationReferenceResponse, ) from govoplan_core.api.v1.schemas import ( ReferenceOptionListResponse, ReferenceOptionResponse, ) from govoplan_core.audit.logging import audit_from_principal from govoplan_core.auth import ApiPrincipal, has_scope, require_scope from govoplan_core.core.notifications import ( NotificationDispatchRequest, notification_dispatch_provider, ) from govoplan_core.core.references import ( access_scope_reference_page, access_scope_reference_provider_available, ) from govoplan_core.db.session import get_session from govoplan_core.security.time import utc_now router = APIRouter(prefix="/campaigns", tags=["campaign collaboration"]) @router.get( "/{campaign_id}/collaboration/mention-options", response_model=ReferenceOptionListResponse, ) def search_campaign_collaboration_mentions( campaign_id: str, q: str = "", selected: list[str] = Query(default=[]), limit: int = Query(default=50, ge=1, le=100), cursor: str | None = None, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaigns:discussion:post")), ) -> ReferenceOptionListResponse: campaign = _get_campaign_for_principal(session, campaign_id, principal) _require_permission(principal, "campaigns:campaign:read") try: page = access_scope_reference_page( get_registry(), principal, scope_type="user", reference_kind="membership", query=q, selected_values=selected, limit=limit, cursor=cursor, administrative=True, session=session, ) except ValueError as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc), ) from exc allowed = [ option for option in page.options if _mentioned_user_has_campaign_access( session, campaign=campaign, user_id=option.value, ) ] return ReferenceOptionListResponse( options=[ReferenceOptionResponse(**option.to_dict()) for option in allowed], provider_available=access_scope_reference_provider_available(get_registry()), next_cursor=page.next_cursor, has_more=page.has_more, ) @router.get( "/{campaign_id}/collaboration", response_model=CampaignCollaborationListResponse, ) def list_campaign_collaboration( campaign_id: str, limit: int = Query(default=25, ge=1, le=50), cursor: str | None = None, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaigns:discussion:read")), ) -> CampaignCollaborationListResponse: campaign = _get_campaign_for_principal(session, campaign_id, principal) _require_permission(principal, "campaigns:campaign:read") query = session.query(CampaignCollaborationEntry).filter( CampaignCollaborationEntry.tenant_id == principal.tenant_id, CampaignCollaborationEntry.campaign_id == campaign.id, ) if not has_scope(principal, "campaigns:discussion:moderate"): query = query.filter(CampaignCollaborationEntry.visibility == "collaborators") if cursor: created_at, entry_id = _decode_cursor(cursor) query = query.filter( or_( CampaignCollaborationEntry.created_at < created_at, and_( CampaignCollaborationEntry.created_at == created_at, CampaignCollaborationEntry.id < entry_id, ), ) ) rows = ( query.order_by( CampaignCollaborationEntry.created_at.desc(), CampaignCollaborationEntry.id.desc(), ) .limit(limit + 1) .all() ) has_more = len(rows) > limit items = rows[:limit] return CampaignCollaborationListResponse( items=[_entry_response(item) for item in items], next_cursor=_encode_cursor(items[-1]) if has_more and items else None, has_more=has_more, ) @router.post( "/{campaign_id}/collaboration", response_model=CampaignCollaborationEntryResponse, status_code=status.HTTP_201_CREATED, ) def create_campaign_collaboration_entry( campaign_id: str, payload: CampaignCollaborationCreateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaigns:discussion:post")), ) -> CampaignCollaborationEntryResponse: campaign = _get_campaign_for_principal(session, campaign_id, principal) _require_permission(principal, "campaigns:campaign:read") if payload.visibility == "moderators": _require_permission(principal, "campaigns:discussion:moderate") reference = _validated_reference( session, campaign=campaign, reference=payload.reference, ) mentions = _validated_mentions( session, campaign=campaign, user_ids=payload.mention_user_ids, actor_user_id=principal.user.id, ) actor_label = ( getattr(principal.user, "display_name", None) or getattr(principal.user, "email", None) or principal.user.id ) entry = CampaignCollaborationEntry( tenant_id=principal.tenant_id, campaign_id=campaign.id, campaign_version_id=reference[0] if reference else None, reference_kind=reference[1] if reference else None, reference_id=reference[2] if reference else None, reference_label=reference[3] if reference else None, actor_user_id=principal.user.id, actor_label_snapshot=str(actor_label)[:255], visibility=payload.visibility, content=payload.content, content_sha256=hashlib.sha256(payload.content.encode("utf-8")).hexdigest(), mention_user_ids=mentions, ) session.add(entry) session.flush() _enqueue_mention_notifications( session, campaign=campaign, entry=entry, mention_user_ids=mentions, ) audit_from_principal( session, principal, action="campaign.collaboration.posted", object_type="campaign_collaboration_entry", object_id=entry.id, details={ "campaign_id": campaign.id, "campaign_version_id": entry.campaign_version_id, "visibility": entry.visibility, "reference_kind": entry.reference_kind, "reference_id": entry.reference_id, "mention_count": len(mentions), "content_sha256": entry.content_sha256, "content_disclosed": False, }, commit=True, ) session.refresh(entry) return _entry_response(entry) @router.post( "/{campaign_id}/collaboration/{entry_id}/withdraw", response_model=CampaignCollaborationEntryResponse, ) def withdraw_campaign_collaboration_entry( campaign_id: str, entry_id: str, payload: CampaignCollaborationModerationRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaigns:discussion:post")), ) -> CampaignCollaborationEntryResponse: _get_campaign_for_principal(session, campaign_id, principal) _require_permission(principal, "campaigns:campaign:read") entry = _entry_for_campaign(session, campaign_id=campaign_id, entry_id=entry_id, principal=principal) if entry.actor_user_id != principal.user.id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Only the author can withdraw this collaboration entry.", ) if entry.redacted_at is not None or entry.withdrawn_at is not None: return _entry_response(entry) entry.content = None entry.withdrawn_at = utc_now() entry.withdrawn_by_user_id = principal.user.id entry.tombstone_reason = payload.reason audit_from_principal( session, principal, action="campaign.collaboration.withdrawn", object_type="campaign_collaboration_entry", object_id=entry.id, details={ "campaign_id": campaign_id, "content_sha256": entry.content_sha256, "reason_recorded": bool(payload.reason), "content_disclosed": False, }, commit=True, ) session.refresh(entry) return _entry_response(entry) @router.post( "/{campaign_id}/collaboration/{entry_id}/redact", response_model=CampaignCollaborationEntryResponse, ) def redact_campaign_collaboration_entry( campaign_id: str, entry_id: str, payload: CampaignCollaborationModerationRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaigns:discussion:moderate")), ) -> CampaignCollaborationEntryResponse: _get_campaign_for_principal(session, campaign_id, principal) _require_permission(principal, "campaigns:campaign:read") entry = _entry_for_campaign(session, campaign_id=campaign_id, entry_id=entry_id, principal=principal) if entry.redacted_at is not None: return _entry_response(entry) entry.content = None entry.redacted_at = utc_now() entry.redacted_by_user_id = principal.user.id entry.tombstone_reason = payload.reason audit_from_principal( session, principal, action="campaign.collaboration.redacted", object_type="campaign_collaboration_entry", object_id=entry.id, details={ "campaign_id": campaign_id, "content_sha256": entry.content_sha256, "reason_recorded": bool(payload.reason), "previously_withdrawn": entry.withdrawn_at is not None, "content_disclosed": False, }, commit=True, ) session.refresh(entry) return _entry_response(entry) def _entry_for_campaign( session: Session, *, campaign_id: str, entry_id: str, principal: ApiPrincipal, ) -> CampaignCollaborationEntry: entry = session.get(CampaignCollaborationEntry, entry_id) if ( entry is None or entry.tenant_id != principal.tenant_id or entry.campaign_id != campaign_id ): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Collaboration entry not found") return entry def _validated_reference( session: Session, *, campaign: Campaign, reference: CampaignCollaborationReferenceInput | None, ) -> tuple[str | None, str, str, str] | None: if reference is None: return None kind = reference.kind reference_id = reference.id version: CampaignVersion | None = None default_label = kind.replace("_", " ").title() if kind == "campaign_version": version = session.get(CampaignVersion, reference_id) if version is not None: default_label = f"Version {version.version_number}" elif kind == "delivery_job": job = session.get(CampaignJob, reference_id) if job is None or job.campaign_id != campaign.id or job.tenant_id != campaign.tenant_id: raise _invalid_reference() version = session.get(CampaignVersion, job.campaign_version_id) default_label = f"Delivery job {job.id[:8]}" elif kind in {"recipient_import_batch", "attachment_rule"}: version_id, separator, child_id = reference_id.partition(":") if not separator or not version_id or not child_id: raise _invalid_reference() version = session.get(CampaignVersion, version_id) if version is not None and kind == "recipient_import_batch": raw = version.raw_json if isinstance(version.raw_json, dict) else {} entries = raw.get("entries") if isinstance(raw.get("entries"), dict) else {} imports = entries.get("imports") if isinstance(entries, dict) else [] if not any(isinstance(item, dict) and str(item.get("id") or "") == child_id for item in imports or []): raise _invalid_reference() default_label = "Recipient import batch" elif version is not None: raw = version.raw_json if isinstance(version.raw_json, dict) else {} if child_id not in {path for path, _rule in _attachment_rules(raw)}: raise _invalid_reference() default_label = "Attachment rule" else: referenced_campaign_id, separator, remainder = reference_id.partition(":") version_id, separator_two, report_kind = remainder.partition(":") if ( not separator or not separator_two or referenced_campaign_id != campaign.id or not version_id or not report_kind ): raise _invalid_reference() version = session.get(CampaignVersion, version_id) default_label = report_kind.replace("_", " ").title() if version is None or version.campaign_id != campaign.id: raise _invalid_reference() return version.id, kind, reference_id, default_label[:255] def _invalid_reference() -> HTTPException: return HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="The collaboration reference is not stable evidence owned by this campaign.", ) def _validated_mentions( session: Session, *, campaign: Campaign, user_ids: list[str], actor_user_id: str, ) -> list[str]: mentions = [user_id for user_id in user_ids if user_id != actor_user_id] invalid = [ user_id for user_id in mentions if not _mentioned_user_has_campaign_access( session, campaign=campaign, user_id=user_id, ) ] if invalid: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Mentioned users must be active and already have access to this campaign.", ) return mentions def _mentioned_user_has_campaign_access( session: Session, *, campaign: Campaign, user_id: str, ) -> bool: directory = _access_directory() user = next( (candidate for candidate in directory.users_for_tenant(campaign.tenant_id) if candidate.id == user_id), None, ) if user is None or user.status != "active": return False if campaign.owner_user_id == user_id: return True group_ids = { group.id for group in directory.groups_for_user(user_id, tenant_id=campaign.tenant_id) } if campaign.owner_group_id and campaign.owner_group_id in group_ids: return True clauses = [ and_( CampaignShare.target_type == "user", CampaignShare.target_id == user_id, ) ] if group_ids: clauses.append( and_( CampaignShare.target_type == "group", CampaignShare.target_id.in_(sorted(group_ids)), ) ) return ( session.query(CampaignShare.id) .filter( CampaignShare.tenant_id == campaign.tenant_id, CampaignShare.campaign_id == campaign.id, CampaignShare.revoked_at.is_(None), or_(*clauses), ) .first() is not None ) def _enqueue_mention_notifications( session: Session, *, campaign: Campaign, entry: CampaignCollaborationEntry, mention_user_ids: list[str], ) -> None: provider = notification_dispatch_provider(get_registry()) if provider is None or not mention_user_ids: return try: with session.begin_nested(): for user_id in mention_user_ids: provider.enqueue_notification( session, NotificationDispatchRequest( tenant_id=campaign.tenant_id, source_module="campaigns", source_resource_type="campaign_collaboration_entry", source_resource_id=entry.id, event_kind="campaign.collaboration.mentioned", channel="inbox", recipient_type="user", recipient_id=user_id, subject=f"Mentioned in campaign: {campaign.name}", body_text=( f"{entry.actor_label_snapshot} mentioned you in the campaign collaboration thread." ), action_url=f"/campaigns/{campaign.id}/activity", payload={ "campaign_id": campaign.id, "entry_id": entry.id, "content_disclosed": False, }, ), enqueue_delivery=False, ) except Exception: # Collaboration remains available when the optional Notifications # provider is absent or temporarily unhealthy. return def _entry_response(entry: CampaignCollaborationEntry) -> CampaignCollaborationEntryResponse: tombstone: Literal["withdrawn", "redacted"] | None = None if entry.redacted_at is not None: tombstone = "redacted" elif entry.withdrawn_at is not None: tombstone = "withdrawn" reference = None if entry.reference_kind and entry.reference_id: reference = CampaignCollaborationReferenceResponse( kind=entry.reference_kind, # type: ignore[arg-type] id=entry.reference_id, label=entry.reference_label, ) return CampaignCollaborationEntryResponse( id=entry.id, campaign_id=entry.campaign_id, actor_user_id=entry.actor_user_id, actor_label=entry.actor_label_snapshot, visibility=entry.visibility, # type: ignore[arg-type] content=entry.content if tombstone is None else None, content_sha256=entry.content_sha256, mention_user_ids=list(entry.mention_user_ids or []), reference=reference, tombstone=tombstone, tombstone_reason=entry.tombstone_reason, withdrawn_at=entry.withdrawn_at, redacted_at=entry.redacted_at, created_at=entry.created_at, ) def _encode_cursor(entry: CampaignCollaborationEntry) -> str: created_at = entry.created_at if created_at.tzinfo is None: # SQLite returns timezone-aware columns as naive UTC values. created_at = created_at.replace(tzinfo=UTC) payload = json.dumps( {"created_at": created_at.astimezone(UTC).isoformat(), "id": entry.id}, separators=(",", ":"), sort_keys=True, ).encode("utf-8") return base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=") def _decode_cursor(value: str) -> tuple[datetime, str]: try: padded = value + "=" * (-len(value) % 4) payload = json.loads(base64.urlsafe_b64decode(padded).decode("utf-8")) created_at = datetime.fromisoformat(str(payload["created_at"])) entry_id = str(payload["id"]) if created_at.tzinfo is None or not entry_id or len(entry_id) > 36: raise ValueError return created_at, entry_id except ( KeyError, TypeError, ValueError, UnicodeDecodeError, json.JSONDecodeError, binascii.Error, ) as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Invalid collaboration cursor.", ) from exc