package api import ( "encoding/json" "errors" "fmt" "net/http" "strconv" "strings" "time" "git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/coach" "git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/insight" ) func (s *Server) growthCoachDashboard(w http.ResponseWriter, r *http.Request) { if s.coach == nil { s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found") return } terminalID, ok := onlyTerminalQuery(r) if !ok { s.problem(w, r, 422, "VALIDATION_FAILED", "Only terminalId is accepted") return } result, err := s.coach.Dashboard(r.Context(), sessionFrom(r.Context()).MerchantID, terminalID) if errors.Is(err, coach.ErrUnknownTerminal) { s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found") return } if err != nil { s.logger.Error("loading growth coach failed", "error", err) s.problem(w, r, 503, "GROWTH_COACH_UNAVAILABLE", "Growth coach unavailable") return } s.writeJSON(w, 200, result) } func (s *Server) growthCoachProfile(w http.ResponseWriter, r *http.Request) { if s.coach == nil { s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found") return } result, err := s.coach.Profile(r.Context(), sessionFrom(r.Context()).MerchantID, r.PathValue("terminalId")) if errors.Is(err, coach.ErrNotFound) || errors.Is(err, coach.ErrUnknownTerminal) { s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found") return } if err != nil { s.problem(w, r, 503, "GROWTH_COACH_UNAVAILABLE", "Growth coach unavailable") return } s.writeJSON(w, 200, result) } func (s *Server) saveGrowthCoachProfile(w http.ResponseWriter, r *http.Request) { if s.coach == nil { s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found") return } key := r.Header.Get("Idempotency-Key") if !validIdempotencyKey(key) { s.problem(w, r, 422, "VALIDATION_FAILED", "A valid Idempotency-Key is required") return } var input coach.ProfileInput if decodeStrictJSON(w, r, &input) != nil { s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid business profile") return } session := sessionFrom(r.Context()) profile, jobs, cached, err := s.coach.SaveProfile(r.Context(), session.MerchantID, session.UserID, r.PathValue("terminalId"), key, input) switch { case errors.Is(err, coach.ErrUnknownTerminal): s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found") return case errors.Is(err, coach.ErrIdempotencyConflict): s.problem(w, r, 409, "IDEMPOTENCY_CONFLICT", "Idempotency key was already used") return case err != nil: s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid business profile") return } status := http.StatusCreated if cached { status = http.StatusOK } if jobs == nil { jobs = []coach.Job{} } pageJobs := []insight.Job{} if !cached && s.insights != nil { for _, module := range []insight.Module{insight.ModuleOverview, insight.ModulePayments, insight.ModuleFees, insight.ModulePeers, insight.ModuleTransactions, insight.ModuleGrowth} { job, _, enqueueErr := s.insights.Enqueue(r.Context(), session.MerchantID, r.PathValue("terminalId"), module, "PROFILE:"+profile.ProfileID) if enqueueErr != nil { s.logger.Error("profile-triggered page insight scheduling failed", "error", enqueueErr, "module", module) continue } pageJobs = append(pageJobs, job) } } s.writeJSON(w, status, map[string]any{"profile": profile, "generationJobs": jobs, "pageInsightJobs": pageJobs}) } func (s *Server) refreshGrowthCoach(w http.ResponseWriter, r *http.Request) { if s.coach == nil { s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found") return } key := r.Header.Get("Idempotency-Key") if !validIdempotencyKey(key) { s.problem(w, r, 422, "VALIDATION_FAILED", "A valid Idempotency-Key is required") return } var input coach.RefreshInput if decodeStrictJSON(w, r, &input) != nil || input.Validate() != nil { s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid growth coach refresh cadence") return } job, cached, err := s.coach.Refresh(r.Context(), sessionFrom(r.Context()).MerchantID, r.PathValue("terminalId"), key, input) if errors.Is(err, coach.ErrNotFound) || errors.Is(err, coach.ErrUnknownTerminal) { s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found") return } if errors.Is(err, coach.ErrIdempotencyConflict) { s.problem(w, r, 409, "IDEMPOTENCY_CONFLICT", "Idempotency key was already used") return } var limit *coach.RefreshLimitError if errors.As(err, &limit) { retryAfter := max(0, int(time.Until(limit.NextRefreshAt).Seconds())) w.Header().Set("Retry-After", strconv.Itoa(retryAfter)) w.Header().Set("Content-Type", "application/problem+json") w.WriteHeader(http.StatusTooManyRequests) _ = json.NewEncoder(w).Encode(map[string]any{"type": "https://zarinpal.amiirkhl.ir/docs/errors/refresh-limited", "title": "Too Many Requests", "status": 429, "code": "REFRESH_LIMITED", "detail": "Weekly suggestion refresh limit reached", "instance": r.URL.Path, "requestId": w.Header().Get("X-Request-ID"), "nextRefreshAt": limit.NextRefreshAt}) return } if err != nil { s.problem(w, r, 503, "GROWTH_COACH_UNAVAILABLE", "Growth coach unavailable") return } status := http.StatusAccepted if cached { status = http.StatusOK } w.Header().Set("Location", "/api/v1/growth-coach/refreshes/"+job.JobID) s.writeJSON(w, status, job) } func (s *Server) growthCoachPlans(w http.ResponseWriter, r *http.Request) { if s.coach == nil { s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found") return } terminalID, ok := onlyTerminalQuery(r) if !ok { s.problem(w, r, 422, "VALIDATION_FAILED", "Only terminalId is accepted") return } result, err := s.coach.Plans(r.Context(), sessionFrom(r.Context()).MerchantID, terminalID) if errors.Is(err, coach.ErrUnknownTerminal) { s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found") return } if err != nil { s.problem(w, r, 503, "GROWTH_COACH_UNAVAILABLE", "Growth coach unavailable") return } s.writeJSON(w, 200, map[string]any{"plans": result}) } func (s *Server) growthCoachActions(w http.ResponseWriter, r *http.Request) { if s.coach == nil { s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found") return } terminalID, ok := onlyTerminalQuery(r) if !ok { s.problem(w, r, 422, "VALIDATION_FAILED", "Only terminalId is accepted") return } result, err := s.coach.Actions(r.Context(), sessionFrom(r.Context()).MerchantID, terminalID) if errors.Is(err, coach.ErrUnknownTerminal) { s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found") return } if err != nil { s.problem(w, r, 503, "GROWTH_COACH_UNAVAILABLE", "Growth coach unavailable") return } s.writeJSON(w, 200, map[string]any{"actions": result}) } func (s *Server) growthCoachGoals(w http.ResponseWriter, r *http.Request) { if s.coach == nil { s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found") return } terminalID, ok := onlyTerminalQuery(r) if !ok { s.problem(w, r, 422, "VALIDATION_FAILED", "Only terminalId is accepted") return } goals, err := s.coach.Goals(r.Context(), sessionFrom(r.Context()).MerchantID, terminalID) if errors.Is(err, coach.ErrUnknownTerminal) { s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found") return } if err != nil { s.problem(w, r, 503, "GROWTH_COACH_UNAVAILABLE", "Growth coach unavailable") return } s.writeJSON(w, 200, map[string]any{"goals": goals}) } func (s *Server) transitionGrowthCoachSubtask(w http.ResponseWriter, r *http.Request) { if !requireMutationKey(s, w, r) { return } var input coach.SubtaskTransitionInput if decodeStrictJSON(w, r, &input) != nil { s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid subtask transition") return } session := sessionFrom(r.Context()) result, err := s.coach.TransitionSubtask(r.Context(), session.MerchantID, session.UserID, r.PathValue("goalId"), r.PathValue("subtaskId"), r.Header.Get("Idempotency-Key"), input) switch { case errors.Is(err, coach.ErrNotFound): s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found") case errors.Is(err, coach.ErrInvalidTransition): s.problem(w, r, 409, "INVALID_TRANSITION", "This subtask transition is not allowed") case errors.Is(err, coach.ErrIdempotencyConflict): s.problem(w, r, 409, "IDEMPOTENCY_CONFLICT", "Idempotency key was already used") case err != nil: s.problem(w, r, 503, "GROWTH_COACH_UNAVAILABLE", "Growth coach unavailable") default: s.writeJSON(w, 200, result) } } func (s *Server) decideGrowthCoachDeadlineProposal(w http.ResponseWriter, r *http.Request) { if !requireMutationKey(s, w, r) { return } var input coach.DeadlineDecisionInput if decodeStrictJSON(w, r, &input) != nil { s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid deadline decision") return } session := sessionFrom(r.Context()) result, err := s.coach.DecideDeadlineProposal(r.Context(), session.MerchantID, session.UserID, r.PathValue("proposalId"), r.Header.Get("Idempotency-Key"), input) switch { case errors.Is(err, coach.ErrNotFound): s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found") case errors.Is(err, coach.ErrInvalidTransition): s.problem(w, r, 409, "INVALID_TRANSITION", "This deadline decision is not allowed") case errors.Is(err, coach.ErrIdempotencyConflict): s.problem(w, r, 409, "IDEMPOTENCY_CONFLICT", "Idempotency key was already used") case err != nil: s.problem(w, r, 503, "GROWTH_COACH_UNAVAILABLE", "Growth coach unavailable") default: s.writeJSON(w, 200, result) } } func (s *Server) decideGrowthCoachRecommendation(w http.ResponseWriter, r *http.Request) { if !requireMutationKey(s, w, r) { return } var input coach.DecisionInput if decodeStrictJSON(w, r, &input) != nil { s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid recommendation decision") return } session := sessionFrom(r.Context()) action, err := s.coach.DecideRecommendation(r.Context(), session.MerchantID, session.UserID, r.PathValue("id"), r.Header.Get("Idempotency-Key"), input) if errors.Is(err, coach.ErrNotFound) { s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found") return } if errors.Is(err, coach.ErrInvalidTransition) { s.problem(w, r, 409, "INVALID_TRANSITION", "Recommendation was already decided") return } if errors.Is(err, coach.ErrIdempotencyConflict) { s.problem(w, r, 409, "IDEMPOTENCY_CONFLICT", "Idempotency key was already used") return } if err != nil { s.problem(w, r, 503, "GROWTH_COACH_UNAVAILABLE", "Growth coach unavailable") return } s.writeJSON(w, 200, map[string]any{"decision": input.Decision, "action": action}) } func (s *Server) transitionGrowthCoachAction(w http.ResponseWriter, r *http.Request) { if !requireMutationKey(s, w, r) { return } var input coach.TransitionInput if decodeStrictJSON(w, r, &input) != nil { s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid action transition") return } session := sessionFrom(r.Context()) action, err := s.coach.Transition(r.Context(), session.MerchantID, session.UserID, r.PathValue("id"), r.Header.Get("Idempotency-Key"), input) if errors.Is(err, coach.ErrNotFound) { s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found") return } if errors.Is(err, coach.ErrInvalidTransition) { s.problem(w, r, 409, "INVALID_TRANSITION", "This transition is not allowed") return } if errors.Is(err, coach.ErrIdempotencyConflict) { s.problem(w, r, 409, "IDEMPOTENCY_CONFLICT", "Idempotency key was already used") return } if err != nil { s.problem(w, r, 503, "GROWTH_COACH_UNAVAILABLE", "Growth coach unavailable") return } s.writeJSON(w, 200, action) } func (s *Server) decideGrowthCoachDifficulty(w http.ResponseWriter, r *http.Request) { if !requireMutationKey(s, w, r) { return } var input coach.DecisionInput if decodeStrictJSON(w, r, &input) != nil { s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid difficulty decision") return } session := sessionFrom(r.Context()) proposal, err := s.coach.DecideDifficulty(r.Context(), session.MerchantID, session.UserID, r.PathValue("id"), r.Header.Get("Idempotency-Key"), input) if errors.Is(err, coach.ErrNotFound) { s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found") return } if errors.Is(err, coach.ErrInvalidTransition) { s.problem(w, r, 409, "INVALID_TRANSITION", "Difficulty proposal was already decided") return } if errors.Is(err, coach.ErrIdempotencyConflict) { s.problem(w, r, 409, "IDEMPOTENCY_CONFLICT", "Idempotency key was already used") return } if err != nil { s.problem(w, r, 503, "GROWTH_COACH_UNAVAILABLE", "Growth coach unavailable") return } s.writeJSON(w, 200, proposal) } func (s *Server) growthCoachJob(w http.ResponseWriter, r *http.Request) { if s.coach == nil { s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found") return } job, err := s.coach.GetJob(r.Context(), sessionFrom(r.Context()).MerchantID, r.PathValue("jobId")) if errors.Is(err, coach.ErrNotFound) { s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found") return } if err != nil { s.problem(w, r, 503, "GROWTH_COACH_UNAVAILABLE", "Growth coach unavailable") return } s.writeJSON(w, 200, job) } func (s *Server) growthCoachEvents(w http.ResponseWriter, r *http.Request) { if s.coach == nil { s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found") return } flusher, ok := w.(http.Flusher) if !ok { s.problem(w, r, 500, "INTERNAL_ERROR", "Streaming unavailable") return } merchantID := sessionFrom(r.Context()).MerchantID jobID := r.PathValue("jobId") if _, err := s.coach.GetJob(r.Context(), merchantID, jobID); errors.Is(err, coach.ErrNotFound) { s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found") return } else if err != nil { s.problem(w, r, 503, "GROWTH_COACH_UNAVAILABLE", "Growth coach unavailable") return } w.Header().Set("Content-Type", "text/event-stream; charset=utf-8") w.Header().Set("Cache-Control", "no-cache, no-transform") w.Header().Set("X-Accel-Buffering", "no") poll, heartbeat := time.NewTicker(2*time.Second), time.NewTicker(15*time.Second) defer poll.Stop() defer heartbeat.Stop() last, _ := strconv.ParseInt(r.Header.Get("Last-Event-ID"), 10, 64) for { events, err := s.coach.EventsAfter(r.Context(), merchantID, jobID, last) if err != nil { return } for _, event := range events { payload, _ := json.Marshal(event) _, _ = fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", event.ID, event.Type, payload) last = event.ID flusher.Flush() } job, err := s.coach.GetJob(r.Context(), merchantID, jobID) if err != nil { return } if job.State == "COMPLETED" || job.State == "FAILED" { return } select { case <-r.Context().Done(): return case <-poll.C: case <-heartbeat.C: _, _ = fmt.Fprint(w, ": heartbeat\n\n") flusher.Flush() } } } func onlyTerminalQuery(r *http.Request) (string, bool) { for key := range r.URL.Query() { if key != "terminalId" { return "", false } } value := r.URL.Query().Get("terminalId") return value, len(value) <= 128 } func requireMutationKey(s *Server, w http.ResponseWriter, r *http.Request) bool { if s.coach == nil { s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found") return false } if !validIdempotencyKey(strings.TrimSpace(r.Header.Get("Idempotency-Key"))) { s.problem(w, r, 422, "VALIDATION_FAILED", "A valid Idempotency-Key is required") return false } return true }