dvijaykrishnan commited on
Commit
081358f
·
1 Parent(s): 2c4b569

feat: Introduce a utility to correct YouTube channel types, add a full video sync trigger, and refine dashboard channel management.

Browse files
fix-channel-types.ts ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Quick fix script to correct channel_type for external channels
3
+ * Run this once to fix existing data
4
+ */
5
+
6
+ import { db } from './src/lib/db';
7
+ import { youtubeChannels } from './src/lib/db/schema';
8
+ import { eq, and, ne } from 'drizzle-orm';
9
+
10
+ async function fixChannelTypes() {
11
+ console.log('🔍 Checking channel types...');
12
+
13
+ // Get all channels
14
+ const allChannels = await db.query.youtubeChannels.findMany({
15
+ columns: {
16
+ id: true,
17
+ channelId: true,
18
+ channelName: true,
19
+ channelType: true,
20
+ creatorId: true,
21
+ }
22
+ });
23
+
24
+ console.log(`\nFound ${allChannels.length} total channels:`);
25
+ allChannels.forEach(ch => {
26
+ console.log(` - ${ch.channelName} (${ch.channelId}): type=${ch.channelType}`);
27
+ });
28
+
29
+ // The rule: Only the channel "Vijay Krishnan" should be 'verified'
30
+ // All others from Quick Analyze should be 'external'
31
+
32
+ // Update all channels that are NOT "Vijay Krishnan" to be 'external'
33
+ const vijayChannelId = 'UCvBl96kCwiVqjEL-3Z7o_rw'; // Your actual channel ID
34
+
35
+ const updated = await db
36
+ .update(youtubeChannels)
37
+ .set({ channelType: 'external' })
38
+ .where(
39
+ and(
40
+ ne(youtubeChannels.channelId, vijayChannelId),
41
+ eq(youtubeChannels.channelType, 'verified')
42
+ )
43
+ )
44
+ .returning({ id: youtubeChannels.id, channelName: youtubeChannels.channelName });
45
+
46
+ console.log(`\n✅ Updated ${updated.length} channels to 'external':`);
47
+ updated.forEach(ch => {
48
+ console.log(` - ${ch.channelName}`);
49
+ });
50
+
51
+ console.log('\n✨ Done! Refresh your dashboard to see the changes.');
52
+ process.exit(0);
53
+ }
54
+
55
+ fixChannelTypes().catch(err => {
56
+ console.error('❌ Error:', err);
57
+ process.exit(1);
58
+ });
src/app/api/youtube/callback/route.ts CHANGED
@@ -37,8 +37,16 @@ export async function GET(request: NextRequest) {
37
  });
38
 
39
  const tokens = await tokenResponse.json();
 
 
 
 
 
 
 
40
  if (!tokens.access_token) {
41
- throw new Error(`Token exchange failed: ${JSON.stringify(tokens)}`);
 
42
  }
43
 
44
  // Extract Google account ID from id_token (JWT sub claim)
@@ -58,6 +66,7 @@ export async function GET(request: NextRequest) {
58
  const tokenFields = {
59
  accessToken: tokens.access_token,
60
  refreshToken: tokens.refresh_token || null,
 
61
  accessTokenExpiresAt: tokens.expires_in
62
  ? new Date(Date.now() + tokens.expires_in * 1000)
63
  : null,
@@ -69,11 +78,13 @@ export async function GET(request: NextRequest) {
69
  });
70
 
71
  if (existing) {
 
72
  await db
73
  .update(accounts)
74
  .set({ ...tokenFields, updatedAt: now })
75
  .where(eq(accounts.id, existing.id));
76
  } else {
 
77
  await db.insert(accounts).values({
78
  id: crypto.randomUUID(),
79
  accountId: googleAccountId,
 
37
  });
38
 
39
  const tokens = await tokenResponse.json();
40
+ console.log("[YouTube callback] Tokens received:", {
41
+ has_access_token: !!tokens.access_token,
42
+ has_refresh_token: !!tokens.refresh_token,
43
+ expires_in: tokens.expires_in,
44
+ scope: tokens.scope,
45
+ });
46
+
47
  if (!tokens.access_token) {
48
+ console.error("[YouTube callback] Token exchange failed:", tokens);
49
+ throw new Error(`Token exchange failed: ${tokens.error_description || tokens.error || JSON.stringify(tokens)}`);
50
  }
51
 
52
  // Extract Google account ID from id_token (JWT sub claim)
 
66
  const tokenFields = {
67
  accessToken: tokens.access_token,
68
  refreshToken: tokens.refresh_token || null,
69
+ scope: tokens.scope || null,
70
  accessTokenExpiresAt: tokens.expires_in
71
  ? new Date(Date.now() + tokens.expires_in * 1000)
72
  : null,
 
78
  });
79
 
80
  if (existing) {
81
+ console.log("[YouTube callback] Updating existing account record for user:", userId);
82
  await db
83
  .update(accounts)
84
  .set({ ...tokenFields, updatedAt: now })
85
  .where(eq(accounts.id, existing.id));
86
  } else {
87
+ console.log("[YouTube callback] Creating new account record for user:", userId);
88
  await db.insert(accounts).values({
89
  id: crypto.randomUUID(),
90
  accountId: googleAccountId,
src/app/api/youtube/disconnect/route.ts ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { disconnectYouTubeAction } from "@/features/discovery/actions/disconnect-youtube";
2
+ import { NextResponse } from "next/server";
3
+
4
+ export async function GET() {
5
+ const result = await disconnectYouTubeAction();
6
+
7
+ if (result.success) {
8
+ return NextResponse.redirect(new URL('/dashboard', process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'));
9
+ }
10
+
11
+ return NextResponse.redirect(
12
+ new URL(`/dashboard?error=${encodeURIComponent(result.error || 'Failed to disconnect')}`,
13
+ process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000')
14
+ );
15
+ }
src/app/dashboard/fix-channels-action.ts ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use server";
2
+
3
+ import { db } from "@/lib/db";
4
+ import { youtubeChannels } from "@/lib/db/schema";
5
+ import { eq, ne, and } from "drizzle-orm";
6
+ import { revalidatePath } from "next/cache";
7
+
8
+ /**
9
+ * Server action to correct channel types in the database.
10
+ * Marks the main Vijay Krishnan channel as 'verified' and others as 'external'.
11
+ */
12
+ export async function fixChannelTypesAction() {
13
+ const vijayChannelId = 'UC_2QSXGUe_L6KKz7rLHA-7Q';
14
+
15
+ try {
16
+ // 0. Get the current session to associate channels correctly
17
+ const { auth } = await import("@/lib/auth");
18
+ const { headers } = await import("next/headers");
19
+ const session = await auth.api.getSession({ headers: await headers() });
20
+
21
+ if (!session || !session.user) {
22
+ return { success: false, error: "Unauthorized" };
23
+ }
24
+
25
+ const currentUserId = session.user.id;
26
+ console.log(`🔍 [fixChannelTypesAction] Applying fix for user: ${currentUserId}`);
27
+
28
+ // 1. Mark the main channel as 'verified' AND associate with current user
29
+ const verifiedUpdate = await db
30
+ .update(youtubeChannels)
31
+ .set({
32
+ channelType: 'verified',
33
+ creatorId: currentUserId // Critical fix: associate with current user!
34
+ })
35
+ .where(eq(youtubeChannels.channelId, vijayChannelId))
36
+ .returning({ id: youtubeChannels.id, channelName: youtubeChannels.channelName });
37
+
38
+ // 2. Mark all other channels as 'external'
39
+ const externalUpdate = await db
40
+ .update(youtubeChannels)
41
+ .set({ channelType: 'external' })
42
+ .where(ne(youtubeChannels.channelId, vijayChannelId))
43
+ .returning({ id: youtubeChannels.id, channelName: youtubeChannels.channelName });
44
+
45
+ console.log(`✅ [fixChannelTypesAction] Marked ${verifiedUpdate.length} channel(s) as verified for ${currentUserId}`);
46
+
47
+ revalidatePath("/dashboard");
48
+
49
+ return {
50
+ success: true,
51
+ verifiedCount: verifiedUpdate.length,
52
+ externalCount: externalUpdate.length,
53
+ };
54
+ } catch (error) {
55
+ console.error("[fixChannelTypesAction] Error:", error);
56
+ return {
57
+ success: false,
58
+ error: error instanceof Error ? error.message : "Failed to fix channel types"
59
+ };
60
+ }
61
+ }
src/app/dashboard/fix-channels/debug-action.ts ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use server";
2
+
3
+ import { db } from "@/lib/db";
4
+
5
+ /**
6
+ * Debug action to see all channels and their types
7
+ */
8
+ export async function debugChannelsAction() {
9
+ const allChannels = await db.query.youtubeChannels.findMany({
10
+ columns: {
11
+ id: true,
12
+ channelId: true,
13
+ channelName: true,
14
+ channelType: true,
15
+ creatorId: true,
16
+ }
17
+ });
18
+
19
+ console.log('\n=== ALL CHANNELS IN DATABASE ===');
20
+ allChannels.forEach(ch => {
21
+ console.log(` Channel: ${ch.channelName}`);
22
+ console.log(` YouTube ID: ${ch.channelId}`);
23
+ console.log(` Type: ${ch.channelType}`);
24
+ console.log(` DB ID: ${ch.id}`);
25
+ console.log('');
26
+ });
27
+
28
+ return {
29
+ success: true,
30
+ channels: allChannels,
31
+ };
32
+ }
src/app/dashboard/fix-channels/page.tsx ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { fixChannelTypesAction } from "../fix-channels-action";
4
+ import { useState } from "react";
5
+ import { Button } from "@/components/ui/button";
6
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
7
+ import { toast } from "sonner";
8
+ import { Loader2 } from "lucide-react";
9
+
10
+ export default function FixChannelsPage() {
11
+ const [isLoading, setIsLoading] = useState(false);
12
+ const [result, setResult] = useState<{ verifiedCount: number; externalCount: number } | null>(null);
13
+
14
+ const handleFix = async () => {
15
+ setIsLoading(true);
16
+ try {
17
+ const res = await fixChannelTypesAction();
18
+ if (res.success) {
19
+ setResult({
20
+ verifiedCount: res.verifiedCount || 0,
21
+ externalCount: res.externalCount || 0
22
+ });
23
+ toast.success("Channel types corrected!");
24
+ } else {
25
+ toast.error(res.error || "Failed to fix channel types");
26
+ }
27
+ } catch (error) {
28
+ toast.error("An unexpected error occurred");
29
+ } finally {
30
+ setIsLoading(false);
31
+ }
32
+ };
33
+
34
+ return (
35
+ <div className="max-w-2xl mx-auto py-12 px-4">
36
+ <Card className="glass">
37
+ <CardHeader>
38
+ <CardTitle>Repair Channel Types</CardTitle>
39
+ <CardDescription>
40
+ This utility corrects the <code>channel_type</code> for all synced YouTube channels.
41
+ It ensures only your primary channel is marked as <strong>verified</strong> for the Account Connections modal.
42
+ </CardDescription>
43
+ </CardHeader>
44
+ <CardContent className="space-y-6">
45
+ <p className="text-sm text-muted-foreground">
46
+ After running this fix, go back to your dashboard and refresh.
47
+ The Account Connections modal will be filtered correctly.
48
+ </p>
49
+
50
+ {result ? (
51
+ <div className="p-4 bg-primary/10 rounded-lg border border-primary/20 space-y-2">
52
+ <p className="font-medium text-primary">Fix Applied Successfully!</p>
53
+ <ul className="text-sm space-y-1">
54
+ <li>• Marked <strong>{result.verifiedCount}</strong> channels as verified</li>
55
+ <li>• Marked <strong>{result.externalCount}</strong> channels as external</li>
56
+ </ul>
57
+ <Button variant="outline" size="sm" className="mt-2" asChild>
58
+ <a href="/dashboard">Return to Dashboard</a>
59
+ </Button>
60
+ </div>
61
+ ) : (
62
+ <Button
63
+ onClick={handleFix}
64
+ disabled={isLoading}
65
+ className="w-full"
66
+ >
67
+ {isLoading ? (
68
+ <><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Fixing...</>
69
+ ) : (
70
+ "Apply Data Correction"
71
+ )}
72
+ </Button>
73
+ )}
74
+ </CardContent>
75
+ </Card>
76
+ </div>
77
+ );
78
+ }
src/app/dashboard/page.tsx CHANGED
@@ -33,7 +33,7 @@ export default async function DashboardPage({
33
  redirect('/login')
34
  }
35
 
36
- const { sync_youtube } = await searchParams;
37
 
38
  if (sync_youtube === "true") {
39
  try {
@@ -41,15 +41,33 @@ export default async function DashboardPage({
41
  if (!result.success) {
42
  redirect(`/dashboard?error=${encodeURIComponent(result.error || "Failed to sync YouTube account")}`);
43
  }
44
- // Redirect to same page but without the query param to avoid re-syncing on refresh
45
  redirect("/dashboard");
46
- } catch (error) {
47
- if (error instanceof Error && error.message === "NEXT_REDIRECT") throw error;
48
- redirect(`/dashboard?error=${encodeURIComponent(error instanceof Error ? error.message : "An unexpected error occurred")}`);
49
  }
50
  }
51
 
52
- const [channels, googleAccount, pendingCount, requestCount] = await Promise.all([
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  db.query.youtubeChannels.findMany({
54
  where: (channels, { eq }) => eq(channels.creatorId, session.user.id),
55
  columns: {
@@ -94,10 +112,10 @@ export default async function DashboardPage({
94
  .then((rows) => Number(rows[0].count)),
95
  ]);
96
 
97
- // Fetch ALL videos with aggregate engagement metrics
98
  let allVideos: any[] = [];
99
- if (channels.length > 0) {
100
- const channelIds = channels.map(c => c.id);
101
 
102
  // This query joins videos with their various engagement signals
103
  allVideos = await db
@@ -140,17 +158,16 @@ export default async function DashboardPage({
140
 
141
  // AUTO-SYNC LOGIC: If user has a Google account but NO verified channels yet,
142
  // trigger the sync automatically for a seamless "one-click" experience.
143
- if (verifiedChannels.length === 0 && googleAccount?.accessToken && !sync_youtube) {
144
  console.log(`[DashboardPage] Triggering AUTO-SYNC for user: ${session.user.id}`);
145
  try {
146
  const result = await connectYouTubeAction();
147
  if (result.success) {
148
- // Refresh the page to show the new channels
149
  redirect("/dashboard");
150
  }
151
- } catch (error) {
 
152
  console.error("[DashboardPage] Auto-sync failed:", error);
153
- // Don't redirect on fail, just show the manual connect button
154
  }
155
  }
156
 
@@ -218,8 +235,13 @@ export default async function DashboardPage({
218
 
219
  <VideoWorkbench
220
  initialVideos={allVideos}
221
- channels={channels}
222
  isGoogleLinked={!!googleAccount?.accessToken}
 
 
 
 
 
223
  />
224
  </div>
225
 
 
33
  redirect('/login')
34
  }
35
 
36
+ const { sync_youtube, error } = await searchParams;
37
 
38
  if (sync_youtube === "true") {
39
  try {
 
41
  if (!result.success) {
42
  redirect(`/dashboard?error=${encodeURIComponent(result.error || "Failed to sync YouTube account")}`);
43
  }
 
44
  redirect("/dashboard");
45
+ } catch (error: any) {
46
+ if (error.digest?.includes('NEXT_REDIRECT')) throw error;
47
+ redirect(`/dashboard?error=${encodeURIComponent(error.message || "An unexpected error occurred")}`);
48
  }
49
  }
50
 
51
+ const [channels, allChannels, googleAccount, pendingCount, requestCount] = await Promise.all([
52
+ // Only fetch OAuth-verified channels (not external channels from Quick Analyze)
53
+ db.query.youtubeChannels.findMany({
54
+ where: (channels, { eq, and }) => and(
55
+ eq(channels.creatorId, session.user.id),
56
+ eq(channels.channelType, 'verified')
57
+ ),
58
+ columns: {
59
+ id: true,
60
+ channelId: true,
61
+ channelName: true,
62
+ subscriberCount: true,
63
+ thumbnailUrl: true,
64
+ syncStatus: true,
65
+ connectedAt: true,
66
+ channelType: true,
67
+ creatorSlug: true,
68
+ }
69
+ }),
70
+ // Fetch ALL channels (verified + external) for video filtering and display
71
  db.query.youtubeChannels.findMany({
72
  where: (channels, { eq }) => eq(channels.creatorId, session.user.id),
73
  columns: {
 
112
  .then((rows) => Number(rows[0].count)),
113
  ]);
114
 
115
+ // Fetch ALL videos using allChannels
116
  let allVideos: any[] = [];
117
+ if (allChannels.length > 0) {
118
+ const channelIds = allChannels.map(c => c.id);
119
 
120
  // This query joins videos with their various engagement signals
121
  allVideos = await db
 
158
 
159
  // AUTO-SYNC LOGIC: If user has a Google account but NO verified channels yet,
160
  // trigger the sync automatically for a seamless "one-click" experience.
161
+ if (verifiedChannels.length === 0 && googleAccount?.accessToken && !sync_youtube && !error) {
162
  console.log(`[DashboardPage] Triggering AUTO-SYNC for user: ${session.user.id}`);
163
  try {
164
  const result = await connectYouTubeAction();
165
  if (result.success) {
 
166
  redirect("/dashboard");
167
  }
168
+ } catch (error: any) {
169
+ if (error.digest?.includes('NEXT_REDIRECT')) throw error;
170
  console.error("[DashboardPage] Auto-sync failed:", error);
 
171
  }
172
  }
173
 
 
235
 
236
  <VideoWorkbench
237
  initialVideos={allVideos}
238
+ channels={allChannels}
239
  isGoogleLinked={!!googleAccount?.accessToken}
240
+ hasYouTubeScope={
241
+ !!googleAccount?.scope &&
242
+ (googleAccount.scope.includes('https://www.googleapis.com/auth/youtube.readonly') ||
243
+ googleAccount.scope.includes('https://www.googleapis.com/auth/youtube'))
244
+ }
245
  />
246
  </div>
247
 
src/features/dashboard/components/video-workbench.tsx CHANGED
@@ -41,13 +41,14 @@ interface VideoWorkbenchProps {
41
  initialVideos: any[];
42
  channels: any[];
43
  isGoogleLinked: boolean;
 
44
  }
45
 
46
  type Tab = "all" | "active" | "opportunities" | "requested";
47
  type SortField = "publishedAt" | "viewCount" | "clickCount" | "pledgeCount" | "requestCount";
48
  type SortDir = "asc" | "desc";
49
 
50
- export function VideoWorkbench({ initialVideos, channels, isGoogleLinked }: VideoWorkbenchProps) {
51
  const [tab, setTab] = useState<Tab>("all");
52
  const [sortBy, setSortBy] = useState<SortField>("publishedAt");
53
  const [sortDir, setSortDir] = useState<SortDir>("desc");
@@ -71,6 +72,8 @@ export function VideoWorkbench({ initialVideos, channels, isGoogleLinked }: Vide
71
  }
72
  };
73
 
 
 
74
  const stats = useMemo(() => {
75
  return {
76
  totalClicks: initialVideos.reduce((acc, v) => acc + (v.clickCount || 0), 0),
@@ -80,6 +83,29 @@ export function VideoWorkbench({ initialVideos, channels, isGoogleLinked }: Vide
80
  };
81
  }, [initialVideos]);
82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  const filteredAndSortedVideos = useMemo(() => {
84
  let result = [...initialVideos];
85
 
@@ -118,33 +144,6 @@ export function VideoWorkbench({ initialVideos, channels, isGoogleLinked }: Vide
118
  }
119
  };
120
 
121
- if (initialVideos.length === 0) {
122
- return (
123
- <Card className="border-dashed glass">
124
- <CardContent className="flex flex-col items-center justify-center p-12 text-center">
125
- <div className="w-16 h-16 rounded-full bg-secondary flex items-center justify-center mb-6 border border-border">
126
- <Youtube className="h-8 w-8 text-muted-foreground" />
127
- </div>
128
- <CardTitle className="text-2xl">No Videos Found</CardTitle>
129
- <CardDescription className="max-w-xs mx-auto mt-2">
130
- {isGoogleLinked
131
- ? "We couldn't find any videos on your connected channel. Try fetching them below."
132
- : "Connect your YouTube channel to start managing your videos and products."}
133
- </CardDescription>
134
- <div className="mt-8">
135
- {isGoogleLinked ? (
136
- <Button>Fetch Videos</Button>
137
- ) : (
138
- <Button asChild>
139
- <a href="/api/youtube/connect">Connect YouTube</a>
140
- </Button>
141
- )}
142
- </div>
143
- </CardContent>
144
- </Card>
145
- );
146
- }
147
-
148
  return (
149
  <div className="space-y-6">
150
  {/* Workbench Header & Stats Quick-look */}
@@ -167,145 +166,216 @@ export function VideoWorkbench({ initialVideos, channels, isGoogleLinked }: Vide
167
  <p className="text-[10px] uppercase font-bold text-muted-foreground tracking-widest mb-0.5">Monetized</p>
168
  <p className="text-lg font-bold text-green-400 dark:text-green-400">{stats.monetizedVideos} <span className="text-xs font-normal text-muted-foreground">/ {initialVideos.length}</span></p>
169
  </div>
 
 
170
 
171
- <div className="px-2 border-l border-border ml-2">
172
- <AlertDialog>
173
- <AlertDialogTrigger asChild>
174
- <Button variant="ghost" size="icon" className="h-9 w-9 text-muted-foreground hover:bg-secondary rounded-full">
175
- <Settings2 className="h-4 w-4" />
176
- </Button>
177
- </AlertDialogTrigger>
178
- <AlertDialogContent className="glass-panel backdrop-blur-xl">
179
- <AlertDialogHeader>
180
- <AlertDialogTitle>Account Connections</AlertDialogTitle>
181
- <AlertDialogDescription>
182
- Manage your connected YouTube channels and synchronization settings.
183
- </AlertDialogDescription>
184
- </AlertDialogHeader>
 
 
 
 
 
 
 
 
185
 
186
- <div className="space-y-4 my-4">
187
- {channels.map(channel => (
188
- <div key={channel.id} className="flex items-center gap-3 p-3 rounded-xl bg-secondary border border-border">
189
- {channel.thumbnailUrl && (
190
- <img src={channel.thumbnailUrl} alt="" className="w-10 h-10 rounded-full border border-border" />
191
- )}
192
- <div className="flex-1 min-w-0">
193
- <p className="text-sm font-bold truncate">{channel.channelName}</p>
194
- <p className="text-xs text-muted-foreground">{channel.subscriberCount?.toLocaleString()} subscribers</p>
195
- </div>
196
- <Badge variant="secondary" className="bg-green-500/10 text-green-500 border-green-500/20 text-[10px]">
197
- Connected
198
- </Badge>
199
- </div>
200
- ))}
201
- </div>
202
 
203
- <AlertDialogFooter className="flex-col sm:flex-row gap-2">
204
- <div className="flex-1">
205
- <Button
206
- variant="ghost"
207
- size="sm"
208
- className="text-muted-foreground hover:text-destructive gap-2 transition-colors px-0"
209
- onClick={handleDisconnect}
210
- disabled={isDisconnecting}
211
- >
212
- <Trash2 className="h-4 w-4" />
213
- Disconnect YouTube Account
214
- </Button>
215
- </div>
216
- <AlertDialogCancel className="bg-secondary border-border hover:bg-muted transition-colors">Close</AlertDialogCancel>
217
- </AlertDialogFooter>
218
- </AlertDialogContent>
219
- </AlertDialog>
220
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
  </div>
222
  </div>
223
 
224
- {/* Controls Bar */}
225
- <div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4 py-2">
226
- {/* Tabs */}
227
- <div className="flex items-center gap-1 bg-secondary p-1 rounded-xl border border-border">
228
- {(["all", "active", "opportunities", "requested"] as Tab[]).map(t => (
229
- <button
230
- key={t}
231
- onClick={() => setTab(t)}
232
- className={cn(
233
- "px-4 py-1.5 rounded-lg text-sm font-medium capitalize transition-all",
234
- tab === t
235
- ? "bg-background text-foreground shadow-sm border border-border/50"
236
- : "text-muted-foreground hover:text-foreground"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  )}
238
- >
239
- {t === "active" ? "Monetized" : t === "requested" ? "Requests" : t}
240
- </button>
241
- ))}
242
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
 
244
- <div className="flex flex-wrap items-center gap-3">
245
- {/* Channel Filter (if multiple) */}
246
- {channels.length > 1 && (
247
- <div className="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-xl bg-secondary border border-border">
248
- <Filter className="h-3.5 w-3.5 text-muted-foreground" />
249
- <select
250
- value={filterChannel}
251
- onChange={(e) => setFilterChannel(e.target.value)}
252
- className="bg-transparent text-sm font-medium outline-none focus:ring-0 appearance-none pr-4"
253
- >
254
- <option value="all">All Channels</option>
255
- {channels.map(c => (
256
- <option key={c.id} value={c.id}>{c.channelName}</option>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
  ))}
258
- </select>
259
  </div>
260
- )}
261
-
262
- {/* Desktop Sort Labels */}
263
- <div className="hidden lg:flex items-center gap-1 text-[11px] font-bold text-muted-foreground uppercase tracking-widest mr-2">
264
- Sort by:
265
- </div>
266
- <div className="flex flex-wrap gap-2">
267
- {[
268
- { id: "publishedAt", label: "Date", icon: <Clock className="h-3 w-3" /> },
269
- { id: "viewCount", label: "Views", icon: <ArrowUp className="h-3 w-3" /> },
270
- { id: "clickCount", label: "Clicks", icon: <MousePointerClick className="h-3 w-3" /> },
271
- { id: "pledgeCount", label: "Pledges", icon: <Heart className="h-3 w-3" /> },
272
- { id: "requestCount", label: "Requests", icon: <MessageSquare className="h-3 w-3" /> },
273
- ].map(f => (
274
- <button
275
- key={f.id}
276
- onClick={() => toggleSort(f.id as SortField)}
277
- className={cn(
278
- "flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-semibold transition-all border",
279
- sortBy === f.id
280
- ? "bg-primary/10 border-primary/20 text-primary"
281
- : "bg-secondary border-border text-muted-foreground hover:border-muted-foreground/30"
282
- )}
283
- >
284
- {f.icon}
285
- {f.label}
286
- {sortBy === f.id && (
287
- sortDir === "desc" ? <ArrowDown className="h-3 w-3" /> : <ArrowUp className="h-3 w-3" />
288
- )}
289
- </button>
290
- ))}
291
  </div>
292
- </div>
293
- </div>
294
 
295
- {/* Video List */}
296
- <Card className="glass overflow-hidden shadow-sm">
297
- <div className="divide-y divide-border/30">
298
- {filteredAndSortedVideos.length > 0 ? (
299
- filteredAndSortedVideos.map(video => (
300
- <VideoListRow key={video.id} video={video} />
301
- ))
302
- ) : (
303
- <div className="p-20 text-center">
304
- <p className="text-muted-foreground">No videos match your current filters.</p>
 
 
305
  </div>
306
- )}
307
- </div>
308
- </Card>
309
  </div>
310
  );
311
  }
 
41
  initialVideos: any[];
42
  channels: any[];
43
  isGoogleLinked: boolean;
44
+ hasYouTubeScope: boolean;
45
  }
46
 
47
  type Tab = "all" | "active" | "opportunities" | "requested";
48
  type SortField = "publishedAt" | "viewCount" | "clickCount" | "pledgeCount" | "requestCount";
49
  type SortDir = "asc" | "desc";
50
 
51
+ export function VideoWorkbench({ initialVideos, channels, isGoogleLinked, hasYouTubeScope }: VideoWorkbenchProps) {
52
  const [tab, setTab] = useState<Tab>("all");
53
  const [sortBy, setSortBy] = useState<SortField>("publishedAt");
54
  const [sortDir, setSortDir] = useState<SortDir>("desc");
 
72
  }
73
  };
74
 
75
+ const [isSyncing, setIsSyncing] = useState(false);
76
+
77
  const stats = useMemo(() => {
78
  return {
79
  totalClicks: initialVideos.reduce((acc, v) => acc + (v.clickCount || 0), 0),
 
83
  };
84
  }, [initialVideos]);
85
 
86
+ const handleSync = async () => {
87
+ console.log("[VideoWorkbench] Starting full video sync...");
88
+ setIsSyncing(true);
89
+ try {
90
+ const { triggerFullVideoSync } = await import('@/features/discovery/actions/trigger-full-video-sync');
91
+ console.log("[VideoWorkbench] Calling triggerFullVideoSync...");
92
+ const result = await triggerFullVideoSync();
93
+ console.log("[VideoWorkbench] Sync result:", result);
94
+ if (result.success) {
95
+ toast.success(`Video sync started for ${result.channelName}!`);
96
+ // Reload after a short delay to allow the scan to start
97
+ setTimeout(() => window.location.reload(), 2000);
98
+ } else {
99
+ toast.error(result.error || "Failed to start video sync");
100
+ }
101
+ } catch (err) {
102
+ console.error("[VideoWorkbench] Sync error:", err);
103
+ toast.error("An unexpected error occurred during sync");
104
+ } finally {
105
+ setIsSyncing(false);
106
+ }
107
+ };
108
+
109
  const filteredAndSortedVideos = useMemo(() => {
110
  let result = [...initialVideos];
111
 
 
144
  }
145
  };
146
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  return (
148
  <div className="space-y-6">
149
  {/* Workbench Header & Stats Quick-look */}
 
166
  <p className="text-[10px] uppercase font-bold text-muted-foreground tracking-widest mb-0.5">Monetized</p>
167
  <p className="text-lg font-bold text-green-400 dark:text-green-400">{stats.monetizedVideos} <span className="text-xs font-normal text-muted-foreground">/ {initialVideos.length}</span></p>
168
  </div>
169
+ </div>
170
+ </div>
171
 
172
+ {/* Channel Control Bar */}
173
+ <div className="flex flex-col sm:flex-row items-center justify-between gap-4 p-4 glass rounded-xl border border-border">
174
+ {channels.length > 0 ? (
175
+ <div className="flex items-center gap-4 w-full sm:w-auto">
176
+ {channels[0].thumbnailUrl && (
177
+ <img src={channels[0].thumbnailUrl} alt="" className="w-12 h-12 rounded-full border-2 border-border shadow-sm" />
178
+ )}
179
+ <div>
180
+ <div className="flex items-center gap-2">
181
+ <h3 className="font-bold text-lg">{channels[0].channelName}</h3>
182
+ <Badge variant="secondary" className="bg-green-500/10 text-green-500 border-green-500/20 text-[10px]">
183
+ {channels[0].channelType === 'verified' ? 'Connected' : 'External'}
184
+ </Badge>
185
+ </div>
186
+ <p className="text-sm text-muted-foreground flex items-center gap-2">
187
+ {channels[0].subscriberCount?.toLocaleString() || 0} subscribers
188
+ </p>
189
+ </div>
190
+ </div>
191
+ ) : (
192
+ <div className="text-muted-foreground text-sm">No channel connected</div>
193
+ )}
194
 
195
+ <div className="flex items-center gap-2 w-full sm:w-auto justify-end">
196
+ {/* YouTube Scope Warning - Inline */}
197
+ {!hasYouTubeScope && isGoogleLinked && (
198
+ <Button
199
+ asChild
200
+ variant="outline"
201
+ size="sm"
202
+ className="bg-yellow-500/10 border-yellow-500/30 hover:bg-yellow-500/20 text-yellow-500 gap-2"
203
+ >
204
+ <a href="/api/youtube/connect">
205
+ <AlertCircle className="h-4 w-4" />
206
+ Re-authorize Access
207
+ </a>
208
+ </Button>
209
+ )}
 
210
 
211
+ {/* Sync Button */}
212
+ <Button
213
+ variant="default"
214
+ size="sm"
215
+ onClick={handleSync}
216
+ disabled={isSyncing}
217
+ className="gap-2"
218
+ >
219
+ {isSyncing ? (
220
+ <Loader2 className="h-4 w-4 animate-spin" />
221
+ ) : (
222
+ <Youtube className="h-4 w-4" />
223
+ )}
224
+ {isSyncing ? "Syncing..." : "Sync Videos"}
225
+ </Button>
226
+
227
+ {/* Disconnect with Confirmation */}
228
+ <AlertDialog>
229
+ <AlertDialogTrigger asChild>
230
+ <Button variant="ghost" size="sm" className="text-muted-foreground hover:text-destructive gap-2">
231
+ <Trash2 className="h-4 w-4" />
232
+ <span className="hidden sm:inline">Disconnect</span>
233
+ </Button>
234
+ </AlertDialogTrigger>
235
+ <AlertDialogContent>
236
+ <AlertDialogHeader>
237
+ <AlertDialogTitle>Disconnect YouTube Channel?</AlertDialogTitle>
238
+ <AlertDialogDescription>
239
+ Are you sure you want to disconnect <strong>{channels[0]?.channelName}</strong>?
240
+ This will stop video synchronization. Your existing data will be preserved.
241
+ </AlertDialogDescription>
242
+ </AlertDialogHeader>
243
+ <AlertDialogFooter>
244
+ <AlertDialogCancel>Cancel</AlertDialogCancel>
245
+ <Button
246
+ variant="destructive"
247
+ onClick={handleDisconnect}
248
+ disabled={isDisconnecting}
249
+ >
250
+ {isDisconnecting ? "Disconnecting..." : "Disconnect"}
251
+ </Button>
252
+ </AlertDialogFooter>
253
+ </AlertDialogContent>
254
+ </AlertDialog>
255
  </div>
256
  </div>
257
 
258
+ {initialVideos.length === 0 ? (
259
+ <Card className="border-dashed glass">
260
+ <CardContent className="flex flex-col items-center justify-center p-12 text-center">
261
+ <div className="w-16 h-16 rounded-full bg-secondary flex items-center justify-center mb-6 border border-border">
262
+ <Youtube className="h-8 w-8 text-muted-foreground" />
263
+ </div>
264
+ <CardTitle className="text-2xl">No Videos Found</CardTitle>
265
+ <CardDescription className="max-w-xs mx-auto mt-2">
266
+ {isGoogleLinked
267
+ ? "We couldn't find any videos on your connected channel. Try fetching them below."
268
+ : "Connect your YouTube channel to start managing your videos and products."}
269
+ </CardDescription>
270
+ <div className="mt-8">
271
+ {isGoogleLinked ? (
272
+ <Button
273
+ onClick={handleSync}
274
+ disabled={isSyncing}
275
+ >
276
+ {isSyncing ? (
277
+ <><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Fetching...</>
278
+ ) : (
279
+ "Fetch Videos"
280
+ )}
281
+ </Button>
282
+ ) : (
283
+ <Button asChild>
284
+ <a href="/api/youtube/connect">Connect YouTube</a>
285
+ </Button>
286
  )}
287
+ </div>
288
+ </CardContent>
289
+ </Card>
290
+ ) : (
291
+ <>
292
+ {/* Controls Bar */}
293
+ <div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4 py-2">
294
+ {/* Tabs */}
295
+ <div className="flex items-center gap-1 bg-secondary p-1 rounded-xl border border-border">
296
+ {(["all", "active", "opportunities", "requested"] as Tab[]).map(t => (
297
+ <button
298
+ key={t}
299
+ onClick={() => setTab(t)}
300
+ className={cn(
301
+ "px-4 py-1.5 rounded-lg text-sm font-medium capitalize transition-all",
302
+ tab === t
303
+ ? "bg-background text-foreground shadow-sm border border-border/50"
304
+ : "text-muted-foreground hover:text-foreground"
305
+ )}
306
+ >
307
+ {t === "active" ? "Monetized" : t === "requested" ? "Requests" : t}
308
+ </button>
309
+ ))}
310
+ </div>
311
 
312
+ <div className="flex flex-wrap items-center gap-3">
313
+ {/* Channel Filter (if multiple) */}
314
+ {channels.length > 1 && (
315
+ <div className="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-xl bg-secondary border border-border">
316
+ <Filter className="h-3.5 w-3.5 text-muted-foreground" />
317
+ <select
318
+ value={filterChannel}
319
+ onChange={(e) => setFilterChannel(e.target.value)}
320
+ className="bg-transparent text-sm font-medium outline-none focus:ring-0 appearance-none pr-4"
321
+ >
322
+ <option value="all">All Channels</option>
323
+ {channels.map(c => (
324
+ <option key={c.id} value={c.id}>{c.channelName}</option>
325
+ ))}
326
+ </select>
327
+ </div>
328
+ )}
329
+
330
+ {/* Desktop Sort Labels */}
331
+ <div className="hidden lg:flex items-center gap-1 text-[11px] font-bold text-muted-foreground uppercase tracking-widest mr-2">
332
+ Sort by:
333
+ </div>
334
+ <div className="flex flex-wrap gap-2">
335
+ {[
336
+ { id: "publishedAt", label: "Date", icon: <Clock className="h-3 w-3" /> },
337
+ { id: "viewCount", label: "Views", icon: <ArrowUp className="h-3 w-3" /> },
338
+ { id: "clickCount", label: "Clicks", icon: <MousePointerClick className="h-3 w-3" /> },
339
+ { id: "pledgeCount", label: "Pledges", icon: <Heart className="h-3 w-3" /> },
340
+ { id: "requestCount", label: "Requests", icon: <MessageSquare className="h-3 w-3" /> },
341
+ ].map(f => (
342
+ <button
343
+ key={f.id}
344
+ onClick={() => toggleSort(f.id as SortField)}
345
+ className={cn(
346
+ "flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-semibold transition-all border",
347
+ sortBy === f.id
348
+ ? "bg-primary/10 border-primary/20 text-primary"
349
+ : "bg-secondary border-border text-muted-foreground hover:border-muted-foreground/30"
350
+ )}
351
+ >
352
+ {f.icon}
353
+ {f.label}
354
+ {sortBy === f.id && (
355
+ sortDir === "desc" ? <ArrowDown className="h-3 w-3" /> : <ArrowUp className="h-3 w-3" />
356
+ )}
357
+ </button>
358
  ))}
359
+ </div>
360
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
361
  </div>
 
 
362
 
363
+ {/* Video List */}
364
+ <Card className="glass overflow-hidden shadow-sm">
365
+ <div className="divide-y divide-border/30">
366
+ {filteredAndSortedVideos.length > 0 ? (
367
+ filteredAndSortedVideos.map(video => (
368
+ <VideoListRow key={video.id} video={video} />
369
+ ))
370
+ ) : (
371
+ <div className="p-20 text-center">
372
+ <p className="text-muted-foreground">No videos match your current filters.</p>
373
+ </div>
374
+ )}
375
  </div>
376
+ </Card>
377
+ </>
378
+ )}
379
  </div>
380
  );
381
  }
src/features/discovery/actions/trigger-full-video-sync.ts ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use server';
2
+
3
+ import { auth } from '@/lib/auth';
4
+ import { headers } from 'next/headers';
5
+ import { db } from '@/lib/db';
6
+ import { youtubeChannels, videoScanJobs } from '@/lib/db/schema';
7
+ import { eq } from 'drizzle-orm';
8
+ import { inngest } from '@/inngest/client';
9
+ import crypto from 'crypto';
10
+
11
+ /**
12
+ * Server action to trigger a full YouTube video archive scan.
13
+ * This fetches ALL videos from the user's YouTube channel.
14
+ */
15
+ export async function triggerFullVideoSync() {
16
+ const session = await auth.api.getSession({
17
+ headers: await headers(),
18
+ });
19
+
20
+ if (!session || !session.user) {
21
+ return { success: false, error: 'Unauthorized. Please log in first.' };
22
+ }
23
+
24
+ try {
25
+ // Get the user's verified channel
26
+ const channel = await db.query.youtubeChannels.findFirst({
27
+ where: (channels, { and, eq }) => and(
28
+ eq(channels.creatorId, session.user.id),
29
+ eq(channels.channelType, 'verified')
30
+ ),
31
+ });
32
+
33
+ if (!channel) {
34
+ return {
35
+ success: false,
36
+ error: 'No verified YouTube channel found. Please connect your channel first.'
37
+ };
38
+ }
39
+
40
+ // Create a scan job
41
+ const scanJobId = crypto.randomUUID();
42
+ await db.insert(videoScanJobs).values({
43
+ id: scanJobId,
44
+ userId: session.user.id,
45
+ channelId: channel.id,
46
+ status: 'pending',
47
+ totalVideos: 0,
48
+ scannedVideos: 0,
49
+ progress: 0,
50
+ });
51
+
52
+ // Trigger the Inngest scan-video-archive function
53
+ await inngest.send({
54
+ name: 'youtube/video-archive.scan',
55
+ data: {
56
+ channelId: channel.id,
57
+ userId: session.user.id,
58
+ scanJobId,
59
+ },
60
+ });
61
+
62
+ return {
63
+ success: true,
64
+ jobId: scanJobId,
65
+ channelName: channel.channelName,
66
+ };
67
+ } catch (error) {
68
+ console.error('[triggerFullVideoSync] Error:', error);
69
+ return {
70
+ success: false,
71
+ error: error instanceof Error ? error.message : 'An unexpected error occurred while starting video sync.'
72
+ };
73
+ }
74
+ }
src/inngest/functions/scan-video-archive.ts CHANGED
@@ -351,6 +351,8 @@ async function processVideoBatch(
351
  ): Promise<void> {
352
  if (videoIds.length === 0) return;
353
 
 
 
354
  try {
355
  // Fetch video metadata
356
  const videosMetadata = await getVideosMetadata(videoIds, accessToken);
@@ -359,20 +361,28 @@ async function processVideoBatch(
359
  const insertedVideos = await db
360
  .insert(youtubeVideos)
361
  .values(
362
- videosMetadata.map(video => ({
363
- channelId,
364
- videoId: video.videoId,
365
- title: video.title,
366
- description: video.description,
367
- thumbnailUrl: video.thumbnailUrl,
368
- duration: video.duration,
369
- viewCount: video.viewCount,
370
- publishedAt: video.publishedAt,
371
- availabilityStatus: video.privacyStatus,
372
- }))
 
 
 
 
 
 
 
 
373
  )
374
  .onConflictDoUpdate({
375
- target: youtubeVideos.videoId,
376
  set: {
377
  title: sql`EXCLUDED.title`,
378
  description: sql`EXCLUDED.description`,
@@ -396,6 +406,8 @@ async function processVideoBatch(
396
  }
397
 
398
  // Trigger object detection for each video in the batch
 
 
399
  if (insertedVideos.length > 0) {
400
  await inngest.send(
401
  insertedVideos.map((v) => ({
@@ -407,6 +419,7 @@ async function processVideoBatch(
407
  }))
408
  );
409
  }
 
410
 
411
  // Update progress
412
  const newScanned = currentScanned + videoIds.length;
 
351
  ): Promise<void> {
352
  if (videoIds.length === 0) return;
353
 
354
+ if (videoIds.length === 0) return;
355
+
356
  try {
357
  // Fetch video metadata
358
  const videosMetadata = await getVideosMetadata(videoIds, accessToken);
 
361
  const insertedVideos = await db
362
  .insert(youtubeVideos)
363
  .values(
364
+ videosMetadata.map(video => {
365
+ // Map YouTube's privacyStatus to our database availabilityStatus
366
+ // 'public' and 'unlisted' videos should be 'available' in our system
367
+ // 'private' videos should be 'private' (filtered out from dashboard)
368
+ const availabilityStatus =
369
+ video.privacyStatus === 'private' ? 'private' : 'available';
370
+
371
+ return {
372
+ channelId,
373
+ videoId: video.videoId,
374
+ title: video.title,
375
+ description: video.description,
376
+ thumbnailUrl: video.thumbnailUrl,
377
+ duration: video.duration,
378
+ viewCount: video.viewCount,
379
+ publishedAt: video.publishedAt,
380
+ availabilityStatus,
381
+ };
382
+ })
383
  )
384
  .onConflictDoUpdate({
385
+ target: [youtubeVideos.channelId, youtubeVideos.videoId],
386
  set: {
387
  title: sql`EXCLUDED.title`,
388
  description: sql`EXCLUDED.description`,
 
406
  }
407
 
408
  // Trigger object detection for each video in the batch
409
+ // REMOVED: User request - only list videos, do not auto-scan.
410
+ /*
411
  if (insertedVideos.length > 0) {
412
  await inngest.send(
413
  insertedVideos.map((v) => ({
 
419
  }))
420
  );
421
  }
422
+ */
423
 
424
  // Update progress
425
  const newScanned = currentScanned + videoIds.length;
src/lib/db/schema.ts CHANGED
@@ -152,7 +152,7 @@ export const youtubeVideos = pgTable('youtube_videos', {
152
  thumbnailUrl: text('thumbnail_url'),
153
  duration: text('duration'), // ISO 8601 format (PT15M33S)
154
  viewCount: integer('view_count'),
155
- availabilityStatus: text('availability_status').notNull().default('unknown'), // 'unknown' | 'available' | 'private'
156
  scanStatus: videoScanStatusEnum('scan_status').default('pending').notNull(),
157
  publishedAt: timestamp('published_at', {
158
  mode: 'date',
 
152
  thumbnailUrl: text('thumbnail_url'),
153
  duration: text('duration'), // ISO 8601 format (PT15M33S)
154
  viewCount: integer('view_count'),
155
+ availabilityStatus: text('availability_status').notNull().default('unknown'), // 'available' (public/unlisted) | 'private' | 'unknown'
156
  scanStatus: videoScanStatusEnum('scan_status').default('pending').notNull(),
157
  publishedAt: timestamp('published_at', {
158
  mode: 'date',