Dashboard
Marketplace overview and moderation queue.
Operations
Revenue charts, recent bookings, verification requests, support tickets and review moderation plug into this dashboard.
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..e6e2c8e
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,12 @@
+# Security baseline
+- Enforce HTTPS/HSTS in production.
+- Keep private/verification uploads outside public storage; authorize every download.
+- Validate MIME and image decoding; strip metadata where appropriate.
+- Use policies for every tenant-owned resource.
+- Enable 2FA for admin/DJ accounts.
+- Verify Stripe webhook signatures and make handlers idempotent.
+- Rate-limit auth, enquiry, review and messaging endpoints.
+- Audit privileged actions; hash IP addresses if retained.
+- Never store card details.
+- Run queues under Supervisor/systemd and keep dependencies patched.
+- Replace placeholder legal policies before launch.
diff --git a/app/Http/Controllers/Admin/SectionController.php b/app/Http/Controllers/Admin/SectionController.php
new file mode 100644
index 0000000..667e7f0
--- /dev/null
+++ b/app/Http/Controllers/Admin/SectionController.php
@@ -0,0 +1,37 @@
+ User::count(),
+ 'djs' => DjProfile::count(),
+ 'businesses' => Business::count(),
+ 'clients' => User::role('client')->count(),
+ 'bookings' => Booking::count(),
+ 'enquiries' => Enquiry::count(),
+ 'reviews' => Review::count(),
+ ];
+
+ return view('admin.section', compact('section', 'counts'));
+ }
+}
diff --git a/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/app/Http/Controllers/Auth/AuthenticatedSessionController.php
new file mode 100644
index 0000000..4f4923e
--- /dev/null
+++ b/app/Http/Controllers/Auth/AuthenticatedSessionController.php
@@ -0,0 +1,42 @@
+validate([
+ 'email' => ['required', 'email'],
+ 'password' => ['required', 'string'],
+ ]);
+
+ if (! Auth::attempt($credentials, $request->boolean('remember'))) {
+ return back()->withErrors(['email' => 'The provided credentials do not match our records.'])->onlyInput('email');
+ }
+
+ $request->session()->regenerate();
+
+ return redirect()->intended(route('dashboard'));
+ }
+
+ public function destroy(Request $request): RedirectResponse
+ {
+ Auth::guard('web')->logout();
+ $request->session()->invalidate();
+ $request->session()->regenerateToken();
+
+ return redirect()->route('home');
+ }
+}
diff --git a/app/Http/Controllers/Auth/NewPasswordController.php b/app/Http/Controllers/Auth/NewPasswordController.php
new file mode 100644
index 0000000..b553d4a
--- /dev/null
+++ b/app/Http/Controllers/Auth/NewPasswordController.php
@@ -0,0 +1,47 @@
+ $request]);
+ }
+
+ public function store(Request $request): RedirectResponse
+ {
+ $request->validate([
+ 'token' => ['required'],
+ 'email' => ['required', 'email'],
+ 'password' => ['required', 'confirmed', PasswordRule::defaults()],
+ ]);
+
+ $status = Password::reset(
+ $request->only('email', 'password', 'password_confirmation', 'token'),
+ function (User $user, string $password): void {
+ $user->forceFill([
+ 'password' => Hash::make($password),
+ 'remember_token' => Str::random(60),
+ ])->save();
+
+ event(new PasswordReset($user));
+ }
+ );
+
+ return $status === Password::PASSWORD_RESET
+ ? redirect()->route('login')->with('status', __($status))
+ : back()->withInput($request->only('email'))->withErrors(['email' => __($status)]);
+ }
+}
diff --git a/app/Http/Controllers/Auth/PasswordResetLinkController.php b/app/Http/Controllers/Auth/PasswordResetLinkController.php
new file mode 100644
index 0000000..d5ebfef
--- /dev/null
+++ b/app/Http/Controllers/Auth/PasswordResetLinkController.php
@@ -0,0 +1,28 @@
+validate(['email' => ['required', 'email']]);
+
+ $status = Password::sendResetLink($request->only('email'));
+
+ return $status === Password::RESET_LINK_SENT
+ ? back()->with('status', __($status))
+ : back()->withInput($request->only('email'))->withErrors(['email' => __($status)]);
+ }
+}
diff --git a/app/Http/Controllers/Auth/RegisteredUserController.php b/app/Http/Controllers/Auth/RegisteredUserController.php
new file mode 100644
index 0000000..c54dbfe
--- /dev/null
+++ b/app/Http/Controllers/Auth/RegisteredUserController.php
@@ -0,0 +1,37 @@
+validate([
+ 'name' => ['required', 'string', 'max:255'],
+ 'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:users,email'],
+ 'password' => ['required', 'confirmed', Password::defaults()],
+ ]);
+
+ $user = User::create($data);
+ $user->assignRole('client');
+
+ event(new Registered($user));
+ Auth::login($user);
+
+ return redirect()->route('dashboard');
+ }
+}
diff --git a/app/Models/Booking.php b/app/Models/Booking.php
index e4d6721..3e748b0 100644
--- a/app/Models/Booking.php
+++ b/app/Models/Booking.php
@@ -4,6 +4,8 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\HasOne;
class Booking extends Model
{
@@ -15,4 +17,24 @@ class Booking extends Model
{
return ['event_date' => 'date', 'total' => 'decimal:2', 'deposit' => 'decimal:2', 'paid_at' => 'datetime'];
}
+
+ public function enquiry(): BelongsTo
+ {
+ return $this->belongsTo(Enquiry::class);
+ }
+
+ public function client(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'client_id');
+ }
+
+ public function djProfile(): BelongsTo
+ {
+ return $this->belongsTo(DjProfile::class);
+ }
+
+ public function review(): HasOne
+ {
+ return $this->hasOne(Review::class);
+ }
}
diff --git a/app/Models/Enquiry.php b/app/Models/Enquiry.php
index 9fba6b3..6a51f2c 100644
--- a/app/Models/Enquiry.php
+++ b/app/Models/Enquiry.php
@@ -4,6 +4,8 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\HasOne;
class Enquiry extends Model
{
@@ -15,4 +17,19 @@ class Enquiry extends Model
{
return ['event_date' => 'date', 'budget' => 'decimal:2'];
}
+
+ public function user(): BelongsTo
+ {
+ return $this->belongsTo(User::class);
+ }
+
+ public function djProfile(): BelongsTo
+ {
+ return $this->belongsTo(DjProfile::class);
+ }
+
+ public function booking(): HasOne
+ {
+ return $this->hasOne(Booking::class);
+ }
}
diff --git a/app/Models/Review.php b/app/Models/Review.php
index a77ecd2..b939714 100644
--- a/app/Models/Review.php
+++ b/app/Models/Review.php
@@ -4,10 +4,26 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Review extends Model
{
use HasUlids;
protected $fillable = ['booking_id', 'user_id', 'dj_profile_id', 'rating', 'title', 'body', 'status', 'dj_reply'];
+
+ public function booking(): BelongsTo
+ {
+ return $this->belongsTo(Booking::class);
+ }
+
+ public function user(): BelongsTo
+ {
+ return $this->belongsTo(User::class);
+ }
+
+ public function djProfile(): BelongsTo
+ {
+ return $this->belongsTo(DjProfile::class);
+ }
}
diff --git a/app/Models/User.php b/app/Models/User.php
index 33d9e28..533c12b 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -2,14 +2,19 @@
namespace App\Models;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Relations\BelongsToMany;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
+use Laravel\Cashier\Billable;
use Laravel\Sanctum\HasApiTokens;
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable
{
- use HasApiTokens,HasRoles,Notifiable;
+ use Billable, HasApiTokens, HasFactory, HasRoles, Notifiable;
protected $fillable = ['name', 'email', 'password', 'phone', 'avatar_path', 'marketing_opt_in'];
@@ -17,16 +22,31 @@ class User extends Authenticatable
protected function casts(): array
{
- return ['email_verified_at' => 'datetime', 'password' => 'hashed', 'marketing_opt_in' => 'boolean'];
+ return [
+ 'email_verified_at' => 'datetime',
+ 'password' => 'hashed',
+ 'marketing_opt_in' => 'boolean',
+ 'trial_ends_at' => 'datetime',
+ ];
}
- public function businesses()
+ public function businesses(): BelongsToMany
{
return $this->belongsToMany(Business::class)->withPivot(['role'])->withTimestamps();
}
- public function djProfile()
+ public function djProfile(): HasOne
{
return $this->hasOne(DjProfile::class);
}
+
+ public function enquiries(): HasMany
+ {
+ return $this->hasMany(Enquiry::class);
+ }
+
+ public function bookings(): HasMany
+ {
+ return $this->hasMany(Booking::class, 'client_id');
+ }
}
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
new file mode 100644
index 0000000..70a18d2
--- /dev/null
+++ b/app/Providers/AppServiceProvider.php
@@ -0,0 +1,18 @@
+withRouting(
@@ -13,7 +16,13 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
- //
+ $middleware->validateCsrfTokens(except: ['stripe/*']);
+
+ $middleware->alias([
+ 'role' => RoleMiddleware::class,
+ 'permission' => PermissionMiddleware::class,
+ 'role_or_permission' => RoleOrPermissionMiddleware::class,
+ ]);
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
diff --git a/bootstrap/providers.php b/bootstrap/providers.php
index a6f5126..fc94ae6 100644
--- a/bootstrap/providers.php
+++ b/bootstrap/providers.php
@@ -1,7 +1,7 @@
env('APP_NAME', 'BeatBook'),
+ 'env' => env('APP_ENV', 'production'),
+ 'debug' => (bool) env('APP_DEBUG', false),
+ 'url' => env('APP_URL', 'http://localhost'),
+ 'timezone' => env('APP_TIMEZONE', 'UTC'),
+ 'locale' => env('APP_LOCALE', 'en'),
+ 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
+ 'faker_locale' => env('APP_FAKER_LOCALE', 'en_GB'),
+ 'cipher' => 'AES-256-CBC',
+ 'key' => env('APP_KEY'),
+ 'previous_keys' => array_filter(explode(',', (string) env('APP_PREVIOUS_KEYS', ''))),
+ 'maintenance' => [
+ 'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
+ 'store' => env('APP_MAINTENANCE_STORE', 'database'),
+ ],
+];
diff --git a/config/auth.php b/config/auth.php
new file mode 100644
index 0000000..d0b631b
--- /dev/null
+++ b/config/auth.php
@@ -0,0 +1,35 @@
+ [
+ 'guard' => env('AUTH_GUARD', 'web'),
+ 'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
+ ],
+
+ 'guards' => [
+ 'web' => [
+ 'driver' => 'session',
+ 'provider' => 'users',
+ ],
+ ],
+
+ 'providers' => [
+ 'users' => [
+ 'driver' => 'eloquent',
+ 'model' => env('AUTH_MODEL', User::class),
+ ],
+ ],
+
+ 'passwords' => [
+ 'users' => [
+ 'provider' => 'users',
+ 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
+ 'expire' => 60,
+ 'throttle' => 60,
+ ],
+ ],
+
+ 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
+];
diff --git a/config/cache.php b/config/cache.php
new file mode 100644
index 0000000..5a69bfc
--- /dev/null
+++ b/config/cache.php
@@ -0,0 +1,14 @@
+ env('CACHE_STORE', env('CACHE_DRIVER', 'database')),
+ 'stores' => [
+ 'array' => ['driver' => 'array', 'serialize' => false],
+ 'database' => ['driver' => 'database', 'connection' => env('DB_CACHE_CONNECTION'), 'table' => env('DB_CACHE_TABLE', 'cache'), 'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'), 'lock_table' => env('DB_CACHE_LOCK_TABLE')],
+ 'file' => ['driver' => 'file', 'path' => storage_path('framework/cache/data'), 'lock_path' => storage_path('framework/cache/data')],
+ 'redis' => ['driver' => 'redis', 'connection' => env('REDIS_CACHE_CONNECTION', 'cache'), 'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default')],
+ ],
+ 'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
+];
diff --git a/config/database.php b/config/database.php
new file mode 100644
index 0000000..f30042d
--- /dev/null
+++ b/config/database.php
@@ -0,0 +1,78 @@
+ env('DB_CONNECTION', 'sqlite'),
+ 'connections' => [
+ 'sqlite' => [
+ 'driver' => 'sqlite',
+ 'url' => env('DB_URL'),
+ 'database' => env('DB_DATABASE', database_path('database.sqlite')),
+ 'prefix' => '',
+ 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
+ 'busy_timeout' => null,
+ 'journal_mode' => null,
+ 'synchronous' => null,
+ 'transaction_mode' => 'DEFERRED',
+ ],
+ 'mysql' => [
+ 'driver' => 'mysql',
+ 'url' => env('DB_URL'),
+ 'host' => env('DB_HOST', '127.0.0.1'),
+ 'port' => env('DB_PORT', '3306'),
+ 'database' => env('DB_DATABASE', 'laravel'),
+ 'username' => env('DB_USERNAME', 'root'),
+ 'password' => env('DB_PASSWORD', ''),
+ 'unix_socket' => env('DB_SOCKET', ''),
+ 'charset' => env('DB_CHARSET', 'utf8mb4'),
+ 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
+ 'prefix' => '',
+ 'prefix_indexes' => true,
+ 'strict' => true,
+ 'engine' => null,
+ 'options' => extension_loaded('pdo_mysql') ? array_filter([
+ PDO::ATTR_TIMEOUT => env('DB_TIMEOUT') ? (int) env('DB_TIMEOUT') : null,
+ ]) : [],
+ ],
+ 'pgsql' => [
+ 'driver' => 'pgsql',
+ 'url' => env('DB_URL'),
+ 'host' => env('DB_HOST', '127.0.0.1'),
+ 'port' => env('DB_PORT', '5432'),
+ 'database' => env('DB_DATABASE', 'laravel'),
+ 'username' => env('DB_USERNAME', 'root'),
+ 'password' => env('DB_PASSWORD', ''),
+ 'charset' => env('DB_CHARSET', 'utf8'),
+ 'prefix' => '',
+ 'prefix_indexes' => true,
+ 'search_path' => 'public',
+ 'sslmode' => 'prefer',
+ ],
+ ],
+ 'migrations' => ['table' => 'migrations', 'update_date_on_publish' => true],
+ 'redis' => [
+ 'client' => env('REDIS_CLIENT', 'phpredis'),
+ 'options' => [
+ 'cluster' => env('REDIS_CLUSTER', 'redis'),
+ 'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
+ 'persistent' => env('REDIS_PERSISTENT', false),
+ ],
+ 'default' => [
+ 'url' => env('REDIS_URL'),
+ 'host' => env('REDIS_HOST', '127.0.0.1'),
+ 'username' => env('REDIS_USERNAME'),
+ 'password' => env('REDIS_PASSWORD'),
+ 'port' => env('REDIS_PORT', '6379'),
+ 'database' => env('REDIS_DB', '0'),
+ ],
+ 'cache' => [
+ 'url' => env('REDIS_URL'),
+ 'host' => env('REDIS_HOST', '127.0.0.1'),
+ 'username' => env('REDIS_USERNAME'),
+ 'password' => env('REDIS_PASSWORD'),
+ 'port' => env('REDIS_PORT', '6379'),
+ 'database' => env('REDIS_CACHE_DB', '1'),
+ ],
+ ],
+];
diff --git a/config/filesystems.php b/config/filesystems.php
new file mode 100644
index 0000000..f7dbb3c
--- /dev/null
+++ b/config/filesystems.php
@@ -0,0 +1,35 @@
+ env('FILESYSTEM_DISK', 'local'),
+ 'disks' => [
+ 'local' => [
+ 'driver' => 'local',
+ 'root' => storage_path('app/private'),
+ 'serve' => true,
+ 'throw' => false,
+ 'report' => false,
+ ],
+ 'public' => [
+ 'driver' => 'local',
+ 'root' => storage_path('app/public'),
+ 'url' => env('APP_URL').'/storage',
+ 'visibility' => 'public',
+ 'throw' => false,
+ 'report' => false,
+ ],
+ 's3' => [
+ 'driver' => 's3',
+ 'key' => env('AWS_ACCESS_KEY_ID'),
+ 'secret' => env('AWS_SECRET_ACCESS_KEY'),
+ 'region' => env('AWS_DEFAULT_REGION'),
+ 'bucket' => env('AWS_BUCKET'),
+ 'url' => env('AWS_URL'),
+ 'endpoint' => env('AWS_ENDPOINT'),
+ 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
+ 'throw' => false,
+ 'report' => false,
+ ],
+ ],
+ 'links' => [public_path('storage') => storage_path('app/public')],
+];
diff --git a/config/logging.php b/config/logging.php
new file mode 100644
index 0000000..ca7ceca
--- /dev/null
+++ b/config/logging.php
@@ -0,0 +1,20 @@
+ env('LOG_CHANNEL', 'stack'),
+ 'deprecations' => ['channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'), 'trace' => env('LOG_DEPRECATIONS_TRACE', false)],
+ 'channels' => [
+ 'stack' => ['driver' => 'stack', 'channels' => explode(',', (string) env('LOG_STACK', 'single')), 'ignore_exceptions' => false],
+ 'single' => ['driver' => 'single', 'path' => storage_path('logs/laravel.log'), 'level' => env('LOG_LEVEL', 'debug'), 'replace_placeholders' => true],
+ 'daily' => ['driver' => 'daily', 'path' => storage_path('logs/laravel.log'), 'level' => env('LOG_LEVEL', 'debug'), 'days' => env('LOG_DAILY_DAYS', 14), 'replace_placeholders' => true],
+ 'stderr' => ['driver' => 'monolog', 'level' => env('LOG_LEVEL', 'debug'), 'handler' => StreamHandler::class, 'handler_with' => ['stream' => 'php://stderr'], 'processors' => [PsrLogMessageProcessor::class]],
+ 'syslog' => ['driver' => 'syslog', 'level' => env('LOG_LEVEL', 'debug'), 'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER), 'replace_placeholders' => true],
+ 'errorlog' => ['driver' => 'errorlog', 'level' => env('LOG_LEVEL', 'debug'), 'replace_placeholders' => true],
+ 'null' => ['driver' => 'monolog', 'handler' => NullHandler::class],
+ 'emergency' => ['path' => storage_path('logs/laravel.log')],
+ ],
+];
diff --git a/config/mail.php b/config/mail.php
new file mode 100644
index 0000000..7b88d07
--- /dev/null
+++ b/config/mail.php
@@ -0,0 +1,22 @@
+ env('MAIL_MAILER', 'log'),
+ 'mailers' => [
+ 'smtp' => [
+ 'transport' => 'smtp',
+ 'scheme' => env('MAIL_SCHEME'),
+ 'url' => env('MAIL_URL'),
+ 'host' => env('MAIL_HOST', '127.0.0.1'),
+ 'port' => env('MAIL_PORT', 2525),
+ 'username' => env('MAIL_USERNAME'),
+ 'password' => env('MAIL_PASSWORD'),
+ 'timeout' => null,
+ 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
+ ],
+ 'log' => ['transport' => 'log', 'channel' => env('MAIL_LOG_CHANNEL')],
+ 'array' => ['transport' => 'array'],
+ 'failover' => ['transport' => 'failover', 'mailers' => ['smtp', 'log'], 'retry_after' => 60],
+ ],
+ 'from' => ['address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), 'name' => env('MAIL_FROM_NAME', 'BeatBook')],
+];
diff --git a/config/queue.php b/config/queue.php
new file mode 100644
index 0000000..0f95c1e
--- /dev/null
+++ b/config/queue.php
@@ -0,0 +1,12 @@
+ env('QUEUE_CONNECTION', 'database'),
+ 'connections' => [
+ 'sync' => ['driver' => 'sync'],
+ 'database' => ['driver' => 'database', 'connection' => env('DB_QUEUE_CONNECTION'), 'table' => env('DB_QUEUE_TABLE', 'jobs'), 'queue' => env('DB_QUEUE', 'default'), 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90), 'after_commit' => false],
+ 'redis' => ['driver' => 'redis', 'connection' => env('REDIS_QUEUE_CONNECTION', 'default'), 'queue' => env('REDIS_QUEUE', 'default'), 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90), 'block_for' => null, 'after_commit' => false],
+ ],
+ 'batching' => ['database' => env('DB_CONNECTION', 'sqlite'), 'table' => 'job_batches'],
+ 'failed' => ['driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), 'database' => env('DB_CONNECTION', 'sqlite'), 'table' => 'failed_jobs'],
+];
diff --git a/config/services.php b/config/services.php
new file mode 100644
index 0000000..0d824bf
--- /dev/null
+++ b/config/services.php
@@ -0,0 +1,8 @@
+ ['token' => env('POSTMARK_TOKEN')],
+ 'ses' => ['key' => env('AWS_ACCESS_KEY_ID'), 'secret' => env('AWS_SECRET_ACCESS_KEY'), 'region' => env('AWS_DEFAULT_REGION', 'us-east-1')],
+ 'slack' => ['notifications' => ['bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'), 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL')]],
+ 'stripe' => ['key' => env('STRIPE_KEY'), 'secret' => env('STRIPE_SECRET'), 'webhook_secret' => env('STRIPE_WEBHOOK_SECRET')],
+];
diff --git a/config/session.php b/config/session.php
new file mode 100644
index 0000000..bb0c4b5
--- /dev/null
+++ b/config/session.php
@@ -0,0 +1,22 @@
+ env('SESSION_DRIVER', 'database'),
+ 'lifetime' => (int) env('SESSION_LIFETIME', 120),
+ 'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
+ 'encrypt' => env('SESSION_ENCRYPT', false),
+ 'files' => storage_path('framework/sessions'),
+ 'connection' => env('SESSION_CONNECTION'),
+ 'table' => env('SESSION_TABLE', 'sessions'),
+ 'store' => env('SESSION_STORE'),
+ 'lottery' => [2, 100],
+ 'cookie' => env('SESSION_COOKIE', Str::slug((string) env('APP_NAME', 'laravel')).'-session'),
+ 'path' => env('SESSION_PATH', '/'),
+ 'domain' => env('SESSION_DOMAIN'),
+ 'secure' => env('SESSION_SECURE_COOKIE'),
+ 'http_only' => env('SESSION_HTTP_ONLY', true),
+ 'same_site' => env('SESSION_SAME_SITE', 'lax'),
+ 'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
+];
diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php
new file mode 100644
index 0000000..7accb5c
--- /dev/null
+++ b/database/factories/UserFactory.php
@@ -0,0 +1,28 @@
+ fake()->name(),
+ 'email' => fake()->unique()->safeEmail(),
+ 'email_verified_at' => now(),
+ 'password' => static::$password ??= Hash::make('password'),
+ 'remember_token' => Str::random(10),
+ ];
+ }
+
+ public function unverified(): static
+ {
+ return $this->state(fn () => ['email_verified_at' => null]);
+ }
+}
diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php
index 05fb5d9..a5c779a 100644
--- a/database/migrations/0001_01_01_000000_create_users_table.php
+++ b/database/migrations/0001_01_01_000000_create_users_table.php
@@ -17,6 +17,9 @@ return new class extends Migration
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
+ $table->string('phone')->nullable();
+ $table->string('avatar_path')->nullable();
+ $table->boolean('marketing_opt_in')->default(false);
$table->rememberToken();
$table->timestamps();
});
diff --git a/database/migrations/2026_09_12_000001_create_beatbook_tables.php b/database/migrations/2026_09_12_000001_create_beatbook_tables.php
index fa2c77a..77a1ca5 100644
--- a/database/migrations/2026_09_12_000001_create_beatbook_tables.php
+++ b/database/migrations/2026_09_12_000001_create_beatbook_tables.php
@@ -8,122 +8,132 @@ return new class extends Migration
{
public function up(): void
{
- Schema::create('businesses', function (Blueprint $t) {
- $t->ulid('id')->primary();
- $t->string('name');
- $t->string('slug')->unique();
- $t->text('description')->nullable();
- $t->string('email')->nullable();
- $t->string('phone')->nullable();
- $t->string('website')->nullable();
- $t->string('status')->default('active');
- $t->timestamp('verified_at')->nullable();
- $t->timestamps();
+ Schema::create('businesses', function (Blueprint $table) {
+ $table->ulid('id')->primary();
+ $table->string('name');
+ $table->string('slug')->unique();
+ $table->text('description')->nullable();
+ $table->string('email')->nullable();
+ $table->string('phone')->nullable();
+ $table->string('website')->nullable();
+ $table->string('status')->default('active')->index();
+ $table->timestamp('verified_at')->nullable();
+ $table->timestamps();
});
- Schema::create('business_user', function (Blueprint $t) {
- $t->id();
- $t->ulid('business_id');
- $t->foreignId('user_id')->constrained()->cascadeOnDelete();
- $t->string('role')->default('member');
- $t->timestamps();
- $t->unique(['business_id', 'user_id']);
+
+ Schema::create('business_user', function (Blueprint $table) {
+ $table->id();
+ $table->foreignUlid('business_id')->constrained('businesses')->cascadeOnDelete();
+ $table->foreignId('user_id')->constrained()->cascadeOnDelete();
+ $table->string('role')->default('member');
+ $table->timestamps();
+ $table->unique(['business_id', 'user_id']);
});
- Schema::create('dj_profiles', function (Blueprint $t) {
- $t->ulid('id')->primary();
- $t->foreignId('user_id')->constrained()->cascadeOnDelete();
- $t->ulid('business_id')->nullable();
- $t->string('stage_name');
- $t->string('slug')->unique();
- $t->string('headline')->nullable();
- $t->text('bio')->nullable();
- $t->string('city')->index();
- $t->unsignedSmallInteger('service_radius_miles')->default(30);
- $t->decimal('price_from', 10, 2)->nullable();
- $t->string('profile_photo')->nullable();
- $t->string('cover_photo')->nullable();
- $t->timestamp('verified_at')->nullable();
- $t->boolean('is_featured')->default(false);
- $t->boolean('is_published')->default(false);
- $t->decimal('average_rating', 3, 2)->default(0);
- $t->unsignedInteger('review_count')->default(0);
- $t->timestamps();
+
+ Schema::create('dj_profiles', function (Blueprint $table) {
+ $table->ulid('id')->primary();
+ $table->foreignId('user_id')->constrained()->cascadeOnDelete();
+ $table->foreignUlid('business_id')->nullable()->constrained('businesses')->nullOnDelete();
+ $table->string('stage_name');
+ $table->string('slug')->unique();
+ $table->string('headline')->nullable();
+ $table->text('bio')->nullable();
+ $table->string('city')->index();
+ $table->unsignedSmallInteger('service_radius_miles')->default(30);
+ $table->decimal('price_from', 10, 2)->nullable();
+ $table->string('profile_photo')->nullable();
+ $table->string('cover_photo')->nullable();
+ $table->timestamp('verified_at')->nullable();
+ $table->boolean('is_featured')->default(false)->index();
+ $table->boolean('is_published')->default(false)->index();
+ $table->decimal('average_rating', 3, 2)->default(0);
+ $table->unsignedInteger('review_count')->default(0);
+ $table->timestamps();
});
- Schema::create('genres', function (Blueprint $t) {
- $t->id();
- $t->string('name');
- $t->string('slug')->unique();
- $t->timestamps();
+
+ Schema::create('genres', function (Blueprint $table) {
+ $table->id();
+ $table->string('name');
+ $table->string('slug')->unique();
+ $table->timestamps();
});
- Schema::create('dj_profile_genre', function (Blueprint $t) {
- $t->ulid('dj_profile_id');
- $t->foreignId('genre_id')->constrained()->cascadeOnDelete();
- $t->primary(['dj_profile_id', 'genre_id']);
+
+ Schema::create('dj_profile_genre', function (Blueprint $table) {
+ $table->foreignUlid('dj_profile_id')->constrained('dj_profiles')->cascadeOnDelete();
+ $table->foreignId('genre_id')->constrained()->cascadeOnDelete();
+ $table->primary(['dj_profile_id', 'genre_id']);
});
- Schema::create('enquiries', function (Blueprint $t) {
- $t->ulid('id')->primary();
- $t->foreignId('user_id')->constrained();
- $t->ulid('dj_profile_id');
- $t->string('event_type');
- $t->date('event_date');
- $t->time('start_time')->nullable();
- $t->time('end_time')->nullable();
- $t->string('venue');
- $t->string('postcode')->nullable();
- $t->unsignedInteger('guest_count')->nullable();
- $t->decimal('budget', 10, 2)->nullable();
- $t->text('message');
- $t->string('status')->default('new')->index();
- $t->timestamps();
+
+ Schema::create('enquiries', function (Blueprint $table) {
+ $table->ulid('id')->primary();
+ $table->foreignId('user_id')->constrained()->cascadeOnDelete();
+ $table->foreignUlid('dj_profile_id')->constrained('dj_profiles')->cascadeOnDelete();
+ $table->string('event_type');
+ $table->date('event_date')->index();
+ $table->time('start_time')->nullable();
+ $table->time('end_time')->nullable();
+ $table->string('venue');
+ $table->string('postcode')->nullable();
+ $table->unsignedInteger('guest_count')->nullable();
+ $table->decimal('budget', 10, 2)->nullable();
+ $table->text('message');
+ $table->string('status')->default('new')->index();
+ $table->timestamps();
});
- Schema::create('bookings', function (Blueprint $t) {
- $t->ulid('id')->primary();
- $t->ulid('enquiry_id')->nullable();
- $t->foreignId('client_id')->constrained('users');
- $t->ulid('dj_profile_id');
- $t->date('event_date');
- $t->decimal('total', 10, 2);
- $t->decimal('deposit', 10, 2)->default(0);
- $t->string('status')->default('pending')->index();
- $t->timestamp('paid_at')->nullable();
- $t->timestamps();
+
+ Schema::create('bookings', function (Blueprint $table) {
+ $table->ulid('id')->primary();
+ $table->foreignUlid('enquiry_id')->nullable()->constrained('enquiries')->nullOnDelete();
+ $table->foreignId('client_id')->constrained('users')->cascadeOnDelete();
+ $table->foreignUlid('dj_profile_id')->constrained('dj_profiles')->cascadeOnDelete();
+ $table->date('event_date')->index();
+ $table->decimal('total', 10, 2);
+ $table->decimal('deposit', 10, 2)->default(0);
+ $table->string('status')->default('pending')->index();
+ $table->timestamp('paid_at')->nullable();
+ $table->timestamps();
});
- Schema::create('reviews', function (Blueprint $t) {
- $t->ulid('id')->primary();
- $t->ulid('booking_id');
- $t->foreignId('user_id')->constrained();
- $t->ulid('dj_profile_id');
- $t->unsignedTinyInteger('rating');
- $t->string('title')->nullable();
- $t->text('body');
- $t->string('status')->default('pending')->index();
- $t->text('dj_reply')->nullable();
- $t->timestamps();
+
+ Schema::create('reviews', function (Blueprint $table) {
+ $table->ulid('id')->primary();
+ $table->foreignUlid('booking_id')->constrained('bookings')->cascadeOnDelete();
+ $table->foreignId('user_id')->constrained()->cascadeOnDelete();
+ $table->foreignUlid('dj_profile_id')->constrained('dj_profiles')->cascadeOnDelete();
+ $table->unsignedTinyInteger('rating');
+ $table->string('title')->nullable();
+ $table->text('body');
+ $table->string('status')->default('pending')->index();
+ $table->text('dj_reply')->nullable();
+ $table->timestamps();
+ $table->unique(['booking_id', 'user_id']);
});
- Schema::create('cookie_consents', function (Blueprint $t) {
- $t->id();
- $t->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
- $t->string('anonymous_id')->nullable()->index();
- $t->boolean('analytics')->default(false);
- $t->boolean('marketing')->default(false);
- $t->string('policy_version');
- $t->timestamp('consented_at');
- $t->timestamps();
+
+ Schema::create('cookie_consents', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
+ $table->string('anonymous_id')->nullable()->index();
+ $table->boolean('analytics')->default(false);
+ $table->boolean('marketing')->default(false);
+ $table->string('policy_version');
+ $table->timestamp('consented_at');
+ $table->timestamps();
});
- Schema::create('audit_logs', function (Blueprint $t) {
- $t->id();
- $t->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
- $t->string('action')->index();
- $t->nullableMorphs('subject');
- $t->json('meta')->nullable();
- $t->string('ip_hash', 64)->nullable();
- $t->timestamps();
+
+ Schema::create('audit_logs', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
+ $table->string('action')->index();
+ $table->nullableMorphs('subject');
+ $table->json('meta')->nullable();
+ $table->string('ip_hash', 64)->nullable();
+ $table->timestamps();
});
}
public function down(): void
{
- foreach (['audit_logs', 'cookie_consents', 'reviews', 'bookings', 'enquiries', 'dj_profile_genre', 'genres', 'dj_profiles', 'business_user', 'businesses'] as $x) {
- Schema::dropIfExists($x);
+ foreach (['audit_logs', 'cookie_consents', 'reviews', 'bookings', 'enquiries', 'dj_profile_genre', 'genres', 'dj_profiles', 'business_user', 'businesses'] as $table) {
+ Schema::dropIfExists($table);
}
}
};
diff --git a/database/migrations/2026_09_12_000002_create_permission_tables.php b/database/migrations/2026_09_12_000002_create_permission_tables.php
new file mode 100644
index 0000000..0b238ef
--- /dev/null
+++ b/database/migrations/2026_09_12_000002_create_permission_tables.php
@@ -0,0 +1,62 @@
+id();
+ $table->string('name');
+ $table->string('guard_name');
+ $table->timestamps();
+ $table->unique(['name', 'guard_name']);
+ });
+
+ Schema::create('roles', function (Blueprint $table) {
+ $table->id();
+ $table->string('name');
+ $table->string('guard_name');
+ $table->timestamps();
+ $table->unique(['name', 'guard_name']);
+ });
+
+ Schema::create('model_has_permissions', function (Blueprint $table) {
+ $table->unsignedBigInteger('permission_id');
+ $table->string('model_type');
+ $table->unsignedBigInteger('model_id');
+ $table->index(['model_id', 'model_type']);
+ $table->foreign('permission_id')->references('id')->on('permissions')->cascadeOnDelete();
+ $table->primary(['permission_id', 'model_id', 'model_type']);
+ });
+
+ Schema::create('model_has_roles', function (Blueprint $table) {
+ $table->unsignedBigInteger('role_id');
+ $table->string('model_type');
+ $table->unsignedBigInteger('model_id');
+ $table->index(['model_id', 'model_type']);
+ $table->foreign('role_id')->references('id')->on('roles')->cascadeOnDelete();
+ $table->primary(['role_id', 'model_id', 'model_type']);
+ });
+
+ Schema::create('role_has_permissions', function (Blueprint $table) {
+ $table->unsignedBigInteger('permission_id');
+ $table->unsignedBigInteger('role_id');
+ $table->foreign('permission_id')->references('id')->on('permissions')->cascadeOnDelete();
+ $table->foreign('role_id')->references('id')->on('roles')->cascadeOnDelete();
+ $table->primary(['permission_id', 'role_id']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('role_has_permissions');
+ Schema::dropIfExists('model_has_roles');
+ Schema::dropIfExists('model_has_permissions');
+ Schema::dropIfExists('roles');
+ Schema::dropIfExists('permissions');
+ }
+};
diff --git a/database/migrations/2026_09_12_000003_add_cashier_columns.php b/database/migrations/2026_09_12_000003_add_cashier_columns.php
new file mode 100644
index 0000000..af38fe5
--- /dev/null
+++ b/database/migrations/2026_09_12_000003_add_cashier_columns.php
@@ -0,0 +1,55 @@
+string('stripe_id')->nullable()->index();
+ $table->string('pm_type')->nullable();
+ $table->string('pm_last_four', 4)->nullable();
+ $table->timestamp('trial_ends_at')->nullable();
+ });
+
+ Schema::create('subscriptions', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('user_id')->constrained()->cascadeOnDelete();
+ $table->string('type');
+ $table->string('stripe_id')->unique();
+ $table->string('stripe_status');
+ $table->string('stripe_price')->nullable();
+ $table->integer('quantity')->nullable();
+ $table->timestamp('trial_ends_at')->nullable();
+ $table->timestamp('ends_at')->nullable();
+ $table->timestamps();
+ $table->index(['user_id', 'stripe_status']);
+ });
+
+ Schema::create('subscription_items', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('subscription_id')->constrained()->cascadeOnDelete();
+ $table->string('stripe_id')->unique();
+ $table->string('stripe_product');
+ $table->string('stripe_price');
+ $table->integer('quantity')->nullable();
+ $table->string('meter_event_name')->nullable();
+ $table->string('meter_id')->nullable();
+ $table->timestamps();
+ $table->index(['subscription_id', 'stripe_price']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('subscription_items');
+ Schema::dropIfExists('subscriptions');
+ Schema::table('users', function (Blueprint $table) {
+ $table->dropIndex(['stripe_id']);
+ $table->dropColumn(['stripe_id', 'pm_type', 'pm_last_four', 'trial_ends_at']);
+ });
+ }
+};
diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php
index c2d47d6..f1a5d46 100644
--- a/database/seeders/DatabaseSeeder.php
+++ b/database/seeders/DatabaseSeeder.php
@@ -13,7 +13,7 @@ class DatabaseSeeder extends Seeder
public function run(): void
{
foreach (['super-admin', 'admin', 'dj', 'client', 'moderator', 'support'] as $r) {
- Role::firstOrCreate(['name' => $r]);
+ Role::firstOrCreate(['name' => $r, 'guard_name' => 'web']);
} $admin = User::firstOrCreate(['email' => 'admin@beatbook.test'], ['name' => 'BeatBook Admin', 'password' => 'password']);
$admin->assignRole('admin');
$djUser = User::firstOrCreate(['email' => 'dj@beatbook.test'], ['name' => 'Luna Hart', 'password' => 'password']);
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..ae175e7
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,22 @@
+
+
Marketplace overview and moderation queue.
Revenue charts, recent bookings, verification requests, support tickets and review moderation plug into this dashboard.
Marketplace overview and moderation queue.
+Revenue charts, recent bookings, verification requests, support tickets and review moderation plug into this dashboard.
+This BeatBook administration section is connected and ready for its management workflow.
+Enter your email and we will send a password reset link.
+ + @if($errors->any()) +Manage enquiries, bookings and your account.
+ + @if($errors->any()) +Start enquiring with DJs and managing your bookings.
+ + @if($errors->any()) +{{ $dj->headline }}
{{ $dj->bio }}
{{ $review->body }}
{{ $dj->headline }}
{{ $dj->bio }}
{{ $review->body }}
Discover trusted DJs for weddings, parties, corporate events and clubs. Compare profiles, reviews and prices, then enquire with confidence.
{{ $b }}
Discover trusted DJs for weddings, parties, corporate events and clubs. Compare profiles, reviews and prices, then enquire with confidence.
{{ $b }}
Show your sound, experience, service area and pricing in one trusted profile.
Receive structured event details so you can quote quickly and accurately.
Build reputation from completed BeatBook bookings.
Manage bookings, payments and customer communication from your dashboard.
Browsing and creating a client account is free. DJ subscription and payment options can be configured from the BeatBook billing area as the marketplace launches.