feat: add web push and sadhna tracker
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||
)
|
||||
|
||||
// PushHandler serves Web Push subscription management. The VAPID public key
|
||||
// is handed to the browser so it can subscribe; subscriptions are stored per
|
||||
// user. All routes require auth.
|
||||
type PushHandler struct {
|
||||
push *service.PushSender // optional — nil when VAPID isn't configured
|
||||
repo *repository.PushRepo
|
||||
}
|
||||
|
||||
func NewPushHandler(push *service.PushSender, repo *repository.PushRepo) *PushHandler {
|
||||
return &PushHandler{push: push, repo: repo}
|
||||
}
|
||||
|
||||
// Key — GET /push/key. Returns the VAPID public key + whether push is on.
|
||||
func (h *PushHandler) Key(w http.ResponseWriter, r *http.Request) {
|
||||
response.JSON(w, http.StatusOK, struct {
|
||||
PublicKey string `json:"public_key"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}{
|
||||
PublicKey: h.push.PublicKey(),
|
||||
Enabled: h.push.Enabled(),
|
||||
})
|
||||
}
|
||||
|
||||
type subscribeReq struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
Keys struct {
|
||||
P256dh string `json:"p256dh"`
|
||||
Auth string `json:"auth"`
|
||||
} `json:"keys"`
|
||||
}
|
||||
|
||||
// Subscribe — POST /push/subscribe. Stores (or refreshes) the caller's push
|
||||
// subscription. Body is the browser PushSubscription JSON.
|
||||
func (h *PushHandler) Subscribe(w http.ResponseWriter, r *http.Request) {
|
||||
if h.repo == nil || !h.push.Enabled() {
|
||||
response.Error(w, http.StatusServiceUnavailable, "push not configured")
|
||||
return
|
||||
}
|
||||
uid := middleware.UserID(r.Context())
|
||||
var req subscribeReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if req.Endpoint == "" || req.Keys.P256dh == "" || req.Keys.Auth == "" {
|
||||
response.Error(w, http.StatusBadRequest, "missing subscription fields")
|
||||
return
|
||||
}
|
||||
if err := h.repo.Upsert(r.Context(), uid, req.Endpoint, req.Keys.P256dh, req.Keys.Auth, r.UserAgent()); err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not save subscription")
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
type unsubscribeReq struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
}
|
||||
|
||||
// Unsubscribe — POST /push/unsubscribe. Removes a subscription by endpoint.
|
||||
func (h *PushHandler) Unsubscribe(w http.ResponseWriter, r *http.Request) {
|
||||
if h.repo == nil {
|
||||
response.NoContent(w)
|
||||
return
|
||||
}
|
||||
var req unsubscribeReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Endpoint) == "" {
|
||||
response.Error(w, http.StatusBadRequest, "missing endpoint")
|
||||
return
|
||||
}
|
||||
if err := h.repo.DeleteByEndpoint(r.Context(), req.Endpoint); err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not remove subscription")
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||
)
|
||||
|
||||
// ReminderHandler serves the sādhana-reminder preference + the timezone the
|
||||
// scheduler fires against. All routes require auth.
|
||||
type ReminderHandler struct {
|
||||
svc *service.ReminderService
|
||||
}
|
||||
|
||||
func NewReminderHandler(svc *service.ReminderService) *ReminderHandler {
|
||||
return &ReminderHandler{svc: svc}
|
||||
}
|
||||
|
||||
type reminderPrefResp struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// GetPrefs — GET /sadhna/reminders. Whether reminders are on for the caller.
|
||||
func (h *ReminderHandler) GetPrefs(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
enabled, err := h.svc.Enabled(r.Context(), uid)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not load reminder preference")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, reminderPrefResp{Enabled: enabled})
|
||||
}
|
||||
|
||||
// UpdatePrefs — PUT /sadhna/reminders { enabled }. Toggles reminders.
|
||||
func (h *ReminderHandler) UpdatePrefs(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
var req struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if err := h.svc.SetEnabled(r.Context(), uid, req.Enabled); err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not update reminder preference")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, reminderPrefResp{Enabled: req.Enabled})
|
||||
}
|
||||
|
||||
// SetTimezone — POST /sadhna/timezone { timezone }. The browser reports its
|
||||
// IANA zone so reminders fire at local time.
|
||||
func (h *ReminderHandler) SetTimezone(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
var req struct {
|
||||
Timezone string `json:"timezone"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if err := h.svc.SetTimezone(r.Context(), uid, req.Timezone); err != nil {
|
||||
if errors.Is(err, service.ErrInvalidTimezone) {
|
||||
response.Error(w, http.StatusBadRequest, "invalid timezone")
|
||||
return
|
||||
}
|
||||
response.Error(w, http.StatusInternalServerError, "could not save timezone")
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
"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.
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user