diff --git a/drizzle/0006_workspace_items_projection.sql b/drizzle/0006_workspace_items_projection.sql new file mode 100644 index 00000000..84e224f0 --- /dev/null +++ b/drizzle/0006_workspace_items_projection.sql @@ -0,0 +1,63 @@ +CREATE TABLE "workspace_items" ( + "workspace_id" uuid NOT NULL, + "item_id" text NOT NULL, + "type" text NOT NULL, + "name" text NOT NULL, + "subtitle" text DEFAULT '' NOT NULL, + "sort_order" integer NOT NULL, + "data" jsonb NOT NULL, + "color" text, + "folder_id" text, + "layout" jsonb, + "last_modified" bigint, + "source_version" integer NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "workspace_items_pkey" PRIMARY KEY("workspace_id","item_id") +); +--> statement-breakpoint +ALTER TABLE "workspace_items" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "workspace_item_projection_state" ( + "workspace_id" uuid PRIMARY KEY NOT NULL, + "last_applied_version" integer NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "workspace_item_projection_state" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "workspace_items" ADD CONSTRAINT "workspace_items_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "workspace_item_projection_state" ADD CONSTRAINT "workspace_item_projection_state_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "idx_workspace_items_workspace" ON "workspace_items" USING btree ("workspace_id" uuid_ops);--> statement-breakpoint +CREATE INDEX "idx_workspace_items_workspace_folder" ON "workspace_items" USING btree ("workspace_id" uuid_ops,"folder_id" text_ops);--> statement-breakpoint +CREATE INDEX "idx_workspace_items_workspace_type" ON "workspace_items" USING btree ("workspace_id" uuid_ops,"type" text_ops);--> statement-breakpoint +CREATE INDEX "idx_workspace_items_workspace_updated" ON "workspace_items" USING btree ("workspace_id" uuid_ops,"updated_at" timestamptz_ops DESC);--> statement-breakpoint +CREATE INDEX "idx_workspace_items_workspace_version" ON "workspace_items" USING btree ("workspace_id" uuid_ops,"source_version" int4_ops DESC);--> statement-breakpoint +CREATE INDEX "idx_workspace_items_workspace_order" ON "workspace_items" USING btree ("workspace_id" uuid_ops,"sort_order" int4_ops);--> statement-breakpoint +CREATE INDEX "idx_workspace_item_projection_state_version" ON "workspace_item_projection_state" USING btree ("last_applied_version" int4_ops DESC);--> statement-breakpoint +CREATE POLICY "Users can read projected workspace items they have access to" ON "workspace_items" AS PERMISSIVE FOR SELECT TO public USING ((EXISTS ( SELECT 1 + FROM workspaces + WHERE ((workspaces.id = workspace_items.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1 + FROM workspace_collaborators c + WHERE ((c.workspace_id = workspace_items.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text))))));--> statement-breakpoint +CREATE POLICY "Users can write projected workspace items they have write access to" ON "workspace_items" AS PERMISSIVE FOR ALL TO public USING ((EXISTS ( SELECT 1 + FROM workspaces + WHERE ((workspaces.id = workspace_items.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1 + FROM workspace_collaborators c + WHERE ((c.workspace_id = workspace_items.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))) WITH CHECK ((EXISTS ( SELECT 1 + FROM workspaces + WHERE ((workspaces.id = workspace_items.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1 + FROM workspace_collaborators c + WHERE ((c.workspace_id = workspace_items.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text)))));--> statement-breakpoint +CREATE POLICY "Users can read projection state they have access to" ON "workspace_item_projection_state" AS PERMISSIVE FOR SELECT TO public USING ((EXISTS ( SELECT 1 + FROM workspaces + WHERE ((workspaces.id = workspace_item_projection_state.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1 + FROM workspace_collaborators c + WHERE ((c.workspace_id = workspace_item_projection_state.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text))))));--> statement-breakpoint +CREATE POLICY "Users can write projection state they have write access to" ON "workspace_item_projection_state" AS PERMISSIVE FOR ALL TO public USING ((EXISTS ( SELECT 1 + FROM workspaces + WHERE ((workspaces.id = workspace_item_projection_state.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1 + FROM workspace_collaborators c + WHERE ((c.workspace_id = workspace_item_projection_state.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))) WITH CHECK ((EXISTS ( SELECT 1 + FROM workspaces + WHERE ((workspaces.id = workspace_item_projection_state.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1 + FROM workspace_collaborators c + WHERE ((c.workspace_id = workspace_item_projection_state.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text)))));--> statement-breakpoint diff --git a/drizzle/0007_remove_workspace_item_sort_order.sql b/drizzle/0007_remove_workspace_item_sort_order.sql new file mode 100644 index 00000000..e457965d --- /dev/null +++ b/drizzle/0007_remove_workspace_item_sort_order.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS "idx_workspace_items_workspace_order";--> statement-breakpoint +ALTER TABLE "workspace_items" DROP COLUMN IF EXISTS "sort_order"; diff --git a/drizzle/0008_moaning_clea.sql b/drizzle/0008_moaning_clea.sql new file mode 100644 index 00000000..efdd5633 --- /dev/null +++ b/drizzle/0008_moaning_clea.sql @@ -0,0 +1,226 @@ +CREATE TABLE IF NOT EXISTS "workspace_item_content" ( + "workspace_id" uuid NOT NULL, + "item_id" text NOT NULL, + "data_schema_version" integer DEFAULT 1 NOT NULL, + "content_hash" text DEFAULT '' NOT NULL, + "text_content" text, + "structured_data" jsonb, + "asset_data" jsonb, + "embed_data" jsonb, + "source_data" jsonb, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "workspace_item_content_pkey" PRIMARY KEY("workspace_id","item_id") +); +--> statement-breakpoint +ALTER TABLE "workspace_item_content" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint + +CREATE TABLE IF NOT EXISTS "workspace_item_extracted" ( + "workspace_id" uuid NOT NULL, + "item_id" text NOT NULL, + "search_text" text DEFAULT '' NOT NULL, + "content_preview" text, + "ocr_text" text, + "transcript_text" text, + "ocr_pages" jsonb, + "transcript_segments" jsonb, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "workspace_item_extracted_pkey" PRIMARY KEY("workspace_id","item_id") +); +--> statement-breakpoint +ALTER TABLE "workspace_item_extracted" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint + +CREATE TABLE IF NOT EXISTS "workspace_item_projection_state" ( + "workspace_id" uuid PRIMARY KEY NOT NULL, + "last_applied_version" integer NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "workspace_item_projection_state" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint + +CREATE TABLE IF NOT EXISTS "workspace_item_user_state" ( + "workspace_id" uuid NOT NULL, + "item_id" text NOT NULL, + "user_id" text, + "state" jsonb NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "workspace_item_user_state_pkey" PRIMARY KEY("workspace_id","item_id") +); +--> statement-breakpoint +ALTER TABLE "workspace_item_user_state" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint + +ALTER TABLE "workspace_items" ADD COLUMN IF NOT EXISTS "data_schema_version" integer DEFAULT 1 NOT NULL;--> statement-breakpoint +ALTER TABLE "workspace_items" ADD COLUMN IF NOT EXISTS "content_hash" text DEFAULT '' NOT NULL;--> statement-breakpoint +ALTER TABLE "workspace_items" ADD COLUMN IF NOT EXISTS "source_count" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "workspace_items" ADD COLUMN IF NOT EXISTS "has_ocr" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "workspace_items" ADD COLUMN IF NOT EXISTS "ocr_status" text;--> statement-breakpoint +ALTER TABLE "workspace_items" ADD COLUMN IF NOT EXISTS "ocr_page_count" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "workspace_items" ADD COLUMN IF NOT EXISTS "has_transcript" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "workspace_items" ADD COLUMN IF NOT EXISTS "processing_status" text;--> statement-breakpoint + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'workspace_item_content_workspace_item_fkey' + ) THEN + ALTER TABLE "workspace_item_content" + ADD CONSTRAINT "workspace_item_content_workspace_item_fkey" + FOREIGN KEY ("workspace_id","item_id") + REFERENCES "public"."workspace_items"("workspace_id","item_id") + ON DELETE cascade + ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'workspace_item_extracted_workspace_item_fkey' + ) THEN + ALTER TABLE "workspace_item_extracted" + ADD CONSTRAINT "workspace_item_extracted_workspace_item_fkey" + FOREIGN KEY ("workspace_id","item_id") + REFERENCES "public"."workspace_items"("workspace_id","item_id") + ON DELETE cascade + ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'workspace_item_projection_state_workspace_id_fkey' + ) THEN + ALTER TABLE "workspace_item_projection_state" + ADD CONSTRAINT "workspace_item_projection_state_workspace_id_fkey" + FOREIGN KEY ("workspace_id") + REFERENCES "public"."workspaces"("id") + ON DELETE cascade + ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'workspace_item_user_state_workspace_item_fkey' + ) THEN + ALTER TABLE "workspace_item_user_state" + ADD CONSTRAINT "workspace_item_user_state_workspace_item_fkey" + FOREIGN KEY ("workspace_id","item_id") + REFERENCES "public"."workspace_items"("workspace_id","item_id") + ON DELETE cascade + ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint + +CREATE INDEX IF NOT EXISTS "idx_workspace_item_content_workspace" ON "workspace_item_content" USING btree ("workspace_id" uuid_ops);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_workspace_item_extracted_workspace" ON "workspace_item_extracted" USING btree ("workspace_id" uuid_ops);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_workspace_item_projection_state_version" ON "workspace_item_projection_state" USING btree ("last_applied_version" int4_ops);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_workspace_item_user_state_workspace_user" ON "workspace_item_user_state" USING btree ("workspace_id" uuid_ops,"user_id" text_ops);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_workspace_item_user_state_item" ON "workspace_item_user_state" USING btree ("workspace_id" uuid_ops,"item_id" text_ops);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_workspace_items_workspace_processing" ON "workspace_items" USING btree ("workspace_id" uuid_ops,"processing_status" text_ops);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_workspace_items_workspace_ocr" ON "workspace_items" USING btree ("workspace_id" uuid_ops,"has_ocr" bool_ops,"ocr_status" text_ops);--> statement-breakpoint + +DROP POLICY IF EXISTS "Users can read projected workspace items they have access to" ON "workspace_items";--> statement-breakpoint +DROP POLICY IF EXISTS "Users can write projected workspace items they have write acces" ON "workspace_items";--> statement-breakpoint +DROP POLICY IF EXISTS "Users can write projected workspace items they have write access to" ON "workspace_items";--> statement-breakpoint +DROP POLICY IF EXISTS "Users can read projected workspace item content they have access to" ON "workspace_item_content";--> statement-breakpoint +DROP POLICY IF EXISTS "Users can write projected workspace item content they have write access to" ON "workspace_item_content";--> statement-breakpoint +DROP POLICY IF EXISTS "Users can read projected workspace item extracted data they have access to" ON "workspace_item_extracted";--> statement-breakpoint +DROP POLICY IF EXISTS "Users can write projected workspace item extracted data they have write access to" ON "workspace_item_extracted";--> statement-breakpoint +DROP POLICY IF EXISTS "Users can read projection state they have access to" ON "workspace_item_projection_state";--> statement-breakpoint +DROP POLICY IF EXISTS "Users can write projection state they have write access to" ON "workspace_item_projection_state";--> statement-breakpoint +DROP POLICY IF EXISTS "Users can read their projected workspace item state" ON "workspace_item_user_state";--> statement-breakpoint +DROP POLICY IF EXISTS "Users can write their projected workspace item state" ON "workspace_item_user_state";--> statement-breakpoint + +CREATE POLICY "Users can read projected workspace item content they have access to" ON "workspace_item_content" AS PERMISSIVE FOR SELECT TO public USING ((EXISTS ( SELECT 1 + FROM workspaces + WHERE ((workspaces.id = workspace_item_content.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1 + FROM workspace_collaborators c + WHERE ((c.workspace_id = workspace_item_content.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text))))));--> statement-breakpoint + +CREATE POLICY "Users can write projected workspace item content they have write access to" ON "workspace_item_content" AS PERMISSIVE FOR ALL TO public USING ((EXISTS ( SELECT 1 + FROM workspaces + WHERE ((workspaces.id = workspace_item_content.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1 + FROM workspace_collaborators c + WHERE ((c.workspace_id = workspace_item_content.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))) WITH CHECK ((EXISTS ( SELECT 1 + FROM workspaces + WHERE ((workspaces.id = workspace_item_content.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1 + FROM workspace_collaborators c + WHERE ((c.workspace_id = workspace_item_content.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text)))));--> statement-breakpoint + +CREATE POLICY "Users can read projected workspace item extracted data they have access to" ON "workspace_item_extracted" AS PERMISSIVE FOR SELECT TO public USING ((EXISTS ( SELECT 1 + FROM workspaces + WHERE ((workspaces.id = workspace_item_extracted.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1 + FROM workspace_collaborators c + WHERE ((c.workspace_id = workspace_item_extracted.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text))))));--> statement-breakpoint + +CREATE POLICY "Users can write projected workspace item extracted data they have write access to" ON "workspace_item_extracted" AS PERMISSIVE FOR ALL TO public USING ((EXISTS ( SELECT 1 + FROM workspaces + WHERE ((workspaces.id = workspace_item_extracted.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1 + FROM workspace_collaborators c + WHERE ((c.workspace_id = workspace_item_extracted.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))) WITH CHECK ((EXISTS ( SELECT 1 + FROM workspaces + WHERE ((workspaces.id = workspace_item_extracted.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1 + FROM workspace_collaborators c + WHERE ((c.workspace_id = workspace_item_extracted.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text)))));--> statement-breakpoint + +CREATE POLICY "Users can read projection state they have access to" ON "workspace_item_projection_state" AS PERMISSIVE FOR SELECT TO public USING ((EXISTS ( SELECT 1 + FROM workspaces + WHERE ((workspaces.id = workspace_item_projection_state.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1 + FROM workspace_collaborators c + WHERE ((c.workspace_id = workspace_item_projection_state.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text))))));--> statement-breakpoint + +CREATE POLICY "Users can write projection state they have write access to" ON "workspace_item_projection_state" AS PERMISSIVE FOR ALL TO public USING ((EXISTS ( SELECT 1 + FROM workspaces + WHERE ((workspaces.id = workspace_item_projection_state.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1 + FROM workspace_collaborators c + WHERE ((c.workspace_id = workspace_item_projection_state.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))) WITH CHECK ((EXISTS ( SELECT 1 + FROM workspaces + WHERE ((workspaces.id = workspace_item_projection_state.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1 + FROM workspace_collaborators c + WHERE ((c.workspace_id = workspace_item_projection_state.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text)))));--> statement-breakpoint + +CREATE POLICY "Users can read their projected workspace item state" ON "workspace_item_user_state" AS PERMISSIVE FOR SELECT TO public USING ((EXISTS ( SELECT 1 + FROM workspaces + WHERE ((workspaces.id = workspace_item_user_state.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1 + FROM workspace_collaborators c + WHERE ((c.workspace_id = workspace_item_user_state.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text))))));--> statement-breakpoint + +CREATE POLICY "Users can write their projected workspace item state" ON "workspace_item_user_state" AS PERMISSIVE FOR ALL TO public USING ((EXISTS ( SELECT 1 + FROM workspaces + WHERE ((workspaces.id = workspace_item_user_state.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1 + FROM workspace_collaborators c + WHERE ((c.workspace_id = workspace_item_user_state.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))) WITH CHECK ((EXISTS ( SELECT 1 + FROM workspaces + WHERE ((workspaces.id = workspace_item_user_state.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1 + FROM workspace_collaborators c + WHERE ((c.workspace_id = workspace_item_user_state.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text)))));--> statement-breakpoint + +CREATE POLICY "Users can read projected workspace items they have access to" ON "workspace_items" AS PERMISSIVE FOR SELECT TO public USING ((EXISTS ( SELECT 1 + FROM workspaces + WHERE ((workspaces.id = workspace_items.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1 + FROM workspace_collaborators c + WHERE ((c.workspace_id = workspace_items.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text))))));--> statement-breakpoint + +CREATE POLICY "Users can write projected workspace items they have write access to" ON "workspace_items" AS PERMISSIVE FOR ALL TO public USING ((EXISTS ( SELECT 1 + FROM workspaces + WHERE ((workspaces.id = workspace_items.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1 + FROM workspace_collaborators c + WHERE ((c.workspace_id = workspace_items.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))) WITH CHECK ((EXISTS ( SELECT 1 + FROM workspaces + WHERE ((workspaces.id = workspace_items.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1 + FROM workspace_collaborators c + WHERE ((c.workspace_id = workspace_items.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))); \ No newline at end of file diff --git a/drizzle/0009_user_scoped_workspace_item_state.sql b/drizzle/0009_user_scoped_workspace_item_state.sql new file mode 100644 index 00000000..587acd8b --- /dev/null +++ b/drizzle/0009_user_scoped_workspace_item_state.sql @@ -0,0 +1,45 @@ +DELETE FROM "workspace_item_user_state";--> statement-breakpoint + +DO $$ BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'workspace_item_user_state' + AND column_name = 'user_id' + AND is_nullable = 'YES' + ) THEN + ALTER TABLE "workspace_item_user_state" + ALTER COLUMN "user_id" SET NOT NULL; + END IF; +END $$; +--> statement-breakpoint + +ALTER TABLE "workspace_item_user_state" + DROP CONSTRAINT IF EXISTS "workspace_item_user_state_pkey";--> statement-breakpoint + +ALTER TABLE "workspace_item_user_state" + ADD CONSTRAINT "workspace_item_user_state_pkey" + PRIMARY KEY ("workspace_id","item_id","user_id");--> statement-breakpoint + +DROP INDEX IF EXISTS "idx_workspace_item_user_state_item";--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_workspace_item_user_state_item" ON "workspace_item_user_state" USING btree ("workspace_id" uuid_ops,"item_id" text_ops,"user_id" text_ops);--> statement-breakpoint + +DROP POLICY IF EXISTS "Users can read their projected workspace item state" ON "workspace_item_user_state";--> statement-breakpoint +DROP POLICY IF EXISTS "Users can write their projected workspace item state" ON "workspace_item_user_state";--> statement-breakpoint + +CREATE POLICY "Users can read their projected workspace item state" ON "workspace_item_user_state" AS PERMISSIVE FOR SELECT TO public USING ((((workspace_item_user_state.user_id = (auth.jwt() ->> 'sub'::text)) AND (EXISTS ( SELECT 1 + FROM workspaces + WHERE ((workspaces.id = workspace_item_user_state.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text)))))) OR ((workspace_item_user_state.user_id = (auth.jwt() ->> 'sub'::text)) AND (EXISTS ( SELECT 1 + FROM workspace_collaborators c + WHERE ((c.workspace_id = workspace_item_user_state.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text))))))));--> statement-breakpoint + +CREATE POLICY "Users can write their projected workspace item state" ON "workspace_item_user_state" AS PERMISSIVE FOR ALL TO public USING ((((workspace_item_user_state.user_id = (auth.jwt() ->> 'sub'::text)) AND (EXISTS ( SELECT 1 + FROM workspaces + WHERE ((workspaces.id = workspace_item_user_state.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text)))))) OR ((workspace_item_user_state.user_id = (auth.jwt() ->> 'sub'::text)) AND (EXISTS ( SELECT 1 + FROM workspace_collaborators c + WHERE ((c.workspace_id = workspace_item_user_state.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)))))))) WITH CHECK ((((workspace_item_user_state.user_id = (auth.jwt() ->> 'sub'::text)) AND (EXISTS ( SELECT 1 + FROM workspaces + WHERE ((workspaces.id = workspace_item_user_state.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text)))))) OR ((workspace_item_user_state.user_id = (auth.jwt() ->> 'sub'::text)) AND (EXISTS ( SELECT 1 + FROM workspace_collaborators c + WHERE ((c.workspace_id = workspace_item_user_state.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text))))))));--> statement-breakpoint diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json new file mode 100644 index 00000000..b75aed80 --- /dev/null +++ b/drizzle/meta/0008_snapshot.json @@ -0,0 +1,2675 @@ +{ + "id": "9f82ab1c-162d-408d-955e-8b6876341d1c", + "prevId": "4ff977d6-4f70-49fe-a07e-c39bf93e134e", + "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_events": { + "name": "workspace_events", + "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 + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_workspace_events_event_id": { + "name": "idx_workspace_events_event_id", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_events_timestamp": { + "name": "idx_workspace_events_timestamp", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int8_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_events_user_name": { + "name": "idx_workspace_events_user_name", + "columns": [ + { + "expression": "user_name", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_events_workspace": { + "name": "idx_workspace_events_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_events_workspace_id_fkey": { + "name": "workspace_events_workspace_id_fkey", + "tableFrom": "workspace_events", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_events_event_id_key": { + "name": "workspace_events_event_id_key", + "nullsNotDistinct": false, + "columns": [ + "event_id" + ] + } + }, + "policies": { + "Users can insert workspace events they have write access to": { + "name": "Users can insert workspace events they have write access to", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "public" + ], + "withCheck": "(EXISTS ( SELECT 1\n FROM workspaces\n WHERE ((workspaces.id = workspace_events.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_events.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))" + }, + "Users can read workspace events they have access to": { + "name": "Users can read workspace events they have access to", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "(EXISTS ( SELECT 1\n FROM workspaces\n WHERE ((workspaces.id = workspace_events.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_events.workspace_id) AND (c.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 projected workspace item content they have access to": { + "name": "Users can read projected 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 projected workspace item content they have write access to": { + "name": "Users can write projected 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 projected workspace item extracted data they have access to": { + "name": "Users can read projected 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 projected workspace item extracted data they have write access to": { + "name": "Users can write projected 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_item_projection_state": { + "name": "workspace_item_projection_state", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "last_applied_version": { + "name": "last_applied_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_workspace_item_projection_state_version": { + "name": "idx_workspace_item_projection_state_version", + "columns": [ + { + "expression": "last_applied_version", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_item_projection_state_workspace_id_fkey": { + "name": "workspace_item_projection_state_workspace_id_fkey", + "tableFrom": "workspace_item_projection_state", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "Users can read projection state they have access to": { + "name": "Users can read projection state they have access to", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "(EXISTS ( SELECT 1\n FROM workspaces\n WHERE ((workspaces.id = workspace_item_projection_state.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_projection_state.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)))))" + }, + "Users can write projection state they have write access to": { + "name": "Users can write projection state they have write access to", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(EXISTS ( SELECT 1\n FROM workspaces\n WHERE ((workspaces.id = workspace_item_projection_state.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_projection_state.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_projection_state.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_projection_state.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_item_user_state": { + "name": "workspace_item_user_state", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_workspace_item_user_state_workspace_user": { + "name": "idx_workspace_item_user_state_workspace_user", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_item_user_state_item": { + "name": "idx_workspace_item_user_state_item", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_item_user_state_workspace_item_fkey": { + "name": "workspace_item_user_state_workspace_item_fkey", + "tableFrom": "workspace_item_user_state", + "tableTo": "workspace_items", + "columnsFrom": [ + "workspace_id", + "item_id" + ], + "columnsTo": [ + "workspace_id", + "item_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_item_user_state_pkey": { + "name": "workspace_item_user_state_pkey", + "columns": [ + "workspace_id", + "item_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": { + "Users can read their projected workspace item state": { + "name": "Users can read their projected workspace item state", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "((EXISTS ( SELECT 1\n FROM workspaces\n WHERE ((workspaces.id = workspace_item_user_state.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_user_state.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)))))" + }, + "Users can write their projected workspace item state": { + "name": "Users can write their projected workspace item state", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "((EXISTS ( SELECT 1\n FROM workspaces\n WHERE ((workspaces.id = workspace_item_user_state.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_user_state.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_user_state.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_user_state.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": "''" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "data_schema_version": { + "name": "data_schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "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 + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "source_count": { + "name": "source_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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": {} + } + }, + "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 projected workspace items they have access to": { + "name": "Users can read projected 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 projected workspace items they have write access to": { + "name": "Users can write projected 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 + } + }, + "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.workspace_snapshots": { + "name": "workspace_snapshots", + "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 + }, + "snapshot_version": { + "name": "snapshot_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "event_count": { + "name": "event_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_workspace_snapshots_version": { + "name": "idx_workspace_snapshots_version", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "snapshot_version", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_snapshots_workspace": { + "name": "idx_workspace_snapshots_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_snapshots_workspace_id_fkey": { + "name": "workspace_snapshots_workspace_id_fkey", + "tableFrom": "workspace_snapshots", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_snapshots_workspace_id_snapshot_version_key": { + "name": "workspace_snapshots_workspace_id_snapshot_version_key", + "nullsNotDistinct": false, + "columns": [ + "workspace_id", + "snapshot_version" + ] + } + }, + "policies": { + "Service role can insert workspace snapshots": { + "name": "Service role can insert workspace snapshots", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "public" + ], + "withCheck": "true" + }, + "Users can read workspace snapshots they have access to": { + "name": "Users can read workspace snapshots they have access to", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ] + } + }, + "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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 8b5c51d8..c8530b70 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -43,6 +43,34 @@ "when": 1771800001000, "tag": "0005_add_chat_messages_thread_message_unique", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1775558400000, + "tag": "0006_workspace_items_projection", + "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1775606400000, + "tag": "0007_remove_workspace_item_sort_order", + "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1775626411300, + "tag": "0008_moaning_clea", + "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1775629800000, + "tag": "0009_user_scoped_workspace_item_state", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/relations.ts b/drizzle/relations.ts new file mode 100644 index 00000000..22461ad2 --- /dev/null +++ b/drizzle/relations.ts @@ -0,0 +1,93 @@ +import { relations } from "drizzle-orm/relations"; +import { chatThreads, chatMessages, user, workspaces, workspaceShareLinks, workspaceCollaborators, account, session, workspaceEvents, workspaceSnapshots, workspaceInvites, workspaceItems } from "./schema"; + +export const chatMessagesRelations = relations(chatMessages, ({one}) => ({ + chatThread: one(chatThreads, { + fields: [chatMessages.threadId], + references: [chatThreads.id] + }), +})); + +export const chatThreadsRelations = relations(chatThreads, ({one, many}) => ({ + chatMessages: many(chatMessages), + user: one(user, { + fields: [chatThreads.userId], + references: [user.id] + }), + workspace: one(workspaces, { + fields: [chatThreads.workspaceId], + references: [workspaces.id] + }), +})); + +export const userRelations = relations(user, ({many}) => ({ + chatThreads: many(chatThreads), + accounts: many(account), + sessions: many(session), +})); + +export const workspacesRelations = relations(workspaces, ({many}) => ({ + chatThreads: many(chatThreads), + workspaceShareLinks: many(workspaceShareLinks), + workspaceCollaborators: many(workspaceCollaborators), + workspaceEvents: many(workspaceEvents), + workspaceSnapshots: many(workspaceSnapshots), + workspaceInvites: many(workspaceInvites), + workspaceItems: many(workspaceItems), +})); + +export const workspaceShareLinksRelations = relations(workspaceShareLinks, ({one}) => ({ + workspace: one(workspaces, { + fields: [workspaceShareLinks.workspaceId], + references: [workspaces.id] + }), +})); + +export const workspaceCollaboratorsRelations = relations(workspaceCollaborators, ({one}) => ({ + workspace: one(workspaces, { + fields: [workspaceCollaborators.workspaceId], + references: [workspaces.id] + }), +})); + +export const accountRelations = relations(account, ({one}) => ({ + user: one(user, { + fields: [account.userId], + references: [user.id] + }), +})); + +export const sessionRelations = relations(session, ({one}) => ({ + user: one(user, { + fields: [session.userId], + references: [user.id] + }), +})); + +export const workspaceEventsRelations = relations(workspaceEvents, ({one}) => ({ + workspace: one(workspaces, { + fields: [workspaceEvents.workspaceId], + references: [workspaces.id] + }), +})); + +export const workspaceSnapshotsRelations = relations(workspaceSnapshots, ({one}) => ({ + workspace: one(workspaces, { + fields: [workspaceSnapshots.workspaceId], + references: [workspaces.id] + }), +})); + +export const workspaceInvitesRelations = relations(workspaceInvites, ({one}) => ({ + workspace: one(workspaces, { + fields: [workspaceInvites.workspaceId], + references: [workspaces.id] + }), +})); + +export const workspaceItemsRelations = relations(workspaceItems, ({one}) => ({ + workspace: one(workspaces, { + fields: [workspaceItems.workspaceId], + references: [workspaces.id] + }), +})); \ No newline at end of file diff --git a/drizzle/schema.ts b/drizzle/schema.ts new file mode 100644 index 00000000..dbc0e57d --- /dev/null +++ b/drizzle/schema.ts @@ -0,0 +1,316 @@ +import { pgTable, index, foreignKey, unique, uuid, text, jsonb, timestamp, pgPolicy, boolean, bigint, integer, uniqueIndex, primaryKey } from "drizzle-orm/pg-core" +import { sql } from "drizzle-orm" + + + +export const chatMessages = pgTable("chat_messages", { + id: uuid().defaultRandom().primaryKey().notNull(), + threadId: uuid("thread_id").notNull(), + messageId: text("message_id").notNull(), + parentId: text("parent_id"), + format: text().notNull(), + content: jsonb().notNull(), + createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow(), +}, (table) => [ + index("idx_chat_messages_thread").using("btree", table.threadId.asc().nullsLast().op("uuid_ops")), + index("idx_chat_messages_thread_created").using("btree", table.threadId.asc().nullsLast().op("timestamptz_ops"), table.createdAt.asc().nullsLast().op("timestamptz_ops")), + foreignKey({ + columns: [table.threadId], + foreignColumns: [chatThreads.id], + name: "chat_messages_thread_id_chat_threads_id_fk" + }).onDelete("cascade"), + unique("chat_messages_thread_message_key").on(table.threadId, table.messageId), +]); + +export const chatThreads = pgTable("chat_threads", { + id: uuid().defaultRandom().primaryKey().notNull(), + workspaceId: uuid("workspace_id").notNull(), + userId: text("user_id").notNull(), + title: text(), + isArchived: boolean("is_archived").default(false), + externalId: text("external_id"), + createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true, mode: 'string' }).defaultNow(), + lastMessageAt: timestamp("last_message_at", { withTimezone: true, mode: 'string' }).defaultNow(), + headMessageId: text("head_message_id"), +}, (table) => [ + index("idx_chat_threads_last_message").using("btree", table.workspaceId.asc().nullsLast().op("timestamptz_ops"), table.lastMessageAt.asc().nullsLast().op("uuid_ops")), + index("idx_chat_threads_user").using("btree", table.userId.asc().nullsLast().op("text_ops")), + index("idx_chat_threads_workspace").using("btree", table.workspaceId.asc().nullsLast().op("uuid_ops")), + foreignKey({ + columns: [table.userId], + foreignColumns: [user.id], + name: "chat_threads_user_id_user_id_fk" + }).onDelete("cascade"), + foreignKey({ + columns: [table.workspaceId], + foreignColumns: [workspaces.id], + name: "chat_threads_workspace_id_workspaces_id_fk" + }).onDelete("cascade"), + pgPolicy("chat_threads_user_scoped", { as: "permissive", for: "all", to: ["authenticated"], using: sql`((user_id = (auth.jwt() ->> 'sub'::text)) AND ((EXISTS ( SELECT 1 + FROM workspaces w + WHERE ((w.id = chat_threads.workspace_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1 + FROM workspace_collaborators c + WHERE ((c.workspace_id = chat_threads.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)))))))` }), +]); + +export const workspaceShareLinks = pgTable("workspace_share_links", { + id: uuid().defaultRandom().primaryKey().notNull(), + workspaceId: uuid("workspace_id").notNull(), + token: text().notNull(), + permissionLevel: text("permission_level").default('editor').notNull(), + createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow(), + expiresAt: timestamp("expires_at", { withTimezone: true, mode: 'string' }).notNull(), +}, (table) => [ + index("idx_workspace_share_links_token").using("btree", table.token.asc().nullsLast().op("text_ops")), + index("idx_workspace_share_links_workspace").using("btree", table.workspaceId.asc().nullsLast().op("uuid_ops")), + foreignKey({ + columns: [table.workspaceId], + foreignColumns: [workspaces.id], + name: "workspace_share_links_workspace_id_fkey" + }).onDelete("cascade"), + unique("workspace_share_links_workspace_key").on(table.workspaceId), + unique("workspace_share_links_token_key").on(table.token), + pgPolicy("Public can view share link by token", { as: "permissive", for: "select", to: ["public"] }), + pgPolicy("Owners and editors can manage share links", { as: "permissive", for: "all", to: ["authenticated"] }), +]); + +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(), + lastOpenedAt: timestamp("last_opened_at", { withTimezone: true, mode: 'string' }), +}, (table) => [ + index("idx_workspace_collaborators_last_opened_at").using("btree", table.userId.asc().nullsLast().op("text_ops"), table.lastOpenedAt.asc().nullsLast().op("timestamptz_ops")), + index("idx_workspace_collaborators_lookup").using("btree", table.userId.asc().nullsLast().op("uuid_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_workspace_user_unique").on(table.workspaceId, table.userId), + unique("workspace_collaborators_invite_token_unique").on(table.inviteToken), + 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"] }), +]); + +export const user = pgTable("user", { + id: text().primaryKey().notNull(), + name: text(), + email: text().notNull(), + emailVerified: boolean("email_verified").default(false).notNull(), + image: text(), + createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(), + isAnonymous: boolean("is_anonymous").default(false), +}, (table) => [ + index("idx_user_email").using("btree", table.email.asc().nullsLast().op("text_ops")), + unique("user_email_unique").on(table.email), +]); + +export const account = pgTable("account", { + id: text().primaryKey().notNull(), + accountId: text("account_id").notNull(), + providerId: text("provider_id").notNull(), + userId: text("user_id").notNull(), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + idToken: text("id_token"), + accessTokenExpiresAt: timestamp("access_token_expires_at", { mode: 'string' }), + refreshTokenExpiresAt: timestamp("refresh_token_expires_at", { mode: 'string' }), + scope: text(), + password: text(), + createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { mode: 'string' }).notNull(), +}, (table) => [ + index("idx_account_user_id").using("btree", table.userId.asc().nullsLast().op("text_ops")), + foreignKey({ + columns: [table.userId], + foreignColumns: [user.id], + name: "account_user_id_user_id_fk" + }).onDelete("cascade"), +]); + +export const session = pgTable("session", { + id: text().primaryKey().notNull(), + expiresAt: timestamp("expires_at", { mode: 'string' }).notNull(), + token: text().notNull(), + createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { mode: 'string' }).notNull(), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + userId: text("user_id").notNull(), +}, (table) => [ + index("idx_session_token").using("btree", table.token.asc().nullsLast().op("text_ops")), + index("idx_session_user_id").using("btree", table.userId.asc().nullsLast().op("text_ops")), + foreignKey({ + columns: [table.userId], + foreignColumns: [user.id], + name: "session_user_id_user_id_fk" + }).onDelete("cascade"), + unique("session_token_unique").on(table.token), +]); + +export const userProfiles = pgTable("user_profiles", { + id: uuid().defaultRandom().primaryKey().notNull(), + userId: text("user_id").notNull(), + onboardingCompleted: boolean("onboarding_completed").default(false), + onboardingCompletedAt: timestamp("onboarding_completed_at", { withTimezone: true, mode: 'string' }), + createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true, mode: 'string' }).defaultNow(), +}, (table) => [ + index("idx_user_profiles_user_id").using("btree", table.userId.asc().nullsLast().op("text_ops")), + unique("user_profiles_user_id_key").on(table.userId), + pgPolicy("Users can insert their own profile", { as: "permissive", for: "insert", to: ["authenticated"], withCheck: sql`(( SELECT (auth.jwt() ->> 'sub'::text)) = user_id)` }), + pgPolicy("Users can update their own profile", { as: "permissive", for: "update", to: ["authenticated"] }), + pgPolicy("Users can view their own profile", { as: "permissive", for: "select", to: ["authenticated"] }), +]); + +export const verification = pgTable("verification", { + id: text().primaryKey().notNull(), + identifier: text().notNull(), + value: text().notNull(), + expiresAt: timestamp("expires_at", { mode: 'string' }).notNull(), + createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(), +}, (table) => [ + index("idx_verification_identifier").using("btree", table.identifier.asc().nullsLast().op("text_ops")), +]); + +export const workspaceEvents = pgTable("workspace_events", { + id: uuid().defaultRandom().primaryKey().notNull(), + workspaceId: uuid("workspace_id").notNull(), + eventId: text("event_id").notNull(), + eventType: text("event_type").notNull(), + payload: jsonb().notNull(), + // You can use { mode: "bigint" } if numbers are exceeding js number limitations + timestamp: bigint({ mode: "number" }).notNull(), + userId: text("user_id").notNull(), + version: integer().notNull(), + createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow(), + userName: text("user_name"), +}, (table) => [ + index("idx_workspace_events_event_id").using("btree", table.eventId.asc().nullsLast().op("text_ops")), + index("idx_workspace_events_timestamp").using("btree", table.workspaceId.asc().nullsLast().op("int8_ops"), table.timestamp.asc().nullsLast().op("int8_ops")), + index("idx_workspace_events_user_name").using("btree", table.userName.asc().nullsLast().op("text_ops")), + index("idx_workspace_events_workspace").using("btree", table.workspaceId.asc().nullsLast().op("uuid_ops"), table.version.asc().nullsLast().op("uuid_ops")), + foreignKey({ + columns: [table.workspaceId], + foreignColumns: [workspaces.id], + name: "workspace_events_workspace_id_fkey" + }).onDelete("cascade"), + unique("workspace_events_event_id_key").on(table.eventId), + 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))))) 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"] }), +]); + +export const workspaces = pgTable("workspaces", { + id: uuid().defaultRandom().primaryKey().notNull(), + userId: text("user_id").notNull(), + name: text().notNull(), + description: text().default(''), + template: text().default('blank'), + isPublic: boolean("is_public").default(false), + createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true, mode: 'string' }).defaultNow(), + slug: text(), + icon: text(), + sortOrder: integer("sort_order"), + color: text(), + lastOpenedAt: timestamp("last_opened_at", { withTimezone: true, mode: 'string' }), +}, (table) => [ + index("idx_workspaces_created_at").using("btree", table.createdAt.desc().nullsFirst().op("timestamptz_ops")), + index("idx_workspaces_last_opened_at").using("btree", table.lastOpenedAt.asc().nullsLast().op("timestamptz_ops")), + index("idx_workspaces_slug").using("btree", table.slug.asc().nullsLast().op("text_ops")), + index("idx_workspaces_user_id").using("btree", table.userId.asc().nullsLast().op("text_ops")), + uniqueIndex("idx_workspaces_user_slug").using("btree", table.userId.asc().nullsLast().op("text_ops"), table.slug.asc().nullsLast().op("text_ops")), + index("idx_workspaces_user_sort_order").using("btree", table.userId.asc().nullsLast().op("int4_ops"), table.sortOrder.asc().nullsLast().op("int4_ops")), + pgPolicy("Users can delete their own workspaces", { as: "permissive", for: "delete", to: ["authenticated"], using: sql`(( SELECT (auth.jwt() ->> 'sub'::text)) = user_id)` }), + pgPolicy("Users can insert their own workspaces", { as: "permissive", for: "insert", to: ["authenticated"] }), + pgPolicy("Users can update their own workspaces", { as: "permissive", for: "update", to: ["authenticated"] }), + pgPolicy("Users can view their own workspaces", { as: "permissive", for: "select", to: ["authenticated"] }), +]); + +export const workspaceSnapshots = pgTable("workspace_snapshots", { + id: uuid().defaultRandom().primaryKey().notNull(), + workspaceId: uuid("workspace_id").notNull(), + snapshotVersion: integer("snapshot_version").notNull(), + state: jsonb().notNull(), + eventCount: integer("event_count").notNull(), + createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow(), +}, (table) => [ + index("idx_workspace_snapshots_version").using("btree", table.workspaceId.asc().nullsLast().op("int4_ops"), table.snapshotVersion.desc().nullsFirst().op("uuid_ops")), + index("idx_workspace_snapshots_workspace").using("btree", table.workspaceId.asc().nullsLast().op("uuid_ops")), + foreignKey({ + columns: [table.workspaceId], + foreignColumns: [workspaces.id], + name: "workspace_snapshots_workspace_id_fkey" + }).onDelete("cascade"), + unique("workspace_snapshots_workspace_id_snapshot_version_key").on(table.workspaceId, table.snapshotVersion), + pgPolicy("Service role can insert workspace snapshots", { as: "permissive", for: "insert", to: ["public"], withCheck: sql`true` }), + pgPolicy("Users can read workspace snapshots they have access to", { as: "permissive", for: "select", to: ["public"] }), +]); + +export const workspaceInvites = pgTable("workspace_invites", { + id: uuid().defaultRandom().primaryKey().notNull(), + workspaceId: uuid("workspace_id").notNull(), + email: text().notNull(), + token: text().notNull(), + inviterId: text("inviter_id").notNull(), + permissionLevel: text("permission_level").default('editor').notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true, mode: 'string' }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow(), +}, (table) => [ + index("idx_workspace_invites_email").using("btree", table.email.asc().nullsLast().op("text_ops")), + index("idx_workspace_invites_token").using("btree", table.token.asc().nullsLast().op("text_ops")), + index("idx_workspace_invites_workspace").using("btree", table.workspaceId.asc().nullsLast().op("uuid_ops")), + foreignKey({ + columns: [table.workspaceId], + foreignColumns: [workspaces.id], + name: "workspace_invites_workspace_id_fkey" + }).onDelete("cascade"), + unique("workspace_invites_token_key").on(table.token), + pgPolicy("Public can view invite by token", { as: "permissive", for: "select", to: ["public"] }), + pgPolicy("Users can insert invites for workspaces they own/edit", { as: "permissive", for: "insert", to: ["authenticated"] }), +]); + +export const workspaceItems = pgTable("workspace_items", { + workspaceId: uuid("workspace_id").notNull(), + itemId: text("item_id").notNull(), + type: text().notNull(), + name: text().notNull(), + subtitle: text().default('').notNull(), + data: jsonb().notNull(), + color: text(), + folderId: text("folder_id"), + layout: jsonb(), + // You can use { mode: "bigint" } if numbers are exceeding js number limitations + lastModified: bigint("last_modified", { mode: "number" }), + sourceVersion: integer("source_version").notNull(), + createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), +}, (table) => [ + index("idx_workspace_items_workspace").using("btree", table.workspaceId.asc().nullsLast().op("uuid_ops")), + index("idx_workspace_items_workspace_folder").using("btree", table.workspaceId.asc().nullsLast().op("uuid_ops"), table.folderId.asc().nullsLast().op("uuid_ops")), + index("idx_workspace_items_workspace_type").using("btree", table.workspaceId.asc().nullsLast().op("text_ops"), table.type.asc().nullsLast().op("text_ops")), + index("idx_workspace_items_workspace_updated").using("btree", table.workspaceId.asc().nullsLast().op("uuid_ops"), table.updatedAt.asc().nullsLast().op("timestamptz_ops")), + index("idx_workspace_items_workspace_version").using("btree", table.workspaceId.asc().nullsLast().op("int4_ops"), table.sourceVersion.asc().nullsLast().op("uuid_ops")), + foreignKey({ + columns: [table.workspaceId], + foreignColumns: [workspaces.id], + name: "workspace_items_workspace_id_fkey" + }).onDelete("cascade"), + primaryKey({ columns: [table.workspaceId, table.itemId], name: "workspace_items_pkey"}), + pgPolicy("Users can read projected workspace items they have access to", { as: "permissive", for: "select", to: ["public"] }), + pgPolicy("Users can write projected workspace items they have write acces", { as: "permissive", for: "all", to: ["public"] }), +]); diff --git a/src/app/api/share/[id]/route.ts b/src/app/api/share/[id]/route.ts index cf05c2b0..b1dad747 100644 --- a/src/app/api/share/[id]/route.ts +++ b/src/app/api/share/[id]/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 { loadWorkspaceState } from "@/lib/workspace/state-loader"; +import { loadWorkspaceCurrentState } from "@/lib/workspace/workspace-items-projection"; /** * GET /api/share/[id] @@ -26,13 +26,9 @@ export async function GET( return NextResponse.json({ error: "Workspace not found" }, { status: 404 }); } - // Get workspace state by replaying events - const state = await loadWorkspaceState(id); - - // Ensure state has workspace metadata if empty - if (!state.globalTitle) { - state.globalTitle = workspace[0].name || ""; - } + const state = await loadWorkspaceCurrentState( + id, + ); // Return workspace data for forking (public access) return NextResponse.json({ diff --git a/src/app/api/workspaces/[id]/events/route.ts b/src/app/api/workspaces/[id]/events/route.ts index dab5ea0b..e983aca5 100644 --- a/src/app/api/workspaces/[id]/events/route.ts +++ b/src/app/api/workspaces/[id]/events/route.ts @@ -1,63 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; import type { WorkspaceEvent, EventResponse } from "@/lib/workspace/events"; -import { checkAndCreateSnapshot } from "@/lib/workspace/snapshot-manager"; - -/** Strip heavy OCR page payloads from OCR-backed items — client refetches item state as needed. */ -function stripOcrPagesFromItem(item: { type?: string; data?: unknown }): void { - if ( - (item?.type === "pdf" || item?.type === "image") && - item.data && - typeof item.data === "object" - ) { - const d = item.data as Record; - delete d.ocrPages; - } -} - -function stripOcrPagesFromState( - state: { items?: Array<{ type?: string; data?: unknown }> } | undefined, -): void { - state?.items?.forEach(stripOcrPagesFromItem); -} - -function stripPdfOcrFromEventPayload(event: WorkspaceEvent): void { - const p = event.payload as Record; - if (event.type === "ITEM_CREATED" && p?.item) - stripOcrPagesFromItem(p.item as { type?: string; data?: unknown }); - if (event.type === "ITEM_UPDATED") { - const changes = p?.changes as Record | undefined; - if (changes?.data && typeof changes.data === "object") { - const d = changes.data as Record; - delete d.ocrPages; - } - } - if (event.type === "BULK_ITEMS_PATCHED") { - const updates = p?.updates as - | Array<{ changes?: { data?: unknown } }> - | undefined; - updates?.forEach((update) => { - if (update?.changes?.data && typeof update.changes.data === "object") { - const d = update.changes.data as Record; - delete d.ocrPages; - } - }); - } - if (event.type === "BULK_ITEMS_UPDATED") { - ( - p?.addedItems as Array<{ type?: string; data?: unknown }> | undefined - )?.forEach(stripOcrPagesFromItem); - (p?.items as Array<{ type?: string; data?: unknown }> | undefined)?.forEach( - stripOcrPagesFromItem, - ); - } - if (event.type === "WORKSPACE_SNAPSHOT" && p?.items) { - (p.items as Array<{ type?: string; data?: unknown }>).forEach( - stripOcrPagesFromItem, - ); - } -} - -import { loadWorkspaceState } from "@/lib/workspace/state-loader"; import { hasDuplicateName } from "@/lib/workspace/unique-name"; import { db, workspaceEvents } from "@/lib/db/client"; import { eq, gt, asc, sql, and } from "drizzle-orm"; @@ -67,6 +9,10 @@ import { withErrorHandling, } from "@/lib/api/workspace-helpers"; import { broadcastWorkspaceEventFromServer } from "@/lib/realtime/server-broadcast"; +import { loadWorkspaceItemsState } from "@/lib/workspace/workspace-items-projection"; +import { + appendWorkspaceEventWithProjection, +} from "@/lib/workspace/workspace-event-store"; /** * GET /api/workspaces/[id]/events @@ -95,49 +41,15 @@ async function handleGET( 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` - SELECT - id, - snapshot_version as "snapshotVersion", - state, - event_count as "eventCount", - 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 + // Snapshots are deprecated from the normal event-history path. 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), - ), - ); + .where(eq(workspaceEvents.workspaceId, id)); 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[] = []; @@ -147,7 +59,6 @@ async function handleGET( // 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 @@ -160,7 +71,7 @@ async function handleGET( version FROM get_workspace_events_fast( ${id}::uuid, - ${snapshotVersion}::integer, + ${0}::integer, ${PAGE_SIZE}::integer ) `); @@ -198,7 +109,6 @@ async function handleGET( version FROM workspace_events WHERE workspace_id = ${id}::uuid - AND version > ${snapshotVersion} ORDER BY version ASC LIMIT ${PAGE_SIZE} OFFSET ${page * PAGE_SIZE} @@ -245,27 +155,11 @@ async function handleGET( const maxVersion = eventsData && eventsData.length > 0 ? Math.max(...eventsData.map((e) => e.version)) - : snapshotVersion || 0; - - // Strip heavy OCR page payloads — client doesn't need them in event responses. - if (latestSnapshot?.state) - stripOcrPagesFromState( - latestSnapshot.state as { - items?: Array<{ type?: string; data?: unknown }>; - }, - ); - events.forEach(stripPdfOcrFromEventPayload); + : 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; @@ -323,7 +217,7 @@ async function handlePOST( if (event.type === "ITEM_CREATED") { const item = event.payload?.item; if (item?.name != null && item?.type) { - const state = await loadWorkspaceState(id); + const state = await loadWorkspaceItemsState(id); if ( hasDuplicateName( state.items, @@ -345,7 +239,7 @@ async function handlePOST( const itemId = event.payload?.id; const newName = event.payload.changes.name; if (itemId && newName) { - const state = await loadWorkspaceState(id); + const state = await loadWorkspaceItemsState(id); const existingItem = state.items.find( (i: { id: string }) => i.id === itemId, ); @@ -374,48 +268,13 @@ async function handlePOST( // 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, - ${event.type}::text, - ${JSON.stringify(event.payload)}::jsonb, - ${event.timestamp}::bigint, - ${event.userId}::text, - ${baseVersion}::integer, - ${event.userName || null}::text - ) as result - `); + const appendResult = await appendWorkspaceEventWithProjection({ + workspaceId: id, + event, + baseVersion, + }); timings.appendFunction = Date.now() - appendStart; - 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; - - // 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 @@ -445,7 +304,6 @@ async function handlePOST( id: e.eventId, }) as WorkspaceEvent, ); - events.forEach(stripPdfOcrFromEventPayload); const totalTime = Date.now() - startTime; timings.total = totalTime; @@ -457,16 +315,6 @@ async function handlePOST( }); } - // 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 - }); - await broadcastWorkspaceEventFromServer(id, { ...event, version: appendResult.version, diff --git a/src/app/api/workspaces/[id]/events/undo/route.ts b/src/app/api/workspaces/[id]/events/undo/route.ts index 3b6d9cde..77d0a507 100644 --- a/src/app/api/workspaces/[id]/events/undo/route.ts +++ b/src/app/api/workspaces/[id]/events/undo/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { db, workspaceEvents } from "@/lib/db/client"; import { eq, gt, and } from "drizzle-orm"; import { requireAuth, verifyWorkspaceAccess, withErrorHandling } from "@/lib/api/workspace-helpers"; +import { rebuildWorkspaceItemsProjectionFromEvents } from "@/lib/workspace/workspace-items-projection"; /** * POST /api/workspaces/[id]/events/undo @@ -29,23 +30,21 @@ async function handlePOST( await verifyWorkspaceAccess(id, userId, "editor"); - // Don't allow reverting past version 1 (WORKSPACE_CREATED) - if (targetVersion < 1) { - return NextResponse.json( - { error: "Cannot revert past the initial workspace creation" }, - { status: 400 } - ); - } - - const result = await db - .delete(workspaceEvents) - .where( - and( - eq(workspaceEvents.workspaceId, id), - gt(workspaceEvents.version, targetVersion) + const result = await db.transaction(async (tx) => { + const deletedEvents = await tx + .delete(workspaceEvents) + .where( + and( + eq(workspaceEvents.workspaceId, id), + gt(workspaceEvents.version, targetVersion) + ) ) - ) - .returning({ version: workspaceEvents.version }); + .returning({ version: workspaceEvents.version }); + + return deletedEvents; + }); + + await rebuildWorkspaceItemsProjectionFromEvents(id); return NextResponse.json({ success: true, diff --git a/src/app/api/workspaces/[id]/item-user-state/route.ts b/src/app/api/workspaces/[id]/item-user-state/route.ts new file mode 100644 index 00000000..a4b401a1 --- /dev/null +++ b/src/app/api/workspaces/[id]/item-user-state/route.ts @@ -0,0 +1,124 @@ +import { and, eq } from "drizzle-orm"; +import { NextResponse } from "next/server"; +import { db, workspaceItems, workspaceItemUserState } from "@/lib/db/client"; +import { + requireAuth, + verifyWorkspaceAccess, + withErrorHandling, +} from "@/lib/api/workspace-helpers"; +import { extractWorkspaceItemUserState } from "@/lib/workspace/workspace-item-model"; +import type { CardType, ItemData } from "@/lib/workspace-state/types"; + +const STORED_CARD_TYPES: CardType[] = [ + "pdf", + "flashcard", + "folder", + "youtube", + "quiz", + "image", + "audio", + "website", + "document", +]; + +function storedTypeToCardType(value: string): CardType | null { + return STORED_CARD_TYPES.includes(value as CardType) + ? (value as CardType) + : null; +} + +type UpdateWorkspaceItemUserStateBody = { + itemId?: string; + /** Ignored; item type is taken from the workspace_items row. */ + itemType?: CardType; + data?: ItemData; +}; + +async function handlePOST( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const [{ id: workspaceId }, userId, body] = await Promise.all([ + params, + requireAuth(), + request.json() as Promise, + ]); + + await verifyWorkspaceAccess(workspaceId, userId, "viewer"); + + const itemId = body?.itemId; + + if (!itemId) { + return NextResponse.json({ error: "itemId is required" }, { status: 400 }); + } + + const [workspaceItem] = await db + .select({ + itemId: workspaceItems.itemId, + type: workspaceItems.type, + }) + .from(workspaceItems) + .where( + and( + eq(workspaceItems.workspaceId, workspaceId), + eq(workspaceItems.itemId, itemId), + ), + ) + .limit(1); + + if (!workspaceItem) { + return NextResponse.json({ error: "Workspace item not found" }, { status: 404 }); + } + + const itemType = storedTypeToCardType(workspaceItem.type); + if (!itemType) { + return NextResponse.json( + { error: "Workspace item has an invalid type" }, + { status: 500 }, + ); + } + + const userState = extractWorkspaceItemUserState(itemType, body?.data); + + if (!userState) { + await db + .delete(workspaceItemUserState) + .where( + and( + eq(workspaceItemUserState.workspaceId, workspaceId), + eq(workspaceItemUserState.itemId, itemId), + eq(workspaceItemUserState.userId, userId), + ), + ); + + return NextResponse.json({ success: true, cleared: true }); + } + + await db + .insert(workspaceItemUserState) + .values({ + workspaceId, + itemId, + userId, + state: userState, + updatedAt: new Date().toISOString(), + }) + .onConflictDoUpdate({ + target: [ + workspaceItemUserState.workspaceId, + workspaceItemUserState.itemId, + workspaceItemUserState.userId, + ], + set: { + state: userState, + updatedAt: new Date().toISOString(), + }, + }); + + return NextResponse.json({ success: true, cleared: false }); +} + +export const POST = withErrorHandling( + handlePOST, + "POST /api/workspaces/[id]/item-user-state", +); diff --git a/src/app/api/workspaces/[id]/route.ts b/src/app/api/workspaces/[id]/route.ts index 96847343..36ea50cd 100644 --- a/src/app/api/workspaces/[id]/route.ts +++ b/src/app/api/workspaces/[id]/route.ts @@ -1,9 +1,10 @@ import { NextRequest, NextResponse } from "next/server"; -import { db, workspaces } from "@/lib/db/client"; -import { eq, and, ne } from "drizzle-orm"; -import { loadWorkspaceState } from "@/lib/workspace/state-loader"; +import { db, workspaceEvents, workspaces } from "@/lib/db/client"; +import { eq, and, ne, sql } from "drizzle-orm"; import { requireAuth, verifyWorkspaceOwnership, verifyWorkspaceAccess, withErrorHandling } from "@/lib/api/workspace-helpers"; import { generateSlug } from "@/lib/workspace/slug"; +import { loadWorkspaceCurrentState } from "@/lib/workspace/workspace-items-projection"; +import { EMPTY_WORKSPACE_ACTIVITY_SUMMARY } from "@/lib/workspace/workspace-activity"; /** * GET /api/workspaces/[id] @@ -35,18 +36,36 @@ async function handleGET( return NextResponse.json({ error: "Workspace not found" }, { status: 404 }); } - // Get workspace state by replaying events - const state = await loadWorkspaceState(id); - - // Ensure state has workspace metadata if empty - if (!state.globalTitle) { - state.globalTitle = workspace.name || ""; - } + // Current-state reads now prefer the item projection while workspace name stays canonical in workspaces.name. + const [state, activityRow] = await Promise.all([ + loadWorkspaceCurrentState(id, userId), + db + .select({ + version: sql`coalesce(max(${workspaceEvents.version}), 0)::int`, + eventCount: sql`count(*)::int`, + lastEventAt: sql`max(${workspaceEvents.timestamp})`, + }) + .from(workspaceEvents) + .where(eq(workspaceEvents.workspaceId, id)) + .limit(1), + ]); + + const activity = activityRow[0] + ? { + version: Number(activityRow[0].version ?? 0), + eventCount: Number(activityRow[0].eventCount ?? 0), + lastEventAt: + activityRow[0].lastEventAt == null + ? null + : Number(activityRow[0].lastEventAt), + } + : EMPTY_WORKSPACE_ACTIVITY_SUMMARY; return NextResponse.json({ workspace: { ...workspace, state, + activity, isShared: !accessInfo.isOwner, permissionLevel: accessInfo.permissionLevel, }, @@ -158,7 +177,7 @@ async function handleDELETE( // Check ownership await verifyWorkspaceOwnership(id, userId); - // Delete workspace (cascade will delete events and snapshots) + // Delete workspace (cascade also removes any legacy snapshot rows still in the DB) await db .delete(workspaces) .where(eq(workspaces.id, id)); diff --git a/src/app/api/workspaces/[id]/snapshot/route.ts b/src/app/api/workspaces/[id]/snapshot/route.ts deleted file mode 100644 index c7ece1df..00000000 --- a/src/app/api/workspaces/[id]/snapshot/route.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { - checkNeedsSnapshot, - createSnapshot, - checkAndCreateSnapshot -} from "@/lib/workspace/snapshot-manager"; -import { db, workspaces } from "@/lib/db/client"; -import { eq, sql } from "drizzle-orm"; -import { requireAuth, verifyWorkspaceAccess, withErrorHandling } from "@/lib/api/workspace-helpers"; - -/** - * GET /api/workspaces/[id]/snapshot - * Check snapshot status for a workspace (owner only) - */ -async function handleGET( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - // Start independent operations in parallel - const paramsPromise = params; - const authPromise = requireAuth(); - - const { id } = await paramsPromise; - const userId = await authPromise; - - // 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); - - // Get latest snapshot info - const latestSnapshot = await db.execute(sql` - SELECT * FROM get_latest_snapshot(${id}::uuid) - `); - - return NextResponse.json({ - workspace: { - id, - title: workspace.name, - }, - snapshot: latestSnapshot[0] || null, - status: snapshotStatus, - }); -} - -export const GET = withErrorHandling(handleGET, "GET /api/workspaces/[id]/snapshot"); - -/** - * POST /api/workspaces/[id]/snapshot - * Manually create a snapshot for a workspace (owner only) - */ -async function handlePOST( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - // Start independent operations in parallel - const paramsPromise = params; - const authPromise = requireAuth(); - - const { id } = await paramsPromise; - const userId = await authPromise; - - // 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); - - if (!result.success) { - return NextResponse.json( - { error: result.error || "Failed to create snapshot" }, - { status: 500 } - ); - } - - return NextResponse.json({ - success: true, - version: result.version, - message: `Snapshot created at version ${result.version}`, - }); -} - -export const POST = withErrorHandling(handlePOST, "POST /api/workspaces/[id]/snapshot"); - -/** - * PUT /api/workspaces/[id]/snapshot - * Check and create snapshot if needed (owner only) - */ -async function handlePUT( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - // Start independent operations in parallel - const paramsPromise = params; - const authPromise = requireAuth(); - - const { id } = await paramsPromise; - const userId = await authPromise; - - // 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); - - if (!statusBefore.needsSnapshot) { - return NextResponse.json({ - message: "Snapshot not needed yet", - status: statusBefore, - }); - } - - // Create snapshot if needed - await checkAndCreateSnapshot(id); - - const statusAfter = await checkNeedsSnapshot(id); - - return NextResponse.json({ - message: "Snapshot check complete", - created: true, - before: statusBefore, - after: statusAfter, - }); -} - -export const PUT = withErrorHandling(handlePUT, "PUT /api/workspaces/[id]/snapshot"); diff --git a/src/app/api/workspaces/[id]/snapshots/route.ts b/src/app/api/workspaces/[id]/snapshots/route.ts deleted file mode 100644 index 5fab0302..00000000 --- a/src/app/api/workspaces/[id]/snapshots/route.ts +++ /dev/null @@ -1,45 +0,0 @@ -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, verifyWorkspaceAccess, withErrorHandling } from "@/lib/api/workspace-helpers"; - -/** - * GET /api/workspaces/[id]/snapshots - * Fetch all snapshots for a workspace (for version history) - * Note: Owner only (sharing is fork-based) - */ -async function handleGET( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - // Start independent operations in parallel - const paramsPromise = params; - const authPromise = requireAuth(); - - const { id } = await paramsPromise; - const userId = await authPromise; - - // 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)); - - 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 }); -} - -export const GET = withErrorHandling(handleGET, "GET /api/workspaces/[id]/snapshots"); diff --git a/src/app/api/workspaces/autogen/route.ts b/src/app/api/workspaces/autogen/route.ts index 80800f2f..fff0a28d 100644 --- a/src/app/api/workspaces/autogen/route.ts +++ b/src/app/api/workspaces/autogen/route.ts @@ -3,7 +3,6 @@ import { google } from "@ai-sdk/google"; import { streamText, generateText, Output } from "ai"; import { z } from "zod"; import { executeWebSearch } from "@/lib/ai/tools/web-search"; -import { randomUUID } from "crypto"; import { desc, eq, sql } from "drizzle-orm"; import { requireAuthWithUserInfo } from "@/lib/api/workspace-helpers"; import { db, workspaces } from "@/lib/db/client"; @@ -468,24 +467,6 @@ export async function POST(request: NextRequest) { timings.workspaceCreateMs = Date.now() - phase0Start; logger.info("[AUTOGEN] Workspace created (placeholder)", { ms: timings.workspaceCreateMs, workspaceId }); - // Create WORKSPACE_CREATED event - try { - await db.execute(sql` - SELECT append_workspace_event( - ${workspaceId}::uuid, - ${randomUUID()}::text, - ${"WORKSPACE_CREATED"}::text, - ${JSON.stringify({ title: "New Workspace", description: "" })}::jsonb, - ${Date.now()}::bigint, - ${userId}::text, - 0::integer, - ${user.name || user.email || null}::text - ) - `); - } catch (eventError) { - logger.error("[AUTOGEN] Error creating WORKSPACE_CREATED event:", eventError); - } - // Create PDF and image items immediately so OCR can start (runs in parallel with Phase 1) // Seed with known content-item positions so files are placed around them (matching pre-restructuring layout) // Always reserve YouTube footprint so a later searched YouTube item (AUTOGEN_LAYOUTS.youtube) never overlaps early PDFs/images diff --git a/src/app/api/workspaces/route.ts b/src/app/api/workspaces/route.ts index dc2509d2..aa7f5969 100644 --- a/src/app/api/workspaces/route.ts +++ b/src/app/api/workspaces/route.ts @@ -6,8 +6,9 @@ import type { CardColor } from "@/lib/workspace-state/colors"; import { randomUUID } from "crypto"; import { db, workspaces } from "@/lib/db/client"; import { workspaceCollaborators } from "@/lib/db/schema"; -import { eq, desc, asc, sql, inArray } from "drizzle-orm"; +import { eq, desc, asc, inArray } from "drizzle-orm"; import { requireAuth, requireAuthWithUserInfo, withErrorHandling } from "@/lib/api/workspace-helpers"; +import { appendWorkspaceEventWithProjection } from "@/lib/workspace/workspace-event-store"; /** * GET /api/workspaces @@ -224,91 +225,30 @@ async function handlePOST(request: NextRequest) { // Use provided initial state (already validated on client side) initialState = customInitialState; initialState.workspaceId = workspace.id; - if (!initialState.globalTitle) { - initialState.globalTitle = name; - } } else { // Use template-based initial state initialState = getTemplateInitialState(effectiveTemplate); initialState.workspaceId = workspace.id; - initialState.globalTitle = name; } - // Note: No need to create workspace_states record - state is now managed via events - - // Determine if we need a WORKSPACE_SNAPSHOT (has items) or just WORKSPACE_CREATED (empty) - const hasItems = initialState.items && initialState.items.length > 0; - - // For workspaces with items (custom or template-based), create WORKSPACE_SNAPSHOT - // This sets the entire state in one event, including title, description, and items - if (customInitialState || hasItems) { - const eventId = randomUUID(); - const timestamp = Date.now(); - - // Use user info from requireAuthWithUserInfo to avoid duplicate session fetch - const userName = user.name || user.email || undefined; - - // Create snapshot event first (starts at version 0, creates version 1) - try { - await db.execute(sql` - SELECT append_workspace_event( - ${workspace.id}::uuid, - ${eventId}::text, - ${'WORKSPACE_SNAPSHOT'}::text, - ${JSON.stringify(initialState)}::jsonb, - ${timestamp}::bigint, - ${userId}::text, - ${0}::integer, - ${userName || null}::text - ) - `); - - // If snapshot creation succeeded, we're done (it includes title/description) - // No need for separate WORKSPACE_CREATED event - } catch (eventError) { - console.error("Error creating WORKSPACE_SNAPSHOT event:", eventError); - // If snapshot fails, create WORKSPACE_CREATED as fallback - try { - const createdEventId = randomUUID(); - const createdTimestamp = Date.now(); - await db.execute(sql` - SELECT append_workspace_event( - ${workspace.id}::uuid, - ${createdEventId}::text, - ${'WORKSPACE_CREATED'}::text, - ${JSON.stringify({ title: name, description: description || "" })}::jsonb, - ${createdTimestamp}::bigint, - ${userId}::text, - ${0}::integer, - ${null}::text - ) - `); - } catch (createdError) { - console.error("Error creating WORKSPACE_CREATED event:", createdError); - } - } - } else { - // For empty workspaces, just create WORKSPACE_CREATED event + // Workspace metadata now lives directly on the `workspaces` row. + // Only seed the event log if the workspace starts with actual items. + if (initialState.items.length > 0) { try { - const eventId = randomUUID(); - const timestamp = Date.now(); - - await db.execute(sql` - SELECT append_workspace_event( - ${workspace.id}::uuid, - ${eventId}::text, - ${'WORKSPACE_CREATED'}::text, - ${JSON.stringify({ title: name, description: description || "" })}::jsonb, - ${timestamp}::bigint, - ${userId}::text, - ${0}::integer, - ${null}::text - ) - `); + await appendWorkspaceEventWithProjection({ + workspaceId: workspace.id, + event: { + id: randomUUID(), + type: "BULK_ITEMS_CREATED", + payload: { items: initialState.items }, + timestamp: Date.now(), + userId, + userName: user.name || user.email || undefined, + }, + baseVersion: 0, + }); } catch (eventError) { - // If event creation fails, continue – workspace exists in DB, - // but event-sourced title/description may be missing - console.error("Error creating WORKSPACE_CREATED event:", eventError); + console.error("Error creating initial BULK_ITEMS_CREATED event:", eventError); } } diff --git a/src/app/api/workspaces/slug/[slug]/route.ts b/src/app/api/workspaces/slug/[slug]/route.ts index 7945148d..7f1bed67 100644 --- a/src/app/api/workspaces/slug/[slug]/route.ts +++ b/src/app/api/workspaces/slug/[slug]/route.ts @@ -2,8 +2,8 @@ import { NextRequest, NextResponse } from "next/server"; import { db, workspaces } from "@/lib/db/client"; import { workspaceCollaborators } from "@/lib/db/schema"; import { eq, and, or, inArray } from "drizzle-orm"; -import { loadWorkspaceState } from "@/lib/workspace/state-loader"; import { requireAuth, withErrorHandling } from "@/lib/api/workspace-helpers"; +import { loadWorkspaceCurrentState } from "@/lib/workspace/workspace-items-projection"; /** * GET /api/workspaces/slug/[slug] @@ -99,13 +99,10 @@ async function handleGET( }); } - // Get workspace state by replaying events (full mode) - const state = await loadWorkspaceState(workspace.id); - - // Ensure state has workspace metadata if empty - if (!state.globalTitle) { - state.globalTitle = workspace.name || ""; - } + const state = await loadWorkspaceCurrentState( + workspace.id, + userId, + ); return NextResponse.json({ workspace: { diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 6f40da03..389c74d1 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -55,6 +55,10 @@ import { getDocumentUploadPartialMessage, getDocumentUploadSuccessMessage, } from "@/lib/uploads/upload-feedback"; +import { + getWorkspaceLastSavedAt, + shouldShowAnonymousSignInPrompt, +} from "@/lib/workspace/workspace-activity"; // Main dashboard content component interface DashboardContentProps { @@ -101,6 +105,8 @@ function DashboardContent({ state, isLoading: isLoadingWorkspace, version, + eventCount, + lastEventAt, } = useWorkspaceState(currentWorkspaceId); // Open audio recorder when landing from home Record flow (store flag set before navigate). @@ -145,7 +151,16 @@ function DashboardContent({ // Version control (history only) const { revertToVersion: revertToVersionRaw } = useWorkspaceHistory(currentWorkspaceId); - const { data: eventLog } = useWorkspaceEvents(currentWorkspaceId); + + // Workspace settings/share modals (lifted so header can open them) + const [showVersionHistory, setShowVersionHistory] = useState(false); + const [showWorkspaceSettings, setShowWorkspaceSettings] = useState(false); + const [showWorkspaceShare, setShowWorkspaceShare] = useState(false); + + const shouldLoadEventLog = !!currentWorkspaceId && showVersionHistory; + const { data: eventLog } = useWorkspaceEvents(currentWorkspaceId, { + enabled: shouldLoadEventLog, + }); // Track sign-in prompt dismissal per workspace for anonymous users. const [ @@ -153,18 +168,13 @@ function DashboardContent({ setDismissedSignInPromptWorkspaceId, ] = useState(null); - // Workspace settings/share modals (lifted so header can open them) - const [showWorkspaceSettings, setShowWorkspaceSettings] = useState(false); - const [showWorkspaceShare, setShowWorkspaceShare] = useState(false); - const [showVersionHistory, setShowVersionHistory] = useState(false); - - const showSignInPrompt = - !!session?.user?.isAnonymous && - !!eventLog && - !isLoadingWorkspace && - !!currentWorkspaceId && - (eventLog.events?.length ?? 0) >= 15 && - dismissedSignInPromptWorkspaceId !== currentWorkspaceId; + const showSignInPrompt = shouldShowAnonymousSignInPrompt({ + isAnonymous: !!session?.user?.isAnonymous, + isLoadingWorkspace, + currentWorkspaceId, + dismissedWorkspaceId: dismissedSignInPromptWorkspaceId, + eventCount, + }); // Get sidebar state and controls const { toggleSidebar } = useSidebar(); @@ -184,43 +194,17 @@ function DashboardContent({ updateLastSaved, ]); - // Mark as saved when workspace is loaded from events + // Mark as saved when workspace state finishes loading. useEffect(() => { if (!isLoadingWorkspace && currentWorkspaceId && state.items) { - // Use the last event's timestamp if available, otherwise use current time - let lastSavedDate: Date; - if (eventLog?.events && eventLog.events.length > 0) { - // Events are ordered by version, so the last event is the most recent - const lastEvent = eventLog.events[eventLog.events.length - 1]; - // Ensure timestamp exists and is valid - if (lastEvent.timestamp != null) { - // Ensure timestamp is a number (might be string from JSON) - const timestamp = - typeof lastEvent.timestamp === "number" - ? lastEvent.timestamp - : Number(lastEvent.timestamp); - lastSavedDate = new Date(timestamp); - // Validate the date is valid - if (isNaN(lastSavedDate.getTime())) { - // If invalid, fallback to current time - lastSavedDate = new Date(); - } - } else { - // If timestamp is missing, fallback to current time - lastSavedDate = new Date(); - } - } else { - // Fallback to current time if no events exist (new workspace) - lastSavedDate = new Date(); - } - updateLastSaved(lastSavedDate); + updateLastSaved(getWorkspaceLastSavedAt(lastEventAt)); updateHasUnsavedChanges(false); } }, [ isLoadingWorkspace, currentWorkspaceId, state.items, - eventLog, + lastEventAt, updateLastSaved, updateHasUnsavedChanges, ]); @@ -283,6 +267,7 @@ function DashboardContent({ items={state.items} onUpdateItem={operations.updateItem} onUpdateItemData={operations.updateItemData} + onUpdateItemUserStateData={operations.updateItemUserStateData} onFlushPendingChanges={operations.flushPendingChanges} /> ); @@ -395,7 +380,7 @@ function DashboardContent({ isDesktop={isDesktop} isChatExpanded={isChatExpanded} setIsChatExpanded={setIsChatExpanded} - workspaceName={currentWorkspaceTitle || state.globalTitle} + workspaceName={currentWorkspaceTitle || "Workspace"} workspaceIcon={currentWorkspaceIcon} workspaceColor={currentWorkspaceColor} addItem={operations.createItem} @@ -449,17 +434,13 @@ function DashboardContent({ currentWorkspaceId={currentWorkspaceId} currentSlug={currentSlug} state={state} - addItem={operations.createItem} - updateItem={operations.updateItem} - deleteItem={operations.deleteItem} - updateAllItems={operations.updateAllItems} + operations={operations} isChatMaximized={isChatMaximized} isDesktop={isDesktop} isChatExpanded={isChatExpanded} setIsChatExpanded={setIsChatExpanded} openWorkspaceItem={openWorkspaceItem} titleInputRef={titleInputRef as React.RefObject} - operations={operations} scrollAreaRef={scrollAreaRef as React.RefObject} workspaceTitle={currentWorkspaceTitle} workspaceIcon={currentWorkspaceIcon} @@ -484,7 +465,7 @@ function DashboardContent({ events={eventLog?.events || []} currentVersion={version} onRevertToVersion={revertToVersion} - items={currentWorkspace?.state?.items || []} + items={state.items} /> { }; } - // Fetch full state to get potentially updated title/description - const state = await loadWorkspaceState(id); - - const title = state.globalTitle || workspace[0].name || "Untitled Workspace"; + const title = workspace[0].name || "Untitled Workspace"; const sharedTitle = `Shared Workspace: ${title}`; const description = workspace[0].description || "View and import this shared ThinkEx workspace."; const fullTitle = getPageTitle(sharedTitle); diff --git a/src/app/share-copy/[id]/page.tsx b/src/app/share-copy/[id]/page.tsx index a547b8dc..c691c623 100644 --- a/src/app/share-copy/[id]/page.tsx +++ b/src/app/share-copy/[id]/page.tsx @@ -6,7 +6,7 @@ import { useSession, signIn } from "@/lib/auth-client"; import SharedWorkspaceModal from "@/components/workspace/SharedWorkspaceModal"; import { AuthPageBackground } from "@/components/auth/AuthPageBackground"; import { Loader2 } from "lucide-react"; -import type { AgentState } from "@/lib/workspace-state/types"; +import type { WorkspaceState } from "@/lib/workspace-state/types"; import type { CardColor } from "@/lib/workspace-state/colors"; interface SharedWorkspaceData { @@ -16,7 +16,7 @@ interface SharedWorkspaceData { description: string; icon: string | null; color: CardColor | null; - state: AgentState; + state: WorkspaceState; }; } diff --git a/src/components/assistant-ui/AssistantPanel.tsx b/src/components/assistant-ui/AssistantPanel.tsx index 42f51227..de1bb199 100644 --- a/src/components/assistant-ui/AssistantPanel.tsx +++ b/src/components/assistant-ui/AssistantPanel.tsx @@ -121,7 +121,7 @@ function WorkspaceContextWrapperContent({ // Extract workspace items for context display const items = state?.items || []; - // Workspace name from DB (fallback when state.globalTitle is empty, e.g. after rename in Settings) + // Workspace name comes from the canonical `workspaces` row. const { currentWorkspace } = useWorkspaceContext(); // Inject minimal workspace context (metadata and system instructions only) diff --git a/src/components/assistant-ui/CreateDocumentToolUI.tsx b/src/components/assistant-ui/CreateDocumentToolUI.tsx index 143a9410..413d0bf2 100644 --- a/src/components/assistant-ui/CreateDocumentToolUI.tsx +++ b/src/components/assistant-ui/CreateDocumentToolUI.tsx @@ -222,9 +222,7 @@ function CreateDocumentToolRenderer({ status={status} moveItemToFolder={operations.moveItemToFolder} allItems={(workspaceState?.items ?? []) as Item[]} - workspaceName={ - currentWorkspace?.name || workspaceState?.globalTitle || "Workspace" - } + workspaceName={currentWorkspace?.name || "Workspace"} workspaceIcon={currentWorkspace?.icon} workspaceColor={currentWorkspace?.color} /> diff --git a/src/components/assistant-ui/CreateFlashcardToolUI.tsx b/src/components/assistant-ui/CreateFlashcardToolUI.tsx index fefc6c71..719a890f 100644 --- a/src/components/assistant-ui/CreateFlashcardToolUI.tsx +++ b/src/components/assistant-ui/CreateFlashcardToolUI.tsx @@ -283,9 +283,7 @@ function CreateFlashcardToolRenderer({ status={status} moveItemToFolder={operations.moveItemToFolder} allItems={workspaceState?.items || []} - workspaceName={ - currentWorkspace?.name || workspaceState?.globalTitle || "Workspace" - } + workspaceName={currentWorkspace?.name || "Workspace"} workspaceIcon={currentWorkspace?.icon} workspaceColor={currentWorkspace?.color} /> diff --git a/src/components/assistant-ui/CreateQuizToolUI.tsx b/src/components/assistant-ui/CreateQuizToolUI.tsx index 064d2c88..98dd883d 100644 --- a/src/components/assistant-ui/CreateQuizToolUI.tsx +++ b/src/components/assistant-ui/CreateQuizToolUI.tsx @@ -253,9 +253,7 @@ function CreateQuizToolRenderer({ status={status as ToolStatus} moveItemToFolder={operations.moveItemToFolder} allItems={workspaceState?.items || []} - workspaceName={ - currentWorkspace?.name || workspaceState?.globalTitle || "Workspace" - } + workspaceName={currentWorkspace?.name || "Workspace"} workspaceIcon={currentWorkspace?.icon} workspaceColor={currentWorkspace?.color} /> diff --git a/src/components/modals/CardDetailModal.tsx b/src/components/modals/CardDetailModal.tsx index f2d4b058..8bd98748 100644 --- a/src/components/modals/CardDetailModal.tsx +++ b/src/components/modals/CardDetailModal.tsx @@ -11,8 +11,7 @@ interface CardDetailModalProps { onClose: () => void; onUpdateItem: (updates: Partial) => void; onUpdateItemData: (updater: (prev: ItemData) => ItemData) => void; - - onFlushPendingChanges?: (itemId: string) => void; + onUpdateItemUserStateData: (updater: (prev: ItemData) => ItemData) => void; } export function CardDetailModal({ @@ -21,8 +20,7 @@ export function CardDetailModal({ onClose, onUpdateItem, onUpdateItemData, - - onFlushPendingChanges, + onUpdateItemUserStateData, }: CardDetailModalProps) { // Handle escape key const handleEscape = useCallback( @@ -71,6 +69,7 @@ export function CardDetailModal({ onMaximize={() => useUIStore.getState().openWorkspaceItem(null)} onUpdateItem={onUpdateItem} onUpdateItemData={onUpdateItemData} + onUpdateItemUserStateData={onUpdateItemUserStateData} /> diff --git a/src/components/modals/PDFViewerModal.tsx b/src/components/modals/PDFViewerModal.tsx index d0dc22df..fbf2c09a 100644 --- a/src/components/modals/PDFViewerModal.tsx +++ b/src/components/modals/PDFViewerModal.tsx @@ -59,6 +59,7 @@ export function PDFViewerModal({ onUpdateItemData={(updater) => onUpdateItem({ data: updater(item.data) as Item["data"] }) } + onUpdateItemUserStateData={() => {}} /> diff --git a/src/components/workspace-canvas/CardRenderer.tsx b/src/components/workspace-canvas/CardRenderer.tsx index 70ebbb8b..5cae787c 100644 --- a/src/components/workspace-canvas/CardRenderer.tsx +++ b/src/components/workspace-canvas/CardRenderer.tsx @@ -4,9 +4,6 @@ import type { Item, ItemData, PdfData, - FlashcardData, - YouTubeData, - ImageData, DocumentData, } from "@/lib/workspace-state/types"; import { DocumentEditor } from "@/components/editor/DocumentEditor"; @@ -20,10 +17,11 @@ import { QuizContent } from "./QuizContent"; export function CardRenderer(props: { item: Item; onUpdateData: (updater: (prev: ItemData) => ItemData) => void; + onUpdateUserStateData?: (updater: (prev: ItemData) => ItemData) => void; layoutKey?: string | number; quizClassName?: string; // Optional padding/className for quiz when shown in modal }) { - const { item, onUpdateData, quizClassName } = props; + const { item, onUpdateData, onUpdateUserStateData, quizClassName } = props; if (item.type === "pdf") { const pdfData = item.data as PdfData; @@ -47,7 +45,7 @@ export function CardRenderer(props: { return ( ); diff --git a/src/components/workspace-canvas/ItemPanelContent.tsx b/src/components/workspace-canvas/ItemPanelContent.tsx index 8cca3add..e2d8b021 100644 --- a/src/components/workspace-canvas/ItemPanelContent.tsx +++ b/src/components/workspace-canvas/ItemPanelContent.tsx @@ -22,6 +22,7 @@ interface ItemPanelContentProps { onMaximize: () => void; onUpdateItem: (updates: Partial) => void; onUpdateItemData: (updater: (prev: ItemData) => ItemData) => void; + onUpdateItemUserStateData: (updater: (prev: ItemData) => ItemData) => void; } export function ItemPanelContent({ @@ -30,6 +31,7 @@ export function ItemPanelContent({ onMaximize, onUpdateItem, onUpdateItemData, + onUpdateItemUserStateData, }: ItemPanelContentProps) { const isChatExpanded = useUIStore((state) => state.isChatExpanded); const setIsChatExpanded = useUIStore((state) => state.setIsChatExpanded); @@ -161,7 +163,7 @@ export function ItemPanelContent({ ) : isYouTube ? ( ) : isWebsite ? ( )} diff --git a/src/components/workspace-canvas/OpenWorkspaceItemView.tsx b/src/components/workspace-canvas/OpenWorkspaceItemView.tsx index 34588f86..52e68a92 100644 --- a/src/components/workspace-canvas/OpenWorkspaceItemView.tsx +++ b/src/components/workspace-canvas/OpenWorkspaceItemView.tsx @@ -9,6 +9,10 @@ interface OpenWorkspaceItemViewProps { items: Item[]; onUpdateItem: (itemId: string, updates: Partial) => void; onUpdateItemData: (itemId: string, updater: (prev: ItemData) => ItemData) => void; + onUpdateItemUserStateData: ( + itemId: string, + updater: (prev: ItemData) => ItemData, + ) => void; onFlushPendingChanges: (itemId: string) => void; } @@ -20,6 +24,7 @@ export function OpenWorkspaceItemView({ items, onUpdateItem, onUpdateItemData, + onUpdateItemUserStateData, onFlushPendingChanges, }: OpenWorkspaceItemViewProps) { const openItems = useUIStore((state) => state.openItems); @@ -57,7 +62,9 @@ export function OpenWorkspaceItemView({ onClose={() => handleClose(currentItem.id)} onUpdateItem={(updates) => onUpdateItem(currentItem.id, updates)} onUpdateItemData={(updater) => onUpdateItemData(currentItem.id, updater)} - onFlushPendingChanges={onFlushPendingChanges} + onUpdateItemUserStateData={(updater) => + onUpdateItemUserStateData(currentItem.id, updater) + } /> ); } diff --git a/src/components/workspace-canvas/QuizContent.tsx b/src/components/workspace-canvas/QuizContent.tsx index da6693d8..e45a3879 100644 --- a/src/components/workspace-canvas/QuizContent.tsx +++ b/src/components/workspace-canvas/QuizContent.tsx @@ -11,12 +11,17 @@ import { useUIStore } from "@/lib/stores/ui-store"; interface QuizContentProps { item: Item; - onUpdateData: (updater: (prev: ItemData) => ItemData) => void; + onUpdateUserStateData: (updater: (prev: ItemData) => ItemData) => void; isScrollLocked?: boolean; className?: string; // Optional (e.g. padding when in modal) } -export function QuizContent({ item, onUpdateData, isScrollLocked = false, className }: QuizContentProps) { +export function QuizContent({ + item, + onUpdateUserStateData, + isScrollLocked = false, + className, +}: QuizContentProps) { const quizData = item.data as QuizData; const questions = quizData.questions || []; const aui = useAui(); @@ -78,7 +83,7 @@ export function QuizContent({ item, onUpdateData, isScrollLocked = false, classN setShowHint(false); // Clear completedAt since we're no longer complete - onUpdateData((prev) => { + onUpdateUserStateData((prev) => { const current = prev as QuizData; return { ...current, @@ -102,7 +107,7 @@ export function QuizContent({ item, onUpdateData, isScrollLocked = false, classN // Update refs for next comparison prevQuestionCountRef.current = currentCount; prevQuestionIdsRef.current = currentIds; - }, [questions, showResults, answeredQuestions.length, currentIndex, onUpdateData]); + }, [questions, showResults, answeredQuestions.length, currentIndex, onUpdateUserStateData]); // Check if current question was already answered const previousAnswer = useMemo(() => { @@ -123,7 +128,7 @@ export function QuizContent({ item, onUpdateData, isScrollLocked = false, classN // Persist session state const persistSession = useCallback((updates: Partial) => { - onUpdateData((prev) => { + onUpdateUserStateData((prev) => { const current = prev as QuizData; return { ...current, @@ -135,7 +140,7 @@ export function QuizContent({ item, onUpdateData, isScrollLocked = false, classN }, }; }); - }, [onUpdateData, currentIndex, answeredQuestions]); + }, [onUpdateUserStateData, currentIndex, answeredQuestions]); // Handle answer selection const handleSelectAnswer = (index: number) => { @@ -207,7 +212,7 @@ export function QuizContent({ item, onUpdateData, isScrollLocked = false, classN setShowHint(false); setAnsweredQuestions([]); setShowResults(false); - onUpdateData((prev) => { + onUpdateUserStateData((prev) => { const current = prev as QuizData; return { ...current, diff --git a/src/components/workspace-canvas/WorkspaceCard.tsx b/src/components/workspace-canvas/WorkspaceCard.tsx index 781ccd84..149e6e1e 100644 --- a/src/components/workspace-canvas/WorkspaceCard.tsx +++ b/src/components/workspace-canvas/WorkspaceCard.tsx @@ -30,6 +30,10 @@ interface WorkspaceCardProps { workspaceIcon?: string | null; workspaceColor?: string | null; onUpdateItem: (itemId: string, updates: Partial) => void; + onUpdateItemUserStateData: ( + itemId: string, + updater: (prev: Item["data"]) => Item["data"], + ) => void; onDeleteItem: (itemId: string) => void; onOpenModal: (itemId: string) => void; // NOTE: isSelected is now subscribed directly from the store to prevent @@ -48,6 +52,7 @@ function WorkspaceCard({ workspaceIcon, workspaceColor, onUpdateItem, + onUpdateItemUserStateData, onDeleteItem, onOpenModal, onMoveItem, @@ -494,10 +499,8 @@ function WorkspaceCard({ onSubtitleChange={handleSubtitleChange} onTitleFocus={handleTitleFocus} onTitleBlur={handleTitleBlur} - onUpdateItemData={(updater) => - onUpdateItem(item.id, { - data: updater(item.data) as Item["data"], - }) + onUpdateItemUserStateData={(updater) => + onUpdateItemUserStateData(item.id, updater) } /> diff --git a/src/components/workspace-canvas/WorkspaceCardContent.tsx b/src/components/workspace-canvas/WorkspaceCardContent.tsx index 8d6a6147..508bc3c0 100644 --- a/src/components/workspace-canvas/WorkspaceCardContent.tsx +++ b/src/components/workspace-canvas/WorkspaceCardContent.tsx @@ -37,7 +37,9 @@ interface WorkspaceCardContentProps { onSubtitleChange: (value: string) => void; onTitleFocus: () => void; onTitleBlur: () => void; - onUpdateItemData: (updater: (prev: Item["data"]) => Item["data"]) => void; + onUpdateItemUserStateData: ( + updater: (prev: Item["data"]) => Item["data"], + ) => void; } export function WorkspaceCardContent({ @@ -52,7 +54,7 @@ export function WorkspaceCardContent({ onSubtitleChange, onTitleFocus, onTitleBlur, - onUpdateItemData, + onUpdateItemUserStateData, }: WorkspaceCardContentProps) { const isCompactTextCard = (item.type === "pdf" || @@ -141,7 +143,7 @@ export function WorkspaceCardContent({
diff --git a/src/components/workspace-canvas/WorkspaceContent.tsx b/src/components/workspace-canvas/WorkspaceContent.tsx index 268246d6..991f462b 100644 --- a/src/components/workspace-canvas/WorkspaceContent.tsx +++ b/src/components/workspace-canvas/WorkspaceContent.tsx @@ -3,7 +3,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useWorkspaceStore } from "@/lib/stores/workspace-store"; import { Plus, Upload } from "lucide-react"; import { EmptyState } from "@/components/empty-state"; -import type { AgentState, Item, CardType } from "@/lib/workspace-state/types"; +import type { WorkspaceState, Item, CardType } from "@/lib/workspace-state/types"; import { filterItemsByFolder } from "@/lib/workspace-state/search"; import { useAutoScroll } from "@/hooks/ui/use-auto-scroll"; import { WorkspaceGrid } from "./WorkspaceGrid"; @@ -17,9 +17,13 @@ import { } from "@/lib/uploads/workspace-upload-config"; interface WorkspaceContentProps { - viewState: AgentState; + viewState: WorkspaceState; addItem: (type: CardType, name?: string, initialData?: Partial) => string; updateItem: (itemId: string, updates: Partial) => void; + updateItemUserStateData: ( + itemId: string, + updater: (prev: Item["data"]) => Item["data"], + ) => void; deleteItem: (itemId: string) => void; updateAllItems: (items: Item[]) => void; openWorkspaceItem: (itemId: string | null) => void; @@ -40,6 +44,7 @@ export default function WorkspaceContent({ viewState, addItem, updateItem, + updateItemUserStateData, deleteItem, updateAllItems, openWorkspaceItem, @@ -303,6 +308,7 @@ export default function WorkspaceContent({ onDragStart={handleDragStart} onDragStop={handleDragStop} onUpdateItem={handleUpdateItem} + onUpdateItemUserStateData={updateItemUserStateData} onDeleteItem={handleDeleteItem} onUpdateAllItems={handleUpdateAllItems} onOpenModal={handleOpenModal} diff --git a/src/components/workspace-canvas/WorkspaceGrid.tsx b/src/components/workspace-canvas/WorkspaceGrid.tsx index ddfdc8e1..72f97e99 100644 --- a/src/components/workspace-canvas/WorkspaceGrid.tsx +++ b/src/components/workspace-canvas/WorkspaceGrid.tsx @@ -35,6 +35,10 @@ interface WorkspaceGridProps { onDragStart: () => void; onDragStop: (layout: LayoutItem[]) => void; onUpdateItem: (itemId: string, updates: Partial) => void; + onUpdateItemUserStateData: ( + itemId: string, + updater: (prev: Item["data"]) => Item["data"], + ) => void; onDeleteItem: (itemId: string) => void; onUpdateAllItems: (items: Item[]) => void; onOpenModal: (itemId: string) => void; @@ -61,6 +65,7 @@ function WorkspaceGridComponent({ onDragStart, onDragStop, onUpdateItem, + onUpdateItemUserStateData, onDeleteItem, onUpdateAllItems, onOpenModal, @@ -674,6 +679,7 @@ function WorkspaceGridComponent({ workspaceIcon={workspaceIcon} workspaceColor={workspaceColor} onUpdateItem={handleUpdateItem} + onUpdateItemUserStateData={onUpdateItemUserStateData} onDeleteItem={handleDeleteItem} onOpenModal={handleOpenModal} onMoveItem={onMoveItem} @@ -685,6 +691,7 @@ function WorkspaceGridComponent({ allItems, displayItems, handleUpdateItem, + onUpdateItemUserStateData, handleDeleteItem, handleOpenModal, onMoveItem, diff --git a/src/components/workspace-canvas/WorkspaceSection.tsx b/src/components/workspace-canvas/WorkspaceSection.tsx index 0b6ff3cc..53a70d06 100644 --- a/src/components/workspace-canvas/WorkspaceSection.tsx +++ b/src/components/workspace-canvas/WorkspaceSection.tsx @@ -1,7 +1,7 @@ import React, { RefObject, useState, useCallback } from "react"; import { useSearchParams } from "next/navigation"; import { toast } from "sonner"; -import type { AgentState, Item, CardType } from "@/lib/workspace-state/types"; +import type { WorkspaceState, Item } from "@/lib/workspace-state/types"; import { DEFAULT_CARD_DIMENSIONS } from "@/lib/workspace-state/grid-layout-helpers"; import type { WorkspaceOperations } from "@/hooks/workspace/use-workspace-operations"; import WorkspaceContent from "./WorkspaceContent"; @@ -73,16 +73,10 @@ interface WorkspaceSectionProps { // Workspace state currentWorkspaceId: string | null; currentSlug: string | null; - state: AgentState; + state: WorkspaceState; - // Operations - addItem: (type: CardType, name?: string, initialData?: Partial) => string; - updateItem: (itemId: string, updates: Partial) => void; - deleteItem: (itemId: string) => void; - updateAllItems: (items: Item[]) => void; - - // Full operations object for advanced functionality - operations?: WorkspaceOperations; + // Workspace operations + operations: WorkspaceOperations; // Layout state isChatMaximized: boolean; @@ -116,10 +110,7 @@ export function WorkspaceSection({ currentWorkspaceId, currentSlug, state, - addItem, - updateItem, - deleteItem, - updateAllItems, + operations, isChatMaximized, isDesktop, isChatExpanded, @@ -130,7 +121,6 @@ export function WorkspaceSection({ workspaceTitle, workspaceIcon, workspaceColor, - operations, openItemView, }: WorkspaceSectionProps) { // Card selection state from UI store @@ -167,13 +157,10 @@ export function WorkspaceSection({ const { handleCreatedItems } = useReactiveNavigation(state); const handleYouTubeCreate = useCallback((url: string, name: string, thumbnail?: string) => { - if (addItem) { - addItem("youtube", name, { url, thumbnail }); - } - }, [addItem]); + operations.createItem("youtube", name, { url, thumbnail }); + }, [operations]); const handleWebsiteCreate = useCallback((url: string, name: string, favicon?: string) => { - if (!operations) return; operations.createItems([{ type: 'website', name, @@ -182,6 +169,13 @@ export function WorkspaceSection({ }]); }, [operations]); + const handleUpdateItemUserStateData = useCallback( + (itemId: string, updater: (prev: Item["data"]) => Item["data"]) => { + operations.updateItemUserStateData(itemId, updater); + }, + [operations], + ); + // Handle delete request (from button or keyboard) const handleDeleteRequest = React.useCallback(() => { if (selectedCardIds.size > 0) { @@ -255,7 +249,7 @@ export function WorkspaceSection({ // Filter out all selected items at once using Set.has() for O(1) lookup const remainingItems = state.items.filter(item => !selectedCardIds.has(item.id)); const deletedCount = selectedCardIds.size; - updateAllItems(remainingItems); + operations.updateAllItems(remainingItems); setShowDeleteDialog(false); clearCardSelection(); if (deletedCount > 0) { @@ -267,7 +261,7 @@ export function WorkspaceSection({ // Handle move selected items to folder const handleMoveSelected = () => { - if (!operations || selectedCardIdsArray.length === 0) { + if (selectedCardIdsArray.length === 0) { return; } setShowMoveDialog(true); @@ -275,7 +269,7 @@ export function WorkspaceSection({ // Handle move confirmation from dialog const handleMoveConfirm = (itemIds: string[], folderId: string | null) => { - if (!operations || itemIds.length === 0) { + if (itemIds.length === 0) { return; } operations.moveItemsToFolder(itemIds, folderId); @@ -287,7 +281,7 @@ export function WorkspaceSection({ // Handle creating a new folder from selected cards const handleCreateFolderFromSelection = () => { - if (!operations || selectedCardIdsArray.length === 0) { + if (selectedCardIdsArray.length === 0) { return; } @@ -315,7 +309,7 @@ export function WorkspaceSection({ // Handle file upload from workspace pickers/empty states const handlePDFUpload = useCallback(async (files: File[]) => { - if (!operations || !currentWorkspaceId) { + if (!currentWorkspaceId) { throw new Error('Workspace operations not available'); } @@ -396,8 +390,6 @@ export function WorkspaceSection({ openFilePicker(); }, [openFilePicker]); const handleAudioReady = useCallback(async (file: File) => { - if (!addItem) return; - const loadingToastId = toast.loading("Uploading audio..."); try { @@ -416,7 +408,7 @@ export function WorkspaceSection({ }); const title = `${dateStr} ${timeStr} Recording`; - const itemId = addItem("audio", title, { + const itemId = operations.createItem("audio", title, { fileUrl, filename: file.name, fileSize: file.size, @@ -446,7 +438,7 @@ export function WorkspaceSection({ error instanceof Error ? error.message : "Failed to upload audio", ); } - }, [addItem, currentWorkspaceId, handleCreatedItems]); + }, [currentWorkspaceId, handleCreatedItems, operations]); // Get search params for invite check const searchParams = useSearchParams(); @@ -495,19 +487,20 @@ export function WorkspaceSection({ () @@ -526,35 +519,33 @@ export function WorkspaceSection({ {/* Right-Click Context Menu */} - {addItem && ( - - {renderWorkspaceMenuItems({ - callbacks: { - onCreateDocument: () => { - if (addItem) { - const itemId = addItem("document"); - if (handleCreatedItems && itemId) { - handleCreatedItems([itemId]); - } - } - }, - onCreateFolder: () => { if (addItem) addItem("folder"); }, - onUpload: () => handleUploadMenuItemClick(), - onAudio: () => openAudioDialog(), - onYouTube: () => setShowYouTubeDialog(true), - onWebsite: () => setShowWebsiteDialog(true), - onFlashcards: () => setShowFlashcardsDialog(true), - onQuiz: () => setShowQuizDialog(true), + + {renderWorkspaceMenuItems({ + callbacks: { + onCreateDocument: () => { + const itemId = operations.createItem("document"); + if (handleCreatedItems && itemId) { + handleCreatedItems([itemId]); + } + }, + onCreateFolder: () => { + operations.createItem("folder"); }, - MenuItem: ContextMenuItem, - MenuSub: ContextMenuSub, - MenuSubTrigger: ContextMenuSubTrigger, - MenuSubContent: ContextMenuSubContent, - MenuLabel: ContextMenuLabel, - showUpload: !!(operations && currentWorkspaceId), - })} - - )} + onUpload: () => handleUploadMenuItemClick(), + onAudio: () => openAudioDialog(), + onYouTube: () => setShowYouTubeDialog(true), + onWebsite: () => setShowWebsiteDialog(true), + onFlashcards: () => setShowFlashcardsDialog(true), + onQuiz: () => setShowQuizDialog(true), + }, + MenuItem: ContextMenuItem, + MenuSub: ContextMenuSub, + MenuSubTrigger: ContextMenuSubTrigger, + MenuSubContent: ContextMenuSubContent, + MenuLabel: ContextMenuLabel, + showUpload: !!currentWorkspaceId, + })} + {/* Selection Action Bar - show when cards are selected */} {(state.items ?? []).length > 0 && !isChatMaximized && selectedCardIds.size > 0 && ( diff --git a/src/components/workspace-canvas/YouTubePanelContent.tsx b/src/components/workspace-canvas/YouTubePanelContent.tsx index e24924f7..93d1c2be 100644 --- a/src/components/workspace-canvas/YouTubePanelContent.tsx +++ b/src/components/workspace-canvas/YouTubePanelContent.tsx @@ -17,34 +17,47 @@ const PROGRESS_SAVE_INTERVAL_MS = 10000; // Save every 10s while playing interface YouTubePanelContentProps { item: Item; - onUpdateItemData: (updater: (prev: ItemData) => ItemData) => void; + onUpdateUserStateData: (updater: (prev: ItemData) => ItemData) => void; } export function YouTubePanelContent({ item, - onUpdateItemData: _onUpdateItemData, + onUpdateUserStateData, }: YouTubePanelContentProps) { const youtubeData = item.data as YouTubeData; const videoId = extractYouTubeVideoId(youtubeData.url); const playlistId = extractYouTubePlaylistId(youtubeData.url); const progressKey = getYouTubeProgressKey(videoId, playlistId); const stored = getYouTubeProgress(progressKey); - const startSeconds = stored?.progress ?? 0; - const savedPlaybackRate = stored?.playbackRate; + const startSeconds = youtubeData.progress ?? stored?.progress ?? 0; + const savedPlaybackRate = youtubeData.playbackRate ?? stored?.playbackRate; const saveStateRef = useRef< (opts: { seconds: number; playbackRate?: number }) => void >(undefined); const progressIntervalRef = useRef | null>(null); + const playerStateRef = useRef(null); const saveState = useCallback( (opts: { seconds: number; playbackRate?: number }) => { setYouTubeProgress(progressKey, opts.seconds, opts.playbackRate); + onUpdateUserStateData((prev) => { + const current = prev as YouTubeData; + return { + ...current, + ...(opts.seconds >= 0 ? { progress: Math.floor(opts.seconds) } : {}), + ...(opts.playbackRate != null && opts.playbackRate > 0 + ? { playbackRate: opts.playbackRate } + : {}), + }; + }); }, - [progressKey] + [onUpdateUserStateData, progressKey] ); - saveStateRef.current = saveState; + useEffect(() => { + saveStateRef.current = saveState; + }, [saveState]); const { containerRef, playerRef, isReady } = useYouTubePlayer({ videoId: videoId ?? null, @@ -60,7 +73,7 @@ export function YouTubePanelContent({ rel: 0, }, onStateChange: useCallback((state: YT.PlayerState) => { - const player = playerRef.current; + const player = playerStateRef.current; if (!player) return; // Clear any existing interval @@ -82,7 +95,7 @@ export function YouTubePanelContent({ // Save progress periodically while playing progressIntervalRef.current = setInterval(() => { try { - const currentPlayer = playerRef.current; + const currentPlayer = playerStateRef.current; if (currentPlayer?.getPlayerState?.() === YT.PlayerState.PLAYING) { saveStateRef.current?.({ seconds: currentPlayer.getCurrentTime(), @@ -97,6 +110,10 @@ export function YouTubePanelContent({ }, []), }); + useEffect(() => { + playerStateRef.current = playerRef.current; + }, [isReady, playerRef]); + // Apply saved playback rate and style iframe once ready useEffect(() => { if (!isReady || !playerRef.current) return; @@ -129,17 +146,17 @@ export function YouTubePanelContent({ } catch { // Player may have been destroyed } - }, [isReady, playerRef]); + }, [isReady, playerRef, savedPlaybackRate]); // Cleanup interval and save final state on unmount (e.g. when closing split view) useEffect(() => { return () => { + const player = playerStateRef.current; if (progressIntervalRef.current) { clearInterval(progressIntervalRef.current); progressIntervalRef.current = null; } try { - const player = playerRef.current; if (player?.getCurrentTime != null) { saveStateRef.current?.({ seconds: player.getCurrentTime(), diff --git a/src/components/workspace/CreateWorkspaceModal.tsx b/src/components/workspace/CreateWorkspaceModal.tsx index 3487d9f5..2b98e7bd 100644 --- a/src/components/workspace/CreateWorkspaceModal.tsx +++ b/src/components/workspace/CreateWorkspaceModal.tsx @@ -26,7 +26,7 @@ import { SWATCHES_COLOR_GROUPS, type CardColor } from "@/lib/workspace-state/col import { validateImportedJSON, generateImportPreview, type ValidationResult } from "@/lib/workspace/import-validation"; import { Textarea } from "@/components/ui/textarea"; import { Upload } from "lucide-react"; -import type { AgentState } from "@/lib/workspace-state/types"; +import type { WorkspaceState } from "@/lib/workspace-state/types"; interface Workspace { @@ -47,7 +47,7 @@ interface CreateWorkspaceModalProps { description?: string; icon?: string | null; color?: CardColor | null; - initialState?: AgentState; + initialState?: WorkspaceState; }; } diff --git a/src/components/workspace/SharedWorkspaceModal.tsx b/src/components/workspace/SharedWorkspaceModal.tsx index c6bd81e3..f52d9a10 100644 --- a/src/components/workspace/SharedWorkspaceModal.tsx +++ b/src/components/workspace/SharedWorkspaceModal.tsx @@ -14,7 +14,7 @@ import { IconRenderer } from "@/hooks/use-icon-picker"; import { SwatchesPicker, ColorResult } from "react-color"; import { SWATCHES_COLOR_GROUPS, type CardColor } from "@/lib/workspace-state/colors"; import { Skeleton } from "@/components/ui/skeleton"; -import type { AgentState } from "@/lib/workspace-state/types"; +import type { WorkspaceState } from "@/lib/workspace-state/types"; import { useCreateWorkspace } from "@/hooks/workspace/use-create-workspace"; interface SharedWorkspaceData { @@ -24,7 +24,7 @@ interface SharedWorkspaceData { description: string; icon: string | null; color: CardColor | null; - state: AgentState; + state: WorkspaceState; }; } diff --git a/src/components/workspace/SidebarCardList.tsx b/src/components/workspace/SidebarCardList.tsx index b776b63f..57b10733 100644 --- a/src/components/workspace/SidebarCardList.tsx +++ b/src/components/workspace/SidebarCardList.tsx @@ -779,7 +779,7 @@ function SidebarCardList() { }, [workspaces, currentWorkspaceId]); // Get workspace operations for delete functionality - const operations = useWorkspaceOperations(currentWorkspaceId, state || { items: [], workspaceId: currentWorkspaceId || '', globalTitle: '' }); + const operations = useWorkspaceOperations(currentWorkspaceId, state || { items: [], workspaceId: currentWorkspaceId || '' }); const handleDeleteItem = useCallback( async (itemId: string) => { diff --git a/src/components/workspace/VersionHistoryModal.tsx b/src/components/workspace/VersionHistoryModal.tsx index 07825a54..80fe7eb8 100644 --- a/src/components/workspace/VersionHistoryModal.tsx +++ b/src/components/workspace/VersionHistoryModal.tsx @@ -9,7 +9,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { Clock, Undo2, Camera, FolderPlus, Plus, Pencil, Trash2, Folder, FolderInput } from "lucide-react"; +import { Clock, Undo2, FolderPlus, Plus, Pencil, Trash2, Folder, FolderInput } from "lucide-react"; import { CgNotes } from "react-icons/cg"; import { BsFillGrid1X2Fill } from "react-icons/bs"; import type { WorkspaceEvent } from "@/lib/workspace/events"; @@ -36,8 +36,6 @@ function formatItemType(type: string): string { function getEventIcon(event: WorkspaceEvent) { const iconClass = "h-5 w-5 shrink-0"; switch (event.type) { - case 'WORKSPACE_CREATED': - return ; case 'ITEM_CREATED': return ; case 'ITEM_UPDATED': @@ -46,9 +44,6 @@ function getEventIcon(event: WorkspaceEvent) { return ; case 'ITEM_DELETED': return ; - case 'GLOBAL_TITLE_SET': - case 'GLOBAL_DESCRIPTION_SET': - return ; case 'BULK_ITEMS_UPDATED': { const p = event.payload as { deletedIds?: string[]; addedItems?: Item[]; items?: Item[]; previousItemCount?: number }; if (p.deletedIds?.length || (p.items && p.previousItemCount !== undefined && p.previousItemCount > p.items.length)) { @@ -58,8 +53,6 @@ function getEventIcon(event: WorkspaceEvent) { } case 'BULK_ITEMS_CREATED': return ; - case 'WORKSPACE_SNAPSHOT': - return ; case 'FOLDER_CREATED': return ; case 'FOLDER_UPDATED': @@ -78,10 +71,6 @@ function getEventIcon(event: WorkspaceEvent) { function getEventDescription(event: WorkspaceEvent, items?: any[]): string { switch (event.type) { - case 'WORKSPACE_CREATED': { - const title = event.payload.title?.trim(); - return title ? `Created workspace "${title}"` : 'Workspace created'; - } case 'ITEM_CREATED': return `Created ${formatItemType(event.payload.item.type)}: "${event.payload.item.name}"`; case 'ITEM_UPDATED': { @@ -113,10 +102,6 @@ function getEventDescription(event: WorkspaceEvent, items?: any[]): string { const itemTitle = event.payload.name ?? `item ${event.payload.id}`; return `Deleted "${itemTitle}"`; } - case 'GLOBAL_TITLE_SET': - return `Set title to "${event.payload.title}"`; - case 'GLOBAL_DESCRIPTION_SET': - return `Set description to "${event.payload.description}"`; case 'BULK_ITEMS_UPDATED': { const p = event.payload as { deletedIds?: string[]; addedItems?: Item[]; items?: Item[]; layoutUpdates?: unknown[]; previousItemCount?: number }; if (p.deletedIds?.length) { @@ -162,8 +147,6 @@ function getEventDescription(event: WorkspaceEvent, items?: any[]): string { } } } - case 'WORKSPACE_SNAPSHOT': - return 'Saved workspace snapshot'; case 'FOLDER_CREATED': { const name = event.payload.folder?.name; return name ? `Created folder "${name}"` : 'Created folder'; @@ -248,7 +231,7 @@ export function VersionHistoryContent({ const handleRevert = async (eventVersion: number) => { // Revert TO the state before this event (undo the event they clicked on) const targetVersion = eventVersion - 1; - if (targetVersion < 1) return; // WORKSPACE_CREATED is v1, can't go before it + if (targetVersion < 0) return; setIsReverting(true); try { await onRevertToVersion(targetVersion); @@ -304,15 +287,12 @@ export function VersionHistoryContent({ {reversedEvents.map((event) => { const eventVersion = event.version ?? 0; - const isWorkspaceCreated = event.type === 'WORKSPACE_CREATED'; - return (
@@ -339,7 +319,7 @@ export function VersionHistoryContent({
- {eventVersion > 1 && !isWorkspaceCreated && ( + {eventVersion > 0 && ( confirmingVersion === eventVersion ? (