feat: listen analytics — track who heard what and when

Add listen_events table to record opens, plays and listen duration per
share link. Public POST/PATCH endpoints for fire-and-forget tracking
from the listen page; authenticated GET analytics endpoint aggregates
per version. Listen page gains optional name prompt after first play
and sendBeacon on unload. Track page gains Analytics tab with stats
grid and per-listener event list.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Robin Choice
2026-04-23 10:21:56 +02:00
parent e5d0b00761
commit df571df567
10 changed files with 569 additions and 69 deletions

View File

@@ -0,0 +1,13 @@
CREATE TABLE "listen_events" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"share_link_id" uuid NOT NULL,
"listener_name" varchar(255),
"ip_hash" varchar(64),
"user_agent" varchar(500),
"opened_at" timestamp DEFAULT now() NOT NULL,
"first_play_at" timestamp,
"listen_seconds" integer DEFAULT 0 NOT NULL,
"completed" boolean DEFAULT false NOT NULL
);
--> statement-breakpoint
ALTER TABLE "listen_events" ADD CONSTRAINT "listen_events_share_link_id_share_links_id_fk" FOREIGN KEY ("share_link_id") REFERENCES "public"."share_links"("id") ON DELETE cascade ON UPDATE no action;

View File

@@ -57,6 +57,13 @@
"when": 1745395200000,
"tag": "0007_push_subscriptions",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1745481600000,
"tag": "0008_listen_events",
"breakpoints": true
}
]
}

View File

@@ -1,4 +1,4 @@
import { pgTable, uuid, varchar, boolean, timestamp } from 'drizzle-orm/pg-core';
import { pgTable, uuid, varchar, boolean, timestamp, integer } from 'drizzle-orm/pg-core';
import { users } from './users.js';
import { versions } from './tracks.js';
@@ -17,3 +17,17 @@ export const shareLinks = pgTable('share_links', {
passwordHash: varchar('password_hash', { length: 255 }),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export const listenEvents = pgTable('listen_events', {
id: uuid('id').defaultRandom().primaryKey(),
shareLinkId: uuid('share_link_id')
.references(() => shareLinks.id, { onDelete: 'cascade' })
.notNull(),
listenerName: varchar('listener_name', { length: 255 }),
ipHash: varchar('ip_hash', { length: 64 }),
userAgent: varchar('user_agent', { length: 500 }),
openedAt: timestamp('opened_at').defaultNow().notNull(),
firstPlayAt: timestamp('first_play_at'),
listenSeconds: integer('listen_seconds').default(0).notNull(),
completed: boolean('completed').default(false).notNull(),
});

View File

@@ -8,3 +8,10 @@ export const subscribePushSchema = z.object({
}),
userAgent: z.string().optional(),
});
export const updateListenEventSchema = z.object({
listenerName: z.string().max(255).optional(),
firstPlay: z.boolean().optional(),
listenSeconds: z.number().int().min(0).optional(),
completed: z.boolean().optional(),
});