447 lines
15 KiB
Go
447 lines
15 KiB
Go
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
|
|
}
|