diff --git a/app/Http/Controllers/Api/Admin/MailCampaign/MailCampaignController.php b/app/Http/Controllers/Api/Admin/MailCampaign/MailCampaignController.php new file mode 100644 index 0000000..d0863f5 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/MailCampaign/MailCampaignController.php @@ -0,0 +1,218 @@ +get('per_page', 20), 200); + + $paginator = MailCampaign::query() + ->with('template') + ->orderByDesc('created_at') + ->paginate($limit); + + return response()->json([ + 'error' => false, + 'data' => [ + 'items' => $paginator->getCollection()->map($this->mapCampaign(...))->all(), + 'total' => $paginator->total(), + 'per_page' => $paginator->perPage(), + 'current_page' => $paginator->currentPage(), + 'last_page' => $paginator->lastPage(), + ], + ]); + } + + public function show(string $uuid): JsonResponse + { + $campaign = MailCampaign::with('template')->where('uuid', $uuid)->first(); + + if (!$campaign) { + return response()->json(['error' => true, 'message' => 'Không tìm thấy chiến dịch'], 404); + } + + $logs = Mail::with('customer') + ->where('campaign_id', $campaign->id) + ->orderByDesc('created_at') + ->get() + ->map(fn (Mail $log) => [ + 'id' => $log->id, + 'to_email' => $log->to, + 'to_name' => $log->customer->fullname ?? null, + 'customer_id' => $log->customer_id, + 'status' => $log->status, + 'created_at' => $log->created_at, + ]); + + return response()->json([ + 'error' => false, + 'data' => [ + ...$this->mapCampaign($campaign), + 'logs' => $logs, + ], + ]); + } + + public function preview(Request $request): JsonResponse + { + $data = $request->validate([ + 'template_id' => 'nullable|integer|exists:template,id', + 'subject' => 'required_without:template_id|nullable|string|max:255', + 'message' => 'required_without:template_id|nullable|string', + ]); + + $subject = trim((string) ($data['subject'] ?? '')); + $message = trim((string) ($data['message'] ?? '')); + + if (!empty($data['template_id'])) { + $template = Template::find($data['template_id']); + $subject = trim((string) ($template->subject ?? $subject)); + $message = trim((string) ($template->message ?? $message)); + } + + return response()->json([ + 'error' => false, + 'data' => ['html' => $this->renderPreview($subject, $message)], + ]); + } + + public function previewCampaign(string $uuid): JsonResponse + { + $campaign = MailCampaign::where('uuid', $uuid)->first(); + + if (!$campaign) { + return response()->json(['error' => true, 'message' => 'Không tìm thấy chiến dịch'], 404); + } + + return response()->json([ + 'error' => false, + 'data' => ['html' => $this->renderPreview($campaign->subject, $campaign->message)], + ]); + } + + private function renderPreview(string $subject, string $message): string + { + return view('emails.customer.template-camp', [ + 'content' => $message, + 'emailSubject' => $subject, + ])->render(); + } + + public function store(Request $request): JsonResponse + { + $data = $request->validate([ + 'recipient_type' => 'required|in:all,selected', + 'uuids' => 'required_if:recipient_type,selected|array|min:1', + 'uuids.*' => 'string|exists:customers,uuid', + 'template_id' => 'nullable|integer|exists:template,id', + 'subject' => 'required_without:template_id|nullable|string|max:255', + 'message' => 'required_without:template_id|nullable|string', + 'name' => 'nullable|string|max:255', + 'cc' => 'nullable|string|max:1000', + 'bcc' => 'nullable|string|max:1000', + ]); + + $template = null; + $subject = trim((string) ($data['subject'] ?? '')); + $message = trim((string) ($data['message'] ?? '')); + + if (!empty($data['template_id'])) { + $template = Template::query() + ->whereKey($data['template_id']) + ->where('status', 1) + ->first(); + + if (!$template) { + return response()->json([ + 'error' => true, + 'message' => 'Template không tồn tại hoặc đang bị tắt.', + ], 422); + } + + $subject = trim((string) $template->subject); + $message = trim((string) $template->message); + } + + if ($subject === '' || $message === '') { + return response()->json([ + 'error' => true, + 'message' => 'Subject và content không được để trống.', + ], 422); + } + + $customers = $data['recipient_type'] === 'all' + ? Customer::query()->whereNotNull('email')->get(['id', 'email']) + : Customer::query()->whereIn('uuid', $data['uuids'])->get(['id', 'email']); + + if ($customers->isEmpty()) { + return response()->json([ + 'error' => true, + 'message' => 'Không tìm thấy khách hàng nào để gửi.', + ], 422); + } + + $admin = Auth::guard('admin')->user(); + + $campaign = MailCampaign::create([ + 'name' => trim((string) ($data['name'] ?? '')) ?: $subject, + 'template_id' => $template->id ?? null, + 'subject' => $subject, + 'message' => $message, + 'cc' => $data['cc'] ?? null, + 'bcc' => $data['bcc'] ?? null, + 'recipient_type' => $data['recipient_type'], + 'total_recipients' => $customers->count(), + 'status' => 'queued', + 'created_by' => $admin->id ?? null, + ]); + + foreach ($customers as $customer) { + SendMailCampaignRecipient::dispatch( + $campaign->id, + $customer->id, + $subject, + $message, + $data['cc'] ?? null, + $data['bcc'] ?? null, + ); + } + + return response()->json([ + 'error' => false, + 'message' => 'Chiến dịch đã được tạo và đang gửi.', + 'data' => $this->mapCampaign($campaign), + ]); + } + + private function mapCampaign(MailCampaign $campaign): array + { + return [ + 'id' => $campaign->id, + 'uuid' => $campaign->uuid, + 'name' => $campaign->name, + 'subject' => $campaign->subject, + 'template_id' => $campaign->template_id, + 'template_name' => $campaign->template->name ?? null, + 'total_recipients' => $campaign->total_recipients, + 'sent_count' => $campaign->sent_count, + 'failed_count' => $campaign->failed_count, + 'status' => $campaign->status, + 'completed_at' => $campaign->completed_at, + 'created_at' => $campaign->created_at, + ]; + } +} diff --git a/app/Jobs/Mail/SendMailCampaignRecipient.php b/app/Jobs/Mail/SendMailCampaignRecipient.php new file mode 100644 index 0000000..d4a9441 --- /dev/null +++ b/app/Jobs/Mail/SendMailCampaignRecipient.php @@ -0,0 +1,104 @@ +campaignId); + $customer = Customer::find($this->customerId); + + if (!$campaign || !$customer || !$customer->email) { + $this->markSent($campaign, failed: true); + return; + } + + try { + $mailer = Mail::to($customer->email); + if ($cc = $this->parseRecipients($this->cc)) { + $mailer->cc($cc); + } + if ($bcc = $this->parseRecipients($this->bcc)) { + $mailer->bcc($bcc); + } + $mailer->send(new ManualCustomerEmail($this->subject, $this->message)); + + \App\Models\Mail::create([ + 'campaign_id' => $campaign->id, + 'template_id' => $campaign->template_id ?? 0, + 'subject' => $this->subject, + 'to' => $customer->email, + 'customer_id' => $customer->id, + 'message' => $this->message, + 'status' => 'sent', + ]); + + $this->markSent($campaign, failed: false); + } catch (\Throwable) { + \App\Models\Mail::create([ + 'campaign_id' => $campaign->id, + 'template_id' => $campaign->template_id ?? 0, + 'subject' => $this->subject, + 'to' => $customer->email, + 'customer_id' => $customer->id, + 'message' => $this->message, + 'status' => 'failed', + ]); + + $this->markSent($campaign, failed: true); + } + } + + private function parseRecipients(?string $value): array + { + if (!$value) { + return []; + } + + return collect(preg_split('/[,;]+/', $value) ?: []) + ->map(fn ($email) => trim((string) $email)) + ->filter(fn ($email) => $email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) + ->unique() + ->values() + ->all(); + } + + private function markSent(?MailCampaign $campaign, bool $failed): void + { + if (!$campaign) { + return; + } + + $campaign->increment($failed ? 'failed_count' : 'sent_count'); + $campaign->refresh(); + + if ($campaign->sent_count + $campaign->failed_count >= $campaign->total_recipients) { + $campaign->update([ + 'status' => $campaign->failed_count > 0 && $campaign->sent_count === 0 ? 'failed' : 'completed', + 'completed_at' => now(), + ]); + } + } +} diff --git a/app/Mail/Customer/ManualCustomerEmail.php b/app/Mail/Customer/ManualCustomerEmail.php index 56eea90..ede4f7e 100644 --- a/app/Mail/Customer/ManualCustomerEmail.php +++ b/app/Mail/Customer/ManualCustomerEmail.php @@ -24,7 +24,10 @@ class ManualCustomerEmail extends Mailable public function content(): Content { - return new Content(htmlString: $this->htmlContent); + return new Content( + view: 'emails.customer.template-camp', + with: ['content' => $this->htmlContent, 'emailSubject' => $this->emailSubject], + ); } public function attachments(): array diff --git a/app/Models/Mail.php b/app/Models/Mail.php index 5ffd87b..b340e7f 100644 --- a/app/Models/Mail.php +++ b/app/Models/Mail.php @@ -12,6 +12,7 @@ class Mail extends Model protected $table = 'mail'; protected $fillable = [ + 'campaign_id', 'template_id', 'subject', 'to', @@ -42,4 +43,9 @@ class Mail extends Model { return $this->belongsTo(Template::class, 'template_id'); } + + public function campaign() + { + return $this->belongsTo(MailCampaign::class, 'campaign_id'); + } } diff --git a/app/Models/MailCampaign.php b/app/Models/MailCampaign.php new file mode 100644 index 0000000..a41e0ad --- /dev/null +++ b/app/Models/MailCampaign.php @@ -0,0 +1,55 @@ + 'integer', + 'sent_count' => 'integer', + 'failed_count' => 'integer', + 'completed_at' => 'datetime', + ]; + + protected static function booted(): void + { + static::creating(function (self $campaign) { + $campaign->uuid ??= (string) Str::uuid(); + }); + } + + public function logs() + { + return $this->hasMany(Mail::class, 'campaign_id'); + } + + public function template() + { + return $this->belongsTo(Template::class, 'template_id'); + } +} diff --git a/app/Repositories/Order/OrderRepository.php b/app/Repositories/Order/OrderRepository.php index 00782ac..81a7752 100644 --- a/app/Repositories/Order/OrderRepository.php +++ b/app/Repositories/Order/OrderRepository.php @@ -54,12 +54,25 @@ class OrderRepository extends BaseRepository implements OrderRepositoryInterface $model = $model->where("invoice_id", $data['invoice_id']); } } - if (isset($data['status']) && ! empty($data['status']) && $data['status'] != 'full') { - $model = $model->where("status", match ($data['status']) { + if (isset($data['status']) && $data['status'] !== '' && $data['status'] !== null && $data['status'] != 'full') { + $status = match ($data['status']) { 'active' => 1, 'pending' => 0, 'processing' => 3, 'cancel' => 2, + default => $data['status'], + }; + $model = $model->where("status", $status); + } + + if (isset($data['filter']) && $data['filter'] !== '') { + $search = $data['filter']; + $model = $model->where(function ($query) use ($search) { + $query->where('order_num', 'like', "%{$search}%") + ->orWhereHas('customer', function ($query) use ($search) { + $query->where('fullname', 'like', "%{$search}%") + ->orWhere('email', 'like', "%{$search}%"); + }); }); } diff --git a/database/migrations/2026_08_20_000001_create_mail_campaigns_table.php b/database/migrations/2026_08_20_000001_create_mail_campaigns_table.php new file mode 100644 index 0000000..22898a9 --- /dev/null +++ b/database/migrations/2026_08_20_000001_create_mail_campaigns_table.php @@ -0,0 +1,40 @@ +id(); + $table->uuid('uuid')->unique(); + $table->string('name', 255)->nullable(); + $table->bigInteger('template_id')->nullable()->index(); + $table->string('subject', 255); + $table->longText('message'); + $table->unsignedInteger('total_recipients')->default(0); + $table->unsignedInteger('sent_count')->default(0); + $table->unsignedInteger('failed_count')->default(0); + $table->string('status', 20)->default('queued'); + $table->bigInteger('created_by')->nullable()->index(); + $table->timestamp('completed_at')->nullable(); + $table->timestamps(); + }); + + Schema::table('mail', function (Blueprint $table) { + $table->bigInteger('campaign_id')->nullable()->index()->after('id'); + }); + } + + public function down(): void + { + Schema::table('mail', function (Blueprint $table) { + $table->dropColumn('campaign_id'); + }); + + Schema::dropIfExists('mail_campaigns'); + } +}; diff --git a/database/migrations/2026_08_20_000002_add_cc_bcc_recipient_type_to_mail_campaigns_table.php b/database/migrations/2026_08_20_000002_add_cc_bcc_recipient_type_to_mail_campaigns_table.php new file mode 100644 index 0000000..8533b8e --- /dev/null +++ b/database/migrations/2026_08_20_000002_add_cc_bcc_recipient_type_to_mail_campaigns_table.php @@ -0,0 +1,24 @@ +string('cc', 1000)->nullable()->after('message'); + $table->string('bcc', 1000)->nullable()->after('cc'); + $table->string('recipient_type', 20)->default('selected')->after('bcc'); + }); + } + + public function down(): void + { + Schema::table('mail_campaigns', function (Blueprint $table) { + $table->dropColumn(['cc', 'bcc', 'recipient_type']); + }); + } +}; diff --git a/database/seeders/RolesAndPermissions.php b/database/seeders/RolesAndPermissions.php index 1acef0e..2320a85 100644 --- a/database/seeders/RolesAndPermissions.php +++ b/database/seeders/RolesAndPermissions.php @@ -21,6 +21,11 @@ class RolesAndPermissions extends Seeder 'customer.destroy', 'customer.manager', + // Chiến dịch email + 'mail-campaign.view', + 'mail-campaign.create', + 'mail-campaign.manager', + // Nhân sự 'administrator.view', 'administrator.create', diff --git a/resources/views/emails/customer/template-camp.blade.php b/resources/views/emails/customer/template-camp.blade.php new file mode 100644 index 0000000..5edeacc --- /dev/null +++ b/resources/views/emails/customer/template-camp.blade.php @@ -0,0 +1,110 @@ + + + + + + {{ $emailSubject ?? '' }} + + + + + + + + +
+ + + + + + + + + + + + +
+ + + + + + +
+ + Thư gửi từ Công Ty Cổ Phần Giải Pháp Công Nghệ Thế Giới Số + +
+
+ {!! $content !!} +
+
+ + diff --git a/routes/api.php b/routes/api.php index d1f1556..7426860 100644 --- a/routes/api.php +++ b/routes/api.php @@ -36,6 +36,7 @@ use App\Http\Controllers\Api\Admin\EmailNotificationTemplateController; use App\Http\Controllers\Api\Admin\ActivityLog\ActivityLogController; use App\Http\Controllers\Api\Admin\Rebuild\AdminReinstallLogController; use App\Http\Controllers\Api\Admin\EmailLog\EmailLogController; +use App\Http\Controllers\Api\Admin\MailCampaign\MailCampaignController; use App\Http\Controllers\Api\Admin\Transaction\AdminTransactionController; use App\Http\Controllers\Api\Admin\Ticket\TicketController as AdminTicketController; use App\Http\Controllers\Api\Admin\Docs\PostCategoryController as AdminPostCategoryController; @@ -699,6 +700,17 @@ Route::prefix('v1') Route::get('{id}', 'show')->whereNumber('id'); }); + // Mail Campaigns (module: mail-campaign) + Route::prefix('mail-campaigns') + ->controller(MailCampaignController::class) + ->group(function () { + Route::get('/', 'index')->middleware('perm:mail-campaign.view'); + Route::post('preview', 'preview')->middleware('perm:mail-campaign.create'); + Route::get('{uuid}/preview', 'previewCampaign')->middleware('perm:mail-campaign.view'); + Route::get('{uuid}', 'show')->middleware('perm:mail-campaign.view'); + Route::post('/', 'store')->middleware('perm:mail-campaign.create'); + }); + // VM Rebuild Logs — who rebuilt, which OS, post-rebuild password Route::prefix('rebuild-logs') ->controller(AdminReinstallLogController::class)