From fa386df918ac515beae2c18f7ab3cf45b28fedc0 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Wed, 1 Apr 2026 22:19:33 -0400 Subject: [PATCH 01/41] First functional setup --- .../ScopeDropdown/ScopeDropdown.tsx | 1 + client/containers/App/paths.ts | 5 + .../DashboardImpact2/DashboardImpact2.tsx | 438 +++++++++++ .../DashboardImpact2/dashboardImpact2.scss | 151 ++++ client/containers/index.ts | 1 + server/analyticsDailyCache/model.ts | 43 ++ server/apiRoutes.ts | 2 + server/impact2/api.ts | 137 ++++ server/models.ts | 3 + server/routes/dashboardImpact2.tsx | 45 ++ server/routes/index.ts | 2 + server/utils/cloudflareAnalytics.ts | 721 ++++++++++++++++++ utils/dashboard.ts | 1 + 13 files changed, 1550 insertions(+) create mode 100644 client/containers/DashboardImpact2/DashboardImpact2.tsx create mode 100644 client/containers/DashboardImpact2/dashboardImpact2.scss create mode 100644 server/analyticsDailyCache/model.ts create mode 100644 server/impact2/api.ts create mode 100644 server/routes/dashboardImpact2.tsx create mode 100644 server/utils/cloudflareAnalytics.ts diff --git a/client/components/ScopeDropdown/ScopeDropdown.tsx b/client/components/ScopeDropdown/ScopeDropdown.tsx index 993c2dd0a1..0236356475 100644 --- a/client/components/ScopeDropdown/ScopeDropdown.tsx +++ b/client/components/ScopeDropdown/ScopeDropdown.tsx @@ -201,6 +201,7 @@ const ScopeDropdown = (props: Props) => { pubPubIcons.member, )} {renderDropddownButton(scope, 'impact', pubPubIcons.impact)} + {renderDropddownButton(scope, 'impact2', pubPubIcons.impact)} {scope.type === 'Collection' && renderDropddownButton( scope, diff --git a/client/containers/App/paths.ts b/client/containers/App/paths.ts index d943db19d8..e6309aa89c 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, @@ -84,6 +85,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/DashboardImpact2/DashboardImpact2.tsx b/client/containers/DashboardImpact2/DashboardImpact2.tsx new file mode 100644 index 0000000000..56c22f09cd --- /dev/null +++ b/client/containers/DashboardImpact2/DashboardImpact2.tsx @@ -0,0 +1,438 @@ +import React, { useEffect, useState, useMemo, useCallback } from 'react'; + +import { Button, ButtonGroup, Callout, NonIdealState, Spinner, Tag } from '@blueprintjs/core'; +import { + Area, + AreaChart, + CartesianGrid, + Cell, + Legend, + Pie, + PieChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; + +import { DashboardFrame } from 'components'; +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; + }; + stale?: boolean; +}; + +type DateRange = '1d' | '7d' | '30d'; + +const formatNumber = (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 formatDateLabel = (dateStr: string): string => { + const d = new Date(dateStr + 'T00:00:00'); + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); +}; + +const getDateRange = (range: DateRange): { startDate: string; endDate: string } => { + const end = new Date(); + const start = new Date(); + switch (range) { + case '1d': + start.setDate(end.getDate() - 1); + break; + case '7d': + start.setDate(end.getDate() - 7); + break; + case '30d': + start.setDate(end.getDate() - 30); + break; + default: + start.setDate(end.getDate() - 7); + break; + } + return { + startDate: start.toISOString().slice(0, 10), + endDate: end.toISOString().slice(0, 10), + }; +}; + +const StatCard = ({ + label, + value, + subtext, +}: { + label: string; + value: string; + subtext?: string; +}) => ( +
+
{value}
+
{label}
+ {subtext &&
{subtext}
} +
+); + +const CHART_COLORS = { + visits: '#2B95D6', + pageViews: '#15B371', +}; + +const PIE_COLORS = [ + '#2B95D6', + '#15B371', + '#D9822B', + '#8F398F', + '#F5498B', + '#29A634', + '#D99E0B', + '#669EFF', +]; + +const SimpleTable = ({ + data, + columns, +}: { + data: Array>; + columns: Array<{ key: string; label: string; format?: (v: any) => string }>; +}) => ( + + + + {columns.map((col) => ( + + ))} + + + + {data.map((row) => ( + row[c.key]).join('-')}> + {columns.map((col) => ( + + ))} + + ))} + +
{col.label}
+ {col.format ? col.format(row[col.key]) : row[col.key]} +
+); + +const DashboardImpact2 = () => { + const { scopeData } = usePageContext(); + const { + elements: { activeTargetName }, + activePermissions: { canView }, + } = scopeData; + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [stale, setStale] = useState(false); + const [dateRange, setDateRange] = useState('7d'); + + const fetchData = useCallback(async (range: DateRange) => { + setLoading(true); + setError(null); + setStale(false); + try { + const { startDate, endDate } = getDateRange(range); + const res = await fetch(`/api/impact2?startDate=${startDate}&endDate=${endDate}`); + 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); + setStale(!!json.stale); + } catch (err: any) { + setError(err.message ?? 'Failed to load analytics'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (canView) { + fetchData(dateRange); + } + }, [dateRange, canView, fetchData]); + + const chartData = useMemo(() => { + if (!data) return []; + return data.daily.map((d) => ({ + ...d, + label: formatDateLabel(d.date), + })); + }, [data]); + + const handleRangeChange = (range: DateRange) => { + setDateRange(range); + }; + + if (!canView) { + return ( + +

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

+
+ ); + } + + return ( + + + + + + } + > + {loading && ( +
+ +
+ )} + + {error && ( + fetchData(dateRange)} icon="refresh"> + Retry + + } + /> + )} + + {!loading && !error && data && ( + <> + {/* Stale data warning */} + {stale && ( + + Data may be slightly delayed due to temporary limits on live updates. + Please try again in a few minutes. + + )} + + {/* Summary stats */} +
+ + +
+

+ Estimated after adjusting for suspected bot and spam traffic.* +

+ + {/* Visits over time */} + {chartData.length > 1 && ( +
+

Unique Sessions & Pages Viewed Over Time

+ + + + + + + + + + +
+ )} + + {/* Top pages */} + {data.topPaths.length > 0 && ( +
+

Top Pages

+ +
+ )} + + {/* Countries + Devices side by side */} +
+ {data.countries.length > 0 && ( +
+

Top Countries

+ ({ + ...c, + country: countryName(c.country), + }))} + columns={[ + { key: 'country', label: 'Country' }, + { key: 'count', label: 'Views', format: formatNumber }, + ]} + /> +
+ )} + {data.devices.length > 0 && ( +
+

Devices

+ + + + `${device} ${(percent * 100).toFixed(0)}%` + } + > + {data.devices.map((entry, i) => ( + + ))} + + formatNumber(v)} /> + + + +
+ )} +
+ + {/* Referrers */} + {data.referrers.length > 0 && ( +
+

Top Referrers

+ +
+ )} + +
+

Analytics sourced from Cloudflare edge traffic data.

+

+ * Totals are adjusted to exclude traffic from known bot and spam routes + (e.g. /wp-login, /cdn-cgi/,{' '} + /robots.txt). Unique sessions are estimated proportionally + since session counts can't be attributed to individual paths. + Pre-adjustment totals: {formatNumber(data.rawTotals.visits)} sessions /{' '} + {formatNumber(data.rawTotals.pageViews)} page views. +

+
+ + )} +
+ ); +}; + +export default DashboardImpact2; diff --git a/client/containers/DashboardImpact2/dashboardImpact2.scss b/client/containers/DashboardImpact2/dashboardImpact2.scss new file mode 100644 index 0000000000..3f667b75fe --- /dev/null +++ b/client/containers/DashboardImpact2/dashboardImpact2.scss @@ -0,0 +1,151 @@ +.dashboard-impact2-container { + .loading-container { + display: flex; + justify-content: center; + padding: 60px 0; + } + + .stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 16px; + margin: 24px 0 32px; + } + + .stat-card { + background: rgba(0, 0, 0, 0.03); + border-radius: 8px; + padding: 20px; + text-align: center; + + .stat-value { + font-size: 28px; + font-weight: 600; + line-height: 1.2; + color: #1c2127; + } + + .stat-label { + font-size: 13px; + color: #5c7080; + margin-top: 4px; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .stat-subtext { + font-size: 11px; + color: #8a9ba8; + margin-top: 2px; + } + } + + .chart-section { + margin: 32px 0; + + h3 { + font-size: 16px; + font-weight: 600; + margin-bottom: 16px; + display: flex; + align-items: center; + gap: 8px; + } + + .source-tag { + font-size: 10px; + font-weight: 500; + } + } + + .analytics-footer { + margin-top: 40px; + padding-top: 20px; + border-top: 1px solid #e1e8ed; + + p { + font-size: 13px; + color: #8a9ba8; + line-height: 1.5; + } + } + + .two-col { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + + @media (max-width: 768px) { + grid-template-columns: 1fr; + } + } + + .simple-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + + th { + text-align: left; + padding: 8px 12px; + border-bottom: 2px solid #e1e8ed; + font-weight: 600; + color: #5c7080; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + td { + padding: 6px 12px; + border-bottom: 1px solid #e1e8ed; + color: #1c2127; + } + + tr:last-child td { + border-bottom: none; + } + + tr:hover td { + background: rgba(0, 0, 0, 0.02); + } + } +} + +/* Dark mode support */ +.bp5-dark, +.bp3-dark { + .dashboard-impact2-container { + .stat-card { + background: rgba(255, 255, 255, 0.06); + + .stat-value { + color: #f5f8fa; + } + + .stat-label { + color: #a7b6c2; + } + } + + .analytics-footer { + border-top-color: #30404d; + } + + .simple-table { + th { + border-bottom-color: #30404d; + color: #a7b6c2; + } + + td { + border-bottom-color: #30404d; + color: #f5f8fa; + } + + tr:hover td { + background: rgba(255, 255, 255, 0.04); + } + } + } +} diff --git a/client/containers/index.ts b/client/containers/index.ts index 83442f6d9c..c52e22c247 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/server/analyticsDailyCache/model.ts b/server/analyticsDailyCache/model.ts new file mode 100644 index 0000000000..390cdfa033 --- /dev/null +++ b/server/analyticsDailyCache/model.ts @@ -0,0 +1,43 @@ +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. + * + * Composite primary key: (hostname, date). + * Past days are cached permanently (expiresAt = null). + * Today's partial data is cached with a short TTL (expiresAt = now + 3h). + */ +@Table({ tableName: 'AnalyticsDailyCaches', timestamps: false }) +export class AnalyticsDailyCache 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; + + /** + * 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 ~3 hours from write time. + */ + @Column(DataType.DATE) + declare expiresAt: CreationOptional; +} diff --git a/server/apiRoutes.ts b/server/apiRoutes.ts index 772b4cec09..82bbb16c60 100644 --- a/server/apiRoutes.ts +++ b/server/apiRoutes.ts @@ -4,6 +4,7 @@ import { isProd } from 'utils/environment'; import { activityItemRouter } from './activityItem/api'; import { router as apiDocsRouter } from './apiDocs/api'; +import { router as impact2Router } from './impact2/api'; import { router as captchaRouter } from './captcha/api'; import { router as citationRouter } from './citation/api'; import { router as communityBanRouter } from './communityBan/api'; @@ -72,6 +73,7 @@ const apiRouter = Router() .use(userNotificationPreferencesRouter) .use(userSubscriptionRouter) .use(zoteroIntegrationRouter) + .use(impact2Router) .use(apiDocsRouter); if (!isProd() && process.env.NODE_ENV !== 'test') { diff --git a/server/impact2/api.ts b/server/impact2/api.ts new file mode 100644 index 0000000000..dddc34fa92 --- /dev/null +++ b/server/impact2/api.ts @@ -0,0 +1,137 @@ +import { Router } from 'express'; + +import { + fetchCommunityAnalytics, + testCloudflareConnection, + debugCommunityAnalytics, +} from 'server/utils/cloudflareAnalytics'; +import { Community } from 'server/community/model'; +import { handleErrors, ForbiddenError } from 'server/utils/errors'; +import { getInitialData } from 'server/utils/initData'; +import { hostIsValid } from 'server/utils/routes'; + +export const router = Router(); + +/** + * 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=... + */ +router.get('/api/impact2/debug', 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 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 community. + * 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. + */ +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 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.', + }); + } + return res.json(result); + } catch (err) { + return handleErrors(req, res, next)(err); + } +}); diff --git a/server/models.ts b/server/models.ts index 925e1b1b75..f1cdafdee2 100644 --- a/server/models.ts +++ b/server/models.ts @@ -3,6 +3,7 @@ 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 { AnalyticsDailyCache } from './analyticsDailyCache/model'; import { AuthToken } from './authToken/model'; import { Collection } from './collection/model'; import { CollectionAttribution } from './collectionAttribution/model'; @@ -64,6 +65,7 @@ import { ZoteroIntegration } from './zoteroIntegration/model'; sequelize.addModels([ ActivityItem, + AnalyticsDailyCache, AuthToken, Collection, CollectionAttribution, @@ -159,6 +161,7 @@ export const includeUserModel = (() => { export { ActivityItem, + AnalyticsDailyCache, AuthToken, Collection, CollectionAttribution, diff --git a/server/routes/dashboardImpact2.tsx b/server/routes/dashboardImpact2.tsx new file mode 100644 index 0000000000..bce68a0a36 --- /dev/null +++ b/server/routes/dashboardImpact2.tsx @@ -0,0 +1,45 @@ +import React from 'react'; + +import { Router } from 'express'; + +import Html from 'server/Html'; +import { handleErrors, NotFoundError } from 'server/utils/errors'; +import { getInitialData } from 'server/utils/initData'; +import { hostIsValid } from 'server/utils/routes'; +import { generateMetaComponents, renderToNodeStream } from 'server/utils/ssr'; + +export const router = Router(); + +router.get( + [ + '/dash/impact2', + '/dash/collection/:collectionSlug/impact2', + '/dash/pub/:pubSlug/impact2', + ], + async (req, res, next) => { + 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 b5b2f9afd5..3357c47d45 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'; @@ -64,6 +65,7 @@ rootRouter .use(dashboardEdgesRouter) .use(dashboardFacetsRouter) .use(dashboardImpactRouter) + .use(dashboardImpact2Router) .use(dashboardMembersRouter) .use(dashboardCommunityOverviewRouter) .use(dashboardCollectionOverviewRouter) diff --git a/server/utils/cloudflareAnalytics.ts b/server/utils/cloudflareAnalytics.ts new file mode 100644 index 0000000000..f58e33234c --- /dev/null +++ b/server/utils/cloudflareAnalytics.ts @@ -0,0 +1,721 @@ +/** + * 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 + */ + +const CF_GRAPHQL_ENDPOINT = 'https://api.cloudflare.com/client/v4/graphql'; + +function getConfig() { + const apiToken = process.env.CLOUDFLARE_ANALYTICS_API_TOKEN; + const zoneTag = process.env.CLOUDFLARE_ZONE_TAG; + if (!apiToken || !zoneTag) { + 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/', +]; +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 AnalyticsDailyCache 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 { AnalyticsDailyCache } from 'server/analyticsDailyCache/model'; +import { Op } from 'sequelize'; + +/** 3 hours in milliseconds. */ +const TODAY_CACHE_TTL_MS = 3 * 60 * 60 * 1000; + +/** + * Delete cache rows older than 90 days. + * 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 = 90; +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 AnalyticsDailyCache.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[], +): Promise> { + if (dates.length === 0) return new Map(); + const rows = await AnalyticsDailyCache.findAll({ + where: { + hostname, + date: dates, + [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, +) { + 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 AnalyticsDailyCache.upsert({ hostname, date, 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: 200 + 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 () => { + const filter = { + date_geq: chunk.start, + date_leq: chunk.end, + clientRequestHTTPHost: hostname, + requestSource: 'eyeball', + }; + 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(); + 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 + 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, 20); + + // Subtract noise/bot path hits from totals so headline numbers + // reflect real human traffic as closely as possible. + let noisePageViews = 0; + for (const [path, count] of pathMap) { + if (isNoisePath(path)) noisePageViews += count; + } + const adjustedPageViews = Math.max(0, totalPageViews - noisePageViews); + // Visits (unique sessions) aren't per-path, so scale proportionally. + const ratio = totalPageViews > 0 ? adjustedPageViews / totalPageViews : 1; + const adjustedVisits = Math.round(totalVisits * ratio); + + const countries = Array.from(countryMap.entries()) + .map(([country, count]) => ({ country, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 20); + + const devices = Array.from(deviceMap.entries()) + .map(([device, count]) => ({ device, count })) + .sort((a, b) => b.count - a.count); + + const referrers = Array.from(refMap.entries()) + .map(([referrer, count]) => ({ referrer, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 15); + + return { + daily, + topPaths, + countries, + devices, + referrers, + totals: { visits: adjustedVisits, pageViews: adjustedPageViews }, + rawTotals: { visits: totalVisits, pageViews: totalPageViews }, + ...(stale ? { stale: true } : {}), + }; +} + +// --------------------------------------------------------------------------- +// 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', + }; + + 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/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' From df2589ee734397780d3b99868725b54c63a89213 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Wed, 1 Apr 2026 23:31:20 -0400 Subject: [PATCH 02/41] Layout cleanup and filtering improvements --- .../ScopeDropdown/ScopeDropdown.tsx | 6 +- .../DashboardImpact2/DashboardImpact2.tsx | 428 ++++++++---------- .../DashboardImpact2/dashboardImpact2.scss | 204 ++++++--- server/apiRoutes.ts | 2 +- server/impact2/api.ts | 6 +- server/routes/dashboardImpact2.tsx | 6 +- server/utils/cloudflareAnalytics.ts | 290 +++++++----- 7 files changed, 522 insertions(+), 420 deletions(-) diff --git a/client/components/ScopeDropdown/ScopeDropdown.tsx b/client/components/ScopeDropdown/ScopeDropdown.tsx index 0236356475..e1232ad9c5 100644 --- a/client/components/ScopeDropdown/ScopeDropdown.tsx +++ b/client/components/ScopeDropdown/ScopeDropdown.tsx @@ -201,7 +201,11 @@ const ScopeDropdown = (props: Props) => { pubPubIcons.member, )} {renderDropddownButton(scope, 'impact', pubPubIcons.impact)} - {renderDropddownButton(scope, 'impact2', pubPubIcons.impact)} + {renderDropddownButton( + scope, + 'impact2', + pubPubIcons.impact, + )} {scope.type === 'Collection' && renderDropddownButton( scope, diff --git a/client/containers/DashboardImpact2/DashboardImpact2.tsx b/client/containers/DashboardImpact2/DashboardImpact2.tsx index 56c22f09cd..16fcb84d38 100644 --- a/client/containers/DashboardImpact2/DashboardImpact2.tsx +++ b/client/containers/DashboardImpact2/DashboardImpact2.tsx @@ -1,14 +1,11 @@ -import React, { useEffect, useState, useMemo, useCallback } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { Button, ButtonGroup, Callout, NonIdealState, Spinner, Tag } from '@blueprintjs/core'; +import { Button, ButtonGroup, Callout, NonIdealState, Spinner } from '@blueprintjs/core'; import { Area, AreaChart, CartesianGrid, - Cell, Legend, - Pie, - PieChart, ResponsiveContainer, Tooltip, XAxis, @@ -31,12 +28,7 @@ function countryName(code: string): string { } } -type DailyAnalytics = { - date: string; - visits: number; - pageViews: number; -}; - +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 }; @@ -48,107 +40,65 @@ type AnalyticsData = { countries: CountryBreakdown[]; devices: DeviceBreakdown[]; referrers: ReferrerBreakdown[]; - totals: { - visits: number; - pageViews: number; - }; - rawTotals: { - visits: number; - pageViews: number; - }; + totals: { visits: number; pageViews: number }; + rawTotals: { visits: number; pageViews: number }; stale?: boolean; }; type DateRange = '1d' | '7d' | '30d'; -const formatNumber = (n: number): string => { +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 formatDateLabel = (dateStr: string): string => { - const d = new Date(dateStr + 'T00:00:00'); +const fmtDate = (s: string): string => { + const d = new Date(s + 'T00:00:00'); return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); }; -const getDateRange = (range: DateRange): { startDate: string; endDate: string } => { +const getRange = (r: DateRange) => { const end = new Date(); const start = new Date(); - switch (range) { - case '1d': - start.setDate(end.getDate() - 1); - break; - case '7d': - start.setDate(end.getDate() - 7); - break; - case '30d': - start.setDate(end.getDate() - 30); - break; - default: - start.setDate(end.getDate() - 7); - break; - } + 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, - subtext, -}: { - label: string; - value: string; - subtext?: string; -}) => ( -
+const StatCard = ({ label, value, color }: { label: string; value: string; color: string }) => ( +
{value}
{label}
- {subtext &&
{subtext}
}
); -const CHART_COLORS = { - visits: '#2B95D6', - pageViews: '#15B371', -}; - -const PIE_COLORS = [ - '#2B95D6', - '#15B371', - '#D9822B', - '#8F398F', - '#F5498B', - '#29A634', - '#D99E0B', - '#669EFF', -]; +const COLORS = { visits: '#2B95D6', pageViews: '#15B371' }; -const SimpleTable = ({ - data, +/** Compact table used inside data panels. */ +const CompactTable = ({ + rows, columns, }: { - data: Array>; - columns: Array<{ key: string; label: string; format?: (v: any) => string }>; + rows: Array>; + columns: Array<{ key: string; label: string; render?: (v: any, row: any) => React.ReactNode }>; }) => ( - +
- {columns.map((col) => ( - + {columns.map((c) => ( + ))} - {data.map((row) => ( - row[c.key]).join('-')}> - {columns.map((col) => ( - + {rows.map((row) => ( + row[c.key]).join('|')}> + {columns.map((c) => ( + ))} ))} @@ -156,10 +106,12 @@ const SimpleTable = ({
{col.label}{c.label}
- {col.format ? col.format(row[col.key]) : row[col.key]} -
{c.render ? c.render(row[c.key], row) : row[c.key]}
); +// ───────────────────────────────────────────────────────────────────────────── + const DashboardImpact2 = () => { const { scopeData } = usePageContext(); const { - elements: { activeTargetName }, + // elements: { activeTargetName }, activePermissions: { canView }, } = scopeData; @@ -174,7 +126,7 @@ const DashboardImpact2 = () => { setError(null); setStale(false); try { - const { startDate, endDate } = getDateRange(range); + const { startDate, endDate } = getRange(range); const res = await fetch(`/api/impact2?startDate=${startDate}&endDate=${endDate}`); if (!res.ok) { const body = await res.json().catch(() => ({})); @@ -191,29 +143,20 @@ const DashboardImpact2 = () => { }, []); useEffect(() => { - if (canView) { - fetchData(dateRange); - } + if (canView) fetchData(dateRange); }, [dateRange, canView, fetchData]); - const chartData = useMemo(() => { - if (!data) return []; - return data.daily.map((d) => ({ - ...d, - label: formatDateLabel(d.date), - })); - }, [data]); - - const handleRangeChange = (range: DateRange) => { - setDateRange(range); - }; + 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.

@@ -224,28 +167,16 @@ const DashboardImpact2 = () => { - - - @@ -272,162 +203,175 @@ const DashboardImpact2 = () => { {!loading && !error && data && ( <> - {/* Stale data warning */} {stale && ( - - Data may be slightly delayed due to temporary limits on live updates. - Please try again in a few minutes. - +
+ Data may be slightly delayed — try again in a few minutes. +
)} - {/* Summary stats */} -
- - + {/* ── Row 1: Stats + Chart ── */} +
+
+ + +

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

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

Traffic Over Time

+ + + + + + v.toLocaleString()} /> + + + + + +
+ )}
-

- Estimated after adjusting for suspected bot and spam traffic.* -

- - {/* Visits over time */} - {chartData.length > 1 && ( -
-

Unique Sessions & Pages Viewed Over Time

- - - - - - - - - - -
- )} - {/* Top pages */} - {data.topPaths.length > 0 && ( -
-

Top Pages

- -
- )} + {/* ── Row 2: data grid ── */} +
+ {/* Top Pages */} + {data.topPaths.length > 0 && ( +
+

Top Pages

+ ( + + {v} + + ), + }, + { + key: 'count', + label: 'Views', + render: (v: number) => fmt(v), + }, + ]} + /> +
+ )} - {/* Countries + Devices side by side */} -
+ {/* Countries */} {data.countries.length > 0 && ( -
-

Top Countries

- ({ +
+

Countries

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

Referrers

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

Devices

- - - - `${device} ${(percent * 100).toFixed(0)}%` - } - > - {data.devices.map((entry, i) => ( - - ))} - - formatNumber(v)} /> - - - -
+ { + 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: '%' }, + ]} + /> +
)}
- {/* Referrers */} - {data.referrers.length > 0 && ( -
-

Top Referrers

- -
- )} - + {/* Footer */}
-

Analytics sourced from Cloudflare edge traffic data.

- * Totals are adjusted to exclude traffic from known bot and spam routes - (e.g. /wp-login, /cdn-cgi/,{' '} - /robots.txt). Unique sessions are estimated proportionally - since session counts can't be attributed to individual paths. - Pre-adjustment totals: {formatNumber(data.rawTotals.visits)} sessions /{' '} - {formatNumber(data.rawTotals.pageViews)} page views. + * Totals adjusted to exclude known bot/spam routes (e.g.{' '} + /wp-login, /cdn-cgi/). 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.

)} diff --git a/client/containers/DashboardImpact2/dashboardImpact2.scss b/client/containers/DashboardImpact2/dashboardImpact2.scss index 3f667b75fe..71ca5a1f2c 100644 --- a/client/containers/DashboardImpact2/dashboardImpact2.scss +++ b/client/containers/DashboardImpact2/dashboardImpact2.scss @@ -5,147 +5,233 @@ padding: 60px 0; } - .stats-grid { + // ── Top row: stats + chart ────────────────────────────────────────── + .top-row { display: grid; - grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); - gap: 16px; - margin: 24px 0 32px; + 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: rgba(0, 0, 0, 0.03); - border-radius: 8px; - padding: 20px; - text-align: center; + background: none; + border-left: 3px solid #2b95d6; + border-radius: 0; + padding: 4px 0 4px 12px; .stat-value { font-size: 28px; - font-weight: 600; - line-height: 1.2; + font-weight: 700; + line-height: 1.1; color: #1c2127; + font-variant-numeric: tabular-nums; } .stat-label { - font-size: 13px; + font-size: 11px; color: #5c7080; - margin-top: 4px; + margin-top: 1px; text-transform: uppercase; - letter-spacing: 0.5px; + letter-spacing: 0.6px; + font-weight: 600; } .stat-subtext { - font-size: 11px; + font-size: 10px; color: #8a9ba8; - margin-top: 2px; + margin-top: 1px; } } - .chart-section { - margin: 32px 0; + .chart-column { + min-height: 0; h3 { - font-size: 16px; + font-size: 12px; font-weight: 600; - margin-bottom: 16px; - display: flex; - align-items: center; - gap: 8px; - } - - .source-tag { - font-size: 10px; - font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #5c7080; + margin: 0 0 6px; } } - .analytics-footer { - margin-top: 40px; - padding-top: 20px; - border-top: 1px solid #e1e8ed; - - p { - font-size: 13px; - color: #8a9ba8; - line-height: 1.5; - } + .fine-print { + font-size: 11px; + color: #777; + margin: 4px 0 0; } - .two-col { + // ── Data grid: breakdown panels ───────────────────────────────────── + .data-grid { display: grid; - grid-template-columns: 1fr 1fr; - gap: 24px; + grid-template-columns: repeat(4, 1fr); + gap: 16px; + margin-bottom: 36px; - @media (max-width: 768px) { + @media (max-width: 1100px) { + grid-template-columns: 1fr 1fr; + } + @media (max-width: 600px) { grid-template-columns: 1fr; } } - .simple-table { + .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; + } + } + + + + // ── Compact table ─────────────────────────────────────────────────── + .compact-table { width: 100%; border-collapse: collapse; font-size: 13px; th { text-align: left; - padding: 8px 12px; - border-bottom: 2px solid #e1e8ed; + padding: 4px; + border-bottom: 1px solid #e1e8ed; font-weight: 600; - color: #5c7080; + color: #8a9ba8; font-size: 11px; text-transform: uppercase; - letter-spacing: 0.5px; + letter-spacing: 0.4px; } td { - padding: 6px 12px; - border-bottom: 1px solid #e1e8ed; + padding: 3px 4px; color: #1c2127; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 0; } - tr:last-child td { - border-bottom: none; + 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 4px; + + 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 support */ +/* ── Dark mode ─────────────────────────────────────────────────────────── */ .bp5-dark, .bp3-dark { .dashboard-impact2-container { .stat-card { - background: rgba(255, 255, 255, 0.06); - + 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); + } + } } - .simple-table { + .compact-table { th { border-bottom-color: #30404d; - color: #a7b6c2; + color: #738694; } - td { - border-bottom-color: #30404d; color: #f5f8fa; } - + td:last-child { + color: #a7b6c2; + } tr:hover td { background: rgba(255, 255, 255, 0.04); } + a:hover { + color: #48aff0; + } } } } diff --git a/server/apiRoutes.ts b/server/apiRoutes.ts index 82bbb16c60..b6ab10446e 100644 --- a/server/apiRoutes.ts +++ b/server/apiRoutes.ts @@ -4,7 +4,6 @@ import { isProd } from 'utils/environment'; import { activityItemRouter } from './activityItem/api'; import { router as apiDocsRouter } from './apiDocs/api'; -import { router as impact2Router } from './impact2/api'; import { router as captchaRouter } from './captcha/api'; import { router as citationRouter } from './citation/api'; import { router as communityBanRouter } from './communityBan/api'; @@ -14,6 +13,7 @@ import { router as devApiRouter } from './dev/api'; import { router as discussionRouter } from './discussion/api'; import { router as doiRouter } from './doi/api'; import { router as editorRouter } from './editor/api'; +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'; diff --git a/server/impact2/api.ts b/server/impact2/api.ts index dddc34fa92..6fb6513584 100644 --- a/server/impact2/api.ts +++ b/server/impact2/api.ts @@ -1,12 +1,12 @@ import { Router } from 'express'; +import { Community } from 'server/community/model'; import { + debugCommunityAnalytics, fetchCommunityAnalytics, testCloudflareConnection, - debugCommunityAnalytics, } from 'server/utils/cloudflareAnalytics'; -import { Community } from 'server/community/model'; -import { handleErrors, ForbiddenError } from 'server/utils/errors'; +import { ForbiddenError, handleErrors } from 'server/utils/errors'; import { getInitialData } from 'server/utils/initData'; import { hostIsValid } from 'server/utils/routes'; diff --git a/server/routes/dashboardImpact2.tsx b/server/routes/dashboardImpact2.tsx index bce68a0a36..c7198d1336 100644 --- a/server/routes/dashboardImpact2.tsx +++ b/server/routes/dashboardImpact2.tsx @@ -11,11 +11,7 @@ import { generateMetaComponents, renderToNodeStream } from 'server/utils/ssr'; export const router = Router(); router.get( - [ - '/dash/impact2', - '/dash/collection/:collectionSlug/impact2', - '/dash/pub/:pubSlug/impact2', - ], + ['/dash/impact2', '/dash/collection/:collectionSlug/impact2', '/dash/pub/:pubSlug/impact2'], async (req, res, next) => { try { if (!hostIsValid(req, 'community')) { diff --git a/server/utils/cloudflareAnalytics.ts b/server/utils/cloudflareAnalytics.ts index f58e33234c..6168567a8a 100644 --- a/server/utils/cloudflareAnalytics.ts +++ b/server/utils/cloudflareAnalytics.ts @@ -165,6 +165,7 @@ const NOISE_PATH_PREFIXES = [ '/api/', '/dist/', '/static/', + '/login', ]; const NOISE_EXACT_PATHS = new Set([ '/robots.txt', @@ -195,9 +196,10 @@ function isNoisePath(path: string): boolean { // • Repeat load within 3h: 0 CF calls, pure Postgres. // • After 3h: 1 CF call for just today (past days still cached permanently). -import { AnalyticsDailyCache } from 'server/analyticsDailyCache/model'; import { Op } from 'sequelize'; +import { AnalyticsDailyCache } from 'server/analyticsDailyCache/model'; + /** 3 hours in milliseconds. */ const TODAY_CACHE_TTL_MS = 3 * 60 * 60 * 1000; @@ -246,7 +248,7 @@ async function getCachedDays( hostname, date: dates, [Op.or]: [ - { expiresAt: null }, // permanent (past days) + { expiresAt: null }, // permanent (past days) { expiresAt: { [Op.gt]: new Date() } }, // not yet expired (today) ], }, @@ -420,10 +422,7 @@ export async function fetchCommunityAnalytics( 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(), - ]); + const [cached] = await Promise.all([getCachedDays(hostname, allDates), pruneOldCacheRows()]); // 2. Fetch any uncached days from CF const uncachedDates = allDates.filter((d) => !cached.has(d)); @@ -433,108 +432,161 @@ export async function fetchCommunityAnalytics( 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, - requestSource: 'eyeball', - }; - 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(); - function ensure(d: string) { - if (!byDate.has(d)) { - byDate.set(d, { topPaths: [], countries: [], devices: [], referrers: [] }); + 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, + }); } - 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: [], + // 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, empty); - toStore.set(d, empty); + cached.set(d, payload); + toStore.set(d, payload); } - } - await storeCachedDays(hostname, toStore, today).catch((err) => { - console.error('Failed to store analytics cache:', err); - }); - } + // 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); + } + } - let spanChain: Promise = Promise.resolve(); - for (const span of spans) { - spanChain = spanChain.then(() => fetchSpan(span)); - } - await spanChain; + 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); @@ -560,9 +612,12 @@ export async function fetchCommunityAnalytics( 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); + 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()) @@ -582,22 +637,35 @@ export async function fetchCommunityAnalytics( const ratio = totalPageViews > 0 ? adjustedPageViews / totalPageViews : 1; const adjustedVisits = Math.round(totalVisits * ratio); + // Apply noise ratio to daily chart data so the chart numbers + // are consistent with the adjusted headline totals (shape preserved). + const adjustedDaily = daily.map((d) => ({ + date: d.date, + visits: Math.round(d.visits * ratio), + pageViews: Math.round(d.pageViews * ratio), + })); + + // Apply the same proportional noise ratio to all breakdowns so that + // country / device / referrer counts are consistent with the adjusted totals. const countries = Array.from(countryMap.entries()) - .map(([country, count]) => ({ country, count })) + .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 })) + .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 })) + .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, + daily: adjustedDaily, topPaths, countries, devices, @@ -631,6 +699,10 @@ export async function debugCommunityAnalytics( date_leq: endDate, clientRequestHTTPHost: hostname, requestSource: 'eyeball', + edgeResponseStatus_geq: 200, + edgeResponseStatus_lt: 400, + edgeResponseContentTypeName: 'html', + clientRequestHTTPMethodName: 'GET', }; const zoneCheckQuery = ` From 0d26bef6a49413171be0c17df01dd1e7a0d1b40b Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Wed, 1 Apr 2026 23:40:04 -0400 Subject: [PATCH 03/41] Add proper .env vars in enc files. Add UI fallback if env vars not configured --- .../DashboardImpact2/DashboardImpact2.tsx | 14 +++ infra/.env.dev.enc | 90 ++++++++--------- infra/.env.enc | 96 ++++++++++--------- server/utils/cloudflareAnalytics.ts | 8 ++ 4 files changed, 117 insertions(+), 91 deletions(-) diff --git a/client/containers/DashboardImpact2/DashboardImpact2.tsx b/client/containers/DashboardImpact2/DashboardImpact2.tsx index 16fcb84d38..05edef97ff 100644 --- a/client/containers/DashboardImpact2/DashboardImpact2.tsx +++ b/client/containers/DashboardImpact2/DashboardImpact2.tsx @@ -118,6 +118,7 @@ const DashboardImpact2 = () => { 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'); @@ -125,11 +126,16 @@ const DashboardImpact2 = () => { setLoading(true); setError(null); setStale(false); + setNotConfigured(false); try { const { startDate, endDate } = getRange(range); const res = await fetch(`/api/impact2?startDate=${startDate}&endDate=${endDate}`); 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(); @@ -201,6 +207,14 @@ const DashboardImpact2 = () => { /> )} + {notConfigured && ( + + )} + {!loading && !error && data && ( <> {stale && ( diff --git a/infra/.env.dev.enc b/infra/.env.dev.enc index 3ed84c1eed..edfc9c2fe9 100644 --- a/infra/.env.dev.enc +++ b/infra/.env.dev.enc @@ -1,52 +1,54 @@ -AES_ENCRYPTION_KEY=ENC[AES256_GCM,data:Kz4/KiHal4JIP5kmXBSVg+VI/nvNGfPtRv8Rrf9mUdY7bDuc5k1In1oSPHsjjNQLDN8bs6YuzCSBzWyyouDUAg==,iv:QC65R/DVjioNsTb6EnChdSaugtlOqvEx+m4C57eZStM=,tag:kc9NcW6faLAdJIy17wcjtQ==,type:str] -ALGOLIA_ID=ENC[AES256_GCM,data:Pu/lBy/Vo/9p1g==,iv:4HhG6IjEyheW+Ug1mI9m+6xzxRuVf2xvJ+dPtONp130=,tag:9LNs+U1klSpxqVgAnQJaug==,type:str] -ALGOLIA_KEY=ENC[AES256_GCM,data:bY2wVEofNkVMlvwhEhGr4punAQ/JMShYYPpKfR/kyK8=,iv:NIrux9ntBLGphMQ0yem+puX9gjnvlGb3jUiDupiyths=,tag:8DkKbPwvQtPPWHGmH0zRWw==,type:str] -ALGOLIA_SEARCH_KEY=ENC[AES256_GCM,data:MWIU9p3KmmsDjdxk+IRUJgzB7SQw479ZUG9WdlXLXk8=,iv:ujK8AHJPvvbCi7HxhL7cGvxQ/HYFJRriCFbsNkCAHXk=,tag:KaU7v4CrQJRKmH+QfJwGlA==,type:str] -AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:nnIlGdEBlwi268NlCFi8d5Ugjmg=,iv:uP5wFBOWZJrSH50uYNzJ4kJdv3jYUcEFlMRVrDMlTng=,tag:Uv0D0mKNKebrNwuySxIM7g==,type:str] -AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:aWJiiVfgnam1My2J/J3k1p/8j4g=,iv:XD+OHujRRMUyyZ8cMGJ2mw1TCtwELrR16txekGpwMi8=,tag:HEsGvUYMNEiv1mk2mb4GYw==,type:str] -AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:fYAtsyYDbTp3EBvm6vtabfmBupk9ryfpcu4P3ROoyTEOBnZyPrhPnQ==,iv:L/VZB/DccoB55GFWPfsVbQ9uzdIk3nwadkKZ15YzFGE=,tag:WEbCfpCYN0jHrfzWsc5TNg==,type:str] -AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:DGbljDUXr1ehACzOOWFFN2fCIrzq5aChbZg++7IXzLwBxWNldm1vqQ==,iv:G4lKHMPP4BL0kaZz4oH1CulYBiRh7SyZAu3pKhGoUys=,tag:LIVhg5BYgGuH7B7Eyk82Dw==,type:str] -BACKUPS_SECRET=ENC[AES256_GCM,data:eQLNXNHy2Uv6TJt0nn2aZFCa/cyUFQFp490E+sv4+oMY2c0zZ1VpYSuIQCg=,iv:oFQ6xrc6v0z9K0t9m9EU3nXCwsW0lYQNoRbXclnAIFU=,tag:nO+JnRZKD5wBlIoMtyqnuw==,type:str] -DATABASE_URL=ENC[AES256_GCM,data:+uZ8Kkhyxa6KersbX7p6hnqSpp9KcjCEttWRnxDWatf8jr3h+MhyP1SW/6c=,iv:3HQ97+1Gj+7+UjE798q3B4tF9CH7Oi8T4217dgiSygE=,tag:SXFgzJRgDXECYqx1yW6h1w==,type:str] -DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:Q+H2KthVe4xO5RChejW0YU1TgGtJqXW4I7kc+5WXpM40GQ==,iv:jZjvyIZf3p/a4hC2LhqW6eo4wDGPLFgTn4YTWPVSrxI=,tag:WVguZkbwTPQ5kot//hkSew==,type:str] -DOI_LOGIN_ID=ENC[AES256_GCM,data:MnV5UPz2,iv:WILeu6evYxrmctdiCKU9xuuW1E4u7RdFSjG5abdBZZs=,tag:u5gWtVGfeuXrJeqRREMPBg==,type:str] -DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:gNfWFreLj6v6dXP/hxKvD7M7jr0=,iv:VAVh9dBRQP/ggQmKHOLepqDN8+pCmN5kIm+Xe7fpYis=,tag:w4dAIBjRMKUBnoX3FqjtXw==,type:str] -DOI_SUBMISSION_URL=ENC[AES256_GCM,data:BjI1Ebz5t00X3n8PSmjPUk6IaIFgWOqEDDA+SheOHLDNotjabDjUE2k=,iv:FB5IXGUm4c/rdVfzD6S8WnmA7CW8y91YsvDr+EUf1NA=,tag:Q+OwRyQPNaBfNLb21AQVfg==,type:str] -FASTLY_PURGE_TOKEN_DUQDUQ=ENC[AES256_GCM,data:MN2kB/F4ztrOXpuDVV3qDBotky/1suF4NsZbtTmE9gY=,iv:VzvRIdYjX4Tw2op5qTsdLg2l3ABeGQnTRp2EOf4F1S0=,tag:3Ua3WcAWJHVHxyRIfNTDCw==,type:str] -FASTLY_SERVICE_ID_DUQDUQ=ENC[AES256_GCM,data:BpF94SQhZj9+gwlWtGT23VFFQaZBMw==,iv:HRVbeeO7QCYi9wO841TCwS8BZYTvb//3diy9u07RL7c=,tag:EqtpAL5cWLYNulhMupkYOw==,type:str] -FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data: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,iv:gApN9Y2dF+3z6KbQcR5TmRIBO0DK5B907WaPhD9LIuE=,tag:Hl5fty+5t8g221MjMyfCqA==,type:str] -IS_DUQDUQ=ENC[AES256_GCM,data:NQOkmw==,iv:0tqLjo3HE/XZnR6UgZZBFZtn2vq6mj01XgO9j5B0r9s=,tag:dX45cwa5tWLgIzjxVe1Log==,type:str] -JWT_SIGNING_SECRET=ENC[AES256_GCM,data:PjgE+THClW+wcA1gvnT5Ej4sOmIjYJCXtSw7//wpxOaeuKs5Ic9Jt45qFErknu4lL6RtHEHXvQuuxgYEKkVqzpYuH2ckxFXRW/UChWMj/xw1ye8ZHjca1CqY8kHyN5wMGgxAAaNZAuK9IvRGV8DEWdepRR9JKM8f3RwsZSE4mw0/U/ClBbc9NywFFAGotouVPxexqWZgb8K1qA7i59FXYMP8HqBu0Z9XZzSPJ1Jj9lWe0HbRyE5IC5DX4sqCDuC2UdboDRfp5RwpJhMYWkKMf3q+3xccTm/jMuMX1BgybG26r4xgmko13g7aTcznLj0qzzY3OWilk0jZWJ5zIXp7C8282BxnFZmWfE224zi+TEaJBu8aAn/WsZznqp6dHv1PqqhXEKf109/fHKOlxFjQKx4qL4/AwS9rUBWWQZ34Ldm9Y/7F17/Hmo+DMztL9xfRyu3NkzwjOCWKXL+PX5A9AszKELOPkaJxT4MIdtNZp1CLKl3RQtpZ33l7gqb3kJuYA9+WGgya7HN0QX9IbkX9D1YdAV/cA2V9dMd7tb3pvnxybbEODig2eDwxEp0fVAsULwxTF72PRgczGTiza13zCE31NEwqEYEBRE6Nq1UIJSus2aiBocrETDtfvELzvGSTOtlJUOM+ARoZiGyUvg/BpTuNoqEkCB2lYmSiX4b1jrxiE70qgMdbG85Bi+POqcfrg68cI5rk/zIbj08mBw5QTteELNI0EFeTzd013nxYXWkAlpC+OtlaZk/Pi1n8qhqHmkPfSlZVjxL/hqTwSviybwIJDh/cZCPanSpZ+Sn7fgEG8KzaQQHXRVqxXJp/2FRt80dY2v+nX54K664DPMFWgMHlqVQYIDoWn8Yv8NVVlP2lAxAajuOAC0sRRVgbZ6IOM6Gw/MD/u7mG+cxm/cvNZZfhT2kjzotEPqsGOGJk1tBbNuxcosi1sCmFlhZsFm1gjy09kwbRTVF68U/dpfjC+bl2+e7uMWeFcJOOJfI+AczUNu8MI+Perzs1C+P8bCne+27yvNAnPnerXUdpnJyfaRsbD4kOqm89681iMEhJNA2XToK7DTBslgXlLa2YS+Cp2jQAttsoTDKsEfqZIDN11JxXYgRoBjb3usglxGZRSp3pqGv3rrqXyqx4umL77mreb2xdil4kOBySFSKG4l1hHaK/NRJqsiaHX36F1ELFy3P0V+z0hW9QujyWDlt13gymBZalCgiLfawSDplhgULcFFoEQGc/NvFQvuvlo8ZvFycsBlZaam52WXM0BsoDvfGGyunTg27KCjx0+eB2hqsIABTU4qKNIxFFdWCeT325NAO82oO65TgfSglkXRDujjAh/nj3VgSeLU+SGGobM30+Bg==,iv:JHBkoIafd0nzqnzZNYhBx5Y12q94fDdQktC6iW43cyc=,tag:wV01DXrn1ypimNFrzzJLcQ==,type:str] -MAILCHIMP_API_KEY=ENC[AES256_GCM,data:6Co8J9KOFq1Q4qIVk3h8gayLsmP/kJ8Unczd56XjT/lC4Hkl,iv:2sNxyJmFZMigRKzqsaiDM09N/Dank783OE9SxvpUZfU=,tag:n8AzKw/BKoXciipBZkb6VA==,type:str] -MAILGUN_API_KEY=ENC[AES256_GCM,data:+JBNQ4LxSOkZWtB2AQPSDA4juXF5jUKZrTAQo8xGB0boQEW7,iv:AxtIwHYoNnSN9QjgRQNDK85WK+sCyYB+Rlzks106eyg=,tag:G70UlOuspR+BDsx/5r697w==,type:str] -METABASE_SECRET_KEY=ENC[AES256_GCM,data:GW/O7+RQWlC+rBb2A/xIHKyYyrEcmUMy7dxqDC5nQ/UpkzOHK1SOpRvandIa62YivcNNvxwEzAFHu7BASjZK6A==,iv:GPPmRbHHys6I5FGddg/AZsnysFQ3PVqPquupbhiAtng=,tag:XoN4puLeTMwVB4zQ72Qpqw==,type:str] -NODE_ENV=ENC[AES256_GCM,data:7zwIoxkdMtNzBg==,iv:b8n1uKjI71k1dc1kMYJphEQnnefqS6+C8VzGgOldUm0=,tag:rN6nFQjBY4cghk/n0aeBuw==,type:str] -#ENC[AES256_GCM,data:AQh6rlnCeWROveEJsICN/veNdoKD22C++/M=,iv:Sw5zGhSVxTzs0m5aRWoGiPRARwNnCujwdlEN1i9ZMQg=,tag:oSE0CuDZoN3hJNhykICfbA==,type:comment] -#ENC[AES256_GCM,data:189wvfDMP4Wv/4CUSOi/ls4jIZpU7PBTa8mcDYxqnU5N9NhSjq8=,iv:ohTmqGFpcn4cbIos8TTemUwNGzTXFRZHAEo3qf8TqEc=,tag:NrWA7r7wP4MVSwJU8A42Dg==,type:comment] -S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:kinsYfgL36qIqGou0TaalR1HGiWvX/6nCcNe/gYPp9ypSGo=,iv:Nz5Bub7iBiXlo5S5vpiv1v7OrX/2dx3VOcXNSNtigaY=,tag:rpAu5dDTZnveOMMjWCBI+A==,type:str] -S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:4/yI1dtcnuRx+wzb3F+ygBDsKxs=,iv:ybu3IGbI8K98byov4TR9iy3FcG59Yr7EZC5oVHbUpUk=,tag:+pDV21jYmde6KlGa9HfV1w==,type:str] -S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:wEFLWIh6UL+eI9eMsgqad5lI5dppWIzac/E7B8Fa3PUSLQejPQgpeA==,iv:1r3DFRl7i0QPZ7ITr0jhKV08b1ZbnDmnmIw7FCpOkzo=,tag:rIeDFnAM4M4VaoagzalxOQ==,type:str] -S3_BACKUP_BUCKET=ENC[AES256_GCM,data:aJ9K549KzJSlgNs=,iv:Ca9+3SxNXvyd7pvX9ShtGnjPUn8LARnMhOfSaauUAEg=,tag:0sRvNSahQTOsw3Xk9YrOPw==,type:str] -SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:OTK6lJ6IK46n8wXKVLTH5WwKXn47iLIZjEPQasLPW29RfBjOfihuZN2kEf4T8pBwDQh0fGaY6crs1dewF4ama4hXvmg13kWqO9MwSU+NHiuroX4gUZ+Fg8xkBOjnarEe7NMiRMP0BrlntD1LwwcTDV3p/Df6SphgyYfqeXDiJP1O4AosPuv+4egBKqqY2KCy3czc47PcMWapSxWJfMU7KlWggYoSiU3BR3EhbP8cRVZ1tYUttrb0brI28A==,iv:c+h68p57wTshNPaAzd0EiEBVXcAPvBUvgozWJEpZ7n4=,tag:aV3vlkzWJCAp1n6kLXCFNA==,type:str] -SENTRY_ORG=ENC[AES256_GCM,data:m8Qr,iv:RdVMbGg1oRNC6t+Ly27+wbjR7oI6uGYmPgGagNFE3cs=,tag:/xqPyr8e2SZgMw4dM9eRrQ==,type:str] -SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:ius=,iv:SgH1AcWawWQRDHcB0E5aWCfp3UXDSOvCXocgsV1eeKo=,tag:1QfV5Jl9bQvV64Ty+IcNwA==,type:str] -SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:eLJUhmgQ+zOYRHnYKJrp60DQpKR6Li3iOCeJ1VaHWv9IwItuakAEIRbVrC8hp8Q0c02M76a6nlObomuRFSqK/zOMc3aqomg9mCpObYsgQw==,iv:vCCjOU36LdEwEHaA1Rqzqfs0Po3uodY4qJSbbih2BlQ=,tag:er56v92xiIMel1MjqRL/cQ==,type:str] -STITCH_WEBHOOK_URL=ENC[AES256_GCM,data:FFBgY/8j+ZkVDJlx20UsSc7OiR7V4yg38AC6jJWKZXZ/KnRhCjMgsqwgGHwxUBbZ1YgB/ZKLscAyhlGWeF9An9BsFueUJk1v9W+w8IOBpiczjftfC4VEqVWTqpiWT09iGbiiHYZTJKUvdxeNnsStoNAmcN7R,iv:GUY0HRWcMHKlYhVDo/LsXrS0UXMdIuak8bn0gdtAVFs=,tag:Vo5Qvjt9JHpRVbG01s01kw==,type:str] -ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:whlgZOo3vAFqntlSdO+vvrBIws8=,iv:yiBuRlqWtTD7VZuylXFs8c+rDCZbce3N0c3kG8i50Ss=,tag:V7mbPzU7QGJcv98IUc/Mig==,type:str] -ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:oFi5Jd45an5EUeIfbNL2cmcMa2U=,iv:Qda3XG/A/3ilj4Ak0AL2LBSq+N5TClAtIZ15w6//U1I=,tag:5tx1ESB4JOQNGM18L4Y0xA==,type:str] -sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAzZWZlN0NjRG9aZHlKWHVR\nOElRZlVscVFseGRORHZoSEpHS1ViUHpnWFVNCkR5NUU0ZUJlR2VOZHV5aHRJTi9G\nK29CVHhvOUt6OUs2aWNOSG1RNHdzMlUKLS0tIDVHcUF3Z3dzKzhXeHNERUlUb0Na\nUXBaS1doRkx0aVdFWGJVUGFpNlN4aG8K0cjHGDgqdu4DnvrU1QIZAkaMIoZA02aE\nVlURBU9Y4MInhk3xs/9MSxNLaqlOPDu5sCXRI9ATO02fkiWNDIiDRg==\n-----END AGE ENCRYPTED FILE-----\n +AES_ENCRYPTION_KEY=ENC[AES256_GCM,data:QwIZX8X4prK25PkL71h7QgSfTzxi6jrOPrmPZriM1HS+mARLkZS+CsH0L0bCVWU0/RnY9Ul0mtT6W8JrMiENmw==,iv:VZDXJXHEK15m2Aga6qWKw1r9X0/HejZmFD/hySKV9eU=,tag:YtJWe6RgC27XEN1R2Pmt3Q==,type:str] +ALGOLIA_ID=ENC[AES256_GCM,data:LFdkdnTJBkJ6lw==,iv:O18JPjnSZeBlU8ZPbQUz/lz0e46MCeBtRtzvfUf0SFQ=,tag:YB1AML9eiLqUbuBhtPDG5Q==,type:str] +ALGOLIA_KEY=ENC[AES256_GCM,data:9LgXsH/OXSg+8gwTGjwhvikHqWa00Kz7sOSZgBYgZ54=,iv:Puv8Uas8aj/oHl3kEVAfoi1SO73nQqq3BfU7YjPXxPM=,tag:OnFllRwJZdVT8qBl9XhR4w==,type:str] +ALGOLIA_SEARCH_KEY=ENC[AES256_GCM,data:etwRyHHPvUcPAirfMBFl4/RcPNMAHoTj35HG3RZRZXQ=,iv:mV8xbHxdZvqKznqnOTnApd2eu2j3FVnvhR0CkO/rTrs=,tag:iuEM8tkQZ8S8r5JH44iHvA==,type:str] +AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:ojDBZhAQ+y27AqpYacJtWxFrU3s=,iv:uWRxeAM83iwkVR2hiRoGKs7gFXJqoHAjUOgwH58gTQo=,tag:Eh22ZHk6PxoFAOMRwAEZBw==,type:str] +AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:Q5o3MLWBhafVt8N0qv4j5qAod5w=,iv:CUa4NoqfCI9UI6G9rqF7rLvZ48O2n2Yy9VmIzN08pGc=,tag:J4LoI56Ak4M+KPSDLn0iAQ==,type:str] +AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:+u2Nwl9ycTYvA75Qd/UxeT0Ve0JU2BAbpO9GrZy2iwkV+/rNw3T1TA==,iv:EkyKV2vy/SzNaGQ5Oisfywz0dnF6Vrt14RINFs1Rewc=,tag:QpPRGK/1/fzhTEPS0ZipLA==,type:str] +AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:QnDJMxc2tEaQJSgNIY8D4moZ38o3oZLSxJHMuQgvu1kMO+AgrWKUjw==,iv:HCoq5iDtFfwttcUQI1iyUOSopozZpjeLlbXvBr8aYAo=,tag:b8grQVQ4NHG/zvSkTeE8Ug==,type:str] +BACKUPS_SECRET=ENC[AES256_GCM,data:shNTuKUSpUr3PgbvLUgqqd/yANmz8UcsiS3dhrB5BSYVJ4SuWnzzOS863+k=,iv:dn2UsBtL22BolPl7y9cg+mVHiQiR24PnT1giPBM0bZI=,tag:jmC2ATslqzV3geLRXNdFXQ==,type:str] +CLOUDFLARE_ANALYTICS_API_TOKEN=ENC[AES256_GCM,data:SGsNasbiVnbBhDlHQW53q00lPSYidNK9/FfYorR7BJmqRcSWCYoZpKmuem++LCLtfbQ95g8=,iv:hl7lgEobh/WPp0XqI09dp3E4GEjjPDt91g0JK6Qm7Qo=,tag:+pIX5QGfSQJpl1Yj7aZx7Q==,type:str] +CLOUDFLARE_ZONE_TAG=ENC[AES256_GCM,data:Vy6jGSJX2QMVWg71RD6dzREpTLWXnKlGeP0CT9K2KB4=,iv:anwDOF0sl+plFJefNm7swlmHpPFfPkQDBDysEX/55w0=,tag:KJrzy4VHx96jETzyx1pw7Q==,type:str] +DATABASE_URL=ENC[AES256_GCM,data:d9IYhTltxN6k4EuVP9hfvJhYqBcsRW04XoJDpPSYLGtcMGNvqKwL7whD6jA=,iv:0XJOEdBAsrBmCkElxPbOX/kzWCzLxQRwAOezVKHUB+A=,tag:ZbwKfyIdCf39LMb1Vkt58Q==,type:str] +DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:poD5QWfdh/kPOiNMzZdJWWaJ8w1p/aG9xA4zu7Xm2ofEsQ==,iv:NanbyvJ7IpSO5JjJbEaQvgVkiKt5GU8w4knZS7sn5fc=,tag:WLGMLnT9dUWun8IJQG1Sew==,type:str] +DOI_LOGIN_ID=ENC[AES256_GCM,data:/TScbpSP,iv:MWZ1nCD43O2lFpWj2w0MJkkkX/WcwywaYwGaA66pppo=,tag:Kbf8dccofZhTooD6jU0lYA==,type:str] +DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:FMCoA4s1709MnRWGpjaIyNhxYhw=,iv:WRLN4pC2DlRPWk2wHQN1jkWWSDR63TBlLoIOWQkQotI=,tag:VdecgaxWBNEKa4BXneP0Eg==,type:str] +DOI_SUBMISSION_URL=ENC[AES256_GCM,data:EB9oz9jb96SwiCcH/m0va9lFQ/LEJs155K3thqCrDEfCmdahjwzfrzg=,iv:WZDN3jxWyy/AO/BA0jZq/0xWX5e8XLnyB+xHqHFSrlU=,tag:yOpHh4pfkSoJrQ050GCMYw==,type:str] +FASTLY_PURGE_TOKEN_DUQDUQ=ENC[AES256_GCM,data:2vSXaeqJKThevWutefRuLf6HEsAca2UMVirzkEjLZcQ=,iv:lyZruIZvw6870bdtb5uYyZZWPaxGeahWLKznBXM4+y0=,tag:SaJ7gYPIIoLeO97K56V2Rg==,type:str] +FASTLY_SERVICE_ID_DUQDUQ=ENC[AES256_GCM,data:OtLTPCYI/Nni11+bYgxF9G+oIY1auQ==,iv:fyhp7pFod3PY59lqhBccAkt0NuAE5ZyMCiyBiSbTAdE=,tag:N5LObQVbbw94BuWi1cR2rg==,type:str] +FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data: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,iv:lECKfIfdMikTO1b98fqJz28G4CHJjYwKvaEZ4h2bcEw=,tag:DjScRvE8w7XbW46nZZT3gw==,type:str] +IS_DUQDUQ=ENC[AES256_GCM,data:YjPwEA==,iv:zsSPad1f5sbg73RRjhT81GGMNUr2Ua/NFdME65i2rIE=,tag:ooJrJwbpgSyvc4fAZBunDA==,type:str] +JWT_SIGNING_SECRET=ENC[AES256_GCM,data:Wv+bQx9NgAqlX/pJKvs5LeDirgYkdKZmHWZBxzxK7aisYEkOngJmK0ktMfEwqdY85/AcQROXaoytiihmNZFvzxaXHv1rpXcjsupUsdfGzrn705XxZUsq58r/AvQlKNNCvsvgQpVF8cT2gYnPp1PCtV5o1phd+b45qmX95g66im+7SZFWbnay02bTEl2Cnb+upnbCUOog0GcGfiqnu2FT7tAKBNFIefpV66ajcVH3+cPzDtNuKKbF/Kq0VS61uLHEEXYYDMspwmQpnCgyKUmOuRa6rcosbXPVCnWEjmYrZlnsKdaNfN07Kx2KzrhiJAi08a6cFV9qxzTeaOBvuQx5abZ5yjTARKFQ5Wt3smy9AjJNnYl1uqnJU87ueFfX7br+7bza4LOe+5DytI9MdCmi9kq2/JNPrEHd7hYljbsNYJmCAN1nKypxdtc7UEZOvAl4asqtW3KZkHNpKK9hZKswrCg6YdyICvEndQlxFe7lM5FSMMYLS0kTJCOicng6LVb2J0EaNaTPYG3G7w6XKXk19Jzec6RoQkOHLbNJ1E0MTRAd66S0jQZnYjF0Kw3OgidX1E4ZTFPeMrYwvH7kGdsPT9aSA02ANgV711Yfb1kz3BxlxZQgWAdKsqpukavDPMsx7OrcQjUw/bq6LWdaQ0IBrcSgdlK43hvpUEANIzEta9D6H3zCBZSw+4woI6a+ZWL4TmrT1MUjdWQMb+h7n8huBFBYkYPShKcu40mCSmB9lxJFsfeEE4h0KwRz8Wky0ldjxqCtgcVvCRgFkYuMN4Q8DFLoGUmzfUDfzFmlGcW399av9B4fR2uQeSbY49uYAkOHiOQgyW9rn7YM0K/cu4+/kTxXiPIx7Gd9zxBbFsgYFUuYP5C9m/8Q8gKyrNFOavbALppzuHnIq7Ol4aheHwRO9qOdsUIq78WAwtg9vlPEBgzh3IV59VahGT7vK2lzvSwn3aO/pP5WaS4+X5tg/mzKey6W55BrGk4nHhj+Qyn5G9ZoDCuAO/806k+MEzDM+1UnPk14lvm8stMIYRw7uGn7b3gNwoTCqZEtkO5I2kw7+4AwAFsVLOzQjzeXu5thquLrlKpKY9YUTYUwoje1D2vFm3SaA+AvSvmyfqZn8WfKQfC3TIkmENrAY9PMBx9FbFFrSrjRpSADvDF9sMgiVs64/9SB/jnJlR1uq8lnOJoVR4gd1UV3sYljULfLE8QWkIx9U1mOW/4T+kmurvEwxBgAFNSFdPdi/AzIBqFRDFtx/tsTT3PQ9xwCCOK1HWWKZzMAijJpInJe4+Zv031Ef0jisifF3jLefH19nAF776eVhXdZbRJgnr8OsX9E33bhkacsSI63STJDak4PiBVpyfTa4A==,iv:4AB+Gy3o2YjSzpcWf4NlYb+hVzEBJkHVkIEe9AkEnTY=,tag:YY7l8sVvMWvzoG6tt7m8KA==,type:str] +MAILCHIMP_API_KEY=ENC[AES256_GCM,data:Hni3CZSMCAw1Ko3L0QAxmTdyxE2fyGjz6uPf2MDO3p134SZb,iv:eg34UYvFQ6GikDk/3/HDIU7KP6agV1CK5sq9y3yz78M=,tag:Fh+nd2Op5Cq/p/nb+ow9zA==,type:str] +MAILGUN_API_KEY=ENC[AES256_GCM,data:SyyJoOncFXFF2aKmfV827hDCdv5Zr2sSXJiSszSGIW784lQ9,iv:corJ7R8AbJXk8ipaI7O/q5LnsDZfT/erIIPgJsWehNA=,tag:X/fBZgjnDvLKJ4/7TSvy0g==,type:str] +METABASE_SECRET_KEY=ENC[AES256_GCM,data:rrtTqdChIjTs4lrCrV7IwaZaCLG6AoJvglNZWiLAKmo4xrwlohAlNV4hBp1tloq0Mpyw5m2xl4Dd3h8A7hxzew==,iv:FVBVNeTJbhF+E+rpbgXDBS0CCniam24qzr3CKY7pz10=,tag:R7PbxajT0uIJJ/r6ZeOfbw==,type:str] +NODE_ENV=ENC[AES256_GCM,data:+eUy9Aj12c95lg==,iv:ccaeTWNgHUjVPdWBGut4XC6XAVYypqT2EhEU1f3LiK0=,tag:nJcq9H/CcKiRHB93s+ixGw==,type:str] +#ENC[AES256_GCM,data:x86kxjwXzrjAoX9V+4AIRIk+QbAO5UPGpZc=,iv:S2aq8qkVD9VnWyZpfYq+lVoxjFgcWm0AngLj3Nxbf58=,tag:mxS3SBeP8RqFZ3GP+KLv6g==,type:comment] +#ENC[AES256_GCM,data:zJiGD91h0y8+SSRtayvj655B4/opVIFNlKS3fxTVrmVt3ptcYHE=,iv:IJcRp3bvZA3cmdO50jdl7D4RGQXRubnVBf+70cm/Re0=,tag:nS46w09X4ANQQGpCKag7PQ==,type:comment] +S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:bS20RsEE6SXJ/gD0UYRb1tLFzvTc7oc+VUB+rYGeKyMSLvY=,iv:bGDPWDsvphCwax/vwwrmrpeWOZet3o8RmaoX3KFxfbw=,tag:gyoTaOAr+z9IhkWhvmSTew==,type:str] +S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:HMAq2zxdbPKwCifMpnqt0xCTKiU=,iv:vfx8Gzf/5KfFUqMeNDNRxdIZSi+Rxxqm5397ziVmhVI=,tag:mWg2KveS+HKpPh+LhcEVBw==,type:str] +S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:OYeD5BX6pVGOT2L8u7r8Z8dbv3AYqS2mvZ7B6NqJld2f9mMTpTmZGQ==,iv:MrViCuxr7VDn89zeigwWzyhJZ0hWUd2Q/VB56t5/9B4=,tag:lUrJxATW+PWeeeAC7Po+1g==,type:str] +S3_BACKUP_BUCKET=ENC[AES256_GCM,data:AlixUPNBxfAovcg=,iv:KuOXNH8ixFIinRWsHZKHqvyq/TuLRUC8Fc2SWCDxFxY=,tag:SDjLkobA0aqKp0DS/jJUQQ==,type:str] +SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:N1hLCzeQjMbPzn0SSAo0vT+4FA6BihKM7x6uPJDVkK1SvnKcwdjKXH0Y4GxKPI7U39EWK8cbx9PGmk1GeCDgSzkq83tNRD21e88veFnEKP02F8efLnfR0j4NM3Kbg9J8g4DT4Yp1fOvaQ6Twli6+stWFYxde5ziIzrnbUOrMvdAUZPHvbFOvZiJJtYR77pyAR9/dbYMDrVHnCydt9oRmYRsJJbbNiNbrC2ATTQru1kmI1oMcdqLY6iQx7Q==,iv:A1nqc4legVPc+0Gl9RWCaWGMm5/R5yUucrUawEDWTNQ=,tag:GGgNa22SIxa4AcBKFv/89w==,type:str] +SENTRY_ORG=ENC[AES256_GCM,data:GFdm,iv:/ewODHXse8BOGqSumC8ZGIC0chmiyu2b7UfzRBduc7k=,tag:/MRFSdMJDanUqWO+hKxZng==,type:str] +SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:61s=,iv:mr3svYNDAD60QHqUvps165FUbJoU7reh8AvERwRIzDw=,tag:zwOLOx7JrVe3YoYa8FJ8ow==,type:str] +SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:IZGLpb0MwS2hIKiiSNW6zGYPkSXqxRmrBVWOZCMhK81prXVy6Xa43zHZnkFgPw8euE0tOnwPl/Qwqt+7It3UQXJLKfV8Xo8rSmoPG9w/RA==,iv:3YvSRMDG6REfBeKNFYQWtYLk89S9ewUtkWVzRsJa4Q4=,tag:Nj9UWPdP/r+/RZgAwpbK3Q==,type:str] +STITCH_WEBHOOK_URL=ENC[AES256_GCM,data:lsLU8jzBMTLt/UawVWgUjal1YQJWYACXrNCc1vrhqiLgHFzfoGNgttWP8lVK6kBoAloJBcYg8eUxdoMQM5+tq+WNk/iZ8DEri8yIewZSt+NL64/3VvHSsPBw0nbY8HUR1Fm+iYE7sujNx6mA1E6nDhdzIcHl,iv:w6pmAV0gvjX8saQHlEPgk572gPJ6xnPwVnazijFjccE=,tag:1KKKipxmDv7+sKSTco7S2Q==,type:str] +ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:Y9mSpaxJbrISeMNMsUOqVBLOn78=,iv:pGwjaAaeaaQL99edzjt4XvSfP2GrbsVOF1U4zsU8lI0=,tag:lwbndzd9SQvJ1Uw/TqVVog==,type:str] +ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:twNvhGqTnp1uLflUUPsWyFeXEE4=,iv:t6x/BvikRLoG3YJwZtQ2WDmmO5A7SSmRSj+bNeNjFCA=,tag:6O0XkKaPepDarB1bkxZBNw==,type:str] +sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB4R0tWbGk0WGJOZnRSYW44\ncmdGc2xlWmJnblY1dE9kR3Y2cTJjNlhrN2dBCmpSZzBiTUlGMnVnVWlTYUM2MkRu\nZEM5SWV2emVHb04venMvd1owTDNnbncKLS0tIC94SGNOY2NOYm9ha0d0YlVRb3dw\nanhEUTNuZ01vcjRBUHNyZFhYdGhJSmsKPUlgyLqiDrSKJ5LivHiHeoIlr43G5LTA\n1ca/d6ifM95xgULFcB7sZkwoHbeoxxJW9/AdrQB5pee01OnLSB0Xrg==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_0__map_recipient=age1wravpjmed26772xfjhawmnsnc4933htapg6y5xseqml0jdv8z9hqemzhcr -sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAvT3RxWDBqTS9LZ2NDWnND\nZ0p1dkxTNHZ3S1dLaTNPK0Jsd3h3L2xmcDFjCmpoNlgzWU50Tk5DWDc5NWtnTW5X\nZ0VMVzEremEwdWFDL1FDUjBKeUdnRjgKLS0tIFJEeXFqMnlMNzk4cjN3bHBiVm8z\nbVgzdmtaVTdzelRJWXRWQ3VnUllvVEkK7+wFnHcWlQ578ZBdYdEfbstSSUHyftzm\no6E9oYEqwH+oxftQN5E2nVQ2QxwdsXKlnThkMJgVxH3ncgfMSUt5zg==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBCQ3VENHdqNmVZVkd1Nnl3\nZ0tvUndoSmZXWVloTHJVRjNYT1htKzcyRWx3CmovV3g1MktwQUI0OTA4aUdmL2JL\nZFd2bmZpcFd5d0MyRjdZUmhZTko0a2cKLS0tIGN6bmhqOWhkM0sxSXlweWpPMUhY\nbXV4RGU4NWRzRGV1eGxaODc1SGVYNzQKK5nbnkgNTGcS6pkrIxydTeuLlmD4pbdo\nKHfVnYvOh2mCzwWaNbRitgvpBE+xFcl2iB98rBR2YeV3K9kCmA56og==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_1__map_recipient=age1vhftscteyrwphx0jpp0yl60xxrjs77jq05mkzv88js7ckc926vcqepp2cj -sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA4eFhnQkJVaVFiWmdzYTd6\ncXduWCtZdG42dTU3VVJ4cEhlOEhpRndSN0JBCkdTWWhFQnFCTzBOalM1S3RTbDFo\naHBGdDdUcFB2M3o1R25VcTJ5YzZ1YnMKLS0tIDFnVnhzcVRtZmJZSmdxSE5JSGFK\nVkRRZWRPMi92VFpUNldYZ0MxU0lTSlEKh6gcbf04oOIrmLzfoK/0wagfzxDh/DSb\nQCRvyhkY4cFQgO1fn6fU4UXdOq8Lp0rXPEuaK15L7hq3q3hEo74O3A==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBMTUozS290K2NLdGNFQ2ZV\nL1A3OURkWkkvZzdLR2dwZCtNMkwxSENCb3lNCkFwVlEwZWNSeis2dDlzUUpseDhq\nSWtwT3owMXFaTVRiM0FwOXZ4SktBYWMKLS0tIGlKeEpKakJTNEtFZDR4U1hSc0I3\nWXg2UFJIRVRJeU1BaUNHaXV3aWVIVVEK/8vGKWaoOz0Tabq1KS3PZiAaBbdHdfJX\nKDm4iUpXqBFd7xALNKbRDDNY0AbsgTHebS+8QQQM01o/Lf36AFZSoQ==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_2__map_recipient=age1slx6e48k7fre0ddyu7dtm2wwcqaywn5ke2mkngym3rpazxwvvuyq9qjknk -sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA2T1gvVzZKQkx0NkJmek9i\nUmtWUXN6bGt0WVM4V0NIN0pZdzMxNXpUZGtjCk4zRVNRMzlraFNyQjU4Qkl5WkpQ\nbDcxQ3c4cXFtYnBFd3hmM2owNVZaVjgKLS0tIHpVbGlLbDFzZFhHVGlFRU12c2tC\nYktGSm9JRVlCUmxFOURHeENVU1UzMDAKbUeckn/3XgXyPFn/W4Ha0ayo2v5wVMQb\nNrsjhYQFn9cdG8H8hqeGh/yE1KLfIwzI8U/HSXlYs/NtsvH3h5qUPQ==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBlOHJNWVRDb1FqMTh0ZU1W\nN1pkRDhBZTJpTE5kazRBU0RMMW14aEgvTGljCjZVUllGN1VXUit6d0t4Nm1jRU9G\nMkF0ckxhVCtrb1BjeTBiUFJzWHBwaDQKLS0tIHcrVmZ4VlRGNkhYc2k1MVUzenQ2\nRjBIQzYzOGxXZmxwMGtJNnlxd1o3dzQK+QHtZU6x6U74tFWysjjRwFYGZvUjAB7I\nzRHc6qH4zJ7T5H7AyA6IJlauoY9ChQT9lRughZRgcpq5nHvPfjf25A==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_3__map_recipient=age1vfhyk6wmt993dezz5wjf6n3ynkd6xptv2dr0qdl6kmttev9lh5dsfjfs3h -sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBQWkY5YkJiTUVxQXFES1Q4\nYkY5UXp6N0YxaVZILzFkZGMvaEZ3TU1XVkVBCmV1dVhCQU4yZU10MnFhUGxVTEQ3\nNjFOeGlHdGRBQ003WHZGMHJzbk9zZU0KLS0tIFBCb0NRY3UrazRzM3g0TEw3MUhn\nTThUbzdFZkJONGNYVTF5QitLaTV6cW8KR/S0wl3+auYy9Ag0tLckJ2Xhy92e+s47\nm0lLrUGvjLYSGdA9Ox3KS2nmem+RQp0RCjTzErDlsY7X5Ai7duCJRA==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBwR2xvUlFyckxYVjk0alFF\nVnRuUFg1aG5zVHNUWmc2N3VkQkxDeVhtK1hZCjdZQ2xKVzlnMjV5V0Rka1ZlSTZx\nWFlrMTM1bmVURTBKeEl3Z2pacGE4S00KLS0tIERxYUpzZEtJSkVYTlpPR0cvQ1d4\naTUreDNsWGNyNWhZSkNOcWpnd0FOYTQK69rYFQ7g/cFPUQf+4sIbKTkE6UKG5t8N\nbQwQ7C7yqBd/JGq4bUd95n6tUACvWZJZb6PjkdpDnA5Z5z04LOCI5w==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_4__map_recipient=age1jwuvzyghfer7rx3qtqa4vs0gxyff0sg6pqgmqvp5zlhnpmvrkdlsdha4kx -sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA1RWdBT2pqd0w3UHhtMzU3\nYlU2Z2ZsWUlCd3lIZkNMQmV3aElYTHBLNURVCkloRmZHRTM5T0MxVWxjQklRVDJx\ncDN3SnM3ZFBYWk9LdXFEQmQ2ZGFTd3cKLS0tIEs0SHhEQTdRdWp3MkdNa25mK1Zz\ncEU4ME9sMFVXT0NtMFNoVnZnOURmd3cKii8ocexy9c0xfxPaV5FtBWlWy9KsaIEh\nMpH3eJTuAK0ElMjFrrI2AvjuW3OYp3WQU7ZnqI6ubZvi8mW7iZH51Q==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAvL1FJMTN3VDZKakhiY05U\nSjB5ZnR3d2hjNWR2MC9VZTFrbWNCbTVEdUFFCnNxWXFVYmtkNjFxWm54YXJOWnR0\nQVFhN2JGRmlFdVlEajRJdWVhZFk4RDgKLS0tIGNXc1ZYZnFQMFZTV21lQkZreHUz\naGpSTlJRdWE0aXNJV21wTG5Uakp5Mk0KnLJe5q2TO+JOaoqKBqMiDsZfZJiGQv4+\n9T1XiTPId/FbEPW7ClBTy4IsOLcxH16JPxg2h5bxHnip9+eMJhO4yQ==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_5__map_recipient=age1pgxk292zq30wafwg03gge7hu5dlu3h7yfldp2y8kqekfaljjky7s752uwy -sops_lastmodified=2026-03-31T02:48:03Z -sops_mac=ENC[AES256_GCM,data:PtfB+K2c3gueL/a8MnWPsaO5XL/+uk9vgilMCAGHRW53R0ilHzWujyuNTtjsFGbReRP+SXbU0hllj3RkxCQtwZKouci2Qn9PmAKEonhRLjdYl5owSJsgSaXTL97wUS0hAZrCeYYAlLLPNYgP3XE7DqtFx9mY41vcn0ViyK0MPx8=,iv:F09inZuqJ1oRn8IUQE1noenfjdsyMNBkqtW37Z4qogs=,tag:M4Tc4+MD3zl/YZ+VcUxP2w==,type:str] +sops_lastmodified=2026-04-02T03:37:47Z +sops_mac=ENC[AES256_GCM,data:Hk0oh/ZzGjkcf5vBV9s98p09rzF3r7jTj0UjpYPDYOKjN9zuSTPx36RziF339emo112xUEPCAvpudUgLYpV7VxkuKYXl0UeghYNpKw3NyBYGrE6SMWwYBJNJXHCdl4nFAZD7fbc0bD42nzq7HpEKOmqLzl6P/V0kWItk9+diiHc=,iv:onY4fMd1xxlF4IUQ73ONawi4/MAuRmN43EsDtjgEA5k=,tag:UKplSAOZre74TMLl1x7UBg==,type:str] sops_unencrypted_suffix=_unencrypted sops_version=3.11.0 diff --git a/infra/.env.enc b/infra/.env.enc index 1be76f8416..9c3a86747b 100644 --- a/infra/.env.enc +++ b/infra/.env.enc @@ -1,55 +1,57 @@ -AES_ENCRYPTION_KEY=ENC[AES256_GCM,data:vbymPb9pIX3Ar73KrjW62lYM/8A8fWoh7MnJA3IUAusQ6+MpOXNpL5k8eTu7ieGwiV7gY90MnQAra5upymG3Jg==,iv:xY8Lh00p9lcIT9B9LCARxQihN+GrivS7C2QlVKt1J2w=,tag:RDwybIdDYRk7Ecx/5L5vDA==,type:str] -ALGOLIA_ID=ENC[AES256_GCM,data:T9BnAyvLkom50Q==,iv:hntMtsqHiA+1V6D26KA756wRBm1BkFl5AuopGhLhUT8=,tag:GblUMhdJJG+VrGCniCq3zQ==,type:str] -ALGOLIA_KEY=ENC[AES256_GCM,data:72FxZOZkqzMIdbvYpO1yEZFyG0YZY7rVxJjuDPgcGD4=,iv:v5QGHSSljTYWL0/7djTGtPDP/VPGQeq0zPzDHrIfE7k=,tag:QmtvW6czoCHf0QuqZzyOug==,type:str] -ALGOLIA_SEARCH_KEY=ENC[AES256_GCM,data:PhxMicSMXQSqsFVb1K2Z3ByLwpZROrob50lICyVokto=,iv:+BjJtiUur6YEiP3BxNi34wyPw7D5hVZoXYZVs6CtL8g=,tag:78je9z1AKBKwbJbEs7aKbQ==,type:str] -ALTCHA_HMAC_KEY=ENC[AES256_GCM,data:T+llsxSjDyEyDPzQ5ocRshwFLTeqmG/BsQd1P/aTYxd+3IsWbKuDJzuyikAAQozpahKkJZefeN2nmdFhNJlrPPE7xXXvTn2Mm+gnMRJFb5X2H6/OGua+4Ds/wcREkKhCXJJ8PrUgwR6UNagqfvfa5id34ULX3Q4ZyIUCUnE3EtQ=,iv:/Zt+E7wtlGwiMqwIVOgNT8AH74JciranTzaaPi+5esY=,tag:E6LPkAGz+odAlMMsmRbHIg==,type:str] -AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:lZO5lg0B0KwZZqcNsVkLYkQN+xc=,iv:gZiZvft7AAcfl1NEXbrkB0FSvb7ncYZ9BQ7TWK2IyLw=,tag:XdtmjbCznNYvh1UfJrgbdg==,type:str] -AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:4VovW11aEAmALfR0GJzQTUqhPoM=,iv:alQBBxvedCD10OPIf8/J/VRriY38LCQDtHkkzHfdxvc=,tag:RK5YxQOPb0A1MVbLK3ho/g==,type:str] -AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:FZn5un+lcsDlePnkGlMTH6lUjncFMYjzl7aV4xyJMtCHhGHx9OOrMg==,iv:zQR6Z1HxiNJAv/sN3/A3UjRr33wbdNZtCIB6EWj0yNM=,tag:Uk7gvphLvDeXACoCzmryjQ==,type:str] -AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:YZb27G49IssZyhiQJIc596Obf2H1g96Nyh4mXr5rNhHypTUSMYCH8w==,iv:r7aMjD/RZBUjTP5q2rsNOlSt6+ncKgfDuBL4OX39Q7A=,tag:t0Q+Wb9Zq8aDiXRTFdS+SQ==,type:str] -BACKUPS_SECRET=ENC[AES256_GCM,data:4BDHdFZ4+N1bdc2Ae/BO8gUIqcBozcAAW8btH6zbkcTViLq0FVa7jqdfK48=,iv:3f9W35UnnR8KDxTQ1nZAuhMX+9ByCIKJctjjsJ5HvDg=,tag:1xWFZOBVZnK1RQxK3BQ4zA==,type:str] -BLOCKLIST_IP_ADDRESSES=ENC[AES256_GCM,data:hyphmo+BecobINiBmCOtn1uiM91/,iv:5Mz9NiC3siH/ne5a0oomvFdJKsXODXuP9C6gx4kyHIs=,tag:oGRKbNCP+/8S6mEzyccF6Q==,type:str] -DATABASE_URL=ENC[AES256_GCM,data:VlGdDtXbuz4Z03la2CGzYPgaWd9jNjbnZL0Hp7nntg/wsThrxsI5gJcxjNw=,iv:1neCIDqq0sdaaP/5YGi9CDh5bppHCqMeTtMcUwbQ8kg=,tag:8oNLRkD2QccR8a2EBNntjQ==,type:str] -DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:Ffm/UaNqtwglqDY4pJbrVSvbxoV8AS2hpL5/0NI=,iv:rnCV7miKSbOwpDlKpOr076gMhnas/MhZz+YqOSNrzls=,tag:UR3UiN3wZDv3dKyE/A8sig==,type:str] -NEW_ACCOUNT_LINK_COMMENT_WINDOW_MINUTES=ENC[AES256_GCM,data:KiA=,iv:JyFPxlkDeW6mbwFKmIb1V1RmQ79L1NrvJwg8F+ZoDjc=,tag:X7a/MrnxM6Rx41Gj0fxaQg==,type:str] -DOI_LOGIN_ID=ENC[AES256_GCM,data:rnyWq5KO,iv:ES5zyrlIYq+YiM76fbQkQWOkRlHCfCJfvJKFkkvbe6k=,tag:SbakzlP6bPGata6MxQPN6Q==,type:str] -DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:LQKtRZYGCzbIYHTXou+Ajpk8VMk=,iv:GgF8FClckpq+ZzfzxQKxTz9LXXblxIie5JeBM3d9kk0=,tag:I1gRtcQevT63dUQrWeCtUg==,type:str] -DOI_SUBMISSION_URL=ENC[AES256_GCM,data:FIeG9pi8ABy0OOS15akayMmbyVDu5yS0WHZk18Zf/QQ2In5bsC52nQ==,iv:XPADr6CQ4Wq1SOlDoGgrbVxrVM941/jtS49uNW7rQfs=,tag:amR+BxRepiiTrjPxIvpZFQ==,type:str] -FASTLY_PURGE_TOKEN_PROD=ENC[AES256_GCM,data:tEeV4a6MslOGAHCO8O9exE2OR+FWI7/P/DEZr1JQEtI=,iv:rs2G/2JHcjYej7XubvvazBuysfGvcR3JqIT1KHYdaqo=,tag:i5vbABs45IDWLZA6stIZKw==,type:str] -FASTLY_SERVICE_ID_PROD=ENC[AES256_GCM,data:r7i2ROh2K0SPmvjdg7S9fRLewlZmiw==,iv:tyvLY7zHaHM7g/7RJt0WP/k/iNeFIu8wCaZeGkWDFak=,tag:+S9pjgNt1xhmxujbKIEOUA==,type:str] -FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data: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,iv:hscfsdG8NtRkfYYmGDxtiAlkbac2RTyBsKTkl2jr/m8=,tag:bcuZAcwY7JRrysMC9uFu/A==,type:str] -JWT_SIGNING_SECRET=ENC[AES256_GCM,data:7IwVrNGrGdp2uECsH3QOtZ/OvskoO/LGCs/XHVbwMRVtPPo4ydbHxvmxbQiDzk+Q0GHv0sKMSbsd8B3ybYYzEU5jWp4qL8uQnqYqT8UaPVoPqgfXiaugUYLlWN+GVqTyfgOKQFTdyfcr4EGJ3e6njmaZ1rMF66AJyhJPAKHSqi7bgNWqOECjNISV1SZL9vVgo7L3xuwu9t2tUqTE95y5wWlz7XQvOjDCBJB0nuLd3ztfzI6qwMkzkg2nb18tHDTcPDItrA4t+rEcpC7ZeAhjE38536k3WcU60kCXeXnlzn4AP/2boGLGPfYC/qxVJ4fAXVf9WFmmjuhjGNMP4NqcsqufgpwgmxEVJpLC6ZFb0QFNjtPZK+SnLd/qJUzyJVpuIRUdCjK/V587dVBMh53V0Kuc2q88fZKA2cWQtjihJUUyyGayFoBIkgEi5aVmYX04w7szcaEFaCPDphKbV57UZstfrO8oYXAIDLVH4pGBSD97RiIvJkM7u0gItYzk/n3C/8L28g8/Fm1MVTvF+PsEnpb3F1q49fch9/y7ffyqb+i1JdlGdlDKUyVKScs9Ead8BuVe6VyDHxSEUJnNhmEAftTPyLXf/VLUlEtAxNAfsLDZ3tsb8/kZhp27elwvpCLmYZez+zCBnF6m2iCfqDWrextk+Iu4yghzt86g6Qkimqo=,iv:YcUxqeIcGA1lk45lVLThieXDtcfL71tJnmxfGUyut2k=,tag:1sxWXaALcISDV6zhXfafzA==,type:str] -MAILCHIMP_API_KEY=ENC[AES256_GCM,data:KQhcMqowStnITI9nwlMYy4tdRb4HfyEOf429vgMCvh4DNtza,iv:ANcTeRiOVQRPWYADhSuSr4Iec0wD7LPmR2uJkfSaEV4=,tag:Ys18nnsHrPzeFmXirt4/QQ==,type:str] -MAILGUN_API_KEY=ENC[AES256_GCM,data:qZ/mvlCYsb09CUd6oAxausNEYiWsrxvQgFyAWuqmuvnHDZHn,iv:1n4ndvLhR/ZsZG5id4Zc8rThI87gN8NpK+zdguLJ2Pw=,tag:Q5gZsNDyMqjclOv+xWfP9A==,type:str] -METABASE_SECRET_KEY=ENC[AES256_GCM,data:NYstQY07BV9I8vS7bNQ/Cc0MKh0IasFWGldiG+PIzLpg1sicBORDLZlJjD2/9d8v+pZl7d9f8ETjMSJDK2JTQg==,iv:V665xBdAyDoJISCKCGlr3fHz0ukkGPeIFciu4l0/ykI=,tag:10lk2XHC502P2zmOhIMzqw==,type:str] -NODE_ENV=ENC[AES256_GCM,data:d8aLeE89dNwCcw==,iv:O0Hiqq/LRysBJpT5a598UJq4VW37OHf4OBcB7vYWga8=,tag:nlRerWMVGSrBO6ir8SxD5w==,type:str] -PUBPUB_PRODUCTION=ENC[AES256_GCM,data:+YMqvw==,iv:8BX5vv/f5+joMxH5IbRmps+jy1a/PGlWhdocif+LACU=,tag:jPQwqLcBtVrzxgtuX7KZFQ==,type:str] -#ENC[AES256_GCM,data:a1UATWoyeGcPNbqZDI/5t4ZeGHJ4lQGxIw==,iv:gfIL58YSIUuJ2Dd35HWTKqdKHDWlHFxb22etNoqbXzE=,tag:eQeJq8FzglAtgcxTw6GJHw==,type:comment] -#ENC[AES256_GCM,data:2xM1MTnAs4v2RlHq5XcEe1UNIIhV+JCTvdBAGDzH6EUvZEIh0w==,iv:sr9KzmZsbhzXZ2VEA6jeP9LP23JVxv+EwrQQcwtG7Wc=,tag:hB8YG7IuE/syrIylMkDq+Q==,type:comment] -S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:vtIbmGq13DPp6xmmPnoLZ9yNa08X4mGaa+10yG8xfvXzgKc=,iv:8AG4A52LWFlJ0ENL/iusIM5I6kBBTnYQjpPZrvKWPQY=,tag:tw0Fm3VItc0SduiVfZS2OQ==,type:str] -S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:2Th8rzoPAvzCYE9Wuqb8dT7x8Uk=,iv:bBspSn1lZyl9fXwni8kHBXF3HNqT4N24Q0qWCJJrfkI=,tag:aL3JNfqt3z9EtS4e7a6Kuw==,type:str] -S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:Kn+bvOY060HrZ5iKt8LX9FAxUTqE3lsmFQhlX0CV9HOHL1h8JN+i+A==,iv:1EBNgDsy/kAvUJqxFwTVmH3YLlVWb9yyVwhNqaIYpBM=,tag:d1+up1HVO2YbM5UhFRtn3w==,type:str] -S3_BACKUP_BUCKET=ENC[AES256_GCM,data:Q6+2yr3yNTDohG8=,iv:HO7haOuCSn6wqL2n9PZd+wV+yGue1GtZZlu32zvIzKc=,tag:XgrLlDcIfENqw4ILP9pSbQ==,type:str] -SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:foNoUlRH4NSmcZb8wcJQWLGFYT+M4WhBOxcuKrMO3yk3M9ffyXASYEfcQuMX0JtyUHODCjIcZBEenxAjorXLPxRRA/86BvxrmUaQW3Ytzyr1oG7MMbx7C/0eomzahMONHFLqUitEiv+IzAm28/qwEvT7C0JoVjX5Inq1MXVJBlXNtn4eIvFVwFoIZD0DjhDPhNAbb9J/pZ7QP7/kXLd7O0ekWZAuyUeor0xSkvHu3RusGhkZp5E9k2idkw==,iv:P4dLkW6qYWo3V7Z828gjA5c/2HDO6g2ACA5QDY0YsdQ=,tag:PVzxgpN9V8nrvlsT2OWVRg==,type:str] -SENTRY_ORG=ENC[AES256_GCM,data:uD4Y,iv:xCwzDtvUsME6rkXD9ZjnWEpWlkkQeCHBTWO/KQ3d1tk=,tag:arH2UqCWDEnx63u0hYfGrA==,type:str] -SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:xZc=,iv:+VnTLa4E9ebKgzkL6ZXmJ77/z1Wg4IPchiLomjucoKw=,tag:FxcZKE2WP24MtmMqysZcaQ==,type:str] -SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:14eklwo2DK7XzFCl4B0zKorH2mjDJRPdLOQGlILCrrnEf9R7lNE9tAg6Jcw27UrNArwmHA4OoEUKEV2dyCVO8b3MPjsUNtm4X4lhFeQQKA==,iv:GMQNtmbaZf3fr9Lz1lhOyx3WQpEGt7HY0NhFqogT2Ys=,tag:P7Cmgkz8Fp797vDIIz3oDw==,type:str] -STITCH_WEBHOOK_URL=ENC[AES256_GCM,data:0DTdF0yYSzaR7kjhJlKAUI69vlizcQvVNKutdsgkJXEM7j7r7+Aa//kxWzs2RC4iybgqh0cd9SYNyMGgvcP/5ilOoV01LGhMEV4BpEzbC/y71U4PVpzJCRPrUDNbnW6tHcqsMA5neXXZUK9a0iae5NHV4PST,iv:kDMMDSMSYs2f+vd+rzEhAUYWhtO0ihNIF7W7eJWh4Z4=,tag:r7B+miNzxPb2MrANLj9GSQ==,type:str] -ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:3qkmTVWiUpP1D7vYahpLdQh1sG4=,iv:trEME/o+ZXXizoRX3zgRm7d00GGDuhIhPXWO/Ck/5WQ=,tag:WsIG8PSLsA5LIOeOZkTCfA==,type:str] -ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:tVHxu0XEMCKOai0fZcCiddozBKw=,iv:WALv9GYJDI5eDo4YiOZhkhGt+I6RFN7V7GpF/P3MVFk=,tag:0XG3bv9CpS72tC07qDYeyg==,type:str] -sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA1dmQrZ1ZWZ1ozdU96RWs3\nRDU3MEhPbDNXLzRJZ1pKbUl2Y1E4U2Qyd1c4CkQ4TlBCNHM5L28yRDJIZ1N2ZE5D\ndWJQQ3RXQmJjcnZhc0hxYnFhYXpua00KLS0tIEZyeTJMT243cnliVW1TWEFPTVlh\nZS9wTjUybzJ2cFh5YVdsbE1TUmE2K0kK1k8ivaJttK0pzp5UijicG/NT9BaQ8Xog\n0TQpR54x4vtLBgfEMi8vy/V6jBYtCFHoefIUfKw9psNxrYx6NhJz8A==\n-----END AGE ENCRYPTED FILE-----\n +AES_ENCRYPTION_KEY=ENC[AES256_GCM,data:wwndqSv2MyIjmKwq9JqjxKFsUqvg/hBDbDibDDahffBYswCv87Db8XknpyqNGfsSHWbcyltDCZVIBeSuCsyyyA==,iv:Z3c+5UqWqd3/mk42iTM7zXB+7Be7FFKX0ZC9ScO6PuM=,tag:p7QSKjd7FkarIcUn4naqsw==,type:str] +ALGOLIA_ID=ENC[AES256_GCM,data:4JzulnozFmPZLA==,iv:tfrffJmRI5MTP4vK7XFYBPY21TYzUlYvcoEfYGsmtjs=,tag:MtO3Hy0/zBN/mqRSNbLYaA==,type:str] +ALGOLIA_KEY=ENC[AES256_GCM,data:mqwXWlYIl9KTjvG4l1kaiBe3ZBNOncdY9Y1wIgU4lck=,iv:mhYEoF6MpyqYEL6P2j6fnsbfnKIfHTdes0+erVx9DVc=,tag:cuWXTOcX2pRbYaAV0ThbUg==,type:str] +ALGOLIA_SEARCH_KEY=ENC[AES256_GCM,data:jc8dIUKYUodnCWBoCrDHV9y1lMVtc+BspV97xU0sJMQ=,iv:NV0M+aRp7MRxtO0pOb644Ef3d7Sca3/ytEIE662nLGY=,tag:iauwsjDU60rlpxhk+laJew==,type:str] +ALTCHA_HMAC_KEY=ENC[AES256_GCM,data:8Arh/hBVuNDElqQKYp3kXpVlEvtEgAHn/pMwFDh40yPEb0Dt7jbLas8vtDNY+V65cOe7V4kKRSzHXKilt1psz7i30WjwMs9+m7DLsZA79T6zrd//QA3Je8rmnR4wR2boLhzC3qk8a1u7QkCAW9bgLkuIjvT+QyC1IjjZlH2jjs8=,iv:p4oSMyE9nWOjMSvxJRpJhD7T171uqe0HUqgQ5y0lLY0=,tag:73UZAjsF7OfCn6QgqvyhTA==,type:str] +AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:9m10wQNGx5pC2SlheVnqB+fePnc=,iv:1AQbyF8z4EgBXEpXukh9tkVw7PiiPlgoY3/4eo+30cg=,tag:uhZtDF2RI1q8He6g+93JZQ==,type:str] +AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:fGTOKI7qvPVHyiqlvgQoyOpAAf0=,iv:mAzaf/YL+KNRVFIDHhK1qpxl7kAJTWA12F9eZACFbaM=,tag:CwEtd88OeL1+6rPKkWDg4A==,type:str] +AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:ug166i5Al6l8Fu+8CeN1S3S6TVsnIVZmCgP1R9OyWEB+g7lvT8sA9Q==,iv:HLpf24PeVoOIUiwpTZXA9EEqBcbgi3TGVT1iHlfYUmw=,tag:oEPPgqrTvH15F/Eh3olP0A==,type:str] +AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:E/n5h9ghjmTVF5h5hAxF+43N/y2D1z2JyzcTZqYjD7XXNglR7mKK4A==,iv:4FGDZPE8zI1gDHugHl6ybENx5G3pPuDgk8hJvIj3qfU=,tag:AdJ2Foz7YwtaFnje6x9gkQ==,type:str] +BACKUPS_SECRET=ENC[AES256_GCM,data:EHaQxEk4tSDiXTEgPNyZUivr38e4Sgk6140YIboJrq3p+beDeD+RLenM/H8=,iv:tboegTeauTew1SKL35KqdQxIrg0Eya2p70BvzfEkpqY=,tag:aCBl0Fy2IUO5AryIDxQtJw==,type:str] +BLOCKLIST_IP_ADDRESSES=ENC[AES256_GCM,data:1BfJtijgn55OVPAdM5F/3FYwNjfC,iv:RqIcEzrWbYKBEBhOvGm8VH9fHaVlcPdZMjdrQB19Pq8=,tag:uIb/WXMhdYvCz9styt3s5g==,type:str] +CLOUDFLARE_ANALYTICS_API_TOKEN=ENC[AES256_GCM,data:VS8Q4NChEnWSQmuk8l4lb9yWLwyP0cUKsGANIIu6dImkacumOtWyHqenAECNla1cbECzoyU=,iv:A7IWdFAYZp6rtGZvihVnbZvP7yVtPkWNWwHbo8VtaEg=,tag:oRPSqS4u3HmMDXmFCXqY9A==,type:str] +CLOUDFLARE_ZONE_TAG=ENC[AES256_GCM,data:fywQzuQEn9pdNIimV9GuhmfY4xLL2ELP6Ksq7MWFPck=,iv:nXIZDugREgzlNJPABnBmWIdPVp0gb0q30d0yDRtetx8=,tag:GKCKCwltl31qWMBuDuaN1g==,type:str] +DATABASE_URL=ENC[AES256_GCM,data:9BTb4c8rWijnB4ky24X3o5+45IUUKaKOQZ8NAa3DcvpqFbimRwjkgPRaZeg=,iv:8NwXuFcTsN02rz3GnARXJQ4r3kbQZ8i6Hcp5woipi90=,tag:1xq+bqeUBZZ7rzw21nysmQ==,type:str] +DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:ccb1p55D6HVnZMhjI/JOA3W1fQr2iCQUe4YdX9w=,iv:hc3xKVZ64ceE0X70Ma3DSQhpdTC9G74IF9at5u9zgCU=,tag:g6vci/24Hyuu4+uyvr3Kmg==,type:str] +NEW_ACCOUNT_LINK_COMMENT_WINDOW_MINUTES=ENC[AES256_GCM,data:9Js=,iv:aqV/ihhmshyl+AitBR/3ht2lcZfAlbUN1aWv4ykqcZ0=,tag:soRsQ79JSp2dS23z+wJNIQ==,type:str] +DOI_LOGIN_ID=ENC[AES256_GCM,data:7G7CJCl8,iv:Yx7O3m/9ogWC+MC7ELskHY0ds0nd5DhEkCDs9DoUkoo=,tag:lBsZKxlWmeKRXwXq1ZwNPA==,type:str] +DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:d+HERBAHsB/Jgcv1Y1lLZ7SDxbw=,iv:Y/yHx6njcVHspagOVmwml1AF+wiO/xFk9hWNAGXg2gc=,tag:nvMDR6RQs7tqPK+JAJjp4A==,type:str] +DOI_SUBMISSION_URL=ENC[AES256_GCM,data:/yBd5dBnbnT7Wwngak2wetscOFlIk3LdVJIeI5yaOGqSnmnspQ9PLg==,iv:3I22HNo6q+B42PVY7xX9WkhnwhPCkMUFi4nlQEk3EYo=,tag:PwyZbykobCdtFxsbogoo0w==,type:str] +FASTLY_PURGE_TOKEN_PROD=ENC[AES256_GCM,data:quujm0S9LXd15BrFu3Qr2ZG0wXSPl+VV75xWJHELMUY=,iv:KqZ4/pVs3QNP/Lnuqsy0lChxHCKfB1cv6lpr3k9JbLE=,tag:oXfTC8sJfiLmdW2oJT4EOA==,type:str] +FASTLY_SERVICE_ID_PROD=ENC[AES256_GCM,data:5mwHBBxnWSIjgFnL9tvSxhV6z5yiaw==,iv:Oxrpy+indTLVWQIAEguDRMRo3KXrIFEtWwC+5qzns9M=,tag:/16gsvBgWjd02SKEGc98ug==,type:str] +FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data: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,iv:KhxfkNZ8u6Tb0MYevW55/6JA68DJtQuiBdFZj/0K/ek=,tag:Ef6tcnyMlRRxz1papcrbcA==,type:str] +JWT_SIGNING_SECRET=ENC[AES256_GCM,data:CfBlcHV441Ql64es2Ryb5xD7fp2Jlk4H7TTZAGYfH8++NNxlwQx1gBqAYfe95u43hZEZJVSUGlDdFGX+uwsM7sw/mGr3OxzLscNGq+4ZdauFHZmmT2hjGa0G/uHjtXDgHgOT/HTf2/nUTJhegWYHUjSzLN7/ObbT9wfYb5iKAKraAsF1okWw+/OPFfKv8I508qCp5foEN7eFvTMLE6waMW1EVvNrX59UQJRNvlqKPHiRfel6F0GK4OEs+eE+3g6kWoBNyPHADpLufTfG3eyaN1SwwbMnHzBn/DC5H/zjGkPUwubwjAhZkcbvZpHjvDblufJfUz23jQ7CDIupjwrBqCtiK2lV2VbDYKdboQdpCKlFu+bMhYQI0CmG1Znnr9dvbiuXSHLIGj2oX7BuDZ92+AGzKXmHEgZIlKOgILFj6IS+mbYY9eyVS8AhxVj1vMsQ9NGfM1eeP6gV2hl0P9EFvRps102fc9GWWKp5Qrl03xhj9irjglNcUHywYBnI0Su3fmwXf3LqOerWo4iDFaaiRvUbY0Rx6ZXqNsSx+d3HDTHr5y2Kj2N8MW6Oo4ShoB6CygVuaoaChTH+gf+1FsiZj43R/mOkcW3jnzQLG/cRzZkIGAiDMA/rqcSHfScQoI9RMCxdtD0XGwibb8nalqYvP2TBdupRc6+HkMvIERDwgmg=,iv:n/ADcQsr2NPEsyu9HqXtW5UL1HmkT2J02X4q5QU9cdU=,tag:MepIH6qtyy1xfNb48vQXaQ==,type:str] +MAILCHIMP_API_KEY=ENC[AES256_GCM,data:oK6a7gaUFbVOHc8oZ5Hbbbwh0pgm2B9FwYuYETLkdUrDvRGx,iv:o/BGp68U+kT+aohKavkbxD7vSRFU23BjyeTUycPQGAg=,tag:uPq8Xe9SXvt9bpTgdDVOcQ==,type:str] +MAILGUN_API_KEY=ENC[AES256_GCM,data:Kl1xxcaV1Bxk3yR/wlXrEPuREZ9wisXC2wMUGuMa80Q/wSLu,iv:CSi+GdAJdwkxiX0xjMR4nkVecAHxWpy3DLtc/lsi8do=,tag:XOGaaYuI3orhgW2c0Vfa7A==,type:str] +METABASE_SECRET_KEY=ENC[AES256_GCM,data:Z+bYXP/Pr+uIukXhkHRzjDo62+xxEX1rUwkJr7xhVj0bQ/oCBfUvboT+yPf2QzloTak9mFanqinJV+aO+KvXdQ==,iv:t5qBdfiR+39tNTbTOfZZWwrHZltXGoF1hv/GOoZhjhg=,tag:eT5raUeWsHyLIb8Jswn0xQ==,type:str] +NODE_ENV=ENC[AES256_GCM,data:3G1tICQGSXh0Tw==,iv:hrDUTIj06vuIo5slHfCLbjnIGH19bQPfqSiy50zgj6s=,tag:CHuz/X8cY1P+XIriMVVK1w==,type:str] +PUBPUB_PRODUCTION=ENC[AES256_GCM,data:7xMNvQ==,iv:t55FWrYc9cqXQWuwFNS/dxcpFeQxyyELLcIQDAhl1uw=,tag:FjKAUIck/D9TsmR+Rt5oSg==,type:str] +#ENC[AES256_GCM,data:aQAu1OBmTBnIl5Tr7+v/rxGklFf1I2mFkQ==,iv:VMII2xT6vkeYcV4ec5CRYxmQtXZyMlVXjPR60dQenpg=,tag:FZTW69WLfSAmMsexlUzedg==,type:comment] +#ENC[AES256_GCM,data:+oQ7KAt8wey3IXZ+MKbdzFA/lWVm4uLjDez/QHdjn39fLzPz3Q==,iv:shnQ6gjhjeTSd8W+l7xcBFg6+s4x4R3752liFFsix60=,tag:2ed0Kf4sSAUf+9Aatbb5Zg==,type:comment] +S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:90qdmqeJ/i67Uz1m5H/Zuogmm/inKQpoVuatEhOgU1RYLjU=,iv:35YyOqCA+20LzQnVVHoeOeafY4NOA8XhFwyJ/hr+5A8=,tag:BTKfA/aLEzv+q9geSSjEFg==,type:str] +S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:bXG4zuMIweU8MXtZ15iONM1YbDs=,iv:ZpHEbcFctnM8hgHlBNfioAOrnCAqDJdaadK6qbDiTps=,tag:MOZbmMIG3mIPkWL3WaFDKQ==,type:str] +S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:nyM9m9CKTfnFW06d5ddQmvAbTddeJakR7ahCxBa8s+Rf1WsIeJzfEg==,iv:LdLPa0kjBUmSw2o8njxdJD9+e+B0JnV2cm5+DxYm61E=,tag:bKwfaqVbY+NitmKVnCaUfg==,type:str] +S3_BACKUP_BUCKET=ENC[AES256_GCM,data:RAjKU/MeRci7Au8=,iv:XsvfLEBlVFohMkXgdrFioVzrliD9aIMxDjSIFCZLnVo=,tag:/S+CWzhHxlS7aaURuBev9g==,type:str] +SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:AHroshPbxk84wXNJBv7AzufUd8cAyrU+9KgI8zSDRqWaKamsYNzvX4fNTcWB+19Vjq3KhCR6I5h4IF2+GF5Z/dtGC611g+/NdFnlNQN4zreNhmvQJnWrIkYmXcvID81llWCcDvNaMrUVJIirG4nwxMz0jCH6bIFuQIhcEZdqy6oIxEW8J2XU/myyEfIk3WaivR0J16oQ8MhIKyicJUIEtzP+/oc8dxuAsI3ZUYayn7CLSgPtWbUJffPi1w==,iv:UlKd/SkdIG74swZQD/eTgMVgmGEMH+2h2IqXIy9L2UI=,tag:9DcnwXQvXcIBtRovpEMx4g==,type:str] +SENTRY_ORG=ENC[AES256_GCM,data:+tA9,iv:RmHgSpZE2uWtWvTvz+AJB/wlcNIGy8LcMImuDxEsQMQ=,tag:bJcv29ozdpkF5Dd6trm35g==,type:str] +SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:Rx0=,iv:b/Ho1bVGN2KM9t4cZhB/yQ/w74mYo78nr7G9zIfeR/Q=,tag:xJRaQ+AqB4M8RA0S0EKqsg==,type:str] +SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:1AbX/MCuiCfda3ppkqd41qolQepZZ3KAgWk1TiC7fQbs2AUFg4D1F8DDssD9LcJ//nvnkje7gcoeud518YH6UvNSt+ATPGVPGQahSM6RbQ==,iv:ecAE+HXyz6To1a4kxLq54HcDzMiT6dv1VqpmhfandT8=,tag:QqEjlJ8hCPwvnBetvN+Jbg==,type:str] +STITCH_WEBHOOK_URL=ENC[AES256_GCM,data:wT6Wo4wqeAQYS5L5HcIplFlQkFD2RMj+0PZq5jolVCsFOaPNY9DfeiRtW9hLYzshOBNRtAV2bzvppXqcmHPhZAx3oioFpypmV2IZB2YmmWqW/UwQAdg2OkcMUBfDeR/nWrcEaxuxYskevApuWaZ/YLuYInCh,iv:YC8ooLHoBLJeNYyA/qAB6kg/E0UzFCXo9CfkJ/mrtmA=,tag:yUHYcv4ZX7E622EqnOIaCg==,type:str] +ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:c5BnrGOM/vrtSNWacJvF6ueuv74=,iv:1pD4U5qb9bTTIfDjro+Wu/W7Zfv9P0AsSGkD8JvOELU=,tag:TYMDMriv1RF+Sojanx7b3A==,type:str] +ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:YeT5B4h74UW78q9bvonDSwWQKUU=,iv:xBvsrk6HPKR/XBltyOgogARcX/+18nihlHeYOXcYTUE=,tag:5y26F8nCo/ZLXAFncolWOQ==,type:str] +sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBNdDEyaGRqY2ltSjRNSjJz\nWjR2NGdsNDZzeGdWNEV0ckVIRE5kdTBWTFc4CnZiTno3NkdVY0t5VVNuN0o2d1pJ\nVUF6am9NalNReEtDcEF2cVlIT3d5ZWsKLS0tIDBoS3ZnUzB4dStWUXBzMFRrZHFl\nMTVkeEpFdmxRSW9Xenp2eGJ0em9jaEEK0KJEmAMNOu04+3BXpzXHxjK1w8yuPHPm\nWmuWU84VM6hflDfi0w+z4nyB6gfiBnehA61hEwvrzgUF7s+B8DNNug==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_0__map_recipient=age1wravpjmed26772xfjhawmnsnc4933htapg6y5xseqml0jdv8z9hqemzhcr -sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqMGFITjNJazkzNGtwQUF2\ndURyRktTTE9VY3l4TWVlam9zc2psQzBSMUNBClpIcU81VitObWtnM2U2TVhGaHo5\nK25Cb01CQjdwSlRjVGNmdGlUY2xHa1kKLS0tIFMwOUtuVTB6WmNrTDhXd09tRGU0\nLzM1bi8wVmpnazMyLzhvOFYxVmNtUmsKSjl6gGc/oBnkd7rGz5HsXVlRtSY6PopE\nOoGXRVElkLdhBzC9LY6HbgkWSJyu+v56mIbYro7euczYX+6dzrrerw==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAyNndlNnl0Q2ppYzQ4eHhQ\na3JmL2hhTWF2NTdRVjQxM2RmaXUrM3hqM3hVCmV4ZXF0T0VJMTJDVTU4TmZzdFlm\ncXdEYWtkUzNrc1IraWhlZW80N3hqcmsKLS0tIEU0MkRoRFFrWjZtaWJOb0RtWkth\nSkZpYVJYekhtVFR4RW9GN0hiK1N5bHcKUpaRALmGXcbE/dAlx/ijWgccaghfRIcy\naYtpnP5H/CRvuG095RNXit3LN/MV3JiRYKe3QUjks+mDQHrKnUQ49Q==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_1__map_recipient=age1vhftscteyrwphx0jpp0yl60xxrjs77jq05mkzv88js7ckc926vcqepp2cj -sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB2V2c3dkxMQnZwTDRrZWZI\nL3A0ck9CaEo1WGx3UE96bEZnWmVBUXJaazJRCkRLYVN4aFMwN3gvaThkUUE2dGpn\nS3ZGMjlTUW1NL1Radk4xOXIvYm42dHcKLS0tIEJxbHRIQUo3ZlU4QUR5K1RCL2RK\nNmc0bllPUmNDUkVuRFdUaGJYZU5yVmcKZKktxJzujZ/C8VogEoRE4l6RrYEUf41q\nObBXtOgawAax/R0gdCfoTkoC5yfNT27yu5ngIz0wlDpusNF4L+fMpQ==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA1TW1YYkxrWm1sbTJkaUQw\nbzFwbmlsTXJBSkJvalBXam1vQkV0WnZURkV3CkxBYVVpR3BkL0RUOWN1alMzV0lM\nUWI3cmk3b2pHSE0zZjh6bWt2SDNMUVkKLS0tIFpvL05pNW11aGlMTmtLT3lIZy85\ndkJiZHlnVCtSc0hEN1lKRzhvQ0Fxdk0K6bJZXUueiyg1T8fZK3yM/knasxHmR7jn\nNLhaJsdVatDOoBqRFx7SFk4ETQy/eC1hK10K8wfqIuuqICrC/mhVcA==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_2__map_recipient=age1slx6e48k7fre0ddyu7dtm2wwcqaywn5ke2mkngym3rpazxwvvuyq9qjknk -sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBHc2RSV0trMW1qZ2dRaFlS\ndjZ6ZHliM3hVS2FKa2R2R3FiYklwQllrQTFFCjJwNk1ZZEo1UkNDMTBPQy9vcVM2\nbE1MSHg0WXZ3NGd0MmUzY0pmTWRkSlEKLS0tIGxKTThVR016enMvZi8rSjBHNC9V\nL2MwNWhBdlplYnJ2Q3lLMzNDZGJQZVEKkNanfMXN+vxtDXaUSK/w4q4/bc0NwVQw\nVyKvEmwT2IagUVqD7ey+4upLgiGdRuhkooAGfiBtJHyPVsKvCPeMgg==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBKc1NaSzlFdUZYYUZXcGV5\nQVJJUmZYUkp4K3pVTkNQNEZmam9YZGJQb3hBCjRSc0NaNkVubHhuc3VvVURKdU5R\nM0xQUmdFTG9TWFdoYm5NWXhJQzlBaUEKLS0tIFpwcVNVN1dWTHNRSWJVaVUyV0pT\nend6T084WkJ1NG5ldmZ1dXF4akI4MGsKQVC/chR6Spkp4z2RIvERs3OgOF7vam2J\nm3wAsUo23LNhtD2AlohPLmrLMNnMl4rhkkVEJUazJwa4vO7vSpATyg==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_3__map_recipient=age1vfhyk6wmt993dezz5wjf6n3ynkd6xptv2dr0qdl6kmttev9lh5dsfjfs3h -sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA1N0JjWkt1cGxwbzhPVUVk\nVE9MS1R3MHRIL1VLRHlGNGxpSUdGZWNVc21NCnhMamUyd0RwYWw1SHhQQXBzbzh1\nRVlvMXpaUTNId1BwYU1LZFVONExhNzQKLS0tIEZpZ3dhVHJtRHBidU1OZ1NyZFUw\nT04reTVzR0tuK3QxZ1ZzdTJqUThCTzQK3vTB9CQLDcOJShwvYOkmOcLQfJ9QCkZF\n9SNn1wPd1MNGSfxdSOYWl9t1o0z3gz65YSL71KJC8xZ90TaV3Esthw==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBCbjBHemdCdUFtS2RjNHFa\nY3gvbmVXMEI0S0tkOFFya3NFOEtsbUlsR3cwCndEMWxJMlI5bFFZbFFGMnBUZ3hq\neWJYQUxoTEdmRGlUdko4TjN4aG1ZelUKLS0tIDZ0a0wyTGk1Q1d6cWJveC85Y1dt\nclVHUEhkbDNOUFBGcmJTNkpQQ0RFNXMKXaJaLRGZ5Iof5UcRpal/4mgk7JU3xvZ6\nUz3fK/5c9nPT3Q4m90QBpF2yC/dglKkUoyxSJo2tYEByWhVOw4382g==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_4__map_recipient=age1jwuvzyghfer7rx3qtqa4vs0gxyff0sg6pqgmqvp5zlhnpmvrkdlsdha4kx -sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAyeXlpa0xmWkMzSGVNQ2V1\nOXMwWi9ibitBeXk1b0lsdUVYVlZieEVZS25nCkVUcHdkTlpuUjdkTTlwV3BmOW5i\nbEQ0cEY4TEJubGV1YjlnQjdjckRKbjgKLS0tIHRHa252aXVxT3Bwd1JaWW5Ld0xr\nTW84ZHlWNXF0K2dDMEJ2NmxWWndYckUKEsCbco1+C6mhFUxFj9zGQuo1Xs5U2HMb\nFq/OLyclKqJryJbkNRPQTfD0J/vzLKk1TLjiyn6fE74vvHVp0qUOCQ==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBDYUtwNEowcE1yT1pwaWl2\nOHlSdmhNSWkxOUVuWU9ydGI1RlBOc21zcmxNCllSR3pDNzk2VXlRZ2JQSDE0Y0VD\nU2hTUlRwMGp0M2dLVjJxM1RQYWFYSUUKLS0tIFFab1pxUVlXL0RwVUI0U3phTVRW\nQXFtaDVXckdocXN6Q1VzWkxEUHNINlEKMJ8AA0gdiKV8XNytk7eof0W49iUlzr2g\n1et7FiDDzpQdPBD3IB9PkbwmVjBJADm2LkQ0x1DWuInNU+nDpsowZQ==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_5__map_recipient=age1pgxk292zq30wafwg03gge7hu5dlu3h7yfldp2y8kqekfaljjky7s752uwy -sops_lastmodified=2026-04-01T03:03:08Z -sops_mac=ENC[AES256_GCM,data:hQlfCmb8jk2U5+U1cRI0W662mzTMEfaHXhmX2nHLnTSn/n3I3rVnl/tl16XikJjrO6LQpoELhs0Espu+1PYF7VRknDI0vmoP4XYciV/W2LiXZ8Q9au7NSfNrOmkvNEGdXgOedzxO5cCl96ARmmXsz1Yi5SzWWaxDW3mFb6RPSm4=,iv:TLr9e4RhdNm6Yc/YKPXBxQRzb9Q1RqYeW+0dQodcKDo=,tag:Z2TIN79C44dWJWmgzfdd8g==,type:str] +sops_lastmodified=2026-04-02T03:37:53Z +sops_mac=ENC[AES256_GCM,data:Ip76fcVfHTtyP3DS9DGF2L9tSBWhrZ/8HzvzRPfXP4sOfdoq9tUNiJButvgKxhjtleUSaNv23LWD3EsTSyTLGbNx0eluFqSbvSIlhyPG5rUGXSGQuE7fQnKgqqJH4f5Vfs2iXuHY98uK6mfqICS2f++EeVqulQPnDMObTDnVVSg=,iv:Nl2L6K+UPMrBFX5hQagazdKIgj31uPgUCyHUs/pXzko=,tag:jr+t2DlSrCbHWdl0sKLdDg==,type:str] sops_unencrypted_suffix=_unencrypted sops_version=3.11.0 diff --git a/server/utils/cloudflareAnalytics.ts b/server/utils/cloudflareAnalytics.ts index 6168567a8a..23916937ef 100644 --- a/server/utils/cloudflareAnalytics.ts +++ b/server/utils/cloudflareAnalytics.ts @@ -15,6 +15,14 @@ function getConfig() { const apiToken = process.env.CLOUDFLARE_ANALYTICS_API_TOKEN; const zoneTag = process.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 }; From 82b39615e07fd6a77f735d62f4017e94ef05d80d Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Wed, 1 Apr 2026 23:40:17 -0400 Subject: [PATCH 04/41] Lint --- server/utils/cloudflareAnalytics.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/utils/cloudflareAnalytics.ts b/server/utils/cloudflareAnalytics.ts index 23916937ef..5464589acf 100644 --- a/server/utils/cloudflareAnalytics.ts +++ b/server/utils/cloudflareAnalytics.ts @@ -21,7 +21,7 @@ function getConfig() { ].filter(Boolean); console.warn( `[Impact2] Cloudflare analytics disabled — missing env var(s): ${missing.join(', ')}. ` + - 'Set these to enable the Impact dashboard.', + 'Set these to enable the Impact dashboard.', ); return null; } From 8dddd47f5ac59196c7aceea575d079c199e89b10 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Wed, 1 Apr 2026 23:42:24 -0400 Subject: [PATCH 05/41] Don't show debug api in prod --- server/impact2/api.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/server/impact2/api.ts b/server/impact2/api.ts index 6fb6513584..8080ae385d 100644 --- a/server/impact2/api.ts +++ b/server/impact2/api.ts @@ -55,8 +55,13 @@ router.get('/api/impact2/test', async (_req, res) => { * 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(); From c352dcd790d62ebb720dafd971abd2277ffa6ff8 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Wed, 1 Apr 2026 23:51:50 -0400 Subject: [PATCH 06/41] Set live refresh to 1 hour from 3 --- client/containers/DashboardImpact2/DashboardImpact2.tsx | 2 +- server/utils/cloudflareAnalytics.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/containers/DashboardImpact2/DashboardImpact2.tsx b/client/containers/DashboardImpact2/DashboardImpact2.tsx index 05edef97ff..62c894b6b8 100644 --- a/client/containers/DashboardImpact2/DashboardImpact2.tsx +++ b/client/containers/DashboardImpact2/DashboardImpact2.tsx @@ -385,7 +385,7 @@ const DashboardImpact2 = () => { persists. Treat these numbers as directional indicators, not exact measurements.

-

Analytics sourced from Cloudflare edge traffic data.

+

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

)} diff --git a/server/utils/cloudflareAnalytics.ts b/server/utils/cloudflareAnalytics.ts index 5464589acf..1bd39f16f2 100644 --- a/server/utils/cloudflareAnalytics.ts +++ b/server/utils/cloudflareAnalytics.ts @@ -208,8 +208,8 @@ import { Op } from 'sequelize'; import { AnalyticsDailyCache } from 'server/analyticsDailyCache/model'; -/** 3 hours in milliseconds. */ -const TODAY_CACHE_TTL_MS = 3 * 60 * 60 * 1000; +/** 1 hour in milliseconds. */ +const TODAY_CACHE_TTL_MS = 1 * 60 * 60 * 1000; /** * Delete cache rows older than 90 days. From a731d4580d17865f6e917aed70736f30fcc1e963 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Wed, 1 Apr 2026 23:52:09 -0400 Subject: [PATCH 07/41] lint --- client/containers/DashboardImpact2/DashboardImpact2.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/containers/DashboardImpact2/DashboardImpact2.tsx b/client/containers/DashboardImpact2/DashboardImpact2.tsx index 62c894b6b8..1d15404eac 100644 --- a/client/containers/DashboardImpact2/DashboardImpact2.tsx +++ b/client/containers/DashboardImpact2/DashboardImpact2.tsx @@ -385,7 +385,10 @@ const DashboardImpact2 = () => { 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.

+

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

)} From 7513b707a2146eede0513b40b4e83870010fd48d Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Wed, 1 Apr 2026 23:59:09 -0400 Subject: [PATCH 08/41] Small comment edit --- server/analyticsDailyCache/model.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/analyticsDailyCache/model.ts b/server/analyticsDailyCache/model.ts index 390cdfa033..aaa8d7be0b 100644 --- a/server/analyticsDailyCache/model.ts +++ b/server/analyticsDailyCache/model.ts @@ -7,7 +7,7 @@ import { AllowNull, Column, DataType, Model, PrimaryKey, Table } from 'sequelize * * Composite primary key: (hostname, date). * Past days are cached permanently (expiresAt = null). - * Today's partial data is cached with a short TTL (expiresAt = now + 3h). + * Today's partial data is cached with a short TTL (expiresAt = now + 1h). */ @Table({ tableName: 'AnalyticsDailyCaches', timestamps: false }) export class AnalyticsDailyCache extends Model< @@ -36,7 +36,7 @@ export class AnalyticsDailyCache extends Model< /** * When this cache entry expires. NULL = permanent (completed past days). - * For today's partial data, set to ~3 hours from write time. + * For today's partial data, set to ~1 hour from write time. */ @Column(DataType.DATE) declare expiresAt: CreationOptional; From ea5c6f311b16622a8e2527466dfa1d6b94836e51 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 2 Apr 2026 12:16:46 +0200 Subject: [PATCH 09/41] feat: analytics exp --- analytics_schema.sql | 60 +++++++++ infra/docker-compose.dev.yml | 19 +++ server/analytics/__tests__/api.test.ts | 91 +++++++++++--- server/analytics/api.ts | 46 +++---- server/analytics/model.ts | 161 +++++++++++++++++++++++++ server/models.ts | 3 + 6 files changed, 338 insertions(+), 42 deletions(-) create mode 100644 analytics_schema.sql create mode 100644 server/analytics/model.ts diff --git a/analytics_schema.sql b/analytics_schema.sql new file mode 100644 index 0000000000..4dae156928 --- /dev/null +++ b/analytics_schema.sql @@ -0,0 +1,60 @@ +CREATE TABLE pubpub_analytics.data( + __sdc_primary_key character varying(128) ENCODE lzo distkey, + _sdc_batched_at timestamp without time zone ENCODE az64, + _sdc_received_at timestamp without time zone ENCODE az64, + _sdc_sequence bigint ENCODE az64, + _sdc_table_version bigint ENCODE az64, + collectionid character varying(128) ENCODE lzo, + collectionkind character varying(128) ENCODE lzo, + communityid character varying(128) ENCODE lzo, + country character varying(128) ENCODE lzo, + countrycode character varying(128) ENCODE lzo, + event character varying(128) ENCODE lzo, + height bigint ENCODE az64, + isprod boolean ENCODE raw, + primarycollectionid character varying(128) ENCODE lzo, + pubid character varying(128) ENCODE lzo, + type character varying(128) ENCODE lzo, + unique boolean ENCODE raw, + width bigint ENCODE az64, + timestamp bigint ENCODE az64, + utmcontent character varying(128) ENCODE lzo, + utmmedium character varying(128) ENCODE lzo, + utmterm character varying(128) ENCODE lzo, + release__string character varying(128) ENCODE lzo, + path character varying(256) ENCODE lzo, + collectiontitle character varying(256) ENCODE lzo, + collectionslug character varying(256) ENCODE lzo, + pubslug character varying(256) ENCODE lzo, + communityname character varying(256) ENCODE lzo, + collectionids character varying(1024) ENCODE lzo, + release__bigint bigint ENCODE az64, + pagetitle character varying(256) ENCODE lzo, + referrer character varying(4096) ENCODE lzo, + utmcampaign character varying(256) ENCODE lzo, + utmsource character varying(512) ENCODE lzo, + timezone character varying(256) ENCODE lzo, + os character varying(256) ENCODE lzo, + pageid character varying(256) ENCODE lzo, + locale character varying(256) ENCODE lzo, + pageslug character varying(256) ENCODE lzo, + communitysubdomain character varying(256) ENCODE lzo, + format character varying(256) ENCODE lzo, + useragent character varying(8192) ENCODE lzo, + pubtitle character varying(1024) ENCODE lzo, + url character varying(4096) ENCODE lzo, + search character varying(4096) ENCODE lzo, + title character varying(16384) ENCODE lzo, + hash character varying(8192) ENCODE lzo) DISTSTYLE AUTO SORTKEY( + __sdc_primary_key +); + +CREATE TABLE pubpub_analytics.data__collectionids( + _sdc_batched_at timestamp without time zone ENCODE az64, + _sdc_level_0_id bigint ENCODE az64, + _sdc_received_at timestamp without time zone ENCODE az64, + _sdc_sequence bigint ENCODE az64, + _sdc_source_key___sdc_primary_key character varying(128) ENCODE lzo distkey, + _sdc_table_version bigint ENCODE az64, + value character varying(128) ENCODE lzo) DISTSTYLE AUTO; + diff --git a/infra/docker-compose.dev.yml b/infra/docker-compose.dev.yml index 05ae4843fa..c590199cf5 100644 --- a/infra/docker-compose.dev.yml +++ b/infra/docker-compose.dev.yml @@ -85,6 +85,24 @@ services: ports: - "${DB_PORT:-5439}:5432" networks: [appnet] + + analytics_db: + image: postgres:16 + environment: + - POSTGRES_USER=appuser + - POSTGRES_PASSWORD=apppassword + - POSTGRES_DB=analyticsdb + command: > + -c shared_buffers=2GB + -c effective_cache_size=6GB + -c work_mem=16MB + -c maintenance_work_mem=512MB + volumes: + - analytics_pgdata:/var/lib/postgresql/data + - ./analytics_backup.sql:/docker-entrypoint-initdb.d/analytics_backup.sql + ports: + - "${ANALYTICS_DB_PORT:-5440}:5432" + networks: [appnet] # cron: # build: # context: .. @@ -110,3 +128,4 @@ networks: volumes: pgdata: rabbitmqdata: + analytics_pgdata: \ No newline at end of file diff --git a/server/analytics/__tests__/api.test.ts b/server/analytics/__tests__/api.test.ts index 9ab800ace0..525f17f17d 100644 --- a/server/analytics/__tests__/api.test.ts +++ b/server/analytics/__tests__/api.test.ts @@ -1,5 +1,3 @@ -import { vi } from 'vitest'; - import { login, setup, teardown } from 'stubstub'; import { analyticsEventSchema, @@ -8,6 +6,8 @@ import { type sharedEventPayloadSchema, } from 'utils/api/schemas/analytics'; +import { AnalyticsEvent } from '../model'; + const baseTestPayload = { type: 'page', height: 0, @@ -72,22 +72,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 +86,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 +105,22 @@ 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); + + const events = await AnalyticsEvent.findAll({ where: { pubId: payload.pubId } }); + + expect(events).toHaveLength(1); + expect(events[0].event).toBe('pub'); + expect(events[0].pubSlug).toBe('string'); + expect(events[0].release).toBe('draft'); }); test('page page view', async () => { @@ -128,6 +128,11 @@ describe('analytics', () => { const agent = await login(); await agent.post('/api/analytics/track').send(payload).expect(204); + + const events = await AnalyticsEvent.findAll({ where: { event: 'page' } }); + + expect(events).toHaveLength(1); + expect(events[0].pageTitle).toBe('string'); }); test('collection page view', async () => { @@ -135,6 +140,13 @@ describe('analytics', () => { const agent = await login(); await agent.post('/api/analytics/track').send(payload).expect(204); + + const events = await AnalyticsEvent.findAll({ + where: { collectionId: payload.collectionId }, + }); + + expect(events).toHaveLength(1); + expect(events[0].collectionKind).toBe('issue'); }); test('other page view', async () => { @@ -142,12 +154,51 @@ describe('analytics', () => { const agent = await login(); await agent.post('/api/analytics/track').send(payload).expect(204); + + const events = await AnalyticsEvent.findAll({ where: { event: 'other' } }); + + expect(events).toHaveLength(1); + }); + + test('stores country from timezone', async () => { + const payload = makeTestPubPageViewPayload({ timezone: 'Europe/Amsterdam' }); + const agent = await login(); + + await agent.post('/api/analytics/track').send(payload).expect(204); + + const events = await AnalyticsEvent.findAll({ where: { pubId: payload.pubId } }); + + expect(events).toHaveLength(1); + expect(events[0].country).toBe('Netherlands'); + expect(events[0].countryCode).toBe('NL'); }); - test('page page view with optional fields', async () => { - const payload = makeTestPagePageViewPayload(); + test('stores collectionIds as array', async () => { + const id1 = 'de3a36ab-26d9-4b76-aaab-f1bffc18b102'; + const id2 = 'ae3a36ab-26d9-4b76-aaab-f1bffc18b103'; + const payload = makeTestPubPageViewPayload({ + collectionIds: `${id1},${id2}`, + }); const agent = await login(); await agent.post('/api/analytics/track').send(payload).expect(204); + + const events = await AnalyticsEvent.findAll({ where: { pubId: payload.pubId } }); + + expect(events).toHaveLength(1); + expect(events[0].collectionIds).toEqual([id1, id2]); + }); + + test('converts timestamp to 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); + + const events = await AnalyticsEvent.findAll({ where: { pubId: payload.pubId } }); + + expect(events).toHaveLength(1); + expect(new Date(events[0].timestamp).getTime()).toBe(now); }); }); diff --git a/server/analytics/api.ts b/server/analytics/api.ts index 78f660f9a0..e7bbeb6f6a 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'; @@ -8,25 +6,24 @@ import express from 'express'; import { contract } from 'utils/api/contract'; +import { AnalyticsEvent } from './model'; + const s = initServer(); -const sendToStitch = async ( - payload: AnalyticsEvent & { country: string | null; countryCode: string | null }, +const toEventRecord = ( + payload: AnalyticsEventPayload, + enrichment: { country: string | null; countryCode: string | null }, ) => { - if (!process.env.STITCH_WEBHOOK_URL) { - // throw new Error('Missing STITCH_WEBHOOK_URL'); - return null; - } - - const response = await fetch(process.env.STITCH_WEBHOOK_URL, { - method: 'POST', - body: JSON.stringify(payload), - headers: { - 'Content-Type': 'application/json', - }, - }); + const raw = payload as Record; + const { unique, collectionIds, timestamp, ...fields } = raw; - return response; + return { + ...fields, + ...enrichment, + timestamp: new Date(timestamp as number), + isUnique: (unique as boolean | undefined) ?? null, + collectionIds: typeof collectionIds === 'string' ? collectionIds.split(',') : null, + }; }; export const analyticsServer = s.router(contract.analytics, { @@ -40,7 +37,6 @@ export const analyticsServer = s.router(contract.analytics, { req.body = JSON.parse(req.body); } catch (err) { console.error(err); - // do nothing } } next(); @@ -48,10 +44,16 @@ export const analyticsServer = s.router(contract.analytics, { ], handler: async ({ body: payload }) => { const { timezone } = payload; + const { name: country = null, id: countryCode = null } = + getCountryForTimezone(timezone) || {}; - const { name: country = null, id = null } = getCountryForTimezone(timezone) || {}; - - await sendToStitch({ country, countryCode: id, ...payload }); + try { + await AnalyticsEvent.create( + toEventRecord(payload, { country, countryCode }) as any, + ); + } catch (err) { + console.error('Failed to store analytics event:', err); + } return { status: 204, diff --git a/server/analytics/model.ts b/server/analytics/model.ts new file mode 100644 index 0000000000..935f0089fb --- /dev/null +++ b/server/analytics/model.ts @@ -0,0 +1,161 @@ +import type { CreationOptional, InferAttributes, InferCreationAttributes } from 'sequelize'; + +import { + AllowNull, + Column, + DataType, + Default, + Index, + Model, + PrimaryKey, + Table, +} from 'sequelize-typescript'; + +@Table({ + updatedAt: false, + indexes: [{ fields: ['communityId', 'timestamp'] }, { fields: ['pubId', 'timestamp'] }], +}) +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) + @Index + @Column(DataType.TEXT) + declare event: string; + + @AllowNull(false) + @Column(DataType.DATE) + declare timestamp: Date; + + @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; + + @Index + @Column(DataType.UUID) + declare communityId: string | null; + + @Column(DataType.TEXT) + declare communitySubdomain: string | null; + + @Column(DataType.TEXT) + declare communityName: string | null; + + @AllowNull(false) + @Column(DataType.BOOLEAN) + declare isProd: boolean; + + @Column(DataType.TEXT) + declare country: string | null; + + @Column(DataType.TEXT) + declare countryCode: string | null; + + @Column(DataType.TEXT) + declare url: string | null; + + @Column(DataType.TEXT) + declare title: 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.TEXT) + declare pageTitle: string | null; + + @Column(DataType.UUID) + declare pageId: string | null; + + @Column(DataType.TEXT) + declare pageSlug: string | null; + + @Column(DataType.TEXT) + declare collectionTitle: string | null; + + @Column(DataType.TEXT) + declare collectionKind: string | null; + + @Index + @Column(DataType.UUID) + declare collectionId: string | null; + + @Column(DataType.TEXT) + declare collectionSlug: string | null; + + @Column(DataType.TEXT) + declare pubTitle: string | null; + + @Index + @Column(DataType.UUID) + declare pubId: string | null; + + @Column(DataType.TEXT) + declare pubSlug: string | null; + + @Column(DataType.ARRAY(DataType.TEXT)) + declare collectionIds: string[] | null; + + @Column(DataType.UUID) + declare primaryCollectionId: string | null; + + @Column(DataType.TEXT) + declare release: string | null; + + @Column(DataType.TEXT) + declare format: string | null; +} diff --git a/server/models.ts b/server/models.ts index 925e1b1b75..ee4a3f6fe8 100644 --- a/server/models.ts +++ b/server/models.ts @@ -3,6 +3,7 @@ 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 { AuthToken } from './authToken/model'; import { Collection } from './collection/model'; import { CollectionAttribution } from './collectionAttribution/model'; @@ -64,6 +65,7 @@ import { ZoteroIntegration } from './zoteroIntegration/model'; sequelize.addModels([ ActivityItem, + AnalyticsEvent, AuthToken, Collection, CollectionAttribution, @@ -159,6 +161,7 @@ export const includeUserModel = (() => { export { ActivityItem, + AnalyticsEvent, AuthToken, Collection, CollectionAttribution, From 8b321d09d6b5b566fb560b43e2577cc61fe531fc Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 2 Apr 2026 15:18:45 +0200 Subject: [PATCH 10/41] feat: local analytics --- .../AdminDashboard/AdminDashboard.tsx | 2 +- .../DashboardImpact/DashboardImpact.tsx | 2 +- infra/Caddyfile | 10 +- infra/docker-compose.dev.yml | 38 +- infra/docker-compose.prod.yml | 28 + infra/stack.yml | 36 ++ server/analytics/api.ts | 18 +- server/routes/dashboardImpact.tsx | 1 + server/server.ts | 4 + .../migrate-metabase-queries.ts | 505 ++++++++++++++++++ tools/analytics-migration/migrate.sh | 266 +++++++++ 11 files changed, 887 insertions(+), 23 deletions(-) create mode 100644 tools/analytics-migration/migrate-metabase-queries.ts create mode 100755 tools/analytics-migration/migrate.sh 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/DashboardImpact/DashboardImpact.tsx b/client/containers/DashboardImpact/DashboardImpact.tsx index f87aa4792c..b0a455f0ce 100644 --- a/client/containers/DashboardImpact/DashboardImpact.tsx +++ b/client/containers/DashboardImpact/DashboardImpact.tsx @@ -28,7 +28,7 @@ const DashboardImpact = (props: Props) => { 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`; + return `http://localhost:3030/embed/dashboard/${token}#bordered=false&titled=false`; }; const getOffset = (width) => { return width < 960 ? 45 : 61; diff --git a/infra/Caddyfile b/infra/Caddyfile index 14846e6fc2..fcd956c38f 100644 --- a/infra/Caddyfile +++ b/infra/Caddyfile @@ -7,10 +7,18 @@ respond "OK" 200 } +{$METABASE_HOST:analytics.localhost} { + tls internal { + on_demand + } + encode gzip + reverse_proxy pubpub_metabase:3001 +} + :443 { tls internal { on_demand } 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 c590199cf5..dbe4f78478 100644 --- a/infra/docker-compose.dev.yml +++ b/infra/docker-compose.dev.yml @@ -85,24 +85,36 @@ services: ports: - "${DB_PORT:-5439}:5432" networks: [appnet] - - analytics_db: - image: postgres:16 + + metabase: + image: metabase/metabase:v0.48.1 + environment: + MB_DB_TYPE: postgres + MB_DB_DBNAME: metabasedb + MB_DB_PORT: "5432" + MB_DB_HOST: metabase_db + MB_DB_USER: appuser + MB_DB_PASS: apppassword + MB_JETTY_PORT: "3001" + depends_on: + - metabase_db + - db + ports: + - "${METABASE_PORT:-3030}:3001" + networks: [appnet] + + metabase_db: + image: postgres:18 environment: - POSTGRES_USER=appuser - POSTGRES_PASSWORD=apppassword - - POSTGRES_DB=analyticsdb - command: > - -c shared_buffers=2GB - -c effective_cache_size=6GB - -c work_mem=16MB - -c maintenance_work_mem=512MB + - POSTGRES_DB=metabasedb volumes: - - analytics_pgdata:/var/lib/postgresql/data - - ./analytics_backup.sql:/docker-entrypoint-initdb.d/analytics_backup.sql + - metabase_pgdata:/var/lib/postgresql ports: - - "${ANALYTICS_DB_PORT:-5440}:5432" + - "${METABASE_DB_PORT:-5440}:5432" networks: [appnet] + # cron: # build: # context: .. @@ -128,4 +140,4 @@ networks: volumes: pgdata: rabbitmqdata: - analytics_pgdata: \ No newline at end of file + metabase_pgdata: diff --git a/infra/docker-compose.prod.yml b/infra/docker-compose.prod.yml index 2819c25fcc..1a24980379 100644 --- a/infra/docker-compose.prod.yml +++ b/infra/docker-compose.prod.yml @@ -64,6 +64,33 @@ services: limits: memory: 2G + metabase: + image: metabase/metabase:latest + environment: + MB_DB_TYPE: postgres + MB_DB_DBNAME: metabasedb + MB_DB_PORT: "5432" + MB_DB_HOST: metabase_db + MB_DB_USER: appuser + MB_DB_PASS: apppassword + MB_JETTY_PORT: "3001" + depends_on: + - metabase_db + - db + ports: + - "${METABASE_PORT:-3030}:3001" + networks: [appnet] + + metabase_db: + image: postgres:16 + environment: + - POSTGRES_USER=appuser + - POSTGRES_PASSWORD=apppassword + - POSTGRES_DB=metabasedb + volumes: + - metabase_pgdata:/var/lib/postgresql/data + networks: [appnet] + rabbitmq: image: rabbitmq:3.13-alpine environment: @@ -102,3 +129,4 @@ networks: volumes: # pgdata: rabbitmqdata: + metabase_pgdata: diff --git a/infra/stack.yml b/infra/stack.yml index 14ca48d79b..b7ea1777f0 100644 --- a/infra/stack.yml +++ b/infra/stack.yml @@ -126,6 +126,41 @@ services: restart_policy: condition: any + metabase: + image: metabase/metabase:latest + environment: + MB_DB_TYPE: postgres + MB_DB_DBNAME: metabasedb + MB_DB_PORT: '5432' + MB_DB_HOST: metabase_db + MB_DB_USER: '${METABASE_DB_USER:-appuser}' + MB_DB_PASS: '${METABASE_DB_PASS}' + MB_JETTY_PORT: '3001' + networks: [appnet] + deploy: + replicas: 1 + resources: + limits: + memory: 2G + reservations: + memory: 1G + restart_policy: + condition: any + + metabase_db: + image: postgres:16 + environment: + POSTGRES_USER: '${METABASE_DB_USER:-appuser}' + POSTGRES_PASSWORD: '${METABASE_DB_PASS}' + POSTGRES_DB: metabasedb + volumes: + - metabase_pgdata:/var/lib/postgresql/data + networks: [appnet] + deploy: + replicas: 1 + restart_policy: + condition: any + pubstash: image: ghcr.io/knowledgefutures/pubpub:${IMAGE_TAG} env_file: [.env] @@ -163,5 +198,6 @@ networks: volumes: pgdata: rabbitmqdata: + metabase_pgdata: caddy_data: caddy_config: diff --git a/server/analytics/api.ts b/server/analytics/api.ts index e7bbeb6f6a..af27a9e017 100644 --- a/server/analytics/api.ts +++ b/server/analytics/api.ts @@ -6,6 +6,7 @@ import express from 'express'; import { contract } from 'utils/api/contract'; +import { abortStorage } from '../abort'; import { AnalyticsEvent } from './model'; const s = initServer(); @@ -47,13 +48,16 @@ export const analyticsServer = s.router(contract.analytics, { const { name: country = null, id: countryCode = null } = getCountryForTimezone(timezone) || {}; - try { - await AnalyticsEvent.create( - toEventRecord(payload, { country, countryCode }) as any, - ); - } catch (err) { - console.error('Failed to store analytics event:', err); - } + const record = toEventRecord(payload, { country, countryCode }); + + // sendBeacon closes the connection immediately, which triggers the + // request abort middleware. run the insert in a fresh context so it + // isn't killed by the abort signal. + abortStorage.run({ abortController: new AbortController() }, () => { + AnalyticsEvent.create(record as any).catch((err) => { + console.error('Failed to store analytics event:', err); + }); + }); return { status: 204, diff --git a/server/routes/dashboardImpact.tsx b/server/routes/dashboardImpact.tsx index d082304f21..4d6b071779 100644 --- a/server/routes/dashboardImpact.tsx +++ b/server/routes/dashboardImpact.tsx @@ -23,6 +23,7 @@ router.get( throw new NotFoundError(); } const { activeTargetType, activeTarget } = initialData.scopeData.elements; + console.log('activeTargetType', activeTargetType, initialData.locationData.isProd); const impactData = { baseToken: generateMetabaseToken(activeTargetType, activeTarget.id, 'base'), newToken: generateMetabaseToken( diff --git a/server/server.ts b/server/server.ts index 5df9cd7be7..06b7122967 100755 --- a/server/server.ts +++ b/server/server.ts @@ -195,6 +195,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/tools/analytics-migration/migrate-metabase-queries.ts b/tools/analytics-migration/migrate-metabase-queries.ts new file mode 100644 index 0000000000..f50416d6a7 --- /dev/null +++ b/tools/analytics-migration/migrate-metabase-queries.ts @@ -0,0 +1,505 @@ +// migrates metabase saved questions from a redshift-backed database to +// the new postgres database. uses the metabase API exclusively -- +// no parquet, no direct db access. + +// ── column/table name mapping ── + +const ANALYTICS_COLUMN_MAP: Record = { + collectionid: 'collectionId', + collectionkind: 'collectionKind', + communityid: 'communityId', + communityname: 'communityName', + communitysubdomain: 'communitySubdomain', + countrycode: 'countryCode', + isprod: 'isProd', + primarycollectionid: 'primaryCollectionId', + pubid: 'pubId', + pubtitle: 'pubTitle', + pubslug: 'pubSlug', + pagetitle: 'pageTitle', + pageid: 'pageId', + pageslug: 'pageSlug', + useragent: 'userAgent', + utmsource: 'utmSource', + utmmedium: 'utmMedium', + utmcampaign: 'utmCampaign', + utmterm: 'utmTerm', + utmcontent: 'utmContent', + collectionids: 'collectionIds', + collectiontitle: 'collectionTitle', + collectionslug: 'collectionSlug', + unique: 'isUnique', + release__string: 'release', + + // dropped columns + release__bigint: null, + __sdc_primary_key: null, + _sdc_batched_at: null, + _sdc_received_at: null, + _sdc_sequence: null, + _sdc_table_version: null, +}; + +const ANALYTICS_TABLE_OLD = 'data'; +const ANALYTICS_TABLE_NEW = 'AnalyticsEvents'; + +// ── types ── + +type Table = { + id: number; + name: string; + schema: string; + db_id: number; + fields: Field[]; +}; + +type Field = { + id: number; + name: string; + table_id: number; +}; + +type Card = { + id: number; + name: string; + description: string | null; + display: string; + collection_id: number; + dataset_query: DatasetQuery; + visualization_settings: Record; +}; + +type DatasetQuery = { + type: 'query' | 'native'; + database: number; + query?: Record; + native?: { query: string; 'template-tags'?: Record }; +}; + +type IdMaps = { + tableIdMap: Record; + fieldIdMap: Record; + cardIdMap: Record; +}; + +// ── metabase api ── + +async function metabaseRequest( + baseUrl: string, + session: string, + method: string, + path: string, + body?: unknown, +): Promise { + const headers: Record = { 'X-Metabase-Session': session }; + + if (body) { + headers['Content-Type'] = 'application/json'; + } + + const res = await fetch(`${baseUrl}/api${path}`, { + method, + headers, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`${method} ${path}: ${res.status} ${text}`); + } + + return res.json(); +} + +// ── field mapping ── + +function buildFieldMapping(oldTables: Table[], newTables: Table[]) { + const tableIdMap: Record = {}; + const fieldIdMap: Record = {}; + const warnings: string[] = []; + + const newByName = new Map(); + for (const t of newTables) { + newByName.set(t.name.toLowerCase(), t); + } + + for (const oldTable of oldTables) { + const isAnalytics = oldTable.name.toLowerCase() === ANALYTICS_TABLE_OLD; + const targetName = isAnalytics ? ANALYTICS_TABLE_NEW : oldTable.name; + const newTable = newByName.get(targetName.toLowerCase()); + + if (!newTable) { + if (oldTable.name.startsWith('_sdc_')) continue; + warnings.push(`table not mapped: ${oldTable.name}`); + continue; + } + + tableIdMap[oldTable.id] = newTable.id; + + const newFieldsByName = new Map(); + for (const f of newTable.fields) { + newFieldsByName.set(f.name.toLowerCase(), f); + } + + for (const oldField of oldTable.fields) { + let targetFieldName: string | null; + + if (isAnalytics) { + const mapped = ANALYTICS_COLUMN_MAP[oldField.name.toLowerCase()]; + targetFieldName = mapped === undefined ? oldField.name : mapped; + } else { + targetFieldName = oldField.name; + } + + if (targetFieldName === null) continue; + + const newField = + newFieldsByName.get(targetFieldName) || + newFieldsByName.get(targetFieldName.toLowerCase()); + + if (newField) { + fieldIdMap[oldField.id] = newField.id; + } + } + } + + return { tableIdMap, fieldIdMap, warnings }; +} + +// ── mbql query remapping ── + +function remapQuery(obj: unknown, maps: IdMaps): unknown { + if (Array.isArray(obj)) { + if (obj.length >= 2 && obj[0] === 'field' && typeof obj[1] === 'number') { + const mapped = maps.fieldIdMap[obj[1]] ?? obj[1]; + return ['field', mapped, ...obj.slice(2).map((x) => remapQuery(x, maps))]; + } + + return obj.map((item) => remapQuery(item, maps)); + } + + if (obj && typeof obj === 'object') { + const result: Record = {}; + + for (const [k, v] of Object.entries(obj)) { + if (k === 'source-table' && typeof v === 'number') { + result[k] = maps.tableIdMap[v] ?? v; + } else if (k === 'source-table' && typeof v === 'string' && v.startsWith('card__')) { + const oldId = parseInt(v.replace('card__', ''), 10); + result[k] = `card__${maps.cardIdMap[oldId] ?? oldId}`; + } else { + result[k] = remapQuery(v, maps); + } + } + + return result; + } + + return obj; +} + +// ── native sql remapping ── + +function remapNativeSql(sql: string): string { + let result = sql; + + // schema-qualified analytics table references + result = result.replace(/"pubpub_analytics"\s*\.\s*"data"/gi, `"${ANALYTICS_TABLE_NEW}"`); + result = result.replace(/pubpub_analytics\s*\.\s*data/gi, `"${ANALYTICS_TABLE_NEW}"`); + result = result.replace(/"pubpub_analytics"\s*\.\s*/g, ''); + + // unqualified analytics table + result = result.replace(/\bFROM\s+"data"/gi, `FROM "${ANALYTICS_TABLE_NEW}"`); + result = result.replace(/\bJOIN\s+"data"/gi, `JOIN "${ANALYTICS_TABLE_NEW}"`); + + // analytics column names (quoted) + for (const [oldName, newName] of Object.entries(ANALYTICS_COLUMN_MAP)) { + if (newName === null) continue; + if (oldName === newName) continue; + + result = result.replace(new RegExp(`"${oldName}"`, 'gi'), `"${newName}"`); + } + + // redshift timestamp conversion pattern -> just use the column directly + result = result.replace( + /TIMESTAMP\s+'1970-01-01T00:00:00Z'\s*\+\s*"?timestamp"?\s*\*\s*INTERVAL\s+'1 second'\s*\/\s*1000/gi, + '"timestamp"', + ); + + // strip _sdc_ column references (with optional leading comma) + result = result.replace(/,?\s*"?_sdc_\w+"?\s*/g, ' '); + + return result; +} + +// ── topological sort (for card->card references) ── + +function findCardRefs(obj: unknown): number[] { + const refs: number[] = []; + + const walk = (o: unknown) => { + if (!o) return; + + if (typeof o === 'string' && o.startsWith('card__')) { + refs.push(parseInt(o.replace('card__', ''), 10)); + } + + if (Array.isArray(o)) { + o.forEach(walk); + } else if (typeof o === 'object') { + Object.values(o as Record).forEach(walk); + } + }; + + walk(obj); + return refs; +} + +function topologicalSort(cards: Card[], targetIds: Set): number[] { + const sorted: number[] = []; + const visited = new Set(); + const visiting = new Set(); + + const cardById = new Map(cards.map((c) => [c.id, c])); + + const visit = (id: number) => { + if (visited.has(id) || visiting.has(id)) return; + + visiting.add(id); + + const card = cardById.get(id); + if (card) { + for (const ref of findCardRefs(card.dataset_query)) { + if (targetIds.has(ref)) visit(ref); + } + } + + visiting.delete(id); + visited.add(id); + sorted.push(id); + }; + + for (const id of targetIds) visit(id); + + return sorted; +} + +// ── arg parsing ── + +function parseArgs(argv: string[]) { + const result: Record = {}; + + for (let i = 0; i < argv.length; i++) { + if (!argv[i].startsWith('--')) continue; + + const key = argv[i].replace(/^--/, ''); + const next = argv[i + 1]; + + if (next && !next.startsWith('--')) { + result[key] = next; + i++; + } else { + result[key] = 'true'; + } + } + + return result; +} + +// ── main ── + +async function main() { + const raw = parseArgs(process.argv.slice(2)); + + const metabaseUrl = raw['metabase-url'] ?? 'http://localhost:3030'; + const session = raw.session ?? ''; + const oldDbId = parseInt(raw['old-db-id'] ?? '0', 10); + const newDbId = parseInt(raw['new-db-id'] ?? '0', 10); + const collectionId = parseInt(raw.collection ?? '14', 10); + const dryRun = raw['dry-run'] === 'true'; + + const isMissingArgs = !session || !oldDbId || !newDbId; + if (isMissingArgs) { + console.error('required: --session, --old-db-id, --new-db-id'); + process.exit(1); + } + + const get = (path: string) => metabaseRequest(metabaseUrl, session, 'GET', path); + const post = (path: string, body: unknown) => + metabaseRequest(metabaseUrl, session, 'POST', path, body); + + // fetch metadata for old and new databases in parallel + const [oldDb, newDb] = await Promise.all([ + get(`/database/${oldDbId}/metadata?include_hidden=true`), + get(`/database/${newDbId}/metadata?include_hidden=true`), + ]); + + const { tableIdMap, fieldIdMap, warnings } = buildFieldMapping( + oldDb.tables || [], + newDb.tables || [], + ); + + console.log( + ` mapped ${Object.keys(tableIdMap).length} tables, ${Object.keys(fieldIdMap).length} fields`, + ); + + for (const w of warnings) { + console.log(` warning: ${w}`); + } + + // fetch cards in the target collection + const collectionItems = await get( + `/collection/${collectionId}/items?models=card&models=dataset`, + ); + + const itemList = collectionItems.data || collectionItems; + const cardIds: number[] = itemList.map((item: { id: number }) => item.id); + + if (cardIds.length === 0) { + console.log(' no cards found in collection'); + return; + } + + const cards: Card[] = await Promise.all(cardIds.map((id) => get(`/card/${id}`))); + const sortedIds = topologicalSort(cards, new Set(cardIds)); + const cardById = new Map(cards.map((c) => [c.id, c])); + + // create target collection + const { name: oldCollectionName } = await get(`/collection/${collectionId}`); + const newCollectionName = `Migrated - ${oldCollectionName}`; + + if (dryRun) { + console.log(`\n dry run: would create collection '${newCollectionName}'`); + console.log(` dry run: would migrate ${sortedIds.length} cards\n`); + + for (const id of sortedIds) { + const card = cardById.get(id)!; + const dq = card.dataset_query; + + console.log(`--- ${card.name} (id=${id}, ${dq.type}) ---`); + + if (dq.type === 'native') { + console.log(remapNativeSql(dq.native?.query || '')); + } else { + const remapped = remapQuery(dq.query, { tableIdMap, fieldIdMap, cardIdMap: {} }); + console.log(JSON.stringify(remapped, null, 2)); + } + + console.log(); + } + + return; + } + + const newCollection = await post('/collection', { + name: newCollectionName, + description: `auto-migrated from '${oldCollectionName}'`, + }); + + // migrate cards + const cardIdMap: Record = {}; + const maps: IdMaps = { tableIdMap, fieldIdMap, cardIdMap }; + let succeeded = 0; + let failed = 0; + + for (const oldId of sortedIds) { + const card = cardById.get(oldId); + if (!card) continue; + + const dq = card.dataset_query; + let newDatasetQuery: DatasetQuery; + + if (dq.type === 'native') { + newDatasetQuery = { + type: 'native', + database: newDbId, + native: { + query: remapNativeSql(dq.native?.query || ''), + 'template-tags': dq.native?.['template-tags'] || {}, + }, + }; + } else { + newDatasetQuery = { + type: 'query', + database: newDbId, + query: remapQuery(dq.query, maps) as Record, + }; + } + + try { + // biome-ignore lint/correctness/noAwaitInLoop: sequential creation required for card->card references + const newCard = await post('/card', { + name: card.name, + description: card.description, + display: card.display, + dataset_query: newDatasetQuery, + collection_id: newCollection.id, + visualization_settings: card.visualization_settings, + }); + + cardIdMap[oldId] = newCard.id; + console.log(` created: ${card.name} (${oldId} -> ${newCard.id})`); + succeeded++; + } catch (e) { + console.error(` FAILED: ${card.name} (${oldId}): ${e}`); + cardIdMap[oldId] = oldId; + failed++; + } + } + + // migrate dashboards in the collection + const dashboardItems = await get(`/collection/${collectionId}/items?models=dashboard`); + const dashList = dashboardItems.data || dashboardItems; + + for (const item of dashList) { + try { + // biome-ignore lint/correctness/noAwaitInLoop: dashboards must be created one at a time + const oldDash = await get(`/dashboard/${item.id}`); + + const newDash = await post('/dashboard', { + name: oldDash.name, + description: oldDash.description, + collection_id: newCollection.id, + parameters: oldDash.parameters || [], + }); + + const oldCards = oldDash.ordered_cards || oldDash.dashcards || []; + const dashCards = []; + + for (let i = 0; i < oldCards.length; i++) { + const dc = oldCards[i]; + const oldCardId = dc.card_id; + const newCardId = cardIdMap[oldCardId] ?? oldCardId; + const remappedParams = remapQuery(dc.parameter_mappings || [], maps); + + dashCards.push({ + id: -(i + 1), + card_id: newCardId, + row: dc.row ?? 0, + col: dc.col ?? 0, + size_x: dc.size_x ?? 6, + size_y: dc.size_y ?? 4, + parameter_mappings: remappedParams, + visualization_settings: dc.visualization_settings || {}, + }); + } + + if (dashCards.length > 0) { + await metabaseRequest(metabaseUrl, session, 'PUT', `/dashboard/${newDash.id}`, { + dashcards: dashCards, + }); + } + + console.log(` created dashboard: ${oldDash.name} (${oldCards.length} cards)`); + } catch (e) { + console.error(` FAILED dashboard: ${item.name}: ${e}`); + } + } + + console.log(`\n migration complete: ${succeeded} succeeded, ${failed} failed`); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/tools/analytics-migration/migrate.sh b/tools/analytics-migration/migrate.sh new file mode 100755 index 0000000000..852257d711 --- /dev/null +++ b/tools/analytics-migration/migrate.sh @@ -0,0 +1,266 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +REDSHIFT_BAK_DIR="${PROJECT_ROOT}/redshift_bak_data" +S3_REDSHIFT_PATH="${S3_REDSHIFT_PATH:-s3://temp-migration-redshift/pubpub_analytics/backup_20260402/}" +DATABASE_URL="${DATABASE_URL:-postgres://appuser:apppassword@localhost:5439/appdb}" + +echo "Analytics Migration" +echo "===================" +echo " database: $DATABASE_URL" +echo " backup: $REDSHIFT_BAK_DIR" +echo "" + +# ── step 1: download from s3 if not already present ── + +if [ ! -f "$REDSHIFT_BAK_DIR/data000" ]; then + echo "[1/5] downloading redshift backup from s3..." + mkdir -p "$REDSHIFT_BAK_DIR" + aws s3 cp "$S3_REDSHIFT_PATH" "$REDSHIFT_BAK_DIR" --recursive +else + echo "[1/5] backup already present, skipping download" +fi + +# ── step 2: ensure the AnalyticsEvents table exists ── + +echo "[2/5] ensuring AnalyticsEvents table exists..." +psql "$DATABASE_URL" <<'SQL' +CREATE TABLE IF NOT EXISTS "AnalyticsEvents" ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + type text NOT NULL, + event text NOT NULL, + "timestamp" timestamptz NOT NULL, + 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, + "communitySubdomain" text, + "communityName" text, + "isProd" boolean NOT NULL, + country text, + "countryCode" text, + url text, + title text, + hash text, + height integer, + width integer, + path text, + "pageTitle" text, + "pageId" uuid, + "pageSlug" text, + "collectionTitle" text, + "collectionKind" text, + "collectionId" uuid, + "collectionSlug" text, + "pubTitle" text, + "pubId" uuid, + "pubSlug" text, + "collectionIds" text[], + "primaryCollectionId" uuid, + release text, + format text, + "createdAt" timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS "analytics_events_community_ts" + ON "AnalyticsEvents" ("communityId", "timestamp"); +CREATE INDEX IF NOT EXISTS "analytics_events_pub_ts" + ON "AnalyticsEvents" ("pubId", "timestamp"); +CREATE INDEX IF NOT EXISTS "analytics_events_event" + ON "AnalyticsEvents" (event); +CREATE INDEX IF NOT EXISTS "analytics_events_collection" + ON "AnalyticsEvents" ("collectionId"); +SQL + +# ── step 3: create staging table and import csv ── + +echo "[3/5] creating staging table..." +psql "$DATABASE_URL" <<'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 +); +SQL + +echo "[4/5] importing csv files into staging..." +for f in "$REDSHIFT_BAK_DIR"/data[0-9][0-9][0-9]; do + echo " $(basename "$f")..." + psql "$DATABASE_URL" -c "\copy analytics_staging FROM '$f' CSV HEADER" +done + +ROW_COUNT=$(psql "$DATABASE_URL" -tA -c "SELECT count(*) FROM analytics_staging") +echo " staged $ROW_COUNT rows" + +# ── step 5: transform and insert ── + +echo "[5/5] transforming into AnalyticsEvents..." +psql "$DATABASE_URL" -v ON_ERROR_STOP=1 <<'SQL' +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; + +-- cast through double precision to handle scientific notation (e.g. 1.71974e+14) +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; + +INSERT INTO "AnalyticsEvents" ( + id, type, event, "timestamp", + referrer, "isUnique", search, + "utmSource", "utmMedium", "utmCampaign", "utmTerm", "utmContent", + timezone, locale, "userAgent", os, + "communityId", "communitySubdomain", "communityName", "isProd", + country, "countryCode", + url, title, hash, height, width, path, + "pageTitle", "pageId", "pageSlug", + "collectionTitle", "collectionKind", "collectionId", "collectionSlug", + "pubTitle", "pubId", "pubSlug", + "collectionIds", "primaryCollectionId", release, format, + "createdAt" +) +SELECT + COALESCE(pg_temp.safe_uuid(s.__sdc_primary_key), gen_random_uuid()), + s.type, + s.event, + pg_temp.safe_ts(s."timestamp"), + + NULLIF(s.referrer, ''), + CASE WHEN s."unique" = 't' THEN true + WHEN s."unique" = 'f' THEN false + ELSE NULL END, + NULLIF(s.search, ''), + + NULLIF(s.utmsource, ''), + NULLIF(s.utmmedium, ''), + NULLIF(s.utmcampaign, ''), + NULLIF(s.utmterm, ''), + NULLIF(s.utmcontent, ''), + + COALESCE(NULLIF(s.timezone, ''), 'UTC'), + COALESCE(NULLIF(s.locale, ''), 'en-US'), + COALESCE(NULLIF(s.useragent, ''), 'unknown'), + COALESCE(NULLIF(s.os, ''), 'unknown'), + + pg_temp.safe_uuid(s.communityid), + NULLIF(s.communitysubdomain, ''), + NULLIF(s.communityname, ''), + COALESCE(s.isprod = 't', false), + + NULLIF(s.country, ''), + NULLIF(s.countrycode, ''), + + NULLIF(s.url, ''), + NULLIF(s.title, ''), + NULLIF(s.hash, ''), + CASE WHEN s.height ~ '^\d{1,9}$' THEN s.height::int ELSE NULL END, + CASE WHEN s.width ~ '^\d{1,9}$' THEN s.width::int ELSE NULL END, + NULLIF(s.path, ''), + + NULLIF(s.pagetitle, ''), + pg_temp.safe_uuid(s.pageid), + NULLIF(s.pageslug, ''), + + NULLIF(s.collectiontitle, ''), + NULLIF(s.collectionkind, ''), + pg_temp.safe_uuid(s.collectionid), + NULLIF(s.collectionslug, ''), + + NULLIF(s.pubtitle, ''), + pg_temp.safe_uuid(s.pubid), + NULLIF(s.pubslug, ''), + + CASE WHEN NULLIF(s.collectionids, '') IS NOT NULL + THEN string_to_array(s.collectionids, ',') + ELSE NULL END, + pg_temp.safe_uuid(s.primarycollectionid), + COALESCE(NULLIF(s.release__string, ''), NULLIF(s.release__bigint, '')), + NULLIF(s.format, ''), + + COALESCE( + CASE WHEN NULLIF(s._sdc_received_at, '') IS NOT NULL + THEN s._sdc_received_at::timestamptz + ELSE NULL END, + pg_temp.safe_ts(s."timestamp"), + now() + ) + +FROM analytics_staging s +WHERE s.type IS NOT NULL + AND s.event IS NOT NULL + AND pg_temp.safe_ts(s."timestamp") IS NOT NULL; +SQL + +echo " cleaning up staging table..." +psql "$DATABASE_URL" -c "DROP TABLE IF EXISTS analytics_staging;" +psql "$DATABASE_URL" -c 'ANALYZE "AnalyticsEvents";' + +FINAL_COUNT=$(psql "$DATABASE_URL" -tA -c 'SELECT count(*) FROM "AnalyticsEvents"') +echo "" +echo "done. $FINAL_COUNT rows in AnalyticsEvents." From 7262646452f51699ad564bb3b23608ae74965f84 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Thu, 2 Apr 2026 14:16:55 -0400 Subject: [PATCH 11/41] Minor padding --- client/containers/DashboardImpact2/dashboardImpact2.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/containers/DashboardImpact2/dashboardImpact2.scss b/client/containers/DashboardImpact2/dashboardImpact2.scss index 71ca5a1f2c..e56522d328 100644 --- a/client/containers/DashboardImpact2/dashboardImpact2.scss +++ b/client/containers/DashboardImpact2/dashboardImpact2.scss @@ -161,7 +161,7 @@ font-size: 11px; color: #777; line-height: 1.5; - margin: 0 0 4px; + margin: 0 0 8px; code { font-size: 10px; From f87388e57b2dbf9e7b1241cef558b76aa96639a3 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Thu, 2 Apr 2026 15:23:07 -0400 Subject: [PATCH 12/41] Update routes and add banner --- client/components/ScopeDropdown/ScopeDropdown.tsx | 5 ----- .../containers/DashboardImpact2/DashboardImpact2.tsx | 12 ++++++++++++ .../DashboardImpact2/dashboardImpact2.scss | 6 ++++++ server/routes/dashboardImpact.tsx | 2 +- server/routes/dashboardImpact2.tsx | 2 +- utils/analytics/featureFlags.ts | 2 +- utils/dashboard.ts | 2 +- 7 files changed, 22 insertions(+), 9 deletions(-) diff --git a/client/components/ScopeDropdown/ScopeDropdown.tsx b/client/components/ScopeDropdown/ScopeDropdown.tsx index e1232ad9c5..993c2dd0a1 100644 --- a/client/components/ScopeDropdown/ScopeDropdown.tsx +++ b/client/components/ScopeDropdown/ScopeDropdown.tsx @@ -201,11 +201,6 @@ const ScopeDropdown = (props: Props) => { pubPubIcons.member, )} {renderDropddownButton(scope, 'impact', pubPubIcons.impact)} - {renderDropddownButton( - scope, - 'impact2', - pubPubIcons.impact, - )} {scope.type === 'Collection' && renderDropddownButton( scope, diff --git a/client/containers/DashboardImpact2/DashboardImpact2.tsx b/client/containers/DashboardImpact2/DashboardImpact2.tsx index 1d15404eac..796c02b427 100644 --- a/client/containers/DashboardImpact2/DashboardImpact2.tsx +++ b/client/containers/DashboardImpact2/DashboardImpact2.tsx @@ -217,6 +217,18 @@ const DashboardImpact2 = () => { {!loading && !error && data && ( <> + + 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. +
+
+ Our legacy analytics system has been deprecated but is still accessible{' '} + here for now. We plan to fully retire it at + the end of 2026. +
+ {stale && (
Data may be slightly delayed — try again in a few minutes. diff --git a/client/containers/DashboardImpact2/dashboardImpact2.scss b/client/containers/DashboardImpact2/dashboardImpact2.scss index e56522d328..c0780b0a2f 100644 --- a/client/containers/DashboardImpact2/dashboardImpact2.scss +++ b/client/containers/DashboardImpact2/dashboardImpact2.scss @@ -5,6 +5,12 @@ padding: 60px 0; } + .analytics-callout { + margin-bottom: 24px; + font-size: 13px; + line-height: 1.5; + } + // ── Top row: stats + chart ────────────────────────────────────────── .top-row { display: grid; diff --git a/server/routes/dashboardImpact.tsx b/server/routes/dashboardImpact.tsx index d082304f21..76acd2b3dc 100644 --- a/server/routes/dashboardImpact.tsx +++ b/server/routes/dashboardImpact.tsx @@ -12,7 +12,7 @@ import { generateMetaComponents, renderToNodeStream } from 'server/utils/ssr'; export const router = Router(); router.get( - ['/dash/impact', '/dash/collection/:collectionSlug/impact', '/dash/pub/:pubSlug/impact'], + ['/dash/impact-v1', '/dash/collection/:collectionSlug/impact-v1', '/dash/pub/:pubSlug/impact-v1'], async (req, res, next) => { try { if (!hostIsValid(req, 'community')) { diff --git a/server/routes/dashboardImpact2.tsx b/server/routes/dashboardImpact2.tsx index c7198d1336..615c5e8660 100644 --- a/server/routes/dashboardImpact2.tsx +++ b/server/routes/dashboardImpact2.tsx @@ -11,7 +11,7 @@ import { generateMetaComponents, renderToNodeStream } from 'server/utils/ssr'; export const router = Router(); router.get( - ['/dash/impact2', '/dash/collection/:collectionSlug/impact2', '/dash/pub/:pubSlug/impact2'], + ['/dash/impact', '/dash/collection/:collectionSlug/impact', '/dash/pub/:pubSlug/impact'], async (req, res, next) => { try { if (!hostIsValid(req, 'community')) { 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/dashboard.ts b/utils/dashboard.ts index 3ac35b0e7a..67b455db83 100644 --- a/utils/dashboard.ts +++ b/utils/dashboard.ts @@ -2,7 +2,7 @@ export type DashboardMode = | 'activity' | 'connections' | 'impact' - | 'impact2' + | 'impact-v1' | 'layout' | 'members' | 'overview' From 7ce024a71e24a77931cec423bb705f2def15e205 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Thu, 2 Apr 2026 15:55:27 -0400 Subject: [PATCH 13/41] Add scope support --- .../DashboardImpact2/DashboardImpact2.tsx | 83 +++-- server/analyticsDailyCache/model.ts | 13 +- server/impact2/api.ts | 46 ++- server/utils/cloudflareAnalytics.ts | 308 +++++++++++++++++- 4 files changed, 407 insertions(+), 43 deletions(-) diff --git a/client/containers/DashboardImpact2/DashboardImpact2.tsx b/client/containers/DashboardImpact2/DashboardImpact2.tsx index 796c02b427..eb83d94ba1 100644 --- a/client/containers/DashboardImpact2/DashboardImpact2.tsx +++ b/client/containers/DashboardImpact2/DashboardImpact2.tsx @@ -13,6 +13,7 @@ import { } from 'recharts'; import { DashboardFrame } from 'components'; +import { getDashUrl } from 'utils/dashboard'; import { usePageContext } from 'utils/hooks'; import './dashboardImpact2.scss'; @@ -111,7 +112,7 @@ const CompactTable = ({ const DashboardImpact2 = () => { const { scopeData } = usePageContext(); const { - // elements: { activeTargetName }, + elements: { activeTargetType, activeTargetName, activePub, activeCollection }, activePermissions: { canView }, } = scopeData; @@ -122,31 +123,53 @@ const DashboardImpact2 = () => { const [stale, setStale] = useState(false); const [dateRange, setDateRange] = useState('7d'); - 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}`); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - if (res.status === 503) { - setNotConfigured(true); - return; + const legacyImpactUrl = getDashUrl({ + mode: 'impact-v1', + 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})`); } - 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); } - 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); @@ -221,12 +244,13 @@ const DashboardImpact2 = () => { 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. + connecting a dedicated analytics tool in{' '} + Settings.

- Our legacy analytics system has been deprecated but is still accessible{' '} - here for now. We plan to fully retire it at - the end of 2026. + Legacy analytics remain available here. We + plan to wind down legacy analytics at the end of 2026. If you need + historical data, please export it. {stale && ( @@ -386,8 +410,7 @@ const DashboardImpact2 = () => { {/* Footer */}

- * Totals adjusted to exclude known bot/spam routes (e.g.{' '} - /wp-login, /cdn-cgi/). Sessions estimated + * Totals adjusted to exclude known bot/spam routes. Sessions estimated proportionally. Raw totals: {fmt(data.rawTotals.visits)} sessions /{' '} {fmt(data.rawTotals.pageViews)} page views.

diff --git a/server/analyticsDailyCache/model.ts b/server/analyticsDailyCache/model.ts index aaa8d7be0b..c7b5cfcd3f 100644 --- a/server/analyticsDailyCache/model.ts +++ b/server/analyticsDailyCache/model.ts @@ -3,9 +3,12 @@ import type { CreationOptional, InferAttributes, InferCreationAttributes } from import { AllowNull, Column, DataType, Model, PrimaryKey, Table } from 'sequelize-typescript'; /** - * Caches per-day Cloudflare analytics for a community hostname. + * Caches per-day Cloudflare analytics for a community hostname + scope. * - * Composite primary key: (hostname, date). + * 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). */ @@ -26,6 +29,12 @@ export class AnalyticsDailyCache extends Model< @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[] } diff --git a/server/impact2/api.ts b/server/impact2/api.ts index 8080ae385d..6b6df9d10c 100644 --- a/server/impact2/api.ts +++ b/server/impact2/api.ts @@ -1,9 +1,14 @@ import { Router } from 'express'; +import { Collection } from 'server/collection/model'; +import { CollectionPub } from 'server/collectionPub/model'; import { Community } from 'server/community/model'; +import { Pub } from 'server/pub/model'; import { debugCommunityAnalytics, + fetchCollectionAnalytics, fetchCommunityAnalytics, + fetchPubAnalytics, testCloudflareConnection, } from 'server/utils/cloudflareAnalytics'; import { ForbiddenError, handleErrors } from 'server/utils/errors'; @@ -102,10 +107,12 @@ router.get('/api/impact2/debug', async (req, res, next) => { /** * GET /api/impact2 * - * Returns Cloudflare-sourced analytics for the current community. + * 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 { @@ -129,7 +136,42 @@ router.get('/api/impact2', async (req, res, next) => { (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 fetchCommunityAnalytics(hostname, startDate, endDate); + 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.', diff --git a/server/utils/cloudflareAnalytics.ts b/server/utils/cloudflareAnalytics.ts index 1bd39f16f2..75344743df 100644 --- a/server/utils/cloudflareAnalytics.ts +++ b/server/utils/cloudflareAnalytics.ts @@ -249,12 +249,14 @@ type DayCachePayload = { async function getCachedDays( hostname: string, dates: string[], + scope = 'community', ): Promise> { if (dates.length === 0) return new Map(); const rows = await AnalyticsDailyCache.findAll({ where: { hostname, date: dates, + scope, [Op.or]: [ { expiresAt: null }, // permanent (past days) { expiresAt: { [Op.gt]: new Date() } }, // not yet expired (today) @@ -272,11 +274,12 @@ 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 AnalyticsDailyCache.upsert({ hostname, date, data, expiresAt }); + return AnalyticsDailyCache.upsert({ hostname, date, scope, data, expiresAt }); }); await Promise.all(promises); } @@ -305,7 +308,7 @@ const COMBINED_QUERY = ` } topPaths: httpRequestsAdaptiveGroups( filter: $filter - limit: 200 + limit: 1000 orderBy: [count_DESC] ) { count @@ -605,6 +608,18 @@ export async function fetchCommunityAnalytics( } // 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(); @@ -634,27 +649,20 @@ export async function fetchCommunityAnalytics( .sort((a, b) => b.count - a.count) .slice(0, 20); - // Subtract noise/bot path hits from totals so headline numbers - // reflect real human traffic as closely as possible. let noisePageViews = 0; for (const [path, count] of pathMap) { if (isNoisePath(path)) noisePageViews += count; } const adjustedPageViews = Math.max(0, totalPageViews - noisePageViews); - // Visits (unique sessions) aren't per-path, so scale proportionally. const ratio = totalPageViews > 0 ? adjustedPageViews / totalPageViews : 1; const adjustedVisits = Math.round(totalVisits * ratio); - // Apply noise ratio to daily chart data so the chart numbers - // are consistent with the adjusted headline totals (shape preserved). const adjustedDaily = daily.map((d) => ({ date: d.date, visits: Math.round(d.visits * ratio), pageViews: Math.round(d.pageViews * ratio), })); - // Apply the same proportional noise ratio to all breakdowns so that - // country / device / referrer counts are consistent with the adjusted totals. const countries = Array.from(countryMap.entries()) .map(([country, count]) => ({ country, count: Math.round(count * ratio) })) .filter((c) => c.count > 0) @@ -684,6 +692,288 @@ export async function fetchCommunityAnalytics( }; } +// --------------------------------------------------------------------------- +// 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(); + 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); +} + +// --------------------------------------------------------------------------- +// Collection-scope fetch (community data + pub cache enrichment) +// --------------------------------------------------------------------------- + +/** + * Fetch analytics scoped to a collection. + * + * Strategy: + * 1. Ensure community-level data is cached (triggers fetch if needed). + * 2. For each pub in the collection, check if pub-level cache exists. + * If so, use that (more accurate, includes low-traffic paths). + * If not, filter community topPaths by the pub's path prefix. + * 3. Also include the collection's own slug as a top-level page. + * 4. Countries/devices/referrers use community-level data (proportional). + * + * Cost: 0 extra CF API calls (reuses community + any existing pub caches). + */ +export async function fetchCollectionAnalytics( + hostname: string, + collectionSlug: string, + pubSlugs: string[], + startDate: string, + endDate: string, +): Promise { + const config = getConfig(); + if (!config) return null; + + const allDates = dateRange(startDate, endDate); + + // 1. Ensure community data is cached + const communityResult = await fetchCommunityAnalytics(hostname, startDate, endDate); + if (!communityResult) return null; + + // 2. Read community-level cached days (raw, pre-aggregation) + const communityCached = await getCachedDays(hostname, allDates, 'community'); + + // 3. Build the set of path prefixes that belong to this collection + const collectionPrefixes = [ + `/${collectionSlug}`, // collection layout page + ...pubSlugs.map((s) => `/pub/${s}`), + ]; + + function pathBelongsToCollection(path: string): boolean { + return collectionPrefixes.some((prefix) => path === prefix || path.startsWith(prefix + '/')); + } + + // 4. For each pub, check if we have pub-scoped cache (more 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); + } + } + + // 5. 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; + + // Start with collection paths filtered from community data + let dayVisits = 0; + let dayPageViews = 0; + const dayPaths: Array<{ path: string; count: number }> = []; + const slugsHandledByPubCache = new Set(); + + // First, add data from any pub-level caches (more 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); + } + } + + // Then, for pubs without pub-level cache + the collection page itself, + // filter from community topPaths + for (const p of communityDay.topPaths) { + // Skip paths already covered by pub-level cache + const coveredByPubCache = pubSlugs.some( + (slug) => + slugsHandledByPubCache.has(slug) && + (p.path === `/pub/${slug}` || p.path.startsWith(`/pub/${slug}/`)), + ); + if (coveredByPubCache) continue; + + if (pathBelongsToCollection(p.path)) { + dayPageViews += p.count; + dayPaths.push(p); + } + } + + // Estimate visits proportionally from community day + // (for paths from community data, not from pub cache) + if (communityDay.pageViews > 0 && dayPageViews > 0) { + const communityPathPageViews = dayPageViews - [...pubCaches.values()] + .reduce((sum, cache) => sum + (cache.get(date)?.pageViews ?? 0), 0); + if (communityPathPageViews > 0) { + const visitRatio = communityDay.visits / communityDay.pageViews; + dayVisits += Math.round(communityPathPageViews * visitRatio); + } + } + + // Countries/devices/referrers: use community-level, scaled by this + // collection's share of community traffic for the day + const shareRatio = communityDay.pageViews > 0 ? dayPageViews / communityDay.pageViews : 0; + + collectionDays.set(date, { + visits: dayVisits, + pageViews: dayPageViews, + topPaths: dayPaths, + countries: communityDay.countries.map((c) => ({ + country: c.country, + count: Math.round(c.count * shareRatio), + })).filter((c) => c.count > 0), + devices: communityDay.devices.map((d) => ({ + device: d.device, + count: Math.round(d.count * shareRatio), + })).filter((d) => d.count > 0), + referrers: communityDay.referrers.map((r) => ({ + referrer: r.referrer, + count: Math.round(r.count * shareRatio), + })).filter((r) => r.count > 0), + }); + } + + return aggregateDays(allDates, collectionDays, !!communityResult.stale); +} + // --------------------------------------------------------------------------- // Debug helper // --------------------------------------------------------------------------- From 25ae6123911cf4faa6109be4cb1e632555ad199c Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Thu, 2 Apr 2026 16:28:44 -0400 Subject: [PATCH 14/41] Update queryCounts and loading skeleton --- .../DashboardImpact2/DashboardImpact2.tsx | 62 ++-- .../DashboardImpact2/dashboardImpact2.scss | 67 ++++- server/utils/cloudflareAnalytics.ts | 272 +++++++++++++++--- 3 files changed, 338 insertions(+), 63 deletions(-) diff --git a/client/containers/DashboardImpact2/DashboardImpact2.tsx b/client/containers/DashboardImpact2/DashboardImpact2.tsx index eb83d94ba1..883a3bdfeb 100644 --- a/client/containers/DashboardImpact2/DashboardImpact2.tsx +++ b/client/containers/DashboardImpact2/DashboardImpact2.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { Button, ButtonGroup, Callout, NonIdealState, Spinner } from '@blueprintjs/core'; +import { Button, ButtonGroup, Callout, NonIdealState } from '@blueprintjs/core'; import { Area, AreaChart, @@ -211,9 +211,52 @@ const DashboardImpact2 = () => { } > + + 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 + plan to wind down legacy analytics at the end of 2026. If you need + historical legacy data, please export it from there. +
+ {loading && ( -
- +
+ {/* Top row: stats + chart */} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Data grid */} +
+ {[0, 1, 2, 3].map((i) => ( +
+
+ {[0, 1, 2, 3, 4, 5].map((j) => ( +
+
+
+
+ ))} +
+ ))} +
)} @@ -240,19 +283,6 @@ const DashboardImpact2 = () => { {!loading && !error && data && ( <> - - 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 - plan to wind down legacy analytics at the end of 2026. If you need - historical data, please export it. -
- {stale && (
Data may be slightly delayed — try again in a few minutes. diff --git a/client/containers/DashboardImpact2/dashboardImpact2.scss b/client/containers/DashboardImpact2/dashboardImpact2.scss index c0780b0a2f..d682634a04 100644 --- a/client/containers/DashboardImpact2/dashboardImpact2.scss +++ b/client/containers/DashboardImpact2/dashboardImpact2.scss @@ -1,8 +1,60 @@ .dashboard-impact2-container { - .loading-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: center; - padding: 60px 0; + justify-content: space-between; + padding: 5px 4px; + } + + .skeleton-cell { + width: 65%; + height: 12px; + } + + .skeleton-cell-short { + width: 30px; + height: 12px; } .analytics-callout { @@ -192,6 +244,15 @@ .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 { diff --git a/server/utils/cloudflareAnalytics.ts b/server/utils/cloudflareAnalytics.ts index 75344743df..76749b5fa8 100644 --- a/server/utils/cloudflareAnalytics.ts +++ b/server/utils/cloudflareAnalytics.ts @@ -212,12 +212,13 @@ import { AnalyticsDailyCache } from 'server/analyticsDailyCache/model'; const TODAY_CACHE_TTL_MS = 1 * 60 * 60 * 1000; /** - * Delete cache rows older than 90 days. + * 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 = 90; +const CACHE_MAX_AGE_DAYS = 45; const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour let lastCleanup = 0; @@ -308,7 +309,7 @@ const COMBINED_QUERY = ` } topPaths: httpRequestsAdaptiveGroups( filter: $filter - limit: 1000 + limit: 10000 orderBy: [count_DESC] ) { count @@ -837,21 +838,165 @@ export async function fetchPubAnalytics( } // --------------------------------------------------------------------------- -// Collection-scope fetch (community data + pub cache enrichment) +// 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(); + 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 (triggers fetch if needed). - * 2. For each pub in the collection, check if pub-level cache exists. - * If so, use that (more accurate, includes low-traffic paths). - * If not, filter community topPaths by the pub's path prefix. - * 3. Also include the collection's own slug as a top-level page. - * 4. Countries/devices/referrers use community-level data (proportional). + * 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 extra CF API calls (reuses community + any existing pub caches). + * 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, @@ -862,27 +1007,34 @@ export async function fetchCollectionAnalytics( ): Promise { const config = getConfig(); if (!config) return null; + const { apiToken, zoneTag } = config; const allDates = dateRange(startDate, endDate); - // 1. Ensure community data is cached + // 1. Ensure community data is cached (for breakdowns + fallback) const communityResult = await fetchCommunityAnalytics(hostname, startDate, endDate); if (!communityResult) return null; - // 2. Read community-level cached days (raw, pre-aggregation) - const communityCached = await getCachedDays(hostname, allDates, 'community'); - - // 3. Build the set of path prefixes that belong to this collection - const collectionPrefixes = [ - `/${collectionSlug}`, // collection layout page - ...pubSlugs.map((s) => `/pub/${s}`), - ]; + // 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; - function pathBelongsToCollection(path: string): boolean { - return collectionPrefixes.some((prefix) => path === prefix || path.startsWith(prefix + '/')); - } + // 4. Read community-level cached days (raw, pre-aggregation) + const communityCached = await getCachedDays(hostname, allDates, 'community'); - // 4. For each pub, check if we have pub-scoped cache (more accurate) + // 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}`); @@ -896,20 +1048,22 @@ export async function fetchCollectionAnalytics( } } - // 5. Build collection-scoped day payloads by merging sources + // 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; - // Start with collection paths filtered from community data + 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(); - // First, add data from any pub-level caches (more accurate) + // (a) Add data from any individual pub-level caches (most accurate) for (const [slug, cache] of pubCaches) { const pubDay = cache.get(date); if (pubDay) { @@ -920,10 +1074,12 @@ export async function fetchCollectionAnalytics( } } - // Then, for pubs without pub-level cache + the collection page itself, - // filter from community topPaths - for (const p of communityDay.topPaths) { - // Skip paths already covered by pub-level cache + // (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) && @@ -931,47 +1087,75 @@ export async function fetchCollectionAnalytics( ); if (coveredByPubCache) continue; - if (pathBelongsToCollection(p.path)) { + // 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 data, not from pub cache) + // (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 communityPathPageViews = dayPageViews - [...pubCaches.values()] - .reduce((sum, cache) => sum + (cache.get(date)?.pageViews ?? 0), 0); - if (communityPathPageViews > 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(communityPathPageViews * visitRatio); + dayVisits += Math.round(indirectPageViews * visitRatio); } } - // Countries/devices/referrers: use community-level, scaled by this - // collection's share of community traffic for the day - const shareRatio = communityDay.pageViews > 0 ? dayPageViews / communityDay.pageViews : 0; + // 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: communityDay.countries.map((c) => ({ + countries: breakdownSource.countries.map((c) => ({ country: c.country, count: Math.round(c.count * shareRatio), })).filter((c) => c.count > 0), - devices: communityDay.devices.map((d) => ({ + devices: breakdownSource.devices.map((d) => ({ device: d.device, count: Math.round(d.count * shareRatio), })).filter((d) => d.count > 0), - referrers: communityDay.referrers.map((r) => ({ + referrers: breakdownSource.referrers.map((r) => ({ referrer: r.referrer, count: Math.round(r.count * shareRatio), })).filter((r) => r.count > 0), }); } - return aggregateDays(allDates, collectionDays, !!communityResult.stale); + const anyStale = !!communityResult.stale || allPubPathsResult.stale || collectionPageResult.stale; + return aggregateDays(allDates, collectionDays, anyStale); } // --------------------------------------------------------------------------- From 8bdb6fe675998b5d388b941e82021c609fb2ef07 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Thu, 2 Apr 2026 16:29:30 -0400 Subject: [PATCH 15/41] lint --- .../DashboardImpact2/DashboardImpact2.tsx | 17 +- server/routes/dashboardImpact.tsx | 6 +- server/utils/cloudflareAnalytics.ts | 159 +++++++++++++----- 3 files changed, 130 insertions(+), 52 deletions(-) diff --git a/client/containers/DashboardImpact2/DashboardImpact2.tsx b/client/containers/DashboardImpact2/DashboardImpact2.tsx index 883a3bdfeb..fe634c46be 100644 --- a/client/containers/DashboardImpact2/DashboardImpact2.tsx +++ b/client/containers/DashboardImpact2/DashboardImpact2.tsx @@ -112,7 +112,7 @@ const CompactTable = ({ const DashboardImpact2 = () => { const { scopeData } = usePageContext(); const { - elements: { activeTargetType, activeTargetName, activePub, activeCollection }, + elements: { activeTargetType, activePub, activeCollection }, activePermissions: { canView }, } = scopeData; @@ -212,16 +212,15 @@ const DashboardImpact2 = () => { } > - 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. + 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 - plan to wind down legacy analytics at the end of 2026. If you need - historical legacy data, please export it from there. + Legacy analytics remain available here. We plan to + wind down legacy analytics at the end of 2026. If you need historical legacy data, + please export it from there.
{loading && ( diff --git a/server/routes/dashboardImpact.tsx b/server/routes/dashboardImpact.tsx index 76acd2b3dc..b2f99906a2 100644 --- a/server/routes/dashboardImpact.tsx +++ b/server/routes/dashboardImpact.tsx @@ -12,7 +12,11 @@ import { generateMetaComponents, renderToNodeStream } from 'server/utils/ssr'; export const router = Router(); router.get( - ['/dash/impact-v1', '/dash/collection/:collectionSlug/impact-v1', '/dash/pub/:pubSlug/impact-v1'], + [ + '/dash/impact-v1', + '/dash/collection/:collectionSlug/impact-v1', + '/dash/pub/:pubSlug/impact-v1', + ], async (req, res, next) => { try { if (!hostIsValid(req, 'community')) { diff --git a/server/utils/cloudflareAnalytics.ts b/server/utils/cloudflareAnalytics.ts index 76749b5fa8..9a3b5c0355 100644 --- a/server/utils/cloudflareAnalytics.ts +++ b/server/utils/cloudflareAnalytics.ts @@ -773,22 +773,38 @@ export async function fetchPubAnalytics( // Group breakdowns by date type Arr = Array<{ key: string; count: number }>; - const byDate = new Map(); + 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: [] }); + 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 }); + 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 }); + 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 }); + 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 }); + ensure(n.dimensions.date).referrers.push({ + key: n.dimensions.clientRefererHost || '(direct)', + count: n.count, + }); } const toStore = new Map(); @@ -798,9 +814,18 @@ export async function fetchPubAnalytics( 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 })), + 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), @@ -811,7 +836,14 @@ export async function fetchPubAnalytics( for (const d of span) { if (!cached.has(d)) { - const empty: DayCachePayload = { visits: 0, pageViews: 0, topPaths: [], countries: [], devices: [], referrers: [] }; + const empty: DayCachePayload = { + visits: 0, + pageViews: 0, + topPaths: [], + countries: [], + devices: [], + referrers: [], + }; cached.set(d, empty); toStore.set(d, empty); } @@ -911,22 +943,38 @@ async function ensurePathScopeCached( // Group breakdowns by date type Arr = Array<{ key: string; count: number }>; - const byDate = new Map(); + 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: [] }); + 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 }); + 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 }); + 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 }); + 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 }); + ensure(n.dimensions.date).referrers.push({ + key: n.dimensions.clientRefererHost || '(direct)', + count: n.count, + }); } const toStore = new Map(); @@ -936,9 +984,18 @@ async function ensurePathScopeCached( 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 })), + 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), @@ -949,7 +1006,14 @@ async function ensurePathScopeCached( for (const d of span) { if (!cached.has(d)) { - const empty: DayCachePayload = { visits: 0, pageViews: 0, topPaths: [], countries: [], devices: [], referrers: [] }; + const empty: DayCachePayload = { + visits: 0, + pageViews: 0, + topPaths: [], + countries: [], + devices: [], + referrers: [], + }; cached.set(d, empty); toStore.set(d, empty); } @@ -1018,15 +1082,14 @@ export async function fetchCollectionAnalytics( // 2 & 3. Fetch collection-page and all-pub-paths scopes in parallel const [collectionPageResult, allPubPathsResult] = await Promise.all([ ensurePathScopeCached( - hostname, allDates, apiToken, zoneTag, + hostname, + allDates, + apiToken, + zoneTag, `/${collectionSlug}%`, `collection-page:${collectionSlug}`, ), - ensurePathScopeCached( - hostname, allDates, apiToken, zoneTag, - '/pub/%', - 'all-pub-paths', - ), + ensurePathScopeCached(hostname, allDates, apiToken, zoneTag, '/pub/%', 'all-pub-paths'), ]); const collectionPageCached = collectionPageResult.cache; const allPubPathsCached = allPubPathsResult.cache; @@ -1118,9 +1181,14 @@ export async function fetchCollectionAnalytics( // (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 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; @@ -1139,22 +1207,29 @@ export async function fetchCollectionAnalytics( 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), + 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; + const anyStale = + !!communityResult.stale || allPubPathsResult.stale || collectionPageResult.stale; return aggregateDays(allDates, collectionDays, anyStale); } From 13ae870eb770dd9cb9610682779927cc1b9d96b4 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Mon, 6 Apr 2026 22:05:36 -0400 Subject: [PATCH 16/41] More detail on top paths --- .../DashboardImpact2/DashboardImpact2.tsx | 132 +++++++++++++----- .../DashboardImpact2/dashboardImpact2.scss | 77 +++++++++- server/impact2/api.ts | 75 +++++++++- server/utils/cloudflareAnalytics.ts | 2 +- 4 files changed, 249 insertions(+), 37 deletions(-) diff --git a/client/containers/DashboardImpact2/DashboardImpact2.tsx b/client/containers/DashboardImpact2/DashboardImpact2.tsx index fe634c46be..2334a3bb0f 100644 --- a/client/containers/DashboardImpact2/DashboardImpact2.tsx +++ b/client/containers/DashboardImpact2/DashboardImpact2.tsx @@ -43,6 +43,7 @@ type AnalyticsData = { referrers: ReferrerBreakdown[]; totals: { visits: number; pageViews: number }; rawTotals: { visits: number; pageViews: number }; + pathTitles?: Record; stale?: boolean; }; @@ -107,6 +108,28 @@ const CompactTable = ({ ); +/** + * 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 = () => { @@ -122,6 +145,7 @@ const DashboardImpact2 = () => { 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-v1', @@ -218,9 +242,11 @@ const DashboardImpact2 = () => { analytics tool in Settings.

- Legacy analytics remain available here. We plan to - wind down legacy analytics at the end of 2026. If you need historical legacy data, - please export it from there. + 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 && ( @@ -242,9 +268,9 @@ const DashboardImpact2 = () => {
- {/* Data grid */} + {/* Breakdowns skeleton (3-column) */}
- {[0, 1, 2, 3].map((i) => ( + {[0, 1, 2].map((i) => (
{[0, 1, 2, 3, 4, 5].map((j) => ( @@ -256,6 +282,16 @@ const DashboardImpact2 = () => {
))}
+ {/* Top Pages skeleton (full width) */} +
+
+ {[0, 1, 2, 3, 4, 5, 6, 7].map((j) => ( +
+
+
+
+ ))} +
)} @@ -346,34 +382,8 @@ const DashboardImpact2 = () => { )}
- {/* ── Row 2: data grid ── */} + {/* ── Row 2: breakdowns (3-column) ── */}
- {/* Top Pages */} - {data.topPaths.length > 0 && ( -
-

Top Pages

- ( - - {v} - - ), - }, - { - key: 'count', - label: 'Views', - render: (v: number) => fmt(v), - }, - ]} - /> -
- )} - {/* Countries */} {data.countries.length > 0 && (
@@ -436,6 +446,64 @@ const DashboardImpact2 = () => { )}
+ {/* ── 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 */}

diff --git a/client/containers/DashboardImpact2/dashboardImpact2.scss b/client/containers/DashboardImpact2/dashboardImpact2.scss index d682634a04..c3efd638a1 100644 --- a/client/containers/DashboardImpact2/dashboardImpact2.scss +++ b/client/containers/DashboardImpact2/dashboardImpact2.scss @@ -131,14 +131,19 @@ margin: 4px 0 0; } - // ── Data grid: breakdown panels ───────────────────────────────────── + // ── 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(4, 1fr); + grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 36px; - @media (max-width: 1100px) { + @media (max-width: 900px) { grid-template-columns: 1fr 1fr; } @media (max-width: 600px) { @@ -158,6 +163,60 @@ } } + // ── 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 ─────────────────────────────────────────────────── @@ -300,5 +359,17 @@ color: #48aff0; } } + + .path-cell .path-url { + color: #738694; + } + + .path-cell:hover { + color: #48aff0; + } + + .show-more-btn { + color: #48aff0; + } } } diff --git a/server/impact2/api.ts b/server/impact2/api.ts index 6b6df9d10c..8b21cf28fe 100644 --- a/server/impact2/api.ts +++ b/server/impact2/api.ts @@ -1,9 +1,12 @@ +import { Op } from 'sequelize'; import { Router } from 'express'; 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 type { CloudflareAnalyticsResult } from 'server/utils/cloudflareAnalytics'; import { debugCommunityAnalytics, fetchCollectionAnalytics, @@ -17,6 +20,72 @@ 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. * @@ -177,7 +246,11 @@ router.get('/api/impact2', async (req, res, next) => { error: 'Cloudflare analytics not configured. Set CLOUDFLARE_ANALYTICS_API_TOKEN and CLOUDFLARE_ZONE_TAG environment variables.', }); } - return res.json(result); + + // 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/utils/cloudflareAnalytics.ts b/server/utils/cloudflareAnalytics.ts index 9a3b5c0355..cc681e2dbd 100644 --- a/server/utils/cloudflareAnalytics.ts +++ b/server/utils/cloudflareAnalytics.ts @@ -648,7 +648,7 @@ function aggregateDays( .map(([path, count]) => ({ path, count })) .filter((p) => !isNoisePath(p.path)) .sort((a, b) => b.count - a.count) - .slice(0, 20); + .slice(0, 50); let noisePageViews = 0; for (const [path, count] of pathMap) { From d25ec73249a9b9d97d0bbbbc9e87615949af6ef1 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Mon, 6 Apr 2026 22:24:20 -0400 Subject: [PATCH 17/41] lint --- .../DashboardImpact2/DashboardImpact2.tsx | 14 +++----------- server/impact2/api.ts | 5 +++-- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/client/containers/DashboardImpact2/DashboardImpact2.tsx b/client/containers/DashboardImpact2/DashboardImpact2.tsx index 2334a3bb0f..ad31b39611 100644 --- a/client/containers/DashboardImpact2/DashboardImpact2.tsx +++ b/client/containers/DashboardImpact2/DashboardImpact2.tsx @@ -453,9 +453,7 @@ const DashboardImpact2 = () => { { render: (v: string) => { const title = pathTitle(v, data.pathTitles); return ( - + {title ? ( <> {title} - - {v} - + {v} ) : ( v diff --git a/server/impact2/api.ts b/server/impact2/api.ts index 8b21cf28fe..ef37e92b43 100644 --- a/server/impact2/api.ts +++ b/server/impact2/api.ts @@ -1,12 +1,13 @@ -import { Op } from 'sequelize'; +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 type { CloudflareAnalyticsResult } from 'server/utils/cloudflareAnalytics'; import { debugCommunityAnalytics, fetchCollectionAnalytics, From d3ea07878d617ab7eb5ed642d95f6c5abc663d3a Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Tue, 7 Apr 2026 15:39:14 +0200 Subject: [PATCH 18/41] fix: use duckdb! --- .gitignore | 3 +- infra/docker-compose.dev.yml | 25 +- infra/duckdb-sync/Dockerfile | 20 ++ infra/duckdb-sync/sync.sh | 65 +++++ infra/metabase/Dockerfile | 34 +++ infra/stack.yml | 30 ++- server/analytics/model.ts | 16 +- .../migrate-metabase-queries.ts | 26 +- tools/analytics-migration/migrate.sh | 14 +- tools/analytics-migration/setup-metabase.sh | 243 ++++++++++++++++++ 10 files changed, 453 insertions(+), 23 deletions(-) create mode 100644 infra/duckdb-sync/Dockerfile create mode 100644 infra/duckdb-sync/sync.sh create mode 100644 infra/metabase/Dockerfile create mode 100755 tools/analytics-migration/setup-metabase.sh diff --git a/.gitignore b/.gitignore index 86680b3bff..0c3805041c 100755 --- a/.gitignore +++ b/.gitignore @@ -67,4 +67,5 @@ pubpub-localdb/ tsconfig.tsbuildinfo .jest/secret-env.js -infra/pgdata/ \ No newline at end of file +infra/pgdata/ +infra/metabase-plugins/ \ No newline at end of file diff --git a/infra/docker-compose.dev.yml b/infra/docker-compose.dev.yml index dbe4f78478..5b84969836 100644 --- a/infra/docker-compose.dev.yml +++ b/infra/docker-compose.dev.yml @@ -87,7 +87,8 @@ services: networks: [appnet] metabase: - image: metabase/metabase:v0.48.1 + build: + context: ./metabase environment: MB_DB_TYPE: postgres MB_DB_DBNAME: metabasedb @@ -96,11 +97,32 @@ services: MB_DB_USER: appuser MB_DB_PASS: apppassword MB_JETTY_PORT: "3001" + MB_ENABLE_QUERY_CACHING: "true" + MB_QUERY_CACHING_TTL_RATIO: "10" + MB_QUERY_CACHING_MIN_TTL: "120" depends_on: - metabase_db - db ports: - "${METABASE_PORT:-3030}:3001" + volumes: + - analytics_duckdb:/duckdb:ro + networks: [appnet] + + analytics_sync: + build: + context: ./duckdb-sync + environment: + PG_HOST: db + PG_PORT: "5432" + PG_DB: appdb + PG_USER: appuser + PG_PASS: apppassword + SYNC_INTERVAL: "${ANALYTICS_SYNC_INTERVAL:-43200}" + volumes: + - analytics_duckdb:/data + depends_on: + - db networks: [appnet] metabase_db: @@ -141,3 +163,4 @@ volumes: pgdata: rabbitmqdata: metabase_pgdata: + analytics_duckdb: diff --git a/infra/duckdb-sync/Dockerfile b/infra/duckdb-sync/Dockerfile new file mode 100644 index 0000000000..edbc4338b8 --- /dev/null +++ b/infra/duckdb-sync/Dockerfile @@ -0,0 +1,20 @@ +FROM debian:bookworm-slim + +ARG DUCKDB_VERSION=v1.4.4 +ARG TARGETARCH + +RUN apt-get update && \ + apt-get install -y --no-install-recommends curl unzip ca-certificates && \ + rm -rf /var/lib/apt/lists/* && \ + curl -fsSL \ + "https://github.com/duckdb/duckdb/releases/download/${DUCKDB_VERSION}/duckdb_cli-linux-${TARGETARCH}.zip" \ + -o /tmp/duckdb.zip && \ + unzip /tmp/duckdb.zip -d /usr/local/bin/ && \ + rm /tmp/duckdb.zip && \ + chmod +x /usr/local/bin/duckdb && \ + duckdb -c "INSTALL postgres" + +COPY sync.sh /usr/local/bin/sync.sh +RUN chmod +x /usr/local/bin/sync.sh + +CMD ["/usr/local/bin/sync.sh"] diff --git a/infra/duckdb-sync/sync.sh b/infra/duckdb-sync/sync.sh new file mode 100644 index 0000000000..736bd9ea19 --- /dev/null +++ b/infra/duckdb-sync/sync.sh @@ -0,0 +1,65 @@ +#!/bin/bash +set -euo pipefail + +DUCKDB_FILE="${DUCKDB_FILE:-/data/analytics.duckdb}" +PG_HOST="${PG_HOST:-db}" +PG_PORT="${PG_PORT:-5432}" +PG_DB="${PG_DB:-appdb}" +PG_USER="${PG_USER:-appuser}" +PG_PASS="${PG_PASS:-apppassword}" +SYNC_INTERVAL="${SYNC_INTERVAL:-43200}" +MODE="${1:-loop}" + +PG_CONN="dbname=${PG_DB} host=${PG_HOST} port=${PG_PORT} user=${PG_USER} password=${PG_PASS}" + +sync_analytics() { + local start + start=$(date +%s) + + duckdb "$DUCKDB_FILE" < ( + SELECT COALESCE(MAX("createdAt"), '1970-01-01'::TIMESTAMPTZ) + FROM "AnalyticsEvents" + ); + +DETACH pg; +SQL + + local count elapsed + count=$(duckdb "$DUCKDB_FILE" -csv -noheader "SELECT count(*) FROM \"AnalyticsEvents\"") + elapsed=$(( $(date +%s) - start )) + echo "[$(date -Iseconds)] sync complete: ${count} rows (${elapsed}s)" +} + +if [ "$MODE" = "--full" ]; then + echo "full resync requested, removing existing data..." + rm -f "$DUCKDB_FILE" +fi + +echo "duckdb analytics sync" +echo " postgres: ${PG_HOST}:${PG_PORT}/${PG_DB}" +echo " duckdb: ${DUCKDB_FILE}" +echo "" + +sync_analytics + +if [ "$MODE" = "--once" ] || [ "$MODE" = "--full" ]; then + exit 0 +fi + +echo "entering sync loop (interval: ${SYNC_INTERVAL}s)" + +while true; do + sleep "$SYNC_INTERVAL" + sync_analytics +done diff --git a/infra/metabase/Dockerfile b/infra/metabase/Dockerfile new file mode 100644 index 0000000000..38c1176e6a --- /dev/null +++ b/infra/metabase/Dockerfile @@ -0,0 +1,34 @@ +FROM eclipse-temurin:21-jre-jammy + +ARG METABASE_VERSION=0.58.9 +ARG METABASE_DUCKDB_DRIVER_VERSION=1.4.4.0 + +ENV MB_PLUGINS_DIR=/home/metabase/plugins/ + +RUN groupadd -r metabase && useradd -r -g metabase metabase + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +RUN mkdir -p /home/metabase/plugins /home/metabase/data && \ + chown -R metabase:metabase /home/metabase + +WORKDIR /home/metabase + +ADD --chown=metabase:metabase \ + https://downloads.metabase.com/v${METABASE_VERSION}/metabase.jar \ + /home/metabase/ + +ADD --chown=metabase:metabase \ + https://github.com/motherduckdb/metabase_duckdb_driver/releases/download/${METABASE_DUCKDB_DRIVER_VERSION}/duckdb.metabase-driver.jar \ + /home/metabase/plugins/ + +RUN chmod 755 /home/metabase/metabase.jar && \ + chmod 755 /home/metabase/plugins/duckdb.metabase-driver.jar + +EXPOSE 3000 + +USER metabase + +CMD ["java", "-jar", "/home/metabase/metabase.jar"] diff --git a/infra/stack.yml b/infra/stack.yml index b7ea1777f0..3a5b4e909f 100644 --- a/infra/stack.yml +++ b/infra/stack.yml @@ -127,7 +127,7 @@ services: condition: any metabase: - image: metabase/metabase:latest + image: ghcr.io/knowledgefutures/pubpub-metabase:${IMAGE_TAG:-latest} environment: MB_DB_TYPE: postgres MB_DB_DBNAME: metabasedb @@ -136,6 +136,11 @@ services: MB_DB_USER: '${METABASE_DB_USER:-appuser}' MB_DB_PASS: '${METABASE_DB_PASS}' MB_JETTY_PORT: '3001' + MB_ENABLE_QUERY_CACHING: 'true' + MB_QUERY_CACHING_TTL_RATIO: '10' + MB_QUERY_CACHING_MIN_TTL: '120' + volumes: + - analytics_duckdb:/duckdb:ro networks: [appnet] deploy: replicas: 1 @@ -147,6 +152,28 @@ services: restart_policy: condition: any + analytics_sync: + image: ghcr.io/knowledgefutures/pubpub-duckdb-sync:${IMAGE_TAG:-latest} + environment: + PG_HOST: db + PG_PORT: '5432' + PG_DB: appdb + PG_USER: appuser + PG_PASS: '${DB_PASS}' + SYNC_INTERVAL: '${ANALYTICS_SYNC_INTERVAL:-43200}' + volumes: + - analytics_duckdb:/data + networks: [appnet] + deploy: + replicas: 1 + resources: + limits: + memory: 4G + reservations: + memory: 1G + restart_policy: + condition: any + metabase_db: image: postgres:16 environment: @@ -199,5 +226,6 @@ volumes: pgdata: rabbitmqdata: metabase_pgdata: + analytics_duckdb: caddy_data: caddy_config: diff --git a/server/analytics/model.ts b/server/analytics/model.ts index 935f0089fb..2332ada25f 100644 --- a/server/analytics/model.ts +++ b/server/analytics/model.ts @@ -13,7 +13,17 @@ import { @Table({ updatedAt: false, - indexes: [{ fields: ['communityId', 'timestamp'] }, { fields: ['pubId', 'timestamp'] }], + indexes: [ + { + name: 'analytics_events_community_event_ts', + fields: ['communityId', 'event', 'timestamp'], + }, + { name: 'analytics_events_pub_event_ts', fields: ['pubId', 'event', 'timestamp'] }, + { + name: 'analytics_events_collection_event_ts', + fields: ['collectionId', 'event', 'timestamp'], + }, + ], }) export class AnalyticsEvent extends Model< InferAttributes, @@ -29,7 +39,6 @@ export class AnalyticsEvent extends Model< declare type: string; @AllowNull(false) - @Index @Column(DataType.TEXT) declare event: string; @@ -77,7 +86,6 @@ export class AnalyticsEvent extends Model< @Column(DataType.TEXT) declare os: string; - @Index @Column(DataType.UUID) declare communityId: string | null; @@ -130,7 +138,6 @@ export class AnalyticsEvent extends Model< @Column(DataType.TEXT) declare collectionKind: string | null; - @Index @Column(DataType.UUID) declare collectionId: string | null; @@ -140,7 +147,6 @@ export class AnalyticsEvent extends Model< @Column(DataType.TEXT) declare pubTitle: string | null; - @Index @Column(DataType.UUID) declare pubId: string | null; diff --git a/tools/analytics-migration/migrate-metabase-queries.ts b/tools/analytics-migration/migrate-metabase-queries.ts index f50416d6a7..d802db5b93 100644 --- a/tools/analytics-migration/migrate-metabase-queries.ts +++ b/tools/analytics-migration/migrate-metabase-queries.ts @@ -168,14 +168,17 @@ function buildFieldMapping(oldTables: Table[], newTables: Table[]) { // ── mbql query remapping ── -function remapQuery(obj: unknown, maps: IdMaps): unknown { +function remapQuery( + obj: Record | unknown[] | string | undefined, + maps: IdMaps, +): Record | unknown[] | string | undefined { if (Array.isArray(obj)) { if (obj.length >= 2 && obj[0] === 'field' && typeof obj[1] === 'number') { const mapped = maps.fieldIdMap[obj[1]] ?? obj[1]; - return ['field', mapped, ...obj.slice(2).map((x) => remapQuery(x, maps))]; + return ['field', mapped, ...obj.slice(2).map((x) => remapQuery(x as any, maps))]; } - return obj.map((item) => remapQuery(item, maps)); + return obj.map((item) => remapQuery(item as any, maps)); } if (obj && typeof obj === 'object') { @@ -188,7 +191,7 @@ function remapQuery(obj: unknown, maps: IdMaps): unknown { const oldId = parseInt(v.replace('card__', ''), 10); result[k] = `card__${maps.cardIdMap[oldId] ?? oldId}`; } else { - result[k] = remapQuery(v, maps); + result[k] = remapQuery(v as any, maps); } } @@ -427,7 +430,7 @@ async function main() { } try { - // biome-ignore lint/correctness/noAwaitInLoop: sequential creation required for card->card references + // biome-ignore lint/performance/noAwaitInLoops: who cares const newCard = await post('/card', { name: card.name, description: card.description, @@ -453,7 +456,7 @@ async function main() { for (const item of dashList) { try { - // biome-ignore lint/correctness/noAwaitInLoop: dashboards must be created one at a time + // biome-ignore lint/performance/noAwaitInLoops: who cares const oldDash = await get(`/dashboard/${item.id}`); const newDash = await post('/dashboard', { @@ -464,7 +467,16 @@ async function main() { }); const oldCards = oldDash.ordered_cards || oldDash.dashcards || []; - const dashCards = []; + const dashCards = [] as { + id: number; + card_id: number; + row: number; + col: number; + size_x: number; + size_y: number; + parameter_mappings: unknown; + visualization_settings: unknown; + }[]; for (let i = 0; i < oldCards.length; i++) { const dc = oldCards[i]; diff --git a/tools/analytics-migration/migrate.sh b/tools/analytics-migration/migrate.sh index 852257d711..2f38823465 100755 --- a/tools/analytics-migration/migrate.sh +++ b/tools/analytics-migration/migrate.sh @@ -74,14 +74,12 @@ CREATE TABLE IF NOT EXISTS "AnalyticsEvents" ( "createdAt" timestamptz NOT NULL DEFAULT now() ); -CREATE INDEX IF NOT EXISTS "analytics_events_community_ts" - ON "AnalyticsEvents" ("communityId", "timestamp"); -CREATE INDEX IF NOT EXISTS "analytics_events_pub_ts" - ON "AnalyticsEvents" ("pubId", "timestamp"); -CREATE INDEX IF NOT EXISTS "analytics_events_event" - ON "AnalyticsEvents" (event); -CREATE INDEX IF NOT EXISTS "analytics_events_collection" - ON "AnalyticsEvents" ("collectionId"); +CREATE INDEX IF NOT EXISTS "analytics_events_community_event_ts" + ON "AnalyticsEvents" ("communityId", event, "timestamp"); +CREATE INDEX IF NOT EXISTS "analytics_events_pub_event_ts" + ON "AnalyticsEvents" ("pubId", event, "timestamp"); +CREATE INDEX IF NOT EXISTS "analytics_events_collection_event_ts" + ON "AnalyticsEvents" ("collectionId", event, "timestamp"); SQL # ── step 3: create staging table and import csv ── diff --git a/tools/analytics-migration/setup-metabase.sh b/tools/analytics-migration/setup-metabase.sh new file mode 100755 index 0000000000..970e293c69 --- /dev/null +++ b/tools/analytics-migration/setup-metabase.sh @@ -0,0 +1,243 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +COMPOSE_FILE="${PROJECT_ROOT}/infra/docker-compose.dev.yml" + +DUMP_FILE="${1:?usage: setup-metabase.sh [collection-id]}" +COLLECTION_ID="${2:-14}" + +MB_DB_PORT="${METABASE_DB_PORT:-5440}" +MB_DB_USER="${METABASE_DB_USER:-appuser}" +MB_DB_PASS="${METABASE_DB_PASS:-apppassword}" +MB_DB_NAME="${METABASE_DB_NAME:-metabasedb}" +MB_PORT="${METABASE_PORT:-3030}" +MB_URL="http://localhost:${MB_PORT}" + +MAIN_DB_HOST="${MAIN_DB_DOCKER_HOST:-db}" +MAIN_DB_PORT="${MAIN_DB_PORT:-5432}" +MAIN_DB_NAME="${MAIN_DB_NAME:-appdb}" +MAIN_DB_USER="${MAIN_DB_USER:-appuser}" +MAIN_DB_PASS="${MAIN_DB_PASS:-apppassword}" + +: "${METABASE_ADMIN_EMAIL:?set METABASE_ADMIN_EMAIL}" +: "${METABASE_ADMIN_PASSWORD:?set METABASE_ADMIN_PASSWORD}" + +MB_CONN="postgres://${MB_DB_USER}:${MB_DB_PASS}@localhost:${MB_DB_PORT}" +MB_DB_URL="${MB_CONN}/${MB_DB_NAME}" +MB_ADMIN_URL="${MB_CONN}/postgres" + +json_val() { + node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{const o=JSON.parse(d);console.log($1)})" +} + +echo "Metabase Setup" +echo "==============" +echo " dump: $DUMP_FILE" +echo " metabase: $MB_URL" +echo " database: localhost:${MB_DB_PORT}/${MB_DB_NAME}" +echo "" + +# ── 1. stop metabase ── + +echo "[1/10] stopping metabase..." +docker compose -f "$COMPOSE_FILE" stop metabase 2>/dev/null || true + +# ── 2. ensure metabase_db is running ── + +echo "[2/10] ensuring metabase_db is running..." +docker compose -f "$COMPOSE_FILE" up -d metabase_db +until PGPASSWORD="$MB_DB_PASS" pg_isready -h localhost -p "$MB_DB_PORT" -U "$MB_DB_USER" -q 2>/dev/null; do + sleep 1 +done + +# ── 3. reset the database ── + +echo "[3/10] resetting database..." +psql "$MB_ADMIN_URL" -q -c \ + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${MB_DB_NAME}' AND pid != pg_backend_pid();" \ + 2>/dev/null || true + +psql "$MB_ADMIN_URL" -q -c "DROP DATABASE IF EXISTS \"${MB_DB_NAME}\";" +psql "$MB_ADMIN_URL" -q -c "CREATE DATABASE \"${MB_DB_NAME}\" OWNER \"${MB_DB_USER}\";" + +# ── 4. import dump ── + +echo "[4/10] importing dump..." +psql "$MB_DB_URL" -q < "$DUMP_FILE" + +CARD_COUNT=$(psql "$MB_DB_URL" -tA -c "SELECT count(*) FROM report_card") +echo " $CARD_COUNT saved questions imported" + +# ── 5. fix settings ── + +echo "[5/10] patching settings..." +psql "$MB_DB_URL" -q <<'SQL' +UPDATE setting +SET value = REPLACE(value, 'US/Eastern', 'America/New_York') +WHERE value LIKE '%US/Eastern%'; + +UPDATE metabase_database +SET details = REPLACE(details, 'US/Eastern', 'America/New_York') +WHERE details LIKE '%US/Eastern%'; + +UPDATE core_user +SET settings = REPLACE(settings, 'US/Eastern', 'America/New_York') +WHERE settings LIKE '%US/Eastern%'; +SQL +echo " timezone references fixed" + +# ── 6. initial DuckDB sync ── + +echo "[6/10] running initial DuckDB sync..." +docker compose -f "$COMPOSE_FILE" build analytics_sync +docker compose -f "$COMPOSE_FILE" run --rm analytics_sync sync.sh --once + +# ── 7. start metabase ── + +echo "[7/10] starting metabase..." +docker compose -f "$COMPOSE_FILE" up -d metabase + +echo -n " waiting for ready" +for i in $(seq 1 180); do + if curl -sf "${MB_URL}/api/health" >/dev/null 2>&1; then + echo " (${i}s)" + break + fi + + if [ "$i" -eq 180 ]; then + echo "" + echo " ERROR: metabase not ready after 180s" + exit 1 + fi + + echo -n "." + sleep 1 +done + +# ── 8. add Postgres database connection ── + +echo "[8/10] configuring Postgres database connection..." + +SESSION=$(curl -sf "${MB_URL}/api/session" \ + -H 'Content-Type: application/json' \ + -d "{\"username\":\"${METABASE_ADMIN_EMAIL}\",\"password\":\"${METABASE_ADMIN_PASSWORD}\"}" \ + | json_val 'o.id') + +EXISTING_DB=$(curl -sf "${MB_URL}/api/database" \ + -H "X-Metabase-Session: ${SESSION}" \ + | json_val '(o.data || o).find(d => d.name === "PubPub")?.id ?? ""') + +if [ -n "$EXISTING_DB" ] && [ "$EXISTING_DB" != "undefined" ]; then + echo " removing existing 'PubPub' database (id=$EXISTING_DB)..." + curl -sf "${MB_URL}/api/database/${EXISTING_DB}" \ + -H "X-Metabase-Session: ${SESSION}" \ + -X DELETE >/dev/null +fi + +NEW_DB_ID=$(curl -sf "${MB_URL}/api/database" \ + -H "X-Metabase-Session: ${SESSION}" \ + -H 'Content-Type: application/json' \ + -X POST \ + -d "{ + \"name\": \"PubPub\", + \"engine\": \"postgres\", + \"details\": { + \"host\": \"${MAIN_DB_HOST}\", + \"port\": ${MAIN_DB_PORT}, + \"dbname\": \"${MAIN_DB_NAME}\", + \"user\": \"${MAIN_DB_USER}\", + \"password\": \"${MAIN_DB_PASS}\", + \"ssl\": false + }, + \"is_full_sync\": true, + \"auto_run_queries\": true + }" | json_val 'o.id') + +echo " created database 'PubPub' (id=${NEW_DB_ID})" + +curl -sf "${MB_URL}/api/database/${NEW_DB_ID}/sync_schema" \ + -H "X-Metabase-Session: ${SESSION}" \ + -X POST >/dev/null + +echo -n " waiting for sync" +for i in $(seq 1 120); do + STATUS=$(curl -sf "${MB_URL}/api/database/${NEW_DB_ID}" \ + -H "X-Metabase-Session: ${SESSION}" \ + | json_val 'o.initial_sync_status || "incomplete"' 2>/dev/null || echo "incomplete") + + if [ "$STATUS" = "complete" ]; then + echo " (${i}s)" + break + fi + + if [ "$i" -eq 120 ]; then + echo "" + echo " WARNING: sync not complete after 120s, continuing anyway" + fi + + echo -n "." + sleep 2 +done + +# ── 9. add DuckDB database connection ── + +echo "[9/10] configuring DuckDB database..." + +EXISTING_DUCKDB=$(curl -sf "${MB_URL}/api/database" \ + -H "X-Metabase-Session: ${SESSION}" \ + | json_val '(o.data || o).find(d => d.name === "Analytics (DuckDB)")?.id ?? ""') + +if [ -n "$EXISTING_DUCKDB" ] && [ "$EXISTING_DUCKDB" != "undefined" ]; then + echo " removing existing 'Analytics (DuckDB)' database (id=$EXISTING_DUCKDB)..." + curl -sf "${MB_URL}/api/database/${EXISTING_DUCKDB}" \ + -H "X-Metabase-Session: ${SESSION}" \ + -X DELETE >/dev/null +fi + +DUCKDB_ID=$(curl -sf "${MB_URL}/api/database" \ + -H "X-Metabase-Session: ${SESSION}" \ + -H 'Content-Type: application/json' \ + -X POST \ + -d '{ + "name": "Analytics (DuckDB)", + "engine": "duckdb", + "details": { + "database_file": "/duckdb/analytics.duckdb", + "read_only": true + }, + "is_full_sync": true, + "auto_run_queries": true + }' | json_val 'o.id') + +echo " created database 'Analytics (DuckDB)' (id=${DUCKDB_ID})" + +curl -sf "${MB_URL}/api/database/${DUCKDB_ID}/sync_schema" \ + -H "X-Metabase-Session: ${SESSION}" \ + -X POST >/dev/null + +echo " sync triggered" + +# ── 10. migrate queries ── + +OLD_DB_ID=$(psql "$MB_DB_URL" -tA -c "SELECT id FROM metabase_database WHERE engine = 'redshift' ORDER BY id LIMIT 1") + +if [ -z "$OLD_DB_ID" ]; then + echo "[10/10] no redshift database found, skipping query migration" + echo "" + echo "done. open ${MB_URL} to verify." + exit 0 +fi + +echo "[10/10] migrating saved questions (redshift db=$OLD_DB_ID -> postgres db=$NEW_DB_ID)..." + +npx tsx "${SCRIPT_DIR}/migrate-metabase-queries.ts" \ + --metabase-url "$MB_URL" \ + --session "$SESSION" \ + --old-db-id "$OLD_DB_ID" \ + --new-db-id "$NEW_DB_ID" \ + --collection "$COLLECTION_ID" + +echo "" +echo "done. open ${MB_URL} to verify." From d44e62254314b174c608ebe965256de07644d281 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Tue, 7 Apr 2026 15:42:40 +0200 Subject: [PATCH 19/41] fix: rename --- tools/analytics-migration/{migrate.sh => migrate-redshift.sh} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tools/analytics-migration/{migrate.sh => migrate-redshift.sh} (100%) diff --git a/tools/analytics-migration/migrate.sh b/tools/analytics-migration/migrate-redshift.sh similarity index 100% rename from tools/analytics-migration/migrate.sh rename to tools/analytics-migration/migrate-redshift.sh From c53a71ab75502273e283fc81c4ea2531d68c1fa9 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Tue, 7 Apr 2026 18:05:19 +0200 Subject: [PATCH 20/41] feat: lets go --- infra/.env.dev.enc | 106 +++--- infra/.env.enc | 110 +++--- infra/docker-compose.dev.yml | 39 ++- infra/duckdb-sync/sync.sh | 11 +- infra/env.example | 13 + tools/analytics-migration/Dockerfile | 37 ++ tools/analytics-migration/README.md | 34 ++ tools/analytics-migration/migrate-metabase.sh | 316 ++++++++++++++++++ tools/analytics-migration/migrate-redshift.sh | 61 ++-- tools/analytics-migration/setup-metabase.sh | 243 -------------- 10 files changed, 596 insertions(+), 374 deletions(-) create mode 100644 infra/env.example create mode 100644 tools/analytics-migration/Dockerfile create mode 100644 tools/analytics-migration/README.md create mode 100755 tools/analytics-migration/migrate-metabase.sh delete mode 100755 tools/analytics-migration/setup-metabase.sh diff --git a/infra/.env.dev.enc b/infra/.env.dev.enc index 3ed84c1eed..d7491e1299 100644 --- a/infra/.env.dev.enc +++ b/infra/.env.dev.enc @@ -1,52 +1,68 @@ -AES_ENCRYPTION_KEY=ENC[AES256_GCM,data:Kz4/KiHal4JIP5kmXBSVg+VI/nvNGfPtRv8Rrf9mUdY7bDuc5k1In1oSPHsjjNQLDN8bs6YuzCSBzWyyouDUAg==,iv:QC65R/DVjioNsTb6EnChdSaugtlOqvEx+m4C57eZStM=,tag:kc9NcW6faLAdJIy17wcjtQ==,type:str] -ALGOLIA_ID=ENC[AES256_GCM,data:Pu/lBy/Vo/9p1g==,iv:4HhG6IjEyheW+Ug1mI9m+6xzxRuVf2xvJ+dPtONp130=,tag:9LNs+U1klSpxqVgAnQJaug==,type:str] -ALGOLIA_KEY=ENC[AES256_GCM,data:bY2wVEofNkVMlvwhEhGr4punAQ/JMShYYPpKfR/kyK8=,iv:NIrux9ntBLGphMQ0yem+puX9gjnvlGb3jUiDupiyths=,tag:8DkKbPwvQtPPWHGmH0zRWw==,type:str] -ALGOLIA_SEARCH_KEY=ENC[AES256_GCM,data:MWIU9p3KmmsDjdxk+IRUJgzB7SQw479ZUG9WdlXLXk8=,iv:ujK8AHJPvvbCi7HxhL7cGvxQ/HYFJRriCFbsNkCAHXk=,tag:KaU7v4CrQJRKmH+QfJwGlA==,type:str] -AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:nnIlGdEBlwi268NlCFi8d5Ugjmg=,iv:uP5wFBOWZJrSH50uYNzJ4kJdv3jYUcEFlMRVrDMlTng=,tag:Uv0D0mKNKebrNwuySxIM7g==,type:str] -AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:aWJiiVfgnam1My2J/J3k1p/8j4g=,iv:XD+OHujRRMUyyZ8cMGJ2mw1TCtwELrR16txekGpwMi8=,tag:HEsGvUYMNEiv1mk2mb4GYw==,type:str] -AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:fYAtsyYDbTp3EBvm6vtabfmBupk9ryfpcu4P3ROoyTEOBnZyPrhPnQ==,iv:L/VZB/DccoB55GFWPfsVbQ9uzdIk3nwadkKZ15YzFGE=,tag:WEbCfpCYN0jHrfzWsc5TNg==,type:str] -AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:DGbljDUXr1ehACzOOWFFN2fCIrzq5aChbZg++7IXzLwBxWNldm1vqQ==,iv:G4lKHMPP4BL0kaZz4oH1CulYBiRh7SyZAu3pKhGoUys=,tag:LIVhg5BYgGuH7B7Eyk82Dw==,type:str] -BACKUPS_SECRET=ENC[AES256_GCM,data:eQLNXNHy2Uv6TJt0nn2aZFCa/cyUFQFp490E+sv4+oMY2c0zZ1VpYSuIQCg=,iv:oFQ6xrc6v0z9K0t9m9EU3nXCwsW0lYQNoRbXclnAIFU=,tag:nO+JnRZKD5wBlIoMtyqnuw==,type:str] -DATABASE_URL=ENC[AES256_GCM,data:+uZ8Kkhyxa6KersbX7p6hnqSpp9KcjCEttWRnxDWatf8jr3h+MhyP1SW/6c=,iv:3HQ97+1Gj+7+UjE798q3B4tF9CH7Oi8T4217dgiSygE=,tag:SXFgzJRgDXECYqx1yW6h1w==,type:str] -DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:Q+H2KthVe4xO5RChejW0YU1TgGtJqXW4I7kc+5WXpM40GQ==,iv:jZjvyIZf3p/a4hC2LhqW6eo4wDGPLFgTn4YTWPVSrxI=,tag:WVguZkbwTPQ5kot//hkSew==,type:str] -DOI_LOGIN_ID=ENC[AES256_GCM,data:MnV5UPz2,iv:WILeu6evYxrmctdiCKU9xuuW1E4u7RdFSjG5abdBZZs=,tag:u5gWtVGfeuXrJeqRREMPBg==,type:str] -DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:gNfWFreLj6v6dXP/hxKvD7M7jr0=,iv:VAVh9dBRQP/ggQmKHOLepqDN8+pCmN5kIm+Xe7fpYis=,tag:w4dAIBjRMKUBnoX3FqjtXw==,type:str] -DOI_SUBMISSION_URL=ENC[AES256_GCM,data:BjI1Ebz5t00X3n8PSmjPUk6IaIFgWOqEDDA+SheOHLDNotjabDjUE2k=,iv:FB5IXGUm4c/rdVfzD6S8WnmA7CW8y91YsvDr+EUf1NA=,tag:Q+OwRyQPNaBfNLb21AQVfg==,type:str] -FASTLY_PURGE_TOKEN_DUQDUQ=ENC[AES256_GCM,data:MN2kB/F4ztrOXpuDVV3qDBotky/1suF4NsZbtTmE9gY=,iv:VzvRIdYjX4Tw2op5qTsdLg2l3ABeGQnTRp2EOf4F1S0=,tag:3Ua3WcAWJHVHxyRIfNTDCw==,type:str] -FASTLY_SERVICE_ID_DUQDUQ=ENC[AES256_GCM,data:BpF94SQhZj9+gwlWtGT23VFFQaZBMw==,iv:HRVbeeO7QCYi9wO841TCwS8BZYTvb//3diy9u07RL7c=,tag:EqtpAL5cWLYNulhMupkYOw==,type:str] -FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data: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,iv:gApN9Y2dF+3z6KbQcR5TmRIBO0DK5B907WaPhD9LIuE=,tag:Hl5fty+5t8g221MjMyfCqA==,type:str] -IS_DUQDUQ=ENC[AES256_GCM,data:NQOkmw==,iv:0tqLjo3HE/XZnR6UgZZBFZtn2vq6mj01XgO9j5B0r9s=,tag:dX45cwa5tWLgIzjxVe1Log==,type:str] -JWT_SIGNING_SECRET=ENC[AES256_GCM,data:PjgE+THClW+wcA1gvnT5Ej4sOmIjYJCXtSw7//wpxOaeuKs5Ic9Jt45qFErknu4lL6RtHEHXvQuuxgYEKkVqzpYuH2ckxFXRW/UChWMj/xw1ye8ZHjca1CqY8kHyN5wMGgxAAaNZAuK9IvRGV8DEWdepRR9JKM8f3RwsZSE4mw0/U/ClBbc9NywFFAGotouVPxexqWZgb8K1qA7i59FXYMP8HqBu0Z9XZzSPJ1Jj9lWe0HbRyE5IC5DX4sqCDuC2UdboDRfp5RwpJhMYWkKMf3q+3xccTm/jMuMX1BgybG26r4xgmko13g7aTcznLj0qzzY3OWilk0jZWJ5zIXp7C8282BxnFZmWfE224zi+TEaJBu8aAn/WsZznqp6dHv1PqqhXEKf109/fHKOlxFjQKx4qL4/AwS9rUBWWQZ34Ldm9Y/7F17/Hmo+DMztL9xfRyu3NkzwjOCWKXL+PX5A9AszKELOPkaJxT4MIdtNZp1CLKl3RQtpZ33l7gqb3kJuYA9+WGgya7HN0QX9IbkX9D1YdAV/cA2V9dMd7tb3pvnxybbEODig2eDwxEp0fVAsULwxTF72PRgczGTiza13zCE31NEwqEYEBRE6Nq1UIJSus2aiBocrETDtfvELzvGSTOtlJUOM+ARoZiGyUvg/BpTuNoqEkCB2lYmSiX4b1jrxiE70qgMdbG85Bi+POqcfrg68cI5rk/zIbj08mBw5QTteELNI0EFeTzd013nxYXWkAlpC+OtlaZk/Pi1n8qhqHmkPfSlZVjxL/hqTwSviybwIJDh/cZCPanSpZ+Sn7fgEG8KzaQQHXRVqxXJp/2FRt80dY2v+nX54K664DPMFWgMHlqVQYIDoWn8Yv8NVVlP2lAxAajuOAC0sRRVgbZ6IOM6Gw/MD/u7mG+cxm/cvNZZfhT2kjzotEPqsGOGJk1tBbNuxcosi1sCmFlhZsFm1gjy09kwbRTVF68U/dpfjC+bl2+e7uMWeFcJOOJfI+AczUNu8MI+Perzs1C+P8bCne+27yvNAnPnerXUdpnJyfaRsbD4kOqm89681iMEhJNA2XToK7DTBslgXlLa2YS+Cp2jQAttsoTDKsEfqZIDN11JxXYgRoBjb3usglxGZRSp3pqGv3rrqXyqx4umL77mreb2xdil4kOBySFSKG4l1hHaK/NRJqsiaHX36F1ELFy3P0V+z0hW9QujyWDlt13gymBZalCgiLfawSDplhgULcFFoEQGc/NvFQvuvlo8ZvFycsBlZaam52WXM0BsoDvfGGyunTg27KCjx0+eB2hqsIABTU4qKNIxFFdWCeT325NAO82oO65TgfSglkXRDujjAh/nj3VgSeLU+SGGobM30+Bg==,iv:JHBkoIafd0nzqnzZNYhBx5Y12q94fDdQktC6iW43cyc=,tag:wV01DXrn1ypimNFrzzJLcQ==,type:str] -MAILCHIMP_API_KEY=ENC[AES256_GCM,data:6Co8J9KOFq1Q4qIVk3h8gayLsmP/kJ8Unczd56XjT/lC4Hkl,iv:2sNxyJmFZMigRKzqsaiDM09N/Dank783OE9SxvpUZfU=,tag:n8AzKw/BKoXciipBZkb6VA==,type:str] -MAILGUN_API_KEY=ENC[AES256_GCM,data:+JBNQ4LxSOkZWtB2AQPSDA4juXF5jUKZrTAQo8xGB0boQEW7,iv:AxtIwHYoNnSN9QjgRQNDK85WK+sCyYB+Rlzks106eyg=,tag:G70UlOuspR+BDsx/5r697w==,type:str] -METABASE_SECRET_KEY=ENC[AES256_GCM,data:GW/O7+RQWlC+rBb2A/xIHKyYyrEcmUMy7dxqDC5nQ/UpkzOHK1SOpRvandIa62YivcNNvxwEzAFHu7BASjZK6A==,iv:GPPmRbHHys6I5FGddg/AZsnysFQ3PVqPquupbhiAtng=,tag:XoN4puLeTMwVB4zQ72Qpqw==,type:str] -NODE_ENV=ENC[AES256_GCM,data:7zwIoxkdMtNzBg==,iv:b8n1uKjI71k1dc1kMYJphEQnnefqS6+C8VzGgOldUm0=,tag:rN6nFQjBY4cghk/n0aeBuw==,type:str] -#ENC[AES256_GCM,data:AQh6rlnCeWROveEJsICN/veNdoKD22C++/M=,iv:Sw5zGhSVxTzs0m5aRWoGiPRARwNnCujwdlEN1i9ZMQg=,tag:oSE0CuDZoN3hJNhykICfbA==,type:comment] -#ENC[AES256_GCM,data:189wvfDMP4Wv/4CUSOi/ls4jIZpU7PBTa8mcDYxqnU5N9NhSjq8=,iv:ohTmqGFpcn4cbIos8TTemUwNGzTXFRZHAEo3qf8TqEc=,tag:NrWA7r7wP4MVSwJU8A42Dg==,type:comment] -S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:kinsYfgL36qIqGou0TaalR1HGiWvX/6nCcNe/gYPp9ypSGo=,iv:Nz5Bub7iBiXlo5S5vpiv1v7OrX/2dx3VOcXNSNtigaY=,tag:rpAu5dDTZnveOMMjWCBI+A==,type:str] -S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:4/yI1dtcnuRx+wzb3F+ygBDsKxs=,iv:ybu3IGbI8K98byov4TR9iy3FcG59Yr7EZC5oVHbUpUk=,tag:+pDV21jYmde6KlGa9HfV1w==,type:str] -S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:wEFLWIh6UL+eI9eMsgqad5lI5dppWIzac/E7B8Fa3PUSLQejPQgpeA==,iv:1r3DFRl7i0QPZ7ITr0jhKV08b1ZbnDmnmIw7FCpOkzo=,tag:rIeDFnAM4M4VaoagzalxOQ==,type:str] -S3_BACKUP_BUCKET=ENC[AES256_GCM,data:aJ9K549KzJSlgNs=,iv:Ca9+3SxNXvyd7pvX9ShtGnjPUn8LARnMhOfSaauUAEg=,tag:0sRvNSahQTOsw3Xk9YrOPw==,type:str] -SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:OTK6lJ6IK46n8wXKVLTH5WwKXn47iLIZjEPQasLPW29RfBjOfihuZN2kEf4T8pBwDQh0fGaY6crs1dewF4ama4hXvmg13kWqO9MwSU+NHiuroX4gUZ+Fg8xkBOjnarEe7NMiRMP0BrlntD1LwwcTDV3p/Df6SphgyYfqeXDiJP1O4AosPuv+4egBKqqY2KCy3czc47PcMWapSxWJfMU7KlWggYoSiU3BR3EhbP8cRVZ1tYUttrb0brI28A==,iv:c+h68p57wTshNPaAzd0EiEBVXcAPvBUvgozWJEpZ7n4=,tag:aV3vlkzWJCAp1n6kLXCFNA==,type:str] -SENTRY_ORG=ENC[AES256_GCM,data:m8Qr,iv:RdVMbGg1oRNC6t+Ly27+wbjR7oI6uGYmPgGagNFE3cs=,tag:/xqPyr8e2SZgMw4dM9eRrQ==,type:str] -SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:ius=,iv:SgH1AcWawWQRDHcB0E5aWCfp3UXDSOvCXocgsV1eeKo=,tag:1QfV5Jl9bQvV64Ty+IcNwA==,type:str] -SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:eLJUhmgQ+zOYRHnYKJrp60DQpKR6Li3iOCeJ1VaHWv9IwItuakAEIRbVrC8hp8Q0c02M76a6nlObomuRFSqK/zOMc3aqomg9mCpObYsgQw==,iv:vCCjOU36LdEwEHaA1Rqzqfs0Po3uodY4qJSbbih2BlQ=,tag:er56v92xiIMel1MjqRL/cQ==,type:str] -STITCH_WEBHOOK_URL=ENC[AES256_GCM,data:FFBgY/8j+ZkVDJlx20UsSc7OiR7V4yg38AC6jJWKZXZ/KnRhCjMgsqwgGHwxUBbZ1YgB/ZKLscAyhlGWeF9An9BsFueUJk1v9W+w8IOBpiczjftfC4VEqVWTqpiWT09iGbiiHYZTJKUvdxeNnsStoNAmcN7R,iv:GUY0HRWcMHKlYhVDo/LsXrS0UXMdIuak8bn0gdtAVFs=,tag:Vo5Qvjt9JHpRVbG01s01kw==,type:str] -ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:whlgZOo3vAFqntlSdO+vvrBIws8=,iv:yiBuRlqWtTD7VZuylXFs8c+rDCZbce3N0c3kG8i50Ss=,tag:V7mbPzU7QGJcv98IUc/Mig==,type:str] -ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:oFi5Jd45an5EUeIfbNL2cmcMa2U=,iv:Qda3XG/A/3ilj4Ak0AL2LBSq+N5TClAtIZ15w6//U1I=,tag:5tx1ESB4JOQNGM18L4Y0xA==,type:str] -sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAzZWZlN0NjRG9aZHlKWHVR\nOElRZlVscVFseGRORHZoSEpHS1ViUHpnWFVNCkR5NUU0ZUJlR2VOZHV5aHRJTi9G\nK29CVHhvOUt6OUs2aWNOSG1RNHdzMlUKLS0tIDVHcUF3Z3dzKzhXeHNERUlUb0Na\nUXBaS1doRkx0aVdFWGJVUGFpNlN4aG8K0cjHGDgqdu4DnvrU1QIZAkaMIoZA02aE\nVlURBU9Y4MInhk3xs/9MSxNLaqlOPDu5sCXRI9ATO02fkiWNDIiDRg==\n-----END AGE ENCRYPTED FILE-----\n +AES_ENCRYPTION_KEY=ENC[AES256_GCM,data:751MRbeh64vjtiQ2jxp1bq8IRtRixwPGYTfDMpj2lic32BLg6UtlWkeH2GytlzE+KRYsIsiGx72lm3ZrZ1fMhw==,iv:3wgol96YJTLcYw76R85RLy/v/ReXnDJYs6HrOeXg9y0=,tag:6HHc2Uiv/ExPVG3i14ZSbQ==,type:str] +ALGOLIA_ID=ENC[AES256_GCM,data:kBCQeIuUlqbS+g==,iv:p5cEC52oqS8pU+mUaEN8OJy/81EihZB5xr80gBor7JQ=,tag:ApZz0/ou6z5Gbsc/8YRaog==,type:str] +ALGOLIA_KEY=ENC[AES256_GCM,data:6n9R94NaX6G0w4hCE76je8ul9WvyMs7p7nr973E10a4=,iv:pVmd5+t3O5M1qy02m6qZVXVaY++XdSPHVE//5wj8nDQ=,tag:Vbd8ajqX+fMYQ3p5v4kCQg==,type:str] +ALGOLIA_SEARCH_KEY=ENC[AES256_GCM,data:JeHKOBgr8M6drd7wtU8SfKCpSX931f8hO9xpmSPDmRY=,iv:Bwf5WkvVIiOrpKT/HGw6WZpJfTosUgtILluJKzRuBsY=,tag:7PkZS2M0XuxZCqd2BjCyyw==,type:str] +AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:LZeSGTlDiQK/V/8aZuMNDpdDKtw=,iv:Xd72OG34Z1Sk2YyFz7gtZLXv7hRH5BDqOp8RKLfS93k=,tag:dt6RXQ1G+8CCOH9F/pfJQQ==,type:str] +AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:LpA/VZ5/vO9yTV9EpGPDsMvwYNs=,iv:Ogx5wNtEp5CFdoRZUnGRpsrcs3Z1zM3A5GOzRYJ4UDs=,tag:VkdkZTgQfKEomdH4s66Gdg==,type:str] +AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:PZdQ0rzvqo2OL9xyqbQFqa/ePQDhtZuIMjoww0rTUoQIDyl1CzISGg==,iv:o4W8EFEEwzdsqMMmrgPKaLdWTydGILaw0CV1QdrHw4A=,tag:Q3Pz6ppBiiHd9RHzzdaZ1g==,type:str] +AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:96A6q02ifiAqvLA/mQEWyPlcEEC4txeyu0njUeDtwOT/1o2LWmAa/A==,iv:QpJkInEPh1gtGQq0XtsB5sR1kgLiV9oyylEsd4ccsCw=,tag:oNJoIQxWy/mfJVo1kkZo2w==,type:str] +BACKUPS_SECRET=ENC[AES256_GCM,data:QbNhevI8OwrxrjsG5elQCDNkYSbszzt07dyfFO3MP95l7zYoWpLQA2oR5eo=,iv:N4CfzpS9ba+XTbnrNVlQfVxOypT+gcoPZX9/idaQ4YQ=,tag:1CsEbtdZAIeadF5IfppgtQ==,type:str] +CLOUDFLARE_ANALYTICS_API_TOKEN=ENC[AES256_GCM,data:6/u2EmJ4YUB8PJ4dPwhqvA5OPGnWT/ydFs7F7ELw5hwzsy0A6RF90AwVR3Ef+t7QXUf1F6s=,iv:T76mkS5Q+peXlnt5QYQFKABXYXujb4ac6HY4lbTodAI=,tag:aZxiRBxOuk5Dn9qPcx7uZA==,type:str] +CLOUDFLARE_ZONE_TAG=ENC[AES256_GCM,data:JJYHegytraHTH4pHMzogNvYLk4ZLVgabn2ilWenEbgo=,iv:jOd2ohUO/nPcQ2OLC0I+MUuT8FczGpUY+LQQ/zkbAJ8=,tag:68gw9ZyodNRgmw9jwvGpmw==,type:str] +DATABASE_URL=ENC[AES256_GCM,data:6uaTcNlZvgqfr5sdjv2zV0M6XTzz81+WaKpGobtm3GQRf7eYwxB10NFy2e4=,iv:yXJBRJugdINKw4vEf7Smq/YQYTcaecb6bAhkw7GYktY=,tag:/RB11xiG+2zzEUcxYJ6/lA==,type:str] +DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:xgVjAO7ICCud4Z32SoLMvPsMIVpDFtAXPn++8SqyrRFUww==,iv:+jtVgxesr4VgEHiy6Jems/rcQa64gUFGIKBOapIJ3Sw=,tag:fE7xazMZpzJa7Hk9m2hplw==,type:str] +DOI_LOGIN_ID=ENC[AES256_GCM,data:rBmFG4TN,iv:IPt6UCcoZS4HOxMpFRusShyw5zQtGZMl1AhH7qYsrg4=,tag:TdZ/F2NrnFMfS44USlvfGA==,type:str] +DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:35wEuCwiyxp2N9fBUCDegNq8O60=,iv:I1iD5qnltA8IyWPUcp1jBelgN0D2TQ7lG7B2JCdlpvM=,tag:MImbyzW7V7UpJY5aFLPfMw==,type:str] +DOI_SUBMISSION_URL=ENC[AES256_GCM,data:BFNOSgbCFPktK7br1G4kbj2whbaglEvNTmJ3HLxdUYzN41himICbk84=,iv:V/1d30Hp7P8gu0nYveOd6FBknM4KiCQ1iPzZK3KSLq0=,tag:tU9tGnSCVx+Lzmf28yd7ZA==,type:str] +FASTLY_PURGE_TOKEN_DUQDUQ=ENC[AES256_GCM,data:4wy1YftUnRu62Jj3lbN8ylczGF5kAwWmUZfl0rdHztc=,iv:1SibM0+li/9lbCZIbi2U5MdD40Zj0/a3XxL5i1PQ6ck=,tag:/ELz9FvaqLy0cqR0ELo+Bw==,type:str] +FASTLY_SERVICE_ID_DUQDUQ=ENC[AES256_GCM,data:NX4dyxYOGtpMqzMctNDdfEDlMqf1jQ==,iv:tq1kSBmN6Jx00V5M1C8rfZVNbD7M+odY9May5rxnqCE=,tag:QeNLOFU3wjJ5XT5+isbfVQ==,type:str] +FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data: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,iv:oCQ7ftTXEJsBdZtUjGmcqoyz3JlBtDHTk/aAUdYsYqk=,tag:TXkZBrMkUhrlLgMGmtVaxQ==,type:str] +IS_DUQDUQ=ENC[AES256_GCM,data:3C9xTg==,iv:icmSqoh6vo7nhF+lefMRM8eUMKRO42vd4aCyVsLC9CA=,tag:mmV4hbOGp+HlMqrbBErgSw==,type:str] +JWT_SIGNING_SECRET=ENC[AES256_GCM,data:xG4dmP96hzsxRwyoyuNwFmZ+2HcqNt6QVwCSp2ZnnJg6o1gWX6i8KXrdEl00eRF0mjCkVxEFvQyvzHJBBclY0cpvoIOc3JP1My9k2/nGzlwvjpoPe0XtCqVcbj0yAc/FEjeCm+QfcQRuyGC8jXYfx0j061M8ehtvFG0z9U3OrhxC20o3cNf2liyGwycubIhwmxOTCkh7XXn5X9v4I1MtKgo1Y+fG2B3g9Edy7tD9TVlMFwrnYA0NcScejM8xbW9mNhga+Znq40q88Kv5GdSQ9zGCMHSPthmydVbqkMqc98vLkpgoZlsJQubLj9EIgNF7Vo9ZwrCE3jGKs5RwjkRib/kdFCvyJjyBz05IeSJOctLS8aMj5A29s+VxboTvnXrbHSpuML5nfpjY3N08Nr3m+LsikEzRSS2J1ZIpwszdVFoCmKmDVJD1Qp1zvp4lyi9a/MxkM2PCkySuIAdZhrnYuoEztKX/I/PooeBJUYj0aqDE7oq8YSXNdGaDkIFVgjmtGetLBoIixaerguT13zMCoJvuWhoKkwPS/erBQvBUZ3ujnWMUvvBbXgHaiJUSkSGZ61pf6JJwW2obFH2FG6tLoQl33y7beA33vhO0cBXbFC6hCoC067A8kU5hPvsqx8Ex9zaHxZRmaOzFu56aqascXSGvlRAOjap3z+MHPeyvfuC2VPCpqNQrmWdGkHZXRQSGhVmLxf9c+L+L5ypI6Fgfd26ZquUuKpnGl3WJ7Qb8N/sJAZU7VbjfRI8bgfsXsgBecch007bhM1GoX/ARTdHgu3mvpHH9BBatdb/5OruSm9DjSNb8NAFC90lHERmZnRPPPLazrqdTO9HcRePMetH+hfhS4WoZzJY2INHjKevr0eaWz2AHkZ239gUJGbuKA0WtimulH3J1iS/NW2nft5X6IGKfsOWdDF/kXmiKyZeSy2YGLJtkO4ILrVAOZnz/HmGu8rmn7pdtoCcSYiTPorHu+e2QD6wSdD6h6fZ2O1IESIDhBOCiusUM+s1cxJGmhs5Fr47GEpPHAY6Jm1wF0C3HbHRodPu7lurgICoRV836vnM/NKfCfP/gk2i9fHph0482SScVQMvJjnh4nZPpb8+T15PPLvKaz/jZSw/TzKxMGxjcl9kvh/c3CsYgqpgi1/TWW3Hz/5Hs0p4psRYDZ134pOq/Fxeqoyk9ZI7/eXo4JL0CgupoyxhhckU445SOth6fc7XFVZzGWJnx+CMsGtJpAxpSqpwDd/d1zZkH/xxoiO81+2UTKs6at3ZTF9/TDnWLkJ6C6PIGZypogaMUhRlGk8i2wBR0Xn3GPW9Fvmy+SlsvEiKm5yQKs41NUtodTo+XnzvxZ1ncHPvUHDhe5//OgQ==,iv:Brlr/YKrcI0R8Yl7QTGzRG+hDfCzFi034AdrKaFq28g=,tag:qA7/wxhOBBHyS4cdjmdLOg==,type:str] +MAILCHIMP_API_KEY=ENC[AES256_GCM,data:m1GipxI2llyCbW5pMkTwmEtJ5nPBBMqrJ7Gko4cfCYM4jW4Z,iv:mI57ZdsOQIlPV5h49LM6t69UoihIcH6B/+0d3TEPw9A=,tag:SqfxzP50kn2nSR4c3oXHXg==,type:str] +MAILGUN_API_KEY=ENC[AES256_GCM,data:BtXONR8wOLPTO7awQ26A8vi2XfX2KQi5YEaHE4Wi7LrwU2HX,iv:j2t3dzF9YUv/PetPnVSjKs0BQFznpOS/a/rMRJfn4AM=,tag:Acq3pgoE+Oy+gdatBL/HJg==,type:str] +METABASE_SECRET_KEY=ENC[AES256_GCM,data:P0F+OIhhibJcPQMnzLCkv+Vwrq1d4bABZwebGyJcZxeoam1U287qaoNb2mQPFk4fyi8EqcAMB5FyaN2kMsKIXQ==,iv:RBXNiECgP1WVj+N1fbsxXaDZfieZgQEL+0IWcw33qbQ=,tag:OhB9X8MHGYzeN+1AyoY9kQ==,type:str] +NODE_ENV=ENC[AES256_GCM,data:3I9z8WOG2E2/KA==,iv:tmmcp0vcKsU/Nj7y0E6LtiYySzof/UW+5RJlTG5gVjw=,tag:xbT9L+cvLeQLWkdFFTCE3w==,type:str] +#ENC[AES256_GCM,data:SOPIyEXJK24O20QtmZ2eMPCkAkyf3Hfc57k=,iv:wydoEYkCs1uOym3/J4Wsqr55Jw7tT04dTK1p7O0yOlM=,tag:l1ImXkH0lYRORmRixFSjdw==,type:comment] +#ENC[AES256_GCM,data:t5arAwQOBjMoFOvhkDEQEGJwcEIhNCzm7JMIpHKVwHGxJtAh/xQ=,iv:25v0UsaYFDa7N7tvE7425/3b4zOKYA6Ebp6nY7x4jm4=,tag:mOU3HuryXltdLfVjFqvung==,type:comment] +S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:/1qILvvXfTkPlxNcbi1m5fbumckadjzXYR0v3TVHthtdqWI=,iv:5V6XMWkCepZ9vyG8okYX7tNfUvolgs2Pk6BYzxGCnL0=,tag:BeOWLrhcE7pi49cZWO94jg==,type:str] +S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:49w8uFBKAUSjZVihs10/yfc8VH8=,iv:PW/wnHTW/+LWINuXCXjXbFz0s3WbOdiJ6Q1NUkGywF8=,tag:sMy9ZkkKXZ/UVTrJw3W2/A==,type:str] +S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:X5Klz6J57fn52Jgx9CSZ2ehTzwjO6V6LNlx4Ks/mKCWcvXmnpPvDag==,iv:iHWK5TGbXPBtqLqkWGKUme3rVgXt82z8NRSEeK7BlSA=,tag:t11GmKyKhoQqX8Hdk9UlvQ==,type:str] +S3_BACKUP_BUCKET=ENC[AES256_GCM,data:DbVSSihLmR9cA14=,iv:8PV3RoF/rsShCS2EOjYO0kkdgh5p4VuGu60LzCpzMGQ=,tag:WLvrJtjzJ5PMjCHOGNNa6w==,type:str] +SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:pcKtvXZf3CYpvfGr77WuwZC3G3YGhQ+fyfvWp2LGOpq/fV8fon0gy0/7tZwJWJjSC8eHy0k+9SjC4tCP6eY58UlOfATntnTl8EMftoENJouX7hwRJR8SXh/1YKzJOD3P961pnxg2m20tH1iaYjhqx+FYeZDTo03BZGrtPG8vSD4d/93kgY7KpalSRm2SSUfjgcojBWckQ46Y0AgaDoYa12bRZ13nb9noA/p7GAFozVZ3omXFGzuzrKD7AQ==,iv:ufWNitusntf94YUR/QiMysucdtIHJue1UeRVcDJvXTA=,tag:v9ZtXE3uIh6m1FP0tm71IA==,type:str] +SENTRY_ORG=ENC[AES256_GCM,data:l8Oc,iv:6ajU/u4V/Q/pJzLgRiU8sQvcvdIta8StUbsVtGK05Hs=,tag:sAXf+/2Yy5NNjeUEogqXxg==,type:str] +SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:vr0=,iv:wdGODpPJDuJJde7kLs8lwA0Gyp9pPhjIm0uOJtKJquI=,tag:9QunRUGIz35fjZNFfyRMMA==,type:str] +SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:tPrLAXRT/mwgn8/lTPj5pD1Odn3JLXR7zc1BU3x1DVFtCuE0LwPhN0tfyDHU2rAsnLgmpPkeTqrnRgzuVVQZU1Dmd0jxpL+yQeIwJJ4bWA==,iv:m93NYt5LoAAIgxcWwgoit269C8Wq52H+IwuGdsHO4G0=,tag:W0A5PQbQdhLrokN1WKtyAg==,type:str] +STITCH_WEBHOOK_URL=ENC[AES256_GCM,data:HiDHZ/2Gp4vNwbjGNfigKGqz+b5u2OpT56zDt6Xl9HEB9OsMZGxeXf+na/qHdzTZiDpMf3Bn/ow6WHl6jlehqXx4GVnb1FNyGVWUdc8MBagOuTmzu+LQ3POn+H4x04XSseofh+hbRt4Qc8i+ZxbmA3K8UNX+,iv:9UUsj+w9mWnYiTOomj2qZVOHnmOw7gGDReV6BydwH8o=,tag:S+Uo9YqOx0hyPsxeYI3KJQ==,type:str] +ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:SbiFBzCMU214BxacdBEFiKTt9wk=,iv:QlqAGJo+fMFEVQhm8+jm2nsQaBmheeA+BO5S0xkHShY=,tag:NjK3JLxMXGAtf5kbgwHNxg==,type:str] +ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:PIYKCQjxL2vzaPA/GXDLPzkGrok=,iv:uUjS/y3SmbUM2Aj0hSKR3uCnHRpHcq43pHTV0Gk/gGs=,tag:+POGsU+kxYqjx+0KG0UPtA==,type:str] +#ENC[AES256_GCM,data:FZmOvrE9EWFH1CDGZ3Zn9oJ02iyZwlDEhNeJvHhAtxf/XaMf7qsJikYjQWvSX+T4AlDIzRXbv8C5sle8CyJR7CC3,iv:wC1Q/pkP3lxp6GZ/yxnkxyMECiBBnASVHuAJ5EtNRSM=,tag:0sSqQkxL0vPeA4BxUapJ+w==,type:comment] +AM_REDSHIFT_PATH=ENC[AES256_GCM,data:RLhfAvQqCIGcw21WrFoAyeriDWDq1zYZ8339baisOv1Ps3wP5omQATDK9Ql9He1CC08mOWXNeTtr1vwaKC4=,iv:0/BzkwG0bZ/GP4IRfOWSSSSrfUyrZuYCFsEZ1zD7chc=,tag:B9bUenkjJhRJf2/Xe0xOXA==,type:str] +AM_REDSHIFT_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:bSg1P3WWaMWLGnGShzKc1hcL6N4=,iv:FtkZCua1hZYKh1HsXL2rLPlZJgXznQtAYUqeyo82+BQ=,tag:/7qQIVR8ooNCQxudLVAcFQ==,type:str] +AM_REDSHIFT_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:64OsKjx1rJ4fOgkw6bYFY8nlOOLFLK+LPQJwhhto53QMxCu4dmVpBA==,iv:9/hztUvW3sgxqd3sVPLmOWkeXfAC/lm3goZDaEKTBX4=,tag:B1En64y3TevFOqI27Rz/Ng==,type:str] +AM_REDSHIFT_BACKUP_ROLE_ARN=ENC[AES256_GCM,data:l4a0LSfEZ4P0GoT4MLPqPAd6HlkfktvXzH/DFLREGAsP9nYJU7ATIlrC7kPnUyoZ9hSQ,iv:w2V6aP8EBWVKXNCzxgLqp5ukF5eUuaLjSHqFMdIXoGU=,tag:D1a3Y1gHFe6nFjY95YUChg==,type:str] +AM_MB_RDS_URL=ENC[AES256_GCM,data:YrD7jGR8INX+utQIGGrXceseTb3E40L6wFbTuK9z/0PEQm/8vwJuUKNXbti50Wrw9kQL0vg9N1qQdW4gb0d7YsBs1F6IFI6r5dCstheQWt1xIthdy0xoGUIrRn9RANS/cFvDBnbeawQS,iv:liNIxpd4cAd47oR+YJds2CD3BcAXFTFyVekYqh5cS10=,tag:ayvkAUp8q46GthLJeuCPgA==,type:str] +AM_MB_RDS_ADMIN_EMAIL=ENC[AES256_GCM,data:PGAcQzCrjF/ftwgs7/aSO4NmANgXm7XAZrOO,iv:+kmks0PtFE1oMhrKZlAVDXCIoxfJlrRUoVvA0QIUmLM=,tag:TgJo1RJQ9KwK1+ZJTtqdEw==,type:str] +AM_MB_RDS_ADMIN_PASSWORD=ENC[AES256_GCM,data:RojtXCFz94fsX1gkoHIU4cAmeA==,iv:0jRuzMO9hSeuuKVxNw8Rpsl/V3lIs2pq9fpTdALsWuQ=,tag:Hw/Y1gOswEWlaMyKr9bu1g==,type:str] +AM_MB_COLLECTION_ID=ENC[AES256_GCM,data:5Yw=,iv:09zeziTirEHX1CoLYXA9CDquXl5JEgDYBmjjF5NCkoI=,tag:F8YcGx99kASnYo8EeIUtSg==,type:str] +MB_DB_HOST=ENC[AES256_GCM,data:SF9u1X/Jo0ZsJtk=,iv:nl10iP0E1bzEdPYQR3EuiN1IvmvcSkkFAXBW99qvPmE=,tag:zaaKFZb/HI6xrddeZJkCaw==,type:str] +MB_DB_PASS=ENC[AES256_GCM,data:WdDDXujj0cdoQdGOytDqDssTDoymOPGBqE6zj9QX3khajz5ASsZnMuN5UcQY6xq2j7z50b7DaM0+L9FBn4BU+g==,iv:WFGu9BDTgIpaD/Vqc1HLLs+PqmE27xW9zyQA7R8rvZ4=,tag:xsijuzZGQrC3eXd8UReRJA==,type:str] +MB_DB_USER=ENC[AES256_GCM,data:WBz9UNovZ6o=,iv:xZXANxaS463jT6DGFFJdyHmfNVR40wrl5te2VY16+/g=,tag:FXXcAjNxRGhwt13XLO7jhA==,type:str] +MB_DB_NAME=ENC[AES256_GCM,data:Nqn+4XhMeU7dGoM=,iv:56hnF8rySc2qFNxGD6uhSkeko3YM1KKWI5VPV1tkzLc=,tag:TZ9MQ9I5A7E1pAPkjELv3w==,type:str] +MB_DB_PORT=ENC[AES256_GCM,data:Y9KWDg==,iv:N/lw6/4nz6oWH79iRgph+av411emmK1txDQOgc60YPQ=,tag:rKPc28FdvoMBcSSqSvd8pA==,type:str] +sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBiNzhhWDU1d3V5cUJGM3hY\nQVV5OTVzLzFudGlKV09OWkoyUXNGVWNNVkNZCnNHUUE2UkxsNjVXZWtsbU5Na21z\nSFd2b3B5YzBvVGFEWThlSmxmMnI4ajgKLS0tIGQ2TzdyWk84RzRXMWUzZDJpc3Vy\nVUZHakFoRVpvNitzZGRWMzhXYWlVdUUKvd2gu+8GnPJrWuBddev2Gusm5nNH/Bv0\njL8wvnbnLTtBkkRsR+SCJCd9OTXv/SmE2Sqt6J/RGE1ZTfMb7OTvmA==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_0__map_recipient=age1wravpjmed26772xfjhawmnsnc4933htapg6y5xseqml0jdv8z9hqemzhcr -sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAvT3RxWDBqTS9LZ2NDWnND\nZ0p1dkxTNHZ3S1dLaTNPK0Jsd3h3L2xmcDFjCmpoNlgzWU50Tk5DWDc5NWtnTW5X\nZ0VMVzEremEwdWFDL1FDUjBKeUdnRjgKLS0tIFJEeXFqMnlMNzk4cjN3bHBiVm8z\nbVgzdmtaVTdzelRJWXRWQ3VnUllvVEkK7+wFnHcWlQ578ZBdYdEfbstSSUHyftzm\no6E9oYEqwH+oxftQN5E2nVQ2QxwdsXKlnThkMJgVxH3ncgfMSUt5zg==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBQVTMzS2Z3WkI1Z2IrcFNi\nK1Q5ZVFFR2k1bS9kTXJRUGNCYndabjRyM1dJCjcrVXVBcWlNeHJGajBDaHJtSGtu\ndEdsRjdOREJxSWdza1praU1PMmJyZTAKLS0tIE44blFNb0VJeGdIWTVHd2Z2U1NT\nK2VWRGhMdzYyelJWUk95WTF4VWxvZ1kKG2VWJD4LxIqReuC5d90pj0Z/FKEdrdjm\nKvLsxFappiAAMDMZYoEPQ4bvvTPF3+0mLl7KBAIx5eeCZb569xWVEg==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_1__map_recipient=age1vhftscteyrwphx0jpp0yl60xxrjs77jq05mkzv88js7ckc926vcqepp2cj -sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA4eFhnQkJVaVFiWmdzYTd6\ncXduWCtZdG42dTU3VVJ4cEhlOEhpRndSN0JBCkdTWWhFQnFCTzBOalM1S3RTbDFo\naHBGdDdUcFB2M3o1R25VcTJ5YzZ1YnMKLS0tIDFnVnhzcVRtZmJZSmdxSE5JSGFK\nVkRRZWRPMi92VFpUNldYZ0MxU0lTSlEKh6gcbf04oOIrmLzfoK/0wagfzxDh/DSb\nQCRvyhkY4cFQgO1fn6fU4UXdOq8Lp0rXPEuaK15L7hq3q3hEo74O3A==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAwWGFmM2lnSmhsMFlwQklX\ncVlkWmk1NFFNWDRPdjBmdUtjQkRkaXgvd2wwCll0Zk50UlNpaUIrUG9NaUpTZytP\nWnV0ZkMxK3NDdnN4TklPWGMwK1paZm8KLS0tIG1qd1N2MHltZmhQNXpPUWo0Rnpl\nc1J2cGNhVzdhQjF4NEZKV253dXcxYVEKnJ/Cil5FOUopr0gAwVSxWpYe7biR/Mla\n9sV2p/Tue1qugZMF5EVFpTX/D5mYyhI4v/xdA1WvI90Rg6gmJpZ/7w==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_2__map_recipient=age1slx6e48k7fre0ddyu7dtm2wwcqaywn5ke2mkngym3rpazxwvvuyq9qjknk -sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA2T1gvVzZKQkx0NkJmek9i\nUmtWUXN6bGt0WVM4V0NIN0pZdzMxNXpUZGtjCk4zRVNRMzlraFNyQjU4Qkl5WkpQ\nbDcxQ3c4cXFtYnBFd3hmM2owNVZaVjgKLS0tIHpVbGlLbDFzZFhHVGlFRU12c2tC\nYktGSm9JRVlCUmxFOURHeENVU1UzMDAKbUeckn/3XgXyPFn/W4Ha0ayo2v5wVMQb\nNrsjhYQFn9cdG8H8hqeGh/yE1KLfIwzI8U/HSXlYs/NtsvH3h5qUPQ==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB4Y0VVdXYxUE1PYmFzUlFG\nWjFxK2FobGg0enlnUnloTElDelNySVlQSlRnCkV3ellHd3VjNUdkN2loUkJncHNF\nQUtwUExOZUFtNlFURWM0bStBSEtuYWsKLS0tIHhITTNCcytDRGMvd2E5MzRFQmhQ\nekpNbXNPNi85aGp2eXRvM2ozWGtIZDAKz3ECm6Krj4teS3XStAsYzg+fOasmDA+b\nSk83WIRJH4jL0OoKmJDwsrh/XA4EL2ADw66c5B5Uzkei9UG9TlhRyQ==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_3__map_recipient=age1vfhyk6wmt993dezz5wjf6n3ynkd6xptv2dr0qdl6kmttev9lh5dsfjfs3h -sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBQWkY5YkJiTUVxQXFES1Q4\nYkY5UXp6N0YxaVZILzFkZGMvaEZ3TU1XVkVBCmV1dVhCQU4yZU10MnFhUGxVTEQ3\nNjFOeGlHdGRBQ003WHZGMHJzbk9zZU0KLS0tIFBCb0NRY3UrazRzM3g0TEw3MUhn\nTThUbzdFZkJONGNYVTF5QitLaTV6cW8KR/S0wl3+auYy9Ag0tLckJ2Xhy92e+s47\nm0lLrUGvjLYSGdA9Ox3KS2nmem+RQp0RCjTzErDlsY7X5Ai7duCJRA==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB6M1NWUEMxVjFaeHlaMkkz\nQjhUTTJsQWZmL3Z3eEk4WGFJYTUrMURteWwwCkZlaThhcmZFRnFhTEIzWlZVOVh1\nbUJwK0RjSURQUjFEZFJpRE0zTTR1MmsKLS0tIGM3V1l4SlRHQ2FXa2dta29SY1hl\nZi9vdmtORHIzeXNqQVRsWlI2TkJMTHcK4D+hzIY7+tcpYln+lyLhVLwdrtHWdwwb\njeNH7SpAEmEJ3t+2zOu4bPo180GATNvP1LBz3+TPiXU7yiZ4enSpwQ==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_4__map_recipient=age1jwuvzyghfer7rx3qtqa4vs0gxyff0sg6pqgmqvp5zlhnpmvrkdlsdha4kx -sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA1RWdBT2pqd0w3UHhtMzU3\nYlU2Z2ZsWUlCd3lIZkNMQmV3aElYTHBLNURVCkloRmZHRTM5T0MxVWxjQklRVDJx\ncDN3SnM3ZFBYWk9LdXFEQmQ2ZGFTd3cKLS0tIEs0SHhEQTdRdWp3MkdNa25mK1Zz\ncEU4ME9sMFVXT0NtMFNoVnZnOURmd3cKii8ocexy9c0xfxPaV5FtBWlWy9KsaIEh\nMpH3eJTuAK0ElMjFrrI2AvjuW3OYp3WQU7ZnqI6ubZvi8mW7iZH51Q==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAvMUFZdm8vR20zMTN5dUY0\nc3c3bWFEZFB1VWJMdXo3MjRiOHZsZmZac0Y4CldEUWh1cGxMdEJmaGYxMmZOMk9w\nNUdjVzJ1bnlDcWVtYTFYanRZT2IzV0EKLS0tIDlic0g1T29tcld1TGVxUG1VOXVF\nNnVlYXc2aVUxQlI3elBLNHBnOEExNUUKNPvVRkL0qAhb6ER3XnDFfW3te+c72zxn\n0++0uZY3TMwHog+fzDquk6fA8/2pCrNjmvq4IWo6Dqa6c6CVybDhKA==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_5__map_recipient=age1pgxk292zq30wafwg03gge7hu5dlu3h7yfldp2y8kqekfaljjky7s752uwy -sops_lastmodified=2026-03-31T02:48:03Z -sops_mac=ENC[AES256_GCM,data:PtfB+K2c3gueL/a8MnWPsaO5XL/+uk9vgilMCAGHRW53R0ilHzWujyuNTtjsFGbReRP+SXbU0hllj3RkxCQtwZKouci2Qn9PmAKEonhRLjdYl5owSJsgSaXTL97wUS0hAZrCeYYAlLLPNYgP3XE7DqtFx9mY41vcn0ViyK0MPx8=,iv:F09inZuqJ1oRn8IUQE1noenfjdsyMNBkqtW37Z4qogs=,tag:M4Tc4+MD3zl/YZ+VcUxP2w==,type:str] +sops_lastmodified=2026-04-07T16:04:58Z +sops_mac=ENC[AES256_GCM,data:SJ9WySUDpC29NXq16N+hb/BwbOo/r0FisodMihRfVmTgx3x5+G03TzE+zQ/R+sX9IVauYQ/IytgU3qUbollTVqfMp9YbbcCZhk7cnEpGlOyJSem2jvqGG0liqH6bGKv3YkrjG/u+uEamnLcT3d2zKS0UWyAYZ4le750UKkHuBn0=,iv:+9osEtoyu/LNul6wH3g17Wc4BWhpp4ZOuJ+WKkFRAa4=,tag:YPOBDRusEbcSCbeuWntzBw==,type:str] sops_unencrypted_suffix=_unencrypted -sops_version=3.11.0 +sops_version=3.12.1 diff --git a/infra/.env.enc b/infra/.env.enc index 1be76f8416..6eca348a79 100644 --- a/infra/.env.enc +++ b/infra/.env.enc @@ -1,55 +1,69 @@ -AES_ENCRYPTION_KEY=ENC[AES256_GCM,data:vbymPb9pIX3Ar73KrjW62lYM/8A8fWoh7MnJA3IUAusQ6+MpOXNpL5k8eTu7ieGwiV7gY90MnQAra5upymG3Jg==,iv:xY8Lh00p9lcIT9B9LCARxQihN+GrivS7C2QlVKt1J2w=,tag:RDwybIdDYRk7Ecx/5L5vDA==,type:str] -ALGOLIA_ID=ENC[AES256_GCM,data:T9BnAyvLkom50Q==,iv:hntMtsqHiA+1V6D26KA756wRBm1BkFl5AuopGhLhUT8=,tag:GblUMhdJJG+VrGCniCq3zQ==,type:str] -ALGOLIA_KEY=ENC[AES256_GCM,data:72FxZOZkqzMIdbvYpO1yEZFyG0YZY7rVxJjuDPgcGD4=,iv:v5QGHSSljTYWL0/7djTGtPDP/VPGQeq0zPzDHrIfE7k=,tag:QmtvW6czoCHf0QuqZzyOug==,type:str] -ALGOLIA_SEARCH_KEY=ENC[AES256_GCM,data:PhxMicSMXQSqsFVb1K2Z3ByLwpZROrob50lICyVokto=,iv:+BjJtiUur6YEiP3BxNi34wyPw7D5hVZoXYZVs6CtL8g=,tag:78je9z1AKBKwbJbEs7aKbQ==,type:str] -ALTCHA_HMAC_KEY=ENC[AES256_GCM,data:T+llsxSjDyEyDPzQ5ocRshwFLTeqmG/BsQd1P/aTYxd+3IsWbKuDJzuyikAAQozpahKkJZefeN2nmdFhNJlrPPE7xXXvTn2Mm+gnMRJFb5X2H6/OGua+4Ds/wcREkKhCXJJ8PrUgwR6UNagqfvfa5id34ULX3Q4ZyIUCUnE3EtQ=,iv:/Zt+E7wtlGwiMqwIVOgNT8AH74JciranTzaaPi+5esY=,tag:E6LPkAGz+odAlMMsmRbHIg==,type:str] -AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:lZO5lg0B0KwZZqcNsVkLYkQN+xc=,iv:gZiZvft7AAcfl1NEXbrkB0FSvb7ncYZ9BQ7TWK2IyLw=,tag:XdtmjbCznNYvh1UfJrgbdg==,type:str] -AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:4VovW11aEAmALfR0GJzQTUqhPoM=,iv:alQBBxvedCD10OPIf8/J/VRriY38LCQDtHkkzHfdxvc=,tag:RK5YxQOPb0A1MVbLK3ho/g==,type:str] -AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:FZn5un+lcsDlePnkGlMTH6lUjncFMYjzl7aV4xyJMtCHhGHx9OOrMg==,iv:zQR6Z1HxiNJAv/sN3/A3UjRr33wbdNZtCIB6EWj0yNM=,tag:Uk7gvphLvDeXACoCzmryjQ==,type:str] -AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:YZb27G49IssZyhiQJIc596Obf2H1g96Nyh4mXr5rNhHypTUSMYCH8w==,iv:r7aMjD/RZBUjTP5q2rsNOlSt6+ncKgfDuBL4OX39Q7A=,tag:t0Q+Wb9Zq8aDiXRTFdS+SQ==,type:str] -BACKUPS_SECRET=ENC[AES256_GCM,data:4BDHdFZ4+N1bdc2Ae/BO8gUIqcBozcAAW8btH6zbkcTViLq0FVa7jqdfK48=,iv:3f9W35UnnR8KDxTQ1nZAuhMX+9ByCIKJctjjsJ5HvDg=,tag:1xWFZOBVZnK1RQxK3BQ4zA==,type:str] -BLOCKLIST_IP_ADDRESSES=ENC[AES256_GCM,data:hyphmo+BecobINiBmCOtn1uiM91/,iv:5Mz9NiC3siH/ne5a0oomvFdJKsXODXuP9C6gx4kyHIs=,tag:oGRKbNCP+/8S6mEzyccF6Q==,type:str] -DATABASE_URL=ENC[AES256_GCM,data:VlGdDtXbuz4Z03la2CGzYPgaWd9jNjbnZL0Hp7nntg/wsThrxsI5gJcxjNw=,iv:1neCIDqq0sdaaP/5YGi9CDh5bppHCqMeTtMcUwbQ8kg=,tag:8oNLRkD2QccR8a2EBNntjQ==,type:str] -DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:Ffm/UaNqtwglqDY4pJbrVSvbxoV8AS2hpL5/0NI=,iv:rnCV7miKSbOwpDlKpOr076gMhnas/MhZz+YqOSNrzls=,tag:UR3UiN3wZDv3dKyE/A8sig==,type:str] -NEW_ACCOUNT_LINK_COMMENT_WINDOW_MINUTES=ENC[AES256_GCM,data:KiA=,iv:JyFPxlkDeW6mbwFKmIb1V1RmQ79L1NrvJwg8F+ZoDjc=,tag:X7a/MrnxM6Rx41Gj0fxaQg==,type:str] -DOI_LOGIN_ID=ENC[AES256_GCM,data:rnyWq5KO,iv:ES5zyrlIYq+YiM76fbQkQWOkRlHCfCJfvJKFkkvbe6k=,tag:SbakzlP6bPGata6MxQPN6Q==,type:str] -DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:LQKtRZYGCzbIYHTXou+Ajpk8VMk=,iv:GgF8FClckpq+ZzfzxQKxTz9LXXblxIie5JeBM3d9kk0=,tag:I1gRtcQevT63dUQrWeCtUg==,type:str] -DOI_SUBMISSION_URL=ENC[AES256_GCM,data:FIeG9pi8ABy0OOS15akayMmbyVDu5yS0WHZk18Zf/QQ2In5bsC52nQ==,iv:XPADr6CQ4Wq1SOlDoGgrbVxrVM941/jtS49uNW7rQfs=,tag:amR+BxRepiiTrjPxIvpZFQ==,type:str] -FASTLY_PURGE_TOKEN_PROD=ENC[AES256_GCM,data:tEeV4a6MslOGAHCO8O9exE2OR+FWI7/P/DEZr1JQEtI=,iv:rs2G/2JHcjYej7XubvvazBuysfGvcR3JqIT1KHYdaqo=,tag:i5vbABs45IDWLZA6stIZKw==,type:str] -FASTLY_SERVICE_ID_PROD=ENC[AES256_GCM,data:r7i2ROh2K0SPmvjdg7S9fRLewlZmiw==,iv:tyvLY7zHaHM7g/7RJt0WP/k/iNeFIu8wCaZeGkWDFak=,tag:+S9pjgNt1xhmxujbKIEOUA==,type:str] -FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data: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,iv:hscfsdG8NtRkfYYmGDxtiAlkbac2RTyBsKTkl2jr/m8=,tag:bcuZAcwY7JRrysMC9uFu/A==,type:str] -JWT_SIGNING_SECRET=ENC[AES256_GCM,data:7IwVrNGrGdp2uECsH3QOtZ/OvskoO/LGCs/XHVbwMRVtPPo4ydbHxvmxbQiDzk+Q0GHv0sKMSbsd8B3ybYYzEU5jWp4qL8uQnqYqT8UaPVoPqgfXiaugUYLlWN+GVqTyfgOKQFTdyfcr4EGJ3e6njmaZ1rMF66AJyhJPAKHSqi7bgNWqOECjNISV1SZL9vVgo7L3xuwu9t2tUqTE95y5wWlz7XQvOjDCBJB0nuLd3ztfzI6qwMkzkg2nb18tHDTcPDItrA4t+rEcpC7ZeAhjE38536k3WcU60kCXeXnlzn4AP/2boGLGPfYC/qxVJ4fAXVf9WFmmjuhjGNMP4NqcsqufgpwgmxEVJpLC6ZFb0QFNjtPZK+SnLd/qJUzyJVpuIRUdCjK/V587dVBMh53V0Kuc2q88fZKA2cWQtjihJUUyyGayFoBIkgEi5aVmYX04w7szcaEFaCPDphKbV57UZstfrO8oYXAIDLVH4pGBSD97RiIvJkM7u0gItYzk/n3C/8L28g8/Fm1MVTvF+PsEnpb3F1q49fch9/y7ffyqb+i1JdlGdlDKUyVKScs9Ead8BuVe6VyDHxSEUJnNhmEAftTPyLXf/VLUlEtAxNAfsLDZ3tsb8/kZhp27elwvpCLmYZez+zCBnF6m2iCfqDWrextk+Iu4yghzt86g6Qkimqo=,iv:YcUxqeIcGA1lk45lVLThieXDtcfL71tJnmxfGUyut2k=,tag:1sxWXaALcISDV6zhXfafzA==,type:str] -MAILCHIMP_API_KEY=ENC[AES256_GCM,data:KQhcMqowStnITI9nwlMYy4tdRb4HfyEOf429vgMCvh4DNtza,iv:ANcTeRiOVQRPWYADhSuSr4Iec0wD7LPmR2uJkfSaEV4=,tag:Ys18nnsHrPzeFmXirt4/QQ==,type:str] -MAILGUN_API_KEY=ENC[AES256_GCM,data:qZ/mvlCYsb09CUd6oAxausNEYiWsrxvQgFyAWuqmuvnHDZHn,iv:1n4ndvLhR/ZsZG5id4Zc8rThI87gN8NpK+zdguLJ2Pw=,tag:Q5gZsNDyMqjclOv+xWfP9A==,type:str] -METABASE_SECRET_KEY=ENC[AES256_GCM,data:NYstQY07BV9I8vS7bNQ/Cc0MKh0IasFWGldiG+PIzLpg1sicBORDLZlJjD2/9d8v+pZl7d9f8ETjMSJDK2JTQg==,iv:V665xBdAyDoJISCKCGlr3fHz0ukkGPeIFciu4l0/ykI=,tag:10lk2XHC502P2zmOhIMzqw==,type:str] -NODE_ENV=ENC[AES256_GCM,data:d8aLeE89dNwCcw==,iv:O0Hiqq/LRysBJpT5a598UJq4VW37OHf4OBcB7vYWga8=,tag:nlRerWMVGSrBO6ir8SxD5w==,type:str] -PUBPUB_PRODUCTION=ENC[AES256_GCM,data:+YMqvw==,iv:8BX5vv/f5+joMxH5IbRmps+jy1a/PGlWhdocif+LACU=,tag:jPQwqLcBtVrzxgtuX7KZFQ==,type:str] -#ENC[AES256_GCM,data:a1UATWoyeGcPNbqZDI/5t4ZeGHJ4lQGxIw==,iv:gfIL58YSIUuJ2Dd35HWTKqdKHDWlHFxb22etNoqbXzE=,tag:eQeJq8FzglAtgcxTw6GJHw==,type:comment] -#ENC[AES256_GCM,data:2xM1MTnAs4v2RlHq5XcEe1UNIIhV+JCTvdBAGDzH6EUvZEIh0w==,iv:sr9KzmZsbhzXZ2VEA6jeP9LP23JVxv+EwrQQcwtG7Wc=,tag:hB8YG7IuE/syrIylMkDq+Q==,type:comment] -S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:vtIbmGq13DPp6xmmPnoLZ9yNa08X4mGaa+10yG8xfvXzgKc=,iv:8AG4A52LWFlJ0ENL/iusIM5I6kBBTnYQjpPZrvKWPQY=,tag:tw0Fm3VItc0SduiVfZS2OQ==,type:str] -S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:2Th8rzoPAvzCYE9Wuqb8dT7x8Uk=,iv:bBspSn1lZyl9fXwni8kHBXF3HNqT4N24Q0qWCJJrfkI=,tag:aL3JNfqt3z9EtS4e7a6Kuw==,type:str] -S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:Kn+bvOY060HrZ5iKt8LX9FAxUTqE3lsmFQhlX0CV9HOHL1h8JN+i+A==,iv:1EBNgDsy/kAvUJqxFwTVmH3YLlVWb9yyVwhNqaIYpBM=,tag:d1+up1HVO2YbM5UhFRtn3w==,type:str] -S3_BACKUP_BUCKET=ENC[AES256_GCM,data:Q6+2yr3yNTDohG8=,iv:HO7haOuCSn6wqL2n9PZd+wV+yGue1GtZZlu32zvIzKc=,tag:XgrLlDcIfENqw4ILP9pSbQ==,type:str] -SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:foNoUlRH4NSmcZb8wcJQWLGFYT+M4WhBOxcuKrMO3yk3M9ffyXASYEfcQuMX0JtyUHODCjIcZBEenxAjorXLPxRRA/86BvxrmUaQW3Ytzyr1oG7MMbx7C/0eomzahMONHFLqUitEiv+IzAm28/qwEvT7C0JoVjX5Inq1MXVJBlXNtn4eIvFVwFoIZD0DjhDPhNAbb9J/pZ7QP7/kXLd7O0ekWZAuyUeor0xSkvHu3RusGhkZp5E9k2idkw==,iv:P4dLkW6qYWo3V7Z828gjA5c/2HDO6g2ACA5QDY0YsdQ=,tag:PVzxgpN9V8nrvlsT2OWVRg==,type:str] -SENTRY_ORG=ENC[AES256_GCM,data:uD4Y,iv:xCwzDtvUsME6rkXD9ZjnWEpWlkkQeCHBTWO/KQ3d1tk=,tag:arH2UqCWDEnx63u0hYfGrA==,type:str] -SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:xZc=,iv:+VnTLa4E9ebKgzkL6ZXmJ77/z1Wg4IPchiLomjucoKw=,tag:FxcZKE2WP24MtmMqysZcaQ==,type:str] -SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:14eklwo2DK7XzFCl4B0zKorH2mjDJRPdLOQGlILCrrnEf9R7lNE9tAg6Jcw27UrNArwmHA4OoEUKEV2dyCVO8b3MPjsUNtm4X4lhFeQQKA==,iv:GMQNtmbaZf3fr9Lz1lhOyx3WQpEGt7HY0NhFqogT2Ys=,tag:P7Cmgkz8Fp797vDIIz3oDw==,type:str] -STITCH_WEBHOOK_URL=ENC[AES256_GCM,data:0DTdF0yYSzaR7kjhJlKAUI69vlizcQvVNKutdsgkJXEM7j7r7+Aa//kxWzs2RC4iybgqh0cd9SYNyMGgvcP/5ilOoV01LGhMEV4BpEzbC/y71U4PVpzJCRPrUDNbnW6tHcqsMA5neXXZUK9a0iae5NHV4PST,iv:kDMMDSMSYs2f+vd+rzEhAUYWhtO0ihNIF7W7eJWh4Z4=,tag:r7B+miNzxPb2MrANLj9GSQ==,type:str] -ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:3qkmTVWiUpP1D7vYahpLdQh1sG4=,iv:trEME/o+ZXXizoRX3zgRm7d00GGDuhIhPXWO/Ck/5WQ=,tag:WsIG8PSLsA5LIOeOZkTCfA==,type:str] -ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:tVHxu0XEMCKOai0fZcCiddozBKw=,iv:WALv9GYJDI5eDo4YiOZhkhGt+I6RFN7V7GpF/P3MVFk=,tag:0XG3bv9CpS72tC07qDYeyg==,type:str] -sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA1dmQrZ1ZWZ1ozdU96RWs3\nRDU3MEhPbDNXLzRJZ1pKbUl2Y1E4U2Qyd1c4CkQ4TlBCNHM5L28yRDJIZ1N2ZE5D\ndWJQQ3RXQmJjcnZhc0hxYnFhYXpua00KLS0tIEZyeTJMT243cnliVW1TWEFPTVlh\nZS9wTjUybzJ2cFh5YVdsbE1TUmE2K0kK1k8ivaJttK0pzp5UijicG/NT9BaQ8Xog\n0TQpR54x4vtLBgfEMi8vy/V6jBYtCFHoefIUfKw9psNxrYx6NhJz8A==\n-----END AGE ENCRYPTED FILE-----\n +AES_ENCRYPTION_KEY=ENC[AES256_GCM,data:vygyIeOSU4KUHu+/fxzVNsdcHBO+MYuFxmOjoKUkjMnaJh1zUzlnNRhYV0kwKAOnMt9G9AHCHIJSQy2qFVGJ3A==,iv:PbvrO8d9PkDzc6JMbLyuggNt8KccogKgycU8q9yZacQ=,tag:OkxUrar5Cu0pelVnBuV0uQ==,type:str] +ALGOLIA_ID=ENC[AES256_GCM,data:bUHjSRm+AOHkNQ==,iv:qnT7OqcCpL2VYn3wCxwhH2fqyIUvs/jECHpb8zlH8vk=,tag:u2f/vk1G6UC8xEhZiq4K3g==,type:str] +ALGOLIA_KEY=ENC[AES256_GCM,data:tdjWtPWM7cX6hnvt1v4xoqVVifOxFkIZUTL1bAafKyE=,iv:Af3qGbXERA7PDPCqWq0M+BKgI+f0wJkAtXAVogJlCIE=,tag:adyhpIkcATzUuE1bjwlcuw==,type:str] +ALGOLIA_SEARCH_KEY=ENC[AES256_GCM,data:JW7IpGE8QX0wKNzsTxdSxPsED7PE8u6PcM+jWXLao7M=,iv:r0a8KJeMtoB0ZPy804e+59FlFNXSV1d1xNUKTw3mx+s=,tag:P6SHV0+LKpUA038deFLRKQ==,type:str] +ALTCHA_HMAC_KEY=ENC[AES256_GCM,data:zgjxjOtHld02AvBOXI4GCPNuJrPTLiLiSV+61H8Jrm6TZFO4clSqedYeCLLKsCx/z91bFgOpR/o14izffwCoh8CiggDVuMXCjShwQBbDKHRf3GNHoolSUUtDYMHSzsRGq8EBCntyRHLl9e/tXNAbmE2zkwSbNP/J8B0J64EhUkA=,iv:W/cWEFr7cvK/QAwps33eVuaZ4I2vw2W4ecUsfrBD+RQ=,tag:hu3vbVRMM6qunZI8iZm6EA==,type:str] +AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:WGHmOUbhcOZxUj5wB5sp73DuBdc=,iv:NpgY16GRgSfZjC5VQqOwSyOkkmIG7CSqSkRPnokP9DI=,tag:GqImV7HF/TZcbfao7LIEoA==,type:str] +AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:XCikIUyQKb4SMDx3w3tnpZ858fA=,iv:UFBsy/8UjGGsgU/QDzPOLqwfxDS5mEmQFuJNRwjDSkE=,tag:swUNf9SFVtKRin9rLRdoHQ==,type:str] +AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:wjtTJXAFZLwnbD8emLjKc3EEfjxVWA/JUZzduvCf+0DWLVn7IB9aAA==,iv:v/uLO7IeD6DDCBNQ4JJ3pgox5YwsVW9LZgnzN34mLQc=,tag:81R3/hFQqmYzQc2X8s2R8g==,type:str] +AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:ledp+eYDE3t06vVdXs/Eywk9LbJ0t6QITwoSQmSRIRk26g9edDP+kg==,iv:faeUjtqNvUxnYlkxTb6tmARPBYN5I1URFTS+1ENcr3U=,tag:pb/zOwdJ38TF8PG696Dj8Q==,type:str] +BACKUPS_SECRET=ENC[AES256_GCM,data:/RsR0gc+zR7el3g04PugFCKL6LSqxTMYJ9JlOjc0wajz4XxsgU1NUavy2UY=,iv:ykuE2Qwz/bhaJ1qM4ff75VVhDqpcxboPVjyvwWgvTTU=,tag:59y2q7RSLvvtPx9NFSN1JA==,type:str] +BLOCKLIST_IP_ADDRESSES=ENC[AES256_GCM,data:ZJkKI7w5FzILMMwE/EZzdSVMTuq9,iv:1ntwAH57FdntSNoj/U1juyjoSvm3B3yjZAXm3mzx63k=,tag:d3xAa5uDQWLgAQ28mclvLw==,type:str] +DATABASE_URL=ENC[AES256_GCM,data:HIK672oRWuDLX3e4pONz8OjEkb7h7cJipOD8EkESrnWNtocsKEgbmMtpusk=,iv:JVEKUyXVm3UMQfLaqPF/IXKNe6K/mL9n7DdjaFSY1eQ=,tag:LqtRLI5UrnWKPZOgZdGEkw==,type:str] +DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:i7k85Qs/IhjzoPJdGzE3YLUgM2rbeFO61pT87Ig=,iv:xBPkOss4OhO2m+6sURxPUXHzhA06pVFa0zmP7IusD0I=,tag:ZbHinmWqblmQRNUwdb+3ug==,type:str] +NEW_ACCOUNT_LINK_COMMENT_WINDOW_MINUTES=ENC[AES256_GCM,data:bpk=,iv:h0Jff3p54eZmm9akWjZFP/3zmKDVrtJrToZQJMGbkJM=,tag:Qq7w7ujIO+3ig3MFVbxizA==,type:str] +DOI_LOGIN_ID=ENC[AES256_GCM,data:V9egweGB,iv:gjziKGluU7wCwBDhX6Cx3W4MnidEKiru2d0jfy7a86s=,tag:cvIesO03cV/O8fW220ISTQ==,type:str] +DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:b7xkNnujost9rSnFFSQs6IYRGQg=,iv:XkYzQaVAKp3UqENNW7PMekr7Ulnb4kHp71i0nZB5484=,tag:l9qeuqjbpvJQ6Lf2spdXFQ==,type:str] +DOI_SUBMISSION_URL=ENC[AES256_GCM,data:SWISAP/WDBhdQlP10DElDohvyXOVyJdY4c3EZA+K7HlTLffajx50Pg==,iv:jSE3Ho4qrwdq6bV618y6EGaKFxoSFAAg1WedzC6T3wM=,tag:Y7OmgyvhXtzpmS9Rz0DnNw==,type:str] +FASTLY_PURGE_TOKEN=ENC[AES256_GCM,data:y50/aBt5bDpW6xHCmRMqFq2cwyDRYXP9tJ9loPyWP50=,iv:zzt0CuELNQNxjb5xfx7JNLxksgfIS2OdqGnBmpXk8kw=,tag:Ke0VzdElzlebab4c+7NGDg==,type:str] +FASTLY_SERVICE_ID=ENC[AES256_GCM,data:ntSxO41AbYBMzCOqT5wOXd2XRNT8Dw==,iv:b+5DzbKJnB9IeJ5vaREENj6gdwZ6l3pyeDxSCAgVhJY=,tag:VPyazK66wA5wTZNPVvBDZA==,type:str] +FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data: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,iv:X5jJT6sMqBzn4tCxdNLwlbpgnfPYR0lmUOKgN1HhcDE=,tag:YQrj+C24BBJkYp2+ZROSLw==,type:str] +JWT_SIGNING_SECRET=ENC[AES256_GCM,data:ltBeZ7bD3kNw10aMeagzk1sUaADl7DofbtR4cjU7JbjfTzMZAeqnODb/3N7yEyWxCFEenAGQoUyQCynE0FOMGiGvBW0c4OSvUp3seBeVaSVhxnX9EcFltPUPVU2I2xalcDyVvzX7OUnlhtjayYgezqL6ESxlF64JAuAaDYaGBov/QPTDiEXdSvS7S7SXQfKv8008VoiZOdJq6UVZl+tFW3SYFjn69oizYKMcHFKnY1WlLpr/PBqtAs94mYYhdro2O4I6NFSqwvOZiyIne4kUvfr4sKP/xtYnlQnNX/PZn09F8LoNc97wcihM+KO45oWjzKyLJQ5UnrTg+ehfJeyEa+tcCoK9V7B2dcONLIk1B2LY6D4t6KtGT0xEWa+sEQ+OgqVKTwvoQj6vSLKt/SOr54UcTUH8I1GHORhdNhUZrTCruhpheYFz8+fJNJttpaYWq5LHo85/+wzd0IA+j8RG/dfHwl4e5CLHug8CU2SxjtnO8OJ8W0Ba/Z+IdYZFMvkj8KW7vBIuUGtz6R2qzBPUHknZIBIgKC5litYmxqJY9bV6j0CLORoJlLPAR4txLjUqt/Um+CX13Bm8N56YYBKbDiT+2Q6dDiS2C60EZ14GywU2PZn7P13Y2uQUqSyJEy0jnSHaS+M3T8GyPjgaDn81H8smr+p7Rywp5Tz/WAvu+Ks=,iv:HhZBFTWqwgrD63anv57CWZWpURtSeryK7yBOb3XSC3g=,tag:9w6PKSgkQY1o+6Q75ICbaQ==,type:str] +MAILCHIMP_API_KEY=ENC[AES256_GCM,data:eL8cFJAcsegT7jcV7CWYho8xiLD2THC9TsbyEz1O22S40VUX,iv:yl4HlB1gX0roFtjfe18zmM4b0/WH71zf/n1Vvju2G6I=,tag:rR5ZFrMwnD34Yb/km7zjmg==,type:str] +MAILGUN_API_KEY=ENC[AES256_GCM,data:MojQDcCzBxDQCmCcJX3jzQ0rVHZ7Ooc/9AbR9gEq8gFgt85j,iv:WCNzExQsc8obUv8/hP2cbeVGYgxWMPGWX0vzkO16fP0=,tag:Bbucf5m/uJbk9xLJhU0n0Q==,type:str] +METABASE_SECRET_KEY=ENC[AES256_GCM,data:bhiOd5tbfGH7XuMGlNOos0yd7peshSE/eUdN6KPrOSdS5egjs0YKvUiUGpag2lbsTb62rWaEYsoFzK0teFl3/w==,iv:ZyyLhVYMJrHlyLfNHHckhXU+/AcrHcL82OdGXqh319Y=,tag:Ewq8BbqssDlV0vVjZkemWQ==,type:str] +NODE_ENV=ENC[AES256_GCM,data:RAV/1IftnkAcAg==,iv:8TqiRhtJBhucOVfY7eTYPI+hKnjHiiIUGKUAS2Qd8wQ=,tag:fSWBfDrEoOB9Ti0i14cCGQ==,type:str] +PUBPUB_PRODUCTION=ENC[AES256_GCM,data:OGPgLQ==,iv:gNA78W1+EjFvQLbyKrP3Be17uz7bUG1hHZ5oEnPe/BY=,tag:a0W5QGnAmu4rsEMBn1HNnQ==,type:str] +#ENC[AES256_GCM,data:uSLpX7XUPIeSorbyHblVSB0p7kWLTHJwnQ==,iv:2TRabaOiGKuEiYRlJTRv7ERnCokJI/FfsHxtKIYZQw8=,tag:WQA4VDXpg8JFPHt+rh+iUw==,type:comment] +#ENC[AES256_GCM,data:nwOy+yz0Eo9WJ2UkTLmzKAgSoWv6liE6z9lPqWhUAZ/VnlX6kg==,iv:2iD1nHSJjYKANZ1B3SwBgEcsIrZnk52WAFAovJaUBck=,tag:hoM8AHRLEE0LBx43oOWfDw==,type:comment] +S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:BJD0csMZBtBO9eP630N0D3ISZt7AneuV8acF4ZBDy112Jmg=,iv:lep1yhJMng5+Ki7IvJru09tA3bDNtKDBfBQ/SpHD1Y0=,tag:LZpCApTY19yjWEZfPNNkpw==,type:str] +S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:tJ4WFOb9WtAd7QCo/ndSSHGTyko=,iv:dcTibepCqaZfecHUmZVt1GK38pRokUixUM4MLaiTigA=,tag:Y/+7XKS84fShnzNTaYp8RA==,type:str] +S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:Zow8u01eZtBL3zllK23zWDBbdbe77eCxwqDGAZYE6YTrx+ISqF0BsA==,iv:A0Q8UC89I9zJ4C3Ekcz3XwHEH/7XX3DYXyBTafVG0RA=,tag:kQwLNpoN37huRrryUFxywg==,type:str] +S3_BACKUP_BUCKET=ENC[AES256_GCM,data:W9NTPx/byuB/79s=,iv:DfjDrfzj4CtbdOjA2qIEs8n6vbz4zeT8ZP/a6PQKtEY=,tag:DfIR7qHF9fMYvbEB/wd0tQ==,type:str] +SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:motVr7lGtqerwVm1FNkLApk8fkD5oZQO7DBb3JHiGgWYD9HQ0/K4ApH4ops3mKgvWMN4lR0GN05YdZOuEofCY/2pbi3XL/nX3NfwDHZsADtU934GCK8N648+2ay7hlLU4Kgu7H9Km+kcld9DGdUkhze4Q74qnKvfTMkPy8FpF2dyQCK2q9j3whymGbmFHSEvnvla266MLNkG0jBWF0GD4bdMEf2PgklJB9WqRym3ql//Euq2VZr/aqdVpA==,iv:Tt4VnnlfnruoiDFr9zpWhMn6tSH5N9cxE7AcivMWsnc=,tag:XJrYO3KEfFOtmdmsb0ZY3w==,type:str] +SENTRY_ORG=ENC[AES256_GCM,data:5Ksg,iv:9XYHOiq52rAufw7A6N4/zRCkKuvEvE7r8X+FaOL7mwM=,tag:4/i2tvQQxd5OdSEo3PDArQ==,type:str] +SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:NNA=,iv:BxefB2zJtOMeWFhz5UTU8N5DKSJjki+AXbrB3rDDheM=,tag:c1i310cLxylp+U57jI9xLA==,type:str] +SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:DXyAO5GfrTiUEg977N7uBMvhVgkmxIFqEPGrja6rdR+ZpM6T68nDuI/MDoNyOJ/+M3tbIKkJKbBSu2Oqr4k+KoTgAXt2GgUisL1OMEZM4g==,iv:Os4EwwXM98Ij+Xw7oCNqEYWLXykQ5/+eUb5f7kfSFnk=,tag:dxPthqTllQT8t9H82Z2LhA==,type:str] +STITCH_WEBHOOK_URL=ENC[AES256_GCM,data:dTFTHyDN5W98nhBtz8/x/t2vdp70Z4sSd5KC8Bw+67YNJt+AiksNXTGe2cC44rPweF1lClcaXkJY5qDVMhYMdrVdNpdpVZQOKHb/TPWCBtdbImSi9k3O6nz0WxeqKvr/qp2GYcMal/dlzDnGjoolSAGwxK3o,iv:zFZDBO+1VaAEslICj4gfYAiMn+7Evp1UG/y5uainBIk=,tag:LuvLe9acXVlpiswlc6cQyQ==,type:str] +ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:OEA8tPW6hZHSX6yFhCbMsLZ4vW8=,iv:tQIjmn3Av6EwfWMf2f+MRwZTb2d2lSSYJUUUbE3i3Q0=,tag:mKwKmoA1/7/AzBW05fmCgA==,type:str] +ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:RthkhjEo//QeqE9g4VJShfjSZV8=,iv:r5OEVwL924+YD39OSt73XE4Xapy/eR0lxzsdy/5iKvM=,tag:TYkgzO6A5LeR8BwEOdwcUA==,type:str] +#ENC[AES256_GCM,data:9Hm14d2sntOEpU0/mNR6qjDyVhLlPuryhhB0z8/1fG1ziAJ3mu8ICeiP+9nXjyMdLhKFbc8xDFkGnpq4mJ4e42qR,iv:UdaHaDkF3cSVughBKIrpg9xIL8coKvA0/PI+qzsQLfU=,tag:BHSyncX9nq98aZiAQpsDXw==,type:comment] +AM_REDSHIFT_PATH=ENC[AES256_GCM,data:wwGSNkmT+R6kK3Zbjh9fxI/TIjvTO0JKamnlsmG/WfZ9B57ouO+4u9Rk7U0hmo9LI506gJOzBHLj823Kj7k=,iv:T5N2tX5ys1uM/Ii7H807Hzs4UbwCA/fga0k7a2TWyEw=,tag:THBi+OHYFJx89vLVRqmpUg==,type:str] +AM_REDSHIFT_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:ecEUwFwgJSh741C/1xfmMR/syXM=,iv:bEEdtDhXfKBM8c/P4inD9njPGjEe4V6ot2ZJXCPP6ZE=,tag:YEWSQtRGxOGyDLbYlqmlxg==,type:str] +AM_REDSHIFT_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:7M8xTIbH+Cp+PRLzpVecKK7aMKG3p5Z/m1DRVQr0/dFuUAKlwPD4LA==,iv:iftRwF7RrflCnMJmFxHNTk92rEWPcVMNtVkpAxQnkgk=,tag:1+Ia9Iw02H7PlgVNBIVj1Q==,type:str] +AM_REDSHIFT_BACKUP_ROLE_ARN=ENC[AES256_GCM,data:fi3PkT4UR+0t/9JNfbwalkfPEGC2zjnjWGD8inevJ+zzRkiQfEBNzzxEHIMBc2/8WgDl,iv:1GPs28DJCn0gTsSGWJ2U2SFnoXh4SvRzs2KAIPTxA6o=,tag:kfmH7hmfK0kFUy7ydX7w1w==,type:str] +AM_MB_RDS_URL=ENC[AES256_GCM,data:opclwIxyRVRfkjMQ7zjeGAzGkOoRTKVpK8bAVrZVmKghGiLsPysejw35Ad7dhVwm3cc6DOcEkdQxaUCPZNSXx8QisP9Uw4KQ+sJyxipomD43JcwHyM6khHgo8QgvY03uQrBu00oVZerU,iv:qraNn0LtXtCGcdkfExJthEJPCe47KiZWBY/EKhiLYDM=,tag:KtKivtwjjYWF1gu7FV8bBg==,type:str] +AM_MB_RDS_ADMIN_EMAIL=ENC[AES256_GCM,data:zY9ZxUv6AsOnAHl9XA3xmN/Iy9Q+AqVDAuKp,iv:/5+JAw/0RnM7NnAOxpiAGmvJy9k84CAQR6zFzshRZ2c=,tag:mTJ0AJXTa3nZ0nfmcPQU8Q==,type:str] +AM_MB_RDS_ADMIN_PASSWORD=ENC[AES256_GCM,data:VPdTqdzEJBdBsh3JLIb2fXl+CA==,iv:TiiSk0U+5UxewH202IrjTkEDrhyanDaudfMt12LMH7Q=,tag:co5tmib1EzodgRjnEthzog==,type:str] +MB_DB_HOST=ENC[AES256_GCM,data:5F+Js06iU06J1B4=,iv:DWx7Xq3/TkiTh0osC8N0OsT++fq1OjXYy7XZGICFKPM=,tag:LpnbrhItRpgoFaSao8zGew==,type:str] +MB_DB_PASS=ENC[AES256_GCM,data:DppjdGWSmbrsEo14ninXYEPJOWN0N1IJumJAOIRL0C/Q+L0Rv1sd2UppKvBY1/zamfBYPjksucXRbL34tMWQ6Q==,iv:5L87zTW0GHG8DbelyRRidQjyys96SrjGv/nZg7gTCd8=,tag:qQmbzaZqdV/vwHwVzEX98A==,type:str] +MB_DB_USER=ENC[AES256_GCM,data:MKD0jM5ue7Q=,iv:qI0YvXzDUnUx+WfQ3NpuMtawdO82MTQw8QUitip0Xv4=,tag:BliQbhzmRh4z6HiUSzsvpw==,type:str] +MB_DB_NAME=ENC[AES256_GCM,data:Qf044hAeeB8WUSs=,iv:+PacaEGtnvzUDJ6zRokg2FbwXuCU0KrNsSCQ7UbsJ0Y=,tag:zBKwpakiLJ7zBwYZ6E3wIQ==,type:str] +MB_DB_PORT=ENC[AES256_GCM,data:13JAOA==,iv:y8UZ5/GRg4i36RnnzzQvCG0coJdIf4ZE/MCfRfvtUzQ=,tag:Jf3AUlMOd5DkXhQUpaHZFQ==,type:str] +AM_MB_COLLECTION_ID=ENC[AES256_GCM,data:99o=,iv:3aPCWBDOOKy5XHMR6xDUmD6XrnpFMSXac2OmaiYCFAo=,tag:LaNVDdspBzFKZObHQNMY7g==,type:str] +sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB5a0prL1JjWTdVZVFLcnBF\naGdyblZjak0vQU0rby9qclF4cE8zcFNZM1I0CmxnRUEwV2xyTVl4TFVPU2NSZFl0\nNUVEOENzQkJ3bjJhVUdSTkFtMjBOOFEKLS0tIHZNbmNZRG5pdWk0V1pOS1lFY0R3\nK3JGbzFDRzNUcXVkSm9nWW82d2J5dEUK/Y2rtcyxkVTKyOu59RT+FGOSRn9W9JzZ\nhbd1AA/LStk5513h/VBWbayc8GpeOKcQwUXJq8q0XIguOYGjK6uAYA==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_0__map_recipient=age1wravpjmed26772xfjhawmnsnc4933htapg6y5xseqml0jdv8z9hqemzhcr -sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqMGFITjNJazkzNGtwQUF2\ndURyRktTTE9VY3l4TWVlam9zc2psQzBSMUNBClpIcU81VitObWtnM2U2TVhGaHo5\nK25Cb01CQjdwSlRjVGNmdGlUY2xHa1kKLS0tIFMwOUtuVTB6WmNrTDhXd09tRGU0\nLzM1bi8wVmpnazMyLzhvOFYxVmNtUmsKSjl6gGc/oBnkd7rGz5HsXVlRtSY6PopE\nOoGXRVElkLdhBzC9LY6HbgkWSJyu+v56mIbYro7euczYX+6dzrrerw==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBTazlkck1KY1pteS9jV2dv\nalJKR1JNS3dCZFltbnNtYlN1THRHTkhnL0hvCnRQMXJDbFhVSGxHaW10UmJPK05Z\nUGpjTXRRbTRJQjZFVVY0ZXBPajE0RVEKLS0tIEpacHh4UXF0cHQydTczeEtUWUpJ\nSVV5OHVtNFRXcWFISjZQMzluN1BPNlUKb8SmOO/egTtdDtZzEq0GWICT44ffNYmf\nNzwGInRrRMgWtiJg6DBArwJ7l4Ej9yRPkPqE7GAw8ZkoPL64cygBoA==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_1__map_recipient=age1vhftscteyrwphx0jpp0yl60xxrjs77jq05mkzv88js7ckc926vcqepp2cj -sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB2V2c3dkxMQnZwTDRrZWZI\nL3A0ck9CaEo1WGx3UE96bEZnWmVBUXJaazJRCkRLYVN4aFMwN3gvaThkUUE2dGpn\nS3ZGMjlTUW1NL1Radk4xOXIvYm42dHcKLS0tIEJxbHRIQUo3ZlU4QUR5K1RCL2RK\nNmc0bllPUmNDUkVuRFdUaGJYZU5yVmcKZKktxJzujZ/C8VogEoRE4l6RrYEUf41q\nObBXtOgawAax/R0gdCfoTkoC5yfNT27yu5ngIz0wlDpusNF4L+fMpQ==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBwL0lVVWEyMkRDMWZwd0kw\nVTJkd0szNXg0dlhUZkVlYWhHYndZVENDbWlzCms4eHF4OUJ2N2dsQ1ViMHYvUHhU\ndWNjQmxlMHZVVFdPWDRTM3JaNmNjclUKLS0tIC8wWHRCajBIT01BZjRyaDdPZkZx\nbDBVNWZ0MTRqYnNTejBiVjZRdHV5OGMKjNKm9X/GVaJPzO/03k0gqVVZi31XxyZC\nfy3LhFeB+lNLzHJcxpBd7Rm2asQPrHhEtDXd6vanrJCOZEwg7TUtpQ==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_2__map_recipient=age1slx6e48k7fre0ddyu7dtm2wwcqaywn5ke2mkngym3rpazxwvvuyq9qjknk -sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBHc2RSV0trMW1qZ2dRaFlS\ndjZ6ZHliM3hVS2FKa2R2R3FiYklwQllrQTFFCjJwNk1ZZEo1UkNDMTBPQy9vcVM2\nbE1MSHg0WXZ3NGd0MmUzY0pmTWRkSlEKLS0tIGxKTThVR016enMvZi8rSjBHNC9V\nL2MwNWhBdlplYnJ2Q3lLMzNDZGJQZVEKkNanfMXN+vxtDXaUSK/w4q4/bc0NwVQw\nVyKvEmwT2IagUVqD7ey+4upLgiGdRuhkooAGfiBtJHyPVsKvCPeMgg==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB3MDlnV0lBNWJXVFEyaThT\nUWY0VzhFYzEyVWpIZUc0alF3VjFWWEJWbGdjCklPdWZwVXJjQlpGait1cllQaHlJ\nY2lTeWx2djF6THJBWFhGY0RaVXNGRmMKLS0tIEZQMEdmSFVMQ2pabnorM01lUWFI\neFdJR3ErNWp0Rnpjd0psdEl2aUhkVG8KowGgog0VUQAJv6S06RNDc8+awgBhNYzx\nyWWKQ2P/leX0jwKigYGsuYaB1mK9rgZ1rEvW5rpg6jv1Nqqmzh7OaA==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_3__map_recipient=age1vfhyk6wmt993dezz5wjf6n3ynkd6xptv2dr0qdl6kmttev9lh5dsfjfs3h -sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA1N0JjWkt1cGxwbzhPVUVk\nVE9MS1R3MHRIL1VLRHlGNGxpSUdGZWNVc21NCnhMamUyd0RwYWw1SHhQQXBzbzh1\nRVlvMXpaUTNId1BwYU1LZFVONExhNzQKLS0tIEZpZ3dhVHJtRHBidU1OZ1NyZFUw\nT04reTVzR0tuK3QxZ1ZzdTJqUThCTzQK3vTB9CQLDcOJShwvYOkmOcLQfJ9QCkZF\n9SNn1wPd1MNGSfxdSOYWl9t1o0z3gz65YSL71KJC8xZ90TaV3Esthw==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBsV1dZbkd2STc1aGtURk1D\nM0ljblVHU0E2SjlqQzNHTDZlWVJIbDhBUEFRCkFFR21mU2tDZmpMQkpwR1NTbHA2\nbjkxbU1lN3Vlbm5wWjhZV3dubEZ3UEUKLS0tIEVKdStFbzBlYkorWTJ5VERyMmFJ\nQ3NoSWtYVHZUY2VaT0pCRWVZOXNmb3MKfs92pq4pzi0gNoAdhwSxg3ocxF0t+ZJx\nNtfMZQLqo1DXAq1HVsTpH6iE5lPhC/inzGsQCzzn8RRUe25o36biPQ==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_4__map_recipient=age1jwuvzyghfer7rx3qtqa4vs0gxyff0sg6pqgmqvp5zlhnpmvrkdlsdha4kx -sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAyeXlpa0xmWkMzSGVNQ2V1\nOXMwWi9ibitBeXk1b0lsdUVYVlZieEVZS25nCkVUcHdkTlpuUjdkTTlwV3BmOW5i\nbEQ0cEY4TEJubGV1YjlnQjdjckRKbjgKLS0tIHRHa252aXVxT3Bwd1JaWW5Ld0xr\nTW84ZHlWNXF0K2dDMEJ2NmxWWndYckUKEsCbco1+C6mhFUxFj9zGQuo1Xs5U2HMb\nFq/OLyclKqJryJbkNRPQTfD0J/vzLKk1TLjiyn6fE74vvHVp0qUOCQ==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBuVHZkR1FORlFla2huVW1X\nN3pOT1dZYk4wbGVQZ3NGSG5XMVArc3ZjRTJJCkRrdWg3b3B1cUtlYXBnUFhGaDdt\nMUZ2U2QxbXk3bnppcXRhbXB6azhncXMKLS0tIFN3UU40T3YyZVlRc09zY081Vkox\nbG1ZaWcrSVFnM2YrNnVzbUFEYU5MR2MKqW3OObpmnCU9fAp89tpba+ygxYYfxM93\nfl9p3HKUJ5UoZPH9mH0dA2PVEkBguaZ5uwTXHKPo/gl86ooOzo0G/Q==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_5__map_recipient=age1pgxk292zq30wafwg03gge7hu5dlu3h7yfldp2y8kqekfaljjky7s752uwy -sops_lastmodified=2026-04-01T03:03:08Z -sops_mac=ENC[AES256_GCM,data:hQlfCmb8jk2U5+U1cRI0W662mzTMEfaHXhmX2nHLnTSn/n3I3rVnl/tl16XikJjrO6LQpoELhs0Espu+1PYF7VRknDI0vmoP4XYciV/W2LiXZ8Q9au7NSfNrOmkvNEGdXgOedzxO5cCl96ARmmXsz1Yi5SzWWaxDW3mFb6RPSm4=,iv:TLr9e4RhdNm6Yc/YKPXBxQRzb9Q1RqYeW+0dQodcKDo=,tag:Z2TIN79C44dWJWmgzfdd8g==,type:str] +sops_lastmodified=2026-04-07T16:05:01Z +sops_mac=ENC[AES256_GCM,data:82f0QgWIxB3dBcPThB+fq/4s3iwKDpB2QTF2sio+4wzuG11/2MWuZhryz8mF5QNzU8YANu6NZk+IptNd2ZtEQGiiVmfTIMJQvVwlAeFBIw0QqWoh/i3DzMf9zXDdHZiHmVP+7w7vUg1LXVtWUkv/VoRZ5Mu3fVl1UawovvYUTw4=,iv:7hK17kJe/AcSiWMsnNO2CXS+h6CTK5C4XMaIY0jIjrE=,tag:Zht/fZKhKxU+Kb2iX3LvYA==,type:str] sops_unencrypted_suffix=_unencrypted -sops_version=3.11.0 +sops_version=3.12.1 diff --git a/infra/docker-compose.dev.yml b/infra/docker-compose.dev.yml index 5b84969836..bb7fec834f 100644 --- a/infra/docker-compose.dev.yml +++ b/infra/docker-compose.dev.yml @@ -86,16 +86,13 @@ services: - "${DB_PORT:-5439}:5432" networks: [appnet] + metabase: build: context: ./metabase + env_file: + - .env.dev environment: - MB_DB_TYPE: postgres - MB_DB_DBNAME: metabasedb - MB_DB_PORT: "5432" - MB_DB_HOST: metabase_db - MB_DB_USER: appuser - MB_DB_PASS: apppassword MB_JETTY_PORT: "3001" MB_ENABLE_QUERY_CACHING: "true" MB_QUERY_CACHING_TTL_RATIO: "10" @@ -112,13 +109,8 @@ services: analytics_sync: build: context: ./duckdb-sync - environment: - PG_HOST: db - PG_PORT: "5432" - PG_DB: appdb - PG_USER: appuser - PG_PASS: apppassword - SYNC_INTERVAL: "${ANALYTICS_SYNC_INTERVAL:-43200}" + env_file: + - .env.dev volumes: - analytics_duckdb:/data depends_on: @@ -126,6 +118,8 @@ services: networks: [appnet] metabase_db: + env_file: + - .env.dev image: postgres:18 environment: - POSTGRES_USER=appuser @@ -137,6 +131,24 @@ services: - "${METABASE_DB_PORT:-5440}:5432" networks: [appnet] + analytics_migration: + build: + context: ../tools/analytics-migration + env_file: + - .env.dev + environment: + DATABASE_URL: postgres://appuser:apppassword@db:5432/appdb + volumes: + - ../tools/analytics-migration:/migration + - /var/run/docker.sock:/var/run/docker.sock + - analytics_migration_data:/data + - analytics_duckdb:/duckdb + depends_on: + - db + - metabase_db + networks: [appnet] + profiles: [migration] + # cron: # build: # context: .. @@ -164,3 +176,4 @@ volumes: rabbitmqdata: metabase_pgdata: analytics_duckdb: + analytics_migration_data: diff --git a/infra/duckdb-sync/sync.sh b/infra/duckdb-sync/sync.sh index 736bd9ea19..536e24c01f 100644 --- a/infra/duckdb-sync/sync.sh +++ b/infra/duckdb-sync/sync.sh @@ -2,11 +2,12 @@ set -euo pipefail DUCKDB_FILE="${DUCKDB_FILE:-/data/analytics.duckdb}" -PG_HOST="${PG_HOST:-db}" -PG_PORT="${PG_PORT:-5432}" -PG_DB="${PG_DB:-appdb}" -PG_USER="${PG_USER:-appuser}" -PG_PASS="${PG_PASS:-apppassword}" +PG_HOST="${PG_HOST}" +PG_PORT="${PG_PORT}" +PG_DB="${PG_DB}" +PG_USER="${PG_USER}" +PG_PASS="${PG_PASS}" + SYNC_INTERVAL="${SYNC_INTERVAL:-43200}" MODE="${1:-loop}" diff --git a/infra/env.example b/infra/env.example new file mode 100644 index 0000000000..8794bba335 --- /dev/null +++ b/infra/env.example @@ -0,0 +1,13 @@ +AM_REDSHIFT_PATH= +AM_REDSHIFT_BACKUP_ACCESS_KEY= +AM_REDSHIFT_BACKUP_SECRET_KEY= +AM_MB_RDS_URL= +AM_MB_RDS_ADMIN_EMAIL= +AM_MB_RDS_ADMIN_PASSWORD= +AM_MB_COLLECTION_ID=14 + +MB_DB_HOST= +MB_DB_PASS= +MB_DB_USER= +MB_DB_NAME= +MB_DB_PORT= diff --git a/tools/analytics-migration/Dockerfile b/tools/analytics-migration/Dockerfile new file mode 100644 index 0000000000..60b0e8cefa --- /dev/null +++ b/tools/analytics-migration/Dockerfile @@ -0,0 +1,37 @@ +FROM node:22-bookworm-slim + +ARG DUCKDB_VERSION=v1.4.4 + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + curl unzip ca-certificates postgresql-client awscli jq && \ + rm -rf /var/lib/apt/lists/* + +RUN install -m 0755 -d /etc/apt/keyrings && \ + curl -fsSL https://download.docker.com/linux/debian/gpg \ + -o /etc/apt/keyrings/docker.asc && \ + chmod a+r /etc/apt/keyrings/docker.asc && \ + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \ + https://download.docker.com/linux/debian bookworm stable" \ + > /etc/apt/sources.list.d/docker.list && \ + apt-get update && \ + apt-get install -y --no-install-recommends docker-ce-cli && \ + rm -rf /var/lib/apt/lists/* + +RUN ARCH=$(dpkg --print-architecture) && \ + DUCKDB_ARCH=$ARCH && \ + curl -fsSL \ + "https://github.com/duckdb/duckdb/releases/download/${DUCKDB_VERSION}/duckdb_cli-linux-${DUCKDB_ARCH}.zip" \ + -o /tmp/duckdb.zip && \ + unzip /tmp/duckdb.zip -d /usr/local/bin/ && \ + rm /tmp/duckdb.zip && \ + chmod +x /usr/local/bin/duckdb && \ + duckdb -c "INSTALL postgres" + +RUN npm install -g tsx + +COPY migrate-redshift.sh migrate-metabase.sh migrate-metabase-queries.ts /migration/ +RUN chmod +x /migration/*.sh + +WORKDIR /migration +CMD ["bash"] diff --git a/tools/analytics-migration/README.md b/tools/analytics-migration/README.md new file mode 100644 index 0000000000..c3e12a9c8a --- /dev/null +++ b/tools/analytics-migration/README.md @@ -0,0 +1,34 @@ +# analytics migration + +Migrates analytics data from AWS Redshift and the old Metabase RDS instance to the local stack (Postgres, DuckDB, Metabase). Run manually, not automated. + +## prerequisites + +1. Whitelist your IP in the AWS RDS security group for the old Metabase database, otherwise `pg_dump` will hang. +3. The dev stack should be running (`db` and `metabase_db` at minimum). + +## running + +Start the migration container: + + docker compose -f infra/docker-compose.dev.yml --profile migration run --rm analytics_migration + +Inside the container, run the two scripts in order: + + ./migrate-redshift.sh + ./migrate-metabase.sh + +The first script imports Redshift CSV data into the Postgres `AnalyticsEvents` table. The second sets up Metabase with the old dump, syncs DuckDB, configures database connections, and migrates saved questions. + +Both scripts cache downloaded data on a persistent volume. To force re-downloading: + + FORCE_DOWNLOAD=1 ./migrate-redshift.sh + FORCE_DOWNLOAD=1 ./migrate-metabase.sh + +## cleanup + +Once the migration is verified and no longer needs to be re-run: + + docker volume rm pubpub_v6_dev_analytics_migration_data + +Remove the `AM_*` variables from `infra/.env.dev`. diff --git a/tools/analytics-migration/migrate-metabase.sh b/tools/analytics-migration/migrate-metabase.sh new file mode 100755 index 0000000000..832ca37fc8 --- /dev/null +++ b/tools/analytics-migration/migrate-metabase.sh @@ -0,0 +1,316 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +DATA_DIR="${DATA_DIR:-/data}" +DUMP_FILE="$DATA_DIR/metabase_dump.sql" +FORCE="${FORCE_DOWNLOAD:-0}" +COLLECTION_ID="${AM_MB_COLLECTION_ID:-14}" + +DATABASE_URL="${DATABASE_URL:-postgres://appuser:apppassword@db:5432/appdb}" + +MB_HOST="${MB_DB_HOST:-metabase_db}" +MB_PORT="${MB_DB_PORT:-5432}" +MB_USER="${MB_DB_USER:-appuser}" +MB_PASS="${MB_DB_PASS:-apppassword}" +MB_NAME="${MB_DB_NAME:-metabasedb}" + +ADMIN_EMAIL="${AM_MB_RDS_ADMIN_EMAIL:?set AM_MB_RDS_ADMIN_EMAIL}" +ADMIN_PASSWORD="${AM_MB_RDS_ADMIN_PASSWORD:?set AM_MB_RDS_ADMIN_PASSWORD}" + +MB_DB_URL="postgres://${MB_USER}:${MB_PASS}@${MB_HOST}:${MB_PORT}/${MB_NAME}" +MB_ADMIN_URL="postgres://${MB_USER}:${MB_PASS}@${MB_HOST}:${MB_PORT}/postgres" +MB_URL="${MB_URL:-http://metabase:3001}" + +DUCKDB_FILE="${DUCKDB_FILE:-/duckdb/analytics.duckdb}" + +parse_database_url() { + local url="${1#postgres://}" + url="${url#postgresql://}" + + PG_USER_MAIN="${url%%:*}"; url="${url#*:}" + PG_PASS_MAIN="${url%%@*}"; url="${url#*@}" + PG_HOST_MAIN="${url%%:*}"; url="${url#*:}" + PG_PORT_MAIN="${url%%/*}" + PG_DB_MAIN="${url#*/}" +} + +parse_database_url "$DATABASE_URL" + +METABASE_STOPPED=0 +cleanup() { + if [ "$METABASE_STOPPED" = "1" ] && command -v docker &>/dev/null; then + echo "restarting metabase..." + local cid + cid=$(docker ps -aq --filter "label=com.docker.compose.service=metabase" 2>/dev/null | head -1 || true) + + if [ -n "$cid" ]; then + docker start "$cid" 2>/dev/null || true + fi + fi +} +trap cleanup EXIT + +echo "metabase migration" +echo " metabase: $MB_URL" +echo " metabase db: ${MB_HOST}:${MB_PORT}/${MB_NAME}" +echo " main db: ${PG_HOST_MAIN}:${PG_PORT_MAIN}/${PG_DB_MAIN}" +echo " collection: $COLLECTION_ID" +echo "" + +# ── 1. get the dump ── + +NEEDS_DOWNLOAD=0 +if [ ! -f "$DUMP_FILE" ]; then + NEEDS_DOWNLOAD=1 +elif [ "$FORCE" = "1" ]; then + NEEDS_DOWNLOAD=1 +fi + +if [ "$NEEDS_DOWNLOAD" = "1" ]; then + : "${AM_MB_RDS_URL:?no dump at $DUMP_FILE and AM_MB_RDS_URL is not set}" + echo "[1/10] downloading metabase dump from RDS..." + mkdir -p "$DATA_DIR" + pg_dump "$AM_MB_RDS_URL" --no-owner --no-acl > "$DUMP_FILE" + echo " saved to $DUMP_FILE" +else + echo "[1/10] dump already present at $DUMP_FILE (set FORCE_DOWNLOAD=1 to re-download)" +fi + +# ── 2. stop metabase ── + +echo "[2/10] stopping metabase..." +if command -v docker &>/dev/null; then + METABASE_CID=$(docker ps -q --filter "label=com.docker.compose.service=metabase" 2>/dev/null || true) + + if [ -n "$METABASE_CID" ]; then + docker stop "$METABASE_CID" >/dev/null + METABASE_STOPPED=1 + echo " stopped" + else + echo " not running" + fi +else + echo " docker not available, make sure metabase is stopped" +fi + +# ── 3. wait for metabase_db ── + +echo "[3/10] waiting for metabase_db..." +until PGPASSWORD="$MB_PASS" pg_isready -h "$MB_HOST" -p "$MB_PORT" -U "$MB_USER" -q 2>/dev/null; do + sleep 1 +done +echo " ready" + +# ── 4. reset the database ── + +echo "[4/10] resetting metabase database..." +psql "$MB_ADMIN_URL" -q -c \ + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${MB_NAME}' AND pid != pg_backend_pid();" \ + 2>/dev/null || true + +psql "$MB_ADMIN_URL" -q -c "DROP DATABASE IF EXISTS \"${MB_NAME}\";" +psql "$MB_ADMIN_URL" -q -c "CREATE DATABASE \"${MB_NAME}\" OWNER \"${MB_USER}\";" + +# ── 5. import dump ── + +echo "[5/10] importing dump..." +psql "$MB_DB_URL" -q < "$DUMP_FILE" + +CARD_COUNT=$(psql "$MB_DB_URL" -tA -c "SELECT count(*) FROM report_card") +echo " $CARD_COUNT saved questions imported" + +# ── 6. patch settings ── + +echo "[6/10] patching settings..." +psql "$MB_DB_URL" -q <<'SQL' +UPDATE setting +SET value = REPLACE(value, 'US/Eastern', 'America/New_York') +WHERE value LIKE '%US/Eastern%'; + +UPDATE metabase_database +SET details = REPLACE(details, 'US/Eastern', 'America/New_York') +WHERE details LIKE '%US/Eastern%'; + +UPDATE core_user +SET settings = REPLACE(settings, 'US/Eastern', 'America/New_York') +WHERE settings LIKE '%US/Eastern%'; +SQL +echo " timezone references fixed" + +# ── 7. duckdb sync ── + +echo "[7/10] running full DuckDB sync..." +rm -f "$DUCKDB_FILE" + +duckdb "$DUCKDB_FILE" </dev/null; then + METABASE_CID=$(docker ps -aq --filter "label=com.docker.compose.service=metabase" 2>/dev/null | head -1 || true) + + if [ -n "$METABASE_CID" ]; then + docker start "$METABASE_CID" >/dev/null + METABASE_STOPPED=0 + else + echo " WARNING: could not find metabase container, start it manually" + fi +else + echo " docker not available, start metabase manually" +fi + +echo -n " waiting for ready" +for i in $(seq 1 180); do + if curl -sf "${MB_URL}/api/health" >/dev/null 2>&1; then + echo " (${i}s)" + break + fi + + if [ "$i" -eq 180 ]; then + echo "" + echo " ERROR: metabase not ready after 180s" + exit 1 + fi + + echo -n "." + sleep 1 +done + +# ── 9. configure database connections ── + +echo "[9/10] configuring database connections..." + +SESSION=$(curl -sf "${MB_URL}/api/session" \ + -H 'Content-Type: application/json' \ + -d "{\"username\":\"${ADMIN_EMAIL}\",\"password\":\"${ADMIN_PASSWORD}\"}" \ + | jq -r '.id') + +# postgres connection + +EXISTING_PG=$(curl -sf "${MB_URL}/api/database" \ + -H "X-Metabase-Session: ${SESSION}" \ + | jq -r '[(.data // .)[] | select(.name == "PubPub")] | first | .id // empty') + +if [ -n "$EXISTING_PG" ]; then + echo " removing existing 'PubPub' database (id=$EXISTING_PG)..." + curl -sf "${MB_URL}/api/database/${EXISTING_PG}" \ + -H "X-Metabase-Session: ${SESSION}" \ + -X DELETE >/dev/null +fi + +NEW_DB_ID=$(curl -sf "${MB_URL}/api/database" \ + -H "X-Metabase-Session: ${SESSION}" \ + -H 'Content-Type: application/json' \ + -X POST \ + -d "{ + \"name\": \"PubPub\", + \"engine\": \"postgres\", + \"details\": { + \"host\": \"${PG_HOST_MAIN}\", + \"port\": ${PG_PORT_MAIN}, + \"dbname\": \"${PG_DB_MAIN}\", + \"user\": \"${PG_USER_MAIN}\", + \"password\": \"${PG_PASS_MAIN}\", + \"ssl\": false + }, + \"is_full_sync\": true, + \"auto_run_queries\": true + }" | jq -r '.id') + +echo " created database 'PubPub' (id=${NEW_DB_ID})" + +curl -sf "${MB_URL}/api/database/${NEW_DB_ID}/sync_schema" \ + -H "X-Metabase-Session: ${SESSION}" \ + -X POST >/dev/null + +echo -n " waiting for schema sync" +for i in $(seq 1 120); do + STATUS=$(curl -sf "${MB_URL}/api/database/${NEW_DB_ID}" \ + -H "X-Metabase-Session: ${SESSION}" \ + | jq -r '.initial_sync_status // "incomplete"' 2>/dev/null || echo "incomplete") + + if [ "$STATUS" = "complete" ]; then + echo " (${i}s)" + break + fi + + if [ "$i" -eq 120 ]; then + echo "" + echo " WARNING: sync not complete after 120s, continuing anyway" + fi + + echo -n "." + sleep 2 +done + +# duckdb connection + +EXISTING_DUCK=$(curl -sf "${MB_URL}/api/database" \ + -H "X-Metabase-Session: ${SESSION}" \ + | jq -r '[(.data // .)[] | select(.name == "Analytics (DuckDB)")] | first | .id // empty') + +if [ -n "$EXISTING_DUCK" ]; then + echo " removing existing 'Analytics (DuckDB)' database (id=$EXISTING_DUCK)..." + curl -sf "${MB_URL}/api/database/${EXISTING_DUCK}" \ + -H "X-Metabase-Session: ${SESSION}" \ + -X DELETE >/dev/null +fi + +DUCKDB_ID=$(curl -sf "${MB_URL}/api/database" \ + -H "X-Metabase-Session: ${SESSION}" \ + -H 'Content-Type: application/json' \ + -X POST \ + -d '{ + "name": "Analytics (DuckDB)", + "engine": "duckdb", + "details": { + "database_file": "/duckdb/analytics.duckdb", + "read_only": true + }, + "is_full_sync": true, + "auto_run_queries": true + }' | jq -r '.id') + +echo " created database 'Analytics (DuckDB)' (id=${DUCKDB_ID})" + +curl -sf "${MB_URL}/api/database/${DUCKDB_ID}/sync_schema" \ + -H "X-Metabase-Session: ${SESSION}" \ + -X POST >/dev/null +echo " sync triggered" + +# ── 10. migrate queries ── + +OLD_DB_ID=$(psql "$MB_DB_URL" -tA -c "SELECT id FROM metabase_database WHERE engine = 'redshift' ORDER BY id LIMIT 1") + +if [ -z "$OLD_DB_ID" ]; then + echo "[10/10] no redshift database found in dump, skipping query migration" + echo "" + echo "done. open metabase to verify." + exit 0 +fi + +echo "[10/10] migrating saved questions (redshift db=$OLD_DB_ID -> postgres db=$NEW_DB_ID)..." + +tsx "${SCRIPT_DIR}/migrate-metabase-queries.ts" \ + --metabase-url "$MB_URL" \ + --session "$SESSION" \ + --old-db-id "$OLD_DB_ID" \ + --new-db-id "$NEW_DB_ID" \ + --collection "$COLLECTION_ID" + +echo "" +echo "done. open metabase to verify." diff --git a/tools/analytics-migration/migrate-redshift.sh b/tools/analytics-migration/migrate-redshift.sh index 2f38823465..5aa68653de 100755 --- a/tools/analytics-migration/migrate-redshift.sh +++ b/tools/analytics-migration/migrate-redshift.sh @@ -1,32 +1,43 @@ #!/bin/bash set -euo pipefail -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +DATA_DIR="${DATA_DIR:-/data/redshift}" +FORCE="${FORCE_DOWNLOAD:-0}" +DATABASE_URL="${DATABASE_URL:-postgres://appuser:apppassword@db:5432/appdb}" -REDSHIFT_BAK_DIR="${PROJECT_ROOT}/redshift_bak_data" -S3_REDSHIFT_PATH="${S3_REDSHIFT_PATH:-s3://temp-migration-redshift/pubpub_analytics/backup_20260402/}" -DATABASE_URL="${DATABASE_URL:-postgres://appuser:apppassword@localhost:5439/appdb}" +export AWS_ACCESS_KEY_ID="${AM_REDSHIFT_BACKUP_ACCESS_KEY:?set AM_REDSHIFT_BACKUP_ACCESS_KEY}" +export AWS_SECRET_ACCESS_KEY="${AM_REDSHIFT_BACKUP_SECRET_KEY:?set AM_REDSHIFT_BACKUP_SECRET_KEY}" +S3_PATH="${AM_REDSHIFT_PATH:?set AM_REDSHIFT_PATH}" -echo "Analytics Migration" -echo "===================" -echo " database: $DATABASE_URL" -echo " backup: $REDSHIFT_BAK_DIR" +STAGING_CREATED=0 +cleanup() { + if [ "$STAGING_CREATED" = "1" ]; then + echo "cleaning up staging table..." + psql "$DATABASE_URL" -c "DROP TABLE IF EXISTS analytics_staging;" 2>/dev/null || true + fi +} +trap cleanup EXIT + +echo "redshift migration" +echo " database: ${DATABASE_URL%%@*}@..." +echo " s3 path: $S3_PATH" +echo " data dir: $DATA_DIR" echo "" -# ── step 1: download from s3 if not already present ── +# ── step 1: download from s3 ── -if [ ! -f "$REDSHIFT_BAK_DIR/data000" ]; then - echo "[1/5] downloading redshift backup from s3..." - mkdir -p "$REDSHIFT_BAK_DIR" - aws s3 cp "$S3_REDSHIFT_PATH" "$REDSHIFT_BAK_DIR" --recursive +if [ -f "$DATA_DIR/data000" ] && [ "$FORCE" != "1" ]; then + echo "[1/5] backup already present, skipping (set FORCE_DOWNLOAD=1 to re-download)" else - echo "[1/5] backup already present, skipping download" + echo "[1/5] downloading redshift backup from s3..." + mkdir -p "$DATA_DIR" + aws s3 cp "$S3_PATH" "$DATA_DIR" --recursive fi -# ── step 2: ensure the AnalyticsEvents table exists ── +# ── step 2: ensure AnalyticsEvents table exists ── +# this is a safety net; the table may already exist via sequelize migrations. -echo "[2/5] ensuring AnalyticsEvents table exists..." +echo "[2/5] ensuring AnalyticsEvents table..." psql "$DATABASE_URL" <<'SQL' CREATE TABLE IF NOT EXISTS "AnalyticsEvents" ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), @@ -82,9 +93,17 @@ CREATE INDEX IF NOT EXISTS "analytics_events_collection_event_ts" ON "AnalyticsEvents" ("collectionId", event, "timestamp"); SQL +EXISTING_COUNT=$(psql "$DATABASE_URL" -tA -c 'SELECT count(*) FROM "AnalyticsEvents"' 2>/dev/null || echo "0") +if [ "$EXISTING_COUNT" != "0" ]; then + echo " table already has $EXISTING_COUNT rows, duplicates will be skipped" +fi + # ── step 3: create staging table and import csv ── +# uses an unlogged table for faster bulk import. all columns are text because +# the redshift backup uses different column names and types. echo "[3/5] creating staging table..." +STAGING_CREATED=1 psql "$DATABASE_URL" <<'SQL' DROP TABLE IF EXISTS analytics_staging; CREATE UNLOGGED TABLE analytics_staging ( @@ -139,7 +158,7 @@ CREATE UNLOGGED TABLE analytics_staging ( SQL echo "[4/5] importing csv files into staging..." -for f in "$REDSHIFT_BAK_DIR"/data[0-9][0-9][0-9]; do +for f in "$DATA_DIR"/data[0-9][0-9][0-9]; do echo " $(basename "$f")..." psql "$DATABASE_URL" -c "\copy analytics_staging FROM '$f' CSV HEADER" done @@ -159,7 +178,7 @@ SELECT CASE END; $$ LANGUAGE sql IMMUTABLE; --- cast through double precision to handle scientific notation (e.g. 1.71974e+14) +-- handles scientific notation (e.g. 1.71974e+14) via double precision cast CREATE OR REPLACE FUNCTION pg_temp.safe_ts(val text) RETURNS timestamptz AS $$ SELECT CASE WHEN val IS NULL OR val = '' THEN NULL @@ -252,9 +271,11 @@ SELECT FROM analytics_staging s WHERE s.type IS NOT NULL AND s.event IS NOT NULL - AND pg_temp.safe_ts(s."timestamp") IS NOT NULL; + AND pg_temp.safe_ts(s."timestamp") IS NOT NULL +ON CONFLICT (id) DO NOTHING; SQL +STAGING_CREATED=0 echo " cleaning up staging table..." psql "$DATABASE_URL" -c "DROP TABLE IF EXISTS analytics_staging;" psql "$DATABASE_URL" -c 'ANALYZE "AnalyticsEvents";' diff --git a/tools/analytics-migration/setup-metabase.sh b/tools/analytics-migration/setup-metabase.sh deleted file mode 100755 index 970e293c69..0000000000 --- a/tools/analytics-migration/setup-metabase.sh +++ /dev/null @@ -1,243 +0,0 @@ -#!/bin/bash -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" -COMPOSE_FILE="${PROJECT_ROOT}/infra/docker-compose.dev.yml" - -DUMP_FILE="${1:?usage: setup-metabase.sh [collection-id]}" -COLLECTION_ID="${2:-14}" - -MB_DB_PORT="${METABASE_DB_PORT:-5440}" -MB_DB_USER="${METABASE_DB_USER:-appuser}" -MB_DB_PASS="${METABASE_DB_PASS:-apppassword}" -MB_DB_NAME="${METABASE_DB_NAME:-metabasedb}" -MB_PORT="${METABASE_PORT:-3030}" -MB_URL="http://localhost:${MB_PORT}" - -MAIN_DB_HOST="${MAIN_DB_DOCKER_HOST:-db}" -MAIN_DB_PORT="${MAIN_DB_PORT:-5432}" -MAIN_DB_NAME="${MAIN_DB_NAME:-appdb}" -MAIN_DB_USER="${MAIN_DB_USER:-appuser}" -MAIN_DB_PASS="${MAIN_DB_PASS:-apppassword}" - -: "${METABASE_ADMIN_EMAIL:?set METABASE_ADMIN_EMAIL}" -: "${METABASE_ADMIN_PASSWORD:?set METABASE_ADMIN_PASSWORD}" - -MB_CONN="postgres://${MB_DB_USER}:${MB_DB_PASS}@localhost:${MB_DB_PORT}" -MB_DB_URL="${MB_CONN}/${MB_DB_NAME}" -MB_ADMIN_URL="${MB_CONN}/postgres" - -json_val() { - node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{const o=JSON.parse(d);console.log($1)})" -} - -echo "Metabase Setup" -echo "==============" -echo " dump: $DUMP_FILE" -echo " metabase: $MB_URL" -echo " database: localhost:${MB_DB_PORT}/${MB_DB_NAME}" -echo "" - -# ── 1. stop metabase ── - -echo "[1/10] stopping metabase..." -docker compose -f "$COMPOSE_FILE" stop metabase 2>/dev/null || true - -# ── 2. ensure metabase_db is running ── - -echo "[2/10] ensuring metabase_db is running..." -docker compose -f "$COMPOSE_FILE" up -d metabase_db -until PGPASSWORD="$MB_DB_PASS" pg_isready -h localhost -p "$MB_DB_PORT" -U "$MB_DB_USER" -q 2>/dev/null; do - sleep 1 -done - -# ── 3. reset the database ── - -echo "[3/10] resetting database..." -psql "$MB_ADMIN_URL" -q -c \ - "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${MB_DB_NAME}' AND pid != pg_backend_pid();" \ - 2>/dev/null || true - -psql "$MB_ADMIN_URL" -q -c "DROP DATABASE IF EXISTS \"${MB_DB_NAME}\";" -psql "$MB_ADMIN_URL" -q -c "CREATE DATABASE \"${MB_DB_NAME}\" OWNER \"${MB_DB_USER}\";" - -# ── 4. import dump ── - -echo "[4/10] importing dump..." -psql "$MB_DB_URL" -q < "$DUMP_FILE" - -CARD_COUNT=$(psql "$MB_DB_URL" -tA -c "SELECT count(*) FROM report_card") -echo " $CARD_COUNT saved questions imported" - -# ── 5. fix settings ── - -echo "[5/10] patching settings..." -psql "$MB_DB_URL" -q <<'SQL' -UPDATE setting -SET value = REPLACE(value, 'US/Eastern', 'America/New_York') -WHERE value LIKE '%US/Eastern%'; - -UPDATE metabase_database -SET details = REPLACE(details, 'US/Eastern', 'America/New_York') -WHERE details LIKE '%US/Eastern%'; - -UPDATE core_user -SET settings = REPLACE(settings, 'US/Eastern', 'America/New_York') -WHERE settings LIKE '%US/Eastern%'; -SQL -echo " timezone references fixed" - -# ── 6. initial DuckDB sync ── - -echo "[6/10] running initial DuckDB sync..." -docker compose -f "$COMPOSE_FILE" build analytics_sync -docker compose -f "$COMPOSE_FILE" run --rm analytics_sync sync.sh --once - -# ── 7. start metabase ── - -echo "[7/10] starting metabase..." -docker compose -f "$COMPOSE_FILE" up -d metabase - -echo -n " waiting for ready" -for i in $(seq 1 180); do - if curl -sf "${MB_URL}/api/health" >/dev/null 2>&1; then - echo " (${i}s)" - break - fi - - if [ "$i" -eq 180 ]; then - echo "" - echo " ERROR: metabase not ready after 180s" - exit 1 - fi - - echo -n "." - sleep 1 -done - -# ── 8. add Postgres database connection ── - -echo "[8/10] configuring Postgres database connection..." - -SESSION=$(curl -sf "${MB_URL}/api/session" \ - -H 'Content-Type: application/json' \ - -d "{\"username\":\"${METABASE_ADMIN_EMAIL}\",\"password\":\"${METABASE_ADMIN_PASSWORD}\"}" \ - | json_val 'o.id') - -EXISTING_DB=$(curl -sf "${MB_URL}/api/database" \ - -H "X-Metabase-Session: ${SESSION}" \ - | json_val '(o.data || o).find(d => d.name === "PubPub")?.id ?? ""') - -if [ -n "$EXISTING_DB" ] && [ "$EXISTING_DB" != "undefined" ]; then - echo " removing existing 'PubPub' database (id=$EXISTING_DB)..." - curl -sf "${MB_URL}/api/database/${EXISTING_DB}" \ - -H "X-Metabase-Session: ${SESSION}" \ - -X DELETE >/dev/null -fi - -NEW_DB_ID=$(curl -sf "${MB_URL}/api/database" \ - -H "X-Metabase-Session: ${SESSION}" \ - -H 'Content-Type: application/json' \ - -X POST \ - -d "{ - \"name\": \"PubPub\", - \"engine\": \"postgres\", - \"details\": { - \"host\": \"${MAIN_DB_HOST}\", - \"port\": ${MAIN_DB_PORT}, - \"dbname\": \"${MAIN_DB_NAME}\", - \"user\": \"${MAIN_DB_USER}\", - \"password\": \"${MAIN_DB_PASS}\", - \"ssl\": false - }, - \"is_full_sync\": true, - \"auto_run_queries\": true - }" | json_val 'o.id') - -echo " created database 'PubPub' (id=${NEW_DB_ID})" - -curl -sf "${MB_URL}/api/database/${NEW_DB_ID}/sync_schema" \ - -H "X-Metabase-Session: ${SESSION}" \ - -X POST >/dev/null - -echo -n " waiting for sync" -for i in $(seq 1 120); do - STATUS=$(curl -sf "${MB_URL}/api/database/${NEW_DB_ID}" \ - -H "X-Metabase-Session: ${SESSION}" \ - | json_val 'o.initial_sync_status || "incomplete"' 2>/dev/null || echo "incomplete") - - if [ "$STATUS" = "complete" ]; then - echo " (${i}s)" - break - fi - - if [ "$i" -eq 120 ]; then - echo "" - echo " WARNING: sync not complete after 120s, continuing anyway" - fi - - echo -n "." - sleep 2 -done - -# ── 9. add DuckDB database connection ── - -echo "[9/10] configuring DuckDB database..." - -EXISTING_DUCKDB=$(curl -sf "${MB_URL}/api/database" \ - -H "X-Metabase-Session: ${SESSION}" \ - | json_val '(o.data || o).find(d => d.name === "Analytics (DuckDB)")?.id ?? ""') - -if [ -n "$EXISTING_DUCKDB" ] && [ "$EXISTING_DUCKDB" != "undefined" ]; then - echo " removing existing 'Analytics (DuckDB)' database (id=$EXISTING_DUCKDB)..." - curl -sf "${MB_URL}/api/database/${EXISTING_DUCKDB}" \ - -H "X-Metabase-Session: ${SESSION}" \ - -X DELETE >/dev/null -fi - -DUCKDB_ID=$(curl -sf "${MB_URL}/api/database" \ - -H "X-Metabase-Session: ${SESSION}" \ - -H 'Content-Type: application/json' \ - -X POST \ - -d '{ - "name": "Analytics (DuckDB)", - "engine": "duckdb", - "details": { - "database_file": "/duckdb/analytics.duckdb", - "read_only": true - }, - "is_full_sync": true, - "auto_run_queries": true - }' | json_val 'o.id') - -echo " created database 'Analytics (DuckDB)' (id=${DUCKDB_ID})" - -curl -sf "${MB_URL}/api/database/${DUCKDB_ID}/sync_schema" \ - -H "X-Metabase-Session: ${SESSION}" \ - -X POST >/dev/null - -echo " sync triggered" - -# ── 10. migrate queries ── - -OLD_DB_ID=$(psql "$MB_DB_URL" -tA -c "SELECT id FROM metabase_database WHERE engine = 'redshift' ORDER BY id LIMIT 1") - -if [ -z "$OLD_DB_ID" ]; then - echo "[10/10] no redshift database found, skipping query migration" - echo "" - echo "done. open ${MB_URL} to verify." - exit 0 -fi - -echo "[10/10] migrating saved questions (redshift db=$OLD_DB_ID -> postgres db=$NEW_DB_ID)..." - -npx tsx "${SCRIPT_DIR}/migrate-metabase-queries.ts" \ - --metabase-url "$MB_URL" \ - --session "$SESSION" \ - --old-db-id "$OLD_DB_ID" \ - --new-db-id "$NEW_DB_ID" \ - --collection "$COLLECTION_ID" - -echo "" -echo "done. open ${MB_URL} to verify." From e259f640da26dd633b24c1843e59a55ab58b6808 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Tue, 7 Apr 2026 12:24:25 -0400 Subject: [PATCH 21/41] One last merge bit --- server/models.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/server/models.ts b/server/models.ts index ad6cd420b9..a180d23f1f 100644 --- a/server/models.ts +++ b/server/models.ts @@ -3,11 +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'; -<<<<<<< HEAD import { AnalyticsEvent } from './analytics/model'; -======= import { AnalyticsDailyCache } from './analyticsDailyCache/model'; ->>>>>>> tr/impact2 import { AuthToken } from './authToken/model'; import { Collection } from './collection/model'; import { CollectionAttribution } from './collectionAttribution/model'; @@ -65,11 +62,8 @@ import { ZoteroIntegration } from './zoteroIntegration/model'; sequelize.addModels([ ActivityItem, -<<<<<<< HEAD AnalyticsEvent, -======= AnalyticsDailyCache, ->>>>>>> tr/impact2 AuthToken, Collection, CollectionAttribution, From 21887afa4cbfbaf7d49bd2a3833bde8679a3cbcf Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Tue, 7 Apr 2026 16:14:43 -0400 Subject: [PATCH 22/41] Metabase free legacy analytics end to end --- .gitignore | 3 +- .../DashboardImpact/DashboardImpact.tsx | 593 ++++++++++++------ .../DashboardImpact/dashboardImpact.scss | 184 +++++- infra/Caddyfile | 8 - infra/docker-compose.dev.yml | 65 +- infra/docker-compose.prod.yml | 28 - infra/env.example | 10 - infra/stack.yml | 64 -- server/analytics/README.md | 246 ++++++++ server/analytics/impactApi.ts | 425 +++++++++++++ server/analytics/model.ts | 18 + server/analytics/summaryViews.ts | 197 ++++++ server/apiRoutes.ts | 2 + server/routes/dashboardImpact.tsx | 19 +- server/sequelize.ts | 6 + tools/cron.ts | 6 + tools/index.js | 2 + tools/migrateRedshift.ts | 420 +++++++++++++ tools/refreshAnalyticsSummary.ts | 34 + 19 files changed, 1933 insertions(+), 397 deletions(-) create mode 100644 server/analytics/README.md create mode 100644 server/analytics/impactApi.ts create mode 100644 server/analytics/summaryViews.ts create mode 100644 tools/migrateRedshift.ts create mode 100644 tools/refreshAnalyticsSummary.ts diff --git a/.gitignore b/.gitignore index f65e0ef926..2d84599dbf 100755 --- a/.gitignore +++ b/.gitignore @@ -69,4 +69,5 @@ tsconfig.tsbuildinfo .jest/secret-env.js infra/pgdata/ -infra/metabase-plugins/ \ No newline at end of file +infra/metabase-plugins/ +tmp/ \ No newline at end of file diff --git a/client/containers/DashboardImpact/DashboardImpact.tsx b/client/containers/DashboardImpact/DashboardImpact.tsx index b0a455f0ce..5935c76a35 100644 --- a/client/containers/DashboardImpact/DashboardImpact.tsx +++ b/client/containers/DashboardImpact/DashboardImpact.tsx @@ -1,219 +1,438 @@ -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, 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 TopPageRow = { pageTitle: string; path: string; count: number }; +type TopPubRow = { pubTitle: string; pubId: string; views: number; downloads: number }; +type TopCollectionRow = { collectionTitle: string; 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[]; +}; + +type DateRange = '30d' | '90d' | '365d' | 'all'; + +// ─── 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 getRange = (r: DateRange): { startDate: string; endDate: string } => { + const end = new Date(); + const start = new Date(); + if (r === 'all') { + start.setFullYear(2015); + } else { + const days = r === '30d' ? 30 : r === '90d' ? 90 : 365; + start.setDate(end.getDate() - days); + } + return { + startDate: start.toISOString().slice(0, 10), + endDate: end.toISOString().slice(0, 10), }; }; -const DashboardImpact = (props: Props) => { - const { impactData } = props; - const { baseToken, benchmarkToken, newToken } = impactData; +const COLORS = { pageViews: '#15B371', unique: '#2B95D6' }; + +// ─── compact table ─────────────────────────────────────────────────────────── + +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]}
+); + +const StatCard = ({ label, value, color }: { label: string; value: string; color: string }) => ( +

+); + +// ─── component ─────────────────────────────────────────────────────────────── + +const DashboardImpact = () => { const { scopeData } = usePageContext(); const { - elements: { activeTargetType, activeTargetName, activeTarget }, + elements: { activeTargetType, activeTargetName, activePub, activeCollection }, activePermissions: { canView }, } = scopeData; - const displayDataWarning = activeTarget?.createdAt < '2020-04-29'; - const isCollection = activeTargetType === 'collection'; - const genUrl = (token) => { - return `http://localhost:3030/embed/dashboard/${token}#bordered=false&titled=false`; - }; - const getOffset = (width) => { - return width < 960 ? 45 : 61; - }; + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [dateRange, setDateRange] = useState('90d'); + + // 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 (range: DateRange) => { + setLoading(true); + setError(null); + try { + const { startDate, endDate } = getRange(range); + const res = await fetch( + `/api/analytics-impact?startDate=${startDate}&endDate=${endDate}${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(dateRange); + }, [dateRange, canView, fetchData]); + + const chartData = useMemo( + () => (data ? data.daily.map((d) => ({ ...d, label: fmtDate(d.date) })) : []), + [data], + ); - const [showHistoricalAnalytics, setShowHistoricalAnalytics] = React.useState(false); + if (!canView) { + return ( + +

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

+
+ ); + } return ( + + + + + + } > - {canView ? ( + {loading && ( +
+ +
+ )} + + {error && ( + fetchData(dateRange)} icon="refresh"> + Retry + + } + /> + )} + + {!loading && !error && data && ( <> -
- {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} - > -
+ + {/* ── Row 2: Countries ── */} + {data.countries.length > 0 && ( +
+

Countries

+ ({ + ...c, + country: c.countryCode + ? countryName(c.countryCode) + : c.country, + }))} + columns={[ + { key: 'country', label: 'Country' }, + { + key: 'count', + label: 'Pageviews', + render: (v: number) => fmt(v), + }, + ]} + /> +
+ )} + + {/* ── Row 3: Top Pub Downloads and Pageviews ── */} + {data.topPubs.length > 0 && ( +
+

Top Pub Downloads and Pageviews

+ fmt(v), + }, + { + key: 'downloads', + label: 'Downloads', + render: (v: number) => fmt(v), + }, + ]} + /> +
+ )} + + {/* ── Row 4: Top Pages ── */} + {data.topPages.length > 0 && ( +
+

Top Pages

+ ( +
+ {v || row.path || '(home)'} + + ), + }, + { + key: 'count', + label: 'Pageviews', + render: (v: number) => fmt(v), + }, + ]} + /> +
+ )} + + {/* ── Row 5: Top Collections ── */} + {data.topCollections.length > 0 && ( +
+

Top Collections

+ fmt(v), + }, + ]} + /> +
+ )} + + {/* ── Row 6: Referrers + Campaigns side by side ── */} +
+ {data.referrers.length > 0 && ( +
+

Top Referrers

+ fmt(v), + }, + ]} /> - +
)} - -
- {!showHistoricalAnalytics ? ( - - ) : ( - <> - -
-

- 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} - > -
+ )} +
- ) : ( -

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..feac3d186f 100644 --- a/client/containers/DashboardImpact/dashboardImpact.scss +++ b/client/containers/DashboardImpact/dashboardImpact.scss @@ -1,27 +1,177 @@ .dashboard-impact-container { - section { - margin-top: 50px; + // ── Loading ───────────────────────────────────────────────────────── + .loading-container { + display: flex; + justify-content: center; + padding: 60px 0; } - .absolute-header { - position: relative; - z-index: 2; - align-items: baseline; - display: inline-block; + + // ── 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; + } + } + + .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; + } } - .warning-button { - margin: 0px 5px 5px; + + // ── Data panels ───────────────────────────────────────────────────── + .data-panel { + margin-bottom: 28px; + + h3 { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #5c7080; + margin: 0 0 6px; + padding: 0 4px; + } + } + + // ── Two-column layout for referrers + campaigns ───────────────────── + .two-col { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + margin-bottom: 28px; + + @media (max-width: 768px) { + grid-template-columns: 1fr; + } } - iframe.metabase { + + // ── 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; + } + + td { + padding: 3px 4px; + color: #1c2127; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 0; + } + + // Right-align numeric columns + td:last-child, + td:nth-last-child(1 of .num) { + 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 { + .stat-card .stat-value { + color: #f5f8fa; + } + .stat-card .stat-label { + color: #a7b6c2; + } + .data-panel h3, + .chart-column h3 { + color: #a7b6c2; + } + .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; + } + } } } diff --git a/infra/Caddyfile b/infra/Caddyfile index fcd956c38f..96f2e6bccd 100644 --- a/infra/Caddyfile +++ b/infra/Caddyfile @@ -7,14 +7,6 @@ respond "OK" 200 } -{$METABASE_HOST:analytics.localhost} { - tls internal { - on_demand - } - encode gzip - reverse_proxy pubpub_metabase:3001 -} - :443 { tls internal { on_demand diff --git a/infra/docker-compose.dev.yml b/infra/docker-compose.dev.yml index bb7fec834f..57ca27a903 100644 --- a/infra/docker-compose.dev.yml +++ b/infra/docker-compose.dev.yml @@ -87,67 +87,6 @@ services: networks: [appnet] - metabase: - build: - context: ./metabase - env_file: - - .env.dev - environment: - MB_JETTY_PORT: "3001" - MB_ENABLE_QUERY_CACHING: "true" - MB_QUERY_CACHING_TTL_RATIO: "10" - MB_QUERY_CACHING_MIN_TTL: "120" - depends_on: - - metabase_db - - db - ports: - - "${METABASE_PORT:-3030}:3001" - volumes: - - analytics_duckdb:/duckdb:ro - networks: [appnet] - - analytics_sync: - build: - context: ./duckdb-sync - env_file: - - .env.dev - volumes: - - analytics_duckdb:/data - depends_on: - - db - networks: [appnet] - - metabase_db: - env_file: - - .env.dev - image: postgres:18 - environment: - - POSTGRES_USER=appuser - - POSTGRES_PASSWORD=apppassword - - POSTGRES_DB=metabasedb - volumes: - - metabase_pgdata:/var/lib/postgresql - ports: - - "${METABASE_DB_PORT:-5440}:5432" - networks: [appnet] - - analytics_migration: - build: - context: ../tools/analytics-migration - env_file: - - .env.dev - environment: - DATABASE_URL: postgres://appuser:apppassword@db:5432/appdb - volumes: - - ../tools/analytics-migration:/migration - - /var/run/docker.sock:/var/run/docker.sock - - analytics_migration_data:/data - - analytics_duckdb:/duckdb - depends_on: - - db - - metabase_db - networks: [appnet] - profiles: [migration] # cron: # build: @@ -174,6 +113,4 @@ networks: volumes: pgdata: rabbitmqdata: - metabase_pgdata: - analytics_duckdb: - analytics_migration_data: + diff --git a/infra/docker-compose.prod.yml b/infra/docker-compose.prod.yml index 1a24980379..2819c25fcc 100644 --- a/infra/docker-compose.prod.yml +++ b/infra/docker-compose.prod.yml @@ -64,33 +64,6 @@ services: limits: memory: 2G - metabase: - image: metabase/metabase:latest - environment: - MB_DB_TYPE: postgres - MB_DB_DBNAME: metabasedb - MB_DB_PORT: "5432" - MB_DB_HOST: metabase_db - MB_DB_USER: appuser - MB_DB_PASS: apppassword - MB_JETTY_PORT: "3001" - depends_on: - - metabase_db - - db - ports: - - "${METABASE_PORT:-3030}:3001" - networks: [appnet] - - metabase_db: - image: postgres:16 - environment: - - POSTGRES_USER=appuser - - POSTGRES_PASSWORD=apppassword - - POSTGRES_DB=metabasedb - volumes: - - metabase_pgdata:/var/lib/postgresql/data - networks: [appnet] - rabbitmq: image: rabbitmq:3.13-alpine environment: @@ -129,4 +102,3 @@ networks: volumes: # pgdata: rabbitmqdata: - metabase_pgdata: diff --git a/infra/env.example b/infra/env.example index 8794bba335..e491e74bf1 100644 --- a/infra/env.example +++ b/infra/env.example @@ -1,13 +1,3 @@ AM_REDSHIFT_PATH= AM_REDSHIFT_BACKUP_ACCESS_KEY= AM_REDSHIFT_BACKUP_SECRET_KEY= -AM_MB_RDS_URL= -AM_MB_RDS_ADMIN_EMAIL= -AM_MB_RDS_ADMIN_PASSWORD= -AM_MB_COLLECTION_ID=14 - -MB_DB_HOST= -MB_DB_PASS= -MB_DB_USER= -MB_DB_NAME= -MB_DB_PORT= diff --git a/infra/stack.yml b/infra/stack.yml index 5faeea28e2..03fd4a65f2 100644 --- a/infra/stack.yml +++ b/infra/stack.yml @@ -130,68 +130,6 @@ services: restart_policy: condition: any - metabase: - image: ghcr.io/knowledgefutures/pubpub-metabase:${IMAGE_TAG:-latest} - environment: - MB_DB_TYPE: postgres - MB_DB_DBNAME: metabasedb - MB_DB_PORT: '5432' - MB_DB_HOST: metabase_db - MB_DB_USER: '${METABASE_DB_USER:-appuser}' - MB_DB_PASS: '${METABASE_DB_PASS}' - MB_JETTY_PORT: '3001' - MB_ENABLE_QUERY_CACHING: 'true' - MB_QUERY_CACHING_TTL_RATIO: '10' - MB_QUERY_CACHING_MIN_TTL: '120' - volumes: - - analytics_duckdb:/duckdb:ro - networks: [appnet] - deploy: - replicas: 1 - resources: - limits: - memory: 2G - reservations: - memory: 1G - restart_policy: - condition: any - - analytics_sync: - image: ghcr.io/knowledgefutures/pubpub-duckdb-sync:${IMAGE_TAG:-latest} - environment: - PG_HOST: db - PG_PORT: '5432' - PG_DB: appdb - PG_USER: appuser - PG_PASS: '${DB_PASS}' - SYNC_INTERVAL: '${ANALYTICS_SYNC_INTERVAL:-43200}' - volumes: - - analytics_duckdb:/data - networks: [appnet] - deploy: - replicas: 1 - resources: - limits: - memory: 4G - reservations: - memory: 1G - restart_policy: - condition: any - - metabase_db: - image: postgres:16 - environment: - POSTGRES_USER: '${METABASE_DB_USER:-appuser}' - POSTGRES_PASSWORD: '${METABASE_DB_PASS}' - POSTGRES_DB: metabasedb - volumes: - - metabase_pgdata:/var/lib/postgresql/data - networks: [appnet] - deploy: - replicas: 1 - restart_policy: - condition: any - pubstash: image: ghcr.io/knowledgefutures/pubpub:${IMAGE_TAG} env_file: [.env] @@ -229,7 +167,5 @@ networks: volumes: pgdata: rabbitmqdata: - metabase_pgdata: - analytics_duckdb: caddy_data: caddy_config: diff --git a/server/analytics/README.md b/server/analytics/README.md new file mode 100644 index 0000000000..f445353aa7 --- /dev/null +++ b/server/analytics/README.md @@ -0,0 +1,246 @@ +# Analytics (Legacy Impact Dashboard) + +The legacy Impact dashboard (`/dash/impact-v1`) 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/impact`) is an entirely separate system backed by +> Cloudflare analytics and is unaffected by any of this. + +--- + +## Data Flow + +``` +Redshift (historical) + │ + │ one-time migration (tools/migrateRedshift.ts) + │ downloads CSVs from S3, stages into PG via psql \copy + ▼ +┌─────────────────────┐ +│ AnalyticsEvents │ ~19M rows (raw table, Sequelize model) +│ (server/analytics/ │ +│ model.ts) │ +└────────┬────────────┘ + │ + │ CREATE MATERIALIZED VIEW IF NOT EXISTS ... + │ (server/analytics/summaryViews.ts → createSummaryViews) + ▼ +┌─────────────────────────────────────────────────────┐ +│ 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) │ +└────────┬────────────────────────────────────────────┘ + │ + │ REFRESH + CLUSTER + ANALYZE + │ (nightly cron → tools/refreshAnalyticsSummary.ts) + ▼ +┌─────────────────────┐ ┌──────────────────────┐ +│ GET /api/analytics │──────▶│ DashboardImpact.tsx │ +│ -impact │ JSON │ (Recharts) │ +│ (impactApi.ts) │ └──────────────────────┘ +└─────────────────────┘ +``` + +--- + +## 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. + +### 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/model.ts` | Sequelize model + raw table indexes | +| `server/analytics/summaryViews.ts` | Matview DDL, create/refresh functions | +| `server/analytics/impactApi.ts` | Express route + 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-v1` | + +--- + +## 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. diff --git a/server/analytics/impactApi.ts b/server/analytics/impactApi.ts new file mode 100644 index 0000000000..8a3a45d44f --- /dev/null +++ b/server/analytics/impactApi.ts @@ -0,0 +1,425 @@ +/** + * 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 { 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 ReferrerRow = { referrer: string; count: number }; +type CampaignRow = { campaign: string; count: number }; +type TopPageRow = { pageTitle: string; path: string; count: number }; +type TopPubRow = { pubTitle: string; pubId: string; views: number; downloads: number }; +type TopCollectionRow = { collectionTitle: string; collectionId: string; count: number }; + +// ─── 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, + countries, + topPubs, + topPages, + topCollections, + referrers, + campaigns, + ] = 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 + sequelize.query( + `SELECT + country, + country_code AS "countryCode", + SUM(count)::int AS count + FROM analytics_daily_country + WHERE ${mvWhere} + GROUP BY country, country_code + ORDER BY count DESC LIMIT 30`, + { 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", + 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 30`, + { 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 30`, + { replacements, type: QueryTypes.SELECT }, + ), + // ── top collections from matview + sequelize.query( + `SELECT + COALESCE(c.title, mv."collectionId"::text) AS "collectionTitle", + 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 + ORDER BY count DESC LIMIT 30`, + { 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 30`, + { 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 30`, + { 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: countries.map((c) => ({ ...c, count: Number(c.count) })), + 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) })), + }; +} + +/** + * 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 = `"timestamp" >= :startDate::date AND "timestamp" < (: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."timestamp" >= :startDate::date AND ae."timestamp" < (:endDate::date + interval '1 day')`; + const aeBaseWhere = `${aeScopeClause} AND ${aeDateFilter}`; + + const [ + daily, + [totalDlRow], + countries, + topPubs, + topPages, + topCollections, + referrers, + campaigns, + ] = await Promise.all([ + sequelize.query( + `SELECT + date_trunc('day', "timestamp")::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(country, 'Unknown') AS country, + COALESCE("countryCode", '') AS "countryCode", + COUNT(*) AS count + FROM "AnalyticsEvents" + WHERE ${baseWhere} AND ${pageEvents} + GROUP BY country, "countryCode" + ORDER BY count DESC LIMIT 30`, + { replacements: baseReplacements, type: QueryTypes.SELECT }, + ), + sequelize.query( + `SELECT + COALESCE(p.title, p.slug, ae."pubId"::text) AS "pubTitle", + 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 30`, + { replacements: baseReplacements, type: QueryTypes.SELECT }, + ), + sequelize.query( + `SELECT + COALESCE("pageTitle", "pubTitle", "collectionTitle", path, '') AS "pageTitle", + COALESCE(path, '') AS path, + COUNT(*) AS count + FROM "AnalyticsEvents" + WHERE ${baseWhere} AND ${pageEvents} + GROUP BY "pageTitle", "pubTitle", "collectionTitle", path + ORDER BY count DESC LIMIT 30`, + { replacements: baseReplacements, type: QueryTypes.SELECT }, + ), + sequelize.query( + `SELECT + COALESCE("collectionTitle", "collectionSlug", "collectionId"::text) AS "collectionTitle", + "collectionId"::text AS "collectionId", + COUNT(*) AS count + FROM "AnalyticsEvents" + WHERE ${baseWhere} AND "collectionId" IS NOT NULL AND event IN ('collection','pub') + GROUP BY "collectionTitle", "collectionSlug", "collectionId" + ORDER BY count DESC LIMIT 30`, + { 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 30`, + { 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 30`, + { 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: countries.map((c) => ({ ...c, count: Number(c.count) })), + 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) })), + }; +} + +// ─── 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); + + 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 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 index 2332ada25f..5d11f200c5 100644 --- a/server/analytics/model.ts +++ b/server/analytics/model.ts @@ -1,4 +1,5 @@ import type { CreationOptional, InferAttributes, InferCreationAttributes } from 'sequelize'; +import { Op } from 'sequelize'; import { AllowNull, @@ -23,6 +24,23 @@ import { name: 'analytics_events_collection_event_ts', fields: ['collectionId', 'event', 'timestamp'], }, + { + name: 'analytics_events_community_ts', + fields: ['communityId', 'timestamp'], + }, + { + name: 'analytics_events_community_pages', + fields: ['communityId', 'timestamp', 'isUnique'], + where: { event: { [Op.in]: ['page', 'pub', 'collection', 'other'] } }, + }, + { + name: 'analytics_events_pub_views_dl', + fields: ['communityId', 'pubId', 'timestamp'], + where: { + pubId: { [Op.ne]: null }, + event: { [Op.in]: ['pub', 'download'] }, + }, + }, ], }) export class AnalyticsEvent extends Model< diff --git a/server/analytics/summaryViews.ts b/server/analytics/summaryViews.ts new file mode 100644 index 0000000000..b41a7e6d2e --- /dev/null +++ b/server/analytics/summaryViews.ts @@ -0,0 +1,197 @@ +/** + * 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', "timestamp")::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', "timestamp")::date`, + indexSql: ` +CREATE UNIQUE INDEX IF NOT EXISTS analytics_daily_summary_uk + ON analytics_daily_summary ("communityId", date)`, + }, + { + name: 'analytics_daily_country', + createSql: ` +CREATE MATERIALIZED VIEW IF NOT EXISTS analytics_daily_country AS +SELECT + "communityId", + date_trunc('day', "timestamp")::date AS date, + COALESCE(country, 'Unknown') AS country, + COALESCE("countryCode", '') AS country_code, + COUNT(*) AS count +FROM "AnalyticsEvents" +WHERE event IN ('page','pub','collection','other') +GROUP BY "communityId", date_trunc('day', "timestamp")::date, country, "countryCode"`, + indexSql: ` +CREATE UNIQUE INDEX IF NOT EXISTS analytics_daily_country_uk + ON analytics_daily_country ("communityId", date, country, country_code)`, + }, + { + name: 'analytics_daily_pub', + createSql: ` +CREATE MATERIALIZED VIEW IF NOT EXISTS analytics_daily_pub AS +SELECT + "communityId", + "pubId", + date_trunc('day', "timestamp")::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', "timestamp")::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', "timestamp")::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', "timestamp")::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', "timestamp")::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', "timestamp")::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', "timestamp")::date AS date, + "utmCampaign" AS campaign, + COUNT(*) AS count +FROM "AnalyticsEvents" +WHERE "utmCampaign" IS NOT NULL AND "utmCampaign" != '' +GROUP BY "communityId", date_trunc('day', "timestamp")::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 + "communityId", + date_trunc('day', "timestamp")::date AS date, + LEFT(COALESCE("pageTitle", "pubTitle", "collectionTitle", path, ''), 300) AS page_title, + LEFT(COALESCE(path, ''), 300) AS path, + COUNT(*) AS count +FROM "AnalyticsEvents" +WHERE event IN ('page','pub','collection','other') +GROUP BY "communityId", date_trunc('day', "timestamp")::date, + LEFT(COALESCE("pageTitle", "pubTitle", "collectionTitle", path, ''), 300), + LEFT(COALESCE(path, ''), 300)`, + indexSql: ` +CREATE UNIQUE INDEX IF NOT EXISTS analytics_daily_page_uk + ON analytics_daily_page ("communityId", date, md5(page_title || '|' || path))`, + }, +]; + +// ─── 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)`); +} + +/** + * 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', +}; + +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/apiRoutes.ts b/server/apiRoutes.ts index 03986329da..a1fec7fd81 100644 --- a/server/apiRoutes.ts +++ b/server/apiRoutes.ts @@ -14,6 +14,7 @@ import { router as discussionRouter } from './discussion/api'; import { router as doiRouter } from './doi/api'; import { router as draftCheckpointRouter } from './draftCheckpoint/api'; import { router as editorRouter } from './editor/api'; +import { router as analyticsImpactRouter } from './analytics/impactApi'; import { router as impact2Router } from './impact2/api'; import { router as integrationDataOAuth1Router } from './integrationDataOAuth1/api'; import { router as landingPageFeatureRouter } from './landingPageFeature/api'; @@ -75,6 +76,7 @@ const apiRouter = Router() .use(userNotificationPreferencesRouter) .use(userSubscriptionRouter) .use(zoteroIntegrationRouter) + .use(analyticsImpactRouter) .use(impact2Router) .use(apiDocsRouter); diff --git a/server/routes/dashboardImpact.tsx b/server/routes/dashboardImpact.tsx index 4e8df36dff..38702e2d6a 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'; @@ -26,28 +25,12 @@ router.get( if (!initialData.scopeData.elements.activeTarget) { throw new NotFoundError(); } - const { activeTargetType, activeTarget } = initialData.scopeData.elements; - console.log('activeTargetType', activeTargetType, initialData.locationData.isProd); - 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, 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/tools/cron.ts b/tools/cron.ts index ebea8d55c7..98b99936e4 100644 --- a/tools/cron.ts +++ b/tools/cron.ts @@ -42,6 +42,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 43c430b163..242f5a1f18 100644 --- a/tools/index.js +++ b/tools/index.js @@ -72,6 +72,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..e5fb896f28 --- /dev/null +++ b/tools/migrateRedshift.ts @@ -0,0 +1,420 @@ +/** + * 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 { GetObjectCommand, ListObjectsV2Command, S3Client } from '@aws-sdk/client-s3'; +import { createWriteStream, mkdirSync } from 'fs'; +import { readdir } from 'fs/promises'; +import { execSync } from 'child_process'; +import path from 'path'; +import { pipeline } from 'stream/promises'; +import type { Readable } from 'stream'; + +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, + "timestamp" timestamptz NOT NULL, + 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, + "communitySubdomain" text, + "communityName" text, + "isProd" boolean NOT NULL, + country text, + "countryCode" text, + url text, + title text, + hash text, + height integer, + width integer, + path text, + "pageTitle" text, + "pageId" uuid, + "pageSlug" text, + "collectionTitle" text, + "collectionKind" text, + "collectionId" uuid, + "collectionSlug" text, + "pubTitle" text, + "pubId" uuid, + "pubSlug" text, + "collectionIds" text[], + "primaryCollectionId" uuid, + release text, + format text, + "createdAt" timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS "analytics_events_community_event_ts" + ON "AnalyticsEvents" ("communityId", event, "timestamp"); +CREATE INDEX IF NOT EXISTS "analytics_events_pub_event_ts" + ON "AnalyticsEvents" ("pubId", event, "timestamp"); +CREATE INDEX IF NOT EXISTS "analytics_events_collection_event_ts" + ON "AnalyticsEvents" ("collectionId", event, "timestamp"); + +-- Optimized index for dashboard queries: all filter by communityId + time range +CREATE INDEX IF NOT EXISTS "analytics_events_community_ts" + ON "AnalyticsEvents" ("communityId", "timestamp"); + +-- Partial covering index for the common page-view aggregations +CREATE INDEX IF NOT EXISTS "analytics_events_community_pages" + ON "AnalyticsEvents" ("communityId", "timestamp", "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", "timestamp") + 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 +); +`; + +const TRANSFORM_SQL = ` +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; + +INSERT INTO "AnalyticsEvents" ( + id, type, event, "timestamp", + referrer, "isUnique", search, + "utmSource", "utmMedium", "utmCampaign", "utmTerm", "utmContent", + timezone, locale, "userAgent", os, + "communityId", "communitySubdomain", "communityName", "isProd", + country, "countryCode", + url, title, hash, height, width, path, + "pageTitle", "pageId", "pageSlug", + "collectionTitle", "collectionKind", "collectionId", "collectionSlug", + "pubTitle", "pubId", "pubSlug", + "collectionIds", "primaryCollectionId", release, format, + "createdAt" +) +SELECT + COALESCE(pg_temp.safe_uuid(s.__sdc_primary_key), gen_random_uuid()), + s.type, + s.event, + pg_temp.safe_ts(s."timestamp"), + + NULLIF(s.referrer, ''), + CASE WHEN s."unique" = 't' THEN true + WHEN s."unique" = 'f' THEN false + ELSE NULL END, + NULLIF(s.search, ''), + + NULLIF(s.utmsource, ''), + NULLIF(s.utmmedium, ''), + NULLIF(s.utmcampaign, ''), + NULLIF(s.utmterm, ''), + NULLIF(s.utmcontent, ''), + + COALESCE(NULLIF(s.timezone, ''), 'UTC'), + COALESCE(NULLIF(s.locale, ''), 'en-US'), + COALESCE(NULLIF(s.useragent, ''), 'unknown'), + COALESCE(NULLIF(s.os, ''), 'unknown'), + + pg_temp.safe_uuid(s.communityid), + NULLIF(s.communitysubdomain, ''), + NULLIF(s.communityname, ''), + COALESCE(s.isprod = 't', false), + + NULLIF(s.country, ''), + NULLIF(s.countrycode, ''), + + NULLIF(s.url, ''), + NULLIF(s.title, ''), + NULLIF(s.hash, ''), + CASE WHEN s.height ~ '^\\d{1,9}$' THEN s.height::int ELSE NULL END, + CASE WHEN s.width ~ '^\\d{1,9}$' THEN s.width::int ELSE NULL END, + NULLIF(s.path, ''), + + NULLIF(s.pagetitle, ''), + pg_temp.safe_uuid(s.pageid), + NULLIF(s.pageslug, ''), + + NULLIF(s.collectiontitle, ''), + NULLIF(s.collectionkind, ''), + pg_temp.safe_uuid(s.collectionid), + NULLIF(s.collectionslug, ''), + + NULLIF(s.pubtitle, ''), + pg_temp.safe_uuid(s.pubid), + NULLIF(s.pubslug, ''), + + CASE WHEN NULLIF(s.collectionids, '') IS NOT NULL + THEN string_to_array(s.collectionids, ',') + ELSE NULL END, + pg_temp.safe_uuid(s.primarycollectionid), + COALESCE(NULLIF(s.release__string, ''), NULLIF(s.release__bigint, '')), + NULLIF(s.format, ''), + + COALESCE( + CASE WHEN NULLIF(s._sdc_received_at, '') IS NOT NULL + THEN s._sdc_received_at::timestamptz + ELSE NULL END, + pg_temp.safe_ts(s."timestamp"), + now() + ) + +FROM analytics_staging s +WHERE s.type IS NOT NULL + AND s.event IS NOT NULL + AND pg_temp.safe_ts(s."timestamp") IS NOT NULL +ON CONFLICT (id) DO NOTHING; +`; + +// ─── main ──────────────────────────────────────────────────────────────────── + +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`); + } + + // 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/5] 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(TRANSFORM_SQL); + + // 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/6] 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(`[5/6] ${(finalCount as any).count} rows in AnalyticsEvents.`); + + log('[6/6] 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); + }); From 3483588d6c9978aa8fbcacd91260fa5f7ae34c9c Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Tue, 7 Apr 2026 16:15:03 -0400 Subject: [PATCH 23/41] Lint of course --- .../DashboardImpact/DashboardImpact.tsx | 22 +---- server/analytics/impactApi.ts | 83 +++++++++---------- server/analytics/model.ts | 2 +- server/analytics/summaryViews.ts | 3 +- server/apiRoutes.ts | 2 +- tools/migrateRedshift.ts | 29 ++++--- 6 files changed, 62 insertions(+), 79 deletions(-) diff --git a/client/containers/DashboardImpact/DashboardImpact.tsx b/client/containers/DashboardImpact/DashboardImpact.tsx index 5935c76a35..e324d5a719 100644 --- a/client/containers/DashboardImpact/DashboardImpact.tsx +++ b/client/containers/DashboardImpact/DashboardImpact.tsx @@ -195,18 +195,10 @@ const DashboardImpact = () => { details={`Learn more about who your ${activeTargetName} is reaching.`} controls={ - - - @@ -314,9 +302,7 @@ const DashboardImpact = () => { ({ ...c, - country: c.countryCode - ? countryName(c.countryCode) - : c.country, + country: c.countryCode ? countryName(c.countryCode) : c.country, }))} columns={[ { key: 'country', label: 'Country' }, diff --git a/server/analytics/impactApi.ts b/server/analytics/impactApi.ts index 8a3a45d44f..123c93ded9 100644 --- a/server/analytics/impactApi.ts +++ b/server/analytics/impactApi.ts @@ -101,18 +101,11 @@ async function fetchSummary(scope: Scope, startDate: string, endDate: string) { return fetchSummaryFromRaw(scope, startDate, endDate); } - const [ - daily, - countries, - topPubs, - topPages, - topCollections, - referrers, - campaigns, - ] = await Promise.all([ - // ── daily breakdown from matview - sequelize.query( - `SELECT + const [daily, countries, topPubs, topPages, topCollections, referrers, campaigns] = + await Promise.all([ + // ── daily breakdown from matview + sequelize.query( + `SELECT date::text, page_views AS "pageViews", unique_page_views AS "uniquePageViews", @@ -120,11 +113,11 @@ async function fetchSummary(scope: Scope, startDate: string, endDate: string) { FROM analytics_daily_summary WHERE ${mvWhere} ORDER BY date`, - { replacements, type: QueryTypes.SELECT }, - ), - // ── countries from matview - sequelize.query( - `SELECT + { replacements, type: QueryTypes.SELECT }, + ), + // ── countries from matview + sequelize.query( + `SELECT country, country_code AS "countryCode", SUM(count)::int AS count @@ -132,11 +125,11 @@ async function fetchSummary(scope: Scope, startDate: string, endDate: string) { WHERE ${mvWhere} GROUP BY country, country_code ORDER BY count DESC LIMIT 30`, - { replacements, type: QueryTypes.SELECT }, - ), - // ── top pubs from matview, JOIN Pubs for titles - sequelize.query( - `SELECT + { 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", mv."pubId"::text AS "pubId", SUM(mv.views)::int AS views, @@ -146,11 +139,11 @@ async function fetchSummary(scope: Scope, startDate: string, endDate: string) { 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 30`, - { replacements, type: QueryTypes.SELECT }, - ), - // ── top pages from matview - sequelize.query( - `SELECT + { replacements, type: QueryTypes.SELECT }, + ), + // ── top pages from matview + sequelize.query( + `SELECT page_title AS "pageTitle", path, SUM(count)::int AS count @@ -158,11 +151,11 @@ async function fetchSummary(scope: Scope, startDate: string, endDate: string) { WHERE ${mvWhere} GROUP BY page_title, path ORDER BY count DESC LIMIT 30`, - { replacements, type: QueryTypes.SELECT }, - ), - // ── top collections from matview - sequelize.query( - `SELECT + { replacements, type: QueryTypes.SELECT }, + ), + // ── top collections from matview + sequelize.query( + `SELECT COALESCE(c.title, mv."collectionId"::text) AS "collectionTitle", mv."collectionId"::text AS "collectionId", SUM(mv.count)::int AS count @@ -171,31 +164,31 @@ async function fetchSummary(scope: Scope, startDate: string, endDate: string) { WHERE mv."communityId" = :communityId AND mv.date >= :startDate::date AND mv.date <= :endDate::date GROUP BY mv."collectionId", c.title ORDER BY count DESC LIMIT 30`, - { replacements, type: QueryTypes.SELECT }, - ), - // ── referrers from matview - sequelize.query( - `SELECT + { 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 30`, - { replacements, type: QueryTypes.SELECT }, - ), - // ── campaigns from matview - sequelize.query( - `SELECT + { 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 30`, - { replacements, type: QueryTypes.SELECT }, - ), - ]); + { replacements, type: QueryTypes.SELECT }, + ), + ]); // Derive totals from the daily rows let totalPageViews = 0; diff --git a/server/analytics/model.ts b/server/analytics/model.ts index 5d11f200c5..b03f5f5f6d 100644 --- a/server/analytics/model.ts +++ b/server/analytics/model.ts @@ -1,6 +1,6 @@ import type { CreationOptional, InferAttributes, InferCreationAttributes } from 'sequelize'; -import { Op } from 'sequelize'; +import { Op } from 'sequelize'; import { AllowNull, Column, diff --git a/server/analytics/summaryViews.ts b/server/analytics/summaryViews.ts index b41a7e6d2e..f314a99b28 100644 --- a/server/analytics/summaryViews.ts +++ b/server/analytics/summaryViews.ts @@ -181,8 +181,7 @@ 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 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}`); diff --git a/server/apiRoutes.ts b/server/apiRoutes.ts index a1fec7fd81..ee8cdc0547 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'; @@ -14,7 +15,6 @@ import { router as discussionRouter } from './discussion/api'; import { router as doiRouter } from './doi/api'; import { router as draftCheckpointRouter } from './draftCheckpoint/api'; import { router as editorRouter } from './editor/api'; -import { router as analyticsImpactRouter } from './analytics/impactApi'; import { router as impact2Router } from './impact2/api'; import { router as integrationDataOAuth1Router } from './integrationDataOAuth1/api'; import { router as landingPageFeatureRouter } from './landingPageFeature/api'; diff --git a/tools/migrateRedshift.ts b/tools/migrateRedshift.ts index e5fb896f28..0ea6a8166b 100644 --- a/tools/migrateRedshift.ts +++ b/tools/migrateRedshift.ts @@ -14,13 +14,15 @@ * 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 { execSync } from 'child_process'; import path from 'path'; import { pipeline } from 'stream/promises'; -import type { Readable } from 'stream'; import { sequelize } from 'server/sequelize'; @@ -68,7 +70,9 @@ async function downloadFromS3(dataDir: string, force: boolean) { 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)`); + log( + `[1/5] backup already present (${existing.length} files), skipping (set FORCE_DOWNLOAD=1 to re-download)`, + ); return; } } @@ -357,9 +361,7 @@ async function main() { // 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(); + 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}`); @@ -377,16 +379,17 @@ async function main() { // 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' }, - ); + 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...`); + log( + ` [${fileIdx}/${files.length}] ${file}: staged ${(stagingCount as any).count} rows, transforming...`, + ); // Transform and insert into final table await sequelize.query(TRANSFORM_SQL); @@ -406,7 +409,9 @@ async function main() { log(`[5/6] ${(finalCount as any).count} rows in AnalyticsEvents.`); log('[6/6] creating & refreshing summary materialized views...'); - const { createSummaryViews, refreshSummaryViews } = await import('server/analytics/summaryViews'); + const { createSummaryViews, refreshSummaryViews } = await import( + 'server/analytics/summaryViews' + ); await createSummaryViews(); await refreshSummaryViews(); log('done.'); From 0b7c2c3793b4cdbe4a63d02a2166a6ce3ae9d559 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Tue, 7 Apr 2026 16:18:47 -0400 Subject: [PATCH 24/41] Fix tests --- server/analytics/__tests__/api.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/server/analytics/__tests__/api.test.ts b/server/analytics/__tests__/api.test.ts index 525f17f17d..7bc6bb5d0e 100644 --- a/server/analytics/__tests__/api.test.ts +++ b/server/analytics/__tests__/api.test.ts @@ -3,6 +3,7 @@ import { analyticsEventSchema, type basePageViewSchema, type PageViewPayload, + type pageViewSchema, type sharedEventPayloadSchema, } from 'utils/api/schemas/analytics'; @@ -25,8 +26,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 +39,7 @@ const makeTestPubPageViewPayload = (options?: Partial) => { release: 'draft', ...baseTestPayload, ...options, - } satisfies PageViewPayload & { event: 'pub' }; + } satisfies PubPageViewInput; }; type PagePageView = PageViewPayload & { event: 'page' }; From d2f6fd38c6e95b71ea27c1f9b63b3e478b6d37fd Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Tue, 7 Apr 2026 16:27:23 -0400 Subject: [PATCH 25/41] Remove deprecated on this branch --- analytics_schema.sql | 60 -- infra/duckdb-sync/Dockerfile | 20 - infra/duckdb-sync/sync.sh | 66 --- infra/metabase/Dockerfile | 34 -- tools/analytics-migration/Dockerfile | 37 -- tools/analytics-migration/README.md | 34 -- .../migrate-metabase-queries.ts | 517 ------------------ tools/analytics-migration/migrate-metabase.sh | 316 ----------- tools/analytics-migration/migrate-redshift.sh | 285 ---------- 9 files changed, 1369 deletions(-) delete mode 100644 analytics_schema.sql delete mode 100644 infra/duckdb-sync/Dockerfile delete mode 100644 infra/duckdb-sync/sync.sh delete mode 100644 infra/metabase/Dockerfile delete mode 100644 tools/analytics-migration/Dockerfile delete mode 100644 tools/analytics-migration/README.md delete mode 100644 tools/analytics-migration/migrate-metabase-queries.ts delete mode 100755 tools/analytics-migration/migrate-metabase.sh delete mode 100755 tools/analytics-migration/migrate-redshift.sh diff --git a/analytics_schema.sql b/analytics_schema.sql deleted file mode 100644 index 4dae156928..0000000000 --- a/analytics_schema.sql +++ /dev/null @@ -1,60 +0,0 @@ -CREATE TABLE pubpub_analytics.data( - __sdc_primary_key character varying(128) ENCODE lzo distkey, - _sdc_batched_at timestamp without time zone ENCODE az64, - _sdc_received_at timestamp without time zone ENCODE az64, - _sdc_sequence bigint ENCODE az64, - _sdc_table_version bigint ENCODE az64, - collectionid character varying(128) ENCODE lzo, - collectionkind character varying(128) ENCODE lzo, - communityid character varying(128) ENCODE lzo, - country character varying(128) ENCODE lzo, - countrycode character varying(128) ENCODE lzo, - event character varying(128) ENCODE lzo, - height bigint ENCODE az64, - isprod boolean ENCODE raw, - primarycollectionid character varying(128) ENCODE lzo, - pubid character varying(128) ENCODE lzo, - type character varying(128) ENCODE lzo, - unique boolean ENCODE raw, - width bigint ENCODE az64, - timestamp bigint ENCODE az64, - utmcontent character varying(128) ENCODE lzo, - utmmedium character varying(128) ENCODE lzo, - utmterm character varying(128) ENCODE lzo, - release__string character varying(128) ENCODE lzo, - path character varying(256) ENCODE lzo, - collectiontitle character varying(256) ENCODE lzo, - collectionslug character varying(256) ENCODE lzo, - pubslug character varying(256) ENCODE lzo, - communityname character varying(256) ENCODE lzo, - collectionids character varying(1024) ENCODE lzo, - release__bigint bigint ENCODE az64, - pagetitle character varying(256) ENCODE lzo, - referrer character varying(4096) ENCODE lzo, - utmcampaign character varying(256) ENCODE lzo, - utmsource character varying(512) ENCODE lzo, - timezone character varying(256) ENCODE lzo, - os character varying(256) ENCODE lzo, - pageid character varying(256) ENCODE lzo, - locale character varying(256) ENCODE lzo, - pageslug character varying(256) ENCODE lzo, - communitysubdomain character varying(256) ENCODE lzo, - format character varying(256) ENCODE lzo, - useragent character varying(8192) ENCODE lzo, - pubtitle character varying(1024) ENCODE lzo, - url character varying(4096) ENCODE lzo, - search character varying(4096) ENCODE lzo, - title character varying(16384) ENCODE lzo, - hash character varying(8192) ENCODE lzo) DISTSTYLE AUTO SORTKEY( - __sdc_primary_key -); - -CREATE TABLE pubpub_analytics.data__collectionids( - _sdc_batched_at timestamp without time zone ENCODE az64, - _sdc_level_0_id bigint ENCODE az64, - _sdc_received_at timestamp without time zone ENCODE az64, - _sdc_sequence bigint ENCODE az64, - _sdc_source_key___sdc_primary_key character varying(128) ENCODE lzo distkey, - _sdc_table_version bigint ENCODE az64, - value character varying(128) ENCODE lzo) DISTSTYLE AUTO; - diff --git a/infra/duckdb-sync/Dockerfile b/infra/duckdb-sync/Dockerfile deleted file mode 100644 index edbc4338b8..0000000000 --- a/infra/duckdb-sync/Dockerfile +++ /dev/null @@ -1,20 +0,0 @@ -FROM debian:bookworm-slim - -ARG DUCKDB_VERSION=v1.4.4 -ARG TARGETARCH - -RUN apt-get update && \ - apt-get install -y --no-install-recommends curl unzip ca-certificates && \ - rm -rf /var/lib/apt/lists/* && \ - curl -fsSL \ - "https://github.com/duckdb/duckdb/releases/download/${DUCKDB_VERSION}/duckdb_cli-linux-${TARGETARCH}.zip" \ - -o /tmp/duckdb.zip && \ - unzip /tmp/duckdb.zip -d /usr/local/bin/ && \ - rm /tmp/duckdb.zip && \ - chmod +x /usr/local/bin/duckdb && \ - duckdb -c "INSTALL postgres" - -COPY sync.sh /usr/local/bin/sync.sh -RUN chmod +x /usr/local/bin/sync.sh - -CMD ["/usr/local/bin/sync.sh"] diff --git a/infra/duckdb-sync/sync.sh b/infra/duckdb-sync/sync.sh deleted file mode 100644 index 536e24c01f..0000000000 --- a/infra/duckdb-sync/sync.sh +++ /dev/null @@ -1,66 +0,0 @@ -#!/bin/bash -set -euo pipefail - -DUCKDB_FILE="${DUCKDB_FILE:-/data/analytics.duckdb}" -PG_HOST="${PG_HOST}" -PG_PORT="${PG_PORT}" -PG_DB="${PG_DB}" -PG_USER="${PG_USER}" -PG_PASS="${PG_PASS}" - -SYNC_INTERVAL="${SYNC_INTERVAL:-43200}" -MODE="${1:-loop}" - -PG_CONN="dbname=${PG_DB} host=${PG_HOST} port=${PG_PORT} user=${PG_USER} password=${PG_PASS}" - -sync_analytics() { - local start - start=$(date +%s) - - duckdb "$DUCKDB_FILE" < ( - SELECT COALESCE(MAX("createdAt"), '1970-01-01'::TIMESTAMPTZ) - FROM "AnalyticsEvents" - ); - -DETACH pg; -SQL - - local count elapsed - count=$(duckdb "$DUCKDB_FILE" -csv -noheader "SELECT count(*) FROM \"AnalyticsEvents\"") - elapsed=$(( $(date +%s) - start )) - echo "[$(date -Iseconds)] sync complete: ${count} rows (${elapsed}s)" -} - -if [ "$MODE" = "--full" ]; then - echo "full resync requested, removing existing data..." - rm -f "$DUCKDB_FILE" -fi - -echo "duckdb analytics sync" -echo " postgres: ${PG_HOST}:${PG_PORT}/${PG_DB}" -echo " duckdb: ${DUCKDB_FILE}" -echo "" - -sync_analytics - -if [ "$MODE" = "--once" ] || [ "$MODE" = "--full" ]; then - exit 0 -fi - -echo "entering sync loop (interval: ${SYNC_INTERVAL}s)" - -while true; do - sleep "$SYNC_INTERVAL" - sync_analytics -done diff --git a/infra/metabase/Dockerfile b/infra/metabase/Dockerfile deleted file mode 100644 index 38c1176e6a..0000000000 --- a/infra/metabase/Dockerfile +++ /dev/null @@ -1,34 +0,0 @@ -FROM eclipse-temurin:21-jre-jammy - -ARG METABASE_VERSION=0.58.9 -ARG METABASE_DUCKDB_DRIVER_VERSION=1.4.4.0 - -ENV MB_PLUGINS_DIR=/home/metabase/plugins/ - -RUN groupadd -r metabase && useradd -r -g metabase metabase - -RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates \ - && rm -rf /var/lib/apt/lists/* - -RUN mkdir -p /home/metabase/plugins /home/metabase/data && \ - chown -R metabase:metabase /home/metabase - -WORKDIR /home/metabase - -ADD --chown=metabase:metabase \ - https://downloads.metabase.com/v${METABASE_VERSION}/metabase.jar \ - /home/metabase/ - -ADD --chown=metabase:metabase \ - https://github.com/motherduckdb/metabase_duckdb_driver/releases/download/${METABASE_DUCKDB_DRIVER_VERSION}/duckdb.metabase-driver.jar \ - /home/metabase/plugins/ - -RUN chmod 755 /home/metabase/metabase.jar && \ - chmod 755 /home/metabase/plugins/duckdb.metabase-driver.jar - -EXPOSE 3000 - -USER metabase - -CMD ["java", "-jar", "/home/metabase/metabase.jar"] diff --git a/tools/analytics-migration/Dockerfile b/tools/analytics-migration/Dockerfile deleted file mode 100644 index 60b0e8cefa..0000000000 --- a/tools/analytics-migration/Dockerfile +++ /dev/null @@ -1,37 +0,0 @@ -FROM node:22-bookworm-slim - -ARG DUCKDB_VERSION=v1.4.4 - -RUN apt-get update && \ - apt-get install -y --no-install-recommends \ - curl unzip ca-certificates postgresql-client awscli jq && \ - rm -rf /var/lib/apt/lists/* - -RUN install -m 0755 -d /etc/apt/keyrings && \ - curl -fsSL https://download.docker.com/linux/debian/gpg \ - -o /etc/apt/keyrings/docker.asc && \ - chmod a+r /etc/apt/keyrings/docker.asc && \ - echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \ - https://download.docker.com/linux/debian bookworm stable" \ - > /etc/apt/sources.list.d/docker.list && \ - apt-get update && \ - apt-get install -y --no-install-recommends docker-ce-cli && \ - rm -rf /var/lib/apt/lists/* - -RUN ARCH=$(dpkg --print-architecture) && \ - DUCKDB_ARCH=$ARCH && \ - curl -fsSL \ - "https://github.com/duckdb/duckdb/releases/download/${DUCKDB_VERSION}/duckdb_cli-linux-${DUCKDB_ARCH}.zip" \ - -o /tmp/duckdb.zip && \ - unzip /tmp/duckdb.zip -d /usr/local/bin/ && \ - rm /tmp/duckdb.zip && \ - chmod +x /usr/local/bin/duckdb && \ - duckdb -c "INSTALL postgres" - -RUN npm install -g tsx - -COPY migrate-redshift.sh migrate-metabase.sh migrate-metabase-queries.ts /migration/ -RUN chmod +x /migration/*.sh - -WORKDIR /migration -CMD ["bash"] diff --git a/tools/analytics-migration/README.md b/tools/analytics-migration/README.md deleted file mode 100644 index c3e12a9c8a..0000000000 --- a/tools/analytics-migration/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# analytics migration - -Migrates analytics data from AWS Redshift and the old Metabase RDS instance to the local stack (Postgres, DuckDB, Metabase). Run manually, not automated. - -## prerequisites - -1. Whitelist your IP in the AWS RDS security group for the old Metabase database, otherwise `pg_dump` will hang. -3. The dev stack should be running (`db` and `metabase_db` at minimum). - -## running - -Start the migration container: - - docker compose -f infra/docker-compose.dev.yml --profile migration run --rm analytics_migration - -Inside the container, run the two scripts in order: - - ./migrate-redshift.sh - ./migrate-metabase.sh - -The first script imports Redshift CSV data into the Postgres `AnalyticsEvents` table. The second sets up Metabase with the old dump, syncs DuckDB, configures database connections, and migrates saved questions. - -Both scripts cache downloaded data on a persistent volume. To force re-downloading: - - FORCE_DOWNLOAD=1 ./migrate-redshift.sh - FORCE_DOWNLOAD=1 ./migrate-metabase.sh - -## cleanup - -Once the migration is verified and no longer needs to be re-run: - - docker volume rm pubpub_v6_dev_analytics_migration_data - -Remove the `AM_*` variables from `infra/.env.dev`. diff --git a/tools/analytics-migration/migrate-metabase-queries.ts b/tools/analytics-migration/migrate-metabase-queries.ts deleted file mode 100644 index d802db5b93..0000000000 --- a/tools/analytics-migration/migrate-metabase-queries.ts +++ /dev/null @@ -1,517 +0,0 @@ -// migrates metabase saved questions from a redshift-backed database to -// the new postgres database. uses the metabase API exclusively -- -// no parquet, no direct db access. - -// ── column/table name mapping ── - -const ANALYTICS_COLUMN_MAP: Record = { - collectionid: 'collectionId', - collectionkind: 'collectionKind', - communityid: 'communityId', - communityname: 'communityName', - communitysubdomain: 'communitySubdomain', - countrycode: 'countryCode', - isprod: 'isProd', - primarycollectionid: 'primaryCollectionId', - pubid: 'pubId', - pubtitle: 'pubTitle', - pubslug: 'pubSlug', - pagetitle: 'pageTitle', - pageid: 'pageId', - pageslug: 'pageSlug', - useragent: 'userAgent', - utmsource: 'utmSource', - utmmedium: 'utmMedium', - utmcampaign: 'utmCampaign', - utmterm: 'utmTerm', - utmcontent: 'utmContent', - collectionids: 'collectionIds', - collectiontitle: 'collectionTitle', - collectionslug: 'collectionSlug', - unique: 'isUnique', - release__string: 'release', - - // dropped columns - release__bigint: null, - __sdc_primary_key: null, - _sdc_batched_at: null, - _sdc_received_at: null, - _sdc_sequence: null, - _sdc_table_version: null, -}; - -const ANALYTICS_TABLE_OLD = 'data'; -const ANALYTICS_TABLE_NEW = 'AnalyticsEvents'; - -// ── types ── - -type Table = { - id: number; - name: string; - schema: string; - db_id: number; - fields: Field[]; -}; - -type Field = { - id: number; - name: string; - table_id: number; -}; - -type Card = { - id: number; - name: string; - description: string | null; - display: string; - collection_id: number; - dataset_query: DatasetQuery; - visualization_settings: Record; -}; - -type DatasetQuery = { - type: 'query' | 'native'; - database: number; - query?: Record; - native?: { query: string; 'template-tags'?: Record }; -}; - -type IdMaps = { - tableIdMap: Record; - fieldIdMap: Record; - cardIdMap: Record; -}; - -// ── metabase api ── - -async function metabaseRequest( - baseUrl: string, - session: string, - method: string, - path: string, - body?: unknown, -): Promise { - const headers: Record = { 'X-Metabase-Session': session }; - - if (body) { - headers['Content-Type'] = 'application/json'; - } - - const res = await fetch(`${baseUrl}/api${path}`, { - method, - headers, - ...(body ? { body: JSON.stringify(body) } : {}), - }); - - if (!res.ok) { - const text = await res.text(); - throw new Error(`${method} ${path}: ${res.status} ${text}`); - } - - return res.json(); -} - -// ── field mapping ── - -function buildFieldMapping(oldTables: Table[], newTables: Table[]) { - const tableIdMap: Record = {}; - const fieldIdMap: Record = {}; - const warnings: string[] = []; - - const newByName = new Map(); - for (const t of newTables) { - newByName.set(t.name.toLowerCase(), t); - } - - for (const oldTable of oldTables) { - const isAnalytics = oldTable.name.toLowerCase() === ANALYTICS_TABLE_OLD; - const targetName = isAnalytics ? ANALYTICS_TABLE_NEW : oldTable.name; - const newTable = newByName.get(targetName.toLowerCase()); - - if (!newTable) { - if (oldTable.name.startsWith('_sdc_')) continue; - warnings.push(`table not mapped: ${oldTable.name}`); - continue; - } - - tableIdMap[oldTable.id] = newTable.id; - - const newFieldsByName = new Map(); - for (const f of newTable.fields) { - newFieldsByName.set(f.name.toLowerCase(), f); - } - - for (const oldField of oldTable.fields) { - let targetFieldName: string | null; - - if (isAnalytics) { - const mapped = ANALYTICS_COLUMN_MAP[oldField.name.toLowerCase()]; - targetFieldName = mapped === undefined ? oldField.name : mapped; - } else { - targetFieldName = oldField.name; - } - - if (targetFieldName === null) continue; - - const newField = - newFieldsByName.get(targetFieldName) || - newFieldsByName.get(targetFieldName.toLowerCase()); - - if (newField) { - fieldIdMap[oldField.id] = newField.id; - } - } - } - - return { tableIdMap, fieldIdMap, warnings }; -} - -// ── mbql query remapping ── - -function remapQuery( - obj: Record | unknown[] | string | undefined, - maps: IdMaps, -): Record | unknown[] | string | undefined { - if (Array.isArray(obj)) { - if (obj.length >= 2 && obj[0] === 'field' && typeof obj[1] === 'number') { - const mapped = maps.fieldIdMap[obj[1]] ?? obj[1]; - return ['field', mapped, ...obj.slice(2).map((x) => remapQuery(x as any, maps))]; - } - - return obj.map((item) => remapQuery(item as any, maps)); - } - - if (obj && typeof obj === 'object') { - const result: Record = {}; - - for (const [k, v] of Object.entries(obj)) { - if (k === 'source-table' && typeof v === 'number') { - result[k] = maps.tableIdMap[v] ?? v; - } else if (k === 'source-table' && typeof v === 'string' && v.startsWith('card__')) { - const oldId = parseInt(v.replace('card__', ''), 10); - result[k] = `card__${maps.cardIdMap[oldId] ?? oldId}`; - } else { - result[k] = remapQuery(v as any, maps); - } - } - - return result; - } - - return obj; -} - -// ── native sql remapping ── - -function remapNativeSql(sql: string): string { - let result = sql; - - // schema-qualified analytics table references - result = result.replace(/"pubpub_analytics"\s*\.\s*"data"/gi, `"${ANALYTICS_TABLE_NEW}"`); - result = result.replace(/pubpub_analytics\s*\.\s*data/gi, `"${ANALYTICS_TABLE_NEW}"`); - result = result.replace(/"pubpub_analytics"\s*\.\s*/g, ''); - - // unqualified analytics table - result = result.replace(/\bFROM\s+"data"/gi, `FROM "${ANALYTICS_TABLE_NEW}"`); - result = result.replace(/\bJOIN\s+"data"/gi, `JOIN "${ANALYTICS_TABLE_NEW}"`); - - // analytics column names (quoted) - for (const [oldName, newName] of Object.entries(ANALYTICS_COLUMN_MAP)) { - if (newName === null) continue; - if (oldName === newName) continue; - - result = result.replace(new RegExp(`"${oldName}"`, 'gi'), `"${newName}"`); - } - - // redshift timestamp conversion pattern -> just use the column directly - result = result.replace( - /TIMESTAMP\s+'1970-01-01T00:00:00Z'\s*\+\s*"?timestamp"?\s*\*\s*INTERVAL\s+'1 second'\s*\/\s*1000/gi, - '"timestamp"', - ); - - // strip _sdc_ column references (with optional leading comma) - result = result.replace(/,?\s*"?_sdc_\w+"?\s*/g, ' '); - - return result; -} - -// ── topological sort (for card->card references) ── - -function findCardRefs(obj: unknown): number[] { - const refs: number[] = []; - - const walk = (o: unknown) => { - if (!o) return; - - if (typeof o === 'string' && o.startsWith('card__')) { - refs.push(parseInt(o.replace('card__', ''), 10)); - } - - if (Array.isArray(o)) { - o.forEach(walk); - } else if (typeof o === 'object') { - Object.values(o as Record).forEach(walk); - } - }; - - walk(obj); - return refs; -} - -function topologicalSort(cards: Card[], targetIds: Set): number[] { - const sorted: number[] = []; - const visited = new Set(); - const visiting = new Set(); - - const cardById = new Map(cards.map((c) => [c.id, c])); - - const visit = (id: number) => { - if (visited.has(id) || visiting.has(id)) return; - - visiting.add(id); - - const card = cardById.get(id); - if (card) { - for (const ref of findCardRefs(card.dataset_query)) { - if (targetIds.has(ref)) visit(ref); - } - } - - visiting.delete(id); - visited.add(id); - sorted.push(id); - }; - - for (const id of targetIds) visit(id); - - return sorted; -} - -// ── arg parsing ── - -function parseArgs(argv: string[]) { - const result: Record = {}; - - for (let i = 0; i < argv.length; i++) { - if (!argv[i].startsWith('--')) continue; - - const key = argv[i].replace(/^--/, ''); - const next = argv[i + 1]; - - if (next && !next.startsWith('--')) { - result[key] = next; - i++; - } else { - result[key] = 'true'; - } - } - - return result; -} - -// ── main ── - -async function main() { - const raw = parseArgs(process.argv.slice(2)); - - const metabaseUrl = raw['metabase-url'] ?? 'http://localhost:3030'; - const session = raw.session ?? ''; - const oldDbId = parseInt(raw['old-db-id'] ?? '0', 10); - const newDbId = parseInt(raw['new-db-id'] ?? '0', 10); - const collectionId = parseInt(raw.collection ?? '14', 10); - const dryRun = raw['dry-run'] === 'true'; - - const isMissingArgs = !session || !oldDbId || !newDbId; - if (isMissingArgs) { - console.error('required: --session, --old-db-id, --new-db-id'); - process.exit(1); - } - - const get = (path: string) => metabaseRequest(metabaseUrl, session, 'GET', path); - const post = (path: string, body: unknown) => - metabaseRequest(metabaseUrl, session, 'POST', path, body); - - // fetch metadata for old and new databases in parallel - const [oldDb, newDb] = await Promise.all([ - get(`/database/${oldDbId}/metadata?include_hidden=true`), - get(`/database/${newDbId}/metadata?include_hidden=true`), - ]); - - const { tableIdMap, fieldIdMap, warnings } = buildFieldMapping( - oldDb.tables || [], - newDb.tables || [], - ); - - console.log( - ` mapped ${Object.keys(tableIdMap).length} tables, ${Object.keys(fieldIdMap).length} fields`, - ); - - for (const w of warnings) { - console.log(` warning: ${w}`); - } - - // fetch cards in the target collection - const collectionItems = await get( - `/collection/${collectionId}/items?models=card&models=dataset`, - ); - - const itemList = collectionItems.data || collectionItems; - const cardIds: number[] = itemList.map((item: { id: number }) => item.id); - - if (cardIds.length === 0) { - console.log(' no cards found in collection'); - return; - } - - const cards: Card[] = await Promise.all(cardIds.map((id) => get(`/card/${id}`))); - const sortedIds = topologicalSort(cards, new Set(cardIds)); - const cardById = new Map(cards.map((c) => [c.id, c])); - - // create target collection - const { name: oldCollectionName } = await get(`/collection/${collectionId}`); - const newCollectionName = `Migrated - ${oldCollectionName}`; - - if (dryRun) { - console.log(`\n dry run: would create collection '${newCollectionName}'`); - console.log(` dry run: would migrate ${sortedIds.length} cards\n`); - - for (const id of sortedIds) { - const card = cardById.get(id)!; - const dq = card.dataset_query; - - console.log(`--- ${card.name} (id=${id}, ${dq.type}) ---`); - - if (dq.type === 'native') { - console.log(remapNativeSql(dq.native?.query || '')); - } else { - const remapped = remapQuery(dq.query, { tableIdMap, fieldIdMap, cardIdMap: {} }); - console.log(JSON.stringify(remapped, null, 2)); - } - - console.log(); - } - - return; - } - - const newCollection = await post('/collection', { - name: newCollectionName, - description: `auto-migrated from '${oldCollectionName}'`, - }); - - // migrate cards - const cardIdMap: Record = {}; - const maps: IdMaps = { tableIdMap, fieldIdMap, cardIdMap }; - let succeeded = 0; - let failed = 0; - - for (const oldId of sortedIds) { - const card = cardById.get(oldId); - if (!card) continue; - - const dq = card.dataset_query; - let newDatasetQuery: DatasetQuery; - - if (dq.type === 'native') { - newDatasetQuery = { - type: 'native', - database: newDbId, - native: { - query: remapNativeSql(dq.native?.query || ''), - 'template-tags': dq.native?.['template-tags'] || {}, - }, - }; - } else { - newDatasetQuery = { - type: 'query', - database: newDbId, - query: remapQuery(dq.query, maps) as Record, - }; - } - - try { - // biome-ignore lint/performance/noAwaitInLoops: who cares - const newCard = await post('/card', { - name: card.name, - description: card.description, - display: card.display, - dataset_query: newDatasetQuery, - collection_id: newCollection.id, - visualization_settings: card.visualization_settings, - }); - - cardIdMap[oldId] = newCard.id; - console.log(` created: ${card.name} (${oldId} -> ${newCard.id})`); - succeeded++; - } catch (e) { - console.error(` FAILED: ${card.name} (${oldId}): ${e}`); - cardIdMap[oldId] = oldId; - failed++; - } - } - - // migrate dashboards in the collection - const dashboardItems = await get(`/collection/${collectionId}/items?models=dashboard`); - const dashList = dashboardItems.data || dashboardItems; - - for (const item of dashList) { - try { - // biome-ignore lint/performance/noAwaitInLoops: who cares - const oldDash = await get(`/dashboard/${item.id}`); - - const newDash = await post('/dashboard', { - name: oldDash.name, - description: oldDash.description, - collection_id: newCollection.id, - parameters: oldDash.parameters || [], - }); - - const oldCards = oldDash.ordered_cards || oldDash.dashcards || []; - const dashCards = [] as { - id: number; - card_id: number; - row: number; - col: number; - size_x: number; - size_y: number; - parameter_mappings: unknown; - visualization_settings: unknown; - }[]; - - for (let i = 0; i < oldCards.length; i++) { - const dc = oldCards[i]; - const oldCardId = dc.card_id; - const newCardId = cardIdMap[oldCardId] ?? oldCardId; - const remappedParams = remapQuery(dc.parameter_mappings || [], maps); - - dashCards.push({ - id: -(i + 1), - card_id: newCardId, - row: dc.row ?? 0, - col: dc.col ?? 0, - size_x: dc.size_x ?? 6, - size_y: dc.size_y ?? 4, - parameter_mappings: remappedParams, - visualization_settings: dc.visualization_settings || {}, - }); - } - - if (dashCards.length > 0) { - await metabaseRequest(metabaseUrl, session, 'PUT', `/dashboard/${newDash.id}`, { - dashcards: dashCards, - }); - } - - console.log(` created dashboard: ${oldDash.name} (${oldCards.length} cards)`); - } catch (e) { - console.error(` FAILED dashboard: ${item.name}: ${e}`); - } - } - - console.log(`\n migration complete: ${succeeded} succeeded, ${failed} failed`); -} - -main().catch((e) => { - console.error(e); - process.exit(1); -}); diff --git a/tools/analytics-migration/migrate-metabase.sh b/tools/analytics-migration/migrate-metabase.sh deleted file mode 100755 index 832ca37fc8..0000000000 --- a/tools/analytics-migration/migrate-metabase.sh +++ /dev/null @@ -1,316 +0,0 @@ -#!/bin/bash -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -DATA_DIR="${DATA_DIR:-/data}" -DUMP_FILE="$DATA_DIR/metabase_dump.sql" -FORCE="${FORCE_DOWNLOAD:-0}" -COLLECTION_ID="${AM_MB_COLLECTION_ID:-14}" - -DATABASE_URL="${DATABASE_URL:-postgres://appuser:apppassword@db:5432/appdb}" - -MB_HOST="${MB_DB_HOST:-metabase_db}" -MB_PORT="${MB_DB_PORT:-5432}" -MB_USER="${MB_DB_USER:-appuser}" -MB_PASS="${MB_DB_PASS:-apppassword}" -MB_NAME="${MB_DB_NAME:-metabasedb}" - -ADMIN_EMAIL="${AM_MB_RDS_ADMIN_EMAIL:?set AM_MB_RDS_ADMIN_EMAIL}" -ADMIN_PASSWORD="${AM_MB_RDS_ADMIN_PASSWORD:?set AM_MB_RDS_ADMIN_PASSWORD}" - -MB_DB_URL="postgres://${MB_USER}:${MB_PASS}@${MB_HOST}:${MB_PORT}/${MB_NAME}" -MB_ADMIN_URL="postgres://${MB_USER}:${MB_PASS}@${MB_HOST}:${MB_PORT}/postgres" -MB_URL="${MB_URL:-http://metabase:3001}" - -DUCKDB_FILE="${DUCKDB_FILE:-/duckdb/analytics.duckdb}" - -parse_database_url() { - local url="${1#postgres://}" - url="${url#postgresql://}" - - PG_USER_MAIN="${url%%:*}"; url="${url#*:}" - PG_PASS_MAIN="${url%%@*}"; url="${url#*@}" - PG_HOST_MAIN="${url%%:*}"; url="${url#*:}" - PG_PORT_MAIN="${url%%/*}" - PG_DB_MAIN="${url#*/}" -} - -parse_database_url "$DATABASE_URL" - -METABASE_STOPPED=0 -cleanup() { - if [ "$METABASE_STOPPED" = "1" ] && command -v docker &>/dev/null; then - echo "restarting metabase..." - local cid - cid=$(docker ps -aq --filter "label=com.docker.compose.service=metabase" 2>/dev/null | head -1 || true) - - if [ -n "$cid" ]; then - docker start "$cid" 2>/dev/null || true - fi - fi -} -trap cleanup EXIT - -echo "metabase migration" -echo " metabase: $MB_URL" -echo " metabase db: ${MB_HOST}:${MB_PORT}/${MB_NAME}" -echo " main db: ${PG_HOST_MAIN}:${PG_PORT_MAIN}/${PG_DB_MAIN}" -echo " collection: $COLLECTION_ID" -echo "" - -# ── 1. get the dump ── - -NEEDS_DOWNLOAD=0 -if [ ! -f "$DUMP_FILE" ]; then - NEEDS_DOWNLOAD=1 -elif [ "$FORCE" = "1" ]; then - NEEDS_DOWNLOAD=1 -fi - -if [ "$NEEDS_DOWNLOAD" = "1" ]; then - : "${AM_MB_RDS_URL:?no dump at $DUMP_FILE and AM_MB_RDS_URL is not set}" - echo "[1/10] downloading metabase dump from RDS..." - mkdir -p "$DATA_DIR" - pg_dump "$AM_MB_RDS_URL" --no-owner --no-acl > "$DUMP_FILE" - echo " saved to $DUMP_FILE" -else - echo "[1/10] dump already present at $DUMP_FILE (set FORCE_DOWNLOAD=1 to re-download)" -fi - -# ── 2. stop metabase ── - -echo "[2/10] stopping metabase..." -if command -v docker &>/dev/null; then - METABASE_CID=$(docker ps -q --filter "label=com.docker.compose.service=metabase" 2>/dev/null || true) - - if [ -n "$METABASE_CID" ]; then - docker stop "$METABASE_CID" >/dev/null - METABASE_STOPPED=1 - echo " stopped" - else - echo " not running" - fi -else - echo " docker not available, make sure metabase is stopped" -fi - -# ── 3. wait for metabase_db ── - -echo "[3/10] waiting for metabase_db..." -until PGPASSWORD="$MB_PASS" pg_isready -h "$MB_HOST" -p "$MB_PORT" -U "$MB_USER" -q 2>/dev/null; do - sleep 1 -done -echo " ready" - -# ── 4. reset the database ── - -echo "[4/10] resetting metabase database..." -psql "$MB_ADMIN_URL" -q -c \ - "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${MB_NAME}' AND pid != pg_backend_pid();" \ - 2>/dev/null || true - -psql "$MB_ADMIN_URL" -q -c "DROP DATABASE IF EXISTS \"${MB_NAME}\";" -psql "$MB_ADMIN_URL" -q -c "CREATE DATABASE \"${MB_NAME}\" OWNER \"${MB_USER}\";" - -# ── 5. import dump ── - -echo "[5/10] importing dump..." -psql "$MB_DB_URL" -q < "$DUMP_FILE" - -CARD_COUNT=$(psql "$MB_DB_URL" -tA -c "SELECT count(*) FROM report_card") -echo " $CARD_COUNT saved questions imported" - -# ── 6. patch settings ── - -echo "[6/10] patching settings..." -psql "$MB_DB_URL" -q <<'SQL' -UPDATE setting -SET value = REPLACE(value, 'US/Eastern', 'America/New_York') -WHERE value LIKE '%US/Eastern%'; - -UPDATE metabase_database -SET details = REPLACE(details, 'US/Eastern', 'America/New_York') -WHERE details LIKE '%US/Eastern%'; - -UPDATE core_user -SET settings = REPLACE(settings, 'US/Eastern', 'America/New_York') -WHERE settings LIKE '%US/Eastern%'; -SQL -echo " timezone references fixed" - -# ── 7. duckdb sync ── - -echo "[7/10] running full DuckDB sync..." -rm -f "$DUCKDB_FILE" - -duckdb "$DUCKDB_FILE" </dev/null; then - METABASE_CID=$(docker ps -aq --filter "label=com.docker.compose.service=metabase" 2>/dev/null | head -1 || true) - - if [ -n "$METABASE_CID" ]; then - docker start "$METABASE_CID" >/dev/null - METABASE_STOPPED=0 - else - echo " WARNING: could not find metabase container, start it manually" - fi -else - echo " docker not available, start metabase manually" -fi - -echo -n " waiting for ready" -for i in $(seq 1 180); do - if curl -sf "${MB_URL}/api/health" >/dev/null 2>&1; then - echo " (${i}s)" - break - fi - - if [ "$i" -eq 180 ]; then - echo "" - echo " ERROR: metabase not ready after 180s" - exit 1 - fi - - echo -n "." - sleep 1 -done - -# ── 9. configure database connections ── - -echo "[9/10] configuring database connections..." - -SESSION=$(curl -sf "${MB_URL}/api/session" \ - -H 'Content-Type: application/json' \ - -d "{\"username\":\"${ADMIN_EMAIL}\",\"password\":\"${ADMIN_PASSWORD}\"}" \ - | jq -r '.id') - -# postgres connection - -EXISTING_PG=$(curl -sf "${MB_URL}/api/database" \ - -H "X-Metabase-Session: ${SESSION}" \ - | jq -r '[(.data // .)[] | select(.name == "PubPub")] | first | .id // empty') - -if [ -n "$EXISTING_PG" ]; then - echo " removing existing 'PubPub' database (id=$EXISTING_PG)..." - curl -sf "${MB_URL}/api/database/${EXISTING_PG}" \ - -H "X-Metabase-Session: ${SESSION}" \ - -X DELETE >/dev/null -fi - -NEW_DB_ID=$(curl -sf "${MB_URL}/api/database" \ - -H "X-Metabase-Session: ${SESSION}" \ - -H 'Content-Type: application/json' \ - -X POST \ - -d "{ - \"name\": \"PubPub\", - \"engine\": \"postgres\", - \"details\": { - \"host\": \"${PG_HOST_MAIN}\", - \"port\": ${PG_PORT_MAIN}, - \"dbname\": \"${PG_DB_MAIN}\", - \"user\": \"${PG_USER_MAIN}\", - \"password\": \"${PG_PASS_MAIN}\", - \"ssl\": false - }, - \"is_full_sync\": true, - \"auto_run_queries\": true - }" | jq -r '.id') - -echo " created database 'PubPub' (id=${NEW_DB_ID})" - -curl -sf "${MB_URL}/api/database/${NEW_DB_ID}/sync_schema" \ - -H "X-Metabase-Session: ${SESSION}" \ - -X POST >/dev/null - -echo -n " waiting for schema sync" -for i in $(seq 1 120); do - STATUS=$(curl -sf "${MB_URL}/api/database/${NEW_DB_ID}" \ - -H "X-Metabase-Session: ${SESSION}" \ - | jq -r '.initial_sync_status // "incomplete"' 2>/dev/null || echo "incomplete") - - if [ "$STATUS" = "complete" ]; then - echo " (${i}s)" - break - fi - - if [ "$i" -eq 120 ]; then - echo "" - echo " WARNING: sync not complete after 120s, continuing anyway" - fi - - echo -n "." - sleep 2 -done - -# duckdb connection - -EXISTING_DUCK=$(curl -sf "${MB_URL}/api/database" \ - -H "X-Metabase-Session: ${SESSION}" \ - | jq -r '[(.data // .)[] | select(.name == "Analytics (DuckDB)")] | first | .id // empty') - -if [ -n "$EXISTING_DUCK" ]; then - echo " removing existing 'Analytics (DuckDB)' database (id=$EXISTING_DUCK)..." - curl -sf "${MB_URL}/api/database/${EXISTING_DUCK}" \ - -H "X-Metabase-Session: ${SESSION}" \ - -X DELETE >/dev/null -fi - -DUCKDB_ID=$(curl -sf "${MB_URL}/api/database" \ - -H "X-Metabase-Session: ${SESSION}" \ - -H 'Content-Type: application/json' \ - -X POST \ - -d '{ - "name": "Analytics (DuckDB)", - "engine": "duckdb", - "details": { - "database_file": "/duckdb/analytics.duckdb", - "read_only": true - }, - "is_full_sync": true, - "auto_run_queries": true - }' | jq -r '.id') - -echo " created database 'Analytics (DuckDB)' (id=${DUCKDB_ID})" - -curl -sf "${MB_URL}/api/database/${DUCKDB_ID}/sync_schema" \ - -H "X-Metabase-Session: ${SESSION}" \ - -X POST >/dev/null -echo " sync triggered" - -# ── 10. migrate queries ── - -OLD_DB_ID=$(psql "$MB_DB_URL" -tA -c "SELECT id FROM metabase_database WHERE engine = 'redshift' ORDER BY id LIMIT 1") - -if [ -z "$OLD_DB_ID" ]; then - echo "[10/10] no redshift database found in dump, skipping query migration" - echo "" - echo "done. open metabase to verify." - exit 0 -fi - -echo "[10/10] migrating saved questions (redshift db=$OLD_DB_ID -> postgres db=$NEW_DB_ID)..." - -tsx "${SCRIPT_DIR}/migrate-metabase-queries.ts" \ - --metabase-url "$MB_URL" \ - --session "$SESSION" \ - --old-db-id "$OLD_DB_ID" \ - --new-db-id "$NEW_DB_ID" \ - --collection "$COLLECTION_ID" - -echo "" -echo "done. open metabase to verify." diff --git a/tools/analytics-migration/migrate-redshift.sh b/tools/analytics-migration/migrate-redshift.sh deleted file mode 100755 index 5aa68653de..0000000000 --- a/tools/analytics-migration/migrate-redshift.sh +++ /dev/null @@ -1,285 +0,0 @@ -#!/bin/bash -set -euo pipefail - -DATA_DIR="${DATA_DIR:-/data/redshift}" -FORCE="${FORCE_DOWNLOAD:-0}" -DATABASE_URL="${DATABASE_URL:-postgres://appuser:apppassword@db:5432/appdb}" - -export AWS_ACCESS_KEY_ID="${AM_REDSHIFT_BACKUP_ACCESS_KEY:?set AM_REDSHIFT_BACKUP_ACCESS_KEY}" -export AWS_SECRET_ACCESS_KEY="${AM_REDSHIFT_BACKUP_SECRET_KEY:?set AM_REDSHIFT_BACKUP_SECRET_KEY}" -S3_PATH="${AM_REDSHIFT_PATH:?set AM_REDSHIFT_PATH}" - -STAGING_CREATED=0 -cleanup() { - if [ "$STAGING_CREATED" = "1" ]; then - echo "cleaning up staging table..." - psql "$DATABASE_URL" -c "DROP TABLE IF EXISTS analytics_staging;" 2>/dev/null || true - fi -} -trap cleanup EXIT - -echo "redshift migration" -echo " database: ${DATABASE_URL%%@*}@..." -echo " s3 path: $S3_PATH" -echo " data dir: $DATA_DIR" -echo "" - -# ── step 1: download from s3 ── - -if [ -f "$DATA_DIR/data000" ] && [ "$FORCE" != "1" ]; then - echo "[1/5] backup already present, skipping (set FORCE_DOWNLOAD=1 to re-download)" -else - echo "[1/5] downloading redshift backup from s3..." - mkdir -p "$DATA_DIR" - aws s3 cp "$S3_PATH" "$DATA_DIR" --recursive -fi - -# ── step 2: ensure AnalyticsEvents table exists ── -# this is a safety net; the table may already exist via sequelize migrations. - -echo "[2/5] ensuring AnalyticsEvents table..." -psql "$DATABASE_URL" <<'SQL' -CREATE TABLE IF NOT EXISTS "AnalyticsEvents" ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - type text NOT NULL, - event text NOT NULL, - "timestamp" timestamptz NOT NULL, - 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, - "communitySubdomain" text, - "communityName" text, - "isProd" boolean NOT NULL, - country text, - "countryCode" text, - url text, - title text, - hash text, - height integer, - width integer, - path text, - "pageTitle" text, - "pageId" uuid, - "pageSlug" text, - "collectionTitle" text, - "collectionKind" text, - "collectionId" uuid, - "collectionSlug" text, - "pubTitle" text, - "pubId" uuid, - "pubSlug" text, - "collectionIds" text[], - "primaryCollectionId" uuid, - release text, - format text, - "createdAt" timestamptz NOT NULL DEFAULT now() -); - -CREATE INDEX IF NOT EXISTS "analytics_events_community_event_ts" - ON "AnalyticsEvents" ("communityId", event, "timestamp"); -CREATE INDEX IF NOT EXISTS "analytics_events_pub_event_ts" - ON "AnalyticsEvents" ("pubId", event, "timestamp"); -CREATE INDEX IF NOT EXISTS "analytics_events_collection_event_ts" - ON "AnalyticsEvents" ("collectionId", event, "timestamp"); -SQL - -EXISTING_COUNT=$(psql "$DATABASE_URL" -tA -c 'SELECT count(*) FROM "AnalyticsEvents"' 2>/dev/null || echo "0") -if [ "$EXISTING_COUNT" != "0" ]; then - echo " table already has $EXISTING_COUNT rows, duplicates will be skipped" -fi - -# ── step 3: create staging table and import csv ── -# uses an unlogged table for faster bulk import. all columns are text because -# the redshift backup uses different column names and types. - -echo "[3/5] creating staging table..." -STAGING_CREATED=1 -psql "$DATABASE_URL" <<'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 -); -SQL - -echo "[4/5] importing csv files into staging..." -for f in "$DATA_DIR"/data[0-9][0-9][0-9]; do - echo " $(basename "$f")..." - psql "$DATABASE_URL" -c "\copy analytics_staging FROM '$f' CSV HEADER" -done - -ROW_COUNT=$(psql "$DATABASE_URL" -tA -c "SELECT count(*) FROM analytics_staging") -echo " staged $ROW_COUNT rows" - -# ── step 5: transform and insert ── - -echo "[5/5] transforming into AnalyticsEvents..." -psql "$DATABASE_URL" -v ON_ERROR_STOP=1 <<'SQL' -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; - --- handles scientific notation (e.g. 1.71974e+14) via double precision cast -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; - -INSERT INTO "AnalyticsEvents" ( - id, type, event, "timestamp", - referrer, "isUnique", search, - "utmSource", "utmMedium", "utmCampaign", "utmTerm", "utmContent", - timezone, locale, "userAgent", os, - "communityId", "communitySubdomain", "communityName", "isProd", - country, "countryCode", - url, title, hash, height, width, path, - "pageTitle", "pageId", "pageSlug", - "collectionTitle", "collectionKind", "collectionId", "collectionSlug", - "pubTitle", "pubId", "pubSlug", - "collectionIds", "primaryCollectionId", release, format, - "createdAt" -) -SELECT - COALESCE(pg_temp.safe_uuid(s.__sdc_primary_key), gen_random_uuid()), - s.type, - s.event, - pg_temp.safe_ts(s."timestamp"), - - NULLIF(s.referrer, ''), - CASE WHEN s."unique" = 't' THEN true - WHEN s."unique" = 'f' THEN false - ELSE NULL END, - NULLIF(s.search, ''), - - NULLIF(s.utmsource, ''), - NULLIF(s.utmmedium, ''), - NULLIF(s.utmcampaign, ''), - NULLIF(s.utmterm, ''), - NULLIF(s.utmcontent, ''), - - COALESCE(NULLIF(s.timezone, ''), 'UTC'), - COALESCE(NULLIF(s.locale, ''), 'en-US'), - COALESCE(NULLIF(s.useragent, ''), 'unknown'), - COALESCE(NULLIF(s.os, ''), 'unknown'), - - pg_temp.safe_uuid(s.communityid), - NULLIF(s.communitysubdomain, ''), - NULLIF(s.communityname, ''), - COALESCE(s.isprod = 't', false), - - NULLIF(s.country, ''), - NULLIF(s.countrycode, ''), - - NULLIF(s.url, ''), - NULLIF(s.title, ''), - NULLIF(s.hash, ''), - CASE WHEN s.height ~ '^\d{1,9}$' THEN s.height::int ELSE NULL END, - CASE WHEN s.width ~ '^\d{1,9}$' THEN s.width::int ELSE NULL END, - NULLIF(s.path, ''), - - NULLIF(s.pagetitle, ''), - pg_temp.safe_uuid(s.pageid), - NULLIF(s.pageslug, ''), - - NULLIF(s.collectiontitle, ''), - NULLIF(s.collectionkind, ''), - pg_temp.safe_uuid(s.collectionid), - NULLIF(s.collectionslug, ''), - - NULLIF(s.pubtitle, ''), - pg_temp.safe_uuid(s.pubid), - NULLIF(s.pubslug, ''), - - CASE WHEN NULLIF(s.collectionids, '') IS NOT NULL - THEN string_to_array(s.collectionids, ',') - ELSE NULL END, - pg_temp.safe_uuid(s.primarycollectionid), - COALESCE(NULLIF(s.release__string, ''), NULLIF(s.release__bigint, '')), - NULLIF(s.format, ''), - - COALESCE( - CASE WHEN NULLIF(s._sdc_received_at, '') IS NOT NULL - THEN s._sdc_received_at::timestamptz - ELSE NULL END, - pg_temp.safe_ts(s."timestamp"), - now() - ) - -FROM analytics_staging s -WHERE s.type IS NOT NULL - AND s.event IS NOT NULL - AND pg_temp.safe_ts(s."timestamp") IS NOT NULL -ON CONFLICT (id) DO NOTHING; -SQL - -STAGING_CREATED=0 -echo " cleaning up staging table..." -psql "$DATABASE_URL" -c "DROP TABLE IF EXISTS analytics_staging;" -psql "$DATABASE_URL" -c 'ANALYZE "AnalyticsEvents";' - -FINAL_COUNT=$(psql "$DATABASE_URL" -tA -c 'SELECT count(*) FROM "AnalyticsEvents"') -echo "" -echo "done. $FINAL_COUNT rows in AnalyticsEvents." From 7b6a44afb46d1aba450ceea66c7d25edfe606f70 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Tue, 7 Apr 2026 21:03:54 -0400 Subject: [PATCH 26/41] Add in-memory buffer for analytics writes to safeguard against thrashing the db --- server/analytics/README.md | 98 ++++++++++++++++--------- server/analytics/api.ts | 12 +--- server/analytics/writeBuffer.ts | 123 ++++++++++++++++++++++++++++++++ 3 files changed, 188 insertions(+), 45 deletions(-) create mode 100644 server/analytics/writeBuffer.ts diff --git a/server/analytics/README.md b/server/analytics/README.md index f445353aa7..1e3816b68b 100644 --- a/server/analytics/README.md +++ b/server/analytics/README.md @@ -13,44 +13,67 @@ graphs served directly from Postgres. ## Data Flow ``` -Redshift (historical) - │ - │ one-time migration (tools/migrateRedshift.ts) - │ downloads CSVs from S3, stages into PG via psql \copy - ▼ -┌─────────────────────┐ -│ AnalyticsEvents │ ~19M rows (raw table, Sequelize model) -│ (server/analytics/ │ -│ model.ts) │ -└────────┬────────────┘ - │ - │ CREATE MATERIALIZED VIEW IF NOT EXISTS ... - │ (server/analytics/summaryViews.ts → createSummaryViews) - ▼ -┌─────────────────────────────────────────────────────┐ -│ 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) │ -└────────┬────────────────────────────────────────────┘ - │ - │ REFRESH + CLUSTER + ANALYZE - │ (nightly cron → tools/refreshAnalyticsSummary.ts) - ▼ -┌─────────────────────┐ ┌──────────────────────┐ -│ GET /api/analytics │──────▶│ DashboardImpact.tsx │ -│ -impact │ JSON │ (Recharts) │ -│ (impactApi.ts) │ └──────────────────────┘ -└─────────────────────┘ + ┌─────────────────────────────────┐ + │ 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 @@ -61,6 +84,9 @@ On `sequelize.sync()` completion (in `server/sequelize.ts`), the server calls 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**: @@ -209,9 +235,11 @@ don't exist across all matviews). | 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` | Express route + query logic + cache | +| `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 | diff --git a/server/analytics/api.ts b/server/analytics/api.ts index af27a9e017..9a18d98f7b 100644 --- a/server/analytics/api.ts +++ b/server/analytics/api.ts @@ -6,8 +6,7 @@ import express from 'express'; import { contract } from 'utils/api/contract'; -import { abortStorage } from '../abort'; -import { AnalyticsEvent } from './model'; +import { enqueue } from './writeBuffer'; const s = initServer(); @@ -50,14 +49,7 @@ export const analyticsServer = s.router(contract.analytics, { const record = toEventRecord(payload, { country, countryCode }); - // sendBeacon closes the connection immediately, which triggers the - // request abort middleware. run the insert in a fresh context so it - // isn't killed by the abort signal. - abortStorage.run({ abortController: new AbortController() }, () => { - AnalyticsEvent.create(record as any).catch((err) => { - console.error('Failed to store analytics event:', err); - }); - }); + enqueue(record); return { status: 204, diff --git a/server/analytics/writeBuffer.ts b/server/analytics/writeBuffer.ts new file mode 100644 index 0000000000..3e99eb3b3f --- /dev/null +++ b/server/analytics/writeBuffer.ts @@ -0,0 +1,123 @@ +/** + * 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)); +}); From dd526d764f522c592610cfedf3ba9013d164375f Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Tue, 7 Apr 2026 21:04:02 -0400 Subject: [PATCH 27/41] lint --- server/analytics/writeBuffer.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/server/analytics/writeBuffer.ts b/server/analytics/writeBuffer.ts index 3e99eb3b3f..a4ef2bf18b 100644 --- a/server/analytics/writeBuffer.ts +++ b/server/analytics/writeBuffer.ts @@ -39,7 +39,9 @@ export function enqueue(record: EventRecord) { 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().catch(() => { + /* fire-and-forget */ + }); } } @@ -66,10 +68,7 @@ export async function flush(): Promise { }); } catch (err) { // eslint-disable-next-line no-console - console.error( - `[analytics writeBuffer] bulkCreate failed for ${batch.length} events:`, - err, - ); + 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) { @@ -88,7 +87,9 @@ export function start() { if (flushTimer) return; flushTimer = setInterval(() => { // eslint-disable-next-line no-empty - flush().catch(() => { /* fire-and-forget */ }); + 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) { From b055c7961df70a977c5ab6122fd6ecd8a2b7b99e Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Wed, 8 Apr 2026 14:44:51 -0400 Subject: [PATCH 28/41] Update to match new env setup --- infra/.env.dev.enc | 109 ++++++++++++++++----------- infra/.env.enc | 113 ++++++++++++++++------------ infra/stack.yml | 4 - server/envSchema.ts | 17 +++++ server/utils/cloudflareAnalytics.ts | 6 +- 5 files changed, 149 insertions(+), 100 deletions(-) diff --git a/infra/.env.dev.enc b/infra/.env.dev.enc index 893462205a..5050f25f2f 100644 --- a/infra/.env.dev.enc +++ b/infra/.env.dev.enc @@ -1,53 +1,70 @@ -AES_ENCRYPTION_KEY=ENC[AES256_GCM,data:GvdRwipBDtFoAWtA6EQclKecAQsDU6hUxZ/MSB+rEwhcsT9XevYIr0S1VsItxuj+yBv1uvoxWVY2zzbqphDkjw==,iv:WKtzArxkAzUSu+F0ejF8aSuKyoxZbryts58TNtTkSQM=,tag:LRZwVEzlLauBOtCxF8umMA==,type:str] -ALGOLIA_ID=ENC[AES256_GCM,data:gijQsFX+jvJ2FQ==,iv:lycnsjWa9WhdSdOxMlNf+OW55XoBUk96jr5mkezETLg=,tag:fjb/iOAlb8bX0MrtCGpjvg==,type:str] -ALGOLIA_KEY=ENC[AES256_GCM,data:lv7B1+lYP9NIa5IfeP1X3Ax5OjOKu9VmL3LFSO6f7SM=,iv:MIgKuGMotBEYN5BP+LyeH3B8coypUAQonutvFqdiDNU=,tag:gAygP9jMY2Fvv1WNEaDLCQ==,type:str] -ALGOLIA_SEARCH_KEY=ENC[AES256_GCM,data:Nt99E8b5+dLonCo7JWB3v6A040HHq4xJXSH/ann7uJM=,iv:V5vd4IqnFJS5c+p753+eQbQP122xIU+Y/i3/T+DRL90=,tag:MXXD4RbgXEhwBx9C1sT/qg==,type:str] -ALTCHA_HMAC_KEY=ENC[AES256_GCM,data:V15Yt/TkqUPzZdaRGlRWl3Mim6O6ZbiHC5AgNPu4ThQyuGOFoadnk+laegPSamL46n7VCzRNiASKU7ObOl24bQ==,iv:STjKB0wE1w1vxYeW7AJ031oRlxWwpgOfZywiWF2IKBs=,tag:iAL5XDsBOrS0OBq7ZDCoyw==,type:str] -AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:Hu0kYP7LkjP/H5b65o/o/0WL/cs=,iv:NnsyJXRU6oTOpW41UNTXp0SkZT7nDq0f1nEGph/O2iQ=,tag:Rn1E8C5byz1Hy9AUkpBBPg==,type:str] -AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:gm4R8mg0JTDPbGBWYLMyv9tBLKU=,iv:j2elVkVYNOjO0CY558mDgV7EvJ0visQYEnvfRguAu54=,tag:RNqsYkjqVVun87HIFP5pqA==,type:str] -AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:BumjtoTGqwCWtU82ScCsq+WdG9tBEmPALvNq6W4VYvqzbS8KTRiMYw==,iv:jVWIVkpPGfAxse6C4+vtNJ5SNyz+bdYU0t+LJG0Q19M=,tag:xXf8Zu1Mp+NYDXfzWGYWdg==,type:str] -AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:7JKqVd77OV2Xv1aEbOPCjETkh/yWFa1es8/E7jtA9s7cb0cMjWATSw==,iv:JPuiPAJPcOdrOja4n5vpzg5jc+6ZODsbLMx620gRVbs=,tag:/9Nj4tr79MPylVW/ygdwjg==,type:str] -BACKUPS_SECRET=ENC[AES256_GCM,data:NfZmKEQqbD3drcLqqLBCI2OVJB+LFnCC/pqR2NoC8GNqCaopmkn8te0Mapw=,iv:Njv2peQ4NjNfJCoCsSMT38SZ8rYXmQyvJAmr+NVQfpA=,tag:Mp/bz/gKWj83wEcWoCmtgA==,type:str] -DATABASE_URL=ENC[AES256_GCM,data:YAQJt+3MuvHJ9pxFTjX8wnaC3SnLCFEqYyH53vwYVO1BLKzCKJr4hHQsFZg=,iv:18SsNwRtIZPTPVotdasHkdDJZ1b+aMcMRnhQpVIFKiE=,tag:rCrdkPB12FBcRb1xcmUrRw==,type:str] -DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:Yy1fVv4g4sdLWkuRbFiVNkkuGgWouypVufWhzeX1vvwMdQ==,iv:Ivd5FHJVGGKb7ELyusKABUiooNf25ATQy09BpoMHi+8=,tag:qsRN5A1QdI5kFL3pc2xPEw==,type:str] -DOI_LOGIN_ID=ENC[AES256_GCM,data:uHj95v2B,iv:KuA8Ywb+lfigxOHYAfupFIIm+SounxkhSOmsjbv7iFU=,tag:akkufU/FVuvm0M8ned94Aw==,type:str] -DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:LT7HEiZKlz8/7JgylsGUVe0ik7Q=,iv:feRDiDGnijN0v7iwvgJgwgbNJe6mbML+zo11lNFD/yk=,tag:8creIcs3rzPT5lRuFsgA+Q==,type:str] -DOI_SUBMISSION_URL=ENC[AES256_GCM,data:+2R0K12ctF9DxehUiMLd5/z+/vD7D1VsKml5N1IJeTYOWoB8sEI5w9k=,iv:pOLQD2h7JweQcLp+VQUl7HLbUd0v3m4vnr5p2f3vgfE=,tag:rWFSm0x7dYbU2e+aZNrNWg==,type:str] -FASTLY_PURGE_TOKEN=ENC[AES256_GCM,data:H1jkjhPGHUTFqyIIZn8F10QTp66AIkoJmndWy2xrT9w=,iv:hJ7IwyQrg1A5AljtIIhwf3ZDBhFKmgqEPtysMREwprI=,tag:xheg/G26ajU/Ezn4bqSa2A==,type:str] -FASTLY_SERVICE_ID=ENC[AES256_GCM,data:P4y1NvxQtKVzWOnyO1smh8zKXxhcaA==,iv:yshQvQ4RTAtwpz0ivW3PkxStJyjASXDE3FXPn7/UefM=,tag:l9lQKOY/Gb4xktM263hPew==,type:str] -FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data: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,iv:YBw+Mjc+msiliol3qH8GuFT47gGfxouWJRSGwbYtWkI=,tag:jNpugu6Zd/Z5adaCCYTHTQ==,type:str] -IS_DUQDUQ=ENC[AES256_GCM,data:saM2vg==,iv:YJ53quCWNId6SR9DX7T3tYcg6bGRggIV2hNKYpaLLx0=,tag:ZRIGHJLew+JcLAY1QoAk+g==,type:str] -JWT_SIGNING_SECRET=ENC[AES256_GCM,data:YGTm4hpxe7uBhfxqOaIjErRkC8aEel0FLXuEbPt0XTeJv8BAEqH9O7PPyFgQxqBCWJgANmSny7afVjZufyq2GSKHWiQnQkQcEmecyug29HEfhU6I6U5yELARBWhfLNh/8B+eS8EOXzOYCEGUBDQBzpgRGDVYh4ArNBqJmwu2+LoPy3ZeSEjJjLH9rGlX4K2aZAi5mCfN/AoqCkKU/QeFWVF2P65KSi/zcFWjlQGJx562DMqL/QXkLdSqkm19kaYn1j7fwESj1KhMXDbV1F1Vu43cU2r1Ojzxq0mZu+YoKsOkR5PouldZ9D4ON2QC7vnGg1HW9UaVsOQuGluobzufEx/Gdj5m4FDvdP8CSdL8Ub+UCfxpsgpNzn4oj+/LtWsm0SDd2ZatXvpiw0iZM8d5tdf6dAiozn+OYuhp1zUx8nV1J9sEKMeX3eA2NAHz4ZcEg7YjHF+HySgZ8t4tYGA3miICU4dOtIYx15LnXg9jPMynBEM+1ZUKnVsWY3TFfXx26nL05PtT1BpaN6IMOI6N8gAM0cBKJdtL5df3+gQS6tpEzIOgal/b4WRjHzzRv8t1ZIyvGZSRFvozLd2CJ3DcLnQXyqx5io3kz+saMIFGbbzAopgR7gg8EcXSRu7SSBoI2s3e02DzGmikO7EUzfyhwM2vvY4a2Cs8Cl69d9Mxf+aRf2oaTgKAq2mC/GExTRXFey6YDGWx3/GWqemLi+nyv35wA/YYA1Nxt4lRbbfUYtg7ZakoKUlc8F8p6GdbJS8sTue2cxzeAi9N0VWHqjS5aDaWyYAM0DbQe0no/76JJuBomf1SHqRnXOX1Il1piyqMr/tXAfgSMuKJcn4cd799Albmn+u9Pn5L+Mn1INaBGXywEDk4DTUyTlWhUGblQ70QdzsI+QvjmKTmXJDhFd9orxJkmB8OpqSJGE6lw+Uwf6QPFe1l0q8EfXoTOcHrguU/3/EZN+bvNjMQSdJAKl8WaKy6makO8pQrUe2SCvTTWC0eIkUqM0xvtENT1qReF5psX+6sgLDPSOH5goY91pnwibAmS6bZZhV6jWqt3D+52Ac7jiVSPus7GR6xiVTtxa+EzFwSe+HgnZ0q2PccYAk3BF+QtdNBX10WH9ooTzsyDgR1Ogll5rKCi/vpjMba0f2/Oxt/SKiirvUey8gD0RNWWvhx21ajG028b3hOlc9uIbMAP1LkDZpZ6k2x7xHKUGXzY+JmWtgEtTWJWITq816K+BUNjdUhBK0rSAmYM0GNtwmrgRJ9OcyKmlvyYJHoAMv8IWA53M7u7gE/KXrqmz9yR4NaDNIGeKYngBIE5UyH/dVy1UINmnH+PyM3eOng5EFvmT1+BCw5dSWk5TY0lfE9Zg==,iv:J6+qsTSz2tZDPu9HLNa9V3qzvCT6Zns0W5ckiqCbMWY=,tag:z5RnUxHLmaZS5Gl2hY0Tog==,type:str] -MAILCHIMP_API_KEY=ENC[AES256_GCM,data:2yhthb/HuhwC6wCjZp2dhCkHVyyrZqgYZtaIN8zKPI6ZtrhZ,iv:n1YZuNJEA7vcFiJ8WSZwhT1BHOCH7d3U5DVajI8kjXA=,tag:OIfWdbOsxP+0/s1+y+Qmcg==,type:str] -MAILGUN_API_KEY=ENC[AES256_GCM,data:1MPlxgz2cGgoEkDuUQtdTMumpZESxdy+Lr+4RjtnDsdzAShQ,iv:nkYJ6QrBVElNHMTz+41qcze22GqDeWEGqatVvgoH7p8=,tag:9LMhERBcdU+WbLX5N7WCSg==,type:str] -METABASE_SECRET_KEY=ENC[AES256_GCM,data:Nry6mKHrbgk5dnVdScDJQNSYnGAgnke4wuEaoalQn1QKxl6DwvmTADCa2l6dAVnMKk6XhphTrQCQS0oYUoDU/Q==,iv:PzpagHkiPG1RIxk3yemDR7i9eRRze6ELIiAaegh3/fA=,tag:z5gGGI5hwHFT6VpiDptEKA==,type:str] -NODE_ENV=ENC[AES256_GCM,data:E8bk7jJCw8gGZw==,iv:4mjEiGiVl4EJUq0Rmi9uJKzDVALSoNmcZCkdNxsWIAU=,tag:ofGiL5WHdSXQw4HmHej++w==,type:str] -#ENC[AES256_GCM,data:OMeEr9/wyDlmfTbJJn+xkt6VeyL5X29r/7E=,iv:8+7EU1AjpShz5htH6mglu2LUgRZh64iW9zXTZ1UH5gs=,tag:UBl0LiZFU/mnfgpyNP9MBA==,type:comment] -#ENC[AES256_GCM,data:zVk3QjFMPdylqxKO7R93fzF0KbTzUQ8IVaYmLJb+WpH22SYLfrw=,iv:OfObHIoUPoDLCnwTzKLpxUBIKezm3W9KRsB/dwcpL3s=,tag:xlhousAaTS5KWXjOVuEKZg==,type:comment] -S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:P3BiuL6Z4EnbFNuJw/W/P5Nu3H7q3h41uX3ieBxhVDdA3/c=,iv:eSSg0XUDUN1dKt/WEKfb6L4u5Juk0nS2H1PnYp/of3g=,tag:KdMhUqwN0cY8NKHYVtH03A==,type:str] -S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:QgkHaK+iSVm4frBSYWjtaXRxx64=,iv:jKficLChILJXq5sTKBxOtMSqud3GngeosIy0LxUB50Y=,tag:HzknrKA9EEV2pNR8G5MM9w==,type:str] -S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:BoGjps7LO39+lk5apryaUAVNCD8ASlClFpp/RCnNgQzBKhDGIiYU6g==,iv:XYfVnk+7YvI70mbO0h1JuJxo8NDAV3BVjbfunWWoTKA=,tag:qAmowbExsxpAEyhMtCzH4Q==,type:str] -S3_BACKUP_BUCKET=ENC[AES256_GCM,data:xcMAYLd9gEaxSeA=,iv:33CaaJYyhcURmMsmM1hYc2/191RexmjUV/JYO5r9NZk=,tag:Gpq/9iXX6LvpQ1+fjWfmxQ==,type:str] -SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:cBjoCbX3AMBt5nJuW+LDhzZXPKfxnsafq1d4ImSwV/GendvIGsDW1fXl66yAg1sSA1v44kgUuW8jzLhpz2JXmzqcUk9CiLMI+i490A96Wv/Remr6lni4BsxNvY9Rhb47bygTAePtXL96kRMfWy/09F8udiPAC+NslcsHwYxQ5J6r00+sPYGii2NfQJYtcBZDEUufQFbVnZNfnO73ldYYQQFjvtFOFa/uBdsaNerULFu6XDU0GYjM7vnyUw==,iv:lmi9iZu2VeXzvtNZcb4Egq1kBCu3XPRtXKmd8IK/XKo=,tag:BfIY8Okg1EbdqXxlkJ81Ig==,type:str] -SENTRY_ORG=ENC[AES256_GCM,data:FcIZ,iv:9D0JDCbviBb7DiBKFn3xvI8nN1ZqmMzRN+sbb+zQ7fM=,tag:aCPdhfi0rsNDOMsjvgfSBg==,type:str] -SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:3xg=,iv:fvkYfc7MxxBp9gwJcIPAl7taP+MDuv0UjjM/ZiCTtuE=,tag:7QE2EwRhI+qCWr/JJim+ow==,type:str] -SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:+CE+aFxeL62BQ/GS43dnz/pAOn/9t75qaISdYobdOdqvYA2gOHBfSBbg1HQNJJOQ/3LPNrExQiR8ZfhV5HpA8PZkCN/XD0D0M9PvcLf2vQ==,iv:eZOV1PoBpTrj0WRdaiMRlEEqj1jIPIaB7gLC2ZivfgI=,tag:diqqk1ZrBFhLp/QKoH0MdA==,type:str] -STITCH_WEBHOOK_URL=ENC[AES256_GCM,data:ECQw8PC0lmmVjzW1t7RGCuirM6JrBo5auhXimS0JhxraST6SRcAm2JJ97gjwSkOh6TMcnQzF2aT+5j+aJhgN2xen2nhRsX8BzAQi6/0bNKY4uhCWD0I1ykfPQPvz0DOc++FsgSy6cSTjupOKBR1U+OJZkdLq,iv:y+t+AziCModn9Ly/Hxp//vLzKMS2s6X4ldCXVK+zDd8=,tag:PGWw/FLUrYe696Lp1rtXzw==,type:str] -ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:Gi6kndNpfNQVyEHytWhziIEek/8=,iv:FfXpl6gAo41YZWBak8awfny2hIwHq2FP4R8ZOFBaTLM=,tag:IdBL/GUNoU7kh3xLed9MHQ==,type:str] -ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:UW5FlBsBG46EWfZwD9WE7Ksj3ws=,iv:xwFoLwHvrt6LB+I9IsPrick7PEeKkffqhP1vCu7jVGA=,tag:nYyfFCEZFrq5Npc1WBdDig==,type:str] -sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBkZzR1UzhxM3c4WG5XMGhQ\nenJlbXZwdTdzNXUvMEQ4SnI2T052WWhoTUdVClVkY0IzRzhHYW9ScFFvcENTWG9H\nNUJNMlQ1K1ROd0RJVHlqVjh1NVNhZlkKLS0tIDcwaVZQMllrMTNjbFZ3WDdMdWtE\ncENPS2QyUUxQYzMwY0pheDdPeFM3d1EKbBgPvkYc0jaJaFtndRDy8pfgOaGuAs2U\nluZN6vSsoSFSWULEc622I/5vBkYACzzG4BDWFOMYCOqybwyFPhhHaw==\n-----END AGE ENCRYPTED FILE-----\n +AES_ENCRYPTION_KEY=ENC[AES256_GCM,data:oaSHgghcO6Tvj9murU1H6Ob+4BRUp1M4LPA/6OtEiUpAhOCuFIYvnH6oyOxartT9UOLjsqpMMXH/Y9W9VZx22w==,iv:F2rnWX1WJTqD/NhiGXkganLyfFXgpRizMmIZSr1/bmQ=,tag:UEZQqzxs2tqV+rWRaNfCqQ==,type:str] +ALGOLIA_ID=ENC[AES256_GCM,data:rs8xpivxk0pBUw==,iv:qS+FwFwEdjbuTYS+6zMTQN5VkAieYYYw1QJc5FKcbBQ=,tag:LF2vohV9UGEsN2+w6tfBDg==,type:str] +ALGOLIA_KEY=ENC[AES256_GCM,data:V6ZCp/mQ5mGNznxhKbDE46LYjpd1dQ9npc4Mmg1dCPI=,iv:Cl/T8hr6r4aXjdQd4dn0JVI2FNog/adE5S4AAtBpatY=,tag:zAlTGaIOkpdhlql6PA92OQ==,type:str] +ALGOLIA_SEARCH_KEY=ENC[AES256_GCM,data:dTYxPvVEPDV/kVWh0BwQPMPvaDaXphKurP7SqYEVNS4=,iv:vpDbhBz4b193JevwpTq1MPAItOYjz0XummU0j8MpBuU=,tag:jjWsTvEzn8NG9iug2g1Dhw==,type:str] +ALTCHA_HMAC_KEY=ENC[AES256_GCM,data:smzURXraiKsva2N/UGZpTufP6xrPvrHfufc+jY1IOYKvDNzIJt46o+77JM2xKTf+X9uZzFO3ibCHvtiPwKrYJg==,iv:AWziBrXpgxDHpFIo7QU1yZBwweTCIj9aZMOiliwkuXo=,tag:Ji9QgejV3bFkXUyUszaKOw==,type:str] +AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:e0dEqzLcRhFmDLMmGlqlFbNYnHg=,iv:X8ZldK+9dpoqVLG8zLnA1lob6CDjjozuztK0nVfSz+w=,tag:D1l+nzmyR69On1tM0gPceA==,type:str] +AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:FX6ok37xAPy5T0C1TP4cT+mavFo=,iv:zFGGj081MvYw9baZCsCsR4ywlFUpSAwiSD2h3VmZnWs=,tag:H/Sdg1QEou+oKgbv9dye5Q==,type:str] +AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:OxPM5wCP3zYpOBR75rsfaoIjGGkCbVj82f/4CgyAj1Bckzv+xCXrYQ==,iv:wiYAukcudOSZNwYFKmxsPUXrXFQ/je+qkFKRDEhDuOE=,tag:cso5IaVG0hMJ3J33lxNzww==,type:str] +AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:UGhDEvWhMiv8/aywahq3964R/IqBbhQIU76J4Gu2oMkFccSsnkNDqw==,iv:rm72ztpd7g679wIOPSurerCxFvVZD5DmL0pa0LG1/f8=,tag:ZgTs28Alb0x+YH5Fjcttfg==,type:str] +BACKUPS_SECRET=ENC[AES256_GCM,data:jfwuR4sWVlJnJVimEYCviR7WrfzrmNL8LydPw097Oz94VF1xDlmqzYqgPDo=,iv:lLYqAwv2qzE3NwUpgZGzC2tKCYOrD1dJpHtH61WdWM4=,tag:gG5POeBhoRBOt7J6OpVd4A==,type:str] +CLOUDAMQP_URL=ENC[AES256_GCM,data:20y58RkCYRDSe7unUO4P//pKwxe6e4zay6NMqdrivu3HD24tstJZTjiFrVyGcIKzuw==,iv:QdU1JV151npVS5LIkOPuH/cSVZJRro1a7Yr2NWNPRuY=,tag:bPsYfNDMfUvGEM4fRXAQ5w==,type:str] +CLOUDFLARE_ANALYTICS_API_TOKEN=ENC[AES256_GCM,data:JJcK0FXkJh/yuwWDW/EfbONSx1U3XqgCSi6sWxFFDrh8I9KtGi+XWP1LffktjyZXu2ArXm0=,iv:7KMQQiAWiIl9PXVDpsypZWQk+Exiz2DYMGSwMuOqzKY=,tag:0S7hHCFAYU+c+yoVChqwJQ==,type:str] +CLOUDFLARE_ZONE_TAG=ENC[AES256_GCM,data:BI1r98zAXPPzQ5+1KvdxHH8RdZzsCZkRAI5mGWSBkO0=,iv:djjnHHd/WvUiHuSWPozNMGf6eqoKdT+FwpLh4PyGItU=,tag:0CmNCWSyyr8TNBNYOtJQYw==,type:str] +DATABASE_URL=ENC[AES256_GCM,data:2ElSobM0ECgh8oplIAaU/BBNlWwxR7TrHdHodHFo+eptYFKE6Y0yr9tmp6c=,iv:F3QKReJAjIf3L22ZTbWxqth970GL9EuYDQ/332l8o+4=,tag:mO52NqKBl0vzvYGx5Q+UOw==,type:str] +DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:F33LWIbpaeO5TjdNr1bNG20SgqYh5/Em8LUFTOH3ILFuVQ==,iv:ZH3fwh698OoFF0Ue8Ze7tFmlor8a4mmNeDXRZ9TTLKs=,tag:uNOhdagU4hvAl6QGRNT/zg==,type:str] +DOI_LOGIN_ID=ENC[AES256_GCM,data:6YTluc0p,iv:ObHYRBjN62Z5ZEtOnyLO0NE89GlkC+OSFScy2CtCq+8=,tag:B3RTMe3WiCL52FbVL22dYA==,type:str] +DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:K6jRQIj2hge/sGljqxbaXMVS/ik=,iv:MTQREcLHMXi4TkAfatTodKwLnG1T6QR6I8PI/xh4VXY=,tag:wtL9tFRBXon6DDFNECp8GQ==,type:str] +DOI_SUBMISSION_URL=ENC[AES256_GCM,data:oy6xujYcZD68EJdr+uS+NspmOPrvc7SYj/PS0JynmIBaAh1PmHm7CSY=,iv:+3XK3/BZ3OV259ZgrOApzWq6eei2sVA6AKcOl5720k0=,tag:h5o09DgTeeJl/42uTZHMVQ==,type:str] +FASTLY_PURGE_TOKEN=ENC[AES256_GCM,data:OSwv4/KSMe/t32YNljb3UmcbK9kKoUIJTM8HuaFLcPk=,iv:4A6CqQpJo+C4xmPFN6/Smq4kp2GdupHH8hDeoO6YwcM=,tag:Tk6ldDGCkzL5MILkfyLIng==,type:str] +FASTLY_SERVICE_ID=ENC[AES256_GCM,data:57bnKilSCN/Q8ADruX9eeCd9IqGBhg==,iv:tcqlJ4yIxoYvgK2qBGVrI53yTo3xUdDC74Qg1QUQMwo=,tag:HWMp8pSlWy4w1sf1UqNs4A==,type:str] +FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data: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,iv:aLzRMWjE6oMPO1srCba7GW0JloQSyeDlv1d6cqScshk=,tag:veBg6+iEWsAJi0dSNYotHA==,type:str] +IS_DUQDUQ=ENC[AES256_GCM,data:Cm5L8A==,iv:qD2xplC+t7z1rscgwH8fK2j62mmNqOgXWWEh7euAQQU=,tag:Q8xYRC45nyTimz0W/3WxtA==,type:str] +JWT_SIGNING_SECRET=ENC[AES256_GCM,data:3Uf7Opg6QBRzeqHp2b+VMh4H7XprHrAKahNmIpBOzHhazxi2y49wyWk0gFlgJfIavVuMHVfHzjjUqOMEziblp/BxGO7SZrkZG2LyTTfzSn8sRj10rVtfz+6TfRFNofKxH0yx9xKqKgHUz8QhWJ1T50WeLUGGicGJZUVh0qxIPk3K8AqF/3Gwhr1wVhFEZaVpidJAnewLuVP0KXgzj0iwaXOpqnu/1fNomU3IEY+R2tqjo7KxAQyreGKA3NvsJo69Y6Itixv+S86rWSWVo/ZBcZvgmLgec5M/0j8qW7+sabBNoTPvRuCbo8IauTfLFAdCjkuwxcNg6mRNUVgnu69HfzYo79n8XRHd2xpSV9Ck3dgn9+HgFbu00Baj561OA3jQnnl7Du20jpTFlExUaQ2NEsyZryHpY6DH/0w0Mm2Nhw1LWUGKwNXGm8NjG5wxjcjxn2ZTe9KnS2Xx/FZIsWzDqCPbvj4NXFHw92CoQhGP1rucPfJvEeAATbBNbpQdxcepOg5cbxNfxaZNb49YKQsaTTW/098TsaHln6SyL9yn/lhE7bojeax1ODw0C+PMt8mZOdJcxPiNiP7sNRydWbTKi2ez2MxGGNi939DN5GuulG7iI8f3I5TfLyzaMvF/cpAgC06S+YunajK8Io+aQq6/xLGAzARhz8sDZMd5U8xK+Dtfhfsm+0DMH5C8yNrG2VyKQV8OJzjxU7OwWTB+gwfwYCm2N+CLMOazLcnF+LdwlufeyHtMA0roR0tA8roawF4JIkAljervzwrPHv/Bkc8iTw8mwYe1M9H1DrN6z9x2XIGmsBhaQIz+QSvx3Bbd5hbXZW8svFuyEOXmLKVCRvz8h6InR+b9ivU3Bm9KTcu3VZ0HfccIm2h1OpXoiYKs+6Ujcy704fC81CdE0A5fblLQCnXHu/7fGG5CKt5YsLy4pudJoISBaVbTV5QDmU3KTw/z58IE3RNF9U9bem0DWqIIGmbwGoR2N+c36RFPM7Q6PCwHv78wDpctvw7e5YEPBwOaYGla7L5wwXEywgCyKOeU+dvHEs+mwOng1TbRE+w75Gc8yAcvx8QQXJGHaOfzaQiIjPh293rGByePcA55mYj8kCXbWpgYY5BWgF7T7v0TOuJ6Q6wh1Bao4MT/V5laoo64+1cKF8MFxVOu0QJ+ng6NZwWpPO4I0veeqc0FoAu5WoY+MZLNspmBdM+hSl3qcv7/yodDvWAcwI2X91N3vueOojny+PCoSB6ubnKirBW6Uj9D44VcvyRXOxRk4Y1pNN2JtUbHjb8ttB9ZBu9nB7tV8Zk9WZW/GQNHBunS9iuhmvqeJQTPOT3YHQld4LYKBxalPbWsPKVQV7Rxf5CC6tx3MQ==,iv:eFs9YDQWXA0l//tf7irrnbtef89npAEKDIEMIVey4+4=,tag:ZpCIiitSVEuX/WWXloR/jQ==,type:str] +MAILCHIMP_API_KEY=ENC[AES256_GCM,data:f6LR7bEeip12OsQdUCGHNJs9KcR/AcLrejMkqY7jAaSc6adr,iv:xpvSBAEcFClieqg0p7coAVrF+HHJz7LfiYjvvfMie8E=,tag:D6xeykHlmUZG7zXzzK3QQA==,type:str] +MAILGUN_API_KEY=ENC[AES256_GCM,data:Bp9C6Z6k1KTHOX6NgvK+zp0C+2XurepQ3DdE3Cu7544Wngy7,iv:wElgRx5LEoGkuzED+4m08gUBACiSTA1tM5lZfReiTbE=,tag:pWPfW2SK9hhMCMitM+/A8g==,type:str] +METABASE_SECRET_KEY=ENC[AES256_GCM,data:f9iwhfbAbvlJ0jFT/l+vgBSheG8AyLda7ZhsaTPl9Paz64+935Lo4/xewUcAfUY31y20BOVpoOOo1+JQUaXRig==,iv:Sk19s1bvzwFe8ILkCp5PeusiNzodNfDv/WssZ9AWmbA=,tag:KP0B4pWzBCtSDkYX1/QM+Q==,type:str] +NODE_ENV=ENC[AES256_GCM,data:Kqoj/57UeqAXMg==,iv:PFvtiHPv88RcH6Adn2GbRlj50KL43jiG/uFl7LxEmDw=,tag:bnAj/M7CS+DFTpSd6QvgdQ==,type:str] +#ENC[AES256_GCM,data:FkkCeoDVGoc8++eFC7cQ4J+tMpkcP8vTyko=,iv:7xC4VjEKKNk61QLcATYCUD4RB6GfsoxomaH2Pq3q8ug=,tag:jyqSMNlUoaNyqG+6nLQvSw==,type:comment] +#ENC[AES256_GCM,data:TTqvxI1Imbl0P/JP10XjrDSwKyQ6/uaj9KI3mNznnSdrSMeUDGM=,iv:599cz23o4Tm2KLZZy1bYIVmayjSxYu03UCe4lwozXk8=,tag:jo9TMHdKJ7tIo2RbO8xidQ==,type:comment] +S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:KtC+8iA8N/fkf9vePnKK6HPgU31Clbp1LCkc9zkgcf5Pz5M=,iv:OSKbay4ELn4oJ0PmTvGH6pTpUXtrKNp5+Y0dftNavik=,tag:W8O7KSpAmc8XwNQIZoFTTw==,type:str] +S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:RUh9W/+hsGSzL4Lvg+Ic2TV8uFg=,iv:oq0yAKYs+4QFuhPe3UBjZCyBJAc6bo9yTz+WifrlgaI=,tag:QIvIGdVmu4LNv9XrmDHe5g==,type:str] +S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:bC1i5hwZV1XS2Xo5LLRGNAmRcW1ZdaTmWPRZ1F5J+sfPBYXlweeWGA==,iv:ldNbQyYetZkLQbHTN5cbkbUaj3f5rK+U1ztY1p+VMkI=,tag:PxIABwF8oAB8DZ1Zbqocxw==,type:str] +S3_BACKUP_BUCKET=ENC[AES256_GCM,data:2SzqQt8fBHGHXts=,iv:WY6eSvocFHrZBkcF0nJ+PqxRsvpsYqeo5dykjhG1ScM=,tag:xL4KucsoQj1RvqJybi6LGg==,type:str] +SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:t/qvy2Q6IQLDKcChQJFWv/F+U649D48wFzSKGG7KUX9lkrMXY1B0RUYs+J66uEw9pdenf74lsknJ+iTQl9cGZkaupRBi5UVPijbIvXuOMNBTbf1elYzFDZe2lAFNleKtDkybMZqiexEYb9cK+1+qB1Q7hjxHHI3ac/8YSmUBfysRAnq9vhQSmFg4gW0vvOPc8qPjpcocd/Det9C9RexQMrROv2pCxOF9yj5HEk72v+eGyTODvXDDhvSXPw==,iv:eyB9482onxMeRx0SDQ7SFQ8Rk/WD/AopIhaeSnvJQjw=,tag:tItunJuihNqirP3vGyLtVA==,type:str] +SENTRY_ORG=ENC[AES256_GCM,data:M5Ck,iv:QXIjJBQdAhrJwbZ3LcD7RTEfcUU6Gjsa5nncF+Lil2U=,tag:cXqQP+7RyOQ0QHn6fRJJog==,type:str] +SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:g74=,iv:Satfb1E3jIoM9YZ6qpvgkhXi5OlBO5M8e42+yJOpsdU=,tag:lUo15Aixq6mNsjgt7ool+g==,type:str] +SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:+z2prKsqQY/D86igApIjPMaOoRlmch9WtPdQrGU2qZv1sNyzVgrbJ58a5CpX2hz+Lc+8JX5qpFT6UUNy4GOSkCBOrmNFgUVoDLKN4jv+gA==,iv:tnGj0p9COOCylOF3UPRui6qlKzMWsCw6IA+NKOCQx/8=,tag:UMFlz/yr9fbQmZ4VCy8Bxw==,type:str] +STITCH_WEBHOOK_URL=ENC[AES256_GCM,data:ANkjl8NqCPztLg2RBTU3A+WhRHE/CmI21/weYdifzPvgxGL2NOVebdxijlq7unMM34ponBDfS5iXoK1+jv5oSE9acaWDa6GoC9UdHN31cu3D73VHl7uqLXAvdRaao8o0AohLYdKtwdyomzJGiJzt1Hohxbjb,iv:8L2a4Ppogj5F9ptS74mnu1bBsn0OoTJNfh0q3xcLo/M=,tag:3DdaVYNPCakC2I6PqQUNpA==,type:str] +ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:davWvh++/BTYR2wX1NipRdQLF4A=,iv:c+Y8SNjSlCqp2188dq9noJMcAMTv8HHwT4KhJHdjzZk=,tag:ZsA2R5xRofV+glnuE+TCTA==,type:str] +ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:8yF5SohjudQnHlCA+gTqFAQS374=,iv:lxQeQdwUFv20jjRGONfRqhQ/5Ip2uiE2OikYmol/M3w=,tag:TxapMT9d7GLhkMJXtJs5OQ==,type:str] +#ENC[AES256_GCM,data:eqMSyB/etP3U0DQ6q7RpBJwjXimU2ojbsVTZqL5Z5tn2hw/TGAUZODnM4eEunC/rwpiKZh4f1glGQwgR9iera+kA,iv:e+oYjAo7stZlgu2zlO0EpVrqyco0tx5PuVMuHCu7uBM=,tag:h1yvGFK+fxo8ppCrNU0+Ng==,type:comment] +AM_REDSHIFT_PATH=ENC[AES256_GCM,data:xVTP0Y9kHfbqS1Km/hQel8NoS4p0cl3tS1eo7Ja/viYPK/B4nrRmSnzv7kFNZqD9jeYRQ1sLs7XJBKTnaKA=,iv:zph/eYXll3GbncPCTkIgt69aZJh+vomEG3uXwBZeIJw=,tag:9O1aB61StCmY4DNreaJCXA==,type:str] +AM_REDSHIFT_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:MApA9kH2up4gDF+Qbt6aSK3cwZ8=,iv:KE3cz4NJwp/MuVEE3HKvgGR97aFiB8oneWmbszmCq2s=,tag:2CN/MGSqrw94lYpukD1WOQ==,type:str] +AM_REDSHIFT_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:LMvV6xLHdcvsYkapTUyDg57YgrUjoDtCfpeJvYqdF4wAQiSfwnq/lQ==,iv:27Aetfi3jaooZ8aT7b1nIS+qsLEiUxydHb9n1fKDCzc=,tag:1z8MQhywxolfVjWyYtUpkw==,type:str] +AM_REDSHIFT_BACKUP_ROLE_ARN=ENC[AES256_GCM,data:HRK1RWNBF20FD8yqEedmbO+Fqx3Idm4+4VyAQxtx2PIy/TCkSJGi2ZybC1u7g0KBPchc,iv:d8b5IUtKtPo7oOsQZOb9cY27DRuR8tznvn5a+1jMuU4=,tag:Nez5+uqyVOF3yyAMdYAGVQ==,type:str] +#ENC[AES256_GCM,data:rxO0DwgL52K7cz4o1aB2lrop2g/ztRiwzidCduX3s/n6qRT2hP+tDA4PQXxo/Ac3deR5UmCgs9xS8dmK9vlBT9RcqM4CrpuuBjWnRLTtbq+nUlJNMON17n3lxtEnalW+ni53K+W5iPKc7SobzcJgETv6c5l9PmEJ,iv:HIugwS2BhCMKCDchBfHAfV6aYO6NEL3py4Pr9L/Ui0k=,tag:hx5uXZuWhz8Ua/KKd41gaQ==,type:comment] +#ENC[AES256_GCM,data:udkkKGOulqoylAV8xFImmvU/YJLuTC8Jj0k+BvOMlR4HEJGCoBxdXShKKHWgdL1XBwo=,iv:eZOIAaRSZ39AgivFur/sQjkAi+7HXeY6tdINOqrlqno=,tag:PE0Tiv3XKkvOVScsJpHxBg==,type:comment] +#ENC[AES256_GCM,data:eJF7ZVxsrH9JTHHXprOc5abMaG0d+dtX8uaMPBoHmpS3IPqly0FswImg3GOQ,iv:AbhnqTQCSmk7HbRwSvnoZc3LoSg+dFof7nRoop+LvTE=,tag:wvEv1ufTqQGXPkOccNTEaA==,type:comment] +#ENC[AES256_GCM,data:2ijOP2RDGCfh1EEjtHmp+dknrQpY7PQ=,iv:9lxBPKoij9BsqRSsVSqI13qSWW442yfs4UWCgAPOqxw=,tag:1e1J3ROPYxtjBgTiZqejOQ==,type:comment] +#ENC[AES256_GCM,data:v5USw3kCvmNzAWoMJfKMfuj3j1I7s9o=,iv:sHNO03vkykkL/2m+IojvIJy/PZnZ9rPdt8kfWkG2nSw=,tag:h/sbnK4j6WmDQ9EEUV4u3A==,type:comment] +#ENC[AES256_GCM,data:CiFOM43An2iJguoMkK0BQoP197hYArfB0BqCNpmHOVsHHfONDYBlJGAjWSzVzQJubMcnSGMtRncC1VsYzg+j9//Z1AMsH7LczQhi/g==,iv:DAZQhbTLG4xAZZiN+rcOe2kYODOKIutOKknk93gaw+0=,tag:Ed1heIKZDGNDWzWf/2fdTQ==,type:comment] +#ENC[AES256_GCM,data:PVqvuFHoXHKbX61YTRM4feO+6OM=,iv:erF0IhOk+w9Jwc3nLvQExs5AaGflt18mSNUmR+Vazfc=,tag:wAO6FKrqtucdJtCbBNaBnQ==,type:comment] +#ENC[AES256_GCM,data:EM5ZCh4EBF4gz/T2sbyM/tX256QdofA=,iv:ZSE/FKKf711M0hyzL0Ls5ZBanMF2ckOlUj7HLEFYU+Q=,tag:aqkfgg7kAbKcNi3fCf3fng==,type:comment] +#ENC[AES256_GCM,data:HmRAVhht+rzMobDzveFO6A==,iv:63UMnhZBl2g3W8wKFHLmS93/WXGEN9nAfyBOei8HJqk=,tag:c84hpQ4nMnMmfs6YJXMJlw==,type:comment] +sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB6VU5NcDI5SmFVZWxZcEVU\nSkFkR3M2OTFmeVMrdFhWMHlva2NBQmZ4YlV3CkFvdVR2dXNHQllYYkZlOERUQzZS\nL2pMZmNCUXVFUGhueGVMeTdjVWJDU1kKLS0tIEF2WVRQTmhOWUo1UlFaSHl5bkJC\nKzA2Qkc0VFl3ZHVZdk0xTWdTU0RQOEUKcXexJ68XFbCvPeJQi+aXtzOGkPl7w4HQ\nCaen5e0YnCQDl3ILPMUEGbQDxIsBiSZ/whWXC7Oy3LU0+eb0xPir5g==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_0__map_recipient=age1wravpjmed26772xfjhawmnsnc4933htapg6y5xseqml0jdv8z9hqemzhcr -sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA0MTJTY0hTZFJGTVRkNmk1\nTUpZNWdmNnlGT1JzTTR3TVJwbVBNQ0FxYmw4CkJlVEhNWVNWbkY1OGtHMnFMSDdP\nVlIwZ1VCcnVpUWlUY0dCS1F2dmlHQlEKLS0tIC9VQitiODlyeTM2R3RsYkw1UVg1\nMlQ5WUJlTGNRQmMxOEw4TEtzR3FxOGcKKq3EkIFuVRfzQlL4Q8w+OcFBWS3+N1Xn\nimbO2CfXGtVfQaRuzV7uUTX1FWwCVltnQqXigchwIgIgYHFNuGCB4A==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBMc1JMMVpnZXh0VUNmSlh3\ncUhHMDQySC95UkJrbDhLWGF3VitLaEg2amc0CkdTTG15TU5ZSWhwUWduUWY2bm1m\nVC9wMzR1Tm1UeUh3cENEV3YvVUx6L3cKLS0tIEpUVVFJNkQxZmlKcmQwbWNnR0NB\nazJxWGlVRVpPUUZGZFRrK0xaUW9RLzAKtC8e4JUNK6tkxrhJjXTc/pqWx+ewll6x\nwmt457Ra8Hi28JikWDtCMG9F1/IzF94zS1rJd/sQiw67yIckT2I67Q==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_1__map_recipient=age1vhftscteyrwphx0jpp0yl60xxrjs77jq05mkzv88js7ckc926vcqepp2cj -sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBlUjE2RExYWVMzQ1RwczM5\nNExqSDNWclpDbWZaMy9ES0J6ZVBxR1dNVDJFCm04bnl4Y3RvZzRtSWRwaDdZVjg5\nZHBHbGJjYzE1YnlaMXI5MnhiWXpzUEEKLS0tIEZXSC9UYjdaRXU4ZjB4a0ZyYmZt\nL3NFck10cGw1MUdUSzM4L0NhZityVTAKt5hFMz4YzZqFc+YM32B7+PEu5MA4trEV\nBYqVFKPeLKOjD86Hu0RPfmqdDoQSlNXLilfcVX7zsYHGMf//XdU7Pw==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB5U3VHcWVlTkoxVm9xMmla\nVlhsQTIveWk3OUJZMXRTelROUWo4SHgrSGhJCkhsUkN6V3FDMnNtejF5aTBCUmhR\ncC9xRTRLRHAyTlE4bTVLSW5WTzByK3cKLS0tIGg0Yjg5Q1dmTWV3d0tPdkNBOC9F\nWHJGeGFaVVBpbHpWZlVSbDFWUDRzMEUK2UVk9avCNhBVNSmK0uwaHzOTbqpqbjvn\nwa8lL/YDW1hz8mz/6E99PxKYN+vVw6Ti6ihCz1CpQelbf+nsU6+POA==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_2__map_recipient=age1slx6e48k7fre0ddyu7dtm2wwcqaywn5ke2mkngym3rpazxwvvuyq9qjknk -sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBmYyttUXhMVlRUS1VoTC9j\nYmZFdDl5b1N2Rlo1MTBTVmdySVdGYkF3N1N3Cjg0bDNiOERQZnlzREpTRkJMYkVQ\nQ0J4ZXlBejEyNmFSb0JVQ1c4YzFvK1UKLS0tIDBFQzNlTGt2enFTRG4yTnV3T3N0\nM082R2RHSUlQWFk5VHVmUStSTG9FdFUKBfwlGu4CI9zTemI6gRHHgJSiQYU2khR8\nvazgQvehLeIMO5uUtPL68JGI7YAYsOMl1ZCQlBHXQPatctL1OCo3mg==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBVNysra3RYNVVKQjZkanlw\nM3d2bjk1WXg5UXlpQkxqTDlraUVqTm9lNFU4ClhWeStLY2ljczFlRlNqd0lFWXFn\nSC93ZHdTaEo0c09lbURqR1RBMjE0MmMKLS0tIG00L0JrUWxJdENUVkRGa1ZvOTd6\nUU9UNGp3Q3o1WWtOSEYrT2x3Tjd0UkEKwbS715kPpx7fsYFlzlUaDSL94jOlNvV4\nbviL4hmWQwDprywiG73gz/ny+jBIsZT9rTKyqhnYyVaprbBD291i7A==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_3__map_recipient=age1vfhyk6wmt993dezz5wjf6n3ynkd6xptv2dr0qdl6kmttev9lh5dsfjfs3h -sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA4U2lGdFJWWm5SMlRLS2xL\nZFE5V21qa1ZnRXhrajlxVldhYThuYTl4RUZFCk5ZcnBXY2NTaDIzTnlZOVdDdW9s\nYzExYXo5U3VaYlJqaXNxQXgxOXIwYW8KLS0tICtTTUVCNVFqN2RYZmY4bUZRaUNP\naElQZW05MVdRZ2VtRHkrUjkycTBIZWMKwLbIwv7Sel+oj73CnvYn6hCtPtsTEBGB\ny/6yGT+QcT8J6UDRdl90ZhI7fRPySkzbXcv/9Vgu7FyGrce05W2Y4Q==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBrT3JaQTg4QVp6cFJqVlZV\nU0xjUVNyb2Q5QUo1bEpFNW91YVJpWi9oaGlFCkxBWThVVENGanl4cWk2a3JtUndO\ncGxuVkE0ZjFKK0tIRmpqdmJhVnFvZncKLS0tIEtjZmdaMHYvYi9Sa3h6VFBWMHZY\nOFBWVXo0T0ZaSGp6SnFpVTd4VkVsaVEKQpYK8OuvlcrOa0hGMBnxVLbg188tUSfS\nA44eoOeew9ejUApKQ+pGInxFg3dA8kznRErwp3DTHnTXjSiPSgn1HQ==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_4__map_recipient=age1jwuvzyghfer7rx3qtqa4vs0gxyff0sg6pqgmqvp5zlhnpmvrkdlsdha4kx -sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBvd2wxNWJ1MDRUNWFTQ3dW\ndGVBYlVUYzhvR3VpcWR4YUo5aHNkS1lKVGdzClFMTmp2NERPOGpHelp1MWoxbDRU\nK3d1VHc3NDVHZkJQNllKWXEzQzhpeEkKLS0tIDFvNmZuVXpLb0hvbFVqWW9OMWNv\nWTBvT21zYnJPeU5TbHhVWTJ0T1lOWlEKxzeL8PGBzpQFJA1rcH7g03ti/4HkFO27\ngljFN0l8Eu8k0mkW9t9TR13nO1fvQZt7kpTmLlkQ3wvkYuNY0wFkfg==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA5eUFRRmhVd0R3aG5WM0RX\nS2hnQzMvUlZQcExCRml6bE9HWjhLcHp5aHlnCm1sa2FEU1VIY09RQi9lUGplRjRD\nNGliRzQrd2llalhINC9qQXB6dlNjRDQKLS0tIGNFUEV1MC9vd3NiNDdIRDE0dmJV\nZ2xEaFB2cXc5aUR2OXlxQ291RndTSGMKfxHey+ekNTE2gul78tdqwdH+8i8hZXbv\n2K0rJcsEDbO+IBUeVBswjqKQh+ruJMhEhnM3hoglF1ahEKilhME5Mw==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_5__map_recipient=age1pgxk292zq30wafwg03gge7hu5dlu3h7yfldp2y8kqekfaljjky7s752uwy -sops_lastmodified=2026-04-08T14:58:39Z -sops_mac=ENC[AES256_GCM,data:ES3CQBPMyHJSOe46T8e/vCAR5sbGsc0PhM5nSQYOL1fss5GcQedccl2M+/L/WoSXAFF21inb/eKntQOZG6kWDjHZJfGhxO6ka7xWsDCDuuKPyGHDl+pymDPZpT/S4wAUZzLiz2v/JIrH1hNWKrS3pVSInLAHX14SvDi25xOwQNw=,iv:padJN303By7ZvoVdKwwZEtx9XiFMJCM/9xWU8rui+z8=,tag:JzcwZGizGTIpja/zMmxVJQ==,type:str] +sops_lastmodified=2026-04-08T18:41:00Z +sops_mac=ENC[AES256_GCM,data:eYAQYR6nLI4U8aOPNR6w+ulUHrOrtkOpzX3c/sM9tLE6UDxJjREba/FjoBQHS+Rj5xk6xBU4LF5pmiFKdUr1NxaGqG2dusIVwqDvmyF5rX+HaMi1sRxs/UlE64xdEmzzmRY/NbpC4ebQEwHsQ8TzXucSWaZxCS9CH/ZPYmJ/D5o=,iv:wCLx+N5GFFFtMAY+iD2cYiTLROHOclyDcUOm4th33TA=,tag:FGhkF+XYa6M9XUhiY8EeJA==,type:str] sops_unencrypted_suffix=_unencrypted -sops_version=3.12.1 +sops_version=3.11.0 diff --git a/infra/.env.enc b/infra/.env.enc index c56d332bfa..3cb469542d 100644 --- a/infra/.env.enc +++ b/infra/.env.enc @@ -1,55 +1,72 @@ -AES_ENCRYPTION_KEY=ENC[AES256_GCM,data:FzrQWyJl65/yO+oyUBjiV1nGGdYA0XfPNFIGaQsjLpYrNpx+IiXjsDZfIo0ytduVQPp/iUVp1GPn57BpupdCgw==,iv:7/A+NHFCCU6ljCvC37m6Hhxr+P9BuY6nmd6eNQKQooE=,tag:mF8ZBkKVU0ATMHd+PbBJpw==,type:str] -ALGOLIA_ID=ENC[AES256_GCM,data:fB2TWtyk0WJVyQ==,iv:uxpJBLEXbUGeuI678/t8qAdi5Vb/Wacyn5UjwzTajYg=,tag:XUtGIfSaYQkFom0JQfIWuw==,type:str] -ALGOLIA_KEY=ENC[AES256_GCM,data:4o31SXcLs+fHBkx98AYUmFTy+m0RccBnERACY7yoTAM=,iv:ufVpTfOsJJ2tQV2tscKE8PpACIV3drgADBmXjSL8v0o=,tag:voBD6qofZ9xEa+Hi4kuYWQ==,type:str] -ALGOLIA_SEARCH_KEY=ENC[AES256_GCM,data:6urNLZR5+tI5xX6+Y13gcWyvkKY2Vv70u1u1j06Hpho=,iv:LR+98cOtVyX2zWHMABjZ63MVtM317jyhdQQDe5wa1Uo=,tag:n8OLPze5CylQO1s/U8dtqQ==,type:str] -ALTCHA_HMAC_KEY=ENC[AES256_GCM,data:N+MNORx3e85hQR264OrrPUE4OC5zHwraPsLBFXc4Ub9LcIHtHuwBrJJ3thamSRB75lVrBBChiaDqKkFB6q1GWrROX2bjsVM14yUUYz8Fitr8P0rdjeonFLpdAJa2Q8cgcFpVVLHhsRGbYt3UtyHF2kXN//5VIsg4/C56cRC1vC0=,iv:lm8rksvXs3g9Alz6PVezBLX2/R39YuWsrQ7FdGMXk0w=,tag:bZBHlcdSTGXqXhtlH0wifw==,type:str] -AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:rKrqrg+8OrGjLS+SUHEKziAEuW8=,iv:olGPry0h8DgfYLbZuEJStAmc8FO0uuKh90v9Bulxtjk=,tag:uscX02w9U648Sekv/WSy3Q==,type:str] -AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:o1kBlDvsmsZL9C9aarvdQMzUKiI=,iv:6ha79DAj1ta5WM1B3AgcBSyvcxY+ut6xoRRsbjOoULA=,tag:wWNHv+40BsyZwuulvYZv/g==,type:str] -AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:VWYNjlDCznMOsu7PqCe719qPKRyRL+nO5EwbWlT+hpm4vuBPw6KKiQ==,iv:oNdCgvnIQVngYzNICPK7Gr6D2hrhzr0k49JLodG6Z50=,tag:LxGdVxnD0S2B4/X3/m6kxg==,type:str] -AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:wz6Y5Nsp//nZf+ojOsrbsyzEQWO7RPIv58Gmh2r7iqL5Xd7zMYgFnA==,iv:ik8CEdJmi1cLWOG3KAw6+tF5T7xkLiHyraY1bW6Qe6s=,tag:DEMK05VNIbvZgQa9FD1ykA==,type:str] -BACKUPS_SECRET=ENC[AES256_GCM,data:Nfc0PtjXrWpiYNzC0+bLd+qXNJoQ9jmf9KukzeY4Qewm49uCI+AGpmXjwhU=,iv:nP0/z3ALZPWs3EcwenrdWnoNtaUcrDpd2hDz/mJ4pJA=,tag:DqDYoCECWjygkQ0CXW6rBA==,type:str] -BLOCKLIST_IP_ADDRESSES=ENC[AES256_GCM,data:Or5+vQuShF8aucfro2czFoEIG0e4,iv:IokJIv+x84YJF7EiHfTPZfgbC4GBC51HQy7vCRD1HHc=,tag:r/fRovygc/oCrxiOnSWQiQ==,type:str] -DATABASE_URL=ENC[AES256_GCM,data:u2VBjM/f+JsWzaVv/M3+6qk/seqVk+HNSUcuF2ORdglAlmrq+TCAHjUXQDg=,iv:wT5JRttNwZoSOnlFk5z8LSbllh+njDB+O/AfyOXZiCY=,tag:SIfGbD1BjwZnWbSmoy9jqg==,type:str] -DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:IBHnYPBLIKGfajiX4IxhQd/cVYWIsl4mgY6uI/E=,iv:4HUql7C/6M6mK8+RCoTdCJpOotwn1q+NtTDgxirECJY=,tag:hidBxjoMMzPTiT0zh1n2Rg==,type:str] -NEW_ACCOUNT_LINK_COMMENT_WINDOW_MINUTES=ENC[AES256_GCM,data:hjA=,iv:y8NCtCgEPeXepPfb98wqNKbBC/J1U4X7EAUOOXiDMU4=,tag:R5mBZ7P46QywAuwozw1mcQ==,type:str] -DOI_LOGIN_ID=ENC[AES256_GCM,data:xgoue93p,iv:kp3vcPyQOJKHrl6r+hTWW9Tl3bSFehe2GIb9UgbtuOs=,tag:mfdMztiJX43mRJXnkBZdnQ==,type:str] -DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:1HbaEKfSltOr0K0M1zKo7ZpxQyI=,iv:0cEM2iEbuUiCgcVwYhcUdSI6PRxuWXbs7iBBzrNofjs=,tag:Blf07EjoZFRKaLze1UBoTw==,type:str] -DOI_SUBMISSION_URL=ENC[AES256_GCM,data:P7PK/pvpJs8V57kwwYvVUsdHRkqrO2YPmxVI/IfGXQpJfcH8gOh3lQ==,iv:zaBPd4JrSdmJtpDotl6si0e9skRXWdCoQt/DD++QAV4=,tag:oUOxQb3qfM9y8ew8VtMGdw==,type:str] -FASTLY_PURGE_TOKEN=ENC[AES256_GCM,data:yyd+/BTLbqHckJoeyJ3CaAT6qjToSopnr9QocsQFfXI=,iv:h0QF1rnL2t+XlFZfq4+dY3cj5Dx8g+RUE1dZadoWANI=,tag:z1G5mEMrp16ac8I7Jt4dOg==,type:str] -FASTLY_SERVICE_ID=ENC[AES256_GCM,data:9JynM0FkRgV82hHMqUTn2s7bD+YfVQ==,iv:J+e1GCBhKy3dZ0FrsM0WeNY2FUrHi/FE4xeyRpKhCJo=,tag:AIHsLMCzoh18QPHKBSehSQ==,type:str] -FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data: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,iv:I8Q8aBKYXan8D/XzmHkdd/U9kbZ5GsN1Oa+OqKJUjBE=,tag:jtd4uhBZSbVEtMi2zxNHqQ==,type:str] -JWT_SIGNING_SECRET=ENC[AES256_GCM,data:Bg0PwOmMpF1EzPqQ/wu5woARhTV4V6bKxza0AT5SI9mfMF3zxuNMF2emVAsQFAu3oyJxdLdFvGED4YjJH7nWD1uLZMYaaguB8iWZUhMuZAQKYkP+aiNAiDn0Iz2GWJuhS7rvaWZsaIfaRp8cLV+8i+jVib3GjgfbC/eQAm3YLH1jETgbajHE5vwX7tl9NJzik9et5cFce2/+rM5iOfcM+3vmYUyrnd7n7ZuYSsPBLSDwhNFu112e8/KXs659Wq1F4Siy+WHKE7NyA0IotjTfRVnzzA4VIWM/0ARLDZAB5WqgvARu9cN8C/farFM0ve1LoHD4rgzl2aXdw7yWu9eJRXY3KOubzD06oquZrnYJTXsll/KiXCPg0OijaZjPt+BwzH1dWsydKBlkiqHWdZZjsu5NxOqu3TDmSE85T8iIVE9ZpIgKwAwV5sPqKPR1qcHZBQcLIO8VbNh0/XzQTeOcLsPnzrjhKquHe6IxvpI3u5qLH0TLnUeoOQtj6Fo8Gt4bAWehJSYJyZpgVnpqjQnFBxfVGGDay2du7VVt4pK7NJqStHsh16nqewjC3jF+mMAFSN6Jz52fIsv49nmTXObtKHs16ldm4fHkPPvjIaavoYAjhVO0h/yDkXVU2kLCojDRBVOSPhVA16Q1x0sVCygfOUZx8NTh1g8QWEalWphfSzg=,iv:jz/l9gBsVEWv/xwqhOkujOQfM9k/njJe+CkqBDQYNmM=,tag:fKOHI3yzSRrvaJjdS74orQ==,type:str] -MAILCHIMP_API_KEY=ENC[AES256_GCM,data:BqWxiPEWdOlFQpZxoHQhYRNALy+mICkLHAedLdYICGxKCby3,iv:4ufSooSgj4qMBcsfTLyvWanE/hf5mf/k7LLkUj2iZCc=,tag:JraE4se4+aJQxFcFpOPpgQ==,type:str] -MAILGUN_API_KEY=ENC[AES256_GCM,data:GGjA6d8vmNnH8ZRUR1J9M+R0mF14sUhTlcqX6xXweAA99rpA,iv:v9I0Q+86scRlIBhgBKtupTmrYDIg8ECUDqn+4q7xnik=,tag:qvTM9rw1uTZBNv3phYimnA==,type:str] -METABASE_SECRET_KEY=ENC[AES256_GCM,data:ZeFKoY5/DuQcHajeBzoHt4s2zuPmQfck5cWXiC1heSZmnxPvdWcp1svxnlTFp+Ph81jmOGWSKo6PDYq/+g+hyQ==,iv:oBmsG76duMxcQI0HCxqrgatXoeyyFiNMgpGhF3engRk=,tag:6h39+RIMhsLGKQQp25Ej8w==,type:str] -NODE_ENV=ENC[AES256_GCM,data:izLFAHzaZEh9kg==,iv:vNjqaC42NkM/IJFxgnreFMgAosmSDBPVg5Gltn4130I=,tag:h5wgD9zcK+PxfkDJQPXnKw==,type:str] -PUBPUB_PRODUCTION=ENC[AES256_GCM,data:hFd5Kw==,iv:lgXcSXBNZlP8v6Fin/SblVOyqjq/cYnmQETCSVKroII=,tag:2YMNcrzNeIBhH5Dx1dlZJw==,type:str] -#ENC[AES256_GCM,data:5Lc33H4hD8+Xj+B7PFa/yF3XeN3B/F0Kdg==,iv:g5cY/+dfO6/pFpLto+x2BWK+zigYNaGkX8gZ3QvK4B8=,tag:fvnfrAf6voF7IYIbDwCowQ==,type:comment] -#ENC[AES256_GCM,data:nvOJM0Hcg/amQjsvLg9Icf+5i/0PeCvNsyEF7OwN999OsEp3Zw==,iv:nXePC0fqoXyPcW29Gx8ZBDQ9/CxLruhJstpeUN15kVw=,tag:CvO5LsiHfCtMChTq7k+Nig==,type:comment] -S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:xzCtH1OI1sLjZvR6qtro2HFA9LYkebr2ukw5wyR1q4c9jng=,iv:k7b+WKHokihnvby2vxqTP4ch4fIut2byikmpY89Zndo=,tag:8dLesiwz8BbvMYkm6ih8Wg==,type:str] -S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:U45a/exQ7bew48ayU/BINfhclnU=,iv:jF5EdmOWq6eBcl7aHpZjwULf0ERBXUli4qlQzxy9qQ8=,tag:oKATOloKHvk12dlbaogiBw==,type:str] -S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:HSXDCRyZ54mQjH9xzPYeBYn2AbQ1Ikb473RonwhQu/JQQFjjxqyZTw==,iv:3gEN28BZiWHKxBWM0003wmI1JKl7pPjmahUk6m8bhWo=,tag:24LDZ4yVd3SvXdiBSQ3xqw==,type:str] -S3_BACKUP_BUCKET=ENC[AES256_GCM,data:mTdxNcT7bxGGN8Y=,iv:DgrPacnr9KJbHiAbYAqa6QDVdDb+7p1Q+HaEK2o5xW4=,tag:FzLs7p7G0JmpVOXMLDM++A==,type:str] -SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:OdMmFZqG2feiQEKlNOzJLcLcnlKFzwxYWpze25uHlAeoBfbp8eg8K5FriQGuDPWwuG61P+ZL86VR7dBxhotWKJsjruwI52JyhYvPFkCrnQfKDrXl3FN97wXe5sh9ZIUAKmatB+QOina5TCjSQu2PaVMA2Y3rLTFjhZjT6c7NbuY5uM9WPuHz/UG+xhqbJXls7cgfET2HhyAZmL0TJM9ytJXmk+jHkV3sqUbF+IAy7wx63nklzK3fgxoOXw==,iv:4FA6mZMc1zSCLJYdXpj38+2/IEscq9qkZmZUJU2A+IU=,tag:RtQf/I/f20VJ8aMLGRtTXg==,type:str] -SENTRY_ORG=ENC[AES256_GCM,data:xO+G,iv:Bj5seL8b/Y7IsWV4JBTC6qP+z81/ry6UrL+kdLJj8rk=,tag:7OH5qogKXt7b4AuJ9CCPYg==,type:str] -SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:bWE=,iv:ojxFjPgmjugTUeJeOazO9RPilFrc0p7r7rAe4HPhQvQ=,tag:dluXFeHo2jWAyl1WCG6ixQ==,type:str] -SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:eXyN0jLy81aUlTg4xEuKdRB3vGHexe985UsTa3CUj2gplKVjqmWb7DxD7/us+52O0oKRHVfWnmt46WESlzw9TLVM9HQPLgvuDKsJ3PDjdQ==,iv:/uMvDKDO2LcBI5sINc4RrpoHYJ51It1hvyTLK+ZcLLU=,tag:iqpDL/EFo9yHvdyawcW+XQ==,type:str] -STITCH_WEBHOOK_URL=ENC[AES256_GCM,data:+2uiQZc29PFt3ugxv+Xscqw2CySpDZwXS4m3Usx+Zb7BOxlLtu7ov1/4qz9zECWEVPWUqGDEATs3C8/1ZYa7erk7LXJh73rRkmKmtjEWKJ8YvANw8NWFIyMFY0OCrqx9As5O9joWxZZsyEssFMNwFmBFIlLs,iv:mTIx5ZZbngr47Wxc0GcIkLLZt9r7cpQ4bqIoeC61d1o=,tag:iEvvNqdJo/xDs65a6vQ1SQ==,type:str] -ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:4bOh4osMRuzQ7WSpb/eRlKE28nE=,iv:UJdEMFYxAYxhK7IMWzWj/X2i+mUkP9RPhi22xbW3YUI=,tag:o78ysd6fpA8gTTj/M7dx7A==,type:str] -ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:W7aXa/yv3cJ3fMnJJbvfqAoNJUs=,iv:Ep0SW0kEQB0GDeHbmCPiTN4sDNCPCStb51tQHDpe0Rc=,tag:mPUp3hUz55cSzOc2nXBMyg==,type:str] -sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBHTyt2d1ZRU05wU3hRcjRL\nd0lndTVNY1V2RDlvMVIzV1dzaFYydUJ0SVhFCkJRMXAzS2Z6dlJ1MjNwMGtRUFYr\nYTl1RDUxZm5LTU00VmZaUUp4MjdER1EKLS0tIFIxbit2VEpPa3FnRWdQeFVnUUd5\nRngwMjFrZUhEK0QrOGo5NndONFNYZEEK0TCEFubEjXVlPI2otclTYGzSSv1XtkkG\nIXwwvM2KKlxlLtjrE6PSuBXirLUKqayk5larPnhdIOYaokpYqGutGw==\n-----END AGE ENCRYPTED FILE-----\n +AES_ENCRYPTION_KEY=ENC[AES256_GCM,data:ImDomntgoiRqJ7V6gNH07TPxEz3Ry1PoOM4auHxx3PyVNL6OKVs+Suah/7Gks3pWZSfaMnLC4JsRNhHmfNe9Mg==,iv:QfhC24pKYCNar/p4TZtylTjtFqMRCCAf6BqYHSK0WMQ=,tag:D7sw1+ChCKlYg2veNMKI8w==,type:str] +ALGOLIA_ID=ENC[AES256_GCM,data:w2nPzxb/jRbEoA==,iv:XFeBq2FmN+qti73sve4iI/j7iL2EuO3Ol3UQ98EJT04=,tag:WRA+LFVFoksQTGUbsSPcYA==,type:str] +ALGOLIA_KEY=ENC[AES256_GCM,data:Rpwzu4e/nE+3POcfNrJMScmEIngWpeQWL+KPGyXWIX4=,iv:bY7iExc/1N9OcVxgelZ/DmZA5d9FZmdoaoF/MiZbIFU=,tag:2X4EVU94+/wg+Rx052t4Nw==,type:str] +ALGOLIA_SEARCH_KEY=ENC[AES256_GCM,data:bez/54bbFnUyBdd0pHfCk1jrgJLRUq5fHnkMuZkrFMk=,iv:xhk1NgkjsFcyxQ0l7PRW6XDDsiLZdUYyJESMRouOoE4=,tag:IgprFx7Unr6NARnnSrOfeA==,type:str] +ALTCHA_HMAC_KEY=ENC[AES256_GCM,data:BWf4VuzloNav0ISFuw1bDDHKhkro2P9GlmdqR+Xa5K2DL8Ush6zxnM3QQrZkB/iNbQoDpkqqW8TOYmuekzYQSNVCGQC8BpTWkJEDxvOLoEhviUNF8NRTyTOgu84tLrgjOxIMnJGkqca4FO/Gzlv3zbHSxLDdTEK3DM2wpnOpkr0=,iv:LC2PfkJiClv9pftsWy0PkU6x58cWzyNfUQ4hdsSr8Rk=,tag:Wha224e3SDBJV9uVw2OC6Q==,type:str] +AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:6EPvouBaN11RuNN0W5KU3Pc7Uzg=,iv:Hc5Ris2qGtL0XBu96x7Vx/oTj4QyZtttkLNnIfudLJk=,tag:hDkKCUXNUQEscOpgUmWPnA==,type:str] +AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:ZWBShcpUav3NqAMV8xmZKrSgJjM=,iv:EZGHI+/y6guTptcOjG1UWwAWmTIfvzU/p7d5JX33Gy0=,tag:NFXJ/Ntotf/yi4fhQ7GXEQ==,type:str] +AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:GOp/0yqRi4b4779Cdqhtkkty9TTgLEBTEi/dEr+tFV45pFMBGg7k2w==,iv:GzrPNUzeVHXQpCTiItw3CtWe9Ov+jOhnmHzMD4MxUjU=,tag:CoZZ6viY9+NXV2/G/HhHXA==,type:str] +AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:q6aMcaB/IjRbOU4aOzr2vlCa45VZZDDHskhosrId2GfOZ7ac4cgX1A==,iv:pwWV4PxRUw/ryl9eOMf4Uydy3pHNZj8NFnCr3K/XMA8=,tag:wIkokzITyXs6drIBb8BDLw==,type:str] +BACKUPS_SECRET=ENC[AES256_GCM,data:x2IoNXKyqbhcDijrtTcdo5NU4AIeRXJJa7T/eS75PFEO8sHm6WJr5Chj5YI=,iv:X6Iwy3plOlzNaqfQLJpOylq/303idXVEvhHmhpUHrp0=,tag:Tdztp06rBQtZz5AV/WwZVg==,type:str] +CLOUDAMQP_URL=ENC[AES256_GCM,data:YxpL5aKcz/Bkb3uh5/drFiMX+JVpSF59NYlHMy8KJMuZJjS8GJeUcmG7kVbA1Y4afw==,iv:KxmFTaVq3fffQ+bqDXQpG2T1R/0IMmcc7V8c017ZjuI=,tag:vvD8U1t0HC3BnKfmN45qZA==,type:str] +CLOUDFLARE_ANALYTICS_API_TOKEN=ENC[AES256_GCM,data:cFNlRlW5sArIFIlFLSdq8HWJydQhThbIoA3E89bqsFoR/VF5GM07yCbTVexVPntz/ZqE9KY=,iv:6aMUEVeqdL1bdkKxfa8umGgu2HTbDzgIKivrGyLpvUM=,tag:dy1W0P1XxegQuyZaJpAd+Q==,type:str] +CLOUDFLARE_ZONE_TAG=ENC[AES256_GCM,data:foZTDlM3Culpo9lTsJEJvo6t6aKYpkFhbeD+mZtRAd0=,iv:EMgfTWvjAeo5HrQMnvEDbCvg7/2R/tHxpOIMDTU6Xh0=,tag:2f1TD3goetMlS/Xy14YOWw==,type:str] +BLOCKLIST_IP_ADDRESSES=ENC[AES256_GCM,data:liPElci034X1Jibv/JDYaDppFRfe,iv:a56P7OdxA0/gt6Un8sc2GCKJEERlnpNxHtarxCeSCNk=,tag:OC/DlQwHrlGLnh2nknl6ag==,type:str] +DATABASE_URL=ENC[AES256_GCM,data:z+yySjnFdUoeu6xqLH7lG/Ezq6tbR9OV2CpJY4oYOqbGamh+Be1cutRMo0c=,iv:XN5JOKgnZosx5MAOJ7W84yaiC5niuu/VzrYzKQhYAVg=,tag:s7es0GFc9hKd+PviWkfSJA==,type:str] +DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:J6JaQJe0M8VBEcsV1GJmvhkz1d+T9tuP6eupe70=,iv:kUK68uerq3uIjumaJBTJT1AWd0/FhnQ6aEdnY4GbYfg=,tag:El3W18f8tDIAxS8Hm3qnNQ==,type:str] +NEW_ACCOUNT_LINK_COMMENT_WINDOW_MINUTES=ENC[AES256_GCM,data:y0k=,iv:VwqG2aQY2udcOwYXIjJwVUGpMciEVOzmCqEVzS9vjTI=,tag:dEkaD+FKMU4WNbxq8MCSFw==,type:str] +DOI_LOGIN_ID=ENC[AES256_GCM,data:v9CYztET,iv:m61h9Nh8pAPHa4mQiEq1Ih0iWmk3Gmxt94GSky5QAXU=,tag:6jH0q8JknOwVZswq3HVgnw==,type:str] +DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:o39d0NuGgmvaDvXnxdveJUS7Ye0=,iv:arqnhXyMbut8vi+jABwSv9e4kZUpmMYYN39A9lLtZ/U=,tag:XDP9CoAvAefafh9+aNEUnA==,type:str] +DOI_SUBMISSION_URL=ENC[AES256_GCM,data:DIqxKtUFxrV/j38lSsBNT7QafANuD+X+4RTE4i3snPEtg1cuFq7OMg==,iv:JCWfrJeYd8hlB1lAiQe+/8u0I6UATVGRXFYLJ6Mc4wU=,tag:TU5l98yY+rlD1wD351onjw==,type:str] +FASTLY_PURGE_TOKEN=ENC[AES256_GCM,data:cuWw/mKVWChROKTT5NtLM37mW7CA17cTsmbO89saoV0=,iv:ZmIbX5mmVxQ+MVI9wR/mlfZc7HgZdGl75TXo6MapEtI=,tag:Xd3876/rVdsBaUNnYewHwg==,type:str] +FASTLY_SERVICE_ID=ENC[AES256_GCM,data:JMWh1avfQbkUYIswuPmeAcyvf8xP+Q==,iv:XwkC0fybGdDzkcMBYC0dBXgM7ebOtLeH45DIKNeTYqE=,tag:e4JNwIHWfpfNct2BqAsyxw==,type:str] +FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data: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,iv:u6xJcjvD/Bax4uDHdEWjLTmgGNmvwJW8/9yil5pzviA=,tag:xEtFY2KPedCY9jhnMhR3lA==,type:str] +JWT_SIGNING_SECRET=ENC[AES256_GCM,data:9xfp7SYJ8RijYytNZkJTwmD0nCkbgDZyilDgB44ykf8C6OcJi34ED1Wh/xOq0X3g/7D7xb5rWidyEMWipF8QlpbzBRyy6E3hhshZrvZGh/ZoPbGEyRJMJLEMl2J95tjW05+KoA+Knly7Ldbyaa1oIb7caKeGhSHrjsD2h1iGi4iiVNutTuuTUsZzV3RpWfss9r2grxY8GMf2tft96LOirxCTmDuJLX+D3jmJxq8uaX050YZU4kteDjmEBHbwLsiM6DxJm7fYZltjGk7L+4ahItU28EpItmX20Uq5V9L718tYOAAi06sTl+wVxbsy8th4HWzKgdT8zvWXfaA2N7VmE+DfgFir5v3fkLyf0JtKpUKQvLQxoETdZczg+JiAJVHXQDXHPzNu88+rL9a+VQ2AhNmGqoAyeH4fUXdlrUhrzZZlI7hIqc9hPkDiRVXM9Q9DtXIGQtP7Ed/TtfnTe3s+JXmrfMTun6GtX77Pc1yJqhN5XzZdeBtn5B3rJYs71uRmclFRess5EbgGPnV++cPui27vXtiA2OnhdzRA8gftlY+fLjffGvxYc6rLBH2dtrwLppBIm3Do/Fvg+GPkODKj8PHdJSPn+zT0+Wrpl5Utg4KVF0e5/mej3ZrQjNg9BCoAebkDX6hiU13rnuNJZ8A3Zk3yEE8wQTbDwgP/1F0gSIE=,iv:ICcthVLDEZF4PkPM0hdb9KrnbfLMpI3TtxhCpRJgAAU=,tag:LI9Cui89s16QlTF8wC8UGQ==,type:str] +MAILCHIMP_API_KEY=ENC[AES256_GCM,data:zybJgXSqFLdqj3fDXPE2K6bmeXk+EQCPv/ZJ+q+p8Rf0T4zi,iv:CEe/oTRxyvZ82Vww9MEJFLcfociqmth+3px/BNk6ryc=,tag:bPJxD2dFiPj8FlRGqWhloQ==,type:str] +MAILGUN_API_KEY=ENC[AES256_GCM,data:Mqt2MVt6dzrGxBwspUcqWWRUiggCS4uqZpLExYc5KXef5AUS,iv:hgNl8sfOsE8tzh7P1MnGV+iWE/Ut6tkjmRCQn/v/+sQ=,tag:ayKPrSfwVUzYsVhSDaRcSQ==,type:str] +METABASE_SECRET_KEY=ENC[AES256_GCM,data:sJTztDQwtGtWwyB4X8vH+c/FYLYk6k2/NfW0rhx5w9Oh3/4GYsfaBYXALRljOqAnl7zTnV2EmBuRtfnajeYRBg==,iv:LiCeiutogLCRjtzBEPu0C/E2P35faleTgngX1BXy09w=,tag:dABdSHyEs+IoAQxt3KZE9w==,type:str] +NODE_ENV=ENC[AES256_GCM,data:6owU/kExqQIfPw==,iv:ZqmbhziZr2DgpdgbefRT0Mj/jYL77omPtaHAcWvl/9U=,tag:+3IDyKHLmSGoIWhtjLSizA==,type:str] +PUBPUB_PRODUCTION=ENC[AES256_GCM,data:Y27rHw==,iv:ChbzaFTFjJswwfX4GBwSzJ6XFstM1jFOYO26cVafIV0=,tag:KsuB4HtCj1jv8cnQH/P+Gg==,type:str] +#ENC[AES256_GCM,data:J1YoEionAEPIvlhln7tmmgk3aJw454bm5Q==,iv:u3X7iGXFQ+9LwSc2Exp2rLG5kaO5gd+OPB6tnkRY3Ww=,tag:hY5zIVEU8PieUMiXqL555Q==,type:comment] +#ENC[AES256_GCM,data:C1lwfw+ut8wVItFyljypN+SMNrAYsIUbbIPLrDSRzgWqcCfu2g==,iv:2AYx21NC+PmGGm2Qpp+mxrmZPneCcn5JzPS3qD7jYjo=,tag:6O2sC2MsYWREYFYVriXjwg==,type:comment] +S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:P8JIrJgi9I/BXbZ6EoPGbxqrZ9Ug97nJUoYD3qKyZ15hB4o=,iv:+xXcnhFHoxgZ1dkf+jI/UTiKkHw9YowxS6e+Jhx+DCQ=,tag:TFUUF8KI9P4m1D6r7alJjg==,type:str] +S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:U8UV4i7OMVkprd6zKvsQC1cXy7U=,iv:mthq1npvO1z+761I9tCQKXVSE8MBOD0FRWI0RKPDcWM=,tag:TanDb7VWLOYV7uN1O0Vc6A==,type:str] +S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:i7LTkQKFvDhpEP59qeGJxIjUdhRta/TOhU1N+ZukazvzJlPgguKe9Q==,iv:u8/LXia5gWHLDczeVlRzu4lhv/8zITb2ezdw6aZSCr0=,tag:D5LCcY1zPQs3/jHi8DaNLw==,type:str] +S3_BACKUP_BUCKET=ENC[AES256_GCM,data:RAcCXE6Qo91mlE4=,iv:p7DFItlBmdijoGAvqAT8J3d3os4PDLwvXBk+udgpnKg=,tag:WmqIQfNq60JJHUyqPJ0C+w==,type:str] +SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:2omfJmJywibF/+h/xrf1Nj6Iq29Fk3RFwEt6GMFGIN47X2NlJzVM6BeMnNwJ4cdmL1+O3WhIYp7MFQpved6g/ahkjGbwbB+jFNLL++suFdLZfFl2ifzfZHATqamSW/PD/jPoM8H+rb6b6mv0VEElwJJSVOTqUZLsdtAMdMAaebG8bj4E2BoR96ZFcT++wmVCXzVkdeIbFYkfUCKuBeqcEj6sfO1XkBGNTt9f0AzR31Umi2G/i5iCQw4eHA==,iv:OJR9D0NJMQd2bbamfFI+1dD13SH5LJj9hSmBSs6Mbrg=,tag:Gwdn7L8a/zEYvqGYATMnfg==,type:str] +SENTRY_ORG=ENC[AES256_GCM,data:P0JW,iv:RHjbS3mCvQ2TUzTbwe+N7mux0zb0g0BNoRZ18F3GwDU=,tag:k77snhkmGbIR4wVmQM3J1A==,type:str] +SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:Ueo=,iv:e5zuQDUHgW97InYb7cDpNCdLbvTR9tMz/MxqRCzSLzs=,tag:cnRNQN79TLL9BgnWokUFYQ==,type:str] +SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:WlxgKnTiO8f4JOt+9FQKOBEfirf4O8N9xoFDw3RmiW6o2sivnSFrJCU2zHXIje82SlqiLfdne8B7I/s4b/u4KphpgjyJDufxwivj5bE+3w==,iv:2devpXx2Ni62+nq1w0KJzGp6U8f3ku74ep/gFbaXxh0=,tag:hzcaLb7jgdraRzZc6n730g==,type:str] +STITCH_WEBHOOK_URL=ENC[AES256_GCM,data:Mdf9Z3mxi7PBFbIuuLGkkmdAn8KVBXZI498sETKQaqHPrJEfE0k3kbnnGlk8P3yJ8s8GraStgVO87VUAWbvURzbusUWQcAuRcrBcot1egWPA9JXrkVnAbaaH1YmYwPgQdswY4DXoOL9L2TDdrv9DPnsj2Xcx,iv:nJY08CJbPULpOIqgMovS1xMpMwAxWb6Y+DqEXtIZUng=,tag:gx2J1FLTWy0Yp/rhV0vrZw==,type:str] +ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:HlDrv4PX+7pcNEWk5cX46aWye3E=,iv:Lehbl+0So5kEP0rqkeME4DNuEKMXqIfrot+b6vEQ0Yk=,tag:/qDulwICedJQeTEjXDnj0w==,type:str] +ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:LuMHyBlfTFsLSSeuv0W6P3WS/7I=,iv:zbAbs9ItxDZ+oMforSXGc+w6oD15ByrqmMwNVxCE81c=,tag:eROFEiNnI7weYdfcptMPtw==,type:str] +#ENC[AES256_GCM,data:HB9zCs/Bn3JWt5nfY7ywbR9dSSR2Y6HsBYoDmT8UlxHVfkF0Pm21jHTDppml9psdPpAi4lYu7oznwzDLQWbdUP3f,iv:79WaQwgqLhO8EB2JfK/DGoqoc/92hLtsHcu8vWOGVB4=,tag:Szzi2G5oXdNN9R7Th2DySw==,type:comment] +AM_REDSHIFT_PATH=ENC[AES256_GCM,data:6fetSGEP6Q6a5YubGsaRowCgLAXZ+u4cGqQB8SGbzGGvuzoocwH+2Udj5Y/wYgcUKo2vln3ws26i/x/Gpk4=,iv:Y6/mO07KHGPdTeZyVrhwSr5EXIbMCVuH1ndH55Kpk/s=,tag:dREW+CQB5Yqw8lDAVtiunQ==,type:str] +AM_REDSHIFT_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:S7eRcRDx0WwDZzE2KA4eC2Nus/U=,iv:wS1hhPPLEspuNk83482DnLlRSDCEb/kZ1Y/EjqT61Og=,tag:dgAb8UNCdJotocvH/Ker8A==,type:str] +AM_REDSHIFT_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:vABHlCwkSeeFSDz6EJbOoSf3Kzd9A0/QINeS8glcCpLQFZEo/VTpdw==,iv:zUGJXOiosRV20yOFJG4ld34AM1P1otRT6yktDCI3eN8=,tag:oYdSsmOmifBEFy4jOet1Zw==,type:str] +AM_REDSHIFT_BACKUP_ROLE_ARN=ENC[AES256_GCM,data:wAxoj5m3U6y//ab7wSD5ddw33XN0V0h2qur62C+OJRVs7DxwiadD/XvKg0oja7SCnF58,iv:6enzRa0cCL5TJJt5/KsrWpTw3zgZek1R5XKjS3Ye4gE=,tag:uZL9XucmnyFca9SAsxgAHA==,type:str] +#ENC[AES256_GCM,data:mZY9SY+1F56RA7VatJ99SWqvKxJv/L73LOaabNFq8LUQe1iIKVaqB8z5SpnAz16x3dMu4j+kSiVuYqKcKEG8PyCncgKb8QWdeFonUZo6taJ6UFG//PRpbPFiWgOTOy3xNb3pcCnWqFp7RVJOUlhvshH5PlpBiSwg,iv:LZYqvWi18dNFKw/GCrYcp/+TKhIec3LlMrNsv76mFxE=,tag:JLPffRZ3wnMXSso5ov30Lg==,type:comment] +#ENC[AES256_GCM,data:JDHFlIj1JSeTWdXCNs5IfLdwn8N7VmiIN1UjBruupfJwYyT24MgQr6uowVXJbGAxAeM=,iv:/90NfiRwvBjHikHoMxn+U/koRbOa32c8pnNp/tzsQ58=,tag:kPWf9w2OozfuIppqCBjkZg==,type:comment] +#ENC[AES256_GCM,data:VQcLHOhaUfda6ndL225OnwKbvdtFMibLUpCaHtbElWbEKyohl19kUGF743tV,iv:DSLoSCxqquZfnuk7aCFCDM5smTdFIYM5shl+TkmdNLE=,tag:V7ug3uq4ahtrKhk9SqDSCg==,type:comment] +#ENC[AES256_GCM,data:HsGCaBxSXLBO3rvlI5i8bwfZv62Fp8I=,iv:pfs4BoVuysmjEPnKWV7VWQEAadxr735VBKjiyv8JJ/s=,tag:RNutgP/R9OsKGiq/ujba4Q==,type:comment] +#ENC[AES256_GCM,data:viFVI0ZTQxItyApTw8QIVFTys+R8vXQ=,iv:A3fT4J5/Oaqgb5M0Nl1ak3UzJJrssZmfYSM5oeowfSY=,tag:IP7sd34vExcKGBKc/2iMPw==,type:comment] +#ENC[AES256_GCM,data:09ZajJPmVHV9Q7ZVi6+mk2Jq+47j1p5Trfsv0TCUDDdlTdYxtDUf7YxmLBVmj2LvSTCHEYwyUryQnhiJmris9dRBSmhn7sRor4+HjQ==,iv:7EHHNan5SGfwDHgkkQy80inXNey7w5EMF4yQVIUXUCg=,tag:qwLYHgBOn5iDQLdphnZrUg==,type:comment] +#ENC[AES256_GCM,data:qO59sijoIlh8VhjNjP8XW/otEF0=,iv:MMWiN6Sse3/xizR+LERZDO3/QfNA1ZhLmweUwzRRTlE=,tag:xtlXP/8lrjamaqYB8CMfaQ==,type:comment] +#ENC[AES256_GCM,data:HCbdkReUvzQxlUa2GdTN/ijnrw3C39I=,iv:mfcBm+SFexV70QY8T73dalPxEvxiu23s3OFY/XwKEKE=,tag:RR3fga7f6oqy+p/XKxXo9w==,type:comment] +#ENC[AES256_GCM,data:SkzEDrH3C7OiIaxOLgNA0Q==,iv:QSN7fl+8W6Tjd0cBOQE7vc4LDhyUsA68gHyf1iKIOpc=,tag:7ctqCPb6tG4ringrc9T1qQ==,type:comment] +sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBZK2Vwc1VvR3hkb2ZsMENx\nTHFhczI1TUkzdXdoTVgxVWdTN3R5bDI4YVJRCkxsNndQWUJ6cldKMThuSVk1UzZo\nNGJDUFA2b0hNaUtmbDY0Y2QyNm5UUTQKLS0tIFhEMUdvbWVaNVgzZ2dVTVNOckZU\nOWw2NHZnS1BmMUlGdkZvOE9renFzL00Ktu1JZFXY89tl8OSMxsqPgR07RTDl1nic\nfr9txM6rIyrI2fqKc5iRN3E0vEpwN82YxBY0ETy/dJiS/CWLJ2XysQ==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_0__map_recipient=age1wravpjmed26772xfjhawmnsnc4933htapg6y5xseqml0jdv8z9hqemzhcr -sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBuWnJCU0dXWUtVdnpSZXBn\nckZYbmFTVmhLQnFIY0ZrdGtTY2cyQkJ0SjBFClhEMi9WM3lpek5SMjZFaUY3bXB2\nMTA2YVdDNmlXQnZyUENQMTRGOGp3NGcKLS0tIHRKV2VKMHpYMk9Ta01yNzJ0dDVu\nSUdvUjJpOGxEc3hhcEpDTFBzQUx0NWMKSe2zCnQ5LCrcs65nHVLjH7uwhxHEBHLu\nvwdgsPA8uJo/1lVkmOOIT2innBN1lNRGbBFX6CZDe7hUmNjyS17oig==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBlSlJwN1MxM3ZnbEYvc1ln\nTExNSE5URG1vQlNHNy9KVVppVU1kTVhWQ2cwCk82cmpVQ05LNVMxb1F5aVBVSzVG\nbXk2TU84WU1FUlpIUDhMR3pJcGZtSVUKLS0tIDJrVVFXcm0ydUgwT3I3OFM4Rm1s\nUTVQSmpOTU96SzlKbzZHQXkrYldyK28Ku5M2l0PuX7B5odb0wIwboA7mi14F0cQF\n9DPsBju9Lu4un1angQOX//qQGNg8Zw8f/bIjd4JNZ4wus9CZ8L9TEg==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_1__map_recipient=age1vhftscteyrwphx0jpp0yl60xxrjs77jq05mkzv88js7ckc926vcqepp2cj -sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBBM09XU3JMUnRyR3ZpWW9B\nWXJvcmJva2E3Y2ZuR2F5SUFPbW95TWZtMmgwCnBoWmNKcHpyV3VDZGQyY05zNTZP\nb0t4bXJWeG92TnFZeFB5NlRRdWUvTXcKLS0tIDZzejdPZ2lKejVyemoxYkkxcis1\nUXQwRzc5Sks3SjRMUUlpcko3elRpRzQKsG9vHJOM22VispYg6MG6vH3JNkpbiOxK\nmEuJo372CNR+20xdhxYphiDrFDVcjSX9q29tMJ5CmLmEZNp3u1US7Q==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB3SG5HcVpuMzltWUZrai9z\nQXVhM2R3NzNNdUdwUVBRWlZKemdHVGNqb2tBCis1aWgzSm1UUzQ2QlBxVEZkcTNu\nYVYrWmRWbDlmeWxzK3hHZTlTOVFOL2MKLS0tIGVLcHdQL1VWa3lYTXcyNWlodzJX\nc1B6REdJT042MWtySENkUndTQjJaWVUKjP5j0pagjjmRAXTeIrQjuBFQFIE2UTZI\nAdziOXrvzp3hurestYN3xseELuQLESIC9N5/SdC7XD6HSzT7ejsTnw==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_2__map_recipient=age1slx6e48k7fre0ddyu7dtm2wwcqaywn5ke2mkngym3rpazxwvvuyq9qjknk -sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBuRnM0YVd6UlpJbThHMkpC\naHUwelY0WEdpZTJFUk9ySzg2RVBSQ0lBOEg0Cis1UUFRb1RiK1ZsNzJBd3FFYVRK\nc1JOZ3htaTBFTW9vUzEyRktyNTQva3MKLS0tIFZCeC9ORVd4ZVhoMCtGOGVpd0lH\nYngvQWZhVWUzdHFvci82K2FTVFRoRkUKppE3ZbBRtU9pznG/LCDyeDbxDpWeeLeW\nXInx5UcTX4rbuv46FVy5DiGcrDVKGieZa6PZLKxuV+IHW7NW1SCIDA==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBxTzBhcENLblJDV1A3TjMv\ncHN2RzlPRXFEZGtXWisrZWRUb1QwRk1LS0hzCmkxYi8wYVBROG5lcXoydjY5a21k\nUE1idGZGTXB1RW00VTVISGJIQVlHM1kKLS0tIDB5WHRmNGJrRGtmWmthVEhIYjlO\nZFR1eWVzVXo5bTBVZGN6NklXNVkwZlUKV2gxqo7mOkdVA0JPmjLe/bvMl4TKmZAf\naZMYhPIPm4yBUDtiae/cbx7WS+7prnbJyqBwa4lnmTPOvk82uLpKGw==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_3__map_recipient=age1vfhyk6wmt993dezz5wjf6n3ynkd6xptv2dr0qdl6kmttev9lh5dsfjfs3h -sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBsbHhaOGFDTmYxZWR4TTFC\neEZLNHhIZGhRazJCMUlGMjZaU1h4MVphdVJjCmxlY3doWmk2ZDVjcVg1ZjZQRVls\nV3RUVERlcVhER3IzOWJNUWNaclpBTzAKLS0tIGFlaW12Q1JCQjVWR0Q1UnVYOWpY\nS2ZzdzkxVG5NZm1yVnU3eXM4VGdZWWcKWTlk0f21oY9WJdZhFxPuxsTpH5ww0Kw4\nMLPDWnyAQLP4LTOMK1pbAK6h0WSAGFSLiZfZYIWmW+m66gD2W9YQ8Q==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSArYTJxVlB4SG4wQzU4Unl6\nWERwdXJtazN5UjdaczVlZ2lLMXVUVjNGMUNRCjV4UVBGUzB2VjZZa0JpRXYzdEgx\nbUk5OEh0cENxekQvL2R1NHZ2Vjd2VkEKLS0tIGJoVnlZeHhidEgyL1dhbEx1YnN0\nV2IxYitZUE03bHNMSTd4S1hSY0JESFkKRWw1TxiYNHHr/7xB0mIQHVBA7gMlWNjf\n++Dmqai9AbbScFLTVpnJFzbMDegOvTzZTKcfQDrGVUKT9MCoa28Ggg==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_4__map_recipient=age1jwuvzyghfer7rx3qtqa4vs0gxyff0sg6pqgmqvp5zlhnpmvrkdlsdha4kx -sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBJdW1aaFRuZVc3dHZUTXMy\nSVB0RWxYWWxidUxqZitVNStER0Z1Q1pCYTNjCkxOVjZ1V0cvV29vZnh3engrV2Jw\nSlJXUFVqMXZvZXNYUHc1WFhPb3VoTm8KLS0tIDZqWFhYRDVxZVBXQWF3K2VjVnVX\ncEd1YjA1aTVJbmZ6c0hmVjZtdVlFWWsK2eL8X17F56k+xUFQKKtDCAyaehjocCsS\na4WckbCoi6Po0p5d6xvlInrPqOrAT0wHMtUCmc8tqtrrk1MP1M/Olw==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBFeDRiRDFsL1dOOGxjSmND\nemtQNHV2cVhibUVZdHByL1BOTnUzNWZpOUNZCmFIOVBHV0hDQXh0NHRVamJRcWRz\nMnZ0ZWhTS2ZVWDFwVVI4QWViUGxrbTAKLS0tIHQ3UkxJQ1h4dnJwRW5RK25rdk1G\nbzROQjA2WFN2U0cvZm9Oenczc2ZtaDQKVJDoIxc+6kUkR4pWjP06Jl7s7iNxKnr1\n6/GQ3wc67LishAn1w3O7NS2D+RVX2p3t2p9fYH3xhLf0B6Zzl7oCQg==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_5__map_recipient=age1pgxk292zq30wafwg03gge7hu5dlu3h7yfldp2y8kqekfaljjky7s752uwy -sops_lastmodified=2026-04-01T13:33:48Z -sops_mac=ENC[AES256_GCM,data:LGl+b6V3pJbK/EMTqQBknlX+7MbtsJPjUXXsoLPwPm8jIiTzqahoaN7+qwwhe+S4CijuolBRHYtxT8dmiH7cxFwJ136PajxiNgPgccOec9PVp3GUuxfqg5EZiQALzNT6vj+Kv3e07NTC8GvsQIs3yKQ2ow1R9wLq5lVwdGseiLE=,iv:9yG1E/ZLKoYG9TiU+xGbB0UIMUENMww6tnR8oyhyPBE=,tag:1m7hsbRiwWth7g5jRXAdTA==,type:str] +sops_lastmodified=2026-04-08T18:41:05Z +sops_mac=ENC[AES256_GCM,data:K/fu/5WNIiGTbMyDTYU87iHUrGNV9NPzk5YBR7gVUbbMHgu5rH3vUuxPZpF30nsXrkS25VrO3RK/SOB/YJLq1mOduaOrVjCKUy5ZWLOgnY5ihPXucq1T0X+qJHDjJMrPpYrPPf+8nXcU5+vlEcU6EZFIRrijAu9IJhjwCyybxkE=,iv:vvj9QHJTyzqMBlHE65UT2Aiy6GAJ7msL/U3BOZvZrQo=,tag:Cu8VpHrs8mlBQ+ufNd+msQ==,type:str] sops_unencrypted_suffix=_unencrypted -sops_version=3.12.1 +sops_version=3.11.0 diff --git a/infra/stack.yml b/infra/stack.yml index 4e20ede0f8..611f46159a 100644 --- a/infra/stack.yml +++ b/infra/stack.yml @@ -28,8 +28,6 @@ services: NODE_ENV: production PORT: '3000' APP_COMMIT: '${APP_COMMIT:-}' - # Reuse the existing env var name, but point it at the in-swarm broker: - CLOUDAMQP_URL: 'amqp://appuser:apppassword@rabbitmq:5672/appvhost' networks: [appnet] depends_on: [db] # note: ignored by swarm, fix later? healthcheck: @@ -62,7 +60,6 @@ services: environment: NODE_ENV: production APP_COMMIT: '${APP_COMMIT:-}' - CLOUDAMQP_URL: 'amqp://appuser:apppassword@rabbitmq:5672/appvhost' command: ['pnpm', 'run', 'workers-prod'] networks: [appnet] deploy: @@ -128,7 +125,6 @@ services: environment: NODE_ENV: production APP_COMMIT: '${APP_COMMIT:-}' - CLOUDAMQP_URL: 'amqp://appuser:apppassword@rabbitmq:5672/appvhost' command: ['pnpm', 'run', 'cron'] networks: [appnet] deploy: diff --git a/server/envSchema.ts b/server/envSchema.ts index ff3edd8ec5..e56d49337b 100644 --- a/server/envSchema.ts +++ b/server/envSchema.ts @@ -155,6 +155,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/utils/cloudflareAnalytics.ts b/server/utils/cloudflareAnalytics.ts index cc681e2dbd..1d7f8d42b0 100644 --- a/server/utils/cloudflareAnalytics.ts +++ b/server/utils/cloudflareAnalytics.ts @@ -9,11 +9,13 @@ * 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 = process.env.CLOUDFLARE_ANALYTICS_API_TOKEN; - const zoneTag = process.env.CLOUDFLARE_ZONE_TAG; + const apiToken = env.CLOUDFLARE_ANALYTICS_API_TOKEN; + const zoneTag = env.CLOUDFLARE_ZONE_TAG; if (!apiToken || !zoneTag) { const missing = [ !apiToken && 'CLOUDFLARE_ANALYTICS_API_TOKEN', From 5677a6f7aaa9ea208a6a1493f812ea901dc1c110 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Wed, 8 Apr 2026 14:47:07 -0400 Subject: [PATCH 29/41] lint --- server/analytics/api.ts | 1 - server/apiRoutes.ts | 2 +- server/sequelize.ts | 3 +-- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/server/analytics/api.ts b/server/analytics/api.ts index 535776f507..fe62562041 100644 --- a/server/analytics/api.ts +++ b/server/analytics/api.ts @@ -25,7 +25,6 @@ const toEventRecord = ( isUnique: (unique as boolean | undefined) ?? null, collectionIds: typeof collectionIds === 'string' ? collectionIds.split(',') : null, }; - }; export const analyticsServer = s.router(contract.analytics, { diff --git a/server/apiRoutes.ts b/server/apiRoutes.ts index ac56f493de..4aff0caabf 100644 --- a/server/apiRoutes.ts +++ b/server/apiRoutes.ts @@ -15,8 +15,8 @@ import { router as discussionRouter } from './discussion/api'; import { router as doiRouter } from './doi/api'; import { router as draftCheckpointRouter } from './draftCheckpoint/api'; import { router as editorRouter } from './editor/api'; -import { router as impact2Router } from './impact2/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'; diff --git a/server/sequelize.ts b/server/sequelize.ts index 45e68e0ca7..24b45ac5a1 100644 --- a/server/sequelize.ts +++ b/server/sequelize.ts @@ -142,6 +142,5 @@ if (process.env.NODE_ENV !== 'test') { // take several minutes and would delay deploys. const { createSummaryViews } = await import('server/analytics/summaryViews'); await createSummaryViews(); - }); -main + }; } From 60023a745e43dcdf03bbd941cd8445bf2db7da2a Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Wed, 8 Apr 2026 15:06:20 -0400 Subject: [PATCH 30/41] Update tests to use flush --- server/analytics/README.md | 45 ++++++++++++++++++++++++++ server/analytics/__tests__/api.test.ts | 8 +++++ 2 files changed, 53 insertions(+) diff --git a/server/analytics/README.md b/server/analytics/README.md index 1e3816b68b..bda1cbff65 100644 --- a/server/analytics/README.md +++ b/server/analytics/README.md @@ -272,3 +272,48 @@ don't exist across all matviews). 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/analyticsDailyCache/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 7bc6bb5d0e..694042132c 100644 --- a/server/analytics/__tests__/api.test.ts +++ b/server/analytics/__tests__/api.test.ts @@ -8,6 +8,7 @@ import { } from 'utils/api/schemas/analytics'; import { AnalyticsEvent } from '../model'; +import { flush } from '../writeBuffer'; const baseTestPayload = { type: 'page', @@ -118,6 +119,7 @@ describe('analytics', () => { 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 } }); @@ -132,6 +134,7 @@ 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' } }); @@ -144,6 +147,7 @@ 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 }, @@ -158,6 +162,7 @@ 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' } }); @@ -169,6 +174,7 @@ describe('analytics', () => { 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 } }); @@ -186,6 +192,7 @@ describe('analytics', () => { 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 } }); @@ -199,6 +206,7 @@ describe('analytics', () => { 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 } }); From b6cdfaff4c865bb8ab2e6f3c6144702aa1287a3c Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Wed, 8 Apr 2026 15:48:53 -0400 Subject: [PATCH 31/41] Refactor impact2 and date picker --- .../DashboardImpact/DashboardImpact.tsx | 479 ++++++++++++------ .../DashboardImpact/dashboardImpact.scss | 62 ++- .../DashboardImpact2/DashboardImpact2.tsx | 2 +- server/analytics/README.md | 8 +- server/analytics/impactApi.ts | 13 +- .../model.ts | 8 +- server/models.ts | 6 +- server/routes/dashboardImpact.tsx | 6 +- server/routes/dashboardImpact2.tsx | 2 +- server/utils/cloudflareAnalytics.ts | 10 +- tools/migrateRedshift.ts | 51 +- utils/dashboard.ts | 2 +- 12 files changed, 471 insertions(+), 178 deletions(-) rename server/{analyticsDailyCache => analyticsCloudflareCache}/model.ts (87%) diff --git a/client/containers/DashboardImpact/DashboardImpact.tsx b/client/containers/DashboardImpact/DashboardImpact.tsx index e324d5a719..d04e0c3399 100644 --- a/client/containers/DashboardImpact/DashboardImpact.tsx +++ b/client/containers/DashboardImpact/DashboardImpact.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { Button, ButtonGroup, NonIdealState, Spinner } from '@blueprintjs/core'; +import { Button, ButtonGroup, Callout, NonIdealState, Spinner } from '@blueprintjs/core'; import { Area, AreaChart, @@ -40,7 +40,17 @@ type AnalyticsData = { campaigns: CampaignRow[]; }; -type DateRange = '30d' | '90d' | '365d' | 'all'; +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 ───────────────────────────────────────────────────────────────── @@ -65,50 +75,99 @@ const fmtDate = (s: string): string => { return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); }; -const getRange = (r: DateRange): { startDate: string; endDate: string } => { - const end = new Date(); - const start = new Date(); - if (r === 'all') { - start.setFullYear(2015); - } else { - const days = r === '30d' ? 30 : r === '90d' ? 90 : 365; - start.setDate(end.getDate() - days); +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 }; } - return { - startDate: start.toISOString().slice(0, 10), - endDate: end.toISOString().slice(0, 10), - }; + if (diff < 0) { + return { start: end, end: start }; + } + return { start, end }; }; const COLORS = { pageViews: '#15B371', unique: '#2B95D6' }; +// ─── CSV export ────────────────────────────────────────────────────────────── + +function downloadCsv(filename: string, headers: string[], rows: string[][]) { + 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.click(); + URL.revokeObjectURL(url); +} + +const ExportButton = ({ onClick }: { onClick: () => void }) => ( +
); const StatCard = ({ label, value, color }: { label: string; value: string; color: string }) => ( @@ -130,7 +189,32 @@ const DashboardImpact = () => { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [dateRange, setDateRange] = useState('90d'); + + // 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 handleEndChange = (val: string) => { + const { start, end } = clampRange(startDate, val); + setStartDate(start); + setEndDate(end); + setActiveQuick(null); + }; // Build scope query params const scopeParams = useMemo(() => { @@ -144,13 +228,12 @@ const DashboardImpact = () => { }, [activeTargetType, activePub, activeCollection]); const fetchData = useCallback( - async (range: DateRange) => { + async (start: string, end: string) => { setLoading(true); setError(null); try { - const { startDate, endDate } = getRange(range); const res = await fetch( - `/api/analytics-impact?startDate=${startDate}&endDate=${endDate}${scopeParams}`, + `/api/analytics-impact?startDate=${start}&endDate=${end}${scopeParams}`, ); if (!res.ok) { const body = await res.json().catch(() => ({})); @@ -168,14 +251,76 @@ const DashboardImpact = () => { ); useEffect(() => { - if (canView) fetchData(dateRange); - }, [dateRange, canView, fetchData]); + if (canView) fetchData(startDate, endDate); + }, [startDate, endDate, canView, fetchData]); const chartData = useMemo( () => (data ? data.daily.map((d) => ({ ...d, label: fmtDate(d.date) })) : []), [data], ); + // ── 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', 'Pub ID', 'Views', 'Downloads'], + data.topPubs.map((p) => [p.pubTitle, p.pubId, String(p.views), String(p.downloads)]), + ); + }; + + const exportTopPages = () => { + if (!data) return; + downloadCsv( + 'top-pages.csv', + ['Page Title', 'Path', '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', 'Collection ID', 'Pageviews'], + data.topCollections.map((c) => [c.collectionTitle, 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)]), + ); + }; + + // ── Render ─────────────────────────────────────────────────────────── + if (!canView) { return ( { className="dashboard-impact-container" details={`Learn more about who your ${activeTargetName} is reaching.`} controls={ - - - - - - +
+ + + + + + + + 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 && (
@@ -226,7 +409,7 @@ const DashboardImpact = () => { title="Unable to load analytics" description={error} action={ - } @@ -295,66 +478,101 @@ const DashboardImpact = () => { )}
- {/* ── Row 2: Countries ── */} + {/* ── Countries ── */} {data.countries.length > 0 && ( -
-

Countries

- ({ - ...c, - country: c.countryCode ? countryName(c.countryCode) : c.country, - }))} - columns={[ - { key: 'country', label: 'Country' }, - { - key: 'count', - label: 'Pageviews', - render: (v: number) => fmt(v), - }, - ]} - /> -
+ ({ + ...c, + country: c.countryCode + ? countryName(c.countryCode) + : c.country, + }))} + columns={[ + { key: 'country', label: 'Country' }, + { + key: 'count', + label: 'Pageviews', + render: (v: number) => fmt(v), + }, + ]} + /> )} - {/* ── Row 3: Top Pub Downloads and Pageviews ── */} + {/* ── Top Pub Downloads and Pageviews ── */} {data.topPubs.length > 0 && ( -
-

Top Pub Downloads and Pageviews

- fmt(v), - }, - { - key: 'downloads', - label: 'Downloads', - render: (v: number) => fmt(v), - }, - ]} - /> -
+ fmt(v), + }, + { + key: 'downloads', + label: 'Downloads', + render: (v: number) => fmt(v), + }, + ]} + /> )} - {/* ── Row 4: Top Pages ── */} + {/* ── Top Pages ── */} {data.topPages.length > 0 && ( -
-

Top Pages

+ ( + + {v || row.path || '(home)'} + + ), + }, + { + key: 'count', + label: 'Pageviews', + render: (v: number) => fmt(v), + }, + ]} + /> + )} + + {/* ── Top Collections ── */} + {data.topCollections.length > 0 && ( + fmt(v), + }, + ]} + /> + )} + + {/* ── Referrers + Campaigns side by side ── */} +
+ {data.referrers.length > 0 && ( ( - - {v || row.path || '(home)'} - - ), - }, + { key: 'referrer', label: 'Referrer' }, { key: 'count', label: 'Pageviews', @@ -362,17 +580,14 @@ const DashboardImpact = () => { }, ]} /> -
- )} - - {/* ── Row 5: Top Collections ── */} - {data.topCollections.length > 0 && ( -
-

Top Collections

+ )} + {data.campaigns.length > 0 && ( { }, ]} /> -
- )} - - {/* ── Row 6: Referrers + Campaigns side by side ── */} -
- {data.referrers.length > 0 && ( -
-

Top Referrers

- fmt(v), - }, - ]} - /> -
- )} - {data.campaigns.length > 0 && ( -
-

Top Campaigns

- fmt(v), - }, - ]} - /> -
)}
diff --git a/client/containers/DashboardImpact/dashboardImpact.scss b/client/containers/DashboardImpact/dashboardImpact.scss index feac3d186f..d8b7240168 100644 --- a/client/containers/DashboardImpact/dashboardImpact.scss +++ b/client/containers/DashboardImpact/dashboardImpact.scss @@ -1,4 +1,44 @@ .dashboard-impact-container { + // ── 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; @@ -72,9 +112,24 @@ text-transform: uppercase; letter-spacing: 0.5px; color: #5c7080; - margin: 0 0 6px; + margin: 0; padding: 0 4px; } + + .panel-header { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 6px; + + .export-btn { + opacity: 0.4; + transition: opacity 0.15s; + &:hover { + opacity: 1; + } + } + } } // ── Two-column layout for referrers + campaigns ───────────────────── @@ -145,6 +200,11 @@ .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; } diff --git a/client/containers/DashboardImpact2/DashboardImpact2.tsx b/client/containers/DashboardImpact2/DashboardImpact2.tsx index ad31b39611..c7be44cb34 100644 --- a/client/containers/DashboardImpact2/DashboardImpact2.tsx +++ b/client/containers/DashboardImpact2/DashboardImpact2.tsx @@ -148,7 +148,7 @@ const DashboardImpact2 = () => { const [showAllPaths, setShowAllPaths] = useState(false); const legacyImpactUrl = getDashUrl({ - mode: 'impact-v1', + mode: 'impact', pubSlug: activePub?.slug, collectionSlug: activeCollection?.slug, }); diff --git a/server/analytics/README.md b/server/analytics/README.md index bda1cbff65..2052f58c9a 100644 --- a/server/analytics/README.md +++ b/server/analytics/README.md @@ -1,11 +1,11 @@ # Analytics (Legacy Impact Dashboard) -The legacy Impact dashboard (`/dash/impact-v1`) displays page views, unique +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/impact`) is an entirely separate system backed by +> **Impact2** (`/dash/impact2`) is an entirely separate system backed by > Cloudflare analytics and is unaffected by any of this. --- @@ -245,7 +245,7 @@ don't exist across all matviews). | `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-v1` | +| `server/routes/dashboardImpact.tsx` | SSR route for `/dash/impact` | --- @@ -283,7 +283,7 @@ Replaces the Metabase/Redshift analytics pipeline with per-community analytics s **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/analyticsDailyCache/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/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`. diff --git a/server/analytics/impactApi.ts b/server/analytics/impactApi.ts index 123c93ded9..c9a340b58d 100644 --- a/server/analytics/impactApi.ts +++ b/server/analytics/impactApi.ts @@ -392,10 +392,21 @@ router.get('/api/analytics-impact', async (req, res, next) => { const defaultStart = new Date(now); defaultStart.setDate(defaultStart.getDate() - 90); - const startDate = + 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; diff --git a/server/analyticsDailyCache/model.ts b/server/analyticsCloudflareCache/model.ts similarity index 87% rename from server/analyticsDailyCache/model.ts rename to server/analyticsCloudflareCache/model.ts index c7b5cfcd3f..9a54c216eb 100644 --- a/server/analyticsDailyCache/model.ts +++ b/server/analyticsCloudflareCache/model.ts @@ -11,11 +11,13 @@ import { AllowNull, Column, DataType, Model, PrimaryKey, Table } from 'sequelize * etc. * Past days are cached permanently (expiresAt = null). * Today's partial data is cached with a short TTL (expiresAt = now + 1h). + * + * Note: tableName kept as 'AnalyticsDailyCaches' to avoid a DB migration. */ @Table({ tableName: 'AnalyticsDailyCaches', timestamps: false }) -export class AnalyticsDailyCache extends Model< - InferAttributes, - InferCreationAttributes +export class AnalyticsCloudflareCache extends Model< + InferAttributes, + InferCreationAttributes > { /** Community hostname, e.g. "demo.pubpub.org" or "journal.example.com" */ @PrimaryKey diff --git a/server/models.ts b/server/models.ts index a180d23f1f..921b15e84c 100644 --- a/server/models.ts +++ b/server/models.ts @@ -4,7 +4,7 @@ import passportLocalSequelize from 'passport-local-sequelize'; /* Also import them to make them available to other modules */ import { ActivityItem } from './activityItem/model'; import { AnalyticsEvent } from './analytics/model'; -import { AnalyticsDailyCache } from './analyticsDailyCache/model'; +import { AnalyticsCloudflareCache } from './analyticsCloudflareCache/model'; import { AuthToken } from './authToken/model'; import { Collection } from './collection/model'; import { CollectionAttribution } from './collectionAttribution/model'; @@ -63,7 +63,7 @@ import { ZoteroIntegration } from './zoteroIntegration/model'; sequelize.addModels([ ActivityItem, AnalyticsEvent, - AnalyticsDailyCache, + AnalyticsCloudflareCache, AuthToken, Collection, CollectionAttribution, @@ -156,7 +156,7 @@ export const includeUserModel = (() => { export { ActivityItem, AnalyticsEvent, - AnalyticsDailyCache, + AnalyticsCloudflareCache, AuthToken, Collection, CollectionAttribution, diff --git a/server/routes/dashboardImpact.tsx b/server/routes/dashboardImpact.tsx index 38702e2d6a..bbdbbeb5bb 100644 --- a/server/routes/dashboardImpact.tsx +++ b/server/routes/dashboardImpact.tsx @@ -12,9 +12,9 @@ export const router = Router(); router.get( [ - '/dash/impact-v1', - '/dash/collection/:collectionSlug/impact-v1', - '/dash/pub/:pubSlug/impact-v1', + '/dash/impact', + '/dash/collection/:collectionSlug/impact', + '/dash/pub/:pubSlug/impact', ], async (req, res, next) => { try { diff --git a/server/routes/dashboardImpact2.tsx b/server/routes/dashboardImpact2.tsx index 615c5e8660..c7198d1336 100644 --- a/server/routes/dashboardImpact2.tsx +++ b/server/routes/dashboardImpact2.tsx @@ -11,7 +11,7 @@ import { generateMetaComponents, renderToNodeStream } from 'server/utils/ssr'; export const router = Router(); router.get( - ['/dash/impact', '/dash/collection/:collectionSlug/impact', '/dash/pub/:pubSlug/impact'], + ['/dash/impact2', '/dash/collection/:collectionSlug/impact2', '/dash/pub/:pubSlug/impact2'], async (req, res, next) => { try { if (!hostIsValid(req, 'community')) { diff --git a/server/utils/cloudflareAnalytics.ts b/server/utils/cloudflareAnalytics.ts index 1d7f8d42b0..2b5d037144 100644 --- a/server/utils/cloudflareAnalytics.ts +++ b/server/utils/cloudflareAnalytics.ts @@ -196,7 +196,7 @@ function isNoisePath(path: string): boolean { // --------------------------------------------------------------------------- // // Each row stores a full day's pre-aggregated analytics JSON for one hostname. -// Uses the AnalyticsDailyCache Sequelize model (shared Postgres → works across swarm). +// Uses the AnalyticsCloudflareCache Sequelize model (shared Postgres → works across swarm). // // Past days: expiresAt = NULL → permanent cache. // Today: expiresAt = now + 3h → cached, but refreshed periodically. @@ -208,7 +208,7 @@ function isNoisePath(path: string): boolean { import { Op } from 'sequelize'; -import { AnalyticsDailyCache } from 'server/analyticsDailyCache/model'; +import { AnalyticsCloudflareCache } from 'server/analyticsCloudflareCache/model'; /** 1 hour in milliseconds. */ const TODAY_CACHE_TTL_MS = 1 * 60 * 60 * 1000; @@ -230,7 +230,7 @@ function pruneOldCacheRows(): Promise { lastCleanup = now; const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - CACHE_MAX_AGE_DAYS); - return AnalyticsDailyCache.destroy({ + return AnalyticsCloudflareCache.destroy({ where: { date: { [Op.lt]: cutoff.toISOString().slice(0, 10) } }, }) .then(() => undefined) @@ -255,7 +255,7 @@ async function getCachedDays( scope = 'community', ): Promise> { if (dates.length === 0) return new Map(); - const rows = await AnalyticsDailyCache.findAll({ + const rows = await AnalyticsCloudflareCache.findAll({ where: { hostname, date: dates, @@ -282,7 +282,7 @@ async function storeCachedDays( 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 AnalyticsDailyCache.upsert({ hostname, date, scope, data, expiresAt }); + return AnalyticsCloudflareCache.upsert({ hostname, date, scope, data, expiresAt }); }); await Promise.all(promises); } diff --git a/tools/migrateRedshift.ts b/tools/migrateRedshift.ts index 0ea6a8166b..80ed9a2228 100644 --- a/tools/migrateRedshift.ts +++ b/tools/migrateRedshift.ts @@ -243,6 +243,24 @@ SELECT CASE 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') 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). @@ -367,7 +404,7 @@ async function main() { throw new Error(`No data files found in ${dataDir}`); } - log(`[3/5] processing ${files.length} CSV files...`); + log(`[3/7] processing ${files.length} CSV files...`); let fileIdx = 0; await sequential(files, async (file) => { fileIdx++; @@ -399,16 +436,20 @@ async function main() { log(` [${fileIdx}/${files.length}] ${file}: done`); }); - log('[4/6] running ANALYZE...'); + 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(`[5/6] ${(finalCount as any).count} rows in AnalyticsEvents.`); + log(`[6/7] ${(finalCount as any).count} rows in AnalyticsEvents.`); - log('[6/6] creating & refreshing summary materialized views...'); + log('[7/7] creating & refreshing summary materialized views...'); const { createSummaryViews, refreshSummaryViews } = await import( 'server/analytics/summaryViews' ); diff --git a/utils/dashboard.ts b/utils/dashboard.ts index 67b455db83..3ac35b0e7a 100644 --- a/utils/dashboard.ts +++ b/utils/dashboard.ts @@ -2,7 +2,7 @@ export type DashboardMode = | 'activity' | 'connections' | 'impact' - | 'impact-v1' + | 'impact2' | 'layout' | 'members' | 'overview' From d6e6b0f21cd58b33aafe9d184b6266d13ed46d64 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Wed, 8 Apr 2026 16:19:59 -0400 Subject: [PATCH 32/41] lint --- .../DashboardImpact/DashboardImpact.tsx | 23 +++++++++++-------- server/analytics/impactApi.ts | 3 +-- server/routes/dashboardImpact.tsx | 6 +---- tools/migrateRedshift.ts | 2 +- 4 files changed, 16 insertions(+), 18 deletions(-) diff --git a/client/containers/DashboardImpact/DashboardImpact.tsx b/client/containers/DashboardImpact/DashboardImpact.tsx index d04e0c3399..bad4a027c2 100644 --- a/client/containers/DashboardImpact/DashboardImpact.tsx +++ b/client/containers/DashboardImpact/DashboardImpact.tsx @@ -126,7 +126,14 @@ function downloadCsv(filename: string, headers: string[], rows: string[][]) { } const ExportButton = ({ onClick }: { onClick: () => void }) => ( - + )} +
+ {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} + + + +
+ )} + + )}
- - - - {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]}
-
-); + ); +}; const StatCard = ({ label, value, color }: { label: string; value: string; color: string }) => (
@@ -185,9 +282,9 @@ const StatCard = ({ label, value, color }: { label: string; value: string; color // ─── component ─────────────────────────────────────────────────────────────── const DashboardImpact = () => { - const { scopeData } = usePageContext(); + const { scopeData, communityData } = usePageContext(); const { - elements: { activeTargetType, activeTargetName, activePub, activeCollection }, + elements: { activeTargetType, _activeTargetName, activePub, activeCollection }, activePermissions: { canView }, } = scopeData; @@ -264,6 +361,30 @@ const DashboardImpact = () => { [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 = () => { @@ -324,6 +445,15 @@ const DashboardImpact = () => { ); }; + const exportDevices = () => { + if (!data) return; + downloadCsv( + 'devices.csv', + ['Device', 'Pageviews'], + data.devices.map((d) => [d.device_type, String(d.count)]), + ); + }; + // ── Render ─────────────────────────────────────────────────────────── if (!canView) { @@ -331,7 +461,7 @@ const DashboardImpact = () => {

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

@@ -342,7 +472,7 @@ const DashboardImpact = () => { @@ -483,50 +613,51 @@ const DashboardImpact = () => { )}
- {/* ── Countries ── */} - {data.countries.length > 0 && ( + {/* ── Row 2: Top Pubs (2/3) | Countries (1/3) ── */} +
({ - ...c, - country: c.countryCode ? countryName(c.countryCode) : c.country, - }))} + title="Top Pubs" + onExport={exportTopPubs} + rows={data.topPubs} columns={[ - { key: 'country', label: 'Country' }, + { key: 'pubTitle', label: 'Title', flex: true }, { - key: 'count', - label: 'Pageviews', + key: 'downloads', + label: 'Downloads', + align: 'right', render: (v: number) => fmt(v), }, - ]} - /> - )} - - {/* ── Top Pub Downloads and Pageviews ── */} - {data.topPubs.length > 0 && ( - fmt(v), }, + ]} + emptyMessage="No pub data for this period." + /> + ({ + ...c, + country: c.countryCode ? countryName(c.countryCode) : c.country, + }))} + columns={[ + { key: 'country', label: 'Country' }, { - key: 'downloads', - label: 'Downloads', + key: 'count', + label: 'Views', + align: 'right', render: (v: number) => fmt(v), }, ]} + emptyMessage="No country data for this period." /> - )} +
- {/* ── Top Pages ── */} - {data.topPages.length > 0 && ( + {/* ── Row 3: Top Pages | Top Collections (50-50) ── */} + - {/* ── Referrers + Campaigns side by side ── */} -
- {data.referrers.length > 0 && ( - fmt(v), - }, - ]} - /> - )} - {data.campaigns.length > 0 && ( - fmt(v), + {/* ── 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." + />
)} diff --git a/client/containers/DashboardImpact/dashboardImpact.scss b/client/containers/DashboardImpact/dashboardImpact.scss index d8b7240168..be6bca098f 100644 --- a/client/containers/DashboardImpact/dashboardImpact.scss +++ b/client/containers/DashboardImpact/dashboardImpact.scss @@ -106,6 +106,14 @@ .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; @@ -113,37 +121,112 @@ letter-spacing: 0.5px; color: #5c7080; margin: 0; - padding: 0 4px; } - .panel-header { - display: flex; - align-items: center; - gap: 6px; - margin-bottom: 6px; + .export-link { + padding: 0; + background: none; + border: none; + cursor: pointer; + font-size: 11px; + color: #8a9ba8; + text-decoration: none; + white-space: nowrap; - .export-btn { - opacity: 0.4; - transition: opacity 0.15s; - &:hover { - opacity: 1; - } + &:hover { + color: #2b95d6; + text-decoration: underline; } } } - // ── Two-column layout for referrers + campaigns ───────────────────── - .two-col { + // ── 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: 768px) { + @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%; @@ -159,6 +242,13 @@ font-size: 11px; text-transform: uppercase; letter-spacing: 0.4px; + + &.align-right { + text-align: right; + } + &.col-flex { + width: 100%; + } } td { @@ -168,11 +258,22 @@ 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 - td:last-child, - td:nth-last-child(1 of .num) { + // 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; @@ -215,6 +316,9 @@ .chart-column h3 { color: #a7b6c2; } + .empty-message { + color: #738694; + } .compact-table { th { border-bottom-color: #30404d; @@ -223,7 +327,8 @@ td { color: #f5f8fa; } - td:last-child { + td:last-child:not(.col-flex), + td.align-right { color: #a7b6c2; } tr:hover td { @@ -233,5 +338,16 @@ color: #48aff0; } } + .table-pagination { + color: #a7b6c2; + button { + border-color: #5c7080; + color: #a7b6c2; + &:hover:not(:disabled) { + background: #30404d; + color: #f5f8fa; + } + } + } } } diff --git a/server/analytics/api.ts b/server/analytics/api.ts index fe62562041..d4dc8603df 100644 --- a/server/analytics/api.ts +++ b/server/analytics/api.ts @@ -44,6 +44,16 @@ export const analyticsServer = s.router(contract.analytics, { }, ], handler: async ({ body: payload }) => { + // Only record events on the production deployment (or in tests) + // to avoid polluting analytics with localhost / staging page views. + // Checked server-side so it can't be spoofed by clients. + if (!env.PUBPUB_PRODUCTION && env.NODE_ENV !== 'test') { + return { + status: 204, + body: undefined, + }; + } + const { timezone } = payload; const { name: country = null, id: countryCode = null } = getCountryForTimezone(timezone) || {}; diff --git a/server/analytics/impactApi.ts b/server/analytics/impactApi.ts index bf4ccbd6fc..c1fc9a02f7 100644 --- a/server/analytics/impactApi.ts +++ b/server/analytics/impactApi.ts @@ -54,6 +54,7 @@ type CampaignRow = { campaign: string; count: number }; type TopPageRow = { pageTitle: string; path: string; count: number }; type TopPubRow = { pubTitle: string; pubId: string; views: number; downloads: number }; type TopCollectionRow = { collectionTitle: string; collectionId: string; count: number }; +type DeviceRow = { device_type: string; count: number }; // ─── scope filter builder ──────────────────────────────────────────────────── @@ -101,7 +102,7 @@ async function fetchSummary(scope: Scope, startDate: string, endDate: string) { return fetchSummaryFromRaw(scope, startDate, endDate); } - const [daily, countries, topPubs, topPages, topCollections, referrers, campaigns] = + const [daily, countries, topPubs, topPages, topCollections, referrers, campaigns, devices] = await Promise.all([ // ── daily breakdown from matview sequelize.query( @@ -124,7 +125,7 @@ async function fetchSummary(scope: Scope, startDate: string, endDate: string) { FROM analytics_daily_country WHERE ${mvWhere} GROUP BY country, country_code - ORDER BY count DESC LIMIT 30`, + ORDER BY count DESC LIMIT 250`, { replacements, type: QueryTypes.SELECT }, ), // ── top pubs from matview, JOIN Pubs for titles @@ -138,7 +139,7 @@ async function fetchSummary(scope: Scope, startDate: string, endDate: string) { 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 30`, + ORDER BY (SUM(mv.views) + SUM(mv.downloads)) DESC LIMIT 250`, { replacements, type: QueryTypes.SELECT }, ), // ── top pages from matview @@ -150,7 +151,7 @@ async function fetchSummary(scope: Scope, startDate: string, endDate: string) { FROM analytics_daily_page WHERE ${mvWhere} GROUP BY page_title, path - ORDER BY count DESC LIMIT 30`, + ORDER BY count DESC LIMIT 250`, { replacements, type: QueryTypes.SELECT }, ), // ── top collections from matview @@ -163,7 +164,7 @@ async function fetchSummary(scope: Scope, startDate: string, endDate: string) { 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 - ORDER BY count DESC LIMIT 30`, + ORDER BY count DESC LIMIT 250`, { replacements, type: QueryTypes.SELECT }, ), // ── referrers from matview @@ -174,7 +175,7 @@ async function fetchSummary(scope: Scope, startDate: string, endDate: string) { FROM analytics_daily_referrer WHERE ${mvWhere} GROUP BY referrer - ORDER BY count DESC LIMIT 30`, + ORDER BY count DESC LIMIT 250`, { replacements, type: QueryTypes.SELECT }, ), // ── campaigns from matview @@ -185,7 +186,18 @@ async function fetchSummary(scope: Scope, startDate: string, endDate: string) { FROM analytics_daily_campaign WHERE ${mvWhere} GROUP BY campaign - ORDER BY count DESC LIMIT 30`, + 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 }, ), ]); @@ -219,6 +231,7 @@ async function fetchSummary(scope: Scope, startDate: string, endDate: string) { 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) })), }; } @@ -247,6 +260,7 @@ async function fetchSummaryFromRaw(scope: Scope, startDate: string, endDate: str topCollections, referrers, campaigns, + devices, ] = await Promise.all([ sequelize.query( `SELECT @@ -272,7 +286,7 @@ async function fetchSummaryFromRaw(scope: Scope, startDate: string, endDate: str FROM "AnalyticsEvents" WHERE ${baseWhere} AND ${pageEvents} GROUP BY country, "countryCode" - ORDER BY count DESC LIMIT 30`, + ORDER BY count DESC LIMIT 250`, { replacements: baseReplacements, type: QueryTypes.SELECT }, ), sequelize.query( @@ -285,7 +299,7 @@ async function fetchSummaryFromRaw(scope: Scope, startDate: string, endDate: str 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 30`, + ORDER BY (COUNT(*) FILTER (WHERE ae.event = 'pub') + COUNT(*) FILTER (WHERE ae.event = 'download')) DESC LIMIT 250`, { replacements: baseReplacements, type: QueryTypes.SELECT }, ), sequelize.query( @@ -296,7 +310,7 @@ async function fetchSummaryFromRaw(scope: Scope, startDate: string, endDate: str FROM "AnalyticsEvents" WHERE ${baseWhere} AND ${pageEvents} GROUP BY "pageTitle", "pubTitle", "collectionTitle", path - ORDER BY count DESC LIMIT 30`, + ORDER BY count DESC LIMIT 250`, { replacements: baseReplacements, type: QueryTypes.SELECT }, ), sequelize.query( @@ -307,7 +321,7 @@ async function fetchSummaryFromRaw(scope: Scope, startDate: string, endDate: str FROM "AnalyticsEvents" WHERE ${baseWhere} AND "collectionId" IS NOT NULL AND event IN ('collection','pub') GROUP BY "collectionTitle", "collectionSlug", "collectionId" - ORDER BY count DESC LIMIT 30`, + ORDER BY count DESC LIMIT 250`, { replacements: baseReplacements, type: QueryTypes.SELECT }, ), sequelize.query( @@ -317,7 +331,7 @@ async function fetchSummaryFromRaw(scope: Scope, startDate: string, endDate: str FROM "AnalyticsEvents" WHERE ${baseWhere} AND ${pageEvents} GROUP BY referrer - ORDER BY count DESC LIMIT 30`, + ORDER BY count DESC LIMIT 250`, { replacements: baseReplacements, type: QueryTypes.SELECT }, ), sequelize.query( @@ -327,7 +341,22 @@ async function fetchSummaryFromRaw(scope: Scope, startDate: string, endDate: str FROM "AnalyticsEvents" WHERE ${baseWhere} AND "utmCampaign" IS NOT NULL AND "utmCampaign" != '' GROUP BY "utmCampaign" - ORDER BY count DESC LIMIT 30`, + 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 }, ), ]); @@ -357,6 +386,7 @@ async function fetchSummaryFromRaw(scope: Scope, startDate: string, endDate: str 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) })), }; } diff --git a/server/analytics/summaryViews.ts b/server/analytics/summaryViews.ts index f314a99b28..36945dfd68 100644 --- a/server/analytics/summaryViews.ts +++ b/server/analytics/summaryViews.ts @@ -136,6 +136,33 @@ GROUP BY "communityId", date_trunc('day', "timestamp")::date, 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', "timestamp")::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', "timestamp")::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 ────────────────────────────────────────────────────────────── From 8d3c7ab8b91d2d9c5091f1456be9d503131931b9 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Wed, 8 Apr 2026 17:42:24 -0400 Subject: [PATCH 34/41] lint --- client/containers/DashboardImpact/DashboardImpact.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/containers/DashboardImpact/DashboardImpact.tsx b/client/containers/DashboardImpact/DashboardImpact.tsx index dfdf5341d4..962b8f7cb4 100644 --- a/client/containers/DashboardImpact/DashboardImpact.tsx +++ b/client/containers/DashboardImpact/DashboardImpact.tsx @@ -284,7 +284,7 @@ const StatCard = ({ label, value, color }: { label: string; value: string; color const DashboardImpact = () => { const { scopeData, communityData } = usePageContext(); const { - elements: { activeTargetType, _activeTargetName, activePub, activeCollection }, + elements: { activeTargetType, activePub, activeCollection }, activePermissions: { canView }, } = scopeData; From 2c55108a2b8d0f68fcd92eb00b93f03aa76bb34e Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Wed, 15 Apr 2026 07:55:08 -0400 Subject: [PATCH 35/41] merge env files --- infra/.env.dev.enc | 102 +++++++++++++++++++++++++------------------- infra/.env.enc | 104 ++++++++++++++++++++++++++------------------- 2 files changed, 119 insertions(+), 87 deletions(-) diff --git a/infra/.env.dev.enc b/infra/.env.dev.enc index db144b7cef..80fb97d5dd 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:Lqd/Ql81s6HHNwxb1GCyqj9sCDStOXlDs3oiTbBWkuqMpiIzxmV6PdbxNkTV6H0vwTXZC9lmdjedVL1WL8sCng==,iv:AcNchnTlU1+vPX/YvK4N9gsN/tPv3WBbRZ6JS0DR6V4=,tag:PfBca0uMLs9U6KRqLdrSaw==,type:str] +ALTCHA_HMAC_KEY=ENC[AES256_GCM,data:785sEuABwWQRdyt943g8mca5itGH89LdRJeHWs038A9XD9HHpBU1dVLwh/xt1ZJD58d41G+FGZAbXRj3hTuKdA==,iv:UOhVM2JxswhwbVFPv42mJpSYuKLTDdAuawH49HtMhGk=,tag:KeGG8fuHwZpsLzl+hpW6Wg==,type:str] +AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:D8ho3IUPmhoZ201cuev3hEC1QAw=,iv:VnNpvqbXeHfUTvab3eWhBH+i22HiLfa4xshGGF23dfE=,tag:6eMEh8Cr4F26DRLzU6+SAQ==,type:str] +AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:q0kl+aRIwf2bxjWrHdujv4X0yEE=,iv:AZKwZf4LPuJW3YXFSpeq4TgehOXKO0yJd/ZOgHrx5uc=,tag:lut8sHYkw6WOnhQFzFyMEQ==,type:str] +AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:hf8J7JSXBeP1bNFNtaZfAWDa+tCmc+P/GyBS6E1tAugqL/aXR8nOgA==,iv:ZNpaM9vf/FhN1oZp76bpx+ywL0K4JVgvmuEnSbkH6WM=,tag:qWl6Jo1JUpeapGbQivdipQ==,type:str] +AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:l6z8Fcgg8u7hj+WZHAWTOzdpO9bVY82khh0K5BG8F7Ah13Sh9Jkd1Q==,iv:NP9dXhxFdILFogGvlDnCz8ZdQ9S6Q2cgPh1j5zBdINs=,tag:TD30dtZb8VQAFNHGpnYozg==,type:str] +BACKUPS_SECRET=ENC[AES256_GCM,data:wR4BnO1peJJjllxrEPLI8QANctzo5gLrdpoN/kQVpMb/df06a2esX7SUryo=,iv:4hP/3Vx9LggNv0Op9soMnujMuTtGe4NOGFyPat3EEbI=,tag:noPl3Hx49cljY+zSU1SCVQ==,type:str] +CLOUDFLARE_ANALYTICS_API_TOKEN=ENC[AES256_GCM,data:lA+MBSEuig6hdWASJK5QUNdIRzUr4dMvzNbdIKGiMcRKFYl9p9SJ8hdiRj823wCpD/sPZOg=,iv:47eUYECUXPkCwJaq//VxUQZn9/e7uxxcEDQk9DNZhmc=,tag:F8ZgBBZr0W6CgLWzSRkNQw==,type:str] +CLOUDFLARE_ZONE_TAG=ENC[AES256_GCM,data:9W07ZStot60qPr5W1oYroIg4b38ZYWTHaGFvoG3Qcco=,iv:eG8rx2Cuq754udGhpdY0/mnBNY4r5rnnzvLXCFqxo/k=,tag:wwYUP/hfpxnfJfCaAHSK8Q==,type:str] +DATABASE_URL=ENC[AES256_GCM,data:cswqSz2x5NgRDPwCLvMDG5Vz39ojJAc5Jez3tI4VktgbyxQIzUT7ocAuwF4=,iv:TVSuxT3c7dfVpDJZE7xIV14ZQr5ZfgzOWKGZ1HOwgA0=,tag:kLf+06NqCSZAN0p3tU6O2g==,type:str] +DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:Et0XGalqp/MEDrM5BVn60ErUy23bVlAxjBmW5Pj94/fEfA==,iv:lhyoCZUeox4hHelUTUBqtGbkovu8/Xy9JxJsstUJBns=,tag:y7yPbVXuXlatlwgowc33fw==,type:str] +DOI_LOGIN_ID=ENC[AES256_GCM,data:KSWUrAit,iv:CbfEg2PkHDds4QAKL8YugmOdegCcuKE2zq/u7B/LJu0=,tag:NSwGX3ma00KcSiu9jflc3w==,type:str] +DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:OEGDrhG/oWCGRwb6VIG/8Bx3eAw=,iv:gtRuextg2Zv3mgl+fJc0Hn9wXfjiaXl7nlvu6StIE8A=,tag:uIGMrUuRC9HooQ8QIShkkg==,type:str] +DOI_SUBMISSION_URL=ENC[AES256_GCM,data:V8JVDwXcjeOF1/GPF45xw/kT8XukRYH+nBeuCa7ycipqTZUw6R22HUU=,iv:NsW1BqzSoDNNsz5GJYZD9gmXA1cbxY1znajmGSOKe+8=,tag:O4gwAkfMouuZeSPcez2HkA==,type:str] +FASTLY_PURGE_TOKEN=ENC[AES256_GCM,data:GcX2sqdp+2GHS5lYlhrCm7abWa75Kzw+erg4aX06nS0=,iv:7aADL8Bt+cPzj9lPvXqyAWkXlHinqc/JFCIkIsbdYH0=,tag:WvIKX2Sq5yICnyn2IWNJnA==,type:str] +FASTLY_SERVICE_ID=ENC[AES256_GCM,data:krwfvaYNg8OFzlyhWNvLMm7Flo2IGw==,iv:eCLf9uXc1sduUa1JmMV6c3fO18aypgzlARN2YAzvl6c=,tag:z4xK1q2xgd7rA2426oMbnw==,type:str] +FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data: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,iv:D+ldy+1N9K7rLjPlpYgSg//BS88Jv2m9irqmHkINI6s=,tag:HpUbxezlnoiGxf4FCSKMHg==,type:str] +IS_DUQDUQ=ENC[AES256_GCM,data:cq797A==,iv:Frt7sJrW4UtLvwnudBUZ09G3li1o7kl6E3dYT+328ec=,tag:dntdA8s45Al3tSXbLKL1HA==,type:str] +JWT_SIGNING_SECRET=ENC[AES256_GCM,data:wlDaFf3zd6EfrqNl/0wkEOdC2E7H/2ZEfIJWYKGz8VSTHoROBwWEpK+/3OX4L6m3iVgCow/DsNJUKwtPJdYNr8ln2uWoFWagAGskQyL55go+EZa9EQ2kaUo8SazeX0CGQ0LtDjNdFoijzcSRlCiL5gHCeJBqAkc7mFWFHe2skquYUSysJXaEnhXgm6+D9739AJB173rpMg+gQznjMP/vCJ1kFCcZJbCZ48n/gFkVAFZ85L6inJMtuHEZiL2CLPjYhopLsNGYimZyqyr3BS+DB7LxOlfMrqJVU+Otqv7rq/aPlAkLHvcUL/bMeJ6gBuGVKSmrlDo3/0/KuxBrQ6MXnjSfI2bnNwA90wHHzoKf/uXIayXl090BiSWw3WYKXV6wwDhUoLFM22rUqsR+Rf+taKLUfyXKK1eAe3RG3EiJnNm2D87+fsCHse/+ad5JbOphmWJldTtbfmrqdEpNkRteH5qYL6WYYZZpC3h6TrH7Pg9LuAWc0wJacvL3ZvLzSS2dOWXXZgHEcuwjc1WFR5rv7txAnu5WJk4mW4f9uOzTLod2o8WhAiw/iBBv3bNE2G4M5mT9Wd/aywTT+Mkj3mUFeWcHdeb7alfQLzJ5bVl+b3QaaWbLrSDfmOlqoyoxi3g5bSLB5j/K8iTk/XXCXid/SHyZB2PGstfalTCuO08lg3vhp8gNonCC32TOH3xOpa4vJSaSahSGt3AN2Qgx0BDG87+6Fz/4jeeyYgVfnypkbaaUcprWcbfg9qtQYkPrXGb3BAtoVzC3jejBgEG/Mm/CPW+p22/vd7uKnCgY7JMIl+CKMFbRvEUMuo1L0X/cihMOIyXZzTb7pAEbQzyaPyz0JUAR2sssHwROMllJUHAbDT6qbPv92PSPNcOc+IeQtwFALHTmLSjWrKsZMduSyMrPsDOFVeEydBHN8kBopuIhD+gBl5R4dmH1Hl1r8MU0Re+NBo+PXkOq7hJsNlbVoHbi2IEGpJmsiCD853a8wP/sjhHJbFBe2Wtvupb3Gj2hVpuZGHw9sJqhpzM6hNLJRZOZc6+IS+dMJSUGXMMw1q17s1uHJSqA1m2tNPiO0rLoe/OvbqXiox0liLTHyayaPseLrVVlnPc6O19iVNVAQR2EakLVNjAJZgzHAmDf+oc8DZ9ZjKIRIulfbK56wcCFrSDsZXbtPMIPjXdPHkLU7+H2dx44emA1cmzWK4sj8BMSrVXi/BYf3xjZHq+ODtn5/yvxRcSpDrWsSZxgY5UBlJR3yZIjazOuFVTMhrypJuybNOl02XR3Ty0gTj9M3dYdP5TnfxoHmg3D0djfNxGG97be8fdm3Ba5ZTkuNylNXwGIFlfU9gPEQaXCh2giz99kva0LhQ==,iv:JyIuXu6EbiV/i+iUqy5VbdJowBBs6CmhY+SzsZ3y07k=,tag:zlhsiYveYqHpo88wL7SyuA==,type:str] +MAILCHIMP_API_KEY=ENC[AES256_GCM,data:lLAnrXkzjq2IqxWajI0OI0Bw1ENNzB2q4ahDQkdOF3DHbpLA,iv:JSxxRAROKgLeA5ks/3lvIsy+F42s8/hh3+p+QVh5BeU=,tag:tU11S07ppVzRLm5gzynHrQ==,type:str] +MAILGUN_API_KEY=ENC[AES256_GCM,data:/ts+oYdIybk/xf3Y5TmBe7BDIbWyaFrM0xV+hqwqqQsnsxQv,iv:8ii2p0YTmvTFGq4r5sVZMleAeYAg779MLwxAXSgYU0s=,tag:q10GtCZrpvzE0Z4fVfcWtA==,type:str] +METABASE_SECRET_KEY=ENC[AES256_GCM,data:DRxctTjMGnKFKvvGWbAuGL4+pJrj0hclQt/jBiftXEF50sOzfznqGFGBdtBonIHrRH5i1VYoLQiUwxDha022oQ==,iv:APLlG1pu48Wx3MCbAczgxwL+NPSDFbPpylID3Y0hjbY=,tag:lHC439OLsM80+DXJnl2gDQ==,type:str] +NODE_ENV=ENC[AES256_GCM,data:2nEWv3XO6gYSvg==,iv:zXrr9n9PXOfC8HaVoUnt4ba+1kGOotTeUBKKW96j4dU=,tag:aqVPlRQXeGGV+eyFD1rEjg==,type:str] +S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:eyJ8d3rzQJxnsmm7MGr8v+cvmyM=,iv:dUiI0fTi+nTCuaMZ8nB9GYqsiyZVemb+OE8xtqFuTq4=,tag:eOCImwtYmI8xw/alGQ1ITA==,type:str] +S3_BACKUP_BUCKET=ENC[AES256_GCM,data:DLvAAgpzkUnqScU=,iv:qoHp15qxaMscgrCNN+4uH89p3Ph9ILIwtm1pxHJj83E=,tag:VaheZRfnOaf5jeO0STYoSQ==,type:str] +S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:VyQLUi3HEHre8d5wgR1bcYgnHg2Lxw9iwk32EgXogie2mJU=,iv:+VqvNh9Rnkzsesk7ygAH0zknbnVETuwikJ6j0zDnQ5M=,tag:Ft0xOnzGKGP+qE0M2VaPXw==,type:str] +S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:uF5FU3/uJsEKF1IwZ9R8SytTBw/nyREk8sToymSl2Ld6z49SKIZvsQ==,iv:DSTZL7x0oqMyPGTx2QKRen9z5YS9ICuLTloj5hDK+eo=,tag:X1kVnPBk74QeXXEEbhPwHw==,type:str] +SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:iX1U5OkzwyLT+0sfn5JH7baM6zGPMKGdBq8MAZImgJ1zGghfT3TdgzxE4ixIchH7zsQ2TKW/OmDp2QEexw5NAkca43KNwhJdQLfDtv5LEf/iPcw1xV8LN3iuyq+J0J9+cupBSpHBhUbNdKuD/m1txU1MovY6gickTWq1ZVNQslHTv4Kb7GILM96sQFTDGfW+eFhndwvt0S1ff78xV1klxXNTafqHwbXTh55yHkHlPuzLw3s8f6JjlKLLmw==,iv:R71lT4nTIyIFS1RGPvEiqMFfskoP37P12TzbBAV+3Kw=,tag:QvJh5DP0oKkxh1djLdA55Q==,type:str] +SENTRY_ORG=ENC[AES256_GCM,data:D9fN,iv:SXyNMsunSC82JD/X1VFUVyQRp6bBIZLQozirVznxIAU=,tag:jaVSsMJJS1mS4LqpaGKqWw==,type:str] +SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:XQI=,iv:18aQzBgdqbyOe/41EWbBTW3ohuKKfPqthyZW6Jh3uRI=,tag:gvd7bGzG4Jta26cWwqtqpg==,type:str] +SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:nasyx+FB9oNcEah0v1fwLETC5RiHGky3l0YHydggRbmurC2+ZM6MOWf5mxvOzq3VqaqEV1rpYQTPhNZgpK+W0gR2oAa2o/XV2squdD21FA==,iv:PdvzayaSFJg36Xxq3HQRNpx/AN6wMSbGfBkW6DNBR/k=,tag:IkkZuiocTGkop9wPldTdUA==,type:str] +SMTP_HOST=ENC[AES256_GCM,data:lrKpyz6XCimc3JsT0LCCfKB1Oo71uxXJx30VY8bS1GuyfQ==,iv:AdbFMwzDaQvpVlIjphFf7Y70c9YR9qq2pW2ul7+OAzE=,tag:w8h0KjTn/ydxoFloiA3YBg==,type:str] +SMTP_PASS=ENC[AES256_GCM,data:fPcFtfNS7nsFHIwZglr7uBLEEirTJZZBIl1Mb3pDoQn8vOM1HQAVC60SkKQ=,iv:ryqeCvMZ0TSlHl4wWuQhM40Nh/hisJ5aHTLDzc4RGlM=,tag:az+R4xE5DIYj9ZrfZ/3tFg==,type:str] +SMTP_USER=ENC[AES256_GCM,data:rraGRlU2d1alOXpB43NWZBZuSBU=,iv:27lFtkxlQ4w1yLnkPm2N1aoEUbdEn8JV16x03mXmY/M=,tag:qUYxbEIvzk84hLO0dM8a/g==,type:str] +STITCH_WEBHOOK_URL=ENC[AES256_GCM,data:8sXvewyr7xEJgX6fBr6BpeCXIpuOJzQkB1ciOUGVqdFe5vq0aWa2aaZFQNI1/5wZYRk+I5+i4NOWaQjqf+axG5Q7tbkgmL0UjaAQCYxaH8MUYmwKMnBbsXRXE7o3U5cEzCZ6DPDIJZ2tWJGhofhZyYdP6rWB,iv:nr2CcpC3yBBuKcnlYGCRPcLAsflGf7Wl56iZLjsR4xw=,tag:1OOdmVnmHkB5X01kHe0H5Q==,type:str] +ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:3/tnMRx6w/HM+J2bykzSAN1eYig=,iv:YAAPAeddQcfhlax+1ngKIM/mZpjLkMFjpCS2XMYKj50=,tag:aSS7PPt7v1y2B/qVJeiCDA==,type:str] +ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:cKI+nRmdS1dVueA5/jMeTTuY1Bs=,iv:xw8tyrSkKzgJEETKcS7DlW5tSLh0GRgvEsClTI3r3EI=,tag:qVwkuQ12rmVms8Xd5GbHAw==,type:str] +#ENC[AES256_GCM,data:SsEjqLsidHviWsnh/iyUIeJxwHIAu4N3sAF4/02jsRAD2oy36DBCOUy3G2xxtNmJmh/+Dxj4EpW+yUCP8Yn2z5vD,iv:7ULOKd6ak8w4LJZG7740fwIwfBl0AEqFziFbf3nizQQ=,tag:6VZCtxFts+mWWBJagMe+LA==,type:comment] +AM_REDSHIFT_PATH=ENC[AES256_GCM,data:gv3yJSTgt9EgPcNn4qTimnO7lmhSeaCo341VJAebPMCOpN6AgUK2IWmvs8hq/7ThYCHaX4wbkQCynuy7xDg=,iv:fvV1OBfAJFdFOdyTldvA1bhJT5xHBAsyN8muP2a8FKY=,tag:CJT7xC2JWaDnP6EspczOWg==,type:str] +AM_REDSHIFT_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:nUK/G4WObIhiGgjb7YWwE04AZgk=,iv:f6PXvwynPFicwEhYZ+MwN6rAK5i8g01itkdUuYK9TqU=,tag:dnndk9BDhk1Nd0BS16WSZA==,type:str] +AM_REDSHIFT_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:yZYcIBvcADGLO5qgIUgyj697UgvkJygL5SSpBh09FBt+8r2xjVQqsQ==,iv:sL5txZupq1bkufmvvM5OyIVs1Msj6qUnm6VgPOmDuSc=,tag:9Py1k7ehRVpynX5I9D3E0g==,type:str] +AM_REDSHIFT_BACKUP_ROLE_ARN=ENC[AES256_GCM,data:2VaGVEhOaiYeelSx3QdxD0JT41S0sevds36uyXkwzdiZkkJYxwKRr90y2Cu/RK/y9v6w,iv:Bwky6Fxn/Bo0N5QFML3Bi+B2O2LHJgjgmE2z7NTJFlo=,tag:oq6ZEk2rzl48mvvlfOeZDA==,type:str] +#ENC[AES256_GCM,data:ZCwsOckTNIt7zecQHUTkN1ZNDHndVKi9cjy8onpMIhIdv3OEcmRBNrz45B6yTdM1RPgB1LYywPgtWs11VG59mYdhg4xLIvK9iL/UUGJsg2L9LdW/LY+lMYkDTk8tFYECjcti8QawJrKPbz9fwwm9GygVCLw9LLnE,iv:qINORDM2xBm1CzBbQqDs1TeRnrJ2kcmjSqIrsahFfG4=,tag:WivBqdTiKTPLNbuQzE8mVw==,type:comment] +#ENC[AES256_GCM,data:oMQFVL+fcdviA/6W3VW3pc1puNflXoaGh5fEfozZFejZmo4Ca5ZshzI7GJF79oRYPvU=,iv:rajGfzlg5oKDEMYyC332Ntf5ONKFqaIuTzC4ObrTR9E=,tag:NUYqHDwydZdjGbdlrY94xA==,type:comment] +#ENC[AES256_GCM,data:t5Q8H/ju1vz1C30VvSlhgLRpfna7BfeeRzlETYI+hGgUFKe9mTdtGqf8RyZY,iv:WKcNeYh/4NSJ9QtTeH9nkdax3ywZMcJhBOEGsLX4pwM=,tag:DmJ7vd2xOL0ziHsqlVf+fg==,type:comment] +#ENC[AES256_GCM,data:8PB3M8+4pZHLbu1QvMKyr5FJ6/cIxbE=,iv:nXrFHhz5Db8+Ulg+P8YpqjYAW+Bl/BPoh8y2V/0KRMQ=,tag:RIf1iuDwG9eTlxpfwKbatw==,type:comment] +#ENC[AES256_GCM,data:leEyl9ZCwWSUnGpqSbYF65us22v8ZHs=,iv:h30PNswz/5/pzu8RxwKz0QgwuTdjixxde91WWC0OHoo=,tag:KZFZ1wQfEfkrx2dM/NUPdw==,type:comment] +#ENC[AES256_GCM,data:rHjEOLNBCk0onbyWKFNjmdrS/HWyjmQXepr8xaisT+YFRVcHFEUMKT7CPp6axAVzJZz5oQ5nWn8NGsnhgFvcYQEBtCEPZu3gtiZvFQ==,iv:/Q33Gwm5xaLsyWg6u1+nz4QdH3TFluHXtDU4rBdvt+c=,tag:I35ETiymlQMmFdbSN1K+aw==,type:comment] +#ENC[AES256_GCM,data:73trR7JE5tzdKLa0C6DUi/QLMLU=,iv:iiSsxqOoJ74h7Dtr+ssspIwog7DELhkdxsLE04Iw8kE=,tag:VFN6uaa4mt+kqXidX5KGgg==,type:comment] +#ENC[AES256_GCM,data:idJoG7THFRc/90UD/sM2y+NvO5YOo8M=,iv:t+AgSzakyVr5Qv6TADRNJE3+rdEQGlg3NZ52R2ZXV0Y=,tag:bkRfXcdXme5QTv+/5CJXxA==,type:comment] +#ENC[AES256_GCM,data:yiMsOjhDlxNF7LcO+anEBA==,iv:dfic8nD5wF9JOIoa4Sjo3W+cHvQhgtOFFg/t1adPXmI=,tag:XBCpwy5lbdcsAz1D1sbZpw==,type:comment] +sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBhcWpBZVVQRDNOQUNYaGVm\ncGhMY2RTdzY1YXIvUTFOU0syWS9MeGFyQmhFCnZLWS8waGE4aFlMVEUvanZXWjI0\nNFJ6cldaL0V1OExVUzhPV0ZFUDk2SHMKLS0tIDNhZzRINm94R1VUSnRNc0NUZkd4\nQm94Q1NodTBDNkVhSjZJVGdzUk9XaHMKJmD/l+EC+WmCGv5NpyNar6cZSfiZ9Oyl\nNIamMMet4g0Up18UvpuCzlNThI5dmfWG8J5XrEUIrh37jI2ea253mA==\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+IFgyNTUxOSBRaHdSd2I0bHZzcVFxL0NX\nME9uQSswQld5dlNwT1pUZ2ZrMjdwVUdhaW1FCm1Tc1U4WjhrV3VDV2JhSUd0dTRC\ncUZtSEdrOER5WGVMemZtOFNZOUNCYWcKLS0tIGZhbTZ6cUNldFZZV3JsaVR4Snc4\nUnJwd1g3Y2VSNGZxL0ZqMHU3eEVTVEEKp+3xmaZLtXkgbTrhvHZlZAwpWkMQbUyT\nTQrcqt0WL7F7Ksqn6Wm0jTilZjqOnallS6CUMfv6Z5mKDASUbATEeA==\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+IFgyNTUxOSBWRTVxaUczTjdPcE5ycC9a\nenA3akRqZUlUbmVXMGdaVStpNXJtWWVNekFJCk9FRVYxVVRDNksrVHhSRHlYeXY1\naDd1aUszK1d4ek9BOXNPWTYxVTdyNncKLS0tIEdZdXlqMGpKMzJIUVY4UnEyTVlz\neW50bDVIOWVTL3FPa0NVTGxQdFkrcncKvdHfbFstSsf36JfvyulJU2qu61Mhym9w\nJmnM3yLocP88wiDQe7rH+aYqjBAMwe5ZbDH0Q2wti8N6b4jANk2lOA==\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+IFgyNTUxOSAvSlZXQkRsaGJRL0tiaGRV\ndzdvWE5ldzBVQ052eE9tSm16VlY1WXZpZEhjClVZUzlkODEwckpwanVwemVLSGZB\nOE5UWnNRQmYzMWNqdUEzeXlKZENmbU0KLS0tIGFVYTdwZENlOHR6YVEwUFZGOFRm\nbDBwMkhDSGlaa0Fvcy84V3BJYnVkV2sKlgb2jbnJBQR528cA9ugyKMADCLY09DAA\nXB66iHSF5B99eXcwYT5ZAh7CJDUdRR+laacRmizqRrrw1JnUJKNETQ==\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+IFgyNTUxOSA0cnQzby9Jb29RWnNRNEN4\nVVI0TlhVY0xzNnYwU1ZXZWxPMDVEQmNhOWpRCkRjY0FRWG9tVFlWVnB5eG1XUzBj\nclBiZWprNjNIRjhTQjNscU84TW9tOWcKLS0tIC9zNkIvZHhRY254OXo0UUZWZ2Rr\nVzBZUndrcWZOTWNWNFpaRSt3azF2SXMKdbu3KNQjjUSny3c+5Ev/YK06oZFZ1msA\nl0wGdb96nUO7XWiLGVoPPzH4V4dVqXwfQ2IKQIZyw2GPiCjzhsZbEA==\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+IFgyNTUxOSBpZ2E1LzdlbTk4T1Uxejc0\nOGRxMmhQdkpkNTFDenQzNVBQcjBsQmxLM0JZCnBXSWZPN3E2Zm8zUjhVTy9kZmNJ\nZURVUG12Ky8vWlo2dnVOcENGOTF6RDQKLS0tIE9ubFJCQ1NJdmtUT05lMVhnTUNW\na05FRS9ERVUwY1pWN0ZIczNpQXpkV2sKUZb/B5cG3pE6UhMWkx9lniIS1WD0YWb9\nib4XX3i/fuzSgUNs77WLAuPxGnza8wwaLHiHqocmQELB3209mh/3Iw==\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-15T11:54:37Z +sops_mac=ENC[AES256_GCM,data:3zcz9KHiPVdlUQHUR5Zo35gmOqlNip2ikMyWYXBIOeratPQGFXyDU+B3XgjQBj3qV6wkL80c/WK4REwIyBiWBGA+DJhQa5hHV5wXTHbJTu3r8jS0AWwQC5OMDlkmE3p+pmRVeqsBzP9ipEZuDl94k1ytanne3LhzdnbQWRoRHFk=,iv:822ZrCsj8DUXww+eW/1QfKqZVk51jmW50nmWROPxAys=,tag:cqzNZSTy1FADhq9cVRCrvw==,type:str] sops_unencrypted_suffix=_unencrypted sops_version=3.11.0 diff --git a/infra/.env.enc b/infra/.env.enc index 9e61c814b6..9e53724d5d 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:431p9FKVHj9HXtfL9XtXcFbqP7uGjMb2Hr48LsrtRHJhSWcKvtmACoNR+Q84cHhuKC02bNUU+BBpf7iaHoT/FQ==,iv:Y1dOI0dtyRs+233QjsycfqMMVEpQtZmBKXzTRrL64Sc=,tag:IdAy3SCBvf3g4ihaIvWrnA==,type:str] +ALTCHA_HMAC_KEY=ENC[AES256_GCM,data:CNkyyTDXG6SaeVpMFVOa2agEruzjuZp+BqmEbhbAEQh6K85Dz24mUZuab9ERpwv5vI8gjMo+jJ/13AnoPkRC97/SiJg7zmv3mSaMl7TPF9RdhqR7jdluq5g8MLyQG+Q+BwUknokwx4vBL7h3B9LfEJ9FaVFe2xSHz7pZ6jEMFzE=,iv:SXAnupGPoby92fkufccUztxDdm6exKSeoOlJQPJf4GE=,tag:kUB/YxCv3l5x07Lt4IIM1g==,type:str] +AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:MXn0aizg1guV1YxhEEeVi3zja0U=,iv:Orr5GftuFPIk28eAEl08ts4JFJRmGwaCdZSieu340tA=,tag:5Q9X9TRXf69tNqSrLqheSA==,type:str] +AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:jXuJmzddYVMWANuNOGxqFSf6yCs=,iv:u/BGdaFUWnUNw9LuQen+ZsLQob/e7q3CKPIBV50pX/s=,tag:RSTPqjwDEW5DvcIIfcXEEQ==,type:str] +AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:6K0QzGO2sfjy05TZezghs5pqEcaXz/6haBn3xhhJVkuoYBv7zmGjDw==,iv:gsi7Cj3bCgyWWh1wfCP0pVZDE2EiEVWiNrgliAF7GYo=,tag:gR/N1pE2/k6KLpZsTe0WbQ==,type:str] +AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:tUBfZCKYyIqejb23SYpfAkGxWXsdELG5sskIstvUm5FegsTp0YG0Hw==,iv:6+ilIStcQplW/sxuZSKOkifPwKTydz6cTBWJ2Q1UQ6Q=,tag:Lr8ArOQ17ZKNvJP7ryooFg==,type:str] +BACKUPS_SECRET=ENC[AES256_GCM,data:ZPzChj/SOyprrzPnc8dgVUe9U1nTNEtkNlp6tGKIpDCCLOGpkpFlcQX3y30=,iv:iIw2Oz/N7xWVgtPvOkRCqRSYw5OhJJWHP58pwG7idg8=,tag:ZdOBfHAOUxz95aWrEx7Now==,type:str] +BLOCKLIST_IP_ADDRESSES=ENC[AES256_GCM,data:1AbpYGISJIHCVrYJEFrfW2rws6Cr,iv:RjJfLYyDRuYZMgE2m3BW0Kuhhl88lRmiUz5eizOKJHM=,tag:8UHCdyvPDPydaoEIIa5Kdg==,type:str] +CLOUDFLARE_ANALYTICS_API_TOKEN=ENC[AES256_GCM,data:e0L7dhHjViYfv7uVH1jnHMLU554JwyQwhwIrQHytB295FmZ1WlVJWWCAPDsvMBfLiRKDStQ=,iv:ougbBAQX1x9NKAWJOyo+ZuvL5iElPpanKUi+cdESwkI=,tag:ID1IGv/4uQMqyKLY8fVN/A==,type:str] +CLOUDFLARE_ZONE_TAG=ENC[AES256_GCM,data:NZFYr+TdEn7dzGuy0TieGTMcCD3mAwbb0sv7zvAEytU=,iv:AnCtiiWIcomqcg5LBbhjuXAF+NaFpZYKyDQ/1TAi2xI=,tag:SbDnlzriQJmAmxsfWEeigg==,type:str] +DATABASE_URL=ENC[AES256_GCM,data:wGG2OWgbYVpO04tQSRD5i/m141cYpc/DTC1zmdqbqdD3kS1POuNG8oB1x2M=,iv:81q6YZp1RiGoZR+ohpDcH65Ky4nh0ggFx9aLZoAFOto=,tag:mX5geiQpP9p6akVnLmbZHw==,type:str] +DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:sOA+A5VBT55/OAI3huDuvpbKmCM93SKBywFDFok=,iv:O7zRH6cFogiwF256XtcKwOnQ6qpxk+TQCT5r4EHQzeI=,tag:AlcpqSF5F+iZgBVXOJ506Q==,type:str] +DOI_LOGIN_ID=ENC[AES256_GCM,data:MXh011Vu,iv:R3EBbFBvvtVEncxWvoNoDqPxuDn3nf117qq/aOuBt+E=,tag:z72INtQU0jkn+lPjM4i6mw==,type:str] +DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:J57aBuV218k5GG0E72P6pDTBlUM=,iv:3XPudZzsdbQvlGHv2XhzDEecTcmtD5B/gm5VgjM0Fh0=,tag:eLKVqH7JH7wDbIx7KdfTRw==,type:str] +DOI_SUBMISSION_URL=ENC[AES256_GCM,data:SzKXlk2ZrvYQBsaolUk5qOaDfwc4Oi0jJreTADycQy0rkOs9/7CUTw==,iv:H+SdgUuNjEQBlu1x+WpOP8Pj623A6KTc5e24S6mQC1A=,tag:qAMGPPByed6KQIMP8Wv4yQ==,type:str] +FASTLY_PURGE_TOKEN=ENC[AES256_GCM,data:zfHZYrxx60SnrRPzaiN2jS3UUsW0odn0uZwi0DuiwB0=,iv:yDBiYQA7HIZvJti9q3GRPGPU9HKOC5gKpx5dS25l9y8=,tag:EuC9fi3Kvpj2nlqX9wG/0w==,type:str] +FASTLY_SERVICE_ID=ENC[AES256_GCM,data:G/nwPMDjDwkOulpA/T1juFHEPlzLiQ==,iv:RyoHRCBQsFVOmteUbSeHDrCEXBD6+xoHYVUqd+tg4WI=,tag:qv7l1pc/vaz/61j8PwHisw==,type:str] +FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data: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,iv:NgzR8+Go5NfZwBnmpszZRhTa0yyS9q9zsIiiGFROE9A=,tag:5y3N+BnYdnEM+w8yK7HmXg==,type:str] +JWT_SIGNING_SECRET=ENC[AES256_GCM,data:3qMg7i5Pxh2dugNqE8iih7cqs3g5ezsvlS/vriW3V3jbz/NJXxbbYs69OfwdX2DKE6tjqekLr1C5vSpIEUs3vajBCgVyqNnn0/V9IbHb2FTCpiwDTmfc7smpYxMQaZP24Ayc8JBeucHG15aFdCtLhG6NANep0mn0cg5m2zWX9VaX0hTFilgQcgLKp+TqLRWVmvQcw1giyt9SuNW9ePyR1HwfzxVjI+XOxfBKDGh15plmxL1DASTzGRq0BgPa0N8zEHfOAiYIPIyJWy4q8PWOlzi1y+EhB+XyyCCT8yFKic2zmT59z21+5gTM/Fgq1sjJkmkeFJtRQXNlKIFkDMZVhG108XxsDsCRuRKXTKTbYAPS9rT5Lc67q39B5WnGnMrvVU1PI9S74n0dg+MT8ubW0GgFn08vFV9+M4meC/KbERkchZgVdqSW7nIwW2kgasSdN+m7bVZ5kii+75THMizpwz6eIRQsGQFvmQjLmADu3N/LPTXlJYzyfId5nXfh2CbwuU5G520vhH6d//h2a8c1DYCoCczgkMxDiI4TAODzN6Hg5qN8ktPkMZvW6vvlOx7yLYYu/elWtNJyWm3hc6/8XP+yKlGZeVInwaY6xPasmiZEKjmhZWHmcH+jcp4uud2fpAJTl4ke+UZhB1QnFD7GeIeRblDBP4+LZrHJdAVCFrk=,iv:XNFECC9mefq2bxw8wA7zh6jBZVKnyjEC/nYZGIiKGj8=,tag:cz+2JSEcZWCZj9juogLhzQ==,type:str] +MAILCHIMP_API_KEY=ENC[AES256_GCM,data:xLEiBDWHqJ+5dFVqMqWzaQNspfZl/S25veVGAJ+5P8F671Ih,iv:0khXz5swnSu9gtwtq89puGbaC/GCZl1XzzrC0+2l38E=,tag:Cmyeh1Rad1qS/esgA8aUJg==,type:str] +METABASE_SECRET_KEY=ENC[AES256_GCM,data:zIJVcNpb9SbUxwo40b4E5rP9bKJqdh9yIq1eL6kKcaHbtRQgjZkIXdSBvO7Tb68xEdGN4Zw8yuNIoP8p0nO/wg==,iv:MkQoWO8Y8W4JEQORkXBntPH6p9VJU2ScUdQVFDVOw30=,tag:JrBJOoDtuMU7AlAJhdGOeA==,type:str] +NEW_ACCOUNT_LINK_COMMENT_WINDOW_MINUTES=ENC[AES256_GCM,data:mPY=,iv:tV5DxEtdkbACdAPYRdQKNDzaUoVrQQ3enY9k8CU8MdQ=,tag:9FBVwCs5wBhviRAw0P1NBA==,type:str] +NODE_ENV=ENC[AES256_GCM,data:7fjTkbuFCrhWyw==,iv:X0O5L4OuBLMdFJkM2zA0PoopEEQ7Gwijn57oIe9f/3Y=,tag:YjctUatObLN0nyFidCHt6A==,type:str] +PUBPUB_PRODUCTION=ENC[AES256_GCM,data:htTNng==,iv:kMwwbm0a7b8+RY2USmgvCSRk40byyzy67RxpcpMs6Bs=,tag:903Edk1p6dWK4ByNjcg9Nw==,type:str] +S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:5nvQkV0+g2skNv6MNFetGyAJXko=,iv:a8EUoQHcXmNyj2XTh/uk2WNdvuqPZjFOKHhCtGf4CVI=,tag:v9ct0jum03zXO3JYpe0ctA==,type:str] +S3_BACKUP_BUCKET=ENC[AES256_GCM,data:Pah7z8SPqfNxCRQ=,iv:WPLZY98qaLhYAK191N3gBGsh777TRgPQnxZdfFQU3YY=,tag:kkLwJpNdSpH2solCIOpPfg==,type:str] +S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:Sy5/Y8P0epA4Ym+z7oxfzA8z3RT+bm9zFcN6r+08Mxzo708=,iv:+iO5pQpJ4yyFe1q74K/7nGaiqEsZym8Fx6jpX5cQzN0=,tag:RIX7fDXvNUY37W1mG++DKg==,type:str] +S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:kUVyG8XqWwVw3iaAwbcH4c9NwXJaQTBjjtl4Bci0KypC1T1ukbeRPA==,iv:lssD/Cbdce8ebgLhusYzcyr03e7HHBIMeNNbqnqnsgo=,tag:8exckCODgqBnACUJES7YiA==,type:str] +SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:4VTwgEbp3z1Vl3QWaRLzLr0HLExoFmXp3hl71NJRIy0uuchnVhpdWi+n9Os3zC0GbcSLslmryFpWaqs7ETFAGeNHQ3hPMfe8TXJ3tA+Gkibo9PBBzYab4tNpzj8NnhqXHL01oES7gxOEoQWunAI4r22qFtfbMqEtwaVS4DJ1xDgkufjeO2iX1sIpYbjbgWKrgYO9zuV0Lltg/aHTnYVkPA2MmwyMTuEC9K/Aa4pHW7WIkYH97ld5G3GIaQ==,iv:pUIOQIOcINUP5sonRCWB1UkbnPY1J5GWiZe8tQ0c2js=,tag:7hjatvx2A+NZArXu+cZB+Q==,type:str] +SENTRY_ORG=ENC[AES256_GCM,data:Rm5v,iv:OODksoL5VjVknO/5sI7p7vX6hMgqCmQj7Igq0T3lQlM=,tag:BfUyJhoa24+hIu7gP59g3g==,type:str] +SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:bVY=,iv:awqJIfnUc14qh9x30ZFNdBupTbUmgnVYt364G6Mjp7g=,tag:n6aa5aNOETJGXG2FK9ab4Q==,type:str] +SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:JK9Wlmf5pjhBsshNEFWM4mBduIxM5rVppkSS+h1dZaqqC56tYwCtWkHSMzc/2wi0T88D35YwdwjZojfIOuTrgR4ibiGew59KF/Zt6FRz6w==,iv:QfU+Hr/zrRC+lImxiXYnbgpOyl21x5xtoF3ooxGvil4=,tag:dkIIhEmRHZBS3Oo/YdzBIQ==,type:str] +SMTP_HOST=ENC[AES256_GCM,data:gsegbSLOeNLUdOC0YF7arCGtCbJqqIpM4PVP2cN+zeallw==,iv:r6Ay1sZ/XVOfg8QH3F1DsitpVHOgL5A4yaei4+qikLg=,tag:saD2PMrNfawFqvARziOyRQ==,type:str] +SMTP_PASS=ENC[AES256_GCM,data:kGIOlwcNsBbQLeOYleeATNnNd/AI5UfwYQucZm8u6NtuEvtertIXuhDeR6I=,iv:wp19dQFjy/CQ/yeGWcXSIjtPo+cJjwnS6Gblr/2mVKw=,tag:JvqeDwdoVTUXpaBwALDYRQ==,type:str] +SMTP_USER=ENC[AES256_GCM,data:t22GOtnblIOZtgI7hgZ3org+R70=,iv:5WfqqPYZ5l43jpdFJf9YrJ+5Mx1dBdOvOY2gP3DEPGU=,tag:uWjrHMHyFzMtK6XSljEYKQ==,type:str] +STITCH_WEBHOOK_URL=ENC[AES256_GCM,data:DBY1eHPg5PIVESYce23uPvXIvrcPaloVA2m0lzlhSoAqd02ho3v3pZMXziLzVAQkhKWjIPStUqYTuy/MKfHmbqoYxrou3xoBrdBoXhxuwvvlrY1PYYQtOrFQ7amn1rzG+mxAGN37ZIVbyXeKJGoUVnpz3VIL,iv:VTxZrg4McB7HRjtCsqLWIluotc2m9KspLMCFhEGJPQQ=,tag:XWW9iR5r1I6Pp9fY/q5opA==,type:str] +ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:jwgDxqo5nPhenWj+IlR7DGVSRuc=,iv:N53hnInRjvGXHFOb6+k1vmGySMy5j2r8HKGcAIFfd9o=,tag:6yucg/LMlmsGg3LCpb2LvA==,type:str] +ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:WHJ/bUulIHsZO03jkssj0LnArDY=,iv:1fHaJWEnquaBr4ZcxRFlh+QTmJWaJ5AK9R/UfYdwB8Y=,tag:DWbXdlMZig1VK4ApE2iceg==,type:str] +#ENC[AES256_GCM,data:JneTC1rP9TPJdq47qizO0Bn9ayRsipaYq82rU/oqg3Bu3ACdyyu6p+CuG7iN0MCV9W7pxArBiXIZeznvUp/MfZQF,iv:e/qRcA5Rnm74PNAXt9I4RfK2VdEPJnwZOjJGoFZv43A=,tag:yO4gxyRM+2gVD3z51jCDDA==,type:comment] +AM_REDSHIFT_PATH=ENC[AES256_GCM,data:oT5VnekKZ0ZXRMaaxmMKT+4pFLHZJ1UiCgCY93ly67xZTUyG9w3o0HTf4geoyN3xHpOxAXysseznuIe8fXI=,iv:FTKkGBwfA0iHaUQ+SjHh9XyLKHE4HTsGiR3hAx+/0AA=,tag:j/Iq2iecDAiiFYnX+OoCYw==,type:str] +AM_REDSHIFT_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:4f6pmHRz6Ik1GO41ILzXTBJ2rlM=,iv:5oH4QIkkDj0ucWM3+pvXuARQFZKVk3TYPTiOC5DZOl0=,tag:0hQ9oaSUNIObTT6F+qs5dg==,type:str] +AM_REDSHIFT_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:O9l89Iyf9DBu7RfCnh7/SkUzYTWhjbL6q+Lv07MKQ02NjITKp/63Ag==,iv:cA2NOKJpa2ycXxjsY19JefI0e8KmywT02Ra6jAlMl7s=,tag:Jdd6ZDIVaInz7OQ53kR8sg==,type:str] +AM_REDSHIFT_BACKUP_ROLE_ARN=ENC[AES256_GCM,data:dXpdb+HG7SCXKBhkrvGEQxL8Xqvb+diCJVx/y+CEs9Xn2UjG3NaL+NJDxOvAny1B8ZRv,iv:gDYSAlb1GwfXj/SCNXnGvcPrPt/aR63Scq3AMiGBzN8=,tag:Vak3zND8Zx6SNzderm9J2g==,type:str] +#ENC[AES256_GCM,data:7YhHy9WAvqpx8/82BD1Oelh8kkiLHvAwmWtHJm+g44k1VL4mYbVNlwRK5JGCfRNeezy+P/ldGbkNlJPWniTAdCDGUfnBdfoHS8CqpX8WH/nzOi7GevWGacKDzsb0ZK3DyapFZ3/7hTn9NYvCaubl4ck+vCRc9dSo,iv:nSppbrrCYm+/b03rfQjq0rQiyf1wwr8+8/pFHuDkF58=,tag:orU8qx84fBLwltyUtVkEdQ==,type:comment] +#ENC[AES256_GCM,data:ifUpWhWl1Hi9+qn0Ui8HHaKhVrX7yGXWEAewUOBvpfF0kC23QD+hzInmcjYwMDDmt7A=,iv:Otyh+mJpdWaYAtNnrlz5lUoEhJIcca+6CRUk4yN4X5w=,tag:ixxCe1VyEu/DvFrb3KafoQ==,type:comment] +#ENC[AES256_GCM,data:uCevHHgDtwusiI3jACHIY+keiaUm0eJjm78fURCHaI8QdPfskzNlOLwJ+xJK,iv:AzQ/piPHVLvFFMkGwmzUy/T3FbmfA67jABd7cRdHVx8=,tag:Q3MtW5y9Abq/7M/skgStvw==,type:comment] +#ENC[AES256_GCM,data:mWzrA7h2b1SiK/Q6CFv8aBBoQnT53xQ=,iv:b0YJWwLaX9TieaofcfDcTwDUmFaYINtf3PiHzBI19nc=,tag:bvK5Hs2kKgZVH8KydswBcQ==,type:comment] +#ENC[AES256_GCM,data:5DeYmwtMnokrhD7mbidyMfdm+eH1mss=,iv:JyMgU5M55DvzThfMX+UT1ZBYjy0v4iDJ3mTMOYVhucA=,tag:H6Jasz0o3rNqrt03QA8aSg==,type:comment] +#ENC[AES256_GCM,data:FsTaakHdVpmn/c7ARtJ08UPyFrC0UDYPq5GQP3a5PFTQgJOToxoXJN1ChSVZNIO+2NYrH7VB+6Jz6mryBkcFAFRQR2b6BlGVg0jGQw==,iv:VQ1GqebTSteITkyuUdRg6tMSHKrnLt1kqDvtTfAstfU=,tag:pETLrqU4+uUylmeDwltfkA==,type:comment] +#ENC[AES256_GCM,data:VFHoprx0qTsLZmnoJ7oAs7j9suY=,iv:tKMlMhU9pCnBvsxbem1NGO5Cen1xUo7qYOl5xI/YV7I=,tag:OnMU43W/5DLHLEnY+113sg==,type:comment] +#ENC[AES256_GCM,data:t13b2204M8eAsterXKZNO1a+PcGKRtU=,iv:iGWxQcKKcq3oRgf5i6n608k/sLpYmdnOxAMbI3UF/BM=,tag:zymVi2xyYiQGwtTlFTy2og==,type:comment] +#ENC[AES256_GCM,data:ozUzMLjOft4eyhegbVAnOA==,iv:BvvxsXsFt0/8cBF2ze7/aGZzfVgiUYIaVmGj73zvv/Y=,tag:H0utVy2W4hdandTGKQhhzw==,type:comment] +sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBiZUQzUmZsazhSOWo2TThO\nQ0d6bWNLVEpsM2xiU2lHNzdJY3pySEd6MWt3ClNhSDM2OTRNQ3Niay9HWE80MFB5\nc3VoazI4NGFaOG5kbk9aeFZ3MkErNHMKLS0tIDY3RkdKSm1oaUJUOVpDUWhUcURz\nWDdlRUF5SlV1QUZsME51VmxyOTMwRW8K8GMhW+r08oPKzWenAcTlCv9c4UUWa/sI\nH2pWnH+kymSI6tM0gYDzQSEzmDuK1CjYh33ETtC0uW2dMBb9oBdCNw==\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+IFgyNTUxOSB3cWhrTWUrYVJLZHBZK0NP\nZGZmOUtkek5QN05jaklqQXhaZkJQNlU0dEVVCldyT0pEVFFNcGxOeUhSclU0MTd1\nZ25Yc3FjSzJXazU0Mi9BK2dPM2Z4YlEKLS0tIFEyWEZvT1RKODFDdjhoeEdrK3JU\nSXJnNjlVWkVWSDV2bmxrTDN6ZGxXSWsKlpjqKzJ9tfqZSYzwdNok47rKZuxZ4HyC\nU3vlpOPGb98UbjcLNhrMmycvq9HkGEDR92rzq1JIbdN1W25YNbPRdw==\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+IFgyNTUxOSBtQjlvUUtBcWYxeVAyWGhX\nMXY2empiU0hZMG1XWHU5ZmRvZzFQUGt3OFJzCm5SSUFKNFNjcStUaGVRUWhKVjRB\nUU1UR2RGNHdPMHNzcmJ5ellFRitUSVUKLS0tIE50RHR3SmpFTmFMRVY1cUd1bEFW\nejZjRVNWd3BuU05BZSt2dWdYdENBZE0KvVhD4K+4MamTm1fd1sqG84xhqtnm/oO2\nUypC8o/oAuPi7qDUcVezdy2+zo7yl61/a0hNwdAZn19sJ+b9CtVejQ==\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+IFgyNTUxOSBHV29zWUVkZnZ1RlBkeWV6\nM1RNeDdDWTZ6MWdIeHFKT1I3WjNGRWZRL1NzCjJyeWN1dFhNaU5hejhxSU9ZZ0d1\nSEIwWk1tVUY0QldiZXlqdGRsQ0tCOEUKLS0tIG9SK3JPM0RqNUZDZVQ2ZXBWV2Vl\nQVBnMlZydk5nek9mdlR6NElOVXRSRXcKwqoQxXrvwnv/YAsMywEESet8fhsjAMun\nV/PzJeW27I7YpoVfdvv/L1eGB1R/7+B3rw5zPncxWiZsHkx1pQQNfw==\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+IFgyNTUxOSBVSWVjQlpqMVZqK3VyOS9F\nZmo3Ti9JbmJYMk5VNVJUM1AyN21mUVd4NERrCit3N0E4d3g3cW5iNDVBOTRJdW5z\nNGpMTHI3SFZNUFdDb3VrL2pyWkN2QjgKLS0tIGxMNnltWisrVVRjZ0ZrL3d4Y1A1\nNG45N0VXTTNrRUEvekpvWUh3cXNNNEUKbW2FODSbvTEb/Y8Dl3uNhRzYGexQlVIU\nA6uEUmOapFpW9Z0kc0WJ/qX08icW7XAVvH4k5uGzhKuJ6V0qjilDiQ==\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+IFgyNTUxOSByMzlaaE9iYmVtWTNFc1ha\nSFh0bi9EZCtlSHJKcUtpbHF4dERPc0lJeHlJCkhNOXVzR1dmNWZ0bmRsWDJlYkxT\nbU0yR1RQQ1Q4RUVNTkRMb25hYUdjTVUKLS0tIGsxN1FsdUNkejR2d3Z2YVpJdEYr\nTXBmN3JIU1BIdHhYUWY4NUhwdFZKWmMKf4Ebo0ymHcHwSIhxQQx5erIy0D7KHZrJ\nw/ZnnM71IRSa843hbCppDcflvjXJ44WjKUJZ15ShNtZ4iLNI3xSbsQ==\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-15T11:54:43Z +sops_mac=ENC[AES256_GCM,data:x2PyUDBewcCOQ4+NnTNArVYDnRvtSasD1YW/zup5JoZS9ssCIzbdJaIGvWo+TetbMbdw6ZT5ravjnWqTjn8mqEqNocZeF8h/0OFIbpe6ptRoFTzNXmRP9MGdAHzhoCiVtaJ8J1aKNoyK66LMs4+wIC3AB4A8jYx1Y8BC5iycGxI=,iv:8fr4hGbHF0s+66olwDNHI0qJNyDDORCqaXL/z9m3dWA=,tag:SE9B7gEEheqM+Kj5zxmNEQ==,type:str] sops_unencrypted_suffix=_unencrypted sops_version=3.11.0 From 14e474a42a3d39ae56b5cc54556278b4bcbf3e11 Mon Sep 17 00:00:00 2001 From: Travis Date: Wed, 15 Apr 2026 13:35:40 -0400 Subject: [PATCH 36/41] Clean local analytics table (#3579) --- client/containers/Pub/PubHeader/Download.tsx | 1 - infra/.env.test | 3 - pnpm-lock.yaml | 12 +- server/analytics/api.ts | 97 +++++++++++--- server/analytics/impactApi.ts | 85 ++++++++----- server/analytics/model.ts | 72 +++-------- server/analytics/summaryViews.ts | 64 +++++----- server/analyticsCloudflareCache/model.ts | 3 +- tools/migrateRedshift.ts | 126 ++++++++----------- utils/analytics/usePageOnce.ts | 2 - utils/api/schemas/analytics.ts | 34 ++--- 11 files changed, 265 insertions(+), 234 deletions(-) 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/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/pnpm-lock.yaml b/pnpm-lock.yaml index 8e96111a5f..69191cd279 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,7 @@ settings: patchedDependencies: reakit: - hash: 31488077229fe3d19a71c66458b888c58eab18010632a29e3b5c485df77242a8 + hash: hpw5k5ors3jxzufoxbjqeo4iee path: patches/reakit.patch importers: @@ -525,7 +525,7 @@ importers: version: 1.0.9(react@16.14.0) reakit: specifier: 1.0.0-beta.14 - version: 1.0.0-beta.14(patch_hash=31488077229fe3d19a71c66458b888c58eab18010632a29e3b5c485df77242a8)(react-dom@16.14.0(react@16.14.0))(react@16.14.0) + version: 1.0.0-beta.14(patch_hash=hpw5k5ors3jxzufoxbjqeo4iee)(react-dom@16.14.0(react@16.14.0))(react@16.14.0) rebound: specifier: ^0.1.0 version: 0.1.0 @@ -23469,11 +23469,11 @@ snapshots: dependencies: picomatch: 2.3.1 - reakit-system@0.7.2(react-dom@16.14.0(react@16.14.0))(react@16.14.0)(reakit-utils@0.7.3(react-dom@16.14.0(react@16.14.0))(react@16.14.0))(reakit@1.0.0-beta.14(patch_hash=31488077229fe3d19a71c66458b888c58eab18010632a29e3b5c485df77242a8)(react-dom@16.14.0(react@16.14.0))(react@16.14.0)): + reakit-system@0.7.2(react-dom@16.14.0(react@16.14.0))(react@16.14.0)(reakit-utils@0.7.3(react-dom@16.14.0(react@16.14.0))(react@16.14.0))(reakit@1.0.0-beta.14(patch_hash=hpw5k5ors3jxzufoxbjqeo4iee)(react-dom@16.14.0(react@16.14.0))(react@16.14.0)): dependencies: react: 16.14.0 react-dom: 16.14.0(react@16.14.0) - reakit: 1.0.0-beta.14(patch_hash=31488077229fe3d19a71c66458b888c58eab18010632a29e3b5c485df77242a8)(react-dom@16.14.0(react@16.14.0))(react@16.14.0) + reakit: 1.0.0-beta.14(patch_hash=hpw5k5ors3jxzufoxbjqeo4iee)(react-dom@16.14.0(react@16.14.0))(react@16.14.0) reakit-utils: 0.7.3(react-dom@16.14.0(react@16.14.0))(react@16.14.0) reakit-utils@0.7.3(react-dom@16.14.0(react@16.14.0))(react@16.14.0): @@ -23481,13 +23481,13 @@ snapshots: react: 16.14.0 react-dom: 16.14.0(react@16.14.0) - reakit@1.0.0-beta.14(patch_hash=31488077229fe3d19a71c66458b888c58eab18010632a29e3b5c485df77242a8)(react-dom@16.14.0(react@16.14.0))(react@16.14.0): + reakit@1.0.0-beta.14(patch_hash=hpw5k5ors3jxzufoxbjqeo4iee)(react-dom@16.14.0(react@16.14.0))(react@16.14.0): dependencies: body-scroll-lock: 2.7.1 popper.js: 1.16.1 react: 16.14.0 react-dom: 16.14.0(react@16.14.0) - reakit-system: 0.7.2(react-dom@16.14.0(react@16.14.0))(react@16.14.0)(reakit-utils@0.7.3(react-dom@16.14.0(react@16.14.0))(react@16.14.0))(reakit@1.0.0-beta.14(patch_hash=31488077229fe3d19a71c66458b888c58eab18010632a29e3b5c485df77242a8)(react-dom@16.14.0(react@16.14.0))(react@16.14.0)) + reakit-system: 0.7.2(react-dom@16.14.0(react@16.14.0))(react@16.14.0)(reakit-utils@0.7.3(react-dom@16.14.0(react@16.14.0))(react@16.14.0))(reakit@1.0.0-beta.14(patch_hash=hpw5k5ors3jxzufoxbjqeo4iee)(react-dom@16.14.0(react@16.14.0))(react@16.14.0)) reakit-utils: 0.7.3(react-dom@16.14.0(react@16.14.0))(react@16.14.0) rebound@0.1.0: {} diff --git a/server/analytics/api.ts b/server/analytics/api.ts index d4dc8603df..44500c37fe 100644 --- a/server/analytics/api.ts +++ b/server/analytics/api.ts @@ -9,21 +9,82 @@ import { contract } from 'utils/api/contract'; import { enqueue } from './writeBuffer'; +// ─── Stitch dual-write (temporary for rollback safety) ────────────────────── + +/** 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' }, + }).catch(() => { + // Silently swallow — Stitch is best-effort during the transition period. + }); +} + const s = initServer(); -const toEventRecord = ( - payload: AnalyticsEventPayload, - enrichment: { country: string | null; countryCode: string | null }, -) => { +// ─── 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; + +/** 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, collectionIds, timestamp, ...fields } = raw; + 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, - ...enrichment, - timestamp: new Date(timestamp as number), + createdAt: new Date(timestamp as number), isUnique: (unique as boolean | undefined) ?? null, - collectionIds: typeof collectionIds === 'string' ? collectionIds.split(',') : null, }; }; @@ -44,21 +105,23 @@ export const analyticsServer = s.router(contract.analytics, { }, ], handler: async ({ body: payload }) => { - // Only record events on the production deployment (or in tests) - // to avoid polluting analytics with localhost / staging page views. - // Checked server-side so it can't be spoofed by clients. - if (!env.PUBPUB_PRODUCTION && env.NODE_ENV !== 'test') { + // 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 { timezone } = payload; - const { name: country = null, id: countryCode = null } = - getCountryForTimezone(timezone) || {}; - - const record = toEventRecord(payload, { country, countryCode }); + const record = toEventRecord(payload); enqueue(record); diff --git a/server/analytics/impactApi.ts b/server/analytics/impactApi.ts index c1fc9a02f7..cd9fd66040 100644 --- a/server/analytics/impactApi.ts +++ b/server/analytics/impactApi.ts @@ -4,6 +4,8 @@ * * Mounted at /api/analytics-impact/... */ + +import { getCountryForTimezone } from 'countries-and-timezones'; import { Router } from 'express'; import { QueryTypes } from 'sequelize'; @@ -49,6 +51,7 @@ function setCache(key: string, data: unknown) { 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 }; @@ -56,6 +59,28 @@ type TopPubRow = { pubTitle: string; pubId: string; views: number; downloads: nu type TopCollectionRow = { collectionTitle: string; 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 = { @@ -102,7 +127,7 @@ async function fetchSummary(scope: Scope, startDate: string, endDate: string) { return fetchSummaryFromRaw(scope, startDate, endDate); } - const [daily, countries, topPubs, topPages, topCollections, referrers, campaigns, devices] = + const [daily, timezoneRows, topPubs, topPages, topCollections, referrers, campaigns, devices] = await Promise.all([ // ── daily breakdown from matview sequelize.query( @@ -116,16 +141,15 @@ async function fetchSummary(scope: Scope, startDate: string, endDate: string) { ORDER BY date`, { replacements, type: QueryTypes.SELECT }, ), - // ── countries from matview - sequelize.query( + // ── countries from matview (timezone → country mapped in JS) + sequelize.query( `SELECT - country, - country_code AS "countryCode", + timezone, SUM(count)::int AS count - FROM analytics_daily_country + FROM analytics_daily_timezone WHERE ${mvWhere} - GROUP BY country, country_code - ORDER BY count DESC LIMIT 250`, + GROUP BY timezone + ORDER BY count DESC`, { replacements, type: QueryTypes.SELECT }, ), // ── top pubs from matview, JOIN Pubs for titles @@ -221,7 +245,7 @@ async function fetchSummary(scope: Scope, startDate: string, endDate: string) { totalUniqueVisits, totalDownloads, daily: dailyParsed, - countries: countries.map((c) => ({ ...c, count: Number(c.count) })), + countries: rollUpTimezoneToCountries(timezoneRows).slice(0, 250), topPubs: topPubs.map((p) => ({ ...p, views: Number(p.views), @@ -243,18 +267,18 @@ async function fetchSummaryFromRaw(scope: Scope, startDate: string, endDate: str const { clause: scopeClause, replacements: scopeReplacements } = scopeWhere(scope); const baseReplacements = { ...scopeReplacements, startDate, endDate }; - const dateFilter = `"timestamp" >= :startDate::date AND "timestamp" < (:endDate::date + interval '1 day')`; + 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."timestamp" >= :startDate::date AND ae."timestamp" < (:endDate::date + interval '1 day')`; + const aeDateFilter = `ae."createdAt" >= :startDate::date AND ae."createdAt" < (:endDate::date + interval '1 day')`; const aeBaseWhere = `${aeScopeClause} AND ${aeDateFilter}`; const [ daily, [totalDlRow], - countries, + timezoneRows, topPubs, topPages, topCollections, @@ -264,7 +288,7 @@ async function fetchSummaryFromRaw(scope: Scope, startDate: string, endDate: str ] = await Promise.all([ sequelize.query( `SELECT - date_trunc('day', "timestamp")::date::text AS date, + date_trunc('day', "createdAt")::date::text AS date, COUNT(*) AS "pageViews", COUNT(*) FILTER (WHERE "isUnique" = true) AS "uniquePageViews" FROM "AnalyticsEvents" @@ -278,15 +302,14 @@ async function fetchSummaryFromRaw(scope: Scope, startDate: string, endDate: str WHERE ${baseWhere} AND event = 'download'`, { replacements: baseReplacements, type: QueryTypes.SELECT }, ), - sequelize.query( + sequelize.query( `SELECT - COALESCE(country, 'Unknown') AS country, - COALESCE("countryCode", '') AS "countryCode", + COALESCE(timezone, '') AS timezone, COUNT(*) AS count FROM "AnalyticsEvents" WHERE ${baseWhere} AND ${pageEvents} - GROUP BY country, "countryCode" - ORDER BY count DESC LIMIT 250`, + GROUP BY timezone + ORDER BY count DESC`, { replacements: baseReplacements, type: QueryTypes.SELECT }, ), sequelize.query( @@ -304,23 +327,27 @@ async function fetchSummaryFromRaw(scope: Scope, startDate: string, endDate: str ), sequelize.query( `SELECT - COALESCE("pageTitle", "pubTitle", "collectionTitle", path, '') AS "pageTitle", - COALESCE(path, '') AS path, + COALESCE(pg.title, p.title, c.title, ae.path, '') AS "pageTitle", + COALESCE(ae.path, '') AS path, COUNT(*) AS count - FROM "AnalyticsEvents" - WHERE ${baseWhere} AND ${pageEvents} - GROUP BY "pageTitle", "pubTitle", "collectionTitle", path + 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("collectionTitle", "collectionSlug", "collectionId"::text) AS "collectionTitle", - "collectionId"::text AS "collectionId", + COALESCE(c.title, ae."collectionId"::text) AS "collectionTitle", + ae."collectionId"::text AS "collectionId", COUNT(*) AS count - FROM "AnalyticsEvents" - WHERE ${baseWhere} AND "collectionId" IS NOT NULL AND event IN ('collection','pub') - GROUP BY "collectionTitle", "collectionSlug", "collectionId" + 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 ORDER BY count DESC LIMIT 250`, { replacements: baseReplacements, type: QueryTypes.SELECT }, ), @@ -376,7 +403,7 @@ async function fetchSummaryFromRaw(scope: Scope, startDate: string, endDate: str totalUniqueVisits, totalDownloads: parseInt(String((totalDlRow as any)?.totalDownloads ?? '0'), 10), daily: dailyParsed, - countries: countries.map((c) => ({ ...c, count: Number(c.count) })), + countries: rollUpTimezoneToCountries(timezoneRows).slice(0, 250), topPubs: topPubs.map((p) => ({ ...p, views: Number(p.views), diff --git a/server/analytics/model.ts b/server/analytics/model.ts index b03f5f5f6d..00338784cf 100644 --- a/server/analytics/model.ts +++ b/server/analytics/model.ts @@ -14,28 +14,30 @@ import { @Table({ updatedAt: false, + // Map Sequelize's auto-managed createdAt to our renamed column (was "timestamp") + createdAt: 'createdAt', indexes: [ { - name: 'analytics_events_community_event_ts', - fields: ['communityId', 'event', 'timestamp'], + name: 'analytics_events_community_event_created', + fields: ['communityId', 'event', 'createdAt'], }, - { name: 'analytics_events_pub_event_ts', fields: ['pubId', 'event', 'timestamp'] }, + { name: 'analytics_events_pub_event_created', fields: ['pubId', 'event', 'createdAt'] }, { - name: 'analytics_events_collection_event_ts', - fields: ['collectionId', 'event', 'timestamp'], + name: 'analytics_events_collection_event_created', + fields: ['collectionId', 'event', 'createdAt'], }, { - name: 'analytics_events_community_ts', - fields: ['communityId', 'timestamp'], + name: 'analytics_events_community_created', + fields: ['communityId', 'createdAt'], }, { name: 'analytics_events_community_pages', - fields: ['communityId', 'timestamp', 'isUnique'], + fields: ['communityId', 'createdAt', 'isUnique'], where: { event: { [Op.in]: ['page', 'pub', 'collection', 'other'] } }, }, { name: 'analytics_events_pub_views_dl', - fields: ['communityId', 'pubId', 'timestamp'], + fields: ['communityId', 'pubId', 'createdAt'], where: { pubId: { [Op.ne]: null }, event: { [Op.in]: ['pub', 'download'] }, @@ -60,9 +62,9 @@ export class AnalyticsEvent extends Model< @Column(DataType.TEXT) declare event: string; - @AllowNull(false) - @Column(DataType.DATE) - declare timestamp: Date; + // 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; @@ -107,28 +109,9 @@ export class AnalyticsEvent extends Model< @Column(DataType.UUID) declare communityId: string | null; - @Column(DataType.TEXT) - declare communitySubdomain: string | null; - - @Column(DataType.TEXT) - declare communityName: string | null; - - @AllowNull(false) - @Column(DataType.BOOLEAN) - declare isProd: boolean; - - @Column(DataType.TEXT) - declare country: string | null; - - @Column(DataType.TEXT) - declare countryCode: string | null; - @Column(DataType.TEXT) declare url: string | null; - @Column(DataType.TEXT) - declare title: string | null; - @Column(DataType.TEXT) declare hash: string | null; @@ -141,42 +124,15 @@ export class AnalyticsEvent extends Model< @Column(DataType.TEXT) declare path: string | null; - @Column(DataType.TEXT) - declare pageTitle: string | null; - @Column(DataType.UUID) declare pageId: string | null; - @Column(DataType.TEXT) - declare pageSlug: string | null; - - @Column(DataType.TEXT) - declare collectionTitle: string | null; - - @Column(DataType.TEXT) - declare collectionKind: string | null; - @Column(DataType.UUID) declare collectionId: string | null; - @Column(DataType.TEXT) - declare collectionSlug: string | null; - - @Column(DataType.TEXT) - declare pubTitle: string | null; - @Column(DataType.UUID) declare pubId: string | null; - @Column(DataType.TEXT) - declare pubSlug: string | null; - - @Column(DataType.ARRAY(DataType.TEXT)) - declare collectionIds: string[] | null; - - @Column(DataType.UUID) - declare primaryCollectionId: string | null; - @Column(DataType.TEXT) declare release: string | null; diff --git a/server/analytics/summaryViews.ts b/server/analytics/summaryViews.ts index 36945dfd68..c7e55bd7cd 100644 --- a/server/analytics/summaryViews.ts +++ b/server/analytics/summaryViews.ts @@ -21,32 +21,31 @@ const VIEWS: Array<{ name: string; createSql: string; indexSql: string }> = [ CREATE MATERIALIZED VIEW IF NOT EXISTS analytics_daily_summary AS SELECT "communityId", - date_trunc('day', "timestamp")::date AS date, + 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', "timestamp")::date`, +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_country', + name: 'analytics_daily_timezone', createSql: ` -CREATE MATERIALIZED VIEW IF NOT EXISTS analytics_daily_country AS +CREATE MATERIALIZED VIEW IF NOT EXISTS analytics_daily_timezone AS SELECT "communityId", - date_trunc('day', "timestamp")::date AS date, - COALESCE(country, 'Unknown') AS country, - COALESCE("countryCode", '') AS country_code, + 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', "timestamp")::date, country, "countryCode"`, +GROUP BY "communityId", date_trunc('day', "createdAt")::date, timezone`, indexSql: ` -CREATE UNIQUE INDEX IF NOT EXISTS analytics_daily_country_uk - ON analytics_daily_country ("communityId", date, country, country_code)`, +CREATE UNIQUE INDEX IF NOT EXISTS analytics_daily_timezone_uk + ON analytics_daily_timezone ("communityId", date, md5(timezone))`, }, { name: 'analytics_daily_pub', @@ -55,12 +54,12 @@ CREATE MATERIALIZED VIEW IF NOT EXISTS analytics_daily_pub AS SELECT "communityId", "pubId", - date_trunc('day', "timestamp")::date AS date, + 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', "timestamp")::date`, +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)`, @@ -72,11 +71,11 @@ CREATE MATERIALIZED VIEW IF NOT EXISTS analytics_daily_collection AS SELECT "communityId", "collectionId", - date_trunc('day', "timestamp")::date AS date, + 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', "timestamp")::date`, +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)`, @@ -91,12 +90,12 @@ CREATE UNIQUE INDEX IF NOT EXISTS analytics_daily_collection_uk CREATE MATERIALIZED VIEW IF NOT EXISTS analytics_daily_referrer AS SELECT "communityId", - date_trunc('day', "timestamp")::date AS date, + 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', "timestamp")::date, COALESCE(LEFT(referrer, 500), 'Direct')`, +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))`, @@ -107,12 +106,12 @@ CREATE UNIQUE INDEX IF NOT EXISTS analytics_daily_referrer_uk CREATE MATERIALIZED VIEW IF NOT EXISTS analytics_daily_campaign AS SELECT "communityId", - date_trunc('day', "timestamp")::date AS date, + 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', "timestamp")::date, "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))`, @@ -122,16 +121,19 @@ CREATE UNIQUE INDEX IF NOT EXISTS analytics_daily_campaign_uk createSql: ` CREATE MATERIALIZED VIEW IF NOT EXISTS analytics_daily_page AS SELECT - "communityId", - date_trunc('day', "timestamp")::date AS date, - LEFT(COALESCE("pageTitle", "pubTitle", "collectionTitle", path, ''), 300) AS page_title, - LEFT(COALESCE(path, ''), 300) AS path, + 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" -WHERE event IN ('page','pub','collection','other') -GROUP BY "communityId", date_trunc('day', "timestamp")::date, - LEFT(COALESCE("pageTitle", "pubTitle", "collectionTitle", path, ''), 300), - LEFT(COALESCE(path, ''), 300)`, +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))`, @@ -142,7 +144,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS analytics_daily_page_uk CREATE MATERIALIZED VIEW IF NOT EXISTS analytics_daily_device AS SELECT "communityId", - date_trunc('day', "timestamp")::date AS date, + 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' @@ -152,7 +154,7 @@ SELECT COUNT(*) AS count FROM "AnalyticsEvents" WHERE event IN ('page','pub','collection','other') -GROUP BY "communityId", date_trunc('day', "timestamp")::date, +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' @@ -187,6 +189,9 @@ export async function createSummaryViews() { 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)`); } /** @@ -202,6 +207,7 @@ export async function createSummaryViews() { 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() { diff --git a/server/analyticsCloudflareCache/model.ts b/server/analyticsCloudflareCache/model.ts index 9a54c216eb..11f518488d 100644 --- a/server/analyticsCloudflareCache/model.ts +++ b/server/analyticsCloudflareCache/model.ts @@ -12,9 +12,8 @@ import { AllowNull, Column, DataType, Model, PrimaryKey, Table } from 'sequelize * Past days are cached permanently (expiresAt = null). * Today's partial data is cached with a short TTL (expiresAt = now + 1h). * - * Note: tableName kept as 'AnalyticsDailyCaches' to avoid a DB migration. */ -@Table({ tableName: 'AnalyticsDailyCaches', timestamps: false }) +@Table({ timestamps: false }) export class AnalyticsCloudflareCache extends Model< InferAttributes, InferCreationAttributes diff --git a/tools/migrateRedshift.ts b/tools/migrateRedshift.ts index 5fbc23e57e..e64cf848e9 100644 --- a/tools/migrateRedshift.ts +++ b/tools/migrateRedshift.ts @@ -108,7 +108,7 @@ CREATE TABLE IF NOT EXISTS "AnalyticsEvents" ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), type text NOT NULL, event text NOT NULL, - "timestamp" timestamptz NOT NULL, + "createdAt" timestamptz NOT NULL DEFAULT now(), referrer text, "isUnique" boolean, search text, @@ -122,53 +122,37 @@ CREATE TABLE IF NOT EXISTS "AnalyticsEvents" ( "userAgent" text NOT NULL, os text NOT NULL, "communityId" uuid, - "communitySubdomain" text, - "communityName" text, - "isProd" boolean NOT NULL, - country text, - "countryCode" text, url text, - title text, hash text, height integer, width integer, path text, - "pageTitle" text, "pageId" uuid, - "pageSlug" text, - "collectionTitle" text, - "collectionKind" text, "collectionId" uuid, - "collectionSlug" text, - "pubTitle" text, "pubId" uuid, - "pubSlug" text, - "collectionIds" text[], - "primaryCollectionId" uuid, release text, - format text, - "createdAt" timestamptz NOT NULL DEFAULT now() + format text ); -CREATE INDEX IF NOT EXISTS "analytics_events_community_event_ts" - ON "AnalyticsEvents" ("communityId", event, "timestamp"); -CREATE INDEX IF NOT EXISTS "analytics_events_pub_event_ts" - ON "AnalyticsEvents" ("pubId", event, "timestamp"); -CREATE INDEX IF NOT EXISTS "analytics_events_collection_event_ts" - ON "AnalyticsEvents" ("collectionId", event, "timestamp"); +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_ts" - ON "AnalyticsEvents" ("communityId", "timestamp"); +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", "timestamp", "isUnique") + 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", "timestamp") + ON "AnalyticsEvents" ("communityId", "pubId", "createdAt") WHERE "pubId" IS NOT NULL AND event IN ('pub','download'); `; @@ -225,7 +209,17 @@ CREATE UNLOGGED TABLE analytics_staging ( ); `; -const TRANSFORM_SQL = ` +/** + * 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}$' @@ -247,7 +241,7 @@ $$ LANGUAGE sql IMMUTABLE; 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') + AND pg_temp.safe_ts(s."timestamp") <= (now() + interval '1 day')${cutoffClause} ON CONFLICT (id) DO NOTHING; `; +} // ─── main ──────────────────────────────────────────────────────────────────── @@ -365,8 +330,8 @@ DELETE FROM "AnalyticsEvents" WHERE referrer ~* '( (now() + interval '1 day'); + OR "createdAt" < '2016-01-01'::timestamptz + OR "createdAt" > (now() + interval '1 day'); `; async function main() { @@ -394,6 +359,23 @@ async function main() { 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 @@ -429,7 +411,7 @@ async function main() { ); // Transform and insert into final table - await sequelize.query(TRANSFORM_SQL); + await sequelize.query(transformSql); // Drop staging to free disk space before next file await sequelize.query('DROP TABLE IF EXISTS analytics_staging'); 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-]+)*$/) From b8fb8fba1dbddc4b08e856e40fa6a443d6f7dfb8 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Wed, 15 Apr 2026 13:46:09 -0400 Subject: [PATCH 37/41] update redshift path --- infra/.env.dev.enc | 118 ++++++++++++++++++++++---------------------- infra/.env.enc | 120 ++++++++++++++++++++++----------------------- 2 files changed, 119 insertions(+), 119 deletions(-) diff --git a/infra/.env.dev.enc b/infra/.env.dev.enc index 80fb97d5dd..c74e4c8ffe 100644 --- a/infra/.env.dev.enc +++ b/infra/.env.dev.enc @@ -1,67 +1,67 @@ -AES_ENCRYPTION_KEY=ENC[AES256_GCM,data:Lqd/Ql81s6HHNwxb1GCyqj9sCDStOXlDs3oiTbBWkuqMpiIzxmV6PdbxNkTV6H0vwTXZC9lmdjedVL1WL8sCng==,iv:AcNchnTlU1+vPX/YvK4N9gsN/tPv3WBbRZ6JS0DR6V4=,tag:PfBca0uMLs9U6KRqLdrSaw==,type:str] -ALTCHA_HMAC_KEY=ENC[AES256_GCM,data:785sEuABwWQRdyt943g8mca5itGH89LdRJeHWs038A9XD9HHpBU1dVLwh/xt1ZJD58d41G+FGZAbXRj3hTuKdA==,iv:UOhVM2JxswhwbVFPv42mJpSYuKLTDdAuawH49HtMhGk=,tag:KeGG8fuHwZpsLzl+hpW6Wg==,type:str] -AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:D8ho3IUPmhoZ201cuev3hEC1QAw=,iv:VnNpvqbXeHfUTvab3eWhBH+i22HiLfa4xshGGF23dfE=,tag:6eMEh8Cr4F26DRLzU6+SAQ==,type:str] -AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:q0kl+aRIwf2bxjWrHdujv4X0yEE=,iv:AZKwZf4LPuJW3YXFSpeq4TgehOXKO0yJd/ZOgHrx5uc=,tag:lut8sHYkw6WOnhQFzFyMEQ==,type:str] -AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:hf8J7JSXBeP1bNFNtaZfAWDa+tCmc+P/GyBS6E1tAugqL/aXR8nOgA==,iv:ZNpaM9vf/FhN1oZp76bpx+ywL0K4JVgvmuEnSbkH6WM=,tag:qWl6Jo1JUpeapGbQivdipQ==,type:str] -AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:l6z8Fcgg8u7hj+WZHAWTOzdpO9bVY82khh0K5BG8F7Ah13Sh9Jkd1Q==,iv:NP9dXhxFdILFogGvlDnCz8ZdQ9S6Q2cgPh1j5zBdINs=,tag:TD30dtZb8VQAFNHGpnYozg==,type:str] -BACKUPS_SECRET=ENC[AES256_GCM,data:wR4BnO1peJJjllxrEPLI8QANctzo5gLrdpoN/kQVpMb/df06a2esX7SUryo=,iv:4hP/3Vx9LggNv0Op9soMnujMuTtGe4NOGFyPat3EEbI=,tag:noPl3Hx49cljY+zSU1SCVQ==,type:str] -CLOUDFLARE_ANALYTICS_API_TOKEN=ENC[AES256_GCM,data:lA+MBSEuig6hdWASJK5QUNdIRzUr4dMvzNbdIKGiMcRKFYl9p9SJ8hdiRj823wCpD/sPZOg=,iv:47eUYECUXPkCwJaq//VxUQZn9/e7uxxcEDQk9DNZhmc=,tag:F8ZgBBZr0W6CgLWzSRkNQw==,type:str] -CLOUDFLARE_ZONE_TAG=ENC[AES256_GCM,data:9W07ZStot60qPr5W1oYroIg4b38ZYWTHaGFvoG3Qcco=,iv:eG8rx2Cuq754udGhpdY0/mnBNY4r5rnnzvLXCFqxo/k=,tag:wwYUP/hfpxnfJfCaAHSK8Q==,type:str] -DATABASE_URL=ENC[AES256_GCM,data:cswqSz2x5NgRDPwCLvMDG5Vz39ojJAc5Jez3tI4VktgbyxQIzUT7ocAuwF4=,iv:TVSuxT3c7dfVpDJZE7xIV14ZQr5ZfgzOWKGZ1HOwgA0=,tag:kLf+06NqCSZAN0p3tU6O2g==,type:str] -DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:Et0XGalqp/MEDrM5BVn60ErUy23bVlAxjBmW5Pj94/fEfA==,iv:lhyoCZUeox4hHelUTUBqtGbkovu8/Xy9JxJsstUJBns=,tag:y7yPbVXuXlatlwgowc33fw==,type:str] -DOI_LOGIN_ID=ENC[AES256_GCM,data:KSWUrAit,iv:CbfEg2PkHDds4QAKL8YugmOdegCcuKE2zq/u7B/LJu0=,tag:NSwGX3ma00KcSiu9jflc3w==,type:str] -DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:OEGDrhG/oWCGRwb6VIG/8Bx3eAw=,iv:gtRuextg2Zv3mgl+fJc0Hn9wXfjiaXl7nlvu6StIE8A=,tag:uIGMrUuRC9HooQ8QIShkkg==,type:str] -DOI_SUBMISSION_URL=ENC[AES256_GCM,data:V8JVDwXcjeOF1/GPF45xw/kT8XukRYH+nBeuCa7ycipqTZUw6R22HUU=,iv:NsW1BqzSoDNNsz5GJYZD9gmXA1cbxY1znajmGSOKe+8=,tag:O4gwAkfMouuZeSPcez2HkA==,type:str] -FASTLY_PURGE_TOKEN=ENC[AES256_GCM,data:GcX2sqdp+2GHS5lYlhrCm7abWa75Kzw+erg4aX06nS0=,iv:7aADL8Bt+cPzj9lPvXqyAWkXlHinqc/JFCIkIsbdYH0=,tag:WvIKX2Sq5yICnyn2IWNJnA==,type:str] -FASTLY_SERVICE_ID=ENC[AES256_GCM,data:krwfvaYNg8OFzlyhWNvLMm7Flo2IGw==,iv:eCLf9uXc1sduUa1JmMV6c3fO18aypgzlARN2YAzvl6c=,tag:z4xK1q2xgd7rA2426oMbnw==,type:str] -FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data: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,iv:D+ldy+1N9K7rLjPlpYgSg//BS88Jv2m9irqmHkINI6s=,tag:HpUbxezlnoiGxf4FCSKMHg==,type:str] -IS_DUQDUQ=ENC[AES256_GCM,data:cq797A==,iv:Frt7sJrW4UtLvwnudBUZ09G3li1o7kl6E3dYT+328ec=,tag:dntdA8s45Al3tSXbLKL1HA==,type:str] -JWT_SIGNING_SECRET=ENC[AES256_GCM,data:wlDaFf3zd6EfrqNl/0wkEOdC2E7H/2ZEfIJWYKGz8VSTHoROBwWEpK+/3OX4L6m3iVgCow/DsNJUKwtPJdYNr8ln2uWoFWagAGskQyL55go+EZa9EQ2kaUo8SazeX0CGQ0LtDjNdFoijzcSRlCiL5gHCeJBqAkc7mFWFHe2skquYUSysJXaEnhXgm6+D9739AJB173rpMg+gQznjMP/vCJ1kFCcZJbCZ48n/gFkVAFZ85L6inJMtuHEZiL2CLPjYhopLsNGYimZyqyr3BS+DB7LxOlfMrqJVU+Otqv7rq/aPlAkLHvcUL/bMeJ6gBuGVKSmrlDo3/0/KuxBrQ6MXnjSfI2bnNwA90wHHzoKf/uXIayXl090BiSWw3WYKXV6wwDhUoLFM22rUqsR+Rf+taKLUfyXKK1eAe3RG3EiJnNm2D87+fsCHse/+ad5JbOphmWJldTtbfmrqdEpNkRteH5qYL6WYYZZpC3h6TrH7Pg9LuAWc0wJacvL3ZvLzSS2dOWXXZgHEcuwjc1WFR5rv7txAnu5WJk4mW4f9uOzTLod2o8WhAiw/iBBv3bNE2G4M5mT9Wd/aywTT+Mkj3mUFeWcHdeb7alfQLzJ5bVl+b3QaaWbLrSDfmOlqoyoxi3g5bSLB5j/K8iTk/XXCXid/SHyZB2PGstfalTCuO08lg3vhp8gNonCC32TOH3xOpa4vJSaSahSGt3AN2Qgx0BDG87+6Fz/4jeeyYgVfnypkbaaUcprWcbfg9qtQYkPrXGb3BAtoVzC3jejBgEG/Mm/CPW+p22/vd7uKnCgY7JMIl+CKMFbRvEUMuo1L0X/cihMOIyXZzTb7pAEbQzyaPyz0JUAR2sssHwROMllJUHAbDT6qbPv92PSPNcOc+IeQtwFALHTmLSjWrKsZMduSyMrPsDOFVeEydBHN8kBopuIhD+gBl5R4dmH1Hl1r8MU0Re+NBo+PXkOq7hJsNlbVoHbi2IEGpJmsiCD853a8wP/sjhHJbFBe2Wtvupb3Gj2hVpuZGHw9sJqhpzM6hNLJRZOZc6+IS+dMJSUGXMMw1q17s1uHJSqA1m2tNPiO0rLoe/OvbqXiox0liLTHyayaPseLrVVlnPc6O19iVNVAQR2EakLVNjAJZgzHAmDf+oc8DZ9ZjKIRIulfbK56wcCFrSDsZXbtPMIPjXdPHkLU7+H2dx44emA1cmzWK4sj8BMSrVXi/BYf3xjZHq+ODtn5/yvxRcSpDrWsSZxgY5UBlJR3yZIjazOuFVTMhrypJuybNOl02XR3Ty0gTj9M3dYdP5TnfxoHmg3D0djfNxGG97be8fdm3Ba5ZTkuNylNXwGIFlfU9gPEQaXCh2giz99kva0LhQ==,iv:JyIuXu6EbiV/i+iUqy5VbdJowBBs6CmhY+SzsZ3y07k=,tag:zlhsiYveYqHpo88wL7SyuA==,type:str] -MAILCHIMP_API_KEY=ENC[AES256_GCM,data:lLAnrXkzjq2IqxWajI0OI0Bw1ENNzB2q4ahDQkdOF3DHbpLA,iv:JSxxRAROKgLeA5ks/3lvIsy+F42s8/hh3+p+QVh5BeU=,tag:tU11S07ppVzRLm5gzynHrQ==,type:str] -MAILGUN_API_KEY=ENC[AES256_GCM,data:/ts+oYdIybk/xf3Y5TmBe7BDIbWyaFrM0xV+hqwqqQsnsxQv,iv:8ii2p0YTmvTFGq4r5sVZMleAeYAg779MLwxAXSgYU0s=,tag:q10GtCZrpvzE0Z4fVfcWtA==,type:str] -METABASE_SECRET_KEY=ENC[AES256_GCM,data:DRxctTjMGnKFKvvGWbAuGL4+pJrj0hclQt/jBiftXEF50sOzfznqGFGBdtBonIHrRH5i1VYoLQiUwxDha022oQ==,iv:APLlG1pu48Wx3MCbAczgxwL+NPSDFbPpylID3Y0hjbY=,tag:lHC439OLsM80+DXJnl2gDQ==,type:str] -NODE_ENV=ENC[AES256_GCM,data:2nEWv3XO6gYSvg==,iv:zXrr9n9PXOfC8HaVoUnt4ba+1kGOotTeUBKKW96j4dU=,tag:aqVPlRQXeGGV+eyFD1rEjg==,type:str] -S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:eyJ8d3rzQJxnsmm7MGr8v+cvmyM=,iv:dUiI0fTi+nTCuaMZ8nB9GYqsiyZVemb+OE8xtqFuTq4=,tag:eOCImwtYmI8xw/alGQ1ITA==,type:str] -S3_BACKUP_BUCKET=ENC[AES256_GCM,data:DLvAAgpzkUnqScU=,iv:qoHp15qxaMscgrCNN+4uH89p3Ph9ILIwtm1pxHJj83E=,tag:VaheZRfnOaf5jeO0STYoSQ==,type:str] -S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:VyQLUi3HEHre8d5wgR1bcYgnHg2Lxw9iwk32EgXogie2mJU=,iv:+VqvNh9Rnkzsesk7ygAH0zknbnVETuwikJ6j0zDnQ5M=,tag:Ft0xOnzGKGP+qE0M2VaPXw==,type:str] -S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:uF5FU3/uJsEKF1IwZ9R8SytTBw/nyREk8sToymSl2Ld6z49SKIZvsQ==,iv:DSTZL7x0oqMyPGTx2QKRen9z5YS9ICuLTloj5hDK+eo=,tag:X1kVnPBk74QeXXEEbhPwHw==,type:str] -SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:iX1U5OkzwyLT+0sfn5JH7baM6zGPMKGdBq8MAZImgJ1zGghfT3TdgzxE4ixIchH7zsQ2TKW/OmDp2QEexw5NAkca43KNwhJdQLfDtv5LEf/iPcw1xV8LN3iuyq+J0J9+cupBSpHBhUbNdKuD/m1txU1MovY6gickTWq1ZVNQslHTv4Kb7GILM96sQFTDGfW+eFhndwvt0S1ff78xV1klxXNTafqHwbXTh55yHkHlPuzLw3s8f6JjlKLLmw==,iv:R71lT4nTIyIFS1RGPvEiqMFfskoP37P12TzbBAV+3Kw=,tag:QvJh5DP0oKkxh1djLdA55Q==,type:str] -SENTRY_ORG=ENC[AES256_GCM,data:D9fN,iv:SXyNMsunSC82JD/X1VFUVyQRp6bBIZLQozirVznxIAU=,tag:jaVSsMJJS1mS4LqpaGKqWw==,type:str] -SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:XQI=,iv:18aQzBgdqbyOe/41EWbBTW3ohuKKfPqthyZW6Jh3uRI=,tag:gvd7bGzG4Jta26cWwqtqpg==,type:str] -SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:nasyx+FB9oNcEah0v1fwLETC5RiHGky3l0YHydggRbmurC2+ZM6MOWf5mxvOzq3VqaqEV1rpYQTPhNZgpK+W0gR2oAa2o/XV2squdD21FA==,iv:PdvzayaSFJg36Xxq3HQRNpx/AN6wMSbGfBkW6DNBR/k=,tag:IkkZuiocTGkop9wPldTdUA==,type:str] -SMTP_HOST=ENC[AES256_GCM,data:lrKpyz6XCimc3JsT0LCCfKB1Oo71uxXJx30VY8bS1GuyfQ==,iv:AdbFMwzDaQvpVlIjphFf7Y70c9YR9qq2pW2ul7+OAzE=,tag:w8h0KjTn/ydxoFloiA3YBg==,type:str] -SMTP_PASS=ENC[AES256_GCM,data:fPcFtfNS7nsFHIwZglr7uBLEEirTJZZBIl1Mb3pDoQn8vOM1HQAVC60SkKQ=,iv:ryqeCvMZ0TSlHl4wWuQhM40Nh/hisJ5aHTLDzc4RGlM=,tag:az+R4xE5DIYj9ZrfZ/3tFg==,type:str] -SMTP_USER=ENC[AES256_GCM,data:rraGRlU2d1alOXpB43NWZBZuSBU=,iv:27lFtkxlQ4w1yLnkPm2N1aoEUbdEn8JV16x03mXmY/M=,tag:qUYxbEIvzk84hLO0dM8a/g==,type:str] -STITCH_WEBHOOK_URL=ENC[AES256_GCM,data:8sXvewyr7xEJgX6fBr6BpeCXIpuOJzQkB1ciOUGVqdFe5vq0aWa2aaZFQNI1/5wZYRk+I5+i4NOWaQjqf+axG5Q7tbkgmL0UjaAQCYxaH8MUYmwKMnBbsXRXE7o3U5cEzCZ6DPDIJZ2tWJGhofhZyYdP6rWB,iv:nr2CcpC3yBBuKcnlYGCRPcLAsflGf7Wl56iZLjsR4xw=,tag:1OOdmVnmHkB5X01kHe0H5Q==,type:str] -ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:3/tnMRx6w/HM+J2bykzSAN1eYig=,iv:YAAPAeddQcfhlax+1ngKIM/mZpjLkMFjpCS2XMYKj50=,tag:aSS7PPt7v1y2B/qVJeiCDA==,type:str] -ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:cKI+nRmdS1dVueA5/jMeTTuY1Bs=,iv:xw8tyrSkKzgJEETKcS7DlW5tSLh0GRgvEsClTI3r3EI=,tag:qVwkuQ12rmVms8Xd5GbHAw==,type:str] -#ENC[AES256_GCM,data:SsEjqLsidHviWsnh/iyUIeJxwHIAu4N3sAF4/02jsRAD2oy36DBCOUy3G2xxtNmJmh/+Dxj4EpW+yUCP8Yn2z5vD,iv:7ULOKd6ak8w4LJZG7740fwIwfBl0AEqFziFbf3nizQQ=,tag:6VZCtxFts+mWWBJagMe+LA==,type:comment] -AM_REDSHIFT_PATH=ENC[AES256_GCM,data:gv3yJSTgt9EgPcNn4qTimnO7lmhSeaCo341VJAebPMCOpN6AgUK2IWmvs8hq/7ThYCHaX4wbkQCynuy7xDg=,iv:fvV1OBfAJFdFOdyTldvA1bhJT5xHBAsyN8muP2a8FKY=,tag:CJT7xC2JWaDnP6EspczOWg==,type:str] -AM_REDSHIFT_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:nUK/G4WObIhiGgjb7YWwE04AZgk=,iv:f6PXvwynPFicwEhYZ+MwN6rAK5i8g01itkdUuYK9TqU=,tag:dnndk9BDhk1Nd0BS16WSZA==,type:str] -AM_REDSHIFT_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:yZYcIBvcADGLO5qgIUgyj697UgvkJygL5SSpBh09FBt+8r2xjVQqsQ==,iv:sL5txZupq1bkufmvvM5OyIVs1Msj6qUnm6VgPOmDuSc=,tag:9Py1k7ehRVpynX5I9D3E0g==,type:str] -AM_REDSHIFT_BACKUP_ROLE_ARN=ENC[AES256_GCM,data:2VaGVEhOaiYeelSx3QdxD0JT41S0sevds36uyXkwzdiZkkJYxwKRr90y2Cu/RK/y9v6w,iv:Bwky6Fxn/Bo0N5QFML3Bi+B2O2LHJgjgmE2z7NTJFlo=,tag:oq6ZEk2rzl48mvvlfOeZDA==,type:str] -#ENC[AES256_GCM,data:ZCwsOckTNIt7zecQHUTkN1ZNDHndVKi9cjy8onpMIhIdv3OEcmRBNrz45B6yTdM1RPgB1LYywPgtWs11VG59mYdhg4xLIvK9iL/UUGJsg2L9LdW/LY+lMYkDTk8tFYECjcti8QawJrKPbz9fwwm9GygVCLw9LLnE,iv:qINORDM2xBm1CzBbQqDs1TeRnrJ2kcmjSqIrsahFfG4=,tag:WivBqdTiKTPLNbuQzE8mVw==,type:comment] -#ENC[AES256_GCM,data:oMQFVL+fcdviA/6W3VW3pc1puNflXoaGh5fEfozZFejZmo4Ca5ZshzI7GJF79oRYPvU=,iv:rajGfzlg5oKDEMYyC332Ntf5ONKFqaIuTzC4ObrTR9E=,tag:NUYqHDwydZdjGbdlrY94xA==,type:comment] -#ENC[AES256_GCM,data:t5Q8H/ju1vz1C30VvSlhgLRpfna7BfeeRzlETYI+hGgUFKe9mTdtGqf8RyZY,iv:WKcNeYh/4NSJ9QtTeH9nkdax3ywZMcJhBOEGsLX4pwM=,tag:DmJ7vd2xOL0ziHsqlVf+fg==,type:comment] -#ENC[AES256_GCM,data:8PB3M8+4pZHLbu1QvMKyr5FJ6/cIxbE=,iv:nXrFHhz5Db8+Ulg+P8YpqjYAW+Bl/BPoh8y2V/0KRMQ=,tag:RIf1iuDwG9eTlxpfwKbatw==,type:comment] -#ENC[AES256_GCM,data:leEyl9ZCwWSUnGpqSbYF65us22v8ZHs=,iv:h30PNswz/5/pzu8RxwKz0QgwuTdjixxde91WWC0OHoo=,tag:KZFZ1wQfEfkrx2dM/NUPdw==,type:comment] -#ENC[AES256_GCM,data:rHjEOLNBCk0onbyWKFNjmdrS/HWyjmQXepr8xaisT+YFRVcHFEUMKT7CPp6axAVzJZz5oQ5nWn8NGsnhgFvcYQEBtCEPZu3gtiZvFQ==,iv:/Q33Gwm5xaLsyWg6u1+nz4QdH3TFluHXtDU4rBdvt+c=,tag:I35ETiymlQMmFdbSN1K+aw==,type:comment] -#ENC[AES256_GCM,data:73trR7JE5tzdKLa0C6DUi/QLMLU=,iv:iiSsxqOoJ74h7Dtr+ssspIwog7DELhkdxsLE04Iw8kE=,tag:VFN6uaa4mt+kqXidX5KGgg==,type:comment] -#ENC[AES256_GCM,data:idJoG7THFRc/90UD/sM2y+NvO5YOo8M=,iv:t+AgSzakyVr5Qv6TADRNJE3+rdEQGlg3NZ52R2ZXV0Y=,tag:bkRfXcdXme5QTv+/5CJXxA==,type:comment] -#ENC[AES256_GCM,data:yiMsOjhDlxNF7LcO+anEBA==,iv:dfic8nD5wF9JOIoa4Sjo3W+cHvQhgtOFFg/t1adPXmI=,tag:XBCpwy5lbdcsAz1D1sbZpw==,type:comment] -sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBhcWpBZVVQRDNOQUNYaGVm\ncGhMY2RTdzY1YXIvUTFOU0syWS9MeGFyQmhFCnZLWS8waGE4aFlMVEUvanZXWjI0\nNFJ6cldaL0V1OExVUzhPV0ZFUDk2SHMKLS0tIDNhZzRINm94R1VUSnRNc0NUZkd4\nQm94Q1NodTBDNkVhSjZJVGdzUk9XaHMKJmD/l+EC+WmCGv5NpyNar6cZSfiZ9Oyl\nNIamMMet4g0Up18UvpuCzlNThI5dmfWG8J5XrEUIrh37jI2ea253mA==\n-----END AGE ENCRYPTED FILE-----\n +AES_ENCRYPTION_KEY=ENC[AES256_GCM,data:43HyDvwqQuaLllzrp+7NATXC5CIFP4M/Dblp1U2R560p6DfmGHFFLHo7v6U1ZXi4tLBFotW9XG3nxnuSUQjyFw==,iv:rDAP/GnDDOxgHmnXlUkxYEjIQkFqDKx4SwjqO1N8ADg=,tag:cdqiTqj71vpfH5egvyoYLQ==,type:str] +ALTCHA_HMAC_KEY=ENC[AES256_GCM,data:BbVdOTNuFPsrpnH3r7YYDGBi2CN3TNtJtitq8Z4zZHmUyG6qynFUyXIVKp9xXNzhHPOkLTSrrWpltRJK67FmeA==,iv:S/avgJHRawBwqHoy6SOcMsfUWX2Ay5ogwCv7Pwsk/Ew=,tag:c5VJryjLzF3hgjAyr5DEaQ==,type:str] +AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:D98ebD3FUYdgW5YthIHBfp9N/x4=,iv:MakDoLCdY1tOmRsyi341UdBypmLKETd7uJam2JVS2kM=,tag:vwwXKu1EczeAC/9jIt2mQA==,type:str] +AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:Fm9iewyV443QFAmR+4b1AvfMh4o=,iv:0p9ocIq88+Kg45qF7Y5uZZrIR7G92MzAKeRO8yhJL20=,tag:5PvrEGiFxkhGuxweC80qPQ==,type:str] +AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:7jbJfROwEc+DrEJEBbaauZ/qOdA2Smtiw6I02sHijmYufBCG7q2jkw==,iv:GWeqmo9vhf3eyfzYh54MyBMcJmT8vY0E6trhVYZe8/Q=,tag:BqI33qlbr3AjulvVO7NRiw==,type:str] +AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:HBmtBweCuX5ARIWiOQr5Co1M1dlz5gQBLpo8p9Nd2sv6iGL+MjS6lA==,iv:+jujMqsYuKWPhTWVAT5NjOaNNJuP0ikdt9914mBUQT8=,tag:MV4pV9qmiOt9HGY1oxOyeA==,type:str] +BACKUPS_SECRET=ENC[AES256_GCM,data:ZwtkurhCBYr2ymqOGsZ3dm0/faWwBr4FLKN59ftJeRF3DjivO6grEyF0SGg=,iv:JZtuiczd+tP3j6Lkxlxgi8Oiqw06R79oaqSa5Nlqueo=,tag:v92pOC8DgL+j65/UaCrYaw==,type:str] +CLOUDFLARE_ANALYTICS_API_TOKEN=ENC[AES256_GCM,data:9LCrVhEusbY000lkNDKct8xTsC4zk+48U7PDZmFiCAj6qDrR1zSJT2IDH2LZjPm8kRFy9tw=,iv:xGmAQsoUgFyCzvEL7msksmKeQHSKtaOMftsvHiYZEWk=,tag:yTDRP9ttFBdyx12H5ShBJw==,type:str] +CLOUDFLARE_ZONE_TAG=ENC[AES256_GCM,data:lj4fsZtcT7ZtEU784HuLlwnj3Qanek1Debu6MNchtg4=,iv:M0W3V91BtCPAn01qd5iNnrusoAltEhunqT+pS0bS9JM=,tag:sMjVpO74eNkAfcl5quK/CQ==,type:str] +DATABASE_URL=ENC[AES256_GCM,data:gb9dBpM+CwPBDLiQnlORpAF5HRNHYxqRA0emM8c2rZeclMI1jbiNgq/bEHk=,iv:OnHpi79rFgsP+W+sVqQY64SN3GmcfsUvuaMsudwi5LE=,tag:nRz8396KVKMQdlMB8hAgSQ==,type:str] +DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:H1VsQAMhWz7DDwD2mYnAmhjijU/adCjZVDNOxBDDisjPyQ==,iv:NLECaf1JrkHejpZoauQ/TdMByGOOE8Ebw+DwAaOBkSs=,tag:3o9sX79TFNYpEZpeOqzRyg==,type:str] +DOI_LOGIN_ID=ENC[AES256_GCM,data:ePMOQdJv,iv:nm0x/eGbDFOXUVj6smmu9YNdTF3CLRWP4BMdikqj5Ok=,tag:cWpxhZtH1Dux6PyBXk2eBQ==,type:str] +DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:nJjItxHaIeBHPlLn2h81bGKhWhM=,iv:4cuBUXJe51hkfYaVC6y2ubFCzMQ+lZ1x7UVM+NQUwyE=,tag:jsHhfzpbuAWKsNV9mShZnQ==,type:str] +DOI_SUBMISSION_URL=ENC[AES256_GCM,data:sa5Sa5zHnSimXdA+R5WsVbx/3jWyZuAuCTS5VeEc2YdkWmVek1lWDL8=,iv:H2S4JUsP5GeOa33/CBtsjUpMcjjmAglE7pqLYZwaBn0=,tag:qm8a6sOKFxyvNlYrhgUl/g==,type:str] +FASTLY_PURGE_TOKEN=ENC[AES256_GCM,data:VWcFj/YfC1zCo9ErQy0xjuEY5H83cr5+bnEyvGSK4T8=,iv:9RPBuxFxeQnlshTEcEV/W1QHMLZjx3kwT/2kHfdsjJU=,tag:m5pNj+kyuYEvIQ7UY6K91w==,type:str] +FASTLY_SERVICE_ID=ENC[AES256_GCM,data:LGi4loLItqBh9wwASLEDP3Pfu7QAsA==,iv:tIyrKxu209tJv+CrN7Uyc/v09Q1qcrMCC+//ZSyzhMo=,tag:IR18oqC9nZfPGpIyRW8u7A==,type:str] +FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data: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,iv:zSlw+0fxMMdU4sT8k9DZonNEUzhmWXfSz1REDTOXQUo=,tag:8BH9oETlhVPgOQCkhCBRjg==,type:str] +IS_DUQDUQ=ENC[AES256_GCM,data:k1eMCg==,iv:qVVeRxPTYn2TJkahq2bqaOC9PYBtnegB9je/yDB7hGM=,tag:3a5G0TBJHU3hQ837+vXpfg==,type:str] +JWT_SIGNING_SECRET=ENC[AES256_GCM,data:usGqKYNjJYxPhqxX2C6LTAkRyTMnneUkH6gp3y2zSN9ao2Dyc4N2aeVOSJh57s7QTdALdrz617aI8EROXnbq9AThxToHVD//rkmGU9C3dA4AAZjkNuUrvhCKc5PVtPeDPEFFXh91pPnj2k/KifZAChDPWjEjQtX5XoytYER+rk9b8c3EvvrhNl/URzLN0ojQckYc4ze+HwPDVBl/8pFSZ2Khg8F+eOZjjOXX1rFVxpAgqYHXIOVY1KaPK3ZVtSSazoj/j/1xmjBz/g/gDQiIVNmS4TjK+nJyi7W1LtiWrQg4Sxs/K1uM2kR4eOfHUd3TScj9exZXhCI7Bw938SxB9OagdUBE8ouF9tV4dTdgpGWmM+PNdx2BXav6GyDXCM3IhjecSTrjRCC+33kghxwg0crzBvr8Vsfo0tYqDYgzt7n6WRYVmK4mSNUaDrwbDIqmWmC4NGaBD7RntfCMaop5gL3zQlElH7KenEUdaxjV7a3DG2E341eOTTsXBhF81qmo5LurNE/9grh4SpiuHYRAxMsh/j0uaBDdgHXC3vOWcea99e4BcQbWpjg/ckJpGdboDBaL+0aoGpdatf2CW053nNcHi65F5374DYtfCrlx5vo9WKZB+Gkw/B3pijqAbDqYxn5rTSN1A4rBNbTfm4ma6dl9wAQgUpxy4PuEIgvNTCOIuVHOMYxiBIVr3cdPB6cp+QTVNTpmKCxwEMezFCa+raQyU1oy95Bn0Q0zzg+Gasg1u71rZ9DJ1PpmR2F+45lHUfNFkXObD8/Kq3cRS2WYcS1K7idB6AciVcgzN0dU5zJr+1vTn4nXuIoa+isjGvxUA9sHccwIkHhJz96gHLqj7HJB7z/2/7S1yEtsqA1nExCKmKAYzaYWP15/LDIxbM8QQi2xa5RY4TrkI5vBtHV/n8rXQh1PgLnPqmn1NFdsdQ7lvWOLHzbdJ6Oo7glygHUCH1sh2GGYRbgq/BboaFa414gzvLw6O3SbgZxRRrYn3Q51ONron+xIHhwPtHl3G5zdd3zFh0ceIbd244vmXt2YPaj60vmHgBCN/VT6frO1oiPAPEvodAprShSp0HNm3VtrniimCVSiPw5sPQseOzsjaz2SpDrKeej8BtcCnmGX/fytOpkQ0QqHcaNlQMEWeqUblEME0p+GkLApYeuI95kvSBq3pZ0wqCRBsFndmUPGktzQiy4u0cIT+W+ra44Lm6iOQKqmSUjJWIhJz0aMVmQRPwe6aEa6Hgud3QD4WsDLhj+hM9d+h1TrIQPHjfKOw6yNARshYjv50zCO7aO0DJbnYSqFwtNkIIl3f4GMAKTrVupbntOpr3eCCRKoJ8u0v3VVmiUvI6w8k2ih+EtpJ7KIsA==,iv:YU9xwF+7ezFgwp65w3XIE+MHYOZlSZKtalGvAEsltr8=,tag:S8YinSVzgKrDGO6Z37MkSw==,type:str] +MAILCHIMP_API_KEY=ENC[AES256_GCM,data:RxskuppEvi4UKdjzs3ONZp6u3CYXZK9JITbNONPxpwUGKcaL,iv:LexR2P+CM9pqCM2GMUVA/Zd7GeKi6XlyKGk3LwJ7KVU=,tag:Z6SfqNaHVme99WgnspvXAQ==,type:str] +MAILGUN_API_KEY=ENC[AES256_GCM,data:FBEnpO7EJ3LRQM0dX68Ubas79XUfWDshybiBW6z9Lv779txf,iv:F++coAbmREu9yyiXIIRnDvWxnXbHyxgCeoiCEu7/xhc=,tag:Os7VIrNiUnB8DldYVVMPTw==,type:str] +METABASE_SECRET_KEY=ENC[AES256_GCM,data:d9exeSH1pFWWxjQ5sdpb7RiiOSB60+5OPaSEh4JtgUBJep0Y7x5kkqd2sBTF8Tm1tx17d2Gb/G2erEo72PXJBQ==,iv:1zaYLuMJBdOI8B7Wp2Gs6J341G897PSJendyvnoD86c=,tag:wMyO/aQ+yJpYkKBKxH20Qg==,type:str] +NODE_ENV=ENC[AES256_GCM,data:8weyvp9H5n/ijA==,iv:EcQBe+wb9RdjLHfaHpAMPzl7x0Gf5ttMvRF0nyxj6Tc=,tag:kAXGLlcA0H+wMWXPtVdIFQ==,type:str] +S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:NJagDGPoamhpMwS+UkbcHQkus5U=,iv:9rrfrrF7p4WxduhQO9H2jhcosMQPHtKjvA0bBZ3ZsFk=,tag:d6tPV4HMyEoZWUxMdvd/QA==,type:str] +S3_BACKUP_BUCKET=ENC[AES256_GCM,data:7+QI80whS2bxcTs=,iv:bp6yuwSRjZU0k5w3bfXOhGN+VIX/pjC8WtSy8XbuEqY=,tag:ESXmJ7ceeCYQk0LjbFgVnw==,type:str] +S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:okJuGkt9w2v0jeuldpMwTwwlRsqAxvvj++4TPkXeNb/7ZBY=,iv:x60rQeNdlpSouTXdFZZI+lnQIWGn+crUriAzFzgujo0=,tag:vEMWyKylIoQ9TEHHmnKhhg==,type:str] +S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:TXsjk4xH8zoTsJKa8TJfPmH1TL+9YFE3x9vgwbqTUOqukPCShYoerw==,iv:4pfYWoCeqgXpsUMxHs648ke2hojECjQ5i3uVHHSHlVg=,tag:dU+NfaHZL4Qk6DFHPEuDrQ==,type:str] +SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:QApYM3/l4okVOcen4hDwQNYKPaNSimyB7m73sUATPFp7TuCO6ZwQLS8tLRzL0X44evt05C06lN98KM8OP4YoJl5eC4TsNoGoZlVaLpb9wORfrqNrQHdf+LuzJgKW5lNO6T8ZL18cFJrUJPpuTAUXIQlQ3oJlQOfNFScSwuFNVIEwOG0Ur+PXg6rBTBh4JbhJ+dQl9VaqwWUrKS2LwCYKvyvj0Z9NIQccrr0TZA7tUkgwXed0jbHBB6ifoA==,iv:sqjtkFo5pCSfHGnoH1ik9r4JyrtljhtICyKAMuIXiJw=,tag:F6QZMZQBuqb319mGw6o25w==,type:str] +SENTRY_ORG=ENC[AES256_GCM,data:pvrg,iv:X03Pchi8sW5NUvEEEjLzkduo660D1h43NAzAiL/FLbg=,tag:G7+z7RC8LQ+RsLF2uZUgCQ==,type:str] +SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:H48=,iv:Kqnz5BMtnAKUqis20nUQV/3UR1r8lkC3F5Hh0tqrkxA=,tag:eQY1MyqiV0yguoww60ZWfw==,type:str] +SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:Z10Tj3ptzv8ZNxRO72OcduWvQvK6dPyRgH9dEwz/5e+/4HfctcmP8Kc9kAcF0E569rS1n8oG2jYV+NBx35Jvzn2KKgEDJkLTbHyj1TSL1A==,iv:Ab1McqM6nA+kcx2jrz8KM3PRay2V6XpfmM7/D+eix2Y=,tag:V+X9S5g43RMzAXotLNaFrw==,type:str] +SMTP_HOST=ENC[AES256_GCM,data:dqYWBrAY8gDJEn2oRr9iSoAuQCjAUZRLZRxDrkUpXSV0Tg==,iv:DNBGXoFYDgJKj/3IT0KU6fRda7tbbM0xEu2GMVBSees=,tag:SbwaftRZBbroTNPMIkT8vQ==,type:str] +SMTP_PASS=ENC[AES256_GCM,data:rBvXvd/MZXdeb61bn8HOW1qJN92G2D0vUpenp6HhzWyskOIrRgQwSyxTI3A=,iv:w/RGkTCkSq6UJ2k7xGNs9xiQn8EyoqBUu0xmtTFhm4U=,tag:zsOjlHN2ZzpbE2ivIk92JA==,type:str] +SMTP_USER=ENC[AES256_GCM,data:xveMBK4BZaWe/qLYderRKETjz24=,iv:EGKbC/jN6gFecDFLW0fYHvSMCcXNxqlqTIcTh9gRPCc=,tag:BHC80I5SWCln2cZKQse66A==,type:str] +STITCH_WEBHOOK_URL=ENC[AES256_GCM,data:/ua9PnJGERCo1i/ZQf7xFgGF/tbZdwFvanabUGOpwHu7m3Y5xJKO1YO8MdC5e5dbnZrNLOiZGnx6n04elkTjDuhxFkmSbShahcjpsDTJEawCwrOV7FkWcV/H3hAG6Oid+xoaK8IDCea+AlG+sgp2mYhfy5RO,iv:yMHyegHpkwTt735tKuXg7tUtZW9ndAdj8EXn8U8ZsyE=,tag:HPU1lGhxOmld2fA0lnSl9Q==,type:str] +ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:RTam6NE6fk+JqGAXGQWPjP2Jbpo=,iv:ERj+XS1aFKyvkQPqhx7sHQ0OWnH2AGbmNeRVcQD2LEU=,tag:xo238x8lKohBMlJ0tcsjJQ==,type:str] +ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:5GlGQSQTDFNRzVLGLS9BJu2Ruto=,iv:hmqho9W9YPHS5vt1cXYHlWgPbeiiwegU/UgysWLQ3+c=,tag:k3FQjDX1B790H6UZrfEGjg==,type:str] +#ENC[AES256_GCM,data:I4Y0NB5nBWqk0uP11HJ3ukEgLREOTeHnIXT5hpswDSFnW9kswN0lQOXv8MPJwY34SbQtCofNtElKoLwWiAa+mYb0,iv:LIWY7xM7wdtXwa03kjQ5/LNI3WUYSxf3RV5A+k8PFjA=,tag:7tn7wtatxWEmaPnNIdW6uw==,type:comment] +AM_REDSHIFT_PATH=ENC[AES256_GCM,data:eahavUKcqOQa0nPtnivHtouXHNKb6vtBsgUDMLijXsCE0NYE7ahAthVJIrPV0LZX6bhWnkH8dx+544dkqlk=,iv:pAUo/lbFZPz8zYOi3rr57b7hV08gdX4uLR6Xa9turNE=,tag:E45wIlC4efxRyhS6mNvCZw==,type:str] +AM_REDSHIFT_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:vCQvTHv11W+FHnBSuNYnXC2X4a0=,iv:yU8+u2zUTGL7OvbhTOh+zu50lF3vTAWLI4gfUnfmFJs=,tag:JNx2eZoZINA1HVdoz+NoBg==,type:str] +AM_REDSHIFT_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:dvI42r5b40764Fh+gOG4bDH2eu6Xr26a90J3Lb7/SJ2y+ZkCpRHrqg==,iv:tvmm8iYxB+vZiLnpKlRD3Qm+SH14QAgb0DHivZqJlLw=,tag:SceqyLEh8ZsH+QUjq9rpRQ==,type:str] +AM_REDSHIFT_BACKUP_ROLE_ARN=ENC[AES256_GCM,data:eE+iA3ittB3rTuqK7iTqfyYRwqBQwI57FalpfGLs/7FmAS5Svjasyb+SDoOwlKPW47km,iv:uvTwseedM4mp1LuCwrxCec/70j2pawBYnXNBkDirqB8=,tag:jBF78lq7W1JmLfcnDOs05g==,type:str] +#ENC[AES256_GCM,data:FdHGZ3K7ttg+LFwpFtXS1kO/kzHbyDen/hHHnv/Vwkj+jxCm/MLtSIYxqXmFvwz9PRhUsJ6Z7tan/i7t+FYOOV5fLWukFXouagSgS3bd6b4pwoJmTZ/+VATe51rHRozSoLso+ENDiSOsa6Gy1XOmTqpbnH7dD2mk,iv:tfydsikDo/w5flFFVklHxHi3kdEF8FSqaXZbqdaGB6o=,tag:N+XmrtiLczViY54/5EJPgA==,type:comment] +#ENC[AES256_GCM,data:m3WuSq4FHoxysr9Hwg2xG7M3T9LkEECKCU681ZW2UNakNJGxO/EkFVUrLHda0TkHpfk=,iv:+NbhdWyrt0+MmxqKkaLG/UfKw3zRN1x5etY1DdDYJy0=,tag:GTb8mAeld+JYRjXG1tBmlQ==,type:comment] +#ENC[AES256_GCM,data:Ck/gD9yyE+kFRPr/UaNFc6tkQhlDHaWQLBUKfUu9GyrQ79m2m3+ocYqoHWuK,iv:hEW5z/O4Jx8p1qEPc74OIu2jndewM+EsJiSRA6Mb0kI=,tag:fe6FAdWof4TFbGkawGMtkQ==,type:comment] +#ENC[AES256_GCM,data:fDIloni9j/Us4hlp4nym8z/BXnv97bg=,iv:igNXXHWOD4O/XhfQ6Cqne9+nEdXaUnoW4iWE+u5ZFDA=,tag:98nphQO1aekZ6RUHIrz4BQ==,type:comment] +#ENC[AES256_GCM,data:iSCUzm/DQmUSiOTzmngrvaQDRjgpuXc=,iv:ztWODv1XvaaJpp8qbpoMsQTnSPAMf5puy9W9OteQUzs=,tag:XiL8IcaJloP/pjLlu9m3dg==,type:comment] +#ENC[AES256_GCM,data:OVPMqHkgIllcfeWtLlk+TPO3gtZD7IvaJhocHdeQWL/erRjuy8RquhYB8sslAz0ThpQigNXfAImmqYvP2t7sUW1TxTkCDpd3hf1USg==,iv:u6vpOBrk3ZFt4tdA3Wm6U1kyGo87yQol6L+Ic+hapG0=,tag:u+M5VBGemFY4+8BfIYPhUw==,type:comment] +#ENC[AES256_GCM,data:LyxPA/evrqGSkDKYqcOiY7Eyk4c=,iv:6TnDb59JYuZ5Ub76a7szP0HUE4ygykLaiMy0+SCQeHA=,tag:rVRS+cJwsElRP1dj6eza/A==,type:comment] +#ENC[AES256_GCM,data:qfTLGKwRtuBs1938pkXMYGBD7KToiBI=,iv:IIQ8+gQ1Mc9hEEnu8HfmgnC4fUTQt+vWupTLmZxMRQs=,tag:4G40YVwwE8Vbc412wmwVrw==,type:comment] +#ENC[AES256_GCM,data:SpNaFFmLmDPot4HCMqVm+g==,iv:+6nrJ55jVtytl5rvs7hdDT7BeEJuU1LvTv6Ib1Lw9lw=,tag:totaTEe9UqBSNMGUbNwWnA==,type:comment] +sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBWZ3BwNmNsdzlNdHdoT09H\nUEU4MzFLdkZuSjRtMVovUWR1ZWVuTkE2ZEVVCjMzN2Yyb3I0SG5Gb2JwSkhWOHpD\nUmpwRTZGMm1mb0dPd1pHOG40WHFoakUKLS0tIGtIM2lEc3FIdStWRnlORnFoZjZF\nWnAyUkcyUXBJT0dHQnZmSHV5Sk5XcGcK1sFA+lN+9sMlOHvaICwgUkz7apDV2iz7\nU5JtiA4Ir7kRKcPSdWA4kDT8i1Aku/FmcYGnfwhgDdjYwv6c6JH3+Q==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_0__map_recipient=age1wravpjmed26772xfjhawmnsnc4933htapg6y5xseqml0jdv8z9hqemzhcr -sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBRaHdSd2I0bHZzcVFxL0NX\nME9uQSswQld5dlNwT1pUZ2ZrMjdwVUdhaW1FCm1Tc1U4WjhrV3VDV2JhSUd0dTRC\ncUZtSEdrOER5WGVMemZtOFNZOUNCYWcKLS0tIGZhbTZ6cUNldFZZV3JsaVR4Snc4\nUnJwd1g3Y2VSNGZxL0ZqMHU3eEVTVEEKp+3xmaZLtXkgbTrhvHZlZAwpWkMQbUyT\nTQrcqt0WL7F7Ksqn6Wm0jTilZjqOnallS6CUMfv6Z5mKDASUbATEeA==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBSNEZmTnMzZmFzYjdTZHdZ\nUkFpKzdkcXptUUcxVTM5UGU5bzQyaWU1aWwwCnJ0T2pIaXdhdHVKOXo1U2N4T0Jz\nY2s2N3F3YXZWaE8vQWdtNFExVCtjYVkKLS0tIHJRaDYyenBuQzZXOTNlTHVHWEFK\nUGRWQk9GS21UcFY2Q1puazJjZUF1eU0KrGQFNJuD3If9M9OSHOSYr0KtxyitUtSr\nnK/26kJryX8IwHF2Op63vG2Q3QRpEmeqdPsJXDW2Ymjtgn98mlP8LA==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_1__map_recipient=age1vhftscteyrwphx0jpp0yl60xxrjs77jq05mkzv88js7ckc926vcqepp2cj -sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBWRTVxaUczTjdPcE5ycC9a\nenA3akRqZUlUbmVXMGdaVStpNXJtWWVNekFJCk9FRVYxVVRDNksrVHhSRHlYeXY1\naDd1aUszK1d4ek9BOXNPWTYxVTdyNncKLS0tIEdZdXlqMGpKMzJIUVY4UnEyTVlz\neW50bDVIOWVTL3FPa0NVTGxQdFkrcncKvdHfbFstSsf36JfvyulJU2qu61Mhym9w\nJmnM3yLocP88wiDQe7rH+aYqjBAMwe5ZbDH0Q2wti8N6b4jANk2lOA==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBVY0VuVEFMd2VReE13OG5w\nZk1NUTd2WlJvNXowK3N0VlFiMWVFRHE1UmpRCjA3SGdNc0RRc3Jsd0ZxbnlweUlr\nc09tRHB2cTBYMldKM2V3RStWMGtoYXcKLS0tIC9QbzFNenJ6Z0RuRWh5QmtuOEwx\nSlZkRk5raVZFaXNVam5YM0ZRYWJlR0kKZOre1ihuzxREsbQsnrgIk96WtbXryaCp\nHXDa93qkOczd1cHBXsorJAM8m+K+KTURfNERdMirkL8wQQkEqnTmNg==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_2__map_recipient=age1slx6e48k7fre0ddyu7dtm2wwcqaywn5ke2mkngym3rpazxwvvuyq9qjknk -sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAvSlZXQkRsaGJRL0tiaGRV\ndzdvWE5ldzBVQ052eE9tSm16VlY1WXZpZEhjClVZUzlkODEwckpwanVwemVLSGZB\nOE5UWnNRQmYzMWNqdUEzeXlKZENmbU0KLS0tIGFVYTdwZENlOHR6YVEwUFZGOFRm\nbDBwMkhDSGlaa0Fvcy84V3BJYnVkV2sKlgb2jbnJBQR528cA9ugyKMADCLY09DAA\nXB66iHSF5B99eXcwYT5ZAh7CJDUdRR+laacRmizqRrrw1JnUJKNETQ==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBrblN3bWE2Tm9qYURFUjdr\nNUFaMzNIK2pBelZLTHhkb20wbld5T3dnOXowClRuRDZ0T2UrNmJadHArRlRCL283\nc2tvOHhSQitVQVM2aFh2ajNaRXR0aFEKLS0tIGtDZTFaSDQ2NkdiQmxMQ09qcnpD\naXU2b05hOHVPRExjazJ6WmJ5dHVESHMKnRiAXA5KgH0SyMSKpiI8rYDvxpLtT6ET\nqt7+OtI4mIaDxLo8SulfG6HNsSyTD404jE6f2H4eh2B+nqjAcBw2Dw==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_3__map_recipient=age1vfhyk6wmt993dezz5wjf6n3ynkd6xptv2dr0qdl6kmttev9lh5dsfjfs3h -sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA0cnQzby9Jb29RWnNRNEN4\nVVI0TlhVY0xzNnYwU1ZXZWxPMDVEQmNhOWpRCkRjY0FRWG9tVFlWVnB5eG1XUzBj\nclBiZWprNjNIRjhTQjNscU84TW9tOWcKLS0tIC9zNkIvZHhRY254OXo0UUZWZ2Rr\nVzBZUndrcWZOTWNWNFpaRSt3azF2SXMKdbu3KNQjjUSny3c+5Ev/YK06oZFZ1msA\nl0wGdb96nUO7XWiLGVoPPzH4V4dVqXwfQ2IKQIZyw2GPiCjzhsZbEA==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB4YU11dHdGWnNCV1gzNjNN\nQk5MSGNWWHl1UHh4MHJJdis3bmZ2U0ExWkd3Cmhtc1dkNDNkYjJxUldpaG5HVXdZ\nOXJEVWo5VU9KRDZ6VS9FVUFiMjMyZ3MKLS0tIDM4UHlFdTlZd2s2a1B5VnVkdUdQ\nbkhBVjRGSTM0cmNtVStnNFpvZHQrdmMKDyN3R4nqMh2ZhSvUE0k1xA+yaL5//n+A\ntkYRNlyO4xRXgVEeXViTAF777dL4OpzanriHmrm2Ch3PlITdoazfRQ==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_4__map_recipient=age1jwuvzyghfer7rx3qtqa4vs0gxyff0sg6pqgmqvp5zlhnpmvrkdlsdha4kx -sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBpZ2E1LzdlbTk4T1Uxejc0\nOGRxMmhQdkpkNTFDenQzNVBQcjBsQmxLM0JZCnBXSWZPN3E2Zm8zUjhVTy9kZmNJ\nZURVUG12Ky8vWlo2dnVOcENGOTF6RDQKLS0tIE9ubFJCQ1NJdmtUT05lMVhnTUNW\na05FRS9ERVUwY1pWN0ZIczNpQXpkV2sKUZb/B5cG3pE6UhMWkx9lniIS1WD0YWb9\nib4XX3i/fuzSgUNs77WLAuPxGnza8wwaLHiHqocmQELB3209mh/3Iw==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB0WVVpZEJwTU5YNDY5ZVlM\nOTdpTHBVV3phVEtUM1FZRnNseVJSeGhkMGlVCkJCdm1mclJXVWdkYXJZNjQ0cmFz\nZVhDZWtqcWJsRGJJTDRwd2t2aCtQdk0KLS0tIE5OY2pkMlhSUm1JQlZueUo2RVVC\nZ09qb1FpdFB0aGxuL0ZEUCtObG9jMDQK4wpPvZqTVyFKhq3HPfHUNYINqrEz/gb+\nyvdnOl6tPtVyZAtA/GI/Eo4vQpI4hjolSfg5xek/YPJMvG8D7biIJg==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_5__map_recipient=age1pgxk292zq30wafwg03gge7hu5dlu3h7yfldp2y8kqekfaljjky7s752uwy -sops_lastmodified=2026-04-15T11:54:37Z -sops_mac=ENC[AES256_GCM,data:3zcz9KHiPVdlUQHUR5Zo35gmOqlNip2ikMyWYXBIOeratPQGFXyDU+B3XgjQBj3qV6wkL80c/WK4REwIyBiWBGA+DJhQa5hHV5wXTHbJTu3r8jS0AWwQC5OMDlkmE3p+pmRVeqsBzP9ipEZuDl94k1ytanne3LhzdnbQWRoRHFk=,iv:822ZrCsj8DUXww+eW/1QfKqZVk51jmW50nmWROPxAys=,tag:cqzNZSTy1FADhq9cVRCrvw==,type:str] +sops_lastmodified=2026-04-15T17:44:45Z +sops_mac=ENC[AES256_GCM,data:8UX3QxAzvT5p6VXaA0DrwnnZTcy5tKVSGNtKtLglinFPdJatvELiBgZTNtO/L/9cJ0woF1qSd91DQvCWR8NiFwu7Sh88CNPXOy/hkD3C5nEAioupM/+DJ5ftcdo/Sh6b4ZxcQT/306Wjnxmep46ujCTFgUNr7FX0HqImp0OuuQo=,iv:2zx5iVrrDZ1IFDxDwrCaGLX4UIDY500X8SQtYfpB3bk=,tag:o9/cvukVmaffgzI4NQYcBg==,type:str] sops_unencrypted_suffix=_unencrypted sops_version=3.11.0 diff --git a/infra/.env.enc b/infra/.env.enc index 9e53724d5d..5a6c86d907 100644 --- a/infra/.env.enc +++ b/infra/.env.enc @@ -1,68 +1,68 @@ -AES_ENCRYPTION_KEY=ENC[AES256_GCM,data:431p9FKVHj9HXtfL9XtXcFbqP7uGjMb2Hr48LsrtRHJhSWcKvtmACoNR+Q84cHhuKC02bNUU+BBpf7iaHoT/FQ==,iv:Y1dOI0dtyRs+233QjsycfqMMVEpQtZmBKXzTRrL64Sc=,tag:IdAy3SCBvf3g4ihaIvWrnA==,type:str] -ALTCHA_HMAC_KEY=ENC[AES256_GCM,data:CNkyyTDXG6SaeVpMFVOa2agEruzjuZp+BqmEbhbAEQh6K85Dz24mUZuab9ERpwv5vI8gjMo+jJ/13AnoPkRC97/SiJg7zmv3mSaMl7TPF9RdhqR7jdluq5g8MLyQG+Q+BwUknokwx4vBL7h3B9LfEJ9FaVFe2xSHz7pZ6jEMFzE=,iv:SXAnupGPoby92fkufccUztxDdm6exKSeoOlJQPJf4GE=,tag:kUB/YxCv3l5x07Lt4IIM1g==,type:str] -AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:MXn0aizg1guV1YxhEEeVi3zja0U=,iv:Orr5GftuFPIk28eAEl08ts4JFJRmGwaCdZSieu340tA=,tag:5Q9X9TRXf69tNqSrLqheSA==,type:str] -AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:jXuJmzddYVMWANuNOGxqFSf6yCs=,iv:u/BGdaFUWnUNw9LuQen+ZsLQob/e7q3CKPIBV50pX/s=,tag:RSTPqjwDEW5DvcIIfcXEEQ==,type:str] -AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:6K0QzGO2sfjy05TZezghs5pqEcaXz/6haBn3xhhJVkuoYBv7zmGjDw==,iv:gsi7Cj3bCgyWWh1wfCP0pVZDE2EiEVWiNrgliAF7GYo=,tag:gR/N1pE2/k6KLpZsTe0WbQ==,type:str] -AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:tUBfZCKYyIqejb23SYpfAkGxWXsdELG5sskIstvUm5FegsTp0YG0Hw==,iv:6+ilIStcQplW/sxuZSKOkifPwKTydz6cTBWJ2Q1UQ6Q=,tag:Lr8ArOQ17ZKNvJP7ryooFg==,type:str] -BACKUPS_SECRET=ENC[AES256_GCM,data:ZPzChj/SOyprrzPnc8dgVUe9U1nTNEtkNlp6tGKIpDCCLOGpkpFlcQX3y30=,iv:iIw2Oz/N7xWVgtPvOkRCqRSYw5OhJJWHP58pwG7idg8=,tag:ZdOBfHAOUxz95aWrEx7Now==,type:str] -BLOCKLIST_IP_ADDRESSES=ENC[AES256_GCM,data:1AbpYGISJIHCVrYJEFrfW2rws6Cr,iv:RjJfLYyDRuYZMgE2m3BW0Kuhhl88lRmiUz5eizOKJHM=,tag:8UHCdyvPDPydaoEIIa5Kdg==,type:str] -CLOUDFLARE_ANALYTICS_API_TOKEN=ENC[AES256_GCM,data:e0L7dhHjViYfv7uVH1jnHMLU554JwyQwhwIrQHytB295FmZ1WlVJWWCAPDsvMBfLiRKDStQ=,iv:ougbBAQX1x9NKAWJOyo+ZuvL5iElPpanKUi+cdESwkI=,tag:ID1IGv/4uQMqyKLY8fVN/A==,type:str] -CLOUDFLARE_ZONE_TAG=ENC[AES256_GCM,data:NZFYr+TdEn7dzGuy0TieGTMcCD3mAwbb0sv7zvAEytU=,iv:AnCtiiWIcomqcg5LBbhjuXAF+NaFpZYKyDQ/1TAi2xI=,tag:SbDnlzriQJmAmxsfWEeigg==,type:str] -DATABASE_URL=ENC[AES256_GCM,data:wGG2OWgbYVpO04tQSRD5i/m141cYpc/DTC1zmdqbqdD3kS1POuNG8oB1x2M=,iv:81q6YZp1RiGoZR+ohpDcH65Ky4nh0ggFx9aLZoAFOto=,tag:mX5geiQpP9p6akVnLmbZHw==,type:str] -DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:sOA+A5VBT55/OAI3huDuvpbKmCM93SKBywFDFok=,iv:O7zRH6cFogiwF256XtcKwOnQ6qpxk+TQCT5r4EHQzeI=,tag:AlcpqSF5F+iZgBVXOJ506Q==,type:str] -DOI_LOGIN_ID=ENC[AES256_GCM,data:MXh011Vu,iv:R3EBbFBvvtVEncxWvoNoDqPxuDn3nf117qq/aOuBt+E=,tag:z72INtQU0jkn+lPjM4i6mw==,type:str] -DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:J57aBuV218k5GG0E72P6pDTBlUM=,iv:3XPudZzsdbQvlGHv2XhzDEecTcmtD5B/gm5VgjM0Fh0=,tag:eLKVqH7JH7wDbIx7KdfTRw==,type:str] -DOI_SUBMISSION_URL=ENC[AES256_GCM,data:SzKXlk2ZrvYQBsaolUk5qOaDfwc4Oi0jJreTADycQy0rkOs9/7CUTw==,iv:H+SdgUuNjEQBlu1x+WpOP8Pj623A6KTc5e24S6mQC1A=,tag:qAMGPPByed6KQIMP8Wv4yQ==,type:str] -FASTLY_PURGE_TOKEN=ENC[AES256_GCM,data:zfHZYrxx60SnrRPzaiN2jS3UUsW0odn0uZwi0DuiwB0=,iv:yDBiYQA7HIZvJti9q3GRPGPU9HKOC5gKpx5dS25l9y8=,tag:EuC9fi3Kvpj2nlqX9wG/0w==,type:str] -FASTLY_SERVICE_ID=ENC[AES256_GCM,data:G/nwPMDjDwkOulpA/T1juFHEPlzLiQ==,iv:RyoHRCBQsFVOmteUbSeHDrCEXBD6+xoHYVUqd+tg4WI=,tag:qv7l1pc/vaz/61j8PwHisw==,type:str] -FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data:LEae65JWTCe799RSdCmVDll9sBJN6yBHEBdISPDm9ayAbFr3ozH5xyVrGlr1f/fTq6T8Wo5AM1rmz201rff4OSs7shm/edP7PdvMQDKAF0oMkpd6/sutQwJ1f3/NdbWwVEEeedDf0fhVyxzZ69t9yK02N7xQ/coI8X81B04Oj2tpn32YzObwT1WRoA18gdFbcaIBNvyhpT02evb4P2mC4kYjptM70PkesbFJT5JUManWeSkOwDSge15lep/v0+xdUM56Ef9USq0h1sr+EOTymlop5US4UgGMg+kZVPCtLTOF8S0yIGLwJ/rVY9Xk34Si9pnhNaM5JP34xvFcAMNlBL6cJZ2oo6+6DOqGQkCYdw7MYYl7NtHwQJeUdKDWk6Br2s9dHfIjQts8HD7fnHKB32pU1lBK+pgF4tRugTBGuDeiMEjoL/Bp6GiMOngsPYDX3ka6F66O8Syv256jyyETS7f5S29QCGbBp7vN6dwmVA1Z8440rFI8BZdC9v5IpO/rrfxI5Jy8id1aBpBIOVkK6dM/rBC3uZe2GLSg6TgUnvE72GJmfFXwRT4N59lCbEC3rVVJQeYxPBgK/BqPOcXCyygUjOOJUqD/Jp+pRaHi+teTNeHids3bX1sqybHPHqBBla6flbOZgNI3+4Tj3A4X0hmNk7GwH/Tlp0MguiPIeGP3vdTbM6yq+1aPO5S/M2Yqw6S/ty7N/0gTiQ6++NzV4rY3dawQ6iJoWndqYzeF8m8DWXEtL1zRBMyeUfNt2gKon/TrP46oy4SNRnre/1C/PbN8eYcYKteZEbwkM7OiDPvlAFla83cSUw8SguJINKQlOgvzfD8lAqz7PZq0AtKfWDYzfBIOa0z9mBnlnZDCXx8KySS17G65SbazNKdxEv6tIR0NYR0S+KXgJ7QgF1hGrSRh9TPl+ruAnTchxlQHHJ6NhEQvCz3SeDQEV8OEb/igm1ZsLGYKHVdAxllbjUABt5GUFOUJ3IguMzgD93yH9uHJQ1LFey7q7AkSzh1w03bL/D5mJPvTeWYZKdKlgwKYX5XONMFjWU0Zb12otf9bhC9V6YALoS/TUPK1DGJ0kTj3lFXsd7y7Kc+p8MBQ8NLxxSyQTth7oiBHhzubknAwWwY08eQPvXMwOWLiK0TNopaWCqdV+I4ZeTgaianYOVysHOnFyzGIkv7w7FaLJ9n6FxpNpthFvX3fQIO3FcB6N99kMbNo8egutLcHvqZMsEvDhaMS79hZa+kLZFLuu0Snx2f2Y+N9WDVFzzQc48eZ/fV5hM51HcxhVGMaVMhUWFEBjgF4QEmOHoUFhfYowE09O7CMMwW5yktjZmcdA+YmeaSVPUczOforx4SiCVnXm7YvVrUXfmDMpBG1+Cl/dDmmBR2GKR6RFYWdbnGWbBUBmcgEG6S6/pbSrBxK4fu5JfNC4zKXrN/7/Ga2K4AiBOAXeHeHhUY0wQTkE0SOIEZUzEOrrm5yRNWl8HjTainzzT0Jtgo997t/ToE/YwHsMyBsrE2xbKe8ipZeurifNpa7GX+oM/D/EmmUh3W651TLMIUPjeT0mnqlOKOB6c/Wn/0bAuxCV2YOlXmdP7nHRk2sGU4bi/zBRB3zlDdr3iKei3+cqFfCX0z1ZAF1tYRlSnRhu3Ebjjs+6SWV2JfDRscKCGC1W6f9ngZnZsRLc6dA5zuBb3IwE54akUCekbppYN3Mf16elk7L8AvkbHTznvPiLwYW7hS4HD3Jr+J406Cuk777SYbLIBvgUu/S3OgLXnWltrezpcNPrTdlwtvwVSmCiA5PiEuHVqG/hpXZFHM2K0tzDx20l3S29mJZe+pqZcKHfCujUukMjyeT6ahzrx2FqVlLjkMi0xAu/4BlXqpZnGdUCIcgjgFdmPMqtaKthOi1mIGGR4JGww9fEh32BOxXvsSf9c2i9rUfXCuKmBAosn031Gex2Jo6tRrCt9E4WwreSaC9NexgmQ+stNAT9y/OCmgyii6MBqJqsHDYEIO4AbTSS3oE1kYN4Y/aCfELHBH8t0Aqd8uImQA4zDzETTn5Hirz/JN2yCkL3T0eVHB3FNpAn5Yacp3pQOyQn2rHS+yy5e+lhA45UFfmrAfsPj6SaqhLqFg1dwFRBXIaazrwair9lZmR6y3YcBRBzKDYDI6FavaX+9sBzIduBI+HRqdmT/vcSMesVfkJzkTzDDnJRg/JgIGD/j0JPY9GHw5jQ/vi35FT6MGgEVD20SfFIfapdwTE9Ir5iFzWCVGa8FgKfF02gsnG8EgX2efpnFwTo/vpdqslC84IzeksxpKATJ6n0DFrGr96ID1CfBxrnv9f0gImxrzMGMdiGJyVKTIAH5QQbYROYRULqwXcajQj2YhqW+/NrN6nu+0eBztMFvtc9lqm+JAk2eOfxulQDF0GR6QQd+SS9f8kxvNSt8qZuAOPOH0qRSm5nn/xaG/qU1SjsBU2gHwqoUoRWRV5ZROE/IWow+oc/Gz/9Ukgxcxu0WulLbhz2PNjetDnT11ztyKqMoinImeR6S0KHnzDWRfDzPuEUE7WYlmLd644YSBzMx6OslcA+8NEs5ExhoBywKpjFQtD6O/I/jHzohpEz4IK28iaGEY5YY4tk6W63ns78NZudvqrGweSLeKYDv6N1oBiyNU/4tPM+Cw5t2jNCWD2sbyidu4M+98E6xcDuFCQn5htkAKSrUzlWvCtB5ttEXUOtyaVk4u6ihxqJB4uCIQ/YcwrPCi9QSZAM3M1jXGWjuoqPd/SV2yw5QKXb+HmxuWLPvXAHOYXbP1yujOYQak4btxslsEi1t0zwMnHLhwXOsCpNMVVxTviI9K5cfZxj3RQCvJGcLd8XYyUIDcLeKrFm1Ttpa4BL8+G8eNGCVBsvEdZRdZGExUBsTFI/MsOTlkzF7LnsODDmp4mS0LjgPFpTS8ecQp5lvLU6Bqpfjn1D9ye86/xMOdobb5BUiB+m4BlI42qPqa6EJQaSY3qLNizlIyjx5UUrdWY5tGz6s0VU4dmeg8foNrEPLI7TLWSSiqofki4EUpG4gEW+x2TocgtSErCzCs6IA/oxCfC4tgy0Wc5PO3zjkjrQAjREnH1rjLZsuq2K8Odn9ekXI6MpNOsHrZg7JCD1nYG1LQaGoohaPGwTEKUeR3rQvy8hhAqHVwyQ2sIyZV7hjB88KVmZXN+kZcIQuNRONXXPwrZWrGJQKbjPYxiyvpgdCMpJfiNLDmJoE9yokQAApn+IESYPu8bxGkMJ2I6C33/CwI8xIzuWqeS+YOtvQwMVs06ryqu90XWZkJ+r9USImVl/CPJspOggQMBjnkIqSqEW/8rdPBRy83dGCMlstcxWSyA+kX6NSMetTOk9lTE2Z/FStVMlpQQXlYjUuto7gJRSo8Ay2DjwBcbIFHCSdLi5RvJ5v0khlja6jzECYt4LnK5hD2wrN/hoBuhtqjk737nyKJm+RMrBhQguj0C5B+Enng5cN7IhrzHa4jSNyRB8vOdaliiCkmqf9OvpG3MePR65FJ+LeLuCNAPeAvgN1ihiWKcmzYBciHCR9LfyunWDnqx1uEkwxB5KE/P0RhJfj5FwXFZyyJ/H+85jy8TG+VP2X5fI9jpmxsK4hjDelcEjUnaFVMYNgfMqOtUGvoac3BxgcSFipWI7amZ6WW6F5lQSUUsEKHa2yJU+CWRVL9qVdggTOxej3hWtf94KO4j9TJmgQSE6FdUW5/ZHN4RzxtIAnMhemokDcD7L8EoHK4blBkn6k0i1JqzC6AjCj/q+nOXiKQuw14FpHUIBFMtBJ2zVYAbdAj/7qYnDAVg/9T3GmgU0Rudy/1xH0XkoJY6t4Pb+LT5IskUdF9OhIXgh+AK/vmwuScUx+LMLFB2pqcXTIUJDi/gD5yzH3VjRQhE3GVeiiC0w57M6mtkwSzBnmkdd4w3hTBzAe53GJN5VmCt1UQw5nFEgR4zsap/c2ZArKREGq1AtI3U/JfbsBMwmDYmqENNj7cisUiYMR2ENvdRUcxkU92K75bKO8BSf0M2itcE1iTZIF+imb6Pn14H8kafUNHdmgiz24T3Uc1flUYU5nfA6Z+qAl7AaGiDDitqnpTQn0LIzavz8pJ1lAIImzYEbrN31zrJWt4krr06zFIhLZ+kaa+Kr/zvoUZCq7o=,iv:NgzR8+Go5NfZwBnmpszZRhTa0yyS9q9zsIiiGFROE9A=,tag:5y3N+BnYdnEM+w8yK7HmXg==,type:str] -JWT_SIGNING_SECRET=ENC[AES256_GCM,data:3qMg7i5Pxh2dugNqE8iih7cqs3g5ezsvlS/vriW3V3jbz/NJXxbbYs69OfwdX2DKE6tjqekLr1C5vSpIEUs3vajBCgVyqNnn0/V9IbHb2FTCpiwDTmfc7smpYxMQaZP24Ayc8JBeucHG15aFdCtLhG6NANep0mn0cg5m2zWX9VaX0hTFilgQcgLKp+TqLRWVmvQcw1giyt9SuNW9ePyR1HwfzxVjI+XOxfBKDGh15plmxL1DASTzGRq0BgPa0N8zEHfOAiYIPIyJWy4q8PWOlzi1y+EhB+XyyCCT8yFKic2zmT59z21+5gTM/Fgq1sjJkmkeFJtRQXNlKIFkDMZVhG108XxsDsCRuRKXTKTbYAPS9rT5Lc67q39B5WnGnMrvVU1PI9S74n0dg+MT8ubW0GgFn08vFV9+M4meC/KbERkchZgVdqSW7nIwW2kgasSdN+m7bVZ5kii+75THMizpwz6eIRQsGQFvmQjLmADu3N/LPTXlJYzyfId5nXfh2CbwuU5G520vhH6d//h2a8c1DYCoCczgkMxDiI4TAODzN6Hg5qN8ktPkMZvW6vvlOx7yLYYu/elWtNJyWm3hc6/8XP+yKlGZeVInwaY6xPasmiZEKjmhZWHmcH+jcp4uud2fpAJTl4ke+UZhB1QnFD7GeIeRblDBP4+LZrHJdAVCFrk=,iv:XNFECC9mefq2bxw8wA7zh6jBZVKnyjEC/nYZGIiKGj8=,tag:cz+2JSEcZWCZj9juogLhzQ==,type:str] -MAILCHIMP_API_KEY=ENC[AES256_GCM,data:xLEiBDWHqJ+5dFVqMqWzaQNspfZl/S25veVGAJ+5P8F671Ih,iv:0khXz5swnSu9gtwtq89puGbaC/GCZl1XzzrC0+2l38E=,tag:Cmyeh1Rad1qS/esgA8aUJg==,type:str] -METABASE_SECRET_KEY=ENC[AES256_GCM,data:zIJVcNpb9SbUxwo40b4E5rP9bKJqdh9yIq1eL6kKcaHbtRQgjZkIXdSBvO7Tb68xEdGN4Zw8yuNIoP8p0nO/wg==,iv:MkQoWO8Y8W4JEQORkXBntPH6p9VJU2ScUdQVFDVOw30=,tag:JrBJOoDtuMU7AlAJhdGOeA==,type:str] -NEW_ACCOUNT_LINK_COMMENT_WINDOW_MINUTES=ENC[AES256_GCM,data:mPY=,iv:tV5DxEtdkbACdAPYRdQKNDzaUoVrQQ3enY9k8CU8MdQ=,tag:9FBVwCs5wBhviRAw0P1NBA==,type:str] -NODE_ENV=ENC[AES256_GCM,data:7fjTkbuFCrhWyw==,iv:X0O5L4OuBLMdFJkM2zA0PoopEEQ7Gwijn57oIe9f/3Y=,tag:YjctUatObLN0nyFidCHt6A==,type:str] -PUBPUB_PRODUCTION=ENC[AES256_GCM,data:htTNng==,iv:kMwwbm0a7b8+RY2USmgvCSRk40byyzy67RxpcpMs6Bs=,tag:903Edk1p6dWK4ByNjcg9Nw==,type:str] -S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:5nvQkV0+g2skNv6MNFetGyAJXko=,iv:a8EUoQHcXmNyj2XTh/uk2WNdvuqPZjFOKHhCtGf4CVI=,tag:v9ct0jum03zXO3JYpe0ctA==,type:str] -S3_BACKUP_BUCKET=ENC[AES256_GCM,data:Pah7z8SPqfNxCRQ=,iv:WPLZY98qaLhYAK191N3gBGsh777TRgPQnxZdfFQU3YY=,tag:kkLwJpNdSpH2solCIOpPfg==,type:str] -S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:Sy5/Y8P0epA4Ym+z7oxfzA8z3RT+bm9zFcN6r+08Mxzo708=,iv:+iO5pQpJ4yyFe1q74K/7nGaiqEsZym8Fx6jpX5cQzN0=,tag:RIX7fDXvNUY37W1mG++DKg==,type:str] -S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:kUVyG8XqWwVw3iaAwbcH4c9NwXJaQTBjjtl4Bci0KypC1T1ukbeRPA==,iv:lssD/Cbdce8ebgLhusYzcyr03e7HHBIMeNNbqnqnsgo=,tag:8exckCODgqBnACUJES7YiA==,type:str] -SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:4VTwgEbp3z1Vl3QWaRLzLr0HLExoFmXp3hl71NJRIy0uuchnVhpdWi+n9Os3zC0GbcSLslmryFpWaqs7ETFAGeNHQ3hPMfe8TXJ3tA+Gkibo9PBBzYab4tNpzj8NnhqXHL01oES7gxOEoQWunAI4r22qFtfbMqEtwaVS4DJ1xDgkufjeO2iX1sIpYbjbgWKrgYO9zuV0Lltg/aHTnYVkPA2MmwyMTuEC9K/Aa4pHW7WIkYH97ld5G3GIaQ==,iv:pUIOQIOcINUP5sonRCWB1UkbnPY1J5GWiZe8tQ0c2js=,tag:7hjatvx2A+NZArXu+cZB+Q==,type:str] -SENTRY_ORG=ENC[AES256_GCM,data:Rm5v,iv:OODksoL5VjVknO/5sI7p7vX6hMgqCmQj7Igq0T3lQlM=,tag:BfUyJhoa24+hIu7gP59g3g==,type:str] -SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:bVY=,iv:awqJIfnUc14qh9x30ZFNdBupTbUmgnVYt364G6Mjp7g=,tag:n6aa5aNOETJGXG2FK9ab4Q==,type:str] -SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:JK9Wlmf5pjhBsshNEFWM4mBduIxM5rVppkSS+h1dZaqqC56tYwCtWkHSMzc/2wi0T88D35YwdwjZojfIOuTrgR4ibiGew59KF/Zt6FRz6w==,iv:QfU+Hr/zrRC+lImxiXYnbgpOyl21x5xtoF3ooxGvil4=,tag:dkIIhEmRHZBS3Oo/YdzBIQ==,type:str] -SMTP_HOST=ENC[AES256_GCM,data:gsegbSLOeNLUdOC0YF7arCGtCbJqqIpM4PVP2cN+zeallw==,iv:r6Ay1sZ/XVOfg8QH3F1DsitpVHOgL5A4yaei4+qikLg=,tag:saD2PMrNfawFqvARziOyRQ==,type:str] -SMTP_PASS=ENC[AES256_GCM,data:kGIOlwcNsBbQLeOYleeATNnNd/AI5UfwYQucZm8u6NtuEvtertIXuhDeR6I=,iv:wp19dQFjy/CQ/yeGWcXSIjtPo+cJjwnS6Gblr/2mVKw=,tag:JvqeDwdoVTUXpaBwALDYRQ==,type:str] -SMTP_USER=ENC[AES256_GCM,data:t22GOtnblIOZtgI7hgZ3org+R70=,iv:5WfqqPYZ5l43jpdFJf9YrJ+5Mx1dBdOvOY2gP3DEPGU=,tag:uWjrHMHyFzMtK6XSljEYKQ==,type:str] -STITCH_WEBHOOK_URL=ENC[AES256_GCM,data:DBY1eHPg5PIVESYce23uPvXIvrcPaloVA2m0lzlhSoAqd02ho3v3pZMXziLzVAQkhKWjIPStUqYTuy/MKfHmbqoYxrou3xoBrdBoXhxuwvvlrY1PYYQtOrFQ7amn1rzG+mxAGN37ZIVbyXeKJGoUVnpz3VIL,iv:VTxZrg4McB7HRjtCsqLWIluotc2m9KspLMCFhEGJPQQ=,tag:XWW9iR5r1I6Pp9fY/q5opA==,type:str] -ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:jwgDxqo5nPhenWj+IlR7DGVSRuc=,iv:N53hnInRjvGXHFOb6+k1vmGySMy5j2r8HKGcAIFfd9o=,tag:6yucg/LMlmsGg3LCpb2LvA==,type:str] -ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:WHJ/bUulIHsZO03jkssj0LnArDY=,iv:1fHaJWEnquaBr4ZcxRFlh+QTmJWaJ5AK9R/UfYdwB8Y=,tag:DWbXdlMZig1VK4ApE2iceg==,type:str] -#ENC[AES256_GCM,data:JneTC1rP9TPJdq47qizO0Bn9ayRsipaYq82rU/oqg3Bu3ACdyyu6p+CuG7iN0MCV9W7pxArBiXIZeznvUp/MfZQF,iv:e/qRcA5Rnm74PNAXt9I4RfK2VdEPJnwZOjJGoFZv43A=,tag:yO4gxyRM+2gVD3z51jCDDA==,type:comment] -AM_REDSHIFT_PATH=ENC[AES256_GCM,data:oT5VnekKZ0ZXRMaaxmMKT+4pFLHZJ1UiCgCY93ly67xZTUyG9w3o0HTf4geoyN3xHpOxAXysseznuIe8fXI=,iv:FTKkGBwfA0iHaUQ+SjHh9XyLKHE4HTsGiR3hAx+/0AA=,tag:j/Iq2iecDAiiFYnX+OoCYw==,type:str] -AM_REDSHIFT_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:4f6pmHRz6Ik1GO41ILzXTBJ2rlM=,iv:5oH4QIkkDj0ucWM3+pvXuARQFZKVk3TYPTiOC5DZOl0=,tag:0hQ9oaSUNIObTT6F+qs5dg==,type:str] -AM_REDSHIFT_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:O9l89Iyf9DBu7RfCnh7/SkUzYTWhjbL6q+Lv07MKQ02NjITKp/63Ag==,iv:cA2NOKJpa2ycXxjsY19JefI0e8KmywT02Ra6jAlMl7s=,tag:Jdd6ZDIVaInz7OQ53kR8sg==,type:str] -AM_REDSHIFT_BACKUP_ROLE_ARN=ENC[AES256_GCM,data:dXpdb+HG7SCXKBhkrvGEQxL8Xqvb+diCJVx/y+CEs9Xn2UjG3NaL+NJDxOvAny1B8ZRv,iv:gDYSAlb1GwfXj/SCNXnGvcPrPt/aR63Scq3AMiGBzN8=,tag:Vak3zND8Zx6SNzderm9J2g==,type:str] -#ENC[AES256_GCM,data:7YhHy9WAvqpx8/82BD1Oelh8kkiLHvAwmWtHJm+g44k1VL4mYbVNlwRK5JGCfRNeezy+P/ldGbkNlJPWniTAdCDGUfnBdfoHS8CqpX8WH/nzOi7GevWGacKDzsb0ZK3DyapFZ3/7hTn9NYvCaubl4ck+vCRc9dSo,iv:nSppbrrCYm+/b03rfQjq0rQiyf1wwr8+8/pFHuDkF58=,tag:orU8qx84fBLwltyUtVkEdQ==,type:comment] -#ENC[AES256_GCM,data:ifUpWhWl1Hi9+qn0Ui8HHaKhVrX7yGXWEAewUOBvpfF0kC23QD+hzInmcjYwMDDmt7A=,iv:Otyh+mJpdWaYAtNnrlz5lUoEhJIcca+6CRUk4yN4X5w=,tag:ixxCe1VyEu/DvFrb3KafoQ==,type:comment] -#ENC[AES256_GCM,data:uCevHHgDtwusiI3jACHIY+keiaUm0eJjm78fURCHaI8QdPfskzNlOLwJ+xJK,iv:AzQ/piPHVLvFFMkGwmzUy/T3FbmfA67jABd7cRdHVx8=,tag:Q3MtW5y9Abq/7M/skgStvw==,type:comment] -#ENC[AES256_GCM,data:mWzrA7h2b1SiK/Q6CFv8aBBoQnT53xQ=,iv:b0YJWwLaX9TieaofcfDcTwDUmFaYINtf3PiHzBI19nc=,tag:bvK5Hs2kKgZVH8KydswBcQ==,type:comment] -#ENC[AES256_GCM,data:5DeYmwtMnokrhD7mbidyMfdm+eH1mss=,iv:JyMgU5M55DvzThfMX+UT1ZBYjy0v4iDJ3mTMOYVhucA=,tag:H6Jasz0o3rNqrt03QA8aSg==,type:comment] -#ENC[AES256_GCM,data:FsTaakHdVpmn/c7ARtJ08UPyFrC0UDYPq5GQP3a5PFTQgJOToxoXJN1ChSVZNIO+2NYrH7VB+6Jz6mryBkcFAFRQR2b6BlGVg0jGQw==,iv:VQ1GqebTSteITkyuUdRg6tMSHKrnLt1kqDvtTfAstfU=,tag:pETLrqU4+uUylmeDwltfkA==,type:comment] -#ENC[AES256_GCM,data:VFHoprx0qTsLZmnoJ7oAs7j9suY=,iv:tKMlMhU9pCnBvsxbem1NGO5Cen1xUo7qYOl5xI/YV7I=,tag:OnMU43W/5DLHLEnY+113sg==,type:comment] -#ENC[AES256_GCM,data:t13b2204M8eAsterXKZNO1a+PcGKRtU=,iv:iGWxQcKKcq3oRgf5i6n608k/sLpYmdnOxAMbI3UF/BM=,tag:zymVi2xyYiQGwtTlFTy2og==,type:comment] -#ENC[AES256_GCM,data:ozUzMLjOft4eyhegbVAnOA==,iv:BvvxsXsFt0/8cBF2ze7/aGZzfVgiUYIaVmGj73zvv/Y=,tag:H0utVy2W4hdandTGKQhhzw==,type:comment] -sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBiZUQzUmZsazhSOWo2TThO\nQ0d6bWNLVEpsM2xiU2lHNzdJY3pySEd6MWt3ClNhSDM2OTRNQ3Niay9HWE80MFB5\nc3VoazI4NGFaOG5kbk9aeFZ3MkErNHMKLS0tIDY3RkdKSm1oaUJUOVpDUWhUcURz\nWDdlRUF5SlV1QUZsME51VmxyOTMwRW8K8GMhW+r08oPKzWenAcTlCv9c4UUWa/sI\nH2pWnH+kymSI6tM0gYDzQSEzmDuK1CjYh33ETtC0uW2dMBb9oBdCNw==\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:C2EjF2juD6+zRI5Hzb1D79XYfy6ElSUyVpo5Ee/vNxqoDnMaeXKEL9zGo4XA/+hmWsFCL6+m0dNli6FqMlEyxeaiwO11bZCaPZfDM6IFQEuNOCA44uNJSw/vpvaBGQL5rqF1/ivRIwhoU3UVt+4KFNcJeZk9WMD7SyLln6JoVTsJZCofjHaPJVLwb5T0p12zuESJ0UM5EJhOd/APy3diG7nrhQnNTmyTI/6ow6HWZM7vgY6JsvmVYernsoy/oPiv6JDR6hVqc1GMOApmEXeNnt09QfIFCl8CHDbfVTouxey1t9vZn3ZvuIEUXcTegLOulehRG2Jl9LEUwuqJ3vI8jus9hRAYJGC+8Ikcans7RnMz7zMk1NzVtoX6+EoOqP5mc75DZVR/0qPJw9BQY2/amj27NsQareePYNTtDiZwd8Ys83mZxGTiwAusKC55LlYAh5tjK93RtgQuesYwLX2lOA0eSH7ZFbRMd5kEXYG/Z9uZyB4tUkmI2G3tO0oXW3GJzJoP3Jz5S/T57n9eRq/j/yEEpjcWc5KChv+0EO9FzvcpV5LhvR+nJr21yZ3zPNGGV4+wx117eL2cjZzmn7pzDA/8RgPMAAG0W18czth3Z+d4NgrUK+sgYucFOqCuwL/QBtBPqRVgvMkIx7tq6en0+XClf6uyyayjBLtE1wsyFMkhpBIm2ja9MgO/5zHKCqte94j2x9IbQ8fVfBDvUt/CP+F6m7+EatEAgn91IqqtuRQXlZcp34L0Z1tajqUTATEwAwfRbf66JJyvbKvAVBwYN1b8oWmf73ZsO4s1Ikbts3NNInHCTGMiKqvLZqfHEx7dYKXtDDxkQeni3bfMjlAhwq3sr89xymrQdDwLjZqSv0A4NRz6nFPGYbt15Fi4vihUtvQ/52+cWnlMqDpVfDPb0svEAxOWTw/MDSjFwQ310cOS1fMwMlU1jP08KEfaohLYkXjQJhQWYZ3JAmbhaA3Xo2ww4zRUbsplG3eOYCT3qzqw4oHsj/MK1wRUQh1j83J7J2qFpw21BNOnsb9euCnwzoGMXa68WJygX8qmv2R1zNOty68afB3W6UcUPENlU8h+n0Lns8OXbN/0utGAWioWn64is5BEOlgwbGUDAiVxznjRgzYLKf2ylRxasLfI+OAKvIBlQPjYmimKTaQ+XKt1MOGA1qEOkRAz9UkYqJZyzRLhaGcSL/neqoDRd2OhxKK8i06x2JWcOtliv9pyIUkrzJ7ZPss1SQGA5MU1Mx8+oid15pvTWTx5Tml0H6Re3y9K9YKLzppD9x0Ip43ChkaeTPG6dmHg9Rn0uP+Pc+WFd+tw/ZxvU8jQ7yDhqC5+RaocK6o6qRr1kzRHRrvcDvCXopap1MQSurJlr/vT+uRLMtDdxxqdFNRsDbzMfgeGjFPO2StRXdQGSu5zRXRGECC4YjZzSEdGNiEWvU8CsPFJORWEgPEZarlFqCWwWER0hlIiy4c1TDjtwPNCYIiD+PFlmrJS2Q/gZUFmEkJ+RJc0B5DTHRMplJ5E1Dw4A9Sc+HxTvVC0RqUBBG00OT6Sn80nQx66m14KCRMvT1+rC5YfLLLM90l+vjr5L8HZOZXJJXHMo6MeeKle1LSJi0hsw/zN6qT0LUuSjEopxivoivo9uPuH/4bn626GfP6Av+qgND1zGtK+vS1LVqAGhjM5upGVH5OH2nP57O118JOOLwkswgvaKjGJ3EUHuCH8JdayWlTMR3hYHlxfHbL4WwxgkEwqFLY4OTU27QC9yJYOVurLctSrgI8cim/6d4QLFttqDz4wz5B9MmfUOfpJasiLSmWQDzw6XRsdN96JKx12NTDczlO7rk66G42zI7hG3lLcgiVogp5rDLJ+z8AaTpWW1ozBj3Zf7HHHSYygqj4XRP4o0sTW6gJjX2DW7cg7YfKhrFQ5/sCJdOeDnfdAiDlHuVSGOGbzkl5UYB2YcrJq5fsxADG91VP2N8MahAnLQiLq1BP0QJiqvcH5bOy+rFejcZQVrHuFQCh85nmJk29v10pXA8vte4AMBQDJYvZjFO93iDSli1uJb+0fSNTUmB+GT+JJmvPIKVr+WxSmcUqaKdHC8842Jj0rGrFlfPyNxLqgHYRi6c5c11HHE4gISe7vbKc74XVmR1Eal31QOd97zCFNGVb+bVfJ1tbBR6v3cnmiX8Og2TMPU415XWV58i3DNw9VHOJKJLGs/8hew97R8q7f6e1REKMuhlT1hi6n5+0+aL/lLEh5mdYrc3of+fklKhO5w7jWCDvRcSU6SLN8Qew1vKRm0zVRX78et84uK9HKIPDV/W77i0ImM2yeLRjzzACmL7/vqk6wJImfy/VQDs4i3/DXvdrHc4ypu6kZeCSn54Riwgy3oZdxBYH0Ayhuf5f05hvtA+EY7Btd3Ekx/e+WZdNHHB6lx5PNiNQ+xhPPEDrTFIfZg7u9uGvzvtk9VlBmKfnwZErLgRvdANcR53seFzoWo0/XbjZt/kq8M59f9FoKloHlC5Wzqit7JmMK2xTENDckwmrrri5W7GvoDlpZ2HqK5OPjkNcinJIwWtAlp3EtXOjf1Ir7RuWRfIeNyB9cyM1aC1h2nMJ8qrUCSnkSfHN7VPw0DI+sVcGHZ8753yo4W41Va56URjtvPeowobPgEsBlzJ6f2pdw0WtaBCeEgI+R3oISvX53EgGVf1uiACyVZqDbVbvH5RZzHUe2dxIOBGSwuT4M+GJ2lCziQUzHNIAu0f+PhnnApi/kpPDel2u+oELIGjMuXa8S7AWdaCPFLqDE8GsDstjsREfIEyw0jUUUbY+rG1mmELcfIGIlt2LUhpqNQLm+nYsHsK57yLQE0CmQxjGMj9gTmwKg/nLSoej+OshzsYIzt85blJ8KVHbsR8XIV8nANxk+UtnaHTUkWu2mnzkFrUKjS1GXb+GN1Tash7y9PE873J3TNSRiY6h/T4yB5TI99LzwHaZ3d3aYFusAXw5K6zgME99TOloKGXGhBtXuUjT2ShMRGauZu5GDUAwP2FemJ8xdDGpAeZaiLSmkP1RhUYA/1vzax3mAnb+YU1z30xVSCOkkdHA4Iw/8eMaL8tuBrJRxs15RiW0ZDvxRI2/PVoNrlf78s+R2j2adbdph0ySZfFzQVgtGYm5BgU5os4DWp1bPWrrgCVb96UKvY/lhXXccTnkCCYc1u5UIThqH8ji2mLaCnZxPjZTurkSBKGl8hsdZDTMTsP/6Jq8cg09V4s++r+ZAcKJcPWhBudZA5Xx4x4pFtcLCX1QQb1jpyVPbmFGypF2W2VRfY7vxcHQaix4FGlGAoSdrIlEzlxBSzPqheByTBCwFubQWy4zLWIvbQ1nKdzk9DWarcw5GUH4UrG2mrMyPOz+esfidwe91Zlu/U1ZU3/MqxzqYYiOB30jbu/tjRPZlY5oMqwpYdK3u0WRTrfjJkYGumX5frbbNTobZzEtSZ8Sarlz7l5mrcxD77sguqL3RCUtseYCjT/QvHOvDigZmIHFYzV/JLQNApuPEG1E0hI8mF9AYW7coMg1njcK79jJ9+323uv2NFtLy+tISByGpVhkPjYIOZtgPDPrhOWzsIGJrcKVAITM6h/4+KvBwG8Ir6ku3DwXPDkOgzAXaRZqecAKtA5GB6iuI0j2+cg0dCIuWFQEvKj49+iLPldQLPwt5JINOeai73QdL+9IHtDoGhfpimvE/PigM1qk0+nt/2B7HjqThIzoC8TWu386yO1dqK9JpDkHLkGy0qk9nTO4+2nPrvL2x+v3Ex7B1aq6mpnFkYQXbJJ4K/af9/V8BfWTSdQSD9GnURc7TG4bHVO679I7DQ4X5F6apZ4iO29Ywn62zDWTHuEkZ0RKxrRhxMa7F/TpTwpGlHq2UlFo7TmOJ1occrNnVzqTv2Tf/dgpaw3itWW9MKTdUJn98DDKKttTwY8hMvx1LlGytWRzYjr/s/qa64WIbdyQ2Ui/kjoHDx/SyoudxL/btR0FY1zNWHqYrVAZs11c2D0xz9uPgL77ccbMGzrZ7WFG72bTMYKd2DFuA1Kj/NfvrvKKuveYC9qsTZWUWwv0mQZCgjfaSWaGfRxmabsECrJYWWBDZFG4VnUgs6srAQMugdw/C1CmL5oJ9/35GaKF2yr668eNAAY5CHdMCRWw=,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+IFgyNTUxOSB3cWhrTWUrYVJLZHBZK0NP\nZGZmOUtkek5QN05jaklqQXhaZkJQNlU0dEVVCldyT0pEVFFNcGxOeUhSclU0MTd1\nZ25Yc3FjSzJXazU0Mi9BK2dPM2Z4YlEKLS0tIFEyWEZvT1RKODFDdjhoeEdrK3JU\nSXJnNjlVWkVWSDV2bmxrTDN6ZGxXSWsKlpjqKzJ9tfqZSYzwdNok47rKZuxZ4HyC\nU3vlpOPGb98UbjcLNhrMmycvq9HkGEDR92rzq1JIbdN1W25YNbPRdw==\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+IFgyNTUxOSBtQjlvUUtBcWYxeVAyWGhX\nMXY2empiU0hZMG1XWHU5ZmRvZzFQUGt3OFJzCm5SSUFKNFNjcStUaGVRUWhKVjRB\nUU1UR2RGNHdPMHNzcmJ5ellFRitUSVUKLS0tIE50RHR3SmpFTmFMRVY1cUd1bEFW\nejZjRVNWd3BuU05BZSt2dWdYdENBZE0KvVhD4K+4MamTm1fd1sqG84xhqtnm/oO2\nUypC8o/oAuPi7qDUcVezdy2+zo7yl61/a0hNwdAZn19sJ+b9CtVejQ==\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+IFgyNTUxOSBHV29zWUVkZnZ1RlBkeWV6\nM1RNeDdDWTZ6MWdIeHFKT1I3WjNGRWZRL1NzCjJyeWN1dFhNaU5hejhxSU9ZZ0d1\nSEIwWk1tVUY0QldiZXlqdGRsQ0tCOEUKLS0tIG9SK3JPM0RqNUZDZVQ2ZXBWV2Vl\nQVBnMlZydk5nek9mdlR6NElOVXRSRXcKwqoQxXrvwnv/YAsMywEESet8fhsjAMun\nV/PzJeW27I7YpoVfdvv/L1eGB1R/7+B3rw5zPncxWiZsHkx1pQQNfw==\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+IFgyNTUxOSBVSWVjQlpqMVZqK3VyOS9F\nZmo3Ti9JbmJYMk5VNVJUM1AyN21mUVd4NERrCit3N0E4d3g3cW5iNDVBOTRJdW5z\nNGpMTHI3SFZNUFdDb3VrL2pyWkN2QjgKLS0tIGxMNnltWisrVVRjZ0ZrL3d4Y1A1\nNG45N0VXTTNrRUEvekpvWUh3cXNNNEUKbW2FODSbvTEb/Y8Dl3uNhRzYGexQlVIU\nA6uEUmOapFpW9Z0kc0WJ/qX08icW7XAVvH4k5uGzhKuJ6V0qjilDiQ==\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+IFgyNTUxOSByMzlaaE9iYmVtWTNFc1ha\nSFh0bi9EZCtlSHJKcUtpbHF4dERPc0lJeHlJCkhNOXVzR1dmNWZ0bmRsWDJlYkxT\nbU0yR1RQQ1Q4RUVNTkRMb25hYUdjTVUKLS0tIGsxN1FsdUNkejR2d3Z2YVpJdEYr\nTXBmN3JIU1BIdHhYUWY4NUhwdFZKWmMKf4Ebo0ymHcHwSIhxQQx5erIy0D7KHZrJ\nw/ZnnM71IRSa843hbCppDcflvjXJ44WjKUJZ15ShNtZ4iLNI3xSbsQ==\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-15T11:54:43Z -sops_mac=ENC[AES256_GCM,data:x2PyUDBewcCOQ4+NnTNArVYDnRvtSasD1YW/zup5JoZS9ssCIzbdJaIGvWo+TetbMbdw6ZT5ravjnWqTjn8mqEqNocZeF8h/0OFIbpe6ptRoFTzNXmRP9MGdAHzhoCiVtaJ8J1aKNoyK66LMs4+wIC3AB4A8jYx1Y8BC5iycGxI=,iv:8fr4hGbHF0s+66olwDNHI0qJNyDDORCqaXL/z9m3dWA=,tag:SE9B7gEEheqM+Kj5zxmNEQ==,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 From fca3d2fa7db47997354c4a590092bce371252c86 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Wed, 15 Apr 2026 14:54:31 -0400 Subject: [PATCH 38/41] Fix some missing stack. Improve export and links --- .../DashboardImpact/DashboardImpact.tsx | 54 ++++++-- infra/.env.dev.enc | 118 +++++++++--------- infra/stack.yml | 4 + server/analytics/impactApi.ts | 12 +- 4 files changed, 115 insertions(+), 73 deletions(-) diff --git a/client/containers/DashboardImpact/DashboardImpact.tsx b/client/containers/DashboardImpact/DashboardImpact.tsx index 962b8f7cb4..e85d3c268e 100644 --- a/client/containers/DashboardImpact/DashboardImpact.tsx +++ b/client/containers/DashboardImpact/DashboardImpact.tsx @@ -25,8 +25,8 @@ 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; pubId: string; views: number; downloads: number }; -type TopCollectionRow = { collectionTitle: string; collectionId: 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; @@ -404,8 +404,14 @@ const DashboardImpact = () => { if (!data) return; downloadCsv( 'top-pubs.csv', - ['Title', 'Pub ID', 'Views', 'Downloads'], - data.topPubs.map((p) => [p.pubTitle, p.pubId, String(p.views), String(p.downloads)]), + ['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), + ]), ); }; @@ -413,8 +419,12 @@ const DashboardImpact = () => { if (!data) return; downloadCsv( 'top-pages.csv', - ['Page Title', 'Path', 'Pageviews'], - data.topPages.map((p) => [p.pageTitle || p.path || '(home)', p.path, String(p.count)]), + ['Page Title', 'URL', 'Pageviews'], + data.topPages.map((p) => [ + p.pageTitle || p.path || '(home)', + p.path || '/', + String(p.count), + ]), ); }; @@ -422,8 +432,13 @@ const DashboardImpact = () => { if (!data) return; downloadCsv( 'top-collections.csv', - ['Collection Title', 'Collection ID', 'Pageviews'], - data.topCollections.map((c) => [c.collectionTitle, c.collectionId, String(c.count)]), + ['Collection Title', 'URL', 'Collection ID', 'Pageviews'], + data.topCollections.map((c) => [ + c.collectionTitle, + c.collectionSlug ? `/${c.collectionSlug}` : '', + c.collectionId, + String(c.count), + ]), ); }; @@ -620,7 +635,17 @@ const DashboardImpact = () => { onExport={exportTopPubs} rows={data.topPubs} columns={[ - { key: 'pubTitle', label: 'Title', flex: true }, + { + key: 'pubTitle', + label: 'Title', + flex: true, + render: (v: string, row: TopPubRow) => + row.pubSlug ? ( +
{v} + ) : ( + v + ), + }, { key: 'downloads', label: 'Downloads', @@ -686,7 +711,16 @@ const DashboardImpact = () => { onExport={exportTopCollections} rows={data.topCollections} columns={[ - { key: 'collectionTitle', label: 'Collection' }, + { + key: 'collectionTitle', + label: 'Collection', + render: (v: string, row: TopCollectionRow) => + row.collectionSlug ? ( + {v} + ) : ( + v + ), + }, { key: 'count', label: 'Views', diff --git a/infra/.env.dev.enc b/infra/.env.dev.enc index c74e4c8ffe..09cd8af1c6 100644 --- a/infra/.env.dev.enc +++ b/infra/.env.dev.enc @@ -1,67 +1,67 @@ -AES_ENCRYPTION_KEY=ENC[AES256_GCM,data:43HyDvwqQuaLllzrp+7NATXC5CIFP4M/Dblp1U2R560p6DfmGHFFLHo7v6U1ZXi4tLBFotW9XG3nxnuSUQjyFw==,iv:rDAP/GnDDOxgHmnXlUkxYEjIQkFqDKx4SwjqO1N8ADg=,tag:cdqiTqj71vpfH5egvyoYLQ==,type:str] -ALTCHA_HMAC_KEY=ENC[AES256_GCM,data:BbVdOTNuFPsrpnH3r7YYDGBi2CN3TNtJtitq8Z4zZHmUyG6qynFUyXIVKp9xXNzhHPOkLTSrrWpltRJK67FmeA==,iv:S/avgJHRawBwqHoy6SOcMsfUWX2Ay5ogwCv7Pwsk/Ew=,tag:c5VJryjLzF3hgjAyr5DEaQ==,type:str] -AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:D98ebD3FUYdgW5YthIHBfp9N/x4=,iv:MakDoLCdY1tOmRsyi341UdBypmLKETd7uJam2JVS2kM=,tag:vwwXKu1EczeAC/9jIt2mQA==,type:str] -AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:Fm9iewyV443QFAmR+4b1AvfMh4o=,iv:0p9ocIq88+Kg45qF7Y5uZZrIR7G92MzAKeRO8yhJL20=,tag:5PvrEGiFxkhGuxweC80qPQ==,type:str] -AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:7jbJfROwEc+DrEJEBbaauZ/qOdA2Smtiw6I02sHijmYufBCG7q2jkw==,iv:GWeqmo9vhf3eyfzYh54MyBMcJmT8vY0E6trhVYZe8/Q=,tag:BqI33qlbr3AjulvVO7NRiw==,type:str] -AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:HBmtBweCuX5ARIWiOQr5Co1M1dlz5gQBLpo8p9Nd2sv6iGL+MjS6lA==,iv:+jujMqsYuKWPhTWVAT5NjOaNNJuP0ikdt9914mBUQT8=,tag:MV4pV9qmiOt9HGY1oxOyeA==,type:str] -BACKUPS_SECRET=ENC[AES256_GCM,data:ZwtkurhCBYr2ymqOGsZ3dm0/faWwBr4FLKN59ftJeRF3DjivO6grEyF0SGg=,iv:JZtuiczd+tP3j6Lkxlxgi8Oiqw06R79oaqSa5Nlqueo=,tag:v92pOC8DgL+j65/UaCrYaw==,type:str] -CLOUDFLARE_ANALYTICS_API_TOKEN=ENC[AES256_GCM,data:9LCrVhEusbY000lkNDKct8xTsC4zk+48U7PDZmFiCAj6qDrR1zSJT2IDH2LZjPm8kRFy9tw=,iv:xGmAQsoUgFyCzvEL7msksmKeQHSKtaOMftsvHiYZEWk=,tag:yTDRP9ttFBdyx12H5ShBJw==,type:str] -CLOUDFLARE_ZONE_TAG=ENC[AES256_GCM,data:lj4fsZtcT7ZtEU784HuLlwnj3Qanek1Debu6MNchtg4=,iv:M0W3V91BtCPAn01qd5iNnrusoAltEhunqT+pS0bS9JM=,tag:sMjVpO74eNkAfcl5quK/CQ==,type:str] -DATABASE_URL=ENC[AES256_GCM,data:gb9dBpM+CwPBDLiQnlORpAF5HRNHYxqRA0emM8c2rZeclMI1jbiNgq/bEHk=,iv:OnHpi79rFgsP+W+sVqQY64SN3GmcfsUvuaMsudwi5LE=,tag:nRz8396KVKMQdlMB8hAgSQ==,type:str] -DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:H1VsQAMhWz7DDwD2mYnAmhjijU/adCjZVDNOxBDDisjPyQ==,iv:NLECaf1JrkHejpZoauQ/TdMByGOOE8Ebw+DwAaOBkSs=,tag:3o9sX79TFNYpEZpeOqzRyg==,type:str] -DOI_LOGIN_ID=ENC[AES256_GCM,data:ePMOQdJv,iv:nm0x/eGbDFOXUVj6smmu9YNdTF3CLRWP4BMdikqj5Ok=,tag:cWpxhZtH1Dux6PyBXk2eBQ==,type:str] -DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:nJjItxHaIeBHPlLn2h81bGKhWhM=,iv:4cuBUXJe51hkfYaVC6y2ubFCzMQ+lZ1x7UVM+NQUwyE=,tag:jsHhfzpbuAWKsNV9mShZnQ==,type:str] -DOI_SUBMISSION_URL=ENC[AES256_GCM,data:sa5Sa5zHnSimXdA+R5WsVbx/3jWyZuAuCTS5VeEc2YdkWmVek1lWDL8=,iv:H2S4JUsP5GeOa33/CBtsjUpMcjjmAglE7pqLYZwaBn0=,tag:qm8a6sOKFxyvNlYrhgUl/g==,type:str] -FASTLY_PURGE_TOKEN=ENC[AES256_GCM,data:VWcFj/YfC1zCo9ErQy0xjuEY5H83cr5+bnEyvGSK4T8=,iv:9RPBuxFxeQnlshTEcEV/W1QHMLZjx3kwT/2kHfdsjJU=,tag:m5pNj+kyuYEvIQ7UY6K91w==,type:str] -FASTLY_SERVICE_ID=ENC[AES256_GCM,data:LGi4loLItqBh9wwASLEDP3Pfu7QAsA==,iv:tIyrKxu209tJv+CrN7Uyc/v09Q1qcrMCC+//ZSyzhMo=,tag:IR18oqC9nZfPGpIyRW8u7A==,type:str] -FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data: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,iv:zSlw+0fxMMdU4sT8k9DZonNEUzhmWXfSz1REDTOXQUo=,tag:8BH9oETlhVPgOQCkhCBRjg==,type:str] -IS_DUQDUQ=ENC[AES256_GCM,data:k1eMCg==,iv:qVVeRxPTYn2TJkahq2bqaOC9PYBtnegB9je/yDB7hGM=,tag:3a5G0TBJHU3hQ837+vXpfg==,type:str] -JWT_SIGNING_SECRET=ENC[AES256_GCM,data:usGqKYNjJYxPhqxX2C6LTAkRyTMnneUkH6gp3y2zSN9ao2Dyc4N2aeVOSJh57s7QTdALdrz617aI8EROXnbq9AThxToHVD//rkmGU9C3dA4AAZjkNuUrvhCKc5PVtPeDPEFFXh91pPnj2k/KifZAChDPWjEjQtX5XoytYER+rk9b8c3EvvrhNl/URzLN0ojQckYc4ze+HwPDVBl/8pFSZ2Khg8F+eOZjjOXX1rFVxpAgqYHXIOVY1KaPK3ZVtSSazoj/j/1xmjBz/g/gDQiIVNmS4TjK+nJyi7W1LtiWrQg4Sxs/K1uM2kR4eOfHUd3TScj9exZXhCI7Bw938SxB9OagdUBE8ouF9tV4dTdgpGWmM+PNdx2BXav6GyDXCM3IhjecSTrjRCC+33kghxwg0crzBvr8Vsfo0tYqDYgzt7n6WRYVmK4mSNUaDrwbDIqmWmC4NGaBD7RntfCMaop5gL3zQlElH7KenEUdaxjV7a3DG2E341eOTTsXBhF81qmo5LurNE/9grh4SpiuHYRAxMsh/j0uaBDdgHXC3vOWcea99e4BcQbWpjg/ckJpGdboDBaL+0aoGpdatf2CW053nNcHi65F5374DYtfCrlx5vo9WKZB+Gkw/B3pijqAbDqYxn5rTSN1A4rBNbTfm4ma6dl9wAQgUpxy4PuEIgvNTCOIuVHOMYxiBIVr3cdPB6cp+QTVNTpmKCxwEMezFCa+raQyU1oy95Bn0Q0zzg+Gasg1u71rZ9DJ1PpmR2F+45lHUfNFkXObD8/Kq3cRS2WYcS1K7idB6AciVcgzN0dU5zJr+1vTn4nXuIoa+isjGvxUA9sHccwIkHhJz96gHLqj7HJB7z/2/7S1yEtsqA1nExCKmKAYzaYWP15/LDIxbM8QQi2xa5RY4TrkI5vBtHV/n8rXQh1PgLnPqmn1NFdsdQ7lvWOLHzbdJ6Oo7glygHUCH1sh2GGYRbgq/BboaFa414gzvLw6O3SbgZxRRrYn3Q51ONron+xIHhwPtHl3G5zdd3zFh0ceIbd244vmXt2YPaj60vmHgBCN/VT6frO1oiPAPEvodAprShSp0HNm3VtrniimCVSiPw5sPQseOzsjaz2SpDrKeej8BtcCnmGX/fytOpkQ0QqHcaNlQMEWeqUblEME0p+GkLApYeuI95kvSBq3pZ0wqCRBsFndmUPGktzQiy4u0cIT+W+ra44Lm6iOQKqmSUjJWIhJz0aMVmQRPwe6aEa6Hgud3QD4WsDLhj+hM9d+h1TrIQPHjfKOw6yNARshYjv50zCO7aO0DJbnYSqFwtNkIIl3f4GMAKTrVupbntOpr3eCCRKoJ8u0v3VVmiUvI6w8k2ih+EtpJ7KIsA==,iv:YU9xwF+7ezFgwp65w3XIE+MHYOZlSZKtalGvAEsltr8=,tag:S8YinSVzgKrDGO6Z37MkSw==,type:str] -MAILCHIMP_API_KEY=ENC[AES256_GCM,data:RxskuppEvi4UKdjzs3ONZp6u3CYXZK9JITbNONPxpwUGKcaL,iv:LexR2P+CM9pqCM2GMUVA/Zd7GeKi6XlyKGk3LwJ7KVU=,tag:Z6SfqNaHVme99WgnspvXAQ==,type:str] -MAILGUN_API_KEY=ENC[AES256_GCM,data:FBEnpO7EJ3LRQM0dX68Ubas79XUfWDshybiBW6z9Lv779txf,iv:F++coAbmREu9yyiXIIRnDvWxnXbHyxgCeoiCEu7/xhc=,tag:Os7VIrNiUnB8DldYVVMPTw==,type:str] -METABASE_SECRET_KEY=ENC[AES256_GCM,data:d9exeSH1pFWWxjQ5sdpb7RiiOSB60+5OPaSEh4JtgUBJep0Y7x5kkqd2sBTF8Tm1tx17d2Gb/G2erEo72PXJBQ==,iv:1zaYLuMJBdOI8B7Wp2Gs6J341G897PSJendyvnoD86c=,tag:wMyO/aQ+yJpYkKBKxH20Qg==,type:str] -NODE_ENV=ENC[AES256_GCM,data:8weyvp9H5n/ijA==,iv:EcQBe+wb9RdjLHfaHpAMPzl7x0Gf5ttMvRF0nyxj6Tc=,tag:kAXGLlcA0H+wMWXPtVdIFQ==,type:str] -S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:NJagDGPoamhpMwS+UkbcHQkus5U=,iv:9rrfrrF7p4WxduhQO9H2jhcosMQPHtKjvA0bBZ3ZsFk=,tag:d6tPV4HMyEoZWUxMdvd/QA==,type:str] -S3_BACKUP_BUCKET=ENC[AES256_GCM,data:7+QI80whS2bxcTs=,iv:bp6yuwSRjZU0k5w3bfXOhGN+VIX/pjC8WtSy8XbuEqY=,tag:ESXmJ7ceeCYQk0LjbFgVnw==,type:str] -S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:okJuGkt9w2v0jeuldpMwTwwlRsqAxvvj++4TPkXeNb/7ZBY=,iv:x60rQeNdlpSouTXdFZZI+lnQIWGn+crUriAzFzgujo0=,tag:vEMWyKylIoQ9TEHHmnKhhg==,type:str] -S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:TXsjk4xH8zoTsJKa8TJfPmH1TL+9YFE3x9vgwbqTUOqukPCShYoerw==,iv:4pfYWoCeqgXpsUMxHs648ke2hojECjQ5i3uVHHSHlVg=,tag:dU+NfaHZL4Qk6DFHPEuDrQ==,type:str] -SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:QApYM3/l4okVOcen4hDwQNYKPaNSimyB7m73sUATPFp7TuCO6ZwQLS8tLRzL0X44evt05C06lN98KM8OP4YoJl5eC4TsNoGoZlVaLpb9wORfrqNrQHdf+LuzJgKW5lNO6T8ZL18cFJrUJPpuTAUXIQlQ3oJlQOfNFScSwuFNVIEwOG0Ur+PXg6rBTBh4JbhJ+dQl9VaqwWUrKS2LwCYKvyvj0Z9NIQccrr0TZA7tUkgwXed0jbHBB6ifoA==,iv:sqjtkFo5pCSfHGnoH1ik9r4JyrtljhtICyKAMuIXiJw=,tag:F6QZMZQBuqb319mGw6o25w==,type:str] -SENTRY_ORG=ENC[AES256_GCM,data:pvrg,iv:X03Pchi8sW5NUvEEEjLzkduo660D1h43NAzAiL/FLbg=,tag:G7+z7RC8LQ+RsLF2uZUgCQ==,type:str] -SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:H48=,iv:Kqnz5BMtnAKUqis20nUQV/3UR1r8lkC3F5Hh0tqrkxA=,tag:eQY1MyqiV0yguoww60ZWfw==,type:str] -SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:Z10Tj3ptzv8ZNxRO72OcduWvQvK6dPyRgH9dEwz/5e+/4HfctcmP8Kc9kAcF0E569rS1n8oG2jYV+NBx35Jvzn2KKgEDJkLTbHyj1TSL1A==,iv:Ab1McqM6nA+kcx2jrz8KM3PRay2V6XpfmM7/D+eix2Y=,tag:V+X9S5g43RMzAXotLNaFrw==,type:str] -SMTP_HOST=ENC[AES256_GCM,data:dqYWBrAY8gDJEn2oRr9iSoAuQCjAUZRLZRxDrkUpXSV0Tg==,iv:DNBGXoFYDgJKj/3IT0KU6fRda7tbbM0xEu2GMVBSees=,tag:SbwaftRZBbroTNPMIkT8vQ==,type:str] -SMTP_PASS=ENC[AES256_GCM,data:rBvXvd/MZXdeb61bn8HOW1qJN92G2D0vUpenp6HhzWyskOIrRgQwSyxTI3A=,iv:w/RGkTCkSq6UJ2k7xGNs9xiQn8EyoqBUu0xmtTFhm4U=,tag:zsOjlHN2ZzpbE2ivIk92JA==,type:str] -SMTP_USER=ENC[AES256_GCM,data:xveMBK4BZaWe/qLYderRKETjz24=,iv:EGKbC/jN6gFecDFLW0fYHvSMCcXNxqlqTIcTh9gRPCc=,tag:BHC80I5SWCln2cZKQse66A==,type:str] -STITCH_WEBHOOK_URL=ENC[AES256_GCM,data:/ua9PnJGERCo1i/ZQf7xFgGF/tbZdwFvanabUGOpwHu7m3Y5xJKO1YO8MdC5e5dbnZrNLOiZGnx6n04elkTjDuhxFkmSbShahcjpsDTJEawCwrOV7FkWcV/H3hAG6Oid+xoaK8IDCea+AlG+sgp2mYhfy5RO,iv:yMHyegHpkwTt735tKuXg7tUtZW9ndAdj8EXn8U8ZsyE=,tag:HPU1lGhxOmld2fA0lnSl9Q==,type:str] -ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:RTam6NE6fk+JqGAXGQWPjP2Jbpo=,iv:ERj+XS1aFKyvkQPqhx7sHQ0OWnH2AGbmNeRVcQD2LEU=,tag:xo238x8lKohBMlJ0tcsjJQ==,type:str] -ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:5GlGQSQTDFNRzVLGLS9BJu2Ruto=,iv:hmqho9W9YPHS5vt1cXYHlWgPbeiiwegU/UgysWLQ3+c=,tag:k3FQjDX1B790H6UZrfEGjg==,type:str] -#ENC[AES256_GCM,data:I4Y0NB5nBWqk0uP11HJ3ukEgLREOTeHnIXT5hpswDSFnW9kswN0lQOXv8MPJwY34SbQtCofNtElKoLwWiAa+mYb0,iv:LIWY7xM7wdtXwa03kjQ5/LNI3WUYSxf3RV5A+k8PFjA=,tag:7tn7wtatxWEmaPnNIdW6uw==,type:comment] -AM_REDSHIFT_PATH=ENC[AES256_GCM,data:eahavUKcqOQa0nPtnivHtouXHNKb6vtBsgUDMLijXsCE0NYE7ahAthVJIrPV0LZX6bhWnkH8dx+544dkqlk=,iv:pAUo/lbFZPz8zYOi3rr57b7hV08gdX4uLR6Xa9turNE=,tag:E45wIlC4efxRyhS6mNvCZw==,type:str] -AM_REDSHIFT_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:vCQvTHv11W+FHnBSuNYnXC2X4a0=,iv:yU8+u2zUTGL7OvbhTOh+zu50lF3vTAWLI4gfUnfmFJs=,tag:JNx2eZoZINA1HVdoz+NoBg==,type:str] -AM_REDSHIFT_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:dvI42r5b40764Fh+gOG4bDH2eu6Xr26a90J3Lb7/SJ2y+ZkCpRHrqg==,iv:tvmm8iYxB+vZiLnpKlRD3Qm+SH14QAgb0DHivZqJlLw=,tag:SceqyLEh8ZsH+QUjq9rpRQ==,type:str] -AM_REDSHIFT_BACKUP_ROLE_ARN=ENC[AES256_GCM,data:eE+iA3ittB3rTuqK7iTqfyYRwqBQwI57FalpfGLs/7FmAS5Svjasyb+SDoOwlKPW47km,iv:uvTwseedM4mp1LuCwrxCec/70j2pawBYnXNBkDirqB8=,tag:jBF78lq7W1JmLfcnDOs05g==,type:str] -#ENC[AES256_GCM,data:FdHGZ3K7ttg+LFwpFtXS1kO/kzHbyDen/hHHnv/Vwkj+jxCm/MLtSIYxqXmFvwz9PRhUsJ6Z7tan/i7t+FYOOV5fLWukFXouagSgS3bd6b4pwoJmTZ/+VATe51rHRozSoLso+ENDiSOsa6Gy1XOmTqpbnH7dD2mk,iv:tfydsikDo/w5flFFVklHxHi3kdEF8FSqaXZbqdaGB6o=,tag:N+XmrtiLczViY54/5EJPgA==,type:comment] -#ENC[AES256_GCM,data:m3WuSq4FHoxysr9Hwg2xG7M3T9LkEECKCU681ZW2UNakNJGxO/EkFVUrLHda0TkHpfk=,iv:+NbhdWyrt0+MmxqKkaLG/UfKw3zRN1x5etY1DdDYJy0=,tag:GTb8mAeld+JYRjXG1tBmlQ==,type:comment] -#ENC[AES256_GCM,data:Ck/gD9yyE+kFRPr/UaNFc6tkQhlDHaWQLBUKfUu9GyrQ79m2m3+ocYqoHWuK,iv:hEW5z/O4Jx8p1qEPc74OIu2jndewM+EsJiSRA6Mb0kI=,tag:fe6FAdWof4TFbGkawGMtkQ==,type:comment] -#ENC[AES256_GCM,data:fDIloni9j/Us4hlp4nym8z/BXnv97bg=,iv:igNXXHWOD4O/XhfQ6Cqne9+nEdXaUnoW4iWE+u5ZFDA=,tag:98nphQO1aekZ6RUHIrz4BQ==,type:comment] -#ENC[AES256_GCM,data:iSCUzm/DQmUSiOTzmngrvaQDRjgpuXc=,iv:ztWODv1XvaaJpp8qbpoMsQTnSPAMf5puy9W9OteQUzs=,tag:XiL8IcaJloP/pjLlu9m3dg==,type:comment] -#ENC[AES256_GCM,data:OVPMqHkgIllcfeWtLlk+TPO3gtZD7IvaJhocHdeQWL/erRjuy8RquhYB8sslAz0ThpQigNXfAImmqYvP2t7sUW1TxTkCDpd3hf1USg==,iv:u6vpOBrk3ZFt4tdA3Wm6U1kyGo87yQol6L+Ic+hapG0=,tag:u+M5VBGemFY4+8BfIYPhUw==,type:comment] -#ENC[AES256_GCM,data:LyxPA/evrqGSkDKYqcOiY7Eyk4c=,iv:6TnDb59JYuZ5Ub76a7szP0HUE4ygykLaiMy0+SCQeHA=,tag:rVRS+cJwsElRP1dj6eza/A==,type:comment] -#ENC[AES256_GCM,data:qfTLGKwRtuBs1938pkXMYGBD7KToiBI=,iv:IIQ8+gQ1Mc9hEEnu8HfmgnC4fUTQt+vWupTLmZxMRQs=,tag:4G40YVwwE8Vbc412wmwVrw==,type:comment] -#ENC[AES256_GCM,data:SpNaFFmLmDPot4HCMqVm+g==,iv:+6nrJ55jVtytl5rvs7hdDT7BeEJuU1LvTv6Ib1Lw9lw=,tag:totaTEe9UqBSNMGUbNwWnA==,type:comment] -sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBWZ3BwNmNsdzlNdHdoT09H\nUEU4MzFLdkZuSjRtMVovUWR1ZWVuTkE2ZEVVCjMzN2Yyb3I0SG5Gb2JwSkhWOHpD\nUmpwRTZGMm1mb0dPd1pHOG40WHFoakUKLS0tIGtIM2lEc3FIdStWRnlORnFoZjZF\nWnAyUkcyUXBJT0dHQnZmSHV5Sk5XcGcK1sFA+lN+9sMlOHvaICwgUkz7apDV2iz7\nU5JtiA4Ir7kRKcPSdWA4kDT8i1Aku/FmcYGnfwhgDdjYwv6c6JH3+Q==\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+IFgyNTUxOSBSNEZmTnMzZmFzYjdTZHdZ\nUkFpKzdkcXptUUcxVTM5UGU5bzQyaWU1aWwwCnJ0T2pIaXdhdHVKOXo1U2N4T0Jz\nY2s2N3F3YXZWaE8vQWdtNFExVCtjYVkKLS0tIHJRaDYyenBuQzZXOTNlTHVHWEFK\nUGRWQk9GS21UcFY2Q1puazJjZUF1eU0KrGQFNJuD3If9M9OSHOSYr0KtxyitUtSr\nnK/26kJryX8IwHF2Op63vG2Q3QRpEmeqdPsJXDW2Ymjtgn98mlP8LA==\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+IFgyNTUxOSBVY0VuVEFMd2VReE13OG5w\nZk1NUTd2WlJvNXowK3N0VlFiMWVFRHE1UmpRCjA3SGdNc0RRc3Jsd0ZxbnlweUlr\nc09tRHB2cTBYMldKM2V3RStWMGtoYXcKLS0tIC9QbzFNenJ6Z0RuRWh5QmtuOEwx\nSlZkRk5raVZFaXNVam5YM0ZRYWJlR0kKZOre1ihuzxREsbQsnrgIk96WtbXryaCp\nHXDa93qkOczd1cHBXsorJAM8m+K+KTURfNERdMirkL8wQQkEqnTmNg==\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+IFgyNTUxOSBrblN3bWE2Tm9qYURFUjdr\nNUFaMzNIK2pBelZLTHhkb20wbld5T3dnOXowClRuRDZ0T2UrNmJadHArRlRCL283\nc2tvOHhSQitVQVM2aFh2ajNaRXR0aFEKLS0tIGtDZTFaSDQ2NkdiQmxMQ09qcnpD\naXU2b05hOHVPRExjazJ6WmJ5dHVESHMKnRiAXA5KgH0SyMSKpiI8rYDvxpLtT6ET\nqt7+OtI4mIaDxLo8SulfG6HNsSyTD404jE6f2H4eh2B+nqjAcBw2Dw==\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+IFgyNTUxOSB4YU11dHdGWnNCV1gzNjNN\nQk5MSGNWWHl1UHh4MHJJdis3bmZ2U0ExWkd3Cmhtc1dkNDNkYjJxUldpaG5HVXdZ\nOXJEVWo5VU9KRDZ6VS9FVUFiMjMyZ3MKLS0tIDM4UHlFdTlZd2s2a1B5VnVkdUdQ\nbkhBVjRGSTM0cmNtVStnNFpvZHQrdmMKDyN3R4nqMh2ZhSvUE0k1xA+yaL5//n+A\ntkYRNlyO4xRXgVEeXViTAF777dL4OpzanriHmrm2Ch3PlITdoazfRQ==\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+IFgyNTUxOSB0WVVpZEJwTU5YNDY5ZVlM\nOTdpTHBVV3phVEtUM1FZRnNseVJSeGhkMGlVCkJCdm1mclJXVWdkYXJZNjQ0cmFz\nZVhDZWtqcWJsRGJJTDRwd2t2aCtQdk0KLS0tIE5OY2pkMlhSUm1JQlZueUo2RVVC\nZ09qb1FpdFB0aGxuL0ZEUCtObG9jMDQK4wpPvZqTVyFKhq3HPfHUNYINqrEz/gb+\nyvdnOl6tPtVyZAtA/GI/Eo4vQpI4hjolSfg5xek/YPJMvG8D7biIJg==\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-15T17:44:45Z -sops_mac=ENC[AES256_GCM,data:8UX3QxAzvT5p6VXaA0DrwnnZTcy5tKVSGNtKtLglinFPdJatvELiBgZTNtO/L/9cJ0woF1qSd91DQvCWR8NiFwu7Sh88CNPXOy/hkD3C5nEAioupM/+DJ5ftcdo/Sh6b4ZxcQT/306Wjnxmep46ujCTFgUNr7FX0HqImp0OuuQo=,iv:2zx5iVrrDZ1IFDxDwrCaGLX4UIDY500X8SQtYfpB3bk=,tag:o9/cvukVmaffgzI4NQYcBg==,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/stack.yml b/infra/stack.yml index e18286ae9c..7b168b2872 100644 --- a/infra/stack.yml +++ b/infra/stack.yml @@ -28,6 +28,8 @@ services: NODE_ENV: production PORT: '3000' APP_COMMIT: '${APP_COMMIT:-}' + # Reuse the existing env var name, but point it at the in-swarm broker: + CLOUDAMQP_URL: 'amqp://appuser:apppassword@rabbitmq:5672/appvhost' networks: [appnet] depends_on: [db] # note: ignored by swarm, fix later? healthcheck: @@ -60,6 +62,7 @@ services: environment: NODE_ENV: production APP_COMMIT: '${APP_COMMIT:-}' + CLOUDAMQP_URL: 'amqp://appuser:apppassword@rabbitmq:5672/appvhost' command: ['pnpm', 'run', 'workers-prod'] networks: [appnet] deploy: @@ -125,6 +128,7 @@ services: environment: NODE_ENV: production APP_COMMIT: '${APP_COMMIT:-}' + CLOUDAMQP_URL: 'amqp://appuser:apppassword@rabbitmq:5672/appvhost' command: ['pnpm', 'run', 'cron'] networks: [appnet] deploy: diff --git a/server/analytics/impactApi.ts b/server/analytics/impactApi.ts index cd9fd66040..8980e29bfc 100644 --- a/server/analytics/impactApi.ts +++ b/server/analytics/impactApi.ts @@ -55,8 +55,8 @@ 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; pubId: string; views: number; downloads: number }; -type TopCollectionRow = { collectionTitle: string; collectionId: 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 ────────────────────────────────────────────── @@ -156,6 +156,7 @@ async function fetchSummary(scope: Scope, startDate: string, endDate: string) { 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 @@ -182,12 +183,13 @@ async function fetchSummary(scope: Scope, startDate: string, endDate: string) { 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 + GROUP BY mv."collectionId", c.title, c.slug ORDER BY count DESC LIMIT 250`, { replacements, type: QueryTypes.SELECT }, ), @@ -315,6 +317,7 @@ async function fetchSummaryFromRaw(scope: Scope, startDate: string, endDate: str 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 @@ -342,12 +345,13 @@ async function fetchSummaryFromRaw(scope: Scope, startDate: string, endDate: str 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 + GROUP BY ae."collectionId", c.title, c.slug ORDER BY count DESC LIMIT 250`, { replacements: baseReplacements, type: QueryTypes.SELECT }, ), From d56b4aa2b358d901c56d850a7477a841076b19a2 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Wed, 15 Apr 2026 14:54:46 -0400 Subject: [PATCH 39/41] lint --- .../DashboardImpact/DashboardImpact.tsx | 21 ++++++++++++------- server/analytics/impactApi.ts | 15 +++++++++++-- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/client/containers/DashboardImpact/DashboardImpact.tsx b/client/containers/DashboardImpact/DashboardImpact.tsx index e85d3c268e..e4cd5786b0 100644 --- a/client/containers/DashboardImpact/DashboardImpact.tsx +++ b/client/containers/DashboardImpact/DashboardImpact.tsx @@ -25,8 +25,19 @@ 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 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; @@ -640,11 +651,7 @@ const DashboardImpact = () => { label: 'Title', flex: true, render: (v: string, row: TopPubRow) => - row.pubSlug ? ( - {v} - ) : ( - v - ), + row.pubSlug ? {v} : v, }, { key: 'downloads', diff --git a/server/analytics/impactApi.ts b/server/analytics/impactApi.ts index 8980e29bfc..b168717dad 100644 --- a/server/analytics/impactApi.ts +++ b/server/analytics/impactApi.ts @@ -55,8 +55,19 @@ 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 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 ────────────────────────────────────────────── From 05eaf65b171446f209ae028a07b4e36b9bdfc046 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Wed, 15 Apr 2026 14:57:57 -0400 Subject: [PATCH 40/41] fix tests --- server/analytics/__tests__/api.test.ts | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/server/analytics/__tests__/api.test.ts b/server/analytics/__tests__/api.test.ts index 694042132c..3c8e918362 100644 --- a/server/analytics/__tests__/api.test.ts +++ b/server/analytics/__tests__/api.test.ts @@ -125,7 +125,7 @@ describe('analytics', () => { expect(events).toHaveLength(1); expect(events[0].event).toBe('pub'); - expect(events[0].pubSlug).toBe('string'); + expect(events[0].pubId).toBe(payload.pubId); expect(events[0].release).toBe('draft'); }); @@ -139,7 +139,7 @@ describe('analytics', () => { const events = await AnalyticsEvent.findAll({ where: { event: 'page' } }); expect(events).toHaveLength(1); - expect(events[0].pageTitle).toBe('string'); + expect(events[0].pageId).toBe(payload.pageId); }); test('collection page view', async () => { @@ -154,7 +154,7 @@ describe('analytics', () => { }); expect(events).toHaveLength(1); - expect(events[0].collectionKind).toBe('issue'); + expect(events[0].collectionId).toBe(payload.collectionId); }); test('other page view', async () => { @@ -169,7 +169,7 @@ describe('analytics', () => { expect(events).toHaveLength(1); }); - test('stores country from timezone', async () => { + test('stores timezone from payload', async () => { const payload = makeTestPubPageViewPayload({ timezone: 'Europe/Amsterdam' }); const agent = await login(); @@ -179,15 +179,13 @@ describe('analytics', () => { const events = await AnalyticsEvent.findAll({ where: { pubId: payload.pubId } }); expect(events).toHaveLength(1); - expect(events[0].country).toBe('Netherlands'); - expect(events[0].countryCode).toBe('NL'); + expect(events[0].timezone).toBe('Europe/Amsterdam'); }); - test('stores collectionIds as array', async () => { - const id1 = 'de3a36ab-26d9-4b76-aaab-f1bffc18b102'; - const id2 = 'ae3a36ab-26d9-4b76-aaab-f1bffc18b103'; + test('strips dropped fields (collectionIds, pubSlug, etc.)', async () => { const payload = makeTestPubPageViewPayload({ - collectionIds: `${id1},${id2}`, + collectionIds: + 'de3a36ab-26d9-4b76-aaab-f1bffc18b102,ae3a36ab-26d9-4b76-aaab-f1bffc18b103', }); const agent = await login(); @@ -197,10 +195,12 @@ describe('analytics', () => { const events = await AnalyticsEvent.findAll({ where: { pubId: payload.pubId } }); expect(events).toHaveLength(1); - expect(events[0].collectionIds).toEqual([id1, id2]); + // 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 date', async () => { + test('converts timestamp to createdAt date', async () => { const now = Date.now(); const payload = makeTestPubPageViewPayload({ timestamp: now }); const agent = await login(); @@ -211,6 +211,6 @@ describe('analytics', () => { const events = await AnalyticsEvent.findAll({ where: { pubId: payload.pubId } }); expect(events).toHaveLength(1); - expect(new Date(events[0].timestamp).getTime()).toBe(now); + expect(new Date(events[0].createdAt).getTime()).toBe(now); }); }); From fdd9f05b0830a469f173f76cd12a95678bc1f882 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Wed, 15 Apr 2026 15:05:32 -0400 Subject: [PATCH 41/41] Fix lock file 1 --- pnpm-lock.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69191cd279..8e96111a5f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,7 @@ settings: patchedDependencies: reakit: - hash: hpw5k5ors3jxzufoxbjqeo4iee + hash: 31488077229fe3d19a71c66458b888c58eab18010632a29e3b5c485df77242a8 path: patches/reakit.patch importers: @@ -525,7 +525,7 @@ importers: version: 1.0.9(react@16.14.0) reakit: specifier: 1.0.0-beta.14 - version: 1.0.0-beta.14(patch_hash=hpw5k5ors3jxzufoxbjqeo4iee)(react-dom@16.14.0(react@16.14.0))(react@16.14.0) + version: 1.0.0-beta.14(patch_hash=31488077229fe3d19a71c66458b888c58eab18010632a29e3b5c485df77242a8)(react-dom@16.14.0(react@16.14.0))(react@16.14.0) rebound: specifier: ^0.1.0 version: 0.1.0 @@ -23469,11 +23469,11 @@ snapshots: dependencies: picomatch: 2.3.1 - reakit-system@0.7.2(react-dom@16.14.0(react@16.14.0))(react@16.14.0)(reakit-utils@0.7.3(react-dom@16.14.0(react@16.14.0))(react@16.14.0))(reakit@1.0.0-beta.14(patch_hash=hpw5k5ors3jxzufoxbjqeo4iee)(react-dom@16.14.0(react@16.14.0))(react@16.14.0)): + reakit-system@0.7.2(react-dom@16.14.0(react@16.14.0))(react@16.14.0)(reakit-utils@0.7.3(react-dom@16.14.0(react@16.14.0))(react@16.14.0))(reakit@1.0.0-beta.14(patch_hash=31488077229fe3d19a71c66458b888c58eab18010632a29e3b5c485df77242a8)(react-dom@16.14.0(react@16.14.0))(react@16.14.0)): dependencies: react: 16.14.0 react-dom: 16.14.0(react@16.14.0) - reakit: 1.0.0-beta.14(patch_hash=hpw5k5ors3jxzufoxbjqeo4iee)(react-dom@16.14.0(react@16.14.0))(react@16.14.0) + reakit: 1.0.0-beta.14(patch_hash=31488077229fe3d19a71c66458b888c58eab18010632a29e3b5c485df77242a8)(react-dom@16.14.0(react@16.14.0))(react@16.14.0) reakit-utils: 0.7.3(react-dom@16.14.0(react@16.14.0))(react@16.14.0) reakit-utils@0.7.3(react-dom@16.14.0(react@16.14.0))(react@16.14.0): @@ -23481,13 +23481,13 @@ snapshots: react: 16.14.0 react-dom: 16.14.0(react@16.14.0) - reakit@1.0.0-beta.14(patch_hash=hpw5k5ors3jxzufoxbjqeo4iee)(react-dom@16.14.0(react@16.14.0))(react@16.14.0): + reakit@1.0.0-beta.14(patch_hash=31488077229fe3d19a71c66458b888c58eab18010632a29e3b5c485df77242a8)(react-dom@16.14.0(react@16.14.0))(react@16.14.0): dependencies: body-scroll-lock: 2.7.1 popper.js: 1.16.1 react: 16.14.0 react-dom: 16.14.0(react@16.14.0) - reakit-system: 0.7.2(react-dom@16.14.0(react@16.14.0))(react@16.14.0)(reakit-utils@0.7.3(react-dom@16.14.0(react@16.14.0))(react@16.14.0))(reakit@1.0.0-beta.14(patch_hash=hpw5k5ors3jxzufoxbjqeo4iee)(react-dom@16.14.0(react@16.14.0))(react@16.14.0)) + reakit-system: 0.7.2(react-dom@16.14.0(react@16.14.0))(react@16.14.0)(reakit-utils@0.7.3(react-dom@16.14.0(react@16.14.0))(react@16.14.0))(reakit@1.0.0-beta.14(patch_hash=31488077229fe3d19a71c66458b888c58eab18010632a29e3b5c485df77242a8)(react-dom@16.14.0(react@16.14.0))(react@16.14.0)) reakit-utils: 0.7.3(react-dom@16.14.0(react@16.14.0))(react@16.14.0) rebound@0.1.0: {}