diff --git a/.changeset/purge-entry-term-assignments.md b/.changeset/purge-entry-term-assignments.md new file mode 100644 index 0000000000..fe9a900981 --- /dev/null +++ b/.changeset/purge-entry-term-assignments.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +Fixes permanently deleting an entry leaving its taxonomy term assignments in the database. The assignments are removed with the last translation of the entry and kept while another translation exists, including one in the trash. diff --git a/packages/core/src/api/handlers/content.ts b/packages/core/src/api/handlers/content.ts index aa7ad83f87..f4317b63a0 100644 --- a/packages/core/src/api/handlers/content.ts +++ b/packages/core/src/api/handlers/content.ts @@ -1389,6 +1389,7 @@ export async function handleContentPermanentDelete( // Wrap content delete + SEO/comment cleanup in a transaction const deleted = await withTransaction(db, async (trx) => { const trxRepo = new ContentRepository(trx); + const item = await trxRepo.findByIdIncludingTrashed(collection, resolvedId); const wasDeleted = await trxRepo.permanentDelete(collection, resolvedId); if (wasDeleted) { @@ -1402,6 +1403,21 @@ export async function handleContentPermanentDelete( const revisionRepo = new RevisionRepository(trx); await revisionRepo.deleteByEntry(collection, resolvedId); await new EntryLockRepository(trx).releaseEntry(collection, resolvedId); + // Term assignments are keyed by translation_group, so they belong to the + // group rather than to this row. They go only once no row of the group is + // left, trashed ones included, since a trashed row can still be restored. + if (item?.translationGroup) { + const groupSurvives = await trxRepo.hasTranslationsIncludingTrashed( + collection, + item.translationGroup, + ); + if (!groupSurvives) { + await new TaxonomyRepository(trx).clearEntryGroupTerms( + collection, + item.translationGroup, + ); + } + } } return wasDeleted; diff --git a/packages/core/src/database/repositories/content.ts b/packages/core/src/database/repositories/content.ts index 8d44967c40..a3696c2a34 100644 --- a/packages/core/src/database/repositories/content.ts +++ b/packages/core/src/database/repositories/content.ts @@ -1665,6 +1665,19 @@ export class ContentRepository { return result.rows.map((row) => this.mapRow(type, row)); } + /** Whether any row of `translationGroup` exists, trashed rows included. */ + async hasTranslationsIncludingTrashed(type: string, translationGroup: string): Promise { + const tableName = getTableName(type); + + const result = await sql>` + SELECT id FROM ${sql.ref(tableName)} + WHERE translation_group = ${translationGroup} + LIMIT 1 + `.execute(this.db); + + return result.rows.length > 0; + } + /** * Batch variant of {@link findTranslations}: every (non-deleted) locale * variant for any of `translationGroups`, in one `WHERE translation_group IN diff --git a/packages/core/src/database/repositories/taxonomy.ts b/packages/core/src/database/repositories/taxonomy.ts index d6c5bb9bd1..f6e6ec4e98 100644 --- a/packages/core/src/database/repositories/taxonomy.ts +++ b/packages/core/src/database/repositories/taxonomy.ts @@ -749,7 +749,15 @@ export class TaxonomyRepository { async clearEntryTerms(collection: string, entryId: string): Promise { const entryGroup = await this.resolveEntryTranslationGroup(collection, entryId); if (!entryGroup) return 0; + return this.clearEntryGroupTerms(collection, entryGroup); + } + /** + * Remove every term assignment held by an entry translation group. Takes the + * group rather than an entry id, so it still works after the group's last + * row has been deleted. + */ + async clearEntryGroupTerms(collection: string, entryGroup: string): Promise { const result = await this.db .deleteFrom("content_taxonomies") .where("collection", "=", collection) diff --git a/packages/core/tests/integration/content/permanent-delete-terms.test.ts b/packages/core/tests/integration/content/permanent-delete-terms.test.ts new file mode 100644 index 0000000000..e4732f9c62 --- /dev/null +++ b/packages/core/tests/integration/content/permanent-delete-terms.test.ts @@ -0,0 +1,91 @@ +/** + * Term assignments belong to the translation group, so permanent delete + * removes them only with the group's last row, trashed rows included. + */ + +import { afterEach, beforeEach, expect, it } from "vitest"; + +import { handleContentPermanentDelete } from "../../../src/api/handlers/content.js"; +import { ContentRepository } from "../../../src/database/repositories/content.js"; +import { TaxonomyRepository } from "../../../src/database/repositories/taxonomy.js"; +import { + describeEachDialect, + setupForDialectWithCollections, + teardownForDialect, + type DialectTestContext, +} from "../../utils/test-db.js"; + +describeEachDialect("handleContentPermanentDelete: term assignments", (dialect) => { + let ctx: DialectTestContext; + let content: ContentRepository; + let taxonomies: TaxonomyRepository; + let termId: string; + + beforeEach(async () => { + ctx = await setupForDialectWithCollections(dialect); + content = new ContentRepository(ctx.db); + taxonomies = new TaxonomyRepository(ctx.db); + const term = await taxonomies.create({ name: "tag", slug: "news", label: "News" }); + termId = term.id; + }); + + afterEach(async () => { + await teardownForDialect(ctx); + }); + + async function assignmentsFor(group: string) { + return ctx.db + .selectFrom("content_taxonomies") + .select("taxonomy_id") + .where("collection", "=", "post") + .where("entry_id", "=", group) + .execute(); + } + + async function trashAndPurge(id: string) { + expect(await content.delete("post", id)).toBe(true); + const result = await handleContentPermanentDelete(ctx.db, "post", id); + expect(result.success).toBe(true); + } + + async function createTranslatedPost() { + const en = await content.create({ type: "post", locale: "en", data: { title: "Hello" } }); + const de = await content.create({ + type: "post", + locale: "de", + translationOf: en.id, + data: { title: "Hallo" }, + }); + await taxonomies.attachToEntry("post", en.id, termId); + return { en, de, group: en.translationGroup! }; + } + + it("removes the assignments when the last translation is deleted", async () => { + const post = await content.create({ type: "post", data: { title: "Only" } }); + await taxonomies.attachToEntry("post", post.id, termId); + expect(await assignmentsFor(post.translationGroup!)).toHaveLength(1); + + await trashAndPurge(post.id); + + expect(await assignmentsFor(post.translationGroup!)).toEqual([]); + }); + + it("keeps the assignments while another translation exists", async () => { + const { en, de, group } = await createTranslatedPost(); + + await trashAndPurge(en.id); + expect(await assignmentsFor(group)).toHaveLength(1); + + await trashAndPurge(de.id); + expect(await assignmentsFor(group)).toEqual([]); + }); + + it("keeps the assignments while another translation is in the trash", async () => { + const { en, de, group } = await createTranslatedPost(); + expect(await content.delete("post", de.id)).toBe(true); + + await trashAndPurge(en.id); + + expect(await assignmentsFor(group)).toHaveLength(1); + }); +});