diff --git a/server/models/Team.test.ts b/server/models/Team.test.ts index 8b86463b2d..b88019ae36 100644 --- a/server/models/Team.test.ts +++ b/server/models/Team.test.ts @@ -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 }); diff --git a/server/models/Team.ts b/server/models/Team.ts index 4a2ea46f48..6fa258cab5 100644 --- a/server/models/Team.ts +++ b/server/models/Team.ts @@ -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 { - 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( - cacheKey, - fetcher, - Hour.seconds - ); - - return results ?? []; - } - - return (await fetcher()) ?? []; - } } export default Team; diff --git a/server/queues/tasks/CleanupPermanentlyDeletedDocumentsByRetentionTask.ts b/server/queues/tasks/CleanupPermanentlyDeletedDocumentsByRetentionTask.ts index fea7ab33b0..f2736985db 100644 --- a/server/queues/tasks/CleanupPermanentlyDeletedDocumentsByRetentionTask.ts +++ b/server/queues/tasks/CleanupPermanentlyDeletedDocumentsByRetentionTask.ts @@ -22,6 +22,11 @@ export type Props = { */ export default class CleanupPermanentlyDeletedDocumentsByRetentionTask extends BaseTask { 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 diff --git a/server/queues/tasks/CleanupPermanentlyDeletedDocumentsTask.ts b/server/queues/tasks/CleanupPermanentlyDeletedDocumentsTask.ts index 4db19ee8ad..e03f63dabc 100644 --- a/server/queues/tasks/CleanupPermanentlyDeletedDocumentsTask.ts +++ b/server/queues/tasks/CleanupPermanentlyDeletedDocumentsTask.ts @@ -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` ); } diff --git a/server/queues/tasks/ExpireDocumentsInTrashByRetentionTask.ts b/server/queues/tasks/ExpireDocumentsInTrashByRetentionTask.ts index 15ce02e31c..c7ae331f17 100644 --- a/server/queues/tasks/ExpireDocumentsInTrashByRetentionTask.ts +++ b/server/queues/tasks/ExpireDocumentsInTrashByRetentionTask.ts @@ -20,6 +20,12 @@ export type Props = { export default class ExpireDocumentsInTrashByRetentionTask extends BaseTask { 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>'${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 diff --git a/server/queues/tasks/ExpireDocumentsInTrashTask.ts b/server/queues/tasks/ExpireDocumentsInTrashTask.ts index 9df5b33ff6..246ea407e9 100644 --- a/server/queues/tasks/ExpireDocumentsInTrashTask.ts +++ b/server/queues/tasks/ExpireDocumentsInTrashTask.ts @@ -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` ); } diff --git a/server/routes/api/teams/schema.ts b/server/routes/api/teams/schema.ts index 394a5076a7..ce38b08f58 100644 --- a/server/routes/api/teams/schema.ts +++ b/server/routes/api/teams/schema.ts @@ -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. */ diff --git a/shared/constants.ts b/shared/constants.ts index c1e3d14530..59b718fab2 100644 --- a/shared/constants.ts +++ b/shared/constants.ts @@ -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 = { diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 94791935a0..7249faa29e 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -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", diff --git a/shared/types.ts b/shared/types.ts index bdeff145d3..9f91a7c747 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -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; [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[];