dvijaykrishnan commited on
Commit
d5ba005
·
1 Parent(s): 0493cc1

feat: Implement multi-tenancy for showcase data fetching and update product count displays.

Browse files
daemon.log CHANGED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ Uncaught exception: Error: read EIO
2
+ at TTY.onStreamRead (node:internal/stream_base_commons:216:20) {
3
+ errno: -5,
4
+ code: 'EIO',
5
+ syscall: 'read'
6
+ }
src/app/showcase/[videoId]/moderate/page.tsx CHANGED
@@ -19,7 +19,7 @@ export default async function ShowcaseModerationPage({ params }: ModerationPageP
19
  headers: await headers(),
20
  });
21
 
22
- const data = await ShowcaseService.getShowcaseModerationQueue(videoId);
23
 
24
  if (!data) {
25
  notFound();
 
19
  headers: await headers(),
20
  });
21
 
22
+ const data = await ShowcaseService.getShowcaseModerationQueue(videoId, session?.user?.id);
23
 
24
  if (!data) {
25
  notFound();
src/app/showcase/[videoId]/page.tsx CHANGED
@@ -31,16 +31,16 @@ export default async function ShowcasePage({ params }: ShowcasePageProps) {
31
  notFound();
32
  }
33
 
34
- const { vault, products, isProcessing } = await ShowcaseService.getShowcaseVault(videoId);
35
- const videoTitle = vault.videos?.[0]?.title || "Showcase Video";
36
- const editUrl = `/showcase/${videoId}/moderate`;
37
-
38
  // Check if user is authenticated
39
  const session = await auth.api.getSession({
40
  headers: await headers()
41
  });
42
  const isAuthenticated = !!session?.user;
43
 
 
 
 
 
44
  // Check ownership: only the creator who owns this video can access showcase
45
  if (!isAuthenticated || session.user.id !== vault.channel.creatorId) {
46
  // Redirect to public vault page instead
 
31
  notFound();
32
  }
33
 
 
 
 
 
34
  // Check if user is authenticated
35
  const session = await auth.api.getSession({
36
  headers: await headers()
37
  });
38
  const isAuthenticated = !!session?.user;
39
 
40
+ const { vault, products, isProcessing } = await ShowcaseService.getShowcaseVault(videoId, session?.user?.id);
41
+ const videoTitle = vault.videos?.[0]?.title || "Showcase Video";
42
+ const editUrl = `/showcase/${videoId}/moderate`;
43
+
44
  // Check ownership: only the creator who owns this video can access showcase
45
  if (!isAuthenticated || session.user.id !== vault.channel.creatorId) {
46
  // Redirect to public vault page instead
src/app/vault/[creatorSlug]/video/[videoId]/page.tsx CHANGED
@@ -23,7 +23,7 @@ interface VideoPageProps {
23
 
24
  export default async function VideoPage({ params }: VideoPageProps) {
25
  const { creatorSlug, videoId } = await params;
26
- const { video, products } = await VaultService.getVideoProducts(videoId);
27
 
28
  if (!video) {
29
  notFound();
@@ -68,7 +68,11 @@ export default async function VideoPage({ params }: VideoPageProps) {
68
  <div className="min-h-screen bg-background pb-20 overflow-x-hidden">
69
  {/* Unauthenticated Banner - Only show for logged-out users */}
70
  {!isAuthenticated && (
71
- <UnauthenticatedBanner videoId={video.videoId} creatorSlug={creatorSlug} />
 
 
 
 
72
  )}
73
 
74
  {/* Claim Handler - Handles post-sign-in product transfer */}
 
23
 
24
  export default async function VideoPage({ params }: VideoPageProps) {
25
  const { creatorSlug, videoId } = await params;
26
+ const { video, products } = await VaultService.getVideoProducts(videoId, creatorSlug);
27
 
28
  if (!video) {
29
  notFound();
 
68
  <div className="min-h-screen bg-background pb-20 overflow-x-hidden">
69
  {/* Unauthenticated Banner - Only show for logged-out users */}
70
  {!isAuthenticated && (
71
+ <UnauthenticatedBanner
72
+ videoId={video.videoId}
73
+ creatorSlug={creatorSlug}
74
+ existingProductCount={products.length}
75
+ />
76
  )}
77
 
78
  {/* Claim Handler - Handles post-sign-in product transfer */}
src/features/moderation/actions/fetch-product-metadata.ts CHANGED
@@ -44,7 +44,11 @@ async function resolveRedirect(url: string, depth = 0): Promise<string> {
44
 
45
  export async function fetchProductMetadata(url: string): Promise<{ success: boolean; data?: ProductMetadata; error?: string }> {
46
  try {
47
- let currentUrl = url;
 
 
 
 
48
  let domain = new URL(currentUrl).hostname.toLowerCase();
49
 
50
  // If not a known marketplace, try to resolve redirects
 
44
 
45
  export async function fetchProductMetadata(url: string): Promise<{ success: boolean; data?: ProductMetadata; error?: string }> {
46
  try {
47
+ let currentUrl = url.trim();
48
+ if (!currentUrl.startsWith('http://') && !currentUrl.startsWith('https://')) {
49
+ currentUrl = `https://${currentUrl}`;
50
+ }
51
+
52
  let domain = new URL(currentUrl).hostname.toLowerCase();
53
 
54
  // If not a known marketplace, try to resolve redirects
src/features/moderation/actions/transfer-temporary-products.ts CHANGED
@@ -2,7 +2,7 @@
2
 
3
  import { db } from '@/lib/db';
4
  import { detectedObjects, marketplaceMatches, youtubeVideos, youtubeChannels } from '@/lib/db/schema';
5
- import { eq, or } from 'drizzle-orm';
6
  import { auth } from '@/lib/auth';
7
  import { headers } from 'next/headers';
8
  import { revalidatePath } from 'next/cache';
@@ -31,7 +31,8 @@ export interface TransferResult {
31
  }
32
 
33
  export async function transferTemporaryProducts(
34
- products: TemporaryProductData[]
 
35
  ): Promise<TransferResult> {
36
  try {
37
  const session = await auth.api.getSession({
@@ -54,36 +55,104 @@ export async function transferTemporaryProducts(
54
 
55
  for (const product of products) {
56
  try {
57
- // Verify the video exists and get its internal ID
58
- const videoData = await db
59
- .select({
60
- id: youtubeVideos.id,
61
- creatorId: youtubeChannels.creatorId,
62
- })
63
- .from(youtubeVideos)
64
- .innerJoin(youtubeChannels, eq(youtubeVideos.channelId, youtubeChannels.id))
65
- .where(
66
- or(
67
- eq(youtubeVideos.id, product.videoId),
68
- eq(youtubeVideos.videoId, product.videoId)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  )
70
- )
71
- .limit(1)
72
- .then(res => res[0]);
73
 
 
 
74
  if (!videoData) {
75
- errors.push(`Video not found: ${product.videoId}`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  failed++;
77
  continue;
78
  }
79
 
80
- // Verify user owns this video
 
 
81
  if (videoData.creatorId !== userId) {
82
  errors.push(`Unauthorized: User does not own video ${product.videoId}`);
83
  failed++;
84
  continue;
85
  }
86
 
 
87
  // Insert the detected object (auto-approved since user added it)
88
  const [newDetection] = await db
89
  .insert(detectedObjects)
@@ -104,8 +173,11 @@ export async function transferTemporaryProducts(
104
  })
105
  .returning({ id: detectedObjects.id });
106
 
 
 
107
  // Insert marketplace match if provided
108
  if (product.marketplaceMatch) {
 
109
  await db
110
  .insert(marketplaceMatches)
111
  .values({
@@ -118,6 +190,7 @@ export async function transferTemporaryProducts(
118
  affiliateUrl: product.marketplaceMatch.affiliateUrl,
119
  imageUrl: product.marketplaceMatch.imageUrl || null,
120
  });
 
121
  }
122
 
123
  transferred++;
@@ -133,10 +206,20 @@ export async function transferTemporaryProducts(
133
  revalidatePath('/dashboard');
134
  // Revalidate each unique video page
135
  const uniqueVideoIds = [...new Set(products.map(p => p.videoId))];
136
- uniqueVideoIds.forEach(videoId => {
137
- revalidatePath(`/vault/[creatorSlug]/video/${videoId}`, 'page');
138
- revalidatePath(`/showcase/${videoId}`, 'page');
 
 
 
 
 
139
  });
 
 
 
 
 
140
  }
141
 
142
  return {
 
2
 
3
  import { db } from '@/lib/db';
4
  import { detectedObjects, marketplaceMatches, youtubeVideos, youtubeChannels } from '@/lib/db/schema';
5
+ import { eq, or, and } from 'drizzle-orm';
6
  import { auth } from '@/lib/auth';
7
  import { headers } from 'next/headers';
8
  import { revalidatePath } from 'next/cache';
 
31
  }
32
 
33
  export async function transferTemporaryProducts(
34
+ products: TemporaryProductData[],
35
+ targetVideoId?: string
36
  ): Promise<TransferResult> {
37
  try {
38
  const session = await auth.api.getSession({
 
55
 
56
  for (const product of products) {
57
  try {
58
+ let videoData: { id: string; creatorId: string; videoIdString: string } | undefined;
59
+
60
+ // 0. If targetVideoId is provided directly, use it
61
+ if (targetVideoId) {
62
+ console.log(`[transfer-temp] Using explicit targetVideoId: ${targetVideoId}`);
63
+ const resolved = await db
64
+ .select({
65
+ id: youtubeVideos.id,
66
+ creatorId: youtubeChannels.creatorId,
67
+ videoIdString: youtubeVideos.videoId,
68
+ })
69
+ .from(youtubeVideos)
70
+ .innerJoin(youtubeChannels, eq(youtubeVideos.channelId, youtubeChannels.id))
71
+ .where(eq(youtubeVideos.id, targetVideoId))
72
+ .limit(1)
73
+ .then(res => res[0]);
74
+
75
+ if (resolved) {
76
+ videoData = resolved;
77
+ } else {
78
+ console.warn(`[transfer-temp] provided targetVideoId ${targetVideoId} not found!`);
79
+ }
80
+ }
81
+
82
+ // 1. If not provided or not found, try to find a video strictly owned by the user
83
+ if (!videoData) {
84
+ videoData = await db
85
+ .select({
86
+ id: youtubeVideos.id,
87
+ creatorId: youtubeChannels.creatorId,
88
+ videoIdString: youtubeVideos.videoId,
89
+ creatorSlug: youtubeChannels.creatorSlug,
90
+ })
91
+ .from(youtubeVideos)
92
+ .innerJoin(youtubeChannels, eq(youtubeVideos.channelId, youtubeChannels.id))
93
+ .where(
94
+ and(
95
+ eq(youtubeChannels.creatorId, userId),
96
+ or(
97
+ eq(youtubeVideos.id, product.videoId),
98
+ eq(youtubeVideos.videoId, product.videoId)
99
+ )
100
+ )
101
  )
102
+ .limit(1)
103
+ .then(res => res[0]);
104
+ }
105
 
106
+ // 2. If not found, it might be an internal ID of a SOURCE video (not owned by user).
107
+ // We need to resolve it to get the YouTube ID string, then find the user's copy.
108
  if (!videoData) {
109
+ const sourceVideo = await db
110
+ .select({
111
+ videoIdString: youtubeVideos.videoId,
112
+ })
113
+ .from(youtubeVideos)
114
+ .where(eq(youtubeVideos.id, product.videoId))
115
+ .limit(1)
116
+ .then(res => res[0]);
117
+
118
+ if (sourceVideo) {
119
+ // Found the source video, now search for user's copy using the string ID
120
+ videoData = await db
121
+ .select({
122
+ id: youtubeVideos.id,
123
+ creatorId: youtubeChannels.creatorId,
124
+ videoIdString: youtubeVideos.videoId,
125
+ })
126
+ .from(youtubeVideos)
127
+ .innerJoin(youtubeChannels, eq(youtubeVideos.channelId, youtubeChannels.id))
128
+ .where(
129
+ and(
130
+ eq(youtubeChannels.creatorId, userId),
131
+ eq(youtubeVideos.videoId, sourceVideo.videoIdString)
132
+ )
133
+ )
134
+ .limit(1)
135
+ .then(res => res[0]);
136
+ }
137
+ }
138
+
139
+ if (!videoData) {
140
+ console.error(`[transfer-temp] VIDEO_NOT_FOUND: Could not resolve video record for ${product.videoId} and user ${userId}`);
141
+ errors.push(`Video not found or vault not claimed for video: ${product.videoId}`);
142
  failed++;
143
  continue;
144
  }
145
 
146
+ console.log(`[transfer-temp] Resolved target video record: ${videoData.id}`);
147
+
148
+ // Verify user owns this video (double check)
149
  if (videoData.creatorId !== userId) {
150
  errors.push(`Unauthorized: User does not own video ${product.videoId}`);
151
  failed++;
152
  continue;
153
  }
154
 
155
+ console.log(`[transfer-temp] Inserting detection for ${product.objectName}...`);
156
  // Insert the detected object (auto-approved since user added it)
157
  const [newDetection] = await db
158
  .insert(detectedObjects)
 
173
  })
174
  .returning({ id: detectedObjects.id });
175
 
176
+ console.log(`[transfer-temp] Detection created: ${newDetection.id}`);
177
+
178
  // Insert marketplace match if provided
179
  if (product.marketplaceMatch) {
180
+ console.log(`[transfer-temp] Inserting marketplace match for ${product.objectName}...`);
181
  await db
182
  .insert(marketplaceMatches)
183
  .values({
 
190
  affiliateUrl: product.marketplaceMatch.affiliateUrl,
191
  imageUrl: product.marketplaceMatch.imageUrl || null,
192
  });
193
+ console.log(`[transfer-temp] Marketplace match created.`);
194
  }
195
 
196
  transferred++;
 
206
  revalidatePath('/dashboard');
207
  // Revalidate each unique video page
208
  const uniqueVideoIds = [...new Set(products.map(p => p.videoId))];
209
+ uniqueVideoIds.forEach(vid => {
210
+ // Precise revalidation of the public page if we resolved the slug
211
+ const productWithSlug = products.find(p => p.videoId === vid);
212
+ // We use the first videoData we found for that videoId
213
+ // (Note: videoData is inside the loop, so we need to be careful.
214
+ // But usually there's only one videoId in the transfer set).
215
+ // Actually, let's just revalidate the showcase and trust the slug is in the cache.
216
+ revalidatePath(`/showcase/${vid}`, 'page');
217
  });
218
+
219
+ // If we have a resolved slug, revalidate the public page specifically
220
+ const firstResult = products[0]; // Logic assumes most transfers are for one video
221
+ // In a better world we'd map vid -> slug, but for this fix, we'll hit the layout.
222
+ revalidatePath(`/vault/[creatorSlug]/video/[videoId]`, 'layout');
223
  }
224
 
225
  return {
src/features/vault/components/claim-vault-button.tsx CHANGED
@@ -12,13 +12,16 @@ import { useRouter } from 'next/navigation';
12
  interface ClaimVaultButtonProps {
13
  videoId: string;
14
  creatorSlug: string;
 
15
  }
16
 
17
- export function ClaimVaultButton({ videoId, creatorSlug }: ClaimVaultButtonProps) {
18
  const router = useRouter();
19
  const [isPending, startTransition] = useTransition();
20
  const { tempProducts, getAllTemporaryProducts } = useTemporaryProducts(videoId);
21
 
 
 
22
  const handleClaim = async () => {
23
  // First, sign in
24
  const result = await signIn.social({
@@ -40,7 +43,7 @@ export function ClaimVaultButton({ videoId, creatorSlug }: ClaimVaultButtonProps
40
  >
41
  <Sparkles className="h-3.5 w-3.5" />
42
  {isPending ? 'Claiming...' : 'Claim Your Vault'}
43
- {tempProducts.length > 0 && ` (${tempProducts.length})`}
44
  </Button>
45
  );
46
  }
 
12
  interface ClaimVaultButtonProps {
13
  videoId: string;
14
  creatorSlug: string;
15
+ existingCount?: number;
16
  }
17
 
18
+ export function ClaimVaultButton({ videoId, creatorSlug, existingCount = 0 }: ClaimVaultButtonProps) {
19
  const router = useRouter();
20
  const [isPending, startTransition] = useTransition();
21
  const { tempProducts, getAllTemporaryProducts } = useTemporaryProducts(videoId);
22
 
23
+ const totalCount = existingCount + tempProducts.length;
24
+
25
  const handleClaim = async () => {
26
  // First, sign in
27
  const result = await signIn.social({
 
43
  >
44
  <Sparkles className="h-3.5 w-3.5" />
45
  {isPending ? 'Claiming...' : 'Claim Your Vault'}
46
+ {totalCount > 0 && ` (${totalCount})`}
47
  </Button>
48
  );
49
  }
src/features/vault/components/unauthenticated-banner.tsx CHANGED
@@ -8,9 +8,10 @@ import { ClaimVaultButton } from './claim-vault-button';
8
  interface UnauthenticatedBannerProps {
9
  videoId: string;
10
  creatorSlug: string;
 
11
  }
12
 
13
- export function UnauthenticatedBanner({ videoId, creatorSlug }: UnauthenticatedBannerProps) {
14
  const handleShare = async () => {
15
  const url = `${window.location.origin}/vault/${creatorSlug}/video/${videoId}`;
16
  if (navigator.share) {
@@ -48,7 +49,11 @@ export function UnauthenticatedBanner({ videoId, creatorSlug }: UnauthenticatedB
48
  <Share2 className="h-3.5 w-3.5" />
49
  <span className="hidden sm:inline">Share</span>
50
  </Button>
51
- <ClaimVaultButton videoId={videoId} creatorSlug={creatorSlug} />
 
 
 
 
52
  </div>
53
  </div>
54
  </div>
 
8
  interface UnauthenticatedBannerProps {
9
  videoId: string;
10
  creatorSlug: string;
11
+ existingProductCount: number;
12
  }
13
 
14
+ export function UnauthenticatedBanner({ videoId, creatorSlug, existingProductCount }: UnauthenticatedBannerProps) {
15
  const handleShare = async () => {
16
  const url = `${window.location.origin}/vault/${creatorSlug}/video/${videoId}`;
17
  if (navigator.share) {
 
49
  <Share2 className="h-3.5 w-3.5" />
50
  <span className="hidden sm:inline">Share</span>
51
  </Button>
52
+ <ClaimVaultButton
53
+ videoId={videoId}
54
+ creatorSlug={creatorSlug}
55
+ existingCount={existingProductCount}
56
+ />
57
  </div>
58
  </div>
59
  </div>
src/features/vault/components/vault-grid-with-temp-products.tsx CHANGED
@@ -39,14 +39,16 @@ export function VaultGridWithTempProducts({
39
  // Merge server products with temporary products
40
  const allProducts = [...products, ...tempProductsFormatted];
41
 
 
 
42
  return (
43
  <>
44
- {tempProducts.length > 0 && (
45
  <div className="mb-4 p-4 bg-amber-500/10 border border-amber-500/20 rounded-lg flex items-start gap-3">
46
  <AlertCircle className="h-5 w-5 text-amber-500 flex-shrink-0 mt-0.5" />
47
  <div className="flex-1">
48
  <p className="text-sm font-medium text-foreground">
49
- {tempProducts.length} product{tempProducts.length > 1 ? 's' : ''} not saved
50
  </p>
51
  <p className="text-xs text-muted-foreground mt-1">
52
  Sign in to save these products permanently to your vault.
 
39
  // Merge server products with temporary products
40
  const allProducts = [...products, ...tempProductsFormatted];
41
 
42
+ const totalCount = products.length + tempProducts.length;
43
+
44
  return (
45
  <>
46
+ {totalCount > 0 && (
47
  <div className="mb-4 p-4 bg-amber-500/10 border border-amber-500/20 rounded-lg flex items-start gap-3">
48
  <AlertCircle className="h-5 w-5 text-amber-500 flex-shrink-0 mt-0.5" />
49
  <div className="flex-1">
50
  <p className="text-sm font-medium text-foreground">
51
+ {totalCount} products total to save
52
  </p>
53
  <p className="text-xs text-muted-foreground mt-1">
54
  Sign in to save these products permanently to your vault.
src/features/vault/services/showcase.service.ts CHANGED
@@ -11,8 +11,11 @@ export class ShowcaseService {
11
  /**
12
  * Fetches showcase data for a specific YouTube video.
13
  * Prioritizes curated data, then real DB results, then falls back to OEmbed.
 
 
 
14
  */
15
- static async getShowcaseVault(videoId: string): Promise<{
16
  vault: CreatorVault;
17
  products: ProductCard[];
18
  isProcessing?: boolean;
@@ -24,23 +27,20 @@ export class ShowcaseService {
24
 
25
  // 1.5 Check for Sample Data (Story 9.1: Homepage Population)
26
  const sampleVideo = SAMPLE_TRENDING_VIDEOS.find(v => v.videoId === videoId);
27
- if (sampleVideo) {
28
- // Filter products for this video
29
  const sampleProducts = SAMPLE_TRENDING_PRODUCTS.filter(p => p.video.videoId === videoId);
30
-
31
- // Map to ProductCard format
32
  const products: ProductCard[] = sampleProducts.map(p => ({
33
  id: p.objectId,
34
  marketplaceMatchId: p.id,
35
  objectName: p.name,
36
- category: 'Tech', // Default for samples
37
  frameTimestamp: p.frameTimestamp,
38
- videoId: sampleVideo.id, // Internal ID
39
  videoTitle: sampleVideo.title,
40
- marketplace: p.marketplace,
41
  productName: p.name,
42
  price: p.price,
43
- availabilityStatus: p.availabilityStatus,
44
  linkStatus: 'ACTIVE',
45
  affiliateUrl: p.affiliateUrl || null,
46
  imageUrl: p.thumbnailUrl || undefined,
@@ -53,13 +53,13 @@ export class ShowcaseService {
53
  channel: {
54
  id: sampleVideo.creator.id,
55
  channelName: sampleVideo.creator.channelName,
56
- subscriberCount: 1000000, // Placeholder if not in sample
57
  thumbnailUrl: sampleVideo.creator.thumbnailUrl,
58
  creatorSlug: sampleVideo.creator.slug,
59
  creatorId: sampleVideo.creator.id,
60
  },
61
  videos: [{
62
- id: sampleVideo.videoId, // Use Public ID for routing
63
  title: sampleVideo.title,
64
  thumbnailUrl: sampleVideo.thumbnailUrl || '',
65
  viewCount: sampleVideo.viewCount || 0,
@@ -75,16 +75,30 @@ export class ShowcaseService {
75
  }
76
 
77
  // 2. Check Database for real results
78
- const video = await db.query.youtubeVideos.findFirst({
79
- where: eq(youtubeVideos.videoId, videoId),
80
- with: {
81
- channel: true,
82
- }
83
- });
84
-
85
- if (video) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  // Self-healing: If subscriberCount is missing or 0, trigger a background refresh
87
- if (!video.channel.subscriberCount && !SHOWCASE_CONFIG[videoId]) {
88
  // We don't await this to keep the response fast, but it will fix it for the next view
89
  this.ingestAndAnalyze(videoId).catch(console.error);
90
  }
@@ -210,14 +224,14 @@ export class ShowcaseService {
210
  return {
211
  vault: {
212
  channel: {
213
- id: video.channel.id,
214
  // Comprehensive Override: In showcase mode, ensure identity and stats (subscribers/thumbnail)
215
  // reflect the true public creator, not the internal account the video might be linked to.
216
- channelName: video.channel.creatorId === DEMO_USER_ID ? video.channel.channelName : publicData.vault.channel.channelName,
217
- subscriberCount: video.channel.creatorId === DEMO_USER_ID ? video.channel.subscriberCount : publicData.vault.channel.subscriberCount,
218
- thumbnailUrl: video.channel.creatorId === DEMO_USER_ID ? video.channel.thumbnailUrl : publicData.vault.channel.thumbnailUrl,
219
- creatorSlug: video.channel.creatorId === DEMO_USER_ID ? video.channel.creatorSlug : `demo-${publicData.vault.channel.channelName.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`,
220
- creatorId: video.channel.creatorId,
221
  },
222
  videos: [{
223
  id: video.videoId,
@@ -247,12 +261,12 @@ export class ShowcaseService {
247
  return {
248
  vault: {
249
  channel: {
250
- id: video.channel.id,
251
- channelName: video.channel.channelName,
252
- subscriberCount: video.channel.subscriberCount || 0,
253
- thumbnailUrl: video.channel.thumbnailUrl,
254
- creatorSlug: video.channel.creatorSlug,
255
- creatorId: video.channel.creatorId,
256
  },
257
  videos: [{
258
  id: video.videoId,
@@ -279,16 +293,30 @@ export class ShowcaseService {
279
 
280
  /**
281
  * Fetches all detections and stats for a specific video to support the Showcase Moderation view.
 
 
 
282
  */
283
- static async getShowcaseModerationQueue(videoId: string) {
284
- const video = await db.query.youtubeVideos.findFirst({
285
- where: eq(youtubeVideos.videoId, videoId),
286
- with: {
287
- channel: true,
288
- }
289
- });
290
-
291
- if (!video) return null;
 
 
 
 
 
 
 
 
 
 
 
292
 
293
  const detections = await db.query.detectedObjects.findMany({
294
  where: and(
@@ -336,9 +364,9 @@ export class ShowcaseService {
336
  id: video.id,
337
  title: video.title,
338
  channel: {
339
- creatorId: video.channel.creatorId,
340
- creatorSlug: video.channel.creatorSlug || '',
341
- channelName: video.channel.channelName,
342
  }
343
  }
344
  };
 
11
  /**
12
  * Fetches showcase data for a specific YouTube video.
13
  * Prioritizes curated data, then real DB results, then falls back to OEmbed.
14
+ *
15
+ * @param videoId - The YouTube video ID
16
+ * @param userId - Optional user ID to resolve the correct record in multi-tenant scenarios
17
  */
18
+ static async getShowcaseVault(videoId: string, userId?: string): Promise<{
19
  vault: CreatorVault;
20
  products: ProductCard[];
21
  isProcessing?: boolean;
 
27
 
28
  // 1.5 Check for Sample Data (Story 9.1: Homepage Population)
29
  const sampleVideo = SAMPLE_TRENDING_VIDEOS.find(v => v.videoId === videoId);
30
+ if (sampleVideo && !userId) {
 
31
  const sampleProducts = SAMPLE_TRENDING_PRODUCTS.filter(p => p.video.videoId === videoId);
 
 
32
  const products: ProductCard[] = sampleProducts.map(p => ({
33
  id: p.objectId,
34
  marketplaceMatchId: p.id,
35
  objectName: p.name,
36
+ category: 'Tech' as any,
37
  frameTimestamp: p.frameTimestamp,
38
+ videoId: sampleVideo.id,
39
  videoTitle: sampleVideo.title,
40
+ marketplace: p.marketplace as any,
41
  productName: p.name,
42
  price: p.price,
43
+ availabilityStatus: p.availabilityStatus as any,
44
  linkStatus: 'ACTIVE',
45
  affiliateUrl: p.affiliateUrl || null,
46
  imageUrl: p.thumbnailUrl || undefined,
 
53
  channel: {
54
  id: sampleVideo.creator.id,
55
  channelName: sampleVideo.creator.channelName,
56
+ subscriberCount: 1000000,
57
  thumbnailUrl: sampleVideo.creator.thumbnailUrl,
58
  creatorSlug: sampleVideo.creator.slug,
59
  creatorId: sampleVideo.creator.id,
60
  },
61
  videos: [{
62
+ id: sampleVideo.videoId,
63
  title: sampleVideo.title,
64
  thumbnailUrl: sampleVideo.thumbnailUrl || '',
65
  viewCount: sampleVideo.viewCount || 0,
 
75
  }
76
 
77
  // 2. Check Database for real results
78
+ const videoResult = await db
79
+ .select({
80
+ video: youtubeVideos,
81
+ channel: youtubeChannels,
82
+ })
83
+ .from(youtubeVideos)
84
+ .innerJoin(youtubeChannels, eq(youtubeVideos.channelId, youtubeChannels.id))
85
+ .where(
86
+ and(
87
+ eq(youtubeVideos.videoId, videoId),
88
+ userId ? eq(youtubeChannels.creatorId, userId) : undefined
89
+ )
90
+ )
91
+ .limit(1);
92
+
93
+ const video = videoResult[0]?.video;
94
+ const channel = videoResult[0]?.channel;
95
+
96
+ if (video && channel) {
97
+ console.log(`[getShowcaseVault] Found video record: ${video.id} (scanStatus: ${video.scanStatus})`);
98
+ // Attach channel for the 'with' equivalent
99
+ const videoWithChannel = { ...video, channel };
100
  // Self-healing: If subscriberCount is missing or 0, trigger a background refresh
101
+ if (!channel.subscriberCount && !SHOWCASE_CONFIG[videoId]) {
102
  // We don't await this to keep the response fast, but it will fix it for the next view
103
  this.ingestAndAnalyze(videoId).catch(console.error);
104
  }
 
224
  return {
225
  vault: {
226
  channel: {
227
+ id: channel.id,
228
  // Comprehensive Override: In showcase mode, ensure identity and stats (subscribers/thumbnail)
229
  // reflect the true public creator, not the internal account the video might be linked to.
230
+ channelName: channel.creatorId === DEMO_USER_ID ? channel.channelName : publicData.vault.channel.channelName,
231
+ subscriberCount: channel.creatorId === DEMO_USER_ID ? channel.subscriberCount : publicData.vault.channel.subscriberCount,
232
+ thumbnailUrl: channel.creatorId === DEMO_USER_ID ? channel.thumbnailUrl : publicData.vault.channel.thumbnailUrl,
233
+ creatorSlug: channel.creatorId === DEMO_USER_ID ? channel.creatorSlug : `demo-${publicData.vault.channel.channelName.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`,
234
+ creatorId: channel.creatorId,
235
  },
236
  videos: [{
237
  id: video.videoId,
 
261
  return {
262
  vault: {
263
  channel: {
264
+ id: channel.id,
265
+ channelName: channel.channelName,
266
+ subscriberCount: channel.subscriberCount || 0,
267
+ thumbnailUrl: channel.thumbnailUrl,
268
+ creatorSlug: channel.creatorSlug,
269
+ creatorId: channel.creatorId,
270
  },
271
  videos: [{
272
  id: video.videoId,
 
293
 
294
  /**
295
  * Fetches all detections and stats for a specific video to support the Showcase Moderation view.
296
+ *
297
+ * @param videoId - The YouTube video ID
298
+ * @param userId - Optional user ID to resolve the correct record in multi-tenant scenarios
299
  */
300
+ static async getShowcaseModerationQueue(videoId: string, userId?: string) {
301
+ const videoResult = await db
302
+ .select({
303
+ video: youtubeVideos,
304
+ channel: youtubeChannels,
305
+ })
306
+ .from(youtubeVideos)
307
+ .innerJoin(youtubeChannels, eq(youtubeVideos.channelId, youtubeChannels.id))
308
+ .where(
309
+ and(
310
+ eq(youtubeVideos.videoId, videoId),
311
+ userId ? eq(youtubeChannels.creatorId, userId) : undefined
312
+ )
313
+ )
314
+ .limit(1);
315
+
316
+ const video = videoResult[0]?.video;
317
+ const channel = videoResult[0]?.channel;
318
+
319
+ if (!video || !channel) return null;
320
 
321
  const detections = await db.query.detectedObjects.findMany({
322
  where: and(
 
364
  id: video.id,
365
  title: video.title,
366
  channel: {
367
+ creatorId: channel.creatorId,
368
+ creatorSlug: channel.creatorSlug || '',
369
+ channelName: channel.channelName,
370
  }
371
  }
372
  };
src/features/vault/services/vault.service.ts CHANGED
@@ -637,14 +637,24 @@ export class VaultService {
637
  /**
638
  * Get a single video's details and its approved products.
639
  * Used by the /vault/[creatorSlug]/video/[videoId] page.
 
 
 
640
  */
641
- static async getVideoProducts(videoId: string) {
642
- // Try UUID first, then YouTube ID
 
 
 
 
 
 
 
 
643
  const videoResult = await db
644
  .select({
645
  id: youtubeVideos.id,
646
-
647
- videoId: youtubeVideos.videoId, // Added for linking
648
  title: youtubeVideos.title,
649
  thumbnailUrl: youtubeVideos.thumbnailUrl,
650
  viewCount: youtubeVideos.viewCount,
@@ -660,9 +670,7 @@ export class VaultService {
660
  })
661
  .from(youtubeVideos)
662
  .innerJoin(youtubeChannels, eq(youtubeVideos.channelId, youtubeChannels.id))
663
- .where(
664
- sql`${youtubeVideos.id} = ${videoId} OR ${youtubeVideos.videoId} = ${videoId}`
665
- )
666
  .limit(1);
667
 
668
  if (videoResult.length === 0) {
 
637
  /**
638
  * Get a single video's details and its approved products.
639
  * Used by the /vault/[creatorSlug]/video/[videoId] page.
640
+ *
641
+ * @param videoId - The YouTube video ID or internal UUID
642
+ * @param creatorSlug - Optional creator slug to resolve the correct record in multi-tenant scenarios
643
  */
644
+ static async getVideoProducts(videoId: string, creatorSlug?: string) {
645
+ // Build where conditions
646
+ const whereConditions = [
647
+ sql`${youtubeVideos.id} = ${videoId} OR ${youtubeVideos.videoId} = ${videoId}`
648
+ ];
649
+
650
+ if (creatorSlug) {
651
+ whereConditions.push(eq(youtubeChannels.creatorSlug, creatorSlug));
652
+ }
653
+
654
  const videoResult = await db
655
  .select({
656
  id: youtubeVideos.id,
657
+ videoId: youtubeVideos.videoId,
 
658
  title: youtubeVideos.title,
659
  thumbnailUrl: youtubeVideos.thumbnailUrl,
660
  viewCount: youtubeVideos.viewCount,
 
670
  })
671
  .from(youtubeVideos)
672
  .innerJoin(youtubeChannels, eq(youtubeVideos.channelId, youtubeChannels.id))
673
+ .where(and(...whereConditions))
 
 
674
  .limit(1);
675
 
676
  if (videoResult.length === 0) {
src/lib/actions/test-db.ts DELETED
@@ -1,28 +0,0 @@
1
- 'use server';
2
-
3
- import { db } from '@/lib/db';
4
- import { users } from '@/lib/db/schema';
5
-
6
- export async function testDatabaseConnection() {
7
- try {
8
- const email = `test-${Date.now()}@example.com`;
9
- const now = new Date();
10
- // Test insert
11
- const [user] = await db.insert(users).values({
12
- id: crypto.randomUUID(),
13
- name: "Test User",
14
- email,
15
- emailVerified: false,
16
- createdAt: now,
17
- updatedAt: now,
18
- }).returning();
19
-
20
- // Test select
21
- const allUsers = await db.select().from(users);
22
-
23
- return { success: true, user, count: allUsers.length };
24
- } catch (error) {
25
- console.error('Database test error:', error);
26
- return { success: false, error: String(error) };
27
- }
28
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/lib/auth.ts CHANGED
@@ -1,10 +1,18 @@
1
  import { betterAuth } from "better-auth";
2
  import { drizzleAdapter } from "better-auth/adapters/drizzle";
3
- import { db } from "./db";
 
4
  import * as schema from "./db/schema";
5
 
 
 
 
 
 
 
 
6
  export const auth = betterAuth({
7
- database: drizzleAdapter(db, {
8
  provider: "pg",
9
  schema: {
10
  user: schema.users,
@@ -29,16 +37,9 @@ export const auth = betterAuth({
29
  clientId: process.env.GOOGLE_CLIENT_ID || "",
30
  clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
31
  scope: ["https://www.googleapis.com/auth/youtube.readonly"],
32
- authorization: {
33
- params: {
34
- access_type: "offline",
35
- prompt: "consent",
36
- }
37
- }
38
  }
39
  },
40
  advanced: {
41
- // Help resolve state mismatch on localhost
42
  useSecureCookies: false, // Ensure cookies work on HTTP localhost
43
  },
44
  trustedOrigins: ["http://localhost:3000", "http://127.0.0.1:3000"]
 
1
  import { betterAuth } from "better-auth";
2
  import { drizzleAdapter } from "better-auth/adapters/drizzle";
3
+ import { drizzle } from 'drizzle-orm/postgres-js';
4
+ import postgres from 'postgres';
5
  import * as schema from "./db/schema";
6
 
7
+ const pool = postgres(process.env.DIRECT_URL!, {
8
+ ssl: 'require',
9
+ });
10
+ const authDb = drizzle(pool, {
11
+ casing: 'snake_case',
12
+ });
13
+
14
  export const auth = betterAuth({
15
+ database: drizzleAdapter(authDb, {
16
  provider: "pg",
17
  schema: {
18
  user: schema.users,
 
37
  clientId: process.env.GOOGLE_CLIENT_ID || "",
38
  clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
39
  scope: ["https://www.googleapis.com/auth/youtube.readonly"],
 
 
 
 
 
 
40
  }
41
  },
42
  advanced: {
 
43
  useSecureCookies: false, // Ensure cookies work on HTTP localhost
44
  },
45
  trustedOrigins: ["http://localhost:3000", "http://127.0.0.1:3000"]