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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions drizzle/0001_add_realtime_collaboration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
-- =============================================================================
-- Workspace Collaborators Table for Real-Time Collaboration
-- =============================================================================

CREATE TABLE "workspace_collaborators" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"workspace_id" uuid NOT NULL,
"user_id" text NOT NULL,
"permission_level" text DEFAULT 'editor' NOT NULL,
"invite_token" text,
"created_at" timestamp with time zone DEFAULT now(),
CONSTRAINT "workspace_collaborators_invite_token_unique" UNIQUE("invite_token"),
CONSTRAINT "workspace_collaborators_workspace_user_unique" UNIQUE("workspace_id", "user_id")
);
--> statement-breakpoint
ALTER TABLE "workspace_collaborators" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "workspace_collaborators" ADD CONSTRAINT "workspace_collaborators_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_workspace_collaborators_lookup" ON "workspace_collaborators" USING btree ("user_id" text_ops, "workspace_id" uuid_ops);--> statement-breakpoint
CREATE INDEX "idx_workspace_collaborators_workspace" ON "workspace_collaborators" USING btree ("workspace_id" uuid_ops);--> statement-breakpoint

-- RLS Policies for workspace_collaborators
CREATE POLICY "Owners can manage collaborators" ON "workspace_collaborators" AS PERMISSIVE FOR ALL TO "authenticated"
USING (EXISTS (
SELECT 1 FROM workspaces w
WHERE w.id = workspace_collaborators.workspace_id
AND w.user_id = (auth.jwt() ->> 'sub'::text)
));--> statement-breakpoint

CREATE POLICY "Collaborators can view their access" ON "workspace_collaborators" AS PERMISSIVE FOR SELECT TO "authenticated"
USING (user_id = (auth.jwt() ->> 'sub'::text));--> statement-breakpoint

-- =============================================================================
-- Broadcast Trigger for workspace_events
-- Automatically broadcasts when an event is inserted
-- =============================================================================

CREATE OR REPLACE FUNCTION workspace_events_broadcast_trigger()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
-- Broadcast the event to the workspace channel
-- All clients subscribed to workspace:<id>:events will receive this
PERFORM realtime.broadcast_changes(
'workspace:' || NEW.workspace_id::text || ':events',
TG_OP, -- operation type: INSERT
TG_OP, -- event name: INSERT
TG_TABLE_NAME, -- table name
TG_TABLE_SCHEMA, -- schema
NEW, -- new row data
OLD -- old row data (null for INSERT)
);
RETURN NEW;
END;
$$;--> statement-breakpoint

CREATE TRIGGER workspace_events_realtime_broadcast
AFTER INSERT ON workspace_events
FOR EACH ROW EXECUTE FUNCTION workspace_events_broadcast_trigger();--> statement-breakpoint

-- =============================================================================
-- RLS Policies for realtime.messages
-- Authorizes who can subscribe/publish to workspace channels
-- =============================================================================

-- Allow workspace owner OR collaborators to receive events
CREATE POLICY "workspace_access_can_read" ON realtime.messages
FOR SELECT TO authenticated
USING (
topic LIKE 'workspace:%:events'
AND (
-- Owner access
EXISTS (
SELECT 1 FROM public.workspaces w
WHERE w.id = (SPLIT_PART(topic, ':', 2))::uuid
AND w.user_id = (auth.jwt() ->> 'sub'::text)
)
OR
-- Collaborator access
EXISTS (
SELECT 1 FROM public.workspace_collaborators c
WHERE c.workspace_id = (SPLIT_PART(topic, ':', 2))::uuid
AND c.user_id = (auth.jwt() ->> 'sub'::text)
)
)
);--> statement-breakpoint

-- Allow workspace owner OR editor collaborators to send events (presence)
CREATE POLICY "workspace_access_can_write" ON realtime.messages
FOR INSERT TO authenticated
WITH CHECK (
topic LIKE 'workspace:%:events'
AND (
-- Owner access
EXISTS (
SELECT 1 FROM public.workspaces w
WHERE w.id = (SPLIT_PART(topic, ':', 2))::uuid
AND w.user_id = (auth.jwt() ->> 'sub'::text)
)
OR
-- Editor collaborator access
EXISTS (
SELECT 1 FROM public.workspace_collaborators c
WHERE c.workspace_id = (SPLIT_PART(topic, ':', 2))::uuid
AND c.user_id = (auth.jwt() ->> 'sub'::text)
AND c.permission_level = 'editor'
)
)
);--> statement-breakpoint

-- =============================================================================
-- Update existing RLS policies on workspace_events to include collaborators
-- =============================================================================

Comment on lines +95 to +115

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Migration adds realtime.messages RLS policies, but app uses broadcast channels instead

This migration sets RLS policies on realtime.messages for topics like workspace:%:events, but the client implementation (src/hooks/workspace/use-workspace-realtime.ts) uses a broadcast channel named workspace-${workspaceId} and explicitly says "no RLS needed". As written, the realtime.messages policies and the workspace_events_broadcast_trigger() appear unused, and they add operational complexity/migration surface area.

If the intended architecture is broadcast-only, consider removing the trigger + realtime.messages policies. If the intended architecture is DB-triggered changes, the client should subscribe to the topic format the policies protect.

Prompt To Fix With AI
This is a comment left during a code review.
Path: drizzle/0001_add_realtime_collaboration.sql
Line: 95:115

Comment:
[P2] Migration adds `realtime.messages` RLS policies, but app uses broadcast channels instead

This migration sets RLS policies on `realtime.messages` for topics like `workspace:%:events`, but the client implementation (`src/hooks/workspace/use-workspace-realtime.ts`) uses a broadcast channel named `workspace-${workspaceId}` and explicitly says "no RLS needed". As written, the `realtime.messages` policies and the `workspace_events_broadcast_trigger()` appear unused, and they add operational complexity/migration surface area.

If the intended architecture is broadcast-only, consider removing the trigger + `realtime.messages` policies. If the intended architecture is DB-triggered changes, the client should subscribe to the topic format the policies protect.

How can I resolve this? If you propose a fix, please make it concise.

-- Drop old policy that only allows owner
DROP POLICY IF EXISTS "Users can insert workspace events they have write access to" ON "workspace_events";--> statement-breakpoint

-- New policy: owners AND editor collaborators can insert events
CREATE POLICY "Users can insert workspace events they have write access to" ON "workspace_events" AS PERMISSIVE FOR INSERT TO public
WITH CHECK (
-- Owner access
EXISTS (
SELECT 1 FROM workspaces
WHERE workspaces.id = workspace_events.workspace_id
AND workspaces.user_id = (auth.jwt() ->> 'sub'::text)
)
OR
-- Editor collaborator access
EXISTS (
SELECT 1 FROM workspace_collaborators c
WHERE c.workspace_id = workspace_events.workspace_id
AND c.user_id = (auth.jwt() ->> 'sub'::text)
AND c.permission_level = 'editor'
)
);--> statement-breakpoint
Comment on lines +120 to +136

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Policy target inconsistent: TO public vs TO authenticated.

The workspace_events insert policy uses TO public (Line 120), while other policies in this migration use TO authenticated. While the WITH CHECK clause requires a valid JWT, using TO public is inconsistent and could be confusing. The SELECT policy at Line 141 also uses TO public.

🛡️ Suggested fix for consistency
-CREATE POLICY "Users can insert workspace events they have write access to" ON "workspace_events" AS PERMISSIVE FOR INSERT TO public 
+CREATE POLICY "Users can insert workspace events they have write access to" ON "workspace_events" AS PERMISSIVE FOR INSERT TO authenticated 
 WITH CHECK (
   -- ... rest of policy
 );

-CREATE POLICY "Users can read workspace events they have access to" ON "workspace_events" AS PERMISSIVE FOR SELECT TO public
+CREATE POLICY "Users can read workspace events they have access to" ON "workspace_events" AS PERMISSIVE FOR SELECT TO authenticated
 USING (
   -- ... rest of policy
 );
🤖 Prompt for AI Agents
In `@drizzle/0001_add_realtime_collaboration.sql` around lines 120 - 136, The
INSERT policy definition named "Users can insert workspace events they have
write access to" on the workspace_events table incorrectly uses TO public;
update it to TO authenticated to match other policies (and likewise change the
later SELECT policy that also uses TO public to TO authenticated) so the policy
target is consistent with JWT-protected checks in the WITH CHECK clauses (look
for the CREATE POLICY statements referencing workspace_events and the SELECT
policy near the same migration block).


-- Update select policy to include collaborators
DROP POLICY IF EXISTS "Users can read workspace events they have access to" ON "workspace_events";--> statement-breakpoint

CREATE POLICY "Users can read workspace events they have access to" ON "workspace_events" AS PERMISSIVE FOR SELECT TO public
USING (
-- Owner access
EXISTS (
SELECT 1 FROM workspaces
WHERE workspaces.id = workspace_events.workspace_id
AND workspaces.user_id = (auth.jwt() ->> 'sub'::text)
)
OR
-- Collaborator access (viewer or editor)
EXISTS (
SELECT 1 FROM workspace_collaborators c
WHERE c.workspace_id = workspace_events.workspace_id
AND c.user_id = (auth.jwt() ->> 'sub'::text)
)
);
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@
"postgres": "^3.4.7",
"posthog-js": "^1.335.5",
"posthog-node": "^5.21.1",
"radix-ui": "^1.4.3",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check which `@radix-ui` packages are currently used and where
echo "=== Individual `@radix-ui` packages in package.json ==="
rg '@radix-ui/react-' package.json

echo ""
echo "=== Files importing from `@radix-ui/`* ==="
rg "from ['\"]@radix-ui" --type ts --type tsx -l 2>/dev/null | head -20

echo ""
echo "=== Files importing from unified radix-ui ==="
rg "from ['\"]radix-ui" --type ts --type tsx -l 2>/dev/null | head -20

echo ""
echo "=== Radix packages section in package.json ==="
sed -n '60,120p' package.json

Repository: ThinkEx-OSS/thinkex

Length of output: 3127


🏁 Script executed:

#!/bin/bash
# Search for any imports in all file types
echo "=== All imports from `@radix-ui` (any file type) ==="
rg "import.*from\s+['\"]@radix-ui" -g "!node_modules" 2>/dev/null | head -30

echo ""
echo "=== All imports from radix-ui (any file type) ==="
rg "import.*from\s+['\"]radix-ui" -g "!node_modules" 2>/dev/null | head -30

echo ""
echo "=== Check if any components exist in src directory ==="
find src -type f \( -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.jsx" \) 2>/dev/null | head -10

echo ""
echo "=== Look for select.tsx mentioned in comment ==="
find . -name "select.tsx" -type f 2>/dev/null

echo ""
echo "=== Check for ui components directory pattern ==="
find . -type d -name "ui" 2>/dev/null | grep -v node_modules

Repository: ThinkEx-OSS/thinkex

Length of output: 2639


🌐 Web query:

radix-ui unified package 1.4.3 compatibility with individual react-select packages

💡 Result:

radix-ui@1.4.3 (the unified package) already depends on and bundles the individual primitives packages—including @radix-ui/react-select@2.2.6—and re-exports them under the radix-ui entrypoints. [1]

So, compatibility-wise:

  • Safe / recommended: use only radix-ui imports (e.g. import { Select } from "radix-ui"). This is the intended unified-package usage. [2][3]
  • Also possible but watch out: installing/using @radix-ui/react-select alongside radix-ui@1.4.3 is only “compatible” if your dependency graph resolves to the same @radix-ui/react-select version (2.2.6). If it resolves to a different version, you can end up with duplicate Radix packages in node_modules, which commonly causes runtime bugs (context mismatch, refs/portals/focus-scope behaving oddly).

If you must mix (monorepo / incremental migration), pin versions so there’s only one copy:

  • Ensure @radix-ui/react-select is exactly 2.2.6 (matching radix-ui@1.4.3), or use overrides/resolutions to force that. [1]

Sources:
[1] radix-ui@1.4.3 package.json dependency list (shows @radix-ui/react-select: 2.2.6) (cdn.jsdelivr.net)
[2] Radix Select docs showing import { Select } from "radix-ui" (radix-ui.com)
[3] shadcn/ui note about the unified radix-ui package replacing individual @radix-ui/react-* deps (ui.shadcn.com)

Citations:


Consolidate Radix UI packages to avoid runtime bugs from version mismatch.

The codebase mixes the unified radix-ui ^1.4.3 package (used only in select.tsx) with individual @radix-ui/* packages (used in 20+ other component files). Since radix-ui@1.4.3 bundles @radix-ui/react-select@2.2.6 internally, having both creates duplicate Radix packages in node_modules with potential version conflicts.

This can cause runtime issues including context mismatch, ref-forwarding failures, and portal/focus-scope bugs.

Migrate all Radix imports to either:

  1. The unified package (import from "radix-ui" everywhere, remove individual @radix-ui/* dependencies), or
  2. Individual scoped packages only (revert select.tsx to import from "@radix-ui/react-select", remove radix-ui), ensuring all versions match the bundled versions in the unified package.
🤖 Prompt for AI Agents
In `@package.json` at line 114, Remove the unified "radix-ui" dependency and
restore the scoped package usage to avoid duplicate Radix modules: update
package.json to remove the "radix-ui" entry and ensure "@radix-ui/react-select"
is listed with the same version range as the other `@radix-ui/`* packages; then
change the import in select.tsx back to import from "@radix-ui/react-select"
(replacing any imports from "radix-ui") so all components consistently use the
individual scoped packages and avoid context/version mismatches.

"react": "19.2.1",
"react-color": "^2.19.3",
"react-dom": "19.2.1",
Expand Down
105 changes: 105 additions & 0 deletions src/app/api/workspaces/[id]/collaborators/[collaboratorId]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* Collaborator API - Update and delete individual collaborators
*
* PATCH /api/workspaces/[id]/collaborators/[collaboratorId] - Update permission
* DELETE /api/workspaces/[id]/collaborators/[collaboratorId] - Remove collaborator
*/

import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db/client";
import { workspaceCollaborators } from "@/lib/db/schema";
import { eq, and } from "drizzle-orm";
import { verifyWorkspaceOwnership } from "@/lib/api/workspace-helpers";

export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string; collaboratorId: string }> }
) {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const { id: workspaceId, collaboratorId } = await params;
const body = await request.json();
const { permissionLevel } = body;

if (!permissionLevel || !["viewer", "editor"].includes(permissionLevel)) {
return NextResponse.json({ error: "Invalid permission level" }, { status: 400 });
}

// Verify ownership
try {
Comment on lines +29 to +35

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P1] Swallowing ownership-check errors returns the wrong status code

verifyWorkspaceOwnership() throws a NextResponse with either 404 (not found) or 403 (access denied), but the catch { return 404 } block collapses both cases into a 404. That makes unauthorized users look like the workspace doesn't exist, and also hides real 403s from the client.

Consider:

  • catch (e) { if (e instanceof Response) return e; throw e; }

Also appears in DELETE in the same file.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/api/workspaces/[id]/collaborators/[collaboratorId]/route.ts
Line: 29:35

Comment:
[P1] Swallowing ownership-check errors returns the wrong status code

`verifyWorkspaceOwnership()` throws a `NextResponse` with either 404 (not found) or 403 (access denied), but the `catch { return 404 }` block collapses both cases into a 404. That makes unauthorized users look like the workspace doesn't exist, and also hides real 403s from the client.

Consider:
- `catch (e) { if (e instanceof Response) return e; throw e; }`

Also appears in `DELETE` in the same file.

How can I resolve this? If you propose a fix, please make it concise.

await verifyWorkspaceOwnership(workspaceId, session.user.id);
} catch (error) {
if (error instanceof Response) return error;
return NextResponse.json({ error: "Workspace not found" }, { status: 404 });
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Update the collaborator
const [updated] = await db
.update(workspaceCollaborators)
.set({ permissionLevel })
.where(
and(
eq(workspaceCollaborators.id, collaboratorId),
eq(workspaceCollaborators.workspaceId, workspaceId)
)
)
.returning();

if (!updated) {
return NextResponse.json({ error: "Collaborator not found" }, { status: 404 });
}

return NextResponse.json({ collaborator: updated });
} catch (error) {
console.error("Error updating collaborator:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string; collaboratorId: string }> }
) {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const { id: workspaceId, collaboratorId } = await params;

// Verify ownership
try {
await verifyWorkspaceOwnership(workspaceId, session.user.id);
} catch (error) {
if (error instanceof Response) return error;
return NextResponse.json({ error: "Workspace not found" }, { status: 404 });
}

// Delete the collaborator
const [deleted] = await db
.delete(workspaceCollaborators)
.where(
and(
eq(workspaceCollaborators.id, collaboratorId),
eq(workspaceCollaborators.workspaceId, workspaceId)
)
)
.returning();

if (!deleted) {
return NextResponse.json({ error: "Collaborator not found" }, { status: 404 });
}

return NextResponse.json({ success: true });
} catch (error) {
console.error("Error deleting collaborator:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
Loading