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:
13
packages/db/src/migrations/0008_listen_events.sql
Normal file
13
packages/db/src/migrations/0008_listen_events.sql
Normal 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;
|
||||
@@ -57,6 +57,13 @@
|
||||
"when": 1745395200000,
|
||||
"tag": "0007_push_subscriptions",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1745481600000,
|
||||
"tag": "0008_listen_events",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user