Skip to content

Conversation

@Brendonovich
Copy link
Contributor

@Brendonovich Brendonovich commented Aug 10, 2025

  • GET /api/playlist and DELETE /api/video/delete are now implemented with Effect
  • deleteVideo and duplicateVideo server actions have been replaced by VideoDelete and VideoDuplicate RPCs respectively
  • Folder duplicate functionality has been removed temporarily pending a review of what it should actually do
    • Should duplicating a folder in a space containing other peoples' videos duplicate those videos for the owner or the duplicating user?
  • @cap/web-domain has been created to house full-stack types and utilities for @cap/web (README)
  • @cap/web-backend has been created to provide Repo, Policy, Domain, RPC and other services for @cap/web (README)

Summary by CodeRabbit

  • New Features

    • New RPC/HTTP endpoints and an Effect-based runtime enabling server-backed video & folder operations (delete, duplicate) with UI-triggered refreshes.
  • Improvements

    • Faster, concurrent client mutations for bulk actions; stronger ID typing and improved media/CloudFront handling and tracing.
    • Login SSO lookup and space update now enforce proper checks.
  • Bug Fixes

    • Corrected email video name, reduced noisy error logs to warnings, transcription made non-blocking.
  • Chores

    • Dependency, TypeScript and README updates; removed legacy client-side folder/video action handlers.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Aug 10, 2025

Warning

Rate limit exceeded

@Brendonovich has exceeded the limit for the number of commits or files that can be reviewed per hour. Please wait 0 minutes and 7 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📥 Commits

Reviewing files that changed from the base of the PR and between 58224da and 501c937.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (2)
  • .github/workflows/ci.yml (2 hunks)
  • apps/desktop/package.json (1 hunks)

Walkthrough

Introduces a new domain package and backend package with Effect-based services, RPCs, policies, and S3 abstractions; migrates UI flows to an Effect runtime and RPC client; rewrites several API routes to Effect-driven handlers; strengthens branded ID types and TypeScript project references; removes legacy server actions and adds tracing/config updates.

Changes

Cohort / File(s) Change Summary
New domain package
packages/web-domain/*
Added @cap/web-domain: branded IDs, domain schemas (Video, Folder, S3Bucket, Organisation), auth context/middleware, Policy system, RPC contracts, and InternalError/Unauthenticated errors.
New backend package
packages/web-backend/*
Added @cap/web-backend: Effect services and layers (Videos, Folders, S3Buckets), repos, policies, RPC implementations, S3 access wrapper, Database abstraction, auth middleware, and package exports.
Effect runtime & React integration
apps/web/lib/EffectRuntime.ts, apps/web/lib/effect-react-query.ts, apps/web/lib/Rpcs.ts
Added Effect runtime with tracing, effect-react-query adapters, and an RPC client layer plus helper withRpc and React hooks for queries/mutations.
Tracing & server wiring
apps/web/lib/tracing.ts, apps/web/lib/server.ts
Added tracing config (Axiom/OTLP) and centralized server dependency/composition (apiToHandler, Dependencies layer).
API routes → Effect APIs / ERPC
apps/web/app/api/erpc/route.ts, apps/web/app/api/playlist/route.ts, apps/web/app/api/video/delete/route.ts
Replaced imperative handlers with Effect-based HTTP APIs and an ERPC handler; added typed schemas, DI, error mapping, and exported Effect handlers (GET/POST/DELETE/HEAD).
Folder library & removed server actions
apps/web/lib/folder.ts, deleted apps/web/actions/folders/*, deleted apps/web/actions/videos/*
Introduced getFolderById, getFolderBreadcrumb, getChildFolders in a lib; removed legacy server action files (duplicate/delete/get* folder/video actions) in favor of backend services/RPCs.
UI: effect-based mutations & RPC usage
apps/web/app/(org)/dashboard/**, apps/web/app/(org)/dashboard/spaces/**, apps/web/app/(org)/dashboard/folder/**
Replaced local server actions with Effect mutations and RPC calls (delete/duplicate flows), updated components to use EffectRuntime hooks and strong types (Video.VideoId, Folder.FolderId), and adjusted UI state to rely on mutation pending states.
Component type updates & small UI changes
CapCard.tsx, Folder.tsxFolderCard.tsx, FoldersDropdown.tsx, SelectedCapsBar.tsx, SharedCapCard.tsx, SharedCaps.tsx, Caps.tsx, FolderVideosSection.tsx, various pages
Strengthened ID typing to domain-branded types, refactored delete/duplicate handlers to RPC/effect model, removed duplicate folder option, minor formatting and router refresh additions.
Playlist & upload fixes
apps/web/app/api/playlist/route.ts, apps/web/app/api/upload/[...route]/multipart.ts
Rewrote playlist endpoint in Effect with signed URL logic; fixed incorrect console variable in upload complete endpoint.
Transcription call path & asyncification
apps/web/actions/videos/get-status.ts, apps/web/app/embed/[videoId]/page.tsx, apps/web/app/s/[videoId]/page.tsx, apps/web/lib/transcribe.ts
Repointed transcribe imports to lib, made embedding transcription fire-and-forget (removed await), and removed unused S3 presign imports.
S3 & bucket infra
packages/web-backend/src/S3Buckets/*, packages/web-backend/src/S3Buckets/S3BucketAccess.ts, S3BucketClientProvider.ts, S3BucketsRepo.ts
Added S3BucketAccess service with signed URLs, copy/list/delete, multipart support; provider for internal/public clients; repo to look up bucket records.
Videos & Folders service logic
packages/web-backend/src/Videos/*, packages/web-backend/src/Folders/*
Added Videos service/repo (getById, delete, duplicate with S3 ops and policy checks) and Folders service/policy with recursive delete and video reparenting; added corresponding RPC layers.
Server RPC aggregation & auth
packages/web-backend/src/Rpcs.ts, packages/web-backend/src/Auth.ts, packages/web-backend/src/Database.ts
Aggregated RPC layers, provided RPC auth middleware and getCurrentUser effect, and introduced Database context/error abstraction.
Database schema & typing improvements
packages/database/schema.ts, packages/database/package.json
Typed folder id columns as Folder.FolderId, refined transcriptionStatus union, reordered/grouped video columns; added @cap/web-domain dependency.
Tooling & TS project configs
apps/web/package.json, apps/desktop/package.json, package.json, apps/web/tsconfig.json, tsconfig.json, packages/*/tsconfig.json
Added Effect/OpenTelemetry deps and workspace packages (@cap/web-domain, @cap/web-backend, @cap/web-api-contract-effect), added @effect/language-service, enabled composite/project references and emit-only-declaration configs.
Misc docs & small fixes
apps/web/README.md, packages/web-backend/README.md, packages/web-domain/README.md, apps/web/app/Layout/providers.tsx, apps/web/utils/auth.ts, apps/web/utils/video/ffmpeg/helpers.ts, packages/database/emails/config.ts
README additions/condensing, log-level change, auth type narrowing, helper signature change, removed unused email render type; minor formatting and variable fixes.

Sequence Diagram(s)

sequenceDiagram
    participant UI
    participant EffectRuntime
    participant RpcClient
    participant ERPC
    participant Backend
    participant DB
    participant S3

    UI->>EffectRuntime: invoke mutation (e.g. delete/duplicate video)
    EffectRuntime->>RpcClient: withRpc(VideoDelete/VideoDuplicate)
    RpcClient->>ERPC: POST /api/erpc (RPC call)
    ERPC->>Backend: dispatch to Videos/Folders service with auth
    Backend->>DB: read/update/delete video or folder records
    Backend->>S3: list/copy/delete objects as needed
    S3-->>Backend: return result
    DB-->>Backend: return result
    Backend-->>ERPC: success or mapped error
    ERPC-->>RpcClient: response payload
    RpcClient-->>EffectRuntime: Exit result
    EffectRuntime-->>UI: update state, show toasts, refresh
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • fix: double loading effect #840: Modifies apps/web/app/(org)/dashboard/caps/Caps.tsx—related to the same component-level deletion/analytics changes and Effect migration.

Poem

I hopped through code with whiskered care,
Branded IDs and effects in the air.
RPCs stitched and buckets spun,
Folders pruned and duplicates done.
A rabbit cheers — backend blooms everywhere 🐇✨

✨ Finishing Touches
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch effectify

🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

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: 61

🔭 Outside diff range comments (5)
apps/web/actions/organization/update-space.ts (1)

32-44: Replacing all members drops roles, allows self-removal, and can create duplicates

  • All existing roles (e.g., owner/admin) are lost because new rows default to role "member".

  • The actor can remove themselves and/or leave the space without an owner.

  • Duplicates are possible because the composite index on (spaceId, userId) is not unique.

  • No transaction around updates => inconsistent state if any step fails.

  • Wrap the updates in a DB transaction.

  • Preserve existing roles for users that remain, default new users to "member".

  • Enforce at least one "owner" remains and prevent self-removal if that would leave zero owners.

  • De-duplicate the member list before insert; consider adding a unique constraint on (spaceId, userId) at the schema level.

Illustrative refactor (adjust to your Drizzle API):

-  // Update members (simple replace for now)
-  await db().delete(spaceMembers).where(eq(spaceMembers.spaceId, id));
-  if (members.length > 0) {
-    await db()
-      .insert(spaceMembers)
-      .values(
-        members.map((userId) => ({
-          id: uuidv4().substring(0, nanoIdLength),
-          spaceId: id,
-          userId,
-        }))
-      );
-  }
+  // Update members with role preservation, dedupe, and safety checks
+  await db().transaction(async (tx) => {
+    const uniqueMembers = Array.from(new Set(members));
+
+    // Preserve existing roles
+    const existing = await tx
+      .select({
+        userId: spaceMembers.userId,
+        role: spaceMembers.role,
+      })
+      .from(spaceMembers)
+      .where(eq(spaceMembers.spaceId, id));
+
+    const owners = existing.filter((m) => m.role === "owner").map((m) => m.userId);
+    const actorId = user.id;
+
+    // Prevent removing all owners
+    const remainingOwners = owners.filter((u) => uniqueMembers.includes(u));
+    if (remainingOwners.length === 0 && (!uniqueMembers.includes(actorId) || (existing.find(e => e.userId === actorId)?.role !== "owner"))) {
+      throw new Error("Operation would leave the space without an owner");
+    }
+
+    // Optionally: ensure actor stays a member unless they explicitly remain (policy decision)
+    if (!uniqueMembers.includes(actorId)) {
+      uniqueMembers.push(actorId);
+    }
+
+    // Replace membership, preserving roles when possible
+    await tx.delete(spaceMembers).where(eq(spaceMembers.spaceId, id));
+    if (uniqueMembers.length > 0) {
+      await tx.insert(spaceMembers).values(
+        uniqueMembers.map((userId) => ({
+          id: uuidv4().substring(0, nanoIdLength),
+          spaceId: id,
+          userId,
+          role: existing.find((m) => m.userId === userId)?.role ?? "member",
+        }))
+      );
+    }
+  });

Schema follow-up (recommended): make (spaceId, userId) a unique index to prevent duplicates at the DB level.

apps/web/utils/video/ffmpeg/helpers.ts (1)

37-49: HLS correctness: only include AUDIO attribute when audioPlaylistUrl exists

Currently, AUDIO="audio" is appended even when audioPlaylistUrl is null, which yields an invalid master playlist (references an undefined group). Make AUDIO conditional.

-  const streamInfo = xStreamInfo
-    ? xStreamInfo + ',AUDIO="audio"'
-    : `BANDWIDTH=${bandwidth},RESOLUTION=${resolution},AUDIO="audio"`;
+  const hasAudio = Boolean(audioPlaylistUrl);
+  const baseInfo = xStreamInfo
+    ? xStreamInfo
+    : `BANDWIDTH=${bandwidth},RESOLUTION=${resolution}`;
+  const streamInfo = hasAudio ? `${baseInfo},AUDIO="audio"` : baseInfo;

Optionally, consider adding CODECS if known to improve player compatibility.

apps/web/package.json (2)

130-134: Fix ESLint–eslint-config-next version mismatch

Your apps/web/package.json specifies ESLint 9.x, but eslint-config-next@14.1.0 only supports ESLint ^7.23.0 || ^8.0.0. This will trigger peer-dependency warnings or broken linting.

• File: apps/web/package.json
• Lines: 130–134

Recommended fixes (pick one):

  • Downgrade ESLint to a supported version, e.g.
    "eslint": "^8.44.0"
  • Upgrade eslint-config-next once a version supporting ESLint 9 is released.

93-101: React 19 is incompatible with Next 14.2.3’s peer dependencies

Next 14.2.3 lists React & ReactDOM ^18.2.0 as peers, but your apps/web/package.json is on React ^19.1.0. This will cause build/runtime failures.

Please update one of the following:

  • Downgrade React/ReactDOM to ^18.2.0 in apps/web/package.json (lines 93–101), or
  • Upgrade Next.js to a version that officially supports React 19 (e.g., Next 15+ once it reaches stable).
apps/web/app/(org)/dashboard/caps/page.tsx (1)

167-179: Align aggregate JSON type with actual nullability

JSON_ARRAYAGG(JSON_OBJECT(...)) will include entries with null fields when no organization is joined. The declared TypeScript type currently uses id: string, making organization.id !== null a no-op. Reflect nullability in the type.

-      sharedOrganizations: sql<{ id: string; name: string; iconUrl: string }[]>`
+      sharedOrganizations: sql<{ id: string | null; name: string | null; iconUrl: string | null }[]>`

With this, the filter becomes type-correct:

-        ? video.sharedOrganizations.filter(
-            (organization) => organization.id !== null
-          )
+        ? video.sharedOrganizations.filter((organization) => organization?.id !== null)

Optional: you can also eliminate the client-side filter by changing the aggregation to only emit JSON objects when organizations.id IS NOT NULL (using a CASE WHEN inside the aggregation).

Also applies to: 240-244

🧹 Nitpick comments (67)
apps/web/app/Layout/providers.tsx (1)

38-41: Reduce client console noise in prod

Warn is appropriate; consider gating it to dev to avoid noisy client logs in production.

Apply:

-      console.warn(
-        "Missing PostHog environment variables. Events will not be tracked."
-      );
+      if (process.env.NODE_ENV !== "production") {
+        console.warn(
+          "Missing PostHog environment variables. Events will not be tracked."
+        );
+      }
packages/web-api-contract-effect/package.json (1)

8-10: Effect dependency versions are aligned across the workspace

We ran a quick audit and confirmed that:

  • @cap/desktop only depends on effect@^3.17.7 (no @effect/platform)
  • All other packages (@cap/effect, @cap/web, @cap/web-api-contract-effect, @cap/web-backend, @cap/web-domain) depend on both effect@^3.17.7 and @effect/platform@^0.90.1

No mismatched versions were found.

To enforce a single Effect runtime and avoid duplicate copies in downstream consumers, you may want to move both deps into peerDependencies (and keep them in devDependencies for local builds), for example:

   "name": "@cap/web-api-contract-effect",
   "private": true,
   "main": "./src/index.ts",
   "types": "./src/index.ts",
   "type": "module",
-  "dependencies": {
-    "@effect/platform": "^0.90.1",
-    "effect": "^3.17.7"
-  }
+  // If this package is consumed by others, consider extracting Effect into peers:
+  "peerDependencies": {
+    "@effect/platform": "^0.90.1",
+    "effect": "^3.17.7"
+  },
+  "devDependencies": {
+    "@effect/platform": "^0.90.1",
+    "effect": "^3.17.7"
+  }

If these are purely internal/private packages and you’re okay bundling Effect directly, the current setup is consistent and you can leave it as-is.

apps/web/app/(org)/dashboard/folder/[id]/page.tsx (1)

7-9: Consider migrating data fetching to the new Effect RPC layer

This page still uses legacy server actions (getChildFolders, getFolderBreadcrumb, getVideosByFolderId). To align with the PR’s Effect/RPC direction, consider swapping these for RPC calls (e.g., via a server-side Effect runtime or a thin server function invoking domain Rpcs).

Happy to sketch the migration using your Rpcs client/server utilities.

packages/web-domain/src/Rpcs.ts (1)

4-4: Export a stable type alias for consumers

Expose the merged RPC type to downstream code for stronger typing.

Apply:

 export const Rpcs = VideoRpcs.merge(FolderRpcs);
+export type RpcsType = typeof Rpcs;
packages/web-domain/src/Errors.ts (1)

3-6: Rename type to a less ambiguous field, and consider future-proofing

type can be ambiguous (and is a TS keyword). Consider source or system to clarify intent and avoid conflicts. You may also want a code for finer-grained classification without leaking internals.

Example:

-export class InternalError extends Schema.TaggedError<InternalError>()(
-  "InternalError",
-  { type: Schema.Literal("database", "s3") }
-) {}
+export class InternalError extends Schema.TaggedError<InternalError>()(
+  "InternalError",
+  {
+    source: Schema.Literal("database", "s3"),
+    // code: Schema.String, // e.g. "DB_QUERY_FAILED" (optional)
+  }
+) {}
packages/web-domain/README.md (1)

6-10: Mention the combined RPC surface and add quick import hints

Since the package now aggregates RPCs, consider noting the combined Rpcs export and where to import types from for consumers.

Example additions:

  • Combined RPCs (Rpcs)
  • Import example:
    • import { Rpcs } from "@cap/web-domain/Rpcs" or via @cap/web-domain if re-exported from index.ts
    • type RpcsType = typeof Rpcs
packages/web-domain/src/Organisation.ts (1)

3-6: Add minimal invariants for name; keep ID branded

Schema is solid. Tighten the model by ensuring name is non-empty.

 export class Organisation extends Schema.Class<Organisation>("Organisation")({
   id: Schema.String.pipe(Schema.brand("OrganisationId")),
-  name: Schema.String,
+  name: Schema.NonEmptyString,
 }) {}

Optionally export the branded ID type for reuse:

export type OrganisationId = Schema.Schema.Type<typeof Organisation>["id"];
apps/web/README.md (1)

1-4: Add quickstart and links to the new Effect/RPC architecture

Nice concise intro. A short Quickstart and pointers to the new Effect runtime/RPC docs would help contributors ramp faster.

Example addition:

 Cap's NextJS web app for video sharing.
 Used for both self hosting and on [cap.so](https://cap.so).
+
+## Development
+- pnpm i
+- pnpm -w build
+- pnpm dev --filter @cap/web
+
+## Architecture
+- Effect runtime and RPC: see apps/web/lib/EffectRuntime.ts and apps/web/lib/Rpcs.ts
+- Shared domain: packages/web-domain
packages/web-backend/src/S3Buckets/S3BucketClientProvider.ts (2)

4-9: Clarify client lifecycle; prefer stable instances or document caching

Using getters is fine if they return cached, long-lived S3Client instances. Re-creating per call can waste sockets/CPU. Either store clients or guarantee memoization.

Option A (store instances):

 export class S3BucketClientProvider extends Context.Tag(
   "S3BucketClientProvider"
 )<
   S3BucketClientProvider,
-  { getInternal: () => S3Client; getPublic: () => S3Client; bucket: string }
+  { internal: S3Client; public: S3Client; bucket: string }
 >() {}

Option B (keep getters but memoize in the live layer). Also consider branding bucket (e.g., S3BucketName) from your domain package for stronger typing.


4-6: Use a more specific tag key to avoid collisions

Namespace the tag identifier to reduce risk of global collisions.

-export class S3BucketClientProvider extends Context.Tag(
-  "S3BucketClientProvider"
+export class S3BucketClientProvider extends Context.Tag(
+  "cap/web-backend/S3BucketClientProvider"
 )<
packages/effect/src/Organisation.ts (1)

3-6: Promote a reusable branded OrganisationId and reuse in class

Defining/exporting the branded ID improves reuse and consistency with other domain models (e.g., VideoId).

+export const OrganisationId = Schema.String.pipe(Schema.brand("OrganisationId"));
+export type OrganisationId = typeof OrganisationId.Type;
+
 export class Organisation extends Schema.Class<Organisation>("Organisation")({
-  id: Schema.String.pipe(Schema.brand("OrganisationId")),
+  id: OrganisationId,
   name: Schema.String,
 }) {}
packages/web-backend/src/Users/index.ts (1)

5-11: Define a service surface early

An empty service provides no value to dependents. Consider adding at least method stubs (e.g., getById, findByEmail) returning Effect to lock the interface.

apps/web/utils/auth.ts (1)

20-20: Unreachable return type variant

The function never returns "not-org-email". Either remove it from the return union or add logic to return it where appropriate.

apps/web/actions/organization/update-space.ts (1)

22-25: Use existence/limit to avoid scanning and reduce payload

The membership check is correct, but it selects all columns without a limit. Add a limiter (or switch to an existence check) for efficiency.

Apply this minimal diff:

-  const [membership] = await db()
-    .select()
-    .from(spaceMembers)
-    .where(and(eq(spaceMembers.spaceId, id), eq(spaceMembers.userId, user.id)));
+  const [membership] = await db()
+    .select()
+    .from(spaceMembers)
+    .where(and(eq(spaceMembers.spaceId, id), eq(spaceMembers.userId, user.id)))
+    .limit(1);

Alternatively (if available in your Drizzle version), use an existence check to avoid materializing a row.

packages/web-backend/src/Users/UsersRepo.ts (1)

12-16: Return a single row and bound the query

The repo currently returns an array of rows. Repos typically expose “getById” returning one row or undefined. Also, add a limit for efficiency.

Apply this diff:

-      getById: (id: string) =>
-        db.execute((db) =>
-          db.select().from(Db.users).where(Dz.eq(Db.users.id, id))
-        ),
+      getById: (id: string) =>
+        db
+          .execute((db) =>
+            db
+              .select()
+              .from(Db.users)
+              .where(Dz.eq(Db.users.id, id))
+              .limit(1)
+          )
+          .pipe(Effect.map((rows) => rows[0])),

Optional:

  • Narrow selected columns if you don’t need all fields.
  • Type the return as Effect.Effect<InferModel | undefined, DatabaseError> for clarity.
packages/web-domain/tsconfig.json (1)

5-12: Emit declarations explicitly and include maps for DX

You’re already doing emitDeclarationOnly. Add declaration and declarationMap explicitly to avoid ambiguity across TS versions and improve editor jump-to-def.

   "compilerOptions": {
     "baseUrl": ".",
     "moduleResolution": "bundler",
     "composite": true,
     "outDir": "dist",
-    "noEmit": false,
-    "emitDeclarationOnly": true
+    "noEmit": false,
+    "declaration": true,
+    "emitDeclarationOnly": true,
+    "declarationMap": true
   }
packages/web-backend/tsconfig.json (1)

5-13: Small TS ergonomics: declaration flags and baseUrl

For consistency with sibling packages and better DX, explicitly emit declarations/maps and consider baseUrl for relative imports (unless inherited from base config).

   "compilerOptions": {
-    "moduleResolution": "bundler",
+    "moduleResolution": "bundler",
     "composite": true,
     "outDir": "dist",
     "noEmit": false,
-    "emitDeclarationOnly": true,
+    "declaration": true,
+    "emitDeclarationOnly": true,
+    "declarationMap": true,
     "strict": true,
     "noUncheckedIndexedAccess": true
   },

If not already set in the base config, optionally add:

{
  "compilerOptions": {
    "baseUrl": "."
  }
}
packages/database/auth/auth-options.tsx (2)

11-11: Alias import is fine; add type import for getServerSession result

The alias avoids name shadowing. Consider importing Session type to annotate the wrapper return for clarity.

-import { getServerSession as _getServerSession } from "next-auth";
+import { getServerSession as _getServerSession } from "next-auth";
+import type { Session } from "next-auth";

150-152: Reduce noisy logs in production

The multi-line log is fine for debugging but consider gating logs via env (NEXT_PUBLIC_DEBUG or NextAuth debug) to avoid noisy prod logs.

apps/web/app/(org)/login/form.tsx (1)

359-360: Nit: Potential low contrast for “Signing in to” text

"text-gray-1" on a gray background may be hard to read. Consider a higher-contrast token (e.g., text-gray-9/10) for accessibility.

apps/web/actions/organization/get-organization-sso-data.ts (1)

7-34: Solid server action: validation, minimal return shape, and unified error

  • Input validated and errors unified to avoid info leakage.
  • Only necessary fields selected.
  • Return shape matches client usage.

Optional: add .limit(1) to the query for clarity, though destructuring the first row is already fine.

   const [organization] = await db()
     .select({
       workosOrganizationId: organizations.workosOrganizationId,
       workosConnectionId: organizations.workosConnectionId,
       name: organizations.name,
     })
     .from(organizations)
-    .where(eq(organizations.id, organizationId));
+    .where(eq(organizations.id, organizationId))
+    .limit(1);
apps/web/tsconfig.json (1)

23-30: Path alias for “@/services” likely needs wildcard mapping

If you plan to import subpaths (e.g., "@/services/folders"), add a wildcard entry to avoid unresolved module errors.

Apply this diff inside paths:

       "@/actions/*": ["actions/*"],
-      "@/services": ["services"]
+      "@/services": ["services"],
+      "@/services/*": ["services/*"]
packages/web-backend/src/Database.ts (1)

13-15: Preserve root cause on DatabaseError for observability

Including a cause makes debugging far easier and plays well with Effect’s error cause chains.

-export class DatabaseError extends Data.TaggedError("DatabaseError")<{
-  message: string;
-}> {}
+export class DatabaseError extends Data.TaggedError("DatabaseError")<{
+  message: string;
+  cause?: unknown;
+}> {}

When constructing, pass the original error as cause.

packages/web-backend/src/Folders/FoldersPolicy.ts (3)

20-21: Clarify comment wording

Minor wording nit: this policy is about folders, not “space properties”.

-            // All space members can edit space properties
+            // All space members can edit folders within the same space

23-33: Prefer existence check and limit result set

Select only the minimally needed column (or use .limit(1)), and avoid reusing db in the inner callback for readability.

-              const [spaceMember] = yield* db.execute((db) =>
-                db
-                  .select()
-                  .from(Db.spaceMembers)
+              const [spaceMember] = yield* db.execute((conn) =>
+                conn
+                  .select({ id: Db.spaceMembers.userId })
+                  .from(Db.spaceMembers)
                   .where(
                     Dz.and(
                       Dz.eq(Db.spaceMembers.userId, user.id),
                       Dz.eq(Db.spaceMembers.spaceId, spaceId)
                     )
                   )
+                  .limit(1)
               );

13-41: Confirm desired failure semantics for policies

Right now, any database failure during the policy check will fail the effect (likely with DatabaseError) rather than deny by default. Ensure this is intended and consistent with other policies (e.g., VideosPolicy). If you prefer “fail closed,” catch DB errors and return false.

If you want to fail closed, I can provide a patch that wraps DB calls with Effect.catchTag("DatabaseError", () => Effect.succeed(false)).

packages/web-backend/README.md (3)

7-8: Fix typo: “stc” → “etc.”

Minor typo in the domains list.

-Code is organized horizontally by domain/entity (Video, Folder, stc),
+Code is organized horizontally by domain/entity (Video, Folder, etc.),

14-14: Fix heading level increments (MD001)

Headings jump from h2 to h4. Use h3 for the sub-sections to comply with markdownlint (MD001).

-#### Repository Services
+### Repository Services
-#### Policy Services
+### Policy Services
-#### Domain Services
+### Domain Services

Also applies to: 20-20, 25-25


35-35: Tighten wording (“a lot of” → “often”)

Concise wording per style guidance.

-In a lot of cases, endpoint implementations are thin wrappers around Domain Services.
+Often, endpoint implementations are thin wrappers around Domain Services.
apps/web/app/(org)/dashboard/caps/components/FoldersDropdown.tsx (4)

10-14: Remove unused icon import

faCopy is only referenced in commented code. Drop the import to avoid unused import warnings.

-import {
-  faCopy,
-  faEllipsis,
-  faPencil,
-  faTrash,
-} from "@fortawesome/free-solid-svg-icons";
+import { faEllipsis, faPencil, faTrash } from "@fortawesome/free-solid-svg-icons";

36-36: Remove unused value from context

activeSpace is not used (only in commented code). Remove to avoid an unused variable.

-  const { activeSpace } = useDashboardContext();
+  // const { activeSpace } = useDashboardContext();

Additionally, consider removing the useDashboardContext import while the duplicate option remains disabled.


67-73: Guard rename UX: ensure caret selection happens after focus

Sequence is fine; consider using queueMicrotask instead of setTimeout(…, 0) for more explicit microtask scheduling.

-                  setTimeout(() => {
+                  queueMicrotask(() => {
                     nameRef.current?.focus();
                     nameRef.current?.select();
-                  }, 0);
+                  });

91-92: Ensure menu closes on action

If DropdownMenuItem doesn’t auto-close on click in your UI lib, you might need to call a close API or wrap actions to ensure the menu closes after "Delete".

If it doesn’t auto-close, I can propose the minimal wrapper pattern used elsewhere in the project.

apps/web/app/(org)/dashboard/caps/page.tsx (3)

65-75: Normalize shared spaces/orgs “iconUrl” to a non-empty string in one place

You already coalesce iconUrl to "" during post-processing. Consider doing it directly in the select to centralize normalization.

Example:

iconUrl: sql<string>`COALESCE(${organizations.iconUrl}, '')`

Also applies to: 91-102


231-233: Avoid double-fetching shared spaces for empty lists

You already guard inside getSharedSpacesForVideos, but you can skip the call entirely when there are zero videos for clarity.

This is already safe; optional for readability:

const sharedSpacesMap = videoIds.length ? await getSharedSpacesForVideos(videoIds) : {};

249-254: Avoid any in metadata type

Prefer a stronger type if available (e.g., from @cap/web-domain). If not, narrow the index signature.

-      metadata: video.metadata as
-        | {
-            customCreatedAt?: string;
-            [key: string]: any;
-          }
-        | undefined,
+      metadata: video.metadata as
+        | {
+            customCreatedAt?: string;
+            [key: string]: unknown;
+          }
+        | undefined,
packages/web-domain/src/S3Bucket.ts (1)

6-16: Consistency with Video: prefer a static decodeSync on the class and drop the free function.

Aligns with Video.Video.decodeSync usage across the codebase.

 export class S3Bucket extends Schema.Class<S3Bucket>("S3Bucket")({
   id: Schema.String,
   ownerId: Schema.String,
   region: Schema.String,
   endpoint: Schema.OptionFromNullOr(Schema.String),
   name: Schema.String,
   accessKeyId: Schema.String,
   secretAccessKey: Schema.String,
-}) {}
+}) {
+  static decodeSync = Schema.decodeSync(S3Bucket);
+}
 
-export const decodeSync = Schema.decodeSync(S3Bucket);
packages/web-backend/src/Videos/VideosPolicy.ts (1)

12-33: Authorize public videos without password and add tracing name.

Short-circuit for public videos and name the effect for better tracing/observability.

-      const canView = (videoId: Video.VideoId) =>
-        Policy.publicPolicy(
-          Effect.fn(function* (user) {
+      const canView = (videoId: Video.VideoId) =>
+        Policy.publicPolicy(
+          Effect.fn("VideosPolicy.canView", function* (user) {
             const res = yield* repo.getById(videoId);
 
             if (Option.isNone(res)) return true;
 
             const [video, password] = res.value;
 
+            // Public videos do not require password or ownership checks
+            if (video.public) return true;
+
             if (
               user.pipe(
                 Option.filter((user) => user.id === video.ownerId),
                 Option.isSome
               )
             )
               return true;
 
             yield* Video.verifyPassword(video, password);
 
             return true;
           })
         );

Confirm that Video.verifyPassword handles the case where password is Option.none (i.e., password-protected video with no supplied password) by denying appropriately.

apps/web/app/(org)/dashboard/spaces/[spaceId]/SharedCaps.tsx (2)

104-109: Encode query param and drop unnecessary GET headers.

Use encodeURIComponent for safety and omit redundant headers for a GET.

-          const response = await fetch(`/api/analytics?videoId=${video.id}`, {
-            method: "GET",
-            headers: {
-              "Content-Type": "application/json",
-            },
-          });
+          const response = await fetch(
+            `/api/analytics?videoId=${encodeURIComponent(video.id)}`
+          );

129-132: Simplify fulfilled check.

result.value is always present when status === "fulfilled".

-      results.forEach((result) => {
-        if (result.status === "fulfilled" && result.value) {
+      results.forEach((result) => {
+        if (result.status === "fulfilled") {
           analyticsData[result.value.videoId] = result.value.count;
         }
       });
packages/web-backend/src/S3Buckets/S3BucketsRepo.ts (4)

19-23: Prefer starting from videos and inner joining s3Buckets; add limit(1).

Improves plan selection and avoids scanning s3Buckets first; limit(1) expresses intent.

-              db
-                .select({ bucket: Db.s3Buckets })
-                .from(Db.s3Buckets)
-                .leftJoin(Db.videos, Dz.eq(Db.videos.bucket, Db.s3Buckets.id))
-                .where(Dz.and(Dz.eq(Db.videos.id, videoId)))
+              db
+                .select({ bucket: Db.s3Buckets })
+                .from(Db.videos)
+                .innerJoin(Db.s3Buckets, Dz.eq(Db.videos.bucket, Db.s3Buckets.id))
+                .where(Dz.eq(Db.videos.id, videoId))
+                .limit(1)

22-23: Nit: Drop redundant Dz.and wrapper.

If you keep the current shape, the Dz.and around a single predicate is unnecessary.

-                .where(Dz.and(Dz.eq(Db.videos.id, videoId)))
+                .where(Dz.eq(Db.videos.id, videoId))

36-41: Add limit(1) to getById.

Expresses intent and can help query planners.

               db
                 .select({ bucket: Db.s3Buckets })
                 .from(Db.s3Buckets)
-                .where(Dz.eq(Db.s3Buckets.id, id))
+                .where(Dz.eq(Db.s3Buckets.id, id))
+                .limit(1)

52-53: Declare Database as a dependency of the service.

Improves layer wiring clarity and mirrors other services (e.g., VideosPolicy).

-    }),
-  }
+    }),
+    dependencies: [Database.Default],
+  }

Please confirm Database.Default exists (it likely does if created with Effect.Service).

packages/web-api-contract-effect/src/index.ts (1)

1-8: Remove unused import

HttpServerError isn’t used.

 import {
   HttpApi,
   HttpApiEndpoint,
   HttpApiError,
   HttpApiGroup,
   HttpApiMiddleware,
-  HttpServerError,
 } from "@effect/platform";
apps/web/lib/server.ts (1)

25-28: Consider making tracing configuration more flexible

The OpenTelemetry configuration is hardcoded. Consider making it configurable via environment variables for different environments.

Consider extracting configuration:

const TracingLayer = NodeSdk.layer(() => ({
  resource: { serviceName: process.env.OTEL_SERVICE_NAME || "cap-web" },
  spanProcessor: process.env.OTEL_ENABLED === 'true' 
    ? [new BatchSpanProcessor(new OTLPTraceExporter())]
    : [],
}));
apps/web/lib/EffectRuntime.ts (2)

17-20: Avoid type/value shadowing: rename the exported type alias

export type RuntimeLayer = typeof RuntimeLayer; reuses the same identifier for a value and a type, which is legal but confusing. Rename the type for clarity.

-const RuntimeLayer = Layer.mergeAll(Rpc.Default, TracingLayer);
-
-export type RuntimeLayer = typeof RuntimeLayer;
+const RuntimeLayer = Layer.mergeAll(Rpc.Default, TracingLayer);
+
+export type RuntimeServices = typeof RuntimeLayer;

26-26: Naming: consider avoiding the use* prefix for non-hooks

useRpcClient isn’t a React Hook (no React state/effects). Some linters and readers assume use* follows hook rules. Consider getRpcClient for clarity.

-export const useRpcClient = () => EffectRuntime.runSync(Rpc);
+export const getRpcClient = () => EffectRuntime.runSync(Rpc);
packages/web-backend/src/Folders/index.ts (1)

31-33: Optional: avoid deep synchronous recursion

Very deep folder trees could risk stack depth. Iterative DFS/BFS can mitigate this.

If needed, switch to an explicit stack:

const stack = [folder];
while (stack.length) {
  const current = stack.pop()!;
  const children = yield* db.execute(/* ... */);
  stack.push(...children);
  // update videos/spaceVideos for current, then delete current
}
apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx (2)

62-63: Tighten onDelete type

Returning Promise<any> weakens type safety. Prefer Promise<void> (or Promise<unknown> if necessary).

-  onDelete?: () => Promise<any>;
+  onDelete?: () => Promise<void>;

343-348: Prefer useEffectMutation for RPC actions to unify tracing/cancellation/error semantics

You’ve already exposed useEffectMutation from the runtime. Using it here yields consistent behavior with other components and integrates lifecycle callbacks.

Example:

const duplicateCap = useEffectMutation({
  mutationKey: ["video", "duplicate", cap.id],
  mutationFn: () => withRpc((r) => r.VideoDuplicate(cap.id)),
  onSuccess: () => {
    toast.success("Cap duplicated successfully");
    router.refresh();
  },
  onError: () => {
    toast.error("Failed to duplicate cap");
  },
});

// ...
onClick={async () => duplicateCap.mutate()}
apps/web/actions/videos/get-status.ts (1)

50-56: Explicitly mark fire-and-forget to satisfy linters and intent

Clarify intent and silence no-floating-promises warnings.

-      transcribeVideo(videoId, video.ownerId).catch((error) => {
+      void transcribeVideo(videoId, video.ownerId).catch((error) => {
         console.error(
           `[Get Status] Error starting transcription for video ${videoId}:`,
           error
         );
       });
packages/web-backend/src/S3Buckets/index.ts (1)

15-45: Config: simplify optional endpoints handling

The current option -> flatten -> catchTag chain is valid but hard to read. Consider Config.withDefault or Effect.orElse to improve clarity.

Example:

const publicEndpoint = yield* Config.string("S3_PUBLIC_ENDPOINT")
  .pipe(Config.withDefault(yield* Config.string("CAP_AWS_ENDPOINT")));

If neither is provided, explicitly fail with a clear ConfigError via Effect.fail.

packages/web-backend/src/Videos/index.ts (1)

70-107: Consider adjustable concurrency for S3 object copying.

The duplicate method is well-implemented, correctly excluding sensitive data (password, sharing) from duplication. However, the sequential copying of S3 objects (concurrency: 1) might be slow when duplicating videos with many files.

Consider making the concurrency configurable or increasing it slightly for better performance:

-              { concurrency: 1 }
+              { concurrency: 3 }
packages/effect/src/index.ts (1)

269-271: Address TODO for custom bucket handling.

The TODO comment indicates incomplete implementation for custom bucket scenarios.

Would you like me to help implement the missing logic for handling different source types with custom buckets?

packages/web-domain/src/Policy.ts (1)

72-75: Clarify environment typing for withPublicPolicy

Type inference will widen env to R | R2, but the helper’s type annotation currently hides that, which can mislead users.

Consider annotating the return type for clarity:

  • From: <A, E2, R2>(self: Effect.Effect<A, E2, R2>) => Effect.Effect<A, PolicyDeniedError | E | E2, R2>
  • To: <A, E2, R2>(self: Effect.Effect<A, E2, R2>) => Effect.Effect<A, PolicyDeniedError | E | E2, R | R2>
packages/effect/src/domain.ts (1)

89-108: API param typing opportunity

You can reuse your branded ID type here to improve type-safety, similar to usage in the web route:

-            videoId: Schema.String,
+            videoId: Schema.String, // Consider VideoId branded schema if available here
packages/web-domain/src/Video.ts (1)

53-72: verifyPassword semantics: clarify source of the password argument

The function assumes password is the required video password (from DB). If that’s correct, logic is fine. If instead it’s a provided user password, the order of checks should compare against the video’s required password and treat None as “no password required.”

packages/effect/src/S3Provider.ts (2)

121-131: Nit: unnecessary await when passing getClient to Effect.runPromise

getClient(true) returns an Effect; await is unnecessary.

-    async listObjects(config) {
-      return Effect.runPromise(await getClient(true)).then((c) =>
+    async listObjects(config) {
+      return Effect.runPromise(getClient(true)).then((c) =>
         c.send(
           new ListObjectsV2Command({
             Bucket: bucket,
             Prefix: config?.prefix,
             MaxKeys: config?.maxKeys,
           })
         )
       );
     },

40-98: Consider returning Effect in the service interface for consistency

Most of your codebase uses Effect for async and error management. Returning Effect instead of raw Promises here would improve composability and typed error handling.

I can provide a refactor sketch that wraps these in Effect.tryPromise with a tagged S3Error if desired.

packages/web-backend/src/S3Buckets/S3BucketAccess.ts (2)

58-67: ListObjectsV2 pagination not handled

Returning only the first page can silently truncate results. Expose ContinuationToken or provide a helper to list all.

  • Add continuationToken?: string to the config and return IsTruncated/NextContinuationToken.
  • Or add listAllObjects that paginates to exhaustion using ListObjectsV2Command and ContinuationToken.

32-39: Make URL TTL configurable

Hardcoding 3600s can be restrictive for callers.

Add an optional expiresIn?: number:

-      getSignedObjectUrl: (key: string) =>
+      getSignedObjectUrl: (key: string, expiresIn = 3600) =>
         wrapS3Promise((provider) =>
           S3Presigner.getSignedUrl(
             provider.getPublic(),
             new S3.GetObjectCommand({ Bucket: provider.bucket, Key: key }),
-            { expiresIn: 3600 }
+            { expiresIn }
           )
         ),
apps/web/lib/effect-react-query.ts (5)

56-87: Always throwing full Cause from queryFn is correct; consider attaching AbortSignal to effect

You correctly throw the Cause so React Query receives a consistent error shape. Since you already pass signal to runPromiseExit, ensure your effect cooperates with aborts.

  • Document that query effects should honor Effect.interruptWhenSignal(args.signal) or equivalent if they perform long I/O.
  • Optionally wrap queryEffect with an operator that binds to args.signal.

88-121: throwOnError mapping handles UnknownException vs fail causes; minor type note

Logic is sound. One nit: the function type from React Query expects a boolean. Ensure any narrowings don't leak TExposedError mismatches if options change across renders.

  • Consider memoizing the derived throwOnError handler to avoid recreation on each render, as it closes over throwOnDefect.

123-169: Proxy remapping for error/failureReason is careful; add invariant-safe error type guards

Current invariant throws if a non-fail Cause is present with throwOnDefect: true (which should have thrown). Good. Consider guarding in dev only to avoid surprising runtime errors in production.

  • Gate the invariant under process.env.NODE_ENV !== "production".

441-449: mutateAsync wrapper returns Exit; catch type can be UnknownException too

You always throw the full Cause, so .catch receives a Cause. If an unexpected error slips through (e.g., user-land throws non-Cause), it won’t match the type.

Hedge defensively:

-              .catch((cause: Cause.Cause<TError>) =>
-                Exit.fail(cause)
-              ) as Promise<Exit.Exit<TData, TError>>;
+              .catch((err: unknown) =>
+                Exit.fail(err as Cause.Cause<TError>)
+              ) as Promise<Exit.Exit<TData, TError>>;

337-366: onError handler: invariant throw on non-fail Cause with throwOnDefect=true is good; consider dev-only

Same suggestion as queries to avoid unexpected runtime crashes in prod.

Wrap with a dev guard or convert to console.error + rethrow for diagnostics.

export type S3BucketId = typeof S3BucketId.Type;

export class S3Bucket extends Schema.Class<S3Bucket>("S3Bucket")({
id: Schema.String,
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Use S3BucketId for the id field.

Keep IDs strongly typed to prevent mixing IDs across domains.

-  id: Schema.String,
+  id: S3BucketId,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
id: Schema.String,
id: S3BucketId,
🤖 Prompt for AI Agents
In packages/web-domain/src/S3Bucket.ts at line 7, replace the type of the id
field from Schema.String to S3BucketId to ensure strong typing and prevent
mixing IDs across different domains. Update the id field declaration to use the
S3BucketId type instead of a generic string type.

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.

Review continued from previous batch...

Comment on lines 141 to +169
if (
video.transcriptionStatus === "COMPLETE" &&
!metadata.aiProcessing &&
!metadata.summary &&
video.transcriptionStatus === "COMPLETE" &&
!metadata.aiProcessing &&
!metadata.summary &&
!metadata.chapters &&
!metadata.generationError
) {
console.log(`[Get Status] Transcription complete but no AI data, checking feature flag for video owner ${video.ownerId}`);

console.log(
`[Get Status] Transcription complete but no AI data, checking feature flag for video owner ${video.ownerId}`
);

const videoOwnerQuery = await db()
.select({
email: users.email,
stripeSubscriptionStatus: users.stripeSubscriptionStatus
.select({
email: users.email,
stripeSubscriptionStatus: users.stripeSubscriptionStatus,
})
.from(users)
.where(eq(users.id, video.ownerId))
.limit(1);

if (videoOwnerQuery.length > 0 && videoOwnerQuery[0] && (await isAiGenerationEnabled(videoOwnerQuery[0]))) {
console.log(`[Get Status] Feature flag enabled, triggering AI generation for video ${videoId}`);

if (
videoOwnerQuery.length > 0 &&
videoOwnerQuery[0] &&
(await isAiGenerationEnabled(videoOwnerQuery[0]))
) {
console.log(
`[Get Status] Feature flag enabled, triggering AI generation for video ${videoId}`
);

Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Race condition: aiProcessing is not persisted before background job starts

When you start AI generation, the DB metadata.aiProcessing isn’t set to true. Subsequent calls can enqueue multiple concurrent jobs.

Set the flag before kicking off the background task:

-    if (
-      video.transcriptionStatus === "COMPLETE" &&
-      !metadata.aiProcessing &&
-      !metadata.summary &&
-      !metadata.chapters &&
-      !metadata.generationError
-    ) {
+    if (
+      video.transcriptionStatus === "COMPLETE" &&
+      !metadata.aiProcessing &&
+      !metadata.summary &&
+      !metadata.chapters &&
+      !metadata.generationError
+    ) {
       // ... feature flag check ...
-      (async () => {
+      // Persist the aiProcessing flag to prevent duplicate enqueues
+      await db()
+        .update(videos)
+        .set({
+          metadata: { ...metadata, aiProcessing: true, generationError: null },
+        })
+        .where(eq(videos.id, videoId));
+
+      (async () => {
         try {
           // start job
           await generateAiMetadata(videoId, video.ownerId);
           // job success: generator should clear aiProcessing and set metadata itself
         } catch (error) {
           // on failure: reset flag + store error
           try {
-            const currentVideo = await db().select().from(videos).where(eq(videos.id, videoId));
+            const currentVideo = await db()
+              .select()
+              .from(videos)
+              .where(eq(videos.id, videoId));
             if (currentVideo.length > 0 && currentVideo[0]) {
               const currentMetadata = (currentVideo[0].metadata as VideoMetadata) || {};
               await db()
                 .update(videos)
                 .set({
                   metadata: {
                     ...currentMetadata,
                     aiProcessing: false,
                     generationError: error instanceof Error ? error.message : String(error),
                   },
                 })
                 .where(eq(videos.id, videoId));
             }
           } catch (resetError) {
             console.error(
               `[Get Status] Failed to reset AI processing flag for video ${videoId}:`,
               resetError
             );
           }
         }
       })();
       return {
         transcriptionStatus: (video.transcriptionStatus as "PROCESSING" | "COMPLETE" | "ERROR") || null,
-        aiProcessing: true,
+        aiProcessing: true, // mirrors persisted state
         aiTitle: metadata.aiTitle || null,
         summary: metadata.summary || null,
         chapters: metadata.chapters || null,
         generationError: metadata.generationError || null,
       };

Optionally add a guard in the UPDATE WHERE clause to only set aiProcessing when it’s currently false.

Also applies to: 214-224

🤖 Prompt for AI Agents
In apps/web/actions/videos/get-status.ts between lines 141 and 169, the code
starts AI generation without first updating the database to set
metadata.aiProcessing to true, causing a race condition with multiple jobs
enqueued. To fix this, update the database to set metadata.aiProcessing to true
before triggering the background AI generation job, optionally adding a WHERE
clause to only update if aiProcessing is currently false. Apply the same fix to
lines 214-224 where similar logic occurs.

Comment on lines +226 to 229
console.log(
`[Get Status] AI generation feature disabled for video owner ${video.ownerId} (email: ${videoOwner?.email}, pro: ${videoOwner?.stripeSubscriptionStatus})`
);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Avoid logging PII (email) to server logs

Logging user emails is PII. Prefer logging non-PII identifiers (e.g., user ID) or omit entirely.

-      console.log(
-        `[Get Status] AI generation feature disabled for video owner ${video.ownerId} (email: ${videoOwner?.email}, pro: ${videoOwner?.stripeSubscriptionStatus})`
-      );
+      console.log(
+        `[Get Status] AI generation feature disabled for owner ${video.ownerId} (subscription: ${videoOwner?.stripeSubscriptionStatus})`
+      );

If you need the email for debugging, feature-gate it behind a secure diagnostic flag and scrub in production.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
console.log(
`[Get Status] AI generation feature disabled for video owner ${video.ownerId} (email: ${videoOwner?.email}, pro: ${videoOwner?.stripeSubscriptionStatus})`
);
}
console.log(
`[Get Status] AI generation feature disabled for owner ${video.ownerId} (subscription: ${videoOwner?.stripeSubscriptionStatus})`
);
}
🤖 Prompt for AI Agents
In apps/web/actions/videos/get-status.ts around lines 226 to 229, the
console.log statement includes the user's email, which is personally
identifiable information (PII) and should not be logged. Remove the email from
the log message or replace it with a non-PII identifier like the user ID. If
email logging is necessary for debugging, gate it behind a secure diagnostic
flag and ensure it is scrubbed in production environments.

Comment on lines 198 to 253
const deleteCaps = useEffectMutation({
mutationFn: Effect.fn(function* (ids: Video.VideoId[]) {
if (ids.length === 0) return;

setIsDeleting(true);
const rpc = yield* Rpc;

try {
toast.promise(
async () => {
const results = await Promise.allSettled(
selectedCaps.map((capId) => deleteVideo(capId))
);
const fiber = yield* Effect.gen(function* () {
const results = yield* Effect.all(
ids.map((id) => rpc.VideoDelete(id).pipe(Effect.exit)),
{ concurrency: 10 }
);

const successCount = results.filter(
(result) => result.status === "fulfilled" && result.value.success
).length;
const successCount = results.filter(Exit.isSuccess).length;

const errorCount = selectedCaps.length - successCount;
const errorCount = ids.length - successCount;

if (successCount > 0 && errorCount > 0) {
return { success: successCount, error: errorCount };
} else if (successCount > 0) {
return { success: successCount };
} else {
throw new Error(
if (successCount > 0 && errorCount > 0) {
return { success: successCount, error: errorCount };
} else if (successCount > 0) {
return { success: successCount };
} else {
return yield* Effect.fail(
new Error(
`Failed to delete ${errorCount} cap${errorCount === 1 ? "" : "s"}`
);
)
);
}
}).pipe(Effect.fork);

toast.promise(Effect.runPromise(fiber.await.pipe(Effect.flatten)), {
loading: `Deleting ${selectedCaps.length} cap${
selectedCaps.length === 1 ? "" : "s"
}...`,
success: (data) => {
if (data.error) {
return `Successfully deleted ${data.success} cap${
data.success === 1 ? "" : "s"
}, but failed to delete ${data.error} cap${
data.error === 1 ? "" : "s"
}`;
}
return `Successfully deleted ${data.success} cap${
data.success === 1 ? "" : "s"
}`;
},
{
loading: `Deleting ${selectedCaps.length} cap${selectedCaps.length === 1 ? "" : "s"
}...`,
success: (data) => {
if (data.error) {
return `Successfully deleted ${data.success} cap${data.success === 1 ? "" : "s"
}, but failed to delete ${data.error} cap${data.error === 1 ? "" : "s"
}`;
}
return `Successfully deleted ${data.success} cap${data.success === 1 ? "" : "s"
}`;
},
error: (error) =>
error.message || "An error occurred while deleting caps",
}
);
error: (error) =>
error.message || "An error occurred while deleting caps",
});

return yield* fiber.await.pipe(Effect.flatten);
}),
onSuccess: Effect.fn(function* () {
setSelectedCaps([]);
refresh();
} catch (error) {
} finally {
setIsDeleting(false);
}
};
router.refresh();
}),
});
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Fix generator functions that don't yield.

The onSuccess handler uses Effect.fn but doesn't perform any effectful operations, causing unnecessary generator overhead.

Apply this diff to fix the issue:

-    onSuccess: Effect.fn(function* () {
+    onSuccess: () => {
       setSelectedCaps([]);
       router.refresh();
-    }),
+    },

The implementation otherwise handles concurrent deletions well with proper error reporting.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const deleteCaps = useEffectMutation({
mutationFn: Effect.fn(function* (ids: Video.VideoId[]) {
if (ids.length === 0) return;
setIsDeleting(true);
const rpc = yield* Rpc;
try {
toast.promise(
async () => {
const results = await Promise.allSettled(
selectedCaps.map((capId) => deleteVideo(capId))
);
const fiber = yield* Effect.gen(function* () {
const results = yield* Effect.all(
ids.map((id) => rpc.VideoDelete(id).pipe(Effect.exit)),
{ concurrency: 10 }
);
const successCount = results.filter(
(result) => result.status === "fulfilled" && result.value.success
).length;
const successCount = results.filter(Exit.isSuccess).length;
const errorCount = selectedCaps.length - successCount;
const errorCount = ids.length - successCount;
if (successCount > 0 && errorCount > 0) {
return { success: successCount, error: errorCount };
} else if (successCount > 0) {
return { success: successCount };
} else {
throw new Error(
if (successCount > 0 && errorCount > 0) {
return { success: successCount, error: errorCount };
} else if (successCount > 0) {
return { success: successCount };
} else {
return yield* Effect.fail(
new Error(
`Failed to delete ${errorCount} cap${errorCount === 1 ? "" : "s"}`
);
)
);
}
}).pipe(Effect.fork);
toast.promise(Effect.runPromise(fiber.await.pipe(Effect.flatten)), {
loading: `Deleting ${selectedCaps.length} cap${
selectedCaps.length === 1 ? "" : "s"
}...`,
success: (data) => {
if (data.error) {
return `Successfully deleted ${data.success} cap${
data.success === 1 ? "" : "s"
}, but failed to delete ${data.error} cap${
data.error === 1 ? "" : "s"
}`;
}
return `Successfully deleted ${data.success} cap${
data.success === 1 ? "" : "s"
}`;
},
{
loading: `Deleting ${selectedCaps.length} cap${selectedCaps.length === 1 ? "" : "s"
}...`,
success: (data) => {
if (data.error) {
return `Successfully deleted ${data.success} cap${data.success === 1 ? "" : "s"
}, but failed to delete ${data.error} cap${data.error === 1 ? "" : "s"
}`;
}
return `Successfully deleted ${data.success} cap${data.success === 1 ? "" : "s"
}`;
},
error: (error) =>
error.message || "An error occurred while deleting caps",
}
);
error: (error) =>
error.message || "An error occurred while deleting caps",
});
return yield* fiber.await.pipe(Effect.flatten);
}),
onSuccess: Effect.fn(function* () {
setSelectedCaps([]);
refresh();
} catch (error) {
} finally {
setIsDeleting(false);
}
};
router.refresh();
}),
});
const deleteCaps = useEffectMutation({
mutationFn: Effect.fn(function* (ids: Video.VideoId[]) {
if (ids.length === 0) return;
const rpc = yield* Rpc;
const fiber = yield* Effect.gen(function* () {
const results = yield* Effect.all(
ids.map((id) => rpc.VideoDelete(id).pipe(Effect.exit)),
{ concurrency: 10 }
);
const successCount = results.filter(Exit.isSuccess).length;
const errorCount = ids.length - successCount;
if (successCount > 0 && errorCount > 0) {
return { success: successCount, error: errorCount };
} else if (successCount > 0) {
return { success: successCount };
} else {
return yield* Effect.fail(
new Error(
`Failed to delete ${errorCount} cap${errorCount === 1 ? "" : "s"}`
)
);
}
}).pipe(Effect.fork);
toast.promise(Effect.runPromise(fiber.await.pipe(Effect.flatten)), {
loading: `Deleting ${selectedCaps.length} cap${
selectedCaps.length === 1 ? "" : "s"
}...`,
success: (data) => {
if (data.error) {
return `Successfully deleted ${data.success} cap${
data.success === 1 ? "" : "s"
}, but failed to delete ${data.error} cap${
data.error === 1 ? "" : "s"
}`;
}
return `Successfully deleted ${data.success} cap${
data.success === 1 ? "" : "s"
}`;
},
error: (error) =>
error.message || "An error occurred while deleting caps",
});
return yield* fiber.await.pipe(Effect.flatten);
}),
onSuccess: () => {
setSelectedCaps([]);
router.refresh();
},
});
🧰 Tools
🪛 Biome (2.1.2)

[error] 249-252: This generator function doesn't contain yield.

(lint/correctness/useYield)

🤖 Prompt for AI Agents
In apps/web/app/(org)/dashboard/caps/Caps.tsx around lines 198 to 253, the
onSuccess handler is wrapped in Effect.fn as a generator function but does not
yield any effects, causing unnecessary generator overhead. To fix this, refactor
the onSuccess handler to be a normal function instead of a generator by removing
Effect.fn and the generator syntax, and directly call setSelectedCaps([]) and
router.refresh() inside it.

Comment on lines +255 to +264
const deleteCap = useEffectMutation({
mutationFn: (id: Video.VideoId) => withRpc((r) => r.VideoDelete(id)),
onSuccess: Effect.fn(function* () {
toast.success("Cap deleted successfully");
refresh();
} catch (error) {
router.refresh();
}),
onError: Effect.fn(function* () {
toast.error("Failed to delete cap");
}
};
}),
});
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Fix generator functions in deleteCap mutation.

Both onSuccess and onError handlers use Effect.fn unnecessarily.

Apply this diff:

-    onSuccess: Effect.fn(function* () {
+    onSuccess: () => {
       toast.success("Cap deleted successfully");
       router.refresh();
-    }),
-    onError: Effect.fn(function* () {
+    },
+    onError: () => {
       toast.error("Failed to delete cap");
-    }),
+    },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const deleteCap = useEffectMutation({
mutationFn: (id: Video.VideoId) => withRpc((r) => r.VideoDelete(id)),
onSuccess: Effect.fn(function* () {
toast.success("Cap deleted successfully");
refresh();
} catch (error) {
router.refresh();
}),
onError: Effect.fn(function* () {
toast.error("Failed to delete cap");
}
};
}),
});
const deleteCap = useEffectMutation({
mutationFn: (id: Video.VideoId) => withRpc((r) => r.VideoDelete(id)),
onSuccess: () => {
toast.success("Cap deleted successfully");
router.refresh();
},
onError: () => {
toast.error("Failed to delete cap");
},
});
🧰 Tools
🪛 Biome (2.1.2)

[error] 257-260: This generator function doesn't contain yield.

(lint/correctness/useYield)


[error] 261-263: This generator function doesn't contain yield.

(lint/correctness/useYield)

🤖 Prompt for AI Agents
In apps/web/app/(org)/dashboard/caps/Caps.tsx around lines 255 to 264, the
onSuccess and onError handlers in the deleteCap mutation use Effect.fn
unnecessarily for generator functions. Remove Effect.fn wrappers and convert
these handlers to regular functions that directly execute the toast calls and
router refresh to simplify the code.

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: 0

🔭 Outside diff range comments (1)
apps/web/app/(org)/dashboard/caps/Caps.tsx (1)

132-173: Fix stale selection captured by keydown handler (can delete the wrong items).

The effect depends on selectedCaps.length, but the handler uses the full selectedCaps array. If the selection contents change without changing its length, the handler deletes the wrong caps.

-  }, [selectedCaps.length, data]);
+  }, [selectedCaps, data]);

If you want to minimize re-subscribing due to large data arrays, you can also compute a memoized list of ids for dependency or use a stable event utility, but the core fix is to depend on selectedCaps.

♻️ Duplicate comments (3)
apps/web/app/(org)/dashboard/caps/Caps.tsx (3)

250-253: Remove unnecessary generator wrapper and preserve failed selections after bulk delete.

Convert onSuccess to a normal function (BiOMe error), and keep failed deletions selected to let users retry.

-    onSuccess: Effect.fn(function* () {
-      setSelectedCaps([]);
-      router.refresh();
-    }),
+    onSuccess: (data: { failedIds?: Video.VideoId[] }) => {
+      setSelectedCaps(data?.failedIds ?? []);
+      router.refresh();
+    },

Follow-up: to populate failedIds, adjust the mutation to return them (see next comment suggestion).


258-261: Remove unnecessary generator wrapper in deleteCap onSuccess.

No yields inside; switch to a plain function to satisfy lint and reduce overhead.

-    onSuccess: Effect.fn(function* () {
-      toast.success("Cap deleted successfully");
-      router.refresh();
-    }),
+    onSuccess: () => {
+      toast.success("Cap deleted successfully");
+      router.refresh();
+    },

262-264: Remove unnecessary generator wrapper in deleteCap onError.

Same issue: no yields, so a plain function is appropriate.

-    onError: Effect.fn(function* () {
-      toast.error("Failed to delete cap");
-    }),
+    onError: () => {
+      toast.error("Failed to delete cap");
+    },
🧹 Nitpick comments (4)
apps/web/app/(org)/dashboard/caps/Caps.tsx (4)

95-99: Remove unnecessary headers for GET request.

Using "Content-Type" on a GET is unnecessary and sometimes misleading. Prefer the default GET without headers here.

-          const response = await fetch(`/api/analytics?videoId=${video.id}`, {
-            method: "GET",
-            headers: {
-              "Content-Type": "application/json",
-            },
-          });
+          const response = await fetch(`/api/analytics?videoId=${video.id}`);

228-233: Use ids length for loading message to avoid state mismatch.

The loading text uses selectedCaps.length, but the mutation operates on ids (the parameter). If selection changes while the mutation is running, the message can be wrong.

-      toast.promise(Effect.runPromise(fiber.await.pipe(Effect.flatten)), {
-        loading: `Deleting ${selectedCaps.length} cap${
-          selectedCaps.length === 1 ? "" : "s"
+      toast.promise(Effect.runPromise(fiber.await.pipe(Effect.flatten)), {
+        loading: `Deleting ${ids.length} cap${
+          ids.length === 1 ? "" : "s"
         }...`,

206-219: Return failedIds from bulk delete to enable better UX on partial failures.

Returning only counts prevents the UI from keeping failed items selected. Return the ids of failures to allow retrying.

-        const results = yield* Effect.all(
-          ids.map((id) => rpc.VideoDelete(id).pipe(Effect.exit)),
-          { concurrency: 10 }
-        );
-
-        const successCount = results.filter(Exit.isSuccess).length;
-
-        const errorCount = ids.length - successCount;
+        const results = yield* Effect.all(
+          ids.map((id) =>
+            rpc.VideoDelete(id).pipe(
+              Effect.exit,
+              Effect.map((exit) => ({ id, exit })),
+            )
+          ),
+          { concurrency: 10 },
+        );
+
+        const successIds = results
+          .filter(({ exit }) => Exit.isSuccess(exit))
+          .map(({ id }) => id);
+        const failedIds = results
+          .filter(({ exit }) => Exit.isFailure(exit))
+          .map(({ id }) => id);
+
+        const successCount = successIds.length;
+        const errorCount = failedIds.length;
 
-        if (successCount > 0 && errorCount > 0) {
-          return { success: successCount, error: errorCount };
-        } else if (successCount > 0) {
-          return { success: successCount };
+        if (successCount > 0 && errorCount > 0) {
+          return { success: successCount, error: errorCount, failedIds };
+        } else if (successCount > 0) {
+          return { success: successCount, failedIds: [] };
         } else {
           return yield* Effect.fail(
             new Error(
               `Failed to delete ${errorCount} cap${errorCount === 1 ? "" : "s"}`
             )
           );
         }

This pairs with updating onSuccess to re-select only the failed ids.

Also applies to: 215-226


329-333: Consistency: await mutateAsync for single delete or return the promise.

Minor consistency: the bulk path awaits mutateAsync, the single path does not. Either await or return the promise for uniform behavior.

-                  } else {
-                    deleteCap.mutateAsync(cap.id);
-                  }
+                  } else {
+                    await deleteCap.mutateAsync(cap.id);
+                  }
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5c2e3fc and f9e6421.

📒 Files selected for processing (8)
  • apps/desktop/package.json (1 hunks)
  • apps/web/app/(org)/dashboard/caps/Caps.tsx (13 hunks)
  • apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx (12 hunks)
  • apps/web/app/(org)/dashboard/caps/page.tsx (5 hunks)
  • apps/web/app/embed/[videoId]/page.tsx (1 hunks)
  • apps/web/app/s/[videoId]/page.tsx (1 hunks)
  • apps/web/package.json (4 hunks)
  • apps/web/utils/web-api.ts (1 hunks)
✅ Files skipped from review due to trivial changes (2)
  • apps/web/app/s/[videoId]/page.tsx
  • apps/web/utils/web-api.ts
🚧 Files skipped from review as they are similar to previous changes (5)
  • apps/desktop/package.json
  • apps/web/app/embed/[videoId]/page.tsx
  • apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx
  • apps/web/app/(org)/dashboard/caps/page.tsx
  • apps/web/package.json
🧰 Additional context used
🪛 Biome (2.1.2)
apps/web/app/(org)/dashboard/caps/Caps.tsx

[error] 250-253: This generator function doesn't contain yield.

(lint/correctness/useYield)


[error] 258-261: This generator function doesn't contain yield.

(lint/correctness/useYield)


[error] 262-264: This generator function doesn't contain yield.

(lint/correctness/useYield)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Build Desktop (x86_64-pc-windows-msvc, windows-latest)

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: 3

♻️ Duplicate comments (2)
apps/web/app/(org)/dashboard/caps/Caps.tsx (2)

250-254: Remove Effect.fn/generator on onSuccess (no yields)

Matches Biome’s lint and prior feedback; use a plain function.

-    onSuccess: Effect.fn(function* () {
-      setSelectedCaps([]);
-      router.refresh();
-    }),
+    onSuccess: () => {
+      setSelectedCaps([]);
+      router.refresh();
+    },

258-264: Remove Effect.fn/generators in deleteCap handlers (no yields)

Both handlers don’t yield; use normal functions.

-    onSuccess: Effect.fn(function* () {
-      toast.success("Cap deleted successfully");
-      router.refresh();
-    }),
-    onError: Effect.fn(function* () {
-      toast.error("Failed to delete cap");
-    }),
+    onSuccess: () => {
+      toast.success("Cap deleted successfully");
+      router.refresh();
+    },
+    onError: () => {
+      toast.error("Failed to delete cap");
+    },
🧹 Nitpick comments (2)
apps/web/app/(org)/dashboard/caps/Caps.tsx (2)

95-99: Avoid sending Content-Type on GET without a body

It’s unnecessary and can confuse some intermediaries.

-          const response = await fetch(`/api/analytics?videoId=${video.id}`, {
-            method: "GET",
-            headers: {
-              "Content-Type": "application/json",
-            },
-          });
+          const response = await fetch(`/api/analytics?videoId=${video.id}`, {
+            method: "GET",
+          });

119-122: Simplify Promise.allSettled handling

In the fulfilled branch, result.value is always present; the extra guard is redundant.

-      results.forEach((result) => {
-        if (result.status === "fulfilled" && result.value) {
-          analyticsData[result.value.videoId] = result.value.count;
-        }
-      });
+      results.forEach((result) => {
+        if (result.status === "fulfilled") {
+          analyticsData[result.value.videoId] = result.value.count;
+        }
+      });
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5c2e3fc and 73af8b6.

📒 Files selected for processing (10)
  • apps/desktop/package.json (1 hunks)
  • apps/web/app/(org)/dashboard/caps/Caps.tsx (13 hunks)
  • apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx (12 hunks)
  • apps/web/app/(org)/dashboard/caps/page.tsx (5 hunks)
  • apps/web/app/(org)/login/form.tsx (11 hunks)
  • apps/web/app/api/video/delete/route.ts (1 hunks)
  • apps/web/app/embed/[videoId]/page.tsx (1 hunks)
  • apps/web/app/s/[videoId]/page.tsx (1 hunks)
  • apps/web/package.json (4 hunks)
  • apps/web/utils/web-api.ts (1 hunks)
✅ Files skipped from review due to trivial changes (2)
  • apps/web/app/s/[videoId]/page.tsx
  • apps/web/utils/web-api.ts
🚧 Files skipped from review as they are similar to previous changes (6)
  • apps/web/app/embed/[videoId]/page.tsx
  • apps/desktop/package.json
  • apps/web/app/(org)/login/form.tsx
  • apps/web/package.json
  • apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx
  • apps/web/app/(org)/dashboard/caps/page.tsx
🧰 Additional context used
🪛 Biome (2.1.2)
apps/web/app/(org)/dashboard/caps/Caps.tsx

[error] 250-253: This generator function doesn't contain yield.

(lint/correctness/useYield)


[error] 258-261: This generator function doesn't contain yield.

(lint/correctness/useYield)


[error] 262-264: This generator function doesn't contain yield.

(lint/correctness/useYield)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: Build Desktop (aarch64-apple-darwin, macos-latest)
  • GitHub Check: Clippy
  • GitHub Check: Build Desktop (x86_64-pc-windows-msvc, windows-latest)
🔇 Additional comments (1)
apps/web/app/api/video/delete/route.ts (1)

16-18: Confirm query-param semantics for deleteVideo endpoint

setUrlParams(...) appends ?videoId=… to /api/video/delete, matching how other video APIs (OG, transcribe, analytics, playlist) expect videoId as a query parameter. I didn’t find any existing client calls to /api/video/delete, but this aligns with your established pattern. No code changes are required here—only add or update your client‐side fetch to pass videoId in the query string.

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: 3

🧹 Nitpick comments (4)
apps/web/app/api/desktop/[...route]/video.ts (2)

144-144: Nit: use object property shorthand

You can simplify to property shorthand.

-              videoName: videoName,
+              videoName,

85-87: Handle blank/whitespace-only names to avoid empty titles

If the query param name is provided as an empty string, name ?? won’t fall back to the default. Trim and fallback to ensure a non-empty title.

-      const videoName =
-        name ??
-        `Cap ${isScreenshot ? "Screenshot" : "Recording"} - ${formattedDate}`;
+      const defaultName = `Cap ${isScreenshot ? "Screenshot" : "Recording"} - ${formattedDate}`;
+      const videoName = name && name.trim().length > 0 ? name.trim() : defaultName;

Alternatively, normalize at validation time with zod (optional):

-      name: z.string().optional(),
+      name: z.string().transform((s) => s.trim()).optional(),
apps/web/lib/tracing.ts (2)

13-21: Tune BatchSpanProcessor for serverless/short-lived runtimes

Defaults can drop spans on cold starts/fast terminations. Consider tighter flush settings:

-        new BatchSpanProcessor(
+        new BatchSpanProcessor(
           new OTLPTraceExporter({
             ...
-          })
-        ),
+          }),
+          {
+            maxQueueSize: 512,
+            maxExportBatchSize: 256,
+            scheduledDelayMillis: 2000,
+            exportTimeoutMillis: 3000
+          }
+        ),

Adjust numbers per environment. For long-lived servers, defaults may be fine.


11-11: Add richer resource attributes (version/env) for better trace filtering

Augment the resource to include version and deployment environment, which greatly helps triaging:

-      resource: { serviceName: "cap-web" },
+      resource: {
+        serviceName: "cap-web",
+        serviceVersion:
+          process.env.NEXT_PUBLIC_APP_VERSION ??
+          process.env.VERCEL_GIT_COMMIT_SHA ??
+          "dev",
+        attributes: {
+          "deployment.environment": process.env.NODE_ENV ?? "development"
+        }
+      },

Confirm the exact shape expected by @effect/opentelemetry’s layer config for additional attributes.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 73af8b6 and ecd11eb.

📒 Files selected for processing (4)
  • apps/web/app/api/desktop/[...route]/video.ts (1 hunks)
  • apps/web/lib/EffectRuntime.ts (1 hunks)
  • apps/web/lib/server.ts (1 hunks)
  • apps/web/lib/tracing.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/web/lib/server.ts
  • apps/web/lib/EffectRuntime.ts
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: Build Desktop (aarch64-apple-darwin, macos-latest)
  • GitHub Check: Build Desktop (x86_64-pc-windows-msvc, windows-latest)
  • GitHub Check: Clippy
🔇 Additional comments (2)
apps/web/app/api/desktop/[...route]/video.ts (1)

141-145: Good fix: pass the correct video name to the email template

Using the locally computed videoName here is correct and prevents undefined content in the email.

apps/web/lib/tracing.ts (1)

6-25: Confirmed Server-Only Usage of TracingLayer — No AXIOM_TOKEN Leakage

I searched the entire codebase and found that TracingLayer is only imported in:

  • apps/web/lib/server.ts
  • apps/web/lib/EffectRuntime.ts

There are no imports in any .tsx or browser-only files, so bundling the Axiom token into client bundles is not a concern.

Optional improvements (no blocker):

  • Read dataset and URL from environment (e.g. AXIOM_DATASET, AXIOM_TRACES_URL) instead of hardcoding.
  • Gracefully disable or no-op the exporter if the token is absent (to avoid runtime errors).
  • Tune BatchSpanProcessor options for serverless or short-lived processes.

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: 8

🔭 Outside diff range comments (1)
apps/web/app/(org)/dashboard/caps/Caps.tsx (1)

132-173: Fix potential stale selection in keydown handler.
Effect depends on selectedCaps.length only; selection can change without length changes. Depend on the array to avoid stale closures (or use a ref).

-  }, [selectedCaps.length, data]);
+  }, [selectedCaps, data]);
♻️ Duplicate comments (13)
apps/web/app/api/video/delete/route.ts (2)

16-21: Align endpoint contract: add success schema and all error variants from middleware/handler.
Endpoint declares Forbidden/NotFound only; middleware can fail with Unauthorized/InternalServerError, and handler maps PolicyDenied to Unauthorized. Also declare a success schema and return it from the handler.

 HttpApiEndpoint.del("deleteVideo")`/api/video/delete`
   .setUrlParams(Schema.Struct({ videoId: Video.VideoId }))
   .middleware(HttpAuthMiddleware)
+  .addSuccess(Schema.Boolean)
   .addError(HttpApiError.Forbidden)
   .addError(HttpApiError.NotFound)
+  .addError(HttpApiError.Unauthorized)
+  .addError(HttpApiError.InternalServerError)

33-42: Fail inside catchTags (don’t return error objects as values).
Handlers currently return error objects, which produces a success value. Wrap with Effect.fail so the builder encodes error responses correctly.

-              VideoNotFoundError: () => new HttpApiError.NotFound(),
-              PolicyDenied: () => new HttpApiError.Unauthorized(),
+              VideoNotFoundError: () => Effect.fail(new HttpApiError.NotFound()),
+              PolicyDenied:     () => Effect.fail(new HttpApiError.Unauthorized()),
-              DatabaseError: (e) =>
-                Effect.logError(e).pipe(
-                  Effect.andThen(() => new HttpApiError.InternalServerError())
-                ),
+              DatabaseError: (e) =>
+                Effect.logError(e).pipe(
+                  Effect.andThen(Effect.fail(new HttpApiError.InternalServerError()))
+                ),
-              S3Error: (e) =>
-                Effect.logError(e).pipe(
-                  Effect.andThen(() => new HttpApiError.InternalServerError())
-                ),
+              S3Error: (e) =>
+                Effect.logError(e).pipe(
+                  Effect.andThen(Effect.fail(new HttpApiError.InternalServerError()))
+                ),
apps/web/app/(org)/dashboard/caps/components/Folder.tsx (1)

75-87: Fix generator callbacks: they don’t yield.
Use Effect.gen and yield* Effect.sync for side effects (router/toast), or convert to plain functions. This satisfies the linter and ensures proper composition.

 const deleteFolder = useEffectMutation({
   mutationFn: (id: Folder.FolderId) => withRpc((r) => r.FolderDelete(id)),
-  onSuccess: Effect.fn(function* () {
-    router.refresh();
-    toast.success("Folder deleted successfully");
-  }),
-  onError: Effect.fn(function* () {
-    toast.error("Failed to delete folder");
-  }),
-  onSettled: Effect.fn(function* () {
-    setConfirmDeleteFolderOpen(false);
-  }),
+  onSuccess: Effect.gen(function* () {
+    yield* Effect.sync(() => router.refresh());
+    yield* Effect.sync(() => toast.success("Folder deleted successfully"));
+  }),
+  onError: Effect.gen(function* () {
+    yield* Effect.sync(() => toast.error("Failed to delete folder"));
+  }),
+  onSettled: Effect.gen(function* () {
+    yield* Effect.sync(() => setConfirmDeleteFolderOpen(false));
+  }),
 });
packages/web-backend/src/Videos/index.ts (1)

1-1: Avoid shadowing global Array: alias Effect’s Array utilities.
Prevents confusion and satisfies linters.

-import { Array, Effect, Option } from "effect";
+import { Array as EffectArray, Effect, Option } from "effect";
apps/web/app/(org)/dashboard/caps/Caps.tsx (2)

256-262: deleteCap handlers: remove Effect.fn wrappers.
They don’t yield; use regular functions.

-    onSuccess: Effect.fn(function* () {
-      toast.success("Cap deleted successfully");
-      router.refresh();
-    }),
-    onError: Effect.fn(function* () {
-      toast.error("Failed to delete cap");
-    }),
+    onSuccess: () => {
+      toast.success("Cap deleted successfully");
+      router.refresh();
+    },
+    onError: () => {
+      toast.error("Failed to delete cap");
+    },

248-252: onSuccess is a generator with no yields — convert to a normal function.
Removes unnecessary generator overhead and satisfies linter.

-    onSuccess: Effect.fn(function* () {
-      setSelectedCaps([]);
-      router.refresh();
-    }),
+    onSuccess: () => {
+      setSelectedCaps([]);
+      router.refresh();
+    },
packages/web-domain/src/Policy.ts (2)

18-21: TaggedError builder usage is now correct

The zero-arg generic builder form is used properly; this matches Effect’s pattern and avoids runtime/type errors.


64-67: withPolicy signature accepts Policy — good; consider explicit return type annotation

Signature now composes policies requiring CurrentUser. Optional: add an explicit return type to make the widened environment obvious to readers (CurrentUser | R | R2).

 export const withPolicy =
   <E, R>(policy: Policy<E, R>) =>
-  <A, E2, R2>(self: Effect.Effect<A, E2, R2>) =>
-    Effect.zipRight(policy, self);
+  <A, E2, R2>(self: Effect.Effect<A, E2, R2>): Effect.Effect<
+    A,
+    PolicyDeniedError | E | E2,
+    CurrentUser | R | R2
+  > => Effect.zipRight(policy, self);
apps/web/app/api/playlist/route.ts (5)

148-155: Good fix: use signed URLs for .ts segments

Switching to getSignedObjectUrl prevents corrupting the playlist content and is the correct approach.


55-61: Fix NotFound mapping: return a failing effect from catchTag

Handlers must fail the effect, not return values. Also, no need to flatten an error value. Keep flatten if needed for your return type, but make sure catchTag fails.

-            const [video] = yield* videos.getById(urlParams.videoId).pipe(
-              Effect.flatten,
-              Effect.catchTag(
-                "NoSuchElementException",
-                () => new HttpApiError.NotFound()
-              )
-            );
+            const [video] = yield* videos.getById(urlParams.videoId).pipe(
+              Effect.flatten,
+              Effect.catchTag(
+                "NoSuchElementException",
+                () => Effect.fail(new HttpApiError.NotFound())
+              )
+            );

72-85: Ensure catchTags handlers fail with HttpApiError (don’t return values)

All handlers currently return error objects as values. They should return a failing effect.

             provideOptionalAuth,
             Effect.catchTags({
-              VerifyVideoPasswordError: () => new HttpApiError.Forbidden(),
-              PolicyDenied: () => new HttpApiError.Unauthorized(),
+              VerifyVideoPasswordError: () =>
+                Effect.fail(new HttpApiError.Forbidden()),
+              PolicyDenied: () =>
+                Effect.fail(new HttpApiError.Unauthorized()),
               DatabaseError: (e) =>
                 Effect.logError(e).pipe(
-                  Effect.andThen(() => new HttpApiError.InternalServerError())
+                  Effect.andThen(
+                    Effect.fail(new HttpApiError.InternalServerError())
+                  )
                 ),
               S3Error: (e) =>
                 Effect.logError(e).pipe(
-                  Effect.andThen(() => new HttpApiError.InternalServerError())
+                  Effect.andThen(
+                    Effect.fail(new HttpApiError.InternalServerError())
+                  )
                 ),
             })

113-134: Transcription branch must fail on missing object; avoid returning values in Option.match

Wrap Option.match in flatMap and return failures/successes as Effects.

-    ) {
-      return yield* s3
-        .getObject(`${video.ownerId}/${video.id}/transcription.vtt`)
-        .pipe(
-          Effect.andThen(
-            Option.match({
-              onNone: () => new HttpApiError.NotFound(),
-              onSome: (c) =>
-                HttpServerResponse.text(c).pipe(
-                  HttpServerResponse.setHeaders({
-                    ...CACHE_CONTROL_HEADERS,
-                    "Content-Type": "text/vtt",
-                  })
-                ),
-            })
-          ),
-          Effect.withSpan("fetchTranscription")
-        );
-    }
+    ) {
+      return yield* s3
+        .getObject(`${video.ownerId}/${video.id}/transcription.vtt`)
+        .pipe(
+          Effect.flatMap(
+            Option.match({
+              onNone: () => Effect.fail(new HttpApiError.NotFound()),
+              onSome: (c) =>
+                Effect.succeed(
+                  HttpServerResponse.text(c).pipe(
+                    HttpServerResponse.setHeaders({
+                      ...CACHE_CONTROL_HEADERS,
+                      "Content-Type": "text/vtt",
+                    })
+                  )
+                ),
+            })
+          ),
+          Effect.withSpan("fetchTranscription")
+        );
+    }

168-179: Handle the “mp4” case explicitly to avoid listing with undefined prefix

When videoType === "mp4", prefix stays undefined and listObjects may list the whole bucket. Return an early redirect instead.

-      let prefix;
+      let prefix: string | null | undefined;
       switch (urlParams.videoType) {
         case "video":
           prefix = videoPrefix;
           break;
         case "audio":
           prefix = audioPrefix;
           break;
         case "master":
           prefix = null;
           break;
+        case "mp4":
+          return yield* s3
+            .getSignedObjectUrl(`${video.ownerId}/${video.id}/result.mp4`)
+            .pipe(Effect.map(HttpServerResponse.redirect));
       }
🧹 Nitpick comments (7)
apps/web/app/(org)/dashboard/caps/components/Folder.tsx (1)

50-53: Remove unused state in drag ref (isAnimating is never read).
Clean up to reduce noise.

-  const dragStateRef = useRef({
-    isDragging: false,
-    isAnimating: false,
-  });
+  const dragStateRef = useRef({ isDragging: false });
packages/web-backend/src/Videos/index.ts (1)

32-63: Optional: add tracing spans around delete/duplicate.
Improves observability like getById’s withSpan.

Example:

delete: Effect.fn("Videos.delete")(function* (videoId) {
  // ...
}).pipe(Effect.withSpan("Videos.delete"))

duplicate: Effect.fn("Videos.duplicate")(function* (videoId) {
  // ...
}).pipe(Effect.withSpan("Videos.duplicate"))

Also applies to: 69-106

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

228-247: Minor: no-op early return in mutationFn is fine, but can be explicit.
For clarity, return Effect.void when ids is empty.

-      if (ids.length === 0) return;
+      if (ids.length === 0) {
+        return yield* Effect.void;
+      }
packages/web-domain/src/Policy.ts (1)

60-63: Doc comment mismatch: policy failure error type

These combinators fail with PolicyDeniedError, not an HTTP Forbidden. Suggest updating the comment to avoid confusion.

- * If the policy fails, the effect will fail with Forbidden.
+ * If the policy fails, the effect will fail with PolicyDeniedError.
apps/web/app/api/playlist/route.ts (1)

197-208: Remove unnecessary userId query param in generated playlist URLs

userId isn’t part of GetPlaylistParams and isn’t used server-side. Drop it to reduce noise.

-          `${serverEnv().WEB_URL}/api/playlist?userId=${video.ownerId}&videoId=${video.id}&videoType=video`,
+          `${serverEnv().WEB_URL}/api/playlist?videoId=${video.id}&videoType=video`,
           audioMetadata
-            ? `${serverEnv().WEB_URL}/api/playlist?userId=${video.ownerId}&videoId=${video.id}&videoType=audio`
+            ? `${serverEnv().WEB_URL}/api/playlist?videoId=${video.id}&videoType=audio`
             : null
apps/web/lib/effect-react-query.ts (2)

268-269: Remove stray console.log in mutation runner

Avoid noisy logs in production.

-              console.log({ result });

188-214: Allow retry and retryDelay in mutation options (type-only change)

You’re omitting retry and retryDelay from UseMutationOptions. Consider allowing them to pass through.

-    } & Override<
-      Omit<
-        UseMutationOptions<TData, TExposedError, TVariables, TContext>,
-        "retry" | "retryDelay"
-      >,
+    } & Override<
+      UseMutationOptions<TData, TExposedError, TVariables, TContext>,
       {
         mutationFn?: (variables: TVariables) => Effect.Effect<TData, TError, R>;
         onMutate?: (
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b93e503 and 44212f4.

📒 Files selected for processing (21)
  • apps/web/app/(org)/dashboard/caps/Caps.tsx (13 hunks)
  • apps/web/app/(org)/dashboard/caps/components/Folder.tsx (10 hunks)
  • apps/web/app/(org)/dashboard/caps/page.tsx (4 hunks)
  • apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx (11 hunks)
  • apps/web/app/api/playlist/route.ts (2 hunks)
  • apps/web/app/api/video/delete/route.ts (1 hunks)
  • apps/web/app/embed/[videoId]/page.tsx (3 hunks)
  • apps/web/lib/EffectRuntime.ts (1 hunks)
  • apps/web/lib/effect-react-query.ts (1 hunks)
  • apps/web/lib/server.ts (1 hunks)
  • apps/web/lib/tracing.ts (1 hunks)
  • apps/web/package.json (3 hunks)
  • package.json (1 hunks)
  • packages/web-backend/src/Auth.ts (1 hunks)
  • packages/web-backend/src/Folders/FoldersPolicy.ts (1 hunks)
  • packages/web-backend/src/Folders/FoldersRpcs.ts (1 hunks)
  • packages/web-backend/src/Rpcs.ts (1 hunks)
  • packages/web-backend/src/Videos/index.ts (1 hunks)
  • packages/web-domain/src/Authentication.ts (1 hunks)
  • packages/web-domain/src/Policy.ts (1 hunks)
  • packages/web-domain/src/S3Bucket.ts (1 hunks)
✅ Files skipped from review due to trivial changes (1)
  • package.json
🚧 Files skipped from review as they are similar to previous changes (12)
  • apps/web/app/(org)/dashboard/caps/page.tsx
  • apps/web/app/embed/[videoId]/page.tsx
  • apps/web/package.json
  • apps/web/lib/tracing.ts
  • packages/web-domain/src/S3Bucket.ts
  • packages/web-backend/src/Rpcs.ts
  • packages/web-backend/src/Folders/FoldersRpcs.ts
  • packages/web-backend/src/Folders/FoldersPolicy.ts
  • apps/web/lib/server.ts
  • apps/web/lib/EffectRuntime.ts
  • packages/web-backend/src/Auth.ts
  • apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx
🧰 Additional context used
🧬 Code Graph Analysis (3)
packages/web-domain/src/Authentication.ts (1)
packages/web-domain/src/Errors.ts (1)
  • InternalError (3-6)
packages/web-domain/src/Policy.ts (1)
packages/web-domain/src/Authentication.ts (1)
  • CurrentUser (6-9)
apps/web/lib/effect-react-query.ts (1)
apps/web/lib/EffectRuntime.ts (2)
  • useEffectQuery (19-19)
  • useEffectMutation (20-20)
🪛 Biome (2.1.2)
packages/web-backend/src/Videos/index.ts

[error] 1-1: Do not shadow the global "Array" property.

Consider renaming this variable. It's easy to confuse the origin of variables when they're named after a known global.

(lint/suspicious/noShadowRestrictedNames)

apps/web/app/(org)/dashboard/caps/Caps.tsx

[error] 248-251: This generator function doesn't contain yield.

(lint/correctness/useYield)


[error] 256-259: This generator function doesn't contain yield.

(lint/correctness/useYield)


[error] 260-262: This generator function doesn't contain yield.

(lint/correctness/useYield)

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

[error] 77-80: This generator function doesn't contain yield.

(lint/correctness/useYield)


[error] 81-83: This generator function doesn't contain yield.

(lint/correctness/useYield)


[error] 84-86: This generator function doesn't contain yield.

(lint/correctness/useYield)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Clippy
  • GitHub Check: Build Desktop (x86_64-pc-windows-msvc, windows-latest)
🔇 Additional comments (1)
packages/web-domain/src/Authentication.ts (1)

6-33: LGTM: cohesive auth context + middleware definitions.
Schemas and provided context align with HTTP/RPC usage elsewhere.

Comment on lines +181 to +193
if (prefix === null) {
const [videoSegment, audioSegment] = yield* Effect.all([
s3.listObjects({ prefix: videoPrefix, maxKeys: 1 }),
s3.listObjects({ prefix: audioPrefix, maxKeys: 1 }),
]);

const playlist = lines.join("\n");

return c.text(playlist, {
headers: CACHE_CONTROL_HEADERS,
});
}

if (video.source.type === "desktopMP4") {
const playlistUrl = await bucket.getSignedObjectUrl(
`${video.ownerId}/${videoId}/result.mp4`
let audioMetadata;
const videoMetadata = yield* s3.headObject(
videoSegment.Contents?.[0]?.Key ?? ""
);
if (audioSegment?.KeyCount && audioSegment?.KeyCount > 0) {
audioMetadata = yield* s3.headObject(
audioSegment.Contents?.[0]?.Key ?? ""
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Master playlist: fail with NotFound if no video chunk exists

Avoid headObject on an empty key; return a 404 when no video segments are present.

       if (prefix === null) {
         const [videoSegment, audioSegment] = yield* Effect.all([
           s3.listObjects({ prefix: videoPrefix, maxKeys: 1 }),
           s3.listObjects({ prefix: audioPrefix, maxKeys: 1 }),
         ]);
+        if (!videoSegment?.KeyCount || videoSegment.KeyCount === 0) {
+          return yield* Effect.fail(new HttpApiError.NotFound());
+        }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (prefix === null) {
const [videoSegment, audioSegment] = yield* Effect.all([
s3.listObjects({ prefix: videoPrefix, maxKeys: 1 }),
s3.listObjects({ prefix: audioPrefix, maxKeys: 1 }),
]);
const playlist = lines.join("\n");
return c.text(playlist, {
headers: CACHE_CONTROL_HEADERS,
});
}
if (video.source.type === "desktopMP4") {
const playlistUrl = await bucket.getSignedObjectUrl(
`${video.ownerId}/${videoId}/result.mp4`
let audioMetadata;
const videoMetadata = yield* s3.headObject(
videoSegment.Contents?.[0]?.Key ?? ""
);
if (audioSegment?.KeyCount && audioSegment?.KeyCount > 0) {
audioMetadata = yield* s3.headObject(
audioSegment.Contents?.[0]?.Key ?? ""
if (prefix === null) {
const [videoSegment, audioSegment] = yield* Effect.all([
s3.listObjects({ prefix: videoPrefix, maxKeys: 1 }),
s3.listObjects({ prefix: audioPrefix, maxKeys: 1 }),
]);
+ if (!videoSegment?.KeyCount || videoSegment.KeyCount === 0) {
+ return yield* Effect.fail(new HttpApiError.NotFound());
+ }
let audioMetadata;
const videoMetadata = yield* s3.headObject(
videoSegment.Contents?.[0]?.Key ?? ""
);
if (audioSegment?.KeyCount && audioSegment.KeyCount > 0) {
audioMetadata = yield* s3.headObject(
audioSegment.Contents?.[0]?.Key ?? ""
);
}
🤖 Prompt for AI Agents
In apps/web/app/api/playlist/route.ts around lines 181 to 193, the code calls
headObject on a potentially empty video segment key, which can cause errors.
Before calling headObject on the video segment, add a check to see if any video
segments exist; if none are found, immediately return a 404 NotFound response.
This prevents calling headObject with an empty key and properly handles the case
where no video chunks are available.

Comment on lines +215 to +218
prefix,
maxKeys: urlParams.thumbnail ? 1 : undefined,
});

Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Fix Option handling for thumbnail when setting maxKeys

thumbnail is an Option. Checking it as a boolean is always truthy. Use Option.isSome.

       const objects = yield* s3.listObjects({
         prefix,
-        maxKeys: urlParams.thumbnail ? 1 : undefined,
+        maxKeys: Option.isSome(urlParams.thumbnail) ? 1 : undefined,
       });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const objects = yield* s3.listObjects({
prefix,
maxKeys: urlParams.thumbnail ? 1 : undefined,
});
const objects = yield* s3.listObjects({
prefix,
maxKeys: Option.isSome(urlParams.thumbnail) ? 1 : undefined,
});
🤖 Prompt for AI Agents
In apps/web/app/api/playlist/route.ts around lines 215 to 218, the maxKeys
parameter is set based on urlParams.thumbnail, which is an Option type. The
current boolean check always evaluates to true because Option objects are
truthy. Replace the boolean check with Option.isSome(urlParams.thumbnail) to
correctly determine if the thumbnail option is present before setting maxKeys to
1.

Comment on lines +30 to +45
return handlers.handle("deleteVideo", ({ urlParams }) =>
videos.delete(urlParams.videoId).pipe(
Effect.catchTags({
VideoNotFoundError: () => new HttpApiError.NotFound(),
PolicyDenied: () => new HttpApiError.Unauthorized(),
DatabaseError: (e) =>
Effect.logError(e).pipe(
Effect.andThen(() => new HttpApiError.InternalServerError())
),
S3Error: (e) =>
Effect.logError(e).pipe(
Effect.andThen(() => new HttpApiError.InternalServerError())
),
})
)
);
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Return the declared success schema (boolean) from the handler.
After successful deletion, return true to satisfy the success schema.

-        return handlers.handle("deleteVideo", ({ urlParams }) =>
-          videos.delete(urlParams.videoId).pipe(
+        return handlers.handle("deleteVideo", ({ urlParams }) =>
+          videos.delete(urlParams.videoId).pipe(
+            Effect.as(true),
             Effect.catchTags({
-              VideoNotFoundError: () => new HttpApiError.NotFound(),
-              PolicyDenied: () => new HttpApiError.Unauthorized(),
-              DatabaseError: (e) =>
-                Effect.logError(e).pipe(
-                  Effect.andThen(() => new HttpApiError.InternalServerError())
-                ),
-              S3Error: (e) =>
-                Effect.logError(e).pipe(
-                  Effect.andThen(() => new HttpApiError.InternalServerError())
-                ),
+              VideoNotFoundError: () => Effect.fail(new HttpApiError.NotFound()),
+              PolicyDenied:     () => Effect.fail(new HttpApiError.Unauthorized()),
+              DatabaseError: (e) =>
+                Effect.logError(e).pipe(
+                  Effect.andThen(Effect.fail(new HttpApiError.InternalServerError()))
+                ),
+              S3Error: (e) =>
+                Effect.logError(e).pipe(
+                  Effect.andThen(Effect.fail(new HttpApiError.InternalServerError()))
+                ),
             })
           )
         );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return handlers.handle("deleteVideo", ({ urlParams }) =>
videos.delete(urlParams.videoId).pipe(
Effect.catchTags({
VideoNotFoundError: () => new HttpApiError.NotFound(),
PolicyDenied: () => new HttpApiError.Unauthorized(),
DatabaseError: (e) =>
Effect.logError(e).pipe(
Effect.andThen(() => new HttpApiError.InternalServerError())
),
S3Error: (e) =>
Effect.logError(e).pipe(
Effect.andThen(() => new HttpApiError.InternalServerError())
),
})
)
);
return handlers.handle("deleteVideo", ({ urlParams }) =>
videos.delete(urlParams.videoId).pipe(
Effect.as(true),
Effect.catchTags({
VideoNotFoundError: () => Effect.fail(new HttpApiError.NotFound()),
PolicyDenied: () => Effect.fail(new HttpApiError.Unauthorized()),
DatabaseError: (e) =>
Effect.logError(e).pipe(
Effect.andThen(Effect.fail(new HttpApiError.InternalServerError()))
),
S3Error: (e) =>
Effect.logError(e).pipe(
Effect.andThen(Effect.fail(new HttpApiError.InternalServerError()))
),
})
)
);
🤖 Prompt for AI Agents
In apps/web/app/api/video/delete/route.ts between lines 30 and 45, the handler
for "deleteVideo" does not return the declared success schema, which is a
boolean. After the deletion operation completes successfully, modify the handler
to return true to satisfy the expected success schema.

Comment on lines +277 to +313
throwOnError:
typeof throwOnError === "function"
? (error: Cause.Cause<TError>) => {
// this is safe because internally when we call useQuery we always throw the full cause or UnknownException
const cause = error as
| Cause.Cause<TError>
| Cause.UnknownException;

// if the cause is UnknownException, we always return true and throw it
if (Cause.isUnknownException(cause)) {
return true;
}

const failureOrCause = Cause.failureOrCause(cause);

if (throwOnDefect) {
// in this case options.throwOnError expects a TError

// the cause was a fail, so we have TError
if (Either.isLeft(failureOrCause)) {
// this is safe because if throwOnDefect is true then TExposedError is TError
const exposedError =
failureOrCause.left as unknown as TExposedError;
return throwOnError(exposedError);
} else {
// the cause was a die or interrupt, so we return true
return true;
}
} else {
// in this case options.throwOnError expects a Cause<TError>
// this is safe because if throwOnDefect is false then TExposedError is Cause<TError>
const exposedError = cause as unknown as TExposedError;
return throwOnError(exposedError);
}
}
: throwOnError,
onMutate:
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Fix mutation throwOnError signature and forward variables/context

The function form must accept (error, variables, context). Currently only error is forwarded, dropping variables/context.

-      throwOnError:
-        typeof throwOnError === "function"
-          ? (error: Cause.Cause<TError>) => {
+      throwOnError:
+        typeof throwOnError === "function"
+          ? (baseError: Cause.Cause<TError>, variables: TVariables, context: TContext | undefined) => {
               // this is safe because internally when we call useQuery we always throw the full cause or UnknownException
-              const cause = error as
+              const cause = baseError as
                 | Cause.Cause<TError>
                 | Cause.UnknownException;
 
               // if the cause is UnknownException, we always return true and throw it
               if (Cause.isUnknownException(cause)) {
                 return true;
               }
 
               const failureOrCause = Cause.failureOrCause(cause);
 
               if (throwOnDefect) {
                 // in this case options.throwOnError expects a TError
 
                 // the cause was a fail, so we have TError
                 if (Either.isLeft(failureOrCause)) {
                   // this is safe because if throwOnDefect is true then TExposedError is TError
                   const exposedError =
                     failureOrCause.left as unknown as TExposedError;
-                  return throwOnError(exposedError);
+                  return (throwOnError as (
+                    err: TExposedError,
+                    variables: TVariables,
+                    context: TContext | undefined
+                  ) => boolean)(exposedError, variables, context);
                 } else {
                   // the cause was a die or interrupt, so we return true
                   return true;
                 }
               } else {
                 // in this case options.throwOnError expects a Cause<TError>
                 // this is safe because if throwOnDefect is false then TExposedError is Cause<TError>
                 const exposedError = cause as unknown as TExposedError;
-                return throwOnError(exposedError);
+                return (throwOnError as (
+                  err: TExposedError,
+                  variables: TVariables,
+                  context: TContext | undefined
+                ) => boolean)(exposedError, variables, context);
               }
             }
           : throwOnError,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
throwOnError:
typeof throwOnError === "function"
? (error: Cause.Cause<TError>) => {
// this is safe because internally when we call useQuery we always throw the full cause or UnknownException
const cause = error as
| Cause.Cause<TError>
| Cause.UnknownException;
// if the cause is UnknownException, we always return true and throw it
if (Cause.isUnknownException(cause)) {
return true;
}
const failureOrCause = Cause.failureOrCause(cause);
if (throwOnDefect) {
// in this case options.throwOnError expects a TError
// the cause was a fail, so we have TError
if (Either.isLeft(failureOrCause)) {
// this is safe because if throwOnDefect is true then TExposedError is TError
const exposedError =
failureOrCause.left as unknown as TExposedError;
return throwOnError(exposedError);
} else {
// the cause was a die or interrupt, so we return true
return true;
}
} else {
// in this case options.throwOnError expects a Cause<TError>
// this is safe because if throwOnDefect is false then TExposedError is Cause<TError>
const exposedError = cause as unknown as TExposedError;
return throwOnError(exposedError);
}
}
: throwOnError,
onMutate:
throwOnError:
typeof throwOnError === "function"
? (baseError: Cause.Cause<TError>, variables: TVariables, context: TContext | undefined) => {
// this is safe because internally when we call useQuery we always throw the full cause or UnknownException
const cause = baseError as
| Cause.Cause<TError>
| Cause.UnknownException;
// if the cause is UnknownException, we always return true and throw it
if (Cause.isUnknownException(cause)) {
return true;
}
const failureOrCause = Cause.failureOrCause(cause);
if (throwOnDefect) {
// in this case options.throwOnError expects a TError
// the cause was a fail, so we have TError
if (Either.isLeft(failureOrCause)) {
// this is safe because if throwOnDefect is true then TExposedError is TError
const exposedError =
failureOrCause.left as unknown as TExposedError;
return (throwOnError as (
err: TExposedError,
variables: TVariables,
context: TContext | undefined
) => boolean)(exposedError, variables, context);
} else {
// the cause was a die or interrupt, so we return true
return true;
}
} else {
// in this case options.throwOnError expects a Cause<TError>
// this is safe because if throwOnDefect is false then TExposedError is Cause<TError>
const exposedError = cause as unknown as TExposedError;
return (throwOnError as (
err: TExposedError,
variables: TVariables,
context: TContext | undefined
) => boolean)(exposedError, variables, context);
}
}
: throwOnError,
onMutate:
🤖 Prompt for AI Agents
In apps/web/lib/effect-react-query.ts between lines 277 and 313, the
throwOnError function currently only accepts and forwards the error parameter,
but it should also accept and forward variables and context parameters to match
the expected signature. Update the function signature to include (error,
variables, context) and ensure these additional parameters are passed along when
calling throwOnError inside the function.

Comment on lines +33 to +37
const [video] = yield* repo
.getById(videoId)
.pipe(
Effect.flatMap(Effect.catchAll(() => new Video.NotFoundError()))
);
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Incorrect error handling: catchAll returns a value, not a failure.
This converts repository failures into success values. Map to a failure instead.

-        const [video] = yield* repo
-          .getById(videoId)
-          .pipe(
-            Effect.flatMap(Effect.catchAll(() => new Video.NotFoundError()))
-          );
+        const [video] = yield* repo
+          .getById(videoId)
+          .pipe(
+            Effect.catchAll(() => Effect.fail(new Video.NotFoundError()))
+          );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const [video] = yield* repo
.getById(videoId)
.pipe(
Effect.flatMap(Effect.catchAll(() => new Video.NotFoundError()))
);
const [video] = yield* repo
.getById(videoId)
.pipe(
Effect.catchAll(() => Effect.fail(new Video.NotFoundError()))
);
🤖 Prompt for AI Agents
In packages/web-backend/src/Videos/index.ts around lines 33 to 37, the current
use of Effect.catchAll converts repository failures into success values by
returning a new Video.NotFoundError instance. Instead, modify the error handling
to map the caught error into a failure effect, ensuring that the error
propagates correctly as a failure rather than a success value. Use an operator
like Effect.fail or equivalent to convert the error into a failure effect.

Comment on lines +55 to +61
if (listedObjects.Contents?.length) {
yield* s3.deleteObjects(
listedObjects.Contents.map((content) => ({
Key: content.Key,
}))
);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

S3 delete may send undefined keys. Filter nulls before delete.
S3 object listings can have undefined Key; guard before calling deleteObjects.

-          if (listedObjects.Contents?.length) {
-            yield* s3.deleteObjects(
-              listedObjects.Contents.map((content) => ({
-                Key: content.Key,
-              }))
-            );
-          }
+          if (listedObjects.Contents?.length) {
+            const keys = EffectArray.filterMap(
+              listedObjects.Contents,
+              (obj) => Option.fromNullable(obj.Key)
+            );
+            if (keys.length > 0) {
+              yield* s3.deleteObjects(keys.map((key) => ({ Key: key })));
+            }
+          }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (listedObjects.Contents?.length) {
yield* s3.deleteObjects(
listedObjects.Contents.map((content) => ({
Key: content.Key,
}))
);
}
if (listedObjects.Contents?.length) {
const keys = EffectArray.filterMap(
listedObjects.Contents,
(obj) => Option.fromNullable(obj.Key)
);
if (keys.length > 0) {
yield* s3.deleteObjects(keys.map((key) => ({ Key: key })));
}
}
🤖 Prompt for AI Agents
In packages/web-backend/src/Videos/index.ts around lines 55 to 61, the code
calls s3.deleteObjects with keys that may be undefined because some
listedObjects.Contents entries can have undefined Key values. To fix this,
filter the listedObjects.Contents array to exclude any entries where Key is
undefined before mapping and passing to deleteObjects, ensuring only valid keys
are sent for deletion.

Comment on lines +72 to +77
const [video] = yield* repo
.getById(videoId)
.pipe(
Effect.flatMap(Effect.catchAll(() => new Video.NotFoundError())),
Policy.withPolicy(policy.isOwner(videoId))
);
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Same error-handling bug in duplicate().
Don’t return a NotFoundError as a success value.

-        const [video] = yield* repo
-          .getById(videoId)
-          .pipe(
-            Effect.flatMap(Effect.catchAll(() => new Video.NotFoundError())),
-            Policy.withPolicy(policy.isOwner(videoId))
-          );
+        const [video] = yield* repo
+          .getById(videoId)
+          .pipe(
+            Effect.catchAll(() => Effect.fail(new Video.NotFoundError())),
+            Policy.withPolicy(policy.isOwner(videoId))
+          );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const [video] = yield* repo
.getById(videoId)
.pipe(
Effect.flatMap(Effect.catchAll(() => new Video.NotFoundError())),
Policy.withPolicy(policy.isOwner(videoId))
);
const [video] = yield* repo
.getById(videoId)
.pipe(
Effect.catchAll(() => Effect.fail(new Video.NotFoundError())),
Policy.withPolicy(policy.isOwner(videoId))
);
🤖 Prompt for AI Agents
In packages/web-backend/src/Videos/index.ts around lines 72 to 77, the error
handling in the getById call incorrectly returns a NotFoundError as a success
value. Instead of returning the error object, modify the code to properly raise
or throw the NotFoundError so that it is handled as a failure, not a successful
result. Adjust the Effect.catchAll logic to re-raise the error or fail the
effect rather than returning the error instance.

Comment on lines +95 to +104
if (allObjects.Contents)
yield* Effect.all(
Array.filterMap(allObjects.Contents, (obj) =>
Option.map(Option.fromNullable(obj.Key), (key) => {
const newKey = key.replace(prefix, newPrefix);
return s3.copyObject(`${bucketName}/${obj.Key}`, newKey);
})
),
{ concurrency: 1 }
);
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Use aliased EffectArray and the safe key for copyObject.
Avoids undefined keys and shadowing.

-          if (allObjects.Contents)
-            yield* Effect.all(
-              Array.filterMap(allObjects.Contents, (obj) =>
-                Option.map(Option.fromNullable(obj.Key), (key) => {
-                  const newKey = key.replace(prefix, newPrefix);
-                  return s3.copyObject(`${bucketName}/${obj.Key}`, newKey);
-                })
-              ),
-              { concurrency: 1 }
-            );
+          if (allObjects.Contents) {
+            yield* Effect.all(
+              EffectArray.filterMap(allObjects.Contents, (obj) =>
+                Option.map(Option.fromNullable(obj.Key), (key) => {
+                  const newKey = key.replace(prefix, newPrefix);
+                  return s3.copyObject(`${bucketName}/${key}`, newKey);
+                })
+              ),
+              { concurrency: 1 }
+            );
+          }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (allObjects.Contents)
yield* Effect.all(
Array.filterMap(allObjects.Contents, (obj) =>
Option.map(Option.fromNullable(obj.Key), (key) => {
const newKey = key.replace(prefix, newPrefix);
return s3.copyObject(`${bucketName}/${obj.Key}`, newKey);
})
),
{ concurrency: 1 }
);
if (allObjects.Contents) {
yield* Effect.all(
EffectArray.filterMap(allObjects.Contents, (obj) =>
Option.map(Option.fromNullable(obj.Key), (key) => {
const newKey = key.replace(prefix, newPrefix);
return s3.copyObject(`${bucketName}/${key}`, newKey);
})
),
{ concurrency: 1 }
);
}
🤖 Prompt for AI Agents
In packages/web-backend/src/Videos/index.ts around lines 95 to 104, the code
should use the aliased EffectArray instead of Effect.all for better clarity and
consistency. Also, ensure that the key passed to copyObject is safely accessed
to avoid undefined values and variable shadowing. Replace the current Effect.all
call with EffectArray.all and use the safely extracted key variable when calling
copyObject.

@Brendonovich Brendonovich merged commit ee6377f into main Aug 12, 2025
7 of 8 checks passed
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