From d963087bc991497ceb1817db285a96c23286d9f9 Mon Sep 17 00:00:00 2001 From: Martijn van de Wetering Date: Sat, 13 Dec 2025 23:42:48 +0100 Subject: [PATCH 01/19] chore: Add more event triggers --- .../Commands/CheckMarketingTriggers.php | 186 ++++++++++++++++++ .../app/Events/TrialExpiredOrCancelled.php | 21 ++ backend/app/Events/UserActivatedAfter24h.php | 21 ++ backend/app/Events/UserInactive.php | 21 ++ .../app/Events/UserNotActivatedAfter24h.php | 21 ++ backend/app/Events/UserPassive.php | 21 ++ ...endTrialExpiredOrCancelledNotification.php | 27 +++ .../SendUserActivatedAfter24hNotification.php | 27 +++ .../SendUserInactiveNotification.php | 27 +++ ...ndUserNotActivatedAfter24hNotification.php | 27 +++ .../Listeners/SendUserPassiveNotification.php | 27 +++ backend/config/settings.php | 5 + backend/routes/console.php | 6 + 13 files changed, 437 insertions(+) create mode 100644 backend/app/Console/Commands/CheckMarketingTriggers.php create mode 100644 backend/app/Events/TrialExpiredOrCancelled.php create mode 100644 backend/app/Events/UserActivatedAfter24h.php create mode 100644 backend/app/Events/UserInactive.php create mode 100644 backend/app/Events/UserNotActivatedAfter24h.php create mode 100644 backend/app/Events/UserPassive.php create mode 100644 backend/app/Listeners/SendTrialExpiredOrCancelledNotification.php create mode 100644 backend/app/Listeners/SendUserActivatedAfter24hNotification.php create mode 100644 backend/app/Listeners/SendUserInactiveNotification.php create mode 100644 backend/app/Listeners/SendUserNotActivatedAfter24hNotification.php create mode 100644 backend/app/Listeners/SendUserPassiveNotification.php diff --git a/backend/app/Console/Commands/CheckMarketingTriggers.php b/backend/app/Console/Commands/CheckMarketingTriggers.php new file mode 100644 index 0000000..5456fc9 --- /dev/null +++ b/backend/app/Console/Commands/CheckMarketingTriggers.php @@ -0,0 +1,186 @@ +info('Checking marketing triggers...'); + + // Check users not activated after 24h + $this->checkUsersNotActivatedAfter24h(); + + // Check users activated after 24h + $this->checkUsersActivatedAfter24h(); + + // Check passive users (14 days no activity) + $this->checkPassiveUsers(); + + // Check inactive users (30 days no activity) + $this->checkInactiveUsers(); + + // Check trial expired or cancelled + $this->checkTrialExpiredOrCancelled(); + + $this->info('Marketing triggers check completed.'); + + return self::SUCCESS; + } + + /** + * Check users registered 24h ago but haven't created a display + */ + private function checkUsersNotActivatedAfter24h(): void + { + $users = User::whereNull('deleted_at') + ->where('created_at', '<=', now()->subHours(24)) + ->where('created_at', '>', now()->subHours(25)) + ->whereDoesntHave('displays') + ->get(); + + foreach ($users as $user) { + $cacheKey = "marketing:user_not_activated_24h:{$user->id}"; + if (!Cache::has($cacheKey)) { + event(new UserNotActivatedAfter24h($user)); + Cache::put($cacheKey, true, now()->addDays(7)); // Prevent duplicate events for 7 days + $this->line("Fired UserNotActivatedAfter24h for user {$user->email}"); + } + } + } + + /** + * Check users who created their first display 24h ago + */ + private function checkUsersActivatedAfter24h(): void + { + // Get users whose first display was created 24h ago + $users = User::whereNull('deleted_at') + ->whereHas('displays', function ($query) { + $query->where('created_at', '<=', now()->subHours(24)) + ->where('created_at', '>', now()->subHours(25)); + }) + ->get() + ->filter(function ($user) { + // Only if this was their first display + $firstDisplay = $user->displays()->orderBy('created_at')->first(); + return $firstDisplay && + $firstDisplay->created_at->isAfter(now()->subHours(25)) && + $firstDisplay->created_at->isBefore(now()->subHours(24)); + }); + + foreach ($users as $user) { + $cacheKey = "marketing:user_activated_24h:{$user->id}"; + if (!Cache::has($cacheKey)) { + event(new UserActivatedAfter24h($user)); + Cache::put($cacheKey, true, now()->addDays(7)); // Prevent duplicate events for 7 days + $this->line("Fired UserActivatedAfter24h for user {$user->email}"); + } + } + } + + /** + * Check users with no activity for 14 days + */ + private function checkPassiveUsers(): void + { + $users = User::whereNull('deleted_at') + ->whereNotNull('last_activity_at') + ->where('last_activity_at', '<=', now()->subDays(14)) + ->where('last_activity_at', '>', now()->subDays(15)) + ->get(); + + foreach ($users as $user) { + $cacheKey = "marketing:user_passive:{$user->id}"; + if (!Cache::has($cacheKey)) { + event(new UserPassive($user)); + Cache::put($cacheKey, true, now()->addDays(7)); // Prevent duplicate events for 7 days + $this->line("Fired UserPassive for user {$user->email}"); + } + } + } + + /** + * Check users with no activity for 30 days + */ + private function checkInactiveUsers(): void + { + $users = User::whereNull('deleted_at') + ->whereNotNull('last_activity_at') + ->where('last_activity_at', '<=', now()->subDays(30)) + ->where('last_activity_at', '>', now()->subDays(31)) + ->get(); + + foreach ($users as $user) { + $cacheKey = "marketing:user_inactive:{$user->id}"; + if (!Cache::has($cacheKey)) { + event(new UserInactive($user)); + Cache::put($cacheKey, true, now()->addDays(7)); // Prevent duplicate events for 7 days + $this->line("Fired UserInactive for user {$user->email}"); + } + } + } + + /** + * Check users with expired or cancelled trials + */ + private function checkTrialExpiredOrCancelled(): void + { + if (config('settings.is_self_hosted')) { + return; // Skip for self-hosted instances + } + + // Get users whose subscriptions ended in the last 24 hours + $users = User::whereNull('deleted_at') + ->where('is_unlimited', false) + ->whereHas('subscriptions', function ($query) { + // Subscription ended in the last 24 hours + $query->where('ends_at', '<=', now()) + ->where('ends_at', '>', now()->subDay()); + }) + ->whereDoesntHave('subscriptions', function ($query) { + // And they don't have any active subscriptions + $query->where(function ($q) { + $q->whereNull('ends_at') + ->orWhere('ends_at', '>', now()); + }); + }) + ->get(); + + foreach ($users as $user) { + $cacheKey = "marketing:trial_expired:{$user->id}"; + if (!Cache::has($cacheKey)) { + event(new TrialExpiredOrCancelled($user)); + Cache::put($cacheKey, true, now()->addDays(7)); // Prevent duplicate events for 7 days + $this->line("Fired TrialExpiredOrCancelled for user {$user->email}"); + } + } + } +} + diff --git a/backend/app/Events/TrialExpiredOrCancelled.php b/backend/app/Events/TrialExpiredOrCancelled.php new file mode 100644 index 0000000..29e7b7b --- /dev/null +++ b/backend/app/Events/TrialExpiredOrCancelled.php @@ -0,0 +1,21 @@ + 'trial_expired_or_cancelled', + 'user' => UserWebhookData::from($event->user), + ]); + } +} + diff --git a/backend/app/Listeners/SendUserActivatedAfter24hNotification.php b/backend/app/Listeners/SendUserActivatedAfter24hNotification.php new file mode 100644 index 0000000..91d1c3a --- /dev/null +++ b/backend/app/Listeners/SendUserActivatedAfter24hNotification.php @@ -0,0 +1,27 @@ + 'user_activated_after_24h', + 'user' => UserWebhookData::from($event->user), + ]); + } +} + diff --git a/backend/app/Listeners/SendUserInactiveNotification.php b/backend/app/Listeners/SendUserInactiveNotification.php new file mode 100644 index 0000000..0b8b371 --- /dev/null +++ b/backend/app/Listeners/SendUserInactiveNotification.php @@ -0,0 +1,27 @@ + 'user_inactive', + 'user' => UserWebhookData::from($event->user), + ]); + } +} + diff --git a/backend/app/Listeners/SendUserNotActivatedAfter24hNotification.php b/backend/app/Listeners/SendUserNotActivatedAfter24hNotification.php new file mode 100644 index 0000000..8ce0c1a --- /dev/null +++ b/backend/app/Listeners/SendUserNotActivatedAfter24hNotification.php @@ -0,0 +1,27 @@ + 'user_not_activated_after_24h', + 'user' => UserWebhookData::from($event->user), + ]); + } +} + diff --git a/backend/app/Listeners/SendUserPassiveNotification.php b/backend/app/Listeners/SendUserPassiveNotification.php new file mode 100644 index 0000000..de4956d --- /dev/null +++ b/backend/app/Listeners/SendUserPassiveNotification.php @@ -0,0 +1,27 @@ + 'user_passive', + 'user' => UserWebhookData::from($event->user), + ]); + } +} + diff --git a/backend/config/settings.php b/backend/config/settings.php index e8e841d..6884f34 100644 --- a/backend/config/settings.php +++ b/backend/config/settings.php @@ -6,6 +6,11 @@ 'registration_webhook_url' => env('REGISTRATION_WEBHOOK_URL'), 'onboarding_complete_webhook_url' => env('ONBOARDING_COMPLETE_WEBHOOK_URL'), 'order_created_webhook_url' => env('ORDER_CREATED_WEBHOOK_URL'), + 'user_not_activated_after_24h_webhook_url' => env('USER_NOT_ACTIVATED_AFTER_24H_WEBHOOK_URL'), + 'user_activated_after_24h_webhook_url' => env('USER_ACTIVATED_AFTER_24H_WEBHOOK_URL'), + 'trial_expired_or_cancelled_webhook_url' => env('TRIAL_EXPIRED_OR_CANCELLED_WEBHOOK_URL'), + 'user_passive_webhook_url' => env('USER_PASSIVE_WEBHOOK_URL'), + 'user_inactive_webhook_url' => env('USER_INACTIVE_WEBHOOK_URL'), 'license_server' => env('LICENSE_SERVER', 'https://app.spacepad.io'), diff --git a/backend/routes/console.php b/backend/routes/console.php index 2217012..359e65f 100644 --- a/backend/routes/console.php +++ b/backend/routes/console.php @@ -1,5 +1,6 @@ when(fn() => ! config('settings.is_self_hosted')) ->hourly() ->withoutOverlapping(); + +Schedule::command(CheckMarketingTriggers::class) + ->when(fn() => ! config('settings.is_self_hosted')) + ->hourly() + ->withoutOverlapping(); From 601a2475a82074abd79e70ab4861a0d6fb6e2956 Mon Sep 17 00:00:00 2001 From: Martijn van de Wetering Date: Sun, 14 Dec 2025 12:29:33 +0100 Subject: [PATCH 02/19] feat: Edit triggers and add name fields --- .../Commands/CheckMarketingTriggers.php | 56 +++++++++++++------ backend/app/Data/UserWebhookData.php | 2 + backend/app/Models/User.php | 7 +++ ...irst_name_and_last_name_to_users_table.php | 33 +++++++++++ 4 files changed, 80 insertions(+), 18 deletions(-) create mode 100644 backend/database/migrations/2025_12_06_000003_add_first_name_and_last_name_to_users_table.php diff --git a/backend/app/Console/Commands/CheckMarketingTriggers.php b/backend/app/Console/Commands/CheckMarketingTriggers.php index 5456fc9..9d706ed 100644 --- a/backend/app/Console/Commands/CheckMarketingTriggers.php +++ b/backend/app/Console/Commands/CheckMarketingTriggers.php @@ -82,18 +82,10 @@ private function checkUsersActivatedAfter24h(): void { // Get users whose first display was created 24h ago $users = User::whereNull('deleted_at') - ->whereHas('displays', function ($query) { - $query->where('created_at', '<=', now()->subHours(24)) - ->where('created_at', '>', now()->subHours(25)); - }) - ->get() - ->filter(function ($user) { - // Only if this was their first display - $firstDisplay = $user->displays()->orderBy('created_at')->first(); - return $firstDisplay && - $firstDisplay->created_at->isAfter(now()->subHours(25)) && - $firstDisplay->created_at->isBefore(now()->subHours(24)); - }); + ->where('created_at', '<=', now()->subHours(24)) + ->where('created_at', '>', now()->subHours(25)) + ->whereHas('displays') + ->get(); foreach ($users as $user) { $cacheKey = "marketing:user_activated_24h:{$user->id}"; @@ -107,13 +99,27 @@ private function checkUsersActivatedAfter24h(): void /** * Check users with no activity for 14 days + * Activity includes: user activity, device activity */ private function checkPassiveUsers(): void { + $cutoffDate = now()->subDays(14); + $previousCutoffDate = now()->subDays(15); + $users = User::whereNull('deleted_at') - ->whereNotNull('last_activity_at') - ->where('last_activity_at', '<=', now()->subDays(14)) - ->where('last_activity_at', '>', now()->subDays(15)) + ->where(function ($query) use ($cutoffDate, $previousCutoffDate) { + // User's last activity is within the window (or null) + $query->where(function ($q) use ($cutoffDate, $previousCutoffDate) { + $q->whereNotNull('last_activity_at') + ->where('last_activity_at', '<=', $cutoffDate) + ->where('last_activity_at', '>', $previousCutoffDate); + }); + }) + // And no devices with recent activity + ->whereDoesntHave('devices', function ($q) use ($cutoffDate) { + $q->whereNotNull('last_activity_at') + ->where('last_activity_at', '>', $cutoffDate); + }) ->get(); foreach ($users as $user) { @@ -128,13 +134,27 @@ private function checkPassiveUsers(): void /** * Check users with no activity for 30 days + * Activity includes: user activity, device activity */ private function checkInactiveUsers(): void { + $cutoffDate = now()->subDays(30); + $previousCutoffDate = now()->subDays(31); + $users = User::whereNull('deleted_at') - ->whereNotNull('last_activity_at') - ->where('last_activity_at', '<=', now()->subDays(30)) - ->where('last_activity_at', '>', now()->subDays(31)) + ->where(function ($query) use ($cutoffDate, $previousCutoffDate) { + // User's last activity is within the window (or null) + $query->where(function ($q) use ($cutoffDate, $previousCutoffDate) { + $q->whereNotNull('last_activity_at') + ->where('last_activity_at', '<=', $cutoffDate) + ->where('last_activity_at', '>', $previousCutoffDate); + }); + }) + // And no devices with recent activity + ->whereDoesntHave('devices', function ($q) use ($cutoffDate) { + $q->whereNotNull('last_activity_at') + ->where('last_activity_at', '>', $cutoffDate); + }) ->get(); foreach ($users as $user) { diff --git a/backend/app/Data/UserWebhookData.php b/backend/app/Data/UserWebhookData.php index cd220e1..b05ae3c 100644 --- a/backend/app/Data/UserWebhookData.php +++ b/backend/app/Data/UserWebhookData.php @@ -10,6 +10,8 @@ class UserWebhookData extends Data public function __construct( public string $id, public string $name, + public ?string $firstName, + public ?string $lastName, public string $email, public string $status, public ?Carbon $emailVerifiedAt, diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php index 145e8f1..63f50bf 100644 --- a/backend/app/Models/User.php +++ b/backend/app/Models/User.php @@ -26,6 +26,8 @@ class User extends Authenticatable */ protected $fillable = [ 'name', + 'first_name', + 'last_name', 'email', 'password', 'microsoft_id', @@ -84,6 +86,11 @@ public function displays(): HasMany return $this->hasMany(Display::class); } + public function devices(): HasMany + { + return $this->hasMany(Device::class); + } + public function rooms(): HasMany { return $this->hasMany(Room::class); diff --git a/backend/database/migrations/2025_12_06_000003_add_first_name_and_last_name_to_users_table.php b/backend/database/migrations/2025_12_06_000003_add_first_name_and_last_name_to_users_table.php new file mode 100644 index 0000000..7d674c4 --- /dev/null +++ b/backend/database/migrations/2025_12_06_000003_add_first_name_and_last_name_to_users_table.php @@ -0,0 +1,33 @@ +string('first_name')->nullable()->after('name'); + }); + + Schema::table('users', function (Blueprint $table) { + $table->string('last_name')->nullable()->after('first_name'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn(['first_name', 'last_name']); + }); + } +}; + From 51e8e7cff91001ae6c46daeb2ba32a9f77a08f85 Mon Sep 17 00:00:00 2001 From: Martijn van de Wetering Date: Sun, 14 Dec 2025 13:27:57 +0100 Subject: [PATCH 03/19] chore: Add maintenance command --- ...ggerRegistrationWebhookForMissingNames.php | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 backend/app/Console/Commands/TriggerRegistrationWebhookForMissingNames.php diff --git a/backend/app/Console/Commands/TriggerRegistrationWebhookForMissingNames.php b/backend/app/Console/Commands/TriggerRegistrationWebhookForMissingNames.php new file mode 100644 index 0000000..c33aa44 --- /dev/null +++ b/backend/app/Console/Commands/TriggerRegistrationWebhookForMissingNames.php @@ -0,0 +1,52 @@ +where(function ($query) { + $query->whereNull('first_name') + ->orWhereNull('last_name'); + }) + ->orderBy('created_at', 'asc') + ->first(); + + if (!$user) { + $this->info('No users found without first_name or last_name.'); + return self::SUCCESS; + } + + $this->info("Triggering registration webhook for user: {$user->email} (ID: {$user->id})"); + + event(new UserRegistered($user)); + + $this->info('Registration webhook triggered successfully.'); + + return self::SUCCESS; + } +} + From adc75297001c494f3a174f6f0f211c2940e4714d Mon Sep 17 00:00:00 2001 From: Martijn van de Wetering Date: Wed, 31 Dec 2025 00:29:48 +0100 Subject: [PATCH 04/19] feat: Add OpenTelemetry config --- backend/artisan | 3 ++ backend/bootstrap/opentelemetry.php | 13 +++++++++ backend/public/index.php | 3 ++ docker-compose.dev.yml | 45 ++++++++++++++++++++++++----- 4 files changed, 56 insertions(+), 8 deletions(-) create mode 100644 backend/bootstrap/opentelemetry.php diff --git a/backend/artisan b/backend/artisan index 8e04b42..7a4ba00 100755 --- a/backend/artisan +++ b/backend/artisan @@ -5,6 +5,9 @@ use Symfony\Component\Console\Input\ArgvInput; define('LARAVEL_START', microtime(true)); +// Configure OpenTelemetry before autoloader (prevents warnings when extension not installed) +require __DIR__.'/bootstrap/opentelemetry.php'; + // Register the Composer autoloader... require __DIR__.'/vendor/autoload.php'; diff --git a/backend/bootstrap/opentelemetry.php b/backend/bootstrap/opentelemetry.php new file mode 100644 index 0000000..3ed0c30 --- /dev/null +++ b/backend/bootstrap/opentelemetry.php @@ -0,0 +1,13 @@ + Date: Wed, 31 Dec 2025 13:58:44 +0100 Subject: [PATCH 05/19] Add k6 and FARO integration --- backend/FARO_SETUP.md | 219 ++++++++++++ backend/config/faro.php | 128 +++++++ .../views/components/scripts/faro.blade.php | 31 ++ .../resources/views/layouts/blank.blade.php | 1 + .../resources/views/layouts/error.blade.php | 1 + k6/README.md | 157 ++++++++ k6/load-test.js | 336 ++++++++++++++++++ k6/tags.js | 12 + 8 files changed, 885 insertions(+) create mode 100644 backend/FARO_SETUP.md create mode 100644 backend/config/faro.php create mode 100644 backend/resources/views/components/scripts/faro.blade.php create mode 100644 k6/README.md create mode 100644 k6/load-test.js create mode 100644 k6/tags.js diff --git a/backend/FARO_SETUP.md b/backend/FARO_SETUP.md new file mode 100644 index 0000000..a5a55ec --- /dev/null +++ b/backend/FARO_SETUP.md @@ -0,0 +1,219 @@ +# Grafana Faro Frontend Monitoring Setup + +This document explains how to configure Grafana Faro Real User Monitoring (RUM) for the Spacepad frontend. + +## Overview + +Grafana Faro collects frontend telemetry data including: +- **Web Vitals** (LCP, FID, CLS, FCP, TTFB) +- **Page load timing** (DOMContentLoaded, Load, etc.) +- **User interactions** (clicks, form submissions) +- **All fetch/XHR requests** with full trace context +- **Frontend → Backend trace correlation** +- **Long tasks** (performance monitoring) +- **Errors and exceptions** (sent to Loki) +- **Console logs** (errors/warnings sent to Loki) +- **Session tracking** + +## Configuration + +### 1. Enable Faro + +Add to your `.env` file: + +```env +FARO_ENABLED=true +FARO_COLLECTOR_URL=http://localhost:12347/collect +FARO_API_KEY=faro-secret-key +FARO_APP_NAME=spacepad +FARO_APP_VERSION=1.0.0 +FARO_APP_ENV=local +``` + +### 2. Grafana Alloy Configuration + +Ensure Grafana Alloy is running with a FARO receiver configured. The receiver should: +- Listen on port `12347` at `/collect` endpoint +- Use the same `api_key` as configured in `FARO_API_KEY` +- Forward logs to Loki +- Forward traces to Tempo +- Forward metrics to Prometheus + +Example Alloy configuration: + +```river +faro.receiver "faro_receiver" { + server { + listen_address = "0.0.0.0" + listen_port = 12347 + cors_allowed_origins = ["*"] // Allow all origins for development + api_key = "faro-secret-key" // Must match FARO_API_KEY + max_allowed_payload_size = "10MiB" + + rate_limiting { + rate = 100 + } + } + + sourcemaps { } + + output { + logs = [loki.process.faro_logs.receiver] + traces = [otelcol.processor.batch.batch_processor.input] + } +} +``` + +### 3. Docker Environment + +If running in Docker, use `host.docker.internal` to reach Grafana Alloy on the host: + +```env +FARO_COLLECTOR_URL=http://host.docker.internal:12347/collect +``` + +## How It Works + +1. **Frontend Application** → Sends RUM data via FARO SDK → **Grafana Alloy** (port 12347 `/collect`) +2. **Grafana Alloy** → Processes FARO data → Forwards to: + - **Prometheus** (metrics) + - **Loki** (logs) + - **Tempo** (traces) + +## Features + +The Faro integration automatically captures: + +- ✅ **Web Vitals** (LCP, FID, CLS, FCP, TTFB) +- ✅ **Page load timing** (DOMContentLoaded, Load, etc.) +- ✅ **User interactions** (clicks, form submissions) +- ✅ **All fetch/XHR requests** with full trace context +- ✅ **Frontend → Backend trace correlation** +- ✅ **Long tasks** (performance monitoring) +- ✅ **Errors and exceptions** (sent to Loki) +- ✅ **Console logs** (errors/warnings sent to Loki) +- ✅ **Session tracking** + +## Viewing Data + +### Grafana Dashboard + +Import the Grafana Faro Frontend Monitoring dashboard (ID: `17766`): + +1. Open Grafana at http://localhost:3000 +2. Go to Dashboards → Import +3. Enter dashboard ID: `17766` +4. Select Prometheus as the datasource +5. Click "Import" + +### Prometheus Queries + +Query FARO metrics in Prometheus: + +```promql +# Frontend errors +faro_errors_total + +# Page load metrics +faro_page_load_duration_seconds + +# Web Vitals +faro_web_vitals_lcp_seconds +faro_web_vitals_fid_seconds +faro_web_vitals_cls +``` + +### Loki Logs + +Search for frontend logs in Loki: + +```logql +{service_name="spacepad"} |= "error" +``` + +### Tempo Traces + +View frontend traces in Tempo: +- Search for traces from `spacepad` service +- Filter by route or operation +- View trace details and spans + +## Troubleshooting + +### Faro Not Initializing + +1. **Check browser console** for initialization errors +2. **Verify configuration** in `.env` file +3. **Check network tab** for requests to `/collect` endpoint +4. **Verify CORS** settings in Grafana Alloy config + +### No Data in Grafana + +1. **Check Grafana Alloy logs:** + ```bash + docker logs grafana-alloy + ``` + +2. **Verify API key matches:** + - `FARO_API_KEY` in `.env` must match `api_key` in Alloy config + +3. **Check Prometheus targets:** + - Visit http://localhost:9090/targets + - Verify `grafana-alloy` target is UP + +4. **Verify CORS:** + - Ensure `cors_allowed_origins` in Alloy config includes your frontend origin + +### CORS Errors + +If you see CORS errors in the browser console: + +1. Add your frontend origin to `cors_allowed_origins` in Alloy config +2. For development: `cors_allowed_origins = ["*"]` +3. For production: `cors_allowed_origins = ["https://yourdomain.com"]` + +## Security + +**Important:** The default API key `faro-secret-key` is for development only. In production: + +1. Generate a secure random API key +2. Update `FARO_API_KEY` in `.env` +3. Update `api_key` in Grafana Alloy config +4. Consider using environment variables or secrets management + +## Advanced Configuration + +### Custom Instrumentations + +To add custom instrumentations, modify `resources/views/components/scripts/faro.blade.php`: + +```javascript +import { TracingInstrumentation } from '@grafana/faro-web-tracing'; + +const faroInstance = initializeFaro({ + // ... existing config + instrumentations: [ + ...getWebInstrumentations(), + new TracingInstrumentation(), + ], +}); +``` + +### Disable Specific Features + +You can disable specific features via environment variables: + +```env +FARO_PERFORMANCE_ENABLED=false +FARO_ERRORS_ENABLED=false +FARO_CONSOLE_ENABLED=false +FARO_INTERACTIONS_ENABLED=false +FARO_SESSION_TRACKING=false +``` + +## References + +- [Grafana Faro Documentation](https://github.com/grafana/faro-web-sdk) +- [Grafana Faro Quick Start](https://github.com/grafana/faro-web-sdk/blob/main/docs/sources/tutorials/quick-start-browser.md) +- [Grafana Alloy FARO Receiver](https://grafana.com/docs/alloy/latest/reference/components/faro.receiver/) + diff --git a/backend/config/faro.php b/backend/config/faro.php new file mode 100644 index 0000000..e82ef7d --- /dev/null +++ b/backend/config/faro.php @@ -0,0 +1,128 @@ + env('FARO_ENABLED', false), + + /* + |-------------------------------------------------------------------------- + | Faro Collector Endpoint + |-------------------------------------------------------------------------- + | + | The URL where Grafana Alloy FARO receiver is listening. + | Default: http://localhost:12347/collect + | + | In Docker environments, use host.docker.internal to reach the host. + | In production, use your actual Grafana Alloy endpoint. + | + */ + + 'collector_url' => env('FARO_COLLECTOR_URL', 'http://localhost:12347/collect'), + + /* + |-------------------------------------------------------------------------- + | API Key + |-------------------------------------------------------------------------- + | + | The API key that must match the api_key configured in Grafana Alloy. + | Default: faro-secret-key (change this in production!) + | + */ + + 'api_key' => env('FARO_API_KEY', 'faro-secret-key'), + + /* + |-------------------------------------------------------------------------- + | Application Information + |-------------------------------------------------------------------------- + | + | Application metadata sent with Faro telemetry. + | + */ + + 'app' => [ + 'name' => env('FARO_APP_NAME', env('APP_NAME', 'spacepad')), + 'version' => env('FARO_APP_VERSION', env('APP_VERSION', '1.0.0')), + 'environment' => env('FARO_APP_ENV', env('APP_ENV', 'local')), + ], + + /* + |-------------------------------------------------------------------------- + | Session Tracking + |-------------------------------------------------------------------------- + | + | Enable session tracking and user identification. + | + */ + + 'session_tracking' => env('FARO_SESSION_TRACKING', true), + + /* + |-------------------------------------------------------------------------- + | Performance Monitoring + |-------------------------------------------------------------------------- + | + | Enable Web Vitals and performance metrics collection. + | + */ + + 'performance' => [ + 'enabled' => env('FARO_PERFORMANCE_ENABLED', true), + 'observe_long_tasks' => env('FARO_OBSERVE_LONG_TASKS', true), + 'observe_resources' => env('FARO_OBSERVE_RESOURCES', true), + ], + + /* + |-------------------------------------------------------------------------- + | Error Tracking + |-------------------------------------------------------------------------- + | + | Enable automatic error and exception tracking. + | + */ + + 'errors' => [ + 'enabled' => env('FARO_ERRORS_ENABLED', true), + 'capture_unhandled_rejections' => env('FARO_CAPTURE_UNHANDLED_REJECTIONS', true), + ], + + /* + |-------------------------------------------------------------------------- + | Console Logs + |-------------------------------------------------------------------------- + | + | Enable capturing console logs (errors and warnings). + | + */ + + 'console' => [ + 'enabled' => env('FARO_CONSOLE_ENABLED', true), + 'levels' => env('FARO_CONSOLE_LEVELS', 'error,warn'), // Comma-separated: error, warn, info, debug + ], + + /* + |-------------------------------------------------------------------------- + | User Interactions + |-------------------------------------------------------------------------- + | + | Enable tracking user interactions (clicks, form submissions). + | + */ + + 'interactions' => [ + 'enabled' => env('FARO_INTERACTIONS_ENABLED', true), + ], + +]; + diff --git a/backend/resources/views/components/scripts/faro.blade.php b/backend/resources/views/components/scripts/faro.blade.php new file mode 100644 index 0000000..1000f4e --- /dev/null +++ b/backend/resources/views/components/scripts/faro.blade.php @@ -0,0 +1,31 @@ +@if(config('faro.enabled')) + +@endif + diff --git a/backend/resources/views/layouts/blank.blade.php b/backend/resources/views/layouts/blank.blade.php index e5d1ec1..839adcc 100644 --- a/backend/resources/views/layouts/blank.blade.php +++ b/backend/resources/views/layouts/blank.blade.php @@ -28,6 +28,7 @@ @stack('styles') @lemonJS @includeWhen(config('services.clarity.tag_code'), 'components.scripts.clarity') + @include('components.scripts.faro') @includeWhen(config('googletagmanager.enabled') && config('googletagmanager.id'), 'googletagmanager::body') diff --git a/backend/resources/views/layouts/error.blade.php b/backend/resources/views/layouts/error.blade.php index c3a2568..b487a27 100644 --- a/backend/resources/views/layouts/error.blade.php +++ b/backend/resources/views/layouts/error.blade.php @@ -9,6 +9,7 @@ @vite(['resources/css/app.css', 'resources/js/app.js']) + @include('components.scripts.faro')
diff --git a/k6/README.md b/k6/README.md new file mode 100644 index 0000000..aa00dc4 --- /dev/null +++ b/k6/README.md @@ -0,0 +1,157 @@ +# k6 Load Testing for Spacepad + +k6 script for generating realistic traffic to test the Spacepad application and demonstrate the observability stack. + +## Script + +### `load-test.js` +A unified script that can run in two modes: + +**Load Test Mode (default)**: Variable load with different stages (ramp up, steady state, ramp down). Good for testing under different load conditions. + +**Continuous Mode**: Steady, continuous traffic (2 requests/second) indefinitely. Perfect for demonstrating observability in action. + +## Usage + +### Load Test Mode (Default) +```bash +# Run with default settings +k6 run k6/load-test.js + +# Or with custom backend URL +BACKEND_URL=http://localhost:8000 k6 run k6/load-test.js + +# Via docker +docker run --rm -i --network spacepad-dev_app \ + -v $(pwd)/k6:/scripts \ + -e BACKEND_URL=http://app:8080 \ + -e CONNECT_CODE=100001 \ + grafana/k6 run /scripts/load-test.js +``` + +### Continuous Mode +```bash +# Set CONTINUOUS=true to enable continuous mode +CONTINUOUS=true BACKEND_URL=http://localhost:8000 k6 run k6/load-test.js + +# Via docker +docker run --rm -i --network spacepad-dev_app \ + -v $(pwd)/k6:/scripts \ + -e BACKEND_URL=http://app:8080 \ + -e CONNECT_CODE=100001 \ + -e CONTINUOUS=true \ + grafana/k6 run /scripts/load-test.js +``` + +## Configuration + +The script can be configured via environment variables: + +- `BACKEND_URL`: Backend API URL (default: `http://localhost:8000`) +- `CONNECT_CODE`: Connect code for authentication (default: `100001`) +- `CONTINUOUS`: Set to `true` or `1` to enable continuous mode (default: `false`) + +## Authentication + +The script automatically authenticates using the connect code system: +1. Each Virtual User (VU) authenticates once during setup using `/api/auth/login` with connect code `100001` +2. The authentication token is stored and reused for all API requests +3. The scripts fetch available displays and use the first display for testing +4. Includes retry logic (up to 3 attempts) for robust authentication +5. Automatic recovery if authentication is lost during execution + +## Traffic Patterns + +The script simulates realistic user behavior: + +- **Display Data API** (`/api/displays/{display}/data`): 70% of requests + - Most frequently used endpoint + - Requires authentication token + - Returns display calendar data and events + +- **Dashboard Page** (`/`): 30% of requests + - Web dashboard page + - Simulates user browsing + +Each request includes: +- Proper authentication headers (for API endpoints) +- Random think time (simulates user reading/thinking) +- Proper headers (User-Agent, Accept) +- OpenTelemetry trace correlation + +## Load Test Mode Stages + +When running in load test mode (default), the script follows these stages: +- Ramp up to 5 users (30s) +- Ramp up to 10 users (2m) +- Stay at 10 users (5m) +- Ramp up to 20 users (2m) +- Stay at 20 users (5m) +- Ramp down to 10 users (2m) +- Stay at 10 users (5m) + +## Continuous Mode + +When running in continuous mode (`CONTINUOUS=true`): +- Generates 2 requests per second +- Runs for 24 hours (effectively continuous) +- Pre-allocates 5 VUs, scales up to 20 VUs if needed +- Lower think time (0.5-2s) for higher throughput + +## Observing Traffic + +With k6 running, you can observe: + +1. **Grafana** (http://localhost:3000): + - Traces in Tempo showing request flows + - Metrics in Prometheus showing request rates, latencies + - Service maps showing service dependencies + - Logs in Loki showing application logs + +2. **Prometheus** (http://localhost:9090): + - Query: `rate(http_requests_total[1m])` + - Query: `histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))` + +3. **Tempo** (via Grafana): + - Search for traces from `spacepad-app` + - View trace details and spans + - Filter by route: `/api/displays/{display}/data` or `/` + +4. **Loki** (via Grafana): + - Search for logs from the application + - View log entries with trace context + +## Troubleshooting + +### Authentication Failures +- Ensure connect code `100001` exists and is valid +- Check that the user associated with the connect code has displays configured +- Verify the `BACKEND_URL` is correct +- The script includes retry logic, but check logs for persistent failures + +### No Displays Available +- The script will skip API calls if no displays are found +- Ensure the authenticated user has at least one display configured +- Check display status (should be READY or ACTIVE) + +### High Error Rates +- Check application logs for errors +- Verify database connectivity +- Ensure all required services are running +- Check network connectivity between k6 and the backend + +### VUs Failing Authentication +- The script retries authentication up to 3 times +- Check backend logs for authentication errors +- Verify the connect code is valid and not expired +- Ensure the backend is accessible from k6 + +## Customization + +Edit the script to: +- Change request rates (modify `rate` in continuous mode or `stages` in load test mode) +- Modify the endpoint distribution (currently 70% API, 30% dashboard) +- Add more endpoints +- Modify user behavior patterns +- Add custom metrics +- Change load patterns diff --git a/k6/load-test.js b/k6/load-test.js new file mode 100644 index 0000000..70ff6b1 --- /dev/null +++ b/k6/load-test.js @@ -0,0 +1,336 @@ +/** + * k6 Load Test Script for Spacepad + * + * Tests the dashboard page and the /api/displays/{display}/data endpoint + * Uses connect code 100001 for authentication + * + * Can run in two modes: + * - Load test mode (default): Variable load with stages + * - Continuous mode: Steady continuous load (set CONTINUOUS=true) + */ + +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Rate, Trend, Counter } from 'k6/metrics'; + +// Custom metrics +const errorRate = new Rate('errors'); +const requestDuration = new Trend('request_duration'); +const requestsCounter = new Counter('requests_total'); + +// Configuration +const CONTINUOUS_MODE = __ENV.CONTINUOUS === 'true' || __ENV.CONTINUOUS === '1'; +const BASE_URL = __ENV.BACKEND_URL || 'http://localhost:8000'; +const CONNECT_CODE = __ENV.CONNECT_CODE || '100001'; + +// Different execution patterns based on mode +export const options = CONTINUOUS_MODE ? { + scenarios: { + continuous_load: { + executor: 'constant-arrival-rate', + rate: 2, // 2 requests per second + timeUnit: '1s', + duration: '24h', // Run for 24 hours (effectively continuous) + preAllocatedVUs: 5, // Pre-allocate 5 VUs + maxVUs: 20, // Max 20 VUs if needed + }, + }, + thresholds: { + http_req_duration: ['p(95)<1000'], + http_req_failed: ['rate<0.1'], + }, +} : { + stages: [ + { duration: '30s', target: 5 }, // Ramp up to 5 users + { duration: '2m', target: 10 }, // Ramp up to 10 users + { duration: '5m', target: 10 }, // Stay at 10 users + { duration: '2m', target: 20 }, // Ramp up to 20 users + { duration: '5m', target: 20 }, // Stay at 20 users + { duration: '2m', target: 10 }, // Ramp down to 10 users + { duration: '5m', target: 10 }, // Stay at 10 users + ], + thresholds: { + http_req_duration: ['p(95)<500', 'p(99)<1000'], // 95% of requests under 500ms, 99% under 1s + http_req_failed: ['rate<0.05'], // Less than 5% errors + errors: ['rate<0.05'], + }, +}; + +// Shared state for VU - stores auth token and display ID +let authToken = null; +let displayId = null; + +// Setup function - runs once per VU to authenticate +export function setup() { + if (!CONTINUOUS_MODE) { + console.log(`Starting k6 load test against ${BASE_URL}`); + } + + // Authenticate once per VU with retry logic + const deviceUid = `k6-device-${__VU}-${Date.now()}`; + const deviceName = CONTINUOUS_MODE ? `k6-continuous-${__VU}` : `k6-load-test-${__VU}`; + + let token = null; + let displayIdToUse = null; + + // Retry authentication up to 3 times + for (let attempt = 1; attempt <= 3; attempt++) { + const loginResponse = http.post( + `${BASE_URL}/api/auth/login`, + JSON.stringify({ + code: CONNECT_CODE, + uid: deviceUid, + name: deviceName, + }), + { + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + tags: { + endpoint: '/api/auth/login', + test_type: 'setup', + }, + timeout: '10s', + } + ); + + const loginSuccess = check(loginResponse, { + 'login status is 200': (r) => r.status === 200, + 'login has token': (r) => { + try { + const body = JSON.parse(r.body); + return body.data && body.data.token !== undefined; + } catch { + return false; + } + }, + }); + + if (loginSuccess) { + try { + const loginBody = JSON.parse(loginResponse.body); + token = loginBody.data.token; + if (!CONTINUOUS_MODE) { + console.log(`VU ${__VU} authenticated successfully on attempt ${attempt}`); + } + break; + } catch (e) { + console.error(`VU ${__VU} failed to parse login response on attempt ${attempt}: ${e}`); + } + } else { + console.error(`VU ${__VU} authentication failed on attempt ${attempt}: ${loginResponse.status} - ${loginResponse.body}`); + if (attempt < 3) { + sleep(1); // Wait before retry + } + } + } + + if (!token) { + console.error(`VU ${__VU} failed to authenticate after 3 attempts`); + return { baseUrl: BASE_URL, token: null, displayId: null }; + } + + // Get displays list to find a display ID with retry + for (let attempt = 1; attempt <= 3; attempt++) { + const displaysResponse = http.get( + `${BASE_URL}/api/displays`, + { + headers: { + 'Authorization': `Bearer ${token}`, + 'Accept': 'application/json', + }, + tags: { + endpoint: '/api/displays', + test_type: 'setup', + }, + timeout: '10s', + } + ); + + const displaysSuccess = check(displaysResponse, { + 'displays status is 200': (r) => r.status === 200, + 'displays has data': (r) => { + try { + const body = JSON.parse(r.body); + return body.data && Array.isArray(body.data) && body.data.length > 0; + } catch { + return false; + } + }, + }); + + if (displaysSuccess) { + try { + const displaysBody = JSON.parse(displaysResponse.body); + if (displaysBody.data && displaysBody.data.length > 0) { + displayIdToUse = displaysBody.data[0].id; + if (!CONTINUOUS_MODE) { + console.log(`VU ${__VU} found display ID: ${displayIdToUse}`); + } + break; + } else { + console.warn(`VU ${__VU} authenticated but no displays available`); + } + } catch (e) { + console.error(`VU ${__VU} failed to parse displays response on attempt ${attempt}: ${e}`); + } + } else { + console.error(`VU ${__VU} failed to get displays on attempt ${attempt}: ${displaysResponse.status} - ${displaysResponse.body}`); + if (attempt < 3) { + sleep(1); // Wait before retry + } + } + } + + return { + baseUrl: BASE_URL, + token: token, + displayId: displayIdToUse, + }; +} + +// Main test function +export default function (data) { + // Use token and displayId from setup + authToken = data.token; + displayId = data.displayId; + + if (!authToken) { + // If no token, try to re-authenticate (might be a transient issue) + const deviceUid = `k6-device-${__VU}-${Date.now()}`; + const deviceName = CONTINUOUS_MODE ? `k6-continuous-${__VU}` : `k6-load-test-${__VU}`; + + const loginResponse = http.post( + `${BASE_URL}/api/auth/login`, + JSON.stringify({ + code: CONNECT_CODE, + uid: deviceUid, + name: deviceName, + }), + { + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + tags: { + endpoint: '/api/auth/login', + test_type: 'recovery', + }, + timeout: '10s', + } + ); + + if (loginResponse.status === 200) { + try { + const loginBody = JSON.parse(loginResponse.body); + authToken = loginBody.data.token; + data.token = authToken; // Update data for future iterations + if (!CONTINUOUS_MODE) { + console.log(`VU ${__VU} recovered authentication`); + } + } catch (e) { + console.error(`VU ${__VU} failed to parse recovery login response: ${e}`); + } + } + + if (!authToken) { + console.error(`VU ${__VU} has no auth token, skipping iteration`); + sleep(1); + return; + } + } + + // Weighted endpoint selection + // 70% of requests go to the display data endpoint (most used) + // 30% go to dashboard page + const useDisplayData = Math.random() < 0.7; + + let url, params, endpoint; + + if (useDisplayData && displayId) { + // Call the display data endpoint + endpoint = `/api/displays/${displayId}/data`; + url = `${BASE_URL}${endpoint}`; + params = { + headers: { + 'Authorization': `Bearer ${authToken}`, + 'Accept': 'application/json', + 'User-Agent': CONTINUOUS_MODE ? `k6-continuous/${__VU}` : `k6-load-test/${__VU}`, + }, + tags: { + endpoint: endpoint, + test_type: 'api', + load_type: CONTINUOUS_MODE ? 'continuous' : 'load_test', + }, + }; + } else { + // Call the dashboard page + endpoint = '/'; + url = `${BASE_URL}${endpoint}`; + params = { + headers: { + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'User-Agent': CONTINUOUS_MODE ? `k6-continuous/${__VU}` : `k6-load-test/${__VU}`, + }, + tags: { + endpoint: endpoint, + test_type: 'web', + load_type: CONTINUOUS_MODE ? 'continuous' : 'load_test', + }, + }; + } + + const startTime = Date.now(); + const response = http.get(url, params); + const duration = Date.now() - startTime; + + requestsCounter.add(1, { endpoint: endpoint }); + requestDuration.add(duration, { endpoint: endpoint }); + + const success = check(response, { + 'status is 200 or 302': (r) => r.status === 200 || r.status === 302, + 'response time < 2000ms': (r) => r.timings.duration < 2000, + }); + + // For API endpoints, also check for valid JSON + if (useDisplayData && displayId) { + const jsonCheck = check(response, { + 'has valid JSON': (r) => { + try { + JSON.parse(r.body); + return true; + } catch { + return false; + } + }, + 'has display data': (r) => { + try { + const body = JSON.parse(r.body); + return body.data !== undefined; + } catch { + return false; + } + }, + }); + errorRate.add(!success || !jsonCheck); + } else { + errorRate.add(!success); + } + + // Simulate user think time + // Continuous mode: 0.5-2 seconds, Load test mode: 1-3 seconds + const thinkTime = CONTINUOUS_MODE + ? Math.random() * 1.5 + 0.5 + : Math.random() * 2 + 1; + sleep(thinkTime); +} + +// Teardown function - runs once after all VUs finish +export function teardown(data) { + const mode = CONTINUOUS_MODE ? 'continuous load test' : 'load test'; + console.log(`${mode} completed for ${data.baseUrl}`); + if (data.displayId) { + console.log(`Tested display ID: ${data.displayId}`); + } +} diff --git a/k6/tags.js b/k6/tags.js new file mode 100644 index 0000000..99aef2e --- /dev/null +++ b/k6/tags.js @@ -0,0 +1,12 @@ +/** + * k6 StatsD Tags Script + * Adds custom tags to metrics for better observability + */ + +export function tags(data) { + return { + test_type: 'continuous_load', + service: 'spacepad-app', + }; +} + From 0be869b27b151fb3b42abff1c255f46b639b7ce3 Mon Sep 17 00:00:00 2001 From: Martijn van de Wetering Date: Wed, 31 Dec 2025 14:12:55 +0100 Subject: [PATCH 06/19] Add extra logging --- backend/app/Exceptions/Handler.php | 20 +++ .../Http/Controllers/API/ApiController.php | 14 +++ .../Controllers/API/Auth/AuthController.php | 24 +++- .../Controllers/API/DisplayController.php | 114 ++++++++++++++++++ .../Http/Controllers/DashboardController.php | 16 ++- 5 files changed, 183 insertions(+), 5 deletions(-) diff --git a/backend/app/Exceptions/Handler.php b/backend/app/Exceptions/Handler.php index 7855026..967363b 100644 --- a/backend/app/Exceptions/Handler.php +++ b/backend/app/Exceptions/Handler.php @@ -37,6 +37,26 @@ public function register(): void */ public function render($request, Throwable $e): Response|JsonResponse|\Symfony\Component\HttpFoundation\Response { + // Log exceptions with context (skip 404s and validation errors to avoid noise) + if (!($e instanceof NotFoundHttpException) && !($e instanceof ValidationException)) { + $logLevel = $e instanceof AuthenticationException ? 'warning' : 'error'; + + logger()->{$logLevel}('Unhandled exception', [ + 'exception' => get_class($e), + 'message' => $e->getMessage(), + 'code' => $e->getCode(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'route' => $request->route()?->getName(), + 'path' => $request->path(), + 'method' => $request->method(), + 'ip' => $request->ip(), + 'user_id' => auth()->id(), + 'user_agent' => substr($request->userAgent() ?? '', 0, 200), + 'trace' => config('app.debug') ? substr($e->getTraceAsString(), 0, 1000) : null, + ]); + } + if ($request->expectsJson()) { $status = 500; $message = 'Server Error'; diff --git a/backend/app/Http/Controllers/API/ApiController.php b/backend/app/Http/Controllers/API/ApiController.php index 3c09b1a..bbcebe1 100644 --- a/backend/app/Http/Controllers/API/ApiController.php +++ b/backend/app/Http/Controllers/API/ApiController.php @@ -18,6 +18,20 @@ protected function success(string $message = 'Success', mixed $data = null, int protected function error(string $message = 'Error', mixed $errors = null, int $code = 400): JsonResponse { + // Log API errors for observability (skip 404s and auth errors to avoid noise) + if ($code >= 500 || ($code >= 400 && $code < 404)) { + logger()->warning('API error response', [ + 'message' => $message, + 'code' => $code, + 'errors' => $errors, + 'route' => request()->route()?->getName(), + 'path' => request()->path(), + 'method' => request()->method(), + 'ip' => request()->ip(), + 'user_id' => auth()->id(), + ]); + } + return response()->json([ 'success' => false, 'message' => $message, diff --git a/backend/app/Http/Controllers/API/Auth/AuthController.php b/backend/app/Http/Controllers/API/Auth/AuthController.php index fcf9bf0..9bb5dc8 100644 --- a/backend/app/Http/Controllers/API/Auth/AuthController.php +++ b/backend/app/Http/Controllers/API/Auth/AuthController.php @@ -26,17 +26,28 @@ public function __construct(protected OutlookService $outlookService) public function login(LoginRequest $request): JsonResponse { $code = $request->validated()['code']; + $uid = $request->validated()['uid']; + $name = $request->validated()['name'] ?? 'Unknown'; $connectedUserId = cache()->get("connect-code:$code"); // Check if the code is a valid connect code if ($connectedUserId !== null) { $device = Device::firstOrCreate([ 'user_id' => $connectedUserId, - 'uid' => $request->validated()['uid'], + 'uid' => $uid, ],[ 'user_id' => $connectedUserId, - 'uid' => $request->validated()['uid'], - 'name' => $request->validated()['name'], + 'uid' => $uid, + 'name' => $name, + ]); + + logger()->info('Device authentication successful', [ + 'user_id' => $connectedUserId, + 'device_id' => $device->id, + 'device_uid' => substr($uid, 0, 8) . '...', + 'device_name' => $name, + 'ip' => $request->ip(), + 'user_agent' => substr($request->userAgent() ?? '', 0, 100), ]); return $this->success( @@ -47,6 +58,13 @@ public function login(LoginRequest $request): JsonResponse ); } + logger()->warning('Device authentication failed - invalid connect code', [ + 'code_prefix' => substr($code, 0, 3) . '...', + 'device_uid' => substr($uid, 0, 8) . '...', + 'ip' => $request->ip(), + 'user_agent' => substr($request->userAgent() ?? '', 0, 100), + ]); + return $this->error( message: 'Code is incorrect.', errors: [ diff --git a/backend/app/Http/Controllers/API/DisplayController.php b/backend/app/Http/Controllers/API/DisplayController.php index 4300f8c..4bc108e 100644 --- a/backend/app/Http/Controllers/API/DisplayController.php +++ b/backend/app/Http/Controllers/API/DisplayController.php @@ -36,6 +36,13 @@ public function index(): JsonResponse ->with('settings') ->get(); + logger()->info('Display list requested', [ + 'user_id' => $device->user_id, + 'device_id' => $device->id, + 'display_count' => $displays->count(), + 'ip' => request()->ip(), + ]); + return $this->success(data: DisplayResource::collection($displays)); } @@ -46,17 +53,45 @@ public function getData(string $displayId): JsonResponse $permission = $this->displayService->validateDisplayPermission($displayId, $device->id); if (! $permission->permitted) { + logger()->warning('Display data access denied', [ + 'user_id' => $device->user_id, + 'device_id' => $device->id, + 'display_id' => $displayId, + 'reason' => $permission->message, + 'ip' => request()->ip(), + ]); return $this->error(message: $permission->message, code: $permission->code); } try { + $startTime = microtime(true); $display = $this->displayService->getDisplay($displayId); $events = $this->eventService->getEventsForDisplay($displayId); + $duration = round((microtime(true) - $startTime) * 1000, 2); + + logger()->info('Display data retrieved successfully', [ + 'user_id' => $device->user_id, + 'device_id' => $device->id, + 'display_id' => $displayId, + 'display_name' => $display->name ?? 'Unknown', + 'event_count' => count($events), + 'duration_ms' => $duration, + 'ip' => request()->ip(), + ]); + return $this->success(data: DisplayDataResource::make([ 'display' => $display, 'events' => $events, ])); } catch (\Exception $e) { + logger()->error('Failed to fetch display data', [ + 'user_id' => $device->user_id, + 'device_id' => $device->id, + 'display_id' => $displayId, + 'error' => $e->getMessage(), + 'trace' => substr($e->getTraceAsString(), 0, 500), + 'ip' => request()->ip(), + ]); report($e); return $this->error(message: 'Something went wrong while fetching display data. Please try again later.', code: 500); } @@ -83,6 +118,17 @@ public function book(BookEventRequest $request, string $displayId): JsonResponse $end = isset($data['end']) ? Carbon::parse($data['end'])->utc() : null; $duration = isset($data['duration']) ? (int) $data['duration'] : null; + logger()->info('Room booking requested', [ + 'user_id' => $device->user_id, + 'device_id' => $device->id, + 'display_id' => $displayId, + 'start' => $start?->toIso8601String(), + 'end' => $end?->toIso8601String(), + 'duration' => $duration, + 'summary' => Arr::get($data, 'summary', __('Reserved')), + 'ip' => request()->ip(), + ]); + $event = $this->eventService->bookRoom( displayId: $displayId, userId: $device->user_id, @@ -92,8 +138,24 @@ public function book(BookEventRequest $request, string $displayId): JsonResponse end: $end ); + logger()->info('Room booked successfully', [ + 'user_id' => $device->user_id, + 'device_id' => $device->id, + 'display_id' => $displayId, + 'event_id' => $event->id ?? null, + 'ip' => request()->ip(), + ]); + return $this->success(data: new EventResource($event), code: 201); } catch (\Exception $e) { + logger()->error('Room booking failed', [ + 'user_id' => $device->user_id, + 'device_id' => $device->id, + 'display_id' => $displayId, + 'error' => $e->getMessage(), + 'error_code' => $e->getCode(), + 'ip' => request()->ip(), + ]); report($e); $status = $e->getCode() === 403 ? 403 : 400; return $this->error(message: 'Room could not be booked. There may be conflicting events during this time period. Please try a different time or duration.', code: $status); @@ -114,9 +176,35 @@ public function checkIn(string $displayId, string $eventId): JsonResponse } try { + logger()->info('Event check-in requested', [ + 'user_id' => $device->user_id, + 'device_id' => $device->id, + 'display_id' => $displayId, + 'event_id' => $eventId, + 'ip' => request()->ip(), + ]); + $this->eventService->checkInToEvent($eventId, $displayId); + + logger()->info('Event check-in successful', [ + 'user_id' => $device->user_id, + 'device_id' => $device->id, + 'display_id' => $displayId, + 'event_id' => $eventId, + 'ip' => request()->ip(), + ]); + return $this->success(message: 'Checked in successfully'); } catch (\Exception $e) { + logger()->error('Event check-in failed', [ + 'user_id' => $device->user_id, + 'device_id' => $device->id, + 'display_id' => $displayId, + 'event_id' => $eventId, + 'error' => $e->getMessage(), + 'error_code' => $e->getCode(), + 'ip' => request()->ip(), + ]); $status = $e->getCode() === 403 ? 403 : 400; return $this->error(message: 'Could not check in to event. Please try again later.', code: $status); } @@ -136,9 +224,35 @@ public function cancel(string $displayId, string $eventId): JsonResponse } try { + logger()->info('Event cancellation requested', [ + 'user_id' => $device->user_id, + 'device_id' => $device->id, + 'display_id' => $displayId, + 'event_id' => $eventId, + 'ip' => request()->ip(), + ]); + $this->eventService->cancelEvent($eventId, $displayId); + + logger()->info('Event cancelled successfully', [ + 'user_id' => $device->user_id, + 'device_id' => $device->id, + 'display_id' => $displayId, + 'event_id' => $eventId, + 'ip' => request()->ip(), + ]); + return $this->success(message: 'Event cancelled successfully'); } catch (\Exception $e) { + logger()->error('Event cancellation failed', [ + 'user_id' => $device->user_id, + 'device_id' => $device->id, + 'display_id' => $displayId, + 'event_id' => $eventId, + 'error' => $e->getMessage(), + 'error_code' => $e->getCode(), + 'ip' => request()->ip(), + ]); $status = $e->getCode() === 403 ? 403 : 400; return $this->error(message: 'Event could not be cancelled. Please try again later.', code: $status); } diff --git a/backend/app/Http/Controllers/DashboardController.php b/backend/app/Http/Controllers/DashboardController.php index 85f9a2b..6d7b870 100644 --- a/backend/app/Http/Controllers/DashboardController.php +++ b/backend/app/Http/Controllers/DashboardController.php @@ -19,8 +19,20 @@ public function __construct(protected OutlookService $outlookService) */ public function __invoke(): View|Factory|Application { - $connectCode = auth()->user()->getConnectCode(); - $user = auth()->user()->load(['outlookAccounts', 'googleAccounts', 'caldavAccounts', 'displays']); + $user = auth()->user(); + $connectCode = $user->getConnectCode(); + $user->load(['outlookAccounts', 'googleAccounts', 'caldavAccounts', 'displays']); + + logger()->info('Dashboard page accessed', [ + 'user_id' => $user->id, + 'email' => $user->email, + 'outlook_accounts_count' => $user->outlookAccounts->count(), + 'google_accounts_count' => $user->googleAccounts->count(), + 'caldav_accounts_count' => $user->caldavAccounts->count(), + 'displays_count' => $user->displays->count(), + 'ip' => request()->ip(), + 'user_agent' => substr(request()->userAgent() ?? '', 0, 100), + ]); return view('pages.dashboard', [ 'outlookAccounts' => $user->outlookAccounts, From aaf9af8bf5e0284557987e13785aebb45719daf7 Mon Sep 17 00:00:00 2001 From: Martijn van de Wetering Date: Sat, 10 Jan 2026 17:13:22 +0100 Subject: [PATCH 07/19] bugfix: Only create subscriptions once --- backend/app/Services/GoogleService.php | 33 ++++++++++++++--- backend/app/Services/OutlookService.php | 48 ++++++++++++++++++------- 2 files changed, 64 insertions(+), 17 deletions(-) diff --git a/backend/app/Services/GoogleService.php b/backend/app/Services/GoogleService.php index 5692eb1..e30c76d 100644 --- a/backend/app/Services/GoogleService.php +++ b/backend/app/Services/GoogleService.php @@ -376,10 +376,11 @@ public function createEventSubscription( $response = $calendarService->events->watch($calendarId, $channel); if (!$response->getId()) { - logger()->error('Creating Google subscription failed', [ - 'response' => $response + logger()->error('Creating Google subscription failed - no subscription ID returned', [ + 'response' => $response, ]); - return null; + // This is likely a user error (invalid calendar, permissions, etc.) + throw new Exception("Failed to create Google subscription: No subscription ID returned"); } // Create the subscription record in the database @@ -397,11 +398,33 @@ public function createEventSubscription( return $eventSubscription; } catch (Exception $e) { - report($e); + // Re-throw if it's already a user error exception we just created + if (str_contains($e->getMessage(), 'Failed to create Google subscription')) { + throw $e; + } + + // Check if this is a Google API exception with HTTP status code + $statusCode = $e->getCode(); + $isUserError = $statusCode >= 400 && $statusCode < 500; + + // Check exception class name for Google API exceptions + $exceptionClass = get_class($e); + logger()->error('Error creating Google subscription', [ 'error' => $e->getMessage(), - 'calendarId' => $calendarId + 'calendarId' => $calendarId, + 'status_code' => $statusCode, + 'is_user_error' => $isUserError, + 'exception_type' => $exceptionClass, ]); + + // Throw exception for user errors (4xx) so the command can handle it + // Return null for server errors (5xx) or connection errors to avoid marking display as error + if ($isUserError) { + throw new Exception("Failed to create Google subscription: HTTP {$statusCode} - " . $e->getMessage()); + } + + // For connection errors, timeouts, etc., don't throw - these are transient return null; } } diff --git a/backend/app/Services/OutlookService.php b/backend/app/Services/OutlookService.php index 9a28ddf..772a141 100644 --- a/backend/app/Services/OutlookService.php +++ b/backend/app/Services/OutlookService.php @@ -444,18 +444,42 @@ private function createEventSubscription( 'data' => $data ]); - // Create a subscription with Microsoft Graph - $response = Http::withToken($outlookAccount->token) - ->post("https://graph.microsoft.com/v1.0/subscriptions", $data); - - $responseBody = $response->json(); - if ( - $response->failed() || - !Arr::has($responseBody, ['id', 'resource', 'expirationDateTime', 'notificationUrl']) - ) { - logger()->error('Creating outlook subscription failed', [ - 'statuscode' => $response->status(), - 'response' => $responseBody + try { + // Create a subscription with Microsoft Graph + $response = Http::withToken($outlookAccount->token) + ->post("https://graph.microsoft.com/v1.0/subscriptions", $data); + + $responseBody = $response->json(); + if ( + $response->failed() || + !Arr::has($responseBody, ['id', 'resource', 'expirationDateTime', 'notificationUrl']) + ) { + $statusCode = $response->status(); + $isUserError = $statusCode >= 400 && $statusCode < 500; + + logger()->error('Creating outlook subscription failed', [ + 'statuscode' => $statusCode, + 'response' => $responseBody, + 'is_user_error' => $isUserError, + ]); + + // Throw exception for user errors (4xx) so the command can handle it + // Return null for server errors (5xx) to avoid marking display as error + if ($isUserError) { + throw new Exception("Failed to create Outlook subscription: HTTP {$statusCode} - " . ($responseBody['error']['message'] ?? $responseBody['message'] ?? 'Unknown error')); + } + + return null; + } + } catch (Exception $e) { + // Re-throw if it's already a user error exception we just created + if (str_contains($e->getMessage(), 'Failed to create Outlook subscription')) { + throw $e; + } + // For connection errors, timeouts, etc., don't throw - these are transient + logger()->error('Error creating outlook subscription - connection/timeout error', [ + 'error' => $e->getMessage(), + 'exception_type' => get_class($e), ]); return null; } From 18599b7076d051172dbef034b6393055456ab538 Mon Sep 17 00:00:00 2001 From: Martijn van de Wetering Date: Sat, 10 Jan 2026 18:20:48 +0100 Subject: [PATCH 08/19] feat: Add workspaces for users --- backend/WORKSPACE_SETUP.md | 111 +++++++++ backend/app/Enums/WorkspaceRole.php | 28 +++ .../Controllers/API/Auth/AuthController.php | 10 + .../Controllers/API/DisplayController.php | 7 +- .../app/Http/Controllers/AdminController.php | 217 ++++++++++++++++++ .../Http/Controllers/DisplayController.php | 44 +++- .../Http/Controllers/WorkspaceController.php | 191 +++++++++++++++ backend/app/Models/Calendar.php | 6 + backend/app/Models/Device.php | 6 + backend/app/Models/Display.php | 6 + backend/app/Models/Room.php | 6 + backend/app/Models/User.php | 61 +++++ backend/app/Models/Workspace.php | 118 ++++++++++ backend/app/Models/WorkspaceMember.php | 43 ++++ backend/app/Policies/DisplayPolicy.php | 21 +- backend/app/Services/DisplayService.php | 47 +++- ...5_12_30_000000_create_workspaces_table.php | 29 +++ ..._000001_create_workspace_members_table.php | 34 +++ ...2_30_000002_add_workspace_id_to_tables.php | 61 +++++ ...3_create_workspaces_for_existing_users.php | 68 ++++++ backend/docs/WORKSPACE_SETUP.md | 111 +++++++++ backend/resources/views/pages/admin.blade.php | 111 ++++++++- .../views/pages/admin/user.blade.php | 208 +++++++++++++++++ .../resources/views/pages/dashboard.blade.php | 3 +- backend/routes/web.php | 2 + 25 files changed, 1529 insertions(+), 20 deletions(-) create mode 100644 backend/WORKSPACE_SETUP.md create mode 100644 backend/app/Enums/WorkspaceRole.php create mode 100644 backend/app/Http/Controllers/WorkspaceController.php create mode 100644 backend/app/Models/Workspace.php create mode 100644 backend/app/Models/WorkspaceMember.php create mode 100644 backend/database/migrations/2025_12_30_000000_create_workspaces_table.php create mode 100644 backend/database/migrations/2025_12_30_000001_create_workspace_members_table.php create mode 100644 backend/database/migrations/2025_12_30_000002_add_workspace_id_to_tables.php create mode 100644 backend/database/migrations/2025_12_30_000003_create_workspaces_for_existing_users.php create mode 100644 backend/docs/WORKSPACE_SETUP.md create mode 100644 backend/resources/views/pages/admin/user.blade.php diff --git a/backend/WORKSPACE_SETUP.md b/backend/WORKSPACE_SETUP.md new file mode 100644 index 0000000..5b9e354 --- /dev/null +++ b/backend/WORKSPACE_SETUP.md @@ -0,0 +1,111 @@ +# Workspace System Documentation + +## Overview + +The workspace system allows multiple users to collaborate on managing displays, devices, calendars, and rooms. Each user automatically gets their own workspace, and Pro users can invite colleagues to join their workspace. + +## Architecture + +### Models + +1. **Workspace** - Represents a team/workspace + - Has an `owner` (User) + - Has many `members` (Users with roles) + - Contains displays, devices, calendars, rooms + +2. **WorkspaceMember** - Pivot table linking users to workspaces + - Roles: `owner`, `admin`, `member` + - `owner` role is implicit for the workspace owner + +### Relationships + +- **User** → **Workspace** (one-to-many: owned workspaces) +- **User** ↔ **Workspace** (many-to-many: member workspaces) +- **Workspace** → **Display** (one-to-many) +- **Workspace** → **Device** (one-to-many) +- **Workspace** → **Calendar** (one-to-many) +- **Workspace** → **Room** (one-to-many) + +## Migration Strategy + +1. **Existing Users**: Each user automatically gets a workspace created with their name +2. **Existing Data**: All displays, devices, calendars, and rooms are migrated to the user's workspace +3. **Backward Compatibility**: The `user_id` field is kept for backward compatibility + +## Permissions + +### Workspace Roles + +- **Owner**: Full control (can delete workspace, manage all members) +- **Admin**: Can manage members and workspace settings +- **Member**: Can view and use workspace resources + +### Display Access + +- Users can access displays they own directly (`user_id`) +- Users can access displays in workspaces they're members of (`workspace_id`) +- Device authentication checks workspace membership + +## Usage + +### Adding a Colleague + +1. Navigate to workspace settings (requires Pro) +2. Enter colleague's email address +3. Select role (admin or member) +4. Colleague receives access to all workspace resources + +### Managing Members + +- **Add Member**: Only owners/admins can add members +- **Update Role**: Change member role between admin/member +- **Remove Member**: Remove access from workspace + +## API Changes + +### DisplayController + +- `index()` now returns displays from user's workspace(s) +- Access checks include workspace membership + +### DisplayService + +- `validateDisplayPermission()` checks workspace membership +- Pro features check workspace owner's Pro status + +## Frontend Changes Needed + +1. **Workspace Management UI** + - List workspaces + - View workspace members + - Add/remove members + - Update member roles + +2. **Display Creation** + - Automatically assign to user's primary workspace + - Allow selecting workspace (if user has multiple) + +3. **Device Connection** + - Connect code should work with workspace + - Devices inherit workspace from user + +## Migration Commands + +Run migrations in order: + +```bash +php artisan migrate +``` + +The migration `2025_12_30_000003_create_workspaces_for_existing_users.php` will: +1. Create a workspace for each existing user +2. Migrate all user's displays, devices, calendars, and rooms to their workspace +3. Add the user as an owner member + +## Notes + +- Pro subscription is required to add team members +- Workspace owner cannot be removed +- All existing functionality remains backward compatible +- `user_id` fields are kept for direct ownership tracking + diff --git a/backend/app/Enums/WorkspaceRole.php b/backend/app/Enums/WorkspaceRole.php new file mode 100644 index 0000000..35a8e3c --- /dev/null +++ b/backend/app/Enums/WorkspaceRole.php @@ -0,0 +1,28 @@ + 'Owner', + self::ADMIN => 'Admin', + self::MEMBER => 'Member', + }; + } + + /** + * Check if this role can manage the workspace + */ + public function canManage(): bool + { + return in_array($this, [self::OWNER, self::ADMIN]); + } +} + diff --git a/backend/app/Http/Controllers/API/Auth/AuthController.php b/backend/app/Http/Controllers/API/Auth/AuthController.php index 9bb5dc8..44fc6a0 100644 --- a/backend/app/Http/Controllers/API/Auth/AuthController.php +++ b/backend/app/Http/Controllers/API/Auth/AuthController.php @@ -6,6 +6,7 @@ use App\Http\Requests\API\Auth\LoginRequest; use App\Http\Resources\API\DeviceResource; use App\Models\Device; +use App\Models\User; use App\Services\OutlookService; use Illuminate\Http\JsonResponse; use Illuminate\Validation\ValidationException; @@ -32,15 +33,24 @@ public function login(LoginRequest $request): JsonResponse // Check if the code is a valid connect code if ($connectedUserId !== null) { + $user = User::find($connectedUserId); + $workspace = $user?->primaryWorkspace(); + $device = Device::firstOrCreate([ 'user_id' => $connectedUserId, 'uid' => $uid, ],[ 'user_id' => $connectedUserId, + 'workspace_id' => $workspace?->id, 'uid' => $uid, 'name' => $name, ]); + // Update workspace_id if device already existed but didn't have one + if ($device->workspace_id === null && $workspace) { + $device->update(['workspace_id' => $workspace->id]); + } + logger()->info('Device authentication successful', [ 'user_id' => $connectedUserId, 'device_id' => $device->id, diff --git a/backend/app/Http/Controllers/API/DisplayController.php b/backend/app/Http/Controllers/API/DisplayController.php index 4bc108e..6a2eda9 100644 --- a/backend/app/Http/Controllers/API/DisplayController.php +++ b/backend/app/Http/Controllers/API/DisplayController.php @@ -30,8 +30,13 @@ public function index(): JsonResponse /** @var Device $device */ $device = auth()->user(); + if (!$device->workspace_id) { + return $this->success(data: []); + } + + // Get displays from device's workspace $displays = Display::query() - ->where('user_id', $device->user_id) + ->where('workspace_id', $device->workspace_id) ->whereIn('status', [DisplayStatus::READY, DisplayStatus::ACTIVE]) ->with('settings') ->get(); diff --git a/backend/app/Http/Controllers/AdminController.php b/backend/app/Http/Controllers/AdminController.php index 5c48485..2a222ba 100644 --- a/backend/app/Http/Controllers/AdminController.php +++ b/backend/app/Http/Controllers/AdminController.php @@ -6,9 +6,13 @@ use App\Models\Display; use App\Models\Instance; use App\Models\User; +use App\Models\Workspace; +use App\Models\WorkspaceMember; use Illuminate\Http\Request; +use Illuminate\Http\RedirectResponse; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Http; class AdminController extends Controller @@ -110,10 +114,17 @@ public function index() return $user; }); + // All users for the users overview tab + $allUsers = User::query() + ->withCount('displays') + ->orderBy('created_at', 'desc') + ->get(); + return view('pages.admin', [ 'activeInstances' => $activeInstances, 'activeDisplays' => $activeDisplays, 'payingUsers' => $payingUsers, + 'allUsers' => $allUsers, 'activeDisplaysCount' => $activeDisplays->count(), 'totalDisplays' => $totalDisplays, 'activeInstancesCount' => $activeInstances->count(), @@ -313,4 +324,210 @@ private function getSubscriptionPrice(string $subscriptionId, int $displaysCount return null; } } + + /** + * Show user details page + */ + public function showUser(User $user) + { + $admin = Auth::user(); + if (!$admin || !$admin->isAdmin() || config('settings.is_self_hosted')) { + abort(403); + } + + // Load user relationships for display + $user->load([ + 'outlookAccounts', + 'googleAccounts', + 'caldavAccounts', + 'displays', + 'devices', + 'workspaces', + 'subscriptions' => function($query) { + $query->where(function($q) { + $q->whereNull('ends_at') + ->orWhere('ends_at', '>', now()); + })->orderByDesc('created_at'); + }, + ]); + + // Get subscription info + $subscriptionInfo = null; + if (!$user->is_unlimited && $user->subscriptions->isNotEmpty()) { + $subscription = $user->subscriptions->first(); + $subscriptionData = $this->getSubscriptionData($subscription->lemon_squeezy_id, $user->displays_count); + if ($subscriptionData) { + $subscriptionInfo = [ + 'status' => $subscriptionData['status'] ?? null, + 'price' => $subscriptionData['price'] ?? 0, + 'mrr' => ($subscriptionData['price'] ?? 0) * $user->displays_count, + 'ends_at' => $subscription->ends_at, + ]; + } + } + + return view('pages.admin.user', [ + 'user' => $user, + 'subscriptionInfo' => $subscriptionInfo, + ]); + } + + /** + * Delete a user account and all associated data + */ + public function deleteUser(Request $request, User $user): RedirectResponse + { + $admin = Auth::user(); + if (!$admin || !$admin->isAdmin() || config('settings.is_self_hosted')) { + abort(403); + } + + // Prevent deleting yourself + if ($user->id === $admin->id) { + return redirect()->route('admin.index') + ->with('error', 'You cannot delete your own account.'); + } + + // Confirm deletion + $request->validate([ + 'confirm_email' => ['required', 'email'], + ]); + + if ($request->input('confirm_email') !== $user->email) { + return back()->withErrors(['confirm_email' => 'Email confirmation does not match.']); + } + + DB::transaction(function () use ($user, $admin) { + // Delete all user's personal access tokens + $user->tokens()->delete(); + + // Delete displays and their related data first (before calendars/accounts) + if ($user->displays) { + foreach ($user->displays as $display) { + // Delete event subscriptions + $display->eventSubscriptions()->delete(); + // Delete display settings + $display->settings()->delete(); + // Delete events associated with this display + $display->events()->delete(); + // Delete devices associated with this display + $display->devices()->delete(); + $display->delete(); + } + } + + // Delete devices (standalone devices not linked to displays) + $user->devices()->delete(); + + // Delete rooms + $user->rooms()->delete(); + + // Delete Outlook accounts and their calendars/events + if ($user->outlookAccounts) { + foreach ($user->outlookAccounts as $account) { + if ($account->calendars) { + foreach ($account->calendars as $calendar) { + $calendar->events()->delete(); + $calendar->delete(); + } + } + $account->delete(); + } + } + + // Delete Google accounts and their calendars/events + if ($user->googleAccounts) { + foreach ($user->googleAccounts as $account) { + if ($account->calendars) { + foreach ($account->calendars as $calendar) { + $calendar->events()->delete(); + $calendar->delete(); + } + } + $account->delete(); + } + } + + // Delete CalDAV accounts and their calendars/events + if ($user->caldavAccounts) { + foreach ($user->caldavAccounts as $account) { + if ($account->calendars) { + foreach ($account->calendars as $calendar) { + $calendar->events()->delete(); + $calendar->delete(); + } + } + $account->delete(); + } + } + + // Delete any remaining calendars directly linked to user (shouldn't happen, but safety check) + // Note: Calendars are linked through accounts, not directly to users, so this is unlikely + // Events are deleted through calendars above + + // Handle workspaces + $ownedWorkspaces = $user->ownedWorkspaces()->get(); + foreach ($ownedWorkspaces as $workspace) { + // Get other members (excluding the user being deleted) + $otherMembers = $workspace->members()->where('user_id', '!=', $user->id)->get(); + + if ($otherMembers->isNotEmpty()) { + // Find first admin or first member to transfer ownership + $newOwner = $otherMembers->first(function ($member) { + return $member->pivot->role === \App\Enums\WorkspaceRole::ADMIN->value; + }) ?? $otherMembers->first(); + + if ($newOwner) { + // Transfer ownership + WorkspaceMember::where('workspace_id', $workspace->id) + ->where('user_id', $newOwner->id) + ->update(['role' => \App\Enums\WorkspaceRole::OWNER]); + } + } else { + // No other members, delete the workspace and all its data + foreach ($workspace->displays as $display) { + $display->eventSubscriptions()->delete(); + $display->settings()->delete(); + $display->events()->delete(); + $display->devices()->delete(); + $display->delete(); + } + $workspace->devices()->delete(); + foreach ($workspace->calendars as $calendar) { + $calendar->events()->delete(); + $calendar->delete(); + } + $workspace->rooms()->delete(); + WorkspaceMember::where('workspace_id', $workspace->id)->delete(); + $workspace->delete(); + } + } + + // Delete workspace memberships (user's membership in workspaces they don't own) + WorkspaceMember::where('user_id', $user->id)->delete(); + + // Delete instance if exists + Instance::where('user_id', $user->id)->delete(); + + // Cancel LemonSqueezy subscriptions (if any) + // Note: This doesn't actually cancel them in LemonSqueezy, just removes the local reference + // You might want to add API call to cancel subscriptions + if (method_exists($user, 'subscriptions')) { + $user->subscriptions()->delete(); + } + + // Finally, delete the user + $user->delete(); + + logger()->info('User account deleted by admin', [ + 'deleted_user_id' => $user->id, + 'deleted_user_email' => $user->email, + 'deleted_by_admin_id' => $admin->id, + 'deleted_by_admin_email' => $admin->email, + ]); + }); + + return redirect()->route('admin.index') + ->with('success', "User account {$user->email} and all associated data have been permanently deleted."); + } } diff --git a/backend/app/Http/Controllers/DisplayController.php b/backend/app/Http/Controllers/DisplayController.php index 090888f..c996dba 100644 --- a/backend/app/Http/Controllers/DisplayController.php +++ b/backend/app/Http/Controllers/DisplayController.php @@ -68,12 +68,20 @@ public function store(CreateDisplayRequest $request): RedirectResponse default => throw new \InvalidArgumentException('Invalid provider') }; - $display = DB::transaction(function () use ($validatedData) { + $user = auth()->user(); + $workspace = $user->primaryWorkspace(); + + if (!$workspace) { + return redirect()->back()->with('error', 'No workspace found. Please contact support.'); + } + + $display = DB::transaction(function () use ($validatedData, $workspace) { // Handle room or calendar selection - $calendar = $this->createCalendar($validatedData); + $calendar = $this->createCalendar($validatedData, $workspace); return Display::create([ 'user_id' => auth()->id(), + 'workspace_id' => $workspace->id, 'name' => $validatedData['name'], 'display_name' => $validatedData['displayName'], 'status' => DisplayStatus::READY, @@ -127,10 +135,11 @@ public function delete(Display $display): RedirectResponse ->with('status', 'Display has successfully been deleted.'); } - private function createCalendar(array $validatedData): Calendar + private function createCalendar(array $validatedData, $workspace): Calendar { $provider = $validatedData['provider']; $accountId = $validatedData['account']; + $userId = auth()->id(); if (isset($validatedData['room'])) { $roomData = explode(',', $validatedData['room']); @@ -139,20 +148,27 @@ private function createCalendar(array $validatedData): Calendar $calendar = Calendar::firstOrCreate([ 'calendar_id' => $calendarId, - 'user_id' => auth()->id(), + 'user_id' => $userId, ], [ 'calendar_id' => $calendarId, - 'user_id' => auth()->id(), + 'user_id' => $userId, + 'workspace_id' => $workspace->id, "{$provider}_account_id" => $accountId, 'name' => $calendarName, ]); + // Update workspace_id if calendar already existed + if (!$calendar->workspace_id) { + $calendar->update(['workspace_id' => $workspace->id]); + } + Room::firstOrCreate([ 'email_address' => $calendarId, - 'user_id' => auth()->id(), + 'user_id' => $userId, ], [ 'email_address' => $calendarId, - 'user_id' => auth()->id(), + 'user_id' => $userId, + 'workspace_id' => $workspace->id, 'calendar_id' => $calendar->id, 'name' => $calendarName, ]); @@ -163,15 +179,23 @@ private function createCalendar(array $validatedData): Calendar $calendarData = explode(',', $validatedData['calendar']); $calendarName = $this->extractCalendarName($calendarData[1] ?? ''); - return Calendar::firstOrCreate([ + $calendar = Calendar::firstOrCreate([ 'calendar_id' => $calendarData[0], - 'user_id' => auth()->id(), + 'user_id' => $userId, ], [ - 'user_id' => auth()->id(), + 'user_id' => $userId, + 'workspace_id' => $workspace->id, "{$provider}_account_id" => $accountId, 'calendar_id' => $calendarData[0], 'name' => $calendarName, ]); + + // Update workspace_id if calendar already existed + if (!$calendar->workspace_id) { + $calendar->update(['workspace_id' => $workspace->id]); + } + + return $calendar; } /** diff --git a/backend/app/Http/Controllers/WorkspaceController.php b/backend/app/Http/Controllers/WorkspaceController.php new file mode 100644 index 0000000..aeecd92 --- /dev/null +++ b/backend/app/Http/Controllers/WorkspaceController.php @@ -0,0 +1,191 @@ +user(); + $workspaces = $user->accessibleWorkspaces(); + $primaryWorkspace = $user->primaryWorkspace(); + + return view('pages.workspaces.index', [ + 'workspaces' => $workspaces, + 'primaryWorkspace' => $primaryWorkspace, + ]); + } + + /** + * Show workspace members page + */ + public function show(Workspace $workspace) + { + $user = auth()->user(); + + // Check if user has access to this workspace + if (!$workspace->hasMember($user)) { + abort(403, 'You do not have access to this workspace'); + } + + $workspace->load('members'); + + return view('pages.workspaces.show', [ + 'workspace' => $workspace, + 'userRole' => $workspace->getUserRole($user), + ]); + } + + /** + * Add a member to the workspace + */ + public function addMember(Request $request, Workspace $workspace): RedirectResponse + { + $user = auth()->user(); + + // Only workspace owners/admins can add members + if (!$workspace->canBeManagedBy($user)) { + abort(403, 'You do not have permission to add members to this workspace'); + } + + // Check if user has Pro (required for team features) + if (!$user->hasPro()) { + return back()->withErrors(['error' => 'Pro subscription is required to add team members']); + } + + $validated = $request->validate([ + 'email' => ['required', 'email', 'exists:users,email'], + 'role' => ['required', Rule::in([WorkspaceRole::ADMIN->value, WorkspaceRole::MEMBER->value])], + ]); + + $memberUser = User::where('email', $validated['email'])->first(); + + // Check if user is already a member + if ($workspace->hasMember($memberUser)) { + return back()->withErrors(['email' => 'User is already a member of this workspace']); + } + + // Add member (use WorkspaceMember::create to generate ULID) + $role = WorkspaceRole::from($validated['role']); + WorkspaceMember::create([ + 'workspace_id' => $workspace->id, + 'user_id' => $memberUser->id, + 'role' => $role, + ]); + + logger()->info('Workspace member added', [ + 'workspace_id' => $workspace->id, + 'added_by' => $user->id, + 'member_id' => $memberUser->id, + 'role' => $validated['role'], + ]); + + return back()->with('success', 'Member added successfully'); + } + + /** + * Update a member's role + */ + public function updateMemberRole(Request $request, Workspace $workspace, User $member): RedirectResponse + { + $user = auth()->user(); + + // Only workspace owners/admins can update roles + if (!$workspace->canBeManagedBy($user)) { + abort(403, 'You do not have permission to update member roles'); + } + + // Cannot change owner's role + $memberRole = $workspace->getUserRole($member); + if ($memberRole === WorkspaceRole::OWNER) { + return back()->withErrors(['error' => 'Cannot change the owner\'s role']); + } + + $validated = $request->validate([ + 'role' => ['required', Rule::in([WorkspaceRole::ADMIN->value, WorkspaceRole::MEMBER->value])], + ]); + + $role = WorkspaceRole::from($validated['role']); + WorkspaceMember::where('workspace_id', $workspace->id) + ->where('user_id', $member->id) + ->update(['role' => $role]); + + logger()->info('Workspace member role updated', [ + 'workspace_id' => $workspace->id, + 'updated_by' => $user->id, + 'member_id' => $member->id, + 'new_role' => $validated['role'], + ]); + + return back()->with('success', 'Member role updated successfully'); + } + + /** + * Remove a member from the workspace + */ + public function removeMember(Workspace $workspace, User $member): RedirectResponse + { + $user = auth()->user(); + + // Only workspace owners/admins can remove members + if (!$workspace->canBeManagedBy($user)) { + abort(403, 'You do not have permission to remove members from this workspace'); + } + + // Cannot remove owner + $memberRole = $workspace->getUserRole($member); + if ($memberRole === WorkspaceRole::OWNER) { + return back()->withErrors(['error' => 'Cannot remove the workspace owner']); + } + + $workspace->members()->detach($member->id); + + logger()->info('Workspace member removed', [ + 'workspace_id' => $workspace->id, + 'removed_by' => $user->id, + 'member_id' => $member->id, + ]); + + return back()->with('success', 'Member removed successfully'); + } + + /** + * Update workspace name + */ + public function update(Request $request, Workspace $workspace): RedirectResponse + { + $user = auth()->user(); + + // Only workspace owners/admins can update workspace + if (!$workspace->canBeManagedBy($user)) { + abort(403, 'You do not have permission to update this workspace'); + } + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + ]); + + $workspace->update(['name' => $validated['name']]); + + logger()->info('Workspace updated', [ + 'workspace_id' => $workspace->id, + 'updated_by' => $user->id, + 'new_name' => $validated['name'], + ]); + + return back()->with('success', 'Workspace updated successfully'); + } +} + diff --git a/backend/app/Models/Calendar.php b/backend/app/Models/Calendar.php index ee3f88f..0acf46d 100644 --- a/backend/app/Models/Calendar.php +++ b/backend/app/Models/Calendar.php @@ -17,6 +17,7 @@ class Calendar extends Model protected $fillable = [ 'user_id', + 'workspace_id', 'outlook_account_id', 'google_account_id', 'caldav_account_id', @@ -54,4 +55,9 @@ public function events(): HasMany { return $this->hasMany(Event::class); } + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class, 'workspace_id'); + } } diff --git a/backend/app/Models/Device.php b/backend/app/Models/Device.php index 068ef8a..3c47216 100644 --- a/backend/app/Models/Device.php +++ b/backend/app/Models/Device.php @@ -21,6 +21,7 @@ class Device extends Model implements Authenticatable protected $fillable = [ 'user_id', + 'workspace_id', 'display_id', 'name', 'uid', @@ -40,4 +41,9 @@ public function user(): BelongsTo { return $this->belongsTo(User::class, 'user_id'); } + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class, 'workspace_id'); + } } diff --git a/backend/app/Models/Display.php b/backend/app/Models/Display.php index 099ab7c..92f446c 100644 --- a/backend/app/Models/Display.php +++ b/backend/app/Models/Display.php @@ -18,6 +18,7 @@ class Display extends Model protected $fillable = [ 'user_id', + 'workspace_id', 'name', 'display_name', 'calendar_id', @@ -42,6 +43,11 @@ public function user(): BelongsTo return $this->belongsTo(User::class, 'user_id'); } + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class, 'workspace_id'); + } + public function eventSubscriptions(): HasMany { return $this->hasMany(EventSubscription::class); diff --git a/backend/app/Models/Room.php b/backend/app/Models/Room.php index 097d5b8..1762d23 100644 --- a/backend/app/Models/Room.php +++ b/backend/app/Models/Room.php @@ -17,6 +17,7 @@ class Room extends Model protected $fillable = [ 'user_id', + 'workspace_id', 'calendar_id', 'name', 'email_address', @@ -31,4 +32,9 @@ public function user(): BelongsTo { return $this->belongsTo(User::class, 'user_id'); } + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class, 'workspace_id'); + } } diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php index 63f50bf..ff328a0 100644 --- a/backend/app/Models/User.php +++ b/backend/app/Models/User.php @@ -4,10 +4,12 @@ use App\Enums\Plan; use App\Enums\UsageType; +use App\Enums\WorkspaceRole; use App\Traits\HasUlid; use App\Traits\HasLastActivity; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Sanctum\HasApiTokens; @@ -19,6 +21,31 @@ class User extends Authenticatable { use HasApiTokens, HasFactory, Notifiable, HasUlid, HasLastActivity, Billable; + /** + * Boot the model. + */ + protected static function boot() + { + parent::boot(); + + // Auto-create workspace when user is created + static::created(function ($user) { + // Only create if user doesn't already have a workspace + if (!$user->workspaces()->exists()) { + $workspace = Workspace::create([ + 'name' => $user->name . "'s Workspace", + ]); + + // Add user as owner member (use WorkspaceMember::create to generate ULID) + WorkspaceMember::create([ + 'workspace_id' => $workspace->id, + 'user_id' => $user->id, + 'role' => WorkspaceRole::OWNER, + ]); + } + }); + } + /** * The attributes that are mass assignable. * @@ -96,6 +123,40 @@ public function rooms(): HasMany return $this->hasMany(Room::class); } + /** + * Get workspaces owned by this user (where user has 'owner' role) + */ + public function ownedWorkspaces() + { + return $this->workspaces()->wherePivot('role', WorkspaceRole::OWNER->value); + } + + /** + * Get workspaces this user is a member of + */ + public function workspaces(): BelongsToMany + { + return $this->belongsToMany(Workspace::class, 'workspace_members') + ->withPivot('role') + ->withTimestamps(); + } + + /** + * Get the primary workspace for this user (first workspace where user is owner) + */ + public function primaryWorkspace(): ?Workspace + { + return $this->ownedWorkspaces()->first() ?? $this->workspaces()->first(); + } + + /** + * Get all workspaces this user has access to + */ + public function accessibleWorkspaces() + { + return $this->workspaces()->get(); + } + public function hasAnyDisplay(): bool { return $this->displays()->count() > 0; diff --git a/backend/app/Models/Workspace.php b/backend/app/Models/Workspace.php new file mode 100644 index 0000000..d3de3bf --- /dev/null +++ b/backend/app/Models/Workspace.php @@ -0,0 +1,118 @@ +belongsToMany(User::class, 'workspace_members') + ->withPivot('role') + ->withTimestamps(); + } + + /** + * Get all displays in this workspace + */ + public function displays(): HasMany + { + return $this->hasMany(Display::class); + } + + /** + * Get all devices in this workspace + */ + public function devices(): HasMany + { + return $this->hasMany(Device::class); + } + + /** + * Get all calendars in this workspace + */ + public function calendars(): HasMany + { + return $this->hasMany(Calendar::class); + } + + /** + * Get all rooms in this workspace + */ + public function rooms(): HasMany + { + return $this->hasMany(Room::class); + } + + /** + * Check if a user is a member of this workspace + */ + public function hasMember(User $user): bool + { + return $this->members()->where('user_id', $user->id)->exists(); + } + + /** + * Get the owner(s) of the workspace (members with 'owner' role) + */ + public function owners() + { + return $this->members()->wherePivot('role', WorkspaceRole::OWNER->value); + } + + /** + * Check if a user is the owner of this workspace + */ + public function isOwnedBy(User $user): bool + { + return $this->members()->where('user_id', $user->id)->wherePivot('role', WorkspaceRole::OWNER->value)->exists(); + } + + /** + * Check if a user can manage this workspace (owner or admin) + */ + public function canBeManagedBy(User $user): bool + { + $member = $this->members()->where('user_id', $user->id)->first(); + if (!$member) { + return false; + } + + $role = $member->pivot->role instanceof WorkspaceRole + ? $member->pivot->role + : WorkspaceRole::from($member->pivot->role); + + return $role->canManage(); + } + + /** + * Get the role of a user in this workspace + */ + public function getUserRole(User $user): ?WorkspaceRole + { + $member = $this->members()->where('user_id', $user->id)->first(); + if (!$member) { + return null; + } + + $role = $member->pivot->role; + return $role instanceof WorkspaceRole ? $role : WorkspaceRole::from($role); + } +} + diff --git a/backend/app/Models/WorkspaceMember.php b/backend/app/Models/WorkspaceMember.php new file mode 100644 index 0000000..e7e66bc --- /dev/null +++ b/backend/app/Models/WorkspaceMember.php @@ -0,0 +1,43 @@ + WorkspaceRole::class, + ]; + + /** + * Get the workspace this member belongs to + */ + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + /** + * Get the user member + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} + diff --git a/backend/app/Policies/DisplayPolicy.php b/backend/app/Policies/DisplayPolicy.php index 1697a71..b7b1ca3 100644 --- a/backend/app/Policies/DisplayPolicy.php +++ b/backend/app/Policies/DisplayPolicy.php @@ -24,7 +24,12 @@ public function create(User $user): bool */ public function update(User $user, Display $display): bool { - return $user->id === $display->user_id; + if (!$display->workspace_id) { + return false; + } + + $workspace = $display->workspace; + return $workspace && $workspace->canBeManagedBy($user); } /** @@ -32,7 +37,12 @@ public function update(User $user, Display $display): bool */ public function delete(User $user, Display $display): bool { - return $user->id === $display->user_id; + if (!$display->workspace_id) { + return false; + } + + $workspace = $display->workspace; + return $workspace && $workspace->canBeManagedBy($user); } /** @@ -42,7 +52,12 @@ public function view($user, Display $display): bool { // Handle User model if ($user instanceof User) { - return $user->id === $display->user_id; + if (!$display->workspace_id) { + return false; + } + + $workspace = $display->workspace; + return $workspace && $workspace->hasMember($user); } // Handle Device model diff --git a/backend/app/Services/DisplayService.php b/backend/app/Services/DisplayService.php index 8674277..22fdf7f 100644 --- a/backend/app/Services/DisplayService.php +++ b/backend/app/Services/DisplayService.php @@ -23,8 +23,29 @@ public function getDisplay(string $displayId) */ public function validateDisplayPermission(?string $displayId, string $deviceId, array $options = []): PermissionResult { - $userId = Device::query()->where('id', $deviceId)->value('user_id'); - $display = $displayId ? Display::with('user')->where('user_id', $userId)->find($displayId) : null; + $device = Device::query()->find($deviceId); + if (!$device) { + return new PermissionResult(false, 'Device not found', 404); + } + + if (!$device->workspace_id) { + return new PermissionResult(false, 'Device not associated with a workspace', 404); + } + + $workspaceId = $device->workspace_id; + + // Find display by ID, checking workspace access + $display = null; + if ($displayId) { + $display = Display::with('workspace.members')->find($displayId); + + if ($display) { + // Check access: device and display must be in the same workspace + if (!$display->workspace_id || $display->workspace_id !== $workspaceId) { + return new PermissionResult(false, 'Display not found', 404); + } + } + } if (!$display) { return new PermissionResult(false, 'Display not found', 404); @@ -32,9 +53,27 @@ public function validateDisplayPermission(?string $displayId, string $deviceId, if ($display->isDeactivated()) { return new PermissionResult(false, 'Display is deactivated', 400); } - if (!empty($options['pro']) && (!$display->user || !$display->user->hasPro())) { - return new PermissionResult(false, 'This is a Pro feature. Please upgrade to Pro to use this feature.', 403); + + // Check Pro feature - check workspace owners + if (!empty($options['pro'])) { + $workspace = $display->workspace; + $hasPro = false; + + if ($workspace) { + $owners = $workspace->owners()->get(); + foreach ($owners as $owner) { + if ($owner->hasPro()) { + $hasPro = true; + break; + } + } + } + + if (!$hasPro) { + return new PermissionResult(false, 'This is a Pro feature. Please upgrade to Pro to use this feature.', 403); + } } + if (!empty($options['booking']) && !$display->isBookingEnabled()) { return new PermissionResult(false, 'Booking is not enabled for this display', 403); } diff --git a/backend/database/migrations/2025_12_30_000000_create_workspaces_table.php b/backend/database/migrations/2025_12_30_000000_create_workspaces_table.php new file mode 100644 index 0000000..423c935 --- /dev/null +++ b/backend/database/migrations/2025_12_30_000000_create_workspaces_table.php @@ -0,0 +1,29 @@ +ulid('id')->primary(); + $table->string('name'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('workspaces'); + } +}; + diff --git a/backend/database/migrations/2025_12_30_000001_create_workspace_members_table.php b/backend/database/migrations/2025_12_30_000001_create_workspace_members_table.php new file mode 100644 index 0000000..0912dde --- /dev/null +++ b/backend/database/migrations/2025_12_30_000001_create_workspace_members_table.php @@ -0,0 +1,34 @@ +ulid('id')->primary(); + $table->foreignUlid('workspace_id')->constrained()->onDelete('cascade'); + $table->foreignUlid('user_id')->constrained()->onDelete('cascade'); + $table->string('role')->default('member'); // Uses WorkspaceRole enum: 'owner', 'admin', 'member' + $table->timestamps(); + + // Ensure a user can only be a member once per workspace + $table->unique(['workspace_id', 'user_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('workspace_members'); + } +}; + diff --git a/backend/database/migrations/2025_12_30_000002_add_workspace_id_to_tables.php b/backend/database/migrations/2025_12_30_000002_add_workspace_id_to_tables.php new file mode 100644 index 0000000..345113f --- /dev/null +++ b/backend/database/migrations/2025_12_30_000002_add_workspace_id_to_tables.php @@ -0,0 +1,61 @@ +foreignUlid('workspace_id')->nullable()->after('user_id')->constrained()->onDelete('cascade'); + }); + + // Add workspace_id to devices + Schema::table('devices', function (Blueprint $table) { + $table->foreignUlid('workspace_id')->nullable()->after('user_id')->constrained()->onDelete('cascade'); + }); + + // Add workspace_id to calendars + Schema::table('calendars', function (Blueprint $table) { + $table->foreignUlid('workspace_id')->nullable()->after('user_id')->constrained()->onDelete('cascade'); + }); + + // Add workspace_id to rooms + Schema::table('rooms', function (Blueprint $table) { + $table->foreignUlid('workspace_id')->nullable()->after('user_id')->constrained()->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('displays', function (Blueprint $table) { + $table->dropForeign(['workspace_id']); + $table->dropColumn('workspace_id'); + }); + + Schema::table('devices', function (Blueprint $table) { + $table->dropForeign(['workspace_id']); + $table->dropColumn('workspace_id'); + }); + + Schema::table('calendars', function (Blueprint $table) { + $table->dropForeign(['workspace_id']); + $table->dropColumn('workspace_id'); + }); + + Schema::table('rooms', function (Blueprint $table) { + $table->dropForeign(['workspace_id']); + $table->dropColumn('workspace_id'); + }); + } +}; + diff --git a/backend/database/migrations/2025_12_30_000003_create_workspaces_for_existing_users.php b/backend/database/migrations/2025_12_30_000003_create_workspaces_for_existing_users.php new file mode 100644 index 0000000..bdcf194 --- /dev/null +++ b/backend/database/migrations/2025_12_30_000003_create_workspaces_for_existing_users.php @@ -0,0 +1,68 @@ +workspaces()->exists()) { + continue; + } + + // Create workspace for user + $workspace = Workspace::create([ + 'name' => $user->name . "'s Workspace", + ]); + + // Add user as owner member (use WorkspaceMember::create to generate ULID) + WorkspaceMember::create([ + 'workspace_id' => $workspace->id, + 'user_id' => $user->id, + 'role' => WorkspaceRole::OWNER, + ]); + + // Migrate displays to workspace + Display::where('user_id', $user->id)->update(['workspace_id' => $workspace->id]); + + // Migrate devices to workspace + Device::where('user_id', $user->id)->update(['workspace_id' => $workspace->id]); + + // Migrate calendars to workspace + Calendar::where('user_id', $user->id)->update(['workspace_id' => $workspace->id]); + + // Migrate rooms to workspace + Room::where('user_id', $user->id)->update(['workspace_id' => $workspace->id]); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // This migration cannot be fully reversed as we don't know which workspace + // data belongs to which user after potential member additions. + // In practice, you'd need to keep the user_id relationships intact. + } +}; + diff --git a/backend/docs/WORKSPACE_SETUP.md b/backend/docs/WORKSPACE_SETUP.md new file mode 100644 index 0000000..5b9e354 --- /dev/null +++ b/backend/docs/WORKSPACE_SETUP.md @@ -0,0 +1,111 @@ +# Workspace System Documentation + +## Overview + +The workspace system allows multiple users to collaborate on managing displays, devices, calendars, and rooms. Each user automatically gets their own workspace, and Pro users can invite colleagues to join their workspace. + +## Architecture + +### Models + +1. **Workspace** - Represents a team/workspace + - Has an `owner` (User) + - Has many `members` (Users with roles) + - Contains displays, devices, calendars, rooms + +2. **WorkspaceMember** - Pivot table linking users to workspaces + - Roles: `owner`, `admin`, `member` + - `owner` role is implicit for the workspace owner + +### Relationships + +- **User** → **Workspace** (one-to-many: owned workspaces) +- **User** ↔ **Workspace** (many-to-many: member workspaces) +- **Workspace** → **Display** (one-to-many) +- **Workspace** → **Device** (one-to-many) +- **Workspace** → **Calendar** (one-to-many) +- **Workspace** → **Room** (one-to-many) + +## Migration Strategy + +1. **Existing Users**: Each user automatically gets a workspace created with their name +2. **Existing Data**: All displays, devices, calendars, and rooms are migrated to the user's workspace +3. **Backward Compatibility**: The `user_id` field is kept for backward compatibility + +## Permissions + +### Workspace Roles + +- **Owner**: Full control (can delete workspace, manage all members) +- **Admin**: Can manage members and workspace settings +- **Member**: Can view and use workspace resources + +### Display Access + +- Users can access displays they own directly (`user_id`) +- Users can access displays in workspaces they're members of (`workspace_id`) +- Device authentication checks workspace membership + +## Usage + +### Adding a Colleague + +1. Navigate to workspace settings (requires Pro) +2. Enter colleague's email address +3. Select role (admin or member) +4. Colleague receives access to all workspace resources + +### Managing Members + +- **Add Member**: Only owners/admins can add members +- **Update Role**: Change member role between admin/member +- **Remove Member**: Remove access from workspace + +## API Changes + +### DisplayController + +- `index()` now returns displays from user's workspace(s) +- Access checks include workspace membership + +### DisplayService + +- `validateDisplayPermission()` checks workspace membership +- Pro features check workspace owner's Pro status + +## Frontend Changes Needed + +1. **Workspace Management UI** + - List workspaces + - View workspace members + - Add/remove members + - Update member roles + +2. **Display Creation** + - Automatically assign to user's primary workspace + - Allow selecting workspace (if user has multiple) + +3. **Device Connection** + - Connect code should work with workspace + - Devices inherit workspace from user + +## Migration Commands + +Run migrations in order: + +```bash +php artisan migrate +``` + +The migration `2025_12_30_000003_create_workspaces_for_existing_users.php` will: +1. Create a workspace for each existing user +2. Migrate all user's displays, devices, calendars, and rooms to their workspace +3. Add the user as an owner member + +## Notes + +- Pro subscription is required to add team members +- Workspace owner cannot be removed +- All existing functionality remains backward compatible +- `user_id` fields are kept for direct ownership tracking + diff --git a/backend/resources/views/pages/admin.blade.php b/backend/resources/views/pages/admin.blade.php index 4cf449f..d8129d7 100644 --- a/backend/resources/views/pages/admin.blade.php +++ b/backend/resources/views/pages/admin.blade.php @@ -3,10 +3,17 @@ @section('title', 'Admin dashboard') @section('content') -
+
+ + +
+
+
+
+
+ + +
+
+
+
Total Users
+
{{ $allUsers->count() }}
+
+
+
+
+
+
+
+ + +
+
+
+
Users with Displays
+
{{ $allUsers->filter(fn($u) => $u->displays_count > 0)->count() }}
+
+
+
+
+
+
+
+ + +
+
+
+
Pro Users
+
{{ $allUsers->filter(fn($u) => $u->hasPro())->count() }}
+
+
+
+
+
+ +
+

All Users

+
+
+ +
+
+ + + + + + + + + + + + + + + @foreach($allUsers as $user) + + + + + + + + + + + @endforeach + +
NameEmailUsage TypeDisplaysProRegisteredLast ActivityActions
{{ $user->name }}{{ $user->email }}{{ $user->usage_type?->label() ?? '-' }}{{ $user->displays_count }} + @if($user->hasPro()) + Yes + @else + No + @endif + {{ $user->created_at->format('Y-m-d') }}{{ $user->last_activity_at ? $user->last_activity_at->format('Y-m-d') : 'Never' }} + + View + +
+
+
+
+
@endsection diff --git a/backend/resources/views/pages/admin/user.blade.php b/backend/resources/views/pages/admin/user.blade.php new file mode 100644 index 0000000..66ba6db --- /dev/null +++ b/backend/resources/views/pages/admin/user.blade.php @@ -0,0 +1,208 @@ +@extends('layouts.base') +@section('title', 'User Details - ' . $user->email) +@section('container_class', 'max-w-4xl') + +@section('content') + +
+
+

User Details

+

View and manage user account information

+
+ +
+ + @if(session('error')) +
+ {{ session('error') }} +
+ @endif + + @if(session('success')) +
+ {{ session('success') }} +
+ @endif + +
+
+

Account Information

+
+
+
Email
+
{{ $user->email }}
+
+
+
Name
+
{{ $user->name }}
+
+
+
User ID
+
{{ $user->id }}
+
+
+
Usage Type
+
{{ $user->usage_type?->label() ?? 'Not set' }}
+
+
+
Created
+
{{ $user->created_at->format('Y-m-d H:i:s') }}
+
+
+
Last Activity
+
{{ $user->last_activity_at ? $user->last_activity_at->format('Y-m-d H:i:s') : 'Never' }}
+
+
+
+ + @if($user->hasPro() || $subscriptionInfo) +
+

Subscription Information

+
+
+
Plan
+
+ @if($user->is_unlimited) + Unlimited + @elseif($subscriptionInfo) + Pro + @else + Free + @endif +
+
+ @if($subscriptionInfo) +
+
Status
+
+ @php + $status = $subscriptionInfo['status']; + $statusLabel = ucwords(str_replace('_', ' ', $status)); + $statusColors = match($status) { + 'active' => 'bg-green-50 text-green-700 ring-green-600/20', + 'past_due' => 'bg-yellow-50 text-yellow-700 ring-yellow-600/20', + 'unpaid' => 'bg-red-50 text-red-700 ring-red-600/20', + 'cancelled' => 'bg-gray-50 text-gray-700 ring-gray-600/20', + 'on_trial' => 'bg-blue-50 text-blue-700 ring-blue-600/20', + 'paused' => 'bg-orange-50 text-orange-700 ring-orange-600/20', + default => 'bg-gray-50 text-gray-700 ring-gray-600/20', + }; + @endphp + + {{ $statusLabel }} + +
+
+
+
Monthly Price
+
${{ number_format($subscriptionInfo['price'], 2) }}
+
+
+
MRR
+
${{ number_format($subscriptionInfo['mrr'], 2) }}
+
+ @if($subscriptionInfo['ends_at']) +
+
Subscription Ends
+
{{ \Carbon\Carbon::parse($subscriptionInfo['ends_at'])->format('Y-m-d') }}
+
+ @endif + @endif +
+
+ @endif + +
+

Data Summary

+
+
+
Outlook Accounts
+
{{ $user->outlookAccounts->count() }}
+
+
+
Google Accounts
+
{{ $user->googleAccounts->count() }}
+
+
+
CalDAV Accounts
+
{{ $user->caldavAccounts->count() }}
+
+
+
Displays
+
{{ $user->displays->count() }}
+
+
+
Devices
+
{{ $user->devices->count() }}
+
+
+
Workspaces
+
{{ $user->workspaces->count() }}
+
+
+
+ + @if($user->id !== auth()->id()) +
+

⚠️ Delete User Account

+

+ This action cannot be undone. All data associated with this user will be permanently deleted: +

+
    +
  • All connected accounts (Outlook, Google, CalDAV)
  • +
  • All displays and their settings
  • +
  • All devices
  • +
  • All calendars and events
  • +
  • All rooms
  • +
  • All workspace memberships
  • +
  • All personal access tokens
  • +
+ +
+ @csrf + @method('DELETE') + +
+ + + @error('confirm_email') +

{{ $message }}

+ @enderror +
+ +
+ +
+
+
+ @else +
+

⚠️ Notice

+

+ You cannot delete your own account. Please ask another admin to perform this action if needed. +

+
+ @endif +
+
+@endsection diff --git a/backend/resources/views/pages/dashboard.blade.php b/backend/resources/views/pages/dashboard.blade.php index 162f0a0..8ed16aa 100644 --- a/backend/resources/views/pages/dashboard.blade.php +++ b/backend/resources/views/pages/dashboard.blade.php @@ -204,7 +204,7 @@ class="inline-flex items-center rounded-md bg-yellow-600 px-3 py-2 text-sm font-
{{ $display->name }}
-
{{ $display->calendar->name }}
+
{{ $display->display_name }}
@@ -226,6 +226,7 @@ class="inline-flex items-center rounded-md bg-yellow-600 px-3 py-2 text-sm font- {{ $display->calendar->caldavAccount->name }}
@endif +
{{ $display->calendar->name }}
diff --git a/backend/routes/web.php b/backend/routes/web.php index 04d1051..5bab3e6 100644 --- a/backend/routes/web.php +++ b/backend/routes/web.php @@ -116,6 +116,8 @@ })->name('billing.thanks'); Route::get('/admin', [AdminController::class, 'index'])->name('admin.index'); + Route::get('/admin/users/{user}', [AdminController::class, 'showUser'])->name('admin.users.show'); + Route::delete('/admin/users/{user}', [AdminController::class, 'deleteUser'])->name('admin.users.delete'); // Display image serving route Route::get('/displays/{display}/images/{type}', [DisplaySettingsController::class, 'serveImage']) From d565d48d634b6d9cb0737ce67de2f4f8ce72fbac Mon Sep 17 00:00:00 2001 From: Martijn van de Wetering Date: Sun, 11 Jan 2026 11:02:29 +0100 Subject: [PATCH 09/19] feat: Add displaying workspaces in the dashboard --- CONTRIBUTING.md | 1 + .../Http/Controllers/API/DeviceController.php | 9 +- .../Controllers/API/DisplayController.php | 18 +- .../Http/Controllers/DashboardController.php | 27 ++- .../Http/Controllers/DisplayController.php | 30 ++- .../Http/Requests/CreateDisplayRequest.php | 1 + backend/app/Models/User.php | 7 +- .../resources/views/pages/dashboard.blade.php | 196 ++++++++---------- .../views/pages/displays/create.blade.php | 23 ++ 9 files changed, 185 insertions(+), 127 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a14d59e..1525016 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,6 +49,7 @@ Thank you for your interest in contributing to Spacepad! This document provides ### Coding Standards - Follow the [PSR-12](https://www.php-fig.org/psr/psr-12/) coding style guide for PHP +- Always use import statements instead of inline fully qualified class names - See [Coding Standards](backend/docs/CODING_STANDARDS.md) for details - Use ESLint and Prettier for JavaScript/TypeScript - Write meaningful commit messages - Add comments for complex logic diff --git a/backend/app/Http/Controllers/API/DeviceController.php b/backend/app/Http/Controllers/API/DeviceController.php index e85ddd3..d6ffaed 100644 --- a/backend/app/Http/Controllers/API/DeviceController.php +++ b/backend/app/Http/Controllers/API/DeviceController.php @@ -25,8 +25,15 @@ public function changeDisplay(ChangeDisplayRequest $request): JsonResponse $device = auth()->user(); $data = $request->validated(); + if (!$device->workspace_id) { + return $this->error( + message: 'Device is not assigned to a workspace', + code: Response::HTTP_BAD_REQUEST + ); + } + $display = Display::query() - ->where('user_id', $device->user_id) + ->where('workspace_id', $device->workspace_id) ->find($data['display_id']); if (! $display) { diff --git a/backend/app/Http/Controllers/API/DisplayController.php b/backend/app/Http/Controllers/API/DisplayController.php index 6a2eda9..8b6d2a8 100644 --- a/backend/app/Http/Controllers/API/DisplayController.php +++ b/backend/app/Http/Controllers/API/DisplayController.php @@ -9,6 +9,7 @@ use App\Http\Resources\API\EventResource; use App\Models\Device; use App\Models\Display; +use App\Models\User; use App\Services\DisplayService; use App\Services\EventService; use App\Services\ImageService; @@ -30,13 +31,23 @@ public function index(): JsonResponse /** @var Device $device */ $device = auth()->user(); - if (!$device->workspace_id) { + if (!$device->user_id) { + return $this->success(data: []); + } + + $user = User::find($device->user_id); + if (!$user) { + return $this->success(data: []); + } + + // Get displays from all workspaces the user is a member of + $workspaceIds = $user->workspaces->pluck('id'); + if ($workspaceIds->isEmpty()) { return $this->success(data: []); } - // Get displays from device's workspace $displays = Display::query() - ->where('workspace_id', $device->workspace_id) + ->whereIn('workspace_id', $workspaceIds) ->whereIn('status', [DisplayStatus::READY, DisplayStatus::ACTIVE]) ->with('settings') ->get(); @@ -44,6 +55,7 @@ public function index(): JsonResponse logger()->info('Display list requested', [ 'user_id' => $device->user_id, 'device_id' => $device->id, + 'workspace_ids' => $workspaceIds->toArray(), 'display_count' => $displays->count(), 'ip' => request()->ip(), ]); diff --git a/backend/app/Http/Controllers/DashboardController.php b/backend/app/Http/Controllers/DashboardController.php index 6d7b870..ba2dff2 100644 --- a/backend/app/Http/Controllers/DashboardController.php +++ b/backend/app/Http/Controllers/DashboardController.php @@ -6,6 +6,8 @@ use Illuminate\Contracts\Foundation\Application; use Illuminate\Contracts\View\Factory; use Illuminate\Contracts\View\View; +use App\Services\InstanceService; +use App\Models\Display; class DashboardController extends Controller { @@ -20,8 +22,17 @@ public function __construct(protected OutlookService $outlookService) public function __invoke(): View|Factory|Application { $user = auth()->user(); + $user->load(['outlookAccounts', 'googleAccounts', 'caldavAccounts', 'workspaces']); + + // Get connect code for user $connectCode = $user->getConnectCode(); - $user->load(['outlookAccounts', 'googleAccounts', 'caldavAccounts', 'displays']); + + // Get displays from all workspaces user is a member of + $workspaceIds = $user->workspaces->pluck('id'); + $displays = Display::whereIn('workspace_id', $workspaceIds) + ->with(['workspace', 'calendar.outlookAccount', 'calendar.googleAccount', 'calendar.caldavAccount']) + ->get() + ->groupBy('workspace_id'); logger()->info('Dashboard page accessed', [ 'user_id' => $user->id, @@ -29,17 +40,27 @@ public function __invoke(): View|Factory|Application 'outlook_accounts_count' => $user->outlookAccounts->count(), 'google_accounts_count' => $user->googleAccounts->count(), 'caldav_accounts_count' => $user->caldavAccounts->count(), - 'displays_count' => $user->displays->count(), + 'displays_count' => $displays->flatten()->count(), + 'workspaces_count' => $user->workspaces->count(), 'ip' => request()->ip(), 'user_agent' => substr(request()->userAgent() ?? '', 0, 100), ]); + $isSelfHosted = config('settings.is_self_hosted'); + return view('pages.dashboard', [ 'outlookAccounts' => $user->outlookAccounts, 'googleAccounts' => $user->googleAccounts, 'caldavAccounts' => $user->caldavAccounts, - 'displays' => $user->displays, + 'displays' => $displays, // Grouped by workspace + 'displaysFlat' => $displays->flatten(), // Flat list for compatibility + 'workspaces' => $user->workspaces, 'connectCode' => $connectCode, + 'primaryWorkspace' => $user->primaryWorkspace(), + 'version' => config('settings.version', 'dev'), + 'appEnv' => config('app.env', 'production'), + 'appUrl' => config('app.url'), + 'isSelfHosted' => $isSelfHosted, ]); } } diff --git a/backend/app/Http/Controllers/DisplayController.php b/backend/app/Http/Controllers/DisplayController.php index c996dba..3f548dd 100644 --- a/backend/app/Http/Controllers/DisplayController.php +++ b/backend/app/Http/Controllers/DisplayController.php @@ -34,14 +34,15 @@ public function __construct( public function create(): View { - $outlookAccounts = auth()->user()->outlookAccounts; - $googleAccounts = auth()->user()->googleAccounts; - $caldavAccounts = auth()->user()->caldavAccounts; + $user = auth()->user(); + $workspaces = $user->workspaces()->withPivot('role')->get(); return view('pages.displays.create', [ - 'outlookAccounts' => $outlookAccounts, - 'googleAccounts' => $googleAccounts, - 'caldavAccounts' => $caldavAccounts, + 'outlookAccounts' => $user->outlookAccounts, + 'googleAccounts' => $user->googleAccounts, + 'caldavAccounts' => $user->caldavAccounts, + 'workspaces' => $workspaces, + 'defaultWorkspace' => $user->primaryWorkspace(), ]); } @@ -69,11 +70,24 @@ public function store(CreateDisplayRequest $request): RedirectResponse }; $user = auth()->user(); - $workspace = $user->primaryWorkspace(); - if (!$workspace) { + // Get workspace from request, or default to primary + $workspaceId = $validatedData['workspace_id'] ?? $user->primaryWorkspace()?->id; + + if (!$workspaceId) { return redirect()->back()->with('error', 'No workspace found. Please contact support.'); } + + // Verify user has access to this workspace + $workspace = $user->workspaces()->find($workspaceId); + if (!$workspace) { + return redirect()->back()->with('error', 'You do not have access to this workspace.'); + } + + // Check if user can create displays in this workspace (owner/admin) + if (!$workspace->canBeManagedBy($user)) { + return redirect()->back()->with('error', 'You do not have permission to create displays in this workspace.'); + } $display = DB::transaction(function () use ($validatedData, $workspace) { // Handle room or calendar selection diff --git a/backend/app/Http/Requests/CreateDisplayRequest.php b/backend/app/Http/Requests/CreateDisplayRequest.php index 5dc927e..b929dce 100644 --- a/backend/app/Http/Requests/CreateDisplayRequest.php +++ b/backend/app/Http/Requests/CreateDisplayRequest.php @@ -35,6 +35,7 @@ public function rules(): array 'provider' => 'required|string|in:outlook,google,caldav', 'room' => 'required_without:calendar|string', 'calendar' => 'required_without:room|string', + 'workspace_id' => 'nullable|string|exists:workspaces,id', ]; } } diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php index ff328a0..430863f 100644 --- a/backend/app/Models/User.php +++ b/backend/app/Models/User.php @@ -167,6 +167,11 @@ public function hasAnyAccount(): bool return $this->outlookAccounts()->count() > 0 || $this->googleAccounts()->count() > 0 || $this->caldavAccounts()->count() > 0; } + /** + * Get or generate a connect code for this user + * + * @return string 6-digit connect code + */ public function getConnectCode(): string { $connectCode = cache()->get("user:$this->id:connect-code"); @@ -177,7 +182,7 @@ public function getConnectCode(): string } while (cache()->has("connect-code:$connectCode")); cache()->put("user:$this->id:connect-code", $connectCode, $expiresAt); - cache()->put("connect-code:$connectCode", auth()->id(), $expiresAt); + cache()->put("connect-code:$connectCode", $this->id, $expiresAt); } return $connectCode; diff --git a/backend/resources/views/pages/dashboard.blade.php b/backend/resources/views/pages/dashboard.blade.php index 8ed16aa..0bd7008 100644 --- a/backend/resources/views/pages/dashboard.blade.php +++ b/backend/resources/views/pages/dashboard.blade.php @@ -3,12 +3,12 @@ @section('actions') {{-- Instruction Banner --}} - @if(auth()->user()->hasAnyDisplay()) + @if(auth()->user()->hasAnyDisplay() && $connectCode)

Connect code

-

{{ chunk_split($connectCode, 3, ' ') }}

+

{{ chunk_split($connectCode, 3, ' ') }}

@@ -122,7 +122,12 @@ class="inline-flex items-center rounded-md bg-yellow-600 px-3 py-2 text-sm font-

Displays

-

Overview of your displays and their status.

+

+ Overview of your displays and their status. + @if($workspaces->count() > 1) + ({{ $workspaces->count() }} workspaces) + @endif +

@if(auth()->user()->hasAnyDisplay()) @@ -200,118 +205,66 @@ class="inline-flex items-center rounded-md bg-yellow-600 px-3 py-2 text-sm font- - @forelse($displays as $display) - - -
{{ $display->name }}
-
{{ $display->display_name }}
- - -
- @if($display->calendar->outlookAccount) -
- - {{ $display->calendar->outlookAccount->name }} -
- @endif - @if($display->calendar->googleAccount) -
- - {{ $display->calendar->googleAccount->name }} -
- @endif - @if($display->calendar->caldavAccount) -
- - {{ $display->calendar->caldavAccount->name }} + @if($workspaces->count() > 1) + @foreach($displays as $workspaceId => $workspaceDisplays) + @php + $workspace = $workspaces->firstWhere('id', $workspaceId); + @endphp + @if($workspace) + + +
+

+ {{ $workspace->name }} + ({{ $workspaceDisplays->count() }} display{{ $workspaceDisplays->count() !== 1 ? 's' : '' }}) +

+ + @if($workspace->pivot->role === \App\Enums\WorkspaceRole::OWNER->value) + Owner + @elseif($workspace->pivot->role === \App\Enums\WorkspaceRole::ADMIN->value) + Admin + @else + Member + @endif +
- @endif -
{{ $display->calendar->name }}
-
- - - - {{ $display->status->label() }} - - - -
-
- @if($display->devices->isNotEmpty()) -
-
-
-
- - -
+ + + @endif + @foreach($workspaceDisplays as $display) + + @endforeach + @endforeach + @if($displays->isEmpty()) + + +
+ +

+ One more step and you're set up +

+

Pick the calendar or room you would like to synchronize. You are able to connect multiple tablets to one display.

+ @if(! $isSelfHosted && auth()->user()->shouldUpgrade()) + + Create new display Pro + + @elseif($isSelfHosted && auth()->user()->shouldUpgrade()) + + Create new display Pro + @else -
-
-
- No devices + + + Create new display + @endif
- @if($display->last_sync_at) -
Last sync {{ $display->last_sync_at->diffForHumans() }}
- @endif -
- - -
-
- @csrf - @method('PATCH') - - -
- @if(auth()->user()->hasPro()) - - - - - - - @else - - - - - - - @endif -
- @csrf - @method('DELETE') - -
-
- - + + + @endif + @else + @forelse($displaysFlat as $display) + @empty @@ -338,6 +291,7 @@ class="inline-flex items-center rounded-md bg-yellow-600 px-3 py-2 text-sm font- @endforelse + @endif
@@ -520,6 +474,25 @@ class="grow flex items-center justify-center gap-3 rounded-lg border border-gray
+ + {{-- Server Info (Self-hosted only) --}} + @if($isSelfHosted) +
+ + Self-hosted + + @if($version) + + v{{ $version }} + + @endif + @if($appUrl) + + {{ parse_url($appUrl, PHP_URL_HOST) }} ({{ $appEnv }}) + + @endif +
+ @endif @endsection @push('scripts') @@ -563,6 +536,7 @@ function closeConnectModal() { })); }); @endif + @endpush diff --git a/backend/resources/views/pages/displays/create.blade.php b/backend/resources/views/pages/displays/create.blade.php index 8ca07cb..6691844 100644 --- a/backend/resources/views/pages/displays/create.blade.php +++ b/backend/resources/views/pages/displays/create.blade.php @@ -41,6 +41,29 @@ class="block w-full rounded-md border-0 py-1.5 px-3 text-gray-900 shadow-sm ring
+ @if($workspaces->count() > 1) +
+ +
+ +
+

Select which workspace this display should belong to.

+
+ @else + + @endif +

1. Select a calendar account

From f0f7e91d95befb57242b8126bff13d5f4bfa1989 Mon Sep 17 00:00:00 2001 From: Martijn van de Wetering Date: Sun, 11 Jan 2026 11:24:31 +0100 Subject: [PATCH 10/19] feat: Check permissions for user workspaces --- .../Http/Controllers/API/DeviceController.php | 25 +++- backend/app/Services/DisplayService.php | 54 ++++----- backend/docs/CODING_STANDARDS.md | 64 ++++++++++ .../components/displays/table-row.blade.php | 114 ++++++++++++++++++ 4 files changed, 223 insertions(+), 34 deletions(-) create mode 100644 backend/docs/CODING_STANDARDS.md create mode 100644 backend/resources/views/components/displays/table-row.blade.php diff --git a/backend/app/Http/Controllers/API/DeviceController.php b/backend/app/Http/Controllers/API/DeviceController.php index d6ffaed..2e823bc 100644 --- a/backend/app/Http/Controllers/API/DeviceController.php +++ b/backend/app/Http/Controllers/API/DeviceController.php @@ -7,6 +7,7 @@ use App\Http\Resources\API\DeviceResource; use App\Models\Device; use App\Models\Display; +use App\Models\User; use Illuminate\Http\JsonResponse; use Symfony\Component\HttpFoundation\Response; @@ -25,15 +26,33 @@ public function changeDisplay(ChangeDisplayRequest $request): JsonResponse $device = auth()->user(); $data = $request->validated(); - if (!$device->workspace_id) { + if (!$device->user_id) { return $this->error( - message: 'Device is not assigned to a workspace', + message: 'Device is not associated with a user', code: Response::HTTP_BAD_REQUEST ); } + $user = User::with('workspaces')->find($device->user_id); + if (!$user) { + return $this->error( + message: 'User not found', + code: Response::HTTP_NOT_FOUND + ); + } + + // Get all workspace IDs the user is a member of + $workspaceIds = $user->workspaces->pluck('id'); + if ($workspaceIds->isEmpty()) { + return $this->error( + message: 'User is not a member of any workspace', + code: Response::HTTP_BAD_REQUEST + ); + } + + // Find display in any of the user's workspaces $display = Display::query() - ->where('workspace_id', $device->workspace_id) + ->whereIn('workspace_id', $workspaceIds) ->find($data['display_id']); if (! $display) { diff --git a/backend/app/Services/DisplayService.php b/backend/app/Services/DisplayService.php index 22fdf7f..23d8ef9 100644 --- a/backend/app/Services/DisplayService.php +++ b/backend/app/Services/DisplayService.php @@ -5,6 +5,7 @@ use App\Data\PermissionResult; use App\Models\Device; use App\Models\Display; +use App\Models\User; class DisplayService { @@ -23,53 +24,44 @@ public function getDisplay(string $displayId) */ public function validateDisplayPermission(?string $displayId, string $deviceId, array $options = []): PermissionResult { - $device = Device::query()->find($deviceId); - if (!$device) { + $device = Device::with('user.workspaces')->find($deviceId); + + if (!$device || !$device->user_id) { return new PermissionResult(false, 'Device not found', 404); } - if (!$device->workspace_id) { - return new PermissionResult(false, 'Device not associated with a workspace', 404); + $user = $device->user; + if (!$user) { + return new PermissionResult(false, 'User not found', 404); } - $workspaceId = $device->workspace_id; + if (!$displayId) { + return new PermissionResult(false, 'Display ID is required', 400); + } - // Find display by ID, checking workspace access - $display = null; - if ($displayId) { - $display = Display::with('workspace.members')->find($displayId); - - if ($display) { - // Check access: device and display must be in the same workspace - if (!$display->workspace_id || $display->workspace_id !== $workspaceId) { - return new PermissionResult(false, 'Display not found', 404); - } - } + // Get all workspace IDs the user is a member of + $workspaceIds = $user->workspaces->pluck('id'); + if ($workspaceIds->isEmpty()) { + return new PermissionResult(false, 'User is not a member of any workspace', 403); } + // Find display in any of the user's workspaces + $display = Display::with('workspace.members') + ->whereIn('workspace_id', $workspaceIds) + ->find($displayId); + if (!$display) { return new PermissionResult(false, 'Display not found', 404); } + if ($display->isDeactivated()) { return new PermissionResult(false, 'Display is deactivated', 400); } - // Check Pro feature - check workspace owners + // Pro feature check: check if any workspace owner has Pro if (!empty($options['pro'])) { - $workspace = $display->workspace; - $hasPro = false; - - if ($workspace) { - $owners = $workspace->owners()->get(); - foreach ($owners as $owner) { - if ($owner->hasPro()) { - $hasPro = true; - break; - } - } - } - - if (!$hasPro) { + $workspaceOwner = $display->workspace->owners()->first(); + if (!$workspaceOwner || !$workspaceOwner->hasPro()) { return new PermissionResult(false, 'This is a Pro feature. Please upgrade to Pro to use this feature.', 403); } } diff --git a/backend/docs/CODING_STANDARDS.md b/backend/docs/CODING_STANDARDS.md new file mode 100644 index 0000000..1a211c7 --- /dev/null +++ b/backend/docs/CODING_STANDARDS.md @@ -0,0 +1,64 @@ +# Coding Standards + +This document outlines coding standards and best practices for the Spacepad backend codebase. + +## Import Statements + +**Always use import statements at the top of files instead of inline fully qualified class names.** + +### ✅ Correct + +```php +get(); + $user = User::find(1); + $isValid = InstanceService::hasValidLicense(); + } +} +``` + +### ❌ Incorrect + +```php +get(); + $user = \App\Models\User::find(1); + $isValid = \App\Services\InstanceService::hasValidLicense(); + } +} +``` + +### Why? + +- **Readability**: Import statements make it clear which classes are used in a file +- **Maintainability**: Easier to refactor and understand dependencies +- **IDE Support**: Better autocomplete and navigation +- **PSR Standards**: Follows PSR-12 coding standard +- **Consistency**: Matches Laravel conventions + +### When to Use Fully Qualified Names + +Only use fully qualified class names (`\App\Models\...`) when: +- There's a naming conflict that requires disambiguation +- You're using a class from a different namespace that's not commonly imported + +In all other cases, use `use` statements at the top of the file. + diff --git a/backend/resources/views/components/displays/table-row.blade.php b/backend/resources/views/components/displays/table-row.blade.php new file mode 100644 index 0000000..ee96c9b --- /dev/null +++ b/backend/resources/views/components/displays/table-row.blade.php @@ -0,0 +1,114 @@ +@props(['display']) + + + +
{{ $display->name }}
+
{{ $display->display_name }}
+ + +
+ @if($display->calendar->outlookAccount) +
+ + {{ $display->calendar->outlookAccount->name }} +
+ @endif + @if($display->calendar->googleAccount) +
+ + {{ $display->calendar->googleAccount->name }} +
+ @endif + @if($display->calendar->caldavAccount) +
+ + {{ $display->calendar->caldavAccount->name }} +
+ @endif +
{{ $display->calendar->name }}
+
+ + + + {{ $display->status->label() }} + + + +
+
+ @if($display->devices->isNotEmpty()) +
+
+
+
+ + +
+ @else +
+
+
+ No devices + @endif +
+ @if($display->last_sync_at) +
Last sync {{ $display->last_sync_at->diffForHumans() }}
+ @endif +
+ + +
+
+ @csrf + @method('PATCH') + + +
+ @if(auth()->user()->hasPro()) + + + + + + + @else + + + + + + + @endif +
+ @csrf + @method('DELETE') + +
+
+ + + From a9a9b3b5c080a3271def249de6a7b1a42c5c2f9f Mon Sep 17 00:00:00 2001 From: Martijn van de Wetering Date: Sun, 11 Jan 2026 11:36:51 +0100 Subject: [PATCH 11/19] chore: Fix tests --- backend/app/Services/DisplayService.php | 2 +- .../tests/Feature/API/EventControllerTest.php | 13 ++++++- .../tests/Feature/DisplaySettingsApiTest.php | 36 +++++++++++++++---- backend/tests/Unit/DisplaySettingsTest.php | 23 +++++++++--- 4 files changed, 62 insertions(+), 12 deletions(-) diff --git a/backend/app/Services/DisplayService.php b/backend/app/Services/DisplayService.php index 23d8ef9..fe613b0 100644 --- a/backend/app/Services/DisplayService.php +++ b/backend/app/Services/DisplayService.php @@ -36,7 +36,7 @@ public function validateDisplayPermission(?string $displayId, string $deviceId, } if (!$displayId) { - return new PermissionResult(false, 'Display ID is required', 400); + return new PermissionResult(false, 'Display not found', 404); } // Get all workspace IDs the user is a member of diff --git a/backend/tests/Feature/API/EventControllerTest.php b/backend/tests/Feature/API/EventControllerTest.php index 9218d5f..407cf15 100644 --- a/backend/tests/Feature/API/EventControllerTest.php +++ b/backend/tests/Feature/API/EventControllerTest.php @@ -16,11 +16,18 @@ beforeEach(function () { $this->user = User::factory()->create(); - $this->device = Device::factory()->create(['user_id' => $this->user->id]); + // User boot method automatically creates a workspace, get the primary workspace + $this->workspace = $this->user->primaryWorkspace(); + + $this->device = Device::factory()->create([ + 'user_id' => $this->user->id, + 'workspace_id' => $this->workspace->id, + ]); // Create calendar first $this->calendar = Calendar::factory()->create([ 'user_id' => $this->user->id, + 'workspace_id' => $this->workspace->id, 'calendar_id' => 'test@example.com', 'name' => 'Test Calendar' ]); @@ -28,6 +35,7 @@ // Then create display with calendar $this->display = Display::factory()->create([ 'user_id' => $this->user->id, + 'workspace_id' => $this->workspace->id, 'calendar_id' => $this->calendar->id, 'status' => 'active' ]); @@ -58,6 +66,7 @@ $outlookAccount = OutlookAccount::factory()->create(['user_id' => $this->user->id]); Room::factory()->create([ 'user_id' => $this->user->id, + 'workspace_id' => $this->workspace->id, 'calendar_id' => $this->calendar->id, 'email_address' => 'test@example.com' ]); @@ -128,6 +137,7 @@ $googleAccount = GoogleAccount::factory()->create(['user_id' => $this->user->id]); Room::factory()->create([ 'user_id' => $this->user->id, + 'workspace_id' => $this->workspace->id, 'calendar_id' => $this->calendar->id, 'email_address' => 'test@example.com' ]); @@ -254,6 +264,7 @@ $outlookAccount = OutlookAccount::factory()->create(['user_id' => $this->user->id]); Room::factory()->create([ 'user_id' => $this->user->id, + 'workspace_id' => $this->workspace->id, 'calendar_id' => $this->calendar->id, 'email_address' => 'test@example.com' ]); diff --git a/backend/tests/Feature/DisplaySettingsApiTest.php b/backend/tests/Feature/DisplaySettingsApiTest.php index 1f65ccb..a508782 100644 --- a/backend/tests/Feature/DisplaySettingsApiTest.php +++ b/backend/tests/Feature/DisplaySettingsApiTest.php @@ -19,9 +19,17 @@ public function test_display_api_includes_settings() $user = User::factory()->create([ 'usage_type' => UsageType::PERSONAL, ]); + $workspace = $user->primaryWorkspace(); - $display = Display::factory()->create(['user_id' => $user->id]); - $device = Device::factory()->create(['user_id' => $user->id, 'display_id' => $display->id]); + $display = Display::factory()->create([ + 'user_id' => $user->id, + 'workspace_id' => $workspace->id, + ]); + $device = Device::factory()->create([ + 'user_id' => $user->id, + 'workspace_id' => $workspace->id, + 'display_id' => $display->id, + ]); // Set some display settings DisplaySettings::setCheckInEnabled($display, true); @@ -54,9 +62,17 @@ public function test_display_api_includes_default_settings_when_none_set() $user = User::factory()->create([ 'usage_type' => UsageType::PERSONAL, ]); + $workspace = $user->primaryWorkspace(); - $display = Display::factory()->create(['user_id' => $user->id]); - $device = Device::factory()->create(['user_id' => $user->id, 'display_id' => $display->id]); + $display = Display::factory()->create([ + 'user_id' => $user->id, + 'workspace_id' => $workspace->id, + ]); + $device = Device::factory()->create([ + 'user_id' => $user->id, + 'workspace_id' => $workspace->id, + 'display_id' => $display->id, + ]); $response = $this->actingAs($device) ->getJson('/api/displays'); @@ -73,9 +89,17 @@ public function test_display_settings_are_encrypted_in_database() $user = User::factory()->create([ 'usage_type' => UsageType::PERSONAL, ]); + $workspace = $user->primaryWorkspace(); - $display = Display::factory()->create(['user_id' => $user->id]); - Device::factory()->create(['user_id' => $user->id, 'display_id' => $display->id]); + $display = Display::factory()->create([ + 'user_id' => $user->id, + 'workspace_id' => $workspace->id, + ]); + Device::factory()->create([ + 'user_id' => $user->id, + 'workspace_id' => $workspace->id, + 'display_id' => $display->id, + ]); // Set display settings DisplaySettings::setCheckInEnabled($display, true); diff --git a/backend/tests/Unit/DisplaySettingsTest.php b/backend/tests/Unit/DisplaySettingsTest.php index 8ece9d3..8d95986 100644 --- a/backend/tests/Unit/DisplaySettingsTest.php +++ b/backend/tests/Unit/DisplaySettingsTest.php @@ -12,7 +12,11 @@ test('display settings helper can get and set boolean values', function () { $user = User::factory()->create(); - $display = Display::factory()->create(['user_id' => $user->id]); + $workspace = $user->primaryWorkspace(); + $display = Display::factory()->create([ + 'user_id' => $user->id, + 'workspace_id' => $workspace->id, + ]); // Test setting check-in enabled expect(DisplaySettings::setCheckInEnabled($display, true))->toBeTrue(); @@ -23,14 +27,21 @@ expect(DisplaySettings::isBookingEnabled($display))->toBeTrue(); // Test default values - $newDisplay = Display::factory()->create(['user_id' => $user->id]); + $newDisplay = Display::factory()->create([ + 'user_id' => $user->id, + 'workspace_id' => $workspace->id, + ]); expect(DisplaySettings::isCheckInEnabled($newDisplay))->toBeFalse(); expect(DisplaySettings::isBookingEnabled($newDisplay))->toBeFalse(); }); test('display model convenience methods work correctly', function () { $user = User::factory()->create(); - $display = Display::factory()->create(['user_id' => $user->id]); + $workspace = $user->primaryWorkspace(); + $display = Display::factory()->create([ + 'user_id' => $user->id, + 'workspace_id' => $workspace->id, + ]); // Test default values expect($display->isCheckInEnabled())->toBeFalse(); @@ -47,7 +58,11 @@ test('display settings can be retrieved as array', function () { $user = User::factory()->create(); - $display = Display::factory()->create(['user_id' => $user->id]); + $workspace = $user->primaryWorkspace(); + $display = Display::factory()->create([ + 'user_id' => $user->id, + 'workspace_id' => $workspace->id, + ]); // Set some settings DisplaySettings::setCheckInEnabled($display, true); From 6c09c9f31848087bd3bc7d26a5e69e5fdf27cb95 Mon Sep 17 00:00:00 2001 From: Martijn van de Wetering Date: Sun, 11 Jan 2026 12:51:04 +0100 Subject: [PATCH 12/19] feat: Add impersonating --- .../app/Http/Controllers/AdminController.php | 119 ++++++++++- .../Http/Controllers/WorkspaceController.php | 191 ------------------ .../components/impersonation-banner.blade.php | 20 ++ .../resources/views/layouts/base.blade.php | 2 +- .../resources/views/layouts/blank.blade.php | 3 + backend/resources/views/pages/admin.blade.php | 44 +++- .../vendor/pagination/tailwind.blade.php | 107 ++++++++++ backend/routes/web.php | 2 + 8 files changed, 276 insertions(+), 212 deletions(-) delete mode 100644 backend/app/Http/Controllers/WorkspaceController.php create mode 100644 backend/resources/views/components/impersonation-banner.blade.php create mode 100644 backend/resources/views/vendor/pagination/tailwind.blade.php diff --git a/backend/app/Http/Controllers/AdminController.php b/backend/app/Http/Controllers/AdminController.php index 2a222ba..ed53806 100644 --- a/backend/app/Http/Controllers/AdminController.php +++ b/backend/app/Http/Controllers/AdminController.php @@ -17,12 +17,27 @@ class AdminController extends Controller { - public function index() + /** + * Check if the current request is authorized for admin access + */ + private function checkAdminAccess(): void { $user = Auth::user(); + + // Prevent access if impersonating + if (session()->get('impersonating')) { + abort(403, 'Cannot access admin panel while impersonating. Please stop impersonating first.'); + } + + // Check if current user is admin if (!$user || !$user->isAdmin() || config('settings.is_self_hosted')) { abort(403); } + } + + public function index() + { + $this->checkAdminAccess(); $activeDisplays = Display::where('status', DisplayStatus::ACTIVE)->count(); $totalDisplays = Display::count(); @@ -114,11 +129,28 @@ public function index() return $user; }); - // All users for the users overview tab - $allUsers = User::query() + // All users for the users overview tab (paginated for performance) + $search = request()->get('search'); + $allUsersQuery = User::query() ->withCount('displays') + ->with(['subscriptions' => function($query) { + $query->where(function($q) { + $q->whereNull('ends_at') + ->orWhere('ends_at', '>', now()); + }); + }]); + + if ($search) { + $allUsersQuery->where(function($query) use ($search) { + $query->where('name', 'like', "%{$search}%") + ->orWhere('email', 'like', "%{$search}%"); + }); + } + + $allUsers = $allUsersQuery ->orderBy('created_at', 'desc') - ->get(); + ->paginate(50) + ->withQueryString(); return view('pages.admin', [ 'activeInstances' => $activeInstances, @@ -330,10 +362,9 @@ private function getSubscriptionPrice(string $subscriptionId, int $displaysCount */ public function showUser(User $user) { + $this->checkAdminAccess(); + $admin = Auth::user(); - if (!$admin || !$admin->isAdmin() || config('settings.is_self_hosted')) { - abort(403); - } // Load user relationships for display $user->load([ @@ -377,10 +408,9 @@ public function showUser(User $user) */ public function deleteUser(Request $request, User $user): RedirectResponse { + $this->checkAdminAccess(); + $admin = Auth::user(); - if (!$admin || !$admin->isAdmin() || config('settings.is_self_hosted')) { - abort(403); - } // Prevent deleting yourself if ($user->id === $admin->id) { @@ -530,4 +560,73 @@ public function deleteUser(Request $request, User $user): RedirectResponse return redirect()->route('admin.index') ->with('success', "User account {$user->email} and all associated data have been permanently deleted."); } + + /** + * Impersonate a user + */ + public function impersonate(User $user): RedirectResponse + { + $this->checkAdminAccess(); + + $admin = Auth::user(); + + // Prevent impersonating yourself + if ($admin->id === $user->id) { + return redirect()->route('admin.index') + ->with('error', 'You cannot impersonate yourself.'); + } + + // Store original admin ID in session + session()->put('impersonating', true); + session()->put('impersonator_id', $admin->id); + + // Log in as the target user + Auth::login($user); + + logger()->info('Admin started impersonating user', [ + 'admin_id' => $admin->id, + 'admin_email' => $admin->email, + 'impersonated_user_id' => $user->id, + 'impersonated_user_email' => $user->email, + ]); + + return redirect()->route('dashboard') + ->with('success', "You are now impersonating {$user->email}"); + } + + /** + * Stop impersonating and return to admin account + */ + public function stopImpersonating(): RedirectResponse + { + $impersonatorId = session()->get('impersonator_id'); + + if (!$impersonatorId) { + return redirect()->route('dashboard'); + } + + $impersonator = User::find($impersonatorId); + if (!$impersonator || !$impersonator->isAdmin()) { + session()->forget(['impersonating', 'impersonator_id']); + return redirect()->route('dashboard'); + } + + $impersonatedUser = Auth::user(); + + // Clear impersonation session + session()->forget(['impersonating', 'impersonator_id']); + + // Log back in as admin + Auth::login($impersonator); + + logger()->info('Admin stopped impersonating user', [ + 'admin_id' => $impersonator->id, + 'admin_email' => $impersonator->email, + 'impersonated_user_id' => $impersonatedUser->id, + 'impersonated_user_email' => $impersonatedUser->email, + ]); + + return redirect()->route('admin.index') + ->with('success', 'Stopped impersonating user.'); + } } diff --git a/backend/app/Http/Controllers/WorkspaceController.php b/backend/app/Http/Controllers/WorkspaceController.php deleted file mode 100644 index aeecd92..0000000 --- a/backend/app/Http/Controllers/WorkspaceController.php +++ /dev/null @@ -1,191 +0,0 @@ -user(); - $workspaces = $user->accessibleWorkspaces(); - $primaryWorkspace = $user->primaryWorkspace(); - - return view('pages.workspaces.index', [ - 'workspaces' => $workspaces, - 'primaryWorkspace' => $primaryWorkspace, - ]); - } - - /** - * Show workspace members page - */ - public function show(Workspace $workspace) - { - $user = auth()->user(); - - // Check if user has access to this workspace - if (!$workspace->hasMember($user)) { - abort(403, 'You do not have access to this workspace'); - } - - $workspace->load('members'); - - return view('pages.workspaces.show', [ - 'workspace' => $workspace, - 'userRole' => $workspace->getUserRole($user), - ]); - } - - /** - * Add a member to the workspace - */ - public function addMember(Request $request, Workspace $workspace): RedirectResponse - { - $user = auth()->user(); - - // Only workspace owners/admins can add members - if (!$workspace->canBeManagedBy($user)) { - abort(403, 'You do not have permission to add members to this workspace'); - } - - // Check if user has Pro (required for team features) - if (!$user->hasPro()) { - return back()->withErrors(['error' => 'Pro subscription is required to add team members']); - } - - $validated = $request->validate([ - 'email' => ['required', 'email', 'exists:users,email'], - 'role' => ['required', Rule::in([WorkspaceRole::ADMIN->value, WorkspaceRole::MEMBER->value])], - ]); - - $memberUser = User::where('email', $validated['email'])->first(); - - // Check if user is already a member - if ($workspace->hasMember($memberUser)) { - return back()->withErrors(['email' => 'User is already a member of this workspace']); - } - - // Add member (use WorkspaceMember::create to generate ULID) - $role = WorkspaceRole::from($validated['role']); - WorkspaceMember::create([ - 'workspace_id' => $workspace->id, - 'user_id' => $memberUser->id, - 'role' => $role, - ]); - - logger()->info('Workspace member added', [ - 'workspace_id' => $workspace->id, - 'added_by' => $user->id, - 'member_id' => $memberUser->id, - 'role' => $validated['role'], - ]); - - return back()->with('success', 'Member added successfully'); - } - - /** - * Update a member's role - */ - public function updateMemberRole(Request $request, Workspace $workspace, User $member): RedirectResponse - { - $user = auth()->user(); - - // Only workspace owners/admins can update roles - if (!$workspace->canBeManagedBy($user)) { - abort(403, 'You do not have permission to update member roles'); - } - - // Cannot change owner's role - $memberRole = $workspace->getUserRole($member); - if ($memberRole === WorkspaceRole::OWNER) { - return back()->withErrors(['error' => 'Cannot change the owner\'s role']); - } - - $validated = $request->validate([ - 'role' => ['required', Rule::in([WorkspaceRole::ADMIN->value, WorkspaceRole::MEMBER->value])], - ]); - - $role = WorkspaceRole::from($validated['role']); - WorkspaceMember::where('workspace_id', $workspace->id) - ->where('user_id', $member->id) - ->update(['role' => $role]); - - logger()->info('Workspace member role updated', [ - 'workspace_id' => $workspace->id, - 'updated_by' => $user->id, - 'member_id' => $member->id, - 'new_role' => $validated['role'], - ]); - - return back()->with('success', 'Member role updated successfully'); - } - - /** - * Remove a member from the workspace - */ - public function removeMember(Workspace $workspace, User $member): RedirectResponse - { - $user = auth()->user(); - - // Only workspace owners/admins can remove members - if (!$workspace->canBeManagedBy($user)) { - abort(403, 'You do not have permission to remove members from this workspace'); - } - - // Cannot remove owner - $memberRole = $workspace->getUserRole($member); - if ($memberRole === WorkspaceRole::OWNER) { - return back()->withErrors(['error' => 'Cannot remove the workspace owner']); - } - - $workspace->members()->detach($member->id); - - logger()->info('Workspace member removed', [ - 'workspace_id' => $workspace->id, - 'removed_by' => $user->id, - 'member_id' => $member->id, - ]); - - return back()->with('success', 'Member removed successfully'); - } - - /** - * Update workspace name - */ - public function update(Request $request, Workspace $workspace): RedirectResponse - { - $user = auth()->user(); - - // Only workspace owners/admins can update workspace - if (!$workspace->canBeManagedBy($user)) { - abort(403, 'You do not have permission to update this workspace'); - } - - $validated = $request->validate([ - 'name' => ['required', 'string', 'max:255'], - ]); - - $workspace->update(['name' => $validated['name']]); - - logger()->info('Workspace updated', [ - 'workspace_id' => $workspace->id, - 'updated_by' => $user->id, - 'new_name' => $validated['name'], - ]); - - return back()->with('success', 'Workspace updated successfully'); - } -} - diff --git a/backend/resources/views/components/impersonation-banner.blade.php b/backend/resources/views/components/impersonation-banner.blade.php new file mode 100644 index 0000000..26b1e1e --- /dev/null +++ b/backend/resources/views/components/impersonation-banner.blade.php @@ -0,0 +1,20 @@ +@if(session('impersonating')) +
+
+
+
+ + ⚠️ You are impersonating {{ auth()->user()->email }} + +
+
+ @csrf + +
+
+
+
+@endif + diff --git a/backend/resources/views/layouts/base.blade.php b/backend/resources/views/layouts/base.blade.php index d752d3d..9f00b2f 100644 --- a/backend/resources/views/layouts/base.blade.php +++ b/backend/resources/views/layouts/base.blade.php @@ -13,7 +13,7 @@ @endif
- @if(auth()->user()->isAdmin() && !config('settings.is_self_hosted')) + @if(!session('impersonating') && auth()->user()->isAdmin() && !config('settings.is_self_hosted')) Admin diff --git a/backend/resources/views/layouts/blank.blade.php b/backend/resources/views/layouts/blank.blade.php index 839adcc..c696c2c 100644 --- a/backend/resources/views/layouts/blank.blade.php +++ b/backend/resources/views/layouts/blank.blade.php @@ -33,6 +33,9 @@ @includeWhen(config('googletagmanager.enabled') && config('googletagmanager.id'), 'googletagmanager::body') @stack('modals') + + @include('components.impersonation-banner') +
@yield('page')
diff --git a/backend/resources/views/pages/admin.blade.php b/backend/resources/views/pages/admin.blade.php index d8129d7..f29c0bd 100644 --- a/backend/resources/views/pages/admin.blade.php +++ b/backend/resources/views/pages/admin.blade.php @@ -3,7 +3,7 @@ @section('title', 'Admin dashboard') @section('content') -
+
diff --git a/backend/resources/views/vendor/pagination/tailwind.blade.php b/backend/resources/views/vendor/pagination/tailwind.blade.php new file mode 100644 index 0000000..28274a9 --- /dev/null +++ b/backend/resources/views/vendor/pagination/tailwind.blade.php @@ -0,0 +1,107 @@ +@if ($paginator->hasPages()) + +@endif + diff --git a/backend/routes/web.php b/backend/routes/web.php index 5bab3e6..0abd569 100644 --- a/backend/routes/web.php +++ b/backend/routes/web.php @@ -118,6 +118,8 @@ Route::get('/admin', [AdminController::class, 'index'])->name('admin.index'); Route::get('/admin/users/{user}', [AdminController::class, 'showUser'])->name('admin.users.show'); Route::delete('/admin/users/{user}', [AdminController::class, 'deleteUser'])->name('admin.users.delete'); + Route::post('/admin/users/{user}/impersonate', [AdminController::class, 'impersonate'])->name('admin.users.impersonate'); + Route::post('/admin/stop-impersonating', [AdminController::class, 'stopImpersonating'])->name('admin.stop-impersonating'); // Display image serving route Route::get('/displays/{display}/images/{type}', [DisplaySettingsController::class, 'serveImage']) From 67cce5af204ae248aa68b339aa4bc428733fbd4d Mon Sep 17 00:00:00 2001 From: Martijn van de Wetering Date: Sun, 11 Jan 2026 13:48:36 +0100 Subject: [PATCH 13/19] feat: Add workspace switch possibility --- .../app/Http/Controllers/AdminController.php | 3 + .../Controllers/CalDAVAccountsController.php | 5 + .../Http/Controllers/DashboardController.php | 59 ++++++--- .../Http/Controllers/DisplayController.php | 42 ++++--- .../Http/Controllers/WorkspaceController.php | 49 ++++++++ backend/app/Models/CalDAVAccount.php | 6 + backend/app/Models/GoogleAccount.php | 6 + backend/app/Models/OutlookAccount.php | 7 ++ backend/app/Models/User.php | 24 ++++ backend/app/Services/GoogleService.php | 6 + backend/app/Services/OutlookService.php | 6 + ...03_add_workspace_id_to_accounts_tables.php | 51 ++++++++ ..._create_workspaces_for_existing_users.php} | 20 +++- .../resources/views/pages/dashboard.blade.php | 112 ++++++------------ backend/routes/web.php | 3 + 15 files changed, 288 insertions(+), 111 deletions(-) create mode 100644 backend/app/Http/Controllers/WorkspaceController.php create mode 100644 backend/database/migrations/2025_12_30_000003_add_workspace_id_to_accounts_tables.php rename backend/database/migrations/{2025_12_30_000003_create_workspaces_for_existing_users.php => 2025_12_30_000004_create_workspaces_for_existing_users.php} (73%) diff --git a/backend/app/Http/Controllers/AdminController.php b/backend/app/Http/Controllers/AdminController.php index ed53806..a24edef 100644 --- a/backend/app/Http/Controllers/AdminController.php +++ b/backend/app/Http/Controllers/AdminController.php @@ -580,6 +580,9 @@ public function impersonate(User $user): RedirectResponse session()->put('impersonating', true); session()->put('impersonator_id', $admin->id); + // Clear any workspace selection from admin session - let impersonated user's workspace be selected + session()->forget('selected_workspace_id'); + // Log in as the target user Auth::login($user); diff --git a/backend/app/Http/Controllers/CalDAVAccountsController.php b/backend/app/Http/Controllers/CalDAVAccountsController.php index 7715d2c..3f1e9d1 100644 --- a/backend/app/Http/Controllers/CalDAVAccountsController.php +++ b/backend/app/Http/Controllers/CalDAVAccountsController.php @@ -41,9 +41,14 @@ public function store(Request $request): RedirectResponse ])->withInput(); } + // Get selected workspace (from session or default to primary) + $selectedWorkspace = auth()->user()->getSelectedWorkspace(); + $workspaceId = $selectedWorkspace?->id; + // Create the CalDAV account $account = CalDAVAccount::create([ 'user_id' => auth()->id(), + 'workspace_id' => $workspaceId, 'name' => parse_url($validated['url'], PHP_URL_HOST), 'email' => $validated['username'], 'url' => $validated['url'], diff --git a/backend/app/Http/Controllers/DashboardController.php b/backend/app/Http/Controllers/DashboardController.php index ba2dff2..2503d8b 100644 --- a/backend/app/Http/Controllers/DashboardController.php +++ b/backend/app/Http/Controllers/DashboardController.php @@ -8,6 +8,10 @@ use Illuminate\Contracts\View\View; use App\Services\InstanceService; use App\Models\Display; +use App\Models\Calendar; +use App\Models\OutlookAccount; +use App\Models\GoogleAccount; +use App\Models\CalDAVAccount; class DashboardController extends Controller { @@ -22,26 +26,45 @@ public function __construct(protected OutlookService $outlookService) public function __invoke(): View|Factory|Application { $user = auth()->user(); - $user->load(['outlookAccounts', 'googleAccounts', 'caldavAccounts', 'workspaces']); + + // Load workspaces with pivot data (role) - this includes all workspaces user is a member of + $workspaces = $user->workspaces()->withPivot('role')->get(); // Get connect code for user $connectCode = $user->getConnectCode(); - // Get displays from all workspaces user is a member of - $workspaceIds = $user->workspaces->pluck('id'); - $displays = Display::whereIn('workspace_id', $workspaceIds) - ->with(['workspace', 'calendar.outlookAccount', 'calendar.googleAccount', 'calendar.caldavAccount']) - ->get() - ->groupBy('workspace_id'); + // Get selected workspace (from session or default to primary) + $selectedWorkspace = $user->getSelectedWorkspace(); + + // Get displays from selected workspace only + if ($selectedWorkspace) { + $displays = Display::where('workspace_id', $selectedWorkspace->id) + ->with(['workspace', 'calendar.outlookAccount', 'calendar.googleAccount', 'calendar.caldavAccount']) + ->get(); + + // Get accounts for the selected workspace + $outlookAccounts = OutlookAccount::where('workspace_id', $selectedWorkspace->id) + ->get(); + $googleAccounts = GoogleAccount::where('workspace_id', $selectedWorkspace->id) + ->get(); + $caldavAccounts = CalDAVAccount::where('workspace_id', $selectedWorkspace->id) + ->get(); + } else { + $displays = collect(); + $outlookAccounts = collect(); + $googleAccounts = collect(); + $caldavAccounts = collect(); + } logger()->info('Dashboard page accessed', [ 'user_id' => $user->id, 'email' => $user->email, - 'outlook_accounts_count' => $user->outlookAccounts->count(), - 'google_accounts_count' => $user->googleAccounts->count(), - 'caldav_accounts_count' => $user->caldavAccounts->count(), - 'displays_count' => $displays->flatten()->count(), - 'workspaces_count' => $user->workspaces->count(), + 'outlook_accounts_count' => $outlookAccounts->count(), + 'google_accounts_count' => $googleAccounts->count(), + 'caldav_accounts_count' => $caldavAccounts->count(), + 'displays_count' => $displays->count(), + 'workspaces_count' => $workspaces->count(), + 'selected_workspace_id' => $selectedWorkspace?->id, 'ip' => request()->ip(), 'user_agent' => substr(request()->userAgent() ?? '', 0, 100), ]); @@ -49,12 +72,12 @@ public function __invoke(): View|Factory|Application $isSelfHosted = config('settings.is_self_hosted'); return view('pages.dashboard', [ - 'outlookAccounts' => $user->outlookAccounts, - 'googleAccounts' => $user->googleAccounts, - 'caldavAccounts' => $user->caldavAccounts, - 'displays' => $displays, // Grouped by workspace - 'displaysFlat' => $displays->flatten(), // Flat list for compatibility - 'workspaces' => $user->workspaces, + 'outlookAccounts' => $outlookAccounts, + 'googleAccounts' => $googleAccounts, + 'caldavAccounts' => $caldavAccounts, + 'displays' => $displays, + 'workspaces' => $workspaces, + 'selectedWorkspace' => $selectedWorkspace, 'connectCode' => $connectCode, 'primaryWorkspace' => $user->primaryWorkspace(), 'version' => config('settings.version', 'dev'), diff --git a/backend/app/Http/Controllers/DisplayController.php b/backend/app/Http/Controllers/DisplayController.php index 3f548dd..197b555 100644 --- a/backend/app/Http/Controllers/DisplayController.php +++ b/backend/app/Http/Controllers/DisplayController.php @@ -36,13 +36,25 @@ public function create(): View { $user = auth()->user(); $workspaces = $user->workspaces()->withPivot('role')->get(); + $selectedWorkspace = $user->getSelectedWorkspace(); + + // Filter accounts to only show those for the selected workspace + if ($selectedWorkspace) { + $outlookAccounts = $user->outlookAccounts()->where('workspace_id', $selectedWorkspace->id)->get(); + $googleAccounts = $user->googleAccounts()->where('workspace_id', $selectedWorkspace->id)->get(); + $caldavAccounts = $user->caldavAccounts()->where('workspace_id', $selectedWorkspace->id)->get(); + } else { + $outlookAccounts = $user->outlookAccounts; + $googleAccounts = $user->googleAccounts; + $caldavAccounts = $user->caldavAccounts; + } return view('pages.displays.create', [ - 'outlookAccounts' => $user->outlookAccounts, - 'googleAccounts' => $user->googleAccounts, - 'caldavAccounts' => $user->caldavAccounts, + 'outlookAccounts' => $outlookAccounts, + 'googleAccounts' => $googleAccounts, + 'caldavAccounts' => $caldavAccounts, 'workspaces' => $workspaces, - 'defaultWorkspace' => $user->primaryWorkspace(), + 'defaultWorkspace' => $selectedWorkspace ?? $user->primaryWorkspace(), ]); } @@ -71,8 +83,10 @@ public function store(CreateDisplayRequest $request): RedirectResponse $user = auth()->user(); - // Get workspace from request, or default to primary - $workspaceId = $validatedData['workspace_id'] ?? $user->primaryWorkspace()?->id; + // Get workspace from request, session (selected workspace), or default to primary + $workspaceId = $validatedData['workspace_id'] + ?? session()->get('selected_workspace_id') + ?? $user->primaryWorkspace()?->id; if (!$workspaceId) { return redirect()->back()->with('error', 'No workspace found. Please contact support.'); @@ -162,7 +176,7 @@ private function createCalendar(array $validatedData, $workspace): Calendar $calendar = Calendar::firstOrCreate([ 'calendar_id' => $calendarId, - 'user_id' => $userId, + 'workspace_id' => $workspace->id, ], [ 'calendar_id' => $calendarId, 'user_id' => $userId, @@ -171,14 +185,9 @@ private function createCalendar(array $validatedData, $workspace): Calendar 'name' => $calendarName, ]); - // Update workspace_id if calendar already existed - if (!$calendar->workspace_id) { - $calendar->update(['workspace_id' => $workspace->id]); - } - Room::firstOrCreate([ 'email_address' => $calendarId, - 'user_id' => $userId, + 'workspace_id' => $workspace->id, ], [ 'email_address' => $calendarId, 'user_id' => $userId, @@ -195,7 +204,7 @@ private function createCalendar(array $validatedData, $workspace): Calendar $calendar = Calendar::firstOrCreate([ 'calendar_id' => $calendarData[0], - 'user_id' => $userId, + 'workspace_id' => $workspace->id, ], [ 'user_id' => $userId, 'workspace_id' => $workspace->id, @@ -204,11 +213,6 @@ private function createCalendar(array $validatedData, $workspace): Calendar 'name' => $calendarName, ]); - // Update workspace_id if calendar already existed - if (!$calendar->workspace_id) { - $calendar->update(['workspace_id' => $workspace->id]); - } - return $calendar; } diff --git a/backend/app/Http/Controllers/WorkspaceController.php b/backend/app/Http/Controllers/WorkspaceController.php new file mode 100644 index 0000000..f464312 --- /dev/null +++ b/backend/app/Http/Controllers/WorkspaceController.php @@ -0,0 +1,49 @@ +validate([ + 'workspace_id' => 'required|string|exists:workspaces,id', + ]); + + $user = Auth::user(); + $workspaceId = $request->input('workspace_id'); + + // Validate user has access to this workspace (checks membership, not Pro status) + // This works for both regular users and impersonated users + $workspace = $user->workspaces()->find($workspaceId); + if (!$workspace) { + abort(403, 'You do not have access to this workspace.'); + } + + // Store selected workspace in session + // This persists during impersonation since we're using the impersonated user's session + session()->put('selected_workspace_id', $workspace->id); + + logger()->info('User switched workspace', [ + 'user_id' => $user->id, + 'workspace_id' => $workspace->id, + 'workspace_name' => $workspace->name, + 'is_impersonating' => session()->has('impersonating'), + ]); + + return redirect()->route('dashboard')->with('success', "Switched to workspace: {$workspace->name}"); + } +} + diff --git a/backend/app/Models/CalDAVAccount.php b/backend/app/Models/CalDAVAccount.php index dab6413..9076fd1 100644 --- a/backend/app/Models/CalDAVAccount.php +++ b/backend/app/Models/CalDAVAccount.php @@ -25,6 +25,7 @@ class CalDAVAccount extends Model 'status', 'permission_type', 'user_id', + 'workspace_id', 'url', 'username', 'password', @@ -49,4 +50,9 @@ public function calendars(): HasMany { return $this->hasMany(Calendar::class, 'caldav_account_id'); } + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } } diff --git a/backend/app/Models/GoogleAccount.php b/backend/app/Models/GoogleAccount.php index a79bccb..7323ac3 100644 --- a/backend/app/Models/GoogleAccount.php +++ b/backend/app/Models/GoogleAccount.php @@ -27,6 +27,7 @@ class GoogleAccount extends Model 'service_account_file_path', 'booking_method', 'user_id', + 'workspace_id', 'google_id', 'token', 'refresh_token', @@ -61,4 +62,9 @@ public function isBusiness(): bool { return !empty($this->hosted_domain); } + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } } diff --git a/backend/app/Models/OutlookAccount.php b/backend/app/Models/OutlookAccount.php index 13dc95b..aae69d3 100644 --- a/backend/app/Models/OutlookAccount.php +++ b/backend/app/Models/OutlookAccount.php @@ -9,6 +9,7 @@ use App\Enums\AccountStatus; use App\Enums\PermissionType; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\BelongsTo; class OutlookAccount extends Model { @@ -23,6 +24,7 @@ class OutlookAccount extends Model 'status', 'permission_type', 'user_id', + 'workspace_id', 'outlook_id', 'token', 'refresh_token', @@ -51,4 +53,9 @@ public function calendars(): HasMany { return $this->hasMany(Calendar::class, 'outlook_account_id'); } + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } } diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php index 430863f..aec4c75 100644 --- a/backend/app/Models/User.php +++ b/backend/app/Models/User.php @@ -279,4 +279,28 @@ public function isAdmin(): bool { return (bool) $this->is_admin; } + + /** + * Get the currently selected workspace (from session) or default to primary workspace + * + * Note: This works for all users (including non-Pro users) who are members of workspaces. + * Workspace access is based on membership, not Pro status. + */ + public function getSelectedWorkspace(): ?Workspace + { + $selectedWorkspaceId = session()->get('selected_workspace_id'); + + if ($selectedWorkspaceId) { + // Validate user has access to the selected workspace (checks membership, not Pro status) + $workspace = $this->workspaces()->find($selectedWorkspaceId); + if ($workspace) { + return $workspace; + } + // If selected workspace is invalid or user no longer has access, clear it from session + session()->forget('selected_workspace_id'); + } + + // Default to primary workspace (first owned workspace, or first workspace user is a member of) + return $this->primaryWorkspace(); + } } diff --git a/backend/app/Services/GoogleService.php b/backend/app/Services/GoogleService.php index e30c76d..a655575 100644 --- a/backend/app/Services/GoogleService.php +++ b/backend/app/Services/GoogleService.php @@ -59,14 +59,20 @@ public function authenticateGoogleAccount(string $authCode, PermissionType $perm $googleService = new Oauth2($this->client); $googleUserInfo = $googleService->userinfo->get(); + // Get selected workspace (from session or default to primary) + $selectedWorkspace = auth()->user()->getSelectedWorkspace(); + $workspaceId = $selectedWorkspace?->id; + // Save the user's Google account and tokens in the database return GoogleAccount::updateOrCreate( [ 'user_id' => auth()->id(), 'google_id' => $googleUserInfo->id, + 'workspace_id' => $workspaceId, ], [ 'user_id' => auth()->id(), + 'workspace_id' => $workspaceId, 'email' => $googleUserInfo->email, 'name' => $googleUserInfo->name, 'avatar' => $googleUserInfo->picture, diff --git a/backend/app/Services/OutlookService.php b/backend/app/Services/OutlookService.php index 772a141..beb14dd 100644 --- a/backend/app/Services/OutlookService.php +++ b/backend/app/Services/OutlookService.php @@ -111,14 +111,20 @@ public function authenticateOutlookAccount(string $authCode, string|PermissionTy $tenantId = $this->getTenantId($tokenData['access_token']); + // Get selected workspace (from session or default to primary) + $selectedWorkspace = auth()->user()->getSelectedWorkspace(); + $workspaceId = $selectedWorkspace?->id; + // Save the Outlook account and tokens return OutlookAccount::updateOrCreate( [ 'user_id' => auth()->id(), 'outlook_id' => $user['id'], + 'workspace_id' => $workspaceId, ], [ 'user_id' => auth()->id(), + 'workspace_id' => $workspaceId, 'email' => $user['mail'] ?? $user['userPrincipalName'], 'name' => $user['displayName'], 'tenant_id' => $tenantId, diff --git a/backend/database/migrations/2025_12_30_000003_add_workspace_id_to_accounts_tables.php b/backend/database/migrations/2025_12_30_000003_add_workspace_id_to_accounts_tables.php new file mode 100644 index 0000000..019c7bc --- /dev/null +++ b/backend/database/migrations/2025_12_30_000003_add_workspace_id_to_accounts_tables.php @@ -0,0 +1,51 @@ +foreignUlid('workspace_id')->nullable()->after('user_id')->constrained()->onDelete('cascade'); + }); + + // Add workspace_id to google_accounts + Schema::table('google_accounts', function (Blueprint $table) { + $table->foreignUlid('workspace_id')->nullable()->after('user_id')->constrained()->onDelete('cascade'); + }); + + // Add workspace_id to caldav_accounts + Schema::table('caldav_accounts', function (Blueprint $table) { + $table->foreignUlid('workspace_id')->nullable()->after('user_id')->constrained()->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('outlook_accounts', function (Blueprint $table) { + $table->dropForeign(['workspace_id']); + $table->dropColumn('workspace_id'); + }); + + Schema::table('google_accounts', function (Blueprint $table) { + $table->dropForeign(['workspace_id']); + $table->dropColumn('workspace_id'); + }); + + Schema::table('caldav_accounts', function (Blueprint $table) { + $table->dropForeign(['workspace_id']); + $table->dropColumn('workspace_id'); + }); + } +}; + diff --git a/backend/database/migrations/2025_12_30_000003_create_workspaces_for_existing_users.php b/backend/database/migrations/2025_12_30_000004_create_workspaces_for_existing_users.php similarity index 73% rename from backend/database/migrations/2025_12_30_000003_create_workspaces_for_existing_users.php rename to backend/database/migrations/2025_12_30_000004_create_workspaces_for_existing_users.php index bdcf194..318369b 100644 --- a/backend/database/migrations/2025_12_30_000003_create_workspaces_for_existing_users.php +++ b/backend/database/migrations/2025_12_30_000004_create_workspaces_for_existing_users.php @@ -10,7 +10,10 @@ use App\Models\Display; use App\Models\Device; use App\Models\Calendar; -use App\Models\Room; +use App\Models\Room; +use App\Models\OutlookAccount; +use App\Models\GoogleAccount; +use App\Models\CalDAVAccount; use App\Enums\WorkspaceRole; return new class extends Migration @@ -51,6 +54,21 @@ public function up(): void // Migrate rooms to workspace Room::where('user_id', $user->id)->update(['workspace_id' => $workspace->id]); + + // Migrate Outlook accounts to workspace + OutlookAccount::where('user_id', $user->id) + ->whereNull('workspace_id') + ->update(['workspace_id' => $workspace->id]); + + // Migrate Google accounts to workspace + GoogleAccount::where('user_id', $user->id) + ->whereNull('workspace_id') + ->update(['workspace_id' => $workspace->id]); + + // Migrate CalDAV accounts to workspace + CalDAVAccount::where('user_id', $user->id) + ->whereNull('workspace_id') + ->update(['workspace_id' => $workspace->id]); } }); } diff --git a/backend/resources/views/pages/dashboard.blade.php b/backend/resources/views/pages/dashboard.blade.php index 0bd7008..dff99c8 100644 --- a/backend/resources/views/pages/dashboard.blade.php +++ b/backend/resources/views/pages/dashboard.blade.php @@ -2,15 +2,42 @@ @section('title', 'Management dashboard') @section('actions') - {{-- Instruction Banner --}} - @if(auth()->user()->hasAnyDisplay() && $connectCode) -
-
-

Connect code

-
-

{{ chunk_split($connectCode, 3, ' ') }}

+ {{-- Workspace Selector and Connect Code --}} + @if($workspaces->count() > 1 || $connectCode) +
+ @if($workspaces->count() > 1) +
+ @csrf +
+ + +
+
+ @endif + @if(auth()->user()->hasAnyDisplay() && $connectCode) +
+

Connect code

+
+

{{ chunk_split($connectCode, 3, ' ') }}

+
-
+ @endif
@endif @endsection @@ -124,9 +151,6 @@ class="inline-flex items-center rounded-md bg-yellow-600 px-3 py-2 text-sm font-

Displays

Overview of your displays and their status. - @if($workspaces->count() > 1) - ({{ $workspaces->count() }} workspaces) - @endif

@@ -205,66 +229,8 @@ class="inline-flex items-center rounded-md bg-yellow-600 px-3 py-2 text-sm font- - @if($workspaces->count() > 1) - @foreach($displays as $workspaceId => $workspaceDisplays) - @php - $workspace = $workspaces->firstWhere('id', $workspaceId); - @endphp - @if($workspace) - - -
-

- {{ $workspace->name }} - ({{ $workspaceDisplays->count() }} display{{ $workspaceDisplays->count() !== 1 ? 's' : '' }}) -

- - @if($workspace->pivot->role === \App\Enums\WorkspaceRole::OWNER->value) - Owner - @elseif($workspace->pivot->role === \App\Enums\WorkspaceRole::ADMIN->value) - Admin - @else - Member - @endif - -
- - - @endif - @foreach($workspaceDisplays as $display) - - @endforeach - @endforeach - @if($displays->isEmpty()) - - -
- -

- One more step and you're set up -

-

Pick the calendar or room you would like to synchronize. You are able to connect multiple tablets to one display.

- @if(! $isSelfHosted && auth()->user()->shouldUpgrade()) - - Create new display Pro - - @elseif($isSelfHosted && auth()->user()->shouldUpgrade()) - - Create new display Pro - - @else - - - Create new display - - @endif -
- - - @endif - @else - @forelse($displaysFlat as $display) - + @forelse($displays as $display) + @empty @@ -284,14 +250,14 @@ class="inline-flex items-center rounded-md bg-yellow-600 px-3 py-2 text-sm font- @else - Create new display + + Create new display @endif
@endforelse - @endif
diff --git a/backend/routes/web.php b/backend/routes/web.php index 0abd569..023ee3b 100644 --- a/backend/routes/web.php +++ b/backend/routes/web.php @@ -17,6 +17,7 @@ use App\Http\Controllers\LicenseController; use Illuminate\Support\Facades\Route; use App\Http\Controllers\AdminController; +use App\Http\Controllers\WorkspaceController; Route::get('/login', [LoginController::class, 'create']) ->middleware('guest') @@ -99,6 +100,8 @@ Route::post('/license/validate', [LicenseController::class, 'validateLicense'])->name('license.validate'); + Route::post('/workspaces/switch', [WorkspaceController::class, 'switch'])->name('workspaces.switch'); + Route::get('/billing/thanks', function () { \Spatie\GoogleTagManager\GoogleTagManagerFacade::flashPush([ 'event' => 'purchase', From 89ebc05703d9a1ad6ede73b30326dda20dba01a9 Mon Sep 17 00:00:00 2001 From: Martijn van de Wetering Date: Sun, 11 Jan 2026 14:19:44 +0100 Subject: [PATCH 14/19] feat: Make sure users in other pro workspaces can access pro features --- .../Http/Controllers/DisplayController.php | 4 +- .../Controllers/DisplaySettingsController.php | 12 +++--- backend/app/Models/User.php | 40 +++++++++++++++++++ backend/app/Models/Workspace.php | 14 +++++++ backend/app/Services/DisplayService.php | 3 +- .../components/displays/table-row.blade.php | 2 +- .../resources/views/layouts/base.blade.php | 2 +- .../resources/views/pages/dashboard.blade.php | 8 ++-- .../views/pages/displays/create.blade.php | 2 +- .../pages/displays/customization.blade.php | 2 +- .../views/pages/displays/settings.blade.php | 2 +- 11 files changed, 72 insertions(+), 19 deletions(-) diff --git a/backend/app/Http/Controllers/DisplayController.php b/backend/app/Http/Controllers/DisplayController.php index 197b555..6902276 100644 --- a/backend/app/Http/Controllers/DisplayController.php +++ b/backend/app/Http/Controllers/DisplayController.php @@ -68,8 +68,8 @@ public function store(CreateDisplayRequest $request): RedirectResponse $provider = $validatedData['provider']; $accountId = $validatedData['account']; - // Check on access to create multiple displays - if (auth()->user()->shouldUpgrade()) { + // Check on access to create multiple displays (workspace-aware Pro check) + if (auth()->user()->shouldUpgradeForCurrentWorkspace()) { return redirect()->back()->with('error', 'You require an active Pro license to create multiple displays.'); } diff --git a/backend/app/Http/Controllers/DisplaySettingsController.php b/backend/app/Http/Controllers/DisplaySettingsController.php index 531f75a..ab906f0 100644 --- a/backend/app/Http/Controllers/DisplaySettingsController.php +++ b/backend/app/Http/Controllers/DisplaySettingsController.php @@ -21,8 +21,8 @@ public function index(Display $display): View { $this->authorize('update', $display); - // Check if user has Pro access - if (!auth()->user()->hasPro()) { + // Check if user has Pro access (workspace-aware) + if (!auth()->user()->hasProForCurrentWorkspace()) { return redirect()->route('dashboard')->with('error', 'Display settings are only available for Pro users.'); } @@ -35,8 +35,8 @@ public function update(Request $request, Display $display): RedirectResponse { $this->authorize('update', $display); - // Check if user has Pro access - if (!auth()->user()->hasPro()) { + // Check if user has Pro access (workspace-aware) + if (!auth()->user()->hasProForCurrentWorkspace()) { return redirect()->route('dashboard')->with('error', 'Display settings are only available for Pro users.'); } @@ -103,7 +103,7 @@ public function customization(Display $display): View { $this->authorize('update', $display); - if (!auth()->user()->hasPro()) { + if (!auth()->user()->hasProForCurrentWorkspace()) { return redirect()->route('dashboard')->with('error', 'Display customization is only available for Pro users.'); } @@ -116,7 +116,7 @@ public function updateCustomization(UpdateDisplayCustomizationRequest $request, { $this->authorize('update', $display); - if (!auth()->user()->hasPro()) { + if (!auth()->user()->hasProForCurrentWorkspace()) { return redirect()->route('dashboard')->with('error', 'Display customization is only available for Pro users.'); } diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php index aec4c75..1c267f6 100644 --- a/backend/app/Models/User.php +++ b/backend/app/Models/User.php @@ -206,6 +206,26 @@ public function hasPro(): bool return $this->is_unlimited || $this->subscribed(); } + /** + * Check if the user has Pro for the current workspace context. + * Returns true if the user has Pro OR if the selected workspace has Pro (any owner has Pro). + */ + public function hasProForCurrentWorkspace(): bool + { + // If user has Pro, they have Pro everywhere + if ($this->hasPro()) { + return true; + } + + // Check if the selected workspace has Pro (any owner has Pro) + $selectedWorkspace = $this->getSelectedWorkspace(); + if ($selectedWorkspace && $selectedWorkspace->hasPro()) { + return true; + } + + return false; + } + /** * Check if the user should be treated as a business user */ @@ -236,6 +256,26 @@ public function shouldUpgrade(): bool return ! $this->hasPro() && $this->hasAnyDisplay(); } + /** + * Check if the user should upgrade to Pro for the current workspace context. + * Returns false if the user has Pro OR if the selected workspace has Pro. + */ + public function shouldUpgradeForCurrentWorkspace(): bool + { + // If user has Pro for current workspace, no upgrade needed + if ($this->hasProForCurrentWorkspace()) { + return false; + } + + // Self Hosted: If the user is a personal user, use a soft limit + if (config('settings.is_self_hosted') && $this->isPersonalUser()) { + return false; + } + + // Cloud Hosted: If the user is a business user and doesn't have Pro, they should upgrade + return $this->hasAnyDisplay(); + } + public function getCheckoutUrl(?string $redirectUrl = null): ?Checkout { $redirectUrl ??= route('dashboard'); diff --git a/backend/app/Models/Workspace.php b/backend/app/Models/Workspace.php index d3de3bf..45bd964 100644 --- a/backend/app/Models/Workspace.php +++ b/backend/app/Models/Workspace.php @@ -114,5 +114,19 @@ public function getUserRole(User $user): ?WorkspaceRole $role = $member->pivot->role; return $role instanceof WorkspaceRole ? $role : WorkspaceRole::from($role); } + + /** + * Check if this workspace has Pro (any owner has Pro) + */ + public function hasPro(): bool + { + $owners = $this->owners()->with('subscriptions')->get(); + foreach ($owners as $owner) { + if ($owner->hasPro()) { + return true; + } + } + return false; + } } diff --git a/backend/app/Services/DisplayService.php b/backend/app/Services/DisplayService.php index fe613b0..ebebe08 100644 --- a/backend/app/Services/DisplayService.php +++ b/backend/app/Services/DisplayService.php @@ -60,8 +60,7 @@ public function validateDisplayPermission(?string $displayId, string $deviceId, // Pro feature check: check if any workspace owner has Pro if (!empty($options['pro'])) { - $workspaceOwner = $display->workspace->owners()->first(); - if (!$workspaceOwner || !$workspaceOwner->hasPro()) { + if (!$display->workspace->hasPro()) { return new PermissionResult(false, 'This is a Pro feature. Please upgrade to Pro to use this feature.', 403); } } diff --git a/backend/resources/views/components/displays/table-row.blade.php b/backend/resources/views/components/displays/table-row.blade.php index ee96c9b..e603051 100644 --- a/backend/resources/views/components/displays/table-row.blade.php +++ b/backend/resources/views/components/displays/table-row.blade.php @@ -86,7 +86,7 @@ @endif - @if(auth()->user()->hasPro()) + @if(auth()->user()->hasProForCurrentWorkspace()) diff --git a/backend/resources/views/layouts/base.blade.php b/backend/resources/views/layouts/base.blade.php index 9f00b2f..42ff7d4 100644 --- a/backend/resources/views/layouts/base.blade.php +++ b/backend/resources/views/layouts/base.blade.php @@ -8,7 +8,7 @@ Logo
Spacepad - @if(auth()->user()->hasPro()) + @if(auth()->user()->hasProForCurrentWorkspace()) Pro @endif diff --git a/backend/resources/views/pages/dashboard.blade.php b/backend/resources/views/pages/dashboard.blade.php index dff99c8..3ebab16 100644 --- a/backend/resources/views/pages/dashboard.blade.php +++ b/backend/resources/views/pages/dashboard.blade.php @@ -114,7 +114,7 @@ class="inline-flex items-center rounded-md bg-yellow-600 px-3 py-2 text-sm font- {{-- Commercial Banner --}} - @if(! auth()->user()->hasPro() && auth()->user()->hasAnyDisplay()) + @if(! auth()->user()->hasProForCurrentWorkspace() && auth()->user()->hasAnyDisplay())
@@ -161,7 +161,7 @@ class="inline-flex items-center rounded-md bg-yellow-600 px-3 py-2 text-sm font- @endif @if(auth()->user()->can('create', \App\Models\Display::class)) - @if(auth()->user()->shouldUpgrade()) + @if(auth()->user()->shouldUpgradeForCurrentWorkspace()) Create new display Pro @@ -240,11 +240,11 @@ class="inline-flex items-center rounded-md bg-yellow-600 px-3 py-2 text-sm font- One more step and you're set up

Pick the calendar or room you would like to synchronize. You are able to connect multiple tablets to one display.

- @if(! $isSelfHosted && auth()->user()->shouldUpgrade()) + @if(! $isSelfHosted && auth()->user()->shouldUpgradeForCurrentWorkspace()) Create new display Pro - @elseif($isSelfHosted && auth()->user()->shouldUpgrade()) + @elseif($isSelfHosted && auth()->user()->shouldUpgradeForCurrentWorkspace()) Create new display Pro diff --git a/backend/resources/views/pages/displays/create.blade.php b/backend/resources/views/pages/displays/create.blade.php index 6691844..5608afe 100644 --- a/backend/resources/views/pages/displays/create.blade.php +++ b/backend/resources/views/pages/displays/create.blade.php @@ -5,7 +5,7 @@ @php $isSelfHosted = config('settings.is_self_hosted'); $checkout = auth()->user()->getCheckoutUrl(route('displays.create')); - $userHasPro = auth()->user()->hasPro(); + $userHasPro = auth()->user()->hasProForCurrentWorkspace(); @endphp {{-- License Key Modal --}} diff --git a/backend/resources/views/pages/displays/customization.blade.php b/backend/resources/views/pages/displays/customization.blade.php index 67c868e..c2c87af 100644 --- a/backend/resources/views/pages/displays/customization.blade.php +++ b/backend/resources/views/pages/displays/customization.blade.php @@ -3,7 +3,7 @@ @section('container_class', 'max-w-2xl') @section('content') - @if(!auth()->user()->hasPro()) + @if(!auth()->user()->hasProForCurrentWorkspace())
diff --git a/backend/resources/views/pages/displays/settings.blade.php b/backend/resources/views/pages/displays/settings.blade.php index ac0eac5..3e05d86 100644 --- a/backend/resources/views/pages/displays/settings.blade.php +++ b/backend/resources/views/pages/displays/settings.blade.php @@ -3,7 +3,7 @@ @section('container_class', 'max-w-2xl') @section('content') - @if(!auth()->user()->hasPro()) + @if(!auth()->user()->hasProForCurrentWorkspace())
From 28410a9fbfe4c2a55f448e4ca594e8e6b47aa7a5 Mon Sep 17 00:00:00 2001 From: Martijn van de Wetering Date: Sun, 11 Jan 2026 22:00:50 +0100 Subject: [PATCH 15/19] feat: Correct workspace usage --- .../Http/Controllers/DisplayController.php | 9 ++++---- backend/app/Models/Display.php | 5 +++++ backend/app/Models/User.php | 21 +++++++++++++++++-- backend/app/Policies/DisplayPolicy.php | 2 +- .../resources/views/pages/dashboard.blade.php | 4 ++-- 5 files changed, 32 insertions(+), 9 deletions(-) diff --git a/backend/app/Http/Controllers/DisplayController.php b/backend/app/Http/Controllers/DisplayController.php index 6902276..49353bb 100644 --- a/backend/app/Http/Controllers/DisplayController.php +++ b/backend/app/Http/Controllers/DisplayController.php @@ -38,12 +38,13 @@ public function create(): View $workspaces = $user->workspaces()->withPivot('role')->get(); $selectedWorkspace = $user->getSelectedWorkspace(); - // Filter accounts to only show those for the selected workspace + // Filter accounts to show all accounts for the selected workspace (from any workspace member) if ($selectedWorkspace) { - $outlookAccounts = $user->outlookAccounts()->where('workspace_id', $selectedWorkspace->id)->get(); - $googleAccounts = $user->googleAccounts()->where('workspace_id', $selectedWorkspace->id)->get(); - $caldavAccounts = $user->caldavAccounts()->where('workspace_id', $selectedWorkspace->id)->get(); + $outlookAccounts = OutlookAccount::where('workspace_id', $selectedWorkspace->id)->get(); + $googleAccounts = GoogleAccount::where('workspace_id', $selectedWorkspace->id)->get(); + $caldavAccounts = CalDAVAccount::where('workspace_id', $selectedWorkspace->id)->get(); } else { + // Fallback to user's own accounts if no workspace selected $outlookAccounts = $user->outlookAccounts; $googleAccounts = $user->googleAccounts; $caldavAccounts = $user->caldavAccounts; diff --git a/backend/app/Models/Display.php b/backend/app/Models/Display.php index 92f446c..1ea91bb 100644 --- a/backend/app/Models/Display.php +++ b/backend/app/Models/Display.php @@ -53,6 +53,11 @@ public function eventSubscriptions(): HasMany return $this->hasMany(EventSubscription::class); } + public function events(): HasMany + { + return $this->hasMany(Event::class); + } + public function devices(): HasMany { return $this->hasMany(Device::class); diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php index 1c267f6..bbc24bc 100644 --- a/backend/app/Models/User.php +++ b/backend/app/Models/User.php @@ -190,11 +190,28 @@ public function getConnectCode(): string public function isOnboarded(): bool { + // Check if user has accounts OR if any workspace they're a member of has accounts + $hasAccounts = $this->hasAnyAccount(); + + if (!$hasAccounts) { + // Check if any workspace the user is a member of has accounts + $workspaceIds = $this->workspaces()->pluck('workspaces.id')->toArray(); + if (!empty($workspaceIds)) { + $workspaceAccountCount = OutlookAccount::whereIn('workspace_id', $workspaceIds)->count() + + GoogleAccount::whereIn('workspace_id', $workspaceIds)->count() + + CalDAVAccount::whereIn('workspace_id', $workspaceIds)->count(); + + if ($workspaceAccountCount > 0) { + $hasAccounts = true; + } + } + } + if (config('settings.is_self_hosted')) { - return $this->usage_type && $this->terms_accepted_at && $this->hasAnyAccount(); + return $this->usage_type && $this->terms_accepted_at && $hasAccounts; } - return $this->usage_type && $this->hasAnyAccount(); + return $this->usage_type && $hasAccounts; } public function hasPro(): bool diff --git a/backend/app/Policies/DisplayPolicy.php b/backend/app/Policies/DisplayPolicy.php index b7b1ca3..851f17e 100644 --- a/backend/app/Policies/DisplayPolicy.php +++ b/backend/app/Policies/DisplayPolicy.php @@ -16,7 +16,7 @@ class DisplayPolicy */ public function create(User $user): bool { - return $user->outlookAccounts()->count() > 0 || $user->googleAccounts()->count() > 0 || $user->caldavAccounts()->count() > 0; + return $user->isOnboarded(); } /** diff --git a/backend/resources/views/pages/dashboard.blade.php b/backend/resources/views/pages/dashboard.blade.php index 3ebab16..b5f6d13 100644 --- a/backend/resources/views/pages/dashboard.blade.php +++ b/backend/resources/views/pages/dashboard.blade.php @@ -30,7 +30,7 @@ class="flex-1 text-sm font-medium text-gray-900 bg-transparent border bg-white r
@endif - @if(auth()->user()->hasAnyDisplay() && $connectCode) + @if((auth()->user()->hasAnyDisplay() || auth()->user()->workspaces()->count() > 1) && $connectCode)

Connect code

@@ -154,7 +154,7 @@ class="inline-flex items-center rounded-md bg-yellow-600 px-3 py-2 text-sm font-

- @if(auth()->user()->hasAnyDisplay()) + @if(auth()->user()->hasAnyDisplay() || auth()->user()->workspaces()->count() > 1)
-
- @foreach($outlookAccounts as $outlookAccount) + @if($outlookAccounts->isEmpty() && $googleAccounts->isEmpty() && $caldavAccounts->isEmpty()) +
+
+

+ No accounts connected yet +

+

+ Connect a calendar account above to get started. You can connect Microsoft, Google, or CalDAV accounts. +

+
+
+ @else +
+ @foreach($outlookAccounts as $outlookAccount)
@if($outlookAccount->calendars->isEmpty())
@@ -437,7 +449,8 @@ class="grow flex items-center justify-center gap-3 rounded-lg border border-gray
@endforeach -
+
+ @endif
From 401bcacdaaeb2db350be44d3b1587bc43d4c9988 Mon Sep 17 00:00:00 2001 From: Martijn van de Wetering Date: Sun, 11 Jan 2026 22:28:43 +0100 Subject: [PATCH 17/19] bugfix: Remove instance deletion --- backend/app/Http/Controllers/AdminController.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/Http/Controllers/AdminController.php b/backend/app/Http/Controllers/AdminController.php index a24edef..b41c13c 100644 --- a/backend/app/Http/Controllers/AdminController.php +++ b/backend/app/Http/Controllers/AdminController.php @@ -536,8 +536,8 @@ public function deleteUser(Request $request, User $user): RedirectResponse // Delete workspace memberships (user's membership in workspaces they don't own) WorkspaceMember::where('user_id', $user->id)->delete(); - // Delete instance if exists - Instance::where('user_id', $user->id)->delete(); + // Note: Instances are system-wide (for self-hosted tracking), not user-specific + // No need to delete instances when deleting a user // Cancel LemonSqueezy subscriptions (if any) // Note: This doesn't actually cancel them in LemonSqueezy, just removes the local reference From bca08dcbb4f8b2ebb7f507f1646822d106728fcc Mon Sep 17 00:00:00 2001 From: Martijn van de Wetering Date: Sun, 25 Jan 2026 12:13:40 +0100 Subject: [PATCH 18/19] bugfix: Ensure cancelled events are not shown --- backend/app/Services/EventService.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/app/Services/EventService.php b/backend/app/Services/EventService.php index 8d9463c..1f63109 100644 --- a/backend/app/Services/EventService.php +++ b/backend/app/Services/EventService.php @@ -424,7 +424,11 @@ private function fetchGoogleEvents(Calendar $calendar, Display $display): Collec endDateTime: $display->getEndTime(), ); - return collect($events)->map(fn($e) => $this->sanitizeGoogleEvent($e)); + // Filter out cancelled events - Google Calendar returns cancelled events with status "cancelled" + // but they should not be displayed + return collect($events) + ->filter(fn($e) => $e->getStatus() !== 'cancelled') + ->map(fn($e) => $this->sanitizeGoogleEvent($e)); } /** From a718709ab69b5e8007d16f62a23ab839c9d9c150 Mon Sep 17 00:00:00 2001 From: Martijn van de Wetering Date: Sun, 8 Feb 2026 16:01:16 +0100 Subject: [PATCH 19/19] fix: Add retry with other event subscription path --- backend/app/Services/OutlookService.php | 27 ++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/backend/app/Services/OutlookService.php b/backend/app/Services/OutlookService.php index beb14dd..548d257 100644 --- a/backend/app/Services/OutlookService.php +++ b/backend/app/Services/OutlookService.php @@ -334,8 +334,11 @@ public function createEvent( if ($calendar->room) { // For rooms, use the user's calendar $endpoint = "https://graph.microsoft.com/v1.0/users/{$calendar->calendar_id}/calendar/events"; + } elseif ($calendar->is_primary) { + // For primary calendar, use /me/calendar/events (without calendar ID) + $endpoint = "https://graph.microsoft.com/v1.0/me/calendar/events"; } else { - // For calendars, use the calendar ID + // For other calendars, use the calendar ID $endpoint = "https://graph.microsoft.com/v1.0/me/calendars/{$calendar->calendar_id}/events"; } @@ -372,8 +375,11 @@ public function deleteEvent( if ($calendar->room) { // For rooms, use the user's calendar $endpoint = "https://graph.microsoft.com/v1.0/users/{$calendar->calendar_id}/calendar/events/{$eventId}"; + } elseif ($calendar->is_primary) { + // For primary calendar, use /me/calendar/events (without calendar ID) + $endpoint = "https://graph.microsoft.com/v1.0/me/calendar/events/{$eventId}"; } else { - // For calendars, use the calendar ID + // For other calendars, use the calendar ID $endpoint = "https://graph.microsoft.com/v1.0/me/calendars/{$calendar->calendar_id}/events/{$eventId}"; } @@ -402,7 +408,22 @@ public function createEventSubscriptionByUser( Display $display, string $emailAddress ): ?EventSubscription { - return $this->createEventSubscription($outlookAccount, $display, "/users/$emailAddress/events"); + // Try the standard path first + try { + return $this->createEventSubscription($outlookAccount, $display, "/users/$emailAddress/events"); + } catch (\Exception $e) { + // If it fails with a resource invalid error, try with /calendar/ path as backup + if (str_contains($e->getMessage(), 'Resource') && str_contains($e->getMessage(), 'invalid')) { + logger()->warning('Subscription failed with /events path, trying /calendar/events as backup', [ + 'email' => $emailAddress, + 'display_id' => $display->id, + 'error' => $e->getMessage(), + ]); + return $this->createEventSubscription($outlookAccount, $display, "/users/$emailAddress/calendar/events"); + } + // Re-throw if it's not a resource invalid error + throw $e; + } } /**