Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/saas/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -7015,4 +7015,4 @@
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/saas/src/app/(org)/org/[organization]/loading.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export default function Loading() {
<VideoSectionSkeleton cardCount={4} />
{/* New This Week section */}
<VideoSectionSkeleton cardCount={4} />
{/* From Your Channels section */}
{/* From Your Collections section */}
<VideoSectionSkeleton cardCount={4} />
</div>

Expand Down
21 changes: 9 additions & 12 deletions apps/saas/src/app/(org)/org/[organization]/page.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 (
<div className="space-y-8">
Expand Down Expand Up @@ -181,10 +177,11 @@ async function DashboardContent({ organizationId, organizationSlug, userName }:
/>

<VideoSection
title="From Your Collections"
videos={fromCollections}
title="Recently Added"
description="Latest content from your organization"
videos={recentlyAdded}
organization={organizationSlug}
viewAllHref={`/org/${organizationSlug}/collections`}
viewAllHref={`/org/${organizationSlug}/videos`}
/>
</>
) : (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
62 changes: 29 additions & 33 deletions tests/e2e/saas/videos.spec.ts → tests/e2e/saas/content.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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`));

Expand All @@ -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`));

Expand All @@ -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/);
});
});
12 changes: 6 additions & 6 deletions tests/e2e/saas/organization.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,27 +17,27 @@ 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();
}
});

test('should display upload button when no videos', async ({ authenticatedPage: page }) => {
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();
}
});

Expand Down
16 changes: 8 additions & 8 deletions tests/e2e/saas/upload.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down Expand Up @@ -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 }) => {
Expand All @@ -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}`));
});

Expand Down Expand Up @@ -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`));
Expand Down
Loading