Merge pull request #33 from ciphera-net/staging
[PULSE-58] Data retention settings in Site Settings
This commit is contained in:
11
CHANGELOG.md
11
CHANGELOG.md
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
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 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`
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
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}`
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user