# Global CRM Rules Settings Section

> **Status**: Implemented, then refactored to **per-module settings tables** (2026-08-19).
> The original blanket `rules` key-value table was replaced by `meeting_settings`,
> `credit_settings`, and `notification_settings` — each module owns its own table, model
> (`MeetingSetting`/`CreditSetting`/`NotificationSetting` extending `App\Models\ModuleSetting`),
> catalog, seeder, and `{module}/setting/*` endpoints. See `docs/api.md`, `docs/database.md`.

## Context

CRM behavior "rules" — meeting overdue buffer, OTP expiry, upcoming-activity reminder window,
payment overdue threshold — are currently hardcoded PHP constants scattered across models,
controllers, jobs, and services (`Meeting::OVERDUE_BUFFER_MINUTES`,
`DispatchUpcomingActivityNotifications::REMINDER_MINUTES_BEFORE`,
`PaymentReminderService::REMINDER_WINDOW_MONTHS`, literal `subMinutes(30)`/`addMinutes(5)` calls).
There is no admin-facing way to view or change them. The goal is one Settings screen where these
rules can be viewed and updated, as a single **global**, system-wide rule-set (not per-company),
stored in a **generic key-value rules table**.

This follows the existing `PermissionCatalog` (`app/Support/PermissionCatalog.php`) pattern of a
static, code-defined catalog driving what's editable, combined with a new DB-backed key-value store
for the actual values — same spirit as how permissions are catalog-defined but role assignments are
DB-backed.

## Scope (confirmed with user)

Editable rule groups:
- **Meeting**: overdue buffer minutes (currently 30), arrival window minutes (currently 30),
  meeting OTP expiry minutes (currently 5)
- **Payment/credit**: overdue reminder window in days (default 60), default credit duration days
  (currently 60 fallback for `Company.credit_duration_days`)
- **Notification/reminder**: upcoming-activity reminder minutes-before (currently 15)

Storage: one global `rules` table, `key` => `value` (no `company_id`), no per-company overrides.

## Design

### 1. Migration + Model
- New migration `database/migrations/{ts}_create_rules_table.php`: `id`, `key` (unique, string),
  `value` (text, nullable), `type` (string: integer/string/boolean — for cast), `group` (string, e.g.
  `meeting`, `payment`, `notification`), `label`, `description` (nullable), `timestamps`.
- `app/Models/Rule.php`: Eloquent model over that table, plus static helpers:
  - `Rule::get(string $key, mixed $default = null): mixed` — cached (`Cache::rememberForever`),
    casts value per `type`.
  - `Rule::set(string $key, mixed $value): void` — updates DB row, busts cache
    (`Cache::forget`/tagged flush).
  - `Rule::all(): array` — grouped list of all settings with metadata, for the index endpoint.

### 2. Catalog of rule definitions
`app/Support/RuleCatalog.php` (mirrors `PermissionCatalog`'s static-array style): defines the
canonical list of editable settings — key, group, label, type, default, validation rule — so the
seeder, controller, and validation all read from one source of truth instead of duplicating keys.

```
'meeting.overdue_buffer_minutes' => ['group' => 'meeting', 'type' => 'integer', 'default' => 30, 'rule' => 'integer|min:1'],
'meeting.arrival_window_minutes' => [...],
'meeting.otp_expiry_minutes' => [...],
'payment.overdue_window_days' => ['group' => 'payment', 'type' => 'integer', 'default' => 60, ...],
'payment.default_credit_duration_days' => ['group' => 'payment', 'type' => 'integer', 'default' => 60, ...],
'notification.follow_up_reminder_minutes' => ['group' => 'notification', 'type' => 'integer', 'default' => 15, ...],
'notification.callback_reminder_minutes' => [...],
'notification.meeting_reminder_minutes' => [...],
'notification.demo_reminder_minutes' => [...],
```

### 3. Seeder
`database/seeders/RuleSeeder.php` — iterates `RuleCatalog::definitions()` and `firstOrCreate`s
each row with its default value. Registered in `DatabaseSeeder.php`.

### 4. Service layer
`app/Services/RuleService.php`:
- `list(): array` — returns all settings grouped, via `Rule::all()`.
- `update(array $payload): array` — validates each key exists in `RuleCatalog`, casts/persists via
  `Rule::set()`, returns updated list. Wrapped in a DB transaction.

### 5. Request validation
`app/Http/Requests/Rule/UpdateRuleRequest.php` — validates payload is an array of
`{key, value}` (or keyed object), each `key` must exist in `RuleCatalog::definitions()`, `value`
validated against that key's `rule`.

### 6. Resource
`app/Http/Resources/RuleResource.php` — shapes `{key, group, label, description, type, value}`.

### 7. Controller
`app/Http/Controllers/RuleController.php`:
- `index()` — permission `setting.rule.read`, returns grouped settings via `RuleService::list()`.
- `update(UpdateRuleRequest $request)` — permission `setting.rule.update`, calls
  `RuleService::update()`, returns updated list, response envelope per CLAUDE.md standard
  (`{success, message, data}`).

### 8. Routes
Add to the existing `Route::prefix('setting')` group in `routes/app.php:638-648`:
```php
Route::prefix('/rule')->group(function () {
    Route::get('/fetch', [RuleController::class, 'index']);
    Route::post('/update', [RuleController::class, 'update']);
});
```
(Matches this file's existing verb/naming convention — `post('/update', ...)`, `post('/fetch', ...)`
— rather than introducing REST verbs inconsistent with the rest of the file.)

Additionally, the same settings must also be reachable from each module's own section (not only the
central Settings page). Since `RuleService::list()` already groups by `group`, add a thin
`group(string $group)` method to `RuleService` that filters to one group, and wire a matching
route inside each module's existing route group, backed by the *same* `RuleController` logic (or
a shared trait/base so the module controllers don't duplicate validation):
- Meeting module (near existing meeting routes) — `GET /meeting/rule/fetch`,
  `POST /meeting/rule/update` → `RuleController::index('meeting')` /
  `RuleController::update($request, 'meeting')`.
- Payment/credit module — `GET /credit/rule/fetch`, `POST /credit/rule/update` scoped to `payment`.
- Notification module — `GET /notification/rule/fetch`, `POST /notification/rule/update` scoped to
  `notification`.

Both the central page and each module's mini-section write to the exact same `rules` rows via
`RuleService`/`Rule::set()`, so there is one source of truth — the module-level endpoints are
just filtered views/edits into the same table, not a separate config store.

### 9. Permissions
Add to `app/Support/PermissionCatalog.php`'s `setting` module (line 97-106):
```php
'rule' => ['read', 'update', 'override'],
```
`setting.rule.*` gates the central Settings page. The module-scoped mini-sections (Meeting, Payment,
Notification) reuse each module's own existing permission (e.g. `meeting_management.meeting.update`)
rather than requiring `setting.rule.update`, so a user who can already manage Meetings can edit
meeting rules without a separate settings-specific grant.

### 10. Replace hardcoded constants with `Rule::get()`
- `app/Models/Meeting.php:15` — keep `OVERDUE_BUFFER_MINUTES` as fallback default only if desired,
  or remove and replace all call sites with `Rule::get('meeting.overdue_buffer_minutes', 30)`.
- `app/Http/Controllers/MeetingController.php:508-509` (arrival window), `:616` (OTP expiry),
  `:1138` (overdue query) — replace literals with `Rule::get(...)`.
- `app/Jobs/DispatchMeetingStateNotifications.php:71` — replace `Meeting::OVERDUE_BUFFER_MINUTES`
  usage with `Rule::get('meeting.overdue_buffer_minutes', 30)`.
- `app/Jobs/DispatchUpcomingActivityNotifications.php` — replace
  `self::REMINDER_MINUTES_BEFORE` with per-type `Rule::get("notification.{$type}_reminder_minutes", 15)`
  (`follow_up`, `callback`, `meeting`, `demo`).
- `app/Services/PaymentReminderService.php:14,52` — replace `self::REMINDER_WINDOW_MONTHS` with
  `Rule::get('payment.overdue_window_days', 60)`.
- `Company.credit_duration_days` fallback-to-60 logic (wherever it defaults) — replace hardcoded `60`
  with `Rule::get('payment.default_credit_duration_days', 60)`.

### 11. Documentation updates (per CLAUDE.md discipline)
- `docs/api.md` — document new `GET /setting/rule/fetch` and `POST /setting/rule/update` endpoints,
  request/response shapes.
- `docs/database.md` — document new `rules` table.
- `docs/business-rules.md` — update each rule's description to note it is now admin-configurable via
  Settings, replacing "hardcoded" language with the setting key name.
- `docs/architecture.md` — note new `RuleService`, `RuleCatalog`, `RuleController`.

## Extensibility: adding new rules later

The generic key-value `rules` table plus `RuleCatalog` exists specifically so future rules
don't need a new migration/column — only a new catalog entry. This mirrors the existing
`PermissionCatalog` → `PermissionSeeder::syncModelPermissions()` pattern (upserts catalog entries
into `model_permissions`, see `database/seeders/PermissionSeeder.php:34-51`), applied the same way
here:

1. **Adding a rule** = add one entry to `RuleCatalog::definitions()` (key, group, label, type,
   default, validation rule). No migration, no controller change.
2. **Getting it into the DB**: add `RuleSeeder::sync(): void`, modeled directly on
   `PermissionSeeder::syncModelPermissions()` — `DB::table('rules')->upsert($rows, ['key'], [...])`
   over `RuleCatalog::definitions()`, run via a scheduled/deploy-time `db:seed --class=RuleSeeder`
   (same as permissions are synced today). Existing keys are left untouched (upsert only fills in new
   rows), so it's safe to re-run any time a new rule is added to the catalog.
3. **Self-healing read path**: `Rule::get($key, $default)` also falls back to
   `RuleCatalog::definitions()[$key]['default'] ?? $default` when the DB row doesn't exist yet, so
   code referencing a brand-new key works correctly even before the next seed/sync runs — it just
   isn't yet visible in the editable UI until synced.
4. **UI auto-discovery within an existing group**: `RuleService::list()`/`group()` iterate
   whatever's in the catalog/DB, so a new rule added to an *existing* group (e.g. a second Meeting
   rule) automatically appears on both the central Settings page and that module's mini-section with
   no route/controller changes.
5. **A brand-new group** (a rule category that isn't Meeting/Payment/Notification) needs one new line
   in `RuleCatalog` plus, only if that module should also get its own mini-section, one small route
   pair wired the same way as step 8 in the Design section above — the central Settings page needs no
   changes either way since it lists all groups generically.

## Verification
- Run `php artisan migrate` and `php artisan db:seed --class=RuleSeeder` locally, confirm the
  `rules` table populates with the 6 default rows.
- Hit `POST /setting/rule/fetch` and `POST /setting/rule/update` (with a valid Sanctum token holding
  `setting.rule.read`/`setting.rule.update`) to confirm grouped output and successful updates.
- Run existing test suite for Meeting/Notification/Payment areas (`tests/Feature/...`) to confirm
  behavior still works with values now sourced from `Rule::get()` with correct fallback defaults.
- Manually change `meeting.overdue_buffer_minutes` via the update endpoint and confirm
  `MeetingController`'s overdue query (line 1138) reflects the new value without a deploy/restart
  (cache busted correctly on `Rule::set()`).
