[PULSE-58] Data retention settings in Site Settings #33
@@ -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() {
|
|||||||
}
|
}
|
||||||
|
|
|||||||
}, [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>
|
||||||
|
|||||||
@@ -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[]> {
|
||||||
|
|||||||
39
lib/plans.ts
@@ -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`
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
Mismatched default retention value The fallback here is These should use the same default to avoid misleading the user's privacy policy text. Prompt To Fix With AI**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>
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. 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}`
|
||||||
|
|||||||
Clamping only enforced client-side, no backend validation visible
data_retention_monthsis 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
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.