diff --git a/app/Http/Controllers/Api/Admin/Customer/CustomerController.php b/app/Http/Controllers/Api/Admin/Customer/CustomerController.php index 4c7d24d..95ec4eb 100644 --- a/app/Http/Controllers/Api/Admin/Customer/CustomerController.php +++ b/app/Http/Controllers/Api/Admin/Customer/CustomerController.php @@ -53,7 +53,7 @@ class CustomerController extends Controller { $perPage = min((int) $request->get('per_page', 15), 500); $paginator = $this->customerRepository - ->filter($request->all(), [], ['hosting', 'vps', 'domain'], ['*'], true) + ->filter($request->all(), ['tags'], ['hosting', 'vps', 'domain'], ['*'], true) ->paginate($perPage); return $this->paginatedResponse($paginator); diff --git a/app/Http/Controllers/Api/Admin/Invoice/InvoiceController.php b/app/Http/Controllers/Api/Admin/Invoice/InvoiceController.php index dadcb7b..b6b31fd 100644 --- a/app/Http/Controllers/Api/Admin/Invoice/InvoiceController.php +++ b/app/Http/Controllers/Api/Admin/Invoice/InvoiceController.php @@ -49,6 +49,7 @@ class InvoiceController extends Controller 'customer', 'order', ...OrderItemServiceResource::relationsFor('order.orderItems'), + 'tags', ], [], ['*'], diff --git a/app/Http/Controllers/Api/Admin/MailCampaign/Concerns/HandlesCampaignBulkActions.php b/app/Http/Controllers/Api/Admin/MailCampaign/Concerns/HandlesCampaignBulkActions.php new file mode 100644 index 0000000..a278fc8 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/MailCampaign/Concerns/HandlesCampaignBulkActions.php @@ -0,0 +1,100 @@ +validate([ + 'action' => 'required|in:cancel,retry-failed', + 'uuids' => 'required|array|min:1|max:200', + 'uuids.*' => 'string', + ]); + + $campaigns = MailCampaign::whereIn('uuid', $data['uuids'])->get(); + + $message = $data['action'] === 'cancel' + ? $this->cancelCampaigns($campaigns) + : $this->retryFailedRecipients($campaigns); + + return response()->json(['error' => false, 'message' => $message]); + } + + public function bulkDelete(Request $request): JsonResponse + { + $data = $request->validate([ + 'uuids' => 'required|array|min:1|max:200', + 'uuids.*' => 'string', + ]); + + $deleted = MailCampaign::whereIn('uuid', $data['uuids']) + ->whereIn('status', MailCampaign::FINISHED_STATUSES) + ->delete(); + $skipped = count($data['uuids']) - $deleted; + + return response()->json([ + 'error' => false, + 'message' => "Đã xoá {$deleted} chiến dịch." + . ($skipped > 0 ? " Bỏ qua {$skipped} chiến dịch đang gửi (huỷ gửi trước khi xoá)." : ''), + ]); + } + + /** + * @param Collection $campaigns + */ + private function cancelCampaigns(Collection $campaigns): string + { + $cancelled = MailCampaign::whereKey($campaigns->modelKeys()) + ->whereIn('status', MailCampaign::ACTIVE_STATUSES) + ->update(['status' => MailCampaign::STATUS_CANCELLED, 'completed_at' => now()]); + + return "Đã huỷ gửi {$cancelled} chiến dịch. Email đã gửi trước đó không bị thu hồi."; + } + + /** + * @param Collection $campaigns + */ + private function retryFailedRecipients(Collection $campaigns): string + { + $retried = 0; + + foreach ($campaigns->whereIn('status', ['completed', 'failed']) as $campaign) { + DB::transaction(function () use ($campaign, &$retried) { + $failedLogs = Mail::where('campaign_id', $campaign->id) + ->where('status', array_search('failed', Mail::STATUS_MAP, true)) + ->lockForUpdate() + ->get(['id', 'to', 'customer_id']); + if ($failedLogs->isEmpty()) { + return; + } + + Mail::whereKey($failedLogs->modelKeys())->delete(); + $campaign->forceFill([ + 'failed_count' => max(0, $campaign->failed_count - $failedLogs->count()), + 'status' => 'sending', + 'completed_at' => null, + ])->save(); + + foreach ($failedLogs as $log) { + SendMailCampaignRecipient::dispatch($campaign->id, $log->customer_id, $log->to)->afterCommit(); + } + $retried += $failedLogs->count(); + }); + } + + return $retried > 0 + ? "Đã đưa {$retried} email lỗi vào hàng đợi gửi lại." + : 'Không có email lỗi nào để gửi lại (chỉ gửi lại chiến dịch đã kết thúc).'; + } +} diff --git a/app/Http/Controllers/Api/Admin/MailCampaign/MailCampaignController.php b/app/Http/Controllers/Api/Admin/MailCampaign/MailCampaignController.php index 2574bce..dc55714 100644 --- a/app/Http/Controllers/Api/Admin/MailCampaign/MailCampaignController.php +++ b/app/Http/Controllers/Api/Admin/MailCampaign/MailCampaignController.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Http\Controllers\Api\Admin\MailCampaign; +use App\Http\Controllers\Api\Admin\MailCampaign\Concerns\HandlesCampaignBulkActions; use App\Http\Controllers\Controller; use App\Jobs\Mail\SendMailCampaignRecipient; use App\Models\Customer; @@ -19,6 +20,8 @@ use Illuminate\Validation\ValidationException; final class MailCampaignController extends Controller { + use HandlesCampaignBulkActions; + public function index(Request $request): JsonResponse { $limit = min((int) $request->get('per_page', 20), 200); diff --git a/app/Http/Controllers/Api/Admin/Order/OrderController.php b/app/Http/Controllers/Api/Admin/Order/OrderController.php index c2ccb76..7dfddda 100644 --- a/app/Http/Controllers/Api/Admin/Order/OrderController.php +++ b/app/Http/Controllers/Api/Admin/Order/OrderController.php @@ -38,6 +38,7 @@ class OrderController extends Controller 'invoice', 'orderItems.os', ...OrderItemServiceResource::relationsFor('orderItems'), + 'tags', ], [], ['*'], diff --git a/app/Http/Controllers/Api/Admin/Service/ServiceController.php b/app/Http/Controllers/Api/Admin/Service/ServiceController.php index 3035196..e14b87b 100644 --- a/app/Http/Controllers/Api/Admin/Service/ServiceController.php +++ b/app/Http/Controllers/Api/Admin/Service/ServiceController.php @@ -27,10 +27,14 @@ class ServiceController extends Controller { $perPage = min((int) ($request->per_page ?? 15), 100); - $query = Service::with(['customer:id,uuid,fullname,email', 'product:id,uuid,name', 'invoice:id,invoice_number', 'vmware:id,name,module_ip_address', 'orderItem:id,order_id', 'orderItem.order:id,order_num']) + $query = Service::with(['customer:id,uuid,fullname,email', 'product:id,uuid,name,product_type', 'invoice:id,invoice_number', 'vmware:id,name,module_ip_address', 'orderItem:id,order_id', 'orderItem.order:id,order_num', 'tags']) + ->marked($request->all()) ->where(fn($q) => $q->whereNull('order_item_id')->orWhereNull('type')->orWhere('type', '!=', 'domain')) ->when($request->filter, fn($q, $v) => $q->where('server_name', 'like', "%{$v}%")) ->when($request->status, fn($q, $v) => $q->where('status', $v)) + ->when($request->product_type, fn($q, $v) => $q->where(fn($typed) => $typed + ->where('type', $v) + ->orWhere(fn($legacy) => $legacy->whereNull('type')->whereHas('product', fn($product) => $product->where('product_type', $v))))) ->when($request->customer_id, fn($q, $v) => $q->where('customer_id', $v)) ->when($request->boolean('unassigned'), fn($q) => $q->whereNull('customer_id')); diff --git a/app/Http/Controllers/Api/Admin/Setting/SettingController.php b/app/Http/Controllers/Api/Admin/Setting/SettingController.php index c9ce0d4..419762e 100644 --- a/app/Http/Controllers/Api/Admin/Setting/SettingController.php +++ b/app/Http/Controllers/Api/Admin/Setting/SettingController.php @@ -9,11 +9,13 @@ use App\Http\Facade\SignRSA; use App\Http\Services\Telegram; use App\Models\Setting; use App\Support\PublicSetting; +use App\Support\AutoEmailGate; use App\Support\MailSettings; use App\Support\Turnstile; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Validator; @@ -189,6 +191,42 @@ class SettingController extends Controller return response()->json(['error' => false, 'message' => 'Đã gửi tin nhắn test thành công.']); } + public function testSmtp(Request $request): JsonResponse + { + $data = $request->validate(['email' => 'required|email|max:255']); + + if (!AutoEmailGate::mailEnabled()) { + return response()->json([ + 'error' => true, + 'message' => 'Hệ thống đang tắt gửi email (Mẫu email → Cài đặt). Bật lại rồi gửi test.', + ], 422); + } + + MailSettings::refresh(app('mail.manager')); + $mailer = MailSettings::transport() ?? config('mail.default'); + + try { + Mail::mailer($mailer)->html( + '

Đây là email test từ Portal Admin.

' + . '

Nếu bạn nhận được email này, cấu hình gửi email (' . e($mailer) . ') đang hoạt động.

' + . '

Gửi lúc ' . now()->format('H:i:s d/m/Y') . '.

', + fn ($message) => $message->to($data['email'])->subject('[Portal] Email test cấu hình SMTP'), + ); + } catch (\Throwable $e) { + return response()->json([ + 'error' => true, + 'message' => 'Gửi email test thất bại: ' . $e->getMessage(), + ], 422); + } + + return response()->json([ + 'error' => false, + 'message' => $mailer === 'log' + ? 'Driver đang là Log: email được ghi vào log, không gửi thật.' + : "Đã gửi email test tới {$data['email']}.", + ]); + } + public function uploadMedia(Request $request): JsonResponse { $request->validate([ diff --git a/app/Http/Controllers/Api/Admin/Tag/MarkController.php b/app/Http/Controllers/Api/Admin/Tag/MarkController.php new file mode 100644 index 0000000..6e89eb5 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/Tag/MarkController.php @@ -0,0 +1,43 @@ +validate([ + 'is_starred' => 'sometimes|boolean', + 'tag_ids' => 'sometimes|array', + 'tag_ids.*' => 'integer|distinct|exists:tags,id', + ]); + + $model = Markables::find($request->route('type'), $key); + + DB::transaction(function () use ($model, $data) { + if (array_key_exists('is_starred', $data)) { + $model->timestamps = false; + $model->forceFill(['is_starred' => $data['is_starred']])->save(); + } + if (array_key_exists('tag_ids', $data)) { + $model->tags()->sync($data['tag_ids']); + } + }); + + return response()->json([ + 'error' => false, + 'data' => [ + 'is_starred' => (bool) $model->is_starred, + 'tags' => $model->tags()->get(), + ], + ]); + } +} diff --git a/app/Http/Controllers/Api/Admin/Tag/TagController.php b/app/Http/Controllers/Api/Admin/Tag/TagController.php new file mode 100644 index 0000000..283fdeb --- /dev/null +++ b/app/Http/Controllers/Api/Admin/Tag/TagController.php @@ -0,0 +1,57 @@ +json([ + 'error' => false, + 'data' => Tag::withCount(['services', 'customers', 'orders', 'invoices'])->orderBy('title')->get(), + ]); + } + + public function store(Request $request): JsonResponse + { + return response()->json([ + 'error' => false, + 'message' => 'Đã tạo tag.', + 'data' => Tag::create($this->validated($request)), + ], 201); + } + + public function update(Request $request, int $id): JsonResponse + { + $tag = Tag::findOrFail($id); + $tag->update($this->validated($request, $tag->id)); + + return response()->json(['error' => false, 'message' => 'Đã cập nhật tag.', 'data' => $tag]); + } + + public function destroy(int $id): JsonResponse + { + Tag::findOrFail($id)->delete(); + + return response()->json(['error' => false, 'message' => 'Đã xoá tag.']); + } + + /** + * @return array{title: string, color: string} + */ + private function validated(Request $request, ?int $ignoreId = null): array + { + return $request->validate([ + 'title' => ['required', 'string', 'max:50', Rule::unique('tags', 'title')->ignore($ignoreId)], + 'color' => ['required', 'string', 'regex:' . Tag::COLOR_PATTERN], + ]); + } +} diff --git a/app/Http/Resources/Api/Admin/CustomerResource.php b/app/Http/Resources/Api/Admin/CustomerResource.php index dfedf51..6cf5c77 100644 --- a/app/Http/Resources/Api/Admin/CustomerResource.php +++ b/app/Http/Resources/Api/Admin/CustomerResource.php @@ -22,6 +22,8 @@ class CustomerResource extends JsonResource 'email_marketing' => (bool) $this->email_marketing, 'receive_deposit' => (bool) $this->receive_deposit, 'created_at' => $this->created_at, + 'is_starred' => (bool) $this->is_starred, + 'tags' => $this->whenLoaded('tags'), ]; } } diff --git a/app/Http/Resources/Api/Admin/InvoiceResource.php b/app/Http/Resources/Api/Admin/InvoiceResource.php index b09feb6..d15636b 100644 --- a/app/Http/Resources/Api/Admin/InvoiceResource.php +++ b/app/Http/Resources/Api/Admin/InvoiceResource.php @@ -32,6 +32,8 @@ class InvoiceResource extends JsonResource 'amount_paid' => (int) $this->amount_paid, 'outstanding' => \App\Services\InvoicePaymentService::outstanding($this->resource), 'status' => $this->status, + 'is_starred' => (bool) $this->is_starred, + 'tags' => $this->whenLoaded('tags'), 'due_date' => $this->due_date, 'paid_at' => $this->paid_at, 'reminder_step' => (int) $this->reminder_step, diff --git a/app/Http/Resources/Api/Admin/OrderResource.php b/app/Http/Resources/Api/Admin/OrderResource.php index 551d4ed..b3e743f 100644 --- a/app/Http/Resources/Api/Admin/OrderResource.php +++ b/app/Http/Resources/Api/Admin/OrderResource.php @@ -103,6 +103,8 @@ class OrderResource extends JsonResource 'note' => $this->note, 'client_ip' => $this->client_ip, 'status' => $this->status, + 'is_starred' => (bool) $this->is_starred, + 'tags' => $this->whenLoaded('tags'), 'created_at' => $this->created_at, 'updated_at' => $this->updated_at, 'vmwares' => $this->whenLoaded('moduleVmwares', fn() => $this->moduleVmwares->map(fn($vm) => [ diff --git a/app/Jobs/Mail/SendMailCampaignRecipient.php b/app/Jobs/Mail/SendMailCampaignRecipient.php index 651f1f3..4dac82a 100644 --- a/app/Jobs/Mail/SendMailCampaignRecipient.php +++ b/app/Jobs/Mail/SendMailCampaignRecipient.php @@ -32,7 +32,7 @@ class SendMailCampaignRecipient implements ShouldQueue public function handle(MailCampaignRenderer $renderer): void { $campaign = MailCampaign::with('template')->find($this->campaignId); - if (!$campaign) { + if (!$campaign || $campaign->status === MailCampaign::STATUS_CANCELLED) { return; } @@ -93,7 +93,7 @@ class SendMailCampaignRecipient implements ShouldQueue $campaign->increment($failed ? 'failed_count' : 'sent_count'); $finished = MailCampaign::whereKey($campaign->id) - ->whereIn('status', ['queued', 'sending']) + ->whereIn('status', MailCampaign::ACTIVE_STATUSES) ->whereRaw('sent_count + failed_count >= total_recipients') ->update([ 'status' => DB::raw("CASE WHEN sent_count = 0 THEN 'failed' ELSE 'completed' END"), diff --git a/app/Models/Concerns/Markable.php b/app/Models/Concerns/Markable.php new file mode 100644 index 0000000..5bd523a --- /dev/null +++ b/app/Models/Concerns/Markable.php @@ -0,0 +1,37 @@ +mergeCasts(['is_starred' => 'boolean']); + } + + public function tags(): MorphToMany + { + return $this->morphToMany(Tag::class, 'taggable')->orderBy('tags.title'); + } + + /** + * @param array $filters + */ + public function scopeMarked(Builder $query, array $filters): Builder + { + $tagIds = array_values(array_filter(array_map('intval', (array) ($filters['tag_ids'] ?? [])))); + + return $query + ->when( + filter_var($filters['starred'] ?? false, FILTER_VALIDATE_BOOL), + fn (Builder $q) => $q->where($this->qualifyColumn('is_starred'), true), + ) + ->when($tagIds, fn (Builder $q) => $q->whereHas('tags', fn (Builder $tags) => $tags->whereIn('tags.id', $tagIds))); + } +} diff --git a/app/Models/Customer.php b/app/Models/Customer.php index b497d90..56b6f2e 100644 --- a/app/Models/Customer.php +++ b/app/Models/Customer.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Services\Cache\CacheServiceInterface; +use App\Models\Concerns\Markable; use App\Traits\LogsActivity; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -13,7 +14,7 @@ use Tymon\JWTAuth\Contracts\JWTSubject; class Customer extends Authenticatable implements JWTSubject { - use HasFactory, HasUuids, SoftDeletes, LogsActivity; + use HasFactory, HasUuids, SoftDeletes, LogsActivity, Markable; protected $table = 'customers'; diff --git a/app/Models/Invoice.php b/app/Models/Invoice.php index f790189..a2cbf51 100644 --- a/app/Models/Invoice.php +++ b/app/Models/Invoice.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Enums\Invoice\InvoiceStatus; +use App\Models\Concerns\Markable; use App\Traits\LogsActivity; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -11,7 +12,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class Invoice extends Model { - use HasFactory, SoftDeletes, LogsActivity; + use HasFactory, SoftDeletes, LogsActivity, Markable; protected $table = 'invoices'; diff --git a/app/Models/MailCampaign.php b/app/Models/MailCampaign.php index 6633166..a67ccb8 100644 --- a/app/Models/MailCampaign.php +++ b/app/Models/MailCampaign.php @@ -10,6 +10,12 @@ class MailCampaign extends Model { use HasFactory; + public const STATUS_CANCELLED = 'cancelled'; + + public const ACTIVE_STATUSES = ['queued', 'sending']; + + public const FINISHED_STATUSES = ['completed', 'failed', self::STATUS_CANCELLED]; + protected $table = 'mail_campaigns'; protected $fillable = [ diff --git a/app/Models/Order.php b/app/Models/Order.php index 5c453a1..662a92f 100644 --- a/app/Models/Order.php +++ b/app/Models/Order.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Models\Concerns\Markable; use App\Traits\LogsActivity; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -10,7 +11,7 @@ use App\Models\ModuleVmware; class Order extends Model { - use HasFactory, SoftDeletes, LogsActivity; + use HasFactory, SoftDeletes, LogsActivity, Markable; protected $table = 'orders'; diff --git a/app/Models/Service.php b/app/Models/Service.php index 2c285f9..cbfe85b 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Models\Concerns\Markable; use App\Traits\LogsActivity; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -10,7 +11,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class Service extends Model { - use HasFactory, HasUuids, SoftDeletes, LogsActivity; + use HasFactory, HasUuids, SoftDeletes, LogsActivity, Markable; public function uniqueIds(): array { diff --git a/app/Models/Tag.php b/app/Models/Tag.php new file mode 100644 index 0000000..944d409 --- /dev/null +++ b/app/Models/Tag.php @@ -0,0 +1,37 @@ +morphedByMany(Service::class, 'taggable'); + } + + public function customers(): MorphToMany + { + return $this->morphedByMany(Customer::class, 'taggable'); + } + + public function orders(): MorphToMany + { + return $this->morphedByMany(Order::class, 'taggable'); + } + + public function invoices(): MorphToMany + { + return $this->morphedByMany(Invoice::class, 'taggable'); + } +} diff --git a/app/Repositories/Customer/CustomerRepository.php b/app/Repositories/Customer/CustomerRepository.php index 237b599..2ba24db 100644 --- a/app/Repositories/Customer/CustomerRepository.php +++ b/app/Repositories/Customer/CustomerRepository.php @@ -28,7 +28,7 @@ class CustomerRepository extends BaseRepository implements CustomerRepositoryInt array $select = ['*'], bool $returnQuery = false ) { - $model = $this->model->with($relationships)->select($select); + $model = $this->model->with($relationships)->select($select)->marked($data); if (isset($data['filter']) && ! empty($data['filter'])) { $model = $model->where(function ($query) use ($data) { $filter = trim($data['filter']); diff --git a/app/Repositories/Invoice/InvoiceRepository.php b/app/Repositories/Invoice/InvoiceRepository.php index 60a8c4d..0dba6c7 100644 --- a/app/Repositories/Invoice/InvoiceRepository.php +++ b/app/Repositories/Invoice/InvoiceRepository.php @@ -17,7 +17,7 @@ class InvoiceRepository extends BaseRepository implements InvoiceRepositoryInter } public function filter(array $data = [], array $relationships = [], array $withCount = [], array $select = ['*'], bool $returnQuery = false) { - $model = $this->model->with($relationships)->select($select); + $model = $this->model->with($relationships)->select($select)->marked($data); if (isset($data['client']) && ! empty($data['client'])) { $model = $model->where("customer_id", $data['client']); diff --git a/app/Repositories/Order/OrderRepository.php b/app/Repositories/Order/OrderRepository.php index f652179..65e320b 100644 --- a/app/Repositories/Order/OrderRepository.php +++ b/app/Repositories/Order/OrderRepository.php @@ -17,7 +17,7 @@ class OrderRepository extends BaseRepository implements OrderRepositoryInterface } public function filter(array $data = [], array $relationships = [], array $withCount = [], array $select = ['*'], bool $returnQuery = false) { - $model = $this->model->with($relationships)->select($select); + $model = $this->model->with($relationships)->select($select)->marked($data); if (isset($data['client']) && ! empty($data['client'])) { $model = $model->where("customer_id", $data['client']); } diff --git a/app/Support/MailSettings.php b/app/Support/MailSettings.php index 2dc4e20..baafac6 100644 --- a/app/Support/MailSettings.php +++ b/app/Support/MailSettings.php @@ -44,6 +44,11 @@ final class MailSettings Config::set('mail', $config); } + public static function transport(): ?string + { + return self::resolve()['transport'] ?? null; + } + public static function refresh(object $mailer): void { self::apply(); diff --git a/app/Support/Markables.php b/app/Support/Markables.php new file mode 100644 index 0000000..f6fa02a --- /dev/null +++ b/app/Support/Markables.php @@ -0,0 +1,31 @@ +, key: string, permission: string}> + */ + public const TYPES = [ + 'services' => ['model' => Service::class, 'key' => 'id', 'permission' => 'customer.edit'], + 'customers' => ['model' => Customer::class, 'key' => 'uuid', 'permission' => 'customer.edit'], + 'orders' => ['model' => Order::class, 'key' => 'id', 'permission' => 'order.edit'], + 'invoices' => ['model' => Invoice::class, 'key' => 'id', 'permission' => 'invoice.edit'], + ]; + + public static function find(string $type, string $key): Model + { + $definition = self::TYPES[$type]; + + return $definition['model']::query()->where($definition['key'], $key)->firstOrFail(); + } +} diff --git a/database/migrations/2026_10_15_000010_create_tags_and_starred_columns.php b/database/migrations/2026_10_15_000010_create_tags_and_starred_columns.php new file mode 100644 index 0000000..a8cbbfd --- /dev/null +++ b/database/migrations/2026_10_15_000010_create_tags_and_starred_columns.php @@ -0,0 +1,51 @@ +boolean('is_starred')->default(false)->index(); + }); + } + } + + Schema::create('tags', function (Blueprint $table) { + $table->id(); + $table->string('title', 50)->unique(); + $table->string('color', 20); + $table->timestamps(); + }); + + Schema::create('taggables', function (Blueprint $table) { + $table->foreignId('tag_id')->constrained('tags')->cascadeOnDelete(); + $table->string('taggable_type', 100); + $table->unsignedBigInteger('taggable_id'); + $table->primary(['tag_id', 'taggable_type', 'taggable_id']); + $table->index(['taggable_type', 'taggable_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('taggables'); + Schema::dropIfExists('tags'); + + foreach (self::STARRABLE_TABLES as $table) { + if (Schema::hasColumn($table, 'is_starred')) { + Schema::table($table, function (Blueprint $blueprint) { + $blueprint->dropIndex(['is_starred']); + $blueprint->dropColumn('is_starred'); + }); + } + } + } +}; diff --git a/routes/api.php b/routes/api.php index 1824204..4fd31c2 100644 --- a/routes/api.php +++ b/routes/api.php @@ -31,6 +31,7 @@ Route::prefix('v1') require __DIR__.'/api/admin/domains.php'; require __DIR__.'/api/admin/hosting.php'; require __DIR__.'/api/admin/system.php'; + require __DIR__.'/api/admin/tags.php'; }); }); diff --git a/routes/api/admin/system.php b/routes/api/admin/system.php index a068ed7..7f51abc 100644 --- a/routes/api/admin/system.php +++ b/routes/api/admin/system.php @@ -21,6 +21,7 @@ Route::prefix('settings') Route::get('/', 'index'); Route::put('/', 'update'); Route::post('test-telegram', 'testTelegram'); + Route::post('test-smtp', 'testSmtp')->middleware('throttle:10,1'); }); Route::prefix('client-menu') @@ -163,6 +164,8 @@ Route::prefix('mail-campaigns') Route::get('/', 'index')->middleware('perm:mail-campaign.view'); Route::get('variable-suggestions', 'variableSuggestions')->middleware('perm:mail-campaign.create'); Route::post('preview', 'preview')->middleware('perm:mail-campaign.create'); + Route::post('bulk-action', 'bulkAction')->middleware('perm:mail-campaign.create'); + Route::post('bulk-delete', 'bulkDelete')->middleware('perm:mail-campaign.manager'); Route::get('{uuid}/preview', 'previewCampaign')->middleware('perm:mail-campaign.view'); Route::get('{uuid}/logs/{id}', 'showLog')->whereNumber('id')->middleware('perm:mail-campaign.view'); Route::get('{uuid}', 'show')->middleware('perm:mail-campaign.view'); diff --git a/routes/api/admin/tags.php b/routes/api/admin/tags.php new file mode 100644 index 0000000..61dc4b4 --- /dev/null +++ b/routes/api/admin/tags.php @@ -0,0 +1,21 @@ +controller(TagController::class) + ->group(function () { + Route::get('/', 'index'); + Route::post('/', 'store'); + Route::put('{id}', 'update')->whereNumber('id')->middleware('perm:setting.manager'); + Route::delete('{id}', 'destroy')->whereNumber('id')->middleware('perm:setting.manager'); + }); + +foreach (Markables::TYPES as $type => $definition) { + Route::patch("marks/{$type}/{key}", [MarkController::class, 'update']) + ->defaults('type', $type) + ->middleware("perm:{$definition['permission']}"); +} diff --git a/tests/Feature/Api/Admin/MailCampaignBulkActionTest.php b/tests/Feature/Api/Admin/MailCampaignBulkActionTest.php new file mode 100644 index 0000000..b1d413d --- /dev/null +++ b/tests/Feature/Api/Admin/MailCampaignBulkActionTest.php @@ -0,0 +1,88 @@ + 'Full Administrator', 'guard_name' => 'admin']); + $this->admin = User::factory()->create(); + $this->admin->assignRole('Full Administrator'); + } + + private function campaign(string $status, int $sent = 0, int $failed = 0): MailCampaign + { + return MailCampaign::create([ + 'name' => 'C', 'subject' => 'S', 'message' => '

x

', 'recipient_type' => 'selected', + 'total_recipients' => 2, 'sent_count' => $sent, 'failed_count' => $failed, 'status' => $status, + ]); + } + + public function test_cancel_stops_pending_jobs_and_keeps_finished_campaigns(): void + { + MailFacade::fake(); + $sending = $this->campaign('sending'); + $done = $this->campaign('completed', 2); + + $this->actingAs($this->admin, 'admin') + ->postJson('/api/v1/administrator/mail-campaigns/bulk-action', ['action' => 'cancel', 'uuids' => [$sending->uuid, $done->uuid]]) + ->assertOk(); + + $this->assertSame('cancelled', $sending->fresh()->status); + $this->assertSame('completed', $done->fresh()->status); + + (new SendMailCampaignRecipient($sending->id, null, 'a@example.com'))->handle(app(\App\Services\Email\MailCampaignRenderer::class)); + MailFacade::assertNothingSent(); + $this->assertSame(0, $sending->fresh()->sent_count); + } + + public function test_retry_requeues_only_failed_recipients(): void + { + Queue::fake(); + $campaign = $this->campaign('completed', 1, 1); + foreach (['ok@example.com' => 'sent', 'bad@example.com' => 'failed'] as $to => $status) { + Mail::create(['campaign_id' => $campaign->id, 'template_id' => 0, 'subject' => 'S', 'to' => $to, 'message' => 'x', 'status' => $status]); + } + + $this->actingAs($this->admin, 'admin') + ->postJson('/api/v1/administrator/mail-campaigns/bulk-action', ['action' => 'retry-failed', 'uuids' => [$campaign->uuid]]) + ->assertOk(); + + $campaign->refresh(); + $this->assertSame('sending', $campaign->status); + $this->assertSame(0, $campaign->failed_count); + $this->assertDatabaseMissing('mail', ['to' => 'bad@example.com']); + Queue::assertPushed(SendMailCampaignRecipient::class, 1); + } + + public function test_delete_skips_campaigns_still_sending(): void + { + $sending = $this->campaign('queued'); + $done = $this->campaign('failed', 0, 2); + + $this->actingAs($this->admin, 'admin') + ->postJson('/api/v1/administrator/mail-campaigns/bulk-delete', ['uuids' => [$sending->uuid, $done->uuid]]) + ->assertOk(); + + $this->assertNotNull($sending->fresh()); + $this->assertNull($done->fresh()); + } +} diff --git a/tests/Feature/Api/Admin/MarkTest.php b/tests/Feature/Api/Admin/MarkTest.php new file mode 100644 index 0000000..ceb18df --- /dev/null +++ b/tests/Feature/Api/Admin/MarkTest.php @@ -0,0 +1,149 @@ + 'role-' . uniqid(), 'guard_name' => 'admin']); + foreach ($permissions as $permission) { + $role->givePermissionTo(Permission::firstOrCreate(['name' => $permission, 'guard_name' => 'admin'])); + } + $admin = User::factory()->create(); + $admin->assignRole($role); + + return $admin; + } + + public function test_stars_and_tags_customer_then_filters_list(): void + { + $admin = $this->admin('customer.view', 'customer.edit'); + $customer = Customer::factory()->create(); + Customer::factory()->create(); + + $tagId = $this->actingAs($admin, 'admin') + ->postJson('/api/v1/administrator/tags', ['title' => 'VIP', 'color' => '#ff0000']) + ->assertCreated() + ->json('data.id'); + + $this->actingAs($admin, 'admin') + ->patchJson("/api/v1/administrator/marks/customers/{$customer->uuid}", [ + 'is_starred' => true, + 'tag_ids' => [$tagId], + ]) + ->assertOk() + ->assertJsonPath('data.is_starred', true) + ->assertJsonPath('data.tags.0.title', 'VIP'); + + $this->actingAs($admin, 'admin') + ->getJson('/api/v1/administrator/customers?starred=1') + ->assertOk() + ->assertJsonCount(1, 'data.items') + ->assertJsonPath('data.items.0.uuid', $customer->uuid) + ->assertJsonPath('data.items.0.tags.0.color', '#ff0000'); + + $this->actingAs($admin, 'admin') + ->getJson("/api/v1/administrator/customers?tag_ids[]={$tagId}") + ->assertJsonCount(1, 'data.items'); + + $this->actingAs($admin, 'admin') + ->patchJson("/api/v1/administrator/marks/customers/{$customer->uuid}", ['tag_ids' => []]) + ->assertOk() + ->assertJsonCount(0, 'data.tags'); + + $this->assertTrue($customer->fresh()->is_starred); + } + + public function test_requires_module_edit_permission_and_valid_input(): void + { + Permission::firstOrCreate(['name' => 'customer.edit', 'guard_name' => 'admin']); + $customer = Customer::factory()->create(); + + $this->actingAs($this->admin('order.edit'), 'admin') + ->patchJson("/api/v1/administrator/marks/customers/{$customer->uuid}", ['is_starred' => true]) + ->assertForbidden(); + + $admin = $this->admin('customer.edit'); + $this->actingAs($admin, 'admin') + ->patchJson("/api/v1/administrator/marks/customers/{$customer->uuid}", ['tag_ids' => [999]]) + ->assertUnprocessable(); + + $this->actingAs($admin, 'admin') + ->postJson('/api/v1/administrator/tags', ['title' => 'X', 'color' => 'red;']) + ->assertUnprocessable(); + } + + public function test_deleting_tag_detaches_it(): void + { + $customer = Customer::factory()->create(); + $tag = Tag::create(['title' => 'Nợ', 'color' => '#000000']); + $customer->tags()->attach($tag); + + $this->actingAs($this->admin('setting.manager'), 'admin') + ->deleteJson("/api/v1/administrator/tags/{$tag->id}") + ->assertOk(); + + $this->assertDatabaseCount('taggables', 0); + } + + public static function listEndpoints(): array + { + return [ + 'services' => ['services', Service::class, '/api/v1/administrator/services', 'service'], + 'orders' => ['orders', Order::class, '/api/v1/administrator/orders', 'domain'], + 'invoices' => ['invoices', Invoice::class, '/api/v1/administrator/invoices', 'domain'], + ]; + } + + /** + * @param class-string<\Illuminate\Database\Eloquent\Model> $model + */ + #[DataProvider('listEndpoints')] + public function test_marks_and_filters_billing_lists(string $type, string $model, string $listUrl, string $productType): void + { + Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']); + $admin = User::factory()->create(); + $admin->assignRole('Full Administrator'); + $customer = Customer::factory()->create(); + $product = Product::factory()->create(['product_type' => $productType]); + foreach (['a.vn', 'b.vn'] as $serverName) { + $this->actingAs($admin, 'admin')->postJson( + "/api/v1/administrator/customers/{$customer->uuid}/services", + ['product_id' => $product->id, 'payment_method' => 'transfer', 'server_name' => $serverName, 'amount_paid' => 0], + )->assertCreated(); + } + $tag = Tag::create(['title' => 'Gấp', 'color' => '#fa8c16']); + $target = $model::query()->latest('id')->firstOrFail(); + + $this->actingAs($admin, 'admin') + ->patchJson("/api/v1/administrator/marks/{$type}/{$target->id}", ['is_starred' => true, 'tag_ids' => [$tag->id]]) + ->assertOk(); + + $items = $this->actingAs($admin, 'admin') + ->getJson("{$listUrl}?starred=1&tag_ids[]={$tag->id}") + ->assertOk() + ->json('data.items'); + + $this->assertSame([$target->id], array_column($items, 'id')); + $this->assertTrue($items[0]['is_starred']); + $this->assertSame('Gấp', $items[0]['tags'][0]['title']); + } +} diff --git a/tests/Feature/Api/Admin/Service/ServiceListProductTypeFilterTest.php b/tests/Feature/Api/Admin/Service/ServiceListProductTypeFilterTest.php new file mode 100644 index 0000000..b396c94 --- /dev/null +++ b/tests/Feature/Api/Admin/Service/ServiceListProductTypeFilterTest.php @@ -0,0 +1,41 @@ + 'Full Administrator', 'guard_name' => 'admin']); + $admin = User::factory()->create(); + $admin->assignRole('Full Administrator'); + $customer = Customer::factory()->create(); + + foreach (['email' => 'mail.vn', 'hardware' => 'box.vn'] as $type => $serverName) { + $this->actingAs($admin, 'admin')->postJson( + "/api/v1/administrator/customers/{$customer->uuid}/services", + ['product_id' => Product::factory()->create(['product_type' => $type])->id, 'payment_method' => 'transfer', 'server_name' => $serverName, 'amount_paid' => 0], + )->assertCreated(); + } + Service::where('server_name', 'box.vn')->update(['type' => null]); + + $email = $this->actingAs($admin, 'admin')->getJson('/api/v1/administrator/services?product_type=email')->assertOk(); + $this->assertSame(['mail.vn'], array_column($email->json('data.items'), 'server_name')); + $this->assertSame('email', $email->json('data.items.0.product.product_type')); + + $hardware = $this->actingAs($admin, 'admin')->getJson('/api/v1/administrator/services?product_type=hardware')->assertOk(); + $this->assertSame(['box.vn'], array_column($hardware->json('data.items'), 'server_name')); + } +} diff --git a/tests/Feature/Api/Admin/Setting/SmtpTestMailTest.php b/tests/Feature/Api/Admin/Setting/SmtpTestMailTest.php new file mode 100644 index 0000000..216eed7 --- /dev/null +++ b/tests/Feature/Api/Admin/Setting/SmtpTestMailTest.php @@ -0,0 +1,42 @@ + 'Full Administrator', 'guard_name' => 'admin']); + $admin = User::factory()->create(); + $admin->assignRole('Full Administrator'); + + return $admin; + } + + public function test_sends_test_email_to_given_address(): void + { + $this->actingAs($this->admin(), 'admin') + ->postJson('/api/v1/administrator/settings/test-smtp', ['email' => 'ops@example.com']) + ->assertOk(); + + $messages = app('mail.manager')->mailer('array')->getSymfonyTransport()->messages(); + $this->assertCount(1, $messages); + $this->assertSame('ops@example.com', $messages->first()->getEnvelope()->getRecipients()[0]->getAddress()); + } + + public function test_rejects_invalid_address(): void + { + $this->actingAs($this->admin(), 'admin') + ->postJson('/api/v1/administrator/settings/test-smtp', ['email' => 'not-an-email']) + ->assertUnprocessable(); + } +}