store reference assets on supabase

This commit is contained in:
wass08
2026-02-26 09:56:50 +09:00
parent 1117e0cb5f
commit b167702bca
10 changed files with 1438 additions and 15 deletions
@@ -0,0 +1,164 @@
'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 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<UploadAssetResult> {
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.from('project_assets') as any).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',
}
}
}
/**
* 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<DeleteAssetResult> {
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' }
}
// Look up the asset row by url + projectId
const { data: asset, error: fetchError } = await (supabase.from('project_assets') as any)
.select('id, storage_key')
.eq('project_id', projectId)
.eq('url', url)
.maybeSingle()
if (fetchError) {
return { success: false, error: fetchError.message }
}
if (!asset) {
// Nothing to delete — treat as success
return { success: true }
}
// Remove from storage
await supabase.storage.from(BUCKET).remove([(asset as any).storage_key])
// Delete row
await (supabase.from('project_assets') as any)
.delete()
.eq('id', (asset as any).id)
return { success: true }
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to delete asset',
}
}
}
@@ -911,7 +911,17 @@ export async function deleteProject(projectId: string): Promise<ActionResult> {
}
}
// Delete the project (cascade will delete related records)
// Delete project asset files from storage before deleting the project
const { data: assets } = await (supabase.from('project_assets') as any)
.select('storage_key')
.eq('project_id', projectId)
if (assets && assets.length > 0) {
const storageKeys = (assets as { storage_key: string }[]).map((a) => a.storage_key)
await supabase.storage.from('project-assets').remove(storageKeys)
}
// Delete the project (cascade will delete related records including project_assets rows)
const { error } = await supabase.from('projects').delete().eq('id', projectId)
if (error) {