dvijaykrishnan commited on
Commit
7bb5991
·
1 Parent(s): 304bdf5

feat: Implement admin management for product requests and affiliate proposals, including new database migrations and expanded dashboard statistics.

Browse files
diagnose-api-simple.ts DELETED
@@ -1,47 +0,0 @@
1
-
2
- import * as dotenv from 'dotenv';
3
- dotenv.config();
4
-
5
- async function diagnose() {
6
- const videoId = '6RDECCcWiQA';
7
- const apiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY;
8
- console.log('--- Diagnosing YouTube API for:', videoId, '---');
9
- console.log('API Key exists:', !!apiKey);
10
-
11
- if (!apiKey) {
12
- console.error('No API key found');
13
- process.exit(1);
14
- }
15
-
16
- try {
17
- console.log('Fetching video snippet...');
18
- const videoApiRes = await fetch(`https://www.googleapis.com/youtube/v3/videos?part=snippet&id=${videoId}&key=${apiKey}`);
19
- const videoApiData = await videoApiRes.json() as any;
20
- console.log('Video API Items count:', videoApiData.items?.length);
21
-
22
- if (videoApiData.items?.[0]) {
23
- const channelId = videoApiData.items[0].snippet.channelId;
24
- console.log('Channel ID found:', channelId);
25
-
26
- console.log('Fetching channel stats...');
27
- const channelApiRes = await fetch(`https://www.googleapis.com/youtube/v3/channels?part=snippet,statistics&id=${channelId}&key=${apiKey}`);
28
- const channelApiData = await channelApiRes.json() as any;
29
-
30
- if (channelApiData.items?.[0]) {
31
- const stats = channelApiData.items[0].statistics;
32
- console.log('Channel Stats:', JSON.stringify(stats, null, 2));
33
- console.log('Subscriber Count:', stats.subscriberCount);
34
- } else {
35
- console.log('No channel items found. Data:', JSON.stringify(channelApiData, null, 2));
36
- }
37
- } else {
38
- console.log('No video items found. Data:', JSON.stringify(videoApiData, null, 2));
39
- }
40
- } catch (err) {
41
- console.error('Fetch Error:', err);
42
- }
43
-
44
- process.exit(0);
45
- }
46
-
47
- diagnose();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
diagnose-oembed.ts DELETED
@@ -1,21 +0,0 @@
1
-
2
- import { ShowcaseService } from './src/features/vault/services/showcase.service';
3
- import * as dotenv from 'dotenv';
4
- dotenv.config();
5
-
6
- async function diagnose() {
7
- const videoId = '6RDECCcWiQA';
8
- console.log('--- Diagnosing getOEmbedData for:', videoId, '---');
9
-
10
- // Access the private method for testing
11
- const data = await (ShowcaseService as any).getOEmbedData(videoId);
12
-
13
- console.log('Result:', JSON.stringify(data, null, 2));
14
-
15
- process.exit(0);
16
- }
17
-
18
- diagnose().catch(err => {
19
- console.error(err);
20
- process.exit(1);
21
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
drizzle/0016_futuristic_skrulls.sql ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ CREATE TABLE "analysis_notifications" (
2
+ "id" text PRIMARY KEY NOT NULL,
3
+ "video_id" text NOT NULL,
4
+ "email_hash" text NOT NULL,
5
+ "email_encrypted" text NOT NULL,
6
+ "status" text DEFAULT 'PENDING' NOT NULL,
7
+ "unsubscribe_token" text NOT NULL,
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 "video_scan_jobs" ALTER COLUMN "status" SET DATA TYPE text;--> statement-breakpoint
13
+ ALTER TABLE "video_scan_jobs" ALTER COLUMN "status" SET DEFAULT 'pending'::text;--> statement-breakpoint
14
+ ALTER TABLE "youtube_videos" ALTER COLUMN "scan_status" SET DATA TYPE text;--> statement-breakpoint
15
+ ALTER TABLE "youtube_videos" ALTER COLUMN "scan_status" SET DEFAULT 'pending'::text;--> statement-breakpoint
16
+ DROP TYPE "public"."video_scan_status";--> statement-breakpoint
17
+ CREATE TYPE "public"."video_scan_status" AS ENUM('pending_analysis', 'in_progress', 'awaiting_approval', 'completed', 'failed', 'idle', 'pending');--> statement-breakpoint
18
+ ALTER TABLE "video_scan_jobs" ALTER COLUMN "status" SET DEFAULT 'pending'::"public"."video_scan_status";--> statement-breakpoint
19
+ ALTER TABLE "video_scan_jobs" ALTER COLUMN "status" SET DATA TYPE "public"."video_scan_status" USING "status"::"public"."video_scan_status";--> statement-breakpoint
20
+ ALTER TABLE "youtube_videos" ALTER COLUMN "scan_status" SET DEFAULT 'pending'::"public"."video_scan_status";--> statement-breakpoint
21
+ ALTER TABLE "youtube_videos" ALTER COLUMN "scan_status" SET DATA TYPE "public"."video_scan_status" USING "scan_status"::"public"."video_scan_status";--> statement-breakpoint
22
+ ALTER TABLE "analysis_notifications" ADD CONSTRAINT "analysis_notifications_video_id_youtube_videos_id_fk" FOREIGN KEY ("video_id") REFERENCES "public"."youtube_videos"("id") ON DELETE cascade ON UPDATE no action;
drizzle/meta/0016_snapshot.json ADDED
@@ -0,0 +1,2165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "id": "dd255094-b338-48a4-a1c5-2d69e82dfb8f",
3
+ "prevId": "2c5eb6dc-754a-47f3-bf8b-6c37e56fa9c7",
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.analysis_notifications": {
468
+ "name": "analysis_notifications",
469
+ "schema": "",
470
+ "columns": {
471
+ "id": {
472
+ "name": "id",
473
+ "type": "text",
474
+ "primaryKey": true,
475
+ "notNull": true
476
+ },
477
+ "video_id": {
478
+ "name": "video_id",
479
+ "type": "text",
480
+ "primaryKey": false,
481
+ "notNull": true
482
+ },
483
+ "email_hash": {
484
+ "name": "email_hash",
485
+ "type": "text",
486
+ "primaryKey": false,
487
+ "notNull": true
488
+ },
489
+ "email_encrypted": {
490
+ "name": "email_encrypted",
491
+ "type": "text",
492
+ "primaryKey": false,
493
+ "notNull": true
494
+ },
495
+ "status": {
496
+ "name": "status",
497
+ "type": "text",
498
+ "primaryKey": false,
499
+ "notNull": true,
500
+ "default": "'PENDING'"
501
+ },
502
+ "unsubscribe_token": {
503
+ "name": "unsubscribe_token",
504
+ "type": "text",
505
+ "primaryKey": false,
506
+ "notNull": true
507
+ },
508
+ "created_at": {
509
+ "name": "created_at",
510
+ "type": "timestamp (3) with time zone",
511
+ "primaryKey": false,
512
+ "notNull": true,
513
+ "default": "now()"
514
+ },
515
+ "updated_at": {
516
+ "name": "updated_at",
517
+ "type": "timestamp (3) with time zone",
518
+ "primaryKey": false,
519
+ "notNull": true,
520
+ "default": "now()"
521
+ }
522
+ },
523
+ "indexes": {},
524
+ "foreignKeys": {
525
+ "analysis_notifications_video_id_youtube_videos_id_fk": {
526
+ "name": "analysis_notifications_video_id_youtube_videos_id_fk",
527
+ "tableFrom": "analysis_notifications",
528
+ "tableTo": "youtube_videos",
529
+ "columnsFrom": [
530
+ "video_id"
531
+ ],
532
+ "columnsTo": [
533
+ "id"
534
+ ],
535
+ "onDelete": "cascade",
536
+ "onUpdate": "no action"
537
+ }
538
+ },
539
+ "compositePrimaryKeys": {},
540
+ "uniqueConstraints": {},
541
+ "policies": {},
542
+ "checkConstraints": {},
543
+ "isRLSEnabled": false
544
+ },
545
+ "public.bounty_pledges": {
546
+ "name": "bounty_pledges",
547
+ "schema": "",
548
+ "columns": {
549
+ "id": {
550
+ "name": "id",
551
+ "type": "text",
552
+ "primaryKey": true,
553
+ "notNull": true
554
+ },
555
+ "product_id": {
556
+ "name": "product_id",
557
+ "type": "text",
558
+ "primaryKey": false,
559
+ "notNull": true
560
+ },
561
+ "marketplace_match_id": {
562
+ "name": "marketplace_match_id",
563
+ "type": "text",
564
+ "primaryKey": false,
565
+ "notNull": true
566
+ },
567
+ "email_hash": {
568
+ "name": "email_hash",
569
+ "type": "text",
570
+ "primaryKey": false,
571
+ "notNull": true
572
+ },
573
+ "encrypted_email": {
574
+ "name": "encrypted_email",
575
+ "type": "text",
576
+ "primaryKey": false,
577
+ "notNull": true
578
+ },
579
+ "pledge_amount": {
580
+ "name": "pledge_amount",
581
+ "type": "real",
582
+ "primaryKey": false,
583
+ "notNull": true
584
+ },
585
+ "currency": {
586
+ "name": "currency",
587
+ "type": "text",
588
+ "primaryKey": false,
589
+ "notNull": true,
590
+ "default": "'USD'"
591
+ },
592
+ "status": {
593
+ "name": "status",
594
+ "type": "bounty_pledge_status",
595
+ "typeSchema": "public",
596
+ "primaryKey": false,
597
+ "notNull": true,
598
+ "default": "'ACTIVE'"
599
+ },
600
+ "consent_timestamp": {
601
+ "name": "consent_timestamp",
602
+ "type": "timestamp (3) with time zone",
603
+ "primaryKey": false,
604
+ "notNull": true,
605
+ "default": "now()"
606
+ },
607
+ "consent_ip": {
608
+ "name": "consent_ip",
609
+ "type": "text",
610
+ "primaryKey": false,
611
+ "notNull": false
612
+ },
613
+ "unsubscribe_token": {
614
+ "name": "unsubscribe_token",
615
+ "type": "text",
616
+ "primaryKey": false,
617
+ "notNull": true
618
+ },
619
+ "notified_at": {
620
+ "name": "notified_at",
621
+ "type": "timestamp (3) with time zone",
622
+ "primaryKey": false,
623
+ "notNull": false
624
+ },
625
+ "created_at": {
626
+ "name": "created_at",
627
+ "type": "timestamp (3) with time zone",
628
+ "primaryKey": false,
629
+ "notNull": true,
630
+ "default": "now()"
631
+ },
632
+ "updated_at": {
633
+ "name": "updated_at",
634
+ "type": "timestamp (3) with time zone",
635
+ "primaryKey": false,
636
+ "notNull": true,
637
+ "default": "now()"
638
+ }
639
+ },
640
+ "indexes": {
641
+ "bounty_pledges_product_id_idx": {
642
+ "name": "bounty_pledges_product_id_idx",
643
+ "columns": [
644
+ {
645
+ "expression": "product_id",
646
+ "isExpression": false,
647
+ "asc": true,
648
+ "nulls": "last"
649
+ }
650
+ ],
651
+ "isUnique": false,
652
+ "concurrently": false,
653
+ "method": "btree",
654
+ "with": {}
655
+ },
656
+ "bounty_pledges_status_idx": {
657
+ "name": "bounty_pledges_status_idx",
658
+ "columns": [
659
+ {
660
+ "expression": "status",
661
+ "isExpression": false,
662
+ "asc": true,
663
+ "nulls": "last"
664
+ }
665
+ ],
666
+ "isUnique": false,
667
+ "concurrently": false,
668
+ "method": "btree",
669
+ "with": {}
670
+ },
671
+ "bounty_pledges_email_hash_idx": {
672
+ "name": "bounty_pledges_email_hash_idx",
673
+ "columns": [
674
+ {
675
+ "expression": "email_hash",
676
+ "isExpression": false,
677
+ "asc": true,
678
+ "nulls": "last"
679
+ }
680
+ ],
681
+ "isUnique": false,
682
+ "concurrently": false,
683
+ "method": "btree",
684
+ "with": {}
685
+ }
686
+ },
687
+ "foreignKeys": {
688
+ "bounty_pledges_marketplace_match_id_marketplace_matches_id_fk": {
689
+ "name": "bounty_pledges_marketplace_match_id_marketplace_matches_id_fk",
690
+ "tableFrom": "bounty_pledges",
691
+ "tableTo": "marketplace_matches",
692
+ "columnsFrom": [
693
+ "marketplace_match_id"
694
+ ],
695
+ "columnsTo": [
696
+ "id"
697
+ ],
698
+ "onDelete": "cascade",
699
+ "onUpdate": "no action"
700
+ }
701
+ },
702
+ "compositePrimaryKeys": {},
703
+ "uniqueConstraints": {},
704
+ "policies": {},
705
+ "checkConstraints": {},
706
+ "isRLSEnabled": false
707
+ },
708
+ "public.detected_objects": {
709
+ "name": "detected_objects",
710
+ "schema": "",
711
+ "columns": {
712
+ "id": {
713
+ "name": "id",
714
+ "type": "text",
715
+ "primaryKey": true,
716
+ "notNull": true
717
+ },
718
+ "video_id": {
719
+ "name": "video_id",
720
+ "type": "text",
721
+ "primaryKey": false,
722
+ "notNull": true
723
+ },
724
+ "object_name": {
725
+ "name": "object_name",
726
+ "type": "text",
727
+ "primaryKey": false,
728
+ "notNull": true
729
+ },
730
+ "category": {
731
+ "name": "category",
732
+ "type": "object_category",
733
+ "typeSchema": "public",
734
+ "primaryKey": false,
735
+ "notNull": true
736
+ },
737
+ "confidence_score": {
738
+ "name": "confidence_score",
739
+ "type": "real",
740
+ "primaryKey": false,
741
+ "notNull": true
742
+ },
743
+ "frame_timestamp": {
744
+ "name": "frame_timestamp",
745
+ "type": "integer",
746
+ "primaryKey": false,
747
+ "notNull": true
748
+ },
749
+ "detection_metadata": {
750
+ "name": "detection_metadata",
751
+ "type": "jsonb",
752
+ "primaryKey": false,
753
+ "notNull": false
754
+ },
755
+ "thumbnail_url": {
756
+ "name": "thumbnail_url",
757
+ "type": "text",
758
+ "primaryKey": false,
759
+ "notNull": false
760
+ },
761
+ "status": {
762
+ "name": "status",
763
+ "type": "detection_status",
764
+ "typeSchema": "public",
765
+ "primaryKey": false,
766
+ "notNull": true,
767
+ "default": "'pending_review'"
768
+ },
769
+ "moderation_status": {
770
+ "name": "moderation_status",
771
+ "type": "moderation_status",
772
+ "typeSchema": "public",
773
+ "primaryKey": false,
774
+ "notNull": true,
775
+ "default": "'PENDING'"
776
+ },
777
+ "moderated_at": {
778
+ "name": "moderated_at",
779
+ "type": "timestamp (3) with time zone",
780
+ "primaryKey": false,
781
+ "notNull": false
782
+ },
783
+ "moderated_by": {
784
+ "name": "moderated_by",
785
+ "type": "text",
786
+ "primaryKey": false,
787
+ "notNull": false
788
+ },
789
+ "moderation_metadata": {
790
+ "name": "moderation_metadata",
791
+ "type": "jsonb",
792
+ "primaryKey": false,
793
+ "notNull": false
794
+ },
795
+ "created_at": {
796
+ "name": "created_at",
797
+ "type": "timestamp (3) with time zone",
798
+ "primaryKey": false,
799
+ "notNull": true,
800
+ "default": "now()"
801
+ },
802
+ "updated_at": {
803
+ "name": "updated_at",
804
+ "type": "timestamp (3) with time zone",
805
+ "primaryKey": false,
806
+ "notNull": true,
807
+ "default": "now()"
808
+ }
809
+ },
810
+ "indexes": {},
811
+ "foreignKeys": {
812
+ "detected_objects_video_id_youtube_videos_id_fk": {
813
+ "name": "detected_objects_video_id_youtube_videos_id_fk",
814
+ "tableFrom": "detected_objects",
815
+ "tableTo": "youtube_videos",
816
+ "columnsFrom": [
817
+ "video_id"
818
+ ],
819
+ "columnsTo": [
820
+ "id"
821
+ ],
822
+ "onDelete": "cascade",
823
+ "onUpdate": "no action"
824
+ },
825
+ "detected_objects_moderated_by_users_id_fk": {
826
+ "name": "detected_objects_moderated_by_users_id_fk",
827
+ "tableFrom": "detected_objects",
828
+ "tableTo": "users",
829
+ "columnsFrom": [
830
+ "moderated_by"
831
+ ],
832
+ "columnsTo": [
833
+ "id"
834
+ ],
835
+ "onDelete": "no action",
836
+ "onUpdate": "no action"
837
+ }
838
+ },
839
+ "compositePrimaryKeys": {},
840
+ "uniqueConstraints": {},
841
+ "policies": {},
842
+ "checkConstraints": {},
843
+ "isRLSEnabled": false
844
+ },
845
+ "public.interest_pledges": {
846
+ "name": "interest_pledges",
847
+ "schema": "",
848
+ "columns": {
849
+ "id": {
850
+ "name": "id",
851
+ "type": "text",
852
+ "primaryKey": true,
853
+ "notNull": true
854
+ },
855
+ "marketplace_match_id": {
856
+ "name": "marketplace_match_id",
857
+ "type": "text",
858
+ "primaryKey": false,
859
+ "notNull": false
860
+ },
861
+ "detected_object_id": {
862
+ "name": "detected_object_id",
863
+ "type": "text",
864
+ "primaryKey": false,
865
+ "notNull": false
866
+ },
867
+ "email_hash": {
868
+ "name": "email_hash",
869
+ "type": "text",
870
+ "primaryKey": false,
871
+ "notNull": true
872
+ },
873
+ "email_encrypted": {
874
+ "name": "email_encrypted",
875
+ "type": "text",
876
+ "primaryKey": false,
877
+ "notNull": true
878
+ },
879
+ "status": {
880
+ "name": "status",
881
+ "type": "interest_pledge_status",
882
+ "typeSchema": "public",
883
+ "primaryKey": false,
884
+ "notNull": true,
885
+ "default": "'ACTIVE'"
886
+ },
887
+ "consent_timestamp": {
888
+ "name": "consent_timestamp",
889
+ "type": "timestamp (3) with time zone",
890
+ "primaryKey": false,
891
+ "notNull": true,
892
+ "default": "now()"
893
+ },
894
+ "consent_ip": {
895
+ "name": "consent_ip",
896
+ "type": "text",
897
+ "primaryKey": false,
898
+ "notNull": false
899
+ },
900
+ "unsubscribe_token": {
901
+ "name": "unsubscribe_token",
902
+ "type": "text",
903
+ "primaryKey": false,
904
+ "notNull": true
905
+ },
906
+ "consent_metadata": {
907
+ "name": "consent_metadata",
908
+ "type": "jsonb",
909
+ "primaryKey": false,
910
+ "notNull": false
911
+ },
912
+ "notified_at": {
913
+ "name": "notified_at",
914
+ "type": "timestamp (3) with time zone",
915
+ "primaryKey": false,
916
+ "notNull": false
917
+ },
918
+ "created_at": {
919
+ "name": "created_at",
920
+ "type": "timestamp (3) with time zone",
921
+ "primaryKey": false,
922
+ "notNull": true,
923
+ "default": "now()"
924
+ },
925
+ "updated_at": {
926
+ "name": "updated_at",
927
+ "type": "timestamp (3) with time zone",
928
+ "primaryKey": false,
929
+ "notNull": true,
930
+ "default": "now()"
931
+ }
932
+ },
933
+ "indexes": {
934
+ "interest_pledges_marketplace_match_id_idx": {
935
+ "name": "interest_pledges_marketplace_match_id_idx",
936
+ "columns": [
937
+ {
938
+ "expression": "marketplace_match_id",
939
+ "isExpression": false,
940
+ "asc": true,
941
+ "nulls": "last"
942
+ }
943
+ ],
944
+ "isUnique": false,
945
+ "concurrently": false,
946
+ "method": "btree",
947
+ "with": {}
948
+ },
949
+ "interest_pledges_detected_object_id_idx": {
950
+ "name": "interest_pledges_detected_object_id_idx",
951
+ "columns": [
952
+ {
953
+ "expression": "detected_object_id",
954
+ "isExpression": false,
955
+ "asc": true,
956
+ "nulls": "last"
957
+ }
958
+ ],
959
+ "isUnique": false,
960
+ "concurrently": false,
961
+ "method": "btree",
962
+ "with": {}
963
+ },
964
+ "interest_pledges_unsubscribe_token_idx": {
965
+ "name": "interest_pledges_unsubscribe_token_idx",
966
+ "columns": [
967
+ {
968
+ "expression": "unsubscribe_token",
969
+ "isExpression": false,
970
+ "asc": true,
971
+ "nulls": "last"
972
+ }
973
+ ],
974
+ "isUnique": true,
975
+ "concurrently": false,
976
+ "method": "btree",
977
+ "with": {}
978
+ }
979
+ },
980
+ "foreignKeys": {
981
+ "interest_pledges_marketplace_match_id_marketplace_matches_id_fk": {
982
+ "name": "interest_pledges_marketplace_match_id_marketplace_matches_id_fk",
983
+ "tableFrom": "interest_pledges",
984
+ "tableTo": "marketplace_matches",
985
+ "columnsFrom": [
986
+ "marketplace_match_id"
987
+ ],
988
+ "columnsTo": [
989
+ "id"
990
+ ],
991
+ "onDelete": "cascade",
992
+ "onUpdate": "no action"
993
+ },
994
+ "interest_pledges_detected_object_id_detected_objects_id_fk": {
995
+ "name": "interest_pledges_detected_object_id_detected_objects_id_fk",
996
+ "tableFrom": "interest_pledges",
997
+ "tableTo": "detected_objects",
998
+ "columnsFrom": [
999
+ "detected_object_id"
1000
+ ],
1001
+ "columnsTo": [
1002
+ "id"
1003
+ ],
1004
+ "onDelete": "cascade",
1005
+ "onUpdate": "no action"
1006
+ }
1007
+ },
1008
+ "compositePrimaryKeys": {},
1009
+ "uniqueConstraints": {},
1010
+ "policies": {},
1011
+ "checkConstraints": {},
1012
+ "isRLSEnabled": false
1013
+ },
1014
+ "public.marketplace_matches": {
1015
+ "name": "marketplace_matches",
1016
+ "schema": "",
1017
+ "columns": {
1018
+ "id": {
1019
+ "name": "id",
1020
+ "type": "text",
1021
+ "primaryKey": true,
1022
+ "notNull": true
1023
+ },
1024
+ "object_id": {
1025
+ "name": "object_id",
1026
+ "type": "text",
1027
+ "primaryKey": false,
1028
+ "notNull": true
1029
+ },
1030
+ "marketplace": {
1031
+ "name": "marketplace",
1032
+ "type": "marketplace_type",
1033
+ "typeSchema": "public",
1034
+ "primaryKey": false,
1035
+ "notNull": true
1036
+ },
1037
+ "product_id": {
1038
+ "name": "product_id",
1039
+ "type": "text",
1040
+ "primaryKey": false,
1041
+ "notNull": true
1042
+ },
1043
+ "product_name": {
1044
+ "name": "product_name",
1045
+ "type": "text",
1046
+ "primaryKey": false,
1047
+ "notNull": true
1048
+ },
1049
+ "price": {
1050
+ "name": "price",
1051
+ "type": "real",
1052
+ "primaryKey": false,
1053
+ "notNull": true
1054
+ },
1055
+ "availability_status": {
1056
+ "name": "availability_status",
1057
+ "type": "availability_status",
1058
+ "typeSchema": "public",
1059
+ "primaryKey": false,
1060
+ "notNull": true
1061
+ },
1062
+ "affiliate_url": {
1063
+ "name": "affiliate_url",
1064
+ "type": "text",
1065
+ "primaryKey": false,
1066
+ "notNull": true
1067
+ },
1068
+ "image_url": {
1069
+ "name": "image_url",
1070
+ "type": "text",
1071
+ "primaryKey": false,
1072
+ "notNull": false
1073
+ },
1074
+ "link_status": {
1075
+ "name": "link_status",
1076
+ "type": "link_status",
1077
+ "typeSchema": "public",
1078
+ "primaryKey": false,
1079
+ "notNull": true,
1080
+ "default": "'ACTIVE'"
1081
+ },
1082
+ "last_checked_at": {
1083
+ "name": "last_checked_at",
1084
+ "type": "timestamp (3) with time zone",
1085
+ "primaryKey": false,
1086
+ "notNull": false
1087
+ },
1088
+ "check_attempts": {
1089
+ "name": "check_attempts",
1090
+ "type": "integer",
1091
+ "primaryKey": false,
1092
+ "notNull": true,
1093
+ "default": 0
1094
+ },
1095
+ "check_metadata": {
1096
+ "name": "check_metadata",
1097
+ "type": "jsonb",
1098
+ "primaryKey": false,
1099
+ "notNull": false
1100
+ },
1101
+ "matched_at": {
1102
+ "name": "matched_at",
1103
+ "type": "timestamp (3) with time zone",
1104
+ "primaryKey": false,
1105
+ "notNull": true,
1106
+ "default": "now()"
1107
+ },
1108
+ "created_at": {
1109
+ "name": "created_at",
1110
+ "type": "timestamp (3) with time zone",
1111
+ "primaryKey": false,
1112
+ "notNull": true,
1113
+ "default": "now()"
1114
+ },
1115
+ "updated_at": {
1116
+ "name": "updated_at",
1117
+ "type": "timestamp (3) with time zone",
1118
+ "primaryKey": false,
1119
+ "notNull": true,
1120
+ "default": "now()"
1121
+ }
1122
+ },
1123
+ "indexes": {
1124
+ "marketplace_matches_link_status_idx": {
1125
+ "name": "marketplace_matches_link_status_idx",
1126
+ "columns": [
1127
+ {
1128
+ "expression": "link_status",
1129
+ "isExpression": false,
1130
+ "asc": true,
1131
+ "nulls": "last"
1132
+ }
1133
+ ],
1134
+ "isUnique": false,
1135
+ "concurrently": false,
1136
+ "method": "btree",
1137
+ "with": {}
1138
+ },
1139
+ "marketplace_matches_last_checked_at_idx": {
1140
+ "name": "marketplace_matches_last_checked_at_idx",
1141
+ "columns": [
1142
+ {
1143
+ "expression": "last_checked_at",
1144
+ "isExpression": false,
1145
+ "asc": true,
1146
+ "nulls": "last"
1147
+ }
1148
+ ],
1149
+ "isUnique": false,
1150
+ "concurrently": false,
1151
+ "method": "btree",
1152
+ "with": {}
1153
+ }
1154
+ },
1155
+ "foreignKeys": {
1156
+ "marketplace_matches_object_id_detected_objects_id_fk": {
1157
+ "name": "marketplace_matches_object_id_detected_objects_id_fk",
1158
+ "tableFrom": "marketplace_matches",
1159
+ "tableTo": "detected_objects",
1160
+ "columnsFrom": [
1161
+ "object_id"
1162
+ ],
1163
+ "columnsTo": [
1164
+ "id"
1165
+ ],
1166
+ "onDelete": "cascade",
1167
+ "onUpdate": "no action"
1168
+ }
1169
+ },
1170
+ "compositePrimaryKeys": {},
1171
+ "uniqueConstraints": {},
1172
+ "policies": {},
1173
+ "checkConstraints": {},
1174
+ "isRLSEnabled": false
1175
+ },
1176
+ "public.product_clicks": {
1177
+ "name": "product_clicks",
1178
+ "schema": "",
1179
+ "columns": {
1180
+ "id": {
1181
+ "name": "id",
1182
+ "type": "text",
1183
+ "primaryKey": true,
1184
+ "notNull": true
1185
+ },
1186
+ "marketplace_match_id": {
1187
+ "name": "marketplace_match_id",
1188
+ "type": "text",
1189
+ "primaryKey": false,
1190
+ "notNull": true
1191
+ },
1192
+ "clicked_at": {
1193
+ "name": "clicked_at",
1194
+ "type": "timestamp (3) with time zone",
1195
+ "primaryKey": false,
1196
+ "notNull": true,
1197
+ "default": "now()"
1198
+ },
1199
+ "viewer_ip": {
1200
+ "name": "viewer_ip",
1201
+ "type": "text",
1202
+ "primaryKey": false,
1203
+ "notNull": false
1204
+ },
1205
+ "user_agent": {
1206
+ "name": "user_agent",
1207
+ "type": "text",
1208
+ "primaryKey": false,
1209
+ "notNull": false
1210
+ },
1211
+ "referrer": {
1212
+ "name": "referrer",
1213
+ "type": "text",
1214
+ "primaryKey": false,
1215
+ "notNull": false
1216
+ },
1217
+ "created_at": {
1218
+ "name": "created_at",
1219
+ "type": "timestamp (3) with time zone",
1220
+ "primaryKey": false,
1221
+ "notNull": true,
1222
+ "default": "now()"
1223
+ },
1224
+ "updated_at": {
1225
+ "name": "updated_at",
1226
+ "type": "timestamp (3) with time zone",
1227
+ "primaryKey": false,
1228
+ "notNull": true,
1229
+ "default": "now()"
1230
+ }
1231
+ },
1232
+ "indexes": {
1233
+ "product_clicks_marketplace_match_id_idx": {
1234
+ "name": "product_clicks_marketplace_match_id_idx",
1235
+ "columns": [
1236
+ {
1237
+ "expression": "marketplace_match_id",
1238
+ "isExpression": false,
1239
+ "asc": true,
1240
+ "nulls": "last"
1241
+ }
1242
+ ],
1243
+ "isUnique": false,
1244
+ "concurrently": false,
1245
+ "method": "btree",
1246
+ "with": {}
1247
+ },
1248
+ "product_clicks_clicked_at_idx": {
1249
+ "name": "product_clicks_clicked_at_idx",
1250
+ "columns": [
1251
+ {
1252
+ "expression": "clicked_at",
1253
+ "isExpression": false,
1254
+ "asc": true,
1255
+ "nulls": "last"
1256
+ }
1257
+ ],
1258
+ "isUnique": false,
1259
+ "concurrently": false,
1260
+ "method": "btree",
1261
+ "with": {}
1262
+ }
1263
+ },
1264
+ "foreignKeys": {
1265
+ "product_clicks_marketplace_match_id_marketplace_matches_id_fk": {
1266
+ "name": "product_clicks_marketplace_match_id_marketplace_matches_id_fk",
1267
+ "tableFrom": "product_clicks",
1268
+ "tableTo": "marketplace_matches",
1269
+ "columnsFrom": [
1270
+ "marketplace_match_id"
1271
+ ],
1272
+ "columnsTo": [
1273
+ "id"
1274
+ ],
1275
+ "onDelete": "cascade",
1276
+ "onUpdate": "no action"
1277
+ }
1278
+ },
1279
+ "compositePrimaryKeys": {},
1280
+ "uniqueConstraints": {},
1281
+ "policies": {},
1282
+ "checkConstraints": {},
1283
+ "isRLSEnabled": false
1284
+ },
1285
+ "public.product_requests": {
1286
+ "name": "product_requests",
1287
+ "schema": "",
1288
+ "columns": {
1289
+ "id": {
1290
+ "name": "id",
1291
+ "type": "text",
1292
+ "primaryKey": true,
1293
+ "notNull": true
1294
+ },
1295
+ "video_id": {
1296
+ "name": "video_id",
1297
+ "type": "text",
1298
+ "primaryKey": false,
1299
+ "notNull": true
1300
+ },
1301
+ "creator_id": {
1302
+ "name": "creator_id",
1303
+ "type": "text",
1304
+ "primaryKey": false,
1305
+ "notNull": true
1306
+ },
1307
+ "viewer_name": {
1308
+ "name": "viewer_name",
1309
+ "type": "text",
1310
+ "primaryKey": false,
1311
+ "notNull": false
1312
+ },
1313
+ "viewer_email": {
1314
+ "name": "viewer_email",
1315
+ "type": "text",
1316
+ "primaryKey": false,
1317
+ "notNull": false
1318
+ },
1319
+ "note": {
1320
+ "name": "note",
1321
+ "type": "text",
1322
+ "primaryKey": false,
1323
+ "notNull": true
1324
+ },
1325
+ "image_url": {
1326
+ "name": "image_url",
1327
+ "type": "text",
1328
+ "primaryKey": false,
1329
+ "notNull": false
1330
+ },
1331
+ "frame_timestamp": {
1332
+ "name": "frame_timestamp",
1333
+ "type": "integer",
1334
+ "primaryKey": false,
1335
+ "notNull": false
1336
+ },
1337
+ "status": {
1338
+ "name": "status",
1339
+ "type": "request_status",
1340
+ "typeSchema": "public",
1341
+ "primaryKey": false,
1342
+ "notNull": true,
1343
+ "default": "'PENDING'"
1344
+ },
1345
+ "created_at": {
1346
+ "name": "created_at",
1347
+ "type": "timestamp (3) with time zone",
1348
+ "primaryKey": false,
1349
+ "notNull": true,
1350
+ "default": "now()"
1351
+ },
1352
+ "updated_at": {
1353
+ "name": "updated_at",
1354
+ "type": "timestamp (3) with time zone",
1355
+ "primaryKey": false,
1356
+ "notNull": true,
1357
+ "default": "now()"
1358
+ }
1359
+ },
1360
+ "indexes": {},
1361
+ "foreignKeys": {
1362
+ "product_requests_video_id_youtube_videos_id_fk": {
1363
+ "name": "product_requests_video_id_youtube_videos_id_fk",
1364
+ "tableFrom": "product_requests",
1365
+ "tableTo": "youtube_videos",
1366
+ "columnsFrom": [
1367
+ "video_id"
1368
+ ],
1369
+ "columnsTo": [
1370
+ "id"
1371
+ ],
1372
+ "onDelete": "cascade",
1373
+ "onUpdate": "no action"
1374
+ },
1375
+ "product_requests_creator_id_users_id_fk": {
1376
+ "name": "product_requests_creator_id_users_id_fk",
1377
+ "tableFrom": "product_requests",
1378
+ "tableTo": "users",
1379
+ "columnsFrom": [
1380
+ "creator_id"
1381
+ ],
1382
+ "columnsTo": [
1383
+ "id"
1384
+ ],
1385
+ "onDelete": "no action",
1386
+ "onUpdate": "no action"
1387
+ }
1388
+ },
1389
+ "compositePrimaryKeys": {},
1390
+ "uniqueConstraints": {},
1391
+ "policies": {},
1392
+ "checkConstraints": {},
1393
+ "isRLSEnabled": false
1394
+ },
1395
+ "public.session": {
1396
+ "name": "session",
1397
+ "schema": "",
1398
+ "columns": {
1399
+ "id": {
1400
+ "name": "id",
1401
+ "type": "text",
1402
+ "primaryKey": true,
1403
+ "notNull": true
1404
+ },
1405
+ "expires_at": {
1406
+ "name": "expires_at",
1407
+ "type": "timestamp",
1408
+ "primaryKey": false,
1409
+ "notNull": true
1410
+ },
1411
+ "token": {
1412
+ "name": "token",
1413
+ "type": "text",
1414
+ "primaryKey": false,
1415
+ "notNull": true
1416
+ },
1417
+ "created_at": {
1418
+ "name": "created_at",
1419
+ "type": "timestamp",
1420
+ "primaryKey": false,
1421
+ "notNull": true
1422
+ },
1423
+ "updated_at": {
1424
+ "name": "updated_at",
1425
+ "type": "timestamp",
1426
+ "primaryKey": false,
1427
+ "notNull": true
1428
+ },
1429
+ "ip_address": {
1430
+ "name": "ip_address",
1431
+ "type": "text",
1432
+ "primaryKey": false,
1433
+ "notNull": false
1434
+ },
1435
+ "user_agent": {
1436
+ "name": "user_agent",
1437
+ "type": "text",
1438
+ "primaryKey": false,
1439
+ "notNull": false
1440
+ },
1441
+ "user_id": {
1442
+ "name": "user_id",
1443
+ "type": "text",
1444
+ "primaryKey": false,
1445
+ "notNull": true
1446
+ }
1447
+ },
1448
+ "indexes": {},
1449
+ "foreignKeys": {
1450
+ "session_user_id_users_id_fk": {
1451
+ "name": "session_user_id_users_id_fk",
1452
+ "tableFrom": "session",
1453
+ "tableTo": "users",
1454
+ "columnsFrom": [
1455
+ "user_id"
1456
+ ],
1457
+ "columnsTo": [
1458
+ "id"
1459
+ ],
1460
+ "onDelete": "no action",
1461
+ "onUpdate": "no action"
1462
+ }
1463
+ },
1464
+ "compositePrimaryKeys": {},
1465
+ "uniqueConstraints": {
1466
+ "session_token_unique": {
1467
+ "name": "session_token_unique",
1468
+ "nullsNotDistinct": false,
1469
+ "columns": [
1470
+ "token"
1471
+ ]
1472
+ }
1473
+ },
1474
+ "policies": {},
1475
+ "checkConstraints": {},
1476
+ "isRLSEnabled": false
1477
+ },
1478
+ "public.users": {
1479
+ "name": "users",
1480
+ "schema": "",
1481
+ "columns": {
1482
+ "id": {
1483
+ "name": "id",
1484
+ "type": "text",
1485
+ "primaryKey": true,
1486
+ "notNull": true
1487
+ },
1488
+ "name": {
1489
+ "name": "name",
1490
+ "type": "text",
1491
+ "primaryKey": false,
1492
+ "notNull": true
1493
+ },
1494
+ "email": {
1495
+ "name": "email",
1496
+ "type": "text",
1497
+ "primaryKey": false,
1498
+ "notNull": true
1499
+ },
1500
+ "email_verified": {
1501
+ "name": "email_verified",
1502
+ "type": "boolean",
1503
+ "primaryKey": false,
1504
+ "notNull": true
1505
+ },
1506
+ "image": {
1507
+ "name": "image",
1508
+ "type": "text",
1509
+ "primaryKey": false,
1510
+ "notNull": false
1511
+ },
1512
+ "created_at": {
1513
+ "name": "created_at",
1514
+ "type": "timestamp",
1515
+ "primaryKey": false,
1516
+ "notNull": true
1517
+ },
1518
+ "updated_at": {
1519
+ "name": "updated_at",
1520
+ "type": "timestamp",
1521
+ "primaryKey": false,
1522
+ "notNull": true
1523
+ }
1524
+ },
1525
+ "indexes": {},
1526
+ "foreignKeys": {},
1527
+ "compositePrimaryKeys": {},
1528
+ "uniqueConstraints": {
1529
+ "users_email_unique": {
1530
+ "name": "users_email_unique",
1531
+ "nullsNotDistinct": false,
1532
+ "columns": [
1533
+ "email"
1534
+ ]
1535
+ }
1536
+ },
1537
+ "policies": {},
1538
+ "checkConstraints": {},
1539
+ "isRLSEnabled": false
1540
+ },
1541
+ "public.verification": {
1542
+ "name": "verification",
1543
+ "schema": "",
1544
+ "columns": {
1545
+ "id": {
1546
+ "name": "id",
1547
+ "type": "text",
1548
+ "primaryKey": true,
1549
+ "notNull": true
1550
+ },
1551
+ "identifier": {
1552
+ "name": "identifier",
1553
+ "type": "text",
1554
+ "primaryKey": false,
1555
+ "notNull": true
1556
+ },
1557
+ "value": {
1558
+ "name": "value",
1559
+ "type": "text",
1560
+ "primaryKey": false,
1561
+ "notNull": true
1562
+ },
1563
+ "expires_at": {
1564
+ "name": "expires_at",
1565
+ "type": "timestamp",
1566
+ "primaryKey": false,
1567
+ "notNull": true
1568
+ },
1569
+ "created_at": {
1570
+ "name": "created_at",
1571
+ "type": "timestamp",
1572
+ "primaryKey": false,
1573
+ "notNull": false
1574
+ },
1575
+ "updated_at": {
1576
+ "name": "updated_at",
1577
+ "type": "timestamp",
1578
+ "primaryKey": false,
1579
+ "notNull": false
1580
+ }
1581
+ },
1582
+ "indexes": {},
1583
+ "foreignKeys": {},
1584
+ "compositePrimaryKeys": {},
1585
+ "uniqueConstraints": {},
1586
+ "policies": {},
1587
+ "checkConstraints": {},
1588
+ "isRLSEnabled": false
1589
+ },
1590
+ "public.video_scan_jobs": {
1591
+ "name": "video_scan_jobs",
1592
+ "schema": "",
1593
+ "columns": {
1594
+ "id": {
1595
+ "name": "id",
1596
+ "type": "text",
1597
+ "primaryKey": true,
1598
+ "notNull": true
1599
+ },
1600
+ "channel_id": {
1601
+ "name": "channel_id",
1602
+ "type": "text",
1603
+ "primaryKey": false,
1604
+ "notNull": true
1605
+ },
1606
+ "user_id": {
1607
+ "name": "user_id",
1608
+ "type": "text",
1609
+ "primaryKey": false,
1610
+ "notNull": true
1611
+ },
1612
+ "status": {
1613
+ "name": "status",
1614
+ "type": "video_scan_status",
1615
+ "typeSchema": "public",
1616
+ "primaryKey": false,
1617
+ "notNull": true,
1618
+ "default": "'pending'"
1619
+ },
1620
+ "progress": {
1621
+ "name": "progress",
1622
+ "type": "integer",
1623
+ "primaryKey": false,
1624
+ "notNull": true,
1625
+ "default": 0
1626
+ },
1627
+ "total_videos": {
1628
+ "name": "total_videos",
1629
+ "type": "integer",
1630
+ "primaryKey": false,
1631
+ "notNull": false
1632
+ },
1633
+ "scanned_videos": {
1634
+ "name": "scanned_videos",
1635
+ "type": "integer",
1636
+ "primaryKey": false,
1637
+ "notNull": true,
1638
+ "default": 0
1639
+ },
1640
+ "error_message": {
1641
+ "name": "error_message",
1642
+ "type": "text",
1643
+ "primaryKey": false,
1644
+ "notNull": false
1645
+ },
1646
+ "inngest_run_id": {
1647
+ "name": "inngest_run_id",
1648
+ "type": "text",
1649
+ "primaryKey": false,
1650
+ "notNull": false
1651
+ },
1652
+ "created_at": {
1653
+ "name": "created_at",
1654
+ "type": "timestamp (3) with time zone",
1655
+ "primaryKey": false,
1656
+ "notNull": true,
1657
+ "default": "now()"
1658
+ },
1659
+ "updated_at": {
1660
+ "name": "updated_at",
1661
+ "type": "timestamp (3) with time zone",
1662
+ "primaryKey": false,
1663
+ "notNull": true,
1664
+ "default": "now()"
1665
+ }
1666
+ },
1667
+ "indexes": {},
1668
+ "foreignKeys": {
1669
+ "video_scan_jobs_channel_id_youtube_channels_id_fk": {
1670
+ "name": "video_scan_jobs_channel_id_youtube_channels_id_fk",
1671
+ "tableFrom": "video_scan_jobs",
1672
+ "tableTo": "youtube_channels",
1673
+ "columnsFrom": [
1674
+ "channel_id"
1675
+ ],
1676
+ "columnsTo": [
1677
+ "id"
1678
+ ],
1679
+ "onDelete": "cascade",
1680
+ "onUpdate": "no action"
1681
+ },
1682
+ "video_scan_jobs_user_id_users_id_fk": {
1683
+ "name": "video_scan_jobs_user_id_users_id_fk",
1684
+ "tableFrom": "video_scan_jobs",
1685
+ "tableTo": "users",
1686
+ "columnsFrom": [
1687
+ "user_id"
1688
+ ],
1689
+ "columnsTo": [
1690
+ "id"
1691
+ ],
1692
+ "onDelete": "no action",
1693
+ "onUpdate": "no action"
1694
+ }
1695
+ },
1696
+ "compositePrimaryKeys": {},
1697
+ "uniqueConstraints": {},
1698
+ "policies": {},
1699
+ "checkConstraints": {},
1700
+ "isRLSEnabled": false
1701
+ },
1702
+ "public.youtube_channels": {
1703
+ "name": "youtube_channels",
1704
+ "schema": "",
1705
+ "columns": {
1706
+ "id": {
1707
+ "name": "id",
1708
+ "type": "text",
1709
+ "primaryKey": true,
1710
+ "notNull": true
1711
+ },
1712
+ "creator_id": {
1713
+ "name": "creator_id",
1714
+ "type": "text",
1715
+ "primaryKey": false,
1716
+ "notNull": true
1717
+ },
1718
+ "channel_id": {
1719
+ "name": "channel_id",
1720
+ "type": "text",
1721
+ "primaryKey": false,
1722
+ "notNull": true
1723
+ },
1724
+ "channel_name": {
1725
+ "name": "channel_name",
1726
+ "type": "text",
1727
+ "primaryKey": false,
1728
+ "notNull": true
1729
+ },
1730
+ "creator_slug": {
1731
+ "name": "creator_slug",
1732
+ "type": "text",
1733
+ "primaryKey": false,
1734
+ "notNull": true
1735
+ },
1736
+ "subscriber_count": {
1737
+ "name": "subscriber_count",
1738
+ "type": "integer",
1739
+ "primaryKey": false,
1740
+ "notNull": false
1741
+ },
1742
+ "thumbnail_url": {
1743
+ "name": "thumbnail_url",
1744
+ "type": "text",
1745
+ "primaryKey": false,
1746
+ "notNull": false
1747
+ },
1748
+ "connected_at": {
1749
+ "name": "connected_at",
1750
+ "type": "timestamp",
1751
+ "primaryKey": false,
1752
+ "notNull": true,
1753
+ "default": "now()"
1754
+ },
1755
+ "sync_status": {
1756
+ "name": "sync_status",
1757
+ "type": "sync_status",
1758
+ "typeSchema": "public",
1759
+ "primaryKey": false,
1760
+ "notNull": true,
1761
+ "default": "'idle'"
1762
+ },
1763
+ "channel_type": {
1764
+ "name": "channel_type",
1765
+ "type": "channel_type",
1766
+ "typeSchema": "public",
1767
+ "primaryKey": false,
1768
+ "notNull": true,
1769
+ "default": "'external'"
1770
+ },
1771
+ "platform": {
1772
+ "name": "platform",
1773
+ "type": "platform",
1774
+ "typeSchema": "public",
1775
+ "primaryKey": false,
1776
+ "notNull": true,
1777
+ "default": "'youtube'"
1778
+ }
1779
+ },
1780
+ "indexes": {
1781
+ "creator_channel_idx": {
1782
+ "name": "creator_channel_idx",
1783
+ "columns": [
1784
+ {
1785
+ "expression": "creator_id",
1786
+ "isExpression": false,
1787
+ "asc": true,
1788
+ "nulls": "last"
1789
+ },
1790
+ {
1791
+ "expression": "channel_id",
1792
+ "isExpression": false,
1793
+ "asc": true,
1794
+ "nulls": "last"
1795
+ }
1796
+ ],
1797
+ "isUnique": true,
1798
+ "concurrently": false,
1799
+ "method": "btree",
1800
+ "with": {}
1801
+ }
1802
+ },
1803
+ "foreignKeys": {
1804
+ "youtube_channels_creator_id_users_id_fk": {
1805
+ "name": "youtube_channels_creator_id_users_id_fk",
1806
+ "tableFrom": "youtube_channels",
1807
+ "tableTo": "users",
1808
+ "columnsFrom": [
1809
+ "creator_id"
1810
+ ],
1811
+ "columnsTo": [
1812
+ "id"
1813
+ ],
1814
+ "onDelete": "no action",
1815
+ "onUpdate": "no action"
1816
+ }
1817
+ },
1818
+ "compositePrimaryKeys": {},
1819
+ "uniqueConstraints": {
1820
+ "youtube_channels_creator_slug_unique": {
1821
+ "name": "youtube_channels_creator_slug_unique",
1822
+ "nullsNotDistinct": false,
1823
+ "columns": [
1824
+ "creator_slug"
1825
+ ]
1826
+ }
1827
+ },
1828
+ "policies": {},
1829
+ "checkConstraints": {},
1830
+ "isRLSEnabled": false
1831
+ },
1832
+ "public.youtube_videos": {
1833
+ "name": "youtube_videos",
1834
+ "schema": "",
1835
+ "columns": {
1836
+ "id": {
1837
+ "name": "id",
1838
+ "type": "text",
1839
+ "primaryKey": true,
1840
+ "notNull": true
1841
+ },
1842
+ "channel_id": {
1843
+ "name": "channel_id",
1844
+ "type": "text",
1845
+ "primaryKey": false,
1846
+ "notNull": true
1847
+ },
1848
+ "video_id": {
1849
+ "name": "video_id",
1850
+ "type": "text",
1851
+ "primaryKey": false,
1852
+ "notNull": true
1853
+ },
1854
+ "title": {
1855
+ "name": "title",
1856
+ "type": "text",
1857
+ "primaryKey": false,
1858
+ "notNull": true
1859
+ },
1860
+ "description": {
1861
+ "name": "description",
1862
+ "type": "text",
1863
+ "primaryKey": false,
1864
+ "notNull": false
1865
+ },
1866
+ "thumbnail_url": {
1867
+ "name": "thumbnail_url",
1868
+ "type": "text",
1869
+ "primaryKey": false,
1870
+ "notNull": false
1871
+ },
1872
+ "duration": {
1873
+ "name": "duration",
1874
+ "type": "text",
1875
+ "primaryKey": false,
1876
+ "notNull": false
1877
+ },
1878
+ "view_count": {
1879
+ "name": "view_count",
1880
+ "type": "integer",
1881
+ "primaryKey": false,
1882
+ "notNull": false
1883
+ },
1884
+ "availability_status": {
1885
+ "name": "availability_status",
1886
+ "type": "text",
1887
+ "primaryKey": false,
1888
+ "notNull": true,
1889
+ "default": "'unknown'"
1890
+ },
1891
+ "scan_status": {
1892
+ "name": "scan_status",
1893
+ "type": "video_scan_status",
1894
+ "typeSchema": "public",
1895
+ "primaryKey": false,
1896
+ "notNull": true,
1897
+ "default": "'pending'"
1898
+ },
1899
+ "published_at": {
1900
+ "name": "published_at",
1901
+ "type": "timestamp (3) with time zone",
1902
+ "primaryKey": false,
1903
+ "notNull": false
1904
+ },
1905
+ "platform": {
1906
+ "name": "platform",
1907
+ "type": "platform",
1908
+ "typeSchema": "public",
1909
+ "primaryKey": false,
1910
+ "notNull": true,
1911
+ "default": "'youtube'"
1912
+ },
1913
+ "url": {
1914
+ "name": "url",
1915
+ "type": "text",
1916
+ "primaryKey": false,
1917
+ "notNull": false
1918
+ },
1919
+ "width": {
1920
+ "name": "width",
1921
+ "type": "integer",
1922
+ "primaryKey": false,
1923
+ "notNull": false
1924
+ },
1925
+ "height": {
1926
+ "name": "height",
1927
+ "type": "integer",
1928
+ "primaryKey": false,
1929
+ "notNull": false
1930
+ },
1931
+ "is_in_workbench": {
1932
+ "name": "is_in_workbench",
1933
+ "type": "boolean",
1934
+ "primaryKey": false,
1935
+ "notNull": true,
1936
+ "default": true
1937
+ },
1938
+ "created_at": {
1939
+ "name": "created_at",
1940
+ "type": "timestamp (3) with time zone",
1941
+ "primaryKey": false,
1942
+ "notNull": true,
1943
+ "default": "now()"
1944
+ },
1945
+ "updated_at": {
1946
+ "name": "updated_at",
1947
+ "type": "timestamp (3) with time zone",
1948
+ "primaryKey": false,
1949
+ "notNull": true,
1950
+ "default": "now()"
1951
+ }
1952
+ },
1953
+ "indexes": {
1954
+ "channel_video_idx": {
1955
+ "name": "channel_video_idx",
1956
+ "columns": [
1957
+ {
1958
+ "expression": "channel_id",
1959
+ "isExpression": false,
1960
+ "asc": true,
1961
+ "nulls": "last"
1962
+ },
1963
+ {
1964
+ "expression": "video_id",
1965
+ "isExpression": false,
1966
+ "asc": true,
1967
+ "nulls": "last"
1968
+ }
1969
+ ],
1970
+ "isUnique": true,
1971
+ "concurrently": false,
1972
+ "method": "btree",
1973
+ "with": {}
1974
+ }
1975
+ },
1976
+ "foreignKeys": {
1977
+ "youtube_videos_channel_id_youtube_channels_id_fk": {
1978
+ "name": "youtube_videos_channel_id_youtube_channels_id_fk",
1979
+ "tableFrom": "youtube_videos",
1980
+ "tableTo": "youtube_channels",
1981
+ "columnsFrom": [
1982
+ "channel_id"
1983
+ ],
1984
+ "columnsTo": [
1985
+ "id"
1986
+ ],
1987
+ "onDelete": "cascade",
1988
+ "onUpdate": "no action"
1989
+ }
1990
+ },
1991
+ "compositePrimaryKeys": {},
1992
+ "uniqueConstraints": {},
1993
+ "policies": {},
1994
+ "checkConstraints": {},
1995
+ "isRLSEnabled": false
1996
+ }
1997
+ },
1998
+ "enums": {
1999
+ "public.admin_moderation_type": {
2000
+ "name": "admin_moderation_type",
2001
+ "schema": "public",
2002
+ "values": [
2003
+ "corrected",
2004
+ "marked_incorrect"
2005
+ ]
2006
+ },
2007
+ "public.affiliate_submission_status": {
2008
+ "name": "affiliate_submission_status",
2009
+ "schema": "public",
2010
+ "values": [
2011
+ "PENDING",
2012
+ "APPROVED",
2013
+ "REJECTED"
2014
+ ]
2015
+ },
2016
+ "public.availability_status": {
2017
+ "name": "availability_status",
2018
+ "schema": "public",
2019
+ "values": [
2020
+ "IN_STOCK",
2021
+ "SOLD_OUT",
2022
+ "DISCONTINUED"
2023
+ ]
2024
+ },
2025
+ "public.bounty_pledge_status": {
2026
+ "name": "bounty_pledge_status",
2027
+ "schema": "public",
2028
+ "values": [
2029
+ "ACTIVE",
2030
+ "WITHDRAWN",
2031
+ "FULFILLED"
2032
+ ]
2033
+ },
2034
+ "public.channel_type": {
2035
+ "name": "channel_type",
2036
+ "schema": "public",
2037
+ "values": [
2038
+ "verified",
2039
+ "external"
2040
+ ]
2041
+ },
2042
+ "public.detection_status": {
2043
+ "name": "detection_status",
2044
+ "schema": "public",
2045
+ "values": [
2046
+ "pending_review",
2047
+ "approved",
2048
+ "rejected",
2049
+ "flagged"
2050
+ ]
2051
+ },
2052
+ "public.interest_pledge_status": {
2053
+ "name": "interest_pledge_status",
2054
+ "schema": "public",
2055
+ "values": [
2056
+ "ACTIVE",
2057
+ "NOTIFIED",
2058
+ "EXPIRED"
2059
+ ]
2060
+ },
2061
+ "public.link_status": {
2062
+ "name": "link_status",
2063
+ "schema": "public",
2064
+ "values": [
2065
+ "ACTIVE",
2066
+ "CHECKING",
2067
+ "BROKEN"
2068
+ ]
2069
+ },
2070
+ "public.marketplace_type": {
2071
+ "name": "marketplace_type",
2072
+ "schema": "public",
2073
+ "values": [
2074
+ "amazon",
2075
+ "ebay",
2076
+ "etsy"
2077
+ ]
2078
+ },
2079
+ "public.moderation_status": {
2080
+ "name": "moderation_status",
2081
+ "schema": "public",
2082
+ "values": [
2083
+ "PENDING",
2084
+ "APPROVED",
2085
+ "REJECTED"
2086
+ ]
2087
+ },
2088
+ "public.object_category": {
2089
+ "name": "object_category",
2090
+ "schema": "public",
2091
+ "values": [
2092
+ "Tech",
2093
+ "Fashion",
2094
+ "Furniture",
2095
+ "Audio",
2096
+ "Other",
2097
+ "Person",
2098
+ "Apparel"
2099
+ ]
2100
+ },
2101
+ "public.platform": {
2102
+ "name": "platform",
2103
+ "schema": "public",
2104
+ "values": [
2105
+ "youtube",
2106
+ "instagram",
2107
+ "tiktok",
2108
+ "facebook"
2109
+ ]
2110
+ },
2111
+ "public.reason_code": {
2112
+ "name": "reason_code",
2113
+ "schema": "public",
2114
+ "values": [
2115
+ "wrong_object",
2116
+ "wrong_category",
2117
+ "false_positive",
2118
+ "unclear_image",
2119
+ "duplicate",
2120
+ "out_of_scope"
2121
+ ]
2122
+ },
2123
+ "public.request_status": {
2124
+ "name": "request_status",
2125
+ "schema": "public",
2126
+ "values": [
2127
+ "PENDING",
2128
+ "FULFILLED",
2129
+ "DISMISSED"
2130
+ ]
2131
+ },
2132
+ "public.sync_status": {
2133
+ "name": "sync_status",
2134
+ "schema": "public",
2135
+ "values": [
2136
+ "idle",
2137
+ "syncing",
2138
+ "errored"
2139
+ ]
2140
+ },
2141
+ "public.video_scan_status": {
2142
+ "name": "video_scan_status",
2143
+ "schema": "public",
2144
+ "values": [
2145
+ "pending_analysis",
2146
+ "in_progress",
2147
+ "awaiting_approval",
2148
+ "completed",
2149
+ "failed",
2150
+ "idle",
2151
+ "pending"
2152
+ ]
2153
+ }
2154
+ },
2155
+ "schemas": {},
2156
+ "sequences": {},
2157
+ "roles": {},
2158
+ "policies": {},
2159
+ "views": {},
2160
+ "_meta": {
2161
+ "columns": {},
2162
+ "schemas": {},
2163
+ "tables": {}
2164
+ }
2165
+ }
drizzle/meta/_journal.json CHANGED
@@ -113,6 +113,13 @@
113
  "when": 1770685990509,
114
  "tag": "0015_orange_black_knight",
115
  "breakpoints": true
 
 
 
 
 
 
 
116
  }
117
  ]
118
  }
 
113
  "when": 1770685990509,
114
  "tag": "0015_orange_black_knight",
115
  "breakpoints": true
116
+ },
117
+ {
118
+ "idx": 16,
119
+ "version": "7",
120
+ "when": 1770771189811,
121
+ "tag": "0016_futuristic_skrulls",
122
+ "breakpoints": true
123
  }
124
  ]
125
  }
reproduce-issue.ts DELETED
@@ -1,33 +0,0 @@
1
-
2
- async function main() {
3
- const videoId = 'BtAoUN0-yxg';
4
- console.log(`Fetching OEmbed for ${videoId}...`);
5
- try {
6
- const response = await fetch(`https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`);
7
- const data = await response.json();
8
- console.log('OEmbed Data:', JSON.stringify(data, null, 2));
9
-
10
- const authorHandle = data.author_url?.split('/').pop() || data.author_name;
11
- console.log('Author Handle:', authorHandle);
12
-
13
- const rawSlug = authorHandle.toLowerCase().replace(/[^a-z0-9]+/g, '-');
14
- let expectedSlug = rawSlug.replace(/^-+|-+$/g, '');
15
- console.log('Raw Slug:', expectedSlug);
16
-
17
- if (!expectedSlug || expectedSlug.length < 2) {
18
- console.log('Using Fallback...');
19
- // Mimic fallback
20
- const fallbackId = 'shadow-handle-fallback';
21
- const suffix = `-${Date.now().toString(36)}`;
22
- expectedSlug = `user-${fallbackId}${suffix}`;
23
- console.log('Fallback Slug:', expectedSlug);
24
- } else {
25
- console.log('Final Slug:', expectedSlug);
26
- }
27
-
28
- } catch (error) {
29
- console.error('Fetch Error:', error);
30
- }
31
- }
32
-
33
- main();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/app/admin/dashboard/page.tsx CHANGED
@@ -1,6 +1,6 @@
1
  import { getAdminStats } from "@/features/admin/actions/stats";
2
  import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
3
- import { Users, Video, ShoppingBag, Eye, ArrowRight, PlayCircle, Clock } from "lucide-react";
4
  import { Button } from "@/components/ui/button";
5
  import Link from "next/link";
6
 
@@ -58,15 +58,41 @@ export default async function AdminDashboardPage() {
58
  </Card>
59
  </Link>
60
 
61
- <Link href="/admin/moderation" className="block outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-xl">
62
  <Card className="hover:border-emerald-500/50 transition-colors cursor-pointer h-full">
63
  <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
64
- <CardTitle className="text-sm font-medium">High Interest Items</CardTitle>
65
  <ShoppingBag className="h-4 w-4 text-emerald-500" />
66
  </CardHeader>
67
  <CardContent>
68
  <div className="text-2xl font-bold">{stats.highInterestItems}</div>
69
- <p className="text-xs text-muted-foreground">5+ interest pledges</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  </CardContent>
71
  </Card>
72
  </Link>
@@ -106,24 +132,26 @@ export default async function AdminDashboardPage() {
106
  </CardContent>
107
  </Card>
108
 
109
- <Card className="col-span-3">
110
- <CardHeader>
111
- <CardTitle>Platform Stats</CardTitle>
112
- <CardDescription>Overall product health metrics.</CardDescription>
113
- </CardHeader>
114
- <CardContent>
115
- <div className="space-y-4">
116
- <div className="flex items-center justify-between">
117
- <span className="text-sm font-medium">Total Products Linked</span>
118
- <span className="text-lg font-bold">{stats.totalProducts}</span>
119
- </div>
120
- <div className="w-full bg-secondary h-2 rounded-full overflow-hidden">
121
- <div className="bg-primary h-full" style={{ width: '100%' }} />
 
 
 
122
  </div>
123
- <p className="text-xs text-muted-foreground">Values reflect cumulative platform growth since launch.</p>
124
- </div>
125
- </CardContent>
126
- </Card>
127
  </div>
128
  </div>
129
  );
 
1
  import { getAdminStats } from "@/features/admin/actions/stats";
2
  import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
3
+ import { Users, Video, ShoppingBag, Eye, ArrowRight, PlayCircle, Clock, MessagesSquare, Link2 } from "lucide-react";
4
  import { Button } from "@/components/ui/button";
5
  import Link from "next/link";
6
 
 
58
  </Card>
59
  </Link>
60
 
61
+ <Link href="/admin/moderation?minPledges=1" className="block outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-xl">
62
  <Card className="hover:border-emerald-500/50 transition-colors cursor-pointer h-full">
63
  <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
64
+ <CardTitle className="text-sm font-medium">Interest Items</CardTitle>
65
  <ShoppingBag className="h-4 w-4 text-emerald-500" />
66
  </CardHeader>
67
  <CardContent>
68
  <div className="text-2xl font-bold">{stats.highInterestItems}</div>
69
+ <p className="text-xs text-muted-foreground">Any user interest</p>
70
+ </CardContent>
71
+ </Card>
72
+ </Link>
73
+
74
+ <Link href="/admin/requests" className="block outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-xl">
75
+ <Card className="hover:border-amber-500/50 transition-colors cursor-pointer h-full">
76
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
77
+ <CardTitle className="text-sm font-medium">Fan Requests</CardTitle>
78
+ <MessagesSquare className="h-4 w-4 text-amber-500" />
79
+ </CardHeader>
80
+ <CardContent>
81
+ <div className="text-2xl font-bold">{stats.pendingRequests}</div>
82
+ <p className="text-xs text-muted-foreground">Awaiting sourcing</p>
83
+ </CardContent>
84
+ </Card>
85
+ </Link>
86
+
87
+ <Link href="/admin/requests" className="block outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-xl">
88
+ <Card className="hover:border-blue-500/50 transition-colors cursor-pointer h-full">
89
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
90
+ <CardTitle className="text-sm font-medium">Link Proposals</CardTitle>
91
+ <Link2 className="h-4 w-4 text-blue-500" />
92
+ </CardHeader>
93
+ <CardContent>
94
+ <div className="text-2xl font-bold">{stats.pendingProposals}</div>
95
+ <p className="text-xs text-muted-foreground">User suggested links</p>
96
  </CardContent>
97
  </Card>
98
  </Link>
 
132
  </CardContent>
133
  </Card>
134
 
135
+ <Link href="/admin/moderation?status=all" className="col-span-3 block outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-xl">
136
+ <Card className="hover:border-primary/50 transition-colors cursor-pointer h-full">
137
+ <CardHeader>
138
+ <CardTitle>Platform Stats</CardTitle>
139
+ <CardDescription>Overall product health metrics.</CardDescription>
140
+ </CardHeader>
141
+ <CardContent>
142
+ <div className="space-y-4">
143
+ <div className="flex items-center justify-between">
144
+ <span className="text-sm font-medium">Total Products Linked</span>
145
+ <span className="text-lg font-bold">{stats.totalProducts}</span>
146
+ </div>
147
+ <div className="w-full bg-secondary h-2 rounded-full overflow-hidden">
148
+ <div className="bg-primary h-full" style={{ width: '100%' }} />
149
+ </div>
150
+ <p className="text-xs text-muted-foreground">Values reflect cumulative platform growth since launch.</p>
151
  </div>
152
+ </CardContent>
153
+ </Card>
154
+ </Link>
 
155
  </div>
156
  </div>
157
  );
src/app/admin/moderation/page.tsx CHANGED
@@ -3,7 +3,7 @@ import { getAdminSession } from "@/lib/admin";
3
  import { AdminModerationService } from "@/features/moderation/services/admin-moderation.service";
4
  import { AdminModerationQueue } from "@/features/moderation/components/admin-moderation-queue";
5
  import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
6
- import { ShieldCheck, Flag, CheckCircle2, XCircle, ArrowLeft } from "lucide-react";
7
  import Link from "next/link";
8
 
9
  export const metadata = {
@@ -11,16 +11,28 @@ export const metadata = {
11
  description: "High-ambiguity tag moderation for ops admins",
12
  };
13
 
14
- export default async function AdminModerationPage() {
 
 
 
 
15
  const session = await getAdminSession();
16
 
17
  if (!session) {
18
  redirect("/login?callbackUrl=/admin/moderation");
19
  }
20
 
 
 
 
 
21
  const adminService = new AdminModerationService();
22
  const [rawQueue, stats] = await Promise.all([
23
- adminService.getAdminQueue({ limit: 50 }),
 
 
 
 
24
  adminService.getAdminQueueStats(),
25
  ]);
26
 
@@ -48,42 +60,65 @@ export default async function AdminModerationPage() {
48
  </div>
49
 
50
  {/* Stats Overview */}
51
- <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
52
- <Card className="bg-card/40 backdrop-blur-md border-border/40">
53
- <CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
54
- <CardTitle className="text-sm font-medium">Flagged Queue</CardTitle>
55
- <Flag className="w-4 h-4 text-amber-500" />
56
- </CardHeader>
57
- <CardContent>
58
- <div className="text-2xl font-bold">{stats.flagged}</div>
59
- <p className="text-xs text-muted-foreground">Awaiting review</p>
60
- </CardContent>
61
- </Card>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
 
63
- <Card className="bg-card/40 backdrop-blur-md border-border/40">
64
- <CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
65
- <CardTitle className="text-sm font-medium">Corrected Today</CardTitle>
66
- <CheckCircle2 className="w-4 h-4 text-emerald-500" />
67
- </CardHeader>
68
- <CardContent>
69
- <div className="text-2xl font-bold">{stats.correctedToday}</div>
70
- <p className="text-xs text-muted-foreground">Moved to creator queue</p>
71
- </CardContent>
72
- </Card>
 
 
73
 
74
- <Card className="bg-card/40 backdrop-blur-md border-border/40">
75
- <CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
76
- <CardTitle className="text-sm font-medium">Rejected Today</CardTitle>
77
- <XCircle className="w-4 h-4 text-rose-500" />
78
- </CardHeader>
79
- <CardContent>
80
- <div className="text-2xl font-bold">{stats.markedIncorrectToday}</div>
81
- <p className="text-xs text-muted-foreground">Marked as false positives</p>
82
- </CardContent>
83
- </Card>
 
 
84
  </div>
85
 
86
- <AdminModerationQueue initialDetections={queue} />
 
 
 
 
87
  </div>
88
  );
89
  }
 
3
  import { AdminModerationService } from "@/features/moderation/services/admin-moderation.service";
4
  import { AdminModerationQueue } from "@/features/moderation/components/admin-moderation-queue";
5
  import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
6
+ import { ShieldCheck, Flag, CheckCircle2, XCircle, ArrowLeft, Clock, Globe } from "lucide-react";
7
  import Link from "next/link";
8
 
9
  export const metadata = {
 
11
  description: "High-ambiguity tag moderation for ops admins",
12
  };
13
 
14
+ export default async function AdminModerationPage({
15
+ searchParams,
16
+ }: {
17
+ searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
18
+ }) {
19
  const session = await getAdminSession();
20
 
21
  if (!session) {
22
  redirect("/login?callbackUrl=/admin/moderation");
23
  }
24
 
25
+ const params = await searchParams;
26
+ const status = typeof params.status === 'string' ? params.status as any : undefined;
27
+ const minPledges = typeof params.minPledges === 'string' ? parseInt(params.minPledges) : 0;
28
+
29
  const adminService = new AdminModerationService();
30
  const [rawQueue, stats] = await Promise.all([
31
+ adminService.getAdminQueue({
32
+ limit: 100,
33
+ minPledges,
34
+ status
35
+ }),
36
  adminService.getAdminQueueStats(),
37
  ]);
38
 
 
60
  </div>
61
 
62
  {/* Stats Overview */}
63
+ <div className="grid grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6">
64
+ <Link href="/admin/moderation?status=flagged" className="block outline-none">
65
+ <Card className={`h-full bg-card/40 backdrop-blur-md border-border/40 hover:border-amber-500/50 transition-colors cursor-pointer ${status === 'flagged' ? 'border-amber-500/50 ring-1 ring-amber-500/50' : ''}`}>
66
+ <CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
67
+ <CardTitle className="text-sm font-medium">Flagged Queue</CardTitle>
68
+ <Flag className="w-4 h-4 text-amber-500" />
69
+ </CardHeader>
70
+ <CardContent>
71
+ <div className="text-2xl font-bold">{stats.flagged}</div>
72
+ <p className="text-xs text-muted-foreground">AI detections</p>
73
+ </CardContent>
74
+ </Card>
75
+ </Link>
76
+
77
+ <Link href="/admin/moderation?status=pending_creator" className="block outline-none">
78
+ <Card className={`h-full bg-card/40 backdrop-blur-md border-border/40 hover:border-blue-500/50 transition-colors cursor-pointer ${status === 'pending_creator' ? 'border-blue-500/50 ring-1 ring-blue-500/50' : ''}`}>
79
+ <CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
80
+ <CardTitle className="text-sm font-medium">Pending Creator</CardTitle>
81
+ <Clock className="w-4 h-4 text-blue-500" />
82
+ </CardHeader>
83
+ <CardContent>
84
+ <div className="text-2xl font-bold">{stats.pendingCreator}</div>
85
+ <p className="text-xs text-muted-foreground">Waiting for acceptance</p>
86
+ </CardContent>
87
+ </Card>
88
+ </Link>
89
 
90
+ <Link href="/admin/moderation?status=live" className="block outline-none">
91
+ <Card className={`h-full bg-card/40 backdrop-blur-md border-border/40 hover:border-emerald-500/50 transition-colors cursor-pointer ${status === 'live' ? 'border-emerald-500/50 ring-1 ring-emerald-500/50' : ''}`}>
92
+ <CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
93
+ <CardTitle className="text-sm font-medium">Live Catalog</CardTitle>
94
+ <Globe className="w-4 h-4 text-emerald-500" />
95
+ </CardHeader>
96
+ <CardContent>
97
+ <div className="text-2xl font-bold">{stats.live}</div>
98
+ <p className="text-xs text-muted-foreground">Visible in Vaults</p>
99
+ </CardContent>
100
+ </Card>
101
+ </Link>
102
 
103
+ <Link href="/admin/moderation?status=rejected" className="block outline-none">
104
+ <Card className={`h-full bg-card/40 backdrop-blur-md border-border/40 hover:border-rose-500/50 transition-colors cursor-pointer ${status === 'rejected' ? 'border-rose-500/50 ring-1 ring-rose-500/50' : ''}`}>
105
+ <CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
106
+ <CardTitle className="text-sm font-medium">Rejected</CardTitle>
107
+ <XCircle className="w-4 h-4 text-rose-500" />
108
+ </CardHeader>
109
+ <CardContent>
110
+ <div className="text-2xl font-bold">{stats.rejected}</div>
111
+ <p className="text-xs text-muted-foreground">Admin rejected</p>
112
+ </CardContent>
113
+ </Card>
114
+ </Link>
115
  </div>
116
 
117
+ <AdminModerationQueue
118
+ initialDetections={queue}
119
+ initialMinPledges={status === 'flagged' ? minPledges : 0}
120
+ initialCategory={typeof params.category === 'string' ? params.category : "all"}
121
+ />
122
  </div>
123
  );
124
  }
src/app/admin/requests/page.tsx ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { auth } from '@/lib/auth';
2
+ import { headers } from 'next/headers';
3
+ import { redirect } from 'next/navigation';
4
+ import { db } from '@/lib/db';
5
+ import {
6
+ productRequests,
7
+ affiliateProposals,
8
+ youtubeVideos,
9
+ youtubeChannels,
10
+ users,
11
+ } from '@/lib/db/schema';
12
+ import { eq, desc, and, isNull, sql } from 'drizzle-orm';
13
+ import { Clock, ArrowLeft, ExternalLink, MessagesSquare, Link2 as LinkIcon } from 'lucide-react';
14
+ import { Card, CardContent } from '@/components/ui/card';
15
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
16
+ import Link from 'next/link';
17
+ import { UserNav } from '@/components/auth/user-nav';
18
+ import { Badge } from '@/components/ui/badge';
19
+ import { getAdminRequests, getAdminProposals } from '@/features/admin/actions/requests';
20
+
21
+ import { AdminRequestList } from '@/features/admin/components/admin-request-list';
22
+ import { AdminProposalList } from '@/features/admin/components/admin-proposal-list';
23
+
24
+ export default async function AdminRequestsPage() {
25
+ // Basic admin check - for now just session check,
26
+ // real admin check would be on session.user.role
27
+ const session = await auth.api.getSession({
28
+ headers: await headers(),
29
+ });
30
+
31
+ if (!session?.user) {
32
+ redirect('/login');
33
+ }
34
+
35
+ // In a real app, check for ADMIN role here
36
+ // if (session.user.role !== 'ADMIN') redirect('/dashboard');
37
+
38
+ const requests = await getAdminRequests();
39
+ const proposals = await getAdminProposals();
40
+
41
+ return (
42
+ <div className="space-y-8 animate-in fade-in duration-500">
43
+ <div className="flex items-center justify-between">
44
+ <div className="flex items-center gap-4">
45
+ <Link href="/admin/dashboard" className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors">
46
+ <ArrowLeft className="h-4 w-4" />
47
+ Admin Dashboard
48
+ </Link>
49
+ </div>
50
+ <UserNav />
51
+ </div>
52
+
53
+ <div className="space-y-2">
54
+ <h1 className="text-4xl font-extrabold tracking-tight">Global Opportunity Hub</h1>
55
+ <p className="text-muted-foreground text-lg">
56
+ Manage fan requests and community link proposals across all creators.
57
+ </p>
58
+ </div>
59
+
60
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
61
+ <Card className="glass border-white/10 text-left">
62
+ <CardContent className="pt-6">
63
+ <div className="flex items-center justify-between">
64
+ <div>
65
+ <div className="text-2xl font-bold text-orange-400">
66
+ {requests.filter(r => r.status === 'PENDING').length}
67
+ </div>
68
+ <p className="text-sm text-gray-400 mt-1">Pending Fan Requests</p>
69
+ </div>
70
+ <MessagesSquare className="h-8 w-8 text-orange-400/20" />
71
+ </div>
72
+ </CardContent>
73
+ </Card>
74
+ <Card className="glass border-white/10 text-left">
75
+ <CardContent className="pt-6">
76
+ <div className="flex items-center justify-between">
77
+ <div>
78
+ <div className="text-2xl font-bold text-blue-400">
79
+ {proposals.filter(p => p.status === 'PENDING').length}
80
+ </div>
81
+ <p className="text-sm text-gray-400 mt-1">Pending Link Proposals</p>
82
+ </div>
83
+ <LinkIcon className="h-8 w-8 text-blue-400/20" />
84
+ </div>
85
+ </CardContent>
86
+ </Card>
87
+ </div>
88
+
89
+ <Tabs defaultValue="requests" className="w-full">
90
+ <TabsList className="grid w-full max-w-md grid-cols-2 bg-secondary/50 border border-border/50">
91
+ <TabsTrigger value="requests" className="gap-2">
92
+ <MessagesSquare className="h-4 w-4" />
93
+ Fan Requests
94
+ </TabsTrigger>
95
+ <TabsTrigger value="proposals" className="gap-2">
96
+ <LinkIcon className="h-4 w-4" />
97
+ Link Proposals
98
+ </TabsTrigger>
99
+ </TabsList>
100
+
101
+ <TabsContent value="requests" className="mt-6">
102
+ <AdminRequestList requests={requests as any} />
103
+ </TabsContent>
104
+
105
+ <TabsContent value="proposals" className="mt-6">
106
+ <AdminProposalList proposals={proposals as any} />
107
+ </TabsContent>
108
+ </Tabs>
109
+ </div>
110
+ );
111
+ }
src/features/admin/actions/requests.ts ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use server";
2
+
3
+ import { db } from "@/lib/db";
4
+ import {
5
+ productRequests,
6
+ affiliateProposals,
7
+ youtubeVideos,
8
+ youtubeChannels,
9
+ users,
10
+ } from "@/lib/db/schema";
11
+ import { eq, desc, and } from "drizzle-orm";
12
+
13
+ export async function getAdminRequests() {
14
+ const requests = await db.query.productRequests.findMany({
15
+ with: {
16
+ video: {
17
+ with: {
18
+ channel: true
19
+ }
20
+ },
21
+ creator: true,
22
+ },
23
+ orderBy: [desc(productRequests.createdAt)],
24
+ });
25
+
26
+ return requests;
27
+ }
28
+
29
+ export async function getAdminProposals() {
30
+ const proposals = await db.query.affiliateProposals.findMany({
31
+ with: {
32
+ video: {
33
+ with: {
34
+ channel: true
35
+ }
36
+ },
37
+ creator: true,
38
+ },
39
+ orderBy: [desc(affiliateProposals.createdAt)],
40
+ });
41
+
42
+ return proposals;
43
+ }
src/features/admin/actions/stats.ts CHANGED
@@ -1,7 +1,14 @@
1
  "use server";
2
 
3
  import { db } from "@/lib/db";
4
- import { users, youtubeVideos, detectedObjects, interestPledges } from "@/lib/db/schema";
 
 
 
 
 
 
 
5
  import { count, eq, sql } from "drizzle-orm";
6
  import { auth } from "@/lib/auth";
7
  import { headers } from "next/headers";
@@ -30,19 +37,31 @@ export async function getAdminStats() {
30
  const [awaitingApproval] = await db.select({ count: count() }).from(youtubeVideos).where(eq(youtubeVideos.scanStatus, 'awaiting_approval'));
31
  const [totalProducts] = await db.select({ count: count() }).from(detectedObjects);
32
 
33
- // High Interest Items: Detections with 5+ "I want this" requests
34
- const highInterestCount = await db
35
  .select({ count: sql<number>`count(distinct ${detectedObjects.id})` })
36
  .from(detectedObjects)
37
  .innerJoin(interestPledges, eq(detectedObjects.id, interestPledges.detectedObjectId))
38
  .groupBy(detectedObjects.id)
39
- .having(sql`count(${interestPledges.id}) >= 5`);
 
 
 
 
 
 
 
 
 
 
40
 
41
  return {
42
  totalUsers: Number(userCount.count),
43
  pendingAnalysis: Number(pendingAnalysis.count),
44
  awaitingApproval: Number(awaitingApproval.count),
45
  totalProducts: Number(totalProducts.count),
46
- highInterestItems: highInterestCount.length
 
 
47
  };
48
  }
 
1
  "use server";
2
 
3
  import { db } from "@/lib/db";
4
+ import {
5
+ users,
6
+ youtubeVideos,
7
+ detectedObjects,
8
+ interestPledges,
9
+ productRequests,
10
+ affiliateProposals,
11
+ } from "@/lib/db/schema";
12
  import { count, eq, sql } from "drizzle-orm";
13
  import { auth } from "@/lib/auth";
14
  import { headers } from "next/headers";
 
37
  const [awaitingApproval] = await db.select({ count: count() }).from(youtubeVideos).where(eq(youtubeVideos.scanStatus, 'awaiting_approval'));
38
  const [totalProducts] = await db.select({ count: count() }).from(detectedObjects);
39
 
40
+ // Interest Items: Detections with 1+ "I want this" requests
41
+ const interestCount = await db
42
  .select({ count: sql<number>`count(distinct ${detectedObjects.id})` })
43
  .from(detectedObjects)
44
  .innerJoin(interestPledges, eq(detectedObjects.id, interestPledges.detectedObjectId))
45
  .groupBy(detectedObjects.id)
46
+ .having(sql`count(${interestPledges.id}) >= 1`);
47
+
48
+ // Pending Community Requests
49
+ const [pendingRequests] = await db.select({ count: count() })
50
+ .from(productRequests)
51
+ .where(eq(productRequests.status, 'PENDING'));
52
+
53
+ // Pending Affiliate Proposals
54
+ const [pendingProposals] = await db.select({ count: count() })
55
+ .from(affiliateProposals)
56
+ .where(eq(affiliateProposals.status, 'PENDING'));
57
 
58
  return {
59
  totalUsers: Number(userCount.count),
60
  pendingAnalysis: Number(pendingAnalysis.count),
61
  awaitingApproval: Number(awaitingApproval.count),
62
  totalProducts: Number(totalProducts.count),
63
+ highInterestItems: interestCount.length,
64
+ pendingRequests: Number(pendingRequests.count),
65
+ pendingProposals: Number(pendingProposals.count),
66
  };
67
  }
src/features/admin/components/admin-proposal-list.tsx ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import React, { useState, useTransition } from 'react';
4
+ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
5
+ import { Badge } from '@/components/ui/badge';
6
+ import { Button } from '@/components/ui/button';
7
+ import { Check, X, Link2, ExternalLink, User, Clock, Tv, Search, Filter } from 'lucide-react';
8
+ import { approveProposal, rejectProposal } from '@/features/moderation/actions/handle-proposal';
9
+ import { toast } from 'sonner';
10
+ import { formatDistanceToNow } from 'date-fns';
11
+ import { Label } from '@/components/ui/label';
12
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
13
+ import { Input } from '@/components/ui/input';
14
+
15
+ interface Proposal {
16
+ id: string;
17
+ productName: string;
18
+ productUrl: string;
19
+ affiliateUrl: string;
20
+ submitterName: string | null;
21
+ submitterEmail: string | null;
22
+ note: string | null;
23
+ status: 'PENDING' | 'APPROVED' | 'REJECTED';
24
+ createdAt: Date;
25
+ video: {
26
+ title: string;
27
+ channel: {
28
+ channelName: string;
29
+ };
30
+ };
31
+ }
32
+
33
+ interface AdminProposalListProps {
34
+ proposals: Proposal[];
35
+ }
36
+
37
+ export function AdminProposalList({ proposals: initialProposals }: AdminProposalListProps) {
38
+ const [proposals, setProposals] = useState(initialProposals);
39
+ const [isPending, startTransition] = useTransition();
40
+ const [filterStatus, setFilterStatus] = useState<string>('PENDING');
41
+ const [searchQuery, setSearchQuery] = useState('');
42
+
43
+ const handleApprove = (id: string) => {
44
+ startTransition(async () => {
45
+ const result = await approveProposal(id);
46
+ if (result.success) {
47
+ setProposals(prev => prev.map(p => p.id === id ? { ...p, status: 'APPROVED' } : p));
48
+ toast.success('Proposal approved and added to Vault!');
49
+ } else {
50
+ toast.error(result.error || 'Failed to approve');
51
+ }
52
+ });
53
+ };
54
+
55
+ const handleReject = (id: string) => {
56
+ startTransition(async () => {
57
+ const result = await rejectProposal(id);
58
+ if (result.success) {
59
+ setProposals(prev => prev.map(p => p.id === id ? { ...p, status: 'REJECTED' } : p));
60
+ toast.success('Proposal rejected');
61
+ } else {
62
+ toast.error(result.error || 'Failed to reject');
63
+ }
64
+ });
65
+ };
66
+
67
+ const filteredProposals = proposals.filter(p => {
68
+ if (p.status !== filterStatus) return false;
69
+ if (searchQuery) {
70
+ const query = searchQuery.toLowerCase();
71
+ return (
72
+ p.productName.toLowerCase().includes(query) ||
73
+ (p.submitterName?.toLowerCase().includes(query) ?? false) ||
74
+ (p.submitterEmail?.toLowerCase().includes(query) ?? false) ||
75
+ (p.video.title.toLowerCase().includes(query)) ||
76
+ (p.video.channel.channelName.toLowerCase().includes(query))
77
+ );
78
+ }
79
+ return true;
80
+ });
81
+
82
+ const stats = {
83
+ pending: proposals.filter(p => p.status === 'PENDING').length,
84
+ approved: proposals.filter(p => p.status === 'APPROVED').length,
85
+ rejected: proposals.filter(p => p.status === 'REJECTED').length,
86
+ };
87
+
88
+ return (
89
+ <div className="space-y-6">
90
+ {/* Filter Bar */}
91
+ <div className="flex flex-col gap-4 bg-card/50 p-4 rounded-lg border border-border/50 backdrop-blur-sm">
92
+ <div className="flex flex-wrap items-center gap-4">
93
+ <div className="space-y-1">
94
+ <Label>Status</Label>
95
+ <Select value={filterStatus} onValueChange={setFilterStatus}>
96
+ <SelectTrigger className="w-[150px] h-9 bg-background/50">
97
+ <SelectValue placeholder="Status" />
98
+ </SelectTrigger>
99
+ <SelectContent>
100
+ <SelectItem value="PENDING">Pending ({stats.pending})</SelectItem>
101
+ <SelectItem value="APPROVED">Approved ({stats.approved})</SelectItem>
102
+ <SelectItem value="REJECTED">Rejected ({stats.rejected})</SelectItem>
103
+ </SelectContent>
104
+ </Select>
105
+ </div>
106
+
107
+ <div className="space-y-1 flex-grow">
108
+ <Label>Search</Label>
109
+ <div className="relative">
110
+ <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
111
+ <Input
112
+ placeholder="Search by product, creator, video, submitter..."
113
+ value={searchQuery}
114
+ onChange={(e) => setSearchQuery(e.target.value)}
115
+ className="pl-9 h-9 bg-background/50"
116
+ />
117
+ </div>
118
+ </div>
119
+ </div>
120
+ </div>
121
+
122
+ <div className="grid gap-6">
123
+ {filteredProposals.length > 0 ? (
124
+ filteredProposals.map((proposal) => (
125
+ <Card key={proposal.id} className="glass border-white/10 hover:border-white/20 transition-all overflow-hidden group">
126
+ <CardHeader className="pb-3 border-b border-white/5 bg-white/5">
127
+ <div className="flex items-center justify-between">
128
+ <div className="space-y-1">
129
+ <div className="flex items-center gap-2">
130
+ <CardTitle className="text-lg">{proposal.productName}</CardTitle>
131
+ <Badge variant={proposal.status === 'PENDING' ? 'secondary' : proposal.status === 'APPROVED' ? 'default' : 'destructive'} className="text-[10px] uppercase font-bold tracking-wider">
132
+ {proposal.status}
133
+ </Badge>
134
+ <div className="flex items-center gap-1.5 text-[10px] font-bold text-amber-500 uppercase tracking-widest bg-amber-500/10 px-2 py-0.5 rounded-full border border-amber-500/20">
135
+ <Tv className="h-3 w-3" />
136
+ {proposal.video.channel.channelName}
137
+ </div>
138
+ </div>
139
+ <p className="text-xs text-muted-foreground flex items-center gap-1">
140
+ <Clock className="h-3 w-3" />
141
+ Submitted {formatDistanceToNow(new Date(proposal.createdAt))} ago •
142
+ <span className="text-white/40 ml-1 truncate max-w-[200px] inline-block">{proposal.video.title}</span>
143
+ </p>
144
+ </div>
145
+ {proposal.status === 'PENDING' && (
146
+ <div className="flex items-center gap-2">
147
+ <Button
148
+ size="sm"
149
+ variant="ghost"
150
+ onClick={() => handleReject(proposal.id)}
151
+ disabled={isPending}
152
+ className="text-red-400 hover:text-red-300 hover:bg-red-400/10"
153
+ >
154
+ <X className="h-4 w-4 mr-1.5" />
155
+ Reject
156
+ </Button>
157
+ <Button
158
+ size="sm"
159
+ onClick={() => handleApprove(proposal.id)}
160
+ disabled={isPending}
161
+ className="bg-green-600 hover:bg-green-500 text-white"
162
+ >
163
+ <Check className="h-4 w-4 mr-1.5" />
164
+ Approve & List
165
+ </Button>
166
+ </div>
167
+ )}
168
+ </div>
169
+ </CardHeader>
170
+ <CardContent className="py-4 grid sm:grid-cols-2 gap-6">
171
+ <div className="space-y-4">
172
+ <div className="space-y-1.5">
173
+ <span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Original Link</span>
174
+ <a
175
+ href={proposal.productUrl}
176
+ target="_blank"
177
+ rel="noopener noreferrer"
178
+ className="flex items-center gap-2 text-sm text-blue-400 hover:underline group"
179
+ >
180
+ View Product Page
181
+ <ExternalLink className="h-3 w-3 opacity-0 group-hover:opacity-100 transition-opacity" />
182
+ </a>
183
+ </div>
184
+ <div className="space-y-1.5">
185
+ <span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Affiliate Link</span>
186
+ <div className="bg-white/5 border border-white/10 rounded-md p-2 flex items-center justify-between">
187
+ <span className="text-xs font-mono truncate text-gray-300">{proposal.affiliateUrl}</span>
188
+ <a href={proposal.affiliateUrl} target="_blank" rel="noopener noreferrer">
189
+ <Button variant="ghost" size="icon" className="h-6 w-6">
190
+ <ExternalLink className="h-3 w-3" />
191
+ </Button>
192
+ </a>
193
+ </div>
194
+ </div>
195
+ </div>
196
+ <div className="space-y-4">
197
+ <div className="space-y-1.5">
198
+ <span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Submitter Details</span>
199
+ <div className="flex items-center gap-2 text-sm text-gray-200">
200
+ <User className="h-3.5 w-3.5 text-muted-foreground" />
201
+ {proposal.submitterName || 'Anonymous'}
202
+ {proposal.submitterEmail && (
203
+ <span className="text-muted-foreground ml-1">({proposal.submitterEmail})</span>
204
+ )}
205
+ </div>
206
+ </div>
207
+ {proposal.note && (
208
+ <div className="space-y-1.5">
209
+ <span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Note</span>
210
+ <p className="text-sm text-gray-300 italic border-l-2 border-primary/30 pl-3">
211
+ "{proposal.note}"
212
+ </p>
213
+ </div>
214
+ )}
215
+ </div>
216
+ </CardContent>
217
+ </Card>
218
+ ))
219
+ ) : (
220
+ <div className="py-20 text-center space-y-4 bg-muted/20 rounded-xl border-2 border-dashed border-border/50">
221
+ <h3 className="text-xl font-semibold text-muted-foreground">No proposals found</h3>
222
+ <p className="text-muted-foreground text-sm">Try adjusting your filters or search query.</p>
223
+ </div>
224
+ )}
225
+ </div>
226
+ </div>
227
+ );
228
+ }
src/features/admin/components/admin-request-list.tsx ADDED
@@ -0,0 +1,236 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import React, { useState } from 'react';
4
+ import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
5
+ import { Badge } from '@/components/ui/badge';
6
+ import { formatDistanceToNow } from 'date-fns';
7
+ import { CheckCircle, Clock, ShieldAlert, Filter, Search, User, Mail, Video as VideoIcon, Tv } from 'lucide-react';
8
+ import { Label } from '@/components/ui/label';
9
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
10
+ import { Input } from '@/components/ui/input';
11
+ import { updateRequestStatus } from '@/actions/submit-request';
12
+ import { toast } from 'sonner';
13
+ import Image from 'next/image';
14
+
15
+ interface Request {
16
+ id: string;
17
+ videoId: string;
18
+ creatorId: string;
19
+ viewerName: string | null;
20
+ viewerEmail: string | null;
21
+ note: string;
22
+ imageUrl: string | null;
23
+ status: string;
24
+ createdAt: Date;
25
+ video: {
26
+ title: string;
27
+ channel: {
28
+ channelName: string;
29
+ };
30
+ };
31
+ creator: {
32
+ name: string | null;
33
+ };
34
+ }
35
+
36
+ interface AdminRequestListProps {
37
+ requests: Request[];
38
+ }
39
+
40
+ export function AdminRequestList({ requests: initialRequests }: AdminRequestListProps) {
41
+ const [requests, setRequests] = useState(initialRequests);
42
+ const [filterStatus, setFilterStatus] = useState<string>('PENDING');
43
+ const [sortBy, setSortBy] = useState<string>('date');
44
+ const [searchQuery, setSearchQuery] = useState('');
45
+
46
+ const stats = {
47
+ pending: requests.filter(r => r.status === 'PENDING').length,
48
+ fulfilled: requests.filter(r => r.status === 'FULFILLED').length,
49
+ dismissed: requests.filter(r => r.status === 'DISMISSED').length,
50
+ };
51
+
52
+ const filteredRequests = requests.filter(r => {
53
+ if (r.status !== filterStatus) return false;
54
+ if (searchQuery) {
55
+ const query = searchQuery.toLowerCase();
56
+ return (
57
+ r.note.toLowerCase().includes(query) ||
58
+ (r.viewerName?.toLowerCase().includes(query) ?? false) ||
59
+ (r.viewerEmail?.toLowerCase().includes(query) ?? false) ||
60
+ (r.video.title.toLowerCase().includes(query)) ||
61
+ (r.video.channel.channelName.toLowerCase().includes(query))
62
+ );
63
+ }
64
+ return true;
65
+ }).sort((a, b) => {
66
+ if (sortBy === 'date') {
67
+ return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
68
+ }
69
+ return 0;
70
+ });
71
+
72
+ const handleStatusUpdate = async (id: string, newStatus: 'PENDING' | 'FULFILLED' | 'DISMISSED') => {
73
+ try {
74
+ const result = await updateRequestStatus(id, newStatus);
75
+ if (result.success) {
76
+ setRequests(prev => prev.map(r => r.id === id ? { ...r, status: newStatus } : r));
77
+ toast.success(`Request marked as ${newStatus.toLowerCase()}`);
78
+ } else {
79
+ toast.error(result.error || 'Failed to update status');
80
+ }
81
+ } catch (err) {
82
+ toast.error('Failed to update request status');
83
+ }
84
+ };
85
+
86
+ const statCards = [
87
+ { status: 'PENDING', title: 'Pending Review', count: stats.pending, icon: <Clock className="w-5 h-5 text-amber-500" />, description: 'Awaiting action', activeClasses: 'ring-2 ring-amber-500 bg-amber-500/10 border-amber-500/50' },
88
+ { status: 'FULFILLED', title: 'Fulfilled', count: stats.fulfilled, icon: <CheckCircle className="w-5 h-5 text-emerald-500" />, description: 'Added to Vault', activeClasses: 'ring-2 ring-emerald-500 bg-emerald-500/10 border-emerald-500/50' },
89
+ { status: 'DISMISSED', title: 'Dismissed', count: stats.dismissed, icon: <ShieldAlert className="w-5 h-5 text-rose-500" />, description: 'Hidden requests', activeClasses: 'ring-2 ring-rose-500 bg-rose-500/10 border-rose-500/50' },
90
+ ];
91
+
92
+ return (
93
+ <div className="space-y-6">
94
+ {/* Stat Cards */}
95
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
96
+ {statCards.map(({ status, title, count, icon, description, activeClasses }) => (
97
+ <button
98
+ key={status}
99
+ type="button"
100
+ onClick={() => setFilterStatus(status)}
101
+ className={`text-left p-4 rounded-lg border transition-all group ${filterStatus === status ? activeClasses : 'bg-card/40 backdrop-blur-md border-border/40 hover:bg-card/60'
102
+ }`}
103
+ >
104
+ <div className="flex items-center justify-between pb-2">
105
+ <span className="text-sm font-medium">{title}</span>
106
+ <div className="p-2 bg-background/50 rounded-lg group-hover:scale-110 transition-transform">
107
+ {icon}
108
+ </div>
109
+ </div>
110
+ <div className="text-3xl font-bold">{count}</div>
111
+ <p className="text-xs text-muted-foreground mt-1">{description}</p>
112
+ </button>
113
+ ))}
114
+ </div>
115
+
116
+ {/* Filter Bar */}
117
+ <div className="flex flex-col gap-4 bg-card/50 p-4 rounded-lg border border-border/50 backdrop-blur-sm">
118
+ <div className="flex flex-wrap items-center gap-4">
119
+ <div className="space-y-1">
120
+ <Label>Sort By</Label>
121
+ <Select value={sortBy} onValueChange={setSortBy}>
122
+ <SelectTrigger className="w-[150px] h-9 bg-background/50">
123
+ <SelectValue placeholder="Sort By" />
124
+ </SelectTrigger>
125
+ <SelectContent>
126
+ <SelectItem value="date">Date (Newest)</SelectItem>
127
+ </SelectContent>
128
+ </Select>
129
+ </div>
130
+
131
+ <div className="space-y-1 flex-grow">
132
+ <Label>Search</Label>
133
+ <div className="relative">
134
+ <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
135
+ <Input
136
+ placeholder="Search by note, creator, video, name or email..."
137
+ value={searchQuery}
138
+ onChange={(e) => setSearchQuery(e.target.value)}
139
+ className="pl-9 h-9 bg-background/50"
140
+ />
141
+ </div>
142
+ </div>
143
+ </div>
144
+
145
+ <div className="flex items-center justify-between">
146
+ <div className="text-sm text-muted-foreground">
147
+ Showing {filteredRequests.length} of {requests.filter(r => r.status === filterStatus).length} {filterStatus.toLowerCase()} requests
148
+ </div>
149
+ </div>
150
+ </div>
151
+
152
+ {filteredRequests.length > 0 ? (
153
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
154
+ {filteredRequests.map((request) => (
155
+ <Card key={request.id} className="bg-card/60 border-white/10 overflow-hidden flex flex-col hover:border-primary/20 transition-all group">
156
+ {request.imageUrl && (
157
+ <div className="relative aspect-video w-full overflow-hidden border-b border-white/10">
158
+ <Image
159
+ src={request.imageUrl}
160
+ alt="Requested product"
161
+ fill
162
+ className="object-cover group-hover:scale-105 transition-transform duration-500"
163
+ />
164
+ </div>
165
+ )}
166
+ <CardHeader className="pb-2">
167
+ <div className="flex justify-between items-start gap-2">
168
+ <div className="flex flex-col gap-1.5">
169
+ <Badge variant={request.status === 'PENDING' ? 'secondary' : 'default'} className="w-fit bg-primary/20 text-primary border-primary/30 text-[10px] uppercase font-bold tracking-wider">
170
+ {request.status}
171
+ </Badge>
172
+ <div className="flex items-center gap-1.5 text-[10px] font-bold text-amber-500 uppercase tracking-widest bg-amber-500/10 px-2 py-0.5 rounded-full border border-amber-500/20">
173
+ <Tv className="h-3 w-3" />
174
+ {request.video.channel.channelName}
175
+ </div>
176
+ </div>
177
+ <span className="text-[10px] text-muted-foreground flex items-center gap-1">
178
+ <Clock className="h-3 w-3" />
179
+ {formatDistanceToNow(new Date(request.createdAt), { addSuffix: true })}
180
+ </span>
181
+ </div>
182
+ <CardTitle className="text-base mt-3 text-white line-clamp-2 leading-snug">
183
+ {request.note}
184
+ </CardTitle>
185
+ </CardHeader>
186
+ <CardContent className="space-y-3 flex-grow pb-4">
187
+ <div className="flex items-center gap-2 text-xs text-muted-foreground bg-white/5 p-2 rounded-lg">
188
+ <VideoIcon className="h-3.5 w-3.5 shrink-0 text-primary/70" />
189
+ <span className="truncate italic">"{request.video.title}"</span>
190
+ </div>
191
+ <div className="space-y-1.5 px-1">
192
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
193
+ <User className="h-3.5 w-3.5 shrink-0" />
194
+ <span><span className="text-white/60">From:</span> {request.viewerName || 'Anonymous'}</span>
195
+ </div>
196
+ {request.viewerEmail && (
197
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
198
+ <Mail className="h-3.5 w-3.5 shrink-0" />
199
+ <span className="truncate">{request.viewerEmail}</span>
200
+ </div>
201
+ )}
202
+ </div>
203
+ </CardContent>
204
+ <CardFooter className="flex gap-2 pt-4 border-t border-white/5 bg-white/5 mt-auto">
205
+ <button
206
+ onClick={() => handleStatusUpdate(request.id, 'FULFILLED')}
207
+ disabled={request.status === 'FULFILLED'}
208
+ className="flex-1 h-9 rounded-lg bg-emerald-500/10 text-emerald-500 border border-emerald-500/20 text-xs font-bold hover:bg-emerald-500 hover:text-white transition-all disabled:opacity-50"
209
+ >
210
+ Mark Fulfilled
211
+ </button>
212
+ <button
213
+ onClick={() => handleStatusUpdate(request.id, 'DISMISSED')}
214
+ disabled={request.status === 'DISMISSED'}
215
+ className="flex-1 h-9 rounded-lg bg-white/5 text-muted-foreground border border-white/10 text-xs font-bold hover:bg-rose-500/10 hover:text-rose-500 hover:border-rose-500/20 transition-all disabled:opacity-50"
216
+ >
217
+ Dismiss
218
+ </button>
219
+ </CardFooter>
220
+ </Card>
221
+ ))}
222
+ </div>
223
+ ) : (
224
+ <div className="py-20 text-center space-y-4 bg-muted/20 rounded-xl border-2 border-dashed border-border/50">
225
+ <div className="flex justify-center">
226
+ <ShieldAlert className="w-12 h-12 text-muted-foreground opacity-20" />
227
+ </div>
228
+ <div className="space-y-2">
229
+ <h3 className="text-xl font-semibold">No requests found</h3>
230
+ <p className="text-muted-foreground">Try adjusting your filters or search query.</p>
231
+ </div>
232
+ </div>
233
+ )}
234
+ </div>
235
+ );
236
+ }
src/features/moderation/components/admin-detection-card.tsx CHANGED
@@ -16,6 +16,7 @@ interface AdminDetection {
16
  category: string;
17
  confidenceScore: number;
18
  frameTimestamp: number;
 
19
  };
20
  video: {
21
  title: string;
@@ -25,6 +26,7 @@ interface AdminDetection {
25
  channelName: string;
26
  };
27
  marketplaceMatches: any[];
 
28
  }
29
 
30
  interface AdminDetectionCardProps {
@@ -42,6 +44,8 @@ export function AdminDetectionCard({ detection, onActionSuccess }: AdminDetectio
42
  return `${min}:${sec.toString().padStart(2, "0")}`;
43
  };
44
 
 
 
45
  return (
46
  <>
47
  <Card className="group overflow-hidden bg-card/40 backdrop-blur-md border-border/40 hover:ring-2 hover:ring-primary/50 transition-all">
@@ -58,15 +62,27 @@ export function AdminDetectionCard({ detection, onActionSuccess }: AdminDetectio
58
  <ShieldCheck className="w-8 h-8 text-muted-foreground opacity-20" />
59
  </div>
60
  )}
61
- <div className="absolute top-2 left-2">
62
- <Badge variant="outline" className="bg-amber-500/20 border-amber-500/40 text-amber-500 backdrop-blur-sm flex items-center gap-1">
63
- <AlertTriangle className="w-3 h-3" /> Flagged
64
- </Badge>
 
 
 
 
 
 
 
65
  </div>
66
- <div className="absolute top-2 right-2">
67
  <Badge variant="outline" className="bg-background/80 backdrop-blur-sm">
68
- {Math.round(detection.detection.confidenceScore * 100)}% Confidence
69
  </Badge>
 
 
 
 
 
70
  </div>
71
  <div className="absolute bottom-2 left-2 flex gap-2">
72
  <Badge variant="secondary" className="bg-background/80 backdrop-blur-sm flex items-center gap-1">
 
16
  category: string;
17
  confidenceScore: number;
18
  frameTimestamp: number;
19
+ status: string;
20
  };
21
  video: {
22
  title: string;
 
26
  channelName: string;
27
  };
28
  marketplaceMatches: any[];
29
+ pledgeCount?: number;
30
  }
31
 
32
  interface AdminDetectionCardProps {
 
44
  return `${min}:${sec.toString().padStart(2, "0")}`;
45
  };
46
 
47
+ const isHighInterest = (detection.pledgeCount || 0) >= 5;
48
+
49
  return (
50
  <>
51
  <Card className="group overflow-hidden bg-card/40 backdrop-blur-md border-border/40 hover:ring-2 hover:ring-primary/50 transition-all">
 
62
  <ShieldCheck className="w-8 h-8 text-muted-foreground opacity-20" />
63
  </div>
64
  )}
65
+ <div className="absolute top-2 left-2 flex flex-col gap-1.5">
66
+ {detection.detection.status === "flagged" && (
67
+ <Badge variant="outline" className="bg-amber-500/20 border-amber-500/40 text-amber-500 backdrop-blur-sm flex items-center gap-1 w-fit">
68
+ <AlertTriangle className="w-3 h-3" /> Flagged
69
+ </Badge>
70
+ )}
71
+ {isHighInterest && (
72
+ <Badge variant="outline" className="bg-emerald-500/20 border-emerald-500/40 text-emerald-500 backdrop-blur-sm flex items-center gap-1 w-fit">
73
+ <ShieldCheck className="w-3 h-3" /> High Interest
74
+ </Badge>
75
+ )}
76
  </div>
77
+ <div className="absolute top-2 right-2 flex flex-col items-end gap-1.5">
78
  <Badge variant="outline" className="bg-background/80 backdrop-blur-sm">
79
+ {Math.round(detection.detection.confidenceScore * 100)}% Match
80
  </Badge>
81
+ {(detection.pledgeCount || 0) > 0 && (
82
+ <Badge variant="outline" className="bg-primary/20 border-primary/40 text-primary-foreground backdrop-blur-sm font-bold">
83
+ {detection.pledgeCount} Pledges
84
+ </Badge>
85
+ )}
86
  </div>
87
  <div className="absolute bottom-2 left-2 flex gap-2">
88
  <Badge variant="secondary" className="bg-background/80 backdrop-blur-sm flex items-center gap-1">
src/features/moderation/components/admin-moderation-queue.tsx CHANGED
@@ -31,20 +31,31 @@ interface AdminDetection {
31
  channelName: string;
32
  };
33
  marketplaceMatches: any[];
 
34
  }
35
 
36
  interface AdminModerationQueueProps {
37
  initialDetections: AdminDetection[];
 
 
 
38
  }
39
 
40
- export function AdminModerationQueue({ initialDetections }: AdminModerationQueueProps) {
 
 
 
 
 
41
  const [detections, setDetections] = useState(initialDetections);
42
- const [filterCategory, setFilterCategory] = useState<string>("all");
43
- const [minConfidence, setMinConfidence] = useState(0);
 
44
 
45
  const filteredDetections = detections.filter(d =>
46
  (filterCategory === "all" || d.detection.category === filterCategory) &&
47
- d.detection.confidenceScore >= minConfidence
 
48
  );
49
 
50
  const categories = Array.from(new Set(initialDetections.map(d => d.detection.category)));
@@ -61,7 +72,7 @@ export function AdminModerationQueue({ initialDetections }: AdminModerationQueue
61
  <div className="space-y-1">
62
  <Label>Category</Label>
63
  <Select value={filterCategory} onValueChange={setFilterCategory}>
64
- <SelectTrigger className="w-[180px]">
65
  <SelectValue placeholder="All Categories" />
66
  </SelectTrigger>
67
  <SelectContent>
@@ -82,13 +93,26 @@ export function AdminModerationQueue({ initialDetections }: AdminModerationQueue
82
  step="0.05"
83
  value={minConfidence}
84
  onChange={(e) => setMinConfidence(parseFloat(e.target.value))}
85
- className="w-[200px]"
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  />
87
  </div>
88
  </div>
89
 
90
- <Button variant="outline" size="sm" onClick={() => { setFilterCategory("all"); setMinConfidence(0); }}>
91
- <RotateCcw className="w-4 h-4 mr-2" /> Reset Filters
92
  </Button>
93
  </div>
94
 
 
31
  channelName: string;
32
  };
33
  marketplaceMatches: any[];
34
+ pledgeCount?: number;
35
  }
36
 
37
  interface AdminModerationQueueProps {
38
  initialDetections: AdminDetection[];
39
+ initialMinPledges?: number;
40
+ initialCategory?: string;
41
+ initialMinConfidence?: number;
42
  }
43
 
44
+ export function AdminModerationQueue({
45
+ initialDetections,
46
+ initialMinPledges = 0,
47
+ initialCategory = "all",
48
+ initialMinConfidence = 0
49
+ }: AdminModerationQueueProps) {
50
  const [detections, setDetections] = useState(initialDetections);
51
+ const [filterCategory, setFilterCategory] = useState<string>(initialCategory);
52
+ const [minConfidence, setMinConfidence] = useState(initialMinConfidence);
53
+ const [minPledges, setMinPledges] = useState(initialMinPledges);
54
 
55
  const filteredDetections = detections.filter(d =>
56
  (filterCategory === "all" || d.detection.category === filterCategory) &&
57
+ d.detection.confidenceScore >= minConfidence &&
58
+ (d.pledgeCount || 0) >= minPledges
59
  );
60
 
61
  const categories = Array.from(new Set(initialDetections.map(d => d.detection.category)));
 
72
  <div className="space-y-1">
73
  <Label>Category</Label>
74
  <Select value={filterCategory} onValueChange={setFilterCategory}>
75
+ <SelectTrigger className="w-[160px]">
76
  <SelectValue placeholder="All Categories" />
77
  </SelectTrigger>
78
  <SelectContent>
 
93
  step="0.05"
94
  value={minConfidence}
95
  onChange={(e) => setMinConfidence(parseFloat(e.target.value))}
96
+ className="w-[150px]"
97
+ />
98
+ </div>
99
+
100
+ <div className="space-y-1">
101
+ <Label>Min Pledges ({minPledges})</Label>
102
+ <Input
103
+ type="range"
104
+ min="0"
105
+ max="20"
106
+ step="1"
107
+ value={minPledges}
108
+ onChange={(e) => setMinPledges(parseInt(e.target.value))}
109
+ className="w-[150px] accent-emerald-500"
110
  />
111
  </div>
112
  </div>
113
 
114
+ <Button variant="outline" size="sm" onClick={() => { setFilterCategory("all"); setMinConfidence(0); setMinPledges(0); }}>
115
+ <RotateCcw className="w-4 h-4 mr-2" /> Reset
116
  </Button>
117
  </div>
118
 
src/features/moderation/services/admin-moderation.service.ts CHANGED
@@ -5,8 +5,9 @@ import {
5
  youtubeVideos,
6
  youtubeChannels,
7
  adminModeration,
 
8
  } from "@/lib/db/schema";
9
- import { eq, and, desc, sql, asc } from "drizzle-orm";
10
 
11
  export interface AdminQueueFilters {
12
  category?: string;
@@ -15,6 +16,8 @@ export interface AdminQueueFilters {
15
  sortOrder?: "asc" | "desc";
16
  limit?: number;
17
  offset?: number;
 
 
18
  }
19
 
20
  export class AdminModerationService {
@@ -26,6 +29,11 @@ export class AdminModerationService {
26
  const limit = filters?.limit || 20;
27
  const offset = filters?.offset || 0;
28
 
 
 
 
 
 
29
  const baseQuery = db
30
  .select({
31
  detection: detectedObjects,
@@ -53,10 +61,11 @@ export class AdminModerationService {
53
  '[]'::json
54
  )
55
  `.as("marketplace_matches"),
 
56
  })
57
  .from(detectedObjects)
58
- .innerJoin(youtubeVideos, eq(detectedObjects.videoId, youtubeVideos.id))
59
- .innerJoin(
60
  youtubeChannels,
61
  eq(youtubeVideos.channelId, youtubeChannels.id)
62
  )
@@ -64,9 +73,31 @@ export class AdminModerationService {
64
  marketplaceMatches,
65
  eq(detectedObjects.id, marketplaceMatches.objectId)
66
  )
 
 
 
 
67
  .where(
68
  and(
69
- eq(detectedObjects.status, "flagged"),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  filters?.category ? eq(detectedObjects.category, filters.category as any) : undefined,
71
  filters?.minConfidence
72
  ? sql`${detectedObjects.confidenceScore} >= ${filters.minConfidence}`
@@ -76,7 +107,8 @@ export class AdminModerationService {
76
  .groupBy(
77
  detectedObjects.id,
78
  youtubeVideos.id,
79
- youtubeChannels.id
 
80
  );
81
 
82
  // Sorting logic
@@ -93,9 +125,15 @@ export class AdminModerationService {
93
  : desc(detectedObjects.createdAt);
94
  }
95
 
 
96
  const queue = (await baseQuery.orderBy(orderBy).limit(limit).offset(offset)) as any[];
 
97
 
98
- return queue;
 
 
 
 
99
  }
100
 
101
  /**
@@ -105,32 +143,39 @@ export class AdminModerationService {
105
  const flaggedCountResult = await db
106
  .select({ count: sql<number>`count(*)::int` })
107
  .from(detectedObjects)
108
- .where(eq(detectedObjects.status, "flagged"));
109
-
110
- const correctedTodayResult = await db
111
- .select({ count: sql<number>`count(*)::int` })
112
- .from(adminModeration)
113
  .where(
114
  and(
115
- eq(adminModeration.action, "corrected"),
116
- sql`${adminModeration.createdAt} >= date_trunc('day', now())`
117
  )
118
  );
119
 
120
- const incorrectTodayResult = await db
121
  .select({ count: sql<number>`count(*)::int` })
122
- .from(adminModeration)
123
- .where(
124
- and(
125
- eq(adminModeration.action, "marked_incorrect"),
126
- sql`${adminModeration.createdAt} >= date_trunc('day', now())`
127
- )
128
- );
 
 
 
 
 
 
 
 
 
 
129
 
130
  return {
131
  flagged: flaggedCountResult[0]?.count || 0,
132
- correctedToday: correctedTodayResult[0]?.count || 0,
133
- markedIncorrectToday: incorrectTodayResult[0]?.count || 0,
 
 
134
  };
135
  }
136
 
 
5
  youtubeVideos,
6
  youtubeChannels,
7
  adminModeration,
8
+ interestPledges,
9
  } from "@/lib/db/schema";
10
+ import { eq, and, or, desc, sql, asc } from "drizzle-orm";
11
 
12
  export interface AdminQueueFilters {
13
  category?: string;
 
16
  sortOrder?: "asc" | "desc";
17
  limit?: number;
18
  offset?: number;
19
+ minPledges?: number;
20
+ status?: "flagged" | "pending_creator" | "live" | "rejected" | "all";
21
  }
22
 
23
  export class AdminModerationService {
 
29
  const limit = filters?.limit || 20;
30
  const offset = filters?.offset || 0;
31
 
32
+ const pledgeCountSubquery = db.select({
33
+ detectedObjectId: interestPledges.detectedObjectId,
34
+ count: sql<number>`count(*)::int`.as('pledge_count')
35
+ }).from(interestPledges).groupBy(interestPledges.detectedObjectId).as('pc');
36
+
37
  const baseQuery = db
38
  .select({
39
  detection: detectedObjects,
 
61
  '[]'::json
62
  )
63
  `.as("marketplace_matches"),
64
+ pledgeCount: sql<number>`COALESCE(${pledgeCountSubquery.count}, 0)`.as("pledge_count"),
65
  })
66
  .from(detectedObjects)
67
+ .leftJoin(youtubeVideos, eq(detectedObjects.videoId, youtubeVideos.id))
68
+ .leftJoin(
69
  youtubeChannels,
70
  eq(youtubeVideos.channelId, youtubeChannels.id)
71
  )
 
73
  marketplaceMatches,
74
  eq(detectedObjects.id, marketplaceMatches.objectId)
75
  )
76
+ .leftJoin(
77
+ pledgeCountSubquery,
78
+ eq(detectedObjects.id, pledgeCountSubquery.detectedObjectId)
79
+ )
80
  .where(
81
  and(
82
+ filters?.status && filters.status !== 'all'
83
+ ? filters.status === 'flagged'
84
+ ? and(
85
+ or(eq(detectedObjects.status, 'flagged'), eq(detectedObjects.status, 'pending_review')),
86
+ eq(detectedObjects.moderationStatus, 'PENDING')
87
+ )
88
+ : filters.status === 'pending_creator'
89
+ ? and(eq(detectedObjects.status, 'approved'), eq(detectedObjects.moderationStatus, 'PENDING'))
90
+ : filters.status === 'live'
91
+ ? eq(detectedObjects.moderationStatus, 'APPROVED')
92
+ : filters.status === 'rejected'
93
+ ? or(eq(detectedObjects.status, 'rejected'), eq(detectedObjects.moderationStatus, 'REJECTED'))
94
+ : undefined
95
+ : !filters?.status
96
+ ? or(
97
+ and(eq(detectedObjects.status, 'flagged'), eq(detectedObjects.moderationStatus, 'PENDING')),
98
+ sql`COALESCE(${pledgeCountSubquery.count}, 0) >= ${filters?.minPledges ?? 0}`
99
+ )
100
+ : undefined,
101
  filters?.category ? eq(detectedObjects.category, filters.category as any) : undefined,
102
  filters?.minConfidence
103
  ? sql`${detectedObjects.confidenceScore} >= ${filters.minConfidence}`
 
107
  .groupBy(
108
  detectedObjects.id,
109
  youtubeVideos.id,
110
+ youtubeChannels.id,
111
+ pledgeCountSubquery.count
112
  );
113
 
114
  // Sorting logic
 
125
  : desc(detectedObjects.createdAt);
126
  }
127
 
128
+ console.log('--- getAdminQueue Filters ---', filters);
129
  const queue = (await baseQuery.orderBy(orderBy).limit(limit).offset(offset)) as any[];
130
+ console.log('--- getAdminQueue Result Count ---', queue.length);
131
 
132
+ return queue.map(item => ({
133
+ ...item,
134
+ video: item.video?.id ? item.video : { id: 'unknown', title: 'Unknown Video', thumbnailUrl: '' },
135
+ channel: item.channel?.creatorId ? item.channel : { creatorId: 'unknown', channelName: 'Unknown Creator' },
136
+ }));
137
  }
138
 
139
  /**
 
143
  const flaggedCountResult = await db
144
  .select({ count: sql<number>`count(*)::int` })
145
  .from(detectedObjects)
 
 
 
 
 
146
  .where(
147
  and(
148
+ or(eq(detectedObjects.status, 'flagged'), eq(detectedObjects.status, 'pending_review')),
149
+ eq(detectedObjects.moderationStatus, 'PENDING')
150
  )
151
  );
152
 
153
+ const pendingCreatorCountResult = await db
154
  .select({ count: sql<number>`count(*)::int` })
155
+ .from(detectedObjects)
156
+ .where(and(eq(detectedObjects.status, "approved"), eq(detectedObjects.moderationStatus, "PENDING")));
157
+
158
+ const liveCountResult = await db
159
+ .select({ count: sql<number>`count(*)::int` })
160
+ .from(detectedObjects)
161
+ .where(eq(detectedObjects.moderationStatus, "APPROVED"));
162
+
163
+ const rejectedCountResult = await db
164
+ .select({ count: sql<number>`count(*)::int` })
165
+ .from(detectedObjects)
166
+ .where(or(eq(detectedObjects.status, "rejected"), eq(detectedObjects.moderationStatus, "REJECTED")));
167
+
168
+ const totalCountResult = await db
169
+ .select({ count: sql<number>`count(*)::int` })
170
+ .from(detectedObjects)
171
+ .where(eq(detectedObjects.moderationStatus, "APPROVED"));
172
 
173
  return {
174
  flagged: flaggedCountResult[0]?.count || 0,
175
+ pendingCreator: pendingCreatorCountResult[0]?.count || 0,
176
+ live: liveCountResult[0]?.count || 0,
177
+ rejected: rejectedCountResult[0]?.count || 0,
178
+ total: totalCountResult[0]?.count || 0,
179
  };
180
  }
181
 
src/features/vault/components/product-card.tsx CHANGED
@@ -410,8 +410,9 @@ export function ProductCard({ product, priority = false, onTimestampClick, creat
410
  </Link>
411
  )}
412
 
413
- {/* "I want this" as secondary option ONLY for IN_STOCK items with active AND valid affiliate links */}
414
- {product.availabilityStatus === 'IN_STOCK' && product.linkStatus !== 'BROKEN' && product.affiliateUrl && (
 
415
  <Button
416
  variant="outline"
417
  className="w-full"
 
410
  </Link>
411
  )}
412
 
413
+ {/* "I want this" as secondary option ONLY for items where we HAVE a match but NO affiliate link or a BROKEN one */}
414
+ {/* PM decision: If an active link already exists, we hide this to avoid confusing metrics */}
415
+ {product.availabilityStatus === 'IN_STOCK' && (product.linkStatus === 'BROKEN' || !product.affiliateUrl) && (
416
  <Button
417
  variant="outline"
418
  className="w-full"