# Cycle 6: Notifications & Documents Gaps

## Goal
Upgrade template system to support documents (PDF/certificates), add document generation, SMS gateway interface, retry mechanism, and missing channels.

---

## Migration: `2024_01_01_000060_upgrade_notification_templates.php`

The current `notification_templates` table is too simple for the doc's vision (which includes print templates, certificates, contracts). Instead of replacing, we add a NEW `document_templates` table for printable/PDF content while keeping `notification_templates` for notifications.

```sql
CREATE TABLE document_templates (
    id                  BIGSERIAL PRIMARY KEY,
    uuid                UUID NOT NULL UNIQUE,
    academy_id          BIGINT REFERENCES academies(id),    -- NULL = system default
    name                VARCHAR(100) NOT NULL,
    name_ar             VARCHAR(100) NOT NULL,
    slug                VARCHAR(100) NOT NULL,
    type                VARCHAR(20) NOT NULL,
    channel             VARCHAR(20) NOT NULL DEFAULT 'print',
    subject             VARCHAR(200),
    subject_ar          VARCHAR(200),
    body                TEXT NOT NULL,                        -- HTML/Blade template
    body_ar             TEXT,
    css                 TEXT,                                 -- Custom CSS for this template
    header_html         TEXT,
    footer_html         TEXT,
    paper_size          VARCHAR(10) NOT NULL DEFAULT 'A4',
    orientation         VARCHAR(10) NOT NULL DEFAULT 'portrait',
    variables           JSONB NOT NULL DEFAULT '[]',          -- expected variables
    is_default          BOOLEAN NOT NULL DEFAULT FALSE,
    is_system           BOOLEAN NOT NULL DEFAULT FALSE,
    is_active           BOOLEAN NOT NULL DEFAULT TRUE,
    version             INTEGER NOT NULL DEFAULT 1,
    preview_data        JSONB NOT NULL DEFAULT '{}',          -- sample data for preview
    metadata            JSONB NOT NULL DEFAULT '{}',
    created_by          BIGINT REFERENCES users(id),
    updated_by          BIGINT REFERENCES users(id),
    created_at          TIMESTAMP NOT NULL DEFAULT NOW(),
    updated_at          TIMESTAMP NOT NULL DEFAULT NOW(),
    deleted_at          TIMESTAMP
);

ALTER TABLE document_templates ADD CONSTRAINT document_templates_type_check
    CHECK (type IN ('invoice', 'receipt', 'contract', 'certificate', 'report', 'letter', 'custom'));

ALTER TABLE document_templates ADD CONSTRAINT document_templates_channel_check
    CHECK (channel IN ('print', 'email', 'pdf'));

ALTER TABLE document_templates ADD CONSTRAINT document_templates_paper_size_check
    CHECK (paper_size IN ('A4', 'A5', 'letter', 'receipt'));

ALTER TABLE document_templates ADD CONSTRAINT document_templates_orientation_check
    CHECK (orientation IN ('portrait', 'landscape'));

CREATE UNIQUE INDEX document_templates_slug_unique
    ON document_templates(academy_id, slug) WHERE deleted_at IS NULL;
```

---

## Migration: `2024_01_01_000061_create_generated_documents_table.php`

```sql
CREATE TABLE generated_documents (
    id                  BIGSERIAL PRIMARY KEY,
    uuid                UUID NOT NULL UNIQUE,
    academy_id          BIGINT NOT NULL REFERENCES academies(id),
    template_id         BIGINT REFERENCES document_templates(id),
    documentable_type   VARCHAR(100) NOT NULL,               -- polymorphic (Invoice, Participant, etc.)
    documentable_id     BIGINT NOT NULL,
    title               VARCHAR(200) NOT NULL,
    title_ar            VARCHAR(200),
    file_path           VARCHAR(500),                         -- storage path to generated PDF
    file_size           INTEGER,                              -- bytes
    format              VARCHAR(10) NOT NULL DEFAULT 'pdf',
    verification_number VARCHAR(50),                          -- unique for certificates
    verification_qr     VARCHAR(500),                         -- QR code data URL or path
    variables_used      JSONB NOT NULL DEFAULT '{}',          -- snapshot of data used
    is_permanent        BOOLEAN NOT NULL DEFAULT FALSE,       -- permanent = never auto-cleanup
    expires_at          TIMESTAMP,
    generated_by        BIGINT NOT NULL REFERENCES users(id),
    downloaded_count    INTEGER NOT NULL DEFAULT 0,
    last_downloaded_at  TIMESTAMP,
    metadata            JSONB NOT NULL DEFAULT '{}',
    created_at          TIMESTAMP NOT NULL DEFAULT NOW()
    -- NO updated_at (immutable once generated)
);

ALTER TABLE generated_documents ADD CONSTRAINT generated_documents_format_check
    CHECK (format IN ('pdf', 'html', 'png'));

CREATE INDEX generated_documents_documentable
    ON generated_documents(documentable_type, documentable_id);
CREATE INDEX generated_documents_verification
    ON generated_documents(verification_number) WHERE verification_number IS NOT NULL;
```

---

## Migration: `2024_01_01_000062_add_notification_retry_columns.php`

### On `notification_logs`:
```php
$table->uuid('uuid')->unique()->after('id');
$table->foreignId('template_id')->nullable()->after('academy_id')->constrained('notification_templates');
$table->string('recipient_address', 200)->nullable();  -- email/phone that was targeted
$table->timestamp('delivered_at')->nullable();
$table->timestamp('failed_at')->nullable();
$table->integer('retry_count')->default(0);
$table->integer('max_retries')->default(3);
$table->timestamp('next_retry_at')->nullable();
$table->string('gateway_message_id', 100)->nullable();
$table->jsonb('gateway_response')->default('{}');
$table->bigInteger('cost')->default(0);  -- piasters (SMS cost tracking)
```

Update `status` CHECK: `('queued', 'sending', 'sent', 'delivered', 'failed', 'bounced', 'rejected', 'read')`

---

## Service: `app/Domain/Notification/Services/DocumentService.php`

```php
namespace App\Domain\Notification\Services;

use Barryvdh\DomPDF\Facade\Pdf;

class DocumentService
{
    public function generate(
        string $templateSlug,
        Model $entity,
        array $variables,
        User $actor,
        array $options = []
    ): GeneratedDocument {
        return DB::transaction(function () use ($templateSlug, $entity, $variables, $actor, $options) {
            // 1. Resolve template (academy-specific first, then system default)
            $template = $this->resolveTemplate($templateSlug, $entity->academy_id);

            // 2. Validate all required variables are provided
            $this->validateVariables($template, $variables);

            // 3. Render HTML from template + variables
            $html = $this->renderTemplate($template, $variables);

            // 4. Generate PDF via DomPDF
            $pdf = Pdf::loadHTML($html)
                ->setPaper($template->paper_size, $template->orientation);

            // 5. Store file
            $path = "documents/{$entity->academy_id}/" . date('Y/m') . '/' . Str::uuid() . '.pdf';
            Storage::disk('local')->put($path, $pdf->output());

            // 6. Generate verification number (for certificates)
            $verificationNumber = null;
            if ($template->type === 'certificate') {
                $verificationNumber = $this->generateVerificationNumber();
            }

            // 7. Create DB record
            return GeneratedDocument::create([
                'uuid' => (string) Str::uuid(),
                'academy_id' => $entity->academy_id,
                'template_id' => $template->id,
                'documentable_type' => get_class($entity),
                'documentable_id' => $entity->id,
                'title' => $options['title'] ?? $template->name,
                'title_ar' => $options['title_ar'] ?? $template->name_ar,
                'file_path' => $path,
                'file_size' => Storage::disk('local')->size($path),
                'format' => 'pdf',
                'verification_number' => $verificationNumber,
                'variables_used' => $variables,
                'is_permanent' => $options['permanent'] ?? ($template->type === 'certificate'),
                'generated_by' => $actor->id,
            ]);
        });
    }

    public function download(GeneratedDocument $document): StreamedResponse
    {
        $document->increment('downloaded_count');
        $document->update(['last_downloaded_at' => now()]);

        return Storage::disk('local')->download($document->file_path, $document->title . '.pdf');
    }

    public function email(GeneratedDocument $document, string $recipientEmail): void
    {
        Mail::raw('', function ($message) use ($document, $recipientEmail) {
            $message->to($recipientEmail)
                ->subject($document->title_ar)
                ->attach(Storage::disk('local')->path($document->file_path));
        });
    }

    public function verify(string $verificationNumber): ?GeneratedDocument
    {
        return GeneratedDocument::where('verification_number', $verificationNumber)->first();
    }

    private function resolveTemplate(string $slug, int $academyId): DocumentTemplate
    {
        // Academy-specific first
        $template = DocumentTemplate::where('slug', $slug)
            ->where('academy_id', $academyId)
            ->where('is_active', true)
            ->first();

        // Fall back to system default
        if (!$template) {
            $template = DocumentTemplate::where('slug', $slug)
                ->whereNull('academy_id')
                ->where('is_system', true)
                ->where('is_active', true)
                ->first();
        }

        if (!$template) {
            throw new DomainException("قالب غير موجود: {$slug}");
        }

        return $template;
    }

    private function renderTemplate(DocumentTemplate $template, array $variables): string
    {
        $body = app()->getLocale() === 'ar' ? ($template->body_ar ?: $template->body) : $template->body;

        // Simple variable replacement (no Blade in templates — security)
        foreach ($variables as $key => $value) {
            $body = str_replace("{{" . $key . "}}", e($value), $body);
        }

        // Check for unreplaced variables
        if (preg_match('/\{\{[^}]+\}\}/', $body, $matches)) {
            throw new DomainException("متغير مفقود في القالب: {$matches[0]}");
        }

        // Wrap in HTML document with CSS
        return view('pdf.layout', [
            'body' => $body,
            'css' => $template->css,
            'header' => $template->header_html,
            'footer' => $template->footer_html,
            'direction' => 'rtl',
        ])->render();
    }

    private function generateVerificationNumber(): string
    {
        // Format: CERT-XXXXXX (6 alphanumeric)
        do {
            $number = 'CERT-' . strtoupper(Str::random(6));
        } while (GeneratedDocument::where('verification_number', $number)->exists());

        return $number;
    }

    private function validateVariables(DocumentTemplate $template, array $variables): void
    {
        $required = $template->variables ?? [];
        foreach ($required as $var) {
            if (!isset($variables[$var])) {
                throw new DomainException("المتغير المطلوب مفقود: {$var}");
            }
        }
    }
}
```

---

## SMS Gateway Interface Pattern

```php
// app/Domain/Notification/Contracts/SmsGatewayInterface.php
namespace App\Domain\Notification\Contracts;

interface SmsGatewayInterface
{
    public function send(string $phoneNumber, string $message): SmsResult;
    public function getBalance(): int; // remaining credits
    public function getDeliveryStatus(string $messageId): string;
}

// app/Domain/Notification/Gateways/LogSmsGateway.php
class LogSmsGateway implements SmsGatewayInterface
{
    public function send(string $phoneNumber, string $message): SmsResult
    {
        Log::info("SMS → {$phoneNumber}: {$message}");
        return new SmsResult(success: true, messageId: 'log-' . Str::uuid());
    }

    public function getBalance(): int { return 999999; }
    public function getDeliveryStatus(string $messageId): string { return 'delivered'; }
}

// app/Domain/Notification/DTOs/SmsResult.php
class SmsResult
{
    public function __construct(
        public bool $success,
        public ?string $messageId = null,
        public ?string $error = null,
        public int $segments = 1,
        public int $cost = 0, // piasters
    ) {}
}
```

Register in AppServiceProvider:
```php
$this->app->bind(SmsGatewayInterface::class, LogSmsGateway::class);
```

---

## Notification Retry Mechanism

Update `NotificationService::send()`:
```php
// On failure:
$log->update([
    'status' => 'failed',
    'failed_at' => now(),
    'error_message' => $e->getMessage(),
    'retry_count' => 0,
    'max_retries' => 3,
    'next_retry_at' => now()->addMinutes(5), // exponential: 5, 25, 125
]);

// RetryFailedNotifications command picks these up
```

### Exponential Backoff:
```php
$nextRetry = now()->addMinutes(pow(5, $log->retry_count + 1));
// Attempt 1: 5 min, Attempt 2: 25 min, Attempt 3: 125 min (~2 hours)
```

---

## SMS Rate Limiting

In NotificationService, before sending SMS:
```php
private function checkSmsRateLimit(int $academyId): void
{
    $sentLastHour = NotificationLog::where('academy_id', $academyId)
        ->where('channel', 'sms')
        ->where('status', 'sent')
        ->where('created_at', '>=', now()->subHour())
        ->count();

    if ($sentLastHour >= 100) {
        throw new DomainException('تم تجاوز حد الرسائل: 100 رسالة/ساعة');
    }
}
```

---

## Composer Package: DomPDF

```bash
composer require barryvdh/laravel-dompdf
```

### PDF Layout View: `resources/views/pdf/layout.blade.php`
```html
<!DOCTYPE html>
<html dir="{{ $direction }}" lang="ar">
<head>
    <meta charset="utf-8">
    <style>
        @font-face { font-family: 'Cairo'; src: url({{ storage_path('fonts/Cairo-Regular.ttf') }}); }
        body { font-family: 'Cairo', sans-serif; direction: rtl; }
        {!! $css !!}
    </style>
</head>
<body>
    @if($header){!! $header !!}@endif
    <main>{!! $body !!}</main>
    @if($footer){!! $footer !!}@endif
</body>
</html>
```

---

## Certificate Verification Route

```php
// routes/web.php (public, no auth)
Route::get('/verify/{number}', [CertificateVerificationController::class, 'show'])
    ->name('certificate.verify');
```

### Controller
```php
class CertificateVerificationController extends Controller
{
    public function show(string $number)
    {
        $document = app(DocumentService::class)->verify($number);

        if (!$document) {
            abort(404, 'الشهادة غير موجودة');
        }

        return view('certificates.verify', ['document' => $document]);
    }
}
```

---

## Default Document Templates to Seed

| Slug | Type | Name AR | Variables |
|------|------|---------|-----------|
| `invoice-default` | invoice | فاتورة | invoice_number, date, customer_name, items, total, academy_name |
| `receipt-default` | receipt | إيصال | receipt_number, date, amount, method, customer_name |
| `enrollment-certificate` | certificate | شهادة تسجيل | participant_name, program_name, start_date, academy_name |
| `completion-certificate` | certificate | شهادة إتمام | participant_name, program_name, completion_date, grade |
| `attendance-report` | report | تقرير الحضور | participant_name, period, attendance_rate, sessions_attended |

---

## Livewire Components

### Document Template Editor
- `Livewire/Documents/TemplateEditor.php` — left panel (variable chips), center (HTML editor), right (live preview)
- Alpine.js for tab switching between HTML/CSS/Preview
- Variable insertion via click

### Generated Documents List
- `Livewire/Documents/DocumentList.php` — search, type filter, date range, download action
- Download button calls DocumentService::download()

### Routes
```php
Route::get('/documents/templates', TemplateEditor::class)->name('documents.templates');
Route::get('/documents', DocumentList::class)->name('documents.index');
```

---

## Verification

- [ ] DomPDF generates Arabic PDF correctly (Cairo font loaded)
- [ ] Invoice PDF renders with all variables populated
- [ ] Certificate gets unique verification number
- [ ] `/verify/CERT-XXXXXX` shows certificate details (public route)
- [ ] SMS gateway interface swappable (Log → real gateway)
- [ ] Failed notification retries after 5 minutes
- [ ] Rate limit blocks 101st SMS in an hour
- [ ] Template editor previews HTML correctly
