Skip to content

Conversation

oscartbeaumont
Copy link
Member

@oscartbeaumont oscartbeaumont commented Oct 7, 2025

This will allow better type-safety in the new Effect code, it will improve interop with that new code and will prevent identifiers being used in the wrong places.

Summary by CodeRabbit

  • New Features

    • Email notifications sent for new text comments by signed-in users.
    • Comment deletion now removes all replies to the deleted comment.
  • Improvements

    • Consistent, strongly typed identifiers across organizations, spaces, users, videos, and comments for improved reliability.
    • More robust org/space handling in uploads, sharing, and membership flows.
    • SSO and organization lookups accept validated IDs.
  • API

    • Stricter ID validation and clearer 400/404 error responses on comment and video routes.
  • Refactor

    • Widespread type refinements with no expected behavior changes.

Copy link
Contributor

coderabbitai bot commented Oct 7, 2025

Walkthrough

The PR standardizes ID handling across web, backend, and domain layers by replacing plain strings with branded domain ID types (OrganisationId, SpaceIdOrOrganisationId, UserId, VideoId, CommentId). It updates function signatures, schemas, queries, and component props accordingly. It also adds SpaceIdOrOrganisationId, a small Option utility, and refines comment API flows.

Changes

Cohort / File(s) Summary
Domain types and schemas
packages/web-domain/src/Space.ts, packages/web-domain/src/Folder.ts, packages/web-domain/src/Authentication.ts, packages/database/schema.ts
Introduces Space.SpaceIdOrOrganisationId, brands SpaceId, propagates branded ID types across Folder and CurrentUser. Applies .$type<...> to many DB columns to enforce OrganisationId/UserId/SpaceIdOrOrganisationId/VideoId/CommentId.
Auth/session infrastructure
packages/database/auth/drizzle-adapter.ts, packages/database/auth/auth-options.ts, packages/database/auth/session.ts
Replaces raw IDs with User.UserId/Organisation.OrganisationId in user/session creation, queries, and updates; initializes activeOrganizationId with typed empty value; updates lookups to use wrappers.
Backend repos and policies
packages/web-backend/src/Spaces/SpacesRepo.ts, packages/web-backend/src/Spaces/SpacesPolicy.ts, packages/web-backend/src/Spaces/index.ts, packages/web-backend/src/Organisations/OrganisationsRepo.ts, packages/web-backend/src/Organisations/OrganisationsPolicy.ts, packages/web-backend/src/Folders/index.ts, packages/web-backend/src/S3Buckets/S3BucketsRepo.ts, packages/web-backend/src/S3Buckets/index.ts
Tightens method signatures to use branded IDs (UserId, OrganisationId, SpaceIdOrOrganisationId); adjusts comparisons and inputs; adds corresponding type imports.
Organization actions
apps/web/actions/organization/*
Changes organizationId parameters from string to Organisation.OrganisationId across CRUD, SSO, invites, members, domains, spaces (including SpaceId generation via Space.SpaceId.make and deleteSpace using SpaceIdOrOrganisationId).
Space actions
apps/web/actions/spaces/*
Updates spaceId parameters to Space.SpaceIdOrOrganisationId and imports Space type for add/remove/get video IDs.
Video actions
apps/web/actions/video/upload.ts, apps/web/actions/caps/share.ts
Upload: orgId typed as Organisation.OrganisationId; share: widens spaceIds to Space.SpaceIdOrOrganisationId[], adjusts inArray cast.
Comment actions
apps/web/actions/videos/new-comment.ts, apps/web/actions/videos/delete-comment.ts
Switches to domain-wrapped CommentId/UserId/VideoId; generates CommentId with make(...); strengthens delete parameters to CommentId types.
Dashboard components/pages (spaces)
apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx, .../folder/[folderId]/page.tsx, .../components/AddVideosDialog*.tsx, .../components/MembersIndicator.tsx, .../[spaceId]/actions.ts, .../SharedCaps.tsx
Propagates Space.SpaceIdOrOrganisationId/OrganisationId/UserId types through props, helpers, and Zod schemas; generic-izes AddVideosDialogBase over entityId; wraps incoming strings with .make(...).
Dashboard navbar/settings
apps/web/app/(org)/dashboard/_components/Navbar/*, .../settings/account/*, .../settings/organization/components/*
Uses OrganisationId for updateActiveOrganization, account defaultOrgId, domain/invite/member operations; wraps orgId with Organisation.OrganisationId.make(...) at call sites; minor non-null assertions in MembersCard/OrgName.
Auth flows (SSO forms)
apps/web/app/(org)/login/form.tsx, apps/web/app/(org)/signup/form.tsx
Wrap organizationId with Organisation.OrganisationId.make(...) before SSO lookup.
API routes (video/comments/upload/desktop/onboarding/webhooks)
apps/web/app/api/video/comment/route.ts, .../comment/delete/route.ts, .../upload/[...route]/multipart.ts, .../desktop/[...route]/video.ts, .../settings/onboarding/route.ts, .../webhooks/stripe/route.ts
Strengthens ID typing in validators and internals (OrganisationId, VideoId, CommentId, UserId). Comment POST adds input validation and async notification; DELETE uses Option and cascaded delete of replies; onboarding/org creation uses OrganisationId.make(...).
Other app components
apps/web/app/(org)/dashboard/caps/components/*, apps/web/app/embed/[videoId]/page.ts, apps/web/app/s/[videoId]/*
Updates props and optimistic objects to use branded IDs; renames Comment to CommentComponent; adjusts handlers to CommentId/UserId; embed SharedOrganization uses OrganisationId.
Utilities and libs
apps/web/utils/effect.ts, apps/web/utils/helpers.ts, apps/web/lib/Notification.ts, apps/web/components/forms/server.ts
Adds optionFromTOrFirst helper; getHeaders accepts string

Sequence Diagram(s)

sequenceDiagram
  actor U as User
  participant C as Client (page/app)
  participant API as POST /api/video/comment
  participant DB as Database
  participant EM as Email Service

  U->>C: Submit comment (type, content, videoId, [parentCommentId])
  C->>API: Request with domain-wrapped IDs
  API->>API: Validate required fields
  alt invalid
    API-->>C: 400 Bad Request
  else valid
    API->>DB: Insert comment (CommentId, VideoId, UserId)
    opt type == "text" and user != anonymous
      API-->>EM: Fire-and-forget notify new-comment
    end
    API-->>C: 200 Created (comment)
  end
Loading
sequenceDiagram
  actor U as User
  participant C as Client
  participant API as DELETE /api/video/comment
  participant DB as Database

  U->>C: Delete comment (commentId)
  C->>API: Request with commentId
  API->>API: Parse Option<CommentId>
  alt missing
    API-->>C: 400 Missing data
  else present
    API->>DB: Select comment by id and authorId
    alt not found/unauthorized
      API-->>C: 404 Not Found
    else ok
      API->>DB: Delete where id = commentId OR parentCommentId = commentId
      API-->>C: 200 Deleted
    end
  end
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Suggested reviewers

  • Brendonovich

Poem

In fields of IDs, I hop with glee,
From stringy weeds to typed-bright tree.
Space or Org? A union trail—
My paws won’t slip, these brands won’t fail.
Comments sing, uploads chime—
Typed carrots stacked, one nibble at a time. 🥕🐇

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 4.08% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Title Check ✅ Passed The title accurately and succinctly describes the primary change of the pull request, which is adopting branded types for identifiers throughout the codebase. It clearly conveys the intent without extraneous detail or generic phrasing, enabling easy understanding by reviewers and future maintainers.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch branded-ids

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@oscartbeaumont oscartbeaumont marked this pull request as ready for review October 7, 2025 11:42
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 18

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (10)
apps/web/app/(org)/dashboard/settings/organization/components/OrgName.tsx (1)

16-32: Add null check for activeOrganization to prevent runtime error.

The non-null assertion (!) on line 22 assumes activeOrganization is always defined, but this is inconsistent with the optional chaining used elsewhere (lines 12, 63). If activeOrganization is null or undefined, a runtime error will occur when accessing .organization.id.

Apply this diff to add a guard:

 const handleOrgNameChange = async () => {
   try {
-    if (!orgName) return;
+    if (!orgName || !activeOrganization) return;
     setSaveLoading(true);
     await updateOrganizationDetails({
       organizationName: orgName,
       organizationId: activeOrganization!.organization.id,
     });
apps/web/app/api/desktop/[...route]/video.ts (1)

131-160: Type consistency violation between database IDs and branded types.

Lines 131-160 mix plain string IDs from the database with branded Organisation.OrganisationId types, which undermines type safety:

  • Line 131: userOrgIds is created from database results that return plain string IDs
  • Line 136, 141: Comparing branded orgId / user.defaultOrgId with userOrgIds array of plain strings using includes()
  • Lines 145, 154, 159: Assigning plain string IDs directly to videoOrgId which is typed as Organisation.OrganisationId

The database query at lines 110-130 returns plain strings from organizations.id, but these are used without transformation to branded types.

Transform database IDs to branded types immediately after retrieval:

 const userOrganizations = await db()
   .select({
     id: organizations.id,
     name: organizations.name,
   })
   .from(organizations)
   .leftJoin(
     organizationMembers,
     eq(organizations.id, organizationMembers.organizationId),
   )
   .where(
     or(
       eq(organizations.ownerId, user.id),
       eq(organizationMembers.userId, user.id),
     ),
   )
   .groupBy(organizations.id, organizations.name)
   .orderBy(organizations.createdAt);
-const userOrgIds = userOrganizations.map((org) => org.id);
+const userOrgIds = userOrganizations.map((org) => Organisation.OrganisationId.make(org.id));

Similarly, ensure user.defaultOrgId is transformed when retrieved from the database (verify this in the user query/schema).

apps/web/app/(org)/dashboard/settings/organization/components/MembersCard.tsx (1)

115-131: Critical: Confirmation dialog shows hardcoded test data instead of actual member.

The confirmation dialog (line 128) uses pendingMemberTest (hardcoded on lines 115-119) instead of the pendingMember state variable. This means users will always see "John Doe" in the confirmation message, regardless of which member they're actually removing.

Apply this diff to use the correct state variable:

-	const pendingMemberTest = {
-		id: "1",
-		name: "John Doe",
-		email: "john.doe@example.com",
-	};
-
 	return (
 		<>
 			<ConfirmationDialog
 				open={confirmOpen}
 				icon={<FontAwesomeIcon icon={faUser} />}
 				title="Remove member"
 				description={
-					pendingMemberTest
-						? `Are you sure you want to remove ${pendingMemberTest.name}
+					pendingMember
+						? `Are you sure you want to remove ${pendingMember.name}
          from your organization? this action cannot be undone.`
 						: ""
 				}
apps/web/actions/spaces/remove-videos.ts (2)

74-74: Replace any with unknown for stricter type safety.

As per coding guidelines, avoid any. Use unknown instead and perform type narrowing if needed.

Apply this diff:

-	} catch (error: any) {
+	} catch (error: unknown) {
 		return {
 			success: false,
-			message: error.message || "Failed to remove videos from space",
+			message: error instanceof Error ? error.message : "Failed to remove videos from space",
 		};

25-25: Remove inline comments per coding guidelines.

As per coding guidelines: "Do not add inline, block, or docstring comments in any language; code must be self-explanatory."

Apply this diff to remove the comments:

-		// Only allow removing videos the user owns
 		const userVideos = await db()
 			.select({ id: videos.id })
 			.from(videos)
 			.where(and(eq(videos.ownerId, user.id), inArray(videos.id, videoIds)));
 
 		const validVideoIds = userVideos.map((v) => v.id);
 
 		if (validVideoIds.length === 0) {
 			throw new Error("No valid videos found");
 		}
 
-		// Remove from spaceVideos
 		await db()
 			.delete(spaceVideos)
 			.where(
 				and(
 					eq(spaceVideos.spaceId, spaceId),
 					inArray(spaceVideos.videoId, validVideoIds),
 				),
 			);
 
-		// Set folderId to null for any removed videos that are in folders belonging to this space
-		// Find all folder IDs in this space
 		const folderRows = await db()
 			.select({ id: folders.id })
 			.from(folders)

Also applies to: 37-37, 47-48

apps/web/app/api/video/comment/delete/route.ts (1)

10-24: Route should use Effect HttpApi pattern per coding guidelines.

The coding guidelines specify:

  • "Construct API routes with @effect/platform HttpApi/HttpApiBuilder and export only the handler from apiToHandler(ApiLive)"
  • "Use HttpAuthMiddleware for required auth and provideOptionalAuth for guest routes"

This route uses plain Next.js Response handlers and direct getCurrentUser() calls instead of the Effect-based patterns. While the current PR focuses on branded IDs, consider tracking this architectural migration.

As per coding guidelines.

apps/web/actions/organizations/get-organization-videos.ts (1)

30-33: Brand returned video IDs as Video.VideoId[].
Import Video from "@cap/web-domain" and cast in the return:

import type { Organisation, Video } from "@cap/web-domain";
// …
return {
  success: true,
  data: videoIds.map((v) => v.videoId as Video.VideoId),
};
packages/database/auth/drizzle-adapter.ts (1)

141-155: Wrong brand used for account IDs

accounts.id represents an account primary key, but we’re instantiating it with User.UserId.make(...). That defeats the purpose of branded identifiers and risks leaking account IDs where a user ID is expected. Please keep this as the plain nanoId() (or introduce an AccountId brand) instead of reusing the user ID brand.

apps/web/app/(org)/dashboard/spaces/[spaceId]/actions.ts (1)

180-184: Do not brand spaceMembers.id as a UserId.

spaceMembers.id is the primary key of the membership row, not a user identifier. Wrapping the generated value with User.UserId.make misbrands that column and undermines the guarantees you're adding elsewhere—callers could wrongly treat a membership ID as a user ID. Please leave this as a plain nanoid (or introduce a dedicated SpaceMemberId brand if desired).

apps/web/app/api/video/comment/route.ts (1)

50-71: Non-awaited fetch errors aren’t caught; remove inline comments

  • try/catch won’t catch errors from an unawaited Promise.
  • Inline comments violate the repo guideline to avoid comments in TS files.

Apply:

-    // Trigger email notification for new comment
-    if (type === "text" && userId !== "anonymous") {
-      try {
-        // Don't await this to avoid blocking the response
+    if (type === "text" && !isAnonymous) {
         const absoluteUrl = new URL(
           "/api/email/new-comment",
           serverEnv().WEB_URL,
         ).toString();
-        fetch(absoluteUrl, {
+        void fetch(absoluteUrl, {
           method: "POST",
           headers: {
             "Content-Type": "application/json",
           },
           body: JSON.stringify({
             commentId: id,
           }),
-        });
-      } catch (error) {
-        console.error("Error triggering comment notification:", error);
-        // Don't fail the comment creation if notification fails
-      }
+        }).catch((error) => {
+          console.error("Error triggering comment notification:", error);
+        });
       }

As per coding guidelines

♻️ Duplicate comments (1)
apps/web/app/(org)/signup/form.tsx (1)

121-123: Verify input validation for branded type constructor.

The raw organizationId string from user input is wrapped with Organisation.OrganisationId.make() without prior validation. If the make() constructor enforces format constraints or throws on invalid input, the existing try-catch may not provide user-friendly error messages.

🧹 Nitpick comments (13)
apps/web/actions/organizations/get-organization-videos.ts (2)

9-11: Consider adding an explicit return type annotation.

While TypeScript infers the return type, explicitly defining it improves type safety, catches errors earlier, and enhances IDE support.

Apply this diff to add an explicit return type:

 export async function getOrganizationVideoIds(
 	organizationId: Organisation.OrganisationId,
-) {
+): Promise<
+	| { success: true; data: string[] }
+	| { success: false; error: string }
+> {

19-21: Validation check is somewhat redundant with branded types.

Since organizationId is now typed as Organisation.OrganisationId, TypeScript enforces non-null/non-undefined values at compile time, making this truthiness check largely redundant. However, keeping it as defensive programming is acceptable.

apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx (1)

521-525: Consider using branded VideoId type for consistency.

The sendProgressUpdate function uses videoId: string instead of a branded type, which is inconsistent with the PR objective of using branded types for identifiers everywhere. Since this function receives video identifiers (e.g., uploadId at line 439), consider updating it to use Video.VideoId for type consistency.

Apply this diff to use the branded type:

+import type { Video } from "@cap/web-domain";
+
 const sendProgressUpdate = async (
-  videoId: string,
+  videoId: Video.VideoId,
   uploaded: number,
   total: number,
 ) => {
apps/web/app/(org)/dashboard/settings/account/Settings.tsx (1)

127-134: Consider removing empty string fallback for type consistency.

The Select's value prop chains multiple Organisation.OrganisationId values but falls back to an empty string "" on line 133. This mixes branded types with a plain string, which could cause type mismatches. Consider using undefined instead of "" for the final fallback, or ensure all values in the chain are of the same type.

Apply this diff to use a consistent type:

           value={
             defaultOrgId ??
             user?.defaultOrgId ??
-            organizationData?.[0]?.organization.id ??
-            ""
+            organizationData?.[0]?.organization.id
           }
apps/web/app/(org)/login/form.tsx (1)

4-4: Consider type-only import for Organisation.

Since Organisation is only used for accessing the OrganisationId type namespace, consider using a type-only import to clarify intent and potentially improve build performance.

Apply this diff:

-import { Organisation } from "@cap/web-domain";
+import type { Organisation } from "@cap/web-domain";
apps/web/app/(org)/signup/form.tsx (1)

4-4: Consider type-only import for Organisation.

Since Organisation is only used for accessing the OrganisationId type namespace, consider using a type-only import.

Apply this diff:

-import { Organisation } from "@cap/web-domain";
+import type { Organisation } from "@cap/web-domain";
packages/web-backend/src/Spaces/index.ts (1)

18-21: Branded ID adoption looks good; consider symmetric casting for the space query

You correctly cast to Organisation.OrganisationId for the org lookup. For consistency (and to keep Dz.eq operands narrowly typed), consider also casting the space lookup:

  • Current: where(Dz.eq(Db.spaces.id, spaceOrOrgId))
  • Suggested: where(Dz.eq(Db.spaces.id, spaceOrOrgId as Space.SpaceId))

This avoids passing a union into eq and can prevent future type widening.

Also applies to: 42-47

apps/web/app/s/[videoId]/_components/Toolbar.tsx (1)

40-47: Root comment sentinel: ensure empty CommentId is normalized server-side

Using Comment.CommentId.make("") to denote “no parent” is fine for UI, but ensure newComment maps this to null/undefined before DB writes; storing "" in parentCommentId can cause filtering/join issues later.

If normalization isn’t already done, prefer omitting the field, passing null, or introducing an explicit Option type.

Also applies to: 59-62, 81-88, 100-101

apps/web/app/embed/[videoId]/page.tsx (1)

14-14: Verify: is sharedOrganization.organizationId branded before passing to EmbedContent?

EmbedContent expects Organisation.OrganisationId, but the select maps sharedVideos.organizationId (likely a raw string). Brand it before passing (or during shaping) to satisfy the contract:

Example (before render):

const branded = video.sharedOrganization
  ? { organizationId: Organisation.OrganisationId.make(video.sharedOrganization.organizationId) }
  : null;
// pass: <EmbedContent video={{ ...video, sharedOrganization: branded }} ... />

Alternatively, brand inside the select mapping if convenient.

Also applies to: 206-208

apps/web/app/s/[videoId]/_components/tabs/Activity/Comments.tsx (1)

88-95: Branded IDs applied correctly across comments/replies

  • Good: id/authorId branding and delete handler signatures.
  • Verify: Using Comment.CommentId.make("") for roots — ensure newComment normalizes this to null/undefined before persisting.

If not normalized, prefer omitting parentCommentId or using an explicit Option to avoid storing empty strings.

Also applies to: 105-112, 130-141, 176-178, 213-213

apps/web/app/api/video/comment/route.ts (3)

41-41: Branded videoId: good; add input validation

Branding is correct. Consider validating the request body (type/content/videoId/parentCommentId/timestamp) with a shared schema (e.g., Zod/domain schema) before constructing branded types.

As per coding guidelines


92-95: Avoid as any on POST; return a typed Response

Prefer awaiting the middleware and returning a Response without any.

Apply:

 export const POST = async (request: NextRequest) => {
   const headersList = await headers();
-  return rateLimitMiddleware(10, handlePost(request), headersList) as any;
+  return await rateLimitMiddleware(10, handlePost(request), headersList);
 };

If types still force a cast, consider refining rateLimitMiddleware’s return type instead of using any.


1-99: Route style deviates from @effect/platform HttpApi

This route uses NextRequest directly. The repo guidelines for apps/web/app/api/**/route.ts require:

  • Build APIs with @effect/platform HttpApi/HttpApiBuilder and export only the handler from apiToHandler(ApiLive).
  • Map domain errors with HttpApiError.* exhaustively.
  • Use HttpAuthMiddleware/provideOptionalAuth; avoid duplicate session lookups.
  • Provide dependencies via Layer.provide.

Recommend migrating this route to the HttpApi pattern in a follow-up PR to keep this PR focused on branded IDs.

As per coding guidelines

@oscartbeaumont
Copy link
Member Author

@coderabbitai dismiss it all.

Copy link
Contributor

coderabbitai bot commented Oct 7, 2025

@oscartbeaumont: I'll resolve all my review comments on this PR.

✅ Actions performed

Comments resolved.

@Brendonovich Brendonovich merged commit fb8a6c1 into main Oct 7, 2025
14 of 15 checks passed
@oscartbeaumont oscartbeaumont deleted the branded-ids branch October 8, 2025 03:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants