F023 - Coach Booking & Management
Objective
Implement a comprehensive consultation system allowing authors to book one-on-one sessions with writing coaches, including scheduling, video conferencing integration, and session management.
Quick Implementation
Using NextSaaS Components
- Calendar components for scheduling
- Form components for booking
- Card layouts for coach profiles
- Modal dialogs for booking flow
- DataGrid for session management
New Requirements
- Calendar scheduling system
- Video conferencing integration
- Payment processing for sessions
- Coach availability management
MVP Implementation
1. Database Schema
-- Coach profiles
CREATE TABLE analyzer.coach_profiles (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES auth.users(id) UNIQUE,
full_name VARCHAR(255) NOT NULL,
bio TEXT,
specialties TEXT[], -- ['romance', 'mystery', 'character_development']
hourly_rate INTEGER NOT NULL, -- in cents
years_experience INTEGER,
credentials TEXT[],
timezone VARCHAR(50) DEFAULT 'UTC',
avatar_url TEXT,
is_active BOOLEAN DEFAULT true,
rating DECIMAL(3,2) DEFAULT 0,
total_sessions INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
);
-- Coach availability
CREATE TABLE analyzer.coach_availability (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
coach_id UUID REFERENCES analyzer.coach_profiles(id),
day_of_week INTEGER NOT NULL, -- 0-6 (Sunday-Saturday)
start_time TIME NOT NULL,
end_time TIME NOT NULL,
is_recurring BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT NOW()
);
-- Consultation sessions
CREATE TABLE analyzer.consultation_sessions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
coach_id UUID REFERENCES analyzer.coach_profiles(id),
author_id UUID REFERENCES auth.users(id),
manuscript_id UUID REFERENCES analyzer.manuscripts(id),
scheduled_at TIMESTAMP NOT NULL,
duration_minutes INTEGER DEFAULT 60,
status VARCHAR(50) DEFAULT 'scheduled', -- 'scheduled', 'in_progress', 'completed', 'cancelled'
meeting_url TEXT,
payment_intent_id VARCHAR(255),
amount_paid INTEGER, -- in cents
session_notes TEXT,
author_rating INTEGER,
author_feedback TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
-- Session materials
CREATE TABLE analyzer.session_materials (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
session_id UUID REFERENCES analyzer.consultation_sessions(id),
material_type VARCHAR(50), -- 'manuscript', 'notes', 'outline', 'homework'
title VARCHAR(255),
content TEXT,
file_url TEXT,
uploaded_by UUID REFERENCES auth.users(id),
created_at TIMESTAMP DEFAULT NOW()
);
-- Indexes
CREATE INDEX idx_coach_availability_coach_id ON analyzer.coach_availability(coach_id);
CREATE INDEX idx_consultation_sessions_coach_id ON analyzer.consultation_sessions(coach_id);
CREATE INDEX idx_consultation_sessions_author_id ON analyzer.consultation_sessions(author_id);
CREATE INDEX idx_consultation_sessions_scheduled_at ON analyzer.consultation_sessions(scheduled_at);2. Coach Service
// packages/consultations/src/services/coach-service.ts
import { createClient } from '@mystoryflow/database/server'
import { addDays, setHours, setMinutes, format } from 'date-fns'
import { utcToZonedTime, zonedTimeToUtc } from 'date-fns-tz'
interface TimeSlot {
startTime: Date
endTime: Date
available: boolean
coachId: string
}
export class CoachService {
private supabase = getSupabaseBrowserClient()
async getAvailableSlots(
coachId: string,
startDate: Date,
endDate: Date,
timezone: string = 'UTC'
): Promise<TimeSlot[]> {
// Get coach's availability rules
const { data: availability } = await this.supabase
.from('analyzer.coach_availability')
.select('*')
.eq('coach_id', coachId)
.eq('is_recurring', true)
// Get existing bookings
const { data: bookings } = await this.supabase
.from('analyzer.consultation_sessions')
.select('scheduled_at, duration_minutes')
.eq('coach_id', coachId)
.gte('scheduled_at', startDate.toISOString())
.lte('scheduled_at', endDate.toISOString())
.in('status', ['scheduled', 'in_progress'])
// Generate available slots
const slots: TimeSlot[] = []
let currentDate = new Date(startDate)
while (currentDate <= endDate) {
const dayOfWeek = currentDate.getDay()
const dayAvailability = availability?.filter(a => a.day_of_week === dayOfWeek)
for (const slot of dayAvailability || []) {
const slotStart = this.combineDateTime(currentDate, slot.start_time)
const slotEnd = this.combineDateTime(currentDate, slot.end_time)
// Generate hourly slots
let currentSlot = new Date(slotStart)
while (currentSlot < slotEnd) {
const slotEndTime = new Date(currentSlot)
slotEndTime.setHours(currentSlot.getHours() + 1)
// Check if slot is booked
const isBooked = bookings?.some(booking => {
const bookingStart = new Date(booking.scheduled_at)
const bookingEnd = new Date(bookingStart)
bookingEnd.setMinutes(bookingEnd.getMinutes() + booking.duration_minutes)
return (
(currentSlot >= bookingStart && currentSlot < bookingEnd) ||
(slotEndTime > bookingStart && slotEndTime <= bookingEnd)
)
})
// Only add future slots
if (currentSlot > new Date() && !isBooked) {
slots.push({
startTime: currentSlot,
endTime: slotEndTime,
available: true,
coachId
})
}
currentSlot = new Date(slotEndTime)
}
}
currentDate = addDays(currentDate, 1)
}
return slots
}
async bookSession(
coachId: string,
authorId: string,
slotTime: Date,
manuscriptId?: string,
paymentIntentId?: string
): Promise<string> {
// Verify slot is still available
const slots = await this.getAvailableSlots(
coachId,
slotTime,
new Date(slotTime.getTime() + 60 * 60 * 1000),
'UTC'
)
if (slots.length === 0) {
throw new Error('This time slot is no longer available')
}
// Get coach details for pricing
const { data: coach } = await this.supabase
.from('analyzer.coach_profiles')
.select('hourly_rate')
.eq('id', coachId)
.single()
// Create session
const { data: session, error } = await this.supabase
.from('analyzer.consultation_sessions')
.insert({
coach_id: coachId,
author_id: authorId,
manuscript_id: manuscriptId,
scheduled_at: slotTime.toISOString(),
payment_intent_id: paymentIntentId,
amount_paid: coach?.hourly_rate || 0
})
.select()
.single()
if (error) throw error
// Generate meeting URL (integrate with video provider)
const meetingUrl = await this.generateMeetingUrl(session.id)
await this.supabase
.from('analyzer.consultation_sessions')
.update({ meeting_url: meetingUrl })
.eq('id', session.id)
// Send confirmation emails
await this.sendBookingConfirmation(session.id)
return session.id
}
async cancelSession(sessionId: string, userId: string): Promise<void> {
// Get session details
const { data: session } = await this.supabase
.from('analyzer.consultation_sessions')
.select('*, coach_profiles(user_id)')
.eq('id', sessionId)
.single()
// Verify user can cancel (author or coach)
if (session.author_id !== userId && session.coach_profiles.user_id !== userId) {
throw new Error('Unauthorized to cancel this session')
}
// Check cancellation policy (24 hours notice)
const hoursUntilSession =
(new Date(session.scheduled_at).getTime() - Date.now()) / (1000 * 60 * 60)
if (hoursUntilSession < 24) {
throw new Error('Sessions must be cancelled at least 24 hours in advance')
}
// Update session status
await this.supabase
.from('analyzer.consultation_sessions')
.update({
status: 'cancelled',
cancelled_at: new Date().toISOString(),
cancelled_by: userId
})
.eq('id', sessionId)
// Process refund if needed
if (session.payment_intent_id) {
await this.processRefund(session.payment_intent_id)
}
// Send cancellation notifications
await this.sendCancellationNotification(sessionId)
}
private combineDateTime(date: Date, time: string): Date {
const [hours, minutes] = time.split(':').map(Number)
const combined = new Date(date)
combined.setHours(hours, minutes, 0, 0)
return combined
}
private async generateMeetingUrl(sessionId: string): Promise<string> {
// TODO: Integrate with video conferencing provider (Zoom, Daily, etc.)
// For MVP, return a placeholder
return `https://meet.mystoryflow.com/session/${sessionId}`
}
private async sendBookingConfirmation(sessionId: string): Promise<void> {
// Import notification service
const { NotificationService } = await import('@/packages/notifications')
const notificationService = new NotificationService()
const { data: session } = await this.supabase
.from('analyzer.consultation_sessions')
.select(`
*,
coach_profiles(full_name, user_id),
authors:users!consultation_sessions_author_id_fkey(email)
`)
.eq('id', sessionId)
.single()
// Notify author
await notificationService.notify({
userId: session.author_id,
type: 'consultation_booked',
title: 'Consultation Confirmed',
message: `Your consultation with ${session.coach_profiles.full_name} is confirmed for ${format(new Date(session.scheduled_at), 'PPP at p')}`,
data: { sessionId }
})
// Notify coach
await notificationService.notify({
userId: session.coach_profiles.user_id,
type: 'new_booking',
title: 'New Consultation Booking',
message: `You have a new consultation scheduled with ${session.authors.email} on ${format(new Date(session.scheduled_at), 'PPP at p')}`,
data: { sessionId }
})
}
}3. Coach Booking Component
// apps/analyzer-app/src/components/consultations/CoachBooking.tsx
'use client'
import { useState } from 'react'
import { Card, Button, Calendar, Badge } from '@mystoryflow/ui'
import { format, addDays } from 'date-fns'
import { Clock, Video, Star, DollarSign } from 'lucide-react'
import { useCoachSlots } from '@/hooks/useCoachSlots'
import { BookingModal } from './BookingModal'
interface CoachBookingProps {
coach: {
id: string
full_name: string
bio: string
specialties: string[]
hourly_rate: number
rating: number
total_sessions: number
avatar_url?: string
}
manuscriptId?: string
}
export function CoachBooking({ coach, manuscriptId }: CoachBookingProps) {
const [selectedDate, setSelectedDate] = useState(new Date())
const [selectedSlot, setSelectedSlot] = useState<any>(null)
const [showBookingModal, setShowBookingModal] = useState(false)
const { slots, loading } = useCoachSlots(
coach.id,
selectedDate,
addDays(selectedDate, 7)
)
const groupedSlots = slots.reduce((acc, slot) => {
const dateKey = format(slot.startTime, 'yyyy-MM-dd')
if (!acc[dateKey]) acc[dateKey] = []
acc[dateKey].push(slot)
return acc
}, {} as Record<string, any[]>)
return (
<Card className="p-6 bg-gradient-to-br from-amber-50 to-white border-2 border-amber-200">
{/* Coach Header */}
<div className="flex items-start gap-4 mb-6">
{coach.avatar_url ? (
<img
src={coach.avatar_url}
alt={coach.full_name}
className="w-20 h-20 rounded-full object-cover"
/>
) : (
<div className="w-20 h-20 rounded-full bg-primary/10 flex items-center justify-center">
<span className="text-2xl font-semibold">
{coach.full_name.charAt(0)}
</span>
</div>
)}
<div className="flex-1">
<h3 className="text-xl font-semibold">{coach.full_name}</h3>
<p className="text-sm text-muted-foreground mt-1">{coach.bio}</p>
<div className="flex items-center gap-4 mt-3">
<div className="flex items-center gap-1">
<Star className="h-4 w-4 text-yellow-500 fill-current" />
<span className="font-medium">{coach.rating}</span>
<span className="text-muted-foreground">({coach.total_sessions} sessions)</span>
</div>
<div className="flex items-center gap-1">
<DollarSign className="h-4 w-4" />
<span className="font-medium">${coach.hourly_rate / 100}/hour</span>
</div>
</div>
<div className="flex flex-wrap gap-2 mt-3">
{coach.specialties.map(specialty => (
<Badge key={specialty} variant="secondary">
{specialty}
</Badge>
))}
</div>
</div>
</div>
{/* Calendar and Slots */}
<div className="grid md:grid-cols-2 gap-6">
{/* Calendar */}
<div>
<h4 className="font-medium mb-3">Select a Date</h4>
<Calendar
mode="single"
selected={selectedDate}
onSelect={setSelectedDate}
disabled={(date) => date < new Date()}
className="rounded-md border"
/>
</div>
{/* Time Slots */}
<div>
<h4 className="font-medium mb-3">
Available Times for {format(selectedDate, 'MMM d, yyyy')}
</h4>
{loading ? (
<div className="text-center py-8 text-muted-foreground">
Loading available times...
</div>
) : (
<div className="space-y-2 max-h-96 overflow-y-auto">
{groupedSlots[format(selectedDate, 'yyyy-MM-dd')]?.map((slot, index) => (
<button
key={index}
onClick={() => {
setSelectedSlot(slot)
setShowBookingModal(true)
}}
className="w-full p-3 text-left border rounded-lg hover:border-primary hover:bg-primary/5 transition-colors flex items-center justify-between"
>
<div className="flex items-center gap-2">
<Clock className="h-4 w-4" />
<span>
{format(slot.startTime, 'h:mm a')} -
{format(slot.endTime, 'h:mm a')}
</span>
</div>
<Video className="h-4 w-4 text-muted-foreground" />
</button>
)) || (
<p className="text-center py-8 text-muted-foreground">
No available times for this date
</p>
)}
</div>
)}
</div>
</div>
{/* Booking Info */}
<div className="mt-6 p-4 bg-muted/50 rounded-lg">
<h4 className="font-medium mb-2">Session Information</h4>
<ul className="space-y-1 text-sm text-muted-foreground">
<li>• 60-minute one-on-one video consultation</li>
<li>• Personalized feedback on your manuscript</li>
<li>• Actionable advice and next steps</li>
<li>• Session recording available for 30 days</li>
<li>• 24-hour cancellation policy</li>
</ul>
</div>
{/* Booking Modal */}
{showBookingModal && selectedSlot && (
<BookingModal
coach={coach}
slot={selectedSlot}
manuscriptId={manuscriptId}
onClose={() => {
setShowBookingModal(false)
setSelectedSlot(null)
}}
/>
)}
</Card>
)
}4. Session Management Dashboard
// apps/analyzer-app/src/components/consultations/SessionDashboard.tsx
'use client'
import { Card, Tabs, TabsContent, TabsList, TabsTrigger, Badge } from '@mystoryflow/ui'
import { format } from 'date-fns'
import { Video, Calendar, FileText, MessageSquare } from 'lucide-react'
import { useConsultations } from '@/hooks/useConsultations'
export function SessionDashboard({ userId }: { userId: string }) {
const { upcoming, past, loading } = useConsultations(userId)
return (
<div className="space-y-6">
<h2 className="text-2xl font-bold">Your Consultations</h2>
<Tabs defaultValue="upcoming" className="space-y-4">
<TabsList>
<TabsTrigger value="upcoming">
Upcoming ({upcoming.length})
</TabsTrigger>
<TabsTrigger value="past">
Past ({past.length})
</TabsTrigger>
</TabsList>
<TabsContent value="upcoming">
{upcoming.length === 0 ? (
<Card className="p-8 text-center">
<p className="text-muted-foreground mb-4">
No upcoming consultations scheduled
</p>
<Button asChild>
<a href="/coaches">Browse Coaches</a>
</Button>
</Card>
) : (
<div className="space-y-4">
{upcoming.map(session => (
<SessionCard key={session.id} session={session} isUpcoming />
))}
</div>
)}
</TabsContent>
<TabsContent value="past">
{past.length === 0 ? (
<Card className="p-8 text-center">
<p className="text-muted-foreground">
No past consultations
</p>
</Card>
) : (
<div className="space-y-4">
{past.map(session => (
<SessionCard key={session.id} session={session} isUpcoming={false} />
))}
</div>
)}
</TabsContent>
</Tabs>
</div>
)
}
function SessionCard({
session,
isUpcoming
}: {
session: any
isUpcoming: boolean
}) {
const sessionDate = new Date(session.scheduled_at)
const isToday = format(sessionDate, 'yyyy-MM-dd') === format(new Date(), 'yyyy-MM-dd')
const isWithin24Hours = sessionDate.getTime() - Date.now() < 24 * 60 * 60 * 1000
return (
<Card className="p-6 bg-gradient-to-br from-white to-amber-50 border-2 border-amber-200 hover:shadow-lg transition-shadow">
<div className="flex items-start justify-between">
<div className="space-y-3">
<div>
<h3 className="font-semibold text-lg">
{session.coach_profiles.full_name}
</h3>
<p className="text-sm text-muted-foreground">
{session.manuscripts?.title || 'General Consultation'}
</p>
</div>
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
<span>{format(sessionDate, 'PPP')}</span>
</div>
<div className="flex items-center gap-1">
<Clock className="h-4 w-4" />
<span>{format(sessionDate, 'p')}</span>
</div>
</div>
{isUpcoming && (
<div className="flex gap-2">
<Button
variant={isToday ? 'default' : 'outline'}
size="sm"
asChild
>
<a href={session.meeting_url} target="_blank">
<Video className="h-4 w-4 mr-2" />
{isToday ? 'Join Now' : 'Meeting Link'}
</a>
</Button>
{session.session_materials?.length > 0 && (
<Button variant="outline" size="sm">
<FileText className="h-4 w-4 mr-2" />
Materials ({session.session_materials.length})
</Button>
)}
{!isWithin24Hours && (
<Button variant="ghost" size="sm" className="text-destructive">
Cancel
</Button>
)}
</div>
)}
{!isUpcoming && session.author_rating && (
<div className="flex items-center gap-2">
<div className="flex">
{[...Array(5)].map((_, i) => (
<Star
key={i}
className={`h-4 w-4 ${
i < session.author_rating
? 'text-yellow-500 fill-current'
: 'text-gray-300'
}`}
/>
))}
</div>
{session.author_feedback && (
<span className="text-sm text-muted-foreground">
"{session.author_feedback}"
</span>
)}
</div>
)}
</div>
<Badge variant={isUpcoming ? 'default' : 'secondary'}>
{session.status}
</Badge>
</div>
</Card>
)
}5. Coach Availability Settings
// apps/analyzer-app/src/components/consultations/AvailabilitySettings.tsx
'use client'
import { useState } from 'react'
import { Card, Button, Switch, Select } from '@mystoryflow/ui'
import { Plus, Trash } from 'lucide-react'
import { useCoachAvailability } from '@/hooks/useCoachAvailability'
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
const TIMES = Array.from({ length: 24 }, (_, i) => {
const hour = i.toString().padStart(2, '0')
return { value: `${hour}:00`, label: `${hour}:00` }
})
export function AvailabilitySettings({ coachId }: { coachId: string }) {
const { availability, updateAvailability, deleteSlot } = useCoachAvailability(coachId)
const [newSlot, setNewSlot] = useState({
day: 1,
startTime: '09:00',
endTime: '17:00'
})
const handleAddSlot = async () => {
await updateAvailability({
coach_id: coachId,
day_of_week: newSlot.day,
start_time: newSlot.startTime,
end_time: newSlot.endTime,
is_recurring: true
})
}
const groupedAvailability = availability.reduce((acc, slot) => {
if (!acc[slot.day_of_week]) acc[slot.day_of_week] = []
acc[slot.day_of_week].push(slot)
return acc
}, {} as Record<number, any[]>)
return (
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">Availability Settings</h3>
<div className="space-y-6">
{/* Add New Slot */}
<div className="p-4 border rounded-lg">
<h4 className="font-medium mb-3">Add Availability</h4>
<div className="grid grid-cols-4 gap-3">
<Select
value={newSlot.day.toString()}
onValueChange={(value) => setNewSlot({ ...newSlot, day: parseInt(value) })}
>
{DAYS.map((day, index) => (
<option key={index} value={index}>{day}</option>
))}
</Select>
<Select
value={newSlot.startTime}
onValueChange={(value) => setNewSlot({ ...newSlot, startTime: value })}
>
{TIMES.map(time => (
<option key={time.value} value={time.value}>{time.label}</option>
))}
</Select>
<Select
value={newSlot.endTime}
onValueChange={(value) => setNewSlot({ ...newSlot, endTime: value })}
>
{TIMES.filter(t => t.value > newSlot.startTime).map(time => (
<option key={time.value} value={time.value}>{time.label}</option>
))}
</Select>
<Button onClick={handleAddSlot}>
<Plus className="h-4 w-4 mr-2" />
Add
</Button>
</div>
</div>
{/* Current Availability */}
<div className="space-y-4">
<h4 className="font-medium">Current Availability</h4>
{DAYS.map((day, dayIndex) => {
const daySlots = groupedAvailability[dayIndex] || []
return (
<div key={dayIndex} className="border rounded-lg p-3">
<div className="flex items-center justify-between mb-2">
<span className="font-medium">{day}</span>
<Switch
checked={daySlots.length > 0}
onCheckedChange={(checked) => {
if (!checked) {
daySlots.forEach(slot => deleteSlot(slot.id))
}
}}
/>
</div>
{daySlots.length > 0 && (
<div className="space-y-2">
{daySlots.map(slot => (
<div key={slot.id} className="flex items-center justify-between text-sm">
<span>
{slot.start_time} - {slot.end_time}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => deleteSlot(slot.id)}
>
<Trash className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
)
})}
</div>
</div>
</Card>
)
}MVP Acceptance Criteria
- Coach profile management
- Availability scheduling system
- Real-time slot booking
- Calendar integration
- Payment processing for sessions
- Video meeting URL generation
- Session materials sharing
- Booking confirmations
- Cancellation policy enforcement
- Rating and feedback system
Post-MVP Enhancements
- Recurring session bookings
- Package deals (multiple sessions)
- Group consultations
- Waiting list management
- Coach calendar sync (Google/Outlook)
- Session recording and playback
- In-session collaborative tools
- Automated session reminders
- Coach performance analytics
- Revenue sharing automation
Implementation Time
- Development: 3 days
- Testing: 0.5 days
- Total: 3.5 days
Dependencies
- F021-SUBSCRIPTION-TIERS (payment processing)
- F020-NOTIFICATION-SYSTEM (booking notifications)
- Video conferencing API integration
- Calendar component library
Next Feature
After completion, proceed to F024-COACH-MARKETPLACE for coach discovery.