isolate cloud-sync feature
This commit is contained in:
@@ -7,7 +7,7 @@ import useEditor from '@/store/use-editor'
|
||||
import { ZoneSystem } from '../systems/zone/zone-system'
|
||||
import { ToolManager } from '../tools/tool-manager'
|
||||
import { ActionMenu } from '../ui/action-menu'
|
||||
import { CloudSaveButton } from '../ui/cloud-save-button'
|
||||
import { CloudSaveButton } from '@/features/cloud-sync/components/cloud-save-button'
|
||||
import { PanelManager } from '../ui/panels/panel-manager'
|
||||
import { SidebarProvider } from '../ui/primitives/sidebar'
|
||||
import { AppSidebar } from '../ui/sidebar/app-sidebar'
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
# Cloud Sync Feature
|
||||
|
||||
This directory contains the **optional** cloud synchronization and authentication features for the Pascal Editor. This feature is specific to the Pascal platform and can be safely removed if you're using the editor standalone.
|
||||
|
||||
## What This Does
|
||||
|
||||
The cloud sync feature provides:
|
||||
|
||||
- **Authentication** - Sign in with magic link via Better Auth
|
||||
- **Property Management** - Create and manage properties with Google Maps address search
|
||||
- **Database Sync** - Save and load editor state from a PostgreSQL database via Supabase
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
features/cloud-sync/
|
||||
├── lib/
|
||||
│ ├── auth/
|
||||
│ │ ├── client.ts # Better Auth client configuration
|
||||
│ │ ├── server.ts # Server-side session handling
|
||||
│ │ └── hooks.ts # useAuth React hook
|
||||
│ ├── properties/
|
||||
│ │ ├── actions.ts # Server actions for CRUD operations
|
||||
│ │ ├── types.ts # TypeScript types for properties
|
||||
│ │ └── hooks.ts # useProperties and useActiveProperty hooks
|
||||
│ ├── database/
|
||||
│ │ └── server.ts # Supabase server client with service role
|
||||
│ └── utils/
|
||||
│ └── id-generator.ts # nanoid-based ID generation (matches backend)
|
||||
├── components/
|
||||
│ ├── cloud-save-button.tsx # Main UI entry point (top-right button)
|
||||
│ ├── sign-in-dialog.tsx # Magic link sign-in dialog
|
||||
│ ├── profile-dropdown.tsx # User profile menu
|
||||
│ ├── property-dropdown.tsx # Property selector dropdown
|
||||
│ ├── new-property-dialog.tsx # Create new property dialog
|
||||
│ └── google-address-search.tsx # Google Maps autocomplete
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
### Authentication Flow
|
||||
1. User clicks "Save to cloud" button
|
||||
2. Signs in with magic link (email-based, no password)
|
||||
3. Better Auth session is stored in cookies
|
||||
4. Server actions validate session by calling the monorepo backend
|
||||
|
||||
### Property Management
|
||||
1. User creates a property with a real-world address (Google Maps)
|
||||
2. Address and property are saved to PostgreSQL via Supabase
|
||||
3. Properties are associated with the authenticated user
|
||||
4. User can switch between properties
|
||||
|
||||
### Database Integration
|
||||
- Uses Supabase (PostgreSQL) for database access
|
||||
- Server actions use service role key to bypass RLS
|
||||
- Permissions enforced by filtering on `owner_id`
|
||||
- Table names: `properties`, `properties_addresses`, `auth_sessions`
|
||||
|
||||
## Required Environment Variables
|
||||
|
||||
```bash
|
||||
# Backend API URL (Pascal monorepo - for better-auth only)
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3000
|
||||
|
||||
# Supabase Configuration
|
||||
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key_here
|
||||
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key_here
|
||||
|
||||
# Google Maps API Key (for address search)
|
||||
NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=your_google_maps_key_here
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
The cloud sync feature requires these packages:
|
||||
|
||||
```json
|
||||
{
|
||||
"better-auth": "^1.4.18",
|
||||
"@supabase/supabase-js": "^2.95.3",
|
||||
"@react-google-maps/api": "^2.20.8",
|
||||
"nanoid": "^5.1.6"
|
||||
}
|
||||
```
|
||||
|
||||
## How to Remove (For Open Source Users)
|
||||
|
||||
If you want to use the editor without cloud sync:
|
||||
|
||||
### 1. Delete this directory
|
||||
```bash
|
||||
rm -rf features/cloud-sync
|
||||
```
|
||||
|
||||
### 2. Remove the CloudSaveButton from the editor
|
||||
Edit `components/editor/index.tsx`:
|
||||
```diff
|
||||
- import { CloudSaveButton } from '@/features/cloud-sync/components/cloud-save-button'
|
||||
|
||||
export default function Editor() {
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<ActionMenu />
|
||||
<PanelManager />
|
||||
- <CloudSaveButton />
|
||||
```
|
||||
|
||||
### 3. Remove dependencies (optional)
|
||||
Edit `package.json`:
|
||||
```diff
|
||||
- "better-auth": "^1.4.18",
|
||||
- "@supabase/supabase-js": "^2.95.3",
|
||||
- "@react-google-maps/api": "^2.20.8",
|
||||
- "nanoid": "^5.1.6"
|
||||
```
|
||||
|
||||
### 4. Remove environment variables
|
||||
Delete from `.env.local` and `.env.example`:
|
||||
```diff
|
||||
- NEXT_PUBLIC_API_URL=...
|
||||
- NEXT_PUBLIC_SUPABASE_URL=...
|
||||
- NEXT_PUBLIC_SUPABASE_ANON_KEY=...
|
||||
- SUPABASE_SERVICE_ROLE_KEY=...
|
||||
- NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=...
|
||||
```
|
||||
|
||||
That's it! The editor will work as a standalone application without any cloud features.
|
||||
|
||||
## Backend Requirements
|
||||
|
||||
This feature requires the Pascal monorepo backend running with:
|
||||
- Better Auth configured with magic link support
|
||||
- PostgreSQL database with the following tables:
|
||||
- `properties` - Property records
|
||||
- `properties_addresses` - Property addresses
|
||||
- `auth_sessions` - Better Auth sessions
|
||||
- `auth_users` - Better Auth users
|
||||
- Supabase local instance for database access
|
||||
|
||||
## Development
|
||||
|
||||
To work on this feature:
|
||||
|
||||
1. Ensure the monorepo backend is running on port 3000
|
||||
2. Ensure Supabase local is running on port 54321
|
||||
3. Configure all environment variables
|
||||
4. Run the editor: `bun dev`
|
||||
|
||||
The editor will be available at `http://localhost:3002` (different port to avoid conflicts with the monorepo).
|
||||
|
||||
## Notes
|
||||
|
||||
- This feature uses **server actions** (Next.js App Router) for all database operations
|
||||
- Authentication is handled by the monorepo backend via Better Auth
|
||||
- The editor queries the database directly using Supabase with service role key
|
||||
- IDs are generated using nanoid with custom alphabet to match the backend schema
|
||||
- All table names match the monorepo's database schema exactly
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Cloud } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useAuth } from '@/lib/auth/use-auth'
|
||||
import { useAuth } from '../lib/auth/hooks'
|
||||
import { ProfileDropdown } from './profile-dropdown'
|
||||
import { PropertyDropdown } from './property-dropdown'
|
||||
import { SignInDialog } from './sign-in-dialog'
|
||||
@@ -0,0 +1,113 @@
|
||||
'use client'
|
||||
|
||||
import { Autocomplete, LoadScript } from '@react-google-maps/api'
|
||||
import { MapPin } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
const libraries: ('places')[] = ['places']
|
||||
|
||||
interface AddressComponents {
|
||||
streetNumber?: string
|
||||
route?: string
|
||||
city?: string
|
||||
state?: string
|
||||
postalCode?: string
|
||||
country?: string
|
||||
center: [number, number]
|
||||
formattedAddress: string
|
||||
}
|
||||
|
||||
interface GoogleAddressSearchProps {
|
||||
onAddressSelect: (address: AddressComponents) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function GoogleAddressSearch({ onAddressSelect, disabled }: GoogleAddressSearchProps) {
|
||||
const [autocomplete, setAutocomplete] = useState<google.maps.places.Autocomplete | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY
|
||||
|
||||
// Fix Google Maps autocomplete dropdown z-index and pointer events to work with dialog
|
||||
useEffect(() => {
|
||||
const style = document.createElement('style')
|
||||
style.textContent = `
|
||||
.pac-container {
|
||||
z-index: 9999 !important;
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
`
|
||||
document.head.appendChild(style)
|
||||
return () => {
|
||||
document.head.removeChild(style)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!apiKey) {
|
||||
return (
|
||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm">
|
||||
<p className="font-medium">Google Maps API Key Missing</p>
|
||||
<p className="mt-1 text-xs">
|
||||
Add NEXT_PUBLIC_GOOGLE_MAPS_API_KEY to your .env.local file
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const onLoad = (autocompleteInstance: google.maps.places.Autocomplete) => {
|
||||
setAutocomplete(autocompleteInstance)
|
||||
}
|
||||
|
||||
const onPlaceChanged = () => {
|
||||
if (!autocomplete) return
|
||||
|
||||
const place = autocomplete.getPlace()
|
||||
if (!place.geometry?.location || !place.address_components) return
|
||||
|
||||
const components: AddressComponents = {
|
||||
center: [place.geometry.location.lng(), place.geometry.location.lat()],
|
||||
formattedAddress: place.formatted_address || '',
|
||||
}
|
||||
|
||||
// Parse address components
|
||||
for (const component of place.address_components) {
|
||||
const types = component.types
|
||||
|
||||
if (types.includes('street_number')) {
|
||||
components.streetNumber = component.long_name
|
||||
} else if (types.includes('route')) {
|
||||
components.route = component.long_name
|
||||
} else if (types.includes('locality')) {
|
||||
components.city = component.long_name
|
||||
} else if (types.includes('administrative_area_level_1')) {
|
||||
components.state = component.short_name
|
||||
} else if (types.includes('postal_code')) {
|
||||
components.postalCode = component.long_name
|
||||
} else if (types.includes('country')) {
|
||||
components.country = component.short_name
|
||||
}
|
||||
}
|
||||
|
||||
onAddressSelect(components)
|
||||
}
|
||||
|
||||
return (
|
||||
<LoadScript googleMapsApiKey={apiKey} libraries={libraries}>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 font-medium text-sm">
|
||||
<MapPin className="h-4 w-4" />
|
||||
Property Address
|
||||
</label>
|
||||
<Autocomplete onLoad={onLoad} onPlaceChanged={onPlaceChanged}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={disabled}
|
||||
placeholder="Search for an address..."
|
||||
type="text"
|
||||
/>
|
||||
</Autocomplete>
|
||||
</div>
|
||||
</LoadScript>
|
||||
)
|
||||
}
|
||||
+53
-43
@@ -2,8 +2,9 @@
|
||||
|
||||
import { X } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { createProperty } from '@/lib/properties/actions'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './primitives/dialog'
|
||||
import { createProperty } from '../lib/properties/actions'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/primitives/dialog'
|
||||
import { GoogleAddressSearch } from './google-address-search'
|
||||
|
||||
interface NewPropertyDialogProps {
|
||||
open: boolean
|
||||
@@ -11,37 +12,57 @@ interface NewPropertyDialogProps {
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
interface AddressData {
|
||||
streetNumber?: string
|
||||
route?: string
|
||||
city?: string
|
||||
state?: string
|
||||
postalCode?: string
|
||||
country?: string
|
||||
center: [number, number]
|
||||
formattedAddress: string
|
||||
}
|
||||
|
||||
/**
|
||||
* NewPropertyDialog - Dialog for creating a new property
|
||||
*
|
||||
* TODO: Add Google Maps address search integration
|
||||
* TODO: Add address parsing and validation
|
||||
* TODO: Add duplicate checking before creation
|
||||
* NewPropertyDialog - Dialog for creating a new property with Google Maps address search
|
||||
*/
|
||||
export function NewPropertyDialog({ open, onOpenChange, onSuccess }: NewPropertyDialogProps) {
|
||||
const [name, setName] = useState('')
|
||||
const [address, setAddress] = useState<AddressData | null>(null)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleAddressSelect = (addressData: AddressData) => {
|
||||
setAddress(addressData)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
|
||||
if (!address) {
|
||||
setError('Please select an address')
|
||||
return
|
||||
}
|
||||
|
||||
setIsCreating(true)
|
||||
|
||||
try {
|
||||
// TODO: Replace with actual address data from Google Maps
|
||||
// Use formatted address as property name (like monorepo)
|
||||
const result = await createProperty({
|
||||
name,
|
||||
center: [0, 0], // TODO: Get from Google Maps
|
||||
city: '',
|
||||
state: '',
|
||||
postalCode: '',
|
||||
country: 'US',
|
||||
name: address.formattedAddress,
|
||||
center: address.center,
|
||||
streetNumber: address.streetNumber,
|
||||
route: address.route,
|
||||
city: address.city,
|
||||
state: address.state,
|
||||
postalCode: address.postalCode,
|
||||
country: address.country || 'US',
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
onOpenChange(false)
|
||||
setName('')
|
||||
setAddress(null)
|
||||
onSuccess?.()
|
||||
} else {
|
||||
setError(result.error || 'Failed to create property')
|
||||
@@ -56,14 +77,17 @@ export function NewPropertyDialog({ open, onOpenChange, onSuccess }: NewProperty
|
||||
const handleClose = () => {
|
||||
if (!isCreating) {
|
||||
onOpenChange(false)
|
||||
setName('')
|
||||
setAddress(null)
|
||||
setError(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<Dialog open={open} onOpenChange={handleClose} modal={false}>
|
||||
<DialogContent
|
||||
className="sm:max-w-[500px]"
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add New Property</DialogTitle>
|
||||
<button
|
||||
@@ -77,30 +101,16 @@ export function NewPropertyDialog({ open, onOpenChange, onSuccess }: NewProperty
|
||||
</DialogHeader>
|
||||
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
{/* TODO: Add Google Maps address search component */}
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium text-sm" htmlFor="property-name">
|
||||
Property Name
|
||||
</label>
|
||||
<input
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={isCreating}
|
||||
id="property-name"
|
||||
placeholder="Enter property name"
|
||||
required
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{/* Google Maps Address Search */}
|
||||
<GoogleAddressSearch onAddressSelect={handleAddressSelect} disabled={isCreating} />
|
||||
|
||||
{/* TODO: Add address fields with Google Maps autocomplete */}
|
||||
<div className="rounded-md border border-border bg-muted/30 p-4 text-muted-foreground text-sm">
|
||||
<p>TODO: Google Maps address search will be integrated here</p>
|
||||
<p className="mt-2 text-xs">
|
||||
For now, property creation is not fully functional. This is a placeholder for the UI.
|
||||
</p>
|
||||
</div>
|
||||
{/* Show selected address */}
|
||||
{address && (
|
||||
<div className="rounded-md border border-border bg-muted/30 p-3 text-sm">
|
||||
<p className="font-medium">Selected Address:</p>
|
||||
<p className="mt-1 text-muted-foreground">{address.formattedAddress}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm">
|
||||
@@ -119,7 +129,7 @@ export function NewPropertyDialog({ open, onOpenChange, onSuccess }: NewProperty
|
||||
</button>
|
||||
<button
|
||||
className="rounded-md bg-primary px-4 py-2 text-primary-foreground text-sm transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
disabled={isCreating || !name}
|
||||
disabled={isCreating || !address}
|
||||
type="submit"
|
||||
>
|
||||
{isCreating ? 'Creating...' : 'Create Property'}
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useAuth } from '@/lib/auth/use-auth'
|
||||
import { useAuth } from '../lib/auth/hooks'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from './primitives/dropdown-menu'
|
||||
} from '@/components/ui/primitives/dropdown-menu'
|
||||
|
||||
function getInitials(name: string): string {
|
||||
return name
|
||||
+2
-2
@@ -2,14 +2,14 @@
|
||||
|
||||
import { Check, ChevronDown, Home, Plus } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useActiveProperty, useProperties } from '@/lib/properties/use-properties'
|
||||
import { useActiveProperty, useProperties } from '../lib/properties/hooks'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from './primitives/dropdown-menu'
|
||||
} from '@/components/ui/primitives/dropdown-menu'
|
||||
import { NewPropertyDialog } from './new-property-dialog'
|
||||
|
||||
/**
|
||||
+2
-2
@@ -2,8 +2,8 @@
|
||||
|
||||
import { Mail, X } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { authClient } from '@/lib/auth/auth-client'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './primitives/dialog'
|
||||
import { authClient } from '../lib/auth/client'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/primitives/dialog'
|
||||
|
||||
interface SignInDialogProps {
|
||||
open: boolean
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { authClient } from './auth-client'
|
||||
import { authClient } from './client'
|
||||
|
||||
/**
|
||||
* Hook to access authentication state using better-auth
|
||||
@@ -0,0 +1,48 @@
|
||||
import { headers as nextHeaders } from 'next/headers'
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'
|
||||
|
||||
/**
|
||||
* Get the current session from Better Auth backend (server-side)
|
||||
*/
|
||||
export async function getSession() {
|
||||
try {
|
||||
const headersList = await nextHeaders()
|
||||
|
||||
// Make authenticated request to the auth backend to get session
|
||||
const response = await fetch(`${API_URL}/api/auth/get-session`, {
|
||||
headers: {
|
||||
cookie: headersList.get('cookie') || '',
|
||||
},
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Better Auth returns the session data directly
|
||||
if (data?.user && data?.session) {
|
||||
return {
|
||||
user: data.user,
|
||||
session: data.session,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (error) {
|
||||
console.error('Failed to get session:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current user from the session
|
||||
*/
|
||||
export async function getUser() {
|
||||
const session = await getSession()
|
||||
return session?.user ?? null
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
const supabaseServiceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
|
||||
if (!supabaseUrl || !supabaseServiceRoleKey) {
|
||||
throw new Error(
|
||||
'Missing Supabase environment variables. Add NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY to your .env.local file.',
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Supabase client for server-side use with service role key
|
||||
* This bypasses RLS and allows server actions to query the database directly
|
||||
* Authentication is handled by Better Auth, permissions enforced by filtering on user_id
|
||||
*/
|
||||
export async function createServerSupabaseClient() {
|
||||
const client = createClient(supabaseUrl, supabaseServiceRoleKey, {
|
||||
auth: {
|
||||
persistSession: false,
|
||||
autoRefreshToken: false,
|
||||
},
|
||||
})
|
||||
|
||||
return client
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* Property actions - Server actions for property management
|
||||
* Uses Better Auth session + Supabase to query the same database as the monorepo
|
||||
*/
|
||||
|
||||
'use server'
|
||||
|
||||
import { createServerSupabaseClient } from '../database/server'
|
||||
import { getSession } from '../auth/server'
|
||||
import { createId } from '../utils/id-generator'
|
||||
import type { CreatePropertyParams, Property } from './types'
|
||||
|
||||
export type ActionResult<T = unknown> = {
|
||||
success: boolean
|
||||
data?: T
|
||||
error?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all properties for the current user
|
||||
*/
|
||||
export async function getUserProperties(): Promise<ActionResult<Property[]>> {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Not authenticated',
|
||||
data: [],
|
||||
}
|
||||
}
|
||||
|
||||
const supabase = await createServerSupabaseClient()
|
||||
|
||||
// Query properties table with address relation
|
||||
const { data, error } = await supabase
|
||||
.from('properties')
|
||||
.select(`
|
||||
*,
|
||||
address:properties_addresses(*)
|
||||
`)
|
||||
.eq('owner_id', session.user.id)
|
||||
|
||||
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 properties',
|
||||
data: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active property for the current session
|
||||
*/
|
||||
export async function getActiveProperty(): Promise<ActionResult<Property | null>> {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Not authenticated',
|
||||
data: null,
|
||||
}
|
||||
}
|
||||
|
||||
const supabase = await createServerSupabaseClient()
|
||||
|
||||
// Get session's active_property_id from sessions table
|
||||
const { data: sessionData, error: sessionError } = await supabase
|
||||
.from('auth_sessions')
|
||||
.select('active_property_id')
|
||||
.eq('user_id', session.user.id)
|
||||
.single()
|
||||
|
||||
if (sessionError || !sessionData?.active_property_id) {
|
||||
return {
|
||||
success: true,
|
||||
data: null,
|
||||
}
|
||||
}
|
||||
|
||||
// Get the property with address
|
||||
const { data, error } = await supabase
|
||||
.from('properties')
|
||||
.select(`
|
||||
*,
|
||||
address:properties_addresses(*)
|
||||
`)
|
||||
.eq('id', sessionData.active_property_id)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
data: null,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: data as Property,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch active property',
|
||||
data: null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the active property for the current session
|
||||
*/
|
||||
export async function setActiveProperty(propertyId: string | null): Promise<ActionResult> {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Not authenticated',
|
||||
}
|
||||
}
|
||||
|
||||
const supabase = await createServerSupabaseClient()
|
||||
|
||||
// Update session's active_property_id
|
||||
const { error } = await supabase
|
||||
.from('auth_sessions')
|
||||
.update({ active_property_id: propertyId })
|
||||
.eq('user_id', session.user.id)
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: propertyId ? 'Active property updated' : 'Active property cleared',
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to set active property',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new property
|
||||
*/
|
||||
export async function createProperty(params: CreatePropertyParams): Promise<ActionResult<Property>> {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Not authenticated',
|
||||
}
|
||||
}
|
||||
|
||||
const supabase = await createServerSupabaseClient()
|
||||
|
||||
// Generate IDs for address and property
|
||||
const addressId = createId('address')
|
||||
const propertyId = createId('property')
|
||||
|
||||
// First, create the address
|
||||
const { data: address, error: addressError } = await supabase
|
||||
.from('properties_addresses')
|
||||
.insert({
|
||||
id: addressId,
|
||||
street_number: params.streetNumber,
|
||||
route: params.route,
|
||||
city: params.city || '',
|
||||
state: params.state || '',
|
||||
postal_code: params.postalCode || '',
|
||||
country: params.country || 'US',
|
||||
latitude: params.center[1].toString(),
|
||||
longitude: params.center[0].toString(),
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (addressError || !address) {
|
||||
return {
|
||||
success: false,
|
||||
error: addressError?.message || 'Failed to create address',
|
||||
}
|
||||
}
|
||||
|
||||
// Create the property
|
||||
const { data, error } = await supabase
|
||||
.from('properties')
|
||||
.insert({
|
||||
id: propertyId,
|
||||
name: params.name,
|
||||
address_id: address.id,
|
||||
owner_id: session.user.id,
|
||||
details_json: {
|
||||
coordinates: params.center,
|
||||
createdFrom: 'editor-app',
|
||||
},
|
||||
})
|
||||
.select(`
|
||||
*,
|
||||
address:properties_addresses(*)
|
||||
`)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: data as Property,
|
||||
message: 'Property created successfully',
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to create property',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a property with the given address already exists
|
||||
*/
|
||||
export async function checkPropertyDuplicate(params: {
|
||||
streetNumber?: string
|
||||
route?: string
|
||||
city?: string
|
||||
state?: string
|
||||
postalCode?: string
|
||||
}): Promise<
|
||||
ActionResult<{
|
||||
isDuplicate: boolean
|
||||
isUserProperty?: boolean
|
||||
existingProperty?: Property
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Not authenticated',
|
||||
}
|
||||
}
|
||||
|
||||
const supabase = await createServerSupabaseClient()
|
||||
|
||||
// Query for existing property with matching address
|
||||
const { data, error } = await supabase
|
||||
.from('properties')
|
||||
.select(`
|
||||
*,
|
||||
address:properties_addresses!inner(*)
|
||||
`)
|
||||
.eq('address.street_number', params.streetNumber || '')
|
||||
.eq('address.route', params.route || '')
|
||||
.eq('address.city', params.city || '')
|
||||
.eq('address.state', params.state || '')
|
||||
.eq('address.postal_code', params.postalCode || '')
|
||||
.limit(1)
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
}
|
||||
}
|
||||
|
||||
if (data && data.length > 0) {
|
||||
const existingProperty = data[0] as Property
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
isDuplicate: true,
|
||||
isUserProperty: existingProperty.ownerId === session.user.id,
|
||||
existingProperty,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
isDuplicate: false,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to check for duplicates',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { customAlphabet } from 'nanoid'
|
||||
|
||||
const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
|
||||
const nanoid = customAlphabet(alphabet, 16)
|
||||
|
||||
/**
|
||||
* Generate a unique ID with optional prefix (matches monorepo implementation)
|
||||
* @example createId('user') => 'user_Abc123...'
|
||||
*/
|
||||
export const createId = (prefix?: string) => {
|
||||
const id = nanoid()
|
||||
return prefix ? `${prefix}_${id}` : id
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
/**
|
||||
* Property actions - API client for property management
|
||||
* Makes HTTP requests to the Pascal monorepo backend
|
||||
*/
|
||||
|
||||
import type { CreatePropertyParams, Property } from './types'
|
||||
|
||||
export type ActionResult<T = unknown> = {
|
||||
success: boolean
|
||||
data?: T
|
||||
error?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the backend API URL
|
||||
*/
|
||||
function getBackendURL(): string {
|
||||
if (typeof process !== 'undefined' && process.env.NEXT_PUBLIC_API_URL) {
|
||||
return process.env.NEXT_PUBLIC_API_URL
|
||||
}
|
||||
if (typeof window !== 'undefined' && window.location.hostname === 'localhost') {
|
||||
return 'http://localhost:3000'
|
||||
}
|
||||
return typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000'
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all properties for the current user
|
||||
*/
|
||||
export async function getUserProperties(): Promise<ActionResult<Property[]>> {
|
||||
try {
|
||||
const response = await fetch(`${getBackendURL()}/api/properties`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text()
|
||||
return {
|
||||
success: false,
|
||||
error: error || 'Failed to fetch properties',
|
||||
data: [],
|
||||
}
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
return {
|
||||
success: true,
|
||||
data: result.data || [],
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch properties',
|
||||
data: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active property for the current session
|
||||
*/
|
||||
export async function getActiveProperty(): Promise<ActionResult<Property | null>> {
|
||||
try {
|
||||
const response = await fetch(`${getBackendURL()}/api/properties/active`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text()
|
||||
return {
|
||||
success: false,
|
||||
error: error || 'Failed to fetch active property',
|
||||
data: null,
|
||||
}
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
return {
|
||||
success: true,
|
||||
data: result.data || null,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch active property',
|
||||
data: null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the active property for the current session
|
||||
*/
|
||||
export async function setActiveProperty(propertyId: string | null): Promise<ActionResult> {
|
||||
try {
|
||||
const response = await fetch(`${getBackendURL()}/api/properties/active`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ propertyId }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text()
|
||||
return {
|
||||
success: false,
|
||||
error: error || 'Failed to set active property',
|
||||
}
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
return {
|
||||
success: true,
|
||||
message: result.message || (propertyId ? 'Active property updated' : 'Active property cleared'),
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to set active property',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a property with the given address already exists
|
||||
*/
|
||||
export async function checkPropertyDuplicate(params: {
|
||||
streetNumber?: string
|
||||
route?: string
|
||||
city?: string
|
||||
state?: string
|
||||
postalCode?: string
|
||||
}): Promise<
|
||||
ActionResult<{
|
||||
isDuplicate: boolean
|
||||
isUserProperty?: boolean
|
||||
existingProperty?: Property
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
const response = await fetch(`${getBackendURL()}/api/properties/check-duplicate`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text()
|
||||
return {
|
||||
success: false,
|
||||
error: error || 'Failed to check for duplicates',
|
||||
}
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
return {
|
||||
success: true,
|
||||
data: result.data || { isDuplicate: false },
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to check for duplicates',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new property
|
||||
*/
|
||||
export async function createProperty(params: CreatePropertyParams): Promise<ActionResult<Property>> {
|
||||
try {
|
||||
const response = await fetch(`${getBackendURL()}/api/properties`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text()
|
||||
return {
|
||||
success: false,
|
||||
error: error || 'Failed to create property',
|
||||
}
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
message: result.message || 'Property created successfully',
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to create property',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,8 +25,10 @@
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@react-google-maps/api": "^2.20.8",
|
||||
"@react-three/uikit-lucide": "^1.0.60",
|
||||
"@repo/ui": "*",
|
||||
"@supabase/supabase-js": "^2.95.3",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/three": "^0.182.0",
|
||||
"better-auth": "^1.4.18",
|
||||
@@ -34,6 +36,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"motion": "^12.26.2",
|
||||
"nanoid": "^5.1.6",
|
||||
"next": "16.1.0",
|
||||
"postcss": "^8.5.6",
|
||||
"react": "^19.2.0",
|
||||
|
||||
Reference in New Issue
Block a user