From 5a4e968cc1f0b2fc5e1959272f36b7d574dddebc Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Mon, 10 Aug 2026 20:11:32 -0300 Subject: [PATCH] feat(radar): prepare launch news surface (#9926) * feat(radar): prepare launch news surface * docs(changelog): add Radar launch news fragment --------- Co-authored-by: diegosouzapw --- README.md | 19 ++ .../features/9926-radar-launch-news.md | 1 + docs/architecture/REPOSITORY_MAP.md | 3 +- docs/frameworks/RADAR.md | 23 ++ docs/ops/RELEASE_CHECKLIST.md | 17 ++ docs/reference/ENVIRONMENT.md | 5 + news.json | 47 +++- .../(dashboard)/dashboard/HomePageClient.tsx | 33 --- src/app/(dashboard)/dashboard/NewsBanner.tsx | 119 +++++++++ .../changelog/components/NewsViewer.tsx | 103 ++++---- src/app/(dashboard)/home/page.tsx | 2 + src/shared/utils/releaseNotes.ts | 242 ++++++++++++++++-- tests/unit/news-feed-contract.test.ts | 33 +++ tests/unit/release-notes.test.ts | 165 +++++++++++- 14 files changed, 706 insertions(+), 106 deletions(-) create mode 100644 changelog.d/features/9926-radar-launch-news.md create mode 100644 src/app/(dashboard)/dashboard/NewsBanner.tsx create mode 100644 tests/unit/news-feed-contract.test.ts diff --git a/README.md b/README.md index 5edc2d69c7..535ff0519d 100644 --- a/README.md +++ b/README.md @@ -491,6 +491,25 @@ Pix copia-e-cola:
+## 📡 OmniRoute Radar + +The main free-tier headline remains **~1.53B tokens/month** from the documented, +pool-deduplicated catalog above. Temporary provider signup credits can separately lift the first +month to **~2.15B**. Radar is an optional, signed catalog overlay for people who want fresher +free-model availability between OmniRoute releases; the community catalog and every existing free +feature remain free. + +Supporters can receive the live catalog and additional provider opportunities. Its separate, +mutable ceiling is **approximately 3B tokens/month at most**, depending on provider availability. +That ceiling is not a guarantee: providers can change quotas, eligibility, models, or regions at +any time. + +Radar is opt-in and GET-only. The OmniRoute client does not upload prompts, traffic, provider +configuration, usage telemetry, or local announcement-dismiss state. Learn about eligibility and +the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute.online/planos)**. + +
+
## ✨ What's New diff --git a/changelog.d/features/9926-radar-launch-news.md b/changelog.d/features/9926-radar-launch-news.md new file mode 100644 index 0000000000..9ec56bebd0 --- /dev/null +++ b/changelog.d/features/9926-radar-launch-news.md @@ -0,0 +1 @@ +- **feat(radar):** add a localized public news feed and dismissible dashboard launch banner, with the Radar announcement staged inactive for a separately authorized launch ([#9926](https://github.com/diegosouzapw/OmniRoute/pull/9926)) diff --git a/docs/architecture/REPOSITORY_MAP.md b/docs/architecture/REPOSITORY_MAP.md index 20c31c4829..1a5e8eb8be 100644 --- a/docs/architecture/REPOSITORY_MAP.md +++ b/docs/architecture/REPOSITORY_MAP.md @@ -102,7 +102,7 @@ OmniRoute/ | **.gitleaks.toml** | gitleaks secret-scan ruleset | | **.zizmor.yml** | zizmor GitHub-Actions security-lint config | | **socket.yml** | Socket.dev supply-chain config | -| **news.json** | In-app release-notes feed (read by `src/shared/utils/releaseNotes.ts`) | +| **news.json** | Localized v2 announcement feed; Radar launch item ships inactive | | **flake.nix** / **flake.lock** | Nix dev-shell definition + lock | | **.env** | Local secrets (gitignored — generated from `.env.example`) | @@ -256,6 +256,7 @@ src/ | `utils/circuitBreaker.ts` | Provider circuit breaker (see `docs/architecture/RESILIENCE_GUIDE.md`) | | `utils/apiAuth.ts` | API key validation, scope checking | | `utils/fetchTimeout.ts` | Timeout/abort wrappers for upstream fetch | +| `utils/releaseNotes.ts` | Closed v2/legacy announcement parser, localization and ID dismissal | --- diff --git a/docs/frameworks/RADAR.md b/docs/frameworks/RADAR.md index 467269b04f..eeac6b6044 100644 --- a/docs/frameworks/RADAR.md +++ b/docs/frameworks/RADAR.md @@ -43,6 +43,29 @@ or external integration is currently available. --- +## Public announcement reader + +The generic announcement reader is separate from the Radar feature flag. The dashboard Home and +Changelog viewer fetch the repository's public `news.json` through a plain `GET` to +`NEWS_JSON_URL` (`src/shared/utils/releaseNotes.ts`). They send no Radar setting, prompt, provider +configuration, usage record, or local dismissal state. + +`news.json` uses the closed v2 schema implemented by `parseNewsPayload()`: + +- `schemaVersion: 2` and a bounded `items[]` collection; +- stable, unique announcement `id` values; +- explicit `active` and ISO `publishedAt` fields; +- required English copy with optional localized copy; +- optional credential-free HTTPS links and an allowlisted icon; +- newest-active-first selection, locale fallback to English, and per-ID local dismissal. + +The parser temporarily accepts the former singular `{ active, title, message, ... }` shape so +older forks can migrate without a broken Changelog view. Invalid feeds are inert. The Radar launch +entry ships with `active: false`; changing it to `true` is a separate post-merge, post-deploy +release action and does not change `RADAR_ENABLED` or the independent feed-sync opt-in. + +--- + ## Flag: `RADAR_ENABLED` (default off) Radar is gated end-to-end by the `RADAR_ENABLED` feature flag diff --git a/docs/ops/RELEASE_CHECKLIST.md b/docs/ops/RELEASE_CHECKLIST.md index 4d298c66fd..f1bf226293 100644 --- a/docs/ops/RELEASE_CHECKLIST.md +++ b/docs/ops/RELEASE_CHECKLIST.md @@ -65,6 +65,7 @@ directly from anywhere — CI can only stage; only the owner's 2FA releases. as the default reflex (minutes, reversible); `npm unpublish` only inside the 72h/no-dependents window and never as the first move. Docker: never rewrite a version tag — rollback is repointing `latest` to the last good digest. + ## Hotfix Fast-Lane (label `hotfix`) A PR labeled `hotfix` skips the heavy CI matrix (9-shard E2E, coverage ratchet, @@ -276,6 +277,22 @@ Deploy skills use the light rsync flow — no `npm pack`, no `npm i -g`: - [ ] Open milestone for next version - [ ] If critical: pin discussion or post in `news.json` for in-app banner +### Radar public-launch gate + +The Radar announcement is intentionally committed with `active: false`. Activation is a separate +change after every item below is evidenced: + +- [ ] All stacked Radar PRs are merged and the release-tip CI is green +- [ ] Deploy and smoke the OSS Radar routes with `RADAR_ENABLED` still off by default +- [ ] Smoke `GET /planos`, `/termos`, `/privacidade`, and `/reembolso` on the named Radar host +- [ ] Record operator identity/contact/address and owner-approved legal review in the private service +- [ ] Exercise Stripe Checkout and the signed webhook in test mode only +- [ ] Exercise one encrypted transactional-email delivery with the approved sender/domain +- [ ] Prove backup restore and one supervised, budget-capped research run +- [ ] Approve the BRL/PIX review policy before accepting donation evidence +- [ ] Enable public Checkout only after the preceding gates, then activate the new `news.json` ID +- [ ] Verify the Home banner uses localized copy and a new ID reappears after an older ID is dismissed + ## Embedded Services smoke (v3.8.4+) Before shipping any release that includes embedded services changes, verify: diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 69a9fe79af..542183d3fb 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -1292,6 +1292,11 @@ self-hosted or forked feed / supporter-key flow instead of the default OmniRoute Radar service. See [docs/frameworks/RADAR.md](../frameworks/RADAR.md) for the full module doc. +The generic Home/Changelog announcement reader is not configured by an environment +variable and does not depend on the RADAR_ENABLED feature flag. It reads the public repository +`news.json` URL declared in `src/shared/utils/releaseNotes.ts` by +GET only; dismissal IDs remain in browser local storage. + | Variable | Default | Source File | Description | | -------------------------------- | --------------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------ | | `RADAR_FEED_URL` | `https://radar.omniroute.online` | `src/lib/radar/{sync,referralsSync,offersSync,intelSync}.ts` | Base URL shared by the separately signed catalog, referrals, supporter-offers, and Intel feeds. Override to point at a self-hosted or forked service. | diff --git a/news.json b/news.json index bf4077df7d..16e2de2c47 100644 --- a/news.json +++ b/news.json @@ -1,8 +1,43 @@ { - "active": false, - "title": "Novidade no Omniverse", - "message": "Está lançado hoje o tOmni, o terminal interativo múltiplo para Agentes de AI! Experimente a nova interface focada em produtividade para desenvolvedores.", - "link": "https://github.com/diegosouzapw/tOmni", - "linkLabel": "Conhecer o tOmni", - "icon": "campaign" + "schemaVersion": 2, + "items": [ + { + "id": "radar-launch-2026-08", + "active": false, + "publishedAt": "2026-08-09T00:00:00.000Z", + "text": { + "en": { + "title": "OmniRoute Radar", + "message": "An opt-in, GET-only free-model catalog overlay with no telemetry from the OmniRoute client.", + "linkLabel": "Learn about Radar" + }, + "pt-BR": { + "title": "OmniRoute Radar", + "message": "Um catálogo opcional de modelos gratuitos, somente GET e sem telemetria enviada pelo cliente OmniRoute.", + "linkLabel": "Conheça o Radar" + } + }, + "link": "https://radar.omniroute.online/planos", + "icon": "radar" + }, + { + "id": "tomni-launch-2026-07", + "active": false, + "publishedAt": "2026-07-01T00:00:00.000Z", + "text": { + "en": { + "title": "New in the Omniverse", + "message": "tOmni is an interactive multi-agent terminal focused on developer productivity.", + "linkLabel": "Meet tOmni" + }, + "pt-BR": { + "title": "Novidade no Omniverse", + "message": "O tOmni é um terminal interativo para múltiplos agentes, focado na produtividade de desenvolvedores.", + "linkLabel": "Conhecer o tOmni" + } + }, + "link": "https://github.com/diegosouzapw/tOmni", + "icon": "campaign" + } + ] } diff --git a/src/app/(dashboard)/dashboard/HomePageClient.tsx b/src/app/(dashboard)/dashboard/HomePageClient.tsx index b5b5883dfd..3185ce3071 100644 --- a/src/app/(dashboard)/dashboard/HomePageClient.tsx +++ b/src/app/(dashboard)/dashboard/HomePageClient.tsx @@ -22,7 +22,6 @@ import { HomeProviderTopologySection } from "./HomeProviderTopologySection"; import { shouldShowProviderTopologyOnHome } from "./homeAppearance"; const ProviderQuotaWidget = dynamic(() => import("../home/ProviderQuotaWidget"), { ssr: false }); -import type { NewsAnnouncement } from "@/shared/utils/releaseNotes"; type UpdateStep = { step: string; @@ -37,7 +36,6 @@ type VersionInfo = { channel: string; autoUpdateSupported: boolean; autoUpdateError?: string | null; - news?: NewsAnnouncement | null; }; type HomePageClientProps = { @@ -1049,37 +1047,6 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
)} - - {/* News Notification Banner */} - {versionInfo?.news && ( -
-
-
- - {versionInfo.news.icon || "campaign"} - -
-
-

{versionInfo.news.title}

-

- {versionInfo.news.message} -

-
-
- - {versionInfo.news.link && ( - - {versionInfo.news.linkLabel || "Ler Mais"} - arrow_forward - - )} -
- )} )} diff --git a/src/app/(dashboard)/dashboard/NewsBanner.tsx b/src/app/(dashboard)/dashboard/NewsBanner.tsx new file mode 100644 index 0000000000..10922cbb24 --- /dev/null +++ b/src/app/(dashboard)/dashboard/NewsBanner.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { useEffect, useState, useSyncExternalStore } from "react"; +import { useLocale, useTranslations } from "next-intl"; + +import { + NEWS_DISMISS_EVENT, + NEWS_DISMISS_STORAGE_KEY, + fetchNewsPayload, + parseDismissedNewsIds, + selectActiveNews, + serializeDismissedNewsIds, +} from "@/shared/utils/releaseNotes"; + +function subscribeToDismissals(callback: () => void) { + window.addEventListener("storage", callback); + window.addEventListener(NEWS_DISMISS_EVENT, callback); + return () => { + window.removeEventListener("storage", callback); + window.removeEventListener(NEWS_DISMISS_EVENT, callback); + }; +} + +function readDismissedIds(): string { + try { + return localStorage.getItem(NEWS_DISMISS_STORAGE_KEY) ?? ""; + } catch { + return ""; + } +} + +function getServerDismissedIds(): string { + return ""; +} + +/** + * Generic, fail-silent reader for the public announcement feed. Fetching the + * static JSON is GET-only and does not send product state or telemetry. + */ +export default function NewsBanner() { + const locale = useLocale(); + const t = useTranslations("common"); + const [payload, setPayload] = useState(null); + const dismissedSnapshot = useSyncExternalStore( + subscribeToDismissals, + readDismissedIds, + getServerDismissedIds + ); + const dismissedIds = parseDismissedNewsIds(dismissedSnapshot); + const announcement = selectActiveNews(payload, locale, dismissedIds); + + useEffect(() => { + const controller = new AbortController(); + + void fetchNewsPayload(fetch, controller.signal).then((value) => { + if (value !== null) setPayload(value); + }); + + return () => controller.abort(); + }, []); + + if (!announcement) return null; + + const dismiss = () => { + dismissedIds.add(announcement.id); + try { + localStorage.setItem(NEWS_DISMISS_STORAGE_KEY, serializeDismissedNewsIds(dismissedIds)); + } catch { + // Storage is optional; the next announcement fetch remains functional. + } + window.dispatchEvent(new Event(NEWS_DISMISS_EVENT)); + }; + + return ( +
+
+
+ +
+
+

{announcement.title}

+

{announcement.message}

+
+
+ +
+ {announcement.link && ( + + {announcement.linkLabel ?? announcement.title} + + + )} + +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/changelog/components/NewsViewer.tsx b/src/app/(dashboard)/dashboard/changelog/components/NewsViewer.tsx index 44e7997874..764565eb61 100644 --- a/src/app/(dashboard)/dashboard/changelog/components/NewsViewer.tsx +++ b/src/app/(dashboard)/dashboard/changelog/components/NewsViewer.tsx @@ -1,39 +1,39 @@ "use client"; -import { useState, useEffect } from "react"; -import { useTranslations } from "next-intl"; +import { useEffect, useState } from "react"; +import { useLocale, useTranslations } from "next-intl"; + import { Button } from "@/shared/components"; import { - NEWS_JSON_URL, - parseActiveNewsPayload, + fetchNewsPayload, + listActiveNews, type NewsAnnouncement, } from "@/shared/utils/releaseNotes"; export default function NewsViewer() { + const locale = useLocale(); const t = useTranslations("changelogPage"); - const [news, setNews] = useState(null); + const [news, setNews] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(false); useEffect(() => { - async function fetchNews() { - try { - const res = await fetch(NEWS_JSON_URL, { cache: "no-store" }); - if (res.ok) { - const data = await res.json(); - setNews(parseActiveNewsPayload(data)); - } else { - setError(true); + const controller = new AbortController(); + + void fetchNewsPayload(fetch, controller.signal) + .then((payload) => { + if (payload === null) { + if (!controller.signal.aborted) setError(true); + return; } - } catch (err) { - console.error("Failed to fetch news:", err); - setError(true); - } finally { - setLoading(false); - } - } - fetchNews(); - }, []); + setNews(listActiveNews(payload, locale)); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + + return () => controller.abort(); + }, [locale]); if (loading) { return ( @@ -48,7 +48,7 @@ export default function NewsViewer() { if (error) { return (
- + error_outline

{t("announcementsLoadFailed")}

@@ -56,10 +56,10 @@ export default function NewsViewer() { ); } - if (!news || !news.active) { + if (news.length === 0) { return (
- + notifications_off

{t("noAnnouncements")}

@@ -68,30 +68,37 @@ export default function NewsViewer() { } return ( -
-
-
- - {news.icon || "campaign"} - -
- -
-

{news.title}

-

{news.message}

-
- - {news.link && ( -
- - - +
+ {news.map((announcement) => ( +
+
+ + {announcement.icon} +
- )} -
+ +
+

{announcement.title}

+

+ {announcement.message} +

+
+ + {announcement.link && ( + + )} + + ))}
); } diff --git a/src/app/(dashboard)/home/page.tsx b/src/app/(dashboard)/home/page.tsx index 1926f8fd32..92d336a71e 100644 --- a/src/app/(dashboard)/home/page.tsx +++ b/src/app/(dashboard)/home/page.tsx @@ -4,6 +4,7 @@ import { getSettings } from "@/lib/localDb"; import HomePageClient from "../dashboard/HomePageClient"; import BootstrapBanner from "../dashboard/BootstrapBanner"; import KimiSponsorBanner from "../dashboard/KimiSponsorBanner"; +import NewsBanner from "../dashboard/NewsBanner"; export const dynamic = "force-dynamic"; @@ -18,6 +19,7 @@ export default async function HomePage() { <> {isBootstrapped && } + ); diff --git a/src/shared/utils/releaseNotes.ts b/src/shared/utils/releaseNotes.ts index a1e25b3ad9..bdea01d498 100644 --- a/src/shared/utils/releaseNotes.ts +++ b/src/shared/utils/releaseNotes.ts @@ -6,34 +6,242 @@ export const CHANGELOG_RAW_URL = "https://raw.githubusercontent.com/diegosouzapw/OmniRoute/main/CHANGELOG.md"; export const CHANGELOG_GITHUB_URL = "https://github.com/diegosouzapw/OmniRoute/blob/main/CHANGELOG.md"; +export const NEWS_DISMISS_STORAGE_KEY = "omniroute-news-dismissed-v2"; +export const NEWS_DISMISS_EVENT = "omniroute:news-dismissed"; -const activeNewsSchema = z.object({ - active: z.literal(true), - title: z.string().trim().min(1).max(120), - message: z.string().trim().min(1).max(600), - link: z.string().url().optional(), - linkLabel: z.string().trim().min(1).max(80).optional(), - icon: z - .string() - .trim() - .regex(/^[a-z0-9_]+$/) - .optional(), +const NEWS_ID_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,78}[a-z0-9])?$/; +const LOCALE_PATTERN = /^[a-z]{2,3}(?:-[A-Z][a-z]{3})?(?:-[A-Z]{2}|-[0-9]{3})?$/; +const MAX_DISMISSED_IDS = 50; + +const newsIconSchema = z.enum(["campaign", "celebration", "info", "new_releases", "radar"]); +const localizedTextSchema = z + .object({ + title: z.string().trim().min(1).max(120), + message: z.string().trim().min(1).max(600), + linkLabel: z.string().trim().min(1).max(80).optional(), + }) + .strict(); + +const httpsUrlSchema = z + .string() + .url() + .max(500) + .refine((value) => { + const url = new URL(value); + return url.protocol === "https:" && !url.username && !url.password; + }, "Announcement links must use HTTPS without embedded credentials"); + +const localizedTextMapSchema = z.record(localizedTextSchema).superRefine((value, context) => { + if (!value.en) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "English copy is required" }); + } + for (const locale of Object.keys(value)) { + if (!LOCALE_PATTERN.test(locale)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid locale: ${locale}`, + }); + } + } }); -const inactiveNewsSchema = z +const newsFeedItemSchema = z + .object({ + id: z.string().regex(NEWS_ID_PATTERN), + active: z.boolean(), + publishedAt: z.string().datetime({ offset: true }), + text: localizedTextMapSchema, + link: httpsUrlSchema.optional(), + icon: newsIconSchema, + }) + .strict(); + +const newsFeedSchema = z + .object({ + schemaVersion: z.literal(2), + items: z.array(newsFeedItemSchema).max(50), + }) + .strict() + .superRefine(({ items }, context) => { + const ids = new Set(); + for (const [index, item] of items.entries()) { + if (ids.has(item.id)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: `Duplicate announcement id: ${item.id}`, + path: ["items", index, "id"], + }); + } + ids.add(item.id); + } + }); + +const legacyActiveNewsSchema = z + .object({ + active: z.literal(true), + title: z.string().trim().min(1).max(120), + message: z.string().trim().min(1).max(600), + link: httpsUrlSchema.optional(), + linkLabel: z.string().trim().min(1).max(80).optional(), + icon: newsIconSchema.optional(), + }) + .strict(); + +const legacyInactiveNewsSchema = z .object({ active: z.literal(false), }) .passthrough(); -const newsPayloadSchema = z.discriminatedUnion("active", [activeNewsSchema, inactiveNewsSchema]); +const legacyNewsSchema = z.discriminatedUnion("active", [ + legacyActiveNewsSchema, + legacyInactiveNewsSchema, +]); -export type NewsAnnouncement = z.infer; +export type NewsFeedItem = z.infer; +export type NewsIcon = z.infer; + +export type NewsAnnouncement = { + id: string; + active: true; + publishedAt: string; + title: string; + message: string; + link?: string; + linkLabel?: string; + icon: NewsIcon; +}; + +type NewsFetchResponse = Pick; +type NewsFetch = (url: string, init: RequestInit) => Promise; + +export async function fetchNewsPayload( + fetchNews: NewsFetch = fetch, + signal?: AbortSignal +): Promise { + try { + const response = await fetchNews(NEWS_JSON_URL, { + cache: "no-store", + credentials: "omit", + referrerPolicy: "no-referrer", + signal, + }); + return response.ok ? await response.json() : null; + } catch { + return null; + } +} + +function stableHash(value: string): string { + let hash = 0x811c9dc5; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0).toString(16).padStart(8, "0"); +} + +function normalizeLegacyNews(payload: unknown): NewsFeedItem[] { + const parsed = legacyNewsSchema.safeParse(payload); + if (!parsed.success || !parsed.data.active) return []; + + const item = parsed.data; + return [ + { + id: `legacy-${stableHash(`${item.title}\n${item.message}\n${item.link ?? ""}`)}`, + active: true, + publishedAt: "1970-01-01T00:00:00.000Z", + text: { + en: { + title: item.title, + message: item.message, + ...(item.linkLabel ? { linkLabel: item.linkLabel } : {}), + }, + }, + ...(item.link ? { link: item.link } : {}), + icon: item.icon ?? "campaign", + }, + ]; +} + +export function parseNewsPayload(payload: unknown): NewsFeedItem[] { + const feed = newsFeedSchema.safeParse(payload); + if (feed.success) return feed.data.items; + return normalizeLegacyNews(payload); +} + +function resolveLocalizedText(item: NewsFeedItem, locale: string) { + const normalizedLocale = locale.trim().replace("_", "-"); + const exactKey = Object.keys(item.text).find( + (key) => key.toLowerCase() === normalizedLocale.toLowerCase() + ); + if (exactKey) return item.text[exactKey]; + + const language = normalizedLocale.split("-")[0]?.toLowerCase(); + const languageKey = Object.keys(item.text).find((key) => key.toLowerCase() === language); + return (languageKey && item.text[languageKey]) || item.text.en; +} + +export function listActiveNews( + payload: unknown, + locale = "en", + now = new Date() +): NewsAnnouncement[] { + const nowMs = now.getTime(); + if (!Number.isFinite(nowMs)) return []; + + return parseNewsPayload(payload) + .filter((item) => item.active && Date.parse(item.publishedAt) <= nowMs) + .sort( + (left, right) => + Date.parse(right.publishedAt) - Date.parse(left.publishedAt) || + left.id.localeCompare(right.id) + ) + .map((item) => { + const text = resolveLocalizedText(item, locale); + return { + id: item.id, + active: true as const, + publishedAt: item.publishedAt, + title: text.title, + message: text.message, + ...(item.link ? { link: item.link } : {}), + ...(text.linkLabel ? { linkLabel: text.linkLabel } : {}), + icon: item.icon, + }; + }); +} + +export function selectActiveNews( + payload: unknown, + locale = "en", + dismissedIds: ReadonlySet = new Set(), + now = new Date() +): NewsAnnouncement | null { + return listActiveNews(payload, locale, now).find((item) => !dismissedIds.has(item.id)) ?? null; +} export function parseActiveNewsPayload(payload: unknown): NewsAnnouncement | null { - const parsed = newsPayloadSchema.safeParse(payload); - if (!parsed.success || parsed.data.active !== true) return null; - return parsed.data; + return selectActiveNews(payload, "en"); +} + +export function parseDismissedNewsIds(raw: string | null): Set { + if (!raw) return new Set(); + try { + const value: unknown = JSON.parse(raw); + if (!Array.isArray(value)) return new Set(); + const ids = value.filter( + (id): id is string => typeof id === "string" && NEWS_ID_PATTERN.test(id) + ); + return new Set(ids.slice(-MAX_DISMISSED_IDS)); + } catch { + return new Set(); + } +} + +export function serializeDismissedNewsIds(ids: Iterable): string { + const sanitized = [...new Set(ids)].filter((id) => NEWS_ID_PATTERN.test(id)); + return JSON.stringify(sanitized.slice(-MAX_DISMISSED_IDS)); } export function getLatestChangelogMarkdown(markdown: string, limit = 10): string { diff --git a/tests/unit/news-feed-contract.test.ts b/tests/unit/news-feed-contract.test.ts new file mode 100644 index 0000000000..4508eef200 --- /dev/null +++ b/tests/unit/news-feed-contract.test.ts @@ -0,0 +1,33 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; + +const root = new URL("../../", import.meta.url); + +test("news.json ships Radar inactive in the localized v2 feed without commercial details", async () => { + const source = await readFile(new URL("news.json", root), "utf8"); + const payload = JSON.parse(source); + const radar = payload.items.find((item: { id?: string }) => item.id === "radar-launch-2026-08"); + + assert.equal(payload.schemaVersion, 2); + assert.equal(radar.active, false); + assert.equal(radar.link, "https://radar.omniroute.online/planos"); + assert.match(radar.text.en.message, /opt-in/i); + assert.match(radar.text.en.message, /GET-only/i); + assert.match(radar.text.en.message, /no telemetry/i); + assert.doesNotMatch(source, /R\$|US\$|coupon|cupom|discount|desconto/i); +}); + +test("the generic banner is ID-dismissable and independent from the Radar feature flag", async () => { + const source = await readFile( + new URL("src/app/(dashboard)/dashboard/NewsBanner.tsx", root), + "utf8" + ); + + assert.match(source, /selectActiveNews/); + assert.match(source, /parseDismissedNewsIds/); + assert.match(source, /localStorage/); + assert.match(source, /announcement\.id/); + assert.doesNotMatch(source, /RADAR_ENABLED/); + assert.doesNotMatch(source, /method:\s*["']POST["']/); +}); diff --git a/tests/unit/release-notes.test.ts b/tests/unit/release-notes.test.ts index 6a90764bc1..19dfc38388 100644 --- a/tests/unit/release-notes.test.ts +++ b/tests/unit/release-notes.test.ts @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; const releaseNotes = await import("../../src/shared/utils/releaseNotes.ts"); -test("parseActiveNewsPayload returns only valid active announcements", () => { +test("parseActiveNewsPayload keeps the legacy singular contract", () => { assert.deepEqual( releaseNotes.parseActiveNewsPayload({ active: true, @@ -14,7 +14,9 @@ test("parseActiveNewsPayload returns only valid active announcements", () => { icon: "campaign", }), { + id: "legacy-8d301d21", active: true, + publishedAt: "1970-01-01T00:00:00.000Z", title: "Launch", message: "A short announcement", link: "https://github.com/diegosouzapw/tOmni", @@ -35,6 +37,167 @@ test("parseActiveNewsPayload returns only valid active announcements", () => { ); }); +test("parseNewsPayload validates the closed v2 feed and preserves inactive entries", () => { + const payload = { + schemaVersion: 2, + items: [ + { + id: "radar-launch-2026-08", + active: false, + publishedAt: "2026-08-09T12:00:00.000Z", + text: { + en: { title: "Radar", message: "Opt-in catalog", linkLabel: "Learn more" }, + "pt-BR": { title: "Radar", message: "Catálogo opt-in", linkLabel: "Saiba mais" }, + }, + link: "https://radar.omniroute.online/planos", + icon: "radar", + }, + ], + }; + + assert.deepEqual(releaseNotes.parseNewsPayload(payload), payload.items); + assert.deepEqual(releaseNotes.parseNewsPayload({ ...payload, unexpected: true }), []); + assert.deepEqual( + releaseNotes.parseNewsPayload({ + ...payload, + items: [{ ...payload.items[0], link: "http://radar.omniroute.online/planos" }], + }), + [] + ); + assert.deepEqual( + releaseNotes.parseNewsPayload({ + ...payload, + items: [...payload.items, payload.items[0]], + }), + [] + ); +}); + +test("listActiveNews localizes, filters future entries and sorts newest first", () => { + const payload = { + schemaVersion: 2, + items: [ + { + id: "older", + active: true, + publishedAt: "2026-08-08T12:00:00.000Z", + text: { en: { title: "Older", message: "English" } }, + icon: "info", + }, + { + id: "newer", + active: true, + publishedAt: "2026-08-09T12:00:00.000Z", + text: { + en: { title: "Newer", message: "English" }, + "pt-BR": { title: "Mais recente", message: "Português" }, + }, + icon: "campaign", + }, + { + id: "future", + active: true, + publishedAt: "2026-08-11T12:00:00.000Z", + text: { en: { title: "Future", message: "Not published yet" } }, + icon: "campaign", + }, + { + id: "inactive", + active: false, + publishedAt: "2026-08-09T13:00:00.000Z", + text: { en: { title: "Inactive", message: "Hidden" } }, + icon: "campaign", + }, + ], + }; + + const news = releaseNotes.listActiveNews(payload, "pt-BR", new Date("2026-08-10T00:00:00.000Z")); + assert.deepEqual( + news.map((item: { id: string; title: string; message: string }) => [ + item.id, + item.title, + item.message, + ]), + [ + ["newer", "Mais recente", "Português"], + ["older", "Older", "English"], + ] + ); + assert.equal( + releaseNotes.listActiveNews(payload, "pt-PT", new Date("2026-08-10T00:00:00.000Z"))[0]?.message, + "English" + ); +}); + +test("selectActiveNews skips dismissed ids while retaining new announcements", () => { + const payload = { + schemaVersion: 2, + items: [ + { + id: "newer", + active: true, + publishedAt: "2026-08-09T12:00:00.000Z", + text: { en: { title: "Newer", message: "English" } }, + icon: "campaign", + }, + { + id: "older", + active: true, + publishedAt: "2026-08-08T12:00:00.000Z", + text: { en: { title: "Older", message: "English" } }, + icon: "campaign", + }, + ], + }; + + assert.equal( + releaseNotes.selectActiveNews( + payload, + "en", + new Set(["newer"]), + new Date("2026-08-10T00:00:00.000Z") + )?.id, + "older" + ); +}); + +test("dismissed announcement ids are sanitized, deduplicated and bounded", () => { + assert.deepEqual( + [...releaseNotes.parseDismissedNewsIds('["valid-id","valid-id","