dvijaykrishnan commited on
Commit
36a3a93
·
1 Parent(s): e7abb94

feat: introduce affiliate proposals and product requests with a consolidated database schema migration.

Browse files
drizzle/0004_create_storage_bucket.sql DELETED
@@ -1,16 +0,0 @@
1
-
2
- -- Create the storage bucket
3
- insert into storage.buckets (id, name, public)
4
- values ('detected-objects', 'detected-objects', true)
5
- on conflict (id) do nothing;
6
-
7
- -- Set up RLS policy for public read access
8
- create policy "Public Access"
9
- on storage.objects for select
10
- using ( bucket_id = 'detected-objects' );
11
-
12
- -- Set up RLS policy for authenticated uploads (or anon for this dev stage)
13
- -- WARNING: This allows anyone to upload. For production, restrict to authenticated users.
14
- create policy "Allow Uploads"
15
- on storage.objects for insert
16
- with check ( bucket_id = 'detected-objects' );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
drizzle/0006_add_affiliate_revenue.sql DELETED
@@ -1,15 +0,0 @@
1
- -- Migration: Add affiliate revenue tracking table
2
- -- Story: 6-1-build-revenue-summary-card
3
-
4
- CREATE TABLE IF NOT EXISTS "affiliate_revenue" (
5
- "id" text PRIMARY KEY NOT NULL,
6
- "marketplace_match_id" text NOT NULL REFERENCES "marketplace_matches"("id") ON DELETE CASCADE,
7
- "amount" real NOT NULL,
8
- "recorded_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
9
- "order_id" text,
10
- "created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
11
- "updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL
12
- );
13
-
14
- CREATE INDEX IF NOT EXISTS "affiliate_revenue_marketplace_match_id_idx" ON "affiliate_revenue"("marketplace_match_id");
15
- CREATE INDEX IF NOT EXISTS "affiliate_revenue_recorded_at_idx" ON "affiliate_revenue"("recorded_at");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
drizzle/0008_add_moderation_status_index.sql DELETED
@@ -1,2 +0,0 @@
1
- -- Story 4.1: Add index on detected_objects.moderation_status for vault query performance
2
- CREATE INDEX IF NOT EXISTS "idx_detected_objects_moderation_status" ON "detected_objects"("moderation_status");
 
 
 
drizzle/0009_add_category_index.sql DELETED
@@ -1,2 +0,0 @@
1
- -- Migration 0009: Add category index for filtering performance (Story 4.2)
2
- CREATE INDEX IF NOT EXISTS idx_detected_objects_category ON detected_objects(category);
 
 
 
drizzle/0010_add_availability_status_index.sql DELETED
@@ -1,10 +0,0 @@
1
- -- Add index on marketplace_matches.availability_status for demand heatmap queries
2
- -- This index optimizes the query in DemandHeatmapAnalyticsService.getMostRequestedItems()
3
- -- which filters by availabilityStatus IN ('SOLD_OUT', 'DISCONTINUED')
4
-
5
- CREATE INDEX IF NOT EXISTS idx_marketplace_matches_availability_status
6
- ON marketplace_matches(availability_status);
7
-
8
- -- Composite index for even better performance on the full query path
9
- CREATE INDEX IF NOT EXISTS idx_marketplace_matches_object_availability
10
- ON marketplace_matches(object_id, availability_status);
 
 
 
 
 
 
 
 
 
 
 
drizzle/0010_add_product_clicks_table.sql DELETED
@@ -1,14 +0,0 @@
1
- CREATE TABLE "product_clicks" (
2
- "id" text PRIMARY KEY NOT NULL,
3
- "marketplace_match_id" text NOT NULL,
4
- "clicked_at" timestamp (3) with time zone DEFAULT now() NOT NULL,
5
- "viewer_ip" text,
6
- "user_agent" text,
7
- "referrer" text,
8
- "created_at" timestamp (3) with time zone DEFAULT now() NOT NULL,
9
- "updated_at" timestamp (3) with time zone DEFAULT now() NOT NULL
10
- );
11
- --> statement-breakpoint
12
- ALTER TABLE "product_clicks" ADD CONSTRAINT "product_clicks_marketplace_match_id_marketplace_matches_id_fk" FOREIGN KEY ("marketplace_match_id") REFERENCES "public"."marketplace_matches"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
13
- CREATE INDEX "product_clicks_marketplace_match_id_idx" ON "product_clicks" USING btree ("marketplace_match_id");--> statement-breakpoint
14
- CREATE INDEX "product_clicks_clicked_at_idx" ON "product_clicks" USING btree ("clicked_at");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
drizzle/0011_add_product_clicks_clicked_at_index.sql DELETED
@@ -1,2 +0,0 @@
1
- -- Add index on product_clicks.clicked_at for time-based filtering in top videos analytics
2
- CREATE INDEX IF NOT EXISTS "product_clicks_clicked_at_idx" ON "product_clicks" ("clicked_at");
 
 
 
drizzle/0011_add_search_indexes.sql DELETED
@@ -1,18 +0,0 @@
1
- -- Migration: Add search indexes for product search functionality
2
- -- Story 4.5: Add Product Search Across Archive
3
- -- Uses pg_trgm extension for fast ILIKE queries
4
-
5
- -- Enable pg_trgm extension for trigram-based text search
6
- CREATE EXTENSION IF NOT EXISTS pg_trgm;
7
-
8
- -- Add GIN trigram index on detected_objects.object_name for fast ILIKE search
9
- CREATE INDEX IF NOT EXISTS detected_objects_object_name_trgm_idx
10
- ON detected_objects USING GIN (object_name gin_trgm_ops);
11
-
12
- -- Add GIN trigram index on marketplace_matches.product_name for fast ILIKE search
13
- CREATE INDEX IF NOT EXISTS marketplace_matches_product_name_trgm_idx
14
- ON marketplace_matches USING GIN (product_name gin_trgm_ops);
15
-
16
- -- Add GIN trigram index on youtube_videos.title for fast ILIKE search
17
- CREATE INDEX IF NOT EXISTS youtube_videos_title_trgm_idx
18
- ON youtube_videos USING GIN (title gin_trgm_ops);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
drizzle/0011_heavy_morbius.sql ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ CREATE TYPE "public"."affiliate_submission_status" AS ENUM('PENDING', 'APPROVED', 'REJECTED');--> statement-breakpoint
2
+ CREATE TYPE "public"."request_status" AS ENUM('PENDING', 'FULFILLED', 'DISMISSED');--> statement-breakpoint
3
+ ALTER TYPE "public"."object_category" ADD VALUE 'Person';--> statement-breakpoint
4
+ ALTER TYPE "public"."object_category" ADD VALUE 'Apparel';--> statement-breakpoint
5
+ ALTER TYPE "public"."video_scan_status" ADD VALUE 'idle';--> statement-breakpoint
6
+ CREATE TABLE "affiliate_proposals" (
7
+ "id" text PRIMARY KEY NOT NULL,
8
+ "object_id" text,
9
+ "video_id" text NOT NULL,
10
+ "creator_id" text NOT NULL,
11
+ "submitter_name" text,
12
+ "submitter_email" text,
13
+ "product_url" text NOT NULL,
14
+ "affiliate_url" text NOT NULL,
15
+ "product_name" text NOT NULL,
16
+ "price" real,
17
+ "image_url" text,
18
+ "note" text,
19
+ "status" "affiliate_submission_status" DEFAULT 'PENDING' NOT NULL,
20
+ "created_at" timestamp (3) with time zone DEFAULT now() NOT NULL,
21
+ "updated_at" timestamp (3) with time zone DEFAULT now() NOT NULL
22
+ );
23
+ --> statement-breakpoint
24
+ CREATE TABLE "affiliate_revenue" (
25
+ "id" text PRIMARY KEY NOT NULL,
26
+ "marketplace_match_id" text NOT NULL,
27
+ "amount" real NOT NULL,
28
+ "recorded_at" timestamp (3) with time zone DEFAULT now() NOT NULL,
29
+ "order_id" text,
30
+ "created_at" timestamp (3) with time zone DEFAULT now() NOT NULL,
31
+ "updated_at" timestamp (3) with time zone DEFAULT now() NOT NULL
32
+ );
33
+ --> statement-breakpoint
34
+ CREATE TABLE "product_requests" (
35
+ "id" text PRIMARY KEY NOT NULL,
36
+ "video_id" text NOT NULL,
37
+ "creator_id" text NOT NULL,
38
+ "viewer_name" text,
39
+ "viewer_email" text,
40
+ "note" text NOT NULL,
41
+ "image_url" text,
42
+ "frame_timestamp" integer,
43
+ "status" "request_status" DEFAULT 'PENDING' NOT NULL,
44
+ "created_at" timestamp (3) with time zone DEFAULT now() NOT NULL,
45
+ "updated_at" timestamp (3) with time zone DEFAULT now() NOT NULL
46
+ );
47
+ --> statement-breakpoint
48
+ ALTER TABLE "youtube_channels" DROP CONSTRAINT "youtube_channels_channel_id_unique";--> statement-breakpoint
49
+ ALTER TABLE "youtube_videos" DROP CONSTRAINT "youtube_videos_video_id_unique";--> statement-breakpoint
50
+ DROP INDEX "interest_pledges_email_product_idx";--> statement-breakpoint
51
+ ALTER TABLE "interest_pledges" ALTER COLUMN "marketplace_match_id" DROP NOT NULL;--> statement-breakpoint
52
+ ALTER TABLE "detected_objects" ADD COLUMN "thumbnail_url" text;--> statement-breakpoint
53
+ ALTER TABLE "interest_pledges" ADD COLUMN "detected_object_id" text;--> statement-breakpoint
54
+ ALTER TABLE "marketplace_matches" ADD COLUMN "image_url" text;--> statement-breakpoint
55
+ ALTER TABLE "youtube_videos" ADD COLUMN "availability_status" text DEFAULT 'unknown' NOT NULL;--> statement-breakpoint
56
+ ALTER TABLE "youtube_videos" ADD COLUMN "scan_status" "video_scan_status" DEFAULT 'pending' NOT NULL;--> statement-breakpoint
57
+ ALTER TABLE "affiliate_proposals" ADD CONSTRAINT "affiliate_proposals_object_id_detected_objects_id_fk" FOREIGN KEY ("object_id") REFERENCES "public"."detected_objects"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
58
+ ALTER TABLE "affiliate_proposals" ADD CONSTRAINT "affiliate_proposals_video_id_youtube_videos_id_fk" FOREIGN KEY ("video_id") REFERENCES "public"."youtube_videos"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
59
+ ALTER TABLE "affiliate_proposals" ADD CONSTRAINT "affiliate_proposals_creator_id_users_id_fk" FOREIGN KEY ("creator_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
60
+ ALTER TABLE "affiliate_revenue" ADD CONSTRAINT "affiliate_revenue_marketplace_match_id_marketplace_matches_id_fk" FOREIGN KEY ("marketplace_match_id") REFERENCES "public"."marketplace_matches"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
61
+ ALTER TABLE "product_requests" ADD CONSTRAINT "product_requests_video_id_youtube_videos_id_fk" FOREIGN KEY ("video_id") REFERENCES "public"."youtube_videos"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
62
+ ALTER TABLE "product_requests" ADD CONSTRAINT "product_requests_creator_id_users_id_fk" FOREIGN KEY ("creator_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
63
+ CREATE INDEX "affiliate_revenue_marketplace_match_id_idx" ON "affiliate_revenue" USING btree ("marketplace_match_id");--> statement-breakpoint
64
+ CREATE INDEX "affiliate_revenue_recorded_at_idx" ON "affiliate_revenue" USING btree ("recorded_at");--> statement-breakpoint
65
+ ALTER TABLE "interest_pledges" ADD CONSTRAINT "interest_pledges_detected_object_id_detected_objects_id_fk" FOREIGN KEY ("detected_object_id") REFERENCES "public"."detected_objects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
66
+ CREATE INDEX "interest_pledges_detected_object_id_idx" ON "interest_pledges" USING btree ("detected_object_id");
drizzle/0012_add_interest_pledges.sql DELETED
@@ -1,15 +0,0 @@
1
- CREATE TYPE "public"."interest_pledge_status" AS ENUM('ACTIVE', 'NOTIFIED', 'EXPIRED');--> statement-breakpoint
2
- CREATE TABLE "interest_pledges" (
3
- "id" text PRIMARY KEY NOT NULL,
4
- "marketplace_match_id" text NOT NULL,
5
- "email_hash" text NOT NULL,
6
- "email_encrypted" text NOT NULL,
7
- "status" "interest_pledge_status" DEFAULT 'ACTIVE' NOT NULL,
8
- "notified_at" timestamp (3) with time zone,
9
- "created_at" timestamp (3) with time zone DEFAULT now() NOT NULL,
10
- "updated_at" timestamp (3) with time zone DEFAULT now() NOT NULL
11
- );
12
- --> statement-breakpoint
13
- ALTER TABLE "interest_pledges" ADD CONSTRAINT "interest_pledges_marketplace_match_id_marketplace_matches_id_fk" FOREIGN KEY ("marketplace_match_id") REFERENCES "public"."marketplace_matches"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
14
- CREATE UNIQUE INDEX "interest_pledges_email_product_idx" ON "interest_pledges" USING btree ("marketplace_match_id","email_hash");--> statement-breakpoint
15
- CREATE INDEX "interest_pledges_marketplace_match_id_idx" ON "interest_pledges" USING btree ("marketplace_match_id");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
drizzle/0013_add_gdpr_compliance_fields.sql DELETED
@@ -1,8 +0,0 @@
1
- -- Add GDPR/CCPA compliance fields to interest_pledges table
2
- ALTER TABLE "interest_pledges"
3
- ADD COLUMN "consent_timestamp" timestamp (3) with time zone NOT NULL DEFAULT now(),
4
- ADD COLUMN "consent_ip" text,
5
- ADD COLUMN "unsubscribe_token" text NOT NULL DEFAULT gen_random_uuid(),
6
- ADD COLUMN "consent_metadata" jsonb;
7
- --> statement-breakpoint
8
- CREATE UNIQUE INDEX "interest_pledges_unsubscribe_token_idx" ON "interest_pledges" USING btree ("unsubscribe_token");
 
 
 
 
 
 
 
 
 
drizzle/0014_add_link_health_tracking.sql DELETED
@@ -1,18 +0,0 @@
1
- -- Add link health tracking to marketplace_matches table
2
-
3
- -- Create enum for link status
4
- CREATE TYPE "link_status" AS ENUM ('ACTIVE', 'CHECKING', 'BROKEN');
5
-
6
- -- Add new columns to marketplace_matches
7
- ALTER TABLE "marketplace_matches"
8
- ADD COLUMN "link_status" link_status NOT NULL DEFAULT 'ACTIVE',
9
- ADD COLUMN "last_checked_at" timestamp(3) with time zone,
10
- ADD COLUMN "check_attempts" integer NOT NULL DEFAULT 0,
11
- ADD COLUMN "check_metadata" jsonb;
12
-
13
- -- Create indexes for efficient querying
14
- CREATE INDEX "marketplace_matches_link_status_idx"
15
- ON "marketplace_matches" ("link_status");
16
-
17
- CREATE INDEX "marketplace_matches_last_checked_at_idx"
18
- ON "marketplace_matches" ("last_checked_at");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
drizzle/0015_support_unmatched_interest_pledges.sql DELETED
@@ -1,20 +0,0 @@
1
- -- Allow interest pledges on approved items that have no marketplace match yet.
2
- -- marketplaceMatchId becomes nullable; detectedObjectId added as the fallback anchor.
3
- -- Exactly one of the two must be non-null (enforced by CHECK).
4
-
5
- ALTER TABLE "interest_pledges"
6
- ALTER COLUMN "marketplace_match_id" DROP NOT NULL;
7
-
8
- ALTER TABLE "interest_pledges"
9
- ADD COLUMN "detected_object_id" text REFERENCES "detected_objects"("id") ON DELETE CASCADE;
10
-
11
- ALTER TABLE "interest_pledges"
12
- ADD CONSTRAINT "interest_pledges_must_have_target"
13
- CHECK (
14
- "marketplace_match_id" IS NOT NULL
15
- OR "detected_object_id" IS NOT NULL
16
- );
17
-
18
- -- Index for fast lookups by detected object
19
- CREATE INDEX "interest_pledges_detected_object_id_idx"
20
- ON "interest_pledges" ("detected_object_id");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
drizzle/meta/0011_snapshot.json ADDED
@@ -0,0 +1,2018 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "id": "f653faa7-7932-4486-8425-cec5dd2518ff",
3
+ "prevId": "b806ceff-b9cc-4a46-a780-dabf0604a14d",
4
+ "version": "7",
5
+ "dialect": "postgresql",
6
+ "tables": {
7
+ "public.account": {
8
+ "name": "account",
9
+ "schema": "",
10
+ "columns": {
11
+ "id": {
12
+ "name": "id",
13
+ "type": "text",
14
+ "primaryKey": true,
15
+ "notNull": true
16
+ },
17
+ "account_id": {
18
+ "name": "account_id",
19
+ "type": "text",
20
+ "primaryKey": false,
21
+ "notNull": true
22
+ },
23
+ "provider_id": {
24
+ "name": "provider_id",
25
+ "type": "text",
26
+ "primaryKey": false,
27
+ "notNull": true
28
+ },
29
+ "user_id": {
30
+ "name": "user_id",
31
+ "type": "text",
32
+ "primaryKey": false,
33
+ "notNull": true
34
+ },
35
+ "access_token": {
36
+ "name": "access_token",
37
+ "type": "text",
38
+ "primaryKey": false,
39
+ "notNull": false
40
+ },
41
+ "refresh_token": {
42
+ "name": "refresh_token",
43
+ "type": "text",
44
+ "primaryKey": false,
45
+ "notNull": false
46
+ },
47
+ "id_token": {
48
+ "name": "id_token",
49
+ "type": "text",
50
+ "primaryKey": false,
51
+ "notNull": false
52
+ },
53
+ "access_token_expires_at": {
54
+ "name": "access_token_expires_at",
55
+ "type": "timestamp",
56
+ "primaryKey": false,
57
+ "notNull": false
58
+ },
59
+ "refresh_token_expires_at": {
60
+ "name": "refresh_token_expires_at",
61
+ "type": "timestamp",
62
+ "primaryKey": false,
63
+ "notNull": false
64
+ },
65
+ "scope": {
66
+ "name": "scope",
67
+ "type": "text",
68
+ "primaryKey": false,
69
+ "notNull": false
70
+ },
71
+ "password": {
72
+ "name": "password",
73
+ "type": "text",
74
+ "primaryKey": false,
75
+ "notNull": false
76
+ },
77
+ "created_at": {
78
+ "name": "created_at",
79
+ "type": "timestamp",
80
+ "primaryKey": false,
81
+ "notNull": true
82
+ },
83
+ "updated_at": {
84
+ "name": "updated_at",
85
+ "type": "timestamp",
86
+ "primaryKey": false,
87
+ "notNull": true
88
+ }
89
+ },
90
+ "indexes": {},
91
+ "foreignKeys": {
92
+ "account_user_id_users_id_fk": {
93
+ "name": "account_user_id_users_id_fk",
94
+ "tableFrom": "account",
95
+ "tableTo": "users",
96
+ "columnsFrom": [
97
+ "user_id"
98
+ ],
99
+ "columnsTo": [
100
+ "id"
101
+ ],
102
+ "onDelete": "no action",
103
+ "onUpdate": "no action"
104
+ }
105
+ },
106
+ "compositePrimaryKeys": {},
107
+ "uniqueConstraints": {},
108
+ "policies": {},
109
+ "checkConstraints": {},
110
+ "isRLSEnabled": false
111
+ },
112
+ "public.admin_moderation": {
113
+ "name": "admin_moderation",
114
+ "schema": "",
115
+ "columns": {
116
+ "id": {
117
+ "name": "id",
118
+ "type": "text",
119
+ "primaryKey": true,
120
+ "notNull": true
121
+ },
122
+ "detection_id": {
123
+ "name": "detection_id",
124
+ "type": "text",
125
+ "primaryKey": false,
126
+ "notNull": true
127
+ },
128
+ "admin_id": {
129
+ "name": "admin_id",
130
+ "type": "text",
131
+ "primaryKey": false,
132
+ "notNull": true
133
+ },
134
+ "action": {
135
+ "name": "action",
136
+ "type": "admin_moderation_type",
137
+ "typeSchema": "public",
138
+ "primaryKey": false,
139
+ "notNull": true
140
+ },
141
+ "reason_code": {
142
+ "name": "reason_code",
143
+ "type": "reason_code",
144
+ "typeSchema": "public",
145
+ "primaryKey": false,
146
+ "notNull": false
147
+ },
148
+ "original_values": {
149
+ "name": "original_values",
150
+ "type": "jsonb",
151
+ "primaryKey": false,
152
+ "notNull": true
153
+ },
154
+ "corrected_values": {
155
+ "name": "corrected_values",
156
+ "type": "jsonb",
157
+ "primaryKey": false,
158
+ "notNull": false
159
+ },
160
+ "train_ai_flag": {
161
+ "name": "train_ai_flag",
162
+ "type": "boolean",
163
+ "primaryKey": false,
164
+ "notNull": true,
165
+ "default": true
166
+ },
167
+ "created_at": {
168
+ "name": "created_at",
169
+ "type": "timestamp (3) with time zone",
170
+ "primaryKey": false,
171
+ "notNull": true,
172
+ "default": "now()"
173
+ },
174
+ "updated_at": {
175
+ "name": "updated_at",
176
+ "type": "timestamp (3) with time zone",
177
+ "primaryKey": false,
178
+ "notNull": true,
179
+ "default": "now()"
180
+ }
181
+ },
182
+ "indexes": {},
183
+ "foreignKeys": {
184
+ "admin_moderation_detection_id_detected_objects_id_fk": {
185
+ "name": "admin_moderation_detection_id_detected_objects_id_fk",
186
+ "tableFrom": "admin_moderation",
187
+ "tableTo": "detected_objects",
188
+ "columnsFrom": [
189
+ "detection_id"
190
+ ],
191
+ "columnsTo": [
192
+ "id"
193
+ ],
194
+ "onDelete": "cascade",
195
+ "onUpdate": "no action"
196
+ },
197
+ "admin_moderation_admin_id_users_id_fk": {
198
+ "name": "admin_moderation_admin_id_users_id_fk",
199
+ "tableFrom": "admin_moderation",
200
+ "tableTo": "users",
201
+ "columnsFrom": [
202
+ "admin_id"
203
+ ],
204
+ "columnsTo": [
205
+ "id"
206
+ ],
207
+ "onDelete": "no action",
208
+ "onUpdate": "no action"
209
+ }
210
+ },
211
+ "compositePrimaryKeys": {},
212
+ "uniqueConstraints": {},
213
+ "policies": {},
214
+ "checkConstraints": {},
215
+ "isRLSEnabled": false
216
+ },
217
+ "public.affiliate_proposals": {
218
+ "name": "affiliate_proposals",
219
+ "schema": "",
220
+ "columns": {
221
+ "id": {
222
+ "name": "id",
223
+ "type": "text",
224
+ "primaryKey": true,
225
+ "notNull": true
226
+ },
227
+ "object_id": {
228
+ "name": "object_id",
229
+ "type": "text",
230
+ "primaryKey": false,
231
+ "notNull": false
232
+ },
233
+ "video_id": {
234
+ "name": "video_id",
235
+ "type": "text",
236
+ "primaryKey": false,
237
+ "notNull": true
238
+ },
239
+ "creator_id": {
240
+ "name": "creator_id",
241
+ "type": "text",
242
+ "primaryKey": false,
243
+ "notNull": true
244
+ },
245
+ "submitter_name": {
246
+ "name": "submitter_name",
247
+ "type": "text",
248
+ "primaryKey": false,
249
+ "notNull": false
250
+ },
251
+ "submitter_email": {
252
+ "name": "submitter_email",
253
+ "type": "text",
254
+ "primaryKey": false,
255
+ "notNull": false
256
+ },
257
+ "product_url": {
258
+ "name": "product_url",
259
+ "type": "text",
260
+ "primaryKey": false,
261
+ "notNull": true
262
+ },
263
+ "affiliate_url": {
264
+ "name": "affiliate_url",
265
+ "type": "text",
266
+ "primaryKey": false,
267
+ "notNull": true
268
+ },
269
+ "product_name": {
270
+ "name": "product_name",
271
+ "type": "text",
272
+ "primaryKey": false,
273
+ "notNull": true
274
+ },
275
+ "price": {
276
+ "name": "price",
277
+ "type": "real",
278
+ "primaryKey": false,
279
+ "notNull": false
280
+ },
281
+ "image_url": {
282
+ "name": "image_url",
283
+ "type": "text",
284
+ "primaryKey": false,
285
+ "notNull": false
286
+ },
287
+ "note": {
288
+ "name": "note",
289
+ "type": "text",
290
+ "primaryKey": false,
291
+ "notNull": false
292
+ },
293
+ "status": {
294
+ "name": "status",
295
+ "type": "affiliate_submission_status",
296
+ "typeSchema": "public",
297
+ "primaryKey": false,
298
+ "notNull": true,
299
+ "default": "'PENDING'"
300
+ },
301
+ "created_at": {
302
+ "name": "created_at",
303
+ "type": "timestamp (3) with time zone",
304
+ "primaryKey": false,
305
+ "notNull": true,
306
+ "default": "now()"
307
+ },
308
+ "updated_at": {
309
+ "name": "updated_at",
310
+ "type": "timestamp (3) with time zone",
311
+ "primaryKey": false,
312
+ "notNull": true,
313
+ "default": "now()"
314
+ }
315
+ },
316
+ "indexes": {},
317
+ "foreignKeys": {
318
+ "affiliate_proposals_object_id_detected_objects_id_fk": {
319
+ "name": "affiliate_proposals_object_id_detected_objects_id_fk",
320
+ "tableFrom": "affiliate_proposals",
321
+ "tableTo": "detected_objects",
322
+ "columnsFrom": [
323
+ "object_id"
324
+ ],
325
+ "columnsTo": [
326
+ "id"
327
+ ],
328
+ "onDelete": "set null",
329
+ "onUpdate": "no action"
330
+ },
331
+ "affiliate_proposals_video_id_youtube_videos_id_fk": {
332
+ "name": "affiliate_proposals_video_id_youtube_videos_id_fk",
333
+ "tableFrom": "affiliate_proposals",
334
+ "tableTo": "youtube_videos",
335
+ "columnsFrom": [
336
+ "video_id"
337
+ ],
338
+ "columnsTo": [
339
+ "id"
340
+ ],
341
+ "onDelete": "cascade",
342
+ "onUpdate": "no action"
343
+ },
344
+ "affiliate_proposals_creator_id_users_id_fk": {
345
+ "name": "affiliate_proposals_creator_id_users_id_fk",
346
+ "tableFrom": "affiliate_proposals",
347
+ "tableTo": "users",
348
+ "columnsFrom": [
349
+ "creator_id"
350
+ ],
351
+ "columnsTo": [
352
+ "id"
353
+ ],
354
+ "onDelete": "no action",
355
+ "onUpdate": "no action"
356
+ }
357
+ },
358
+ "compositePrimaryKeys": {},
359
+ "uniqueConstraints": {},
360
+ "policies": {},
361
+ "checkConstraints": {},
362
+ "isRLSEnabled": false
363
+ },
364
+ "public.affiliate_revenue": {
365
+ "name": "affiliate_revenue",
366
+ "schema": "",
367
+ "columns": {
368
+ "id": {
369
+ "name": "id",
370
+ "type": "text",
371
+ "primaryKey": true,
372
+ "notNull": true
373
+ },
374
+ "marketplace_match_id": {
375
+ "name": "marketplace_match_id",
376
+ "type": "text",
377
+ "primaryKey": false,
378
+ "notNull": true
379
+ },
380
+ "amount": {
381
+ "name": "amount",
382
+ "type": "real",
383
+ "primaryKey": false,
384
+ "notNull": true
385
+ },
386
+ "recorded_at": {
387
+ "name": "recorded_at",
388
+ "type": "timestamp (3) with time zone",
389
+ "primaryKey": false,
390
+ "notNull": true,
391
+ "default": "now()"
392
+ },
393
+ "order_id": {
394
+ "name": "order_id",
395
+ "type": "text",
396
+ "primaryKey": false,
397
+ "notNull": false
398
+ },
399
+ "created_at": {
400
+ "name": "created_at",
401
+ "type": "timestamp (3) with time zone",
402
+ "primaryKey": false,
403
+ "notNull": true,
404
+ "default": "now()"
405
+ },
406
+ "updated_at": {
407
+ "name": "updated_at",
408
+ "type": "timestamp (3) with time zone",
409
+ "primaryKey": false,
410
+ "notNull": true,
411
+ "default": "now()"
412
+ }
413
+ },
414
+ "indexes": {
415
+ "affiliate_revenue_marketplace_match_id_idx": {
416
+ "name": "affiliate_revenue_marketplace_match_id_idx",
417
+ "columns": [
418
+ {
419
+ "expression": "marketplace_match_id",
420
+ "isExpression": false,
421
+ "asc": true,
422
+ "nulls": "last"
423
+ }
424
+ ],
425
+ "isUnique": false,
426
+ "concurrently": false,
427
+ "method": "btree",
428
+ "with": {}
429
+ },
430
+ "affiliate_revenue_recorded_at_idx": {
431
+ "name": "affiliate_revenue_recorded_at_idx",
432
+ "columns": [
433
+ {
434
+ "expression": "recorded_at",
435
+ "isExpression": false,
436
+ "asc": true,
437
+ "nulls": "last"
438
+ }
439
+ ],
440
+ "isUnique": false,
441
+ "concurrently": false,
442
+ "method": "btree",
443
+ "with": {}
444
+ }
445
+ },
446
+ "foreignKeys": {
447
+ "affiliate_revenue_marketplace_match_id_marketplace_matches_id_fk": {
448
+ "name": "affiliate_revenue_marketplace_match_id_marketplace_matches_id_fk",
449
+ "tableFrom": "affiliate_revenue",
450
+ "tableTo": "marketplace_matches",
451
+ "columnsFrom": [
452
+ "marketplace_match_id"
453
+ ],
454
+ "columnsTo": [
455
+ "id"
456
+ ],
457
+ "onDelete": "cascade",
458
+ "onUpdate": "no action"
459
+ }
460
+ },
461
+ "compositePrimaryKeys": {},
462
+ "uniqueConstraints": {},
463
+ "policies": {},
464
+ "checkConstraints": {},
465
+ "isRLSEnabled": false
466
+ },
467
+ "public.bounty_pledges": {
468
+ "name": "bounty_pledges",
469
+ "schema": "",
470
+ "columns": {
471
+ "id": {
472
+ "name": "id",
473
+ "type": "text",
474
+ "primaryKey": true,
475
+ "notNull": true
476
+ },
477
+ "product_id": {
478
+ "name": "product_id",
479
+ "type": "text",
480
+ "primaryKey": false,
481
+ "notNull": true
482
+ },
483
+ "marketplace_match_id": {
484
+ "name": "marketplace_match_id",
485
+ "type": "text",
486
+ "primaryKey": false,
487
+ "notNull": true
488
+ },
489
+ "email_hash": {
490
+ "name": "email_hash",
491
+ "type": "text",
492
+ "primaryKey": false,
493
+ "notNull": true
494
+ },
495
+ "encrypted_email": {
496
+ "name": "encrypted_email",
497
+ "type": "text",
498
+ "primaryKey": false,
499
+ "notNull": true
500
+ },
501
+ "pledge_amount": {
502
+ "name": "pledge_amount",
503
+ "type": "real",
504
+ "primaryKey": false,
505
+ "notNull": true
506
+ },
507
+ "currency": {
508
+ "name": "currency",
509
+ "type": "text",
510
+ "primaryKey": false,
511
+ "notNull": true,
512
+ "default": "'USD'"
513
+ },
514
+ "status": {
515
+ "name": "status",
516
+ "type": "bounty_pledge_status",
517
+ "typeSchema": "public",
518
+ "primaryKey": false,
519
+ "notNull": true,
520
+ "default": "'ACTIVE'"
521
+ },
522
+ "consent_timestamp": {
523
+ "name": "consent_timestamp",
524
+ "type": "timestamp (3) with time zone",
525
+ "primaryKey": false,
526
+ "notNull": true,
527
+ "default": "now()"
528
+ },
529
+ "consent_ip": {
530
+ "name": "consent_ip",
531
+ "type": "text",
532
+ "primaryKey": false,
533
+ "notNull": false
534
+ },
535
+ "unsubscribe_token": {
536
+ "name": "unsubscribe_token",
537
+ "type": "text",
538
+ "primaryKey": false,
539
+ "notNull": true
540
+ },
541
+ "notified_at": {
542
+ "name": "notified_at",
543
+ "type": "timestamp (3) with time zone",
544
+ "primaryKey": false,
545
+ "notNull": false
546
+ },
547
+ "created_at": {
548
+ "name": "created_at",
549
+ "type": "timestamp (3) with time zone",
550
+ "primaryKey": false,
551
+ "notNull": true,
552
+ "default": "now()"
553
+ },
554
+ "updated_at": {
555
+ "name": "updated_at",
556
+ "type": "timestamp (3) with time zone",
557
+ "primaryKey": false,
558
+ "notNull": true,
559
+ "default": "now()"
560
+ }
561
+ },
562
+ "indexes": {
563
+ "bounty_pledges_product_id_idx": {
564
+ "name": "bounty_pledges_product_id_idx",
565
+ "columns": [
566
+ {
567
+ "expression": "product_id",
568
+ "isExpression": false,
569
+ "asc": true,
570
+ "nulls": "last"
571
+ }
572
+ ],
573
+ "isUnique": false,
574
+ "concurrently": false,
575
+ "method": "btree",
576
+ "with": {}
577
+ },
578
+ "bounty_pledges_status_idx": {
579
+ "name": "bounty_pledges_status_idx",
580
+ "columns": [
581
+ {
582
+ "expression": "status",
583
+ "isExpression": false,
584
+ "asc": true,
585
+ "nulls": "last"
586
+ }
587
+ ],
588
+ "isUnique": false,
589
+ "concurrently": false,
590
+ "method": "btree",
591
+ "with": {}
592
+ },
593
+ "bounty_pledges_email_hash_idx": {
594
+ "name": "bounty_pledges_email_hash_idx",
595
+ "columns": [
596
+ {
597
+ "expression": "email_hash",
598
+ "isExpression": false,
599
+ "asc": true,
600
+ "nulls": "last"
601
+ }
602
+ ],
603
+ "isUnique": false,
604
+ "concurrently": false,
605
+ "method": "btree",
606
+ "with": {}
607
+ }
608
+ },
609
+ "foreignKeys": {
610
+ "bounty_pledges_marketplace_match_id_marketplace_matches_id_fk": {
611
+ "name": "bounty_pledges_marketplace_match_id_marketplace_matches_id_fk",
612
+ "tableFrom": "bounty_pledges",
613
+ "tableTo": "marketplace_matches",
614
+ "columnsFrom": [
615
+ "marketplace_match_id"
616
+ ],
617
+ "columnsTo": [
618
+ "id"
619
+ ],
620
+ "onDelete": "cascade",
621
+ "onUpdate": "no action"
622
+ }
623
+ },
624
+ "compositePrimaryKeys": {},
625
+ "uniqueConstraints": {},
626
+ "policies": {},
627
+ "checkConstraints": {},
628
+ "isRLSEnabled": false
629
+ },
630
+ "public.detected_objects": {
631
+ "name": "detected_objects",
632
+ "schema": "",
633
+ "columns": {
634
+ "id": {
635
+ "name": "id",
636
+ "type": "text",
637
+ "primaryKey": true,
638
+ "notNull": true
639
+ },
640
+ "video_id": {
641
+ "name": "video_id",
642
+ "type": "text",
643
+ "primaryKey": false,
644
+ "notNull": true
645
+ },
646
+ "object_name": {
647
+ "name": "object_name",
648
+ "type": "text",
649
+ "primaryKey": false,
650
+ "notNull": true
651
+ },
652
+ "category": {
653
+ "name": "category",
654
+ "type": "object_category",
655
+ "typeSchema": "public",
656
+ "primaryKey": false,
657
+ "notNull": true
658
+ },
659
+ "confidence_score": {
660
+ "name": "confidence_score",
661
+ "type": "real",
662
+ "primaryKey": false,
663
+ "notNull": true
664
+ },
665
+ "frame_timestamp": {
666
+ "name": "frame_timestamp",
667
+ "type": "integer",
668
+ "primaryKey": false,
669
+ "notNull": true
670
+ },
671
+ "detection_metadata": {
672
+ "name": "detection_metadata",
673
+ "type": "jsonb",
674
+ "primaryKey": false,
675
+ "notNull": false
676
+ },
677
+ "thumbnail_url": {
678
+ "name": "thumbnail_url",
679
+ "type": "text",
680
+ "primaryKey": false,
681
+ "notNull": false
682
+ },
683
+ "status": {
684
+ "name": "status",
685
+ "type": "detection_status",
686
+ "typeSchema": "public",
687
+ "primaryKey": false,
688
+ "notNull": true,
689
+ "default": "'pending_review'"
690
+ },
691
+ "moderation_status": {
692
+ "name": "moderation_status",
693
+ "type": "moderation_status",
694
+ "typeSchema": "public",
695
+ "primaryKey": false,
696
+ "notNull": true,
697
+ "default": "'PENDING'"
698
+ },
699
+ "moderated_at": {
700
+ "name": "moderated_at",
701
+ "type": "timestamp (3) with time zone",
702
+ "primaryKey": false,
703
+ "notNull": false
704
+ },
705
+ "moderated_by": {
706
+ "name": "moderated_by",
707
+ "type": "text",
708
+ "primaryKey": false,
709
+ "notNull": false
710
+ },
711
+ "moderation_metadata": {
712
+ "name": "moderation_metadata",
713
+ "type": "jsonb",
714
+ "primaryKey": false,
715
+ "notNull": false
716
+ },
717
+ "created_at": {
718
+ "name": "created_at",
719
+ "type": "timestamp (3) with time zone",
720
+ "primaryKey": false,
721
+ "notNull": true,
722
+ "default": "now()"
723
+ },
724
+ "updated_at": {
725
+ "name": "updated_at",
726
+ "type": "timestamp (3) with time zone",
727
+ "primaryKey": false,
728
+ "notNull": true,
729
+ "default": "now()"
730
+ }
731
+ },
732
+ "indexes": {},
733
+ "foreignKeys": {
734
+ "detected_objects_video_id_youtube_videos_id_fk": {
735
+ "name": "detected_objects_video_id_youtube_videos_id_fk",
736
+ "tableFrom": "detected_objects",
737
+ "tableTo": "youtube_videos",
738
+ "columnsFrom": [
739
+ "video_id"
740
+ ],
741
+ "columnsTo": [
742
+ "id"
743
+ ],
744
+ "onDelete": "cascade",
745
+ "onUpdate": "no action"
746
+ },
747
+ "detected_objects_moderated_by_users_id_fk": {
748
+ "name": "detected_objects_moderated_by_users_id_fk",
749
+ "tableFrom": "detected_objects",
750
+ "tableTo": "users",
751
+ "columnsFrom": [
752
+ "moderated_by"
753
+ ],
754
+ "columnsTo": [
755
+ "id"
756
+ ],
757
+ "onDelete": "no action",
758
+ "onUpdate": "no action"
759
+ }
760
+ },
761
+ "compositePrimaryKeys": {},
762
+ "uniqueConstraints": {},
763
+ "policies": {},
764
+ "checkConstraints": {},
765
+ "isRLSEnabled": false
766
+ },
767
+ "public.interest_pledges": {
768
+ "name": "interest_pledges",
769
+ "schema": "",
770
+ "columns": {
771
+ "id": {
772
+ "name": "id",
773
+ "type": "text",
774
+ "primaryKey": true,
775
+ "notNull": true
776
+ },
777
+ "marketplace_match_id": {
778
+ "name": "marketplace_match_id",
779
+ "type": "text",
780
+ "primaryKey": false,
781
+ "notNull": false
782
+ },
783
+ "detected_object_id": {
784
+ "name": "detected_object_id",
785
+ "type": "text",
786
+ "primaryKey": false,
787
+ "notNull": false
788
+ },
789
+ "email_hash": {
790
+ "name": "email_hash",
791
+ "type": "text",
792
+ "primaryKey": false,
793
+ "notNull": true
794
+ },
795
+ "email_encrypted": {
796
+ "name": "email_encrypted",
797
+ "type": "text",
798
+ "primaryKey": false,
799
+ "notNull": true
800
+ },
801
+ "status": {
802
+ "name": "status",
803
+ "type": "interest_pledge_status",
804
+ "typeSchema": "public",
805
+ "primaryKey": false,
806
+ "notNull": true,
807
+ "default": "'ACTIVE'"
808
+ },
809
+ "consent_timestamp": {
810
+ "name": "consent_timestamp",
811
+ "type": "timestamp (3) with time zone",
812
+ "primaryKey": false,
813
+ "notNull": true,
814
+ "default": "now()"
815
+ },
816
+ "consent_ip": {
817
+ "name": "consent_ip",
818
+ "type": "text",
819
+ "primaryKey": false,
820
+ "notNull": false
821
+ },
822
+ "unsubscribe_token": {
823
+ "name": "unsubscribe_token",
824
+ "type": "text",
825
+ "primaryKey": false,
826
+ "notNull": true
827
+ },
828
+ "consent_metadata": {
829
+ "name": "consent_metadata",
830
+ "type": "jsonb",
831
+ "primaryKey": false,
832
+ "notNull": false
833
+ },
834
+ "notified_at": {
835
+ "name": "notified_at",
836
+ "type": "timestamp (3) with time zone",
837
+ "primaryKey": false,
838
+ "notNull": false
839
+ },
840
+ "created_at": {
841
+ "name": "created_at",
842
+ "type": "timestamp (3) with time zone",
843
+ "primaryKey": false,
844
+ "notNull": true,
845
+ "default": "now()"
846
+ },
847
+ "updated_at": {
848
+ "name": "updated_at",
849
+ "type": "timestamp (3) with time zone",
850
+ "primaryKey": false,
851
+ "notNull": true,
852
+ "default": "now()"
853
+ }
854
+ },
855
+ "indexes": {
856
+ "interest_pledges_marketplace_match_id_idx": {
857
+ "name": "interest_pledges_marketplace_match_id_idx",
858
+ "columns": [
859
+ {
860
+ "expression": "marketplace_match_id",
861
+ "isExpression": false,
862
+ "asc": true,
863
+ "nulls": "last"
864
+ }
865
+ ],
866
+ "isUnique": false,
867
+ "concurrently": false,
868
+ "method": "btree",
869
+ "with": {}
870
+ },
871
+ "interest_pledges_detected_object_id_idx": {
872
+ "name": "interest_pledges_detected_object_id_idx",
873
+ "columns": [
874
+ {
875
+ "expression": "detected_object_id",
876
+ "isExpression": false,
877
+ "asc": true,
878
+ "nulls": "last"
879
+ }
880
+ ],
881
+ "isUnique": false,
882
+ "concurrently": false,
883
+ "method": "btree",
884
+ "with": {}
885
+ },
886
+ "interest_pledges_unsubscribe_token_idx": {
887
+ "name": "interest_pledges_unsubscribe_token_idx",
888
+ "columns": [
889
+ {
890
+ "expression": "unsubscribe_token",
891
+ "isExpression": false,
892
+ "asc": true,
893
+ "nulls": "last"
894
+ }
895
+ ],
896
+ "isUnique": true,
897
+ "concurrently": false,
898
+ "method": "btree",
899
+ "with": {}
900
+ }
901
+ },
902
+ "foreignKeys": {
903
+ "interest_pledges_marketplace_match_id_marketplace_matches_id_fk": {
904
+ "name": "interest_pledges_marketplace_match_id_marketplace_matches_id_fk",
905
+ "tableFrom": "interest_pledges",
906
+ "tableTo": "marketplace_matches",
907
+ "columnsFrom": [
908
+ "marketplace_match_id"
909
+ ],
910
+ "columnsTo": [
911
+ "id"
912
+ ],
913
+ "onDelete": "cascade",
914
+ "onUpdate": "no action"
915
+ },
916
+ "interest_pledges_detected_object_id_detected_objects_id_fk": {
917
+ "name": "interest_pledges_detected_object_id_detected_objects_id_fk",
918
+ "tableFrom": "interest_pledges",
919
+ "tableTo": "detected_objects",
920
+ "columnsFrom": [
921
+ "detected_object_id"
922
+ ],
923
+ "columnsTo": [
924
+ "id"
925
+ ],
926
+ "onDelete": "cascade",
927
+ "onUpdate": "no action"
928
+ }
929
+ },
930
+ "compositePrimaryKeys": {},
931
+ "uniqueConstraints": {},
932
+ "policies": {},
933
+ "checkConstraints": {},
934
+ "isRLSEnabled": false
935
+ },
936
+ "public.marketplace_matches": {
937
+ "name": "marketplace_matches",
938
+ "schema": "",
939
+ "columns": {
940
+ "id": {
941
+ "name": "id",
942
+ "type": "text",
943
+ "primaryKey": true,
944
+ "notNull": true
945
+ },
946
+ "object_id": {
947
+ "name": "object_id",
948
+ "type": "text",
949
+ "primaryKey": false,
950
+ "notNull": true
951
+ },
952
+ "marketplace": {
953
+ "name": "marketplace",
954
+ "type": "marketplace_type",
955
+ "typeSchema": "public",
956
+ "primaryKey": false,
957
+ "notNull": true
958
+ },
959
+ "product_id": {
960
+ "name": "product_id",
961
+ "type": "text",
962
+ "primaryKey": false,
963
+ "notNull": true
964
+ },
965
+ "product_name": {
966
+ "name": "product_name",
967
+ "type": "text",
968
+ "primaryKey": false,
969
+ "notNull": true
970
+ },
971
+ "price": {
972
+ "name": "price",
973
+ "type": "real",
974
+ "primaryKey": false,
975
+ "notNull": true
976
+ },
977
+ "availability_status": {
978
+ "name": "availability_status",
979
+ "type": "availability_status",
980
+ "typeSchema": "public",
981
+ "primaryKey": false,
982
+ "notNull": true
983
+ },
984
+ "affiliate_url": {
985
+ "name": "affiliate_url",
986
+ "type": "text",
987
+ "primaryKey": false,
988
+ "notNull": true
989
+ },
990
+ "image_url": {
991
+ "name": "image_url",
992
+ "type": "text",
993
+ "primaryKey": false,
994
+ "notNull": false
995
+ },
996
+ "link_status": {
997
+ "name": "link_status",
998
+ "type": "link_status",
999
+ "typeSchema": "public",
1000
+ "primaryKey": false,
1001
+ "notNull": true,
1002
+ "default": "'ACTIVE'"
1003
+ },
1004
+ "last_checked_at": {
1005
+ "name": "last_checked_at",
1006
+ "type": "timestamp (3) with time zone",
1007
+ "primaryKey": false,
1008
+ "notNull": false
1009
+ },
1010
+ "check_attempts": {
1011
+ "name": "check_attempts",
1012
+ "type": "integer",
1013
+ "primaryKey": false,
1014
+ "notNull": true,
1015
+ "default": 0
1016
+ },
1017
+ "check_metadata": {
1018
+ "name": "check_metadata",
1019
+ "type": "jsonb",
1020
+ "primaryKey": false,
1021
+ "notNull": false
1022
+ },
1023
+ "matched_at": {
1024
+ "name": "matched_at",
1025
+ "type": "timestamp (3) with time zone",
1026
+ "primaryKey": false,
1027
+ "notNull": true,
1028
+ "default": "now()"
1029
+ },
1030
+ "created_at": {
1031
+ "name": "created_at",
1032
+ "type": "timestamp (3) with time zone",
1033
+ "primaryKey": false,
1034
+ "notNull": true,
1035
+ "default": "now()"
1036
+ },
1037
+ "updated_at": {
1038
+ "name": "updated_at",
1039
+ "type": "timestamp (3) with time zone",
1040
+ "primaryKey": false,
1041
+ "notNull": true,
1042
+ "default": "now()"
1043
+ }
1044
+ },
1045
+ "indexes": {
1046
+ "marketplace_matches_link_status_idx": {
1047
+ "name": "marketplace_matches_link_status_idx",
1048
+ "columns": [
1049
+ {
1050
+ "expression": "link_status",
1051
+ "isExpression": false,
1052
+ "asc": true,
1053
+ "nulls": "last"
1054
+ }
1055
+ ],
1056
+ "isUnique": false,
1057
+ "concurrently": false,
1058
+ "method": "btree",
1059
+ "with": {}
1060
+ },
1061
+ "marketplace_matches_last_checked_at_idx": {
1062
+ "name": "marketplace_matches_last_checked_at_idx",
1063
+ "columns": [
1064
+ {
1065
+ "expression": "last_checked_at",
1066
+ "isExpression": false,
1067
+ "asc": true,
1068
+ "nulls": "last"
1069
+ }
1070
+ ],
1071
+ "isUnique": false,
1072
+ "concurrently": false,
1073
+ "method": "btree",
1074
+ "with": {}
1075
+ }
1076
+ },
1077
+ "foreignKeys": {
1078
+ "marketplace_matches_object_id_detected_objects_id_fk": {
1079
+ "name": "marketplace_matches_object_id_detected_objects_id_fk",
1080
+ "tableFrom": "marketplace_matches",
1081
+ "tableTo": "detected_objects",
1082
+ "columnsFrom": [
1083
+ "object_id"
1084
+ ],
1085
+ "columnsTo": [
1086
+ "id"
1087
+ ],
1088
+ "onDelete": "cascade",
1089
+ "onUpdate": "no action"
1090
+ }
1091
+ },
1092
+ "compositePrimaryKeys": {},
1093
+ "uniqueConstraints": {},
1094
+ "policies": {},
1095
+ "checkConstraints": {},
1096
+ "isRLSEnabled": false
1097
+ },
1098
+ "public.product_clicks": {
1099
+ "name": "product_clicks",
1100
+ "schema": "",
1101
+ "columns": {
1102
+ "id": {
1103
+ "name": "id",
1104
+ "type": "text",
1105
+ "primaryKey": true,
1106
+ "notNull": true
1107
+ },
1108
+ "marketplace_match_id": {
1109
+ "name": "marketplace_match_id",
1110
+ "type": "text",
1111
+ "primaryKey": false,
1112
+ "notNull": true
1113
+ },
1114
+ "clicked_at": {
1115
+ "name": "clicked_at",
1116
+ "type": "timestamp (3) with time zone",
1117
+ "primaryKey": false,
1118
+ "notNull": true,
1119
+ "default": "now()"
1120
+ },
1121
+ "viewer_ip": {
1122
+ "name": "viewer_ip",
1123
+ "type": "text",
1124
+ "primaryKey": false,
1125
+ "notNull": false
1126
+ },
1127
+ "user_agent": {
1128
+ "name": "user_agent",
1129
+ "type": "text",
1130
+ "primaryKey": false,
1131
+ "notNull": false
1132
+ },
1133
+ "referrer": {
1134
+ "name": "referrer",
1135
+ "type": "text",
1136
+ "primaryKey": false,
1137
+ "notNull": false
1138
+ },
1139
+ "created_at": {
1140
+ "name": "created_at",
1141
+ "type": "timestamp (3) with time zone",
1142
+ "primaryKey": false,
1143
+ "notNull": true,
1144
+ "default": "now()"
1145
+ },
1146
+ "updated_at": {
1147
+ "name": "updated_at",
1148
+ "type": "timestamp (3) with time zone",
1149
+ "primaryKey": false,
1150
+ "notNull": true,
1151
+ "default": "now()"
1152
+ }
1153
+ },
1154
+ "indexes": {
1155
+ "product_clicks_marketplace_match_id_idx": {
1156
+ "name": "product_clicks_marketplace_match_id_idx",
1157
+ "columns": [
1158
+ {
1159
+ "expression": "marketplace_match_id",
1160
+ "isExpression": false,
1161
+ "asc": true,
1162
+ "nulls": "last"
1163
+ }
1164
+ ],
1165
+ "isUnique": false,
1166
+ "concurrently": false,
1167
+ "method": "btree",
1168
+ "with": {}
1169
+ },
1170
+ "product_clicks_clicked_at_idx": {
1171
+ "name": "product_clicks_clicked_at_idx",
1172
+ "columns": [
1173
+ {
1174
+ "expression": "clicked_at",
1175
+ "isExpression": false,
1176
+ "asc": true,
1177
+ "nulls": "last"
1178
+ }
1179
+ ],
1180
+ "isUnique": false,
1181
+ "concurrently": false,
1182
+ "method": "btree",
1183
+ "with": {}
1184
+ }
1185
+ },
1186
+ "foreignKeys": {
1187
+ "product_clicks_marketplace_match_id_marketplace_matches_id_fk": {
1188
+ "name": "product_clicks_marketplace_match_id_marketplace_matches_id_fk",
1189
+ "tableFrom": "product_clicks",
1190
+ "tableTo": "marketplace_matches",
1191
+ "columnsFrom": [
1192
+ "marketplace_match_id"
1193
+ ],
1194
+ "columnsTo": [
1195
+ "id"
1196
+ ],
1197
+ "onDelete": "cascade",
1198
+ "onUpdate": "no action"
1199
+ }
1200
+ },
1201
+ "compositePrimaryKeys": {},
1202
+ "uniqueConstraints": {},
1203
+ "policies": {},
1204
+ "checkConstraints": {},
1205
+ "isRLSEnabled": false
1206
+ },
1207
+ "public.product_requests": {
1208
+ "name": "product_requests",
1209
+ "schema": "",
1210
+ "columns": {
1211
+ "id": {
1212
+ "name": "id",
1213
+ "type": "text",
1214
+ "primaryKey": true,
1215
+ "notNull": true
1216
+ },
1217
+ "video_id": {
1218
+ "name": "video_id",
1219
+ "type": "text",
1220
+ "primaryKey": false,
1221
+ "notNull": true
1222
+ },
1223
+ "creator_id": {
1224
+ "name": "creator_id",
1225
+ "type": "text",
1226
+ "primaryKey": false,
1227
+ "notNull": true
1228
+ },
1229
+ "viewer_name": {
1230
+ "name": "viewer_name",
1231
+ "type": "text",
1232
+ "primaryKey": false,
1233
+ "notNull": false
1234
+ },
1235
+ "viewer_email": {
1236
+ "name": "viewer_email",
1237
+ "type": "text",
1238
+ "primaryKey": false,
1239
+ "notNull": false
1240
+ },
1241
+ "note": {
1242
+ "name": "note",
1243
+ "type": "text",
1244
+ "primaryKey": false,
1245
+ "notNull": true
1246
+ },
1247
+ "image_url": {
1248
+ "name": "image_url",
1249
+ "type": "text",
1250
+ "primaryKey": false,
1251
+ "notNull": false
1252
+ },
1253
+ "frame_timestamp": {
1254
+ "name": "frame_timestamp",
1255
+ "type": "integer",
1256
+ "primaryKey": false,
1257
+ "notNull": false
1258
+ },
1259
+ "status": {
1260
+ "name": "status",
1261
+ "type": "request_status",
1262
+ "typeSchema": "public",
1263
+ "primaryKey": false,
1264
+ "notNull": true,
1265
+ "default": "'PENDING'"
1266
+ },
1267
+ "created_at": {
1268
+ "name": "created_at",
1269
+ "type": "timestamp (3) with time zone",
1270
+ "primaryKey": false,
1271
+ "notNull": true,
1272
+ "default": "now()"
1273
+ },
1274
+ "updated_at": {
1275
+ "name": "updated_at",
1276
+ "type": "timestamp (3) with time zone",
1277
+ "primaryKey": false,
1278
+ "notNull": true,
1279
+ "default": "now()"
1280
+ }
1281
+ },
1282
+ "indexes": {},
1283
+ "foreignKeys": {
1284
+ "product_requests_video_id_youtube_videos_id_fk": {
1285
+ "name": "product_requests_video_id_youtube_videos_id_fk",
1286
+ "tableFrom": "product_requests",
1287
+ "tableTo": "youtube_videos",
1288
+ "columnsFrom": [
1289
+ "video_id"
1290
+ ],
1291
+ "columnsTo": [
1292
+ "id"
1293
+ ],
1294
+ "onDelete": "cascade",
1295
+ "onUpdate": "no action"
1296
+ },
1297
+ "product_requests_creator_id_users_id_fk": {
1298
+ "name": "product_requests_creator_id_users_id_fk",
1299
+ "tableFrom": "product_requests",
1300
+ "tableTo": "users",
1301
+ "columnsFrom": [
1302
+ "creator_id"
1303
+ ],
1304
+ "columnsTo": [
1305
+ "id"
1306
+ ],
1307
+ "onDelete": "no action",
1308
+ "onUpdate": "no action"
1309
+ }
1310
+ },
1311
+ "compositePrimaryKeys": {},
1312
+ "uniqueConstraints": {},
1313
+ "policies": {},
1314
+ "checkConstraints": {},
1315
+ "isRLSEnabled": false
1316
+ },
1317
+ "public.session": {
1318
+ "name": "session",
1319
+ "schema": "",
1320
+ "columns": {
1321
+ "id": {
1322
+ "name": "id",
1323
+ "type": "text",
1324
+ "primaryKey": true,
1325
+ "notNull": true
1326
+ },
1327
+ "expires_at": {
1328
+ "name": "expires_at",
1329
+ "type": "timestamp",
1330
+ "primaryKey": false,
1331
+ "notNull": true
1332
+ },
1333
+ "token": {
1334
+ "name": "token",
1335
+ "type": "text",
1336
+ "primaryKey": false,
1337
+ "notNull": true
1338
+ },
1339
+ "created_at": {
1340
+ "name": "created_at",
1341
+ "type": "timestamp",
1342
+ "primaryKey": false,
1343
+ "notNull": true
1344
+ },
1345
+ "updated_at": {
1346
+ "name": "updated_at",
1347
+ "type": "timestamp",
1348
+ "primaryKey": false,
1349
+ "notNull": true
1350
+ },
1351
+ "ip_address": {
1352
+ "name": "ip_address",
1353
+ "type": "text",
1354
+ "primaryKey": false,
1355
+ "notNull": false
1356
+ },
1357
+ "user_agent": {
1358
+ "name": "user_agent",
1359
+ "type": "text",
1360
+ "primaryKey": false,
1361
+ "notNull": false
1362
+ },
1363
+ "user_id": {
1364
+ "name": "user_id",
1365
+ "type": "text",
1366
+ "primaryKey": false,
1367
+ "notNull": true
1368
+ }
1369
+ },
1370
+ "indexes": {},
1371
+ "foreignKeys": {
1372
+ "session_user_id_users_id_fk": {
1373
+ "name": "session_user_id_users_id_fk",
1374
+ "tableFrom": "session",
1375
+ "tableTo": "users",
1376
+ "columnsFrom": [
1377
+ "user_id"
1378
+ ],
1379
+ "columnsTo": [
1380
+ "id"
1381
+ ],
1382
+ "onDelete": "no action",
1383
+ "onUpdate": "no action"
1384
+ }
1385
+ },
1386
+ "compositePrimaryKeys": {},
1387
+ "uniqueConstraints": {
1388
+ "session_token_unique": {
1389
+ "name": "session_token_unique",
1390
+ "nullsNotDistinct": false,
1391
+ "columns": [
1392
+ "token"
1393
+ ]
1394
+ }
1395
+ },
1396
+ "policies": {},
1397
+ "checkConstraints": {},
1398
+ "isRLSEnabled": false
1399
+ },
1400
+ "public.users": {
1401
+ "name": "users",
1402
+ "schema": "",
1403
+ "columns": {
1404
+ "id": {
1405
+ "name": "id",
1406
+ "type": "text",
1407
+ "primaryKey": true,
1408
+ "notNull": true
1409
+ },
1410
+ "name": {
1411
+ "name": "name",
1412
+ "type": "text",
1413
+ "primaryKey": false,
1414
+ "notNull": true
1415
+ },
1416
+ "email": {
1417
+ "name": "email",
1418
+ "type": "text",
1419
+ "primaryKey": false,
1420
+ "notNull": true
1421
+ },
1422
+ "email_verified": {
1423
+ "name": "email_verified",
1424
+ "type": "boolean",
1425
+ "primaryKey": false,
1426
+ "notNull": true
1427
+ },
1428
+ "image": {
1429
+ "name": "image",
1430
+ "type": "text",
1431
+ "primaryKey": false,
1432
+ "notNull": false
1433
+ },
1434
+ "created_at": {
1435
+ "name": "created_at",
1436
+ "type": "timestamp",
1437
+ "primaryKey": false,
1438
+ "notNull": true
1439
+ },
1440
+ "updated_at": {
1441
+ "name": "updated_at",
1442
+ "type": "timestamp",
1443
+ "primaryKey": false,
1444
+ "notNull": true
1445
+ }
1446
+ },
1447
+ "indexes": {},
1448
+ "foreignKeys": {},
1449
+ "compositePrimaryKeys": {},
1450
+ "uniqueConstraints": {
1451
+ "users_email_unique": {
1452
+ "name": "users_email_unique",
1453
+ "nullsNotDistinct": false,
1454
+ "columns": [
1455
+ "email"
1456
+ ]
1457
+ }
1458
+ },
1459
+ "policies": {},
1460
+ "checkConstraints": {},
1461
+ "isRLSEnabled": false
1462
+ },
1463
+ "public.verification": {
1464
+ "name": "verification",
1465
+ "schema": "",
1466
+ "columns": {
1467
+ "id": {
1468
+ "name": "id",
1469
+ "type": "text",
1470
+ "primaryKey": true,
1471
+ "notNull": true
1472
+ },
1473
+ "identifier": {
1474
+ "name": "identifier",
1475
+ "type": "text",
1476
+ "primaryKey": false,
1477
+ "notNull": true
1478
+ },
1479
+ "value": {
1480
+ "name": "value",
1481
+ "type": "text",
1482
+ "primaryKey": false,
1483
+ "notNull": true
1484
+ },
1485
+ "expires_at": {
1486
+ "name": "expires_at",
1487
+ "type": "timestamp",
1488
+ "primaryKey": false,
1489
+ "notNull": true
1490
+ },
1491
+ "created_at": {
1492
+ "name": "created_at",
1493
+ "type": "timestamp",
1494
+ "primaryKey": false,
1495
+ "notNull": false
1496
+ },
1497
+ "updated_at": {
1498
+ "name": "updated_at",
1499
+ "type": "timestamp",
1500
+ "primaryKey": false,
1501
+ "notNull": false
1502
+ }
1503
+ },
1504
+ "indexes": {},
1505
+ "foreignKeys": {},
1506
+ "compositePrimaryKeys": {},
1507
+ "uniqueConstraints": {},
1508
+ "policies": {},
1509
+ "checkConstraints": {},
1510
+ "isRLSEnabled": false
1511
+ },
1512
+ "public.video_scan_jobs": {
1513
+ "name": "video_scan_jobs",
1514
+ "schema": "",
1515
+ "columns": {
1516
+ "id": {
1517
+ "name": "id",
1518
+ "type": "text",
1519
+ "primaryKey": true,
1520
+ "notNull": true
1521
+ },
1522
+ "channel_id": {
1523
+ "name": "channel_id",
1524
+ "type": "text",
1525
+ "primaryKey": false,
1526
+ "notNull": true
1527
+ },
1528
+ "user_id": {
1529
+ "name": "user_id",
1530
+ "type": "text",
1531
+ "primaryKey": false,
1532
+ "notNull": true
1533
+ },
1534
+ "status": {
1535
+ "name": "status",
1536
+ "type": "video_scan_status",
1537
+ "typeSchema": "public",
1538
+ "primaryKey": false,
1539
+ "notNull": true,
1540
+ "default": "'pending'"
1541
+ },
1542
+ "progress": {
1543
+ "name": "progress",
1544
+ "type": "integer",
1545
+ "primaryKey": false,
1546
+ "notNull": true,
1547
+ "default": 0
1548
+ },
1549
+ "total_videos": {
1550
+ "name": "total_videos",
1551
+ "type": "integer",
1552
+ "primaryKey": false,
1553
+ "notNull": false
1554
+ },
1555
+ "scanned_videos": {
1556
+ "name": "scanned_videos",
1557
+ "type": "integer",
1558
+ "primaryKey": false,
1559
+ "notNull": true,
1560
+ "default": 0
1561
+ },
1562
+ "error_message": {
1563
+ "name": "error_message",
1564
+ "type": "text",
1565
+ "primaryKey": false,
1566
+ "notNull": false
1567
+ },
1568
+ "inngest_run_id": {
1569
+ "name": "inngest_run_id",
1570
+ "type": "text",
1571
+ "primaryKey": false,
1572
+ "notNull": false
1573
+ },
1574
+ "created_at": {
1575
+ "name": "created_at",
1576
+ "type": "timestamp (3) with time zone",
1577
+ "primaryKey": false,
1578
+ "notNull": true,
1579
+ "default": "now()"
1580
+ },
1581
+ "updated_at": {
1582
+ "name": "updated_at",
1583
+ "type": "timestamp (3) with time zone",
1584
+ "primaryKey": false,
1585
+ "notNull": true,
1586
+ "default": "now()"
1587
+ }
1588
+ },
1589
+ "indexes": {},
1590
+ "foreignKeys": {
1591
+ "video_scan_jobs_channel_id_youtube_channels_id_fk": {
1592
+ "name": "video_scan_jobs_channel_id_youtube_channels_id_fk",
1593
+ "tableFrom": "video_scan_jobs",
1594
+ "tableTo": "youtube_channels",
1595
+ "columnsFrom": [
1596
+ "channel_id"
1597
+ ],
1598
+ "columnsTo": [
1599
+ "id"
1600
+ ],
1601
+ "onDelete": "cascade",
1602
+ "onUpdate": "no action"
1603
+ },
1604
+ "video_scan_jobs_user_id_users_id_fk": {
1605
+ "name": "video_scan_jobs_user_id_users_id_fk",
1606
+ "tableFrom": "video_scan_jobs",
1607
+ "tableTo": "users",
1608
+ "columnsFrom": [
1609
+ "user_id"
1610
+ ],
1611
+ "columnsTo": [
1612
+ "id"
1613
+ ],
1614
+ "onDelete": "no action",
1615
+ "onUpdate": "no action"
1616
+ }
1617
+ },
1618
+ "compositePrimaryKeys": {},
1619
+ "uniqueConstraints": {},
1620
+ "policies": {},
1621
+ "checkConstraints": {},
1622
+ "isRLSEnabled": false
1623
+ },
1624
+ "public.youtube_channels": {
1625
+ "name": "youtube_channels",
1626
+ "schema": "",
1627
+ "columns": {
1628
+ "id": {
1629
+ "name": "id",
1630
+ "type": "text",
1631
+ "primaryKey": true,
1632
+ "notNull": true
1633
+ },
1634
+ "creator_id": {
1635
+ "name": "creator_id",
1636
+ "type": "text",
1637
+ "primaryKey": false,
1638
+ "notNull": true
1639
+ },
1640
+ "channel_id": {
1641
+ "name": "channel_id",
1642
+ "type": "text",
1643
+ "primaryKey": false,
1644
+ "notNull": true
1645
+ },
1646
+ "channel_name": {
1647
+ "name": "channel_name",
1648
+ "type": "text",
1649
+ "primaryKey": false,
1650
+ "notNull": true
1651
+ },
1652
+ "creator_slug": {
1653
+ "name": "creator_slug",
1654
+ "type": "text",
1655
+ "primaryKey": false,
1656
+ "notNull": true
1657
+ },
1658
+ "subscriber_count": {
1659
+ "name": "subscriber_count",
1660
+ "type": "integer",
1661
+ "primaryKey": false,
1662
+ "notNull": false
1663
+ },
1664
+ "thumbnail_url": {
1665
+ "name": "thumbnail_url",
1666
+ "type": "text",
1667
+ "primaryKey": false,
1668
+ "notNull": false
1669
+ },
1670
+ "connected_at": {
1671
+ "name": "connected_at",
1672
+ "type": "timestamp",
1673
+ "primaryKey": false,
1674
+ "notNull": true,
1675
+ "default": "now()"
1676
+ },
1677
+ "sync_status": {
1678
+ "name": "sync_status",
1679
+ "type": "sync_status",
1680
+ "typeSchema": "public",
1681
+ "primaryKey": false,
1682
+ "notNull": true,
1683
+ "default": "'idle'"
1684
+ }
1685
+ },
1686
+ "indexes": {
1687
+ "creator_channel_idx": {
1688
+ "name": "creator_channel_idx",
1689
+ "columns": [
1690
+ {
1691
+ "expression": "creator_id",
1692
+ "isExpression": false,
1693
+ "asc": true,
1694
+ "nulls": "last"
1695
+ },
1696
+ {
1697
+ "expression": "channel_id",
1698
+ "isExpression": false,
1699
+ "asc": true,
1700
+ "nulls": "last"
1701
+ }
1702
+ ],
1703
+ "isUnique": true,
1704
+ "concurrently": false,
1705
+ "method": "btree",
1706
+ "with": {}
1707
+ }
1708
+ },
1709
+ "foreignKeys": {
1710
+ "youtube_channels_creator_id_users_id_fk": {
1711
+ "name": "youtube_channels_creator_id_users_id_fk",
1712
+ "tableFrom": "youtube_channels",
1713
+ "tableTo": "users",
1714
+ "columnsFrom": [
1715
+ "creator_id"
1716
+ ],
1717
+ "columnsTo": [
1718
+ "id"
1719
+ ],
1720
+ "onDelete": "no action",
1721
+ "onUpdate": "no action"
1722
+ }
1723
+ },
1724
+ "compositePrimaryKeys": {},
1725
+ "uniqueConstraints": {
1726
+ "youtube_channels_creator_slug_unique": {
1727
+ "name": "youtube_channels_creator_slug_unique",
1728
+ "nullsNotDistinct": false,
1729
+ "columns": [
1730
+ "creator_slug"
1731
+ ]
1732
+ }
1733
+ },
1734
+ "policies": {},
1735
+ "checkConstraints": {},
1736
+ "isRLSEnabled": false
1737
+ },
1738
+ "public.youtube_videos": {
1739
+ "name": "youtube_videos",
1740
+ "schema": "",
1741
+ "columns": {
1742
+ "id": {
1743
+ "name": "id",
1744
+ "type": "text",
1745
+ "primaryKey": true,
1746
+ "notNull": true
1747
+ },
1748
+ "channel_id": {
1749
+ "name": "channel_id",
1750
+ "type": "text",
1751
+ "primaryKey": false,
1752
+ "notNull": true
1753
+ },
1754
+ "video_id": {
1755
+ "name": "video_id",
1756
+ "type": "text",
1757
+ "primaryKey": false,
1758
+ "notNull": true
1759
+ },
1760
+ "title": {
1761
+ "name": "title",
1762
+ "type": "text",
1763
+ "primaryKey": false,
1764
+ "notNull": true
1765
+ },
1766
+ "description": {
1767
+ "name": "description",
1768
+ "type": "text",
1769
+ "primaryKey": false,
1770
+ "notNull": false
1771
+ },
1772
+ "thumbnail_url": {
1773
+ "name": "thumbnail_url",
1774
+ "type": "text",
1775
+ "primaryKey": false,
1776
+ "notNull": false
1777
+ },
1778
+ "duration": {
1779
+ "name": "duration",
1780
+ "type": "text",
1781
+ "primaryKey": false,
1782
+ "notNull": false
1783
+ },
1784
+ "view_count": {
1785
+ "name": "view_count",
1786
+ "type": "integer",
1787
+ "primaryKey": false,
1788
+ "notNull": false
1789
+ },
1790
+ "availability_status": {
1791
+ "name": "availability_status",
1792
+ "type": "text",
1793
+ "primaryKey": false,
1794
+ "notNull": true,
1795
+ "default": "'unknown'"
1796
+ },
1797
+ "scan_status": {
1798
+ "name": "scan_status",
1799
+ "type": "video_scan_status",
1800
+ "typeSchema": "public",
1801
+ "primaryKey": false,
1802
+ "notNull": true,
1803
+ "default": "'pending'"
1804
+ },
1805
+ "published_at": {
1806
+ "name": "published_at",
1807
+ "type": "timestamp (3) with time zone",
1808
+ "primaryKey": false,
1809
+ "notNull": false
1810
+ },
1811
+ "created_at": {
1812
+ "name": "created_at",
1813
+ "type": "timestamp (3) with time zone",
1814
+ "primaryKey": false,
1815
+ "notNull": true,
1816
+ "default": "now()"
1817
+ },
1818
+ "updated_at": {
1819
+ "name": "updated_at",
1820
+ "type": "timestamp (3) with time zone",
1821
+ "primaryKey": false,
1822
+ "notNull": true,
1823
+ "default": "now()"
1824
+ }
1825
+ },
1826
+ "indexes": {
1827
+ "channel_video_idx": {
1828
+ "name": "channel_video_idx",
1829
+ "columns": [
1830
+ {
1831
+ "expression": "channel_id",
1832
+ "isExpression": false,
1833
+ "asc": true,
1834
+ "nulls": "last"
1835
+ },
1836
+ {
1837
+ "expression": "video_id",
1838
+ "isExpression": false,
1839
+ "asc": true,
1840
+ "nulls": "last"
1841
+ }
1842
+ ],
1843
+ "isUnique": true,
1844
+ "concurrently": false,
1845
+ "method": "btree",
1846
+ "with": {}
1847
+ }
1848
+ },
1849
+ "foreignKeys": {
1850
+ "youtube_videos_channel_id_youtube_channels_id_fk": {
1851
+ "name": "youtube_videos_channel_id_youtube_channels_id_fk",
1852
+ "tableFrom": "youtube_videos",
1853
+ "tableTo": "youtube_channels",
1854
+ "columnsFrom": [
1855
+ "channel_id"
1856
+ ],
1857
+ "columnsTo": [
1858
+ "id"
1859
+ ],
1860
+ "onDelete": "cascade",
1861
+ "onUpdate": "no action"
1862
+ }
1863
+ },
1864
+ "compositePrimaryKeys": {},
1865
+ "uniqueConstraints": {},
1866
+ "policies": {},
1867
+ "checkConstraints": {},
1868
+ "isRLSEnabled": false
1869
+ }
1870
+ },
1871
+ "enums": {
1872
+ "public.admin_moderation_type": {
1873
+ "name": "admin_moderation_type",
1874
+ "schema": "public",
1875
+ "values": [
1876
+ "corrected",
1877
+ "marked_incorrect"
1878
+ ]
1879
+ },
1880
+ "public.affiliate_submission_status": {
1881
+ "name": "affiliate_submission_status",
1882
+ "schema": "public",
1883
+ "values": [
1884
+ "PENDING",
1885
+ "APPROVED",
1886
+ "REJECTED"
1887
+ ]
1888
+ },
1889
+ "public.availability_status": {
1890
+ "name": "availability_status",
1891
+ "schema": "public",
1892
+ "values": [
1893
+ "IN_STOCK",
1894
+ "SOLD_OUT",
1895
+ "DISCONTINUED"
1896
+ ]
1897
+ },
1898
+ "public.bounty_pledge_status": {
1899
+ "name": "bounty_pledge_status",
1900
+ "schema": "public",
1901
+ "values": [
1902
+ "ACTIVE",
1903
+ "WITHDRAWN",
1904
+ "FULFILLED"
1905
+ ]
1906
+ },
1907
+ "public.detection_status": {
1908
+ "name": "detection_status",
1909
+ "schema": "public",
1910
+ "values": [
1911
+ "pending_review",
1912
+ "approved",
1913
+ "rejected",
1914
+ "flagged"
1915
+ ]
1916
+ },
1917
+ "public.interest_pledge_status": {
1918
+ "name": "interest_pledge_status",
1919
+ "schema": "public",
1920
+ "values": [
1921
+ "ACTIVE",
1922
+ "NOTIFIED",
1923
+ "EXPIRED"
1924
+ ]
1925
+ },
1926
+ "public.link_status": {
1927
+ "name": "link_status",
1928
+ "schema": "public",
1929
+ "values": [
1930
+ "ACTIVE",
1931
+ "CHECKING",
1932
+ "BROKEN"
1933
+ ]
1934
+ },
1935
+ "public.marketplace_type": {
1936
+ "name": "marketplace_type",
1937
+ "schema": "public",
1938
+ "values": [
1939
+ "amazon",
1940
+ "ebay",
1941
+ "etsy"
1942
+ ]
1943
+ },
1944
+ "public.moderation_status": {
1945
+ "name": "moderation_status",
1946
+ "schema": "public",
1947
+ "values": [
1948
+ "PENDING",
1949
+ "APPROVED",
1950
+ "REJECTED"
1951
+ ]
1952
+ },
1953
+ "public.object_category": {
1954
+ "name": "object_category",
1955
+ "schema": "public",
1956
+ "values": [
1957
+ "Tech",
1958
+ "Fashion",
1959
+ "Furniture",
1960
+ "Audio",
1961
+ "Other",
1962
+ "Person",
1963
+ "Apparel"
1964
+ ]
1965
+ },
1966
+ "public.reason_code": {
1967
+ "name": "reason_code",
1968
+ "schema": "public",
1969
+ "values": [
1970
+ "wrong_object",
1971
+ "wrong_category",
1972
+ "false_positive",
1973
+ "unclear_image",
1974
+ "duplicate",
1975
+ "out_of_scope"
1976
+ ]
1977
+ },
1978
+ "public.request_status": {
1979
+ "name": "request_status",
1980
+ "schema": "public",
1981
+ "values": [
1982
+ "PENDING",
1983
+ "FULFILLED",
1984
+ "DISMISSED"
1985
+ ]
1986
+ },
1987
+ "public.sync_status": {
1988
+ "name": "sync_status",
1989
+ "schema": "public",
1990
+ "values": [
1991
+ "idle",
1992
+ "syncing",
1993
+ "errored"
1994
+ ]
1995
+ },
1996
+ "public.video_scan_status": {
1997
+ "name": "video_scan_status",
1998
+ "schema": "public",
1999
+ "values": [
2000
+ "pending",
2001
+ "in_progress",
2002
+ "completed",
2003
+ "failed",
2004
+ "idle"
2005
+ ]
2006
+ }
2007
+ },
2008
+ "schemas": {},
2009
+ "sequences": {},
2010
+ "roles": {},
2011
+ "policies": {},
2012
+ "views": {},
2013
+ "_meta": {
2014
+ "columns": {},
2015
+ "schemas": {},
2016
+ "tables": {}
2017
+ }
2018
+ }
drizzle/meta/_journal.json CHANGED
@@ -78,6 +78,13 @@
78
  "when": 1770169651961,
79
  "tag": "0010_white_jamie_braddock",
80
  "breakpoints": true
 
 
 
 
 
 
 
81
  }
82
  ]
83
  }
 
78
  "when": 1770169651961,
79
  "tag": "0010_white_jamie_braddock",
80
  "breakpoints": true
81
+ },
82
+ {
83
+ "idx": 11,
84
+ "version": "7",
85
+ "when": 1770570101895,
86
+ "tag": "0011_heavy_morbius",
87
+ "breakpoints": true
88
  }
89
  ]
90
  }
src/app/dashboard/page.tsx CHANGED
@@ -49,6 +49,17 @@ export default async function DashboardPage({
49
  const [channels, googleAccount, pendingCount, requestCount] = await Promise.all([
50
  db.query.youtubeChannels.findMany({
51
  where: (channels, { eq }) => eq(channels.creatorId, session.user.id),
 
 
 
 
 
 
 
 
 
 
 
52
  }),
53
  db.query.accounts.findFirst({
54
  where: (accounts, { and, eq }) => and(
@@ -115,6 +126,8 @@ export default async function DashboardPage({
115
  }
116
  }
117
 
 
 
118
  return (
119
  <div className="max-w-7xl mx-auto px-4 py-8 space-y-8 animate-in fade-in duration-500">
120
  <div className="flex justify-between items-start">
@@ -132,9 +145,6 @@ export default async function DashboardPage({
132
  {/* Key Metrics Row */}
133
  <KeyMetricsRow />
134
 
135
- {/* Top Evergreen Videos */}
136
- <TopEvergreenVideos creatorSlug={channels.length > 0 ? channels[0].creatorSlug : undefined} />
137
-
138
  {/* Quick nav */}
139
  <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
140
  <Link href="/dashboard/moderation" className="group">
@@ -153,22 +163,6 @@ export default async function DashboardPage({
153
  </CardContent>
154
  </Card>
155
  </Link>
156
- <Link href="/dashboard/requests" className="group">
157
- <Card className="glass border-white/10 hover:bg-white/[0.07] transition-all duration-300 cursor-pointer">
158
- <CardContent className="flex items-center gap-4 p-4">
159
- <div className="p-2.5 bg-white/5 rounded-lg border border-white/10 group-hover:scale-110 transition-transform">
160
- <Mail className="h-5 w-5 text-primary" />
161
- </div>
162
- <div className="flex-1 min-w-0">
163
- <p className="text-sm font-medium">Viewer Requests</p>
164
- <p className="text-xs text-muted-foreground">What fans want to see</p>
165
- </div>
166
- {requestCount > 0 && (
167
- <Badge className="bg-primary text-white text-xs">{requestCount}</Badge>
168
- )}
169
- </CardContent>
170
- </Card>
171
- </Link>
172
  {channels.length > 0 && channels[0].creatorSlug && (
173
  <Link href={`/vault/${channels[0].creatorSlug}`} className="group">
174
  <Card className="glass border-white/10 hover:bg-white/[0.07] transition-all duration-300 cursor-pointer">
@@ -189,8 +183,11 @@ export default async function DashboardPage({
189
  )}
190
  </div>
191
 
 
 
 
192
  <YouTubeConnectSection
193
- channels={channels}
194
  isGoogleLinked={!!googleAccount?.accessToken}
195
  videosByChannel={videosByChannel}
196
  />
 
49
  const [channels, googleAccount, pendingCount, requestCount] = await Promise.all([
50
  db.query.youtubeChannels.findMany({
51
  where: (channels, { eq }) => eq(channels.creatorId, session.user.id),
52
+ columns: {
53
+ id: true,
54
+ channelId: true,
55
+ channelName: true,
56
+ subscriberCount: true,
57
+ thumbnailUrl: true,
58
+ syncStatus: true,
59
+ connectedAt: true,
60
+ channelType: true,
61
+ creatorSlug: true,
62
+ }
63
  }),
64
  db.query.accounts.findFirst({
65
  where: (accounts, { and, eq }) => and(
 
126
  }
127
  }
128
 
129
+ const verifiedChannels = channels.filter(c => c.channelType === 'verified');
130
+
131
  return (
132
  <div className="max-w-7xl mx-auto px-4 py-8 space-y-8 animate-in fade-in duration-500">
133
  <div className="flex justify-between items-start">
 
145
  {/* Key Metrics Row */}
146
  <KeyMetricsRow />
147
 
 
 
 
148
  {/* Quick nav */}
149
  <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
150
  <Link href="/dashboard/moderation" className="group">
 
163
  </CardContent>
164
  </Card>
165
  </Link>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  {channels.length > 0 && channels[0].creatorSlug && (
167
  <Link href={`/vault/${channels[0].creatorSlug}`} className="group">
168
  <Card className="glass border-white/10 hover:bg-white/[0.07] transition-all duration-300 cursor-pointer">
 
183
  )}
184
  </div>
185
 
186
+ {/* Top Evergreen Videos */}
187
+ <TopEvergreenVideos creatorSlug={channels.length > 0 ? channels[0].creatorSlug : undefined} />
188
+
189
  <YouTubeConnectSection
190
+ channels={verifiedChannels}
191
  isGoogleLinked={!!googleAccount?.accessToken}
192
  videosByChannel={videosByChannel}
193
  />
src/app/dashboard/requests/opportunity-list.tsx ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { Card, CardContent } from '@/components/ui/card';
5
+ import { Badge } from '@/components/ui/badge';
6
+ import { Button } from '@/components/ui/button';
7
+ import { Users, Link2, ExternalLink, ChevronRight, MessageCircle, ArrowUpRight } from 'lucide-react';
8
+ import Image from 'next/image';
9
+ import Link from 'next/link';
10
+ import { AffiliateSubmissionDialog } from '@/features/vault/components/affiliate-submission-dialog';
11
+ import { ProposalManager } from './proposal-manager';
12
+ import { RequestManager } from './request-manager';
13
+
14
+ interface Opportunity {
15
+ id: string;
16
+ type: 'DETECTION' | 'REQUEST' | 'PROPOSAL_ONLY';
17
+ name: string;
18
+ thumbnail: string | null;
19
+ videoId: string | null;
20
+ videoTitle: string;
21
+ stats: {
22
+ interestClicks: number;
23
+ manualRequests: number;
24
+ totalDemand: number;
25
+ };
26
+ proposals: any[];
27
+ requests: any[];
28
+ }
29
+
30
+ interface OpportunityListProps {
31
+ opportunities: Opportunity[];
32
+ videos: { id: string; title: string }[];
33
+ creatorId: string;
34
+ }
35
+
36
+ export function OpportunityList({ opportunities, videos, creatorId }: OpportunityListProps) {
37
+ return (
38
+ <div className="space-y-6">
39
+ {opportunities.map((opt) => (
40
+ <OpportunityCard key={opt.id} opportunity={opt} videos={videos} creatorId={creatorId} />
41
+ ))}
42
+ </div>
43
+ );
44
+ }
45
+
46
+ function OpportunityCard({ opportunity, videos, creatorId }: { opportunity: Opportunity; videos: any[]; creatorId: string }) {
47
+ const [isExpanded, setIsExpanded] = React.useState(false);
48
+
49
+ const hasProposals = opportunity.proposals.length > 0;
50
+ const hasRequests = opportunity.requests.length > 0;
51
+
52
+ return (
53
+ <Card className={`glass border-white/10 overflow-hidden transition-all duration-300 ${isExpanded ? 'ring-1 ring-primary/40 shadow-lg shadow-primary/5' : 'hover:border-white/20'}`}>
54
+ <CardContent className="p-0">
55
+ <div className="p-6 flex items-start gap-6">
56
+ {/* Visual Asset */}
57
+ <div className="relative flex-shrink-0 w-24 h-24 rounded-xl overflow-hidden bg-white/5 border border-white/10">
58
+ {opportunity.thumbnail ? (
59
+ <Image
60
+ src={opportunity.thumbnail}
61
+ alt={opportunity.name}
62
+ fill
63
+ className="object-cover"
64
+ />
65
+ ) : (
66
+ <div className="w-full h-full flex items-center justify-center text-gray-500 bg-gradient-to-br from-white/5 to-white/[0.02]">
67
+ <Link2 className="h-8 w-8 opacity-20" />
68
+ </div>
69
+ )}
70
+ <Badge className="absolute top-1 right-1 bg-black/60 backdrop-blur-md text-[10px] py-0 px-1.5 border-none">
71
+ {opportunity.type === 'DETECTION' ? 'Ref' : opportunity.type === 'REQUEST' ? 'Fan' : 'Prop'}
72
+ </Badge>
73
+ </div>
74
+
75
+ {/* Info */}
76
+ <div className="flex-1 min-w-0">
77
+ <div className="flex items-start justify-between">
78
+ <div>
79
+ <h3 className="text-xl font-bold text-gray-100 truncate flex items-center gap-2">
80
+ {opportunity.name}
81
+ {hasProposals && (
82
+ <Badge className="bg-green-500/10 text-green-400 border-green-500/20 text-[10px] animate-pulse">
83
+ {opportunity.proposals.length} Link Ready
84
+ </Badge>
85
+ )}
86
+ </h3>
87
+ <p className="text-sm text-gray-400 truncate mt-0.5 flex items-center gap-1.5">
88
+ <span className="opacity-60">{opportunity.videoTitle}</span>
89
+ </p>
90
+ </div>
91
+
92
+ <div className="flex items-center gap-2">
93
+ <div className="text-right mr-4">
94
+ <div className="text-2xl font-black text-orange-400 flex items-center justify-end gap-1.5">
95
+ {opportunity.stats.totalDemand}
96
+ <ArrowUpRight className="h-4 w-4 text-orange-400/50" />
97
+ </div>
98
+ <p className="text-[10px] font-bold uppercase tracking-tighter text-muted-foreground">Demand Signals</p>
99
+ </div>
100
+ <Button
101
+ variant="ghost"
102
+ size="sm"
103
+ onClick={() => setIsExpanded(!isExpanded)}
104
+ className="h-10 w-10 p-0 rounded-full hover:bg-white/5"
105
+ >
106
+ <ChevronRight className={`h-5 w-5 transition-transform duration-300 ${isExpanded ? 'rotate-90 text-primary' : 'text-muted-foreground'}`} />
107
+ </Button>
108
+ </div>
109
+ </div>
110
+
111
+ <div className="flex items-center gap-3 mt-4">
112
+ <Badge variant="secondary" className="bg-white/5 text-gray-300 border-white/10 rounded-md py-1 px-2.5">
113
+ <Users className="h-3.5 w-3.5 mr-1.5 text-orange-400/70" />
114
+ {opportunity.stats.interestClicks} Clicks
115
+ </Badge>
116
+ <Badge variant="secondary" className="bg-white/5 text-gray-300 border-white/10 rounded-md py-1 px-2.5">
117
+ <MessageCircle className="h-3.5 w-3.5 mr-1.5 text-blue-400/70" />
118
+ {opportunity.stats.manualRequests} Requests
119
+ </Badge>
120
+
121
+ <div className="flex-1" />
122
+
123
+ <div className="flex items-center gap-2">
124
+ {opportunity.videoId && (
125
+ <AffiliateSubmissionDialog
126
+ videoId={opportunity.videoId}
127
+ creatorId={creatorId}
128
+ objectId={opportunity.type === 'DETECTION' ? opportunity.id : undefined}
129
+ objectName={opportunity.name}
130
+ trigger={
131
+ <Button size="sm" variant="outline" className="h-9 border-primary/20 hover:border-primary/40 bg-primary/5 hover:bg-primary/10">
132
+ <Link2 className="h-4 w-4 mr-2" />
133
+ Add Link
134
+ </Button>
135
+ }
136
+ />
137
+ )}
138
+ <Link href={`/dashboard/moderation?status=approved&highlight=${opportunity.id}`}>
139
+ <Button size="sm" variant="ghost" className="h-9 text-muted-foreground text-xs">
140
+ Details
141
+ </Button>
142
+ </Link>
143
+ </div>
144
+ </div>
145
+ </div>
146
+ </div>
147
+
148
+ {/* Expanded Details */}
149
+ {isExpanded && (
150
+ <div className="border-t border-white/10 bg-black/20 animate-in slide-in-from-top-2 duration-300">
151
+ {opportunity.proposals.length > 0 && (
152
+ <div className="p-6 border-b border-white/5">
153
+ <div className="mb-4">
154
+ <h4 className="text-xs font-bold uppercase tracking-widest text-green-400/80 mb-1">Fan Sourced Links</h4>
155
+ <p className="text-[10px] text-muted-foreground">Community members found these links for this item.</p>
156
+ </div>
157
+ <ProposalManager proposals={opportunity.proposals} />
158
+ </div>
159
+ )}
160
+
161
+ {opportunity.requests.length > 0 && (
162
+ <div className="p-6">
163
+ <div className="mb-4">
164
+ <h4 className="text-xs font-bold uppercase tracking-widest text-blue-400/80 mb-1">Direct Fan Requests</h4>
165
+ <p className="text-[10px] text-muted-foreground">Specific questions or notes about this item from your audience.</p>
166
+ </div>
167
+ <RequestManager requests={opportunity.requests as any} videos={videos} />
168
+ </div>
169
+ )}
170
+
171
+ {!hasProposals && !hasRequests && (
172
+ <div className="p-12 text-center">
173
+ <p className="text-sm text-muted-foreground italic">No detailed reports for this opportunity yet.</p>
174
+ </div>
175
+ )}
176
+ </div>
177
+ )}
178
+ </CardContent>
179
+ </Card>
180
+ );
181
+ }
src/app/dashboard/requests/page.tsx CHANGED
@@ -20,6 +20,7 @@ import { ProposalManager } from './proposal-manager';
20
  import Link from 'next/link';
21
  import { UserNav } from '@/components/auth/user-nav';
22
  import { Badge } from '@/components/ui/badge';
 
23
 
24
  export default async function RequestsPage() {
25
  const session = await auth.api.getSession({
@@ -99,6 +100,7 @@ export default async function RequestsPage() {
99
  status: affiliateProposals.status,
100
  createdAt: affiliateProposals.createdAt,
101
  videoTitle: youtubeVideos.title,
 
102
  })
103
  .from(affiliateProposals)
104
  .innerJoin(youtubeVideos, eq(affiliateProposals.videoId, youtubeVideos.id))
@@ -114,6 +116,73 @@ export default async function RequestsPage() {
114
  ...p,
115
  status: p.status as 'PENDING' | 'APPROVED' | 'REJECTED'
116
  }));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
 
118
  return (
119
  <div className="container mx-auto py-8 px-4 space-y-8 animate-in fade-in duration-500">
@@ -137,9 +206,9 @@ export default async function RequestsPage() {
137
  </div>
138
 
139
  <div className="space-y-2">
140
- <h1 className="text-4xl font-extrabold tracking-tight">Viewer Requests</h1>
141
  <p className="text-muted-foreground text-lg">
142
- Manage products requested by your fans and add affiliate links to detected products.
143
  </p>
144
  </div>
145
 
@@ -148,41 +217,31 @@ export default async function RequestsPage() {
148
  <Card className="glass border-white/10">
149
  <CardContent className="pt-6">
150
  <div className="text-2xl font-bold text-gray-200">
151
- {productsAwaitingLinks.length}
152
  </div>
153
- <p className="text-sm text-gray-400 mt-1">Products Awaiting Links</p>
154
  </CardContent>
155
  </Card>
156
  <Card className="glass border-white/10">
157
  <CardContent className="pt-6">
158
- <div className="text-2xl font-bold text-gray-200">
159
- {requests.length}
160
  </div>
161
- <p className="text-sm text-gray-400 mt-1">Product Requests</p>
162
  </CardContent>
163
  </Card>
164
  <Card className="glass border-white/10">
165
  <CardContent className="pt-6">
166
- <div className="text-2xl font-bold text-orange-400">
167
- {productsAwaitingLinks.length + requests.length}
168
  </div>
169
- <p className="text-sm text-gray-400 mt-1">Total Pending Demand</p>
170
  </CardContent>
171
  </Card>
172
  </div>
173
 
174
- {/* Proposals Section */}
175
- {proposals.length > 0 && (
176
- <ProposalManager proposals={proposals} />
177
- )}
178
-
179
- {/* Products Awaiting Links Section */}
180
- {productsAwaitingLinks.length > 0 && (
181
- <ProductsAwaitingLinks products={productsAwaitingLinks} />
182
- )}
183
-
184
- {/* Product Requests Section */}
185
- {requests.length === 0 && productsAwaitingLinks.length === 0 ? (
186
  <Card className="bg-card/40 border-dashed border-white/10 py-20">
187
  <CardContent className="flex flex-col items-center text-center">
188
  <div className="h-20 w-20 rounded-full bg-primary/10 flex items-center justify-center mb-6">
@@ -191,13 +250,24 @@ export default async function RequestsPage() {
191
  <h3 className="text-2xl font-semibold text-white">No pending demand yet</h3>
192
  <p className="text-muted-foreground max-w-md mt-2 text-sm">
193
  As viewers explore your vault, their requests and interests will appear here.
194
- It's a great way to discover what your fans are actually looking for!
195
  </p>
196
  </CardContent>
197
  </Card>
198
- ) : requests.length > 0 ? (
199
- <RequestManager requests={requests as any} videos={videos} />
200
- ) : null}
 
 
 
 
 
 
 
 
 
 
 
 
201
  </div>
202
  );
203
  }
 
20
  import Link from 'next/link';
21
  import { UserNav } from '@/components/auth/user-nav';
22
  import { Badge } from '@/components/ui/badge';
23
+ import { OpportunityList } from './opportunity-list';
24
 
25
  export default async function RequestsPage() {
26
  const session = await auth.api.getSession({
 
100
  status: affiliateProposals.status,
101
  createdAt: affiliateProposals.createdAt,
102
  videoTitle: youtubeVideos.title,
103
+ objectId: affiliateProposals.objectId,
104
  })
105
  .from(affiliateProposals)
106
  .innerJoin(youtubeVideos, eq(affiliateProposals.videoId, youtubeVideos.id))
 
116
  ...p,
117
  status: p.status as 'PENDING' | 'APPROVED' | 'REJECTED'
118
  }));
119
+ // Unified Opportunity Center Logic
120
+ const opportunitiesMap = new Map<string, any>();
121
+
122
+ // 1. Detections awaiting links (Demand from clicks)
123
+ productsAwaitingLinks.forEach(p => {
124
+ opportunitiesMap.set(p.id, {
125
+ id: p.id,
126
+ type: 'DETECTION',
127
+ name: p.objectName,
128
+ thumbnail: p.thumbnailUrl,
129
+ videoId: p.videoId,
130
+ videoTitle: p.videoTitle,
131
+ stats: {
132
+ interestClicks: p.pledgeCount,
133
+ manualRequests: 0,
134
+ totalDemand: p.pledgeCount,
135
+ },
136
+ proposals: [],
137
+ requests: [],
138
+ });
139
+ });
140
+
141
+ // 2. Attach proposals to detections or create new sourcing units
142
+ proposals.forEach(p => {
143
+ if (p.objectId && opportunitiesMap.has(p.objectId)) {
144
+ opportunitiesMap.get(p.objectId).proposals.push(p);
145
+ } else {
146
+ const propId = `proposal-${p.id}`;
147
+ opportunitiesMap.set(propId, {
148
+ id: p.id,
149
+ type: 'PROPOSAL_ONLY',
150
+ name: p.productName,
151
+ thumbnail: null,
152
+ videoId: (p as any).videoId || null,
153
+ videoTitle: p.videoTitle,
154
+ stats: {
155
+ interestClicks: 0,
156
+ manualRequests: 0,
157
+ totalDemand: 0,
158
+ },
159
+ proposals: [p],
160
+ requests: [],
161
+ });
162
+ }
163
+ });
164
+
165
+ // 3. Manual Requests (Demand from forms)
166
+ requests.forEach(r => {
167
+ const reqId = `request-${r.id}`;
168
+ opportunitiesMap.set(reqId, {
169
+ id: r.id,
170
+ type: 'REQUEST',
171
+ name: r.note,
172
+ thumbnail: r.imageUrl || null,
173
+ videoId: r.videoId,
174
+ videoTitle: r.video.title,
175
+ stats: {
176
+ interestClicks: 0,
177
+ manualRequests: 1,
178
+ totalDemand: 1,
179
+ },
180
+ proposals: [],
181
+ requests: [r],
182
+ });
183
+ });
184
+
185
+ const opportunities = Array.from(opportunitiesMap.values()).sort((a, b) => b.stats.totalDemand - a.stats.totalDemand);
186
 
187
  return (
188
  <div className="container mx-auto py-8 px-4 space-y-8 animate-in fade-in duration-500">
 
206
  </div>
207
 
208
  <div className="space-y-2">
209
+ <h1 className="text-4xl font-extrabold tracking-tight">Opportunity Center</h1>
210
  <p className="text-muted-foreground text-lg">
211
+ Fulfill fan demand and manage community link proposals to monetize your content.
212
  </p>
213
  </div>
214
 
 
217
  <Card className="glass border-white/10">
218
  <CardContent className="pt-6">
219
  <div className="text-2xl font-bold text-gray-200">
220
+ {opportunities.length}
221
  </div>
222
+ <p className="text-sm text-gray-400 mt-1">Untapped Opportunities</p>
223
  </CardContent>
224
  </Card>
225
  <Card className="glass border-white/10">
226
  <CardContent className="pt-6">
227
+ <div className="text-2xl font-bold text-orange-400">
228
+ {productsAwaitingLinks.reduce((acc, p) => acc + p.pledgeCount, 0) + (requests?.length || 0)}
229
  </div>
230
+ <p className="text-sm text-gray-400 mt-1">Active Fan Demand</p>
231
  </CardContent>
232
  </Card>
233
  <Card className="glass border-white/10">
234
  <CardContent className="pt-6">
235
+ <div className="text-2xl font-bold text-blue-400">
236
+ {proposals.length}
237
  </div>
238
+ <p className="text-sm text-gray-400 mt-1">Sourcing Proposals</p>
239
  </CardContent>
240
  </Card>
241
  </div>
242
 
243
+ {/* Opportunity Hub Implementation */}
244
+ {opportunities.length === 0 ? (
 
 
 
 
 
 
 
 
 
 
245
  <Card className="bg-card/40 border-dashed border-white/10 py-20">
246
  <CardContent className="flex flex-col items-center text-center">
247
  <div className="h-20 w-20 rounded-full bg-primary/10 flex items-center justify-center mb-6">
 
250
  <h3 className="text-2xl font-semibold text-white">No pending demand yet</h3>
251
  <p className="text-muted-foreground max-w-md mt-2 text-sm">
252
  As viewers explore your vault, their requests and interests will appear here.
 
253
  </p>
254
  </CardContent>
255
  </Card>
256
+ ) : (
257
+ <div className="space-y-6">
258
+ <div className="flex items-center justify-between mb-2">
259
+ <h2 className="text-sm font-bold uppercase tracking-widest text-muted-foreground">Active Opportunities</h2>
260
+ <Badge variant="outline" className="text-[10px] text-muted-foreground border-white/10">
261
+ Sorted by High Interest
262
+ </Badge>
263
+ </div>
264
+ <OpportunityList
265
+ opportunities={opportunities}
266
+ videos={videos}
267
+ creatorId={session.user.id}
268
+ />
269
+ </div>
270
+ )}
271
  </div>
272
  );
273
  }
src/features/analytics/components/key-metrics-row.tsx CHANGED
@@ -80,12 +80,12 @@ export function KeyMetricsRow() {
80
  </Card>
81
  </Link>
82
 
83
- {/* Pending Demand */}
84
  <Link href="/dashboard/requests" className="block">
85
  <Card className="glass border-white/10 hover:bg-white/[0.07] transition-all duration-300 cursor-pointer hover:shadow-lg">
86
  <CardHeader className="pb-3">
87
  <div className="flex items-center justify-between">
88
- <CardTitle className="text-sm font-medium text-gray-400">Pending Demand</CardTitle>
89
  <svg
90
  xmlns="http://www.w3.org/2000/svg"
91
  viewBox="0 0 24 24"
@@ -107,7 +107,7 @@ export function KeyMetricsRow() {
107
  </span>
108
  )}
109
  <p className="text-xs text-gray-500">
110
- opportunities to add links
111
  </p>
112
  </div>
113
  </CardContent>
 
80
  </Card>
81
  </Link>
82
 
83
+ {/* Untapped Opportunities */}
84
  <Link href="/dashboard/requests" className="block">
85
  <Card className="glass border-white/10 hover:bg-white/[0.07] transition-all duration-300 cursor-pointer hover:shadow-lg">
86
  <CardHeader className="pb-3">
87
  <div className="flex items-center justify-between">
88
+ <CardTitle className="text-sm font-medium text-gray-400">Untapped Opportunities</CardTitle>
89
  <svg
90
  xmlns="http://www.w3.org/2000/svg"
91
  viewBox="0 0 24 24"
 
107
  </span>
108
  )}
109
  <p className="text-xs text-gray-500">
110
+ review to monetize demand
111
  </p>
112
  </div>
113
  </CardContent>
src/features/discovery/actions/connect-youtube.ts CHANGED
@@ -64,6 +64,7 @@ export async function connectYouTubeAction() {
64
  subscriberCount: metadata.subscriberCount,
65
  thumbnailUrl: metadata.thumbnailUrl,
66
  syncStatus: "idle" as const,
 
67
  }))
68
  ).onConflictDoUpdate({
69
  target: youtubeChannels.channelId,
@@ -73,6 +74,7 @@ export async function connectYouTubeAction() {
73
  subscriberCount: sql`EXCLUDED.subscriber_count`,
74
  thumbnailUrl: sql`EXCLUDED.thumbnail_url`,
75
  syncStatus: "idle",
 
76
  }
77
  });
78
 
 
64
  subscriberCount: metadata.subscriberCount,
65
  thumbnailUrl: metadata.thumbnailUrl,
66
  syncStatus: "idle" as const,
67
+ channelType: "verified" as const,
68
  }))
69
  ).onConflictDoUpdate({
70
  target: youtubeChannels.channelId,
 
74
  subscriberCount: sql`EXCLUDED.subscriber_count`,
75
  thumbnailUrl: sql`EXCLUDED.thumbnail_url`,
76
  syncStatus: "idle",
77
+ channelType: "verified",
78
  }
79
  });
80
 
src/features/discovery/components/youtube-connect-section.tsx CHANGED
@@ -34,6 +34,7 @@ interface YouTubeChannel {
34
  thumbnailUrl: string | null;
35
  syncStatus: 'idle' | 'syncing' | 'errored';
36
  connectedAt: Date;
 
37
  }
38
 
39
  interface VideoRow {
@@ -177,64 +178,66 @@ export function YouTubeConnectSection({
177
  </Card>
178
  ) : (
179
  <div className={cn("space-y-6", isDisconnecting === "all" && "opacity-50 pointer-events-none")}>
180
- {channels.map((channel) => {
181
- const status = statusMap[channel.syncStatus as keyof typeof statusMap] || statusMap.idle;
182
- const channelVideos = videosByChannel[channel.id] || [];
183
- return (
184
- <div key={channel.id}>
185
- <Card className="overflow-hidden glass border-white/10 hover:bg-white/5 transition-all duration-300 group">
186
- <CardHeader className="flex flex-row items-center gap-4 space-y-0 pb-4">
187
- {channel.thumbnailUrl ? (
188
- <div className="relative">
189
- <Image
190
- src={channel.thumbnailUrl}
191
- alt={channel.channelName}
192
- width={48}
193
- height={48}
194
- className="w-12 h-12 rounded-full border border-white/20 shadow-lg object-cover group-hover:scale-105 transition-transform"
195
- />
196
- <div className="absolute -bottom-1 -right-1 bg-background rounded-full p-0.5 border border-white/20">
197
- <Youtube className="h-3 w-3 text-red-600" />
 
 
 
198
  </div>
199
- </div>
200
- ) : (
201
- <div className="w-12 h-12 rounded-full bg-white/5 flex items-center justify-center border border-white/10">
202
- <Youtube className="h-6 w-6 text-red-600" />
203
- </div>
204
- )}
205
- <div className="flex-1 min-w-0">
206
- <CardTitle className="text-lg truncate font-medium">{channel.channelName}</CardTitle>
207
- <CardDescription className="flex flex-col gap-0.5 text-xs">
208
- <div className="flex items-center gap-1.5">
209
- <span className="font-semibold text-foreground">{channel.subscriberCount?.toLocaleString()}</span>
210
- <span className="text-muted-foreground">subscribers</span>
211
  </div>
212
- <span className="text-muted-foreground text-[10px]">
213
- Connected {new Date(channel.connectedAt).toLocaleDateString()}
214
- </span>
215
- </CardDescription>
216
- </div>
217
- <Badge variant={status.variant} className="gap-1 px-2 py-0 text-[10px] uppercase font-bold tracking-wider">
218
- {status.icon}
219
- {status.label}
220
- </Badge>
221
- </CardHeader>
222
- <CardContent className="pt-0 flex justify-end items-center bg-white/5 px-6 py-3 border-t border-white/10">
223
- <Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1 opacity-70 hover:opacity-100 transition-opacity" asChild>
224
- <a href={`https://youtube.com/channel/${channel.channelId}`} target="_blank" rel="noopener noreferrer">
225
- View <ExternalLink className="h-3 w-3" />
226
- </a>
227
- </Button>
228
- </CardContent>
229
- </Card>
230
- <VideoListSection
231
- channelId={channel.id}
232
- channelName={channel.channelName}
233
- videos={channelVideos}
234
- />
235
- </div>
236
- );
237
- })}
 
 
 
 
 
 
 
 
238
  </div>
239
  )}
240
  </div>
 
34
  thumbnailUrl: string | null;
35
  syncStatus: 'idle' | 'syncing' | 'errored';
36
  connectedAt: Date;
37
+ channelType: 'verified' | 'external';
38
  }
39
 
40
  interface VideoRow {
 
178
  </Card>
179
  ) : (
180
  <div className={cn("space-y-6", isDisconnecting === "all" && "opacity-50 pointer-events-none")}>
181
+ {channels
182
+ .filter(channel => channel.channelType === 'verified')
183
+ .map((channel) => {
184
+ const status = statusMap[channel.syncStatus as keyof typeof statusMap] || statusMap.idle;
185
+ const channelVideos = videosByChannel[channel.id] || [];
186
+ return (
187
+ <div key={channel.id}>
188
+ <Card className="overflow-hidden glass border-white/10 hover:bg-white/5 transition-all duration-300 group">
189
+ <CardHeader className="flex flex-row items-center gap-4 space-y-0 pb-4">
190
+ {channel.thumbnailUrl ? (
191
+ <div className="relative">
192
+ <Image
193
+ src={channel.thumbnailUrl}
194
+ alt={channel.channelName}
195
+ width={48}
196
+ height={48}
197
+ className="w-12 h-12 rounded-full border border-white/20 shadow-lg object-cover group-hover:scale-105 transition-transform"
198
+ />
199
+ <div className="absolute -bottom-1 -right-1 bg-background rounded-full p-0.5 border border-white/20">
200
+ <Youtube className="h-3 w-3 text-red-600" />
201
+ </div>
202
  </div>
203
+ ) : (
204
+ <div className="w-12 h-12 rounded-full bg-white/5 flex items-center justify-center border border-white/10">
205
+ <Youtube className="h-6 w-6 text-red-600" />
 
 
 
 
 
 
 
 
 
206
  </div>
207
+ )}
208
+ <div className="flex-1 min-w-0">
209
+ <CardTitle className="text-lg truncate font-medium">{channel.channelName}</CardTitle>
210
+ <CardDescription className="flex flex-col gap-0.5 text-xs">
211
+ <div className="flex items-center gap-1.5">
212
+ <span className="font-semibold text-foreground">{channel.subscriberCount?.toLocaleString()}</span>
213
+ <span className="text-muted-foreground">subscribers</span>
214
+ </div>
215
+ <span className="text-muted-foreground text-[10px]">
216
+ Connected {new Date(channel.connectedAt).toLocaleDateString()}
217
+ </span>
218
+ </CardDescription>
219
+ </div>
220
+ <Badge variant={status.variant} className="gap-1 px-2 py-0 text-[10px] uppercase font-bold tracking-wider">
221
+ {status.icon}
222
+ {status.label}
223
+ </Badge>
224
+ </CardHeader>
225
+ <CardContent className="pt-0 flex justify-end items-center bg-white/5 px-6 py-3 border-t border-white/10">
226
+ <Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1 opacity-70 hover:opacity-100 transition-opacity" asChild>
227
+ <a href={`https://youtube.com/channel/${channel.channelId}`} target="_blank" rel="noopener noreferrer">
228
+ View <ExternalLink className="h-3 w-3" />
229
+ </a>
230
+ </Button>
231
+ </CardContent>
232
+ </Card>
233
+ <VideoListSection
234
+ channelId={channel.id}
235
+ channelName={channel.channelName}
236
+ videos={channelVideos}
237
+ />
238
+ </div>
239
+ );
240
+ })}
241
  </div>
242
  )}
243
  </div>
src/features/interest/components/interest-capture-modal.tsx CHANGED
@@ -6,9 +6,10 @@ import { Input } from '@/components/ui/input';
6
  import { Button } from '@/components/ui/button';
7
  import { Label } from '@/components/ui/label';
8
  import { Checkbox } from '@/components/ui/checkbox';
9
- import { Loader2, CheckCircle2 } from 'lucide-react';
10
  import { createInterestPledge } from '../actions/create-interest-pledge';
11
  import type { InterestCaptureModalProps } from '../types/interest.types';
 
12
 
13
  export function InterestCaptureModal({
14
  isOpen,
@@ -16,6 +17,8 @@ export function InterestCaptureModal({
16
  marketplaceMatchId,
17
  detectedObjectId,
18
  productName,
 
 
19
  }: InterestCaptureModalProps) {
20
  const [email, setEmail] = useState('');
21
  const [hasConsent, setHasConsent] = useState(false);
@@ -25,13 +28,13 @@ export function InterestCaptureModal({
25
 
26
  const handleSubmit = async (e: React.FormEvent) => {
27
  e.preventDefault();
28
-
29
  if (!email) return;
30
 
31
  setIsSubmitting(true);
32
  setErrorMessage('');
33
 
34
- const result = await createInterestPledge(marketplaceMatchId, email, detectedObjectId || null);
35
 
36
  if (result.success) {
37
  setStatus('success');
@@ -64,31 +67,46 @@ export function InterestCaptureModal({
64
  </DialogDescription>
65
  </DialogHeader>
66
 
67
- {status === 'success' ? (
68
- <div className="flex flex-col items-center gap-4 py-6">
69
- <CheckCircle2 className="h-12 w-12 text-green-500" />
70
- <p className="text-center text-lg font-medium">
71
- We&apos;ll notify you!
72
- </p>
73
- <p className="text-center text-sm text-muted-foreground">
74
- You&apos;ll receive an email when this item becomes available.
75
- </p>
76
- <Button onClick={handleClose} className="mt-4">
77
- Close
78
- </Button>
79
- </div>
80
- ) : status === 'duplicate' ? (
81
  <div className="flex flex-col items-center gap-4 py-6">
82
- <CheckCircle2 className="h-12 w-12 text-blue-500" />
83
  <p className="text-center text-lg font-medium">
84
- Already registered!
85
  </p>
86
- <p className="text-center text-sm text-muted-foreground">
87
- You&apos;re already on the waitlist for this item.
 
 
88
  </p>
89
- <Button onClick={handleClose} className="mt-4">
90
- Close
91
- </Button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  </div>
93
  ) : (
94
  <form onSubmit={handleSubmit} className="space-y-4">
@@ -120,7 +138,7 @@ export function InterestCaptureModal({
120
  htmlFor="consent"
121
  className="text-sm font-normal cursor-pointer"
122
  >
123
- I consent to receive email notifications about this product&apos;s availability.
124
  We&apos;ll only email you about this specific item.
125
  </Label>
126
  </div>
 
6
  import { Button } from '@/components/ui/button';
7
  import { Label } from '@/components/ui/label';
8
  import { Checkbox } from '@/components/ui/checkbox';
9
+ import { Loader2, CheckCircle2, Link2 } from 'lucide-react';
10
  import { createInterestPledge } from '../actions/create-interest-pledge';
11
  import type { InterestCaptureModalProps } from '../types/interest.types';
12
+ import { AffiliateSubmissionDialog } from '@/features/vault/components/affiliate-submission-dialog';
13
 
14
  export function InterestCaptureModal({
15
  isOpen,
 
17
  marketplaceMatchId,
18
  detectedObjectId,
19
  productName,
20
+ videoId,
21
+ creatorId,
22
  }: InterestCaptureModalProps) {
23
  const [email, setEmail] = useState('');
24
  const [hasConsent, setHasConsent] = useState(false);
 
28
 
29
  const handleSubmit = async (e: React.FormEvent) => {
30
  e.preventDefault();
31
+
32
  if (!email) return;
33
 
34
  setIsSubmitting(true);
35
  setErrorMessage('');
36
 
37
+ const result = await createInterestPledge(marketplaceMatchId ?? null, email, detectedObjectId ?? null);
38
 
39
  if (result.success) {
40
  setStatus('success');
 
67
  </DialogDescription>
68
  </DialogHeader>
69
 
70
+ {status === 'success' || status === 'duplicate' ? (
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  <div className="flex flex-col items-center gap-4 py-6">
72
+ <CheckCircle2 className={`h-12 w-12 ${status === 'success' ? 'text-green-500' : 'text-blue-500'}`} />
73
  <p className="text-center text-lg font-medium">
74
+ {status === 'success' ? 'We\'ll notify you!' : 'Already registered!'}
75
  </p>
76
+ <p className="text-center text-sm text-muted-foreground px-4">
77
+ {status === 'success'
78
+ ? 'You\'ll receive an email when this item becomes available.'
79
+ : 'You\'re already on the waitlist for this item.'}
80
  </p>
81
+
82
+ <div className="w-full space-y-3 pt-4 border-t border-border mt-2">
83
+ <p className="text-center text-xs font-semibold text-muted-foreground uppercase tracking-wider">
84
+ Help the creator find it faster?
85
+ </p>
86
+
87
+ {videoId && creatorId ? (
88
+ <AffiliateSubmissionDialog
89
+ videoId={videoId}
90
+ creatorId={creatorId}
91
+ objectId={detectedObjectId || undefined}
92
+ objectName={productName}
93
+ trigger={
94
+ <Button variant="outline" className="w-full gap-2 border-primary/20 hover:border-primary/40">
95
+ <Link2 className="h-4 w-4" />
96
+ I found the link!
97
+ </Button>
98
+ }
99
+ />
100
+ ) : (
101
+ <p className="text-center text-[10px] text-muted-foreground italic">
102
+ Link submission is unavailable for this item.
103
+ </p>
104
+ )}
105
+
106
+ <Button onClick={handleClose} variant="ghost" className="w-full text-muted-foreground">
107
+ Maybe later
108
+ </Button>
109
+ </div>
110
  </div>
111
  ) : (
112
  <form onSubmit={handleSubmit} className="space-y-4">
 
138
  htmlFor="consent"
139
  className="text-sm font-normal cursor-pointer"
140
  >
141
+ I consent to receive email notifications about this product&apos;s availability.
142
  We&apos;ll only email you about this specific item.
143
  </Label>
144
  </div>
src/features/interest/types/interest.types.ts CHANGED
@@ -33,4 +33,6 @@ export interface InterestCaptureModalProps {
33
  marketplaceMatchId?: string | null;
34
  detectedObjectId?: string | null;
35
  productName: string;
 
 
36
  }
 
33
  marketplaceMatchId?: string | null;
34
  detectedObjectId?: string | null;
35
  productName: string;
36
+ videoId?: string;
37
+ creatorId?: string;
38
  }
src/features/vault/components/product-card.tsx CHANGED
@@ -98,29 +98,38 @@ function getCTAButton(
98
  linkStatus: 'ACTIVE' | 'CHECKING' | 'BROKEN' | undefined,
99
  affiliateUrl: string | null,
100
  onClick: () => void,
101
- isLoading: boolean
 
102
  ) {
103
  // Story 5.3: Broken links show "I want this" button
104
  // Story 5.5: Products without affiliate links show "I want this" button
105
  const isInterestCapture = status !== 'IN_STOCK' || linkStatus === 'BROKEN' || !affiliateUrl;
106
 
107
  if (isInterestCapture) {
 
108
  return (
109
  <Button
110
- className="w-full bg-[#4169FF] hover:bg-[#4169FF]/90"
111
  onClick={onClick}
112
  disabled={isLoading}
113
  aria-label="Express interest in this product"
114
  >
115
  {isLoading ? (
116
  <>
117
- <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
118
  Processing...
119
  </>
120
  ) : (
121
  <>
122
- <Heart className="mr-2 h-4 w-4" aria-hidden="true" />
123
- I want this
 
 
 
 
 
 
 
124
  </>
125
  )}
126
  </Button>
@@ -379,7 +388,8 @@ export function ProductCard({ product, priority = false, onTimestampClick, creat
379
  product.linkStatus,
380
  product.affiliateUrl,
381
  handleCTAClick,
382
- isLoading
 
383
  )}
384
 
385
  {/* Story 5.5: Ghost "Buy Now" button for Creators (Feature Discovery) */}
@@ -436,6 +446,8 @@ export function ProductCard({ product, priority = false, onTimestampClick, creat
436
  marketplaceMatchId={product.marketplaceMatchId}
437
  detectedObjectId={product.id}
438
  productName={displayName}
 
 
439
  />
440
  </article>
441
  );
 
98
  linkStatus: 'ACTIVE' | 'CHECKING' | 'BROKEN' | undefined,
99
  affiliateUrl: string | null,
100
  onClick: () => void,
101
+ isLoading: boolean,
102
+ productInterestCount?: number
103
  ) {
104
  // Story 5.3: Broken links show "I want this" button
105
  // Story 5.5: Products without affiliate links show "I want this" button
106
  const isInterestCapture = status !== 'IN_STOCK' || linkStatus === 'BROKEN' || !affiliateUrl;
107
 
108
  if (isInterestCapture) {
109
+ const interestCount = productInterestCount || 0;
110
  return (
111
  <Button
112
+ className="w-full bg-[#4169FF] hover:bg-[#4169FF]/90 flex items-center justify-center gap-2"
113
  onClick={onClick}
114
  disabled={isLoading}
115
  aria-label="Express interest in this product"
116
  >
117
  {isLoading ? (
118
  <>
119
+ <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
120
  Processing...
121
  </>
122
  ) : (
123
  <>
124
+ <div className="flex items-center gap-1.5 font-bold">
125
+ <Heart className="h-4 w-4 fill-white" aria-hidden="true" />
126
+ <span>I want this</span>
127
+ </div>
128
+ {interestCount > 0 && (
129
+ <Badge variant="secondary" className="bg-white/20 hover:bg-white/20 text-white border-none text-[10px] h-5 px-1.5 font-black">
130
+ {interestCount}
131
+ </Badge>
132
+ )}
133
  </>
134
  )}
135
  </Button>
 
388
  product.linkStatus,
389
  product.affiliateUrl,
390
  handleCTAClick,
391
+ isLoading,
392
+ product.interestPledgeCount
393
  )}
394
 
395
  {/* Story 5.5: Ghost "Buy Now" button for Creators (Feature Discovery) */}
 
446
  marketplaceMatchId={product.marketplaceMatchId}
447
  detectedObjectId={product.id}
448
  productName={displayName}
449
+ videoId={product.videoId}
450
+ creatorId={product.creatorId}
451
  />
452
  </article>
453
  );
src/features/vault/components/vault-filters.tsx CHANGED
@@ -77,8 +77,8 @@ export function VaultFilters({
77
  <SelectContent>
78
  <SelectItem value="date">Date (Newest)</SelectItem>
79
  <SelectItem value="clicks">Most Clicks</SelectItem>
80
- <SelectItem value="interest">Most Interest</SelectItem>
81
- <SelectItem value="video">By Video</SelectItem>
82
  </SelectContent>
83
  </Select>
84
  </div>
 
77
  <SelectContent>
78
  <SelectItem value="date">Date (Newest)</SelectItem>
79
  <SelectItem value="clicks">Most Clicks</SelectItem>
80
+ <SelectItem value="interest">Most Wanted</SelectItem>
81
+ <SelectItem value="video">Video Order</SelectItem>
82
  </SelectContent>
83
  </Select>
84
  </div>
src/features/vault/services/showcase.service.ts CHANGED
@@ -581,6 +581,7 @@ export class ShowcaseService {
581
  subscriberCount: subscriberCount,
582
  creatorSlug: finalSlug,
583
  thumbnailUrl: betterThumbnailUrl,
 
584
  }).returning();
585
  channel = newChannel;
586
  } else {
 
581
  subscriberCount: subscriberCount,
582
  creatorSlug: finalSlug,
583
  thumbnailUrl: betterThumbnailUrl,
584
+ channelType: 'external',
585
  }).returning();
586
  channel = newChannel;
587
  } else {
src/features/vault/services/vault.service.ts CHANGED
@@ -310,6 +310,7 @@ export class VaultService {
310
  videoThumbnailUrl: youtubeVideos.thumbnailUrl,
311
  clickCount: sql`COUNT(DISTINCT ${productClicks.id})`,
312
  interestPledgeCount: sql`COUNT(DISTINCT ${interestPledges.id})`,
 
313
  })
314
  .from(youtubeChannels)
315
  .innerJoin(youtubeVideos, eq(youtubeVideos.channelId, youtubeChannels.id))
@@ -325,7 +326,8 @@ export class VaultService {
325
  youtubeVideos.title,
326
  youtubeVideos.thumbnailUrl,
327
  marketplaceMatches.imageUrl,
328
- detectedObjects.thumbnailUrl
 
329
  )
330
  .orderBy(...orderByClause)
331
  .limit(100);
@@ -349,6 +351,7 @@ export class VaultService {
349
  snapshotUrl: row.snapshotUrl || undefined,
350
  clickCount: Number(row.clickCount) || 0,
351
  interestPledgeCount: Number(row.interestPledgeCount) || 0,
 
352
  }));
353
 
354
  // Cache the result
@@ -685,7 +688,7 @@ export class VaultService {
685
  )
686
  .orderBy(asc(detectedObjects.frameTimestamp));
687
 
688
- const products: ProductCard[] = rows.map((row) => ({
689
  id: row.id,
690
  marketplaceMatchId: row.matchId || null,
691
  objectName: row.objectName,
@@ -701,9 +704,10 @@ export class VaultService {
701
  affiliateUrl: row.affiliateUrl || null,
702
  imageUrl: row.imageUrl || row.snapshotUrl || undefined,
703
  snapshotUrl: row.snapshotUrl || undefined,
 
704
  }));
705
 
706
- return { video, products };
707
  }
708
 
709
  /**
 
310
  videoThumbnailUrl: youtubeVideos.thumbnailUrl,
311
  clickCount: sql`COUNT(DISTINCT ${productClicks.id})`,
312
  interestPledgeCount: sql`COUNT(DISTINCT ${interestPledges.id})`,
313
+ creatorId: youtubeChannels.creatorId,
314
  })
315
  .from(youtubeChannels)
316
  .innerJoin(youtubeVideos, eq(youtubeVideos.channelId, youtubeChannels.id))
 
326
  youtubeVideos.title,
327
  youtubeVideos.thumbnailUrl,
328
  marketplaceMatches.imageUrl,
329
+ detectedObjects.thumbnailUrl,
330
+ youtubeChannels.creatorId
331
  )
332
  .orderBy(...orderByClause)
333
  .limit(100);
 
351
  snapshotUrl: row.snapshotUrl || undefined,
352
  clickCount: Number(row.clickCount) || 0,
353
  interestPledgeCount: Number(row.interestPledgeCount) || 0,
354
+ creatorId: row.creatorId,
355
  }));
356
 
357
  // Cache the result
 
688
  )
689
  .orderBy(asc(detectedObjects.frameTimestamp));
690
 
691
+ const productCards: ProductCard[] = rows.map((row) => ({
692
  id: row.id,
693
  marketplaceMatchId: row.matchId || null,
694
  objectName: row.objectName,
 
704
  affiliateUrl: row.affiliateUrl || null,
705
  imageUrl: row.imageUrl || row.snapshotUrl || undefined,
706
  snapshotUrl: row.snapshotUrl || undefined,
707
+ creatorId: video.channel.creatorId,
708
  }));
709
 
710
+ return { video, products: productCards };
711
  }
712
 
713
  /**
src/features/vault/types/vault.types.ts CHANGED
@@ -75,6 +75,7 @@ export interface ProductCard {
75
  snapshotUrl?: string; // Snapshot from video
76
  clickCount?: number;
77
  interestPledgeCount?: number;
 
78
  }
79
 
80
  export interface ProductCardProps {
 
75
  snapshotUrl?: string; // Snapshot from video
76
  clickCount?: number;
77
  interestPledgeCount?: number;
78
+ creatorId?: string;
79
  }
80
 
81
  export interface ProductCardProps {
src/lib/db/schema.ts CHANGED
@@ -9,6 +9,8 @@ export const videoScanStatusEnum = pgEnum('video_scan_status', [
9
  'idle'
10
  ]);
11
 
 
 
12
  export const objectCategory = pgEnum('object_category', [
13
  'Tech',
14
  'Fashion',
@@ -133,6 +135,7 @@ export const youtubeChannels = pgTable('youtube_channels', {
133
  thumbnailUrl: text('thumbnail_url'),
134
  connectedAt: timestamp('connected_at').defaultNow().notNull(),
135
  syncStatus: syncStatusEnum('sync_status').default('idle').notNull(),
 
136
  }, (table) => ({
137
  creatorChannelIdx: uniqueIndex('creator_channel_idx').on(table.creatorId, table.channelId),
138
  }));
 
9
  'idle'
10
  ]);
11
 
12
+ export const channelTypeEnum = pgEnum('channel_type', ['verified', 'external']);
13
+
14
  export const objectCategory = pgEnum('object_category', [
15
  'Tech',
16
  'Fashion',
 
135
  thumbnailUrl: text('thumbnail_url'),
136
  connectedAt: timestamp('connected_at').defaultNow().notNull(),
137
  syncStatus: syncStatusEnum('sync_status').default('idle').notNull(),
138
+ channelType: channelTypeEnum('channel_type').default('verified').notNull(),
139
  }, (table) => ({
140
  creatorChannelIdx: uniqueIndex('creator_channel_idx').on(table.creatorId, table.channelId),
141
  }));