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/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/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/Console/Commands/CheckMarketingTriggers.php b/backend/app/Console/Commands/CheckMarketingTriggers.php new file mode 100644 index 0000000..9d706ed --- /dev/null +++ b/backend/app/Console/Commands/CheckMarketingTriggers.php @@ -0,0 +1,206 @@ +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') + ->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}"; + 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 + * Activity includes: user activity, device activity + */ + private function checkPassiveUsers(): void + { + $cutoffDate = now()->subDays(14); + $previousCutoffDate = now()->subDays(15); + + $users = User::whereNull('deleted_at') + ->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) { + $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 + * Activity includes: user activity, device activity + */ + private function checkInactiveUsers(): void + { + $cutoffDate = now()->subDays(30); + $previousCutoffDate = now()->subDays(31); + + $users = User::whereNull('deleted_at') + ->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) { + $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/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; + } +} + 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/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/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 @@ +{$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..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; @@ -26,17 +27,37 @@ 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) { + $user = User::find($connectedUserId); + $workspace = $user?->primaryWorkspace(); + $device = Device::firstOrCreate([ 'user_id' => $connectedUserId, - 'uid' => $request->validated()['uid'], + 'uid' => $uid, ],[ 'user_id' => $connectedUserId, - 'uid' => $request->validated()['uid'], - 'name' => $request->validated()['name'], + '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, + 'device_uid' => substr($uid, 0, 8) . '...', + 'device_name' => $name, + 'ip' => $request->ip(), + 'user_agent' => substr($request->userAgent() ?? '', 0, 100), ]); return $this->success( @@ -47,6 +68,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/DeviceController.php b/backend/app/Http/Controllers/API/DeviceController.php index e85ddd3..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,8 +26,33 @@ public function changeDisplay(ChangeDisplayRequest $request): JsonResponse $device = auth()->user(); $data = $request->validated(); + if (!$device->user_id) { + return $this->error( + 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('user_id', $device->user_id) + ->whereIn('workspace_id', $workspaceIds) ->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 4300f8c..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,12 +31,35 @@ public function index(): JsonResponse /** @var Device $device */ $device = auth()->user(); + 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: []); + } + $displays = Display::query() - ->where('user_id', $device->user_id) + ->whereIn('workspace_id', $workspaceIds) ->whereIn('status', [DisplayStatus::READY, DisplayStatus::ACTIVE]) ->with('settings') ->get(); + 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(), + ]); + return $this->success(data: DisplayResource::collection($displays)); } @@ -46,17 +70,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 +135,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 +155,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 +193,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 +241,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/AdminController.php b/backend/app/Http/Controllers/AdminController.php index 5c48485..b41c13c 100644 --- a/backend/app/Http/Controllers/AdminController.php +++ b/backend/app/Http/Controllers/AdminController.php @@ -6,19 +6,38 @@ 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 { - 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(); @@ -110,10 +129,34 @@ public function index() return $user; }); + // 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') + ->paginate(50) + ->withQueryString(); + return view('pages.admin', [ 'activeInstances' => $activeInstances, 'activeDisplays' => $activeDisplays, 'payingUsers' => $payingUsers, + 'allUsers' => $allUsers, 'activeDisplaysCount' => $activeDisplays->count(), 'totalDisplays' => $totalDisplays, 'activeInstancesCount' => $activeInstances->count(), @@ -313,4 +356,280 @@ private function getSubscriptionPrice(string $subscriptionId, int $displaysCount return null; } } + + /** + * Show user details page + */ + public function showUser(User $user) + { + $this->checkAdminAccess(); + + $admin = Auth::user(); + + // 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 + { + $this->checkAdminAccess(); + + $admin = Auth::user(); + + // 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(); + + // 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 + // 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."); + } + + /** + * 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); + + // 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); + + 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/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 85f9a2b..c7f0427 100644 --- a/backend/app/Http/Controllers/DashboardController.php +++ b/backend/app/Http/Controllers/DashboardController.php @@ -6,6 +6,12 @@ use Illuminate\Contracts\Foundation\Application; use Illuminate\Contracts\View\Factory; 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 { @@ -19,15 +25,75 @@ 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(); + + // Load workspaces with pivot data (role) - this includes all workspaces user is a member of + $workspaces = $user->workspaces()->withPivot('role')->get(); + + // Get selected workspace (from session or default to primary) + $selectedWorkspace = $user->getSelectedWorkspace(); + + // Get connect code from workspace owner (or current user if no workspace selected) + $connectCode = null; + if ($selectedWorkspace) { + $workspaceOwner = $selectedWorkspace->owners()->first(); + if ($workspaceOwner) { + $connectCode = $workspaceOwner->getConnectCode(); + } + } + // Fallback to current user's connect code if no workspace or owner found + if (!$connectCode) { + $connectCode = $user->getConnectCode(); + } + + // 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' => $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), + ]); + + $isSelfHosted = config('settings.is_self_hosted'); + return view('pages.dashboard', [ - 'outlookAccounts' => $user->outlookAccounts, - 'googleAccounts' => $user->googleAccounts, - 'caldavAccounts' => $user->caldavAccounts, - 'displays' => $user->displays, + 'outlookAccounts' => $outlookAccounts, + 'googleAccounts' => $googleAccounts, + 'caldavAccounts' => $caldavAccounts, + 'displays' => $displays, + 'workspaces' => $workspaces, + 'selectedWorkspace' => $selectedWorkspace, '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 090888f..49353bb 100644 --- a/backend/app/Http/Controllers/DisplayController.php +++ b/backend/app/Http/Controllers/DisplayController.php @@ -34,14 +34,28 @@ 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(); + $selectedWorkspace = $user->getSelectedWorkspace(); + + // Filter accounts to show all accounts for the selected workspace (from any workspace member) + if ($selectedWorkspace) { + $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; + } return view('pages.displays.create', [ 'outlookAccounts' => $outlookAccounts, 'googleAccounts' => $googleAccounts, 'caldavAccounts' => $caldavAccounts, + 'workspaces' => $workspaces, + 'defaultWorkspace' => $selectedWorkspace ?? $user->primaryWorkspace(), ]); } @@ -55,8 +69,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.'); } @@ -68,12 +82,35 @@ public function store(CreateDisplayRequest $request): RedirectResponse default => throw new \InvalidArgumentException('Invalid provider') }; - $display = DB::transaction(function () use ($validatedData) { + $user = auth()->user(); + + // 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.'); + } + + // 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 - $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 +164,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 +177,22 @@ private function createCalendar(array $validatedData): Calendar $calendar = Calendar::firstOrCreate([ 'calendar_id' => $calendarId, - 'user_id' => auth()->id(), + 'workspace_id' => $workspace->id, ], [ 'calendar_id' => $calendarId, - 'user_id' => auth()->id(), + 'user_id' => $userId, + 'workspace_id' => $workspace->id, "{$provider}_account_id" => $accountId, 'name' => $calendarName, ]); Room::firstOrCreate([ 'email_address' => $calendarId, - 'user_id' => auth()->id(), + 'workspace_id' => $workspace->id, ], [ 'email_address' => $calendarId, - 'user_id' => auth()->id(), + 'user_id' => $userId, + 'workspace_id' => $workspace->id, 'calendar_id' => $calendar->id, 'name' => $calendarName, ]); @@ -163,15 +203,18 @@ 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(), + 'workspace_id' => $workspace->id, ], [ - 'user_id' => auth()->id(), + 'user_id' => $userId, + 'workspace_id' => $workspace->id, "{$provider}_account_id" => $accountId, 'calendar_id' => $calendarData[0], 'name' => $calendarName, ]); + + return $calendar; } /** 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/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/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/Listeners/SendTrialExpiredOrCancelledNotification.php b/backend/app/Listeners/SendTrialExpiredOrCancelledNotification.php new file mode 100644 index 0000000..84def11 --- /dev/null +++ b/backend/app/Listeners/SendTrialExpiredOrCancelledNotification.php @@ -0,0 +1,27 @@ + '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/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/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..1ea91bb 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,11 +43,21 @@ 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); } + public function events(): HasMany + { + return $this->hasMany(Event::class); + } + public function devices(): HasMany { return $this->hasMany(Device::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/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 145e8f1..bbc24bc 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. * @@ -26,6 +53,8 @@ class User extends Authenticatable */ protected $fillable = [ 'name', + 'first_name', + 'last_name', 'email', 'password', 'microsoft_id', @@ -84,11 +113,50 @@ 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); } + /** + * 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; @@ -99,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"); @@ -109,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; @@ -117,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 @@ -133,6 +223,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 */ @@ -163,6 +273,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'); @@ -206,4 +336,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/Models/Workspace.php b/backend/app/Models/Workspace.php new file mode 100644 index 0000000..45bd964 --- /dev/null +++ b/backend/app/Models/Workspace.php @@ -0,0 +1,132 @@ +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); + } + + /** + * 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/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..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(); } /** @@ -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..ebebe08 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,18 +24,47 @@ 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::with('user.workspaces')->find($deviceId); + + if (!$device || !$device->user_id) { + return new PermissionResult(false, 'Device not found', 404); + } + + $user = $device->user; + if (!$user) { + return new PermissionResult(false, 'User not found', 404); + } + + if (!$displayId) { + 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); } - 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); + + // Pro feature check: check if any workspace owner has Pro + if (!empty($options['pro'])) { + if (!$display->workspace->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/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)); } /** diff --git a/backend/app/Services/GoogleService.php b/backend/app/Services/GoogleService.php index 5692eb1..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, @@ -376,10 +382,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 +404,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..548d257 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, @@ -328,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"; } @@ -366,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}"; } @@ -396,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; + } } /** @@ -444,18 +471,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; } 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 @@ + 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/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/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']); + }); + } +}; + 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_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_000004_create_workspaces_for_existing_users.php b/backend/database/migrations/2025_12_30_000004_create_workspaces_for_existing_users.php new file mode 100644 index 0000000..318369b --- /dev/null +++ b/backend/database/migrations/2025_12_30_000004_create_workspaces_for_existing_users.php @@ -0,0 +1,86 @@ +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]); + + // 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]); + } + }); + } + + /** + * 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/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/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/public/index.php b/backend/public/index.php index 947d989..0d5aafe 100644 --- a/backend/public/index.php +++ b/backend/public/index.php @@ -9,6 +9,9 @@ require $maintenance; } +// 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/resources/views/components/displays/table-row.blade.php b/backend/resources/views/components/displays/table-row.blade.php new file mode 100644 index 0000000..e603051 --- /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()->hasProForCurrentWorkspace()) + + + + + + + @else + + + + + + + @endif +
+ @csrf + @method('DELETE') + +
+
+ + + 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/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/base.blade.php b/backend/resources/views/layouts/base.blade.php index d752d3d..42ff7d4 100644 --- a/backend/resources/views/layouts/base.blade.php +++ b/backend/resources/views/layouts/base.blade.php @@ -8,12 +8,12 @@ Logo Spacepad - @if(auth()->user()->hasPro()) + @if(auth()->user()->hasProForCurrentWorkspace()) Pro @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 e5d1ec1..c696c2c 100644 --- a/backend/resources/views/layouts/blank.blade.php +++ b/backend/resources/views/layouts/blank.blade.php @@ -28,10 +28,14 @@ @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') @stack('modals') + + @include('components.impersonation-banner') +
@yield('page')
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/backend/resources/views/pages/admin.blade.php b/backend/resources/views/pages/admin.blade.php index 4cf449f..f29c0bd 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

+
+
+ +
+
+ + + + + + + + + + + + + + + @forelse($allUsers as $user) + + + + + + + + + + + @empty + + + + @endforelse + +
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 + +
+ @csrf + +
+
+
+ @if(request('search')) + No users found matching "{{ request('search') }}" + @else + No users found + @endif +
+
+ @if($allUsers->hasPages()) +
+ {{ $allUsers->links('vendor.pagination.tailwind') }} +
+ @endif +
+
+
@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..cdd29f6 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()) -
-
-

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() || auth()->user()->workspaces()->count() > 1) && $connectCode) +
+

Connect code

+
+

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

+
-
+ @endif
@endif @endsection @@ -87,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())
@@ -122,17 +149,19 @@ 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(auth()->user()->hasAnyDisplay()) + @if(auth()->user()->hasAnyDisplay() || auth()->user()->workspaces()->count() > 1) @endif @if(auth()->user()->can('create', \App\Models\Display::class)) - @if(auth()->user()->shouldUpgrade()) + @if(auth()->user()->shouldUpgradeForCurrentWorkspace()) Create new display Pro @@ -201,116 +230,7 @@ class="inline-flex items-center rounded-md bg-yellow-600 px-3 py-2 text-sm font- @forelse($displays as $display) - - -
{{ $display->name }}
-
{{ $display->calendar->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->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') - -
-
- - + @empty @@ -320,17 +240,18 @@ 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 @else - Create new display + + Create new display @endif
@@ -387,8 +308,20 @@ class="grow flex items-center justify-center gap-3 rounded-lg border border-gray Connected accounts
-
- @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())
@@ -516,9 +449,29 @@ class="grow flex items-center justify-center gap-3 rounded-lg border border-gray
@endforeach -
+
+ @endif
+ + {{-- 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') @@ -562,6 +515,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..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 --}} @@ -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

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())
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/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(); diff --git a/backend/routes/web.php b/backend/routes/web.php index 04d1051..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', @@ -116,6 +119,10 @@ })->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'); + 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']) 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); diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 5ad9f93..cfa2be3 100755 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -14,14 +14,21 @@ services: PHP_OPCACHE_ENABLE: 0 AUTORUN_ENABLED: 'false' AUTORUN_LARAVEL_MIGRATION: 'false' + # OpenTelemetry configuration + OTEL_PHP_AUTOLOAD_ENABLED: true + OTEL_EXPORTER_OTLP_PROTOCOL: http/protobuf + OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://host.docker.internal:4318} + OTEL_EXPORTER_OTLP_HEADERS: ${OTEL_EXPORTER_OTLP_HEADERS:-} + OTEL_RESOURCE_ATTRIBUTES: "service.name=spacepad-app,service.namespace=spacepad,service.version=${SPACEPAD_VERSION:-dev},deployment.environment=local" + OTEL_SERVICE_NAME: spacepad-app + OTEL_METRICS_EXPORTER: otlp + OTEL_TRACES_EXPORTER: otlp + OTEL_LOGS_EXPORTER: otlp + OTEL_EXPERIMENTAL_METRIC_ENABLE: true ports: - "8000:8080" extra_hosts: - "host.docker.internal:host-gateway" - depends_on: - - mariadb - - redis - - mailhog scheduler: image: spacepad/app:latest @@ -33,10 +40,18 @@ services: - ./backend:/var/www/html extra_hosts: - "host.docker.internal:host-gateway" - depends_on: - - mariadb - - redis - - mailhog + environment: + # OpenTelemetry configuration + OTEL_PHP_AUTOLOAD_ENABLED: true + OTEL_EXPORTER_OTLP_PROTOCOL: http/protobuf + OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://host.docker.internal:4318} + OTEL_EXPORTER_OTLP_HEADERS: ${OTEL_EXPORTER_OTLP_HEADERS:-} + OTEL_RESOURCE_ATTRIBUTES: "service.name=spacepad-scheduler,service.namespace=spacepad,service.version=${SPACEPAD_VERSION:-dev},deployment.environment=local" + OTEL_SERVICE_NAME: spacepad-scheduler + OTEL_METRICS_EXPORTER: otlp + OTEL_TRACES_EXPORTER: otlp + OTEL_LOGS_EXPORTER: otlp + OTEL_EXPERIMENTAL_METRIC_ENABLE: true mariadb: image: mariadb:lts @@ -72,6 +87,20 @@ services: - "1025:1025" # SMTP server - "8025:8025" # Web interface + # k6 Load Generator (Continuous Traffic) + k6-load: + image: grafana/k6:latest + volumes: + - ./k6:/scripts + command: run /scripts/load-test.js + environment: + - BACKEND_URL=http://app:8080 + networks: + - app + depends_on: + - app + restart: unless-stopped + networks: app: 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', + }; +} +