diff --git a/.gitignore b/.gitignore index af23a74142..3b1ce5d009 100755 --- a/.gitignore +++ b/.gitignore @@ -70,6 +70,8 @@ tsconfig.tsbuildinfo .jest/secret-env.js infra/pgdata/ +infra/metabase-plugins/ +tmp/ + +details.md -tmp -details.md \ No newline at end of file diff --git a/client/containers/AdminDashboard/AdminDashboard.tsx b/client/containers/AdminDashboard/AdminDashboard.tsx index f1b7974551..8aeb766499 100644 --- a/client/containers/AdminDashboard/AdminDashboard.tsx +++ b/client/containers/AdminDashboard/AdminDashboard.tsx @@ -11,7 +11,7 @@ type Props = { const AdminDashboard = (props: Props) => { const { impactData } = props; const { baseToken } = impactData; - const dashUrl = `https://metabase.pubpub.org/embed/dashboard/${baseToken}#bordered=false&titled=false`; + const dashUrl = `http://localhost:3030/embed/dashboard/${baseToken}#bordered=false&titled=false`; const getOffset = (width) => { return width < 960 ? 45 : 61; }; diff --git a/client/containers/App/paths.ts b/client/containers/App/paths.ts index 190f89794c..a0f3f060eb 100644 --- a/client/containers/App/paths.ts +++ b/client/containers/App/paths.ts @@ -13,6 +13,7 @@ import { DashboardEdges, DashboardFacets, DashboardImpact, + DashboardImpact2, DashboardMembers, DashboardPage, DashboardPages, @@ -83,6 +84,10 @@ export default (viewData, locationData, chunkName) => { ActiveComponent: DashboardImpact, isDashboard: true, }, + DashboardImpact2: { + ActiveComponent: DashboardImpact2, + isDashboard: true, + }, DashboardMembers: { ActiveComponent: DashboardMembers, isDashboard: true, diff --git a/client/containers/DashboardImpact/DashboardImpact.tsx b/client/containers/DashboardImpact/DashboardImpact.tsx index f87aa4792c..e4cd5786b0 100644 --- a/client/containers/DashboardImpact/DashboardImpact.tsx +++ b/client/containers/DashboardImpact/DashboardImpact.tsx @@ -1,219 +1,807 @@ -import React from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { Button, Intent, PopoverInteractionKind, Position, Tooltip } from '@blueprintjs/core'; -import IframeResizer from 'iframe-resizer-react'; +import { Button, ButtonGroup, Callout, NonIdealState, Spinner } from '@blueprintjs/core'; +import { + Area, + AreaChart, + CartesianGrid, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; import { DashboardFrame } from 'components'; import { usePageContext } from 'utils/hooks'; import './dashboardImpact.scss'; -type Props = { - impactData: { - baseToken: string; - benchmarkToken?: string | null; - newToken?: string | null; +// ─── types ─────────────────────────────────────────────────────────────────── + +type DailyRow = { date: string; pageViews: number; uniquePageViews: number }; +type CountryRow = { country: string; countryCode: string; count: number }; +type ReferrerRow = { referrer: string; count: number }; +type CampaignRow = { campaign: string; count: number }; +type DeviceRow = { device_type: string; count: number }; +type TopPageRow = { pageTitle: string; path: string; count: number }; +type TopPubRow = { + pubTitle: string; + pubSlug: string | null; + pubId: string; + views: number; + downloads: number; +}; +type TopCollectionRow = { + collectionTitle: string; + collectionSlug: string | null; + collectionId: string; + count: number; +}; + +type AnalyticsData = { + totalPageViews: number; + totalUniqueVisits: number; + totalDownloads: number; + daily: DailyRow[]; + countries: CountryRow[]; + topPubs: TopPubRow[]; + topPages: TopPageRow[]; + topCollections: TopCollectionRow[]; + referrers: ReferrerRow[]; + campaigns: CampaignRow[]; + devices: DeviceRow[]; +}; + +type QuickRange = '30d' | '90d' | '1yr' | '2yr'; + +// ─── constants ─────────────────────────────────────────────────────────────── + +const MAX_RANGE_DAYS = 365 * 2; // 2 years + +/** + * Temporary: set to `true` to show the migration banner. Remove this constant + * and the block once the Redshift import has completed in production. + */ +const SHOW_MIGRATION_BANNER = true; + +// ─── helpers ───────────────────────────────────────────────────────────────── + +const countryDisplayNames = new Intl.DisplayNames(['en'], { type: 'region' }); +function countryName(code: string): string { + if (!code || code.length !== 2) return code || 'Unknown'; + try { + return countryDisplayNames.of(code.toUpperCase()) ?? code; + } catch { + return code; + } +} + +const fmt = (n: number): string => { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return n.toLocaleString(); +}; + +const fmtDate = (s: string): string => { + const d = new Date(s + 'T00:00:00'); + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); +}; + +const toIso = (d: Date): string => d.toISOString().slice(0, 10); + +const daysAgo = (n: number): string => { + const d = new Date(); + d.setDate(d.getDate() - n); + return toIso(d); +}; + +const quickRangeDates = (r: QuickRange): { start: string; end: string } => { + const days = r === '30d' ? 30 : r === '90d' ? 90 : r === '1yr' ? 365 : 730; + return { start: daysAgo(days), end: toIso(new Date()) }; +}; + +/** Clamp a date range to at most MAX_RANGE_DAYS. */ +const clampRange = (start: string, end: string): { start: string; end: string } => { + const s = new Date(start); + const e = new Date(end); + const diff = (e.getTime() - s.getTime()) / (1000 * 60 * 60 * 24); + if (diff > MAX_RANGE_DAYS) { + const clamped = new Date(e); + clamped.setDate(clamped.getDate() - MAX_RANGE_DAYS); + return { start: toIso(clamped), end }; + } + if (diff < 0) { + return { start: end, end: start }; + } + return { start, end }; +}; + +const COLORS = { pageViews: '#15B371', unique: '#2B95D6' }; + +// ─── CSV export ────────────────────────────────────────────────────────────── + +let lastDownloadTs = 0; +function downloadCsv(filename: string, headers: string[], rows: string[][]) { + const now = Date.now(); + if (now - lastDownloadTs < 1000) return; + lastDownloadTs = now; + const escapeCsv = (v: string) => { + if (v.includes(',') || v.includes('"') || v.includes('\n')) { + return `"${v.replace(/"/g, '""')}"`; + } + return v; }; + const csv = [headers.join(','), ...rows.map((r) => r.map(escapeCsv).join(','))].join('\n'); + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.style.display = 'none'; + document.body.appendChild(a); + a.dispatchEvent(new MouseEvent('click', { bubbles: false, cancelable: false, view: window })); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 100); +} + +// ─── compact table with pagination ─────────────────────────────────────────── + +const PAGE_SIZE = 15; + +const CompactTable = ({ + title, + rows, + columns, + onExport, + emptyMessage, +}: { + title: string; + rows: Array>; + columns: Array<{ + key: string; + label: string; + align?: 'left' | 'right'; + flex?: boolean; + render?: (v: any, row: any) => React.ReactNode; + }>; + onExport?: () => void; + emptyMessage?: string; +}) => { + const [page, setPage] = useState(0); + const totalPages = Math.max(1, Math.ceil(rows.length / PAGE_SIZE)); + const start = page * PAGE_SIZE; + const visible = rows.slice(start, start + PAGE_SIZE); + const showPagination = rows.length > PAGE_SIZE; + + return ( +
+
+

{title}

+ {onExport && rows.length > 0 && ( + + )} +
+ {rows.length === 0 && emptyMessage ? ( + <> + + + + {columns.map((c) => ( + + ))} + + +
+ {c.label} +
+
{emptyMessage}
+ + ) : ( + <> +
+ + + + {columns.map((c) => ( + + ))} + + + + {visible.map((row) => ( + row[c.key]).join('|')}> + {columns.map((c) => ( + + ))} + + ))} + +
+ {c.label} +
+ {c.render ? c.render(row[c.key], row) : row[c.key]} +
+
+ {showPagination && ( +
+ + {start + 1}–{Math.min(start + PAGE_SIZE, rows.length)} of{' '} + {rows.length} + + + +
+ )} + + )} +
+ ); }; -const DashboardImpact = (props: Props) => { - const { impactData } = props; - const { baseToken, benchmarkToken, newToken } = impactData; - const { scopeData } = usePageContext(); +const StatCard = ({ label, value, color }: { label: string; value: string; color: string }) => ( +
+
{value}
+
{label}
+
+); + +// ─── component ─────────────────────────────────────────────────────────────── + +const DashboardImpact = () => { + const { scopeData, communityData } = usePageContext(); const { - elements: { activeTargetType, activeTargetName, activeTarget }, + elements: { activeTargetType, activePub, activeCollection }, activePermissions: { canView }, } = scopeData; - const displayDataWarning = activeTarget?.createdAt < '2020-04-29'; - const isCollection = activeTargetType === 'collection'; - const genUrl = (token) => { - return `https://metabase.pubpub.org/embed/dashboard/${token}#bordered=false&titled=false`; + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Date range state — quick-select buttons set both, custom inputs clear activeQuick + const [startDate, setStartDate] = useState(() => daysAgo(90)); + const [endDate, setEndDate] = useState(() => toIso(new Date())); + const [activeQuick, setActiveQuick] = useState('90d'); + + const handleQuickRange = (r: QuickRange) => { + const { start, end } = quickRangeDates(r); + setStartDate(start); + setEndDate(end); + setActiveQuick(r); + }; + + const handleStartChange = (val: string) => { + const { start, end } = clampRange(val, endDate); + setStartDate(start); + setEndDate(end); + setActiveQuick(null); }; - const getOffset = (width) => { - return width < 960 ? 45 : 61; + + const handleEndChange = (val: string) => { + const { start, end } = clampRange(startDate, val); + setStartDate(start); + setEndDate(end); + setActiveQuick(null); }; - const [showHistoricalAnalytics, setShowHistoricalAnalytics] = React.useState(false); + // Build scope query params + const scopeParams = useMemo(() => { + if (activeTargetType === 'pub' && activePub) { + return `&pubId=${encodeURIComponent(activePub.id)}`; + } + if (activeTargetType === 'collection' && activeCollection) { + return `&collectionId=${encodeURIComponent(activeCollection.id)}`; + } + return ''; + }, [activeTargetType, activePub, activeCollection]); + + const fetchData = useCallback( + async (start: string, end: string) => { + setLoading(true); + setError(null); + try { + const res = await fetch( + `/api/analytics-impact?startDate=${start}&endDate=${end}${scopeParams}`, + ); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `Request failed (${res.status})`); + } + const json: AnalyticsData = await res.json(); + setData(json); + } catch (err: any) { + setError(err.message ?? 'Failed to load analytics'); + } finally { + setLoading(false); + } + }, + [scopeParams], + ); + + useEffect(() => { + if (canView) fetchData(startDate, endDate); + }, [startDate, endDate, canView, fetchData]); + + const chartData = useMemo( + () => (data ? data.daily.map((d) => ({ ...d, label: fmtDate(d.date) })) : []), + [data], + ); + + // Filter out referrers from the community's own domain, localhost, and pubpub.org + const filteredReferrers = useMemo(() => { + if (!data) return []; + const ownHosts: string[] = ['localhost', 'pubpub.org', 'www.pubpub.org']; + if (communityData.subdomain) { + ownHosts.push(`${communityData.subdomain}.pubpub.org`); + } + if (communityData.domain) { + ownHosts.push(communityData.domain); + } + return data.referrers.filter((r) => { + try { + const url = new URL(r.referrer); + const host = url.hostname.replace(/^www\./, ''); + if (url.hostname === 'localhost' || url.hostname.startsWith('localhost:')) + return false; + return !ownHosts.some((h) => host === h || host === `www.${h}`); + } catch { + // 'Direct' or other non-URL values — keep them + return true; + } + }); + }, [data, communityData.subdomain, communityData.domain]); + + // ── CSV export handlers ────────────────────────────────────────────── + + const exportCountries = () => { + if (!data) return; + downloadCsv( + 'countries.csv', + ['Country', 'Country Code', 'Pageviews'], + data.countries.map((c) => [ + c.countryCode ? countryName(c.countryCode) : c.country, + c.countryCode, + String(c.count), + ]), + ); + }; + + const exportTopPubs = () => { + if (!data) return; + downloadCsv( + 'top-pubs.csv', + ['Title', 'URL', 'Pub ID', 'Views', 'Downloads'], + data.topPubs.map((p) => [ + p.pubTitle, + p.pubSlug ? `/pub/${p.pubSlug}` : '', + p.pubId, + String(p.views), + String(p.downloads), + ]), + ); + }; + + const exportTopPages = () => { + if (!data) return; + downloadCsv( + 'top-pages.csv', + ['Page Title', 'URL', 'Pageviews'], + data.topPages.map((p) => [ + p.pageTitle || p.path || '(home)', + p.path || '/', + String(p.count), + ]), + ); + }; + + const exportTopCollections = () => { + if (!data) return; + downloadCsv( + 'top-collections.csv', + ['Collection Title', 'URL', 'Collection ID', 'Pageviews'], + data.topCollections.map((c) => [ + c.collectionTitle, + c.collectionSlug ? `/${c.collectionSlug}` : '', + c.collectionId, + String(c.count), + ]), + ); + }; + + const exportReferrers = () => { + if (!data) return; + downloadCsv( + 'referrers.csv', + ['Referrer', 'Pageviews'], + data.referrers.map((r) => [r.referrer, String(r.count)]), + ); + }; + + const exportCampaigns = () => { + if (!data) return; + downloadCsv( + 'campaigns.csv', + ['Campaign', 'Pageviews'], + data.campaigns.map((c) => [c.campaign, String(c.count)]), + ); + }; + + const exportDevices = () => { + if (!data) return; + downloadCsv( + 'devices.csv', + ['Device', 'Pageviews'], + data.devices.map((d) => [d.device_type, String(d.count)]), + ); + }; + + // ── Render ─────────────────────────────────────────────────────────── + + if (!canView) { + return ( + +

Login or ask the community administrator for access to impact data.

+
+ ); + } return ( - {canView ? ( - <> -
- {newToken && ( - <> -

- New Analytics - - These analytics are collected using our new - cookieless and more privacy friendly analytics - system that was implemented in March 2024. They do - not map directly to the historical analytics system, - but should be close. In the near future the old - system will stop collecting data, and the new system - will be the only source of analytics. We will - provide a way to view both the old and new data side - by side for a period of time, after which we will - work on reconciling the two datasets. - - } - hoverCloseDelay={300} - intent={Intent.PRIMARY} - position={Position.BOTTOM_LEFT} - interactionKind={PopoverInteractionKind.HOVER} - > -

- { - iframe.style.height = `${height - getOffset(width)}px`; - }} - /> - - )} -
-
- {!showHistoricalAnalytics ? ( + // details={`Learn more about who your ${activeTargetName} is reaching.`} + controls={ +
+ - ) : ( - <> - -
-

- Historical Analytics - {canView && !isCollection && displayDataWarning && ( - - Analytics data collected before April 30, 2020 - used a different analytics system than data - collected between April 30, 2020 and March 2024. - As a result, the Users metric will be - inconsistent between these two periods, and - should not be used for direct comparisons. For - more information, see our{' '} - - explanatory Discourse post - {' '} - and the{' '} - - Historical Benchmark Dashboard - - , below. -

- } - hoverCloseDelay={300} - intent={Intent.WARNING} - position={Position.BOTTOM_LEFT} - interactionKind={PopoverInteractionKind.HOVER} - > - + + + + + handleStartChange(e.target.value)} + /> + + handleEndChange(e.target.value)} + /> + + + } + > + {SHOW_MIGRATION_BANNER && ( + + A data migration is in progress. Historical analytics data may be incomplete or + unavailable for a few hours. Data will backfill automatically once the migration + completes. + + )} + + {loading && ( +
+ +
+ )} + + {error && ( + fetchData(startDate, endDate)} icon="refresh"> + Retry + + } + /> + )} + + {!loading && !error && data && ( + <> + {/* ── Row 1: Stats + Chart ── */} +
+
+ + + +
+ {chartData.length > 1 && ( +
+

Pageviews Over Time

+ + + + + - - )} - - - )} + v.toLocaleString()} /> + + + + + +
+ )} +
+ + {/* ── Row 2: Top Pubs (2/3) | Countries (1/3) ── */} +
+ + row.pubSlug ? {v} : v, + }, + { + key: 'downloads', + label: 'Downloads', + align: 'right', + render: (v: number) => fmt(v), + }, + { + key: 'views', + label: 'Views', + align: 'right', + render: (v: number) => fmt(v), + }, + ]} + emptyMessage="No pub data for this period." + /> + ({ + ...c, + country: c.countryCode ? countryName(c.countryCode) : c.country, + }))} + columns={[ + { key: 'country', label: 'Country' }, + { + key: 'count', + label: 'Views', + align: 'right', + render: (v: number) => fmt(v), + }, + ]} + emptyMessage="No country data for this period." + /> +
+ + {/* ── Row 3: Top Pages | Top Collections (50-50) ── */} +
+ ( + + {v || row.path || '(home)'} + + ), + }, + { + key: 'count', + label: 'Views', + align: 'right', + render: (v: number) => fmt(v), + }, + ]} + emptyMessage="No page data for this period." + /> + + row.collectionSlug ? ( + {v} + ) : ( + v + ), + }, + { + key: 'count', + label: 'Views', + align: 'right', + render: (v: number) => fmt(v), + }, + ]} + emptyMessage="No collection data for this period." + /> +
+ + {/* ── Row 4: Referrers | Devices | Campaigns (always 3-col) ── */} +
+ { + try { + const url = new URL(v); + return ( + url.hostname + + (url.pathname !== '/' ? url.pathname : '') + ); + } catch { + return v; + } + }, + }, + { + key: 'count', + label: 'Views', + align: 'right', + render: (v: number) => fmt(v), + }, + ]} + emptyMessage="No referrer data for this period." + /> + fmt(v), + }, + ]} + emptyMessage="No device data for this period." + /> + fmt(v), + }, + ]} + emptyMessage="No campaign data for this period." + /> +
- ) : ( -

Login or ask the community administrator for access to impact data.

)}
); diff --git a/client/containers/DashboardImpact/dashboardImpact.scss b/client/containers/DashboardImpact/dashboardImpact.scss index f9e1a1f625..be6bca098f 100644 --- a/client/containers/DashboardImpact/dashboardImpact.scss +++ b/client/containers/DashboardImpact/dashboardImpact.scss @@ -1,27 +1,353 @@ .dashboard-impact-container { - section { - margin-top: 50px; + // ── Date controls (in DashboardFrame controls slot) ───────────────── + .date-controls { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + + .date-inputs { + display: inline-flex; + align-items: center; + gap: 6px; + + input[type='date'] { + font-size: 12px; + padding: 2px 6px; + border: 1px solid #ced9e0; + border-radius: 3px; + background: #fff; + color: #1c2127; + height: 26px; + + &:focus { + outline: none; + border-color: #2b95d6; + box-shadow: 0 0 0 1px rgba(43, 149, 214, 0.3); + } + } + + .date-sep { + color: #8a9ba8; + font-size: 13px; + } + } + } + + // ── Migration banner ──────────────────────────────────────────────── + .migration-banner { + margin-bottom: 20px; + } + + // ── Loading ───────────────────────────────────────────────────────── + .loading-container { + display: flex; + justify-content: center; + padding: 60px 0; + } + + // ── Top row: stats + chart ────────────────────────────────────────── + .top-row { + display: grid; + grid-template-columns: 200px 1fr; + gap: 20px; + margin-bottom: 32px; + + @media (max-width: 768px) { + grid-template-columns: 1fr; + } + } + + .stats-column { + display: flex; + flex-direction: column; + gap: 20px; + padding-top: 4px; } - .absolute-header { - position: relative; - z-index: 2; - align-items: baseline; - display: inline-block; + + .stat-card { + background: none; + border-left: 3px solid #2b95d6; + border-radius: 0; + padding: 4px 0 4px 12px; + + .stat-value { + font-size: 28px; + font-weight: 700; + line-height: 1.1; + color: #1c2127; + font-variant-numeric: tabular-nums; + } + + .stat-label { + font-size: 11px; + color: #5c7080; + margin-top: 1px; + text-transform: uppercase; + letter-spacing: 0.6px; + font-weight: 600; + } } - .warning-button { - margin: 0px 5px 5px; + + .chart-column { + min-height: 0; + + h3 { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #5c7080; + margin: 0 0 6px; + } } - iframe.metabase { + + // ── Data panels ───────────────────────────────────────────────────── + .data-panel { + margin-bottom: 28px; + + .panel-header { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 6px; + padding: 0 4px; + } + + h3 { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #5c7080; + margin: 0; + } + + .export-link { + padding: 0; + background: none; + border: none; + cursor: pointer; + font-size: 11px; + color: #8a9ba8; + text-decoration: none; + white-space: nowrap; + + &:hover { + color: #2b95d6; + text-decoration: underline; + } + } + } + + // ── Row 2: pubs (2/3) + countries (1/3) ───────────────────────────── + .row-pubs-countries { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 24px; + margin-bottom: 28px; + + @media (max-width: 1024px) { + grid-template-columns: 1fr; + } + } + + // ── Row 3: pages + collections (50-50) ────────────────────────────── + .row-half { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + margin-bottom: 28px; + + @media (max-width: 1024px) { + grid-template-columns: 1fr; + } + } + + // ── Row 4: referrers + devices + campaigns (always 3-col) ─────────── + .row-third { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 24px; + margin-bottom: 28px; + + @media (max-width: 1024px) { + grid-template-columns: 1fr; + } + } + + // ── Empty message for empty tables ────────────────────────────────── + .empty-message { + font-size: 13px; + color: #8a9ba8; + padding: 24px 4px; + text-align: center; + } + + // ── Pagination footer ─────────────────────────────────────────────── + // The table-scroll-area has a min-height so that pagination controls + // stay in a stable position even when the last page has fewer rows. + .table-scroll-area { + // 15 rows × ~23px each + ~25px header = ~370px + min-height: 370px; + } + + .table-pagination { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 4px; + padding: 4px 4px 0; + font-size: 11px; + color: #8a9ba8; + + .page-info { + margin-right: 4px; + } + + button { + background: none; + border: 1px solid #ced9e0; + border-radius: 3px; + cursor: pointer; + font-size: 13px; + line-height: 1; + padding: 1px 6px; + color: #5c7080; + + &:hover:not(:disabled) { + background: #f5f8fa; + color: #1c2127; + } + + &:disabled { + opacity: 0.35; + cursor: default; + } + } + } + + // ── Compact table ─────────────────────────────────────────────────── + .compact-table { width: 100%; - z-index: 1; - position: relative; + border-collapse: collapse; + font-size: 13px; + + th { + text-align: left; + padding: 4px; + border-bottom: 1px solid #e1e8ed; + font-weight: 600; + color: #8a9ba8; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.4px; + + &.align-right { + text-align: right; + } + &.col-flex { + width: 100%; + } + } + + td { + padding: 3px 4px; + color: #1c2127; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 0; + + &.align-right { + text-align: right; + font-variant-numeric: tabular-nums; + width: 80px; + max-width: none; + color: #5c7080; + font-weight: 500; + } + &.col-flex { + width: 100%; + } + } + + // Right-align numeric columns (fallback for tables without explicit align) + td:last-child:not(.col-flex) { + text-align: right; + font-variant-numeric: tabular-nums; + width: 80px; + max-width: none; + color: #5c7080; + font-weight: 500; + } + + tr:hover td { + background: rgba(0, 0, 0, 0.02); + } + + a { + color: inherit; + text-decoration: none; + &:hover { + color: #2b95d6; + text-decoration: underline; + } + } } } -.impact-warning-tooltip { - max-width: 400px; - a, - a:hover { - color: inherit; +/* ── Dark mode ─────────────────────────────────────────────────────────── */ +.bp5-dark, +.bp3-dark { + .dashboard-impact-container { + .date-controls .date-inputs input[type='date'] { + background: #30404d; + border-color: #5c7080; + color: #f5f8fa; + } + .stat-card .stat-value { + color: #f5f8fa; + } + .stat-card .stat-label { + color: #a7b6c2; + } + .data-panel h3, + .chart-column h3 { + color: #a7b6c2; + } + .empty-message { + color: #738694; + } + .compact-table { + th { + border-bottom-color: #30404d; + color: #738694; + } + td { + color: #f5f8fa; + } + td:last-child:not(.col-flex), + td.align-right { + color: #a7b6c2; + } + tr:hover td { + background: rgba(255, 255, 255, 0.04); + } + a:hover { + color: #48aff0; + } + } + .table-pagination { + color: #a7b6c2; + button { + border-color: #5c7080; + color: #a7b6c2; + &:hover:not(:disabled) { + background: #30404d; + color: #f5f8fa; + } + } + } } } diff --git a/client/containers/DashboardImpact2/DashboardImpact2.tsx b/client/containers/DashboardImpact2/DashboardImpact2.tsx new file mode 100644 index 0000000000..c7be44cb34 --- /dev/null +++ b/client/containers/DashboardImpact2/DashboardImpact2.tsx @@ -0,0 +1,523 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; + +import { Button, ButtonGroup, Callout, NonIdealState } from '@blueprintjs/core'; +import { + Area, + AreaChart, + CartesianGrid, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; + +import { DashboardFrame } from 'components'; +import { getDashUrl } from 'utils/dashboard'; +import { usePageContext } from 'utils/hooks'; + +import './dashboardImpact2.scss'; + +// Country code → full name (browser Intl API) +const countryDisplayNames = new Intl.DisplayNames(['en'], { type: 'region' }); +function countryName(code: string): string { + if (!code || code.length !== 2) return code || 'Unknown'; + try { + return countryDisplayNames.of(code.toUpperCase()) ?? code; + } catch { + return code; + } +} + +type DailyAnalytics = { date: string; visits: number; pageViews: number }; +type TopPath = { path: string; count: number }; +type CountryBreakdown = { country: string; count: number }; +type DeviceBreakdown = { device: string; count: number }; +type ReferrerBreakdown = { referrer: string; count: number }; + +type AnalyticsData = { + daily: DailyAnalytics[]; + topPaths: TopPath[]; + countries: CountryBreakdown[]; + devices: DeviceBreakdown[]; + referrers: ReferrerBreakdown[]; + totals: { visits: number; pageViews: number }; + rawTotals: { visits: number; pageViews: number }; + pathTitles?: Record; + stale?: boolean; +}; + +type DateRange = '1d' | '7d' | '30d'; + +const fmt = (n: number): string => { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return n.toLocaleString(); +}; + +const fmtDate = (s: string): string => { + const d = new Date(s + 'T00:00:00'); + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); +}; + +const getRange = (r: DateRange) => { + const end = new Date(); + const start = new Date(); + const days = r === '1d' ? 1 : r === '7d' ? 7 : 30; + start.setDate(end.getDate() - days); + return { + startDate: start.toISOString().slice(0, 10), + endDate: end.toISOString().slice(0, 10), + }; +}; + +const StatCard = ({ label, value, color }: { label: string; value: string; color: string }) => ( +
+
{value}
+
{label}
+
+); + +const COLORS = { visits: '#2B95D6', pageViews: '#15B371' }; + +/** Compact table used inside data panels. */ +const CompactTable = ({ + rows, + columns, +}: { + rows: Array>; + columns: Array<{ key: string; label: string; render?: (v: any, row: any) => React.ReactNode }>; +}) => ( + + + + {columns.map((c) => ( + + ))} + + + + {rows.map((row) => ( + row[c.key]).join('|')}> + {columns.map((c) => ( + + ))} + + ))} + +
{c.label}
{c.render ? c.render(row[c.key], row) : row[c.key]}
+); + +/** + * Resolve a display title for a path using the pathTitles map. + * For /pub/slug/release/3 → looks up /pub/slug. + * For /my-collection → looks up /my-collection. + */ +function pathTitle(path: string, titles?: Record): string | null { + if (!titles) return null; + // Try /pub/{slug} match + const pubMatch = path.match(/^\/pub\/([^/]+)/); + if (pubMatch) { + return titles[`/pub/${pubMatch[1]}`] ?? null; + } + // Try top-level /{slug} + const topMatch = path.match(/^\/([^/]+)/); + if (topMatch) { + return titles[`/${topMatch[1]}`] ?? null; + } + return null; +} + +const INITIAL_PATHS_SHOWN = 15; + +// ───────────────────────────────────────────────────────────────────────────── + +const DashboardImpact2 = () => { + const { scopeData } = usePageContext(); + const { + elements: { activeTargetType, activePub, activeCollection }, + activePermissions: { canView }, + } = scopeData; + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [notConfigured, setNotConfigured] = useState(false); + const [stale, setStale] = useState(false); + const [dateRange, setDateRange] = useState('7d'); + const [showAllPaths, setShowAllPaths] = useState(false); + + const legacyImpactUrl = getDashUrl({ + mode: 'impact', + pubSlug: activePub?.slug, + collectionSlug: activeCollection?.slug, + }); + + // Build scope query params based on active dashboard scope + const scopeParams = useMemo(() => { + if (activeTargetType === 'pub' && activePub) { + return `&pubSlug=${encodeURIComponent(activePub.slug)}`; + } + if (activeTargetType === 'collection' && activeCollection) { + return `&collectionId=${encodeURIComponent(activeCollection.id)}`; + } + return ''; + }, [activeTargetType, activePub, activeCollection]); + + const fetchData = useCallback( + async (range: DateRange) => { + setLoading(true); + setError(null); + setStale(false); + setNotConfigured(false); + try { + const { startDate, endDate } = getRange(range); + const res = await fetch( + `/api/impact2?startDate=${startDate}&endDate=${endDate}${scopeParams}`, + ); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + if (res.status === 503) { + setNotConfigured(true); + return; + } + throw new Error(body.error || `Request failed (${res.status})`); + } + const json: AnalyticsData = await res.json(); + setData(json); + setStale(!!json.stale); + } catch (err: any) { + setError(err.message ?? 'Failed to load analytics'); + } finally { + setLoading(false); + } + }, + [scopeParams], + ); + + useEffect(() => { + if (canView) fetchData(dateRange); + }, [dateRange, canView, fetchData]); + + const chartData = useMemo( + () => (data ? data.daily.map((d) => ({ ...d, label: fmtDate(d.date) })) : []), + [data], + ); + + if (!canView) { + return ( + +

Login or ask the community administrator for access to impact data.

+
+ ); + } + + return ( + + + + + + } + > + + This dashboard reflects recent activity based on edge data and is designed for + quick, transient insight. It does not provide historical reporting or long-term + retention. For comprehensive analytics, we strongly recommend connecting a dedicated + analytics tool in Settings. +
+
+ Legacy analytics remain available here. We'll announce + more formal plans for the legacy analytics shortly. In the meantime, you can + continue to view and download any historical data you might need. Please feel free + to reach out if you + have any specific needs or feedback we should keep in mind. +
+ + {loading && ( +
+ {/* Top row: stats + chart */} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Breakdowns skeleton (3-column) */} +
+ {[0, 1, 2].map((i) => ( +
+
+ {[0, 1, 2, 3, 4, 5].map((j) => ( +
+
+
+
+ ))} +
+ ))} +
+ {/* Top Pages skeleton (full width) */} +
+
+ {[0, 1, 2, 3, 4, 5, 6, 7].map((j) => ( +
+
+
+
+ ))} +
+
+ )} + + {error && ( + fetchData(dateRange)} icon="refresh"> + Retry + + } + /> + )} + + {notConfigured && ( + + )} + + {!loading && !error && data && ( + <> + {stale && ( +
+ Data may be slightly delayed — try again in a few minutes. +
+ )} + + {/* ── Row 1: Stats + Chart ── */} +
+
+ + +

+ All numbers adjusted for suspected bot/spam traffic.* +

+
+ {chartData.length > 1 && ( +
+

Traffic Over Time

+ + + + + + v.toLocaleString()} /> + + + + + +
+ )} +
+ + {/* ── Row 2: breakdowns (3-column) ── */} +
+ {/* Countries */} + {data.countries.length > 0 && ( +
+

Countries

+ ({ + ...c, + country: countryName(c.country), + }))} + columns={[ + { key: 'country', label: 'Country' }, + { + key: 'count', + label: 'Views', + render: (v: number) => fmt(v), + }, + ]} + /> +
+ )} + + {/* Referrers */} + {data.referrers.length > 0 && ( +
+

Referrers

+ fmt(v), + }, + ]} + /> +
+ )} + + {/* Devices */} + {data.devices.length > 0 && ( +
+

Devices

+ { + const total = data.devices.reduce((s, d) => s + d.count, 0); + return data.devices.map((d) => ({ + device: d.device, + pct: total + ? `${((d.count / total) * 100).toFixed(1)}%` + : '0%', + })); + })()} + columns={[ + { key: 'device', label: 'Type' }, + { key: 'pct', label: '%' }, + ]} + /> +
+ )} +
+ + {/* ── Row 3: Top Pages (full width) ── */} + {data.topPaths.length > 0 && ( +
+

Top Pages

+ { + const title = pathTitle(v, data.pathTitles); + return ( + + {title ? ( + <> + + {title} + + {v} + + ) : ( + v + )} + + ); + }, + }, + { + key: 'count', + label: 'Views', + render: (v: number) => fmt(v), + }, + ]} + /> + {data.topPaths.length > INITIAL_PATHS_SHOWN && ( + + )} +
+ )} + + {/* Footer */} +
+

+ * Totals adjusted to exclude known bot/spam routes. Sessions estimated + proportionally. Raw totals: {fmt(data.rawTotals.visits)} sessions /{' '} + {fmt(data.rawTotals.pageViews)} page views. +

+

+ All web analytics capture unavoidable noise. While we apply filtering + and normalization, some non-human or ambiguous traffic almost surely + persists. Treat these numbers as directional indicators, not exact + measurements. +

+

+ Analytics sourced from Cloudflare edge traffic data. Today's data is + refreshed at most every hour. +

+
+ + )} + + ); +}; + +export default DashboardImpact2; diff --git a/client/containers/DashboardImpact2/dashboardImpact2.scss b/client/containers/DashboardImpact2/dashboardImpact2.scss new file mode 100644 index 0000000000..c3efd638a1 --- /dev/null +++ b/client/containers/DashboardImpact2/dashboardImpact2.scss @@ -0,0 +1,375 @@ +.dashboard-impact2-container { + // ── Skeleton loading ──────────────────────────────────────────────── + @keyframes skeleton-pulse { + 0% { opacity: 0.15; } + 50% { opacity: 0.25; } + 100% { opacity: 0.15; } + } + + .skeleton-line { + background: #1c2127; + border-radius: 3px; + animation: skeleton-pulse 1.5s ease-in-out infinite; + } + + .skeleton-stat { + border-left-color: #d3d8de !important; + } + + .skeleton-value { + width: 80px; + height: 28px; + margin-bottom: 6px; + } + + .skeleton-label { + width: 100px; + height: 10px; + } + + .skeleton-heading { + width: 100px; + height: 10px; + margin-bottom: 10px; + } + + .skeleton-chart { + width: 100%; + height: 180px; + background: #1c2127; + border-radius: 4px; + animation: skeleton-pulse 1.5s ease-in-out infinite; + } + + .skeleton-table-row { + display: flex; + justify-content: space-between; + padding: 5px 4px; + } + + .skeleton-cell { + width: 65%; + height: 12px; + } + + .skeleton-cell-short { + width: 30px; + height: 12px; + } + + .analytics-callout { + margin-bottom: 24px; + font-size: 13px; + line-height: 1.5; + } + + // ── Top row: stats + chart ────────────────────────────────────────── + .top-row { + display: grid; + grid-template-columns: 200px 1fr; + gap: 20px; + margin-bottom: 32px; + + @media (max-width: 768px) { + grid-template-columns: 1fr; + } + } + + .stats-column { + display: flex; + flex-direction: column; + gap: 20px; + padding-top: 4px; + } + + .stat-card { + background: none; + border-left: 3px solid #2b95d6; + border-radius: 0; + padding: 4px 0 4px 12px; + + .stat-value { + font-size: 28px; + font-weight: 700; + line-height: 1.1; + color: #1c2127; + font-variant-numeric: tabular-nums; + } + + .stat-label { + font-size: 11px; + color: #5c7080; + margin-top: 1px; + text-transform: uppercase; + letter-spacing: 0.6px; + font-weight: 600; + } + + .stat-subtext { + font-size: 10px; + color: #8a9ba8; + margin-top: 1px; + } + } + + .chart-column { + min-height: 0; + + h3 { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #5c7080; + margin: 0 0 6px; + } + } + + .fine-print { + font-size: 11px; + color: #777; + margin: 4px 0 0; + } + + // ── Top Pages panel (full width) ──────────────────────────────────── + .top-pages-panel { + margin-bottom: 24px; + } + + // ── Data grid: breakdown panels (3-column) ───────────────────────── + .data-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; + margin-bottom: 36px; + + @media (max-width: 900px) { + grid-template-columns: 1fr 1fr; + } + @media (max-width: 600px) { + grid-template-columns: 1fr; + } + } + + .data-panel { + h3 { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #5c7080; + margin: 0 0 6px; + padding: 0 4px; + } + } + + // ── Path cell with title + URL ────────────────────────────────────── + .path-cell { + display: flex; + flex-direction: column; + gap: 1px; + overflow: hidden; + color: inherit; + text-decoration: none; + + &:hover { + color: #2b95d6; + text-decoration: none; + + .path-title { + text-decoration: underline; + } + } + + .path-title { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 13px; + line-height: 1.3; + } + + .path-url { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 11px; + color: #8a9ba8; + line-height: 1.3; + } + } + + // ── Show more button ──────────────────────────────────────────────── + .show-more-btn { + display: block; + width: 100%; + padding: 6px 0; + margin-top: 4px; + background: none; + border: none; + color: #2b95d6; + font-size: 11px; + cursor: pointer; + text-align: center; + + &:hover { + text-decoration: underline; + } + } + + + + // ── Compact table ─────────────────────────────────────────────────── + .compact-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + + th { + text-align: left; + padding: 4px; + border-bottom: 1px solid #e1e8ed; + font-weight: 600; + color: #8a9ba8; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.4px; + } + + td { + padding: 3px 4px; + color: #1c2127; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 0; + } + + td:last-child { + text-align: right; + font-variant-numeric: tabular-nums; + width: 60px; + max-width: none; + color: #5c7080; + font-weight: 500; + } + + tr:hover td { + background: rgba(0, 0, 0, 0.02); + } + + a { + color: inherit; + text-decoration: none; + &:hover { + color: #2b95d6; + text-decoration: underline; + } + } + } + + // ── Footer ────────────────────────────────────────────────────────── + .analytics-footer { + margin-top: 20px; + padding-top: 12px; + border-top: 1px solid #e1e8ed; + + p { + font-size: 11px; + color: #777; + line-height: 1.5; + margin: 0 0 8px; + + code { + font-size: 10px; + background: rgba(0, 0, 0, 0.04); + padding: 1px 3px; + border-radius: 2px; + } + } + } + + .stale-callout { + margin-bottom: 12px; + font-size: 11px; + padding: 5px 10px; + background: rgba(217, 130, 43, 0.08); + color: #946638; + border-radius: 4px; + } +} + +/* ── Dark mode ─────────────────────────────────────────────────────────── */ +.bp5-dark, +.bp3-dark { + .dashboard-impact2-container { + .skeleton-line, + .skeleton-chart { + background: #f5f8fa; + } + + .skeleton-stat { + border-left-color: #5c7080 !important; + } + + .stat-card { + background: none; + .stat-value { + color: #f5f8fa; + } + .stat-label { + color: #a7b6c2; + } + } + + .fine-print { + color: #738694; + } + + .data-panel h3, + .chart-column h3 { + color: #a7b6c2; + } + + .analytics-footer { + border-top-color: #30404d; + p { + color: #738694; + code { + background: rgba(255, 255, 255, 0.06); + } + } + } + + .compact-table { + th { + border-bottom-color: #30404d; + color: #738694; + } + td { + color: #f5f8fa; + } + td:last-child { + color: #a7b6c2; + } + tr:hover td { + background: rgba(255, 255, 255, 0.04); + } + a:hover { + color: #48aff0; + } + } + + .path-cell .path-url { + color: #738694; + } + + .path-cell:hover { + color: #48aff0; + } + + .show-more-btn { + color: #48aff0; + } + } +} diff --git a/client/containers/Pub/PubHeader/Download.tsx b/client/containers/Pub/PubHeader/Download.tsx index 767cf6f2b5..e8752efbc0 100644 --- a/client/containers/Pub/PubHeader/Download.tsx +++ b/client/containers/Pub/PubHeader/Download.tsx @@ -71,7 +71,6 @@ const Download = (props: Props) => { communitySubdomain: communityData.subdomain, format: type.format, pubId: pubData.id, - isProd: locationData.isProd, }); if (type.format === 'formatted') { return download(formattedDownload.url); diff --git a/client/containers/index.ts b/client/containers/index.ts index 681af9e332..f8ab7d357e 100644 --- a/client/containers/index.ts +++ b/client/containers/index.ts @@ -10,6 +10,7 @@ export { default as DashboardDiscussions } from './DashboardDiscussions/Dashboar export { default as DashboardEdges } from './DashboardEdges/DashboardEdges'; export { default as DashboardFacets } from './DashboardFacets/DashboardFacets'; export { default as DashboardImpact } from './DashboardImpact/DashboardImpact'; +export { default as DashboardImpact2 } from './DashboardImpact2/DashboardImpact2'; export { default as DashboardMembers } from './DashboardMembers/DashboardMembers'; export { DashboardCollectionOverview, diff --git a/infra/.env.dev.enc b/infra/.env.dev.enc index db144b7cef..09cd8af1c6 100644 --- a/infra/.env.dev.enc +++ b/infra/.env.dev.enc @@ -1,51 +1,67 @@ -AES_ENCRYPTION_KEY=ENC[AES256_GCM,data:t5h9uZx6KnDaAwv8C2zqLOPvdDvPBwFM7IJ3yCBjnmpnbOsl2+qTWmsSfowFXUtSkzOvL9RqBqw+oPQ++Q4PGQ==,iv:hP3NwD4ezGJQVODYKHz3wuYTDFbIPVW6F1RXxIGRXz8=,tag:5FVssGt+RkcO0HMWm6tkIA==,type:str] -ALTCHA_HMAC_KEY=ENC[AES256_GCM,data:kq1ISoxNHTcwf2w8/MHKtfmagFFwaf/3BPz6CJpYZNeOs46KVqaJNvelVdAB5sHMlCeTPxRK2MlRSPHDYfL5yQ==,iv:oTVQyo+Rctt0GoO2HWG2vfAYOIK0Wa84egCvFVIFNlk=,tag:ZJKz9RBHqpYOd2Ik++y0Iw==,type:str] -AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:yGZrAJSLog5aYmcO4OYwv1ZWZUM=,iv:exq4PdTva7dwmSr2/Iachcds+RApzrXhtR97ZVOSLIE=,tag:aYkRj9A23RsHTxCyZFKGSQ==,type:str] -AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:4ikQjiHkDKfU9SUfv5rJpeBDb/Q=,iv:f4N4/yIsDTtoKJ4Ib5BL+QKDZV58FskwiB3leb4kp9s=,tag:eHs6yl8E3p/MUyi2EWebEg==,type:str] -AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:SpnYk3I5xTSs7igyburPo7PKYwarj0xx0dK5Hp8c6k7GSZWKAwIONA==,iv:9YRkywK1ZpDgzemFKVJqyCdyDXmouzcgcB0NGM0bwV8=,tag:MjrwOyr3SEmle2XvN5KqFw==,type:str] -AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:5ddsmcH3vHBjeQkK6nLXn5pGv71t18pS25zQ1yFgl+Zz9gp179oNpA==,iv:KVabtzNApVK16WVc0Rz89YBn6o0xyXG6R0kiROYMSZ0=,tag:sG3NlDf8s7hk4IKdEz/BvA==,type:str] -BACKUPS_SECRET=ENC[AES256_GCM,data:L8uWycTcenAz8XPYBapTfX8kqWo/xlxWn7MsSW9x3G+arruVHfrcvLtZew4=,iv:AXI09DowsKAHSL9sYGcsYp64l54nsMDdOSyLIcJynTU=,tag:roNwyeBH5zFjMXtYqZCl3g==,type:str] -DATABASE_URL=ENC[AES256_GCM,data:p/AkuTNohCUuF9I+h8yp1J5YLAQCmWeLCDwrOWup6u+kAPmpwl9BY/rMM5s=,iv:yDnLxoEi9mDVKZJtl7wqvWYdmjwYrBs+Kfo+j8Yf28o=,tag:I0v4442X4hvLKfW+8NAj5g==,type:str] -DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:gkc2sHZp82K0vJG9UEh4msAbH+Gb+xMswpwSWIkxhioKHQ==,iv:sYn3EDUhdnMt1c57JFgbmyiq7fW4Z9hhLIiJzxeLfcM=,tag:cxTk6z771NmsmYONZwOAiA==,type:str] -DOI_LOGIN_ID=ENC[AES256_GCM,data:ZeURnqkg,iv:PTRJc5/g85StDi3rioFUbGovQEiODwZok1bkN6IgE44=,tag:qqrwVMvjHW7cOxgAaHaO5g==,type:str] -DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:b64cNAWyqoyM7YustNxFKsE4ZxY=,iv:MQu9r3kSds0LRv06O8mO4lH91pLfTXLBsJYa9+gJ3NE=,tag:Rfd5zeTFeS+LBCCskadhPg==,type:str] -DOI_SUBMISSION_URL=ENC[AES256_GCM,data:202hN1ZUmxov05SSalPkRbNUcJRvjp9XYnQTHUUDDC4rTasuGnRfRSs=,iv:kboLNhgPlGZT9dVQL0khZLMX0seGBXDlLwk1RUXmJrw=,tag:ijEAJmXtGje0CApsVi6d9A==,type:str] -FASTLY_PURGE_TOKEN=ENC[AES256_GCM,data:sc+pjZf1IyiuU+DmaKhtKLjk6vIuK0iw8TBmmnhtu9k=,iv:Tqv67Fis6YkWojXGAyYvoKwA6NGtpktldhA3C0M6mbo=,tag:X4mnMv2mhfspMXfAViFb1A==,type:str] -FASTLY_SERVICE_ID=ENC[AES256_GCM,data:zhFfsc9WFRy6dqUT91smBaCWLwwcpQ==,iv:vRwCgtoD7fSrEpv46wJ6UKRH/+OllsQv3IZurM25HUU=,tag:5UCEDyqPf9p8Vxp2hZVVZg==,type:str] -FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data: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,iv:qGN7CEyCcekgC2+MteGeSMNg8A/2FyIEaKZcNGblDCY=,tag:rvxWjuJ2vEqtMgkCPMHWPw==,type:str] -IS_DUQDUQ=ENC[AES256_GCM,data:udxowQ==,iv:RbZdYFj41B5oxXiSbVtld/n+/FoqzCzSlC/Cj1nQ6Zo=,tag:Pt5eD5ZJ1mxh0MCfHAOtsQ==,type:str] -JWT_SIGNING_SECRET=ENC[AES256_GCM,data:72PLmNpahurRwiAzG/Mc56gax4zni/gIvhlFDjuKXQ87T2jHRUq6/vZB7EYojCvRtC6LDiou7axNoTPOfmybc/ZBm3jj4CRdKts6B1BD1OLg6jlA/EFi1RJWF9sGuMjmXn9WYLLIpDSKv7KPtD4m+mEQ1vdlSWQkyj7+SXhZ/FGcHpU4nfk3DgqD0OSA0D3meYhG//zwWvcEWWFez84hQOcShmA4tc6F9qxK2/KI1NpsTtVOoo21w1mWOgi/EZxbTuwzR/z2PbckmK5/GfJdBy9WfnpKlcbS1/KatmU2jxsDi8btf04PK1+o3jDz+jLu57zrT6yx1CeiRO5hrQQvG1/rFkOWKj8ptvGRkBUwzbZVhgm1Zgt1xMJ8ZKtGFt/wNfKwDod6UwZxqgIJ7nK2pqHfhhIZhTaYX4gwlN5hsHY43LMzCd8PU57YeEK8xOxLeQH6jP6+3EiQoM7ww5hkmkNboujjaOHwE63lHUPvL8WeFNiDanZ+oEfTxAacxj262PT5PX7iYJYRC27zsiti+kUf2vN1CMVeIRr0S125AeJhXTTLiFNYlIqkfaCsfLQ/WbUH5h3sEJJ/jNEFO6Sql37XYg00JCOF+X3BTkAmCEg4aypLI98QcfJlfVUDbLxxkQ0WZnY5IGv9qo8V1kKff+Ek9D0y490K62fQDDE1sV15MSMQ9HTCKwPyD3mqZLUvk/hjMtX1X4C5kxtlYXkROtvqr/UpnR8FP/7OJ5r54p2/DJ4tOV2etTBGyMCuxzEunF1Bh9ct9SiREqf2E2Zf/3QNvsuRod4W4WgKULTeF69Yos2pHuPnA87mnCjAMI1hAuBe0d1XoQ8KR4xlD1eTQUScvDx/HsITfnpLl99H/PdhDNqx2H0obrw9O2q/fm5MbYU7Y9yRaKJpUg+Q9YitqwxiDu9x8zfqHtu9P22nXYXkK0E5xIfGd831sZyZEM4n2yvq8A/3rIqep4EVmJrciYRLdWxpebSX1hGxQ9+qkOGxPpQfmL8FIgahPspU50QEzk2q89j2p3RLYPwsYhZrqOfGbHLtVfFYvfN3cUOOStvmJvGMNpFyXfFBBdEZV7i8jj6OlxYiQOO9pziZDf5/+ZWjPMbkFo1HKJugpr4t4oZ/AI+xnXZyJe7mEt6lJDdEFV+p/oRfIkxz8MlACu3IebDoeLxMc4N+1ceMnbj0k/HbS5edI98rqmjuPgtmAoBOiew7Qm6FxPAJYR4j7ZPA7UlidQU5kJSZYHOWTQ96m1AezLqTrh3e5XrB5phqvCpy6CBO1Z2P1d0iJhVsucOKU39pAcd0+7rWAEfEn2pkn7sme8ynY0Xnanrno0XF92GyPVkLY14qXuT6VRqMH7WEiw==,iv:nj2cMi83mfOCacbIz09+Nzh8Ce36mQhmE09gzPlu66Y=,tag:4Gvbr65NLfL94kemqmnvxw==,type:str] -MAILCHIMP_API_KEY=ENC[AES256_GCM,data:6eY35hzQufzScK295c3ikwAm++MD0/vV4iS5fYBkbhde1T0t,iv:K88KtOyq7HLnGfWYbL5RlR/WtQBh/8CC+/WhNHP26qs=,tag:9GuO8mhhAjqbiXPK9quCdw==,type:str] -MAILGUN_API_KEY=ENC[AES256_GCM,data:CdHYXetA1EDkH4v+5cf1/s463tTqumQGKZqvtP/NrDyc1UDh,iv:7e5gsnXux0ZOVs3jRyrmQmABkyuXUanjRDJ5r4/TY54=,tag:9zsL1wsWtkPVpnggPZSOmw==,type:str] -METABASE_SECRET_KEY=ENC[AES256_GCM,data:JoCLKklFhOTE7uWxK15+xunNKhTAkLIWsR9ZZUmKvencBQNRxZ3EthpBUFJryiUS8qGgeyI7gcPIxUMa3lKQBA==,iv:RqWUPP4S+JY4vU2rjRsspz6TDxzFkBBTwZAYgsvtJEQ=,tag:gqsJgTnwH5azQP2VIHpR7A==,type:str] -NODE_ENV=ENC[AES256_GCM,data:c1YCDLx9RO8pbA==,iv:+7dvO86AX6DFtDuTNnhyqAIHUVIn2l0/wosz5fIFqF0=,tag:xU4SAODIwdNVJk+yYgtU/A==,type:str] -S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:cus/BZtmKiGXj01sxkIhoAudyhF19j7q1HqRyneM+8e/2Oo=,iv:rfjMGLN6ro7ZUNLPk7SWChZ2PxGHWLSkqe5XEy9S++E=,tag:YRME0L5lVJO+w1DO7uHAFg==,type:str] -S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:9m1L+cv6OUdskVUOAcEZx+PPW6c=,iv:nV9NGnLmFOJR2H5GJF1am7BPlQTKH8YrIb9odj/CiGM=,tag:NjVsa6uQoEj73vR34w3NMw==,type:str] -S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:qFVV4dhecrgIrbQeohC16dcn8HXH10h2yMJMkeLXjqvXXV8fzp2h/w==,iv:eot8B5ymQ3EkwRXLPF31iKSCQsAbLEoj0cP+QHLykRo=,tag:xt51yYpgvd+oAI3AUOg0kw==,type:str] -S3_BACKUP_BUCKET=ENC[AES256_GCM,data:SiYfnd+wI52M0e0=,iv:mZDbPvA9r24PPIFVi3ukrBYv0g7wOqid6pxo20mIiZo=,tag:kA6IPtewUzDXxWkFCF3Wew==,type:str] -SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:iYDl1VKRyEV2KqfQRUPINiErBBQ2HAF+EvESj/qrcZb25g7wa23eaygzTMopD9mrvnEZCSPVfuFOyEHDy+y8GdYBMsf9gwyPuZ4nWQk1GCHO3lTDIqvKwM7KeeLKcL2TOwv8a4J0B8ZilK5IUxnkVo/QxN+Ns5nShgW6ERsvFNScpLv3I+OZh/y+WS9jwjpSgNhcMAkYZy8ZL+azJ7ceoIZWBtoD+wwltiIYf60lWzxpB5DwyRDf4kSd0w==,iv:Z06vTPC+nBfW8hlKjJlF0qfTSwNObWuxmUH8NokiKcU=,tag:4xR3L0+Yi3Wvfx9LOSuFbA==,type:str] -SENTRY_ORG=ENC[AES256_GCM,data:WZU8,iv:jl+CPp9caIxl6oDc5iaIh223GaW87tM8m7Y6SrDupOc=,tag:VETO8YSS1vfMO8vAPBQ/8g==,type:str] -SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:hMw=,iv:fQty+ppyT4ZEn1oBJUN+67RYaFHrj46lj04bCGfRX0M=,tag:DMVGEkBLM1TQag7jM3KJ4g==,type:str] -SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:RxwJgIl3M3YcHFSECiQ0Wu0AFwfuu6FxVH7k1j7zH66FccbCNYvlmXYOyXMXwa7R0xPndgHq6jdBb/0JLi5BbuJ5l4zWK4NyW8XtL/P49g==,iv:x68oK0niAN/+imCiloNcRwlJRJA1NREhtthlQsPW+Ag=,tag:QqIXrAJctwkmssRVfqaGcQ==,type:str] -SMTP_HOST=ENC[AES256_GCM,data:3aXUrHdZ9izPDWvuy4BXMe2CHkZ5WL+aBL4F34zv06FdMw==,iv:iI2CIF+OlS6lH9ITJrjHPyXskMXzhauY1fqCoFY5r6k=,tag:6bgp5sXVoW8OrrUH9L3Q7g==,type:str] -SMTP_USER=ENC[AES256_GCM,data:D8Oodw0pnojFtZGhgKYpDVboL0A=,iv:Wy5qj11p/5P5kHNCyU4kgwpwrEC47VY1AmLKUa7TOQQ=,tag:hCijmyAKqwATXAMEHymKKw==,type:str] -SMTP_PASS=ENC[AES256_GCM,data:+Wa3H2tEIe/iGs9oYYwR6unVsonTNYr2rsLBi7I2areEmJnlIuTNxkWtTlc=,iv:fYnQtUEvQ8XPmA3KFrkZ23vF/IfeA9F0hbaucLyY4MM=,tag:k06VTZJnlqNrjbz8rj+f/A==,type:str] -STITCH_WEBHOOK_URL=ENC[AES256_GCM,data:4w5ZmPMYQWCXU/578/IgX9f0z5OybR+kD9EE5uOrXB7fp3lYTofhl3wLuYNHbPZIAwoJLAArJGi1loHknzVZ/N12VxZLSaY6Oyj+yZM1dqzLxnFJw7iDSohig7AGzKogCs+fSCMLLnFBGx++dVzYstezQzB+,iv:0mbWRspp0ntbS70eiYcwiQaGErkx4jZhipEs9e3BdH4=,tag:rkTHS7ZmdDRx22V8jTQA6g==,type:str] -ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:4k3qBQDwyJEssi1N14OV7GwjNu4=,iv:pQPNnskH0yrvPxYnPnGpfOqPHEfIXzCQ58Zq34Bc3Ho=,tag:zXzsfiuOsjMADQ0rS2405g==,type:str] -ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:Jy6BC+622194EGVW9I/bc3JsHIc=,iv:SqrVTX0qfHUmFJpwmvV8YjaVRFVCQ5JwL3y8SOr2F5U=,tag:bTLt8XRd8YUtDa9KeA2QbQ==,type:str] -sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB6anRZZ3lORE5ZblhBYW1E\nWTN6OWdRMWVqMktkTE1QakRSajhPa0NWc1FRCjhYVXJmNUpiSVJjWExsZEFmUnlj\nck10bXdJWis2akl4MUZna2oxMWJJd2sKLS0tIGJDT1YzbVUzemFLaHZCb0VNM3FT\nN1Vlc2NKTlhrc2JMc2IvK1dEOS84d3cKWhUS2Y6ZFuA7KqIsZlfcEfVLssGNlH/V\n3O/RKv5qcrH6Xek6gj3O3UrbM5ou83saSFlb6z00Bp8gTJ83UYqonw==\n-----END AGE ENCRYPTED FILE-----\n +AES_ENCRYPTION_KEY=ENC[AES256_GCM,data:sdsTMHdnONVMVLGEGEaLKSkzZwPRGVR7t0EqwPyYiOVU/RQWmwt5GLrkSpgxnslnB+vKqJjJ3EHy2Ra0yJTq2Q==,iv:ZFbJ2G5vvCx+Jpsc2W8lgU81JazvO1yvrFuwp8Vkw1Q=,tag:yZxVETMwMT7IQ4XklgeXEg==,type:str] +ALTCHA_HMAC_KEY=ENC[AES256_GCM,data:vVeD6Jj5kbnF1RanqGGOT4JQEvBZTNzWSTDN+DHgbysDpK8dLis9/mAnwZynet/n8KaUHEQowfGNyIrQqphE0Q==,iv:Rs6ztx9f0p229aYAaXOljp5mtaOi9JadcYMlvSmTLpg=,tag:iBY50HZddP7gy+G/HBkGww==,type:str] +AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:1IHTxJYOjCDuwa/iAm2nkIonu0c=,iv:CCTLJdrtuUjHDpoQlIkp1Ym/eBg8+O9qMBOztmiLANA=,tag:Q3WBPQm0l37XP4HmCWn0Rg==,type:str] +AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:Uq5mD7Hm5ZTN2elIBB5IWU1Sn+E=,iv:yn5VNT2Hk6hRrkxWPAKTSx34o1HHQV2PfJ5UjE4hwwA=,tag:TLTNtLOZNFeSXxsMw9qAwQ==,type:str] +AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:Qcsvc4j/FKXblUEwGtQxnEoyWG6RjuD6NjpPl+t2r83h9h+zgso/HA==,iv:eknE9q5ogT45PqEbhb/eFfYELqeChbJwsijxRe4Eyc4=,tag:BQPAxgEi2sovfFMCJlKbDw==,type:str] +AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:QmLjRtrDRWpZPf+6XqBbzcww2u3aP1yRHNucmNyMJ+tsV5ugLRv/Qg==,iv:K9jfcn9Ly9EV9y72BWUueBHTV6WrsHmDSfk1t16A0t8=,tag:OZNU6o6qSHpTjsnQLcHnJg==,type:str] +BACKUPS_SECRET=ENC[AES256_GCM,data:uQ6Lv6LVwLyXd9decuD3Dxeno7SNo2At33ZWjyZJ6Z6GYzO2kq3StgOyOnI=,iv:t/pTDeXi4gl/hudPE5SPd6KWAtOcbsSesxntwCes3cg=,tag:70TlQfceSjUJ+MX921w0oQ==,type:str] +CLOUDFLARE_ANALYTICS_API_TOKEN=ENC[AES256_GCM,data:GrMqgtaJrlFj+HihSJsg49DTrC+jREYnOcNOhTH2MZJHhfh1eAREIKmZTJjfId1ZbQGY2d0=,iv:w7uk9FcTgZTklLAsnctaU3FxR1/NEGEFLBiNbtitZ0U=,tag:+s//7IdBNXCe43R9HVklvA==,type:str] +CLOUDFLARE_ZONE_TAG=ENC[AES256_GCM,data:9RUjkbJfD08dxJkTF4jdT8AuCvjYEisYW/X8pZD2bS0=,iv:0QdNUvlE34SSas7OPaqTUkqk9WZlRTYolOXa07nlnIc=,tag:7rPxmkh1mLXvIxn0mXUBTw==,type:str] +DATABASE_URL=ENC[AES256_GCM,data:amJ2d6JCFQebBwQhY/uZ7ztjkzTS3X2e5pGhs0wF6Bz9wCSJjrYaPHmMbgk=,iv:0HUYjfe1xDBehq6mln0cwCek5Jebb5eABGbQeo5p7Yg=,tag:gfab6jNP2JIe5LyzH5S24A==,type:str] +DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:uItgPlYGb2CcR19FaweKJipS57xc6SN+nDasYPqirKQskQ==,iv:BCpQUB7Xwf27F06PlauCN3at+1TtslywCUQisrHIw9Q=,tag:7lA/jAwSP8jdhK4mjsmGcg==,type:str] +DOI_LOGIN_ID=ENC[AES256_GCM,data:+A9GJT3V,iv:tjfng+W8VYYSUohlEJPK4tW53Nt2u1pCqp2wsTwdQMk=,tag:TiaGIR16TIVv0kBOFAXZfg==,type:str] +DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:ST3ZUeTsDaqWkVBC1Oy2L3feGyg=,iv:ytcKZNuuQgKP4jNtPvK9BUsfwteB1iT/wM0iaDRIcN0=,tag:wWTJ22095Sb5piJGBF5xBA==,type:str] +DOI_SUBMISSION_URL=ENC[AES256_GCM,data:TARvuD9fMh0u6aH5sqc253v2ISGjl1qvuINCi/Qv829c0yrMN/MU+Vs=,iv:eZx7cCDbVu2LYauCiso9ujWrKGfygY1Sxrlr6KhJcAk=,tag:r3hIEN/yvlVGYgNPJGcKIQ==,type:str] +FASTLY_PURGE_TOKEN=ENC[AES256_GCM,data:OE+T/oDodxAvJlPw1tvZtdR1zl97IsFGV9pVFeCuI64=,iv:zylNicWvtko5REScZmOhAED1ip2tOslz0ShnDI6zeDs=,tag:uXKxG3fU0HdpFaZVSH49tQ==,type:str] +FASTLY_SERVICE_ID=ENC[AES256_GCM,data:3J9QKEXwJ9kbpTqAMvolnOcNVDWUvw==,iv:z/KatzFraDYg7UzWuuaG5Ip8dVHFRLfFnP+m3DA90UY=,tag:gu5c/Hzqe3y3qxM+3DALaQ==,type:str] +FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data: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,iv:4G3yDw2z0krSwefzgPTmbIrLiPKdw8Ahbyj4fCv6iLQ=,tag:agrJ5RrxRwGkUpRhDkMkrg==,type:str] +IS_DUQDUQ=ENC[AES256_GCM,data:lwH36A==,iv:WHgpJJX2qdo5iXJ52FGy1qFqTdUSjGxJLqnDqkCPR8U=,tag:MtUO7dZvFBk/CxMEONiYPQ==,type:str] +JWT_SIGNING_SECRET=ENC[AES256_GCM,data:lkkFBBKZtLWOVhQm1uQ7Trnz52XRF0JTRbb6bfX1EpwhiEJmz8WgRJlqc7Cs++xkTz4OxrDS3p86GOK6fZ/BO2rGKr/va4tSx3SrGkK+bSruTyzUC+chnb2g2mBfWPOjAYo87Wj8Acy4gwdzcNnXfB8l/poY+ao+DpT/vZw+TGR0h6mvNzWEwgtxxbvlJiirmJSqv4BvJMVwBOhWq0BiMcOqwtjxGPBs5HiP3iebRZRwOaQ7j/ORLq4OM0La8RHl8SXLWBCU0Ttjho5Q3luWHW5Wq8fY8HynpZadwdD1WBpDVz+VdqvVddTUySoyaoG5gO+YNXIvErAuTP/Sm6lk5Kr4qUOGZvflT5l8WF8un52AARmlDFwhrj5eQUbDy+7sDZ7yFmbJADrP4yu/yvVO93P2BTk+9Ku9kxRcOmDo5BPjh9uA0ZBtcXtXkLFf/UOogxbVZYWOi5rP0cz9B36YvwMTG23sbKppaPRr6uBZbdGiCsMtK4SFLOkdXDQf5MnQ2OstcS68Jj5Eg7SUvpZtc3tE5YX61PBDNmvEAzWJKubC5dubswAARPXuGC4NatIO6Wl8AZ4jC9CTYZPyTd1UsTX5UUeMk5Lnngfxfskf21XqalX8EXqvKZCuXsUIzInFrF98gjklwQJZhgW0IzuiX9qW6SY58QVuHLrdlWyVCSjarWArqurIJYZLThauCBa1yOLbl2N1T3IG8ZV39McYdEgpKR/zopxHT9rf1af4twgzUqSGMA2SU91GpHG/4hLFvsSuY6pYhgiIWD2b50RDn0J4CmwsNmLt0WROdihOeOdCB9rdf4RdIdIXgt59eszPQpu80hwM85QVTV3vnRwUlgH7Wq7wsiIG1qD8b/vS9KHkFzl5fqC/v6f7XiDfGXqVudsbJQZFMmKjSYTqblgpdpI3grfczdlPhpHiq5394OZSKxOJGcMcxBltQhqQvI41RsZISnZN2Wm0LMU2dS8wb9Fb9fVnzXsUt4KwSVybT63+F43ArBWMdMnWHfk30rTSyiVHJ67DpxRMxMhUC2ZvSYIaV0UmEqv3ftcmcgoFN+oUGfOCzqBdxfLrHkZXQxt1C/kQStLXWgL/DSghrR1LZV1g1mE4WGR9Ez10eY9mq9PD1xO/gOSP30/dB/cbRGefahyZJ4aJmZByO7xvc/d+z17rvLAw0pgAG4mfUKSrZ6dlVLUgCAFiLJyT819ISuJcoe+LyyLhNGbm1Xube2jMEIBgAIHpDXP/7og8emqJE1SgQI72rWFon6wT+im3RnwZZrmF/djgo/fyW6cR9uW0/TI6K7e9BLrBtMtEeJyMwETjR1iriPscfJj7/q3l9PMgwnVs+9T8oNtN92tbK2fx0g==,iv:yAOn75fpabKTBerH+rXWD3t0LhgamgFd6p/lOOInWFo=,tag:koKa+B8QcVZucXsL2sx8BA==,type:str] +MAILCHIMP_API_KEY=ENC[AES256_GCM,data:8vEFhiBFhWB91LeLalAuG2Vt7GoBswHIUr4nnd6EfyXy7GK1,iv:kCpmyFyWxwuWQeelc0axeCHOZ89qv/AcK4VBhURJjC8=,tag:8NHa3TUDNWWC+UAMstKpjw==,type:str] +MAILGUN_API_KEY=ENC[AES256_GCM,data:YoI2DxZpTJ95Wrlf8fR78nM04Yy839v2cGSInXvlh6APaDKG,iv:x7z8HHT+saUegym+Px1wNPmx7ug8VRxcv4XsuihqFyE=,tag:M/LnaXh/sJ60IOuP5tmZ7g==,type:str] +METABASE_SECRET_KEY=ENC[AES256_GCM,data:qfU32TvQB1Q8RHPMex6og8bN2j6JPNatsgzv22iFaIu83xAtshMvp8Q7BaNuSwfBwShxc1fIgIxLJllc2L2NSg==,iv:r1ZB4HV1xiLcLF7wMCbN3BQ8yncMyaMQuobhpV/ruug=,tag:Gj97jl+P1iyEb3utG6ovDQ==,type:str] +NODE_ENV=ENC[AES256_GCM,data:x5s7Dz+7SjlKog==,iv:Deo1oawK6hF6qhCANL73lJR+e72Yoth8exoNw/DoObM=,tag:AewO8DutFe795MdMCFufFg==,type:str] +S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:zvA3GfI2nCVT9fBRSLUPRZkf5/8=,iv:jHnN4we/rOGiBSXS8BsrvifWcUdlrgJqMmCjUnseQ5U=,tag:O0TNFcxCoGajXujEDnLcrQ==,type:str] +S3_BACKUP_BUCKET=ENC[AES256_GCM,data:Lj3dJBNZRSeHqUQ=,iv:RGfg+GIG8wnL2O5eRxChKGmZJSTVXSHYWwZ+Zn0EDdc=,tag:jVbfqi3xuj9i6bLqla2LIw==,type:str] +S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:1OrAI1iKlYgehYedsVEmaY0h0xIKIBlyQiATFdHR7wmM2Ac=,iv:RVLgTyh9yS6flHbwyLt84VMos3XVE69bnqAmkOaUDo4=,tag:bbTLu0hfzWa5ozB2ojzuSQ==,type:str] +S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:TrEnluwtYjnpUUs3owZagtUyU8mB+pvaYfSTg+HHk2aOPADRLimhqQ==,iv:/sO28OOD8sHXCHSXCcSSiC+stN80ZRKGTh4TSINCYbs=,tag:alryeRrfX3GjomuGnQVT8Q==,type:str] +SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:KfuH9pQ3b0Ojfx3wwFR0nj9wrlKc2OiFTiZG+ooanyxljSheCWMTgXpnhspF/wggc4+D1QE9c5H9al4LHG1SI25/u6/zprzFIifMhw9Fy0j4grJDRJLZ6ugk7Lvvxzh9jWCj2Nx7wnUGgorIjFY9wb1yn38NZYF+qnbLkte+U4yc8ujgZWVGwh1WpAW+9YCPcTNTRAfbGGYVcCaytroeRGfZURygv9y0L7wxhZiGzv1DIRgpfko0OPgb1g==,iv:OH3Xq3NiRHtI+sPf39SV8JBIWhBZPAvpL2aLu8ao0PE=,tag:htgiLoAy/C/96+KteX96wg==,type:str] +SENTRY_ORG=ENC[AES256_GCM,data:2MWR,iv:l22j8tSl0rPPq9+fZUjoU9eBykNMlsUM8zNBimA6bAc=,tag:0JBd/P3mVFQM1xsJ3Qp3GA==,type:str] +SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:ono=,iv:weVapfSe7tLG0D/c/iHKHBKt8asLOTe3h4j07yp2PgA=,tag:ltuz7Qzpa+r9dw8dJdOSPA==,type:str] +SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:twqzbW2Aq0DwwBsy7ccprRigsYFoJMQClh8qGozdHR5DiiIh/t0fS59qepp2uioLwt6F2SRBBY7tfyTxTBbyFglgEC4l/EBLKO1dlLYmjg==,iv:ns0rz9G//qFgDYclf0RypoXSc4w67j7hllght9JTqBk=,tag:Afev8kxv80trQl9bI9bK1Q==,type:str] +SMTP_HOST=ENC[AES256_GCM,data:i9JaAqu42uWPjO+PhqvHSUzvii/7X0xEjyPpvtXpJGN4Lg==,iv:b3OhtgljEsYQfeQUD4a5K7/IFP7aw3HkCWU6ecnU5fg=,tag:+BkFjOJxuEM2Bj+UKHyzrg==,type:str] +SMTP_PASS=ENC[AES256_GCM,data:pIalNdOXcThauiS7VjOXBhgh48XVN0t/WdQuzezqQV/GsUBDqw1IIKtTOK8=,iv:t/NQPiEkIRt8haHYtEd738agqK7TT3Y3kS7+ISNtAN8=,tag:b3EUgJ4dqMGFOnkMi/NfgQ==,type:str] +SMTP_USER=ENC[AES256_GCM,data:wjIZY8In97sSOoXL15RFmRndekk=,iv:2WivLoRaMYCVor6Yb6J6ttHOBuJdM9HfOEWQPNc/uTY=,tag:nMH1aF7seRXJlIUlvZA8Wg==,type:str] +STITCH_WEBHOOK_URL=ENC[AES256_GCM,data:oE4ZuuVMgAeKcRfo028sOiasF3bsAGirLc+yUbxJySYwUHVXuKO8t9cOVaIGJmEsqpPz4JUdJCR7rVPiQzqLFNNSb2FCtNsYfuykg8z1zLuVeaNtylDM+ZDoD1+S7GmFyNGi0Kgu2sxzp4cZ9rduom9lbVDL,iv:htNYpPakrOK3PuOtz5UHukwcLTWBOTx8BM3xNrGo80w=,tag:vu9G0ItpNlaMDLvj/CPtcg==,type:str] +ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:j1a+M1C25ICgDPUBjDQTOR4ki3s=,iv:6fEZGn06BUMWB5pkfY+vSJRB+5AeRgZph5oAForEGPw=,tag:BbzVz67rpGbEfa29ZCXcIA==,type:str] +ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:/Lh6A+47J2BXPtY+t215Jqv78pM=,iv:LTHXNsQkiS+bSldQCR8zewXeuy0+8C7QR6gtwyDcJaQ=,tag:EZQb764ROd4g68NFyZrjOQ==,type:str] +#ENC[AES256_GCM,data:shmP594h1c5CnReR0m1U3gpHDXUGM2V2AI6fDCHoFnoT4loL3LwZGVVa9UMKsBPF4nTiauIf8skWq/CBKIOn9C4D,iv:O3UN8uCG/5UiYMAHsC7HC+s/OOp2H6re0O0ctqUFhvY=,tag:exMjR/xbxO2ME3jPZsRu/w==,type:comment] +AM_REDSHIFT_PATH=ENC[AES256_GCM,data:dWUPc2D2bJO3nYJDyurckEfW2BWZj55OWt4ulYG0QzQIfOfmg6BvcALI4TVsOTnW9Q/xgnLbZIHrUY1702E=,iv:7arLiemNsZwqivC3WMiJJ40iXNqDVsh/RwbBsW0NdKw=,tag:ZooblXyzzbS2EyGH7bu26A==,type:str] +AM_REDSHIFT_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:ZSqqr/nG3JjIdU4VUmr2H3xhXB8=,iv:FJfbLg0luUYazTqCFdPd9nKOOcKtTczrkesEc8UguLo=,tag:ACkaN7f7cCZ68v9Ck/MQ9A==,type:str] +AM_REDSHIFT_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:VOi9wl4sQBibExS9p7dGn4xDWoZhuN+lXKbiP1+whubywgYCgYnqPw==,iv:qr+gBQjYzFs8+rQ/o26joLJHvTppNQA1o8trefHjAKU=,tag:NIcPShYCuyIIv7LemMu6pw==,type:str] +AM_REDSHIFT_BACKUP_ROLE_ARN=ENC[AES256_GCM,data:6eWGzdyhr+7SyWN67LTyJRWt6/XCVEF1W6+PDda1pX1ezHwkG1O/7gy45E62ptJLrv3+,iv:C+WZbmJZKM9KMdg+r2gZLNYVPuqWKxakEhuPgPXWrU0=,tag:KnR0iy67GTj/UtYfnbS6lg==,type:str] +#ENC[AES256_GCM,data:OPzgcUPQDarEoyZc9rfCgp8bOH7dr8dJapJWWw+6Yw1LzYU3bm6HGaAZGHou/uzXpSaE0tFNimaCLe9UB2VgLJM365uUZBNk3dbCH02JJGIFj4cJf/LHZ2ireg/jX0QgTSc68Kny+6lQn5jggEG7NeSbdybpUDbn,iv:590Qf8RimA3o0AvL6T4ztVsxgL1YEe2cto7hLHJzTDY=,tag:8Q1MQkUlt03ZfmStiJRliQ==,type:comment] +#ENC[AES256_GCM,data:IsDe8Ca2kQF7CbNgdIzh4BZWv7JPSnqyQHqjRt+mgj8g3zx7kMBc81xMSMzMlBHJLWs=,iv:gczcuNyKDDk8FS2llCdFm7PJxdeA6CcXXANnjx5a6p4=,tag:hJAOY8lSQoB3e51OGG/MwA==,type:comment] +#ENC[AES256_GCM,data:vtcU22QZkdqkoC7mCgJMmHSrB6MclkvPT6Rx1jBZ2W6Dfpz3nQlmPnqajHeL,iv:NofVcQTZBaky/TeeY7YK0rl3m85pQC5JTM534CocC9o=,tag:qicq6/4A3gHZnrIjjKvTuQ==,type:comment] +#ENC[AES256_GCM,data:wWvrc4R3qrOhzyaMZZdub4lnh73FydM=,iv:F5M0afhjSt4RcMVa5k6VaaWXGH+yXKwhUEbhH+L3gAw=,tag:2Y63Xh/pd999lZrip9Xvaw==,type:comment] +#ENC[AES256_GCM,data:zN4hrA34ka8RoFJu3XbOBJ6B+Vod33c=,iv:HCxBKJOoQOii1bA4xu2TvJWt+QLuZq408tWTO9mle+0=,tag:IVKxYJGxx3BeASs/98SR6g==,type:comment] +#ENC[AES256_GCM,data:gMZWS4Ozc9306qBc6gpCeIrCibDKa8SiyvxD9gB2bUj16Mn0y+EPuk+jBtJzyaiW0T6YZ+WODrgR0g7V77Ecqq6xQ1P+hsMi57GvQg==,iv:Cyp3g4Q5tkxgJaMZioDJyI2yBp3IHZrj3HVsfR8Tjfo=,tag:ljPs0RKe7ZRtOLI1lHMNpg==,type:comment] +#ENC[AES256_GCM,data:ZeDp9r07+f9pVpzAQgMHwgEIqho=,iv:Qt5gxxRToOU8oh0nkXGYpIDJMKe/aqIajchH+f/2P7Y=,tag:VzAC6h6/1d0hjRH/if6quA==,type:comment] +#ENC[AES256_GCM,data:akUbOuJaQDHLdwOBxNonQ1yOiQ90urU=,iv:op5jFbAZJHRuIM3u9oD/T/ovxemhDvWKUiOO0FvW4zY=,tag:8GexIZMmjDFj0arh+qdj6A==,type:comment] +#ENC[AES256_GCM,data:hedGehtv6rJvRtoSvrO+qg==,iv:CEp6sQXiczQboIEzoJg0xb3yqIy6oylVfYOPNmYhYU0=,tag:lGSmt3p4e3j56cmQ00V5eg==,type:comment] +sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAyRUZNbU1YLytkdWhWdXB1\nUTRkTktERnM4VmRyRGZDMENuT0NXSDVjZWdRCnlvVk1HNHlFdjM5UE9uK1AzRGJV\nZEp0L2hDRTJpLzJSOWcvVmRwZm1tZzQKLS0tIGZuZXdRdVNoaFh4ZU13ZHRrdEZp\nL09KS3c2eGJqallsRUF2RHVXY0F3WEUKliSIrRYs0MKqiYR6PlFvy9H6cPQB4J5F\ngMSnG0h3/uFdd07KE7aB3OXepS/wYANYuQ5FDsvsAFaENJxNinttVg==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_0__map_recipient=age1wravpjmed26772xfjhawmnsnc4933htapg6y5xseqml0jdv8z9hqemzhcr -sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAycis1Y25QVFFWaWpjbWo4\nWWVFRlo3OWVmR0dZbmw3SFVxWjVLbW9BTTNrCmVKNWJ1dE1nYnR2MjBSSkovWXZa\nQkZ5M3VqVzB3OXZLTDVvVjhuMmVzR3MKLS0tIDh0NEtjZFRSbEw4UFoxNS84SVkx\nenlkVmlkMEpnaWd3dU9OTHZyaGtEWGsK5aLrZvSmTnp2gETz7tS/zqDjP6Vd0H+D\nHwaiYxBndYBrM41e/oeGrVajsPmkUyILQ0pVhxhaGVT3/uJ+53aIYg==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBSeHdrWmlLbDAyL29DSHhu\nRnNSOEFNR0FyQ080dTE3enpBRVlVYkwrSkd3CkkvSHQzc1pJWFZUUXpyM053NVJX\nYWtnV2NUZHZpblU2Ym5rQTYrZkRBYlkKLS0tIGppSzhUT1lINi9nYzBnbERHSUVC\nQjJrYTVqUUwvRU44cDl0dWc3cFpOaTAKxgbIs7bawmyi1IUAWTo82nz6DiugL8Ht\nmrX9IAoM+oEcd50gaC99PKveUX6Hm9YFt01wYjcFmJlD0p60exsx8Q==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_1__map_recipient=age1vhftscteyrwphx0jpp0yl60xxrjs77jq05mkzv88js7ckc926vcqepp2cj -sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBTQmI2bWlUaVBXcW5nMldt\nRHVFUjVFOUVrb0JmRVB4aGFpeGgvU2RkT0VvCmRiY2hXRkllWEdnV2hQYjk3dDFV\nWVVFeUFiTEhwTDdMUmlrcU9Ib1p6WEkKLS0tIHEvdll2ZFk0dTI1WmhQcFJ4Ny9j\ndkJPR2lyTjR5SGVPN2RsNHpyWTFNc2sKKgk+8XEYDBZehXb0/wv6GBmdf4Qa4hGh\nkSEAjSNMnptQ18wKlC8oBzORiomoFWWcTXMgFQhQESgDC4tbOzDLRg==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB2eHhEYkpERTJKZWhqcWw2\nQnpheC9vR3daT1NxVWlnTXhwTTFWcDZaOW5RCmVxTUY2UnhUc3Q3K2hiWFZYemhw\nbUFTaDIrSjBkejVieGpIb2RlNkhMU1EKLS0tIFBLSVgrdTc3ZU9YMGZDRDJRbk1R\ncC9RWXl0SVRPQ1E1L2JNSmcxTmhhSDQKXyPOv47SGBPhvMJTFMw+mU95GwwQWl+1\n7tGbjWYk5swJuFqIzzOIkpPxKsusMo5DuZ601OBJjM2KNzjXa5R5Hw==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_2__map_recipient=age1slx6e48k7fre0ddyu7dtm2wwcqaywn5ke2mkngym3rpazxwvvuyq9qjknk -sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBLQThsemZzYkxKRGRJUE44\nRi8ySDJ4NWN6RmZKZkZXOHhZeTdpZVU0NFR3Ck43Tk8yZnJnSlF6UjFTTGdyb2tm\nU2Z0ejlDR1BMRUJLbUdKcEFlRWpYbHMKLS0tIEczbmd2eXIyd1ZZUVhtUzFsbjRy\ncURzaGZ4OTNJQVVZNGNiSG9yc0JSN3MK4om5yHTXGLuETxxqMiC6EeIsiOuzoxV3\nNON1RRRt9kzCCvGWT0Ek+Hpjwtx6QSUxXqRVblUmTLJoGJswhqYqtA==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBhRTc0THMyUENhWXl4bFo4\nLzRwS0RIUG43MG1OaXlrdkxKTXB6RG5kV3hzCnk1TDYxZEZjNE9mbVp5M0FEYU91\nRWQ4Rnh0WlhTeW1oVlJOQ2wwSDVFZDQKLS0tIG5MWHl4ZGJCQ2V1RDliaURpUDY1\nV1p6VjdkcXJOZkJxcUxWQiswVUFUTmcK/TAYHS0XclvIhwWZ4kKMOYhaC7JofcoR\nD6FRY8uxabHJ8jfpNqjh+MV7YUTGTZEpmFyThdWJrK2t9o4V68ikDQ==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_3__map_recipient=age1vfhyk6wmt993dezz5wjf6n3ynkd6xptv2dr0qdl6kmttev9lh5dsfjfs3h -sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAwaXVaS0xkNEJZUVJtaVB1\nZFNXcHhyaC9jWEVYcURlTW5HY25NNnYxb1FVCmtrbGdpTUdFV0xPKzVKTW4yLzNx\nUi92Y0NhYThhNFQ5VzY2MVZreW1nblkKLS0tIGM4OVY0ekdDQ2xzTSsxZFQyaEJX\nYVZRNS94aVlvMjFKb0F3VWhwWExINGcKs9IfpMtoMsnsWEYgDagU5I56H6ypddTq\n8oknZvt6vsqbT1+gExf7/IHqXVRI51wlOWtau3CksWSj/Slhsh3O5w==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBBMFNRSlFVckliT3I0blBy\nNy9lVjFxL2hMdUFZMWVXLzRlRXpUMGlPUjNNCmEwYlR1ekNBbGkyaDZVdFBhckZJ\nOVR3VjF1VTA3NU8rbzR6Zy9ZL1VkL1kKLS0tIEJ1ZW5LbUdVOStxUXUwb0gxZld5\nSHZoTkVLQkRzeE55YkRyMUVuYkJ0dlUKwHsI1pGDDIjTaF2tJ/anbuycq4IbNxEL\nmKkXkLjqPHI023iLrcdQejzKNYAbNxCyA5lyt5OeW92aDYXQJL5Rkg==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_4__map_recipient=age1jwuvzyghfer7rx3qtqa4vs0gxyff0sg6pqgmqvp5zlhnpmvrkdlsdha4kx -sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBXVXF6YXhTbGxJVFdiVkVR\nYkYxQldya1c2T1hMWXlhUVErV0YzdThDcng4Ckc0NFFFVS9OSGdOYml3SDR3N2tL\nYW5CODhvNFRzc0hIU1BzK1NmRWlmd1UKLS0tIEI1dnNvT0JOMGgzTEIwTEErdEc0\nd1lkakxwMnlQaHgzRFdhVTB1KytjSEUKGIcBiKRRETkIjj2n0rTLD9SM12tfys1u\nSd6qHl3vVIt/egY0ACttFFOEybRp/TCqsgZkPlOUIp5SskvSn8GZ6g==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA0U2dkTTlPN212TmhUVVRp\nSWJ2L0tEU054MGcyYW8waWJ3OFpPeGdEVlFFCmI4NkpndSthTUVIOWlVNzYyaFZh\nSkhIbFdNNkw3MXU1YlVFelFpZSttVE0KLS0tIEcva2NoWUxYZ2o5bC9ONU1VeXlB\nU2dseXp1blZpNm1Kc0RyL3M3Yk1LN2sK9JqFOxQA5rDCtnyA8cFYb1VYJ8dWfrj6\nTwsiuNJDTfyNgoNYx9RkahjH/nthCm8IdtBM1+TI77Ru58U0hLPjSQ==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_5__map_recipient=age1pgxk292zq30wafwg03gge7hu5dlu3h7yfldp2y8kqekfaljjky7s752uwy -sops_lastmodified=2026-04-13T17:43:31Z -sops_mac=ENC[AES256_GCM,data:BiJLzcwAgL9o2vByU05leeDocgOqTetGb6OOilexkq4nb+kgxhFw/QUcoRZ84aSpOktLiEXGa3ivTUwJdsg8MwxwXbLzqtltamLTddwWl8sttLHbZc4q+s9lLBKbcXeZSKJTA73ktT3PT+I63OH2u0hpKkRQPAcCagPKH3HN00Y=,iv:v5Ef+m6TOgRaUKhXFjaVTrRI6jSziGlXM/1ZeasN+rM=,tag:duwwCTtN7uljoeAmn4+2Cw==,type:str] +sops_lastmodified=2026-04-15T18:32:53Z +sops_mac=ENC[AES256_GCM,data:Ts3o8hy0OP/+dc25PL0LkCH8corWa7MJMsw/LFsS5+YnaoIULVIYytXpR1zAvVtH92WOJ43wvMh/C/c5GcLVYi3xgvSW9OFGoHobgh0n9/0GMkIP1qLQxRj7qk3yRuSU6E6fQE3LheCi1BlgUvd22l1qhMOdbwWBRsNuYbGWWY8=,iv:BPHRHRw0gzPzh1MR6S/SQ+uptvoJyibjrKY0Y4GaitE=,tag:HCwPb+h0W5KjZYuVTiK2Cw==,type:str] sops_unencrypted_suffix=_unencrypted sops_version=3.11.0 diff --git a/infra/.env.enc b/infra/.env.enc index 9e61c814b6..5a6c86d907 100644 --- a/infra/.env.enc +++ b/infra/.env.enc @@ -1,52 +1,68 @@ -AES_ENCRYPTION_KEY=ENC[AES256_GCM,data:i1lPMpGsxl+t+VoWTZHqLYQqhoCP3HHUNRjBdxHZ8Asay7Rgu2rxW8oH/l9YFsQ5SDvUkhoZVWxUcrMfK4tKLw==,iv:8lY34wWGVK9V6uy6/70V/P0Ag03Z3t5ZWKJaLu/3FW8=,tag:wB7HUpySY35IBOrxBqmOCQ==,type:str] -ALTCHA_HMAC_KEY=ENC[AES256_GCM,data:YenPYzl7+JQxxsrcZ6yANu/nUe1HYiA6iemPdWdIc/sVt0YKLe6eaXg09P9qezSEf2/qFhB+6KE3rVC442s5KCHdsyCTx0yXEJvERV6w3EWM3n5sU3W1Tbd58pGmvnFVrZ3sEoiQQDNw3eutHX0wWNqEo9nv9D1QK8j4mAjqNsc=,iv:ClP3y9+k7q7p1J/Cq8qDplqq+FkAvYnvF5VtWwVtog0=,tag:Xqpz0AMb7a6GYkcxv0Qx0w==,type:str] -AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:Npvk3CgWd4fxN6jqzJHNCtBgDU4=,iv:A5C6gNkM+CT4mq4PoSsbvRH6LXYpI/GAIxHpsJciqM8=,tag:ZmQzOkv46S5zRwgYkd6fKw==,type:str] -AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:yyP9C42qEWIrK0FEoW6x2OWMgVw=,iv:c5QemlK7aexP3UxN2ITnPmphdqEaogrxI8jQcA7kuZ8=,tag:oZTLMs1ZbOsQyUrQknF2HQ==,type:str] -AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:7UiVDLIFiiGBGEu9dXsSPg7n0+kFuEJ6/8B5xX2QJ1PSPtAnc9cPRA==,iv:wz0ckHGJ4VKY7JL3HAaoxFnhyED4SuJ8BCSZj2MKd20=,tag:ar6jeSPumK+q0ju26eoIkw==,type:str] -AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:DtarDKlVR7GWpi/cOE0mk22qcT2n0DvriXYoL3ITyFf5CJ6kEeFmtQ==,iv:QgrehXOhje5d2y6DAvRWP1vDRc+HXFBjDirDx+tFCPg=,tag:WM/2/i4i0sz98QkuoMrrFA==,type:str] -BACKUPS_SECRET=ENC[AES256_GCM,data:v/H7GbSPp5xKhayus0DQWzEPZSKLfT4zyaoquDme1dt92HZhCWPgj314Aq4=,iv:wFrrugV0JPfvZkl9EbPAMMXG/NESzAYmvyy0jysr0bM=,tag:AQ8f9MAtUd+l0mm4v5MRWg==,type:str] -BLOCKLIST_IP_ADDRESSES=ENC[AES256_GCM,data:uUGTG+vS1XKHIO1sLt1TV8oOLw4b,iv:tENw/mHwANCRFBjkE1LslGk210O0BoS11rCS/YaSryM=,tag:WRSoBED8937LDuG9QF/ZIA==,type:str] -DATABASE_URL=ENC[AES256_GCM,data:TM0JT8hZDVD/tV4w94U0IGjYlXYCkxtJMfIhjn+UFjZ80oQp/LIYkvN8Mxc=,iv:PPH4TU36aEFdkD//kkG5NbE6ITYHfTV9XaHTjmVIRe0=,tag:c/kJ55XC4YzXcSvqY6XPwQ==,type:str] -DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:rCn4UHmvIN0UaredzgdO1+LD82MQFysl5QYUudY=,iv:VNBjb19///roZThbANmf1RXs/+jVE3mnd/TxSKnr+wE=,tag:ptvGT07u3HJl8Ai5swY4iw==,type:str] -NEW_ACCOUNT_LINK_COMMENT_WINDOW_MINUTES=ENC[AES256_GCM,data:N7E=,iv:dICixbNZl4b8iiH56bpZxoJcfuMJBisqzCwwbmWpMzU=,tag:Jt314MRWkJbPXKEuSwNwjw==,type:str] -DOI_LOGIN_ID=ENC[AES256_GCM,data:yT3//XQk,iv:jUTZbyxI3zA2vqFQJRgALSsi+MLolZh9dZd6e0hTTCc=,tag:DNepoXR+E9HnM1VAihXJcA==,type:str] -DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:TcS9bQvR6wVjXzQn2kDOIZD7OtM=,iv:c4SM41Uu5HnG1agEniwyNpy2P5L+Bcq5/8gooVj3C8g=,tag:pbu1tAn074zwOoAz7gjXIw==,type:str] -DOI_SUBMISSION_URL=ENC[AES256_GCM,data:Rk4lWg8/4z/tb11qNrpYUbeP1qQzXpGqGXrjLh+YZbsmjySyQivGCA==,iv:H/IM+cZcr85BdTEXC79epWzuntBR4jneQEJjbTxS3M8=,tag:e9BEC7HUCAcHpvMMSeTeuw==,type:str] -FASTLY_PURGE_TOKEN=ENC[AES256_GCM,data:eCoBaqHYlqd/okU9TOO9e1tpVD8TMEGPD3KgBobaqq8=,iv:TAje2puLdkSMj3th7aHjrV3FeHu+qMQM8F3l+x70oCc=,tag:4adfTbpnRjcJe8WNw0kshA==,type:str] -FASTLY_SERVICE_ID=ENC[AES256_GCM,data:hmuPYXwwzZ8TM8A/Wz/aTmhbnGLt/A==,iv:IrEeoZm49mZBz91fKFY3l3j7C3JWoILIYvA3SoXEePA=,tag:HTZM70jFAve9o3QLvBv6bg==,type:str] -FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data: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,iv:vkqS6+DuihShziOh1/tYz8zCJCorEJvU3FMY7n6WZIU=,tag:pk8te1WeM6JyFynZyoQnuA==,type:str] -JWT_SIGNING_SECRET=ENC[AES256_GCM,data:X+B4OVZbnRFvb8Y6DN+FIG6HIaWqBcUqOzR9SE3UOaLEXSHOR6HqLQdG1HHtcT1cJ6Z6FeuvmZkw9V6NxsOo2CyxUHfWZzz5mGKhw95rbUMVXDw3j6rwQDlNRqSnCONdkK0lNTyKlxx3fAOb9Edcrnk+WD4kw3Idc24/cO3WH71ELzzcJxEsWzsrIKr1ibJepaFAHSVtfQJkGGwoZcs4YnDn+HW/83TADksv59xELe18XOR9t2npIcxRppvkP86vcmCsxWUYURqgUvc2OKzwEFbBaRZItIYwdBk4LZEm+K6J+09a3dRHnbI8Z+EWczL5c7CmttkoZxkdWh4mfz0NjKYSsihvvvROC18Mfgi3tXYgV0sw0QUhOzO4FSfS8qGFUhLJQY/N1kfXxXl+Ijg118GCnNRbcjAdV2vHM6Rz2lZRM1oSfc10b41ZGRndddbxuccYSvawVsx7/Qn6SmoIFfe20MyCQDrgvpcHxg//XDm+044CeyNoKHkPHEP4R/Qf1njgla6igUzqL2uUlYkswgNqPnAMBC9bBRPyPw2rFKNhJGFnAAzv5zaEKmnTDbid/omvCBibkljSjvYrXLqlEC5HukJgJYGBAndIawUStsznPLW+lk6MrvddFD6TCFGDpsTOdFz/HpZorg7xiEkeP+INkiwlH9oPaTLrDEmIMD8=,iv:Ui9K4t5PGxcj7DZJ5eiGXwpDISn3ZkfZCzi4nS4atQY=,tag:kMHZyJCSt5tT5JIydUjTfA==,type:str] -MAILCHIMP_API_KEY=ENC[AES256_GCM,data:M9r6uaemvCBkj9QwuXTOVZefyyIZDgSPs1BQjdXjVn4Z9aSv,iv:sVrfmbT93IzLZAw1fyfjzsCbGIpmGpEW1K9nIO4QK5k=,tag:C9p4ByJEDWWpI1H1Az7M4Q==,type:str] -METABASE_SECRET_KEY=ENC[AES256_GCM,data:JgGz9HJ8Jl0o0bPRBLwKOSfU2YZ2Li3cq0o05m9Zy/9J4PPEwCozvEIGW/uBRt/FVbnLhrIuF0z/x7WMgVqeag==,iv:+Mu+9ie5+f8opNx9xlHMaudfj2OxGyOT8kd1WVxevew=,tag:xXYi2zh85LOym3O3cullyA==,type:str] -NODE_ENV=ENC[AES256_GCM,data:wptdXsmGMwsqPw==,iv:ERvn/c/rC7jVux0J10bEJ18HCk2L6VKfYH9ruMj2wpQ=,tag:ubofF/VKZQpGgaIpAJUqFA==,type:str] -PUBPUB_PRODUCTION=ENC[AES256_GCM,data:rQrbug==,iv:j5GsDMT239dozmbZUkxXG2y5FvNRxbeg1WKjcUMvUcE=,tag:aNOuEE1iq3bNZ5cTcjqrvA==,type:str] -S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:RcCLQKq+kgLhVWwBq+EVAg3KRBJlHjsqiCv48U3ioUNUGwA=,iv:jAOsE9KbY4TX94CsAH5ZrxOKG+UDgae0rSiuBHwssjg=,tag:mr3J9GpziOludJYS0q0WuQ==,type:str] -S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:F5K5fXGUl/AfBBtj19Up/phjkio=,iv:c5K6mBn3xMzQl+6eFjq4jHyQjduHx8ZddnmnXaVBbcM=,tag:bRQ1pVDZEl5BUhL4qGeX3A==,type:str] -S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:b8so1vrReeJM95CbFdmz4uMLSMrIhlYmbReI0vnozOuldTSq+aCvHA==,iv:dgadvV9njxC5iD9kjUSn9a7bFjUg3+jHpPO5bem2mlU=,tag:hD1s71fBC7XfJ5rczb3aOg==,type:str] -S3_BACKUP_BUCKET=ENC[AES256_GCM,data:q7CGEvbxAVb96wA=,iv:M/BPFZCQgyA85SbKEL/w2QgDAOK1PJyalmAQqt1gQX4=,tag:7MpTG98k5Ql74Bi3iMNfgw==,type:str] -SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:QTLUOcC4jTpEgVH8ntGBkR/4m40Ka8188QnJYiRwxqbcXkDG4tLxDcYu1fU/i9cBCI0ZEBICQxNhJl784ivJOOSF/Yznd4ug/H9rMCaFB5ded84sAlSdqFvdt922lrGIORQ8LoMvWe/zEK8evWPUzeHZpgg00JUBtlMfdZ+7wGuRLeoJBikQA/bmSQfy5Inrg1LdQ6KJkv2QjNnhDhoCVPA+PB3DFPfdhdAyTlC9qQYCAtww6ytQcSXIiQ==,iv:dPf2gke366fujU+6/SwRkSbikDQEK2ocrCFgaFysT+Q=,tag:LdRfR+FaJxJjebJJtlyDEw==,type:str] -SENTRY_ORG=ENC[AES256_GCM,data:qPve,iv:H9aa61ouwBC5SWjoyIPAcDAehGK4F4aYHbzBTyxx1E8=,tag:P1GXRPH607EgPHk1qBzlGg==,type:str] -SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:oX8=,iv:IPx9b2eskN2yLBFabGWU87HGI0tAyPx01u2lLqqbOXo=,tag:e41g+9Hi61SDtwikzIHUng==,type:str] -SMTP_HOST=ENC[AES256_GCM,data:SO4h/DilRx038Exedj1Cel0JWt1Pkjwjv2JSy7kGIhYQvA==,iv:cLbDk9pxw3Oc4mgiY7OnruQ0Zixq4o+NoT0FvsCpAAE=,tag:jbSC+jjPHOi8dg7wv1O/qQ==,type:str] -SMTP_USER=ENC[AES256_GCM,data:ADauFrQGLHw49U562G2mVzv2+Cc=,iv:rXogsMS4cmkS5YUVlIJa/gJQYp+HvU17b81lpch3UGY=,tag:u/zSjf57kLz4ogeytKR79Q==,type:str] -SMTP_PASS=ENC[AES256_GCM,data:PZeUn+bhusf6u8ywiSnKrXhkQpxx9H7BgpLYCAS/AkJSCoZB1CIfPwr+quc=,iv:Gv/w+U6kD0KqXcTn0yI6meurIVumBOVm3JQt6ere+EQ=,tag:WJH1JFZwFd9Dj0LigfegnA==,type:str] -SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:HSl0nOnuJRnGWjzX09q9+H5tdGi/qeogvx5kCyNAFEHNCSZK9vOVvYwA/saEHpcjKEnvfEWKdQeetsSC+vR+EKLI/6gX8bwUVQdKaIIOog==,iv:AXjfCBv+JN9AIHY6/l04EY8ms02Vqtgdf1uButF+HHw=,tag:bLUH48ON4EwhjQsurD9PrQ==,type:str] -STITCH_WEBHOOK_URL=ENC[AES256_GCM,data:zoJiQ9ZlIxY9aHaCtQi6HeS7vj+zh6tJ1vZHQ4/g5dCcsQq1TFkYnXS92dCUxvzWuKDlg4+UvA2PJdqIr57oC6b+B+0YM8WuUIcdKZdZCaFOBXMBbeR8R42xhehtIyrrDaQaGYjTU0KRNOdkPCKb1ZgDp3pZ,iv:KW6Yql9yDhuZJ4RX4KZYgnsFs4L9fc8sr9hkQzD+Wvs=,tag:9Phu12xpWpANYkBE8g/r4w==,type:str] -ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:rpVgjQXlqYh/bXOjTxUATtS4s8g=,iv:/eczGd0UZ4spCN6mHsHO/wTX3wAMFoHa+1dqAF15O2k=,tag:8GGuxYp4+y7AlblLQvzugQ==,type:str] -ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:gRketZWj7AfcC1HZ14YCnp2B0ys=,iv:ADnx6a40qXzyc52vSh641wu+5HW8GczkOJCmdIoxL1c=,tag:HmYQrzuusSWpmX2m7BV9LQ==,type:str] -sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBFRllLellPdUFqemYzZHo1\najJSYlpuM09wRU9uMmZRMVVMeTZzVW5VUEdBCnhJYjY2WGlCbWF3dnNnditUV01u\nUUxzK0pzSTRyREdKL0V1STN1by9JeFUKLS0tIHU2a3VseU5wb3gvdWI2WFNoL3FQ\nKzRxSWdoUGw5RU44Nk84bmgvemlZVWMKuk0neuTnFJw1gb3P4yc9s4b2ETa9Ycu8\nPcuNXzlDo1m2FRwgW13kmmt/Teq0622GYIO9oQYo265PPPdSj5mG4A==\n-----END AGE ENCRYPTED FILE-----\n +AES_ENCRYPTION_KEY=ENC[AES256_GCM,data:Ui95vUqLY/G2f/1rcD/FdaxiF5Zmk9qBd9PQl/Ul4XZvr4M14oZTbbE8IB7FuF5ie8HWDWWIYcU1T7sdsz+RzQ==,iv:I8bUx6PzPr6IK3nugl3/CZGuNaljfWOqyt7+eEeNkck=,tag:S5p0lpHqA2LR8Bsp2M4ODA==,type:str] +ALTCHA_HMAC_KEY=ENC[AES256_GCM,data:oo1Hc1B+9RlRk4umcCSzlhflHB7hV3MDnlUhudMCuiEoV4+iNY3PPuNQHbsvevPnuIeAPHFaG7wA566Kdwc1gEoK+WK0KvvvK2byOw6IzJRWOyf7EEpV3YewVXmwQDSBp1YIidMDM66EAxR8AH6Cs1gayzyS3bPGqRNe15FywYc=,iv:yFZFP+rR6yTtrE9acWW4iiC/KAzcALsl+NdjxbPNNqU=,tag:4G0nPI2s9IBurcTqpUuReQ==,type:str] +AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:JfCeg/T0BeOzIQft7GPgexf4kOY=,iv:5y4euI2hkNIzzOJspRmdl6ew/P44eHqjDqMttySvwrQ=,tag:y43N8u9UkcwVfeiW4RnGaw==,type:str] +AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:gGWKKyerQwKptnjri6LB6jOKJwQ=,iv:hOvG3tct0RgDY/Omd2fx2+SVzylFYFxK9nCgzzjnMR8=,tag:moAleVDs90VggM5Ou+I09g==,type:str] +AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:OiIY34wX+x6lYIiL+39eshFpeflU2di2dryO69ECPfvxkPhjWzntew==,iv:DuvHRWz+OJe6C8sRvy5Z0TJsN9pfD9MXkaFoNOXTvX8=,tag:QH8DIAk8zN5SjT2yZP56uA==,type:str] +AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:0pnKqThCTK0mLQnh98l6VawCgA2KBD4UZ8YDJ4Nyuyxf+eBG2yOqkg==,iv:+ZNIas01EJMTzOU1Y3BtVNkdabd9SA+rt1t4UcKZp5o=,tag:h9vVf97l3v//+N0DovuJDA==,type:str] +BACKUPS_SECRET=ENC[AES256_GCM,data:iVWN5AhCYGseXzZQBvtRnp4aO9knsLc5Hz5tyfGLKpYED5QAsPcyrtk+R5o=,iv:FBOG6bvNAzDDgNoA795KtYv96TY9q9OJeztDHoNouEM=,tag:YOa+wj6s99OXgKPgt/1L6A==,type:str] +BLOCKLIST_IP_ADDRESSES=ENC[AES256_GCM,data:Pcy7hSfUwl+MBjcJV5T5z1+V70AG,iv:PsTMvFvcTLv2+rMgb912WeqRQGlBEZ2JOCfzzwZTOfM=,tag:uF6KjILqEkwLpj3ipBnbPg==,type:str] +CLOUDFLARE_ANALYTICS_API_TOKEN=ENC[AES256_GCM,data:i+g4KxXrgPxgMo+wZ0zTlSh72RTwdoLH61V90geRxpwwOf5u7Ue5HuS26rpuzO+u0q3/LKA=,iv:ZOvNALk+KmO2HPTi5A4vb7JhRUyLAdBVGn8/m0aME1c=,tag:6zXlB4n21/Pqaxipf0om/Q==,type:str] +CLOUDFLARE_ZONE_TAG=ENC[AES256_GCM,data:Xt3vEGsfOM1FrLt9crPvUCrDek+sXBJ9niQy1zMMuSg=,iv:WGEgKfDz7HvnO+iw14xvbltakAdOII7zq00u9lQwFjM=,tag:0gWEWH6g9u8B+b48WY1/hQ==,type:str] +DATABASE_URL=ENC[AES256_GCM,data:nwiAGeb/u6LQC7rfg1Ru4F4iIGGtJMR0NaT5YHHJzq4Tf3i5DkcIJIDUUBU=,iv:m/Tw5+hsz9AH9np4qijUB59dwpQ1N+3yTDb/tVkN40E=,tag:JTJWsjwzWvITb9NB0yR73w==,type:str] +DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:ZH5JC9vE1UAU2Epz7DzESByzQp57gyR+J9zdXQM=,iv:uziEOlB/TBG9raV3IsYDsryUywZN/yTJ0tCiZ3I4cSY=,tag:/PurtclnuVB9rxDvBp/5MA==,type:str] +DOI_LOGIN_ID=ENC[AES256_GCM,data:aatCuRDt,iv:9Fx/FciXouymlkRTMO8W+eI26XyaZvMQVmfejb0Tt2Q=,tag:HlhZfXGYTXEMnpLKrR44YA==,type:str] +DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:V5QYtcdXhS8ZSigFx2IPA1wzYNk=,iv:x/632bVVT4ZGACOspA+4lnZio8NY2GS7FEDqQhx58Nc=,tag:tYUgWfsMyQTkNCYHtjcD0A==,type:str] +DOI_SUBMISSION_URL=ENC[AES256_GCM,data:5aIe7Q6btQa7Ay9fxETATB9p6w2fMiv5icOkRtWtJLYW9GRxRWyabA==,iv:FvXboA+elgPY7+/PEaARtAXIZLnpDAR7SvzO2Azgnv4=,tag:U1b+t6/xaTalBkuN7XMD2Q==,type:str] +FASTLY_PURGE_TOKEN=ENC[AES256_GCM,data:S5X6eqK6aBKYvzPWF6+xKs9AA7VeLYwsU/ZWQ4GxF5A=,iv:JuhldtCNS1MV2RBR/sfL7NnKWeRI0+zoO8gOYbtdlbo=,tag:YjjchdYtu+WNp0G+2J8aNQ==,type:str] +FASTLY_SERVICE_ID=ENC[AES256_GCM,data:X5JHZ3poR0PSnv5XIc22KfFv9czwKg==,iv:Xc3xOZ4f96zddfC2vffhd7ATsk+Vo6NT7LjCDVxsYyw=,tag:rYAIMoxdoJWn7T2FZRE02w==,type:str] +FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data: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,iv:wA4rc6BK1skj2aQr9bXlAc0nP9bTXzaE6yHPKF1JjNo=,tag:H3LsnFJdFRplzfM9/5HtBw==,type:str] +JWT_SIGNING_SECRET=ENC[AES256_GCM,data:JxF48s+ihDRN6zl57XlBgiCUAymisZCqLpdBkzo6MYg0jaSP56SXVGGbYRV3qyV4Un9usu7e1Ltar8kLASs903ZhlDgBAdDGCq6OTNWelWVaXhdloUKiI19sTV6jTvLKfD7fT/3eEpTXaXNypYvJTFo9xukb4fm2bPH4h29H5qrv1nhKGEVExHIDf8LljjWIqoXleynuwcqeY410vpqRSZsQYrZzQmhc+VD3kOXKoTj73w7j9CTfJgE2sEx8nrHftEo2ez8pjgaiWFdXlt4Y4rE/0pTGUYy+azjLElyBY5FppaNjXc9W4mOhaact6BY4RRUjc1Dp61HRwPO28fJg+RI2Bo3phIH2wzDZtgRSBR8Q3R54rCnNQvG7r6sWaC03IY+okCaBILwR9IMqlFLePpfJb+asyrVuTm82uIik1wE6tKR4O4jmkp8zfMXyz5NSTUVo6/XP8I8Jv9nfpopHhP92FlSUa0lDsSOsAa50tLDxas8QZRI9J1T0Xb5jkdBbrWmNfmUz8TkOMxHlAnSPkVroRDfvQKWlTUjgSMlcEUKssWxYYq/awbguMwcAPFEObSEaEY7UhmbYlcKLmVcaSGBipcaJF1NGK6lJ8DiZx/Zaii6WddyDeTJtxJXnteaQPcS5wSN1qj+KYefXn5a19FKBeSRnkT0KKgNFvAHOc0g=,iv:sqENtnaEjUIYDq/9Q2557aIDFPhe0+N9bGf0aWboiM0=,tag:f9qK+cwExgIi1sn/6eJ9Mg==,type:str] +MAILCHIMP_API_KEY=ENC[AES256_GCM,data:6NRaF25eBYrqsG8wWof+msHo9Msxf9CLR7/VEBvGBb2cJ2PS,iv:+4+iVWHW81SVBnMW1asJ9T1Or4hnPD50HoytVOROTq0=,tag:HkutAebm2bKRLVaCsxrbpw==,type:str] +METABASE_SECRET_KEY=ENC[AES256_GCM,data:o4sxmTU7TqaE4xcF5ujJZUDcTky9ughd4uw/OTvNMz70Tixhx6Mo8NGaZsOOO1HbDS7QJ4aPWv0qBaUbPQAapw==,iv:ONPeQ75IPNzLcn3ZIcUmMuFiK9eTOFwAg6XmNFGEFSk=,tag:fqYLhkLCVhwucj27KU1sXw==,type:str] +NEW_ACCOUNT_LINK_COMMENT_WINDOW_MINUTES=ENC[AES256_GCM,data:keI=,iv:WOoVf4nBZDMEdt6fuY8jxIi+x13qmLEWfrPXULJ4Qbk=,tag:sOEL37Lhpmf+WyE6zEp8vA==,type:str] +NODE_ENV=ENC[AES256_GCM,data:EnQ15KJ5Jf4DeQ==,iv:7aXPVshnuOuJohlf+hNGPBJXKnzJ9+9XoRZa4JAQx0M=,tag:KoodR3g/7doSORc4c68cXg==,type:str] +PUBPUB_PRODUCTION=ENC[AES256_GCM,data:nUrdaA==,iv:72bfWPP1saPw31EkmmBquhtbi95rJOr2g0jgUo9xGSM=,tag:H1zDiUzpn/X+smxyaW3dew==,type:str] +S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:4jQaVGHL/fy19Jo76aH+bX7vUKQ=,iv:nXP539AJbdSgkXzUuA41jsKh0Gl/8f8YSsqtBhu4xII=,tag:BtjURukoqejdzP5eG6dPjg==,type:str] +S3_BACKUP_BUCKET=ENC[AES256_GCM,data:wxn3e9/czomF1OI=,iv:jAZyv7oChFc2nY2l8echaMEDQTG+rNo4d+H4HhUwZx8=,tag:bG1i6pbFXpdCbw+F4QAoaQ==,type:str] +S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:ySpn9Bms+6YaSBOAqQsM2mzCp0lWm6YmpAWBEraP7Xz6vHA=,iv:q7qan8OqUtn20RlOZI8/jr6iS8SMcQYani3/xXmwSGg=,tag:go/bW9hBdwYeMQXxeZlU+w==,type:str] +S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:waqthMMpZHZqbVyWx3HqeuW2eAVZoiRx52ORgO7SFP6zEgfDjm1Irw==,iv:jQOuO3/kmEiZI6KYFZ6eAYtgOuWLD+/HmjboBc8G3ZE=,tag:aXcPkdDy2I9YBKGZiwJV6w==,type:str] +SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:wETPjQQEUAy3dCm8oPYLqeKwDH9wGO4sOsvEJTu0+QxOpiQRWltGWNkHAsPXXdbPUD2R4OiE82sbr32y5TdmCKaE7R9cXLQl9NQGsR0+jIRhiSf8JlOH4ooEvaSCaH3FcOSFE2TtNaXf8pl76Rh2+naSvgIOIWHbNKysFaSTxdZpr6T/5ekLZeNO1b1C+m+5HPYTFCBzW+fLYmfebbXNSIrG3A+D8CL38BiGqcyncmlj6lipyJTumpLCWg==,iv:euflMK8i4pdbdj6G8EjXp+H7S6TzV1m1mDFPr3jIKHU=,tag:aTF7mNb7K7Q11gKyajSXGg==,type:str] +SENTRY_ORG=ENC[AES256_GCM,data:WN1g,iv:C9sbcs49bITQ/i5EuxUsCXgSCL8k3iNzacoDEII1p68=,tag:qgPpCqgmepqnriXifDCv4w==,type:str] +SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:zjo=,iv:2SrKZRhXKNdQTyKRQCzGSRQ9ATwl91N8VCEbaMvF4Rs=,tag:VPQdStR20kkIuK/P8fRfdw==,type:str] +SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:2rIsrHikodKhA2l0omEeuAKcQY/1k3VLFr9WgsxpCFGaZz5O+VddEYfXE+DrxcGU+/OnBi6Ssq4Sn2aYu/XkvhTU8nyzGkVlVLJwaeZ1ng==,iv:+ccSwsH7T8zqnZVyRy2UF3LLnl4Sn5OpeqtIP5vyyaU=,tag:v820AKoH6IyVC920451nXA==,type:str] +SMTP_HOST=ENC[AES256_GCM,data:zPSKG3aKDZ9DIrXl3b0EOvgw+JoDVS7TSGvFm6PYbIH98A==,iv:lfpbDHqvVH7YOuQeCR+IeJ22LiVqvk6vYkPxJdh90as=,tag:HDwJrIk8XoP/CL25HGx8dw==,type:str] +SMTP_PASS=ENC[AES256_GCM,data:hwxxJbvxow5hEVfLCNQ6kdXwsfp8BTKiqyBX52Jdha8mqFcyeqHuDGTLDEs=,iv:0OHh+bzoBiRoheBQmX57IhOJjA0ASh916nOAIiZL+28=,tag:86+pC+ibCdU/ex9ByyWxcQ==,type:str] +SMTP_USER=ENC[AES256_GCM,data:640gXFa+ySFr12kDxGEz7d72QgY=,iv:L5hzg9g9dA56jG+7Xko/qgXGNMz4yZOHhhUHzvA7oGo=,tag:GwHUmR/JP9nsa21ZCZphXg==,type:str] +STITCH_WEBHOOK_URL=ENC[AES256_GCM,data:Lwg99AwYPgGEuc2aiO3ayFeMT16icSfOTzuSoSctYbIOEG651T/2YYBIVXT37yAaP8gArDEiM19DjXKGuZK+hUuRM9gjkNMYgbj5rHsnAAh4FWqRzZJjgrGRDW79HwQ5fo2bA+af9lF08pEdOrPNdKYH6EXg,iv:cpRi/MVVp20icPKjHyTb3Jr/jI3u9x73gtSFRKc2Sz8=,tag:Cz3gfvNdr5FAIhT7d+TgbQ==,type:str] +ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:fui6RVlZOlSsUNBq86dYOqTexjY=,iv:BKhff03EAN1pF1+933YYVf+19FvftPzad8jbUZIM5qs=,tag:ngdAsFyK47pJUd/4Xvezuw==,type:str] +ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:9jU5zCYTzBMaTjaT3Rb0DIXaRR4=,iv:DqVGEgiYd0Pdq0geN1GLaowiLrXE1AP1WAXZLp/nmv4=,tag:vRAkHobs9ve0xomIkCEc8Q==,type:str] +#ENC[AES256_GCM,data:OP+ZLOvH2R52GBI80RcU1eYWfcb2W4ly64OnJ1BfYgmJTx9VkRaqyWGB7as3d2W7p+ky7lkFnAN7iLswExbwiItJ,iv:3rXPj9fIKdE5tjsgDIr08J+Gw8tCXYT9Pu9CX8GCmwg=,tag:4WZ8fvwFyRHvt3olQKjc9Q==,type:comment] +AM_REDSHIFT_PATH=ENC[AES256_GCM,data:igLvrNR/FpflwvkSlRnfSIInWrfCLxmPJupSEDPwbmQA+l0ev81/Sj3PvHOfHvZEQjmwfTvYc2BFMz7C6ok=,iv:fNjdzWbt6kfav8xf52/UHQ8ri8YJzWH7HaQSkR0Q+To=,tag:mb57ik4m9zN+iIiBYwMj1w==,type:str] +AM_REDSHIFT_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:NpJj5NXkdeaJJGtWhIUv/k7TU34=,iv:/yt9D6JBsfl/fvqg+E+MkkiL3a9FNKbstFzMSym1ypM=,tag:P7WsaMZxT5StqTDYbHvyKA==,type:str] +AM_REDSHIFT_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:7+xvjVagQ6hIJs/vtCfGnnuYokquAvH6rfq5Ez86wBlwIQp74Jvbrw==,iv:E5dFxn0Ra1SeAQGHHLdzb7j27mDI08dvRS9X/Y5KUpE=,tag:H8EOanX1mGXRiwOxkftsBA==,type:str] +AM_REDSHIFT_BACKUP_ROLE_ARN=ENC[AES256_GCM,data:tItV2uNaN5bj+jY1ghsGtL0nInranzwTh+KEoa5NwDepD3akYfs+wDDXUytM5JCrDTIY,iv:qjs2EmBy0s6qCLM8IIO+4BJpp+8RP90WfmhecWKRuY8=,tag:FEw/9d1sExXMK2+DxOLTTQ==,type:str] +#ENC[AES256_GCM,data:6WyNJDMVgvdDK6oeHpP50WokRB4YYzLJdWbXXGyrWygo3matYIQzO8gVGgYfVXszc7DidELAbHTyrEwL6OAmUlVQq6Fo8fwLEwGv96dqlkKULqN6UFcw1/jYYppx0C9rc64RAa7agEK8cqDSwIRBMmu1QSLwRRF1,iv:A37qc5SisamZ1rXF3QZbRMGYHqReEVMFKQDt2hbR/kI=,tag:xDMWNXmEnqyJEQHmJy1gGA==,type:comment] +#ENC[AES256_GCM,data:iTi2tYUq0gY/CWanYwHxv8O+Ga8IE5aRZXEBfb1WN4NUyE3RnvY6fvepclFOilhtBcQ=,iv:RFi4O6WLG4abQ6U+BZbLtPT7Bl+bthDwA72EgeeNnmw=,tag:vbaWF5cIoEj41v96bJ9bmw==,type:comment] +#ENC[AES256_GCM,data:uTs7pKOJdLJ+2F7ZW0TOJqxkfhsGoZNVzFGZ77XMNqRBUkneiyi4gDXsauEL,iv:yVqJOalHuik9X1zq06SgjsicUK/2f+ufrukItqKZM1w=,tag:yV9eiHtuTXofMfxU5D3fEQ==,type:comment] +#ENC[AES256_GCM,data:NxIpgvISjxk4Jt716yJa/OlopqO3t20=,iv:GEr2LCyJGzLmI0WLJDaKeUYRT5LYiDyw5k/TLptqdV0=,tag:5AHbYLkohLR3MMrIX216fw==,type:comment] +#ENC[AES256_GCM,data:5UFfTdn4NxUXDDCg0F8Fb0s6R3vdUqM=,iv:7bq8eZVHew47EOaKdA46QixSdnOSEvphCqaYhjAtdU4=,tag:1zgeP7JCmbyYS+9pCcMuLQ==,type:comment] +#ENC[AES256_GCM,data:6BC06vYVVaIKuFHb/XuN0jnZzDX4pbcQlID7vyv+yzL0tRIQ+86dvMoi62y+43G+ymOBzVOvu6oslp4cZZqKskUwhPVACQMWwrfkjQ==,iv:1Xcqz1K9BWdv13cXq/PUfyR47VssV7iyaTxmvy6AfhY=,tag:gfOahTzuZ7IkKS3WFXx36Q==,type:comment] +#ENC[AES256_GCM,data:EZegD+uzbGMrn3s609Q1OMvM/Tc=,iv:s06BJmwwYaxEs/Qi0bax26+3rCNMKtQ5sk8/G3WCjAA=,tag:KRmnUgjl4f0SP9pYL3g9Lw==,type:comment] +#ENC[AES256_GCM,data:2N/dLT88ZMjTo90fN5CPiOHlV6Gg6rk=,iv:ojS+I5eA+xrsbl3qNqNv4sPoiOYiR+janW4XCzdB52Q=,tag:GcCfQMSXWgjO7eW74XI0+Q==,type:comment] +#ENC[AES256_GCM,data:ZLka4KlfWYZ5CaGvenshNA==,iv:1AXeIICI+CHq/Z21dY0DSxhrNONPrmKMvjnx24Xr2nQ=,tag:R9u5PTqzB6/CSXocdGI3rA==,type:comment] +sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB6cTdCL2d0SFBKTzhUdk5B\naEhudm9aU3YvNXVlQm95NVN2UmptajcrTjJFCkk5cGF4YnllQ1N2YlhoOHhnSnNw\ncWo4dUZoUDljbDJCRTV1UnVmMlJMZ2sKLS0tIFJBekVDOXNQMnVwNU9kTjYzdmk5\na1ZWaFdEdmY2eGttajc1SEgrS0k4YUUKmPFm4Y1t6bm+bCd94s7a/NR2g89l3VEN\n4NrPJ2gB4TaQ+wThajUu+S3MG5Qj+4p1gRgcA6SOSSp9/hJwNK0//g==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_0__map_recipient=age1wravpjmed26772xfjhawmnsnc4933htapg6y5xseqml0jdv8z9hqemzhcr -sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBNOHREbzdGekdidU5wQlNa\nU0JISmZEY0RyTGk0UkNTZzlEdUUyLzk4bXpZCkNrL1p3RS91enNRcDFvcDN3dzdQ\nRHlNMVBZWVNyY2NLekJ0NDVwWU5IM28KLS0tIEtwelQxcTdrcHh1SHNXNEJVN0JH\ndkFzWjJnS0c5Vnk3a21mQUJMcUJ4OGMKB1m96ePcLaLNlFhtORP8Gk6M0NKCzzRe\nIZijtGDy1IFMSJFNS5kk/W4dUcnN1vC0kByrP77wQOO330AXl/3eNg==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBBcC9Zek9VaDBkT3BoVThi\nMHBRTHVUWjh0aHJwc0d3UkN4blVGZkhuRmdRCkFUdEIxZ2NRYnJ3WkJibEQ4aVBR\naFBoQ08xRS9vbHh1Tys2bjU3M01KRlkKLS0tIG8ybTFWN2Y4VzVoWlNNVzhBUjVS\nd3RKSUxBSnZRdGI1T3RkMEJpU1JwY3cKH1+fap5aRx4Pytg9qT+jM9eY/2WyinT8\n9nrmFlBT6As4+J9WiJQPbD6I9GLjjdHBkxBfevvm4j0GbD3gU1fXxw==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_1__map_recipient=age1vhftscteyrwphx0jpp0yl60xxrjs77jq05mkzv88js7ckc926vcqepp2cj -sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBJa1MzT1c2aitrenV1cCtJ\nejJuZit0eThHK2FGNEFzeFF6cFdZYjM2VlhnClJ1NFI2OG53SlkyQnd5Unh2cFdw\nK2pBQWVHT0UwRnpwMHd2TCtOSWxxMTgKLS0tIGFGenRBcy9YQzlCQ3BrOFNINnBC\nOHNnVlNicGRGV0w2TDVVWVJ5OC9haWcKedCLkDJrx/Y2jSI+vDskzA5EAJLtxKIe\n32rn356MBstQcyccoTlWUnfs7TgbeJtKziqFoxvlvCE/59dbjC30+g==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBJOG1ieERCbDExSHNvaHQ3\na0RHK1B0N1loT01BUXYrZmt6MnBRZDFZNGtvCk1HakxaM3J6MFNsVHkrTjRBZnZN\naGllbTRpbDJUWjg2VTJRQ01janNLOHcKLS0tIGdIcFVXV2dnYlhWYWhtQmlyalUv\nUVl2dFVwMFVTaWJTK1g5MFUvbkhmeGsKs6q4o9gsWu7EhTDimF0GOMGId3txOmYf\nKJBHia/S4nb4FJkYC8xwJA9oRoli5/v39TAnTCzihLmwPOWDEmKsnw==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_2__map_recipient=age1slx6e48k7fre0ddyu7dtm2wwcqaywn5ke2mkngym3rpazxwvvuyq9qjknk -sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB5endLbGZobXZXK255dDQv\nMi9aa0lsRHYyck81VzVQQk5TOFRyU2thTGhvCmprbjc4Zkt0NjcyejE5blp4NklM\nV0dNcVcvam5GZkJDd3RwSkp0U2I0UGcKLS0tIFV3cXcvQU9EemlQcXFJclFsTjk3\nMExDVndUc1A1clRIV2txalVPWXFHT1UKOEdEb0uoxviXLFuBLIJu1zM6gdqWje2y\nM17C6aBiV2WEWG5Tr3tHrgqJTki1pEuMwPv9TVmVnRUU99pcyWvtYg==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBlWnpkQmZyL0VOejkzVFhZ\nVFhsNGpvTEFpR3lKQmRncEZSVXdUS1hlcHpJCjdWSVFTd3AwK1lOSWY1aHN6RkY4\nMFQyTmdmeDI1Wk9qZ3lnZmc4d2tmTjgKLS0tIDA0eEpCZzJ0T2lXaXY0bWc1UFM3\nL3k2Vi9vbFh5eWxnUm10eDN6bHp2cUUKFx9NstG3M+zWeu1PKjmE90iOLhPfDm9c\n0onKUXaBH5HZugiXj5TXsL/zsHRUdCsQkHv6LODSzqsnAIrc53P6jw==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_3__map_recipient=age1vfhyk6wmt993dezz5wjf6n3ynkd6xptv2dr0qdl6kmttev9lh5dsfjfs3h -sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBNUjFEaWMzUllWTnBBOTVU\nT0pLdUMwdHZiU2c5S1JCb1RMazV6c005VmcwCkxXcCtOV2xYR3RzSnVTdFJldVlJ\nNlF2aVBSdXdOeCsvN25STHkxVjIyK28KLS0tIDlLZTVzcG50WDNoRFplY2tqRHZ4\nUXVmRkpQWlJOMjR6anlCNFlSRXR5MmsKxiVO7UhcxoG47i1ZfdXMCHeWlafvm3YO\nlSmnKBWUktP9rvY7MtD+ahMOxvcGZaLbkprRKjtoVNTjY3D6lBi8mg==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA2VGNTa0ExbHRNQjYrNDd4\ndlgrMEdJVExIUzFnUTFOY21iTHIwOWVuUzFBCjEwQm12TnJKdzA0QjNpSWxtSTM4\nSDh3ZmdDandubVlETUVoWi96VEtDbXcKLS0tIDZESHMxMkVsZllteFlQN2sxRDFH\nOGV0bXVna0Exdk9ScFdvVTY5SVA1djAKdQZNKiezS5Lx83Pkw2zFE8VpBn6ImuGU\nvd4uYrtSe8flMh14hoCO8zVnIJW/J4jKQtrLryydOn/3JdGU7I0S0g==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_4__map_recipient=age1jwuvzyghfer7rx3qtqa4vs0gxyff0sg6pqgmqvp5zlhnpmvrkdlsdha4kx -sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBHT3lXWXowNDJQZEN4TVVX\nOE9Ha2MvR0UzZTE4dXF1MFVzN1owM0xHQlZFCkxmUmVGK24vU09jbEhUUkt5MGlY\nV3V0R2FUTWkxTkc2clIwT1gxMWdteUEKLS0tIGlUYVBHL2R2dVIzdmduZGRxY1ZM\nRWRLVnQwN212U0E1UHZTUitQZ3h5YkEKUl16i3dPBXsHOZmhR9XDD8bZr8Bxhk5I\n4iupZR3Fw9YUDUHFrhLsCNXx4XYRvpEXb7nox/7ZyLsZRjn46pweAQ==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB5dmdadXl3RE1BZkZ3amlw\nRUQ0dVBGYnp3TGZHa3o5d0FneFFZampiaTFnCmd6Z2x0alJwODJMaDlYR08vbkFa\nRnJKNTNWUHpPWk1kWW8yUHNxbFJhWlkKLS0tIFNzcVJWcUVqbTZRb2E3UzNoZlow\nYi9sa1lBSmhLMjdRQXRtQUlBTlN4Z0UK29CesxlXkkXVXGvfg4sTwDrljeSrDxxq\nblF7jGpIPtMjuTYuhVfEX9vIjIq9Fv6HEI8EVOc4BpwBEDp7wY9PvQ==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_5__map_recipient=age1pgxk292zq30wafwg03gge7hu5dlu3h7yfldp2y8kqekfaljjky7s752uwy -sops_lastmodified=2026-04-13T17:43:27Z -sops_mac=ENC[AES256_GCM,data:IrFHc5yqUPcYNsUAkcL6Qyggbyr38b2FLbo6y9e8L0FJM07AwVruuJUEpd308mKxX64fYxW2SkD8jGURChZ5Z6eH3DlXZO+52S7AQGcO3rQgQCf6VOU7CndT9CJbeZnSA4wMOF+foGOJ7rYwIJ3eAGwyr6X6zHgHsGql7knErJw=,iv:qo1A1ipcr8DAegAMqRAEfW24UJ5KJSg6lXFzf9nad8Y=,tag:O6JcFIEbNlOgQ3HU0jz+2Q==,type:str] +sops_lastmodified=2026-04-15T17:44:42Z +sops_mac=ENC[AES256_GCM,data:0oQJu5w+tn6pDTAaRSYs9qbu9QrQzw/xsOYCWVRrkzCOizJuDWWgV1f0GOW8751NH0WqHW36PZ1fXuHWX1PB71egN25RW5twjhl6ExEkOg98jqWtZpkhtnp5pVDe4Tz0SA/ZRyPuGp5tkknZgVI+n2PAz24NFpKxOZ68ceE6tlo=,iv:JNT5xJeufeda/ZufmATtvo8xbnNeZIpHckfnDp3jkP0=,tag:U9ckKGYMWRZfGH8AJfcAQQ==,type:str] sops_unencrypted_suffix=_unencrypted sops_version=3.11.0 diff --git a/infra/.env.test b/infra/.env.test index 36ced00c38..6664540dd5 100644 --- a/infra/.env.test +++ b/infra/.env.test @@ -10,9 +10,6 @@ MAILCHIMP_API_KEY=xxx FIREBASE_SERVICE_ACCOUNT_BASE64=xxx CLOUDAMQP_URL=xxx -ALGOLIA_ID=ooo -ALGOLIA_KEY=ooo -ALGOLIA_SEARCH_KEY=ooo JWT_SIGNING_SECRET=shhhhhh BYPASS_CAPTCHA=true FIREBASE_TEST_DB_URL=http://localhost:9875?ns=pubpub-v6 diff --git a/infra/Caddyfile b/infra/Caddyfile index 14846e6fc2..96f2e6bccd 100644 --- a/infra/Caddyfile +++ b/infra/Caddyfile @@ -13,4 +13,4 @@ } encode gzip reverse_proxy pubpub_app:3000 -} \ No newline at end of file +} diff --git a/infra/docker-compose.dev.yml b/infra/docker-compose.dev.yml index b8f27fef23..6d069a00c8 100644 --- a/infra/docker-compose.dev.yml +++ b/infra/docker-compose.dev.yml @@ -85,6 +85,9 @@ services: ports: - "${DB_PORT:-5439}:5432" networks: [appnet] + + + # cron: # build: # context: .. @@ -110,3 +113,4 @@ networks: volumes: pgdata: rabbitmqdata: + diff --git a/infra/env.example b/infra/env.example new file mode 100644 index 0000000000..e491e74bf1 --- /dev/null +++ b/infra/env.example @@ -0,0 +1,3 @@ +AM_REDSHIFT_PATH= +AM_REDSHIFT_BACKUP_ACCESS_KEY= +AM_REDSHIFT_BACKUP_SECRET_KEY= diff --git a/server/analytics/README.md b/server/analytics/README.md new file mode 100644 index 0000000000..2052f58c9a --- /dev/null +++ b/server/analytics/README.md @@ -0,0 +1,319 @@ +# Analytics (Legacy Impact Dashboard) + +The Impact dashboard (`/dash/impact`) displays page views, unique +visits, downloads, top pubs, countries, referrers, campaigns, and pages for a +community. It replaces the old Metabase-iframe approach with native Recharts +graphs served directly from Postgres. + +> **Impact2** (`/dash/impact2`) is an entirely separate system backed by +> Cloudflare analytics and is unaffected by any of this. + +--- + +## Data Flow + +``` + ┌─────────────────────────────────┐ + │ Browser (every page view) │ + │ navigator.sendBeacon(payload) │ + └──────────────┬──────────────────┘ + │ POST /api/analytics/track + Redshift (historical) ▼ + │ ┌──────────────────────┐ + │ one-time │ Write Buffer │ in-memory, flushes every + │ migration │ (writeBuffer.ts) │ 5s or 500 events + │ └──────────┬───────────┘ + │ │ bulkCreate + ▼ ▼ + ┌─────────────────────────────────────────────┐ + │ AnalyticsEvents │ ~19M rows (raw table) + │ (server/analytics/model.ts) │ + └────────────────────┬────────────────────────┘ + │ + │ REFRESH + CLUSTER + ANALYZE + │ (nightly cron → tools/refreshAnalyticsSummary.ts) + ▼ + ┌─────────────────────────────────────────────────────┐ + │ 7 Materialized Views (pre-aggregated by day) │ + │ │ + │ analytics_daily_summary (44 MB) │ + │ analytics_daily_country (233 MB) │ + │ analytics_daily_pub (593 MB) │ + │ analytics_daily_collection (99 MB) │ + │ analytics_daily_referrer (589 MB) │ + │ analytics_daily_campaign (1 MB) │ + │ analytics_daily_page (1.4 GB) │ + └────────────────────┬────────────────────────────────┘ + │ + ▼ + ┌─────────────────────┐ ┌──────────────────────┐ + │ GET /api/analytics │──────▶│ DashboardImpact.tsx │ + │ -impact │ JSON │ (Recharts) │ + │ (impactApi.ts) │ └──────────────────────┘ + └─────────────────────┘ +``` + +--- + +## Write Path (Ingestion) + +Every page view sends a `navigator.sendBeacon` POST to `/api/analytics/track`. +Instead of issuing one INSERT per request, events are queued in an **in-memory +write buffer** (`server/analytics/writeBuffer.ts`) and flushed to Postgres in +batches: + +- **Flush interval:** every 5 seconds +- **Flush cap:** immediately if buffer reaches 500 events +- **Graceful shutdown:** SIGTERM / SIGINT flushes remaining events before exit +- **Failure handling:** failed batches are retried on the next tick; dropped + only if the buffer exceeds 2× the cap (PG truly down) + +This reduces per-request DB overhead (index maintenance, WAL writes, connection +churn) from N round-trips to 1. The tradeoff: up to 5 seconds of events can be +lost on an ungraceful crash (OOM, SIGKILL). This is acceptable for analytics. + +--- + +## Lifecycle + +### Server Startup + +On `sequelize.sync()` completion (in `server/sequelize.ts`), the server calls +`createSummaryViews()`. This is **idempotent** — it uses +`CREATE MATERIALIZED VIEW IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS`, so +it's a no-op on subsequent boots. It does **not** refresh data, so startup stays +fast. + +The write buffer starts automatically when `writeBuffer.ts` is first imported +(via `api.ts`). + +### Nightly Cron + +`tools/cron.ts` schedules a refresh at **3:30 AM UTC daily**: + +``` +pnpm run tools-prod refreshAnalyticsSummary refresh +``` + +This runs `refreshSummaryViews()` which, for each of the 7 views: + +1. `REFRESH MATERIALIZED VIEW [CONCURRENTLY] ` +2. `CLUSTER USING ` — physically reorders rows by + `(communityId, date)` so range scans read contiguous disk pages +3. `ANALYZE ` — updates planner statistics + +Total refresh time is typically 3–5 minutes depending on data volume. + +### Manual Refresh + +```sh +# Create views if missing + refresh all: +pnpm run tools refreshAnalyticsSummary + +# Refresh only (views must already exist): +pnpm run tools refreshAnalyticsSummary refresh +``` + +--- + +## One-Time Migration (Redshift → Postgres) + +The original data lived in AWS Redshift. `tools/migrateRedshift.ts` performs the +migration: + +```sh +docker compose -f infra/docker-compose.dev.yml run --rm app pnpm run tools migrateRedshift +``` + +Steps: +1. Downloads gzipped CSV files from the S3 backup bucket +2. For each CSV: creates a staging table → `psql \copy` import → transforms + column names/types into `AnalyticsEvents` → `INSERT ... ON CONFLICT DO NOTHING` → drops staging table +3. After all files: calls `createSummaryViews()` + `refreshSummaryViews()` + +This only needs to run once per environment. Re-running is safe (idempotent via +`ON CONFLICT`). + +--- + +## Performance Optimizations + +### Problem + +The raw `AnalyticsEvents` table has ~19M rows. A year-long dashboard query for +the largest community took 26+ seconds scanning millions of rows. + +### Solution: Layered Approach + +| Layer | Technique | Impact | +|-------|-----------|--------| +| **1. Materialized Views** | Pre-aggregate by day — 7 views that collapse millions of rows into thousands | 26s → 3.7s | +| **2. CLUSTER** | Physically reorder matview rows by `(communityId, date)` so range scans read contiguous disk pages instead of random I/O | 3.7s → 730ms | +| **3. Plain B-tree Indexes** | Add non-functional `(communityId, date)` indexes on views whose unique index uses `md5()` (referrer, page) — lets PG do a simple index range scan | 730ms → ~400ms sequential, **~260ms wall-clock** with `Promise.all` | +| **4. In-Memory Cache** | 5-minute TTL, max 500 entries — subsequent requests within the window are instant | 260ms → 0ms (cache hit) | + +### Why md5() Unique Indexes? + +The `referrer` and `page_title` columns can be very long (URLs). PostgreSQL's +btree index entries have a hard 2704-byte limit. We use `md5()` in the unique +index to stay within that limit, but md5-based indexes are less efficient for +range scans. That's why those two views get an **additional** plain +`(communityId, date)` index that PG uses for the actual `WHERE` filter. + +### CLUSTER vs. No CLUSTER + +Without CLUSTER, PG stores matview rows in insertion order (which follows the +raw table's order — effectively random with respect to communityId). A query +for one community's year of data might touch 38K scattered heap blocks. After +CLUSTER, the same query touches ~1,800 contiguous blocks — an **8.7× speedup** +for the largest view. + +CLUSTER is re-applied on every refresh because `REFRESH MATERIALIZED VIEW` +rewrites the entire view. + +--- + +## Indexes on the Raw Table + +The Sequelize model (`server/analytics/model.ts`) defines 6 indexes that are +managed by `sequelize.sync()`: + +| Index | Fields | Partial WHERE | +|-------|--------|---------------| +| `analytics_events_community_event_ts` | `communityId, event, timestamp` | — | +| `analytics_events_pub_event_ts` | `pubId, event, timestamp` | — | +| `analytics_events_collection_event_ts` | `collectionId, event, timestamp` | — | +| `analytics_events_community_ts` | `communityId, timestamp` | — | +| `analytics_events_community_pages` | `communityId, timestamp, isUnique` | `event IN ('page','pub','collection','other')` | +| `analytics_events_pub_views_dl` | `communityId, pubId, timestamp` | `pubId IS NOT NULL AND event IN ('pub','download')` | + +These are used by the **raw-table fallback** path (`fetchSummaryFromRaw`) which +runs when a query is scoped to a specific pub or collection (dimensions that +don't exist across all matviews). + +--- + +## API + +### `GET /api/analytics-impact` + +**Auth:** Requires community dashboard view permission. + +**Query parameters:** + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `startDate` | ISO date string | 90 days ago | Start of range | +| `endDate` | ISO date string | today | End of range | +| `pubId` | UUID | — | Scope to a specific pub (uses raw table) | +| `collectionId` | UUID | — | Scope to a specific collection (uses raw table) | + +**Response shape:** + +```json +{ + "totalPageViews": 123456, + "totalUniqueVisits": 78901, + "totalDownloads": 4567, + "daily": [{ "date": "2025-01-01", "pageViews": 100, "uniquePageViews": 60 }], + "countries": [{ "country": "United States", "countryCode": "US", "count": 5000 }], + "topPubs": [{ "pubTitle": "...", "pubId": "...", "views": 100, "downloads": 5 }], + "topPages": [{ "pageTitle": "...", "path": "/...", "count": 100 }], + "topCollections": [{ "collectionTitle": "...", "collectionId": "...", "count": 100 }], + "referrers": [{ "referrer": "https://google.com", "count": 100 }], + "campaigns": [{ "campaign": "spring-2025", "count": 50 }] +} +``` + +**Query routing:** +- Community-level (no pubId/collectionId) → reads from materialized views +- Pub/collection-scoped → falls back to raw `AnalyticsEvents` table + +--- + +## File Map + +| File | Purpose | +|------|---------| +| `server/analytics/api.ts` | HTTP handler for `POST /api/analytics/track` | +| `server/analytics/writeBuffer.ts` | Batched write buffer (enqueue → bulkCreate) | +| `server/analytics/model.ts` | Sequelize model + raw table indexes | +| `server/analytics/summaryViews.ts` | Matview DDL, create/refresh functions | +| `server/analytics/impactApi.ts` | Dashboard read API + query logic + cache | +| `server/sequelize.ts` | Calls `createSummaryViews()` on startup | +| `tools/refreshAnalyticsSummary.ts` | CLI tool for manual/cron refresh | +| `tools/migrateRedshift.ts` | One-time Redshift → PG migration | +| `tools/cron.ts` | Nightly refresh schedule (3:30 AM UTC) | +| `client/containers/DashboardImpact/` | Frontend (Recharts, date picker, tables) | +| `server/routes/dashboardImpact.tsx` | SSR route for `/dash/impact` | + +--- + +## Maintaining Performance + +### If queries slow down as data grows: + +1. **Check that the nightly cron is running** — matviews must be refreshed and + CLUSTERed regularly. Stale statistics (`ANALYZE`) also hurt the planner. + +2. **Increase `work_mem`** — the page and referrer queries sort large result + sets in memory. If they spill to disk, bump `work_mem` (currently 16MB in + dev). + +3. **Consider partitioning matviews by year** — not natively supported in PG + for matviews, but you could create separate `analytics_daily_page_2024`, + `analytics_daily_page_2025`, etc. and UNION in the API. + +4. **Pre-aggregate "top N" matviews** — a secondary view like + `analytics_top_pages_by_community` that pre-computes the all-time top 50 + pages per community would eliminate the GROUP BY at query time, at the cost + of losing flexible date range filtering. + +5. **Increase cache TTL** — the 5-minute cache already covers repeat loads. + Bumping to 15–30 minutes is safe for analytics data that only refreshes + nightly. + + + +## Impact2 + +Replaces the Metabase/Redshift analytics pipeline with per-community analytics sourced directly from Cloudflare's edge data. Staged as "Impact2" alongside the existing Impact tab so nothing is removed while we test. + +### What this adds + +**Server** +- `server/utils/cloudflareAnalytics.ts`: Cloudflare GraphQL Analytics API client. Single combined query fetches daily traffic, top paths, countries, devices, and referrers per hostname. Includes date chunking (CF max ~31 days per query), contiguous span grouping, and per-day breakdown caching. +- `server/analyticsCloudflareCache/model.ts`: Sequelize model for Postgres-backed daily cache. Past days are cached permanently; today's data has a 1-hour TTL. Auto-prunes rows older than 90 days (hourly throttle). +- `server/impact2/api.ts`: API routes (`GET /api/impact2`, `/test`, `/debug`). Resolves the Cloudflare-facing hostname by querying the Community model directly (bypasses `getInitialData`'s localhost domain overwrite in dev). Debug endpoint is disabled in production. +- Registered in `server/apiRoutes.ts`, `server/routes/index.ts`, `server/models.ts`. + +**Client** +- `client/containers/DashboardImpact2/`: React component + SCSS. Top row with stat cards (color-coded left border) and area chart side by side. Four-column data grid below: Top Pages (with clickable links), Countries, Referrers, Devices (as percentage table). Responsive down to single-column on mobile. +- Date range picker: Today / 7 days / 30 days. +- Graceful degradation: friendly "not available" state when env vars are missing, stale-data callout when CF is unreachable but cache exists, standard error state with retry for transient failures. +- Registered in `client/containers/App/paths.ts`, `client/containers/index.ts`, `utils/dashboard.ts`. +- Nav button added to `ScopeDropdown`. + +### Filtering and noise reduction + +Two layers of filtering to get numbers closer to real human readership: + +1. **Cloudflare query filters**: Only counts requests that are successful (2xx/3xx), serve HTML content, use GET method, and come from eyeball sources (not cloudflare internal routing). +2. **Server-side noise path filter**: Removes `/wp-*`, `/cdn-cgi/`, `/api/`, `/static/`, `/login`, `/robots.txt`, `.xml`, etc. from Top Pages list. Subtracts noise path page views from totals and proportionally scales visits, countries, devices, referrers, and daily chart data to match. + +Raw (pre-adjustment) totals are included in the API response and shown in the footer for transparency. + +### Caching strategy + +- 0 Cloudflare API calls when all requested days are cached and within TTL. +- Past days: permanent cache (data is final). +- Today: 1-hour TTL, then re-fetched from CF on next request. +- Cache is Postgres-backed, so it persists across swarm deploys. + +### Env vars required + +``` +CLOUDFLARE_ANALYTICS_API_TOKEN # API token with Analytics:Read permission +CLOUDFLARE_ZONE_TAG # Zone ID for the PubPub traffic zone +``` +If missing, the server logs a warning and the client shows a "not available" message instead of erroring. \ No newline at end of file diff --git a/server/analytics/__tests__/api.test.ts b/server/analytics/__tests__/api.test.ts index 9ab800ace0..3c8e918362 100644 --- a/server/analytics/__tests__/api.test.ts +++ b/server/analytics/__tests__/api.test.ts @@ -1,13 +1,15 @@ -import { vi } from 'vitest'; - import { login, setup, teardown } from 'stubstub'; import { analyticsEventSchema, type basePageViewSchema, type PageViewPayload, + type pageViewSchema, type sharedEventPayloadSchema, } from 'utils/api/schemas/analytics'; +import { AnalyticsEvent } from '../model'; +import { flush } from '../writeBuffer'; + const baseTestPayload = { type: 'page', height: 0, @@ -25,8 +27,11 @@ const baseTestPayload = { communitySubdomain: 'string', } satisfies Omit<(typeof basePageViewSchema & typeof sharedEventPayloadSchema)['_input'], 'event'>; -type PubPageView = PageViewPayload & { event: 'pub' }; -const makeTestPubPageViewPayload = (options?: Partial) => { +/** Full input type (payload + base fields like timestamp/timezone, before Zod transforms) */ +type PageViewInput = (typeof pageViewSchema)['_input']; + +type PubPageViewInput = PageViewInput & { event: 'pub' }; +const makeTestPubPageViewPayload = (options?: Partial) => { return { event: 'pub', pubId: 'de3a36ab-26d9-4b76-aaab-f1bffc18b102', @@ -35,7 +40,7 @@ const makeTestPubPageViewPayload = (options?: Partial) => { release: 'draft', ...baseTestPayload, ...options, - } satisfies PageViewPayload & { event: 'pub' }; + } satisfies PubPageViewInput; }; type PagePageView = PageViewPayload & { event: 'page' }; @@ -72,22 +77,9 @@ const makeTestOtherPageViewPayload = (options?: Partial) => { } satisfies OtherPageView; }; -setup(beforeAll, async () => { - // mock fetch, we don't actually want to send api calls - vi.spyOn(global, 'fetch').mockImplementation( - () => - Promise.resolve({ - json: () => Promise.resolve({ status: 'ok', id: 'id' }), - }) as unknown as Promise, - ); - - // to be safe, do not actually send any requests to stitch - process.env.STITCH_WEBHOOK_URL = 'http://localhost:9876'; -}); +setup(beforeAll, () => undefined); -teardown(afterAll, () => { - vi.restoreAllMocks(); -}); +teardown(afterAll); describe('analytics schema', () => { describe('pub page view', () => { @@ -99,14 +91,16 @@ describe('analytics schema', () => { expect(analyticsEventSchema.safeParse(pubViewNumber)).toBeTruthy(); }); - // this is needed otherwise redshift will create two colunms, release__bigint and release__string it('should convert number releases into strings', () => { const pubViewNumber = makeTestPubPageViewPayload({ release: 1 }); const parsed = analyticsEventSchema.safeParse(pubViewNumber); + expect(parsed.success).toBeTruthy(); + if (!parsed.success) { throw new Error('parsed failed'); } + expect(parsed.data).toEqual({ ...pubViewNumber, release: '1', @@ -116,11 +110,23 @@ describe('analytics schema', () => { }); describe('analytics', () => { + afterEach(async () => { + await AnalyticsEvent.destroy({ where: {} }); + }); + test('pub page view', async () => { const payload = makeTestPubPageViewPayload(); const agent = await login(); await agent.post('/api/analytics/track').send(payload).expect(204); + await flush(); + + const events = await AnalyticsEvent.findAll({ where: { pubId: payload.pubId } }); + + expect(events).toHaveLength(1); + expect(events[0].event).toBe('pub'); + expect(events[0].pubId).toBe(payload.pubId); + expect(events[0].release).toBe('draft'); }); test('page page view', async () => { @@ -128,6 +134,12 @@ describe('analytics', () => { const agent = await login(); await agent.post('/api/analytics/track').send(payload).expect(204); + await flush(); + + const events = await AnalyticsEvent.findAll({ where: { event: 'page' } }); + + expect(events).toHaveLength(1); + expect(events[0].pageId).toBe(payload.pageId); }); test('collection page view', async () => { @@ -135,6 +147,14 @@ describe('analytics', () => { const agent = await login(); await agent.post('/api/analytics/track').send(payload).expect(204); + await flush(); + + const events = await AnalyticsEvent.findAll({ + where: { collectionId: payload.collectionId }, + }); + + expect(events).toHaveLength(1); + expect(events[0].collectionId).toBe(payload.collectionId); }); test('other page view', async () => { @@ -142,12 +162,55 @@ describe('analytics', () => { const agent = await login(); await agent.post('/api/analytics/track').send(payload).expect(204); + await flush(); + + const events = await AnalyticsEvent.findAll({ where: { event: 'other' } }); + + expect(events).toHaveLength(1); }); - test('page page view with optional fields', async () => { - const payload = makeTestPagePageViewPayload(); + test('stores timezone from payload', async () => { + const payload = makeTestPubPageViewPayload({ timezone: 'Europe/Amsterdam' }); + const agent = await login(); + + await agent.post('/api/analytics/track').send(payload).expect(204); + await flush(); + + const events = await AnalyticsEvent.findAll({ where: { pubId: payload.pubId } }); + + expect(events).toHaveLength(1); + expect(events[0].timezone).toBe('Europe/Amsterdam'); + }); + + test('strips dropped fields (collectionIds, pubSlug, etc.)', async () => { + const payload = makeTestPubPageViewPayload({ + collectionIds: + 'de3a36ab-26d9-4b76-aaab-f1bffc18b102,ae3a36ab-26d9-4b76-aaab-f1bffc18b103', + }); const agent = await login(); await agent.post('/api/analytics/track').send(payload).expect(204); + await flush(); + + const events = await AnalyticsEvent.findAll({ where: { pubId: payload.pubId } }); + + expect(events).toHaveLength(1); + // Dropped fields should not appear on the model + expect((events[0] as any).collectionIds).toBeUndefined(); + expect((events[0] as any).pubSlug).toBeUndefined(); + }); + + test('converts timestamp to createdAt date', async () => { + const now = Date.now(); + const payload = makeTestPubPageViewPayload({ timestamp: now }); + const agent = await login(); + + await agent.post('/api/analytics/track').send(payload).expect(204); + await flush(); + + const events = await AnalyticsEvent.findAll({ where: { pubId: payload.pubId } }); + + expect(events).toHaveLength(1); + expect(new Date(events[0].createdAt).getTime()).toBe(now); }); }); diff --git a/server/analytics/api.ts b/server/analytics/api.ts index 68b561ba7f..44500c37fe 100644 --- a/server/analytics/api.ts +++ b/server/analytics/api.ts @@ -1,6 +1,4 @@ -/** This should all be moved to an AWS lambda */ - -import type { AnalyticsEvent } from 'utils/api/schemas/analytics'; +import type { AnalyticsEvent as AnalyticsEventPayload } from 'utils/api/schemas/analytics'; import { initServer } from '@ts-rest/express'; import { getCountryForTimezone } from 'countries-and-timezones'; @@ -9,25 +7,85 @@ import express from 'express'; import { env } from 'server/env'; import { contract } from 'utils/api/contract'; -const s = initServer(); +import { enqueue } from './writeBuffer'; -const sendToStitch = async ( - payload: AnalyticsEvent & { country: string | null; countryCode: string | null }, -) => { - if (!env.STITCH_WEBHOOK_URL) { - // throw new Error('Missing STITCH_WEBHOOK_URL'); - return null; - } +// ─── Stitch dual-write (temporary for rollback safety) ────────────────────── - const response = await fetch(env.STITCH_WEBHOOK_URL, { +/** Fire-and-forget POST to the old Stitch/Redshift webhook so we can rollback if needed. */ +function sendToStitch(payload: unknown) { + fetch(env.STITCH_WEBHOOK_URL, { method: 'POST', body: JSON.stringify(payload), - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, + }).catch(() => { + // Silently swallow — Stitch is best-effort during the transition period. }); +} + +const s = initServer(); + +// ─── validation helpers ────────────────────────────────────────────────────── + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** Coerce a value to a valid UUID or null. Prevents bad UUIDs from killing the entire bulkCreate batch. */ +function sanitizeUuid(val: unknown): string | null { + return typeof val === 'string' && UUID_RE.test(val) ? val : null; +} + +const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; - return response; +/** Returns true if the timestamp is within an acceptable range (not future, not >30 days old). */ +function isTimestampValid(ts: number): boolean { + const now = Date.now(); + return ts <= now && ts >= now - THIRTY_DAYS_MS; +} + +// ─── fields to strip (no longer stored) ────────────────────────────────────── + +const DROPPED_FIELDS = new Set([ + 'title', + 'country', + 'countryCode', + 'isProd', + 'communityName', + 'communitySubdomain', + 'pubTitle', + 'pubSlug', + 'collectionTitle', + 'collectionSlug', + 'collectionKind', + 'collectionIds', + 'primaryCollectionId', + 'pageTitle', + 'pageSlug', +]); + +// ─── transform payload → DB record ────────────────────────────────────────── + +const toEventRecord = (payload: AnalyticsEventPayload) => { + const raw = payload as Record; + const { unique, timestamp, ...rest } = raw; + + // Strip fields that are no longer stored in the table + const fields: Record = {}; + for (const [key, value] of Object.entries(rest)) { + if (!DROPPED_FIELDS.has(key)) { + fields[key] = value; + } + } + + // Sanitize UUID fields to prevent a single bad value from failing the whole batch + fields.communityId = sanitizeUuid(fields.communityId); + fields.pubId = sanitizeUuid(fields.pubId); + fields.collectionId = sanitizeUuid(fields.collectionId); + fields.pageId = sanitizeUuid(fields.pageId); + + return { + ...fields, + createdAt: new Date(timestamp as number), + isUnique: (unique as boolean | undefined) ?? null, + }; }; export const analyticsServer = s.router(contract.analytics, { @@ -41,18 +99,31 @@ export const analyticsServer = s.router(contract.analytics, { req.body = JSON.parse(req.body); } catch (err) { console.error(err); - // do nothing } } next(); }, ], handler: async ({ body: payload }) => { + // Dual-write to Stitch/Redshift for rollback safety (temporary). + // Sent unconditionally (before validation) to match old behavior exactly: + // { country, countryCode, ...payload } const { timezone } = payload; + const { name: country = null, id: countryCode = null } = + getCountryForTimezone(timezone) || {}; + sendToStitch({ country, countryCode, ...payload }); + + // Reject events with unreasonable timestamps (future or >30 days old) + if (!isTimestampValid(payload.timestamp)) { + return { + status: 204, + body: undefined, + }; + } - const { name: country = null, id = null } = getCountryForTimezone(timezone) || {}; + const record = toEventRecord(payload); - await sendToStitch({ country, countryCode: id, ...payload }); + enqueue(record); return { status: 204, diff --git a/server/analytics/impactApi.ts b/server/analytics/impactApi.ts new file mode 100644 index 0000000000..b168717dad --- /dev/null +++ b/server/analytics/impactApi.ts @@ -0,0 +1,500 @@ +/** + * API that serves analytics data for the legacy Impact dashboard directly + * from the Postgres AnalyticsEvents table (replacing the old Metabase iframes). + * + * Mounted at /api/analytics-impact/... + */ + +import { getCountryForTimezone } from 'countries-and-timezones'; +import { Router } from 'express'; +import { QueryTypes } from 'sequelize'; + +import { sequelize } from 'server/sequelize'; +import { ForbiddenError, handleErrors } from 'server/utils/errors'; +import { getInitialData } from 'server/utils/initData'; +import { hostIsValid } from 'server/utils/routes'; + +export const router = Router(); + +// ─── in-memory cache (5 min TTL) ──────────────────────────────────────────── + +const CACHE_TTL_MS = 5 * 60 * 1000; +const cache = new Map(); + +function cacheKey(scope: Scope, startDate: string, endDate: string) { + return `${scope.communityId}:${scope.pubId ?? ''}:${scope.collectionId ?? ''}:${startDate}:${endDate}`; +} + +function getCached(key: string): unknown | null { + const entry = cache.get(key); + if (entry && Date.now() - entry.ts < CACHE_TTL_MS) { + return entry.data; + } + cache.delete(key); + return null; +} + +function setCache(key: string, data: unknown) { + cache.set(key, { data, ts: Date.now() }); + // Evict stale entries lazily (keep cache bounded) + if (cache.size > 500) { + const now = Date.now(); + for (const [k, v] of cache) { + if (now - v.ts > CACHE_TTL_MS) { + cache.delete(k); + } + } + } +} + +// ─── types ─────────────────────────────────────────────────────────────────── + +type DailyRow = { date: string; pageViews: number; uniquePageViews: number }; +type CountryRow = { country: string; countryCode: string; count: number }; +type TimezoneRow = { timezone: string; count: number }; +type ReferrerRow = { referrer: string; count: number }; +type CampaignRow = { campaign: string; count: number }; +type TopPageRow = { pageTitle: string; path: string; count: number }; +type TopPubRow = { + pubTitle: string; + pubSlug: string | null; + pubId: string; + views: number; + downloads: number; +}; +type TopCollectionRow = { + collectionTitle: string; + collectionSlug: string | null; + collectionId: string; + count: number; +}; +type DeviceRow = { device_type: string; count: number }; + +// ─── timezone → country mapping ────────────────────────────────────────────── + +/** Rolls up timezone-level rows into country-level totals using the npm package. */ +function rollUpTimezoneToCountries(rows: TimezoneRow[]): CountryRow[] { + const countryMap = new Map(); + for (const row of rows) { + const tz = getCountryForTimezone(row.timezone); + const key = tz ? tz.id : 'Unknown'; + const existing = countryMap.get(key); + if (existing) { + existing.count += Number(row.count); + } else { + countryMap.set(key, { + country: tz?.name ?? 'Unknown', + countryCode: tz?.id ?? '', + count: Number(row.count), + }); + } + } + return [...countryMap.values()].sort((a, b) => b.count - a.count); +} + +// ─── scope filter builder ──────────────────────────────────────────────────── + +type Scope = { + communityId: string; + pubId?: string; + collectionId?: string; +}; + +function scopeWhere( + scope: Scope, + alias?: string, +): { clause: string; replacements: Record } { + const col = (name: string) => (alias ? `${alias}."${name}"` : `"${name}"`); + const parts: string[] = [`${col('communityId')} = :communityId`]; + const replacements: Record = { communityId: scope.communityId }; + + if (scope.pubId) { + parts.push(`${col('pubId')} = :pubId`); + replacements.pubId = scope.pubId; + } + if (scope.collectionId) { + parts.push(`${col('collectionId')} = :collectionId`); + replacements.collectionId = scope.collectionId; + } + + return { clause: parts.join(' AND '), replacements }; +} + +// ─── query helper ──────────────────────────────────────────────────────────── + +async function fetchSummary(scope: Scope, startDate: string, endDate: string) { + const replacements: Record = { + communityId: scope.communityId, + startDate, + endDate, + }; + + const mvDateRange = `date >= :startDate::date AND date <= :endDate::date`; + const mvCommunity = `"communityId" = :communityId`; + const mvWhere = `${mvCommunity} AND ${mvDateRange}`; + + // For pub/collection scoping we need the raw table for everything. + if (scope.pubId || scope.collectionId) { + return fetchSummaryFromRaw(scope, startDate, endDate); + } + + const [daily, timezoneRows, topPubs, topPages, topCollections, referrers, campaigns, devices] = + await Promise.all([ + // ── daily breakdown from matview + sequelize.query( + `SELECT + date::text, + page_views AS "pageViews", + unique_page_views AS "uniquePageViews", + downloads + FROM analytics_daily_summary + WHERE ${mvWhere} + ORDER BY date`, + { replacements, type: QueryTypes.SELECT }, + ), + // ── countries from matview (timezone → country mapped in JS) + sequelize.query( + `SELECT + timezone, + SUM(count)::int AS count + FROM analytics_daily_timezone + WHERE ${mvWhere} + GROUP BY timezone + ORDER BY count DESC`, + { replacements, type: QueryTypes.SELECT }, + ), + // ── top pubs from matview, JOIN Pubs for titles + sequelize.query( + `SELECT + COALESCE(p.title, p.slug, mv."pubId"::text) AS "pubTitle", + p.slug AS "pubSlug", + mv."pubId"::text AS "pubId", + SUM(mv.views)::int AS views, + SUM(mv.downloads)::int AS downloads + FROM analytics_daily_pub mv + LEFT JOIN "Pubs" p ON p.id = mv."pubId" + WHERE mv."communityId" = :communityId AND mv.date >= :startDate::date AND mv.date <= :endDate::date + GROUP BY mv."pubId", p.title, p.slug + ORDER BY (SUM(mv.views) + SUM(mv.downloads)) DESC LIMIT 250`, + { replacements, type: QueryTypes.SELECT }, + ), + // ── top pages from matview + sequelize.query( + `SELECT + page_title AS "pageTitle", + path, + SUM(count)::int AS count + FROM analytics_daily_page + WHERE ${mvWhere} + GROUP BY page_title, path + ORDER BY count DESC LIMIT 250`, + { replacements, type: QueryTypes.SELECT }, + ), + // ── top collections from matview + sequelize.query( + `SELECT + COALESCE(c.title, mv."collectionId"::text) AS "collectionTitle", + c.slug AS "collectionSlug", + mv."collectionId"::text AS "collectionId", + SUM(mv.count)::int AS count + FROM analytics_daily_collection mv + LEFT JOIN "Collections" c ON c.id = mv."collectionId" + WHERE mv."communityId" = :communityId AND mv.date >= :startDate::date AND mv.date <= :endDate::date + GROUP BY mv."collectionId", c.title, c.slug + ORDER BY count DESC LIMIT 250`, + { replacements, type: QueryTypes.SELECT }, + ), + // ── referrers from matview + sequelize.query( + `SELECT + referrer, + SUM(count)::int AS count + FROM analytics_daily_referrer + WHERE ${mvWhere} + GROUP BY referrer + ORDER BY count DESC LIMIT 250`, + { replacements, type: QueryTypes.SELECT }, + ), + // ── campaigns from matview + sequelize.query( + `SELECT + campaign, + SUM(count)::int AS count + FROM analytics_daily_campaign + WHERE ${mvWhere} + GROUP BY campaign + ORDER BY count DESC LIMIT 250`, + { replacements, type: QueryTypes.SELECT }, + ), + // ── devices from matview (Desktop/Mobile/Unknown) + sequelize.query( + `SELECT + device_type, + SUM(count)::int AS count + FROM analytics_daily_device + WHERE ${mvWhere} + GROUP BY device_type + ORDER BY count DESC LIMIT 250`, + { replacements, type: QueryTypes.SELECT }, + ), + ]); + + // Derive totals from the daily rows + let totalPageViews = 0; + let totalUniqueVisits = 0; + let totalDownloads = 0; + const dailyParsed = daily.map((d: any) => { + const pv = Number(d.pageViews); + const upv = Number(d.uniquePageViews); + const dl = Number(d.downloads ?? 0); + totalPageViews += pv; + totalUniqueVisits += upv; + totalDownloads += dl; + return { date: d.date, pageViews: pv, uniquePageViews: upv }; + }); + + return { + totalPageViews, + totalUniqueVisits, + totalDownloads, + daily: dailyParsed, + countries: rollUpTimezoneToCountries(timezoneRows).slice(0, 250), + topPubs: topPubs.map((p) => ({ + ...p, + views: Number(p.views), + downloads: Number(p.downloads), + })), + topPages: topPages.map((p) => ({ ...p, count: Number(p.count) })), + topCollections: topCollections.map((c) => ({ ...c, count: Number(c.count) })), + referrers: referrers.map((r) => ({ ...r, count: Number(r.count) })), + campaigns: campaigns.map((c) => ({ ...c, count: Number(c.count) })), + devices: devices.map((d) => ({ ...d, count: Number(d.count) })), + }; +} + +/** + * Fallback: query the raw AnalyticsEvents table directly. + * Used when scoping to a specific pub or collection (dimensions not in every matview). + */ +async function fetchSummaryFromRaw(scope: Scope, startDate: string, endDate: string) { + const { clause: scopeClause, replacements: scopeReplacements } = scopeWhere(scope); + const baseReplacements = { ...scopeReplacements, startDate, endDate }; + + const dateFilter = `"createdAt" >= :startDate::date AND "createdAt" < (:endDate::date + interval '1 day')`; + const pageEvents = `event IN ('page','pub','collection','other')`; + const baseWhere = `${scopeClause} AND ${dateFilter}`; + + const { clause: aeScopeClause } = scopeWhere(scope, 'ae'); + const aeDateFilter = `ae."createdAt" >= :startDate::date AND ae."createdAt" < (:endDate::date + interval '1 day')`; + const aeBaseWhere = `${aeScopeClause} AND ${aeDateFilter}`; + + const [ + daily, + [totalDlRow], + timezoneRows, + topPubs, + topPages, + topCollections, + referrers, + campaigns, + devices, + ] = await Promise.all([ + sequelize.query( + `SELECT + date_trunc('day', "createdAt")::date::text AS date, + COUNT(*) AS "pageViews", + COUNT(*) FILTER (WHERE "isUnique" = true) AS "uniquePageViews" + FROM "AnalyticsEvents" + WHERE ${baseWhere} AND ${pageEvents} + GROUP BY 1 ORDER BY 1`, + { replacements: baseReplacements, type: QueryTypes.SELECT }, + ), + sequelize.query<{ totalDownloads: string }>( + `SELECT COUNT(*) AS "totalDownloads" + FROM "AnalyticsEvents" + WHERE ${baseWhere} AND event = 'download'`, + { replacements: baseReplacements, type: QueryTypes.SELECT }, + ), + sequelize.query( + `SELECT + COALESCE(timezone, '') AS timezone, + COUNT(*) AS count + FROM "AnalyticsEvents" + WHERE ${baseWhere} AND ${pageEvents} + GROUP BY timezone + ORDER BY count DESC`, + { replacements: baseReplacements, type: QueryTypes.SELECT }, + ), + sequelize.query( + `SELECT + COALESCE(p.title, p.slug, ae."pubId"::text) AS "pubTitle", + p.slug AS "pubSlug", + ae."pubId"::text AS "pubId", + COUNT(*) FILTER (WHERE ae.event = 'pub') AS views, + COUNT(*) FILTER (WHERE ae.event = 'download') AS downloads + FROM "AnalyticsEvents" ae + LEFT JOIN "Pubs" p ON p.id = ae."pubId" + WHERE ${aeBaseWhere} AND ae."pubId" IS NOT NULL AND ae.event IN ('pub','download') + GROUP BY ae."pubId", p.title, p.slug + ORDER BY (COUNT(*) FILTER (WHERE ae.event = 'pub') + COUNT(*) FILTER (WHERE ae.event = 'download')) DESC LIMIT 250`, + { replacements: baseReplacements, type: QueryTypes.SELECT }, + ), + sequelize.query( + `SELECT + COALESCE(pg.title, p.title, c.title, ae.path, '') AS "pageTitle", + COALESCE(ae.path, '') AS path, + COUNT(*) AS count + FROM "AnalyticsEvents" ae + LEFT JOIN "Pages" pg ON pg.id = ae."pageId" + LEFT JOIN "Pubs" p ON p.id = ae."pubId" + LEFT JOIN "Collections" c ON c.id = ae."collectionId" + WHERE ${aeBaseWhere} AND ae.event IN ('page','pub','collection','other') + GROUP BY pg.title, p.title, c.title, ae.path + ORDER BY count DESC LIMIT 250`, + { replacements: baseReplacements, type: QueryTypes.SELECT }, + ), + sequelize.query( + `SELECT + COALESCE(c.title, ae."collectionId"::text) AS "collectionTitle", + c.slug AS "collectionSlug", + ae."collectionId"::text AS "collectionId", + COUNT(*) AS count + FROM "AnalyticsEvents" ae + LEFT JOIN "Collections" c ON c.id = ae."collectionId" + WHERE ${aeBaseWhere} AND ae."collectionId" IS NOT NULL AND ae.event IN ('collection','pub') + GROUP BY ae."collectionId", c.title, c.slug + ORDER BY count DESC LIMIT 250`, + { replacements: baseReplacements, type: QueryTypes.SELECT }, + ), + sequelize.query( + `SELECT + COALESCE(referrer, 'Direct') AS referrer, + COUNT(*) AS count + FROM "AnalyticsEvents" + WHERE ${baseWhere} AND ${pageEvents} + GROUP BY referrer + ORDER BY count DESC LIMIT 250`, + { replacements: baseReplacements, type: QueryTypes.SELECT }, + ), + sequelize.query( + `SELECT + "utmCampaign" AS campaign, + COUNT(*) AS count + FROM "AnalyticsEvents" + WHERE ${baseWhere} AND "utmCampaign" IS NOT NULL AND "utmCampaign" != '' + GROUP BY "utmCampaign" + ORDER BY count DESC LIMIT 250`, + { replacements: baseReplacements, type: QueryTypes.SELECT }, + ), + sequelize.query( + `SELECT + CASE + WHEN os IN ('iOS','Android') THEN 'Mobile' + WHEN os IN ('Windows','MacOS','Linux','UNIX','ChromeOS') THEN 'Desktop' + WHEN os = 'Unknown OS' OR os IS NULL OR os = '' THEN 'Unknown' + ELSE 'Other' + END AS device_type, + COUNT(*)::int AS count + FROM "AnalyticsEvents" + WHERE ${baseWhere} AND ${pageEvents} + GROUP BY device_type + ORDER BY count DESC LIMIT 250`, + { replacements: baseReplacements, type: QueryTypes.SELECT }, + ), + ]); + + let totalPageViews = 0; + let totalUniqueVisits = 0; + const dailyParsed = daily.map((d) => { + const pv = Number(d.pageViews); + const upv = Number(d.uniquePageViews); + totalPageViews += pv; + totalUniqueVisits += upv; + return { ...d, pageViews: pv, uniquePageViews: upv }; + }); + + return { + totalPageViews, + totalUniqueVisits, + totalDownloads: parseInt(String((totalDlRow as any)?.totalDownloads ?? '0'), 10), + daily: dailyParsed, + countries: rollUpTimezoneToCountries(timezoneRows).slice(0, 250), + topPubs: topPubs.map((p) => ({ + ...p, + views: Number(p.views), + downloads: Number(p.downloads), + })), + topPages: topPages.map((p) => ({ ...p, count: Number(p.count) })), + topCollections: topCollections.map((c) => ({ ...c, count: Number(c.count) })), + referrers: referrers.map((r) => ({ ...r, count: Number(r.count) })), + campaigns: campaigns.map((c) => ({ ...c, count: Number(c.count) })), + devices: devices.map((d) => ({ ...d, count: Number(d.count) })), + }; +} + +// ─── route ─────────────────────────────────────────────────────────────────── + +/** + * GET /api/analytics-impact + * + * Returns analytics from the AnalyticsEvents Postgres table for the legacy + * Impact dashboard. + * + * Query params: + * startDate – ISO date (e.g. "2024-01-01"). Defaults to 90 days ago. + * endDate – ISO date. Defaults to today. + * pubId – scope to a specific pub. + * collectionId – scope to a specific collection. + */ +router.get('/api/analytics-impact', async (req, res, next) => { + try { + if (!hostIsValid(req, 'community')) { + return next(); + } + + const initialData = await getInitialData(req, { isDashboard: true }); + const { canView } = initialData.scopeData.activePermissions; + if (!canView) { + throw new ForbiddenError(); + } + + const communityId = initialData.communityData.id; + + const now = new Date(); + const defaultStart = new Date(now); + defaultStart.setDate(defaultStart.getDate() - 90); + + let startDate = (req.query.startDate as string) || defaultStart.toISOString().slice(0, 10); + const endDate = (req.query.endDate as string) || now.toISOString().slice(0, 10); + + // Enforce 2-year maximum range (730 days) + const MAX_RANGE_DAYS = 730; + const sDate = new Date(startDate); + const eDate = new Date(endDate); + const diffDays = (eDate.getTime() - sDate.getTime()) / (1000 * 60 * 60 * 24); + if (diffDays > MAX_RANGE_DAYS) { + const clamped = new Date(eDate); + clamped.setDate(clamped.getDate() - MAX_RANGE_DAYS); + startDate = clamped.toISOString().slice(0, 10); + } + + const pubId = req.query.pubId as string | undefined; + const collectionId = req.query.collectionId as string | undefined; + + const scope: Scope = { communityId }; + if (pubId) scope.pubId = pubId; + if (collectionId) scope.collectionId = collectionId; + + const key = cacheKey(scope, startDate, endDate); + const cached = getCached(key); + if (cached) { + return res.json(cached); + } + + const result = await fetchSummary(scope, startDate, endDate); + setCache(key, result); + return res.json(result); + } catch (err) { + return handleErrors(req, res, next)(err); + } +}); diff --git a/server/analytics/model.ts b/server/analytics/model.ts new file mode 100644 index 0000000000..00338784cf --- /dev/null +++ b/server/analytics/model.ts @@ -0,0 +1,141 @@ +import type { CreationOptional, InferAttributes, InferCreationAttributes } from 'sequelize'; + +import { Op } from 'sequelize'; +import { + AllowNull, + Column, + DataType, + Default, + Index, + Model, + PrimaryKey, + Table, +} from 'sequelize-typescript'; + +@Table({ + updatedAt: false, + // Map Sequelize's auto-managed createdAt to our renamed column (was "timestamp") + createdAt: 'createdAt', + indexes: [ + { + name: 'analytics_events_community_event_created', + fields: ['communityId', 'event', 'createdAt'], + }, + { name: 'analytics_events_pub_event_created', fields: ['pubId', 'event', 'createdAt'] }, + { + name: 'analytics_events_collection_event_created', + fields: ['collectionId', 'event', 'createdAt'], + }, + { + name: 'analytics_events_community_created', + fields: ['communityId', 'createdAt'], + }, + { + name: 'analytics_events_community_pages', + fields: ['communityId', 'createdAt', 'isUnique'], + where: { event: { [Op.in]: ['page', 'pub', 'collection', 'other'] } }, + }, + { + name: 'analytics_events_pub_views_dl', + fields: ['communityId', 'pubId', 'createdAt'], + where: { + pubId: { [Op.ne]: null }, + event: { [Op.in]: ['pub', 'download'] }, + }, + }, + ], +}) +export class AnalyticsEvent extends Model< + InferAttributes, + InferCreationAttributes +> { + @Default(DataType.UUIDV4) + @PrimaryKey + @Column(DataType.UUID) + declare id: CreationOptional; + + @AllowNull(false) + @Column(DataType.TEXT) + declare type: string; + + @AllowNull(false) + @Column(DataType.TEXT) + declare event: string; + + // Sequelize auto-manages this via `createdAt: 'createdAt'` in Table options. + // For imported rows the value was preserved from the original Redshift "timestamp" column. + declare createdAt: CreationOptional; + + @Column(DataType.TEXT) + declare referrer: string | null; + + @Column(DataType.BOOLEAN) + declare isUnique: boolean | null; + + @Column(DataType.TEXT) + declare search: string | null; + + @Column(DataType.TEXT) + declare utmSource: string | null; + + @Column(DataType.TEXT) + declare utmMedium: string | null; + + @Column(DataType.TEXT) + declare utmCampaign: string | null; + + @Column(DataType.TEXT) + declare utmTerm: string | null; + + @Column(DataType.TEXT) + declare utmContent: string | null; + + @AllowNull(false) + @Column(DataType.TEXT) + declare timezone: string; + + @AllowNull(false) + @Column(DataType.TEXT) + declare locale: string; + + @AllowNull(false) + @Column(DataType.TEXT) + declare userAgent: string; + + @AllowNull(false) + @Column(DataType.TEXT) + declare os: string; + + @Column(DataType.UUID) + declare communityId: string | null; + + @Column(DataType.TEXT) + declare url: string | null; + + @Column(DataType.TEXT) + declare hash: string | null; + + @Column(DataType.INTEGER) + declare height: number | null; + + @Column(DataType.INTEGER) + declare width: number | null; + + @Column(DataType.TEXT) + declare path: string | null; + + @Column(DataType.UUID) + declare pageId: string | null; + + @Column(DataType.UUID) + declare collectionId: string | null; + + @Column(DataType.UUID) + declare pubId: string | null; + + @Column(DataType.TEXT) + declare release: string | null; + + @Column(DataType.TEXT) + declare format: string | null; +} diff --git a/server/analytics/summaryViews.ts b/server/analytics/summaryViews.ts new file mode 100644 index 0000000000..c7e55bd7cd --- /dev/null +++ b/server/analytics/summaryViews.ts @@ -0,0 +1,229 @@ +/** + * Materialized views that pre-aggregate AnalyticsEvents by day. + * + * Each view collapses millions of raw rows into a handful of daily summary rows + * so dashboard queries scan orders of magnitude less data. + * + * Call `createSummaryViews()` once (idempotent) and `refreshSummaryViews()` + * periodically (e.g. nightly cron or after new data is imported). + */ +import { sequelize } from 'server/sequelize'; + +// ─── Individual CREATE statements ──────────────────────────────────────────── +// Each view has its own CREATE + UNIQUE INDEX so failures are isolated. +// For views with potentially large text keys (referrer, campaign, page_title, +// path), we use md5() hashes in the unique index to stay within btree limits. + +const VIEWS: Array<{ name: string; createSql: string; indexSql: string }> = [ + { + name: 'analytics_daily_summary', + createSql: ` +CREATE MATERIALIZED VIEW IF NOT EXISTS analytics_daily_summary AS +SELECT + "communityId", + date_trunc('day', "createdAt")::date AS date, + COUNT(*) FILTER (WHERE event IN ('page','pub','collection','other')) AS page_views, + COUNT(*) FILTER (WHERE event IN ('page','pub','collection','other') AND "isUnique" = true) AS unique_page_views, + COUNT(*) FILTER (WHERE event = 'download') AS downloads +FROM "AnalyticsEvents" +GROUP BY "communityId", date_trunc('day', "createdAt")::date`, + indexSql: ` +CREATE UNIQUE INDEX IF NOT EXISTS analytics_daily_summary_uk + ON analytics_daily_summary ("communityId", date)`, + }, + { + name: 'analytics_daily_timezone', + createSql: ` +CREATE MATERIALIZED VIEW IF NOT EXISTS analytics_daily_timezone AS +SELECT + "communityId", + date_trunc('day', "createdAt")::date AS date, + COALESCE(timezone, '') AS timezone, + COUNT(*) AS count +FROM "AnalyticsEvents" +WHERE event IN ('page','pub','collection','other') +GROUP BY "communityId", date_trunc('day', "createdAt")::date, timezone`, + indexSql: ` +CREATE UNIQUE INDEX IF NOT EXISTS analytics_daily_timezone_uk + ON analytics_daily_timezone ("communityId", date, md5(timezone))`, + }, + { + name: 'analytics_daily_pub', + createSql: ` +CREATE MATERIALIZED VIEW IF NOT EXISTS analytics_daily_pub AS +SELECT + "communityId", + "pubId", + date_trunc('day', "createdAt")::date AS date, + COUNT(*) FILTER (WHERE event = 'pub') AS views, + COUNT(*) FILTER (WHERE event = 'download') AS downloads +FROM "AnalyticsEvents" +WHERE "pubId" IS NOT NULL AND event IN ('pub','download') +GROUP BY "communityId", "pubId", date_trunc('day', "createdAt")::date`, + indexSql: ` +CREATE UNIQUE INDEX IF NOT EXISTS analytics_daily_pub_uk + ON analytics_daily_pub ("communityId", "pubId", date)`, + }, + { + name: 'analytics_daily_collection', + createSql: ` +CREATE MATERIALIZED VIEW IF NOT EXISTS analytics_daily_collection AS +SELECT + "communityId", + "collectionId", + date_trunc('day', "createdAt")::date AS date, + COUNT(*) AS count +FROM "AnalyticsEvents" +WHERE "collectionId" IS NOT NULL AND event IN ('collection','pub') +GROUP BY "communityId", "collectionId", date_trunc('day', "createdAt")::date`, + indexSql: ` +CREATE UNIQUE INDEX IF NOT EXISTS analytics_daily_collection_uk + ON analytics_daily_collection ("communityId", "collectionId", date)`, + }, + // NOTE: referrer and page matviews have high cardinality per community×day, + // but still dramatically outperform the raw table for year-long date ranges + // (raw table seq-scans ~19M rows; matviews only scan the pre-grouped rows). + // The unique index uses md5() to stay within btree's 2704-byte limit. + { + name: 'analytics_daily_referrer', + createSql: ` +CREATE MATERIALIZED VIEW IF NOT EXISTS analytics_daily_referrer AS +SELECT + "communityId", + date_trunc('day', "createdAt")::date AS date, + COALESCE(LEFT(referrer, 500), 'Direct') AS referrer, + COUNT(*) AS count +FROM "AnalyticsEvents" +WHERE event IN ('page','pub','collection','other') +GROUP BY "communityId", date_trunc('day', "createdAt")::date, COALESCE(LEFT(referrer, 500), 'Direct')`, + indexSql: ` +CREATE UNIQUE INDEX IF NOT EXISTS analytics_daily_referrer_uk + ON analytics_daily_referrer ("communityId", date, md5(referrer))`, + }, + { + name: 'analytics_daily_campaign', + createSql: ` +CREATE MATERIALIZED VIEW IF NOT EXISTS analytics_daily_campaign AS +SELECT + "communityId", + date_trunc('day', "createdAt")::date AS date, + "utmCampaign" AS campaign, + COUNT(*) AS count +FROM "AnalyticsEvents" +WHERE "utmCampaign" IS NOT NULL AND "utmCampaign" != '' +GROUP BY "communityId", date_trunc('day', "createdAt")::date, "utmCampaign"`, + indexSql: ` +CREATE UNIQUE INDEX IF NOT EXISTS analytics_daily_campaign_uk + ON analytics_daily_campaign ("communityId", date, md5(campaign))`, + }, + { + name: 'analytics_daily_page', + createSql: ` +CREATE MATERIALIZED VIEW IF NOT EXISTS analytics_daily_page AS +SELECT + ae."communityId", + date_trunc('day', ae."createdAt")::date AS date, + LEFT(COALESCE(pg.title, p.title, c.title, ae.path, ''), 300) AS page_title, + LEFT(COALESCE(ae.path, ''), 300) AS path, + COUNT(*) AS count +FROM "AnalyticsEvents" ae +LEFT JOIN "Pages" pg ON pg.id = ae."pageId" +LEFT JOIN "Pubs" p ON p.id = ae."pubId" +LEFT JOIN "Collections" c ON c.id = ae."collectionId" +WHERE ae.event IN ('page','pub','collection','other') +GROUP BY ae."communityId", date_trunc('day', ae."createdAt")::date, + LEFT(COALESCE(pg.title, p.title, c.title, ae.path, ''), 300), + LEFT(COALESCE(ae.path, ''), 300)`, + indexSql: ` +CREATE UNIQUE INDEX IF NOT EXISTS analytics_daily_page_uk + ON analytics_daily_page ("communityId", date, md5(page_title || '|' || path))`, + }, + { + name: 'analytics_daily_device', + createSql: ` +CREATE MATERIALIZED VIEW IF NOT EXISTS analytics_daily_device AS +SELECT + "communityId", + date_trunc('day', "createdAt")::date AS date, + CASE + WHEN os IN ('iOS','Android') THEN 'Mobile' + WHEN os IN ('Windows','MacOS','Linux','UNIX','ChromeOS') THEN 'Desktop' + WHEN os = 'Unknown OS' OR os IS NULL OR os = '' THEN 'Unknown' + ELSE 'Other' + END AS device_type, + COUNT(*) AS count +FROM "AnalyticsEvents" +WHERE event IN ('page','pub','collection','other') +GROUP BY "communityId", date_trunc('day', "createdAt")::date, + CASE + WHEN os IN ('iOS','Android') THEN 'Mobile' + WHEN os IN ('Windows','MacOS','Linux','UNIX','ChromeOS') THEN 'Desktop' + WHEN os = 'Unknown OS' OR os IS NULL OR os = '' THEN 'Unknown' + ELSE 'Other' + END`, + indexSql: ` +CREATE UNIQUE INDEX IF NOT EXISTS analytics_daily_device_uk + ON analytics_daily_device ("communityId", date, device_type)`, + }, +]; + +// ─── public API ────────────────────────────────────────────────────────────── + +export async function createSummaryViews() { + // Execute each view creation + index separately so that already-existing + // views don't block subsequent ones. + await VIEWS.reduce( + (chain, v) => + chain.then(async () => { + await sequelize.query(v.createSql); + await sequelize.query(v.indexSql); + }), + Promise.resolve() as Promise, + ); + // Plain B-tree indexes on (communityId, date) for views whose unique index + // uses md5() – the plain index lets PG do a simple range scan without the + // overhead of the functional index. + await sequelize.query(` + CREATE INDEX IF NOT EXISTS analytics_daily_referrer_comm_date + ON analytics_daily_referrer ("communityId", date)`); + await sequelize.query(` + CREATE INDEX IF NOT EXISTS analytics_daily_page_comm_date + ON analytics_daily_page ("communityId", date)`); + await sequelize.query(` + CREATE INDEX IF NOT EXISTS analytics_daily_timezone_comm_date + ON analytics_daily_timezone ("communityId", date)`); +} + +/** + * Refresh all summary materialized views. + * Uses CONCURRENTLY for views with plain unique indexes, and non-concurrent + * for views whose unique index uses function expressions (md5). + * After refresh, CLUSTERs each view so that rows for the same communityId+date + * are physically contiguous on disk, dramatically reducing random I/O. + */ +// Map view name → preferred CLUSTER index. For views with md5-based unique +// indexes, we cluster by the plain (communityId, date) index instead for +// better physical data locality. +const CLUSTER_INDEX: Record = { + analytics_daily_referrer: 'analytics_daily_referrer_comm_date', + analytics_daily_page: 'analytics_daily_page_comm_date', + analytics_daily_timezone: 'analytics_daily_timezone_comm_date', +}; + +export async function refreshSummaryViews() { + await VIEWS.reduce( + (chain, v) => { + const concurrent = v.indexSql.includes('md5(') ? '' : ' CONCURRENTLY'; + const defaultIdx = v.indexSql.match(/INDEX IF NOT EXISTS (\S+)/)?.[1] ?? ''; + const clusterIdx = CLUSTER_INDEX[v.name] ?? defaultIdx; + return chain.then(async () => { + await sequelize.query(`REFRESH MATERIALIZED VIEW${concurrent} ${v.name}`); + if (clusterIdx) { + await sequelize.query(`CLUSTER ${v.name} USING ${clusterIdx}`); + await sequelize.query(`ANALYZE ${v.name}`); + } + }); + }, + Promise.resolve() as Promise, + ); +} diff --git a/server/analytics/writeBuffer.ts b/server/analytics/writeBuffer.ts new file mode 100644 index 0000000000..a4ef2bf18b --- /dev/null +++ b/server/analytics/writeBuffer.ts @@ -0,0 +1,124 @@ +/** + * Batched write buffer for analytics events. + * + * Instead of issuing one INSERT per page view, events are accumulated in memory + * and flushed to Postgres in a single `bulkCreate` every few seconds — or when + * the buffer reaches a size cap. This reduces per-request DB overhead (index + * maintenance, WAL writes, connection churn) from N round-trips to 1. + * + * ## Guarantees + * - Events are never silently dropped. Flush failures are logged and the + * failed batch is retried once on the next tick. + * - On graceful shutdown (SIGTERM / SIGINT) the buffer is flushed before exit. + * - On crash (OOM, SIGKILL) up to FLUSH_INTERVAL_MS of events may be lost. + * This is acceptable for analytics — no user-facing data is affected. + * + * ## Tuning + * - FLUSH_INTERVAL_MS: max age of a buffered event (default 5 s). + * - MAX_BUFFER_SIZE: flush early if we accumulate this many events. + */ +import { AnalyticsEvent } from './model'; + +// ─── configuration ─────────────────────────────────────────────────────────── + +const FLUSH_INTERVAL_MS = 5_000; +const MAX_BUFFER_SIZE = 500; + +// ─── state ─────────────────────────────────────────────────────────────────── + +type EventRecord = Record; + +let buffer: EventRecord[] = []; +let flushTimer: ReturnType | null = null; + +// ─── public API ────────────────────────────────────────────────────────────── + +/** Queue a single analytics event for batched insertion. */ +export function enqueue(record: EventRecord) { + buffer.push(record); + if (buffer.length >= MAX_BUFFER_SIZE) { + // Buffer is full — flush immediately (non-blocking). + // eslint-disable-next-line no-empty + flush().catch(() => { + /* fire-and-forget */ + }); + } +} + +/** + * Flush all buffered events to Postgres. Safe to call at any time (no-ops if + * the buffer is empty). Returns a promise that resolves when the write completes. + */ +export async function flush(): Promise { + if (buffer.length === 0) return; + + // Swap out the buffer so new events that arrive during the INSERT go into a + // fresh array and aren't lost. + const batch = buffer; + buffer = []; + + try { + await AnalyticsEvent.bulkCreate(batch as any[], { + // ignoreDuplicates uses ON CONFLICT DO NOTHING — if a UUID collides + // (astronomically unlikely) the row is silently skipped. + ignoreDuplicates: true, + // Skip per-row model validation for speed; the Zod schema in the + // HTTP handler already validates the shape. + validate: false, + }); + } catch (err) { + // eslint-disable-next-line no-console + console.error(`[analytics writeBuffer] bulkCreate failed for ${batch.length} events:`, err); + // Put the failed batch back so the next flush retries them. Cap at + // 2× MAX_BUFFER_SIZE to avoid unbounded growth if PG is truly down. + if (buffer.length + batch.length <= MAX_BUFFER_SIZE * 2) { + buffer = batch.concat(buffer); + } else { + // eslint-disable-next-line no-console + console.error( + `[analytics writeBuffer] dropping ${batch.length} events (buffer overflow)`, + ); + } + } +} + +/** Start the periodic flush timer. Called once at import time. */ +export function start() { + if (flushTimer) return; + flushTimer = setInterval(() => { + // eslint-disable-next-line no-empty + flush().catch(() => { + /* fire-and-forget */ + }); + }, FLUSH_INTERVAL_MS); + // Don't let the timer keep the process alive during shutdown. + if (flushTimer && typeof flushTimer === 'object' && 'unref' in flushTimer) { + flushTimer.unref(); + } +} + +/** Stop the timer and flush remaining events. Called on SIGTERM / SIGINT. */ +export async function stop(): Promise { + if (flushTimer) { + clearInterval(flushTimer); + flushTimer = null; + } + await flush(); +} + +// ─── auto-start & graceful shutdown ────────────────────────────────────────── + +start(); + +const shutdown = async () => { + // eslint-disable-next-line no-console + console.info('[analytics writeBuffer] flushing before shutdown…'); + await stop(); +}; + +process.once('SIGTERM', () => { + shutdown().finally(() => process.exit(0)); +}); +process.once('SIGINT', () => { + shutdown().finally(() => process.exit(0)); +}); diff --git a/server/analyticsCloudflareCache/model.ts b/server/analyticsCloudflareCache/model.ts new file mode 100644 index 0000000000..11f518488d --- /dev/null +++ b/server/analyticsCloudflareCache/model.ts @@ -0,0 +1,53 @@ +import type { CreationOptional, InferAttributes, InferCreationAttributes } from 'sequelize'; + +import { AllowNull, Column, DataType, Model, PrimaryKey, Table } from 'sequelize-typescript'; + +/** + * Caches per-day Cloudflare analytics for a community hostname + scope. + * + * Composite primary key: (hostname, date, scope). + * scope = 'community' for community-wide data, + * 'pub:' for per-pub data, + * etc. + * Past days are cached permanently (expiresAt = null). + * Today's partial data is cached with a short TTL (expiresAt = now + 1h). + * + */ +@Table({ timestamps: false }) +export class AnalyticsCloudflareCache extends Model< + InferAttributes, + InferCreationAttributes +> { + /** Community hostname, e.g. "demo.pubpub.org" or "journal.example.com" */ + @PrimaryKey + @AllowNull(false) + @Column(DataType.TEXT) + declare hostname: string; + + /** Calendar date (ISO format, e.g. "2026-04-01") */ + @PrimaryKey + @AllowNull(false) + @Column(DataType.DATEONLY) + declare date: string; + + /** Scope identifier: 'community', 'pub:my-slug', etc. */ + @PrimaryKey + @AllowNull(false) + @Column({ type: DataType.TEXT, defaultValue: 'community' }) + declare scope: CreationOptional; + + /** + * Pre-aggregated analytics payload for this day. + * Shape: { visits, pageViews, topPaths[], countries[], devices[], referrers[] } + */ + @AllowNull(false) + @Column(DataType.JSONB) + declare data: object; + + /** + * When this cache entry expires. NULL = permanent (completed past days). + * For today's partial data, set to ~1 hour from write time. + */ + @Column(DataType.DATE) + declare expiresAt: CreationOptional; +} diff --git a/server/apiRoutes.ts b/server/apiRoutes.ts index 84b414316d..4aff0caabf 100644 --- a/server/apiRoutes.ts +++ b/server/apiRoutes.ts @@ -3,6 +3,7 @@ import { Router } from 'express'; import { isProd } from 'utils/environment'; import { activityItemRouter } from './activityItem/api'; +import { router as analyticsImpactRouter } from './analytics/impactApi'; import { router as apiDocsRouter } from './apiDocs/api'; import { router as captchaRouter } from './captcha/api'; import { router as citationRouter } from './citation/api'; @@ -15,6 +16,7 @@ import { router as doiRouter } from './doi/api'; import { router as draftCheckpointRouter } from './draftCheckpoint/api'; import { router as editorRouter } from './editor/api'; import { env } from './env'; +import { router as impact2Router } from './impact2/api'; import { router as integrationDataOAuth1Router } from './integrationDataOAuth1/api'; import { router as landingPageFeatureRouter } from './landingPageFeature/api'; import { router as layoutRouter } from './layout/api'; @@ -75,6 +77,8 @@ const apiRouter = Router() .use(userNotificationPreferencesRouter) .use(userSubscriptionRouter) .use(zoteroIntegrationRouter) + .use(analyticsImpactRouter) + .use(impact2Router) .use(apiDocsRouter); if (!isProd() && env.NODE_ENV !== 'test') { diff --git a/server/envSchema.ts b/server/envSchema.ts index d1a49dcdf7..692beaea06 100644 --- a/server/envSchema.ts +++ b/server/envSchema.ts @@ -152,6 +152,23 @@ export const envSchema = z.object({ // ── Analytics ─────────────────────────────────────────────────────── METABASE_SECRET_KEY: z.string().describe('Metabase embedding secret key'), STITCH_WEBHOOK_URL: z.string().describe('MongoDB Stitch webhook URL for analytics'), + CLOUDFLARE_ANALYTICS_API_TOKEN: z + .string() + .optional() + .describe('Analytics token with read permissions fo Cloudflare GraphQL'), + CLOUDFLARE_ZONE_TAG: z.string().optional().describe('Zone ID of the domain used'), + + // ── Analytics Migration (Redshift) ────────────────────────────────── + AM_REDSHIFT_PATH: z.string().optional().describe('S3 path for Redshift analytics backup'), + AM_REDSHIFT_BACKUP_ACCESS_KEY: z + .string() + .optional() + .describe('AWS access key for Redshift backup'), + AM_REDSHIFT_BACKUP_SECRET_KEY: z + .string() + .optional() + .describe('AWS secret key for Redshift backup'), + AM_REDSHIFT_BACKUP_ROLE_ARN: z.string().optional().describe('IAM role ARN for Redshift backup'), // ── Spam / Security ───────────────────────────────────────────────── BLOCKLIST_IP_ADDRESSES: z diff --git a/server/impact2/api.ts b/server/impact2/api.ts new file mode 100644 index 0000000000..ef37e92b43 --- /dev/null +++ b/server/impact2/api.ts @@ -0,0 +1,258 @@ +import type { CloudflareAnalyticsResult } from 'server/utils/cloudflareAnalytics'; + +import { Router } from 'express'; +import { Op } from 'sequelize'; + +import { Collection } from 'server/collection/model'; +import { CollectionPub } from 'server/collectionPub/model'; +import { Community } from 'server/community/model'; +import { Page } from 'server/page/model'; +import { Pub } from 'server/pub/model'; +import { + debugCommunityAnalytics, + fetchCollectionAnalytics, + fetchCommunityAnalytics, + fetchPubAnalytics, + testCloudflareConnection, +} from 'server/utils/cloudflareAnalytics'; +import { ForbiddenError, handleErrors } from 'server/utils/errors'; +import { getInitialData } from 'server/utils/initData'; +import { hostIsValid } from 'server/utils/routes'; + +export const router = Router(); + +/** + * Build a slug → title map for paths in the analytics result. + * + * Extracts pub slugs from /pub/{slug}[/...] and top-level slugs from /{slug}, + * then batch-queries Pub, Collection, and Page tables. Returns a map from + * the *base path* (e.g. '/pub/my-pub' or '/my-collection') to its title. + */ +async function resolvePathTitles( + result: CloudflareAnalyticsResult, + communityId: string, +): Promise> { + const pubSlugs = new Set(); + const topLevelSlugs = new Set(); + + for (const { path } of result.topPaths) { + const pubMatch = path.match(/^\/pub\/([^/]+)/); + if (pubMatch) { + pubSlugs.add(pubMatch[1]); + } else { + const topMatch = path.match(/^\/([^/]+)/); + if (topMatch) { + topLevelSlugs.add(topMatch[1]); + } + } + } + + const titles: Record = {}; + + // Batch-query pub titles + if (pubSlugs.size > 0) { + const pubs = await Pub.findAll({ + where: { communityId, slug: { [Op.in]: [...pubSlugs] } }, + attributes: ['slug', 'title'], + }); + for (const p of pubs) { + titles[`/pub/${p.slug}`] = p.title; + } + } + + // Batch-query collection + page titles for top-level slugs + if (topLevelSlugs.size > 0) { + const slugArr = [...topLevelSlugs]; + const [collections, pages] = await Promise.all([ + Collection.findAll({ + where: { communityId, slug: { [Op.in]: slugArr } }, + attributes: ['slug', 'title'], + }), + Page.findAll({ + where: { communityId, slug: { [Op.in]: slugArr } }, + attributes: ['slug', 'title'], + }), + ]); + for (const c of collections) { + titles[`/${c.slug}`] = c.title; + } + for (const p of pages) { + // Don't overwrite if a collection already matched + if (!titles[`/${p.slug}`]) { + titles[`/${p.slug}`] = p.title; + } + } + } + + return titles; +} + +/** + * Resolve the hostname Cloudflare actually sees for a community. + * + * We query the Community model directly because getInitialData overwrites + * communityData.domain with the localhost proxy header in dev mode. + * + * Priority: + * 1. Raw `domain` column from the DB (if it's a real domain, not localhost). + * 2. Fallback to {subdomain}.pubpub.org. + */ +async function resolveCloudflareHostname(communityId: string): Promise { + const row = await Community.findByPk(communityId, { + attributes: ['subdomain', 'domain'], + }); + if (!row) { + throw new Error(`Community not found: ${communityId}`); + } + const { domain, subdomain } = row; + if (domain && !domain.includes('localhost') && !domain.includes('127.0.0.1')) { + // Strip port if present (shouldn't be in prod, but just in case) + return domain.replace(/:\d+$/, ''); + } + return `${subdomain}.pubpub.org`; +} + +/** + * GET /api/impact2/test + * + * Quick diagnostic to verify Cloudflare env vars are set and working. + * Returns JSON with { ok, error?, zoneTag?, tokenPrefix? }. + */ +router.get('/api/impact2/test', async (_req, res) => { + const result = await testCloudflareConnection(); + const status = result.ok ? 200 : 503; + return res.status(status).json(result); +}); + +/** + * GET /api/impact2/debug + * + * Shows the exact hostname being used, the filter sent to Cloudflare, + * raw CF responses, and which hostnames actually have data in the zone. + * Accepts optional ?hostname=override&startDate=...&endDate=... + * + * Only available in non-production environments. + */ +router.get('/api/impact2/debug', async (req, res, next) => { + if (process.env.NODE_ENV === 'production') { + return res.status(404).json({ error: 'Not available in production' }); + } + try { + if (!hostIsValid(req, 'community')) { + return next(); + } + const initialData = await getInitialData(req, { isDashboard: true }); + const { canView } = initialData.scopeData.activePermissions; + if (!canView) { + throw new ForbiddenError(); + } + + const communityData = initialData.communityData; + const defaultHostname = await resolveCloudflareHostname(communityData.id); + const hostname = (req.query.hostname as string) || defaultHostname; + + const now = new Date(); + const defaultStart = new Date(now); + defaultStart.setDate(defaultStart.getDate() - 7); + + const startDate = + (req.query.startDate as string) || defaultStart.toISOString().slice(0, 10); + const endDate = (req.query.endDate as string) || now.toISOString().slice(0, 10); + + const result = await debugCommunityAnalytics(hostname, startDate, endDate); + return res.json({ + communityFromDb: { + subdomain: communityData.subdomain, + domainRaw: communityData.domain, + resolvedHostname: defaultHostname, + }, + overrideHostname: req.query.hostname || null, + ...result, + }); + } catch (err) { + return handleErrors(req, res, next)(err); + } +}); + +/** + * GET /api/impact2 + * + * Returns Cloudflare-sourced analytics for the current scope. + * Query params: + * startDate – ISO date (e.g. "2026-03-01"). Defaults to 30 days ago. + * endDate – ISO date (e.g. "2026-03-31"). Defaults to today. + * pubSlug – if set, returns pub-scoped analytics (CF path filter). + * collectionId – if set, returns collection-scoped analytics (merged). + */ +router.get('/api/impact2', async (req, res, next) => { + try { + if (!hostIsValid(req, 'community')) { + return next(); + } + const initialData = await getInitialData(req, { isDashboard: true }); + const { canView } = initialData.scopeData.activePermissions; + if (!canView) { + throw new ForbiddenError(); + } + + const communityData = initialData.communityData; + const hostname = await resolveCloudflareHostname(communityData.id); + + const now = new Date(); + const defaultStart = new Date(now); + defaultStart.setDate(defaultStart.getDate() - 30); + + const startDate = + (req.query.startDate as string) || defaultStart.toISOString().slice(0, 10); + const endDate = (req.query.endDate as string) || now.toISOString().slice(0, 10); + + const pubSlug = req.query.pubSlug as string | undefined; + const collectionId = req.query.collectionId as string | undefined; + + let result; + + if (pubSlug) { + // Pub scope: CF query filtered by path prefix + result = await fetchPubAnalytics(hostname, pubSlug, startDate, endDate); + } else if (collectionId) { + // Collection scope: community data + pub cache enrichment + const collection = await Collection.findByPk(collectionId, { + attributes: ['slug'], + }); + if (!collection) { + return res.status(404).json({ error: 'Collection not found' }); + } + const collectionPubs = await CollectionPub.findAll({ + where: { collectionId }, + include: [{ model: Pub, as: 'pub', attributes: ['slug'] }], + }); + const pubSlugs = collectionPubs + .map((cp) => cp.pub?.slug) + .filter((s): s is string => !!s); + + result = await fetchCollectionAnalytics( + hostname, + collection.slug, + pubSlugs, + startDate, + endDate, + ); + } else { + // Community scope (default) + result = await fetchCommunityAnalytics(hostname, startDate, endDate); + } + + if (!result) { + return res.status(503).json({ + error: 'Cloudflare analytics not configured. Set CLOUDFLARE_ANALYTICS_API_TOKEN and CLOUDFLARE_ZONE_TAG environment variables.', + }); + } + + // Enrich with titles looked up from the DB (not cached — titles can change) + const pathTitles = await resolvePathTitles(result, communityData.id); + + return res.json({ ...result, pathTitles }); + } catch (err) { + return handleErrors(req, res, next)(err); + } +}); diff --git a/server/models.ts b/server/models.ts index e240c5c09c..921b15e84c 100644 --- a/server/models.ts +++ b/server/models.ts @@ -3,6 +3,8 @@ import passportLocalSequelize from 'passport-local-sequelize'; /* Import and create all models. */ /* Also import them to make them available to other modules */ import { ActivityItem } from './activityItem/model'; +import { AnalyticsEvent } from './analytics/model'; +import { AnalyticsCloudflareCache } from './analyticsCloudflareCache/model'; import { AuthToken } from './authToken/model'; import { Collection } from './collection/model'; import { CollectionAttribution } from './collectionAttribution/model'; @@ -60,6 +62,8 @@ import { ZoteroIntegration } from './zoteroIntegration/model'; sequelize.addModels([ ActivityItem, + AnalyticsEvent, + AnalyticsCloudflareCache, AuthToken, Collection, CollectionAttribution, @@ -151,6 +155,8 @@ export const includeUserModel = (() => { export { ActivityItem, + AnalyticsEvent, + AnalyticsCloudflareCache, AuthToken, Collection, CollectionAttribution, diff --git a/server/routes/dashboardImpact.tsx b/server/routes/dashboardImpact.tsx index d082304f21..1e891f8f9d 100644 --- a/server/routes/dashboardImpact.tsx +++ b/server/routes/dashboardImpact.tsx @@ -5,7 +5,6 @@ import { Router } from 'express'; import Html from 'server/Html'; import { handleErrors, NotFoundError } from 'server/utils/errors'; import { getInitialData } from 'server/utils/initData'; -import { generateMetabaseToken } from 'server/utils/metabase'; import { hostIsValid } from 'server/utils/routes'; import { generateMetaComponents, renderToNodeStream } from 'server/utils/ssr'; @@ -22,27 +21,12 @@ router.get( if (!initialData.scopeData.elements.activeTarget) { throw new NotFoundError(); } - const { activeTargetType, activeTarget } = initialData.scopeData.elements; - const impactData = { - baseToken: generateMetabaseToken(activeTargetType, activeTarget.id, 'base'), - newToken: generateMetabaseToken( - activeTargetType, - activeTarget.id, - 'new', - Boolean(initialData.locationData.isProd), - ), - benchmarkToken: generateMetabaseToken( - activeTargetType, - activeTarget.id, - 'benchmark', - ), - }; return renderToNodeStream( res, { + try { + if (!hostIsValid(req, 'community')) { + return next(); + } + const initialData = await getInitialData(req, { isDashboard: true }); + if (!initialData.scopeData.elements.activeTarget) { + throw new NotFoundError(); + } + return renderToNodeStream( + res, + , + ); + } catch (err) { + return handleErrors(req, res, next)(err); + } + }, +); diff --git a/server/routes/index.ts b/server/routes/index.ts index 12968b2146..458b5f9c9d 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -12,6 +12,7 @@ import { router as dashboardDiscussionsRouter } from './dashboardDiscussions'; import { router as dashboardEdgesRouter } from './dashboardEdges'; import { router as dashboardFacetsRouter } from './dashboardFacets'; import { router as dashboardImpactRouter } from './dashboardImpact'; +import { router as dashboardImpact2Router } from './dashboardImpact2'; import { router as dashboardMembersRouter } from './dashboardMembers'; import { router as dashboardPageRouter } from './dashboardPage'; import { router as dashboardPagesRouter } from './dashboardPages'; @@ -63,6 +64,7 @@ rootRouter .use(dashboardEdgesRouter) .use(dashboardFacetsRouter) .use(dashboardImpactRouter) + .use(dashboardImpact2Router) .use(dashboardMembersRouter) .use(dashboardCommunityOverviewRouter) .use(dashboardCollectionOverviewRouter) diff --git a/server/sequelize.ts b/server/sequelize.ts index 57d03423c2..faf739285f 100644 --- a/server/sequelize.ts +++ b/server/sequelize.ts @@ -136,5 +136,11 @@ if (process.env.NODE_ENV !== 'test') { await backfillCommunitySearchVectors(); })().catch((err) => console.error('Search vector backfill error:', err)); } + + // Create analytics materialized views (idempotent — no-ops if they exist). + // Refresh is handled by the nightly cron, not at startup, because it can + // take several minutes and would delay deploys. + const { createSummaryViews } = await import('server/analytics/summaryViews'); + await createSummaryViews(); })(); } diff --git a/server/server.ts b/server/server.ts index adc4238357..c5a51bdf17 100755 --- a/server/server.ts +++ b/server/server.ts @@ -190,6 +190,10 @@ appRouter.use((req, res, next) => { return next(); } + if (req.path.includes('/api/analytics')) { + return next(); + } + const abortController = new AbortController(); abortStorage.enterWith({ abortController }); diff --git a/server/utils/cloudflareAnalytics.ts b/server/utils/cloudflareAnalytics.ts new file mode 100644 index 0000000000..2b5d037144 --- /dev/null +++ b/server/utils/cloudflareAnalytics.ts @@ -0,0 +1,1352 @@ +/** + * Cloudflare GraphQL Analytics API client. + * + * Uses the httpRequestsAdaptiveGroups dataset, filtered by clientRequestHTTPHost, + * to get per-community (per-domain) analytics sourced from Cloudflare's edge. + * + * Required env vars: + * CLOUDFLARE_ANALYTICS_API_TOKEN – a Cloudflare API token with Analytics:Read + * CLOUDFLARE_ZONE_TAG – the zone ID that fronts PubPub traffic + */ + +import { env } from 'server/env'; + +const CF_GRAPHQL_ENDPOINT = 'https://api.cloudflare.com/client/v4/graphql'; + +function getConfig() { + const apiToken = env.CLOUDFLARE_ANALYTICS_API_TOKEN; + const zoneTag = env.CLOUDFLARE_ZONE_TAG; + if (!apiToken || !zoneTag) { + const missing = [ + !apiToken && 'CLOUDFLARE_ANALYTICS_API_TOKEN', + !zoneTag && 'CLOUDFLARE_ZONE_TAG', + ].filter(Boolean); + console.warn( + `[Impact2] Cloudflare analytics disabled — missing env var(s): ${missing.join(', ')}. ` + + 'Set these to enable the Impact dashboard.', + ); + return null; + } + return { apiToken, zoneTag }; +} + +export { getConfig as getCloudflareConfig }; + +/** + * Run a minimal introspection query to verify the API token + zone tag work. + * Returns { ok: true } or { ok: false, error: string }. + */ +export async function testCloudflareConnection(): Promise<{ + ok: boolean; + error?: string; + zoneTag?: string; + tokenPrefix?: string; +}> { + const config = getConfig(); + if (!config) { + return { + ok: false, + error: 'Missing env vars. Set CLOUDFLARE_ANALYTICS_API_TOKEN and CLOUDFLARE_ZONE_TAG.', + }; + } + const { apiToken, zoneTag } = config; + try { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + const dateStr = yesterday.toISOString().slice(0, 10); + const result = await cfGraphQL( + `query Test($zoneTag: string, $date: Date!) { + viewer { + zones(filter: { zoneTag: $zoneTag }) { + httpRequests1dGroups(limit: 1, filter: { date_gt: $date }) { + dimensions { date } + } + } + } + }`, + { zoneTag, date: dateStr }, + apiToken, + ); + const zones = result?.data?.viewer?.zones; + if (!zones || zones.length === 0) { + return { + ok: false, + error: `No zone found for zoneTag "${zoneTag}". Check CLOUDFLARE_ZONE_TAG.`, + zoneTag, + tokenPrefix: apiToken.slice(0, 6) + '…', + }; + } + return { ok: true, zoneTag, tokenPrefix: apiToken.slice(0, 6) + '…' }; + } catch (err: any) { + return { + ok: false, + error: err.message ?? String(err), + zoneTag, + tokenPrefix: apiToken.slice(0, 6) + '…', + }; + } +} + +async function cfGraphQL(query: string, variables: Record, apiToken: string) { + const res = await fetch(CF_GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ query, variables }), + }); + const body = await res.json(); + if (!res.ok) { + const detail = JSON.stringify(body?.errors ?? body); + throw new Error( + `Cloudflare GraphQL request failed: ${res.status} ${res.statusText} – ${detail}`, + ); + } + if (body.errors?.length) { + throw new Error(`Cloudflare GraphQL errors: ${JSON.stringify(body.errors)}`); + } + return body; +} + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +export type DailyAnalytics = { + date: string; + visits: number; + pageViews: number; +}; + +export type TopPath = { + path: string; + count: number; +}; + +export type CountryBreakdown = { + country: string; + count: number; +}; + +export type DeviceBreakdown = { + device: string; + count: number; +}; + +export type ReferrerBreakdown = { + referrer: string; + count: number; +}; + +export type CloudflareAnalyticsResult = { + daily: DailyAnalytics[]; + topPaths: TopPath[]; + countries: CountryBreakdown[]; + devices: DeviceBreakdown[]; + referrers: ReferrerBreakdown[]; + totals: { + visits: number; + pageViews: number; + }; + /** Pre-adjustment totals (before noise/bot filtering). */ + rawTotals: { + visits: number; + pageViews: number; + }; + /** True when CF returned an error (e.g. rate limit) and we fell back to cache. */ + stale?: boolean; +}; + +// --------------------------------------------------------------------------- +// Noise path filter — strip bot probes / infrastructure routes +// --------------------------------------------------------------------------- + +const NOISE_PATH_PREFIXES = [ + '/cdn-cgi/', + '/wp-', + '/.env', + '/.git', + '/xmlrpc.php', + '/wp-login', + '/wp-admin', + '/wp-content', + '/wp-includes', + '/api/', + '/dist/', + '/static/', + '/login', +]; +const NOISE_EXACT_PATHS = new Set([ + '/robots.txt', + '/favicon.ico', + '/sitemap.xml', + '/sitemap_index.xml', + '/.well-known/security.txt', +]); + +function isNoisePath(path: string): boolean { + if (NOISE_EXACT_PATHS.has(path)) return true; + if (path.endsWith('.xml')) return true; + return NOISE_PATH_PREFIXES.some((prefix) => path.startsWith(prefix)); +} + +// --------------------------------------------------------------------------- +// Postgres-backed daily cache +// --------------------------------------------------------------------------- +// +// Each row stores a full day's pre-aggregated analytics JSON for one hostname. +// Uses the AnalyticsCloudflareCache Sequelize model (shared Postgres → works across swarm). +// +// Past days: expiresAt = NULL → permanent cache. +// Today: expiresAt = now + 3h → cached, but refreshed periodically. +// +// Effect: +// • First load for a community: 1 CF API call, all days stored. +// • Repeat load within 3h: 0 CF calls, pure Postgres. +// • After 3h: 1 CF call for just today (past days still cached permanently). + +import { Op } from 'sequelize'; + +import { AnalyticsCloudflareCache } from 'server/analyticsCloudflareCache/model'; + +/** 1 hour in milliseconds. */ +const TODAY_CACHE_TTL_MS = 1 * 60 * 60 * 1000; + +/** + * Delete cache rows older than 45 days. + * We only display up to 30 days, so 45 gives a comfortable buffer. + * Throttled to run at most once per hour — the Date.now() check is ~free, + * so we skip the DB round-trip on 99.9% of calls. Triggered from the + * analytics fetch path (not a background job). + */ +const CACHE_MAX_AGE_DAYS = 45; +const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour +let lastCleanup = 0; + +function pruneOldCacheRows(): Promise { + const now = Date.now(); + if (now - lastCleanup < CLEANUP_INTERVAL_MS) return Promise.resolve(); + lastCleanup = now; + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - CACHE_MAX_AGE_DAYS); + return AnalyticsCloudflareCache.destroy({ + where: { date: { [Op.lt]: cutoff.toISOString().slice(0, 10) } }, + }) + .then(() => undefined) + .catch((err) => { + console.error('Analytics cache cleanup failed:', err); + }); +} + +/** What we store per cached day. */ +type DayCachePayload = { + visits: number; + pageViews: number; + topPaths: Array<{ path: string; count: number }>; + countries: Array<{ country: string; count: number }>; + devices: Array<{ device: string; count: number }>; + referrers: Array<{ referrer: string; count: number }>; +}; + +async function getCachedDays( + hostname: string, + dates: string[], + scope = 'community', +): Promise> { + if (dates.length === 0) return new Map(); + const rows = await AnalyticsCloudflareCache.findAll({ + where: { + hostname, + date: dates, + scope, + [Op.or]: [ + { expiresAt: null }, // permanent (past days) + { expiresAt: { [Op.gt]: new Date() } }, // not yet expired (today) + ], + }, + }); + const map = new Map(); + for (const row of rows) { + map.set(row.date, row.data as DayCachePayload); + } + return map; +} + +async function storeCachedDays( + hostname: string, + entries: Map, + today: string, + scope = 'community', +) { + if (entries.size === 0) return; + const promises = Array.from(entries.entries()).map(([date, data]) => { + const expiresAt = date === today ? new Date(Date.now() + TODAY_CACHE_TTL_MS) : null; + return AnalyticsCloudflareCache.upsert({ hostname, date, scope, data, expiresAt }); + }); + await Promise.all(promises); +} + +// --------------------------------------------------------------------------- +// Single combined GraphQL query (1 API call per ≤30-day chunk) +// --------------------------------------------------------------------------- +// +// Fetches daily counts + all breakdowns in one request per chunk. +// Breakdowns use per-day grouping so each cached day holds its own slice. + +const CF_MAX_DAYS = 30; + +const COMBINED_QUERY = ` + query CommunityAnalytics($zoneTag: string, $filter: ZoneHttpRequestsAdaptiveGroupsFilter_InputObject!) { + viewer { + zones(filter: { zoneTag: $zoneTag }) { + daily: httpRequestsAdaptiveGroups( + filter: $filter + limit: 10000 + orderBy: [date_ASC] + ) { + count + sum { visits } + dimensions { date } + } + topPaths: httpRequestsAdaptiveGroups( + filter: $filter + limit: 10000 + orderBy: [count_DESC] + ) { + count + dimensions { date clientRequestPath } + } + countries: httpRequestsAdaptiveGroups( + filter: $filter + limit: 200 + orderBy: [count_DESC] + ) { + count + dimensions { date clientCountryName } + } + devices: httpRequestsAdaptiveGroups( + filter: $filter + limit: 200 + orderBy: [count_DESC] + ) { + count + dimensions { date clientDeviceType } + } + referrers: httpRequestsAdaptiveGroups( + filter: $filter + limit: 200 + orderBy: [count_DESC] + ) { + count + dimensions { date clientRefererHost } + } + } + } + } +`; + +// --------------------------------------------------------------------------- +// Date helpers +// --------------------------------------------------------------------------- + +function dateRange(startDate: string, endDate: string): string[] { + const dates: string[] = []; + const cursor = new Date(startDate + 'T00:00:00Z'); + const end = new Date(endDate + 'T00:00:00Z'); + while (cursor <= end) { + dates.push(cursor.toISOString().slice(0, 10)); + cursor.setUTCDate(cursor.getUTCDate() + 1); + } + return dates; +} + +function splitDateRange( + startDate: string, + endDate: string, + maxDays: number, +): Array<{ start: string; end: string }> { + const chunks: Array<{ start: string; end: string }> = []; + let cursor = new Date(startDate + 'T00:00:00Z'); + const end = new Date(endDate + 'T00:00:00Z'); + while (cursor <= end) { + const chunkEnd = new Date(cursor); + chunkEnd.setUTCDate(chunkEnd.getUTCDate() + maxDays - 1); + if (chunkEnd > end) chunkEnd.setTime(end.getTime()); + chunks.push({ + start: cursor.toISOString().slice(0, 10), + end: chunkEnd.toISOString().slice(0, 10), + }); + cursor = new Date(chunkEnd); + cursor.setUTCDate(cursor.getUTCDate() + 1); + } + return chunks; +} + +/** + * Group sorted date strings into contiguous spans. + * e.g. ["2026-03-25", "2026-03-26", "2026-04-01"] → [["2026-03-25","2026-03-26"], ["2026-04-01"]] + * This prevents a single CF query from spanning cached dates in the middle. + */ +function groupContiguousDates(dates: string[]): string[][] { + if (dates.length === 0) return []; + const spans: string[][] = [[dates[0]]]; + for (let i = 1; i < dates.length; i++) { + const prev = new Date(dates[i - 1] + 'T00:00:00Z'); + const curr = new Date(dates[i] + 'T00:00:00Z'); + const diffMs = curr.getTime() - prev.getTime(); + if (diffMs <= 86_400_000) { + // consecutive day + spans[spans.length - 1].push(dates[i]); + } else { + spans.push([dates[i]]); + } + } + return spans; +} + +// --------------------------------------------------------------------------- +// Main fetch +// --------------------------------------------------------------------------- + +/** + * Fetch analytics for a hostname over a date range. + * + * Strategy: + * 1. Check Postgres cache for each day in the range. + * 2. Group uncached days into contiguous spans. + * 3. For each span, query CF (1 API call per ≤30-day chunk) — the combined + * query includes `date` in every dimension so breakdowns are per-day. + * 4. Store every fetched day (visits, pageViews, AND breakdowns) in cache. + * 5. Aggregate all days from cache into final result. + * + * Cost: 0 CF API calls when all days are cached (within TTL). + * 1 CF call per uncached ≤30-day chunk otherwise. + */ +export async function fetchCommunityAnalytics( + hostname: string, + startDate: string, + endDate: string, +): Promise { + const config = getConfig(); + if (!config) return null; + const { apiToken, zoneTag } = config; + + const allDates = dateRange(startDate, endDate); + const today = new Date().toISOString().slice(0, 10); + + // 1. Read cache + prune stale rows in parallel + const [cached] = await Promise.all([getCachedDays(hostname, allDates), pruneOldCacheRows()]); + + // 2. Fetch any uncached days from CF + const uncachedDates = allDates.filter((d) => !cached.has(d)); + let stale = false; + if (uncachedDates.length > 0) { + try { + const spans = groupContiguousDates(uncachedDates); + + async function fetchSpan(span: string[]) { + const chunks = splitDateRange(span[0], span[span.length - 1], CF_MAX_DAYS); + + const allNodes: { + daily: any[]; + topPaths: any[]; + countries: any[]; + devices: any[]; + referrers: any[]; + } = { daily: [], topPaths: [], countries: [], devices: [], referrers: [] }; + + let chunkPromise: Promise = Promise.resolve(); + for (const chunk of chunks) { + chunkPromise = chunkPromise.then(async () => { + // Cloudflare filter — designed to count real human page views only. + // + // requestSource: 'eyeball' + // CF built-in: excludes known bots, prefetch, and healthcheck traffic. + // + // edgeResponseStatus 200–399 + // Only successful responses. Excludes 4xx (bot probes hitting + // /wp-login, /.env, etc. that return 404/403) and 5xx errors. + // + // edgeResponseContentTypeName: 'html' + // Only HTML page loads. Excludes asset requests (JS/CSS/images), + // API calls (JSON), RSS feeds (XML), and other non-page traffic. + // + // clientRequestHTTPMethodName: 'GET' + // Excludes HEAD/OPTIONS/POST probes from scanners and bots. + // + // Combined with the server-side isNoisePath() filter (which removes + // /wp-*, /cdn-cgi/, /api/, /static/, /login, etc. from Top Pages + // and proportionally adjusts all totals), these filters ensure the + // dashboard reflects genuine human readership rather than raw + // Cloudflare edge hit counts. + const filter = { + date_geq: chunk.start, + date_leq: chunk.end, + clientRequestHTTPHost: hostname, + requestSource: 'eyeball', + edgeResponseStatus_geq: 200, + edgeResponseStatus_lt: 400, + edgeResponseContentTypeName: 'html', + clientRequestHTTPMethodName: 'GET', + }; + const result = await cfGraphQL( + COMBINED_QUERY, + { zoneTag, filter }, + apiToken, + ); + const zone = result?.data?.viewer?.zones?.[0] ?? {}; + allNodes.daily.push(...(zone.daily ?? [])); + allNodes.topPaths.push(...(zone.topPaths ?? [])); + allNodes.countries.push(...(zone.countries ?? [])); + allNodes.devices.push(...(zone.devices ?? [])); + allNodes.referrers.push(...(zone.referrers ?? [])); + }); + } + await chunkPromise; + + // Group breakdowns by date + type Arr = Array<{ key: string; count: number }>; + const byDate = new Map< + string, + { + topPaths: Arr; + countries: Arr; + devices: Arr; + referrers: Arr; + } + >(); + function ensure(d: string) { + if (!byDate.has(d)) { + byDate.set(d, { topPaths: [], countries: [], devices: [], referrers: [] }); + } + return byDate.get(d)!; + } + for (const n of allNodes.topPaths) { + ensure(n.dimensions.date).topPaths.push({ + key: n.dimensions.clientRequestPath, + count: n.count, + }); + } + for (const n of allNodes.countries) { + ensure(n.dimensions.date).countries.push({ + key: n.dimensions.clientCountryName || 'Unknown', + count: n.count, + }); + } + for (const n of allNodes.devices) { + ensure(n.dimensions.date).devices.push({ + key: n.dimensions.clientDeviceType || 'Unknown', + count: n.count, + }); + } + for (const n of allNodes.referrers) { + ensure(n.dimensions.date).referrers.push({ + key: n.dimensions.clientRefererHost || '(direct)', + count: n.count, + }); + } + + // Build per-day payloads + const toStore = new Map(); + for (const node of allNodes.daily) { + const d = node.dimensions.date; + const bd = byDate.get(d); + const payload: DayCachePayload = { + visits: node.sum.visits ?? 0, + pageViews: node.count ?? 0, + topPaths: (bd?.topPaths ?? []).map((p) => ({ + path: p.key, + count: p.count, + })), + countries: (bd?.countries ?? []).map((c) => ({ + country: c.key, + count: c.count, + })), + devices: (bd?.devices ?? []).map((dv) => ({ + device: dv.key, + count: dv.count, + })), + referrers: (bd?.referrers ?? []) + .map((r) => ({ referrer: r.key, count: r.count })) + .filter((r) => r.referrer !== hostname), + }; + cached.set(d, payload); + toStore.set(d, payload); + } + + // Backfill any requested dates CF returned no data for + for (const d of span) { + if (!cached.has(d)) { + const empty: DayCachePayload = { + visits: 0, + pageViews: 0, + topPaths: [], + countries: [], + devices: [], + referrers: [], + }; + cached.set(d, empty); + toStore.set(d, empty); + } + } + + await storeCachedDays(hostname, toStore, today).catch((err) => { + console.error('Failed to store analytics cache:', err); + }); + } + + let spanChain: Promise = Promise.resolve(); + for (const span of spans) { + spanChain = spanChain.then(() => fetchSpan(span)); + } + await spanChain; + } catch (err) { + // CF error (rate limit, network, etc.) — fall back to whatever is cached. + console.error('Cloudflare analytics fetch failed, using cached data:', err); + stale = cached.size > 0; + // If we have zero cached data, re-throw so the API returns an error. + if (cached.size === 0) throw err; + } + } + + // 3. Aggregate all cached days into final result + return aggregateDays(allDates, cached, stale); +} + +// --------------------------------------------------------------------------- +// Shared aggregation: turn cached days into a CloudflareAnalyticsResult +// --------------------------------------------------------------------------- + +function aggregateDays( + allDates: string[], + cached: Map, + stale: boolean, +): CloudflareAnalyticsResult { + const daily: DailyAnalytics[] = []; + const pathMap = new Map(); + const countryMap = new Map(); + const deviceMap = new Map(); + const refMap = new Map(); + let totalVisits = 0; + let totalPageViews = 0; + + for (const date of allDates) { + const day = cached.get(date); + if (!day) continue; + daily.push({ date, visits: day.visits, pageViews: day.pageViews }); + totalVisits += day.visits; + totalPageViews += day.pageViews; + for (const p of day.topPaths) pathMap.set(p.path, (pathMap.get(p.path) ?? 0) + p.count); + for (const c of day.countries) + countryMap.set(c.country, (countryMap.get(c.country) ?? 0) + c.count); + for (const d of day.devices) + deviceMap.set(d.device, (deviceMap.get(d.device) ?? 0) + d.count); + for (const r of day.referrers) + refMap.set(r.referrer, (refMap.get(r.referrer) ?? 0) + r.count); + } + + const topPaths = Array.from(pathMap.entries()) + .map(([path, count]) => ({ path, count })) + .filter((p) => !isNoisePath(p.path)) + .sort((a, b) => b.count - a.count) + .slice(0, 50); + + let noisePageViews = 0; + for (const [path, count] of pathMap) { + if (isNoisePath(path)) noisePageViews += count; + } + const adjustedPageViews = Math.max(0, totalPageViews - noisePageViews); + const ratio = totalPageViews > 0 ? adjustedPageViews / totalPageViews : 1; + const adjustedVisits = Math.round(totalVisits * ratio); + + const adjustedDaily = daily.map((d) => ({ + date: d.date, + visits: Math.round(d.visits * ratio), + pageViews: Math.round(d.pageViews * ratio), + })); + + const countries = Array.from(countryMap.entries()) + .map(([country, count]) => ({ country, count: Math.round(count * ratio) })) + .filter((c) => c.count > 0) + .sort((a, b) => b.count - a.count) + .slice(0, 20); + + const devices = Array.from(deviceMap.entries()) + .map(([device, count]) => ({ device, count: Math.round(count * ratio) })) + .filter((d) => d.count > 0) + .sort((a, b) => b.count - a.count); + + const referrers = Array.from(refMap.entries()) + .map(([referrer, count]) => ({ referrer, count: Math.round(count * ratio) })) + .filter((r) => r.count > 0) + .sort((a, b) => b.count - a.count) + .slice(0, 15); + + return { + daily: adjustedDaily, + topPaths, + countries, + devices, + referrers, + totals: { visits: adjustedVisits, pageViews: adjustedPageViews }, + rawTotals: { visits: totalVisits, pageViews: totalPageViews }, + ...(stale ? { stale: true } : {}), + }; +} + +// --------------------------------------------------------------------------- +// Pub-scope fetch (single CF query with path prefix filter) +// --------------------------------------------------------------------------- + +/** + * Fetch analytics scoped to a single pub. + * + * Uses the same combined query but adds clientRequestPath_like to filter to + * /pub/{slug}%. Cached separately under scope='pub:{slug}'. + * + * Cost: 0 CF calls when cached. 1 call per ≤30-day chunk when not. + */ +export async function fetchPubAnalytics( + hostname: string, + pubSlug: string, + startDate: string, + endDate: string, +): Promise { + const config = getConfig(); + if (!config) return null; + const { apiToken, zoneTag } = config; + + const scope = `pub:${pubSlug}`; + const pathPrefix = `/pub/${pubSlug}`; + const allDates = dateRange(startDate, endDate); + const today = new Date().toISOString().slice(0, 10); + + const [cached] = await Promise.all([ + getCachedDays(hostname, allDates, scope), + pruneOldCacheRows(), + ]); + + const uncachedDates = allDates.filter((d) => !cached.has(d)); + let stale = false; + + if (uncachedDates.length > 0) { + try { + const spans = groupContiguousDates(uncachedDates); + + async function fetchSpan(span: string[]) { + const chunks = splitDateRange(span[0], span[span.length - 1], CF_MAX_DAYS); + const allNodes: { + daily: any[]; + topPaths: any[]; + countries: any[]; + devices: any[]; + referrers: any[]; + } = { daily: [], topPaths: [], countries: [], devices: [], referrers: [] }; + + let chunkPromise: Promise = Promise.resolve(); + for (const chunk of chunks) { + chunkPromise = chunkPromise.then(async () => { + const filter = { + date_geq: chunk.start, + date_leq: chunk.end, + clientRequestHTTPHost: hostname, + clientRequestPath_like: `${pathPrefix}%`, + requestSource: 'eyeball', + edgeResponseStatus_geq: 200, + edgeResponseStatus_lt: 400, + edgeResponseContentTypeName: 'html', + clientRequestHTTPMethodName: 'GET', + }; + const result = await cfGraphQL( + COMBINED_QUERY, + { zoneTag, filter }, + apiToken, + ); + const zone = result?.data?.viewer?.zones?.[0] ?? {}; + allNodes.daily.push(...(zone.daily ?? [])); + allNodes.topPaths.push(...(zone.topPaths ?? [])); + allNodes.countries.push(...(zone.countries ?? [])); + allNodes.devices.push(...(zone.devices ?? [])); + allNodes.referrers.push(...(zone.referrers ?? [])); + }); + } + await chunkPromise; + + // Group breakdowns by date + type Arr = Array<{ key: string; count: number }>; + const byDate = new Map< + string, + { topPaths: Arr; countries: Arr; devices: Arr; referrers: Arr } + >(); + function ensure(d: string) { + if (!byDate.has(d)) + byDate.set(d, { topPaths: [], countries: [], devices: [], referrers: [] }); + return byDate.get(d)!; + } + for (const n of allNodes.topPaths) { + ensure(n.dimensions.date).topPaths.push({ + key: n.dimensions.clientRequestPath, + count: n.count, + }); + } + for (const n of allNodes.countries) { + ensure(n.dimensions.date).countries.push({ + key: n.dimensions.clientCountryName || 'Unknown', + count: n.count, + }); + } + for (const n of allNodes.devices) { + ensure(n.dimensions.date).devices.push({ + key: n.dimensions.clientDeviceType || 'Unknown', + count: n.count, + }); + } + for (const n of allNodes.referrers) { + ensure(n.dimensions.date).referrers.push({ + key: n.dimensions.clientRefererHost || '(direct)', + count: n.count, + }); + } + + const toStore = new Map(); + for (const node of allNodes.daily) { + const d = node.dimensions.date; + const bd = byDate.get(d); + const payload: DayCachePayload = { + visits: node.sum.visits ?? 0, + pageViews: node.count ?? 0, + topPaths: (bd?.topPaths ?? []).map((p) => ({ + path: p.key, + count: p.count, + })), + countries: (bd?.countries ?? []).map((c) => ({ + country: c.key, + count: c.count, + })), + devices: (bd?.devices ?? []).map((dv) => ({ + device: dv.key, + count: dv.count, + })), + referrers: (bd?.referrers ?? []) + .map((r) => ({ referrer: r.key, count: r.count })) + .filter((r) => r.referrer !== hostname), + }; + cached.set(d, payload); + toStore.set(d, payload); + } + + for (const d of span) { + if (!cached.has(d)) { + const empty: DayCachePayload = { + visits: 0, + pageViews: 0, + topPaths: [], + countries: [], + devices: [], + referrers: [], + }; + cached.set(d, empty); + toStore.set(d, empty); + } + } + + await storeCachedDays(hostname, toStore, today, scope).catch((err) => { + console.error('Failed to store pub analytics cache:', err); + }); + } + + let spanChain: Promise = Promise.resolve(); + for (const span of spans) { + spanChain = spanChain.then(() => fetchSpan(span)); + } + await spanChain; + } catch (err) { + console.error('Cloudflare pub analytics fetch failed, using cached data:', err); + stale = cached.size > 0; + if (cached.size === 0) throw err; + } + } + + return aggregateDays(allDates, cached, stale); +} + +// --------------------------------------------------------------------------- +// Generic path-prefix scope cache (used by collection aggregation) +// --------------------------------------------------------------------------- + +/** + * Ensure we have a cached scope for a given path prefix and date range. + * + * Queries CF with `clientRequestPath_like` set to the given path prefix, + * storing results under the given scope key. + * + * Used for: + * - 'all-pub-paths' (pathLike = '/pub/%') — top 1000 pub-specific paths + * - 'collection-page:{slug}' (pathLike = '/{slug}%') — collection page data + * + * Cost: 1 CF query per ≤30-day chunk when not cached, 0 when cached. + */ +async function ensurePathScopeCached( + hostname: string, + allDates: string[], + apiToken: string, + zoneTag: string, + pathLike: string, + scope: string, +): Promise<{ cache: Map; stale: boolean }> { + const today = new Date().toISOString().slice(0, 10); + const cached = await getCachedDays(hostname, allDates, scope); + const uncachedDates = allDates.filter((d) => !cached.has(d)); + let stale = false; + + if (uncachedDates.length > 0) { + try { + const spans = groupContiguousDates(uncachedDates); + + async function fetchSpan(span: string[]) { + const chunks = splitDateRange(span[0], span[span.length - 1], CF_MAX_DAYS); + const allNodes: { + daily: any[]; + topPaths: any[]; + countries: any[]; + devices: any[]; + referrers: any[]; + } = { daily: [], topPaths: [], countries: [], devices: [], referrers: [] }; + + let chunkPromise: Promise = Promise.resolve(); + for (const chunk of chunks) { + chunkPromise = chunkPromise.then(async () => { + const filter = { + date_geq: chunk.start, + date_leq: chunk.end, + clientRequestHTTPHost: hostname, + clientRequestPath_like: pathLike, + requestSource: 'eyeball', + edgeResponseStatus_geq: 200, + edgeResponseStatus_lt: 400, + edgeResponseContentTypeName: 'html', + clientRequestHTTPMethodName: 'GET', + }; + const result = await cfGraphQL( + COMBINED_QUERY, + { zoneTag, filter }, + apiToken, + ); + const zone = result?.data?.viewer?.zones?.[0] ?? {}; + allNodes.daily.push(...(zone.daily ?? [])); + allNodes.topPaths.push(...(zone.topPaths ?? [])); + allNodes.countries.push(...(zone.countries ?? [])); + allNodes.devices.push(...(zone.devices ?? [])); + allNodes.referrers.push(...(zone.referrers ?? [])); + }); + } + await chunkPromise; + + // Group breakdowns by date + type Arr = Array<{ key: string; count: number }>; + const byDate = new Map< + string, + { topPaths: Arr; countries: Arr; devices: Arr; referrers: Arr } + >(); + function ensure(d: string) { + if (!byDate.has(d)) + byDate.set(d, { topPaths: [], countries: [], devices: [], referrers: [] }); + return byDate.get(d)!; + } + for (const n of allNodes.topPaths) { + ensure(n.dimensions.date).topPaths.push({ + key: n.dimensions.clientRequestPath, + count: n.count, + }); + } + for (const n of allNodes.countries) { + ensure(n.dimensions.date).countries.push({ + key: n.dimensions.clientCountryName || 'Unknown', + count: n.count, + }); + } + for (const n of allNodes.devices) { + ensure(n.dimensions.date).devices.push({ + key: n.dimensions.clientDeviceType || 'Unknown', + count: n.count, + }); + } + for (const n of allNodes.referrers) { + ensure(n.dimensions.date).referrers.push({ + key: n.dimensions.clientRefererHost || '(direct)', + count: n.count, + }); + } + + const toStore = new Map(); + for (const node of allNodes.daily) { + const d = node.dimensions.date; + const bd = byDate.get(d); + const payload: DayCachePayload = { + visits: node.sum.visits ?? 0, + pageViews: node.count ?? 0, + topPaths: (bd?.topPaths ?? []).map((p) => ({ + path: p.key, + count: p.count, + })), + countries: (bd?.countries ?? []).map((c) => ({ + country: c.key, + count: c.count, + })), + devices: (bd?.devices ?? []).map((dv) => ({ + device: dv.key, + count: dv.count, + })), + referrers: (bd?.referrers ?? []) + .map((r) => ({ referrer: r.key, count: r.count })) + .filter((r) => r.referrer !== hostname), + }; + cached.set(d, payload); + toStore.set(d, payload); + } + + for (const d of span) { + if (!cached.has(d)) { + const empty: DayCachePayload = { + visits: 0, + pageViews: 0, + topPaths: [], + countries: [], + devices: [], + referrers: [], + }; + cached.set(d, empty); + toStore.set(d, empty); + } + } + + await storeCachedDays(hostname, toStore, today, scope).catch((err) => { + console.error(`Failed to store ${scope} cache:`, err); + }); + } + + let spanChain: Promise = Promise.resolve(); + for (const span of spans) { + spanChain = spanChain.then(() => fetchSpan(span)); + } + await spanChain; + } catch (err) { + console.error(`Cloudflare ${scope} fetch failed, falling back:`, err); + stale = cached.size > 0; + } + } + + return { cache: cached, stale }; +} + +// --------------------------------------------------------------------------- +// Collection-scope fetch (dedicated queries + pub cache enrichment) +// --------------------------------------------------------------------------- + +/** + * Fetch analytics scoped to a collection. + * + * Strategy: + * 1. Ensure community-level data is cached (for fallback breakdowns). + * 2. Dedicated CF query for the collection page itself (/{slug}%), + * cached as 'collection-page:{slug}'. Guarantees the collection + * always has *some* data even if it's outside any top-paths list. + * 3. Dedicated CF query for all pub paths (/pub/%), cached as + * 'all-pub-paths'. Top 1000 pub-specific paths — much better + * coverage than filtering the community top 1000. + * 4. For each pub in the collection, prefer individual pub-level cache + * (most accurate), then fall back to all-pub-paths, then community. + * 5. Countries/devices/referrers: proportional from all-pub-paths + * breakdowns, falling back to community breakdowns. + * + * Cost: 0 when fully cached. At most 3 CF queries when cold + * (community + all-pub-paths + collection-page), but typically + * community is already warm, so 2 in practice. + */ +export async function fetchCollectionAnalytics( + hostname: string, + collectionSlug: string, + pubSlugs: string[], + startDate: string, + endDate: string, +): Promise { + const config = getConfig(); + if (!config) return null; + const { apiToken, zoneTag } = config; + + const allDates = dateRange(startDate, endDate); + + // 1. Ensure community data is cached (for breakdowns + fallback) + const communityResult = await fetchCommunityAnalytics(hostname, startDate, endDate); + if (!communityResult) return null; + + // 2 & 3. Fetch collection-page and all-pub-paths scopes in parallel + const [collectionPageResult, allPubPathsResult] = await Promise.all([ + ensurePathScopeCached( + hostname, + allDates, + apiToken, + zoneTag, + `/${collectionSlug}%`, + `collection-page:${collectionSlug}`, + ), + ensurePathScopeCached(hostname, allDates, apiToken, zoneTag, '/pub/%', 'all-pub-paths'), + ]); + const collectionPageCached = collectionPageResult.cache; + const allPubPathsCached = allPubPathsResult.cache; + + // 4. Read community-level cached days (raw, pre-aggregation) + const communityCached = await getCachedDays(hostname, allDates, 'community'); + + // 5. For each pub, check if we have pub-scoped cache (most accurate) + const pubCacheEntries = await Promise.all( + pubSlugs.map(async (slug) => { + const pubCache = await getCachedDays(hostname, allDates, `pub:${slug}`); + return [slug, pubCache] as const; + }), + ); + const pubCaches = new Map>(); + for (const [slug, cache] of pubCacheEntries) { + if (cache.size > 0) { + pubCaches.set(slug, cache); + } + } + + // 6. Build collection-scoped day payloads by merging sources + const collectionDays = new Map(); + + for (const date of allDates) { + const communityDay = communityCached.get(date); + if (!communityDay) continue; + + const allPubPathsDay = allPubPathsCached.get(date); + const collectionPageDay = collectionPageCached.get(date); + + let dayVisits = 0; + let dayPageViews = 0; + const dayPaths: Array<{ path: string; count: number }> = []; + const slugsHandledByPubCache = new Set(); + + // (a) Add data from any individual pub-level caches (most accurate) + for (const [slug, cache] of pubCaches) { + const pubDay = cache.get(date); + if (pubDay) { + slugsHandledByPubCache.add(slug); + dayVisits += pubDay.visits; + dayPageViews += pubDay.pageViews; + for (const p of pubDay.topPaths) dayPaths.push(p); + } + } + + // (b) For pubs without individual cache, use all-pub-paths topPaths + // (top 1000 /pub/* paths — much better coverage than community top 1000). + // Falls back to community topPaths if all-pub-paths cache is unavailable. + const pubPathSource = allPubPathsDay?.topPaths ?? communityDay.topPaths; + for (const p of pubPathSource) { + // Skip paths already covered by individual pub-level cache + const coveredByPubCache = pubSlugs.some( + (slug) => + slugsHandledByPubCache.has(slug) && + (p.path === `/pub/${slug}` || p.path.startsWith(`/pub/${slug}/`)), + ); + if (coveredByPubCache) continue; + + // Check if this path belongs to a collection pub + const isPubInCollection = pubSlugs.some( + (slug) => p.path === `/pub/${slug}` || p.path.startsWith(`/pub/${slug}/`), + ); + if (isPubInCollection) { + dayPageViews += p.count; + dayPaths.push(p); + } + } + + // (c) Collection layout page — from dedicated collection-page cache + // (guaranteed data even if the page isn't in any top-paths list). + // Falls back to community topPaths if the dedicated cache failed. + if (collectionPageDay && collectionPageDay.pageViews > 0) { + dayVisits += collectionPageDay.visits; + dayPageViews += collectionPageDay.pageViews; + for (const p of collectionPageDay.topPaths) dayPaths.push(p); + } else { + // Fallback: filter community topPaths for collection page + for (const p of communityDay.topPaths) { + if (p.path === `/${collectionSlug}` || p.path.startsWith(`/${collectionSlug}/`)) { + dayPageViews += p.count; + dayPaths.push(p); + } + } + } + + // Estimate visits proportionally from community day + // (for paths from community/all-pub-paths data, not from individual pub cache + // or the collection-page dedicated cache which has its own visits) + if (communityDay.pageViews > 0 && dayPageViews > 0) { + const directVisitSources = + [...pubCaches.values()].reduce( + (sum, cache) => sum + (cache.get(date)?.pageViews ?? 0), + 0, + ) + + (collectionPageDay && collectionPageDay.pageViews > 0 + ? collectionPageDay.pageViews + : 0); + const indirectPageViews = dayPageViews - directVisitSources; + if (indirectPageViews > 0) { + const visitRatio = communityDay.visits / communityDay.pageViews; + dayVisits += Math.round(indirectPageViews * visitRatio); + } + } + + // Countries/devices/referrers: use all-pub-paths breakdowns if available + // (more accurate for pub-heavy collections), else community-level. + // Scaled by this collection's share of the source's total traffic. + const breakdownSource = allPubPathsDay ?? communityDay; + const sourcePageViews = allPubPathsDay ? allPubPathsDay.pageViews : communityDay.pageViews; + const shareRatio = sourcePageViews > 0 ? dayPageViews / sourcePageViews : 0; + + collectionDays.set(date, { + visits: dayVisits, + pageViews: dayPageViews, + topPaths: dayPaths, + countries: breakdownSource.countries + .map((c) => ({ + country: c.country, + count: Math.round(c.count * shareRatio), + })) + .filter((c) => c.count > 0), + devices: breakdownSource.devices + .map((d) => ({ + device: d.device, + count: Math.round(d.count * shareRatio), + })) + .filter((d) => d.count > 0), + referrers: breakdownSource.referrers + .map((r) => ({ + referrer: r.referrer, + count: Math.round(r.count * shareRatio), + })) + .filter((r) => r.count > 0), + }); + } + + const anyStale = + !!communityResult.stale || allPubPathsResult.stale || collectionPageResult.stale; + return aggregateDays(allDates, collectionDays, anyStale); +} + +// --------------------------------------------------------------------------- +// Debug helper +// --------------------------------------------------------------------------- + +/** + * Raw debug query — returns the exact filter + raw Cloudflare response. + */ +export async function debugCommunityAnalytics( + hostname: string, + startDate: string, + endDate: string, +) { + const config = getConfig(); + if (!config) { + return { error: 'Missing env vars' }; + } + const { apiToken, zoneTag } = config; + + const filter = { + date_geq: startDate, + date_leq: endDate, + clientRequestHTTPHost: hostname, + requestSource: 'eyeball', + edgeResponseStatus_geq: 200, + edgeResponseStatus_lt: 400, + edgeResponseContentTypeName: 'html', + clientRequestHTTPMethodName: 'GET', + }; + + const zoneCheckQuery = ` + query ZoneCheck($zoneTag: string) { + viewer { + zones(filter: { zoneTag: $zoneTag }) { + totals: httpRequestsAdaptiveGroups( + filter: { date_geq: "${startDate}", date_leq: "${endDate}", requestSource: "eyeball" } + limit: 5 + orderBy: [count_DESC] + ) { + count + dimensions { date } + } + } + } + } + `; + + const hostnameQuery = ` + query HostnameCheck($zoneTag: string, $filter: ZoneHttpRequestsAdaptiveGroupsFilter_InputObject!) { + viewer { + zones(filter: { zoneTag: $zoneTag }) { + daily: httpRequestsAdaptiveGroups( + filter: $filter + limit: 5 + orderBy: [count_DESC] + ) { + count + sum { visits } + dimensions { date clientRequestHTTPHost } + } + } + } + } + `; + + const hostnamesQuery = ` + query Hostnames($zoneTag: string) { + viewer { + zones(filter: { zoneTag: $zoneTag }) { + byHost: httpRequestsAdaptiveGroups( + filter: { date_geq: "${startDate}", date_leq: "${endDate}", requestSource: "eyeball" } + limit: 25 + orderBy: [count_DESC] + ) { + count + dimensions { clientRequestHTTPHost } + } + } + } + } + `; + + let zoneCheck: any; + let hostnameCheck: any; + let hostnamesCheck: any; + + try { + zoneCheck = await cfGraphQL(zoneCheckQuery, { zoneTag }, apiToken); + } catch (err: any) { + zoneCheck = { error: err.message }; + } + try { + hostnameCheck = await cfGraphQL(hostnameQuery, { zoneTag, filter }, apiToken); + } catch (err: any) { + hostnameCheck = { error: err.message }; + } + try { + hostnamesCheck = await cfGraphQL(hostnamesQuery, { zoneTag }, apiToken); + } catch (err: any) { + hostnamesCheck = { error: err.message }; + } + + return { + input: { + hostname, + startDate, + endDate, + zoneTag, + tokenPrefix: apiToken.slice(0, 6) + '…', + filter, + }, + zoneWideData: zoneCheck, + filteredByHostname: hostnameCheck, + topHostnames: hostnamesCheck, + }; +} diff --git a/tools/cron.ts b/tools/cron.ts index 17965eb3a0..6c2ee332cf 100644 --- a/tools/cron.ts +++ b/tools/cron.ts @@ -54,6 +54,12 @@ if (process.env.PUBPUB_PRODUCTION === 'true') { }, ); // Weekly on Sunday at 5 AM UTC + cron.schedule( + '30 3 * * *', + () => run('Refresh Analytics Matviews', 'tools-prod refreshAnalyticsSummary refresh'), + { timezone: 'UTC' }, + ); // Daily at 3:30 AM UTC (before spam scan) + cron.schedule( '0 4 * * *', () => { diff --git a/tools/index.js b/tools/index.js index fb19d820f0..8cba71893e 100644 --- a/tools/index.js +++ b/tools/index.js @@ -73,6 +73,8 @@ const commandFiles = { flattenBranchHistory: "./flattenBranchHistory", layoutcheck: "./layoutcheck/check", migrate: "./migrate", + migrateRedshift: "./migrateRedshift", + refreshAnalyticsSummary: "./refreshAnalyticsSummary", migrateDash: "./dashboardMigrations/runMigrations", migrateFirebasePaths: "./migrateFirebasePaths", migration2020_05_06: "./migration2020_05_06", diff --git a/tools/migrateRedshift.ts b/tools/migrateRedshift.ts new file mode 100644 index 0000000000..e64cf848e9 --- /dev/null +++ b/tools/migrateRedshift.ts @@ -0,0 +1,448 @@ +/** + * Migrates analytics data from a Redshift CSV backup (stored in S3) into the + * local Postgres AnalyticsEvents table. + * + * Usage: + * pnpm run tools migrateRedshift + * + * Required env vars (set in .env.dev or export manually): + * AM_REDSHIFT_BACKUP_ACCESS_KEY – AWS key for the S3 bucket holding the backup + * AM_REDSHIFT_BACKUP_SECRET_KEY – AWS secret + * AM_REDSHIFT_PATH – s3://bucket/prefix/ path to the CSV files + * DATABASE_URL – Postgres connection string (auto-set by tools runner) + * + * Optional: + * FORCE_DOWNLOAD=1 – re-download even if local copies exist + */ + +import type { Readable } from 'stream'; + +import { GetObjectCommand, ListObjectsV2Command, S3Client } from '@aws-sdk/client-s3'; +import { execSync } from 'child_process'; +import { createWriteStream, mkdirSync } from 'fs'; +import { readdir } from 'fs/promises'; +import path from 'path'; +import { pipeline } from 'stream/promises'; + +import { sequelize } from 'server/sequelize'; + +// ─── helpers ───────────────────────────────────────────────────────────────── + +/* eslint-disable no-await-in-loop */ + +const log = (msg: string) => console.info(`[migrateRedshift] ${msg}`); + +/** Run async fn sequentially over items (avoids biome noAwaitInLoops). */ +async function sequential(items: T[], fn: (item: T) => Promise): Promise { + if (items.length === 0) return; + const [head, ...tail] = items; + return fn(head).then(() => sequential(tail, fn)); +} + +function required(name: string): string { + const val = process.env[name]; + if (!val) throw new Error(`Missing required env var: ${name}`); + return val; +} + +function parseS3Path(s3Path: string): { bucket: string; prefix: string } { + const match = s3Path.match(/^s3:\/\/([^/]+)\/?(.*)$/); + if (!match) throw new Error(`Invalid S3 path: ${s3Path}`); + return { bucket: match[1], prefix: match[2].replace(/\/$/, '') }; +} + +// ─── S3 download ───────────────────────────────────────────────────────────── + +async function downloadFromS3(dataDir: string, force: boolean) { + const accessKeyId = required('AM_REDSHIFT_BACKUP_ACCESS_KEY'); + const secretAccessKey = required('AM_REDSHIFT_BACKUP_SECRET_KEY'); + const s3Path = required('AM_REDSHIFT_PATH'); + const { bucket, prefix } = parseS3Path(s3Path); + + const s3 = new S3Client({ + region: 'us-east-1', + credentials: { accessKeyId, secretAccessKey }, + }); + + mkdirSync(dataDir, { recursive: true }); + + // Check if we already have files + if (!force) { + const existing = (await readdir(dataDir)).filter((f) => f.startsWith('data')); + if (existing.length > 0) { + log( + `[1/5] backup already present (${existing.length} files), skipping (set FORCE_DOWNLOAD=1 to re-download)`, + ); + return; + } + } + + log('[1/5] downloading redshift backup from S3...'); + + const listRes = await s3.send( + new ListObjectsV2Command({ Bucket: bucket, Prefix: prefix ? `${prefix}/` : undefined }), + ); + const objects = listRes.Contents ?? []; + if (objects.length === 0) { + throw new Error(`No objects found at s3://${bucket}/${prefix}/`); + } + + const downloadable = objects.filter((obj) => !!obj.Key); + await sequential(downloadable, async (obj) => { + const filename = path.basename(obj.Key!); + const localPath = path.join(dataDir, filename); + + log(` downloading ${filename}...`); + const getRes = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: obj.Key })); + if (!getRes.Body) return; + + await pipeline(getRes.Body as Readable, createWriteStream(localPath)); + }); + log(` downloaded ${objects.length} files`); +} + +// ─── SQL statements ────────────────────────────────────────────────────────── + +const ENSURE_TABLE_SQL = ` +CREATE TABLE IF NOT EXISTS "AnalyticsEvents" ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + type text NOT NULL, + event text NOT NULL, + "createdAt" timestamptz NOT NULL DEFAULT now(), + referrer text, + "isUnique" boolean, + search text, + "utmSource" text, + "utmMedium" text, + "utmCampaign" text, + "utmTerm" text, + "utmContent" text, + timezone text NOT NULL, + locale text NOT NULL, + "userAgent" text NOT NULL, + os text NOT NULL, + "communityId" uuid, + url text, + hash text, + height integer, + width integer, + path text, + "pageId" uuid, + "collectionId" uuid, + "pubId" uuid, + release text, + format text +); + +CREATE INDEX IF NOT EXISTS "analytics_events_community_event_created" + ON "AnalyticsEvents" ("communityId", event, "createdAt"); +CREATE INDEX IF NOT EXISTS "analytics_events_pub_event_created" + ON "AnalyticsEvents" ("pubId", event, "createdAt"); +CREATE INDEX IF NOT EXISTS "analytics_events_collection_event_created" + ON "AnalyticsEvents" ("collectionId", event, "createdAt"); + +-- Optimized index for dashboard queries: all filter by communityId + time range +CREATE INDEX IF NOT EXISTS "analytics_events_community_created" + ON "AnalyticsEvents" ("communityId", "createdAt"); + +-- Partial covering index for the common page-view aggregations +CREATE INDEX IF NOT EXISTS "analytics_events_community_pages" + ON "AnalyticsEvents" ("communityId", "createdAt", "isUnique") + WHERE event IN ('page','pub','collection','other'); + +-- Partial index for pub-scoped views + downloads +CREATE INDEX IF NOT EXISTS "analytics_events_pub_views_dl" + ON "AnalyticsEvents" ("communityId", "pubId", "createdAt") + WHERE "pubId" IS NOT NULL AND event IN ('pub','download'); +`; + +const CREATE_STAGING_SQL = ` +DROP TABLE IF EXISTS analytics_staging; +CREATE UNLOGGED TABLE analytics_staging ( + __sdc_primary_key text, + _sdc_batched_at text, + _sdc_received_at text, + _sdc_sequence text, + _sdc_table_version text, + collectionid text, + collectionkind text, + communityid text, + country text, + countrycode text, + event text, + height text, + isprod text, + primarycollectionid text, + pubid text, + type text, + "unique" text, + width text, + "timestamp" text, + utmcontent text, + utmmedium text, + utmterm text, + release__string text, + path text, + collectiontitle text, + collectionslug text, + pubslug text, + communityname text, + collectionids text, + release__bigint text, + pagetitle text, + referrer text, + utmcampaign text, + utmsource text, + timezone text, + os text, + pageid text, + locale text, + pageslug text, + communitysubdomain text, + format text, + useragent text, + pubtitle text, + url text, + search text, + title text, + hash text +); +`; + +/** + * Build the TRANSFORM SQL with an optional cutoff timestamp. + * If cutoffTs is provided, Redshift rows with a timestamp >= cutoffTs are skipped + * to avoid importing duplicates of events already written directly to PG. + */ +function buildTransformSql(cutoffTs: string | null): string { + const cutoffClause = cutoffTs + ? `\n AND pg_temp.safe_ts(s."timestamp") < '${cutoffTs}'::timestamptz` + : ''; + + return ` +CREATE OR REPLACE FUNCTION pg_temp.safe_uuid(val text) RETURNS uuid AS $$ +SELECT CASE + WHEN val ~ '^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$' + THEN val::uuid + ELSE NULL +END; +$$ LANGUAGE sql IMMUTABLE; + +CREATE OR REPLACE FUNCTION pg_temp.safe_ts(val text) RETURNS timestamptz AS $$ +SELECT CASE + WHEN val IS NULL OR val = '' THEN NULL + WHEN val::double precision BETWEEN 1e12 AND 2e13 + THEN to_timestamp(val::double precision / 1000.0) + ELSE NULL +END; +$$ LANGUAGE sql IMMUTABLE; + +-- Helper: returns true when ANY of the given text fields look like injection/spam +CREATE OR REPLACE FUNCTION pg_temp.is_spam(VARIADIC vals text[]) RETURNS boolean AS $$ +SELECT EXISTS ( + SELECT 1 FROM unnest(vals) v + WHERE v ~* '(= '2016-01-01'::timestamptz + AND pg_temp.safe_ts(s."timestamp") <= (now() + interval '1 day')${cutoffClause} +ON CONFLICT (id) DO NOTHING; +`; +} + +// ─── main ──────────────────────────────────────────────────────────────────── + +/** Post-import cleanup: delete spam rows that may have slipped in from earlier runs. */ +const CLEANUP_SPAM_SQL = ` +DELETE FROM "AnalyticsEvents" +WHERE referrer ~* '( (now() + interval '1 day'); +`; + +async function main() { + // Default data dir is inside the repo so it persists across container runs + // (the app service bind-mounts the repo at /app). + const dataDir = process.env.DATA_DIR ?? path.join(__dirname, '../tmp/redshift-data'); + const force = process.env.FORCE_DOWNLOAD === '1'; + + log('redshift migration'); + log(` data dir: ${dataDir}`); + + // Step 1: download + await downloadFromS3(dataDir, force); + + // Step 2: ensure table + log('[2/5] ensuring AnalyticsEvents table...'); + await sequelize.query(ENSURE_TABLE_SQL); + + const [countResult] = await sequelize.query<{ count: string }>( + 'SELECT count(*) as count FROM "AnalyticsEvents"', + { type: 'SELECT' as any }, + ); + const existingCount = parseInt((countResult as any).count, 10); + if (existingCount > 0) { + log(` table already has ${existingCount} rows, duplicates will be skipped`); + } + + // Determine cutoff: if the PG table already has rows (from direct writes), + // skip any Redshift data with a timestamp >= the earliest PG row to avoid + // importing duplicates of events that were dual-written. + let cutoffTs: string | null = null; + if (existingCount > 0) { + const [minRow] = await sequelize.query<{ min_ts: string }>( + 'SELECT MIN("createdAt")::text AS min_ts FROM "AnalyticsEvents"', + { type: 'SELECT' as any }, + ); + cutoffTs = (minRow as any)?.min_ts ?? null; + if (cutoffTs) { + log(` cutoff: skipping Redshift rows with timestamp >= ${cutoffTs}`); + } + } + + const transformSql = buildTransformSql(cutoffTs); + + // Steps 3-5: process each CSV file one at a time to keep peak disk usage + // low. For each file: create staging → COPY in → transform to final table + // → drop staging. This avoids needing disk space for ALL rows in staging + // AND the final table simultaneously. + const files = (await readdir(dataDir)).filter((f) => /^data\d{3}$/.test(f)).sort(); + + if (files.length === 0) { + throw new Error(`No data files found in ${dataDir}`); + } + + log(`[3/7] processing ${files.length} CSV files...`); + let fileIdx = 0; + await sequential(files, async (file) => { + fileIdx++; + const filePath = path.join(dataDir, file); + log(` [${fileIdx}/${files.length}] ${file}: staging...`); + + // Create fresh staging table for this file + await sequelize.query(CREATE_STAGING_SQL); + + // Stream CSV into staging via psql \copy (client-side COPY FROM STDIN) + const dbUrl = process.env.DATABASE_URL!; + execSync(`psql "${dbUrl}" -c "\\copy analytics_staging FROM '${filePath}' CSV HEADER"`, { + stdio: 'inherit', + }); + + const [stagingCount] = await sequelize.query<{ count: string }>( + 'SELECT count(*) as count FROM analytics_staging', + { type: 'SELECT' as any }, + ); + log( + ` [${fileIdx}/${files.length}] ${file}: staged ${(stagingCount as any).count} rows, transforming...`, + ); + + // Transform and insert into final table + await sequelize.query(transformSql); + + // Drop staging to free disk space before next file + await sequelize.query('DROP TABLE IF EXISTS analytics_staging'); + log(` [${fileIdx}/${files.length}] ${file}: done`); + }); + + log('[4/7] cleaning spam rows from existing data...'); + const [, { rowCount: spamDeleted }] = (await sequelize.query(CLEANUP_SPAM_SQL)) as any; + log(` removed ${spamDeleted ?? 0} spam/invalid rows`); + + log('[5/7] running ANALYZE...'); + await sequelize.query('ANALYZE "AnalyticsEvents"'); + + const [finalCount] = await sequelize.query<{ count: string }>( + 'SELECT count(*) as count FROM "AnalyticsEvents"', + { type: 'SELECT' as any }, + ); + log(`[6/7] ${(finalCount as any).count} rows in AnalyticsEvents.`); + + log('[7/7] creating & refreshing summary materialized views...'); + const { createSummaryViews, refreshSummaryViews } = await import( + 'server/analytics/summaryViews' + ); + await createSummaryViews(); + await refreshSummaryViews(); + log('done.'); +} + +main() + .then(() => process.exit(0)) + .catch((err) => { + console.error('[migrateRedshift] FATAL:', err); + process.exit(1); + }); diff --git a/tools/refreshAnalyticsSummary.ts b/tools/refreshAnalyticsSummary.ts new file mode 100644 index 0000000000..9ca9e0619e --- /dev/null +++ b/tools/refreshAnalyticsSummary.ts @@ -0,0 +1,34 @@ +/** + * Create or refresh the analytics summary materialized views. + * + * Usage: + * pnpm run tools refreshAnalyticsSummary # create + refresh + * pnpm run tools refreshAnalyticsSummary refresh # refresh only + */ +import { createSummaryViews, refreshSummaryViews } from 'server/analytics/summaryViews'; + +// eslint-disable-next-line no-console +const log = (msg: string) => console.info(`[refreshAnalyticsSummary] ${msg}`); + +async function main() { + const refreshOnly = process.argv[3] === 'refresh'; + + if (!refreshOnly) { + log('creating materialized views (if not exist)...'); + await createSummaryViews(); + log('views created.'); + } + + log('refreshing materialized views (CONCURRENTLY)...'); + const t0 = Date.now(); + await refreshSummaryViews(); + const elapsed = ((Date.now() - t0) / 1000).toFixed(1); + log(`refresh complete in ${elapsed}s.`); +} + +main() + .then(() => process.exit(0)) + .catch((err) => { + console.error('[refreshAnalyticsSummary] FATAL:', err); + process.exit(1); + }); diff --git a/utils/analytics/featureFlags.ts b/utils/analytics/featureFlags.ts index 2f986d8740..e9b0a90548 100644 --- a/utils/analytics/featureFlags.ts +++ b/utils/analytics/featureFlags.ts @@ -4,7 +4,7 @@ export const shouldUseNewAnalytics = (featureFlags: InitialData['featureFlags']) featureFlags?.newAnalytics; export const canUseCustomAnalyticsProvider = (featureFlags: InitialData['featureFlags']) => - featureFlags?.customAnalyticsProvider; + true || featureFlags?.customAnalyticsProvider; export const noCookieBanner = (featureFlags: InitialData['featureFlags']) => featureFlags?.noCookieBanner; diff --git a/utils/analytics/usePageOnce.ts b/utils/analytics/usePageOnce.ts index e7c12b7aeb..63433653e1 100644 --- a/utils/analytics/usePageOnce.ts +++ b/utils/analytics/usePageOnce.ts @@ -64,7 +64,6 @@ const determinePayload = ( communityId: pubData.communityId, communityName: communityData.title, communitySubdomain: communityData.subdomain, - isProd: locationData.isProd, release: pubData.isRelease && pubData.releaseNumber ? pubData.releaseNumber @@ -76,7 +75,6 @@ const determinePayload = ( communityId: communityData.id, communityName: communityData.title, communitySubdomain: communityData.subdomain, - isProd: locationData.isProd, }; const collection = scopeData?.elements?.activeCollection; diff --git a/utils/api/schemas/analytics.ts b/utils/api/schemas/analytics.ts index 907885282b..38b720f6aa 100644 --- a/utils/api/schemas/analytics.ts +++ b/utils/api/schemas/analytics.ts @@ -23,17 +23,18 @@ export const baseSchema = z.object({ /** Information that should always be included in any event payload */ export const sharedEventPayloadSchema = z.object({ communityId: z.string().uuid(), - // if it's null, it 'www.pubpub.org' - communitySubdomain: z.string(), - communityName: z.string(), - isProd: z.boolean(), + // Dropped columns — accepted for backward compat with cached clients but not stored + communitySubdomain: z.string().optional(), + communityName: z.string().optional(), + isProd: z.boolean().optional(), }); export const basePageViewSchema = baseSchema.merge( z.object({ type: z.literal('page'), url: z.string().url(), - title: z.string(), + // Dropped column — accepted for backward compat but not stored + title: z.string().optional(), hash: z.string().optional(), height: z.number().int(), width: z.number().int(), @@ -44,9 +45,9 @@ export const basePageViewSchema = baseSchema.merge( export const sharedPageViewPayloadSchema = sharedEventPayloadSchema.merge( z.object({ communityId: z.string().uuid().nullable(), - // if it's null, it 'www.pubpub.org' - communitySubdomain: z.string().nullable().default('www'), - communityName: z.string().nullable().default('pubpub'), + // Dropped columns — accepted for backward compat but not stored + communitySubdomain: z.string().nullable().optional(), + communityName: z.string().nullable().optional(), event: z.enum(['page', 'collection', 'pub', 'other']), }), ); @@ -61,19 +62,21 @@ export const otherPageViewPayloadSchema = sharedPageViewPayloadSchema.merge( export const pagePageViewPayloadSchema = sharedPageViewPayloadSchema.merge( z.object({ event: z.literal('page'), - pageTitle: z.string(), + // Dropped columns — accepted for backward compat but not stored + pageTitle: z.string().optional(), pageId: z.string(), - pageSlug: z.string(), + pageSlug: z.string().optional(), }), ); export const collectionPageViewPayloadSchema = sharedPageViewPayloadSchema.merge( z.object({ event: z.literal('collection'), - collectionTitle: z.string(), - collectionKind: z.enum(['issue', 'conference', 'book', 'tag']), + // Dropped columns — accepted for backward compat but not stored + collectionTitle: z.string().optional(), + collectionKind: z.enum(['issue', 'conference', 'book', 'tag']).optional(), collectionId: z.string().uuid(), - collectionSlug: z.string(), + collectionSlug: z.string().optional(), }), ); @@ -81,9 +84,10 @@ export const pubPageViewPayloadSchema = sharedPageViewPayloadSchema .merge( z.object({ event: z.literal('pub'), - pubTitle: z.string(), + // Dropped columns — accepted for backward compat but not stored + pubTitle: z.string().optional(), pubId: z.string().uuid(), - pubSlug: z.string(), + pubSlug: z.string().optional(), collectionIds: z .string() .regex(/^[a-f0-9-]+(,[a-f0-9-]+)*$/) diff --git a/utils/dashboard.ts b/utils/dashboard.ts index 3f1d57e401..3ac35b0e7a 100644 --- a/utils/dashboard.ts +++ b/utils/dashboard.ts @@ -2,6 +2,7 @@ export type DashboardMode = | 'activity' | 'connections' | 'impact' + | 'impact2' | 'layout' | 'members' | 'overview'