From 0e8fc730ae0a48b87e1cd685e328033e0233bcf3 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Mon, 2 Feb 2026 22:33:01 -0500 Subject: [PATCH 1/5] feat: collab workspaces --- drizzle/0001_add_realtime_collaboration.sql | 156 ++++++++ package.json | 1 + .../collaborators/[collaboratorId]/route.ts | 103 +++++ .../workspaces/[id]/collaborators/route.ts | 168 ++++++++ src/app/api/workspaces/[id]/events/route.ts | 364 +++++++++--------- src/app/api/workspaces/[id]/route.ts | 77 ++-- src/app/api/workspaces/[id]/snapshot/route.ts | 76 ++-- .../api/workspaces/[id]/snapshots/route.ts | 36 +- .../api/workspaces/[id]/track-open/route.ts | 12 +- src/app/api/workspaces/route.ts | 56 ++- src/app/api/workspaces/slug/[slug]/route.ts | 63 ++- src/app/dashboard/page.tsx | 13 +- src/components/ui/select.tsx | 190 +++++++++ .../workspace-canvas/WorkspaceHeader.tsx | 4 + .../workspace/CollaboratorAvatars.tsx | 76 ++++ .../workspace/ShareWorkspaceDialog.tsx | 361 +++++++++++++++-- src/contexts/RealtimeContext.tsx | 96 +++++ src/hooks/workspace/use-workspace-mutation.ts | 23 +- .../workspace/use-workspace-operations.ts | 32 +- src/hooks/workspace/use-workspace-presence.ts | 121 ++++++ src/hooks/workspace/use-workspace-realtime.ts | 184 +++++++++ src/lib/api/workspace-helpers.ts | 56 ++- src/lib/db/schema.ts | 35 +- src/lib/db/types.ts | 8 +- src/lib/supabase-client.ts | 46 +++ 25 files changed, 2005 insertions(+), 352 deletions(-) create mode 100644 drizzle/0001_add_realtime_collaboration.sql create mode 100644 src/app/api/workspaces/[id]/collaborators/[collaboratorId]/route.ts create mode 100644 src/app/api/workspaces/[id]/collaborators/route.ts create mode 100644 src/components/ui/select.tsx create mode 100644 src/components/workspace/CollaboratorAvatars.tsx create mode 100644 src/contexts/RealtimeContext.tsx create mode 100644 src/hooks/workspace/use-workspace-presence.ts create mode 100644 src/hooks/workspace/use-workspace-realtime.ts create mode 100644 src/lib/supabase-client.ts diff --git a/drizzle/0001_add_realtime_collaboration.sql b/drizzle/0001_add_realtime_collaboration.sql new file mode 100644 index 00000000..61908122 --- /dev/null +++ b/drizzle/0001_add_realtime_collaboration.sql @@ -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::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 +-- ============================================================================= + +-- 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 + +-- 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) + ) +); diff --git a/package.json b/package.json index 00e30153..969532a6 100644 --- a/package.json +++ b/package.json @@ -111,6 +111,7 @@ "postgres": "^3.4.7", "posthog-js": "^1.335.5", "posthog-node": "^5.21.1", + "radix-ui": "^1.4.3", "react": "19.2.1", "react-color": "^2.19.3", "react-dom": "19.2.1", diff --git a/src/app/api/workspaces/[id]/collaborators/[collaboratorId]/route.ts b/src/app/api/workspaces/[id]/collaborators/[collaboratorId]/route.ts new file mode 100644 index 00000000..5b7e8d0c --- /dev/null +++ b/src/app/api/workspaces/[id]/collaborators/[collaboratorId]/route.ts @@ -0,0 +1,103 @@ +/** + * 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 { + await verifyWorkspaceOwnership(workspaceId, session.user.id); + } catch { + return NextResponse.json({ error: "Workspace not found" }, { status: 404 }); + } + + // 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 { + 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 }); + } +} diff --git a/src/app/api/workspaces/[id]/collaborators/route.ts b/src/app/api/workspaces/[id]/collaborators/route.ts new file mode 100644 index 00000000..37c9ccf5 --- /dev/null +++ b/src/app/api/workspaces/[id]/collaborators/route.ts @@ -0,0 +1,168 @@ +/** + * Collaborators API - List and invite collaborators + * + * GET /api/workspaces/[id]/collaborators - List collaborators + * POST /api/workspaces/[id]/collaborators - Invite a new 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, workspaces, user } from "@/lib/db/schema"; +import { eq, and } from "drizzle-orm"; +import { verifyWorkspaceAccess } from "@/lib/api/workspace-helpers"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: 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 } = await params; + + // Verify access (viewers can see collaborators) + try { + await verifyWorkspaceAccess(workspaceId, session.user.id, "viewer"); + } catch (error) { + if (error instanceof Response) return error; + return NextResponse.json({ error: "Workspace not found" }, { status: 404 }); + } + + + // Get owner details + const [workspaceOwner] = await db + .select({ + userId: user.id, + name: user.name, + email: user.email, + image: user.image, + createdAt: workspaces.createdAt, + }) + .from(workspaces) + .leftJoin(user, eq(workspaces.userId, user.id)) + .where(eq(workspaces.id, workspaceId)); + + // Get collaborators with user info + const collaborators = await db + .select({ + id: workspaceCollaborators.id, + userId: workspaceCollaborators.userId, + permissionLevel: workspaceCollaborators.permissionLevel, + createdAt: workspaceCollaborators.createdAt, + name: user.name, + email: user.email, + image: user.image, + }) + .from(workspaceCollaborators) + .leftJoin(user, eq(workspaceCollaborators.userId, user.id)) + .where(eq(workspaceCollaborators.workspaceId, workspaceId)); + + const ownerAsCollaborator = workspaceOwner ? { + id: `owner-${workspaceOwner.userId}`, + userId: workspaceOwner.userId, + permissionLevel: "owner", + createdAt: workspaceOwner.createdAt, + name: workspaceOwner.name, + email: workspaceOwner.email, + image: workspaceOwner.image + } : null; + + const allCollaborators = ownerAsCollaborator + ? [ownerAsCollaborator, ...collaborators] + : collaborators; + + return NextResponse.json({ collaborators: allCollaborators }); + } catch (error) { + console.error("Error fetching collaborators:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: 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 } = await params; + const body = await request.json(); + const { email, permissionLevel = "editor" } = body; + + if (!email || typeof email !== "string") { + return NextResponse.json({ error: "Email is required" }, { status: 400 }); + } + + // Verify access (only editors/owners can invite) + try { + await verifyWorkspaceAccess(workspaceId, session.user.id, "editor"); + } catch (error) { + if (error instanceof Response) return error; + return NextResponse.json({ error: "Workspace not found" }, { status: 404 }); + } + + // Find the user by email + const [invitedUser] = await db + .select({ id: user.id }) + .from(user) + .where(eq(user.email, email.trim().toLowerCase())) + .limit(1); + + if (!invitedUser) { + return NextResponse.json( + { message: "User not found. They need to sign up first." }, + { status: 404 } + ); + } + + // Check if already a collaborator + const [existing] = await db + .select({ id: workspaceCollaborators.id }) + .from(workspaceCollaborators) + .where( + and( + eq(workspaceCollaborators.workspaceId, workspaceId), + eq(workspaceCollaborators.userId, invitedUser.id) + ) + ) + .limit(1); + + if (existing) { + return NextResponse.json( + { message: "User is already a collaborator" }, + { status: 409 } + ); + } + + // Can't invite yourself + if (invitedUser.id === session.user.id) { + return NextResponse.json( + { message: "You can't invite yourself" }, + { status: 400 } + ); + } + + // Add collaborator + const [newCollaborator] = await db + .insert(workspaceCollaborators) + .values({ + workspaceId, + userId: invitedUser.id, + permissionLevel: permissionLevel === "viewer" ? "viewer" : "editor", + }) + .returning(); + + return NextResponse.json({ collaborator: newCollaborator }, { status: 201 }); + } catch (error) { + console.error("Error adding collaborator:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/workspaces/[id]/events/route.ts b/src/app/api/workspaces/[id]/events/route.ts index ef481b1a..bf80ab52 100644 --- a/src/app/api/workspaces/[id]/events/route.ts +++ b/src/app/api/workspaces/[id]/events/route.ts @@ -3,11 +3,11 @@ import type { WorkspaceEvent, EventResponse } from "@/lib/workspace/events"; import { checkAndCreateSnapshot } from "@/lib/workspace/snapshot-manager"; import { db, workspaceEvents } from "@/lib/db/client"; import { eq, gt, asc, sql, and } from "drizzle-orm"; -import { requireAuth, verifyWorkspaceOwnership, withErrorHandling } from "@/lib/api/workspace-helpers"; +import { requireAuth, verifyWorkspaceAccess, withErrorHandling } from "@/lib/api/workspace-helpers"; /** * GET /api/workspaces/[id]/events - * Fetch all events for a workspace (owner only) + * Fetch all events for a workspace (owner or collaborator) */ async function handleGET( request: NextRequest, @@ -15,11 +15,11 @@ async function handleGET( ) { const startTime = Date.now(); const timings: Record = {}; - + // Start independent operations in parallel const paramsPromise = params; const authPromise = requireAuth(); - + const paramsResolved = await paramsPromise; const id = paramsResolved.id; @@ -27,15 +27,15 @@ async function handleGET( const userId = await authPromise; timings.auth = Date.now() - authStart; - // Check if user is workspace owner + // Check if user has access (owner or collaborator) const workspaceCheckStart = Date.now(); - await verifyWorkspaceOwnership(id, userId); + await verifyWorkspaceAccess(id, userId, 'viewer'); timings.workspaceCheck = Date.now() - workspaceCheckStart; - // Get only the latest snapshot (not all snapshots - loaded on demand for version history) - // Use optimized function that bypasses RLS (access already verified above) - const snapshotStart = Date.now(); - const latestSnapshotData = await db.execute(sql` + // Get only the latest snapshot (not all snapshots - loaded on demand for version history) + // Use optimized function that bypasses RLS (access already verified above) + const snapshotStart = Date.now(); + const latestSnapshotData = await db.execute(sql` SELECT id, snapshot_version as "snapshotVersion", @@ -44,46 +44,46 @@ async function handleGET( created_at as "createdAt" FROM get_latest_snapshot_fast(${id}::uuid) `); - timings.snapshotFetch = Date.now() - snapshotStart; - - const latestSnapshot = latestSnapshotData[0] as { - id?: string; - snapshotVersion?: number; - state?: any; - eventCount?: number; - createdAt?: string; - } | undefined; - const snapshotVersion = typeof latestSnapshot?.snapshotVersion === 'number' - ? latestSnapshot.snapshotVersion - : 0; - - // Check how many events we need to fetch - const countStart = Date.now(); - const eventCountResult = await db - .select({ count: sql`count(*)::int` }) - .from(workspaceEvents) - .where( - and( - eq(workspaceEvents.workspaceId, id), - gt(workspaceEvents.version, snapshotVersion) - ) - ); - const eventCount = eventCountResult[0]?.count ?? 0; - timings.countQuery = Date.now() - countStart; - - // Only fetch events AFTER the snapshot version - const PAGE_SIZE = 1000; - let eventsData: any[] = []; - - if (eventCount === 0) { - timings.eventsFetch = 0; - } else if (eventCount <= PAGE_SIZE) { - // If we have fewer events than PAGE_SIZE, fetch all at once (no pagination needed) - const eventsFetchStart = Date.now(); - - // Use optimized function that bypasses RLS (access already verified above) - const queryStart = Date.now(); - const fastQueryResult = await db.execute(sql` + timings.snapshotFetch = Date.now() - snapshotStart; + + const latestSnapshot = latestSnapshotData[0] as { + id?: string; + snapshotVersion?: number; + state?: any; + eventCount?: number; + createdAt?: string; + } | undefined; + const snapshotVersion = typeof latestSnapshot?.snapshotVersion === 'number' + ? latestSnapshot.snapshotVersion + : 0; + + // Check how many events we need to fetch + const countStart = Date.now(); + const eventCountResult = await db + .select({ count: sql`count(*)::int` }) + .from(workspaceEvents) + .where( + and( + eq(workspaceEvents.workspaceId, id), + gt(workspaceEvents.version, snapshotVersion) + ) + ); + const eventCount = eventCountResult[0]?.count ?? 0; + timings.countQuery = Date.now() - countStart; + + // Only fetch events AFTER the snapshot version + const PAGE_SIZE = 1000; + let eventsData: any[] = []; + + if (eventCount === 0) { + timings.eventsFetch = 0; + } else if (eventCount <= PAGE_SIZE) { + // If we have fewer events than PAGE_SIZE, fetch all at once (no pagination needed) + const eventsFetchStart = Date.now(); + + // Use optimized function that bypasses RLS (access already verified above) + const queryStart = Date.now(); + const fastQueryResult = await db.execute(sql` SELECT event_id as "eventId", event_type as "eventType", @@ -98,30 +98,30 @@ async function handleGET( ${PAGE_SIZE}::integer ) `); - const queryTime = Date.now() - queryStart; - - // Transform result to match expected format - eventsData = fastQueryResult.map((row: any) => ({ - eventId: row.eventId, - eventType: row.eventType, - payload: row.payload, - timestamp: row.timestamp, - userId: row.userId, - userName: row.userName, - version: row.version, - })); - timings.eventsFetch = Date.now() - eventsFetchStart; - timings.eventsQuery = queryTime; - timings.eventsDataProcessing = timings.eventsFetch - queryTime; - } else { - // Only paginate if we have more than PAGE_SIZE events - const eventsFetchStart = Date.now(); - let allEvents: any[] = []; - let page = 0; - let hasMore = true; - - while (hasMore) { - const pageDataResult = await db.execute(sql` + const queryTime = Date.now() - queryStart; + + // Transform result to match expected format + eventsData = fastQueryResult.map((row: any) => ({ + eventId: row.eventId, + eventType: row.eventType, + payload: row.payload, + timestamp: row.timestamp, + userId: row.userId, + userName: row.userName, + version: row.version, + })); + timings.eventsFetch = Date.now() - eventsFetchStart; + timings.eventsQuery = queryTime; + timings.eventsDataProcessing = timings.eventsFetch - queryTime; + } else { + // Only paginate if we have more than PAGE_SIZE events + const eventsFetchStart = Date.now(); + let allEvents: any[] = []; + let page = 0; + let hasMore = true; + + while (hasMore) { + const pageDataResult = await db.execute(sql` SELECT event_id as "eventId", event_type as "eventType", @@ -138,54 +138,54 @@ async function handleGET( OFFSET ${page * PAGE_SIZE} `); - const pageData = pageDataResult.map((row: any) => ({ - eventId: row.eventId, - eventType: row.eventType, - payload: row.payload, - timestamp: row.timestamp, - userId: row.userId, - userName: row.userName, - version: row.version, - })); - - allEvents = allEvents.concat(pageData); - hasMore = pageData.length === PAGE_SIZE; - page++; - } - - eventsData = allEvents; - timings.eventsFetch = Date.now() - eventsFetchStart; + const pageData = pageDataResult.map((row: any) => ({ + eventId: row.eventId, + eventType: row.eventType, + payload: row.payload, + timestamp: row.timestamp, + userId: row.userId, + userName: row.userName, + version: row.version, + })); + + allEvents = allEvents.concat(pageData); + hasMore = pageData.length === PAGE_SIZE; + page++; } - // Transform database events to WorkspaceEvent format - const transformStart = Date.now(); - const events: WorkspaceEvent[] = eventsData.map((e) => ({ - type: e.eventType, - payload: e.payload, - timestamp: e.timestamp, - userId: e.userId, - userName: e.userName || undefined, - id: e.eventId, - version: e.version, // Include version from database - } as WorkspaceEvent)); - timings.transform = Date.now() - transformStart; - - // Version should be the max version from database, not events.length - const maxVersion = eventsData && eventsData.length > 0 - ? Math.max(...eventsData.map(e => e.version)) - : (snapshotVersion || 0); - - const response: EventResponse = { - events, - version: maxVersion, - snapshot: latestSnapshot && typeof latestSnapshot.snapshotVersion === 'number' ? { - version: latestSnapshot.snapshotVersion, - state: latestSnapshot.state as any, - } : undefined, - }; + eventsData = allEvents; + timings.eventsFetch = Date.now() - eventsFetchStart; + } - const totalTime = Date.now() - startTime; - timings.total = totalTime; + // Transform database events to WorkspaceEvent format + const transformStart = Date.now(); + const events: WorkspaceEvent[] = eventsData.map((e) => ({ + type: e.eventType, + payload: e.payload, + timestamp: e.timestamp, + userId: e.userId, + userName: e.userName || undefined, + id: e.eventId, + version: e.version, // Include version from database + } as WorkspaceEvent)); + timings.transform = Date.now() - transformStart; + + // Version should be the max version from database, not events.length + const maxVersion = eventsData && eventsData.length > 0 + ? Math.max(...eventsData.map(e => e.version)) + : (snapshotVersion || 0); + + const response: EventResponse = { + events, + version: maxVersion, + snapshot: latestSnapshot && typeof latestSnapshot.snapshotVersion === 'number' ? { + version: latestSnapshot.snapshotVersion, + state: latestSnapshot.state as any, + } : undefined, + }; + + const totalTime = Date.now() - startTime; + timings.total = totalTime; return NextResponse.json(response); } @@ -202,12 +202,12 @@ async function handlePOST( ) { const startTime = Date.now(); const timings: Record = {}; - + // Start independent operations in parallel const paramsPromise = params; const authPromise = requireAuth(); const bodyPromise = request.json(); - + const paramsResolved = await paramsPromise; const id = paramsResolved.id; @@ -227,14 +227,14 @@ async function handlePOST( ); } - // Check if user is workspace owner + // Check if user has editor access (owner or editor collaborator) const workspaceCheckStart = Date.now(); - await verifyWorkspaceOwnership(id, userId); + await verifyWorkspaceAccess(id, userId, 'editor'); timings.workspaceCheck = Date.now() - workspaceCheckStart; - // Use the append function to handle versioning and conflicts - const appendStart = Date.now(); - const result = await db.execute(sql` + // Use the append function to handle versioning and conflicts + const appendStart = Date.now(); + const result = await db.execute(sql` SELECT append_workspace_event( ${id}::uuid, ${event.id}::text, @@ -246,72 +246,72 @@ async function handlePOST( ${event.userName || null}::text ) as result `); - timings.appendFunction = Date.now() - appendStart; + timings.appendFunction = Date.now() - appendStart; - if (!result || result.length === 0 || !result[0]) { - return NextResponse.json({ error: "Failed to append event" }, { status: 500 }); - } + if (!result || result.length === 0 || !result[0]) { + return NextResponse.json({ error: "Failed to append event" }, { status: 500 }); + } - // PostgreSQL returns result as string like "(6,t)" - need to parse it - const rawResult = result[0].result as string; + // PostgreSQL returns result as string like "(6,t)" - need to parse it + const rawResult = result[0].result as string; - // Parse the PostgreSQL tuple format "(version,conflict)" - const match = rawResult.match(/\((\d+),(t|f)\)/); - if (!match) { - console.error(`[POST /api/workspaces/${id}/events] Failed to parse PostgreSQL result:`, rawResult); - return NextResponse.json({ error: "Invalid database response" }, { status: 500 }); - } + // Parse the PostgreSQL tuple format "(version,conflict)" + const match = rawResult.match(/\((\d+),(t|f)\)/); + if (!match) { + console.error(`[POST /api/workspaces/${id}/events] Failed to parse PostgreSQL result:`, rawResult); + return NextResponse.json({ error: "Invalid database response" }, { status: 500 }); + } - const appendResult = { - version: parseInt(match[1], 10), - conflict: match[2] === 't' - }; - - // Check for conflict - if (appendResult.conflict) { - // Fetch current events for client to merge - const conflictFetchStart = Date.now(); - const currentEvents = await db - .select() - .from(workspaceEvents) - .where( - and( - eq(workspaceEvents.workspaceId, id), - gt(workspaceEvents.version, baseVersion) - ) + const appendResult = { + version: parseInt(match[1], 10), + conflict: match[2] === 't' + }; + + // Check for conflict + if (appendResult.conflict) { + // Fetch current events for client to merge + const conflictFetchStart = Date.now(); + const currentEvents = await db + .select() + .from(workspaceEvents) + .where( + and( + eq(workspaceEvents.workspaceId, id), + gt(workspaceEvents.version, baseVersion) ) - .orderBy(asc(workspaceEvents.version)); - timings.conflictFetch = Date.now() - conflictFetchStart; - - const events: WorkspaceEvent[] = currentEvents.map((e) => ({ - type: e.eventType, - payload: e.payload, - timestamp: e.timestamp, - userId: e.userId, - userName: e.userName || undefined, - id: e.eventId, - } as WorkspaceEvent)); - - const totalTime = Date.now() - startTime; - timings.total = totalTime; - - return NextResponse.json({ - conflict: true, - version: appendResult.version, - currentEvents: events, - }); - } + ) + .orderBy(asc(workspaceEvents.version)); + timings.conflictFetch = Date.now() - conflictFetchStart; - // Success - no conflict - // Check if we need to create a snapshot (async, non-blocking) - checkAndCreateSnapshot(id).catch((err) => { - console.error(`[POST /api/workspaces/${id}/events] Failed to create snapshot:`, err); - // Don't fail the request if snapshot creation fails - }); + const events: WorkspaceEvent[] = currentEvents.map((e) => ({ + type: e.eventType, + payload: e.payload, + timestamp: e.timestamp, + userId: e.userId, + userName: e.userName || undefined, + id: e.eventId, + } as WorkspaceEvent)); const totalTime = Date.now() - startTime; timings.total = totalTime; + return NextResponse.json({ + conflict: true, + version: appendResult.version, + currentEvents: events, + }); + } + + // Success - no conflict + // Check if we need to create a snapshot (async, non-blocking) + checkAndCreateSnapshot(id).catch((err) => { + console.error(`[POST /api/workspaces/${id}/events] Failed to create snapshot:`, err); + // Don't fail the request if snapshot creation fails + }); + + const totalTime = Date.now() - startTime; + timings.total = totalTime; + return NextResponse.json({ success: true, version: appendResult.version, diff --git a/src/app/api/workspaces/[id]/route.ts b/src/app/api/workspaces/[id]/route.ts index 0d134a7f..c1973bc1 100644 --- a/src/app/api/workspaces/[id]/route.ts +++ b/src/app/api/workspaces/[id]/route.ts @@ -2,12 +2,12 @@ import { NextRequest, NextResponse } from "next/server"; import { db, workspaces } from "@/lib/db/client"; import { eq } from "drizzle-orm"; import { loadWorkspaceState } from "@/lib/workspace/state-loader"; -import { requireAuth, verifyWorkspaceOwnership, verifyWorkspaceOwnershipWithData, withErrorHandling } from "@/lib/api/workspace-helpers"; +import { requireAuth, verifyWorkspaceOwnership, verifyWorkspaceAccess, withErrorHandling } from "@/lib/api/workspace-helpers"; /** * GET /api/workspaces/[id] * Get a specific workspace with its state - * Note: Only owners can access (sharing is fork-based - users import copies) + * Supports owner and collaborators */ async function handleGET( request: NextRequest, @@ -16,15 +16,26 @@ async function handleGET( // Start independent operations in parallel const paramsPromise = params; const authPromise = requireAuth(); - + const { id } = await paramsPromise; const userId = await authPromise; - // Get workspace and verify ownership - const workspace = await verifyWorkspaceOwnershipWithData(id, userId); + // Check access (owner or collaborator) + const accessInfo = await verifyWorkspaceAccess(id, userId, 'viewer'); + + // Get workspace data + const [workspace] = await db + .select() + .from(workspaces) + .where(eq(workspaces.id, id)) + .limit(1); + + if (!workspace) { + return NextResponse.json({ error: "Workspace not found" }, { status: 404 }); + } - // Get workspace state by replaying events - const state = await loadWorkspaceState(id); + // Get workspace state by replaying events + const state = await loadWorkspaceState(id); // Ensure state has workspace metadata if empty if (!state.globalTitle && !state.globalDescription) { @@ -36,6 +47,8 @@ async function handleGET( workspace: { ...workspace, state, + isShared: !accessInfo.isOwner, + permissionLevel: accessInfo.permissionLevel, }, }); } @@ -54,7 +67,7 @@ async function handlePATCH( const paramsPromise = params; const authPromise = requireAuth(); const bodyPromise = request.json(); - + const { id } = await paramsPromise; const userId = await authPromise; const body = await bodyPromise; @@ -63,25 +76,25 @@ async function handlePATCH( // Check ownership await verifyWorkspaceOwnership(id, userId); - // Update workspace - const updateData: { - name?: string; - description?: string; - isPublic?: boolean; - icon?: string | null; - color?: string | null; - } = {}; - if (name !== undefined) updateData.name = name; - if (description !== undefined) updateData.description = description; - if (is_public !== undefined) updateData.isPublic = is_public; - if (icon !== undefined) updateData.icon = icon; - if (color !== undefined) updateData.color = color; - - const [updatedWorkspace] = await db - .update(workspaces) - .set(updateData) - .where(eq(workspaces.id, id)) - .returning(); + // Update workspace + const updateData: { + name?: string; + description?: string; + isPublic?: boolean; + icon?: string | null; + color?: string | null; + } = {}; + if (name !== undefined) updateData.name = name; + if (description !== undefined) updateData.description = description; + if (is_public !== undefined) updateData.isPublic = is_public; + if (icon !== undefined) updateData.icon = icon; + if (color !== undefined) updateData.color = color; + + const [updatedWorkspace] = await db + .update(workspaces) + .set(updateData) + .where(eq(workspaces.id, id)) + .returning(); return NextResponse.json({ workspace: updatedWorkspace }); } @@ -99,17 +112,17 @@ async function handleDELETE( // Start independent operations in parallel const paramsPromise = params; const authPromise = requireAuth(); - + const { id } = await paramsPromise; const userId = await authPromise; // Check ownership await verifyWorkspaceOwnership(id, userId); - // Delete workspace (cascade will delete events and snapshots) - await db - .delete(workspaces) - .where(eq(workspaces.id, id)); + // Delete workspace (cascade will delete events and snapshots) + await db + .delete(workspaces) + .where(eq(workspaces.id, id)); return NextResponse.json({ success: true }); } diff --git a/src/app/api/workspaces/[id]/snapshot/route.ts b/src/app/api/workspaces/[id]/snapshot/route.ts index cd746fda..c7ece1df 100644 --- a/src/app/api/workspaces/[id]/snapshot/route.ts +++ b/src/app/api/workspaces/[id]/snapshot/route.ts @@ -6,7 +6,7 @@ import { } from "@/lib/workspace/snapshot-manager"; import { db, workspaces } from "@/lib/db/client"; import { eq, sql } from "drizzle-orm"; -import { requireAuth, verifyWorkspaceOwnership, verifyWorkspaceOwnershipWithData, withErrorHandling } from "@/lib/api/workspace-helpers"; +import { requireAuth, verifyWorkspaceAccess, withErrorHandling } from "@/lib/api/workspace-helpers"; /** * GET /api/workspaces/[id]/snapshot @@ -19,18 +19,28 @@ async function handleGET( // Start independent operations in parallel const paramsPromise = params; const authPromise = requireAuth(); - + const { id } = await paramsPromise; const userId = await authPromise; - // Check if user is workspace owner - const workspace = await verifyWorkspaceOwnershipWithData(id, userId); + // Check if user has access (owner or collaborator) + const [workspace] = await db + .select() + .from(workspaces) + .where(eq(workspaces.id, id)) + .limit(1); + + if (!workspace) { + return NextResponse.json({ error: "Workspace not found" }, { status: 404 }); + } + + await verifyWorkspaceAccess(id, userId, 'viewer'); - // Check snapshot status using utility functions - const snapshotStatus = await checkNeedsSnapshot(id); + // Check snapshot status using utility functions + const snapshotStatus = await checkNeedsSnapshot(id); - // Get latest snapshot info - const latestSnapshot = await db.execute(sql` + // Get latest snapshot info + const latestSnapshot = await db.execute(sql` SELECT * FROM get_latest_snapshot(${id}::uuid) `); @@ -57,22 +67,22 @@ async function handlePOST( // Start independent operations in parallel const paramsPromise = params; const authPromise = requireAuth(); - + const { id } = await paramsPromise; const userId = await authPromise; - // Check if user is workspace owner - await verifyWorkspaceOwnership(id, userId); + // Check if user has editor access (owner or editor collaborator) + await verifyWorkspaceAccess(id, userId, 'editor'); - // Create snapshot using utility function - const result = await createSnapshot(id); + // Create snapshot using utility function + const result = await createSnapshot(id); - if (!result.success) { - return NextResponse.json( - { error: result.error || "Failed to create snapshot" }, - { status: 500 } - ); - } + if (!result.success) { + return NextResponse.json( + { error: result.error || "Failed to create snapshot" }, + { status: 500 } + ); + } return NextResponse.json({ success: true, @@ -94,27 +104,27 @@ async function handlePUT( // Start independent operations in parallel const paramsPromise = params; const authPromise = requireAuth(); - + const { id } = await paramsPromise; const userId = await authPromise; - // Check if user is workspace owner - await verifyWorkspaceOwnership(id, userId); + // Check if user has editor access (owner or editor collaborator) + await verifyWorkspaceAccess(id, userId, 'editor'); - // Check and create snapshot if needed - const statusBefore = await checkNeedsSnapshot(id); + // Check and create snapshot if needed + const statusBefore = await checkNeedsSnapshot(id); - if (!statusBefore.needsSnapshot) { - return NextResponse.json({ - message: "Snapshot not needed yet", - status: statusBefore, - }); - } + if (!statusBefore.needsSnapshot) { + return NextResponse.json({ + message: "Snapshot not needed yet", + status: statusBefore, + }); + } - // Create snapshot if needed - await checkAndCreateSnapshot(id); + // Create snapshot if needed + await checkAndCreateSnapshot(id); - const statusAfter = await checkNeedsSnapshot(id); + const statusAfter = await checkNeedsSnapshot(id); return NextResponse.json({ message: "Snapshot check complete", diff --git a/src/app/api/workspaces/[id]/snapshots/route.ts b/src/app/api/workspaces/[id]/snapshots/route.ts index 17f4f66c..5fab0302 100644 --- a/src/app/api/workspaces/[id]/snapshots/route.ts +++ b/src/app/api/workspaces/[id]/snapshots/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import type { SnapshotInfo } from "@/lib/workspace/events"; import { db, workspaceSnapshots } from "@/lib/db/client"; import { eq, desc } from "drizzle-orm"; -import { requireAuth, verifyWorkspaceOwnership, withErrorHandling } from "@/lib/api/workspace-helpers"; +import { requireAuth, verifyWorkspaceAccess, withErrorHandling } from "@/lib/api/workspace-helpers"; /** * GET /api/workspaces/[id]/snapshots @@ -16,28 +16,28 @@ async function handleGET( // Start independent operations in parallel const paramsPromise = params; const authPromise = requireAuth(); - + const { id } = await paramsPromise; const userId = await authPromise; - // Check if user is workspace owner - await verifyWorkspaceOwnership(id, userId); + // Check if user has access (owner or collaborator) + await verifyWorkspaceAccess(id, userId, 'viewer'); - // Get ALL snapshots for version history - const allSnapshotsData = await db - .select() - .from(workspaceSnapshots) - .where(eq(workspaceSnapshots.workspaceId, id)) - .orderBy(desc(workspaceSnapshots.snapshotVersion)); + // Get ALL snapshots for version history + const allSnapshotsData = await db + .select() + .from(workspaceSnapshots) + .where(eq(workspaceSnapshots.workspaceId, id)) + .orderBy(desc(workspaceSnapshots.snapshotVersion)); - const snapshots: SnapshotInfo[] = allSnapshotsData.map(s => ({ - id: s.id, - version: s.snapshotVersion, - eventCount: s.eventCount, - createdAt: s.createdAt || '', - // Include state for restoration - state: s.state as any, - })); + const snapshots: SnapshotInfo[] = allSnapshotsData.map(s => ({ + id: s.id, + version: s.snapshotVersion, + eventCount: s.eventCount, + createdAt: s.createdAt || '', + // Include state for restoration + state: s.state as any, + })); return NextResponse.json({ snapshots }); } diff --git a/src/app/api/workspaces/[id]/track-open/route.ts b/src/app/api/workspaces/[id]/track-open/route.ts index e58acb16..aa6ab506 100644 --- a/src/app/api/workspaces/[id]/track-open/route.ts +++ b/src/app/api/workspaces/[id]/track-open/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { db, workspaces } from "@/lib/db/client"; import { eq } from "drizzle-orm"; -import { requireAuth, verifyWorkspaceOwnership, withErrorHandling } from "@/lib/api/workspace-helpers"; +import { requireAuth, verifyWorkspaceAccess, withErrorHandling } from "@/lib/api/workspace-helpers"; /** * POST /api/workspaces/[id]/track-open @@ -15,12 +15,12 @@ async function handlePOST( // Start independent operations in parallel const paramsPromise = params; const authPromise = requireAuth(); - + const { id } = await paramsPromise; const userId = await authPromise; - // Check ownership - await verifyWorkspaceOwnership(id, userId); + // Check access (owner or collaborator) + await verifyWorkspaceAccess(id, userId, 'viewer'); // Update lastOpenedAt to current timestamp const [updatedWorkspace] = await db @@ -37,9 +37,9 @@ async function handlePOST( ); } - return NextResponse.json({ + return NextResponse.json({ success: true, - lastOpenedAt: updatedWorkspace.lastOpenedAt + lastOpenedAt: updatedWorkspace.lastOpenedAt }); } diff --git a/src/app/api/workspaces/route.ts b/src/app/api/workspaces/route.ts index e35f92ce..1012af2c 100644 --- a/src/app/api/workspaces/route.ts +++ b/src/app/api/workspaces/route.ts @@ -5,13 +5,13 @@ import type { WorkspaceWithState, WorkspaceTemplate } from "@/lib/workspace-stat import type { CardColor } from "@/lib/workspace-state/colors"; import { randomUUID } from "crypto"; import { db, workspaces } from "@/lib/db/client"; -import { eq, desc, asc, sql } from "drizzle-orm"; +import { workspaceCollaborators } from "@/lib/db/schema"; +import { eq, desc, asc, sql, inArray } from "drizzle-orm"; import { requireAuth, requireAuthWithUserInfo, withErrorHandling } from "@/lib/api/workspace-helpers"; /** * GET /api/workspaces - * List all workspaces for the authenticated user - * Note: Sharing is fork-based - users import copies, not access the original + * List all workspaces for the authenticated user (owned + shared) */ async function handleGET() { const userId = await requireAuth(); @@ -31,8 +31,30 @@ async function handleGET() { desc(workspaces.updatedAt) ); - // Format results (using camelCase for Drizzle types) - const workspaceList: WorkspaceWithState[] = ownedWorkspaces.map((w) => ({ + // Get workspaces user is a collaborator on + const collaborations = await db + .select({ workspaceId: workspaceCollaborators.workspaceId, permissionLevel: workspaceCollaborators.permissionLevel }) + .from(workspaceCollaborators) + .where(eq(workspaceCollaborators.userId, userId)); + + let sharedWorkspaces: typeof ownedWorkspaces = []; + if (collaborations.length > 0) { + const sharedWorkspaceIds = collaborations.map(c => c.workspaceId); + sharedWorkspaces = await db + .select() + .from(workspaces) + .where(inArray(workspaces.id, sharedWorkspaceIds)) + .orderBy( + sql`${workspaces.lastOpenedAt} DESC NULLS LAST`, + desc(workspaces.updatedAt) + ); + } + + // Create a map of permission levels for shared workspaces + const permissionMap = new Map(collaborations.map(c => [c.workspaceId, c.permissionLevel])); + + // Format owned workspaces + const ownedList: (WorkspaceWithState & { isShared?: boolean; permissionLevel?: string })[] = ownedWorkspaces.map((w) => ({ id: w.id, userId: w.userId, name: w.name, @@ -46,13 +68,37 @@ async function handleGET() { sortOrder: w.sortOrder ?? null, color: w.color as CardColor | null, lastOpenedAt: w.lastOpenedAt ?? null, + isShared: false, })); + // Format shared workspaces + const sharedList: (WorkspaceWithState & { isShared?: boolean; permissionLevel?: string })[] = sharedWorkspaces.map((w) => ({ + id: w.id, + userId: w.userId, + name: w.name, + description: w.description || '', + template: (w.template as WorkspaceTemplate) || 'blank', + isPublic: w.isPublic || false, + createdAt: w.createdAt || '', + updatedAt: w.updatedAt || '', + slug: w.slug || '', + icon: w.icon, + sortOrder: w.sortOrder ?? null, + color: w.color as CardColor | null, + lastOpenedAt: w.lastOpenedAt ?? null, + isShared: true, + permissionLevel: permissionMap.get(w.id) || 'viewer', + })); + + // Merge lists - owned first, then shared + const workspaceList = [...ownedList, ...sharedList]; + return NextResponse.json({ workspaces: workspaceList }); } export const GET = withErrorHandling(handleGET, "GET /api/workspaces"); + /** * POST /api/workspaces * Create a new workspace diff --git a/src/app/api/workspaces/slug/[slug]/route.ts b/src/app/api/workspaces/slug/[slug]/route.ts index 10b07956..434b4b62 100644 --- a/src/app/api/workspaces/slug/[slug]/route.ts +++ b/src/app/api/workspaces/slug/[slug]/route.ts @@ -1,13 +1,14 @@ import { NextRequest, NextResponse } from "next/server"; import { db, workspaces } from "@/lib/db/client"; -import { eq, and } from "drizzle-orm"; +import { workspaceCollaborators } from "@/lib/db/schema"; +import { eq, and, or } from "drizzle-orm"; import { loadWorkspaceState } from "@/lib/workspace/state-loader"; import { requireAuth, withErrorHandling } from "@/lib/api/workspace-helpers"; /** * GET /api/workspaces/slug/[slug] * Get a workspace by slug (more user-friendly than UUID) - * Note: Owner only (sharing is fork-based) + * Supports owner and collaborators * * Query params: * - metadata=true: Return only workspace metadata (faster, for initial load) @@ -19,15 +20,15 @@ async function handleGET( // Start independent operations in parallel const paramsPromise = params; const authPromise = requireAuth(); - + const { slug } = await paramsPromise; const userId = await authPromise; // Check if metadata-only mode is requested (faster path for initial workspace load) const metadataOnly = request.nextUrl.searchParams.get('metadata') === 'true'; - // Get workspace by slug for this user (ownership only) - const workspace = await db + // Get workspace by slug - first check ownership + const [ownedWorkspace] = await db .select() .from(workspaces) .where( @@ -38,7 +39,41 @@ async function handleGET( ) .limit(1); - if (!workspace[0]) { + let workspace = ownedWorkspace; + let isShared = false; + let permissionLevel: string | null = null; + + // If not owned, check if user is a collaborator + if (!workspace) { + // First find the workspace by slug + const [anyWorkspace] = await db + .select() + .from(workspaces) + .where(eq(workspaces.slug, slug)) + .limit(1); + + if (anyWorkspace) { + // Check if user is a collaborator on this workspace + const [collab] = await db + .select({ permissionLevel: workspaceCollaborators.permissionLevel }) + .from(workspaceCollaborators) + .where( + and( + eq(workspaceCollaborators.workspaceId, anyWorkspace.id), + eq(workspaceCollaborators.userId, userId) + ) + ) + .limit(1); + + if (collab) { + workspace = anyWorkspace; + isShared = true; + permissionLevel = collab.permissionLevel; + } + } + } + + if (!workspace) { return NextResponse.json({ error: "Workspace not found" }, { status: 404 }); } @@ -46,23 +81,29 @@ async function handleGET( // This is much faster and used for initial workspace identification if (metadataOnly) { return NextResponse.json({ - workspace: workspace[0], + workspace: { + ...workspace, + isShared, + permissionLevel, + }, }); } // Get workspace state by replaying events (full mode) - const state = await loadWorkspaceState(workspace[0].id); + const state = await loadWorkspaceState(workspace.id); // Ensure state has workspace metadata if empty if (!state.globalTitle && !state.globalDescription) { - state.globalTitle = workspace[0].name || ""; - state.globalDescription = workspace[0].description || ""; + state.globalTitle = workspace.name || ""; + state.globalDescription = workspace.description || ""; } return NextResponse.json({ workspace: { - ...workspace[0], + ...workspace, state, + isShared, + permissionLevel, }, }); } diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 31fe7ca9..505bbb5a 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -35,6 +35,7 @@ import { AnonymousSessionHandler, SidebarCoordinator } from "@/components/layout import { PdfEngineWrapper } from "@/components/pdf/PdfEngineWrapper"; import WorkspaceSettingsModal from "@/components/workspace/WorkspaceSettingsModal"; import ShareWorkspaceDialog from "@/components/workspace/ShareWorkspaceDialog"; +import { RealtimeProvider } from "@/contexts/RealtimeContext"; // Main dashboard content component interface DashboardContentProps { currentWorkspace: WorkspaceWithState | null; @@ -539,11 +540,13 @@ export function DashboardPage() { }, [currentWorkspaceId, clearPlayingYouTubeCards]); return ( - + + + ); } diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx new file mode 100644 index 00000000..fd01b746 --- /dev/null +++ b/src/components/ui/select.tsx @@ -0,0 +1,190 @@ +"use client" + +import * as React from "react" +import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" +import { Select as SelectPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Select({ + ...props +}: React.ComponentProps) { + return +} + +function SelectGroup({ + ...props +}: React.ComponentProps) { + return +} + +function SelectValue({ + ...props +}: React.ComponentProps) { + return +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: React.ComponentProps & { + size?: "sm" | "default" +}) { + return ( + + {children} + + + + + ) +} + +function SelectContent({ + className, + children, + position = "item-aligned", + align = "center", + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ) +} + +function SelectLabel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +} diff --git a/src/components/workspace-canvas/WorkspaceHeader.tsx b/src/components/workspace-canvas/WorkspaceHeader.tsx index 2b9cbcd1..d457f93f 100644 --- a/src/components/workspace-canvas/WorkspaceHeader.tsx +++ b/src/components/workspace-canvas/WorkspaceHeader.tsx @@ -50,6 +50,7 @@ import { useMemo } from "react"; import { CreateYouTubeDialog } from "@/components/modals/CreateYouTubeDialog"; import { CreateWebsiteDialog } from "@/components/modals/CreateWebsiteDialog"; import { useQueryClient } from "@tanstack/react-query"; +import { CollaboratorAvatars } from "@/components/workspace/CollaboratorAvatars"; interface WorkspaceHeaderProps { titleInputRef: React.RefObject; searchQuery: string; @@ -774,6 +775,9 @@ export default function WorkspaceHeader({ ) : ( // Default Mode: Standard Workspace Controls
+ {/* Collaborator Avatars - show who's in the workspace */} + + {/* Save Indicator - hidden in compact mode */} {!isCompactMode && ( +
+ {visibleCollaborators.map((collaborator) => ( + + + + + + {getInitials(collaborator.userName)} + + + + +
{collaborator.userName}
+
+
+ ))} + + {remainingCount > 0 && ( + + + + + +{remainingCount} + + + + + {remainingCount} other collaborator{remainingCount > 1 ? 's' : ''} + + + )} +
+ + ); +} + +function getInitials(name: string): string { + const parts = name.trim().split(/\s+/); + if (parts.length >= 2) { + return `${parts[0][0]}${parts[1][0]}`.toUpperCase(); + } + return name.slice(0, 2).toUpperCase(); +} diff --git a/src/components/workspace/ShareWorkspaceDialog.tsx b/src/components/workspace/ShareWorkspaceDialog.tsx index 488e2806..141e5148 100644 --- a/src/components/workspace/ShareWorkspaceDialog.tsx +++ b/src/components/workspace/ShareWorkspaceDialog.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect } from "react"; -import { Copy, Check, Mail } from "lucide-react"; +import { Copy, Check, Mail, UserPlus, Users, Trash2, Loader2 } from "lucide-react"; import { Dialog, DialogContent, @@ -12,7 +12,28 @@ import { import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { toast } from "sonner"; import type { WorkspaceWithState } from "@/lib/workspace-state/types"; +import { useSession } from "@/lib/auth-client"; + +interface Collaborator { + id: string; + userId: string; + email?: string; + name?: string; + image?: string; + permissionLevel: "viewer" | "editor" | "owner"; + createdAt: string; +} interface ShareWorkspaceDialogProps { workspace: WorkspaceWithState | null; @@ -25,17 +46,53 @@ export default function ShareWorkspaceDialog({ open, onOpenChange, }: ShareWorkspaceDialogProps) { + const { data: session } = useSession(); const [copied, setCopied] = useState(false); const [shareUrl, setShareUrl] = useState(""); + const [inviteEmail, setInviteEmail] = useState(""); + const [invitePermission, setInvitePermission] = useState<"viewer" | "editor">("editor"); + const [isInviting, setIsInviting] = useState(false); + const [collaborators, setCollaborators] = useState([]); + const [isLoadingCollaborators, setIsLoadingCollaborators] = useState(false); + + // Determine permissions + const isOwner = workspace?.userId === session?.user?.id; + + // Find current user in collaborators list (if not owner) + const currentUserCollaborator = collaborators.find(c => c.userId === session?.user?.id); + + // Can invite: Owner OR Editor + const canInvite = isOwner || currentUserCollaborator?.permissionLevel === 'editor'; + + // Can manage (remove/change permission): Only Owner + const canManage = isOwner; useEffect(() => { if (workspace && open) { const baseUrl = typeof window !== "undefined" ? window.location.origin : ""; const url = `${baseUrl}/share/${workspace.id}`; setShareUrl(url); + loadCollaborators(); } }, [workspace, open]); + const loadCollaborators = async () => { + if (!workspace) return; + + setIsLoadingCollaborators(true); + try { + const response = await fetch(`/api/workspaces/${workspace.id}/collaborators`); + if (response.ok) { + const data = await response.json(); + setCollaborators(data.collaborators || []); + } + } catch (error) { + console.error("Failed to load collaborators:", error); + } finally { + setIsLoadingCollaborators(false); + } + }; + const handleCopy = async () => { if (shareUrl) { try { @@ -56,10 +113,96 @@ export default function ShareWorkspaceDialog({ window.location.href = emailUrl; }; + const handleInvite = async () => { + if (!workspace || !inviteEmail.trim()) return; + + setIsInviting(true); + try { + const response = await fetch(`/api/workspaces/${workspace.id}/collaborators`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: inviteEmail.trim(), + permissionLevel: invitePermission, + }), + }); + + if (response.ok) { + toast.success(`Invited ${inviteEmail} as ${invitePermission}`); + setInviteEmail(""); + loadCollaborators(); + } else { + const error = await response.json(); + toast.error(error.message || "Failed to send invite"); + } + } catch (error) { + console.error("Failed to invite:", error); + toast.error("Failed to send invite"); + } finally { + setIsInviting(false); + } + }; + + const handleRemoveCollaborator = async (collaboratorId: string) => { + if (!workspace) return; + + try { + const response = await fetch(`/api/workspaces/${workspace.id}/collaborators/${collaboratorId}`, { + method: "DELETE", + }); + + if (response.ok) { + toast.success("Collaborator removed"); + loadCollaborators(); + } else { + toast.error("Failed to remove collaborator"); + } + } catch (error) { + console.error("Failed to remove collaborator:", error); + toast.error("Failed to remove collaborator"); + } + }; + + const handleUpdatePermission = async (collaboratorId: string, newPermission: "viewer" | "editor") => { + if (!workspace) return; + + try { + const response = await fetch(`/api/workspaces/${workspace.id}/collaborators/${collaboratorId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ permissionLevel: newPermission }), + }); + + if (response.ok) { + toast.success("Permission updated"); + loadCollaborators(); + } else { + toast.error("Failed to update permission"); + } + } catch (error) { + console.error("Failed to update permission:", error); + toast.error("Failed to update permission"); + } + }; + + const getInitials = (name?: string, email?: string) => { + if (name) { + const parts = name.trim().split(/\s+/); + if (parts.length >= 2) { + return `${parts[0][0]}${parts[1][0]}`.toUpperCase(); + } + return name.slice(0, 2).toUpperCase(); + } + if (email) { + return email.slice(0, 2).toUpperCase(); + } + return "??"; + }; + return ( - Share Workspace - Share this link to allow others to fork your workspace. They'll get their own copy—changes to their copy won't affect your original. + Invite collaborators to work together in real-time or share a link for others to fork. -
-
- -
- + + + + + Invite + + + + Share Link + + + + + {/* Invite Form */} +
+ +
+ setInviteEmail(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleInvite()} + className="flex-1" + disabled={!canInvite} + /> + + +
+ {!canInvite && ( +

+ You must be an editor or owner to invite others. +

+ )} +

+ Editors can add and edit cards. Viewers can only view. +

+
+ + {/* Collaborators List */} +
+
+ + People with access ({collaborators.length}) +
+ + {isLoadingCollaborators ? ( +
+ +
+ ) : collaborators.length === 0 ? ( +

+ No collaborators yet. Invite someone above! +

+ ) : ( +
+ {collaborators.map((collab) => ( +
+
+ + + + {getInitials(collab.name, collab.email)} + + +
+

+ {collab.name || collab.email || "Unknown"} +

+ {collab.name && collab.email && ( +

+ {collab.email} +

+ )} +
+
+
+ {collab.permissionLevel === "owner" ? ( + + Owner + + ) : canManage ? ( + <> + + + + ) : ( + + {collab.permissionLevel} + + )} +
+
+ ))} +
+ )} +
+
+ + +
+ +

+ Anyone with this link can fork your workspace. They'll get their own copy—changes won't affect your original. +

+
+ + +
+ {copied && ( +

Copied to clipboard!

+ )} +
+ +
- {copied && ( -

Copied to clipboard!

- )} -
- -
- -
-
+ +
); } - diff --git a/src/contexts/RealtimeContext.tsx b/src/contexts/RealtimeContext.tsx new file mode 100644 index 00000000..3ba95fcc --- /dev/null +++ b/src/contexts/RealtimeContext.tsx @@ -0,0 +1,96 @@ +/** + * Workspace Realtime Context + * + * Provides real-time collaboration state to workspace components. + * Combines subscription (events) and presence (locks/users) hooks. + */ + +"use client"; + +import React, { createContext, useContext, useMemo } from "react"; +import { useWorkspaceRealtime } from "@/hooks/workspace/use-workspace-realtime"; +import { useWorkspacePresence, type CollaboratorPresence } from "@/hooks/workspace/use-workspace-presence"; +import { useSession } from "@/lib/auth-client"; +import type { WorkspaceEvent } from "@/lib/workspace/events"; + +interface RealtimeContextType { + /** Connection status for realtime sync */ + connectionStatus: 'connecting' | 'connected' | 'disconnected' | 'error'; + /** Collaborators currently in the workspace */ + collaborators: CollaboratorPresence[]; + /** Broadcast an event to other clients (call after saving) */ + broadcastEvent: (event: WorkspaceEvent) => Promise; +} + +const RealtimeContext = createContext(null); + +interface RealtimeProviderProps { + children: React.ReactNode; + workspaceId: string | null; +} + +/** + * Provider that sets up realtime subscription and presence for a workspace + */ +export function RealtimeProvider({ + children, + workspaceId, +}: RealtimeProviderProps) { + const { data: session } = useSession(); + + // Track connection status + const [connectionStatus, setConnectionStatus] = React.useState('connecting'); + + // Current user info for presence + const currentUser = useMemo(() => { + if (!session?.user) return null; + return { + id: session.user.id, + name: session.user.name || 'Anonymous', + image: session.user.image ?? undefined, + }; + }, [session?.user]); + + // Subscribe to realtime events and get broadcast function + const { broadcastEvent } = useWorkspaceRealtime(workspaceId, { + currentUserId: currentUser?.id, + onStatusChange: setConnectionStatus, + }); + + // Track presence (which users are in workspace) + const { collaborators } = useWorkspacePresence(workspaceId, { + currentUser, + }); + + const value = useMemo(() => ({ + connectionStatus, + collaborators, + broadcastEvent, + }), [connectionStatus, collaborators, broadcastEvent]); + + return ( + + {children} + + ); +} + +/** + * Hook to access realtime collaboration state + */ +export function useRealtimeContext() { + const context = useContext(RealtimeContext); + if (!context) { + throw new Error("useRealtimeContext must be used within RealtimeProvider"); + } + return context; +} + +/** + * Optional hook that returns null if not inside provider + * Useful for components that may or may not be inside a collaborative workspace + */ +export function useRealtimeContextOptional() { + return useContext(RealtimeContext); +} + diff --git a/src/hooks/workspace/use-workspace-mutation.ts b/src/hooks/workspace/use-workspace-mutation.ts index 7fca7f26..cb81fa25 100644 --- a/src/hooks/workspace/use-workspace-mutation.ts +++ b/src/hooks/workspace/use-workspace-mutation.ts @@ -8,6 +8,11 @@ interface AppendEventParams { baseVersion: number; } +interface WorkspaceMutationOptions { + /** Called after event is successfully saved (for realtime broadcast) */ + onEventSaved?: (event: WorkspaceEvent) => void; +} + /** * Append event to workspace event log */ @@ -42,8 +47,9 @@ async function appendWorkspaceEvent( * Hook to mutate workspace by appending events * Implements optimistic updates with automatic rollback on error */ -export function useWorkspaceMutation(workspaceId: string | null) { +export function useWorkspaceMutation(workspaceId: string | null, options: WorkspaceMutationOptions = {}) { const queryClient = useQueryClient(); + const { onEventSaved } = options; return useMutation({ mutationFn: (event: WorkspaceEvent) => { @@ -64,13 +70,13 @@ export function useWorkspaceMutation(workspaceId: string | null) { // that are higher than the cache's version field const events = currentData?.events ?? []; const optimisticEventsCount = events.filter(e => typeof e.version !== 'number').length; - + // Find the max version from all events that have versions // This accounts for tool events that were added with versions const maxEventVersion = events .filter(e => typeof e.version === 'number') .reduce((max, e) => Math.max(max, e.version!), currentData?.version ?? 0); - + // Use the higher of: cache version or max event version // This ensures we account for tool events that updated individual event versions // but might not have updated the cache version field @@ -168,7 +174,7 @@ export function useWorkspaceMutation(workspaceId: string | null) { }, // Refetch to ensure consistency on success - onSuccess: (data) => { + onSuccess: (data, event) => { if (!workspaceId) return; logger.debug("✅ [SUCCESS] Mutation succeeded:", { @@ -229,6 +235,15 @@ export function useWorkspaceMutation(workspaceId: string | null) { ); logger.debug("✅ [SUCCESS] Version updated to:", data.version); + + // Broadcast the event to other clients for realtime sync + if (onEventSaved) { + const eventWithVersion: WorkspaceEvent = { + ...event, + version: data.version, + }; + onEventSaved(eventWithVersion); + } } }, }); diff --git a/src/hooks/workspace/use-workspace-operations.ts b/src/hooks/workspace/use-workspace-operations.ts index 2080721b..60e7ca5b 100644 --- a/src/hooks/workspace/use-workspace-operations.ts +++ b/src/hooks/workspace/use-workspace-operations.ts @@ -13,6 +13,7 @@ import { getRandomCardColor } from "@/lib/workspace-state/colors"; import { logger } from "@/lib/utils/logger"; import { useUIStore } from "@/lib/stores/ui-store"; import { getLayoutForBreakpoint } from "@/lib/workspace-state/grid-layout-helpers"; +import { useRealtimeContextOptional } from "@/contexts/RealtimeContext"; /** * Return type for workspace operations @@ -53,7 +54,16 @@ export function useWorkspaceOperations( const { data: session } = useSession(); const user = session?.user; const queryClient = useQueryClient(); - const mutation = useWorkspaceMutation(workspaceId); + + // Get broadcast function from realtime context (if available) + const realtimeContext = useRealtimeContextOptional(); + const broadcastEvent = realtimeContext?.broadcastEvent; + + // Pass broadcast callback to mutation hook for realtime sync + const mutation = useWorkspaceMutation(workspaceId, { + onEventSaved: broadcastEvent, + }); + const userId = user?.id || "anonymous"; const userName = user?.name || user?.email || undefined; @@ -490,7 +500,7 @@ export function useWorkspaceOperations( (folderId: string, items: Item[]): string[] => { const directChildren = items.filter(item => item.folderId === folderId); const descendantIds: string[] = []; - + for (const child of directChildren) { descendantIds.push(child.id); // Recursively get descendants of nested folders @@ -498,7 +508,7 @@ export function useWorkspaceOperations( descendantIds.push(...getAllDescendantIds(child.id, items)); } } - + return descendantIds; }, [] @@ -526,19 +536,19 @@ export function useWorkspaceOperations( } else { latestItems = currentState.items; } - + const folder = latestItems.find(i => i.id === folderId && i.type === 'folder'); logger.debug("📁 [FOLDER-DELETE-WITH-CONTENTS] Deleting folder and contents:", { folderId, folderName: folder?.name }); - + // Find all descendant items recursively (handles nested folders) const allDescendantIds = getAllDescendantIds(folderId, latestItems); - + // Create set of all IDs to delete (descendants + folder itself) const idsToDelete = new Set([...allDescendantIds, folderId]); const itemCount = allDescendantIds.length; - + logger.debug("📁 [FOLDER-DELETE-WITH-CONTENTS] Found items to delete:", { itemCount, itemIds: [...idsToDelete] }); - + // Delete PDF files from storage (fire-and-forget, non-blocking) // This is best-effort cleanup - files may become orphaned if this fails const itemsToDelete = latestItems.filter(item => idsToDelete.has(item.id)); @@ -552,13 +562,13 @@ export function useWorkspaceOperations( } } } - + // Atomic bulk delete using updateAllItems pattern (single BULK_ITEMS_UPDATED event) const remainingItems = latestItems.filter(item => !idsToDelete.has(item.id)); updateAllItems(remainingItems); - + toast.success( - folder + folder ? `Folder "${folder.name}" and ${itemCount} ${itemCount === 1 ? 'item' : 'items'} deleted` : `Folder and ${itemCount} ${itemCount === 1 ? 'item' : 'items'} deleted` ); diff --git a/src/hooks/workspace/use-workspace-presence.ts b/src/hooks/workspace/use-workspace-presence.ts new file mode 100644 index 00000000..9ad105fa --- /dev/null +++ b/src/hooks/workspace/use-workspace-presence.ts @@ -0,0 +1,121 @@ +/** + * Workspace Presence Hook + * + * Tracks which users are in a workspace. + */ + +import { useEffect, useState, useCallback, useRef } from 'react'; +import type { RealtimeChannel } from '@supabase/supabase-js'; +import { getSupabaseClient } from '@/lib/supabase-client'; + +export interface CollaboratorPresence { + userId: string; + userName: string; + userImage?: string; + /** When the user joined */ + joinedAt: string; +} + +interface UseWorkspacePresenceOptions { + /** Current user info */ + currentUser: { + id: string; + name: string; + image?: string; + } | null; +} + +interface UseWorkspacePresenceReturn { + /** All collaborators currently in the workspace (excluding current user) */ + collaborators: CollaboratorPresence[]; +} + +/** + * Hook to track presence in a workspace + * Uses Supabase Realtime Presence to sync user state + */ +export function useWorkspacePresence( + workspaceId: string | null, + options: UseWorkspacePresenceOptions +): UseWorkspacePresenceReturn { + const { currentUser } = options; + const [collaborators, setCollaborators] = useState([]); + const channelRef = useRef(null); + + // Clean up channel + const cleanup = useCallback(() => { + if (channelRef.current) { + const supabase = getSupabaseClient(); + supabase.removeChannel(channelRef.current); + channelRef.current = null; + } + }, []); + + // Initialize presence channel + useEffect(() => { + if (!workspaceId || !currentUser) { + cleanup(); + setCollaborators([]); + return; + } + + const supabase = getSupabaseClient(); + const channelName = `workspace:${workspaceId}:presence`; + + const channel = supabase.channel(channelName, { + config: { + presence: { + key: currentUser.id, + }, + }, + }); + + channelRef.current = channel; + + // Handle presence sync + channel.on('presence', { event: 'sync' }, () => { + const state = channel.presenceState(); + + // Flatten presence state and filter out current user + const otherUsers: CollaboratorPresence[] = []; + for (const [userId, presences] of Object.entries(state)) { + if (userId !== currentUser.id && presences.length > 0) { + otherUsers.push(presences[0]); + } + } + + setCollaborators(otherUsers); + }); + + // Subscribe and track presence + channel.subscribe(async (status) => { + if (status === 'SUBSCRIBED') { + await channel.track({ + userId: currentUser.id, + userName: currentUser.name, + userImage: currentUser.image, + joinedAt: new Date().toISOString(), + }); + } + }); + + return cleanup; + }, [workspaceId, currentUser?.id, cleanup]); + + // Update presence when user info changes + useEffect(() => { + const channel = channelRef.current; + if (!channel || !currentUser) return; + + channel.track({ + userId: currentUser.id, + userName: currentUser.name, + userImage: currentUser.image, + joinedAt: new Date().toISOString(), + }); + }, [currentUser]); + + return { + collaborators, + }; +} diff --git a/src/hooks/workspace/use-workspace-realtime.ts b/src/hooks/workspace/use-workspace-realtime.ts new file mode 100644 index 00000000..b9bcb7c5 --- /dev/null +++ b/src/hooks/workspace/use-workspace-realtime.ts @@ -0,0 +1,184 @@ +/** + * Real-time workspace subscription hook + * + * Uses Supabase Realtime Broadcast for simple pub/sub messaging. + * Clients broadcast events after successfully saving them, and other clients receive them. + * This avoids the complexity of DB triggers + RLS on realtime.messages. + */ + +import { useEffect, useRef, useCallback, useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import type { RealtimeChannel } from '@supabase/supabase-js'; +import { getSupabaseClient } from '@/lib/supabase-client'; +import type { EventResponse, WorkspaceEvent } from '@/lib/workspace/events'; + +interface WorkspaceRealtimeOptions { + /** Current user ID to filter out own events */ + currentUserId?: string | null; + /** Called when connection status changes */ + onStatusChange?: (status: 'connecting' | 'connected' | 'disconnected' | 'error') => void; + /** Called when a remote event is received */ + onRemoteEvent?: (event: WorkspaceEvent) => void; +} + +interface WorkspaceRealtimeReturn { + isConnected: boolean; + /** Broadcast an event to other clients */ + broadcastEvent: (event: WorkspaceEvent) => Promise; + /** Force reconnect to the channel */ + reconnect: () => void; +} + +/** + * Hook to subscribe to real-time workspace events using client-side Broadcast + * + * This is a simple pub/sub pattern: + * 1. All clients subscribe to same channel + * 2. When a client saves an event, it broadcasts to the channel + * 3. Other clients receive the broadcast and update their cache + */ +export function useWorkspaceRealtime( + workspaceId: string | null, + options: WorkspaceRealtimeOptions = {} +): WorkspaceRealtimeReturn { + const queryClient = useQueryClient(); + const channelRef = useRef(null); + const { currentUserId, onStatusChange, onRemoteEvent } = options; + const [isConnected, setIsConnected] = useState(false); + + // Clean up channel on unmount or workspaceId change + const cleanup = useCallback(() => { + if (channelRef.current) { + console.log('[REALTIME] Cleaning up channel'); + const supabase = getSupabaseClient(); + supabase.removeChannel(channelRef.current); + channelRef.current = null; + setIsConnected(false); + } + }, []); + + useEffect(() => { + if (!workspaceId) { + cleanup(); + return; + } + + const supabase = getSupabaseClient(); + const channelName = `workspace-${workspaceId}`; + + console.log('[REALTIME] Subscribing to channel:', channelName); + + // Create a public broadcast channel (no RLS needed) + const channel = supabase.channel(channelName, { + config: { + broadcast: { self: false }, // Don't receive our own broadcasts + }, + }); + + channelRef.current = channel; + + // Listen for workspace events broadcast by other clients + channel.on('broadcast', { event: 'workspace_event' }, (payload) => { + console.log('[REALTIME] Received workspace_event:', payload); + + const event = payload.payload as WorkspaceEvent; + + if (!event || !event.id) { + console.warn('[REALTIME] Invalid event payload:', payload); + return; + } + + // Skip our own events (though self: false should handle this) + if (currentUserId && event.userId === currentUserId) { + console.log('[REALTIME] Skipping own event'); + return; + } + + // Notify callback + onRemoteEvent?.(event); + + // Merge into React Query cache + queryClient.setQueryData( + ['workspace', workspaceId, 'events'], + (old) => { + if (!old) { + console.log('[REALTIME] No existing cache to update'); + return old; + } + + // Check if event already exists (by id) + const exists = old.events.some((e) => e.id === event.id); + if (exists) { + console.log('[REALTIME] Event already exists, skipping'); + return old; + } + + console.log('[REALTIME] Adding event to cache, new version:', Math.max(old.version, event.version || 0)); + + return { + ...old, + events: [...old.events, event], + version: Math.max(old.version, event.version || 0), + }; + } + ); + }); + + // Subscribe to channel + channel.subscribe((status) => { + console.log('[REALTIME] Channel status:', status); + switch (status) { + case 'SUBSCRIBED': + setIsConnected(true); + onStatusChange?.('connected'); + break; + case 'CHANNEL_ERROR': + setIsConnected(false); + onStatusChange?.('error'); + break; + case 'CLOSED': + case 'TIMED_OUT': + setIsConnected(false); + onStatusChange?.('disconnected'); + break; + default: + onStatusChange?.('connecting'); + } + }); + + return cleanup; + }, [workspaceId, currentUserId, queryClient, cleanup, onStatusChange, onRemoteEvent]); + + // Broadcast an event to other clients + const broadcastEvent = useCallback(async (event: WorkspaceEvent) => { + if (!channelRef.current) { + console.log('[REALTIME] Cannot broadcast - channel not initialized'); + return; + } + + // Supabase allows broadcasting via HTTP if not connected via WebSocket + if (!isConnected) { + console.log('[REALTIME] Broadcasting via HTTP fallback (not fully connected yet)'); + } + + try { + const result = await channelRef.current.send({ + type: 'broadcast', + event: 'workspace_event', + payload: event, + }); + console.log('[REALTIME] Broadcast result:', result, 'Event:', event.type); + } catch (err) { + console.error('[REALTIME] Failed to broadcast:', err); + } + }, [isConnected]); + + return { + isConnected, + broadcastEvent, + reconnect: useCallback(() => { + if (!workspaceId) return; + cleanup(); + }, [workspaceId, cleanup]), + }; +} diff --git a/src/lib/api/workspace-helpers.ts b/src/lib/api/workspace-helpers.ts index 69ca89b8..abd04181 100644 --- a/src/lib/api/workspace-helpers.ts +++ b/src/lib/api/workspace-helpers.ts @@ -2,7 +2,8 @@ import { NextResponse } from "next/server"; import { headers } from "next/headers"; import { auth } from "@/lib/auth"; import { db, workspaces } from "@/lib/db/client"; -import { eq } from "drizzle-orm"; +import { workspaceCollaborators } from "@/lib/db/schema"; +import { eq, and } from "drizzle-orm"; /** * Get authenticated user from session @@ -49,6 +50,57 @@ export async function verifyWorkspaceOwnership( return workspace[0]; } +/** + * Verify workspace access (owner OR collaborator) + * Returns access info including permission level + * Throws NextResponse errors for unauthorized/not found cases + */ +export async function verifyWorkspaceAccess( + workspaceId: string, + userId: string, + requiredPermission: 'viewer' | 'editor' = 'viewer' +): Promise<{ isOwner: boolean; permissionLevel: 'owner' | 'editor' | 'viewer' }> { + // Check if workspace exists and get owner + const workspace = await db + .select({ userId: workspaces.userId }) + .from(workspaces) + .where(eq(workspaces.id, workspaceId)) + .limit(1); + + if (!workspace[0]) { + throw NextResponse.json({ error: "Workspace not found" }, { status: 404 }); + } + + // Owner has full access + if (workspace[0].userId === userId) { + return { isOwner: true, permissionLevel: 'owner' }; + } + + // Check if user is a collaborator + const [collaborator] = await db + .select({ permissionLevel: workspaceCollaborators.permissionLevel }) + .from(workspaceCollaborators) + .where( + and( + eq(workspaceCollaborators.workspaceId, workspaceId), + eq(workspaceCollaborators.userId, userId) + ) + ) + .limit(1); + + if (!collaborator) { + throw NextResponse.json({ error: "Access denied" }, { status: 403 }); + } + + // Check if user has required permission level + const permLevel = collaborator.permissionLevel as 'editor' | 'viewer'; + if (requiredPermission === 'editor' && permLevel !== 'editor') { + throw NextResponse.json({ error: "Editor access required" }, { status: 403 }); + } + + return { isOwner: false, permissionLevel: permLevel }; +} + /** * Verify workspace ownership and return full workspace data * Throws NextResponse errors for unauthorized/not found cases @@ -89,7 +141,7 @@ export function withErrorHandling( if (error instanceof Response) { return error as NextResponse; } - + console.error(`Error in ${routeName}:`, error); return NextResponse.json({ error: "Internal server error" }, { status: 500 }); } diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 226c5ea2..7dc4daef 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -174,6 +174,37 @@ export const workspaceEvents = pgTable("workspace_events", { pgPolicy("Users can insert workspace events they have write access to", { as: "permissive", for: "insert", to: ["public"], withCheck: sql`(EXISTS ( SELECT 1 FROM workspaces - WHERE ((workspaces.id = workspace_events.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text)))))` }), - pgPolicy("Users can read workspace events they have access to", { as: "permissive", for: "select", to: ["public"] }), + WHERE ((workspaces.id = workspace_events.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (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'::text))))` }), + pgPolicy("Users can read workspace events they have access to", { + as: "permissive", for: "select", to: ["public"], using: sql`(EXISTS ( SELECT 1 + FROM workspaces + WHERE ((workspaces.id = workspace_events.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1 + FROM workspace_collaborators c + WHERE ((c.workspace_id = workspace_events.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)))))` }), +]); + +export const workspaceCollaborators = pgTable("workspace_collaborators", { + id: uuid().defaultRandom().primaryKey().notNull(), + workspaceId: uuid("workspace_id").notNull(), + userId: text("user_id").notNull(), + permissionLevel: text("permission_level").default('editor').notNull(), + inviteToken: text("invite_token"), + createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow(), +}, (table) => [ + index("idx_workspace_collaborators_lookup").using("btree", table.userId.asc().nullsLast().op("text_ops"), table.workspaceId.asc().nullsLast().op("uuid_ops")), + index("idx_workspace_collaborators_workspace").using("btree", table.workspaceId.asc().nullsLast().op("uuid_ops")), + foreignKey({ + columns: [table.workspaceId], + foreignColumns: [workspaces.id], + name: "workspace_collaborators_workspace_id_fkey" + }).onDelete("cascade"), + unique("workspace_collaborators_invite_token_unique").on(table.inviteToken), + unique("workspace_collaborators_workspace_user_unique").on(table.workspaceId, table.userId), + pgPolicy("Owners can manage collaborators", { + as: "permissive", for: "all", to: ["authenticated"], using: sql`(EXISTS ( SELECT 1 + FROM workspaces w + WHERE ((w.id = workspace_collaborators.workspace_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text)))))` }), + pgPolicy("Collaborators can view their access", { as: "permissive", for: "select", to: ["authenticated"], using: sql`(user_id = (auth.jwt() ->> 'sub'::text))` }), ]); diff --git a/src/lib/db/types.ts b/src/lib/db/types.ts index bf0426a9..3b9d35d2 100644 --- a/src/lib/db/types.ts +++ b/src/lib/db/types.ts @@ -8,7 +8,8 @@ import { workspaces, workspaceEvents, workspaceSnapshots, - userProfiles + userProfiles, + workspaceCollaborators } from './schema'; import type { AgentState } from '@/lib/workspace-state/types'; @@ -28,6 +29,11 @@ export type WorkspaceSnapshotInsert = InferInsertModel; export type UserProfileInsert = InferInsertModel; +export type WorkspaceCollaborator = InferSelectModel; +export type WorkspaceCollaboratorInsert = InferInsertModel; + +export type PermissionLevel = 'viewer' | 'editor'; + // Extended types for frontend use export interface WorkspaceWithState extends Workspace { diff --git a/src/lib/supabase-client.ts b/src/lib/supabase-client.ts new file mode 100644 index 00000000..b2bbc2c0 --- /dev/null +++ b/src/lib/supabase-client.ts @@ -0,0 +1,46 @@ +/** + * Supabase client singleton for client-side use + * Used for Realtime subscriptions and storage operations + */ + +import { createClient, SupabaseClient } from '@supabase/supabase-js'; + +let supabaseClient: SupabaseClient | null = null; + +/** + * Get the Supabase client singleton for client-side operations + * Lazily initializes the client on first call + */ +export function getSupabaseClient(): SupabaseClient { + if (supabaseClient) { + return supabaseClient; + } + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + + if (!supabaseUrl || !supabaseAnonKey) { + throw new Error( + 'Missing Supabase environment variables: NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY are required' + ); + } + + supabaseClient = createClient(supabaseUrl, supabaseAnonKey, { + realtime: { + params: { + eventsPerSecond: 10, + }, + }, + }); + + return supabaseClient; +} + +/** + * Set the auth token for the Supabase realtime connection + * Should be called after user logs in or token refreshes + */ +export async function setRealtimeAuth(accessToken: string): Promise { + const client = getSupabaseClient(); + await client.realtime.setAuth(accessToken); +} From ef451b99ddae4dc4f15c6b3087f52b2436719c97 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Mon, 2 Feb 2026 22:52:02 -0500 Subject: [PATCH 2/5] fix: code review --- .../collaborators/[collaboratorId]/route.ts | 6 +- .../workspaces/[id]/collaborators/route.ts | 287 +++++++++--------- .../api/workspaces/[id]/track-open/route.ts | 63 ++-- src/app/api/workspaces/route.ts | 94 +++--- src/hooks/workspace/use-workspace-presence.ts | 8 +- src/hooks/workspace/use-workspace-realtime.ts | 3 +- src/lib/db/schema.ts | 2 + 7 files changed, 252 insertions(+), 211 deletions(-) diff --git a/src/app/api/workspaces/[id]/collaborators/[collaboratorId]/route.ts b/src/app/api/workspaces/[id]/collaborators/[collaboratorId]/route.ts index 5b7e8d0c..97c43911 100644 --- a/src/app/api/workspaces/[id]/collaborators/[collaboratorId]/route.ts +++ b/src/app/api/workspaces/[id]/collaborators/[collaboratorId]/route.ts @@ -34,7 +34,8 @@ export async function PATCH( // Verify ownership try { await verifyWorkspaceOwnership(workspaceId, session.user.id); - } catch { + } catch (error) { + if (error instanceof Response) return error; return NextResponse.json({ error: "Workspace not found" }, { status: 404 }); } @@ -76,7 +77,8 @@ export async function DELETE( // Verify ownership try { await verifyWorkspaceOwnership(workspaceId, session.user.id); - } catch { + } catch (error) { + if (error instanceof Response) return error; return NextResponse.json({ error: "Workspace not found" }, { status: 404 }); } diff --git a/src/app/api/workspaces/[id]/collaborators/route.ts b/src/app/api/workspaces/[id]/collaborators/route.ts index 37c9ccf5..3a1308cb 100644 --- a/src/app/api/workspaces/[id]/collaborators/route.ts +++ b/src/app/api/workspaces/[id]/collaborators/route.ts @@ -5,164 +5,159 @@ * POST /api/workspaces/[id]/collaborators - Invite a new collaborator */ +/** + * Collaborators API - List and invite collaborators + * + * GET /api/workspaces/[id]/collaborators - List collaborators + * POST /api/workspaces/[id]/collaborators - Invite a new 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, workspaces, user } from "@/lib/db/schema"; import { eq, and } from "drizzle-orm"; -import { verifyWorkspaceAccess } from "@/lib/api/workspace-helpers"; - -export async function GET( +import { + verifyWorkspaceAccess, + withErrorHandling, + requireAuth, + requireAuthWithUserInfo +} from "@/lib/api/workspace-helpers"; + +// GET /api/workspaces/[id]/collaborators +async function handleGET( request: NextRequest, { params }: { params: Promise<{ id: 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 } = await params; - - // Verify access (viewers can see collaborators) - try { - await verifyWorkspaceAccess(workspaceId, session.user.id, "viewer"); - } catch (error) { - if (error instanceof Response) return error; - return NextResponse.json({ error: "Workspace not found" }, { status: 404 }); - } - - - // Get owner details - const [workspaceOwner] = await db - .select({ - userId: user.id, - name: user.name, - email: user.email, - image: user.image, - createdAt: workspaces.createdAt, - }) - .from(workspaces) - .leftJoin(user, eq(workspaces.userId, user.id)) - .where(eq(workspaces.id, workspaceId)); - - // Get collaborators with user info - const collaborators = await db - .select({ - id: workspaceCollaborators.id, - userId: workspaceCollaborators.userId, - permissionLevel: workspaceCollaborators.permissionLevel, - createdAt: workspaceCollaborators.createdAt, - name: user.name, - email: user.email, - image: user.image, - }) - .from(workspaceCollaborators) - .leftJoin(user, eq(workspaceCollaborators.userId, user.id)) - .where(eq(workspaceCollaborators.workspaceId, workspaceId)); - - const ownerAsCollaborator = workspaceOwner ? { - id: `owner-${workspaceOwner.userId}`, - userId: workspaceOwner.userId, - permissionLevel: "owner", - createdAt: workspaceOwner.createdAt, - name: workspaceOwner.name, - email: workspaceOwner.email, - image: workspaceOwner.image - } : null; - - const allCollaborators = ownerAsCollaborator - ? [ownerAsCollaborator, ...collaborators] - : collaborators; - - return NextResponse.json({ collaborators: allCollaborators }); - } catch (error) { - console.error("Error fetching collaborators:", error); - return NextResponse.json({ error: "Internal server error" }, { status: 500 }); - } + const paramsPromise = params; + const authPromise = requireAuth(); + + const { id: workspaceId } = await paramsPromise; + const userId = await authPromise; + + // Verify access (viewers can see collaborators) + await verifyWorkspaceAccess(workspaceId, userId, "viewer"); + + // Get owner details + const [workspaceOwner] = await db + .select({ + userId: user.id, + name: user.name, + email: user.email, + image: user.image, + createdAt: workspaces.createdAt, + }) + .from(workspaces) + .leftJoin(user, eq(workspaces.userId, user.id)) + .where(eq(workspaces.id, workspaceId)); + + // Get collaborators with user info + const collaborators = await db + .select({ + id: workspaceCollaborators.id, + userId: workspaceCollaborators.userId, + permissionLevel: workspaceCollaborators.permissionLevel, + createdAt: workspaceCollaborators.createdAt, + name: user.name, + email: user.email, + image: user.image, + }) + .from(workspaceCollaborators) + .leftJoin(user, eq(workspaceCollaborators.userId, user.id)) + .where(eq(workspaceCollaborators.workspaceId, workspaceId)); + + const ownerAsCollaborator = workspaceOwner ? { + id: `owner-${workspaceOwner.userId}`, + userId: workspaceOwner.userId, + permissionLevel: "owner", + createdAt: workspaceOwner.createdAt, + name: workspaceOwner.name, + email: workspaceOwner.email, + image: workspaceOwner.image + } : null; + + const allCollaborators = ownerAsCollaborator + ? [ownerAsCollaborator, ...collaborators] + : collaborators; + + return NextResponse.json({ collaborators: allCollaborators }); } -export async function POST( +// POST /api/workspaces/[id]/collaborators +async function handlePOST( request: NextRequest, { params }: { params: Promise<{ id: 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 } = await params; - const body = await request.json(); - const { email, permissionLevel = "editor" } = body; - - if (!email || typeof email !== "string") { - return NextResponse.json({ error: "Email is required" }, { status: 400 }); - } - - // Verify access (only editors/owners can invite) - try { - await verifyWorkspaceAccess(workspaceId, session.user.id, "editor"); - } catch (error) { - if (error instanceof Response) return error; - return NextResponse.json({ error: "Workspace not found" }, { status: 404 }); - } - - // Find the user by email - const [invitedUser] = await db - .select({ id: user.id }) - .from(user) - .where(eq(user.email, email.trim().toLowerCase())) - .limit(1); - - if (!invitedUser) { - return NextResponse.json( - { message: "User not found. They need to sign up first." }, - { status: 404 } - ); - } - - // Check if already a collaborator - const [existing] = await db - .select({ id: workspaceCollaborators.id }) - .from(workspaceCollaborators) - .where( - and( - eq(workspaceCollaborators.workspaceId, workspaceId), - eq(workspaceCollaborators.userId, invitedUser.id) - ) + const paramsPromise = params; + const authPromise = requireAuthWithUserInfo(); + + const { id: workspaceId } = await paramsPromise; + const currentUser = await authPromise; + + const body = await request.json(); + const { email, permissionLevel = "editor" } = body; + + if (!email || typeof email !== "string") { + return NextResponse.json({ error: "Email is required" }, { status: 400 }); + } + + // Verify access (only editors/owners can invite) + // Note: The original code allowed editors to invite. + // Usually only owners/admins invite, but respecting original logic: + await verifyWorkspaceAccess(workspaceId, currentUser.userId, "editor"); + + // Find the user by email + const [invitedUser] = await db + .select({ id: user.id }) + .from(user) + .where(eq(user.email, email.trim().toLowerCase())) + .limit(1); + + if (!invitedUser) { + return NextResponse.json( + { message: "User not found. They need to sign up first." }, + { status: 404 } + ); + } + + // Check if already a collaborator + const [existing] = await db + .select({ id: workspaceCollaborators.id }) + .from(workspaceCollaborators) + .where( + and( + eq(workspaceCollaborators.workspaceId, workspaceId), + eq(workspaceCollaborators.userId, invitedUser.id) ) - .limit(1); - - if (existing) { - return NextResponse.json( - { message: "User is already a collaborator" }, - { status: 409 } - ); - } - - // Can't invite yourself - if (invitedUser.id === session.user.id) { - return NextResponse.json( - { message: "You can't invite yourself" }, - { status: 400 } - ); - } - - // Add collaborator - const [newCollaborator] = await db - .insert(workspaceCollaborators) - .values({ - workspaceId, - userId: invitedUser.id, - permissionLevel: permissionLevel === "viewer" ? "viewer" : "editor", - }) - .returning(); - - return NextResponse.json({ collaborator: newCollaborator }, { status: 201 }); - } catch (error) { - console.error("Error adding collaborator:", error); - return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + ) + .limit(1); + + if (existing) { + return NextResponse.json( + { message: "User is already a collaborator" }, + { status: 409 } + ); + } + + // Can't invite yourself + if (invitedUser.id === currentUser.userId) { + return NextResponse.json( + { message: "You can't invite yourself" }, + { status: 400 } + ); } + + // Add collaborator + const [newCollaborator] = await db + .insert(workspaceCollaborators) + .values({ + workspaceId, + userId: invitedUser.id, + permissionLevel: permissionLevel === "viewer" ? "viewer" : "editor", + }) + .returning(); + + return NextResponse.json({ collaborator: newCollaborator }, { status: 201 }); } + +export const GET = withErrorHandling(handleGET, "GET /api/workspaces/[id]/collaborators"); +export const POST = withErrorHandling(handlePOST, "POST /api/workspaces/[id]/collaborators"); diff --git a/src/app/api/workspaces/[id]/track-open/route.ts b/src/app/api/workspaces/[id]/track-open/route.ts index aa6ab506..94551a27 100644 --- a/src/app/api/workspaces/[id]/track-open/route.ts +++ b/src/app/api/workspaces/[id]/track-open/route.ts @@ -1,7 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; import { db, workspaces } from "@/lib/db/client"; -import { eq } from "drizzle-orm"; -import { requireAuth, verifyWorkspaceAccess, withErrorHandling } from "@/lib/api/workspace-helpers"; +import { workspaceCollaborators } from "@/lib/db/schema"; +import { eq, and } from "drizzle-orm"; +import { requireAuthWithUserInfo, verifyWorkspaceAccess, withErrorHandling } from "@/lib/api/workspace-helpers"; /** * POST /api/workspaces/[id]/track-open @@ -14,32 +15,54 @@ async function handlePOST( ) { // Start independent operations in parallel const paramsPromise = params; - const authPromise = requireAuth(); + // Get user info + const user = await requireAuthWithUserInfo(); const { id } = await paramsPromise; - const userId = await authPromise; + const userId = user.userId; // Check access (owner or collaborator) - await verifyWorkspaceAccess(id, userId, 'viewer'); - - // Update lastOpenedAt to current timestamp - const [updatedWorkspace] = await db - .update(workspaces) - .set({ lastOpenedAt: new Date().toISOString() }) - .where(eq(workspaces.id, id)) - .returning(); - - // Guard against empty update result (workspace deleted between ownership check and update) - if (!updatedWorkspace) { - return NextResponse.json( - { error: "Workspace not found" }, - { status: 404 } - ); + // We need to know IF they are owner to decide which table to update + const { isOwner } = await verifyWorkspaceAccess(id, userId, 'viewer'); + + const now = new Date().toISOString(); + let lastOpenedAt = now; + + if (isOwner) { + // Update owner's lastOpenedAt on the workspace itself + const [updatedWorkspace] = await db + .update(workspaces) + .set({ lastOpenedAt: now }) + .where(eq(workspaces.id, id)) + .returning(); + + if (!updatedWorkspace) { + return NextResponse.json({ error: "Workspace not found" }, { status: 404 }); + } + lastOpenedAt = updatedWorkspace.lastOpenedAt || now; + } else { + // Update collaborator's lastOpenedAt on the junction table + const [updatedCollaborator] = await db + .update(workspaceCollaborators) + .set({ lastOpenedAt: now }) + .where( + and( + eq(workspaceCollaborators.workspaceId, id), + eq(workspaceCollaborators.userId, userId) + ) + ) + .returning(); + + if (!updatedCollaborator) { + // Should not happen if verifyWorkspaceAccess passed, but good safeguard + return NextResponse.json({ error: "Collaborator record not found" }, { status: 404 }); + } + lastOpenedAt = updatedCollaborator.lastOpenedAt || now; } return NextResponse.json({ success: true, - lastOpenedAt: updatedWorkspace.lastOpenedAt + lastOpenedAt }); } diff --git a/src/app/api/workspaces/route.ts b/src/app/api/workspaces/route.ts index 1012af2c..57cf4f4c 100644 --- a/src/app/api/workspaces/route.ts +++ b/src/app/api/workspaces/route.ts @@ -17,23 +17,18 @@ async function handleGET() { const userId = await requireAuth(); // Get workspaces owned by user - // Order by: - // 1. lastOpenedAt DESC (most recently opened first, NULLs last) - // 2. sortOrder ASC (user-defined order for workspaces never opened, NULLs last) - // 3. updatedAt DESC (fallback for workspaces without sortOrder or lastOpenedAt) const ownedWorkspaces = await db .select() .from(workspaces) - .where(eq(workspaces.userId, userId)) - .orderBy( - sql`${workspaces.lastOpenedAt} DESC NULLS LAST`, - sql`${workspaces.sortOrder} ASC NULLS LAST`, - desc(workspaces.updatedAt) - ); + .where(eq(workspaces.userId, userId)); // Get workspaces user is a collaborator on const collaborations = await db - .select({ workspaceId: workspaceCollaborators.workspaceId, permissionLevel: workspaceCollaborators.permissionLevel }) + .select({ + workspaceId: workspaceCollaborators.workspaceId, + permissionLevel: workspaceCollaborators.permissionLevel, + lastOpenedAt: workspaceCollaborators.lastOpenedAt + }) .from(workspaceCollaborators) .where(eq(workspaceCollaborators.userId, userId)); @@ -43,18 +38,14 @@ async function handleGET() { sharedWorkspaces = await db .select() .from(workspaces) - .where(inArray(workspaces.id, sharedWorkspaceIds)) - .orderBy( - sql`${workspaces.lastOpenedAt} DESC NULLS LAST`, - desc(workspaces.updatedAt) - ); + .where(inArray(workspaces.id, sharedWorkspaceIds)); // No sort here, we sort in JS } - // Create a map of permission levels for shared workspaces - const permissionMap = new Map(collaborations.map(c => [c.workspaceId, c.permissionLevel])); + // Create a map of permission levels and lastOpened for shared workspaces + const collaborationMap = new Map(collaborations.map(c => [c.workspaceId, c])); // Format owned workspaces - const ownedList: (WorkspaceWithState & { isShared?: boolean; permissionLevel?: string })[] = ownedWorkspaces.map((w) => ({ + const ownedList = ownedWorkspaces.map((w) => ({ id: w.id, userId: w.userId, name: w.name, @@ -67,32 +58,57 @@ async function handleGET() { icon: w.icon, sortOrder: w.sortOrder ?? null, color: w.color as CardColor | null, - lastOpenedAt: w.lastOpenedAt ?? null, + lastOpenedAt: w.lastOpenedAt ?? null, // Owner uses workspace field isShared: false, })); // Format shared workspaces - const sharedList: (WorkspaceWithState & { isShared?: boolean; permissionLevel?: string })[] = sharedWorkspaces.map((w) => ({ - id: w.id, - userId: w.userId, - name: w.name, - description: w.description || '', - template: (w.template as WorkspaceTemplate) || 'blank', - isPublic: w.isPublic || false, - createdAt: w.createdAt || '', - updatedAt: w.updatedAt || '', - slug: w.slug || '', - icon: w.icon, - sortOrder: w.sortOrder ?? null, - color: w.color as CardColor | null, - lastOpenedAt: w.lastOpenedAt ?? null, - isShared: true, - permissionLevel: permissionMap.get(w.id) || 'viewer', - })); - - // Merge lists - owned first, then shared + const sharedList = sharedWorkspaces.map((w) => { + const collaboration = collaborationMap.get(w.id); + return { + id: w.id, + userId: w.userId, + name: w.name, + description: w.description || '', + template: (w.template as WorkspaceTemplate) || 'blank', + isPublic: w.isPublic || false, + createdAt: w.createdAt || '', + updatedAt: w.updatedAt || '', + slug: w.slug || '', + icon: w.icon, + sortOrder: w.sortOrder ?? null, + color: w.color as CardColor | null, + lastOpenedAt: collaboration?.lastOpenedAt ?? null, // Collaborator uses junction field + isShared: true, + permissionLevel: collaboration?.permissionLevel || 'viewer', + }; + }); + + // Merge lists const workspaceList = [...ownedList, ...sharedList]; + // Sort by lastOpenedAt DESC, then sortOrder ASC, then updatedAt DESC + workspaceList.sort((a, b) => { + // 1. lastOpenedAt DESC (most recent first) + if (a.lastOpenedAt && b.lastOpenedAt) { + return new Date(b.lastOpenedAt).getTime() - new Date(a.lastOpenedAt).getTime(); + } + if (a.lastOpenedAt) return -1; // a has date, goes first + if (b.lastOpenedAt) return 1; // b has date, goes first + + // 2. sortOrder ASC (nulls last) + if (a.sortOrder !== null && b.sortOrder !== null) { + return a.sortOrder - b.sortOrder; + } + if (a.sortOrder !== null) return -1; // a has order, goes first + if (b.sortOrder !== null) return 1; + + // 3. updatedAt DESC (fallback) + const dateA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0; + const dateB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0; + return dateB - dateA; + }); + return NextResponse.json({ workspaces: workspaceList }); } diff --git a/src/hooks/workspace/use-workspace-presence.ts b/src/hooks/workspace/use-workspace-presence.ts index 9ad105fa..19de2cad 100644 --- a/src/hooks/workspace/use-workspace-presence.ts +++ b/src/hooks/workspace/use-workspace-presence.ts @@ -41,6 +41,8 @@ export function useWorkspacePresence( const { currentUser } = options; const [collaborators, setCollaborators] = useState([]); const channelRef = useRef(null); + // Keep joinedAt stable across re-renders + const joinedAtRef = useRef(new Date().toISOString()); // Clean up channel const cleanup = useCallback(() => { @@ -94,13 +96,13 @@ export function useWorkspacePresence( userId: currentUser.id, userName: currentUser.name, userImage: currentUser.image, - joinedAt: new Date().toISOString(), + joinedAt: joinedAtRef.current, }); } }); return cleanup; - }, [workspaceId, currentUser?.id, cleanup]); + }, [workspaceId, currentUser?.id, currentUser?.name, currentUser?.image, cleanup]); // Update presence when user info changes useEffect(() => { @@ -111,7 +113,7 @@ export function useWorkspacePresence( userId: currentUser.id, userName: currentUser.name, userImage: currentUser.image, - joinedAt: new Date().toISOString(), + joinedAt: joinedAtRef.current, }); }, [currentUser]); diff --git a/src/hooks/workspace/use-workspace-realtime.ts b/src/hooks/workspace/use-workspace-realtime.ts index b9bcb7c5..062f2900 100644 --- a/src/hooks/workspace/use-workspace-realtime.ts +++ b/src/hooks/workspace/use-workspace-realtime.ts @@ -64,7 +64,8 @@ export function useWorkspaceRealtime( } const supabase = getSupabaseClient(); - const channelName = `workspace-${workspaceId}`; + // Use colon format to match RLS policies in database + const channelName = `workspace:${workspaceId}:events`; console.log('[REALTIME] Subscribing to channel:', channelName); diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 7dc4daef..0364cd6e 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -191,10 +191,12 @@ export const workspaceCollaborators = pgTable("workspace_collaborators", { userId: text("user_id").notNull(), permissionLevel: text("permission_level").default('editor').notNull(), inviteToken: text("invite_token"), + lastOpenedAt: timestamp("last_opened_at", { withTimezone: true, mode: 'string' }), createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow(), }, (table) => [ index("idx_workspace_collaborators_lookup").using("btree", table.userId.asc().nullsLast().op("text_ops"), table.workspaceId.asc().nullsLast().op("uuid_ops")), index("idx_workspace_collaborators_workspace").using("btree", table.workspaceId.asc().nullsLast().op("uuid_ops")), + index("idx_workspace_collaborators_last_opened_at").using("btree", table.userId.asc().nullsLast().op("text_ops"), table.lastOpenedAt.desc().nullsFirst().op("timestamptz_ops")), foreignKey({ columns: [table.workspaceId], foreignColumns: [workspaces.id], From 4ea39d39a541f89a642bcab4da1668e27580ca2c Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Mon, 2 Feb 2026 23:04:51 -0500 Subject: [PATCH 3/5] Update route.ts --- src/app/api/workspaces/route.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/app/api/workspaces/route.ts b/src/app/api/workspaces/route.ts index 57cf4f4c..43cbb475 100644 --- a/src/app/api/workspaces/route.ts +++ b/src/app/api/workspaces/route.ts @@ -84,8 +84,10 @@ async function handleGET() { }; }); - // Merge lists - const workspaceList = [...ownedList, ...sharedList]; + // Merge lists (filtering out shared workspaces that are also owned) + const ownedIds = new Set(ownedList.map(w => w.id)); + const uniqueSharedList = sharedList.filter(w => !ownedIds.has(w.id)); + const workspaceList = [...ownedList, ...uniqueSharedList]; // Sort by lastOpenedAt DESC, then sortOrder ASC, then updatedAt DESC workspaceList.sort((a, b) => { From aff9996d3c9190364a3cd3012b2e6513b60add0e Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Mon, 2 Feb 2026 23:06:53 -0500 Subject: [PATCH 4/5] fix: code review --- src/app/api/workspaces/[id]/collaborators/route.ts | 14 ++++++++++++++ src/hooks/workspace/use-workspace-presence.ts | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/app/api/workspaces/[id]/collaborators/route.ts b/src/app/api/workspaces/[id]/collaborators/route.ts index 3a1308cb..1de552c9 100644 --- a/src/app/api/workspaces/[id]/collaborators/route.ts +++ b/src/app/api/workspaces/[id]/collaborators/route.ts @@ -146,6 +146,20 @@ async function handlePOST( ); } + // Get workspace to check if invitee is the owner + const [ws] = await db + .select({ userId: workspaces.userId }) + .from(workspaces) + .where(eq(workspaces.id, workspaceId)) + .limit(1); + + if (ws && invitedUser.id === ws.userId) { + return NextResponse.json( + { message: "Cannot invite workspace owner as collaborator" }, + { status: 400 } + ); + } + // Add collaborator const [newCollaborator] = await db .insert(workspaceCollaborators) diff --git a/src/hooks/workspace/use-workspace-presence.ts b/src/hooks/workspace/use-workspace-presence.ts index 19de2cad..cd9a6ca2 100644 --- a/src/hooks/workspace/use-workspace-presence.ts +++ b/src/hooks/workspace/use-workspace-presence.ts @@ -102,7 +102,7 @@ export function useWorkspacePresence( }); return cleanup; - }, [workspaceId, currentUser?.id, currentUser?.name, currentUser?.image, cleanup]); + }, [workspaceId, currentUser?.id, cleanup]); // Update presence when user info changes useEffect(() => { From b93fd7f48a40aca01a0fea39b09c55c1c515a78c Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Mon, 2 Feb 2026 23:10:39 -0500 Subject: [PATCH 5/5] fix: remove role dropdwon --- .../workspace/ShareWorkspaceDialog.tsx | 22 +++---------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/src/components/workspace/ShareWorkspaceDialog.tsx b/src/components/workspace/ShareWorkspaceDialog.tsx index 141e5148..db0b697f 100644 --- a/src/components/workspace/ShareWorkspaceDialog.tsx +++ b/src/components/workspace/ShareWorkspaceDialog.tsx @@ -314,20 +314,8 @@ export default function ShareWorkspaceDialog({ Owner - ) : canManage ? ( - <> - + ) : ( + canManage && ( - - ) : ( - - {collab.permissionLevel} - + ) )}