Files
wekan/models/lib/softDelete.js
T
Lauri Ojansivu 46924985f8 Soft delete for lists: delete = mark, restorable + undoable (#1023).
Deleting a list no longer destroys it. The list and its cards are marked
(deletedAt/deletedBy/deleteBatchId), hidden from the board, recorded as a
reversible delete in history, and restorable via lists.restore or Ctrl+Z.
This is the first slice of "no permanent delete in ordinary use": physical
removal is limited to GDPR/account erasure and a Global-Admin purge gated by
a new Admin Panel / Features / Delete "Enable permanent delete" toggle
(off by default).

- models/lib/softDelete.js: pure notDeleted/softDeleteSet/restoreModifier/
  canPurge helpers (+ tests/softDelete.test.cjs, wired into test:unit:node).
  softDeleteSet omits falsy actor/batch and restore uses $unset so no null is
  ever written to a Date/String schema field.
- enablePermanentDelete feature flag (models/lib/featureFlags.js) + Settings
  schema field.
- deletedAt/deletedBy/deleteBatchId schema fields on Lists and Cards
  (optional, no null default -> absent means live).
- board/swimlane list render paths filter deletedAt: null.
- server/models/lists.js: shared softRemoveList() + lists.softRemove /
  lists.restore / lists.purge (purge gated on canPurge = isAdmin && flag);
  client delete handler and REST DELETE now soft-remove.
- userPositionHistory undo()/redo() handle actionType delete/restore via a
  batch-consistent _applyDeleted() helper.

Design + planned rollout (other entities, Recycle Bin, admin Delete category
table with GDPR data classes) in docs/Features/Undo/Undo.md.
2026-07-17 20:20:06 +03:00

66 lines
2.6 KiB
JavaScript

'use strict';
// Soft-delete core (delete = mark, never destroy). Design: docs/Features/Undo/Undo.md.
//
// Kept Meteor-free and CommonJS so these decisions are unit-testable
// (tests/softDelete.test.cjs) AND importable from server model code (Meteor's
// module system reads the named exports off `module.exports`). Every user-facing
// delete routes through here so the "no permanent delete in ordinary use" rule is
// enforced in one place; physical removal is only ever done by the flag-gated
// admin purge or by GDPR/account erasure.
// Add the "not soft-deleted" constraint to a Mongo/Minimongo selector.
// `{ deletedAt: null }` matches BOTH documents whose field is null and whose field
// is ABSENT (all pre-feature rows), so no migration/backfill is needed. Merges into
// an existing `$and` when present so we never clobber a sibling constraint.
function notDeleted(selector) {
const sel = selector || {};
if (sel && typeof sel === 'object' && Array.isArray(sel.$and)) {
return { ...sel, $and: [...sel.$and, { deletedAt: null }] };
}
return { ...sel, deletedAt: null };
}
// The `$set` applied when soft-deleting a document. `at` is passed in (never
// generated here) so the same batch of cascaded deletes shares one timestamp and
// the function stays pure/deterministic. Falsy userId/batchId are OMITTED rather
// than set to null: `deletedBy`/`deleteBatchId` are String schema fields and
// collection2 would reject a null value on update.
function softDeleteSet(userId, batchId, at) {
const set = { deletedAt: at };
if (userId) set.deletedBy = userId;
if (batchId) set.deleteBatchId = batchId;
return set;
}
// The full modifier applied when RESTORING (undo of a delete): drop all three
// bookkeeping fields, so a restored doc is indistinguishable from one that was
// never deleted. Pure `$unset` (rather than `$set deletedAt: null`) because setting
// an optional Date field to null trips SimpleSchema type validation; an absent
// field reads as "live" via the `{ deletedAt: null }` filter.
function restoreModifier() {
return {
$unset: { deletedAt: '', deletedBy: '', deleteBatchId: '' },
};
}
// Is a document soft-deleted?
function isDeleted(doc) {
return !!(doc && doc.deletedAt);
}
// May this user PHYSICALLY purge user content? Only a global admin, and only when
// the Admin Panel / Features / Delete toggle ("Enable permanent delete for Global
// Admin") is on. GDPR/account erasure is a SEPARATE path and does not consult this.
function canPurge(user, enablePermanentDelete) {
return !!(user && user.isAdmin) && !!enablePermanentDelete;
}
module.exports = {
notDeleted,
softDeleteSet,
restoreModifier,
isDeleted,
canPurge,
};