feat: add terminal-scoped transaction analytics
This commit is contained in:
@@ -40,11 +40,13 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("GET /api/v1/auth/session", s.authenticate(http.HandlerFunc(s.currentSession)))
|
||||
mux.Handle("DELETE /api/v1/auth/session", s.authenticate(s.csrf(http.HandlerFunc(s.logout))))
|
||||
mux.Handle("GET /api/v1/analytics/periods", s.authenticate(http.HandlerFunc(s.periods)))
|
||||
mux.Handle("GET /api/v1/analytics/terminals", s.authenticate(http.HandlerFunc(s.terminals)))
|
||||
mux.Handle("POST /api/v1/analytics/runs", s.authenticate(s.csrf(http.HandlerFunc(s.startRun))))
|
||||
mux.Handle("GET /api/v1/analytics/runs/{runId}", s.authenticate(http.HandlerFunc(s.getRun)))
|
||||
mux.Handle("GET /api/v1/analytics/runs/{runId}/events", s.authenticate(http.HandlerFunc(s.events)))
|
||||
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)))
|
||||
return s.boundary(mux)
|
||||
}
|
||||
|
||||
@@ -148,12 +150,34 @@ func (s *Server) periods(w http.ResponseWriter, r *http.Request) {
|
||||
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Analytics unavailable")
|
||||
return
|
||||
}
|
||||
s.writeJSON(w, http.StatusOK, map[string]any{"defaultPeriodId": periods[0].PeriodID, "periods": periods})
|
||||
if len(periods) == 0 {
|
||||
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Analytics unavailable")
|
||||
return
|
||||
}
|
||||
defaultPeriodID := periods[0].PeriodID
|
||||
for _, period := range periods {
|
||||
if period.IsDefault {
|
||||
defaultPeriodID = period.PeriodID
|
||||
break
|
||||
}
|
||||
}
|
||||
s.writeJSON(w, http.StatusOK, map[string]any{"defaultPeriodId": defaultPeriodID, "periods": periods})
|
||||
}
|
||||
|
||||
func (s *Server) terminals(w http.ResponseWriter, r *http.Request) {
|
||||
terminals, err := s.analytics.Terminals(r.Context(), sessionFrom(r.Context()).MerchantID)
|
||||
if err != nil {
|
||||
s.logger.Error("terminal discovery failed", "error", err)
|
||||
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Analytics unavailable")
|
||||
return
|
||||
}
|
||||
s.writeJSON(w, http.StatusOK, map[string]any{"terminals": terminals})
|
||||
}
|
||||
|
||||
func (s *Server) startRun(w http.ResponseWriter, r *http.Request) {
|
||||
var input struct {
|
||||
PeriodID string `json:"periodId"`
|
||||
PeriodID string `json:"periodId"`
|
||||
TerminalID string `json:"terminalId"`
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
@@ -180,7 +204,11 @@ func (s *Server) startRun(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
session := sessionFrom(r.Context())
|
||||
run, err := s.analytics.Start(r.Context(), session.MerchantID, period)
|
||||
run, err := s.analytics.Start(r.Context(), session.MerchantID, period, input.TerminalID)
|
||||
if errors.Is(err, analytics.ErrUnknownTerminal) {
|
||||
s.problem(w, r, 422, "VALIDATION_FAILED", "Unknown terminal")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.logger.Error("analytics calculation failed", "error", err)
|
||||
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Analytics unavailable")
|
||||
@@ -229,7 +257,22 @@ func (s *Server) details(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
limit = parsed
|
||||
}
|
||||
filter := analytics.DetailFilter{View: r.URL.Query().Get("view"), FailureStage: r.URL.Query().Get("failureStage"), TerminalID: r.URL.Query().Get("terminalId"), Cursor: r.URL.Query().Get("cursor"), Limit: limit}
|
||||
minAmount, valid := optionalNonNegativeInt64(r.URL.Query().Get("minAmountRial"))
|
||||
if !valid {
|
||||
s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid minimum amount")
|
||||
return
|
||||
}
|
||||
maxAmount, valid := optionalNonNegativeInt64(r.URL.Query().Get("maxAmountRial"))
|
||||
if !valid {
|
||||
s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid maximum amount")
|
||||
return
|
||||
}
|
||||
filter := analytics.DetailFilter{
|
||||
View: r.URL.Query().Get("view"), FailureStage: r.URL.Query().Get("failureStage"),
|
||||
TerminalID: r.URL.Query().Get("terminalId"), Status: r.URL.Query().Get("status"),
|
||||
MinAmountRial: minAmount, MaxAmountRial: maxAmount,
|
||||
Cursor: r.URL.Query().Get("cursor"), Limit: limit,
|
||||
}
|
||||
items, next, err := s.analytics.Details(r.Context(), sessionFrom(r.Context()).MerchantID, period, filter)
|
||||
if err != nil {
|
||||
s.logger.Error("detail query failed", "error", err)
|
||||
@@ -240,7 +283,24 @@ func (s *Server) details(w http.ResponseWriter, r *http.Request) {
|
||||
if next != "" {
|
||||
nextValue = next
|
||||
}
|
||||
s.writeJSON(w, 200, map[string]any{"period": period, "view": filter.View, "appliedFilters": map[string]any{"terminalId": nullable(filter.TerminalID), "failureStage": nullable(filter.FailureStage)}, "items": items, "pageInfo": map[string]any{"limit": limit, "hasNextPage": next != "", "nextCursor": nextValue}})
|
||||
s.writeJSON(w, 200, map[string]any{"period": period, "view": filter.View, "appliedFilters": map[string]any{
|
||||
"terminalId": nullable(filter.TerminalID), "failureStage": nullable(filter.FailureStage), "status": nullable(filter.Status),
|
||||
"minAmountRial": filter.MinAmountRial, "maxAmountRial": filter.MaxAmountRial,
|
||||
}, "items": items, "pageInfo": map[string]any{"limit": limit, "hasNextPage": next != "", "nextCursor": nextValue}})
|
||||
}
|
||||
|
||||
func (s *Server) sessionDetail(w http.ResponseWriter, r *http.Request) {
|
||||
detail, found, err := s.analytics.Session(r.Context(), sessionFrom(r.Context()).MerchantID, r.PathValue("sessionId"))
|
||||
if err != nil {
|
||||
s.logger.Error("session detail query failed", "error", err)
|
||||
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Analytics unavailable")
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
|
||||
return
|
||||
}
|
||||
s.writeJSON(w, 200, detail)
|
||||
}
|
||||
|
||||
func (s *Server) exportCSV(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -294,6 +354,16 @@ func nullable(value string) any {
|
||||
}
|
||||
return value
|
||||
}
|
||||
func optionalNonNegativeInt64(value string) (*int64, bool) {
|
||||
if value == "" {
|
||||
return nil, true
|
||||
}
|
||||
parsed, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil || parsed < 0 {
|
||||
return nil, false
|
||||
}
|
||||
return &parsed, true
|
||||
}
|
||||
func formatTime(value *time.Time) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
|
||||
@@ -28,3 +28,35 @@ func TestPersianStatus(t *testing.T) {
|
||||
t.Fatalf("persianStatus() = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptionalNonNegativeInt64(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name, input string
|
||||
want *int64
|
||||
valid bool
|
||||
}{
|
||||
{name: "omitted", valid: true},
|
||||
{name: "zero", input: "0", want: int64Pointer(0), valid: true},
|
||||
{name: "positive", input: "125000", want: int64Pointer(125000), valid: true},
|
||||
{name: "negative", input: "-1"},
|
||||
{name: "not a number", input: "یک"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, valid := optionalNonNegativeInt64(tt.input)
|
||||
if valid != tt.valid || !equalInt64Pointers(got, tt.want) {
|
||||
t.Fatalf("optionalNonNegativeInt64(%q) = %v,%v, want %v,%v", tt.input, got, valid, tt.want, tt.valid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func int64Pointer(value int64) *int64 { return &value }
|
||||
|
||||
func equalInt64Pointers(left, right *int64) bool {
|
||||
if left == nil || right == nil {
|
||||
return left == right
|
||||
}
|
||||
return *left == *right
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user