update migration seeder email template

This commit is contained in:
baodev0
2026-10-22 12:23:05 +07:00
parent 0fef417dcb
commit 54c14c41d8
6 changed files with 208 additions and 13 deletions
+1
View File
@@ -20,6 +20,7 @@ final class MailPlaceholders
'name' => 'name',
'customer_email' => 'email',
'email' => 'email',
'account_email' => 'email',
'customer_phone' => 'phone',
'phone' => 'phone',
];
@@ -0,0 +1,14 @@
<?php
use Database\Seeders\TemplateSeeder;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
public function up(): void
{
(new TemplateSeeder())->seedServiceLifecycleTemplates();
}
public function down(): void {}
};
@@ -0,0 +1,14 @@
<?php
use Database\Seeders\TemplateSeeder;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
public function up(): void
{
(new TemplateSeeder())->seedServiceLifecycleTemplates();
}
public function down(): void {}
};
@@ -0,0 +1,14 @@
<?php
use Database\Seeders\TemplateSeeder;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
public function up(): void
{
(new TemplateSeeder())->seedServiceLifecycleTemplates();
}
public function down(): void {}
};
@@ -71,6 +71,12 @@ final class ServiceLifecycleTemplates
],
];
private const NEW_WITH_ACCOUNT_ROWS = [
'ssl' => ['Tên miền' => 'domain_name', 'Loại chứng chỉ' => 'ssl_type', 'Ngày cấp' => 'issued_date', 'Ngày hết hạn' => 'expires_date'],
'hosting' => ['Gói dịch vụ' => 'hosting_plan', 'Tên miền' => 'domain_name', 'IP / Host' => 'service_host', 'Tài khoản Hosting' => 'service_user', 'Mật khẩu Hosting' => 'service_pass', 'Ngày cấp' => 'issued_date', 'Ngày hết hạn' => 'expires_date'],
'cloud_vps' => ['Dịch vụ' => 'service_name', 'Hệ điều hành' => 'os_name', 'IP / Host' => 'service_host', 'Tài khoản VPS' => 'service_user', 'Mật khẩu VPS' => 'service_pass', 'Ngày cấp' => 'issued_date', 'Ngày hết hạn' => 'expires_date'],
];
private const RENEWALS = [
'vps' => ['label' => 'VPS', 'noun' => 'VPS', 'target' => 'service_name', 'column' => 'Dịch vụ'],
'hosting' => ['label' => 'Hosting', 'noun' => 'Hosting', 'target' => 'service_name', 'column' => 'Dịch vụ'],
@@ -114,6 +120,7 @@ final class ServiceLifecycleTemplates
$items[] = self::resetService($code, $service);
foreach ([true, false] as $hasAccount) {
$items[] = self::trialService($service, $hasAccount);
$items[] = self::newServiceWithAccount($code, $service, $hasAccount);
}
}
@@ -121,6 +128,10 @@ final class ServiceLifecycleTemplates
$items[] = self::renewService($service);
}
foreach ([true, false] as $hasAccount) {
$items[] = self::genericNewService($hasAccount);
}
return $items;
}
@@ -182,7 +193,73 @@ final class ServiceLifecycleTemplates
);
}
private static function domainRegisteredBody(): string
private static function genericNewService(bool $hasAccount): array
{
$accessRows = '';
foreach (['IP / Host' => 'service_host', 'Tài khoản dịch vụ' => 'service_user', 'Mật khẩu dịch vụ' => 'service_pass'] as $label => $key) {
$accessRows .= '{{#if ' . $key . '}}' . T::row($label, '{{' . $key . '}}') . '{{/if}}';
}
return self::template(
'Dịch vụ — Cấp mới dịch vụ (' . ($hasAccount ? 'đã có tài khoản' : 'chưa có tài khoản') . ')',
'V/v: Thế Giới Số cấp mới dịch vụ - {{service_name}} | {{client_name}}',
self::newServiceWithAccountBody(
'Dịch vụ <strong>{{service_name}}</strong>',
T::infoTable(['Dịch vụ' => '{{service_name}}', 'Ngày cấp' => '{{issued_date}}', 'Ngày hết hạn' => '{{expires_date}}'], $accessRows),
$hasAccount,
),
['service_host' => '', 'service_user' => '', 'service_pass' => ''],
);
}
private static function newServiceWithAccount(string $code, array $service, bool $hasAccount): array
{
$isDomain = $code === 'domain';
return self::template(
"{$service['label']} — Cấp mới dịch vụ (khách hàng " . ($hasAccount ? 'đã có tài khoản' : 'chưa có tài khoản') . ')',
self::subject("cấp mới {$service['noun']}", $service),
self::newServiceWithAccountBody(
"{$service['label']} <strong>{{{$service['target']}}}</strong>",
$isDomain ? self::domainTable() : T::infoTable(self::rows(self::NEW_WITH_ACCOUNT_ROWS[$code])),
$hasAccount,
$isDomain ? self::kycSection('III') : '',
),
);
}
private static function newServiceWithAccountBody(string $serviceHtml, string $serviceTable, bool $hasAccount, string $afterAccount = ''): string
{
$loginLink = '<a style="color: #c2410c;" href="{{login_url}}" target="_blank" rel="noopener">{{login_url}}</a>';
$account = $hasAccount
? T::paragraph('Dịch vụ đã được gắn vào tài khoản <strong>{{account_email}}</strong> Quý khách đang sử dụng tại {{app_name}}. Vui lòng đăng nhập bằng tài khoản này để quản lý dịch vụ.')
. T::infoTable([
'Trang đăng nhập' => $loginLink,
'Email đăng nhập' => '{{account_email}}',
'Mật khẩu' => 'Mật khẩu hiện tại của Quý khách',
])
. T::note('Nếu không nhớ mật khẩu, Quý khách chọn <strong>"Quên mật khẩu"</strong> tại trang đăng nhập để đặt lại.')
: T::paragraph('Hệ thống đã tạo tài khoản để Quý khách đăng nhập và quản lý dịch vụ:')
. T::infoTable([
'Trang đăng nhập' => $loginLink,
'Email đăng nhập' => '{{account_email}}',
'Mật khẩu' => '{{account_pass}}',
])
. T::note('Quý khách vui lòng đổi mật khẩu ngay sau lần đăng nhập đầu tiên và không chia sẻ thông tin tài khoản cho người khác.', 'Bảo mật:');
return T::greeting('{{client_name}}')
. T::paragraph("Cảm ơn Quý khách đã tin tưởng sử dụng dịch vụ của <strong>{{app_name}}</strong>. {$serviceHtml} đã được khởi tạo thành công.")
. self::section('📋 I. THÔNG TIN DỊCH VỤ')
. $serviceTable
. self::section('🔐 II. TÀI KHOẢN QUẢN LÝ DỊCH VỤ')
. $account
. T::button('{{login_url}}', 'ĐĂNG NHẬP QUẢN LÝ DỊCH VỤ')
. $afterAccount
. T::supportLine()
. T::paragraph('Trân trọng,');
}
private static function domainTable(): string
{
$cell = 'box-sizing: border-box; padding: 10px; font-size: 13px; text-align: center;';
$head = T::HEAD_CELL_STYLE . ' text-align: center;';
@@ -194,13 +271,27 @@ final class ServiceLifecycleTemplates
foreach (['{{domain_name}}', '{{issued_date}}', '{{expires_date}}', '{{name_server_1}}', '{{name_server_2}}'] as $value) {
$values .= '<td style="' . $cell . ' color: #111827;"><strong>' . $value . '</strong></td>';
}
$domainTable = '<table style="' . T::TABLE_STYLE . ' margin: 0 0 24px 0;" role="presentation" width="100%" cellspacing="0" cellpadding="0"><tbody><tr>'
. $headers . '</tr><tr>' . $values . '</tr></tbody></table>';
return '<table style="' . T::TABLE_STYLE . ' margin: 0 0 24px 0;" role="presentation" width="100%" cellspacing="0" cellpadding="0"><tbody><tr>'
. $headers . '</tr><tr>' . $values . '</tr></tbody></table>';
}
private static function kycSection(string $number): string
{
return '{{#if kyc_url}}'
. self::section("📋 {$number}. LƯU Ý QUAN TRỌNG")
. T::paragraph('Căn cứ Khoản 2 Điều 9 và Khoản 6 Điều 21 Thông tư số 48/2025/TT-BKHCN ngày 25/12/2025 của Bộ Khoa học và Công nghệ về Quản lý và sử dụng tài nguyên Internet, chủ thể đăng ký tên miền là cá nhân có trách nhiệm hoàn thiện hồ sơ đăng ký thông qua hình thức xác thực điện tử (eKYC) trong vòng <strong>03 ngày</strong> kể từ thời điểm nhận được email này.')
. T::button('{{kyc_url}}', '✅ XÁC THỰC eKYC NGAY')
. T::note('Việc không hoàn thành xác thực đúng hạn có thể ảnh hưởng đến quyền sử dụng tên miền của Quý khách.', '⏰ Lưu ý:')
. '{{/if}}';
}
private static function domainRegisteredBody(): string
{
return T::greeting('{{client_name}}')
. T::paragraph('Công ty Cổ phần Giải pháp Công nghệ Thế Giới Số xin chân thành cảm ơn Quý khách đã tin tưởng lựa chọn dịch vụ đăng ký và quản lý tên miền của chúng tôi.')
. self::section('🌐 I. THÔNG TIN TÊN MIỀN')
. $domainTable
. self::domainTable()
. self::section('🔐 II. THÔNG TIN TÀI KHOẢN QUẢN LÝ')
. T::infoTable([
'Trang đăng nhập' => '<a style="color: #c2410c;" href="{{service_login_url}}" target="_blank" rel="noopener">{{service_login_url}}</a>',
@@ -208,12 +299,7 @@ final class ServiceLifecycleTemplates
'Mật khẩu' => '{{account_pass}}',
])
. T::note('Quý khách vui lòng đổi mật khẩu ngay sau lần đăng nhập đầu tiên và bảo mật thông tin tài khoản.', 'Bảo mật:')
. '{{#if kyc_url}}'
. self::section('📋 III. LƯU Ý QUAN TRỌNG')
. T::paragraph('Căn cứ Khoản 2 Điều 9 và Khoản 6 Điều 21 Thông tư số 48/2025/TT-BKHCN ngày 25/12/2025 của Bộ Khoa học và Công nghệ về Quản lý và sử dụng tài nguyên Internet, chủ thể đăng ký tên miền là cá nhân có trách nhiệm hoàn thiện hồ sơ đăng ký thông qua hình thức xác thực điện tử (eKYC) trong vòng <strong>03 ngày</strong> kể từ thời điểm nhận được email này.')
. T::button('{{kyc_url}}', '✅ XÁC THỰC eKYC NGAY')
. T::note('Việc không hoàn thành xác thực đúng hạn có thể ảnh hưởng đến quyền sử dụng tên miền của Quý khách.', '⏰ Lưu ý:')
. '{{/if}}'
. self::kycSection('III')
. self::section('📞 {{#if kyc_url}}IV{{else}}III{{/if}}. HỖ TRỢ KỸ THUẬT 24/7')
. T::paragraph('<strong>Hotline:</strong> (028) 7309 7379<br><strong>Email:</strong> <a style="color: #c2410c;" href="mailto:support@tgs.com.vn">support@tgs.com.vn</a>')
. T::paragraph('Một lần nữa, Thế Giới Số xin chân thành cảm ơn sự tin tưởng và đồng hành của Quý khách. Chúng tôi luôn sẵn sàng hỗ trợ để đảm bảo trải nghiệm dịch vụ tốt nhất.')
@@ -305,12 +391,15 @@ final class ServiceLifecycleTemplates
return "V/v: Thế Giới Số {$topic} - {{{$service['target']}}} | {{client_name}}";
}
private static function template(string $name, string $subject, string $body): array
/**
* @param array<string, string> $examples
*/
private static function template(string $name, string $subject, string $body, array $examples = []): array
{
$variables = [];
foreach (MailPlaceholders::extract($subject, $body) as $key) {
if (isset(self::VARIABLES[$key])) {
$variables[] = ['key' => $key, 'label' => self::VARIABLES[$key][0], 'example' => self::VARIABLES[$key][1]];
$variables[] = ['key' => $key, 'label' => self::VARIABLES[$key][0], 'example' => $examples[$key] ?? self::VARIABLES[$key][1]];
}
}
+64 -1
View File
@@ -2,10 +2,12 @@
namespace Tests\Feature;
use App\Models\Customer;
use App\Models\Template;
use App\Services\Email\EmailTemplateRenderer;
use App\Services\Email\EmailTemplateService;
use App\Support\EmailScenarioRegistry;
use App\Support\MailGlobals;
use App\Support\MailPlaceholders;
use Database\Seeders\EmailTemplates\TemplateVariables;
use Database\Seeders\EmailTemplates\ServiceLifecycleTemplates;
@@ -43,7 +45,7 @@ class TemplateSeederTest extends TestCase
public function test_service_lifecycle_templates_are_seeded_once_as_marketing(): void
{
$items = ServiceLifecycleTemplates::all();
$this->assertCount(27, $items);
$this->assertCount(37, $items);
$this->seed(TemplateSeeder::class);
$this->seed(TemplateSeeder::class);
@@ -56,6 +58,67 @@ class TemplateSeederTest extends TestCase
}
}
public function test_generic_new_service_template_fills_customer_email_and_hides_empty_access_rows(): void
{
$this->seed(TemplateSeeder::class);
$template = Template::where('name', 'Dịch vụ — Cấp mới dịch vụ (chưa có tài khoản)')->firstOrFail();
$customer = new Customer(['fullname' => 'Nguyễn Văn A', 'email' => 'nva@example.com']);
$values = collect($template->variables())->map(fn ($definition) => $definition['example'] ?? '')
->merge(MailGlobals::values())
->merge(MailPlaceholders::recipientValues($customer, $customer->email))
->merge(['account_pass' => 'Pass#2026'])
->all();
$html = app(EmailTemplateRenderer::class)->render($template, $values)['html'];
$this->assertStringContainsString('nva@example.com', $html);
$this->assertStringContainsString('Pass#2026', $html);
$this->assertStringContainsString(MailGlobals::values()['login_url'], $html);
$this->assertStringNotContainsString('IP / Host', $html);
}
public function test_cloud_vps_new_service_templates_include_service_and_customer_account(): void
{
$this->seed(TemplateSeeder::class);
$customer = new Customer(['fullname' => 'Nguyễn Văn A', 'email' => 'nva@example.com']);
foreach (['đã có tài khoản' => false, 'chưa có tài khoản' => true] as $suffix => $showsPassword) {
$template = Template::where('name', "Cloud VPS — Cấp mới dịch vụ (khách hàng {$suffix})")->firstOrFail();
$values = collect($template->variables())->map(fn ($definition) => $definition['example'] ?? '')
->merge(MailGlobals::values())
->merge(MailPlaceholders::recipientValues($customer, $customer->email))
->merge(['account_pass' => 'Pass#2026', 'service_host' => '203.0.113.99'])
->all();
$html = app(EmailTemplateRenderer::class)->render($template, $values)['html'];
$this->assertStringContainsString('nva@example.com', $html, $suffix);
$this->assertStringContainsString('203.0.113.99', $html, $suffix);
$this->assertStringContainsString('Hệ điều hành', $html, $suffix);
$this->assertSame($showsPassword, str_contains($html, 'Pass#2026'), $suffix);
}
}
public function test_every_service_has_new_service_templates_by_account_state(): void
{
$this->seed(TemplateSeeder::class);
$customer = new Customer(['fullname' => 'Nguyễn Văn A', 'email' => 'nva@example.com']);
foreach (['SSL', 'Tên miền', 'Hosting', 'Cloud VPS'] as $label) {
foreach (['đã có tài khoản', 'chưa có tài khoản'] as $suffix) {
$template = Template::where('name', "{$label} — Cấp mới dịch vụ (khách hàng {$suffix})")->firstOrFail();
$values = collect($template->variables())->map(fn ($definition) => $definition['example'] ?? '')
->merge(MailGlobals::values())
->merge(MailPlaceholders::recipientValues($customer, $customer->email))
->all();
$html = app(EmailTemplateRenderer::class)->render($template, $values)['html'];
$this->assertStringContainsString('nva@example.com', $html, "{$label} {$suffix}");
$this->assertStringContainsString(MailGlobals::values()['login_url'], $html, "{$label} {$suffix}");
$this->assertStringNotContainsString('eKYC', $html, "{$label} {$suffix}");
}
}
}
public function test_every_placeholder_has_a_variable_definition(): void
{
foreach ([...TemplateSeeder::templates(), ...TemplateSeeder::legacyTemplates()] as $item) {