Compare commits

..

10 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
36 changed files with 2954 additions and 1522 deletions
+1
View File
@@ -1,3 +1,4 @@
# WARNING: Replace CHANGE_ME_* values before applying
apiVersion: v1
kind: Secret
metadata:
+44 -45
View File
@@ -11,7 +11,7 @@
"@chenglou/pretext": "^0.0.8",
"bcryptjs": "^2.4.3",
"jose": "^5.6.3",
"next": "14.2.28",
"next": "^14.2.35",
"pg": "^8.12.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -1354,15 +1354,15 @@
}
},
"node_modules/@next/env": {
"version": "14.2.28",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.28.tgz",
"integrity": "sha512-PAmWhJfJQlP+kxZwCjrVd9QnR5x0R3u0mTXTiZDgSd4h5LdXmjxCCWbN9kq6hkZBOax8Rm3xDW5HagWyJuT37g==",
"version": "14.2.35",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.35.tgz",
"integrity": "sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==",
"license": "MIT"
},
"node_modules/@next/swc-darwin-arm64": {
"version": "14.2.28",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.28.tgz",
"integrity": "sha512-kzGChl9setxYWpk3H6fTZXXPFFjg7urptLq5o5ZgYezCrqlemKttwMT5iFyx/p1e/JeglTwDFRtb923gTJ3R1w==",
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.33.tgz",
"integrity": "sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA==",
"cpu": [
"arm64"
],
@@ -1376,9 +1376,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "14.2.28",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.28.tgz",
"integrity": "sha512-z6FXYHDJlFOzVEOiiJ/4NG8aLCeayZdcRSMjPDysW297Up6r22xw6Ea9AOwQqbNsth8JNgIK8EkWz2IDwaLQcw==",
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.33.tgz",
"integrity": "sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA==",
"cpu": [
"x64"
],
@@ -1392,9 +1392,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "14.2.28",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.28.tgz",
"integrity": "sha512-9ARHLEQXhAilNJ7rgQX8xs9aH3yJSj888ssSjJLeldiZKR4D7N08MfMqljk77fAwZsWwsrp8ohHsMvurvv9liQ==",
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.33.tgz",
"integrity": "sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw==",
"cpu": [
"arm64"
],
@@ -1411,9 +1411,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "14.2.28",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.28.tgz",
"integrity": "sha512-p6gvatI1nX41KCizEe6JkF0FS/cEEF0u23vKDpl+WhPe/fCTBeGkEBh7iW2cUM0rvquPVwPWdiUR6Ebr/kQWxQ==",
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.33.tgz",
"integrity": "sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==",
"cpu": [
"arm64"
],
@@ -1430,9 +1430,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "14.2.28",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.28.tgz",
"integrity": "sha512-nsiSnz2wO6GwMAX2o0iucONlVL7dNgKUqt/mDTATGO2NY59EO/ZKnKEr80BJFhuA5UC1KZOMblJHWZoqIJddpA==",
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.33.tgz",
"integrity": "sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==",
"cpu": [
"x64"
],
@@ -1449,9 +1449,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "14.2.28",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.28.tgz",
"integrity": "sha512-+IuGQKoI3abrXFqx7GtlvNOpeExUH1mTIqCrh1LGFf8DnlUcTmOOCApEnPJUSLrSbzOdsF2ho2KhnQoO0I1RDw==",
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.33.tgz",
"integrity": "sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==",
"cpu": [
"x64"
],
@@ -1468,9 +1468,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "14.2.28",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.28.tgz",
"integrity": "sha512-l61WZ3nevt4BAnGksUVFKy2uJP5DPz2E0Ma/Oklvo3sGj9sw3q7vBWONFRgz+ICiHpW5mV+mBrkB3XEubMrKaA==",
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.33.tgz",
"integrity": "sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==",
"cpu": [
"arm64"
],
@@ -1484,9 +1484,9 @@
}
},
"node_modules/@next/swc-win32-ia32-msvc": {
"version": "14.2.28",
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.28.tgz",
"integrity": "sha512-+Kcp1T3jHZnJ9v9VTJ/yf1t/xmtFAc/Sge4v7mVc1z+NYfYzisi8kJ9AsY8itbgq+WgEwMtOpiLLJsUy2qnXZw==",
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.33.tgz",
"integrity": "sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==",
"cpu": [
"ia32"
],
@@ -1500,9 +1500,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "14.2.28",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.28.tgz",
"integrity": "sha512-1gCmpvyhz7DkB1srRItJTnmR2UwQPAUXXIg9r0/56g3O8etGmwlX68skKXJOp9EejW3hhv7nSQUJ2raFiz4MoA==",
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.33.tgz",
"integrity": "sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg==",
"cpu": [
"x64"
],
@@ -2896,13 +2896,12 @@
}
},
"node_modules/next": {
"version": "14.2.28",
"resolved": "https://registry.npmjs.org/next/-/next-14.2.28.tgz",
"integrity": "sha512-QLEIP/kYXynIxtcKB6vNjtWLVs3Y4Sb+EClTC/CSVzdLD1gIuItccpu/n1lhmduffI32iPGEK2cLLxxt28qgYA==",
"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.",
"version": "14.2.35",
"resolved": "https://registry.npmjs.org/next/-/next-14.2.35.tgz",
"integrity": "sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==",
"license": "MIT",
"dependencies": {
"@next/env": "14.2.28",
"@next/env": "14.2.35",
"@swc/helpers": "0.5.5",
"busboy": "1.6.0",
"caniuse-lite": "^1.0.30001579",
@@ -2917,15 +2916,15 @@
"node": ">=18.17.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "14.2.28",
"@next/swc-darwin-x64": "14.2.28",
"@next/swc-linux-arm64-gnu": "14.2.28",
"@next/swc-linux-arm64-musl": "14.2.28",
"@next/swc-linux-x64-gnu": "14.2.28",
"@next/swc-linux-x64-musl": "14.2.28",
"@next/swc-win32-arm64-msvc": "14.2.28",
"@next/swc-win32-ia32-msvc": "14.2.28",
"@next/swc-win32-x64-msvc": "14.2.28"
"@next/swc-darwin-arm64": "14.2.33",
"@next/swc-darwin-x64": "14.2.33",
"@next/swc-linux-arm64-gnu": "14.2.33",
"@next/swc-linux-arm64-musl": "14.2.33",
"@next/swc-linux-x64-gnu": "14.2.33",
"@next/swc-linux-x64-musl": "14.2.33",
"@next/swc-win32-arm64-msvc": "14.2.33",
"@next/swc-win32-ia32-msvc": "14.2.33",
"@next/swc-win32-x64-msvc": "14.2.33"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
+1 -1
View File
@@ -13,7 +13,7 @@
"@chenglou/pretext": "^0.0.8",
"bcryptjs": "^2.4.3",
"jose": "^5.6.3",
"next": "14.2.28",
"next": "^14.2.35",
"pg": "^8.12.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+5 -1
View File
@@ -11,7 +11,11 @@ const pool = new Pool({
port: 5432,
database: 'lahuasca',
user: 'lahuasca',
password: process.env.DB_PASSWORD || 'lahuasca123',
password: (() => {
const p = process.env.DB_PASSWORD;
if (!p) throw new Error('DB_PASSWORD env var is required');
return p;
})(),
});
async function convertToWebP(base64Data) {
+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>
);
}
+858 -30
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 }
);
}
}
+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 }
);
}
}
+1 -1
View File
@@ -95,7 +95,7 @@ export async function POST(request: NextRequest) {
const sizes = await generateImageSizes(data);
const { rows } = await db.query(
'INSERT INTO images (filename, data, thumbnail, medium, category, room_id, location_target) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, filename, 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, sizes.data, sizes.thumbnail, sizes.medium, category || 'other', room_id || null, location_target || 'gallery']
);
@@ -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 }
);
}
}
+15 -104
View File
@@ -1,114 +1,25 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireRole, createUser } from '@/lib/auth';
import { NextResponse } from 'next/server';
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() {
try {
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) {
try {
await requireRole('admin');
const body = await request.json();
const { username, password, role = 'user', first_name, last_name, email, phone, country, preferred_language = 'es' } = body;
const { rows } = await db.query(`
SELECT id, username, first_name, last_name, role, email, created_at
FROM users
ORDER BY role, first_name, last_name
`);
if (!username || !password) {
return NextResponse.json({ error: 'Username and password are required' }, { status: 400 });
}
if (role !== 'admin' && role !== 'user') {
return NextResponse.json({ error: 'Role must be admin or user' }, { status: 400 });
}
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
return NextResponse.json({ users: rows });
} catch (error) {
console.error('Get users error:', error);
return NextResponse.json(
{ error: getErrorMessage(error, 'Failed to get users') },
{ status: 500 }
);
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 });
}
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]
);
const user = rows[0] || {};
@@ -19,6 +19,7 @@ export async function GET() {
id: session.id,
first_name: user.first_name || null,
last_name: user.last_name || null,
email: user.email || null,
comments_disabled: user.comments_disabled === true,
});
}
+6 -2
View File
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server';
import { createUser } from '@/lib/auth';
import { getErrorMessage } from '@/lib/errors';
export async function POST() {
try {
@@ -12,7 +13,10 @@ export async function POST() {
const user = await createUser(username, password, 'admin');
return NextResponse.json({ ok: true, user: { id: user.id, username: user.username, role: user.role } });
} catch (err: any) {
return NextResponse.json({ error: err?.message || 'Failed to create bootstrap user' }, { status: 500 });
} catch (err: unknown) {
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 }
);
}
}
+1
View File
@@ -122,6 +122,7 @@ export async function GET(request: Request) {
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
@@ -2,6 +2,7 @@ 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
@@ -38,7 +39,10 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ data: rows });
} catch (error) {
console.error('Error fetching private event reservations:', error);
return NextResponse.json({ error: 'Failed to fetch reservations' }, { status: 500 });
return NextResponse.json(
{ error: getErrorMessage(error, 'Failed to fetch reservations') },
{ status: 500 }
);
}
}
@@ -136,6 +140,9 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ data: rows[0] });
} catch (error) {
console.error('Error creating private event reservation:', error);
return NextResponse.json({ error: 'Failed to create reservation' }, { status: 500 });
return NextResponse.json(
{ error: getErrorMessage(error, 'Failed to create reservation') },
{ status: 500 }
);
}
}
+9 -2
View File
@@ -1,6 +1,7 @@
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 {
@@ -103,7 +104,10 @@ export async function POST(request: Request) {
});
} catch (error) {
console.error('Reservation error:', error);
return NextResponse.json({ error: 'Failed to create reservation' }, { status: 500 });
return NextResponse.json(
{ error: getErrorMessage(error, 'Failed to create reservation') },
{ status: 500 }
);
}
}
@@ -137,6 +141,9 @@ export async function GET(request: Request) {
return NextResponse.json({ reservations: rows });
} catch (error) {
console.error('Get reservations error:', error);
return NextResponse.json({ error: 'Failed to get reservations' }, { status: 500 });
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 });
}
}
+4 -4
View File
@@ -7,13 +7,13 @@ export async function PUT(request: NextRequest, { params }: { params: { id: stri
await requireRole('admin');
const id = parseInt(params.id, 10);
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(
`UPDATE social_events
SET name=$1, description=$2, price=$3, date=$4, photos=$5, featured_photo=$6, active=$7, sort_order=$8
WHERE id=$9
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=$11
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 });
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 { db } from '@/lib/db';
export async function GET() {
export async function GET(request: NextRequest) {
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 COUNT(*) FROM social_event_reservations ser WHERE ser.social_event_id = se.id) as reservation_count
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 });
} catch (error: any) {
console.error('GET /api/social_events error:', error);
@@ -22,12 +33,12 @@ export async function POST(request: NextRequest) {
try {
await requireRole('admin');
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(
`INSERT INTO social_events (name, description, price, date, photos, featured_photo, active, sort_order)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`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, $9, $10)
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] });
} catch (error: any) {
+13 -4
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, hashPassword } from '@/lib/auth';
import { db } from '@/lib/db';
import { getErrorMessage } from '@/lib/errors';
export async function POST(request: NextRequest) {
try {
@@ -12,8 +13,12 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Current and new password are required' }, { status: 400 });
}
if (new_password.length < 4) {
return NextResponse.json({ error: 'Password must be at least 4 characters' }, { status: 400 });
if (new_password.length < 12) {
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
@@ -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]);
return NextResponse.json({ ok: true });
} catch (err: any) {
return NextResponse.json({ error: err.message || 'Failed to change password' }, { status: err.message === 'Unauthorized' ? 401 : 500 });
} catch (err: unknown) {
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 }
);
}
}
+40 -4
View File
@@ -35,6 +35,7 @@ export default function CoffeePage() {
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 [error, setError] = useState('');
@@ -53,8 +54,13 @@ export default function CoffeePage() {
if (!res.ok) throw new Error('Failed to load menu');
return res.json();
}),
fetch('/api/auth/session').then(async (res) => {
if (res.ok) return res.json();
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) => {
@@ -62,10 +68,25 @@ export default function CoffeePage() {
return res.json();
}),
])
.then(([menuData, sessionData, roomsData]) => {
.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))
.finally(() => setLoading(false));
@@ -359,7 +380,21 @@ export default function CoffeePage() {
{/* Room Selection for Delivery */}
{orderType === 'room_delivery' && user && (
<div style={{ marginBottom: '1.5rem' }}>
<label style={{ display: 'block', fontWeight: 600, marginBottom: '0.5rem', color: navy }}>Select Room</label>
<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)}
@@ -376,6 +411,7 @@ export default function CoffeePage() {
<option key={room.id} value={room.id}>{room.name}</option>
))}
</select>
)}
</div>
)}
+18
View File
@@ -88,6 +88,24 @@ a {
.navbar-actions {
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 {
+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>
);
}
+15 -1
View File
@@ -87,7 +87,21 @@ export default function RoomsPage() {
fetch('/api/auth/me', { credentials: 'same-origin' })
.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(() => {});
}, []);
+11
View File
@@ -85,6 +85,17 @@ export default function Navbar() {
})}
</ul>
<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 ? (
<button className="navbar-btn-ghost" onClick={handleLogout}>Logout</button>
) : (
+1 -10
View File
@@ -18,17 +18,8 @@ export function useAuth() {
}, []);
const login = useCallback((username: string, password: string): boolean => {
// TODO: Implement server-side authentication via API call
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;
}, []);
+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 }) {
const token = await new SignJWT({ id: user.id, username: user.username, role: user.role })
.setProtectedHeader({ alg: 'HS256' })
.setExpirationTime('7d')
.setExpirationTime('4h') // 4 hours for security (reduced from 7 days)
.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;
}
+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 });
}
+102
View File
@@ -112,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(`
CREATE TABLE IF NOT EXISTS places (
id SERIAL PRIMARY KEY,
@@ -128,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(`
CREATE TABLE IF NOT EXISTS reviews (
id SERIAL PRIMARY KEY,
@@ -176,11 +184,17 @@ export async function initSchema() {
photos TEXT[] DEFAULT '{}',
featured_photo VARCHAR(500),
active BOOLEAN DEFAULT TRUE,
is_private BOOLEAN DEFAULT FALSE,
max_guests INTEGER,
sort_order INTEGER DEFAULT 0,
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(`
CREATE TABLE IF NOT EXISTS social_event_reservations (
id SERIAL PRIMARY KEY,
@@ -376,6 +390,62 @@ export async function initSchema() {
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 (
@@ -434,6 +504,24 @@ export async function initSchema() {
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 (
@@ -470,6 +558,20 @@ export async function migrateFromLegacyPlaces() {
await db.query(`
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() {
+54 -6
View File
@@ -1,14 +1,62 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
// Middleware runs on edge runtime - we can't verify JWT here easily
// Let the pages handle auth checks via /api/auth/me
export function middleware(request: NextRequest) {
// No redirects - login and admin pages handle their own auth UI
return NextResponse.next();
// Paths that require authentication
const PROTECTED_PATHS = ['/admin'];
// Paths that require no authentication (login page should not redirect logged-in users)
const AUTH_PATHS = ['/login'];
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
export const config = {
matcher: ['/admin/:path*', '/login'],
matcher: ['/admin/:path*', '/login', '/api/:path*'],
};