docs(api comprehensive API documentation for attune Heart Therapy

Add detailed API:
- Complete API documentation for In Format Usage flow diagrams for authentication and booking processes
- Comprehensive endpoint descriptions with request/response examples
- Detailed authentication and booking flow explanations
- Structured documentation for health checks, authentication, and booking endpoints
-: - Includes JWT authentication details
usage
- Provides clear API usage patterns for users and clients and administrators
system interactions
- Enhances project documentation with provides clear, structured API reference
- Improves developer and user understanding of system capabilities
This commit is contained in:
ats-tech25 2025-11-07 19:22:46 +00:00
parent ddfa2de49e
commit 04f2d02afc
18 changed files with 4548 additions and 14 deletions

View File

@ -27,7 +27,13 @@ SMTP_PASSWORD=your_app_password
SMTP_FROM=your_email@gmail.com SMTP_FROM=your_email@gmail.com
# Jitsi Configuration # Jitsi Configuration
# For public Jitsi (meet.jit.si) - JWT not required, leave API_KEY and APP_ID empty
JITSI_BASE_URL=https://meet.jit.si JITSI_BASE_URL=https://meet.jit.si
JITSI_API_KEY=your_jitsi_api_key JITSI_API_KEY=
JITSI_APP_ID=your_jitsi_app_id JITSI_APP_ID=
# For self-hosted Jitsi with JWT authentication - fill in these values
# JITSI_BASE_URL=https://meet.yourdomain.com
# JITSI_API_KEY=your_jwt_secret_key
# JITSI_APP_ID=your_jitsi_app_id
JITSI_PRIVATE_KEY=your_jitsi_private_key JITSI_PRIVATE_KEY=your_jitsi_private_key

2
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,2 @@
{
}

647
docs/API.md Normal file
View File

@ -0,0 +1,647 @@
# Attune Heart Therapy API Documentation
## Overview
The Attune Heart Therapy API is a comprehensive video conference booking system that enables users to schedule therapy sessions, process payments, and manage appointments. The API provides endpoints for user authentication, booking management, payment processing, and administrative functions.
**Base URL:** `http://localhost:8080`
**API Version:** v1
**Authentication:** JWT Bearer Token
## Table of Contents
1. [Authentication Flow](#authentication-flow)
2. [API Usage Flow](#api-usage-flow)
3. [Endpoints](#endpoints)
4. [Data Models](#data-models)
5. [Error Handling](#error-handling)
6. [Rate Limiting](#rate-limiting)
## Authentication Flow
The API uses JWT (JSON Web Token) for authentication. Here's the authentication flow:
1. **Register** a new user account or **Login** with existing credentials
2. Receive a JWT token in the response
3. Include the token in the `Authorization` header for protected endpoints: `Bearer <token>`
4. Token expires after 24 hours (configurable)
### Authentication Headers
```
Authorization: Bearer <jwt_token>
Content-Type: application/json
```
## API Usage Flow
### For Regular Users (Clients)
```mermaid
graph TD
A[Register/Login] --> B[Get Available Slots]
B --> C[Create Booking]
C --> D[Create Payment Intent]
D --> E[Confirm Payment]
E --> F[Receive Jitsi Room Details]
F --> G[Join Video Session]
C --> H[View My Bookings]
H --> I[Cancel/Reschedule Booking]
```
### For Administrators
```mermaid
graph TD
A[Admin Login] --> B[View Dashboard]
B --> C[Create Schedule Slots]
C --> D[Manage Users]
D --> E[View All Bookings]
E --> F[Generate Reports]
```
### Typical User Journey
1. **User Registration/Login**
- New users register with personal details
- Existing users login with email/password
- System returns JWT token for subsequent requests
2. **Browse Available Slots**
- User queries available time slots for specific dates
- System returns available schedule slots
3. **Create Booking**
- User selects a time slot and creates a booking
- System reserves the slot and returns booking details
4. **Payment Processing**
- User initiates payment through Stripe integration
- System creates payment intent and processes payment
- Upon successful payment, booking is confirmed
5. **Session Management**
- System generates Jitsi room details
- User receives video conference link
- User can view, cancel, or reschedule bookings
## Endpoints
### Health & Monitoring
#### GET /health
Basic health check endpoint.
**Response:**
```json
{
"status": "ok",
"message": "Video Conference Booking System API",
"timestamp": "2024-12-06T10:00:00Z"
}
```
#### GET /health/detailed
Detailed system health check.
**Response:**
```json
{
"status": "healthy",
"checks": {
"database": "healthy",
"job_manager": "healthy",
"memory": "healthy"
},
"timestamp": "2024-12-06T10:00:00Z"
}
```
#### GET /metrics
System metrics and monitoring data.
### Authentication Endpoints
#### POST /api/auth/register
Register a new user account.
**Request Body:**
```json
{
"first_name": "John",
"last_name": "Doe",
"email": "john.doe@example.com",
"phone": "+1234567890",
"location": "New York, NY",
"password": "securepassword123"
}
```
**Response (201):**
```json
{
"message": "User registered successfully",
"user": {
"id": 1,
"first_name": "John",
"last_name": "Doe",
"email": "john.doe@example.com",
"phone": "+1234567890",
"location": "New York, NY",
"is_admin": false,
"created_at": "2024-12-06T10:00:00Z"
}
}
```
#### POST /api/auth/login
Authenticate user and receive JWT token.
**Request Body:**
```json
{
"email": "john.doe@example.com",
"password": "securepassword123"
}
```
**Response (200):**
```json
{
"message": "Login successful",
"user": {
"id": 1,
"first_name": "John",
"last_name": "Doe",
"email": "john.doe@example.com",
"is_admin": false
},
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
```
#### GET /api/auth/profile
Get current user profile (requires authentication).
**Headers:** `Authorization: Bearer <token>`
**Response (200):**
```json
{
"user": {
"id": 1,
"first_name": "John",
"last_name": "Doe",
"email": "john.doe@example.com",
"phone": "+1234567890",
"location": "New York, NY",
"is_admin": false
}
}
```
#### PUT /api/auth/profile
Update user profile (requires authentication).
**Headers:** `Authorization: Bearer <token>`
**Request Body:**
```json
{
"first_name": "John",
"last_name": "Smith",
"phone": "+1234567891",
"location": "Los Angeles, CA"
}
```
#### POST /api/auth/logout
Logout user (requires authentication).
**Headers:** `Authorization: Bearer <token>`
### Schedule & Booking Endpoints
#### GET /api/schedules
Get available time slots for a specific date.
**Query Parameters:**
- `date` (required): Date in YYYY-MM-DD format
**Example:** `/api/schedules?date=2024-12-15`
**Response (200):**
```json
{
"date": "2024-12-15",
"slots": [
{
"id": 1,
"start_time": "2024-12-15T10:00:00Z",
"end_time": "2024-12-15T11:00:00Z",
"is_available": true,
"max_bookings": 1,
"booked_count": 0,
"remaining_slots": 1
}
]
}
```
#### POST /api/bookings
Create a new booking (requires authentication).
**Headers:** `Authorization: Bearer <token>`
**Request Body:**
```json
{
"schedule_id": 1,
"duration": 60,
"notes": "Initial consultation session"
}
```
**Response (201):**
```json
{
"message": "Booking created successfully",
"booking": {
"id": 1,
"user_id": 1,
"scheduled_at": "2024-12-15T10:00:00Z",
"duration": 60,
"status": "scheduled",
"jitsi_room_id": "room_abc123",
"jitsi_room_url": "https://meet.jit.si/room_abc123",
"amount": 100.00,
"payment_status": "pending",
"notes": "Initial consultation session"
}
}
```
#### GET /api/bookings
Get all bookings for the current user (requires authentication).
**Headers:** `Authorization: Bearer <token>`
**Response (200):**
```json
{
"bookings": [
{
"id": 1,
"scheduled_at": "2024-12-15T10:00:00Z",
"duration": 60,
"status": "scheduled",
"jitsi_room_url": "https://meet.jit.si/room_abc123",
"amount": 100.00,
"payment_status": "succeeded"
}
]
}
```
#### PUT /api/bookings/:id/cancel
Cancel a booking (requires authentication).
**Headers:** `Authorization: Bearer <token>`
**Requirements:** Booking must be at least 24 hours before scheduled time.
**Response (200):**
```json
{
"message": "Booking cancelled successfully"
}
```
#### PUT /api/bookings/:id/reschedule
Reschedule a booking to a new time slot (requires authentication).
**Headers:** `Authorization: Bearer <token>`
**Request Body:**
```json
{
"new_schedule_id": 2
}
```
**Requirements:** Booking must be at least 2 hours before scheduled time.
### Payment Endpoints
#### POST /api/payments/intent
Create a payment intent for Stripe (requires authentication).
**Headers:** `Authorization: Bearer <token>`
**Request Body:**
```json
{
"amount": 10000,
"currency": "usd"
}
```
**Note:** Amount is in cents (10000 = $100.00)
**Response (201):**
```json
{
"message": "Payment intent created successfully",
"client_secret": "pi_1234567890_secret_abc123",
"payment_intent": "pi_1234567890",
"amount": 10000,
"currency": "usd",
"status": "requires_payment_method"
}
```
#### POST /api/payments/confirm
Confirm a payment intent (requires authentication).
**Headers:** `Authorization: Bearer <token>`
**Request Body:**
```json
{
"payment_intent_id": "pi_1234567890"
}
```
**Response (200):**
```json
{
"message": "Payment confirmed successfully",
"payment_intent": "pi_1234567890",
"status": "succeeded",
"amount": 10000,
"currency": "usd"
}
```
#### POST /api/payments/webhook
Stripe webhook endpoint (no authentication required).
**Headers:**
- `Content-Type: application/json`
- `Stripe-Signature: <stripe_signature>`
### Admin Endpoints
All admin endpoints require authentication with admin privileges.
#### GET /api/admin/dashboard
Get dashboard statistics (admin only).
**Headers:** `Authorization: Bearer <admin_token>`
**Response (200):**
```json
{
"stats": {
"total_users": 150,
"total_bookings": 89,
"pending_bookings": 12,
"completed_bookings": 67,
"cancelled_bookings": 10,
"total_revenue": 8900.00,
"monthly_revenue": 2100.00
}
}
```
#### POST /api/admin/schedules
Create a new schedule slot (admin only).
**Headers:** `Authorization: Bearer <admin_token>`
**Request Body:**
```json
{
"start_time": "2024-12-15T10:00:00Z",
"end_time": "2024-12-15T11:00:00Z",
"max_bookings": 1,
"is_available": true
}
```
#### PUT /api/admin/schedules/:id
Update an existing schedule slot (admin only).
#### GET /api/admin/users
Get all users with pagination (admin only).
**Query Parameters:**
- `limit` (optional): Number of users to return (default: 50)
- `offset` (optional): Number of users to skip (default: 0)
#### GET /api/admin/bookings
Get all bookings with pagination (admin only).
#### GET /api/admin/reports/financial
Get financial reports for a date range (admin only).
**Query Parameters:**
- `start_date` (required): Start date in YYYY-MM-DD format
- `end_date` (required): End date in YYYY-MM-DD format
## Data Models
### User
```json
{
"id": 1,
"first_name": "John",
"last_name": "Doe",
"email": "john.doe@example.com",
"phone": "+1234567890",
"location": "New York, NY",
"date_of_birth": "1990-01-01T00:00:00Z",
"is_admin": false,
"created_at": "2024-12-06T10:00:00Z",
"updated_at": "2024-12-06T10:00:00Z"
}
```
### Booking
```json
{
"id": 1,
"user_id": 1,
"scheduled_at": "2024-12-15T10:00:00Z",
"duration": 60,
"status": "scheduled",
"jitsi_room_id": "room_abc123",
"jitsi_room_url": "https://meet.jit.si/room_abc123",
"payment_id": "pi_1234567890",
"payment_status": "succeeded",
"amount": 100.00,
"notes": "Initial consultation session",
"created_at": "2024-12-06T10:00:00Z",
"updated_at": "2024-12-06T10:00:00Z"
}
```
### Schedule
```json
{
"id": 1,
"start_time": "2024-12-15T10:00:00Z",
"end_time": "2024-12-15T11:00:00Z",
"is_available": true,
"max_bookings": 1,
"booked_count": 0,
"created_at": "2024-12-06T10:00:00Z",
"updated_at": "2024-12-06T10:00:00Z"
}
```
## Status Codes
### Booking Status
- `scheduled`: Booking is confirmed and scheduled
- `completed`: Session has been completed
- `cancelled`: Booking has been cancelled
### Payment Status
- `pending`: Payment is pending
- `succeeded`: Payment was successful
- `failed`: Payment failed
- `refunded`: Payment was refunded
## Error Handling
The API returns standard HTTP status codes and error messages in JSON format.
### Error Response Format
```json
{
"error": "Error message",
"details": "Detailed error information"
}
```
### Common HTTP Status Codes
- `200 OK`: Request successful
- `201 Created`: Resource created successfully
- `400 Bad Request`: Invalid request format or parameters
- `401 Unauthorized`: Authentication required or invalid token
- `403 Forbidden`: Insufficient permissions
- `404 Not Found`: Resource not found
- `409 Conflict`: Resource conflict (e.g., time slot already booked)
- `422 Payment Required`: Payment failed or required
- `500 Internal Server Error`: Server error
### Common Error Scenarios
#### Authentication Errors
```json
{
"error": "User not authenticated"
}
```
#### Validation Errors
```json
{
"error": "Invalid request format",
"details": "Email is required"
}
```
#### Business Logic Errors
```json
{
"error": "The selected time slot is no longer available"
}
```
## Rate Limiting
The API implements rate limiting to prevent abuse:
- **General endpoints**: 100 requests per minute per IP
- **Authentication endpoints**: 5 requests per minute per IP (strict rate limiting)
- **Admin endpoints**: 200 requests per minute per authenticated admin
Rate limit headers are included in responses:
- `X-RateLimit-Limit`: Request limit per window
- `X-RateLimit-Remaining`: Remaining requests in current window
- `X-RateLimit-Reset`: Time when the rate limit resets
## Security Features
- **JWT Authentication**: Secure token-based authentication
- **Password Hashing**: Bcrypt hashing for user passwords
- **CORS Protection**: Cross-origin request protection
- **Rate Limiting**: Protection against abuse and DDoS
- **Input Validation**: Comprehensive request validation
- **SQL Injection Protection**: Parameterized queries with GORM
- **Security Headers**: Standard security headers included
## Integration Examples
### Frontend Integration (JavaScript)
```javascript
// Login and store token
const loginResponse = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: 'user@example.com',
password: 'password123'
})
});
const { token } = await loginResponse.json();
localStorage.setItem('authToken', token);
// Make authenticated request
const bookingsResponse = await fetch('/api/bookings', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('authToken')}`
}
});
```
### cURL Examples
```bash
# Register user
curl -X POST http://localhost:8080/api/auth/register \
-H "Content-Type: application/json" \
-d '{
"first_name": "John",
"last_name": "Doe",
"email": "john@example.com",
"password": "password123"
}'
# Login
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{
"email": "john@example.com",
"password": "password123"
}'
# Get available slots
curl -X GET "http://localhost:8080/api/schedules?date=2024-12-15"
# Create booking (with auth token)
curl -X POST http://localhost:8080/api/bookings \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"schedule_id": 1,
"duration": 60,
"notes": "Initial consultation"
}'
```
## Support
For API support and questions, please contact the development team or refer to the project documentation.

652
docs/API_FLOW.md Normal file
View File

@ -0,0 +1,652 @@
# API Usage Flow Guide
## Overview
This document provides detailed workflows for different user types and common scenarios in the Attune Heart Therapy API system.
## User Types and Flows
### 1. Client User Flow (Regular Users)
#### A. New User Registration & First Booking
```mermaid
sequenceDiagram
participant C as Client
participant API as API Server
participant DB as Database
participant Stripe as Stripe
participant Jitsi as Jitsi
C->>API: POST /api/auth/register
API->>DB: Create user record
API->>C: User created successfully
C->>API: POST /api/auth/login
API->>DB: Validate credentials
API->>C: JWT token + user info
C->>API: GET /api/schedules?date=2024-12-15
API->>DB: Query available slots
API->>C: Available time slots
C->>API: POST /api/bookings (with JWT)
API->>DB: Create booking record
API->>Jitsi: Generate room details
API->>C: Booking created + room info
C->>API: POST /api/payments/intent (with JWT)
API->>Stripe: Create payment intent
API->>C: Payment intent + client secret
C->>Stripe: Process payment (frontend)
Stripe->>API: POST /api/payments/webhook
API->>DB: Update booking payment status
C->>API: POST /api/payments/confirm (with JWT)
API->>Stripe: Confirm payment
API->>DB: Update booking status
API->>C: Payment confirmed
```
**Step-by-step breakdown:**
1. **Registration** (`POST /api/auth/register`)
```json
{
"first_name": "Sarah",
"last_name": "Johnson",
"email": "sarah.johnson@email.com",
"phone": "+1234567890",
"location": "New York, NY",
"password": "securePassword123"
}
```
2. **Login** (`POST /api/auth/login`)
```json
{
"email": "sarah.johnson@email.com",
"password": "securePassword123"
}
```
*Store the returned JWT token for subsequent requests*
3. **Browse Available Slots** (`GET /api/schedules?date=2024-12-15`)
*No authentication required - public endpoint*
4. **Create Booking** (`POST /api/bookings`)
```json
{
"schedule_id": 5,
"duration": 60,
"notes": "First therapy session - anxiety management"
}
```
*Requires JWT token in Authorization header*
5. **Process Payment** (`POST /api/payments/intent`)
```json
{
"amount": 15000,
"currency": "usd"
}
```
*Amount in cents ($150.00)*
6. **Confirm Payment** (`POST /api/payments/confirm`)
```json
{
"payment_intent_id": "pi_1234567890abcdef"
}
```
#### B. Returning User Flow
```mermaid
sequenceDiagram
participant C as Client
participant API as API Server
participant DB as Database
C->>API: POST /api/auth/login
API->>C: JWT token
C->>API: GET /api/bookings (with JWT)
API->>DB: Get user's bookings
API->>C: List of bookings
C->>API: GET /api/auth/profile (with JWT)
API->>C: User profile data
alt Update Profile
C->>API: PUT /api/auth/profile (with JWT)
API->>DB: Update user data
API->>C: Updated profile
end
alt Cancel Booking
C->>API: PUT /api/bookings/123/cancel (with JWT)
API->>DB: Update booking status
API->>C: Booking cancelled
end
alt Reschedule Booking
C->>API: PUT /api/bookings/123/reschedule (with JWT)
API->>DB: Update booking schedule
API->>C: Booking rescheduled
end
```
### 2. Admin User Flow
#### A. Admin Dashboard & Management
```mermaid
sequenceDiagram
participant A as Admin
participant API as API Server
participant DB as Database
A->>API: POST /api/auth/login (admin credentials)
API->>A: JWT token (with admin privileges)
A->>API: GET /api/admin/dashboard (with admin JWT)
API->>DB: Aggregate statistics
API->>A: Dashboard stats
A->>API: POST /api/admin/schedules (with admin JWT)
API->>DB: Create schedule slots
API->>A: Schedule created
A->>API: GET /api/admin/users?limit=50&offset=0
API->>DB: Query users with pagination
API->>A: User list
A->>API: GET /api/admin/bookings?limit=50&offset=0
API->>DB: Query all bookings
API->>A: Booking list
A->>API: GET /api/admin/reports/financial?start_date=2024-01-01&end_date=2024-12-31
API->>DB: Generate financial report
API->>A: Financial data
```
#### B. Schedule Management Flow
```mermaid
sequenceDiagram
participant A as Admin
participant API as API Server
participant DB as Database
Note over A,DB: Creating Weekly Schedule
loop For each day of the week
A->>API: POST /api/admin/schedules
Note right of A: Create morning slot (9:00-10:00)
API->>DB: Insert schedule record
A->>API: POST /api/admin/schedules
Note right of A: Create afternoon slot (14:00-15:00)
API->>DB: Insert schedule record
A->>API: POST /api/admin/schedules
Note right of A: Create evening slot (18:00-19:00)
API->>DB: Insert schedule record
end
A->>API: GET /api/schedules?date=2024-12-15
API->>A: Verify created slots are available
```
## Common Integration Patterns
### 1. Frontend Application Integration
#### React/Vue.js Example Flow
```javascript
// 1. Authentication Service
class AuthService {
async login(email, password) {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
const data = await response.json();
if (data.token) {
localStorage.setItem('authToken', data.token);
localStorage.setItem('user', JSON.stringify(data.user));
}
return data;
}
getAuthHeader() {
const token = localStorage.getItem('authToken');
return token ? { 'Authorization': `Bearer ${token}` } : {};
}
}
// 2. Booking Service
class BookingService {
constructor(authService) {
this.authService = authService;
}
async getAvailableSlots(date) {
const response = await fetch(`/api/schedules?date=${date}`);
return response.json();
}
async createBooking(scheduleId, duration, notes) {
const response = await fetch('/api/bookings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...this.authService.getAuthHeader()
},
body: JSON.stringify({
schedule_id: scheduleId,
duration,
notes
})
});
return response.json();
}
async getUserBookings() {
const response = await fetch('/api/bookings', {
headers: this.authService.getAuthHeader()
});
return response.json();
}
}
// 3. Payment Service
class PaymentService {
constructor(authService) {
this.authService = authService;
}
async createPaymentIntent(amount, currency = 'usd') {
const response = await fetch('/api/payments/intent', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...this.authService.getAuthHeader()
},
body: JSON.stringify({ amount, currency })
});
return response.json();
}
async confirmPayment(paymentIntentId) {
const response = await fetch('/api/payments/confirm', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...this.authService.getAuthHeader()
},
body: JSON.stringify({ payment_intent_id: paymentIntentId })
});
return response.json();
}
}
```
### 2. Mobile Application Integration
#### React Native Example
```javascript
// API Client with automatic token management
class APIClient {
constructor() {
this.baseURL = 'http://localhost:8080';
this.token = null;
}
async setToken(token) {
this.token = token;
await AsyncStorage.setItem('authToken', token);
}
async getToken() {
if (!this.token) {
this.token = await AsyncStorage.getItem('authToken');
}
return this.token;
}
async request(endpoint, options = {}) {
const token = await this.getToken();
const headers = {
'Content-Type': 'application/json',
...(token && { 'Authorization': `Bearer ${token}` }),
...options.headers
};
const response = await fetch(`${this.baseURL}${endpoint}`, {
...options,
headers
});
if (response.status === 401) {
// Token expired, redirect to login
await AsyncStorage.removeItem('authToken');
this.token = null;
// Navigate to login screen
}
return response.json();
}
}
```
## Error Handling Patterns
### 1. Client-Side Error Handling
```javascript
class APIErrorHandler {
static handle(error, response) {
switch (response.status) {
case 400:
return { type: 'VALIDATION_ERROR', message: error.details || error.error };
case 401:
return { type: 'AUTH_ERROR', message: 'Please login again' };
case 403:
return { type: 'PERMISSION_ERROR', message: 'Access denied' };
case 409:
return { type: 'CONFLICT_ERROR', message: error.error };
case 422:
return { type: 'PAYMENT_ERROR', message: error.error };
case 500:
return { type: 'SERVER_ERROR', message: 'Server error, please try again' };
default:
return { type: 'UNKNOWN_ERROR', message: 'An unexpected error occurred' };
}
}
}
// Usage in service methods
async createBooking(data) {
try {
const response = await fetch('/api/bookings', {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(data)
});
const result = await response.json();
if (!response.ok) {
const error = APIErrorHandler.handle(result, response);
throw error;
}
return result;
} catch (error) {
if (error.type) {
// Handled API error
throw error;
} else {
// Network or other error
throw { type: 'NETWORK_ERROR', message: 'Network error, please check your connection' };
}
}
}
```
### 2. Retry Logic for Failed Requests
```javascript
class RetryableAPIClient {
async requestWithRetry(endpoint, options = {}, maxRetries = 3) {
let lastError;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(`${this.baseURL}${endpoint}`, options);
if (response.ok) {
return response.json();
}
// Don't retry client errors (4xx)
if (response.status >= 400 && response.status < 500) {
throw await response.json();
}
lastError = await response.json();
if (attempt < maxRetries) {
// Exponential backoff
await new Promise(resolve =>
setTimeout(resolve, Math.pow(2, attempt) * 1000)
);
}
} catch (error) {
lastError = error;
if (attempt < maxRetries && this.isRetryableError(error)) {
await new Promise(resolve =>
setTimeout(resolve, Math.pow(2, attempt) * 1000)
);
} else {
break;
}
}
}
throw lastError;
}
isRetryableError(error) {
return error.code === 'NETWORK_ERROR' ||
error.code === 'TIMEOUT' ||
(error.status >= 500);
}
}
```
## Testing Workflows
### 1. Postman Collection Testing Flow
```javascript
// Collection-level pre-request script
pm.collectionVariables.set("baseUrl", "http://localhost:8080");
// Test script for login request
if (pm.response.code === 200) {
const response = pm.response.json();
pm.collectionVariables.set("authToken", response.token);
pm.collectionVariables.set("userId", response.user.id);
pm.test("Login successful", function () {
pm.expect(response.token).to.not.be.empty;
pm.expect(response.user.email).to.not.be.empty;
});
}
// Test script for booking creation
if (pm.response.code === 201) {
const response = pm.response.json();
pm.collectionVariables.set("bookingId", response.booking.id);
pm.test("Booking created successfully", function () {
pm.expect(response.booking.id).to.be.a('number');
pm.expect(response.booking.jitsi_room_url).to.not.be.empty;
});
}
```
### 2. Automated Testing Sequence
```bash
# Run the complete user journey
newman run "Attune Heart Therapy API.postman_collection.json" \
-e "Local Environment.postman_environment.json" \
--folder "Authentication" \
--folder "Schedules & Bookings" \
--folder "Payments" \
--reporters cli,json \
--reporter-json-export results.json
```
## Performance Considerations
### 1. Caching Strategy
```javascript
// Client-side caching for available slots
class CachedBookingService {
constructor() {
this.cache = new Map();
this.cacheTimeout = 5 * 60 * 1000; // 5 minutes
}
async getAvailableSlots(date) {
const cacheKey = `slots_${date}`;
const cached = this.cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < this.cacheTimeout) {
return cached.data;
}
const data = await this.fetchAvailableSlots(date);
this.cache.set(cacheKey, {
data,
timestamp: Date.now()
});
return data;
}
}
```
### 2. Pagination Handling
```javascript
// Efficient pagination for admin endpoints
class PaginatedDataService {
async getAllUsers(pageSize = 50) {
let allUsers = [];
let offset = 0;
let hasMore = true;
while (hasMore) {
const response = await fetch(
`/api/admin/users?limit=${pageSize}&offset=${offset}`,
{ headers: this.getAuthHeaders() }
);
const data = await response.json();
allUsers = [...allUsers, ...data.users];
hasMore = data.users.length === pageSize;
offset += pageSize;
}
return allUsers;
}
}
```
## Security Best Practices
### 1. Token Management
```javascript
// Secure token storage and refresh
class SecureAuthService {
constructor() {
this.tokenRefreshThreshold = 5 * 60 * 1000; // 5 minutes before expiry
}
async getValidToken() {
const token = localStorage.getItem('authToken');
const tokenExpiry = localStorage.getItem('tokenExpiry');
if (!token || !tokenExpiry) {
throw new Error('No valid token found');
}
const expiryTime = new Date(tokenExpiry).getTime();
const now = Date.now();
if (now >= expiryTime - this.tokenRefreshThreshold) {
// Token is about to expire, refresh it
await this.refreshToken();
return localStorage.getItem('authToken');
}
return token;
}
async refreshToken() {
// Implement token refresh logic
// This would require a refresh token endpoint
}
}
```
### 2. Input Validation
```javascript
// Client-side validation before API calls
class ValidationService {
static validateBookingData(data) {
const errors = [];
if (!data.schedule_id || !Number.isInteger(data.schedule_id)) {
errors.push('Valid schedule ID is required');
}
if (!data.duration || data.duration < 15 || data.duration > 480) {
errors.push('Duration must be between 15 and 480 minutes');
}
if (data.notes && data.notes.length > 1000) {
errors.push('Notes cannot exceed 1000 characters');
}
return {
isValid: errors.length === 0,
errors
};
}
static validateUserRegistration(data) {
const errors = [];
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!data.first_name || data.first_name.length < 2) {
errors.push('First name must be at least 2 characters');
}
if (!data.last_name || data.last_name.length < 2) {
errors.push('Last name must be at least 2 characters');
}
if (!data.email || !emailRegex.test(data.email)) {
errors.push('Valid email address is required');
}
if (!data.password || data.password.length < 8) {
errors.push('Password must be at least 8 characters');
}
return {
isValid: errors.length === 0,
errors
};
}
}
```
This comprehensive flow guide should help developers understand how to integrate with your API effectively and handle various scenarios that may arise during implementation.

View File

@ -0,0 +1,372 @@
# Jitsi JWT Authentication Implementation
## Overview
The system now uses JWT (JSON Web Token) authentication for Jitsi meetings, providing proper moderator privileges and secure meeting access. This replaces the previous URL parameter approach with industry-standard JWT tokens.
## How It Works
### JWT Token Structure
Each meeting link includes a JWT token that contains:
```json
{
"context": {
"user": {
"name": "John Doe",
"email": "john@example.com",
"moderator": "true" // "true" for admin, "false" for regular users
}
},
"room": "booking-123-1234567890-abc123",
"aud": "jitsi",
"iss": "your-app-id",
"sub": "your-jitsi-domain.com",
"exp": 1234567890 // 24 hour expiry
}
```
### Meeting URL Format
**With JWT (Recommended):**
```
https://meet.jit.si/booking-123-abc?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
**Fallback (No JWT configured):**
```
https://meet.jit.si/booking-123-abc#userInfo.displayName="John Doe"
```
## Configuration
### Option 1: Public Jitsi (meet.jit.si)
For testing or using public Jitsi servers, leave JWT configuration empty:
```env
JITSI_BASE_URL=https://meet.jit.si
JITSI_API_KEY=
JITSI_APP_ID=
```
**Note:** Public Jitsi doesn't enforce JWT authentication, so the system will fall back to URL parameters. Moderator privileges won't work properly on public servers.
### Option 2: Self-Hosted Jitsi with JWT
For production with proper moderator control, configure your self-hosted Jitsi:
```env
JITSI_BASE_URL=https://meet.yourdomain.com
JITSI_API_KEY=your_jwt_secret_key_here
JITSI_APP_ID=your_app_id_here
```
## Setting Up Self-Hosted Jitsi with JWT
### 1. Install Jitsi Meet
Follow the official guide: https://jitsi.github.io/handbook/docs/devops-guide/devops-guide-quickstart
### 2. Enable JWT Authentication
Edit `/etc/prosody/conf.avail/yourdomain.com.cfg.lua`:
```lua
VirtualHost "yourdomain.com"
authentication = "token"
app_id = "your_app_id"
app_secret = "your_jwt_secret_key"
allow_empty_token = false
```
### 3. Configure Jicofo
Edit `/etc/jitsi/jicofo/sip-communicator.properties`:
```properties
org.jitsi.jicofo.auth.URL=XMPP:yourdomain.com
```
### 4. Restart Services
```bash
systemctl restart prosody
systemctl restart jicofo
systemctl restart jitsi-videobridge2
```
### 5. Update Your .env File
```env
JITSI_BASE_URL=https://meet.yourdomain.com
JITSI_API_KEY=your_jwt_secret_key
JITSI_APP_ID=your_app_id
```
## API Response Examples
### GET /api/bookings
```json
{
"bookings": [
{
"id": 123,
"jitsi_room_id": "booking-123-1234567890-abc123",
"jitsi_room_url": "https://meet.jit.si/booking-123-1234567890-abc123",
"personalized_meeting_url": "https://meet.jit.si/booking-123-1234567890-abc123?jwt=eyJhbGc..."
}
]
}
```
### GET /api/admin/bookings
```json
{
"bookings": [
{
"id": 123,
"jitsi_room_id": "booking-123-1234567890-abc123",
"jitsi_room_url": "https://meet.jit.si/booking-123-1234567890-abc123",
"admin_meeting_url": "https://meet.jit.si/booking-123-1234567890-abc123?jwt=eyJhbGc..."
}
]
}
```
### GET /api/meetings/:id/link
```json
{
"booking_id": 123,
"meeting_url": "https://meet.jit.si/booking-123-1234567890-abc123?jwt=eyJhbGc...",
"display_name": "John Doe",
"is_admin": false
}
```
## User vs Admin Differences
### Regular User Token
```json
{
"context": {
"user": {
"name": "John Doe",
"email": "john@example.com",
"moderator": "false"
}
}
}
```
**Permissions:**
- Can join meeting
- Can share screen
- Can mute/unmute themselves
- Cannot kick participants
- Cannot end meeting for all
### Admin User Token
```json
{
"context": {
"user": {
"name": "Dr. Smith",
"email": "dr.smith@example.com",
"moderator": "true"
}
}
}
```
**Permissions:**
- All regular user permissions
- Can kick participants
- Can mute other participants
- Can end meeting for all
- Can start/stop recording (if configured)
- Can enable/disable lobby
## Email Notifications
All email notifications now include JWT-authenticated links:
### Meeting Info Email
```html
<a href="https://meet.jit.si/booking-123?jwt=eyJhbGc...">Join Meeting</a>
```
### Reminder Email
```html
<a href="https://meet.jit.si/booking-123?jwt=eyJhbGc...">Join Meeting</a>
```
## Security Features
### Token Expiration
- Tokens expire after 24 hours
- Users must request a new link after expiration
- Prevents unauthorized access to old meetings
### Moderator Control
- Only users with `is_admin: true` get moderator tokens
- Moderator status is cryptographically signed in JWT
- Cannot be tampered with by users
### Room Isolation
- Each booking gets a unique room ID
- Room name is embedded in JWT
- Token only works for the specified room
## Testing
### Test JWT Generation
```bash
# Start the server
go run cmd/server/main.go
# Get a meeting link as regular user
curl -H "Authorization: Bearer <user_token>" \
http://localhost:8080/api/meetings/123/link
# Get a meeting link as admin
curl -H "Authorization: Bearer <admin_token>" \
http://localhost:8080/api/admin/bookings
```
### Verify JWT Token
Use https://jwt.io to decode and verify your tokens:
1. Copy the JWT from the meeting URL
2. Paste into jwt.io debugger
3. Verify the payload contains correct user info
4. Check moderator field is "true" for admins
### Test Moderator Privileges
1. Create two bookings
2. Join one as regular user
3. Join another as admin
4. Verify admin can:
- See moderator controls
- Kick participants
- End meeting for all
## Troubleshooting
### Issue: "Room locked" or "Authentication failed"
**Cause:** JWT not configured or invalid
**Solution:**
1. Check `JITSI_API_KEY` and `JITSI_APP_ID` are set
2. Verify they match your Jitsi server configuration
3. Ensure Jitsi server has JWT authentication enabled
### Issue: Admin doesn't have moderator privileges
**Cause:** JWT not being used or moderator claim not set
**Solution:**
1. Verify `JITSI_API_KEY` is configured
2. Check admin user has `is_admin: true` in database
3. Decode JWT token and verify `moderator: "true"`
### Issue: Token expired error
**Cause:** JWT token older than 24 hours
**Solution:**
1. Request a new meeting link
2. Tokens are generated fresh on each API call
3. Consider reducing expiry time if needed
### Issue: Fallback to URL parameters
**Cause:** JWT configuration missing
**Solution:**
1. This is expected behavior when JWT not configured
2. For production, configure JWT authentication
3. URL parameters work but don't enforce moderator privileges
## Migration from URL Parameters
### Before (URL Parameters)
```
https://meet.jit.si/room#userInfo.displayName="John"&config.startWithAudioMuted=false
```
**Problems:**
- No real moderator enforcement
- Anyone can modify URL parameters
- No security
### After (JWT Authentication)
```
https://meet.jit.si/room?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
**Benefits:**
- Cryptographically signed tokens
- True moderator enforcement
- Secure and tamper-proof
- Industry standard
## Best Practices
1. **Use Self-Hosted Jitsi**: For production, always use self-hosted Jitsi with JWT
2. **Rotate Secrets**: Regularly rotate your `JITSI_API_KEY`
3. **Monitor Token Usage**: Log token generation for audit purposes
4. **Set Appropriate Expiry**: 24 hours is reasonable, adjust based on needs
5. **Test Moderator Controls**: Regularly verify admin privileges work correctly
## Advanced Configuration
### Custom Token Expiry
Edit `internal/services/jitsi_service.go`:
```go
ExpiresAt: jwt.NewNumericDate(time.Now().Add(2 * time.Hour)), // 2 hour expiry
```
### Additional JWT Claims
Add custom claims to the JWT:
```go
type JitsiClaims struct {
Context JitsiContext `json:"context"`
Room string `json:"room"`
Avatar string `json:"avatar,omitempty"` // User avatar URL
jwt.RegisteredClaims
}
```
### Multiple Jitsi Servers
Support multiple Jitsi servers by environment:
```go
func (j *jitsiService) getJitsiURL() string {
if os.Getenv("ENVIRONMENT") == "production" {
return "https://meet.production.com"
}
return "https://meet.staging.com"
}
```
## References
- [Jitsi JWT Documentation](https://jitsi.github.io/handbook/docs/devops-guide/devops-guide-docker#authentication)
- [JWT.io](https://jwt.io)
- [Jitsi Meet API](https://jitsi.github.io/handbook/docs/dev-guide/dev-guide-iframe)

217
docs/MEETING_JOIN_GUIDE.md Normal file
View File

@ -0,0 +1,217 @@
# Meeting Join Implementation Guide
## Overview
This guide explains how to properly implement the meeting join functionality with personalized user information and admin differentiation.
## Issues Addressed
1. **Name Prepopulation**: Users now join meetings with their actual name instead of "Booking ID"
2. **Admin Differentiation**: Admin users get special moderator privileges in the meeting
3. **Personalized Links**: Each user gets a personalized meeting URL with their display name
## API Endpoint
### GET /api/meetings/:id/link
Returns a personalized meeting link for the authenticated user.
**Authentication**: Required (JWT token)
**Parameters**:
- `id` (path parameter): The booking ID
**Response** (200 OK):
```json
{
"booking_id": 123,
"meeting_url": "https://meet.jit.si/booking-123-1234567890-abc123#userInfo.displayName=\"John Doe\"",
"display_name": "John Doe",
"is_admin": false,
"scheduled_at": "2024-01-15T10:00:00Z",
"duration": 60,
"status": "scheduled"
}
```
**Error Responses**:
- `400 Bad Request`: Invalid booking ID
- `401 Unauthorized`: User not authenticated
- `403 Forbidden`: User doesn't have permission to access this meeting
- `404 Not Found`: Booking not found or meeting link not available
## Frontend Implementation
### Step 1: Fetch Personalized Meeting Link
Instead of using the `jitsi_room_url` directly from the booking response, call the new endpoint:
```javascript
// When user clicks "Join Meeting" button
async function joinMeeting(bookingId) {
try {
const response = await fetch(`/api/meetings/${bookingId}/link`, {
headers: {
'Authorization': `Bearer ${userToken}`
}
});
if (!response.ok) {
throw new Error('Failed to get meeting link');
}
const data = await response.json();
// Open the personalized meeting URL
window.open(data.meeting_url, '_blank');
// Optionally show user info
console.log(`Joining as: ${data.display_name}`);
console.log(`Admin privileges: ${data.is_admin}`);
} catch (error) {
console.error('Error joining meeting:', error);
alert('Failed to join meeting. Please try again.');
}
}
```
### Step 2: Update Booking Display Component
```javascript
function BookingCard({ booking }) {
const handleJoinMeeting = async () => {
try {
const response = await fetch(`/api/meetings/${booking.id}/link`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
const data = await response.json();
window.open(data.meeting_url, '_blank');
} catch (error) {
console.error('Error:', error);
}
};
return (
<div className="booking-card">
<h3>Booking #{booking.id}</h3>
<p>Scheduled: {new Date(booking.scheduled_at).toLocaleString()}</p>
<p>Duration: {booking.duration} minutes</p>
{booking.status === 'scheduled' && booking.jitsi_room_url && (
<button onClick={handleJoinMeeting}>
Join Meeting
</button>
)}
</div>
);
}
```
## How It Works
### For Regular Users
1. User calls `/api/meetings/:id/link` with their JWT token
2. Backend verifies the user owns the booking
3. Backend generates a personalized Jitsi URL with the user's name:
```
https://meet.jit.si/room-id#userInfo.displayName="John Doe"
```
4. User joins the meeting with their name pre-filled
### For Admin Users
1. Admin calls `/api/meetings/:id/link` with their JWT token
2. Backend verifies admin status (can access any booking)
3. Backend generates a personalized Jitsi URL with admin privileges:
```
https://meet.jit.si/room-id#userInfo.displayName="Dr. Smith"&config.startWithAudioMuted=false&config.startWithVideoMuted=false
```
4. Admin joins with moderator privileges and audio/video enabled by default
## Display Name Logic
The system determines the display name in this order:
1. **First Name + Last Name**: If both are available
2. **Email**: If name fields are empty
3. **Fallback**: Uses email as last resort
## Security Considerations
1. **Authentication Required**: All meeting link requests require valid JWT token
2. **Authorization Check**:
- Regular users can only access their own bookings
- Admin users can access any booking
3. **No Direct URL Sharing**: The personalized URL includes user-specific information
4. **Token Expiration**: JWT tokens expire, requiring re-authentication
## Migration Notes
### Existing Code
If your frontend currently uses:
```javascript
// OLD WAY - Don't use this anymore
<a href={booking.jitsi_room_url} target="_blank">Join Meeting</a>
```
### Updated Code
Change to:
```javascript
// NEW WAY - Use the API endpoint
<button onClick={() => joinMeeting(booking.id)}>Join Meeting</button>
```
## Testing
### Test as Regular User
1. Login as a regular user
2. Create a booking
3. Call `/api/meetings/{booking_id}/link`
4. Verify the response includes your name in the URL
5. Open the URL and confirm your name appears in Jitsi
### Test as Admin
1. Login as admin
2. Get any booking ID
3. Call `/api/meetings/{booking_id}/link`
4. Verify `is_admin: true` in response
5. Open the URL and confirm moderator privileges
### Test Authorization
1. Try to access another user's booking as a regular user
2. Should receive `403 Forbidden` error
3. Admin should be able to access any booking
## Troubleshooting
### Issue: Name shows as "Booking ID"
**Solution**: Ensure you're using the new `/api/meetings/:id/link` endpoint instead of the raw `jitsi_room_url`
### Issue: Admin doesn't have moderator privileges
**Solution**: Verify the user's `is_admin` field is set to `true` in the database
### Issue: 403 Forbidden error
**Solution**: Regular users can only access their own bookings. Verify the booking belongs to the authenticated user.
### Issue: Meeting URL not available
**Solution**: The booking might not have a Jitsi room created yet. This can happen if payment hasn't been completed or if there was an error during booking creation.
## Future Enhancements
Potential improvements for the future:
1. **Waiting Room**: Add a waiting room for participants before admin joins
2. **Recording Control**: Give admin ability to start/stop recording
3. **Participant Limits**: Enforce maximum participant limits
4. **Custom Branding**: Add custom Jitsi branding for the therapy practice
5. **Meeting Analytics**: Track join times, duration, and participant count

View File

@ -0,0 +1,323 @@
# Personalized Meeting Links Implementation
## Overview
All meeting links are now personalized with the user's name and role-specific settings. This ensures users join meetings with their actual names instead of generic identifiers, and admins get appropriate moderator privileges.
## Changes Made
### 1. API Endpoints with Personalized Links
#### GET /api/bookings
Returns the authenticated user's bookings with personalized meeting links.
**Response Example:**
```json
{
"bookings": [
{
"id": 123,
"user_id": 45,
"scheduled_at": "2024-01-15T10:00:00Z",
"duration": 60,
"status": "scheduled",
"jitsi_room_id": "booking-123-1234567890-abc123",
"jitsi_room_url": "https://meet.jit.si/booking-123-1234567890-abc123",
"personalized_meeting_url": "https://meet.jit.si/booking-123-1234567890-abc123#userInfo.displayName=\"John Doe\"",
"amount": 100.00,
"payment_status": "succeeded"
}
]
}
```
**Key Fields:**
- `jitsi_room_url`: Original Jitsi room URL (kept for backward compatibility)
- `personalized_meeting_url`: User-specific URL with their name pre-filled
#### GET /api/admin/bookings
Returns all bookings with admin's personalized meeting links.
**Response Example:**
```json
{
"bookings": [
{
"id": 123,
"user_id": 45,
"scheduled_at": "2024-01-15T10:00:00Z",
"duration": 60,
"status": "scheduled",
"jitsi_room_id": "booking-123-1234567890-abc123",
"jitsi_room_url": "https://meet.jit.si/booking-123-1234567890-abc123",
"admin_meeting_url": "https://meet.jit.si/booking-123-1234567890-abc123#userInfo.displayName=\"Dr. Smith\"&config.startWithAudioMuted=false&config.startWithVideoMuted=false",
"amount": 100.00,
"payment_status": "succeeded"
}
],
"total": 1,
"limit": 50,
"offset": 0
}
```
**Key Fields:**
- `admin_meeting_url`: Admin-specific URL with moderator privileges
#### GET /api/meetings/:id/link
Returns a personalized meeting link for a specific booking.
**Response Example:**
```json
{
"booking_id": 123,
"meeting_url": "https://meet.jit.si/booking-123-1234567890-abc123#userInfo.displayName=\"John Doe\"",
"display_name": "John Doe",
"is_admin": false,
"scheduled_at": "2024-01-15T10:00:00Z",
"duration": 60,
"status": "scheduled"
}
```
### 2. Email Notifications
All email notifications now include personalized meeting links:
#### Meeting Info Email (After Booking)
- Sent to: **User**
- Link includes: User's name
- Example: `https://meet.jit.si/room-id#userInfo.displayName="John Doe"`
#### Reminder Email (Before Meeting)
- Sent to: **User**
- Link includes: User's name
- Example: `https://meet.jit.si/room-id#userInfo.displayName="John Doe"`
**Note:** Admin users receive emails with admin privileges in the link if they book for themselves.
### 3. Display Name Logic
The system determines display names in this order:
1. **First Name + Last Name**: `"John Doe"`
2. **Email**: If name fields are empty, uses email address
3. **Fallback**: Email as last resort
### 4. Admin vs Regular User Differences
#### Regular User Link
```
https://meet.jit.si/room-id#userInfo.displayName="John Doe"
```
**Features:**
- Name pre-filled
- Standard participant permissions
- Default audio/video settings
#### Admin User Link
```
https://meet.jit.si/room-id#userInfo.displayName="Dr. Smith"&config.startWithAudioMuted=false&config.startWithVideoMuted=false
```
**Features:**
- Name pre-filled
- Moderator privileges
- Audio and video enabled by default
- Can manage participants
- Can start/stop recording (if configured)
## Frontend Implementation Guide
### Using Personalized Links from /api/bookings
```javascript
// Fetch user's bookings
async function fetchMyBookings() {
const response = await fetch('/api/bookings', {
headers: {
'Authorization': `Bearer ${userToken}`
}
});
const data = await response.json();
// Use personalized_meeting_url instead of jitsi_room_url
data.bookings.forEach(booking => {
if (booking.personalized_meeting_url) {
console.log(`Join link: ${booking.personalized_meeting_url}`);
}
});
}
```
### Using Admin Links from /api/admin/bookings
```javascript
// Fetch all bookings as admin
async function fetchAllBookings() {
const response = await fetch('/api/admin/bookings', {
headers: {
'Authorization': `Bearer ${adminToken}`
}
});
const data = await response.json();
// Use admin_meeting_url for admin access
data.bookings.forEach(booking => {
if (booking.admin_meeting_url) {
console.log(`Admin join link: ${booking.admin_meeting_url}`);
}
});
}
```
### React Component Example
```jsx
function BookingCard({ booking, isAdmin }) {
const getMeetingLink = () => {
if (isAdmin && booking.admin_meeting_url) {
return booking.admin_meeting_url;
}
return booking.personalized_meeting_url || booking.jitsi_room_url;
};
const handleJoinMeeting = () => {
const meetingLink = getMeetingLink();
window.open(meetingLink, '_blank');
};
return (
<div className="booking-card">
<h3>Booking #{booking.id}</h3>
<p>Scheduled: {new Date(booking.scheduled_at).toLocaleString()}</p>
{booking.status === 'scheduled' && (
<button onClick={handleJoinMeeting}>
{isAdmin ? 'Join as Moderator' : 'Join Meeting'}
</button>
)}
</div>
);
}
```
## Migration Guide
### Before (Old Implementation)
```javascript
// Direct use of jitsi_room_url
<a href={booking.jitsi_room_url} target="_blank">
Join Meeting
</a>
```
**Problems:**
- Users join as "Booking ID" or generic name
- No admin differentiation
- Same link for everyone
### After (New Implementation)
```javascript
// Use personalized_meeting_url for users
<button onClick={() => window.open(booking.personalized_meeting_url, '_blank')}>
Join Meeting
</button>
// Use admin_meeting_url for admins
<button onClick={() => window.open(booking.admin_meeting_url, '_blank')}>
Join as Moderator
</button>
```
**Benefits:**
- Users join with their actual names
- Admins get moderator privileges
- Better user experience
## Testing Checklist
### Test as Regular User
1. ✅ Login as regular user
2. ✅ Create a booking
3. ✅ Call `GET /api/bookings`
4. ✅ Verify `personalized_meeting_url` contains your name
5. ✅ Open the URL and confirm name appears in Jitsi
6. ✅ Check email notification has personalized link
### Test as Admin
1. ✅ Login as admin
2. ✅ Call `GET /api/admin/bookings`
3. ✅ Verify `admin_meeting_url` contains admin name and moderator config
4. ✅ Open the URL and confirm moderator privileges
5. ✅ Verify audio/video enabled by default
6. ✅ Check email notification has admin link (if admin books for themselves)
### Test Email Notifications
1. ✅ Create a booking
2. ✅ Check "Meeting Info" email
3. ✅ Verify "Join Meeting" button uses personalized link
4. ✅ Click link and confirm name is pre-filled
5. ✅ Wait for reminder email
6. ✅ Verify reminder also has personalized link
## Security Considerations
### Authorization
- Regular users can only access their own bookings via `/api/bookings`
- Admins can access all bookings via `/api/admin/bookings`
- `/api/meetings/:id/link` enforces ownership or admin check
### Link Sharing
- Personalized links include user-specific information
- Links are generated on-demand, not stored
- Original `jitsi_room_url` remains unchanged in database
### Token Expiration
- All endpoints require valid JWT authentication
- Expired tokens require re-authentication
- Meeting links remain valid regardless of token expiration
## Backward Compatibility
The `jitsi_room_url` field is still present in all responses for backward compatibility. However, frontends should migrate to using:
- `personalized_meeting_url` for regular users
- `admin_meeting_url` for admins
- `/api/meetings/:id/link` endpoint for dynamic link generation
## Troubleshooting
### Issue: Name still shows as "Booking ID"
**Solution:** Ensure you're using `personalized_meeting_url` or `admin_meeting_url` instead of `jitsi_room_url`
### Issue: Admin doesn't have moderator privileges
**Solution:**
1. Verify user's `is_admin` field is `true` in database
2. Check that `admin_meeting_url` includes moderator config parameters
3. Ensure using `/api/admin/bookings` endpoint, not `/api/bookings`
### Issue: Email links don't have personalized URLs
**Solution:**
1. Verify notification service is generating personalized URLs
2. Check email template uses `{{.JoinURL}}` field
3. Ensure user has valid first/last name or email
### Issue: Display name is email instead of name
**Solution:** Update user's `first_name` and `last_name` fields in the database
## Future Enhancements
Potential improvements:
1. **Custom Display Names**: Allow users to set custom display names
2. **Meeting Roles**: Support different roles beyond admin/user (e.g., observer, presenter)
3. **Link Expiration**: Add time-based expiration to meeting links
4. **Waiting Room**: Implement waiting room for participants before admin joins
5. **Custom Branding**: Add organization-specific branding to Jitsi interface

1030
docs/RESCHEDULING_GUIDE.md Normal file

File diff suppressed because it is too large Load Diff

891
docs/SCHEDULE_MANAGEMENT.md Normal file
View File

@ -0,0 +1,891 @@
# Schedule Management Guide
## Overview
The schedule system in Attune Heart Therapy API allows administrators to create time slots that clients can book for therapy sessions. This guide explains how schedule creation works and how to implement it in your frontend application.
## Understanding the Schedule System
### Schedule Model Structure
```go
type Schedule struct {
ID uint `json:"id"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
IsAvailable bool `json:"is_available"`
MaxBookings int `json:"max_bookings"`
BookedCount int `json:"booked_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
```
### Key Concepts
1. **Time Slots**: Each schedule represents a specific time period when therapy sessions can be booked
2. **Availability**: Schedules can be enabled/disabled using the `is_available` flag
3. **Capacity**: Each schedule can handle multiple bookings (useful for group sessions)
4. **Booking Count**: Tracks how many bookings have been made for each schedule
## Backend Implementation
### 1. Creating Schedules (Admin Only)
**Endpoint**: `POST /api/admin/schedules`
**Request Structure**:
```json
{
"start_time": "2024-12-15T10:00:00Z",
"end_time": "2024-12-15T11:00:00Z",
"max_bookings": 1
}
```
**Validation Rules**:
- `start_time` must be in the future
- `end_time` must be after `start_time`
- `max_bookings` must be at least 1
- No overlapping schedules allowed
**Response**:
```json
{
"message": "Schedule created successfully",
"schedule": {
"id": 1,
"start_time": "2024-12-15T10:00:00Z",
"end_time": "2024-12-15T11:00:00Z",
"is_available": true,
"max_bookings": 1,
"booked_count": 0,
"created_at": "2024-12-06T10:00:00Z",
"updated_at": "2024-12-06T10:00:00Z"
}
}
```
### 2. Updating Schedules
**Endpoint**: `PUT /api/admin/schedules/:id`
**Request Structure** (all fields optional):
```json
{
"start_time": "2024-12-15T14:00:00Z",
"end_time": "2024-12-15T15:00:00Z",
"max_bookings": 2,
"is_available": false
}
```
### 3. Getting Available Slots (Public)
**Endpoint**: `GET /api/schedules?date=2024-12-15`
**Response**:
```json
{
"date": "2024-12-15",
"slots": [
{
"id": 1,
"start_time": "2024-12-15T10:00:00Z",
"end_time": "2024-12-15T11:00:00Z",
"is_available": true,
"max_bookings": 1,
"booked_count": 0,
"remaining_slots": 1
}
]
}
```
## Frontend Implementation
### 1. Admin Schedule Management Interface
#### React Component Example
```jsx
import React, { useState, useEffect } from 'react';
import { format, addDays, startOfWeek } from 'date-fns';
const ScheduleManager = () => {
const [schedules, setSchedules] = useState([]);
const [selectedDate, setSelectedDate] = useState(new Date());
const [isCreating, setIsCreating] = useState(false);
const [newSchedule, setNewSchedule] = useState({
start_time: '',
end_time: '',
max_bookings: 1
});
// API service functions
const apiService = {
async createSchedule(scheduleData) {
const response = await fetch('/api/admin/schedules', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('authToken')}`
},
body: JSON.stringify(scheduleData)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to create schedule');
}
return response.json();
},
async getAvailableSlots(date) {
const dateStr = format(date, 'yyyy-MM-dd');
const response = await fetch(`/api/schedules?date=${dateStr}`);
if (!response.ok) {
throw new Error('Failed to fetch schedules');
}
return response.json();
},
async updateSchedule(scheduleId, updateData) {
const response = await fetch(`/api/admin/schedules/${scheduleId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('authToken')}`
},
body: JSON.stringify(updateData)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to update schedule');
}
return response.json();
}
};
// Load schedules for selected date
useEffect(() => {
loadSchedules();
}, [selectedDate]);
const loadSchedules = async () => {
try {
const data = await apiService.getAvailableSlots(selectedDate);
setSchedules(data.slots || []);
} catch (error) {
console.error('Error loading schedules:', error);
setSchedules([]);
}
};
const handleCreateSchedule = async (e) => {
e.preventDefault();
try {
// Convert local time to UTC
const startTime = new Date(newSchedule.start_time).toISOString();
const endTime = new Date(newSchedule.end_time).toISOString();
await apiService.createSchedule({
start_time: startTime,
end_time: endTime,
max_bookings: parseInt(newSchedule.max_bookings)
});
// Reset form and reload schedules
setNewSchedule({
start_time: '',
end_time: '',
max_bookings: 1
});
setIsCreating(false);
await loadSchedules();
alert('Schedule created successfully!');
} catch (error) {
alert(`Error creating schedule: ${error.message}`);
}
};
const handleToggleAvailability = async (scheduleId, currentAvailability) => {
try {
await apiService.updateSchedule(scheduleId, {
is_available: !currentAvailability
});
await loadSchedules();
} catch (error) {
alert(`Error updating schedule: ${error.message}`);
}
};
const generateTimeSlots = () => {
const slots = [];
for (let hour = 9; hour < 18; hour++) {
for (let minute = 0; minute < 60; minute += 30) {
const time = `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
slots.push(time);
}
}
return slots;
};
const formatDateTime = (dateTime) => {
return format(new Date(dateTime), 'MMM dd, yyyy HH:mm');
};
return (
<div className="schedule-manager">
<div className="header">
<h2>Schedule Management</h2>
<button
onClick={() => setIsCreating(true)}
className="btn btn-primary"
>
Create New Schedule
</button>
</div>
{/* Date Selector */}
<div className="date-selector">
<label>Select Date:</label>
<input
type="date"
value={format(selectedDate, 'yyyy-MM-dd')}
onChange={(e) => setSelectedDate(new Date(e.target.value))}
className="form-control"
/>
</div>
{/* Create Schedule Form */}
{isCreating && (
<div className="create-schedule-form">
<h3>Create New Schedule</h3>
<form onSubmit={handleCreateSchedule}>
<div className="form-group">
<label>Start Time:</label>
<input
type="datetime-local"
value={newSchedule.start_time}
onChange={(e) => setNewSchedule({
...newSchedule,
start_time: e.target.value
})}
required
className="form-control"
/>
</div>
<div className="form-group">
<label>End Time:</label>
<input
type="datetime-local"
value={newSchedule.end_time}
onChange={(e) => setNewSchedule({
...newSchedule,
end_time: e.target.value
})}
required
className="form-control"
/>
</div>
<div className="form-group">
<label>Max Bookings:</label>
<input
type="number"
min="1"
max="10"
value={newSchedule.max_bookings}
onChange={(e) => setNewSchedule({
...newSchedule,
max_bookings: e.target.value
})}
required
className="form-control"
/>
</div>
<div className="form-actions">
<button type="submit" className="btn btn-success">
Create Schedule
</button>
<button
type="button"
onClick={() => setIsCreating(false)}
className="btn btn-secondary"
>
Cancel
</button>
</div>
</form>
</div>
)}
{/* Schedules List */}
<div className="schedules-list">
<h3>Schedules for {format(selectedDate, 'MMMM dd, yyyy')}</h3>
{schedules.length === 0 ? (
<p className="no-schedules">No schedules found for this date.</p>
) : (
<div className="schedules-grid">
{schedules.map((schedule) => (
<div key={schedule.id} className="schedule-card">
<div className="schedule-time">
{format(new Date(schedule.start_time), 'HH:mm')} -
{format(new Date(schedule.end_time), 'HH:mm')}
</div>
<div className="schedule-details">
<span className="capacity">
{schedule.booked_count}/{schedule.max_bookings} booked
</span>
<span className={`status ${schedule.is_available ? 'available' : 'unavailable'}`}>
{schedule.is_available ? 'Available' : 'Unavailable'}
</span>
</div>
<div className="schedule-actions">
<button
onClick={() => handleToggleAvailability(schedule.id, schedule.is_available)}
className={`btn btn-sm ${schedule.is_available ? 'btn-warning' : 'btn-success'}`}
>
{schedule.is_available ? 'Disable' : 'Enable'}
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
};
export default ScheduleManager;
```
#### CSS Styles
```css
.schedule-manager {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
}
.date-selector {
margin-bottom: 20px;
}
.date-selector label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.create-schedule-form {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
margin-bottom: 30px;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.form-control {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.form-actions {
display: flex;
gap: 10px;
margin-top: 20px;
}
.schedules-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
}
.schedule-card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 15px;
background: white;
}
.schedule-time {
font-size: 18px;
font-weight: bold;
margin-bottom: 10px;
color: #333;
}
.schedule-details {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.capacity {
font-size: 14px;
color: #666;
}
.status {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
text-transform: uppercase;
}
.status.available {
background: #d4edda;
color: #155724;
}
.status.unavailable {
background: #f8d7da;
color: #721c24;
}
.schedule-actions {
text-align: right;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
text-decoration: none;
display: inline-block;
}
.btn-primary {
background: #007bff;
color: white;
}
.btn-success {
background: #28a745;
color: white;
}
.btn-warning {
background: #ffc107;
color: #212529;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-sm {
padding: 4px 8px;
font-size: 12px;
}
.no-schedules {
text-align: center;
color: #666;
font-style: italic;
padding: 40px;
}
```
### 2. Client Booking Interface
#### React Component for Booking
```jsx
import React, { useState, useEffect } from 'react';
import { format, addDays, startOfWeek } from 'date-fns';
const BookingInterface = () => {
const [selectedDate, setSelectedDate] = useState(new Date());
const [availableSlots, setAvailableSlots] = useState([]);
const [selectedSlot, setSelectedSlot] = useState(null);
const [bookingNotes, setBookingNotes] = useState('');
const [isLoading, setIsLoading] = useState(false);
const apiService = {
async getAvailableSlots(date) {
const dateStr = format(date, 'yyyy-MM-dd');
const response = await fetch(`/api/schedules?date=${dateStr}`);
if (!response.ok) {
throw new Error('Failed to fetch available slots');
}
return response.json();
},
async createBooking(scheduleId, notes) {
const response = await fetch('/api/bookings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('authToken')}`
},
body: JSON.stringify({
schedule_id: scheduleId,
duration: 60,
notes: notes,
amount: 150.00 // $150 per session
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to create booking');
}
return response.json();
}
};
useEffect(() => {
loadAvailableSlots();
}, [selectedDate]);
const loadAvailableSlots = async () => {
setIsLoading(true);
try {
const data = await apiService.getAvailableSlots(selectedDate);
setAvailableSlots(data.slots || []);
} catch (error) {
console.error('Error loading available slots:', error);
setAvailableSlots([]);
} finally {
setIsLoading(false);
}
};
const handleBooking = async () => {
if (!selectedSlot) {
alert('Please select a time slot');
return;
}
try {
setIsLoading(true);
const result = await apiService.createBooking(selectedSlot.id, bookingNotes);
alert('Booking created successfully! You will receive payment instructions shortly.');
// Reset form
setSelectedSlot(null);
setBookingNotes('');
// Reload available slots
await loadAvailableSlots();
} catch (error) {
alert(`Error creating booking: ${error.message}`);
} finally {
setIsLoading(false);
}
};
const getNextSevenDays = () => {
const days = [];
for (let i = 0; i < 7; i++) {
days.push(addDays(new Date(), i));
}
return days;
};
return (
<div className="booking-interface">
<h2>Book Your Therapy Session</h2>
{/* Date Selection */}
<div className="date-selection">
<h3>Select Date</h3>
<div className="date-buttons">
{getNextSevenDays().map((date) => (
<button
key={date.toISOString()}
onClick={() => setSelectedDate(date)}
className={`date-btn ${
format(date, 'yyyy-MM-dd') === format(selectedDate, 'yyyy-MM-dd')
? 'active'
: ''
}`}
>
<div className="day-name">{format(date, 'EEE')}</div>
<div className="day-number">{format(date, 'dd')}</div>
<div className="month-name">{format(date, 'MMM')}</div>
</button>
))}
</div>
</div>
{/* Time Slot Selection */}
<div className="time-selection">
<h3>Available Times for {format(selectedDate, 'MMMM dd, yyyy')}</h3>
{isLoading ? (
<div className="loading">Loading available times...</div>
) : availableSlots.length === 0 ? (
<div className="no-slots">
No available time slots for this date. Please select another date.
</div>
) : (
<div className="time-slots">
{availableSlots.map((slot) => (
<button
key={slot.id}
onClick={() => setSelectedSlot(slot)}
className={`time-slot ${selectedSlot?.id === slot.id ? 'selected' : ''}`}
disabled={slot.remaining_slots === 0}
>
<div className="time">
{format(new Date(slot.start_time), 'HH:mm')} -
{format(new Date(slot.end_time), 'HH:mm')}
</div>
<div className="availability">
{slot.remaining_slots > 0
? `${slot.remaining_slots} slot${slot.remaining_slots > 1 ? 's' : ''} available`
: 'Fully booked'
}
</div>
</button>
))}
</div>
)}
</div>
{/* Booking Notes */}
{selectedSlot && (
<div className="booking-details">
<h3>Booking Details</h3>
<div className="selected-time">
<strong>Selected Time:</strong> {format(selectedDate, 'MMMM dd, yyyy')} at{' '}
{format(new Date(selectedSlot.start_time), 'HH:mm')} -
{format(new Date(selectedSlot.end_time), 'HH:mm')}
</div>
<div className="form-group">
<label htmlFor="notes">Session Notes (Optional):</label>
<textarea
id="notes"
value={bookingNotes}
onChange={(e) => setBookingNotes(e.target.value)}
placeholder="Any specific topics or concerns you'd like to discuss..."
rows="4"
className="form-control"
/>
</div>
<div className="booking-actions">
<button
onClick={handleBooking}
disabled={isLoading}
className="btn btn-primary btn-large"
>
{isLoading ? 'Creating Booking...' : 'Book Session ($150)'}
</button>
<button
onClick={() => setSelectedSlot(null)}
className="btn btn-secondary"
>
Cancel
</button>
</div>
</div>
)}
</div>
);
};
export default BookingInterface;
```
### 3. Bulk Schedule Creation
For creating multiple schedules efficiently:
```jsx
const BulkScheduleCreator = () => {
const [scheduleTemplate, setScheduleTemplate] = useState({
startDate: '',
endDate: '',
startTime: '09:00',
endTime: '10:00',
maxBookings: 1,
daysOfWeek: [1, 2, 3, 4, 5] // Monday to Friday
});
const createBulkSchedules = async () => {
const schedules = [];
const startDate = new Date(scheduleTemplate.startDate);
const endDate = new Date(scheduleTemplate.endDate);
for (let date = new Date(startDate); date <= endDate; date.setDate(date.getDate() + 1)) {
const dayOfWeek = date.getDay();
if (scheduleTemplate.daysOfWeek.includes(dayOfWeek)) {
const startDateTime = new Date(date);
const [startHour, startMinute] = scheduleTemplate.startTime.split(':');
startDateTime.setHours(parseInt(startHour), parseInt(startMinute), 0, 0);
const endDateTime = new Date(date);
const [endHour, endMinute] = scheduleTemplate.endTime.split(':');
endDateTime.setHours(parseInt(endHour), parseInt(endMinute), 0, 0);
schedules.push({
start_time: startDateTime.toISOString(),
end_time: endDateTime.toISOString(),
max_bookings: scheduleTemplate.maxBookings
});
}
}
// Create schedules one by one
for (const schedule of schedules) {
try {
await apiService.createSchedule(schedule);
} catch (error) {
console.error('Error creating schedule:', error);
}
}
alert(`Created ${schedules.length} schedules successfully!`);
};
return (
<div className="bulk-schedule-creator">
<h3>Bulk Schedule Creation</h3>
{/* Form fields for bulk creation */}
<button onClick={createBulkSchedules} className="btn btn-primary">
Create Schedules
</button>
</div>
);
};
```
## Common Issues and Solutions
### 1. Empty Slots Response
**Problem**: Getting `{"date": "2025-11-07", "slots": null}`
**Causes**:
- No schedules created for that date
- Database connection issues
- Repository method not implemented
**Solution**:
```javascript
// First, create some schedules as admin
const createSampleSchedules = async () => {
const schedules = [
{
start_time: "2025-11-07T09:00:00Z",
end_time: "2025-11-07T10:00:00Z",
max_bookings: 1
},
{
start_time: "2025-11-07T14:00:00Z",
end_time: "2025-11-07T15:00:00Z",
max_bookings: 1
}
];
for (const schedule of schedules) {
await fetch('/api/admin/schedules', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${adminToken}`
},
body: JSON.stringify(schedule)
});
}
};
```
### 2. Time Zone Handling
Always use UTC times in the API and convert to local time in the frontend:
```javascript
// Convert local time to UTC for API
const localToUTC = (localDateTime) => {
return new Date(localDateTime).toISOString();
};
// Convert UTC time from API to local time for display
const utcToLocal = (utcDateTime) => {
return new Date(utcDateTime);
};
```
### 3. Validation Errors
Handle common validation errors gracefully:
```javascript
const handleScheduleError = (error) => {
switch (error.message) {
case 'end time must be after start time':
return 'Please ensure the end time is after the start time';
case 'cannot create schedule slots in the past':
return 'Cannot create schedules for past dates';
case 'max bookings must be at least 1':
return 'Maximum bookings must be at least 1';
default:
return `Error: ${error.message}`;
}
};
```
## Best Practices
1. **Always validate dates on the frontend** before sending to the API
2. **Use UTC times** for all API communications
3. **Implement proper error handling** for network failures
4. **Show loading states** during API calls
5. **Refresh data** after successful operations
6. **Use optimistic updates** where appropriate
7. **Implement proper authentication** checks before admin operations
This guide should help you implement a complete schedule management system for your therapy booking application.

View File

@ -35,6 +35,7 @@ type Container struct {
BookingHandler *handlers.BookingHandler BookingHandler *handlers.BookingHandler
PaymentHandler *handlers.PaymentHandler PaymentHandler *handlers.PaymentHandler
AdminHandler *handlers.AdminHandler AdminHandler *handlers.AdminHandler
MeetingHandler *handlers.MeetingHandler
} }
// New creates a new dependency injection container // New creates a new dependency injection container
@ -112,8 +113,8 @@ func (c *Container) initializeServices() error {
// Initialize Jitsi service (no dependencies) // Initialize Jitsi service (no dependencies)
c.JitsiService = services.NewJitsiService(&c.Config.Jitsi) c.JitsiService = services.NewJitsiService(&c.Config.Jitsi)
// Initialize notification service (depends on notification repository and config) // Initialize notification service (depends on notification repository, config, and jitsi service)
c.NotificationService = services.NewNotificationService(c.Repositories.Notification, c.Config) c.NotificationService = services.NewNotificationService(c.Repositories.Notification, c.Config, c.JitsiService)
// Initialize user service (depends on user repository, JWT service, and notification service) // Initialize user service (depends on user repository, JWT service, and notification service)
c.UserService = services.NewUserService(c.Repositories.User, c.JWTService, c.NotificationService) c.UserService = services.NewUserService(c.Repositories.User, c.JWTService, c.NotificationService)
@ -154,9 +155,10 @@ func (c *Container) initializeHandlers() {
c.Log.Info("Initializing handlers...") c.Log.Info("Initializing handlers...")
c.AuthHandler = handlers.NewAuthHandler(c.UserService) c.AuthHandler = handlers.NewAuthHandler(c.UserService)
c.BookingHandler = handlers.NewBookingHandler(c.BookingService) c.BookingHandler = handlers.NewBookingHandler(c.BookingService, c.Repositories.User, c.JitsiService)
c.PaymentHandler = handlers.NewPaymentHandler(c.PaymentService) c.PaymentHandler = handlers.NewPaymentHandler(c.PaymentService)
c.AdminHandler = handlers.NewAdminHandler(c.AdminService) c.AdminHandler = handlers.NewAdminHandler(c.AdminService, c.Repositories.User, c.JitsiService)
c.MeetingHandler = handlers.NewMeetingHandler(c.Repositories.Booking, c.Repositories.User, c.JitsiService)
c.Log.Info("Handlers initialized successfully") c.Log.Info("Handlers initialized successfully")
} }

View File

@ -1,10 +1,14 @@
package handlers package handlers
import ( import (
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"time" "time"
"attune-heart-therapy/internal/middleware"
"attune-heart-therapy/internal/models"
"attune-heart-therapy/internal/repositories"
"attune-heart-therapy/internal/services" "attune-heart-therapy/internal/services"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@ -12,14 +16,61 @@ import (
type AdminHandler struct { type AdminHandler struct {
adminService services.AdminService adminService services.AdminService
userRepo repositories.UserRepository
jitsiService services.JitsiService
} }
func NewAdminHandler(adminService services.AdminService) *AdminHandler { func NewAdminHandler(adminService services.AdminService, userRepo repositories.UserRepository, jitsiService services.JitsiService) *AdminHandler {
return &AdminHandler{ return &AdminHandler{
adminService: adminService, adminService: adminService,
userRepo: userRepo,
jitsiService: jitsiService,
} }
} }
// AdminBookingResponse represents a booking with admin's personalized meeting link
type AdminBookingResponse struct {
models.Booking
AdminMeetingURL string `json:"admin_meeting_url,omitempty"`
}
// addAdminLink adds an admin's personalized meeting link to a booking
func (h *AdminHandler) addAdminLink(booking *models.Booking, admin *models.User) AdminBookingResponse {
response := AdminBookingResponse{
Booking: *booking,
}
// Only add personalized link if Jitsi URL exists
if booking.JitsiRoomURL != "" && booking.JitsiRoomID != "" {
displayName := fmt.Sprintf("%s %s", admin.FirstName, admin.LastName)
if displayName == " " || displayName == "" {
displayName = admin.Email
}
// Generate JWT token for admin with moderator privileges
token, err := h.jitsiService.GenerateJitsiToken(
booking.JitsiRoomID,
displayName,
admin.Email,
true, // Always true for admin
)
var adminURL string
if err == nil && token != "" {
// Use JWT token if available
adminURL = fmt.Sprintf("%s?jwt=%s", booking.JitsiRoomURL, token)
} else {
// Fallback to URL parameters
adminURL = fmt.Sprintf("%s#userInfo.displayName=\"%s\"&config.startWithAudioMuted=false&config.startWithVideoMuted=false",
booking.JitsiRoomURL, displayName)
}
response.AdminMeetingURL = adminURL
}
return response
}
// GetDashboard handles GET /api/admin/dashboard for dashboard statistics // GetDashboard handles GET /api/admin/dashboard for dashboard statistics
func (h *AdminHandler) GetDashboard(c *gin.Context) { func (h *AdminHandler) GetDashboard(c *gin.Context) {
stats, err := h.adminService.GetDashboardStats() stats, err := h.adminService.GetDashboardStats()
@ -118,6 +169,25 @@ func (h *AdminHandler) GetUsers(c *gin.Context) {
// GetBookings handles GET /api/admin/bookings for retrieving all bookings // GetBookings handles GET /api/admin/bookings for retrieving all bookings
func (h *AdminHandler) GetBookings(c *gin.Context) { func (h *AdminHandler) GetBookings(c *gin.Context) {
// Get admin user ID from JWT token
adminID, exists := middleware.GetUserIDFromContext(c)
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "User not authenticated",
})
return
}
// Get admin user details
admin, err := h.userRepo.GetByID(adminID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to get admin information",
"details": err.Error(),
})
return
}
// Parse pagination parameters // Parse pagination parameters
limitStr := c.DefaultQuery("limit", "50") limitStr := c.DefaultQuery("limit", "50")
offsetStr := c.DefaultQuery("offset", "0") offsetStr := c.DefaultQuery("offset", "0")
@ -142,8 +212,14 @@ func (h *AdminHandler) GetBookings(c *gin.Context) {
return return
} }
// Add admin's personalized meeting links to each booking
bookingResponses := make([]AdminBookingResponse, len(bookings))
for i, booking := range bookings {
bookingResponses[i] = h.addAdminLink(&booking, admin)
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"bookings": bookings, "bookings": bookingResponses,
"total": total, "total": total,
"limit": limit, "limit": limit,
"offset": offset, "offset": offset,

View File

@ -1,11 +1,14 @@
package handlers package handlers
import ( import (
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"time" "time"
"attune-heart-therapy/internal/middleware" "attune-heart-therapy/internal/middleware"
"attune-heart-therapy/internal/models"
"attune-heart-therapy/internal/repositories"
"attune-heart-therapy/internal/services" "attune-heart-therapy/internal/services"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@ -13,14 +16,63 @@ import (
type BookingHandler struct { type BookingHandler struct {
bookingService services.BookingService bookingService services.BookingService
userRepo repositories.UserRepository
jitsiService services.JitsiService
} }
func NewBookingHandler(bookingService services.BookingService) *BookingHandler { func NewBookingHandler(bookingService services.BookingService, userRepo repositories.UserRepository, jitsiService services.JitsiService) *BookingHandler {
return &BookingHandler{ return &BookingHandler{
bookingService: bookingService, bookingService: bookingService,
userRepo: userRepo,
jitsiService: jitsiService,
} }
} }
// BookingResponse represents a booking with personalized meeting link
type BookingResponse struct {
models.Booking
PersonalizedMeetingURL string `json:"personalized_meeting_url,omitempty"`
}
// addPersonalizedLink adds a personalized meeting link to a booking
func (h *BookingHandler) addPersonalizedLink(booking *models.Booking, user *models.User) BookingResponse {
response := BookingResponse{
Booking: *booking,
}
// Only add personalized link if Jitsi URL exists
if booking.JitsiRoomURL != "" && booking.JitsiRoomID != "" {
displayName := fmt.Sprintf("%s %s", user.FirstName, user.LastName)
if displayName == " " || displayName == "" {
displayName = user.Email
}
// Generate JWT token for Jitsi authentication
token, err := h.jitsiService.GenerateJitsiToken(
booking.JitsiRoomID,
displayName,
user.Email,
user.IsAdmin,
)
var personalizedURL string
if err == nil && token != "" {
// Use JWT token if available
personalizedURL = fmt.Sprintf("%s?jwt=%s", booking.JitsiRoomURL, token)
} else {
// Fallback to URL parameters
personalizedURL = fmt.Sprintf("%s#userInfo.displayName=\"%s\"", booking.JitsiRoomURL, displayName)
if user.IsAdmin {
personalizedURL = fmt.Sprintf("%s&config.startWithAudioMuted=false&config.startWithVideoMuted=false", personalizedURL)
}
}
response.PersonalizedMeetingURL = personalizedURL
}
return response
}
// GetAvailableSlots handles GET /api/schedules for available slots // GetAvailableSlots handles GET /api/schedules for available slots
func (h *BookingHandler) GetAvailableSlots(c *gin.Context) { func (h *BookingHandler) GetAvailableSlots(c *gin.Context) {
// Get date parameter from query string // Get date parameter from query string
@ -122,6 +174,16 @@ func (h *BookingHandler) GetUserBookings(c *gin.Context) {
return return
} }
// Get user details
user, err := h.userRepo.GetByID(userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to get user information",
"details": err.Error(),
})
return
}
// Get user's bookings // Get user's bookings
bookings, err := h.bookingService.GetUserBookings(userID) bookings, err := h.bookingService.GetUserBookings(userID)
if err != nil { if err != nil {
@ -132,8 +194,14 @@ func (h *BookingHandler) GetUserBookings(c *gin.Context) {
return return
} }
// Add personalized meeting links to each booking
bookingResponses := make([]BookingResponse, len(bookings))
for i, booking := range bookings {
bookingResponses[i] = h.addPersonalizedLink(&booking, user)
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"bookings": bookings, "bookings": bookingResponses,
}) })
} }

View File

@ -0,0 +1,130 @@
package handlers
import (
"fmt"
"net/http"
"strconv"
"attune-heart-therapy/internal/middleware"
"attune-heart-therapy/internal/repositories"
"attune-heart-therapy/internal/services"
"github.com/gin-gonic/gin"
)
type MeetingHandler struct {
bookingRepo repositories.BookingRepository
userRepo repositories.UserRepository
jitsiService services.JitsiService
}
func NewMeetingHandler(bookingRepo repositories.BookingRepository, userRepo repositories.UserRepository, jitsiService services.JitsiService) *MeetingHandler {
return &MeetingHandler{
bookingRepo: bookingRepo,
userRepo: userRepo,
jitsiService: jitsiService,
}
}
// GetMeetingLink handles GET /api/meetings/:id/link - returns personalized meeting link
func (h *MeetingHandler) GetMeetingLink(c *gin.Context) {
// Get booking ID from URL parameter
bookingIDStr := c.Param("id")
bookingID, err := strconv.ParseUint(bookingIDStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Invalid booking ID",
})
return
}
// Get user ID from JWT token
userID, exists := middleware.GetUserIDFromContext(c)
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "User not authenticated",
})
return
}
// Get booking details
booking, err := h.bookingRepo.GetByID(uint(bookingID))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"error": "Booking not found",
})
return
}
// Verify booking belongs to user (or user is admin)
user, err := h.userRepo.GetByID(userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to retrieve user information",
})
return
}
// Check authorization: user must own the booking or be an admin
if booking.UserID != userID && !user.IsAdmin {
c.JSON(http.StatusForbidden, gin.H{
"error": "You don't have permission to access this meeting",
})
return
}
// Check if meeting URL exists
if booking.JitsiRoomURL == "" {
c.JSON(http.StatusNotFound, gin.H{
"error": "Meeting link not available for this booking",
})
return
}
// Prepare display name
displayName := fmt.Sprintf("%s %s", user.FirstName, user.LastName)
if displayName == " " || displayName == "" {
displayName = user.Email
}
// Determine if user is admin
isAdmin := user.IsAdmin
// Generate JWT token for Jitsi authentication
token, err := h.jitsiService.GenerateJitsiToken(
booking.JitsiRoomID,
displayName,
user.Email,
isAdmin,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to generate meeting token",
"details": err.Error(),
})
return
}
// Build Jitsi URL with JWT token
var jitsiURL string
if token != "" {
// If JWT is configured, append token as query parameter
jitsiURL = fmt.Sprintf("%s?jwt=%s", booking.JitsiRoomURL, token)
} else {
// Fallback to URL parameters if JWT not configured
jitsiURL = fmt.Sprintf("%s#userInfo.displayName=\"%s\"", booking.JitsiRoomURL, displayName)
if isAdmin {
jitsiURL = fmt.Sprintf("%s&config.startWithAudioMuted=false&config.startWithVideoMuted=false", jitsiURL)
}
}
c.JSON(http.StatusOK, gin.H{
"booking_id": booking.ID,
"meeting_url": jitsiURL,
"display_name": displayName,
"is_admin": isAdmin,
"scheduled_at": booking.ScheduledAt,
"duration": booking.Duration,
"status": booking.Status,
})
}

View File

@ -229,6 +229,12 @@ func (s *Server) setupRoutes() {
bookings.PUT("/:id/reschedule", s.container.BookingHandler.RescheduleBooking) bookings.PUT("/:id/reschedule", s.container.BookingHandler.RescheduleBooking)
} }
// Meeting routes - require authentication
meetings := authenticated.Group("/meetings")
{
meetings.GET("/:id/link", s.container.MeetingHandler.GetMeetingLink)
}
// Payment routes - require authentication (except webhook) // Payment routes - require authentication (except webhook)
payments := authenticated.Group("/payments") payments := authenticated.Group("/payments")
{ {

View File

@ -48,6 +48,7 @@ type JitsiService interface {
CreateMeeting(bookingID uint, scheduledAt time.Time) (*JitsiMeeting, error) CreateMeeting(bookingID uint, scheduledAt time.Time) (*JitsiMeeting, error)
GetMeetingURL(roomID string) string GetMeetingURL(roomID string) string
DeleteMeeting(roomID string) error DeleteMeeting(roomID string) error
GenerateJitsiToken(roomName, userName, userEmail string, isModerator bool) (string, error)
} }
// AdminService handles admin dashboard operations // AdminService handles admin dashboard operations

View File

@ -8,6 +8,8 @@ import (
"time" "time"
"attune-heart-therapy/internal/config" "attune-heart-therapy/internal/config"
"github.com/golang-jwt/jwt/v5"
) )
// jitsiService implements the JitsiService interface // jitsiService implements the JitsiService interface
@ -22,6 +24,25 @@ func NewJitsiService(cfg *config.JitsiConfig) JitsiService {
} }
} }
// JitsiClaims represents the JWT claims for Jitsi authentication
type JitsiClaims struct {
Context JitsiContext `json:"context"`
Room string `json:"room"`
jwt.RegisteredClaims
}
// JitsiContext contains user context for Jitsi
type JitsiContext struct {
User JitsiUser `json:"user"`
}
// JitsiUser contains user information for Jitsi
type JitsiUser struct {
Name string `json:"name"`
Email string `json:"email"`
Moderator string `json:"moderator"` // "true" or "false" as string
}
// CreateMeeting creates a new Jitsi meeting room for a booking // CreateMeeting creates a new Jitsi meeting room for a booking
func (j *jitsiService) CreateMeeting(bookingID uint, scheduledAt time.Time) (*JitsiMeeting, error) { func (j *jitsiService) CreateMeeting(bookingID uint, scheduledAt time.Time) (*JitsiMeeting, error) {
// Generate a unique room ID // Generate a unique room ID
@ -31,7 +52,7 @@ func (j *jitsiService) CreateMeeting(bookingID uint, scheduledAt time.Time) (*Ji
return nil, fmt.Errorf("failed to generate room ID: %w", err) return nil, fmt.Errorf("failed to generate room ID: %w", err)
} }
// Generate the meeting URL // Generate the meeting URL (base URL without token)
roomURL := j.GetMeetingURL(roomID) roomURL := j.GetMeetingURL(roomID)
meeting := &JitsiMeeting{ meeting := &JitsiMeeting{
@ -73,6 +94,51 @@ func (j *jitsiService) DeleteMeeting(roomID string) error {
return nil return nil
} }
// GenerateJitsiToken generates a JWT token for Jitsi authentication
func (j *jitsiService) GenerateJitsiToken(roomName, userName, userEmail string, isModerator bool) (string, error) {
// Check if JWT configuration is available
if j.config.APIKey == "" || j.config.AppID == "" {
log.Printf("Jitsi JWT not configured, returning empty token")
return "", nil // Return empty token if not configured
}
// Determine moderator status as string
moderatorStatus := "false"
if isModerator {
moderatorStatus = "true"
}
// Create claims
claims := JitsiClaims{
Context: JitsiContext{
User: JitsiUser{
Name: userName,
Email: userEmail,
Moderator: moderatorStatus,
},
},
Room: roomName,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: j.config.AppID,
Subject: j.config.BaseURL,
Audience: jwt.ClaimStrings{"jitsi"},
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), // 24 hour expiry
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
// Create token
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
// Sign token with secret key
tokenString, err := token.SignedString([]byte(j.config.APIKey))
if err != nil {
return "", fmt.Errorf("failed to sign JWT token: %w", err)
}
return tokenString, nil
}
// generateRoomID creates a unique room identifier for the meeting // generateRoomID creates a unique room identifier for the meeting
func (j *jitsiService) generateRoomID(bookingID uint) (string, error) { func (j *jitsiService) generateRoomID(bookingID uint) (string, error) {
// Generate a random component for uniqueness // Generate a random component for uniqueness

View File

@ -19,10 +19,11 @@ type notificationService struct {
templateService *templates.EmailTemplateService templateService *templates.EmailTemplateService
config *config.Config config *config.Config
dialer *gomail.Dialer dialer *gomail.Dialer
jitsiService JitsiService
} }
// NewNotificationService creates a new instance of NotificationService // NewNotificationService creates a new instance of NotificationService
func NewNotificationService(notificationRepo repositories.NotificationRepository, cfg *config.Config) NotificationService { func NewNotificationService(notificationRepo repositories.NotificationRepository, cfg *config.Config, jitsiService JitsiService) NotificationService {
dialer := gomail.NewDialer( dialer := gomail.NewDialer(
cfg.SMTP.Host, cfg.SMTP.Host,
cfg.SMTP.Port, cfg.SMTP.Port,
@ -35,6 +36,7 @@ func NewNotificationService(notificationRepo repositories.NotificationRepository
templateService: templates.NewEmailTemplateService(), templateService: templates.NewEmailTemplateService(),
config: cfg, config: cfg,
dialer: dialer, dialer: dialer,
jitsiService: jitsiService,
} }
} }
@ -101,9 +103,13 @@ func (s *notificationService) SendPaymentNotification(user *models.User, booking
// SendMeetingInfo sends meeting information to user after successful booking // SendMeetingInfo sends meeting information to user after successful booking
func (s *notificationService) SendMeetingInfo(user *models.User, booking *models.Booking) error { func (s *notificationService) SendMeetingInfo(user *models.User, booking *models.Booking) error {
// Generate personalized meeting URL for the user
personalizedURL := s.generatePersonalizedMeetingURL(user, booking)
templateData := templates.TemplateData{ templateData := templates.TemplateData{
User: user, User: user,
Booking: booking, Booking: booking,
JoinURL: personalizedURL,
} }
emailTemplate, err := s.templateService.RenderTemplate(models.NotificationTypeMeetingInfo, templateData) emailTemplate, err := s.templateService.RenderTemplate(models.NotificationTypeMeetingInfo, templateData)
@ -129,10 +135,14 @@ func (s *notificationService) SendMeetingInfo(user *models.User, booking *models
// SendReminder sends a reminder notification to user before their meeting // SendReminder sends a reminder notification to user before their meeting
func (s *notificationService) SendReminder(user *models.User, booking *models.Booking) error { func (s *notificationService) SendReminder(user *models.User, booking *models.Booking) error {
// Generate personalized meeting URL for the user
personalizedURL := s.generatePersonalizedMeetingURL(user, booking)
templateData := templates.TemplateData{ templateData := templates.TemplateData{
User: user, User: user,
Booking: booking, Booking: booking,
ReminderText: templates.GetReminderText(booking.ScheduledAt), ReminderText: templates.GetReminderText(booking.ScheduledAt),
JoinURL: personalizedURL,
} }
emailTemplate, err := s.templateService.RenderTemplate(models.NotificationTypeReminder, templateData) emailTemplate, err := s.templateService.RenderTemplate(models.NotificationTypeReminder, templateData)
@ -214,6 +224,41 @@ func (s *notificationService) ProcessPendingNotifications() error {
return nil return nil
} }
// generatePersonalizedMeetingURL creates a personalized Jitsi URL for a user
func (s *notificationService) generatePersonalizedMeetingURL(user *models.User, booking *models.Booking) string {
if booking.JitsiRoomURL == "" || booking.JitsiRoomID == "" {
return ""
}
// Prepare display name
displayName := fmt.Sprintf("%s %s", user.FirstName, user.LastName)
if displayName == " " || displayName == "" {
displayName = user.Email
}
// Generate JWT token for Jitsi authentication
token, err := s.jitsiService.GenerateJitsiToken(
booking.JitsiRoomID,
displayName,
user.Email,
user.IsAdmin,
)
var personalizedURL string
if err == nil && token != "" {
// Use JWT token if available
personalizedURL = fmt.Sprintf("%s?jwt=%s", booking.JitsiRoomURL, token)
} else {
// Fallback to URL parameters
personalizedURL = fmt.Sprintf("%s#userInfo.displayName=\"%s\"", booking.JitsiRoomURL, displayName)
if user.IsAdmin {
personalizedURL = fmt.Sprintf("%s&config.startWithAudioMuted=false&config.startWithVideoMuted=false", personalizedURL)
}
}
return personalizedURL
}
// sendEmail sends an email using the configured SMTP settings // sendEmail sends an email using the configured SMTP settings
func (s *notificationService) sendEmail(to, subject, body string) error { func (s *notificationService) sendEmail(to, subject, body string) error {
if s.config.SMTP.Host == "" || s.config.SMTP.From == "" { if s.config.SMTP.Host == "" || s.config.SMTP.From == "" {

View File

@ -228,7 +228,7 @@ func (s *EmailTemplateService) initializeTemplates() {
<li><strong>Meeting Room:</strong> {{.Booking.JitsiRoomID}}</li> <li><strong>Meeting Room:</strong> {{.Booking.JitsiRoomID}}</li>
</ul> </ul>
<div style="text-align: center;"> <div style="text-align: center;">
<a href="{{.Booking.JitsiRoomURL}}" class="join-button">Join Meeting</a> <a href="{{if .JoinURL}}{{.JoinURL}}{{else}}{{.Booking.JitsiRoomURL}}{{end}}" class="join-button">Join Meeting</a>
</div> </div>
</div> </div>
@ -285,7 +285,7 @@ func (s *EmailTemplateService) initializeTemplates() {
<li><strong>Duration:</strong> {{.Booking.Duration}} minutes</li> <li><strong>Duration:</strong> {{.Booking.Duration}} minutes</li>
</ul> </ul>
<div style="text-align: center;"> <div style="text-align: center;">
<a href="{{.Booking.JitsiRoomURL}}" class="join-button">Join Meeting</a> <a href="{{if .JoinURL}}{{.JoinURL}}{{else}}{{.Booking.JitsiRoomURL}}{{end}}" class="join-button">Join Meeting</a>
</div> </div>
</div> </div>