diff --git a/.env.example b/.env.example index df09e3a..aa8b3ff 100644 --- a/.env.example +++ b/.env.example @@ -52,3 +52,8 @@ ALLOWED_ORIGINS=https://craft.app # Generate: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" # See docs/field-encryption.md for key rotation procedure. FIELD_ENCRYPTION_KEY=your_64_char_hex_key_here + +# Analytics Retention +# Number of days to retain deployment_analytics rows (default: 90). +# Set to 0 to disable automatic deletion entirely. +ANALYTICS_RETENTION_DAYS=90 diff --git a/apps/backend/src/app/api/cron/purge-analytics/route.ts b/apps/backend/src/app/api/cron/purge-analytics/route.ts new file mode 100644 index 0000000..7bf7c61 --- /dev/null +++ b/apps/backend/src/app/api/cron/purge-analytics/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { analyticsService } from '@/services/analytics.service'; + +/** + * Cron: purge old deployment_analytics rows + * + * Deletes records from the deployment_analytics table that are older than + * ANALYTICS_RETENTION_DAYS (default: 90). This prevents the table from + * growing unbounded and degrading query performance over time. + * + * Set ANALYTICS_RETENTION_DAYS=0 to disable deletion entirely. + * + * Scheduled daily via vercel.json. Protected by CRON_SECRET. + */ +export async function GET(req: NextRequest) { + const cronSecret = process.env.CRON_SECRET; + if (cronSecret && req.headers.get('authorization') !== `Bearer ${cronSecret}`) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const retentionDays = parseInt(process.env.ANALYTICS_RETENTION_DAYS ?? '90', 10); + + try { + const deleted = await analyticsService.applyRetentionPolicy(retentionDays); + return NextResponse.json({ deleted }); + } catch (error: any) { + console.error('Error running analytics retention purge:', error); + return NextResponse.json({ error: error.message || 'Purge failed' }, { status: 500 }); + } +} diff --git a/apps/backend/src/services/analytics.service.ts b/apps/backend/src/services/analytics.service.ts index 722656a..0bc9034 100644 --- a/apps/backend/src/services/analytics.service.ts +++ b/apps/backend/src/services/analytics.service.ts @@ -157,6 +157,35 @@ export class AnalyticsService { }; } + /** + * Delete deployment_analytics rows older than `retentionDays` days. + * + * Called by the daily purge-analytics cron job. + * If retentionDays is 0 the policy is considered disabled and nothing is deleted. + * + * @param retentionDays - Number of days to retain records (0 = disabled) + * @returns Number of rows deleted + */ + async applyRetentionPolicy(retentionDays: number): Promise { + if (retentionDays === 0) { + return 0; + } + + const supabase = createClient(); + const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString(); + + const { error, count } = await supabase + .from('deployment_analytics') + .delete() + .lt('recorded_at', cutoff); + + if (error) { + throw new Error(`Failed to apply retention policy: ${error.message}`); + } + + return count ?? 0; + } + /** * Export analytics data as CSV */ diff --git a/apps/backend/tests/metrics/purge-analytics.test.ts b/apps/backend/tests/metrics/purge-analytics.test.ts new file mode 100644 index 0000000..e69de29 diff --git a/vercel.json b/vercel.json index 61d61ef..49bda70 100644 --- a/vercel.json +++ b/vercel.json @@ -11,6 +11,10 @@ { "path": "/api/cron/smoke-test", "schedule": "0 * * * *" + }, + { + "path": "/api/cron/purge-analytics", + "schedule": "0 2 * * *" } ] } \ No newline at end of file