mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(data-retention): workspace-level overrides for retention and PII (#5186)
* feat(data-retention): workspace-level overrides for retention and PII * fix(data-retention): hide unmanageable PII rows when flag off, scope override workspace IDs to org, dedupe key type * improvement(data-retention): unify org default and workspace overrides into one policy list * fix(data-retention): clean up overrides for workspaces deselected during edit
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import type { DataRetentionSettings } from '@sim/db/schema'
|
||||
import { member, organization } from '@sim/db/schema'
|
||||
import { member, organization, workspace } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
type OrganizationRetentionValues,
|
||||
@@ -26,6 +26,7 @@ function enterpriseDefaults(): OrganizationRetentionValues {
|
||||
softDeleteRetentionHours: CLEANUP_CONFIG['cleanup-soft-deletes'].defaults.enterprise,
|
||||
taskCleanupHours: CLEANUP_CONFIG['cleanup-tasks'].defaults.enterprise,
|
||||
piiRedaction: null,
|
||||
retentionOverrides: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +45,7 @@ function normalizeConfigured(
|
||||
})),
|
||||
}
|
||||
: null,
|
||||
retentionOverrides: settings?.retentionOverrides ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,6 +189,32 @@ export const PUT = withRouteHandler(
|
||||
}
|
||||
merged.piiRedaction = body.piiRedaction
|
||||
}
|
||||
if (body.retentionOverrides !== undefined) {
|
||||
merged.retentionOverrides = body.retentionOverrides
|
||||
}
|
||||
|
||||
const targetedWorkspaceIds = new Set<string>()
|
||||
for (const override of body.retentionOverrides ?? []) {
|
||||
targetedWorkspaceIds.add(override.workspaceId)
|
||||
}
|
||||
for (const rule of body.piiRedaction?.rules ?? []) {
|
||||
if (rule.workspaceId) targetedWorkspaceIds.add(rule.workspaceId)
|
||||
}
|
||||
if (targetedWorkspaceIds.size > 0) {
|
||||
const ids = [...targetedWorkspaceIds]
|
||||
const orgWorkspaces = await db
|
||||
.select({ id: workspace.id })
|
||||
.from(workspace)
|
||||
.where(and(eq(workspace.organizationId, organizationId), inArray(workspace.id, ids)))
|
||||
const known = new Set(orgWorkspaces.map((row) => row.id))
|
||||
const unknown = ids.filter((id) => !known.has(id))
|
||||
if (unknown.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `Override targets workspaces outside this organization: ${unknown.join(', ')}` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(organization)
|
||||
|
||||
@@ -5,10 +5,11 @@ import { createLogger } from '@sim/logger'
|
||||
import { isOrgAdminRole } from '@sim/platform-authz/predicates'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { ArrowRight, Plus } from 'lucide-react'
|
||||
import {
|
||||
Checkbox,
|
||||
Chip,
|
||||
ChipDropdown,
|
||||
ChipInput,
|
||||
ChipModal,
|
||||
ChipModalBody,
|
||||
@@ -16,9 +17,12 @@ import {
|
||||
ChipModalFooter,
|
||||
ChipModalHeader,
|
||||
ChipSelect,
|
||||
ChipSwitch,
|
||||
Search,
|
||||
toast,
|
||||
} from '@/components/emcn'
|
||||
import type { UpdateOrganizationDataRetentionBody } from '@/lib/api/contracts/organization'
|
||||
import type { RetentionOverride } from '@/lib/api/contracts/primitives'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { isBillingEnabled } from '@/lib/core/config/env-flags'
|
||||
import {
|
||||
@@ -30,8 +34,6 @@ import {
|
||||
} from '@/lib/guardrails/pii-entities'
|
||||
import { getUserRole } from '@/lib/workspaces/organization/utils'
|
||||
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
|
||||
import { InfoNote } from '@/ee/components/info-note'
|
||||
import { SettingRow } from '@/ee/components/setting-row'
|
||||
import {
|
||||
useOrganizationRetention,
|
||||
useUpdateOrganizationRetention,
|
||||
@@ -43,6 +45,9 @@ const logger = createLogger('DataRetentionSettings')
|
||||
|
||||
const ENTITY_LABELS = SUPPORTED_PII_ENTITIES as Record<string, string>
|
||||
|
||||
/** Sentinel `RetentionSelect` value meaning "inherit the org-level value". */
|
||||
const INHERIT = 'inherit'
|
||||
|
||||
const DAY_OPTIONS = [
|
||||
{ value: '1', label: '1 day' },
|
||||
{ value: '3', label: '3 days' },
|
||||
@@ -57,17 +62,37 @@ const DAY_OPTIONS = [
|
||||
{ value: 'never', label: 'Forever' },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Local editable shape of a PII redaction rule. `workspaceId: null` is the
|
||||
* all-workspaces default; a non-null id is a per-workspace override of it.
|
||||
*/
|
||||
interface RuleDraft {
|
||||
interface PiiOverride {
|
||||
id: string
|
||||
workspaceId: string
|
||||
entityTypes: string[]
|
||||
workspaceId: string | null
|
||||
language: PIILanguage
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified editable shape for one retention policy — the organization default
|
||||
* (`isOrgDefault`) or a workspace override. Retention fields hold
|
||||
* `RetentionSelect` values; for overrides `INHERIT` means "use the org value".
|
||||
* `piiOverride` gates the PII grid (always on for the org default; toggled by
|
||||
* the inherit/override switch for workspace overrides).
|
||||
*/
|
||||
interface PolicyDraft {
|
||||
isOrgDefault: boolean
|
||||
workspaceIds: string[]
|
||||
logDays: string
|
||||
softDeleteDays: string
|
||||
taskCleanupDays: string
|
||||
piiOverride: boolean
|
||||
piiEntityTypes: string[]
|
||||
piiLanguage: PIILanguage
|
||||
}
|
||||
|
||||
interface ActiveModal {
|
||||
draft: PolicyDraft
|
||||
original: PolicyDraft
|
||||
isNew: boolean
|
||||
}
|
||||
|
||||
function hoursToDisplayDays(hours: number | null): string {
|
||||
if (hours === null) return 'never'
|
||||
return String(Math.round(hours / 24))
|
||||
@@ -78,11 +103,44 @@ function daysToHours(days: string): number | null {
|
||||
return Number(days) * 24
|
||||
}
|
||||
|
||||
function normalizeRule(rule: RuleDraft): string {
|
||||
/** Override field: `INHERIT` ⇄ undefined, `'never'` ⇄ null (forever), day count ⇄ hours. */
|
||||
function hoursToOverrideValue(hours: number | null | undefined): string {
|
||||
if (hours === undefined) return INHERIT
|
||||
if (hours === null) return 'never'
|
||||
return String(Math.round(hours / 24))
|
||||
}
|
||||
|
||||
function overrideValueToHours(value: string): number | null | undefined {
|
||||
if (value === INHERIT) return undefined
|
||||
if (value === 'never') return null
|
||||
return Number(value) * 24
|
||||
}
|
||||
|
||||
function buildRetentionOverride(workspaceId: string, draft: PolicyDraft): RetentionOverride | null {
|
||||
const override: RetentionOverride = { workspaceId }
|
||||
const log = overrideValueToHours(draft.logDays)
|
||||
const soft = overrideValueToHours(draft.softDeleteDays)
|
||||
const task = overrideValueToHours(draft.taskCleanupDays)
|
||||
if (log !== undefined) override.logRetentionHours = log
|
||||
if (soft !== undefined) override.softDeleteRetentionHours = soft
|
||||
if (task !== undefined) override.taskCleanupHours = task
|
||||
const hasField =
|
||||
override.logRetentionHours !== undefined ||
|
||||
override.softDeleteRetentionHours !== undefined ||
|
||||
override.taskCleanupHours !== undefined
|
||||
return hasField ? override : null
|
||||
}
|
||||
|
||||
function normalizePolicyDraft(draft: PolicyDraft): string {
|
||||
return JSON.stringify({
|
||||
entityTypes: [...rule.entityTypes].sort(),
|
||||
workspaceId: rule.workspaceId,
|
||||
language: rule.language,
|
||||
isOrgDefault: draft.isOrgDefault,
|
||||
workspaceIds: [...draft.workspaceIds].sort(),
|
||||
logDays: draft.logDays,
|
||||
softDeleteDays: draft.softDeleteDays,
|
||||
taskCleanupDays: draft.taskCleanupDays,
|
||||
piiOverride: draft.piiOverride,
|
||||
piiEntityTypes: draft.piiOverride ? [...draft.piiEntityTypes].sort() : [],
|
||||
piiLanguage: draft.piiLanguage,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -93,19 +151,36 @@ function entitySummary(entityTypes: string[]): string {
|
||||
return `${labels.slice(0, 3).join(', ')} +${labels.length - 3} more`
|
||||
}
|
||||
|
||||
/** Row-summary label for a retention field driven by stored hours. */
|
||||
function retentionLabel(hours: number | null | undefined): string {
|
||||
if (hours === undefined) return 'inherited'
|
||||
if (hours === null) return 'forever'
|
||||
return `${Math.round(hours / 24)}d`
|
||||
}
|
||||
|
||||
/** Row-summary label for a retention field driven by a `RetentionSelect` day value. */
|
||||
function dayValueLabel(days: string): string {
|
||||
if (days === 'never') return 'forever'
|
||||
if (!days) return '—'
|
||||
return `${days}d`
|
||||
}
|
||||
|
||||
interface RetentionSelectProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
/** Prepend an "Inherit from organization" option (workspace-override fields). */
|
||||
allowInherit?: boolean
|
||||
}
|
||||
|
||||
function RetentionSelect({ value, onChange }: RetentionSelectProps) {
|
||||
const standard = DAY_OPTIONS.find((o) => o.value === value)
|
||||
const options = standard
|
||||
? DAY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))
|
||||
: [
|
||||
...DAY_OPTIONS.map((o) => ({ value: o.value, label: o.label })),
|
||||
{ value, label: `${value} days (custom)` },
|
||||
]
|
||||
function RetentionSelect({ value, onChange, allowInherit = false }: RetentionSelectProps) {
|
||||
const base = DAY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))
|
||||
const withInherit = allowInherit
|
||||
? [{ value: INHERIT, label: 'Inherit from organization' }, ...base]
|
||||
: base
|
||||
const isKnown = value === INHERIT || DAY_OPTIONS.some((o) => o.value === value)
|
||||
const options = isKnown
|
||||
? withInherit
|
||||
: [...withInherit, { value, label: `${value} days (custom)` }]
|
||||
|
||||
return <ChipSelect value={value} onChange={onChange} options={options} align='start' />
|
||||
}
|
||||
@@ -188,72 +263,133 @@ function EntityCheckboxGrid({ selected, onChange }: EntityCheckboxGridProps) {
|
||||
)
|
||||
}
|
||||
|
||||
interface RuleModalProps {
|
||||
draft: RuleDraft
|
||||
isNew: boolean
|
||||
isSaving: boolean
|
||||
/** Workspaces selectable for an override (excludes those taken by other overrides). */
|
||||
workspaceOptions: { value: string; label: string }[]
|
||||
onChange: (draft: RuleDraft) => void
|
||||
onClose: () => void
|
||||
onSave: () => void
|
||||
interface PiiLanguageSelectProps {
|
||||
value: PIILanguage
|
||||
onChange: (language: PIILanguage) => void
|
||||
}
|
||||
|
||||
function RuleModal({
|
||||
function PiiLanguageSelect({ value, onChange }: PiiLanguageSelectProps) {
|
||||
return (
|
||||
<ChipSelect
|
||||
value={value}
|
||||
onChange={(language) => onChange(language as PIILanguage)}
|
||||
options={PII_LANGUAGES.map((l) => ({ value: l.value, label: l.label }))}
|
||||
align='start'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface PolicyModalProps {
|
||||
draft: PolicyDraft
|
||||
isNew: boolean
|
||||
isSaving: boolean
|
||||
piiEnabled: boolean
|
||||
canRemove: boolean
|
||||
workspaceOptions: { value: string; label: string }[]
|
||||
onChange: (draft: PolicyDraft) => void
|
||||
onClose: () => void
|
||||
onSave: () => void
|
||||
onRemove: () => void
|
||||
}
|
||||
|
||||
function PolicyModal({
|
||||
draft,
|
||||
isNew,
|
||||
isSaving,
|
||||
piiEnabled,
|
||||
canRemove,
|
||||
workspaceOptions,
|
||||
onChange,
|
||||
onClose,
|
||||
onSave,
|
||||
}: RuleModalProps) {
|
||||
const isDefault = draft.workspaceId === null
|
||||
onRemove,
|
||||
}: PolicyModalProps) {
|
||||
const isOrg = draft.isOrgDefault
|
||||
const showPiiGrid = isOrg || draft.piiOverride
|
||||
|
||||
return (
|
||||
<ChipModal open onOpenChange={onClose} size='xl' srTitle='PII redaction'>
|
||||
<ChipModal open onOpenChange={onClose} size='xl' srTitle='Retention policy'>
|
||||
<ChipModalHeader onClose={onClose}>
|
||||
{isDefault
|
||||
? 'Default redaction · all workspaces'
|
||||
{isOrg
|
||||
? 'Organization defaults'
|
||||
: isNew
|
||||
? 'Add workspace override'
|
||||
: 'Edit workspace override'}
|
||||
</ChipModalHeader>
|
||||
<ChipModalBody>
|
||||
{!isDefault && (
|
||||
<ChipModalField type='custom' title='Workspace'>
|
||||
<ChipSelect
|
||||
value={draft.workspaceId ?? ''}
|
||||
onChange={(value) => onChange({ ...draft, workspaceId: value })}
|
||||
{!isOrg && (
|
||||
<ChipModalField type='custom' title='Apply to workspaces'>
|
||||
<ChipDropdown
|
||||
multiple
|
||||
value={draft.workspaceIds}
|
||||
onChange={(workspaceIds) => onChange({ ...draft, workspaceIds })}
|
||||
options={workspaceOptions}
|
||||
align='start'
|
||||
placeholder='Select workspaces'
|
||||
/>
|
||||
</ChipModalField>
|
||||
)}
|
||||
<ChipModalField type='custom' title='Redact'>
|
||||
<EntityCheckboxGrid
|
||||
selected={draft.entityTypes}
|
||||
onChange={(entityTypes) => onChange({ ...draft, entityTypes })}
|
||||
<ChipModalField type='custom' title='Log retention'>
|
||||
<RetentionSelect
|
||||
allowInherit={!isOrg}
|
||||
value={draft.logDays}
|
||||
onChange={(logDays) => onChange({ ...draft, logDays })}
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField
|
||||
type='custom'
|
||||
title='Language'
|
||||
hint='Detection runs with this language’s recognizers — match it to your log content.'
|
||||
>
|
||||
<ChipSelect
|
||||
value={draft.language}
|
||||
onChange={(language) => onChange({ ...draft, language: language as PIILanguage })}
|
||||
options={PII_LANGUAGES.map((l) => ({ value: l.value, label: l.label }))}
|
||||
align='start'
|
||||
<ChipModalField type='custom' title='Soft deletion cleanup'>
|
||||
<RetentionSelect
|
||||
allowInherit={!isOrg}
|
||||
value={draft.softDeleteDays}
|
||||
onChange={(softDeleteDays) => onChange({ ...draft, softDeleteDays })}
|
||||
/>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' title='Task cleanup'>
|
||||
<RetentionSelect
|
||||
allowInherit={!isOrg}
|
||||
value={draft.taskCleanupDays}
|
||||
onChange={(taskCleanupDays) => onChange({ ...draft, taskCleanupDays })}
|
||||
/>
|
||||
</ChipModalField>
|
||||
{piiEnabled && (
|
||||
<ChipModalField type='custom' title='PII redaction'>
|
||||
<div className='flex flex-col gap-3'>
|
||||
{!isOrg && (
|
||||
<ChipSwitch
|
||||
value={draft.piiOverride ? 'override' : 'inherit'}
|
||||
onChange={(mode) => onChange({ ...draft, piiOverride: mode === 'override' })}
|
||||
aria-label='PII redaction override mode'
|
||||
options={[
|
||||
{ value: 'inherit', label: 'Inherit' },
|
||||
{ value: 'override', label: 'Override' },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{showPiiGrid && (
|
||||
<>
|
||||
<EntityCheckboxGrid
|
||||
selected={draft.piiEntityTypes}
|
||||
onChange={(piiEntityTypes) => onChange({ ...draft, piiEntityTypes })}
|
||||
/>
|
||||
<PiiLanguageSelect
|
||||
value={draft.piiLanguage}
|
||||
onChange={(piiLanguage) => onChange({ ...draft, piiLanguage })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ChipModalField>
|
||||
)}
|
||||
</ChipModalBody>
|
||||
<ChipModalFooter
|
||||
onCancel={onClose}
|
||||
secondaryActions={
|
||||
canRemove
|
||||
? [{ label: 'Remove override', onClick: onRemove, variant: 'destructive' }]
|
||||
: undefined
|
||||
}
|
||||
primaryAction={{
|
||||
label: isSaving ? 'Saving...' : 'Save',
|
||||
onClick: onSave,
|
||||
disabled: isSaving,
|
||||
disabled: isSaving || (!isOrg && draft.workspaceIds.length === 0),
|
||||
}}
|
||||
/>
|
||||
</ChipModal>
|
||||
@@ -279,118 +415,201 @@ export function DataRetentionSettings() {
|
||||
const userEmail = session?.user?.email
|
||||
const userRole = getUserRole(activeOrganization, userEmail)
|
||||
const canManage = isOrgAdminRole(userRole)
|
||||
const piiEnabled = Boolean(data?.piiRedactionEnabled)
|
||||
|
||||
const [logDays, setLogDays] = useState('')
|
||||
const [softDeleteDays, setSoftDeleteDays] = useState('')
|
||||
const [taskCleanupDays, setTaskCleanupDays] = useState('')
|
||||
const [savedHours, setSavedHours] = useState('')
|
||||
const [rules, setRules] = useState<RuleDraft[]>([])
|
||||
const [modalDraft, setModalDraft] = useState<RuleDraft | null>(null)
|
||||
const [modalOriginal, setModalOriginal] = useState<RuleDraft | null>(null)
|
||||
const [modalIsNew, setModalIsNew] = useState(false)
|
||||
const [defaultPii, setDefaultPii] = useState<Omit<PiiOverride, 'workspaceId'> | null>(null)
|
||||
const [piiOverrides, setPiiOverrides] = useState<PiiOverride[]>([])
|
||||
const [overrides, setOverrides] = useState<RetentionOverride[]>([])
|
||||
const [modal, setModal] = useState<ActiveModal | null>(null)
|
||||
const [showUnsaved, setShowUnsaved] = useState(false)
|
||||
// Org the form was hydrated for; re-hydrate when the active org switches so
|
||||
// saves don't target the new org with the previous org's config.
|
||||
const hydratedOrgRef = useRef<string | null>(null)
|
||||
|
||||
function hoursSnapshot(log: string, soft: string, task: string): string {
|
||||
return JSON.stringify({ log, soft, task })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || !orgId || hydratedOrgRef.current === orgId) return
|
||||
const log = hoursToDisplayDays(data.effective.logRetentionHours)
|
||||
const soft = hoursToDisplayDays(data.effective.softDeleteRetentionHours)
|
||||
const task = hoursToDisplayDays(data.effective.taskCleanupHours)
|
||||
setLogDays(log)
|
||||
setSoftDeleteDays(soft)
|
||||
setTaskCleanupDays(task)
|
||||
setSavedHours(hoursSnapshot(log, soft, task))
|
||||
setRules(
|
||||
(data.configured.piiRedaction?.rules ?? []).map((r) => ({
|
||||
id: r.id,
|
||||
entityTypes: r.entityTypes,
|
||||
workspaceId: r.workspaceId,
|
||||
language: r.language ?? DEFAULT_PII_LANGUAGE,
|
||||
}))
|
||||
setLogDays(hoursToDisplayDays(data.effective.logRetentionHours))
|
||||
setSoftDeleteDays(hoursToDisplayDays(data.effective.softDeleteRetentionHours))
|
||||
setTaskCleanupDays(hoursToDisplayDays(data.effective.taskCleanupHours))
|
||||
|
||||
const rules = data.configured.piiRedaction?.rules ?? []
|
||||
const defaultRule = rules.find((r) => r.workspaceId === null)
|
||||
setDefaultPii(
|
||||
defaultRule
|
||||
? {
|
||||
id: defaultRule.id,
|
||||
entityTypes: defaultRule.entityTypes,
|
||||
language: defaultRule.language ?? DEFAULT_PII_LANGUAGE,
|
||||
}
|
||||
: null
|
||||
)
|
||||
setPiiOverrides(
|
||||
rules
|
||||
.filter((r) => r.workspaceId !== null)
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
workspaceId: r.workspaceId as string,
|
||||
entityTypes: r.entityTypes,
|
||||
language: r.language ?? DEFAULT_PII_LANGUAGE,
|
||||
}))
|
||||
)
|
||||
setOverrides(data.configured.retentionOverrides ?? [])
|
||||
hydratedOrgRef.current = orgId
|
||||
}, [data, orgId])
|
||||
|
||||
const hoursChanged = hoursSnapshot(logDays, softDeleteDays, taskCleanupDays) !== savedHours
|
||||
const modalChanged =
|
||||
modalDraft !== null &&
|
||||
modalOriginal !== null &&
|
||||
normalizeRule(modalDraft) !== normalizeRule(modalOriginal)
|
||||
modal !== null && normalizePolicyDraft(modal.draft) !== normalizePolicyDraft(modal.original)
|
||||
|
||||
const defaultRule = rules.find((r) => r.workspaceId === null) ?? null
|
||||
const overrideRules = rules.filter((r) => r.workspaceId !== null)
|
||||
const takenWorkspaceIds = new Set(overrideRules.map((r) => r.workspaceId as string))
|
||||
// PII-only rows are only surfaced when redaction is enabled — the route
|
||||
// rejects PII writes while the flag is off, so such rows couldn't be deleted.
|
||||
const overrideWorkspaceIds = Array.from(
|
||||
new Set([
|
||||
...overrides.map((o) => o.workspaceId),
|
||||
...(piiEnabled ? piiOverrides.map((p) => p.workspaceId) : []),
|
||||
])
|
||||
).sort((a, b) => workspaceName(a).localeCompare(workspaceName(b)))
|
||||
const takenWorkspaceIds = new Set(overrideWorkspaceIds)
|
||||
const freeWorkspaces = workspaceOptions.filter((w) => !takenWorkspaceIds.has(w.value))
|
||||
|
||||
/** Workspaces selectable for `draft` — excludes workspaces taken by OTHER overrides. */
|
||||
function overrideOptionsForDraft(draft: RuleDraft): { value: string; label: string }[] {
|
||||
const otherTaken = new Set(
|
||||
rules
|
||||
.filter((r) => r.id !== draft.id && r.workspaceId !== null)
|
||||
.map((r) => r.workspaceId as string)
|
||||
)
|
||||
return workspaceOptions.filter((w) => !otherTaken.has(w.value))
|
||||
/** Options for the modal's workspace picker — excludes workspaces taken by OTHER overrides. */
|
||||
function workspaceModalOptions(draft: PolicyDraft): { value: string; label: string }[] {
|
||||
const others = new Set(overrideWorkspaceIds.filter((id) => !draft.workspaceIds.includes(id)))
|
||||
return workspaceOptions.filter((w) => !others.has(w.value))
|
||||
}
|
||||
|
||||
async function persistRules(nextRules: RuleDraft[]) {
|
||||
if (!orgId) return
|
||||
await updateMutation.mutateAsync({
|
||||
orgId,
|
||||
settings: {
|
||||
piiRedaction: {
|
||||
rules: nextRules.map((r) => ({
|
||||
id: r.id,
|
||||
entityTypes: r.entityTypes,
|
||||
workspaceId: r.workspaceId,
|
||||
language: r.language,
|
||||
})),
|
||||
},
|
||||
},
|
||||
})
|
||||
setRules(nextRules)
|
||||
}
|
||||
|
||||
function openEditDefault() {
|
||||
const rule: RuleDraft = defaultRule ?? {
|
||||
id: generateId(),
|
||||
entityTypes: [],
|
||||
workspaceId: null,
|
||||
language: DEFAULT_PII_LANGUAGE,
|
||||
function orgRowSummary(): string {
|
||||
const parts = [
|
||||
`Log ${dayValueLabel(logDays)}`,
|
||||
`Soft-delete ${dayValueLabel(softDeleteDays)}`,
|
||||
`Task ${dayValueLabel(taskCleanupDays)}`,
|
||||
]
|
||||
if (piiEnabled) {
|
||||
parts.push(
|
||||
defaultPii?.entityTypes.length ? `PII: ${entitySummary(defaultPii.entityTypes)}` : 'No PII'
|
||||
)
|
||||
}
|
||||
setModalIsNew(defaultRule === null)
|
||||
setModalOriginal(rule)
|
||||
setModalDraft({ ...rule })
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
function overrideRowSummary(workspaceId: string): string {
|
||||
const ov = overrides.find((o) => o.workspaceId === workspaceId)
|
||||
const pii = piiOverrides.find((p) => p.workspaceId === workspaceId)
|
||||
const parts = [
|
||||
`Log ${retentionLabel(ov?.logRetentionHours)}`,
|
||||
`Soft-delete ${retentionLabel(ov?.softDeleteRetentionHours)}`,
|
||||
`Task ${retentionLabel(ov?.taskCleanupHours)}`,
|
||||
]
|
||||
if (piiEnabled) parts.push(pii ? `PII: ${entitySummary(pii.entityTypes)}` : 'PII inherited')
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a full snapshot of org hours + PII rules + retention overrides in
|
||||
* one PUT. The route replaces each provided key, so always sending the whole
|
||||
* state keeps the three editable surfaces consistent.
|
||||
*/
|
||||
async function persistSnapshot(next: {
|
||||
logDays: string
|
||||
softDeleteDays: string
|
||||
taskCleanupDays: string
|
||||
defaultPii: Omit<PiiOverride, 'workspaceId'> | null
|
||||
piiOverrides: PiiOverride[]
|
||||
overrides: RetentionOverride[]
|
||||
}) {
|
||||
if (!orgId) return
|
||||
const settings: UpdateOrganizationDataRetentionBody = {
|
||||
logRetentionHours: daysToHours(next.logDays),
|
||||
softDeleteRetentionHours: daysToHours(next.softDeleteDays),
|
||||
taskCleanupHours: daysToHours(next.taskCleanupDays),
|
||||
retentionOverrides: next.overrides,
|
||||
}
|
||||
if (piiEnabled) {
|
||||
const rules: {
|
||||
id: string
|
||||
entityTypes: string[]
|
||||
workspaceId: string | null
|
||||
language: PIILanguage
|
||||
}[] = next.piiOverrides.map((p) => ({
|
||||
id: p.id,
|
||||
entityTypes: p.entityTypes,
|
||||
workspaceId: p.workspaceId,
|
||||
language: p.language,
|
||||
}))
|
||||
if (next.defaultPii) {
|
||||
rules.unshift({
|
||||
id: next.defaultPii.id,
|
||||
entityTypes: next.defaultPii.entityTypes,
|
||||
workspaceId: null,
|
||||
language: next.defaultPii.language,
|
||||
})
|
||||
}
|
||||
settings.piiRedaction = { rules }
|
||||
}
|
||||
await updateMutation.mutateAsync({ orgId, settings })
|
||||
setLogDays(next.logDays)
|
||||
setSoftDeleteDays(next.softDeleteDays)
|
||||
setTaskCleanupDays(next.taskCleanupDays)
|
||||
setOverrides(next.overrides)
|
||||
if (piiEnabled) {
|
||||
setDefaultPii(next.defaultPii)
|
||||
setPiiOverrides(next.piiOverrides)
|
||||
}
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
return { logDays, softDeleteDays, taskCleanupDays, defaultPii, piiOverrides, overrides }
|
||||
}
|
||||
|
||||
function openEditOrg() {
|
||||
const draft: PolicyDraft = {
|
||||
isOrgDefault: true,
|
||||
workspaceIds: [],
|
||||
logDays,
|
||||
softDeleteDays,
|
||||
taskCleanupDays,
|
||||
piiOverride: true,
|
||||
piiEntityTypes: defaultPii?.entityTypes ?? [],
|
||||
piiLanguage: defaultPii?.language ?? DEFAULT_PII_LANGUAGE,
|
||||
}
|
||||
setModal({ draft, original: draft, isNew: false })
|
||||
}
|
||||
|
||||
function openAddOverride() {
|
||||
const workspaceId = freeWorkspaces[0]?.value
|
||||
if (!workspaceId) return
|
||||
const blank: RuleDraft = {
|
||||
id: generateId(),
|
||||
entityTypes: [],
|
||||
workspaceId,
|
||||
language: DEFAULT_PII_LANGUAGE,
|
||||
if (freeWorkspaces.length === 0) return
|
||||
const draft: PolicyDraft = {
|
||||
isOrgDefault: false,
|
||||
workspaceIds: [],
|
||||
logDays: INHERIT,
|
||||
softDeleteDays: INHERIT,
|
||||
taskCleanupDays: INHERIT,
|
||||
piiOverride: false,
|
||||
piiEntityTypes: [],
|
||||
piiLanguage: DEFAULT_PII_LANGUAGE,
|
||||
}
|
||||
setModalIsNew(true)
|
||||
setModalOriginal(blank)
|
||||
setModalDraft(blank)
|
||||
setModal({ draft, original: draft, isNew: true })
|
||||
}
|
||||
|
||||
function openEditOverride(rule: RuleDraft) {
|
||||
setModalIsNew(false)
|
||||
setModalOriginal(rule)
|
||||
setModalDraft({ ...rule })
|
||||
function openEditOverride(workspaceId: string) {
|
||||
const ov = overrides.find((o) => o.workspaceId === workspaceId)
|
||||
const pii = piiOverrides.find((p) => p.workspaceId === workspaceId)
|
||||
const draft: PolicyDraft = {
|
||||
isOrgDefault: false,
|
||||
workspaceIds: [workspaceId],
|
||||
logDays: hoursToOverrideValue(ov?.logRetentionHours),
|
||||
softDeleteDays: hoursToOverrideValue(ov?.softDeleteRetentionHours),
|
||||
taskCleanupDays: hoursToOverrideValue(ov?.taskCleanupHours),
|
||||
piiOverride: Boolean(pii),
|
||||
piiEntityTypes: pii?.entityTypes ?? [],
|
||||
piiLanguage: pii?.language ?? DEFAULT_PII_LANGUAGE,
|
||||
}
|
||||
setModal({ draft, original: draft, isNew: false })
|
||||
}
|
||||
|
||||
function clearModal() {
|
||||
setModalDraft(null)
|
||||
setModalOriginal(null)
|
||||
setModal(null)
|
||||
setShowUnsaved(false)
|
||||
}
|
||||
|
||||
@@ -402,49 +621,79 @@ export function DataRetentionSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveModalRule() {
|
||||
if (!modalDraft) return
|
||||
const next = rules.some((r) => r.id === modalDraft.id)
|
||||
? rules.map((r) => (r.id === modalDraft.id ? modalDraft : r))
|
||||
: [...rules, modalDraft]
|
||||
async function saveModal() {
|
||||
if (!modal) return
|
||||
const draft = modal.draft
|
||||
try {
|
||||
await persistRules(next)
|
||||
clearModal()
|
||||
toast.success('PII redaction saved.')
|
||||
} catch (error) {
|
||||
const msg = toError(error).message
|
||||
logger.error('Failed to save PII redaction', { error: msg })
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
if (draft.isOrgDefault) {
|
||||
await persistSnapshot({
|
||||
...snapshot(),
|
||||
logDays: draft.logDays,
|
||||
softDeleteDays: draft.softDeleteDays,
|
||||
taskCleanupDays: draft.taskCleanupDays,
|
||||
defaultPii: draft.piiEntityTypes.length
|
||||
? {
|
||||
id: defaultPii?.id ?? generateId(),
|
||||
entityTypes: draft.piiEntityTypes,
|
||||
language: draft.piiLanguage,
|
||||
}
|
||||
: null,
|
||||
})
|
||||
clearModal()
|
||||
toast.success('Organization defaults saved.')
|
||||
return
|
||||
}
|
||||
|
||||
async function removeRule(id: string) {
|
||||
try {
|
||||
await persistRules(rules.filter((r) => r.id !== id))
|
||||
toast.success('PII redaction updated.')
|
||||
} catch (error) {
|
||||
const msg = toError(error).message
|
||||
logger.error('Failed to update PII redaction', { error: msg })
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveHours() {
|
||||
if (!orgId) return
|
||||
try {
|
||||
await updateMutation.mutateAsync({
|
||||
orgId,
|
||||
settings: {
|
||||
logRetentionHours: daysToHours(logDays),
|
||||
softDeleteRetentionHours: daysToHours(softDeleteDays),
|
||||
taskCleanupHours: daysToHours(taskCleanupDays),
|
||||
},
|
||||
const ids = draft.workspaceIds
|
||||
if (ids.length === 0) return
|
||||
// Clear the workspaces this edit previously owned plus the new selection,
|
||||
// so deselecting a workspace removes its override instead of orphaning it.
|
||||
const clearIds = new Set([...modal.original.workspaceIds, ...ids])
|
||||
const nextOverrides = overrides.filter((o) => !clearIds.has(o.workspaceId))
|
||||
const nextPiiOverrides = piiOverrides.filter((p) => !clearIds.has(p.workspaceId))
|
||||
for (const workspaceId of ids) {
|
||||
const ov = buildRetentionOverride(workspaceId, draft)
|
||||
if (ov) nextOverrides.push(ov)
|
||||
if (piiEnabled && draft.piiOverride) {
|
||||
const existing = piiOverrides.find((p) => p.workspaceId === workspaceId)
|
||||
nextPiiOverrides.push({
|
||||
id: existing?.id ?? generateId(),
|
||||
workspaceId,
|
||||
entityTypes: draft.piiEntityTypes,
|
||||
language: draft.piiLanguage,
|
||||
})
|
||||
}
|
||||
}
|
||||
await persistSnapshot({
|
||||
...snapshot(),
|
||||
overrides: nextOverrides,
|
||||
piiOverrides: nextPiiOverrides,
|
||||
})
|
||||
setSavedHours(hoursSnapshot(logDays, softDeleteDays, taskCleanupDays))
|
||||
toast.success('Data retention settings saved.')
|
||||
clearModal()
|
||||
toast.success('Workspace override saved.')
|
||||
} catch (error) {
|
||||
const msg = toError(error).message
|
||||
logger.error('Failed to save data retention settings', { error: msg })
|
||||
logger.error('Failed to save data retention policy', { error: msg })
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
async function removeCurrentOverride() {
|
||||
if (!modal || modal.draft.isOrgDefault) return
|
||||
// Remove the override(s) this row originally owned, regardless of any
|
||||
// unsaved changes to the workspace multi-select in the open modal.
|
||||
const idSet = new Set(modal.original.workspaceIds)
|
||||
try {
|
||||
await persistSnapshot({
|
||||
...snapshot(),
|
||||
overrides: overrides.filter((o) => !idSet.has(o.workspaceId)),
|
||||
piiOverrides: piiOverrides.filter((p) => !idSet.has(p.workspaceId)),
|
||||
})
|
||||
clearModal()
|
||||
toast.success('Workspace override removed.')
|
||||
} catch (error) {
|
||||
const msg = toError(error).message
|
||||
logger.error('Failed to remove workspace override', { error: msg })
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
@@ -491,135 +740,78 @@ export function DataRetentionSettings() {
|
||||
<div />
|
||||
<div className='flex items-center'>
|
||||
<Chip
|
||||
leftIcon={Plus}
|
||||
variant='primary'
|
||||
onClick={handleSaveHours}
|
||||
disabled={updateMutation.isPending || !hoursChanged}
|
||||
onClick={openAddOverride}
|
||||
disabled={freeWorkspaces.length === 0}
|
||||
>
|
||||
{updateMutation.isPending ? 'Saving...' : 'Save'}
|
||||
Add override
|
||||
</Chip>
|
||||
</div>
|
||||
</div>
|
||||
<div className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'>
|
||||
<div className='mx-auto flex max-w-[48rem] flex-col gap-8 pt-6 pb-6'>
|
||||
<InfoNote>Applies organization-wide</InfoNote>
|
||||
<SettingsSection label='Data Retention'>
|
||||
<div className='flex flex-col gap-5'>
|
||||
<SettingRow
|
||||
label='Log retention'
|
||||
description='How long execution logs are kept before they are permanently deleted.'
|
||||
>
|
||||
<RetentionSelect value={logDays} onChange={setLogDays} />
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label='Soft deletion cleanup'
|
||||
description='How long deleted resources remain recoverable before they are permanently removed.'
|
||||
>
|
||||
<RetentionSelect value={softDeleteDays} onChange={setSoftDeleteDays} />
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label='Task cleanup'
|
||||
description='How long copilot chats, runs, and inbox tasks are kept before they are permanently deleted.'
|
||||
>
|
||||
<RetentionSelect value={taskCleanupDays} onChange={setTaskCleanupDays} />
|
||||
</SettingRow>
|
||||
<SettingsSection label='Retention policies'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<span className='text-[var(--text-muted)] text-caption'>
|
||||
Workspaces without an override inherit the organization defaults.
|
||||
</span>
|
||||
<div className='-mx-2 flex flex-col gap-y-0.5'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={openEditOrg}
|
||||
className='flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]'
|
||||
>
|
||||
<div className='flex min-w-0 flex-1 flex-col'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='truncate text-[14px] text-[var(--text-body)]'>
|
||||
Organization
|
||||
</span>
|
||||
<span className='flex-shrink-0 rounded-sm bg-[var(--surface-3)] px-1.5 py-0.5 text-[var(--text-muted)] text-micro'>
|
||||
Default
|
||||
</span>
|
||||
</div>
|
||||
<span className='truncate text-[12px] text-[var(--text-muted)]'>
|
||||
{orgRowSummary()}
|
||||
</span>
|
||||
</div>
|
||||
<ArrowRight className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
</button>
|
||||
{overrideWorkspaceIds.map((workspaceId) => (
|
||||
<button
|
||||
key={workspaceId}
|
||||
type='button'
|
||||
onClick={() => openEditOverride(workspaceId)}
|
||||
className='flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]'
|
||||
>
|
||||
<div className='flex min-w-0 flex-1 flex-col'>
|
||||
<span className='truncate text-[14px] text-[var(--text-body)]'>
|
||||
{workspaceName(workspaceId)}
|
||||
</span>
|
||||
<span className='truncate text-[12px] text-[var(--text-muted)]'>
|
||||
{overrideRowSummary(workspaceId)}
|
||||
</span>
|
||||
</div>
|
||||
<ArrowRight className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
{data?.piiRedactionEnabled && (
|
||||
<SettingsSection label='PII Redaction'>
|
||||
<div className='flex flex-col gap-6'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<span className='font-medium text-[var(--text-muted)] text-small'>
|
||||
Default · all workspaces
|
||||
</span>
|
||||
{!defaultRule && (
|
||||
<Chip leftIcon={Plus} onClick={openEditDefault}>
|
||||
Add redaction
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
{defaultRule && (
|
||||
<div className='flex items-center justify-between gap-3 rounded-lg border border-[var(--border-1)] px-3 py-2'>
|
||||
<span className='truncate text-[var(--text-body)] text-small'>
|
||||
{entitySummary(defaultRule.entityTypes)}
|
||||
</span>
|
||||
<div className='flex flex-shrink-0 items-center gap-2'>
|
||||
<Chip onClick={openEditDefault}>Edit</Chip>
|
||||
<Chip
|
||||
onClick={() => removeRule(defaultRule.id)}
|
||||
disabled={updateMutation.isPending}
|
||||
>
|
||||
Delete
|
||||
</Chip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{defaultRule && (
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<span className='font-medium text-[var(--text-muted)] text-small'>
|
||||
Workspace overrides
|
||||
</span>
|
||||
<Chip
|
||||
leftIcon={Plus}
|
||||
onClick={openAddOverride}
|
||||
disabled={freeWorkspaces.length === 0}
|
||||
>
|
||||
Add override
|
||||
</Chip>
|
||||
</div>
|
||||
{overrideRules.length === 0 ? (
|
||||
<p className='text-[var(--text-muted)] text-caption'>
|
||||
No overrides — every workspace uses the default.
|
||||
</p>
|
||||
) : (
|
||||
<div className='flex flex-col gap-2'>
|
||||
{overrideRules.map((rule) => (
|
||||
<div
|
||||
key={rule.id}
|
||||
className='flex items-center justify-between gap-3 rounded-lg border border-[var(--border-1)] px-3 py-2'
|
||||
>
|
||||
<div className='flex min-w-0 flex-col'>
|
||||
<span className='truncate text-[var(--text-body)] text-small'>
|
||||
{workspaceName(rule.workspaceId as string)}
|
||||
</span>
|
||||
<span className='truncate text-[var(--text-muted)] text-caption'>
|
||||
{entitySummary(rule.entityTypes)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex flex-shrink-0 items-center gap-2'>
|
||||
<Chip onClick={() => openEditOverride(rule)}>Edit</Chip>
|
||||
<Chip
|
||||
onClick={() => removeRule(rule.id)}
|
||||
disabled={updateMutation.isPending}
|
||||
>
|
||||
Delete
|
||||
</Chip>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<span className='text-[var(--text-muted)] text-caption'>
|
||||
Workspaces not listed use the default.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{modalDraft && (
|
||||
<RuleModal
|
||||
draft={modalDraft}
|
||||
isNew={modalIsNew}
|
||||
{modal && (
|
||||
<PolicyModal
|
||||
draft={modal.draft}
|
||||
isNew={modal.isNew}
|
||||
isSaving={updateMutation.isPending}
|
||||
workspaceOptions={overrideOptionsForDraft(modalDraft)}
|
||||
onChange={setModalDraft}
|
||||
piiEnabled={piiEnabled}
|
||||
canRemove={!modal.draft.isOrgDefault && !modal.isNew}
|
||||
workspaceOptions={workspaceModalOptions(modal.draft)}
|
||||
onChange={(draft) => setModal({ ...modal, draft })}
|
||||
onClose={requestCloseModal}
|
||||
onSave={saveModalRule}
|
||||
onSave={saveModal}
|
||||
onRemove={removeCurrentOverride}
|
||||
/>
|
||||
)}
|
||||
<ChipModal
|
||||
@@ -640,7 +832,7 @@ export function DataRetentionSettings() {
|
||||
secondaryActions={[{ label: 'Discard', onClick: clearModal, variant: 'destructive' }]}
|
||||
primaryAction={{
|
||||
label: updateMutation.isPending ? 'Saving...' : 'Save',
|
||||
onClick: saveModalRule,
|
||||
onClick: saveModal,
|
||||
disabled: updateMutation.isPending,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { updateOrganizationDataRetentionBodySchema } from '@/lib/api/contracts/organization'
|
||||
import { retentionOverridesSchema } from '@/lib/api/contracts/primitives'
|
||||
|
||||
describe('retentionOverridesSchema', () => {
|
||||
it('accepts an override that overrides one field and inherits the rest', () => {
|
||||
const result = retentionOverridesSchema.safeParse([
|
||||
{ workspaceId: 'ws-1', logRetentionHours: 168 },
|
||||
])
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts null (forever) for a field', () => {
|
||||
const result = retentionOverridesSchema.safeParse([
|
||||
{ workspaceId: 'ws-1', logRetentionHours: null },
|
||||
])
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects two overrides for the same workspace', () => {
|
||||
const result = retentionOverridesSchema.safeParse([
|
||||
{ workspaceId: 'ws-1', logRetentionHours: 168 },
|
||||
{ workspaceId: 'ws-1', softDeleteRetentionHours: 720 },
|
||||
])
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('allows distinct workspaces', () => {
|
||||
const result = retentionOverridesSchema.safeParse([
|
||||
{ workspaceId: 'ws-1', logRetentionHours: 168 },
|
||||
{ workspaceId: 'ws-2', logRetentionHours: 720 },
|
||||
])
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects hours below the 24h minimum and above the ~5y maximum', () => {
|
||||
expect(
|
||||
retentionOverridesSchema.safeParse([{ workspaceId: 'ws-1', logRetentionHours: 1 }]).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
retentionOverridesSchema.safeParse([{ workspaceId: 'ws-1', logRetentionHours: 100000 }])
|
||||
.success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an empty workspaceId', () => {
|
||||
expect(
|
||||
retentionOverridesSchema.safeParse([{ workspaceId: '', logRetentionHours: 168 }]).success
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateOrganizationDataRetentionBodySchema', () => {
|
||||
it('accepts retentionOverrides alongside the org hours', () => {
|
||||
const result = updateOrganizationDataRetentionBodySchema.safeParse({
|
||||
logRetentionHours: 720,
|
||||
retentionOverrides: [{ workspaceId: 'ws-1', logRetentionHours: 168 }],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts a body with no retentionOverrides (field is optional)', () => {
|
||||
const result = updateOrganizationDataRetentionBodySchema.safeParse({ logRetentionHours: 720 })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { z } from 'zod'
|
||||
import {
|
||||
type PiiRedactionSettings,
|
||||
piiRedactionSettingsSchema,
|
||||
retentionOverridesSchema,
|
||||
workspaceIdSchema,
|
||||
} from '@/lib/api/contracts/primitives'
|
||||
import { organizationBillingDataSchema } from '@/lib/api/contracts/subscription'
|
||||
@@ -109,6 +110,7 @@ export const updateOrganizationDataRetentionBodySchema = z.object({
|
||||
softDeleteRetentionHours: organizationDataRetentionHoursSchema,
|
||||
taskCleanupHours: organizationDataRetentionHoursSchema,
|
||||
piiRedaction: piiRedactionSettingsSchema.optional(),
|
||||
retentionOverrides: retentionOverridesSchema.optional(),
|
||||
})
|
||||
|
||||
export type UpdateOrganizationDataRetentionBody = z.input<
|
||||
@@ -120,6 +122,7 @@ const organizationRetentionValuesSchema = z.object({
|
||||
softDeleteRetentionHours: z.number().int().nullable(),
|
||||
taskCleanupHours: z.number().int().nullable(),
|
||||
piiRedaction: piiRedactionSettingsSchema.nullable(),
|
||||
retentionOverrides: retentionOverridesSchema.nullable(),
|
||||
})
|
||||
|
||||
export type OrganizationRetentionValues = z.output<typeof organizationRetentionValuesSchema>
|
||||
|
||||
@@ -124,6 +124,41 @@ export const piiRedactionSettingsSchema = z.object({
|
||||
|
||||
export type PiiRedactionSettings = z.output<typeof piiRedactionSettingsSchema>
|
||||
|
||||
/** Retention hours bound: 1 day to ~5 years, in hours. */
|
||||
const retentionOverrideHoursSchema = z.number().int().min(24).max(43800).nullable().optional()
|
||||
|
||||
/**
|
||||
* A per-workspace override of the org retention hours. Each field is tri-state:
|
||||
* omitted = inherit the org value; a number = that workspace's retention in
|
||||
* hours; `null` = forever (never delete).
|
||||
*/
|
||||
export const retentionOverrideSchema = z.object({
|
||||
workspaceId: workspaceIdSchema,
|
||||
logRetentionHours: retentionOverrideHoursSchema,
|
||||
softDeleteRetentionHours: retentionOverrideHoursSchema,
|
||||
taskCleanupHours: retentionOverrideHoursSchema,
|
||||
})
|
||||
|
||||
export type RetentionOverride = z.output<typeof retentionOverrideSchema>
|
||||
|
||||
/**
|
||||
* Per-workspace retention overrides. Each workspace appears at most once —
|
||||
* resolution is workspace-override-then-org-default, so duplicate workspaces
|
||||
* would make the effective value depend on array order.
|
||||
*/
|
||||
export const retentionOverridesSchema = z
|
||||
.array(retentionOverrideSchema)
|
||||
.max(1000)
|
||||
.refine(
|
||||
(overrides) => {
|
||||
const ids = overrides.map((o) => o.workspaceId)
|
||||
return new Set(ids).size === ids.length
|
||||
},
|
||||
{ message: 'Each workspace may have at most one retention override.' }
|
||||
)
|
||||
|
||||
export type RetentionOverrides = z.output<typeof retentionOverridesSchema>
|
||||
|
||||
export const booleanQueryFlagSchema = z.preprocess(
|
||||
(value) => {
|
||||
if (typeof value === 'boolean') return value
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db } from '@sim/db'
|
||||
import type { WorkspaceMode } from '@sim/db/schema'
|
||||
import type { DataRetentionSettings, WorkspaceMode } from '@sim/db/schema'
|
||||
import { organization, workspace } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { tasks } from '@trigger.dev/sdk'
|
||||
@@ -7,6 +7,7 @@ import { and, asc, eq, gt, isNull } from 'drizzle-orm'
|
||||
import { getOrganizationSubscription } from '@/lib/billing/core/billing'
|
||||
import { getHighestPriorityPersonalSubscription } from '@/lib/billing/core/subscription'
|
||||
import { getPlanType, type PlanCategory } from '@/lib/billing/plan-helpers'
|
||||
import { type RetentionHoursKey, resolveEffectiveRetentionHours } from '@/lib/billing/retention'
|
||||
import { chunkArray } from '@/lib/cleanup/batch-delete'
|
||||
import { getJobQueue } from '@/lib/core/async-jobs'
|
||||
import { shouldExecuteInline } from '@/lib/core/async-jobs/config'
|
||||
@@ -26,15 +27,6 @@ const WORKSPACES_PER_CLEANUP_CHUNK = 500
|
||||
|
||||
export type CleanupJobType = 'cleanup-logs' | 'cleanup-soft-deletes' | 'cleanup-tasks'
|
||||
|
||||
export type OrganizationRetentionKey =
|
||||
| 'logRetentionHours'
|
||||
| 'softDeleteRetentionHours'
|
||||
| 'taskCleanupHours'
|
||||
|
||||
export type OrganizationRetentionSettings = {
|
||||
[K in OrganizationRetentionKey]: number | null
|
||||
}
|
||||
|
||||
export type NonEnterprisePlan = Exclude<PlanCategory, 'enterprise'>
|
||||
|
||||
const NON_ENTERPRISE_PLANS = ['free', 'pro', 'team'] as const satisfies readonly NonEnterprisePlan[]
|
||||
@@ -49,7 +41,7 @@ export interface CleanupJobPayload {
|
||||
}
|
||||
|
||||
interface CleanupJobConfig {
|
||||
key: OrganizationRetentionKey
|
||||
key: RetentionHoursKey
|
||||
defaults: Record<PlanCategory, number | null>
|
||||
}
|
||||
|
||||
@@ -58,7 +50,7 @@ interface WorkspaceCleanupScopeRow {
|
||||
billedAccountUserId: string
|
||||
organizationId: string | null
|
||||
workspaceMode: WorkspaceMode
|
||||
organizationSettings: OrganizationRetentionSettings | null
|
||||
organizationSettings: DataRetentionSettings | null
|
||||
}
|
||||
|
||||
const DAY = 24
|
||||
@@ -113,8 +105,7 @@ async function listActiveWorkspaceCleanupScopeRowsPage(
|
||||
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
organizationSettings:
|
||||
(row.organizationSettings as OrganizationRetentionSettings | null) ?? null,
|
||||
organizationSettings: (row.organizationSettings as DataRetentionSettings | null) ?? null,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -266,7 +257,11 @@ async function forEachCleanupChunk(
|
||||
|
||||
for (const row of rows) {
|
||||
if (planByWorkspaceId.get(row.id) !== 'enterprise') continue
|
||||
const hours = row.organizationSettings?.[config.key]
|
||||
const hours = resolveEffectiveRetentionHours({
|
||||
orgSettings: row.organizationSettings,
|
||||
workspaceId: row.id,
|
||||
key: config.key,
|
||||
})
|
||||
if (hours == null) continue
|
||||
workspaceCount++
|
||||
await emitChunk({
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
*/
|
||||
import type { DataRetentionSettings, PiiRedactionRule } from '@sim/db/schema'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveEffectivePiiRedaction } from '@/lib/billing/retention'
|
||||
import {
|
||||
resolveEffectivePiiRedaction,
|
||||
resolveEffectiveRetentionHours,
|
||||
} from '@/lib/billing/retention'
|
||||
|
||||
function settings(rules: PiiRedactionRule[]): DataRetentionSettings {
|
||||
return { piiRedaction: { rules } }
|
||||
@@ -83,3 +86,80 @@ describe('resolveEffectivePiiRedaction', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveEffectiveRetentionHours', () => {
|
||||
const orgSettings: DataRetentionSettings = {
|
||||
logRetentionHours: 720,
|
||||
softDeleteRetentionHours: 2160,
|
||||
taskCleanupHours: null,
|
||||
}
|
||||
|
||||
it('returns the org value when the workspace has no override', () => {
|
||||
expect(
|
||||
resolveEffectiveRetentionHours({ orgSettings, workspaceId: 'ws-1', key: 'logRetentionHours' })
|
||||
).toBe(720)
|
||||
})
|
||||
|
||||
it('returns the org value when an override exists but omits the field (inherit)', () => {
|
||||
expect(
|
||||
resolveEffectiveRetentionHours({
|
||||
orgSettings: { ...orgSettings, retentionOverrides: [{ workspaceId: 'ws-1' }] },
|
||||
workspaceId: 'ws-1',
|
||||
key: 'logRetentionHours',
|
||||
})
|
||||
).toBe(720)
|
||||
})
|
||||
|
||||
it('uses the override hours when the field is set to a number', () => {
|
||||
expect(
|
||||
resolveEffectiveRetentionHours({
|
||||
orgSettings: {
|
||||
...orgSettings,
|
||||
retentionOverrides: [{ workspaceId: 'ws-1', logRetentionHours: 168 }],
|
||||
},
|
||||
workspaceId: 'ws-1',
|
||||
key: 'logRetentionHours',
|
||||
})
|
||||
).toBe(168)
|
||||
})
|
||||
|
||||
it('uses null (forever) when the override field is explicitly null', () => {
|
||||
expect(
|
||||
resolveEffectiveRetentionHours({
|
||||
orgSettings: {
|
||||
...orgSettings,
|
||||
retentionOverrides: [{ workspaceId: 'ws-1', logRetentionHours: null }],
|
||||
},
|
||||
workspaceId: 'ws-1',
|
||||
key: 'logRetentionHours',
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('only applies the override to its own workspace', () => {
|
||||
const settingsWithOverride: DataRetentionSettings = {
|
||||
...orgSettings,
|
||||
retentionOverrides: [{ workspaceId: 'ws-1', logRetentionHours: 168 }],
|
||||
}
|
||||
expect(
|
||||
resolveEffectiveRetentionHours({
|
||||
orgSettings: settingsWithOverride,
|
||||
workspaceId: 'ws-2',
|
||||
key: 'logRetentionHours',
|
||||
})
|
||||
).toBe(720)
|
||||
})
|
||||
|
||||
it('returns null when neither an override nor an org value is configured', () => {
|
||||
expect(
|
||||
resolveEffectiveRetentionHours({ orgSettings, workspaceId: 'ws-1', key: 'taskCleanupHours' })
|
||||
).toBeNull()
|
||||
expect(
|
||||
resolveEffectiveRetentionHours({
|
||||
orgSettings: null,
|
||||
workspaceId: 'ws-1',
|
||||
key: 'logRetentionHours',
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -41,3 +41,27 @@ export function resolveEffectivePiiRedaction(params: {
|
||||
const language = coercePiiLanguage(rule?.language) ?? DEFAULT_PII_LANGUAGE
|
||||
return { enabled: true, entityTypes: types, language }
|
||||
}
|
||||
|
||||
export type RetentionHoursKey =
|
||||
| 'logRetentionHours'
|
||||
| 'softDeleteRetentionHours'
|
||||
| 'taskCleanupHours'
|
||||
|
||||
/**
|
||||
* Resolve the effective retention hours for one workspace and job type. A
|
||||
* workspace override wins when it sets the field (a number, or `null` for
|
||||
* forever); an omitted field inherits the org-level value. Returns `null` when
|
||||
* nothing is configured (the dispatcher treats `null` as "skip").
|
||||
*/
|
||||
export function resolveEffectiveRetentionHours(params: {
|
||||
orgSettings: DataRetentionSettings | null | undefined
|
||||
workspaceId: string
|
||||
key: RetentionHoursKey
|
||||
}): number | null {
|
||||
const override = params.orgSettings?.retentionOverrides?.find(
|
||||
(o) => o?.workspaceId === params.workspaceId
|
||||
)
|
||||
const overrideValue = override?.[params.key]
|
||||
if (overrideValue !== undefined) return overrideValue
|
||||
return params.orgSettings?.[params.key] ?? null
|
||||
}
|
||||
|
||||
+17
-1
@@ -1096,10 +1096,24 @@ export interface PiiRedactionRule {
|
||||
language?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A per-workspace override of the org-level retention hours. Each field is
|
||||
* tri-state: absent = inherit the org value; a number = that workspace's
|
||||
* retention in hours; `null` = forever (never delete). `workspaceId` is unique
|
||||
* across overrides.
|
||||
*/
|
||||
export interface RetentionOverride {
|
||||
workspaceId: string
|
||||
logRetentionHours?: number | null
|
||||
softDeleteRetentionHours?: number | null
|
||||
taskCleanupHours?: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Org-level data retention + governance settings. Retention-hours fall back to
|
||||
* plan defaults when unset. `piiRedaction.rules` are org-scoped; each rule
|
||||
* selects which workspaces it applies to.
|
||||
* selects which workspaces it applies to. `retentionOverrides` lets individual
|
||||
* workspaces override the org retention hours (enterprise only).
|
||||
*/
|
||||
export interface DataRetentionSettings {
|
||||
logRetentionHours?: number | null
|
||||
@@ -1109,6 +1123,8 @@ export interface DataRetentionSettings {
|
||||
piiRedaction?: {
|
||||
rules?: PiiRedactionRule[]
|
||||
} | null
|
||||
/** Per-workspace overrides of the retention hours above (enterprise only). */
|
||||
retentionOverrides?: RetentionOverride[] | null
|
||||
}
|
||||
|
||||
export const organization = pgTable('organization', {
|
||||
|
||||
Reference in New Issue
Block a user