# Certificate Validity Feature — Implementation Plan

> Save this file before execution. Once you confirm, I will start implementing the steps below.

## Goal

Make every certificate carry a validity period in a non-breaking, additive way. Per-item `validity_years` on `courses` and `professional_certificates`, a global default in `settings`, `expires_at` on each `certificates` row, expiry shown in UI and on the rendered image, and a daily email when a certificate just expired. Renewal is out of scope (email-only).

## Decisions (already confirmed)

- Validity is configured **per Course / per ProfessionalCertificate**, with a **global default** (`default_certificate_validity_years = 2`) used when an item has no value.
- For **legacy certificates** without `expires_at`, value is **resolved on the fly** from the item / global setting (no DB backfill).
- When a certificate expires: it stays **viewable and downloadable**, but shows an **"Expired" badge** in UI + an **"Expired" overlay** on the rendered image, and the user receives an **email notification** asking them to contact support for renewal.
- Renewal flow is **out of scope** (email-only in this phase).

## Resolution rule (single source of truth)

A new model accessor `Certificate::resolvedExpiresAt()` returns expiry in this priority:

1. `$this->expires_at` if set (explicit value).
2. Else `created_at + (course->validity_years ?? professional_certificate->validity_years)` years.
3. Else `created_at + Helpers::settings('default_certificate_validity_years', 2)` years.

This makes legacy rows automatically gain an expiry without any backfill.

## Data layer changes

- **New migration** `add_validity_years_to_courses_table`: adds `validity_years` (`unsignedSmallInteger`, nullable) to the `courses` table (created by `database/migrations/2022_09_21_120013_create_courses_table.php`).
- **New migration** `add_validity_years_to_professional_certificates_table`: adds the same column to the `professional_certificates` table (created by `database/migrations/2025_10_09_084829_create_professional_certificates_table.php`).
- **New migration** `add_expires_at_to_certificates_table`: adds `expires_at` (nullable timestamp) and `last_expiry_notified_at` (nullable timestamp) to `certificates` (created by `database/migrations/2025_07_09_125055_create_certificates_table.php`). No backfill — `NULL` means "compute via resolver".
- **New seeder / settings entry** for the global setting `default_certificate_validity_years = 2`, resolvable through `Helpers::settings('default_certificate_validity_years', 2)` in `app/Helpers/Helpers.php`.

## Model changes (additive only)

- `app/Models/Certificate/Certificate.php`: add `expires_at`, `last_expiry_notified_at` to `$fillable`; cast both as `datetime`; add `resolvedExpiresAt(): ?Carbon`, `isExpired(): bool`, `expiryStatus(): string`.
- `app/Models/ProfessionalCertificate/ProfessionalCertificate.php`: add `'validity_years'` to `$fillable`.
- `app/Models/Course/Course.php`: no change (uses `$guarded = ['id']`).

## Issuance pipeline (set `expires_at` without changing existing logic)

The single chokepoint where every newly created `Certificate` is rendered is `app/Services/CertificatesService.php` `get_certificate()`. We add a small `assignExpiresAtIfMissing()` call at the top that sets `$this->certificate->expires_at` only if it is NULL, then saves. All three current creation paths keep working unchanged:

- `app/Jobs/StudentCourseCertificateChecker.php`
- `app/Http/Controllers/Dashboard/CertificateController.php` `store()`
- `app/Http/Controllers/Dashboard/SubscribersCertificateController.php` `store()`

## Image overlay

In each generator inside `app/Services/CertificatesService.php` (`generate_course_certificate`, `generate_diploma_certificate`, `generate_phd_certificate`, `generate_masters_certificate`):

- Print **expiry date** next to the existing date (`Y-m-d`).
- If `isExpired()`, draw a watermark text **"EXPIRED" / "منتهية الصلاحية"** diagonally across the image.

Because the existing `get_certificate()` uses `if (false && ...)` and always regenerates the file on every show, the overlay appears as soon as the cert becomes expired with no extra invalidation logic.

## Admin UI (forms + lists)

- `resources/views/backend/courses/form.blade.php`: add a numeric field "Validity (years)" with placeholder "Leave empty to use site default".
- `resources/views/backend/professionalCertificates/form.blade.php`: same.
- `app/Http/Requests/Dashboard/Course/StoreCourseRequest.php` + `UpdateCourseRequest.php`: add `'validity_years' => ['nullable', 'integer', 'min:1', 'max:99']`.
- `app/Http/Requests/Dashboard/ProfessionalCertificate/StoreProfessionalCertificateRequest.php` + Update equivalent: same rule.
- Settings page (existing settings module) gets a new field "Default Certificate Validity (years)".
- Add an "Expires At" column and an "Expired" badge to:
  - `resources/views/backend/certificates/index.blade.php`
  - `resources/views/backend/subscribers-certificates/index.blade.php`
  - `resources/views/backend/pending-certificates/index.blade.php`
  - `resources/views/backend/certificates-log/index.blade.php`

## API

- `app/Http/Resources/Api/CertificateResource.php`: expose `expires_at` (Y-m-d) and `is_expired` (bool). No filtering changes in `app/Http/Controllers/API/MyCertificateController.php` since expired certificates remain visible.

## Email notification (no renewal action)

- New mailable `app/Mail/CertificateExpiredMail.php`, modeled on `app/Mail/CertificateIssuedMail.php`.
- New blade `resources/views/mail/certificate-expired.blade.php`, modeled on `resources/views/mail/certificate-issued.blade.php`. Copy: certificate title + expiry date + "to renew, please contact support".
- New translation keys in `resources/lang/{ar,en}/mail.php` (`certificate_expired_*`).
- Add `sendCertificateExpired(Certificate $c)` method to `app/Services/Mail/UserEmailService.php` (mirroring how the issued mail is currently sent from `app/Actions/Dashboard/Certificate/CertificateApprovalAction.php`).
- New artisan command `app/Console/Commands/NotifyExpiredCertificates.php` (signature: `certificates:notify-expired`) that selects certificates where `is_approved = true`, the resolved expiry is in the past, and `last_expiry_notified_at` is `NULL`; sends the mail; sets `last_expiry_notified_at = now()` to dedupe.
- Registered in `app/Console/Kernel.php` to run **daily at 06:00 server time**:

  ```php
  $schedule->command('certificates:notify-expired')->dailyAt('06:00');
  ```

## Server cron setup (one-time, manual)

Laravel's scheduler needs a single system-level cron entry on the production server that runs every minute and lets Laravel decide what to dispatch. We will add a new doc `SETUP_CRON.md` at the project root that explains:

```bash
* * * * * cd /path-to-LMS && php artisan schedule:run >> /dev/null 2>&1
```

The doc will cover: where to place it (`crontab -e` for the web user, e.g. `www-data`), how to verify it (`php artisan schedule:list`), and how to test the new command manually (`php artisan certificates:notify-expired`). Without this entry the daily email job will not fire on the server.

## Translations

Add new keys in `resources/lang/{ar,en}/main.php`: `validity_years`, `expires_at`, `expired`, `years`, `default_certificate_validity_years`, `leave_empty_to_use_default`.

## Flow diagram

```mermaid
flowchart TD
    AdminForm[Admin sets validity_years per Course or PC] --> DB[(courses / professional_certificates)]
    Settings[Settings: default_certificate_validity_years = 2] --> Resolver
    Issue[Certificate created via Job or Admin] --> Service[CertificatesService.get_certificate]
    Service -->|fills if NULL| Resolver{resolvedExpiresAt}
    Resolver -->|expires_at on row| Final[expires_at saved]
    DB --> Resolver
    Final --> UI[UI badges + image overlay]
    Final --> Cron[NotifyExpiredCertificates daily]
    Cron -->|just expired and not notified| Mail[CertificateExpiredMail to user]
```

## Non-breaking guarantees

- All new DB columns are nullable.
- No existing query, controller, action, or job loses or changes a field.
- `MyCertificateController` keeps its `where('is_approved', true)` filter and includes expired certs (per chosen UX).
- `CertificatesService::get_certificate()` change is purely additive (assign expiry if NULL + draw overlay).
- Validation in existing requests is unchanged; only the new optional rule is added.

## Implementation checklist

- [ ] **migrations** — Add `validity_years` to `courses` and `professional_certificates`, `expires_at` + `last_expiry_notified_at` to `certificates`, and `default_certificate_validity_years` setting (default 2).
- [ ] **models** — Update `Certificate` model (fillable, casts, `resolvedExpiresAt`, `isExpired`) and `ProfessionalCertificate` fillable.
- [ ] **service-issuance** — In `CertificatesService::get_certificate` fill `expires_at` if NULL using the resolver, then save before rendering.
- [ ] **service-overlay** — In all 4 generators print expiry date and draw an EXPIRED watermark when `isExpired()`.
- [ ] **admin-forms** — Add "Validity (years)" field to `courses` and `professionalCertificates` forms + validation rules in Store/Update requests.
- [ ] **admin-lists** — Add "Expires At" column + "Expired" badge to `certificates`, `subscribers-certificates`, `pending-certificates`, `certificates-log` index views.
- [ ] **settings-ui** — Add "Default Certificate Validity (years)" field to the existing settings page.
- [ ] **api-resource** — Expose `expires_at` and `is_expired` in `CertificateResource`.
- [ ] **mail** — Create `CertificateExpiredMail`, mail blade, mail translation keys, and `sendCertificateExpired` in `UserEmailService`.
- [ ] **schedule** — Create `NotifyExpiredCertificates` artisan command and schedule it daily at 06:00 in `Console/Kernel`.
- [ ] **cron-docs** — Add `SETUP_CRON.md` at project root explaining the one-line system cron entry (`* * * * * php artisan schedule:run`), how to install it (`crontab -e`), how to verify (`php artisan schedule:list`), and how to test the new command manually.
- [ ] **translations** — Add `main.php` translation keys (ar/en) for the new labels.
