"""Endpoint /internal/* chiamati dal BE Gepafin (polling + callback). Auth: header X-Internal-Secret con valore settings.internal_secret. Non passa per JWT utente — e comunicazione M2M tra servizi. Flusso: 1. BE poller chiama GET /internal/remission-amendments?status=pending-pec 2. Per ogni item chiama GET /internal/remission-amendments/{id} per dettagli 3. BE compone PEC (template per-hub), chiama PEC Massiva / Mailgun 4. BE callback POST /internal/remission-amendments/{id}/mark-pec-sent (o failed) Il microservizio resta tenant-agnostic: non conosce hub_id, non tocca PEC. """ from datetime import datetime, timezone from uuid import UUID from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Header, Query from fastapi.responses import FileResponse from sqlalchemy.orm import Session from sqlalchemy import and_, or_, text from ..storage import BASE_PATH, StorageError from ..db import get_db from ..config import get_settings, Settings from ..models import RemissionAmendmentRequest, RemissionPractice from ..schemas import ( ApiResponse, AmendmentPendingPecOut, AmendmentPecDetail, MarkPecSent, MarkPecFailed, AmendmentStatus ) router = APIRouter(prefix="/internal/remission-amendments", tags=["internal"]) def _check_internal_auth( x_internal_secret: Optional[str] = Header(None, alias="X-Internal-Secret"), settings: Settings = Depends(get_settings), ): """Valida shared secret. In PROD aggiungere anche IP allowlist via middleware.""" if not x_internal_secret or x_internal_secret != settings.internal_secret: raise HTTPException(status_code=401, detail="Invalid internal secret") return True def _fetch_application_id(db: Session, practice_id: UUID) -> int: """Recupera application_id dalla pratica. Il BE lo userà per risolvere hub/tenant.""" p = db.query(RemissionPractice).filter(RemissionPractice.id == practice_id).first() if not p: raise HTTPException(status_code=404, detail="Pratica non trovata") return p.application_id @router.get("", response_model=ApiResponse) def list_pending_pec( status_filter: str = Query("pending-pec", alias="status", description="pending-pec (nuove AWAITING senza PEC), pending-reminder (retry richiesto)"), since: Optional[datetime] = Query(None, description="ISO datetime, filtra updated_at >= since"), limit: int = Query(50, ge=1, le=500), db: Session = Depends(get_db), _: bool = Depends(_check_internal_auth), ): """Lista amendment da processare (polling BE). Due filtri: - pending-pec: status=AWAITING AND pec_sent_at IS NULL (prime invio) - pending-reminder: status=AWAITING AND pec_sent_at IS NOT NULL AND pec_retry_after IS NOT NULL """ q = db.query(RemissionAmendmentRequest) if status_filter == "pending-pec": q = q.filter( RemissionAmendmentRequest.status == AmendmentStatus.AWAITING.value, RemissionAmendmentRequest.pec_sent_at.is_(None), ) elif status_filter == "pending-reminder": q = q.filter( RemissionAmendmentRequest.status == AmendmentStatus.AWAITING.value, RemissionAmendmentRequest.pec_sent_at.isnot(None), RemissionAmendmentRequest.pec_retry_after.isnot(None), ) else: raise HTTPException(status_code=422, detail="status deve essere pending-pec o pending-reminder") if since is not None: q = q.filter(RemissionAmendmentRequest.updated_at >= since) q = q.order_by(RemissionAmendmentRequest.created_at.asc()).limit(limit) results = q.all() items = [] for ar in results: application_id = _fetch_application_id(db, ar.practice_id) items.append(AmendmentPendingPecOut( id=ar.id, practice_id=ar.practice_id, application_id=application_id, request_text=ar.request_text, deadline=ar.deadline, response_days=ar.response_days, amendment_document_path=ar.amendment_document_path, created_at=ar.created_at, ).model_dump(mode="json")) return ApiResponse(message=f"{len(items)} amendment pending", data={"items": items, "count": len(items)}) @router.get("/{amendment_id}", response_model=ApiResponse) def get_amendment_detail( amendment_id: UUID, db: Session = Depends(get_db), _: bool = Depends(_check_internal_auth), ): """Dettaglio completo per comporre PEC lato BE. Include application_id, company_id, call_id, sequence_number (per il titolo 'II fase 2021', ecc.).""" ar = db.query(RemissionAmendmentRequest).filter( RemissionAmendmentRequest.id == amendment_id ).first() if not ar: raise HTTPException(status_code=404, detail="Amendment non trovata") p = db.query(RemissionPractice).filter(RemissionPractice.id == ar.practice_id).first() if not p: raise HTTPException(status_code=404, detail="Pratica collegata non trovata") # serve company_id + call_id: il BE li dovrebbe gia sapere da application_id, # ma glieli restituiamo pure qui per evitare join extra lato loro. # Non avendo accesso a application/call nel microservizio (sono su gepafin_schema), # facciamo una SELECT diretta. row = db.execute(text(""" SELECT a.company_id, a.call_id FROM gepafin_schema.application a WHERE a.id = :app_id """), {"app_id": p.application_id}).fetchone() if not row: raise HTTPException(status_code=404, detail=f"Application {p.application_id} non trovata") company_id, call_id = row detail = AmendmentPecDetail( id=ar.id, practice_id=p.id, application_id=p.application_id, company_id=company_id, call_id=call_id, sequence_number=p.sequence_number, period_label=p.period_label, request_text=ar.request_text, deadline=ar.deadline, response_days=ar.response_days, amendment_document_path=ar.amendment_document_path, ) return ApiResponse(message="ok", data=detail.model_dump(mode="json")) @router.post("/{amendment_id}/mark-pec-sent", response_model=ApiResponse) def mark_pec_sent( amendment_id: UUID, body: MarkPecSent, db: Session = Depends(get_db), _: bool = Depends(_check_internal_auth), ): """Callback dal BE: PEC inviata con successo. Salva protocol_id + email_log_id + ts.""" ar = db.query(RemissionAmendmentRequest).filter( RemissionAmendmentRequest.id == amendment_id ).first() if not ar: raise HTTPException(status_code=404, detail="Amendment non trovata") if ar.status != AmendmentStatus.AWAITING.value: raise HTTPException(status_code=409, detail=f"mark-pec-sent atteso solo su AWAITING (attuale: {ar.status})") ar.protocol_id = body.protocol_id ar.email_log_id = body.email_log_id ar.user_action_id = body.user_action_id ar.pec_sent_at = body.pec_sent_at or datetime.now(timezone.utc) ar.pec_failed_reason = None ar.pec_retry_after = None # reset retry flag (era usato come "send reminder") db.commit() return ApiResponse(message="PEC marcata come inviata", data={"id": str(amendment_id), "protocol_id": body.protocol_id, "pec_sent_at": ar.pec_sent_at.isoformat()}) @router.post("/{amendment_id}/mark-pec-failed", response_model=ApiResponse) def mark_pec_failed( amendment_id: UUID, body: MarkPecFailed, db: Session = Depends(get_db), _: bool = Depends(_check_internal_auth), ): """Callback dal BE: PEC fallita. Salva motivazione + eventuale retry_after.""" ar = db.query(RemissionAmendmentRequest).filter( RemissionAmendmentRequest.id == amendment_id ).first() if not ar: raise HTTPException(status_code=404, detail="Amendment non trovata") ar.pec_failed_reason = body.reason[:2000] # limite safety ar.pec_retry_after = body.retry_after db.commit() return ApiResponse(message="PEC marcata come fallita", data={"id": str(amendment_id), "reason": body.reason[:200]}) def _resolve_amendment_file(amendment_id: UUID, db: Session, kind: str) -> tuple: """Risolve il path fisico di un allegato amendment. kind: 'instructor' (amendment_document_path) | 'response' (response_document_path). Ritorna tuple (abs_path, mime, safe_filename_hint). Solleva HTTPException(404) se amendment non esiste o non ha il file. Hardening: path deve restare dentro BASE_PATH (no traversal). """ ar = db.query(RemissionAmendmentRequest).filter( RemissionAmendmentRequest.id == amendment_id ).first() if not ar: raise HTTPException(status_code=404, detail="Amendment non trovata") if kind == "instructor": rel_path, mime = ar.amendment_document_path, ar.amendment_document_type elif kind == "response": rel_path, mime = ar.response_document_path, ar.response_document_type else: raise HTTPException(status_code=422, detail=f"kind invalido: {kind}") if not rel_path: raise HTTPException(status_code=404, detail=f"Nessun documento {kind} per amendment {amendment_id}") abs_path = BASE_PATH / rel_path try: abs_path.resolve().relative_to(BASE_PATH.resolve()) except ValueError: raise HTTPException(status_code=500, detail="Path non valido (hardening)") if not abs_path.is_file(): raise HTTPException(status_code=404, detail=f"File non presente su storage: {rel_path}") # filename hint = tail dopo l'ultimo "-" (struttura {sha}-{nome.ext}) base = abs_path.name if "-" in base: safe_name = base.split("-", 1)[1] else: safe_name = base return abs_path, (mime or "application/pdf"), safe_name @router.get("/{amendment_id}/document") def get_amendment_document( amendment_id: UUID, db: Session = Depends(get_db), _: bool = Depends(_check_internal_auth), ): """Scarica il PDF allegato dall'istruttore al soccorso (binary stream). Usato dal poller BE per archiviare su S3 nel folder pratica. """ abs_path, mime, safe_name = _resolve_amendment_file(amendment_id, db, "instructor") return FileResponse( path=str(abs_path), media_type=mime, headers={"Content-Disposition": f'attachment; filename="{safe_name}"'}, ) @router.get("/{amendment_id}/response-document") def get_response_document( amendment_id: UUID, db: Session = Depends(get_db), _: bool = Depends(_check_internal_auth), ): """Scarica il PDF allegato dal beneficiario come risposta (binary stream). Usato dal poller BE per archiviare su S3 nel folder pratica. """ abs_path, mime, safe_name = _resolve_amendment_file(amendment_id, db, "response") return FileResponse( path=str(abs_path), media_type=mime, headers={"Content-Disposition": f'attachment; filename="{safe_name}"'}, )