From 253080259b6b12beb4095efead3865efc67141a8 Mon Sep 17 00:00:00 2001 From: Ashrit-Anala Date: Sun, 4 Jan 2026 22:04:00 -0500 Subject: [PATCH 1/2] 2026 dev tasks setup --- backend/app.ts | 9 +- backend/config/socket.ts | 129 +++++++++++ backend/config/stripe.ts | 9 +- backend/controllers/eventsController.ts | 121 ++++++++++ backend/controllers/messagesController.ts | 96 ++++++++ backend/controllers/stripeController.ts | 78 +++++++ backend/package-lock.json | 211 +++++++++++++++++- backend/package.json | 3 +- backend/prisma/schema.prisma | 81 +++++++ backend/server.ts | 4 + backend/services/EmailService.ts | 3 - backend/services/eventReminderService.ts | 56 +++++ backend/services/stripeService.ts | 51 +++++ backend/types/socket.ts | 159 +++++++++++++ frontend/package-lock.json | 120 +++++++++- frontend/package.json | 5 +- frontend/src/components/StripePaymentForm.tsx | 40 ++++ frontend/src/config/socket.ts | 124 ++++++++++ frontend/src/config/stripe.ts | 5 + frontend/src/hooks/mutations/useEvents.ts | 97 ++++++++ frontend/src/hooks/mutations/useMessages.ts | 65 ++++++ frontend/src/hooks/mutations/useStripe.ts | 38 ++++ frontend/src/hooks/queries/useEvents.ts | 48 ++++ frontend/src/hooks/queries/useMessages.ts | 51 +++++ frontend/src/hooks/queries/useStripe.ts | 32 +++ frontend/src/hooks/useSocket.ts | 175 +++++++++++++++ .../EventManagementPage.tsx | 43 ++++ .../EventsPage/EventsPage.tsx | 31 +++ .../SubscriptionPage/SubscriptionPage.tsx | 35 +++ frontend/src/types/socket.ts | 161 +++++++++++++ 30 files changed, 2062 insertions(+), 18 deletions(-) create mode 100644 backend/config/socket.ts create mode 100644 backend/controllers/eventsController.ts create mode 100644 backend/controllers/messagesController.ts create mode 100644 backend/controllers/stripeController.ts create mode 100644 backend/services/eventReminderService.ts create mode 100644 backend/services/stripeService.ts create mode 100644 backend/types/socket.ts create mode 100644 frontend/src/components/StripePaymentForm.tsx create mode 100644 frontend/src/config/socket.ts create mode 100644 frontend/src/config/stripe.ts create mode 100644 frontend/src/hooks/mutations/useEvents.ts create mode 100644 frontend/src/hooks/mutations/useMessages.ts create mode 100644 frontend/src/hooks/mutations/useStripe.ts create mode 100644 frontend/src/hooks/queries/useEvents.ts create mode 100644 frontend/src/hooks/queries/useMessages.ts create mode 100644 frontend/src/hooks/queries/useStripe.ts create mode 100644 frontend/src/hooks/useSocket.ts create mode 100644 frontend/src/pages/Admin/EventManagementPage/EventManagementPage.tsx create mode 100644 frontend/src/pages/OrganizationView/EventsPage/EventsPage.tsx create mode 100644 frontend/src/pages/OrganizationView/SubscriptionPage/SubscriptionPage.tsx create mode 100644 frontend/src/types/socket.ts diff --git a/backend/app.ts b/backend/app.ts index 6c23654..31cd018 100644 --- a/backend/app.ts +++ b/backend/app.ts @@ -17,8 +17,7 @@ import contactRoutes from './routes/contactRoutes.js'; import fileUploadRoutes from './routes/fileUploadRoutes.js'; import pageContentRoutes from './routes/pageContentRoutes.js'; import mapRoutes from './routes/mapRoutes.js'; -import { prisma } from './config/prisma.js'; -import { clerkClient, clerkMiddleware } from '@clerk/express'; +import { clerkMiddleware } from '@clerk/express'; import { connectRedis } from './config/redis.js'; import { warmCache } from './utils/cacheWarmer.js'; @@ -75,4 +74,10 @@ app.use('/api/files', fileUploadRoutes); app.use('/api/page-content', pageContentRoutes); app.use('/api/map', mapRoutes); +// Add new route imports and register new routes + +// For Stripe webhook (MUST be before express.json() middleware): +// Add this line BEFORE app.use(express.json()): +// app.use('/api/stripe/webhook', express.raw({ type: 'application/json' }), stripeRoutes); + export default app; diff --git a/backend/config/socket.ts b/backend/config/socket.ts new file mode 100644 index 0000000..f746684 --- /dev/null +++ b/backend/config/socket.ts @@ -0,0 +1,129 @@ +import { Server as SocketIOServer } from 'socket.io'; +import { Server as HTTPServer } from 'http'; +import { verifyToken } from '@clerk/express'; + +let io: SocketIOServer | null = null; + +/** + * Call this function in server.ts after creating the HTTP server + */ +export const initializeSocket = (httpServer: HTTPServer): SocketIOServer => { + io = new SocketIOServer(httpServer, { + cors: { + origin: process.env.FRONTEND_URL || 'http://localhost:5173', + methods: ['GET', 'POST'], + credentials: true, + }, + }); + + io.use(async (socket, next) => { + try { + const token = socket.handshake.auth.token; + + if (!token) { + return next(new Error('Authentication token required')); + } + + // Verify Clerk token + const verified = await verifyToken(token, { + secretKey: process.env.CLERK_SECRET_KEY!, + clockSkewInMs: 5000, + }); + + if (!verified) { + return next(new Error('Invalid authentication token')); + } + + socket.data.userId = verified.sub; + socket.data.sessionId = verified.sid; + + next(); + } catch (error) { + console.error('Socket authentication error:', error); + next(new Error('Authentication failed')); + } + }); + + io.on('connection', (socket) => { + console.log(`User connected: ${socket.data.userId} (Socket: ${socket.id})`); + + socket.on('disconnect', () => { + console.log(`User disconnected: ${socket.data.userId} (Socket: ${socket.id})`); + }); + + // Uncomment and implement these handlers as needed + + /* + // Join a conversation room + socket.on('join_conversation', (data: { conversationId: string }) => { + socket.join(`conversation:${data.conversationId}`); + console.log(`User ${socket.data.userId} joined conversation ${data.conversationId}`); + }); + + // Leave a conversation room + socket.on('leave_conversation', (data: { conversationId: string }) => { + socket.leave(`conversation:${data.conversationId}`); + console.log(`User ${socket.data.userId} left conversation ${data.conversationId}`); + }); + + // Send message event (you'll also save to DB in the controller) + socket.on('send_message', async (data: { conversationId: string; content: string }) => { + try { + // Verify user is part of this conversation (check DB) + // Save message to database via controller + // Then emit to all users in the conversation room + io?.to(`conversation:${data.conversationId}`).emit('new_message', { + conversationId: data.conversationId, + message: { + // message data from DB + } + }); + } catch (error) { + socket.emit('error', { message: 'Failed to send message' }); + } + }); + + // Mark messages as read + socket.on('mark_as_read', async (data: { conversationId: string }) => { + try { + // Update DB to mark messages as read + // Emit to conversation room + io?.to(`conversation:${data.conversationId}`).emit('messages_read', { + conversationId: data.conversationId, + userId: socket.data.userId, + readAt: new Date().toISOString() + }); + } catch (error) { + socket.emit('error', { message: 'Failed to mark as read' }); + } + }); + */ + }); + + return io; +}; + +export const getSocketIO = (): SocketIOServer => { + if (!io) { + throw new Error('Socket.IO not initialized. Call initializeSocket() first.'); + } + return io; +}; + +export const emitToConversation = ( + conversationId: string, + event: string, + data: any +): void => { + if (io) { + io.to(`conversation:${conversationId}`).emit(event, data); + } +}; + + +export const emitToUser = (userId: string, event: string, data: any): void => { + // Implement userId -> socketId mapping + // You can store this mapping when users connect + // For now, this is a placeholder + console.warn('emitToUser not fully implemented. Needs userId -> socketId mapping.'); +}; diff --git a/backend/config/stripe.ts b/backend/config/stripe.ts index 7466c4f..a15611d 100644 --- a/backend/config/stripe.ts +++ b/backend/config/stripe.ts @@ -1 +1,8 @@ -// Stripe config later +import Stripe from 'stripe'; + +export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || '', { + apiVersion: '2025-02-24.acacia', + typescript: true, +}); + +export const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET || ''; diff --git a/backend/controllers/eventsController.ts b/backend/controllers/eventsController.ts new file mode 100644 index 0000000..cef84b0 --- /dev/null +++ b/backend/controllers/eventsController.ts @@ -0,0 +1,121 @@ +import { Request, Response } from 'express'; +// import { prisma } from '../config/prisma.js'; + +export const eventsController = { + /** + * Get all events + */ + getAll: async (req: Request, res: Response) => { + try { + // Get filters from query params (status, upcoming) + // Fetch events with RSVP counts + res.status(501).json({ error: 'Not implemented' }); + } catch (error: any) { + console.error('Error getting events:', error); + res.status(500).json({ error: error.message }); + } + }, + + /** + * Get single event + */ + getById: async (req: Request, res: Response) => { + try { + // Get event ID from params + // Fetch event with RSVPs and creator info + res.status(501).json({ error: 'Not implemented' }); + } catch (error: any) { + console.error('Error getting event:', error); + res.status(500).json({ error: error.message }); + } + }, + + /** + * Create event (Admin only) + */ + create: async (req: Request, res: Response) => { + try { + // Verify user is admin + // Get event details from request body + // Create event in database + res.status(501).json({ error: 'Not implemented' }); + } catch (error: any) { + console.error('Error creating event:', error); + res.status(500).json({ error: error.message }); + } + }, + + /** + * Update event (Admin only) + */ + update: async (req: Request, res: Response) => { + try { + // Verify user is admin + // Get event ID from params and updates from body + // Update event in database + res.status(501).json({ error: 'Not implemented' }); + } catch (error: any) { + console.error('Error updating event:', error); + res.status(500).json({ error: error.message }); + } + }, + + /** + * Publish event (Admin only) + */ + publish: async (req: Request, res: Response) => { + try { + // Verify user is admin + // Update event status to PUBLISHED + // Send notification email to all organizations + res.status(501).json({ error: 'Not implemented' }); + } catch (error: any) { + console.error('Error publishing event:', error); + res.status(500).json({ error: error.message }); + } + }, + + /** + * Delete event (Admin only) + */ + delete: async (req: Request, res: Response) => { + try { + // Verify user is admin + // Delete event from database + res.status(501).json({ error: 'Not implemented' }); + } catch (error: any) { + console.error('Error deleting event:', error); + res.status(500).json({ error: error.message }); + } + }, + + /** + * RSVP to event + */ + rsvp: async (req: Request, res: Response) => { + try { + // Get eventId from params + // Get RSVP details from request body + // Check if event is full + // Create or update RSVP + res.status(501).json({ error: 'Not implemented' }); + } catch (error: any) { + console.error('Error creating RSVP:', error); + res.status(500).json({ error: error.message }); + } + }, + + /** + * Get user's RSVPs + */ + getMyRSVPs: async (req: Request, res: Response) => { + try { + // Get organizationId from req.user + // Fetch all RSVPs for organization + res.status(501).json({ error: 'Not implemented' }); + } catch (error: any) { + console.error('Error getting RSVPs:', error); + res.status(500).json({ error: error.message }); + } + }, +}; diff --git a/backend/controllers/messagesController.ts b/backend/controllers/messagesController.ts new file mode 100644 index 0000000..897b484 --- /dev/null +++ b/backend/controllers/messagesController.ts @@ -0,0 +1,96 @@ +import { Request, Response } from 'express'; +// import { prisma } from '../config/prisma.js'; + +export const messagesController = { + /** + * Get all conversations for current user + */ + getConversations: async (req: Request, res: Response) => { + try { + // Get userId and userRole from req.user + // Query conversations based on role (admin vs organization) + // Include unread message count + res.status(501).json({ error: 'Not implemented' }); + } catch (error: any) { + console.error('Error getting conversations:', error); + res.status(500).json({ error: error.message }); + } + }, + + /** + * Get messages in a conversation + */ + getMessages: async (req: Request, res: Response) => { + try { + // Get conversationId from params + // Verify user is part of conversation + // Fetch messages with sender info + // Mark messages as read + res.status(501).json({ error: 'Not implemented' }); + } catch (error: any) { + console.error('Error getting messages:', error); + res.status(500).json({ error: error.message }); + } + }, + + /** + * Create a new conversation + */ + createConversation: async (req: Request, res: Response) => { + try { + // Get recipientId, type, subject, initialMessage from request body + // Create conversation with correct participant relationships + // Create initial message if provided + res.status(501).json({ error: 'Not implemented' }); + } catch (error: any) { + console.error('Error creating conversation:', error); + res.status(500).json({ error: error.message }); + } + }, + + /** + * Send a message + */ + sendMessage: async (req: Request, res: Response) => { + try { + // Get conversationId, content, attachments from request body + // Verify user is part of conversation + // Create message with correct sender info + // Update conversation lastMessageAt + // Send email notification to recipient + // Emit Socket.io event for real-time update (optional) + res.status(501).json({ error: 'Not implemented' }); + } catch (error: any) { + console.error('Error sending message:', error); + res.status(500).json({ error: error.message }); + } + }, + + /** + * Mark messages as read + */ + markAsRead: async (req: Request, res: Response) => { + try { + // Get conversationId from params + // Mark unread messages as read for current user + res.status(501).json({ error: 'Not implemented' }); + } catch (error: any) { + console.error('Error marking messages as read:', error); + res.status(500).json({ error: error.message }); + } + }, + + /** + * Get unread message count + */ + getUnreadCount: async (req: Request, res: Response) => { + try { + // Get userId and userRole from req.user + // Count unread messages for user + res.status(501).json({ error: 'Not implemented' }); + } catch (error: any) { + console.error('Error getting unread count:', error); + res.status(500).json({ error: error.message }); + } + }, +}; diff --git a/backend/controllers/stripeController.ts b/backend/controllers/stripeController.ts new file mode 100644 index 0000000..a9fa0b2 --- /dev/null +++ b/backend/controllers/stripeController.ts @@ -0,0 +1,78 @@ +import { Request, Response } from 'express'; +// import { StripeService } from '../services/stripeService.js'; +// import { stripe } from '../config/stripe.js'; +// import { STRIPE_WEBHOOK_SECRET } from '../config/stripe.js'; + +export const stripeController = { + /** + * Create subscription + */ + createSubscription: async (req: Request, res: Response) => { + try { + // Get priceId from request body + // Get organizationId from req.user + // Call StripeService.createSubscription + // Return subscription details with clientSecret + res.status(501).json({ error: 'Not implemented' }); + } catch (error: any) { + console.error('Error creating subscription:', error); + res.status(500).json({ error: error.message }); + } + }, + + /** + * Get subscription status + */ + getSubscription: async (req: Request, res: Response) => { + try { + // Get organizationId from req.user + // Call StripeService.getSubscription + res.status(501).json({ error: 'Not implemented' }); + } catch (error: any) { + console.error('Error getting subscription:', error); + res.status(500).json({ error: error.message }); + } + }, + + /** + * Cancel subscription + */ + cancelSubscription: async (req: Request, res: Response) => { + try { + // Get immediate flag from request body + // Get organizationId from req.user + // Call StripeService.cancelSubscription + res.status(501).json({ error: 'Not implemented' }); + } catch (error: any) { + console.error('Error canceling subscription:', error); + res.status(500).json({ error: error.message }); + } + }, + + /** + * Stripe webhook handler + */ + handleWebhook: async (req: Request, res: Response) => { + try { + // Verify webhook signature + // Call StripeService.handleWebhook + res.status(501).json({ error: 'Not implemented' }); + } catch (error: any) { + console.error('Webhook error:', error); + res.status(400).json({ error: error.message }); + } + }, + + /** + * Get available pricing plans + */ + getPrices: async (req: Request, res: Response) => { + try { + // Fetch active prices from Stripe + res.status(501).json({ error: 'Not implemented' }); + } catch (error: any) { + console.error('Error getting prices:', error); + res.status(500).json({ error: error.message }); + } + }, +}; diff --git a/backend/package-lock.json b/backend/package-lock.json index c980faf..7355b29 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -26,7 +26,8 @@ "node-cron": "^4.2.1", "pg": "^8.12.0", "redis": "^5.10.0", - "stripe": "^17.3.1", + "socket.io": "^4.8.3", + "stripe": "^17.7.0", "uuid": "^10.0.0" }, "devDependencies": { @@ -3975,6 +3976,12 @@ "node": ">=18.0.0" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@stablelib/base64": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", @@ -4100,7 +4107,6 @@ "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -5077,6 +5083,15 @@ ], "license": "MIT" }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.8.9", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.9.tgz", @@ -6061,6 +6076,67 @@ "node": ">= 0.8" } }, + "node_modules/engine.io": { + "version": "6.6.5", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.5.tgz", + "integrity": "sha512-2RZdgEbXmp5+dVbRm0P7HQUImZpICccJy7rN7Tv+SFa55pH+lxnuw6/K1ZxxBfHoYpSkHLAO92oa8O4SwFXA2A==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -10101,6 +10177,116 @@ "node": ">=8" } }, + "node_modules/socket.io": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", + "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz", + "integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==", + "license": "MIT", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.18.3" + } + }, + "node_modules/socket.io-adapter/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-adapter/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/socket.io-parser": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz", + "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-parser/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/socket.io/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -11105,6 +11291,27 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xml2js": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", diff --git a/backend/package.json b/backend/package.json index 2ea621f..7feaabc 100644 --- a/backend/package.json +++ b/backend/package.json @@ -32,7 +32,8 @@ "node-cron": "^4.2.1", "pg": "^8.12.0", "redis": "^5.10.0", - "stripe": "^17.3.1", + "socket.io": "^4.8.3", + "stripe": "^17.7.0", "uuid": "^10.0.0" }, "devDependencies": { diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 39e56b7..1437f62 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -284,3 +284,84 @@ enum NotificationType { ALERT SURVEY } + +// ==================== ADD STRIPE MODELS ==================== +// Add these enums: +// - SubscriptionStatus: ACTIVE, PAST_DUE, CANCELED, INCOMPLETE, TRIALING +// - PaymentStatus: PENDING, SUCCEEDED, FAILED, REFUNDED +// +// Add Subscription model with fields: +// - organizationId (unique, relation to Organization) +// - stripeCustomerId, stripeSubscriptionId, stripePriceId +// - status, currentPeriodStart, currentPeriodEnd, cancelAtPeriodEnd +// - relations: payments[], invoices[] +// +// Add Payment model with fields: +// - subscriptionId (relation to Subscription) +// - stripePaymentIntentId, stripeInvoiceId +// - amount, currency, status, paymentMethod, receiptUrl +// +// Add Invoice model with fields: +// - subscriptionId (relation to Subscription) +// - stripeInvoiceId, amount, currency, status +// - invoicePdf, hostedInvoiceUrl, periodStart, periodEnd +// +// Update Organization model to add: +// - subscription Subscription? + +// ==================== ADD MESSAGING MODELS ==================== +// Add these enums: +// - MessageStatus: SENT, DELIVERED, READ +// - ConversationType: ORG_TO_ORG, ORG_TO_ADMIN, ADMIN_TO_ORG +// +// Add Conversation model with fields: +// - type (ConversationType) +// - organizationId?, adminId?, otherOrganizationId? (for different conversation types) +// - subject, lastMessageAt +// - relations: messages[] +// - indexes on organizationId, adminId, otherOrganizationId +// +// Add Message model with fields: +// - conversationId (relation to Conversation) +// - senderType, senderOrganizationId?, senderAdminId? +// - content (Text), attachments (String[]) +// - status, readAt +// - indexes on conversationId, senderOrganizationId, senderAdminId +// +// Update Organization model to add: +// - conversations Conversation[] +// - sentMessages Message[] +// - otherConversations Conversation[] @relation("OrgToOrgConversations") +// +// Update AdminUser model to add: +// - conversations Conversation[] +// - sentMessages Message[] + +// ==================== ADD EVENTS & RSVP MODELS ==================== +// Add these enums: +// - EventStatus: DRAFT, PUBLISHED, CANCELLED, COMPLETED +// - RSVPStatus: GOING, NOT_GOING, MAYBE +// +// Add Event model with fields: +// - title, description (Text), location, zoomLink, meetingPassword +// - startTime, endTime, timezone (default: "America/Chicago") +// - status, maxAttendees (optional Int) +// - createdByAdminId (relation to AdminUser) +// - tags (String[]), attachments (String[]) +// - relations: rsvps[] +// - indexes on createdByAdminId, startTime, status +// +// Add EventRSVP model with fields: +// - eventId (relation to Event), organizationId (relation to Organization) +// - status (RSVPStatus) +// - attendeeName?, attendeeEmail?, attendeePhone? +// - reminder3DaysSent, reminder1DaySent, reminder1HourSent (all Boolean, default: false) +// - notes (Text) +// - unique constraint on [eventId, organizationId] +// - indexes on eventId, organizationId, status +// +// Update Organization model to add: +// - eventRSVPs EventRSVP[] +// +// Update AdminUser model to add: +// - createdEvents Event[] diff --git a/backend/server.ts b/backend/server.ts index 4d1023e..a3e4cb9 100644 --- a/backend/server.ts +++ b/backend/server.ts @@ -2,6 +2,7 @@ import dotenv from 'dotenv'; import app from './app.js'; import { clerkMiddleware } from '@clerk/express'; import { startScheduledEmailService } from './services/scheduledEmailService.js'; +// import { initializeEventReminders } from './services/eventReminderService.js'; dotenv.config(); const PORT = 8000; @@ -13,6 +14,9 @@ app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); startScheduledEmailService(); + + // Initialize event reminder cron jobs + // initializeEventReminders(); }); export default app; diff --git a/backend/services/EmailService.ts b/backend/services/EmailService.ts index 83ae994..a852045 100644 --- a/backend/services/EmailService.ts +++ b/backend/services/EmailService.ts @@ -1,6 +1,3 @@ -// AWS SES email service -// TODO: Implement SES integration - interface AlertEmailParams { to: string; organizationName: string; diff --git a/backend/services/eventReminderService.ts b/backend/services/eventReminderService.ts new file mode 100644 index 0000000..5cc45ee --- /dev/null +++ b/backend/services/eventReminderService.ts @@ -0,0 +1,56 @@ +import cron from 'node-cron'; +import { prisma } from '../config/prisma.js'; +// Import email sending function + +/** + * Check and send 3-day reminders + */ +async function send3DayReminders() { + // Query RSVPs for events starting in 3 days + // Filter for status='GOING' and reminder3DaysSent=false + // Send email to each RSVP + // Update reminder3DaysSent flag + console.log('Implement 3-day reminder logic'); +} + +/** + * Check and send 1-day reminders + */ +async function send1DayReminders() { + // Query RSVPs for events starting in 1 day + // Filter for status='GOING' and reminder1DaySent=false + // Send email to each RSVP + // Update reminder1DaySent flag + console.log('Implement 1-day reminder logic'); +} + +/** + * Check and send 1-hour reminders + */ +async function send1HourReminders() { + // Query RSVPs for events starting in 1 hour + // Filter for status='GOING' and reminder1HourSent=false + // Send email to each RSVP + // Update reminder1HourSent flag + console.log('Implement 1-hour reminder logic'); +} + +/** + * Initialize cron jobs for event reminders + */ +export function initializeEventReminders() { + // Run every day at 9:00 AM for 3-day and 1-day reminders + cron.schedule('0 9 * * *', async () => { + console.log('Running event reminder checks...'); + await send3DayReminders(); + await send1DayReminders(); + }); + + // Run every hour for 1-hour reminders + cron.schedule('0 * * * *', async () => { + console.log('Running 1-hour event reminder'); + await send1HourReminders(); + }); + + console.log('Event reminder cron jobs initialized'); +} diff --git a/backend/services/stripeService.ts b/backend/services/stripeService.ts new file mode 100644 index 0000000..86cf642 --- /dev/null +++ b/backend/services/stripeService.ts @@ -0,0 +1,51 @@ +import { stripe } from '../config/stripe.js'; +import { prisma } from '../config/prisma.js'; +import Stripe from 'stripe'; + +export class StripeService { + /** + * Create a Stripe customer for an organization + */ + static async createCustomer(organizationId: string) { + // Implement customer creation + throw new Error('Not implemented'); + } + + /** + * Create or update subscription + */ + static async createSubscription(organizationId: string, priceId: string) { + // Implement subscription creation + // Handle payment intent and client secret + throw new Error('Not implemented'); + } + + /** + * Cancel subscription + */ + static async cancelSubscription(organizationId: string, immediate = false) { + // Implement subscription cancellation + throw new Error('Not implemented'); + } + + /** + * Get subscription status + */ + static async getSubscription(organizationId: string) { + // Implement get subscription with payments and invoices + throw new Error('Not implemented'); + } + + /** + * Handle webhook events + */ + static async handleWebhook(event: Stripe.Event) { + // Handle different webhook event types: + // - customer.subscription.updated + // - customer.subscription.deleted + // - invoice.payment_succeeded + // - invoice.payment_failed + // - payment_intent.succeeded + throw new Error('Not implemented'); + } +} diff --git a/backend/types/socket.ts b/backend/types/socket.ts new file mode 100644 index 0000000..80cab09 --- /dev/null +++ b/backend/types/socket.ts @@ -0,0 +1,159 @@ +/** + * Socket.IO Event Type Definitions + * These interfaces define the structure of events exchanged between client and server + */ + +// ========================================== +// CLIENT TO SERVER EVENTS +// ========================================== + +export interface ClientToServerEvents { + // Join a conversation room to receive real-time updates + join_conversation: (data: JoinConversationPayload) => void; + + // Leave a conversation room + leave_conversation: (data: LeaveConversationPayload) => void; + + // Send a message (note: also saved via REST API, socket for real-time delivery) + send_message: (data: SendMessagePayload) => void; + + // Mark messages in a conversation as read + mark_as_read: (data: MarkAsReadPayload) => void; + + // Typing indicator (optional feature) + typing: (data: TypingPayload) => void; +} + +// ========================================== +// SERVER TO CLIENT EVENTS +// ========================================== + +export interface ServerToClientEvents { + // New message received in a conversation + new_message: (data: NewMessageEvent) => void; + + // Messages marked as read by another user + messages_read: (data: MessagesReadEvent) => void; + + // Unread count updated for current user + unread_count_update: (data: UnreadCountEvent) => void; + + // User typing in a conversation (optional) + user_typing: (data: UserTypingEvent) => void; + + // Error event + error: (data: ErrorEvent) => void; + + // Connection status events + connect: () => void; + disconnect: () => void; +} + +// ========================================== +// EVENT PAYLOAD INTERFACES +// ========================================== + +// Client -> Server Payloads + +export interface JoinConversationPayload { + conversationId: string; +} + +export interface LeaveConversationPayload { + conversationId: string; +} + +export interface SendMessagePayload { + conversationId: string; + content: string; + attachments?: string[]; +} + +export interface MarkAsReadPayload { + conversationId: string; +} + +export interface TypingPayload { + conversationId: string; + isTyping: boolean; +} + +// Server -> Client Event Data + +export interface NewMessageEvent { + conversationId: string; + message: MessageData; +} + +export interface MessagesReadEvent { + conversationId: string; + userId: string; + readAt: string; // ISO date string +} + +export interface UnreadCountEvent { + count: number; +} + +export interface UserTypingEvent { + conversationId: string; + userId: string; + isTyping: boolean; +} + +export interface ErrorEvent { + message: string; + code?: string; +} + +// ========================================== +// SHARED DATA STRUCTURES +// ========================================== + +export interface MessageData { + id: string; + conversationId: string; + senderType: 'ORG' | 'ADMIN'; + senderOrganizationId?: string; + senderAdminId?: string; + content: string; + attachments: string[]; + status: 'SENT' | 'DELIVERED' | 'READ'; + readAt?: string; + createdAt: string; + updatedAt: string; +} + +export interface ConversationData { + id: string; + type: 'ORG_TO_ORG' | 'ORG_TO_ADMIN' | 'ADMIN_TO_ORG'; + organizationId?: string; + adminId?: string; + otherOrganizationId?: string; + subject: string; + lastMessageAt: string; + unreadCount?: number; + createdAt: string; + updatedAt: string; +} + +export interface SocketData { + userId: string; + sessionId: string; +} + +import { Server, Socket } from 'socket.io'; + +export type TypedServer = Server< + ClientToServerEvents, + ServerToClientEvents, + {}, + SocketData +>; + +export type TypedSocket = Socket< + ClientToServerEvents, + ServerToClientEvents, + {}, + SocketData +>; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 464ad0e..e82941e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,8 @@ "version": "0.0.0", "dependencies": { "@clerk/clerk-react": "^5.51.0", + "@stripe/react-stripe-js": "^5.4.1", + "@stripe/stripe-js": "^8.6.0", "@tailwindcss/postcss": "^4.1.13", "@tanstack/react-query": "^5.90.15", "@tanstack/react-query-devtools": "^5.91.2", @@ -23,7 +25,8 @@ "react-icons": "^5.5.0", "react-loader-spinner": "^8.0.0", "react-quill-new": "^3.6.0", - "react-router-dom": "^7.9.3" + "react-router-dom": "^7.9.3", + "socket.io-client": "^4.8.3" }, "devDependencies": { "@eslint/js": "^9.36.0", @@ -2212,6 +2215,35 @@ "win32" ] }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, + "node_modules/@stripe/react-stripe-js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-5.4.1.tgz", + "integrity": "sha512-ipeYcAHa4EPmjwfv0lFE+YDVkOQ0TMKkFWamW+BqmnSkEln/hO8rmxGPPWcd9WjqABx6Ro8Xg4pAS7evCcR9cw==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "@stripe/stripe-js": ">=8.0.0 <9.0.0", + "react": ">=16.8.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@stripe/stripe-js": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-8.6.0.tgz", + "integrity": "sha512-EB0/GGgs4hfezzkiMkinlRgWtjz8fSdwVQhwYS7Sg/RQrSvuNOz+ssPjD+lAzqaYTCB0zlbrt0fcqVziLJrufQ==", + "license": "MIT", + "engines": { + "node": ">=12.16" + } + }, "node_modules/@tailwindcss/node": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.13.tgz", @@ -3605,7 +3637,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3729,6 +3760,28 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/engine.io-client": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz", + "integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.18.3", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", @@ -5284,7 +5337,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -5673,7 +5725,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -5793,7 +5844,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -5842,7 +5892,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6166,7 +6215,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -6308,7 +6356,6 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, "node_modules/react-loader-spinner": { @@ -6800,6 +6847,34 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz", + "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -7690,6 +7765,35 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/frontend/package.json b/frontend/package.json index 2d8c06d..ff3e40f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,8 @@ }, "dependencies": { "@clerk/clerk-react": "^5.51.0", + "@stripe/react-stripe-js": "^5.4.1", + "@stripe/stripe-js": "^8.6.0", "@tailwindcss/postcss": "^4.1.13", "@tanstack/react-query": "^5.90.15", "@tanstack/react-query-devtools": "^5.91.2", @@ -26,7 +28,8 @@ "react-icons": "^5.5.0", "react-loader-spinner": "^8.0.0", "react-quill-new": "^3.6.0", - "react-router-dom": "^7.9.3" + "react-router-dom": "^7.9.3", + "socket.io-client": "^4.8.3" }, "devDependencies": { "@eslint/js": "^9.36.0", diff --git a/frontend/src/components/StripePaymentForm.tsx b/frontend/src/components/StripePaymentForm.tsx new file mode 100644 index 0000000..4565458 --- /dev/null +++ b/frontend/src/components/StripePaymentForm.tsx @@ -0,0 +1,40 @@ +// import { useState } from 'react'; +// import { +// PaymentElement, +// useStripe, +// useElements, +// } from '@stripe/react-stripe-js'; + +// interface StripePaymentFormProps { +// clientSecret: string; +// onSuccess: () => void; +// } + +// export function StripePaymentForm({ clientSecret, onSuccess }: StripePaymentFormProps) { +// const stripe = useStripe(); +// const elements = useElements(); +// const [error, setError] = useState(null); +// const [processing, setProcessing] = useState(false); + + // const handleSubmit = async (e: React.FormEvent) => { + // e.preventDefault(); + + // Implement payment confirmation with Stripe + // Handle success/error states + // Call onSuccess when payment succeeds + + // setError('Not implemented'); + // }; + + // return ( + //
+ {/* Add PaymentElement from Stripe */} + {/* Add error display */} + {/* Add submit button with loading state */} + + {/*
+ Implement Stripe payment form +
+
+ ); +} */} diff --git a/frontend/src/config/socket.ts b/frontend/src/config/socket.ts new file mode 100644 index 0000000..1a66c3f --- /dev/null +++ b/frontend/src/config/socket.ts @@ -0,0 +1,124 @@ +import { io, Socket } from 'socket.io-client'; +import type { ClientToServerEvents, ServerToClientEvents } from '../types/socket'; + +export type TypedSocket = Socket; + +let socket: TypedSocket | null = null; + +export const initializeSocket = (token: string): TypedSocket => { + if (socket && socket.connected) { + return socket; + } + if (socket) { + socket.disconnect(); + } + + const SOCKET_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:8000'; + + socket = io(SOCKET_URL, { + auth: { + token, + }, + autoConnect: true, + reconnection: true, + reconnectionDelay: 1000, + reconnectionDelayMax: 5000, + reconnectionAttempts: 5, + }); + + socket.on('connect', () => { + console.log('Socket.IO connected:', socket?.id); + }); + + socket.on('disconnect', (reason) => { + console.log('Socket.IO disconnected:', reason); + }); + + socket.on('connect_error', (error) => { + console.error('Socket.IO connection error:', error.message); + }); + + socket.on('error', (data) => { + console.error('Socket.IO error:', data.message); + }); + + return socket; +}; + +/** + * Get the current socket instance + * Returns null if socket is not initialized + */ +export const getSocket = (): TypedSocket | null => { + return socket; +}; + +/** + * Disconnect and cleanup socket connection + */ +export const disconnectSocket = (): void => { + if (socket) { + socket.disconnect(); + socket = null; + } +}; + +/** + * Check if socket is connected + */ +export const isSocketConnected = (): boolean => { + return socket?.connected ?? false; +}; + + +/** + * Join a conversation room to receive real-time updates + */ +export const joinConversation = (conversationId: string): void => { + if (socket && socket.connected) { + socket.emit('join_conversation', { conversationId }); + } +}; + +/** + * Leave a conversation room + */ +export const leaveConversation = (conversationId: string): void => { + if (socket && socket.connected) { + socket.emit('leave_conversation', { conversationId }); + } +}; + +/** + * Send a message via socket (should also be sent via REST API) + */ +export const sendMessageViaSocket = ( + conversationId: string, + content: string, + attachments?: string[] +): void => { + if (socket && socket.connected) { + socket.emit('send_message', { conversationId, content, attachments }); + } +}; + +/** + * Mark messages as read + */ +export const markAsReadViaSocket = (conversationId: string): void => { + if (socket && socket.connected) { + socket.emit('mark_as_read', { conversationId }); + } +}; + +/** + * Send typing indicator + */ +export const sendTypingIndicator = ( + conversationId: string, + isTyping: boolean +): void => { + if (socket && socket.connected) { + socket.emit('typing', { conversationId, isTyping }); + } +}; diff --git a/frontend/src/config/stripe.ts b/frontend/src/config/stripe.ts new file mode 100644 index 0000000..7f4e37e --- /dev/null +++ b/frontend/src/config/stripe.ts @@ -0,0 +1,5 @@ +import { loadStripe } from '@stripe/stripe-js'; + +export const stripePromise = loadStripe( + import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || '' +); diff --git a/frontend/src/hooks/mutations/useEvents.ts b/frontend/src/hooks/mutations/useEvents.ts new file mode 100644 index 0000000..ec22e8f --- /dev/null +++ b/frontend/src/hooks/mutations/useEvents.ts @@ -0,0 +1,97 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +// import { useApi } from '../useApi'; + +/** + * Create event mutation (Admin only) + */ +export function useCreateEvent() { + // const api = useApi(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (_data: any) => { + // Implement API call to POST /api/events + throw new Error('Not implemented'); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['events'] }); + }, + }); +} + +/** + * Update event mutation (Admin only) + */ +export function useUpdateEvent() { + // const api = useApi(); + const queryClient = useQueryClient(); + + return useMutation({ + // add appropriate parameter to async + mutationFn: async ({ }: any) => { + // Implement API call to PUT /api/events/:id + throw new Error('Not implemented'); + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: ['event', variables.id] }); + queryClient.invalidateQueries({ queryKey: ['events'] }); + }, + }); +} + +/** + * Publish event mutation (Admin only) + */ +export function usePublishEvent() { + // const api = useApi(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (_eventId: string) => { + // Implement API call to POST /api/events/:id/publish + throw new Error('Not implemented'); + }, + onSuccess: (_, eventId) => { + queryClient.invalidateQueries({ queryKey: ['event', eventId] }); + queryClient.invalidateQueries({ queryKey: ['events'] }); + }, + }); +} + +/** + * Delete event mutation (Admin only) + */ +export function useDeleteEvent() { + // const api = useApi(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (_eventId: string) => { + // Implement API call to DELETE /api/events/:id + throw new Error('Not implemented'); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['events'] }); + }, + }); +} + +/** + * RSVP to event mutation + */ +export function useRSVP() { + // const api = useApi(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ }: any) => { + // Implement API call to POST /api/events/:eventId/rsvp + throw new Error('Not implemented'); + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: ['event', variables.eventId] }); + queryClient.invalidateQueries({ queryKey: ['events'] }); + queryClient.invalidateQueries({ queryKey: ['my-rsvps'] }); + }, + }); +} diff --git a/frontend/src/hooks/mutations/useMessages.ts b/frontend/src/hooks/mutations/useMessages.ts new file mode 100644 index 0000000..a943a78 --- /dev/null +++ b/frontend/src/hooks/mutations/useMessages.ts @@ -0,0 +1,65 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +// import { useApi } from '../useApi'; + +/** + * Send message mutation + */ +export function useSendMessage() { + // const api = useApi(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (_data: { conversationId: string; content: string; attachments?: string[] }) => { + // Implement API call to POST /api/messages/messages + throw new Error('Not implemented'); + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: ['messages', variables.conversationId] }); + queryClient.invalidateQueries({ queryKey: ['conversations'] }); + queryClient.invalidateQueries({ queryKey: ['unread-count'] }); + }, + }); +} + +/** + * Create conversation mutation + */ +export function useCreateConversation() { + // const api = useApi(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (_data: { + recipientId: string; + type: string; + subject: string; + initialMessage?: string; + }) => { + // Implement API call to POST /api/messages/conversations + throw new Error('Not implemented'); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['conversations'] }); + }, + }); +} + +/** + * Mark messages as read mutation + */ +export function useMarkAsRead() { + // const api = useApi(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (_conversationId: string) => { + // Implement API call to PUT /api/messages/conversations/:id/read + throw new Error('Not implemented'); + }, + onSuccess: (_, conversationId) => { + queryClient.invalidateQueries({ queryKey: ['messages', conversationId] }); + queryClient.invalidateQueries({ queryKey: ['conversations'] }); + queryClient.invalidateQueries({ queryKey: ['unread-count'] }); + }, + }); +} diff --git a/frontend/src/hooks/mutations/useStripe.ts b/frontend/src/hooks/mutations/useStripe.ts new file mode 100644 index 0000000..6875446 --- /dev/null +++ b/frontend/src/hooks/mutations/useStripe.ts @@ -0,0 +1,38 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +// import { useApi } from '../useApi'; + +/** + * Create subscription mutation + */ +export function useCreateSubscription() { + // const api = useApi(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (_priceId: string) => { + // Implement API call to POST /api/stripe/subscription + throw new Error('Not implemented'); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['subscription'] }); + }, + }); +} + +/** + * Cancel subscription mutation + */ +export function useCancelSubscription() { + // const api = useApi(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (_immediate: boolean = false) => { + // Implement API call to POST /api/stripe/subscription/cancel + throw new Error('Not implemented'); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['subscription'] }); + }, + }); +} diff --git a/frontend/src/hooks/queries/useEvents.ts b/frontend/src/hooks/queries/useEvents.ts new file mode 100644 index 0000000..358b981 --- /dev/null +++ b/frontend/src/hooks/queries/useEvents.ts @@ -0,0 +1,48 @@ +import { useQuery } from '@tanstack/react-query'; +// import { useApi } from '../useApi'; + +/** + * Fetch all events with optional filters + */ +export function useEvents(filters?: { status?: string; upcoming?: boolean }) { + // const api = useApi(); + + return useQuery({ + queryKey: ['events', filters], + queryFn: async () => { + // Implement API call to /api/events with query params + throw new Error('Not implemented'); + }, + }); +} + +/** + * Fetch single event by ID + */ +export function useEvent(id: string) { + // const api = useApi(); + + return useQuery({ + queryKey: ['event', id], + queryFn: async () => { + // Implement API call to /api/events/:id + throw new Error('Not implemented'); + }, + enabled: !!id, + }); +} + +/** + * Get current user's RSVPs + */ +export function useMyRSVPs() { + // const api = useApi(); + + return useQuery({ + queryKey: ['my-rsvps'], + queryFn: async () => { + // Implement API call to /api/events/my/rsvps + throw new Error('Not implemented'); + }, + }); +} diff --git a/frontend/src/hooks/queries/useMessages.ts b/frontend/src/hooks/queries/useMessages.ts new file mode 100644 index 0000000..02a69c7 --- /dev/null +++ b/frontend/src/hooks/queries/useMessages.ts @@ -0,0 +1,51 @@ +import { useQuery } from '@tanstack/react-query'; +// import { useApi } from '../useApi'; + +/** + * Fetch all conversations for current user + */ +export function useConversations() { + // const api = useApi(); + + return useQuery({ + queryKey: ['conversations'], + queryFn: async () => { + // Implement API call to /api/messages/conversations + throw new Error('Not implemented'); + }, + // Real-time updates handled by Socket.IO - no polling needed + }); +} + +/** + * Fetch messages for a specific conversation + */ +export function useMessages(conversationId: string) { + // const api = useApi(); + + return useQuery({ + queryKey: ['messages', conversationId], + queryFn: async () => { + // Implement API call to /api/messages/conversations/:id/messages + throw new Error('Not implemented'); + }, + enabled: !!conversationId, + // Real-time updates handled by Socket.IO - no polling needed + }); +} + +/** + * Get unread message count + */ +export function useUnreadCount() { + // const api = useApi(); + + return useQuery({ + queryKey: ['unread-count'], + queryFn: async () => { + // Implement API call to /api/messages/unread-count + throw new Error('Not implemented'); + }, + // Real-time updates handled by Socket.IO - no polling needed + }); +} diff --git a/frontend/src/hooks/queries/useStripe.ts b/frontend/src/hooks/queries/useStripe.ts new file mode 100644 index 0000000..14a9949 --- /dev/null +++ b/frontend/src/hooks/queries/useStripe.ts @@ -0,0 +1,32 @@ +import { useQuery } from '@tanstack/react-query'; +// import { useApi }from '../useApi'; + +/** + * Fetch subscription status for current organization + */ +export function useSubscription() { + // const api = useApi(); + + return useQuery({ + queryKey: ['subscription'], + queryFn: async () => { + // Implement API call to /api/stripe/subscription + throw new Error('Not implemented'); + }, + }); +} + +/** + * Fetch available Stripe prices + */ +export function usePrices() { + // const api = useApi(); + + return useQuery({ + queryKey: ['stripe-prices'], + queryFn: async () => { + // Implement API call to /api/stripe/prices + throw new Error('Not implemented'); + }, + }); +} diff --git a/frontend/src/hooks/useSocket.ts b/frontend/src/hooks/useSocket.ts new file mode 100644 index 0000000..6ba3289 --- /dev/null +++ b/frontend/src/hooks/useSocket.ts @@ -0,0 +1,175 @@ +import { useEffect, useState } from 'react'; +import { useAuth } from '@clerk/clerk-react'; +import { + initializeSocket, + disconnectSocket, + isSocketConnected, + type TypedSocket, +} from '../config/socket'; + +/** + * React hook to manage Socket.IO connection lifecycle + * + * Features: + * - Automatically connects when user is authenticated + * - Automatically disconnects when user logs out or component unmounts + * - Provides socket instance and connection status + * + * @returns {Object} Socket instance and connection status + * + * @example + * function MessagesPage() { + * const { socket, isConnected } = useSocket(); + * + * useEffect(() => { + * if (socket && isConnected) { + * // Listen to socket events + * socket.on('new_message', (data) => { + * console.log('New message:', data); + * }); + * + * return () => { + * socket.off('new_message'); + * }; + * } + * }, [socket, isConnected]); + * + * return
...
; + * } + */ +export const useSocket = () => { + const { getToken, isSignedIn } = useAuth(); + const [socket, setSocket] = useState(null); + const [isConnected, setIsConnected] = useState(false); + + useEffect(() => { + let mounted = true; + + const connectSocket = async () => { + if (!isSignedIn) { + // User not signed in, disconnect if connected + disconnectSocket(); + setSocket(null); + setIsConnected(false); + return; + } + + try { + // Get Clerk authentication token + const token = await getToken(); + + if (!token) { + console.error('Failed to get authentication token'); + return; + } + + // Initialize socket connection + const socketInstance = initializeSocket(token); + + if (mounted) { + setSocket(socketInstance); + setIsConnected(socketInstance.connected); + + // Listen to connection state changes + socketInstance.on('connect', () => { + if (mounted) { + setIsConnected(true); + } + }); + + socketInstance.on('disconnect', () => { + if (mounted) { + setIsConnected(false); + } + }); + } + } catch (error) { + console.error('Error connecting to socket:', error); + } + }; + + connectSocket(); + + // Cleanup on unmount + return () => { + mounted = false; + disconnectSocket(); + }; + }, [isSignedIn, getToken]); + + return { + socket, + isConnected, + }; +}; + +/** + * Hook to listen to specific socket event + * Automatically handles cleanup when component unmounts + * + * @param eventName - Socket event name to listen to + * @param handler - Event handler function + * + * @example + * function ConversationView({ conversationId }) { + * useSocketEvent('new_message', (data) => { + * if (data.conversationId === conversationId) { + * // Handle new message + * } + * }); + * + * return
...
; + * } + */ +export const useSocketEvent = ( + eventName: K, + handler: import('../types/socket').ServerToClientEvents[K] +) => { + const { socket, isConnected } = useSocket(); + + useEffect(() => { + if (socket && isConnected) { + // TypeScript workaround for event listener + socket.on(eventName as any, handler as any); + + return () => { + socket.off(eventName as any, handler as any); + }; + } + }, [socket, isConnected, eventName, handler]); +}; + +/** + * Hook to check socket connection status + * Useful for displaying connection status in UI + * + * @example + * function ConnectionStatus() { + * const isConnected = useSocketConnectionStatus(); + * + * return ( + *
+ * Status: {isConnected ? 'Connected' : 'Disconnected'} + *
+ * ); + * } + */ +export const useSocketConnectionStatus = (): boolean => { + const [connected, setConnected] = useState(false); + + useEffect(() => { + const checkConnection = () => { + setConnected(isSocketConnected()); + }; + + // Check immediately + checkConnection(); + + // Check periodically (every 2 seconds) + const interval = setInterval(checkConnection, 2000); + + return () => clearInterval(interval); + }, []); + + return connected; +}; diff --git a/frontend/src/pages/Admin/EventManagementPage/EventManagementPage.tsx b/frontend/src/pages/Admin/EventManagementPage/EventManagementPage.tsx new file mode 100644 index 0000000..3de0ce3 --- /dev/null +++ b/frontend/src/pages/Admin/EventManagementPage/EventManagementPage.tsx @@ -0,0 +1,43 @@ +// import { useState } from 'react'; +// Import event hooks +// import { useEvents } from '../../../hooks/queries/useEvents'; +// import { useCreateEvent, useUpdateEvent, usePublishEvent, useDeleteEvent } from '../../../hooks/mutations/useEvents'; + +export function EventManagementPage() { + // const [showCreateModal, setShowCreateModal] = useState(false); + + // Fetch all events with useEvents() (no filters to see all statuses) + // Handle create event with useCreateEvent() + // Handle update event with useUpdateEvent() + // Handle publish event with usePublishEvent() + // Handle delete event with useDeleteEvent() + + return ( +
+
+

Event Management

+ +
+ +
+ Implement event management page +
    +
  • Display all events with filters (draft, published, etc.)
  • +
  • Create event form (title, description, date/time, location, zoom link)
  • +
  • Edit existing events
  • +
  • Publish/unpublish events
  • +
  • View RSVP list for each event
  • +
  • Delete events
  • +
+
+ + {/* Events table/list */} + {/* Create/Edit event modal */} +
+ ); +} diff --git a/frontend/src/pages/OrganizationView/EventsPage/EventsPage.tsx b/frontend/src/pages/OrganizationView/EventsPage/EventsPage.tsx new file mode 100644 index 0000000..2d49c0c --- /dev/null +++ b/frontend/src/pages/OrganizationView/EventsPage/EventsPage.tsx @@ -0,0 +1,31 @@ +// import { useState } from 'react'; +// Import event hooks +// import { useEvents, useMyRSVPs } from '../../../hooks/queries/useEvents'; +// import { useRSVP } from '../../../hooks/mutations/useEvents'; + +export function EventsPage() { + // const [selectedEvent, setSelectedEvent] = useState(null); + + // Fetch upcoming events with useEvents({ status: 'PUBLISHED', upcoming: true }) + // Handle RSVP submission with useRSVP() + + return ( +
+

Upcoming Events

+ +
+ Implement events page +
    +
  • Display upcoming events in cards/list
  • +
  • Show event details (title, date, location, zoom link)
  • +
  • Display RSVP count and max attendees
  • +
  • Add RSVP buttons (Going, Maybe, Not Going)
  • +
  • Show event details modal
  • +
+
+ + {/* Event cards grid */} + {/* Event details modal */} +
+ ); +} diff --git a/frontend/src/pages/OrganizationView/SubscriptionPage/SubscriptionPage.tsx b/frontend/src/pages/OrganizationView/SubscriptionPage/SubscriptionPage.tsx new file mode 100644 index 0000000..1eca15f --- /dev/null +++ b/frontend/src/pages/OrganizationView/SubscriptionPage/SubscriptionPage.tsx @@ -0,0 +1,35 @@ +// import { useState } from 'react'; +// Import Stripe Elements and stripePromise +// import { Elements } from '@stripe/react-stripe-js'; +// import { stripePromise } from '../../../config/stripe'; +// import { StripePaymentForm } from '../../../components/StripePaymentForm'; + +// Import hooks +// import { useSubscription, usePrices } from '../../../hooks/queries/useStripe'; +// import { useCreateSubscription, useCancelSubscription } from '../../../hooks/mutations/useStripe'; + +export function SubscriptionPage() { + // const [selectedPrice, setSelectedPrice] = useState(null); + // const [clientSecret, setClientSecret] = useState(null); + + // Fetch subscription status with useSubscription() + // Fetch available prices with usePrices() + // Handle create subscription with useCreateSubscription() + // Handle cancel subscription with useCancelSubscription() + + return ( +
+

Subscription Management

+ +
+ Implement subscription page +
    +
  • Show current subscription status
  • +
  • Display available pricing plans
  • +
  • Handle subscription creation with Stripe
  • +
  • Allow subscription cancellation
  • +
+
+
+ ); +} diff --git a/frontend/src/types/socket.ts b/frontend/src/types/socket.ts new file mode 100644 index 0000000..44eee21 --- /dev/null +++ b/frontend/src/types/socket.ts @@ -0,0 +1,161 @@ +/** + * Socket.IO Event Type Definitions + * These interfaces define the structure of events exchanged between client and server + * Should match backend/types/socket.ts + */ + +export interface ClientToServerEvents { + // Join a conversation room to receive real-time updates + join_conversation: (data: JoinConversationPayload) => void; + + // Leave a conversation room + leave_conversation: (data: LeaveConversationPayload) => void; + + // Send a message (note: also saved via REST API, socket for real-time delivery) + send_message: (data: SendMessagePayload) => void; + + // Mark messages in a conversation as read + mark_as_read: (data: MarkAsReadPayload) => void; + + // Typing indicator (optional feature) + typing: (data: TypingPayload) => void; +} + +// ========================================== +// SERVER TO CLIENT EVENTS +// ========================================== + +export interface ServerToClientEvents { + // New message received in a conversation + new_message: (data: NewMessageEvent) => void; + + // Messages marked as read by another user + messages_read: (data: MessagesReadEvent) => void; + + // Unread count updated for current user + unread_count_update: (data: UnreadCountEvent) => void; + + // User typing in a conversation (optional) + user_typing: (data: UserTypingEvent) => void; + + // Error event + error: (data: ErrorEvent) => void; + + // Connection status events + connect: () => void; + disconnect: () => void; +} + +// ========================================== +// EVENT PAYLOAD INTERFACES +// ========================================== + +// Client -> Server Payloads + +export interface JoinConversationPayload { + conversationId: string; +} + +export interface LeaveConversationPayload { + conversationId: string; +} + +export interface SendMessagePayload { + conversationId: string; + content: string; + attachments?: string[]; +} + +export interface MarkAsReadPayload { + conversationId: string; +} + +export interface TypingPayload { + conversationId: string; + isTyping: boolean; +} + +// Server -> Client Event Data + +export interface NewMessageEvent { + conversationId: string; + message: MessageData; +} + +export interface MessagesReadEvent { + conversationId: string; + userId: string; + readAt: string; // ISO date string +} + +export interface UnreadCountEvent { + count: number; +} + +export interface UserTypingEvent { + conversationId: string; + userId: string; + isTyping: boolean; +} + +export interface ErrorEvent { + message: string; + code?: string; +} + +// ========================================== +// SHARED DATA STRUCTURES +// ========================================== + +export interface MessageData { + id: string; + conversationId: string; + senderType: 'ORG' | 'ADMIN'; + senderOrganizationId?: string; + senderAdminId?: string; + content: string; + attachments: string[]; + status: 'SENT' | 'DELIVERED' | 'READ'; + readAt?: string; + createdAt: string; + updatedAt: string; +} + +export interface ConversationData { + id: string; + type: 'ORG_TO_ORG' | 'ORG_TO_ADMIN' | 'ADMIN_TO_ORG'; + organizationId?: string; + adminId?: string; + otherOrganizationId?: string; + subject: string; + lastMessageAt: string; + unreadCount?: number; + createdAt: string; + updatedAt: string; +} + +// ========================================== +// EVENT NAME CONSTANTS +// ========================================== + +export const SOCKET_EVENTS = { + // Client -> Server + JOIN_CONVERSATION: 'join_conversation', + LEAVE_CONVERSATION: 'leave_conversation', + SEND_MESSAGE: 'send_message', + MARK_AS_READ: 'mark_as_read', + TYPING: 'typing', + + // Server -> Client + NEW_MESSAGE: 'new_message', + MESSAGES_READ: 'messages_read', + UNREAD_COUNT_UPDATE: 'unread_count_update', + USER_TYPING: 'user_typing', + ERROR: 'error', + + // Connection + CONNECT: 'connect', + DISCONNECT: 'disconnect', +} as const; + +export type SocketEventName = (typeof SOCKET_EVENTS)[keyof typeof SOCKET_EVENTS]; From fff7230c9a5cd6fafaf69fded48d0d306314c37b Mon Sep 17 00:00:00 2001 From: Ashrit-Anala Date: Mon, 5 Jan 2026 13:59:55 -0500 Subject: [PATCH 2/2] prisma --- backend/prisma/schema.prisma | 294 +++++++++++++++++++++++++---------- 1 file changed, 208 insertions(+), 86 deletions(-) diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 1437f62..1a2e37c 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -114,6 +114,11 @@ model Organization { lastCheckedBlogsAt DateTime? lastCheckedMessagesAt DateTime? surveyResponses SurveyResponse[] + subscription Subscription? + conversations Conversation[] @relation("OrgConversations") + sentMessages Message[] @relation("SentMessages") + otherConversations Conversation[] @relation("OrgToOrgConversations") + eventRSVPs EventRSVP[] @relation("EventRSVPs") @@map("organizations") } @@ -150,12 +155,15 @@ model Survey { } model AdminUser { - id String @id @default(cuid()) - clerkId String @unique - email String - name String - isSuperAdmin Boolean @default(false) - isActive Boolean + id String @id @default(cuid()) + clerkId String @unique + email String + name String + isSuperAdmin Boolean @default(false) + isActive Boolean + conversations Conversation[] @relation("AdminConversations") + sentMessages Message[] @relation("SentMessages") + createdEvents Event[] @relation("CreatedEvents") @@map("AdminUser") } @@ -234,6 +242,161 @@ model PageContent { @@map("page_content") } +model Subscription { + id String @id @default(cuid()) + organizationId String @unique + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + stripeCustomerId String + stripeSubscriptionId String @unique + stripePriceId String + status SubscriptionStatus + currentPeriodStart DateTime + currentPeriodEnd DateTime + cancelAtPeriodEnd Boolean @default(false) + payments Payment[] + invoices Invoice[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([organizationId]) + @@index([stripeCustomerId]) + @@map("subscriptions") +} + +model Payment { + id String @id @default(cuid()) + subscriptionId String + subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade) + stripePaymentIntentId String @unique + stripeInvoiceId String? + amount Int // Amount in cents + currency String @default("usd") + status PaymentStatus + paymentMethod String? + receiptUrl String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([subscriptionId]) + @@index([status]) + @@map("payments") +} + +model Invoice { + id String @id @default(cuid()) + subscriptionId String + subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade) + stripeInvoiceId String @unique + amount Int // Amount in cents + currency String @default("usd") + status String + invoicePdf String? + hostedInvoiceUrl String? + periodStart DateTime + periodEnd DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([subscriptionId]) + @@index([status]) + @@map("invoices") +} + +model Conversation { + id String @id @default(cuid()) + type ConversationType + organizationId String? + organization Organization? @relation("OrgConversations", fields: [organizationId], references: [id], onDelete: Cascade) + adminId String? + admin AdminUser? @relation("AdminConversations", fields: [adminId], references: [id], onDelete: Cascade) + otherOrganizationId String? + otherOrganization Organization? @relation("OrgToOrgConversations", fields: [otherOrganizationId], references: [id], onDelete: Cascade) + subject String + lastMessageAt DateTime @default(now()) + messages Message[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([organizationId]) + @@index([adminId]) + @@index([otherOrganizationId]) + @@index([lastMessageAt(sort: Desc)]) + @@map("conversations") +} + +model Message { + id String @id @default(cuid()) + conversationId String + conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) + senderType String // "ORG" or "ADMIN" + senderOrganizationId String? + senderOrganization Organization? @relation("SentMessages", fields: [senderOrganizationId], references: [id], onDelete: Cascade) + senderAdminId String? + senderAdmin AdminUser? @relation("SentMessages", fields: [senderAdminId], references: [id], onDelete: Cascade) + content String @db.Text + attachments String[] + status MessageStatus @default(SENT) + readAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([conversationId]) + @@index([senderOrganizationId]) + @@index([senderAdminId]) + @@index([createdAt(sort: Desc)]) + @@map("messages") +} + +model Event { + id String @id @default(cuid()) + title String + description String @db.Text + location String? + link String? + meetingPassword String? + startTime DateTime + endTime DateTime + timezone String @default("America/Chicago") + status EventStatus @default(DRAFT) + maxAttendees Int? + createdByAdminId String + createdByAdmin AdminUser @relation("CreatedEvents", fields: [createdByAdminId], references: [id], onDelete: Cascade) + tags String[] + attachments String[] + rsvps EventRSVP[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([createdByAdminId]) + @@index([startTime]) + @@index([status]) + @@map("events") +} + +model EventRSVP { + id String @id @default(cuid()) + eventId String + event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) + organizationId String + organization Organization @relation("EventRSVPs", fields: [organizationId], references: [id], onDelete: Cascade) + status RSVPStatus + attendeeName String? + attendeeEmail String? + attendeePhone String? + reminder3DaysSent Boolean @default(false) + reminder1DaySent Boolean @default(false) + reminder1HourSent Boolean @default(false) + notes String? @db.Text + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([eventId, organizationId]) + @@index([eventId]) + @@index([organizationId]) + @@index([status]) + @@map("event_rsvps") +} + enum EmailStatus { SCHEDULED SENT @@ -285,83 +448,42 @@ enum NotificationType { SURVEY } -// ==================== ADD STRIPE MODELS ==================== -// Add these enums: -// - SubscriptionStatus: ACTIVE, PAST_DUE, CANCELED, INCOMPLETE, TRIALING -// - PaymentStatus: PENDING, SUCCEEDED, FAILED, REFUNDED -// -// Add Subscription model with fields: -// - organizationId (unique, relation to Organization) -// - stripeCustomerId, stripeSubscriptionId, stripePriceId -// - status, currentPeriodStart, currentPeriodEnd, cancelAtPeriodEnd -// - relations: payments[], invoices[] -// -// Add Payment model with fields: -// - subscriptionId (relation to Subscription) -// - stripePaymentIntentId, stripeInvoiceId -// - amount, currency, status, paymentMethod, receiptUrl -// -// Add Invoice model with fields: -// - subscriptionId (relation to Subscription) -// - stripeInvoiceId, amount, currency, status -// - invoicePdf, hostedInvoiceUrl, periodStart, periodEnd -// -// Update Organization model to add: -// - subscription Subscription? - -// ==================== ADD MESSAGING MODELS ==================== -// Add these enums: -// - MessageStatus: SENT, DELIVERED, READ -// - ConversationType: ORG_TO_ORG, ORG_TO_ADMIN, ADMIN_TO_ORG -// -// Add Conversation model with fields: -// - type (ConversationType) -// - organizationId?, adminId?, otherOrganizationId? (for different conversation types) -// - subject, lastMessageAt -// - relations: messages[] -// - indexes on organizationId, adminId, otherOrganizationId -// -// Add Message model with fields: -// - conversationId (relation to Conversation) -// - senderType, senderOrganizationId?, senderAdminId? -// - content (Text), attachments (String[]) -// - status, readAt -// - indexes on conversationId, senderOrganizationId, senderAdminId -// -// Update Organization model to add: -// - conversations Conversation[] -// - sentMessages Message[] -// - otherConversations Conversation[] @relation("OrgToOrgConversations") -// -// Update AdminUser model to add: -// - conversations Conversation[] -// - sentMessages Message[] - -// ==================== ADD EVENTS & RSVP MODELS ==================== -// Add these enums: -// - EventStatus: DRAFT, PUBLISHED, CANCELLED, COMPLETED -// - RSVPStatus: GOING, NOT_GOING, MAYBE -// -// Add Event model with fields: -// - title, description (Text), location, zoomLink, meetingPassword -// - startTime, endTime, timezone (default: "America/Chicago") -// - status, maxAttendees (optional Int) -// - createdByAdminId (relation to AdminUser) -// - tags (String[]), attachments (String[]) -// - relations: rsvps[] -// - indexes on createdByAdminId, startTime, status -// -// Add EventRSVP model with fields: -// - eventId (relation to Event), organizationId (relation to Organization) -// - status (RSVPStatus) -// - attendeeName?, attendeeEmail?, attendeePhone? -// - reminder3DaysSent, reminder1DaySent, reminder1HourSent (all Boolean, default: false) -// - notes (Text) -// - unique constraint on [eventId, organizationId] -// - indexes on eventId, organizationId, status -// -// Update Organization model to add: -// - eventRSVPs EventRSVP[] -// -// Update AdminUser model to add: -// - createdEvents Event[] +enum SubscriptionStatus { + ACTIVE + PAST_DUE + CANCELED + INCOMPLETE + TRIALING +} + +enum PaymentStatus { + PENDING + SUCCEEDED + FAILED + REFUNDED +} + +enum MessageStatus { + SENT + DELIVERED + READ +} + +enum ConversationType { + ORG_TO_ORG + ORG_TO_ADMIN + ADMIN_TO_ORG +} + +enum EventStatus { + DRAFT + PUBLISHED + CANCELLED + COMPLETED +} + +enum RSVPStatus { + GOING + NOT_GOING + MAYBE +}