'use server' import { createServerSupabaseClient } from '../database/server' import { getSession } from '../auth/server' import { createId } from '../utils/id-generator' const BUCKET = 'project-assets' export type AssetType = 'scan' | 'guide' export type UploadAssetResult = | { success: true; url: string } | { success: false; error: string } export type CreateUploadUrlResult = | { success: true; signedUrl: string; storageKey: string; assetId: string } | { success: false; error: string } export type ConfirmUploadResult = | { success: true; url: string } | { success: false; error: string } export type DeleteAssetResult = | { success: true } | { success: false; error: string } /** * Upload a scan or guide file to Supabase Storage and record it in project_assets. * Returns the public HTTPS URL that can be stored directly on the scene node. */ export async function uploadProjectAsset( projectId: string, file: File, type: AssetType, ): Promise { try { const session = await getSession() if (!session?.user?.id) { return { success: false, error: 'Not authenticated' } } const supabase = await createServerSupabaseClient() // Verify the user owns this project const { data: project, error: projectError } = await supabase .from('projects') .select('owner_id') .eq('id', projectId) .single() if (projectError || !project) { return { success: false, error: 'Project not found' } } if ((project as any).owner_id !== session.user.id) { return { success: false, error: 'Not authorized to upload to this project' } } // Derive extension from file name const ext = file.name.includes('.') ? file.name.split('.').pop()! : '' const assetId = createId('asset') const storageKey = ext ? `${projectId}/${assetId}.${ext}` : `${projectId}/${assetId}` const arrayBuffer = await file.arrayBuffer() const bytes = new Uint8Array(arrayBuffer) const { data: uploadData, error: uploadError } = await supabase.storage .from(BUCKET) .upload(storageKey, bytes, { contentType: file.type || 'application/octet-stream', upsert: false, }) if (uploadError) { return { success: false, error: `Upload failed: ${uploadError.message}` } } const { data: urlData } = supabase.storage .from(BUCKET) .getPublicUrl(uploadData.path) const url = urlData.publicUrl // Record in project_assets table const { error: insertError } = await (supabase as any).from('project_assets').insert({ id: assetId, project_id: projectId, storage_key: storageKey, url, type, original_name: file.name, mime_type: file.type || null, }) if (insertError) { // Best-effort cleanup: remove the uploaded file await supabase.storage.from(BUCKET).remove([storageKey]) return { success: false, error: `Failed to record asset: ${insertError.message}` } } return { success: true, url } } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Failed to upload asset', } } } /** * Create a signed upload URL so the client can upload directly to Supabase Storage. * Bypasses Next.js body-size limits — supports files up to the bucket limit (500 MB). */ export async function createAssetUploadUrl( projectId: string, fileName: string, contentType: string, type: AssetType, ): Promise { try { const session = await getSession() if (!session?.user?.id) { return { success: false, error: 'Not authenticated' } } const supabase = await createServerSupabaseClient() const { data: project, error: projectError } = await supabase .from('projects') .select('owner_id') .eq('id', projectId) .single() if (projectError || !project) { return { success: false, error: 'Project not found' } } if ((project as any).owner_id !== session.user.id) { return { success: false, error: 'Not authorized to upload to this project' } } const ext = fileName.includes('.') ? fileName.split('.').pop()! : '' const assetId = createId('asset') const storageKey = ext ? `${projectId}/${assetId}.${ext}` : `${projectId}/${assetId}` const { data, error } = await supabase.storage .from(BUCKET) .createSignedUploadUrl(storageKey) if (error || !data) { return { success: false, error: `Failed to create upload URL: ${error?.message}` } } return { success: true, signedUrl: data.signedUrl, storageKey, assetId } } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Failed to create upload URL', } } } /** * Record a successfully uploaded asset in the project_assets table. * Called after the client uploads the file directly to Supabase Storage. */ export async function confirmAssetUpload( projectId: string, assetId: string, storageKey: string, originalName: string, mimeType: string | null, type: AssetType, ): Promise { try { const session = await getSession() if (!session?.user?.id) { return { success: false, error: 'Not authenticated' } } const supabase = await createServerSupabaseClient() const { data: project, error: projectError } = await supabase .from('projects') .select('owner_id') .eq('id', projectId) .single() if (projectError || !project) { return { success: false, error: 'Project not found' } } if ((project as any).owner_id !== session.user.id) { return { success: false, error: 'Not authorized' } } const { data: urlData } = supabase.storage .from(BUCKET) .getPublicUrl(storageKey) const url = urlData.publicUrl const { error: insertError } = await (supabase as any).from('project_assets').insert({ id: assetId, project_id: projectId, storage_key: storageKey, url, type, original_name: originalName, mime_type: mimeType, }) if (insertError) { await supabase.storage.from(BUCKET).remove([storageKey]) return { success: false, error: `Failed to record asset: ${insertError.message}` } } return { success: true, url } } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Failed to confirm upload', } } } /** * Delete a project asset by its public URL. * Removes both the storage file and the project_assets row. */ export async function deleteProjectAssetByUrl( projectId: string, url: string, ): Promise { try { const session = await getSession() if (!session?.user?.id) { return { success: false, error: 'Not authenticated' } } const supabase = await createServerSupabaseClient() // Verify ownership const { data: project, error: projectError } = await supabase .from('projects') .select('owner_id') .eq('id', projectId) .single() if (projectError || !project) { return { success: false, error: 'Project not found' } } if ((project as any).owner_id !== session.user.id) { return { success: false, error: 'Not authorized' } } // Derive storage_key from the public URL // URL format: https://.supabase.co/storage/v1/object/public/project-assets/ const storageKeyFromUrl = url.split(`/${BUCKET}/`)[1]?.split('?')[0] if (!storageKeyFromUrl) { return { success: false, error: 'Could not derive storage key from URL' } } // Delete from storage directly — remove() is a no-op if the file doesn't exist const { error: storageError } = await supabase.storage.from(BUCKET).remove([storageKeyFromUrl]) if (storageError) { return { success: false, error: `Storage delete failed: ${storageError.message}` } } // Delete DB row by storage_key scoped to this project const { error: dbError } = await (supabase as any).from('project_assets') .delete() .eq('project_id', projectId) .eq('storage_key', storageKeyFromUrl) if (dbError) { return { success: false, error: `DB delete failed: ${dbError.message}` } } return { success: true } } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Failed to delete asset', } } }