From 07fff59bdfd9e8174dd0e7641188d69d2f248122 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Thu, 30 Oct 2025 15:29:56 +0800 Subject: [PATCH 01/22] analytics views --- .github/workflows/tinybird.yml | 41 +++++++++++++++++++ .gitignore | 3 ++ .../datasources/analytics_views.datasource | 17 ++++++++ 3 files changed, 61 insertions(+) create mode 100644 .github/workflows/tinybird.yml create mode 100644 tinybird/datasources/analytics_views.datasource diff --git a/.github/workflows/tinybird.yml b/.github/workflows/tinybird.yml new file mode 100644 index 0000000000..0651f262dd --- /dev/null +++ b/.github/workflows/tinybird.yml @@ -0,0 +1,41 @@ +name: Tinybird + +on: + workflow_dispatch: + pull_request: + branches: + - main + - master + types: [opened, reopened, labeled, unlabeled, synchronize] + +concurrency: ${{ github.workflow }}-${{ github.event.pull_request.number }} + +env: + TINYBIRD_HOST: ${{ secrets.TINYBIRD_HOST }} + TINYBIRD_TOKEN: ${{ secrets.TINYBIRD_TOKEN }} + +jobs: + ci: + runs-on: ubuntu-latest + defaults: + run: + working-directory: '.' + services: + tinybird: + image: tinybirdco/tinybird-local:latest + ports: + - 7181:7181 + steps: + - uses: actions/checkout@v3 + + - name: Install Tinybird CLI + run: curl https://tinybird.co | sh + + - name: Build project + run: tb build + + - name: Test project + run: tb test run + + - name: Deployment check + run: tb --cloud --host ${{ env.TINYBIRD_HOST }} --token ${{ env.TINYBIRD_TOKEN }} deploy --check diff --git a/.gitignore b/.gitignore index b88fcb3f4d..f788d7a461 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,6 @@ tauri.windows.conf.json # Cursor .cursor .env*.local + +# Tinybird +.tinyb diff --git a/tinybird/datasources/analytics_views.datasource b/tinybird/datasources/analytics_views.datasource new file mode 100644 index 0000000000..26d4cb9ae7 --- /dev/null +++ b/tinybird/datasources/analytics_views.datasource @@ -0,0 +1,17 @@ +DESCRIPTION > + Analytics events for when the share page is opened. + +TOKEN "tracker" APPEND + +SCHEMA > + `timestamp` DateTime `json:$.timestamp`, + `version` LowCardinality(String) `json:$.version`, + + `session_id` Nullable(String) `json:$.session_id`, + `video_id` String `json:$.video_id`, + + `payload` String `json:$.payload` + +ENGINE MergeTree +ENGINE_PARTITION_KEY toYYYYMM(timestamp) +ENGINE_SORTING_KEY video_id, timestamp From 25b54cb0a68ffd3afd86eed9516670bc418f2efb Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Thu, 30 Oct 2025 17:25:32 +0800 Subject: [PATCH 02/22] tracking page views --- .github/workflows/tinybird.yml | 1 + .../s/[videoId]/_components/ShareVideo.tsx | 15 +- apps/web/app/s/[videoId]/page.tsx | 6 +- packages/env/server.ts | 1 + packages/web-backend/package.json | 1 + packages/web-backend/src/Videos/VideosRpcs.ts | 11 + packages/web-backend/src/Videos/index.ts | 23 ++ packages/web-domain/src/Errors.ts | 10 +- packages/web-domain/src/Video.ts | 4 + pnpm-lock.yaml | 228 +++++++++--------- .../datasources/analytics_sessions.datasource | 18 ++ .../datasources/analytics_watch.datasource | 19 ++ 12 files changed, 219 insertions(+), 118 deletions(-) create mode 100644 tinybird/datasources/analytics_sessions.datasource create mode 100644 tinybird/datasources/analytics_watch.datasource diff --git a/.github/workflows/tinybird.yml b/.github/workflows/tinybird.yml index 0651f262dd..36643e259a 100644 --- a/.github/workflows/tinybird.yml +++ b/.github/workflows/tinybird.yml @@ -16,6 +16,7 @@ env: jobs: ci: + name: Deploy runs-on: ubuntu-latest defaults: run: diff --git a/apps/web/app/s/[videoId]/_components/ShareVideo.tsx b/apps/web/app/s/[videoId]/_components/ShareVideo.tsx index a7443a404d..b197ba2cf3 100644 --- a/apps/web/app/s/[videoId]/_components/ShareVideo.tsx +++ b/apps/web/app/s/[videoId]/_components/ShareVideo.tsx @@ -1,5 +1,4 @@ -import type { userSelectProps } from "@cap/database/auth/session"; -import type { comments as commentsSchema, videos } from "@cap/database/schema"; +import type { comments as commentsSchema } from "@cap/database/schema"; import { NODE_ENV } from "@cap/env"; import { Logo } from "@cap/ui"; import type { ImageUpload } from "@cap/web-domain"; @@ -11,7 +10,6 @@ import { useRef, useState, } from "react"; -import type { OrganizationSettings } from "@/app/(org)/dashboard/dashboard-data"; import { UpgradeModal } from "@/components/UpgradeModal"; import type { VideoData } from "../types"; import { CapVideoPlayer } from "./CapVideoPlayer"; @@ -22,6 +20,7 @@ import { parseVTT, type TranscriptEntry, } from "./utils/transcript-utils"; +import { useEffectMutation, useRpcClient } from "@/lib/EffectRuntime"; declare global { interface Window { @@ -75,6 +74,16 @@ export const ShareVideo = forwardRef< data.transcriptionStatus, ); + const rpc = useRpcClient(); + const mutation = useEffectMutation({ + mutationFn: () => rpc.VideoCaptureAnalytics(data.id), + }); + + // biome-ignore lint/correctness/useExhaustiveDependencies: just cause + useEffect(() => { + if (mutation.isIdle) mutation.mutate(); + }, []); + // Handle comments data useEffect(() => { if (comments) { diff --git a/apps/web/app/s/[videoId]/page.tsx b/apps/web/app/s/[videoId]/page.tsx index b9e06a99fb..fa639b9625 100644 --- a/apps/web/app/s/[videoId]/page.tsx +++ b/apps/web/app/s/[videoId]/page.tsx @@ -27,7 +27,7 @@ import { type ImageUpload, type Organisation, Policy, - type Video, + Video, } from "@cap/web-domain"; import { eq, type InferSelectModel, sql } from "drizzle-orm"; import { Effect, Option } from "effect"; @@ -254,7 +254,7 @@ export async function generateMetadata( export default async function ShareVideoPage(props: PageProps<"/s/[videoId]">) { const params = await props.params; const searchParams = await props.searchParams; - const videoId = params.videoId as Video.VideoId; + const videoId = Video.VideoId.make(params.videoId); return Effect.gen(function* () { const videosPolicy = yield* VideosPolicy; @@ -492,7 +492,7 @@ async function AuthorizedContent({ } } - const currentMetadata = (video.metadata as VideoMetadata) || {}; + const currentMetadata = video.metadata || {}; const metadata = currentMetadata; let initialAiData = null; diff --git a/packages/env/server.ts b/packages/env/server.ts index eee4df5a8c..238b8d33c0 100644 --- a/packages/env/server.ts +++ b/packages/env/server.ts @@ -68,6 +68,7 @@ function createServerEnv() { VERCEL_AWS_ROLE_ARN: z.string().optional(), WORKFLOWS_RPC_URL: z.string().optional(), WORKFLOWS_RPC_SECRET: z.string().optional(), + TINYBIRD_DATA_SOURCE_NAME: z.string().optional(), }, experimental__runtimeEnv: { ...process.env, diff --git a/packages/web-backend/package.json b/packages/web-backend/package.json index 22b0d0ef47..89eee8ae93 100644 --- a/packages/web-backend/package.json +++ b/packages/web-backend/package.json @@ -19,6 +19,7 @@ "@cap/database": "workspace:*", "@cap/utils": "workspace:*", "@cap/web-domain": "workspace:*", + "@cap/env": "workspace:*", "@effect/cluster": "^0.50.4", "@effect/platform": "^0.92.1", "@effect/rpc": "^0.71.0", diff --git a/packages/web-backend/src/Videos/VideosRpcs.ts b/packages/web-backend/src/Videos/VideosRpcs.ts index 8f843fa20b..46aa2ff854 100644 --- a/packages/web-backend/src/Videos/VideosRpcs.ts +++ b/packages/web-backend/src/Videos/VideosRpcs.ts @@ -97,6 +97,17 @@ export const VideosRpcsLive = Video.VideoRpcs.toLayer( UnknownException: () => new InternalError({ type: "unknown" }), }), ), + + VideoCaptureAnalytics: (videoId) => + videos.captureAnalytics(videoId).pipe( + provideOptionalAuth, + Effect.catchTags({ + DatabaseError: () => new InternalError({ type: "database" }), + RequestError: () => new InternalError({ type: "httpRequest" }), + ResponseError: () => new InternalError({ type: "httpResponse" }), + UnknownException: () => new InternalError({ type: "unknown" }), + }), + ), }; }), ); diff --git a/packages/web-backend/src/Videos/index.ts b/packages/web-backend/src/Videos/index.ts index b85a464e7e..5855d700e8 100644 --- a/packages/web-backend/src/Videos/index.ts +++ b/packages/web-backend/src/Videos/index.ts @@ -3,11 +3,13 @@ import { dub } from "@cap/utils"; import { CurrentUser, Policy, Video } from "@cap/web-domain"; import * as Dz from "drizzle-orm"; import { Array, Effect, Option, pipe } from "effect"; +import { serverEnv } from "@cap/env"; import { Database } from "../Database.ts"; import { S3Buckets } from "../S3Buckets/index.ts"; import { VideosPolicy } from "./VideosPolicy.ts"; import { VideosRepo } from "./VideosRepo.ts"; +import { FetchHttpClient, HttpBody, HttpClient } from "@effect/platform"; export class Videos extends Effect.Service()("Videos", { effect: Effect.gen(function* () { @@ -15,6 +17,7 @@ export class Videos extends Effect.Service()("Videos", { const repo = yield* VideosRepo; const policy = yield* VideosPolicy; const s3Buckets = yield* S3Buckets; + const client = yield* HttpClient.HttpClient; const getByIdForViewing = (id: Video.VideoId) => repo @@ -219,6 +222,25 @@ export class Videos extends Effect.Service()("Videos", { return { count: clicks }; }), + + captureAnalytics: Effect.fn("Videos.captureAnalytics")(function* ( + videoId: Video.VideoId, + ) { + console.log("TODO"); + const dsn = serverEnv().TINYBIRD_DATA_SOURCE_NAME; + if (!dsn) return; + + const response = yield* client.post( + `https://api.tinybird.co/v0/events?name=${encodeURIComponent(dsn)}`, + { + body: HttpBody.unsafeJson({ + title: "foo", + body: "bar", + userId: 1, + }), + }, + ); + }), }; }), dependencies: [ @@ -226,5 +248,6 @@ export class Videos extends Effect.Service()("Videos", { VideosRepo.Default, Database.Default, S3Buckets.Default, + FetchHttpClient.layer, ], }) {} diff --git a/packages/web-domain/src/Errors.ts b/packages/web-domain/src/Errors.ts index 597d8c3d90..d077b445c7 100644 --- a/packages/web-domain/src/Errors.ts +++ b/packages/web-domain/src/Errors.ts @@ -2,5 +2,13 @@ import { Schema } from "effect"; export class InternalError extends Schema.TaggedError()( "InternalError", - { type: Schema.Literal("database", "s3", "unknown") }, + { + type: Schema.Literal( + "database", + "s3", + "httpRequest", + "httpResponse", + "unknown", + ), + }, ) {} diff --git a/packages/web-domain/src/Video.ts b/packages/web-domain/src/Video.ts index 98d8c633bf..1ffa1f15fb 100644 --- a/packages/web-domain/src/Video.ts +++ b/packages/web-domain/src/Video.ts @@ -203,4 +203,8 @@ export class VideoRpcs extends RpcGroup.make( ), error: InternalError, }), + Rpc.make("VideoCaptureAnalytics", { + payload: VideoId, + error: InternalError, + }), ) {} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a08cf69e61..7113e6df19 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,7 +115,7 @@ importers: version: 0.14.10(solid-js@1.9.6) '@solidjs/start': specifier: ^1.1.3 - version: 1.1.3(@testing-library/jest-dom@6.5.0)(@types/node@22.15.17)(jiti@2.6.1)(solid-js@1.9.6)(terser@5.44.0)(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.43)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(yaml@2.8.1) + version: 1.1.3(@testing-library/jest-dom@6.5.0)(@types/node@22.15.17)(jiti@2.6.1)(solid-js@1.9.6)(terser@5.44.0)(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(yaml@2.8.1) '@tanstack/solid-query': specifier: ^5.51.21 version: 5.75.4(solid-js@1.9.6) @@ -202,7 +202,7 @@ importers: version: 9.0.1 vinxi: specifier: ^0.5.6 - version: 0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.43)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1) + version: 0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1) webcodecs: specifier: ^0.1.0 version: 0.1.0 @@ -704,7 +704,7 @@ importers: version: 15.5.4(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) next-auth: specifier: ^4.24.5 - version: 4.24.11(next@15.5.4(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.10.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + version: 4.24.11(next@15.5.4(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.10.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) next-contentlayer2: specifier: ^0.5.3 version: 0.5.8(acorn@8.15.0)(contentlayer2@0.5.8(acorn@8.15.0)(esbuild@0.25.5))(esbuild@0.25.5)(next@15.5.4(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -1017,7 +1017,7 @@ importers: version: 15.5.4(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) next-auth: specifier: ^4.24.5 - version: 4.24.11(next@15.5.4(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.10.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + version: 4.24.11(next@15.5.4(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.10.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) react-email: specifier: ^4.0.16 version: 4.0.16(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -1336,6 +1336,9 @@ importers: '@cap/database': specifier: workspace:* version: link:../database + '@cap/env': + specifier: workspace:* + version: link:../env '@cap/utils': specifier: workspace:* version: link:../utils @@ -4517,6 +4520,9 @@ packages: '@oxc-project/types@0.94.0': resolution: {integrity: sha512-+UgQT/4o59cZfH6Cp7G0hwmqEQ0wE+AdIwhikdwnhWI9Dp8CgSY081+Q3O67/wq3VJu8mgUEB93J9EHHn70fOw==} + '@oxc-project/types@0.95.0': + resolution: {integrity: sha512-vACy7vhpMPhjEJhULNxrdR0D943TkA/MigMpJCHmBHvMXxRStRi/dPtTlfQ3uDwWSzRpT8z+7ImjZVf8JWBocQ==} + '@panva/hkdf@1.2.1': resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} @@ -5445,8 +5451,8 @@ packages: cpu: [arm64] os: [android] - '@rolldown/binding-android-arm64@1.0.0-beta.43': - resolution: {integrity: sha512-TP8bcPOb1s6UmY5syhXrDn9k0XkYcw+XaoylTN4cJxf0JOVS2j682I3aTcpfT51hOFGr2bRwNKN9RZ19XxeQbA==} + '@rolldown/binding-android-arm64@1.0.0-beta.45': + resolution: {integrity: sha512-bfgKYhFiXJALeA/riil908+2vlyWGdwa7Ju5S+JgWZYdR4jtiPOGdM6WLfso1dojCh+4ZWeiTwPeV9IKQEX+4g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] @@ -5457,8 +5463,8 @@ packages: cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-arm64@1.0.0-beta.43': - resolution: {integrity: sha512-kuVWnZsE4vEjMF/10SbSUyzucIW2zmdsqFghYMqy+fsjXnRHg0luTU6qWF8IqJf4Cbpm9NEZRnjIEPpAbdiSNQ==} + '@rolldown/binding-darwin-arm64@1.0.0-beta.45': + resolution: {integrity: sha512-xjCv4CRVsSnnIxTuyH1RDJl5OEQ1c9JYOwfDAHddjJDxCw46ZX9q80+xq7Eok7KC4bRSZudMJllkvOKv0T9SeA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] @@ -5469,8 +5475,8 @@ packages: cpu: [x64] os: [darwin] - '@rolldown/binding-darwin-x64@1.0.0-beta.43': - resolution: {integrity: sha512-u9Ps4sh6lcmJ3vgLtyEg/x4jlhI64U0mM93Ew+tlfFdLDe7yKyA+Fe80cpr2n1mNCeZXrvTSbZluKpXQ0GxLjw==} + '@rolldown/binding-darwin-x64@1.0.0-beta.45': + resolution: {integrity: sha512-ddcO9TD3D/CLUa/l8GO8LHzBOaZqWg5ClMy3jICoxwCuoz47h9dtqPsIeTiB6yR501LQTeDsjA4lIFd7u3Ljfw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] @@ -5481,8 +5487,8 @@ packages: cpu: [x64] os: [freebsd] - '@rolldown/binding-freebsd-x64@1.0.0-beta.43': - resolution: {integrity: sha512-h9lUtVtXgfbk/tnicMpbFfZ3DJvk5Zn2IvmlC1/e0+nUfwoc/TFqpfrRRqcNBXk/e+xiWMSKv6b0MF8N+Rtvlg==} + '@rolldown/binding-freebsd-x64@1.0.0-beta.45': + resolution: {integrity: sha512-MBTWdrzW9w+UMYDUvnEuh0pQvLENkl2Sis15fHTfHVW7ClbGuez+RWopZudIDEGkpZXdeI4CkRXk+vdIIebrmg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] @@ -5493,8 +5499,8 @@ packages: cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.43': - resolution: {integrity: sha512-IX2C6bA6wM2rX/RvD75ko+ix9yxPKjKGGq7pOhB8wGI4Z4fqX5B1nDHga/qMDmAdCAR1m9ymzxkmqhm/AFYf7A==} + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.45': + resolution: {integrity: sha512-4YgoCFiki1HR6oSg+GxxfzfnVCesQxLF1LEnw9uXS/MpBmuog0EOO2rYfy69rWP4tFZL9IWp6KEfGZLrZ7aUog==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] @@ -5505,8 +5511,8 @@ packages: cpu: [arm64] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.43': - resolution: {integrity: sha512-mcjd57vEj+CEQbZAzUiaxNzNgwwgOpFtZBWcINm8DNscvkXl5b/s622Z1dqGNWSdrZmdjdC6LWMvu8iHM6v9sQ==} + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.45': + resolution: {integrity: sha512-LE1gjAwQRrbCOorJJ7LFr10s5vqYf5a00V5Ea9wXcT2+56n5YosJkcp8eQ12FxRBv2YX8dsdQJb+ZTtYJwb6XQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] @@ -5517,8 +5523,8 @@ packages: cpu: [arm64] os: [linux] - '@rolldown/binding-linux-arm64-musl@1.0.0-beta.43': - resolution: {integrity: sha512-Pa8QMwlkrztTo/1mVjZmPIQ44tCSci10TBqxzVBvXVA5CFh5EpiEi99fPSll2dHG2uT4dCOMeC6fIhyDdb0zXA==} + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.45': + resolution: {integrity: sha512-tdy8ThO/fPp40B81v0YK3QC+KODOmzJzSUOO37DinQxzlTJ026gqUSOM8tzlVixRbQJltgVDCTYF8HNPRErQTA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] @@ -5529,8 +5535,8 @@ packages: cpu: [x64] os: [linux] - '@rolldown/binding-linux-x64-gnu@1.0.0-beta.43': - resolution: {integrity: sha512-BgynXKMjeaX4AfWLARhOKDetBOOghnSiVRjAHVvhiAaDXgdQN8e65mSmXRiVoVtD3cHXx/cfU8Gw0p0K+qYKVQ==} + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.45': + resolution: {integrity: sha512-lS082ROBWdmOyVY/0YB3JmsiClaWoxvC+dA8/rbhyB9VLkvVEaihLEOr4CYmrMse151C4+S6hCw6oa1iewox7g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] @@ -5541,8 +5547,8 @@ packages: cpu: [x64] os: [linux] - '@rolldown/binding-linux-x64-musl@1.0.0-beta.43': - resolution: {integrity: sha512-VIsoPlOB/tDSAw9CySckBYysoIBqLeps1/umNSYUD8pMtalJyzMTneAVI1HrUdf4ceFmQ5vARoLIXSsPwVFxNg==} + '@rolldown/binding-linux-x64-musl@1.0.0-beta.45': + resolution: {integrity: sha512-Hi73aYY0cBkr1/SvNQqH8Cd+rSV6S9RB5izCv0ySBcRnd/Wfn5plguUoGYwBnhHgFbh6cPw9m2dUVBR6BG1gxA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] @@ -5553,8 +5559,8 @@ packages: cpu: [arm64] os: [openharmony] - '@rolldown/binding-openharmony-arm64@1.0.0-beta.43': - resolution: {integrity: sha512-YDXTxVJG67PqTQMKyjVJSddoPbSWJ4yRz/E3xzTLHqNrTDGY0UuhG8EMr8zsYnfH/0cPFJ3wjQd/hJWHuR6nkA==} + '@rolldown/binding-openharmony-arm64@1.0.0-beta.45': + resolution: {integrity: sha512-fljEqbO7RHHogNDxYtTzr+GNjlfOx21RUyGmF+NrkebZ8emYYiIqzPxsaMZuRx0rgZmVmliOzEp86/CQFDKhJQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] @@ -5564,8 +5570,8 @@ packages: engines: {node: '>=14.0.0'} cpu: [wasm32] - '@rolldown/binding-wasm32-wasi@1.0.0-beta.43': - resolution: {integrity: sha512-3M+2DmorXvDuAIGYQ9Z93Oy1G9ETkejLwdXXb1uRTgKN9pMcu7N+KG2zDrJwqyxeeLIFE22AZGtSJm3PJbNu9Q==} + '@rolldown/binding-wasm32-wasi@1.0.0-beta.45': + resolution: {integrity: sha512-ZJDB7lkuZE9XUnWQSYrBObZxczut+8FZ5pdanm8nNS1DAo8zsrPuvGwn+U3fwU98WaiFsNrA4XHngesCGr8tEQ==} engines: {node: '>=14.0.0'} cpu: [wasm32] @@ -5575,8 +5581,8 @@ packages: cpu: [arm64] os: [win32] - '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.43': - resolution: {integrity: sha512-/B1j1pJs33y9ywtslOMxryUPHq8zIGu/OGEc2gyed0slimJ8fX2uR/SaJVhB4+NEgCFIeYDR4CX6jynAkeRuCA==} + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.45': + resolution: {integrity: sha512-zyzAjItHPUmxg6Z8SyRhLdXlJn3/D9KL5b9mObUrBHhWS/GwRH4665xCiFqeuktAhhWutqfc+rOV2LjK4VYQGQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] @@ -5587,8 +5593,8 @@ packages: cpu: [ia32] os: [win32] - '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.43': - resolution: {integrity: sha512-29oG1swCz7hNP+CQYrsM4EtylsKwuYzM8ljqbqC5TsQwmKat7P8ouDpImsqg/GZxFSXcPP9ezQm0Q0wQwGM3JA==} + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.45': + resolution: {integrity: sha512-wODcGzlfxqS6D7BR0srkJk3drPwXYLu7jPHN27ce2c4PUnVVmJnp9mJzUQGT4LpmHmmVdMZ+P6hKvyTGBzc1CA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] @@ -5599,8 +5605,8 @@ packages: cpu: [x64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.0.0-beta.43': - resolution: {integrity: sha512-eWBV1Ef3gfGNehxVGCyXs7wLayRIgCmyItuCZwYYXW5bsk4EvR4n2GP5m3ohjnx7wdiY3nLmwQfH2Knb5gbNZw==} + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.45': + resolution: {integrity: sha512-wiU40G1nQo9rtfvF9jLbl79lUgjfaD/LTyUEw2Wg/gdF5OhjzpKMVugZQngO+RNdwYaNj+Fs+kWBWfp4VXPMHA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -5608,8 +5614,8 @@ packages: '@rolldown/pluginutils@1.0.0-beta.42': resolution: {integrity: sha512-N7pQzk9CyE7q0bBN/q0J8s6Db279r5kUZc6d7/wWRe9/zXqC52HQovVyu6iXPIDY4BEzzgbVLhVFXrOuGJ22ZQ==} - '@rolldown/pluginutils@1.0.0-beta.43': - resolution: {integrity: sha512-5Uxg7fQUCmfhax7FJke2+8B6cqgeUJUD9o2uXIKXhD+mG0mL6NObmVoi9wXEU1tY89mZKgAYA6fTbftx3q2ZPQ==} + '@rolldown/pluginutils@1.0.0-beta.45': + resolution: {integrity: sha512-Le9ulGCrD8ggInzWw/k2J8QcbPz7eGIOWqfJ2L+1R0Opm7n6J37s2hiDWlh6LJN0Lk9L5sUzMvRHKW7UxBZsQA==} '@rollup/plugin-alias@5.1.1': resolution: {integrity: sha512-PR9zDb+rOzkRb2VD+EuKB7UC41vU5DIwZ5qqCpk0KJudcWAyi8rvYOhS7+L5aZCspw1stTViLgN5v6FF1p5cgQ==} @@ -6495,10 +6501,10 @@ packages: react-dom: optional: true - '@storybook/builder-vite@10.0.0-beta.13': - resolution: {integrity: sha512-ZPxqgoofi2yfJRQkkEBINSzPjkhrgfh4HsPs4fsbPt3jfVyfP4Wic7vHep4UlR1os2iL2MTRLcapkkiajoztWQ==} + '@storybook/builder-vite@10.1.0-alpha.0': + resolution: {integrity: sha512-ipGNG3YeYnSsnnWyKJKQUv6On8OIK8Vk7Pr51rA4BDH8Jw1zI5ulSd2zTCu6iCCH2G26KrMoXaoZ49rrJ1XEdw==} peerDependencies: - storybook: ^10.0.0-beta.13 + storybook: ^10.1.0-alpha.0 vite: ^5.0.0 || ^6.0.0 || ^7.0.0 '@storybook/core@8.6.12': @@ -6509,12 +6515,12 @@ packages: prettier: optional: true - '@storybook/csf-plugin@10.0.0-beta.13': - resolution: {integrity: sha512-mjdDsl5MAn6bLZHHInut+TQjYUJDdErpXnYlXPLvZRoaCxqxLN2O2sf5LmxAP9SRjEdKnsLFvBwMCJxVcav/lQ==} + '@storybook/csf-plugin@10.1.0-alpha.0': + resolution: {integrity: sha512-UKvA8vmGf7De9YCMsR5FRZ5J4jlTKwebheCopni3nCTn744Y9k2JmucdzfBTXIwwEl6SSuAnNc6eIKzUdSM0KQ==} peerDependencies: esbuild: '*' rollup: '*' - storybook: ^10.0.0-beta.13 + storybook: ^10.1.0-alpha.0 vite: '*' webpack: '*' peerDependenciesMeta: @@ -12965,8 +12971,8 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - rolldown@1.0.0-beta.43: - resolution: {integrity: sha512-6RcqyRx0tY1MlRLnjXPp/849Rl/CPFhzpGGwNPEPjKwqBMqPq/Rbbkxasa8s0x+IkUk46ty4jazb5skZ/Vgdhw==} + rolldown@1.0.0-beta.45: + resolution: {integrity: sha512-iMmuD72XXLf26Tqrv1cryNYLX6NNPLhZ3AmNkSf8+xda0H+yijjGJ+wVT9UdBUHOpKzq9RjKtQKRCWoEKQQBZQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -18868,6 +18874,8 @@ snapshots: '@oxc-project/types@0.94.0': {} + '@oxc-project/types@0.95.0': {} + '@panva/hkdf@1.2.1': {} '@paralleldrive/cuid2@2.2.2': @@ -19827,61 +19835,61 @@ snapshots: '@rolldown/binding-android-arm64@1.0.0-beta.42': optional: true - '@rolldown/binding-android-arm64@1.0.0-beta.43': + '@rolldown/binding-android-arm64@1.0.0-beta.45': optional: true '@rolldown/binding-darwin-arm64@1.0.0-beta.42': optional: true - '@rolldown/binding-darwin-arm64@1.0.0-beta.43': + '@rolldown/binding-darwin-arm64@1.0.0-beta.45': optional: true '@rolldown/binding-darwin-x64@1.0.0-beta.42': optional: true - '@rolldown/binding-darwin-x64@1.0.0-beta.43': + '@rolldown/binding-darwin-x64@1.0.0-beta.45': optional: true '@rolldown/binding-freebsd-x64@1.0.0-beta.42': optional: true - '@rolldown/binding-freebsd-x64@1.0.0-beta.43': + '@rolldown/binding-freebsd-x64@1.0.0-beta.45': optional: true '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.42': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.43': + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.45': optional: true '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.42': optional: true - '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.43': + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.45': optional: true '@rolldown/binding-linux-arm64-musl@1.0.0-beta.42': optional: true - '@rolldown/binding-linux-arm64-musl@1.0.0-beta.43': + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.45': optional: true '@rolldown/binding-linux-x64-gnu@1.0.0-beta.42': optional: true - '@rolldown/binding-linux-x64-gnu@1.0.0-beta.43': + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.45': optional: true '@rolldown/binding-linux-x64-musl@1.0.0-beta.42': optional: true - '@rolldown/binding-linux-x64-musl@1.0.0-beta.43': + '@rolldown/binding-linux-x64-musl@1.0.0-beta.45': optional: true '@rolldown/binding-openharmony-arm64@1.0.0-beta.42': optional: true - '@rolldown/binding-openharmony-arm64@1.0.0-beta.43': + '@rolldown/binding-openharmony-arm64@1.0.0-beta.45': optional: true '@rolldown/binding-wasm32-wasi@1.0.0-beta.42': @@ -19889,7 +19897,7 @@ snapshots: '@napi-rs/wasm-runtime': 1.0.6 optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-beta.43': + '@rolldown/binding-wasm32-wasi@1.0.0-beta.45': dependencies: '@napi-rs/wasm-runtime': 1.0.7 optional: true @@ -19897,24 +19905,24 @@ snapshots: '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.42': optional: true - '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.43': + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.45': optional: true '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.42': optional: true - '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.43': + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.45': optional: true '@rolldown/binding-win32-x64-msvc@1.0.0-beta.42': optional: true - '@rolldown/binding-win32-x64-msvc@1.0.0-beta.43': + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.45': optional: true '@rolldown/pluginutils@1.0.0-beta.42': {} - '@rolldown/pluginutils@1.0.0-beta.43': {} + '@rolldown/pluginutils@1.0.0-beta.45': {} '@rollup/plugin-alias@5.1.1(rollup@4.40.2)': optionalDependencies: @@ -20903,11 +20911,11 @@ snapshots: dependencies: solid-js: 1.9.6 - '@solidjs/start@1.1.3(@testing-library/jest-dom@6.5.0)(@types/node@22.15.17)(jiti@2.6.1)(solid-js@1.9.6)(terser@5.44.0)(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.43)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(yaml@2.8.1)': + '@solidjs/start@1.1.3(@testing-library/jest-dom@6.5.0)(@types/node@22.15.17)(jiti@2.6.1)(solid-js@1.9.6)(terser@5.44.0)(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(yaml@2.8.1)': dependencies: '@tanstack/server-functions-plugin': 1.119.2(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1) - '@vinxi/plugin-directives': 0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.43)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1)) - '@vinxi/server-components': 0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.43)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1)) + '@vinxi/plugin-directives': 0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1)) + '@vinxi/server-components': 0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1)) defu: 6.1.4 error-stack-parser: 2.1.4 html-to-image: 1.11.13 @@ -20918,7 +20926,7 @@ snapshots: source-map-js: 1.2.1 terracotta: 1.0.6(solid-js@1.9.6) tinyglobby: 0.2.13 - vinxi: 0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.43)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1) + vinxi: 0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1) vite-plugin-solid: 2.11.6(@testing-library/jest-dom@6.5.0)(solid-js@1.9.6)(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)) transitivePeerDependencies: - '@testing-library/jest-dom' @@ -21046,9 +21054,9 @@ snapshots: react: 19.1.1 react-dom: 19.1.1(react@19.1.1) - '@storybook/builder-vite@10.0.0-beta.13(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5))': + '@storybook/builder-vite@10.1.0-alpha.0(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5))': dependencies: - '@storybook/csf-plugin': 10.0.0-beta.13(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5)) + '@storybook/csf-plugin': 10.1.0-alpha.0(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5)) storybook: 8.6.12(prettier@3.5.3) ts-dedent: 2.2.0 vite: 6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1) @@ -21078,7 +21086,7 @@ snapshots: - supports-color - utf-8-validate - '@storybook/csf-plugin@10.0.0-beta.13(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5))': + '@storybook/csf-plugin@10.1.0-alpha.0(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5))': dependencies: storybook: 8.6.12(prettier@3.5.3) unplugin: 2.3.10 @@ -22261,7 +22269,7 @@ snapshots: untun: 0.1.3 uqr: 0.1.2 - '@vinxi/plugin-directives@0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.43)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1))': + '@vinxi/plugin-directives@0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1))': dependencies: '@babel/parser': 7.27.2 acorn: 8.14.1 @@ -22272,18 +22280,18 @@ snapshots: magicast: 0.2.11 recast: 0.23.11 tslib: 2.8.1 - vinxi: 0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.43)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1) + vinxi: 0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1) - '@vinxi/server-components@0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.43)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1))': + '@vinxi/server-components@0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1))': dependencies: - '@vinxi/plugin-directives': 0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.43)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1)) + '@vinxi/plugin-directives': 0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1)) acorn: 8.14.1 acorn-loose: 8.5.0 acorn-typescript: 1.4.13(acorn@8.14.1) astring: 1.9.0 magicast: 0.2.11 recast: 0.23.11 - vinxi: 0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.43)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1) + vinxi: 0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1) '@virtual-grid/core@2.0.1': {} @@ -24379,8 +24387,8 @@ snapshots: '@typescript-eslint/parser': 7.18.0(eslint@9.30.1(jiti@2.6.1))(typescript@5.8.3) eslint: 9.30.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0)(eslint@9.30.1(jiti@2.6.1)) - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.18.0(eslint@9.30.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.30.1(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.18.0(eslint@9.30.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.30.1(jiti@2.6.1)))(eslint@9.30.1(jiti@2.6.1)) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.18.0(eslint@9.30.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.18.0(eslint@9.30.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.30.1(jiti@2.6.1)))(eslint@9.30.1(jiti@2.6.1)))(eslint@9.30.1(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.30.1(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.30.1(jiti@2.6.1)) eslint-plugin-react-hooks: 5.2.0(eslint@9.30.1(jiti@2.6.1)) @@ -24408,33 +24416,33 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0)(eslint@8.57.1): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.18.0(eslint@9.30.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.30.1(jiti@2.6.1)))(eslint@9.30.1(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.0(supports-color@5.5.0) - eslint: 8.57.1 + eslint: 9.30.1(jiti@2.6.1) get-tsconfig: 4.10.0 is-bun-module: 2.0.0 stable-hash: 0.0.5 tinyglobby: 0.2.13 unrs-resolver: 1.7.2 optionalDependencies: - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.18.0(eslint@9.30.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.18.0(eslint@9.30.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.30.1(jiti@2.6.1)))(eslint@9.30.1(jiti@2.6.1)))(eslint@9.30.1(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0)(eslint@9.30.1(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0)(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.0(supports-color@5.5.0) - eslint: 9.30.1(jiti@2.6.1) + eslint: 8.57.1 get-tsconfig: 4.10.0 is-bun-module: 2.0.0 stable-hash: 0.0.5 tinyglobby: 0.2.13 unrs-resolver: 1.7.2 optionalDependencies: - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.18.0(eslint@9.30.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.30.1(jiti@2.6.1)) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) transitivePeerDependencies: - supports-color @@ -24460,14 +24468,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@7.18.0(eslint@9.30.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.30.1(jiti@2.6.1)): + eslint-module-utils@2.12.0(@typescript-eslint/parser@7.18.0(eslint@9.30.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.18.0(eslint@9.30.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.30.1(jiti@2.6.1)))(eslint@9.30.1(jiti@2.6.1)))(eslint@9.30.1(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 7.18.0(eslint@9.30.1(jiti@2.6.1))(typescript@5.8.3) eslint: 9.30.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0)(eslint@9.30.1(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.18.0(eslint@9.30.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.30.1(jiti@2.6.1)))(eslint@9.30.1(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -24529,7 +24537,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.18.0(eslint@9.30.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.30.1(jiti@2.6.1)): + eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.18.0(eslint@9.30.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.18.0(eslint@9.30.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.30.1(jiti@2.6.1)))(eslint@9.30.1(jiti@2.6.1)))(eslint@9.30.1(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.8 @@ -24540,7 +24548,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.30.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@7.18.0(eslint@9.30.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.30.1(jiti@2.6.1)) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@7.18.0(eslint@9.30.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.18.0(eslint@9.30.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.30.1(jiti@2.6.1)))(eslint@9.30.1(jiti@2.6.1)))(eslint@9.30.1(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -27538,7 +27546,7 @@ snapshots: p-wait-for: 5.0.2 qs: 6.14.0 - next-auth@4.24.11(next@15.5.4(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.10.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1): + next-auth@4.24.11(next@15.5.4(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.10.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1): dependencies: '@babel/runtime': 7.27.1 '@panva/hkdf': 1.2.1 @@ -27613,7 +27621,7 @@ snapshots: cors: 2.8.5 next: 15.5.4(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - nitropack@2.11.11(@planetscale/database@1.19.0)(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(mysql2@3.15.2)(rolldown@1.0.0-beta.43)(xml2js@0.6.2): + nitropack@2.11.11(@planetscale/database@1.19.0)(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(mysql2@3.15.2)(xml2js@0.6.2): dependencies: '@cloudflare/kv-asset-handler': 0.4.0 '@netlify/functions': 3.1.5(encoding@0.1.13)(rollup@4.40.2) @@ -27667,7 +27675,7 @@ snapshots: pretty-bytes: 6.1.1 radix3: 1.1.2 rollup: 4.40.2 - rollup-plugin-visualizer: 5.14.0(rolldown@1.0.0-beta.43)(rollup@4.40.2) + rollup-plugin-visualizer: 5.14.0(rollup@4.40.2) scule: 1.3.0 semver: 7.7.2 serve-placeholder: 2.0.2 @@ -29008,7 +29016,7 @@ snapshots: dependencies: glob: 7.2.3 - rolldown-plugin-dts@0.16.11(rolldown@1.0.0-beta.43)(typescript@5.8.3): + rolldown-plugin-dts@0.16.11(rolldown@1.0.0-beta.45)(typescript@5.8.3): dependencies: '@babel/generator': 7.28.3 '@babel/parser': 7.28.4 @@ -29019,7 +29027,7 @@ snapshots: dts-resolver: 2.1.2 get-tsconfig: 4.11.0 magic-string: 0.30.19 - rolldown: 1.0.0-beta.43 + rolldown: 1.0.0-beta.45 optionalDependencies: typescript: 5.8.3 transitivePeerDependencies: @@ -29047,26 +29055,25 @@ snapshots: '@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.42 '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.42 - rolldown@1.0.0-beta.43: + rolldown@1.0.0-beta.45: dependencies: - '@oxc-project/types': 0.94.0 - '@rolldown/pluginutils': 1.0.0-beta.43 - ansis: 4.2.0 + '@oxc-project/types': 0.95.0 + '@rolldown/pluginutils': 1.0.0-beta.45 optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-beta.43 - '@rolldown/binding-darwin-arm64': 1.0.0-beta.43 - '@rolldown/binding-darwin-x64': 1.0.0-beta.43 - '@rolldown/binding-freebsd-x64': 1.0.0-beta.43 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.43 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.43 - '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.43 - '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.43 - '@rolldown/binding-linux-x64-musl': 1.0.0-beta.43 - '@rolldown/binding-openharmony-arm64': 1.0.0-beta.43 - '@rolldown/binding-wasm32-wasi': 1.0.0-beta.43 - '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.43 - '@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.43 - '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.43 + '@rolldown/binding-android-arm64': 1.0.0-beta.45 + '@rolldown/binding-darwin-arm64': 1.0.0-beta.45 + '@rolldown/binding-darwin-x64': 1.0.0-beta.45 + '@rolldown/binding-freebsd-x64': 1.0.0-beta.45 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.45 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.45 + '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.45 + '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.45 + '@rolldown/binding-linux-x64-musl': 1.0.0-beta.45 + '@rolldown/binding-openharmony-arm64': 1.0.0-beta.45 + '@rolldown/binding-wasm32-wasi': 1.0.0-beta.45 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.45 + '@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.45 + '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.45 rollup-plugin-inject@3.0.2: dependencies: @@ -29078,14 +29085,13 @@ snapshots: dependencies: rollup-plugin-inject: 3.0.2 - rollup-plugin-visualizer@5.14.0(rolldown@1.0.0-beta.43)(rollup@4.40.2): + rollup-plugin-visualizer@5.14.0(rollup@4.40.2): dependencies: open: 8.4.2 picomatch: 4.0.3 source-map: 0.7.4 yargs: 17.7.2 optionalDependencies: - rolldown: 1.0.0-beta.43 rollup: 4.40.2 rollup-pluginutils@2.8.2: @@ -29681,7 +29687,7 @@ snapshots: storybook-solidjs-vite@1.0.0-beta.7(@storybook/test@8.6.12(storybook@8.6.12(prettier@3.5.3)))(esbuild@0.25.5)(rollup@4.40.2)(solid-js@1.9.6)(storybook@8.6.12(prettier@3.5.3))(vite-plugin-solid@2.11.6(@testing-library/jest-dom@6.5.0)(solid-js@1.9.6)(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5)): dependencies: - '@storybook/builder-vite': 10.0.0-beta.13(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5)) + '@storybook/builder-vite': 10.1.0-alpha.0(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5)) '@storybook/types': 9.0.0-alpha.1(storybook@8.6.12(prettier@3.5.3)) magic-string: 0.30.17 solid-js: 1.9.6 @@ -30285,8 +30291,8 @@ snapshots: diff: 8.0.2 empathic: 2.0.0 hookable: 5.5.3 - rolldown: 1.0.0-beta.43 - rolldown-plugin-dts: 0.16.11(rolldown@1.0.0-beta.43)(typescript@5.8.3) + rolldown: 1.0.0-beta.45 + rolldown-plugin-dts: 0.16.11(rolldown@1.0.0-beta.45)(typescript@5.8.3) semver: 7.7.2 tinyexec: 1.0.1 tinyglobby: 0.2.15 @@ -30878,7 +30884,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.2 - vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.43)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1): + vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1): dependencies: '@babel/core': 7.27.1 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.27.1) @@ -30900,7 +30906,7 @@ snapshots: hookable: 5.5.3 http-proxy: 1.18.1 micromatch: 4.0.8 - nitropack: 2.11.11(@planetscale/database@1.19.0)(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(mysql2@3.15.2)(rolldown@1.0.0-beta.43)(xml2js@0.6.2) + nitropack: 2.11.11(@planetscale/database@1.19.0)(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(mysql2@3.15.2)(xml2js@0.6.2) node-fetch-native: 1.6.6 path-to-regexp: 6.3.0 pathe: 1.1.2 diff --git a/tinybird/datasources/analytics_sessions.datasource b/tinybird/datasources/analytics_sessions.datasource new file mode 100644 index 0000000000..5c04350d88 --- /dev/null +++ b/tinybird/datasources/analytics_sessions.datasource @@ -0,0 +1,18 @@ +DESCRIPTION > + Sessions + +SCHEMA > + `date` Date, + `session_id` String, + `tenant_id` String, + `domain` String, + `device` SimpleAggregateFunction(any, String), + `browser` SimpleAggregateFunction(any, String), + `location` SimpleAggregateFunction(any, String), + `first_hit` SimpleAggregateFunction(min, DateTime), + `latest_hit` SimpleAggregateFunction(max, DateTime), + `hits` AggregateFunction(count) + +ENGINE AggregatingMergeTree +ENGINE_PARTITION_KEY toYYYYMM(date) +ENGINE_SORTING_KEY tenant_id, domain, date, session_id diff --git a/tinybird/datasources/analytics_watch.datasource b/tinybird/datasources/analytics_watch.datasource new file mode 100644 index 0000000000..166cd04d92 --- /dev/null +++ b/tinybird/datasources/analytics_watch.datasource @@ -0,0 +1,19 @@ +DESCRIPTION > + Analytics events for when a view is being viewed in the player. + +TOKEN "tracker" APPEND + +SCHEMA > + `timestamp` DateTime `json:$.timestamp`, + `version` LowCardinality(String) `json:$.version`, + + `session_id` Nullable(String) `json:$.session_id`, + `video_id` String `json:$.video_id`, + + `action` LowCardinality(String) `json:$.action`, + + `ts` DateTime64(3) `json:$.ts`, + +ENGINE MergeTree +ENGINE_PARTITION_KEY toYYYYMM(timestamp) +ENGINE_SORTING_KEY video_id, action, timestamp From 2bcb9a6a5c0c594ba6daaf3604bafcf15ec4538b Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Thu, 30 Oct 2025 18:17:00 +0800 Subject: [PATCH 03/22] query for analytics data --- apps/web/actions/videos/get-analytics.ts | 28 ------------ apps/web/app/(org)/dashboard/caps/Caps.tsx | 6 +-- apps/web/app/(org)/dashboard/caps/page.tsx | 7 +-- apps/web/app/api/analytics/route.ts | 22 --------- apps/web/app/layout.tsx | 4 ++ .../_components/tabs/Activity/Analytics.tsx | 27 ++--------- .../_components/tabs/Activity/index.tsx | 1 - apps/web/app/s/[videoId]/page.tsx | 17 +++---- apps/web/utils/public-env.tsx | 1 + packages/env/server.ts | 3 +- packages/web-backend/src/Videos/VideosRpcs.ts | 3 ++ packages/web-backend/src/Videos/index.ts | 45 ++++++++++++++++--- .../datasources/analytics_views.datasource | 2 +- .../datasources/analytics_watch.datasource | 2 +- tinybird/endpoints/video_views.pipe | 9 ++++ 15 files changed, 74 insertions(+), 103 deletions(-) delete mode 100644 apps/web/actions/videos/get-analytics.ts delete mode 100644 apps/web/app/api/analytics/route.ts create mode 100644 tinybird/endpoints/video_views.pipe diff --git a/apps/web/actions/videos/get-analytics.ts b/apps/web/actions/videos/get-analytics.ts deleted file mode 100644 index 7792c16d46..0000000000 --- a/apps/web/actions/videos/get-analytics.ts +++ /dev/null @@ -1,28 +0,0 @@ -"use server"; - -import { dub } from "@cap/utils"; - -export async function getVideoAnalytics(videoId: string) { - if (!videoId) { - throw new Error("Video ID is required"); - } - - try { - const response = await dub().analytics.retrieve({ - domain: "cap.link", - key: videoId, - }); - const { clicks } = response as { clicks: number }; - - if (typeof clicks !== "number" || clicks === null) { - return { count: 0 }; - } - - return { count: clicks }; - } catch (error: any) { - if (error.code === "not_found") { - return { count: 0 }; - } - return { count: 0 }; - } -} diff --git a/apps/web/app/(org)/dashboard/caps/Caps.tsx b/apps/web/app/(org)/dashboard/caps/Caps.tsx index 6d27c4b0da..3fdf976123 100644 --- a/apps/web/app/(org)/dashboard/caps/Caps.tsx +++ b/apps/web/app/(org)/dashboard/caps/Caps.tsx @@ -24,6 +24,7 @@ import { EmptyCapState } from "./components/EmptyCapState"; import type { FolderDataType } from "./components/Folder"; import Folder from "./components/Folder"; import { useUploadingStatus } from "./UploadingContext"; +import { usePublicEnv } from "@/utils/public-env"; export type VideoData = { id: Video.VideoId; @@ -54,18 +55,17 @@ export type VideoData = { export const Caps = ({ data, count, - dubApiKeyEnabled, folders, }: { data: VideoData; count: number; folders: FolderDataType[]; - dubApiKeyEnabled: boolean; }) => { const router = useRouter(); const params = useSearchParams(); const page = Number(params.get("page")) || 1; const { user } = useDashboardContext(); + const publicEnv = usePublicEnv(); const limit = 15; const [openNewFolderDialog, setOpenNewFolderDialog] = useState(false); const totalPages = Math.ceil(count / limit); @@ -77,7 +77,7 @@ export const Caps = ({ const analyticsQuery = useVideosAnalyticsQuery( data.map((video) => video.id), - dubApiKeyEnabled, + publicEnv.analyticsAvailable, ); const analytics = analyticsQuery.data || {}; diff --git a/apps/web/app/(org)/dashboard/caps/page.tsx b/apps/web/app/(org)/dashboard/caps/page.tsx index a4da232f39..2eae3b2bd4 100644 --- a/apps/web/app/(org)/dashboard/caps/page.tsx +++ b/apps/web/app/(org)/dashboard/caps/page.tsx @@ -270,11 +270,6 @@ export default async function CapsPage(props: PageProps<"/dashboard/caps">) { ).pipe(runPromise); return ( - + ); } diff --git a/apps/web/app/api/analytics/route.ts b/apps/web/app/api/analytics/route.ts deleted file mode 100644 index 7a3fd4a061..0000000000 --- a/apps/web/app/api/analytics/route.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { NextRequest } from "next/server"; -import { getVideoAnalytics } from "@/actions/videos/get-analytics"; - -export async function GET(request: NextRequest) { - const url = new URL(request.url); - const videoId = url.searchParams.get("videoId"); - - if (!videoId) { - return Response.json({ error: "Video ID is required" }, { status: 400 }); - } - - try { - const result = await getVideoAnalytics(videoId); - return Response.json({ count: result.count }, { status: 200 }); - } catch (error) { - console.error("Error fetching video analytics:", error); - return Response.json( - { error: "Failed to fetch analytics" }, - { status: 500 }, - ); - } -} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 7130597f6e..6d63da528a 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -131,6 +131,10 @@ export default ({ children }: PropsWithChildren) => webUrl: buildEnv.NEXT_PUBLIC_WEB_URL, workosAuthAvailable: !!serverEnv().WORKOS_CLIENT_ID, googleAuthAvailable: !!serverEnv().GOOGLE_CLIENT_ID, + analyticsAvailable: + !!serverEnv().DUB_API_KEY || + (!!serverEnv().TINYBIRD_HOST && + !!serverEnv().TINYBIRD_TOKEN), }} > diff --git a/apps/web/app/s/[videoId]/_components/tabs/Activity/Analytics.tsx b/apps/web/app/s/[videoId]/_components/tabs/Activity/Analytics.tsx index 436714a48b..08d1b53a11 100644 --- a/apps/web/app/s/[videoId]/_components/tabs/Activity/Analytics.tsx +++ b/apps/web/app/s/[videoId]/_components/tabs/Activity/Analytics.tsx @@ -1,7 +1,4 @@ -"use client"; - -import { use, useEffect, useMemo, useState } from "react"; -import { getVideoAnalytics } from "@/actions/videos/get-analytics"; +import { use, useMemo } from "react"; import { CapCardAnalytics } from "@/app/(org)/dashboard/caps/components/CapCard/CapCardAnalytics"; import type { CommentType } from "../../../Share"; @@ -9,25 +6,9 @@ const Analytics = (props: { videoId: string; views: MaybePromise; comments: CommentType[]; - isLoadingAnalytics: boolean; }) => { - const [views, setViews] = useState( - props.views instanceof Promise ? use(props.views) : props.views, - ); - - useEffect(() => { - const fetchAnalytics = async () => { - try { - const result = await getVideoAnalytics(props.videoId); - - setViews(result.count); - } catch (error) { - console.error("Error fetching analytics:", error); - } - }; - - fetchAnalytics(); - }, [props.videoId]); + const views = + typeof props.views === "number" ? props.views : use(props.views); const totalComments = useMemo( () => props.comments.filter((c) => c.type === "text").length, @@ -41,7 +22,7 @@ const Analytics = (props: { return ( } diff --git a/apps/web/app/s/[videoId]/page.tsx b/apps/web/app/s/[videoId]/page.tsx index fa639b9625..21e8859724 100644 --- a/apps/web/app/s/[videoId]/page.tsx +++ b/apps/web/app/s/[videoId]/page.tsx @@ -11,7 +11,6 @@ import { videos, videoUploads, } from "@cap/database/schema"; -import type { VideoMetadata } from "@cap/database/types"; import { buildEnv } from "@cap/env"; import { Logo } from "@cap/ui"; import { userIsPro } from "@cap/utils"; @@ -22,13 +21,7 @@ import { Videos, } from "@cap/web-backend"; import { VideosPolicy } from "@cap/web-backend/src/Videos/VideosPolicy"; -import { - Comment, - type ImageUpload, - type Organisation, - Policy, - Video, -} from "@cap/web-domain"; +import { Comment, type Organisation, Policy, Video } from "@cap/web-domain"; import { eq, type InferSelectModel, sql } from "drizzle-orm"; import { Effect, Option } from "effect"; import type { Metadata } from "next"; @@ -36,7 +29,6 @@ import { headers } from "next/headers"; import Link from "next/link"; import { notFound } from "next/navigation"; import { generateAiMetadata } from "@/actions/videos/generate-ai-metadata"; -import { getVideoAnalytics } from "@/actions/videos/get-analytics"; import { getDashboardData, type OrganizationSettings, @@ -679,7 +671,12 @@ async function AuthorizedContent({ ); }).pipe(EffectRuntime.runPromise); - const viewsPromise = getVideoAnalytics(videoId).then((v) => v.count); + const viewsPromise = Effect.flatMap(Videos, (videos) => + videos.getAnalytics(videoId), + ).pipe( + Effect.map((v) => v.count), + EffectRuntime.runPromise, + ); const [ membersList, diff --git a/apps/web/utils/public-env.tsx b/apps/web/utils/public-env.tsx index 9499992390..0ec2cf4a4e 100644 --- a/apps/web/utils/public-env.tsx +++ b/apps/web/utils/public-env.tsx @@ -6,6 +6,7 @@ type PublicEnvContext = { webUrl: string; googleAuthAvailable: boolean; workosAuthAvailable: boolean; + analyticsAvailable: boolean; }; const Context = createContext(null); diff --git a/packages/env/server.ts b/packages/env/server.ts index 238b8d33c0..1818002738 100644 --- a/packages/env/server.ts +++ b/packages/env/server.ts @@ -68,7 +68,8 @@ function createServerEnv() { VERCEL_AWS_ROLE_ARN: z.string().optional(), WORKFLOWS_RPC_URL: z.string().optional(), WORKFLOWS_RPC_SECRET: z.string().optional(), - TINYBIRD_DATA_SOURCE_NAME: z.string().optional(), + TINYBIRD_HOST: z.string().optional(), + TINYBIRD_TOKEN: z.string().optional(), }, experimental__runtimeEnv: { ...process.env, diff --git a/packages/web-backend/src/Videos/VideosRpcs.ts b/packages/web-backend/src/Videos/VideosRpcs.ts index 46aa2ff854..e5eef46cfd 100644 --- a/packages/web-backend/src/Videos/VideosRpcs.ts +++ b/packages/web-backend/src/Videos/VideosRpcs.ts @@ -77,6 +77,9 @@ export const VideosRpcsLive = Video.VideoRpcs.toLayer( videos.getAnalytics(id).pipe( Effect.catchTags({ DatabaseError: () => new InternalError({ type: "database" }), + RequestError: () => new InternalError({ type: "httpRequest" }), + ResponseError: () => + new InternalError({ type: "httpResponse" }), UnknownException: () => new InternalError({ type: "unknown" }), }), Effect.matchEffect({ diff --git a/packages/web-backend/src/Videos/index.ts b/packages/web-backend/src/Videos/index.ts index 5855d700e8..4acd4aea9a 100644 --- a/packages/web-backend/src/Videos/index.ts +++ b/packages/web-backend/src/Videos/index.ts @@ -210,6 +210,23 @@ export class Videos extends Effect.Service()("Videos", { ), ); + console.log("HERE GET"); + const token = serverEnv().TINYBIRD_TOKEN; + const host = serverEnv().TINYBIRD_HOST; + if (token && host) { + const response2 = yield* client.get( + `${host}/v0/pipes/video_views.json?token=${token}&video_id=${video.id}`, + ); + if (response2.status !== 200) { + // TODO + } + console.log("ANALYTICS", response2.status, yield* response2.text); + + // TODO: Effect schema + const result = JSON.parse(yield* response2.text); + return { count: result.data[0].count }; + } + const response = yield* Effect.tryPromise(() => dub().analytics.retrieve({ domain: "cap.link", @@ -226,20 +243,34 @@ export class Videos extends Effect.Service()("Videos", { captureAnalytics: Effect.fn("Videos.captureAnalytics")(function* ( videoId: Video.VideoId, ) { - console.log("TODO"); - const dsn = serverEnv().TINYBIRD_DATA_SOURCE_NAME; - if (!dsn) return; + const token = serverEnv().TINYBIRD_TOKEN; + const host = serverEnv().TINYBIRD_HOST; + if (!token || !host) return; + console.log("TINYBIRD EVENT"); // TODO const response = yield* client.post( - `https://api.tinybird.co/v0/events?name=${encodeURIComponent(dsn)}`, + `${host}/v0/events?name=analytics_views`, { body: HttpBody.unsafeJson({ - title: "foo", - body: "bar", - userId: 1, + timestamp: new Date().toISOString(), + version: "1", + session_id: "todo", // TODO + video_id: videoId, + payload: JSON.stringify({ + hello: "world", // TODO + }), }), + headers: { + Authorization: `Bearer ${token}`, + }, }, ); + // const response = yield* HttpClientResponse.filterStatusOk(response); + if (response.status !== 200) { + // TODO + } + + console.log(response.status, yield* response.text); }), }; }), diff --git a/tinybird/datasources/analytics_views.datasource b/tinybird/datasources/analytics_views.datasource index 26d4cb9ae7..41f7efab73 100644 --- a/tinybird/datasources/analytics_views.datasource +++ b/tinybird/datasources/analytics_views.datasource @@ -1,7 +1,7 @@ DESCRIPTION > Analytics events for when the share page is opened. -TOKEN "tracker" APPEND +TOKEN "dashboard" APPEND SCHEMA > `timestamp` DateTime `json:$.timestamp`, diff --git a/tinybird/datasources/analytics_watch.datasource b/tinybird/datasources/analytics_watch.datasource index 166cd04d92..3af4baa8dd 100644 --- a/tinybird/datasources/analytics_watch.datasource +++ b/tinybird/datasources/analytics_watch.datasource @@ -1,7 +1,7 @@ DESCRIPTION > Analytics events for when a view is being viewed in the player. -TOKEN "tracker" APPEND +TOKEN "dashboard" APPEND SCHEMA > `timestamp` DateTime `json:$.timestamp`, diff --git a/tinybird/endpoints/video_views.pipe b/tinybird/endpoints/video_views.pipe new file mode 100644 index 0000000000..db25008418 --- /dev/null +++ b/tinybird/endpoints/video_views.pipe @@ -0,0 +1,9 @@ +TOKEN dashboard READ +DESCRIPTION endpoint to get views for video + +NODE result +SQL > + % + SELECT count(*) as count FROM analytics_views + WHERE video_id = {{String(video_id)}} +TYPE ENDPOINT From e6fa9df6cce020af37ed24ea86aa546a5c41079c Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Mon, 3 Nov 2025 13:38:04 +0800 Subject: [PATCH 04/22] format --- apps/web/app/(org)/dashboard/caps/Caps.tsx | 2 +- apps/web/app/s/[videoId]/_components/ShareVideo.tsx | 2 +- packages/web-backend/src/Videos/index.ts | 5 ++--- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/web/app/(org)/dashboard/caps/Caps.tsx b/apps/web/app/(org)/dashboard/caps/Caps.tsx index 3fdf976123..a2f1c1b267 100644 --- a/apps/web/app/(org)/dashboard/caps/Caps.tsx +++ b/apps/web/app/(org)/dashboard/caps/Caps.tsx @@ -11,6 +11,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { useEffectMutation, useRpcClient } from "@/lib/EffectRuntime"; import { useVideosAnalyticsQuery } from "@/lib/Queries/Analytics"; +import { usePublicEnv } from "@/utils/public-env"; import { useDashboardContext } from "../Contexts"; import { NewFolderDialog, @@ -24,7 +25,6 @@ import { EmptyCapState } from "./components/EmptyCapState"; import type { FolderDataType } from "./components/Folder"; import Folder from "./components/Folder"; import { useUploadingStatus } from "./UploadingContext"; -import { usePublicEnv } from "@/utils/public-env"; export type VideoData = { id: Video.VideoId; diff --git a/apps/web/app/s/[videoId]/_components/ShareVideo.tsx b/apps/web/app/s/[videoId]/_components/ShareVideo.tsx index b197ba2cf3..2d5f655cb2 100644 --- a/apps/web/app/s/[videoId]/_components/ShareVideo.tsx +++ b/apps/web/app/s/[videoId]/_components/ShareVideo.tsx @@ -11,6 +11,7 @@ import { useState, } from "react"; import { UpgradeModal } from "@/components/UpgradeModal"; +import { useEffectMutation, useRpcClient } from "@/lib/EffectRuntime"; import type { VideoData } from "../types"; import { CapVideoPlayer } from "./CapVideoPlayer"; import { HLSVideoPlayer } from "./HLSVideoPlayer"; @@ -20,7 +21,6 @@ import { parseVTT, type TranscriptEntry, } from "./utils/transcript-utils"; -import { useEffectMutation, useRpcClient } from "@/lib/EffectRuntime"; declare global { interface Window { diff --git a/packages/web-backend/src/Videos/index.ts b/packages/web-backend/src/Videos/index.ts index 4acd4aea9a..08b671acc6 100644 --- a/packages/web-backend/src/Videos/index.ts +++ b/packages/web-backend/src/Videos/index.ts @@ -1,15 +1,14 @@ import * as Db from "@cap/database/schema"; +import { serverEnv } from "@cap/env"; import { dub } from "@cap/utils"; import { CurrentUser, Policy, Video } from "@cap/web-domain"; +import { FetchHttpClient, HttpBody, HttpClient } from "@effect/platform"; import * as Dz from "drizzle-orm"; import { Array, Effect, Option, pipe } from "effect"; -import { serverEnv } from "@cap/env"; - import { Database } from "../Database.ts"; import { S3Buckets } from "../S3Buckets/index.ts"; import { VideosPolicy } from "./VideosPolicy.ts"; import { VideosRepo } from "./VideosRepo.ts"; -import { FetchHttpClient, HttpBody, HttpClient } from "@effect/platform"; export class Videos extends Effect.Service()("Videos", { effect: Effect.gen(function* () { From 8298b83b7bad8956aba844ea9d12e8daa76b20aa Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Mon, 3 Nov 2025 13:42:05 +0800 Subject: [PATCH 05/22] fix Tinybird CI? --- .github/workflows/tinybird-cd.yml | 27 +++++++++++++++++++ .../{tinybird.yml => tinybird-ci.yml} | 6 ++--- 2 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/tinybird-cd.yml rename .github/workflows/{tinybird.yml => tinybird-ci.yml} (84%) diff --git a/.github/workflows/tinybird-cd.yml b/.github/workflows/tinybird-cd.yml new file mode 100644 index 0000000000..67c6f7c6ea --- /dev/null +++ b/.github/workflows/tinybird-cd.yml @@ -0,0 +1,27 @@ +name: Tinybird CD + +on: + workflow_dispatch: + push: + branches: + - main + - master + +concurrency: ${{ github.workflow }}-${{ github.event.ref }} + +env: + TINYBIRD_HOST: ${{ secrets.TINYBIRD_HOST }} + TINYBIRD_TOKEN: ${{ secrets.TINYBIRD_TOKEN }} + +jobs: + cd: + name: Deploy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Install Tinybird CLI + run: curl https://tinybird.co | sh + + - name: Deploy project + run: tb --cloud --host "${{ env.TINYBIRD_HOST }}" --token "${{ env.TINYBIRD_TOKEN }}" deploy diff --git a/.github/workflows/tinybird.yml b/.github/workflows/tinybird-ci.yml similarity index 84% rename from .github/workflows/tinybird.yml rename to .github/workflows/tinybird-ci.yml index 36643e259a..c93a124c32 100644 --- a/.github/workflows/tinybird.yml +++ b/.github/workflows/tinybird-ci.yml @@ -1,4 +1,4 @@ -name: Tinybird +name: Tinybird CI on: workflow_dispatch: @@ -16,7 +16,7 @@ env: jobs: ci: - name: Deploy + name: Validate runs-on: ubuntu-latest defaults: run: @@ -39,4 +39,4 @@ jobs: run: tb test run - name: Deployment check - run: tb --cloud --host ${{ env.TINYBIRD_HOST }} --token ${{ env.TINYBIRD_TOKEN }} deploy --check + run: tb --cloud --host "${{ env.TINYBIRD_HOST }}" --token "${{ env.TINYBIRD_TOKEN }}" deploy --check From 09a20ec43ff2e0d428297928b093a62a9c87416b Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Mon, 3 Nov 2025 13:46:01 +0800 Subject: [PATCH 06/22] maybe? --- .github/workflows/tinybird-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tinybird-ci.yml b/.github/workflows/tinybird-ci.yml index c93a124c32..ec995189a7 100644 --- a/.github/workflows/tinybird-ci.yml +++ b/.github/workflows/tinybird-ci.yml @@ -39,4 +39,4 @@ jobs: run: tb test run - name: Deployment check - run: tb --cloud --host "${{ env.TINYBIRD_HOST }}" --token "${{ env.TINYBIRD_TOKEN }}" deploy --check + run: tb --cloud deploy --check From 1dcafb467e1e095f19b1b3c8993586b39b28ce95 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Mon, 3 Nov 2025 13:52:58 +0800 Subject: [PATCH 07/22] no shot? --- .github/workflows/tinybird-cd.yml | 12 ++++++++---- .github/workflows/tinybird-ci.yml | 11 ++++++----- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/.github/workflows/tinybird-cd.yml b/.github/workflows/tinybird-cd.yml index 67c6f7c6ea..6370f757c5 100644 --- a/.github/workflows/tinybird-cd.yml +++ b/.github/workflows/tinybird-cd.yml @@ -6,17 +6,18 @@ on: branches: - main - master + paths: + - 'tinybird/**' concurrency: ${{ github.workflow }}-${{ github.event.ref }} -env: - TINYBIRD_HOST: ${{ secrets.TINYBIRD_HOST }} - TINYBIRD_TOKEN: ${{ secrets.TINYBIRD_TOKEN }} - jobs: cd: name: Deploy runs-on: ubuntu-latest + defaults: + run: + working-directory: 'tinybird' steps: - uses: actions/checkout@v3 @@ -24,4 +25,7 @@ jobs: run: curl https://tinybird.co | sh - name: Deploy project + with: + TINYBIRD_HOST: ${{ secrets.TINYBIRD_HOST }} + TINYBIRD_TOKEN: ${{ secrets.TINYBIRD_TOKEN }} run: tb --cloud --host "${{ env.TINYBIRD_HOST }}" --token "${{ env.TINYBIRD_TOKEN }}" deploy diff --git a/.github/workflows/tinybird-ci.yml b/.github/workflows/tinybird-ci.yml index ec995189a7..c961453eab 100644 --- a/.github/workflows/tinybird-ci.yml +++ b/.github/workflows/tinybird-ci.yml @@ -6,21 +6,19 @@ on: branches: - main - master + paths: + - 'tinybird/**' types: [opened, reopened, labeled, unlabeled, synchronize] concurrency: ${{ github.workflow }}-${{ github.event.pull_request.number }} -env: - TINYBIRD_HOST: ${{ secrets.TINYBIRD_HOST }} - TINYBIRD_TOKEN: ${{ secrets.TINYBIRD_TOKEN }} - jobs: ci: name: Validate runs-on: ubuntu-latest defaults: run: - working-directory: '.' + working-directory: 'tinybird' services: tinybird: image: tinybirdco/tinybird-local:latest @@ -39,4 +37,7 @@ jobs: run: tb test run - name: Deployment check + with: + TINYBIRD_HOST: ${{ secrets.TINYBIRD_HOST }} + TINYBIRD_TOKEN: ${{ secrets.TINYBIRD_TOKEN }} run: tb --cloud deploy --check From aea85f23269b67b7490b400d11f5f240e78c7c3f Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Mon, 3 Nov 2025 13:55:04 +0800 Subject: [PATCH 08/22] add permissions --- .github/workflows/tinybird-cd.yml | 3 +++ .github/workflows/tinybird-ci.yml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/.github/workflows/tinybird-cd.yml b/.github/workflows/tinybird-cd.yml index 6370f757c5..c17e2cb9fd 100644 --- a/.github/workflows/tinybird-cd.yml +++ b/.github/workflows/tinybird-cd.yml @@ -11,6 +11,9 @@ on: concurrency: ${{ github.workflow }}-${{ github.event.ref }} +permissions: + contents: read + jobs: cd: name: Deploy diff --git a/.github/workflows/tinybird-ci.yml b/.github/workflows/tinybird-ci.yml index c961453eab..6f5ecf6209 100644 --- a/.github/workflows/tinybird-ci.yml +++ b/.github/workflows/tinybird-ci.yml @@ -12,6 +12,9 @@ on: concurrency: ${{ github.workflow }}-${{ github.event.pull_request.number }} +permissions: + contents: read + jobs: ci: name: Validate From ca8187a4e68f9d8d2e3672c105dea85d4ec870eb Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Mon, 3 Nov 2025 13:55:31 +0800 Subject: [PATCH 09/22] bruh --- .github/workflows/tinybird-ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tinybird-ci.yml b/.github/workflows/tinybird-ci.yml index 6f5ecf6209..b5882c8568 100644 --- a/.github/workflows/tinybird-ci.yml +++ b/.github/workflows/tinybird-ci.yml @@ -6,8 +6,8 @@ on: branches: - main - master - paths: - - 'tinybird/**' + # paths: + # - 'tinybird/**' # TODO: Reenable this types: [opened, reopened, labeled, unlabeled, synchronize] concurrency: ${{ github.workflow }}-${{ github.event.pull_request.number }} From 0bb5da0e601b8cef77b835deba6cdf6941074346 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Mon, 3 Nov 2025 14:00:28 +0800 Subject: [PATCH 10/22] debug --- .github/workflows/tinybird-cd.yml | 7 ++++--- .github/workflows/tinybird-ci.yml | 10 +++++++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/.github/workflows/tinybird-cd.yml b/.github/workflows/tinybird-cd.yml index c17e2cb9fd..9fa6e98dee 100644 --- a/.github/workflows/tinybird-cd.yml +++ b/.github/workflows/tinybird-cd.yml @@ -14,6 +14,10 @@ concurrency: ${{ github.workflow }}-${{ github.event.ref }} permissions: contents: read +env: + TINYBIRD_HOST: ${{ secrets.TINYBIRD_HOST }} + TINYBIRD_TOKEN: ${{ secrets.TINYBIRD_TOKEN }} + jobs: cd: name: Deploy @@ -28,7 +32,4 @@ jobs: run: curl https://tinybird.co | sh - name: Deploy project - with: - TINYBIRD_HOST: ${{ secrets.TINYBIRD_HOST }} - TINYBIRD_TOKEN: ${{ secrets.TINYBIRD_TOKEN }} run: tb --cloud --host "${{ env.TINYBIRD_HOST }}" --token "${{ env.TINYBIRD_TOKEN }}" deploy diff --git a/.github/workflows/tinybird-ci.yml b/.github/workflows/tinybird-ci.yml index b5882c8568..b7f06d8f56 100644 --- a/.github/workflows/tinybird-ci.yml +++ b/.github/workflows/tinybird-ci.yml @@ -15,6 +15,10 @@ concurrency: ${{ github.workflow }}-${{ github.event.pull_request.number }} permissions: contents: read +env: + TINYBIRD_HOST: ${{ secrets.TINYBIRD_HOST }} + TINYBIRD_TOKEN: ${{ secrets.TINYBIRD_TOKEN }} + jobs: ci: name: Validate @@ -39,8 +43,8 @@ jobs: - name: Test project run: tb test run + - name: DEBUG + run: tb --cloud info + - name: Deployment check - with: - TINYBIRD_HOST: ${{ secrets.TINYBIRD_HOST }} - TINYBIRD_TOKEN: ${{ secrets.TINYBIRD_TOKEN }} run: tb --cloud deploy --check From 71215031b8c995553bd89cdc8ca3ba9e2b85e60e Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Mon, 3 Nov 2025 14:17:57 +0800 Subject: [PATCH 11/22] bruh --- .github/workflows/tinybird-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tinybird-ci.yml b/.github/workflows/tinybird-ci.yml index b7f06d8f56..6f2b838c99 100644 --- a/.github/workflows/tinybird-ci.yml +++ b/.github/workflows/tinybird-ci.yml @@ -44,7 +44,7 @@ jobs: run: tb test run - name: DEBUG - run: tb --cloud info + run: tb --cloud info && echo "$TINYBIRD_HOST $TINYBIRD_TOKEN" - name: Deployment check run: tb --cloud deploy --check From f970f27ed7cc3465662eec3015eff5751a627272 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Mon, 3 Nov 2025 14:21:21 +0800 Subject: [PATCH 12/22] bruh --- .github/workflows/tinybird-cd.yml | 2 +- .github/workflows/tinybird-ci.yml | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tinybird-cd.yml b/.github/workflows/tinybird-cd.yml index 9fa6e98dee..eddb97e87f 100644 --- a/.github/workflows/tinybird-cd.yml +++ b/.github/workflows/tinybird-cd.yml @@ -32,4 +32,4 @@ jobs: run: curl https://tinybird.co | sh - name: Deploy project - run: tb --cloud --host "${{ env.TINYBIRD_HOST }}" --token "${{ env.TINYBIRD_TOKEN }}" deploy + run: tb --cloud --host ${{ env.TINYBIRD_HOST }} --token ${{ env.TINYBIRD_TOKEN }} deploy diff --git a/.github/workflows/tinybird-ci.yml b/.github/workflows/tinybird-ci.yml index 6f2b838c99..ce6ad37a8a 100644 --- a/.github/workflows/tinybird-ci.yml +++ b/.github/workflows/tinybird-ci.yml @@ -43,8 +43,11 @@ jobs: - name: Test project run: tb test run + - name: Check auth + run: tb --host ${{ secrets.TB_HOST }} --token ${{ secrets.TB_ADMIN_TOKEN }} auth info + - name: DEBUG - run: tb --cloud info && echo "$TINYBIRD_HOST $TINYBIRD_TOKEN" + run: tb --cloud --host ${{ env.TINYBIRD_HOST }} --token ${{ env.TINYBIRD_TOKEN }} info && echo "$TINYBIRD_HOST $TINYBIRD_TOKEN" - name: Deployment check - run: tb --cloud deploy --check + run: tb --cloud --host ${{ env.TINYBIRD_HOST }} --token ${{ env.TINYBIRD_TOKEN }} deploy --check From 4cf0b7ac8ce9126b272165d0423004a948d03be3 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Mon, 3 Nov 2025 14:23:51 +0800 Subject: [PATCH 13/22] fix --- .github/workflows/tinybird-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tinybird-ci.yml b/.github/workflows/tinybird-ci.yml index ce6ad37a8a..5fe1e42e8c 100644 --- a/.github/workflows/tinybird-ci.yml +++ b/.github/workflows/tinybird-ci.yml @@ -44,7 +44,7 @@ jobs: run: tb test run - name: Check auth - run: tb --host ${{ secrets.TB_HOST }} --token ${{ secrets.TB_ADMIN_TOKEN }} auth info + run: tb --host ${{ secrets.TINYBIRD_HOST }} --token ${{ secrets.TINYBIRD_TOKEN }} auth info - name: DEBUG run: tb --cloud --host ${{ env.TINYBIRD_HOST }} --token ${{ env.TINYBIRD_TOKEN }} info && echo "$TINYBIRD_HOST $TINYBIRD_TOKEN" From 266febfc3961b2f5c3cbeab539ea81930b7fb8f5 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Mon, 3 Nov 2025 15:07:41 +0800 Subject: [PATCH 14/22] Effect properly --- .../s/[videoId]/_components/ShareVideo.tsx | 5 +- apps/web/lib/Requests/AnalyticsRequest.ts | 2 +- apps/web/lib/server.ts | 2 + biome.json | 3 + packages/web-backend/src/Rpcs.ts | 2 + packages/web-backend/src/Videos/VideosRpcs.ts | 41 ------ .../VideosAnalytics/VideosAnalyticsRpcs.ts | 66 ++++++++++ .../web-backend/src/VideosAnalytics/index.ts | 120 ++++++++++++++++++ packages/web-backend/src/index.ts | 1 + packages/web-domain/src/Rpcs.ts | 2 + packages/web-domain/src/Video.ts | 21 --- packages/web-domain/src/VideoAnalytics.ts | 49 +++++++ packages/web-domain/src/index.ts | 1 + 13 files changed, 251 insertions(+), 64 deletions(-) create mode 100644 packages/web-backend/src/VideosAnalytics/VideosAnalyticsRpcs.ts create mode 100644 packages/web-backend/src/VideosAnalytics/index.ts create mode 100644 packages/web-domain/src/VideoAnalytics.ts diff --git a/apps/web/app/s/[videoId]/_components/ShareVideo.tsx b/apps/web/app/s/[videoId]/_components/ShareVideo.tsx index 2d5f655cb2..d5b9f5c7de 100644 --- a/apps/web/app/s/[videoId]/_components/ShareVideo.tsx +++ b/apps/web/app/s/[videoId]/_components/ShareVideo.tsx @@ -76,7 +76,10 @@ export const ShareVideo = forwardRef< const rpc = useRpcClient(); const mutation = useEffectMutation({ - mutationFn: () => rpc.VideoCaptureAnalytics(data.id), + mutationFn: () => + rpc.VideosCaptureEvent({ + video: data.id, + }), }); // biome-ignore lint/correctness/useExhaustiveDependencies: just cause diff --git a/apps/web/lib/Requests/AnalyticsRequest.ts b/apps/web/lib/Requests/AnalyticsRequest.ts index da475c5bb2..331cd5e765 100644 --- a/apps/web/lib/Requests/AnalyticsRequest.ts +++ b/apps/web/lib/Requests/AnalyticsRequest.ts @@ -19,7 +19,7 @@ export namespace AnalyticsRequest { const requestResolver = RequestResolver.makeBatched( (requests: NonEmptyArray) => - rpc.VideosGetAnalytics(requests.map((r) => r.videoId)).pipe( + rpc.VideosGetViewCount(requests.map((r) => r.videoId)).pipe( Effect.flatMap( // biome-ignore lint/suspicious/useIterableCallbackReturn: effect Effect.forEach((result, index) => diff --git a/apps/web/lib/server.ts b/apps/web/lib/server.ts index 3c93c45b7f..2c4d6f80d0 100644 --- a/apps/web/lib/server.ts +++ b/apps/web/lib/server.ts @@ -17,6 +17,7 @@ import { Videos, VideosPolicy, VideosRepo, + VideosAnalytics, Workflows, } from "@cap/web-backend"; import { type HttpAuthMiddleware, Video } from "@cap/web-domain"; @@ -113,6 +114,7 @@ export const Dependencies = Layer.mergeAll( Videos.Default, VideosPolicy.Default, VideosRepo.Default, + VideosAnalytics.Default, Folders.Default, SpacesPolicy.Default, OrganisationsPolicy.Default, diff --git a/biome.json b/biome.json index 621aba7608..e231fd0063 100644 --- a/biome.json +++ b/biome.json @@ -23,6 +23,9 @@ "recommended": true, "suspicious": { "noShadowRestrictedNames": "off" + }, + "correctness": { + "useYield": "off" } } }, diff --git a/packages/web-backend/src/Rpcs.ts b/packages/web-backend/src/Rpcs.ts index 0b4fb06328..ce0ce86ea8 100644 --- a/packages/web-backend/src/Rpcs.ts +++ b/packages/web-backend/src/Rpcs.ts @@ -11,9 +11,11 @@ import { FolderRpcsLive } from "./Folders/FoldersRpcs.ts"; import { OrganisationsRpcsLive } from "./Organisations/OrganisationsRpcs.ts"; import { UsersRpcsLive } from "./Users/UsersRpcs.ts"; import { VideosRpcsLive } from "./Videos/VideosRpcs.ts"; +import { VideosAnalyticsRpcsLive } from "./VideosAnalytics/VideosAnalyticsRpcs.ts"; export const RpcsLive = Layer.mergeAll( VideosRpcsLive, + VideosAnalyticsRpcsLive, FolderRpcsLive, UsersRpcsLive, OrganisationsRpcsLive, diff --git a/packages/web-backend/src/Videos/VideosRpcs.ts b/packages/web-backend/src/Videos/VideosRpcs.ts index e5eef46cfd..f27239ea12 100644 --- a/packages/web-backend/src/Videos/VideosRpcs.ts +++ b/packages/web-backend/src/Videos/VideosRpcs.ts @@ -70,47 +70,6 @@ export const VideosRpcsLive = Video.VideoRpcs.toLayer( UnknownException: () => new InternalError({ type: "unknown" }), }), ), - - VideosGetAnalytics: (videoIds) => - Effect.all( - videoIds.map((id) => - videos.getAnalytics(id).pipe( - Effect.catchTags({ - DatabaseError: () => new InternalError({ type: "database" }), - RequestError: () => new InternalError({ type: "httpRequest" }), - ResponseError: () => - new InternalError({ type: "httpResponse" }), - UnknownException: () => new InternalError({ type: "unknown" }), - }), - Effect.matchEffect({ - onSuccess: (v) => Effect.succeed(Exit.succeed(v)), - onFailure: (e) => - Schema.is(InternalError)(e) - ? Effect.fail(e) - : Effect.succeed(Exit.fail(e)), - }), - Effect.map((v) => Unify.unify(v)), - ), - ), - { concurrency: 10 }, - ).pipe( - provideOptionalAuth, - Effect.catchTags({ - DatabaseError: () => new InternalError({ type: "database" }), - UnknownException: () => new InternalError({ type: "unknown" }), - }), - ), - - VideoCaptureAnalytics: (videoId) => - videos.captureAnalytics(videoId).pipe( - provideOptionalAuth, - Effect.catchTags({ - DatabaseError: () => new InternalError({ type: "database" }), - RequestError: () => new InternalError({ type: "httpRequest" }), - ResponseError: () => new InternalError({ type: "httpResponse" }), - UnknownException: () => new InternalError({ type: "unknown" }), - }), - ), }; }), ); diff --git a/packages/web-backend/src/VideosAnalytics/VideosAnalyticsRpcs.ts b/packages/web-backend/src/VideosAnalytics/VideosAnalyticsRpcs.ts new file mode 100644 index 0000000000..9bb976948d --- /dev/null +++ b/packages/web-backend/src/VideosAnalytics/VideosAnalyticsRpcs.ts @@ -0,0 +1,66 @@ +import { InternalError, VideoAnalytics } from "@cap/web-domain"; +import { Effect, Exit, Schema, Unify } from "effect"; + +import { provideOptionalAuth } from "../Auth.ts"; +import { VideosAnalytics } from "./index.ts"; + +export const VideosAnalyticsRpcsLive = + VideoAnalytics.VideoAnalyticsRpcs.toLayer( + Effect.gen(function* () { + const videosAnalytics = yield* VideosAnalytics; + + return { + VideosGetViewCount: (videoIds) => + Effect.all( + videoIds.map((id) => + videosAnalytics.getViewCount(id).pipe( + Effect.catchTags({ + DatabaseError: () => new InternalError({ type: "database" }), + RequestError: () => + new InternalError({ type: "httpRequest" }), + ResponseError: () => + new InternalError({ type: "httpResponse" }), + UnknownException: () => + new InternalError({ type: "unknown" }), + }), + Effect.matchEffect({ + onSuccess: (v) => Effect.succeed(Exit.succeed(v)), + onFailure: (e) => + Schema.is(InternalError)(e) + ? Effect.fail(e) + : Effect.succeed(Exit.fail(e)), + }), + Effect.map((v) => Unify.unify(v)), + ), + ), + { concurrency: 10 }, + ).pipe( + provideOptionalAuth, + Effect.catchTags({ + DatabaseError: () => new InternalError({ type: "database" }), + UnknownException: () => new InternalError({ type: "unknown" }), + }), + ), + + VideosGetAnalytics: (videoId) => + videosAnalytics.getAnalytics(videoId).pipe( + provideOptionalAuth, + Effect.catchTags({ + DatabaseError: () => new InternalError({ type: "database" }), + UnknownException: () => new InternalError({ type: "unknown" }), + }), + ), + + VideosCaptureEvent: (event) => + videosAnalytics.captureEvent(event).pipe( + provideOptionalAuth, + Effect.catchTags({ + DatabaseError: () => new InternalError({ type: "database" }), + RequestError: () => new InternalError({ type: "httpRequest" }), + ResponseError: () => new InternalError({ type: "httpResponse" }), + UnknownException: () => new InternalError({ type: "unknown" }), + }), + ), + }; + }), + ); diff --git a/packages/web-backend/src/VideosAnalytics/index.ts b/packages/web-backend/src/VideosAnalytics/index.ts new file mode 100644 index 0000000000..b595ed900d --- /dev/null +++ b/packages/web-backend/src/VideosAnalytics/index.ts @@ -0,0 +1,120 @@ +import { serverEnv } from "@cap/env"; +import { dub } from "@cap/utils"; +import { Policy, Video, VideoAnalytics } from "@cap/web-domain"; +import { FetchHttpClient, HttpBody, HttpClient } from "@effect/platform"; +import { Effect } from "effect"; +import { VideosRepo } from "../Videos/VideosRepo"; +import { VideosPolicy } from "../Videos/VideosPolicy"; + +export class VideosAnalytics extends Effect.Service()( + "VideosAnalytics", + { + effect: Effect.gen(function* () { + const repo = yield* VideosRepo; + const policy = yield* VideosPolicy; + const client = yield* HttpClient.HttpClient; + + const getByIdForViewing = (id: Video.VideoId) => + repo + .getById(id) + .pipe( + Policy.withPublicPolicy(policy.canView(id)), + Effect.withSpan("VideosAnalytics.getById"), + ); + + return { + getViewCount: Effect.fn("VideosAnalytics.getViewCount")(function* ( + videoId: Video.VideoId, + ) { + const [video] = yield* getByIdForViewing(videoId).pipe( + Effect.flatten, + Effect.catchTag( + "NoSuchElementException", + () => new Video.NotFoundError(), + ), + ); + + console.log("HERE GET"); + const token = serverEnv().TINYBIRD_TOKEN; + const host = serverEnv().TINYBIRD_HOST; + if (token && host) { + const response2 = yield* client.get( + `${host}/v0/pipes/video_views.json?token=${token}&video_id=${video.id}`, + ); + if (response2.status !== 200) { + // TODO + } + console.log("ANALYTICS", response2.status, yield* response2.text); + + // TODO: Effect schema + const result = JSON.parse(yield* response2.text); + return { count: result.data[0].count }; + } + + const response = yield* Effect.tryPromise(() => + dub().analytics.retrieve({ + domain: "cap.link", + key: video.id, + }), + ); + const { clicks } = response as { clicks: unknown }; + + if (typeof clicks !== "number" || clicks === null) + return { count: 0 }; + + return { count: clicks }; + }), + + getAnalytics: Effect.fn("VideosAnalytics.getAnalytics")(function* ( + videoId: Video.VideoId, + ) { + // TODO: Implement this + + return VideoAnalytics.VideoAnalytics.make({ + views: 0, + }); + }), + + captureEvent: Effect.fn("VideosAnalytics.captureEvent")(function* ( + event: VideoAnalytics.VideoCaptureEvent, + ) { + const videoId = event.video; + + const token = serverEnv().TINYBIRD_TOKEN; + const host = serverEnv().TINYBIRD_HOST; + if (!token || !host) return; + + console.log("TINYBIRD EVENT"); // TODO + const response = yield* client.post( + `${host}/v0/events?name=analytics_views`, + { + body: HttpBody.unsafeJson({ + timestamp: new Date().toISOString(), + version: "1", + session_id: "todo", // TODO + video_id: videoId, + payload: JSON.stringify({ + hello: "world", // TODO + }), + }), + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + // const response = yield* HttpClientResponse.filterStatusOk(response); + if (response.status !== 200) { + // TODO + } + + console.log(response.status, yield* response.text); + }), + }; + }), + dependencies: [ + VideosPolicy.Default, + VideosRepo.Default, + FetchHttpClient.layer, + ], + }, +) {} diff --git a/packages/web-backend/src/index.ts b/packages/web-backend/src/index.ts index b3b4382dcf..dfbb2e227c 100644 --- a/packages/web-backend/src/index.ts +++ b/packages/web-backend/src/index.ts @@ -15,4 +15,5 @@ export { Users } from "./Users/index.ts"; export { Videos } from "./Videos/index.ts"; export { VideosPolicy } from "./Videos/VideosPolicy.ts"; export { VideosRepo } from "./Videos/VideosRepo.ts"; +export { VideosAnalytics } from "./VideosAnalytics/index.ts"; export * as Workflows from "./Workflows.ts"; diff --git a/packages/web-domain/src/Rpcs.ts b/packages/web-domain/src/Rpcs.ts index f4106430c1..a4366695a6 100644 --- a/packages/web-domain/src/Rpcs.ts +++ b/packages/web-domain/src/Rpcs.ts @@ -4,9 +4,11 @@ import { FolderRpcs } from "./Folder.ts"; import { OrganisationRpcs } from "./Organisation.ts"; import { UserRpcs } from "./User.ts"; import { VideoRpcs } from "./Video.ts"; +import { VideoAnalyticsRpcs } from "./VideoAnalytics.ts"; export const Rpcs = RpcGroup.make().merge( VideoRpcs, + VideoAnalyticsRpcs, FolderRpcs, UserRpcs, OrganisationRpcs, diff --git a/packages/web-domain/src/Video.ts b/packages/web-domain/src/Video.ts index 1ffa1f15fb..cd3fda5dfc 100644 --- a/packages/web-domain/src/Video.ts +++ b/packages/web-domain/src/Video.ts @@ -186,25 +186,4 @@ export class VideoRpcs extends RpcGroup.make( ), error: InternalError, }), - Rpc.make("VideosGetAnalytics", { - payload: Schema.Array(VideoId).pipe( - Schema.filter((a) => a.length <= 50 || "Maximum of 50 videos at a time"), - ), - success: Schema.Array( - Schema.Exit({ - success: Schema.Struct({ count: Schema.Int }), - failure: Schema.Union( - NotFoundError, - PolicyDeniedError, - VerifyVideoPasswordError, - ), - defect: Schema.Unknown, - }), - ), - error: InternalError, - }), - Rpc.make("VideoCaptureAnalytics", { - payload: VideoId, - error: InternalError, - }), ) {} diff --git a/packages/web-domain/src/VideoAnalytics.ts b/packages/web-domain/src/VideoAnalytics.ts new file mode 100644 index 0000000000..81b6bb8a7b --- /dev/null +++ b/packages/web-domain/src/VideoAnalytics.ts @@ -0,0 +1,49 @@ +import { Rpc, RpcGroup } from "@effect/rpc"; +import { Schema } from "effect"; +import { InternalError } from "./Errors.ts"; +import { PolicyDeniedError } from "./Policy.ts"; +import { NotFoundError, VerifyVideoPasswordError, VideoId } from "./Video.ts"; + +export class VideoAnalytics extends Schema.Class( + "VideoAnalytics", +)({ + views: Schema.Int, +}) {} + +// TODO: Break into enum for page vs watch event +export class VideoCaptureEvent extends Schema.Class( + "VideoCaptureEvent", +)({ + video: VideoId, +}) {} + +export class VideoAnalyticsRpcs extends RpcGroup.make( + Rpc.make("VideosGetViewCount", { + payload: Schema.Array(VideoId).pipe( + Schema.filter((a) => a.length <= 50 || "Maximum of 50 videos at a time"), + ), + success: Schema.Array( + Schema.Exit({ + success: Schema.Struct({ count: Schema.Int }), + failure: Schema.Union( + NotFoundError, + PolicyDeniedError, + VerifyVideoPasswordError, // TODO: Is this correct? + ), + defect: Schema.Unknown, + }), + ), + error: InternalError, + }), + + Rpc.make("VideosGetAnalytics", { + payload: VideoId, + success: VideoAnalytics, + error: InternalError, + }), + + Rpc.make("VideosCaptureEvent", { + payload: VideoCaptureEvent, + error: InternalError, + }), +) {} diff --git a/packages/web-domain/src/index.ts b/packages/web-domain/src/index.ts index 76c7755801..93abfc9ca9 100644 --- a/packages/web-domain/src/index.ts +++ b/packages/web-domain/src/index.ts @@ -18,3 +18,4 @@ export { SpaceMemberId, SpaceMemberRole } from "./Space.ts"; export * as User from "./User.ts"; export { UserId } from "./User.ts"; export * as Video from "./Video.ts"; +export * as VideoAnalytics from "./VideoAnalytics.ts"; From ac0210aaaad22bb072aac9d97a1f610a85782c41 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Mon, 3 Nov 2025 15:08:09 +0800 Subject: [PATCH 15/22] format --- apps/web/lib/server.ts | 2 +- packages/web-backend/src/VideosAnalytics/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/lib/server.ts b/apps/web/lib/server.ts index 2c4d6f80d0..ca206f90b5 100644 --- a/apps/web/lib/server.ts +++ b/apps/web/lib/server.ts @@ -15,9 +15,9 @@ import { SpacesPolicy, Users, Videos, + VideosAnalytics, VideosPolicy, VideosRepo, - VideosAnalytics, Workflows, } from "@cap/web-backend"; import { type HttpAuthMiddleware, Video } from "@cap/web-domain"; diff --git a/packages/web-backend/src/VideosAnalytics/index.ts b/packages/web-backend/src/VideosAnalytics/index.ts index b595ed900d..83cd58ff47 100644 --- a/packages/web-backend/src/VideosAnalytics/index.ts +++ b/packages/web-backend/src/VideosAnalytics/index.ts @@ -3,8 +3,8 @@ import { dub } from "@cap/utils"; import { Policy, Video, VideoAnalytics } from "@cap/web-domain"; import { FetchHttpClient, HttpBody, HttpClient } from "@effect/platform"; import { Effect } from "effect"; -import { VideosRepo } from "../Videos/VideosRepo"; import { VideosPolicy } from "../Videos/VideosPolicy"; +import { VideosRepo } from "../Videos/VideosRepo"; export class VideosAnalytics extends Effect.Service()( "VideosAnalytics", From 89ca95637fc5aa34b46956934822d523c1e57a83 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Mon, 3 Nov 2025 15:08:58 +0800 Subject: [PATCH 16/22] fix workflow --- .github/workflows/tinybird-ci.yml | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/.github/workflows/tinybird-ci.yml b/.github/workflows/tinybird-ci.yml index 5fe1e42e8c..23e9366483 100644 --- a/.github/workflows/tinybird-ci.yml +++ b/.github/workflows/tinybird-ci.yml @@ -6,8 +6,8 @@ on: branches: - main - master - # paths: - # - 'tinybird/**' # TODO: Reenable this + paths: + - 'tinybird/**' # TODO: Reenable this types: [opened, reopened, labeled, unlabeled, synchronize] concurrency: ${{ github.workflow }}-${{ github.event.pull_request.number }} @@ -43,11 +43,5 @@ jobs: - name: Test project run: tb test run - - name: Check auth - run: tb --host ${{ secrets.TINYBIRD_HOST }} --token ${{ secrets.TINYBIRD_TOKEN }} auth info - - - name: DEBUG - run: tb --cloud --host ${{ env.TINYBIRD_HOST }} --token ${{ env.TINYBIRD_TOKEN }} info && echo "$TINYBIRD_HOST $TINYBIRD_TOKEN" - - name: Deployment check run: tb --cloud --host ${{ env.TINYBIRD_HOST }} --token ${{ env.TINYBIRD_TOKEN }} deploy --check From 48ed4f2be0c225aa78e703da8391176acccea426 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Mon, 3 Nov 2025 15:09:49 +0800 Subject: [PATCH 17/22] fix --- .github/workflows/tinybird-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tinybird-ci.yml b/.github/workflows/tinybird-ci.yml index 23e9366483..397b68dadc 100644 --- a/.github/workflows/tinybird-ci.yml +++ b/.github/workflows/tinybird-ci.yml @@ -7,7 +7,7 @@ on: - main - master paths: - - 'tinybird/**' # TODO: Reenable this + - 'tinybird/**' types: [opened, reopened, labeled, unlabeled, synchronize] concurrency: ${{ github.workflow }}-${{ github.event.pull_request.number }} From 1176bd367ca04eee6e00712e37bb10043412b1a4 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 11 Nov 2025 14:42:09 +0000 Subject: [PATCH 18/22] Add share watch time analytics and improve seek bar --- apps/web/app/s/[videoId]/Share.tsx | 5 +- .../s/[videoId]/_components/ShareVideo.tsx | 309 +++- .../_components/video/media-player.tsx | 202 ++- apps/web/app/s/[videoId]/page.tsx | 1467 +++++++++-------- apps/web/app/s/[videoId]/types.ts | 13 + apps/web/lib/effect-react-query.ts | 1 - packages/web-backend/src/Videos/VideosRpcs.ts | 51 +- .../web-backend/src/VideosAnalytics/index.ts | 75 +- packages/web-domain/src/VideoAnalytics.ts | 14 + .../datasources/analytics_views.datasource | 15 + 10 files changed, 1264 insertions(+), 888 deletions(-) diff --git a/apps/web/app/s/[videoId]/Share.tsx b/apps/web/app/s/[videoId]/Share.tsx index 347c5bd5a0..048e6559f5 100644 --- a/apps/web/app/s/[videoId]/Share.tsx +++ b/apps/web/app/s/[videoId]/Share.tsx @@ -21,7 +21,7 @@ import { ShareVideo } from "./_components/ShareVideo"; import { Sidebar } from "./_components/Sidebar"; import SummaryChapters from "./_components/SummaryChapters"; import { Toolbar } from "./_components/Toolbar"; -import type { VideoData } from "./types"; +import type { ShareAnalyticsContext, VideoData } from "./types"; type CommentWithAuthor = typeof commentsSchema.$inferSelect & { authorName: string | null; @@ -49,6 +49,7 @@ interface ShareProps { processing?: boolean; } | null; aiGenerationEnabled: boolean; + analyticsContext?: ShareAnalyticsContext; } const useVideoStatus = ( @@ -134,6 +135,7 @@ export const Share = ({ initialAiData, aiGenerationEnabled, videoSettings, + analyticsContext, }: ShareProps) => { const effectiveDate: Date = data.metadata?.customCreatedAt ? new Date(data.metadata.customCreatedAt) @@ -287,6 +289,7 @@ export const Share = ({ areReactionStampsDisabled={areReactionStampsDisabled} chapters={aiData?.chapters || []} aiProcessing={aiData?.processing || false} + analyticsContext={analyticsContext} ref={playerRef} /> diff --git a/apps/web/app/s/[videoId]/_components/ShareVideo.tsx b/apps/web/app/s/[videoId]/_components/ShareVideo.tsx index 3f4531bf7f..ed31ef3ad1 100644 --- a/apps/web/app/s/[videoId]/_components/ShareVideo.tsx +++ b/apps/web/app/s/[videoId]/_components/ShareVideo.tsx @@ -1,18 +1,20 @@ import type { comments as commentsSchema } from "@cap/database/schema"; import { NODE_ENV } from "@cap/env"; import { Logo } from "@cap/ui"; -import type { ImageUpload } from "@cap/web-domain"; +import type { ImageUpload, VideoAnalytics } from "@cap/web-domain"; import { useTranscript } from "hooks/use-transcript"; import { forwardRef, + useCallback, useEffect, useImperativeHandle, + useMemo, useRef, useState, } from "react"; import { UpgradeModal } from "@/components/UpgradeModal"; import { useEffectMutation, useRpcClient } from "@/lib/EffectRuntime"; -import type { VideoData } from "../types"; +import type { ShareAnalyticsContext, VideoData } from "../types"; import { CapVideoPlayer } from "./CapVideoPlayer"; import { HLSVideoPlayer } from "./HLSVideoPlayer"; import { @@ -22,9 +24,12 @@ import { type TranscriptEntry, } from "./utils/transcript-utils"; +const SHARE_WATCH_TIME_ENABLED = + process.env.NEXT_PUBLIC_SHARE_WATCH_TIME === "true"; + declare global { interface Window { - MSStream: any; + MSStream: unknown; } } @@ -46,6 +51,7 @@ export const ShareVideo = forwardRef< areCommentStampsDisabled?: boolean; areReactionStampsDisabled?: boolean; aiProcessing?: boolean; + analyticsContext?: ShareAnalyticsContext; } >( ( @@ -57,6 +63,7 @@ export const ShareVideo = forwardRef< areChaptersDisabled, areCommentStampsDisabled, areReactionStampsDisabled, + analyticsContext, }, ref, ) => { @@ -75,17 +82,213 @@ export const ShareVideo = forwardRef< ); const rpc = useRpcClient(); - const mutation = useEffectMutation({ - mutationFn: () => - rpc.VideosCaptureEvent({ - video: data.id, - }), + const { mutate: captureEvent } = useEffectMutation({ + mutationFn: (event: VideoAnalytics.VideoCaptureEvent) => + rpc.VideosCaptureEvent(event), + }); + + const sessionId = useMemo(() => ensureShareSessionId(), []); + const userAgentDetails = useMemo( + () => + deriveUserAgentDetails( + analyticsContext?.userAgent ?? + (typeof navigator !== "undefined" + ? navigator.userAgent + : undefined), + ), + [analyticsContext?.userAgent], + ); + + const analyticsBase = useMemo( + () => ({ + video: data.id, + sessionId: sessionId ?? undefined, + city: analyticsContext?.city ?? undefined, + country: analyticsContext?.country ?? undefined, + referrer: analyticsContext?.referrer ?? undefined, + referrerUrl: analyticsContext?.referrerUrl ?? undefined, + utmSource: analyticsContext?.utmSource ?? undefined, + utmMedium: analyticsContext?.utmMedium ?? undefined, + utmCampaign: analyticsContext?.utmCampaign ?? undefined, + utmTerm: analyticsContext?.utmTerm ?? undefined, + utmContent: analyticsContext?.utmContent ?? undefined, + device: userAgentDetails.device ?? undefined, + browser: userAgentDetails.browser ?? undefined, + os: userAgentDetails.os ?? undefined, + }), + [ + analyticsContext?.city, + analyticsContext?.country, + analyticsContext?.referrer, + analyticsContext?.referrerUrl, + analyticsContext?.utmSource, + analyticsContext?.utmMedium, + analyticsContext?.utmCampaign, + analyticsContext?.utmTerm, + analyticsContext?.utmContent, + data.id, + sessionId, + userAgentDetails.browser, + userAgentDetails.device, + userAgentDetails.os, + ], + ); + + const watchTimeTrackingEnabled = + SHARE_WATCH_TIME_ENABLED && Boolean(sessionId) && Boolean(analyticsContext); + + const watchStateRef = useRef({ + startedAt: null as number | null, + accumulatedMs: 0, }); + const hasFlushedRef = useRef(false); + const hasInitializedRef = useRef(false); + const didStrictCleanupRef = useRef(false); + + const startWatchTimer = useCallback(() => { + if (!watchTimeTrackingEnabled) return; + if (watchStateRef.current.startedAt !== null) return; + watchStateRef.current.startedAt = nowMs(); + }, [watchTimeTrackingEnabled]); + + const stopWatchTimer = useCallback(() => { + if (!watchTimeTrackingEnabled) return; + if (watchStateRef.current.startedAt === null) return; + watchStateRef.current.accumulatedMs += + nowMs() - watchStateRef.current.startedAt; + watchStateRef.current.startedAt = null; + }, [watchTimeTrackingEnabled]); + + const readWatchTimeSeconds = useCallback(() => { + if (!watchTimeTrackingEnabled) return 0; + stopWatchTimer(); + return watchStateRef.current.accumulatedMs / 1000; + }, [stopWatchTimer, watchTimeTrackingEnabled]); + + const flushAnalyticsEvent = useCallback( + (_reason?: string) => { + if (!watchTimeTrackingEnabled) return; + if (hasFlushedRef.current) return; + const watchTimeSeconds = Number( + Math.max(0, readWatchTimeSeconds()).toFixed(2), + ); + const payload: VideoAnalytics.VideoCaptureEvent = { + ...analyticsBase, + watchTimeSeconds, + }; + captureEvent(payload); + hasFlushedRef.current = true; + }, + [analyticsBase, captureEvent, readWatchTimeSeconds, watchTimeTrackingEnabled], + ); + + useEffect(() => { + if (!watchTimeTrackingEnabled) return; + if (hasInitializedRef.current) { + flushAnalyticsEvent(`video-change-${data.id}`); + } else { + hasInitializedRef.current = true; + } + watchStateRef.current = { startedAt: null, accumulatedMs: 0 }; + hasFlushedRef.current = false; + }, [data.id, flushAnalyticsEvent, watchTimeTrackingEnabled]); + + useEffect( + () => () => { + if (!watchTimeTrackingEnabled) return; + if (didStrictCleanupRef.current) { + flushAnalyticsEvent("unmount"); + } else { + didStrictCleanupRef.current = true; + } + }, + [flushAnalyticsEvent, watchTimeTrackingEnabled], + ); - // biome-ignore lint/correctness/useExhaustiveDependencies: just cause useEffect(() => { - if (mutation.isIdle) mutation.mutate(); - }, []); + if (!watchTimeTrackingEnabled) return; + if (typeof window === "undefined") return; + let rafId: number | null = null; + let cleanup: (() => void) | undefined; + + const attach = () => { + const video = videoRef.current; + if (!video) return false; + + const handlePlay = () => startWatchTimer(); + const handlePause = () => stopWatchTimer(); + const handleEnded = () => { + stopWatchTimer(); + flushAnalyticsEvent("ended"); + }; + + video.addEventListener("play", handlePlay); + video.addEventListener("playing", handlePlay); + video.addEventListener("pause", handlePause); + video.addEventListener("waiting", handlePause); + video.addEventListener("seeking", handlePause); + video.addEventListener("ended", handleEnded); + + cleanup = () => { + video.removeEventListener("play", handlePlay); + video.removeEventListener("playing", handlePlay); + video.removeEventListener("pause", handlePause); + video.removeEventListener("waiting", handlePause); + video.removeEventListener("seeking", handlePause); + video.removeEventListener("ended", handleEnded); + }; + + return true; + }; + + if (!attach()) { + const check = () => { + if (attach()) return; + rafId = window.requestAnimationFrame(check); + }; + rafId = window.requestAnimationFrame(check); + } + + return () => { + cleanup?.(); + if (rafId) window.cancelAnimationFrame(rafId); + }; + }, [ + flushAnalyticsEvent, + startWatchTimer, + stopWatchTimer, + watchTimeTrackingEnabled, + ]); + + useEffect(() => { + if (!watchTimeTrackingEnabled) return; + if (typeof window === "undefined") return; + const handleVisibility = () => { + if (document.visibilityState === "hidden") { + flushAnalyticsEvent("visibility"); + } + }; + const handlePageHide = () => flushAnalyticsEvent("pagehide"); + const handleBeforeUnload = () => flushAnalyticsEvent("beforeunload"); + + document.addEventListener("visibilitychange", handleVisibility); + window.addEventListener("pagehide", handlePageHide); + window.addEventListener("beforeunload", handleBeforeUnload); + + return () => { + document.removeEventListener("visibilitychange", handleVisibility); + window.removeEventListener("pagehide", handlePageHide); + window.removeEventListener("beforeunload", handleBeforeUnload); + }; + }, [flushAnalyticsEvent, watchTimeTrackingEnabled]); + + useEffect( + () => () => { + if (!watchTimeTrackingEnabled) return; + flushAnalyticsEvent("unmount"); + }, + [flushAnalyticsEvent, watchTimeTrackingEnabled], + ); // Handle comments data useEffect(() => { @@ -145,7 +348,7 @@ export const ShareVideo = forwardRef< setSubtitleUrl(null); } } - }, [data.transcriptionStatus, transcriptData]); + }, [data.transcriptionStatus, transcriptData, subtitleUrl]); // Handle chapters URL creation useEffect(() => { @@ -171,7 +374,7 @@ export const ShareVideo = forwardRef< setChaptersUrl(null); } } - }, [chapters]); + }, [chapters, chaptersUrl]); const isMp4Source = data.source.type === "desktopMP4" || data.source.type === "webMP4"; @@ -236,8 +439,10 @@ export const ShareVideo = forwardRef< {!data.owner.isPro && (
-
{ e.stopPropagation(); setUpgradeModalOpen(true); @@ -254,7 +459,7 @@ export const ShareVideo = forwardRef<

- + )} { + if (typeof window === "undefined") return undefined; + try { + const existing = window.sessionStorage.getItem(SHARE_SESSION_STORAGE_KEY); + if (existing) return existing; + const newId = + typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : Math.random().toString(36).slice(2); + window.sessionStorage.setItem(SHARE_SESSION_STORAGE_KEY, newId); + return newId; + } catch { + return undefined; + } +}; + +const nowMs = () => + typeof performance !== "undefined" && typeof performance.now === "function" + ? performance.now() + : Date.now(); + +type UserAgentDetails = { + device?: string; + browser?: string; + os?: string; +}; + +const deriveUserAgentDetails = (ua?: string): UserAgentDetails => { + if (!ua) return {}; + const value = ua.toLowerCase(); + const details: UserAgentDetails = {}; + + if ( + /ipad|tablet/.test(value) || + (/android/.test(value) && !/mobile/.test(value)) + ) { + details.device = "tablet"; + } else if (/mobi|iphone|ipod|android/.test(value)) { + details.device = "mobile"; + } else { + details.device = "desktop"; + } + + if (/edg\//.test(value)) details.browser = "Edge"; + else if ( + /chrome|crios|crmo/.test(value) && + !/opr\//.test(value) && + !/edg\//.test(value) + ) + details.browser = "Chrome"; + else if (/safari/.test(value) && !/chrome|crios|android/.test(value)) + details.browser = "Safari"; + else if (/firefox|fxios/.test(value)) details.browser = "Firefox"; + else if (/opr\//.test(value) || /opera/.test(value)) + details.browser = "Opera"; + else if (/msie|trident/.test(value)) details.browser = "IE"; + else details.browser = "Other"; + + if (/windows nt/.test(value)) details.os = "Windows"; + else if (/mac os x/.test(value) && !/iphone|ipad|ipod/.test(value)) + details.os = "macOS"; + else if (/iphone|ipad|ipod/.test(value)) details.os = "iOS"; + else if (/android/.test(value)) details.os = "Android"; + else if (/cros/.test(value)) details.os = "ChromeOS"; + else if (/linux/.test(value)) details.os = "Linux"; + else details.os = "Other"; + + return details; +}; diff --git a/apps/web/app/s/[videoId]/_components/video/media-player.tsx b/apps/web/app/s/[videoId]/_components/video/media-player.tsx index 3d576b71c9..3caeb2ffc0 100644 --- a/apps/web/app/s/[videoId]/_components/video/media-player.tsx +++ b/apps/web/app/s/[videoId]/_components/video/media-player.tsx @@ -1488,9 +1488,20 @@ function MediaPlayerSeek(props: MediaPlayerSeekProps) { const mediaCurrentTime = useMediaSelector( (state) => state.mediaCurrentTime ?? 0, ); - const [seekableStart = 0, seekableEnd = 0] = useMediaSelector( + const [seekableStartRaw = 0, seekableEndRaw = 0] = useMediaSelector( (state) => state.mediaSeekable ?? [0, 0], ); + const seekableStart = Number.isFinite(seekableStartRaw) + ? seekableStartRaw + : 0; + const seekableEnd = + Number.isFinite(seekableEndRaw) && seekableEndRaw >= seekableStart + ? seekableEndRaw + : seekableStart; + const isSeekRangeValid = + Number.isFinite(seekableEnd) && + Number.isFinite(seekableStart) && + seekableEnd > seekableStart; const mediaBuffered = useMediaSelector((state) => state.mediaBuffered ?? []); const mediaEnded = useMediaSelector((state) => state.mediaEnded ?? false); @@ -1537,7 +1548,29 @@ function MediaPlayerSeek(props: MediaPlayerSeekProps) { const timeCache = React.useRef>(new Map()); - const displayValue = seekState.pendingSeekTime ?? mediaCurrentTime; + const onInteractionStart = React.useCallback(() => { + if (!store.getState().dragging) { + store.setState("dragging", true); + } + }, [store.getState, store.setState]); + + const onInteractionEnd = React.useCallback(() => { + if (store.getState().dragging) { + store.setState("dragging", false); + } + }, [store.getState, store.setState]); + + const displayValueCandidate = + seekState.pendingSeekTime ?? mediaCurrentTime ?? seekableStart; + const displayValue = Math.min( + Math.max( + Number.isFinite(displayValueCandidate) + ? displayValueCandidate + : seekableStart, + seekableStart, + ), + seekableEnd, + ); const isDisabled = disabled || context.disabled; const tooltipDisabled = @@ -1709,7 +1742,7 @@ function MediaPlayerSeek(props: MediaPlayerSeekProps) { ); const onHoverProgressUpdate = React.useCallback(() => { - if (!seekRef.current || seekableEnd <= 0) return; + if (!seekRef.current || !isSeekRangeValid) return; const hoverPercent = Math.min( 100, @@ -1719,7 +1752,7 @@ function MediaPlayerSeek(props: MediaPlayerSeekProps) { SEEK_HOVER_PERCENT, `${hoverPercent.toFixed(4)}%`, ); - }, [seekableEnd]); + }, [isSeekRangeValid, seekableEnd]); React.useEffect(() => { if (seekState.pendingSeekTime !== null) { @@ -1752,7 +1785,7 @@ function MediaPlayerSeek(props: MediaPlayerSeekProps) { }, [dispatch, seekState.isHovering, tooltipDisabled]); const bufferedProgress = React.useMemo(() => { - if (mediaBuffered.length === 0 || seekableEnd <= 0) return 0; + if (mediaBuffered.length === 0 || !isSeekRangeValid) return 0; if (mediaEnded) return 1; @@ -1765,7 +1798,14 @@ function MediaPlayerSeek(props: MediaPlayerSeekProps) { } return Math.min(1, seekableStart / seekableEnd); - }, [mediaBuffered, mediaCurrentTime, seekableEnd, mediaEnded, seekableStart]); + }, [ + isSeekRangeValid, + mediaBuffered, + mediaCurrentTime, + seekableEnd, + mediaEnded, + seekableStart, + ]); const onPointerEnter = React.useCallback(() => { if (seekRef.current) { @@ -1777,7 +1817,7 @@ function MediaPlayerSeek(props: MediaPlayerSeekProps) { horizontalMovementRef.current = 0; verticalMovementRef.current = 0; - if (seekableEnd > 0) { + if (isSeekRangeValid) { if (hoverTimeoutRef.current) { clearTimeout(hoverTimeoutRef.current); } @@ -1792,7 +1832,7 @@ function MediaPlayerSeek(props: MediaPlayerSeekProps) { } } } - }, [seekableEnd, onTooltipPositionUpdate, tooltipDisabled]); + }, [isSeekRangeValid, onTooltipPositionUpdate, tooltipDisabled]); const onPointerLeave = React.useCallback(() => { if (hoverTimeoutRef.current) { @@ -1835,7 +1875,7 @@ function MediaPlayerSeek(props: MediaPlayerSeekProps) { const onPointerMove = React.useCallback( (event: React.PointerEvent) => { - if (seekableEnd <= 0) return; + if (!isSeekRangeValid) return; if (!seekRectRef.current && seekRef.current) { seekRectRef.current = seekRef.current.getBoundingClientRect(); @@ -1926,6 +1966,7 @@ function MediaPlayerSeek(props: MediaPlayerSeekProps) { }); }, [ + isSeekRangeValid, onPreviewUpdate, onTooltipPositionUpdate, onHoverProgressUpdate, @@ -1939,11 +1980,9 @@ function MediaPlayerSeek(props: MediaPlayerSeekProps) { (value: number[]) => { const time = value[0] ?? 0; - setSeekState((prev) => ({ ...prev, pendingSeekTime: time })); + if (!store.getState().dragging) return; - if (!store.getState().dragging) { - store.setState("dragging", true); - } + setSeekState((prev) => ({ ...prev, pendingSeekTime: time })); if (seekThrottleRef.current) { cancelAnimationFrame(seekThrottleRef.current); @@ -1957,7 +1996,7 @@ function MediaPlayerSeek(props: MediaPlayerSeekProps) { seekThrottleRef.current = null; }); }, - [dispatch, store.getState, store.setState], + [dispatch, store.getState], ); const onSeekCommit = React.useCallback( @@ -1998,9 +2037,7 @@ function MediaPlayerSeek(props: MediaPlayerSeekProps) { horizontalMovementRef.current = 0; verticalMovementRef.current = 0; - if (store.getState().dragging) { - store.setState("dragging", false); - } + onInteractionEnd(); dispatch({ type: MediaActionTypes.MEDIA_SEEK_REQUEST, @@ -2012,7 +2049,7 @@ function MediaPlayerSeek(props: MediaPlayerSeekProps) { detail: undefined, }); }, - [dispatch, store.getState, store.setState], + [dispatch, onInteractionEnd], ); React.useEffect(() => { @@ -2037,7 +2074,7 @@ function MediaPlayerSeek(props: MediaPlayerSeekProps) { const hoverTime = getCachedTime(hoverTimeRef.current, seekableEnd); const chapterSeparators = React.useMemo(() => { - if (withoutChapter || chapterCues.length <= 1 || seekableEnd <= 0) { + if (withoutChapter || chapterCues.length <= 1 || !isSeekRangeValid) { return null; } @@ -2059,7 +2096,7 @@ function MediaPlayerSeek(props: MediaPlayerSeekProps) { /> ); }); - }, [chapterCues, seekableEnd, withoutChapter]); + }, [chapterCues, isSeekRangeValid, seekableEnd, withoutChapter]); const spriteStyle = React.useMemo(() => { if (!thumbnail?.coords || !thumbnail?.src) { @@ -2088,7 +2125,7 @@ function MediaPlayerSeek(props: MediaPlayerSeekProps) { }; }, [thumbnail?.coords, thumbnail?.src]); - const SeekSlider = ( + const SeekSlider = isSeekRangeValid ? (
- {seekState.isHovering && seekableEnd > 0 && ( + {seekState.isHovering && (
- {!withoutTooltip && - !context.withoutTooltip && - seekState.isHovering && - seekableEnd > 0 && ( - + {!withoutTooltip && !context.withoutTooltip && seekState.isHovering && ( + +
+ {thumbnail?.src && ( +
+
+
+ )} + {currentChapterCue && ( +
+ {currentChapterCue.text} +
+ )}
- {thumbnail?.src && ( -
- {thumbnail.coords ? ( -
- ) : ( - {`Preview - )} -
- )} - {currentChapterCue && ( -
- {currentChapterCue.text} -
- )} -
- {tooltipTimeVariant === "progress" - ? `${hoverTime} / ${duration}` - : hoverTime} -
+ {tooltipTimeVariant === "progress" + ? `${hoverTime} / ${duration}` + : hoverTime}
- - )} +
+ + )} +
+ ) : ( +
+
); diff --git a/apps/web/app/s/[videoId]/page.tsx b/apps/web/app/s/[videoId]/page.tsx index 7e010eb3f2..00eb1f12e0 100644 --- a/apps/web/app/s/[videoId]/page.tsx +++ b/apps/web/app/s/[videoId]/page.tsx @@ -1,29 +1,28 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { - comments, - organizationMembers, - organizations, - sharedVideos, - spaces, - spaceVideos, - users, - videos, - videoUploads, + comments, + organizationMembers, + organizations, + sharedVideos, + spaces, + spaceVideos, + users, + videos, + videoUploads, } from "@cap/database/schema"; import { buildEnv } from "@cap/env"; import { Logo } from "@cap/ui"; import { userIsPro } from "@cap/utils"; import { - Database, - ImageUploads, - provideOptionalAuth, - Videos, + Database, + ImageUploads, + provideOptionalAuth, + Videos, } from "@cap/web-backend"; import { VideosPolicy } from "@cap/web-backend/src/Videos/VideosPolicy"; import { Comment, type Organisation, Policy, Video } from "@cap/web-domain"; -import { eq, type InferSelectModel, sql } from "drizzle-orm"; -import { type ImageUpload } from "@cap/web-domain"; +import { and, eq, type InferSelectModel, isNull, sql } from "drizzle-orm"; import { Effect, Option } from "effect"; import type { Metadata } from "next"; import { headers } from "next/headers"; @@ -31,8 +30,8 @@ import Link from "next/link"; import { notFound } from "next/navigation"; import { generateAiMetadata } from "@/actions/videos/generate-ai-metadata"; import { - getDashboardData, - type OrganizationSettings, + getDashboardData, + type OrganizationSettings, } from "@/app/(org)/dashboard/dashboard-data"; import { createNotification } from "@/lib/Notification"; import * as EffectRuntime from "@/lib/server"; @@ -43,723 +42,755 @@ import { isAiGenerationEnabled } from "@/utils/flags"; import { PasswordOverlay } from "./_components/PasswordOverlay"; import { ShareHeader } from "./_components/ShareHeader"; import { Share } from "./Share"; +import type { ShareAnalyticsContext } from "./types"; // Helper function to fetch shared spaces data for a video async function getSharedSpacesForVideo(videoId: Video.VideoId) { - // Fetch space-level sharing - const spaceSharing = await db() - .select({ - id: spaces.id, - name: spaces.name, - organizationId: spaces.organizationId, - iconUrl: organizations.iconUrl, - }) - .from(spaceVideos) - .innerJoin(spaces, eq(spaceVideos.spaceId, spaces.id)) - .innerJoin(organizations, eq(spaces.organizationId, organizations.id)) - .where(eq(spaceVideos.videoId, videoId)); - - // Fetch organization-level sharing - const orgSharing = await db() - .select({ - id: organizations.id, - name: organizations.name, - organizationId: organizations.id, - iconUrl: organizations.iconUrl, - }) - .from(sharedVideos) - .innerJoin(organizations, eq(sharedVideos.organizationId, organizations.id)) - .where(eq(sharedVideos.videoId, videoId)); - - const sharedSpaces: Array<{ - id: string; - name: string; - organizationId: string; - iconUrl?: string; - }> = []; - - // Add space-level sharing - spaceSharing.forEach((space) => { - sharedSpaces.push({ - id: space.id, - name: space.name, - organizationId: space.organizationId, - iconUrl: space.iconUrl || undefined, - }); - }); - - // Add organization-level sharing - orgSharing.forEach((org) => { - sharedSpaces.push({ - id: org.id, - name: org.name, - organizationId: org.organizationId, - iconUrl: org.iconUrl || undefined, - }); - }); - - return sharedSpaces; + // Fetch space-level sharing + const spaceSharing = await db() + .select({ + id: spaces.id, + name: spaces.name, + organizationId: spaces.organizationId, + iconUrl: organizations.iconUrl, + }) + .from(spaceVideos) + .innerJoin(spaces, eq(spaceVideos.spaceId, spaces.id)) + .innerJoin(organizations, eq(spaces.organizationId, organizations.id)) + .where(eq(spaceVideos.videoId, videoId)); + + // Fetch organization-level sharing + const orgSharing = await db() + .select({ + id: organizations.id, + name: organizations.name, + organizationId: organizations.id, + iconUrl: organizations.iconUrl, + }) + .from(sharedVideos) + .innerJoin(organizations, eq(sharedVideos.organizationId, organizations.id)) + .where(eq(sharedVideos.videoId, videoId)); + + const sharedSpaces: Array<{ + id: string; + name: string; + organizationId: string; + iconUrl?: string; + }> = []; + + // Add space-level sharing + spaceSharing.forEach((space) => { + sharedSpaces.push({ + id: space.id, + name: space.name, + organizationId: space.organizationId, + iconUrl: space.iconUrl || undefined, + }); + }); + + // Add organization-level sharing + orgSharing.forEach((org) => { + sharedSpaces.push({ + id: org.id, + name: org.name, + organizationId: org.organizationId, + iconUrl: org.iconUrl || undefined, + }); + }); + + return sharedSpaces; } const ALLOWED_REFERRERS = [ - "x.com", - "twitter.com", - "facebook.com", - "fb.com", - "slack.com", - "notion.so", - "linkedin.com", + "x.com", + "twitter.com", + "facebook.com", + "fb.com", + "slack.com", + "notion.so", + "linkedin.com", ]; export async function generateMetadata( - props: PageProps<"/s/[videoId]"> + props: PageProps<"/s/[videoId]">, ): Promise { - const params = await props.params; - const videoId = params.videoId as Video.VideoId; - - const referrer = (await headers()).get("x-referrer") || ""; - const isAllowedReferrer = ALLOWED_REFERRERS.some((domain) => - referrer.includes(domain) - ); - - return Effect.flatMap(Videos, (v) => v.getByIdForViewing(videoId)).pipe( - Effect.map( - Option.match({ - onNone: () => notFound(), - onSome: ([video]) => ({ - title: video.name + " | Cap Recording", - description: "Watch this video on Cap", - openGraph: { - images: [ - { - url: new URL( - `/api/video/og?videoId=${videoId}`, - buildEnv.NEXT_PUBLIC_WEB_URL - ).toString(), - width: 1200, - height: 630, - }, - ], - videos: [ - { - url: new URL( - `/api/playlist?videoId=${video.id}`, - buildEnv.NEXT_PUBLIC_WEB_URL - ).toString(), - width: 1280, - height: 720, - type: "video/mp4", - }, - ], - }, - twitter: { - card: "player", - title: video.name + " | Cap Recording", - description: "Watch this video on Cap", - images: [ - new URL( - `/api/video/og?videoId=${videoId}`, - buildEnv.NEXT_PUBLIC_WEB_URL - ).toString(), - ], - players: { - playerUrl: new URL( - `/s/${videoId}`, - buildEnv.NEXT_PUBLIC_WEB_URL - ).toString(), - streamUrl: new URL( - `/api/playlist?videoId=${video.id}`, - buildEnv.NEXT_PUBLIC_WEB_URL - ).toString(), - width: 1280, - height: 720, - }, - }, - robots: isAllowedReferrer ? "index, follow" : "noindex, nofollow", - }), - }) - ), - Effect.catchTags({ - PolicyDenied: () => - Effect.succeed({ - title: "Cap: This video is private", - description: "This video is private and cannot be shared.", - openGraph: { - images: [ - { - url: new URL( - `/api/video/og?videoId=${videoId}`, - buildEnv.NEXT_PUBLIC_WEB_URL - ).toString(), - width: 1200, - height: 630, - }, - ], - videos: [ - { - url: new URL( - `/api/playlist?videoId=${videoId}`, - buildEnv.NEXT_PUBLIC_WEB_URL - ).toString(), - width: 1280, - height: 720, - type: "video/mp4", - }, - ], - }, - robots: "noindex, nofollow", - }), - VerifyVideoPasswordError: () => - Effect.succeed({ - title: "Cap: Password Protected Video", - description: "This video is password protected.", - openGraph: { - images: [ - { - url: new URL( - `/api/video/og?videoId=${videoId}`, - buildEnv.NEXT_PUBLIC_WEB_URL - ).toString(), - width: 1200, - height: 630, - }, - ], - }, - twitter: { - card: "summary_large_image", - title: "Cap: Password Protected Video", - description: "This video is password protected.", - images: [ - new URL( - `/api/video/og?videoId=${videoId}`, - buildEnv.NEXT_PUBLIC_WEB_URL - ).toString(), - ], - }, - robots: "noindex, nofollow", - }), - }), - provideOptionalAuth, - EffectRuntime.runPromise - ); + const params = await props.params; + const videoId = params.videoId as Video.VideoId; + + const referrer = (await headers()).get("x-referrer") || ""; + const isAllowedReferrer = ALLOWED_REFERRERS.some((domain) => + referrer.includes(domain), + ); + + return Effect.flatMap(Videos, (v) => v.getByIdForViewing(videoId)).pipe( + Effect.map( + Option.match({ + onNone: () => notFound(), + onSome: ([video]) => ({ + title: `${video.name} | Cap Recording`, + description: "Watch this video on Cap", + openGraph: { + images: [ + { + url: new URL( + `/api/video/og?videoId=${videoId}`, + buildEnv.NEXT_PUBLIC_WEB_URL, + ).toString(), + width: 1200, + height: 630, + }, + ], + videos: [ + { + url: new URL( + `/api/playlist?videoId=${video.id}`, + buildEnv.NEXT_PUBLIC_WEB_URL, + ).toString(), + width: 1280, + height: 720, + type: "video/mp4", + }, + ], + }, + twitter: { + card: "player", + title: `${video.name} | Cap Recording`, + description: "Watch this video on Cap", + images: [ + new URL( + `/api/video/og?videoId=${videoId}`, + buildEnv.NEXT_PUBLIC_WEB_URL, + ).toString(), + ], + players: { + playerUrl: new URL( + `/s/${videoId}`, + buildEnv.NEXT_PUBLIC_WEB_URL, + ).toString(), + streamUrl: new URL( + `/api/playlist?videoId=${video.id}`, + buildEnv.NEXT_PUBLIC_WEB_URL, + ).toString(), + width: 1280, + height: 720, + }, + }, + robots: isAllowedReferrer ? "index, follow" : "noindex, nofollow", + }), + }), + ), + Effect.catchTags({ + PolicyDenied: () => + Effect.succeed({ + title: "Cap: This video is private", + description: "This video is private and cannot be shared.", + openGraph: { + images: [ + { + url: new URL( + `/api/video/og?videoId=${videoId}`, + buildEnv.NEXT_PUBLIC_WEB_URL, + ).toString(), + width: 1200, + height: 630, + }, + ], + videos: [ + { + url: new URL( + `/api/playlist?videoId=${videoId}`, + buildEnv.NEXT_PUBLIC_WEB_URL, + ).toString(), + width: 1280, + height: 720, + type: "video/mp4", + }, + ], + }, + robots: "noindex, nofollow", + }), + VerifyVideoPasswordError: () => + Effect.succeed({ + title: "Cap: Password Protected Video", + description: "This video is password protected.", + openGraph: { + images: [ + { + url: new URL( + `/api/video/og?videoId=${videoId}`, + buildEnv.NEXT_PUBLIC_WEB_URL, + ).toString(), + width: 1200, + height: 630, + }, + ], + }, + twitter: { + card: "summary_large_image", + title: "Cap: Password Protected Video", + description: "This video is password protected.", + images: [ + new URL( + `/api/video/og?videoId=${videoId}`, + buildEnv.NEXT_PUBLIC_WEB_URL, + ).toString(), + ], + }, + robots: "noindex, nofollow", + }), + }), + provideOptionalAuth, + EffectRuntime.runPromise, + ); } export default async function ShareVideoPage(props: PageProps<"/s/[videoId]">) { - const params = await props.params; - const searchParams = await props.searchParams; - const videoId = Video.VideoId.make(params.videoId); - - return Effect.gen(function* () { - const videosPolicy = yield* VideosPolicy; - - const [video] = yield* Effect.promise(() => - db() - .select({ - id: videos.id, - name: videos.name, - orgId: videos.orgId, - createdAt: videos.createdAt, - updatedAt: videos.updatedAt, - effectiveCreatedAt: videos.effectiveCreatedAt, - bucket: videos.bucket, - metadata: videos.metadata, - public: videos.public, - videoStartTime: videos.videoStartTime, - audioStartTime: videos.audioStartTime, - awsRegion: videos.awsRegion, - awsBucket: videos.awsBucket, - xStreamInfo: videos.xStreamInfo, - jobId: videos.jobId, - jobStatus: videos.jobStatus, - isScreenshot: videos.isScreenshot, - skipProcessing: videos.skipProcessing, - transcriptionStatus: videos.transcriptionStatus, - source: videos.source, - videoSettings: videos.settings, - width: videos.width, - height: videos.height, - duration: videos.duration, - fps: videos.fps, - hasPassword: sql`${videos.password} IS NOT NULL`.mapWith(Boolean), - sharedOrganization: { - organizationId: sharedVideos.organizationId, - }, - orgSettings: organizations.settings, - hasActiveUpload: sql`${videoUploads.videoId} IS NOT NULL`.mapWith( - Boolean - ), - owner: users, - }) - .from(videos) - .leftJoin(sharedVideos, eq(videos.id, sharedVideos.videoId)) - .innerJoin(users, eq(videos.ownerId, users.id)) - .leftJoin(videoUploads, eq(videos.id, videoUploads.videoId)) - .leftJoin(organizations, eq(videos.orgId, organizations.id)) - .where(and(eq(videos.id, videoId), isNull(organizations.tombstoneAt))) - ).pipe(Policy.withPublicPolicy(videosPolicy.canView(videoId))); - - return Option.fromNullable(video); - }).pipe( - Effect.flatten, - Effect.map((video) => ({ needsPassword: false, video } as const)), - Effect.catchTag("VerifyVideoPasswordError", () => - Effect.succeed({ needsPassword: true } as const) - ), - Effect.map((data) => ( -
- - {!data.needsPassword && ( - - )} -
- )), - Effect.catchTags({ - PolicyDenied: () => - Effect.succeed( -
- -

- This video is private -

-

- If you own this video, please sign in{" "} - to manage sharing. -

-
- ), - NoSuchElementException: () => Effect.sync(() => notFound()), - }), - provideOptionalAuth, - EffectRuntime.runPromise - ); + const params = await props.params; + const searchParams = await props.searchParams; + const videoId = Video.VideoId.make(params.videoId); + + return Effect.gen(function* () { + const videosPolicy = yield* VideosPolicy; + + const [video] = yield* Effect.promise(() => + db() + .select({ + id: videos.id, + name: videos.name, + orgId: videos.orgId, + createdAt: videos.createdAt, + updatedAt: videos.updatedAt, + effectiveCreatedAt: videos.effectiveCreatedAt, + bucket: videos.bucket, + metadata: videos.metadata, + public: videos.public, + videoStartTime: videos.videoStartTime, + audioStartTime: videos.audioStartTime, + awsRegion: videos.awsRegion, + awsBucket: videos.awsBucket, + xStreamInfo: videos.xStreamInfo, + jobId: videos.jobId, + jobStatus: videos.jobStatus, + isScreenshot: videos.isScreenshot, + skipProcessing: videos.skipProcessing, + transcriptionStatus: videos.transcriptionStatus, + source: videos.source, + videoSettings: videos.settings, + width: videos.width, + height: videos.height, + duration: videos.duration, + fps: videos.fps, + hasPassword: sql`${videos.password} IS NOT NULL`.mapWith(Boolean), + sharedOrganization: { + organizationId: sharedVideos.organizationId, + }, + orgSettings: organizations.settings, + hasActiveUpload: sql`${videoUploads.videoId} IS NOT NULL`.mapWith( + Boolean, + ), + owner: users, + }) + .from(videos) + .leftJoin(sharedVideos, eq(videos.id, sharedVideos.videoId)) + .innerJoin(users, eq(videos.ownerId, users.id)) + .leftJoin(videoUploads, eq(videos.id, videoUploads.videoId)) + .leftJoin(organizations, eq(videos.orgId, organizations.id)) + .where(and(eq(videos.id, videoId), isNull(organizations.tombstoneAt))), + ).pipe(Policy.withPublicPolicy(videosPolicy.canView(videoId))); + + return Option.fromNullable(video); + }).pipe( + Effect.flatten, + Effect.map((video) => ({ needsPassword: false, video }) as const), + Effect.catchTag("VerifyVideoPasswordError", () => + Effect.succeed({ needsPassword: true } as const), + ), + Effect.map((data) => ( +
+ + {!data.needsPassword && ( + + )} +
+ )), + Effect.catchTags({ + // biome-ignore lint/correctness/noNestedComponentDefinitions: inline Effect handler returning JSX is fine here + PolicyDenied: () => + Effect.succeed( +
+ +

+ This video is private +

+

+ If you own this video, please sign in{" "} + to manage sharing. +

+
, + ), + // biome-ignore lint/correctness/noNestedComponentDefinitions: inline Effect handler returning JSX is fine here + NoSuchElementException: () => Effect.sync(() => notFound()), + }), + provideOptionalAuth, + EffectRuntime.runPromise, + ); } async function AuthorizedContent({ - video, - searchParams, + video, + searchParams, }: { - video: Omit< - InferSelectModel, - "folderId" | "password" | "settings" | "ownerId" - > & { - owner: InferSelectModel; - sharedOrganization: { organizationId: Organisation.OrganisationId } | null; - hasPassword: boolean; - orgSettings?: OrganizationSettings | null; - videoSettings?: OrganizationSettings | null; - }; - searchParams: { [key: string]: string | string[] | undefined }; + video: Omit< + InferSelectModel, + "folderId" | "password" | "settings" | "ownerId" + > & { + owner: InferSelectModel; + sharedOrganization: { organizationId: Organisation.OrganisationId } | null; + hasPassword: boolean; + orgSettings?: OrganizationSettings | null; + videoSettings?: OrganizationSettings | null; + }; + searchParams: { [key: string]: string | string[] | undefined }; }) { - // will have already been fetched if auth is required - const user = await getCurrentUser(); - const videoId = video.id; - - if (user && video && user.id !== video.owner.id) { - try { - await createNotification({ - type: "view", - videoId: video.id, - authorId: user.id, - }); - } catch (error) { - console.warn("Failed to create view notification:", error); - } - } - - const userId = user?.id; - const commentId = optionFromTOrFirst(searchParams.comment).pipe( - Option.map(Comment.CommentId.make) - ); - const replyId = optionFromTOrFirst(searchParams.reply).pipe( - Option.map(Comment.CommentId.make) - ); - - // Fetch spaces data for the sharing dialog - let spacesData = null; - if (user) { - try { - const dashboardData = await getDashboardData(user); - spacesData = dashboardData.spacesData; - } catch (error) { - console.error("Failed to fetch spaces data for sharing dialog:", error); - spacesData = []; - } - } - - // Fetch shared spaces data for this video - const sharedSpaces = await getSharedSpacesForVideo(videoId); - - let aiGenerationEnabled = false; - const videoOwnerQuery = await db() - .select({ - email: users.email, - stripeSubscriptionStatus: users.stripeSubscriptionStatus, - }) - .from(users) - .where(eq(users.id, video.owner.id)) - .limit(1); - - if (videoOwnerQuery.length > 0 && videoOwnerQuery[0]) { - const videoOwner = videoOwnerQuery[0]; - aiGenerationEnabled = await isAiGenerationEnabled(videoOwner); - } - - if (video.sharedOrganization?.organizationId) { - const organization = await db() - .select() - .from(organizations) - .where(eq(organizations.id, video.sharedOrganization.organizationId)) - .limit(1); - - if (organization[0]?.allowedEmailDomain) { - if ( - !user?.email || - !user.email.endsWith(`@${organization[0].allowedEmailDomain}`) - ) { - console.log( - "[ShareVideoPage] Access denied - domain restriction:", - organization[0].allowedEmailDomain - ); - return ( -
-

Access Restricted

-

- This video is only accessible to members of this organization. -

-

- Please sign in with your organization email address to access this - content. -

-
- ); - } - } - } - - if ( - video.transcriptionStatus !== "COMPLETE" && - video.transcriptionStatus !== "PROCESSING" - ) { - console.log("[ShareVideoPage] Starting transcription for video:", videoId); - await transcribeVideo(videoId, video.owner.id, aiGenerationEnabled); - - const updatedVideoQuery = await db() - .select({ - id: videos.id, - name: videos.name, - createdAt: videos.createdAt, - updatedAt: videos.updatedAt, - effectiveCreatedAt: videos.effectiveCreatedAt, - bucket: videos.bucket, - metadata: videos.metadata, - public: videos.public, - videoStartTime: videos.videoStartTime, - audioStartTime: videos.audioStartTime, - xStreamInfo: videos.xStreamInfo, - jobId: videos.jobId, - jobStatus: videos.jobStatus, - isScreenshot: videos.isScreenshot, - skipProcessing: videos.skipProcessing, - transcriptionStatus: videos.transcriptionStatus, - source: videos.source, - sharedOrganization: { - organizationId: sharedVideos.organizationId, - }, - orgSettings: organizations.settings, - videoSettings: videos.settings, - }) - .from(videos) - .leftJoin(sharedVideos, eq(videos.id, sharedVideos.videoId)) - .innerJoin(users, eq(videos.ownerId, users.id)) - .leftJoin(organizations, eq(videos.orgId, organizations.id)) - .where(eq(videos.id, videoId)) - .execute(); - - if (updatedVideoQuery[0]) { - Object.assign(video, updatedVideoQuery[0]); - console.log( - "[ShareVideoPage] Updated transcription status:", - video.transcriptionStatus - ); - } - } - - const currentMetadata = video.metadata || {}; - const metadata = currentMetadata; - let initialAiData = null; - - if (metadata.summary || metadata.chapters || metadata.aiTitle) { - initialAiData = { - title: metadata.aiTitle || null, - summary: metadata.summary || null, - chapters: metadata.chapters || null, - processing: metadata.aiProcessing || false, - }; - } else if (metadata.aiProcessing) { - initialAiData = { - title: null, - summary: null, - chapters: null, - processing: true, - }; - } - - if ( - video.transcriptionStatus === "COMPLETE" && - !currentMetadata.aiProcessing && - !currentMetadata.summary && - !currentMetadata.chapters && - // !currentMetadata.generationError && - aiGenerationEnabled - ) { - try { - generateAiMetadata(videoId, video.owner.id).catch((error) => { - console.error( - `[ShareVideoPage] Error generating AI metadata for video ${videoId}:`, - error - ); - }); - } catch (error) { - console.error( - `[ShareVideoPage] Error starting AI metadata generation for video ${videoId}:`, - error - ); - } - } - - const customDomainPromise = (async () => { - if (!user) { - return { customDomain: null, domainVerified: false }; - } - const activeOrganizationId = user.activeOrganizationId; - if (!activeOrganizationId) { - return { customDomain: null, domainVerified: false }; - } - - // Fetch the active org - const orgArr = await db() - .select({ - customDomain: organizations.customDomain, - domainVerified: organizations.domainVerified, - }) - .from(organizations) - .where(eq(organizations.id, activeOrganizationId)) - .limit(1); - - const org = orgArr[0]; - if ( - org && - org.customDomain && - org.domainVerified !== null && - user.id === video.owner.id - ) { - return { customDomain: org.customDomain, domainVerified: true }; - } - return { customDomain: null, domainVerified: false }; - })(); - - const sharedOrganizationsPromise = db() - .select({ id: sharedVideos.organizationId, name: organizations.name }) - .from(sharedVideos) - .innerJoin(organizations, eq(sharedVideos.organizationId, organizations.id)) - .where(eq(sharedVideos.videoId, videoId)); - - const userOrganizationsPromise = (async () => { - if (!userId) return []; - - const [ownedOrganizations, memberOrganizations] = await Promise.all([ - db() - .select({ id: organizations.id, name: organizations.name }) - .from(organizations) - .where(eq(organizations.ownerId, userId)), - db() - .select({ id: organizations.id, name: organizations.name }) - .from(organizations) - .innerJoin( - organizationMembers, - eq(organizations.id, organizationMembers.organizationId) - ) - .where(eq(organizationMembers.userId, userId)), - ]); - - const allOrganizations = [...ownedOrganizations, ...memberOrganizations]; - const uniqueOrganizationIds = new Set(); - - return allOrganizations.filter((organization) => { - if (uniqueOrganizationIds.has(organization.id)) return false; - uniqueOrganizationIds.add(organization.id); - return true; - }); - })(); - - const membersListPromise = video.sharedOrganization?.organizationId - ? db() - .select({ userId: organizationMembers.userId }) - .from(organizationMembers) - .where( - eq( - organizationMembers.organizationId, - video.sharedOrganization.organizationId - ) - ) - : Promise.resolve([]); - - const commentsPromise = Effect.gen(function* () { - const db = yield* Database; - const imageUploads = yield* ImageUploads; - - let toplLevelCommentId = Option.none(); - - if (Option.isSome(replyId)) { - const [parentComment] = yield* db.use((db) => - db - .select({ parentCommentId: comments.parentCommentId }) - .from(comments) - .where(eq(comments.id, replyId.value)) - .limit(1) - ); - toplLevelCommentId = Option.fromNullable(parentComment?.parentCommentId); - } - - const commentToBringToTheTop = Option.orElse( - toplLevelCommentId, - () => commentId - ); - - return yield* db - .use((db) => - db - .select({ - id: comments.id, - content: comments.content, - timestamp: comments.timestamp, - type: comments.type, - authorId: comments.authorId, - videoId: comments.videoId, - createdAt: comments.createdAt, - updatedAt: comments.updatedAt, - parentCommentId: comments.parentCommentId, - authorName: users.name, - authorImage: users.image, - }) - .from(comments) - .leftJoin(users, eq(comments.authorId, users.id)) - .where(eq(comments.videoId, videoId)) - .orderBy( - Option.match(commentToBringToTheTop, { - onSome: (commentId) => - sql`CASE WHEN ${comments.id} = ${commentId} THEN 0 ELSE 1 END, ${comments.createdAt}`, - onNone: () => comments.createdAt, - }) - ) - ) - .pipe( - Effect.map((comments) => - comments.map( - Effect.fn(function* (c) { - return Object.assign(c, { - authorImage: yield* Option.fromNullable(c.authorImage).pipe( - Option.map(imageUploads.resolveImageUrl), - Effect.transposeOption, - Effect.map(Option.getOrNull) - ), - }); - }) - ) - ), - Effect.flatMap(Effect.all) - ); - }).pipe(EffectRuntime.runPromise); - - const viewsPromise = Effect.flatMap(Videos, (videos) => - videos.getAnalytics(videoId) - ).pipe( - Effect.map((v) => v.count), - EffectRuntime.runPromise - ); - - const [ - membersList, - userOrganizations, - sharedOrganizations, - { customDomain, domainVerified }, - ] = await Promise.all([ - membersListPromise, - userOrganizationsPromise, - sharedOrganizationsPromise, - customDomainPromise, - ]); - - const videoWithOrganizationInfo = await Effect.gen(function* () { - const imageUploads = yield* ImageUploads; - - return { - ...video, - owner: { - id: video.owner.id, - name: video.owner.name, - isPro: userIsPro(video.owner), - image: video.owner.image - ? yield* imageUploads.resolveImageUrl(video.owner.image) - : null, - }, - organization: { - organizationMembers: membersList.map((member) => member.userId), - organizationId: video.sharedOrganization?.organizationId ?? undefined, - }, - sharedOrganizations: sharedOrganizations, - password: null, - folderId: null, - orgSettings: video.orgSettings || null, - settings: video.videoSettings || null, - }; - }).pipe(runPromise); - - return ( - <> -
- - - -
- - - ); + // will have already been fetched if auth is required + const user = await getCurrentUser(); + const videoId = video.id; + const headerList = await headers(); + + const getSearchParam = (key: string) => { + const value = searchParams?.[key]; + if (!value) return null; + return Array.isArray(value) ? (value[0] ?? null) : value; + }; + + const referrerUrl = + headerList.get("x-referrer") ?? headerList.get("referer") ?? null; + const analyticsContext: ShareAnalyticsContext = { + city: headerList.get("x-vercel-ip-city"), + country: headerList.get("x-vercel-ip-country"), + referrerUrl, + referrer: (() => { + if (!referrerUrl) return null; + try { + return new URL(referrerUrl).hostname; + } catch { + return null; + } + })(), + utmSource: getSearchParam("utm_source"), + utmMedium: getSearchParam("utm_medium"), + utmCampaign: getSearchParam("utm_campaign"), + utmTerm: getSearchParam("utm_term"), + utmContent: getSearchParam("utm_content"), + userAgent: headerList.get("x-user-agent") ?? headerList.get("user-agent"), + }; + + if (user && video && user.id !== video.owner.id) { + try { + await createNotification({ + type: "view", + videoId: video.id, + authorId: user.id, + }); + } catch (error) { + console.warn("Failed to create view notification:", error); + } + } + + const userId = user?.id; + const commentId = optionFromTOrFirst(searchParams.comment).pipe( + Option.map(Comment.CommentId.make), + ); + const replyId = optionFromTOrFirst(searchParams.reply).pipe( + Option.map(Comment.CommentId.make), + ); + + // Fetch spaces data for the sharing dialog + let spacesData = null; + if (user) { + try { + const dashboardData = await getDashboardData(user); + spacesData = dashboardData.spacesData; + } catch (error) { + console.error("Failed to fetch spaces data for sharing dialog:", error); + spacesData = []; + } + } + + // Fetch shared spaces data for this video + const sharedSpaces = await getSharedSpacesForVideo(videoId); + + let aiGenerationEnabled = false; + const videoOwnerQuery = await db() + .select({ + email: users.email, + stripeSubscriptionStatus: users.stripeSubscriptionStatus, + }) + .from(users) + .where(eq(users.id, video.owner.id)) + .limit(1); + + if (videoOwnerQuery.length > 0 && videoOwnerQuery[0]) { + const videoOwner = videoOwnerQuery[0]; + aiGenerationEnabled = await isAiGenerationEnabled(videoOwner); + } + + if (video.sharedOrganization?.organizationId) { + const organization = await db() + .select() + .from(organizations) + .where(eq(organizations.id, video.sharedOrganization.organizationId)) + .limit(1); + + if (organization[0]?.allowedEmailDomain) { + if ( + !user?.email || + !user.email.endsWith(`@${organization[0].allowedEmailDomain}`) + ) { + console.log( + "[ShareVideoPage] Access denied - domain restriction:", + organization[0].allowedEmailDomain, + ); + return ( +
+

Access Restricted

+

+ This video is only accessible to members of this organization. +

+

+ Please sign in with your organization email address to access this + content. +

+
+ ); + } + } + } + + if ( + video.transcriptionStatus !== "COMPLETE" && + video.transcriptionStatus !== "PROCESSING" + ) { + console.log("[ShareVideoPage] Starting transcription for video:", videoId); + await transcribeVideo(videoId, video.owner.id, aiGenerationEnabled); + + const updatedVideoQuery = await db() + .select({ + id: videos.id, + name: videos.name, + createdAt: videos.createdAt, + updatedAt: videos.updatedAt, + effectiveCreatedAt: videos.effectiveCreatedAt, + bucket: videos.bucket, + metadata: videos.metadata, + public: videos.public, + videoStartTime: videos.videoStartTime, + audioStartTime: videos.audioStartTime, + xStreamInfo: videos.xStreamInfo, + jobId: videos.jobId, + jobStatus: videos.jobStatus, + isScreenshot: videos.isScreenshot, + skipProcessing: videos.skipProcessing, + transcriptionStatus: videos.transcriptionStatus, + source: videos.source, + sharedOrganization: { + organizationId: sharedVideos.organizationId, + }, + orgSettings: organizations.settings, + videoSettings: videos.settings, + }) + .from(videos) + .leftJoin(sharedVideos, eq(videos.id, sharedVideos.videoId)) + .innerJoin(users, eq(videos.ownerId, users.id)) + .leftJoin(organizations, eq(videos.orgId, organizations.id)) + .where(eq(videos.id, videoId)) + .execute(); + + if (updatedVideoQuery[0]) { + Object.assign(video, updatedVideoQuery[0]); + console.log( + "[ShareVideoPage] Updated transcription status:", + video.transcriptionStatus, + ); + } + } + + const currentMetadata = video.metadata || {}; + const metadata = currentMetadata; + let initialAiData = null; + + if (metadata.summary || metadata.chapters || metadata.aiTitle) { + initialAiData = { + title: metadata.aiTitle || null, + summary: metadata.summary || null, + chapters: metadata.chapters || null, + processing: metadata.aiProcessing || false, + }; + } else if (metadata.aiProcessing) { + initialAiData = { + title: null, + summary: null, + chapters: null, + processing: true, + }; + } + + if ( + video.transcriptionStatus === "COMPLETE" && + !currentMetadata.aiProcessing && + !currentMetadata.summary && + !currentMetadata.chapters && + // !currentMetadata.generationError && + aiGenerationEnabled + ) { + try { + generateAiMetadata(videoId, video.owner.id).catch((error) => { + console.error( + `[ShareVideoPage] Error generating AI metadata for video ${videoId}:`, + error, + ); + }); + } catch (error) { + console.error( + `[ShareVideoPage] Error starting AI metadata generation for video ${videoId}:`, + error, + ); + } + } + + const customDomainPromise = (async () => { + if (!user) { + return { customDomain: null, domainVerified: false }; + } + const activeOrganizationId = user.activeOrganizationId; + if (!activeOrganizationId) { + return { customDomain: null, domainVerified: false }; + } + + // Fetch the active org + const orgArr = await db() + .select({ + customDomain: organizations.customDomain, + domainVerified: organizations.domainVerified, + }) + .from(organizations) + .where(eq(organizations.id, activeOrganizationId)) + .limit(1); + + const org = orgArr[0]; + if ( + org?.customDomain && + org.domainVerified !== null && + user.id === video.owner.id + ) { + return { customDomain: org.customDomain, domainVerified: true }; + } + return { customDomain: null, domainVerified: false }; + })(); + + const sharedOrganizationsPromise = db() + .select({ id: sharedVideos.organizationId, name: organizations.name }) + .from(sharedVideos) + .innerJoin(organizations, eq(sharedVideos.organizationId, organizations.id)) + .where(eq(sharedVideos.videoId, videoId)); + + const userOrganizationsPromise = (async () => { + if (!userId) return []; + + const [ownedOrganizations, memberOrganizations] = await Promise.all([ + db() + .select({ id: organizations.id, name: organizations.name }) + .from(organizations) + .where(eq(organizations.ownerId, userId)), + db() + .select({ id: organizations.id, name: organizations.name }) + .from(organizations) + .innerJoin( + organizationMembers, + eq(organizations.id, organizationMembers.organizationId), + ) + .where(eq(organizationMembers.userId, userId)), + ]); + + const allOrganizations = [...ownedOrganizations, ...memberOrganizations]; + const uniqueOrganizationIds = new Set(); + + return allOrganizations.filter((organization) => { + if (uniqueOrganizationIds.has(organization.id)) return false; + uniqueOrganizationIds.add(organization.id); + return true; + }); + })(); + + const membersListPromise = video.sharedOrganization?.organizationId + ? db() + .select({ userId: organizationMembers.userId }) + .from(organizationMembers) + .where( + eq( + organizationMembers.organizationId, + video.sharedOrganization.organizationId, + ), + ) + : Promise.resolve([]); + + const commentsPromise = Effect.gen(function* () { + const db = yield* Database; + const imageUploads = yield* ImageUploads; + + let toplLevelCommentId = Option.none(); + + if (Option.isSome(replyId)) { + const [parentComment] = yield* db.use((db) => + db + .select({ parentCommentId: comments.parentCommentId }) + .from(comments) + .where(eq(comments.id, replyId.value)) + .limit(1), + ); + toplLevelCommentId = Option.fromNullable(parentComment?.parentCommentId); + } + + const commentToBringToTheTop = Option.orElse( + toplLevelCommentId, + () => commentId, + ); + + return yield* db + .use((db) => + db + .select({ + id: comments.id, + content: comments.content, + timestamp: comments.timestamp, + type: comments.type, + authorId: comments.authorId, + videoId: comments.videoId, + createdAt: comments.createdAt, + updatedAt: comments.updatedAt, + parentCommentId: comments.parentCommentId, + authorName: users.name, + authorImage: users.image, + }) + .from(comments) + .leftJoin(users, eq(comments.authorId, users.id)) + .where(eq(comments.videoId, videoId)) + .orderBy( + Option.match(commentToBringToTheTop, { + onSome: (commentId) => + sql`CASE WHEN ${comments.id} = ${commentId} THEN 0 ELSE 1 END, ${comments.createdAt}`, + onNone: () => comments.createdAt, + }), + ), + ) + .pipe( + Effect.map((comments) => + comments.map( + Effect.fn(function* (c) { + return Object.assign(c, { + authorImage: yield* Option.fromNullable(c.authorImage).pipe( + Option.map(imageUploads.resolveImageUrl), + Effect.transposeOption, + Effect.map(Option.getOrNull), + ), + }); + }), + ), + ), + Effect.flatMap(Effect.all), + ); + }).pipe(EffectRuntime.runPromise); + + const viewsPromise = Effect.flatMap(Videos, (videos) => + videos.getAnalytics(videoId), + ).pipe( + Effect.map((v) => v.count), + EffectRuntime.runPromise, + ); + + const [ + membersList, + userOrganizations, + sharedOrganizations, + { customDomain, domainVerified }, + ] = await Promise.all([ + membersListPromise, + userOrganizationsPromise, + sharedOrganizationsPromise, + customDomainPromise, + ]); + + const videoWithOrganizationInfo = await Effect.gen(function* () { + const imageUploads = yield* ImageUploads; + + return { + ...video, + owner: { + id: video.owner.id, + name: video.owner.name, + isPro: userIsPro(video.owner), + image: video.owner.image + ? yield* imageUploads.resolveImageUrl(video.owner.image) + : null, + }, + organization: { + organizationMembers: membersList.map((member) => member.userId), + organizationId: video.sharedOrganization?.organizationId ?? undefined, + }, + sharedOrganizations: sharedOrganizations, + password: null, + folderId: null, + orgSettings: video.orgSettings || null, + settings: video.videoSettings || null, + }; + }).pipe(runPromise); + + return ( + <> +
+ + + +
+ + + ); } diff --git a/apps/web/app/s/[videoId]/types.ts b/apps/web/app/s/[videoId]/types.ts index 6bd2536f80..de9cd9abb1 100644 --- a/apps/web/app/s/[videoId]/types.ts +++ b/apps/web/app/s/[videoId]/types.ts @@ -17,3 +17,16 @@ export type VideoOwner = { name?: string | null; image?: ImageUpload.ImageUrl | null; }; + +export type ShareAnalyticsContext = { + city?: string | null; + country?: string | null; + referrer?: string | null; + referrerUrl?: string | null; + utmSource?: string | null; + utmMedium?: string | null; + utmCampaign?: string | null; + utmTerm?: string | null; + utmContent?: string | null; + userAgent?: string | null; +}; diff --git a/apps/web/lib/effect-react-query.ts b/apps/web/lib/effect-react-query.ts index f9d0d651fd..bbf79f44d5 100644 --- a/apps/web/lib/effect-react-query.ts +++ b/apps/web/lib/effect-react-query.ts @@ -265,7 +265,6 @@ export function makeUseEffectMutation( // }) // ); const result = await runtime.runPromiseExit(effectToRun); - console.log({ result }); if (Exit.isFailure(result)) { // we always throw the cause throw result.cause; diff --git a/packages/web-backend/src/Videos/VideosRpcs.ts b/packages/web-backend/src/Videos/VideosRpcs.ts index dc4aa4de8d..7e090cd4de 100644 --- a/packages/web-backend/src/Videos/VideosRpcs.ts +++ b/packages/web-backend/src/Videos/VideosRpcs.ts @@ -73,9 +73,9 @@ export const VideosRpcsLive = Video.VideoRpcs.toLayer( Effect.catchTag("S3Error", () => new InternalError({ type: "s3" })), ), - VideosGetThumbnails: (videoIds) => + VideosGetThumbnails: (videoIds: ReadonlyArray) => Effect.all( - videoIds.map((id) => + videoIds.map((id: Video.VideoId) => videos.getThumbnailURL(id).pipe( Effect.catchTag( "DatabaseError", @@ -98,49 +98,10 @@ export const VideosRpcsLive = Video.VideoRpcs.toLayer( { concurrency: 10 }, ).pipe( provideOptionalAuth, - Effect.catchTag( - "DatabaseError", - () => new InternalError({ type: "database" }), - ), - Effect.catchTag( - "UnknownException", - () => new InternalError({ type: "unknown" }), - ), - ), - - VideosGetAnalytics: (videoIds) => - Effect.all( - videoIds.map((id) => - videos.getAnalytics(id).pipe( - Effect.catchTag( - "DatabaseError", - () => new InternalError({ type: "database" }), - ), - Effect.catchTag( - "UnknownException", - () => new InternalError({ type: "unknown" }), - ), - Effect.matchEffect({ - onSuccess: (v) => Effect.succeed(Exit.succeed(v)), - onFailure: (e) => - Schema.is(InternalError)(e) - ? Effect.fail(e) - : Effect.succeed(Exit.fail(e)), - }), - Effect.map((v) => Unify.unify(v)), - ), - ), - { concurrency: 10 }, - ).pipe( - provideOptionalAuth, - Effect.catchTag( - "DatabaseError", - () => new InternalError({ type: "database" }), - ), - Effect.catchTag( - "UnknownException", - () => new InternalError({ type: "unknown" }), - ), + Effect.catchTags({ + DatabaseError: () => new InternalError({ type: "database" }), + UnknownException: () => new InternalError({ type: "unknown" }), + }), ), }; }), diff --git a/packages/web-backend/src/VideosAnalytics/index.ts b/packages/web-backend/src/VideosAnalytics/index.ts index 83cd58ff47..108903f14d 100644 --- a/packages/web-backend/src/VideosAnalytics/index.ts +++ b/packages/web-backend/src/VideosAnalytics/index.ts @@ -1,7 +1,12 @@ import { serverEnv } from "@cap/env"; import { dub } from "@cap/utils"; import { Policy, Video, VideoAnalytics } from "@cap/web-domain"; -import { FetchHttpClient, HttpBody, HttpClient } from "@effect/platform"; +import { + FetchHttpClient, + HttpBody, + HttpClient, + HttpClientResponse, +} from "@effect/platform"; import { Effect } from "effect"; import { VideosPolicy } from "../Videos/VideosPolicy"; import { VideosRepo } from "../Videos/VideosRepo"; @@ -66,7 +71,7 @@ export class VideosAnalytics extends Effect.Service()( }), getAnalytics: Effect.fn("VideosAnalytics.getAnalytics")(function* ( - videoId: Video.VideoId, + _videoId: Video.VideoId, ) { // TODO: Implement this @@ -84,30 +89,50 @@ export class VideosAnalytics extends Effect.Service()( const host = serverEnv().TINYBIRD_HOST; if (!token || !host) return; - console.log("TINYBIRD EVENT"); // TODO - const response = yield* client.post( - `${host}/v0/events?name=analytics_views`, - { - body: HttpBody.unsafeJson({ - timestamp: new Date().toISOString(), - version: "1", - session_id: "todo", // TODO - video_id: videoId, - payload: JSON.stringify({ - hello: "world", // TODO - }), - }), - headers: { - Authorization: `Bearer ${token}`, - }, - }, - ); - // const response = yield* HttpClientResponse.filterStatusOk(response); - if (response.status !== 200) { - // TODO - } + const payload = { + timestamp: new Date().toISOString(), + version: "1", + session_id: event.sessionId ?? null, + video_id: videoId, + watch_time_seconds: event.watchTimeSeconds ?? 0, + city: event.city ?? null, + country: event.country ?? null, + device: event.device ?? null, + browser: event.browser ?? null, + os: event.os ?? null, + referrer: event.referrer ?? null, + referrer_url: event.referrerUrl ?? null, + utm_source: event.utmSource ?? null, + utm_medium: event.utmMedium ?? null, + utm_campaign: event.utmCampaign ?? null, + utm_term: event.utmTerm ?? null, + utm_content: event.utmContent ?? null, + payload: JSON.stringify({ + watchTimeSeconds: event.watchTimeSeconds ?? 0, + city: event.city ?? null, + country: event.country ?? null, + device: event.device ?? null, + browser: event.browser ?? null, + os: event.os ?? null, + referrer: event.referrer ?? null, + referrerUrl: event.referrerUrl ?? null, + utmSource: event.utmSource ?? null, + utmMedium: event.utmMedium ?? null, + utmCampaign: event.utmCampaign ?? null, + utmTerm: event.utmTerm ?? null, + utmContent: event.utmContent ?? null, + }), + }; + + console.log("TINYBIRD EVENT", payload); - console.log(response.status, yield* response.text); + yield* client + .post(`${host}/v0/events?name=analytics_views`, { + body: yield* HttpBody.json(payload), + headers: { + Authorization: `Bearer ${token}`, + }, + }); }), }; }), diff --git a/packages/web-domain/src/VideoAnalytics.ts b/packages/web-domain/src/VideoAnalytics.ts index 81b6bb8a7b..a3e16a369d 100644 --- a/packages/web-domain/src/VideoAnalytics.ts +++ b/packages/web-domain/src/VideoAnalytics.ts @@ -15,6 +15,20 @@ export class VideoCaptureEvent extends Schema.Class( "VideoCaptureEvent", )({ video: VideoId, + sessionId: Schema.optional(Schema.String), + watchTimeSeconds: Schema.optional(Schema.Number), + city: Schema.optional(Schema.String), + country: Schema.optional(Schema.String), + device: Schema.optional(Schema.String), + browser: Schema.optional(Schema.String), + os: Schema.optional(Schema.String), + referrer: Schema.optional(Schema.String), + referrerUrl: Schema.optional(Schema.String), + utmSource: Schema.optional(Schema.String), + utmMedium: Schema.optional(Schema.String), + utmCampaign: Schema.optional(Schema.String), + utmTerm: Schema.optional(Schema.String), + utmContent: Schema.optional(Schema.String), }) {} export class VideoAnalyticsRpcs extends RpcGroup.make( diff --git a/tinybird/datasources/analytics_views.datasource b/tinybird/datasources/analytics_views.datasource index 41f7efab73..8ea9ddfeef 100644 --- a/tinybird/datasources/analytics_views.datasource +++ b/tinybird/datasources/analytics_views.datasource @@ -10,6 +10,21 @@ SCHEMA > `session_id` Nullable(String) `json:$.session_id`, `video_id` String `json:$.video_id`, + `watch_time_seconds` Float32 `json:$.watch_time_seconds`, + + `city` LowCardinality(String) `json:$.city`, + `country` LowCardinality(String) `json:$.country`, + `device` LowCardinality(String) `json:$.device`, + `browser` LowCardinality(String) `json:$.browser`, + `os` LowCardinality(String) `json:$.os`, + `referrer` LowCardinality(String) `json:$.referrer`, + `referrer_url` String `json:$.referrer_url`, + `utm_source` LowCardinality(String) `json:$.utm_source`, + `utm_medium` LowCardinality(String) `json:$.utm_medium`, + `utm_campaign` LowCardinality(String) `json:$.utm_campaign`, + `utm_term` LowCardinality(String) `json:$.utm_term`, + `utm_content` LowCardinality(String) `json:$.utm_content`, + `payload` String `json:$.payload` ENGINE MergeTree From 70f610fbc410ecb49831dac5c9ebcc5c26d0a2f8 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 11 Nov 2025 17:23:18 +0000 Subject: [PATCH 19/22] Implement client-side video analytics tracking --- apps/web/app/api/video/analytics/route.ts | 150 ++++ apps/web/app/s/[videoId]/Share.tsx | 20 +- .../s/[videoId]/_components/ShareVideo.tsx | 725 ++++++------------ apps/web/app/s/[videoId]/page.tsx | 56 +- apps/web/app/s/[videoId]/types.ts | 3 + apps/web/app/s/[videoId]/useShareAnalytics.ts | 273 +++++++ packages/web-backend/src/Videos/index.ts | 4 +- .../VideosAnalytics/VideosAnalyticsRpcs.ts | 1 + .../web-backend/src/VideosAnalytics/index.ts | 69 +- packages/web-domain/src/VideoAnalytics.ts | 8 +- 10 files changed, 766 insertions(+), 543 deletions(-) create mode 100644 apps/web/app/api/video/analytics/route.ts create mode 100644 apps/web/app/s/[videoId]/useShareAnalytics.ts diff --git a/apps/web/app/api/video/analytics/route.ts b/apps/web/app/api/video/analytics/route.ts new file mode 100644 index 0000000000..c49bd674c0 --- /dev/null +++ b/apps/web/app/api/video/analytics/route.ts @@ -0,0 +1,150 @@ +import { VideosAnalytics } from "@cap/web-backend"; +import { VideoAnalytics } from "@cap/web-domain"; +import { + HttpApi, + HttpApiBuilder, + HttpApiEndpoint, + HttpApiError, + HttpApiGroup, +} from "@effect/platform"; +import { geolocation } from "@vercel/functions"; +import { Effect, Layer } from "effect"; +import { apiToHandler } from "@/lib/server"; + +const normalizeHeaderValue = (value?: string | null) => { + if (!value) return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +}; + +const LOCALHOST_GEO_DATA = { + city: normalizeHeaderValue(process.env.LOCAL_GEO_CITY) ?? "San Francisco", + country: normalizeHeaderValue(process.env.LOCAL_GEO_COUNTRY) ?? "US", +} as const; + +const isRunningOnVercel = process.env.VERCEL === "1"; + +class Api extends HttpApi.make("VideoAnalyticsCaptureApi").add( + HttpApiGroup.make("root").add( + HttpApiEndpoint.post("captureAnalytics")`/api/video/analytics` + .setPayload(VideoAnalytics.VideoCaptureEvent) + .addError(HttpApiError.BadRequest) + .addError(HttpApiError.InternalServerError), + ), +) {} + +const ApiLive = HttpApiBuilder.api(Api).pipe( + Layer.provide( + HttpApiBuilder.group(Api, "root", (handlers) => + Effect.gen(function* () { + const videosAnalytics = yield* VideosAnalytics; + + return handlers.handle("captureAnalytics", ({ payload }) => + videosAnalytics.captureEvent(payload).pipe( + Effect.catchTags({ + HttpBodyError: () => new HttpApiError.BadRequest(), + RequestError: () => new HttpApiError.InternalServerError(), + ResponseError: () => new HttpApiError.InternalServerError(), + }), + ), + ); + }), + ), + ), +); + +const handler = apiToHandler(ApiLive); + +const CITY_HEADER_KEYS = [ + "x-vercel-ip-city", + "cf-ipcity", + "x-nf-geo-city", + "x-geo-city", + "x-appengine-city", +] as const; + +const COUNTRY_HEADER_KEYS = [ + "x-vercel-ip-country", + "cf-ipcountry", + "x-nf-geo-country", + "x-geo-country", + "x-appengine-country", + "x-country-code", +] as const; + +const pickHeaderValue = (request: Request, keys: readonly string[]) => { + for (const key of keys) { + const value = normalizeHeaderValue(request.headers.get(key)); + if (value) return value; + } + + return undefined; +}; + +const getGeoFromRequest = (request: Request) => { + if (!isRunningOnVercel) return { city: undefined, country: undefined }; + + try { + const details = geolocation(request); + return { + city: normalizeHeaderValue(details.city), + country: normalizeHeaderValue(details.country), + }; + } catch { + return { city: undefined, country: undefined }; + } +}; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +export const POST = async (request: Request) => { + const { city: geoCity, country: geoCountry } = getGeoFromRequest(request); + const headerCity = pickHeaderValue(request, CITY_HEADER_KEYS); + const headerCountry = pickHeaderValue(request, COUNTRY_HEADER_KEYS); + + const fallbackCity = !isRunningOnVercel ? LOCALHOST_GEO_DATA.city : undefined; + const fallbackCountry = !isRunningOnVercel ? LOCALHOST_GEO_DATA.country : undefined; + + const derivedCity = geoCity ?? headerCity ?? fallbackCity; + const derivedCountry = geoCountry ?? headerCountry ?? fallbackCountry; + + if (!derivedCity && !derivedCountry) return handler(request); + + let parsedBody: unknown; + try { + parsedBody = await request.clone().json(); + } catch { + return handler(request); + } + + if (!isRecord(parsedBody)) return handler(request); + + const existingCity = normalizeHeaderValue( + typeof parsedBody.city === "string" ? parsedBody.city : undefined, + ); + const existingCountry = normalizeHeaderValue( + typeof parsedBody.country === "string" ? parsedBody.country : undefined, + ); + + const cityToApply = !existingCity ? derivedCity : undefined; + const countryToApply = !existingCountry ? derivedCountry : undefined; + + if (!cityToApply && !countryToApply) return handler(request); + + const enhancedPayload = { + ...parsedBody, + ...(cityToApply ? { city: cityToApply } : {}), + ...(countryToApply ? { country: countryToApply } : {}), + }; + + const headers = new Headers(request.headers); + headers.delete("content-length"); + + const enhancedRequest = new Request(request, { + headers, + body: JSON.stringify(enhancedPayload), + }); + + return handler(enhancedRequest); +}; diff --git a/apps/web/app/s/[videoId]/Share.tsx b/apps/web/app/s/[videoId]/Share.tsx index 048e6559f5..bc70dd72cb 100644 --- a/apps/web/app/s/[videoId]/Share.tsx +++ b/apps/web/app/s/[videoId]/Share.tsx @@ -17,10 +17,12 @@ import { type VideoStatusResult, } from "@/actions/videos/get-status"; import type { OrganizationSettings } from "@/app/(org)/dashboard/dashboard-data"; +import { usePublicEnv } from "@/utils/public-env"; import { ShareVideo } from "./_components/ShareVideo"; import { Sidebar } from "./_components/Sidebar"; import SummaryChapters from "./_components/SummaryChapters"; import { Toolbar } from "./_components/Toolbar"; +import { useShareAnalytics } from "./useShareAnalytics"; import type { ShareAnalyticsContext, VideoData } from "./types"; type CommentWithAuthor = typeof commentsSchema.$inferSelect & { @@ -49,7 +51,7 @@ interface ShareProps { processing?: boolean; } | null; aiGenerationEnabled: boolean; - analyticsContext?: ShareAnalyticsContext; + analyticsContext: ShareAnalyticsContext; } const useVideoStatus = ( @@ -140,9 +142,15 @@ export const Share = ({ const effectiveDate: Date = data.metadata?.customCreatedAt ? new Date(data.metadata.customCreatedAt) : data.createdAt; + const publicEnv = usePublicEnv(); const playerRef = useRef(null); + const [videoElement, setVideoElement] = useState(null); const activityRef = useRef<{ scrollToBottom: () => void }>(null); + const handlePlayerRef = useCallback((node: HTMLVideoElement | null) => { + playerRef.current = node; + setVideoElement(node); + }, []); const initialComments: CommentType[] = comments instanceof Promise ? use(comments) : comments; const [commentsData, setCommentsData] = @@ -274,6 +282,13 @@ export const Share = ({ isDisabled("disableSummary") && isDisabled("disableTranscript"); + useShareAnalytics({ + videoId: data.id, + analyticsContext, + videoElement, + enabled: publicEnv.analyticsAvailable, + }); + return (
@@ -289,8 +304,7 @@ export const Share = ({ areReactionStampsDisabled={areReactionStampsDisabled} chapters={aiData?.chapters || []} aiProcessing={aiData?.processing || false} - analyticsContext={analyticsContext} - ref={playerRef} + ref={handlePlayerRef} />
diff --git a/apps/web/app/s/[videoId]/_components/ShareVideo.tsx b/apps/web/app/s/[videoId]/_components/ShareVideo.tsx index ed31ef3ad1..df37bd4661 100644 --- a/apps/web/app/s/[videoId]/_components/ShareVideo.tsx +++ b/apps/web/app/s/[videoId]/_components/ShareVideo.tsx @@ -1,544 +1,249 @@ import type { comments as commentsSchema } from "@cap/database/schema"; import { NODE_ENV } from "@cap/env"; import { Logo } from "@cap/ui"; -import type { ImageUpload, VideoAnalytics } from "@cap/web-domain"; +import type { ImageUpload } from "@cap/web-domain"; import { useTranscript } from "hooks/use-transcript"; import { forwardRef, - useCallback, useEffect, useImperativeHandle, - useMemo, useRef, useState, } from "react"; import { UpgradeModal } from "@/components/UpgradeModal"; -import { useEffectMutation, useRpcClient } from "@/lib/EffectRuntime"; -import type { ShareAnalyticsContext, VideoData } from "../types"; +import type { VideoData } from "../types"; import { CapVideoPlayer } from "./CapVideoPlayer"; import { HLSVideoPlayer } from "./HLSVideoPlayer"; import { - formatChaptersAsVTT, - formatTranscriptAsVTT, - parseVTT, - type TranscriptEntry, + formatChaptersAsVTT, + formatTranscriptAsVTT, + parseVTT, + type TranscriptEntry, } from "./utils/transcript-utils"; -const SHARE_WATCH_TIME_ENABLED = - process.env.NEXT_PUBLIC_SHARE_WATCH_TIME === "true"; - -declare global { - interface Window { - MSStream: unknown; - } -} - type CommentWithAuthor = typeof commentsSchema.$inferSelect & { authorName: string | null; authorImage: ImageUpload.ImageUrl | null; }; export const ShareVideo = forwardRef< - HTMLVideoElement, + HTMLVideoElement, { data: VideoData & { hasActiveUpload?: boolean; }; comments: MaybePromise; - chapters?: { title: string; start: number }[]; - areChaptersDisabled?: boolean; - areCaptionsDisabled?: boolean; - areCommentStampsDisabled?: boolean; - areReactionStampsDisabled?: boolean; + chapters?: { title: string; start: number }[]; + areChaptersDisabled?: boolean; + areCaptionsDisabled?: boolean; + areCommentStampsDisabled?: boolean; + areReactionStampsDisabled?: boolean; aiProcessing?: boolean; - analyticsContext?: ShareAnalyticsContext; } >( ( - { - data, - comments, - chapters = [], - areCaptionsDisabled, - areChaptersDisabled, - areCommentStampsDisabled, - areReactionStampsDisabled, - analyticsContext, - }, - ref, - ) => { - const videoRef = useRef(null); - useImperativeHandle(ref, () => videoRef.current as HTMLVideoElement, []); - - const [upgradeModalOpen, setUpgradeModalOpen] = useState(false); - const [transcriptData, setTranscriptData] = useState([]); - const [subtitleUrl, setSubtitleUrl] = useState(null); - const [chaptersUrl, setChaptersUrl] = useState(null); - const [commentsData, setCommentsData] = useState([]); - - const { data: transcriptContent, error: transcriptError } = useTranscript( - data.id, - data.transcriptionStatus, - ); - - const rpc = useRpcClient(); - const { mutate: captureEvent } = useEffectMutation({ - mutationFn: (event: VideoAnalytics.VideoCaptureEvent) => - rpc.VideosCaptureEvent(event), - }); - - const sessionId = useMemo(() => ensureShareSessionId(), []); - const userAgentDetails = useMemo( - () => - deriveUserAgentDetails( - analyticsContext?.userAgent ?? - (typeof navigator !== "undefined" - ? navigator.userAgent - : undefined), - ), - [analyticsContext?.userAgent], - ); - - const analyticsBase = useMemo( - () => ({ - video: data.id, - sessionId: sessionId ?? undefined, - city: analyticsContext?.city ?? undefined, - country: analyticsContext?.country ?? undefined, - referrer: analyticsContext?.referrer ?? undefined, - referrerUrl: analyticsContext?.referrerUrl ?? undefined, - utmSource: analyticsContext?.utmSource ?? undefined, - utmMedium: analyticsContext?.utmMedium ?? undefined, - utmCampaign: analyticsContext?.utmCampaign ?? undefined, - utmTerm: analyticsContext?.utmTerm ?? undefined, - utmContent: analyticsContext?.utmContent ?? undefined, - device: userAgentDetails.device ?? undefined, - browser: userAgentDetails.browser ?? undefined, - os: userAgentDetails.os ?? undefined, - }), - [ - analyticsContext?.city, - analyticsContext?.country, - analyticsContext?.referrer, - analyticsContext?.referrerUrl, - analyticsContext?.utmSource, - analyticsContext?.utmMedium, - analyticsContext?.utmCampaign, - analyticsContext?.utmTerm, - analyticsContext?.utmContent, - data.id, - sessionId, - userAgentDetails.browser, - userAgentDetails.device, - userAgentDetails.os, - ], - ); - - const watchTimeTrackingEnabled = - SHARE_WATCH_TIME_ENABLED && Boolean(sessionId) && Boolean(analyticsContext); - - const watchStateRef = useRef({ - startedAt: null as number | null, - accumulatedMs: 0, - }); - const hasFlushedRef = useRef(false); - const hasInitializedRef = useRef(false); - const didStrictCleanupRef = useRef(false); - - const startWatchTimer = useCallback(() => { - if (!watchTimeTrackingEnabled) return; - if (watchStateRef.current.startedAt !== null) return; - watchStateRef.current.startedAt = nowMs(); - }, [watchTimeTrackingEnabled]); - - const stopWatchTimer = useCallback(() => { - if (!watchTimeTrackingEnabled) return; - if (watchStateRef.current.startedAt === null) return; - watchStateRef.current.accumulatedMs += - nowMs() - watchStateRef.current.startedAt; - watchStateRef.current.startedAt = null; - }, [watchTimeTrackingEnabled]); - - const readWatchTimeSeconds = useCallback(() => { - if (!watchTimeTrackingEnabled) return 0; - stopWatchTimer(); - return watchStateRef.current.accumulatedMs / 1000; - }, [stopWatchTimer, watchTimeTrackingEnabled]); - - const flushAnalyticsEvent = useCallback( - (_reason?: string) => { - if (!watchTimeTrackingEnabled) return; - if (hasFlushedRef.current) return; - const watchTimeSeconds = Number( - Math.max(0, readWatchTimeSeconds()).toFixed(2), - ); - const payload: VideoAnalytics.VideoCaptureEvent = { - ...analyticsBase, - watchTimeSeconds, - }; - captureEvent(payload); - hasFlushedRef.current = true; - }, - [analyticsBase, captureEvent, readWatchTimeSeconds, watchTimeTrackingEnabled], - ); - - useEffect(() => { - if (!watchTimeTrackingEnabled) return; - if (hasInitializedRef.current) { - flushAnalyticsEvent(`video-change-${data.id}`); - } else { - hasInitializedRef.current = true; - } - watchStateRef.current = { startedAt: null, accumulatedMs: 0 }; - hasFlushedRef.current = false; - }, [data.id, flushAnalyticsEvent, watchTimeTrackingEnabled]); - - useEffect( - () => () => { - if (!watchTimeTrackingEnabled) return; - if (didStrictCleanupRef.current) { - flushAnalyticsEvent("unmount"); - } else { - didStrictCleanupRef.current = true; - } - }, - [flushAnalyticsEvent, watchTimeTrackingEnabled], - ); - - useEffect(() => { - if (!watchTimeTrackingEnabled) return; - if (typeof window === "undefined") return; - let rafId: number | null = null; - let cleanup: (() => void) | undefined; - - const attach = () => { - const video = videoRef.current; - if (!video) return false; - - const handlePlay = () => startWatchTimer(); - const handlePause = () => stopWatchTimer(); - const handleEnded = () => { - stopWatchTimer(); - flushAnalyticsEvent("ended"); - }; - - video.addEventListener("play", handlePlay); - video.addEventListener("playing", handlePlay); - video.addEventListener("pause", handlePause); - video.addEventListener("waiting", handlePause); - video.addEventListener("seeking", handlePause); - video.addEventListener("ended", handleEnded); - - cleanup = () => { - video.removeEventListener("play", handlePlay); - video.removeEventListener("playing", handlePlay); - video.removeEventListener("pause", handlePause); - video.removeEventListener("waiting", handlePause); - video.removeEventListener("seeking", handlePause); - video.removeEventListener("ended", handleEnded); - }; - - return true; - }; - - if (!attach()) { - const check = () => { - if (attach()) return; - rafId = window.requestAnimationFrame(check); - }; - rafId = window.requestAnimationFrame(check); - } - - return () => { - cleanup?.(); - if (rafId) window.cancelAnimationFrame(rafId); - }; - }, [ - flushAnalyticsEvent, - startWatchTimer, - stopWatchTimer, - watchTimeTrackingEnabled, - ]); - - useEffect(() => { - if (!watchTimeTrackingEnabled) return; - if (typeof window === "undefined") return; - const handleVisibility = () => { - if (document.visibilityState === "hidden") { - flushAnalyticsEvent("visibility"); - } - }; - const handlePageHide = () => flushAnalyticsEvent("pagehide"); - const handleBeforeUnload = () => flushAnalyticsEvent("beforeunload"); - - document.addEventListener("visibilitychange", handleVisibility); - window.addEventListener("pagehide", handlePageHide); - window.addEventListener("beforeunload", handleBeforeUnload); - - return () => { - document.removeEventListener("visibilitychange", handleVisibility); - window.removeEventListener("pagehide", handlePageHide); - window.removeEventListener("beforeunload", handleBeforeUnload); - }; - }, [flushAnalyticsEvent, watchTimeTrackingEnabled]); - - useEffect( - () => () => { - if (!watchTimeTrackingEnabled) return; - flushAnalyticsEvent("unmount"); - }, - [flushAnalyticsEvent, watchTimeTrackingEnabled], - ); - - // Handle comments data - useEffect(() => { - if (comments) { - if (Array.isArray(comments)) { - setCommentsData(comments); - } else { - comments.then(setCommentsData); - } - } - }, [comments]); - - // Handle seek functionality - const handleSeek = (time: number) => { - if (videoRef.current) { - videoRef.current.currentTime = time; - } - }; - - useEffect(() => { - if (transcriptContent) { - const parsed = parseVTT(transcriptContent); - setTranscriptData(parsed); - } else if (transcriptError) { - console.error( - "[Transcript] Transcript error from React Query:", - transcriptError.message, - ); - } - }, [transcriptContent, transcriptError]); - - // Handle subtitle URL creation - useEffect(() => { - if ( - data.transcriptionStatus === "COMPLETE" && - transcriptData && - transcriptData.length > 0 - ) { - const vttContent = formatTranscriptAsVTT(transcriptData); - const blob = new Blob([vttContent], { type: "text/vtt" }); - const newUrl = URL.createObjectURL(blob); - - // Clean up previous URL - if (subtitleUrl) { - URL.revokeObjectURL(subtitleUrl); - } - - setSubtitleUrl(newUrl); - - return () => { - URL.revokeObjectURL(newUrl); - }; - } else { - // Clean up if no longer needed - if (subtitleUrl) { - URL.revokeObjectURL(subtitleUrl); - setSubtitleUrl(null); - } - } - }, [data.transcriptionStatus, transcriptData, subtitleUrl]); - - // Handle chapters URL creation - useEffect(() => { - if (chapters?.length > 0) { - const vttContent = formatChaptersAsVTT(chapters); - const blob = new Blob([vttContent], { type: "text/vtt" }); - const newUrl = URL.createObjectURL(blob); - - // Clean up previous URL - if (chaptersUrl) { - URL.revokeObjectURL(chaptersUrl); - } - - setChaptersUrl(newUrl); - - return () => { - URL.revokeObjectURL(newUrl); - }; - } else { - // Clean up if no longer needed - if (chaptersUrl) { - URL.revokeObjectURL(chaptersUrl); - setChaptersUrl(null); - } - } - }, [chapters, chaptersUrl]); - - const isMp4Source = - data.source.type === "desktopMP4" || data.source.type === "webMP4"; - let videoSrc: string; - let enableCrossOrigin = false; - - if (isMp4Source) { - videoSrc = `/api/playlist?userId=${data.owner.id}&videoId=${data.id}&videoType=mp4`; - // Start with CORS enabled for MP4 sources, CapVideoPlayer will disable if needed - enableCrossOrigin = true; - } else if ( - NODE_ENV === "development" || - ((data.skipProcessing === true || data.jobStatus !== "COMPLETE") && - data.source.type === "MediaConvert") - ) { - videoSrc = `/api/playlist?userId=${data.owner.id}&videoId=${data.id}&videoType=master`; - } else if (data.source.type === "MediaConvert") { - videoSrc = `/api/playlist?userId=${data.owner.id}&videoId=${data.id}&videoType=video`; - } else { - videoSrc = `/api/playlist?userId=${data.owner.id}&videoId=${data.id}&videoType=video`; - } - - return ( - <> -
- {isMp4Source ? ( - ({ - id: comment.id, - type: comment.type, - timestamp: comment.timestamp, - content: comment.content, - authorName: comment.authorName, - authorImage: comment.authorImage ?? undefined, - }))} - onSeek={handleSeek} - /> - ) : ( - - )} -
- - {!data.owner.isPro && ( -
- -
- )} - - - ); + { + data, + comments, + chapters = [], + areCaptionsDisabled, + areChaptersDisabled, + areCommentStampsDisabled, + areReactionStampsDisabled, }, + ref +) => { + const videoRef = useRef(null); + useImperativeHandle(ref, () => videoRef.current as HTMLVideoElement, []); + + const [upgradeModalOpen, setUpgradeModalOpen] = useState(false); + const [transcriptData, setTranscriptData] = useState([]); + const [subtitleUrl, setSubtitleUrl] = useState(null); + const [chaptersUrl, setChaptersUrl] = useState(null); + const [commentsData, setCommentsData] = useState([]); + + const { data: transcriptContent, error: transcriptError } = useTranscript( + data.id, + data.transcriptionStatus + ); + + // Handle comments data + useEffect(() => { + if (comments) { + if (Array.isArray(comments)) { + setCommentsData(comments); + } else { + comments.then(setCommentsData); + } + } + }, [comments]); + + // Handle seek functionality + const handleSeek = (time: number) => { + if (videoRef.current) { + videoRef.current.currentTime = time; + } + }; + + useEffect(() => { + if (transcriptContent) { + const parsed = parseVTT(transcriptContent); + setTranscriptData(parsed); + } else if (transcriptError) { + console.error( + "[Transcript] Transcript error from React Query:", + transcriptError.message + ); + } + }, [transcriptContent, transcriptError]); + + // Handle subtitle URL creation + useEffect(() => { + if ( + data.transcriptionStatus === "COMPLETE" && + transcriptData && + transcriptData.length > 0 + ) { + const vttContent = formatTranscriptAsVTT(transcriptData); + const blob = new Blob([vttContent], { type: "text/vtt" }); + const newUrl = URL.createObjectURL(blob); + + // Clean up previous URL + if (subtitleUrl) { + URL.revokeObjectURL(subtitleUrl); + } + + setSubtitleUrl(newUrl); + + return () => { + URL.revokeObjectURL(newUrl); + }; + } else { + // Clean up if no longer needed + if (subtitleUrl) { + URL.revokeObjectURL(subtitleUrl); + setSubtitleUrl(null); + } + } + }, [data.transcriptionStatus, transcriptData, subtitleUrl]); + + // Handle chapters URL creation + useEffect(() => { + if (chapters?.length > 0) { + const vttContent = formatChaptersAsVTT(chapters); + const blob = new Blob([vttContent], { type: "text/vtt" }); + const newUrl = URL.createObjectURL(blob); + + // Clean up previous URL + if (chaptersUrl) { + URL.revokeObjectURL(chaptersUrl); + } + + setChaptersUrl(newUrl); + + return () => { + URL.revokeObjectURL(newUrl); + }; + } else { + // Clean up if no longer needed + if (chaptersUrl) { + URL.revokeObjectURL(chaptersUrl); + setChaptersUrl(null); + } + } + }, [chapters, chaptersUrl]); + + const isMp4Source = + data.source.type === "desktopMP4" || data.source.type === "webMP4"; + let videoSrc: string; + let enableCrossOrigin = false; + + if (isMp4Source) { + videoSrc = `/api/playlist?userId=${data.owner.id}&videoId=${data.id}&videoType=mp4`; + // Start with CORS enabled for MP4 sources, CapVideoPlayer will disable if needed + enableCrossOrigin = true; + } else if ( + NODE_ENV === "development" || + ((data.skipProcessing === true || data.jobStatus !== "COMPLETE") && + data.source.type === "MediaConvert") + ) { + videoSrc = `/api/playlist?userId=${data.owner.id}&videoId=${data.id}&videoType=master`; + } else if (data.source.type === "MediaConvert") { + videoSrc = `/api/playlist?userId=${data.owner.id}&videoId=${data.id}&videoType=video`; + } else { + videoSrc = `/api/playlist?userId=${data.owner.id}&videoId=${data.id}&videoType=video`; + } + + return ( + <> +
+ {isMp4Source ? ( + ({ + id: comment.id, + type: comment.type, + timestamp: comment.timestamp, + content: comment.content, + authorName: comment.authorName, + authorImage: comment.authorImage ?? undefined, + }))} + onSeek={handleSeek} + /> + ) : ( + + )} +
+ + {!data.owner.isPro && ( +
+ +
+ )} + + + ); + } ); - -const SHARE_SESSION_STORAGE_KEY = "cap_share_view_session"; - -const ensureShareSessionId = () => { - if (typeof window === "undefined") return undefined; - try { - const existing = window.sessionStorage.getItem(SHARE_SESSION_STORAGE_KEY); - if (existing) return existing; - const newId = - typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" - ? crypto.randomUUID() - : Math.random().toString(36).slice(2); - window.sessionStorage.setItem(SHARE_SESSION_STORAGE_KEY, newId); - return newId; - } catch { - return undefined; - } -}; - -const nowMs = () => - typeof performance !== "undefined" && typeof performance.now === "function" - ? performance.now() - : Date.now(); - -type UserAgentDetails = { - device?: string; - browser?: string; - os?: string; -}; - -const deriveUserAgentDetails = (ua?: string): UserAgentDetails => { - if (!ua) return {}; - const value = ua.toLowerCase(); - const details: UserAgentDetails = {}; - - if ( - /ipad|tablet/.test(value) || - (/android/.test(value) && !/mobile/.test(value)) - ) { - details.device = "tablet"; - } else if (/mobi|iphone|ipod|android/.test(value)) { - details.device = "mobile"; - } else { - details.device = "desktop"; - } - - if (/edg\//.test(value)) details.browser = "Edge"; - else if ( - /chrome|crios|crmo/.test(value) && - !/opr\//.test(value) && - !/edg\//.test(value) - ) - details.browser = "Chrome"; - else if (/safari/.test(value) && !/chrome|crios|android/.test(value)) - details.browser = "Safari"; - else if (/firefox|fxios/.test(value)) details.browser = "Firefox"; - else if (/opr\//.test(value) || /opera/.test(value)) - details.browser = "Opera"; - else if (/msie|trident/.test(value)) details.browser = "IE"; - else details.browser = "Other"; - - if (/windows nt/.test(value)) details.os = "Windows"; - else if (/mac os x/.test(value) && !/iphone|ipad|ipod/.test(value)) - details.os = "macOS"; - else if (/iphone|ipad|ipod/.test(value)) details.os = "iOS"; - else if (/android/.test(value)) details.os = "Android"; - else if (/cros/.test(value)) details.os = "ChromeOS"; - else if (/linux/.test(value)) details.os = "Linux"; - else details.os = "Other"; - - return details; -}; diff --git a/apps/web/app/s/[videoId]/page.tsx b/apps/web/app/s/[videoId]/page.tsx index 00eb1f12e0..c39961054f 100644 --- a/apps/web/app/s/[videoId]/page.tsx +++ b/apps/web/app/s/[videoId]/page.tsx @@ -368,6 +368,8 @@ async function AuthorizedContent({ const referrerUrl = headerList.get("x-referrer") ?? headerList.get("referer") ?? null; + const userAgent = headerList.get("x-user-agent") ?? headerList.get("user-agent"); + const userAgentDetails = deriveUserAgentDetails(userAgent); const analyticsContext: ShareAnalyticsContext = { city: headerList.get("x-vercel-ip-city"), country: headerList.get("x-vercel-ip-country"), @@ -385,7 +387,10 @@ async function AuthorizedContent({ utmCampaign: getSearchParam("utm_campaign"), utmTerm: getSearchParam("utm_term"), utmContent: getSearchParam("utm_content"), - userAgent: headerList.get("x-user-agent") ?? headerList.get("user-agent"), + userAgent, + device: userAgentDetails.device ?? null, + browser: userAgentDetails.browser ?? null, + os: userAgentDetails.os ?? null, }; if (user && video && user.id !== video.owner.id) { @@ -794,3 +799,52 @@ async function AuthorizedContent({ ); } + +type UserAgentDetails = { + device?: string; + browser?: string; + os?: string; +}; + +const deriveUserAgentDetails = (ua?: string | null): UserAgentDetails => { + if (!ua) return {}; + const value = ua.toLowerCase(); + const details: UserAgentDetails = {}; + + if ( + /ipad|tablet/.test(value) || + (/android/.test(value) && !/mobile/.test(value)) + ) { + details.device = "tablet"; + } else if (/mobi|iphone|ipod|android/.test(value)) { + details.device = "mobile"; + } else if (/mac|win|linux|cros|x11/.test(value)) { + details.device = "desktop"; + } + + if (/chrome|crios/.test(value) && !/edge|edg\//.test(value)) { + details.browser = "chrome"; + } else if (/safari/.test(value) && !/chrome|crios/.test(value)) { + details.browser = "safari"; + } else if (/firefox/.test(value)) { + details.browser = "firefox"; + } else if (/edg\//.test(value)) { + details.browser = "edge"; + } else if (/msie|trident/.test(value)) { + details.browser = "ie"; + } + + if (/windows nt/.test(value)) { + details.os = "windows"; + } else if (/mac os x/.test(value)) { + details.os = "mac"; + } else if (/android/.test(value)) { + details.os = "android"; + } else if (/iphone|ipad|ipod/.test(value)) { + details.os = "ios"; + } else if (/linux/.test(value)) { + details.os = "linux"; + } + + return details; +}; diff --git a/apps/web/app/s/[videoId]/types.ts b/apps/web/app/s/[videoId]/types.ts index de9cd9abb1..81c5ab0860 100644 --- a/apps/web/app/s/[videoId]/types.ts +++ b/apps/web/app/s/[videoId]/types.ts @@ -29,4 +29,7 @@ export type ShareAnalyticsContext = { utmTerm?: string | null; utmContent?: string | null; userAgent?: string | null; + device?: string | null; + browser?: string | null; + os?: string | null; }; diff --git a/apps/web/app/s/[videoId]/useShareAnalytics.ts b/apps/web/app/s/[videoId]/useShareAnalytics.ts new file mode 100644 index 0000000000..bcb1acf271 --- /dev/null +++ b/apps/web/app/s/[videoId]/useShareAnalytics.ts @@ -0,0 +1,273 @@ +"use client"; + +import type * as WebDomain from "@cap/web-domain"; +import type { Schema } from "effect/Schema"; +import { useCallback, useEffect, useRef } from "react"; +import type { ShareAnalyticsContext } from "./types"; + +type CapturePayload = Schema.Type< + typeof WebDomain.VideoAnalytics.VideoCaptureEvent +>; + + +type ClientAnalyticsExtras = { + locale?: string; + language?: string; + timezone?: string; + pathname?: string; + href?: string; + referrer?: string; + userAgent?: string; +}; + +type Options = { + videoId: WebDomain.Video.VideoId; + analyticsContext: ShareAnalyticsContext; + videoElement: HTMLVideoElement | null; + enabled: boolean; +}; + +const ANALYTICS_ENDPOINT = "/api/video/analytics"; +const SESSION_STORAGE_KEY = "cap.analytics.session"; +const SESSION_TTL_MS = 30 * 60 * 1000; + +const randomId = () => + typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : Math.random().toString(36).slice(2); + +export function useShareAnalytics({ + videoId, + analyticsContext, + videoElement, + enabled, +}: Options) { + const analyticsRef = useRef(analyticsContext); + const videoIdRef = useRef(videoId); + const fallbackSessionRef = useRef(null); + const sessionIdRef = useRef(null); + const clientDataRef = useRef(null); + const sentRef = useRef(false); + const watchStartRef = useRef(null); + const watchedMsRef = useRef(0); + + analyticsRef.current = analyticsContext; + videoIdRef.current = videoId; + + const accumulateWatchTime = useCallback(() => { + if (watchStartRef.current === null) return; + watchedMsRef.current += performance.now() - watchStartRef.current; + watchStartRef.current = null; + }, []); + + const ensureSessionId = useCallback(() => { + if (typeof window === "undefined") { + if (!fallbackSessionRef.current) + fallbackSessionRef.current = randomId(); + return fallbackSessionRef.current; + } + + try { + const storedValue = window.localStorage.getItem(SESSION_STORAGE_KEY); + const now = Date.now(); + if (storedValue) { + const parsed = JSON.parse(storedValue) as { value?: string; expiry?: number }; + if ( + parsed?.value && + typeof parsed.value === "string" && + typeof parsed.expiry === "number" && + parsed.expiry > now + ) { + const refreshed = JSON.stringify({ + value: parsed.value, + expiry: now + SESSION_TTL_MS, + }); + window.localStorage.setItem(SESSION_STORAGE_KEY, refreshed); + return parsed.value; + } + } + + const nextSessionId = + window.crypto?.randomUUID?.() ?? randomId(); + const serialized = JSON.stringify({ + value: nextSessionId, + expiry: now + SESSION_TTL_MS, + }); + window.localStorage.setItem(SESSION_STORAGE_KEY, serialized); + return nextSessionId; + } catch { + if (!fallbackSessionRef.current) fallbackSessionRef.current = randomId(); + return fallbackSessionRef.current; + } + }, []); + + const getClientAnalytics = useCallback((): ClientAnalyticsExtras => { + if (clientDataRef.current) return clientDataRef.current; + if (typeof window === "undefined") return {}; + + const data: ClientAnalyticsExtras = { + locale: + (window.navigator.languages?.[0]) || + window.navigator.language, + language: window.navigator.language, + timezone: (() => { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone; + } catch { + return undefined; + } + })(), + pathname: window.location.pathname, + href: window.location.href, + referrer: document.referrer || undefined, + userAgent: window.navigator.userAgent, + }; + + clientDataRef.current = data; + return data; + }, []); + + const buildPayload = useCallback( + (watchTimeSeconds: number): CapturePayload => { + const analytics = analyticsRef.current; + const client = getClientAnalytics(); + const normalizedWatch = Math.max(0, Math.trunc(watchTimeSeconds)); + const sessionId = sessionIdRef.current ?? ensureSessionId(); + const normalize = (value?: string | null) => + value && value.trim().length > 0 ? value : undefined; + + const city = normalize(analytics.city); + const country = normalize(analytics.country); + const device = normalize(analytics.device); + const browser = normalize(analytics.browser); + const os = normalize(analytics.os); + + const clientReferrer = normalize(client.referrer); + const referrer = normalize(analytics.referrer) ?? clientReferrer; + const referrerUrl = normalize(analytics.referrerUrl) ?? clientReferrer; + const utmSource = normalize(analytics.utmSource); + const utmMedium = normalize(analytics.utmMedium); + const utmCampaign = normalize(analytics.utmCampaign); + const utmTerm = normalize(analytics.utmTerm); + const utmContent = normalize(analytics.utmContent); + const userAgent = normalize(analytics.userAgent) ?? normalize(client.userAgent); + const locale = normalize(client.locale); + const language = normalize(client.language); + const timezone = normalize(client.timezone); + const pathname = normalize(client.pathname); + const href = normalize(client.href); + + return { + video: videoIdRef.current, + watchTimeSeconds: normalizedWatch > 0 ? normalizedWatch : 0, + ...(sessionId ? { sessionId } : {}), + ...(city ? { city } : {}), + ...(country ? { country } : {}), + ...(device ? { device } : {}), + ...(browser ? { browser } : {}), + ...(os ? { os } : {}), + ...(referrer ? { referrer } : {}), + ...(referrerUrl ? { referrerUrl } : {}), + ...(utmSource ? { utmSource } : {}), + ...(utmMedium ? { utmMedium } : {}), + ...(utmCampaign ? { utmCampaign } : {}), + ...(utmTerm ? { utmTerm } : {}), + ...(utmContent ? { utmContent } : {}), + ...(userAgent ? { userAgent } : {}), + ...(locale ? { locale } : {}), + ...(language ? { language } : {}), + ...(timezone ? { timezone } : {}), + ...(pathname ? { pathname } : {}), + ...(href ? { href } : {}), + } satisfies CapturePayload; + }, + [getClientAnalytics, ensureSessionId], + ); + + const sendPayload = useCallback( + (reason: string) => { + if (!enabled || sentRef.current) return; + + accumulateWatchTime(); + const watchSeconds = watchedMsRef.current / 1000; + const payload = buildPayload(watchSeconds); + sentRef.current = true; + const body = JSON.stringify(payload); + + const sendWithBeacon = () => { + if (typeof navigator === "undefined" || !navigator.sendBeacon) return false; + try { + const blob = new Blob([body], { type: "application/json" }); + return navigator.sendBeacon(ANALYTICS_ENDPOINT, blob); + } catch { + return false; + } + }; + + const fallbackFetch = () => + fetch(ANALYTICS_ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + keepalive: reason !== "manual", + }).catch(() => undefined); + + if (!sendWithBeacon()) void fallbackFetch(); + }, [accumulateWatchTime, buildPayload, enabled]); + + useEffect(() => { + if (!enabled) return; + sessionIdRef.current = ensureSessionId(); + }, [enabled, ensureSessionId]); + + useEffect(() => { + if (!enabled || !videoElement) return; + + const handlePlay = () => { + if (document.hidden) return; + if (watchStartRef.current === null) watchStartRef.current = performance.now(); + }; + const handlePauseLike = () => accumulateWatchTime(); + const handleVisibility = () => { + if (document.visibilityState === "hidden") { + accumulateWatchTime(); + } else if (!videoElement.paused) { + handlePlay(); + } + }; + + videoElement.addEventListener("playing", handlePlay); + videoElement.addEventListener("pause", handlePauseLike); + videoElement.addEventListener("ended", handlePauseLike); + videoElement.addEventListener("waiting", handlePauseLike); + videoElement.addEventListener("seeking", handlePauseLike); + document.addEventListener("visibilitychange", handleVisibility); + + if (!videoElement.paused && !document.hidden) handlePlay(); + + return () => { + accumulateWatchTime(); + videoElement.removeEventListener("playing", handlePlay); + videoElement.removeEventListener("pause", handlePauseLike); + videoElement.removeEventListener("ended", handlePauseLike); + videoElement.removeEventListener("waiting", handlePauseLike); + videoElement.removeEventListener("seeking", handlePauseLike); + document.removeEventListener("visibilitychange", handleVisibility); + }; + }, [accumulateWatchTime, enabled, videoElement]); + + useEffect(() => { + if (!enabled) return; + + const handlePageHide = () => sendPayload("pagehide"); + + window.addEventListener("pagehide", handlePageHide); + window.addEventListener("beforeunload", handlePageHide); + + return () => { + window.removeEventListener("pagehide", handlePageHide); + window.removeEventListener("beforeunload", handlePageHide); + sendPayload("manual"); + }; + }, [enabled, sendPayload]); +} diff --git a/packages/web-backend/src/Videos/index.ts b/packages/web-backend/src/Videos/index.ts index 6f4143ac3d..7ecd0d8df7 100644 --- a/packages/web-backend/src/Videos/index.ts +++ b/packages/web-backend/src/Videos/index.ts @@ -1,7 +1,7 @@ import * as Db from "@cap/database/schema"; -import { serverEnv } from "@cap/env"; +import { buildEnv, NODE_ENV, serverEnv } from "@cap/env"; import { dub } from "@cap/utils"; -import { CurrentUser, Policy, Video } from "@cap/web-domain"; +import { CurrentUser, Folder, Policy, Video } from "@cap/web-domain"; import { FetchHttpClient, HttpBody, HttpClient } from "@effect/platform"; import * as Dz from "drizzle-orm"; import { Array, Effect, Option, pipe } from "effect"; diff --git a/packages/web-backend/src/VideosAnalytics/VideosAnalyticsRpcs.ts b/packages/web-backend/src/VideosAnalytics/VideosAnalyticsRpcs.ts index 9bb976948d..491c6c15a8 100644 --- a/packages/web-backend/src/VideosAnalytics/VideosAnalyticsRpcs.ts +++ b/packages/web-backend/src/VideosAnalytics/VideosAnalyticsRpcs.ts @@ -56,6 +56,7 @@ export const VideosAnalyticsRpcsLive = provideOptionalAuth, Effect.catchTags({ DatabaseError: () => new InternalError({ type: "database" }), + HttpBodyError: () => new InternalError({ type: "httpRequest" }), RequestError: () => new InternalError({ type: "httpRequest" }), ResponseError: () => new InternalError({ type: "httpResponse" }), UnknownException: () => new InternalError({ type: "unknown" }), diff --git a/packages/web-backend/src/VideosAnalytics/index.ts b/packages/web-backend/src/VideosAnalytics/index.ts index 108903f14d..a07a9394ec 100644 --- a/packages/web-backend/src/VideosAnalytics/index.ts +++ b/packages/web-backend/src/VideosAnalytics/index.ts @@ -89,43 +89,60 @@ export class VideosAnalytics extends Effect.Service()( const host = serverEnv().TINYBIRD_HOST; if (!token || !host) return; + const toNullableString = (value?: string | null) => + value && value.trim().length > 0 ? value : null; + const toNullableNumber = (value?: number | null) => + typeof value === "number" && Number.isFinite(value) ? value : null; + const toNullableInt = (value?: number | null) => + typeof value === "number" && Number.isFinite(value) + ? Math.trunc(value) + : null; + + const watchTime = toNullableNumber(event.watchTimeSeconds) ?? 0; + + const serializedPayload = JSON.stringify({ + city: event.city ?? null, + country: event.country ?? null, + device: event.device ?? null, + browser: event.browser ?? null, + os: event.os ?? null, + referrer: toNullableString(event.referrer), + referrerUrl: toNullableString(event.referrerUrl), + utmSource: toNullableString(event.utmSource), + utmMedium: toNullableString(event.utmMedium), + utmCampaign: toNullableString(event.utmCampaign), + utmTerm: toNullableString(event.utmTerm), + utmContent: toNullableString(event.utmContent), + locale: toNullableString(event.locale), + language: toNullableString(event.language), + timezone: toNullableString(event.timezone), + pathname: toNullableString(event.pathname), + href: toNullableString(event.href), + userAgent: toNullableString(event.userAgent), + watchTimeSeconds: watchTime, + }); + const payload = { timestamp: new Date().toISOString(), version: "1", - session_id: event.sessionId ?? null, + session_id: toNullableString(event.sessionId), video_id: videoId, - watch_time_seconds: event.watchTimeSeconds ?? 0, + watch_time_seconds: watchTime, city: event.city ?? null, country: event.country ?? null, device: event.device ?? null, browser: event.browser ?? null, os: event.os ?? null, - referrer: event.referrer ?? null, - referrer_url: event.referrerUrl ?? null, - utm_source: event.utmSource ?? null, - utm_medium: event.utmMedium ?? null, - utm_campaign: event.utmCampaign ?? null, - utm_term: event.utmTerm ?? null, - utm_content: event.utmContent ?? null, - payload: JSON.stringify({ - watchTimeSeconds: event.watchTimeSeconds ?? 0, - city: event.city ?? null, - country: event.country ?? null, - device: event.device ?? null, - browser: event.browser ?? null, - os: event.os ?? null, - referrer: event.referrer ?? null, - referrerUrl: event.referrerUrl ?? null, - utmSource: event.utmSource ?? null, - utmMedium: event.utmMedium ?? null, - utmCampaign: event.utmCampaign ?? null, - utmTerm: event.utmTerm ?? null, - utmContent: event.utmContent ?? null, - }), + referrer: toNullableString(event.referrer), + referrer_url: toNullableString(event.referrerUrl), + utm_source: toNullableString(event.utmSource), + utm_medium: toNullableString(event.utmMedium), + utm_campaign: toNullableString(event.utmCampaign), + utm_term: toNullableString(event.utmTerm), + utm_content: toNullableString(event.utmContent), + payload: serializedPayload, }; - console.log("TINYBIRD EVENT", payload); - yield* client .post(`${host}/v0/events?name=analytics_views`, { body: yield* HttpBody.json(payload), diff --git a/packages/web-domain/src/VideoAnalytics.ts b/packages/web-domain/src/VideoAnalytics.ts index a3e16a369d..5f56f88044 100644 --- a/packages/web-domain/src/VideoAnalytics.ts +++ b/packages/web-domain/src/VideoAnalytics.ts @@ -16,7 +16,6 @@ export class VideoCaptureEvent extends Schema.Class( )({ video: VideoId, sessionId: Schema.optional(Schema.String), - watchTimeSeconds: Schema.optional(Schema.Number), city: Schema.optional(Schema.String), country: Schema.optional(Schema.String), device: Schema.optional(Schema.String), @@ -29,6 +28,13 @@ export class VideoCaptureEvent extends Schema.Class( utmCampaign: Schema.optional(Schema.String), utmTerm: Schema.optional(Schema.String), utmContent: Schema.optional(Schema.String), + watchTimeSeconds: Schema.optional(Schema.Int), + locale: Schema.optional(Schema.String), + language: Schema.optional(Schema.String), + timezone: Schema.optional(Schema.String), + pathname: Schema.optional(Schema.String), + href: Schema.optional(Schema.String), + userAgent: Schema.optional(Schema.String), }) {} export class VideoAnalyticsRpcs extends RpcGroup.make( From 9f08ba467dc6c134df9a19cf838d36b4057fbb4f Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 11 Nov 2025 17:29:54 +0000 Subject: [PATCH 20/22] Enhance Tinybird CLI install security in workflow --- .github/workflows/tinybird-cd.yml | 32 ++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tinybird-cd.yml b/.github/workflows/tinybird-cd.yml index eddb97e87f..e0bc01f0d2 100644 --- a/.github/workflows/tinybird-cd.yml +++ b/.github/workflows/tinybird-cd.yml @@ -17,6 +17,11 @@ permissions: env: TINYBIRD_HOST: ${{ secrets.TINYBIRD_HOST }} TINYBIRD_TOKEN: ${{ secrets.TINYBIRD_TOKEN }} + # URL must point at a pinned Tinybird CLI installer or binary published by Tinybird + # (e.g., a GitHub Release asset). The checksum must be computed from that asset and + # stored in secrets so we can detect tampering before executing the installer. + TINYBIRD_INSTALLER_URL: ${{ vars.TINYBIRD_INSTALLER_URL }} + TINYBIRD_INSTALLER_SHA256: ${{ secrets.TINYBIRD_INSTALLER_SHA256 }} jobs: cd: @@ -29,7 +34,32 @@ jobs: - uses: actions/checkout@v3 - name: Install Tinybird CLI - run: curl https://tinybird.co | sh + env: + INSTALLER_URL: ${{ env.TINYBIRD_INSTALLER_URL }} + INSTALLER_SHA256: ${{ env.TINYBIRD_INSTALLER_SHA256 }} + run: | + set -euo pipefail + + if [[ -z "${INSTALLER_URL:-}" ]]; then + echo "TINYBIRD_INSTALLER_URL repo variable is not set. Point it to the" >&2 + echo "pinned Tinybird CLI release asset (e.g., https://github.com/tinybirdco/... )." >&2 + exit 1 + fi + + if [[ -z "${INSTALLER_SHA256:-}" ]]; then + echo "TINYBIRD_INSTALLER_SHA256 secret is not defined." >&2 + echo "Store the SHA-256 computed from the trusted release asset so tampering" >&2 + echo "causes the workflow to fail before execution." >&2 + exit 1 + fi + + installer_path="/tmp/tinybird-cli-installer" + curl -fSL "$INSTALLER_URL" -o "$installer_path" + + echo "${INSTALLER_SHA256} ${installer_path}" | sha256sum -c - + + chmod +x "$installer_path" + "$installer_path" - name: Deploy project run: tb --cloud --host ${{ env.TINYBIRD_HOST }} --token ${{ env.TINYBIRD_TOKEN }} deploy From eb58693a2e3e3936e102ed6fb9be0f0ac8a3a71a Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 11 Nov 2025 17:31:25 +0000 Subject: [PATCH 21/22] Add health check wait step for Tinybird Local --- .github/workflows/tinybird-ci.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/.github/workflows/tinybird-ci.yml b/.github/workflows/tinybird-ci.yml index 397b68dadc..2bd1dbd775 100644 --- a/.github/workflows/tinybird-ci.yml +++ b/.github/workflows/tinybird-ci.yml @@ -40,6 +40,31 @@ jobs: - name: Build project run: tb build + - name: Wait for Tinybird Local + run: | + set -euo pipefail + + endpoints=("http://localhost:7181/_health" "http://localhost:7181/health") + max_attempts=20 + base_delay=3 + + for ((attempt = 1; attempt <= max_attempts; attempt++)); do + for endpoint in "${endpoints[@]}"; do + status=$(curl -fsS -o /dev/null -w "%{http_code}" "$endpoint" || true) + if [[ "$status" == "200" ]]; then + echo "Tinybird local is healthy via $endpoint" + exit 0 + fi + done + + sleep_seconds=$((base_delay * attempt)) + echo "Tinybird local not ready (attempt ${attempt}/${max_attempts}). Retrying in ${sleep_seconds}s..." + sleep "$sleep_seconds" + done + + echo "Tinybird local failed to become healthy after waiting ~$((base_delay * max_attempts * (max_attempts + 1) / 2))s" >&2 + exit 1 + - name: Test project run: tb test run From cad1309c2e72e03a33a74dc8178b89770ba329de Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 11 Nov 2025 17:32:30 +0000 Subject: [PATCH 22/22] Enhance Tinybird CLI installation security in CI --- .github/workflows/tinybird-ci.yml | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tinybird-ci.yml b/.github/workflows/tinybird-ci.yml index 2bd1dbd775..2739cc27f2 100644 --- a/.github/workflows/tinybird-ci.yml +++ b/.github/workflows/tinybird-ci.yml @@ -18,6 +18,8 @@ permissions: env: TINYBIRD_HOST: ${{ secrets.TINYBIRD_HOST }} TINYBIRD_TOKEN: ${{ secrets.TINYBIRD_TOKEN }} + TINYBIRD_INSTALLER_URL: ${{ vars.TINYBIRD_INSTALLER_URL }} + TINYBIRD_INSTALLER_SHA256: ${{ secrets.TINYBIRD_INSTALLER_SHA256 }} jobs: ci: @@ -35,7 +37,32 @@ jobs: - uses: actions/checkout@v3 - name: Install Tinybird CLI - run: curl https://tinybird.co | sh + env: + INSTALLER_URL: ${{ env.TINYBIRD_INSTALLER_URL }} + INSTALLER_SHA256: ${{ env.TINYBIRD_INSTALLER_SHA256 }} + run: | + set -euo pipefail + + if [[ -z "${INSTALLER_URL:-}" ]]; then + echo "TINYBIRD_INSTALLER_URL repo variable is not set. Point it to the" >&2 + echo "pinned Tinybird CLI release asset (e.g., https://github.com/tinybirdco/... )." >&2 + exit 1 + fi + + if [[ -z "${INSTALLER_SHA256:-}" ]]; then + echo "TINYBIRD_INSTALLER_SHA256 secret is not defined." >&2 + echo "Store the SHA-256 computed from the trusted release asset so tampering" >&2 + echo "causes the workflow to fail before execution." >&2 + exit 1 + fi + + installer_path="/tmp/tinybird-cli-installer" + curl -fSL "$INSTALLER_URL" -o "$installer_path" + + echo "${INSTALLER_SHA256} ${installer_path}" | sha256sum -c - + + chmod +x "$installer_path" + "$installer_path" - name: Build project run: tb build