ultisuite-backend/internal/api/devices/handlers.go
R3D347HR4Y f97988b51f
Some checks failed
CI / Go tests (push) Has been cancelled
CI / Integration tests (push) Has been cancelled
CI / DB migrations (push) Has been cancelled
feat(devices): implement mobile device token management and push notifications
- Added device token management API for mobile devices, including registration, unregistration, and listing of devices.
- Implemented push notification functionality using FCM for Android and APNS for iOS.
- Introduced new endpoints for device registration and management in the devices API.
- Enhanced the configuration to support mobile push notifications with optional credentials for FCM and APNS.
- Updated database schema to include a new table for storing device tokens.
- Added integration tests for device management and push notification features.
2026-06-17 00:11:25 +02:00

181 lines
5.1 KiB
Go

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
}