fix: Complete customizable document retention

Follow-up fixes to the retention feature.

The superseded CleanupDeletedDocumentsTask was still registered and ran
hourly, hard deleting documents 30 days after `deletedAt` regardless of the
team's configured period. Any retention longer than 30 days — including
infinite — had no effect.

The `destroyedAt` column was declared with a bare `@Column`. Decorator
metadata is disabled in the server build, so sequelize-typescript could not
infer its type and threw while loading the Document model.

Documents pending permanent deletion stayed readable and restorable through
`documents.info`, `documents.restore` and the MCP restore tool. They are now
treated as if they no longer exist.

Also:

- Add trash and data retention controls to workspace security settings, the
  only way to configure either was to call the API directly.
- Derive the retention tranches to process from the periods teams actually
  use, so a team holding a period that is no longer offered is not skipped.
- Bound the trash expiry task by `limit`, it previously ran an unbounded
  UPDATE across the whole documents table every hour.
- Match team preferences on JSON text rather than casting to int, so one bad
  value cannot fail the query for every other team in the batch.
- Derive the API validation schema from the same presets the UI offers.
- Send `ASC` rather than `asc` from the trash list, the sort direction was
  silently coerced back to descending.
- Move the retention badge in with the other document badges, pluralize it,
  and never show a negative day count.
- Drop the unused `Template.destroyedAt` computed, which returned a future
  date under a name meaning the opposite on the sibling model.
- Rename the migration to match its position in the sequence and the column
  it adds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U3kXbDxLMJesYLM5E4P6HQ
This commit is contained in:
Claude
2026-08-02 16:54:56 +00:00
parent 71fbb77505
commit 96a5b6a0f0
25 changed files with 717 additions and 177 deletions
+15 -18
View File
@@ -45,7 +45,6 @@ type Props = {
showPublished?: boolean;
showDraft?: boolean;
showLastViewed?: boolean;
showTemplate?: boolean;
};
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
@@ -85,7 +84,6 @@ function DocumentListItem(
showPublished,
showDraft = true,
showLastViewed = true,
showTemplate,
highlight,
context,
...rest
@@ -237,6 +235,21 @@ function DocumentListItem(
<Badge>{t("Draft")}</Badge>
</Tooltip>
)}
{document.destroysInDays !== undefined && (
<Tooltip
content={t(
"Will be permanently deleted in {{ count }} days unless restored",
{ count: document.destroysInDays }
)}
placement="top"
>
<Badge>
{t("{{ count }} days", {
count: document.destroysInDays,
})}
</Badge>
</Tooltip>
)}
{canStar && !isMobile && <StarButton document={document} />}
</Heading>
@@ -263,22 +276,6 @@ function DocumentListItem(
onClose={handleMenuClose}
/>
</Actions>
{document.deletedAt &&
document.destroysInDays !== undefined &&
document.destroysInDays >= 0 && (
<Tooltip
content={t("Permanently deletes in {{ days }} days", {
days: document.destroysInDays,
})}
placement="bottom"
>
<Badge>
{t("{{ days }} days", {
days: document.destroysInDays,
})}
</Badge>
</Tooltip>
)}
</DocumentLink>
</ContextMenu>
</ActionContextProvider>
+17 -6
View File
@@ -399,6 +399,11 @@ export default class Document extends ArchivableModel implements Searchable {
return this.title === "";
}
/**
* The point at which this document leaves the trash and can no longer be
* restored, based on the workspace trash retention period. Undefined when the
* document is not deleted, or when the workspace retains trash indefinitely.
*/
@computed
get willDestroyAt(): string | undefined {
if (!this.deletedAt) {
@@ -409,24 +414,30 @@ export default class Document extends ArchivableModel implements Searchable {
if (!team) {
return undefined;
}
const retentionDays = team.getPreference(
TeamPreference.TrashRetentionDays,
30
);
const retentionDays = team.getPreference(TeamPreference.TrashRetentionDays);
if (!retentionDays) {
return undefined;
}
return addDays(new Date(this.deletedAt), retentionDays).toString();
return addDays(new Date(this.deletedAt), retentionDays).toISOString();
}
/**
* The number of whole days remaining before this document leaves the trash.
* Never negative a document past its retention period is reported as zero
* days, as the cleanup task will remove it on its next run.
*/
@computed
get destroysInDays(): number | undefined {
if (!this.willDestroyAt) {
return undefined;
}
return differenceInDays(new Date(this.willDestroyAt), new Date());
return Math.max(
0,
differenceInDays(new Date(this.willDestroyAt), new Date())
);
}
@computed
-10
View File
@@ -1,4 +1,3 @@
import { addDays } from "date-fns";
import i18n from "i18next";
import { computed, observable } from "mobx";
import type { ProsemirrorData } from "@shared/types";
@@ -129,15 +128,6 @@ export default class Template extends ParanoidModel implements Searchable {
return !this.collectionId;
}
@computed
get destroyedAt(): string | undefined {
if (!this.deletedAt) {
return undefined;
}
return addDays(new Date(this.deletedAt), 30).toString();
}
get titleWithDefault(): string {
return this.title || i18n.t("Untitled");
}
+89
View File
@@ -6,6 +6,11 @@ import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import { errToString } from "@shared/utils/error";
import {
RetentionPeriodPresets,
isRetentionPeriodPreset,
} from "@shared/constants";
import type { RetentionPeriodPreset } from "@shared/types";
import { CommentingAccess, TeamPreference, EmailDisplay } from "@shared/types";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Heading from "~/components/Heading";
@@ -19,6 +24,17 @@ import useStores from "~/hooks/useStores";
import isCloudHosted from "~/utils/isCloudHosted";
import SettingRow from "./components/SettingRow";
/**
* Resolves a select value back to a supported retention period.
*
* @param value the raw value from the select input.
* @returns the retention period in days, or undefined if unsupported.
*/
function toRetentionPeriod(value: string): RetentionPeriodPreset | undefined {
const days = Number(value);
return isRetentionPeriodPreset(days) ? days : undefined;
}
function Security() {
const { dialogs } = useStores();
const team = useCurrentTeam();
@@ -72,6 +88,17 @@ function Security() {
[t]
);
const retentionOptions: Option[] = React.useMemo(
() =>
RetentionPeriodPresets.map((days) => ({
type: "item",
label:
days === 0 ? t("Forever") : t("{{ count }} days", { count: days }),
value: String(days),
})) satisfies Option[],
[t]
);
const commentingOptions: Option[] = React.useMemo(
() =>
[
@@ -194,6 +221,36 @@ function Security() {
[saveData, team.preferences]
);
const handleTrashRetentionChange = React.useCallback(
async (value: string) => {
const retentionDays = toRetentionPeriod(value);
if (retentionDays === undefined) {
return;
}
const preferences = {
...team.preferences,
[TeamPreference.TrashRetentionDays]: retentionDays,
};
await saveData({ preferences });
},
[saveData, team.preferences]
);
const handleDataRetentionChange = React.useCallback(
async (value: string) => {
const retentionDays = toRetentionPeriod(value);
if (retentionDays === undefined) {
return;
}
const preferences = {
...team.preferences,
[TeamPreference.DataRetentionDays]: retentionDays,
};
await saveData({ preferences });
},
[saveData, team.preferences]
);
const handleCommentingChange = React.useCallback(
async (commenting: string) => {
const preferences = {
@@ -412,6 +469,38 @@ function Security() {
/>
</SettingRow>
)}
<SettingRow
label={t("Trash retention")}
name={TeamPreference.TrashRetentionDays}
description={t(
"How long deleted documents remain in the trash, where they can still be restored"
)}
>
<InputSelect
value={String(team.getPreference(TeamPreference.TrashRetentionDays))}
options={retentionOptions}
onChange={handleTrashRetentionChange}
label={t("Trash retention")}
labelHidden
short
/>
</SettingRow>
<SettingRow
label={t("Data retention")}
name={TeamPreference.DataRetentionDays}
description={t(
"How long documents are retained after leaving the trash, before they are permanently erased"
)}
>
<InputSelect
value={String(team.getPreference(TeamPreference.DataRetentionDays))}
options={retentionOptions}
onChange={handleDataRetentionChange}
label={t("Data retention")}
labelHidden
short
/>
</SettingRow>
</Scene>
);
}
+1 -1
View File
@@ -35,7 +35,7 @@ function Trash() {
documents={documents.deleted}
fetch={documents.fetchDeleted}
options={{
direction: "asc",
direction: "ASC",
sort: "deletedAt",
}}
heading={<Subheading sticky>{t("Recently deleted")}</Subheading>}
+6
View File
@@ -24,6 +24,12 @@ export default async function loadDocument({
throw NotFoundError();
}
// Documents that have left the trash are pending permanent deletion, they are
// treated as if they no longer exist.
if (document.isDestroyed) {
throw NotFoundError();
}
if (document.deletedAt) {
// don't send data if user cannot restore deleted doc
if (user) {
+8 -1
View File
@@ -1,5 +1,5 @@
import { traceFunction } from "@server/logging/tracing";
import { ValidationError } from "@server/errors";
import { NotFoundError, ValidationError } from "@server/errors";
import { Collection, Revision } from "@server/models";
import type { Document } from "@server/models";
import { authorize } from "@server/policies";
@@ -23,6 +23,7 @@ type Props = {
* @param ctx - the API context, providing the acting user and transaction.
* @param props - the document and restore options.
* @returns the restored document.
* @throws NotFoundError if the document is pending permanent deletion.
* @throws ValidationError if the destination collection is not active.
*/
async function documentRestorer(
@@ -32,6 +33,12 @@ async function documentRestorer(
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
// Documents that have left the trash are pending permanent deletion, they are
// treated as if they no longer exist.
if (document.isDestroyed) {
throw NotFoundError();
}
const sourceCollectionId = document.collectionId;
const destCollectionId = collectionId ?? sourceCollectionId;
+17 -2
View File
@@ -420,10 +420,14 @@ class Document extends ArchivableModel<
@Column(DataType.DATE)
publishedAt: Date | null;
/** Whether the document has been destroyed (hard deleted), and if so when. */
/**
* When the document left the trash and entered the data retention period. Once
* set the document is no longer visible or restorable, and the row is removed
* entirely once the team's data retention period has elapsed.
*/
@AllowNull
@IsDate
@Column
@Column(DataType.DATE)
destroyedAt: Date | null;
@BeforeRestore
@@ -956,6 +960,17 @@ class Document extends ArchivableModel<
return !this.archivedAt && !this.deletedAt;
}
/**
* Whether this document has left the trash and is pending permanent deletion.
* Destroyed documents are not readable or restorable, they only remain in the
* database until the team's data retention period has elapsed.
*
* @returns boolean
*/
get isDestroyed(): boolean {
return !!this.destroyedAt;
}
/**
* Convenience method that returns whether this document is a draft.
*
+2
View File
@@ -192,6 +192,7 @@ allow(User, "restore", Document, (actor, document) =>
and(
!actor.isGuest,
!!document?.isDeleted,
!document?.isDestroyed,
isTeamModel(actor, document),
or(
includesMembership(document, [
@@ -208,6 +209,7 @@ allow(User, "permanentDelete", Document, (actor, document) =>
and(
!actor.isGuest,
!!document?.isDeleted,
!document?.isDestroyed,
isTeamModel(actor, document),
isTeamAdmin(actor, document)
)
@@ -1,45 +0,0 @@
import { subDays } from "date-fns";
import { Op } from "sequelize";
import documentPermanentDeleter from "@server/commands/documentPermanentDeleter";
import Logger from "@server/logging/Logger";
import { Document } from "@server/models";
import { TaskPriority } from "./base/BaseTask";
import { Minute } from "@shared/utils/time";
import type { Props } from "./base/CronTask";
import { CronTask, TaskInterval } from "./base/CronTask";
export default class CleanupDeletedDocumentsTask extends CronTask {
public async perform({ limit, partition }: Props) {
Logger.info(
"task",
`Permanently destroying upto ${limit} documents older than 30 days…`
);
const documents = await Document.unscoped().findAll({
attributes: ["id", "teamId", "deletedAt", "content", "state", "text"],
where: {
deletedAt: {
[Op.lt]: subDays(new Date(), 30),
},
...this.getPartitionWhereClause("id", partition),
},
paranoid: false,
limit,
});
const countDeletedDocument = await documentPermanentDeleter(documents);
Logger.info("task", `Destroyed ${countDeletedDocument} documents`);
}
public get options() {
return {
attempts: 1,
priority: TaskPriority.Background,
};
}
public get cron() {
return {
interval: TaskInterval.Hour,
partitionWindow: 15 * Minute.ms,
};
}
}
@@ -1,13 +1,16 @@
import { Op, Sequelize } from "sequelize";
import { Op } from "sequelize";
import { subDays } from "date-fns";
import documentPermanentDeleter from "@server/commands/documentPermanentDeleter";
import Logger from "@server/logging/Logger";
import { Document } from "@server/models";
import { TeamPreferenceDefaults } from "@shared/constants";
import type { RetentionPreference } from "@server/utils/retention";
import { teamRetentionPeriodFilter } from "@server/utils/retention";
import { TeamPreference } from "@shared/types";
import { BaseTask, TaskPriority } from "./base/BaseTask";
import type { PartitionInfo } from "./base/BaseTask";
const preference: RetentionPreference = TeamPreference.DataRetentionDays;
export type Props = {
/** The retention period in days to process in this tranche. */
retentionDays: number;
@@ -27,16 +30,17 @@ export default class CleanupPermanentlyDeletedDocumentsByRetentionTask extends B
return;
}
const defaultRetentionDays = TeamPreferenceDefaults[
TeamPreference.DataRetentionDays
] as number;
const isDefault = retentionDays === defaultRetentionDays;
Logger.debug(
"task",
`Permanently destroying upto ${limit} documents past ${retentionDays} day retention timeout…`
);
const team = teamRetentionPeriodFilter(
preference,
retentionDays,
"document"
);
const documents = await Document.scope([
"withDrafts",
"withoutState",
@@ -45,30 +49,10 @@ export default class CleanupPermanentlyDeletedDocumentsByRetentionTask extends B
destroyedAt: {
[Op.lt]: subDays(new Date(), retentionDays),
},
[Op.and]: [
Sequelize.literal(
isDefault
? `EXISTS (
SELECT 1 FROM teams
WHERE teams.id = "document"."teamId"
AND (
preferences->> :preference IS NULL
OR (preferences->> :preference)::int = :retentionDays
)
)`
: `EXISTS (
SELECT 1 FROM teams
WHERE teams.id = "document"."teamId"
AND (preferences->> :preference)::int = :retentionDays
)`
),
],
[Op.and]: [team.where],
...this.getPartitionWhereClause("id", partition),
},
replacements: {
preference: TeamPreference.DataRetentionDays,
retentionDays,
},
replacements: team.replacements,
paranoid: false,
limit,
});
@@ -1,5 +1,6 @@
import Logger from "@server/logging/Logger";
import { RetentionPeriodPresets } from "@shared/constants";
import { getRetentionPeriodsInUse } from "@server/utils/retention";
import { TeamPreference } from "@shared/types";
import { Minute } from "@shared/utils/time";
import { TaskPriority } from "./base/BaseTask";
import { CronTask, TaskInterval } from "./base/CronTask";
@@ -8,27 +9,27 @@ import CleanupPermanentlyDeletedDocumentsByRetentionTask from "./CleanupPermanen
export default class CleanupPermanentlyDeletedDocumentsTask extends CronTask {
/**
* Schedules a worker task for each retention period preset.
* Schedules a worker task for each data retention period in use.
*
* @param props Properties to be used by the task.
*/
public async perform(props: Props) {
const task = new CleanupPermanentlyDeletedDocumentsByRetentionTask();
const retentionPeriods = await getRetentionPeriodsInUse(
TeamPreference.DataRetentionDays
);
for (const days of RetentionPeriodPresets) {
if (days === 0) {
continue;
}
for (const retentionDays of retentionPeriods) {
await task.schedule({
limit: props.limit,
retentionDays: days,
retentionDays,
partition: props.partition,
});
}
Logger.debug(
"task",
`Scheduled ${RetentionPeriodPresets.length - 1} tranches for document cleanup`
`Scheduled ${retentionPeriods.length} tranches for document cleanup`
);
}
+5
View File
@@ -24,6 +24,11 @@ export default class EmptyTrashTask extends BaseTask<Props> {
deletedAt: {
[Op.ne]: null,
},
// documents already pending permanent deletion keep their original
// timestamp, so emptying the trash cannot extend their retention.
destroyedAt: {
[Op.is]: null,
},
},
paranoid: false,
}
@@ -1,11 +1,14 @@
import { Op } from "sequelize";
import { subDays } from "date-fns";
import { Document } from "@server/models";
import { sequelize } from "@server/storage/database";
import { buildDocument, buildTeam } from "@server/test/factories";
import { TeamPreferenceDefaults } from "@shared/constants";
import { TeamPreference } from "@shared/types";
import ExpireDocumentsInTrashByRetentionTask from "./ExpireDocumentsInTrashByRetentionTask";
const props = {
limit: 100,
partition: {
partitionIndex: 0,
partitionCount: 1,
@@ -113,4 +116,104 @@ describe("ExpireDocumentsInTrashByRetentionTask", () => {
});
expect(doc?.destroyedAt).toBeNull();
});
it("should not mark documents for a team with a custom period when processing the default tranche", async () => {
const team = await buildTeam();
team.setPreference(TeamPreference.TrashRetentionDays, 90);
await team.save();
await buildDocument({
teamId: team.id,
publishedAt: new Date(),
deletedAt: subDays(new Date(), defaultRetentionDays + 1),
});
const task = new ExpireDocumentsInTrashByRetentionTask();
await task.perform({ ...props, retentionDays: defaultRetentionDays });
const doc = await Document.unscoped().findOne({
where: { teamId: team.id },
paranoid: false,
});
expect(doc?.destroyedAt).toBeNull();
});
it("should never mark documents when retention is infinite", async () => {
const team = await buildTeam();
team.setPreference(TeamPreference.TrashRetentionDays, 0);
await team.save();
await buildDocument({
teamId: team.id,
publishedAt: new Date(),
deletedAt: subDays(new Date(), 365),
});
const task = new ExpireDocumentsInTrashByRetentionTask();
await task.perform({ ...props, retentionDays: 0 });
const doc = await Document.unscoped().findOne({
where: { teamId: team.id },
paranoid: false,
});
expect(doc?.destroyedAt).toBeNull();
});
it("should not mark more documents than the limit allows", async () => {
const team = await buildTeam();
await Promise.all(
[1, 2, 3].map(() =>
buildDocument({
teamId: team.id,
publishedAt: new Date(),
deletedAt: subDays(new Date(), defaultRetentionDays + 1),
})
)
);
const task = new ExpireDocumentsInTrashByRetentionTask();
await task.perform({
...props,
limit: 2,
retentionDays: defaultRetentionDays,
});
expect(
await Document.unscoped().count({
where: { teamId: team.id, destroyedAt: { [Op.ne]: null } },
paranoid: false,
})
).toEqual(2);
});
it("should tolerate a team holding an unexpected retention value", async () => {
// Preferences are free-form JSON, a value that cannot be read as a number
// must not fail the query for every other team in the batch.
const team = await buildTeam();
await sequelize.query(
`UPDATE teams SET preferences = jsonb_set(coalesce(preferences, '{}'::jsonb), :path, '"not-a-number"') WHERE id = :id`,
{
replacements: {
path: `{${TeamPreference.TrashRetentionDays}}`,
id: team.id,
},
}
);
const other = await buildTeam();
await buildDocument({
teamId: other.id,
publishedAt: new Date(),
deletedAt: subDays(new Date(), defaultRetentionDays + 1),
});
const task = new ExpireDocumentsInTrashByRetentionTask();
await task.perform({ ...props, retentionDays: defaultRetentionDays });
const doc = await Document.unscoped().findOne({
where: { teamId: other.id },
paranoid: false,
});
expect(doc?.destroyedAt).not.toBeNull();
});
});
@@ -1,15 +1,20 @@
import { Op, Sequelize } from "sequelize";
import { Op } from "sequelize";
import { subDays } from "date-fns";
import Logger from "@server/logging/Logger";
import { Document } from "@server/models";
import { TeamPreferenceDefaults } from "@shared/constants";
import type { RetentionPreference } from "@server/utils/retention";
import { teamRetentionPeriodFilter } from "@server/utils/retention";
import { TeamPreference } from "@shared/types";
import type { PartitionInfo } from "./base/BaseTask";
import { BaseTask, TaskPriority } from "./base/BaseTask";
const preference: RetentionPreference = TeamPreference.TrashRetentionDays;
export type Props = {
/** The trash retention period in days to process in this tranche. */
retentionDays: number;
/** The maximum number of documents to expire in this task. */
limit: number;
/** Partition information for distributing work. */
partition?: PartitionInfo;
};
@@ -18,65 +23,64 @@ export type Props = {
* A task that marks documents in the trash for permanent deletion based on a retention period.
*/
export default class ExpireDocumentsInTrashByRetentionTask extends BaseTask<Props> {
public async perform(props: Props) {
const { partition, retentionDays } = props;
public async perform({ limit, partition, retentionDays }: Props) {
// Infinite retention means documents are never expired from trash.
if (retentionDays === 0) {
return;
}
const defaultTrashRetentionDays = TeamPreferenceDefaults[
TeamPreference.TrashRetentionDays
] as number;
const isDefault = retentionDays === defaultTrashRetentionDays;
Logger.debug(
"task",
`Marking documents past ${retentionDays} day trash timeout as pending permanent deletion…`
`Marking upto ${limit} documents past ${retentionDays} day trash timeout as pending permanent deletion…`
);
// Mark documents that have been in the trash for longer than the retention period.
// This moves them from "Trash" to "Pending Permanent Deletion" (Retention phase).
const team = teamRetentionPeriodFilter(
preference,
retentionDays,
"document"
);
// The batch is selected before updating as Postgres does not support a limit
// on UPDATE, and an unbounded update would hold locks across the entire table.
const documents = await Document.unscoped().findAll({
attributes: ["id"],
where: {
deletedAt: {
[Op.lt]: subDays(new Date(), retentionDays),
},
destroyedAt: {
[Op.is]: null,
},
[Op.and]: [team.where],
...this.getPartitionWhereClause("id", partition),
},
replacements: team.replacements,
paranoid: false,
limit,
});
if (!documents.length) {
return;
}
// Documents that have been in the trash for longer than the retention period
// move from the trash to pending permanent deletion.
const [count] = await Document.unscoped().update(
{
destroyedAt: new Date(),
},
{
where: {
deletedAt: {
[Op.lt]: subDays(new Date(), retentionDays),
},
id: documents.map((document) => document.id),
destroyedAt: {
[Op.is]: null,
},
[Op.and]: [
Sequelize.literal(
isDefault
? `EXISTS (
SELECT 1 FROM teams
WHERE teams.id = "documents"."teamId"
AND (
preferences->>'${TeamPreference.TrashRetentionDays}' IS NULL
OR (preferences->>'${TeamPreference.TrashRetentionDays}')::int = ${defaultTrashRetentionDays}
)
)`
: `EXISTS (
SELECT 1 FROM teams
WHERE teams.id = "documents"."teamId"
AND (preferences->>'${TeamPreference.TrashRetentionDays}')::int = ${retentionDays}
)`
),
],
...this.getPartitionWhereClause("id", partition),
},
paranoid: false,
}
);
if (count > 0) {
Logger.info("task", `Marked ${count} documents for permanent deletion`);
}
Logger.info("task", `Marked ${count} documents for permanent deletion`);
}
public get options() {
@@ -43,6 +43,7 @@ describe("ExpireDocumentsInTrashTask", () => {
// Verify that the custom retention task was scheduled.
expect(scheduleSpy).toHaveBeenCalledWith(
expect.objectContaining({
limit: props.limit,
retentionDays: customDays,
partition: props.partition,
})
@@ -69,4 +70,25 @@ describe("ExpireDocumentsInTrashTask", () => {
scheduleSpy.mockRestore();
});
it("should not schedule a worker for infinite retention", async () => {
const scheduleSpy = vi.spyOn(
ExpireDocumentsInTrashByRetentionTask.prototype,
"schedule"
);
const team = await buildTeam();
team.setPreference(TeamPreference.TrashRetentionDays, 0);
await team.save();
const task = new ExpireDocumentsInTrashTask();
await task.perform(props);
const scheduled = scheduleSpy.mock.calls.map(
([{ retentionDays }]) => retentionDays
);
expect(scheduled).not.toContain(0);
scheduleSpy.mockRestore();
});
});
@@ -1,5 +1,6 @@
import Logger from "@server/logging/Logger";
import { RetentionPeriodPresets } from "@shared/constants";
import { getRetentionPeriodsInUse } from "@server/utils/retention";
import { TeamPreference } from "@shared/types";
import { Minute } from "@shared/utils/time";
import { TaskPriority } from "./base/BaseTask";
import { CronTask, TaskInterval } from "./base/CronTask";
@@ -8,26 +9,27 @@ import ExpireDocumentsInTrashByRetentionTask from "./ExpireDocumentsInTrashByRet
export default class ExpireDocumentsInTrashTask extends CronTask {
/**
* Schedules a worker task for each retention period preset.
* Schedules a worker task for each trash retention period in use.
*
* @param props Properties to be used by the task.
*/
public async perform(props: Props) {
const task = new ExpireDocumentsInTrashByRetentionTask();
const retentionPeriods = await getRetentionPeriodsInUse(
TeamPreference.TrashRetentionDays
);
for (const days of RetentionPeriodPresets) {
if (days === 0) {
continue;
}
for (const retentionDays of retentionPeriods) {
await task.schedule({
retentionDays: days,
limit: props.limit,
retentionDays,
partition: props.partition,
});
}
Logger.debug(
"task",
`Scheduled ${RetentionPeriodPresets.length - 1} tranches for marking documents for permanent deletion`
`Scheduled ${retentionPeriods.length} tranches for marking documents for permanent deletion`
);
}
@@ -179,6 +179,22 @@ describe("#documents.info", () => {
expect(body.data.id).toEqual(document.id);
});
it("should not return a document pending permanent deletion", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
deletedAt: new Date(),
destroyedAt: new Date(),
});
const res = await server.post("/api/documents.info", user, {
body: {
id: document.id,
},
});
expect(res.status).toEqual(404);
});
it("should return published document for urlId", async () => {
const user = await buildUser();
const document = await buildDocument({
@@ -2458,6 +2474,20 @@ describe("#documents.archived", () => {
});
describe("#documents.deleted", () => {
it("should not return documents pending permanent deletion", async () => {
const user = await buildUser();
await buildDocument({
userId: user.id,
teamId: user.teamId,
deletedAt: new Date(),
destroyedAt: new Date(),
});
const res = await server.post("/api/documents.deleted", user);
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(0);
});
it("should return deleted documents", async () => {
const user = await buildUser();
const document = await buildDocument({
@@ -2870,6 +2900,45 @@ describe("#documents.move", () => {
});
describe("#documents.restore", () => {
it("should not restore a document pending permanent deletion", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
deletedAt: new Date(),
destroyedAt: new Date(),
});
const res = await server.post("/api/documents.restore", user, {
body: { id: document.id },
});
expect(res.status).toEqual(404);
const stillDestroyed = await Document.findByPk(document.id, {
paranoid: false,
});
expect(stillDestroyed?.deletedAt).not.toBe(null);
expect(stillDestroyed?.destroyedAt).not.toBe(null);
});
it("should clear destroyedAt when a document is restored", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
await withAPIContext(user, (ctx) => document.destroyWithCtx(ctx));
const res = await server.post("/api/documents.restore", user, {
body: { id: document.id },
});
expect(res.status).toEqual(200);
const restored = await Document.findByPk(document.id, { paranoid: false });
expect(restored?.deletedAt).toBe(null);
expect(restored?.destroyedAt).toBe(null);
});
it("should fail if attempting to restore document to an archived collection", async () => {
const user = await buildUser();
const collection = await buildCollection({
@@ -4502,6 +4571,40 @@ describe("#documents.delete", () => {
expect(body.success).toEqual(true);
});
it("should mark a permanently deleted document as destroyed", async () => {
const user = await buildAdmin();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
await server.post("/api/documents.delete", user, {
body: { id: document.id },
});
await server.post("/api/documents.delete", user, {
body: { id: document.id, permanent: true },
});
const destroyed = await Document.findByPk(document.id, {
paranoid: false,
});
expect(destroyed?.destroyedAt).not.toBe(null);
});
it("should not allow permanently deleting a document twice", async () => {
const user = await buildAdmin();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
deletedAt: new Date(),
destroyedAt: new Date(),
});
const res = await server.post("/api/documents.delete", user, {
body: { id: document.id, permanent: true },
});
expect(res.status).toEqual(403);
});
it("should not allow permanently deleting a document as non-admin", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
+4 -9
View File
@@ -5,19 +5,14 @@ import {
TOCPosition,
UserRole,
} from "@shared/types";
import { isRetentionPeriodPreset } from "@shared/constants";
import type { RetentionPeriodPreset } from "@shared/types";
import { TeamValidation } from "@shared/validations";
import { BaseSchema } from "@server/routes/api/schema";
// Derived from the presets offered in the UI so that the two cannot diverge.
const retentionDaysSchema = z
.union([
z.literal(0),
z.literal(7),
z.literal(14),
z.literal(30),
z.literal(90),
z.literal(180),
z.literal(365),
])
.custom<RetentionPeriodPreset>(isRetentionPeriodPreset)
.optional();
export const TeamsUpdateSchema = BaseSchema.extend({
+44
View File
@@ -7,6 +7,7 @@ import {
buildUser,
} from "@server/test/factories";
import { getTestServer, setSelfHosted } from "@server/test/support";
import { TeamPreference } from "@shared/types";
const server = getTestServer();
@@ -53,6 +54,49 @@ describe("#team.update", () => {
expect(body.data.name).toEqual(name);
});
it("should update retention preferences", async () => {
const admin = await buildAdmin();
const res = await server.post("/api/team.update", admin, {
body: {
preferences: {
[TeamPreference.TrashRetentionDays]: 90,
[TeamPreference.DataRetentionDays]: 7,
},
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.preferences[TeamPreference.TrashRetentionDays]).toEqual(
90
);
expect(body.data.preferences[TeamPreference.DataRetentionDays]).toEqual(7);
});
it("should reject an unsupported retention period", async () => {
const admin = await buildAdmin();
const res = await server.post("/api/team.update", admin, {
body: {
preferences: {
[TeamPreference.TrashRetentionDays]: 45,
},
},
});
expect(res.status).toEqual(400);
});
it("should not allow a member to update retention preferences", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const res = await server.post("/api/team.update", user, {
body: {
preferences: {
[TeamPreference.TrashRetentionDays]: 7,
},
},
});
expect(res.status).toEqual(403);
});
it("should add avatar", async () => {
const team = await buildTeam();
const admin = await buildAdmin({ teamId: team.id });
+78
View File
@@ -0,0 +1,78 @@
import { sequelize } from "@server/storage/database";
import { buildTeam } from "@server/test/factories";
import { TeamPreferenceDefaults } from "@shared/constants";
import { TeamPreference } from "@shared/types";
import {
getDefaultRetentionPeriod,
getRetentionPeriodsInUse,
} from "./retention";
const defaultRetentionDays = TeamPreferenceDefaults[
TeamPreference.TrashRetentionDays
] as number;
describe("getDefaultRetentionPeriod", () => {
it("should return the configured default", () => {
expect(
getDefaultRetentionPeriod(TeamPreference.TrashRetentionDays)
).toEqual(defaultRetentionDays);
});
});
describe("getRetentionPeriodsInUse", () => {
it("should always include the default period", async () => {
const periods = await getRetentionPeriodsInUse(
TeamPreference.TrashRetentionDays
);
expect(periods).toContain(defaultRetentionDays);
});
it("should include a period configured by a team", async () => {
const team = await buildTeam();
team.setPreference(TeamPreference.TrashRetentionDays, 365);
await team.save();
const periods = await getRetentionPeriodsInUse(
TeamPreference.TrashRetentionDays
);
expect(periods).toContain(365);
});
it("should exclude infinite retention", async () => {
const team = await buildTeam();
team.setPreference(TeamPreference.TrashRetentionDays, 0);
await team.save();
const periods = await getRetentionPeriodsInUse(
TeamPreference.TrashRetentionDays
);
expect(periods).not.toContain(0);
});
it("should ignore values that are not a whole number of days", async () => {
const team = await buildTeam();
await sequelize.query(
`UPDATE teams SET preferences = jsonb_set(coalesce(preferences, '{}'::jsonb), :path, '"nonsense"') WHERE id = :id`,
{
replacements: {
path: `{${TeamPreference.TrashRetentionDays}}`,
id: team.id,
},
}
);
const periods = await getRetentionPeriodsInUse(
TeamPreference.TrashRetentionDays
);
expect(periods.every((days) => Number.isInteger(days) && days > 0)).toBe(
true
);
});
it("should return periods in ascending order", async () => {
const periods = await getRetentionPeriodsInUse(
TeamPreference.TrashRetentionDays
);
expect([...periods].sort((a, b) => a - b)).toEqual(periods);
});
});
+110
View File
@@ -0,0 +1,110 @@
import type { Utils } from "sequelize";
import { QueryTypes, Sequelize } from "sequelize";
import { TeamPreferenceDefaults } from "@shared/constants";
import type { RetentionPeriodPreset, TeamPreference } from "@shared/types";
import { sequelize } from "@server/storage/database";
/** Team preferences that express a retention period, in days. */
export type RetentionPreference =
| TeamPreference.TrashRetentionDays
| TeamPreference.DataRetentionDays;
/**
* The SQL alias of the table holding the `teamId` column being matched against.
* Sequelize aliases a table by its model name when selecting, and by its table
* name when updating.
*/
type TeamOwnedAlias = "document" | "documents";
/**
* The retention period applied to teams that have not set an explicit
* preference. Falls back to infinite retention, so a missing default can never
* cause data to be deleted sooner than intended.
*
* @param preference the retention preference to read.
* @returns the default retention period in days.
*/
export function getDefaultRetentionPeriod(
preference: RetentionPreference
): RetentionPeriodPreset {
return TeamPreferenceDefaults[preference] ?? 0;
}
/**
* Returns every retention period, in days, that is currently in use across all
* teams for the given preference. The default period is always included so that
* teams without an explicit preference are covered, and periods of zero
* (infinite retention) are omitted as they never require processing.
*
* Deriving the periods from the data rather than from a fixed list of presets
* ensures teams are never silently skipped if the presets offered in the UI
* change, or if a team holds a value that is no longer offered.
*
* @param preference the retention preference to read.
* @returns a sorted list of retention periods in days.
*/
export async function getRetentionPeriodsInUse(
preference: RetentionPreference
): Promise<number[]> {
const rows = await sequelize.query<{ days: string | null }>(
`SELECT DISTINCT preferences->>:preference AS days FROM teams WHERE preferences->>:preference IS NOT NULL`,
{
type: QueryTypes.SELECT,
replacements: { preference },
}
);
const periods = new Set<number>([getDefaultRetentionPeriod(preference)]);
for (const row of rows) {
const days = Number(row.days);
// Preferences are free-form JSON, values that are not a positive whole
// number of days cannot be acted on and are ignored.
if (Number.isInteger(days) && days > 0) {
periods.add(days);
}
}
periods.delete(0);
return [...periods].sort((a, b) => a - b);
}
/**
* Builds a filter matching rows that belong to a team configured with the given
* retention period. Teams that have not set the preference are matched by the
* default period.
*
* Comparison is performed on the raw JSON text rather than casting to an integer
* so that an unexpected value stored against the preference cannot fail the
* query for every other team in the same batch.
*
* @param preference the retention preference to match on.
* @param retentionDays the retention period in days.
* @param alias the SQL alias of the table holding the `teamId` column.
* @returns the where clause literal and the replacements it requires.
*/
export function teamRetentionPeriodFilter(
preference: RetentionPreference,
retentionDays: number,
alias: TeamOwnedAlias
): { where: Utils.Literal; replacements: Record<string, string> } {
const isDefault = retentionDays === getDefaultRetentionPeriod(preference);
const matchesPreference = isDefault
? `(preferences->>:preference IS NULL OR preferences->>:preference = :retentionDaysText)`
: `preferences->>:preference = :retentionDaysText`;
return {
where: Sequelize.literal(
`EXISTS (
SELECT 1 FROM teams
WHERE teams.id = "${alias}"."teamId"
AND ${matchesPreference}
)`
),
replacements: {
preference,
retentionDaysText: String(retentionDays),
},
};
}
+12
View File
@@ -17,6 +17,18 @@ export const RetentionPeriodPresets: readonly RetentionPeriodPreset[] = [
0, 7, 14, 30, 90, 180, 365,
];
/**
* Whether the given value is a retention period that can be configured.
*
* @param value the value to check.
* @returns true if the value is a supported retention period.
*/
export function isRetentionPeriodPreset(
value: unknown
): value is RetentionPeriodPreset {
return RetentionPeriodPresets.some((days) => days === value);
}
export const MAX_AVATAR_DISPLAY = 6;
/** Preset colors offered when choosing an icon color. */
+7 -2
View File
@@ -324,8 +324,8 @@
"New": "New",
"Only visible to you": "Only visible to you",
"Draft": "Draft",
"Permanently deletes in {{ days }} days": "Permanently deletes in {{ days }} days",
"{{ days }} days": "{{ days }} days",
"Will be permanently deleted in {{ count }} days unless restored": "Will be permanently deleted in {{ count }} day unless restored",
"Will be permanently deleted in {{ count }} days unless restored_plural": "Will be permanently deleted in {{ count }} days unless restored",
"You updated": "You updated",
"{{ userName }} updated": "{{ userName }} updated",
"You deleted": "You deleted",
@@ -1449,6 +1449,11 @@
"Allow editors to create new collections within the workspace": "Allow editors to create new collections within the workspace",
"Workspace creation": "Workspace creation",
"Allow editors to create new workspaces": "Allow editors to create new workspaces",
"Forever": "Forever",
"Trash retention": "Trash retention",
"How long deleted documents remain in the trash, where they can still be restored": "How long deleted documents remain in the trash, where they can still be restored",
"Data retention": "Data retention",
"How long documents are retained after leaving the trash, before they are permanently erased": "How long documents are retained after leaving the trash, before they are permanently erased",
"Could not load shares": "Could not load shares",
"Sharing is currently disabled.": "Sharing is currently disabled.",
"You can globally enable and disable public document sharing in the <em>security settings</em>.": "You can globally enable and disable public document sharing in the <em>security settings</em>.",