From 5f188ec7072417512f5da3dc949d0bf4c4f6ea8d Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Thu, 26 Mar 2026 11:21:10 -0700 Subject: [PATCH 1/3] Add notification campaign feature --- api/dbv1/models.go | 6 ++ api/server.go | 2 + api/swagger/swagger-v1.yaml | 2 +- api/v1_notification_campaign_push_open.go | 88 +++++++++++++++++++ config/config.go | 4 + ...260316_notification_campaign_push_open.sql | 20 +++++ 6 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 api/v1_notification_campaign_push_open.go create mode 100644 sql/migrations/20260316_notification_campaign_push_open.sql diff --git a/api/dbv1/models.go b/api/dbv1/models.go index bb9fdfc1..32838e07 100644 --- a/api/dbv1/models.go +++ b/api/dbv1/models.go @@ -1381,6 +1381,12 @@ type Notification struct { TypeV2 pgtype.Text `json:"type_v2"` } +type NotificationCampaignPushOpen struct { + CampaignID pgtype.UUID `json:"campaign_id"` + UserID int32 `json:"user_id"` + OpenedAt pgtype.Timestamptz `json:"opened_at"` +} + type NotificationSeen struct { UserID int32 `json:"user_id"` SeenAt time.Time `json:"seen_at"` diff --git a/api/server.go b/api/server.go index e0f7224a..6fd470ff 100644 --- a/api/server.go +++ b/api/server.go @@ -465,6 +465,7 @@ func NewApiServer(config config.Config) *ApiServer { g.Post("/users/:userId/mute", app.requireAuthMiddleware, app.requireWriteScope, app.postV1UserMute) g.Delete("/users/:userId/mute", app.requireAuthMiddleware, app.requireWriteScope, app.deleteV1UserMute) g.Put("/users/:userId", app.requireAuthMiddleware, app.requireWriteScope, app.putV1User) + g.Post("/users/:userId/notifications/campaigns/:campaignId/open", app.requireAuthMiddleware, app.requireAuthForUserId, app.v1NotificationCampaignPushOpen) // Tracks g.Get("/tracks", app.v1Tracks) @@ -626,6 +627,7 @@ func NewApiServer(config config.Config) *ApiServer { // Notifications g.Get("/notifications/:userId", app.requireUserIdMiddleware, app.v1Notifications) g.Get("/notifications/:userId/playlist_updates", app.requireUserIdMiddleware, app.v1NotificationsPlaylistUpdates) + g.Get("/notifications/campaigns/:campaignId/opens", app.v1NotificationCampaignPushOpenMetrics) // Protocol dashboard g.Get("/dashboard_wallet_users", app.v1DashboardWalletUsers) diff --git a/api/swagger/swagger-v1.yaml b/api/swagger/swagger-v1.yaml index ffaba36d..716d05b0 100644 --- a/api/swagger/swagger-v1.yaml +++ b/api/swagger/swagger-v1.yaml @@ -18347,7 +18347,7 @@ components: type: string route: type: string - dashboard_announcement_id: + notification_campaign_id: type: string nullable: true supporter_rank_up_notification_action: diff --git a/api/v1_notification_campaign_push_open.go b/api/v1_notification_campaign_push_open.go new file mode 100644 index 00000000..d906b664 --- /dev/null +++ b/api/v1_notification_campaign_push_open.go @@ -0,0 +1,88 @@ +package api + +import ( + "crypto/subtle" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" +) + +const notificationCampaignOpenMetricsHeader = "X-Notification-Campaign-Metrics-Secret" + +// POST /v1/users/:userId/notifications/campaigns/:campaignId/open +// Records a first-party push open for an internal notification campaign id (e.g. Supabase announcement / engagement send UUID). +func (app *ApiServer) v1NotificationCampaignPushOpen(c *fiber.Ctx) error { + if app.writePool == nil { + return fiber.NewError(fiber.StatusServiceUnavailable, "write database unavailable") + } + + campaignID, err := uuid.Parse(c.Params("campaignId")) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "invalid campaignId (expected UUID)") + } + + userID := app.getUserId(c) + if userID <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "invalid userId") + } + + ctx := c.Context() + cmd, err := app.writePool.Exec(ctx, ` +INSERT INTO notification_campaign_push_open (campaign_id, user_id, opened_at) +VALUES ($1, $2, now()) +ON CONFLICT (campaign_id, user_id) DO NOTHING +`, campaignID, userID) + if err != nil { + return err + } + + firstOpen := cmd.RowsAffected() > 0 + return c.JSON(fiber.Map{ + "data": fiber.Map{ + "first_open": firstOpen, + }, + }) +} + +// GET /v1/notifications/campaigns/:campaignId/opens +// Server-to-server: returns distinct opener count for metrics sync (protected by shared secret). +func (app *ApiServer) v1NotificationCampaignPushOpenMetrics(c *fiber.Ctx) error { + secret := app.config.NotificationCampaignOpenMetricsSecret + if secret == "" { + return fiber.NewError(fiber.StatusNotFound, "not found") + } + + got := c.Get(notificationCampaignOpenMetricsHeader) + if !constantTimeStringEqual(got, secret) { + return fiber.NewError(fiber.StatusUnauthorized, "unauthorized") + } + + campaignID, err := uuid.Parse(c.Params("campaignId")) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "invalid campaignId (expected UUID)") + } + + ctx := c.Context() + var count int64 + err = app.pool.QueryRow(ctx, ` +SELECT COUNT(*)::bigint +FROM notification_campaign_push_open +WHERE campaign_id = $1 +`, campaignID).Scan(&count) + if err != nil { + return err + } + + return c.JSON(fiber.Map{ + "data": fiber.Map{ + "unique_opens": count, + }, + }) +} + +func constantTimeStringEqual(a, b string) bool { + if len(a) != len(b) { + return false + } + return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 +} diff --git a/config/config.go b/config/config.go index 710e5211..67b16b4d 100644 --- a/config/config.go +++ b/config/config.go @@ -56,6 +56,8 @@ type Config struct { UploadNodes []string // Optional API secret to be used for api.audius.co frontends AudiusApiSecret string + // Shared secret for notifications-dashboard (or other internal jobs) to read notification campaign push open counts + NotificationCampaignOpenMetricsSecret string } var Cfg = Config{ @@ -80,6 +82,8 @@ var Cfg = Config{ LaunchpadDeterministicSecret: os.Getenv("launchpadDeterministicSecret"), UnsplashKeys: strings.Split(os.Getenv("unsplashKeys"), ","), AudiusApiSecret: os.Getenv("audiusApiSecret"), + AudiusApiSecret: os.Getenv("audiusApiSecret"), + NotificationCampaignOpenMetricsSecret: os.Getenv("notificationCampaignOpenMetricsSecret"), } func init() { diff --git a/sql/migrations/20260316_notification_campaign_push_open.sql b/sql/migrations/20260316_notification_campaign_push_open.sql new file mode 100644 index 00000000..fdff1fae --- /dev/null +++ b/sql/migrations/20260316_notification_campaign_push_open.sql @@ -0,0 +1,20 @@ +-- Run against Discovery Postgres (same DB as API read/write pools). +-- Idempotent: safe to run once in environments that do not use full 01_schema dumps. + +CREATE TABLE IF NOT EXISTS public.notification_campaign_push_open ( + campaign_id uuid NOT NULL, + user_id integer NOT NULL, + opened_at timestamp with time zone DEFAULT now() NOT NULL +); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'notification_campaign_push_open_pkey' + ) THEN + ALTER TABLE ONLY public.notification_campaign_push_open + ADD CONSTRAINT notification_campaign_push_open_pkey + PRIMARY KEY (campaign_id, user_id); + END IF; +END $$; From b39b70fc6946a817fded4c84e620b3b7833bb319 Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Thu, 26 Mar 2026 11:22:19 -0700 Subject: [PATCH 2/3] Add sql changes --- sql/01_schema.sql | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/sql/01_schema.sql b/sql/01_schema.sql index 0256c531..5e9e3b7e 100644 --- a/sql/01_schema.sql +++ b/sql/01_schema.sql @@ -7173,6 +7173,18 @@ CREATE TABLE public.notification_seen ( ); +-- +-- Name: notification_campaign_push_open; Type: TABLE; Schema: public; Owner: - +-- Internal notification campaign id (e.g. Supabase announcement / engagement send UUID) + discovery user_id; first open per pair. +-- + +CREATE TABLE public.notification_campaign_push_open ( + campaign_id uuid NOT NULL, + user_id integer NOT NULL, + opened_at timestamp with time zone DEFAULT now() NOT NULL +); + + -- -- Name: oauth_authorization_codes; Type: TABLE; Schema: public; Owner: - -- @@ -9985,6 +9997,14 @@ ALTER TABLE ONLY public.notification_seen ADD CONSTRAINT notification_seen_pkey PRIMARY KEY (user_id, seen_at); +-- +-- Name: notification_campaign_push_open notification_campaign_push_open_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.notification_campaign_push_open + ADD CONSTRAINT notification_campaign_push_open_pkey PRIMARY KEY (campaign_id, user_id); + + -- -- Name: oauth_authorization_codes oauth_authorization_codes_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- From 4e12f43f7af9ab4d0320cf0fb1655016c9334b1d Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Thu, 26 Mar 2026 11:37:30 -0700 Subject: [PATCH 3/3] Fix config --- config/config.go | 41 ++++++++++++++++++++--------------------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/config/config.go b/config/config.go index 67b16b4d..29cbe594 100644 --- a/config/config.go +++ b/config/config.go @@ -61,27 +61,26 @@ type Config struct { } var Cfg = Config{ - Git: os.Getenv("GIT_SHA"), - Env: os.Getenv("ENV"), - LogLevel: os.Getenv("logLevel"), - ReadDbUrl: os.Getenv("readDbUrl"), - ReadDbReplicas: strings.Split(os.Getenv("readDbReplicas"), ","), - WriteDbUrl: os.Getenv("writeDbUrl"), - RunMigrations: os.Getenv("runMigrations") == "true", - EsUrl: os.Getenv("elasticsearchUrl"), - DelegatePrivateKey: os.Getenv("delegatePrivateKey"), - AxiomToken: os.Getenv("axiomToken"), - AxiomDataset: os.Getenv("axiomDataset"), - NetworkTakeRate: 10, - AudiusdURL: os.Getenv("audiusdUrl"), - OpenAudioURLs: []string{}, - BirdeyeToken: os.Getenv("birdeyeToken"), - SolanaIndexerWorkers: 50, - SolanaIndexerRetryInterval: 5 * time.Minute, - CommsMessagePush: true, - LaunchpadDeterministicSecret: os.Getenv("launchpadDeterministicSecret"), - UnsplashKeys: strings.Split(os.Getenv("unsplashKeys"), ","), - AudiusApiSecret: os.Getenv("audiusApiSecret"), + Git: os.Getenv("GIT_SHA"), + Env: os.Getenv("ENV"), + LogLevel: os.Getenv("logLevel"), + ReadDbUrl: os.Getenv("readDbUrl"), + ReadDbReplicas: strings.Split(os.Getenv("readDbReplicas"), ","), + WriteDbUrl: os.Getenv("writeDbUrl"), + RunMigrations: os.Getenv("runMigrations") == "true", + EsUrl: os.Getenv("elasticsearchUrl"), + DelegatePrivateKey: os.Getenv("delegatePrivateKey"), + AxiomToken: os.Getenv("axiomToken"), + AxiomDataset: os.Getenv("axiomDataset"), + NetworkTakeRate: 10, + AudiusdURL: os.Getenv("audiusdUrl"), + OpenAudioURLs: []string{}, + BirdeyeToken: os.Getenv("birdeyeToken"), + SolanaIndexerWorkers: 50, + SolanaIndexerRetryInterval: 5 * time.Minute, + CommsMessagePush: true, + LaunchpadDeterministicSecret: os.Getenv("launchpadDeterministicSecret"), + UnsplashKeys: strings.Split(os.Getenv("unsplashKeys"), ","), AudiusApiSecret: os.Getenv("audiusApiSecret"), NotificationCampaignOpenMetricsSecret: os.Getenv("notificationCampaignOpenMetricsSecret"), }