package devices import ( "errors" "log/slog" "net/http" "strings" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/ultisuite/ulti-backend/internal/api/apiresponse" "github.com/ultisuite/ulti-backend/internal/api/apivalidate" "github.com/ultisuite/ulti-backend/internal/api/middleware" ) const maxRequestBody = 16 << 10 // Handler exposes mobile device token registration endpoints. type Handler struct { svc *Service logger *slog.Logger } func NewHandler(db *pgxpool.Pool) *Handler { return &Handler{ svc: NewService(db), logger: slog.Default().With("component", "devices-api"), } } func (h *Handler) Routes() chi.Router { r := chi.NewRouter() r.Get("/", h.List) r.Post("/register", h.Register) r.Post("/unregister", h.Unregister) r.Delete("/{id}", h.Delete) return r } type registerRequest struct { Platform string `json:"platform"` App string `json:"app"` PushToken string `json:"push_token"` DeviceID string `json:"device_id"` } type unregisterRequest struct { PushToken string `json:"push_token"` } func (h *Handler) Register(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) if claims == nil { apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "unauthorized", nil) return } var req registerRequest if err := apivalidate.DecodeJSON(w, r, maxRequestBody, &req); err != nil { return } platform := strings.TrimSpace(strings.ToLower(req.Platform)) app := strings.TrimSpace(req.App) pushToken := strings.TrimSpace(req.PushToken) deviceID := strings.TrimSpace(req.DeviceID) if verr := validateRegister(platform, app, pushToken); verr != nil { apivalidate.WriteValidationError(w, r, verr) return } id, err := h.svc.Register(r.Context(), claims.Sub, platform, app, pushToken, deviceID) if err != nil { if errors.Is(err, ErrUserNotFound) { apiresponse.WriteError(w, r, http.StatusNotFound, apiresponse.CodeNotFound, "user not found", nil) return } h.logger.Error("register device", "error", err, "sub", claims.Sub) apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"id": id}) } func (h *Handler) Unregister(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) if claims == nil { apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "unauthorized", nil) return } var req unregisterRequest if err := apivalidate.DecodeJSON(w, r, maxRequestBody, &req); err != nil { return } pushToken := strings.TrimSpace(req.PushToken) if pushToken == "" { apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(apivalidate.FieldDetail{ Field: "push_token", Message: "required", })) return } if err := h.svc.UnregisterByToken(r.Context(), claims.Sub, pushToken); err != nil { if errors.Is(err, ErrDeviceNotFound) || errors.Is(err, ErrUserNotFound) { w.WriteHeader(http.StatusNoContent) return } h.logger.Error("unregister device", "error", err, "sub", claims.Sub) apivalidate.WriteInternal(w, r) return } w.WriteHeader(http.StatusNoContent) } func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) if claims == nil { apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "unauthorized", nil) return } id := strings.TrimSpace(chi.URLParam(r, "id")) if id == "" { apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(apivalidate.FieldDetail{ Field: "id", Message: "required", })) return } if err := h.svc.UnregisterByID(r.Context(), claims.Sub, id); err != nil { if errors.Is(err, ErrDeviceNotFound) || errors.Is(err, ErrUserNotFound) { apivalidate.WriteNotFound(w, r, "device token not found") return } h.logger.Error("delete device", "error", err, "sub", claims.Sub) apivalidate.WriteInternal(w, r) return } w.WriteHeader(http.StatusNoContent) } func (h *Handler) List(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) if claims == nil { apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "unauthorized", nil) return } list, err := h.svc.List(r.Context(), claims.Sub) if err != nil { if errors.Is(err, ErrUserNotFound) { apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"devices": []Device{}}) return } h.logger.Error("list devices", "error", err, "sub", claims.Sub) apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"devices": list}) } func validateRegister(platform, app, pushToken string) *apivalidate.ValidationError { if platform != "ios" && platform != "android" { return apivalidate.NewValidationError(apivalidate.FieldDetail{ Field: "platform", Message: "must be 'ios' or 'android'", }) } if app == "" { return apivalidate.NewValidationError(apivalidate.FieldDetail{ Field: "app", Message: "required", }) } if pushToken == "" { return apivalidate.NewValidationError(apivalidate.FieldDetail{ Field: "push_token", Message: "required", }) } return nil }