- PUT v1/client/update: gender theo enum ClientGender (0 Nam/1 Nữ), thêm
birthday (cột mới), province_code/ward_code validate theo bảng provinces/wards
(phường phải thuộc tỉnh). Migration nạp dữ liệu hành chính 2025 từ
province_ward_import.sql (chỉ bảng trống), API GET v1/provinces[/{code}/wards].
- CustomerObserver/OrderObserver log giá trị cũ bằng getRawOriginal: getOriginal
trả enum object, nối chuỗi gây 500 khi khách đổi giới tính.
- ClientResource: các cờ trả boolean, CCCD chỉ trả cccd_updated (không lộ ảnh/số).
- GET v1/setting/menu trả cả items + quick_links, bỏ v1/setting/quick-links.
- Thông báo admin/khách tra theo uuid thay vì id tự tăng.
- Chặn gửi template marketing cho khách đã tắt nhận email marketing.
- Xoá ProvinceWardSeeder và Customer::$updatable không dùng.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
135 lines
4.3 KiB
PHP
135 lines
4.3 KiB
PHP
<?php
|
|
|
|
namespace App\Models;
|
|
|
|
use App\Support\EmailScenarioRegistry;
|
|
use App\Support\EmailTemplateShell;
|
|
use App\Support\MailGlobals;
|
|
use App\Support\MailPlaceholders;
|
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Support\Facades\Cache;
|
|
|
|
class Template extends Model
|
|
{
|
|
use HasFactory;
|
|
|
|
public const CACHE_PREFIX = 'email_template:';
|
|
|
|
protected $table = 'template';
|
|
|
|
protected $fillable = [
|
|
'type_id',
|
|
'scenario',
|
|
'key',
|
|
'format',
|
|
'invoice_template_id',
|
|
'name',
|
|
'subject',
|
|
'message',
|
|
'metadata',
|
|
'builder_data',
|
|
'status',
|
|
];
|
|
|
|
protected $casts = [
|
|
'status' => 'integer',
|
|
'metadata' => 'array',
|
|
'builder_data' => 'array',
|
|
];
|
|
|
|
protected static function booted(): void
|
|
{
|
|
static::saved(fn (self $template) => $template->forgetCache());
|
|
static::deleted(fn (self $template) => $template->forgetCache());
|
|
}
|
|
|
|
public function forgetCache(): void
|
|
{
|
|
if ($this->scenario) {
|
|
Cache::forget(self::CACHE_PREFIX . $this->scenario);
|
|
}
|
|
if ($this->key) {
|
|
Cache::forget(self::CACHE_PREFIX . $this->key);
|
|
}
|
|
}
|
|
|
|
public function isMarketing(): bool
|
|
{
|
|
return EmailScenarioRegistry::describe($this->key, $this->scenario)['category'] === EmailScenarioRegistry::MARKETING;
|
|
}
|
|
|
|
/** Danh sách variable hợp lệ khai báo trong metadata.variables, key theo variable key. */
|
|
public function variables(): array
|
|
{
|
|
return collect($this->metadata['variables'] ?? [])->keyBy('key')->all();
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array{key: string, label?: string|null, example?: string|null}> $overrides
|
|
*/
|
|
public function syncVariables(array $overrides = []): void
|
|
{
|
|
$declared = $this->variables();
|
|
$used = MailPlaceholders::extract($this->subject, $this->message);
|
|
$overrides = collect($overrides)->keyBy('key');
|
|
|
|
$variables = collect(array_unique([...array_keys($declared), ...$used]))
|
|
->reject(fn (string $key) => ($declared[$key]['custom'] ?? false) && !in_array($key, $used, true))
|
|
->map(function (string $key) use ($declared, $overrides) {
|
|
$variable = $declared[$key] ?? ['key' => $key, 'label' => MailGlobals::LABELS[$key] ?? $key, 'type' => 'string', 'custom' => true];
|
|
$override = $overrides->get($key, []);
|
|
|
|
if (filled($override['label'] ?? null)) {
|
|
$variable['label'] = trim($override['label']);
|
|
}
|
|
if (array_key_exists('example', $override)) {
|
|
$variable['example'] = (string) $override['example'];
|
|
}
|
|
|
|
return $variable;
|
|
})
|
|
->values()
|
|
->all();
|
|
|
|
$this->metadata = [...($this->metadata ?? []), 'variables' => $variables];
|
|
}
|
|
|
|
/** Mail sender config khai báo trong metadata.mail (from_email, from_name, reply_to, reply_to_name, cc, bcc). */
|
|
public function mailConfig(): array
|
|
{
|
|
return $this->metadata['mail'] ?? [];
|
|
}
|
|
|
|
public function invoiceTemplate()
|
|
{
|
|
return $this->belongsTo(self::class, 'invoice_template_id');
|
|
}
|
|
|
|
/**
|
|
* Wrap $contentHtml with the admin-editable "layout" template (scenario=layout),
|
|
* replacing its {{$content}} placeholder.
|
|
*
|
|
* Không còn fallback về blade: mọi email đều render từ tbl.template. Thiếu layout thì
|
|
* dùng khung header/footer chuẩn trong code thay vì gửi ra nội dung trần.
|
|
*/
|
|
public static function renderWithLayout(?string $subject, string $contentHtml): string
|
|
{
|
|
return EmailTemplateShell::document($subject, self::layoutBody($contentHtml));
|
|
}
|
|
|
|
public static function layoutBody(string $contentHtml): string
|
|
{
|
|
$layout = self::where('scenario', 'layout')->where('status', 1)->first();
|
|
|
|
return $layout && $layout->message
|
|
? str_replace('{{$content}}', $contentHtml, $layout->message)
|
|
: EmailTemplateShell::wrap(EmailTemplateShell::body($contentHtml));
|
|
}
|
|
|
|
public function type()
|
|
{
|
|
return $this->belongsTo(\App\Models\Type::class);
|
|
}
|
|
}
|