Skip to content

Commit 85000ad

Browse files
authored
Retention policy schema/types (#325)
1 parent c5672d0 commit 85000ad

7 files changed

Lines changed: 189 additions & 19 deletions

File tree

packages/backend/src/database/schema/compliance.ts

Lines changed: 94 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,15 @@ import {
55
jsonb,
66
pgEnum,
77
pgTable,
8+
primaryKey,
89
text,
910
timestamp,
1011
uuid,
12+
varchar,
1113
} from 'drizzle-orm/pg-core';
14+
import { archivedEmails } from './archived-emails';
1215
import { custodians } from './custodians';
16+
import { users } from './users';
1317

1418
// --- Enums ---
1519

@@ -33,6 +37,36 @@ export const retentionPolicies = pgTable('retention_policies', {
3337
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
3438
});
3539

40+
export const retentionLabels = pgTable('retention_labels', {
41+
id: uuid('id').defaultRandom().primaryKey(),
42+
name: varchar('name', { length: 255 }).notNull(),
43+
retentionPeriodDays: integer('retention_period_days').notNull(),
44+
description: text('description'),
45+
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
46+
});
47+
48+
export const emailRetentionLabels = pgTable('email_retention_labels', {
49+
emailId: uuid('email_id')
50+
.references(() => archivedEmails.id, { onDelete: 'cascade' })
51+
.notNull(),
52+
labelId: uuid('label_id')
53+
.references(() => retentionLabels.id, { onDelete: 'cascade' })
54+
.notNull(),
55+
appliedAt: timestamp('applied_at', { withTimezone: true }).notNull().defaultNow(),
56+
appliedByUserId: uuid('applied_by_user_id').references(() => users.id),
57+
}, (t) => [
58+
primaryKey({ columns: [t.emailId, t.labelId] }),
59+
]);
60+
61+
export const retentionEvents = pgTable('retention_events', {
62+
id: uuid('id').defaultRandom().primaryKey(),
63+
eventName: varchar('event_name', { length: 255 }).notNull(),
64+
eventType: varchar('event_type', { length: 100 }).notNull(),
65+
eventTimestamp: timestamp('event_timestamp', { withTimezone: true }).notNull(),
66+
targetCriteria: jsonb('target_criteria').notNull(),
67+
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
68+
});
69+
3670
export const ediscoveryCases = pgTable('ediscovery_cases', {
3771
id: uuid('id').primaryKey().defaultRandom(),
3872
name: text('name').notNull().unique(),
@@ -44,18 +78,31 @@ export const ediscoveryCases = pgTable('ediscovery_cases', {
4478
});
4579

4680
export const legalHolds = pgTable('legal_holds', {
47-
id: uuid('id').primaryKey().defaultRandom(),
48-
caseId: uuid('case_id')
49-
.notNull()
50-
.references(() => ediscoveryCases.id, { onDelete: 'cascade' }),
51-
custodianId: uuid('custodian_id').references(() => custodians.id, { onDelete: 'cascade' }),
52-
holdCriteria: jsonb('hold_criteria'),
81+
id: uuid('id').defaultRandom().primaryKey(),
82+
name: varchar('name', { length: 255 }).notNull(),
5383
reason: text('reason'),
54-
appliedByIdentifier: text('applied_by_identifier').notNull(),
55-
appliedAt: timestamp('applied_at', { withTimezone: true }).notNull().defaultNow(),
56-
removedAt: timestamp('removed_at', { withTimezone: true }),
84+
isActive: boolean('is_active').notNull().default(true),
85+
// Optional link to ediscovery cases for backward compatibility or future use
86+
caseId: uuid('case_id').references(() => ediscoveryCases.id, { onDelete: 'set null' }),
87+
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
88+
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
5789
});
5890

91+
export const emailLegalHolds = pgTable(
92+
'email_legal_holds',
93+
{
94+
emailId: uuid('email_id')
95+
.references(() => archivedEmails.id, { onDelete: 'cascade' })
96+
.notNull(),
97+
legalHoldId: uuid('legal_hold_id')
98+
.references(() => legalHolds.id, { onDelete: 'cascade' })
99+
.notNull(),
100+
},
101+
(t) => [
102+
primaryKey({ columns: [t.emailId, t.legalHoldId] }),
103+
],
104+
);
105+
59106
export const exportJobs = pgTable('export_jobs', {
60107
id: uuid('id').primaryKey().defaultRandom(),
61108
caseId: uuid('case_id').references(() => ediscoveryCases.id, { onDelete: 'set null' }),
@@ -70,20 +117,51 @@ export const exportJobs = pgTable('export_jobs', {
70117

71118
// --- Relations ---
72119

73-
export const ediscoveryCasesRelations = relations(ediscoveryCases, ({ many }) => ({
74-
legalHolds: many(legalHolds),
75-
exportJobs: many(exportJobs),
120+
export const retentionPoliciesRelations = relations(retentionPolicies, ({ many }) => ({
121+
// Add relations if needed
122+
}));
123+
124+
export const retentionLabelsRelations = relations(retentionLabels, ({ many }) => ({
125+
emailRetentionLabels: many(emailRetentionLabels),
126+
}));
127+
128+
export const emailRetentionLabelsRelations = relations(emailRetentionLabels, ({ one }) => ({
129+
label: one(retentionLabels, {
130+
fields: [emailRetentionLabels.labelId],
131+
references: [retentionLabels.id],
132+
}),
133+
email: one(archivedEmails, {
134+
fields: [emailRetentionLabels.emailId],
135+
references: [archivedEmails.id],
136+
}),
137+
appliedByUser: one(users, {
138+
fields: [emailRetentionLabels.appliedByUserId],
139+
references: [users.id],
140+
}),
76141
}));
77142

78-
export const legalHoldsRelations = relations(legalHolds, ({ one }) => ({
143+
export const legalHoldsRelations = relations(legalHolds, ({ one, many }) => ({
144+
emailLegalHolds: many(emailLegalHolds),
79145
ediscoveryCase: one(ediscoveryCases, {
80146
fields: [legalHolds.caseId],
81147
references: [ediscoveryCases.id],
82148
}),
83-
custodian: one(custodians, {
84-
fields: [legalHolds.custodianId],
85-
references: [custodians.id],
149+
}));
150+
151+
export const emailLegalHoldsRelations = relations(emailLegalHolds, ({ one }) => ({
152+
legalHold: one(legalHolds, {
153+
fields: [emailLegalHolds.legalHoldId],
154+
references: [legalHolds.id],
86155
}),
156+
email: one(archivedEmails, {
157+
fields: [emailLegalHolds.emailId],
158+
references: [archivedEmails.id],
159+
}),
160+
}));
161+
162+
export const ediscoveryCasesRelations = relations(ediscoveryCases, ({ many }) => ({
163+
legalHolds: many(legalHolds),
164+
exportJobs: many(exportJobs),
87165
}));
88166

89167
export const exportJobsRelations = relations(exportJobs, ({ one }) => ({

packages/backend/src/helpers/deletionGuard.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
import { config } from '../config';
22
import i18next from 'i18next';
33

4-
export function checkDeletionEnabled() {
4+
interface DeletionOptions {
5+
allowSystemDelete?: boolean;
6+
}
7+
8+
export function checkDeletionEnabled(options?: DeletionOptions) {
9+
// If system delete is allowed (e.g. by retention policy), bypass the config check
10+
if (options?.allowSystemDelete) {
11+
return;
12+
}
13+
514
if (!config.app.enableDeletion) {
615
const errorMessage = i18next.t('Deletion is disabled for this instance.');
716
throw new Error(errorMessage);
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { logger } from '../config/logger';
2+
3+
export type DeletionCheck = (emailId: string) => Promise<boolean>;
4+
5+
export class RetentionHook {
6+
private static checks: DeletionCheck[] = [];
7+
8+
/**
9+
* Registers a function that checks if an email can be deleted.
10+
* The function should return true if deletion is allowed, false otherwise.
11+
*/
12+
static registerCheck(check: DeletionCheck) {
13+
this.checks.push(check);
14+
}
15+
16+
/**
17+
* Verifies if an email can be deleted by running all registered checks.
18+
* If ANY check returns false, deletion is blocked.
19+
*/
20+
static async canDelete(emailId: string): Promise<boolean> {
21+
for (const check of this.checks) {
22+
try {
23+
const allowed = await check(emailId);
24+
if (!allowed) {
25+
logger.info(`Deletion blocked by retention check for email ${emailId}`);
26+
return false;
27+
}
28+
} catch (error) {
29+
logger.error(`Error in retention check for email ${emailId}:`, error);
30+
// Fail safe: if a check errors, assume we CANNOT delete to be safe
31+
return false;
32+
}
33+
}
34+
return true;
35+
}
36+
}

packages/backend/src/services/ArchivedEmailService.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type { Readable } from 'stream';
2020
import { AuditService } from './AuditService';
2121
import { User } from '@open-archiver/types';
2222
import { checkDeletionEnabled } from '../helpers/deletionGuard';
23+
import { RetentionHook } from '../hooks/RetentionHook';
2324

2425
interface DbRecipients {
2526
to: { name: string; address: string }[];
@@ -197,9 +198,16 @@ export class ArchivedEmailService {
197198
public static async deleteArchivedEmail(
198199
emailId: string,
199200
actor: User,
200-
actorIp: string
201+
actorIp: string,
202+
options: { systemDelete?: boolean } = {}
201203
): Promise<void> {
202-
checkDeletionEnabled();
204+
checkDeletionEnabled({ allowSystemDelete: options.systemDelete });
205+
206+
const canDelete = await RetentionHook.canDelete(emailId);
207+
if (!canDelete) {
208+
throw new Error('Deletion blocked by retention policy (Legal Hold or similar).');
209+
}
210+
203211
const [email] = await db
204212
.select()
205213
.from(archivedEmails)

packages/types/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ export * from './audit-log.enums';
1313
export * from './integrity.types';
1414
export * from './jobs.types';
1515
export * from './license.types';
16+
export * from './retention.types';

packages/types/src/license.types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ export interface LicenseStatusPayload {
6464
lastCheckedAt?: string;
6565
/** The current plan seat limit from the license server. */
6666
planSeats: number;
67+
/** ISO 8601 UTC timestamp of the license expiration date. */
68+
expirationDate?: string;
69+
/** Optional message from the license server (e.g. regarding account status). */
70+
message?: string;
6771
}
6872

6973
/**
@@ -78,6 +82,7 @@ export interface ConsolidatedLicenseStatus {
7882
remoteStatus: 'VALID' | 'INVALID' | 'UNKNOWN';
7983
gracePeriodEnds?: string;
8084
lastCheckedAt?: string;
85+
message?: string;
8186
// Calculated values
8287
activeSeats: number;
8388
isExpired: boolean;
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
export interface RetentionPolicy {
2+
id: string;
3+
name: string;
4+
priority: number;
5+
conditions: Record<string, any>; // JSON condition logic
6+
retentionPeriodDays: number;
7+
isActive: boolean;
8+
createdAt: string; // ISO Date string
9+
}
10+
11+
export interface RetentionLabel {
12+
id: string;
13+
name: string;
14+
retentionPeriodDays: number;
15+
description?: string;
16+
createdAt: string; // ISO Date string
17+
}
18+
19+
export interface RetentionEvent {
20+
id: string;
21+
eventName: string;
22+
eventType: string; // e.g., 'EMPLOYEE_EXIT'
23+
eventTimestamp: string; // ISO Date string
24+
targetCriteria: Record<string, any>; // JSON criteria
25+
createdAt: string; // ISO Date string
26+
}
27+
28+
export interface LegalHold {
29+
id: string;
30+
name: string;
31+
reason?: string;
32+
isActive: boolean;
33+
}

0 commit comments

Comments
 (0)