Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions apps/docs/content/docs/en/enterprise/data-retention.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ description: Control how long execution logs, deleted resources, and copilot dat
import { FAQ } from '@/components/ui/faq'
import { Image } from '@/components/ui/image'

Data Retention lets workspace admins on Enterprise plans configure how long three categories of data are kept before they are permanently deleted. Each workspace in your organization can have its own independent configuration.
Data Retention lets organization owners and admins on Enterprise plans configure how long three categories of data are kept before they are permanently deleted. The configuration applies to every workspace in the organization.

---

Expand Down Expand Up @@ -58,9 +58,9 @@ Each setting is independent. You can configure a short log retention period alon

---

## Per-workspace configuration
## Organization-wide configuration

Retention is configured at the **workspace level**, not organization-wide. Each workspace in your organization can have a different configuration. Changes to one workspace's settings do not affect other workspaces.
Retention is configured at the **organization level**. A single configuration applies to every workspace in the organization — there are no per-workspace overrides.

---

Expand All @@ -73,7 +73,7 @@ By default, all three settings are unconfigured — no data is automatically del
<FAQ items={[
{
question: "Who can configure data retention settings?",
answer: "Only workspace admins can configure data retention settings. On Sim Cloud, the workspace must be on an Enterprise plan."
answer: "Only organization owners and admins can configure data retention settings. On Sim Cloud, the organization must be on an Enterprise plan."
},
{
question: "Is deletion immediate once the retention period expires?",
Expand All @@ -85,7 +85,7 @@ By default, all three settings are unconfigured — no data is automatically del
},
{
question: "Does the retention period apply to all workspaces in my organization?",
answer: "No. Retention is configured per workspace. Each workspace in your organization can have a different configuration."
answer: "Yes. Retention is configured once per organization and applies to every workspace in the organization."
},
{
question: "What happens if I shorten the retention period?",
Expand Down
214 changes: 214 additions & 0 deletions apps/sim/app/api/organizations/[id]/data-retention/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { member, organization } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import {
CLEANUP_CONFIG,
type OrganizationRetentionSettings,
} from '@/lib/billing/cleanup-dispatcher'
import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'

const logger = createLogger('DataRetentionAPI')

const MIN_HOURS = 24
const MAX_HOURS = 43800

const updateRetentionSchema = z.object({
logRetentionHours: z.number().int().min(MIN_HOURS).max(MAX_HOURS).nullable().optional(),
softDeleteRetentionHours: z.number().int().min(MIN_HOURS).max(MAX_HOURS).nullable().optional(),
taskCleanupHours: z.number().int().min(MIN_HOURS).max(MAX_HOURS).nullable().optional(),
})

function enterpriseDefaults(): OrganizationRetentionSettings {
return {
logRetentionHours: CLEANUP_CONFIG['cleanup-logs'].defaults.enterprise,
softDeleteRetentionHours: CLEANUP_CONFIG['cleanup-soft-deletes'].defaults.enterprise,
taskCleanupHours: CLEANUP_CONFIG['cleanup-tasks'].defaults.enterprise,
}
}

function normalizeConfigured(
settings: Partial<OrganizationRetentionSettings> | null | undefined
): OrganizationRetentionSettings {
return {
logRetentionHours: settings?.logRetentionHours ?? null,
softDeleteRetentionHours: settings?.softDeleteRetentionHours ?? null,
taskCleanupHours: settings?.taskCleanupHours ?? null,
}
}

/**
* GET /api/organizations/[id]/data-retention
* Returns the organization's data retention settings.
* Accessible by any member of the organization.
*/
export const GET = withRouteHandler(
async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

const { id: organizationId } = await params

const [memberEntry] = await db
.select({ id: member.id })
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
.limit(1)

if (!memberEntry) {
return NextResponse.json(
{ error: 'Forbidden - Not a member of this organization' },
{ status: 403 }
)
}

const [org] = await db
.select({ dataRetentionSettings: organization.dataRetentionSettings })
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)

if (!org) {
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
}

const isEnterprise = !isBillingEnabled || (await isOrganizationOnEnterprisePlan(organizationId))
const configured = normalizeConfigured(org.dataRetentionSettings)
const defaults = enterpriseDefaults()

return NextResponse.json({
success: true,
data: {
isEnterprise,
defaults,
configured,
effective: isEnterprise ? configured : defaults,
},
})
}
)

/**
* PUT /api/organizations/[id]/data-retention
* Updates the organization's data retention settings.
* Requires enterprise plan and owner/admin role.
*/
export const PUT = withRouteHandler(
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

const { id: organizationId } = await params

const body = await request.json()
const parsed = updateRetentionSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.errors[0]?.message ?? 'Invalid request body' },
{ status: 400 }
)
}

const [memberEntry] = await db
.select({ role: member.role })
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
.limit(1)

if (!memberEntry) {
return NextResponse.json(
{ error: 'Forbidden - Not a member of this organization' },
{ status: 403 }
)
}

if (memberEntry.role !== 'owner' && memberEntry.role !== 'admin') {
return NextResponse.json(
{ error: 'Forbidden - Only organization owners and admins can update data retention' },
{ status: 403 }
)
}

if (isBillingEnabled) {
const hasEnterprise = await isOrganizationOnEnterprisePlan(organizationId)
if (!hasEnterprise) {
return NextResponse.json(
{ error: 'Data Retention is available on Enterprise plans only' },
{ status: 403 }
)
}
}

const [currentOrg] = await db
.select({
name: organization.name,
dataRetentionSettings: organization.dataRetentionSettings,
})
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)

if (!currentOrg) {
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
}

const current = normalizeConfigured(currentOrg.dataRetentionSettings)
const merged: OrganizationRetentionSettings = { ...current }
if (parsed.data.logRetentionHours !== undefined) {
merged.logRetentionHours = parsed.data.logRetentionHours
}
if (parsed.data.softDeleteRetentionHours !== undefined) {
merged.softDeleteRetentionHours = parsed.data.softDeleteRetentionHours
}
if (parsed.data.taskCleanupHours !== undefined) {
merged.taskCleanupHours = parsed.data.taskCleanupHours
}

const [updated] = await db
.update(organization)
.set({ dataRetentionSettings: merged, updatedAt: new Date() })
.where(eq(organization.id, organizationId))
.returning({ dataRetentionSettings: organization.dataRetentionSettings })

if (!updated) {
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
}

recordAudit({
workspaceId: null,
actorId: session.user.id,
action: AuditAction.ORGANIZATION_UPDATED,
resourceType: AuditResourceType.ORGANIZATION,
resourceId: organizationId,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: currentOrg.name,
description: 'Updated data retention settings',
metadata: { changes: parsed.data },
request,
})

const configured = normalizeConfigured(updated.dataRetentionSettings)
const defaults = enterpriseDefaults()

return NextResponse.json({
success: true,
data: {
isEnterprise: true,
defaults,
configured,
effective: configured,
},
})
}
)
Loading
Loading