package admin import ( "encoding/csv" "errors" "fmt" "io" "net/http" "strings" "github.com/go-chi/chi/v5" "github.com/ultisuite/ulti-backend/internal/api/apiresponse" "github.com/ultisuite/ulti-backend/internal/api/apivalidate" "github.com/ultisuite/ulti-backend/internal/api/query" migr "github.com/ultisuite/ulti-backend/internal/migration" ) const maxAdminMailRequestBody = 1 << 20 func (h *Handler) registerMailAdminRoutes(r chi.Router, read, write func(http.Handler) http.Handler) { if h.svc.hosted == nil && h.svc.migration == nil { return } r.Route("/mail", func(r chi.Router) { if h.svc.hosted != nil { r.With(read).Get("/domains", h.ListMailDomains) r.With(write).Post("/domains", h.CreateMailDomain) r.With(read).Get("/domains/{domainID}", h.GetMailDomain) r.With(write).Post("/domains/{domainID}/verify-txt", h.VerifyMailDomainTXT) r.With(write).Post("/domains/{domainID}/verify-mx", h.VerifyMailDomainMX) } }) r.Route("/migration", func(r chi.Router) { if h.svc.migration == nil { return } r.With(read).Get("/projects", h.ListMigrationProjects) r.With(write).Post("/projects", h.CreateMigrationProject) r.With(write).Post("/projects/{projectID}/activate", h.ActivateMigrationProject) r.With(read).Get("/projects/{projectID}/cutover-dns", h.PreflightMigrationCutoverDNS) r.With(write).Post("/projects/{projectID}/cutover", h.StartMigrationCutover) r.With(write).Post("/projects/{projectID}/invites", h.CreateMigrationInvite) r.With(write).Post("/projects/{projectID}/invites/import", h.ImportMigrationInvites) r.With(read).Get("/projects/{projectID}/roster", h.ListMigrationRoster) r.With(write).Post("/projects/{projectID}/roster", h.ImportMigrationRoster) r.With(read).Get("/projects/{projectID}/jobs", h.ListMigrationProjectJobs) r.With(read).Get("/projects/{projectID}/jobs/{jobID}/audit", h.ListMigrationJobAudit) r.With(read).Get("/projects/{projectID}/jobs/{jobID}/audit/summary", h.MigrationJobAuditSummary) r.With(read).Get("/projects/{projectID}/jobs/{jobID}/audit/export", h.ExportMigrationJobAudit) r.With(read).Get("/projects/{projectID}/audit/export", h.ExportMigrationProjectAudit) r.With(write).Post("/projects/{projectID}/jobs/retry-failed", h.RetryMigrationFailedJobs) r.With(write).Post("/projects/{projectID}/jobs/{jobID}/retry", h.RetryMigrationJob) r.With(write).Post("/projects/{projectID}/jobs/{jobID}/reset-cursor", h.ResetMigrationJobCursor) r.With(write).Patch("/projects/{projectID}/shared-drive-mode", h.UpdateMigrationSharedDriveMode) r.With(read).Get("/projects/{projectID}/shared-drives", h.ListMigrationSharedDrives) r.With(write).Post("/projects/{projectID}/shared-drives/{driveID}/approve", h.ApproveMigrationSharedDrive) r.With(write).Post("/projects/{projectID}/shared-drives/{driveID}/reject", h.RejectMigrationSharedDrive) r.With(read).Get("/microsoft/admin-consent-url", h.MicrosoftMigrationAdminConsentURL) r.With(read).Get("/microsoft/admin-consents", h.ListMicrosoftAdminConsents) }) } func (h *Handler) ListMailDomains(w http.ResponseWriter, r *http.Request) { rows, err := h.svc.hosted.ListDomains(r.Context()) if err != nil { apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"domains": rows}) } type createMailDomainRequest struct { Name string `json:"name"` } func (h *Handler) CreateMailDomain(w http.ResponseWriter, r *http.Request) { var req createMailDomainRequest if err := apivalidate.DecodeJSON(w, r, maxAdminMailRequestBody, &req); err != nil { return } row, err := h.svc.hosted.CreateDomain(r.Context(), req.Name, false) if err != nil { apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusCreated, row) } func (h *Handler) GetMailDomain(w http.ResponseWriter, r *http.Request) { row, err := h.svc.hosted.GetDomain(r.Context(), chi.URLParam(r, "domainID")) if err != nil { apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusOK, row) } func (h *Handler) VerifyMailDomainTXT(w http.ResponseWriter, r *http.Request) { domainID := chi.URLParam(r, "domainID") row, report, err := h.svc.hosted.VerifyDomainTXTRecord(r.Context(), domainID) if err != nil { apiresponse.WriteError(w, r, http.StatusBadRequest, "dns_txt_not_verified", err.Error(), map[string]any{"dns": report}) return } apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"domain": row, "dns": report}) } func (h *Handler) VerifyMailDomainMX(w http.ResponseWriter, r *http.Request) { domainID := chi.URLParam(r, "domainID") expected := h.migrationCutoverConfig().ExpectedMXHosts row, report, err := h.svc.hosted.VerifyDomainMXRecord(r.Context(), domainID, expected) if err != nil { apiresponse.WriteError(w, r, http.StatusBadRequest, "dns_mx_not_verified", err.Error(), map[string]any{"dns": report}) return } apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"domain": row, "dns": report}) } type createMigrationProjectRequest struct { Name string `json:"name"` SourceProvider string `json:"source_provider"` DomainID string `json:"domain_id"` AuthMode string `json:"auth_mode"` } func (h *Handler) CreateMigrationProject(w http.ResponseWriter, r *http.Request) { var req createMigrationProjectRequest if err := apivalidate.DecodeJSON(w, r, maxAdminMailRequestBody, &req); err != nil { return } row, err := h.svc.migration.CreateProject(r.Context(), req.Name, req.SourceProvider, req.DomainID, req.AuthMode) if err != nil { apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusCreated, row) } func (h *Handler) ListMigrationProjects(w http.ResponseWriter, r *http.Request) { rows, err := h.svc.migration.ListProjects(r.Context()) if err != nil { apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"projects": rows}) } func (h *Handler) ActivateMigrationProject(w http.ResponseWriter, r *http.Request) { row, err := h.svc.migration.ActivateProject(r.Context(), chi.URLParam(r, "projectID")) if err != nil { apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusOK, row) } func (h *Handler) PreflightMigrationCutoverDNS(w http.ResponseWriter, r *http.Request) { if h.svc.migration == nil { apivalidate.WriteInternal(w, r) return } report, err := h.svc.migration.PreflightCutoverDNS(r.Context(), chi.URLParam(r, "projectID"), h.migrationCutoverConfig()) if err != nil { apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"dns": report}) } func (h *Handler) StartMigrationCutover(w http.ResponseWriter, r *http.Request) { result, err := h.svc.migration.StartCutover(r.Context(), chi.URLParam(r, "projectID")) if errors.Is(err, migr.ErrCutoverMXNotReady) { apiresponse.WriteError(w, r, http.StatusConflict, "migration_cutover_mx_not_ready", err.Error(), map[string]any{ "dns": result.DNS, "project": result.Project, }) return } if err != nil { apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusOK, result) } func (h *Handler) migrationCutoverConfig() migr.CutoverConfig { if h.svc.cfg == nil { return migr.CutoverConfig{} } return migr.CutoverConfig{ ExpectedMXHosts: migr.ParseCutoverMXHosts( h.svc.cfg.MigrationCutoverMXHosts, h.svc.cfg.PlatformMailDomain, h.svc.cfg.StalwartIMAPHost, ), RequireMX: h.svc.cfg.MigrationCutoverRequireMX, } } type createMigrationInviteRequest struct { Email string `json:"email"` AlternateEmails []string `json:"alternate_emails,omitempty"` } func (h *Handler) CreateMigrationInvite(w http.ResponseWriter, r *http.Request) { var req createMigrationInviteRequest if err := apivalidate.DecodeJSON(w, r, maxAdminMailRequestBody, &req); err != nil { return } row, err := h.svc.migration.CreateInvite(r.Context(), chi.URLParam(r, "projectID"), req.Email, req.AlternateEmails) if err != nil { apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusCreated, row) } func (h *Handler) ImportMigrationInvites(w http.ResponseWriter, r *http.Request) { var emails []string contentType := r.Header.Get("Content-Type") if strings.Contains(contentType, "multipart/form-data") { file, _, err := r.FormFile("file") if err == nil { defer file.Close() reader := csv.NewReader(file) for { record, err := reader.Read() if err == io.EOF { break } if err != nil { apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(apivalidate.FieldDetail{ Field: "file", Message: "invalid csv", })) return } if len(record) > 0 { emails = append(emails, record[0]) } } } } if len(emails) == 0 { var body struct { Emails []string `json:"emails"` } if err := apivalidate.DecodeJSON(w, r, maxAdminMailRequestBody, &body); err != nil { return } emails = body.Emails } count, err := h.svc.migration.ImportInvites(r.Context(), chi.URLParam(r, "projectID"), emails) if err != nil { apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"imported": count}) } func (h *Handler) ListMigrationRoster(w http.ResponseWriter, r *http.Request) { if h.svc.migration == nil { apivalidate.WriteInternal(w, r) return } rows, err := h.svc.migration.ListRoster(r.Context(), chi.URLParam(r, "projectID")) if err != nil { apivalidate.WriteInternal(w, r) return } if rows == nil { rows = []migr.RosterEntry{} } apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"roster": rows}) } func (h *Handler) ImportMigrationRoster(w http.ResponseWriter, r *http.Request) { if h.svc.migration == nil { apivalidate.WriteInternal(w, r) return } projectID := chi.URLParam(r, "projectID") var inputs []migr.RosterRowInput contentType := r.Header.Get("Content-Type") if strings.Contains(contentType, "multipart/form-data") { file, _, err := r.FormFile("file") if err == nil { defer file.Close() parsed, err := migr.ParseRosterCSV(file) if err != nil { apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(apivalidate.FieldDetail{ Field: "file", Message: err.Error(), })) return } inputs = parsed } } if len(inputs) == 0 { var body struct { CSV string `json:"csv"` Rows []migr.RosterRowInput `json:"rows"` } if err := apivalidate.DecodeJSON(w, r, maxAdminMailRequestBody, &body); err != nil { return } if len(body.Rows) > 0 { inputs = body.Rows } else if strings.TrimSpace(body.CSV) != "" { parsed, err := migr.ParseRosterCSV(strings.NewReader(body.CSV)) if err != nil { apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(apivalidate.FieldDetail{ Field: "csv", Message: err.Error(), })) return } inputs = parsed } } if len(inputs) == 0 { apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(apivalidate.FieldDetail{ Field: "csv", Message: "roster csv or rows required", })) return } result, err := h.svc.migration.ImportRoster(r.Context(), projectID, inputs) if err != nil { apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusOK, result) } func (h *Handler) MicrosoftMigrationAdminConsentURL(w http.ResponseWriter, r *http.Request) { if h.svc.migration == nil { apivalidate.WriteInternal(w, r) return } consentURL, err := h.svc.migration.MicrosoftAdminConsentURL( r.URL.Query().Get("tenant"), r.URL.Query().Get("project_id"), ) if err != nil { apiresponse.WriteError(w, r, http.StatusBadRequest, "admin_consent_unavailable", err.Error(), nil) return } apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"url": consentURL}) } func (h *Handler) ListMicrosoftAdminConsents(w http.ResponseWriter, r *http.Request) { if h.svc.migration == nil { apivalidate.WriteInternal(w, r) return } rows, err := h.svc.migration.ListMicrosoftAdminConsents(r.Context()) if err != nil { apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"consents": rows}) } func (h *Handler) ListMigrationProjectJobs(w http.ResponseWriter, r *http.Request) { if h.svc.migration == nil { apivalidate.WriteInternal(w, r) return } rows, err := h.svc.migration.ListProjectJobs(r.Context(), chi.URLParam(r, "projectID")) if err != nil { apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"jobs": rows}) } func (h *Handler) RetryMigrationJob(w http.ResponseWriter, r *http.Request) { if h.svc.migration == nil { apivalidate.WriteInternal(w, r) return } row, err := h.svc.migration.RetryJob(r.Context(), chi.URLParam(r, "projectID"), chi.URLParam(r, "jobID")) if err != nil { apiresponse.WriteError(w, r, http.StatusNotFound, "migration_job_not_retryable", err.Error(), nil) return } apiresponse.WriteJSON(w, http.StatusOK, row) } func (h *Handler) ResetMigrationJobCursor(w http.ResponseWriter, r *http.Request) { if h.svc.migration == nil { apivalidate.WriteInternal(w, r) return } row, err := h.svc.migration.ResetJobCursor(r.Context(), chi.URLParam(r, "projectID"), chi.URLParam(r, "jobID")) if err != nil { status := http.StatusNotFound code := "migration_job_not_resettable" if strings.Contains(err.Error(), "running") { status = http.StatusConflict } else if strings.Contains(err.Error(), "not found") { code = "migration_job_not_found" } apiresponse.WriteError(w, r, status, code, err.Error(), nil) return } apiresponse.WriteJSON(w, http.StatusOK, row) } func (h *Handler) RetryMigrationFailedJobs(w http.ResponseWriter, r *http.Request) { if h.svc.migration == nil { apivalidate.WriteInternal(w, r) return } count, err := h.svc.migration.RetryFailedJobs(r.Context(), chi.URLParam(r, "projectID")) if err != nil { apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"retried": count}) } func (h *Handler) ListMigrationJobAudit(w http.ResponseWriter, r *http.Request) { if h.svc.migration == nil { apivalidate.WriteInternal(w, r) return } params, err := query.ParseListRequest(r) if err != nil { apivalidate.WriteQueryError(w, r, err) return } items, pagination, err := h.svc.migration.ListJobAudit( r.Context(), chi.URLParam(r, "projectID"), chi.URLParam(r, "jobID"), r.URL.Query().Get("status"), params, ) if err != nil { if strings.Contains(err.Error(), "not found") { apiresponse.WriteError(w, r, http.StatusNotFound, "migration_job_not_found", err.Error(), nil) return } apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusOK, map[string]any{ "items": items, "pagination": pagination, }) } func (h *Handler) MigrationJobAuditSummary(w http.ResponseWriter, r *http.Request) { if h.svc.migration == nil { apivalidate.WriteInternal(w, r) return } summary, err := h.svc.migration.JobAuditSummary( r.Context(), chi.URLParam(r, "projectID"), chi.URLParam(r, "jobID"), ) if err != nil { if strings.Contains(err.Error(), "not found") { apiresponse.WriteError(w, r, http.StatusNotFound, "migration_job_not_found", err.Error(), nil) return } apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusOK, summary) } func (h *Handler) ExportMigrationJobAudit(w http.ResponseWriter, r *http.Request) { if h.svc.migration == nil { apivalidate.WriteInternal(w, r) return } format, verr := validateExportFormat(r.URL.Query().Get("format")) if verr != nil { apivalidate.WriteValidationError(w, r, verr) return } projectID := chi.URLParam(r, "projectID") jobID := chi.URLParam(r, "jobID") meta, err := h.svc.migration.PrepareJobAuditExport(r.Context(), projectID, jobID, format) if err != nil { if strings.Contains(err.Error(), "not found") { apiresponse.WriteError(w, r, http.StatusNotFound, "migration_job_not_found", err.Error(), nil) return } apivalidate.WriteInternal(w, r) return } w.Header().Set("Content-Type", meta.ContentType) w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, meta.FileName)) w.WriteHeader(http.StatusOK) if err := h.svc.migration.WriteJobAuditExport( r.Context(), projectID, jobID, r.URL.Query().Get("status"), format, w, ); err != nil { h.logger.Error("export migration job audit", "error", err) } } func (h *Handler) ExportMigrationProjectAudit(w http.ResponseWriter, r *http.Request) { if h.svc.migration == nil { apivalidate.WriteInternal(w, r) return } format, verr := validateExportFormat(r.URL.Query().Get("format")) if verr != nil { apivalidate.WriteValidationError(w, r, verr) return } projectID := chi.URLParam(r, "projectID") meta, err := h.svc.migration.PrepareProjectAuditExport(r.Context(), projectID, format) if err != nil { if strings.Contains(err.Error(), "not found") { apiresponse.WriteError(w, r, http.StatusNotFound, "migration_project_not_found", err.Error(), nil) return } apivalidate.WriteInternal(w, r) return } w.Header().Set("Content-Type", meta.ContentType) w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, meta.FileName)) w.WriteHeader(http.StatusOK) if err := h.svc.migration.WriteProjectAuditExport( r.Context(), projectID, r.URL.Query().Get("status"), format, w, ); err != nil { h.logger.Error("export migration project audit", "error", err) } } type updateSharedDriveModeRequest struct { Mode string `json:"shared_drive_mode"` } func (h *Handler) UpdateMigrationSharedDriveMode(w http.ResponseWriter, r *http.Request) { if h.svc.migration == nil { apivalidate.WriteInternal(w, r) return } var req updateSharedDriveModeRequest if err := apivalidate.DecodeJSON(w, r, maxAdminMailRequestBody, &req); err != nil { return } row, err := h.svc.migration.UpdateSharedDriveMode(r.Context(), chi.URLParam(r, "projectID"), req.Mode) if err != nil { apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusOK, row) } func (h *Handler) ListMigrationSharedDrives(w http.ResponseWriter, r *http.Request) { if h.svc.migration == nil { apivalidate.WriteInternal(w, r) return } rows, err := h.svc.migration.ListSharedDrives(r.Context(), chi.URLParam(r, "projectID"), r.URL.Query().Get("status")) if err != nil { apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"shared_drives": rows}) } func (h *Handler) ApproveMigrationSharedDrive(w http.ResponseWriter, r *http.Request) { if h.svc.migration == nil { apivalidate.WriteInternal(w, r) return } row, err := h.svc.migration.ApproveSharedDrive(r.Context(), chi.URLParam(r, "projectID"), chi.URLParam(r, "driveID")) if err != nil { apiresponse.WriteError(w, r, http.StatusNotFound, "shared_drive_not_found", err.Error(), nil) return } apiresponse.WriteJSON(w, http.StatusOK, row) } func (h *Handler) RejectMigrationSharedDrive(w http.ResponseWriter, r *http.Request) { if h.svc.migration == nil { apivalidate.WriteInternal(w, r) return } row, err := h.svc.migration.RejectSharedDrive(r.Context(), chi.URLParam(r, "projectID"), chi.URLParam(r, "driveID")) if err != nil { apiresponse.WriteError(w, r, http.StatusNotFound, "shared_drive_not_found", err.Error(), nil) return } apiresponse.WriteJSON(w, http.StatusOK, row) }