-- Derived project discovery projection. Project/Folder remain authoritative; -- triggers prevent index drift through ordinary database mutations. CREATE EXTENSION IF NOT EXISTS pg_trgm; CREATE TYPE "FolderKind" AS ENUM ('REGULAR', 'SYSTEM_INBOX'); ALTER TABLE "Folder" ADD COLUMN "kind" "FolderKind" NOT NULL DEFAULT 'REGULAR'; DO $$ BEGIN IF EXISTS ( SELECT 1 FROM "Folder" WHERE "parentId" IS NULL AND "name" = 'Inbox' AND "archivedAt" IS NULL GROUP BY "organizationId" HAVING count(*) > 1 ) THEN RAISE EXCEPTION 'cannot identify system Inbox: organization has multiple active root Inbox folders'; END IF; END $$; UPDATE "Folder" f SET "kind" = 'SYSTEM_INBOX' WHERE f."parentId" IS NULL AND f."name" = 'Inbox' AND f."archivedAt" IS NULL; INSERT INTO "Folder" ("id", "organizationId", "parentId", "name", "kind", "sortKey", "createdAt", "updatedAt") SELECT o."id" || ':system-inbox', o."id", NULL, 'Inbox', 'SYSTEM_INBOX', '000000', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP FROM "Organization" o WHERE NOT EXISTS ( SELECT 1 FROM "Folder" f WHERE f."organizationId" = o."id" AND f."kind" = 'SYSTEM_INBOX' AND f."archivedAt" IS NULL ); CREATE UNIQUE INDEX "Folder_one_active_system_inbox_per_org" ON "Folder"("organizationId") WHERE "kind" = 'SYSTEM_INBOX' AND "archivedAt" IS NULL; CREATE OR REPLACE FUNCTION cph_protect_system_inbox() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF TG_OP = 'UPDATE' AND OLD."kind" = 'SYSTEM_INBOX' AND ( NEW."id" IS DISTINCT FROM OLD."id" OR NEW."organizationId" IS DISTINCT FROM OLD."organizationId" OR NEW."kind" IS DISTINCT FROM OLD."kind" OR NEW."name" IS DISTINCT FROM OLD."name" OR NEW."parentId" IS DISTINCT FROM OLD."parentId" OR NEW."archivedAt" IS DISTINCT FROM OLD."archivedAt" ) THEN RAISE EXCEPTION 'system Inbox identity cannot be changed'; END IF; IF TG_OP IN ('INSERT', 'UPDATE') AND NEW."kind" = 'SYSTEM_INBOX' AND ( NEW."name" <> 'Inbox' OR NEW."parentId" IS NOT NULL OR NEW."archivedAt" IS NOT NULL ) THEN RAISE EXCEPTION 'system Inbox must be an active root folder named Inbox'; END IF; IF TG_OP = 'DELETE' AND OLD."kind" = 'SYSTEM_INBOX' AND EXISTS ( SELECT 1 FROM "Organization" WHERE "id" = OLD."organizationId" ) THEN RAISE EXCEPTION 'system Inbox cannot be deleted while its organization exists'; END IF; RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END; END; $$; CREATE TRIGGER cph_protect_system_inbox BEFORE INSERT OR UPDATE OR DELETE ON "Folder" FOR EACH ROW EXECUTE FUNCTION cph_protect_system_inbox(); ALTER TABLE "Project" ADD COLUMN "code" TEXT; CREATE TABLE "ProjectSearchDocument" ( "projectId" TEXT NOT NULL, "organizationId" TEXT NOT NULL, "name" TEXT NOT NULL, "code" TEXT, "normalizedCode" TEXT NOT NULL, "normalizedName" TEXT NOT NULL, "breadcrumb" TEXT NOT NULL, "normalizedBreadcrumb" TEXT NOT NULL, "normalizedSearchText" TEXT NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "ProjectSearchDocument_pkey" PRIMARY KEY ("projectId") ); CREATE INDEX "ProjectSearchDocument_organizationId_idx" ON "ProjectSearchDocument"("organizationId"); CREATE INDEX "ProjectSearchDocument_normalizedName_trgm_idx" ON "ProjectSearchDocument" USING GIN ("normalizedName" gin_trgm_ops); CREATE INDEX "ProjectSearchDocument_normalizedCode_trgm_idx" ON "ProjectSearchDocument" USING GIN ("normalizedCode" gin_trgm_ops); CREATE INDEX "ProjectSearchDocument_normalizedBreadcrumb_trgm_idx" ON "ProjectSearchDocument" USING GIN ("normalizedBreadcrumb" gin_trgm_ops); CREATE INDEX "ProjectSearchDocument_normalizedSearchText_trgm_idx" ON "ProjectSearchDocument" USING GIN ("normalizedSearchText" gin_trgm_ops); ALTER TABLE "ProjectSearchDocument" ADD CONSTRAINT "ProjectSearchDocument_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE "ProjectSearchDocument" ADD CONSTRAINT "ProjectSearchDocument_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; CREATE OR REPLACE FUNCTION cph_project_search_normalize(value TEXT) RETURNS TEXT LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE AS $$ SELECT lower(regexp_replace(normalize(value, NFKC), '[[:space:]_.:/\\-]+', '', 'g')) $$; CREATE OR REPLACE FUNCTION cph_folder_breadcrumb(folder_id TEXT) RETURNS TEXT LANGUAGE sql STABLE PARALLEL SAFE AS $$ WITH RECURSIVE ancestors AS ( SELECT f."id", f."parentId", f."name", f."kind", 0 AS depth FROM "Folder" f WHERE f."id" = folder_id UNION ALL SELECT parent."id", parent."parentId", parent."name", parent."kind", child.depth + 1 FROM "Folder" parent JOIN ancestors child ON parent."id" = child."parentId" ) SELECT CASE WHEN count(*) = 1 AND bool_and("kind" = 'SYSTEM_INBOX') THEN '未分类' ELSE coalesce(string_agg("name", ' / ' ORDER BY depth DESC), '') END FROM ancestors $$; CREATE OR REPLACE FUNCTION cph_project_search_code(explicit_code TEXT, project_name TEXT) RETURNS TEXT LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$ SELECT CASE WHEN explicit_code IS NOT NULL AND btrim(explicit_code) <> '' THEN cph_project_search_normalize(explicit_code) ELSE coalesce(substring(cph_project_search_normalize(project_name) FROM '^[a-z]+[0-9]+'), '') END $$; CREATE OR REPLACE FUNCTION cph_refresh_project_search_document(target_project_id TEXT) RETURNS void LANGUAGE plpgsql AS $$ BEGIN INSERT INTO "ProjectSearchDocument" ( "projectId", "organizationId", "name", "code", "normalizedCode", "normalizedName", "breadcrumb", "normalizedBreadcrumb", "normalizedSearchText", "updatedAt" ) SELECT p."id", p."organizationId", p."name", p."code", cph_project_search_code(p."code", p."name"), cph_project_search_normalize(p."name"), cph_folder_breadcrumb(p."folderId"), cph_project_search_normalize(cph_folder_breadcrumb(p."folderId")), cph_project_search_normalize(cph_folder_breadcrumb(p."folderId") || ' ' || p."name"), CURRENT_TIMESTAMP FROM "Project" p WHERE p."id" = target_project_id ON CONFLICT ("projectId") DO UPDATE SET "organizationId" = EXCLUDED."organizationId", "name" = EXCLUDED."name", "code" = EXCLUDED."code", "normalizedCode" = EXCLUDED."normalizedCode", "normalizedName" = EXCLUDED."normalizedName", "breadcrumb" = EXCLUDED."breadcrumb", "normalizedBreadcrumb" = EXCLUDED."normalizedBreadcrumb", "normalizedSearchText" = EXCLUDED."normalizedSearchText", "updatedAt" = CURRENT_TIMESTAMP; END; $$; CREATE OR REPLACE FUNCTION cph_project_search_project_trigger() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN PERFORM cph_refresh_project_search_document(NEW."id"); RETURN NEW; END; $$; CREATE TRIGGER cph_project_search_project_changed AFTER INSERT OR UPDATE OF "name", "code", "folderId", "organizationId", "archivedAt" ON "Project" FOR EACH ROW EXECUTE FUNCTION cph_project_search_project_trigger(); CREATE OR REPLACE FUNCTION cph_project_search_folder_trigger() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE project_id TEXT; BEGIN FOR project_id IN WITH RECURSIVE descendants AS ( SELECT NEW."id" UNION ALL SELECT child."id" FROM "Folder" child JOIN descendants parent ON child."parentId" = parent."id" ) SELECT p."id" FROM "Project" p WHERE p."folderId" IN (SELECT "id" FROM descendants) LOOP PERFORM cph_refresh_project_search_document(project_id); END LOOP; RETURN NEW; END; $$; CREATE TRIGGER cph_project_search_folder_changed AFTER UPDATE OF "name", "kind", "parentId", "archivedAt" ON "Folder" FOR EACH ROW EXECUTE FUNCTION cph_project_search_folder_trigger(); SELECT cph_refresh_project_search_document("id") FROM "Project";