fix: deliver validated AI insight responses
This commit is contained in:
446
internal/api/coach.go
Normal file
446
internal/api/coach.go
Normal file
@@ -0,0 +1,446 @@
|
||||
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
|
||||
}
|
||||
@@ -18,8 +18,91 @@ var insightJobIDPattern = regexp.MustCompile(`^insightjob_[a-f0-9]{24}$`)
|
||||
type insightService interface {
|
||||
Latest(context.Context, int64, string, insight.Module) (insight.Snapshot, error)
|
||||
Refresh(context.Context, int64, string, insight.Module, string) (insight.Job, bool, error)
|
||||
RefreshSection(context.Context, int64, string, insight.Module, insight.Section, string) (insight.Job, bool, error)
|
||||
Enqueue(context.Context, int64, string, insight.Module, string) (insight.Job, bool, error)
|
||||
GetJob(context.Context, int64, string) (insight.Job, error)
|
||||
EventsAfter(context.Context, int64, string, int64) ([]insight.Event, error)
|
||||
LatestForPeriod(context.Context, int64, string, insight.Module, insight.PeriodScope) (insight.Snapshot, error)
|
||||
RefreshForPeriod(context.Context, int64, string, insight.Module, insight.PeriodScope, string) (insight.Job, bool, error)
|
||||
RefreshSectionForPeriod(context.Context, int64, string, insight.Module, insight.Section, insight.PeriodScope, string) (insight.Job, bool, error)
|
||||
}
|
||||
|
||||
func (s *Server) refreshAIInsightSection(w http.ResponseWriter, r *http.Request) {
|
||||
module, moduleOK := insight.ParseModule(r.PathValue("module"))
|
||||
section, sectionOK := insight.ParseSection(module, r.PathValue("sectionId"))
|
||||
if !moduleOK || s.insights == nil {
|
||||
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
|
||||
return
|
||||
}
|
||||
if !sectionOK {
|
||||
s.problem(w, r, 422, "VALIDATION_FAILED", "Unsupported AI insight section")
|
||||
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 struct {
|
||||
TerminalID string `json:"terminalId"`
|
||||
PeriodID string `json:"periodId"`
|
||||
}
|
||||
if err := decodeStrictJSON(w, r, &input); err != nil || len(input.TerminalID) > 128 {
|
||||
s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid insight refresh request")
|
||||
return
|
||||
}
|
||||
period, periodOK := s.insightPeriod(r.Context(), module, input.PeriodID)
|
||||
if !periodOK {
|
||||
s.problem(w, r, 422, "VALIDATION_FAILED", "Unknown reporting period")
|
||||
return
|
||||
}
|
||||
var job insight.Job
|
||||
var cached bool
|
||||
var err error
|
||||
if period.PeriodID != "" {
|
||||
job, cached, err = s.insights.RefreshSectionForPeriod(r.Context(), sessionFrom(r.Context()).MerchantID, input.TerminalID, module, section, period, key)
|
||||
} else {
|
||||
job, cached, err = s.insights.RefreshSection(r.Context(), sessionFrom(r.Context()).MerchantID, input.TerminalID, module, section, key)
|
||||
}
|
||||
if s.writeInsightRefreshResult(w, r, job, cached, err) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) writeInsightRefreshResult(w http.ResponseWriter, r *http.Request, job insight.Job, cached bool, err error) bool {
|
||||
switch {
|
||||
case errors.Is(err, insight.ErrUnknownTerminal):
|
||||
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
|
||||
return true
|
||||
case errors.Is(err, insight.ErrIdempotencyConflict):
|
||||
s.problem(w, r, 409, "IDEMPOTENCY_CONFLICT", "Idempotency key was already used for another request")
|
||||
return true
|
||||
case errors.Is(err, insight.ErrOnboardingRequired):
|
||||
s.problem(w, r, 409, "ONBOARDING_REQUIRED", "Complete terminal onboarding before generating AI insights")
|
||||
return true
|
||||
case errors.Is(err, insight.ErrRefreshLimited), errors.Is(err, insight.ErrRefreshCooldown):
|
||||
next := nextTehranDay(time.Now())
|
||||
var cooldown *insight.RefreshCooldownError
|
||||
if errors.As(err, &cooldown) {
|
||||
next = cooldown.NextRefreshAt
|
||||
}
|
||||
w.Header().Set("Retry-After", strconv.FormatInt(max(1, int64(time.Until(next).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": "AI section refresh is not available yet", "instance": r.URL.Path, "requestId": w.Header().Get("X-Request-ID"), "nextRefreshAt": next})
|
||||
return true
|
||||
case err != nil:
|
||||
s.logger.Error("creating AI insight refresh failed", "error", err)
|
||||
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "AI insight unavailable")
|
||||
return true
|
||||
}
|
||||
status := http.StatusAccepted
|
||||
if cached {
|
||||
status = http.StatusOK
|
||||
}
|
||||
w.Header().Set("Location", "/api/v1/analytics/ai-insight-refreshes/"+job.JobID)
|
||||
s.writeJSON(w, status, job)
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) latestAIInsight(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -28,12 +111,23 @@ func (s *Server) latestAIInsight(w http.ResponseWriter, r *http.Request) {
|
||||
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
|
||||
return
|
||||
}
|
||||
terminalID, ok := insightTerminalQuery(r)
|
||||
terminalID, periodID, ok := insightScopeQuery(r)
|
||||
if !ok {
|
||||
s.problem(w, r, 422, "VALIDATION_FAILED", "Only terminalId is accepted")
|
||||
s.problem(w, r, 422, "VALIDATION_FAILED", "Only terminalId and periodId are accepted")
|
||||
return
|
||||
}
|
||||
result, err := s.insights.Latest(r.Context(), sessionFrom(r.Context()).MerchantID, terminalID, module)
|
||||
period, periodOK := s.insightPeriod(r.Context(), module, periodID)
|
||||
if !periodOK {
|
||||
s.problem(w, r, 422, "VALIDATION_FAILED", "Unknown reporting period")
|
||||
return
|
||||
}
|
||||
var result insight.Snapshot
|
||||
var err error
|
||||
if period.PeriodID != "" {
|
||||
result, err = s.insights.LatestForPeriod(r.Context(), sessionFrom(r.Context()).MerchantID, terminalID, module, period)
|
||||
} else {
|
||||
result, err = s.insights.Latest(r.Context(), sessionFrom(r.Context()).MerchantID, terminalID, module)
|
||||
}
|
||||
if errors.Is(err, insight.ErrNotFound) || errors.Is(err, insight.ErrUnknownTerminal) {
|
||||
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
|
||||
return
|
||||
@@ -59,37 +153,26 @@ func (s *Server) refreshAIInsight(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
var input struct {
|
||||
TerminalID string `json:"terminalId"`
|
||||
PeriodID string `json:"periodId"`
|
||||
}
|
||||
if err := decodeStrictJSON(w, r, &input); err != nil || len(input.TerminalID) > 128 {
|
||||
s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid insight refresh request")
|
||||
return
|
||||
}
|
||||
job, cached, err := s.insights.Refresh(r.Context(), sessionFrom(r.Context()).MerchantID, input.TerminalID, module, key)
|
||||
switch {
|
||||
case errors.Is(err, insight.ErrUnknownTerminal):
|
||||
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
|
||||
return
|
||||
case errors.Is(err, insight.ErrIdempotencyConflict):
|
||||
s.problem(w, r, 409, "IDEMPOTENCY_CONFLICT", "Idempotency key was already used for another request")
|
||||
return
|
||||
case errors.Is(err, insight.ErrRefreshLimited):
|
||||
next := nextTehranDay(time.Now())
|
||||
w.Header().Set("Retry-After", strconv.FormatInt(max(1, int64(time.Until(next).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": next})
|
||||
return
|
||||
case err != nil:
|
||||
s.logger.Error("creating AI insight refresh failed", "error", err)
|
||||
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "AI insight unavailable")
|
||||
period, periodOK := s.insightPeriod(r.Context(), module, input.PeriodID)
|
||||
if !periodOK {
|
||||
s.problem(w, r, 422, "VALIDATION_FAILED", "Unknown reporting period")
|
||||
return
|
||||
}
|
||||
status := http.StatusAccepted
|
||||
if cached {
|
||||
status = http.StatusOK
|
||||
var job insight.Job
|
||||
var cached bool
|
||||
var err error
|
||||
if period.PeriodID != "" {
|
||||
job, cached, err = s.insights.RefreshForPeriod(r.Context(), sessionFrom(r.Context()).MerchantID, input.TerminalID, module, period, key)
|
||||
} else {
|
||||
job, cached, err = s.insights.Refresh(r.Context(), sessionFrom(r.Context()).MerchantID, input.TerminalID, module, key)
|
||||
}
|
||||
w.Header().Set("Location", "/api/v1/analytics/ai-insight-refreshes/"+job.JobID)
|
||||
s.writeJSON(w, status, job)
|
||||
s.writeInsightRefreshResult(w, r, job, cached, err)
|
||||
}
|
||||
|
||||
func (s *Server) getAIInsightJob(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -165,14 +248,33 @@ func (s *Server) aiInsightEvents(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func insightTerminalQuery(r *http.Request) (string, bool) {
|
||||
func insightScopeQuery(r *http.Request) (string, string, bool) {
|
||||
for key := range r.URL.Query() {
|
||||
if key != "terminalId" {
|
||||
return "", false
|
||||
if key != "terminalId" && key != "periodId" {
|
||||
return "", "", false
|
||||
}
|
||||
}
|
||||
value := r.URL.Query().Get("terminalId")
|
||||
return value, len(value) <= 128
|
||||
terminalID, periodID := r.URL.Query().Get("terminalId"), r.URL.Query().Get("periodId")
|
||||
return terminalID, periodID, len(terminalID) <= 128 && len(periodID) <= 128
|
||||
}
|
||||
|
||||
func (s *Server) insightPeriod(ctx context.Context, module insight.Module, periodID string) (insight.PeriodScope, bool) {
|
||||
if periodID == "" {
|
||||
return insight.PeriodScope{}, true
|
||||
}
|
||||
if module != insight.ModuleTransactions || s.analytics == nil {
|
||||
return insight.PeriodScope{}, false
|
||||
}
|
||||
periods, err := s.analytics.Periods(ctx)
|
||||
if err != nil {
|
||||
return insight.PeriodScope{}, false
|
||||
}
|
||||
for _, period := range periods {
|
||||
if period.PeriodID == periodID {
|
||||
return insight.PeriodScope{PeriodID: period.PeriodID, PeriodType: period.PeriodType, StartAt: period.StartAt, EndAt: period.EndAt, JalaliLabelFA: period.JalaliLabelFA}, true
|
||||
}
|
||||
}
|
||||
return insight.PeriodScope{}, false
|
||||
}
|
||||
|
||||
func nextTehranDay(now time.Time) time.Time {
|
||||
|
||||
82
internal/api/insights_test.go
Normal file
82
internal/api/insights_test.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/identity"
|
||||
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/insight"
|
||||
)
|
||||
|
||||
type fakeInsightService struct {
|
||||
refreshSection func(int64, string, insight.Module, insight.Section, string) (insight.Job, bool, error)
|
||||
}
|
||||
|
||||
func (fakeInsightService) Latest(context.Context, int64, string, insight.Module) (insight.Snapshot, error) {
|
||||
return insight.Snapshot{}, insight.ErrNotFound
|
||||
}
|
||||
func (fakeInsightService) Refresh(context.Context, int64, string, insight.Module, string) (insight.Job, bool, error) {
|
||||
return insight.Job{}, false, nil
|
||||
}
|
||||
func (fakeInsightService) Enqueue(context.Context, int64, string, insight.Module, string) (insight.Job, bool, error) {
|
||||
return insight.Job{}, false, nil
|
||||
}
|
||||
func (f fakeInsightService) RefreshSection(_ context.Context, merchantID int64, terminalID string, module insight.Module, section insight.Section, key string) (insight.Job, bool, error) {
|
||||
return f.refreshSection(merchantID, terminalID, module, section, key)
|
||||
}
|
||||
func (fakeInsightService) GetJob(context.Context, int64, string) (insight.Job, error) {
|
||||
return insight.Job{}, insight.ErrNotFound
|
||||
}
|
||||
func (fakeInsightService) EventsAfter(context.Context, int64, string, int64) ([]insight.Event, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (fakeInsightService) LatestForPeriod(context.Context, int64, string, insight.Module, insight.PeriodScope) (insight.Snapshot, error) {
|
||||
return insight.Snapshot{}, insight.ErrNotFound
|
||||
}
|
||||
func (fakeInsightService) RefreshForPeriod(context.Context, int64, string, insight.Module, insight.PeriodScope, string) (insight.Job, bool, error) {
|
||||
return insight.Job{}, false, nil
|
||||
}
|
||||
func (fakeInsightService) RefreshSectionForPeriod(context.Context, int64, string, insight.Module, insight.Section, insight.PeriodScope, string) (insight.Job, bool, error) {
|
||||
return insight.Job{}, false, nil
|
||||
}
|
||||
|
||||
func TestRefreshAIInsightSectionTargetsOnlyRequestedSection(t *testing.T) {
|
||||
service := fakeInsightService{refreshSection: func(merchantID int64, terminalID string, module insight.Module, section insight.Section, key string) (insight.Job, bool, error) {
|
||||
if merchantID != 7 || terminalID != "T83" || module != insight.ModulePayments || section != insight.SectionFuture7Days || key != "section-refresh-123" {
|
||||
t.Fatalf("unexpected request: %d %s %s %s %s", merchantID, terminalID, module, section, key)
|
||||
}
|
||||
return insight.Job{JobID: "insightjob_0123456789abcdef01234567", SnapshotID: "snapshot", Module: module, Section: section, State: "QUEUED"}, false, nil
|
||||
}}
|
||||
server := &Server{logger: slog.New(slog.NewTextHandler(io.Discard, nil)), insights: service}
|
||||
request := httptest.NewRequest("POST", "/", strings.NewReader(`{"terminalId":"T83"}`))
|
||||
request.SetPathValue("module", "payments")
|
||||
request.SetPathValue("sectionId", "future-7-days")
|
||||
request.Header.Set("Idempotency-Key", "section-refresh-123")
|
||||
request = request.WithContext(context.WithValue(request.Context(), sessionKey{}, identity.Session{MerchantID: 7, UserID: 3}))
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
server.refreshAIInsightSection(response, request)
|
||||
|
||||
if response.Code != 202 || !strings.Contains(response.Body.String(), `"sectionId":"future-7-days"`) {
|
||||
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshAIInsightSectionRejectsUnsupportedNinetyDaySection(t *testing.T) {
|
||||
server := &Server{logger: slog.New(slog.NewTextHandler(io.Discard, nil)), insights: fakeInsightService{}}
|
||||
request := httptest.NewRequest("POST", "/", strings.NewReader(`{}`))
|
||||
request.SetPathValue("module", "payments")
|
||||
request.SetPathValue("sectionId", "future-90-days")
|
||||
request.Header.Set("Idempotency-Key", "section-refresh-123")
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
server.refreshAIInsightSection(response, request)
|
||||
|
||||
if response.Code != 422 {
|
||||
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"unicode"
|
||||
|
||||
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/analytics"
|
||||
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/coach"
|
||||
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/identity"
|
||||
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/insight"
|
||||
)
|
||||
@@ -39,6 +40,7 @@ type Server struct {
|
||||
analytics *analytics.Service
|
||||
growth growthReportService
|
||||
insights insightService
|
||||
coach *coach.Service
|
||||
logger *slog.Logger
|
||||
frontendOrigin string
|
||||
cookieSecure bool
|
||||
@@ -52,6 +54,8 @@ func (s *Server) WithInsights(service *insight.Service) *Server {
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) WithCoach(service *coach.Service) *Server { s.coach = service; return s }
|
||||
|
||||
type sessionKey struct{}
|
||||
type tokenKey struct{}
|
||||
|
||||
@@ -83,8 +87,23 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("GET /api/v1/analytics/growth-reports/{reportId}/events", s.authenticate(http.HandlerFunc(s.growthReportEvents)))
|
||||
mux.Handle("GET /api/v1/analytics/ai-insights/{module}", s.authenticate(http.HandlerFunc(s.latestAIInsight)))
|
||||
mux.Handle("POST /api/v1/analytics/ai-insights/{module}/refreshes", s.authenticate(s.csrf(http.HandlerFunc(s.refreshAIInsight))))
|
||||
mux.Handle("POST /api/v1/analytics/ai-insights/{module}/sections/{sectionId}/refreshes", s.authenticate(s.csrf(http.HandlerFunc(s.refreshAIInsightSection))))
|
||||
mux.Handle("GET /api/v1/analytics/ai-insight-refreshes/{jobId}", s.authenticate(http.HandlerFunc(s.getAIInsightJob)))
|
||||
mux.Handle("GET /api/v1/analytics/ai-insight-refreshes/{jobId}/events", s.authenticate(http.HandlerFunc(s.aiInsightEvents)))
|
||||
mux.Handle("GET /api/v1/growth-coach", s.authenticate(http.HandlerFunc(s.growthCoachDashboard)))
|
||||
mux.Handle("GET /api/v1/growth-coach/terminals/{terminalId}/profile", s.authenticate(http.HandlerFunc(s.growthCoachProfile)))
|
||||
mux.Handle("PUT /api/v1/growth-coach/terminals/{terminalId}/profile", s.authenticate(s.csrf(http.HandlerFunc(s.saveGrowthCoachProfile))))
|
||||
mux.Handle("POST /api/v1/growth-coach/terminals/{terminalId}/refreshes", s.authenticate(s.csrf(http.HandlerFunc(s.refreshGrowthCoach))))
|
||||
mux.Handle("GET /api/v1/growth-coach/plans", s.authenticate(http.HandlerFunc(s.growthCoachPlans)))
|
||||
mux.Handle("GET /api/v1/growth-coach/actions", s.authenticate(http.HandlerFunc(s.growthCoachActions)))
|
||||
mux.Handle("GET /api/v1/growth-coach/goals", s.authenticate(http.HandlerFunc(s.growthCoachGoals)))
|
||||
mux.Handle("POST /api/v1/growth-coach/goals/{goalId}/subtasks/{subtaskId}/transitions", s.authenticate(s.csrf(http.HandlerFunc(s.transitionGrowthCoachSubtask))))
|
||||
mux.Handle("POST /api/v1/growth-coach/deadline-proposals/{proposalId}/decisions", s.authenticate(s.csrf(http.HandlerFunc(s.decideGrowthCoachDeadlineProposal))))
|
||||
mux.Handle("POST /api/v1/growth-coach/recommendations/{id}/decisions", s.authenticate(s.csrf(http.HandlerFunc(s.decideGrowthCoachRecommendation))))
|
||||
mux.Handle("POST /api/v1/growth-coach/actions/{id}/transitions", s.authenticate(s.csrf(http.HandlerFunc(s.transitionGrowthCoachAction))))
|
||||
mux.Handle("POST /api/v1/growth-coach/difficulty-proposals/{id}/decisions", s.authenticate(s.csrf(http.HandlerFunc(s.decideGrowthCoachDifficulty))))
|
||||
mux.Handle("GET /api/v1/growth-coach/refreshes/{jobId}", s.authenticate(http.HandlerFunc(s.growthCoachJob)))
|
||||
mux.Handle("GET /api/v1/growth-coach/refreshes/{jobId}/events", s.authenticate(http.HandlerFunc(s.growthCoachEvents)))
|
||||
return s.boundary(mux)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user