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`));