Centralised alerts for every Solvro app
Solvro Alerts is the single source of truth for banner-style messages that the Solvro frontends (Testownik, Planer, Eventownik, …) display to their users. Admins push messages here once; every frontend polls a single read-only endpoint.
1. Get an admin account
- Click Sign in and choose Continue with Solvro Auth, or Register with your email + password.
- Wait for an existing admin to approve your account.
- Once approved, sign in and you'll land in the Django admin.
2. Register your application
- A superuser opens Applications → Add in the admin.
- They set the human
name(e.g.Testownik); thecodeis what your frontend will pass as?app=. - In the Granted users field they pick which staff members can manage alerts for that app.
3. Fetch alerts from your frontend
Call GET /api/v1/alerts/?app=<your-app-code>.
You'll receive every active alert that's either global
or targeted at your app. The endpoint is public — no
API key. An unknown app code returns
400.
Omit ?app= to receive only global alerts.
curl
curl https://alerts.solvro.pl/api/v1/alerts/?app=testownik
Example response
[
{
"id": "0a1c2e8e-9f30-4a4f-b4a3-7b1f3a1a2b9c",
"title": "Maintenance tonight",
"content": "<p>We're upgrading our server at 22:00.</p>",
"alert_type": "info",
"link": "https://status.solvro.pl",
"is_global": true,
"is_dismissable": true,
"start_at": null,
"end_at": "2026-05-01T22:00:00Z"
}
]
React + TanStack Query
import { useQuery } from "@tanstack/react-query";
import type { Alert } from "./alerts";
const APP_CODE = "testownik";
export function useAlerts() {
return useQuery<Alert[]>({
queryKey: ["solvro-alerts", APP_CODE],
queryFn: async ({ signal }) => {
const r = await fetch(
`https://alerts.solvro.pl/api/v1/alerts/?app=${APP_CODE}`,
{ signal },
);
if (!r.ok) throw new Error(`alerts fetch failed: ${r.status}`);
return r.json();
},
staleTime: 60_000,
});
}
Response schema
TypeScript
export type AlertType = "info" | "warning" | "critical";
export interface Alert {
id: string;
title: string;
content: string;
alert_type: AlertType;
link: string;
open_in_new_tab: boolean;
is_global: boolean;
is_dismissable: boolean;
start_at: string | null;
end_at: string | null;
}
| Field | Type | Notes |
|---|---|---|
| id | uuid | Stable per alert; use for client-side dismissal memory. |
| title | string | May be empty. |
| content | string (HTML) | HTML content of alert. |
| alert_type | "info" | "warning" | "critical" | Style your banner accordingly. |
| link | string (URL) | Empty string when unset. If non-empty, make the entire banner clickable. |
| open_in_new_tab | bool | When true and link is set, open it in a new tab (target="_blank" rel="noopener"). Ignored when link is empty. |
| is_global | bool | True if the alert was published for all apps. |
| is_dismissable | bool | When false, render the banner without a close button. |
| start_at, end_at | datetime | null | Active window. The server already filters by these; included for client display. |
Notes for frontend authors
- Style the banner by
alert_type: info, warning, critical. - If
linkis set, make the entire banner clickable. Honouropen_in_new_tab: when true usetarget="_blank" rel="noopener", otherwise navigate in the same tab (use the app router for internal links). - If you're on Tailwind, install @tailwindcss/typography and wrap the rendered
contentin<div class="prose prose-sm max-w-none dark:prose-invert">— gives you sane defaults for headings, lists, blockquotes, links, etc. without authoring a custom stylesheet. - Allow users to dismiss alerts client-side using the
id; only show a close button whenis_dismissableis true. The server doesn't track per-user state. - The
contentfield is sanitised server-side (nh3 / Ammonia), but defence-in-depth: run it through DOMPurify or smth else on the client before injecting it into the DOM. - Calls with an unknown
?app=code respond400 Bad Request; check the spelling of your app's slug.
AI prompt
Paste this into your AI to scaffold the integration in any frontend project. It's framework-agnostic — clarify the stack you're using and the AI will adapt.
You're integrating Solvro Alerts into this app. The goal is a single drop-in
<Alerts /> component (or the platform-equivalent: a Flutter widget, a SwiftUI
view, a Jetpack Compose @Composable, etc.) that I can mount once near the root
and forget about. It owns fetching, caching, sanitising, rendering, dismissal,
and link handling.
Detect the platform from the project I'm in (web, Flutter, React Native,
native iOS/Android, …) and adapt accordingly. Where this prompt mentions a web
API, use the closest equivalent on the target platform — examples are listed
inline below.
ENDPOINT
GET https://alerts.solvro.pl/api/v1/alerts/?app=<APP_CODE>
- public, no auth, no API key
- cache for ~60s on the client
- 400 if the `app` code is unknown — surface as a developer error, never to end-users
- replace <APP_CODE> with the slug registered for this app (ask me if unset)
RESPONSE (array; render in order)
type Alert = {
id: string; // uuid; key dismissal state by this
title: string; // may be empty
content: string; // HTML — sanitise + render via the platform's HTML renderer
alert_type: "info" | "warning" | "critical";
link: string; // empty string when unset
open_in_new_tab: boolean; // when link is set, true => target=_blank rel=noopener
is_global: boolean;
is_dismissable: boolean;
start_at: string | null; // ISO-8601; server already filters by window
end_at: string | null;
};
UI RULES
- Stack banners vertically near the top of the screen. Place above or below the
navbar/header — pick whichever matches the existing layout.
- Color + icon by alert_type:
info -> blue / info icon
warning -> amber / warning icon
critical -> red / alert icon
- Render `content` as HTML, but DEFENCE-IN-DEPTH: pass it through a sanitiser before
handing it to the platform's HTML renderer. The server already strips scripts/img/etc.
with nh3, but a second client-side pass protects against future regressions and
CDN/cache tampering. Allow only these tags:
a, b, blockquote, br, code, del, div, em, h1, h2, h3, h4, h5, h6, hr, i, li, ol, p, pre, s, span, strong, sub, sup, u, ul
and attributes: href, title, target on <a>.
Sanitiser per platform:
web -> DOMPurify
Node SSR -> sanitize-html (or DOMPurify with jsdom)
React Native -> sanitize-html (no DOM available); render with react-native-render-html
Flutter -> flutter_html (parses + drops disallowed tags) or html package
iOS native -> NSAttributedString from a sanitised string (drop <script>, etc.)
Android native -> Html.fromHtml on a pre-stripped string (Jsoup safelist)
Never `eval`, never inject inline scripts.
- STYLING the rendered HTML:
- If the project uses Tailwind: install @tailwindcss/typography and wrap the rendered
content in <div class="prose prose-sm max-w-none dark:prose-invert"> — gives sane
defaults for headings, lists, blockquotes, code, links. No custom CSS needed.
- Otherwise: ship a tiny scoped stylesheet for the banner that sets sensible defaults
for h1-h6, ul/ol, blockquote, pre/code, a (underline + colour). Keep it scoped to
the banner so it doesn't bleed into the host app.
- If `link` is non-empty, make the whole banner tappable/clickable and open it.
Platform link-openers:
web -> <a href> or router navigation, honour `open_in_new_tab`
React Native -> Linking.openURL
Flutter -> url_launcher
iOS / Android -> the system URL opener (UIApplication.open / Intent.ACTION_VIEW)
- If `is_dismissable` is true, show a close affordance. On tap, persist the alert `id`
and hide it. On mount, read the persisted set and filter the response before rendering.
Storage per platform:
web -> localStorage, key "solvro-alerts-dismissed", JSON array of ids
React Native -> AsyncStorage / MMKV, same key + format
Flutter -> shared_preferences (StringList)
iOS / Android -> UserDefaults / SharedPreferences
- If `is_dismissable` is false, omit the close affordance entirely.
- If the array is empty, render nothing — no empty state, no skeleton on subsequent fetches.
DELIVERABLE
A single self-contained component (` ` or the platform equivalent). Internally
it must:
1. fetch the endpoint with the platform's HTTP client + a 60s cache (TanStack Query /
SWR / framework equivalent; on platforms without one, a simple useState + revalidate
on mount is fine);
2. read/write the dismissal set with the platform's persistent storage;
3. sanitise `content` with the platform sanitiser;
4. render the banner stack, applying color/icon by alert_type, the typography styling
above (Tailwind prose if available), and the click/dismiss behavior.
Mount it once near the root layout. Expose only one prop if any (override APP_CODE) —
consumers should not have to wire a fetcher, a query client, or a sanitiser themselves.
Read APP_CODE from the project's env config (.env / app config / Info.plist /
AndroidManifest meta-data, whichever is idiomatic) and document it in the README.
Do NOT add a new state library or UI library — use what the project already has.
ACCEPTANCE
- Mounting ` ` once is enough; no extra wiring required at call sites.
- When the API returns alerts, banners render in order with correct color/icon.
- Tapping a banner with a `link` navigates correctly and respects `open_in_new_tab`;
without a `link`, the banner is non-interactive (no hover affordance, no tap target).
- Dismissed alerts stay hidden across app launches but reappear when the admin
publishes a new alert with a different id.
- Expired / inactive alerts never appear (server already filters; do not re-filter
by start_at/end_at on the client beyond dismissal).
- No XSS / script-injection regressions: `content` passes through the platform
sanitiser with the inline-only allow-list. A `<script>` smuggled into a fixture
must be stripped both by the server (already enforced) and on the client.