diff --git a/migrations/0036_deleted_records_cancel_pending_reviews.sql b/migrations/0036_deleted_records_cancel_pending_reviews.sql new file mode 100644 index 0000000..706e2f8 --- /dev/null +++ b/migrations/0036_deleted_records_cancel_pending_reviews.sql @@ -0,0 +1,79 @@ +PRAGMA foreign_keys = ON; + +-- Repair records deleted before this migration. A deletion is terminal for the +-- pending moderation lane: the review is cancelled, its pending revision is +-- rejected, and the record no longer advertises a pending revision. +INSERT INTO publication_review_transitions ( + id, review_id, decision, actor_type, actor_id, review_note, created_at +) +SELECT + 'record-delete-repair-' || pr.id, + pr.id, + 'cancelled', + rd.actor_type, + rd.actor_id, + 'Record deleted before review completed.', + rd.created_at +FROM publication_reviews pr +JOIN records r ON r.id = pr.record_id +JOIN record_deletion_transitions rd ON rd.record_id = r.id +WHERE r.deleted_at IS NOT NULL + AND pr.status = 'pending'; + +UPDATE record_revisions +SET state = 'rejected' +WHERE state = 'pending' + AND id IN ( + SELECT pending_revision_id + FROM records + WHERE deleted_at IS NOT NULL + AND pending_revision_id IS NOT NULL + ); + +UPDATE records +SET pending_revision_id = NULL +WHERE deleted_at IS NOT NULL + AND pending_revision_id IS NOT NULL; + +-- The original Slice 4 trigger made the record terminal but left an active +-- moderation review attached to a deleted record. Keep deletion append-only, +-- but close that moderation lane atomically through the existing review +-- transition mechanism before clearing the pending revision pointer. +DROP TRIGGER record_deletion_transitions_apply; + +CREATE TRIGGER record_deletion_transitions_apply +AFTER INSERT ON record_deletion_transitions +BEGIN + INSERT INTO publication_review_transitions ( + id, review_id, decision, actor_type, actor_id, review_note, created_at + ) + SELECT + NEW.id || '-review-' || pr.id, + pr.id, + 'cancelled', + NEW.actor_type, + NEW.actor_id, + 'Record deleted before review completed.', + NEW.created_at + FROM publication_reviews pr + WHERE pr.record_id = NEW.record_id + AND pr.status = 'pending'; + + UPDATE record_revisions + SET state = 'rejected' + WHERE id = ( + SELECT pending_revision_id + FROM records + WHERE id = NEW.record_id + ) + AND state = 'pending'; + + UPDATE records + SET lifecycle_state = 'deleted', + pending_revision_id = NULL, + deleted_at = NEW.created_at, + updated_at = NEW.created_at, + version = version + 1 + WHERE id = NEW.record_id + AND deleted_at IS NULL; +END; diff --git a/scripts/orbit-slice1-tests.ts b/scripts/orbit-slice1-tests.ts index b5f1656..b8e4cf9 100644 --- a/scripts/orbit-slice1-tests.ts +++ b/scripts/orbit-slice1-tests.ts @@ -1365,19 +1365,10 @@ let firstCredentialToken = ''; const deletedDirectPost = await postJson( `/v1/mcp/grants/${encodeURIComponent(recipientGrant.grantId)}/records/${encodeURIComponent(directPostBody.record.id)}/delete`, { reason: 'v0.5.1 regression cleanup' }, - mcpServiceHeaders('mcp-v051-delete'), - NOW + 17_130, + mcpServiceHeaders('mcp-v051-delete-direct-post'), + NOW + 17_133, ); assert.equal(deletedDirectPost.status, 200, await deletedDirectPost.clone().text()); - assert.equal((await deletedDirectPost.json() as { record: { status: string } }).record.status, 'deleted'); - const deletedRecordDetail = await postJson( - `/v1/mcp/grants/${encodeURIComponent(recipientGrant.grantId)}/agent/records/${encodeURIComponent(directPostBody.record.id)}`, - {}, - mcpServiceHeaders(), - NOW + 17_131, - ); - assert.equal(deletedRecordDetail.status, 200, await deletedRecordDetail.clone().text()); - assert.equal((await deletedRecordDetail.json() as { record: { lifecycleState: string } }).record.lifecycleState, 'deleted'); const initialUnread = await postJson( `/v1/mcp/grants/${encodeURIComponent(recipientGrant.grantId)}/direct-messages/unread-count`, @@ -1526,11 +1517,47 @@ let firstCredentialToken = ''; ); assert.equal(revokedRecipientGrant.status, 200, await revokedRecipientGrant.clone().text()); + const pendingDeleteReply = await postJson( + `/v1/mcp/grants/${encodeURIComponent(full.grantId)}/records/${encodeURIComponent(createdPostBody.record.id)}/replies`, + { bodyMarkdown: 'MCP v0.5.1 pending delete cleanup testi.', projectSlug: null, topicSlugs: [], mediaId: null }, + mcpServiceHeaders('mcp-v051-pending-delete-create'), + NOW + 32_100, + ); + assert.equal(pendingDeleteReply.status, 202, await pendingDeleteReply.clone().text()); + const pendingDeleteReplyBody = await pendingDeleteReply.json() as { record: { id: string } }; + + const deletedPendingReply = await postJson( + `/v1/mcp/grants/${encodeURIComponent(full.grantId)}/records/${encodeURIComponent(pendingDeleteReplyBody.record.id)}/delete`, + { reason: 'v0.5.1 regression cleanup' }, + mcpServiceHeaders('mcp-v051-delete'), + NOW + 32_101, + ); + assert.equal(deletedPendingReply.status, 200, await deletedPendingReply.clone().text()); + assert.equal((await deletedPendingReply.json() as { record: { status: string } }).record.status, 'deleted'); + const deletedRecordDetail = await postJson( + `/v1/mcp/grants/${encodeURIComponent(full.grantId)}/agent/records/${encodeURIComponent(pendingDeleteReplyBody.record.id)}`, + {}, + mcpServiceHeaders(), + NOW + 32_102, + ); + assert.equal(deletedRecordDetail.status, 200, await deletedRecordDetail.clone().text()); + const deletedRecordDetailBody = await deletedRecordDetail.json() as { + record: { + lifecycleState: string; + pendingRevision: unknown; + latestReview: { status: string; revision: { state: string } } | null; + }; + }; + assert.equal(deletedRecordDetailBody.record.lifecycleState, 'deleted'); + assert.equal(deletedRecordDetailBody.record.pendingRevision, null); + assert.equal(deletedRecordDetailBody.record.latestReview?.status, 'cancelled'); + assert.equal(deletedRecordDetailBody.record.latestReview?.revision.state, 'rejected'); + const revokedMcpWriteGrant = await postJson( `/v1/mcp/authorizations/${encodeURIComponent(full.grantId)}/revoke`, {}, authenticatedHeaders(sponsorCookies, true), - NOW + 17_002, + NOW + 32_103, ); assert.equal( revokedMcpWriteGrant.status, @@ -1542,7 +1569,7 @@ let firstCredentialToken = ''; `/v1/mcp/grants/${encodeURIComponent(full.grantId)}/records`, { ...mcpWritePostBody, bodyMarkdown: 'İptalden sonra yazılamaz.' }, mcpServiceHeaders('mcp-after-revoke'), - NOW + 17_003, + NOW + 32_104, ); assert.equal(afterRevoke.status, 401); const afterRevokeBody = await afterRevoke.json() as { error: { code: string } };