[PULSE-58] Data retention settings in Site Settings #33

Merged
uz1mani merged 6 commits from staging into main 2026-02-21 19:03:25 +00:00
4 changed files with 108 additions and 3 deletions
Showing only changes of commit 1ae20dba4c - Show all commits

View File

@@ -13,6 +13,8 @@ import { PasswordInput } from '@ciphera-net/ui'
import { Select, Modal, Button } from '@ciphera-net/ui' import { Select, Modal, Button } from '@ciphera-net/ui'
import { APP_URL } from '@/lib/api/client' import { APP_URL } from '@/lib/api/client'
import { generatePrivacySnippet } from '@/lib/utils/privacySnippet' import { generatePrivacySnippet } from '@/lib/utils/privacySnippet'
import { getSubscription, type SubscriptionDetails } from '@/lib/api/billing'
import { getRetentionOptionsForPlan, formatRetentionMonths } from '@/lib/plans'
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
import { useAuth } from '@/lib/auth/context' import { useAuth } from '@/lib/auth/context'
import { import {
@@ -68,8 +70,11 @@ export default function SiteSettingsPage() {
// Performance insights setting // Performance insights setting
enable_performance_insights: false, enable_performance_insights: false,
// Bot and noise filtering // Bot and noise filtering
filter_bots: true filter_bots: true,
// Data retention
data_retention_months: 12
}) })
const [subscription, setSubscription] = useState<SubscriptionDetails | null>(null)
const [linkCopied, setLinkCopied] = useState(false) const [linkCopied, setLinkCopied] = useState(false)
const [snippetCopied, setSnippetCopied] = useState(false) const [snippetCopied, setSnippetCopied] = useState(false)
const [showVerificationModal, setShowVerificationModal] = useState(false) const [showVerificationModal, setShowVerificationModal] = useState(false)
@@ -83,6 +88,7 @@ export default function SiteSettingsPage() {
useEffect(() => { useEffect(() => {
loadSite() loadSite()
loadSubscription()
}, [siteId]) }, [siteId])
useEffect(() => { useEffect(() => {
@@ -91,6 +97,15 @@ export default function SiteSettingsPage() {
} }
greptile-apps[bot] commented 2026-02-21 18:48:59 +00:00 (Migrated from github.com)
Review

Clamping only enforced client-side, no backend validation visible

data_retention_months is clamped in the UI when subscription loads, but there's no indication of backend validation. If a user saves before subscription loads, or if they manipulate the request, they could set retention beyond their plan limits. The backend should enforce plan-based retention caps.

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/sites/[id]/settings/page.tsx
Line: 115-117

Comment:
Clamping only enforced client-side, no backend validation visible

`data_retention_months` is clamped in the UI when subscription loads, but there's no indication of backend validation. If a user saves before subscription loads, or if they manipulate the request, they could set retention beyond their plan limits. The backend should enforce plan-based retention caps.

How can I resolve this? If you propose a fix, please make it concise.
Clamping only enforced client-side, no backend validation visible `data_retention_months` is clamped in the UI when subscription loads, but there's no indication of backend validation. If a user saves before subscription loads, or if they manipulate the request, they could set retention beyond their plan limits. The backend should enforce plan-based retention caps. <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: app/sites/[id]/settings/page.tsx Line: 115-117 Comment: Clamping only enforced client-side, no backend validation visible `data_retention_months` is clamped in the UI when subscription loads, but there's no indication of backend validation. If a user saves before subscription loads, or if they manipulate the request, they could set retention beyond their plan limits. The backend should enforce plan-based retention caps. How can I resolve this? If you propose a fix, please make it concise. ````` </details>
uz1mani commented 2026-02-21 18:50:44 +00:00 (Migrated from github.com)
Review

Issue: Reviewer questioned whether the backend enforces plan-based retention caps.
Fix: No code change. The backend already validates in UpdateSiteHandler (internal/api/sites.go): it fetches org billing, derives MaxRetentionMonthsForPlan, and returns 400 when data_retention_months exceeds that value. Client-side clamping is only for UX; the server enforces the limit.
Why: The backend was implemented with validation from the start; this is a clarification rather than a missing check.

Issue: Reviewer questioned whether the backend enforces plan-based retention caps. Fix: No code change. The backend already validates in UpdateSiteHandler (internal/api/sites.go): it fetches org billing, derives MaxRetentionMonthsForPlan, and returns 400 when data_retention_months exceeds that value. Client-side clamping is only for UX; the server enforces the limit. Why: The backend was implemented with validation from the start; this is a clarification rather than a missing check.
}, [activeTab, siteId]) }, [activeTab, siteId])
const loadSubscription = async () => {
try {
const sub = await getSubscription()
setSubscription(sub)
} catch {
// * Non-critical; free tier assumed if billing unavailable
}
}
const loadSite = async () => { const loadSite = async () => {
try { try {
setLoading(true) setLoading(true)
@@ -111,7 +126,9 @@ export default function SiteSettingsPage() {
// Performance insights setting (default to false) // Performance insights setting (default to false)
enable_performance_insights: data.enable_performance_insights ?? false, enable_performance_insights: data.enable_performance_insights ?? false,
// Bot and noise filtering (default to true) // Bot and noise filtering (default to true)
filter_bots: data.filter_bots ?? true filter_bots: data.filter_bots ?? true,
// Data retention
data_retention_months: data.data_retention_months ?? 12
}) })
if (data.has_password) { if (data.has_password) {
setIsPasswordEnabled(true) setIsPasswordEnabled(true)
@@ -226,7 +243,9 @@ export default function SiteSettingsPage() {
// Performance insights setting // Performance insights setting
enable_performance_insights: formData.enable_performance_insights, enable_performance_insights: formData.enable_performance_insights,
// Bot and noise filtering // Bot and noise filtering
filter_bots: formData.filter_bots filter_bots: formData.filter_bots,
// Data retention
data_retention_months: formData.data_retention_months
}) })
toast.success('Site updated successfully') toast.success('Site updated successfully')
loadSite() loadSite()
@@ -821,6 +840,44 @@ export default function SiteSettingsPage() {
</div> </div>
</div> </div>
{/* Data Retention */}
<div className="space-y-4 pt-6 border-t border-neutral-100 dark:border-neutral-800">
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">Data Retention</h3>
<div className="p-4 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium text-neutral-900 dark:text-white">Keep raw event data for</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">
Events older than this are automatically deleted. Aggregated daily stats are kept permanently.
</p>
</div>
<Select
value={String(formData.data_retention_months)}
onChange={(v) => setFormData({ ...formData, data_retention_months: Number(v) })}
options={getRetentionOptionsForPlan(subscription?.plan_id).map(opt => ({
value: String(opt.value),
label: opt.label,
}))}
variant="input"
align="right"
className="min-w-[160px]"
/>
</div>
{subscription?.plan_id && subscription.plan_id !== 'free' && (
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-3">
Your {subscription.plan_id} plan supports up to {formatRetentionMonths(
getRetentionOptionsForPlan(subscription.plan_id).at(-1)?.value ?? 6
)} of data retention.
</p>
)}
{(!subscription?.plan_id || subscription.plan_id === 'free') && (
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-3">
Free plan supports up to 6 months. <a href="/pricing" className="text-brand-orange hover:underline">Upgrade</a> for longer retention.
</p>
)}
</div>
</div>
{/* Excluded Paths */} {/* Excluded Paths */}
<div className="space-y-4 pt-6 border-t border-neutral-100 dark:border-neutral-800"> <div className="space-y-4 pt-6 border-t border-neutral-100 dark:border-neutral-800">
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">Path Filtering</h3> <h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">Path Filtering</h3>

View File

@@ -21,6 +21,8 @@ export interface Site {
enable_performance_insights?: boolean enable_performance_insights?: boolean
// Bot and noise filtering // Bot and noise filtering
filter_bots?: boolean filter_bots?: boolean
// Data retention (months); 0 = keep forever
data_retention_months?: number
created_at: string created_at: string
updated_at: string updated_at: string
} }
@@ -47,6 +49,8 @@ export interface UpdateSiteRequest {
enable_performance_insights?: boolean enable_performance_insights?: boolean
// Bot and noise filtering // Bot and noise filtering
filter_bots?: boolean filter_bots?: boolean
// Data retention (months); 0 = keep forever
data_retention_months?: number
} }
export async function listSites(): Promise<Site[]> { export async function listSites(): Promise<Site[]> {

View File

@@ -40,3 +40,42 @@ export function getLimitForTierIndex(index: number): number {
if (index < 0 || index >= TRAFFIC_TIERS.length) return 100000 if (index < 0 || index >= TRAFFIC_TIERS.length) return 100000
return TRAFFIC_TIERS[index].value return TRAFFIC_TIERS[index].value
} }
/** Maximum data retention (months) allowed per plan. */
export function getMaxRetentionMonthsForPlan(planId: string | null | undefined): number {
switch (planId) {
case 'business': return 60
case 'team': return 24
case 'solo': return 12
default: return 6
}
}
/** Selectable retention options (months) for the given plan. */
export function getRetentionOptionsForPlan(planId: string | null | undefined): { label: string; value: number }[] {
const base = [
{ label: '1 month', value: 1 },
{ label: '3 months', value: 3 },
{ label: '6 months', value: 6 },
]
const solo = [...base, { label: '1 year', value: 12 }]
const team = [...solo, { label: '2 years', value: 24 }]
const business = [...team, { label: '3 years', value: 36 }, { label: '5 years', value: 60 }]
switch (planId) {
case 'business': return business
case 'team': return team
case 'solo': return solo
default: return base
}
}
/** Human-readable label for a retention value in months. */
export function formatRetentionMonths(months: number): string {
if (months === 0) return 'Forever'
if (months === 1) return '1 month'
if (months < 12) return `${months} months`
const years = months / 12
if (Number.isInteger(years)) return years === 1 ? '1 year' : `${years} years`
return `${months} months`
}

View File

@@ -1,4 +1,5 @@
import type { Site } from '@/lib/api/sites' import type { Site } from '@/lib/api/sites'
import { formatRetentionMonths } from '@/lib/plans'
const DOCS_URL = const DOCS_URL =
(typeof process !== 'undefined' && process.env?.NEXT_PUBLIC_APP_URL) (typeof process !== 'undefined' && process.env?.NEXT_PUBLIC_APP_URL)
@@ -22,6 +23,7 @@ export function generatePrivacySnippet(site: Site): string {
const screen = site.collect_screen_resolution ?? true const screen = site.collect_screen_resolution ?? true
greptile-apps[bot] commented 2026-02-21 18:58:09 +00:00 (Migrated from github.com)
Review

Mismatched default retention value

The fallback here is ?? 12 (12 months), but the settings page (page.tsx:146) defaults to ?? 6 (6 months). For existing sites that haven't saved a data_retention_months value yet, the privacy snippet will claim "Raw event data is automatically deleted after 1 year" while the settings page displays and saves "6 months."

These should use the same default to avoid misleading the user's privacy policy text.

  const retentionMonths = site.data_retention_months ?? 6
Prompt To Fix With AI
This is a comment left during a code review.
Path: lib/utils/privacySnippet.ts
Line: 26

Comment:
**Mismatched default retention value**

The fallback here is `?? 12` (12 months), but the settings page (`page.tsx:146`) defaults to `?? 6` (6 months). For existing sites that haven't saved a `data_retention_months` value yet, the privacy snippet will claim "Raw event data is automatically deleted after 1 year" while the settings page displays and saves "6 months."

These should use the same default to avoid misleading the user's privacy policy text.

```suggestion
  const retentionMonths = site.data_retention_months ?? 6
```

How can I resolve this? If you propose a fix, please make it concise.
**Mismatched default retention value** The fallback here is `?? 12` (12 months), but the settings page (`page.tsx:146`) defaults to `?? 6` (6 months). For existing sites that haven't saved a `data_retention_months` value yet, the privacy snippet will claim "Raw event data is automatically deleted after 1 year" while the settings page displays and saves "6 months." These should use the same default to avoid misleading the user's privacy policy text. ```suggestion const retentionMonths = site.data_retention_months ?? 6 ``` <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: lib/utils/privacySnippet.ts Line: 26 Comment: **Mismatched default retention value** The fallback here is `?? 12` (12 months), but the settings page (`page.tsx:146`) defaults to `?? 6` (6 months). For existing sites that haven't saved a `data_retention_months` value yet, the privacy snippet will claim "Raw event data is automatically deleted after 1 year" while the settings page displays and saves "6 months." These should use the same default to avoid misleading the user's privacy policy text. ```suggestion const retentionMonths = site.data_retention_months ?? 6 ``` How can I resolve this? If you propose a fix, please make it concise. ````` </details>
uz1mani commented 2026-02-21 18:59:05 +00:00 (Migrated from github.com)
Review

Issue: The privacy snippet defaulted to 12 months while the settings page defaults to 6, so unsaved sites could show "1 year" in the snippet but "6 months" in the UI.
Fix: Use ?? 6 to match the settings page default.
Why: Keeps defaults aligned so the generated privacy text matches what the user sees in settings.

Issue: The privacy snippet defaulted to 12 months while the settings page defaults to 6, so unsaved sites could show "1 year" in the snippet but "6 months" in the UI. Fix: Use `?? 6` to match the settings page default. Why: Keeps defaults aligned so the generated privacy text matches what the user sees in settings.
const perf = site.enable_performance_insights ?? false const perf = site.enable_performance_insights ?? false
const filterBots = site.filter_bots ?? true const filterBots = site.filter_bots ?? true
const retentionMonths = site.data_retention_months ?? 12
const parts: string[] = [] const parts: string[] = []
if (paths) parts.push('which pages are viewed') if (paths) parts.push('which pages are viewed')
@@ -44,6 +46,9 @@ export function generatePrivacySnippet(site: Site): string {
if (filterBots) { if (filterBots) {
p2 += 'Known bots and referrer spam are excluded from our analytics. ' p2 += 'Known bots and referrer spam are excluded from our analytics. '
} }
if (retentionMonths > 0) {
p2 += `Raw event data is automatically deleted after ${formatRetentionMonths(retentionMonths)}. `
}
p2 += `Data is processed in a privacy-preserving way and is not used to identify individuals. For more information, see Pulse's documentation: ${DOCS_URL}` p2 += `Data is processed in a privacy-preserving way and is not used to identify individuals. For more information, see Pulse's documentation: ${DOCS_URL}`
return `${p1}\n\n${p2}` return `${p1}\n\n${p2}`