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 ?? '' }}
+
+
+
+
+
+
+
+
+
+
+ |
+ {!! $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)