import { formatNumber, t } from '@/i18n' import { formatDateRange } from '@/lib/activityTime' export const ACTIVITY_CATEGORIES = [ 'articles', 'media', 'users', 'api_keys', 'auth', 'system', ] as const export type ActivityCategory = (typeof ACTIVITY_CATEGORIES)[number] export type ActivityOutcome = 'success' | 'failed' export type OutcomeFilter = 'all' | ActivityOutcome export type ActorType = 'user' | 'api_key' | 'system' | 'anonymous' export type ActivitySource = 'web' | 'api_key' | 'system' export type RangeKey = 'today' | '7d' | '30d' | 'custom' export const PAGE_SIZES = [25, 50, 100] as const export const DEFAULT_PAGE_SIZE = 25 export const DEFAULT_RANGE: RangeKey = '7d' export const RANGE_KEYS: RangeKey[] = ['today', '7d', '30d', 'custom'] export const OUTCOME_FILTERS: OutcomeFilter[] = ['all', 'success', 'failed'] export interface ActivityActor { type: ActorType id: number | null name: string } export interface ActivityTarget { type: string id: string name: string exists: boolean } export type ActivityChange = | { field: string; kind: 'text'; before: string; after: string } | { field: string; kind: 'body'; delta: number } export interface ActivityItem { id: number created_at: string action: string category: ActivityCategory actor: ActivityActor target: ActivityTarget | null outcome: ActivityOutcome failure_reason: string | null security: boolean source: ActivitySource ip: string | null user_agent: string | null params: Record changes: ActivityChange[] } export interface ActivityPage { items: ActivityItem[] total: number page: number size: number } export interface ActivitySummary { total: number previous_total: number active_people: { users: number; api_keys: number } failed: number security: number series: { total: number[] active: number[] failed: number[] security: number[] } } export interface ActivityFilters { q: string actor: string actorLabel: string category: ActivityCategory | '' outcome: OutcomeFilter security: boolean range: RangeKey from: string to: string } export type ChipKey = 'q' | 'actor' | 'category' | 'outcome' | 'security' | 'range' export interface ActivityChip { key: ChipKey label: string } export function defaultFilters(): ActivityFilters { return { q: '', actor: '', actorLabel: '', category: '', outcome: 'all', security: false, range: DEFAULT_RANGE, from: '', to: '', } } export function summaryRangeChanged(a: ActivityFilters, b: ActivityFilters) { return a.range !== b.range || a.from !== b.from || a.to !== b.to } function rangeParams(range: ExportRange, from: string, to: string) { const params = new URLSearchParams({ range }) if (range === 'custom') { params.set('from', from) params.set('to', to) } return params } function filterParams(filters: ActivityFilters) { const params = new URLSearchParams() if (filters.q) params.set('q', filters.q) if (filters.actor) params.set('actor', filters.actor) if (filters.category) params.set('category', filters.category) if (filters.outcome !== 'all') params.set('outcome', filters.outcome) if (filters.security) params.set('security', 'true') rangeParams(filters.range, filters.from, filters.to).forEach((value, key) => params.set(key, value), ) return params } export function listQuery(filters: ActivityFilters, page: number, size: number, withActor = true) { const params = filterParams(withActor ? filters : { ...filters, actor: '' }) params.set('page', String(page)) params.set('size', String(size)) return params.toString() } export function exportQuery(filters: ActivityFilters) { return filterParams(filters).toString() } export const OWNERS_ONLY_COPY = 'Only owners and admins can export all activity.' export const NOTHING_TO_EXPORT_COPY = 'Nothing to export. No events match these filters.' export type ExportRange = 'all' | RangeKey export const EXPORT_RANGE_KEYS: ExportRange[] = ['all', 'today', '7d', '30d', 'custom'] export function exportRangeLabel(range: ExportRange) { return range === 'all' ? t('All time') : rangeLabel(range) } export function exportAllQuery(range: ExportRange, from: string, to: string) { return rangeParams(range, from, to).toString() } export function isExportRangeValid(range: ExportRange, from: string, to: string) { return range !== 'custom' || (from !== '' && to !== '' && from <= to) } function localDateStamp() { const now = new Date() const month = String(now.getMonth() + 1).padStart(2, '0') const day = String(now.getDate()).padStart(2, '0') return `${now.getFullYear()}-${month}-${day}` } export function currentViewFileName() { return `activity-${localDateStamp()}.csv` } export function allActivityFileName() { return `activity-all-${localDateStamp()}.csv` } export function readBlobText(blob: Blob) { return new Promise((resolve, reject) => { const reader = new FileReader() reader.onload = () => resolve(String(reader.result)) reader.onerror = () => reject(reader.error) reader.readAsText(blob) }) } export function isHeaderOnlyCsv(text: string) { return text.trim().split(/\r?\n/).length <= 1 } export function summaryQuery(filters: ActivityFilters) { return rangeParams(filters.range, filters.from, filters.to).toString() } export function categoryLabel(category: ActivityCategory) { switch (category) { case 'articles': return t('Articles') case 'media': return t('Media') case 'users': return t('Users and invites') case 'api_keys': return t('API keys') case 'auth': return t('Authentication') case 'system': return t('System') } } export function categoryIcon(category: ActivityCategory) { switch (category) { case 'articles': return 'i-lucide-file-text' case 'media': return 'i-lucide-image' case 'users': return 'i-lucide-users' case 'api_keys': return 'i-lucide-key-round' case 'auth': return 'i-lucide-shield-check' case 'system': return 'i-lucide-server-cog' } } export function outcomeFilterLabel(outcome: OutcomeFilter) { switch (outcome) { case 'all': return t('All') case 'success': return t('Succeeded') case 'failed': return t('Failed') } } export function outcomeOptions() { return OUTCOME_FILTERS.map((value) => ({ value, label: outcomeFilterLabel(value), })) } export function outcomeBadge(outcome: ActivityOutcome) { return outcome === 'success' ? { tone: 'ok' as const, label: t('Succeeded') } : { tone: 'fail' as const, label: t('Failed') } } export function rowSurface(selected: boolean, security: boolean) { return [selected ? 'bg-elevated' : 'bg-default', security ? 'shadow-flag' : ''] } export function rangeLabel(range: RangeKey) { switch (range) { case 'today': return t('Today') case '7d': return t('7 days') case '30d': return t('30 days') case 'custom': return t('Custom') } } export function activeChips(filters: ActivityFilters): ActivityChip[] { const chips: ActivityChip[] = [] if (filters.q) chips.push({ key: 'q', label: t('Search: {0}', [filters.q]) }) if (filters.actor) chips.push({ key: 'actor', label: t('Person: {0}', [filters.actorLabel]), }) if (filters.category) chips.push({ key: 'category', label: t('Category: {0}', [categoryLabel(filters.category)]), }) if (filters.outcome !== 'all') chips.push({ key: 'outcome', label: t('Outcome: {0}', [outcomeFilterLabel(filters.outcome)]), }) if (filters.security) chips.push({ key: 'security', label: t('Type: {0}', [t('Security events')]), }) if (filters.range !== DEFAULT_RANGE) { const value = filters.range === 'custom' ? formatDateRange(filters.from, filters.to) : rangeLabel(filters.range) chips.push({ key: 'range', label: t('Dates: {0}', [value]) }) } return chips } export function clearChip(filters: ActivityFilters, key: ChipKey): ActivityFilters { const defaults = defaultFilters() switch (key) { case 'q': return { ...filters, q: '' } case 'actor': return { ...filters, actor: '', actorLabel: '' } case 'category': return { ...filters, category: '' } case 'outcome': return { ...filters, outcome: 'all' } case 'security': return { ...filters, security: false } case 'range': return { ...filters, range: defaults.range, from: '', to: '' } } } export function initials(name: string) { return name .split(/\s+/) .filter(Boolean) .slice(0, 2) .map((word) => Array.from(word)[0]) .join('') .toUpperCase() } export function sourceLabel(source: ActivitySource) { switch (source) { case 'web': return t('Web app') case 'api_key': return t('API key') case 'system': return t('System task') } } export function targetTypeLabel(type: string) { switch (type) { case 'article': return t('Article') case 'image': return t('Image') case 'user': return t('User') case 'invite': return t('Invite') case 'api_key': return t('API key') case 'release': return t('Release') default: return type } } export function targetLink(target: ActivityTarget | null) { if (!target?.exists) return null switch (target.type) { case 'article': return `/article/${target.id}/edit` case 'image': return '/media' case 'user': case 'invite': return '/users' case 'api_key': return '/integration/api-keys' default: return null } } export function fieldLabel(field: string) { switch (field) { case 'title': return t('Title') case 'online': return t('Publish state') case 'body': return t('Body') case 'filename': return t('File name') case 'display_name': return t('Display name') default: return field } } export function changeValue(field: string, value: string) { if (field !== 'online') return value if (value === 'false') return t('Draft') if (value === 'true') return t('Published') return value } export function bodyDeltaLabel(delta: number) { const sign = delta < 0 ? '-' : '+' const amount = `${sign}${formatNumber(Math.abs(delta))}` return Math.abs(delta) === 1 ? t('Body edited ({0} character)', [amount]) : t('Body edited ({0} characters)', [amount]) }