diff --git a/packages/twenty-website/src/platform/community/community-stats-store.test.ts b/packages/twenty-website/src/platform/community/community-stats-store.test.ts new file mode 100644 index 0000000000..2a2bf272de --- /dev/null +++ b/packages/twenty-website/src/platform/community/community-stats-store.test.ts @@ -0,0 +1,95 @@ +import { getCloudflareContext } from '@opennextjs/cloudflare'; + +import { communityStatsStore } from './community-stats-store'; + +jest.mock('server-only', () => ({})); + +jest.mock('@opennextjs/cloudflare', () => ({ + getCloudflareContext: jest.fn(), +})); + +const mockedGetCloudflareContext = getCloudflareContext as jest.Mock; + +const buildBucket = () => ({ get: jest.fn(), put: jest.fn() }); + +const bindBucket = (bucket: ReturnType) => { + mockedGetCloudflareContext.mockResolvedValue({ + env: { NEXT_INC_CACHE_R2_BUCKET: bucket }, + }); +}; + +const storedObject = (value: unknown) => ({ + text: async () => JSON.stringify(value), +}); + +describe('communityStatsStore', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should read stored stats', async () => { + const bucket = buildBucket(); + bucket.get.mockResolvedValue( + storedObject({ githubStars: 50000, discordMembers: 6900 }), + ); + bindBucket(bucket); + + await expect(communityStatsStore.read()).resolves.toEqual({ + githubStars: 50000, + discordMembers: 6900, + }); + }); + + it('should return null when no bucket is bound', async () => { + mockedGetCloudflareContext.mockResolvedValue({ env: {} }); + + await expect(communityStatsStore.read()).resolves.toBeNull(); + }); + + it('should keep the stored value for a field the caller has no value for', async () => { + const bucket = buildBucket(); + bucket.get.mockResolvedValue( + storedObject({ githubStars: 50000, discordMembers: 6900 }), + ); + bindBucket(bucket); + + await communityStatsStore.write({ + githubStars: 50123, + discordMembers: null, + }); + + expect(bucket.put).toHaveBeenCalledWith( + 'community-stats/latest.json', + JSON.stringify({ githubStars: 50123, discordMembers: 6900 }), + ); + }); + + it('should skip the write when the stored entry cannot be read', async () => { + const bucket = buildBucket(); + bucket.get.mockRejectedValue(new Error('bucket unavailable')); + bindBucket(bucket); + + await communityStatsStore.write({ + githubStars: 50123, + discordMembers: null, + }); + + expect(bucket.put).not.toHaveBeenCalled(); + }); + + it('should overwrite a corrupt stored entry', async () => { + const bucket = buildBucket(); + bucket.get.mockResolvedValue({ text: async () => 'not json' }); + bindBucket(bucket); + + await communityStatsStore.write({ + githubStars: 50123, + discordMembers: null, + }); + + expect(bucket.put).toHaveBeenCalledWith( + 'community-stats/latest.json', + JSON.stringify({ githubStars: 50123, discordMembers: null }), + ); + }); +}); diff --git a/packages/twenty-website/src/platform/community/community-stats-store.ts b/packages/twenty-website/src/platform/community/community-stats-store.ts new file mode 100644 index 0000000000..8edc930e7c --- /dev/null +++ b/packages/twenty-website/src/platform/community/community-stats-store.ts @@ -0,0 +1,82 @@ +import 'server-only'; + +import { getCloudflareContext } from '@opennextjs/cloudflare'; +import { isDefined } from 'twenty-shared/utils'; + +import { type CommunityStats } from './get-community-stats'; +import { readFiniteNumber } from './read-finite-number'; + +const STORE_KEY = 'community-stats/latest.json'; + +type CommunityStatsBucket = { + get: (key: string) => Promise<{ text: () => Promise } | null>; + put: (key: string, value: string) => Promise; +}; + +const isCommunityStatsBucket = ( + value: unknown, +): value is CommunityStatsBucket => + isDefined(value) && + typeof (value as CommunityStatsBucket).get === 'function' && + typeof (value as CommunityStatsBucket).put === 'function'; + +const getBucket = async (): Promise => { + try { + const { env } = await getCloudflareContext({ async: true }); + const binding = (env as { NEXT_INC_CACHE_R2_BUCKET?: unknown }) + .NEXT_INC_CACHE_R2_BUCKET; + return isCommunityStatsBucket(binding) ? binding : null; + } catch { + return null; + } +}; + +const readStoredStats = async ( + bucket: CommunityStatsBucket, +): Promise => { + const storedObject = await bucket.get(STORE_KEY); + if (!isDefined(storedObject)) return null; + + const raw = await storedObject.text(); + try { + const parsed = JSON.parse(raw) as { + githubStars?: unknown; + discordMembers?: unknown; + }; + return { + githubStars: readFiniteNumber(parsed.githubStars), + discordMembers: readFiniteNumber(parsed.discordMembers), + }; + } catch { + return null; + } +}; + +export const communityStatsStore = { + read: async (): Promise => { + const bucket = await getBucket(); + if (!isDefined(bucket)) return null; + + try { + return await readStoredStats(bucket); + } catch { + return null; + } + }, + + write: async (stats: CommunityStats): Promise => { + const bucket = await getBucket(); + if (!isDefined(bucket)) return; + + try { + const stored = await readStoredStats(bucket); + const entry: CommunityStats = { + githubStars: stats.githubStars ?? stored?.githubStars ?? null, + discordMembers: stats.discordMembers ?? stored?.discordMembers ?? null, + }; + await bucket.put(STORE_KEY, JSON.stringify(entry)); + } catch { + return; + } + }, +}; diff --git a/packages/twenty-website/src/platform/community/format-compact-count.ts b/packages/twenty-website/src/platform/community/format-compact-count.ts index 6389b8a10b..01577968bd 100644 --- a/packages/twenty-website/src/platform/community/format-compact-count.ts +++ b/packages/twenty-website/src/platform/community/format-compact-count.ts @@ -1,6 +1,10 @@ -// 49600 -> "49.6K" (en) / "49,6 k" (fr) — compact notation in the visitor's -// locale, like every other number on the site. -export function formatCompactCount(value: number, locale: string): string { +import { isDefined } from 'twenty-shared/utils'; + +export function formatCompactCount( + value: number | null, + locale: string, +): string | null { + if (!isDefined(value)) return null; return new Intl.NumberFormat(locale, { notation: 'compact', maximumFractionDigits: 1, diff --git a/packages/twenty-website/src/platform/community/get-community-stats.test.ts b/packages/twenty-website/src/platform/community/get-community-stats.test.ts new file mode 100644 index 0000000000..49583c91a1 --- /dev/null +++ b/packages/twenty-website/src/platform/community/get-community-stats.test.ts @@ -0,0 +1,106 @@ +import { communityStatsStore } from './community-stats-store'; +import { getCommunityStats } from './get-community-stats'; + +jest.mock('server-only', () => ({})); + +jest.mock('./community-stats-store', () => ({ + communityStatsStore: { read: jest.fn(), write: jest.fn() }, +})); + +const mockedReadCache = communityStatsStore.read as jest.Mock; +const mockedWriteCache = communityStatsStore.write as jest.Mock; + +const ORIGINAL_FETCH = global.fetch; + +const githubResponse = (stars: number) => + new Response(JSON.stringify({ stargazers_count: stars }), { status: 200 }); + +const discordResponse = (members: number) => + new Response(JSON.stringify({ approximate_member_count: members }), { + status: 200, + }); + +const failedResponse = () => new Response('rate limited', { status: 403 }); + +describe('getCommunityStats', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(console, 'error').mockImplementation(() => {}); + mockedReadCache.mockResolvedValue(null); + mockedWriteCache.mockResolvedValue(undefined); + }); + + afterEach(() => { + global.fetch = ORIGINAL_FETCH; + }); + + it('should return live numbers when both APIs succeed', async () => { + global.fetch = jest + .fn() + .mockResolvedValueOnce(githubResponse(50123)) + .mockResolvedValueOnce(discordResponse(6712)); + + const stats = await getCommunityStats(); + + expect(stats).toEqual({ githubStars: 50123, discordMembers: 6712 }); + expect(mockedReadCache).not.toHaveBeenCalled(); + expect(mockedWriteCache).toHaveBeenCalledWith({ + githubStars: 50123, + discordMembers: 6712, + }); + }); + + it('should fall back to the cached value when a fetch fails', async () => { + mockedReadCache.mockResolvedValue({ + githubStars: 49999, + discordMembers: 6666, + }); + global.fetch = jest + .fn() + .mockResolvedValueOnce(failedResponse()) + .mockResolvedValueOnce(discordResponse(6800)); + + const stats = await getCommunityStats(); + + expect(stats).toEqual({ githubStars: 49999, discordMembers: 6800 }); + }); + + it('should return null when a fetch fails and the cache is empty', async () => { + global.fetch = jest + .fn() + .mockResolvedValueOnce(failedResponse()) + .mockResolvedValueOnce(discordResponse(6800)); + + const stats = await getCommunityStats(); + + expect(stats).toEqual({ githubStars: null, discordMembers: 6800 }); + }); + + it('should return nulls and not write the cache when everything fails', async () => { + global.fetch = jest + .fn() + .mockResolvedValueOnce(failedResponse()) + .mockResolvedValueOnce(failedResponse()); + + const stats = await getCommunityStats(); + + expect(stats).toEqual({ githubStars: null, discordMembers: null }); + expect(mockedWriteCache).not.toHaveBeenCalled(); + }); + + it('should serve cached values without writing when both fetches fail', async () => { + mockedReadCache.mockResolvedValue({ + githubStars: 49999, + discordMembers: 6666, + }); + global.fetch = jest + .fn() + .mockResolvedValueOnce(failedResponse()) + .mockResolvedValueOnce(failedResponse()); + + const stats = await getCommunityStats(); + + expect(stats).toEqual({ githubStars: 49999, discordMembers: 6666 }); + expect(mockedWriteCache).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/twenty-website/src/platform/community/get-community-stats.ts b/packages/twenty-website/src/platform/community/get-community-stats.ts index 4a10f6582b..4b5af12c69 100644 --- a/packages/twenty-website/src/platform/community/get-community-stats.ts +++ b/packages/twenty-website/src/platform/community/get-community-stats.ts @@ -1,51 +1,83 @@ +import 'server-only'; + +import { isDefined } from 'twenty-shared/utils'; + +import { communityStatsStore } from './community-stats-store'; +import { readFiniteNumber } from './read-finite-number'; + export type CommunityStats = { - discordMembers: number; - githubStars: number; + discordMembers: number | null; + githubStars: number | null; }; -// Live numbers when reachable, recent-real fallbacks when not — so the menu -// never renders empty chips and never blocks on a third party. -// Snapshot of 2026-06-10; refresh when the live numbers drift visibly. -const FALLBACK_STATS: CommunityStats = { - discordMembers: 6600, - githubStars: 49600, -}; +const REVALIDATE_SECONDS = 900; -const REVALIDATE_SECONDS = 3600; +const GITHUB_REPO_URL = 'https://api.github.com/repos/twentyhq/twenty'; +const DISCORD_INVITE_URL = + 'https://discord.com/api/v9/invites/cx5n4Jzs57?with_counts=true'; -const fetchJson = async (url: string): Promise => { - const response = await fetch(url, { - next: { revalidate: REVALIDATE_SECONDS }, - }); - if (!response.ok) throw new Error(`${url} -> ${response.status}`); - return response.json(); -}; +async function fetchGithubStars(): Promise { + const token = process.env.GITHUB_STATS_TOKEN; + try { + const response = await fetch(GITHUB_REPO_URL, { + headers: { + Accept: 'application/vnd.github+json', + 'User-Agent': 'twenty-website', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + next: { revalidate: REVALIDATE_SECONDS }, + }); + if (!response.ok) { + console.error( + `community stats: ${GITHUB_REPO_URL} -> ${response.status}`, + ); + return null; + } + const body = (await response.json()) as { stargazers_count?: unknown }; + return readFiniteNumber(body.stargazers_count); + } catch { + return null; + } +} -const readNumber = (value: unknown): number | null => - typeof value === 'number' && Number.isFinite(value) ? value : null; +async function fetchDiscordMembers(): Promise { + try { + const response = await fetch(DISCORD_INVITE_URL, { + next: { revalidate: REVALIDATE_SECONDS }, + }); + if (!response.ok) { + console.error( + `community stats: ${DISCORD_INVITE_URL} -> ${response.status}`, + ); + return null; + } + const body = (await response.json()) as { + approximate_member_count?: unknown; + }; + return readFiniteNumber(body.approximate_member_count); + } catch { + return null; + } +} export async function getCommunityStats(): Promise { - const [github, discord] = await Promise.allSettled([ - fetchJson('https://api.github.com/repos/twentyhq/twenty'), - fetchJson('https://discord.com/api/v9/invites/cx5n4Jzs57?with_counts=true'), + const [githubStars, discordMembers] = await Promise.all([ + fetchGithubStars(), + fetchDiscordMembers(), ]); - const githubStars = - github.status === 'fulfilled' - ? readNumber( - (github.value as { stargazers_count?: unknown }).stargazers_count, - ) - : null; - const discordMembers = - discord.status === 'fulfilled' - ? readNumber( - (discord.value as { approximate_member_count?: unknown }) - .approximate_member_count, - ) - : null; + const hasMissingLiveValue = + !isDefined(githubStars) || !isDefined(discordMembers); + const cached = hasMissingLiveValue ? await communityStatsStore.read() : null; - return { - discordMembers: discordMembers ?? FALLBACK_STATS.discordMembers, - githubStars: githubStars ?? FALLBACK_STATS.githubStars, + const resolved: CommunityStats = { + githubStars: githubStars ?? cached?.githubStars ?? null, + discordMembers: discordMembers ?? cached?.discordMembers ?? null, }; + + if (isDefined(githubStars) || isDefined(discordMembers)) { + await communityStatsStore.write(resolved); + } + + return resolved; } diff --git a/packages/twenty-website/src/platform/community/read-finite-number.ts b/packages/twenty-website/src/platform/community/read-finite-number.ts new file mode 100644 index 0000000000..e5308a768b --- /dev/null +++ b/packages/twenty-website/src/platform/community/read-finite-number.ts @@ -0,0 +1,2 @@ +export const readFiniteNumber = (value: unknown): number | null => + typeof value === 'number' && Number.isFinite(value) ? value : null; diff --git a/packages/twenty-website/src/sections/menu/components/MenuDrawer.tsx b/packages/twenty-website/src/sections/menu/components/MenuDrawer.tsx index c933813821..5da527b2fe 100644 --- a/packages/twenty-website/src/sections/menu/components/MenuDrawer.tsx +++ b/packages/twenty-website/src/sections/menu/components/MenuDrawer.tsx @@ -7,7 +7,8 @@ import { useLingui } from '@lingui/react'; import { styled } from '@linaria/react'; import { Fragment, useState } from 'react'; -import { formatCompactCount, type CommunityStats } from '@/platform/community'; +import { formatCompactCount } from '@/platform/community/format-compact-count'; +import { type CommunityStats } from '@/platform/community'; import { useLocale } from '@/platform/i18n'; import { LocalizedLink } from '@/platform/i18n/LocalizedLink'; import { diff --git a/packages/twenty-website/src/sections/menu/components/MenuSocial.tsx b/packages/twenty-website/src/sections/menu/components/MenuSocial.tsx index aaa800077d..c29caa11d8 100644 --- a/packages/twenty-website/src/sections/menu/components/MenuSocial.tsx +++ b/packages/twenty-website/src/sections/menu/components/MenuSocial.tsx @@ -2,7 +2,8 @@ import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { styled } from '@linaria/react'; -import { formatCompactCount, type CommunityStats } from '@/platform/community'; +import { formatCompactCount } from '@/platform/community/format-compact-count'; +import { type CommunityStats } from '@/platform/community'; import { useLocale } from '@/platform/i18n'; import { color,