libra/docs/stories/story-3.6-calendar-file-gen...

9.3 KiB

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

namespace App\Services;

use App\Models\Consultation;
use Carbon\Carbon;

class CalendarService
{
    public function generateIcs(Consultation $consultation): string
    {
        $user = $consultation->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

// 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

// 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

@if($consultation->status === 'approved')
    <flux:button
        size="sm"
        href="{{ route('client.consultations.calendar', $consultation) }}"
    >
        <flux:icon name="calendar" class="w-4 h-4 me-1" />
        {{ __('client.add_to_calendar') }}
    </flux:button>
@endif

Testing Calendar File

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