package handler import ( "encoding/json" "errors" "net/http" "time" "github.com/go-chi/chi/v5" "github.com/prabhupadasays/spsays-bff/internal/middleware" "github.com/prabhupadasays/spsays-bff/internal/model" "github.com/prabhupadasays/spsays-bff/internal/repository" "github.com/prabhupadasays/spsays-bff/internal/service" "github.com/prabhupadasays/spsays-bff/pkg/response" ) const sadhnaDayFmt = "2006-01-02" // SadhnaHandler serves the sādhana-streak dashboard and records the day's // chanting progress. Both endpoints are auth-required; the user is read // from the request context populated by middleware.RequireAuth. type SadhnaHandler struct { svc *service.SadhnaService } func NewSadhnaHandler(svc *service.SadhnaService) *SadhnaHandler { return &SadhnaHandler{svc: svc} } // Dashboard — GET /sadhna/dashboard. Returns today's progress, the 7-day // strip, streak counters, and plant state. func (h *SadhnaHandler) Dashboard(w http.ResponseWriter, r *http.Request) { uid := middleware.UserID(r.Context()) dash, err := h.svc.Dashboard(r.Context(), uid) if err != nil { response.Error(w, http.StatusInternalServerError, "could not load sadhna dashboard") return } response.JSON(w, http.StatusOK, dash) } type saveChantReq struct { RoundsCompleted int `json:"rounds_completed"` } // SaveChant — POST /sadhna/chant. Persists today's round count and returns // the recomputed dashboard so the client updates the strip + plant in one // round-trip. func (h *SadhnaHandler) SaveChant(w http.ResponseWriter, r *http.Request) { uid := middleware.UserID(r.Context()) var req saveChantReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { response.Error(w, http.StatusBadRequest, "invalid JSON body") return } dash, err := h.svc.SaveChant(r.Context(), uid, req.RoundsCompleted) if err != nil { response.Error(w, http.StatusInternalServerError, "could not save chanting progress") return } response.JSON(w, http.StatusOK, dash) } // History — GET /sadhna/history?from=YYYY-MM-DD&to=YYYY-MM-DD. Returns the // per-day sādhana state for the inclusive UTC date range, which the // calendar uses to render any week or month. The range is bounded to a // year so a malformed/huge request can't scan the whole table. func (h *SadhnaHandler) History(w http.ResponseWriter, r *http.Request) { uid := middleware.UserID(r.Context()) from, err := time.ParseInLocation(sadhnaDayFmt, r.URL.Query().Get("from"), time.UTC) if err != nil { response.Error(w, http.StatusBadRequest, "invalid 'from' date (want YYYY-MM-DD)") return } to, err := time.ParseInLocation(sadhnaDayFmt, r.URL.Query().Get("to"), time.UTC) if err != nil { response.Error(w, http.StatusBadRequest, "invalid 'to' date (want YYYY-MM-DD)") return } if to.Before(from) { response.Error(w, http.StatusBadRequest, "'to' must not be before 'from'") return } if to.Sub(from) > 366*24*time.Hour { response.Error(w, http.StatusBadRequest, "range too large (max 1 year)") return } days, err := h.svc.History(r.Context(), uid, from, to) if err != nil { response.Error(w, http.StatusInternalServerError, "could not load sadhna history") return } response.JSON(w, http.StatusOK, days) } type rewardsResp struct { Collected []model.SadhnaRewardGrant `json:"collected"` Total int `json:"total"` } // Rewards — GET /sadhna/rewards. The user's collected gifts (newest first) // plus the size of the giftable catalog, for the rewards gallery. func (h *SadhnaHandler) Rewards(w http.ResponseWriter, r *http.Request) { uid := middleware.UserID(r.Context()) grants, total, err := h.svc.Rewards(r.Context(), uid) if err != nil { response.Error(w, http.StatusInternalServerError, "could not load rewards") return } if grants == nil { grants = []model.SadhnaRewardGrant{} } response.JSON(w, http.StatusOK, rewardsResp{Collected: grants, Total: total}) } // AckRewards — POST /sadhna/rewards/ack. Clears pending celebrations once the // client has shown the confetti, so a gift surfaces exactly once. // OpenGift — POST /sadhna/rewards/gifts/{key}/open // // Called after the celebration is dismissed. Idempotent: opening an already // opened gift changes nothing. func (h *SadhnaHandler) OpenGift(w http.ResponseWriter, r *http.Request) { if err := h.svc.OpenGift(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "key")); err != nil { response.Error(w, http.StatusInternalServerError, "could not open that gift") return } response.NoContent(w) } // GiftableRewards — GET /admin/reward-gifts/catalog func (h *SadhnaHandler) GiftableRewards(w http.ResponseWriter, r *http.Request) { list, err := h.svc.GiftableRewards(r.Context()) if err != nil { response.Error(w, http.StatusInternalServerError, "could not load the catalog") return } response.JSON(w, http.StatusOK, list) } type giftReq struct { RewardKey string `json:"reward_key"` } // GiftToEveryone — POST /admin/reward-gifts // // Hands one reward to every account, unopened. Idempotent, so a repeated // click tops up only the people who don't have it rather than duplicating. func (h *SadhnaHandler) GiftToEveryone(w http.ResponseWriter, r *http.Request) { var req giftReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.RewardKey == "" { response.Error(w, http.StatusBadRequest, "reward_key is required") return } n, err := h.svc.GiftToEveryone(r.Context(), req.RewardKey) if err != nil { if errors.Is(err, repository.ErrRewardNotFound) { response.Error(w, http.StatusNotFound, "no such reward") return } response.Error(w, http.StatusInternalServerError, "could not send the gift") return } response.JSON(w, http.StatusOK, map[string]int64{"granted": n}) } func (h *SadhnaHandler) AckRewards(w http.ResponseWriter, r *http.Request) { uid := middleware.UserID(r.Context()) if err := h.svc.AckRewards(r.Context(), uid); err != nil { response.Error(w, http.StatusInternalServerError, "could not acknowledge rewards") return } response.NoContent(w) }