Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ vendor/
coverage/
storage/
logs/
vault/
*.log
*.cache
*.tmp
Expand Down
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,33 @@ All notable changes to `bitshost/upmvc-saas-pack`.

## [Unreleased]

### Harvest — ops / mail / notify kernel / i18n / MFA primitives

Lifted from the `myCMMS-PHP` probe into pack libraries so every SaaS product gets shared factory pieces without CMMS domain coupling. See `docs/09-harvest-ops.md`.

#### Added
- **`database/migrations/002_saas_ops_layer.sql`** — `platform_settings`, `smtp_settings`, `email_log`, `audit_log`, `notification_*`, `i18n_overlays`, optional `users.totp_*`.
- **`BitsHost\UpmvcSaas\Mail\MailGateway`** + **`Security\SecretBox`** — platform SMTP, plan-gated tenant SMTP, `.env` fallback, send log.
- **`BitsHost\UpmvcSaas\Notify\{NotifyRegistry,NotifyGate,Notify}`** — empty type registry (apps register), global pause, instant fire with mutes.
- **`BitsHost\UpmvcSaas\Audit\AuditLog`** — append-only audit with impersonator support.
- **`BitsHost\UpmvcSaas\Security\Totp`** — RFC 6238 helper for platform MFA.
- **`BitsHost\UpmvcSaas\Ops\{Attention,PlatformSettings,SchedulerHeartbeat}`** — generic Attention + flags + cron heartbeat.
- **`BitsHost\UpmvcSaas\I18n\Catalog`** — file catalogs + dotted-key DB overlays.
- **`Api/Modules/PlatformOps`**, **`Api/Modules/I18n`** — thin overridable API surfaces.
- **`public/lang/{en,ro}.json`** — minimal shell i18n seed.
- **`tests/Unit/HarvestKernelTest.php`** — Totp, registry, audit actors, catalog overlays, migration guard.
- **`docs/09-harvest-ops.md`** — wiring guide and pack vs new-pack rule.

#### Changed
- **`App\Modules\Mail\MailController`** — delegates to `MailGateway` (legacy helpers kept, deprecated).
- **Harvest kernel classes** (`AuditLog`, `Totp`, `SecretBox`, `MailGateway`, `NotifyGate`, `Notify`) — dropped `final` so consuming apps can thin-extend without import churn.
- **`Security\SecretBox` / `Security\Totp`** — dual-unseal read bridge: `seal()` writes with pack peppers only; `unseal()` also accepts myCMMS legacy peppers (`|smtp-password-v1`, `mycmms-totp|`) so path installs do not brick existing SMTP/MFA rows. See `docs/09-harvest-ops.md`.
- **`.gitignore`** — ignore local `vault/` (agent handoff notes; never commit contents).
- **`tests/Unit/HarvestKernelTest.php`** — dual-unseal coverage for legacy SMTP and TOTP peppers.

#### Deliberately out of pack
- Domain notify types, payments/billing, full PlatformAdmin UI tabs, AI translate.

---

## v1.0.0 "Tenancy" - Multi-tenant SaaS modules for upMVC (2026-07-29)
Expand Down
168 changes: 168 additions & 0 deletions database/migrations/002_saas_ops_layer.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
-- ============================================================
-- upMVC SaaS Pack — Migration 002 — Ops layer (Harvest)
--
-- Platform settings, SMTP gateway + email log, audit trail,
-- notification kill-switches / mutes / settings, i18n overlays,
-- optional TOTP columns on users.
--
-- Domain notification *types* stay in the app (NotifyRegistry).
-- ============================================================

SET @db := DATABASE();

CREATE TABLE IF NOT EXISTS `platform_settings` (
`setting_key` varchar(64) NOT NULL,
`setting_value` text DEFAULT NULL,
`updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`setting_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

INSERT IGNORE INTO `platform_settings` (`setting_key`, `setting_value`) VALUES
('signup_enabled', '1'),
('maintenance_mode', '0'),
('scheduler_heartbeat', ''),
('default_locale', 'en'),
('announcement_json', '');

CREATE TABLE IF NOT EXISTS `smtp_settings` (
`id` int NOT NULL AUTO_INCREMENT,
`tenant_id` int DEFAULT NULL,
`host` varchar(253) NOT NULL,
`port` int NOT NULL DEFAULT 587,
`secure` tinyint(1) NOT NULL DEFAULT 0 COMMENT '1 = implicit TLS (465)',
`username` varchar(254) DEFAULT NULL,
`password_enc` text DEFAULT NULL,
`from_email` varchar(254) NOT NULL,
`from_name` varchar(120) DEFAULT NULL,
`reply_to` varchar(254) DEFAULT NULL,
`enabled` tinyint(1) NOT NULL DEFAULT 1,
`last_test_at` datetime DEFAULT NULL,
`last_test_ok` tinyint(1) DEFAULT NULL,
`last_error` varchar(500) DEFAULT NULL,
`updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_smtp_tenant` (`tenant_id`),
CONSTRAINT `fk_smtp_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

SET @q := (
SELECT IF(
EXISTS(SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='smtp_settings' AND COLUMN_NAME='platform_slot'),
'SELECT 1',
'ALTER TABLE smtp_settings ADD COLUMN platform_slot TINYINT GENERATED ALWAYS AS (IF(tenant_id IS NULL, 1, NULL)) STORED'
)
);
PREPARE s FROM @q; EXECUTE s; DEALLOCATE PREPARE s;

SET @q := (
SELECT IF(
EXISTS(SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='smtp_settings' AND INDEX_NAME='uk_smtp_platform'),
'SELECT 1',
'ALTER TABLE smtp_settings ADD UNIQUE KEY uk_smtp_platform (platform_slot)'
)
);
PREPARE s FROM @q; EXECUTE s; DEALLOCATE PREPARE s;

CREATE TABLE IF NOT EXISTS `email_log` (
`id` bigint NOT NULL AUTO_INCREMENT,
`tenant_id` int DEFAULT NULL,
`to_email` varchar(254) NOT NULL,
`subject` varchar(300) NOT NULL,
`template` varchar(80) NOT NULL DEFAULT 'generic',
`transport` enum('tenant','platform','none') NOT NULL DEFAULT 'none',
`status` enum('sent','failed','skipped') NOT NULL,
`error` varchar(500) DEFAULT NULL,
`type` varchar(64) DEFAULT NULL,
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
KEY `idx_email_log_at` (`created_at`),
KEY `idx_email_log_tenant` (`tenant_id`, `created_at`),
KEY `idx_email_log_status` (`status`, `created_at`),
CONSTRAINT `fk_email_log_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE IF NOT EXISTS `audit_log` (
`id` bigint NOT NULL AUTO_INCREMENT,
`tenant_id` int DEFAULT NULL,
`user_id` int DEFAULT NULL,
`impersonator_id` int DEFAULT NULL,
`entity` varchar(64) NOT NULL,
`entity_id` int DEFAULT NULL,
`action` varchar(64) NOT NULL,
`details` json DEFAULT NULL,
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
KEY `idx_audit_tenant_at` (`tenant_id`, `created_at`),
KEY `idx_audit_action` (`action`, `created_at`),
KEY `idx_audit_impersonator` (`impersonator_id`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE IF NOT EXISTS `notification_global` (
`type` varchar(64) NOT NULL,
`paused` tinyint(1) NOT NULL DEFAULT 0,
`reason` varchar(500) DEFAULT NULL,
`updated_by` int DEFAULT NULL,
`updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE IF NOT EXISTS `notification_mutes` (
`user_id` int NOT NULL,
`mute_key` varchar(64) NOT NULL,
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`user_id`, `mute_key`),
CONSTRAINT `fk_notif_mute_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE IF NOT EXISTS `notification_settings` (
`id` int NOT NULL AUTO_INCREMENT,
`tenant_id` int DEFAULT NULL COMMENT 'NULL = platform zone',
`type` varchar(64) NOT NULL,
`enabled` tinyint(1) NOT NULL DEFAULT 1,
`frequency` varchar(16) DEFAULT NULL,
`hour` tinyint DEFAULT NULL,
`weekday` tinyint DEFAULT NULL,
`roles` json DEFAULT NULL,
`only_new` tinyint(1) NOT NULL DEFAULT 1,
`params` json DEFAULT NULL,
`updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_notif_settings` (`tenant_id`, `type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE IF NOT EXISTS `i18n_overlays` (
`locale` varchar(10) NOT NULL,
`msg_key` varchar(191) NOT NULL,
`msg_value` text NOT NULL,
`updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`locale`, `msg_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- TOTP (optional MFA) on users
SET @q := (
SELECT IF(
EXISTS(SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='users' AND COLUMN_NAME='totp_enabled'),
'SELECT 1',
'ALTER TABLE users ADD COLUMN totp_enabled TINYINT(1) NOT NULL DEFAULT 0'
)
);
PREPARE s FROM @q; EXECUTE s; DEALLOCATE PREPARE s;

SET @q := (
SELECT IF(
EXISTS(SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='users' AND COLUMN_NAME='totp_secret_enc'),
'SELECT 1',
'ALTER TABLE users ADD COLUMN totp_secret_enc VARCHAR(255) NULL DEFAULT NULL'
)
);
PREPARE s FROM @q; EXECUTE s; DEALLOCATE PREPARE s;

SET @q := (
SELECT IF(
EXISTS(SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='users' AND COLUMN_NAME='totp_last_step'),
'SELECT 1',
'ALTER TABLE users ADD COLUMN totp_last_step BIGINT NULL DEFAULT NULL'
)
);
PREPARE s FROM @q; EXECUTE s; DEALLOCATE PREPARE s;
66 changes: 66 additions & 0 deletions docs/09-harvest-ops.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# 09 — Harvest: mail, notify kernel, ops, i18n, MFA

This layer was proven in `myCMMS-PHP` and harvested into the pack so every SaaS product gets the same factory pieces without CMMS domain coupling.

## What ships

| Piece | Namespace / path | Role |
|---|---|---|
| Mail gateway | `BitsHost\UpmvcSaas\Mail\MailGateway` | Platform SMTP + plan-gated tenant SMTP + `.env` fallback + `email_log` |
| Secret seal | `BitsHost\UpmvcSaas\Security\SecretBox` | AES-GCM for SMTP passwords (dual-unseal; see below) |
| Notify registry | `BitsHost\UpmvcSaas\Notify\NotifyRegistry` | Apps **register** types; pack stays empty |
| Notify gate | `BitsHost\UpmvcSaas\Notify\NotifyGate` | Global pause / kill switch |
| Notify fire | `BitsHost\UpmvcSaas\Notify\Notify` | Instant send with mute + MailGateway |
| Audit | `BitsHost\UpmvcSaas\Audit\AuditLog` | Append-only trail + impersonator |
| TOTP | `BitsHost\UpmvcSaas\Security\Totp` | Platform MFA helper (dual-unseal; see below) |
| Attention | `BitsHost\UpmvcSaas\Ops\Attention` | SMTP / email fails / scheduler / flags |
| Settings | `BitsHost\UpmvcSaas\Ops\PlatformSettings` | Key/value platform flags |
| Heartbeat | `BitsHost\UpmvcSaas\Ops\SchedulerHeartbeat` | Cron liveness |
| i18n catalog | `BitsHost\UpmvcSaas\I18n\Catalog` | File + DB overlays |
| Migration | `002_saas_ops_layer.sql` | Tables for the above |
| API | `Api/Modules/PlatformOps`, `Api/Modules/I18n` | Thin routes apps can override |
| Shell i18n seed | `public/lang/{en,ro}.json` | Minimal chrome keys |

## App wiring

```php
use BitsHost\UpmvcSaas\Notify\NotifyRegistry;
use BitsHost\UpmvcSaas\Notify\Notify;

// boot / service provider
NotifyRegistry::registerMany([
['key' => 'invoice_paid', 'zone' => 'tenant', 'kind' => 'instant', 'enabled' => true],
]);

// domain code
Notify::fire($tenantId, 'invoice_paid', [['id' => 1, 'email' => $to]], function (array $u) {
return ['subject' => 'Paid', 'html' => '<p>Thanks</p>'];
});
```

Override `App\Modules\Api\Modules\PlatformOps` in the consuming app to add domain Attention items (plan limits, trials, CMMS metrics, …).

Harvest kernel classes are **not** `final` — products may thin-extend (e.g. `App\Cmms\MailGateway extends BitsHost\…`) to keep call-site namespaces stable.

## Seal vs unseal (dual-unseal continuity)

When secrets moved from myCMMS into the pack, only the **pepper string** changed. Existing DB rows still use CMMS peppers.

| | Write (`seal`) | Read (`unseal`) |
|---|---|---|
| **SecretBox** | `{APP_KEY\|JWT}\|saas-secret-v1` only | also `|smtp-password-v1` (+ historical insecure-dev candidates) |
| **Totp** | `upmvc-saas-totp\|{JWT_SECRET}` only | also `mycmms-totp\|{JWT_SECRET}` |

Dual-unseal is a **read bridge**, not dual-write. After a successful unseal + re-save, the next `seal()` stores the pack pepper only. Keep legacy peppers until every known consumer has re-saved; drop them only in a deliberate major with a migration plan.

## Deliberately not in this harvest

- Domain notify types (WO/PM/stock, …)
- Payments / billing (optional future pack)
- Full PlatformAdmin UI tabs (apps override shells)
- AI translate (after overlays are used in production)

## Rule of thumb

If car-rental and ContaPlus both need it before domain logic → saas pack.
If it pulls a heavy vendor SDK or is vertical-only → new pack or app module.
22 changes: 22 additions & 0 deletions public/lang/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"common": {
"loading": "Loading…",
"save": "Save",
"cancel": "Cancel",
"name": "Name",
"email": "Email",
"username": "Username",
"status": "Status",
"language": "Language",
"saved": "Saved",
"failed_load": "Failed to load data.",
"sign_out": "Sign out",
"done": "Done"
},
"platform": {
"ops_tab": "Ops",
"attention_title": "Needs attention",
"attention_clear": "Nothing needs attention.",
"languages_tab": "Languages"
}
}
22 changes: 22 additions & 0 deletions public/lang/ro.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"common": {
"loading": "Se încarcă…",
"save": "Salvează",
"cancel": "Anulează",
"name": "Nume",
"email": "Email",
"username": "Utilizator",
"status": "Status",
"language": "Limbă",
"saved": "Salvat",
"failed_load": "Nu s-au putut încărca datele.",
"sign_out": "Deconectare",
"done": "Gata"
},
"platform": {
"ops_tab": "Ops",
"attention_title": "Necesită atenție",
"attention_clear": "Nimic de rezolvat.",
"languages_tab": "Limbi"
}
}
71 changes: 71 additions & 0 deletions src/Modules/Api/Modules/I18n/Controller.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php

namespace App\Modules\Api\Modules\I18n;

use BitsHost\UpmvcSaas\Http\SaasApiController;
use BitsHost\UpmvcSaas\I18n\Catalog;
use BitsHost\UpmvcSaas\Ops\PlatformSettings;

class Controller extends SaasApiController
{
/** @var list<string> */
private const LOCALES = ['en', 'ro'];

/** GET /api/i18n */
public function index(): never
{
$this->success([
'locales' => self::LOCALES,
'default' => PlatformSettings::get('default_locale', 'en') ?: 'en',
'source' => 'file+overlays',
]);
}

/** GET /api/i18n/{locale} */
public function show(): never
{
$locale = strtolower(trim((string) ($_GET['locale'] ?? '')));
try {
$loaded = null;
$last = null;
foreach ($this->langDirs() as $dir) {
try {
$loaded = Catalog::load($locale, $dir, self::LOCALES);
break;
} catch (\Throwable $e) {
$last = $e;
}
}
if ($loaded === null) {
throw $last ?? new \RuntimeException('Language file not found');
}
} catch (\InvalidArgumentException $e) {
$this->error($e->getMessage(), 404);
} catch (\Throwable $e) {
$this->error($e->getMessage(), 404);
}

$this->success(array_merge($loaded, [
'locales' => self::LOCALES,
'default' => PlatformSettings::get('default_locale', 'en') ?: 'en',
]));
}

/** @return list<string> */
private function langDirs(): array
{
$dirs = [];
if (defined('SITEPATH')) {
$dirs[] = rtrim((string) SITEPATH, '/\\') . DIRECTORY_SEPARATOR . 'lang';
}
// Pack root: …/src/Modules/Api/Modules/I18n → 5 levels up
$packRoot = dirname(__DIR__, 5);
$dirs[] = $packRoot . DIRECTORY_SEPARATOR . 'public' . DIRECTORY_SEPARATOR . 'lang';
// When pack lives under vendor/, prefer the consuming app's public/lang
$vendorParent = dirname($packRoot, 3); // …/vendor/bitshost/upmvc-saas-pack → project
if (is_dir($vendorParent . DIRECTORY_SEPARATOR . 'public' . DIRECTORY_SEPARATOR . 'lang')) {
array_unshift($dirs, $vendorParent . DIRECTORY_SEPARATOR . 'public' . DIRECTORY_SEPARATOR . 'lang');
}
return array_values(array_unique(array_filter($dirs, 'is_dir')));
}
}
Loading
Loading