Skip to Content
📚 MyStoryFlow Docs — Your guide to preserving family stories

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.