Initial commit: SmoothSchedule multi-tenant scheduling platform

This commit includes:
- Django backend with multi-tenancy (django-tenants)
- React + TypeScript frontend with Vite
- Platform administration API with role-based access control
- Authentication system with token-based auth
- Quick login dev tools for testing different user roles
- CORS and CSRF configuration for local development
- Docker development environment setup

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
poduck
2025-11-27 01:43:20 -05:00
commit 2e111364a2
567 changed files with 96410 additions and 0 deletions

View File

@@ -0,0 +1,208 @@
/**
* WebSocket hook for real-time user notifications.
* Connects to the backend WebSocket and updates React Query cache.
*/
import { useEffect, useRef, useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { getCookie } from '../utils/cookies';
import { UserEmail } from '../api/profile';
interface WebSocketMessage {
type: 'connection_established' | 'email_verified' | 'profile_updated' | 'pong';
email_id?: number;
email?: string;
user_id?: string;
message?: string;
fields?: string[];
}
interface UseUserNotificationsOptions {
enabled?: boolean;
onConnected?: () => void;
onDisconnected?: () => void;
onError?: (error: Event) => void;
onEmailVerified?: (emailId: number, email: string) => void;
}
/**
* Hook for real-time user notifications via WebSocket.
*/
export function useUserNotifications(options: UseUserNotificationsOptions = {}) {
const { enabled = true, onConnected, onDisconnected, onError, onEmailVerified } = options;
const queryClient = useQueryClient();
// Use refs for callbacks to avoid recreating connect function
const onConnectedRef = useRef(onConnected);
const onDisconnectedRef = useRef(onDisconnected);
const onErrorRef = useRef(onError);
const onEmailVerifiedRef = useRef(onEmailVerified);
// Update refs when callbacks change
useEffect(() => {
onConnectedRef.current = onConnected;
onDisconnectedRef.current = onDisconnected;
onErrorRef.current = onError;
onEmailVerifiedRef.current = onEmailVerified;
}, [onConnected, onDisconnected, onError, onEmailVerified]);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const reconnectAttemptsRef = useRef(0);
const isConnectingRef = useRef(false);
const maxReconnectAttempts = 5;
const updateEmailQueryCache = useCallback((emailId: number) => {
// Update the userEmails query cache to mark the email as verified
queryClient.setQueryData<UserEmail[]>(['userEmails'], (old) => {
if (!old) return old;
return old.map((email) =>
email.id === emailId ? { ...email, verified: true } : email
);
});
}, [queryClient]);
const disconnect = useCallback(() => {
// Clear reconnect timeout
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
// Clear ping interval
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
// Close WebSocket
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
isConnectingRef.current = false;
}, []);
const connect = useCallback(() => {
// Prevent multiple simultaneous connection attempts
if (isConnectingRef.current || wsRef.current?.readyState === WebSocket.OPEN) {
return;
}
const token = getCookie('access_token');
if (!token) {
console.log('UserNotifications WebSocket: Missing token, skipping connection');
return;
}
isConnectingRef.current = true;
// Close existing connection if any
if (wsRef.current) {
wsRef.current.close();
}
// Determine WebSocket host - use api subdomain for WebSocket
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsHost = `api.lvh.me:8000`; // In production, this would come from config
const url = `${wsProtocol}//${wsHost}/ws/user/?token=${token}`;
console.log('UserNotifications WebSocket: Connecting');
const ws = new WebSocket(url);
ws.onopen = () => {
console.log('UserNotifications WebSocket: Connected');
reconnectAttemptsRef.current = 0;
isConnectingRef.current = false;
onConnectedRef.current?.();
// Start ping interval to keep connection alive
pingIntervalRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, 30000); // Ping every 30 seconds
};
ws.onmessage = (event) => {
try {
const message: WebSocketMessage = JSON.parse(event.data);
switch (message.type) {
case 'connection_established':
console.log('UserNotifications WebSocket: Connection confirmed -', message.message);
break;
case 'pong':
// Heartbeat response, ignore
break;
case 'email_verified':
console.log('UserNotifications WebSocket: Email verified', message.email);
if (message.email_id) {
updateEmailQueryCache(message.email_id);
onEmailVerifiedRef.current?.(message.email_id, message.email || '');
}
break;
case 'profile_updated':
console.log('UserNotifications WebSocket: Profile updated', message.fields);
// Invalidate profile queries to refresh data
queryClient.invalidateQueries({ queryKey: ['currentUser'] });
break;
default:
console.log('UserNotifications WebSocket: Unknown message type', message);
}
} catch (err) {
console.error('UserNotifications WebSocket: Failed to parse message', err);
}
};
ws.onerror = (error) => {
console.error('UserNotifications WebSocket: Error', error);
isConnectingRef.current = false;
onErrorRef.current?.(error);
};
ws.onclose = (event) => {
console.log('UserNotifications WebSocket: Disconnected', event.code, event.reason);
isConnectingRef.current = false;
onDisconnectedRef.current?.();
// Clear ping interval
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
// Only attempt reconnection if this wasn't a deliberate close
// Code 1000 = normal closure, 1001 = going away (page unload)
if (event.code !== 1000 && event.code !== 1001 && reconnectAttemptsRef.current < maxReconnectAttempts) {
const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000);
console.log(`UserNotifications WebSocket: Reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1})`);
reconnectTimeoutRef.current = setTimeout(() => {
reconnectAttemptsRef.current++;
connect();
}, delay);
}
};
wsRef.current = ws;
}, [queryClient, updateEmailQueryCache]);
useEffect(() => {
if (enabled) {
connect();
}
return () => {
disconnect();
};
}, [enabled, connect, disconnect]);
return {
isConnected: wsRef.current?.readyState === WebSocket.OPEN,
reconnect: connect,
disconnect,
};
}