Spaces:
Runtime error
Runtime error
Commit ·
d5ba005
1
Parent(s): 0493cc1
feat: Implement multi-tenancy for showcase data fetching and update product count displays.
Browse files- daemon.log +6 -0
- src/app/showcase/[videoId]/moderate/page.tsx +1 -1
- src/app/showcase/[videoId]/page.tsx +4 -4
- src/app/vault/[creatorSlug]/video/[videoId]/page.tsx +6 -2
- src/features/moderation/actions/fetch-product-metadata.ts +5 -1
- src/features/moderation/actions/transfer-temporary-products.ts +105 -22
- src/features/vault/components/claim-vault-button.tsx +5 -2
- src/features/vault/components/unauthenticated-banner.tsx +7 -2
- src/features/vault/components/vault-grid-with-temp-products.tsx +4 -2
- src/features/vault/services/showcase.service.ts +72 -44
- src/features/vault/services/vault.service.ts +15 -7
- src/lib/actions/test-db.ts +0 -28
- src/lib/auth.ts +10 -9
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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
)
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
|
|
|
|
|
|
|
| 74 |
if (!videoData) {
|
| 75 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
failed++;
|
| 77 |
continue;
|
| 78 |
}
|
| 79 |
|
| 80 |
-
|
|
|
|
|
|
|
| 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(
|
| 137 |
-
|
| 138 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
{
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
{
|
| 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 |
-
{
|
| 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'
|
| 37 |
frameTimestamp: p.frameTimestamp,
|
| 38 |
-
videoId: sampleVideo.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,
|
| 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,16 +75,30 @@ export class ShowcaseService {
|
|
| 75 |
}
|
| 76 |
|
| 77 |
// 2. Check Database for real results
|
| 78 |
-
const
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
channel:
|
| 82 |
-
}
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
// Self-healing: If subscriberCount is missing or 0, trigger a background refresh
|
| 87 |
-
if (!
|
| 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:
|
| 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:
|
| 217 |
-
subscriberCount:
|
| 218 |
-
thumbnailUrl:
|
| 219 |
-
creatorSlug:
|
| 220 |
-
creatorId:
|
| 221 |
},
|
| 222 |
videos: [{
|
| 223 |
id: video.videoId,
|
|
@@ -247,12 +261,12 @@ export class ShowcaseService {
|
|
| 247 |
return {
|
| 248 |
vault: {
|
| 249 |
channel: {
|
| 250 |
-
id:
|
| 251 |
-
channelName:
|
| 252 |
-
subscriberCount:
|
| 253 |
-
thumbnailUrl:
|
| 254 |
-
creatorSlug:
|
| 255 |
-
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
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
channel:
|
| 288 |
-
}
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 340 |
-
creatorSlug:
|
| 341 |
-
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 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
|
|
|
| 4 |
import * as schema from "./db/schema";
|
| 5 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
export const auth = betterAuth({
|
| 7 |
-
database: drizzleAdapter(
|
| 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"]
|