Files
lobehub__lobehub/packages/database/migrations/0155_document_likes.sql
Rdmclin2 b4c5ff79ff ✨ feat(database): add document_likes table (#18947)
* ✨ feat: add document_likes table for workspace document likes

Introduce the `document_likes` storage layer so workspace members can like
a document: one row per (document, user), unique on that pair, with
cascading FKs to documents / users / workspaces and paging-friendly
indexes. Ships the Drizzle schema, relations, and an idempotent migration
(0155); the model, router, and UI land in a follow-up PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HNfPCfAEiWyuvTbXMoqNTZ

* 🐛 fix: keep document_likes consistent across document scope transfers

DocumentModel.transferTo now rehomes like rows to the target workspace and
deletes them on a workspace→personal transfer, mirroring how comments and
mention rows are handled. Adds a regression test covering both directions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HNfPCfAEiWyuvTbXMoqNTZ

* 🐛 fix: count teammates' likes as foreign rows in document transfer guard

subtreeHasForeignRows now also detects document_likes rows owned by other
users, so a non-owner member cannot transfer a subtree that would rehome or
delete teammates' reactions. Covered by a regression test asserting the
caller's own like stays transferable while a teammate's blocks it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HNfPCfAEiWyuvTbXMoqNTZ

* ✨ feat: add DocumentLikeModel with transfer-serialized like creation

Ships the model half of the likes storage layer in the same PR as the
schema so the serialization contract stays reviewable in one place:
like() runs in a transaction that locks the document row FOR UPDATE
(mirroring DocumentCommentModel.create), so a like racing a concurrent
scope transfer blocks on the transfer's row lock and re-validates the
workspace after it commits instead of stamping the stale source
workspace_id — the transfer-side foreign-row guard and rehoming from the
previous commits therefore see a consistent row set. Includes the
DocumentLikeSummary types and model unit tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HNfPCfAEiWyuvTbXMoqNTZ

* 🐛 fix: recheck foreign rows inside the transfer transaction

The non-owner foreign-row guard used to run as a router preflight outside
the transfer transaction, so a teammate's like (or comment) committed
between the preflight and the transfer could still be rehomed or deleted.
transferTo now locks every subtree document row FOR UPDATE and, when
called with forbidForeignRows, re-runs the shared foreign-row predicate
inside the transaction, throwing DOCUMENT_TRANSFER_FOREIGN_ROWS which the
router maps to the existing OwnerOnly error. Concurrent like/comment
writers lock the same document row before stamping a workspace, so they
either commit before the recheck (and are seen) or block until the
transfer commits and then re-validate their scope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HNfPCfAEiWyuvTbXMoqNTZ

* 🐛 fix: serialize unlike and summary against document scope transfers

unlike() and summary() shared like()'s validate-then-act shape without its
serialization, so a transfer committing between the workspace validation
and the delete/reads let a stale source-workspace caller mutate or read
rehomed rows. All three operations now run through a shared lockDocument
helper inside a transaction — writes lock the document row FOR UPDATE,
summary takes FOR SHARE so the validation stays true for the duration of
its reads while concurrent transfers wait.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HNfPCfAEiWyuvTbXMoqNTZ

* 🐛 fix: route resource-manager transfers through the in-transaction guard

file.transferEntity was the remaining caller still running the foreign-row
check as a pre-transaction preflight, leaving the same TOCTOU window the
document router just closed. It now passes forbidForeignRows to
transferTo and translates DOCUMENT_TRANSFER_FOREIGN_ROWS to the OwnerOnly
error exactly like document.transferDocument.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HNfPCfAEiWyuvTbXMoqNTZ

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 17:21:36 +08:00

19 lines
1.8 KiB
SQL

CREATE TABLE IF NOT EXISTS "document_likes" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"document_id" text NOT NULL,
"user_id" text NOT NULL,
"workspace_id" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "document_likes" DROP CONSTRAINT IF EXISTS "document_likes_document_id_documents_id_fk";--> statement-breakpoint
ALTER TABLE "document_likes" ADD CONSTRAINT "document_likes_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "document_likes" DROP CONSTRAINT IF EXISTS "document_likes_user_id_users_id_fk";--> statement-breakpoint
ALTER TABLE "document_likes" ADD CONSTRAINT "document_likes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "document_likes" DROP CONSTRAINT IF EXISTS "document_likes_workspace_id_workspaces_id_fk";--> statement-breakpoint
ALTER TABLE "document_likes" ADD CONSTRAINT "document_likes_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "document_likes_document_id_user_id_unique" ON "document_likes" USING btree ("document_id","user_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "document_likes_document_id_created_at_idx" ON "document_likes" USING btree ("document_id","created_at");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "document_likes_user_id_idx" ON "document_likes" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "document_likes_workspace_id_idx" ON "document_likes" USING btree ("workspace_id");