From 66e8453db3345d92f61df70816d05f6b31cf21c0 Mon Sep 17 00:00:00 2001 From: wass08 Date: Mon, 16 Feb 2026 13:43:19 +0900 Subject: [PATCH] hub features --- apps/editor/app/editor/[propertyId]/page.tsx | 30 + apps/editor/app/page.tsx | 10 +- apps/editor/app/viewer/[id]/page.tsx | 45 +- apps/editor/components/editor/index.tsx | 22 +- .../components/cloud-save-button.tsx | 42 +- .../community/components/community-hub.tsx | 217 +++++++ .../components/create-property-button.tsx | 19 + .../local-property-migration-dialog.tsx | 83 +++ .../components/new-property-dialog.tsx | 38 +- .../community/components/property-grid.tsx | 234 ++++++++ .../components/property-settings-dialog.tsx | 242 ++++++++ .../community/lib/local-storage/hooks.ts | 93 +++ .../lib/local-storage/property-store.ts | 89 +++ .../community/lib/properties/actions.ts | 564 ++++++++++++++++++ .../community/lib/properties/store.ts | 13 +- .../community/lib/properties/types.ts | 7 + apps/editor/lib/navigation.ts | 15 + .../db/src/schema/properties/properties.ts | 8 + .../20240216000001_add_community_fields.sql | 13 + ...00002_update_rls_for_public_properties.sql | 78 +++ ...6000003_create_view_increment_function.sql | 9 + .../20240216000004_create_property_likes.sql | 41 ++ 22 files changed, 1869 insertions(+), 43 deletions(-) create mode 100644 apps/editor/app/editor/[propertyId]/page.tsx create mode 100644 apps/editor/features/community/components/community-hub.tsx create mode 100644 apps/editor/features/community/components/create-property-button.tsx create mode 100644 apps/editor/features/community/components/local-property-migration-dialog.tsx create mode 100644 apps/editor/features/community/components/property-grid.tsx create mode 100644 apps/editor/features/community/components/property-settings-dialog.tsx create mode 100644 apps/editor/features/community/lib/local-storage/hooks.ts create mode 100644 apps/editor/features/community/lib/local-storage/property-store.ts create mode 100644 apps/editor/lib/navigation.ts create mode 100644 packages/db/supabase/migrations/20240216000001_add_community_fields.sql create mode 100644 packages/db/supabase/migrations/20240216000002_update_rls_for_public_properties.sql create mode 100644 packages/db/supabase/migrations/20240216000003_create_view_increment_function.sql create mode 100644 packages/db/supabase/migrations/20240216000004_create_property_likes.sql diff --git a/apps/editor/app/editor/[propertyId]/page.tsx b/apps/editor/app/editor/[propertyId]/page.tsx new file mode 100644 index 00000000..47954a0a --- /dev/null +++ b/apps/editor/app/editor/[propertyId]/page.tsx @@ -0,0 +1,30 @@ +'use client' + +import Editor from '@/components/editor' +import { useParams } from 'next/navigation' +import { useEffect, useLayoutEffect } from 'react' +import { usePropertyStore } from '@/features/community/lib/properties/store' +import { useAuth } from '@/features/community/lib/auth/hooks' + +export default function EditorPage() { + const params = useParams() + const propertyId = params.propertyId as string + const { isAuthenticated } = useAuth() + const setActiveProperty = usePropertyStore((state) => state.setActiveProperty) + + // Use layoutEffect to set active property BEFORE the editor renders and hooks run + useLayoutEffect(() => { + // For authenticated users with cloud properties, set the active property from URL + if (isAuthenticated && propertyId && !propertyId.startsWith('local_')) { + setActiveProperty(propertyId) + } + }, [propertyId, isAuthenticated, setActiveProperty]) + + return ( +
+
+ +
+
+ ) +} diff --git a/apps/editor/app/page.tsx b/apps/editor/app/page.tsx index e9e2c256..a7013249 100644 --- a/apps/editor/app/page.tsx +++ b/apps/editor/app/page.tsx @@ -1,11 +1,5 @@ -import Editor from '../components/editor' +import CommunityHub from '@/features/community/components/community-hub' export default function Home() { - return ( -
-
- -
-
- ) + return } diff --git a/apps/editor/app/viewer/[id]/page.tsx b/apps/editor/app/viewer/[id]/page.tsx index bd7a541f..a44851c9 100644 --- a/apps/editor/app/viewer/[id]/page.tsx +++ b/apps/editor/app/viewer/[id]/page.tsx @@ -7,6 +7,7 @@ import { useEffect, useState } from 'react' import { ViewerCameraControls } from './viewer-camera-controls' import { ViewerOverlay } from './viewer-overlay' import { ViewerZoneSystem } from './viewer-zone-system' +import { getPropertyModelPublic, incrementPropertyViews } from '@/features/community/lib/properties/actions' export default function ViewerPage() { const params = useParams() @@ -16,25 +17,47 @@ export default function ViewerPage() { const setScene = useScene((state) => state.setScene) useEffect(() => { - const loadDemo = async () => { + const loadContent = async () => { try { - const response = await fetch(`/demos/${id}.json`) - if (!response.ok) { - throw new Error(`Demo "${id}" not found`) - } - const data = await response.json() - if (data.nodes && data.rootNodeIds) { - setScene(data.nodes, data.rootNodeIds) - initSpatialGridSync() + // Check if it's a demo file (starts with 'demo_') + if (id.startsWith('demo_')) { + const response = await fetch(`/demos/${id}.json`) + if (!response.ok) { + throw new Error(`Demo "${id}" not found`) + } + const data = await response.json() + if (data.nodes && data.rootNodeIds) { + setScene(data.nodes, data.rootNodeIds) + initSpatialGridSync() + } + } else { + // Load from database (public property) + const result = await getPropertyModelPublic(id) + + if (result.success && result.data) { + const { model } = result.data + + if (model?.scene_graph) { + const { nodes, rootNodeIds } = model.scene_graph + setScene(nodes, rootNodeIds) + initSpatialGridSync() + } + + // Increment view count + await incrementPropertyViews(id) + } else { + throw new Error(result.error || 'Property not found') + } } + setLoading(false) } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load demo') + setError(err instanceof Error ? err.message : 'Failed to load content') setLoading(false) } } - loadDemo() + loadContent() }, [id, setScene]) if (loading) { diff --git a/apps/editor/components/editor/index.tsx b/apps/editor/components/editor/index.tsx index e27abda6..a4905ea0 100644 --- a/apps/editor/components/editor/index.tsx +++ b/apps/editor/components/editor/index.tsx @@ -5,6 +5,8 @@ import { Viewer } from '@pascal-app/viewer' import { useKeyboard } from '@/hooks/use-keyboard' import useEditor from '@/store/use-editor' import { usePropertyScene } from '@/features/community/lib/models/hooks' +import { useLocalPropertyScene } from '@/features/community/lib/local-storage/hooks' +import { useAuth } from '@/features/community/lib/auth/hooks' import { ZoneSystem } from '../systems/zone/zone-system' import { ToolManager } from '../tools/tool-manager' import { ActionMenu } from '../ui/action-menu' @@ -28,8 +30,24 @@ initSpaceDetectionSync(useScene, useEditor) // Initialize SFX bus to connect events to sound effects initSFXBus() -export default function Editor() { +interface EditorProps { + propertyId?: string +} + +export default function Editor({ propertyId }: EditorProps) { useKeyboard() + const { isAuthenticated } = useAuth() + + // Determine which mode to use + const isLocalProperty = propertyId?.startsWith('local_') + const shouldUseCloud = isAuthenticated && !isLocalProperty + const shouldUseLocal = !shouldUseCloud && !!propertyId + + // Call hooks unconditionally (hooks internally check if they should activate) + // Cloud hook activates when there's an activeProperty in the store + usePropertyScene() + // Local hook activates when propertyId is provided and starts with 'local_' + useLocalPropertyScene(shouldUseLocal ? propertyId : undefined) return (
@@ -43,7 +61,7 @@ export default function Editor() {
- +
diff --git a/apps/editor/features/community/components/cloud-save-button.tsx b/apps/editor/features/community/components/cloud-save-button.tsx index 0aebf429..dc42a7fc 100644 --- a/apps/editor/features/community/components/cloud-save-button.tsx +++ b/apps/editor/features/community/components/cloud-save-button.tsx @@ -1,23 +1,31 @@ 'use client' -import { Cloud } from 'lucide-react' +import { Cloud, Home } from 'lucide-react' import { useEffect, useState } from 'react' +import { useRouter } from 'next/navigation' import { useAuth } from '../lib/auth/hooks' import { usePropertyStore } from '../lib/properties/store' import { ProfileDropdown } from './profile-dropdown' -import { PropertyDropdown } from './property-dropdown' import { SignInDialog } from './sign-in-dialog' +interface CloudSaveButtonProps { + propertyId?: string +} + /** * CloudSaveButton - Shows authentication state and property management * - * Not authenticated: Shows "Save to cloud" button - * Authenticated: Shows PropertyDropdown and ProfileDropdown + * Guest with local property: Shows "Save to cloud" button + * Guest without property: Shows "Home" button + * Authenticated: Shows ProfileDropdown */ -export function CloudSaveButton() { +export function CloudSaveButton({ propertyId }: CloudSaveButtonProps) { const { isAuthenticated, isLoading } = useAuth() const [isSignInDialogOpen, setIsSignInDialogOpen] = useState(false) const initialize = usePropertyStore(state => state.initialize) + const router = useRouter() + + const isLocalProperty = propertyId?.startsWith('local_') // Initialize property store when authenticated useEffect(() => { @@ -36,7 +44,8 @@ export function CloudSaveButton() { ) } - if (!isAuthenticated) { + // Guest user with local property + if (!isAuthenticated && isLocalProperty) { return ( <>
@@ -53,12 +62,25 @@ export function CloudSaveButton() { ) } + // Guest user (no property context or browsing) + if (!isAuthenticated) { + return ( +
+ +
+ ) + } + + // Authenticated user return (
-
- - -
+
) } diff --git a/apps/editor/features/community/components/community-hub.tsx b/apps/editor/features/community/components/community-hub.tsx new file mode 100644 index 00000000..00954bc4 --- /dev/null +++ b/apps/editor/features/community/components/community-hub.tsx @@ -0,0 +1,217 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useAuth } from '../lib/auth/hooks' +import { useRouter } from 'next/navigation' +import { SignInDialog } from './sign-in-dialog' +import { PropertyGrid } from './property-grid' +import { CreatePropertyButton } from './create-property-button' +import { ProfileDropdown } from './profile-dropdown' +import { NewPropertyDialog } from './new-property-dialog' +import { getPublicProperties, getUserProperties } from '../lib/properties/actions' +import { getLocalProperties, createLocalProperty } from '../lib/local-storage/property-store' +import type { Property } from '../lib/properties/types' +import type { LocalProperty } from '../lib/local-storage/property-store' + +export default function CommunityHub() { + const { isAuthenticated, isLoading: authLoading, user } = useAuth() + const router = useRouter() + const [isSignInDialogOpen, setIsSignInDialogOpen] = useState(false) + const [isNewPropertyDialogOpen, setIsNewPropertyDialogOpen] = useState(false) + const [localPropertyToSave, setLocalPropertyToSave] = useState(null) + const [publicProperties, setPublicProperties] = useState([]) + const [userProperties, setUserProperties] = useState([]) + const [localProperties, setLocalProperties] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + async function loadProperties() { + setLoading(true) + + // Load public properties (always) + const publicResult = await getPublicProperties() + if (publicResult.success) { + setPublicProperties(publicResult.data || []) + } + + // Load user properties if authenticated + if (isAuthenticated) { + const userResult = await getUserProperties() + if (userResult.success) { + setUserProperties(userResult.data || []) + } + } + + // Always load local properties + setLocalProperties(getLocalProperties()) + + setLoading(false) + } + + if (!authLoading) { + loadProperties() + } + }, [isAuthenticated, authLoading]) + + const handleCreateProperty = async () => { + if (!isAuthenticated) { + // Create local property for guest + const property = createLocalProperty('Untitled Property') + router.push(`/editor/${property.id}`) + } else { + // Open property creation dialog for authenticated users + setIsNewPropertyDialogOpen(true) + } + } + + const handlePropertyCreated = async (propertyId: string) => { + // If this was a local property being saved, delete it from localStorage + if (localPropertyToSave) { + const { deleteLocalProperty } = await import('../lib/local-storage/property-store') + deleteLocalProperty(localPropertyToSave.id) + setLocalProperties(getLocalProperties()) + setLocalPropertyToSave(null) + } + + // Reload properties and navigate to the new property + const result = await getUserProperties() + if (result.success) { + setUserProperties(result.data || []) + } + router.push(`/editor/${propertyId}`) + } + + const handleSaveLocalToCloud = (localProperty: LocalProperty) => { + setLocalPropertyToSave(localProperty) + setIsNewPropertyDialogOpen(true) + } + + const handlePropertyClick = (propertyId: string) => { + router.push(`/editor/${propertyId}`) + } + + const handleViewProperty = (propertyId: string) => { + router.push(`/viewer/${propertyId}`) + } + + + if (authLoading || loading) { + return ( +
+

Loading...

+
+ ) + } + + return ( +
+ {/* Header */} +
+
+
+

Hub

+ {!isAuthenticated ? ( + + ) : ( + + )} +
+
+
+ +
+ {/* User's Properties Section */} + {isAuthenticated && (userProperties.length > 0 || localProperties.length > 0) && ( +
+
+

My Properties

+ +
+ { + // Reload properties after settings update + if (!authLoading) { + getUserProperties().then((result) => { + if (result.success) { + setUserProperties(result.data || []) + } + }) + } + }} + /> +
+ )} + + {/* Local Properties Section (Guest Users) */} + {!isAuthenticated && localProperties.length > 0 && ( +
+
+

My Local Projects

+ +
+ +
+ )} + + {/* Create First Property CTA */} + {!isAuthenticated && localProperties.length === 0 && ( +
+

Get Started

+

+ Create your first property to start designing +

+ +
+ )} + + {/* Public Properties Section */} +
+

Community Properties

+ {publicProperties.length > 0 ? ( + + ) : ( +
+ No public properties yet +
+ )} +
+
+ + + +
+ ) +} diff --git a/apps/editor/features/community/components/create-property-button.tsx b/apps/editor/features/community/components/create-property-button.tsx new file mode 100644 index 00000000..fd48a761 --- /dev/null +++ b/apps/editor/features/community/components/create-property-button.tsx @@ -0,0 +1,19 @@ +'use client' + +import { Plus } from 'lucide-react' + +interface CreatePropertyButtonProps { + onCreateProperty: () => void +} + +export function CreatePropertyButton({ onCreateProperty }: CreatePropertyButtonProps) { + return ( + + ) +} diff --git a/apps/editor/features/community/components/local-property-migration-dialog.tsx b/apps/editor/features/community/components/local-property-migration-dialog.tsx new file mode 100644 index 00000000..c1b6bd8f --- /dev/null +++ b/apps/editor/features/community/components/local-property-migration-dialog.tsx @@ -0,0 +1,83 @@ +'use client' + +import { useState } from 'react' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/primitives/dialog' +import type { LocalProperty } from '../lib/local-storage/property-store' + +interface LocalPropertyMigrationDialogProps { + localProperties: LocalProperty[] + open: boolean + onMigrate: () => Promise + onSkip: () => void +} + +export function LocalPropertyMigrationDialog({ + localProperties, + open, + onMigrate, + onSkip, +}: LocalPropertyMigrationDialogProps) { + const [isMigrating, setIsMigrating] = useState(false) + + const handleMigrate = async () => { + setIsMigrating(true) + try { + await onMigrate() + } finally { + setIsMigrating(false) + } + } + + return ( + !open && !isMigrating && onSkip()}> + + + Save Local Properties to Cloud + + You have {localProperties.length} local {localProperties.length === 1 ? 'property' : 'properties'} that {localProperties.length === 1 ? 'hasn\'t' : 'haven\'t'} been saved to the cloud yet. + + + +
+

+ Would you like to save {localProperties.length === 1 ? 'it' : 'them'} to your account? +

+
    + {localProperties.map((property) => ( +
  • + + {property.name} +
  • + ))} +
+
+ + + + + +
+
+ ) +} diff --git a/apps/editor/features/community/components/new-property-dialog.tsx b/apps/editor/features/community/components/new-property-dialog.tsx index 2e73e123..2e73d6e6 100644 --- a/apps/editor/features/community/components/new-property-dialog.tsx +++ b/apps/editor/features/community/components/new-property-dialog.tsx @@ -4,12 +4,18 @@ import { X } from 'lucide-react' import { useState } from 'react' import { createProperty } from '../lib/properties/actions' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/primitives/dialog' +import { Switch } from '@/components/ui/primitives/switch' import { GoogleAddressSearch } from './google-address-search' interface NewPropertyDialogProps { open: boolean onOpenChange: (open: boolean) => void onSuccess?: (propertyId: string) => void + localPropertyData?: { + id: string + name: string + sceneGraph: any + } } interface AddressData { @@ -26,8 +32,9 @@ interface AddressData { /** * NewPropertyDialog - Dialog for creating a new property with Google Maps address search */ -export function NewPropertyDialog({ open, onOpenChange, onSuccess }: NewPropertyDialogProps) { +export function NewPropertyDialog({ open, onOpenChange, onSuccess, localPropertyData }: NewPropertyDialogProps) { const [address, setAddress] = useState(null) + const [isPrivate, setIsPrivate] = useState(false) const [isCreating, setIsCreating] = useState(false) const [error, setError] = useState(null) @@ -58,11 +65,14 @@ export function NewPropertyDialog({ open, onOpenChange, onSuccess }: NewProperty state: address.state, postalCode: address.postalCode, country: address.country || 'US', + isPrivate, + sceneGraph: localPropertyData?.sceneGraph, }) if (result.success && result.data) { onOpenChange(false) setAddress(null) + setIsPrivate(false) onSuccess?.(result.data.id) } else { setError(result.error || 'Failed to create property') @@ -78,6 +88,7 @@ export function NewPropertyDialog({ open, onOpenChange, onSuccess }: NewProperty if (!isCreating) { onOpenChange(false) setAddress(null) + setIsPrivate(false) setError(null) } } @@ -112,6 +123,31 @@ export function NewPropertyDialog({ open, onOpenChange, onSuccess }: NewProperty
)} + {/* Privacy Toggle */} +
+
+
Privacy
+
+ {isPrivate ? 'Only you can view this property' : 'Anyone can view this property'} +
+
+
+ Public + setIsPrivate(!checked)} /> +
+
+ + {localPropertyData && ( +
+

+ Saving local property: {localPropertyData.name} +

+

+ Your building data will be preserved +

+
+ )} + {error && (
{error} diff --git a/apps/editor/features/community/components/property-grid.tsx b/apps/editor/features/community/components/property-grid.tsx new file mode 100644 index 00000000..21f2800c --- /dev/null +++ b/apps/editor/features/community/components/property-grid.tsx @@ -0,0 +1,234 @@ +'use client' + +import { Eye, Heart, Settings } from 'lucide-react' +import { useEffect, useState } from 'react' +import type { Property } from '../lib/properties/types' +import type { LocalProperty } from '../lib/local-storage/property-store' +import { PropertySettingsDialog } from './property-settings-dialog' +import { getUserPropertyLikes, togglePropertyLike } from '../lib/properties/actions' +import { useAuth } from '../lib/auth/hooks' + +interface PropertyGridProps { + properties: (Property | LocalProperty)[] + onPropertyClick: (id: string) => void + onViewClick?: (id: string) => void + onSaveToCloud?: (property: LocalProperty) => void + showOwner: boolean + isLocal?: boolean + canEdit?: boolean + onUpdate?: () => void +} + +function isLocalProperty(prop: Property | LocalProperty): prop is LocalProperty { + return 'is_local' in prop && prop.is_local === true +} + +export function PropertyGrid({ + properties, + onPropertyClick, + onViewClick, + onSaveToCloud, + showOwner, + isLocal = false, + canEdit = false, + onUpdate, +}: PropertyGridProps) { + const { isAuthenticated } = useAuth() + const [settingsProperty, setSettingsProperty] = useState(null) + const [userLikes, setUserLikes] = useState>({}) + const [likeCounts, setLikeCounts] = useState>({}) + + // Initialize like counts from properties + useEffect(() => { + const counts: Record = {} + properties.forEach((prop) => { + if (!isLocalProperty(prop)) { + counts[prop.id] = prop.likes + } + }) + setLikeCounts(counts) + }, [properties]) + + // Fetch which properties the user has liked + useEffect(() => { + if (!isAuthenticated) { + setUserLikes({}) + return + } + + const propertyIds = properties + .filter((p) => !isLocalProperty(p)) + .map((p) => p.id) + + if (propertyIds.length === 0) return + + getUserPropertyLikes(propertyIds).then((result) => { + if (result.success && result.data) { + setUserLikes(result.data) + } + }) + }, [properties, isAuthenticated]) + + const handleSettingsClick = (e: React.MouseEvent, property: Property | LocalProperty) => { + e.stopPropagation() + if (!isLocalProperty(property)) { + setSettingsProperty(property) + } + } + + const handleViewClick = (e: React.MouseEvent, propertyId: string) => { + e.stopPropagation() + onViewClick?.(propertyId) + } + + const handleLikeClick = async (e: React.MouseEvent, propertyId: string) => { + e.stopPropagation() + + if (!isAuthenticated) { + // Could show a sign-in prompt here + return + } + + // Optimistic update + const wasLiked = userLikes[propertyId] || false + const currentCount = likeCounts[propertyId] || 0 + + setUserLikes((prev) => ({ ...prev, [propertyId]: !wasLiked })) + setLikeCounts((prev) => ({ + ...prev, + [propertyId]: wasLiked ? currentCount - 1 : currentCount + 1 + })) + + // Call server action + const result = await togglePropertyLike(propertyId) + + if (result.success && result.data) { + // Update with actual values from server + setUserLikes((prev) => ({ ...prev, [propertyId]: result.data.liked })) + setLikeCounts((prev) => ({ ...prev, [propertyId]: result.data.likes })) + } else { + // Revert on error + setUserLikes((prev) => ({ ...prev, [propertyId]: wasLiked })) + setLikeCounts((prev) => ({ ...prev, [propertyId]: currentCount })) + } + } + + return ( + <> +
+ {properties.map((property) => ( + + ) : ( +
+ Local +
+ )} +
+ )} + {canEdit && !isLocalProperty(property) && ( +
+ {onViewClick && ( + + )} + +
+ )} +
+ + {/* Info */} +
+

{property.name}

+ + {!isLocalProperty(property) && ( +
+
+ + {property.views} +
+ +
+ )} + + {isLocalProperty(property) && ( +
+ {new Date(property.updated_at).toLocaleDateString()} +
+ )} +
+ + ))} + + + {/* Settings Dialog */} + {settingsProperty && ( + !open && setSettingsProperty(null)} + onUpdate={onUpdate} + onDelete={() => { + setSettingsProperty(null) + onUpdate?.() + }} + /> + )} + + ) +} diff --git a/apps/editor/features/community/components/property-settings-dialog.tsx b/apps/editor/features/community/components/property-settings-dialog.tsx new file mode 100644 index 00000000..25c6111c --- /dev/null +++ b/apps/editor/features/community/components/property-settings-dialog.tsx @@ -0,0 +1,242 @@ +'use client' + +import { useState } from 'react' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/primitives/dialog' +import { Switch } from '@/components/ui/primitives/switch' +import { updatePropertyAddress, updatePropertyPrivacy, deleteProperty } from '../lib/properties/actions' +import type { Property } from '../lib/properties/types' + +interface PropertySettingsDialogProps { + property: Property + open: boolean + onOpenChange: (open: boolean) => void + onUpdate?: () => void + onDelete?: () => void +} + +export function PropertySettingsDialog({ + property, + open, + onOpenChange, + onUpdate, + onDelete, +}: PropertySettingsDialogProps) { + const [loading, setLoading] = useState(false) + const [isDeleting, setIsDeleting] = useState(false) + const [isPrivate, setIsPrivate] = useState(property.is_private) + const [address, setAddress] = useState({ + street_number: property.address.street_number || '', + route: property.address.route || '', + city: property.address.city || '', + state: property.address.state || '', + postal_code: property.address.postal_code || '', + country: property.address.country || 'US', + }) + + const handleSave = async () => { + setLoading(true) + try { + // Update privacy if changed + if (isPrivate !== property.is_private) { + const privacyResult = await updatePropertyPrivacy(property.id, isPrivate) + if (!privacyResult.success) { + alert(`Failed to update privacy: ${privacyResult.error}`) + setLoading(false) + return + } + } + + // Update address if changed + const addressChanged = + address.street_number !== (property.address.street_number || '') || + address.route !== (property.address.route || '') || + address.city !== (property.address.city || '') || + address.state !== (property.address.state || '') || + address.postal_code !== (property.address.postal_code || '') || + address.country !== (property.address.country || 'US') + + if (addressChanged) { + const addressResult = await updatePropertyAddress(property.id, address) + if (!addressResult.success) { + alert(`Failed to update address: ${addressResult.error}`) + setLoading(false) + return + } + } + + onUpdate?.() + onOpenChange(false) + } catch (error) { + alert('Failed to save settings') + } finally { + setLoading(false) + } + } + + const handleDelete = async () => { + if (!confirm('Are you sure you want to delete this property? This action cannot be undone.')) { + return + } + + setIsDeleting(true) + try { + const result = await deleteProperty(property.id) + if (result.success) { + onDelete?.() + onOpenChange(false) + } else { + alert(`Failed to delete property: ${result.error}`) + } + } catch (error) { + alert('Failed to delete property') + } finally { + setIsDeleting(false) + } + } + + return ( + + + + Property Settings + Update property address and privacy settings + + +
+ {/* Privacy Toggle */} +
+
+
Privacy
+
+ {isPrivate ? 'Only you can view this property' : 'Anyone can view this property'} +
+
+
+ Public + setIsPrivate(!checked)} /> +
+
+ + {/* Address Fields */} +
+

Address

+ +
+
+ + setAddress({ ...address, street_number: e.target.value })} + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm" + placeholder="123" + /> +
+ +
+ + setAddress({ ...address, route: e.target.value })} + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm" + placeholder="Main St" + /> +
+
+ +
+
+ + setAddress({ ...address, city: e.target.value })} + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm" + placeholder="San Francisco" + /> +
+ +
+ + setAddress({ ...address, state: e.target.value })} + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm" + placeholder="CA" + /> +
+
+ +
+
+ + setAddress({ ...address, postal_code: e.target.value })} + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm" + placeholder="94102" + /> +
+ +
+ + setAddress({ ...address, country: e.target.value })} + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm" + placeholder="US" + /> +
+
+
+ + {/* Danger Zone */} +
+

Danger Zone

+

+ Once you delete a property, there is no going back. Please be certain. +

+ +
+
+ + + + + +
+
+ ) +} diff --git a/apps/editor/features/community/lib/local-storage/hooks.ts b/apps/editor/features/community/lib/local-storage/hooks.ts new file mode 100644 index 00000000..b9258d4b --- /dev/null +++ b/apps/editor/features/community/lib/local-storage/hooks.ts @@ -0,0 +1,93 @@ +'use client' + +import { initSpatialGridSync, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { useEffect, useRef } from 'react' +import useEditor from '@/store/use-editor' +import { getLocalProperty, updateLocalPropertyScene } from './property-store' + +/** + * Hook for local property scene management (guest users) + * Loads scene from localStorage and auto-saves changes + */ +export function useLocalPropertyScene(propertyId?: string) { + const saveTimeoutRef = useRef(undefined) + const currentPropertyIdRef = useRef(null) + const lastPropertyIdRef = useRef(null) + + // Load scene when property ID changes + useEffect(() => { + if (!propertyId || !propertyId.startsWith('local_')) { + return + } + + if (lastPropertyIdRef.current === propertyId) { + return + } + + lastPropertyIdRef.current = propertyId + currentPropertyIdRef.current = propertyId + + const property = getLocalProperty(propertyId) + + if (property?.scene_graph) { + const { nodes, rootNodeIds } = property.scene_graph + useScene.getState().setScene(nodes, rootNodeIds) + initSpatialGridSync() + } else { + useScene.getState().clearScene() + } + + useEditor.getState().setPhase('site') + useViewer.getState().setSelection({ + buildingId: null, + levelId: null, + selectedIds: [], + zoneId: null, + }) + }, [propertyId]) + + // Auto-save to localStorage with debouncing + useEffect(() => { + if (!propertyId || !propertyId.startsWith('local_')) { + currentPropertyIdRef.current = null + return + } + + currentPropertyIdRef.current = propertyId + let lastNodesSnapshot = JSON.stringify(useScene.getState().nodes) + + const unsubscribe = useScene.subscribe((state) => { + const currentNodesSnapshot = JSON.stringify(state.nodes) + + if (currentNodesSnapshot === lastNodesSnapshot) { + return + } + + lastNodesSnapshot = currentNodesSnapshot + const nodes = state.nodes + + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current) + } + + // Debounce save by 1 second (faster than cloud save) + saveTimeoutRef.current = setTimeout(() => { + const currentId = currentPropertyIdRef.current + if (!currentId) return + + const rootNodeIds = useScene.getState().rootNodeIds + const sceneGraph = { nodes, rootNodeIds } + + updateLocalPropertyScene(currentId, sceneGraph) + }, 1000) + }) + + return () => { + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current) + } + unsubscribe() + } + }, [propertyId]) +} diff --git a/apps/editor/features/community/lib/local-storage/property-store.ts b/apps/editor/features/community/lib/local-storage/property-store.ts new file mode 100644 index 00000000..3730054e --- /dev/null +++ b/apps/editor/features/community/lib/local-storage/property-store.ts @@ -0,0 +1,89 @@ +/** + * Local storage management for guest users + * Stores properties and scenes in browser localStorage + */ + +import { createId } from '../utils/id-generator' + +export interface SceneGraph { + nodes: Record + rootNodeIds: string[] +} + +export interface LocalProperty { + id: string // Format: 'local_property_xyz' + name: string + created_at: string + updated_at: string + scene_graph: SceneGraph | null + is_local: true +} + +const LOCAL_PROPERTIES_KEY = 'pascal_local_properties' + +export function getLocalProperties(): LocalProperty[] { + if (typeof window === 'undefined') return [] + + try { + const stored = localStorage.getItem(LOCAL_PROPERTIES_KEY) + return stored ? JSON.parse(stored) : [] + } catch (error) { + console.error('Failed to load local properties:', error) + return [] + } +} + +export function getLocalProperty(id: string): LocalProperty | null { + const properties = getLocalProperties() + return properties.find((p) => p.id === id) || null +} + +export function saveLocalProperty(property: LocalProperty): void { + const properties = getLocalProperties() + const index = properties.findIndex((p) => p.id === property.id) + + if (index >= 0) { + properties[index] = { ...property, updated_at: new Date().toISOString() } + } else { + properties.push(property) + } + + localStorage.setItem(LOCAL_PROPERTIES_KEY, JSON.stringify(properties)) +} + +export function createLocalProperty(name: string): LocalProperty { + const property: LocalProperty = { + id: createId('local_property'), + name, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + scene_graph: null, + is_local: true, + } + + saveLocalProperty(property) + return property +} + +export function deleteLocalProperty(id: string): void { + const properties = getLocalProperties().filter((p) => p.id !== id) + localStorage.setItem(LOCAL_PROPERTIES_KEY, JSON.stringify(properties)) +} + +export function updateLocalPropertyScene(id: string, sceneGraph: SceneGraph): void { + const property = getLocalProperty(id) + if (property) { + property.scene_graph = sceneGraph + saveLocalProperty(property) + } +} + +export function migrateLocalPropertiesToCloud(userId: string): LocalProperty[] { + // Return local properties that need to be migrated + // Actual migration handled by separate function + return getLocalProperties() +} + +export function clearLocalProperties(): void { + localStorage.removeItem(LOCAL_PROPERTIES_KEY) +} diff --git a/apps/editor/features/community/lib/properties/actions.ts b/apps/editor/features/community/lib/properties/actions.ts index 81f3c5f0..f424505c 100644 --- a/apps/editor/features/community/lib/properties/actions.ts +++ b/apps/editor/features/community/lib/properties/actions.ts @@ -42,6 +42,7 @@ export async function getUserProperties(): Promise> { address:properties_addresses(*) `) .eq('owner_id', session.user.id) + .order('created_at', { ascending: false }) if (error) { return { @@ -218,6 +219,7 @@ export async function createProperty(params: CreatePropertyParams): Promise> { + try { + const supabase = await createServerSupabaseClient() + + const { data, error } = await supabase + .from('properties') + .select(` + *, + address:properties_addresses(*) + `) + .eq('is_private', false) + .order('views', { ascending: false }) + .limit(50) + + if (error) { + return { + success: false, + error: error.message, + data: [], + } + } + + return { + success: true, + data: data as Property[], + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch public properties', + data: [], + } + } +} + +/** + * Get a property model for viewing + * Allows viewing if: property is public OR user owns the property + */ +export async function getPropertyModelPublic(propertyId: string): Promise< + ActionResult<{ property: Property; model: any | null }> +> { + try { + const session = await getSession() + const supabase = await createServerSupabaseClient() + + // Get the property (without privacy filter first) + const { data: property, error: propertyError } = await supabase + .from('properties') + .select(` + *, + address:properties_addresses(*) + `) + .eq('id', propertyId) + .single() + + if (propertyError || !property) { + return { + success: false, + error: 'Property not found', + data: null, + } + } + + // Check if user can view this property + // Allow if: property is public OR user owns it + const isOwner = session?.user && property.owner_id === session.user.id + const isPublic = property.is_private === false + + if (!isPublic && !isOwner) { + return { + success: false, + error: 'Property is private', + data: null, + } + } + + // Get the model + const { data: model } = await supabase + .from('properties_models') + .select('*') + .eq('property_id', propertyId) + .is('deleted_at', null) + .order('version', { ascending: false }) + .limit(1) + .maybeSingle() + + return { + success: true, + data: { + property: property as Property, + model: model || null, + }, + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch property', + data: null, + } + } +} + +/** + * Increment property view count + */ +export async function incrementPropertyViews(propertyId: string): Promise { + try { + const supabase = await createServerSupabaseClient() + + const { error } = await supabase.rpc('increment_property_views', { + property_id: propertyId, + }) + + if (error) { + console.error('Failed to increment views:', error) + // Don't fail the request if view increment fails + } + + return { success: true } + } catch (error) { + console.error('Failed to increment views:', error) + return { success: true } // Don't fail on view tracking errors + } +} + +/** + * Update property privacy setting + */ +export async function updatePropertyPrivacy( + propertyId: string, + isPrivate: boolean, +): Promise { + try { + const session = await getSession() + + if (!session?.user) { + return { + success: false, + error: 'Not authenticated', + } + } + + const supabase = await createServerSupabaseClient() + + // Verify ownership + const { data: property } = await supabase + .from('properties') + .select('owner_id') + .eq('id', propertyId) + .single() + + if (property?.owner_id !== session.user.id) { + return { + success: false, + error: 'Unauthorized', + } + } + + // Update privacy + const { error } = await supabase + .from('properties') + .update({ is_private: isPrivate }) + .eq('id', propertyId) + + if (error) { + return { + success: false, + error: error.message, + } + } + + return { + success: true, + message: `Property is now ${isPrivate ? 'private' : 'public'}`, + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to update property privacy', + } + } +} + +/** + * Update property address + */ +export async function updatePropertyAddress( + propertyId: string, + addressData: { + street_number?: string + route?: string + city?: string + state?: string + postal_code?: string + country?: string + }, +): Promise { + try { + const session = await getSession() + + if (!session?.user) { + return { + success: false, + error: 'Not authenticated', + } + } + + const supabase = await createServerSupabaseClient() + + // Verify ownership and get address_id + const { data: property } = await supabase + .from('properties') + .select('owner_id, address_id') + .eq('id', propertyId) + .single() + + if (!property) { + return { + success: false, + error: 'Property not found', + } + } + + if (property.owner_id !== session.user.id) { + return { + success: false, + error: 'Unauthorized', + } + } + + // Update address + const { error } = await supabase + .from('properties_addresses') + .update(addressData) + .eq('id', property.address_id) + + if (error) { + return { + success: false, + error: error.message, + } + } + + return { + success: true, + message: 'Address updated successfully', + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to update address', + } + } +} + +/** + * Migrate a local property to the cloud + * Creates a new property with the local property's data + */ +export async function migrateLocalProperty( + localProperty: { + name: string + scene_graph: any + }, +): Promise> { + try { + const session = await getSession() + + if (!session?.user) { + return { + success: false, + error: 'Not authenticated', + } + } + + const supabase = await createServerSupabaseClient() + + // Create a default address (user can edit later via settings) + const addressId = createId('address') + const { error: addressError } = await supabase.from('properties_addresses').insert({ + id: addressId, + country: 'US', + }) + + if (addressError) { + return { + success: false, + error: addressError.message, + } + } + + // Create the property + const propertyId = createId('property') + const { error: propertyError } = await supabase.from('properties').insert({ + id: propertyId, + name: localProperty.name, + owner_id: session.user.id, + address_id: addressId, + is_private: true, // Default to private + }) + + if (propertyError) { + return { + success: false, + error: propertyError.message, + } + } + + // Create the model with the scene graph + if (localProperty.scene_graph) { + const modelId = createId('model') + const { error: modelError } = await supabase.from('properties_models').insert({ + id: modelId, + property_id: propertyId, + version: 1, + scene_graph: localProperty.scene_graph, + }) + + if (modelError) { + return { + success: false, + error: modelError.message, + } + } + } + + return { + success: true, + data: { id: propertyId }, + message: 'Property migrated successfully', + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to migrate property', + } + } +} + +/** + * Delete a property + * Only the owner can delete their property + */ +export async function deleteProperty(propertyId: string): Promise { + try { + const session = await getSession() + + if (!session?.user) { + return { + success: false, + error: 'Not authenticated', + } + } + + const supabase = await createServerSupabaseClient() + + // Verify ownership + const { data: property } = await supabase + .from('properties') + .select('owner_id') + .eq('id', propertyId) + .single() + + if (!property) { + return { + success: false, + error: 'Property not found', + } + } + + if (property.owner_id !== session.user.id) { + return { + success: false, + error: 'Unauthorized', + } + } + + // Delete the property (cascade will delete related records) + const { error } = await supabase.from('properties').delete().eq('id', propertyId) + + if (error) { + return { + success: false, + error: error.message, + } + } + + return { + success: true, + message: 'Property deleted successfully', + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to delete property', + } + } +} + +/** + * Check if the current user has liked specific properties + * Returns a map of propertyId -> boolean + */ +export async function getUserPropertyLikes( + propertyIds: string[], +): Promise>> { + try { + const session = await getSession() + + if (!session?.user || propertyIds.length === 0) { + // Return empty map for unauthenticated users or no properties + return { + success: true, + data: {}, + } + } + + const supabase = await createServerSupabaseClient() + + const { data: likes, error } = await supabase + .from('property_likes') + .select('property_id') + .eq('user_id', session.user.id) + .in('property_id', propertyIds) + + if (error) { + return { + success: false, + error: error.message, + data: {}, + } + } + + // Convert array to map + const likeMap: Record = {} + propertyIds.forEach((id) => { + likeMap[id] = likes?.some((like) => like.property_id === id) || false + }) + + return { + success: true, + data: likeMap, + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch likes', + data: {}, + } + } +} + +/** + * Toggle like on a property + * Returns the new like state and updated like count + */ +export async function togglePropertyLike( + propertyId: string, +): Promise> { + try { + const session = await getSession() + + if (!session?.user) { + return { + success: false, + error: 'Not authenticated', + } + } + + const supabase = await createServerSupabaseClient() + const userId = session.user.id + + // Check if user has already liked this property + const { data: existingLike } = await supabase + .from('property_likes') + .select('id') + .eq('property_id', propertyId) + .eq('user_id', userId) + .maybeSingle() + + let liked = false + + if (existingLike) { + // Unlike - remove the like + const { error } = await supabase + .from('property_likes') + .delete() + .eq('id', existingLike.id) + + if (error) { + return { + success: false, + error: error.message, + } + } + + liked = false + } else { + // Like - add a new like + const likeId = createId('like') + const { error } = await supabase.from('property_likes').insert({ + id: likeId, + property_id: propertyId, + user_id: userId, + }) + + if (error) { + return { + success: false, + error: error.message, + } + } + + liked = true + } + + // Get updated like count + const { data: likeCount } = await supabase.rpc('get_property_like_count', { + property_id: propertyId, + }) + + // Update the property's like count cache + await supabase + .from('properties') + .update({ likes: likeCount || 0 }) + .eq('id', propertyId) + + return { + success: true, + data: { + liked, + likes: likeCount || 0, + }, + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to toggle like', + } + } +} diff --git a/apps/editor/features/community/lib/properties/store.ts b/apps/editor/features/community/lib/properties/store.ts index aef9ab69..405b4d27 100644 --- a/apps/editor/features/community/lib/properties/store.ts +++ b/apps/editor/features/community/lib/properties/store.ts @@ -54,17 +54,8 @@ export const usePropertyStore = create((set, get) => ({ isLoading: false, error: null }) - - // If no active property, auto-select the first one - if (!result.data) { - const propertiesResult = await getUserProperties() - if (propertiesResult.success && propertiesResult.data && propertiesResult.data.length > 0) { - const firstProperty = propertiesResult.data[0] - if (firstProperty) { - await get().setActiveProperty(firstProperty.id) - } - } - } + // Note: Auto-select logic removed - now using URL-based routing + // The URL parameter determines which property to load } else { set({ error: result.error || 'Failed to fetch active property', diff --git a/apps/editor/features/community/lib/properties/types.ts b/apps/editor/features/community/lib/properties/types.ts index 393f583a..2b142c43 100644 --- a/apps/editor/features/community/lib/properties/types.ts +++ b/apps/editor/features/community/lib/properties/types.ts @@ -11,6 +11,11 @@ export type Property = { address_id: string created_at: string updated_at: string + // Community features + is_private: boolean + views: number + likes: number + thumbnail_url: string | null address: { id: string street_number?: string @@ -40,4 +45,6 @@ export type CreatePropertyParams = { country?: string countryLong?: string rawJson?: Record + isPrivate?: boolean + sceneGraph?: any } diff --git a/apps/editor/lib/navigation.ts b/apps/editor/lib/navigation.ts new file mode 100644 index 00000000..dbb7b2ad --- /dev/null +++ b/apps/editor/lib/navigation.ts @@ -0,0 +1,15 @@ +/** + * Navigation helpers for property-based routing + */ + +export function getEditorUrl(propertyId: string): string { + return `/editor/${propertyId}` +} + +export function getViewerUrl(propertyId: string): string { + return `/viewer/${propertyId}` +} + +export function getHomeUrl(): string { + return '/' +} diff --git a/packages/db/src/schema/properties/properties.ts b/packages/db/src/schema/properties/properties.ts index 5e0dc416..97eafbf0 100644 --- a/packages/db/src/schema/properties/properties.ts +++ b/packages/db/src/schema/properties/properties.ts @@ -19,11 +19,19 @@ export const properties = pgTable( .references(() => users.id, { onDelete: 'set null' }), detailsJson: t.jsonb('details_json'), metadata: t.jsonb('metadata'), + // Community features + isPrivate: t.boolean('is_private').notNull().default(true), + views: t.integer('views').notNull().default(0), + likes: t.integer('likes').notNull().default(0), + thumbnailUrl: t.text('thumbnail_url'), ...timestampsColumns, }), (t) => [ index('property_address_idx').on(t.addressId), index('property_owner_idx').on(t.ownerId), + index('property_is_private_idx').on(t.isPrivate), + index('property_views_idx').on(t.views), + index('property_likes_idx').on(t.likes), ], ).enableRLS() diff --git a/packages/db/supabase/migrations/20240216000001_add_community_fields.sql b/packages/db/supabase/migrations/20240216000001_add_community_fields.sql new file mode 100644 index 00000000..a1ef8f7a --- /dev/null +++ b/packages/db/supabase/migrations/20240216000001_add_community_fields.sql @@ -0,0 +1,13 @@ +-- Add community features to properties table +ALTER TABLE properties ADD COLUMN IF NOT EXISTS is_private BOOLEAN NOT NULL DEFAULT true; +ALTER TABLE properties ADD COLUMN IF NOT EXISTS views INTEGER NOT NULL DEFAULT 0; +ALTER TABLE properties ADD COLUMN IF NOT EXISTS likes INTEGER NOT NULL DEFAULT 0; +ALTER TABLE properties ADD COLUMN IF NOT EXISTS thumbnail_url TEXT; + +-- Create indexes for community queries +CREATE INDEX IF NOT EXISTS idx_properties_is_private ON properties(is_private) WHERE is_private = false; +CREATE INDEX IF NOT EXISTS idx_properties_views ON properties(views DESC); +CREATE INDEX IF NOT EXISTS idx_properties_likes ON properties(likes DESC); + +-- Set existing properties to private (user opt-in to share) +UPDATE properties SET is_private = true WHERE is_private IS NULL; diff --git a/packages/db/supabase/migrations/20240216000002_update_rls_for_public_properties.sql b/packages/db/supabase/migrations/20240216000002_update_rls_for_public_properties.sql new file mode 100644 index 00000000..7ef2c0e7 --- /dev/null +++ b/packages/db/supabase/migrations/20240216000002_update_rls_for_public_properties.sql @@ -0,0 +1,78 @@ +-- Drop existing RLS policies for properties +DROP POLICY IF EXISTS "Users can view their own properties" ON properties; +DROP POLICY IF EXISTS "Users can insert their own properties" ON properties; +DROP POLICY IF EXISTS "Users can update their own properties" ON properties; +DROP POLICY IF EXISTS "Users can delete their own properties" ON properties; + +-- New RLS policy: Users can view their own properties OR public properties +CREATE POLICY "Users can view own or public properties" + ON properties FOR SELECT + USING ( + owner_id = current_setting('app.user_id', true)::TEXT + OR is_private = false + OR owner_id IS NULL + ); + +-- Keep other policies the same (insert/update/delete still require ownership) +CREATE POLICY "Users can insert their own properties" + ON properties FOR INSERT + WITH CHECK (owner_id = current_setting('app.user_id', true)::TEXT OR owner_id IS NULL); + +CREATE POLICY "Users can update their own properties" + ON properties FOR UPDATE + USING (owner_id = current_setting('app.user_id', true)::TEXT OR owner_id IS NULL); + +CREATE POLICY "Users can delete their own properties" + ON properties FOR DELETE + USING (owner_id = current_setting('app.user_id', true)::TEXT OR owner_id IS NULL); + +-- Drop existing RLS policies for models +DROP POLICY IF EXISTS "Users can view models of their own properties" ON properties_models; +DROP POLICY IF EXISTS "Users can insert models for their own properties" ON properties_models; +DROP POLICY IF EXISTS "Users can update models of their own properties" ON properties_models; +DROP POLICY IF EXISTS "Users can delete models of their own properties" ON properties_models; + +-- Update models policy: Users can view models of their own properties OR public properties +CREATE POLICY "Users can view models of own or public properties" + ON properties_models FOR SELECT + USING ( + EXISTS ( + SELECT 1 FROM properties + WHERE properties.id = properties_models.property_id + AND ( + properties.owner_id = current_setting('app.user_id', true)::TEXT + OR properties.is_private = false + ) + ) + ); + +-- Keep other model policies the same +CREATE POLICY "Users can insert models for their own properties" + ON properties_models FOR INSERT + WITH CHECK ( + EXISTS ( + SELECT 1 FROM properties + WHERE properties.id = properties_models.property_id + AND properties.owner_id = current_setting('app.user_id', true)::TEXT + ) + ); + +CREATE POLICY "Users can update models of their own properties" + ON properties_models FOR UPDATE + USING ( + EXISTS ( + SELECT 1 FROM properties + WHERE properties.id = properties_models.property_id + AND properties.owner_id = current_setting('app.user_id', true)::TEXT + ) + ); + +CREATE POLICY "Users can delete models of their own properties" + ON properties_models FOR DELETE + USING ( + EXISTS ( + SELECT 1 FROM properties + WHERE properties.id = properties_models.property_id + AND properties.owner_id = current_setting('app.user_id', true)::TEXT + ) + ); diff --git a/packages/db/supabase/migrations/20240216000003_create_view_increment_function.sql b/packages/db/supabase/migrations/20240216000003_create_view_increment_function.sql new file mode 100644 index 00000000..73fccec2 --- /dev/null +++ b/packages/db/supabase/migrations/20240216000003_create_view_increment_function.sql @@ -0,0 +1,9 @@ +-- Function to atomically increment view count +CREATE OR REPLACE FUNCTION increment_property_views(property_id TEXT) +RETURNS void AS $$ +BEGIN + UPDATE properties + SET views = views + 1 + WHERE id = property_id; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; diff --git a/packages/db/supabase/migrations/20240216000004_create_property_likes.sql b/packages/db/supabase/migrations/20240216000004_create_property_likes.sql new file mode 100644 index 00000000..56fec51e --- /dev/null +++ b/packages/db/supabase/migrations/20240216000004_create_property_likes.sql @@ -0,0 +1,41 @@ +-- Create property_likes table to track user likes +CREATE TABLE IF NOT EXISTS property_likes ( + id TEXT PRIMARY KEY, + property_id TEXT NOT NULL REFERENCES properties(id) ON DELETE CASCADE, + user_id TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Ensure a user can only like a property once + UNIQUE(property_id, user_id) +); + +-- Enable RLS +ALTER TABLE property_likes ENABLE ROW LEVEL SECURITY; + +-- RLS Policies +-- Users can view all likes (to see like counts) +CREATE POLICY "Anyone can view likes" + ON property_likes FOR SELECT + USING (true); + +-- Users can insert their own likes +CREATE POLICY "Users can create their own likes" + ON property_likes FOR INSERT + WITH CHECK (user_id = current_setting('app.user_id', true)::TEXT); + +-- Users can delete their own likes +CREATE POLICY "Users can delete their own likes" + ON property_likes FOR DELETE + USING (user_id = current_setting('app.user_id', true)::TEXT); + +-- Create index for efficient querying +CREATE INDEX IF NOT EXISTS idx_property_likes_property_id ON property_likes(property_id); +CREATE INDEX IF NOT EXISTS idx_property_likes_user_id ON property_likes(user_id); + +-- Function to get like count for a property +CREATE OR REPLACE FUNCTION get_property_like_count(property_id TEXT) +RETURNS INTEGER AS $$ +BEGIN + RETURN (SELECT COUNT(*)::INTEGER FROM property_likes WHERE property_likes.property_id = $1); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER STABLE;