diff --git a/packages/backend/src/database/schema/compliance.ts b/packages/backend/src/database/schema/compliance.ts index dca199c..2019df6 100644 --- a/packages/backend/src/database/schema/compliance.ts +++ b/packages/backend/src/database/schema/compliance.ts @@ -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 }) => ({ diff --git a/packages/backend/src/helpers/deletionGuard.ts b/packages/backend/src/helpers/deletionGuard.ts index 996b274..3d118c1 100644 --- a/packages/backend/src/helpers/deletionGuard.ts +++ b/packages/backend/src/helpers/deletionGuard.ts @@ -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); diff --git a/packages/backend/src/hooks/RetentionHook.ts b/packages/backend/src/hooks/RetentionHook.ts new file mode 100644 index 0000000..96406f6 --- /dev/null +++ b/packages/backend/src/hooks/RetentionHook.ts @@ -0,0 +1,36 @@ +import { logger } from '../config/logger'; + +export type DeletionCheck = (emailId: string) => Promise; + +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 { + 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; + } +} diff --git a/packages/backend/src/services/ArchivedEmailService.ts b/packages/backend/src/services/ArchivedEmailService.ts index ccf322d..fe2168c 100644 --- a/packages/backend/src/services/ArchivedEmailService.ts +++ b/packages/backend/src/services/ArchivedEmailService.ts @@ -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 { - 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) diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index f1eda70..a0b08aa 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -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'; diff --git a/packages/types/src/license.types.ts b/packages/types/src/license.types.ts index ee850a0..afbbd7b 100644 --- a/packages/types/src/license.types.ts +++ b/packages/types/src/license.types.ts @@ -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; diff --git a/packages/types/src/retention.types.ts b/packages/types/src/retention.types.ts new file mode 100644 index 0000000..056e3dc --- /dev/null +++ b/packages/types/src/retention.types.ts @@ -0,0 +1,33 @@ +export interface RetentionPolicy { + id: string; + name: string; + priority: number; + conditions: Record; // 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; // JSON criteria + createdAt: string; // ISO Date string +} + +export interface LegalHold { + id: string; + name: string; + reason?: string; + isActive: boolean; +}