Commit 815be22a authored by Mahmoud Aglan's avatar Mahmoud Aglan

ver1

parent fd08eae7
...@@ -20,7 +20,7 @@ COPY . . ...@@ -20,7 +20,7 @@ COPY . .
RUN npm run build RUN npm run build
# Stage 3: Production image # Stage 3: Production image
FROM php:8.2-fpm-alpine FROM php:8.3-fpm-alpine
# Install system dependencies # Install system dependencies
RUN apk add --no-cache \ RUN apk add --no-cache \
......
...@@ -2,7 +2,9 @@ ...@@ -2,7 +2,9 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Shared\Traits\ApiResponse;
abstract class Controller abstract class Controller
{ {
// use ApiResponse;
} }
...@@ -2,8 +2,11 @@ ...@@ -2,8 +2,11 @@
namespace App\Models; namespace App\Models;
use App\Shared\Enums\UserRole;
use App\Shared\Enums\UserStatus;
use App\Shared\Traits\HasUuid; use App\Shared\Traits\HasUuid;
use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
...@@ -75,6 +78,40 @@ class User extends Authenticatable implements MustVerifyEmail ...@@ -75,6 +78,40 @@ class User extends Authenticatable implements MustVerifyEmail
public function isActive(): bool public function isActive(): bool
{ {
return $this->status === 'active'; return $this->status === UserStatus::Active->value;
}
public function isSuspended(): bool
{
return $this->status === UserStatus::Suspended->value;
}
public function isBanned(): bool
{
return $this->status === UserStatus::Banned->value;
}
public function scopeActive(Builder $query): Builder
{
return $query->where('status', UserStatus::Active->value);
}
public function scopeByRole(Builder $query, string|UserRole $role): Builder
{
$value = $role instanceof UserRole ? $role->value : $role;
return $query->where('role', $value);
}
public function scopeVerified(Builder $query): Builder
{
return $query->whereNotNull('email_verified_at');
}
public function scopeSearch(Builder $query, string $term): Builder
{
return $query->where(function (Builder $q) use ($term) {
$q->where('name', 'ilike', "%{$term}%")
->orWhere('email', 'ilike', "%{$term}%");
});
} }
} }
<?php
namespace App\Modules\Admin\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Admin\Services\AdminService;
use Illuminate\Http\Request;
class AdminAuditLogController extends Controller
{
public function __construct(
private AdminService $service,
) {}
public function __invoke(Request $request)
{
$logs = $this->service->getActivityLog($request->only([
'action', 'admin_id', 'date_from', 'date_to', 'per_page',
]));
if ($request->expectsJson()) {
return $this->success($logs->items(), '', 200, [
'current_page' => $logs->currentPage(),
'per_page' => $logs->perPage(),
'total' => $logs->total(),
'last_page' => $logs->lastPage(),
]);
}
return view('admin.audit-log', compact('logs'));
}
}
<?php
namespace App\Modules\Admin\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Campaigns\Models\Campaign;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class AdminCampaignController extends Controller
{
public function index(Request $request)
{
$query = Campaign::with(['company']);
if ($request->filled('status')) {
$query->where('status', $request->query('status'));
}
if ($request->filled('search')) {
$search = $request->query('search');
$query->where(function ($q) use ($search) {
$q->where('title', 'ilike', "%{$search}%")
->orWhereHas('company', fn ($c) => $c->where('company_name', 'ilike', "%{$search}%"));
});
}
if ($request->query('featured')) {
$query->where('is_featured', true);
}
$campaigns = $query->orderByDesc('created_at')->paginate(25);
return view('admin.campaigns.index', compact('campaigns'));
}
public function show(Campaign $campaign)
{
$campaign->load(['company', 'applications', 'projects']);
$stats = [
'total_applications' => $campaign->applications()->count(),
'accepted_applications' => $campaign->applications()->where('status', 'accepted')->count(),
'active_projects' => $campaign->projects()->whereIn('status', ['active', 'in_progress'])->count(),
'completed_projects' => $campaign->projects()->where('status', 'completed')->count(),
];
return view('admin.campaigns.show', compact('campaign', 'stats'));
}
public function toggleFeatured(Request $request, Campaign $campaign)
{
$campaign->update(['is_featured' => !$campaign->is_featured]);
$this->logAction($request->user(), 'campaign_feature_toggle', $campaign, [
'is_featured' => $campaign->is_featured,
]);
return back()->with('success', __('admin.campaign_updated'));
}
public function togglePaused(Request $request, Campaign $campaign)
{
$newStatus = $campaign->status === 'paused' ? 'published' : 'paused';
$campaign->update(['status' => $newStatus]);
$this->logAction($request->user(), 'campaign_status_change', $campaign, [
'new_status' => $newStatus,
]);
return back()->with('success', __('admin.campaign_updated'));
}
private function logAction($admin, string $action, $target, array $metadata = []): void
{
DB::table('admin_activity_logs')->insert([
'admin_id' => $admin->id,
'action' => $action,
'target_type' => 'campaign',
'target_id' => $target->id,
'metadata' => json_encode($metadata),
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
'created_at' => now(),
]);
}
}
<?php
namespace App\Modules\Admin\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Admin\Services\AdminService;
use App\Modules\Companies\Models\CompanyProfile;
use Illuminate\Http\Request;
class AdminCompanyController extends Controller
{
public function __construct(
private AdminService $service,
) {}
public function pending(Request $request)
{
$companies = $this->service->getPendingCompanies($request->only([
'search', 'per_page',
]));
if ($request->expectsJson()) {
return $this->success($companies->items(), '', 200, [
'current_page' => $companies->currentPage(),
'per_page' => $companies->perPage(),
'total' => $companies->total(),
'last_page' => $companies->lastPage(),
]);
}
return view('admin.companies.pending', compact('companies'));
}
public function show(Request $request, int $id)
{
$company = CompanyProfile::with('user')->findOrFail($id);
if ($request->expectsJson()) {
return $this->success($company);
}
return view('admin.companies.show', compact('company'));
}
public function approve(Request $request, int $id)
{
$company = CompanyProfile::findOrFail($id);
$this->service->approveCompany($company, $request->user());
if ($request->expectsJson()) {
return $this->success(null, __('admin.company_approved'));
}
return back()->with('toast', [
'type' => 'success',
'message' => __('admin.company_approved'),
]);
}
public function reject(\App\Modules\Admin\Requests\RejectCompanyRequest $request, int $id)
{
$company = CompanyProfile::findOrFail($id);
$this->service->rejectCompany($company, $request->user(), $request->validated('reason'));
if ($request->expectsJson()) {
return $this->success(null, __('admin.company_rejected'));
}
return back()->with('toast', [
'type' => 'success',
'message' => __('admin.company_rejected'),
]);
}
}
<?php
namespace App\Modules\Admin\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Creators\Models\CreatorProfile;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class AdminCreatorController extends Controller
{
public function index(Request $request)
{
$query = CreatorProfile::with(['user']);
if ($request->filled('search')) {
$search = $request->query('search');
$query->where(function ($q) use ($search) {
$q->where('display_name', 'ilike', "%{$search}%")
->orWhere('username', 'ilike', "%{$search}%")
->orWhereHas('user', fn ($u) => $u->where('email', 'ilike', "%{$search}%"));
});
}
if ($request->filled('tier')) {
$query->where('reputation_tier', $request->query('tier'));
}
if ($request->query('featured')) {
$query->where('is_featured', true);
}
if ($request->query('verified')) {
$query->where('is_verified', true);
}
$sort = $request->query('sort', 'created_at');
$query->orderByDesc(match ($sort) {
'reputation' => 'reputation_score',
'projects' => 'completed_projects_count',
default => 'created_at',
});
$creators = $query->paginate(25);
return view('admin.creators.index', compact('creators'));
}
public function show(CreatorProfile $creator)
{
$creator->load(['user', 'projects', 'applications']);
$stats = [
'total_projects' => $creator->projects()->count(),
'completed_projects' => $creator->projects()->where('status', 'completed')->count(),
'active_projects' => $creator->projects()->whereIn('status', ['active', 'in_progress'])->count(),
'total_applications' => $creator->applications()->count(),
'accepted_applications' => $creator->applications()->where('status', 'accepted')->count(),
];
return view('admin.creators.show', compact('creator', 'stats'));
}
public function toggleVerified(Request $request, CreatorProfile $creator)
{
$creator->update(['is_verified' => !$creator->is_verified]);
$this->logAction($request->user(), 'creator_verify_toggle', $creator, [
'is_verified' => $creator->is_verified,
]);
return back()->with('success', __('admin.creator_updated'));
}
public function toggleFeatured(Request $request, CreatorProfile $creator)
{
$creator->update(['is_featured' => !$creator->is_featured]);
$this->logAction($request->user(), 'creator_feature_toggle', $creator, [
'is_featured' => $creator->is_featured,
]);
return back()->with('success', __('admin.creator_updated'));
}
private function logAction($admin, string $action, $target, array $metadata = []): void
{
DB::table('admin_activity_logs')->insert([
'admin_id' => $admin->id,
'action' => $action,
'target_type' => 'creator',
'target_id' => $target->id,
'metadata' => json_encode($metadata),
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
'created_at' => now(),
]);
}
}
<?php
namespace App\Modules\Admin\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Admin\Services\AdminService;
use Illuminate\Http\Request;
class AdminDashboardController extends Controller
{
public function __construct(
private AdminService $service,
) {}
public function __invoke(Request $request)
{
$stats = $this->service->getDashboardStats();
if ($request->expectsJson()) {
return $this->success($stats);
}
return view('admin.dashboard', compact('stats'));
}
}
<?php
namespace App\Modules\Admin\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Projects\Models\Project;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class AdminProjectController extends Controller
{
public function index(Request $request)
{
$query = Project::with(['creator.user', 'company']);
if ($request->filled('status')) {
$query->where('status', $request->query('status'));
}
if ($request->filled('search')) {
$search = $request->query('search');
$query->where(function ($q) use ($search) {
$q->where('title', 'ilike', "%{$search}%");
});
}
if ($request->query('overdue')) {
$query->where('status', 'in_progress')
->whereNotNull('deadline')
->where('deadline', '<', now());
}
if ($request->query('disputed')) {
$query->where('status', 'disputed');
}
$projects = $query->orderByDesc('created_at')->paginate(25);
return view('admin.projects.index', compact('projects'));
}
public function show(Project $project)
{
$project->load([
'creator.user',
'company',
'deliverables',
'conversation.messages',
]);
return view('admin.projects.show', compact('project'));
}
public function forceStatus(\App\Modules\Admin\Requests\ForceProjectStatusRequest $request, Project $project)
{
$validated = $request->validated();
$oldStatus = $project->status;
$project->update(['status' => $validated['status']]);
$this->logAction($request->user(), 'project_force_status', $project, [
'from' => $oldStatus,
'to' => $validated['status'],
'reason' => $validated['reason'],
]);
return back()->with('success', __('admin.project_updated'));
}
public function addNote(\App\Modules\Admin\Requests\AddProjectNoteRequest $request, Project $project)
{
$note = $request->validated('note');
$existingNotes = $project->admin_notes ?? [];
$existingNotes[] = [
'admin_id' => $request->user()->id,
'admin_name' => $request->user()->name,
'note' => $note,
'created_at' => now()->toIso8601String(),
];
$project->update(['admin_notes' => $existingNotes]);
$this->logAction($request->user(), 'project_note_added', $project, [
'note_preview' => \Illuminate\Support\Str::limit($note, 100),
]);
return back()->with('success', __('admin.note_added'));
}
public function resolveDispute(\App\Modules\Admin\Requests\ResolveDisputeRequest $request, Project $project)
{
$validated = $request->validated();
$project->update([
'status' => $validated['new_status'],
'dispute_resolved_at' => now(),
'dispute_resolution' => $validated['resolution'],
'dispute_reason' => $validated['reason'],
]);
$this->logAction($request->user(), 'dispute_resolved', $project, [
'resolution' => $validated['resolution'],
'new_status' => $validated['new_status'],
'reason' => $validated['reason'],
]);
return back()->with('success', __('admin.dispute_resolved'));
}
private function logAction($admin, string $action, $target, array $metadata = []): void
{
DB::table('admin_activity_logs')->insert([
'admin_id' => $admin->id,
'action' => $action,
'target_type' => 'project',
'target_id' => $target->id,
'metadata' => json_encode($metadata),
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
'created_at' => now(),
]);
}
}
<?php
namespace App\Modules\Admin\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Admin\Requests\ChangeUserStatusRequest;
use App\Modules\Admin\Services\AdminService;
use Illuminate\Http\Request;
class AdminUserController extends Controller
{
public function __construct(
private AdminService $service,
) {}
public function index(Request $request)
{
$users = $this->service->getUsers($request->only([
'search', 'role', 'status', 'verified', 'sort', 'direction', 'per_page',
]));
if ($request->expectsJson()) {
return $this->success($users->items(), '', 200, [
'current_page' => $users->currentPage(),
'per_page' => $users->perPage(),
'total' => $users->total(),
'last_page' => $users->lastPage(),
]);
}
return view('admin.users.index', compact('users'));
}
public function show(Request $request, string $uuid)
{
$user = $this->service->getUser($uuid);
if ($request->expectsJson()) {
return $this->success($user);
}
return view('admin.users.show', compact('user'));
}
public function changeStatus(ChangeUserStatusRequest $request, string $uuid)
{
$validated = $request->validated();
$target = $this->service->getUser($uuid);
$this->service->changeUserStatus(
target: $target,
newStatus: $validated['status'],
admin: $request->user(),
reason: $validated['reason'] ?? null,
);
if ($request->expectsJson()) {
return $this->success(null, __('admin.user_status_changed'));
}
return back()->with('toast', [
'type' => 'success',
'message' => __('admin.user_status_changed'),
]);
}
public function impersonate(Request $request, string $uuid)
{
$target = $this->service->getUser($uuid);
$this->service->startImpersonation($request->user(), $target);
return redirect('/');
}
}
<?php
namespace App\Modules\Admin\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Admin\Services\AdminService;
use Illuminate\Http\Request;
class ImpersonationController extends Controller
{
public function __construct(
private AdminService $service,
) {}
public function stop(Request $request)
{
$this->service->stopImpersonation();
return redirect()->route('admin.dashboard');
}
}
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('admin_activity_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('admin_id')->constrained('users')->onDelete('cascade');
$table->string('action', 50);
$table->string('target_type', 50)->nullable();
$table->unsignedBigInteger('target_id')->nullable();
$table->jsonb('metadata')->nullable();
$table->ipAddress('ip_address')->nullable();
$table->string('user_agent', 500)->nullable();
$table->timestamp('created_at');
$table->index('admin_id');
$table->index('action');
$table->index(['target_type', 'target_id']);
$table->index('created_at');
});
}
public function down(): void
{
Schema::dropIfExists('admin_activity_logs');
}
};
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('users')) {
Schema::table('users', function (Blueprint $table) {
$table->index('role');
$table->index('status');
$table->index(['role', 'status']);
$table->index('email_verified_at');
$table->index('last_login_at');
});
}
if (Schema::hasTable('campaigns')) {
Schema::table('campaigns', function (Blueprint $table) {
$table->index('status');
$table->index('deadline');
$table->index(['status', 'deadline']);
$table->index('is_featured');
$table->index('created_at');
});
}
if (Schema::hasTable('applications')) {
Schema::table('applications', function (Blueprint $table) {
$table->index('status');
$table->index(['campaign_id', 'status']);
$table->index(['creator_profile_id', 'status']);
});
}
if (Schema::hasTable('projects')) {
Schema::table('projects', function (Blueprint $table) {
$table->index('status');
$table->index('deadline');
$table->index(['status', 'deadline']);
$table->index(['company_id', 'status']);
$table->index(['creator_profile_id', 'status']);
});
}
if (Schema::hasTable('deliverables')) {
Schema::table('deliverables', function (Blueprint $table) {
$table->index('status');
$table->index(['project_id', 'status']);
$table->index('due_date');
});
}
if (Schema::hasTable('notifications')) {
Schema::table('notifications', function (Blueprint $table) {
$table->index(['user_id', 'is_read', 'created_at']);
$table->index(['user_id', 'type', 'created_at']);
});
}
if (Schema::hasTable('messages')) {
Schema::table('messages', function (Blueprint $table) {
$table->index(['conversation_id', 'created_at']);
});
}
if (Schema::hasTable('reviews')) {
Schema::table('reviews', function (Blueprint $table) {
$table->index(['reviewee_id', 'is_visible']);
$table->index('is_visible');
});
}
if (Schema::hasTable('creator_profiles')) {
Schema::table('creator_profiles', function (Blueprint $table) {
$table->index('availability_status');
$table->index('is_verified');
$table->index('is_featured');
$table->index('reputation_score');
});
}
if (Schema::hasTable('company_profiles')) {
Schema::table('company_profiles', function (Blueprint $table) {
$table->index('approval_status');
$table->index('reputation_score');
});
}
}
public function down(): void
{
if (Schema::hasTable('users')) {
Schema::table('users', function (Blueprint $table) {
$table->dropIndex(['role']);
$table->dropIndex(['status']);
$table->dropIndex(['role', 'status']);
$table->dropIndex(['email_verified_at']);
$table->dropIndex(['last_login_at']);
});
}
}
};
<?php
namespace App\Modules\Admin\Requests;
use Illuminate\Foundation\Http\FormRequest;
class AddProjectNoteRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->isAdmin();
}
public function rules(): array
{
return [
'note' => ['required', 'string', 'min:5', 'max:2000'],
];
}
}
<?php
namespace App\Modules\Admin\Requests;
use App\Shared\Enums\UserStatus;
use Illuminate\Foundation\Http\FormRequest;
class ChangeUserStatusRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->isAdmin();
}
public function rules(): array
{
$allowed = implode(',', [
UserStatus::Active->value,
UserStatus::Suspended->value,
UserStatus::Banned->value,
UserStatus::Deactivated->value,
]);
return [
'status' => ['required', 'string', "in:{$allowed}"],
'reason' => ['required_if:status,suspended,banned', 'nullable', 'string', 'max:500'],
];
}
public function messages(): array
{
return [
'reason.required_if' => __('admin.reason_required_for_action'),
];
}
}
<?php
namespace App\Modules\Admin\Requests;
use Illuminate\Foundation\Http\FormRequest;
class ForceProjectStatusRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->isAdmin();
}
public function rules(): array
{
return [
'status' => ['required', 'string', 'in:active,in_progress,completed,cancelled'],
'reason' => ['required', 'string', 'min:10', 'max:1000'],
];
}
public function messages(): array
{
return [
'reason.min' => __('admin.force_status_reason_too_short'),
];
}
}
<?php
namespace App\Modules\Admin\Requests;
use Illuminate\Foundation\Http\FormRequest;
class RejectCompanyRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->isAdmin();
}
public function rules(): array
{
return [
'reason' => ['required', 'string', 'min:10', 'max:1000'],
];
}
public function messages(): array
{
return [
'reason.min' => __('admin.rejection_reason_too_short'),
];
}
}
<?php
namespace App\Modules\Admin\Requests;
use Illuminate\Foundation\Http\FormRequest;
class ResolveDisputeRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->isAdmin();
}
public function rules(): array
{
return [
'resolution' => ['required', 'string', 'in:creator_favor,company_favor,mutual,no_fault,custom'],
'reason' => ['required', 'string', 'min:20', 'max:5000'],
'new_status' => ['required', 'string', 'in:active,in_progress,completed,cancelled'],
];
}
public function messages(): array
{
return [
'reason.min' => __('admin.dispute_reason_too_short'),
];
}
}
<?php
use App\Modules\Admin\Controllers\AdminAuditLogController;
use App\Modules\Admin\Controllers\AdminCampaignController;
use App\Modules\Admin\Controllers\AdminCompanyController;
use App\Modules\Admin\Controllers\AdminCreatorController;
use App\Modules\Admin\Controllers\AdminDashboardController;
use App\Modules\Admin\Controllers\AdminProjectController;
use App\Modules\Admin\Controllers\AdminUserController;
use App\Modules\Admin\Controllers\ImpersonationController;
use Illuminate\Support\Facades\Route;
Route::middleware(['auth', 'verified', 'role:admin'])->prefix('admin')->name('admin.')->group(function () {
Route::get('/dashboard', AdminDashboardController::class)->name('dashboard');
// User Management
Route::get('/users', [AdminUserController::class, 'index'])->name('users.index');
Route::get('/users/{uuid}', [AdminUserController::class, 'show'])->name('users.show');
Route::put('/users/{uuid}/status', [AdminUserController::class, 'changeStatus'])->name('users.status');
Route::post('/users/{uuid}/impersonate', [AdminUserController::class, 'impersonate'])->name('users.impersonate');
// Company Approval
Route::get('/companies/pending', [AdminCompanyController::class, 'pending'])->name('companies.pending');
Route::get('/companies/{id}', [AdminCompanyController::class, 'show'])->name('companies.show');
Route::post('/companies/{id}/approve', [AdminCompanyController::class, 'approve'])->name('companies.approve');
Route::post('/companies/{id}/reject', [AdminCompanyController::class, 'reject'])->name('companies.reject');
// Campaign Management
Route::get('/campaigns', [AdminCampaignController::class, 'index'])->name('campaigns.index');
Route::get('/campaigns/{campaign}', [AdminCampaignController::class, 'show'])->name('campaigns.show');
Route::post('/campaigns/{campaign}/feature', [AdminCampaignController::class, 'toggleFeatured'])->name('campaigns.feature');
Route::post('/campaigns/{campaign}/pause', [AdminCampaignController::class, 'togglePaused'])->name('campaigns.pause');
// Project Management
Route::get('/projects', [AdminProjectController::class, 'index'])->name('projects.index');
Route::get('/projects/{project}', [AdminProjectController::class, 'show'])->name('projects.show');
Route::post('/projects/{project}/force-status', [AdminProjectController::class, 'forceStatus'])->name('projects.force-status');
Route::post('/projects/{project}/note', [AdminProjectController::class, 'addNote'])->name('projects.note');
Route::post('/projects/{project}/resolve-dispute', [AdminProjectController::class, 'resolveDispute'])->name('projects.resolve-dispute');
// Creator Management
Route::get('/creators', [AdminCreatorController::class, 'index'])->name('creators.index');
Route::get('/creators/{creator}', [AdminCreatorController::class, 'show'])->name('creators.show');
Route::post('/creators/{creator}/verify', [AdminCreatorController::class, 'toggleVerified'])->name('creators.verify');
Route::post('/creators/{creator}/feature', [AdminCreatorController::class, 'toggleFeatured'])->name('creators.feature');
// Audit Log
Route::get('/audit-log', AdminAuditLogController::class)->name('audit-log');
});
// Impersonation stop — accessible from any page while impersonating
Route::middleware(['auth'])->group(function () {
Route::post('/admin/impersonate/stop', [ImpersonationController::class, 'stop'])->name('admin.impersonate.stop');
});
<?php
namespace App\Modules\Admin\Services;
use App\Models\User;
use App\Modules\Companies\Models\CompanyProfile;
use App\Modules\Companies\Services\CompanyProfileService;
use App\Shared\Enums\UserStatus;
use App\Shared\Exceptions\InvalidStatusTransitionException;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
class AdminService
{
public function __construct(
private CompanyProfileService $companyService,
) {}
public function getUsers(array $filters = []): LengthAwarePaginator
{
$query = User::query()->withTrashed();
if (!empty($filters['search'])) {
$query->search($filters['search']);
}
if (!empty($filters['role'])) {
$query->byRole($filters['role']);
}
if (!empty($filters['status'])) {
$query->where('status', $filters['status']);
}
if (isset($filters['verified']) && $filters['verified'] !== '') {
if ($filters['verified']) {
$query->verified();
} else {
$query->whereNull('email_verified_at');
}
}
$sortBy = $filters['sort'] ?? 'created_at';
$sortDir = $filters['direction'] ?? 'desc';
$allowedSorts = ['name', 'email', 'created_at', 'last_login_at', 'status', 'role'];
if (in_array($sortBy, $allowedSorts, true)) {
$query->orderBy($sortBy, $sortDir === 'asc' ? 'asc' : 'desc');
}
return $query->paginate(
perPage: min((int) ($filters['per_page'] ?? 25), 100)
);
}
public function getUser(string $uuid): User
{
return User::where('uuid', $uuid)
->withTrashed()
->with(['creatorProfile', 'companyProfile'])
->firstOrFail();
}
public function changeUserStatus(User $target, string $newStatus, User $admin, ?string $reason = null): User
{
if ($target->isAdmin()) {
throw new InvalidStatusTransitionException('Cannot change status of an admin user.');
}
if (!UserStatus::canTransition($target->status, $newStatus)) {
throw new InvalidStatusTransitionException(
"Cannot transition user from {$target->status} to {$newStatus}."
);
}
return DB::transaction(function () use ($target, $newStatus, $admin, $reason) {
$oldStatus = $target->status;
$target->status = $newStatus;
$target->save();
DB::table('admin_activity_logs')->insert([
'admin_id' => $admin->id,
'action' => 'user_status_change',
'target_type' => 'user',
'target_id' => $target->id,
'metadata' => json_encode([
'from' => $oldStatus,
'to' => $newStatus,
'reason' => $reason,
]),
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
'created_at' => now(),
]);
return $target;
});
}
public function getPendingCompanies(array $filters = []): LengthAwarePaginator
{
$query = CompanyProfile::pending()->with('user');
if (!empty($filters['search'])) {
$query->search($filters['search']);
}
return $query->orderBy('created_at', 'asc')
->paginate(min((int) ($filters['per_page'] ?? 25), 100));
}
public function approveCompany(CompanyProfile $company, User $admin): CompanyProfile
{
return $this->companyService->approve($company, $admin);
}
public function rejectCompany(CompanyProfile $company, User $admin, string $reason): CompanyProfile
{
return $this->companyService->reject($company, $admin, $reason);
}
public function startImpersonation(User $admin, User $target): void
{
if ($target->isAdmin()) {
throw new InvalidStatusTransitionException('Cannot impersonate another admin.');
}
session([
'impersonating' => true,
'impersonator_id' => $admin->id,
'original_user_id' => $admin->id,
]);
DB::table('admin_activity_logs')->insert([
'admin_id' => $admin->id,
'action' => 'impersonation_start',
'target_type' => 'user',
'target_id' => $target->id,
'metadata' => json_encode([
'target_name' => $target->name,
'target_email' => $target->email,
]),
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
'created_at' => now(),
]);
auth()->login($target);
}
public function stopImpersonation(): void
{
$originalId = session('original_user_id');
if (!$originalId) {
return;
}
$admin = User::findOrFail($originalId);
DB::table('admin_activity_logs')->insert([
'admin_id' => $admin->id,
'action' => 'impersonation_end',
'target_type' => 'user',
'target_id' => auth()->id(),
'metadata' => json_encode([
'impersonated_name' => auth()->user()->name,
]),
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
'created_at' => now(),
]);
session()->forget(['impersonating', 'impersonator_id', 'original_user_id']);
auth()->login($admin);
}
public function getDashboardStats(): array
{
return [
'total_users' => User::count(),
'total_creators' => User::byRole('creator')->count(),
'total_companies' => User::byRole('company')->count(),
'pending_companies' => CompanyProfile::pending()->count(),
'active_users' => User::active()->count(),
'suspended_users' => User::where('status', UserStatus::Suspended->value)->count(),
'banned_users' => User::where('status', UserStatus::Banned->value)->count(),
'verified_users' => User::verified()->count(),
'new_users_today' => User::whereDate('created_at', today())->count(),
'new_users_week' => User::where('created_at', '>=', now()->subWeek())->count(),
];
}
public function getActivityLog(array $filters = []): LengthAwarePaginator
{
$query = DB::table('admin_activity_logs')
->join('users', 'admin_activity_logs.admin_id', '=', 'users.id')
->select([
'admin_activity_logs.*',
'users.name as admin_name',
'users.email as admin_email',
]);
if (!empty($filters['action'])) {
$query->where('admin_activity_logs.action', $filters['action']);
}
if (!empty($filters['admin_id'])) {
$query->where('admin_activity_logs.admin_id', $filters['admin_id']);
}
if (!empty($filters['date_from'])) {
$query->where('admin_activity_logs.created_at', '>=', $filters['date_from']);
}
if (!empty($filters['date_to'])) {
$query->where('admin_activity_logs.created_at', '<=', $filters['date_to']);
}
return $query->orderBy('admin_activity_logs.created_at', 'desc')
->paginate(min((int) ($filters['per_page'] ?? 25), 100));
}
}
<?php
namespace App\Modules\Applications\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Applications\Models\Application;
use App\Modules\Applications\Requests\ApplyToCampaignRequest;
use App\Modules\Applications\Requests\UpdateApplicationRequest;
use App\Modules\Applications\Resources\ApplicationResource;
use App\Modules\Applications\Services\ApplicationService;
use App\Modules\Campaigns\Models\Campaign;
use Illuminate\Http\Request;
class ApplicationController extends Controller
{
public function __construct(
private ApplicationService $service,
) {}
public function index(Request $request)
{
$creator = $request->user()->creatorProfile;
$applications = $this->service->getCreatorApplications($creator, $request->only(['status', 'per_page']));
if ($request->expectsJson()) {
return ApplicationResource::collection($applications);
}
return view('applications.index', compact('applications'));
}
public function create(Request $request, string $slug)
{
$campaign = Campaign::where('slug', $slug)
->with('company')
->firstOrFail();
$this->authorize('create', [Application::class, $campaign]);
$creator = $request->user()->creatorProfile;
$portfolioItems = $creator->portfolioItems()->latest()->limit(20)->get();
return view('applications.create', compact('campaign', 'creator', 'portfolioItems'));
}
public function store(ApplyToCampaignRequest $request, string $slug)
{
$campaign = Campaign::where('slug', $slug)->firstOrFail();
$creator = $request->user()->creatorProfile;
try {
$application = $this->service->apply($creator, $campaign, $request->validated());
} catch (\App\Modules\Applications\Exceptions\DuplicateApplicationException $e) {
return $this->handleApplicationError($request, $e, $campaign);
} catch (\App\Modules\Applications\Exceptions\ProfileIncompleteException $e) {
return $this->handleApplicationError($request, $e, $campaign);
} catch (\App\Modules\Applications\Exceptions\DeadlinePassedException $e) {
return $this->handleApplicationError($request, $e, $campaign);
} catch (\App\Modules\Applications\Exceptions\InviteOnlyCampaignException $e) {
return $this->handleApplicationError($request, $e, $campaign);
} catch (\App\Modules\Applications\Exceptions\CampaignNotAcceptingException $e) {
return $this->handleApplicationError($request, $e, $campaign);
} catch (\App\Modules\Applications\Exceptions\OwnCampaignException $e) {
return $this->handleApplicationError($request, $e, $campaign);
}
if ($request->expectsJson()) {
return new ApplicationResource($application);
}
return redirect()
->route('creator.applications.index')
->with('success', __('applications.submitted'));
}
public function show(Request $request, Application $application)
{
$this->authorize('view', $application);
$application->load(['campaign.company', 'portfolioItems']);
if ($request->expectsJson()) {
return new ApplicationResource($application);
}
return view('applications.show', compact('application'));
}
public function edit(Request $request, Application $application)
{
$this->authorize('update', $application);
$application->load(['campaign.company', 'portfolioItems']);
$creator = $request->user()->creatorProfile;
$portfolioItems = $creator->portfolioItems()->latest()->limit(20)->get();
return view('applications.edit', compact('application', 'portfolioItems'));
}
public function update(UpdateApplicationRequest $request, Application $application)
{
$this->authorize('update', $application);
$application = $this->service->update($application, $request->validated());
if ($request->expectsJson()) {
return new ApplicationResource($application);
}
return redirect()
->route('creator.applications.index')
->with('success', __('applications.updated'));
}
public function withdraw(Request $request, Application $application)
{
$this->authorize('withdraw', $application);
$this->service->withdraw($application);
if ($request->expectsJson()) {
return $this->success(null, __('applications.withdrawn'));
}
return redirect()
->route('creator.applications.index')
->with('success', __('applications.withdrawn'));
}
private function handleApplicationError(Request $request, \Exception $e, Campaign $campaign)
{
$errorMap = [
\App\Modules\Applications\Exceptions\DuplicateApplicationException::class => ['DUPLICATE_APPLICATION', 409],
\App\Modules\Applications\Exceptions\ProfileIncompleteException::class => ['PROFILE_INCOMPLETE', 422],
\App\Modules\Applications\Exceptions\DeadlinePassedException::class => ['DEADLINE_PASSED', 422],
\App\Modules\Applications\Exceptions\InviteOnlyCampaignException::class => ['INVITE_ONLY', 403],
\App\Modules\Applications\Exceptions\CampaignNotAcceptingException::class => ['CAMPAIGN_NOT_ACCEPTING', 422],
\App\Modules\Applications\Exceptions\OwnCampaignException::class => ['OWN_CAMPAIGN', 403],
];
$mapped = $errorMap[get_class($e)] ?? ['APPLICATION_ERROR', 422];
if ($request->expectsJson()) {
return $this->error($e->getMessage(), $mapped[1], $mapped[0]);
}
return redirect()
->route('campaigns.show', $campaign->slug)
->with('error', $e->getMessage());
}
}
<?php
namespace App\Modules\Applications\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Applications\Models\Application;
use App\Modules\Applications\Resources\ApplicationResource;
use App\Modules\Applications\Services\ApplicationService;
use App\Modules\Campaigns\Models\Campaign;
use Illuminate\Http\Request;
class CompanyApplicationController extends Controller
{
public function __construct(
private ApplicationService $service,
) {}
public function index(Request $request, string $uuid)
{
$campaign = Campaign::where('uuid', $uuid)
->where('company_id', $request->user()->companyProfile->id)
->firstOrFail();
$applications = $this->service->getCampaignApplications(
$campaign,
$request->only(['status', 'sort', 'per_page'])
);
if ($request->expectsJson()) {
return ApplicationResource::collection($applications);
}
return view('applications.company.index', compact('campaign', 'applications'));
}
public function show(Request $request, Application $application)
{
$this->authorize('manage', $application);
$application->load(['creator.user', 'campaign', 'portfolioItems']);
$this->service->markViewed($application);
if ($request->expectsJson()) {
return new ApplicationResource($application);
}
return view('applications.company.show', compact('application'));
}
public function shortlist(Request $request, Application $application)
{
$this->authorize('shortlist', $application);
$this->service->shortlist($application);
if ($request->expectsJson()) {
return $this->success(null, __('applications.shortlisted'));
}
return back()->with('success', __('applications.shortlisted'));
}
public function accept(Request $request, Application $application)
{
$this->authorize('accept', $application);
$this->service->accept($application);
if ($request->expectsJson()) {
return $this->success(null, __('applications.accepted'));
}
return back()->with('success', __('applications.accepted'));
}
public function reject(\App\Modules\Applications\Requests\RejectApplicationRequest $request, Application $application)
{
$this->authorize('reject', $application);
$this->service->reject($application, $request->validated('reason'));
if ($request->expectsJson()) {
return $this->success(null, __('applications.rejected'));
}
return back()->with('success', __('applications.rejected'));
}
public function bulkReject(\App\Modules\Applications\Requests\BulkRejectApplicationsRequest $request, string $uuid)
{
$campaign = Campaign::where('uuid', $uuid)
->where('company_id', $request->user()->companyProfile->id)
->firstOrFail();
$validated = $request->validated();
$applications = Application::whereIn('uuid', $validated['application_ids'])
->where('campaign_id', $campaign->id)
->get();
$count = $this->service->bulkReject($applications, $validated['reason'] ?? null);
if ($request->expectsJson()) {
return $this->success(null, __('applications.bulk_rejected', ['count' => $count]));
}
return back()->with('success', __('applications.bulk_rejected', ['count' => $count]));
}
}
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('applications', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
$table->foreignId('campaign_id')->constrained('campaigns')->cascadeOnDelete();
$table->foreignId('creator_id')->constrained('creator_profiles')->cascadeOnDelete();
$table->string('status', 20)->default('submitted');
$table->text('cover_message');
$table->decimal('proposed_budget', 10, 2)->nullable();
$table->string('proposed_timeline', 200)->nullable();
$table->text('questions_for_company')->nullable();
$table->text('rejection_reason')->nullable();
$table->timestamp('viewed_at')->nullable();
$table->timestamp('shortlisted_at')->nullable();
$table->timestamp('accepted_at')->nullable();
$table->timestamp('rejected_at')->nullable();
$table->timestamp('withdrawn_at')->nullable();
$table->timestamps();
$table->unique(['campaign_id', 'creator_id']);
$table->index('campaign_id');
$table->index('creator_id');
$table->index('status');
});
DB::statement("ALTER TABLE applications ADD CONSTRAINT applications_status_check CHECK (status IN ('submitted', 'viewed', 'shortlisted', 'accepted', 'rejected', 'withdrawn', 'cancelled'))");
}
public function down(): void
{
Schema::dropIfExists('applications');
}
};
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('application_portfolio_items', function (Blueprint $table) {
$table->foreignId('application_id')->constrained('applications')->cascadeOnDelete();
$table->foreignId('portfolio_item_id')->constrained('portfolio_items')->cascadeOnDelete();
$table->primary(['application_id', 'portfolio_item_id']);
});
}
public function down(): void
{
Schema::dropIfExists('application_portfolio_items');
}
};
<?php
namespace App\Modules\Applications\Enums;
enum ApplicationStatus: string
{
case Submitted = 'submitted';
case Viewed = 'viewed';
case Shortlisted = 'shortlisted';
case Accepted = 'accepted';
case Rejected = 'rejected';
case Withdrawn = 'withdrawn';
case Cancelled = 'cancelled';
public function label(): string
{
return match ($this) {
self::Submitted => __('applications.status_submitted'),
self::Viewed => __('applications.status_viewed'),
self::Shortlisted => __('applications.status_shortlisted'),
self::Accepted => __('applications.status_accepted'),
self::Rejected => __('applications.status_rejected'),
self::Withdrawn => __('applications.status_withdrawn'),
self::Cancelled => __('applications.status_cancelled'),
};
}
public function icon(): string
{
return match ($this) {
self::Submitted => 'send',
self::Viewed => 'eye',
self::Shortlisted => 'star',
self::Accepted => 'check-circle',
self::Rejected => 'x-circle',
self::Withdrawn => 'arrow-left',
self::Cancelled => 'ban',
};
}
public function isTerminal(): bool
{
return in_array($this, [self::Accepted, self::Rejected, self::Withdrawn, self::Cancelled]);
}
public function isPending(): bool
{
return in_array($this, [self::Submitted, self::Viewed, self::Shortlisted]);
}
public function canBeEdited(): bool
{
return $this === self::Submitted;
}
public function canBeWithdrawn(): bool
{
return in_array($this, [self::Submitted, self::Viewed, self::Shortlisted]);
}
public static function allowedTransitions(): array
{
return [
self::Submitted->value => [self::Viewed->value, self::Shortlisted->value, self::Accepted->value, self::Rejected->value, self::Withdrawn->value, self::Cancelled->value],
self::Viewed->value => [self::Shortlisted->value, self::Accepted->value, self::Rejected->value, self::Withdrawn->value, self::Cancelled->value],
self::Shortlisted->value => [self::Accepted->value, self::Rejected->value, self::Withdrawn->value, self::Cancelled->value],
self::Rejected->value => [self::Accepted->value],
];
}
public static function canTransition(string $from, string $to): bool
{
$transitions = self::allowedTransitions();
return isset($transitions[$from]) && in_array($to, $transitions[$from], true);
}
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}
<?php
namespace App\Modules\Applications\Events;
use App\Modules\Applications\Models\Application;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ApplicationAccepted
{
use Dispatchable, SerializesModels;
public function __construct(
public Application $application,
) {}
}
<?php
namespace App\Modules\Applications\Events;
use App\Modules\Applications\Models\Application;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ApplicationRejected
{
use Dispatchable, SerializesModels;
public function __construct(
public Application $application,
) {}
}
<?php
namespace App\Modules\Applications\Events;
use App\Modules\Applications\Models\Application;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ApplicationShortlisted
{
use Dispatchable, SerializesModels;
public function __construct(
public Application $application,
) {}
}
<?php
namespace App\Modules\Applications\Events;
use App\Modules\Applications\Models\Application;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ApplicationSubmitted
{
use Dispatchable, SerializesModels;
public function __construct(
public Application $application,
) {}
}
<?php
namespace App\Modules\Applications\Events;
use App\Modules\Applications\Models\Application;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ApplicationViewed
{
use Dispatchable, SerializesModels;
public function __construct(
public Application $application,
) {}
}
<?php
namespace App\Modules\Applications\Events;
use App\Modules\Applications\Models\Application;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ApplicationWithdrawn
{
use Dispatchable, SerializesModels;
public function __construct(
public Application $application,
) {}
}
<?php
namespace App\Modules\Applications\Exceptions;
use Exception;
class CampaignNotAcceptingException extends Exception
{
}
<?php
namespace App\Modules\Applications\Exceptions;
use Exception;
class DeadlinePassedException extends Exception
{
}
<?php
namespace App\Modules\Applications\Exceptions;
use Exception;
class DuplicateApplicationException extends Exception
{
}
<?php
namespace App\Modules\Applications\Exceptions;
use Exception;
class InviteOnlyCampaignException extends Exception
{
}
<?php
namespace App\Modules\Applications\Exceptions;
use Exception;
class OwnCampaignException extends Exception
{
}
<?php
namespace App\Modules\Applications\Exceptions;
use Exception;
class ProfileIncompleteException extends Exception
{
}
<?php
namespace App\Modules\Applications\Models;
use App\Modules\Applications\Enums\ApplicationStatus;
use App\Modules\Campaigns\Models\Campaign;
use App\Modules\Creators\Models\CreatorProfile;
use App\Modules\Portfolios\Models\PortfolioItem;
use App\Shared\Traits\HasUuid;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Application extends Model
{
use HasUuid, HasFactory;
protected static function newFactory(): \Database\Factories\ApplicationFactory
{
return \Database\Factories\ApplicationFactory::new();
}
protected $fillable = [
'uuid',
'campaign_id',
'creator_id',
'status',
'cover_message',
'proposed_budget',
'proposed_timeline',
'questions_for_company',
'rejection_reason',
'viewed_at',
'shortlisted_at',
'accepted_at',
'rejected_at',
'withdrawn_at',
];
protected function casts(): array
{
return [
'proposed_budget' => 'decimal:2',
'viewed_at' => 'datetime',
'shortlisted_at' => 'datetime',
'accepted_at' => 'datetime',
'rejected_at' => 'datetime',
'withdrawn_at' => 'datetime',
];
}
public function getRouteKeyName(): string
{
return 'uuid';
}
public function campaign(): BelongsTo
{
return $this->belongsTo(Campaign::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(CreatorProfile::class, 'creator_id');
}
public function portfolioItems(): BelongsToMany
{
return $this->belongsToMany(
PortfolioItem::class,
'application_portfolio_items',
'application_id',
'portfolio_item_id'
);
}
public function getStatusEnumAttribute(): ApplicationStatus
{
return ApplicationStatus::from($this->status);
}
public function canTransitionTo(string $status): bool
{
return ApplicationStatus::canTransition($this->status, $status);
}
public function canBeEdited(): bool
{
return ApplicationStatus::from($this->status)->canBeEdited();
}
public function canBeWithdrawn(): bool
{
return ApplicationStatus::from($this->status)->canBeWithdrawn();
}
public function scopeByStatus(Builder $query, string $status): Builder
{
return $query->where('status', $status);
}
public function scopeByCampaign(Builder $query, int $campaignId): Builder
{
return $query->where('campaign_id', $campaignId);
}
public function scopeByCreator(Builder $query, int $creatorId): Builder
{
return $query->where('creator_id', $creatorId);
}
public function scopePending(Builder $query): Builder
{
return $query->whereIn('status', ['submitted', 'viewed', 'shortlisted']);
}
public function scopeActive(Builder $query): Builder
{
return $query->whereNotIn('status', ['withdrawn', 'cancelled']);
}
public function getTimeSinceSubmittedAttribute(): string
{
return $this->created_at->diffForHumans();
}
public function getHasNoResponseAttribute(): bool
{
return $this->status === 'submitted'
&& $this->created_at->diffInDays(now()) >= 30;
}
}
<?php
namespace App\Modules\Applications\Policies;
use App\Models\User;
use App\Modules\Applications\Models\Application;
use App\Modules\Campaigns\Models\Campaign;
class ApplicationPolicy
{
public function viewAny(User $user): bool
{
return true;
}
public function view(User $user, Application $application): bool
{
if ($user->role === 'admin') {
return true;
}
if ($user->isCreator() && $user->creatorProfile?->id === $application->creator_id) {
return true;
}
if ($user->isCompany() && $user->companyProfile?->id === $application->campaign?->company_id) {
return true;
}
return false;
}
public function create(User $user, Campaign $campaign): bool
{
if (!$user->isCreator()) {
return false;
}
if (!$user->creatorProfile) {
return false;
}
return true;
}
public function update(User $user, Application $application): bool
{
if (!$user->isCreator()) {
return false;
}
return $user->creatorProfile?->id === $application->creator_id
&& $application->canBeEdited();
}
public function withdraw(User $user, Application $application): bool
{
if (!$user->isCreator()) {
return false;
}
return $user->creatorProfile?->id === $application->creator_id
&& $application->canBeWithdrawn();
}
public function manage(User $user, Application $application): bool
{
if ($user->role === 'admin') {
return true;
}
if (!$user->isCompany()) {
return false;
}
return $user->companyProfile?->id === $application->campaign?->company_id;
}
public function shortlist(User $user, Application $application): bool
{
return $this->manage($user, $application)
&& $application->canTransitionTo('shortlisted');
}
public function accept(User $user, Application $application): bool
{
return $this->manage($user, $application)
&& $application->canTransitionTo('accepted');
}
public function reject(User $user, Application $application): bool
{
return $this->manage($user, $application)
&& $application->canTransitionTo('rejected');
}
}
<?php
namespace App\Modules\Applications\Requests;
use Illuminate\Foundation\Http\FormRequest;
class ApplyToCampaignRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->isCreator()
&& $this->user()->creatorProfile !== null;
}
public function rules(): array
{
$creatorId = $this->user()->creatorProfile?->id;
return [
'cover_message' => ['required', 'string', 'min:50', 'max:1000'],
'proposed_budget' => ['nullable', 'numeric', 'min:0', 'max:999999.99'],
'proposed_timeline' => ['nullable', 'string', 'max:200'],
'questions_for_company' => ['nullable', 'string', 'max:500'],
'availability_confirmed' => ['required', 'accepted'],
'portfolio_items' => ['nullable', 'array', 'max:5'],
'portfolio_items.*' => [
'integer',
"exists:portfolio_items,id,creator_profile_id,{$creatorId}",
],
];
}
public function messages(): array
{
return [
'cover_message.min' => __('applications.cover_message_too_short'),
'cover_message.max' => __('applications.cover_message_too_long'),
'availability_confirmed.accepted' => __('applications.must_confirm_availability'),
'portfolio_items.max' => __('applications.max_portfolio_items'),
];
}
}
<?php
namespace App\Modules\Applications\Requests;
use Illuminate\Foundation\Http\FormRequest;
class BulkRejectApplicationsRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'application_ids' => ['required', 'array', 'min:1'],
'application_ids.*' => ['string', 'exists:applications,uuid'],
'reason' => ['nullable', 'string', 'max:500'],
];
}
}
<?php
namespace App\Modules\Applications\Requests;
use Illuminate\Foundation\Http\FormRequest;
class RejectApplicationRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'reason' => ['nullable', 'string', 'max:500'],
];
}
}
<?php
namespace App\Modules\Applications\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateApplicationRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->isCreator()
&& $this->user()->creatorProfile !== null;
}
public function rules(): array
{
$creatorId = $this->user()->creatorProfile?->id;
return [
'cover_message' => ['sometimes', 'required', 'string', 'min:50', 'max:1000'],
'proposed_budget' => ['nullable', 'numeric', 'min:0', 'max:999999.99'],
'proposed_timeline' => ['nullable', 'string', 'max:200'],
'questions_for_company' => ['nullable', 'string', 'max:500'],
'portfolio_items' => ['nullable', 'array', 'max:5'],
'portfolio_items.*' => [
'integer',
"exists:portfolio_items,id,creator_profile_id,{$creatorId}",
],
];
}
}
<?php
namespace App\Modules\Applications\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ApplicationResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->uuid,
'status' => $this->status,
'status_label' => $this->status_enum->label(),
'cover_message' => $this->cover_message,
'proposed_budget' => $this->proposed_budget,
'proposed_timeline' => $this->proposed_timeline,
'questions_for_company' => $this->questions_for_company,
'rejection_reason' => $this->when(
$this->status === 'rejected' && $request->user()?->creatorProfile?->id === $this->creator_id,
$this->rejection_reason
),
'viewed_at' => $this->viewed_at?->toIso8601String(),
'shortlisted_at' => $this->shortlisted_at?->toIso8601String(),
'accepted_at' => $this->accepted_at?->toIso8601String(),
'rejected_at' => $this->rejected_at?->toIso8601String(),
'withdrawn_at' => $this->withdrawn_at?->toIso8601String(),
'submitted_at' => $this->created_at->toIso8601String(),
'has_no_response' => $this->has_no_response,
'campaign' => $this->whenLoaded('campaign', fn () => [
'id' => $this->campaign->uuid,
'title' => $this->campaign->title,
'slug' => $this->campaign->slug,
'status' => $this->campaign->status,
'company_name' => $this->campaign->company?->company_name,
]),
'creator' => $this->whenLoaded('creator', fn () => [
'id' => $this->creator->user?->uuid ?? null,
'display_name' => $this->creator->full_name,
'username' => $this->creator->username,
'avatar_url' => $this->creator->avatar_url,
'country' => $this->creator->country,
'experience_level' => $this->creator->experience_level,
'completion_percentage' => $this->creator->completion_percentage,
'completed_projects_count' => $this->creator->completed_projects_count,
'review_avg' => $this->creator->review_avg,
]),
'portfolio_items' => $this->whenLoaded('portfolioItems', fn () =>
$this->portfolioItems->map(fn ($item) => [
'id' => $item->uuid ?? $item->id,
'title' => $item->title,
'type' => $item->type,
'thumbnail_url' => $item->thumbnail_url ?? null,
])
),
];
}
}
<?php
use App\Modules\Applications\Controllers\ApplicationController;
use App\Modules\Applications\Controllers\CompanyApplicationController;
use Illuminate\Support\Facades\Route;
Route::middleware(['auth:sanctum'])->group(function () {
// Creator
Route::middleware(['role:creator'])->prefix('creator')->name('api.creator.')->group(function () {
Route::get('/applications', [ApplicationController::class, 'index'])->name('applications.index');
Route::get('/applications/{application}', [ApplicationController::class, 'show'])->name('applications.show');
Route::put('/applications/{application}', [ApplicationController::class, 'update'])->name('applications.update');
Route::post('/applications/{application}/withdraw', [ApplicationController::class, 'withdraw'])->name('applications.withdraw');
});
Route::middleware(['role:creator'])->group(function () {
Route::post('/campaigns/{slug}/apply', [ApplicationController::class, 'store'])->name('api.campaigns.apply');
});
// Company
Route::middleware(['role:company', 'company.approved'])->prefix('company')->name('api.company.')->group(function () {
Route::get('/campaigns/{uuid}/applications', [CompanyApplicationController::class, 'index'])->name('campaigns.applications.index');
Route::get('/applications/{application}', [CompanyApplicationController::class, 'show'])->name('applications.show');
Route::post('/applications/{application}/shortlist', [CompanyApplicationController::class, 'shortlist'])->name('applications.shortlist');
Route::post('/applications/{application}/accept', [CompanyApplicationController::class, 'accept'])->name('applications.accept');
Route::post('/applications/{application}/reject', [CompanyApplicationController::class, 'reject'])->name('applications.reject');
Route::post('/campaigns/{uuid}/applications/bulk-reject', [CompanyApplicationController::class, 'bulkReject'])->name('campaigns.applications.bulk-reject');
});
});
<?php
use App\Modules\Applications\Controllers\ApplicationController;
use App\Modules\Applications\Controllers\CompanyApplicationController;
use Illuminate\Support\Facades\Route;
// Creator application routes
Route::middleware(['auth', 'verified', 'role:creator'])->prefix('creator')->name('creator.')->group(function () {
Route::get('/applications', [ApplicationController::class, 'index'])->name('applications.index');
Route::get('/applications/{application}', [ApplicationController::class, 'show'])->name('applications.show');
Route::get('/applications/{application}/edit', [ApplicationController::class, 'edit'])->name('applications.edit');
Route::put('/applications/{application}', [ApplicationController::class, 'update'])->name('applications.update');
Route::post('/applications/{application}/withdraw', [ApplicationController::class, 'withdraw'])->name('applications.withdraw');
});
// Application form (apply to campaign)
Route::middleware(['auth', 'verified', 'role:creator'])->group(function () {
Route::get('/campaigns/{slug}/apply', [ApplicationController::class, 'create'])->name('campaigns.apply');
Route::post('/campaigns/{slug}/apply', [ApplicationController::class, 'store'])->name('campaigns.apply.store');
});
// Company-side application management
Route::middleware(['auth', 'verified', 'role:company', 'company.approved'])->prefix('company')->name('company.')->group(function () {
Route::get('/campaigns/{uuid}/applications', [CompanyApplicationController::class, 'index'])->name('campaigns.applications.index');
Route::get('/applications/{application}', [CompanyApplicationController::class, 'show'])->name('applications.show');
Route::post('/applications/{application}/shortlist', [CompanyApplicationController::class, 'shortlist'])->name('applications.shortlist');
Route::post('/applications/{application}/accept', [CompanyApplicationController::class, 'accept'])->name('applications.accept');
Route::post('/applications/{application}/reject', [CompanyApplicationController::class, 'reject'])->name('applications.reject');
Route::post('/campaigns/{uuid}/applications/bulk-reject', [CompanyApplicationController::class, 'bulkReject'])->name('campaigns.applications.bulk-reject');
});
This diff is collapsed.
<?php
namespace App\Modules\Applications\Tests;
use App\Models\User;
use App\Modules\Applications\Models\Application;
use App\Modules\Campaigns\Models\Campaign;
use App\Modules\Companies\Models\CompanyProfile;
use App\Modules\Creators\Models\CreatorProfile;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;
class ApplicationApiTest extends TestCase
{
use RefreshDatabase;
private User $creatorUser;
private CreatorProfile $creator;
private User $companyUser;
private CompanyProfile $company;
private Campaign $campaign;
protected function setUp(): void
{
parent::setUp();
Event::fake();
$this->creatorUser = User::factory()->creator()->create();
$this->creator = CreatorProfile::factory()->create([
'user_id' => $this->creatorUser->id,
'completion_percentage' => 80,
]);
$this->companyUser = User::factory()->company()->create();
$this->company = CompanyProfile::factory()->create([
'user_id' => $this->companyUser->id,
]);
$this->campaign = Campaign::factory()->create([
'company_id' => $this->company->id,
'status' => 'published',
'visibility' => 'public',
'allow_applications' => true,
'application_deadline' => now()->addDays(30),
]);
}
// --- Store (Apply) ---
public function test_creator_can_apply_to_campaign_via_api(): void
{
$response = $this->actingAs($this->creatorUser)
->postJson("/api/v1/campaigns/{$this->campaign->slug}/apply", [
'cover_message' => 'I would love to work on this.',
'proposed_budget' => 500,
'proposed_timeline' => '2 weeks',
]);
$response->assertStatus(200)
->assertJsonStructure([
'data' => ['id', 'status', 'cover_message', 'proposed_budget'],
]);
$this->assertDatabaseHas('applications', [
'campaign_id' => $this->campaign->id,
'creator_id' => $this->creator->id,
'status' => 'submitted',
]);
}
public function test_unauthenticated_user_cannot_apply(): void
{
$response = $this->postJson("/api/v1/campaigns/{$this->campaign->slug}/apply", [
'cover_message' => 'Test.',
]);
$response->assertStatus(401);
}
public function test_company_user_cannot_apply(): void
{
$response = $this->actingAs($this->companyUser)
->postJson("/api/v1/campaigns/{$this->campaign->slug}/apply", [
'cover_message' => 'Test.',
]);
$response->assertStatus(403);
}
public function test_apply_returns_409_for_duplicate(): void
{
Application::factory()->create([
'campaign_id' => $this->campaign->id,
'creator_id' => $this->creator->id,
'status' => 'submitted',
]);
$response = $this->actingAs($this->creatorUser)
->postJson("/api/v1/campaigns/{$this->campaign->slug}/apply", [
'cover_message' => 'Second attempt.',
]);
$response->assertStatus(409)
->assertJson([
'success' => false,
'error_code' => 'DUPLICATE_APPLICATION',
]);
}
public function test_apply_returns_422_for_incomplete_profile(): void
{
$this->creator->update(['completion_percentage' => 20]);
$response = $this->actingAs($this->creatorUser)
->postJson("/api/v1/campaigns/{$this->campaign->slug}/apply", [
'cover_message' => 'Test.',
]);
$response->assertStatus(422)
->assertJson([
'success' => false,
'error_code' => 'PROFILE_INCOMPLETE',
]);
}
public function test_apply_returns_422_after_deadline(): void
{
$this->campaign->update(['application_deadline' => now()->subDay()]);
$response = $this->actingAs($this->creatorUser)
->postJson("/api/v1/campaigns/{$this->campaign->slug}/apply", [
'cover_message' => 'Test.',
]);
$response->assertStatus(422)
->assertJson([
'success' => false,
'error_code' => 'DEADLINE_PASSED',
]);
}
public function test_apply_validation_rejects_empty_cover_message(): void
{
$response = $this->actingAs($this->creatorUser)
->postJson("/api/v1/campaigns/{$this->campaign->slug}/apply", [
'cover_message' => '',
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['cover_message']);
}
// --- Show ---
public function test_creator_can_view_own_application(): void
{
$application = Application::factory()->create([
'campaign_id' => $this->campaign->id,
'creator_id' => $this->creator->id,
]);
$response = $this->actingAs($this->creatorUser)
->getJson("/api/v1/creator/applications/{$application->uuid}");
$response->assertStatus(200)
->assertJsonPath('data.id', $application->uuid);
}
public function test_other_creator_cannot_view_application(): void
{
$application = Application::factory()->create([
'campaign_id' => $this->campaign->id,
'creator_id' => $this->creator->id,
]);
$otherUser = User::factory()->creator()->create();
CreatorProfile::factory()->create(['user_id' => $otherUser->id]);
$response = $this->actingAs($otherUser)
->getJson("/api/v1/creator/applications/{$application->uuid}");
$response->assertStatus(403);
}
// --- Withdraw ---
public function test_creator_can_withdraw_own_application(): void
{
$application = Application::factory()->create([
'campaign_id' => $this->campaign->id,
'creator_id' => $this->creator->id,
'status' => 'submitted',
]);
$response = $this->actingAs($this->creatorUser)
->postJson("/api/v1/creator/applications/{$application->uuid}/withdraw");
$response->assertStatus(200);
$this->assertEquals('withdrawn', $application->fresh()->status);
}
public function test_cannot_withdraw_accepted_application(): void
{
$application = Application::factory()->create([
'campaign_id' => $this->campaign->id,
'creator_id' => $this->creator->id,
'status' => 'accepted',
'accepted_at' => now(),
]);
$response = $this->actingAs($this->creatorUser)
->postJson("/api/v1/creator/applications/{$application->uuid}/withdraw");
$response->assertStatus(403);
}
// --- Company Actions ---
public function test_company_can_shortlist_application(): void
{
$application = Application::factory()->create([
'campaign_id' => $this->campaign->id,
'creator_id' => $this->creator->id,
'status' => 'viewed',
'viewed_at' => now(),
]);
$response = $this->actingAs($this->companyUser)
->postJson("/api/v1/company/applications/{$application->uuid}/shortlist");
$response->assertStatus(200);
$this->assertEquals('shortlisted', $application->fresh()->status);
}
public function test_company_can_accept_application(): void
{
$application = Application::factory()->create([
'campaign_id' => $this->campaign->id,
'creator_id' => $this->creator->id,
'status' => 'shortlisted',
'shortlisted_at' => now(),
]);
$response = $this->actingAs($this->companyUser)
->postJson("/api/v1/company/applications/{$application->uuid}/accept");
$response->assertStatus(200);
$this->assertEquals('accepted', $application->fresh()->status);
}
public function test_company_can_reject_application_with_reason(): void
{
$application = Application::factory()->create([
'campaign_id' => $this->campaign->id,
'creator_id' => $this->creator->id,
'status' => 'submitted',
]);
$response = $this->actingAs($this->companyUser)
->postJson("/api/v1/company/applications/{$application->uuid}/reject", [
'reason' => 'Does not meet our requirements.',
]);
$response->assertStatus(200);
$fresh = $application->fresh();
$this->assertEquals('rejected', $fresh->status);
$this->assertEquals('Does not meet our requirements.', $fresh->rejection_reason);
}
public function test_other_company_cannot_manage_application(): void
{
$application = Application::factory()->create([
'campaign_id' => $this->campaign->id,
'creator_id' => $this->creator->id,
'status' => 'submitted',
]);
$otherCompanyUser = User::factory()->company()->create();
CompanyProfile::factory()->create(['user_id' => $otherCompanyUser->id]);
$response = $this->actingAs($otherCompanyUser)
->postJson("/api/v1/company/applications/{$application->uuid}/shortlist");
$response->assertStatus(403);
}
}
<?php
namespace App\Modules\Applications\Tests;
use App\Models\User;
use App\Modules\Applications\Models\Application;
use App\Modules\Applications\Policies\ApplicationPolicy;
use App\Modules\Campaigns\Models\Campaign;
use App\Modules\Companies\Models\CompanyProfile;
use App\Modules\Creators\Models\CreatorProfile;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ApplicationPolicyTest extends TestCase
{
use RefreshDatabase;
private ApplicationPolicy $policy;
protected function setUp(): void
{
parent::setUp();
$this->policy = new ApplicationPolicy();
}
private function createCreatorWithApplication(): array
{
$user = User::factory()->creator()->create();
$creator = CreatorProfile::factory()->create(['user_id' => $user->id]);
$companyUser = User::factory()->company()->create();
$company = CompanyProfile::factory()->create(['user_id' => $companyUser->id]);
$campaign = Campaign::factory()->create(['company_id' => $company->id]);
$application = Application::factory()->create([
'campaign_id' => $campaign->id,
'creator_id' => $creator->id,
'status' => 'submitted',
]);
return compact('user', 'creator', 'companyUser', 'company', 'campaign', 'application');
}
// --- View Tests ---
public function test_creator_can_view_own_application(): void
{
['user' => $user, 'application' => $application] = $this->createCreatorWithApplication();
$this->assertTrue($this->policy->view($user, $application));
}
public function test_company_can_view_application_to_their_campaign(): void
{
['companyUser' => $companyUser, 'application' => $application] = $this->createCreatorWithApplication();
$this->assertTrue($this->policy->view($companyUser, $application));
}
public function test_admin_can_view_any_application(): void
{
['application' => $application] = $this->createCreatorWithApplication();
$admin = User::factory()->admin()->create();
$this->assertTrue($this->policy->view($admin, $application));
}
public function test_other_creator_cannot_view_application(): void
{
['application' => $application] = $this->createCreatorWithApplication();
$otherUser = User::factory()->creator()->create();
CreatorProfile::factory()->create(['user_id' => $otherUser->id]);
$this->assertFalse($this->policy->view($otherUser, $application));
}
public function test_other_company_cannot_view_application(): void
{
['application' => $application] = $this->createCreatorWithApplication();
$otherCompanyUser = User::factory()->company()->create();
CompanyProfile::factory()->create(['user_id' => $otherCompanyUser->id]);
$this->assertFalse($this->policy->view($otherCompanyUser, $application));
}
// --- Create Tests ---
public function test_creator_can_create_application(): void
{
$user = User::factory()->creator()->create();
CreatorProfile::factory()->create(['user_id' => $user->id]);
$campaign = Campaign::factory()->create();
$this->assertTrue($this->policy->create($user, $campaign));
}
public function test_company_cannot_create_application(): void
{
$user = User::factory()->company()->create();
$campaign = Campaign::factory()->create();
$this->assertFalse($this->policy->create($user, $campaign));
}
public function test_creator_without_profile_cannot_create_application(): void
{
$user = User::factory()->creator()->create();
$campaign = Campaign::factory()->create();
$this->assertFalse($this->policy->create($user, $campaign));
}
// --- Update Tests ---
public function test_creator_can_update_own_submitted_application(): void
{
['user' => $user, 'application' => $application] = $this->createCreatorWithApplication();
$this->assertTrue($this->policy->update($user, $application));
}
public function test_creator_cannot_update_viewed_application(): void
{
['user' => $user, 'application' => $application] = $this->createCreatorWithApplication();
$application->update(['status' => 'viewed', 'viewed_at' => now()]);
$this->assertFalse($this->policy->update($user, $application->fresh()));
}
public function test_other_creator_cannot_update_application(): void
{
['application' => $application] = $this->createCreatorWithApplication();
$otherUser = User::factory()->creator()->create();
CreatorProfile::factory()->create(['user_id' => $otherUser->id]);
$this->assertFalse($this->policy->update($otherUser, $application));
}
// --- Withdraw Tests ---
public function test_creator_can_withdraw_submitted_application(): void
{
['user' => $user, 'application' => $application] = $this->createCreatorWithApplication();
$this->assertTrue($this->policy->withdraw($user, $application));
}
public function test_creator_cannot_withdraw_accepted_application(): void
{
['user' => $user, 'application' => $application] = $this->createCreatorWithApplication();
$application->update(['status' => 'accepted', 'accepted_at' => now()]);
$this->assertFalse($this->policy->withdraw($user, $application->fresh()));
}
// --- Manage Tests ---
public function test_company_can_manage_application_to_their_campaign(): void
{
['companyUser' => $companyUser, 'application' => $application] = $this->createCreatorWithApplication();
$this->assertTrue($this->policy->manage($companyUser, $application));
}
public function test_other_company_cannot_manage_application(): void
{
['application' => $application] = $this->createCreatorWithApplication();
$otherUser = User::factory()->company()->create();
CompanyProfile::factory()->create(['user_id' => $otherUser->id]);
$this->assertFalse($this->policy->manage($otherUser, $application));
}
public function test_creator_cannot_manage_application(): void
{
['user' => $user, 'application' => $application] = $this->createCreatorWithApplication();
$this->assertFalse($this->policy->manage($user, $application));
}
// --- Shortlist Tests ---
public function test_company_can_shortlist_viewed_application(): void
{
['companyUser' => $companyUser, 'application' => $application] = $this->createCreatorWithApplication();
$application->update(['status' => 'viewed', 'viewed_at' => now()]);
$this->assertTrue($this->policy->shortlist($companyUser, $application->fresh()));
}
public function test_company_cannot_shortlist_already_rejected_application(): void
{
['companyUser' => $companyUser, 'application' => $application] = $this->createCreatorWithApplication();
$application->update(['status' => 'rejected', 'rejected_at' => now()]);
$this->assertFalse($this->policy->shortlist($companyUser, $application->fresh()));
}
// --- Accept Tests ---
public function test_company_can_accept_shortlisted_application(): void
{
['companyUser' => $companyUser, 'application' => $application] = $this->createCreatorWithApplication();
$application->update(['status' => 'shortlisted', 'shortlisted_at' => now()]);
$this->assertTrue($this->policy->accept($companyUser, $application->fresh()));
}
public function test_company_cannot_accept_submitted_application(): void
{
['companyUser' => $companyUser, 'application' => $application] = $this->createCreatorWithApplication();
$this->assertFalse($this->policy->accept($companyUser, $application));
}
// --- Reject Tests ---
public function test_company_can_reject_submitted_application(): void
{
['companyUser' => $companyUser, 'application' => $application] = $this->createCreatorWithApplication();
$this->assertTrue($this->policy->reject($companyUser, $application));
}
public function test_company_cannot_reject_already_accepted_application(): void
{
['companyUser' => $companyUser, 'application' => $application] = $this->createCreatorWithApplication();
$application->update(['status' => 'accepted', 'accepted_at' => now()]);
$this->assertFalse($this->policy->reject($companyUser, $application->fresh()));
}
}
This diff is collapsed.
<?php
namespace App\Modules\Auth\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Auth\Requests\ForgotPasswordRequest;
use App\Modules\Auth\Services\AuthService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Password;
use Illuminate\View\View;
class ForgotPasswordController extends Controller
{
public function __construct(private AuthService $authService)
{
}
public function showForm(): View
{
return view('auth.forgot-password');
}
public function store(ForgotPasswordRequest $request): JsonResponse
{
$this->authService->sendPasswordResetLink($request->input('email'));
return $this->success(null, __('auth.reset_link_sent'));
}
}
<?php
namespace App\Modules\Auth\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Auth\Requests\LoginRequest;
use App\Modules\Auth\Services\AuthService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
class LoginController extends Controller
{
public function __construct(private AuthService $authService)
{
}
public function showForm(): View
{
return view('auth.login');
}
public function store(LoginRequest $request): JsonResponse|RedirectResponse
{
$request->ensureIsNotRateLimited();
$result = $this->authService->attemptLogin(
$request->only('email', 'password'),
$request->boolean('remember')
);
if (!$result['success']) {
$request->hitRateLimit();
$message = match ($result['reason']) {
'account_suspended' => __('auth.account_suspended'),
'account_banned' => __('auth.account_banned'),
'account_deactivated' => __('auth.account_deactivated'),
default => __('auth.failed'),
};
return $this->error($message, 422, null, [
'email' => [$message],
]);
}
$request->clearRateLimit();
$request->session()->regenerate();
if ($request->expectsJson()) {
return $this->success(
['redirect' => $result['redirect']],
__('auth.login_successful')
);
}
return redirect()->intended($result['redirect']);
}
}
<?php
namespace App\Modules\Auth\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Auth\Services\AuthService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class LogoutController extends Controller
{
public function __construct(private AuthService $authService)
{
}
public function __invoke(Request $request): JsonResponse|RedirectResponse
{
$this->authService->logout();
if ($request->expectsJson()) {
return $this->success(null, __('auth.logged_out'));
}
return redirect('/');
}
}
<?php
namespace App\Modules\Auth\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Auth\Requests\RegisterCompanyRequest;
use App\Modules\Auth\Services\AuthService;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class RegisterCompanyController extends Controller
{
public function __construct(private AuthService $authService)
{
}
public function showForm(): View
{
return view('auth.register-company');
}
public function store(RegisterCompanyRequest $request): JsonResponse
{
$user = $this->authService->registerCompany($request->validated());
event(new Registered($user));
Auth::login($user);
return $this->success(
['redirect' => '/email/verify'],
__('auth.registration_successful_company')
);
}
}
<?php
namespace App\Modules\Auth\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Auth\Requests\RegisterCreatorRequest;
use App\Modules\Auth\Services\AuthService;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class RegisterCreatorController extends Controller
{
public function __construct(private AuthService $authService)
{
}
public function showForm(): View
{
return view('auth.register-creator');
}
public function store(RegisterCreatorRequest $request): JsonResponse
{
$user = $this->authService->registerCreator($request->validated());
event(new Registered($user));
Auth::login($user);
return $this->success(
['redirect' => '/email/verify'],
__('auth.registration_successful')
);
}
public function checkUsername(Request $request): JsonResponse
{
$username = $request->input('username', '');
if (strlen($username) < 3) {
return $this->error(__('auth.username_too_short'), 422);
}
if ($this->authService->isReservedUsername($username)) {
return $this->error(__('auth.username_reserved'), 422, [
'suggestions' => $this->authService->suggestUsernames($username),
]);
}
if ($this->authService->usernameExists($username)) {
return $this->error(__('auth.username_taken'), 422, [
'suggestions' => $this->authService->suggestUsernames($username),
]);
}
return $this->success(['available' => true]);
}
}
<?php
namespace App\Modules\Auth\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Auth\Requests\ResetPasswordRequest;
use App\Modules\Auth\Services\AuthService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Illuminate\View\View;
class ResetPasswordController extends Controller
{
public function __construct(private AuthService $authService)
{
}
public function showForm(Request $request, string $token): View
{
return view('auth.reset-password', [
'token' => $token,
'email' => $request->query('email', ''),
]);
}
public function store(ResetPasswordRequest $request): JsonResponse
{
$status = $this->authService->resetPassword($request->validated());
if ($status === Password::PASSWORD_RESET) {
return $this->success(
['redirect' => '/login'],
__('auth.password_reset_successful')
);
}
return $this->error(__('auth.password_reset_failed'), 422);
}
}
<?php
namespace App\Modules\Auth\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Auth\Services\AuthService;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class VerificationController extends Controller
{
public function __construct(private AuthService $authService)
{
}
public function notice(Request $request): View|RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended('/');
}
return view('auth.verify-email');
}
public function verify(EmailVerificationRequest $request): RedirectResponse
{
$this->authService->verifyEmail($request->user());
return redirect()->intended('/');
}
public function resend(Request $request): JsonResponse|RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
if ($request->expectsJson()) {
return $this->success(null, __('auth.already_verified'));
}
return redirect()->intended('/');
}
$request->user()->sendEmailVerificationNotification();
if ($request->expectsJson()) {
return $this->success(null, __('auth.verification_resent'));
}
return back()->with('status', __('auth.verification_resent'));
}
}
<?php
namespace App\Modules\Auth\Events;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class PasswordResetRequested
{
use Dispatchable, SerializesModels;
public function __construct(public User $user)
{
}
}
<?php
namespace App\Modules\Auth\Events;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class PasswordWasReset
{
use Dispatchable, SerializesModels;
public function __construct(public User $user)
{
}
}
<?php
namespace App\Modules\Auth\Events;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class UserLoggedIn
{
use Dispatchable, SerializesModels;
public function __construct(public User $user)
{
}
}
<?php
namespace App\Modules\Auth\Events;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class UserRegistered
{
use Dispatchable, SerializesModels;
public function __construct(public User $user)
{
}
}
<?php
namespace App\Modules\Auth\Requests;
use Illuminate\Foundation\Http\FormRequest;
class ForgotPasswordRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'email' => ['required', 'email', 'max:255'],
];
}
}
<?php
namespace App\Modules\Auth\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class LoginRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'email' => ['required', 'email', 'max:255'],
'password' => ['required', 'string'],
'remember' => ['nullable', 'boolean'],
];
}
public function ensureIsNotRateLimited(): void
{
if (!RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
return;
}
$seconds = RateLimiter::availableIn($this->throttleKey());
throw ValidationException::withMessages([
'email' => __('auth.throttle', [
'seconds' => $seconds,
'minutes' => ceil($seconds / 60),
]),
]);
}
public function hitRateLimit(): void
{
RateLimiter::hit($this->throttleKey(), 1800); // 30 min lockout
}
public function clearRateLimit(): void
{
RateLimiter::clear($this->throttleKey());
}
public function throttleKey(): string
{
return Str::transliterate(
Str::lower($this->input('email')) . '|' . $this->ip()
);
}
}
<?php
namespace App\Modules\Auth\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Password;
class RegisterCompanyRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'company_name' => ['required', 'string', 'min:2', 'max:100'],
'contact_person_name' => ['required', 'string', 'min:2', 'max:100'],
'email' => ['required', 'email:rfc,dns', 'max:255', 'unique:users,email'],
'password' => [
'required',
'confirmed',
Password::min(8)->mixedCase()->numbers(),
],
'phone' => ['required', 'string', 'regex:/^\+[1-9]\d{6,14}$/'],
'country' => ['required', 'string', 'size:2', 'regex:/^[A-Z]{2}$/'],
'industry' => ['required', 'string', 'in:' . implode(',', self::industries())],
'website' => ['nullable', 'url:http,https', 'max:255'],
'company_size' => ['required', 'string', 'in:solo,2-10,11-50,51-200,201-500,500+'],
'agreed_to_terms' => ['required', 'accepted'],
];
}
public function messages(): array
{
return [
'phone.regex' => __('auth.phone_format'),
'country.regex' => __('auth.country_format'),
'agreed_to_terms.accepted' => __('auth.must_accept_terms'),
'industry.in' => __('auth.invalid_industry'),
'company_size.in' => __('auth.invalid_company_size'),
];
}
public static function industries(): array
{
return [
'technology', 'ecommerce', 'fashion', 'beauty', 'food_beverage',
'health_wellness', 'travel', 'finance', 'education', 'entertainment',
'sports', 'automotive', 'real_estate', 'gaming', 'media',
'retail', 'telecommunications', 'energy', 'agriculture', 'other',
];
}
}
<?php
namespace App\Modules\Auth\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Password;
class RegisterCreatorRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'first_name' => ['required', 'string', 'min:2', 'max:50', 'regex:/^[\p{L}\s]+$/u'],
'last_name' => ['required', 'string', 'min:2', 'max:50', 'regex:/^[\p{L}\s]+$/u'],
'email' => ['required', 'email:rfc,dns', 'max:255', 'unique:users,email'],
'password' => [
'required',
'confirmed',
Password::min(8)->mixedCase()->numbers(),
],
'username' => [
'required',
'string',
'min:3',
'max:30',
'regex:/^[a-zA-Z0-9_]+$/',
'unique:creator_profiles,username',
function ($attribute, $value, $fail) {
$reserved = [
'admin', 'support', 'ugcheaven', 'company', 'creator',
'api', 'www', 'mail', 'help', 'null', 'undefined',
'root', 'system', 'moderator', 'mod', 'staff',
];
if (in_array(strtolower($value), $reserved, true)) {
$fail(__('auth.username_reserved'));
}
},
],
'phone' => ['nullable', 'string', 'regex:/^\+[1-9]\d{6,14}$/'],
'country' => ['required', 'string', 'size:2', 'regex:/^[A-Z]{2}$/'],
'agreed_to_terms' => ['required', 'accepted'],
];
}
public function messages(): array
{
return [
'first_name.regex' => __('auth.name_letters_only'),
'last_name.regex' => __('auth.name_letters_only'),
'username.regex' => __('auth.username_format'),
'phone.regex' => __('auth.phone_format'),
'country.regex' => __('auth.country_format'),
'agreed_to_terms.accepted' => __('auth.must_accept_terms'),
];
}
}
<?php
namespace App\Modules\Auth\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Password;
class ResetPasswordRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'token' => ['required', 'string'],
'email' => ['required', 'email', 'max:255'],
'password' => [
'required',
'confirmed',
Password::min(8)->mixedCase()->numbers(),
],
];
}
}
<?php
use App\Modules\Auth\Controllers\LoginController;
use App\Modules\Auth\Controllers\LogoutController;
use App\Modules\Auth\Controllers\RegisterCompanyController;
use App\Modules\Auth\Controllers\RegisterCreatorController;
use App\Modules\Auth\Controllers\ForgotPasswordController;
use App\Modules\Auth\Controllers\ResetPasswordController;
use App\Modules\Auth\Controllers\VerificationController;
use Illuminate\Support\Facades\Route;
Route::prefix('auth')->group(function () {
Route::middleware('throttle:auth')->group(function () {
Route::post('/register/creator', [RegisterCreatorController::class, 'store']);
Route::post('/register/company', [RegisterCompanyController::class, 'store']);
Route::post('/login', [LoginController::class, 'store']);
Route::post('/forgot-password', [ForgotPasswordController::class, 'store']);
Route::post('/reset-password', [ResetPasswordController::class, 'store']);
});
Route::post('/check-username', [RegisterCreatorController::class, 'checkUsername']);
Route::middleware('auth:sanctum')->group(function () {
Route::post('/logout', LogoutController::class);
Route::post('/email/verification-notification', [VerificationController::class, 'resend'])
->middleware('throttle:6,1');
});
});
<?php
use App\Modules\Auth\Controllers\ForgotPasswordController;
use App\Modules\Auth\Controllers\LoginController;
use App\Modules\Auth\Controllers\LogoutController;
use App\Modules\Auth\Controllers\RegisterCompanyController;
use App\Modules\Auth\Controllers\RegisterCreatorController;
use App\Modules\Auth\Controllers\ResetPasswordController;
use App\Modules\Auth\Controllers\VerificationController;
use Illuminate\Support\Facades\Route;
Route::middleware('guest')->group(function () {
Route::get('/register', fn () => view('auth.register'))
->name('register');
Route::get('/register/creator', [RegisterCreatorController::class, 'showForm'])
->name('register.creator');
Route::post('/register/creator', [RegisterCreatorController::class, 'store']);
Route::get('/register/company', [RegisterCompanyController::class, 'showForm'])
->name('register.company');
Route::post('/register/company', [RegisterCompanyController::class, 'store']);
Route::get('/login', [LoginController::class, 'showForm'])
->name('login');
Route::post('/login', [LoginController::class, 'store']);
Route::get('/forgot-password', [ForgotPasswordController::class, 'showForm'])
->name('password.request');
Route::post('/forgot-password', [ForgotPasswordController::class, 'store'])
->name('password.email')
->middleware('throttle:auth');
Route::get('/reset-password/{token}', [ResetPasswordController::class, 'showForm'])
->name('password.reset');
Route::post('/reset-password', [ResetPasswordController::class, 'store'])
->name('password.update');
});
Route::middleware('auth')->group(function () {
Route::post('/logout', LogoutController::class)->name('logout');
Route::get('/email/verify', [VerificationController::class, 'notice'])
->name('verification.notice');
Route::get('/email/verify/{id}/{hash}', [VerificationController::class, 'verify'])
->middleware('signed')
->name('verification.verify');
Route::post('/email/verification-notification', [VerificationController::class, 'resend'])
->middleware('throttle:6,1')
->name('verification.send');
});
<?php
namespace App\Modules\Auth\Services;
use App\Models\User;
use App\Modules\Auth\Events\UserLoggedIn;
use App\Modules\Auth\Events\UserRegistered;
use App\Modules\Auth\Events\PasswordResetRequested;
use App\Modules\Auth\Events\PasswordWasReset;
use App\Shared\Enums\UserRole;
use App\Shared\Enums\UserStatus;
use Illuminate\Auth\Events\Verified;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
class AuthService
{
private array $reservedUsernames = [
'admin', 'support', 'ugcheaven', 'company', 'creator',
'api', 'www', 'mail', 'help', 'null', 'undefined',
'root', 'system', 'moderator', 'mod', 'staff',
];
public function registerCreator(array $data): User
{
return DB::transaction(function () use ($data) {
$user = User::create([
'name' => $data['first_name'] . ' ' . $data['last_name'],
'email' => $data['email'],
'password' => Hash::make($data['password']),
'role' => UserRole::Creator->value,
'status' => UserStatus::Unverified->value,
'phone' => $data['phone'] ?? null,
]);
$user->creatorProfile()->create([
'first_name' => $data['first_name'],
'last_name' => $data['last_name'],
'username' => $data['username'],
'country' => $data['country'],
'completion_percentage' => 10,
]);
event(new UserRegistered($user));
return $user;
});
}
public function registerCompany(array $data): User
{
return DB::transaction(function () use ($data) {
$user = User::create([
'name' => $data['contact_person_name'],
'email' => $data['email'],
'password' => Hash::make($data['password']),
'role' => UserRole::Company->value,
'status' => UserStatus::PendingReview->value,
'phone' => $data['phone'],
]);
$user->companyProfile()->create([
'company_name' => $data['company_name'],
'contact_person_name' => $data['contact_person_name'],
'country' => $data['country'],
'industry' => $data['industry'],
'website' => $data['website'] ?? null,
'company_size' => $data['company_size'],
'status' => 'pending_review',
'completion_percentage' => 15,
]);
event(new UserRegistered($user));
return $user;
});
}
public function attemptLogin(array $credentials, bool $remember = false): array
{
$user = User::where('email', $credentials['email'])->first();
if (!$user || !Hash::check($credentials['password'], $user->password)) {
return ['success' => false, 'reason' => 'invalid_credentials'];
}
$status = UserStatus::from($user->status);
if (!$status->canLogin()) {
return [
'success' => false,
'reason' => $this->getLoginBlockReason($status),
'user' => $user,
];
}
Auth::login($user, $remember);
$user->update([
'last_login_at' => now(),
'last_login_ip' => request()->ip(),
]);
event(new UserLoggedIn($user));
return [
'success' => true,
'user' => $user,
'redirect' => $this->getRedirectForRole($user),
];
}
public function logout(): void
{
Auth::logout();
request()->session()->invalidate();
request()->session()->regenerateToken();
}
public function sendPasswordResetLink(string $email): string
{
$user = User::where('email', $email)->first();
if (!$user) {
return Password::RESET_LINK_SENT;
}
if ($user->status === UserStatus::Banned->value) {
return Password::RESET_LINK_SENT;
}
$status = Password::sendResetLink(['email' => $email]);
if ($status === Password::RESET_LINK_SENT) {
event(new PasswordResetRequested($user));
}
return $status;
}
public function resetPassword(array $data): string
{
$status = Password::reset(
$data,
function (User $user, string $password) {
$user->update([
'password' => Hash::make($password),
'remember_token' => Str::random(60),
]);
Auth::logout();
event(new PasswordWasReset($user));
}
);
return $status;
}
public function verifyEmail(User $user): bool
{
if ($user->hasVerifiedEmail()) {
return true;
}
$user->markEmailAsVerified();
if ($user->status === UserStatus::Unverified->value && $user->role !== UserRole::Company->value) {
$user->update(['status' => UserStatus::Active->value]);
}
event(new Verified($user));
return true;
}
public function isReservedUsername(string $username): bool
{
return in_array(strtolower($username), $this->reservedUsernames, true);
}
public function suggestUsernames(string $base): array
{
$suggestions = [];
$base = preg_replace('/[^a-zA-Z0-9_]/', '', $base);
for ($i = 0; $i < 3; $i++) {
$suggestion = $base . '_' . rand(10, 999);
if (!$this->usernameExists($suggestion)) {
$suggestions[] = $suggestion;
}
}
return $suggestions;
}
public function usernameExists(string $username): bool
{
return DB::table('creator_profiles')
->where('username', strtolower($username))
->exists();
}
private function getLoginBlockReason(UserStatus $status): string
{
return match ($status) {
UserStatus::Suspended => 'account_suspended',
UserStatus::Banned => 'account_banned',
UserStatus::Deactivated => 'account_deactivated',
default => 'account_inactive',
};
}
private function getRedirectForRole(User $user): string
{
return match ($user->role) {
UserRole::Admin->value => '/admin/dashboard',
UserRole::Company->value => '/company/dashboard',
UserRole::Creator->value => '/creator/dashboard',
default => '/',
};
}
}
<?php
namespace App\Modules\Campaigns\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Campaigns\Models\Campaign;
use App\Modules\Campaigns\Requests\CreateCampaignRequest;
use App\Modules\Campaigns\Requests\UpdateCampaignRequest;
use App\Modules\Campaigns\Resources\CampaignResource;
use App\Modules\Campaigns\Services\CampaignService;
use Illuminate\Http\Request;
class CampaignController extends Controller
{
public function __construct(
private CampaignService $service,
) {}
public function index(Request $request)
{
$this->authorize('create', Campaign::class);
$company = $request->user()->companyProfile;
$query = Campaign::byCompany($company->id)
->withCount('attachments');
if ($request->filled('status')) {
$query->where('status', $request->input('status'));
}
$campaigns = $query->orderByDesc('updated_at')
->paginate(25);
if ($request->expectsJson()) {
return CampaignResource::collection($campaigns);
}
return view('campaigns.index', compact('campaigns'));
}
public function create(Request $request)
{
$this->authorize('create', Campaign::class);
return view('campaigns.create');
}
public function store(CreateCampaignRequest $request)
{
$company = $request->user()->companyProfile;
$campaign = $this->service->create($company, $request->validated());
if ($request->expectsJson()) {
return (new CampaignResource($campaign))
->response()
->setStatusCode(201);
}
return redirect()
->route('company.campaigns.edit', $campaign->uuid)
->with('toast', [
'type' => 'success',
'message' => __('campaigns.created'),
]);
}
public function show(Request $request, string $slug)
{
$campaign = Campaign::where('slug', $slug)
->with(['company', 'attachments'])
->firstOrFail();
$this->authorize('view', $campaign);
if ($request->expectsJson()) {
return new CampaignResource($campaign);
}
$this->service->incrementViews($campaign);
return view('campaigns.show', compact('campaign'));
}
public function edit(Request $request, string $uuid)
{
$campaign = Campaign::where('uuid', $uuid)
->with('attachments')
->firstOrFail();
$this->authorize('update', $campaign);
return view('campaigns.edit', compact('campaign'));
}
public function update(UpdateCampaignRequest $request, string $uuid)
{
$campaign = Campaign::where('uuid', $uuid)->firstOrFail();
$this->authorize('update', $campaign);
$campaign = $this->service->update($campaign, $request->validated());
if ($request->expectsJson()) {
return new CampaignResource($campaign);
}
return back()->with('toast', [
'type' => 'success',
'message' => __('campaigns.updated'),
]);
}
public function publish(Request $request, string $uuid)
{
$campaign = Campaign::where('uuid', $uuid)->firstOrFail();
$this->authorize('publish', $campaign);
$this->service->publish($campaign);
if ($request->expectsJson()) {
return $this->success(null, __('campaigns.published'));
}
return back()->with('toast', [
'type' => 'success',
'message' => __('campaigns.published'),
]);
}
public function pause(Request $request, string $uuid)
{
$campaign = Campaign::where('uuid', $uuid)->firstOrFail();
$this->authorize('pause', $campaign);
$this->service->pause($campaign);
if ($request->expectsJson()) {
return $this->success(null, __('campaigns.paused'));
}
return back()->with('toast', [
'type' => 'success',
'message' => __('campaigns.paused'),
]);
}
public function resume(Request $request, string $uuid)
{
$campaign = Campaign::where('uuid', $uuid)->firstOrFail();
$this->authorize('resume', $campaign);
$this->service->resume($campaign);
if ($request->expectsJson()) {
return $this->success(null, __('campaigns.resumed'));
}
return back()->with('toast', [
'type' => 'success',
'message' => __('campaigns.resumed'),
]);
}
public function close(Request $request, string $uuid)
{
$campaign = Campaign::where('uuid', $uuid)->firstOrFail();
$this->authorize('close', $campaign);
$this->service->close($campaign);
if ($request->expectsJson()) {
return $this->success(null, __('campaigns.closed'));
}
return back()->with('toast', [
'type' => 'success',
'message' => __('campaigns.closed'),
]);
}
public function cancel(\App\Modules\Campaigns\Requests\CancelCampaignRequest $request, string $uuid)
{
$campaign = Campaign::where('uuid', $uuid)->firstOrFail();
$this->authorize('cancel', $campaign);
$this->service->cancel($campaign, $request->validated('reason'));
if ($request->expectsJson()) {
return $this->success(null, __('campaigns.cancelled'));
}
return redirect()
->route('company.campaigns.index')
->with('toast', [
'type' => 'success',
'message' => __('campaigns.cancelled'),
]);
}
public function destroy(Request $request, string $uuid)
{
$campaign = Campaign::where('uuid', $uuid)->firstOrFail();
$this->authorize('delete', $campaign);
$campaign->delete();
if ($request->expectsJson()) {
return $this->success(null, __('campaigns.deleted'));
}
return redirect()
->route('company.campaigns.index')
->with('toast', [
'type' => 'success',
'message' => __('campaigns.deleted'),
]);
}
}
<?php
namespace App\Modules\Campaigns\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Campaigns\Models\Campaign;
use App\Modules\Campaigns\Resources\CampaignResource;
use App\Modules\Campaigns\Services\CampaignDiscoveryService;
use Illuminate\Http\Request;
class CampaignDiscoveryController extends Controller
{
public function __construct(
private CampaignDiscoveryService $service,
) {}
public function index(Request $request)
{
$creator = null;
if ($request->user() && $request->user()->isCreator()) {
$creator = $request->user()->creatorProfile;
}
$campaigns = $this->service->search(
$request->only([
'q', 'niches', 'countries', 'languages', 'budget_type',
'budget_min', 'budget_max', 'deliverable_type', 'experience_level',
'deadline', 'posted', 'sort', 'per_page',
]),
$creator,
);
if ($request->expectsJson()) {
return CampaignResource::collection($campaigns);
}
return view('campaigns.discover', compact('campaigns', 'creator'));
}
public function show(Request $request, string $slug)
{
$campaign = Campaign::where('slug', $slug)
->with(['company', 'attachments'])
->firstOrFail();
$this->authorize('view', $campaign);
$creator = null;
$matchScore = null;
if ($request->user() && $request->user()->isCreator()) {
$creator = $request->user()->creatorProfile;
$matchScore = $this->service->calculateMatchScore($campaign, $creator);
}
$this->service->recordView($campaign, $request->user()?->id);
if ($request->expectsJson()) {
$resource = new CampaignResource($campaign);
return $resource->additional(['meta' => ['match_score' => $matchScore]]);
}
return view('campaigns.show', compact('campaign', 'creator', 'matchScore'));
}
}
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('campaigns', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
$table->foreignId('company_id')->constrained('company_profiles')->cascadeOnDelete();
$table->string('title', 100);
$table->string('slug', 120)->unique();
$table->text('description');
$table->string('objective', 50);
$table->string('product_service_name', 100)->nullable();
$table->string('product_url', 500)->nullable();
$table->string('status', 20)->default('draft');
$table->string('visibility', 20)->default('public');
// Requirements
$table->integer('creator_count')->default(1);
$table->string('gender_preference', 20)->nullable();
$table->integer('age_range_min')->nullable();
$table->integer('age_range_max')->nullable();
$table->jsonb('countries')->default('[]');
$table->jsonb('languages')->default('[]');
$table->jsonb('niches')->default('[]');
$table->string('experience_level_min', 20)->nullable();
$table->jsonb('specific_skills')->default('[]');
$table->text('equipment_requirements')->nullable();
// Deliverables
$table->string('deliverable_type', 20);
$table->integer('video_count')->nullable();
$table->integer('video_duration_min')->nullable();
$table->integer('video_duration_max')->nullable();
$table->string('aspect_ratio', 10)->nullable();
$table->text('format_notes')->nullable();
$table->boolean('includes_raw_footage')->default(false);
$table->boolean('includes_script_approval')->default(false);
$table->integer('max_revisions')->default(2);
// Budget
$table->string('budget_type', 20);
$table->decimal('budget_amount', 12, 2)->nullable();
$table->string('budget_currency', 3)->nullable();
$table->boolean('product_provided')->default(false);
$table->decimal('product_value', 10, 2)->nullable();
$table->text('payment_terms')->nullable();
// Timeline
$table->timestamp('application_deadline');
$table->date('project_start_date')->nullable();
$table->date('project_deadline');
$table->string('estimated_turnaround', 20)->nullable();
// Settings
$table->boolean('allow_applications')->default(true);
$table->boolean('auto_close_on_count')->default(false);
$table->boolean('is_featured')->default(false);
// Counters
$table->integer('views_count')->default(0);
$table->integer('applications_count')->default(0);
// Timestamps
$table->timestamp('published_at')->nullable();
$table->timestamp('closed_at')->nullable();
$table->timestamp('completed_at')->nullable();
$table->timestamp('cancelled_at')->nullable();
$table->timestamps();
$table->softDeletes();
// Indexes
$table->index('company_id');
$table->index('status');
$table->index('visibility');
$table->index('application_deadline');
});
// CHECK constraints
DB::statement("ALTER TABLE campaigns ADD CONSTRAINT chk_campaign_status CHECK (status IN ('draft', 'published', 'paused', 'closed', 'in_progress', 'completed', 'cancelled'))");
DB::statement("ALTER TABLE campaigns ADD CONSTRAINT chk_campaign_visibility CHECK (visibility IN ('public', 'unlisted', 'invite_only'))");
// GIN indexes for JSONB columns
DB::statement('CREATE INDEX idx_campaign_niches ON campaigns USING gin(niches)');
DB::statement('CREATE INDEX idx_campaign_languages ON campaigns USING gin(languages)');
DB::statement('CREATE INDEX idx_campaign_countries ON campaigns USING gin(countries)');
// Full-text search index
DB::statement("CREATE INDEX idx_campaign_search ON campaigns USING gin(to_tsvector('english', title || ' ' || description))");
}
public function down(): void
{
Schema::dropIfExists('campaigns');
}
};
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('campaign_attachments', function (Blueprint $table) {
$table->id();
$table->foreignId('campaign_id')->constrained('campaigns')->cascadeOnDelete();
$table->string('type', 30);
$table->string('file_name', 255);
$table->string('file_path', 500);
$table->integer('file_size');
$table->string('mime_type', 100);
$table->string('external_url', 500)->nullable();
$table->integer('sort_order')->default(0);
$table->timestamp('created_at')->useCurrent();
$table->index('campaign_id');
});
DB::statement("ALTER TABLE campaign_attachments ADD CONSTRAINT chk_attachment_type CHECK (type IN ('brand_guidelines', 'reference_video', 'product_image', 'script', 'mood_board', 'document'))");
}
public function down(): void
{
Schema::dropIfExists('campaign_attachments');
}
};
<?php
namespace App\Modules\Campaigns\Enums;
enum CampaignStatus: string
{
case Draft = 'draft';
case Published = 'published';
case Paused = 'paused';
case Closed = 'closed';
case InProgress = 'in_progress';
case Completed = 'completed';
case Cancelled = 'cancelled';
public function label(): string
{
return match ($this) {
self::Draft => __('campaigns.status_draft'),
self::Published => __('campaigns.status_published'),
self::Paused => __('campaigns.status_paused'),
self::Closed => __('campaigns.status_closed'),
self::InProgress => __('campaigns.status_in_progress'),
self::Completed => __('campaigns.status_completed'),
self::Cancelled => __('campaigns.status_cancelled'),
};
}
public function isPubliclyVisible(): bool
{
return $this === self::Published;
}
public function isActive(): bool
{
return in_array($this, [self::Published, self::InProgress]);
}
public function isTerminal(): bool
{
return in_array($this, [self::Completed, self::Cancelled]);
}
public function acceptsApplications(): bool
{
return $this === self::Published;
}
public static function allowedTransitions(): array
{
return [
self::Draft->value => [self::Published->value, self::Cancelled->value],
self::Published->value => [self::Paused->value, self::Closed->value, self::Cancelled->value],
self::Paused->value => [self::Published->value, self::Closed->value, self::Cancelled->value],
self::Closed->value => [self::InProgress->value, self::Cancelled->value],
self::InProgress->value => [self::Completed->value, self::Cancelled->value],
];
}
public static function canTransition(string $from, string $to): bool
{
$transitions = self::allowedTransitions();
return isset($transitions[$from]) && in_array($to, $transitions[$from], true);
}
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}
<?php
namespace App\Modules\Campaigns\Events;
use App\Modules\Campaigns\Models\Campaign;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class CampaignCancelled
{
use Dispatchable, SerializesModels;
public function __construct(
public Campaign $campaign,
public string $reason,
) {}
}
<?php
namespace App\Modules\Campaigns\Events;
use App\Modules\Campaigns\Models\Campaign;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class CampaignClosed
{
use Dispatchable, SerializesModels;
public function __construct(
public Campaign $campaign,
) {}
}
<?php
namespace App\Modules\Campaigns\Events;
use App\Modules\Campaigns\Models\Campaign;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class CampaignPublished
{
use Dispatchable, SerializesModels;
public function __construct(
public Campaign $campaign,
) {}
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment