generated sotries
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
# 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
|
||||
<?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
|
||||
```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')
|
||||
<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
|
||||
```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
|
||||
Reference in New Issue
Block a user