diff --git a/apps/saas/public/openapi.json b/apps/saas/public/openapi.json index c72e8a20..0b3159c3 100644 --- a/apps/saas/public/openapi.json +++ b/apps/saas/public/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "Nuclom API", "version": "1.0.0", - "description": "Nuclom is a video collaboration platform that helps teams organize, share, and collaborate on video content.", + "description": "Nuclom is a Knowledge Hub that helps teams capture, organize, and surface institutional knowledge from videos, meetings, and documents.", "contact": { "name": "Nuclom Support", "url": "https://nuclom.com/support" @@ -7015,4 +7015,4 @@ } } } -} \ No newline at end of file +} diff --git a/apps/saas/src/app/(api)/api/highlight-reels/[id]/render/route.ts b/apps/saas/src/app/(api)/api/highlight-reels/[id]/render/route.ts index 89a0126e..c44b92e4 100644 --- a/apps/saas/src/app/(api)/api/highlight-reels/[id]/render/route.ts +++ b/apps/saas/src/app/(api)/api/highlight-reels/[id]/render/route.ts @@ -1,4 +1,5 @@ import { handleEffectExit, runApiEffect } from '@nuclom/lib/api-handler'; +import { ValidationError } from '@nuclom/lib/effect/errors'; import { VideoLayer } from '@nuclom/lib/effect/layers/video'; import { Auth } from '@nuclom/lib/effect/services/auth'; import { ClipRepository } from '@nuclom/lib/effect/services/clip-repository'; @@ -24,10 +25,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ // Validate that the reel has clips if (!reel.clipIds || reel.clipIds.length === 0) { - return yield* Effect.fail({ - _tag: 'ValidationError' as const, - message: 'Highlight reel must have at least one clip', - }); + return yield* Effect.fail(new ValidationError({ message: 'Highlight reel must have at least one clip' })); } // Trigger the rendering workflow diff --git a/apps/saas/src/app/(org)/org/[organization]/loading.tsx b/apps/saas/src/app/(org)/org/[organization]/loading.tsx index e2de99cf..30211d63 100644 --- a/apps/saas/src/app/(org)/org/[organization]/loading.tsx +++ b/apps/saas/src/app/(org)/org/[organization]/loading.tsx @@ -90,7 +90,7 @@ export default function Loading() { {/* New This Week section */} - {/* From Your Channels section */} + {/* From Your Collections section */} diff --git a/apps/saas/src/app/(org)/org/[organization]/page.tsx b/apps/saas/src/app/(org)/org/[organization]/page.tsx index 73ea1eae..352d0095 100644 --- a/apps/saas/src/app/(org)/org/[organization]/page.tsx +++ b/apps/saas/src/app/(org)/org/[organization]/page.tsx @@ -1,5 +1,5 @@ import { auth } from '@nuclom/lib/auth'; -import { getCollections, getOrganizationBySlug, getVideos } from '@nuclom/lib/effect/server'; +import { getOrganizationBySlug, getVideos } from '@nuclom/lib/effect/server'; import type { Metadata } from 'next'; import { headers } from 'next/headers'; import { notFound, redirect } from 'next/navigation'; @@ -138,21 +138,17 @@ interface DashboardContentProps { } async function DashboardContent({ organizationId, organizationSlug, userName }: DashboardContentProps) { - // Fetch videos and collections using cached Effect queries - const [videosResult, collectionsResult] = await Promise.all([ - getVideos(organizationId), - getCollections(organizationId), - ]); + // Fetch videos using cached Effect queries + const videosResult = await getVideos(organizationId); const videos = videosResult.data; - const collections = collectionsResult.data; const hasVideos = videos.length > 0; - const hasCollectionsWithVideos = collections.some((collection) => collection.videoCount > 0); // Split videos into sections const continueWatching = videos.slice(0, 4); const newThisWeek = videos.slice(0, 8); - const fromCollections = hasCollectionsWithVideos ? videos.slice(0, 6) : []; + // Show the most recently added videos as a "Recently Added" section + const recentlyAdded = videos.slice(0, 6); return (
@@ -181,10 +177,11 @@ async function DashboardContent({ organizationId, organizationSlug, userName }: /> ) : ( diff --git a/apps/saas/src/components/knowledge/video-decisions-sidebar.tsx b/apps/saas/src/components/knowledge/video-decisions-sidebar.tsx index ccada143..2b5acbd2 100644 --- a/apps/saas/src/components/knowledge/video-decisions-sidebar.tsx +++ b/apps/saas/src/components/knowledge/video-decisions-sidebar.tsx @@ -7,6 +7,7 @@ * Integrates with video player for seeking to decision timestamps. */ +import { logger } from '@nuclom/lib/client-logger'; import type { DecisionStatus, DecisionType } from '@nuclom/lib/db/schema/types'; import { cn } from '@nuclom/lib/utils'; import { Avatar, AvatarFallback, AvatarImage } from '@nuclom/ui/avatar'; @@ -289,8 +290,9 @@ export function VideoDecisionsSidebar({ } else { onLoad?.(0); } - } catch { - // Silently fail - decisions are supplementary + } catch (err) { + // Log the error for observability - decisions are supplementary so we don't surface it to the user + logger.error('Failed to fetch decisions for video', { videoId, error: err }); onLoad?.(0); } finally { setIsLoading(false); diff --git a/tests/e2e/saas/videos.spec.ts b/tests/e2e/saas/content.spec.ts similarity index 60% rename from tests/e2e/saas/videos.spec.ts rename to tests/e2e/saas/content.spec.ts index 8889a720..fac001cc 100644 --- a/tests/e2e/saas/videos.spec.ts +++ b/tests/e2e/saas/content.spec.ts @@ -2,45 +2,45 @@ import { expect, TEST_CONFIG, test } from '../shared/fixtures'; const { testOrg } = TEST_CONFIG; -test.describe('Video List', () => { +test.describe('Content Library', () => { test.describe('Authenticated User', () => { - test('should display video cards if videos exist', async ({ authenticatedPage: page }) => { + test('should display content cards if items exist', async ({ authenticatedPage: page }) => { await page.goto(`/org/${testOrg}`); await expect(page).toHaveURL(new RegExp(`/org/${testOrg}`)); await page.waitForLoadState('domcontentloaded'); - // Check if we have video content or empty state - // Video sections show "Continue Watching", "New This Week", etc. + // Check if we have content items or empty state + // Content sections show "Continue Viewing", "New This Week", etc. const hasVideoSection = await page - .getByText('Continue Watching') + .getByText('Continue Viewing') .isVisible() .catch(() => false); - // Empty state shows upload prompt + // Empty state shows upload prompt or "no content yet" message const isEmpty = await page - .getByText(/upload your first video|get started by uploading/i) + .getByText(/upload your first content|no content yet|get started by uploading/i) .isVisible() .catch(() => false); - // Also check for the dashboard hero which shows a greeting when no videos + // Also check for the dashboard hero which shows a greeting when no content const hasDashboardHero = await page .getByRole('heading', { name: /good (morning|afternoon|evening)/i }) .isVisible() .catch(() => false); - // Either videos or empty state should be visible + // Either content or empty state should be visible expect(hasVideoSection || isEmpty || hasDashboardHero).toBe(true); }); }); }); -test.describe('Video Detail Page', () => { +test.describe('Content Detail Page', () => { test.describe('Page Elements', () => { - test('should handle video page navigation', async ({ authenticatedPage: page }) => { + test('should handle content page navigation', async ({ authenticatedPage: page }) => { // First go to org page await page.goto(`/org/${testOrg}`); await expect(page).toHaveURL(new RegExp(`/org/${testOrg}`)); - // Look for video links + // Look for content links const videoLinks = page.locator("a[href*='/videos/']"); const count = await videoLinks.count(); @@ -69,8 +69,8 @@ test.describe('Search Page', () => { }); }); -test.describe('My Videos Page', () => { - test('should display my videos page', async ({ authenticatedPage: page }) => { +test.describe('My Content Page', () => { + test('should display my content page', async ({ authenticatedPage: page }) => { await page.goto(`/org/${testOrg}/my-videos`); await expect(page).toHaveURL(new RegExp(`/org/${testOrg}/my-videos`)); @@ -81,8 +81,8 @@ test.describe('My Videos Page', () => { }); }); -test.describe('Shared Videos Page', () => { - test('should display shared videos page', async ({ authenticatedPage: page }) => { +test.describe('Shared Content Page', () => { + test('should display shared content page', async ({ authenticatedPage: page }) => { await page.goto(`/org/${testOrg}/shared`); await expect(page).toHaveURL(new RegExp(`/org/${testOrg}/shared`)); @@ -93,30 +93,26 @@ test.describe('Shared Videos Page', () => { }); }); -test.describe('Channels Page', () => { - test('should handle channel navigation', async ({ authenticatedPage: page }) => { - await page.goto(`/org/${testOrg}`); - await expect(page).toHaveURL(new RegExp(`/org/${testOrg}`)); +test.describe('Collections Page', () => { + test('should display collections page', async ({ authenticatedPage: page }) => { + await page.goto(`/org/${testOrg}/collections`); + await expect(page).toHaveURL(new RegExp(`/org/${testOrg}/collections`)); - // Look for channel links - const channelLinks = page.locator("a[href*='/channels/']"); - const count = await channelLinks.count(); + await page.waitForLoadState('domcontentloaded'); - if (count > 0) { - await channelLinks.first().click(); - await expect(page).toHaveURL(/\/channels\/[\w-]+/); - } + // Should be on the collections page + await expect(page).toHaveURL(/\/collections/); }); }); -test.describe('Series Page', () => { - test('should display series page', async ({ authenticatedPage: page }) => { - await page.goto(`/org/${testOrg}/series`); - await expect(page).toHaveURL(new RegExp(`/org/${testOrg}/series`)); +test.describe('Topics Page', () => { + test('should display topics page', async ({ authenticatedPage: page }) => { + await page.goto(`/org/${testOrg}/topics`); + await expect(page).toHaveURL(new RegExp(`/org/${testOrg}/topics`)); await page.waitForLoadState('domcontentloaded'); - // Should be on the series page - await expect(page).toHaveURL(/\/series/); + // Should be on the topics page + await expect(page).toHaveURL(/\/topics/); }); }); diff --git a/tests/e2e/saas/organization.spec.ts b/tests/e2e/saas/organization.spec.ts index 10126824..aa2e5815 100644 --- a/tests/e2e/saas/organization.spec.ts +++ b/tests/e2e/saas/organization.spec.ts @@ -17,14 +17,14 @@ test.describe('Organization Dashboard', () => { // Check if videos exist for further assertions const hasVideos = await page - .getByText('Continue Watching') + .getByText('Continue Viewing') .isVisible() .catch(() => false); if (hasVideos) { // If videos exist, check for all sections - await expect(page.getByText('Continue Watching')).toBeVisible(); + await expect(page.getByText('Continue Viewing')).toBeVisible(); await expect(page.getByText('New This Week')).toBeVisible(); - await expect(page.getByText('From Your Collections')).toBeVisible(); + await expect(page.getByText('Recently Added')).toBeVisible(); } }); @@ -32,12 +32,12 @@ test.describe('Organization Dashboard', () => { await page.goto(`/org/${testOrg}`); await expect(page).toHaveURL(new RegExp(`/org/${testOrg}`)); - // If no videos, should show upload button - const noVideosMessage = page.getByText(/no videos found|upload your first video/i); + // If no videos, should show upload button or dashboard hero + const noVideosMessage = page.getByText(/no content yet|upload your first content|get started by uploading/i); const hasNoVideos = await noVideosMessage.isVisible().catch(() => false); if (hasNoVideos) { - await expect(page.getByRole('link', { name: /upload first video/i })).toBeVisible(); + await expect(page.getByRole('link', { name: /upload your first content|upload content/i })).toBeVisible(); } }); diff --git a/tests/e2e/saas/upload.spec.ts b/tests/e2e/saas/upload.spec.ts index a4122544..80455fd7 100644 --- a/tests/e2e/saas/upload.spec.ts +++ b/tests/e2e/saas/upload.spec.ts @@ -2,7 +2,7 @@ import { expect, TEST_CONFIG, test } from '../shared/fixtures'; const { testOrg } = TEST_CONFIG; -test.describe('Video Upload Page', () => { +test.describe('Content Upload Page', () => { test.describe('Authenticated User', () => { test('should display upload page with form elements', async ({ authenticatedPage: page }) => { await page.goto(`/org/${testOrg}/upload`); @@ -49,15 +49,15 @@ test.describe('Video Upload Page', () => { .toBe(true); }); - test('should have back to videos link', async ({ authenticatedPage: page }) => { + test('should have back to dashboard link', async ({ authenticatedPage: page }) => { await page.goto(`/org/${testOrg}/upload`); await expect(page).toHaveURL(new RegExp(`/org/${testOrg}/upload`)); // Wait for initial DOM content await page.waitForLoadState('domcontentloaded'); - // Wait for the back link to appear - await expect(page.getByText(/back to videos/i).first()).toBeVisible({ timeout: 5000 }); + // Wait for the back link to appear (upload page shows "Back to Dashboard") + await expect(page.getByText(/back to dashboard/i).first()).toBeVisible({ timeout: 5000 }); }); test('should navigate back to organization page', async ({ authenticatedPage: page }) => { @@ -67,12 +67,12 @@ test.describe('Video Upload Page', () => { // Wait for initial DOM content await page.waitForLoadState('domcontentloaded'); - // Wait for the back link to appear - const backLink = page.getByText(/back to videos/i).first(); + // Wait for the back link to appear (upload page shows "Back to Dashboard") + const backLink = page.getByText(/back to dashboard/i).first(); await expect(backLink).toBeVisible({ timeout: 5000 }); await backLink.click(); - // The link goes to /org/{slug} which shows videos + // The link goes to /org/{slug} dashboard await expect(page).toHaveURL(new RegExp(`/org/${testOrg}`)); }); @@ -115,7 +115,7 @@ test.describe('Video Upload Page', () => { }); }); -test.describe('Video Upload Flow', () => { +test.describe('Content Upload Flow', () => { test('upload area should respond to hover interactions', async ({ authenticatedPage: page }) => { await page.goto(`/org/${testOrg}/upload`); await expect(page).toHaveURL(new RegExp(`/org/${testOrg}/upload`));