Spaces:
Runtime error
Runtime error
feat: Implement an AI analysis gatekeeper modal and auto-triggering for the DiscoveryPanel component.
85f636b | "use server"; | |
| import { db } from "@/lib/db"; | |
| import { youtubeVideos, detectedObjects, marketplaceMatches, adminModeration } from "@/lib/db/schema"; | |
| import { eq, and, inArray, desc } from "drizzle-orm"; | |
| import { auth } from "@/lib/auth"; | |
| import { headers } from "next/headers"; | |
| import { inngest } from "@/inngest/client"; | |
| import { revalidatePath } from "next/cache"; | |
| async function ensureAdmin() { | |
| const session = await auth.api.getSession({ | |
| headers: await headers() | |
| }); | |
| const adminEmails = process.env.ADMIN_EMAILS?.split(",") || []; | |
| if (!session?.user?.email || !adminEmails.includes(session.user.email)) { | |
| throw new Error("Unauthorized"); | |
| } | |
| } | |
| export async function getAnalysisQueue(status?: string) { | |
| await ensureAdmin(); | |
| const statuses = status | |
| ? [status] | |
| : ['pending_analysis', 'awaiting_approval', 'in_progress']; | |
| const queue = await db.query.youtubeVideos.findMany({ | |
| where: inArray(youtubeVideos.scanStatus, statuses as any), | |
| with: { | |
| channel: true | |
| }, | |
| orderBy: (youtubeVideos, { desc }) => [desc(youtubeVideos.createdAt)] | |
| }); | |
| return queue; | |
| } | |
| /** | |
| * Triggers AI analysis for a video that is currently in 'pending_analysis'. | |
| */ | |
| export async function triggerAIAnalysisAction(videoId: string) { | |
| await ensureAdmin(); | |
| const video = await db.query.youtubeVideos.findFirst({ | |
| where: eq(youtubeVideos.id, videoId) | |
| }); | |
| if (!video) throw new Error("Video not found"); | |
| // Send to Inngest | |
| await inngest.send({ | |
| name: 'youtube/video.detect-objects', | |
| data: { | |
| videoId: video.id, | |
| videoUrl: video.url || `https://www.youtube.com/watch?v=${video.videoId}`, | |
| }, | |
| }); | |
| // Note: status is updated to 'in_progress' inside the Inngest function. | |
| revalidatePath(`/admin/analysis/${videoId}`); | |
| return { success: true }; | |
| } | |
| /** | |
| * Approves a single detected object (making it visible to the user). | |
| */ | |
| export async function approveDetectionAction(detectionId: string) { | |
| const session = await auth.api.getSession({ | |
| headers: await headers() | |
| }); | |
| await ensureAdmin(); | |
| const detection = await db.query.detectedObjects.findFirst({ | |
| where: eq(detectedObjects.id, detectionId), | |
| with: { | |
| video: { | |
| with: { | |
| channel: true | |
| } | |
| } | |
| } | |
| }); | |
| if (!detection) throw new Error("Detection not found"); | |
| const isDemoRecord = detection.video?.channel?.creatorId?.startsWith('demo'); | |
| await db.transaction(async (tx) => { | |
| const updateData: any = { moderationStatus: 'APPROVED' }; | |
| // If it's a demo record, we auto-approve at the creator level too | |
| // so it appears in the public vault immediately. | |
| if (isDemoRecord) { | |
| updateData.status = 'approved'; | |
| } | |
| await tx.update(detectedObjects) | |
| .set(updateData) | |
| .where(eq(detectedObjects.id, detectionId)); | |
| await tx.insert(adminModeration).values({ | |
| detectionId, | |
| adminId: session!.user.id, | |
| action: 'approved', | |
| originalValues: { | |
| moderationStatus: detection.moderationStatus, | |
| status: detection.status | |
| }, | |
| correctedValues: updateData, | |
| trainAiFlag: true | |
| }); | |
| }); | |
| revalidatePath('/admin/analysis'); | |
| return { success: true }; | |
| } | |
| /** | |
| * Rejects a single detected object (hiding it from the user). | |
| */ | |
| export async function rejectDetectionAction(detectionId: string) { | |
| const session = await auth.api.getSession({ | |
| headers: await headers() | |
| }); | |
| await ensureAdmin(); | |
| const detection = await db.query.detectedObjects.findFirst({ | |
| where: eq(detectedObjects.id, detectionId) | |
| }); | |
| if (!detection) throw new Error("Detection not found"); | |
| await db.transaction(async (tx) => { | |
| await tx.update(detectedObjects) | |
| .set({ moderationStatus: 'REJECTED' }) | |
| .where(eq(detectedObjects.id, detectionId)); | |
| await tx.insert(adminModeration).values({ | |
| detectionId, | |
| adminId: session!.user.id, | |
| action: 'rejected', | |
| originalValues: { moderationStatus: detection.moderationStatus }, | |
| correctedValues: { moderationStatus: 'REJECTED' }, | |
| trainAiFlag: true | |
| }); | |
| }); | |
| revalidatePath('/admin/analysis'); | |
| return { success: true }; | |
| } | |
| /** | |
| * Marks the entire video analysis as complete and removes it from the queue. | |
| */ | |
| export async function completeVideoReviewAction(videoId: string) { | |
| await ensureAdmin(); | |
| await db.update(youtubeVideos) | |
| .set({ scanStatus: 'completed' }) | |
| .where(eq(youtubeVideos.id, videoId)); | |
| // Optional: Trigger notifications here | |
| // notifyUser(videoId, 'analysis_ready'); | |
| revalidatePath('/admin/analysis-queue'); | |
| revalidatePath(`/admin/analysis/${videoId}`); | |
| return { success: true }; | |
| } | |
| /** | |
| * Manually adds a product to a video from the admin interface. | |
| */ | |
| export async function addManualProductAction(videoId: string, data: { | |
| objectName: string; | |
| category: any; | |
| price?: number; | |
| affiliateUrl?: string; | |
| imageUrl?: string; | |
| }) { | |
| await ensureAdmin(); | |
| // Check if it's a demo record | |
| const videoResult = await db.query.youtubeVideos.findFirst({ | |
| where: eq(youtubeVideos.id, videoId), | |
| with: { | |
| channel: true | |
| } | |
| }); | |
| const isDemoRecord = videoResult?.channel?.creatorId?.startsWith('demo'); | |
| // 1. Create Detection (AUTO-APPROVED if added by admin) | |
| const [detection] = await db.insert(detectedObjects).values({ | |
| videoId, | |
| objectName: data.objectName, | |
| category: data.category, | |
| confidenceScore: 1.0, | |
| frameTimestamp: 0, | |
| status: isDemoRecord ? 'approved' : 'pending_review', | |
| moderationStatus: 'APPROVED', | |
| thumbnailUrl: data.imageUrl || '/placeholder-product.png', | |
| }).returning(); | |
| // 2. Create Marketplace Match if URL provided | |
| if (data.affiliateUrl) { | |
| // Simple productId extraction fallback | |
| const productId = data.affiliateUrl.match(/\/dp\/([A-Z0-9]{10})/) | |
| ? data.affiliateUrl.match(/\/dp\/([A-Z0-9]{10})/)?.[1] | |
| : `manual-${crypto.randomUUID().slice(0, 8)}`; | |
| await db.insert(marketplaceMatches).values({ | |
| objectId: detection.id, | |
| marketplace: 'amazon', | |
| productId: productId || 'unknown', | |
| productName: data.objectName, | |
| price: data.price || 0, | |
| availabilityStatus: 'IN_STOCK', | |
| affiliateUrl: data.affiliateUrl, | |
| imageUrl: data.imageUrl, | |
| }); | |
| } | |
| revalidatePath(`/admin/analysis/${videoId}`); | |
| return { success: true }; | |
| } | |
| /** | |
| * Fetches products from other vaults for the same videoId. | |
| */ | |
| export async function getCrossVaultSuggestions(videoId: string, excludeCreatorId?: string) { | |
| await ensureAdmin(); | |
| // Find other instances of this videoId | |
| const otherVideos = await db.query.youtubeVideos.findMany({ | |
| where: eq(youtubeVideos.videoId, videoId), | |
| }); | |
| const otherVideoIds = otherVideos.map(v => v.id); | |
| if (otherVideoIds.length === 0) return []; | |
| // Fetch unique products from these videos | |
| const products = await db.query.detectedObjects.findMany({ | |
| where: and( | |
| inArray(detectedObjects.videoId, otherVideoIds), | |
| eq(detectedObjects.moderationStatus, 'APPROVED') | |
| ), | |
| with: { | |
| marketplaceMatches: true | |
| } | |
| }); | |
| // Deduplicate by product name/link | |
| return products; | |
| } | |
| /** | |
| * Clones a detection (and its first marketplace match) to the specified video. | |
| */ | |
| export async function cloneDetectionAction(videoId: string, sourceDetectionId: string) { | |
| await ensureAdmin(); | |
| const source = await db.query.detectedObjects.findFirst({ | |
| where: eq(detectedObjects.id, sourceDetectionId), | |
| with: { | |
| marketplaceMatches: true | |
| } | |
| }); | |
| if (!source) throw new Error("Source detection not found"); | |
| // 1. Create new detection | |
| const [newDetection] = await db.insert(detectedObjects).values({ | |
| videoId, | |
| objectName: source.objectName, | |
| category: source.category, | |
| confidenceScore: 1.0, | |
| frameTimestamp: source.frameTimestamp, | |
| thumbnailUrl: source.thumbnailUrl, | |
| status: 'pending_review', | |
| moderationStatus: 'APPROVED', | |
| }).returning(); | |
| // 2. Clone first marketplace match | |
| const match = source.marketplaceMatches[0]; | |
| if (match) { | |
| await db.insert(marketplaceMatches).values({ | |
| objectId: newDetection.id, | |
| marketplace: match.marketplace, | |
| productId: match.productId, | |
| productName: match.productName, | |
| price: match.price, | |
| availabilityStatus: match.availabilityStatus, | |
| affiliateUrl: match.affiliateUrl, | |
| imageUrl: match.imageUrl, | |
| }); | |
| } | |
| revalidatePath(`/admin/analysis/${videoId}`); | |
| return { success: true }; | |
| } | |
| /** | |
| * Fetches the moderation history for a specific video. | |
| */ | |
| export async function getAnalysisHistory(videoInternalId: string) { | |
| await ensureAdmin(); | |
| // Get all detections for this video | |
| const detections = await db.query.detectedObjects.findMany({ | |
| where: eq(detectedObjects.videoId, videoInternalId), | |
| }); | |
| if (detections.length === 0) return []; | |
| // Get audit logs for these detections | |
| const logs = await db.query.adminModeration.findMany({ | |
| where: inArray(adminModeration.detectionId, detections.map(d => d.id)), | |
| with: { | |
| admin: true, | |
| detection: true, | |
| }, | |
| orderBy: [desc(adminModeration.createdAt)], | |
| }); | |
| return logs; | |
| } | |