Extend the push pipeline to Android devices through the Silo push relay's /v1/fcm/send endpoint. push_devices gains platform-conditional FCM token columns (encrypted at rest with row AAD, hashed like APNs tokens), the generic POST /notifications/push/devices endpoint the Android client already calls registers FCM tokens, and fanout, operational dispatch, retries, and terminal UNREGISTERED device disabling all reuse the existing Apple machinery. Delivery is gated by a new notifications.android_push_delivery_enabled setting, advertised through the capability endpoint's android_push block, and testable via POST /admin/notifications/push/fcm/test. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
154 lines
5.5 KiB
Go
154 lines
5.5 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
|
|
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
|
"github.com/Silo-Server/silo-server/internal/notifications"
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
type applePushRegisterRequest struct {
|
|
DeviceID string `json:"device_id"`
|
|
APNsToken string `json:"apns_token"`
|
|
APNsEnvironment string `json:"apns_environment"`
|
|
APNsTopic string `json:"apns_topic"`
|
|
PushMode string `json:"push_mode"`
|
|
}
|
|
|
|
type applePushRegisterResponse struct {
|
|
ID string `json:"id"`
|
|
ServerDeviceID string `json:"server_device_id"`
|
|
Enabled bool `json:"enabled"`
|
|
PushMode string `json:"push_mode"`
|
|
}
|
|
|
|
func (h *NotificationsHandler) pushDevices() *notifications.PushDeviceService {
|
|
if h == nil || h.system == nil {
|
|
return nil
|
|
}
|
|
return h.system.PushDevices
|
|
}
|
|
|
|
// HandleRegisterApplePushDevice handles POST /devices/push/apple.
|
|
func (h *NotificationsHandler) HandleRegisterApplePushDevice(w http.ResponseWriter, r *http.Request) {
|
|
service := h.pushDevices()
|
|
if service == nil || !service.Available() {
|
|
writeError(w, http.StatusServiceUnavailable, "unavailable", "Apple push registration is not available")
|
|
return
|
|
}
|
|
|
|
var req applePushRegisterRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
|
|
device, err := service.RegisterApple(r.Context(), apimw.GetUserID(r.Context()), apimw.GetProfileID(r.Context()), notifications.ApplePushRegistrationInput{
|
|
DeviceID: req.DeviceID,
|
|
APNsToken: req.APNsToken,
|
|
APNsEnvironment: req.APNsEnvironment,
|
|
APNsTopic: req.APNsTopic,
|
|
PushMode: req.PushMode,
|
|
})
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, notifications.ErrPushDeviceInvalid):
|
|
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
case errors.Is(err, notifications.ErrPushDeviceUnsupported):
|
|
writeError(w, http.StatusUnprocessableEntity, "unsupported_push_device", err.Error())
|
|
case errors.Is(err, notifications.ErrPushDeviceUnavailable):
|
|
writeError(w, http.StatusServiceUnavailable, "unavailable", "Apple push registration is not available")
|
|
default:
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to register Apple push device")
|
|
}
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, applePushRegisterResponse{
|
|
ID: device.ID,
|
|
ServerDeviceID: device.ServerDeviceID,
|
|
Enabled: device.Enabled,
|
|
PushMode: device.PushMode,
|
|
})
|
|
}
|
|
|
|
type pushDeviceRegisterRequest struct {
|
|
Platform string `json:"platform"`
|
|
Token string `json:"token"`
|
|
DeviceID string `json:"device_id"`
|
|
PushMode string `json:"push_mode"`
|
|
}
|
|
|
|
// HandleRegisterPushDevice handles POST /notifications/push/devices, the
|
|
// platform-generic registration used by the Android client. Apple installs
|
|
// keep the dedicated /devices/push/apple route because their registration
|
|
// carries APNs-specific fields (environment, topic).
|
|
func (h *NotificationsHandler) HandleRegisterPushDevice(w http.ResponseWriter, r *http.Request) {
|
|
service := h.pushDevices()
|
|
if service == nil || !service.Available() {
|
|
writeError(w, http.StatusServiceUnavailable, "unavailable", "Push registration is not available")
|
|
return
|
|
}
|
|
|
|
var req pushDeviceRegisterRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
if req.Platform != notifications.PushPlatformAndroid {
|
|
writeError(w, http.StatusUnprocessableEntity, "unsupported_push_device", "platform is not supported on this endpoint")
|
|
return
|
|
}
|
|
|
|
device, err := service.RegisterFCM(r.Context(), apimw.GetUserID(r.Context()), apimw.GetProfileID(r.Context()), notifications.FCMPushRegistrationInput{
|
|
DeviceID: req.DeviceID,
|
|
FCMToken: req.Token,
|
|
PushMode: req.PushMode,
|
|
})
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, notifications.ErrPushDeviceInvalid):
|
|
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
case errors.Is(err, notifications.ErrPushDeviceUnsupported):
|
|
writeError(w, http.StatusUnprocessableEntity, "unsupported_push_device", err.Error())
|
|
case errors.Is(err, notifications.ErrPushDeviceUnavailable):
|
|
writeError(w, http.StatusServiceUnavailable, "unavailable", "Push registration is not available")
|
|
default:
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to register push device")
|
|
}
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, applePushRegisterResponse{
|
|
ID: device.ID,
|
|
ServerDeviceID: device.ServerDeviceID,
|
|
Enabled: device.Enabled,
|
|
PushMode: device.PushMode,
|
|
})
|
|
}
|
|
|
|
// HandleUnregisterPushDevice handles DELETE /notifications/push/devices/{device_id}.
|
|
func (h *NotificationsHandler) HandleUnregisterPushDevice(w http.ResponseWriter, r *http.Request) {
|
|
service := h.pushDevices()
|
|
if service == nil || !service.Available() {
|
|
writeError(w, http.StatusServiceUnavailable, "unavailable", "Push registration is not available")
|
|
return
|
|
}
|
|
err := service.Unregister(r.Context(), apimw.GetProfileID(r.Context()), chi.URLParam(r, "device_id"))
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, notifications.ErrPushDeviceInvalid):
|
|
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
case errors.Is(err, notifications.ErrPushDeviceUnavailable):
|
|
writeError(w, http.StatusServiceUnavailable, "unavailable", "Push registration is not available")
|
|
default:
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to unregister push device")
|
|
}
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|