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
|
apiVersion: v1
|
||||||
kind: Secret
|
kind: Secret
|
||||||
metadata:
|
metadata:
|
||||||
|
|||||||
Generated
+44
-45
@@ -11,7 +11,7 @@
|
|||||||
"@chenglou/pretext": "^0.0.8",
|
"@chenglou/pretext": "^0.0.8",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"jose": "^5.6.3",
|
"jose": "^5.6.3",
|
||||||
"next": "14.2.28",
|
"next": "^14.2.35",
|
||||||
"pg": "^8.12.0",
|
"pg": "^8.12.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
@@ -1354,15 +1354,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/env": {
|
"node_modules/@next/env": {
|
||||||
"version": "14.2.28",
|
"version": "14.2.35",
|
||||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.28.tgz",
|
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.35.tgz",
|
||||||
"integrity": "sha512-PAmWhJfJQlP+kxZwCjrVd9QnR5x0R3u0mTXTiZDgSd4h5LdXmjxCCWbN9kq6hkZBOax8Rm3xDW5HagWyJuT37g==",
|
"integrity": "sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-arm64": {
|
"node_modules/@next/swc-darwin-arm64": {
|
||||||
"version": "14.2.28",
|
"version": "14.2.33",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.28.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.33.tgz",
|
||||||
"integrity": "sha512-kzGChl9setxYWpk3H6fTZXXPFFjg7urptLq5o5ZgYezCrqlemKttwMT5iFyx/p1e/JeglTwDFRtb923gTJ3R1w==",
|
"integrity": "sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1376,9 +1376,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-x64": {
|
"node_modules/@next/swc-darwin-x64": {
|
||||||
"version": "14.2.28",
|
"version": "14.2.33",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.28.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.33.tgz",
|
||||||
"integrity": "sha512-z6FXYHDJlFOzVEOiiJ/4NG8aLCeayZdcRSMjPDysW297Up6r22xw6Ea9AOwQqbNsth8JNgIK8EkWz2IDwaLQcw==",
|
"integrity": "sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1392,9 +1392,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||||
"version": "14.2.28",
|
"version": "14.2.33",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.28.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.33.tgz",
|
||||||
"integrity": "sha512-9ARHLEQXhAilNJ7rgQX8xs9aH3yJSj888ssSjJLeldiZKR4D7N08MfMqljk77fAwZsWwsrp8ohHsMvurvv9liQ==",
|
"integrity": "sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1411,9 +1411,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-musl": {
|
"node_modules/@next/swc-linux-arm64-musl": {
|
||||||
"version": "14.2.28",
|
"version": "14.2.33",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.28.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.33.tgz",
|
||||||
"integrity": "sha512-p6gvatI1nX41KCizEe6JkF0FS/cEEF0u23vKDpl+WhPe/fCTBeGkEBh7iW2cUM0rvquPVwPWdiUR6Ebr/kQWxQ==",
|
"integrity": "sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1430,9 +1430,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-gnu": {
|
"node_modules/@next/swc-linux-x64-gnu": {
|
||||||
"version": "14.2.28",
|
"version": "14.2.33",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.28.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.33.tgz",
|
||||||
"integrity": "sha512-nsiSnz2wO6GwMAX2o0iucONlVL7dNgKUqt/mDTATGO2NY59EO/ZKnKEr80BJFhuA5UC1KZOMblJHWZoqIJddpA==",
|
"integrity": "sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1449,9 +1449,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-musl": {
|
"node_modules/@next/swc-linux-x64-musl": {
|
||||||
"version": "14.2.28",
|
"version": "14.2.33",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.28.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.33.tgz",
|
||||||
"integrity": "sha512-+IuGQKoI3abrXFqx7GtlvNOpeExUH1mTIqCrh1LGFf8DnlUcTmOOCApEnPJUSLrSbzOdsF2ho2KhnQoO0I1RDw==",
|
"integrity": "sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1468,9 +1468,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||||
"version": "14.2.28",
|
"version": "14.2.33",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.28.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.33.tgz",
|
||||||
"integrity": "sha512-l61WZ3nevt4BAnGksUVFKy2uJP5DPz2E0Ma/Oklvo3sGj9sw3q7vBWONFRgz+ICiHpW5mV+mBrkB3XEubMrKaA==",
|
"integrity": "sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1484,9 +1484,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-ia32-msvc": {
|
"node_modules/@next/swc-win32-ia32-msvc": {
|
||||||
"version": "14.2.28",
|
"version": "14.2.33",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.28.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.33.tgz",
|
||||||
"integrity": "sha512-+Kcp1T3jHZnJ9v9VTJ/yf1t/xmtFAc/Sge4v7mVc1z+NYfYzisi8kJ9AsY8itbgq+WgEwMtOpiLLJsUy2qnXZw==",
|
"integrity": "sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -1500,9 +1500,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-x64-msvc": {
|
"node_modules/@next/swc-win32-x64-msvc": {
|
||||||
"version": "14.2.28",
|
"version": "14.2.33",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.28.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.33.tgz",
|
||||||
"integrity": "sha512-1gCmpvyhz7DkB1srRItJTnmR2UwQPAUXXIg9r0/56g3O8etGmwlX68skKXJOp9EejW3hhv7nSQUJ2raFiz4MoA==",
|
"integrity": "sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2896,13 +2896,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/next": {
|
"node_modules/next": {
|
||||||
"version": "14.2.28",
|
"version": "14.2.35",
|
||||||
"resolved": "https://registry.npmjs.org/next/-/next-14.2.28.tgz",
|
"resolved": "https://registry.npmjs.org/next/-/next-14.2.35.tgz",
|
||||||
"integrity": "sha512-QLEIP/kYXynIxtcKB6vNjtWLVs3Y4Sb+EClTC/CSVzdLD1gIuItccpu/n1lhmduffI32iPGEK2cLLxxt28qgYA==",
|
"integrity": "sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==",
|
||||||
"deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details.",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/env": "14.2.28",
|
"@next/env": "14.2.35",
|
||||||
"@swc/helpers": "0.5.5",
|
"@swc/helpers": "0.5.5",
|
||||||
"busboy": "1.6.0",
|
"busboy": "1.6.0",
|
||||||
"caniuse-lite": "^1.0.30001579",
|
"caniuse-lite": "^1.0.30001579",
|
||||||
@@ -2917,15 +2916,15 @@
|
|||||||
"node": ">=18.17.0"
|
"node": ">=18.17.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@next/swc-darwin-arm64": "14.2.28",
|
"@next/swc-darwin-arm64": "14.2.33",
|
||||||
"@next/swc-darwin-x64": "14.2.28",
|
"@next/swc-darwin-x64": "14.2.33",
|
||||||
"@next/swc-linux-arm64-gnu": "14.2.28",
|
"@next/swc-linux-arm64-gnu": "14.2.33",
|
||||||
"@next/swc-linux-arm64-musl": "14.2.28",
|
"@next/swc-linux-arm64-musl": "14.2.33",
|
||||||
"@next/swc-linux-x64-gnu": "14.2.28",
|
"@next/swc-linux-x64-gnu": "14.2.33",
|
||||||
"@next/swc-linux-x64-musl": "14.2.28",
|
"@next/swc-linux-x64-musl": "14.2.33",
|
||||||
"@next/swc-win32-arm64-msvc": "14.2.28",
|
"@next/swc-win32-arm64-msvc": "14.2.33",
|
||||||
"@next/swc-win32-ia32-msvc": "14.2.28",
|
"@next/swc-win32-ia32-msvc": "14.2.33",
|
||||||
"@next/swc-win32-x64-msvc": "14.2.28"
|
"@next/swc-win32-x64-msvc": "14.2.33"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@opentelemetry/api": "^1.1.0",
|
"@opentelemetry/api": "^1.1.0",
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@
|
|||||||
"@chenglou/pretext": "^0.0.8",
|
"@chenglou/pretext": "^0.0.8",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"jose": "^5.6.3",
|
"jose": "^5.6.3",
|
||||||
"next": "14.2.28",
|
"next": "^14.2.35",
|
||||||
"pg": "^8.12.0",
|
"pg": "^8.12.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ const pool = new Pool({
|
|||||||
port: 5432,
|
port: 5432,
|
||||||
database: 'lahuasca',
|
database: 'lahuasca',
|
||||||
user: '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) {
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+858
-30
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 sizes = await generateImageSizes(data);
|
||||||
|
|
||||||
const { rows } = await db.query(
|
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']
|
[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 { NextResponse } from 'next/server';
|
||||||
import { requireRole, createUser } from '@/lib/auth';
|
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
import { requireRole } from '@/lib/auth';
|
||||||
|
import { getErrorMessage } from '@/lib/errors';
|
||||||
|
|
||||||
|
// Admin: Get all users for recipient selection
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
await requireRole('admin');
|
await requireRole('admin');
|
||||||
const { rows } = await db.query('SELECT id, username, role, comments_disabled, first_name, last_name, preferred_language, terms_accepted_at, email, phone, country, created_at FROM users ORDER BY created_at DESC');
|
|
||||||
return NextResponse.json({ data: rows });
|
|
||||||
} catch (err: any) {
|
|
||||||
return NextResponse.json({ error: err.message || 'Failed to fetch users' }, { status: err.message === 'Unauthorized' ? 401 : 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
const { rows } = await db.query(`
|
||||||
try {
|
SELECT id, username, first_name, last_name, role, email, created_at
|
||||||
await requireRole('admin');
|
FROM users
|
||||||
const body = await request.json();
|
ORDER BY role, first_name, last_name
|
||||||
const { username, password, role = 'user', first_name, last_name, email, phone, country, preferred_language = 'es' } = body;
|
`);
|
||||||
|
|
||||||
if (!username || !password) {
|
return NextResponse.json({ users: rows });
|
||||||
return NextResponse.json({ error: 'Username and password are required' }, { status: 400 });
|
} catch (error) {
|
||||||
}
|
console.error('Get users error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
if (role !== 'admin' && role !== 'user') {
|
{ error: getErrorMessage(error, 'Failed to get users') },
|
||||||
return NextResponse.json({ error: 'Role must be admin or user' }, { status: 400 });
|
{ status: 500 }
|
||||||
}
|
|
||||||
|
|
||||||
const user = await createUser(username, password, role as 'admin' | 'user', { first_name, last_name, email, phone, country, preferred_language });
|
|
||||||
return NextResponse.json({ ok: true, user: { id: user.id, username: user.username, role: user.role, first_name: user.first_name, last_name: user.last_name } });
|
|
||||||
} catch (err: any) {
|
|
||||||
return NextResponse.json({ error: err.message || 'Failed to create user' }, { status: err.message === 'Unauthorized' ? 401 : 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PUT(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
await requireRole('admin');
|
|
||||||
const body = await request.json();
|
|
||||||
const { id, first_name, last_name, comments_disabled, preferred_language, email, phone, country, password } = body;
|
|
||||||
|
|
||||||
// Build dynamic update query
|
|
||||||
const updates: string[] = [];
|
|
||||||
const values: any[] = [];
|
|
||||||
let paramIndex = 1;
|
|
||||||
|
|
||||||
if (first_name !== undefined) {
|
|
||||||
updates.push(`first_name = $${paramIndex++}`);
|
|
||||||
values.push(first_name || null);
|
|
||||||
}
|
|
||||||
if (last_name !== undefined) {
|
|
||||||
updates.push(`last_name = $${paramIndex++}`);
|
|
||||||
values.push(last_name || null);
|
|
||||||
}
|
|
||||||
if (comments_disabled !== undefined) {
|
|
||||||
updates.push(`comments_disabled = $${paramIndex++}`);
|
|
||||||
values.push(comments_disabled === true);
|
|
||||||
}
|
|
||||||
if (preferred_language !== undefined) {
|
|
||||||
updates.push(`preferred_language = $${paramIndex++}`);
|
|
||||||
values.push(preferred_language || 'es');
|
|
||||||
}
|
|
||||||
if (email !== undefined) {
|
|
||||||
updates.push(`email = $${paramIndex++}`);
|
|
||||||
values.push(email || null);
|
|
||||||
}
|
|
||||||
if (phone !== undefined) {
|
|
||||||
updates.push(`phone = $${paramIndex++}`);
|
|
||||||
values.push(phone || null);
|
|
||||||
}
|
|
||||||
if (country !== undefined) {
|
|
||||||
updates.push(`country = $${paramIndex++}`);
|
|
||||||
values.push(country || null);
|
|
||||||
}
|
|
||||||
if (password !== undefined && password) {
|
|
||||||
const { hashPassword } = await import('@/lib/auth');
|
|
||||||
updates.push(`password_hash = $${paramIndex++}`);
|
|
||||||
values.push(await hashPassword(password));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updates.length === 0) {
|
|
||||||
return NextResponse.json({ error: 'No fields to update' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
values.push(id);
|
|
||||||
const { rows } = await db.query(
|
|
||||||
`UPDATE users SET ${updates.join(', ')} WHERE id = $${paramIndex} RETURNING id, username, role, first_name, last_name, comments_disabled, preferred_language, email, phone, country, created_at`,
|
|
||||||
values
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (rows.length === 0) {
|
|
||||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ data: rows[0] });
|
|
||||||
} catch (err: any) {
|
|
||||||
return NextResponse.json({ error: err.message || 'Failed to update user' }, { status: err.message === 'Unauthorized' ? 401 : 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function DELETE(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
await requireRole('admin');
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
const id = searchParams.get('id');
|
|
||||||
if (!id) {
|
|
||||||
return NextResponse.json({ error: 'User ID required' }, { status: 400 });
|
|
||||||
}
|
|
||||||
await db.query('DELETE FROM users WHERE id = $1', [id]);
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
} catch (err: any) {
|
|
||||||
return NextResponse.json({ error: err.message || 'Failed to delete user' }, { status: err.message === 'Unauthorized' ? 401 : 500 });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,7 @@ export async function GET() {
|
|||||||
return NextResponse.json({ authenticated: false }, { status: 401 });
|
return NextResponse.json({ authenticated: false }, { status: 401 });
|
||||||
}
|
}
|
||||||
const { rows } = await db.query(
|
const { rows } = await db.query(
|
||||||
'SELECT id, username, role, first_name, last_name, comments_disabled FROM users WHERE id = $1',
|
'SELECT id, username, role, first_name, last_name, email, comments_disabled FROM users WHERE id = $1',
|
||||||
[session.id]
|
[session.id]
|
||||||
);
|
);
|
||||||
const user = rows[0] || {};
|
const user = rows[0] || {};
|
||||||
@@ -19,6 +19,7 @@ export async function GET() {
|
|||||||
id: session.id,
|
id: session.id,
|
||||||
first_name: user.first_name || null,
|
first_name: user.first_name || null,
|
||||||
last_name: user.last_name || null,
|
last_name: user.last_name || null,
|
||||||
|
email: user.email || null,
|
||||||
comments_disabled: user.comments_disabled === true,
|
comments_disabled: user.comments_disabled === true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { createUser } from '@/lib/auth';
|
import { createUser } from '@/lib/auth';
|
||||||
|
import { getErrorMessage } from '@/lib/errors';
|
||||||
|
|
||||||
export async function POST() {
|
export async function POST() {
|
||||||
try {
|
try {
|
||||||
@@ -12,7 +13,10 @@ export async function POST() {
|
|||||||
|
|
||||||
const user = await createUser(username, password, 'admin');
|
const user = await createUser(username, password, 'admin');
|
||||||
return NextResponse.json({ ok: true, user: { id: user.id, username: user.username, role: user.role } });
|
return NextResponse.json({ ok: true, user: { id: user.id, username: user.username, role: user.role } });
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
return NextResponse.json({ error: err?.message || 'Failed to create bootstrap user' }, { status: 500 });
|
return NextResponse.json(
|
||||||
|
{ error: getErrorMessage(err, 'Failed to create bootstrap user') },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { requireAuth } from '@/lib/auth';
|
||||||
|
import { getErrorMessage } from '@/lib/errors';
|
||||||
|
|
||||||
|
// Send a message to a conversation
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const user = await requireAuth();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const { content } = await request.json();
|
||||||
|
|
||||||
|
if (!content || content.trim().length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Message content is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user is participant
|
||||||
|
const { rows: participants } = await db.query(
|
||||||
|
'SELECT * FROM conversation_participants WHERE conversation_id = $1 AND user_id = $2',
|
||||||
|
[id, user.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (participants.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Not authorized for this conversation' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create message
|
||||||
|
const { rows: msgRows } = await db.query(
|
||||||
|
'INSERT INTO direct_messages (conversation_id, sender_id, content) VALUES ($1, $2, $3) RETURNING *',
|
||||||
|
[id, user.id, content]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update conversation updated_at
|
||||||
|
await db.query(
|
||||||
|
'UPDATE conversations SET updated_at = NOW() WHERE id = $1',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get sender info
|
||||||
|
const { rows: userRows } = await db.query(
|
||||||
|
'SELECT id, username, first_name, last_name, role FROM users WHERE id = $1',
|
||||||
|
[user.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: {
|
||||||
|
...msgRows[0],
|
||||||
|
sender_id: user.id,
|
||||||
|
username: userRows[0].username,
|
||||||
|
first_name: userRows[0].first_name,
|
||||||
|
last_name: userRows[0].last_name,
|
||||||
|
role: userRows[0].role
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Send message error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: getErrorMessage(error, 'Failed to send message') },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { searchParams } = new URL(request.url);
|
||||||
const status = searchParams.get('status') || 'all';
|
const status = searchParams.get('status') || 'all';
|
||||||
const limit = parseInt(searchParams.get('limit') || '50');
|
const limit = parseInt(searchParams.get('limit') || '50');
|
||||||
|
const kitchen = searchParams.get('kitchen') === 'true';
|
||||||
|
|
||||||
let query = `
|
let query = `
|
||||||
SELECT o.*, r.name as room_name, u.username, u.first_name, u.last_name
|
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 { db } from '@/lib/db';
|
||||||
import { requireAuth } from '@/lib/auth';
|
import { requireAuth } from '@/lib/auth';
|
||||||
import { withRateLimit, getClientIp } from '@/lib/rate-limit';
|
import { withRateLimit, getClientIp } from '@/lib/rate-limit';
|
||||||
|
import { getErrorMessage } from '@/lib/errors';
|
||||||
|
|
||||||
// Honeypot field name - bots often autofill all fields
|
// Honeypot field name - bots often autofill all fields
|
||||||
// This field should remain empty for legitimate submissions
|
// This field should remain empty for legitimate submissions
|
||||||
@@ -38,7 +39,10 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ data: rows });
|
return NextResponse.json({ data: rows });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching private event reservations:', 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] });
|
return NextResponse.json({ data: rows[0] });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating private event reservation:', 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 { NextResponse } from 'next/server';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { getSession } from '@/lib/auth';
|
import { getSession } from '@/lib/auth';
|
||||||
|
import { getErrorMessage } from '@/lib/errors';
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
@@ -103,7 +104,10 @@ export async function POST(request: Request) {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Reservation error:', 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 });
|
return NextResponse.json({ reservations: rows });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Get reservations error:', 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');
|
await requireRole('admin');
|
||||||
const id = parseInt(params.id, 10);
|
const id = parseInt(params.id, 10);
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { name, description, price, date, photos, featured_photo, active, sort_order } = body;
|
const { name, description, price, date, photos, featured_photo, active, is_private, max_guests, sort_order } = body;
|
||||||
const { rows } = await db.query(
|
const { rows } = await db.query(
|
||||||
`UPDATE social_events
|
`UPDATE social_events
|
||||||
SET name=$1, description=$2, price=$3, date=$4, photos=$5, featured_photo=$6, active=$7, sort_order=$8
|
SET name=$1, description=$2, price=$3, date=$4, photos=$5, featured_photo=$6, active=$7, is_private=$8, max_guests=$9, sort_order=$10
|
||||||
WHERE id=$9
|
WHERE id=$11
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[name, description || '', price || null, date || null, photos || [], featured_photo || null, active !== false, sort_order || 0, id]
|
[name, description || '', price || null, date || null, photos || [], featured_photo || null, active !== false, is_private || false, max_guests || null, sort_order || 0, id]
|
||||||
);
|
);
|
||||||
if (rows.length === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
if (rows.length === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
return NextResponse.json({ data: rows[0] });
|
return NextResponse.json({ data: rows[0] });
|
||||||
|
|||||||
@@ -2,15 +2,26 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { requireRole } from '@/lib/auth';
|
import { requireRole } from '@/lib/auth';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { rows } = await db.query(`
|
const { searchParams } = new URL(request.url);
|
||||||
|
const includePrivate = searchParams.get('includePrivate') === 'true';
|
||||||
|
|
||||||
|
// Public requests only see active, non-private events
|
||||||
|
// Admin requests with includePrivate=true see all events
|
||||||
|
let query = `
|
||||||
SELECT se.*,
|
SELECT se.*,
|
||||||
(SELECT COUNT(*) FROM social_event_reservations ser WHERE ser.social_event_id = se.id) as reservation_count
|
(SELECT COUNT(*) FROM social_event_reservations ser WHERE ser.social_event_id = se.id) as reservation_count
|
||||||
FROM social_events se
|
FROM social_events se
|
||||||
WHERE se.active = TRUE
|
`;
|
||||||
ORDER BY se.sort_order, se.date, se.id
|
|
||||||
`);
|
if (includePrivate) {
|
||||||
|
query += ' ORDER BY se.is_private, se.sort_order, se.date, se.id';
|
||||||
|
} else {
|
||||||
|
query += ' WHERE se.active = TRUE AND (se.is_private = FALSE OR se.is_private IS NULL) ORDER BY se.sort_order, se.date, se.id';
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows } = await db.query(query);
|
||||||
return NextResponse.json({ data: rows });
|
return NextResponse.json({ data: rows });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('GET /api/social_events error:', error);
|
console.error('GET /api/social_events error:', error);
|
||||||
@@ -22,12 +33,12 @@ export async function POST(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
await requireRole('admin');
|
await requireRole('admin');
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { name, description, price, date, photos, featured_photo, active, sort_order } = body;
|
const { name, description, price, date, photos, featured_photo, active, is_private, max_guests, sort_order } = body;
|
||||||
const { rows } = await db.query(
|
const { rows } = await db.query(
|
||||||
`INSERT INTO social_events (name, description, price, date, photos, featured_photo, active, sort_order)
|
`INSERT INTO social_events (name, description, price, date, photos, featured_photo, active, is_private, max_guests, sort_order)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[name, description || '', price || null, date || null, photos || [], featured_photo || null, active !== false, sort_order || 0]
|
[name, description || '', price || null, date || null, photos || [], featured_photo || null, active !== false, is_private || false, max_guests || null, sort_order || 0]
|
||||||
);
|
);
|
||||||
return NextResponse.json({ data: rows[0] });
|
return NextResponse.json({ data: rows[0] });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { requireAuth, hashPassword } from '@/lib/auth';
|
import { requireAuth, hashPassword } from '@/lib/auth';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
import { getErrorMessage } from '@/lib/errors';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -12,8 +13,12 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Current and new password are required' }, { status: 400 });
|
return NextResponse.json({ error: 'Current and new password are required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (new_password.length < 4) {
|
if (new_password.length < 12) {
|
||||||
return NextResponse.json({ error: 'Password must be at least 4 characters' }, { status: 400 });
|
return NextResponse.json({ error: 'Password must be at least 12 characters' }, { status: 400 });
|
||||||
|
}
|
||||||
|
// Password complexity requirements
|
||||||
|
if (!/[A-Z]/.test(new_password) || !/[a-z]/.test(new_password) || !/[0-9]/.test(new_password)) {
|
||||||
|
return NextResponse.json({ error: 'Password must contain uppercase, lowercase, and numbers' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify current password
|
// Verify current password
|
||||||
@@ -33,7 +38,11 @@ export async function POST(request: NextRequest) {
|
|||||||
await db.query('UPDATE users SET password_hash = $1 WHERE id = $2', [hash, session.id]);
|
await db.query('UPDATE users SET password_hash = $1 WHERE id = $2', [hash, session.id]);
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
return NextResponse.json({ error: err.message || 'Failed to change password' }, { status: err.message === 'Unauthorized' ? 401 : 500 });
|
const message = err instanceof Error && err.message === 'Unauthorized' ? 'Unauthorized' : getErrorMessage(err, 'Failed to change password');
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: message },
|
||||||
|
{ status: err instanceof Error && err.message === 'Unauthorized' ? 401 : 500 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+40
-4
@@ -35,6 +35,7 @@ export default function CoffeePage() {
|
|||||||
const [items, setItems] = useState<MenuItem[]>([]);
|
const [items, setItems] = useState<MenuItem[]>([]);
|
||||||
const [rooms, setRooms] = useState<Room[]>([]);
|
const [rooms, setRooms] = useState<Room[]>([]);
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [assignedRoom, setAssignedRoom] = useState<Room | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
@@ -53,8 +54,13 @@ export default function CoffeePage() {
|
|||||||
if (!res.ok) throw new Error('Failed to load menu');
|
if (!res.ok) throw new Error('Failed to load menu');
|
||||||
return res.json();
|
return res.json();
|
||||||
}),
|
}),
|
||||||
fetch('/api/auth/session').then(async (res) => {
|
fetch('/api/auth/me').then(async (res) => {
|
||||||
if (res.ok) return res.json();
|
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 };
|
return { user: null };
|
||||||
}),
|
}),
|
||||||
fetch('/api/rooms').then(async (res) => {
|
fetch('/api/rooms').then(async (res) => {
|
||||||
@@ -62,10 +68,25 @@ export default function CoffeePage() {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
.then(([menuData, sessionData, roomsData]) => {
|
.then(async ([menuData, sessionData, roomsData]) => {
|
||||||
setItems(menuData.data || []);
|
setItems(menuData.data || []);
|
||||||
setUser(sessionData.user || null);
|
setUser(sessionData.user || null);
|
||||||
setRooms(roomsData.rooms || []);
|
setRooms(roomsData.rooms || []);
|
||||||
|
|
||||||
|
// Fetch user's active room assignment if logged in
|
||||||
|
if (sessionData.user) {
|
||||||
|
try {
|
||||||
|
const assignRes = await fetch('/api/room-assignments');
|
||||||
|
if (assignRes.ok) {
|
||||||
|
const assignData = await assignRes.json();
|
||||||
|
if (assignData.assignments?.length > 0) {
|
||||||
|
const assignment = assignData.assignments[0];
|
||||||
|
setAssignedRoom({ id: assignment.room_id, name: assignment.room_name });
|
||||||
|
setSelectedRoom(assignment.room_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => setError(err.message))
|
.catch((err) => setError(err.message))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
@@ -359,7 +380,21 @@ export default function CoffeePage() {
|
|||||||
{/* Room Selection for Delivery */}
|
{/* Room Selection for Delivery */}
|
||||||
{orderType === 'room_delivery' && user && (
|
{orderType === 'room_delivery' && user && (
|
||||||
<div style={{ marginBottom: '1.5rem' }}>
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
<label style={{ display: 'block', fontWeight: 600, marginBottom: '0.5rem', color: navy }}>Select Room</label>
|
<label style={{ display: 'block', fontWeight: 600, marginBottom: '0.5rem', color: navy }}>
|
||||||
|
{assignedRoom ? 'Your Room' : 'Select Room'}
|
||||||
|
</label>
|
||||||
|
{assignedRoom ? (
|
||||||
|
<div style={{
|
||||||
|
padding: '0.75rem',
|
||||||
|
border: '2px solid gold',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: '#fff9e6',
|
||||||
|
color: navy,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}>
|
||||||
|
🏨 {assignedRoom.name}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<select
|
<select
|
||||||
value={selectedRoom || ''}
|
value={selectedRoom || ''}
|
||||||
onChange={(e) => setSelectedRoom(Number(e.target.value) || null)}
|
onChange={(e) => setSelectedRoom(Number(e.target.value) || null)}
|
||||||
@@ -376,6 +411,7 @@ export default function CoffeePage() {
|
|||||||
<option key={room.id} value={room.id}>{room.name}</option>
|
<option key={room.id} value={room.id}>{room.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,24 @@ a {
|
|||||||
|
|
||||||
.navbar-actions {
|
.navbar-actions {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-icon-link {
|
||||||
|
color: var(--gold);
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-icon-link:hover {
|
||||||
|
background: rgba(212, 175, 55, 0.1);
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-btn-ghost {
|
.navbar-btn-ghost {
|
||||||
|
|||||||
@@ -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' })
|
fetch('/api/auth/me', { credentials: 'same-origin' })
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((j) => setUser(j.user || null))
|
.then((j) => {
|
||||||
|
if (j.authenticated) {
|
||||||
|
setUser({
|
||||||
|
id: j.id,
|
||||||
|
username: j.username,
|
||||||
|
role: j.role,
|
||||||
|
first_name: j.first_name,
|
||||||
|
last_name: j.last_name,
|
||||||
|
email: j.email,
|
||||||
|
comments_disabled: j.comments_disabled,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,17 @@ export default function Navbar() {
|
|||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
<div className="navbar-actions">
|
<div className="navbar-actions">
|
||||||
|
{auth?.authenticated && (
|
||||||
|
<Link
|
||||||
|
href={auth?.role === 'admin' ? '/admin/messages' : '/messages'}
|
||||||
|
className="navbar-icon-link"
|
||||||
|
title="Messages"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
{auth?.authenticated ? (
|
{auth?.authenticated ? (
|
||||||
<button className="navbar-btn-ghost" onClick={handleLogout}>Logout</button>
|
<button className="navbar-btn-ghost" onClick={handleLogout}>Logout</button>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
+1
-10
@@ -18,17 +18,8 @@ export function useAuth() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const login = useCallback((username: string, password: string): boolean => {
|
const login = useCallback((username: string, password: string): boolean => {
|
||||||
|
// TODO: Implement server-side authentication via API call
|
||||||
if (typeof window === 'undefined') return false;
|
if (typeof window === 'undefined') return false;
|
||||||
if (username === 'admin' && password === 'admin') {
|
|
||||||
localStorage.setItem(ROLE_KEY, 'admin');
|
|
||||||
setRole('admin');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (username === 'user' && password === 'user') {
|
|
||||||
localStorage.setItem(ROLE_KEY, 'user');
|
|
||||||
setRole('user');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
return false;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
+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 }) {
|
export async function createSession(user: { id: number; username: string; role: string }) {
|
||||||
const token = await new SignJWT({ id: user.id, username: user.username, role: user.role })
|
const token = await new SignJWT({ id: user.id, username: user.username, role: user.role })
|
||||||
.setProtectedHeader({ alg: 'HS256' })
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
.setExpirationTime('7d')
|
.setExpirationTime('4h') // 4 hours for security (reduced from 7 days)
|
||||||
.sign(getSecret());
|
.sign(getSecret());
|
||||||
cookies().set('session', token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 60 * 60 * 24 * 7 });
|
cookies().set('session', token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 60 * 60 * 4 }); // 4 hours
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(`
|
await db.query(`
|
||||||
CREATE TABLE IF NOT EXISTS places (
|
CREATE TABLE IF NOT EXISTS places (
|
||||||
id SERIAL PRIMARY KEY,
|
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(`
|
await db.query(`
|
||||||
CREATE TABLE IF NOT EXISTS reviews (
|
CREATE TABLE IF NOT EXISTS reviews (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
@@ -176,11 +184,17 @@ export async function initSchema() {
|
|||||||
photos TEXT[] DEFAULT '{}',
|
photos TEXT[] DEFAULT '{}',
|
||||||
featured_photo VARCHAR(500),
|
featured_photo VARCHAR(500),
|
||||||
active BOOLEAN DEFAULT TRUE,
|
active BOOLEAN DEFAULT TRUE,
|
||||||
|
is_private BOOLEAN DEFAULT FALSE,
|
||||||
|
max_guests INTEGER,
|
||||||
sort_order INTEGER DEFAULT 0,
|
sort_order INTEGER DEFAULT 0,
|
||||||
created_at TIMESTAMP DEFAULT NOW()
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Add is_private and max_guests columns if they don't exist
|
||||||
|
await db.query(`ALTER TABLE social_events ADD COLUMN IF NOT EXISTS is_private BOOLEAN DEFAULT FALSE`);
|
||||||
|
await db.query(`ALTER TABLE social_events ADD COLUMN IF NOT EXISTS max_guests INTEGER`);
|
||||||
|
|
||||||
await db.query(`
|
await db.query(`
|
||||||
CREATE TABLE IF NOT EXISTS social_event_reservations (
|
CREATE TABLE IF NOT EXISTS social_event_reservations (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
@@ -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_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)`);
|
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
|
// Orders tables for coffee/kitchen
|
||||||
await db.query(`
|
await db.query(`
|
||||||
CREATE TABLE IF NOT EXISTS orders (
|
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_orders_status ON orders(status)`);
|
||||||
await db.query(`CREATE INDEX IF NOT EXISTS idx_order_items_order ON order_items(order_id)`);
|
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
|
// Audit log for admin actions
|
||||||
await db.query(`
|
await db.query(`
|
||||||
CREATE TABLE IF NOT EXISTS audit_log (
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
@@ -470,6 +558,20 @@ export async function migrateFromLegacyPlaces() {
|
|||||||
await db.query(`
|
await db.query(`
|
||||||
ALTER TABLE rooms ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500);
|
ALTER TABLE rooms ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Add room status columns for housekeeping management
|
||||||
|
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS clean_status VARCHAR(20) DEFAULT 'clean'`);
|
||||||
|
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS notes TEXT`);
|
||||||
|
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS last_cleaned TIMESTAMP`);
|
||||||
|
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS assigned_staff_id INTEGER REFERENCES users(id)`);
|
||||||
|
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS priority VARCHAR(10) DEFAULT 'normal'`);
|
||||||
|
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS issues_count INTEGER DEFAULT 0`);
|
||||||
|
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS is_vip BOOLEAN DEFAULT FALSE`);
|
||||||
|
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS checkout_time TIME`);
|
||||||
|
await db.query(`ALTER TABLE rooms ADD COLUMN IF NOT EXISTS checkout_date DATE`);
|
||||||
|
|
||||||
|
// Add payment_status to room_reservations
|
||||||
|
await db.query(`ALTER TABLE room_reservations ADD COLUMN IF NOT EXISTS payment_status VARCHAR(20) DEFAULT 'pending'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function seed() {
|
export async function seed() {
|
||||||
|
|||||||
+54
-6
@@ -1,14 +1,62 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import type { NextRequest } from 'next/server';
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
// Middleware runs on edge runtime - we can't verify JWT here easily
|
// Paths that require authentication
|
||||||
// Let the pages handle auth checks via /api/auth/me
|
const PROTECTED_PATHS = ['/admin'];
|
||||||
export function middleware(request: NextRequest) {
|
// Paths that require no authentication (login page should not redirect logged-in users)
|
||||||
// No redirects - login and admin pages handle their own auth UI
|
const AUTH_PATHS = ['/login'];
|
||||||
return NextResponse.next();
|
|
||||||
|
export async function middleware(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
|
// Check if this is a protected path
|
||||||
|
const isProtectedPath = PROTECTED_PATHS.some(path => pathname.startsWith(path));
|
||||||
|
const isAuthPath = AUTH_PATHS.some(path => pathname.startsWith(path));
|
||||||
|
|
||||||
|
if (isProtectedPath) {
|
||||||
|
// Get session token from cookie
|
||||||
|
const sessionToken = request.cookies.get('session')?.value;
|
||||||
|
|
||||||
|
if (!sessionToken) {
|
||||||
|
// No session, redirect to login
|
||||||
|
const loginUrl = new URL('/login', request.url);
|
||||||
|
return NextResponse.redirect(loginUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session exists - actual auth validation happens in API routes
|
||||||
|
// We can't verify JWT in Edge runtime without external libraries
|
||||||
|
// The login page and API routes handle full auth checks
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add security headers to all responses
|
||||||
|
const response = NextResponse.next();
|
||||||
|
|
||||||
|
response.headers.set('X-Content-Type-Options', 'nosniff');
|
||||||
|
response.headers.set('X-Frame-Options', 'DENY');
|
||||||
|
response.headers.set('X-XSS-Protection', '1; mode=block');
|
||||||
|
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||||
|
|
||||||
|
// Content Security Policy - allow inline styles for Next.js
|
||||||
|
response.headers.set(
|
||||||
|
'Content-Security-Policy',
|
||||||
|
"default-src 'self'; " +
|
||||||
|
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; " +
|
||||||
|
"style-src 'self' 'unsafe-inline'; " +
|
||||||
|
"img-src 'self' data: blob: https:; " +
|
||||||
|
"font-src 'self' data:; " +
|
||||||
|
"connect-src 'self'; " +
|
||||||
|
"frame-ancestors 'none';"
|
||||||
|
);
|
||||||
|
|
||||||
|
// HSTS for production (assuming TLS termination at proxy)
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
response.headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure which paths the middleware should run on
|
// Configure which paths the middleware should run on
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ['/admin/:path*', '/login'],
|
matcher: ['/admin/:path*', '/login', '/api/:path*'],
|
||||||
};
|
};
|
||||||
Reference in New Issue
Block a user