diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/references-dialog.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/references-dialog.tsx index 6d384fff..590a8db1 100644 --- a/apps/editor/components/ui/sidebar/panels/site-panel/references-dialog.tsx +++ b/apps/editor/components/ui/sidebar/panels/site-panel/references-dialog.tsx @@ -5,12 +5,13 @@ import { type LevelNode, type ScanNode, ScanNode as ScanNodeSchema, - saveAsset, useScene, } from '@pascal-app/core' import { Box, Image, Pencil, Plus, Trash2 } from 'lucide-react' -import { useCallback, useRef } from 'react' +import { useCallback, useRef, useState } from 'react' import useEditor from '@/store/use-editor' +import { deleteProjectAssetByUrl, uploadProjectAsset } from '@/features/community/lib/assets/actions' +import { useProjectStore } from '@/features/community/lib/projects/store' import { Dialog, DialogContent, @@ -23,6 +24,8 @@ import { PopoverTrigger, } from '@/components/ui/primitives/popover' +const MAX_FILE_SIZE = 100 * 1024 * 1024 // 100MB — matches server action bodySizeLimit + interface ReferencesDialogProps { levelId: string open: boolean @@ -34,6 +37,9 @@ export function ReferencesDialog({ levelId, open, onOpenChange }: ReferencesDial const createNode = useScene((s) => s.createNode) const deleteNode = useScene((s) => s.deleteNode) const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId) + const activeProject = useProjectStore((s) => s.activeProject) + const [uploadError, setUploadError] = useState(null) + const [uploading, setUploading] = useState(false) const scanInputRef = useRef(null) const guideInputRef = useRef(null) @@ -42,38 +48,80 @@ export function ReferencesDialog({ levelId, open, onOpenChange }: ReferencesDial async (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (!file) return - const url = await saveAsset(file) + e.target.value = '' + + const projectId = activeProject?.id + if (!projectId || projectId.startsWith('local_')) { + setUploadError('Save your project to the cloud first to add references.') + return + } + + if (file.size > MAX_FILE_SIZE) { + setUploadError(`File is too large (${(file.size / 1024 / 1024).toFixed(0)} MB). Maximum size is 100 MB.`) + return + } + + setUploadError(null) + setUploading(true) + const result = await uploadProjectAsset(projectId, file, 'scan') + setUploading(false) + + if (!result.success) { + setUploadError(result.error) + return + } + const node = ScanNodeSchema.parse({ - url, + url: result.url, name: file.name, parentId: levelId, }) createNode(node, levelId as AnyNodeId) - e.target.value = '' // Auto-select and close dialog setSelectedReferenceId(node.id) onOpenChange(false) }, - [levelId, createNode, setSelectedReferenceId, onOpenChange], + [levelId, createNode, setSelectedReferenceId, onOpenChange, activeProject], ) const handleAddGuide = useCallback( async (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (!file) return - const url = await saveAsset(file) + e.target.value = '' + + const projectId = activeProject?.id + if (!projectId || projectId.startsWith('local_')) { + setUploadError('Save your project to the cloud first to add references.') + return + } + + if (file.size > MAX_FILE_SIZE) { + setUploadError(`File is too large (${(file.size / 1024 / 1024).toFixed(0)} MB). Maximum size is 100 MB.`) + return + } + + setUploadError(null) + setUploading(true) + const result = await uploadProjectAsset(projectId, file, 'guide') + setUploading(false) + + if (!result.success) { + setUploadError(result.error) + return + } + const node = GuideNodeSchema.parse({ - url, + url: result.url, name: file.name, parentId: levelId, }) createNode(node, levelId as AnyNodeId) - e.target.value = '' // Auto-select and close dialog setSelectedReferenceId(node.id) onOpenChange(false) }, - [levelId, createNode, setSelectedReferenceId, onOpenChange], + [levelId, createNode, setSelectedReferenceId, onOpenChange, activeProject], ) const handleEdit = useCallback( @@ -86,9 +134,20 @@ export function ReferencesDialog({ levelId, open, onOpenChange }: ReferencesDial const handleDelete = useCallback( (nodeId: string) => { + const refNode = nodes[nodeId as AnyNodeId] as ScanNode | GuideNode | undefined deleteNode(nodeId as AnyNodeId) + // Fire-and-forget storage cleanup for Supabase-hosted assets + const projectId = activeProject?.id + if ( + projectId && + !projectId.startsWith('local_') && + refNode?.url && + refNode.url.startsWith('https://') + ) { + deleteProjectAssetByUrl(projectId, refNode.url).catch(console.error) + } }, - [deleteNode], + [deleteNode, nodes, activeProject], ) const level = nodes[levelId as AnyNodeId] as LevelNode | undefined @@ -145,6 +204,10 @@ export function ReferencesDialog({ levelId, open, onOpenChange }: ReferencesDial ))} + {uploadError && ( +

{uploadError}

+ )} +
- diff --git a/apps/editor/features/community/lib/assets/actions.ts b/apps/editor/features/community/lib/assets/actions.ts new file mode 100644 index 00000000..f39e9748 --- /dev/null +++ b/apps/editor/features/community/lib/assets/actions.ts @@ -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 { + 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 { + 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', + } + } +} diff --git a/apps/editor/features/community/lib/projects/actions.ts b/apps/editor/features/community/lib/projects/actions.ts index 1c3a1ded..0bc37057 100644 --- a/apps/editor/features/community/lib/projects/actions.ts +++ b/apps/editor/features/community/lib/projects/actions.ts @@ -911,7 +911,17 @@ export async function deleteProject(projectId: string): Promise { } } - // 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) { diff --git a/apps/editor/instrumentation.ts b/apps/editor/instrumentation.ts index a5d0ddca..81648c17 100644 --- a/apps/editor/instrumentation.ts +++ b/apps/editor/instrumentation.ts @@ -29,5 +29,13 @@ export async function register() { }) console.log('Created "project-thumbnails" storage bucket') } + + if (!bucketNames.has('project-assets')) { + await supabase.storage.createBucket('project-assets', { + public: true, + fileSizeLimit: 500 * 1024 * 1024, // 500MB for GLB/GLTF scans + }) + console.log('Created "project-assets" storage bucket') + } } } diff --git a/apps/editor/next.config.ts b/apps/editor/next.config.ts index bd08139b..7e9dfaaa 100644 --- a/apps/editor/next.config.ts +++ b/apps/editor/next.config.ts @@ -4,7 +4,7 @@ const nextConfig: NextConfig = { transpilePackages: ['three', '@pascal-app/viewer', '@pascal-app/core'], experimental: { serverActions: { - bodySizeLimit: '10mb', + bodySizeLimit: '100mb', }, }, images: { diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 745b8a7a..65773f88 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -10,6 +10,7 @@ export * from './feedback/feedback' // Project tables export * from './projects/addresses' +export * from './projects/assets' export * from './projects/likes' export * from './projects/models' export * from './projects/projects' diff --git a/packages/db/src/schema/projects/assets.ts b/packages/db/src/schema/projects/assets.ts new file mode 100644 index 00000000..ef2d7444 --- /dev/null +++ b/packages/db/src/schema/projects/assets.ts @@ -0,0 +1,25 @@ +import { relations } from 'drizzle-orm' +import { pgTable } from 'drizzle-orm/pg-core' +import { id, timestampsColumns } from '../../helpers' +import { projects } from './projects' + +export const projectAssets = pgTable('project_assets', (t) => ({ + id: id('asset'), + projectId: t.text('project_id').notNull().references(() => projects.id, { onDelete: 'cascade' }), + storageKey: t.text('storage_key').notNull(), + url: t.text('url').notNull(), + type: t.text('type').notNull(), // 'scan' | 'guide' + originalName: t.text('original_name'), + mimeType: t.text('mime_type'), + ...timestampsColumns, +})).enableRLS() + +export const projectAssetsRelations = relations(projectAssets, ({ one }) => ({ + project: one(projects, { + fields: [projectAssets.projectId], + references: [projects.id], + }), +})) + +export type ProjectAsset = typeof projectAssets.$inferSelect +export type NewProjectAsset = typeof projectAssets.$inferInsert diff --git a/supabase/migrations/20260226002554_big_pestilence.sql b/supabase/migrations/20260226002554_big_pestilence.sql new file mode 100644 index 00000000..3fdc542f --- /dev/null +++ b/supabase/migrations/20260226002554_big_pestilence.sql @@ -0,0 +1,14 @@ +CREATE TABLE "project_assets" ( + "id" text PRIMARY KEY NOT NULL, + "project_id" text NOT NULL, + "storage_key" text NOT NULL, + "url" text NOT NULL, + "type" text NOT NULL, + "original_name" text, + "mime_type" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "project_assets" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "project_assets" ADD CONSTRAINT "project_assets_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/supabase/migrations/meta/20260226002554_snapshot.json b/supabase/migrations/meta/20260226002554_snapshot.json new file mode 100644 index 00000000..16b6250f --- /dev/null +++ b/supabase/migrations/meta/20260226002554_snapshot.json @@ -0,0 +1,1128 @@ +{ + "id": "0d2d0fe5-900e-410c-aa0b-e44aa124e5de", + "prevId": "709551b6-3bd6-4cc4-bf0f-683ccc08b2b9", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.auth_accounts": { + "name": "auth_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "auth_accounts_user_id_auth_users_id_fk": { + "name": "auth_accounts_user_id_auth_users_id_fk", + "tableFrom": "auth_accounts", + "tableTo": "auth_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.auth_jwks": { + "name": "auth_jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.auth_sessions": { + "name": "auth_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_project_id": { + "name": "active_project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "auth_sessions_user_id_auth_users_id_fk": { + "name": "auth_sessions_user_id_auth_users_id_fk", + "tableFrom": "auth_sessions", + "tableTo": "auth_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "auth_sessions_impersonated_by_auth_users_id_fk": { + "name": "auth_sessions_impersonated_by_auth_users_id_fk", + "tableFrom": "auth_sessions", + "tableTo": "auth_users", + "columnsFrom": [ + "impersonated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.auth_users": { + "name": "auth_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_url": { + "name": "github_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "x_url": { + "name": "x_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "youtube_url": { + "name": "youtube_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_notifications": { + "name": "email_notifications", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "role": { + "name": "role", + "type": "auth_user_roles", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_unique_index": { + "name": "email_unique_index", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "username_unique_index": { + "name": "username_unique_index", + "columns": [ + { + "expression": "lower(\"username\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.auth_verifications": { + "name": "auth_verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_index": { + "name": "verification_identifier_index", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.feedback": { + "name": "feedback", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "images": { + "name": "images", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scene_graph": { + "name": "scene_graph", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.projects_addresses": { + "name": "projects_addresses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "street_number": { + "name": "street_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "route": { + "name": "route", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "route_short": { + "name": "route_short", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "neighborhood": { + "name": "neighborhood", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "county": { + "name": "county", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_long": { + "name": "state_long", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "postal_code": { + "name": "postal_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "postal_code_suffix": { + "name": "postal_code_suffix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country_long": { + "name": "country_long", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latitude": { + "name": "latitude", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "longitude": { + "name": "longitude", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "raw_json": { + "name": "raw_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "address_components_unique": { + "name": "address_components_unique", + "nullsNotDistinct": false, + "columns": [ + "street_number", + "route", + "city", + "state", + "postal_code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.project_assets": { + "name": "project_assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "project_assets_project_id_projects_id_fk": { + "name": "project_assets_project_id_projects_id_fk", + "tableFrom": "project_assets", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.projects_likes": { + "name": "projects_likes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "projects_likes_project_id_projects_id_fk": { + "name": "projects_likes_project_id_projects_id_fk", + "tableFrom": "projects_likes", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "projects_likes_project_user_unique": { + "name": "projects_likes_project_user_unique", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.projects_models": { + "name": "projects_models", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "draft": { + "name": "draft", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scene_graph": { + "name": "scene_graph", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "projects_models_project_id_projects_id_fk": { + "name": "projects_models_project_id_projects_id_fk", + "tableFrom": "projects_models", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_id": { + "name": "address_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details_json": { + "name": "details_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_scans_public": { + "name": "show_scans_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_guides_public": { + "name": "show_guides_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "likes": { + "name": "likes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_address_idx": { + "name": "project_address_idx", + "columns": [ + { + "expression": "address_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_owner_idx": { + "name": "project_owner_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_is_private_idx": { + "name": "project_is_private_idx", + "columns": [ + { + "expression": "is_private", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_views_idx": { + "name": "project_views_idx", + "columns": [ + { + "expression": "views", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_likes_idx": { + "name": "project_likes_idx", + "columns": [ + { + "expression": "likes", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_address_id_projects_addresses_id_fk": { + "name": "projects_address_id_projects_addresses_id_fk", + "tableFrom": "projects", + "tableTo": "projects_addresses", + "columnsFrom": [ + "address_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "projects_owner_id_auth_users_id_fk": { + "name": "projects_owner_id_auth_users_id_fk", + "tableFrom": "projects", + "tableTo": "auth_users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + } + }, + "enums": { + "public.auth_user_roles": { + "name": "auth_user_roles", + "schema": "public", + "values": [ + "user", + "admin" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/supabase/migrations/meta/_journal.json b/supabase/migrations/meta/_journal.json index 9921faab..8213f70a 100644 --- a/supabase/migrations/meta/_journal.json +++ b/supabase/migrations/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1771911353115, "tag": "20260224053553_stormy_carnage", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1772065554845, + "tag": "20260226002554_big_pestilence", + "breakpoints": true } ] } \ No newline at end of file