From a4b23e44b6c297a4a319e89f0cc5f131e658bc79 Mon Sep 17 00:00:00 2001 From: poduck Date: Mon, 8 Dec 2025 02:33:27 -0500 Subject: [PATCH] feat(messaging): Add broadcast messaging system for owners and managers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add BroadcastMessage and MessageRecipient models for sending messages to groups or individuals - Add Messages page with compose form and sent messages list - Support targeting by role (owners, managers, staff, customers) or individual users - Add can_send_messages permission (owners always, managers by default with revocable permission) - Add autofill search dropdown with infinite scroll for selecting individual recipients - Add staff permission toggle for managers' messaging access - Integrate Messages link in sidebar for users with permission 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- frontend/src/App.tsx | 8 +- frontend/src/api/auth.ts | 10 + frontend/src/components/Sidebar.tsx | 5 +- frontend/src/components/StaffPermissions.tsx | 9 + frontend/src/i18n/locales/en.json | 24 +- frontend/src/pages/Messages.tsx | 842 ++++++++++++++++++ frontend/src/types.ts | 3 + .../config/settings/multitenancy.py | 3 + smoothschedule/config/urls.py | 2 + .../communication/messaging/apps.py | 4 +- .../0002_add_broadcast_messaging.py | 98 ++ .../communication/messaging/models.py | 206 ++++- .../communication/messaging/serializers.py | 187 ++++ .../communication/messaging/urls.py | 11 + .../communication/messaging/views.py | 344 ++++++- .../identity/users/api_views.py | 35 +- .../smoothschedule/identity/users/models.py | 18 +- 17 files changed, 1782 insertions(+), 27 deletions(-) create mode 100644 frontend/src/pages/Messages.tsx create mode 100644 smoothschedule/smoothschedule/communication/messaging/migrations/0002_add_broadcast_messaging.py create mode 100644 smoothschedule/smoothschedule/communication/messaging/serializers.py create mode 100644 smoothschedule/smoothschedule/communication/messaging/urls.py diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0b56e93..ba60f7c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -41,6 +41,7 @@ const Scheduler = React.lazy(() => import('./pages/Scheduler')); const Customers = React.lazy(() => import('./pages/Customers')); const Settings = React.lazy(() => import('./pages/Settings')); const Payments = React.lazy(() => import('./pages/Payments')); +const Messages = React.lazy(() => import('./pages/Messages')); const Resources = React.lazy(() => import('./pages/Resources')); const Services = React.lazy(() => import('./pages/Services')); const Staff = React.lazy(() => import('./pages/Staff')); @@ -861,11 +862,8 @@ const AppContent: React.FC = () => { -

Messages

-

Messages feature coming soon...

- + hasAccess(['owner', 'manager']) && user?.can_send_messages ? ( + ) : ( ) diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index d728490..734960d 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -47,6 +47,7 @@ export interface LoginResponse { business?: number; business_name?: string; business_subdomain?: string; + can_send_messages?: boolean; }; masquerade_stack?: MasqueradeStackEntry[]; // MFA challenge response @@ -73,6 +74,7 @@ export interface User { can_invite_staff?: boolean; can_access_tickets?: boolean; can_edit_schedule?: boolean; + can_send_messages?: boolean; linked_resource_id?: number; quota_overages?: QuotaOverage[]; } @@ -134,3 +136,11 @@ export const stopMasquerade = async ( ); return response.data; }; + +/** + * Request password reset email + */ +export const forgotPassword = async (email: string): Promise<{ message: string }> => { + const response = await apiClient.post<{ message: string }>('/auth/password-reset/', { email }); + return response.data; +}; diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index b016fea..93788b5 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -46,6 +46,7 @@ const Sidebar: React.FC = ({ business, user, isCollapsed, toggleCo const isStaff = role === 'staff'; const canViewSettings = role === 'owner'; const canViewTickets = role === 'owner' || role === 'manager' || (role === 'staff' && user.can_access_tickets); + const canSendMessages = user.can_send_messages === true; const handleSignOut = () => { logoutMutation.mutate(); @@ -195,9 +196,9 @@ const Sidebar: React.FC = ({ business, user, isCollapsed, toggleCo )} {/* Communicate Section - Tickets + Messages */} - {(canViewTickets || canViewAdminPages) && ( + {(canViewTickets || canSendMessages) && ( - {canViewAdminPages && ( + {canSendMessages && ( { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + + // State + const [activeTab, setActiveTab] = useState('compose'); + const [subject, setSubject] = useState(''); + const [body, setBody] = useState(''); + const [selectedRoles, setSelectedRoles] = useState([]); + const [selectedUsers, setSelectedUsers] = useState([]); + const [deliveryMethod, setDeliveryMethod] = useState<'IN_APP' | 'EMAIL' | 'SMS' | 'ALL'>('IN_APP'); + const [searchTerm, setSearchTerm] = useState(''); + const [statusFilter, setStatusFilter] = useState<'ALL' | 'SENT' | 'SENDING' | 'FAILED'>('ALL'); + const [selectedMessage, setSelectedMessage] = useState(null); + const [recipientSearchTerm, setRecipientSearchTerm] = useState(''); + const [isRecipientDropdownOpen, setIsRecipientDropdownOpen] = useState(false); + const [visibleRecipientCount, setVisibleRecipientCount] = useState(20); + const dropdownRef = useRef(null); + + // Queries + const { data: messages = [], isLoading: messagesLoading } = useQuery({ + queryKey: ['broadcast-messages'], + queryFn: async () => { + const response = await api.get('/messages/broadcast-messages/'); + return response.data; + }, + }); + + const { data: recipientOptions, isLoading: recipientsLoading } = useQuery({ + queryKey: ['message-recipient-options'], + queryFn: async () => { + const response = await api.get('/messages/broadcast-messages/recipient_options/'); + return response.data; + }, + }); + + // Mutations + const createMessage = useMutation({ + mutationFn: async (data: { + subject: string; + body: string; + target_roles: string[]; + target_users: string[]; + delivery_method: string; + }) => { + const response = await api.post('/messages/broadcast-messages/', data); + return response.data; + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['broadcast-messages'] }); + // Auto-send the message + sendMessage.mutate(data.id); + }, + onError: (error: any) => { + toast.error(error.response?.data?.error || 'Failed to create message'); + }, + }); + + const sendMessage = useMutation({ + mutationFn: async (messageId: string) => { + const response = await api.post(`/messages/broadcast-messages/${messageId}/send/`); + return response.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['broadcast-messages'] }); + toast.success('Message sent successfully!'); + resetForm(); + setActiveTab('sent'); + }, + onError: (error: any) => { + toast.error(error.response?.data?.error || 'Failed to send message'); + }, + }); + + // Handlers + const handleRoleToggle = (role: string) => { + setSelectedRoles((prev) => + prev.includes(role) ? prev.filter((r) => r !== role) : [...prev, role] + ); + }; + + const handleAddUser = (user: RecipientOption) => { + if (!selectedUsers.find(u => u.id === user.id)) { + setSelectedUsers((prev) => [...prev, user]); + } + // Clear search and close dropdown for tag-style input + setRecipientSearchTerm(''); + setIsRecipientDropdownOpen(false); + }; + + const handleRemoveUser = (userId: string) => { + setSelectedUsers((prev) => prev.filter((u) => u.id !== userId)); + }; + + const handleDropdownScroll = useCallback((e: React.UIEvent) => { + const target = e.target as HTMLDivElement; + const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight; + + // Load more when within 50px of bottom + if (scrollBottom < 50) { + setVisibleRecipientCount((prev) => prev + 20); + } + }, []); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + // Validation + if (!subject.trim()) { + toast.error('Subject is required'); + return; + } + if (!body.trim()) { + toast.error('Message body is required'); + return; + } + if (selectedRoles.length === 0 && selectedUsers.length === 0) { + toast.error('Please select at least one recipient'); + return; + } + + createMessage.mutate({ + subject, + body, + target_roles: selectedRoles, + target_users: selectedUsers.map(u => u.id), + delivery_method: deliveryMethod, + }); + }; + + const resetForm = () => { + setSubject(''); + setBody(''); + setSelectedRoles([]); + setSelectedUsers([]); + setDeliveryMethod('IN_APP'); + setRecipientSearchTerm(''); + setIsRecipientDropdownOpen(false); + setVisibleRecipientCount(20); + }; + + // Computed + const roleOptions = [ + { value: 'owner', label: 'All Owners', icon: Users }, + { value: 'manager', label: 'All Managers', icon: Users }, + { value: 'staff', label: 'All Staff', icon: Users }, + { value: 'customer', label: 'All Customers', icon: Users }, + ]; + + const deliveryMethodOptions = [ + { value: 'IN_APP' as const, label: 'In-App Only', icon: Bell }, + { value: 'EMAIL' as const, label: 'Email Only', icon: Mail }, + { value: 'SMS' as const, label: 'SMS Only', icon: Smartphone }, + { value: 'ALL' as const, label: 'All Channels', icon: MessageSquare }, + ]; + + const filteredMessages = useMemo(() => { + let filtered = messages; + + // Status filter + if (statusFilter !== 'ALL') { + filtered = filtered.filter((msg) => msg.status === statusFilter); + } + + // Search filter + if (searchTerm) { + const term = searchTerm.toLowerCase(); + filtered = filtered.filter( + (msg) => + msg.subject.toLowerCase().includes(term) || + msg.body.toLowerCase().includes(term) || + msg.created_by_name.toLowerCase().includes(term) + ); + } + + return filtered; + }, [messages, statusFilter, searchTerm]); + + const filteredRecipients = useMemo(() => { + if (!recipientOptions) return []; + + // Filter out already selected users + const selectedIds = selectedUsers.map(u => u.id); + let filtered = recipientOptions.users.filter(u => !selectedIds.includes(u.id)); + + // Apply search filter + if (recipientSearchTerm) { + const term = recipientSearchTerm.toLowerCase(); + filtered = filtered.filter( + (user) => + user.name.toLowerCase().includes(term) || + user.email.toLowerCase().includes(term) + ); + } + + return filtered; + }, [recipientOptions, recipientSearchTerm, selectedUsers]); + + const recipientCount = useMemo(() => { + let count = 0; + + // Count users from selected roles + if (recipientOptions) { + selectedRoles.forEach((role) => { + count += recipientOptions.users.filter((u) => u.role === role).length; + }); + } + + // Add individually selected users (avoid double counting) + const roleUserIds = recipientOptions?.users + .filter((u) => selectedRoles.includes(u.role)) + .map((u) => u.id) || []; + + count += selectedUsers.filter((u) => !roleUserIds.includes(u.id)).length; + + return count; + }, [selectedRoles, selectedUsers, recipientOptions]); + + const getDeliveryMethodIcon = (method: string) => { + switch (method) { + case 'IN_APP': return ; + case 'EMAIL': return ; + case 'SMS': return ; + case 'ALL': return ; + default: return ; + } + }; + + const getStatusBadge = (status: string) => { + switch (status) { + case 'SENT': + return ( + + + Sent + + ); + case 'SENDING': + return ( + + + Sending + + ); + case 'FAILED': + return ( + + + Failed + + ); + default: + return ( + + + Draft + + ); + } + }; + + const formatDate = (dateStr: string | null) => { + if (!dateStr) return 'Not sent'; + return new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + }).format(new Date(dateStr)); + }; + + const getTargetDescription = (message: BroadcastMessage) => { + const parts: string[] = []; + + if (message.target_roles.length > 0) { + const roleLabels = message.target_roles.map((role) => { + const option = roleOptions.find((opt) => opt.value === role); + return option?.label || role; + }); + parts.push(...roleLabels); + } + + if (message.target_users.length > 0) { + parts.push(`${message.target_users.length} individual user(s)`); + } + + return parts.join(', '); + }; + + return ( +
+ {/* Header */} +
+
+

Broadcast Messages

+

+ Send messages to staff and customers +

+
+
+ + {/* Tabs */} +
+ +
+ + {/* Compose Tab */} + {activeTab === 'compose' && ( +
+
+ {/* Subject */} +
+ + setSubject(e.target.value)} + className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-transparent dark:bg-gray-700 dark:text-white" + placeholder="Enter message subject..." + required + /> +
+ + {/* Body */} +
+ +