diff --git a/package-lock.json b/package-lock.json index 4291fbb..0b2afbc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "@chenglou/pretext": "^0.0.8", "bcryptjs": "^2.4.3", "jose": "^5.6.3", - "next": "14.2.28", + "next": "^14.2.35", "pg": "^8.12.0", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -1354,15 +1354,15 @@ } }, "node_modules/@next/env": { - "version": "14.2.28", - "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.28.tgz", - "integrity": "sha512-PAmWhJfJQlP+kxZwCjrVd9QnR5x0R3u0mTXTiZDgSd4h5LdXmjxCCWbN9kq6hkZBOax8Rm3xDW5HagWyJuT37g==", + "version": "14.2.35", + "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.35.tgz", + "integrity": "sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==", "license": "MIT" }, "node_modules/@next/swc-darwin-arm64": { - "version": "14.2.28", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.28.tgz", - "integrity": "sha512-kzGChl9setxYWpk3H6fTZXXPFFjg7urptLq5o5ZgYezCrqlemKttwMT5iFyx/p1e/JeglTwDFRtb923gTJ3R1w==", + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.33.tgz", + "integrity": "sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA==", "cpu": [ "arm64" ], @@ -1376,9 +1376,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "14.2.28", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.28.tgz", - "integrity": "sha512-z6FXYHDJlFOzVEOiiJ/4NG8aLCeayZdcRSMjPDysW297Up6r22xw6Ea9AOwQqbNsth8JNgIK8EkWz2IDwaLQcw==", + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.33.tgz", + "integrity": "sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA==", "cpu": [ "x64" ], @@ -1392,9 +1392,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "14.2.28", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.28.tgz", - "integrity": "sha512-9ARHLEQXhAilNJ7rgQX8xs9aH3yJSj888ssSjJLeldiZKR4D7N08MfMqljk77fAwZsWwsrp8ohHsMvurvv9liQ==", + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.33.tgz", + "integrity": "sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw==", "cpu": [ "arm64" ], @@ -1411,9 +1411,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "14.2.28", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.28.tgz", - "integrity": "sha512-p6gvatI1nX41KCizEe6JkF0FS/cEEF0u23vKDpl+WhPe/fCTBeGkEBh7iW2cUM0rvquPVwPWdiUR6Ebr/kQWxQ==", + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.33.tgz", + "integrity": "sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==", "cpu": [ "arm64" ], @@ -1430,9 +1430,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "14.2.28", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.28.tgz", - "integrity": "sha512-nsiSnz2wO6GwMAX2o0iucONlVL7dNgKUqt/mDTATGO2NY59EO/ZKnKEr80BJFhuA5UC1KZOMblJHWZoqIJddpA==", + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.33.tgz", + "integrity": "sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==", "cpu": [ "x64" ], @@ -1449,9 +1449,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "14.2.28", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.28.tgz", - "integrity": "sha512-+IuGQKoI3abrXFqx7GtlvNOpeExUH1mTIqCrh1LGFf8DnlUcTmOOCApEnPJUSLrSbzOdsF2ho2KhnQoO0I1RDw==", + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.33.tgz", + "integrity": "sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==", "cpu": [ "x64" ], @@ -1468,9 +1468,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "14.2.28", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.28.tgz", - "integrity": "sha512-l61WZ3nevt4BAnGksUVFKy2uJP5DPz2E0Ma/Oklvo3sGj9sw3q7vBWONFRgz+ICiHpW5mV+mBrkB3XEubMrKaA==", + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.33.tgz", + "integrity": "sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==", "cpu": [ "arm64" ], @@ -1484,9 +1484,9 @@ } }, "node_modules/@next/swc-win32-ia32-msvc": { - "version": "14.2.28", - "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.28.tgz", - "integrity": "sha512-+Kcp1T3jHZnJ9v9VTJ/yf1t/xmtFAc/Sge4v7mVc1z+NYfYzisi8kJ9AsY8itbgq+WgEwMtOpiLLJsUy2qnXZw==", + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.33.tgz", + "integrity": "sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==", "cpu": [ "ia32" ], @@ -1500,9 +1500,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "14.2.28", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.28.tgz", - "integrity": "sha512-1gCmpvyhz7DkB1srRItJTnmR2UwQPAUXXIg9r0/56g3O8etGmwlX68skKXJOp9EejW3hhv7nSQUJ2raFiz4MoA==", + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.33.tgz", + "integrity": "sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg==", "cpu": [ "x64" ], @@ -2896,13 +2896,12 @@ } }, "node_modules/next": { - "version": "14.2.28", - "resolved": "https://registry.npmjs.org/next/-/next-14.2.28.tgz", - "integrity": "sha512-QLEIP/kYXynIxtcKB6vNjtWLVs3Y4Sb+EClTC/CSVzdLD1gIuItccpu/n1lhmduffI32iPGEK2cLLxxt28qgYA==", - "deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details.", + "version": "14.2.35", + "resolved": "https://registry.npmjs.org/next/-/next-14.2.35.tgz", + "integrity": "sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==", "license": "MIT", "dependencies": { - "@next/env": "14.2.28", + "@next/env": "14.2.35", "@swc/helpers": "0.5.5", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", @@ -2917,15 +2916,15 @@ "node": ">=18.17.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "14.2.28", - "@next/swc-darwin-x64": "14.2.28", - "@next/swc-linux-arm64-gnu": "14.2.28", - "@next/swc-linux-arm64-musl": "14.2.28", - "@next/swc-linux-x64-gnu": "14.2.28", - "@next/swc-linux-x64-musl": "14.2.28", - "@next/swc-win32-arm64-msvc": "14.2.28", - "@next/swc-win32-ia32-msvc": "14.2.28", - "@next/swc-win32-x64-msvc": "14.2.28" + "@next/swc-darwin-arm64": "14.2.33", + "@next/swc-darwin-x64": "14.2.33", + "@next/swc-linux-arm64-gnu": "14.2.33", + "@next/swc-linux-arm64-musl": "14.2.33", + "@next/swc-linux-x64-gnu": "14.2.33", + "@next/swc-linux-x64-musl": "14.2.33", + "@next/swc-win32-arm64-msvc": "14.2.33", + "@next/swc-win32-ia32-msvc": "14.2.33", + "@next/swc-win32-x64-msvc": "14.2.33" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", diff --git a/package.json b/package.json index 216f1dc..cf3bf40 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "@chenglou/pretext": "^0.0.8", "bcryptjs": "^2.4.3", "jose": "^5.6.3", - "next": "14.2.28", + "next": "^14.2.35", "pg": "^8.12.0", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/src/app/admin/messages/page.tsx b/src/app/admin/messages/page.tsx new file mode 100644 index 0000000..33c6e23 --- /dev/null +++ b/src/app/admin/messages/page.tsx @@ -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([]); + const [users, setUsers] = useState([]); + const [selectedConv, setSelectedConv] = useState(null); + const [messages, setMessages] = useState([]); + const [participants, setParticipants] = useState([]); + 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([]); + const [loading, setLoading] = useState(true); + const [sending, setSending] = useState(false); + const messagesEndRef = useRef(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 ( +
+
Loading...
+
+ ); + } + + return ( +
+
+

Messages

+ +
+ +
+ {/* Conversations Panel */} +
+
Customer Conversations
+
+ {customerConvs.length === 0 ? ( +
No customer conversations
+ ) : ( + customerConvs.map(conv => ( +
setSelectedConv(conv.id)} + className={`p-3 cursor-pointer hover:bg-gray-50 ${selectedConv === conv.id ? 'bg-blue-50' : ''}`} + > +
+
+ {conv.participants.filter(p => p.role === 'customer').map(getDisplayName).join(', ')} +
+ {conv.unread_count > 0 && ( + + {conv.unread_count} + + )} +
+ {conv.last_message && ( +
{conv.last_message}
+ )} +
+ {conv.last_message_at ? formatDisplayDateTime(conv.last_message_at) : ''} +
+
+ )) + )} +
+ +
Admin Conversations
+
+ {adminConvs.length === 0 ? ( +
No admin conversations
+ ) : ( + adminConvs.map(conv => ( +
setSelectedConv(conv.id)} + className={`p-3 cursor-pointer hover:bg-gray-50 ${selectedConv === conv.id ? 'bg-blue-50' : ''}`} + > +
+ {conv.subject || conv.participants.map(getDisplayName).join(', ')} +
+ {conv.last_message && ( +
{conv.last_message}
+ )} +
+ )) + )} +
+
+ + {/* Messages Panel */} +
+ {selectedConv ? ( + <> +
+
+ Conversation with: {participants.filter(p => p.role === 'customer').map(getDisplayName).join(', ') || 'Admins'} +
+
+ +
+ {messages.map(msg => ( +
+
+
+ + {msg.first_name || msg.username} + + + {msg.role} + +
+
{msg.content}
+
+ {formatDisplayDateTime(msg.created_at)} +
+
+
+ ))} +
+
+ +
+
+ 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" + /> + +
+
+ + ) : ( +
+ Select a conversation to view messages +
+ )} +
+
+ + {/* Bulk Message Modal */} + {showBulk && ( +
+
+

Send Bulk Message

+ +
+
+ + +
+ + {bulkType === 'specific_users' && ( +
+ +
+ {users.filter(u => u.role === 'customer').map(user => ( + + ))} +
+
{selectedUsers.length} selected
+
+ )} + +
+ + 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" + /> +
+ +
+ +