From f97988b51ff1c03b8a2c6118d61fc043df812202 Mon Sep 17 00:00:00 2001 From: R3D347HR4Y Date: Wed, 17 Jun 2026 00:11:25 +0200 Subject: [PATCH] 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. --- .env.example | 28 ++++ deploy/expose.sh | 28 ++++ deploy/jitsi/docker-compose.jitsi.yml | 13 +- internal/api/contacts/handlers.go | 43 +++++ internal/api/contacts/import.go | 110 ++++++++++++ internal/api/contacts/service.go | 33 ++++ internal/api/devices/handlers.go | 180 ++++++++++++++++++++ internal/api/devices/service.go | 145 ++++++++++++++++ internal/config/config.go | 18 ++ internal/mail/imap/pipeline.go | 73 +++++++- internal/mail/imap/sync.go | 3 +- internal/push/apns.go | 204 +++++++++++++++++++++++ internal/push/fcm.go | 145 ++++++++++++++++ internal/push/push.go | 183 ++++++++++++++++++++ internal/server/bootstrap.go | 14 ++ migrations/000054_device_tokens.down.sql | 1 + migrations/000054_device_tokens.up.sql | 13 ++ package.json | 7 + 18 files changed, 1235 insertions(+), 6 deletions(-) create mode 100755 deploy/expose.sh create mode 100644 internal/api/contacts/import.go create mode 100644 internal/api/devices/handlers.go create mode 100644 internal/api/devices/service.go create mode 100644 internal/push/apns.go create mode 100644 internal/push/fcm.go create mode 100644 internal/push/push.go create mode 100644 migrations/000054_device_tokens.down.sql create mode 100644 migrations/000054_device_tokens.up.sql create mode 100644 package.json diff --git a/.env.example b/.env.example index be75612..51a79b8 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,9 @@ JITSI_INTERNAL_AUTH_PASSWORD=changeme KEYDB_PASSWORD= MEILISEARCH_API_KEY=changeme TYPESENSE_API_KEY=changeme +# Cloudflare Tunnel — dev local exposé publiquement (npm run expose) +# CLOUDFLARE_TUNNEL_TOKEN= +# CLOUDFLARE_TUNNEL_PUBLIC_URL=https://dev.ultispace.fr # ----------------------------------------------------------------------------- # General @@ -207,6 +210,8 @@ SKYNET_WHISPER_MODEL=tiny JICOFO_AUTH_PASSWORD={{JITSI_INTERNAL_AUTH_PASSWORD}} JVB_AUTH_PASSWORD={{JITSI_INTERNAL_AUTH_PASSWORD}} +JIGASI_XMPP_PASSWORD={{JITSI_INTERNAL_AUTH_PASSWORD}} +JIGASI_TRANSCRIBER_PASSWORD={{JITSI_INTERNAL_AUTH_PASSWORD}} JVB_STUN_SERVERS=stun.l.google.com:19302 # ----------------------------------------------------------------------------- @@ -343,3 +348,26 @@ SEARCH_ENGINE=postgres # VirusTotal (optional env fallback; prefer admin Settings > File policies) # ----------------------------------------------------------------------------- # VIRUSTOTAL_API_KEY= + +# ----------------------------------------------------------------------------- +# Mobile push notifications (FCM Android + APNS iOS) — optional +# When unset, the push dispatcher logs and skips (local dev works without creds). +# Tokens are registered by mobile apps via POST /api/v1/devices/register and a +# new incoming mail fans out a push alongside the existing WS broadcast. +# ----------------------------------------------------------------------------- +# --- FCM (Android), HTTP v1 API --- +# Service account JSON (single line, e.g. from Firebase console > Service accounts). +# project_id is read from the JSON unless FCM_PROJECT_ID overrides it. +# Runtime secret supported via FCM_SERVICE_ACCOUNT_JSON_FILE. +FCM_SERVICE_ACCOUNT_JSON= +# FCM_PROJECT_ID= + +# --- APNS (iOS), token-based .p8 auth --- +# PEM contents of the AuthKey_XXXXXXXXXX.p8 file (multi-line OK with quotes, or via APNS_PRIVATE_KEY_FILE). +APNS_PRIVATE_KEY= +# 10-char Key ID from the Apple Developer key, the Apple Team ID, and the app bundle id (apns-topic). +APNS_KEY_ID= +APNS_TEAM_ID= +APNS_BUNDLE_ID= +# false = sandbox gateway (development builds); true = production gateway. +APNS_PRODUCTION=false diff --git a/deploy/expose.sh b/deploy/expose.sh new file mode 100755 index 0000000..d238d8b --- /dev/null +++ b/deploy/expose.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Expose local nginx (:80) via Cloudflare Tunnel (e.g. https://dev.ultispace.fr). +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +if [[ ! -f .env ]]; then + echo "Missing .env — run: cp .env.example .env" >&2 + exit 1 +fi + +set -a +# shellcheck disable=SC1091 +source .env +set +a + +if [[ -z "${CLOUDFLARE_TUNNEL_TOKEN:-}" ]]; then + echo "CLOUDFLARE_TUNNEL_TOKEN is not set in .env" >&2 + exit 1 +fi + +PUBLIC_URL="${CLOUDFLARE_TUNNEL_PUBLIC_URL:-https://dev.ultispace.fr}" +echo "Cloudflare tunnel → local stack (nginx :80)" +echo "Public URL: ${PUBLIC_URL}" +echo "Start stack first: ./deploy/compose-up.sh up -d" + +exec cloudflared tunnel --loglevel error run --token "$CLOUDFLARE_TUNNEL_TOKEN" diff --git a/deploy/jitsi/docker-compose.jitsi.yml b/deploy/jitsi/docker-compose.jitsi.yml index 980b477..951e7f9 100644 --- a/deploy/jitsi/docker-compose.jitsi.yml +++ b/deploy/jitsi/docker-compose.jitsi.yml @@ -3,6 +3,9 @@ x-jitsi-env: &jitsi-env JWT_APP_SECRET: ${JITSI_APP_SECRET:-changeme-jwt-secret} JICOFO_AUTH_PASSWORD: ${JICOFO_AUTH_PASSWORD:-changeme} JVB_AUTH_PASSWORD: ${JVB_AUTH_PASSWORD:-changeme} + JIGASI_XMPP_PASSWORD: ${JIGASI_XMPP_PASSWORD:-changeme} + JIGASI_TRANSCRIBER_PASSWORD: ${JIGASI_TRANSCRIBER_PASSWORD:-changeme} + XMPP_SERVER: jitsi-prosody TZ: Europe/Paris services: @@ -19,6 +22,7 @@ services: XMPP_DOMAIN: meet.jitsi XMPP_MUC_DOMAIN: muc.meet.jitsi XMPP_BOSH_URL_BASE: http://jitsi-prosody:5280 + ENABLE_TRANSCRIPTIONS: "1" networks: - ulti-net depends_on: @@ -37,6 +41,7 @@ services: XMPP_DOMAIN: meet.jitsi XMPP_MUC_DOMAIN: muc.meet.jitsi XMPP_INTERNAL_MUC_DOMAIN: internal-muc.meet.jitsi + ENABLE_TRANSCRIPTIONS: "1" networks: - ulti-net @@ -48,6 +53,7 @@ services: XMPP_DOMAIN: meet.jitsi XMPP_MUC_DOMAIN: muc.meet.jitsi XMPP_INTERNAL_MUC_DOMAIN: internal-muc.meet.jitsi + ENABLE_TRANSCRIPTIONS: "1" networks: - ulti-net depends_on: @@ -72,7 +78,7 @@ services: skynet: build: - context: ./skynet + context: jitsi/skynet dockerfile: Dockerfile restart: unless-stopped environment: @@ -94,15 +100,14 @@ services: XMPP_DOMAIN: meet.jitsi XMPP_MUC_DOMAIN: muc.meet.jitsi XMPP_INTERNAL_MUC_DOMAIN: internal-muc.meet.jitsi - JIGASI_BREWERY_MUC: jigasibrewery@internal-muc.meet.jitsi + JIGASI_MODE: transcriber + JIGASI_BREWERY_MUC: jigasibrewery JIGASI_ENABLE_SDES_SRTP: "0" ENABLE_TRANSCRIPTIONS: "1" JIGASI_TRANSCRIBER_CUSTOM_SERVICE: org.jitsi.jigasi.transcription.WhisperTranscriptionService JIGASI_TRANSCRIBER_WHISPER_URL: ws://skynet:8000/streaming-whisper/ws JIGASI_TRANSCRIBER_SEND_JSON: "true" JIGASI_TRANSCRIBER_BASE_URL: http://ultid:8080/api/v1/meet/transcripts/ - volumes: - - ./jigasi:/config:ro networks: - ulti-net depends_on: diff --git a/internal/api/contacts/handlers.go b/internal/api/contacts/handlers.go index 9831141..05c29f7 100644 --- a/internal/api/contacts/handlers.go +++ b/internal/api/contacts/handlers.go @@ -62,6 +62,7 @@ func (h *Handler) Routes() chi.Router { r.With(read).Get("/interactions", h.GetInteractionsByEmail) r.With(read).Get("/*", h.GetContact) r.With(write).Post("/books/{bookID}", h.CreateContact) + r.With(write).Post("/books/{bookID}/import", h.ImportContacts) r.With(write).Post("/books/{bookID}/merge-duplicates", h.MergeDuplicateContacts) r.With(write).Post("/improve", h.ImproveContact) r.With(write).Put("/*", h.UpdateContact) @@ -235,6 +236,48 @@ func (h *Handler) CreateContact(w http.ResponseWriter, r *http.Request) { apiresponse.WriteJSON(w, http.StatusCreated, created) } +func (h *Handler) ImportContacts(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + ncUser, ok := h.nextcloudUser(w, r, claims) + if !ok { + return + } + + var req importRequest + if err := apivalidate.DecodeJSON(w, r, maxImportBody, &req); err != nil { + return + } + contacts, preFailures, verr := parseImportContacts(req.Contacts) + if verr != nil { + apivalidate.WriteValidationError(w, r, verr) + return + } + + bookID := chi.URLParam(r, "bookID") + var result ImportResult + err := h.retryOnDAVMissing(r.Context(), claims, ncUser, func(userID string) error { + var importErr error + result, importErr = h.svc.ImportContacts(r.Context(), userID, bookID, contacts.contacts) + return importErr + }) + if err != nil { + h.writeContactServiceError(w, r, "import contacts", err) + return + } + + if h.automation != nil { + for i := range result.Contacts { + h.automation.OnContactEvent(r.Context(), claims.Sub, rules.TriggerContactCreated, contactPayloadFrom(bookID, &result.Contacts[i])) + } + } + + failed := mergeImportFailures(preFailures, result.Failed, contacts.originalIndex) + apiresponse.WriteJSON(w, http.StatusOK, map[string]any{ + "created": result.Created, + "failed": failed, + }) +} + func (h *Handler) GetContact(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) ncUser, ok := h.nextcloudUser(w, r, claims) diff --git a/internal/api/contacts/import.go b/internal/api/contacts/import.go new file mode 100644 index 0000000..105c449 --- /dev/null +++ b/internal/api/contacts/import.go @@ -0,0 +1,110 @@ +package contacts + +import ( + "encoding/json" + "sort" + "strings" + + "github.com/ultisuite/ulti-backend/internal/api/apivalidate" + "github.com/ultisuite/ulti-backend/internal/nextcloud" +) + +const ( + maxImportBody = 8 << 20 // 8 MiB + maxContactsPerImport = 5000 +) + +// importRequest is the bulk import body. Each element of contacts is either a +// raw vCard string ("BEGIN:VCARD...") or a structured contact object matching +// the single-create shape (full_name/email/phone/org or raw_vcard). +type importRequest struct { + Contacts []json.RawMessage `json:"contacts"` +} + +// parsedImport holds the valid contacts to send and a mapping back to the +// caller's original array index (so failures can be reported precisely). +type parsedImport struct { + contacts []nextcloud.Contact + originalIndex []int +} + +func parseImportContacts(items []json.RawMessage) (parsedImport, []ImportFailure, *apivalidate.ValidationError) { + if len(items) == 0 { + return parsedImport{}, nil, apivalidate.NewValidationError(apivalidate.FieldDetail{ + Field: "contacts", Message: "at least one contact is required", + }) + } + if len(items) > maxContactsPerImport { + return parsedImport{}, nil, apivalidate.NewValidationError(apivalidate.FieldDetail{ + Field: "contacts", Message: "too many contacts in a single import", + }) + } + + out := parsedImport{ + contacts: make([]nextcloud.Contact, 0, len(items)), + originalIndex: make([]int, 0, len(items)), + } + preFailures := make([]ImportFailure, 0) + + for i, raw := range items { + contact, err := decodeImportItem(raw) + if err != "" { + preFailures = append(preFailures, ImportFailure{Index: i, Error: err}) + continue + } + out.contacts = append(out.contacts, contact) + out.originalIndex = append(out.originalIndex, i) + } + + return out, preFailures, nil +} + +// decodeImportItem turns a single array element into a Contact, returning a +// non-empty error string when the element is unusable. +func decodeImportItem(raw json.RawMessage) (nextcloud.Contact, string) { + trimmed := strings.TrimSpace(string(raw)) + if trimmed == "" || trimmed == "null" { + return nextcloud.Contact{}, "empty contact" + } + + // A JSON string element is treated as a raw vCard. + if trimmed[0] == '"' { + var vcard string + if err := json.Unmarshal(raw, &vcard); err != nil { + return nextcloud.Contact{}, "invalid vcard string" + } + vcard = strings.TrimSpace(vcard) + if vcard == "" { + return nextcloud.Contact{}, "empty vcard" + } + return nextcloud.Contact{RawVCard: vcard}, "" + } + + var contact nextcloud.Contact + if err := json.Unmarshal(raw, &contact); err != nil { + return nextcloud.Contact{}, "invalid contact object" + } + if strings.TrimSpace(contact.RawVCard) == "" && strings.TrimSpace(contact.FullName) == "" { + return nextcloud.Contact{}, "full_name or raw_vcard required" + } + return contact, "" +} + +// mergeImportFailures combines pre-send validation failures with service send +// failures, remapping service indices (relative to the valid slice) back to the +// caller's original array indices, sorted ascending. +func mergeImportFailures(pre []ImportFailure, svc []ImportFailure, originalIndex []int) []ImportFailure { + merged := make([]ImportFailure, 0, len(pre)+len(svc)) + merged = append(merged, pre...) + for _, f := range svc { + idx := f.Index + if idx >= 0 && idx < len(originalIndex) { + idx = originalIndex[idx] + } + merged = append(merged, ImportFailure{Index: idx, Error: f.Error}) + } + sort.SliceStable(merged, func(i, j int) bool { + return merged[i].Index < merged[j].Index + }) + return merged +} diff --git a/internal/api/contacts/service.go b/internal/api/contacts/service.go index 43cf46d..b5d0a87 100644 --- a/internal/api/contacts/service.go +++ b/internal/api/contacts/service.go @@ -99,6 +99,39 @@ func (s *Service) CreateContact(ctx context.Context, userID, bookID string, cont return s.nc.CreateContact(ctx, userID, bookPath(userID, bookID), contact) } +// ImportFailure describes a single contact that could not be imported. +type ImportFailure struct { + Index int `json:"index"` + Error string `json:"error"` +} + +// ImportResult is the outcome of a bulk import. +type ImportResult struct { + Created int `json:"created"` + Failed []ImportFailure `json:"failed"` + Contacts []nextcloud.Contact `json:"contacts,omitempty"` +} + +// ImportContacts creates many contacts in a single address book. Each item is +// created independently; failures are collected and reported without aborting +// the rest of the batch. +func (s *Service) ImportContacts(ctx context.Context, userID, bookID string, contacts []nextcloud.Contact) (ImportResult, error) { + result := ImportResult{Failed: make([]ImportFailure, 0), Contacts: make([]nextcloud.Contact, 0, len(contacts))} + path := bookPath(userID, bookID) + for i := range contacts { + created, err := s.nc.CreateContact(ctx, userID, path, &contacts[i]) + if err != nil { + result.Failed = append(result.Failed, ImportFailure{Index: i, Error: err.Error()}) + continue + } + result.Created++ + if created != nil { + result.Contacts = append(result.Contacts, *created) + } + } + return result, nil +} + func (s *Service) GetContact(ctx context.Context, userID, contactPath string) (*nextcloud.Contact, error) { return s.nc.GetContact(ctx, userID, contactPath) } diff --git a/internal/api/devices/handlers.go b/internal/api/devices/handlers.go new file mode 100644 index 0000000..4688c96 --- /dev/null +++ b/internal/api/devices/handlers.go @@ -0,0 +1,180 @@ +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 +} diff --git a/internal/api/devices/service.go b/internal/api/devices/service.go new file mode 100644 index 0000000..d7b306c --- /dev/null +++ b/internal/api/devices/service.go @@ -0,0 +1,145 @@ +package devices + +import ( + "context" + "errors" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/ultisuite/ulti-backend/internal/users" +) + +// ErrUserNotFound is returned when the authenticated user has no provisioned row. +var ErrUserNotFound = errors.New("user not found") + +// ErrDeviceNotFound is returned when a delete affects no rows. +var ErrDeviceNotFound = errors.New("device token not found") + +// Device is a registered mobile device push token. +type Device struct { + ID string `json:"id"` + Platform string `json:"platform"` + App string `json:"app"` + DeviceID string `json:"device_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Service persists device tokens keyed by the internal user id. +type Service struct { + db *pgxpool.Pool +} + +func NewService(db *pgxpool.Pool) *Service { + return &Service{db: db} +} + +// Register upserts a device token for the given external user id (OIDC sub), +// returning the row id. Re-registering an identical (user, app, push_token) +// refreshes platform/device_id/updated_at. +func (s *Service) Register(ctx context.Context, externalID, platform, app, pushToken, deviceID string) (string, error) { + userID, err := s.resolveUser(ctx, externalID) + if err != nil { + return "", err + } + + var devicePtr *string + if deviceID != "" { + devicePtr = &deviceID + } + + var id string + err = s.db.QueryRow(ctx, ` + INSERT INTO device_tokens (user_id, platform, app, push_token, device_id) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (user_id, app, push_token) + DO UPDATE SET platform = EXCLUDED.platform, + device_id = EXCLUDED.device_id, + updated_at = now() + RETURNING id::text + `, userID, platform, app, pushToken, devicePtr).Scan(&id) + if err != nil { + return "", err + } + return id, nil +} + +// UnregisterByID removes a device token owned by the user. +func (s *Service) UnregisterByID(ctx context.Context, externalID, id string) error { + userID, err := s.resolveUser(ctx, externalID) + if err != nil { + return err + } + tag, err := s.db.Exec(ctx, ` + DELETE FROM device_tokens WHERE id = $1 AND user_id = $2 + `, id, userID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrDeviceNotFound + } + return nil +} + +// UnregisterByToken removes a device token by its push token for the user. +func (s *Service) UnregisterByToken(ctx context.Context, externalID, pushToken string) error { + userID, err := s.resolveUser(ctx, externalID) + if err != nil { + return err + } + tag, err := s.db.Exec(ctx, ` + DELETE FROM device_tokens WHERE push_token = $1 AND user_id = $2 + `, pushToken, userID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrDeviceNotFound + } + return nil +} + +// List returns the user's registered devices, most recently updated first. +func (s *Service) List(ctx context.Context, externalID string) ([]Device, error) { + userID, err := s.resolveUser(ctx, externalID) + if err != nil { + return nil, err + } + rows, err := s.db.Query(ctx, ` + SELECT id::text, platform, app, coalesce(device_id, ''), created_at, updated_at + FROM device_tokens + WHERE user_id = $1 + ORDER BY updated_at DESC + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make([]Device, 0) + for rows.Next() { + var d Device + if err := rows.Scan(&d.ID, &d.Platform, &d.App, &d.DeviceID, &d.CreatedAt, &d.UpdatedAt); err != nil { + return nil, err + } + out = append(out, d) + } + return out, rows.Err() +} + +func (s *Service) resolveUser(ctx context.Context, externalID string) (string, error) { + userID, err := users.LookupUserID(ctx, s.db, externalID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return "", ErrUserNotFound + } + return "", err + } + if userID == "" { + return "", ErrUserNotFound + } + return userID, nil +} diff --git a/internal/config/config.go b/internal/config/config.go index 030e517..90e9410 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -166,6 +166,16 @@ type Config struct { // VirusTotal (optional env fallback for org file_policies.virustotal_api_key) VirusTotalAPIKey string + // Mobile push notifications (FCM Android + APNS iOS). + // All optional; when unset the dispatcher no-ops so local dev works. + FCMServiceAccountJSON string + FCMProjectID string + APNSPrivateKey string + APNSKeyID string + APNSTeamID string + APNSBundleID string + APNSProduction bool + // Observability HealthNextcloudURL string HealthImmichURL string @@ -315,6 +325,14 @@ func Load() (*Config, error) { VirusTotalAPIKey: secrets.Env("VIRUSTOTAL_API_KEY"), + FCMServiceAccountJSON: secrets.Env("FCM_SERVICE_ACCOUNT_JSON"), + FCMProjectID: os.Getenv("FCM_PROJECT_ID"), + APNSPrivateKey: secrets.Env("APNS_PRIVATE_KEY"), + APNSKeyID: os.Getenv("APNS_KEY_ID"), + APNSTeamID: os.Getenv("APNS_TEAM_ID"), + APNSBundleID: os.Getenv("APNS_BUNDLE_ID"), + APNSProduction: envBool("APNS_PRODUCTION", false), + HealthNextcloudURL: envOrDefault("HEALTH_NEXTCLOUD_URL", joinURL(envOrDefault("NEXTCLOUD_URL", "http://nextcloud:80"), "/status.php")), HealthImmichURL: envOrDefault("HEALTH_IMMICH_URL", joinURL(envOrDefault("IMMICH_API_URL", "http://immich-server:2283/api"), "/server-info/ping")), HealthJitsiURL: envOrDefault("HEALTH_JITSI_URL", defaultHealthJitsiURL(envOrDefault("JITSI_PUBLIC_URL", "https://localhost/meet"))), diff --git a/internal/mail/imap/pipeline.go b/internal/mail/imap/pipeline.go index 76fff31..f60f2ea 100644 --- a/internal/mail/imap/pipeline.go +++ b/internal/mail/imap/pipeline.go @@ -4,12 +4,19 @@ import ( "context" "encoding/json" "log/slog" + "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/ultisuite/ulti-backend/internal/mail/rules" "github.com/ultisuite/ulti-backend/internal/realtime" ) +// Pusher fans a mobile push notification out to a user's registered devices on +// a newly received inbox message. Implemented by *push.Dispatcher. +type Pusher interface { + NotifyNewMail(ctx context.Context, userID, messageID, accountID, sender, subject string) +} + type postSyncEvent struct { userID string accountID string @@ -24,15 +31,17 @@ type syncPipeline struct { rules *rules.Engine automation MailAutomation hub *realtime.Hub + push Pusher } -func newSyncPipeline(db *pgxpool.Pool, rulesEngine *rules.Engine, automation MailAutomation, hub *realtime.Hub) *syncPipeline { +func newSyncPipeline(db *pgxpool.Pool, rulesEngine *rules.Engine, automation MailAutomation, hub *realtime.Hub, pusher Pusher) *syncPipeline { return &syncPipeline{ db: db, logger: slog.Default().With("component", "imap-pipeline"), rules: rulesEngine, automation: automation, hub: hub, + push: pusher, } } @@ -58,10 +67,72 @@ func (p *syncPipeline) handle(ctx context.Context, ev postSyncEvent) { event := realtime.NewMailUpdatedEvent(ev.messageID, ev.accountID) if ev.kind == "created" { event = realtime.NewMailCreatedEvent(ev.messageID, ev.accountID) + p.maybePush(ctx, ev) } p.broadcast(ev, event) } +// maybePush fires a best-effort mobile push for a newly received inbox message. +// It skips non-inbox folders, already-read messages, and stale backfilled mail +// (older than 24h) to avoid notification storms during initial account sync. +func (p *syncPipeline) maybePush(ctx context.Context, ev postSyncEvent) { + if p.push == nil || ev.userID == "" { + return + } + + var ( + fromJSON []byte + subject string + date time.Time + folderType string + flags []string + ) + err := p.db.QueryRow(ctx, ` + SELECT m.from_addr, m.subject, m.date, COALESCE(mf.folder_type, ''), COALESCE(m.flags, '{}') + FROM messages m + LEFT JOIN mail_folders mf ON m.folder_id = mf.id + WHERE m.id = $1 + `, ev.messageID).Scan(&fromJSON, &subject, &date, &folderType, &flags) + if err != nil { + p.logger.Error("load message for push", "message_id", ev.messageID, "error", err) + return + } + + if folderType != "inbox" { + return + } + for _, f := range flags { + if f == `\Seen` { + return + } + } + if !date.IsZero() && time.Since(date) > 24*time.Hour { + return + } + + sender := firstAddressDisplay(fromJSON) + if subject == "" { + subject = "(no subject)" + } + + go p.push.NotifyNewMail(context.Background(), ev.userID, ev.messageID, ev.accountID, sender, subject) +} + +func firstAddressDisplay(fromJSON []byte) string { + var addrs []EmailAddress + if err := json.Unmarshal(fromJSON, &addrs); err != nil || len(addrs) == 0 { + return "New mail" + } + a := addrs[0] + if a.Name != "" { + return a.Name + } + if a.Address != "" { + return a.Address + } + return "New mail" +} + func (p *syncPipeline) broadcast(ev postSyncEvent, event realtime.Event) { if p.hub == nil || ev.userID == "" { return diff --git a/internal/mail/imap/sync.go b/internal/mail/imap/sync.go index d701e1e..2f39f53 100644 --- a/internal/mail/imap/sync.go +++ b/internal/mail/imap/sync.go @@ -40,6 +40,7 @@ type SyncDeps struct { Automation MailAutomation Hub *realtime.Hub FileScanner *filescan.Scanner + Push Pusher } type SyncWorker struct { @@ -64,7 +65,7 @@ func NewSyncWorker(db *pgxpool.Pool, interval time.Duration, credManager *creden storage: deps.Storage, attachBucket: deps.AttachBucket, scanner: deps.FileScanner, - pipeline: newSyncPipeline(db, deps.Rules, deps.Automation, deps.Hub), + pipeline: newSyncPipeline(db, deps.Rules, deps.Automation, deps.Hub, deps.Push), } } diff --git a/internal/push/apns.go b/internal/push/apns.go new file mode 100644 index 0000000..425d680 --- /dev/null +++ b/internal/push/apns.go @@ -0,0 +1,204 @@ +package push + +import ( + "context" + "crypto/ecdsa" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "math/big" + "net/http" + "strings" + "sync" + "time" +) + +// apnsClient sends notifications through the APNS HTTP/2 provider API using +// token-based (.p8) authentication. +type apnsClient struct { + key *ecdsa.PrivateKey + keyID string + teamID string + topic string + host string + http *http.Client + + mu sync.Mutex + cachedJWT string + jwtIssued time.Time +} + +// newAPNSClient returns a configured client, or nil (no error) when APNS is not +// configured. An error is returned only when provided credentials are invalid. +func newAPNSClient(cfg Config) (*apnsClient, error) { + pemKey := strings.TrimSpace(cfg.APNSPrivateKey) + if pemKey == "" { + return nil, nil + } + if strings.TrimSpace(cfg.APNSKeyID) == "" || + strings.TrimSpace(cfg.APNSTeamID) == "" || + strings.TrimSpace(cfg.APNSBundleID) == "" { + return nil, fmt.Errorf("apns requires APNS_KEY_ID, APNS_TEAM_ID and APNS_BUNDLE_ID") + } + + key, err := parseAPNSKey(pemKey) + if err != nil { + return nil, err + } + + host := "https://api.sandbox.push.apple.com" + if cfg.APNSProduction { + host = "https://api.push.apple.com" + } + + return &apnsClient{ + key: key, + keyID: strings.TrimSpace(cfg.APNSKeyID), + teamID: strings.TrimSpace(cfg.APNSTeamID), + topic: strings.TrimSpace(cfg.APNSBundleID), + host: host, + http: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +func parseAPNSKey(pemKey string) (*ecdsa.PrivateKey, error) { + block, _ := pem.Decode([]byte(pemKey)) + if block == nil { + return nil, fmt.Errorf("apns private key is not valid PEM") + } + parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parse apns private key: %w", err) + } + key, ok := parsed.(*ecdsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("apns private key is not an ECDSA key") + } + return key, nil +} + +// jwt returns a cached provider authentication token, regenerating it when it +// is older than 40 minutes (Apple allows reuse for up to 60 minutes). +func (c *apnsClient) jwt() (string, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.cachedJWT != "" && time.Since(c.jwtIssued) < 40*time.Minute { + return c.cachedJWT, nil + } + + now := time.Now() + header := map[string]string{"alg": "ES256", "kid": c.keyID} + claims := map[string]any{"iss": c.teamID, "iat": now.Unix()} + + headerJSON, _ := json.Marshal(header) + claimsJSON, _ := json.Marshal(claims) + + signingInput := base64URL(headerJSON) + "." + base64URL(claimsJSON) + + digest := sha256.Sum256([]byte(signingInput)) + r, s, err := ecdsa.Sign(rand.Reader, c.key, digest[:]) + if err != nil { + return "", fmt.Errorf("sign apns jwt: %w", err) + } + signature := ecdsaSignatureBytes(r, s) + + token := signingInput + "." + base64URL(signature) + c.cachedJWT = token + c.jwtIssued = now + return token, nil +} + +type apnsPayload struct { + APS apnsAPS `json:"aps"` + Data map[string]string `json:"data,omitempty"` +} + +type apnsAPS struct { + Alert apnsAlert `json:"alert"` + Sound string `json:"sound,omitempty"` +} + +type apnsAlert struct { + Title string `json:"title,omitempty"` + Body string `json:"body,omitempty"` +} + +func (c *apnsClient) send(ctx context.Context, deviceToken string, n Notification) error { + token, err := c.jwt() + if err != nil { + return err + } + + payload := apnsPayload{ + APS: apnsAPS{ + Alert: apnsAlert{Title: n.Title, Body: n.Body}, + Sound: "default", + }, + Data: n.Data, + } + body, err := json.Marshal(payload) + if err != nil { + return err + } + + url := c.host + "/3/device/" + deviceToken + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(body))) + if err != nil { + return err + } + req.Header.Set("authorization", "bearer "+token) + req.Header.Set("apns-topic", c.topic) + req.Header.Set("apns-push-type", "alert") + req.Header.Set("content-type", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + return nil + } + + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + // 410 Gone (and 400 BadDeviceToken/DeviceTokenNotForTopic) means prune. + if resp.StatusCode == http.StatusGone || apnsIsBadToken(respBody) { + return errTokenUnregistered + } + return fmt.Errorf("apns send status %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody))) +} + +func apnsIsBadToken(body []byte) bool { + var parsed struct { + Reason string `json:"reason"` + } + if err := json.Unmarshal(body, &parsed); err != nil { + return false + } + switch parsed.Reason { + case "Unregistered", "BadDeviceToken", "DeviceTokenNotForTopic": + return true + } + return false +} + +func base64URL(b []byte) string { + return base64.RawURLEncoding.EncodeToString(b) +} + +// ecdsaSignatureBytes encodes an ES256 signature as the fixed-width R||S form +// required by JWS (each integer left-padded to 32 bytes). +func ecdsaSignatureBytes(r, s *big.Int) []byte { + const size = 32 + out := make([]byte, size*2) + r.FillBytes(out[:size]) + s.FillBytes(out[size:]) + return out +} diff --git a/internal/push/fcm.go b/internal/push/fcm.go new file mode 100644 index 0000000..ab51002 --- /dev/null +++ b/internal/push/fcm.go @@ -0,0 +1,145 @@ +package push + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" +) + +const fcmMessagingScope = "https://www.googleapis.com/auth/firebase.messaging" + +// fcmClient sends notifications through the FCM HTTP v1 API. +type fcmClient struct { + projectID string + tokenSource oauth2.TokenSource + http *http.Client +} + +// newFCMClient returns a configured client, or nil (no error) when FCM is not +// configured. An error is returned only when provided credentials are invalid. +func newFCMClient(cfg Config) (*fcmClient, error) { + raw := strings.TrimSpace(cfg.FCMServiceAccountJSON) + if raw == "" { + return nil, nil + } + + creds, err := google.CredentialsFromJSON(context.Background(), []byte(raw), fcmMessagingScope) + if err != nil { + return nil, fmt.Errorf("parse fcm service account: %w", err) + } + + projectID := strings.TrimSpace(cfg.FCMProjectID) + if projectID == "" { + projectID = creds.ProjectID + } + if projectID == "" { + // Fall back to parsing the JSON directly for project_id. + var sa struct { + ProjectID string `json:"project_id"` + } + _ = json.Unmarshal([]byte(raw), &sa) + projectID = strings.TrimSpace(sa.ProjectID) + } + if projectID == "" { + return nil, fmt.Errorf("fcm project id missing (set FCM_PROJECT_ID or include project_id in service account)") + } + + return &fcmClient{ + projectID: projectID, + tokenSource: creds.TokenSource, + http: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +type fcmMessage struct { + Message fcmMessageBody `json:"message"` +} + +type fcmMessageBody struct { + Token string `json:"token"` + Notification fcmNotification `json:"notification"` + Data map[string]string `json:"data,omitempty"` +} + +type fcmNotification struct { + Title string `json:"title,omitempty"` + Body string `json:"body,omitempty"` +} + +func (c *fcmClient) send(ctx context.Context, deviceToken string, n Notification) error { + accessToken, err := c.tokenSource.Token() + if err != nil { + return fmt.Errorf("fcm access token: %w", err) + } + + payload := fcmMessage{ + Message: fcmMessageBody{ + Token: deviceToken, + Notification: fcmNotification{ + Title: n.Title, + Body: n.Body, + }, + Data: n.Data, + }, + } + body, err := json.Marshal(payload) + if err != nil { + return err + } + + url := fmt.Sprintf("https://fcm.googleapis.com/v1/projects/%s/messages:send", c.projectID) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+accessToken.AccessToken) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + // A 404 (NOT_FOUND) or UNREGISTERED error means the token is dead. + if resp.StatusCode == http.StatusNotFound || fcmIsUnregistered(respBody) { + return errTokenUnregistered + } + return fmt.Errorf("fcm send status %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody))) +} + +func fcmIsUnregistered(body []byte) bool { + var parsed struct { + Error struct { + Status string `json:"status"` + Details []struct { + ErrorCode string `json:"errorCode"` + } `json:"details"` + } `json:"error"` + } + if err := json.Unmarshal(body, &parsed); err != nil { + return false + } + if parsed.Error.Status == "NOT_FOUND" { + return true + } + for _, d := range parsed.Error.Details { + if d.ErrorCode == "UNREGISTERED" { + return true + } + } + return false +} diff --git a/internal/push/push.go b/internal/push/push.go new file mode 100644 index 0000000..fa813a8 --- /dev/null +++ b/internal/push/push.go @@ -0,0 +1,183 @@ +// Package push delivers mobile push notifications to registered device tokens +// via FCM (Android, HTTP v1) and APNS (iOS, HTTP/2). When credentials are not +// configured the dispatcher degrades to a no-op so local development keeps +// working without Firebase/Apple secrets. +package push + +import ( + "context" + "errors" + "log/slog" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// Config holds FCM/APNS credentials. All fields are optional; a provider is only +// enabled when its required fields are present. +type Config struct { + // FCM (Android) — service account JSON (HTTP v1 API). + FCMServiceAccountJSON string + FCMProjectID string // optional; derived from the service account JSON when empty + + // APNS (iOS) — token-based auth (.p8 key). + APNSPrivateKey string // PEM contents of the AuthKey_XXXX.p8 file + APNSKeyID string + APNSTeamID string + APNSBundleID string // apns-topic (app bundle identifier) + APNSProduction bool // false -> sandbox gateway +} + +// Notification is the platform-agnostic payload fanned out to devices. +type Notification struct { + Title string + Body string + // Data is delivered as the FCM data map / APNS custom keys. Values must be strings. + Data map[string]string +} + +// deviceToken is a single registered device row. +type deviceToken struct { + id string + platform string + app string + pushToken string +} + +// Dispatcher loads device tokens and sends notifications best-effort. +type Dispatcher struct { + db *pgxpool.Pool + fcm *fcmClient + apns *apnsClient + logger *slog.Logger +} + +// NewDispatcher builds a dispatcher. Missing credentials disable the relevant +// provider; an entirely unconfigured dispatcher silently skips all sends. +func NewDispatcher(db *pgxpool.Pool, cfg Config) *Dispatcher { + logger := slog.Default().With("component", "push") + + d := &Dispatcher{db: db, logger: logger} + + if fcm, err := newFCMClient(cfg); err != nil { + logger.Warn("fcm push disabled", "error", err) + } else if fcm != nil { + d.fcm = fcm + logger.Info("fcm push enabled", "project_id", fcm.projectID) + } + + if apns, err := newAPNSClient(cfg); err != nil { + logger.Warn("apns push disabled", "error", err) + } else if apns != nil { + d.apns = apns + logger.Info("apns push enabled", "bundle_id", apns.topic, "production", cfg.APNSProduction) + } + + return d +} + +// Enabled reports whether at least one provider is configured. +func (d *Dispatcher) Enabled() bool { + return d != nil && (d.fcm != nil || d.apns != nil) +} + +// NotifyUser fans a notification out to every registered device of the given +// internal user id (users.id UUID). It is best-effort: failures are logged and +// stale tokens are pruned, but no error is returned. +func (d *Dispatcher) NotifyUser(ctx context.Context, userID string, n Notification) { + if d == nil || d.db == nil || userID == "" { + return + } + if !d.Enabled() { + return + } + + tokens, err := d.loadTokens(ctx, userID) + if err != nil { + d.logger.Error("load device tokens", "user_id", userID, "error", err) + return + } + if len(tokens) == 0 { + return + } + + for _, t := range tokens { + var sendErr error + switch t.platform { + case "android": + if d.fcm == nil { + continue + } + sendErr = d.fcm.send(ctx, t.pushToken, n) + case "ios": + if d.apns == nil { + continue + } + sendErr = d.apns.send(ctx, t.pushToken, n) + default: + continue + } + + if sendErr == nil { + continue + } + if errors.Is(sendErr, errTokenUnregistered) { + d.deleteToken(ctx, t.id) + d.logger.Info("pruned unregistered device token", "platform", t.platform, "app", t.app) + continue + } + d.logger.Warn("push send failed", "platform", t.platform, "app", t.app, "error", sendErr) + } +} + +// NotifyNewMail sends a "new mail" notification to the user's devices. It +// satisfies the imap.Pusher interface used by the mail sync pipeline. +func (d *Dispatcher) NotifyNewMail(ctx context.Context, userID, messageID, accountID, sender, subject string) { + if d == nil || !d.Enabled() { + return + } + title := sender + if title == "" { + title = "New mail" + } + d.NotifyUser(ctx, userID, Notification{ + Title: title, + Body: subject, + Data: map[string]string{ + "type": "mail.created", + "message_id": messageID, + "account_id": accountID, + }, + }) +} + +func (d *Dispatcher) loadTokens(ctx context.Context, userID string) ([]deviceToken, error) { + rows, err := d.db.Query(ctx, ` + SELECT id::text, platform, app, push_token + FROM device_tokens + WHERE user_id = $1 + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []deviceToken + for rows.Next() { + var t deviceToken + if err := rows.Scan(&t.id, &t.platform, &t.app, &t.pushToken); err != nil { + return nil, err + } + out = append(out, t) + } + return out, rows.Err() +} + +func (d *Dispatcher) deleteToken(ctx context.Context, id string) { + if _, err := d.db.Exec(ctx, `DELETE FROM device_tokens WHERE id = $1`, id); err != nil { + d.logger.Warn("delete stale device token", "id", id, "error", err) + } +} + +// errTokenUnregistered signals that a device token is no longer valid and +// should be removed from storage. +var errTokenUnregistered = errors.New("push: device token unregistered") diff --git a/internal/server/bootstrap.go b/internal/server/bootstrap.go index 126b380..09de403 100644 --- a/internal/server/bootstrap.go +++ b/internal/server/bootstrap.go @@ -20,6 +20,7 @@ import ( "github.com/ultisuite/ulti-backend/internal/api/admin" "github.com/ultisuite/ulti-backend/internal/api/calendar" "github.com/ultisuite/ulti-backend/internal/api/contacts" + "github.com/ultisuite/ulti-backend/internal/api/devices" "github.com/ultisuite/ulti-backend/internal/api/docs" "github.com/ultisuite/ulti-backend/internal/api/drive" mailapi "github.com/ultisuite/ulti-backend/internal/api/mail" @@ -56,6 +57,7 @@ import ( "github.com/ultisuite/ulti-backend/internal/observability" "github.com/ultisuite/ulti-backend/internal/orgpolicy" "github.com/ultisuite/ulti-backend/internal/photos" + "github.com/ultisuite/ulti-backend/internal/push" "github.com/ultisuite/ulti-backend/internal/realtime" "github.com/ultisuite/ulti-backend/internal/search" "github.com/ultisuite/ulti-backend/internal/securityaudit" @@ -195,6 +197,16 @@ func New(ctx context.Context, cfg *config.Config, opts Options) (*App, error) { hub := realtime.NewHub(verifierHolder, pool) healthChecker := observability.NewHealthChecker(cfg, pool, rdb) + pushDispatcher := push.NewDispatcher(pool, push.Config{ + FCMServiceAccountJSON: cfg.FCMServiceAccountJSON, + FCMProjectID: cfg.FCMProjectID, + APNSPrivateKey: cfg.APNSPrivateKey, + APNSKeyID: cfg.APNSKeyID, + APNSTeamID: cfg.APNSTeamID, + APNSBundleID: cfg.APNSBundleID, + APNSProduction: cfg.APNSProduction, + }) + hookExec := webhooks.NewExecutor(pool) rulesEngine := rules.NewEngineWithWebhooks(pool, hookExec) autoDispatcher := automation.NewDispatcher(pool, rulesEngine, hookExec) @@ -300,6 +312,7 @@ func New(ctx context.Context, cfg *config.Config, opts Options) (*App, error) { Automation: autoDispatcher, Hub: hub, FileScanner: fileScanner, + Push: pushDispatcher, }) go syncWorker.Start(workerCtx) } @@ -414,6 +427,7 @@ func New(ctx context.Context, cfg *config.Config, opts Options) (*App, error) { r.Use(middleware.EnforceApiTokenPolicy()) r.Mount("/api/v1/users", usersapi.NewHandler(pool, cfg).Routes()) + r.Mount("/api/v1/devices", devices.NewHandler(pool).Routes()) adminHandler := admin.NewHandler(pool, auditLogger, cfg, ncClient) adminHandler.SetHostedService(hostedSvc) adminHandler.SetMigrationService(migrationSvc) diff --git a/migrations/000054_device_tokens.down.sql b/migrations/000054_device_tokens.down.sql new file mode 100644 index 0000000..99a806d --- /dev/null +++ b/migrations/000054_device_tokens.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS device_tokens; diff --git a/migrations/000054_device_tokens.up.sql b/migrations/000054_device_tokens.up.sql new file mode 100644 index 0000000..d3725e1 --- /dev/null +++ b/migrations/000054_device_tokens.up.sql @@ -0,0 +1,13 @@ +CREATE TABLE device_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + platform TEXT NOT NULL CHECK (platform IN ('ios', 'android')), + app TEXT NOT NULL, + push_token TEXT NOT NULL, + device_id TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX idx_device_tokens_user_app_token ON device_tokens(user_id, app, push_token); +CREATE INDEX idx_device_tokens_user ON device_tokens(user_id); diff --git a/package.json b/package.json new file mode 100644 index 0000000..d984e47 --- /dev/null +++ b/package.json @@ -0,0 +1,7 @@ +{ + "name": "ulti-backend", + "private": true, + "scripts": { + "expose": "bash deploy/expose.sh" + } +}