package api import ( "encoding/csv" "fmt" "net/http" "strconv" "archivmail/internal/audit" ) // tenantScope returns the tenant filter for reconciliation queries: a // domain_admin (and any other tenant-scoped role) is restricted to its own // tenant, while superadmin (sess.TenantID == nil) sees all tenants. This // mirrors handleMailTimeseries and prevents cross-tenant leakage of source // figures (PROJ-55/61 tenant-isolation discipline). func (s *Server) reconTenantScope(r *http.Request) *int64 { sess := sessionFromCtx(r.Context()) if sess.TenantID != nil { return tenantFromCtx(r.Context()) } return nil } // handleReconciliation returns the last N days (default 7) of completeness // figures per source, tenant-scoped. // GET /api/admin/reconciliation?days=7 func (s *Server) handleReconciliation(w http.ResponseWriter, r *http.Request) { if s.reconStore == nil { writeError(w, http.StatusServiceUnavailable, "reconciliation not enabled") return } days := 7 if v := r.URL.Query().Get("days"); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 90 { days = n } } tid := s.reconTenantScope(r) sources, err := s.reconStore.DashboardData(r.Context(), tid, days, s.reconThresholdPct) if err != nil { s.logger.Error("reconciliation dashboard query failed", "err", err) writeError(w, http.StatusInternalServerError, "reconciliation query failed") return } writeJSON(w, http.StatusOK, map[string]interface{}{ "days": days, "threshold_pct": s.reconThresholdPct, "sources": sources, }) } // handleReconciliationExport streams the reconciliation report as CSV, // tenant-scoped (analog PROJ-11 audit export). // GET /api/admin/reconciliation/export.csv?days=30 func (s *Server) handleReconciliationExport(w http.ResponseWriter, r *http.Request) { if s.reconStore == nil { writeError(w, http.StatusServiceUnavailable, "reconciliation not enabled") return } days := 30 if v := r.URL.Query().Get("days"); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 366 { days = n } } tid := s.reconTenantScope(r) rows, err := s.reconStore.ExportRows(r.Context(), tid, days) if err != nil { s.logger.Error("reconciliation export query failed", "err", err) writeError(w, http.StatusInternalServerError, "reconciliation query failed") return } sess := sessionFromCtx(r.Context()) w.Header().Set("Content-Type", "text/csv; charset=utf-8") w.Header().Set("Content-Disposition", `attachment; filename="reconciliation.csv"`) w.WriteHeader(http.StatusOK) cw := csv.NewWriter(w) cw.Write([]string{"date", "tenant_id", "source", "expected_count", "archived_count", "delta"}) //nolint:errcheck for _, row := range rows { cw.Write([]string{ //nolint:errcheck row.Date.UTC().Format("2006-01-02"), nullableInt(row.TenantID), reconSourceKey(row.SourceType, row.SourceID), nullableInt(row.ExpectedCount), strconv.FormatInt(row.ArchivedCount, 10), nullableInt(row.Delta), }) } cw.Flush() s.audlog.Log(audit.Entry{ EventType: audit.EventExport, Username: sess.Username, TenantID: sess.TenantID, IPAddress: s.remoteIP(r), Detail: fmt.Sprintf("reconciliation csv: %d days, %d rows", days, len(rows)), Success: true, }) } // nullableInt formats a *int64 for CSV, emitting an empty string for nil. func nullableInt(v *int64) string { if v == nil { return "" } return strconv.FormatInt(*v, 10) } // reconSourceKey mirrors reconciliation.SourceKey without importing the package // into the CSV hot path (kept local and tiny). func reconSourceKey(sourceType string, sourceID *int64) string { if sourceID != nil && (sourceType == "imap" || sourceType == "pop3") { return fmt.Sprintf("%s:%d", sourceType, *sourceID) } return sourceType }