From a89ac6ec07cc15df59aefc42afdec302466481ee Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Fri, 13 Mar 2026 12:10:42 -0400 Subject: [PATCH 1/9] docs: add spec for portal training video completions refactor Adds design spec for migrating training video completion flow from direct DB access to RBAC-guarded NestJS API endpoints with SWR hooks for reactive UI updates. Co-Authored-By: Claude Opus 4.6 --- ...3-13-portal-training-completions-design.md | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-13-portal-training-completions-design.md diff --git a/docs/superpowers/specs/2026-03-13-portal-training-completions-design.md b/docs/superpowers/specs/2026-03-13-portal-training-completions-design.md new file mode 100644 index 000000000..2d2634fa0 --- /dev/null +++ b/docs/superpowers/specs/2026-03-13-portal-training-completions-design.md @@ -0,0 +1,171 @@ +# Portal Training Video Completions — Design Spec + +## Problem + +Employees cannot reliably complete training videos in the portal. Two root causes: + +1. **No RBAC coverage**: The portal's `mark-video-completed` route writes directly to the DB, bypassing the NestJS API and RBAC entirely. Employees/contractors have no `training` or `portal` permissions — their actions are unaudited. + +2. **Stale UI**: Training completion data is fetched server-side in `OrganizationDashboard` and passed as static props. After marking a video complete, the progress bar and "all completed" screen never update because there's no client-side data fetching or cache invalidation. + +## Design + +### 1. New `portal` Permission Resource + +Add `portal: ['read', 'update']` to `packages/auth/src/permissions.ts`. + +- `portal:read` — view own training completions, own compliance status +- `portal:update` — mark own training videos complete + +Granted to: `employee`, `contractor`, `owner`, `admin`. Not granted to `auditor` — auditors have no compliance obligations and don't use the portal. + +This is separate from `training:read/update` which gates admin-level operations (send completion emails, generate certificates for any member). The `portal` permission scopes to self-service actions on the authenticated user's own data. + +Add `'portal'` to the `GRCResource` type union in `require-permission.decorator.ts` (note: this type is documentation-only, not enforced at runtime by `RequirePermission`). + +### 2. New NestJS API Endpoints + +Add two endpoints to the existing `TrainingController`: + +#### `GET /v1/training/completions` + +- Guard: `@RequirePermission('portal', 'read')` +- Extracts `memberId` from `request.memberId` (session auth) — no member ID in URL +- Returns: `EmployeeTrainingVideoCompletion[]` for the authenticated user +- Scoped to session auth only (employees use cookies) + +**Important**: The `@MemberId()` decorator returns `string | undefined` (it does not throw like `@UserId()`). Both endpoints must guard against undefined `memberId` and throw `BadRequestException` if missing — this protects against API key or service token requests where no member context exists. + +```typescript +@Get('completions') +@RequirePermission('portal', 'read') +async getCompletions( + @MemberId() memberId: string | undefined, + @OrganizationId() organizationId: string, +): Promise { + if (!memberId) { + throw new BadRequestException('Session authentication required'); + } + return this.trainingService.getCompletions(memberId, organizationId); +} +``` + +#### `POST /v1/training/completions/:videoId/complete` + +- Guard: `@RequirePermission('portal', 'update')` +- Extracts `memberId` from `request.memberId` (session auth) — must guard against undefined +- Validates `videoId` against known training video IDs +- Creates or updates the `EmployeeTrainingVideoCompletion` record +- After marking complete, checks if all training is done and triggers completion email internally via `TrainingService.sendTrainingCompletionEmailIfComplete` (replaces the old portal service-token call — no service token permission changes needed) +- Returns: the updated `EmployeeTrainingVideoCompletion` record + +```typescript +@Post('completions/:videoId/complete') +@RequirePermission('portal', 'update') +async markComplete( + @MemberId() memberId: string | undefined, + @OrganizationId() organizationId: string, + @Param('videoId') videoId: string, +): Promise { + if (!memberId) { + throw new BadRequestException('Session authentication required'); + } + return this.trainingService.markVideoComplete(memberId, organizationId, videoId); +} +``` + +### 3. Training Service Additions + +Add two methods to `TrainingService`: + +#### `getCompletions(memberId, organizationId)` + +- Validates member belongs to organization +- Returns all `EmployeeTrainingVideoCompletion` records for the member + +#### `markVideoComplete(memberId, organizationId, videoId)` + +- Validates member belongs to organization +- Validates `videoId` is in the known `TRAINING_VIDEO_IDS` list +- Upserts the completion record (create if not exists, update `completedAt` if null) +- After success, checks if all training is complete and sends completion email if so +- Returns the upserted record + +### 4. Portal SWR Hook + +Create a `useTrainingCompletions` hook in the portal that: + +- Fetches from `GET /v1/training/completions` via the NestJS API (using `credentials: 'include'` for session cookies) +- Accepts `fallbackData` from server-side props for SSR hydration +- Exposes a `markVideoComplete(videoId)` function that: + - Calls `POST /v1/training/completions/:videoId/complete` + - Optimistically updates the SWR cache using the functional `mutate` form (receives current data to avoid race conditions when marking multiple videos in succession) + - Returns the updated record +- Uses `mutate()` for cache invalidation — no callback props needed + +### 5. Portal Component Refactor + +#### `EmployeeTasksList` + +- Replace static `trainingVideoCompletions` prop usage with `useTrainingCompletions` hook +- Pass `trainingVideoCompletions` as `fallbackData` to the hook +- Derive `hasCompletedGeneralTraining` from the SWR data (reactive) +- Remove `useState` / `useCallback` for local completion tracking +- Remove `onVideoComplete` callback prop drilling + +#### `GeneralTrainingAccordionItem` + +- Consume `useTrainingCompletions` hook directly (SWR cache sharing by key) +- Remove `onVideoComplete` prop — SWR cache is shared across components using the same key +- Remove local `completedVideoIds` state — derive from SWR data + +#### `VideoCarousel` + +- Consume `useTrainingCompletions` hook directly +- Replace `fetch('/api/portal/mark-video-completed')` with the hook's `markVideoComplete` function +- Remove `onVideoComplete` callback prop +- Remove local `completedVideoIds` state — derive from SWR data + +### 6. Portal Route Cleanup + +Delete `apps/portal/src/app/api/portal/mark-video-completed/route.ts` — its logic moves to the NestJS API endpoint. + +### 7. Server-Side Data Fetching + +`OrganizationDashboard` continues to fetch training completions server-side via `@db` for SSR. This data is passed as `fallbackData` to the SWR hook. The `@db` read in server components is acceptable for initial page load (read-only, no mutation). This is existing tech debt — eventually these reads should migrate to `serverApi`, but that's out of scope here. + +## Data Flow + +``` +Page Load: + OrganizationDashboard (server) → @db → trainingVideoCompletions + → EmployeeTasksList (client) → useTrainingCompletions(fallbackData) + → GeneralTrainingAccordionItem → useTrainingCompletions() [shared cache] + → VideoCarousel → useTrainingCompletions() [shared cache] + +Mark Complete: + VideoCarousel → hook.markVideoComplete(videoId) + → POST /v1/training/completions/:videoId/complete (NestJS API) + → SWR mutate() updates cache + → All components re-render with new data (EmployeeTasksList progress bar, accordion badge, carousel state) +``` + +## Files Changed + +| File | Change | +|------|--------| +| `packages/auth/src/permissions.ts` | Add `portal: ['read', 'update']` resource, grant to employee/contractor/admin/owner | +| `apps/api/src/auth/require-permission.decorator.ts` | Add `'portal'` to `GRCResource` type | +| `apps/api/src/training/training.controller.ts` | Add `GET completions` and `POST completions/:videoId/complete` endpoints | +| `apps/api/src/training/training.service.ts` | Add `getCompletions` and `markVideoComplete` methods | +| `apps/portal/src/hooks/useTrainingCompletions.ts` | New SWR hook | +| `apps/portal/src/app/(app)/(home)/[orgId]/components/EmployeeTasksList.tsx` | Use SWR hook, remove local state | +| `apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/GeneralTrainingAccordionItem.tsx` | Use SWR hook, remove callback props | +| `apps/portal/src/app/(app)/(home)/[orgId]/components/video/VideoCarousel.tsx` | Use SWR hook, remove callback props and local state | +| `apps/portal/src/app/api/portal/mark-video-completed/route.ts` | Delete | + +## Out of Scope + +- Migrating other portal DB reads (policies, fleet) to API endpoints +- Adding `portal` permission checks to policy signing or device agent flows +- Changing the admin-facing training UI in `apps/app` From a32828f6c9e11ef40aa6444b309e81470107a1ee Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Fri, 13 Mar 2026 12:16:06 -0400 Subject: [PATCH 2/9] docs: add implementation plan for portal training completions Co-Authored-By: Claude Opus 4.6 --- .../2026-03-13-portal-training-completions.md | 787 ++++++++++++++++++ ...3-13-portal-training-completions-design.md | 2 +- 2 files changed, 788 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-03-13-portal-training-completions.md diff --git a/docs/superpowers/plans/2026-03-13-portal-training-completions.md b/docs/superpowers/plans/2026-03-13-portal-training-completions.md new file mode 100644 index 000000000..01f92d7bc --- /dev/null +++ b/docs/superpowers/plans/2026-03-13-portal-training-completions.md @@ -0,0 +1,787 @@ +# Portal Training Video Completions Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Migrate training video completion flow from direct DB access to RBAC-guarded NestJS API endpoints with SWR hooks for reactive UI. + +**Architecture:** New `portal` permission resource grants employees self-service access. Two new NestJS endpoints (`GET /v1/training/completions`, `POST /v1/training/completions/:videoId/complete`) replace the portal's direct DB route. Portal components consume a shared SWR hook for reactive data with optimistic updates. + +**Tech Stack:** NestJS (API), better-auth RBAC, SWR (portal), Prisma (DB) + +**Spec:** `docs/superpowers/specs/2026-03-13-portal-training-completions-design.md` + +--- + +## Chunk 1: RBAC Permission + API Endpoints + +### Task 1: Add `portal` permission resource + +**Files:** +- Modify: `packages/auth/src/permissions.ts` + +- [ ] **Step 1: Add `portal` to the statement object** + +In `packages/auth/src/permissions.ts`, add after the `training` line: + +```typescript +// Portal self-service resources (scoped to authenticated user's own data) +portal: ['read', 'update'], +``` + +- [ ] **Step 2: Grant `portal` to owner role** + +In the `owner` role definition, add after `training: ['read', 'update'],`: + +```typescript +// Portal self-service +portal: ['read', 'update'], +``` + +- [ ] **Step 3: Grant `portal` to admin role** + +In the `admin` role definition, add after `training: ['read', 'update'],`: + +```typescript +// Portal self-service +portal: ['read', 'update'], +``` + +- [ ] **Step 4: Grant `portal` to employee role** + +In the `employee` role definition, add `portal`: + +```typescript +export const employee = ac.newRole({ + policy: ['read'], + portal: ['read', 'update'], +}); +``` + +- [ ] **Step 5: Grant `portal` to contractor role** + +In the `contractor` role definition, add `portal`: + +```typescript +export const contractor = ac.newRole({ + policy: ['read'], + portal: ['read', 'update'], +}); +``` + +- [ ] **Step 6: Add `portal` to GRCResource type** + +In `apps/api/src/auth/require-permission.decorator.ts`, add `'portal'` to the `GRCResource` type union: + +```typescript +export type GRCResource = + | 'organization' + // ... existing entries ... + | 'trust' + | 'portal'; +``` + +- [ ] **Step 7: Typecheck** + +Run: `npx tsc --noEmit --project packages/auth/tsconfig.json && npx tsc --noEmit --project apps/api/tsconfig.json` + +- [ ] **Step 8: Commit** + +```bash +git add packages/auth/src/permissions.ts apps/api/src/auth/require-permission.decorator.ts +git commit -m "feat(auth): add portal permission resource for employee self-service" +``` + +--- + +### Task 2: Add `getCompletions` and `markVideoComplete` to TrainingService + +**Files:** +- Modify: `apps/api/src/training/training.service.ts` + +- [ ] **Step 1: Write the `getCompletions` method** + +Add to `TrainingService` class in `apps/api/src/training/training.service.ts`. Add `NotFoundException` and `BadRequestException` to the imports from `@nestjs/common`: + +```typescript +/** + * Get all training video completions for a member (portal self-service) + */ +async getCompletions(memberId: string, organizationId: string) { + const member = await db.member.findFirst({ + where: { id: memberId, organizationId, deactivated: false }, + }); + + if (!member) { + throw new NotFoundException('Member not found'); + } + + return db.employeeTrainingVideoCompletion.findMany({ + where: { memberId }, + }); +} +``` + +- [ ] **Step 2: Write the `markVideoComplete` method** + +Add to `TrainingService` class: + +```typescript +/** + * Mark a training video as complete for a member (portal self-service) + * Creates the record if it doesn't exist, updates completedAt if null. + * Triggers completion email via Trigger.dev if all training is now done. + */ +async markVideoComplete( + memberId: string, + organizationId: string, + videoId: string, +) { + if (!TRAINING_VIDEO_IDS.includes(videoId)) { + throw new BadRequestException(`Invalid video ID: ${videoId}`); + } + + const member = await db.member.findFirst({ + where: { id: memberId, organizationId, deactivated: false }, + }); + + if (!member) { + throw new NotFoundException('Member not found'); + } + + // Upsert: create if not exists, update completedAt if null + let record = await db.employeeTrainingVideoCompletion.findFirst({ + where: { videoId, memberId }, + }); + + if (!record) { + record = await db.employeeTrainingVideoCompletion.create({ + data: { + videoId, + memberId, + completedAt: new Date(), + }, + }); + } else if (!record.completedAt) { + record = await db.employeeTrainingVideoCompletion.update({ + where: { id: record.id }, + data: { completedAt: new Date() }, + }); + } + + // Check if all training is now complete and send email if so + const allComplete = await this.hasCompletedAllTraining(memberId); + if (allComplete) { + try { + await this.sendTrainingCompletionEmailIfComplete( + memberId, + organizationId, + ); + } catch (error) { + this.logger.error( + `Failed to send training completion email for member ${memberId}:`, + error, + ); + // Don't fail the request if email fails + } + } + + return record; +} +``` + +- [ ] **Step 3: Typecheck** + +Run: `npx tsc --noEmit --project apps/api/tsconfig.json` + +- [ ] **Step 4: Commit** + +```bash +git add apps/api/src/training/training.service.ts +git commit -m "feat(api): add getCompletions and markVideoComplete to TrainingService" +``` + +--- + +### Task 3: Add new controller endpoints + +**Files:** +- Modify: `apps/api/src/training/training.controller.ts` + +- [ ] **Step 1: Add imports** + +Add `Get` and `Param` to the `@nestjs/common` import. Add `MemberId` to the auth-context import: + +```typescript +import { + Controller, + Post, + Get, + Body, + Param, + HttpCode, + HttpStatus, + Res, + BadRequestException, + UseGuards, +} from '@nestjs/common'; +``` + +```typescript +import { OrganizationId, MemberId } from '../auth/auth-context.decorator'; +``` + +- [ ] **Step 2: Add GET completions endpoint** + +Add to `TrainingController` class: + +```typescript +@Get('completions') +@RequirePermission('portal', 'read') +@ApiOperation({ + summary: 'Get training video completions for the authenticated user', + description: + 'Returns all training video completion records for the authenticated member. Requires session authentication.', +}) +@ApiResponse({ + status: 200, + description: 'List of training video completion records', +}) +async getCompletions( + @MemberId() memberId: string | undefined, + @OrganizationId() organizationId: string, +) { + if (!memberId) { + throw new BadRequestException('Session authentication required'); + } + return this.trainingService.getCompletions(memberId, organizationId); +} +``` + +- [ ] **Step 3: Add POST mark complete endpoint** + +Add to `TrainingController` class: + +```typescript +@Post('completions/:videoId/complete') +@HttpCode(HttpStatus.OK) +@RequirePermission('portal', 'update') +@ApiOperation({ + summary: 'Mark a training video as complete', + description: + 'Marks a specific training video as completed for the authenticated member. Triggers completion email if all training is now done.', +}) +@ApiResponse({ + status: 200, + description: 'The updated completion record', +}) +async markVideoComplete( + @MemberId() memberId: string | undefined, + @OrganizationId() organizationId: string, + @Param('videoId') videoId: string, +) { + if (!memberId) { + throw new BadRequestException('Session authentication required'); + } + return this.trainingService.markVideoComplete( + memberId, + organizationId, + videoId, + ); +} +``` + +- [ ] **Step 4: Typecheck** + +Run: `npx tsc --noEmit --project apps/api/tsconfig.json` + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/src/training/training.controller.ts +git commit -m "feat(api): add portal training completion endpoints" +``` + +--- + +### Task 4: Write API tests + +**Files:** +- Modify: `apps/api/src/training/training.controller.spec.ts` + +- [ ] **Step 1: Read the existing test file to understand the test patterns** + +Run: `cat apps/api/src/training/training.controller.spec.ts` + +- [ ] **Step 2: Add tests for GET completions endpoint** + +Add a describe block for the new `getCompletions` endpoint following the existing test patterns in the file. Test: +- Returns completions when memberId is present +- Throws BadRequestException when memberId is undefined + +- [ ] **Step 3: Add tests for POST mark complete endpoint** + +Add a describe block for the new `markVideoComplete` endpoint. Test: +- Marks a video complete and returns the record +- Throws BadRequestException when memberId is undefined +- Calls service with correct parameters + +- [ ] **Step 4: Run the tests** + +Run: `cd apps/api && npx jest src/training --passWithNoTests` +Expected: All tests pass + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/src/training/training.controller.spec.ts +git commit -m "test(api): add tests for portal training completion endpoints" +``` + +--- + +## Chunk 2: Portal SWR Hook + Component Refactor + +### Task 5: Create `useTrainingCompletions` SWR hook + +**Files:** +- Create: `apps/portal/src/hooks/use-training-completions.ts` + +- [ ] **Step 1: Create the hook file** + +The portal uses `NEXT_PUBLIC_API_URL` env var to reach the NestJS API. Create `apps/portal/src/hooks/use-training-completions.ts`: + +```typescript +import type { EmployeeTrainingVideoCompletion } from '@db'; +import { env } from '@/env.mjs'; +import useSWR from 'swr'; +import { toast } from 'sonner'; +import { useCallback } from 'react'; + +const API_URL = env.NEXT_PUBLIC_API_URL || 'http://localhost:3333'; + +const SWR_KEY = `${API_URL}/v1/training/completions`; + +const fetcher = async (url: string) => { + const res = await fetch(url, { credentials: 'include' }); + if (!res.ok) { + throw new Error('Failed to fetch training completions'); + } + return res.json(); +}; + +export function useTrainingCompletions({ + fallbackData, +}: { + fallbackData?: EmployeeTrainingVideoCompletion[]; +} = {}) { + const { data, error, isLoading, mutate } = useSWR< + EmployeeTrainingVideoCompletion[] + >(SWR_KEY, fetcher, { + fallbackData, + revalidateOnMount: !fallbackData, + revalidateOnFocus: false, + }); + + const completions = Array.isArray(data) ? data : []; + + const markVideoComplete = useCallback( + async (videoId: string) => { + try { + // Optimistic update using functional form to avoid race conditions + await mutate( + async (current) => { + const res = await fetch( + `${API_URL}/v1/training/completions/${videoId}/complete`, + { + method: 'POST', + credentials: 'include', + }, + ); + + if (!res.ok) { + throw new Error('Failed to mark video as completed'); + } + + const updatedRecord: EmployeeTrainingVideoCompletion = + await res.json(); + + if (!Array.isArray(current)) return [updatedRecord]; + + // Replace existing record or add new one + const exists = current.some((c) => c.videoId === videoId); + if (exists) { + return current.map((c) => + c.videoId === videoId ? updatedRecord : c, + ); + } + return [...current, updatedRecord]; + }, + { revalidate: false }, + ); + } catch { + toast.error('Failed to mark video as completed'); + } + }, + [mutate], + ); + + return { + completions, + isLoading, + error, + markVideoComplete, + }; +} +``` + +- [ ] **Step 2: Typecheck** + +Run: `npx tsc --noEmit --project apps/portal/tsconfig.json` + +- [ ] **Step 3: Commit** + +```bash +git add apps/portal/src/hooks/use-training-completions.ts +git commit -m "feat(portal): add useTrainingCompletions SWR hook" +``` + +--- + +### Task 6: Refactor all portal training components to use SWR hook + +**Important:** These three components (`VideoCarousel`, `GeneralTrainingAccordionItem`, `EmployeeTasksList`) must be refactored together in one task because removing props from a child before updating its parent would break the typecheck. + +**Files:** +- Modify: `apps/portal/src/app/(app)/(home)/[orgId]/components/video/VideoCarousel.tsx` +- Modify: `apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/GeneralTrainingAccordionItem.tsx` +- Modify: `apps/portal/src/app/(app)/(home)/[orgId]/components/EmployeeTasksList.tsx` + +- [ ] **Step 1: Rewrite VideoCarousel** + +Replace the entire file content. Key changes: +- Remove `videos` and `onVideoComplete` props +- Use `useTrainingCompletions` hook for data and mutations +- Remove all local `completedVideoIds` state — derive from SWR data +- Remove `useEffect` sync — SWR handles reactivity +- Remove raw `fetch` call — use `markVideoComplete` from hook + +```typescript +'use client'; + +import { trainingVideos } from '@/lib/data/training-videos'; +import { useTrainingCompletions } from '@/hooks/use-training-completions'; +import { useState } from 'react'; +import { CarouselControls } from './CarouselControls'; +import { YoutubeEmbed } from './YoutubeEmbed'; + +export function VideoCarousel() { + const { completions, markVideoComplete } = useTrainingCompletions(); + const [isExecuting, setIsExecuting] = useState(false); + + // Create a map of completion records by videoId + const completionRecordsMap = new Map( + completions.map((record) => [record.videoId, record]), + ); + + // Merge metadata with completion status + const mergedVideos = trainingVideos.map((metadata) => { + const completionRecord = completionRecordsMap.get(metadata.id); + return { + ...metadata, + dbRecordId: completionRecord?.id, + isCompleted: !!completionRecord?.completedAt, + }; + }); + + // Derive completed set from SWR data + const completedVideoIds = new Set( + mergedVideos.filter((v) => v.isCompleted).map((v) => v.id), + ); + + // Start carousel at the last completed video + const lastCompletedIndex = (() => { + const completedIndices = mergedVideos + .map((video, index) => ({ index, completed: video.isCompleted })) + .filter((item) => item.completed) + .map((item) => item.index); + return completedIndices.length > 0 + ? completedIndices[completedIndices.length - 1] + : 0; + })(); + + const [currentIndex, setCurrentIndex] = useState(lastCompletedIndex); + + const goToPrevious = () => { + const isFirstVideo = currentIndex === 0; + setCurrentIndex(isFirstVideo ? mergedVideos.length - 1 : currentIndex - 1); + }; + + const goToNext = () => { + const currentMetadataId = mergedVideos[currentIndex].id; + if (!completedVideoIds.has(currentMetadataId)) return; + const isLastVideo = currentIndex === mergedVideos.length - 1; + setCurrentIndex(isLastVideo ? 0 : currentIndex + 1); + }; + + const handleVideoComplete = async () => { + const currentVideo = mergedVideos[currentIndex]; + if (completedVideoIds.has(currentVideo.id)) return; + + setIsExecuting(true); + try { + await markVideoComplete(currentVideo.id); + } finally { + setIsExecuting(false); + } + }; + + const isCurrentVideoCompleted = completedVideoIds.has( + mergedVideos[currentIndex].id, + ); + const hasNextVideo = currentIndex < mergedVideos.length - 1; + const allVideosCompleted = trainingVideos.every((metadata) => + completedVideoIds.has(metadata.id), + ); + + return ( +
+ {allVideosCompleted && ( +
+

+ All Training Videos Completed! +

+

+ You're all done, now your manager won't pester you! +

+
+ )} + {!allVideosCompleted && ( + <> + + + + )} +
+ ); +} +``` + +- [ ] **Step 2: Rewrite GeneralTrainingAccordionItem** + +Key changes: +- Remove `trainingVideoCompletions` prop +- Use `useTrainingCompletions` hook (shared SWR cache with VideoCarousel) +- Remove local `completedVideoIds` state +- Remove `handleVideoComplete` callback + +```typescript +'use client'; + +import { trainingVideos } from '@/lib/data/training-videos'; +import { useTrainingCompletions } from '@/hooks/use-training-completions'; +import { + AccordionContent, + AccordionItem, + AccordionTrigger, + Badge, + cn, +} from '@trycompai/design-system'; +import { CheckmarkFilled, CircleDash } from '@trycompai/design-system/icons'; +import { VideoCarousel } from '../video/VideoCarousel'; + +const generalTrainingVideoIds = trainingVideos + .filter((video) => video.id.startsWith('sat-')) + .map((video) => video.id); + +export function GeneralTrainingAccordionItem() { + const { completions } = useTrainingCompletions(); + + const completedVideoIds = new Set( + completions + .filter( + (c) => + generalTrainingVideoIds.includes(c.videoId) && + c.completedAt !== null, + ) + .map((c) => c.videoId), + ); + + const hasCompletedGeneralTraining = generalTrainingVideoIds.every( + (videoId) => completedVideoIds.has(videoId), + ); + + const completedCount = completedVideoIds.size; + const totalCount = generalTrainingVideoIds.length; + + return ( +
+ +
+ +
+ {hasCompletedGeneralTraining ? ( +
+ +
+ ) : ( +
+ +
+ )} + + Security Awareness Training + + {!hasCompletedGeneralTraining && totalCount > 0 && ( + + {completedCount}/{totalCount} + + )} +
+
+
+ +
+

+ Complete the security awareness training videos to learn about + best practices for keeping company data secure. +

+ +
+
+
+
+ ); +} +``` + +- [ ] **Step 3: Refactor EmployeeTasksList to use SWR hook** + +Key changes: +- Import `useTrainingCompletions` hook +- Call the hook with `trainingVideoCompletions` as `fallbackData` +- Derive `hasCompletedGeneralTraining` from the hook's reactive `completions` data +- Remove static `trainingVideoCompletions` usage for progress calculation +- Remove `trainingVideoCompletions` prop from `GeneralTrainingAccordionItem` + +Replace the training completion calculation block (the lines calculating `generalTrainingVideoIds`, `completedGeneralTrainingCount`, `hasCompletedGeneralTraining`) with: + +```typescript +// Import at top of file +import { useTrainingCompletions } from '@/hooks/use-training-completions'; + +// Inside the component, before the fleet SWR hooks: +const { completions: trainingCompletions } = useTrainingCompletions({ + fallbackData: trainingVideoCompletions, +}); +``` + +Replace the training completion calculation (the `generalTrainingVideoIds` / `completedGeneralTrainingCount` / `hasCompletedGeneralTraining` block) with: + +```typescript +// Calculate general training completion from reactive SWR data +const generalTrainingVideoIds = trainingVideos + .filter((video) => video.id.startsWith('sat-')) + .map((video) => video.id); + +const completedGeneralTrainingCount = trainingCompletions.filter( + (completion) => + generalTrainingVideoIds.includes(completion.videoId) && + completion.completedAt !== null, +).length; + +const hasCompletedGeneralTraining = + completedGeneralTrainingCount === generalTrainingVideoIds.length; +``` + +Update the `GeneralTrainingAccordionItem` rendering — remove the `trainingVideoCompletions` prop: + +```typescript + +``` + +- [ ] **Step 4: Typecheck all three components together** + +Run: `npx tsc --noEmit --project apps/portal/tsconfig.json` + +- [ ] **Step 5: Commit** + +```bash +git add apps/portal/src/app/\(app\)/\(home\)/\[orgId\]/components/video/VideoCarousel.tsx apps/portal/src/app/\(app\)/\(home\)/\[orgId\]/components/tasks/GeneralTrainingAccordionItem.tsx apps/portal/src/app/\(app\)/\(home\)/\[orgId\]/components/EmployeeTasksList.tsx +git commit -m "refactor(portal): migrate training components to SWR hook" +``` + +--- + +### Task 7: Delete old portal route + +**Files:** +- Delete: `apps/portal/src/app/api/portal/mark-video-completed/route.ts` + +- [ ] **Step 1: Verify no other files import this route** + +Run: `grep -r "mark-video-completed" apps/portal/src/ --include="*.ts" --include="*.tsx"` + +Expected: No results (VideoCarousel no longer calls this route after Task 6). + +- [ ] **Step 2: Delete the file** + +```bash +rm apps/portal/src/app/api/portal/mark-video-completed/route.ts +``` + +- [ ] **Step 3: Typecheck the full portal** + +Run: `npx tsc --noEmit --project apps/portal/tsconfig.json` + +- [ ] **Step 4: Commit** + +```bash +git add -A apps/portal/src/app/api/portal/mark-video-completed/ +git commit -m "refactor(portal): delete mark-video-completed route, replaced by NestJS API" +``` + +--- + +### Task 8: Final verification + +- [ ] **Step 1: Typecheck all affected packages** + +Run: `npx turbo run typecheck --filter=@comp/api --filter=@comp/portal --filter=@comp/auth` + +- [ ] **Step 2: Run API tests** + +Run: `cd apps/api && npx jest src/training --passWithNoTests` + +- [ ] **Step 3: Verify the complete data flow** + +Verify these files are consistent: +- `packages/auth/src/permissions.ts` — `portal` resource exists, granted to employee/contractor/admin/owner +- `apps/api/src/training/training.controller.ts` — two new endpoints with `portal` permission +- `apps/api/src/training/training.service.ts` — `getCompletions` and `markVideoComplete` methods +- `apps/portal/src/hooks/use-training-completions.ts` — SWR hook calling API +- Portal components — all use hook, no callback props, no local completion state +- `apps/portal/src/app/api/portal/mark-video-completed/route.ts` — deleted + +- [ ] **Step 4: Commit any remaining fixes** diff --git a/docs/superpowers/specs/2026-03-13-portal-training-completions-design.md b/docs/superpowers/specs/2026-03-13-portal-training-completions-design.md index 2d2634fa0..c504fad3e 100644 --- a/docs/superpowers/specs/2026-03-13-portal-training-completions-design.md +++ b/docs/superpowers/specs/2026-03-13-portal-training-completions-design.md @@ -56,7 +56,7 @@ async getCompletions( - Extracts `memberId` from `request.memberId` (session auth) — must guard against undefined - Validates `videoId` against known training video IDs - Creates or updates the `EmployeeTrainingVideoCompletion` record -- After marking complete, checks if all training is done and triggers completion email internally via `TrainingService.sendTrainingCompletionEmailIfComplete` (replaces the old portal service-token call — no service token permission changes needed) +- After marking complete, checks if all training is done and triggers completion email via Trigger.dev task (all emails go through Nest API → Trigger.dev for rate limiting) - Returns: the updated `EmployeeTrainingVideoCompletion` record ```typescript From 58b46041590bd0240abb82b15aa610ddb67bce36 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Fri, 13 Mar 2026 12:17:26 -0400 Subject: [PATCH 3/9] feat(auth): add portal permission resource for employee self-service Co-Authored-By: Claude Opus 4.6 --- apps/api/src/auth/require-permission.decorator.ts | 3 ++- packages/auth/src/permissions.ts | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/api/src/auth/require-permission.decorator.ts b/apps/api/src/auth/require-permission.decorator.ts index c4326f934..65045ebbd 100644 --- a/apps/api/src/auth/require-permission.decorator.ts +++ b/apps/api/src/auth/require-permission.decorator.ts @@ -69,7 +69,8 @@ export type GRCResource = | 'cloud-security' | 'training' | 'app' - | 'trust'; + | 'trust' + | 'portal'; /** * Action types available for GRC resources — CRUD only diff --git a/packages/auth/src/permissions.ts b/packages/auth/src/permissions.ts index 97c906fbd..f7ddac400 100644 --- a/packages/auth/src/permissions.ts +++ b/packages/auth/src/permissions.ts @@ -42,6 +42,8 @@ export const statement = { pentest: ['create', 'read', 'delete'], // Training management training: ['read', 'update'], + // Portal self-service + portal: ['read', 'update'], } as const; export const ac = createAccessControl(statement); @@ -77,6 +79,8 @@ export const owner = ac.newRole({ pentest: ['create', 'read', 'delete'], // Training management training: ['read', 'update'], + // Portal self-service + portal: ['read', 'update'], }); /** @@ -110,6 +114,8 @@ export const admin = ac.newRole({ pentest: ['create', 'read', 'delete'], // Training management training: ['read', 'update'], + // Portal self-service + portal: ['read', 'update'], }); /** @@ -147,6 +153,7 @@ export const auditor = ac.newRole({ export const employee = ac.newRole({ // Portal access only — can read policies to sign them policy: ['read'], + portal: ['read', 'update'], }); /** @@ -157,6 +164,7 @@ export const employee = ac.newRole({ export const contractor = ac.newRole({ // Portal access only — can read policies to sign them policy: ['read'], + portal: ['read', 'update'], }); /** From 68343a5f00e908c79f1a20fedf48bd80b18adca5 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Fri, 13 Mar 2026 12:17:55 -0400 Subject: [PATCH 4/9] feat(api): add getCompletions and markVideoComplete to TrainingService Co-Authored-By: Claude Opus 4.6 --- apps/api/src/training/training.service.ts | 76 ++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/apps/api/src/training/training.service.ts b/apps/api/src/training/training.service.ts index 517f67d59..63363e689 100644 --- a/apps/api/src/training/training.service.ts +++ b/apps/api/src/training/training.service.ts @@ -1,4 +1,9 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { + Injectable, + Logger, + NotFoundException, + BadRequestException, +} from '@nestjs/common'; import { db } from '@db'; import { TrainingEmailService } from './training-email.service'; import { TrainingCertificatePdfService } from './training-certificate-pdf.service'; @@ -15,6 +20,75 @@ export class TrainingService { private readonly trainingCertificatePdfService: TrainingCertificatePdfService, ) {} + async getCompletions(memberId: string, organizationId: string) { + const member = await db.member.findFirst({ + where: { id: memberId, organizationId, deactivated: false }, + }); + + if (!member) { + throw new NotFoundException('Member not found'); + } + + return db.employeeTrainingVideoCompletion.findMany({ + where: { memberId }, + }); + } + + async markVideoComplete( + memberId: string, + organizationId: string, + videoId: string, + ) { + if (!TRAINING_VIDEO_IDS.includes(videoId)) { + throw new BadRequestException(`Invalid video ID: ${videoId}`); + } + + const member = await db.member.findFirst({ + where: { id: memberId, organizationId, deactivated: false }, + }); + + if (!member) { + throw new NotFoundException('Member not found'); + } + + let record = await db.employeeTrainingVideoCompletion.findFirst({ + where: { videoId, memberId }, + }); + + if (!record) { + record = await db.employeeTrainingVideoCompletion.create({ + data: { + videoId, + memberId, + completedAt: new Date(), + }, + }); + } else if (!record.completedAt) { + record = await db.employeeTrainingVideoCompletion.update({ + where: { id: record.id }, + data: { completedAt: new Date() }, + }); + } + + // Check if all training is now complete and send email if so + const allComplete = await this.hasCompletedAllTraining(memberId); + if (allComplete) { + try { + await this.sendTrainingCompletionEmailIfComplete( + memberId, + organizationId, + ); + } catch (error) { + this.logger.error( + `Failed to send training completion email for member ${memberId}:`, + error, + ); + } + } + + return record; + } + /** * Check if a member has completed all training videos */ From e0c0739684c6a267bbc0b4772c3a8d20c13d8d8d Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Fri, 13 Mar 2026 12:18:14 -0400 Subject: [PATCH 5/9] feat(api): add portal training completion endpoints Co-Authored-By: Claude Opus 4.6 --- apps/api/src/training/training.controller.ts | 52 +++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/apps/api/src/training/training.controller.ts b/apps/api/src/training/training.controller.ts index 3320480a0..9ed6d3ea7 100644 --- a/apps/api/src/training/training.controller.ts +++ b/apps/api/src/training/training.controller.ts @@ -1,10 +1,12 @@ import { Controller, Post, + Get, Body, HttpCode, HttpStatus, Res, + Param, BadRequestException, UseGuards, } from '@nestjs/common'; @@ -24,7 +26,7 @@ import { import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; import { PermissionGuard } from '../auth/permission.guard'; import { RequirePermission } from '../auth/require-permission.decorator'; -import { OrganizationId } from '../auth/auth-context.decorator'; +import { OrganizationId, MemberId } from '../auth/auth-context.decorator'; @ApiTags('Training') @Controller({ path: 'training', version: '1' }) @@ -33,6 +35,54 @@ import { OrganizationId } from '../auth/auth-context.decorator'; export class TrainingController { constructor(private readonly trainingService: TrainingService) {} + @Get('completions') + @RequirePermission('portal', 'read') + @ApiOperation({ + summary: 'Get training video completions for the authenticated user', + description: + 'Returns all training video completion records for the authenticated member. Requires session authentication.', + }) + @ApiResponse({ + status: 200, + description: 'List of training video completion records', + }) + async getCompletions( + @MemberId() memberId: string | undefined, + @OrganizationId() organizationId: string, + ) { + if (!memberId) { + throw new BadRequestException('Session authentication required'); + } + return this.trainingService.getCompletions(memberId, organizationId); + } + + @Post('completions/:videoId/complete') + @HttpCode(HttpStatus.OK) + @RequirePermission('portal', 'update') + @ApiOperation({ + summary: 'Mark a training video as complete', + description: + 'Marks a specific training video as completed for the authenticated member. Triggers completion email if all training is now done.', + }) + @ApiResponse({ + status: 200, + description: 'The updated completion record', + }) + async markVideoComplete( + @MemberId() memberId: string | undefined, + @OrganizationId() organizationId: string, + @Param('videoId') videoId: string, + ) { + if (!memberId) { + throw new BadRequestException('Session authentication required'); + } + return this.trainingService.markVideoComplete( + memberId, + organizationId, + videoId, + ); + } + @Post('send-completion-email') @HttpCode(HttpStatus.OK) @RequirePermission('training', 'update') From 53b7f9bceb8e952e976ecd82b6dedf6a7ab78903 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Fri, 13 Mar 2026 12:19:31 -0400 Subject: [PATCH 6/9] test(api): add tests for portal training completion endpoints and RBAC Co-Authored-By: Claude Opus 4.6 --- .../src/training/training.controller.spec.ts | 103 +++++++++++++++++- 1 file changed, 102 insertions(+), 1 deletion(-) diff --git a/apps/api/src/training/training.controller.spec.ts b/apps/api/src/training/training.controller.spec.ts index 5ec5576a5..38a86c30a 100644 --- a/apps/api/src/training/training.controller.spec.ts +++ b/apps/api/src/training/training.controller.spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { BadRequestException } from '@nestjs/common'; import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; -import { PermissionGuard } from '../auth/permission.guard'; +import { PermissionGuard, PERMISSIONS_KEY } from '../auth/permission.guard'; import { TrainingController } from './training.controller'; import { TrainingService } from './training.service'; @@ -21,6 +21,8 @@ describe('TrainingController', () => { const mockTrainingService = { sendTrainingCompletionEmailIfComplete: jest.fn(), generateCertificate: jest.fn(), + getCompletions: jest.fn(), + markVideoComplete: jest.fn(), }; const mockGuard = { canActivate: jest.fn().mockReturnValue(true) }; @@ -115,4 +117,103 @@ describe('TrainingController', () => { ).rejects.toThrow(BadRequestException); }); }); + + describe('getCompletions', () => { + it('should return completions when memberId is provided', async () => { + const mockCompletions = [ + { id: 'comp_1', videoId: 'vid_1', completedAt: new Date() }, + ]; + mockTrainingService.getCompletions.mockResolvedValue(mockCompletions); + + const result = await controller.getCompletions('mem_123', 'org_123'); + + expect(trainingService.getCompletions).toHaveBeenCalledWith( + 'mem_123', + 'org_123', + ); + expect(result).toEqual(mockCompletions); + }); + + it('should throw BadRequestException when memberId is undefined', async () => { + await expect( + controller.getCompletions(undefined as unknown as string, 'org_123'), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('markVideoComplete', () => { + it('should mark video complete and return record', async () => { + const mockRecord = { + id: 'comp_1', + videoId: 'vid_abc', + completedAt: new Date(), + }; + mockTrainingService.markVideoComplete.mockResolvedValue(mockRecord); + + const result = await controller.markVideoComplete( + 'mem_123', + 'org_123', + 'vid_abc', + ); + + expect(trainingService.markVideoComplete).toHaveBeenCalledWith( + 'mem_123', + 'org_123', + 'vid_abc', + ); + expect(result).toEqual(mockRecord); + }); + + it('should throw BadRequestException when memberId is undefined', async () => { + await expect( + controller.markVideoComplete( + undefined as unknown as string, + 'org_123', + 'vid_abc', + ), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('RBAC - permission decorators', () => { + it('getCompletions should require portal:read', () => { + const permissions = Reflect.getMetadata( + PERMISSIONS_KEY, + controller.getCompletions, + ); + expect(permissions).toEqual([ + { resource: 'portal', actions: ['read'] }, + ]); + }); + + it('markVideoComplete should require portal:update', () => { + const permissions = Reflect.getMetadata( + PERMISSIONS_KEY, + controller.markVideoComplete, + ); + expect(permissions).toEqual([ + { resource: 'portal', actions: ['update'] }, + ]); + }); + + it('sendTrainingCompletionEmail should require training:update', () => { + const permissions = Reflect.getMetadata( + PERMISSIONS_KEY, + controller.sendTrainingCompletionEmail, + ); + expect(permissions).toEqual([ + { resource: 'training', actions: ['update'] }, + ]); + }); + + it('generateCertificate should require training:read', () => { + const permissions = Reflect.getMetadata( + PERMISSIONS_KEY, + controller.generateCertificate, + ); + expect(permissions).toEqual([ + { resource: 'training', actions: ['read'] }, + ]); + }); + }); }); From d83a0b86321cb4ba33cbbf5e8f190566e503e2fb Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Fri, 13 Mar 2026 12:24:09 -0400 Subject: [PATCH 7/9] refactor(portal): migrate training to SWR hook, delete old route Co-Authored-By: Claude Opus 4.6 --- .../[orgId]/components/EmployeeTasksList.tsx | 14 +- .../tasks/GeneralTrainingAccordionItem.tsx | 63 ++++----- .../components/video/VideoCarousel.tsx | 122 ++++++------------ .../api/portal/mark-video-completed/route.ts | 109 ---------------- .../src/hooks/use-training-completions.ts | 79 ++++++++++++ 5 files changed, 158 insertions(+), 229 deletions(-) delete mode 100644 apps/portal/src/app/api/portal/mark-video-completed/route.ts create mode 100644 apps/portal/src/hooks/use-training-completions.ts diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/components/EmployeeTasksList.tsx b/apps/portal/src/app/(app)/(home)/[orgId]/components/EmployeeTasksList.tsx index 41c5d4470..49b36a731 100644 --- a/apps/portal/src/app/(app)/(home)/[orgId]/components/EmployeeTasksList.tsx +++ b/apps/portal/src/app/(app)/(home)/[orgId]/components/EmployeeTasksList.tsx @@ -1,6 +1,7 @@ 'use client'; import { trainingVideos } from '@/lib/data/training-videos'; +import { useTrainingCompletions } from '@/hooks/use-training-completions'; import { evidenceFormDefinitionList } from '@comp/company'; import type { Device, EmployeeTrainingVideoCompletion, Member, Policy, PolicyVersion } from '@db'; import { Accordion, Button, Card, CardContent } from '@trycompai/design-system'; @@ -45,6 +46,10 @@ export const EmployeeTasksList = ({ whistleblowerReportEnabled, accessRequestFormEnabled, }: EmployeeTasksListProps) => { + const { completions: trainingCompletions } = useTrainingCompletions({ + fallbackData: trainingVideoCompletions, + }); + const { data: response, isValidating, @@ -114,9 +119,10 @@ export const EmployeeTasksList = ({ .filter((video) => video.id.startsWith('sat-')) .map((video) => video.id); - const completedGeneralTrainingCount = trainingVideoCompletions.filter( + const completedGeneralTrainingCount = trainingCompletions.filter( (completion) => - generalTrainingVideoIds.includes(completion.videoId) && completion.completedAt !== null, + generalTrainingVideoIds.includes(completion.videoId) && + completion.completedAt !== null, ).length; const hasCompletedGeneralTraining = @@ -155,9 +161,7 @@ export const EmployeeTasksList = ({ { title: 'Complete general security awareness training', content: ( - + ), }, ] diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/GeneralTrainingAccordionItem.tsx b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/GeneralTrainingAccordionItem.tsx index aa5114907..4ed840c0c 100644 --- a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/GeneralTrainingAccordionItem.tsx +++ b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/GeneralTrainingAccordionItem.tsx @@ -1,7 +1,7 @@ 'use client'; import { trainingVideos } from '@/lib/data/training-videos'; -import type { EmployeeTrainingVideoCompletion } from '@db'; +import { useTrainingCompletions } from '@/hooks/use-training-completions'; import { AccordionContent, AccordionItem, @@ -10,37 +10,27 @@ import { cn, } from '@trycompai/design-system'; import { CheckmarkFilled, CircleDash } from '@trycompai/design-system/icons'; -import { useCallback, useState } from 'react'; import { VideoCarousel } from '../video/VideoCarousel'; -interface GeneralTrainingAccordionItemProps { - trainingVideoCompletions: EmployeeTrainingVideoCompletion[]; -} - -export function GeneralTrainingAccordionItem({ - trainingVideoCompletions, -}: GeneralTrainingAccordionItemProps) { - // Filter for general training videos (all 'sat-' prefixed videos) - const generalTrainingVideoIds = trainingVideos - .filter((video) => video.id.startsWith('sat-')) - .map((video) => video.id); +const generalTrainingVideoIds = trainingVideos + .filter((video) => video.id.startsWith('sat-')) + .map((video) => video.id); - // Track completed video IDs in local state so count updates in real time - const [completedVideoIds, setCompletedVideoIds] = useState>(() => { - const generalCompletions = trainingVideoCompletions.filter((c) => - generalTrainingVideoIds.includes(c.videoId), - ); - return new Set( - generalCompletions.filter((c) => c.completedAt).map((c) => c.videoId), - ); - }); +export function GeneralTrainingAccordionItem() { + const { completions } = useTrainingCompletions(); - const handleVideoComplete = useCallback((videoId: string) => { - setCompletedVideoIds((prev) => new Set([...prev, videoId])); - }, []); + const completedVideoIds = new Set( + completions + .filter( + (c) => + generalTrainingVideoIds.includes(c.videoId) && + c.completedAt !== null, + ) + .map((c) => c.videoId), + ); - const hasCompletedGeneralTraining = generalTrainingVideoIds.every((videoId) => - completedVideoIds.has(videoId), + const hasCompletedGeneralTraining = generalTrainingVideoIds.every( + (videoId) => completedVideoIds.has(videoId), ); const completedCount = completedVideoIds.size; @@ -53,14 +43,19 @@ export function GeneralTrainingAccordionItem({
{hasCompletedGeneralTraining ? ( -
+
+ +
) : ( -
+
+ +
)} Security Awareness Training @@ -76,12 +71,10 @@ export function GeneralTrainingAccordionItem({

- Complete the security awareness training videos to learn about best practices for - keeping company data secure. + Complete the security awareness training videos to learn about + best practices for keeping company data secure.

- - {/* Only show videos that are general training (sat- prefix) */} - generalTrainingVideoIds.includes(c.videoId))} onVideoComplete={handleVideoComplete} /> +
diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/components/video/VideoCarousel.tsx b/apps/portal/src/app/(app)/(home)/[orgId]/components/video/VideoCarousel.tsx index bd1a4b9bf..d0005e21c 100644 --- a/apps/portal/src/app/(app)/(home)/[orgId]/components/video/VideoCarousel.tsx +++ b/apps/portal/src/app/(app)/(home)/[orgId]/components/video/VideoCarousel.tsx @@ -1,125 +1,83 @@ 'use client'; import { trainingVideos } from '@/lib/data/training-videos'; -import type { EmployeeTrainingVideoCompletion } from '@db'; -import { useParams } from 'next/navigation'; -import { useEffect, useState } from 'react'; -import { toast } from 'sonner'; +import { useTrainingCompletions } from '@/hooks/use-training-completions'; +import { useState } from 'react'; import { CarouselControls } from './CarouselControls'; import { YoutubeEmbed } from './YoutubeEmbed'; -interface VideoCarouselProps { - videos: EmployeeTrainingVideoCompletion[]; - onVideoComplete?: (videoId: string) => void; -} +export function VideoCarousel() { + const { completions, markVideoComplete } = useTrainingCompletions(); + const [isExecuting, setIsExecuting] = useState(false); -export function VideoCarousel({ videos, onVideoComplete }: VideoCarouselProps) { - // Create a map of completion records by their videoId for efficient lookup - // videoId in the DB record corresponds to the id in the metadata - const completionRecordsMap = new Map(videos.map((record) => [record.videoId, record])); - const { orgId } = useParams<{ orgId: string }>(); + const completionRecordsMap = new Map( + completions.map((record) => [record.videoId, record]), + ); - // Create our merged videos array by enriching metadata with completion status const mergedVideos = trainingVideos.map((metadata) => { - const completionRecord = completionRecordsMap.get(metadata.id); // Match metadata.id with record.videoId + const completionRecord = completionRecordsMap.get(metadata.id); return { - ...metadata, // Spread metadata fields (id, title, youtubeId, etc.) - dbRecordId: completionRecord?.id, // Store the database *record* ID if it exists - isCompleted: !!completionRecord?.completedAt, // Check if the record has a completedAt timestamp + ...metadata, + dbRecordId: completionRecord?.id, + isCompleted: !!completionRecord?.completedAt, }; }); - // Find the index of the last completed video to start the carousel there + const completedVideoIds = new Set( + mergedVideos.filter((v) => v.isCompleted).map((v) => v.id), + ); + const lastCompletedIndex = (() => { const completedIndices = mergedVideos - .map((video, index) => ({ - index, - completed: video.isCompleted, - })) + .map((video, index) => ({ index, completed: video.isCompleted })) .filter((item) => item.completed) .map((item) => item.index); - - // Default to the first video (index 0) if none are completed - return completedIndices.length > 0 ? completedIndices[completedIndices.length - 1] : 0; + return completedIndices.length > 0 + ? completedIndices[completedIndices.length - 1] + : 0; })(); const [currentIndex, setCurrentIndex] = useState(lastCompletedIndex); - const [isExecuting, setIsExecuting] = useState(false); - - // Local state to track completed videos in the UI (using metadata IDs) - const initialCompletedVideoIds = new Set( - mergedVideos.filter((video) => video.isCompleted).map((video) => video.id), // Use metadata id - ); - - const [completedVideoIds, setCompletedVideoIds] = useState>(initialCompletedVideoIds); - - // Effect to synchronize local UI state with changes in DB records (props) - useEffect(() => { - const newCompletionRecordsMap = new Map(videos.map((record) => [record.videoId, record])); - const newCompletedVideoIds = new Set( - trainingVideos - .filter((metadata) => !!newCompletionRecordsMap.get(metadata.id)?.completedAt) - .map((metadata) => metadata.id), // Use metadata id - ); - setCompletedVideoIds(newCompletedVideoIds); - }, [videos]); // Depend only on the DB records prop const goToPrevious = () => { const isFirstVideo = currentIndex === 0; - const newIndex = isFirstVideo ? mergedVideos.length - 1 : currentIndex - 1; - setCurrentIndex(newIndex); + setCurrentIndex(isFirstVideo ? mergedVideos.length - 1 : currentIndex - 1); }; const goToNext = () => { const currentMetadataId = mergedVideos[currentIndex].id; - // Allow going next only if the current video is marked complete in the local state if (!completedVideoIds.has(currentMetadataId)) return; const isLastVideo = currentIndex === mergedVideos.length - 1; - const newIndex = isLastVideo ? 0 : currentIndex + 1; - setCurrentIndex(newIndex); + setCurrentIndex(isLastVideo ? 0 : currentIndex + 1); }; const handleVideoComplete = async () => { const currentVideo = mergedVideos[currentIndex]; - const metadataVideoId = currentVideo.id; // This is the ID like 'sat-1' - - // Check if already marked complete in local state to avoid redundant calls - if (completedVideoIds.has(metadataVideoId)) { - return; - } + if (completedVideoIds.has(currentVideo.id)) return; setIsExecuting(true); try { - const res = await fetch('/api/portal/mark-video-completed', { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ videoId: metadataVideoId, organizationId: orgId }), - }); - if (!res.ok) { - throw new Error('Failed to mark video as completed'); - } - // Update local UI state immediately upon successful action - setCompletedVideoIds((prev) => new Set([...prev, metadataVideoId])); - onVideoComplete?.(metadataVideoId); - } catch (error) { - toast.error('Failed to mark video as completed'); + await markVideoComplete(currentVideo.id); } finally { setIsExecuting(false); } }; - // Determine completion based on the local UI state (using metadata ID) - const isCurrentVideoCompleted = completedVideoIds.has(mergedVideos[currentIndex].id); + const isCurrentVideoCompleted = completedVideoIds.has( + mergedVideos[currentIndex].id, + ); const hasNextVideo = currentIndex < mergedVideos.length - 1; - // Determine if all videos are complete based on local UI state - const allVideosCompleted = trainingVideos.every((metadata) => completedVideoIds.has(metadata.id)); + const allVideosCompleted = trainingVideos.every((metadata) => + completedVideoIds.has(metadata.id), + ); return (
{allVideosCompleted && (
-

All Training Videos Completed!

+

+ All Training Videos Completed! +

You're all done, now your manager won't pester you!

@@ -128,18 +86,22 @@ export function VideoCarousel({ videos, onVideoComplete }: VideoCarouselProps) { {!allVideosCompleted && ( <> )} diff --git a/apps/portal/src/app/api/portal/mark-video-completed/route.ts b/apps/portal/src/app/api/portal/mark-video-completed/route.ts deleted file mode 100644 index f9d581974..000000000 --- a/apps/portal/src/app/api/portal/mark-video-completed/route.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { auth } from '@/app/lib/auth'; -import { env } from '@/env.mjs'; -import { trainingVideos } from '@/lib/data/training-videos'; -import { logger } from '@/utils/logger'; -import { db } from '@db'; -import { type NextRequest, NextResponse } from 'next/server'; -import { z } from 'zod'; - -export const runtime = 'nodejs'; -export const dynamic = 'force-dynamic'; - -// Derive training video IDs from the canonical source -const TRAINING_VIDEO_IDS = trainingVideos.map((v) => v.id); - -const schema = z.object({ - videoId: z.string().min(1), - organizationId: z.string().min(1), -}); - -export async function POST(req: NextRequest) { - const session = await auth.api.getSession({ headers: req.headers }); - - if (!session?.user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const body = await req.json(); - const parsed = schema.safeParse(body); - - if (!parsed.success) { - return NextResponse.json( - { error: 'Invalid request body', details: parsed.error.flatten() }, - { status: 400 }, - ); - } - - const { videoId, organizationId } = parsed.data; - - const member = await db.member.findFirstOrThrow({ - where: { - userId: session.user.id, - organizationId, - deactivated: false, - }, - }); - - // Try to find existing record - let record = await db.employeeTrainingVideoCompletion.findFirst({ - where: { - videoId, - memberId: member.id, - }, - }); - - if (!record) { - record = await db.employeeTrainingVideoCompletion.create({ - data: { - videoId, - memberId: member.id, - completedAt: new Date(), - }, - }); - } else if (!record.completedAt) { - record = await db.employeeTrainingVideoCompletion.update({ - where: { id: record.id }, - data: { completedAt: new Date() }, - }); - } - - // Check if all training videos are now complete - const completions = await db.employeeTrainingVideoCompletion.findMany({ - where: { - memberId: member.id, - videoId: { in: TRAINING_VIDEO_IDS }, - completedAt: { not: null }, - }, - }); - - const allTrainingComplete = completions.length === TRAINING_VIDEO_IDS.length; - - if (allTrainingComplete) { - const apiUrl = env.NEXT_PUBLIC_API_URL || 'http://localhost:3333'; - const serviceToken = env.SERVICE_TOKEN_PORTAL; - - if (serviceToken) { - try { - await fetch(`${apiUrl}/v1/training/send-completion-email`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-service-token': serviceToken, - 'x-organization-id': organizationId, - }, - body: JSON.stringify({ - memberId: member.id, - organizationId, - }), - }); - } catch (error) { - logger('Error calling training completion API', { - error: error instanceof Error ? error.message : String(error), - memberId: member.id, - }); - } - } - } - - return NextResponse.json({ success: true, data: record }); -} diff --git a/apps/portal/src/hooks/use-training-completions.ts b/apps/portal/src/hooks/use-training-completions.ts new file mode 100644 index 000000000..7ef677cd2 --- /dev/null +++ b/apps/portal/src/hooks/use-training-completions.ts @@ -0,0 +1,79 @@ +import type { EmployeeTrainingVideoCompletion } from '@db'; +import { env } from '@/env.mjs'; +import useSWR from 'swr'; +import { toast } from 'sonner'; +import { useCallback } from 'react'; + +const API_URL = env.NEXT_PUBLIC_API_URL || 'http://localhost:3333'; + +const SWR_KEY = `${API_URL}/v1/training/completions`; + +const fetcher = async (url: string) => { + const res = await fetch(url, { credentials: 'include' }); + if (!res.ok) { + throw new Error('Failed to fetch training completions'); + } + return res.json(); +}; + +export function useTrainingCompletions({ + fallbackData, +}: { + fallbackData?: EmployeeTrainingVideoCompletion[]; +} = {}) { + const { data, error, isLoading, mutate } = useSWR< + EmployeeTrainingVideoCompletion[] + >(SWR_KEY, fetcher, { + fallbackData, + revalidateOnMount: !fallbackData, + revalidateOnFocus: false, + }); + + const completions = Array.isArray(data) ? data : []; + + const markVideoComplete = useCallback( + async (videoId: string) => { + try { + await mutate( + async (current) => { + const res = await fetch( + `${API_URL}/v1/training/completions/${videoId}/complete`, + { + method: 'POST', + credentials: 'include', + }, + ); + + if (!res.ok) { + throw new Error('Failed to mark video as completed'); + } + + const updatedRecord: EmployeeTrainingVideoCompletion = + await res.json(); + + if (!Array.isArray(current)) return [updatedRecord]; + + const exists = current.some((c) => c.videoId === videoId); + if (exists) { + return current.map((c) => + c.videoId === videoId ? updatedRecord : c, + ); + } + return [...current, updatedRecord]; + }, + { revalidate: false }, + ); + } catch { + toast.error('Failed to mark video as completed'); + } + }, + [mutate], + ); + + return { + completions, + isLoading, + error, + markVideoComplete, + }; +} From 3ab13eb732c267636917c9b50042c5c0fa962f6c Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Fri, 13 Mar 2026 12:24:28 -0400 Subject: [PATCH 8/9] feat(api): add new training video completion endpoints to OpenAPI spec Includes GET and POST methods for retrieving and marking training video completions, requiring session authentication. Updates .gitignore to exclude superpowers directory. --- .gitignore | 2 ++ packages/docs/openapi.json | 51 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/.gitignore b/.gitignore index 77fc3860d..9d2ceaae8 100644 --- a/.gitignore +++ b/.gitignore @@ -92,3 +92,5 @@ scripts/sync-release-branch.sh /.vscode .claude/audit-findings.md + +.superpowers/* \ No newline at end of file diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json index 10278747b..8a4111452 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -17328,6 +17328,57 @@ ] } }, + "/v1/training/completions": { + "get": { + "description": "Returns all training video completion records for the authenticated member. Requires session authentication.", + "operationId": "TrainingController_getCompletions_v1", + "parameters": [], + "responses": { + "200": { + "description": "List of training video completion records" + } + }, + "security": [ + { + "apikey": [] + } + ], + "summary": "Get training video completions for the authenticated user", + "tags": [ + "Training" + ] + } + }, + "/v1/training/completions/{videoId}/complete": { + "post": { + "description": "Marks a specific training video as completed for the authenticated member. Triggers completion email if all training is now done.", + "operationId": "TrainingController_markVideoComplete_v1", + "parameters": [ + { + "name": "videoId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The updated completion record" + } + }, + "security": [ + { + "apikey": [] + } + ], + "summary": "Mark a training video as complete", + "tags": [ + "Training" + ] + } + }, "/v1/training/send-completion-email": { "post": { "description": "Checks if the member has completed all training videos. If so, sends an email with the training certificate attached.", From 625d9878c7ab9f2e0a247323c102a3578d2caa0d Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Fri, 13 Mar 2026 12:31:11 -0400 Subject: [PATCH 9/9] test(api): add regression tests for built-in role permissions Introduces a new test suite to verify that the addition of the `portal` resource does not affect the existing permissions of built-in roles. The tests cover various roles including owner, admin, auditor, and employee, ensuring that permissions are correctly defined and maintained. --- .../training/permissions-regression.spec.ts | 335 ++++++++++++++++++ 1 file changed, 335 insertions(+) create mode 100644 apps/api/src/training/permissions-regression.spec.ts diff --git a/apps/api/src/training/permissions-regression.spec.ts b/apps/api/src/training/permissions-regression.spec.ts new file mode 100644 index 000000000..2ea282c0d --- /dev/null +++ b/apps/api/src/training/permissions-regression.spec.ts @@ -0,0 +1,335 @@ +/** + * Regression tests for built-in role permissions. + * + * Verifies that adding the `portal` resource did not alter + * the pre-existing permissions of any built-in role. + * + * We mock better-auth's ESM modules so the permissions.ts code + * can execute under Jest, while still testing the real role definitions. + */ + +// Mock better-auth ESM modules before importing @comp/auth +jest.mock('better-auth/plugins/access', () => ({ + createAccessControl: (stmt: Record) => ({ + newRole: (statements: Record) => ({ + statements, + }), + }), +})); + +jest.mock('better-auth/plugins/organization/access', () => ({ + defaultStatements: { + organization: ['update', 'delete'], + member: ['create', 'update', 'delete'], + invitation: ['create', 'delete'], + team: ['create', 'update', 'delete'], + }, + ownerAc: { + statements: { + organization: ['update', 'delete'], + member: ['create', 'update', 'delete'], + invitation: ['create', 'delete'], + team: ['create', 'update', 'delete'], + }, + }, + adminAc: { + statements: { + organization: ['update'], + member: ['create', 'update', 'delete'], + invitation: ['create', 'delete'], + team: ['create', 'update', 'delete'], + }, + }, +})); + +import { + BUILT_IN_ROLE_PERMISSIONS, + BUILT_IN_ROLE_OBLIGATIONS, +} from '@comp/auth'; + +describe('Built-in role permissions — regression', () => { + // ─── Owner ────────────────────────────────────────────────────────── + describe('owner', () => { + const perms = BUILT_IN_ROLE_PERMISSIONS.owner; + + it('should exist', () => { + expect(perms).toBeDefined(); + }); + + it('should have full GRC CRUD', () => { + const fullCrud = ['create', 'read', 'update', 'delete']; + for (const resource of [ + 'control', + 'evidence', + 'policy', + 'risk', + 'vendor', + 'task', + 'framework', + 'finding', + 'questionnaire', + 'integration', + ]) { + expect(perms[resource]).toEqual(expect.arrayContaining(fullCrud)); + } + }); + + it('should have audit create/read/update (no delete)', () => { + expect(perms.audit).toEqual( + expect.arrayContaining(['create', 'read', 'update']), + ); + expect(perms.audit).not.toContain('delete'); + }); + + it('should have apiKey create/read/delete', () => { + expect(perms.apiKey).toEqual( + expect.arrayContaining(['create', 'read', 'delete']), + ); + }); + + it('should have app:read', () => { + expect(perms.app).toEqual(expect.arrayContaining(['read'])); + }); + + it('should have trust read/update', () => { + expect(perms.trust).toEqual( + expect.arrayContaining(['read', 'update']), + ); + }); + + it('should have pentest create/read/delete', () => { + expect(perms.pentest).toEqual( + expect.arrayContaining(['create', 'read', 'delete']), + ); + }); + + it('should have training read/update', () => { + expect(perms.training).toEqual( + expect.arrayContaining(['read', 'update']), + ); + }); + + it('should have portal read/update', () => { + expect(perms.portal).toEqual( + expect.arrayContaining(['read', 'update']), + ); + }); + + it('should have organization read/update/delete', () => { + expect(perms.organization).toEqual( + expect.arrayContaining(['read', 'update', 'delete']), + ); + }); + + it('should have member CRUD', () => { + expect(perms.member).toEqual( + expect.arrayContaining(['create', 'read', 'update', 'delete']), + ); + }); + }); + + // ─── Admin ────────────────────────────────────────────────────────── + describe('admin', () => { + const perms = BUILT_IN_ROLE_PERMISSIONS.admin; + + it('should exist', () => { + expect(perms).toBeDefined(); + }); + + it('should have full GRC CRUD', () => { + const fullCrud = ['create', 'read', 'update', 'delete']; + for (const resource of [ + 'control', + 'evidence', + 'policy', + 'risk', + 'vendor', + 'task', + 'framework', + 'finding', + 'questionnaire', + 'integration', + ]) { + expect(perms[resource]).toEqual(expect.arrayContaining(fullCrud)); + } + }); + + it('should NOT have organization:delete', () => { + expect(perms.organization).not.toContain('delete'); + expect(perms.organization).toEqual( + expect.arrayContaining(['read', 'update']), + ); + }); + + it('should have app:read', () => { + expect(perms.app).toEqual(expect.arrayContaining(['read'])); + }); + + it('should have training read/update', () => { + expect(perms.training).toEqual( + expect.arrayContaining(['read', 'update']), + ); + }); + + it('should have portal read/update', () => { + expect(perms.portal).toEqual( + expect.arrayContaining(['read', 'update']), + ); + }); + + it('should have pentest create/read/delete', () => { + expect(perms.pentest).toEqual( + expect.arrayContaining(['create', 'read', 'delete']), + ); + }); + }); + + // ─── Auditor ──────────────────────────────────────────────────────── + describe('auditor', () => { + const perms = BUILT_IN_ROLE_PERMISSIONS.auditor; + + it('should exist', () => { + expect(perms).toBeDefined(); + }); + + it('should have read-only GRC access (except findings)', () => { + for (const resource of [ + 'control', + 'evidence', + 'policy', + 'risk', + 'vendor', + 'task', + 'framework', + 'audit', + 'questionnaire', + 'integration', + ]) { + expect(perms[resource]).toEqual(expect.arrayContaining(['read'])); + expect(perms[resource]).not.toContain('delete'); + } + }); + + it('should have finding create/read/update', () => { + expect(perms.finding).toEqual( + expect.arrayContaining(['create', 'read', 'update']), + ); + }); + + it('should have app:read', () => { + expect(perms.app).toEqual(expect.arrayContaining(['read'])); + }); + + it('should have trust:read only (no update)', () => { + expect(perms.trust).toEqual(expect.arrayContaining(['read'])); + expect(perms.trust).not.toContain('update'); + }); + + it('should have pentest:read only', () => { + expect(perms.pentest).toEqual(expect.arrayContaining(['read'])); + expect(perms.pentest).not.toContain('create'); + expect(perms.pentest).not.toContain('delete'); + }); + + it('should NOT have portal permissions', () => { + expect(perms.portal).toBeUndefined(); + }); + + it('should NOT have training permissions', () => { + expect(perms.training).toBeUndefined(); + }); + }); + + // ─── Employee ─────────────────────────────────────────────────────── + describe('employee', () => { + const perms = BUILT_IN_ROLE_PERMISSIONS.employee; + + it('should exist', () => { + expect(perms).toBeDefined(); + }); + + it('should have policy:read only', () => { + expect(perms.policy).toEqual(expect.arrayContaining(['read'])); + }); + + it('should have portal read/update', () => { + expect(perms.portal).toEqual( + expect.arrayContaining(['read', 'update']), + ); + }); + + it('should NOT have app access', () => { + expect(perms.app).toBeUndefined(); + }); + + it('should NOT have admin resources', () => { + for (const resource of [ + 'control', + 'evidence', + 'risk', + 'vendor', + 'task', + 'framework', + 'audit', + 'finding', + 'questionnaire', + 'integration', + 'apiKey', + 'pentest', + 'training', + ]) { + expect(perms[resource]).toBeUndefined(); + } + }); + }); + + // ─── Contractor ───────────────────────────────────────────────────── + describe('contractor', () => { + const perms = BUILT_IN_ROLE_PERMISSIONS.contractor; + + it('should exist', () => { + expect(perms).toBeDefined(); + }); + + it('should have same permissions as employee', () => { + const employeePerms = BUILT_IN_ROLE_PERMISSIONS.employee; + expect(Object.keys(perms).sort()).toEqual( + Object.keys(employeePerms).sort(), + ); + for (const resource of Object.keys(perms)) { + expect(perms[resource]).toEqual(employeePerms[resource]); + } + }); + + it('should NOT have app access', () => { + expect(perms.app).toBeUndefined(); + }); + }); + + // ─── Obligations ──────────────────────────────────────────────────── + describe('role obligations', () => { + it('owner should have compliance obligation', () => { + expect(BUILT_IN_ROLE_OBLIGATIONS.owner).toEqual({ compliance: true }); + }); + + it('admin should have compliance obligation', () => { + expect(BUILT_IN_ROLE_OBLIGATIONS.admin).toEqual({ compliance: true }); + }); + + it('auditor should have NO obligations', () => { + expect(BUILT_IN_ROLE_OBLIGATIONS.auditor).toEqual({}); + }); + + it('employee should have compliance obligation', () => { + expect(BUILT_IN_ROLE_OBLIGATIONS.employee).toEqual({ + compliance: true, + }); + }); + + it('contractor should have compliance obligation', () => { + expect(BUILT_IN_ROLE_OBLIGATIONS.contractor).toEqual({ + compliance: true, + }); + }); + }); +});