From 832972766d0cfcffc359df57917186a017314979 Mon Sep 17 00:00:00 2001 From: urjitc <135136842+urjitc@users.noreply.github.com> Date: Thu, 23 Apr 2026 02:07:35 +0000 Subject: [PATCH 1/4] Add one-time midterms raffle popup with share-link UI on workspace routes --- src/app/dashboard/page.tsx | 7 + src/components/modals/MidtermsRafflePopup.tsx | 197 ++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 src/components/modals/MidtermsRafflePopup.tsx diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index c1d9bfff..1a7ecaa3 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -18,6 +18,7 @@ import { useSession } from "@/lib/auth-client"; import { WorkspaceSection } from "@/components/workspace-canvas/WorkspaceSection"; import { OpenWorkspaceItemView } from "@/components/workspace-canvas/OpenWorkspaceItemView"; import { AnonymousSignInPrompt } from "@/components/modals/AnonymousSignInPrompt"; +import { MidtermsRafflePopup } from "@/components/modals/MidtermsRafflePopup"; import { DashboardLayout } from "@/components/layout/DashboardLayout"; import WorkspaceHeader from "@/components/workspace-canvas/WorkspaceHeader"; import { WorkspaceSearchDialog } from "@/components/workspace-canvas/WorkspaceSearchDialog"; @@ -279,6 +280,12 @@ function DashboardContent({ } }} /> + setShowWorkspaceShare(true)} + /> void; +} + +const FEATURE_KEY = "midterms-raffle-2026-04"; +const FEATURE_END_DATE = new Date(2026, 3, 24, 23, 59, 59); + +export function MidtermsRafflePopup({ + workspace, + currentWorkspaceId, + isLoadingWorkspace, + onOpenFullShare, +}: MidtermsRafflePopupProps) { + const { data: session } = useSession(); + const { isNew, dismiss } = useNewFeature({ + featureKey: FEATURE_KEY, + endDate: FEATURE_END_DATE, + }); + const [linkMode, setLinkMode] = useState<"collaborate" | "deepcopy">("collaborate"); + const [shareLinkUrl, setShareLinkUrl] = useState(""); + const [isLoadingShareLink, setIsLoadingShareLink] = useState(false); + const [copied, setCopied] = useState(false); + + const canRender = + !!currentWorkspaceId && + !isLoadingWorkspace && + !!workspace && + session?.user?.isAnonymous !== true && + workspace?.userId === session?.user?.id && + isNew; + + useEffect(() => { + if (!canRender || !workspace) return; + + setIsLoadingShareLink(true); + setShareLinkUrl(""); + fetch(`/api/workspaces/${workspace.id}/share-link`, { method: "POST" }) + .then((res) => res.json()) + .then((data) => { + if (data.url) setShareLinkUrl(data.url); + }) + .catch(console.error) + .finally(() => setIsLoadingShareLink(false)); + }, [canRender, workspace]); + + if (!canRender || !workspace) { + return null; + } + + const deepCopyUrl = `${typeof window !== "undefined" ? window.location.origin : ""}/share-copy/${workspace.id}`; + const activeUrl = linkMode === "collaborate" ? shareLinkUrl : deepCopyUrl; + const isActiveLoading = linkMode === "collaborate" && isLoadingShareLink; + + const handleCopyShareLink = async () => { + if (!activeUrl) return; + + try { + await navigator.clipboard.writeText(activeUrl); + setCopied(true); + toast.success("Link copied"); + setTimeout(() => setCopied(false), 2000); + } catch { + toast.error("Failed to copy"); + } + }; + + return ( + { + if (!next) dismiss(); + }} + > + + +
+
+ +
+ + Midterms raffle + +
+ Happy midterms! + + Share this workspace with 5 or more classmates and you'll be entered + in our raffle to win a $50 Amazon gift card. Copy your share link + below and send it over — each person who joins counts. + +
+ +
+ + +
+ +
+ + +
+ +

+ {linkMode === "collaborate" + ? "Adds them as a collaborator. Expires in 7 days." + : "Recipient gets their own independent copy. Does not expire."} +

+ + + {onOpenFullShare ? ( + + ) : ( + + )} + + +
+
+ ); +} From b011cf6b31264a51e01a9dd0a64ab8ca53c86478 Mon Sep 17 00:00:00 2001 From: urjitc <135136842+urjitc@users.noreply.github.com> Date: Thu, 23 Apr 2026 02:11:27 +0000 Subject: [PATCH 2/4] Fix raffle popup share-link fetch race Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- src/components/modals/MidtermsRafflePopup.tsx | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/components/modals/MidtermsRafflePopup.tsx b/src/components/modals/MidtermsRafflePopup.tsx index 6199155c..6ea20897 100644 --- a/src/components/modals/MidtermsRafflePopup.tsx +++ b/src/components/modals/MidtermsRafflePopup.tsx @@ -55,15 +55,30 @@ export function MidtermsRafflePopup({ useEffect(() => { if (!canRender || !workspace) return; + const controller = new AbortController(); setIsLoadingShareLink(true); setShareLinkUrl(""); - fetch(`/api/workspaces/${workspace.id}/share-link`, { method: "POST" }) + + fetch(`/api/workspaces/${workspace.id}/share-link`, { + method: "POST", + signal: controller.signal, + }) .then((res) => res.json()) .then((data) => { if (data.url) setShareLinkUrl(data.url); }) - .catch(console.error) - .finally(() => setIsLoadingShareLink(false)); + .catch((err) => { + if ((err as { name?: string } | null)?.name !== "AbortError") { + console.error(err); + } + }) + .finally(() => { + if (!controller.signal.aborted) setIsLoadingShareLink(false); + }); + + return () => { + controller.abort(); + }; }, [canRender, workspace]); if (!canRender || !workspace) { From 1f8aa7153980750e735b0bb551f04c2de8f344a2 Mon Sep 17 00:00:00 2001 From: urjitc <135136842+urjitc@users.noreply.github.com> Date: Thu, 23 Apr 2026 03:08:40 +0000 Subject: [PATCH 3/4] Add raffle share-link tracking and progress badge --- drizzle/0003_famous_skaar.sql | 1 + drizzle/meta/0003_snapshot.json | 2083 +++++++++++++++++ drizzle/meta/_journal.json | 7 + src/app/api/share-link/claim/route.ts | 6 +- .../api/workspaces/[id]/share-link/route.ts | 23 + src/app/dashboard/page.tsx | 9 +- src/app/invite/claim/[token]/page.tsx | 19 +- ...idtermsRafflePopup.tsx => RafflePopup.tsx} | 33 +- .../workspace-canvas/ShareCountBadge.tsx | 82 + .../workspace-canvas/WorkspaceHeader.tsx | 9 + src/lib/db/schema.ts | 2 +- 11 files changed, 2251 insertions(+), 23 deletions(-) create mode 100644 drizzle/0003_famous_skaar.sql create mode 100644 drizzle/meta/0003_snapshot.json rename src/components/modals/{MidtermsRafflePopup.tsx => RafflePopup.tsx} (88%) create mode 100644 src/components/workspace-canvas/ShareCountBadge.tsx diff --git a/drizzle/0003_famous_skaar.sql b/drizzle/0003_famous_skaar.sql new file mode 100644 index 00000000..3511c3e8 --- /dev/null +++ b/drizzle/0003_famous_skaar.sql @@ -0,0 +1 @@ +ALTER TABLE "workspace_share_links" ADD COLUMN "claim_count" integer DEFAULT 0 NOT NULL; diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 00000000..26089e8e --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,2083 @@ +{ + "id": "fe5effef-0bfd-4d84-84dc-571c0b80b2ae", + "prevId": "77a2e4b0-3244-4eda-8ffd-41ba7999b3e1", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_account_user_id": { + "name": "idx_account_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_messages": { + "name": "chat_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "thread_id": { + "name": "thread_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_chat_messages_thread": { + "name": "idx_chat_messages_thread", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_messages_thread_created": { + "name": "idx_chat_messages_thread_created", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "timestamptz_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_messages_thread_id_chat_threads_id_fk": { + "name": "chat_messages_thread_id_chat_threads_id_fk", + "tableFrom": "chat_messages", + "tableTo": "chat_threads", + "columnsFrom": ["thread_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "chat_messages_thread_message_key": { + "name": "chat_messages_thread_message_key", + "nullsNotDistinct": false, + "columns": ["thread_id", "message_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_threads": { + "name": "chat_threads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "head_message_id": { + "name": "head_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_chat_threads_workspace": { + "name": "idx_chat_threads_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_threads_user": { + "name": "idx_chat_threads_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_threads_last_message": { + "name": "idx_chat_threads_last_message", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamptz_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_threads_workspace_id_workspaces_id_fk": { + "name": "chat_threads_workspace_id_workspaces_id_fk", + "tableFrom": "chat_threads", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_threads_user_id_user_id_fk": { + "name": "chat_threads_user_id_user_id_fk", + "tableFrom": "chat_threads", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "chat_threads_user_scoped": { + "name": "chat_threads_user_scoped", + "as": "PERMISSIVE", + "for": "ALL", + "to": ["authenticated"], + "using": "((chat_threads.user_id = (auth.jwt() ->> 'sub'::text))\n AND ((EXISTS ( SELECT 1 FROM workspaces w\n WHERE ((w.id = chat_threads.workspace_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text)))))\n OR (EXISTS ( SELECT 1 FROM workspace_collaborators c\n WHERE ((c.workspace_id = chat_threads.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)))))))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_session_user_id": { + "name": "idx_session_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_session_token": { + "name": "idx_session_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_user_email": { + "name": "idx_user_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_profiles": { + "name": "user_profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "onboarding_completed": { + "name": "onboarding_completed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_user_profiles_user_id": { + "name": "idx_user_profiles_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_profiles_user_id_key": { + "name": "user_profiles_user_id_key", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": { + "Users can insert their own profile": { + "name": "Users can insert their own profile", + "as": "PERMISSIVE", + "for": "INSERT", + "to": ["authenticated"], + "withCheck": "(( SELECT (auth.jwt() ->> 'sub'::text)) = user_id)" + }, + "Users can update their own profile": { + "name": "Users can update their own profile", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": ["authenticated"] + }, + "Users can view their own profile": { + "name": "Users can view their own profile", + "as": "PERMISSIVE", + "for": "SELECT", + "to": ["authenticated"] + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_verification_identifier": { + "name": "idx_verification_identifier", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_collaborators": { + "name": "workspace_collaborators", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'editor'" + }, + "invite_token": { + "name": "invite_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_workspace_collaborators_lookup": { + "name": "idx_workspace_collaborators_lookup", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_collaborators_workspace": { + "name": "idx_workspace_collaborators_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_collaborators_last_opened_at": { + "name": "idx_workspace_collaborators_last_opened_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "last_opened_at", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamptz_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_collaborators_workspace_id_fkey": { + "name": "workspace_collaborators_workspace_id_fkey", + "tableFrom": "workspace_collaborators", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_collaborators_invite_token_unique": { + "name": "workspace_collaborators_invite_token_unique", + "nullsNotDistinct": false, + "columns": ["invite_token"] + }, + "workspace_collaborators_workspace_user_unique": { + "name": "workspace_collaborators_workspace_user_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id", "user_id"] + } + }, + "policies": { + "Owners can manage collaborators": { + "name": "Owners can manage collaborators", + "as": "PERMISSIVE", + "for": "ALL", + "to": ["authenticated"], + "using": "(EXISTS ( SELECT 1\n FROM workspaces w\n WHERE ((w.id = workspace_collaborators.workspace_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text)))))" + }, + "Collaborators can view their access": { + "name": "Collaborators can view their access", + "as": "PERMISSIVE", + "for": "SELECT", + "to": ["authenticated"], + "using": "(user_id = (auth.jwt() ->> 'sub'::text))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_invites": { + "name": "workspace_invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'editor'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_workspace_invites_token": { + "name": "idx_workspace_invites_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_invites_email": { + "name": "idx_workspace_invites_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_invites_workspace": { + "name": "idx_workspace_invites_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_invites_workspace_id_fkey": { + "name": "workspace_invites_workspace_id_fkey", + "tableFrom": "workspace_invites", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_invites_token_key": { + "name": "workspace_invites_token_key", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": { + "Public can view invite by token": { + "name": "Public can view invite by token", + "as": "PERMISSIVE", + "for": "SELECT", + "to": ["public"], + "using": "true" + }, + "Users can insert invites for workspaces they own/edit": { + "name": "Users can insert invites for workspaces they own/edit", + "as": "PERMISSIVE", + "for": "INSERT", + "to": ["authenticated"], + "withCheck": "(EXISTS ( SELECT 1\n FROM workspaces w\n WHERE ((w.id = workspace_invites.workspace_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_invites.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_item_content": { + "name": "workspace_item_content", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data_schema_version": { + "name": "data_schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "text_content": { + "name": "text_content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "structured_data": { + "name": "structured_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "asset_data": { + "name": "asset_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "embed_data": { + "name": "embed_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "source_data": { + "name": "source_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_workspace_item_content_workspace": { + "name": "idx_workspace_item_content_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_item_content_workspace_item_fkey": { + "name": "workspace_item_content_workspace_item_fkey", + "tableFrom": "workspace_item_content", + "tableTo": "workspace_items", + "columnsFrom": ["workspace_id", "item_id"], + "columnsTo": ["workspace_id", "item_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_item_content_pkey": { + "name": "workspace_item_content_pkey", + "columns": ["workspace_id", "item_id"] + } + }, + "uniqueConstraints": {}, + "policies": { + "Users can read workspace item content they have access to": { + "name": "Users can read workspace item content they have access to", + "as": "PERMISSIVE", + "for": "SELECT", + "to": ["public"], + "using": "(EXISTS ( SELECT 1\n FROM workspaces\n WHERE ((workspaces.id = workspace_item_content.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_item_content.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)))))" + }, + "Users can write workspace item content they have write access to": { + "name": "Users can write workspace item content they have write access to", + "as": "PERMISSIVE", + "for": "ALL", + "to": ["public"], + "using": "(EXISTS ( SELECT 1\n FROM workspaces\n WHERE ((workspaces.id = workspace_item_content.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_item_content.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))", + "withCheck": "(EXISTS ( SELECT 1\n FROM workspaces\n WHERE ((workspaces.id = workspace_item_content.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_item_content.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_item_extracted": { + "name": "workspace_item_extracted", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "search_text": { + "name": "search_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "content_preview": { + "name": "content_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ocr_text": { + "name": "ocr_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transcript_text": { + "name": "transcript_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ocr_pages": { + "name": "ocr_pages", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "transcript_segments": { + "name": "transcript_segments", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_workspace_item_extracted_workspace": { + "name": "idx_workspace_item_extracted_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_item_extracted_workspace_item_fkey": { + "name": "workspace_item_extracted_workspace_item_fkey", + "tableFrom": "workspace_item_extracted", + "tableTo": "workspace_items", + "columnsFrom": ["workspace_id", "item_id"], + "columnsTo": ["workspace_id", "item_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_item_extracted_pkey": { + "name": "workspace_item_extracted_pkey", + "columns": ["workspace_id", "item_id"] + } + }, + "uniqueConstraints": {}, + "policies": { + "Users can read workspace item extracted data they have access to": { + "name": "Users can read workspace item extracted data they have access to", + "as": "PERMISSIVE", + "for": "SELECT", + "to": ["public"], + "using": "(EXISTS ( SELECT 1\n FROM workspaces\n WHERE ((workspaces.id = workspace_item_extracted.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_item_extracted.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)))))" + }, + "Users can write workspace item extracted data they have write access to": { + "name": "Users can write workspace item extracted data they have write access to", + "as": "PERMISSIVE", + "for": "ALL", + "to": ["public"], + "using": "(EXISTS ( SELECT 1\n FROM workspaces\n WHERE ((workspaces.id = workspace_item_extracted.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_item_extracted.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))", + "withCheck": "(EXISTS ( SELECT 1\n FROM workspaces\n WHERE ((workspaces.id = workspace_item_extracted.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_item_extracted.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_items": { + "name": "workspace_items", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subtitle": { + "name": "subtitle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "layout": { + "name": "layout", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_modified": { + "name": "last_modified", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "source_version": { + "name": "source_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "data_schema_version": { + "name": "data_schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_ocr": { + "name": "has_ocr", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ocr_status": { + "name": "ocr_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ocr_page_count": { + "name": "ocr_page_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "has_transcript": { + "name": "has_transcript", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "source_count": { + "name": "source_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_workspace_items_workspace": { + "name": "idx_workspace_items_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_items_workspace_folder": { + "name": "idx_workspace_items_workspace_folder", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_items_workspace_type": { + "name": "idx_workspace_items_workspace_type", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_items_workspace_updated": { + "name": "idx_workspace_items_workspace_updated", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamptz_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_items_workspace_version": { + "name": "idx_workspace_items_workspace_version", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "source_version", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_items_workspace_processing": { + "name": "idx_workspace_items_workspace_processing", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_items_workspace_ocr": { + "name": "idx_workspace_items_workspace_ocr", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "has_ocr", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "bool_ops" + }, + { + "expression": "ocr_status", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_items_workspace_ocr_page_count": { + "name": "idx_workspace_items_workspace_ocr_page_count", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "ocr_page_count", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_items_workspace_has_transcript": { + "name": "idx_workspace_items_workspace_has_transcript", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "has_transcript", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "bool_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_items_workspace_source_count": { + "name": "idx_workspace_items_workspace_source_count", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "source_count", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_items_workspace_id_fkey": { + "name": "workspace_items_workspace_id_fkey", + "tableFrom": "workspace_items", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_items_pkey": { + "name": "workspace_items_pkey", + "columns": ["workspace_id", "item_id"] + } + }, + "uniqueConstraints": {}, + "policies": { + "Users can read workspace items they have access to": { + "name": "Users can read workspace items they have access to", + "as": "PERMISSIVE", + "for": "SELECT", + "to": ["public"], + "using": "(EXISTS ( SELECT 1\n FROM workspaces\n WHERE ((workspaces.id = workspace_items.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_items.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)))))" + }, + "Users can write workspace items they have write access to": { + "name": "Users can write workspace items they have write access to", + "as": "PERMISSIVE", + "for": "ALL", + "to": ["public"], + "using": "(EXISTS ( SELECT 1\n FROM workspaces\n WHERE ((workspaces.id = workspace_items.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_items.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))", + "withCheck": "(EXISTS ( SELECT 1\n FROM workspaces\n WHERE ((workspaces.id = workspace_items.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_items.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_share_links": { + "name": "workspace_share_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'editor'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "claim_count": { + "name": "claim_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "idx_workspace_share_links_token": { + "name": "idx_workspace_share_links_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_share_links_workspace": { + "name": "idx_workspace_share_links_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_share_links_workspace_id_fkey": { + "name": "workspace_share_links_workspace_id_fkey", + "tableFrom": "workspace_share_links", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_share_links_token_key": { + "name": "workspace_share_links_token_key", + "nullsNotDistinct": false, + "columns": ["token"] + }, + "workspace_share_links_workspace_key": { + "name": "workspace_share_links_workspace_key", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": { + "Public can view share link by token": { + "name": "Public can view share link by token", + "as": "PERMISSIVE", + "for": "SELECT", + "to": ["public"], + "using": "true" + }, + "Owners and editors can manage share links": { + "name": "Owners and editors can manage share links", + "as": "PERMISSIVE", + "for": "ALL", + "to": ["authenticated"], + "using": "(EXISTS ( SELECT 1\n FROM workspaces w\n WHERE ((w.id = workspace_share_links.workspace_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_share_links.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'blank'" + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_workspaces_created_at": { + "name": "idx_workspaces_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamptz_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspaces_slug": { + "name": "idx_workspaces_slug", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspaces_user_id": { + "name": "idx_workspaces_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspaces_user_slug": { + "name": "idx_workspaces_user_slug", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspaces_user_sort_order": { + "name": "idx_workspaces_user_sort_order", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspaces_last_opened_at": { + "name": "idx_workspaces_last_opened_at", + "columns": [ + { + "expression": "last_opened_at", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamptz_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "Users can delete their own workspaces": { + "name": "Users can delete their own workspaces", + "as": "PERMISSIVE", + "for": "DELETE", + "to": ["authenticated"], + "using": "(( SELECT (auth.jwt() ->> 'sub'::text)) = user_id)" + }, + "Users can insert their own workspaces": { + "name": "Users can insert their own workspaces", + "as": "PERMISSIVE", + "for": "INSERT", + "to": ["authenticated"] + }, + "Users can update their own workspaces": { + "name": "Users can update their own workspaces", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": ["authenticated"] + }, + "Users can view their own workspaces": { + "name": "Users can view their own workspaces", + "as": "PERMISSIVE", + "for": "SELECT", + "to": ["authenticated"] + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index c5478bf0..ed0f8b33 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1775793600000, "tag": "0002_zero_publication", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1776913168760, + "tag": "0003_famous_skaar", + "breakpoints": true } ] } diff --git a/src/app/api/share-link/claim/route.ts b/src/app/api/share-link/claim/route.ts index fd901478..2abdc756 100644 --- a/src/app/api/share-link/claim/route.ts +++ b/src/app/api/share-link/claim/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { and, eq } from "drizzle-orm"; +import { and, eq, sql } from "drizzle-orm"; import { requireAuth, withErrorHandling } from "@/lib/api/workspace-helpers"; import { db } from "@/lib/db/client"; import { @@ -64,6 +64,10 @@ async function handlePOST(request: NextRequest) { userId, permissionLevel: shareLink.permissionLevel, }); + await db + .update(workspaceShareLinks) + .set({ claimCount: sql`${workspaceShareLinks.claimCount} + 1` }) + .where(eq(workspaceShareLinks.id, shareLink.id)); } const [workspace] = await db diff --git a/src/app/api/workspaces/[id]/share-link/route.ts b/src/app/api/workspaces/[id]/share-link/route.ts index 8e9a0a50..23c139a2 100644 --- a/src/app/api/workspaces/[id]/share-link/route.ts +++ b/src/app/api/workspaces/[id]/share-link/route.ts @@ -54,6 +54,29 @@ async function handlePOST( return NextResponse.json({ token, url }); } +async function handleGET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const userId = await requireAuth(); + const { id: workspaceId } = await params; + + await verifyWorkspaceAccess(workspaceId, userId, "editor"); + + const [shareLink] = await db + .select({ claimCount: workspaceShareLinks.claimCount }) + .from(workspaceShareLinks) + .where(eq(workspaceShareLinks.workspaceId, workspaceId)) + .limit(1); + + return NextResponse.json({ claimCount: shareLink?.claimCount ?? 0 }); +} + +export const GET = withErrorHandling( + handleGET, + "GET /api/workspaces/[id]/share-link", +); + export const POST = withErrorHandling( handlePOST, "POST /api/workspaces/[id]/share-link", diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 1a7ecaa3..40b4b577 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -18,7 +18,7 @@ import { useSession } from "@/lib/auth-client"; import { WorkspaceSection } from "@/components/workspace-canvas/WorkspaceSection"; import { OpenWorkspaceItemView } from "@/components/workspace-canvas/OpenWorkspaceItemView"; import { AnonymousSignInPrompt } from "@/components/modals/AnonymousSignInPrompt"; -import { MidtermsRafflePopup } from "@/components/modals/MidtermsRafflePopup"; +import { RafflePopup } from "@/components/modals/RafflePopup"; import { DashboardLayout } from "@/components/layout/DashboardLayout"; import WorkspaceHeader from "@/components/workspace-canvas/WorkspaceHeader"; import { WorkspaceSearchDialog } from "@/components/workspace-canvas/WorkspaceSearchDialog"; @@ -71,6 +71,10 @@ function DashboardContent({ const currentWorkspaceTitle = currentWorkspace?.name; const currentWorkspaceIcon = currentWorkspace?.icon; const currentWorkspaceColor = currentWorkspace?.color; + const isWorkspaceOwner = + !!session?.user?.id && + !!currentWorkspace && + session.user.id === currentWorkspace.userId; // Check onboarding status // const { shouldShowOnboarding, isLoading: isLoadingOnboarding } = useOnboardingStatus(); @@ -280,7 +284,7 @@ function DashboardContent({ } }} /> - ) : undefined } diff --git a/src/app/invite/claim/[token]/page.tsx b/src/app/invite/claim/[token]/page.tsx index b0d69f1d..6a4e3761 100644 --- a/src/app/invite/claim/[token]/page.tsx +++ b/src/app/invite/claim/[token]/page.tsx @@ -1,6 +1,6 @@ import { headers } from "next/headers"; import { redirect } from "next/navigation"; -import { eq } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; import { auth } from "@/lib/auth"; import { db } from "@/lib/db/client"; import { @@ -17,8 +17,8 @@ async function claimAndResolveSlug(params: { workspaceId: string; userId: string; permissionLevel: string; -}): Promise<{ slug: string | null }> { - await db +}): Promise<{ slug: string | null; inserted: boolean }> { + const inserted = await db .insert(workspaceCollaborators) .values({ workspaceId: params.workspaceId, @@ -30,7 +30,8 @@ async function claimAndResolveSlug(params: { workspaceCollaborators.workspaceId, workspaceCollaborators.userId, ], - }); + }) + .returning({ id: workspaceCollaborators.id }); const [workspace] = await db .select({ slug: workspaces.slug }) @@ -38,7 +39,7 @@ async function claimAndResolveSlug(params: { .where(eq(workspaces.id, params.workspaceId)) .limit(1); - return { slug: workspace?.slug ?? null }; + return { slug: workspace?.slug ?? null, inserted: inserted.length > 0 }; } export default async function InviteClaimPage({ @@ -115,7 +116,7 @@ export default async function InviteClaimPage({ if (new Date(shareLink.expiresAt) < new Date()) { error = "expired"; } else { - const { slug } = await claimAndResolveSlug({ + const { slug, inserted } = await claimAndResolveSlug({ workspaceId: shareLink.workspaceId, userId: session.user.id, permissionLevel: shareLink.permissionLevel, @@ -124,6 +125,12 @@ export default async function InviteClaimPage({ if (!slug) { error = "workspace-deleted"; } else { + if (inserted) { + await db + .update(workspaceShareLinks) + .set({ claimCount: sql`${workspaceShareLinks.claimCount} + 1` }) + .where(eq(workspaceShareLinks.id, shareLink.id)); + } workspaceSlug = slug; } } diff --git a/src/components/modals/MidtermsRafflePopup.tsx b/src/components/modals/RafflePopup.tsx similarity index 88% rename from src/components/modals/MidtermsRafflePopup.tsx rename to src/components/modals/RafflePopup.tsx index 6ea20897..87ba1bd4 100644 --- a/src/components/modals/MidtermsRafflePopup.tsx +++ b/src/components/modals/RafflePopup.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState } from "react"; -import { Check, Copy, Files, GraduationCap, Link2, Mail } from "lucide-react"; +import { Check, Copy, Files, Gift, Link2, Mail } from "lucide-react"; import { toast } from "sonner"; import { Dialog, @@ -18,7 +18,7 @@ import { useSession } from "@/lib/auth-client"; import { useNewFeature } from "@/lib/utils/new-feature"; import type { WorkspaceWithState } from "@/lib/workspace-state/types"; -interface MidtermsRafflePopupProps { +interface RafflePopupProps { workspace: WorkspaceWithState | null; currentWorkspaceId: string | null; isLoadingWorkspace: boolean; @@ -26,20 +26,22 @@ interface MidtermsRafflePopupProps { } const FEATURE_KEY = "midterms-raffle-2026-04"; -const FEATURE_END_DATE = new Date(2026, 3, 24, 23, 59, 59); +const FEATURE_END_DATE = new Date(2026, 3, 25, 23, 59, 59); -export function MidtermsRafflePopup({ +export function RafflePopup({ workspace, currentWorkspaceId, isLoadingWorkspace, onOpenFullShare, -}: MidtermsRafflePopupProps) { +}: RafflePopupProps) { const { data: session } = useSession(); const { isNew, dismiss } = useNewFeature({ featureKey: FEATURE_KEY, endDate: FEATURE_END_DATE, }); - const [linkMode, setLinkMode] = useState<"collaborate" | "deepcopy">("collaborate"); + const [linkMode, setLinkMode] = useState<"collaborate" | "deepcopy">( + "collaborate", + ); const [shareLinkUrl, setShareLinkUrl] = useState(""); const [isLoadingShareLink, setIsLoadingShareLink] = useState(false); const [copied, setCopied] = useState(false); @@ -113,20 +115,21 @@ export function MidtermsRafflePopup({
- +
- Midterms raffle + Raffle
- Happy midterms! + Win a $50 Amazon gift card - Share this workspace with 5 or more classmates and you'll be entered - in our raffle to win a $50 Amazon gift card. Copy your share link - below and send it over — each person who joins counts. + Share this workspace with 5 or more people and + you'll be entered to win a $50 Amazon gift card. + Copy your share link below and send it over — each person who joins + counts.
@@ -175,7 +178,11 @@ export function MidtermsRafflePopup({ disabled={!activeUrl || isActiveLoading} className="shrink-0" > - {copied ? : } + {copied ? ( + + ) : ( + + )} Copy diff --git a/src/components/workspace-canvas/ShareCountBadge.tsx b/src/components/workspace-canvas/ShareCountBadge.tsx new file mode 100644 index 00000000..9b417549 --- /dev/null +++ b/src/components/workspace-canvas/ShareCountBadge.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { PartyPopper } from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; + +interface ShareCountBadgeProps { + workspaceId: string; + onClick?: () => void; + endDate?: Date; +} + +const RAFFLE_THRESHOLD = 5; +const DEFAULT_END_DATE = new Date(2026, 3, 25, 23, 59, 59); + +export function ShareCountBadge({ + workspaceId, + onClick, + endDate = DEFAULT_END_DATE, +}: ShareCountBadgeProps) { + const [claimCount, setClaimCount] = useState(null); + + const isWithinWindow = Date.now() <= endDate.getTime(); + + useEffect(() => { + if (!isWithinWindow) return; + + const controller = new AbortController(); + fetch(`/api/workspaces/${workspaceId}/share-link`, { + signal: controller.signal, + }) + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (data && typeof data.claimCount === "number") { + setClaimCount(data.claimCount); + } + }) + .catch((err: unknown) => { + if ((err as { name?: string } | null)?.name !== "AbortError") { + console.error(err); + } + }); + + return () => controller.abort(); + }, [workspaceId, isWithinWindow]); + + if (!isWithinWindow || claimCount === null) return null; + + const reached = claimCount >= RAFFLE_THRESHOLD; + const label = `${claimCount}/${RAFFLE_THRESHOLD}`; + const tooltip = reached + ? "You're entered in the raffle!" + : `${RAFFLE_THRESHOLD - claimCount} more ${ + RAFFLE_THRESHOLD - claimCount === 1 ? "share" : "shares" + } to enter the raffle`; + + return ( + + + + + {tooltip} + + ); +} diff --git a/src/components/workspace-canvas/WorkspaceHeader.tsx b/src/components/workspace-canvas/WorkspaceHeader.tsx index 0bfb0dfe..9eaede81 100644 --- a/src/components/workspace-canvas/WorkspaceHeader.tsx +++ b/src/components/workspace-canvas/WorkspaceHeader.tsx @@ -73,6 +73,7 @@ import { AudioRecorderDialog } from "@/components/modals/AudioRecorderDialog"; import { useAudioRecordingStore } from "@/lib/stores/audio-recording-store"; import { renderWorkspaceMenuItems } from "./workspace-menu-items"; import { WorkspaceFeedbackDialog } from "./WorkspaceFeedbackDialog"; +import { ShareCountBadge } from "@/components/workspace-canvas/ShareCountBadge"; import { PromptBuilderDialog } from "@/components/assistant-ui/PromptBuilderDialog"; const EMPTY_ITEMS: Item[] = []; const EMPTY_RESPONSIVE_BREADCRUMBS = { @@ -264,6 +265,7 @@ interface WorkspaceHeaderProps { /** Flush pending saves and read latest document markdown from workspace cache (avoids stale export). */ getDocumentMarkdownForExport?: (itemId: string) => string; googleLoginHint?: string | null; + isWorkspaceOwner?: boolean; } export function WorkspaceHeader({ @@ -292,6 +294,7 @@ export function WorkspaceHeader({ onUpdateActiveItem, getDocumentMarkdownForExport, googleLoginHint, + isWorkspaceOwner = false, }: WorkspaceHeaderProps) { const [isNewMenuOpen, setIsNewMenuOpen] = useState(false); const [showRenameDialog, setShowRenameDialog] = useState(false); @@ -1237,6 +1240,12 @@ export function WorkspaceHeader({ Feedback + {isWorkspaceOwner && currentWorkspaceId && onOpenShare && ( + + )} {onOpenShare && (