[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
6 changed files with 149 additions and 5 deletions

View File

@@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
## [Unreleased] ## [Unreleased]
## [0.9.0-alpha] - 2026-02-21
### Added
- **Data retention settings (PULSE-58).** Site owners can choose how long raw event data is kept (1 month to 3 years depending on plan). Events older than the retention period are automatically deleted every 24 hours. Aggregated daily stats are preserved so historical charts remain intact.
- **Data Retention section in Site Settings.** Under Data & Privacy, a dropdown lets you set retention; options are capped by your plan (free: up to 6 months, solo: 1 year, team: 2 years, business: 3 years).
- **Privacy snippet includes retention.** The generated privacy policy text now mentions when raw data is automatically deleted.
## [0.8.0-alpha] - 2026-02-20 ## [0.8.0-alpha] - 2026-02-20
### Added ### Added
@@ -104,7 +112,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
--- ---
[Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.8.0-alpha...HEAD [Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.9.0-alpha...HEAD
[0.9.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.8.0-alpha...v0.9.0-alpha
[0.8.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.7.0-alpha...v0.8.0-alpha [0.8.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.7.0-alpha...v0.8.0-alpha
[0.7.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.6.0-alpha...v0.7.0-alpha [0.7.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.6.0-alpha...v0.7.0-alpha
[0.6.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.5.1-alpha...v0.6.0-alpha [0.6.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.5.1-alpha...v0.6.0-alpha

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,12 @@ 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 (6 = free-tier max; safe default)
data_retention_months: 6
}) })
const [subscription, setSubscription] = useState<SubscriptionDetails | null>(null)
const [subscriptionLoadFailed, setSubscriptionLoadFailed] = useState(false)
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 +89,7 @@ export default function SiteSettingsPage() {
useEffect(() => { useEffect(() => {
loadSite() loadSite()
loadSubscription()
}, [siteId]) }, [siteId])
useEffect(() => { useEffect(() => {
@@ -91,6 +98,30 @@ export default function SiteSettingsPage() {
} }
}, [activeTab, siteId]) }, [activeTab, siteId])
const loadSubscription = async () => {
try {
setSubscriptionLoadFailed(false)
const sub = await getSubscription()
setSubscription(sub)
} catch (e) {
setSubscriptionLoadFailed(true)
toast.error(getAuthErrorMessage(e as Error) || 'Could not load plan limits. Showing default options.')
}
}
// * Snap data_retention_months to nearest valid option when subscription loads
useEffect(() => {
if (!subscription) return
const opts = getRetentionOptionsForPlan(subscription.plan_id)
const values = opts.map(o => o.value)
const maxVal = Math.max(...values)
setFormData(prev => {
if (values.includes(prev.data_retention_months)) return prev
const bestFit = values.filter(v => v <= prev.data_retention_months).pop() ?? maxVal
return { ...prev, data_retention_months: Math.min(bestFit, maxVal) }
})
}, [subscription])
const loadSite = async () => { const loadSite = async () => {
try { try {
setLoading(true) setLoading(true)
@@ -111,7 +142,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 (default 6 = free-tier max; avoids flash-then-clamp for existing sites)
data_retention_months: data.data_retention_months ?? 6
}) })
if (data.has_password) { if (data.has_password) {
setIsPasswordEnabled(true) setIsPasswordEnabled(true)
@@ -226,7 +259,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 +856,58 @@ 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>
{subscriptionLoadFailed && (
<div className="p-3 rounded-xl border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 flex items-center justify-between gap-3">
<p className="text-sm text-amber-800 dark:text-amber-200">
Plan limits could not be loaded. Options shown may be limited.
</p>
<button
type="button"
onClick={loadSubscription}
className="shrink-0 text-sm font-medium text-amber-800 dark:text-amber-200 hover:underline"
>
Retry
</button>
</div>
)}
<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 36
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 }]
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 ?? 6
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}`

View File

@@ -1,6 +1,6 @@
{ {
"name": "pulse-frontend", "name": "pulse-frontend",
"version": "0.8.0-alpha", "version": "0.9.0-alpha",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",