import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; import { stat, writeFile } from 'fs/promises'; import path from 'path'; import { createClient, type SupabaseClient } from '@supabase/supabase-js'; import type { PublicReviewCampaign, PublicSubmitResult, ReviewRewardType, ReviewSubmissionStatus, } from '@/lib/reviews/types'; /** * Review store for the standalone prototype. * * Preferred mode: Supabase Storage * review-videos/videos/. * review-videos/submissions/.json * * Local file fallback remains available when Supabase env vars are absent. */ export type LocalSubmission = { submissionId: string; interviewId: string; campaignSlug: string; status: ReviewSubmissionStatus; decision: 'pass' | 'fail_and_retry' | null; feedback: string; reward: { type: ReviewRewardType; value: string } | null; reasons: string[]; consentAccepted: boolean; socialHandle: string | null; deviceKey: string | null; tableId: string | null; durationSeconds: number; videoSize: number; videoMime: string; videoFileName: string; storagePath: string; storageBackend: 'local' | 'supabase'; createdAt: number; updatedAt: string; }; export type AdminReviewSubmission = LocalSubmission & { restaurantName: string; previewUrl: string; createdAtIso: string; }; type StoreState = { campaigns: Map; submissions: Map; }; type LocalVideoResult = { kind: 'local'; submission: LocalSubmission; filePath: string; size: number; contentType: string; }; type SupabaseVideoResult = { kind: 'supabase'; submission: LocalSubmission; storagePath: string; size: number; contentType: string; }; export type SubmissionVideoResult = LocalVideoResult | SupabaseVideoResult; const LOCAL_DATA_DIR = path.join(process.cwd(), '.local-review-data'); const UPLOADS_DIR = path.join(LOCAL_DATA_DIR, 'uploads'); const SUBMISSIONS_FILE = path.join(LOCAL_DATA_DIR, 'submissions.json'); const ENV_FILE = path.join(process.cwd(), '.env.local'); const DEFAULT_BUCKET = 'review-videos'; const SAGE_AND_STONE: PublicReviewCampaign = { id: 'sage-and-stone-cafe', slug: 'sageandstone', restaurantName: 'Sage & Stone Cafe', status: 'active', rulesConfig: { minDurationSeconds: 8, maxDurationSeconds: 90, minWordCount: 8, requireRestaurantMention: false, blockedTerms: [], }, settings: { dailyRewardLimitPerDevice: 1 }, mode: 'guided_clips', prompts: [ { step: 1, title: 'Close-up pan of the food.', tip: 'Move slowly across the texture, sauce, steam, and toppings.', mediaType: 'video', camera: 'rear', maxSeconds: 10, optional: false, }, { step: 2, title: 'Wide shot of the table.', tip: 'Show the full plate, drink, table setup, and a little cafe vibe.', mediaType: 'video', camera: 'rear', maxSeconds: 10, optional: false, }, { step: 3, title: 'Action detail of the food.', tip: 'Cut, scoop, pour, lift, or show the best bite without focusing on your face.', mediaType: 'video', camera: 'rear', maxSeconds: 10, optional: false, }, { step: 4, title: 'What did you order?', tip: 'Voice only. Say the dish name and describe what is on the plate.', mediaType: 'audio', camera: 'front', maxSeconds: 7, optional: false, }, { step: 5, title: 'What did you like about it?', tip: 'Voice only. Mention the flavor, texture, portion, or what stood out.', mediaType: 'audio', camera: 'front', maxSeconds: 10, optional: false, }, { step: 6, title: 'Optional: quick reaction shot.', tip: 'Take one bite or sip and react naturally. Skip if you would rather keep it food-only.', mediaType: 'video', camera: 'front', maxSeconds: 4, optional: true, }, ], rewardType: 'static_code', rewardValue: null, theme: 'cafe-cream', }; let supabaseAdmin: SupabaseClient | null = null; let bucketReady = false; let envFileCache: Record | null = null; function readEnvFile() { if (envFileCache) return envFileCache; envFileCache = {}; if (!existsSync(ENV_FILE)) return envFileCache; try { const raw = readFileSync(ENV_FILE, 'utf8'); for (const line of raw.split(/\r?\n/)) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; const splitAt = trimmed.indexOf('='); if (splitAt <= 0) continue; const key = trimmed.slice(0, splitAt).trim(); const value = trimmed.slice(splitAt + 1).trim(); envFileCache[key] = value.replace(/^['"]|['"]$/g, ''); } } catch { envFileCache = {}; } return envFileCache; } function serverEnv(name: string) { return process.env[name] || readEnvFile()[name] || ''; } function hasSupabaseConfig() { return Boolean(serverEnv('SUPABASE_URL') && serverEnv('SUPABASE_SERVICE_ROLE_KEY')); } function getBucketName() { return serverEnv('SUPABASE_REVIEW_VIDEO_BUCKET') || DEFAULT_BUCKET; } function getSupabaseAdmin() { if (!hasSupabaseConfig()) return null; if (!supabaseAdmin) { supabaseAdmin = createClient(serverEnv('SUPABASE_URL'), serverEnv('SUPABASE_SERVICE_ROLE_KEY'), { auth: { autoRefreshToken: false, persistSession: false, }, global: { fetch: (input, init) => fetch(input, { ...init, cache: 'no-store' }), }, }); } return supabaseAdmin; } function supabaseObjectUrl(storagePath: string) { const baseUrl = serverEnv('SUPABASE_URL').replace(/\/$/, ''); const encodedBucket = encodeURIComponent(getBucketName()); const encodedPath = storagePath.split('/').map(encodeURIComponent).join('/'); return `${baseUrl}/storage/v1/object/${encodedBucket}/${encodedPath}`; } async function ensureSupabaseBucket(client: SupabaseClient) { if (bucketReady) return; const bucket = getBucketName(); const { data } = await client.storage.getBucket(bucket); if (!data) { const { error } = await client.storage.createBucket(bucket, { public: false, }); if (error && !error.message.toLowerCase().includes('already exists')) { throw error; } } bucketReady = true; } function ensureLocalDirs() { mkdirSync(UPLOADS_DIR, { recursive: true }); } function readPersistedSubmissions() { if (!existsSync(SUBMISSIONS_FILE)) return []; try { const raw = readFileSync(SUBMISSIONS_FILE, 'utf8'); const parsed = JSON.parse(raw) as unknown; if (!Array.isArray(parsed)) return []; return parsed.filter(isLocalSubmission); } catch { return []; } } function isLocalSubmission(value: unknown): value is LocalSubmission { if (!value || typeof value !== 'object') return false; const maybe = value as Partial; return ( typeof maybe.submissionId === 'string' && typeof maybe.interviewId === 'string' && typeof maybe.campaignSlug === 'string' && typeof maybe.status === 'string' && typeof maybe.storagePath === 'string' && typeof maybe.createdAt === 'number' ); } function normalizeSubmission(value: LocalSubmission): LocalSubmission { return { ...value, storageBackend: value.storageBackend ?? 'local', }; } function createState(): StoreState { const campaigns = new Map(); campaigns.set(SAGE_AND_STONE.slug, SAGE_AND_STONE); const submissions = new Map(); for (const submission of readPersistedSubmissions()) { const normalized = normalizeSubmission(submission); submissions.set(normalized.submissionId, normalized); } return { campaigns, submissions }; } const globalForStore = globalThis as unknown as { __matchaReviewStore?: StoreState; }; const state = globalForStore.__matchaReviewStore ?? createState(); state.campaigns.set(SAGE_AND_STONE.slug, SAGE_AND_STONE); for (const [id, submission] of state.submissions.entries()) { if (!isLocalSubmission(submission)) { state.submissions.delete(id); } else { state.submissions.set(id, normalizeSubmission(submission)); } } if (process.env.NODE_ENV !== 'production') { globalForStore.__matchaReviewStore = state; } function persistLocalSubmissions() { ensureLocalDirs(); const submissions = [...state.submissions.values()] .filter((submission) => submission.storageBackend === 'local') .sort((a, b) => a.createdAt - b.createdAt); writeFileSync(SUBMISSIONS_FILE, `${JSON.stringify(submissions, null, 2)}\n`, 'utf8'); } export function getCampaignBySlug(slug: string): PublicReviewCampaign | null { return state.campaigns.get(slug) ?? null; } function newId(prefix: string) { const rand = Math.random().toString(36).slice(2, 10); return `${prefix}_${Date.now().toString(36)}_${rand}`; } function rewardCode() { return `MATCHA-${Math.random().toString(36).slice(2, 6).toUpperCase()}`; } function resolveVideoExt(fileName: string, mime: string) { const lowerName = fileName.toLowerCase(); const lowerMime = mime.toLowerCase(); if (lowerName.endsWith('.mp4') || lowerMime.includes('mp4')) return 'mp4'; if (lowerName.endsWith('.mov') || lowerMime.includes('quicktime')) return 'mov'; if (lowerName.endsWith('.webm') || lowerMime.includes('webm')) return 'webm'; return 'webm'; } function buildPreviewUrl(submissionId: string) { return `/api/public/reviews/video/${encodeURIComponent(submissionId)}`; } function localStoragePathFor(submissionId: string, ext: string) { return `uploads/${submissionId}.${ext}`; } function supabaseVideoPathFor(submissionId: string, ext: string) { return `videos/${submissionId}.${ext}`; } function metadataPathFor(submissionId: string) { return `submissions/${submissionId}.json`; } function absoluteStoragePath(storagePath: string) { const absolute = path.resolve(LOCAL_DATA_DIR, storagePath); const localRoot = path.resolve(LOCAL_DATA_DIR); if (absolute !== localRoot && !absolute.startsWith(`${localRoot}${path.sep}`)) { return null; } return absolute; } async function textFromDownloadedData(data: unknown) { if (typeof data === 'string') return data; if (data && typeof data === 'object') { const maybeBlob = data as { text?: () => Promise; arrayBuffer?: () => Promise; }; if (typeof maybeBlob.text === 'function') { return maybeBlob.text(); } if (typeof maybeBlob.arrayBuffer === 'function') { return Buffer.from(await maybeBlob.arrayBuffer()).toString('utf8'); } } if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8'); if (Buffer.isBuffer(data)) return data.toString('utf8'); throw new Error('Unsupported downloaded data type'); } async function persistSupabaseSubmission(client: SupabaseClient, submission: LocalSubmission) { await ensureSupabaseBucket(client); const body = JSON.stringify(submission, null, 2); const { error } = await client.storage .from(getBucketName()) .upload(metadataPathFor(submission.submissionId), body, { contentType: 'application/json', upsert: true, }); if (error) throw error; } async function loadSupabaseSubmission( client: SupabaseClient, submissionId: string, ): Promise { await ensureSupabaseBucket(client); const { data, error } = await client.storage .from(getBucketName()) .download(metadataPathFor(submissionId)); if (error || !data) { if (error) { console.warn('[matcha-moments/review-store] failed to download Supabase metadata', { submissionId, message: error.message, }); } return null; } try { const text = await textFromDownloadedData(data); const parsed = JSON.parse(text) as unknown; if (!isLocalSubmission(parsed)) { console.warn('[matcha-moments/review-store] invalid Supabase metadata', { submissionId, }); return null; } return normalizeSubmission(parsed); } catch (err) { console.warn('[matcha-moments/review-store] failed to parse Supabase metadata', { submissionId, message: err instanceof Error ? err.message : 'Unknown error', }); return null; } } async function listSupabaseSubmissions(client: SupabaseClient) { await ensureSupabaseBucket(client); const { data, error } = await client.storage .from(getBucketName()) .list('submissions', { limit: 200, sortBy: { column: 'created_at', order: 'desc' }, }); if (error || !data) { if (error) { console.warn('[matcha-moments/review-store] failed to list Supabase submissions', error); } return []; } const submissions = await Promise.all( data .filter((item) => item.name.endsWith('.json')) .map((item) => loadSupabaseSubmission(client, item.name.replace(/\.json$/, ''))), ); return submissions.filter((submission): submission is LocalSubmission => Boolean(submission)); } function mergeSubmissions(...groups: LocalSubmission[][]) { const merged = new Map(); for (const group of groups) { for (const submission of group) { merged.set(submission.submissionId, normalizeSubmission(submission)); } } return [...merged.values()]; } async function persistSubmission(submission: LocalSubmission) { state.submissions.set(submission.submissionId, submission); if (submission.storageBackend === 'supabase') { const client = getSupabaseAdmin(); if (!client) throw new Error('Supabase is not configured'); await persistSupabaseSubmission(client, submission); return; } persistLocalSubmissions(); } function advanceSubmission(submission: LocalSubmission) { if ( submission.status === 'reward_issued' || submission.status === 'processing_failed' || submission.status === 'fail_and_retry' ) { return false; } const campaign = getCampaignBySlug(submission.campaignSlug); if (!campaign) return false; const elapsed = Date.now() - submission.createdAt; let changed = false; if (submission.status === 'opened' && elapsed >= 1000) { submission.status = 'processing_interview'; changed = true; } if (submission.status === 'processing_interview' && elapsed >= 2500) { submission.status = 'evaluating_rules'; changed = true; } if (submission.status === 'evaluating_rules' && elapsed >= 4000) { submission.status = 'reward_issued'; submission.decision = 'pass'; submission.feedback = 'Office prototype approved. Show the matcha code to staff.'; submission.reward = { type: campaign.rewardType, value: campaign.rewardValue ?? rewardCode(), }; changed = true; } if (changed) { submission.updatedAt = new Date().toISOString(); } return changed; } export type CreateSubmissionInput = { slug: string; consentAccepted: boolean; socialHandle?: string | null; deviceKey?: string | null; tableId?: string | null; durationSeconds: number; video: File; }; export async function createSubmission(input: CreateSubmissionInput): Promise< | { ok: true; submission: LocalSubmission } | { ok: false; status: number; error: string } > { if (!input.slug) { return { ok: false, status: 400, error: 'Missing campaign slug' }; } if (!input.consentAccepted) { return { ok: false, status: 400, error: 'Consent is required' }; } if (!(input.video instanceof File) || input.video.size <= 0) { return { ok: false, status: 400, error: 'A video file is required' }; } if (!input.video.type.startsWith('video/')) { return { ok: false, status: 400, error: 'Only video uploads are supported' }; } const campaign = getCampaignBySlug(input.slug); if (!campaign) { return { ok: false, status: 404, error: 'Campaign not found' }; } const submissionId = newId('sub'); const interviewId = newId('iv'); const videoMime = input.video.type || 'video/webm'; const videoFileName = input.video.name || 'matcha-moments.webm'; const ext = resolveVideoExt(videoFileName, videoMime); const bytes = Buffer.from(await input.video.arrayBuffer()); const storageBackend = hasSupabaseConfig() ? 'supabase' : 'local'; const storagePath = storageBackend === 'supabase' ? supabaseVideoPathFor(submissionId, ext) : localStoragePathFor(submissionId, ext); if (storageBackend === 'supabase') { const client = getSupabaseAdmin(); if (!client) { return { ok: false, status: 500, error: 'Supabase is not configured' }; } await ensureSupabaseBucket(client); const { error } = await client.storage.from(getBucketName()).upload(storagePath, bytes, { contentType: videoMime, upsert: false, }); if (error) { return { ok: false, status: 500, error: `Video upload failed: ${error.message}` }; } } else { const absolutePath = absoluteStoragePath(storagePath); if (!absolutePath) { return { ok: false, status: 500, error: 'Invalid local storage path' }; } ensureLocalDirs(); await writeFile(absolutePath, bytes); } const now = new Date().toISOString(); const submission: LocalSubmission = { submissionId, interviewId, campaignSlug: input.slug, status: 'opened', decision: null, feedback: 'Your review is being processed.', reward: null, reasons: [], consentAccepted: input.consentAccepted, socialHandle: input.socialHandle?.trim() || null, deviceKey: input.deviceKey?.trim() || null, tableId: input.tableId?.trim() || null, durationSeconds: Math.max(0, Math.round(input.durationSeconds)), videoSize: input.video.size, videoMime, videoFileName, storagePath, storageBackend, createdAt: Date.now(), updatedAt: now, }; await persistSubmission(submission); return { ok: true, submission }; } export async function getSubmission( submissionId: string, slug: string, ): Promise< | { ok: true; submission: LocalSubmission } | { ok: false; status: number; error: string } > { const campaign = getCampaignBySlug(slug); if (!campaign) return { ok: false, status: 404, error: 'Campaign not found' }; const client = getSupabaseAdmin(); const submission = (client ? await loadSupabaseSubmission(client, submissionId) : null) ?? state.submissions.get(submissionId); if (!submission || submission.campaignSlug !== slug) { return { ok: false, status: 404, error: 'Submission not found' }; } if (advanceSubmission(submission)) { await persistSubmission(submission); } else { state.submissions.set(submission.submissionId, submission); } return { ok: true, submission }; } export async function listAdminReviewSubmissions(): Promise { const client = getSupabaseAdmin(); const supabaseSubmissions = client ? await listSupabaseSubmissions(client) : []; const sourceSubmissions = mergeSubmissions( [...state.submissions.values()], supabaseSubmissions, ); const submissions: LocalSubmission[] = []; for (const submission of sourceSubmissions) { if (advanceSubmission(submission)) { await persistSubmission(submission); } else { state.submissions.set(submission.submissionId, submission); } submissions.push(submission); } return submissions .sort((a, b) => b.createdAt - a.createdAt) .map((submission) => ({ ...submission, restaurantName: getCampaignBySlug(submission.campaignSlug)?.restaurantName ?? submission.campaignSlug, previewUrl: buildPreviewUrl(submission.submissionId), createdAtIso: new Date(submission.createdAt).toISOString(), })); } export async function getSubmissionVideo( submissionId: string, ): Promise { const client = getSupabaseAdmin(); const submission = (client ? await loadSupabaseSubmission(client, submissionId) : null) ?? state.submissions.get(submissionId); if (!submission) return null; if (submission.storageBackend === 'supabase') { if (!client) return null; return { kind: 'supabase', submission, storagePath: submission.storagePath, size: submission.videoSize, contentType: submission.videoMime || 'video/webm', }; } const filePath = absoluteStoragePath(submission.storagePath); if (!filePath) return null; try { const fileStat = await stat(filePath); if (!fileStat.isFile()) return null; return { kind: 'local', submission, filePath, size: fileStat.size, contentType: submission.videoMime || 'video/webm', }; } catch { return null; } } export async function fetchSupabaseVideoObject( video: Extract, rangeHeader: string | null, ) { const serviceRoleKey = serverEnv('SUPABASE_SERVICE_ROLE_KEY'); if (!serviceRoleKey) return null; const headers: HeadersInit = { apikey: serviceRoleKey, Authorization: `Bearer ${serviceRoleKey}`, }; if (rangeHeader) { headers.Range = rangeHeader; } const response = await fetch(supabaseObjectUrl(video.storagePath), { headers, cache: 'no-store', }); if (!response.ok || !response.body) return null; return response; } export function toPublicSubmitResult(submission: LocalSubmission): PublicSubmitResult { return { submissionId: submission.submissionId, interviewId: submission.interviewId, status: submission.status, decision: submission.decision, feedback: submission.feedback, reward: submission.reward, reasons: submission.reasons, previewUrl: buildPreviewUrl(submission.submissionId), updatedAt: submission.updatedAt, }; }