diff --git a/sql/add-indexes.sql b/sql/add-indexes.sql new file mode 100644 index 0000000..323ca3b --- /dev/null +++ b/sql/add-indexes.sql @@ -0,0 +1,51 @@ +-- Database Index Optimization (fixed) +-- Run with: psql -U lahuasca -d lahuasca -f add-indexes.sql + +-- Images table indexes (most queried) +CREATE INDEX IF NOT EXISTS idx_images_room_id ON images(room_id); +CREATE INDEX IF NOT EXISTS idx_images_category ON images(category); +CREATE INDEX IF NOT EXISTS idx_images_location_target ON images(location_target); +CREATE INDEX IF NOT EXISTS idx_images_uploaded_at ON images(uploaded_at DESC); + +-- Room amenity links (join table) +CREATE INDEX IF NOT EXISTS idx_room_amenity_links_amenity_id ON room_amenity_links(amenity_id); + +-- Orders table +CREATE INDEX IF NOT EXISTS idx_orders_user_id ON orders(user_id); +CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status); +CREATE INDEX IF NOT EXISTS idx_orders_created_at ON orders(created_at DESC); + +-- Room reservations +CREATE INDEX IF NOT EXISTS idx_room_reservations_room_id ON room_reservations(room_id); +CREATE INDEX IF NOT EXISTS idx_room_reservations_dates ON room_reservations(check_in, check_out); +CREATE INDEX IF NOT EXISTS idx_room_reservations_user_id ON room_reservations(user_id); + +-- Restaurant reservations +CREATE INDEX IF NOT EXISTS idx_reservations_date ON reservations(date); +CREATE INDEX IF NOT EXISTS idx_reservations_user_id ON reservations(user_id); + +-- Social event reservations +CREATE INDEX IF NOT EXISTS idx_social_event_reservations_event_id ON social_event_reservations(social_event_id); + +-- Contact messages +CREATE INDEX IF NOT EXISTS idx_contact_messages_created_at ON contact_messages(created_at DESC); + +-- Room comments +CREATE INDEX IF NOT EXISTS idx_room_comments_room_id ON room_comments(room_id); +CREATE INDEX IF NOT EXISTS idx_room_comments_created_at ON room_comments(created_at DESC); + +-- Messages (chat) +CREATE INDEX IF NOT EXISTS idx_messages_room ON messages(room_id); +CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at DESC); + +-- Enable TOAST compression for large TEXT columns in images +ALTER TABLE images ALTER COLUMN data SET STORAGE EXTERNAL; +ALTER TABLE images ALTER COLUMN thumbnail SET STORAGE EXTERNAL; +ALTER TABLE images ALTER COLUMN medium SET STORAGE EXTERNAL; + +-- Vacuum analyze to update statistics +ANALYZE images; +ANALYZE rooms; +ANALYZE orders; +ANALYZE room_reservations; +ANALYZE reservations; \ No newline at end of file diff --git a/sql/migrate-room-photos.sql b/sql/migrate-room-photos.sql new file mode 100644 index 0000000..43e3c9c --- /dev/null +++ b/sql/migrate-room-photos.sql @@ -0,0 +1,76 @@ +-- Migration: Move room photos from rooms.photos to images table +-- Run with: psql -U lahuasca -d lahuasca -f migrate-room-photos.sql + +BEGIN; + +-- First, check which rooms have photos in rooms.photos but not in images +-- For rooms 7 and 8, we need to copy their photos to images table + +DO $$ +DECLARE + rec RECORD; + photo_data TEXT; + room_id_val INTEGER; + photo_index INTEGER; +BEGIN + -- Loop through rooms that have photos in the array but not in images table + FOR rec IN + SELECT r.id, r.name, r.photos + FROM rooms r + WHERE array_length(r.photos, 1) > 0 + AND NOT EXISTS ( + SELECT 1 FROM images i WHERE i.room_id = r.id + ) + LOOP + room_id_val := rec.id; + + -- Insert each photo from the array into images + FOR photo_index IN 1..array_length(rec.photos, 1) LOOP + photo_data := rec.photos[photo_index]; + + INSERT INTO images (filename, data, thumbnail, medium, category, room_id, location_target) + VALUES ( + CONCAT('room-', room_id_val, '-photo-', photo_index, '.webp'), + photo_data, + photo_data, -- thumbnail (will be regenerated by WebP migration if different) + NULL, + 'rooms', + room_id_val, + CONCAT('room-', room_id_val) + ); + END LOOP; + + RAISE NOTICE 'Migrated % photos for room % (%)', array_length(rec.photos, 1), rec.name, room_id_val; + END LOOP; +END $$; + +-- Now clear the photos arrays in rooms table (images table is the source of truth) +UPDATE rooms SET photos = '{}' WHERE array_length(photos, 1) > 0; + +-- For rooms that already have images, sync the photo count +-- This is informational only +DO $$ +DECLARE + rec RECORD; +BEGIN + FOR rec IN + SELECT r.id, r.name, + array_length(r.photos, 1) as old_count, + (SELECT COUNT(*) FROM images i WHERE i.room_id = r.id) as new_count + FROM rooms r + LOOP + RAISE NOTICE 'Room % (%): photos array=%, images table=%', + rec.name, rec.id, rec.old_count, rec.new_count; + END LOOP; +END $$; + +COMMIT; + +-- Verify the migration +SELECT + r.id, + r.name, + array_length(r.photos, 1) as room_photos_count, + (SELECT COUNT(*) FROM images i WHERE i.room_id = r.id) as images_count +FROM rooms r +ORDER BY r.id; \ No newline at end of file diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index a2654bb..edba6b1 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -921,17 +921,25 @@ function RoomsSection({ rooms, setRooms, images, onToast }: any) { function RoomEditor({ room, setRoom, images, onSave, onCancel }: any) { const [showPicker, setShowPicker] = useState(false); - const [localPhotos, setLocalPhotos] = useState(room.photos || []); + const [roomImages, setRoomImages] = useState<{id: number; data: string; thumbnail: string}[]>([]); const [allAmenities, setAllAmenities] = useState([]); const [selectedAmenities, setSelectedAmenities] = useState(room.amenity_ids || []); - const r = { ...room, photos: localPhotos, amenity_ids: selectedAmenities }; + const r = { ...room, photos: roomImages.map(img => img.thumbnail || img.data), amenity_ids: selectedAmenities }; useEffect(() => { fetch('/api/amenities') .then(res => res.json()) .then(d => setAllAmenities(d.data || [])) .catch(() => {}); - }, []); + + // Load existing images for this room from images table + if (room.id) { + fetch(`/api/admin/images?room_id=${room.id}`) + .then(res => res.json()) + .then(d => setRoomImages(d.data || [])) + .catch(() => {}); + } + }, [room.id]); const toggleAmenity = (id: number) => { setSelectedAmenities(prev => @@ -940,7 +948,7 @@ function RoomEditor({ room, setRoom, images, onSave, onCancel }: any) { }; const addPhotos = async (files: FileList) => { - const newPhotos: string[] = []; + const newImages: {id: number; data: string; thumbnail: string}[] = []; for (const f of Array.from(files)) { const b64 = await readFileBase64(f); try { @@ -948,10 +956,20 @@ function RoomEditor({ room, setRoom, images, onSave, onCancel }: any) { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: f.name, data: b64, category: 'rooms', room_id: r.id || null, location_target: `room-${r.id || 'new'}` }), }); - if (res.ok) { const d = await res.json(); newPhotos.push(d.data.data); } - } catch { const b = await readFileBase64(f); newPhotos.push(b); } + if (res.ok) { + const d = await res.json(); + newImages.push({ id: d.data.id, data: d.data.data, thumbnail: d.data.thumbnail || d.data.data }); + } + } catch { /* ignore */ } } - setLocalPhotos(prev => [...prev, ...newPhotos]); + setRoomImages(prev => [...prev, ...newImages]); + }; + + const removeImage = async (imageId: number) => { + try { + await fetch(`/api/admin/images?id=${imageId}`, { method: 'DELETE' }); + setRoomImages(prev => prev.filter(img => img.id !== imageId)); + } catch { /* ignore */ } }; return ( @@ -965,15 +983,15 @@ function RoomEditor({ room, setRoom, images, onSave, onCancel }: any) {
- document.getElementById(`room-photos-${r.id}`)?.click()} size="sm" variant="secondary">+ Add Photos - document.getElementById(`room-photos-${r.id || 'new'}`)?.click()} size="sm" variant="secondary">+ Add Photos + { addPhotos(e.target.files!); e.target.value = ''; }} />
- {localPhotos.map((p, i) => ( -
- -
))} diff --git a/src/app/api/admin/images/route.ts b/src/app/api/admin/images/route.ts index f4decf4..20ba0e0 100644 --- a/src/app/api/admin/images/route.ts +++ b/src/app/api/admin/images/route.ts @@ -71,7 +71,7 @@ export async function GET(request: NextRequest) { } const { rows } = await db.query(query, params); - return NextResponse.json({ images: rows, count: rows.length }); + return NextResponse.json({ data: rows, count: rows.length }); } catch (error) { if ((error as Error).message === 'Unauthorized') { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); diff --git a/src/app/api/rooms/[id]/route.ts b/src/app/api/rooms/[id]/route.ts index f620bc2..6d253e1 100644 --- a/src/app/api/rooms/[id]/route.ts +++ b/src/app/api/rooms/[id]/route.ts @@ -29,15 +29,16 @@ export async function PUT(request: NextRequest, { params }: { params: { id: stri const id = parseInt(params.id); if (isNaN(id)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 }); const body = await request.json(); - const { name, description, price, photos, sort_order, featured_photo, active, amenity_ids } = body; + const { name, description, price, sort_order, featured_photo, active, amenity_ids } = body; if (!name || !description || price === undefined) { return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); } + // Photos are stored in images table, not rooms.photos const { rows } = await db.query( - `UPDATE rooms SET name = $1, description = $2, price = $3, photos = $4, sort_order = $5, featured_photo = $6, active = $7 WHERE id = $8 RETURNING *`, - [name, description, price, photos || [], sort_order || 0, featured_photo || null, active !== false, id] + `UPDATE rooms SET name = $1, description = $2, price = $3, sort_order = $4, featured_photo = $5, active = $6 WHERE id = $7 RETURNING *`, + [name, description, price, sort_order || 0, featured_photo || null, active !== false, id] ); // Update amenities @@ -47,7 +48,14 @@ export async function PUT(request: NextRequest, { params }: { params: { id: stri await db.query(`INSERT INTO room_amenity_links (room_id, amenity_id) VALUES ${values}`); } - return NextResponse.json({ data: rows[0] }); + // Get photos from images table + const { rows: imageRows } = await db.query( + 'SELECT id, data, thumbnail FROM images WHERE room_id = $1 ORDER BY id', + [id] + ); + const photos = imageRows.map((r: any) => r.thumbnail || r.data); + + return NextResponse.json({ data: { ...rows[0], photos, amenity_ids: amenity_ids || [] } }); } catch (error: any) { if ((error as Error).message === 'Unauthorized') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); console.error('PUT /api/rooms/[id]:', error); diff --git a/src/app/api/rooms/route.ts b/src/app/api/rooms/route.ts index 665bfc3..8fbfaa3 100644 --- a/src/app/api/rooms/route.ts +++ b/src/app/api/rooms/route.ts @@ -20,8 +20,24 @@ export async function GET() { amenityMap.get(row.room_id)!.push({ id: row.id, name: row.name, icon_svg: row.icon_svg }); } + // Get photos from images table for all rooms + const { rows: imageRows } = await db.query(` + SELECT room_id, data, thumbnail, medium + FROM images + WHERE room_id IS NOT NULL + ORDER BY room_id, id + `); + + const imageMap = new Map(); + for (const row of imageRows) { + if (!imageMap.has(row.room_id)) imageMap.set(row.room_id, []); + // Prefer thumbnail for display, fallback to data + imageMap.get(row.room_id)!.push(row.thumbnail || row.data); + } + const roomsWithAmenities = rows.map(r => ({ ...r, + photos: imageMap.get(r.id) || [], amenities: amenityMap.get(r.id) || [] })); @@ -36,17 +52,18 @@ export async function POST(request: NextRequest) { try { await requireRole('admin'); const body = await request.json(); - const { name, description, price, photos, sort_order, featured_photo, amenity_ids } = body; + const { name, description, price, sort_order, featured_photo, amenity_ids } = body; if (!name || !description || price === undefined) { return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); } + // Photos are now stored in images table, not rooms.photos const { rows } = await db.query( `INSERT INTO rooms (name, description, price, photos, sort_order, featured_photo) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, - [name, description, price, photos || [], sort_order || 0, featured_photo || null] + [name, description, price, [], sort_order || 0, featured_photo || null] ); const roomId = rows[0].id; @@ -57,9 +74,40 @@ export async function POST(request: NextRequest) { await db.query(`INSERT INTO room_amenity_links (room_id, amenity_id) VALUES ${values}`); } - return NextResponse.json({ data: { ...rows[0], amenity_ids: amenity_ids || [] } }); + return NextResponse.json({ data: { ...rows[0], photos: [], amenity_ids: amenity_ids || [] } }); } catch (error: any) { console.error('POST /api/rooms error:', error); return NextResponse.json({ error: error.message || 'Failed to create room' }, { status: 500 }); } } + +export async function PUT(request: NextRequest) { + try { + await requireRole('admin'); + const body = await request.json(); + const { id, name, description, price, active, sort_order, featured_photo, amenity_ids } = body; + + if (!id) { + return NextResponse.json({ error: 'Room ID required' }, { status: 400 }); + } + + // Photos are stored in images table, not rooms.photos + const { rows } = await db.query( + `UPDATE rooms SET name = $1, description = $2, price = $3, active = $4, sort_order = $5, featured_photo = $6 + WHERE id = $7 RETURNING *`, + [name, description, price, active ?? true, sort_order ?? 0, featured_photo || null, id] + ); + + // Update amenity links + await db.query('DELETE FROM room_amenity_links WHERE room_id = $1', [id]); + if (amenity_ids && amenity_ids.length > 0) { + const values = amenity_ids.map((aid: number) => `(${id}, ${aid})`).join(','); + await db.query(`INSERT INTO room_amenity_links (room_id, amenity_id) VALUES ${values}`); + } + + return NextResponse.json({ data: { ...rows[0], photos: [], amenity_ids: amenity_ids || [] } }); + } catch (error: any) { + console.error('PUT /api/rooms error:', error); + return NextResponse.json({ error: error.message || 'Failed to update room' }, { status: 500 }); + } +}