package api import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "regexp" "strconv" "time" "git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/growthreport" ) var growthReportIDPattern = regexp.MustCompile(`^growth_[a-f0-9]{24}$`) type growthReportService interface { Create(context.Context, int64, int64, growthreport.CreateRequest, string) (growthreport.Record, bool, error) Get(context.Context, int64, string) (growthreport.Record, error) Latest(context.Context, int64, string) (growthreport.Record, error) } func (s *Server) createGrowthReport(w http.ResponseWriter, r *http.Request) { if s.growth == 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 growthreport.CreateRequest if err := decodeStrictJSON(w, r, &input); err != nil || len(input.TerminalID) > 128 { s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid growth report request") return } session := sessionFrom(r.Context()) record, cached, err := s.growth.Create(r.Context(), session.UserID, session.MerchantID, input, key) if err != nil { s.handleGrowthCreateError(w, r, err) return } status := http.StatusAccepted if cached { status = http.StatusOK } w.Header().Set("Location", "/api/v1/analytics/growth-reports/"+record.ReportID) s.writeJSON(w, status, record.Report) } func (s *Server) handleGrowthCreateError(w http.ResponseWriter, r *http.Request, err error) { var limit *growthreport.RefreshLimitError switch { case errors.Is(err, growthreport.ErrDisabled): s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found") case errors.Is(err, growthreport.ErrUnknownTerminal): s.problem(w, r, 422, "VALIDATION_FAILED", "Unknown terminal") case errors.Is(err, growthreport.ErrIdempotencyConflict): s.problem(w, r, 409, "IDEMPOTENCY_CONFLICT", "Idempotency key was already used for another request") case errors.As(err, &limit): seconds := max(int64(1), int64(time.Until(limit.NextRefreshAt).Seconds())) w.Header().Set("Retry-After", strconv.FormatInt(seconds, 10)) w.Header().Set("Content-Type", "application/problem+json") w.WriteHeader(429) _ = 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": "Daily refresh is not available yet", "instance": r.URL.Path, "requestId": w.Header().Get("X-Request-ID"), "nextRefreshAt": limit.NextRefreshAt}) case errors.Is(err, growthreport.ErrRateLimited): s.problem(w, r, 429, "RATE_LIMITED", "Too many growth report requests") default: s.logger.Error("growth report creation failed", "error", err) s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Growth analytics unavailable") } } func (s *Server) latestGrowthReport(w http.ResponseWriter, r *http.Request) { if s.growth == nil { s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found") return } for key := range r.URL.Query() { if key != "terminalId" { s.problem(w, r, 422, "VALIDATION_FAILED", "Unknown query parameter") return } } terminalID := r.URL.Query().Get("terminalId") if len(terminalID) > 128 { s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid terminal") return } record, err := s.growth.Latest(r.Context(), sessionFrom(r.Context()).MerchantID, terminalID) s.writeGrowthResult(w, r, record, err) } func (s *Server) getGrowthReport(w http.ResponseWriter, r *http.Request) { if s.growth == nil { s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found") return } id := r.PathValue("reportId") if !growthReportIDPattern.MatchString(id) { s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found") return } record, err := s.growth.Get(r.Context(), sessionFrom(r.Context()).MerchantID, id) s.writeGrowthResult(w, r, record, err) } func (s *Server) writeGrowthResult(w http.ResponseWriter, r *http.Request, record growthreport.Record, err error) { if errors.Is(err, growthreport.ErrNotFound) { s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found") return } if err != nil { s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Growth analytics unavailable") return } s.writeJSON(w, 200, record.Report) } func (s *Server) growthReportEvents(w http.ResponseWriter, r *http.Request) { if s.growth == nil { s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found") return } id := r.PathValue("reportId") if !growthReportIDPattern.MatchString(id) { s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found") return } merchantID := sessionFrom(r.Context()).MerchantID record, err := s.growth.Get(r.Context(), merchantID, id) if errors.Is(err, growthreport.ErrNotFound) { s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found") return } if err != nil { s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Growth analytics unavailable") return } flusher, ok := w.(http.Flusher) if !ok { s.problem(w, r, 500, "INTERNAL_ERROR", "Streaming 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") ticker := time.NewTicker(2 * time.Second) defer ticker.Stop() heartbeat := time.NewTicker(15 * time.Second) defer heartbeat.Stop() last := "" for { eventID := record.ReportID + ":" + strconv.FormatInt(record.UpdatedAt.UnixNano(), 10) if eventID != last { payload, _ := json.Marshal(map[string]any{"eventId": eventID, "report": record.Report}) _, _ = fmt.Fprintf(w, "id: %s\nevent: report.snapshot\ndata: %s\n\n", eventID, payload) flusher.Flush() last = eventID } if record.Status.Terminal() { return } select { case <-r.Context().Done(): return case <-ticker.C: record, err = s.growth.Get(r.Context(), merchantID, id) if err != nil { return } case <-heartbeat.C: _, _ = fmt.Fprint(w, ": heartbeat\n\n") flusher.Flush() } } } func validIdempotencyKey(key string) bool { if len(key) < 8 || len(key) > 128 { return false } for _, value := range []byte(key) { if value < 0x21 || value > 0x7e { return false } } return true } func decodeStrictJSON(w http.ResponseWriter, r *http.Request, value any) error { decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)) decoder.DisallowUnknownFields() if err := decoder.Decode(value); err != nil { return err } var extra any if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { return errors.New("trailing JSON") } return nil }