Skip to content

Commit 66918b8

Browse files
authored
Add notification campaign endpoints (#739)
1 parent beb455e commit 66918b8

7 files changed

Lines changed: 161 additions & 22 deletions

File tree

api/dbv1/models.go

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

api/server.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,7 @@ func NewApiServer(config config.Config) *ApiServer {
465465
g.Post("/users/:userId/mute", app.requireAuthMiddleware, app.requireWriteScope, app.postV1UserMute)
466466
g.Delete("/users/:userId/mute", app.requireAuthMiddleware, app.requireWriteScope, app.deleteV1UserMute)
467467
g.Put("/users/:userId", app.requireAuthMiddleware, app.requireWriteScope, app.putV1User)
468+
g.Post("/users/:userId/notifications/campaigns/:campaignId/open", app.requireAuthMiddleware, app.requireAuthForUserId, app.v1NotificationCampaignPushOpen)
468469

469470
// Tracks
470471
g.Get("/tracks", app.v1Tracks)
@@ -626,6 +627,7 @@ func NewApiServer(config config.Config) *ApiServer {
626627
// Notifications
627628
g.Get("/notifications/:userId", app.requireUserIdMiddleware, app.v1Notifications)
628629
g.Get("/notifications/:userId/playlist_updates", app.requireUserIdMiddleware, app.v1NotificationsPlaylistUpdates)
630+
g.Get("/notifications/campaigns/:campaignId/opens", app.v1NotificationCampaignPushOpenMetrics)
629631

630632
// Protocol dashboard
631633
g.Get("/dashboard_wallet_users", app.v1DashboardWalletUsers)

api/swagger/swagger-v1.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18347,7 +18347,7 @@ components:
1834718347
type: string
1834818348
route:
1834918349
type: string
18350-
dashboard_announcement_id:
18350+
notification_campaign_id:
1835118351
type: string
1835218352
nullable: true
1835318353
supporter_rank_up_notification_action:
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package api
2+
3+
import (
4+
"crypto/subtle"
5+
6+
"github.com/gofiber/fiber/v2"
7+
"github.com/google/uuid"
8+
)
9+
10+
const notificationCampaignOpenMetricsHeader = "X-Notification-Campaign-Metrics-Secret"
11+
12+
// POST /v1/users/:userId/notifications/campaigns/:campaignId/open
13+
// Records a first-party push open for an internal notification campaign id (e.g. Supabase announcement / engagement send UUID).
14+
func (app *ApiServer) v1NotificationCampaignPushOpen(c *fiber.Ctx) error {
15+
if app.writePool == nil {
16+
return fiber.NewError(fiber.StatusServiceUnavailable, "write database unavailable")
17+
}
18+
19+
campaignID, err := uuid.Parse(c.Params("campaignId"))
20+
if err != nil {
21+
return fiber.NewError(fiber.StatusBadRequest, "invalid campaignId (expected UUID)")
22+
}
23+
24+
userID := app.getUserId(c)
25+
if userID <= 0 {
26+
return fiber.NewError(fiber.StatusBadRequest, "invalid userId")
27+
}
28+
29+
ctx := c.Context()
30+
cmd, err := app.writePool.Exec(ctx, `
31+
INSERT INTO notification_campaign_push_open (campaign_id, user_id, opened_at)
32+
VALUES ($1, $2, now())
33+
ON CONFLICT (campaign_id, user_id) DO NOTHING
34+
`, campaignID, userID)
35+
if err != nil {
36+
return err
37+
}
38+
39+
firstOpen := cmd.RowsAffected() > 0
40+
return c.JSON(fiber.Map{
41+
"data": fiber.Map{
42+
"first_open": firstOpen,
43+
},
44+
})
45+
}
46+
47+
// GET /v1/notifications/campaigns/:campaignId/opens
48+
// Server-to-server: returns distinct opener count for metrics sync (protected by shared secret).
49+
func (app *ApiServer) v1NotificationCampaignPushOpenMetrics(c *fiber.Ctx) error {
50+
secret := app.config.NotificationCampaignOpenMetricsSecret
51+
if secret == "" {
52+
return fiber.NewError(fiber.StatusNotFound, "not found")
53+
}
54+
55+
got := c.Get(notificationCampaignOpenMetricsHeader)
56+
if !constantTimeStringEqual(got, secret) {
57+
return fiber.NewError(fiber.StatusUnauthorized, "unauthorized")
58+
}
59+
60+
campaignID, err := uuid.Parse(c.Params("campaignId"))
61+
if err != nil {
62+
return fiber.NewError(fiber.StatusBadRequest, "invalid campaignId (expected UUID)")
63+
}
64+
65+
ctx := c.Context()
66+
var count int64
67+
err = app.pool.QueryRow(ctx, `
68+
SELECT COUNT(*)::bigint
69+
FROM notification_campaign_push_open
70+
WHERE campaign_id = $1
71+
`, campaignID).Scan(&count)
72+
if err != nil {
73+
return err
74+
}
75+
76+
return c.JSON(fiber.Map{
77+
"data": fiber.Map{
78+
"unique_opens": count,
79+
},
80+
})
81+
}
82+
83+
func constantTimeStringEqual(a, b string) bool {
84+
if len(a) != len(b) {
85+
return false
86+
}
87+
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
88+
}

config/config.go

Lines changed: 24 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -56,30 +56,33 @@ type Config struct {
5656
UploadNodes []string
5757
// Optional API secret to be used for api.audius.co frontends
5858
AudiusApiSecret string
59+
// Shared secret for notifications-dashboard (or other internal jobs) to read notification campaign push open counts
60+
NotificationCampaignOpenMetricsSecret string
5961
}
6062

6163
var Cfg = Config{
62-
Git: os.Getenv("GIT_SHA"),
63-
Env: os.Getenv("ENV"),
64-
LogLevel: os.Getenv("logLevel"),
65-
ReadDbUrl: os.Getenv("readDbUrl"),
66-
ReadDbReplicas: strings.Split(os.Getenv("readDbReplicas"), ","),
67-
WriteDbUrl: os.Getenv("writeDbUrl"),
68-
RunMigrations: os.Getenv("runMigrations") == "true",
69-
EsUrl: os.Getenv("elasticsearchUrl"),
70-
DelegatePrivateKey: os.Getenv("delegatePrivateKey"),
71-
AxiomToken: os.Getenv("axiomToken"),
72-
AxiomDataset: os.Getenv("axiomDataset"),
73-
NetworkTakeRate: 10,
74-
AudiusdURL: os.Getenv("audiusdUrl"),
75-
OpenAudioURLs: []string{},
76-
BirdeyeToken: os.Getenv("birdeyeToken"),
77-
SolanaIndexerWorkers: 50,
78-
SolanaIndexerRetryInterval: 5 * time.Minute,
79-
CommsMessagePush: true,
80-
LaunchpadDeterministicSecret: os.Getenv("launchpadDeterministicSecret"),
81-
UnsplashKeys: strings.Split(os.Getenv("unsplashKeys"), ","),
82-
AudiusApiSecret: os.Getenv("audiusApiSecret"),
64+
Git: os.Getenv("GIT_SHA"),
65+
Env: os.Getenv("ENV"),
66+
LogLevel: os.Getenv("logLevel"),
67+
ReadDbUrl: os.Getenv("readDbUrl"),
68+
ReadDbReplicas: strings.Split(os.Getenv("readDbReplicas"), ","),
69+
WriteDbUrl: os.Getenv("writeDbUrl"),
70+
RunMigrations: os.Getenv("runMigrations") == "true",
71+
EsUrl: os.Getenv("elasticsearchUrl"),
72+
DelegatePrivateKey: os.Getenv("delegatePrivateKey"),
73+
AxiomToken: os.Getenv("axiomToken"),
74+
AxiomDataset: os.Getenv("axiomDataset"),
75+
NetworkTakeRate: 10,
76+
AudiusdURL: os.Getenv("audiusdUrl"),
77+
OpenAudioURLs: []string{},
78+
BirdeyeToken: os.Getenv("birdeyeToken"),
79+
SolanaIndexerWorkers: 50,
80+
SolanaIndexerRetryInterval: 5 * time.Minute,
81+
CommsMessagePush: true,
82+
LaunchpadDeterministicSecret: os.Getenv("launchpadDeterministicSecret"),
83+
UnsplashKeys: strings.Split(os.Getenv("unsplashKeys"), ","),
84+
AudiusApiSecret: os.Getenv("audiusApiSecret"),
85+
NotificationCampaignOpenMetricsSecret: os.Getenv("notificationCampaignOpenMetricsSecret"),
8386
}
8487

8588
func init() {

sql/01_schema.sql

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7173,6 +7173,18 @@ CREATE TABLE public.notification_seen (
71737173
);
71747174

71757175

7176+
--
7177+
-- Name: notification_campaign_push_open; Type: TABLE; Schema: public; Owner: -
7178+
-- Internal notification campaign id (e.g. Supabase announcement / engagement send UUID) + discovery user_id; first open per pair.
7179+
--
7180+
7181+
CREATE TABLE public.notification_campaign_push_open (
7182+
campaign_id uuid NOT NULL,
7183+
user_id integer NOT NULL,
7184+
opened_at timestamp with time zone DEFAULT now() NOT NULL
7185+
);
7186+
7187+
71767188
--
71777189
-- Name: oauth_authorization_codes; Type: TABLE; Schema: public; Owner: -
71787190
--
@@ -9985,6 +9997,14 @@ ALTER TABLE ONLY public.notification_seen
99859997
ADD CONSTRAINT notification_seen_pkey PRIMARY KEY (user_id, seen_at);
99869998

99879999

10000+
--
10001+
-- Name: notification_campaign_push_open notification_campaign_push_open_pkey; Type: CONSTRAINT; Schema: public; Owner: -
10002+
--
10003+
10004+
ALTER TABLE ONLY public.notification_campaign_push_open
10005+
ADD CONSTRAINT notification_campaign_push_open_pkey PRIMARY KEY (campaign_id, user_id);
10006+
10007+
998810008
--
998910009
-- Name: oauth_authorization_codes oauth_authorization_codes_pkey; Type: CONSTRAINT; Schema: public; Owner: -
999010010
--
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
-- Run against Discovery Postgres (same DB as API read/write pools).
2+
-- Idempotent: safe to run once in environments that do not use full 01_schema dumps.
3+
4+
CREATE TABLE IF NOT EXISTS public.notification_campaign_push_open (
5+
campaign_id uuid NOT NULL,
6+
user_id integer NOT NULL,
7+
opened_at timestamp with time zone DEFAULT now() NOT NULL
8+
);
9+
10+
DO $$
11+
BEGIN
12+
IF NOT EXISTS (
13+
SELECT 1 FROM pg_constraint
14+
WHERE conname = 'notification_campaign_push_open_pkey'
15+
) THEN
16+
ALTER TABLE ONLY public.notification_campaign_push_open
17+
ADD CONSTRAINT notification_campaign_push_open_pkey
18+
PRIMARY KEY (campaign_id, user_id);
19+
END IF;
20+
END $$;

0 commit comments

Comments
 (0)