Retention policy schema/types (#325)

This commit is contained in:
Wei S.
2026-03-07 01:16:14 +01:00
committed by GitHub
parent c5672d0f81
commit 85000ad82b
7 changed files with 189 additions and 19 deletions

View File

@@ -5,11 +5,15 @@ import {
jsonb,
pgEnum,
pgTable,
primaryKey,
text,
timestamp,
uuid,
varchar,
} from 'drizzle-orm/pg-core';
import { archivedEmails } from './archived-emails';
import { custodians } from './custodians';
import { users } from './users';
// --- Enums ---
@@ -33,6 +37,36 @@ export const retentionPolicies = pgTable('retention_policies', {
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
export const retentionLabels = pgTable('retention_labels', {
id: uuid('id').defaultRandom().primaryKey(),
name: varchar('name', { length: 255 }).notNull(),
retentionPeriodDays: integer('retention_period_days').notNull(),
description: text('description'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});
export const emailRetentionLabels = pgTable('email_retention_labels', {
emailId: uuid('email_id')
.references(() => archivedEmails.id, { onDelete: 'cascade' })
.notNull(),
labelId: uuid('label_id')
.references(() => retentionLabels.id, { onDelete: 'cascade' })
.notNull(),
appliedAt: timestamp('applied_at', { withTimezone: true }).notNull().defaultNow(),
appliedByUserId: uuid('applied_by_user_id').references(() => users.id),
}, (t) => [
primaryKey({ columns: [t.emailId, t.labelId] }),
]);
export const retentionEvents = pgTable('retention_events', {
id: uuid('id').defaultRandom().primaryKey(),
eventName: varchar('event_name', { length: 255 }).notNull(),
eventType: varchar('event_type', { length: 100 }).notNull(),
eventTimestamp: timestamp('event_timestamp', { withTimezone: true }).notNull(),
targetCriteria: jsonb('target_criteria').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});
export const ediscoveryCases = pgTable('ediscovery_cases', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull().unique(),
@@ -44,18 +78,31 @@ export const ediscoveryCases = pgTable('ediscovery_cases', {
});
export const legalHolds = pgTable('legal_holds', {
id: uuid('id').primaryKey().defaultRandom(),
caseId: uuid('case_id')
.notNull()
.references(() => ediscoveryCases.id, { onDelete: 'cascade' }),
custodianId: uuid('custodian_id').references(() => custodians.id, { onDelete: 'cascade' }),
holdCriteria: jsonb('hold_criteria'),
id: uuid('id').defaultRandom().primaryKey(),
name: varchar('name', { length: 255 }).notNull(),
reason: text('reason'),
appliedByIdentifier: text('applied_by_identifier').notNull(),
appliedAt: timestamp('applied_at', { withTimezone: true }).notNull().defaultNow(),
removedAt: timestamp('removed_at', { withTimezone: true }),
isActive: boolean('is_active').notNull().default(true),
// Optional link to ediscovery cases for backward compatibility or future use
caseId: uuid('case_id').references(() => ediscoveryCases.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
export const emailLegalHolds = pgTable(
'email_legal_holds',
{
emailId: uuid('email_id')
.references(() => archivedEmails.id, { onDelete: 'cascade' })
.notNull(),
legalHoldId: uuid('legal_hold_id')
.references(() => legalHolds.id, { onDelete: 'cascade' })
.notNull(),
},
(t) => [
primaryKey({ columns: [t.emailId, t.legalHoldId] }),
],
);
export const exportJobs = pgTable('export_jobs', {
id: uuid('id').primaryKey().defaultRandom(),
caseId: uuid('case_id').references(() => ediscoveryCases.id, { onDelete: 'set null' }),
@@ -70,20 +117,51 @@ export const exportJobs = pgTable('export_jobs', {
// --- Relations ---
export const ediscoveryCasesRelations = relations(ediscoveryCases, ({ many }) => ({
legalHolds: many(legalHolds),
exportJobs: many(exportJobs),
export const retentionPoliciesRelations = relations(retentionPolicies, ({ many }) => ({
// Add relations if needed
}));
export const legalHoldsRelations = relations(legalHolds, ({ one }) => ({
export const retentionLabelsRelations = relations(retentionLabels, ({ many }) => ({
emailRetentionLabels: many(emailRetentionLabels),
}));
export const emailRetentionLabelsRelations = relations(emailRetentionLabels, ({ one }) => ({
label: one(retentionLabels, {
fields: [emailRetentionLabels.labelId],
references: [retentionLabels.id],
}),
email: one(archivedEmails, {
fields: [emailRetentionLabels.emailId],
references: [archivedEmails.id],
}),
appliedByUser: one(users, {
fields: [emailRetentionLabels.appliedByUserId],
references: [users.id],
}),
}));
export const legalHoldsRelations = relations(legalHolds, ({ one, many }) => ({
emailLegalHolds: many(emailLegalHolds),
ediscoveryCase: one(ediscoveryCases, {
fields: [legalHolds.caseId],
references: [ediscoveryCases.id],
}),
custodian: one(custodians, {
fields: [legalHolds.custodianId],
references: [custodians.id],
}));
export const emailLegalHoldsRelations = relations(emailLegalHolds, ({ one }) => ({
legalHold: one(legalHolds, {
fields: [emailLegalHolds.legalHoldId],
references: [legalHolds.id],
}),
email: one(archivedEmails, {
fields: [emailLegalHolds.emailId],
references: [archivedEmails.id],
}),
}));
export const ediscoveryCasesRelations = relations(ediscoveryCases, ({ many }) => ({
legalHolds: many(legalHolds),
exportJobs: many(exportJobs),
}));
export const exportJobsRelations = relations(exportJobs, ({ one }) => ({

View File

@@ -1,7 +1,16 @@
import { config } from '../config';
import i18next from 'i18next';
export function checkDeletionEnabled() {
interface DeletionOptions {
allowSystemDelete?: boolean;
}
export function checkDeletionEnabled(options?: DeletionOptions) {
// If system delete is allowed (e.g. by retention policy), bypass the config check
if (options?.allowSystemDelete) {
return;
}
if (!config.app.enableDeletion) {
const errorMessage = i18next.t('Deletion is disabled for this instance.');
throw new Error(errorMessage);

View File

@@ -0,0 +1,36 @@
import { logger } from '../config/logger';
export type DeletionCheck = (emailId: string) => Promise<boolean>;
export class RetentionHook {
private static checks: DeletionCheck[] = [];
/**
* Registers a function that checks if an email can be deleted.
* The function should return true if deletion is allowed, false otherwise.
*/
static registerCheck(check: DeletionCheck) {
this.checks.push(check);
}
/**
* Verifies if an email can be deleted by running all registered checks.
* If ANY check returns false, deletion is blocked.
*/
static async canDelete(emailId: string): Promise<boolean> {
for (const check of this.checks) {
try {
const allowed = await check(emailId);
if (!allowed) {
logger.info(`Deletion blocked by retention check for email ${emailId}`);
return false;
}
} catch (error) {
logger.error(`Error in retention check for email ${emailId}:`, error);
// Fail safe: if a check errors, assume we CANNOT delete to be safe
return false;
}
}
return true;
}
}

View File

@@ -20,6 +20,7 @@ import type { Readable } from 'stream';
import { AuditService } from './AuditService';
import { User } from '@open-archiver/types';
import { checkDeletionEnabled } from '../helpers/deletionGuard';
import { RetentionHook } from '../hooks/RetentionHook';
interface DbRecipients {
to: { name: string; address: string }[];
@@ -197,9 +198,16 @@ export class ArchivedEmailService {
public static async deleteArchivedEmail(
emailId: string,
actor: User,
actorIp: string
actorIp: string,
options: { systemDelete?: boolean } = {}
): Promise<void> {
checkDeletionEnabled();
checkDeletionEnabled({ allowSystemDelete: options.systemDelete });
const canDelete = await RetentionHook.canDelete(emailId);
if (!canDelete) {
throw new Error('Deletion blocked by retention policy (Legal Hold or similar).');
}
const [email] = await db
.select()
.from(archivedEmails)

View File

@@ -13,3 +13,4 @@ export * from './audit-log.enums';
export * from './integrity.types';
export * from './jobs.types';
export * from './license.types';
export * from './retention.types';

View File

@@ -64,6 +64,10 @@ export interface LicenseStatusPayload {
lastCheckedAt?: string;
/** The current plan seat limit from the license server. */
planSeats: number;
/** ISO 8601 UTC timestamp of the license expiration date. */
expirationDate?: string;
/** Optional message from the license server (e.g. regarding account status). */
message?: string;
}
/**
@@ -78,6 +82,7 @@ export interface ConsolidatedLicenseStatus {
remoteStatus: 'VALID' | 'INVALID' | 'UNKNOWN';
gracePeriodEnds?: string;
lastCheckedAt?: string;
message?: string;
// Calculated values
activeSeats: number;
isExpired: boolean;

View File

@@ -0,0 +1,33 @@
export interface RetentionPolicy {
id: string;
name: string;
priority: number;
conditions: Record<string, any>; // JSON condition logic
retentionPeriodDays: number;
isActive: boolean;
createdAt: string; // ISO Date string
}
export interface RetentionLabel {
id: string;
name: string;
retentionPeriodDays: number;
description?: string;
createdAt: string; // ISO Date string
}
export interface RetentionEvent {
id: string;
eventName: string;
eventType: string; // e.g., 'EMPLOYEE_EXIT'
eventTimestamp: string; // ISO Date string
targetCriteria: Record<string, any>; // JSON criteria
createdAt: string; // ISO Date string
}
export interface LegalHold {
id: string;
name: string;
reason?: string;
isActive: boolean;
}