Solvro

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

  1. Click Sign in and choose Continue with Solvro Auth, or Register with your email + password.
  2. Wait for an existing admin to approve your account.
  3. Once approved, sign in and you'll land in the Django admin.

2. Register your application

  1. A superuser opens Applications → Add in the admin.
  2. They set the human name (e.g. Testownik); the code is what your frontend will pass as ?app=.
  3. 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
iduuidStable per alert; use for client-side dismissal memory.
titlestringMay be empty.
contentstring (HTML)HTML content of alert.
alert_type"info" | "warning" | "critical"Style your banner accordingly.
linkstring (URL)Empty string when unset. If non-empty, make the entire banner clickable.
open_in_new_tabboolWhen true and link is set, open it in a new tab (target="_blank" rel="noopener"). Ignored when link is empty.
is_globalboolTrue if the alert was published for all apps.
is_dismissableboolWhen false, render the banner without a close button.
start_at, end_atdatetime | nullActive window. The server already filters by these; included for client display.

Notes for frontend authors

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.