mirror of
https://github.com/outline/outline.git
synced 2026-08-03 13:27:25 +03:00
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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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>.",
|
||||
|
||||
Reference in New Issue
Block a user