Compare commits
10 Commits
dc0c5fd289
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 288767f6c0 | |||
| 9286f3e323 | |||
| 4e514c2fbb | |||
| bfc627f451 | |||
| 60afc2349e | |||
| 9557fa7d07 | |||
| 200784fa49 | |||
| 0785facd9e | |||
| 6c8d70d41c | |||
| 1e66e918ac |
@@ -1,3 +1,4 @@
|
||||
# WARNING: Replace CHANGE_ME_* values before applying
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
|
||||
Generated
+44
-45
@@ -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
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+865
-37
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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] });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
+54
-18
@@ -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,23 +380,38 @@ 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>
|
||||
<select
|
||||
value={selectedRoom || ''}
|
||||
onChange={(e) => setSelectedRoom(Number(e.target.value) || null)}
|
||||
style={{
|
||||
width: '100%',
|
||||
<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 #ddd',
|
||||
border: '2px solid gold',
|
||||
borderRadius: '8px',
|
||||
fontSize: '16px',
|
||||
}}
|
||||
>
|
||||
<option value="">Select a room...</option>
|
||||
{rooms.map((room) => (
|
||||
<option key={room.id} value={room.id}>{room.name}</option>
|
||||
))}
|
||||
</select>
|
||||
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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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(() => {});
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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
@@ -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*'],
|
||||
};
|
||||
Reference in New Issue
Block a user