Compare commits

...

31 Commits

Author SHA1 Message Date
muken 288767f6c0 Security: remove hardcoded credentials, require DB_PASSWORD, delete .bak file, warn on secrets template 2026-07-17 14:24:48 -05:00
muken 9286f3e323 feat: room status table view with all rooms 2026-07-03 00:34:43 -05:00
muken 4e514c2fbb feat: admins can create private events with max_guests
- Added is_private and max_guests columns to social_events table
- Updated SocialEvent type with new fields
- Modified /api/social_events to include private events for admins
- Merged Public/Private Events into single 'Events' section with tabs
- SocialEditor now supports private event creation with max_guests field
- Public frontend still only sees public events
2026-07-03 00:27:44 -05:00
muken bfc627f451 fix: use correct /api/auth/me endpoint for coffee page user detection 2026-07-03 00:23:04 -05:00
muken 60afc2349e feat: room assignments, kitchen dashboard, and order user tracking
- Add room_assignments table for assigning users to rooms with dates
- Auto-detect logged-in user's assigned room when ordering food
- Kitchen tab in admin: dedicated view for active orders with auto-refresh
- Room Assignments tab: assign registered users to rooms by date range
- Orders now show user info (username, name) for tracking
- Kitchen view groups orders by status (pending/preparing/ready)
- Messages section shows order notifications
2026-07-03 00:22:00 -05:00
muken 9557fa7d07 fix: logged-in users can now comment without entering personal info
- Fix user data extraction in rooms page (was looking for j.user instead of direct fields)
- Add email to /api/auth/me response for logged-in user display
- User object now properly populated with id, username, role, first_name, last_name, email, comments_disabled
- Comments section now correctly shows 'Posting as {name}' for logged-in users
- Reservations and messages already use user info when logged in, only require guest info for guests
2026-07-03 00:14:32 -05:00
muken 200784fa49 feat: add housekeeping fields to room status
- Add checkout time/date for current guest
- Add last_cleaned timestamp
- Add assigned_staff_id with staff dropdown
- Add priority (urgent/normal/low) for cleaning order
- Add issues_count for tracking problems
- Add is_vip flag for VIP guests
- Update RoomStatusSection UI to display and edit all new fields
- Auto-set last_cleaned when status changes to 'clean'
2026-07-03 00:13:19 -05:00
muken 0785facd9e fix: correct user authentication check in messages page
The /api/auth/me endpoint returns { authenticated: true, id, username, ... }
not { user: {...} }. Fixed the messages page to correctly parse the response.
2026-07-03 00:11:09 -05:00
muken 6c8d70d41c feat: add Room Status tab in admin panel for housekeeping management
- Add clean_status and notes columns to rooms table
- Create /api/admin/room-status endpoint for status tracking
- Add RoomStatusSection component with:
  - Visual status icons ( clean, 🧹 dirty, 🔧 maintenance)
  - Occupancy status (occupied/available)
  - Current guest display with payment status
  - Upcoming reservations preview
  - Inline status and notes editing
- Add 'Room Status' tab to admin navigation
2026-07-03 00:09:38 -05:00
muken 1e66e918ac feat: add messaging system with admin bulk messages and user conversations
- Add conversations and direct_messages tables to schema
- User messaging page at /messages (users can start conversations, see admin responses)
- Admin messaging page at /admin/messages (view all conversations, reply to users)
- Admin bulk messaging: send to all customers, specific users, or admins
- Mail icon in navbar for logged-in users (links to appropriate messaging page)
- Show first name of admin who responded in conversation view
- Clean build cache after middleware changes
2026-07-03 00:06:07 -05:00
muken dc0c5fd289 feat: add security hardening for reservation APIs
- Add rate limiting (5/hr public, 100/min admin endpoints)
- Add honeypot bot protection on public POST
- Add audit logging for status changes and deletions
- Add input validation (email, phone, date, length limits)
- Fix missing auth on private event GET endpoints
- Add audit_log table to schema
- Fix failing tests (auth.test, rooms-route.test)
2026-07-02 20:56:36 -05:00
muken 0ca4f961f6 feat: add reservation system for public and private events
- Add private_event_reservations table with contact info fields
- Add status column to social_event_reservations (pending/confirmed/cancelled)
- Create /api/private-event-reservations CRUD endpoints
- Update social_event_reservations API to support guest submissions
- Add Reservations tab to Public Events admin section
- Add full reservation management UI for Private Events
- Support filtering by status (pending/confirmed/cancelled/all)
- Allow confirm/cancel/reactivate/delete actions
2026-07-02 20:29:17 -05:00
muken 361fd67e49 feat: add Spa/Pool gallery tabs, rename Social to Public Events, add Private Events
- Add 'Spa Gallery' (🧖) section in Image Gallery
- Add 'Pool Gallery' (🏊) section in Image Gallery
- Rename 'Social Events' to 'Public Events' (🎪) in sidebar
- Add 'Private Events' (🔒) placeholder section in sidebar
- Update category filters for spa/pool/public/private images
2026-07-02 20:26:16 -05:00
muken 5b30d0ca24 feat: add Food Gallery category to image gallery
- Add 'food' category filter in ImageGallery component
- Add '🍽️ Food Gallery' section in sectionCategories
- Update filteredImages logic to handle food category
- Clean up duplicate logo/favicon images in database
2026-07-02 20:22:13 -05:00
muken d730961c96 fix: SVG icons now scale properly with CSS instead of fixed width/height
- Remove hardcoded width/height attributes from SVGs
- Use CSS (width:100%;height:100%) for proper scaling
- Fixes amenity icons appearing compacted/overlapping
- viewBox maintains aspect ratio, CSS controls display size
2026-07-01 21:59:22 -05:00
muken 09bbe0a441 refactor: migrate room photos from rooms.photos to images table
- Photos now stored exclusively in images table (no duplication)
- rooms.photos array cleared, freed 17 MB storage
- Updated room APIs to fetch images by room_id
- Added PUT endpoint for room updates
- Updated RoomEditor to load/delete images from images table
- Fixed images API to return 'data' key consistently
2026-07-01 21:54:39 -05:00
muken ee68dc2897 feat: add WebP migration UI and API endpoint
- Add /api/admin/migrate-images endpoint to convert JPEG/PNG to WebP
- Add WebPMigration component in admin gallery showing stats
- Upload endpoint already converts new images to WebP
- Migration saves ~30-50% storage space per image
2026-07-01 21:47:16 -05:00
muken d0be23551a fix: use Union Jack (UK flag) instead of England flag for English language 2026-07-01 21:21:02 -05:00
muken 223b6e4670 fix: apply navy color to amenity SVG icons via stroke attribute 2026-07-01 21:19:04 -05:00
muken b71f66a177 fix: improve amenity icon styling - larger icons, better spacing 2026-07-01 21:18:02 -05:00
muken f7bb5ed752 feat: food ordering on coffee page
- Add 'Place Order' button to coffee page
- Order modal with menu item selection by category
- Order type options: pickup, dine in, room delivery (+10%)
- Room delivery requires login (disabled for guests)
- Calculate subtotal, service fee, and total
- Special instructions field
- Admin receives new orders in database
- Create orders and order_items tables
2026-07-01 00:12:34 -05:00
muken d9049d2294 feat: guest reservations and messages with proper table names
- Remove Reserve button from rooms page
- Calendar modal now supports direct reservation for guests
- Message modal works for guests (asks for name, contact method, contact)
- Create room_reservations table (separate from restaurant reservations)
- Create messages table for room inquiries
- LEFT JOIN users in queries to include guest reservations/messages
- Auto-fill form for logged-in users, show input fields for guests
2026-07-01 00:09:25 -05:00
muken ae6ea0ea46 feat: guest reservations and messages
- Remove Reserve button from rooms page
- Calendar modal now supports direct reservation for guests
- Message modal works for guests (asks for name, contact method, contact)
- Add guest_name, guest_contact_method, guest_contact to reservations and messages tables
- LEFT JOIN users in reservations GET to include guest reservations
- Auto-fill form for logged-in users, show input fields for guests
2026-07-01 00:05:39 -05:00
muken 72b507e434 fix: handle base64 images and fix amenity icon rendering
- Main images: check for base64 data URLs and render directly
- Thumbnails: same base64 handling for thumbnail strip
- Amenity icons: add null check and use global regex for SVG attrs
- All SVGs now properly sized at 14x14 with navy color
2026-06-30 23:58:06 -05:00
muken a8df420f36 fix: consolidate hearts to single like button with count
- Remove duplicate LikeButton component from room info section
- Remove separate like count badge from bottom-right
- Single unified like button (top-left) shows heart + count
- Cleaner UI: one heart, one tap, clear feedback
2026-06-30 23:41:37 -05:00
muken 4fb97e1f10 fix: enhance image loading animation with cream-colored shimmer
- Match shimmer colors to site's cream/warm palette (#f5f0e8, #e8e4dc)
- Add ease-in-out timing for smoother animation
- Proper z-index layering (shimmer below image)
- Longer fade-in transition (0.4s) for polished feel
2026-06-30 23:38:51 -05:00
muken c98824c8d7 fix: improve amenity icon display on room cards
- Icons now render with proper size (14px) inside pill badges
- Show amenity name next to icon for clarity
- Use regex for robust width/height replacement
- Compact layout with smaller gaps and font sizes
- Better text wrapping with whiteSpace: nowrap
2026-06-30 23:36:30 -05:00
muken 81c12ace7b feat: image optimization with multiple sizes and lazy loading
- Generate thumbnail (150px), medium (800px), full size on upload
- OptimizedImage component with Intersection Observer lazy loading
- Blur placeholder while loading
- Fetches appropriate size based on props (thumbnail/medium/full)
- Async image loading reduces initial page load time
- Added medium and thumbnail columns to images table
2026-06-30 23:34:23 -05:00
muken 73be5d2f22 fix: navbar refreshes auth state on navigation
Login button now correctly changes to Logout after login
2026-06-30 23:28:47 -05:00
muken 2353198924 feat: add room likes feature
- Users can like/unlike rooms
- Like count displayed on room cards
- LikeButton component with heart icon
- room_likes table with unique constraint
2026-06-30 21:57:28 -05:00
muken 7eae73eefb feat: room booking system with 4 action buttons
- Display amenity icons on room cards
- Reserve button: date picker, availability check, booking
- Message button: contact admins via platform
- Calendar button: view room availability
- Comment button: submit reviews for admin approval
- Admin comment queue for approve/reject/edit
- Reservations, availability, and messages tables
2026-06-30 21:54:46 -05:00
60 changed files with 7227 additions and 1633 deletions
+1
View File
@@ -1,3 +1,4 @@
# WARNING: Replace CHANGE_ME_* values before applying
apiVersion: v1 apiVersion: v1
kind: Secret kind: Secret
metadata: metadata:
+548 -45
View File
@@ -11,7 +11,7 @@
"@chenglou/pretext": "^0.0.8", "@chenglou/pretext": "^0.0.8",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"jose": "^5.6.3", "jose": "^5.6.3",
"next": "14.2.28", "next": "^14.2.35",
"pg": "^8.12.0", "pg": "^8.12.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
@@ -27,6 +27,7 @@
"@types/react-dom": "^18.3.6", "@types/react-dom": "^18.3.6",
"@vitejs/plugin-react": "^6.0.3", "@vitejs/plugin-react": "^6.0.3",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"tsx": "^4.22.4",
"typescript": "^5.4.5", "typescript": "^5.4.5",
"vitest": "^4.1.9" "vitest": "^4.1.9"
} }
@@ -318,6 +319,448 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@exodus/bytes": { "node_modules/@exodus/bytes": {
"version": "1.15.1", "version": "1.15.1",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
@@ -911,15 +1354,15 @@
} }
}, },
"node_modules/@next/env": { "node_modules/@next/env": {
"version": "14.2.28", "version": "14.2.35",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.28.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.35.tgz",
"integrity": "sha512-PAmWhJfJQlP+kxZwCjrVd9QnR5x0R3u0mTXTiZDgSd4h5LdXmjxCCWbN9kq6hkZBOax8Rm3xDW5HagWyJuT37g==", "integrity": "sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@next/swc-darwin-arm64": { "node_modules/@next/swc-darwin-arm64": {
"version": "14.2.28", "version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.28.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.33.tgz",
"integrity": "sha512-kzGChl9setxYWpk3H6fTZXXPFFjg7urptLq5o5ZgYezCrqlemKttwMT5iFyx/p1e/JeglTwDFRtb923gTJ3R1w==", "integrity": "sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -933,9 +1376,9 @@
} }
}, },
"node_modules/@next/swc-darwin-x64": { "node_modules/@next/swc-darwin-x64": {
"version": "14.2.28", "version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.28.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.33.tgz",
"integrity": "sha512-z6FXYHDJlFOzVEOiiJ/4NG8aLCeayZdcRSMjPDysW297Up6r22xw6Ea9AOwQqbNsth8JNgIK8EkWz2IDwaLQcw==", "integrity": "sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -949,9 +1392,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-gnu": { "node_modules/@next/swc-linux-arm64-gnu": {
"version": "14.2.28", "version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.28.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.33.tgz",
"integrity": "sha512-9ARHLEQXhAilNJ7rgQX8xs9aH3yJSj888ssSjJLeldiZKR4D7N08MfMqljk77fAwZsWwsrp8ohHsMvurvv9liQ==", "integrity": "sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -968,9 +1411,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-musl": { "node_modules/@next/swc-linux-arm64-musl": {
"version": "14.2.28", "version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.28.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.33.tgz",
"integrity": "sha512-p6gvatI1nX41KCizEe6JkF0FS/cEEF0u23vKDpl+WhPe/fCTBeGkEBh7iW2cUM0rvquPVwPWdiUR6Ebr/kQWxQ==", "integrity": "sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -987,9 +1430,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-gnu": { "node_modules/@next/swc-linux-x64-gnu": {
"version": "14.2.28", "version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.28.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.33.tgz",
"integrity": "sha512-nsiSnz2wO6GwMAX2o0iucONlVL7dNgKUqt/mDTATGO2NY59EO/ZKnKEr80BJFhuA5UC1KZOMblJHWZoqIJddpA==", "integrity": "sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1006,9 +1449,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-musl": { "node_modules/@next/swc-linux-x64-musl": {
"version": "14.2.28", "version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.28.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.33.tgz",
"integrity": "sha512-+IuGQKoI3abrXFqx7GtlvNOpeExUH1mTIqCrh1LGFf8DnlUcTmOOCApEnPJUSLrSbzOdsF2ho2KhnQoO0I1RDw==", "integrity": "sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1025,9 +1468,9 @@
} }
}, },
"node_modules/@next/swc-win32-arm64-msvc": { "node_modules/@next/swc-win32-arm64-msvc": {
"version": "14.2.28", "version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.28.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.33.tgz",
"integrity": "sha512-l61WZ3nevt4BAnGksUVFKy2uJP5DPz2E0Ma/Oklvo3sGj9sw3q7vBWONFRgz+ICiHpW5mV+mBrkB3XEubMrKaA==", "integrity": "sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1041,9 +1484,9 @@
} }
}, },
"node_modules/@next/swc-win32-ia32-msvc": { "node_modules/@next/swc-win32-ia32-msvc": {
"version": "14.2.28", "version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.28.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.33.tgz",
"integrity": "sha512-+Kcp1T3jHZnJ9v9VTJ/yf1t/xmtFAc/Sge4v7mVc1z+NYfYzisi8kJ9AsY8itbgq+WgEwMtOpiLLJsUy2qnXZw==", "integrity": "sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -1057,9 +1500,9 @@
} }
}, },
"node_modules/@next/swc-win32-x64-msvc": { "node_modules/@next/swc-win32-x64-msvc": {
"version": "14.2.28", "version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.28.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.33.tgz",
"integrity": "sha512-1gCmpvyhz7DkB1srRItJTnmR2UwQPAUXXIg9r0/56g3O8etGmwlX68skKXJOp9EejW3hhv7nSQUJ2raFiz4MoA==", "integrity": "sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1914,6 +2357,48 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/esbuild": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/estree-walker": { "node_modules/estree-walker": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
@@ -2411,13 +2896,12 @@
} }
}, },
"node_modules/next": { "node_modules/next": {
"version": "14.2.28", "version": "14.2.35",
"resolved": "https://registry.npmjs.org/next/-/next-14.2.28.tgz", "resolved": "https://registry.npmjs.org/next/-/next-14.2.35.tgz",
"integrity": "sha512-QLEIP/kYXynIxtcKB6vNjtWLVs3Y4Sb+EClTC/CSVzdLD1gIuItccpu/n1lhmduffI32iPGEK2cLLxxt28qgYA==", "integrity": "sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==",
"deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details.",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@next/env": "14.2.28", "@next/env": "14.2.35",
"@swc/helpers": "0.5.5", "@swc/helpers": "0.5.5",
"busboy": "1.6.0", "busboy": "1.6.0",
"caniuse-lite": "^1.0.30001579", "caniuse-lite": "^1.0.30001579",
@@ -2432,15 +2916,15 @@
"node": ">=18.17.0" "node": ">=18.17.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@next/swc-darwin-arm64": "14.2.28", "@next/swc-darwin-arm64": "14.2.33",
"@next/swc-darwin-x64": "14.2.28", "@next/swc-darwin-x64": "14.2.33",
"@next/swc-linux-arm64-gnu": "14.2.28", "@next/swc-linux-arm64-gnu": "14.2.33",
"@next/swc-linux-arm64-musl": "14.2.28", "@next/swc-linux-arm64-musl": "14.2.33",
"@next/swc-linux-x64-gnu": "14.2.28", "@next/swc-linux-x64-gnu": "14.2.33",
"@next/swc-linux-x64-musl": "14.2.28", "@next/swc-linux-x64-musl": "14.2.33",
"@next/swc-win32-arm64-msvc": "14.2.28", "@next/swc-win32-arm64-msvc": "14.2.33",
"@next/swc-win32-ia32-msvc": "14.2.28", "@next/swc-win32-ia32-msvc": "14.2.33",
"@next/swc-win32-x64-msvc": "14.2.28" "@next/swc-win32-x64-msvc": "14.2.33"
}, },
"peerDependencies": { "peerDependencies": {
"@opentelemetry/api": "^1.1.0", "@opentelemetry/api": "^1.1.0",
@@ -3051,6 +3535,25 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/tsx": {
"version": "4.22.4",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
"integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+2 -1
View File
@@ -13,7 +13,7 @@
"@chenglou/pretext": "^0.0.8", "@chenglou/pretext": "^0.0.8",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"jose": "^5.6.3", "jose": "^5.6.3",
"next": "14.2.28", "next": "^14.2.35",
"pg": "^8.12.0", "pg": "^8.12.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
@@ -29,6 +29,7 @@
"@types/react-dom": "^18.3.6", "@types/react-dom": "^18.3.6",
"@vitejs/plugin-react": "^6.0.3", "@vitejs/plugin-react": "^6.0.3",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"tsx": "^4.22.4",
"typescript": "^5.4.5", "typescript": "^5.4.5",
"vitest": "^4.1.9" "vitest": "^4.1.9"
} }
+113
View File
@@ -0,0 +1,113 @@
/**
* Migration script to convert existing JPEG/PNG images to WebP
* Run with: npx ts-node scripts/migrate-images-to-webp.ts
*/
import { Pool } from 'pg';
import sharp from 'sharp';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
async function convertToWebP(base64Data: string): Promise<string> {
const matches = base64Data.match(/^data:image\/(\w+);base64,(.+)$/);
if (!matches) {
console.log(' - Already WebP or invalid format, skipping');
return base64Data;
}
const ext = matches[2] ? matches[1] : 'unknown';
const buffer = Buffer.from(matches[2], 'base64');
// Convert to WebP
const webpBuffer = await sharp(buffer)
.webp({ quality: 90 })
.toBuffer();
return `data:image/webp;base64,${webpBuffer.toString('base64')}`;
}
async function main() {
console.log('Starting image migration to WebP...\n');
const client = await pool.connect();
try {
// Get all images
const { rows: images } = await client.query(
'SELECT id, filename, data, thumbnail, medium FROM images'
);
console.log(`Found ${images.length} images to process\n`);
let converted = 0;
let skipped = 0;
let errors = 0;
for (const img of images) {
console.log(`Processing image ${img.id}: ${img.filename}`);
try {
let needsUpdate = false;
const updates: { data?: string; thumbnail?: string; medium?: string } = {};
// Check if already WebP
if (img.data && !img.data.includes('image/webp')) {
console.log(' - Converting full image...');
updates.data = await convertToWebP(img.data);
needsUpdate = true;
}
if (img.thumbnail && !img.thumbnail.includes('image/webp')) {
console.log(' - Converting thumbnail...');
updates.thumbnail = await convertToWebP(img.thumbnail);
needsUpdate = true;
}
if (img.medium && !img.medium.includes('image/webp')) {
console.log(' - Converting medium...');
updates.medium = await convertToWebP(img.medium);
needsUpdate = true;
}
if (needsUpdate) {
// Calculate size savings
const oldSize = (img.data?.length || 0) + (img.thumbnail?.length || 0) + (img.medium?.length || 0);
const newSize = (updates.data?.length || img.data?.length || 0) +
(updates.thumbnail?.length || img.thumbnail?.length || 0) +
(updates.medium?.length || img.medium?.length || 0);
const savings = ((oldSize - newSize) / oldSize * 100).toFixed(1);
const savedKB = ((oldSize - newSize) / 1024).toFixed(1);
console.log(` - Size reduction: ${savings}% (${savedKB} KB saved)`);
await client.query(
'UPDATE images SET data = $1, thumbnail = $2, medium = $3 WHERE id = $4',
[updates.data || img.data, updates.thumbnail || img.thumbnail, updates.medium || img.medium, img.id]
);
converted++;
} else {
console.log(' - Already WebP, skipping');
skipped++;
}
} catch (err) {
console.error(` - Error: ${(err as Error).message}`);
errors++;
}
console.log('');
}
console.log('Migration complete!\n');
console.log(`Converted: ${converted}`);
console.log(`Skipped: ${skipped}`);
console.log(`Errors: ${errors}`);
} finally {
client.release();
await pool.end();
}
}
main().catch(console.error);
+115
View File
@@ -0,0 +1,115 @@
/**
* Migration script to convert existing JPEG/PNG images to WebP
* Run from the k8s cluster directly
*/
const { Pool } = require('pg');
const sharp = require('sharp');
const pool = new Pool({
host: 'postgres.lahuasca',
port: 5432,
database: 'lahuasca',
user: 'lahuasca',
password: (() => {
const p = process.env.DB_PASSWORD;
if (!p) throw new Error('DB_PASSWORD env var is required');
return p;
})(),
});
async function convertToWebP(base64Data) {
const matches = base64Data.match(/^data:image\/(\w+);base64,(.+)$/);
if (!matches) {
return base64Data;
}
const buffer = Buffer.from(matches[2], 'base64');
const webpBuffer = await sharp(buffer)
.webp({ quality: 90 })
.toBuffer();
return `data:image/webp;base64,${webpBuffer.toString('base64')}`;
}
async function main() {
console.log('Starting image migration to WebP...\n');
const client = await pool.connect();
try {
const { rows: images } = await client.query(
'SELECT id, filename, data, thumbnail, medium FROM images'
);
console.log(`Found ${images.length} images to process\n`);
let converted = 0;
let skipped = 0;
let errors = 0;
let totalSaved = 0;
for (const img of images) {
console.log(`Processing image ${img.id}: ${img.filename}`);
try {
let needsUpdate = false;
const updates = {};
if (img.data && !img.data.includes('image/webp')) {
console.log(' Converting full image...');
updates.data = await convertToWebP(img.data);
needsUpdate = true;
}
if (img.thumbnail && !img.thumbnail.includes('image/webp')) {
console.log(' Converting thumbnail...');
updates.thumbnail = await convertToWebP(img.thumbnail);
needsUpdate = true;
}
if (img.medium && !img.medium.includes('image/webp')) {
console.log(' Converting medium...');
updates.medium = await convertToWebP(img.medium);
needsUpdate = true;
}
if (needsUpdate) {
const oldSize = (img.data?.length || 0) + (img.thumbnail?.length || 0) + (img.medium?.length || 0);
const newSize = (updates.data?.length || img.data?.length || 0) +
(updates.thumbnail?.length || img.thumbnail?.length || 0) +
(updates.medium?.length || img.medium?.length || 0);
const savedKB = Math.round((oldSize - newSize) / 1024);
totalSaved += savedKB;
console.log(` Saved: ${savedKB} KB`);
await client.query(
'UPDATE images SET data = $1, thumbnail = $2, medium = $3 WHERE id = $4',
[updates.data || img.data, updates.thumbnail || img.thumbnail, updates.medium || img.medium, img.id]
);
converted++;
} else {
console.log(' Already WebP, skipping');
skipped++;
}
} catch (err) {
console.error(` Error: ${err.message}`);
errors++;
}
}
console.log('\n========================================');
console.log(`Converted: ${converted}`);
console.log(`Skipped: ${skipped}`);
console.log(`Errors: ${errors}`);
console.log(`Total saved: ${Math.round(totalSaved / 1024)} MB`);
console.log('========================================');
} finally {
client.release();
await pool.end();
}
}
main().catch(console.error);
+51
View File
@@ -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;
+76
View File
@@ -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;
+20
View File
@@ -0,0 +1,20 @@
-- Remove duplicate images, keeping only the most recent one per (filename, location_target, data length)
-- This handles upload accidents where same image was uploaded multiple times
-- First, identify duplicates
-- SELECT filename, location_target, COUNT(*) as dupes, array_agg(id) as ids
-- FROM images GROUP BY filename, location_target, LENGTH(data) HAVING COUNT(*) > 1;
-- Delete duplicates, keeping the latest (highest id) per group
DELETE FROM images a
USING images b
WHERE a.id < b.id
AND a.filename = b.filename
AND a.location_target = b.location_target
AND LENGTH(a.data) = LENGTH(b.data);
-- Verify cleanup
SELECT filename, location_target, COUNT(*) as count
FROM images
GROUP BY filename, location_target, LENGTH(data)
HAVING COUNT(*) > 1;
+383
View File
@@ -0,0 +1,383 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { formatDisplayDateTime } from '@/lib/date';
type User = { id: number; username: string; first_name: string; last_name: string; role: string; email: string };
type Message = { id: number; content: string; created_at: string; sender_id: number; username: string; first_name: string; last_name: string; role: string };
type Participant = { id: number; username: string; first_name: string; last_name: string; role: string };
type Conversation = {
id: number;
subject: string | null;
last_message: string | null;
last_message_at: string | null;
message_count: number;
unread_count: number;
participants: Participant[];
};
export default function AdminMessagesPage() {
const [conversations, setConversations] = useState<Conversation[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [selectedConv, setSelectedConv] = useState<number | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [participants, setParticipants] = useState<Participant[]>([]);
const [newMessage, setNewMessage] = useState('');
const [showBulk, setShowBulk] = useState(false);
const [bulkSubject, setBulkSubject] = useState('');
const [bulkContent, setBulkContent] = useState('');
const [bulkType, setBulkType] = useState<'all_customers' | 'specific_users' | 'admins'>('all_customers');
const [selectedUsers, setSelectedUsers] = useState<number[]>([]);
const [loading, setLoading] = useState(true);
const [sending, setSending] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
fetchData();
}, []);
useEffect(() => {
if (selectedConv) {
fetchMessages(selectedConv);
}
}, [selectedConv]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const fetchData = async () => {
try {
const [convRes, usersRes] = await Promise.all([
fetch('/api/admin/conversations'),
fetch('/api/admin/users'),
]);
if (convRes.ok) {
const data = await convRes.json();
setConversations(data.conversations || []);
}
if (usersRes.ok) {
const data = await usersRes.json();
setUsers(data.users || []);
}
} catch (err) {
console.error('Failed to fetch data:', err);
} finally {
setLoading(false);
}
};
const fetchMessages = async (convId: number) => {
try {
const res = await fetch(`/api/conversations/${convId}`);
if (res.ok) {
const data = await res.json();
setMessages(data.messages || []);
setParticipants(data.participants || []);
}
} catch (err) {
console.error('Failed to fetch messages:', err);
}
};
const sendMessage = async () => {
if (!newMessage.trim() || !selectedConv) return;
try {
const res = await fetch(`/api/conversations/${selectedConv}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: newMessage.trim() }),
});
if (res.ok) {
const data = await res.json();
setMessages(prev => [...prev, data.message]);
setNewMessage('');
fetchData();
}
} catch (err) {
console.error('Failed to send message:', err);
}
};
const sendBulkMessage = async () => {
if (!bulkContent.trim()) return;
if (bulkType === 'specific_users' && selectedUsers.length === 0) {
alert('Please select at least one user');
return;
}
setSending(true);
try {
const res = await fetch('/api/admin/bulk-messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
subject: bulkSubject || null,
content: bulkContent.trim(),
recipient_type: bulkType,
recipient_ids: bulkType === 'specific_users' ? selectedUsers : undefined,
}),
});
if (res.ok) {
const data = await res.json();
alert(`Message sent to ${data.sent_count} users`);
setShowBulk(false);
setBulkSubject('');
setBulkContent('');
setSelectedUsers([]);
fetchData();
} else {
const err = await res.json();
alert(err.error || 'Failed to send');
}
} catch (err) {
console.error('Failed to send bulk message:', err);
alert('Failed to send message');
} finally {
setSending(false);
}
};
const getDisplayName = (p: Participant | User) => {
return p.first_name || p.username;
};
const customerConvs = conversations.filter(c => c.participants.some(p => p.role === 'customer'));
const adminConvs = conversations.filter(c => c.participants.every(p => p.role === 'admin'));
if (loading) {
return (
<div className="p-8 flex items-center justify-center">
<div className="text-gray-500">Loading...</div>
</div>
);
}
return (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-gray-800">Messages</h1>
<button
onClick={() => setShowBulk(true)}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
Send Bulk Message
</button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Conversations Panel */}
<div className="lg:col-span-1 bg-white rounded-lg shadow">
<div className="p-4 border-b bg-gray-50 font-medium">Customer Conversations</div>
<div className="divide-y max-h-[300px] overflow-y-auto">
{customerConvs.length === 0 ? (
<div className="p-4 text-center text-gray-500 text-sm">No customer conversations</div>
) : (
customerConvs.map(conv => (
<div
key={conv.id}
onClick={() => setSelectedConv(conv.id)}
className={`p-3 cursor-pointer hover:bg-gray-50 ${selectedConv === conv.id ? 'bg-blue-50' : ''}`}
>
<div className="flex justify-between items-start">
<div className="font-medium text-sm">
{conv.participants.filter(p => p.role === 'customer').map(getDisplayName).join(', ')}
</div>
{conv.unread_count > 0 && (
<span className="bg-blue-600 text-white text-xs px-2 py-0.5 rounded-full">
{conv.unread_count}
</span>
)}
</div>
{conv.last_message && (
<div className="text-xs text-gray-500 truncate mt-1">{conv.last_message}</div>
)}
<div className="text-xs text-gray-400 mt-1">
{conv.last_message_at ? formatDisplayDateTime(conv.last_message_at) : ''}
</div>
</div>
))
)}
</div>
<div className="p-4 border-b bg-gray-50 font-medium mt-4">Admin Conversations</div>
<div className="divide-y max-h-[300px] overflow-y-auto">
{adminConvs.length === 0 ? (
<div className="p-4 text-center text-gray-500 text-sm">No admin conversations</div>
) : (
adminConvs.map(conv => (
<div
key={conv.id}
onClick={() => setSelectedConv(conv.id)}
className={`p-3 cursor-pointer hover:bg-gray-50 ${selectedConv === conv.id ? 'bg-blue-50' : ''}`}
>
<div className="font-medium text-sm">
{conv.subject || conv.participants.map(getDisplayName).join(', ')}
</div>
{conv.last_message && (
<div className="text-xs text-gray-500 truncate mt-1">{conv.last_message}</div>
)}
</div>
))
)}
</div>
</div>
{/* Messages Panel */}
<div className="lg:col-span-2 bg-white rounded-lg shadow flex flex-col h-[700px]">
{selectedConv ? (
<>
<div className="p-4 border-b bg-gray-50">
<div className="font-medium">
Conversation with: {participants.filter(p => p.role === 'customer').map(getDisplayName).join(', ') || 'Admins'}
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{messages.map(msg => (
<div key={msg.id} className={`flex ${msg.role === 'admin' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[70%] ${msg.role === 'admin' ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-800'} rounded-lg px-4 py-2`}>
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium">
{msg.first_name || msg.username}
</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${msg.role === 'admin' ? 'bg-blue-500' : 'bg-gray-200 text-gray-600'}`}>
{msg.role}
</span>
</div>
<div>{msg.content}</div>
<div className={`text-xs mt-1 ${msg.role === 'admin' ? 'text-blue-200' : 'text-gray-400'}`}>
{formatDisplayDateTime(msg.created_at)}
</div>
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
<div className="p-3 border-t bg-gray-50">
<div className="flex gap-2">
<input
type="text"
value={newMessage}
onChange={e => setNewMessage(e.target.value)}
onKeyDown={e => e.key === 'Enter' && sendMessage()}
placeholder="Type a reply..."
className="flex-1 border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
onClick={sendMessage}
disabled={!newMessage.trim()}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
Send
</button>
</div>
</div>
</>
) : (
<div className="flex-1 flex items-center justify-center text-gray-500">
Select a conversation to view messages
</div>
)}
</div>
</div>
{/* Bulk Message Modal */}
{showBulk && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
<h2 className="text-xl font-bold mb-4">Send Bulk Message</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Recipients</label>
<select
value={bulkType}
onChange={e => setBulkType(e.target.value as typeof bulkType)}
className="w-full border rounded-lg px-3 py-2"
>
<option value="all_customers">All Customers</option>
<option value="specific_users">Specific Users</option>
<option value="admins">Admins Only</option>
</select>
</div>
{bulkType === 'specific_users' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Select Users</label>
<div className="max-h-48 overflow-y-auto border rounded-lg p-2 space-y-1">
{users.filter(u => u.role === 'customer').map(user => (
<label key={user.id} className="flex items-center gap-2 p-1 hover:bg-gray-50 rounded cursor-pointer">
<input
type="checkbox"
checked={selectedUsers.includes(user.id)}
onChange={e => {
if (e.target.checked) {
setSelectedUsers(prev => [...prev, user.id]);
} else {
setSelectedUsers(prev => prev.filter(id => id !== user.id));
}
}}
className="rounded"
/>
<span className="text-sm">{user.first_name || user.username} ({user.email})</span>
</label>
))}
</div>
<div className="text-xs text-gray-500 mt-1">{selectedUsers.length} selected</div>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Subject (optional)</label>
<input
type="text"
value={bulkSubject}
onChange={e => setBulkSubject(e.target.value)}
placeholder="Message subject"
className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Message *</label>
<textarea
value={bulkContent}
onChange={e => setBulkContent(e.target.value)}
placeholder="Type your message..."
rows={5}
className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div className="flex justify-end gap-2 mt-6">
<button
onClick={() => setShowBulk(false)}
className="px-4 py-2 text-gray-600 hover:text-gray-800"
>
Cancel
</button>
<button
onClick={sendBulkMessage}
disabled={!bulkContent.trim() || sending}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{sending ? 'Sending...' : 'Send Message'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
+1148 -36
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+113
View File
@@ -0,0 +1,113 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { requireRole } from '@/lib/auth';
import { getErrorMessage } from '@/lib/errors';
// Admin: Send bulk message to customers
export async function POST(request: NextRequest) {
try {
const admin = await requireRole('admin');
const { subject, content, recipient_type, recipient_ids } = await request.json();
if (!content || content.trim().length === 0) {
return NextResponse.json({ error: 'Message content is required' }, { status: 400 });
}
if (!['all_customers', 'specific_users', 'admins'].includes(recipient_type)) {
return NextResponse.json({ error: 'Invalid recipient type' }, { status: 400 });
}
// Get recipients based on type
let recipientList: number[] = [];
if (recipient_type === 'all_customers') {
const { rows } = await db.query(
"SELECT id FROM users WHERE role = 'customer'"
);
recipientList = rows.map((r: any) => r.id);
} else if (recipient_type === 'admins') {
const { rows } = await db.query(
"SELECT id FROM users WHERE role = 'admin'"
);
recipientList = rows.map((r: any) => r.id);
} else if (recipient_type === 'specific_users' && recipient_ids) {
recipientList = recipient_ids;
}
if (recipientList.length === 0) {
return NextResponse.json({ error: 'No recipients found' }, { status: 400 });
}
// Create bulk message record
const { rows: bulkRows } = await db.query(
'INSERT INTO bulk_messages (sender_id, subject, content, recipient_type) VALUES ($1, $2, $3, $4) RETURNING *',
[admin.id, subject || null, content, recipient_type]
);
const bulkMessage = bulkRows[0];
// Create recipient records
for (const userId of recipientList) {
await db.query(
'INSERT INTO bulk_message_recipients (bulk_message_id, user_id) VALUES ($1, $2)',
[bulkMessage.id, userId]
);
}
// Create individual conversations for each recipient if they don't exist
for (const userId of recipientList) {
// Check if there's an existing conversation between this admin and user
const { rows: existingConv } = await db.query(`
SELECT c.id FROM conversations c
JOIN conversation_participants cp1 ON c.id = cp1.conversation_id
JOIN conversation_participants cp2 ON c.id = cp2.conversation_id
WHERE cp1.user_id = $1 AND cp2.user_id = $2
GROUP BY c.id
HAVING COUNT(DISTINCT cp1.user_id) = 1 AND COUNT(DISTINCT cp2.user_id) = 1
`, [admin.id, userId]);
let conversationId: number;
if (existingConv.length > 0) {
conversationId = existingConv[0].id;
} else {
// Create new conversation
const { rows: convRows } = await db.query(
'INSERT INTO conversations (subject, created_by) VALUES ($1, $2) RETURNING id',
[subject || 'Admin Message', admin.id]
);
conversationId = convRows[0].id;
// Add participants
await db.query(
'INSERT INTO conversation_participants (conversation_id, user_id) VALUES ($1, $2), ($1, $3)',
[conversationId, admin.id, userId]
);
}
// Add the message to the conversation
await db.query(
'INSERT INTO direct_messages (conversation_id, sender_id, content) VALUES ($1, $2, $3)',
[conversationId, admin.id, content]
);
// Update conversation timestamp
await db.query(
'UPDATE conversations SET updated_at = NOW() WHERE id = $1',
[conversationId]
);
}
return NextResponse.json({
success: true,
sent_count: recipientList.length,
bulk_message: bulkMessage
});
} catch (error) {
console.error('Send bulk message error:', error);
return NextResponse.json(
{ error: getErrorMessage(error, 'Failed to send bulk message') },
{ status: 500 }
);
}
}
+74
View File
@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { requireAuth } from '@/lib/auth';
// GET - list pending comments for admin review
export async function GET(request: NextRequest) {
try {
const session = await requireAuth();
if (session.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
}
const { searchParams } = new URL(request.url);
const status = searchParams.get('status') || 'pending';
const { rows } = await db.query(`
SELECT rc.*, r.name as room_name, u.username
FROM room_comments rc
LEFT JOIN rooms r ON rc.room_id = r.id
LEFT JOIN users u ON rc.user_id = u.id
WHERE rc.status = $1
ORDER BY rc.created_at DESC
`, [status]);
return NextResponse.json({ comments: rows });
} catch (err: any) {
return NextResponse.json({ error: err.message || 'Failed to fetch comments' }, { status: 500 });
}
}
// PATCH - approve, reject, or edit a comment
export async function PATCH(request: NextRequest) {
try {
const session = await requireAuth();
if (session.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
}
const { comment_id, action, text } = await request.json();
if (!comment_id || !action) {
return NextResponse.json({ error: 'comment_id and action required' }, { status: 400 });
}
let result;
if (action === 'approve') {
result = await db.query(`
UPDATE room_comments SET status = 'approved' WHERE id = $1 RETURNING *
`, [comment_id]);
} else if (action === 'reject') {
result = await db.query(`
UPDATE room_comments SET status = 'rejected' WHERE id = $1 RETURNING *
`, [comment_id]);
} else if (action === 'edit') {
if (!text) {
return NextResponse.json({ error: 'text required for edit action' }, { status: 400 });
}
result = await db.query(`
UPDATE room_comments SET text = $1, status = 'approved' WHERE id = $2 RETURNING *
`, [text, comment_id]);
} else if (action === 'delete') {
result = await db.query(`
UPDATE room_comments SET deleted_by_admin = TRUE WHERE id = $1 RETURNING *
`, [comment_id]);
} else {
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
}
return NextResponse.json({ success: true, comment: result?.rows?.[0] });
} catch (err: any) {
return NextResponse.json({ error: err.message || 'Failed to update comment' }, { status: 500 });
}
}
+63
View File
@@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { requireRole } from '@/lib/auth';
import { getErrorMessage } from '@/lib/errors';
// Admin: Get all conversations
export async function GET() {
try {
await requireRole('admin');
// Get all conversations with participant info and last message
const { rows } = await db.query(`
SELECT
c.id,
c.subject,
c.created_at,
c.updated_at,
(SELECT content FROM direct_messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) as last_message,
(SELECT created_at FROM direct_messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) as last_message_at,
(SELECT COUNT(*) FROM direct_messages WHERE conversation_id = c.id) as message_count
FROM conversations c
ORDER BY c.updated_at DESC
`);
// Get participants for each conversation
const conversations = await Promise.all(rows.map(async (conv: any) => {
const { rows: participants } = await db.query(`
SELECT u.id, u.username, u.first_name, u.last_name, u.role
FROM conversation_participants cp
JOIN users u ON cp.user_id = u.id
WHERE cp.conversation_id = $1
ORDER BY u.role DESC, u.first_name
`, [conv.id]);
// Count unread for admins (messages from customers)
const { rows: unreadRows } = await db.query(`
SELECT COUNT(*) as unread
FROM direct_messages dm
JOIN users u ON dm.sender_id = u.id
WHERE dm.conversation_id = $1
AND u.role != 'admin'
AND dm.created_at > COALESCE(
(SELECT joined_at FROM conversation_participants WHERE conversation_id = $1 AND user_id = (SELECT id FROM users WHERE role = 'admin' LIMIT 1)),
'1970-01-01'::timestamp
)
`, [conv.id]);
return {
...conv,
participants,
unread_count: parseInt(unreadRows[0]?.unread || '0')
};
}));
return NextResponse.json({ conversations });
} catch (error) {
console.error('Admin get conversations error:', error);
return NextResponse.json(
{ error: getErrorMessage(error, 'Failed to get conversations') },
{ status: 500 }
);
}
}
+40 -11
View File
@@ -3,17 +3,46 @@ import { requireRole } from '@/lib/auth';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import sharp from 'sharp'; import sharp from 'sharp';
function generateThumbnail(base64Data: string, width = 300, height = 200): Promise<string> { interface ImageSizes {
thumbnail: string;
medium: string;
data: string;
}
async function generateImageSizes(base64Data: string): Promise<ImageSizes> {
const matches = base64Data.match(/^data:image\/(\w+);base64,(.+)$/); const matches = base64Data.match(/^data:image\/(\w+);base64,(.+)$/);
if (!matches) return Promise.resolve(base64Data); if (!matches) {
return { thumbnail: base64Data, medium: base64Data, data: base64Data };
}
const ext = matches[1]; const ext = matches[1];
const buffer = Buffer.from(matches[2], 'base64'); const buffer = Buffer.from(matches[2], 'base64');
return sharp(buffer) // Convert to WebP for better compression
.resize(width, height, { fit: 'cover' }) const toBase64 = (buf: Buffer) => `data:image/webp;base64,${buf.toString('base64')}`;
.toBuffer()
.then(resized => `data:image/${ext};base64,${resized.toString('base64')}`); // Generate multiple sizes in parallel
const [thumbnail, medium] = await Promise.all([
sharp(buffer)
.resize(400, 300, { fit: 'cover' })
.webp({ quality: 80 })
.toBuffer(),
sharp(buffer)
.resize(800, 600, { fit: 'inside', withoutEnlargement: true })
.webp({ quality: 85 })
.toBuffer(),
]);
// Also convert full image to WebP
const fullImage = await sharp(buffer)
.webp({ quality: 90 })
.toBuffer();
return {
thumbnail: toBase64(thumbnail),
medium: toBase64(medium),
data: toBase64(fullImage),
};
} }
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
@@ -42,7 +71,7 @@ export async function GET(request: NextRequest) {
} }
const { rows } = await db.query(query, params); 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) { } catch (error) {
if ((error as Error).message === 'Unauthorized') { if ((error as Error).message === 'Unauthorized') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
@@ -62,12 +91,12 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Missing filename or data' }, { status: 400 }); return NextResponse.json({ error: 'Missing filename or data' }, { status: 400 });
} }
// Generate thumbnail for faster loading // Generate multiple sizes for responsive images (thumbnail, medium, full)
const thumbnail = await generateThumbnail(data, 400, 300); const sizes = await generateImageSizes(data);
const { rows } = await db.query( const { rows } = await db.query(
'INSERT INTO images (filename, data, thumbnail, category, room_id, location_target) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, filename, data, thumbnail, category, room_id, location_target, uploaded_at', 'INSERT INTO images (filename, data, thumbnail, medium, category, room_id, location_target) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, filename, data, thumbnail, medium, category, room_id, location_target, uploaded_at',
[filename, data, thumbnail, category || 'other', room_id || null, location_target || 'gallery'] [filename, sizes.data, sizes.thumbnail, sizes.medium, category || 'other', room_id || null, location_target || 'gallery']
); );
return NextResponse.json({ data: rows[0] }); return NextResponse.json({ data: rows[0] });
+131
View File
@@ -0,0 +1,131 @@
import { NextResponse } from 'next/server';
import { requireRole } from '@/lib/auth';
import { db } from '@/lib/db';
import sharp from 'sharp';
export const maxDuration = 300; // 5 minutes for large migrations
async function convertToWebP(base64Data: string): Promise<string> {
const matches = base64Data.match(/^data:image\/(\w+);base64,(.+)$/);
if (!matches) {
return base64Data;
}
const buffer = Buffer.from(matches[2], 'base64');
const webpBuffer = await sharp(buffer)
.webp({ quality: 90 })
.toBuffer();
return `data:image/webp;base64,${webpBuffer.toString('base64')}`;
}
export async function POST() {
try {
await requireRole('admin');
console.log('Starting WebP migration...');
const { rows: images } = await db.query(
'SELECT id, filename, data, thumbnail, medium FROM images'
);
console.log(`Found ${images.length} images to process`);
let converted = 0;
let skipped = 0;
let errors: number[] = [];
let totalSavedKB = 0;
for (const img of images) {
try {
let needsUpdate = false;
const updates: { data?: string; thumbnail?: string; medium?: string } = {};
if (img.data && !img.data.includes('image/webp')) {
console.log(`Converting image ${img.id}: ${img.filename} (full)`);
updates.data = await convertToWebP(img.data);
needsUpdate = true;
}
if (img.thumbnail && !img.thumbnail.includes('image/webp')) {
console.log(`Converting image ${img.id}: ${img.filename} (thumbnail)`);
updates.thumbnail = await convertToWebP(img.thumbnail);
needsUpdate = true;
}
if (img.medium && !img.medium.includes('image/webp')) {
console.log(`Converting image ${img.id}: ${img.filename} (medium)`);
updates.medium = await convertToWebP(img.medium);
needsUpdate = true;
}
if (needsUpdate) {
const oldSize = (img.data?.length || 0) + (img.thumbnail?.length || 0) + (img.medium?.length || 0);
const newSize = (updates.data?.length || img.data?.length || 0) +
(updates.thumbnail?.length || img.thumbnail?.length || 0) +
(updates.medium?.length || img.medium?.length || 0);
const savedKB = Math.round((oldSize - newSize) / 1024);
totalSavedKB += savedKB;
await db.query(
'UPDATE images SET data = $1, thumbnail = $2, medium = $3 WHERE id = $4',
[updates.data || img.data, updates.thumbnail || img.thumbnail, updates.medium || img.medium, img.id]
);
converted++;
} else {
skipped++;
}
} catch (err) {
console.error(`Error processing image ${img.id}:`, err);
errors.push(img.id);
}
}
return NextResponse.json({
success: true,
total: images.length,
converted,
skipped,
errors,
totalSavedMB: Math.round(totalSavedKB / 1024 * 10) / 10,
});
} catch (error) {
console.error('Migration error:', error);
if ((error as Error).message === 'Unauthorized') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
return NextResponse.json({ error: 'Migration failed', details: (error as Error).message }, { status: 500 });
}
}
export async function GET() {
try {
await requireRole('admin');
const { rows: images } = await db.query(
'SELECT id, filename, LENGTH(data) as data_size, LENGTH(thumbnail) as thumb_size, LENGTH(medium) as medium_size FROM images'
);
let totalSize = 0;
let webpCount = 0;
let jpegCount = 0;
for (const img of images) {
totalSize += (img.data_size || 0) + (img.thumb_size || 0) + (img.medium_size || 0);
if (img.data_size) {
// We'd need to fetch the actual data to check format, so estimate based on size
}
}
return NextResponse.json({
totalImages: images.length,
totalSizeKB: Math.round(totalSize / 1024),
totalSizeMB: Math.round(totalSize / 1024 / 1024 * 10) / 10,
});
} catch (error) {
if ((error as Error).message === 'Unauthorized') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
return NextResponse.json({ error: 'Failed to get stats' }, { status: 500 });
}
}
@@ -0,0 +1,46 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
export async function GET(request: Request) {
try {
const user = await getSession();
if (!user || user.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { rows } = await db.query(`
SELECT r.*, u.username, u.first_name, u.last_name
FROM reservations r
LEFT JOIN users u ON r.user_id = u.id
ORDER BY r.date DESC, r.time DESC
`);
return NextResponse.json({ reservations: rows });
} catch (error) {
console.error('Get restaurant reservations error:', error);
return NextResponse.json({ error: 'Failed to get reservations' }, { status: 500 });
}
}
export async function DELETE(request: Request) {
try {
const user = await getSession();
if (!user || user.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (!id) {
return NextResponse.json({ error: 'Reservation ID required' }, { status: 400 });
}
await db.query('DELETE FROM reservations WHERE id = $1', [id]);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Delete restaurant reservation error:', error);
return NextResponse.json({ error: 'Failed to delete reservation' }, { status: 500 });
}
}
@@ -0,0 +1,118 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
export async function GET(request: Request) {
try {
const user = await getSession();
if (!user || user.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const status = searchParams.get('status') || 'all';
let query = `
SELECT r.*, rm.name as room_name,
u.username, u.first_name, u.last_name, u.email, u.phone
FROM room_reservations r
JOIN rooms rm ON r.room_id = rm.id
LEFT JOIN users u ON r.user_id = u.id
`;
const params: any[] = [];
if (status !== 'all') {
query += ' WHERE r.status = $1';
params.push(status);
}
query += ' ORDER BY r.created_at DESC';
const { rows } = await db.query(query, params);
return NextResponse.json({ reservations: rows });
} catch (error) {
console.error('Get room reservations error:', error);
return NextResponse.json({ error: 'Failed to get reservations' }, { status: 500 });
}
}
export async function PATCH(request: Request) {
try {
const user = await getSession();
if (!user || user.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { id, status } = body;
if (!id || !status) {
return NextResponse.json({ error: 'ID and status required' }, { status: 400 });
}
await db.query(
'UPDATE room_reservations SET status = $1, updated_at = NOW() WHERE id = $2',
[status, id]
);
// If cancelling, free up the blocked dates
if (status === 'cancelled') {
const { rows: reservation } = await db.query(
'SELECT room_id, check_in, check_out FROM room_reservations WHERE id = $1',
[id]
);
if (reservation.length > 0) {
const { room_id, check_in, check_out } = reservation[0];
await db.query(`
DELETE FROM room_availability
WHERE room_id = $1 AND date >= $2 AND date < $3 AND reason = $4
`, [room_id, check_in, check_out, `Reservation #${id}`]);
}
}
return NextResponse.json({ success: true });
} catch (error) {
console.error('Update room reservation error:', error);
return NextResponse.json({ error: 'Failed to update reservation' }, { status: 500 });
}
}
export async function DELETE(request: Request) {
try {
const user = await getSession();
if (!user || user.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (!id) {
return NextResponse.json({ error: 'Reservation ID required' }, { status: 400 });
}
// Get reservation details before deleting
const { rows: reservation } = await db.query(
'SELECT room_id, check_in, check_out FROM room_reservations WHERE id = $1',
[id]
);
// Delete the reservation
await db.query('DELETE FROM room_reservations WHERE id = $1', [id]);
// Free up blocked dates
if (reservation.length > 0) {
const { room_id, check_in, check_out } = reservation[0];
await db.query(`
DELETE FROM room_availability
WHERE room_id = $1 AND date >= $2 AND date < $3 AND reason = $4
`, [room_id, check_in, check_out, `Reservation #${id}`]);
}
return NextResponse.json({ success: true });
} catch (error) {
console.error('Delete room reservation error:', error);
return NextResponse.json({ error: 'Failed to delete reservation' }, { status: 500 });
}
}
+217
View File
@@ -0,0 +1,217 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { requireRole } from '@/lib/auth';
import { getErrorMessage } from '@/lib/errors';
// Get all rooms with their current status and reservation info
export async function GET() {
try {
await requireRole('admin');
// Get all rooms with current reservation info and staff assignment
const { rows } = await db.query(`
SELECT
r.id,
r.name,
r.price,
r.clean_status,
r.notes,
r.active,
r.last_cleaned,
r.assigned_staff_id,
r.priority,
r.issues_count,
r.is_vip,
r.checkout_time,
r.checkout_date,
rr.id as reservation_id,
rr.guest_name,
rr.check_in,
rr.check_out,
rr.status as reservation_status,
rr.payment_status,
u.first_name,
u.last_name,
u.username,
s.first_name as staff_first_name,
s.last_name as staff_last_name,
CASE
WHEN rr.id IS NOT NULL AND rr.status = 'confirmed'
AND CURRENT_DATE >= rr.check_in
AND CURRENT_DATE <= rr.check_out
THEN 'occupied'
ELSE 'available'
END as occupancy_status
FROM rooms r
LEFT JOIN room_reservations rr ON r.id = rr.room_id
AND rr.status = 'confirmed'
AND CURRENT_DATE >= rr.check_in
AND CURRENT_DATE <= rr.check_out
LEFT JOIN users u ON rr.user_id = u.id
LEFT JOIN users s ON r.assigned_staff_id = s.id
ORDER BY
CASE r.priority
WHEN 'urgent' THEN 1
WHEN 'normal' THEN 2
WHEN 'low' THEN 3
END,
r.name
`);
// Get upcoming reservations for each room
const rooms = await Promise.all(rows.map(async (room: any) => {
const { rows: upcoming } = await db.query(`
SELECT
rr.id,
rr.guest_name,
rr.check_in,
rr.check_out,
rr.status,
rr.payment_status,
u.first_name,
u.last_name
FROM room_reservations rr
LEFT JOIN users u ON rr.user_id = u.id
WHERE rr.room_id = $1
AND rr.status = 'confirmed'
AND rr.check_in > CURRENT_DATE
ORDER BY rr.check_in
LIMIT 3
`, [room.id]);
return {
...room,
upcoming_reservations: upcoming,
current_guest: room.guest_name || (room.first_name ? `${room.first_name} ${room.last_name || ''}`.trim() : room.username) || null,
assigned_staff: room.assigned_staff_id ? {
id: room.assigned_staff_id,
first_name: room.staff_first_name,
last_name: room.staff_last_name,
} : null,
};
}));
// Get all staff members (users who can be assigned to rooms)
const { rows: staff } = await db.query(`
SELECT id, first_name, last_name, username
FROM users
WHERE role IN ('admin', 'staff') OR role = 'user'
ORDER BY first_name, last_name
`);
return NextResponse.json({ rooms, staff });
} catch (error) {
console.error('Get room status error:', error);
return NextResponse.json(
{ error: getErrorMessage(error, 'Failed to get room status') },
{ status: 500 }
);
}
}
// Update room status
export async function PATCH(request: NextRequest) {
try {
await requireRole('admin');
const body = await request.json();
const {
id,
clean_status,
notes,
last_cleaned,
assigned_staff_id,
priority,
issues_count,
is_vip,
checkout_time,
checkout_date,
} = body;
if (!id) {
return NextResponse.json({ error: 'Room ID is required' }, { status: 400 });
}
const updates: string[] = [];
const values: any[] = [];
let paramIndex = 1;
if (clean_status !== undefined) {
updates.push(`clean_status = $${paramIndex}`);
values.push(clean_status);
paramIndex++;
}
if (notes !== undefined) {
updates.push(`notes = $${paramIndex}`);
values.push(notes);
paramIndex++;
}
if (last_cleaned !== undefined) {
updates.push(`last_cleaned = $${paramIndex}`);
values.push(last_cleaned);
paramIndex++;
}
if (assigned_staff_id !== undefined) {
updates.push(`assigned_staff_id = $${paramIndex}`);
values.push(assigned_staff_id || null);
paramIndex++;
}
if (priority !== undefined) {
updates.push(`priority = $${paramIndex}`);
values.push(priority);
paramIndex++;
}
if (issues_count !== undefined) {
updates.push(`issues_count = $${paramIndex}`);
values.push(issues_count);
paramIndex++;
}
if (is_vip !== undefined) {
updates.push(`is_vip = $${paramIndex}`);
values.push(is_vip);
paramIndex++;
}
if (checkout_time !== undefined) {
updates.push(`checkout_time = $${paramIndex}`);
values.push(checkout_time || null);
paramIndex++;
}
if (checkout_date !== undefined) {
updates.push(`checkout_date = $${paramIndex}`);
values.push(checkout_date || null);
paramIndex++;
}
// Auto-set last_cleaned when status changes to 'clean'
if (clean_status === 'clean') {
updates.push(`last_cleaned = NOW()`);
}
if (updates.length === 0) {
return NextResponse.json({ error: 'No updates provided' }, { status: 400 });
}
values.push(id);
const { rows } = await db.query(
`UPDATE rooms SET ${updates.join(', ')}, updated_at = NOW() WHERE id = $${paramIndex} RETURNING *`,
values
);
return NextResponse.json({ room: rows[0] });
} catch (error) {
console.error('Update room status error:', error);
return NextResponse.json(
{ error: getErrorMessage(error, 'Failed to update room status') },
{ status: 500 }
);
}
}
+16 -105
View File
@@ -1,114 +1,25 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { requireRole, createUser } from '@/lib/auth';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { requireRole } from '@/lib/auth';
import { getErrorMessage } from '@/lib/errors';
// Admin: Get all users for recipient selection
export async function GET() { export async function GET() {
try { try {
await requireRole('admin'); await requireRole('admin');
const { rows } = await db.query('SELECT id, username, role, comments_disabled, first_name, last_name, preferred_language, terms_accepted_at, email, phone, country, created_at FROM users ORDER BY created_at DESC');
return NextResponse.json({ data: rows });
} catch (err: any) {
return NextResponse.json({ error: err.message || 'Failed to fetch users' }, { status: err.message === 'Unauthorized' ? 401 : 500 });
}
}
export async function POST(request: NextRequest) { const { rows } = await db.query(`
try { SELECT id, username, first_name, last_name, role, email, created_at
await requireRole('admin'); FROM users
const body = await request.json(); ORDER BY role, first_name, last_name
const { username, password, role = 'user', first_name, last_name, email, phone, country, preferred_language = 'es' } = body; `);
if (!username || !password) { return NextResponse.json({ users: rows });
return NextResponse.json({ error: 'Username and password are required' }, { status: 400 }); } catch (error) {
} console.error('Get users error:', error);
return NextResponse.json(
if (role !== 'admin' && role !== 'user') { { error: getErrorMessage(error, 'Failed to get users') },
return NextResponse.json({ error: 'Role must be admin or user' }, { status: 400 }); { status: 500 }
}
const user = await createUser(username, password, role as 'admin' | 'user', { first_name, last_name, email, phone, country, preferred_language });
return NextResponse.json({ ok: true, user: { id: user.id, username: user.username, role: user.role, first_name: user.first_name, last_name: user.last_name } });
} catch (err: any) {
return NextResponse.json({ error: err.message || 'Failed to create user' }, { status: err.message === 'Unauthorized' ? 401 : 500 });
}
}
export async function PUT(request: NextRequest) {
try {
await requireRole('admin');
const body = await request.json();
const { id, first_name, last_name, comments_disabled, preferred_language, email, phone, country, password } = body;
// Build dynamic update query
const updates: string[] = [];
const values: any[] = [];
let paramIndex = 1;
if (first_name !== undefined) {
updates.push(`first_name = $${paramIndex++}`);
values.push(first_name || null);
}
if (last_name !== undefined) {
updates.push(`last_name = $${paramIndex++}`);
values.push(last_name || null);
}
if (comments_disabled !== undefined) {
updates.push(`comments_disabled = $${paramIndex++}`);
values.push(comments_disabled === true);
}
if (preferred_language !== undefined) {
updates.push(`preferred_language = $${paramIndex++}`);
values.push(preferred_language || 'es');
}
if (email !== undefined) {
updates.push(`email = $${paramIndex++}`);
values.push(email || null);
}
if (phone !== undefined) {
updates.push(`phone = $${paramIndex++}`);
values.push(phone || null);
}
if (country !== undefined) {
updates.push(`country = $${paramIndex++}`);
values.push(country || null);
}
if (password !== undefined && password) {
const { hashPassword } = await import('@/lib/auth');
updates.push(`password_hash = $${paramIndex++}`);
values.push(await hashPassword(password));
}
if (updates.length === 0) {
return NextResponse.json({ error: 'No fields to update' }, { status: 400 });
}
values.push(id);
const { rows } = await db.query(
`UPDATE users SET ${updates.join(', ')} WHERE id = $${paramIndex} RETURNING id, username, role, first_name, last_name, comments_disabled, preferred_language, email, phone, country, created_at`,
values
); );
if (rows.length === 0) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
return NextResponse.json({ data: rows[0] });
} catch (err: any) {
return NextResponse.json({ error: err.message || 'Failed to update user' }, { status: err.message === 'Unauthorized' ? 401 : 500 });
} }
} }
export async function DELETE(request: NextRequest) {
try {
await requireRole('admin');
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (!id) {
return NextResponse.json({ error: 'User ID required' }, { status: 400 });
}
await db.query('DELETE FROM users WHERE id = $1', [id]);
return NextResponse.json({ ok: true });
} catch (err: any) {
return NextResponse.json({ error: err.message || 'Failed to delete user' }, { status: err.message === 'Unauthorized' ? 401 : 500 });
}
}
+2 -1
View File
@@ -8,7 +8,7 @@ export async function GET() {
return NextResponse.json({ authenticated: false }, { status: 401 }); return NextResponse.json({ authenticated: false }, { status: 401 });
} }
const { rows } = await db.query( const { rows } = await db.query(
'SELECT id, username, role, first_name, last_name, comments_disabled FROM users WHERE id = $1', 'SELECT id, username, role, first_name, last_name, email, comments_disabled FROM users WHERE id = $1',
[session.id] [session.id]
); );
const user = rows[0] || {}; const user = rows[0] || {};
@@ -19,6 +19,7 @@ export async function GET() {
id: session.id, id: session.id,
first_name: user.first_name || null, first_name: user.first_name || null,
last_name: user.last_name || null, last_name: user.last_name || null,
email: user.email || null,
comments_disabled: user.comments_disabled === true, comments_disabled: user.comments_disabled === true,
}); });
} }
+6 -2
View File
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { createUser } from '@/lib/auth'; import { createUser } from '@/lib/auth';
import { getErrorMessage } from '@/lib/errors';
export async function POST() { export async function POST() {
try { try {
@@ -12,7 +13,10 @@ export async function POST() {
const user = await createUser(username, password, 'admin'); const user = await createUser(username, password, 'admin');
return NextResponse.json({ ok: true, user: { id: user.id, username: user.username, role: user.role } }); return NextResponse.json({ ok: true, user: { id: user.id, username: user.username, role: user.role } });
} catch (err: any) { } catch (err: unknown) {
return NextResponse.json({ error: err?.message || 'Failed to create bootstrap user' }, { status: 500 }); return NextResponse.json(
{ error: getErrorMessage(err, 'Failed to create bootstrap user') },
{ status: 500 }
);
} }
} }
@@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { requireAuth } from '@/lib/auth';
import { getErrorMessage } from '@/lib/errors';
// Send a message to a conversation
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const user = await requireAuth();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;
const { content } = await request.json();
if (!content || content.trim().length === 0) {
return NextResponse.json({ error: 'Message content is required' }, { status: 400 });
}
// Verify user is participant
const { rows: participants } = await db.query(
'SELECT * FROM conversation_participants WHERE conversation_id = $1 AND user_id = $2',
[id, user.id]
);
if (participants.length === 0) {
return NextResponse.json({ error: 'Not authorized for this conversation' }, { status: 403 });
}
// Create message
const { rows: msgRows } = await db.query(
'INSERT INTO direct_messages (conversation_id, sender_id, content) VALUES ($1, $2, $3) RETURNING *',
[id, user.id, content]
);
// Update conversation updated_at
await db.query(
'UPDATE conversations SET updated_at = NOW() WHERE id = $1',
[id]
);
// Get sender info
const { rows: userRows } = await db.query(
'SELECT id, username, first_name, last_name, role FROM users WHERE id = $1',
[user.id]
);
return NextResponse.json({
message: {
...msgRows[0],
sender_id: user.id,
username: userRows[0].username,
first_name: userRows[0].first_name,
last_name: userRows[0].last_name,
role: userRows[0].role
}
});
} catch (error) {
console.error('Send message error:', error);
return NextResponse.json(
{ error: getErrorMessage(error, 'Failed to send message') },
{ status: 500 }
);
}
}
+76
View File
@@ -0,0 +1,76 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { requireAuth } from '@/lib/auth';
import { getErrorMessage } from '@/lib/errors';
// Get messages for a conversation
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const user = await requireAuth();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;
// Verify user is participant
const { rows: participants } = await db.query(
'SELECT * FROM conversation_participants WHERE conversation_id = $1 AND user_id = $2',
[id, user.id]
);
if (participants.length === 0) {
return NextResponse.json({ error: 'Not authorized for this conversation' }, { status: 403 });
}
// Get all messages with sender info
const { rows: messages } = await db.query(`
SELECT
dm.id,
dm.content,
dm.created_at,
u.id as sender_id,
u.username,
u.first_name,
u.last_name,
u.role
FROM direct_messages dm
JOIN users u ON dm.sender_id = u.id
WHERE dm.conversation_id = $1
ORDER BY dm.created_at ASC
`, [id]);
// Get conversation details with participants
const { rows: convRows } = await db.query(`
SELECT
c.id,
c.subject,
c.created_at,
c.updated_at
FROM conversations c
WHERE c.id = $1
`, [id]);
const { rows: participantRows } = await db.query(`
SELECT u.id, u.username, u.first_name, u.last_name, u.role
FROM conversation_participants cp
JOIN users u ON cp.user_id = u.id
WHERE cp.conversation_id = $1
`, [id]);
return NextResponse.json({
conversation: convRows[0],
participants: participantRows,
messages
});
} catch (error) {
console.error('Get messages error:', error);
return NextResponse.json(
{ error: getErrorMessage(error, 'Failed to get messages') },
{ status: 500 }
);
}
}
+127
View File
@@ -0,0 +1,127 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { requireAuth } from '@/lib/auth';
import { getErrorMessage } from '@/lib/errors';
// Get all conversations for the current user
export async function GET() {
try {
const user = await requireAuth();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Get all conversations where user is a participant
const { rows } = await db.query(`
SELECT
c.id,
c.subject,
c.created_at,
c.updated_at,
(SELECT content FROM direct_messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) as last_message,
(SELECT created_at FROM direct_messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) as last_message_at,
(SELECT COUNT(*) FROM direct_messages WHERE conversation_id = c.id AND created_at > COALESCE(
(SELECT joined_at FROM conversation_participants WHERE conversation_id = c.id AND user_id = $1), c.created_at
)) as unread_count
FROM conversations c
JOIN conversation_participants cp ON c.id = cp.conversation_id
WHERE cp.user_id = $1
ORDER BY c.updated_at DESC
`, [user.id]);
// Get other participants for each conversation
const conversations = await Promise.all(rows.map(async (conv: any) => {
const { rows: participants } = await db.query(`
SELECT u.id, u.username, u.first_name, u.last_name, u.role
FROM conversation_participants cp
JOIN users u ON cp.user_id = u.id
WHERE cp.conversation_id = $1
`, [conv.id]);
return {
...conv,
participants
};
}));
return NextResponse.json({ conversations });
} catch (error) {
console.error('Get conversations error:', error);
return NextResponse.json(
{ error: getErrorMessage(error, 'Failed to get conversations') },
{ status: 500 }
);
}
}
// Create a new conversation
export async function POST(request: NextRequest) {
try {
const user = await requireAuth();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { subject, initial_message, recipient_ids } = await request.json();
if (!initial_message || initial_message.trim().length === 0) {
return NextResponse.json({ error: 'Message is required' }, { status: 400 });
}
// Create conversation
const { rows: convRows } = await db.query(
'INSERT INTO conversations (subject, created_by) VALUES ($1, $2) RETURNING *',
[subject || null, user.id]
);
const conversation = convRows[0];
// Add creator as participant
await db.query(
'INSERT INTO conversation_participants (conversation_id, user_id) VALUES ($1, $2)',
[conversation.id, user.id]
);
// Add other participants (admins for customer messages, specific user for direct)
if (recipient_ids && Array.isArray(recipient_ids)) {
for (const recipientId of recipient_ids) {
await db.query(
'INSERT INTO conversation_participants (conversation_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
[conversation.id, recipientId]
);
}
}
// If user is not admin, add all admins as participants
if (user.role !== 'admin') {
const { rows: admins } = await db.query(
"SELECT id FROM users WHERE role = 'admin'"
);
for (const admin of admins) {
await db.query(
'INSERT INTO conversation_participants (conversation_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
[conversation.id, admin.id]
);
}
}
// Add initial message
const { rows: msgRows } = await db.query(
'INSERT INTO direct_messages (conversation_id, sender_id, content) VALUES ($1, $2, $3) RETURNING *',
[conversation.id, user.id, initial_message]
);
// Update conversation updated_at
await db.query(
'UPDATE conversations SET updated_at = NOW() WHERE id = $1',
[conversation.id]
);
return NextResponse.json({ conversation, message: msgRows[0] });
} catch (error) {
console.error('Create conversation error:', error);
return NextResponse.json(
{ error: getErrorMessage(error, 'Failed to create conversation') },
{ status: 500 }
);
}
}
+14 -5
View File
@@ -6,12 +6,19 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const id = searchParams.get('id'); const id = searchParams.get('id');
const thumb = searchParams.get('thumb'); // ?thumb=1 for thumbnail const thumb = searchParams.get('thumb'); // ?thumb=1 for thumbnail
const medium = searchParams.get('medium'); // ?medium=1 for medium size
if (!id) { if (!id) {
return NextResponse.json({ error: 'Missing id' }, { status: 400 }); return NextResponse.json({ error: 'Missing id' }, { status: 400 });
} }
const { rows } = await db.query('SELECT data FROM images WHERE id = $1', [id]); // Select appropriate column based on size
const column = thumb ? 'thumbnail' : medium ? 'medium' : 'data';
const { rows } = await db.query(
`SELECT ${column} as data FROM images WHERE id = $1`,
[id]
);
if (!rows.length) { if (!rows.length) {
return NextResponse.json({ error: 'Not found' }, { status: 404 }); return NextResponse.json({ error: 'Not found' }, { status: 404 });
@@ -19,10 +26,12 @@ export async function GET(request: NextRequest) {
const imageData = rows[0].data; const imageData = rows[0].data;
// If thumbnail requested and we have thumbnail data stored if (!imageData) {
if (thumb) { // Fallback to full image if variant doesn't exist
// For now, return the full image (thumbnail generation happens on upload) const { rows: fallback } = await db.query('SELECT data FROM images WHERE id = $1', [id]);
// Future: store thumbnail separately in thumbnail column if (!fallback.length || !fallback[0].data) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
} }
// Parse base64 data // Parse base64 data
+91
View File
@@ -0,0 +1,91 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
export async function POST(request: Request) {
try {
const user = await getSession();
const { room_id, message, guest_name, guest_contact_method, guest_contact } = await request.json();
if (!message || !message.trim()) {
return NextResponse.json({ error: 'Message is required' }, { status: 400 });
}
// For guests, require name and contact info
if (!user && (!guest_name || !guest_contact)) {
return NextResponse.json({ error: 'Name and contact info are required' }, { status: 400 });
}
// Insert message - for guests, user_id is NULL
const { rows } = await db.query(`
INSERT INTO messages (room_id, user_id, message, guest_name, guest_contact_method, guest_contact)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *
`, [room_id || null, user?.id || null, message.trim(), guest_name || null, guest_contact_method || null, guest_contact || null]);
// Get admin emails for notification
const { rows: admins } = await db.query(`SELECT email, phone FROM users WHERE role = 'admin' AND (email IS NOT NULL OR phone IS NOT NULL)`);
// TODO: Send email/SMS notifications to admins
return NextResponse.json({ success: true, message: 'Message sent successfully' });
} catch (error) {
console.error('Message error:', error);
return NextResponse.json({ error: 'Failed to send message' }, { status: 500 });
}
}
export async function GET(request: Request) {
try {
const user = await getSession();
if (!user || user.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const unreadOnly = searchParams.get('unread') === 'true';
let query = `
SELECT m.*, r.name as room_name, u.username, u.first_name, u.last_name, u.email, u.phone
FROM messages m
LEFT JOIN rooms r ON m.room_id = r.id
LEFT JOIN users u ON m.user_id = u.id
`;
if (unreadOnly) {
query += ' WHERE m.read_by_admins = FALSE';
}
query += ' ORDER BY m.created_at DESC LIMIT 100';
const { rows } = await db.query(query);
return NextResponse.json({ messages: rows });
} catch (error) {
console.error('Get messages error:', error);
return NextResponse.json({ error: 'Failed to get messages' }, { status: 500 });
}
}
export async function PATCH(request: Request) {
try {
const user = await getSession();
if (!user || user.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { message_ids } = await request.json();
if (!message_ids || !Array.isArray(message_ids)) {
return NextResponse.json({ error: 'message_ids array is required' }, { status: 400 });
}
await db.query(`
UPDATE messages SET read_by_admins = TRUE WHERE id = ANY($1)
`, [message_ids]);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Mark messages read error:', error);
return NextResponse.json({ error: 'Failed to mark messages as read' }, { status: 500 });
}
}
+195
View File
@@ -0,0 +1,195 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
export async function POST(request: Request) {
try {
const user = await getSession();
const { items, order_type, room_id, notes } = await request.json();
// items is array of { menu_item_id, quantity }
if (!items || !Array.isArray(items) || items.length === 0) {
return NextResponse.json({ error: 'Order items are required' }, { status: 400 });
}
if (!order_type || !['pickup', 'dine_in', 'room_delivery'].includes(order_type)) {
return NextResponse.json({ error: 'Valid order type is required (pickup, dine_in, room_delivery)' }, { status: 400 });
}
// Room delivery requires login
if (order_type === 'room_delivery' && !user) {
return NextResponse.json({ error: 'Room delivery requires login. Please log in to continue.' }, { status: 401 });
}
// Room delivery requires room_id
if (order_type === 'room_delivery' && !room_id) {
return NextResponse.json({ error: 'Room selection is required for room delivery' }, { status: 400 });
}
// Get menu item prices
const menuItemIds = items.map((i: { menu_item_id: number }) => i.menu_item_id);
const { rows: menuItems } = await db.query(
'SELECT id, name, price FROM menu_items WHERE id = ANY($1)',
[menuItemIds]
);
if (menuItems.length !== menuItemIds.length) {
return NextResponse.json({ error: 'One or more menu items not found' }, { status: 400 });
}
// Calculate total
const priceMap = new Map(menuItems.map((m: { id: number; price: string }) => [m.id, parseFloat(m.price)]));
let subtotal = 0;
const orderItems = items.map((i: { menu_item_id: number; quantity: number }) => {
const price = priceMap.get(i.menu_item_id) || 0;
const itemTotal = price * i.quantity;
subtotal += itemTotal;
return {
menu_item_id: i.menu_item_id,
quantity: i.quantity,
unit_price: price,
total_price: itemTotal,
};
});
// Room delivery adds 10% service fee
const serviceFee = order_type === 'room_delivery' ? subtotal * 0.1 : 0;
const total = subtotal + serviceFee;
// Create order
const { rows: orderRows } = await db.query(`
INSERT INTO orders (user_id, order_type, room_id, subtotal, service_fee, total, status, notes)
VALUES ($1, $2, $3, $4, $5, $6, 'pending', $7)
RETURNING *
`, [user?.id || null, order_type, order_type === 'room_delivery' ? room_id : null, subtotal, serviceFee, total, notes || null]);
const orderId = orderRows[0].id;
// Insert order items
for (const item of orderItems) {
await db.query(`
INSERT INTO order_items (order_id, menu_item_id, quantity, unit_price, total_price)
VALUES ($1, $2, $3, $4, $5)
`, [orderId, item.menu_item_id, item.quantity, item.unit_price, item.total_price]);
}
// Get full order with items
const { rows: fullOrder } = await db.query(`
SELECT o.*,
json_agg(json_build_object(
'menu_item_id', oi.menu_item_id,
'quantity', oi.quantity,
'unit_price', oi.unit_price,
'total_price', oi.total_price,
'name', m.name
)) as items
FROM orders o
LEFT JOIN order_items oi ON o.id = oi.order_id
LEFT JOIN menu_items m ON oi.menu_item_id = m.id
WHERE o.id = $1
GROUP BY o.id
`, [orderId]);
// Get admin notification targets
const { rows: admins } = await db.query(
'SELECT email, phone FROM users WHERE role = \'admin\' AND (email IS NOT NULL OR phone IS NOT NULL)'
);
// TODO: Send notification to admins (email/SMS)
return NextResponse.json({
success: true,
order: fullOrder[0],
message: order_type === 'room_delivery'
? 'Order placed! We\'ll deliver it to your room shortly.'
: order_type === 'dine_in'
? 'Order placed! We\'ll prepare it for dine-in.'
: 'Order placed! We\'ll have it ready for pickup.'
});
} catch (error) {
console.error('Order error:', error);
return NextResponse.json({ error: 'Failed to create order' }, { status: 500 });
}
}
export async function GET(request: Request) {
try {
const user = await getSession();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const status = searchParams.get('status') || 'all';
const limit = parseInt(searchParams.get('limit') || '50');
const kitchen = searchParams.get('kitchen') === 'true';
let query = `
SELECT o.*, r.name as room_name, u.username, u.first_name, u.last_name
FROM orders o
LEFT JOIN rooms r ON o.room_id = r.id
LEFT JOIN users u ON o.user_id = u.id
`;
const params: any[] = [];
if (status !== 'all') {
query += ' WHERE o.status = $1';
params.push(status);
}
query += ' ORDER BY o.created_at DESC LIMIT $' + (params.length + 1);
params.push(limit);
const { rows } = await db.query(query, params);
// Get items for each order
for (const order of rows) {
const { rows: items } = await db.query(`
SELECT oi.*, m.name, m.description
FROM order_items oi
JOIN menu_items m ON oi.menu_item_id = m.id
WHERE oi.order_id = $1
`, [order.id]);
order.items = items;
}
return NextResponse.json({ orders: rows });
} catch (error) {
console.error('Get orders error:', error);
return NextResponse.json({ error: 'Failed to get orders' }, { status: 500 });
}
}
export async function PATCH(request: Request) {
try {
const user = await getSession();
if (!user || user.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { order_id, status } = await request.json();
if (!order_id || !status) {
return NextResponse.json({ error: 'order_id and status are required' }, { status: 400 });
}
const validStatuses = ['pending', 'preparing', 'ready', 'delivered', 'cancelled'];
if (!validStatuses.includes(status)) {
return NextResponse.json({ error: 'Invalid status' }, { status: 400 });
}
const { rows } = await db.query(
'UPDATE orders SET status = $1, updated_at = NOW() WHERE id = $2 RETURNING *',
[status, order_id]
);
if (rows.length === 0) {
return NextResponse.json({ error: 'Order not found' }, { status: 404 });
}
return NextResponse.json({ success: true, order: rows[0] });
} catch (error) {
console.error('Update order error:', error);
return NextResponse.json({ error: 'Failed to update order' }, { status: 500 });
}
}
@@ -0,0 +1,145 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { requireAuth } from '@/lib/auth';
import { auditLog, getAuditClientIp, getAuditUserAgent } from '@/lib/audit';
import { withRateLimit } from '@/lib/rate-limit';
// GET - Get a single private event reservation (admin only)
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await requireAuth();
if (session.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
}
// Rate limiting
const rateCheck = withRateLimit(request, 'admin-private-events');
if (!rateCheck.allowed) {
return rateCheck.response;
}
const { rows } = await db.query(
'SELECT * FROM private_event_reservations WHERE id = $1',
[params.id]
);
if (rows.length === 0) {
return NextResponse.json({ error: 'Reservation not found' }, { status: 404 });
}
return NextResponse.json({ data: rows[0] });
} catch (error) {
console.error('Error fetching reservation:', error);
return NextResponse.json({ error: 'Failed to fetch reservation' }, { status: 500 });
}
}
// PUT - Update reservation status (admin only)
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await requireAuth();
if (session.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
}
// Rate limiting
const rateCheck = withRateLimit(request, 'admin-private-events');
if (!rateCheck.allowed) {
return rateCheck.response;
}
const body = await request.json();
const { status } = body;
if (!status || !['pending', 'confirmed', 'cancelled'].includes(status)) {
return NextResponse.json({ error: 'Invalid status. Must be pending, confirmed, or cancelled' }, { status: 400 });
}
// Get current status for audit log
const { rows: beforeRows } = await db.query(
'SELECT * FROM private_event_reservations WHERE id = $1',
[params.id]
);
if (beforeRows.length === 0) {
return NextResponse.json({ error: 'Reservation not found' }, { status: 404 });
}
const oldStatus = beforeRows[0].status;
const { rows } = await db.query(
`UPDATE private_event_reservations SET status = $1 WHERE id = $2 RETURNING *`,
[status, params.id]
);
// Audit log
await auditLog({
userId: session.id,
action: 'status_change',
entityType: 'private_event_reservation',
entityId: parseInt(params.id, 10),
oldValues: { status: oldStatus },
newValues: { status },
ipAddress: getAuditClientIp(request),
userAgent: getAuditUserAgent(request),
});
return NextResponse.json({ data: rows[0] });
} catch (error) {
console.error('Error updating reservation:', error);
return NextResponse.json({ error: 'Failed to update reservation' }, { status: 500 });
}
}
// DELETE - Delete a reservation (admin only)
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await requireAuth();
if (session.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
}
// Rate limiting
const rateCheck = withRateLimit(request, 'admin-private-events');
if (!rateCheck.allowed) {
return rateCheck.response;
}
// Get the reservation before deleting for audit log
const { rows: beforeRows } = await db.query(
'SELECT * FROM private_event_reservations WHERE id = $1',
[params.id]
);
if (beforeRows.length === 0) {
return NextResponse.json({ error: 'Reservation not found' }, { status: 404 });
}
await db.query('DELETE FROM private_event_reservations WHERE id = $1', [params.id]);
// Audit log
await auditLog({
userId: session.id,
action: 'delete',
entityType: 'private_event_reservation',
entityId: parseInt(params.id, 10),
oldValues: beforeRows[0],
ipAddress: getAuditClientIp(request),
userAgent: getAuditUserAgent(request),
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting reservation:', error);
return NextResponse.json({ error: 'Failed to delete reservation' }, { status: 500 });
}
}
@@ -0,0 +1,148 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { requireAuth } from '@/lib/auth';
import { withRateLimit, getClientIp } from '@/lib/rate-limit';
import { getErrorMessage } from '@/lib/errors';
// Honeypot field name - bots often autofill all fields
// This field should remain empty for legitimate submissions
const HONEYPOT_FIELD = 'website_url';
// GET - List all private event reservations (admin only)
export async function GET(request: NextRequest) {
try {
const session = await requireAuth();
if (session.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
}
// Rate limiting for admin endpoint
const rateCheck = withRateLimit(request, 'admin-private-events');
if (!rateCheck.allowed) {
return rateCheck.response;
}
const { searchParams } = new URL(request.url);
const status = searchParams.get('status');
let query = 'SELECT * FROM private_event_reservations';
const params: any[] = [];
if (status && ['pending', 'confirmed', 'cancelled'].includes(status)) {
query += ' WHERE status = $1 ORDER BY created_at DESC';
params.push(status);
} else {
query += ' ORDER BY status = \'pending\' DESC, created_at DESC';
}
const { rows } = await db.query(query, params);
return NextResponse.json({ data: rows });
} catch (error) {
console.error('Error fetching private event reservations:', error);
return NextResponse.json(
{ error: getErrorMessage(error, 'Failed to fetch reservations') },
{ status: 500 }
);
}
}
// POST - Create a new private event reservation (public endpoint)
export async function POST(request: NextRequest) {
try {
// Rate limiting
const rateCheck = withRateLimit(request, 'private-event-reservation');
if (!rateCheck.allowed) {
return rateCheck.response;
}
const body = await request.json();
const {
event_name,
contact_name,
contact_email,
contact_phone,
contact_method,
event_date,
event_time,
guests,
notes,
// Honeypot field - should be empty
[HONEYPOT_FIELD]: honeypot
} = body;
// Honeypot check - reject if filled (bot detection)
if (honeypot && honeypot.trim() !== '') {
// Silently reject but return success to confuse bots
console.log('Honeypot triggered, rejecting submission from:', getClientIp(request));
return NextResponse.json({
data: { id: Date.now(), status: 'pending' }
});
}
// Validation
if (!event_name || !contact_name || !contact_method || !guests) {
return NextResponse.json({ error: 'Missing required fields: event_name, contact_name, contact_method, guests' }, { status: 400 });
}
if (!['email', 'phone', 'whatsapp'].includes(contact_method)) {
return NextResponse.json({ error: 'Invalid contact method. Must be email, phone, or whatsapp' }, { status: 400 });
}
// Validate email format if provided
if (contact_email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(contact_email)) {
return NextResponse.json({ error: 'Invalid email format' }, { status: 400 });
}
// Validate phone format if provided (basic international format)
if (contact_phone && !/^[\d\s\-+()]{7,20}$/.test(contact_phone)) {
return NextResponse.json({ error: 'Invalid phone format' }, { status: 400 });
}
// Validate guests is positive
const guestsNum = parseInt(guests, 10);
if (isNaN(guestsNum) || guestsNum < 1 || guestsNum > 1000) {
return NextResponse.json({ error: 'Guests must be between 1 and 1000' }, { status: 400 });
}
// Length limits (prevent abuse)
if (event_name.length > 255 || contact_name.length > 255) {
return NextResponse.json({ error: 'Event name and contact name must be under 255 characters' }, { status: 400 });
}
if (contact_email && contact_email.length > 255) {
return NextResponse.json({ error: 'Email must be under 255 characters' }, { status: 400 });
}
if (contact_phone && contact_phone.length > 50) {
return NextResponse.json({ error: 'Phone must be under 50 characters' }, { status: 400 });
}
if (notes && notes.length > 2000) {
return NextResponse.json({ error: 'Notes must be under 2000 characters' }, { status: 400 });
}
// Validate event_date is in the future if provided
if (event_date) {
const date = new Date(event_date);
if (isNaN(date.getTime())) {
return NextResponse.json({ error: 'Invalid date format' }, { status: 400 });
}
if (date < new Date()) {
return NextResponse.json({ error: 'Event date must be in the future' }, { status: 400 });
}
}
const { rows } = await db.query(
`INSERT INTO private_event_reservations
(event_name, contact_name, contact_email, contact_phone, contact_method, event_date, event_time, guests, notes, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending')
RETURNING id, event_name, guests, event_date, event_time, status, created_at`,
[event_name, contact_name, contact_email, contact_phone, contact_method, event_date, event_time, guestsNum, notes]
);
return NextResponse.json({ data: rows[0] });
} catch (error) {
console.error('Error creating private event reservation:', error);
return NextResponse.json(
{ error: getErrorMessage(error, 'Failed to create reservation') },
{ status: 500 }
);
}
}
+149
View File
@@ -0,0 +1,149 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
import { getErrorMessage } from '@/lib/errors';
export async function POST(request: Request) {
try {
const user = await getSession();
const body = await request.json();
const { room_id, check_in, check_out, guest_name, guest_contact_method, guest_contact } = body;
// Require either logged-in user or guest info
if (!user && (!guest_name || !guest_contact)) {
return NextResponse.json({ error: 'Please provide your name and contact information' }, { status: 400 });
}
if (!room_id || !check_in || !check_out) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
// Check if room exists
const { rows: roomRows } = await db.query('SELECT * FROM rooms WHERE id = $1', [room_id]);
if (roomRows.length === 0) {
return NextResponse.json({ error: 'Room not found' }, { status: 404 });
}
const room = roomRows[0];
// Check for conflicting reservations
const { rows: conflicts } = await db.query(`
SELECT * FROM room_reservations
WHERE room_id = $1
AND status != 'cancelled'
AND (
(check_in <= $2 AND check_out > $2) OR
(check_in < $3 AND check_out >= $3) OR
(check_in >= $2 AND check_out <= $3)
)
`, [room_id, check_in, check_out]);
if (conflicts.length > 0) {
return NextResponse.json({ error: 'Room is not available for selected dates' }, { status: 409 });
}
// Check for blocked dates
const { rows: blocked } = await db.query(`
SELECT * FROM room_availability
WHERE room_id = $1
AND date >= $2
AND date < $3
AND status != 'available'
`, [room_id, check_in, check_out]);
if (blocked.length > 0) {
return NextResponse.json({ error: 'Some dates are not available' }, { status: 409 });
}
// Calculate total price
const nights = Math.ceil((new Date(check_out).getTime() - new Date(check_in).getTime()) / (1000 * 60 * 60 * 24));
const pricePerNight = parseFloat(room.price) || 0;
const total_price = nights * pricePerNight;
// Create reservation - for guests, user_id is NULL
const { rows } = await db.query(`
INSERT INTO room_reservations (room_id, user_id, check_in, check_out, status, total_price, guest_name, guest_contact_method, guest_contact)
VALUES ($1, $2, $3, $4, 'pending', $5, $6, $7, $8)
RETURNING *
`, [
room_id,
user?.id || null,
check_in,
check_out,
total_price,
guest_name || null,
guest_contact_method || null,
guest_contact || null,
]);
// Block the dates
const insertPromises = [];
const checkInDate = new Date(check_in);
const checkOutDate = new Date(check_out);
for (let d = new Date(checkInDate); d < checkOutDate; d.setDate(d.getDate() + 1)) {
insertPromises.push(
db.query(`
INSERT INTO room_availability (room_id, date, status, reason)
VALUES ($1, $2, 'booked', $3)
ON CONFLICT (room_id, date) DO UPDATE SET status = 'booked', reason = $3
`, [room_id, d.toISOString().split('T')[0], `Reservation #${rows[0].id}`])
);
}
await Promise.all(insertPromises);
// Get admin emails for notification
const { rows: admins } = await db.query(`SELECT email, phone FROM users WHERE role = 'admin' AND email IS NOT NULL`);
// TODO: Send email notifications to admins
// TODO: Send welcome email to user with WiFi password and instructions
return NextResponse.json({
success: true,
reservation: rows[0],
message: 'Reservation request submitted. You will receive confirmation shortly.'
});
} catch (error) {
console.error('Reservation error:', error);
return NextResponse.json(
{ error: getErrorMessage(error, 'Failed to create reservation') },
{ status: 500 }
);
}
}
export async function GET(request: Request) {
try {
const user = await getSession();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const status = searchParams.get('status') || 'all';
let query = `
SELECT r.*, rm.name as room_name,
u.username, u.first_name, u.last_name, u.email, u.phone
FROM room_reservations r
JOIN rooms rm ON r.room_id = rm.id
LEFT JOIN users u ON r.user_id = u.id
`;
const params: any[] = [];
if (status !== 'all') {
query += ' WHERE r.status = $1';
params.push(status);
}
query += ' ORDER BY r.created_at DESC';
const { rows } = await db.query(query, params);
return NextResponse.json({ reservations: rows });
} catch (error) {
console.error('Get reservations error:', error);
return NextResponse.json(
{ error: getErrorMessage(error, 'Failed to get reservations') },
{ status: 500 }
);
}
}
+111
View File
@@ -0,0 +1,111 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
// GET - list all room assignments (admin) or current user's assignment
export async function GET(request: Request) {
try {
const user = await getSession();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const onlyActive = searchParams.get('active') === 'true';
if (user.role !== 'admin') {
// Regular user - return their current active assignment
const today = new Date().toISOString().split('T')[0];
const { rows } = await db.query(`
SELECT ra.*, r.name as room_name, r.price
FROM room_assignments ra
JOIN rooms r ON ra.room_id = r.id
WHERE ra.user_id = $1 AND ra.check_in <= $2 AND ra.check_out >= $2
`, [user.id, today]);
return NextResponse.json({ assignments: rows });
}
// Admin - return all assignments
let query = `
SELECT ra.*, r.name as room_name, u.username, u.first_name, u.last_name, u.email
FROM room_assignments ra
JOIN rooms r ON ra.room_id = r.id
JOIN users u ON ra.user_id = u.id
`;
const params: any[] = [];
if (onlyActive) {
const today = new Date().toISOString().split('T')[0];
query += ' WHERE ra.check_in <= $1 AND ra.check_out >= $1';
params.push(today);
}
query += ' ORDER BY ra.check_in DESC';
const { rows } = await db.query(query, params);
return NextResponse.json({ assignments: rows });
} catch (error) {
console.error('Get room assignments error:', error);
return NextResponse.json({ error: 'Failed to get room assignments' }, { status: 500 });
}
}
// POST - create new room assignment (admin only)
export async function POST(request: Request) {
try {
const user = await getSession();
if (!user || user.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { room_id, user_id, check_in, check_out, notes } = await request.json();
if (!room_id || !user_id || !check_in || !check_out) {
return NextResponse.json({ error: 'Room, user, check-in, and check-out are required' }, { status: 400 });
}
// Check for conflicting assignments
const { rows: conflicts } = await db.query(`
SELECT id FROM room_assignments
WHERE room_id = $1 AND check_in < $3 AND check_out > $2
`, [room_id, check_in, check_out]);
if (conflicts.length > 0) {
return NextResponse.json({ error: 'Room has conflicting assignment for these dates' }, { status: 400 });
}
const { rows } = await db.query(`
INSERT INTO room_assignments (room_id, user_id, check_in, check_out, notes, created_by)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *
`, [room_id, user_id, check_in, check_out, notes || null, user.id]);
return NextResponse.json({ success: true, assignment: rows[0] });
} catch (error) {
console.error('Create room assignment error:', error);
return NextResponse.json({ error: 'Failed to create room assignment' }, { status: 500 });
}
}
// DELETE - remove room assignment (admin only)
export async function DELETE(request: Request) {
try {
const user = await getSession();
if (!user || user.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (!id) {
return NextResponse.json({ error: 'Assignment ID required' }, { status: 400 });
}
await db.query('DELETE FROM room_assignments WHERE id = $1', [id]);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Delete room assignment error:', error);
return NextResponse.json({ error: 'Failed to delete room assignment' }, { status: 500 });
}
}
@@ -0,0 +1,90 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
try {
const roomId = parseInt(params.id);
const { searchParams } = new URL(request.url);
const start = searchParams.get('start');
const end = searchParams.get('end');
if (!start || !end) {
return NextResponse.json({ error: 'start and end dates required' }, { status: 400 });
}
// Get availability status for the date range
const { rows: availability } = await db.query(`
SELECT date, status, reason
FROM room_availability
WHERE room_id = $1 AND date >= $2 AND date <= $3
`, [roomId, start, end]);
// Get reservations in the date range
const { rows: reservations } = await db.query(`
SELECT check_in, check_out, status
FROM reservations
WHERE room_id = $1
AND status != 'cancelled'
AND check_in <= $3
AND check_out >= $2
`, [roomId, start, end]);
// Build day-by-day status
const days: Array<{ date: string; status: 'available' | 'booked' | 'maintenance' }> = [];
const startDate = new Date(start);
const endDate = new Date(end);
const availabilityMap = new Map(availability.map(a => [a.date, a]));
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
const dateStr = d.toISOString().split('T')[0];
const avail = availabilityMap.get(dateStr);
if (avail) {
days.push({ date: dateStr, status: avail.status as 'available' | 'booked' | 'maintenance' });
} else {
// Check if date falls within any reservation
const isBooked = reservations.some(r => {
const checkIn = new Date(r.check_in);
const checkOut = new Date(r.check_out);
return d >= checkIn && d < checkOut;
});
days.push({ date: dateStr, status: isBooked ? 'booked' : 'available' });
}
}
return NextResponse.json({ days });
} catch (error) {
console.error('Availability error:', error);
return NextResponse.json({ error: 'Failed to get availability' }, { status: 500 });
}
}
export async function POST(
request: Request,
{ params }: { params: { id: string } }
) {
try {
const roomId = parseInt(params.id);
const { date, status, reason } = await request.json();
if (!date || !status) {
return NextResponse.json({ error: 'date and status required' }, { status: 400 });
}
const { rows } = await db.query(`
INSERT INTO room_availability (room_id, date, status, reason)
VALUES ($1, $2, $3, $4)
ON CONFLICT (room_id, date) DO UPDATE SET status = $3, reason = $4
RETURNING *
`, [roomId, date, status, reason || null]);
return NextResponse.json({ success: true, availability: rows[0] });
} catch (error) {
console.error('Set availability error:', error);
return NextResponse.json({ error: 'Failed to set availability' }, { status: 500 });
}
}
+5 -5
View File
@@ -9,9 +9,9 @@ export async function GET(request: NextRequest, { params }: { params: { id: stri
const photoUrl = searchParams.get('photo_url') || null; const photoUrl = searchParams.get('photo_url') || null;
const { rows } = await db.query( const { rows } = await db.query(
`SELECT id, room_id, photo_url, user_id, first_name, last_initial, text, created_at, deleted_by_admin `SELECT id, room_id, photo_url, user_id, first_name, last_initial, text, created_at, deleted_by_admin, status
FROM room_comments FROM room_comments
WHERE room_id = $1 AND ($2::varchar IS NULL OR photo_url = $2) AND deleted_by_admin = FALSE WHERE room_id = $1 AND ($2::varchar IS NULL OR photo_url = $2) AND deleted_by_admin = FALSE AND status = 'approved'
ORDER BY created_at DESC`, ORDER BY created_at DESC`,
[roomId, photoUrl] [roomId, photoUrl]
); );
@@ -47,12 +47,12 @@ export async function POST(request: NextRequest, { params }: { params: { id: str
const lastInitial = user.last_name ? user.last_name.charAt(0).toUpperCase() : null; const lastInitial = user.last_name ? user.last_name.charAt(0).toUpperCase() : null;
const { rows: inserted } = await db.query( const { rows: inserted } = await db.query(
`INSERT INTO room_comments (room_id, photo_url, user_id, first_name, last_initial, text) `INSERT INTO room_comments (room_id, photo_url, user_id, first_name, last_initial, text, status)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, VALUES ($1, $2, $3, $4, $5, $6, 'pending') RETURNING *`,
[roomId, photo_url || null, session.id, firstName, lastInitial, text.trim()] [roomId, photo_url || null, session.id, firstName, lastInitial, text.trim()]
); );
return NextResponse.json({ data: inserted[0] }); return NextResponse.json({ data: inserted[0], message: 'Comment submitted for review' });
} catch (err: any) { } catch (err: any) {
return NextResponse.json( return NextResponse.json(
{ error: err.message || 'Failed to post comment' }, { error: err.message || 'Failed to post comment' },
+66 -30
View File
@@ -1,54 +1,90 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
export async function GET(request: NextRequest, { params }: { params: { id: string } }) { // GET - check if user liked a room and get total likes
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try { try {
const roomId = parseInt(params.id, 10); const roomId = parseInt(params.id, 10);
if (isNaN(roomId)) { const session = await getSession();
return NextResponse.json({ error: 'Invalid room ID' }, { status: 400 });
// Get total likes
const { rows: countRows } = await db.query(
'SELECT COUNT(*) as count FROM room_likes WHERE room_id = $1',
[roomId]
);
const totalLikes = parseInt(countRows[0].count) || 0;
// Check if current user liked this room
let likedByUser = false;
if (session) {
const { rows: likeRows } = await db.query(
'SELECT 1 FROM room_likes WHERE room_id = $1 AND user_id = $2',
[roomId, session.id]
);
likedByUser = likeRows.length > 0;
} }
const [countResult, userLikeResult] = await Promise.all([ return NextResponse.json({ totalLikes, likedByUser });
db.query('SELECT COUNT(*) AS count FROM room_likes WHERE room_id = $1', [roomId]),
db.query('SELECT 1 FROM room_likes WHERE room_id = $1 AND user_id = $2', [roomId, 1]),
]);
return NextResponse.json({
count: parseInt(countResult.rows[0].count, 10),
user_liked: userLikeResult.rows.length > 0,
});
} catch (err: any) { } catch (err: any) {
// Table may not exist yet — return safe defaults return NextResponse.json({ error: err.message || 'Failed to get likes' }, { status: 500 });
return NextResponse.json({ count: 0, user_liked: false });
} }
} }
export async function POST(request: NextRequest, { params }: { params: { id: string } }) { // POST - toggle like (add if not liked, remove if already liked)
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try { try {
const roomId = parseInt(params.id, 10); const session = await getSession();
if (isNaN(roomId)) { if (!session) {
return NextResponse.json({ error: 'Invalid room ID' }, { status: 400 }); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }
// TODO: get user_id from session/auth instead of hardcoded const roomId = parseInt(params.id, 10);
const userId = 1;
// Toggle: if already liked, unlike; otherwise like // Check if already liked
const existing = await db.query( const { rows: existing } = await db.query(
'SELECT id FROM room_likes WHERE room_id = $1 AND user_id = $2', 'SELECT 1 FROM room_likes WHERE room_id = $1 AND user_id = $2',
[roomId, userId] [roomId, session.id]
); );
if (existing.rows.length > 0) { let liked: boolean;
await db.query('DELETE FROM room_likes WHERE id = $1', [existing.rows[0].id]); if (existing.length > 0) {
// Unlike - remove the like
await db.query(
'DELETE FROM room_likes WHERE room_id = $1 AND user_id = $2',
[roomId, session.id]
);
liked = false;
} else { } else {
await db.query('INSERT INTO room_likes (room_id, user_id) VALUES ($1, $2)', [roomId, userId]); // Like - add the like
await db.query(
'INSERT INTO room_likes (room_id, user_id) VALUES ($1, $2)',
[roomId, session.id]
);
liked = true;
} }
const countResult = await db.query('SELECT COUNT(*) AS count FROM room_likes WHERE room_id = $1', [roomId]); // Get updated count
const { rows: countRows } = await db.query(
'SELECT COUNT(*) as count FROM room_likes WHERE room_id = $1',
[roomId]
);
const totalLikes = parseInt(countRows[0].count) || 0;
return NextResponse.json({ count: parseInt(countResult.rows[0].count, 10) }); // Update the rooms table for quick access
await db.query(
'UPDATE rooms SET likes_count = $1 WHERE id = $2',
[totalLikes, roomId]
);
return NextResponse.json({ liked, totalLikes });
} catch (err: any) { } catch (err: any) {
return NextResponse.json({ error: err.message || 'Failed to toggle like' }, { status: 500 }); return NextResponse.json({ error: err.message || 'Failed to toggle like' }, { status: 500 });
} }
} }
+12 -4
View File
@@ -29,15 +29,16 @@ export async function PUT(request: NextRequest, { params }: { params: { id: stri
const id = parseInt(params.id); const id = parseInt(params.id);
if (isNaN(id)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 }); if (isNaN(id)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
const body = await request.json(); 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) { if (!name || !description || price === undefined) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
} }
// Photos are stored in images table, not rooms.photos
const { rows } = await db.query( 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 *`, `UPDATE rooms SET name = $1, description = $2, price = $3, sort_order = $4, featured_photo = $5, active = $6 WHERE id = $7 RETURNING *`,
[name, description, price, photos || [], sort_order || 0, featured_photo || null, active !== false, id] [name, description, price, sort_order || 0, featured_photo || null, active !== false, id]
); );
// Update amenities // 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}`); 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) { } catch (error: any) {
if ((error as Error).message === 'Unauthorized') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); if ((error as Error).message === 'Unauthorized') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
console.error('PUT /api/rooms/[id]:', error); console.error('PUT /api/rooms/[id]:', error);
+64 -10
View File
@@ -6,17 +6,39 @@ export async function GET() {
try { try {
const { rows } = await db.query('SELECT * FROM rooms WHERE active = TRUE ORDER BY sort_order, id'); const { rows } = await db.query('SELECT * FROM rooms WHERE active = TRUE ORDER BY sort_order, id');
// Get amenity links for all rooms // Get amenity details with icons for all rooms
const { rows: amenityLinks } = await db.query('SELECT room_id, amenity_id FROM room_amenity_links'); const { rows: amenityRows } = await db.query(`
const amenityMap = new Map<number, number[]>(); SELECT ral.room_id, ra.id, ra.name, ra.icon_svg
for (const link of amenityLinks) { FROM room_amenity_links ral
if (!amenityMap.has(link.room_id)) amenityMap.set(link.room_id, []); JOIN room_amenities ra ON ral.amenity_id = ra.id
amenityMap.get(link.room_id)!.push(link.amenity_id); ORDER BY ra.sort_order
`);
const amenityMap = new Map<number, Array<{id: number; name: string; icon_svg: string}>>();
for (const row of amenityRows) {
if (!amenityMap.has(row.room_id)) amenityMap.set(row.room_id, []);
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<number, string[]>();
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 => ({ const roomsWithAmenities = rows.map(r => ({
...r, ...r,
amenity_ids: amenityMap.get(r.id) || [] photos: imageMap.get(r.id) || [],
amenities: amenityMap.get(r.id) || []
})); }));
return NextResponse.json({ data: roomsWithAmenities }); return NextResponse.json({ data: roomsWithAmenities });
@@ -30,17 +52,18 @@ export async function POST(request: NextRequest) {
try { try {
await requireRole('admin'); await requireRole('admin');
const body = await request.json(); 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) { if (!name || !description || price === undefined) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
} }
// Photos are now stored in images table, not rooms.photos
const { rows } = await db.query( const { rows } = await db.query(
`INSERT INTO rooms (name, description, price, photos, sort_order, featured_photo) `INSERT INTO rooms (name, description, price, photos, sort_order, featured_photo)
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`, 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; const roomId = rows[0].id;
@@ -51,9 +74,40 @@ export async function POST(request: NextRequest) {
await db.query(`INSERT INTO room_amenity_links (room_id, amenity_id) VALUES ${values}`); 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) { } catch (error: any) {
console.error('POST /api/rooms error:', error); console.error('POST /api/rooms error:', error);
return NextResponse.json({ error: error.message || 'Failed to create room' }, { status: 500 }); 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 });
}
}
@@ -0,0 +1,145 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { requireAuth } from '@/lib/auth';
import { auditLog, getAuditClientIp, getAuditUserAgent } from '@/lib/audit';
import { withRateLimit } from '@/lib/rate-limit';
// GET - Get a single social event reservation
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
await requireAuth();
// Rate limiting
const rateCheck = withRateLimit(request, 'admin-social-events');
if (!rateCheck.allowed) {
return rateCheck.response;
}
const { rows } = await db.query(
`SELECT ser.*, se.name as event_name
FROM social_event_reservations ser
JOIN social_events se ON se.id = ser.social_event_id
WHERE ser.id = $1`,
[params.id]
);
if (rows.length === 0) {
return NextResponse.json({ error: 'Reservation not found' }, { status: 404 });
}
return NextResponse.json({ data: rows[0] });
} catch (error) {
console.error('Error fetching reservation:', error);
return NextResponse.json({ error: 'Failed to fetch reservation' }, { status: 500 });
}
}
// PUT - Update reservation status (admin only)
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await requireAuth();
if (session.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
}
// Rate limiting
const rateCheck = withRateLimit(request, 'admin-social-events');
if (!rateCheck.allowed) {
return rateCheck.response;
}
const body = await request.json();
const { status } = body;
if (!status || !['pending', 'confirmed', 'cancelled'].includes(status)) {
return NextResponse.json({ error: 'Invalid status' }, { status: 400 });
}
// Get current status for audit log
const { rows: beforeRows } = await db.query(
'SELECT * FROM social_event_reservations WHERE id = $1',
[params.id]
);
if (beforeRows.length === 0) {
return NextResponse.json({ error: 'Reservation not found' }, { status: 404 });
}
const oldStatus = beforeRows[0].status;
const { rows } = await db.query(
`UPDATE social_event_reservations SET status = $1 WHERE id = $2 RETURNING *`,
[status, params.id]
);
// Audit log
await auditLog({
userId: session.id,
action: 'status_change',
entityType: 'social_event_reservation',
entityId: parseInt(params.id, 10),
oldValues: { status: oldStatus },
newValues: { status },
ipAddress: getAuditClientIp(request),
userAgent: getAuditUserAgent(request),
});
return NextResponse.json({ data: rows[0] });
} catch (error) {
console.error('Error updating reservation:', error);
return NextResponse.json({ error: 'Failed to update reservation' }, { status: 500 });
}
}
// DELETE - Delete a reservation (admin only)
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await requireAuth();
if (session.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
}
// Rate limiting
const rateCheck = withRateLimit(request, 'admin-social-events');
if (!rateCheck.allowed) {
return rateCheck.response;
}
// Get the reservation before deleting for audit log
const { rows: beforeRows } = await db.query(
'SELECT * FROM social_event_reservations WHERE id = $1',
[params.id]
);
if (beforeRows.length === 0) {
return NextResponse.json({ error: 'Reservation not found' }, { status: 404 });
}
await db.query('DELETE FROM social_event_reservations WHERE id = $1', [params.id]);
// Audit log
await auditLog({
userId: session.id,
action: 'delete',
entityType: 'social_event_reservation',
entityId: parseInt(params.id, 10),
oldValues: beforeRows[0],
ipAddress: getAuditClientIp(request),
userAgent: getAuditUserAgent(request),
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting reservation:', error);
return NextResponse.json({ error: 'Failed to delete reservation' }, { status: 500 });
}
}
+38 -12
View File
@@ -7,20 +7,31 @@ export async function GET(request: Request) {
const session = await requireAuth(); const session = await requireAuth();
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const socialEventId = searchParams.get('social_event_id'); const socialEventId = searchParams.get('social_event_id');
const status = searchParams.get('status');
let query = ` let query = `
SELECT ser.*, u.username, u.first_name, u.last_name, se.name as event_name SELECT ser.*, se.name as event_name
FROM social_event_reservations ser FROM social_event_reservations ser
JOIN users u ON u.id = ser.user_id
JOIN social_events se ON se.id = ser.social_event_id JOIN social_events se ON se.id = ser.social_event_id
`; `;
const params: (number | string)[] = []; const params: (number | string)[] = [];
if (session.role === 'admin') { if (session.role === 'admin') {
const conditions: string[] = [];
let paramCount = 1;
if (socialEventId) { if (socialEventId) {
query += ' WHERE ser.social_event_id = $1'; conditions.push(`ser.social_event_id = $${paramCount++}`);
params.push(parseInt(socialEventId, 10)); params.push(parseInt(socialEventId, 10));
} }
if (status && ['pending', 'confirmed', 'cancelled'].includes(status)) {
conditions.push(`ser.status = $${paramCount++}`);
params.push(status);
}
if (conditions.length > 0) {
query += ' WHERE ' + conditions.join(' AND ');
}
} else { } else {
query += ' WHERE ser.user_id = $1'; query += ' WHERE ser.user_id = $1';
params.push(session.id); params.push(session.id);
@@ -41,23 +52,38 @@ export async function GET(request: Request) {
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const session = await requireAuth();
const body = await request.json(); const body = await request.json();
const { social_event_id, guests, notes } = body; const { social_event_id, guests, notes, contact_name, contact_email, contact_phone, contact_method } = body;
if (!social_event_id || !guests) { if (!social_event_id || !guests) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
} }
// Try to authenticate, but allow guest submissions
let userId: number | null = null;
try {
const session = await requireAuth();
userId = session.id;
} catch {
// Guest submission - require contact info
if (!contact_name || !contact_method) {
return NextResponse.json({ error: 'Contact name and method required for guest reservations' }, { status: 400 });
}
if (!['email', 'phone', 'whatsapp'].includes(contact_method)) {
return NextResponse.json({ error: 'Invalid contact method' }, { status: 400 });
}
}
const { rows } = await db.query( const { rows } = await db.query(
`INSERT INTO social_event_reservations (user_id, social_event_id, guests, notes) `INSERT INTO social_event_reservations
VALUES ($1, $2, $3, $4) (user_id, social_event_id, guests, notes, status, contact_name, contact_email, contact_phone, contact_method)
ON CONFLICT (user_id, social_event_id) VALUES ($1, $2, $3, $4, 'pending', $5, $6, $7, $8)
DO UPDATE SET guests = EXCLUDED.guests, notes = EXCLUDED.notes, status = 'pending'
RETURNING *`, RETURNING *`,
[session.id, parseInt(social_event_id, 10), parseInt(guests, 10), notes || ''] [userId, parseInt(social_event_id, 10), parseInt(guests, 10), notes || null, contact_name || null, contact_email || null, contact_phone || null, contact_method || null]
); );
return NextResponse.json({ data: rows[0] }); return NextResponse.json({ data: rows[0] });
} catch (error: any) { } catch (error: any) {
console.error('POST /api/social_event_reservations error:', error); console.error('POST /api/social_event_reservations error:', error);
return NextResponse.json({ error: error.message || 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: error.message || 'Failed to create reservation' }, { status: 500 });
} }
} }
+4 -4
View File
@@ -7,13 +7,13 @@ export async function PUT(request: NextRequest, { params }: { params: { id: stri
await requireRole('admin'); await requireRole('admin');
const id = parseInt(params.id, 10); const id = parseInt(params.id, 10);
const body = await request.json(); const body = await request.json();
const { name, description, price, date, photos, featured_photo, active, sort_order } = body; const { name, description, price, date, photos, featured_photo, active, is_private, max_guests, sort_order } = body;
const { rows } = await db.query( const { rows } = await db.query(
`UPDATE social_events `UPDATE social_events
SET name=$1, description=$2, price=$3, date=$4, photos=$5, featured_photo=$6, active=$7, sort_order=$8 SET name=$1, description=$2, price=$3, date=$4, photos=$5, featured_photo=$6, active=$7, is_private=$8, max_guests=$9, sort_order=$10
WHERE id=$9 WHERE id=$11
RETURNING *`, RETURNING *`,
[name, description || '', price || null, date || null, photos || [], featured_photo || null, active !== false, sort_order || 0, id] [name, description || '', price || null, date || null, photos || [], featured_photo || null, active !== false, is_private || false, max_guests || null, sort_order || 0, id]
); );
if (rows.length === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 }); if (rows.length === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 });
return NextResponse.json({ data: rows[0] }); return NextResponse.json({ data: rows[0] });
+20 -9
View File
@@ -2,15 +2,26 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireRole } from '@/lib/auth'; import { requireRole } from '@/lib/auth';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
export async function GET() { export async function GET(request: NextRequest) {
try { try {
const { rows } = await db.query(` const { searchParams } = new URL(request.url);
const includePrivate = searchParams.get('includePrivate') === 'true';
// Public requests only see active, non-private events
// Admin requests with includePrivate=true see all events
let query = `
SELECT se.*, SELECT se.*,
(SELECT COUNT(*) FROM social_event_reservations ser WHERE ser.social_event_id = se.id) as reservation_count (SELECT COUNT(*) FROM social_event_reservations ser WHERE ser.social_event_id = se.id) as reservation_count
FROM social_events se FROM social_events se
WHERE se.active = TRUE `;
ORDER BY se.sort_order, se.date, se.id
`); if (includePrivate) {
query += ' ORDER BY se.is_private, se.sort_order, se.date, se.id';
} else {
query += ' WHERE se.active = TRUE AND (se.is_private = FALSE OR se.is_private IS NULL) ORDER BY se.sort_order, se.date, se.id';
}
const { rows } = await db.query(query);
return NextResponse.json({ data: rows }); return NextResponse.json({ data: rows });
} catch (error: any) { } catch (error: any) {
console.error('GET /api/social_events error:', error); console.error('GET /api/social_events error:', error);
@@ -22,12 +33,12 @@ export async function POST(request: NextRequest) {
try { try {
await requireRole('admin'); await requireRole('admin');
const body = await request.json(); const body = await request.json();
const { name, description, price, date, photos, featured_photo, active, sort_order } = body; const { name, description, price, date, photos, featured_photo, active, is_private, max_guests, sort_order } = body;
const { rows } = await db.query( const { rows } = await db.query(
`INSERT INTO social_events (name, description, price, date, photos, featured_photo, active, sort_order) `INSERT INTO social_events (name, description, price, date, photos, featured_photo, active, is_private, max_guests, sort_order)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *`, RETURNING *`,
[name, description || '', price || null, date || null, photos || [], featured_photo || null, active !== false, sort_order || 0] [name, description || '', price || null, date || null, photos || [], featured_photo || null, active !== false, is_private || false, max_guests || null, sort_order || 0]
); );
return NextResponse.json({ data: rows[0] }); return NextResponse.json({ data: rows[0] });
} catch (error: any) { } catch (error: any) {
+13 -4
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, hashPassword } from '@/lib/auth'; import { requireAuth, hashPassword } from '@/lib/auth';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { getErrorMessage } from '@/lib/errors';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
@@ -12,8 +13,12 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Current and new password are required' }, { status: 400 }); return NextResponse.json({ error: 'Current and new password are required' }, { status: 400 });
} }
if (new_password.length < 4) { if (new_password.length < 12) {
return NextResponse.json({ error: 'Password must be at least 4 characters' }, { status: 400 }); return NextResponse.json({ error: 'Password must be at least 12 characters' }, { status: 400 });
}
// Password complexity requirements
if (!/[A-Z]/.test(new_password) || !/[a-z]/.test(new_password) || !/[0-9]/.test(new_password)) {
return NextResponse.json({ error: 'Password must contain uppercase, lowercase, and numbers' }, { status: 400 });
} }
// Verify current password // Verify current password
@@ -33,7 +38,11 @@ export async function POST(request: NextRequest) {
await db.query('UPDATE users SET password_hash = $1 WHERE id = $2', [hash, session.id]); await db.query('UPDATE users SET password_hash = $1 WHERE id = $2', [hash, session.id]);
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} catch (err: any) { } catch (err: unknown) {
return NextResponse.json({ error: err.message || 'Failed to change password' }, { status: err.message === 'Unauthorized' ? 401 : 500 }); const message = err instanceof Error && err.message === 'Unauthorized' ? 'Unauthorized' : getErrorMessage(err, 'Failed to change password');
return NextResponse.json(
{ error: message },
{ status: err instanceof Error && err.message === 'Unauthorized' ? 401 : 500 }
);
} }
} }
+459 -9
View File
@@ -13,6 +13,19 @@ interface MenuItem {
featured_photo?: string | null; featured_photo?: string | null;
} }
interface Room {
id: number;
name: string;
}
interface User {
id: number;
username: string;
role: 'admin' | 'user';
first_name: string | null;
last_name: string | null;
}
const gold = '#E8A849'; const gold = '#E8A849';
const navy = '#010D1E'; const navy = '#010D1E';
const cream = '#f5f0e8'; const cream = '#f5f0e8';
@@ -20,15 +33,60 @@ const warm = '#a6683c';
export default function CoffeePage() { export default function CoffeePage() {
const [items, setItems] = useState<MenuItem[]>([]); const [items, setItems] = useState<MenuItem[]>([]);
const [rooms, setRooms] = useState<Room[]>([]);
const [user, setUser] = useState<User | null>(null);
const [assignedRoom, setAssignedRoom] = useState<Room | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
// Order state
const [showOrderModal, setShowOrderModal] = useState(false);
const [selectedItems, setSelectedItems] = useState<Map<number, number>>(new Map());
const [orderType, setOrderType] = useState<'pickup' | 'dine_in' | 'room_delivery'>('pickup');
const [selectedRoom, setSelectedRoom] = useState<number | null>(null);
const [notes, setNotes] = useState('');
const [submitting, setSubmitting] = useState(false);
const [orderResult, setOrderResult] = useState<{ success: boolean; message: string } | null>(null);
useEffect(() => { useEffect(() => {
fetch('/api/menu') Promise.all([
.then(async (res) => { fetch('/api/menu').then(async (res) => {
if (!res.ok) throw new Error('Failed to load menu'); if (!res.ok) throw new Error('Failed to load menu');
const json = await res.json(); return res.json();
setItems(json.data || []); }),
fetch('/api/auth/me').then(async (res) => {
if (res.ok) {
const data = await res.json();
if (data.authenticated && data.id) {
return { user: { id: data.id, username: data.username, role: data.role, first_name: data.first_name, last_name: data.last_name } };
}
}
return { user: null };
}),
fetch('/api/rooms').then(async (res) => {
if (!res.ok) return { rooms: [] };
return res.json();
}),
])
.then(async ([menuData, sessionData, roomsData]) => {
setItems(menuData.data || []);
setUser(sessionData.user || null);
setRooms(roomsData.rooms || []);
// Fetch user's active room assignment if logged in
if (sessionData.user) {
try {
const assignRes = await fetch('/api/room-assignments');
if (assignRes.ok) {
const assignData = await assignRes.json();
if (assignData.assignments?.length > 0) {
const assignment = assignData.assignments[0];
setAssignedRoom({ id: assignment.room_id, name: assignment.room_name });
setSelectedRoom(assignment.room_id);
}
}
} catch { /* ignore */ }
}
}) })
.catch((err) => setError(err.message)) .catch((err) => setError(err.message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
@@ -37,12 +95,111 @@ export default function CoffeePage() {
const categories = Array.from(new Set(items.map((i) => i.category))); const categories = Array.from(new Set(items.map((i) => i.category)));
const daily = items.find((i) => i.is_daily_special); const daily = items.find((i) => i.is_daily_special);
// Calculate order totals
const orderItems = items.filter((item) => selectedItems.has(item.id) && (selectedItems.get(item.id) || 0) > 0);
const subtotal = orderItems.reduce((sum, item) => {
const qty = selectedItems.get(item.id) || 0;
return sum + parseFloat(item.price) * qty;
}, 0);
const serviceFee = orderType === 'room_delivery' ? subtotal * 0.1 : 0;
const total = subtotal + serviceFee;
const updateQuantity = (itemId: number, delta: number) => {
setSelectedItems((prev) => {
const newMap = new Map(prev);
const current = newMap.get(itemId) || 0;
const newQty = Math.max(0, current + delta);
if (newQty === 0) {
newMap.delete(itemId);
} else {
newMap.set(itemId, newQty);
}
return newMap;
});
};
const handleSubmitOrder = async () => {
if (selectedItems.size === 0) {
alert('Please select at least one item');
return;
}
if (orderType === 'room_delivery' && !selectedRoom) {
alert('Please select a room for delivery');
return;
}
setSubmitting(true);
try {
const res = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
items: Array.from(selectedItems.entries()).map(([menu_item_id, quantity]) => ({
menu_item_id,
quantity,
})),
order_type: orderType,
room_id: orderType === 'room_delivery' ? selectedRoom : null,
notes: notes || null,
}),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Failed to place order');
}
setOrderResult({ success: true, message: data.message });
setSelectedItems(new Map());
setNotes('');
} catch (err) {
setOrderResult({ success: false, message: err instanceof Error ? err.message : 'Failed to place order' });
} finally {
setSubmitting(false);
}
};
const closeModal = () => {
setShowOrderModal(false);
setOrderResult(null);
};
return ( return (
<main style={{ padding: 'clamp(1rem, 5vw, 2rem)', maxWidth: '72rem', margin: '0 auto', minHeight: '60vh' }}> <main style={{ padding: 'clamp(1rem, 5vw, 2rem)', maxWidth: '72rem', margin: '0 auto', minHeight: '60vh' }}>
<h1 style={{ fontSize: 'clamp(28px, 6vw, 48px)', fontWeight: 400, fontStyle: 'italic', color: navy, margin: '0 0 0.25rem' }}>Coffee &amp; Kitchen</h1> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '1rem' }}>
<p style={{ color: '#555', marginBottom: '1.5rem', maxWidth: '50ch', fontSize: 'clamp(14px, 3vw, 16px)' }}> <div>
Locally sourced coffee, fresh pastries, and seasonal plates. <h1 style={{ fontSize: 'clamp(28px, 6vw, 48px)', fontWeight: 400, fontStyle: 'italic', color: navy, margin: '0 0 0.25rem' }}>Coffee &amp; Kitchen</h1>
</p> <p style={{ color: '#555', marginBottom: 0, maxWidth: '50ch', fontSize: 'clamp(14px, 3vw, 16px)' }}>
Locally sourced coffee, fresh pastries, and seasonal plates.
</p>
</div>
<button
onClick={() => setShowOrderModal(true)}
style={{
background: `linear-gradient(135deg, ${gold} 0%, ${warm} 100%)`,
color: 'white',
border: 'none',
padding: '0.75rem 1.5rem',
borderRadius: '8px',
fontSize: 'clamp(14px, 3vw, 16px)',
fontWeight: 600,
cursor: 'pointer',
boxShadow: '0 2px 8px rgba(232,168,73,0.3)',
transition: 'transform 0.2s, box-shadow 0.2s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(232,168,73,0.4)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(232,168,73,0.3)';
}}
>
Place Order
</button>
</div>
{loading && <p style={{ color: warm }}>Loading menu...</p>} {loading && <p style={{ color: warm }}>Loading menu...</p>}
{error && <p style={{ color: '#c23b22' }}>{error}</p>} {error && <p style={{ color: '#c23b22' }}>{error}</p>}
@@ -130,6 +287,299 @@ export default function CoffeePage() {
</div> </div>
</section> </section>
))} ))}
{/* Order Modal */}
{showOrderModal && (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.6)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '1rem',
zIndex: 1000,
}}
onClick={closeModal}
>
<div
style={{
background: 'white',
borderRadius: '16px',
maxWidth: 'min(95vw, 600px)',
width: '100%',
maxHeight: '90vh',
overflow: 'auto',
padding: 'clamp(1rem, 4vw, 1.5rem)',
}}
onClick={(e) => e.stopPropagation()}
>
{orderResult ? (
<div style={{ textAlign: 'center', padding: '2rem' }}>
<div style={{ fontSize: '48px', marginBottom: '1rem' }}>
{orderResult.success ? '✓' : '✕'}
</div>
<h3 style={{ margin: '0 0 1rem', color: navy }}>{orderResult.success ? 'Order Placed!' : 'Error'}</h3>
<p style={{ color: '#555', marginBottom: '1.5rem' }}>{orderResult.message}</p>
<button
onClick={closeModal}
style={{
background: navy,
color: 'white',
border: 'none',
padding: '0.75rem 2rem',
borderRadius: '8px',
fontSize: '16px',
cursor: 'pointer',
}}
>
Close
</button>
</div>
) : (
<>
<h3 style={{ margin: '0 0 1rem', color: navy, fontSize: '24px' }}>Place Your Order</h3>
{/* Order Type Selection */}
<div style={{ marginBottom: '1.5rem' }}>
<label style={{ display: 'block', fontWeight: 600, marginBottom: '0.5rem', color: navy }}>Order Type</label>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
{[
{ value: 'pickup', label: 'Pickup' },
{ value: 'dine_in', label: 'Dine In' },
{ value: 'room_delivery', label: 'Room Delivery (+10%)' },
].map((opt) => (
<button
key={opt.value}
onClick={() => setOrderType(opt.value as typeof orderType)}
disabled={opt.value === 'room_delivery' && !user}
style={{
flex: 1,
minWidth: '120px',
padding: '0.75rem 1rem',
border: `2px solid ${orderType === opt.value ? gold : '#ddd'}`,
borderRadius: '8px',
background: orderType === opt.value ? gold : 'white',
color: orderType === opt.value ? 'white' : opt.value === 'room_delivery' && !user ? '#aaa' : navy,
cursor: opt.value === 'room_delivery' && !user ? 'not-allowed' : 'pointer',
opacity: opt.value === 'room_delivery' && !user ? 0.5 : 1,
fontWeight: 500,
fontSize: '14px',
}}
>
{opt.label}
{opt.value === 'room_delivery' && !user && (
<div style={{ fontSize: '11px', marginTop: '2px' }}>Login required</div>
)}
</button>
))}
</div>
</div>
{/* Room Selection for Delivery */}
{orderType === 'room_delivery' && user && (
<div style={{ marginBottom: '1.5rem' }}>
<label style={{ display: 'block', fontWeight: 600, marginBottom: '0.5rem', color: navy }}>
{assignedRoom ? 'Your Room' : 'Select Room'}
</label>
{assignedRoom ? (
<div style={{
padding: '0.75rem',
border: '2px solid gold',
borderRadius: '8px',
background: '#fff9e6',
color: navy,
fontWeight: 500,
}}>
🏨 {assignedRoom.name}
</div>
) : (
<select
value={selectedRoom || ''}
onChange={(e) => setSelectedRoom(Number(e.target.value) || null)}
style={{
width: '100%',
padding: '0.75rem',
border: '2px solid #ddd',
borderRadius: '8px',
fontSize: '16px',
}}
>
<option value="">Select a room...</option>
{rooms.map((room) => (
<option key={room.id} value={room.id}>{room.name}</option>
))}
</select>
)}
</div>
)}
{/* Menu Items Selection */}
<div style={{ marginBottom: '1.5rem' }}>
<label style={{ display: 'block', fontWeight: 600, marginBottom: '0.5rem', color: navy }}>Select Items</label>
<div style={{ maxHeight: '300px', overflow: 'auto', border: '2px solid #ddd', borderRadius: '8px' }}>
{categories.map((category) => (
<div key={category}>
<div style={{ background: '#f5f5f5', padding: '0.5rem 1rem', fontWeight: 600, color: navy, borderBottom: '1px solid #ddd' }}>
{category}
</div>
{items
.filter((i) => i.category === category)
.map((item) => {
const qty = selectedItems.get(item.id) || 0;
return (
<div
key={item.id}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0.75rem 1rem',
borderBottom: '1px solid #eee',
}}
>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 500, color: navy }}>{item.name}</div>
<div style={{ color: warm, fontWeight: 600 }}>${item.price}</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<button
onClick={() => updateQuantity(item.id, -1)}
disabled={qty === 0}
style={{
width: '28px',
height: '28px',
border: `2px solid ${qty > 0 ? navy : '#ddd'}`,
borderRadius: '50%',
background: 'white',
color: qty > 0 ? navy : '#aaa',
cursor: qty > 0 ? 'pointer' : 'not-allowed',
fontSize: '16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
</button>
<span style={{ minWidth: '24px', textAlign: 'center', fontWeight: 600 }}>{qty}</span>
<button
onClick={() => updateQuantity(item.id, 1)}
style={{
width: '28px',
height: '28px',
border: `2px solid ${navy}`,
borderRadius: '50%',
background: navy,
color: 'white',
cursor: 'pointer',
fontSize: '16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
+
</button>
</div>
</div>
);
})}
</div>
))}
</div>
</div>
{/* Notes */}
<div style={{ marginBottom: '1.5rem' }}>
<label style={{ display: 'block', fontWeight: 600, marginBottom: '0.5rem', color: navy }}>Special Instructions (optional)</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Any allergies, preferences, or special requests..."
style={{
width: '100%',
padding: '0.75rem',
border: '2px solid #ddd',
borderRadius: '8px',
fontSize: '14px',
minHeight: '80px',
resize: 'vertical',
}}
/>
</div>
{/* Order Summary */}
{selectedItems.size > 0 && (
<div style={{ background: cream, borderRadius: '8px', padding: '1rem', marginBottom: '1.5rem' }}>
<h4 style={{ margin: '0 0 0.5rem', color: navy }}>Order Summary</h4>
{orderItems.map((item) => (
<div key={item.id} style={{ display: 'flex', justifyContent: 'space-between', fontSize: '14px', marginBottom: '0.25rem' }}>
<span>{item.name} × {selectedItems.get(item.id)}</span>
<span>${(parseFloat(item.price) * (selectedItems.get(item.id) || 0)).toFixed(2)}</span>
</div>
))}
<div style={{ borderTop: '1px solid #ddd', marginTop: '0.5rem', paddingTop: '0.5rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span>Subtotal</span>
<span>${subtotal.toFixed(2)}</span>
</div>
{orderType === 'room_delivery' && (
<div style={{ display: 'flex', justifyContent: 'space-between', color: warm }}>
<span>Service Fee (10%)</span>
<span>${serviceFee.toFixed(2)}</span>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', fontWeight: 700, marginTop: '0.25rem', fontSize: '16px' }}>
<span>Total</span>
<span>${total.toFixed(2)}</span>
</div>
</div>
</div>
)}
{/* Submit Button */}
<div style={{ display: 'flex', gap: '1rem' }}>
<button
onClick={closeModal}
style={{
flex: 1,
padding: '0.75rem',
border: '2px solid #ddd',
borderRadius: '8px',
background: 'white',
color: navy,
fontSize: '16px',
cursor: 'pointer',
}}
>
Cancel
</button>
<button
onClick={handleSubmitOrder}
disabled={submitting || selectedItems.size === 0}
style={{
flex: 2,
padding: '0.75rem',
border: 'none',
borderRadius: '8px',
background: `linear-gradient(135deg, ${gold} 0%, ${warm} 100%)`,
color: 'white',
fontSize: '16px',
fontWeight: 600,
cursor: submitting || selectedItems.size === 0 ? 'not-allowed' : 'pointer',
opacity: submitting || selectedItems.size === 0 ? 0.5 : 1,
}}
>
{submitting ? 'Placing Order...' : `Place Order • $${total.toFixed(2)}`}
</button>
</div>
</>
)}
</div>
</div>
)}
</main> </main>
); );
} }
+18
View File
@@ -88,6 +88,24 @@ a {
.navbar-actions { .navbar-actions {
margin-left: auto; margin-left: auto;
display: flex;
align-items: center;
gap: 0.5rem;
}
.navbar-icon-link {
color: var(--gold);
padding: 0.5rem;
border-radius: 0.375rem;
transition: background 0.2s, color 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.navbar-icon-link:hover {
background: rgba(212, 175, 55, 0.1);
color: #fff;
} }
.navbar-btn-ghost { .navbar-btn-ghost {
+343
View File
@@ -0,0 +1,343 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { formatDisplayDateTime } from '@/lib/date';
type User = { id: number; username: string; first_name: string; last_name: string; role: string };
type Message = { id: number; content: string; created_at: string; sender_id: number; username: string; first_name: string; last_name: string; role: string };
type Participant = { id: number; username: string; first_name: string; last_name: string; role: string };
type Conversation = {
id: number;
subject: string | null;
last_message: string | null;
last_message_at: string | null;
unread_count: number;
participants: Participant[];
};
export default function MessagesPage() {
const [user, setUser] = useState<User | null>(null);
const [conversations, setConversations] = useState<Conversation[]>([]);
const [selectedConv, setSelectedConv] = useState<number | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [participants, setParticipants] = useState<Participant[]>([]);
const [newMessage, setNewMessage] = useState('');
const [showNewConv, setShowNewConv] = useState(false);
const [convSubject, setConvSubject] = useState('');
const [convMessage, setConvMessage] = useState('');
const [loading, setLoading] = useState(true);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
fetchUser();
}, []);
useEffect(() => {
if (user) {
fetchConversations();
}
}, [user]);
useEffect(() => {
if (selectedConv) {
fetchMessages(selectedConv);
}
}, [selectedConv]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const fetchUser = async () => {
try {
const res = await fetch('/api/auth/me');
if (res.ok) {
const data = await res.json();
if (data.authenticated) {
setUser({
id: data.id,
username: data.username,
first_name: data.first_name,
last_name: data.last_name,
role: data.role,
});
} else {
window.location.href = '/login';
}
} else {
window.location.href = '/login';
}
} catch {
window.location.href = '/login';
}
};
const fetchConversations = async () => {
try {
const res = await fetch('/api/conversations');
if (res.ok) {
const data = await res.json();
setConversations(data.conversations || []);
}
} catch (err) {
console.error('Failed to fetch conversations:', err);
} finally {
setLoading(false);
}
};
const fetchMessages = async (convId: number) => {
try {
const res = await fetch(`/api/conversations/${convId}`);
if (res.ok) {
const data = await res.json();
setMessages(data.messages || []);
setParticipants(data.participants || []);
}
} catch (err) {
console.error('Failed to fetch messages:', err);
}
};
const sendMessage = async () => {
if (!newMessage.trim() || !selectedConv) return;
try {
const res = await fetch(`/api/conversations/${selectedConv}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: newMessage.trim() }),
});
if (res.ok) {
const data = await res.json();
setMessages(prev => [...prev, data.message]);
setNewMessage('');
fetchConversations(); // Update last message in list
}
} catch (err) {
console.error('Failed to send message:', err);
}
};
const createConversation = async () => {
if (!convMessage.trim()) return;
try {
const res = await fetch('/api/conversations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
subject: convSubject || null,
initial_message: convMessage.trim(),
}),
});
if (res.ok) {
const data = await res.json();
setConversations(prev => [data.conversation, ...prev]);
setSelectedConv(data.conversation.id);
setShowNewConv(false);
setConvSubject('');
setConvMessage('');
}
} catch (err) {
console.error('Failed to create conversation:', err);
}
};
const getOtherParticipants = (conv: Conversation) => {
return conv.participants.filter(p => p.id !== user?.id);
};
const getDisplayName = (p: Participant) => {
if (p.role === 'admin') {
return p.first_name || p.username;
}
return p.first_name || p.username;
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-gray-500">Loading...</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-6xl mx-auto p-4">
<h1 className="text-2xl font-bold text-gray-800 mb-6">Messages</h1>
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="flex h-[600px]">
{/* Conversations List */}
<div className={`w-full md:w-1/3 border-r ${selectedConv ? 'hidden md:block' : ''}`}>
<div className="p-3 border-b bg-gray-50 flex justify-between items-center">
<span className="font-medium text-gray-700">Conversations</span>
<button
onClick={() => setShowNewConv(true)}
className="bg-blue-600 text-white px-3 py-1 rounded text-sm hover:bg-blue-700"
>
New
</button>
</div>
<div className="overflow-y-auto h-[calc(100%-50px)]">
{conversations.length === 0 ? (
<div className="p-4 text-center text-gray-500">
No conversations yet. Start a new one!
</div>
) : (
conversations.map(conv => (
<div
key={conv.id}
onClick={() => setSelectedConv(conv.id)}
className={`p-3 border-b cursor-pointer hover:bg-gray-50 ${
selectedConv === conv.id ? 'bg-blue-50' : ''
}`}
>
<div className="flex justify-between items-start">
<div className="font-medium text-gray-800">
{conv.subject || getOtherParticipants(conv).map(getDisplayName).join(', ') || 'Conversation'}
</div>
{conv.unread_count > 0 && (
<span className="bg-blue-600 text-white text-xs px-2 py-0.5 rounded-full">
{conv.unread_count}
</span>
)}
</div>
{conv.last_message && (
<div className="text-sm text-gray-500 truncate mt-1">
{conv.last_message}
</div>
)}
<div className="text-xs text-gray-400 mt-1">
{conv.last_message_at ? formatDisplayDateTime(conv.last_message_at) : ''}
</div>
</div>
))
)}
</div>
</div>
{/* Messages View */}
<div className={`flex-1 flex flex-col ${selectedConv ? '' : 'hidden md:flex'}`}>
{selectedConv ? (
<>
{/* Header */}
<div className="p-3 border-b bg-gray-50">
<button
onClick={() => setSelectedConv(null)}
className="md:hidden text-blue-600 mr-3"
>
Back
</button>
<span className="font-medium text-gray-700">
{participants.filter(p => p.id !== user?.id).map(getDisplayName).join(', ')}
</span>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{messages.map(msg => (
<div
key={msg.id}
className={`flex ${msg.sender_id === user?.id ? 'justify-end' : 'justify-start'}`}
>
<div className={`max-w-[70%] ${msg.sender_id === user?.id ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-800'} rounded-lg px-4 py-2`}>
{msg.sender_id !== user?.id && (
<div className="text-xs font-medium mb-1 text-gray-600">
{msg.first_name || msg.username}
</div>
)}
<div>{msg.content}</div>
<div className={`text-xs mt-1 ${msg.sender_id === user?.id ? 'text-blue-200' : 'text-gray-400'}`}>
{formatDisplayDateTime(msg.created_at)}
</div>
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="p-3 border-t bg-gray-50">
<div className="flex gap-2">
<input
type="text"
value={newMessage}
onChange={e => setNewMessage(e.target.value)}
onKeyDown={e => e.key === 'Enter' && sendMessage()}
placeholder="Type a message..."
className="flex-1 border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
onClick={sendMessage}
disabled={!newMessage.trim()}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
Send
</button>
</div>
</div>
</>
) : (
<div className="flex-1 flex items-center justify-center text-gray-500">
Select a conversation or start a new one
</div>
)}
</div>
</div>
</div>
</div>
{/* New Conversation Modal */}
{showNewConv && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
<h2 className="text-xl font-bold mb-4">New Conversation</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Subject (optional)</label>
<input
type="text"
value={convSubject}
onChange={e => setConvSubject(e.target.value)}
placeholder="What is this about?"
className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Message *</label>
<textarea
value={convMessage}
onChange={e => setConvMessage(e.target.value)}
placeholder="Type your message..."
rows={4}
className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div className="flex justify-end gap-2 mt-6">
<button
onClick={() => setShowNewConv(false)}
className="px-4 py-2 text-gray-600 hover:text-gray-800"
>
Cancel
</button>
<button
onClick={createConversation}
disabled={!convMessage.trim()}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
Send
</button>
</div>
</div>
</div>
)}
</div>
);
}
+543 -43
View File
@@ -2,6 +2,8 @@
import { useEffect, useState, useRef } from 'react'; import { useEffect, useState, useRef } from 'react';
import { formatDisplayDate } from '@/lib/date'; import { formatDisplayDate } from '@/lib/date';
import RoomCalendar from '@/components/RoomCalendar';
import OptimizedImage from '@/components/OptimizedImage';
interface Room { interface Room {
id: number; id: number;
@@ -11,6 +13,7 @@ interface Room {
photos: string[]; photos: string[];
featured_photo: string | null; featured_photo: string | null;
active?: boolean; active?: boolean;
amenities: Array<{ id: number; name: string; icon_svg: string }>;
} }
interface RoomComment { interface RoomComment {
@@ -30,6 +33,7 @@ interface User {
role: 'admin' | 'user'; role: 'admin' | 'user';
first_name: string | null; first_name: string | null;
last_name: string | null; last_name: string | null;
email?: string;
comments_disabled: boolean; comments_disabled: boolean;
} }
@@ -57,6 +61,15 @@ export default function RoomsPage() {
const [draft, setDraft] = useState(''); const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [loadingComments, setLoadingComments] = useState<Record<number, boolean>>({}); const [loadingComments, setLoadingComments] = useState<Record<number, boolean>>({});
// Action modal states
const [activeModal, setActiveModal] = useState<'reserve' | 'message' | 'calendar' | 'review' | null>(null);
const [selectedRoom, setSelectedRoom] = useState<Room | null>(null);
const [reservationDates, setReservationDates] = useState({ checkIn: '', checkOut: '' });
const [messageText, setMessageText] = useState('');
const [reviewText, setReviewText] = useState('');
const [submitting, setSubmitting] = useState(false);
const [guestForm, setGuestForm] = useState({ name: '', contactMethod: 'email', contact: '' });
useEffect(() => { useEffect(() => {
fetch('/api/rooms') fetch('/api/rooms')
@@ -74,7 +87,21 @@ export default function RoomsPage() {
fetch('/api/auth/me', { credentials: 'same-origin' }) fetch('/api/auth/me', { credentials: 'same-origin' })
.then((r) => r.json()) .then((r) => r.json())
.then((j) => setUser(j.user || null)) .then((j) => {
if (j.authenticated) {
setUser({
id: j.id,
username: j.username,
role: j.role,
first_name: j.first_name,
last_name: j.last_name,
email: j.email,
comments_disabled: j.comments_disabled,
});
} else {
setUser(null);
}
})
.catch(() => {}); .catch(() => {});
}, []); }, []);
@@ -139,13 +166,155 @@ export default function RoomsPage() {
const getPhoto = (room: Room, index: number): string | null => { const getPhoto = (room: Room, index: number): string | null => {
if (!room.photos || room.photos.length === 0) return null; if (!room.photos || room.photos.length === 0) return null;
return room.photos[Math.min(index, room.photos.length - 1)] || null; const photo = room.photos[Math.min(index, room.photos.length - 1)];
if (!photo) return null;
// If it's a URL with id= param, extract id; if it's base64, return as-is
if (photo.includes('id=')) {
return photo;
}
return photo;
};
const getPhotoId = (photo: string): number | null => {
if (!photo) return null;
if (photo.includes('id=')) {
const match = photo.match(/id=(\d+)/);
return match ? parseInt(match[1]) : null;
}
return null;
}; };
const getActiveIndex = (room: Room): number => { const getActiveIndex = (room: Room): number => {
return activePhotoByRoom[room.id] ?? 0; return activePhotoByRoom[room.id] ?? 0;
}; };
const openModal = (type: 'reserve' | 'message' | 'calendar' | 'review', room: Room) => {
setSelectedRoom(room);
setActiveModal(type);
setReservationDates({ checkIn: '', checkOut: '' });
setMessageText('');
setReviewText('');
};
const closeModal = () => {
setActiveModal(null);
setSelectedRoom(null);
};
const handleReserve = async () => {
if (!selectedRoom || !reservationDates.checkIn || !reservationDates.checkOut) {
alert('Please select dates');
return;
}
// For guests, require name and contact
if (!user && (!guestForm.name.trim() || !guestForm.contact.trim())) {
alert('Please provide your name and contact info');
return;
}
setSubmitting(true);
try {
const res = await fetch('/api/reservations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({
room_id: selectedRoom.id,
check_in: reservationDates.checkIn,
check_out: reservationDates.checkOut,
...(user ? {} : {
guest_name: guestForm.name.trim(),
guest_contact_method: guestForm.contactMethod,
guest_contact: guestForm.contact.trim(),
}),
}),
});
if (res.ok) {
alert('Reservation request sent! You will receive a confirmation shortly.');
closeModal();
setReservationDates({ checkIn: '', checkOut: '' });
setGuestForm({ name: '', contactMethod: 'email', contact: '' });
} else {
const err = await res.json();
alert(err.error || 'Failed to create reservation');
}
} catch {
alert('Failed to create reservation');
}
setSubmitting(false);
};
const handleSendMessage = async () => {
if (!selectedRoom || !messageText.trim()) {
alert('Please enter a message');
return;
}
// For guests, require name and contact
if (!user && (!guestForm.name.trim() || !guestForm.contact.trim())) {
alert('Please provide your name and contact info');
return;
}
setSubmitting(true);
try {
const res = await fetch('/api/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({
room_id: selectedRoom.id,
message: messageText.trim(),
...(user ? {} : {
guest_name: guestForm.name.trim(),
guest_contact_method: guestForm.contactMethod,
guest_contact: guestForm.contact.trim(),
}),
}),
});
if (res.ok) {
alert('Message sent to our team!');
closeModal();
setMessageText('');
setGuestForm({ name: '', contactMethod: 'email', contact: '' });
} else {
const err = await res.json();
alert(err.error || 'Failed to send message');
}
} catch {
alert('Failed to send message');
}
setSubmitting(false);
};
const handleReview = async () => {
if (!selectedRoom || !reviewText.trim() || !user) {
alert('Please log in and enter a review');
return;
}
setSubmitting(true);
try {
const res = await fetch(`/api/rooms/${selectedRoom.id}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ text: reviewText.trim(), status: 'pending' }),
});
if (res.ok) {
alert('Review submitted for approval');
closeModal();
loadComments(selectedRoom.id);
} else {
const err = await res.json();
alert(err.error || 'Failed to submit review');
}
} catch {
alert('Failed to submit review');
}
setSubmitting(false);
};
const displayedRooms = rooms.filter((r) => r.active !== false); const displayedRooms = rooms.filter((r) => r.active !== false);
return ( return (
@@ -205,65 +374,56 @@ export default function RoomsPage() {
onMouseLeave={(e) => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(1,13,30,0.08)'; }} onMouseLeave={(e) => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(1,13,30,0.08)'; }}
> >
{/* ─── Main Image ─── */} {/* ─── Main Image ─── */}
<div style={{ position: 'relative', width: '100%' }}> <div style={{ position: 'relative', width: '100%', height: '300px' }}>
{activeSrc ? ( {activeSrc ? (
<img activeSrc.startsWith('data:') ? (
src={activeSrc} <img
alt={room.name} src={activeSrc}
style={{ alt={room.name}
width: '100%', style={{ width: '100%', height: '300px', objectFit: 'cover' }}
height: '300px', />
objectFit: 'cover', ) : (
display: 'block', <OptimizedImage
}} id={parseInt(activeSrc.split('id=')[1])}
/> alt={room.name}
size="medium"
lazy={true}
style={{ width: '100%', height: '300px' }}
/>
)
) : ( ) : (
<div style={{ width: '100%', height: '300px', background: '#ddd', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#999' }}> <div style={{ width: '100%', height: '300px', background: '#ddd', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#999' }}>
No photo No photo
</div> </div>
)} )}
{/* Like button overlay */} {/* Like button with count */}
<button <button
onClick={() => toggleLike(room.id)} onClick={() => toggleLike(room.id)}
style={{ style={{
position: 'absolute', position: 'absolute',
top: 12, top: 12,
left: 12, left: 12,
width: 40,
height: 40,
borderRadius: '50%',
border: 'none',
background: hasLiked ? 'rgba(231,76,60,0.9)' : 'rgba(0,0,0,0.4)',
color: '#fff',
fontSize: '20px',
cursor: 'pointer',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', gap: '6px',
padding: '6px 12px',
borderRadius: '999px',
border: 'none',
background: hasLiked ? 'rgba(231,76,60,0.9)' : 'rgba(0,0,0,0.5)',
color: '#fff',
fontSize: '14px',
fontWeight: 600,
cursor: 'pointer',
transition: 'all 0.2s ease', transition: 'all 0.2s ease',
}} }}
onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.15)'; }} onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.05)'; }}
onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; }} onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
aria-label="Like this room" aria-label="Like this room"
> >
{hasLiked ? '\u2764\uFE0F' : '\u2661'} <span style={{ fontSize: '16px' }}>{hasLiked ? '❤️' : '♡'}</span>
<span>{totalLikes}</span>
</button> </button>
{/* Like count */}
<div style={{
position: 'absolute',
bottom: 8,
right: 12,
background: hasLiked ? 'rgba(231,76,60,0.85)' : 'rgba(0,0,0,0.5)',
color: '#fff',
fontSize: '13px',
fontWeight: 600,
padding: '4px 10px',
borderRadius: '999px',
}}>
{'\u2764\uFE0F'} {totalLikes}
</div>
</div> </div>
{/* ─── Thumbnail Strip ─── */} {/* ─── Thumbnail Strip ─── */}
@@ -299,7 +459,21 @@ export default function RoomsPage() {
}} }}
> >
{p ? ( {p ? (
<img src={p} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> p.startsWith('data:') ? (
<img
src={p}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<OptimizedImage
id={parseInt(p.split('id=')[1])}
alt=""
size="thumbnail"
lazy={true}
style={{ width: '100%', height: '100%' }}
/>
)
) : ( ) : (
<div style={{ width: '100%', height: '100%', background: '#ddd' }} /> <div style={{ width: '100%', height: '100%', background: '#ddd' }} />
)} )}
@@ -311,13 +485,105 @@ export default function RoomsPage() {
{/* ─── Room Info ─── */} {/* ─── Room Info ─── */}
<div style={{ padding: '1.5rem', flex: 1, display: 'flex', flexDirection: 'column' }}> <div style={{ padding: '1.5rem', flex: 1, display: 'flex', flexDirection: 'column' }}>
<h2 style={{ margin: '0 0 0.5rem', color: navy, fontSize: '24px' }}>{room.name}</h2> <h2 style={{ margin: 0, color: navy, fontSize: '24px' }}>{room.name}</h2>
<p style={{ color: '#555', lineHeight: 1.5, flex: 1 }}>{room.description}</p> <p style={{ color: '#555', lineHeight: 1.5, flex: 1, marginTop: '0.5rem' }}>{room.description}</p>
{/* ─── Amenity Icons ─── */}
{room.amenities && room.amenities.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', marginTop: '0.75rem' }}>
{room.amenities.slice(0, 6).map((amenity) => (
<div
key={amenity.id}
title={amenity.name}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.35rem',
padding: '0.35rem 0.6rem',
background: 'rgba(1,13,30,0.06)',
borderRadius: '999px',
fontSize: '12px',
color: navy,
whiteSpace: 'nowrap',
}}
>
{amenity.icon_svg ? (
<span
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: '16px', height: '16px' }}
dangerouslySetInnerHTML={{ __html: amenity.icon_svg
.replace(/width="[^"]*"/g, '')
.replace(/height="[^"]*"/g, '')
.replace(/stroke="currentColor"/g, `stroke="${navy}"`)
.replace(/<svg/, '<svg style="width:100%;height:100%"')
}}
/>
) : null}
<span>{amenity.name}</span>
</div>
))}
{room.amenities.length > 6 && (
<span style={{ fontSize: '10px', color: '#777', alignSelf: 'center', padding: '0 0.5rem' }}>+{room.amenities.length - 6}</span>
)}
</div>
)}
<div style={{ marginTop: '1rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ marginTop: '1rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ color: warm, fontWeight: 700, fontSize: '18px' }}>${room.price}</span> <span style={{ color: warm, fontWeight: 700, fontSize: '18px' }}>${room.price}</span>
<span style={{ fontSize: '13px', color: '#777' }}>per night</span> <span style={{ fontSize: '13px', color: '#777' }}>per night</span>
</div> </div>
{/* ─── Action Buttons ─── */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '0.5rem', marginTop: '1rem' }}>
<button
onClick={() => openModal('message', room)}
style={{
padding: '0.6rem 0.5rem',
borderRadius: '10px',
border: `2px solid ${navy}`,
background: 'transparent',
color: navy,
fontWeight: 600,
fontSize: '12px',
cursor: 'pointer',
transition: 'all 0.2s',
}}
>
Message
</button>
<button
onClick={() => openModal('calendar', room)}
style={{
padding: '0.6rem 0.5rem',
borderRadius: '10px',
border: `2px solid ${navy}`,
background: 'transparent',
color: navy,
fontWeight: 600,
fontSize: '12px',
cursor: 'pointer',
transition: 'all 0.2s',
}}
>
Reserve
</button>
<button
onClick={() => openModal('review', room)}
style={{
padding: '0.6rem 0.5rem',
borderRadius: '10px',
border: `2px solid ${navy}`,
background: 'transparent',
color: navy,
fontWeight: 600,
fontSize: '12px',
cursor: 'pointer',
transition: 'all 0.2s',
}}
>
Review
</button>
</div>
{/* Comments button — cream */} {/* Comments button — cream */}
<button <button
onClick={() => setExpandedRoom(isExpanded ? null : room.id)} onClick={() => setExpandedRoom(isExpanded ? null : room.id)}
@@ -422,6 +688,240 @@ export default function RoomsPage() {
); );
})} })}
</div> </div>
{/* ─── Modals ─── */}
{activeModal && selectedRoom && (
<div
onClick={closeModal}
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
padding: '1rem',
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
background: '#fff',
borderRadius: '20px',
padding: '2rem',
maxWidth: '500px',
width: '100%',
maxHeight: '90vh',
overflow: 'auto',
}}
>
{/* Reserve Modal */}
{activeModal === 'reserve' && (
<>
<h3 style={{ margin: '0 0 1rem', color: navy, fontSize: '24px' }}>Reserve {selectedRoom.name}</h3>
{!user ? (
<p style={{ color: '#777' }}>Please <a href="/login" style={{ color: gold }}>log in</a> to make a reservation.</p>
) : (
<>
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 600, color: navy }}>Check-in Date</label>
<input
type="date"
value={reservationDates.checkIn}
onChange={(e) => setReservationDates({ ...reservationDates, checkIn: e.target.value })}
min={new Date().toISOString().split('T')[0]}
style={{ width: '100%', padding: '0.75rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '16px' }}
/>
</div>
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 600, color: navy }}>Check-out Date</label>
<input
type="date"
value={reservationDates.checkOut}
onChange={(e) => setReservationDates({ ...reservationDates, checkOut: e.target.value })}
min={reservationDates.checkIn || new Date().toISOString().split('T')[0]}
style={{ width: '100%', padding: '0.75rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '16px' }}
/>
</div>
<div style={{ display: 'flex', gap: '1rem', marginTop: '1.5rem' }}>
<button onClick={closeModal} style={{ flex: 1, padding: '0.75rem', borderRadius: '10px', border: `2px solid ${navy}`, background: 'transparent', color: navy, fontWeight: 600, cursor: 'pointer' }}>Cancel</button>
<button onClick={handleReserve} disabled={submitting || !reservationDates.checkIn || !reservationDates.checkOut} style={{ flex: 1, padding: '0.75rem', borderRadius: '10px', border: 'none', background: gold, color: navy, fontWeight: 600, cursor: 'pointer', opacity: submitting ? 0.6 : 1 }}>{submitting ? 'Processing...' : 'Request Reservation'}</button>
</div>
</>
)}
</>
)}
{/* Message Modal */}
{activeModal === 'message' && (
<>
<h3 style={{ margin: '0 0 1rem', color: navy, fontSize: '24px' }}>Send a Message</h3>
<p style={{ color: '#555', marginBottom: '1rem' }}>Send a message about {selectedRoom.name} to our team.</p>
{!user && (
<div style={{ marginBottom: '1rem', padding: '1rem', background: '#f8f8f8', borderRadius: '10px' }}>
<div style={{ marginBottom: '0.75rem' }}>
<label style={{ display: 'block', marginBottom: '0.25rem', fontWeight: 600, color: navy, fontSize: '13px' }}>Your Name *</label>
<input
type="text"
value={guestForm.name}
onChange={(e) => setGuestForm({ ...guestForm, name: e.target.value })}
placeholder="Enter your name"
style={{ width: '100%', padding: '0.5rem', borderRadius: '8px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '14px' }}
/>
</div>
<div style={{ marginBottom: '0.75rem' }}>
<label style={{ display: 'block', marginBottom: '0.25rem', fontWeight: 600, color: navy, fontSize: '13px' }}>Preferred Contact Method</label>
<select
value={guestForm.contactMethod}
onChange={(e) => setGuestForm({ ...guestForm, contactMethod: e.target.value })}
style={{ width: '100%', padding: '0.5rem', borderRadius: '8px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '14px' }}
>
<option value="email">Email</option>
<option value="whatsapp">WhatsApp</option>
<option value="phone">Phone</option>
</select>
</div>
<div>
<label style={{ display: 'block', marginBottom: '0.25rem', fontWeight: 600, color: navy, fontSize: '13px' }}>
{guestForm.contactMethod === 'email' ? 'Email Address' : guestForm.contactMethod === 'whatsapp' ? 'WhatsApp Number' : 'Phone Number'} *
</label>
<input
type={guestForm.contactMethod === 'email' ? 'email' : 'tel'}
value={guestForm.contact}
onChange={(e) => setGuestForm({ ...guestForm, contact: e.target.value })}
placeholder={guestForm.contactMethod === 'email' ? 'your@email.com' : '+593 99 999 9999'}
style={{ width: '100%', padding: '0.5rem', borderRadius: '8px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '14px' }}
/>
</div>
</div>
)}
{user && (
<div style={{ marginBottom: '1rem', padding: '0.75rem', background: '#f8f8f8', borderRadius: '10px', fontSize: '13px', color: '#555' }}>
<strong>From:</strong> {user.first_name} {user.last_name} ({user.email})
</div>
)}
<textarea
value={messageText}
onChange={(e) => setMessageText(e.target.value)}
placeholder="Your message..."
rows={5}
style={{ width: '100%', padding: '0.75rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '14px', resize: 'vertical' }}
/>
<div style={{ display: 'flex', gap: '1rem', marginTop: '1.5rem' }}>
<button onClick={closeModal} style={{ flex: 1, padding: '0.75rem', borderRadius: '10px', border: `2px solid ${navy}`, background: 'transparent', color: navy, fontWeight: 600, cursor: 'pointer' }}>Cancel</button>
<button onClick={handleSendMessage} disabled={submitting || !messageText.trim() || (!user && (!guestForm.name.trim() || !guestForm.contact.trim()))} style={{ flex: 1, padding: '0.75rem', borderRadius: '10px', border: 'none', background: gold, color: navy, fontWeight: 600, cursor: 'pointer', opacity: submitting ? 0.6 : 1 }}>{submitting ? 'Sending...' : 'Send Message'}</button>
</div>
</>
)}
{/* Reserve/Calendar Modal */}
{activeModal === 'calendar' && (
<>
<h3 style={{ margin: '0 0 1rem', color: navy, fontSize: '24px' }}>Reserve {selectedRoom.name}</h3>
<p style={{ color: '#555', marginBottom: '1rem' }}>Select your dates and we'll contact you to confirm.</p>
{!user && (
<div style={{ marginBottom: '1rem', padding: '1rem', background: '#f8f8f8', borderRadius: '10px' }}>
<div style={{ marginBottom: '0.75rem' }}>
<label style={{ display: 'block', marginBottom: '0.25rem', fontWeight: 600, color: navy, fontSize: '13px' }}>Your Name *</label>
<input
type="text"
value={guestForm.name}
onChange={(e) => setGuestForm({ ...guestForm, name: e.target.value })}
placeholder="Enter your name"
style={{ width: '100%', padding: '0.5rem', borderRadius: '8px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '14px' }}
/>
</div>
<div style={{ marginBottom: '0.75rem' }}>
<label style={{ display: 'block', marginBottom: '0.25rem', fontWeight: 600, color: navy, fontSize: '13px' }}>Preferred Contact Method</label>
<select
value={guestForm.contactMethod}
onChange={(e) => setGuestForm({ ...guestForm, contactMethod: e.target.value })}
style={{ width: '100%', padding: '0.5rem', borderRadius: '8px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '14px' }}
>
<option value="email">Email</option>
<option value="whatsapp">WhatsApp</option>
<option value="phone">Phone</option>
</select>
</div>
<div>
<label style={{ display: 'block', marginBottom: '0.25rem', fontWeight: 600, color: navy, fontSize: '13px' }}>
{guestForm.contactMethod === 'email' ? 'Email Address' : guestForm.contactMethod === 'whatsapp' ? 'WhatsApp Number' : 'Phone Number'} *
</label>
<input
type={guestForm.contactMethod === 'email' ? 'email' : 'tel'}
value={guestForm.contact}
onChange={(e) => setGuestForm({ ...guestForm, contact: e.target.value })}
placeholder={guestForm.contactMethod === 'email' ? 'your@email.com' : '+593 99 999 9999'}
style={{ width: '100%', padding: '0.5rem', borderRadius: '8px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '14px' }}
/>
</div>
</div>
)}
{user && (
<div style={{ marginBottom: '1rem', padding: '0.75rem', background: '#f8f8f8', borderRadius: '10px', fontSize: '13px', color: '#555' }}>
<strong>Reserving as:</strong> {user.first_name} {user.last_name} ({user.email})
</div>
)}
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 600, color: navy }}>Check-in Date *</label>
<input
type="date"
value={reservationDates.checkIn}
onChange={(e) => setReservationDates({ ...reservationDates, checkIn: e.target.value })}
min={new Date().toISOString().split('T')[0]}
style={{ width: '100%', padding: '0.75rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '16px' }}
/>
</div>
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 600, color: navy }}>Check-out Date *</label>
<input
type="date"
value={reservationDates.checkOut}
onChange={(e) => setReservationDates({ ...reservationDates, checkOut: e.target.value })}
min={reservationDates.checkIn || new Date().toISOString().split('T')[0]}
style={{ width: '100%', padding: '0.75rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '16px' }}
/>
</div>
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 600, color: navy }}>Availability Calendar</label>
<RoomCalendar roomId={selectedRoom.id} />
</div>
<div style={{ display: 'flex', gap: '1rem', marginTop: '1.5rem' }}>
<button onClick={closeModal} style={{ flex: 1, padding: '0.75rem', borderRadius: '10px', border: `2px solid ${navy}`, background: 'transparent', color: navy, fontWeight: 600, cursor: 'pointer' }}>Cancel</button>
<button onClick={handleReserve} disabled={submitting || !reservationDates.checkIn || !reservationDates.checkOut || (!user && (!guestForm.name.trim() || !guestForm.contact.trim()))} style={{ flex: 1, padding: '0.75rem', borderRadius: '10px', border: 'none', background: gold, color: navy, fontWeight: 600, cursor: 'pointer', opacity: submitting ? 0.6 : 1 }}>{submitting ? 'Processing...' : 'Request Reservation'}</button>
</div>
</>
)}
{/* Review Modal */}
{activeModal === 'review' && (
<>
<h3 style={{ margin: '0 0 1rem', color: navy, fontSize: '24px' }}>Leave a Review</h3>
<p style={{ color: '#555', marginBottom: '1rem' }}>Share your experience with {selectedRoom.name}.</p>
{!user ? (
<p style={{ color: '#777' }}>Please <a href="/login" style={{ color: gold }}>log in</a> to leave a review.</p>
) : (
<>
<textarea
value={reviewText}
onChange={(e) => setReviewText(e.target.value)}
placeholder="Your review or suggestion..."
rows={5}
style={{ width: '100%', padding: '0.75rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.2)', fontSize: '14px', resize: 'vertical' }}
/>
<p style={{ fontSize: '12px', color: '#777', marginTop: '0.5rem' }}>Reviews are submitted for admin approval before being published.</p>
<div style={{ display: 'flex', gap: '1rem', marginTop: '1.5rem' }}>
<button onClick={closeModal} style={{ flex: 1, padding: '0.75rem', borderRadius: '10px', border: `2px solid ${navy}`, background: 'transparent', color: navy, fontWeight: 600, cursor: 'pointer' }}>Cancel</button>
<button onClick={handleReview} disabled={submitting || !reviewText.trim()} style={{ flex: 1, padding: '0.75rem', borderRadius: '10px', border: 'none', background: gold, color: navy, fontWeight: 600, cursor: 'pointer', opacity: submitting ? 0.6 : 1 }}>{submitting ? 'Submitting...' : 'Submit Review'}</button>
</div>
</>
)}
</>
)}
</div>
</div>
)}
</main> </main>
); );
} }
+15 -4
View File
@@ -4,11 +4,22 @@ import { Language, setLanguage, useLanguage } from '@/lib/i18n';
function Flag({ language }: { language: Language }) { function Flag({ language }: { language: Language }) {
if (language === 'en') { if (language === 'en') {
// Union Jack (UK flag) - simplified but recognizable
return ( return (
<svg width="24" height="16" viewBox="0 0 24 16" fill="none" aria-hidden="true"> <svg width="24" height="16" viewBox="0 0 60 30" fill="none" aria-hidden="true">
<rect width="24" height="16" fill="white" /> {/* Blue background */}
<rect x="0" y="6" width="24" height="4" fill="#CE1124" /> <rect width="60" height="30" fill="#012169"/>
<rect x="10" y="0" width="4" height="16" fill="#CE1124" /> {/* White diagonal saltire (St Andrew) */}
<path d="M0 0L60 30M60 0L0 30" stroke="white" strokeWidth="6"/>
{/* Red diagonal saltire (St Patrick) - offset */}
<path d="M0 0L30 15M60 0L30 15" stroke="#C8102E" strokeWidth="2"/>
<path d="M60 30L30 15M0 30L30 15" stroke="#C8102E" strokeWidth="2"/>
{/* White cross (St George + St Andrew) */}
<rect x="25" y="0" width="10" height="30" fill="white"/>
<rect x="0" y="10" width="60" height="10" fill="white"/>
{/* Red cross (St George) */}
<rect x="27" y="0" width="6" height="30" fill="#C8102E"/>
<rect x="0" y="12" width="60" height="6" fill="#C8102E"/>
</svg> </svg>
); );
} }
+89
View File
@@ -0,0 +1,89 @@
'use client';
import { useEffect, useState } from 'react';
interface LikeButtonProps {
roomId: number;
}
export default function LikeButton({ roomId }: LikeButtonProps) {
const [liked, setLiked] = useState(false);
const [totalLikes, setTotalLikes] = useState(0);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/rooms/${roomId}/likes`)
.then(res => res.json())
.then(data => {
setLiked(data.likedByUser);
setTotalLikes(data.totalLikes);
setLoading(false);
})
.catch(() => setLoading(false));
}, [roomId]);
const toggleLike = async () => {
try {
const res = await fetch(`/api/rooms/${roomId}/likes`, { method: 'POST' });
if (res.ok) {
const data = await res.json();
setLiked(data.liked);
setTotalLikes(data.totalLikes);
}
} catch (err) {
console.error('Failed to toggle like:', err);
}
};
if (loading) {
return (
<div style={{
display: 'flex',
alignItems: 'center',
gap: '0.35rem',
padding: '0.4rem 0.75rem',
background: 'rgba(0,0,0,0.04)',
borderRadius: '999px',
fontSize: '13px',
color: '#666'
}}>
...
</div>
);
}
return (
<button
onClick={toggleLike}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.35rem',
padding: '0.4rem 0.75rem',
background: liked ? '#fee2e2' : 'rgba(0,0,0,0.04)',
border: 'none',
borderRadius: '999px',
cursor: 'pointer',
fontSize: '13px',
fontWeight: 500,
color: liked ? '#dc2626' : '#666',
transition: 'all 0.2s ease',
}}
title={liked ? 'Unlike this room' : 'Like this room'}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill={liked ? 'currentColor' : 'none'}
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
{totalLikes > 0 && <span>{totalLikes}</span>}
</button>
);
}
+12 -1
View File
@@ -52,7 +52,7 @@ export default function Navbar() {
link.href = data.data; link.href = data.data;
}) })
.catch(() => {}); .catch(() => {});
}, []); }, [pathname]);
const handleLogout = async () => { const handleLogout = async () => {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' }); await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' });
@@ -85,6 +85,17 @@ export default function Navbar() {
})} })}
</ul> </ul>
<div className="navbar-actions"> <div className="navbar-actions">
{auth?.authenticated && (
<Link
href={auth?.role === 'admin' ? '/admin/messages' : '/messages'}
className="navbar-icon-link"
title="Messages"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</Link>
)}
{auth?.authenticated ? ( {auth?.authenticated ? (
<button className="navbar-btn-ghost" onClick={handleLogout}>Logout</button> <button className="navbar-btn-ghost" onClick={handleLogout}>Logout</button>
) : ( ) : (
+126
View File
@@ -0,0 +1,126 @@
'use client';
import { useState, useEffect, useRef } from 'react';
interface OptimizedImageProps {
id: number;
alt: string;
style?: React.CSSProperties;
className?: string;
size?: 'thumbnail' | 'medium' | 'full';
lazy?: boolean;
placeholder?: boolean;
}
export default function OptimizedImage({
id,
alt,
style,
className,
size = 'medium',
lazy = true,
placeholder = true,
}: OptimizedImageProps) {
const [loaded, setLoaded] = useState(false);
const [inView, setInView] = useState(!lazy);
const [error, setError] = useState(false);
const imgRef = useRef<HTMLDivElement>(null);
// Intersection Observer for lazy loading
useEffect(() => {
if (!lazy || !imgRef.current) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setInView(true);
observer.disconnect();
}
},
{ rootMargin: '50px' }
);
observer.observe(imgRef.current);
return () => observer.disconnect();
}, [lazy]);
const getUrl = () => {
const base = `/api/image?id=${id}`;
if (size === 'thumbnail') return `${base}&thumb=1`;
if (size === 'medium') return `${base}&medium=1`;
return base;
};
return (
<div
ref={imgRef}
className={className}
style={{
position: 'relative',
overflow: 'hidden',
background: placeholder ? '#f0f0f0' : undefined,
...style,
}}
>
{/* Placeholder shimmer */}
{placeholder && !loaded && !error && (
<div
style={{
position: 'absolute',
inset: 0,
background: 'linear-gradient(90deg, #f5f0e8 25%, #e8e4dc 50%, #f5f0e8 75%)',
backgroundSize: '200% 100%',
animation: 'shimmer 1.5s ease-in-out infinite',
zIndex: 1,
}}
/>
)}
{/* Error state */}
{error && (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#f5f5f5',
color: '#999',
fontSize: '14px',
}}
>
Failed to load
</div>
)}
{/* Actual image - only loads when in view */}
{inView && !error && (
<img
src={getUrl()}
alt={alt}
loading={lazy ? 'lazy' : 'eager'}
decoding="async"
onLoad={() => setLoaded(true)}
onError={() => setError(true)}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
opacity: loaded ? 1 : 0,
transition: 'opacity 0.4s ease-out',
position: 'relative',
zIndex: 2,
}}
/>
)}
<style jsx global>{`
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
`}</style>
</div>
);
}
+133
View File
@@ -0,0 +1,133 @@
'use client';
import { useEffect, useState } from 'react';
interface RoomCalendarProps {
roomId: number;
}
interface AvailabilityDay {
date: string;
status: 'available' | 'booked' | 'maintenance';
}
const gold = '#E8A849';
const navy = '#010D1E';
export default function RoomCalendar({ roomId }: RoomCalendarProps) {
const [currentMonth, setCurrentMonth] = useState(new Date());
const [availability, setAvailability] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
useEffect(() => {
const year = currentMonth.getFullYear();
const month = currentMonth.getMonth();
const startDate = new Date(year, month, 1).toISOString().split('T')[0];
const endDate = new Date(year, month + 1, 0).toISOString().split('T')[0];
fetch(`/api/rooms/${roomId}/availability?start=${startDate}&end=${endDate}`)
.then((r) => r.json())
.then((data) => {
const map: Record<string, string> = {};
(data.days || []).forEach((d: AvailabilityDay) => {
map[d.date] = d.status;
});
setAvailability(map);
})
.catch(() => {})
.finally(() => setLoading(false));
}, [roomId, currentMonth]);
const getDaysInMonth = (date: Date) => {
const year = date.getFullYear();
const month = date.getMonth();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const firstDayOfMonth = new Date(year, month, 1).getDay();
return { daysInMonth, firstDayOfMonth };
};
const { daysInMonth, firstDayOfMonth } = getDaysInMonth(currentMonth);
const days = [];
// Empty cells for days before the first day of the month
for (let i = 0; i < firstDayOfMonth; i++) {
days.push(<div key={`empty-${i}`} />);
}
// Days of the month
for (let day = 1; day <= daysInMonth; day++) {
const dateStr = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const status = availability[dateStr] || 'available';
const isToday = dateStr === new Date().toISOString().split('T')[0];
let bgColor = '#e8f5e9'; // green for available
let textColor = navy;
if (status === 'booked') {
bgColor = '#ffebee'; // red for booked
textColor = '#c23b22';
} else if (status === 'maintenance') {
bgColor = '#fff3e0'; // orange for maintenance
textColor = '#e65100';
}
days.push(
<div
key={day}
title={`${dateStr}: ${status}`}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '36px',
borderRadius: '8px',
background: bgColor,
color: textColor,
fontWeight: isToday ? 700 : 400,
border: isToday ? `2px solid ${gold}` : 'none',
fontSize: '13px',
}}
>
{day}
</div>
);
}
const prevMonth = () => {
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1));
};
const nextMonth = () => {
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1));
};
const monthName = currentMonth.toLocaleString('en-US', { month: 'long', year: 'numeric' });
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<button onClick={prevMonth} style={{ padding: '0.5rem 1rem', borderRadius: '8px', border: `1px solid ${navy}`, background: 'transparent', cursor: 'pointer' }}>&larr;</button>
<span style={{ fontWeight: 600, color: navy }}>{monthName}</span>
<button onClick={nextMonth} style={{ padding: '0.5rem 1rem', borderRadius: '8px', border: `1px solid ${navy}`, background: 'transparent', cursor: 'pointer' }}>&rarr;</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: '4px', textAlign: 'center' }}>
{['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map((d) => (
<div key={d} style={{ fontSize: '11px', fontWeight: 600, color: '#777', padding: '4px' }}>{d}</div>
))}
{days}
</div>
<div style={{ display: 'flex', gap: '1rem', marginTop: '1rem', fontSize: '12px', justifyContent: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
<div style={{ width: '12px', height: '12px', background: '#e8f5e9', borderRadius: '2px' }} /> Available
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
<div style={{ width: '12px', height: '12px', background: '#ffebee', borderRadius: '2px' }} /> Booked
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
<div style={{ width: '12px', height: '12px', background: '#fff3e0', borderRadius: '2px' }} /> Maintenance
</div>
</div>
</div>
);
}
+1 -10
View File
@@ -18,17 +18,8 @@ export function useAuth() {
}, []); }, []);
const login = useCallback((username: string, password: string): boolean => { const login = useCallback((username: string, password: string): boolean => {
// TODO: Implement server-side authentication via API call
if (typeof window === 'undefined') return false; if (typeof window === 'undefined') return false;
if (username === 'admin' && password === 'admin') {
localStorage.setItem(ROLE_KEY, 'admin');
setRole('admin');
return true;
}
if (username === 'user' && password === 'user') {
localStorage.setItem(ROLE_KEY, 'user');
setRole('user');
return true;
}
return false; return false;
}, []); }, []);
+124
View File
@@ -0,0 +1,124 @@
// Audit logging for admin actions
// Records who did what to which entity, with before/after values
import { db } from './db';
export type AuditAction =
| 'create'
| 'update'
| 'delete'
| 'status_change'
| 'login'
| 'logout'
| 'password_change';
export type EntityType =
| 'reservation'
| 'private_event_reservation'
| 'social_event_reservation'
| 'room_reservation'
| 'user'
| 'room'
| 'menu_item'
| 'social_event'
| 'offer'
| 'config';
interface AuditLogEntry {
userId?: number;
action: AuditAction;
entityType: EntityType;
entityId?: number;
oldValues?: Record<string, unknown>;
newValues?: Record<string, unknown>;
ipAddress?: string;
userAgent?: string;
}
/**
* Log an admin action to the audit log
*/
export async function auditLog(entry: AuditLogEntry): Promise<void> {
try {
await db.query(
`INSERT INTO audit_log
(user_id, action, entity_type, entity_id, old_values, new_values, ip_address, user_agent)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
entry.userId || null,
entry.action,
entry.entityType,
entry.entityId || null,
entry.oldValues ? JSON.stringify(entry.oldValues) : null,
entry.newValues ? JSON.stringify(entry.newValues) : null,
entry.ipAddress || null,
entry.userAgent || null,
]
);
} catch (error) {
// Log but don't throw - audit failures shouldn't break the main operation
console.error('Failed to write audit log:', error);
}
}
/**
* Get client IP from request headers
*/
export function getAuditClientIp(request: Request): string | undefined {
const forwardedFor = request.headers.get('x-forwarded-for');
if (forwardedFor) {
return forwardedFor.split(',')[0].trim();
}
return request.headers.get('x-real-ip') || undefined;
}
/**
* Get user agent from request
*/
export function getAuditUserAgent(request: Request): string | undefined {
return request.headers.get('user-agent') || undefined;
}
/**
* Helper to log a status change on a reservation
*/
export async function logStatusChange(
request: Request,
userId: number,
entityType: EntityType,
entityId: number,
oldStatus: string,
newStatus: string
): Promise<void> {
await auditLog({
userId,
action: 'status_change',
entityType,
entityId,
oldValues: { status: oldStatus },
newValues: { status: newStatus },
ipAddress: getAuditClientIp(request),
userAgent: getAuditUserAgent(request),
});
}
/**
* Helper to log a deletion
*/
export async function logDeletion(
request: Request,
userId: number,
entityType: EntityType,
entityId: number,
deletedValues: Record<string, unknown>
): Promise<void> {
await auditLog({
userId,
action: 'delete',
entityType,
entityId,
oldValues: deletedValues,
ipAddress: getAuditClientIp(request),
userAgent: getAuditUserAgent(request),
});
}
+2 -2
View File
@@ -54,9 +54,9 @@ export async function authenticateUser(username: string, password: string) {
export async function createSession(user: { id: number; username: string; role: string }) { export async function createSession(user: { id: number; username: string; role: string }) {
const token = await new SignJWT({ id: user.id, username: user.username, role: user.role }) const token = await new SignJWT({ id: user.id, username: user.username, role: user.role })
.setProtectedHeader({ alg: 'HS256' }) .setProtectedHeader({ alg: 'HS256' })
.setExpirationTime('7d') .setExpirationTime('4h') // 4 hours for security (reduced from 7 days)
.sign(getSecret()); .sign(getSecret());
cookies().set('session', token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 60 * 60 * 24 * 7 }); cookies().set('session', token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 60 * 60 * 4 }); // 4 hours
return token; return token;
} }
+34
View File
@@ -0,0 +1,34 @@
/**
* Production-safe error handling utility
* Returns detailed errors in development, generic errors in production
*/
export function getErrorMessage(error: unknown, fallback = 'An error occurred'): string {
if (process.env.NODE_ENV === 'production') {
return fallback;
}
if (error instanceof Error) {
return error.message;
}
if (typeof error === 'string') {
return error;
}
return fallback;
}
/**
* Create a standardized error response
*/
export function errorResponse(message: string, status = 500, details?: Record<string, unknown>) {
const body: Record<string, unknown> = { error: message };
// Only include details in development
if (process.env.NODE_ENV !== 'production' && details) {
body.details = details;
}
return Response.json(body, { status });
}
+150
View File
@@ -0,0 +1,150 @@
// Rate limiting for public API endpoints
// Uses in-memory store with sliding window - suitable for single-instance deployments
// For multi-instance deployments, consider using Redis or database-backed rate limiting
interface RateLimitEntry {
count: number;
resetAt: number;
}
const rateLimitStore = new Map<string, RateLimitEntry>();
interface RateLimitConfig {
windowMs: number; // Time window in milliseconds
maxRequests: number; // Max requests per window
}
// Default configs for different endpoints
export const RATE_LIMITS: Record<string, RateLimitConfig> = {
'private-event-reservation': {
windowMs: 60 * 60 * 1000, // 1 hour
maxRequests: 5, // 5 submissions per hour per IP
},
'contact-form': {
windowMs: 60 * 60 * 1000, // 1 hour
maxRequests: 10,
},
'login': {
windowMs: 15 * 60 * 1000, // 15 minutes
maxRequests: 10,
},
// Admin endpoints - higher limits for legitimate admin use
'admin-private-events': {
windowMs: 60 * 1000, // 1 minute
maxRequests: 100, // 100 requests per minute
},
'admin-social-events': {
windowMs: 60 * 1000, // 1 minute
maxRequests: 100, // 100 requests per minute
},
};
// Clean up expired entries periodically
setInterval(() => {
const now = Date.now();
rateLimitStore.forEach((entry, key) => {
if (entry.resetAt < now) {
rateLimitStore.delete(key);
}
});
}, 60 * 1000); // Clean every minute
/**
* Check if a request is rate limited
* @param endpointKey - The endpoint identifier (e.g., 'private-event-reservation')
* @param identifier - The client identifier (e.g., IP address)
* @returns { limited: boolean, remaining: number, resetAt: number }
*/
export function checkRateLimit(
endpointKey: string,
identifier: string
): { limited: boolean; remaining: number; resetAt: number; retryAfter?: number } {
const config = RATE_LIMITS[endpointKey];
if (!config) {
// No rate limit configured for this endpoint
return { limited: false, remaining: Infinity, resetAt: Date.now() + 60000 };
}
const key = `${endpointKey}:${identifier}`;
const now = Date.now();
const entry = rateLimitStore.get(key);
// If no entry or window expired, create new entry
if (!entry || entry.resetAt < now) {
const resetAt = now + config.windowMs;
rateLimitStore.set(key, { count: 1, resetAt });
return { limited: false, remaining: config.maxRequests - 1, resetAt };
}
// Check if limit exceeded
if (entry.count >= config.maxRequests) {
return {
limited: true,
remaining: 0,
resetAt: entry.resetAt,
retryAfter: Math.ceil((entry.resetAt - now) / 1000),
};
}
// Increment count
entry.count++;
return { limited: false, remaining: config.maxRequests - entry.count, resetAt: entry.resetAt };
}
/**
* Get client IP from request headers
* Handles X-Forwarded-For and X-Real-IP headers for reverse proxy setups
*/
export function getClientIp(request: Request): string {
// Check X-Forwarded-For header (common for reverse proxies)
const forwardedFor = request.headers.get('x-forwarded-for');
if (forwardedFor) {
// Take the first IP (original client) from the chain
return forwardedFor.split(',')[0].trim();
}
// Check X-Real-IP header (used by some proxies)
const realIp = request.headers.get('x-real-ip');
if (realIp) {
return realIp.trim();
}
// Fallback - this may be undefined in some environments
return 'unknown';
}
/**
* Middleware-style rate limit checker for API routes
* Returns null if allowed, or a Response object if rate limited
*/
export function withRateLimit(
request: Request,
endpointKey: string
): { allowed: true } | { allowed: false; response: Response } {
const clientIp = getClientIp(request);
const result = checkRateLimit(endpointKey, clientIp);
if (result.limited) {
return {
allowed: false,
response: new Response(
JSON.stringify({
error: 'Too many requests. Please try again later.',
retryAfter: result.retryAfter,
}),
{
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': String(result.retryAfter || 60),
'X-RateLimit-Limit': String(RATE_LIMITS[endpointKey]?.maxRequests || 0),
'X-RateLimit-Remaining': '0',
'X-RateLimit-Reset': String(Math.ceil(result.resetAt / 1000)),
},
}
),
};
}
return { allowed: true };
}
+247
View File
@@ -20,6 +20,8 @@ export async function initSchema() {
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
filename VARCHAR(255) NOT NULL, filename VARCHAR(255) NOT NULL,
data TEXT NOT NULL, data TEXT NOT NULL,
thumbnail TEXT,
medium TEXT,
category VARCHAR(100) DEFAULT 'other', category VARCHAR(100) DEFAULT 'other',
room_id INTEGER REFERENCES rooms(id) ON DELETE SET NULL, room_id INTEGER REFERENCES rooms(id) ON DELETE SET NULL,
location_target VARCHAR(100) DEFAULT 'gallery', location_target VARCHAR(100) DEFAULT 'gallery',
@@ -27,6 +29,10 @@ export async function initSchema() {
); );
`); `);
// Add columns if they don't exist (for existing databases)
await db.query(`ALTER TABLE images ADD COLUMN IF NOT EXISTS medium TEXT`);
await db.query(`ALTER TABLE images ADD COLUMN IF NOT EXISTS thumbnail TEXT`);
await db.query(` await db.query(`
CREATE TABLE IF NOT EXISTS reservations ( CREATE TABLE IF NOT EXISTS reservations (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
@@ -106,6 +112,10 @@ export async function initSchema() {
); );
`); `);
// Add columns if they don't exist (for existing databases)
await db.query(`ALTER TABLE menu_items ADD COLUMN IF NOT EXISTS photos TEXT[] DEFAULT '{}'`);
await db.query(`ALTER TABLE menu_items ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500)`);
await db.query(` await db.query(`
CREATE TABLE IF NOT EXISTS places ( CREATE TABLE IF NOT EXISTS places (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
@@ -122,6 +132,10 @@ export async function initSchema() {
); );
`); `);
// Add columns if they don't exist (for existing databases)
await db.query(`ALTER TABLE places ADD COLUMN IF NOT EXISTS photos TEXT[] DEFAULT '{}'`);
await db.query(`ALTER TABLE places ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500)`);
await db.query(` await db.query(`
CREATE TABLE IF NOT EXISTS reviews ( CREATE TABLE IF NOT EXISTS reviews (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
@@ -170,11 +184,17 @@ export async function initSchema() {
photos TEXT[] DEFAULT '{}', photos TEXT[] DEFAULT '{}',
featured_photo VARCHAR(500), featured_photo VARCHAR(500),
active BOOLEAN DEFAULT TRUE, active BOOLEAN DEFAULT TRUE,
is_private BOOLEAN DEFAULT FALSE,
max_guests INTEGER,
sort_order INTEGER DEFAULT 0, sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW() created_at TIMESTAMP DEFAULT NOW()
); );
`); `);
// Add is_private and max_guests columns if they don't exist
await db.query(`ALTER TABLE social_events ADD COLUMN IF NOT EXISTS is_private BOOLEAN DEFAULT FALSE`);
await db.query(`ALTER TABLE social_events ADD COLUMN IF NOT EXISTS max_guests INTEGER`);
await db.query(` await db.query(`
CREATE TABLE IF NOT EXISTS social_event_reservations ( CREATE TABLE IF NOT EXISTS social_event_reservations (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
@@ -308,6 +328,219 @@ export async function initSchema() {
PRIMARY KEY (room_id, amenity_id) PRIMARY KEY (room_id, amenity_id)
); );
`); `);
await db.query(`
CREATE TABLE IF NOT EXISTS room_reservations (
id SERIAL PRIMARY KEY,
room_id INTEGER NOT NULL REFERENCES rooms(id),
user_id INTEGER REFERENCES users(id),
check_in DATE NOT NULL,
check_out DATE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
total_price DECIMAL(10,2),
guest_name VARCHAR(100),
guest_contact_method VARCHAR(20),
guest_contact VARCHAR(255),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS room_availability (
id SERIAL PRIMARY KEY,
room_id INTEGER NOT NULL REFERENCES rooms(id),
date DATE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'available',
reason TEXT,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(room_id, date)
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS messages (
id SERIAL PRIMARY KEY,
room_id INTEGER REFERENCES rooms(id),
user_id INTEGER REFERENCES users(id),
message TEXT NOT NULL,
guest_name VARCHAR(100),
guest_contact_method VARCHAR(20),
guest_contact VARCHAR(255),
read_by_admins BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW()
);
`);
await db.query(`ALTER TABLE room_comments ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'approved'`);
// Add guest columns to room_reservations
await db.query(`ALTER TABLE room_reservations ADD COLUMN IF NOT EXISTS guest_name VARCHAR(100)`);
await db.query(`ALTER TABLE room_reservations ADD COLUMN IF NOT EXISTS guest_contact_method VARCHAR(20)`);
await db.query(`ALTER TABLE room_reservations ADD COLUMN IF NOT EXISTS guest_contact VARCHAR(255)`);
await db.query(`ALTER TABLE room_reservations ALTER COLUMN user_id DROP NOT NULL`);
// Add guest columns to messages
await db.query(`ALTER TABLE messages ADD COLUMN IF NOT EXISTS guest_name VARCHAR(100)`);
await db.query(`ALTER TABLE messages ADD COLUMN IF NOT EXISTS guest_contact_method VARCHAR(20)`);
await db.query(`ALTER TABLE messages ADD COLUMN IF NOT EXISTS guest_contact VARCHAR(255)`);
await db.query(`ALTER TABLE messages ALTER COLUMN user_id DROP NOT NULL`);
await db.query(`CREATE INDEX IF NOT EXISTS idx_reservations_room_dates ON reservations(room_id, check_in, check_out)`);
await db.query(`CREATE INDEX IF NOT EXISTS idx_room_availability_room_date ON room_availability(room_id, date)`);
await db.query(`CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at DESC)`);
// ─── Conversations & Direct Messages ───
await db.query(`
CREATE TABLE IF NOT EXISTS conversations (
id SERIAL PRIMARY KEY,
subject VARCHAR(255),
created_by INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS conversation_participants (
id SERIAL PRIMARY KEY,
conversation_id INTEGER NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
joined_at TIMESTAMP DEFAULT NOW(),
UNIQUE(conversation_id, user_id)
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS direct_messages (
id SERIAL PRIMARY KEY,
conversation_id INTEGER NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
sender_id INTEGER NOT NULL REFERENCES users(id),
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
`);
await db.query(`CREATE INDEX IF NOT EXISTS idx_conversation_participants_user ON conversation_participants(user_id)`);
await db.query(`CREATE INDEX IF NOT EXISTS idx_direct_messages_conversation ON direct_messages(conversation_id, created_at DESC)`);
// ─── Bulk Messages ───
await db.query(`
CREATE TABLE IF NOT EXISTS bulk_messages (
id SERIAL PRIMARY KEY,
sender_id INTEGER NOT NULL REFERENCES users(id),
subject VARCHAR(255),
content TEXT NOT NULL,
recipient_type VARCHAR(20) NOT NULL DEFAULT 'all_customers',
created_at TIMESTAMP DEFAULT NOW()
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS bulk_message_recipients (
id SERIAL PRIMARY KEY,
bulk_message_id INTEGER NOT NULL REFERENCES bulk_messages(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
read_at TIMESTAMP,
UNIQUE(bulk_message_id, user_id)
);
`);
// Orders tables for coffee/kitchen
await db.query(`
CREATE TABLE IF NOT EXISTS orders (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id),
order_type VARCHAR(20) NOT NULL,
room_id INTEGER REFERENCES rooms(id),
subtotal DECIMAL(10,2) NOT NULL,
service_fee DECIMAL(10,2) DEFAULT 0,
total DECIMAL(10,2) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
notes TEXT,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS order_items (
id SERIAL PRIMARY KEY,
order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
menu_item_id INTEGER NOT NULL REFERENCES menu_items(id),
quantity INTEGER NOT NULL,
unit_price DECIMAL(10,2) NOT NULL,
total_price DECIMAL(10,2) NOT NULL
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS private_event_reservations (
id SERIAL PRIMARY KEY,
event_name VARCHAR(255) NOT NULL,
contact_name VARCHAR(255) NOT NULL,
contact_email VARCHAR(255),
contact_phone VARCHAR(50),
contact_method VARCHAR(20) NOT NULL CHECK (contact_method IN ('email', 'phone', 'whatsapp')),
event_date DATE,
event_time TIME,
guests INTEGER NOT NULL,
notes TEXT,
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'confirmed', 'cancelled')),
created_at TIMESTAMP DEFAULT NOW()
);
`);
// Add status column to social_event_reservations if it doesn't exist
await db.query(`ALTER TABLE social_event_reservations ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'confirmed', 'cancelled'))`);
// Add contact columns to social_event_reservations
await db.query(`ALTER TABLE social_event_reservations ADD COLUMN IF NOT EXISTS contact_name VARCHAR(255)`);
await db.query(`ALTER TABLE social_event_reservations ADD COLUMN IF NOT EXISTS contact_email VARCHAR(255)`);
await db.query(`ALTER TABLE social_event_reservations ADD COLUMN IF NOT EXISTS contact_phone VARCHAR(50)`);
await db.query(`ALTER TABLE social_event_reservations ALTER COLUMN user_id DROP NOT NULL`);
await db.query(`CREATE INDEX IF NOT EXISTS idx_orders_user ON orders(user_id)`);
await db.query(`CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status)`);
await db.query(`CREATE INDEX IF NOT EXISTS idx_order_items_order ON order_items(order_id)`);
// Room assignments - admins assign registered users to rooms
await db.query(`
CREATE TABLE IF NOT EXISTS room_assignments (
id SERIAL PRIMARY KEY,
room_id INTEGER NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
check_in DATE NOT NULL,
check_out DATE NOT NULL,
notes TEXT,
created_by INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(room_id, check_in, check_out)
);
`);
await db.query(`CREATE INDEX IF NOT EXISTS idx_room_assignments_room ON room_assignments(room_id)`);
await db.query(`CREATE INDEX IF NOT EXISTS idx_room_assignments_user ON room_assignments(user_id)`);
await db.query(`CREATE INDEX IF NOT EXISTS idx_room_assignments_dates ON room_assignments(check_in, check_out)`);
// Audit log for admin actions
await db.query(`
CREATE TABLE IF NOT EXISTS audit_log (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
action VARCHAR(50) NOT NULL,
entity_type VARCHAR(50) NOT NULL,
entity_id INTEGER,
old_values JSONB,
new_values JSONB,
ip_address VARCHAR(45),
user_agent TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
`);
await db.query(`CREATE INDEX IF NOT EXISTS idx_audit_log_user ON audit_log(user_id)`);
await db.query(`CREATE INDEX IF NOT EXISTS idx_audit_log_entity ON audit_log(entity_type, entity_id)`);
await db.query(`CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC)`);
} }
export async function migrateFromLegacyPlaces() { export async function migrateFromLegacyPlaces() {
@@ -325,6 +558,20 @@ export async function migrateFromLegacyPlaces() {
await db.query(` await db.query(`
ALTER TABLE rooms ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500); ALTER TABLE rooms ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500);
`); `);
// Add room status columns for housekeeping management
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS clean_status VARCHAR(20) DEFAULT 'clean'`);
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS notes TEXT`);
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS last_cleaned TIMESTAMP`);
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS assigned_staff_id INTEGER REFERENCES users(id)`);
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS priority VARCHAR(10) DEFAULT 'normal'`);
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS issues_count INTEGER DEFAULT 0`);
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS is_vip BOOLEAN DEFAULT FALSE`);
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS checkout_time TIME`);
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS checkout_date DATE`);
// Add payment_status to room_reservations
await db.query(`ALTER TABLE room_reservations ADD COLUMN IF NOT EXISTS payment_status VARCHAR(20) DEFAULT 'pending'`);
} }
export async function seed() { export async function seed() {
+54 -6
View File
@@ -1,14 +1,62 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server'; import type { NextRequest } from 'next/server';
// Middleware runs on edge runtime - we can't verify JWT here easily // Paths that require authentication
// Let the pages handle auth checks via /api/auth/me const PROTECTED_PATHS = ['/admin'];
export function middleware(request: NextRequest) { // Paths that require no authentication (login page should not redirect logged-in users)
// No redirects - login and admin pages handle their own auth UI const AUTH_PATHS = ['/login'];
return NextResponse.next();
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Check if this is a protected path
const isProtectedPath = PROTECTED_PATHS.some(path => pathname.startsWith(path));
const isAuthPath = AUTH_PATHS.some(path => pathname.startsWith(path));
if (isProtectedPath) {
// Get session token from cookie
const sessionToken = request.cookies.get('session')?.value;
if (!sessionToken) {
// No session, redirect to login
const loginUrl = new URL('/login', request.url);
return NextResponse.redirect(loginUrl);
}
// Session exists - actual auth validation happens in API routes
// We can't verify JWT in Edge runtime without external libraries
// The login page and API routes handle full auth checks
}
// Add security headers to all responses
const response = NextResponse.next();
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-XSS-Protection', '1; mode=block');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
// Content Security Policy - allow inline styles for Next.js
response.headers.set(
'Content-Security-Policy',
"default-src 'self'; " +
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: blob: https:; " +
"font-src 'self' data:; " +
"connect-src 'self'; " +
"frame-ancestors 'none';"
);
// HSTS for production (assuming TLS termination at proxy)
if (process.env.NODE_ENV === 'production') {
response.headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
return response;
} }
// Configure which paths the middleware should run on // Configure which paths the middleware should run on
export const config = { export const config = {
matcher: ['/admin/:path*', '/login'], matcher: ['/admin/:path*', '/login', '/api/:path*'],
}; };
+5 -1
View File
@@ -31,13 +31,17 @@ describe('auth helpers', () => {
it('authenticates a user with the stored password hash', async () => { it('authenticates a user with the stored password hash', async () => {
const passwordHash = await hashPassword('secret-password'); const passwordHash = await hashPassword('secret-password');
query.mockResolvedValueOnce({ query.mockResolvedValueOnce({
rows: [{ id: 2, username: 'bob', password_hash: passwordHash, role: 'admin' }], rows: [{ id: 2, username: 'bob', password_hash: passwordHash, role: 'admin', first_name: null, last_name: null, preferred_language: 'es', terms_accepted_at: null }],
}); });
await expect(authenticateUser('bob', 'secret-password')).resolves.toEqual({ await expect(authenticateUser('bob', 'secret-password')).resolves.toEqual({
id: 2, id: 2,
username: 'bob', username: 'bob',
role: 'admin', role: 'admin',
first_name: null,
last_name: null,
preferred_language: 'es',
terms_accepted_at: null,
}); });
}); });
+7 -2
View File
@@ -20,13 +20,18 @@ describe('/api/rooms', () => {
}); });
it('returns active rooms', async () => { it('returns active rooms', async () => {
// Mock rooms query
query.mockResolvedValueOnce({ rows: [{ id: 1, name: 'Suite', active: true }] }); query.mockResolvedValueOnce({ rows: [{ id: 1, name: 'Suite', active: true }] });
// Mock amenities query
query.mockResolvedValueOnce({ rows: [] });
// Mock images query
query.mockResolvedValueOnce({ rows: [] });
const response = await GET(); const response = await GET();
const body = await response.json(); const body = await response.json();
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(body).toEqual({ data: [{ id: 1, name: 'Suite', active: true }] }); expect(body).toEqual({ data: [{ id: 1, name: 'Suite', active: true, photos: [], amenities: [] }] });
}); });
it('creates a room for admins', async () => { it('creates a room for admins', async () => {
@@ -52,6 +57,6 @@ describe('/api/rooms', () => {
expect(requireRole).toHaveBeenCalledWith('admin'); expect(requireRole).toHaveBeenCalledWith('admin');
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(body).toEqual({ data: { id: 7, name: 'Garden Room', price: '120.00' } }); expect(body).toEqual({ data: { id: 7, name: 'Garden Room', price: '120.00', photos: [], amenity_ids: [] } });
}); });
}); });