From d4265b662f2ffda891f982570ef2458f70dace91 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Fri, 21 Aug 2026 00:43:42 +0330 Subject: [PATCH] feat: expose authenticated growth report API --- docs/openapi.yaml | 6 +- internal/api/growth.go | 204 ++++++++++++++++++++++++++++++++++++ internal/api/growth_test.go | 129 +++++++++++++++++++++++ internal/api/server.go | 13 ++- 4 files changed, 347 insertions(+), 5 deletions(-) create mode 100644 internal/api/growth.go create mode 100644 internal/api/growth_test.go diff --git a/docs/openapi.yaml b/docs/openapi.yaml index c78acea..ee973a7 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1,10 +1,10 @@ openapi: 3.1.0 info: title: ZarinPal Merchant Analytics API - version: 1.0.0 - summary: Contract-first API for authenticated merchant analytics. + version: 1.1.0 + summary: Contract-first API for authenticated merchant analytics and growth reports. description: | - Approved MVP interface. This document is the API source of truth; generated + Approved analytics and AI growth-report interface. This document is the API source of truth; generated frontend types must come from this contract. Merchant scope is always derived from the authenticated server-side session. diff --git a/internal/api/growth.go b/internal/api/growth.go new file mode 100644 index 0000000..b4aa6ae --- /dev/null +++ b/internal/api/growth.go @@ -0,0 +1,204 @@ +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 +} diff --git a/internal/api/growth_test.go b/internal/api/growth_test.go new file mode 100644 index 0000000..6479d07 --- /dev/null +++ b/internal/api/growth_test.go @@ -0,0 +1,129 @@ +package api + +import ( + "context" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/growthreport" + "git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/identity" +) + +func TestValidIdempotencyKeyUsesPrintableAllowlist(t *testing.T) { + t.Parallel() + tests := []struct { + name, key string + want bool + }{{"valid", "growth-123456", true}, {"short", "short", false}, {"space", "growth key", false}, {"unicode", "رشد-گزارش", false}} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := validIdempotencyKey(tt.key); got != tt.want { + t.Fatalf("validIdempotencyKey(%q)=%v", tt.key, got) + } + }) + } +} +func TestDecodeStrictJSONRejectsTrailingAndUnknownFields(t *testing.T) { + t.Parallel() + for _, body := range []string{`{"refresh":true}{}`, `{"unexpected":true}`} { + request := httptest.NewRequest("POST", "/", strings.NewReader(body)) + recorder := httptest.NewRecorder() + var value struct { + Refresh bool `json:"refresh"` + } + if err := decodeStrictJSON(recorder, request, &value); err == nil { + t.Fatalf("decodeStrictJSON(%q) error=nil", body) + } + } +} +func TestGrowthReportIDPatternRejectsTenantEnumerationInputs(t *testing.T) { + t.Parallel() + if !growthReportIDPattern.MatchString("growth_0123456789abcdef01234567") { + t.Fatal("valid report id rejected") + } + for _, id := range []string{"growth_1", "../growth_0123456789abcdef01234567", "run_0123456789abcdef01234567"} { + if growthReportIDPattern.MatchString(id) { + t.Fatalf("unsafe id accepted: %q", id) + } + } +} + +type fakeGrowthService struct { + get func(int64, string) (growthreport.Record, error) + create func() (growthreport.Record, bool, error) +} + +func (f fakeGrowthService) Create(_ context.Context, _, _ int64, _ growthreport.CreateRequest, _ string) (growthreport.Record, bool, error) { + return f.create() +} +func (f fakeGrowthService) Get(_ context.Context, merchantID int64, id string) (growthreport.Record, error) { + return f.get(merchantID, id) +} +func (f fakeGrowthService) Latest(_ context.Context, merchantID int64, terminal string) (growthreport.Record, error) { + return f.get(merchantID, terminal) +} + +func TestGrowthEndpointsRequireAuthentication(t *testing.T) { + t.Parallel() + server := &Server{logger: slog.New(slog.NewTextHandler(io.Discard, nil)), growth: fakeGrowthService{}} + request := httptest.NewRequest("GET", "/api/v1/analytics/growth-reports/latest", nil) + response := httptest.NewRecorder() + server.Handler().ServeHTTP(response, request) + if response.Code != http.StatusUnauthorized { + t.Fatalf("status=%d", response.Code) + } +} +func TestGetGrowthReportHidesOtherMerchantResource(t *testing.T) { + t.Parallel() + service := fakeGrowthService{get: func(merchantID int64, _ string) (growthreport.Record, error) { + if merchantID != 7 { + t.Fatalf("merchant=%d", merchantID) + } + return growthreport.Record{}, growthreport.ErrNotFound + }} + server := &Server{logger: slog.New(slog.NewTextHandler(io.Discard, nil)), growth: service} + request := httptest.NewRequest("GET", "/", nil) + request.SetPathValue("reportId", "growth_0123456789abcdef01234567") + request = request.WithContext(context.WithValue(request.Context(), sessionKey{}, identity.Session{MerchantID: 7})) + response := httptest.NewRecorder() + server.getGrowthReport(response, request) + if response.Code != 404 { + t.Fatalf("status=%d", response.Code) + } +} +func TestRefreshLimitProblemIncludesNextRefreshAt(t *testing.T) { + t.Parallel() + next := time.Now().UTC().Add(time.Hour) + service := fakeGrowthService{create: func() (growthreport.Record, bool, error) { + return growthreport.Record{}, false, &growthreport.RefreshLimitError{NextRefreshAt: next} + }} + server := &Server{logger: slog.New(slog.NewTextHandler(io.Discard, nil)), growth: service} + request := httptest.NewRequest("POST", "/api/v1/analytics/growth-reports", strings.NewReader(`{"refresh":true}`)) + request.Header.Set("Idempotency-Key", "growth-refresh-1") + request = request.WithContext(context.WithValue(request.Context(), sessionKey{}, identity.Session{MerchantID: 7, UserID: 3})) + response := httptest.NewRecorder() + server.createGrowthReport(response, request) + if response.Code != 429 || !strings.Contains(response.Body.String(), "nextRefreshAt") { + t.Fatalf("status=%d body=%s", response.Code, response.Body.String()) + } +} +func TestGrowthSSEAuthorizesBeforeSettingStreamHeaders(t *testing.T) { + t.Parallel() + service := fakeGrowthService{get: func(int64, string) (growthreport.Record, error) { + return growthreport.Record{}, growthreport.ErrNotFound + }} + server := &Server{logger: slog.New(slog.NewTextHandler(io.Discard, nil)), growth: service} + request := httptest.NewRequest("GET", "/", nil) + request.SetPathValue("reportId", "growth_0123456789abcdef01234567") + request = request.WithContext(context.WithValue(request.Context(), sessionKey{}, identity.Session{MerchantID: 7})) + response := httptest.NewRecorder() + server.growthReportEvents(response, request) + if response.Code != 404 || !strings.HasPrefix(response.Header().Get("Content-Type"), "application/problem+json") { + t.Fatalf("status=%d content-type=%q", response.Code, response.Header().Get("Content-Type")) + } +} diff --git a/internal/api/server.go b/internal/api/server.go index 3ec5005..0eae150 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -28,6 +28,7 @@ var errExportBytesExceeded = errors.New("export exceeds byte limit") type Server struct { identity *identity.Service analytics *analytics.Service + growth growthReportService logger *slog.Logger frontendOrigin string cookieSecure bool @@ -36,8 +37,12 @@ type Server struct { type sessionKey struct{} type tokenKey struct{} -func NewServer(identityService *identity.Service, analyticsService *analytics.Service, logger *slog.Logger, frontendOrigin string, cookieSecure bool) *Server { - return &Server{identity: identityService, analytics: analyticsService, logger: logger, frontendOrigin: frontendOrigin, cookieSecure: cookieSecure} +func NewServer(identityService *identity.Service, analyticsService *analytics.Service, logger *slog.Logger, frontendOrigin string, cookieSecure bool, growthService ...growthReportService) *Server { + server := &Server{identity: identityService, analytics: analyticsService, logger: logger, frontendOrigin: frontendOrigin, cookieSecure: cookieSecure} + if len(growthService) > 0 { + server.growth = growthService[0] + } + return server } func (s *Server) Handler() http.Handler { @@ -54,6 +59,10 @@ func (s *Server) Handler() http.Handler { mux.Handle("GET /api/v1/analytics/session-details", s.authenticate(http.HandlerFunc(s.details))) mux.Handle("GET /api/v1/analytics/session-details.csv", s.authenticate(http.HandlerFunc(s.exportCSV))) mux.Handle("GET /api/v1/analytics/sessions/{sessionId}", s.authenticate(http.HandlerFunc(s.sessionDetail))) + mux.Handle("POST /api/v1/analytics/growth-reports", s.authenticate(s.csrf(http.HandlerFunc(s.createGrowthReport)))) + mux.Handle("GET /api/v1/analytics/growth-reports/latest", s.authenticate(http.HandlerFunc(s.latestGrowthReport))) + mux.Handle("GET /api/v1/analytics/growth-reports/{reportId}", s.authenticate(http.HandlerFunc(s.getGrowthReport))) + mux.Handle("GET /api/v1/analytics/growth-reports/{reportId}/events", s.authenticate(http.HandlerFunc(s.growthReportEvents))) return s.boundary(mux) }