mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
Use retention period presets instead of arbitrary values
Replace free-form retention days with a fixed set of presets (0, 7, 14, 30, 90, 180, 365) where 0 means infinite/never delete. This bounds the number of cron sub-tasks and simplifies the system by removing findUniquePreferenceValues in favor of iterating over known presets directly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,3 @@
|
||||
import { TeamPreference } from "@shared/types";
|
||||
import { Team } from "@server/models";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
buildTeam,
|
||||
@@ -50,48 +48,6 @@ describe("Team", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("findUniquePreferenceValues", () => {
|
||||
it("should return unique custom preference values", async () => {
|
||||
const val1 = Math.floor(Math.random() * 10000) + 1000;
|
||||
const val2 = Math.floor(Math.random() * 10000) + 11000;
|
||||
|
||||
const team1 = await buildTeam();
|
||||
team1.setPreference(TeamPreference.TrashRetentionDays, val1);
|
||||
await team1.save();
|
||||
|
||||
const team2 = await buildTeam();
|
||||
team2.setPreference(TeamPreference.TrashRetentionDays, val2);
|
||||
await team2.save();
|
||||
|
||||
const team3 = await buildTeam();
|
||||
team3.setPreference(TeamPreference.TrashRetentionDays, val1);
|
||||
await team3.save();
|
||||
|
||||
const results = await Team.findUniquePreferenceValues(
|
||||
TeamPreference.TrashRetentionDays,
|
||||
{ useCache: false }
|
||||
);
|
||||
expect(results).toContain(val1);
|
||||
expect(results).toContain(val2);
|
||||
});
|
||||
|
||||
it("should not return default preference values", async () => {
|
||||
const results = await Team.findUniquePreferenceValues(
|
||||
TeamPreference.TrashRetentionDays,
|
||||
{ useCache: false }
|
||||
);
|
||||
const countBefore = results.length;
|
||||
|
||||
await buildTeam();
|
||||
|
||||
const resultsAfter = await Team.findUniquePreferenceValues(
|
||||
TeamPreference.TrashRetentionDays,
|
||||
{ useCache: false }
|
||||
);
|
||||
expect(resultsAfter.length).toEqual(countBefore);
|
||||
});
|
||||
});
|
||||
|
||||
describe("publicAvatarUrl", () => {
|
||||
it("should return null when no avatarUrl is set", async () => {
|
||||
const team = await buildTeam({ avatarUrl: null });
|
||||
|
||||
+1
-55
@@ -3,7 +3,7 @@ import { URL } from "node:url";
|
||||
import { subMinutes } from "date-fns";
|
||||
import type { InferAttributes, InferCreationAttributes } from "sequelize";
|
||||
import { type SaveOptions } from "sequelize";
|
||||
import { Op, Sequelize } from "sequelize";
|
||||
import { Op } from "sequelize";
|
||||
import {
|
||||
Column,
|
||||
IsLowercase,
|
||||
@@ -25,8 +25,6 @@ import {
|
||||
IsNumeric,
|
||||
} from "sequelize-typescript";
|
||||
import { isEmail } from "validator";
|
||||
import { Hour } from "@shared/utils/time";
|
||||
import { CacheHelper } from "@server/utils/CacheHelper";
|
||||
import { TeamPreferenceDefaults } from "@shared/constants";
|
||||
import type { TeamPreferences } from "@shared/types";
|
||||
import { TeamPreference, UserRole } from "@shared/types";
|
||||
@@ -577,58 +575,6 @@ class Team extends ParanoidModel<
|
||||
order: [["updatedAt", "DESC"]],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all unique custom values for a given preference across all teams.
|
||||
*
|
||||
* @param preference The preference to search for.
|
||||
* @param options Options for the query.
|
||||
* @returns An array of unique values for the preference.
|
||||
*/
|
||||
static async findUniquePreferenceValues(
|
||||
preference: TeamPreference,
|
||||
options: { useCache?: boolean } = {}
|
||||
): Promise<number[]> {
|
||||
const cacheKey = `unique_preference_values:${preference}`;
|
||||
const defaultValue = TeamPreferenceDefaults[preference];
|
||||
const useCache = options.useCache ?? true;
|
||||
|
||||
const fetcher = async () => {
|
||||
const results = await this.findAll({
|
||||
attributes: [
|
||||
[
|
||||
Sequelize.fn(
|
||||
"DISTINCT",
|
||||
Sequelize.literal("(preferences->> :preference)::int")
|
||||
),
|
||||
"value",
|
||||
],
|
||||
],
|
||||
where: Sequelize.literal(
|
||||
"preferences->> :preference IS NOT NULL AND (preferences->> :preference)::int != :defaultValue"
|
||||
),
|
||||
replacements: {
|
||||
preference,
|
||||
defaultValue,
|
||||
},
|
||||
raw: true,
|
||||
});
|
||||
|
||||
return results.map((r: any) => r.value);
|
||||
};
|
||||
|
||||
if (useCache) {
|
||||
const results = await CacheHelper.getDataOrSet<number[]>(
|
||||
cacheKey,
|
||||
fetcher,
|
||||
Hour.seconds
|
||||
);
|
||||
|
||||
return results ?? [];
|
||||
}
|
||||
|
||||
return (await fetcher()) ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
export default Team;
|
||||
|
||||
@@ -22,6 +22,11 @@ export type Props = {
|
||||
*/
|
||||
export default class CleanupPermanentlyDeletedDocumentsByRetentionTask extends BaseTask<Props> {
|
||||
public async perform({ limit, partition, retentionDays }: Props) {
|
||||
// Infinite retention means documents are never permanently deleted.
|
||||
if (retentionDays === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultRetentionDays = TeamPreferenceDefaults[
|
||||
TeamPreference.DataRetentionDays
|
||||
] as number;
|
||||
@@ -43,11 +48,13 @@ export default class CleanupPermanentlyDeletedDocumentsByRetentionTask extends B
|
||||
[Op.and]: [
|
||||
Sequelize.literal(
|
||||
isDefault
|
||||
? `NOT EXISTS (
|
||||
? `EXISTS (
|
||||
SELECT 1 FROM teams
|
||||
WHERE teams.id = "document"."teamId"
|
||||
AND preferences->> :preference IS NOT NULL
|
||||
AND (preferences->> :preference)::int != :retentionDays
|
||||
AND (
|
||||
preferences->> :preference IS NULL
|
||||
OR (preferences->> :preference)::int = :retentionDays
|
||||
)
|
||||
)`
|
||||
: `EXISTS (
|
||||
SELECT 1 FROM teams
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { Team } from "@server/models";
|
||||
import { TeamPreferenceDefaults } from "@shared/constants";
|
||||
import { TeamPreference } from "@shared/types";
|
||||
import { RetentionPeriodPresets } from "@shared/constants";
|
||||
import { Minute } from "@shared/utils/time";
|
||||
import { TaskPriority } from "./base/BaseTask";
|
||||
import { CronTask, TaskInterval } from "./base/CronTask";
|
||||
@@ -10,24 +8,17 @@ import CleanupPermanentlyDeletedDocumentsByRetentionTask from "./CleanupPermanen
|
||||
|
||||
export default class CleanupPermanentlyDeletedDocumentsTask extends CronTask {
|
||||
/**
|
||||
* Identifies all unique retention periods and schedules a worker task for each.
|
||||
* Schedules a worker task for each retention period preset.
|
||||
*
|
||||
* @param props Properties to be used by the task
|
||||
* @param props Properties to be used by the task.
|
||||
*/
|
||||
public async perform(props: Props) {
|
||||
const defaultRetentionDays = TeamPreferenceDefaults[
|
||||
TeamPreference.DataRetentionDays
|
||||
] as number;
|
||||
|
||||
// Find all unique custom retention periods currently in use by teams.
|
||||
const customRetentionPeriods = await Team.findUniquePreferenceValues(
|
||||
TeamPreference.DataRetentionDays
|
||||
);
|
||||
|
||||
const task = new CleanupPermanentlyDeletedDocumentsByRetentionTask();
|
||||
|
||||
// Schedule a task for each unique custom retention period.
|
||||
for (const days of customRetentionPeriods) {
|
||||
for (const days of RetentionPeriodPresets) {
|
||||
if (days === 0) {
|
||||
continue;
|
||||
}
|
||||
await task.schedule({
|
||||
limit: props.limit,
|
||||
retentionDays: days,
|
||||
@@ -35,16 +26,9 @@ export default class CleanupPermanentlyDeletedDocumentsTask extends CronTask {
|
||||
});
|
||||
}
|
||||
|
||||
// Schedule a task for the default retention period.
|
||||
await task.schedule({
|
||||
limit: props.limit,
|
||||
retentionDays: defaultRetentionDays,
|
||||
partition: props.partition,
|
||||
});
|
||||
|
||||
Logger.debug(
|
||||
"task",
|
||||
`Scheduled ${customRetentionPeriods.length + 1} tranches for document cleanup`
|
||||
`Scheduled ${RetentionPeriodPresets.length - 1} tranches for document cleanup`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,12 @@ export type Props = {
|
||||
export default class ExpireDocumentsInTrashByRetentionTask extends BaseTask<Props> {
|
||||
public async perform(props: Props) {
|
||||
const { partition, retentionDays } = props;
|
||||
|
||||
// Infinite retention means documents are never expired from trash.
|
||||
if (retentionDays === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultTrashRetentionDays = TeamPreferenceDefaults[
|
||||
TeamPreference.TrashRetentionDays
|
||||
] as number;
|
||||
@@ -47,11 +53,13 @@ export default class ExpireDocumentsInTrashByRetentionTask extends BaseTask<Prop
|
||||
[Op.and]: [
|
||||
Sequelize.literal(
|
||||
isDefault
|
||||
? `NOT EXISTS (
|
||||
? `EXISTS (
|
||||
SELECT 1 FROM teams
|
||||
WHERE teams.id = "documents"."teamId"
|
||||
AND preferences->>'${TeamPreference.TrashRetentionDays}' IS NOT NULL
|
||||
AND (preferences->>'${TeamPreference.TrashRetentionDays}')::int != ${defaultTrashRetentionDays}
|
||||
AND (
|
||||
preferences->>'${TeamPreference.TrashRetentionDays}' IS NULL
|
||||
OR (preferences->>'${TeamPreference.TrashRetentionDays}')::int = ${defaultTrashRetentionDays}
|
||||
)
|
||||
)`
|
||||
: `EXISTS (
|
||||
SELECT 1 FROM teams
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { Team } from "@server/models";
|
||||
import { TeamPreferenceDefaults } from "@shared/constants";
|
||||
import { TeamPreference } from "@shared/types";
|
||||
import { RetentionPeriodPresets } from "@shared/constants";
|
||||
import { Minute } from "@shared/utils/time";
|
||||
import { TaskPriority } from "./base/BaseTask";
|
||||
import { CronTask, TaskInterval } from "./base/CronTask";
|
||||
@@ -10,39 +8,26 @@ import ExpireDocumentsInTrashByRetentionTask from "./ExpireDocumentsInTrashByRet
|
||||
|
||||
export default class ExpireDocumentsInTrashTask extends CronTask {
|
||||
/**
|
||||
* Identifies all unique trash retention periods and schedules a worker task for each.
|
||||
* Schedules a worker task for each retention period preset.
|
||||
*
|
||||
* @param props Properties to be used by the task
|
||||
* @param props Properties to be used by the task.
|
||||
*/
|
||||
public async perform(props: Props) {
|
||||
const defaultTrashRetentionDays = TeamPreferenceDefaults[
|
||||
TeamPreference.TrashRetentionDays
|
||||
] as number;
|
||||
|
||||
// Find all unique custom trash retention periods currently in use by teams.
|
||||
const customRetentionPeriods = await Team.findUniquePreferenceValues(
|
||||
TeamPreference.TrashRetentionDays
|
||||
);
|
||||
|
||||
const task = new ExpireDocumentsInTrashByRetentionTask();
|
||||
|
||||
// Schedule a task for each unique custom retention period.
|
||||
for (const days of customRetentionPeriods) {
|
||||
for (const days of RetentionPeriodPresets) {
|
||||
if (days === 0) {
|
||||
continue;
|
||||
}
|
||||
await task.schedule({
|
||||
retentionDays: days,
|
||||
partition: props.partition,
|
||||
});
|
||||
}
|
||||
|
||||
// Schedule a task for the default retention period.
|
||||
await task.schedule({
|
||||
retentionDays: defaultTrashRetentionDays,
|
||||
partition: props.partition,
|
||||
});
|
||||
|
||||
Logger.debug(
|
||||
"task",
|
||||
`Scheduled ${customRetentionPeriods.length + 1} tranches for marking documents for permanent deletion`
|
||||
`Scheduled ${RetentionPeriodPresets.length - 1} tranches for marking documents for permanent deletion`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,18 @@ import { EmailDisplay, TOCPosition, UserRole } from "@shared/types";
|
||||
import { TeamValidation } from "@shared/validations";
|
||||
import { BaseSchema } from "@server/routes/api/schema";
|
||||
|
||||
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),
|
||||
])
|
||||
.optional();
|
||||
|
||||
export const TeamsUpdateSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
/** Team name */
|
||||
@@ -66,6 +78,10 @@ export const TeamsUpdateSchema = BaseSchema.extend({
|
||||
emailDisplay: z.enum(EmailDisplay).optional(),
|
||||
/** Whether to prevent shared documents from being embedded in iframes on external websites. */
|
||||
preventDocumentEmbedding: z.boolean().optional(),
|
||||
/** Days to keep documents in trash before retention phase. 0 = infinite. */
|
||||
trashRetentionDays: retentionDaysSchema,
|
||||
/** Days to keep documents in retention phase before permanent deletion. 0 = infinite. */
|
||||
dataRetentionDays: retentionDaysSchema,
|
||||
/** Whether external MCP clients can connect to the workspace. */
|
||||
mcp: z.boolean().optional(),
|
||||
/** List of disabled embed provider titles. */
|
||||
|
||||
+10
-1
@@ -1,4 +1,8 @@
|
||||
import type { TeamPreferences, UserPreferences } from "./types";
|
||||
import type {
|
||||
RetentionPeriodPreset,
|
||||
TeamPreferences,
|
||||
UserPreferences,
|
||||
} from "./types";
|
||||
import {
|
||||
TOCPosition,
|
||||
TeamPreference,
|
||||
@@ -7,6 +11,11 @@ import {
|
||||
NotificationBadgeType,
|
||||
} from "./types";
|
||||
|
||||
/** Allowed retention period values in days. 0 means infinite (never delete). */
|
||||
export const RetentionPeriodPresets: readonly RetentionPeriodPreset[] = [
|
||||
0, 7, 14, 30, 90, 180, 365,
|
||||
];
|
||||
|
||||
export const MAX_AVATAR_DISPLAY = 6;
|
||||
|
||||
export const Pagination = {
|
||||
|
||||
@@ -272,7 +272,6 @@
|
||||
"New": "New",
|
||||
"Only visible to you": "Only visible to you",
|
||||
"Draft": "Draft",
|
||||
"Template": "Template",
|
||||
"Permanently deletes in {{ days }} days": "Permanently deletes in {{ days }} days",
|
||||
"{{ days }} days": "{{ days }} days",
|
||||
"You updated": "You updated",
|
||||
|
||||
+5
-2
@@ -1,3 +1,6 @@
|
||||
/** Allowed retention period in days. 0 means infinite (never delete). */
|
||||
export type RetentionPeriodPreset = 0 | 7 | 14 | 30 | 90 | 180 | 365;
|
||||
|
||||
/** Available user roles. */
|
||||
export enum UserRole {
|
||||
Admin = "admin",
|
||||
@@ -430,8 +433,8 @@ export type TeamPreferences = {
|
||||
[TeamPreference.CustomTheme]?: Partial<CustomTheme>;
|
||||
[TeamPreference.TocPosition]?: TOCPosition;
|
||||
[TeamPreference.PreventDocumentEmbedding]?: boolean;
|
||||
[TeamPreference.TrashRetentionDays]?: number;
|
||||
[TeamPreference.DataRetentionDays]?: number;
|
||||
[TeamPreference.TrashRetentionDays]?: RetentionPeriodPreset;
|
||||
[TeamPreference.DataRetentionDays]?: RetentionPeriodPreset;
|
||||
[TeamPreference.EmailDisplay]?: EmailDisplay;
|
||||
[TeamPreference.MCP]?: boolean;
|
||||
[TeamPreference.DisabledEmbeds]?: string[];
|
||||
|
||||
Reference in New Issue
Block a user