# Story 3.6: Calendar File Generation (.ics) ## Epic Reference **Epic 3:** Booking & Consultation System ## User Story As a **client**, I want **to receive a calendar file when my booking is approved**, So that **I can easily add the consultation to my calendar app**. ## Story Context ### Existing System Integration - **Integrates with:** Consultation model, email attachments - **Technology:** iCalendar format (RFC 5545) - **Follows pattern:** Service class for generation - **Touch points:** Approval email, client dashboard download ## Acceptance Criteria ### Calendar File Generation - [ ] Generate valid .ics file on booking approval - [ ] File follows iCalendar specification (RFC 5545) - [ ] Compatible with major calendar apps: - Google Calendar - Apple Calendar - Microsoft Outlook - Other standard clients ### Event Details - [ ] Event title: "Consultation with Libra Law Firm" (bilingual) - [ ] Date and time (correct timezone) - [ ] Duration: 45 minutes - [ ] Location (office address or "Phone consultation") - [ ] Description with: - Booking reference - Consultation type (free/paid) - Contact information - [ ] Reminder: 1 hour before ### Delivery - [ ] Attach to approval email - [ ] Available for download from client dashboard - [ ] Proper MIME type (text/calendar) - [ ] Correct filename (consultation-{date}.ics) ### Language Support - [ ] Event title in client's preferred language - [ ] Description in client's preferred language ### Quality Requirements - [ ] Valid iCalendar format (passes validators) - [ ] Tests for file generation - [ ] Tests for calendar app compatibility ## Technical Notes ### Calendar Service ```php user; $locale = $user->preferred_language ?? 'ar'; $startDateTime = Carbon::parse( $consultation->scheduled_date->format('Y-m-d') . ' ' . $consultation->scheduled_time ); $endDateTime = $startDateTime->copy()->addMinutes($consultation->duration); $title = $locale === 'ar' ? 'استشارة مع مكتب ليبرا للمحاماة' : 'Consultation with Libra Law Firm'; $description = $this->buildDescription($consultation, $locale); $location = $this->getLocation($locale); $uid = sprintf( 'consultation-%d@libra.ps', $consultation->id ); $ics = [ 'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Libra Law Firm//Consultation Booking//EN', 'CALSCALE:GREGORIAN', 'METHOD:REQUEST', 'BEGIN:VTIMEZONE', 'TZID:Asia/Jerusalem', 'BEGIN:STANDARD', 'DTSTART:19701025T020000', 'RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU', 'TZOFFSETFROM:+0300', 'TZOFFSETTO:+0200', 'END:STANDARD', 'BEGIN:DAYLIGHT', 'DTSTART:19700329T020000', 'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1FR', 'TZOFFSETFROM:+0200', 'TZOFFSETTO:+0300', 'END:DAYLIGHT', 'END:VTIMEZONE', 'BEGIN:VEVENT', 'UID:' . $uid, 'DTSTAMP:' . gmdate('Ymd\THis\Z'), 'DTSTART;TZID=Asia/Jerusalem:' . $startDateTime->format('Ymd\THis'), 'DTEND;TZID=Asia/Jerusalem:' . $endDateTime->format('Ymd\THis'), 'SUMMARY:' . $this->escapeIcs($title), 'DESCRIPTION:' . $this->escapeIcs($description), 'LOCATION:' . $this->escapeIcs($location), 'STATUS:CONFIRMED', 'SEQUENCE:0', 'BEGIN:VALARM', 'TRIGGER:-PT1H', 'ACTION:DISPLAY', 'DESCRIPTION:Reminder', 'END:VALARM', 'END:VEVENT', 'END:VCALENDAR', ]; return implode("\r\n", $ics); } private function buildDescription(Consultation $consultation, string $locale): string { $lines = []; if ($locale === 'ar') { $lines[] = 'رقم الحجز: ' . $consultation->id; $lines[] = 'نوع الاستشارة: ' . ($consultation->type === 'free' ? 'مجانية' : 'مدفوعة'); if ($consultation->type === 'paid') { $lines[] = 'المبلغ: ' . number_format($consultation->payment_amount, 2) . ' شيكل'; } $lines[] = ''; $lines[] = 'للاستفسارات:'; $lines[] = 'مكتب ليبرا للمحاماة'; $lines[] = 'libra.ps'; } else { $lines[] = 'Booking Reference: ' . $consultation->id; $lines[] = 'Consultation Type: ' . ucfirst($consultation->type); if ($consultation->type === 'paid') { $lines[] = 'Amount: ' . number_format($consultation->payment_amount, 2) . ' ILS'; } $lines[] = ''; $lines[] = 'For inquiries:'; $lines[] = 'Libra Law Firm'; $lines[] = 'libra.ps'; } return implode('\n', $lines); } private function getLocation(string $locale): string { // Configure in config/libra.php return config('libra.office_address.' . $locale, 'Libra Law Firm'); } private function escapeIcs(string $text): string { return str_replace( [',', ';', '\\'], ['\,', '\;', '\\\\'], $text ); } public function generateDownloadResponse(Consultation $consultation): \Symfony\Component\HttpFoundation\Response { $content = $this->generateIcs($consultation); $filename = sprintf( 'consultation-%s.ics', $consultation->scheduled_date->format('Y-m-d') ); return response($content) ->header('Content-Type', 'text/calendar; charset=utf-8') ->header('Content-Disposition', 'attachment; filename="' . $filename . '"'); } } ``` ### Email Attachment ```php // In BookingApproved notification public function toMail(object $notifiable): MailMessage { $locale = $notifiable->preferred_language ?? 'ar'; return (new MailMessage) ->subject($this->getSubject($locale)) ->markdown('emails.booking.approved.' . $locale, [ 'consultation' => $this->consultation, 'paymentInstructions' => $this->paymentInstructions, ]) ->attachData( $this->icsContent, 'consultation.ics', ['mime' => 'text/calendar'] ); } ``` ### Download Route ```php // routes/web.php Route::middleware(['auth'])->group(function () { Route::get('/consultations/{consultation}/calendar', function (Consultation $consultation) { // Verify user owns this consultation abort_unless($consultation->user_id === auth()->id(), 403); abort_unless($consultation->status === 'approved', 404); return app(CalendarService::class)->generateDownloadResponse($consultation); })->name('client.consultations.calendar'); }); ``` ### Client Dashboard Button ```blade @if($consultation->status === 'approved') {{ __('client.add_to_calendar') }} @endif ``` ### Testing Calendar File ```php use App\Services\CalendarService; use App\Models\Consultation; it('generates valid ics file', function () { $consultation = Consultation::factory()->approved()->create([ 'scheduled_date' => '2024-03-15', 'scheduled_time' => '10:00:00', 'duration' => 45, ]); $service = new CalendarService(); $ics = $service->generateIcs($consultation); expect($ics) ->toContain('BEGIN:VCALENDAR') ->toContain('BEGIN:VEVENT') ->toContain('DTSTART') ->toContain('DTEND') ->toContain('END:VCALENDAR'); }); it('includes correct duration', function () { $consultation = Consultation::factory()->approved()->create([ 'scheduled_date' => '2024-03-15', 'scheduled_time' => '10:00:00', 'duration' => 45, ]); $service = new CalendarService(); $ics = $service->generateIcs($consultation); // Start at 10:00, end at 10:45 expect($ics) ->toContain('DTSTART;TZID=Asia/Jerusalem:20240315T100000') ->toContain('DTEND;TZID=Asia/Jerusalem:20240315T104500'); }); ``` ## Definition of Done - [ ] .ics file generated on approval - [ ] File follows iCalendar RFC 5545 - [ ] Works with Google Calendar - [ ] Works with Apple Calendar - [ ] Works with Microsoft Outlook - [ ] Attached to approval email - [ ] Downloadable from client dashboard - [ ] Bilingual event details - [ ] Includes 1-hour reminder - [ ] Tests pass for generation - [ ] Code formatted with Pint ## Dependencies - **Story 3.5:** Booking approval (triggers generation) - **Epic 8:** Email system (for attachment) ## Risk Assessment - **Primary Risk:** Calendar app compatibility issues - **Mitigation:** Test with multiple calendar apps, follow RFC strictly - **Rollback:** Provide manual calendar details if .ics fails ## Estimation **Complexity:** Medium **Estimated Effort:** 3-4 hours