mirror of
https://github.com/LogicLabs-OU/OpenArchiver.git
synced 2026-04-06 00:31:57 +02:00
Retention policy schema/types
This commit is contained in:
@@ -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 }) => ({
|
||||
|
||||
@@ -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);
|
||||
|
||||
36
packages/backend/src/hooks/RetentionHook.ts
Normal file
36
packages/backend/src/hooks/RetentionHook.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
33
packages/types/src/retention.types.ts
Normal file
33
packages/types/src/retention.types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user