feat: add domain registration form and email notifications

- Create a new Blade view for domain registration (`registrant-tochuc.blade.php`) to capture organization and manager details.
- Implement email templates for new domain orders for both admin (`domain-order-new.blade.php`) and customers (`domain-order-received.blade.php`).
- Add CSV exports for client and domain data.
- Introduce tests for customer mass assignment, password reset, login throttling, and permission gating.
- Ensure service renewals use current pricing and validate ownership for virtual machines.
- Validate mass assignment behavior in service model.
This commit is contained in:
baodev0
2026-08-22 17:30:55 +07:00
parent eec2c5906e
commit c377c7e195
58 changed files with 3063 additions and 418 deletions
@@ -0,0 +1,313 @@
<?php
namespace App\Http\Controllers\Api\Admin\Report;
use App\Enums\Invoice\InvoiceStatus;
use App\Enums\TransactionStatus;
use App\Exports\Reports\ReportRowsExport;
use App\Http\Controllers\Controller;
use App\Models\Customer;
use App\Models\DnsDomain;
use App\Models\ExportJob;
use App\Models\Invoice;
use App\Models\Service;
use App\Models\Transaction;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Maatwebsite\Excel\Excel as ExcelWriter;
use Maatwebsite\Excel\Facades\Excel;
class ExportController extends Controller
{
private const PREVIEW_LIMIT = 20;
public function preview(Request $request): JsonResponse
{
$data = $request->validate([
'type' => 'required|string',
'filters' => 'array',
'fields' => 'array',
]);
$query = $this->baseQuery($data['type'], $data['filters'] ?? []);
$total = (clone $query)->count();
$items = $query->limit(self::PREVIEW_LIMIT)->get()
->map(fn ($model) => $this->rowValues($model, $data['type']));
return response()->json([
'error' => false,
'data' => ['items' => $items, 'total' => $total],
]);
}
public function store(Request $request): JsonResponse
{
$data = $request->validate([
'type' => 'required|string',
'format' => 'required|in:csv,xlsx',
'filters' => 'array',
'fields' => 'array',
]);
$labels = $this->fieldLabels($data['type']);
$fields = !empty($data['fields']) ? $data['fields'] : array_keys($labels);
$rows = $this->baseQuery($data['type'], $data['filters'] ?? [])->get()
->map(fn ($model) => $this->rowValues($model, $data['type']));
$uuid = (string) Str::uuid();
$filename = "exports/{$data['type']}_{$uuid}.{$data['format']}";
$writerType = $data['format'] === 'xlsx' ? ExcelWriter::XLSX : ExcelWriter::CSV;
Excel::store(new ReportRowsExport($rows, $fields, $labels), $filename, 'local', $writerType);
$job = ExportJob::create([
'uuid' => $uuid,
'type' => $data['type'],
'format' => $data['format'],
'filters' => $data['filters'] ?? [],
'fields' => $fields,
'status' => 'completed',
'file_path' => $filename,
'total_rows' => $rows->count(),
'created_by' => auth('admin')->id(),
'completed_at' => now(),
]);
return response()->json(['error' => false, 'data' => ['id' => $job->uuid]]);
}
public function show(string $uuid): JsonResponse
{
$job = ExportJob::where('uuid', $uuid)->firstOrFail();
return response()->json([
'error' => false,
'data' => [
'id' => $job->uuid,
'type' => $job->type,
'format' => $job->format,
'status' => $job->status,
'progress' => $job->status === 'completed' ? 100 : 0,
'processed' => $job->total_rows ?? 0,
'total' => $job->total_rows ?? 0,
'error' => $job->error,
],
]);
}
public function history(Request $request): JsonResponse
{
$perPage = min((int) $request->get('per_page', 15), 100);
$query = ExportJob::query()->with('creator')->latest();
if ($request->filled('filter')) {
$query->where('type', 'like', '%' . $request->get('filter') . '%');
}
if ($request->filled('status')) {
$query->where('status', $request->get('status'));
}
$paginator = $query->paginate($perPage);
return response()->json([
'error' => false,
'data' => [
'items' => $paginator->getCollection()->map(fn (ExportJob $job) => [
'id' => $job->uuid,
'type' => $job->type,
'format' => $job->format,
'records' => $job->total_rows ?? 0,
'created_by' => $job->creator?->username ?? '—',
'created_at' => $job->created_at?->toIso8601String(),
'status' => $job->status,
]),
'total' => $paginator->total(),
],
]);
}
public function download(string $uuid)
{
$job = ExportJob::where('uuid', $uuid)->firstOrFail();
abort_unless($job->status === 'completed' && $job->file_path, 404);
return Storage::disk('local')->download($job->file_path, basename($job->file_path));
}
private function baseQuery(string $type, array $filters): Builder
{
return match ($type) {
'client', 'clients' => Customer::query()
->when($type === 'client', fn (Builder $q) => $q->where('id', $filters['client_id'] ?? 0))
->when(!empty($filters['date_from']), fn (Builder $q) => $q->whereDate('created_at', '>=', $filters['date_from']))
->when(!empty($filters['date_to']), fn (Builder $q) => $q->whereDate('created_at', '<=', $filters['date_to'])),
'domains' => DnsDomain::query()
->with(['customer', 'server', 'ns'])
->when(!empty($filters['date_from']), fn (Builder $q) => $q->whereDate('created_at', '>=', $filters['date_from']))
->when(!empty($filters['date_to']), fn (Builder $q) => $q->whereDate('created_at', '<=', $filters['date_to'])),
'invoices' => Invoice::query()
->when(!empty($filters['date_from']), fn (Builder $q) => $q->whereDate('created_at', '>=', $filters['date_from']))
->when(!empty($filters['date_to']), fn (Builder $q) => $q->whereDate('created_at', '<=', $filters['date_to']))
->when(!empty($filters['invoice_status']), fn (Builder $q) => $q->where(
'status',
$filters['invoice_status'] === 'paid' ? InvoiceStatus::PAID->value : InvoiceStatus::UNPAID->value
)),
'services' => Service::query()
->with(['product', 'orderItem'])
->when(!empty($filters['date_from']), fn (Builder $q) => $q->whereDate('created_at', '>=', $filters['date_from']))
->when(!empty($filters['date_to']), fn (Builder $q) => $q->whereDate('created_at', '<=', $filters['date_to'])),
'transactions' => Transaction::query()
->when(!empty($filters['date_from']), fn (Builder $q) => $q->whereDate('created_at', '>=', $filters['date_from']))
->when(!empty($filters['date_to']), fn (Builder $q) => $q->whereDate('created_at', '<=', $filters['date_to'])),
default => throw new \InvalidArgumentException("Loại xuất dữ liệu không được hỗ trợ: {$type}"),
};
}
private function rowValues($model, string $type): array
{
return match ($type) {
'client', 'clients' => [
'id' => $model->id,
'name' => $model->fullname,
'company' => $model->company,
'email' => $model->email,
'phone' => $model->phone,
'address' => $model->address,
'city' => $model->city,
'state' => $model->state,
'postcode' => $model->postcode,
'country' => $model->country,
'status' => match ((int) $model->status) {
1 => 'Actived',
2 => 'De-actived',
default => 'Banned',
},
'created_at' => optional($model->created_at)->format('Y-m-d H:i'),
],
'domains' => [
'domain' => $model->domain_name,
'customer' => $model->customer
->map(fn ($c) => $c->email ? "{$c->fullname} ({$c->email})" : $c->fullname)
->filter()
->implode(', '),
'server' => $model->server?->name,
'nameservers' => $model->ns->pluck('value')->filter()->implode(', '),
'status' => match ((int) $model->is_created) {
1 => 'Đã khởi tạo',
2 => 'Không hoạt động',
default => 'Chưa khởi tạo',
},
'created_at' => optional($model->created_at)->format('Y-m-d H:i'),
],
'invoices' => [
'id' => $model->id,
'client_id' => $model->customer_id,
'invoice_number' => $model->invoice_number,
'date' => optional($model->created_at)->format('Y-m-d'),
'due_date' => optional($model->due_date)->format('Y-m-d'),
'subtotal' => $model->sub_total,
'tax' => $model->tax,
'total' => $model->total,
'status' => InvoiceStatus::tryFrom((int) $model->status)?->label() ?? $model->status,
'payment_method' => $model->payment_method,
],
'services' => [
'id' => $model->id,
'client_id' => $model->customer_id,
'product_id' => $model->product_id,
'product_name' => $model->product?->name,
'domain' => $model->server_name,
'billing_cycle' => $model->orderItem?->billing_cycle,
'amount' => $model->orderItem?->price,
'next_due_date' => optional($model->expires_at)->format('Y-m-d'),
'status' => $model->status,
],
'transactions' => [
'id' => $model->id,
'client_id' => $model->customer_id,
'date' => optional($model->created_at)->format('Y-m-d H:i'),
'description' => $model->trans,
'amount' => $model->amount,
'gateway' => $model->type,
'status' => TransactionStatus::tryFrom((int) $model->status)?->label() ?? $model->status,
],
default => [],
};
}
private function fieldLabels(string $type): array
{
return match ($type) {
'client', 'clients' => [
'id' => 'Client ID',
'name' => 'Name',
'company' => 'Company',
'email' => 'Email',
'phone' => 'Phone',
'address' => 'Address',
'city' => 'City',
'state' => 'State',
'postcode' => 'Postcode',
'country' => 'Country',
'status' => 'Status',
'created_at' => 'Created At',
],
'domains' => [
'domain' => 'Domain',
'customer' => 'Khách hàng',
'server' => 'DNS Server',
'nameservers' => 'NS',
'status' => 'Trạng thái',
'created_at' => 'Ngày tạo',
],
'invoices' => [
'id' => 'Invoice ID',
'client_id' => 'Client ID',
'invoice_number' => 'Invoice Number',
'date' => 'Date',
'due_date' => 'Due Date',
'subtotal' => 'Subtotal',
'tax' => 'Tax',
'total' => 'Total',
'status' => 'Status',
'payment_method' => 'Payment Method',
],
'services' => [
'id' => 'Service ID',
'client_id' => 'Client ID',
'product_id' => 'Product ID',
'product_name' => 'Product Name',
'domain' => 'Domain',
'billing_cycle' => 'Billing Cycle',
'amount' => 'Amount',
'next_due_date' => 'Next Due Date',
'status' => 'Status',
],
'transactions' => [
'id' => 'Transaction ID',
'client_id' => 'Client ID',
'date' => 'Date',
'description' => 'Description',
'amount' => 'Amount',
'gateway' => 'Gateway',
'status' => 'Status',
],
default => [],
};
}
}