[ case study ]

Smart Radiowęzeł - System dla szkoły

Oddaliśmy szkolny radiowęzeł w ręce uczniów. .NET orkiestruje dzwonki (Hangfire), Redis i SignalR dźwigają głosowania na żywo w sieci LAN, a Guardrail AI w Pythonie blokuje wulgaryzmy. Czyste wdrożenie on-premise spięte z fizycznym sprzętem audio.

[Live / Przekazany]

Full-Stack & DevOps Lead

Zespół 3 osoby · 400+ aktywnych użytkowników

Stack

Goal

Społecznościowy wybór muzyki na przerwy z gwarancją bezpieczeństwa. AI chroni głośniki przed trollami, a infrastruktura on-premise bezbłędnie synchronizuje się z fizycznym czasem dzwonków szkolnych.

Przewiń dalej

System wdrożony - działa w moim byłym technikum: ZSZ Gostyń

01 - Z chmury do serwerowni

Z chmury do szkolnej serwerowni

Większość case studies juniorów kończy się na deployu w chmurze i linku do Vercela. Mój poprzedni projekt - GroupNote - był architektonicznym monstrum, które padło ofiarą over-engineeringu i nigdy nie zderzyło się z rynkiem. Wyciągnąłem z tego prostą lekcję: prawdziwa inżynieria to nie budowanie idealnych systemów w próżni, tylko rozwiązywanie brudnych, fizycznych problemów.

Smart Radiowęzeł to odwrócenie startupowej mrzonki: system zrodzony i wdrożony w bojowych warunkach - w zamkniętej sieci ZSZ im. Powstańców Wielkopolskich w Gostyniu.

Proof of work: to nie działało „na moim komputerze”. Działało na szkolnym serwerze bare-metal, obsługując codziennie - ułamek sekundy po dzwonku - setki uczniów próbujących przeforsować swoje ulubione (i często zakazane) utwory.

Fizyczny dowód wdrożenia: plakaty z kodami QR do sieci ODN_Uczniowie oraz do aplikacji webowej - na korytarzach szkoły.

Problem: jak oddać głośniki uczniom, nie ryzykując katastrofy?

Szkolny radiowęzeł to infrastruktura podwyższonego ryzyka. Dźwięk idzie na korytarze, do sal i poza budynek. Stary układ to PC i statyczna playlista. Chcieliśmy dać uczniom kontrolę z telefonów - ale z pełną świadomością ryzyka:

  • Trolling i wulgaryzmy: pierwsza myśl uczniów to niecenzuralny rap albo „żartobliwy” hymn. Potrzebna była twarda polityka zero trust wobec treści.

  • Synchronizacja z fizycznym czasem: głosowania i odtwarzanie musiały trzymać się dzwonków - bez losowych opóźnień „z chmury”.

  • Thundering herd: o 8:35 dzwoni dzwonek i w tej samej sekundzie setki telefonów uderza w API - baza i realtime musiały to znosić bez zadyszki.

Zespół i półtora roku iteracji

Przez około półtora roku, w trzyosobowym zespole (ja + dwóch kolegów z klasy) pod okiem nauczyciela IT, zbudowaliśmy architekturę, która spięła nowoczesny web z fizycznym sprzętem.

Ja odpowiadałem za Lead Backend + DevOps + architekturę: baza danych, .NET, SignalR, Redis, Docker, aplikację kliencką w React oraz aplikację administracyjną we Flutterze. Koledzy dostarczyli kluczowy mikroserwis w Pythonie - AI Guardrail scrapujący teksty z sieci i oceniający treść modelami LLM pod kątem przydatności do szkoły.

To case study o zderzeniu kodu z setkami użytkowników naraz i o podłączeniu nowoczesnego softu do miedzianego kabla od szkolnego wzmacniacza.

UI w React, mobile-first: lista utworów, głosy w czasie rzeczywistym - tak uczniowie wybierali muzykę między lekcjami.

02 - Architektura

Architektura - modularny monolit na bare-metalu

[ design principle ]

Pragmatyzm ponad hype. Rozbicie systemu na mikroserwisy dla ~400 użytkowników w szkolnym LAN-ie to książkowy over-engineering, który zabiłby nas operacyjnie. Z drugiej strony klasyczne warstwowe spaghetti uniemożliwiłoby bezpieczne aktualizacje na produkcji między przerwami. Wybrałem Modular Monolith.

System musiał działać stabilnie w jednym kontenerze Dockera na lokalnym serwerze w sieci ODN_Uczniowie. Zamiast rozpraszać architekturę po sieci, zbudowałem jeden proces orkiestrujący całość - logicznie podzielony, operacyjnie spójny.

High-level: studenci (React) i admin (Flutter) → API .NET → kolejka głosów, moderacja, PostgreSQL, Redis, moduł AI (Python + Gemini) → zatwierdzone utwory → odtwarzacz AIMP; stack w Docker Compose na serwerze szkolnym.

Vertical slices i komunikacja in-process

Backend ASP.NET Core podzieliłem na pionowe moduły: Modules.Users, Modules.Votings, Modules.Admin, Modules.Feedback.

Zamiast REST „wewnątrz monolitu”, użyłem jawnej komunikacji in-process przez MediatR: każda operacja to Command + Handler. Kontrakty między modułami leżą w Shared/Events.

Efekt: jeden proces na serwerze, ale czytelne granice domeny (bounded contexts) w kodzie.

Baza danych: jeden serwer, cztery konteksty

Jedna instancja PostgreSQL, ale w kodzie absolutna separacja: każdy moduł miał własny DbContext i własne migracje - najczęstszy błąd monolitów (wspólne tabele bez granic) był z góry blokowany.

Modules.Votings/Database/VotingDbContext.cs
public class VotingDbContext : DbContext
{
    public DbSet<Song> Songs { get; set; }
    public DbSet<VotingSession> Votings { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.HasDefaultSchema("votings"); // Izolacja na poziomie schematu DB
        // Konfiguracja specyficzna dla modułu głosowań
    }
}

Dzięki temu moduł Admin nie mógł „na skróty” czytać tabel Votings - tylko przez jawny kontrakt (np. query przez MediatR).

Docker Compose jako operacyjny backbone

Na bare-metal nie ma managed DB ani auto-scalingu - jedynym kontraktem z serwerem był docker-compose.yml. Musiał wstać po każdym resecie: API .NET, PostgreSQL, Redis (sesje + SignalR), telemetria (Aspire Dashboard).

docker-compose.yml (fragment)
# Fragment infrastruktury - jedna sieć LAN
services:
  radiowezelapi:
    build:
      context: .
      dockerfile: RadiowezelAPI/Dockerfile
    environment:
      - OTEL_EXPORTER_OTLP_ENDPOINT=http://radiowezel.dashboard:18889
      - PYTHON_URL=${PYTHON_URL}
      - TZ=Europe/Warsaw # Krytyczne: synchronizacja z czasem szkolnym
    ports:
      - "8080:8080"
    depends_on:
      - radiowezel.cache
      - radiowezel.postgres

Kluczowy detal: TZ=Europe/Warsaw. Gdy większość stacków cloudowych żyje w UTC, my sterowaliśmy fizycznymi dzwonkami w polskim technikum - Hangfire musiał rozumieć lokalną strefę, letni/zimowy i moment, kdy wybija 8:35.

Pipeline produktowy w skrócie: link YT → walidacja → kolejka → głosowanie → odtwarzanie na głośnikach.

03 - Most sprzętowy i czas

Most sprzętowy i czas fizyczny (Hangfire)

Większość aplikacji webowych żyje w świecie, gdzie czas to po prostu DateTime.UtcNow. U nas musieliśmy trzymać się fizycznych dzwonków na korytarzach. Spóźnienie o kilka sekund oznaczało muzykę w trakcie lekcji - czyli natychmiastowe wyłączenie systemu przez dyrekcję.

Hangfire i pułapka stref czasowych

Harmonogram otwierania i zamykania głosowań na przerwach oparliśmy o Hangfire z magazynem w PostgreSQL. W kontenerach szybko wyszło, że Docker domyślnie żyje w UTC, a dzwonek w polskiej szkole - w Europe/Warsaw z letnim/zimowym.

[ time zone ]

Ustawiliśmy w kontenerze jawnie TZ=Europe/Warsaw, a harmonogram w .NET wymuszał czas lokalny serwera. Bez tego cały rozkład przerw „jeździłby” po każdej zmianie czasu.

Modules.Votings/Extensions/ScheduleTasksProvider.cs
services.AddHangfire(cfg => cfg
    .SetDataCompatibilityLevel(CompatibilityLevel.Version_170)
    .UseSimpleAssemblyNameTypeSerializer()
    .UseRecommendedSerializerSettings()
    .UsePostgreSqlStorage(connectionString));

// Krytyczny detal: harmonogram musi rozumieć fizyczny czas lokalny
var jobsOptions = new RecurringJobOptions { TimeZone = TimeZoneInfo.Local };

// Rejestracja jobów startujących głosowanie (Cron)
RecurringJob.AddOrUpdate<VotingJobHandler>(
    "StartVoting_Przerwa1",
    x => x.StartVotingAsync(CancellationToken.None),
    "35 8 * * 1-5", // Pon-Pt, 8:35
    jobsOptions);

Hardware bridge: od .NET do miedzianego kabla

Samo zamknięcie głosowania w bazie nie generuje dźwięku - trzeba było wypchnąć zwycięskie utwory na fizyczne głośniki. Sprzęt to stary komputer podłączony do wzmacniacza i AIMP. Bez budżetu na dedykowane API audio poszedł pragmatic wrapper wokół CLI.

Most sprzętowy: API zatwierdza utwory, lekki bridge w Pythonie (FastAPI) odpytuje backend i tłumaczy to na komendy CLI dla AIMP; sygnał idzie na wzmacniacz i głośniki w całym budynku.

Koledzy znaleźli narzędzie CLI do AIMP-a, a na komputerze w „kanciapie” radiowęzła postawiliśmy cienki serwis FastAPI, który cyklicznie wołał backend:

GET /voting/songs-to-play - zwycięska playlista (URL + czas trwania), potem mapowanie na polecenia AIMP (play, pause, głośność).

Pętla zwrotna (Now Playing)

System był dwukierunkowy: gdy AIMP zaczynał grać, controller w Pythonie wołał POST /voting/playing-song. Backend zapisywał krótki stan w Redis i od razu wypychał zmianę przez SignalR do telefonów - w praktyce status „Teraz gra” pojawiał się na ekranach w tym samym momencie, kiedy bas docierał do korytarza.

POST /voting/playing-song
POST /voting/playing-song
{
  "songId": "guid-utworu",
  "duration": 215
}
UI zsynchronizowany w czasie rzeczywistym: studenci widzieli „Teraz gra” i kolejkę głosów tak szybko, jak backend dostawał sygnał z mostu sprzętowego.

04 - Ruch na przerwie

Ruch na przerwie (Redis & SignalR)

W typowej aplikacji ruch rozłoży się w miarę równo w ciągu dnia. U nas przez 45 minut lekcji było prawie zero - a o 8:35, w ułamku sekundy po dzwonku, setki uczniów włączało aplikację, żeby zagłosować. To książkowy thundering herd.

Gdyby przy każdym odświeżeniu pytać PostgreSQL, czy głosowanie jest aktywne i przeliczać lajki w locie, w kilka sekund dobilibyśmy connection pool.

Redis jako tarcza dla bazy

Redis trzymał gorący stan na czas przerwy - krótki, ulotny, w RAM:

  • stan głosowania (aktywne / nieaktywne),

  • bieżący utwór (Now Playing),

  • sesje (ograniczenie multikont),

  • szybki cache oddanych głosów.

API odpowiadało z pamięci zamiast uderzać w relacyjną bazę przy każdym ruchu na liście.

Modules.Users/Auth (fragment)
// Modules.Users/Auth - szybki check sesji w Redis (antyspam)
var sessionExists = await cache.GetStringAsync($"session:{userId}");
if (sessionExists is null)
{
    await cache.SetStringAsync($"session:{userId}", "true",
        options: new DistributedCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(15)
        });
    return false;
}
return true; // Blokada: uczeń już działa w tej sesji

Pragmatyczny event bus (SignalR)

Polling zabiłby Wi‑Fi. Frontend w React musiał żyć na żywo - weszliśmy w SignalR, ale bez armii dedykowanych metod w hubie na każdą akcję.

Jeden kanał ReceiveMessage: backend wysyłał krótkie stringi, a klient decydował w switch/if, co z nimi zrobić.

votingHubClient.ts (uproszczony)
// React - jeden kanał ReceiveMessage zamiast armii metod w Hubie
const connection = new signalR.HubConnectionBuilder()
  .withUrl(`${API_BASE_URL}/voting-hub`)
  .withAutomaticReconnect()
  .build();

connection.on("ReceiveMessage", (message: string) => {
  if (isPlayingSongDto(message)) {
    onPlayingSongUpdate?.(message);
  } else if (message === "Like added to song.") {
    onLikeUpdate?.();
  } else if (message === "Voting started.") {
    onVotingStarted?.();
  } else if (message === "Voting ended.") {
    onVotingEnded?.();
  }
});

Trade-off inżynieryjny: poświęciliśmy silne typowanie każdego eventu po stronie huba, żeby zyskać prostą integrację i jeden strumień wiadomości. Nowy sygnał z backendu (np. komunikat awaryjny) nie wymagał zmiany sygnatury Huba - wystarczyła kolejna gałąź po stronie frontu.

Gdy jeden uczeń oddawał głos, Redis trzymał stan, a SignalR rozsyłał aktualizację - liczniki na setkach telefonów zmieniały się praktycznie naraz.

05 - AI Guardrail

AI Guardrail - tarcza przed trollami

Gdy oddajesz setkom uczniów możliwość puszczania muzyki, pierwszy ruch to często wulgarny rap albo „żart” w stylu hymnu ZSRR. Ręczna moderacja każdego utworu przez nauczyciela zabiłaby projekt pierwszego dnia - potrzebowaliśmy automatycznej tarczy.

Wszedł moduł AI Guardrail: analiza treści zanim piosenka trafi do puli głosów.

Zespół i granice API: modułu AI w Pythonie (FastAPI + LLM) nie pisałem sam - dostarczyli go dwaj koledzy z zespołu (scraping, modele, Gemini). Moja rola po stronie .NET to orkiestracja: zaprojektowanie twardych kontraktów HTTP, decyzje domenowe po odpowiedzi AI oraz mapowanie wyników na realne tabele - RejectedSongs, SongsToCheck - tak, żeby ich worker pozostał czarną skrzynką, a granice systemu były jasne.

Podział: orchestrator vs worker

Nie mieszaliśmy scrapingu tekstów i zapytań do Gemini w głównym kodzie C# - podział był jawny:

Python (worker): link YouTube, metadane, tekst z zewnętrznych źródeł, wywołanie LLM, zwrot ustandaryzowanego wyniku (Positive / Negative / Neutral).

.NET (orchestrator): przepływ domeny, baza, ostateczna decyzja co zrobić z wynikiem - i persystencja, której Python nie musiał znać.

Z perspektywy backendu moduł kolegów był czarną skrzynką z prostym kontraktem:

Modules.Votings/Integrations (fragment)
var result = await $"{PythonApiUrl}/sentiment"
    .PostJsonAsync(new { URL = request.Url }) // Kontrakt uzgodniony z zespołem Pythona
    .ReceiveJson<ValidateSongResponse>();

Logika domenowa po stronie .NET

Samo „odpytanie AI” to za mało. Zbudowałem w C# trójdzielny flow na podstawie odpowiedzi z Pythona:

Positive - automatyczna akceptacja, utwór idzie do głosowania.

Negative - twardy blok: zapis do RejectedSongs (banlista), błąd na froncie.

Neutral - bezpieczny bufor: wpis do SongsToCheck na ręczną decyzję w aplikacji admina (Flutter). LLM bywa niepewny albo nie łapie ironii - lepiej jeden klik nauczyciela niż przepuszczenie ryzykownej treści.

AddSong handler (fragment)
// Fragment logiki weryfikacji w handlerze .NET
if (isInRejectedSongs)
{
    return Result.Failure<AddSongResponse>(
        Error.Conflict("Utwór zablokowany przez Guardrail AI.")
    );
}

if (aiResult.Sentiment == Sentiment.Neutral)
{
    await context.SongsToCheck.AddAsync(new SongToCheck { Url = request.Url });
    await context.SaveChangesAsync();
    return Result.Success(new AddSongResponse("Piosenka oczekuje na moderację."));
}

Stan: oczekiwanie

Stan: sukces

Od oczekiwania do wyniku: po wklejeniu linku .NET wołał serwis Pythona; scraping i LLM trwały kilka sekund - najpierw stan analizy (lewo), potem modal sukcesu z utworem w kolejce (prawo) albo komunikat błędu Guardrail.

06 - Auth i pragmatyzm

Bezpieczeństwo i pragmatyzm auth

W systemach enterprise standardem jest pełne IAM - OAuth2, OpenID Connect, Azure AD. Szkoła miała konta Microsoft 365 dla każdego ucznia, więc technicznie „Zaloguj się kontem szkolnym” było najbardziej „poprawną” ścieżką.

Dobry inżynier produktu wie jednak, kiedy „najlepsze praktyki” zabiją projekt.

[ UX vs security ]

Wyobraź sobie ~400 uczniów z ~10 minutami przerwy. Zmuszenie ich do wpisywania na telefonach długich maili (imie.nazwisko@domena.pl) i haseł zabiłoby adopcję pierwszego dnia. Zrezygnowałem z Azure AD na rzecz maksymalnego obniżenia tarcia.

Logowanie bez tarcia: 4 znaki

Zamiast korporacyjnego SSO każdy uczeń dostał wygenerowany, unikalny 4-znakowy kod alfanumeryczny. Wpisanie go na klawiaturze telefonu zajmowało sekundę.

Twój kod

Logowanie

Od wyświetlenia unikalnego kodu (lewo) do wpisania tych samych czterech znaków na ekranie logowania (prawo): prosty, mobilny flow bez maili i haseł szkolnych - friction na minimum przy setkach użytkowników w jednej przerwie.

Proste kody brzmią jak koszmar bezpieczeństwa, ale system nie chronił danych bankowych - tylko dostępu do głośników. Żeby ograniczyć nadużycia (jeden uczeń, wiele kart, spam głosów), dodałem szybką blokadę sesji w Redis przypisaną do użytkownika na czas przerwy:

Modules.Users/Auth (fragment)
// Modules.Users/Auth - blokada sesji w Redis
var sessionExists = await cache.GetStringAsync($"session:{userId}");
if (sessionExists is not null)
{
    return Result.Failure(Error.Conflict("Sesja jest już aktywna."));
}

await cache.SetStringAsync(
    $"session:{userId}",
    "true",
    new DistributedCacheEntryOptions
    {
        AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(15)
    });

Dzięki temu prosta autoryzacja była odporna na próby rozciągnięcia jednego konta na wiele urządzeń naraz i na manipulację wynikami głosowania w szczycie ruchu.

Admin: pragmatyczne OTP

Odrzucenie ciężkiego identity dotyczyło też panelu administratora (moderacja bufora Neutral). Nie stawiałem IdentityServera - zrobiłem prosty OTP wysyłany przez SMTP na predefiniowane adresy. Kod był ważny ~25 minut i lądował w Redisie; chronił go dedykowany middleware na wybranych ścieżkach:

Admin OTP middleware (fragment)
// Fragment middleware chroniącego endpointy administracyjne
if (context.Request.Path.StartsWithSegments("/admin/songs"))
{
    if (!context.Request.Headers.TryGetValue("X-Admin-OTP", out var providedOtp))
    {
        context.Response.StatusCode = StatusCodes.Status401Unauthorized;
        return;
    }

    var cachedOtp = await cache.GetStringAsync("OTP");
    if (string.IsNullOrEmpty(cachedOtp) || cachedOtp != providedOtp)
    {
        context.Response.StatusCode = StatusCodes.Status403Forbidden;
        return;
    }
}

Żadnych ról, żadnych rozbudowanych polityk claimsów - surowe sprawdzenie nagłówka HTTP i pamięci podręcznej, dopasowane do skali projektu w LAN.

07 - Obserwowalność

Obserwowalność w szafie - OpenTelemetry

Na Vercelu błędy oglądasz w wygodnym dashboardzie. Przy deployu on-prem na szkolnym serwerze domyślnie zostają płaskie logi z Dockera i czarny terminal.

Gdy w trakcie przerwy milknie muzyka, nie ma czasu na żmudne grep po SSH - trzeba od razu wiedzieć: czy padła baza, czy Python zwrócił timeout, czy przyszedł zepsuty link.

[ minimum viable ops ]

Nie budowałem centrum dowodzenia z Grafaną, Prometheusem i ELK - to zabiłoby mały serwer (kolejny argument za unikaniem over-engineeringu). Zamiast tego: czyste OpenTelemetry w .NET i lekki kontener Aspire Dashboard.

Telemetria strukturalna (.NET + OTLP)

W głównym API skonfigurowałem instrumentację zbierającą metryki i ślady z krytycznych ścieżek:

RadiowezelAPI/Program.cs (fragment)
// RadiowezelAPI/Program.cs - rejestracja OpenTelemetry
services.AddOpenTelemetry()
    .ConfigureResource(resource => resource.AddService("radiowezel"))
    .WithMetrics(metrics =>
    {
        metrics.AddAspNetCoreInstrumentation()
            .AddHttpClientInstrumentation(); // Korelacja wywołań do Pythona
        metrics.AddOtlpExporter();
    })
    .WithTracing(tracing =>
    {
        tracing.AddAspNetCoreInstrumentation()
            .AddHttpClientInstrumentation()
            .AddEntityFrameworkCoreInstrumentation(); // Zapytania SQL
        tracing.AddOtlpExporter();
    });

AddEntityFrameworkCoreInstrumentation pokazywał czas zapytań do Postgresa - przy „lagi” na przerwie od razu było widać, czy winna jest baza, czy np. blokada w Redis.

AddHttpClientInstrumentation dawał pełny obraz czasu po stronie mikroserwisu Python (AI Guardrail) przy analizie treści utworu.

Jeden port OTLP, bez dodatkowej warstwy observability: pełna korelacja HTTP → zapytania SQL (EF Core) → wywołanie do AI w Pythonie i czasy odpowiedzi w jednym widoku.

Aspire Dashboard w Docker Compose

Do docker-compose.yml dorzuciłem oficjalny obraz Aspire Dashboard - w tej samej sieci LAN zbierał dane po OTLP:

docker-compose.yml (fragment)
# Fragment docker-compose.yml
services:
  radiowezel.dashboard:
    image: mcr.microsoft.com/dotnet/nightly/aspire-dashboard:latest
    ports:
      - "15677:18888" # Dostęp lokalnie dla deweloperów

  radiowezelapi:
    environment:
      # API wysyła telemetrię OpenTelemetry do Dashboardu (OTLP)
      - OTEL_EXPORTER_OTLP_ENDPOINT=http://radiowezel.dashboard:18889

Efekt: pod jednym adresem na serwerze szkolnym - dashboard z korelacją między requestem od ucznia, czasem w bazie i zapytaniem do AI. Koszt operacyjny bliski zeru względem pełnego stacku observability.

08 - Zderzenie z użytkownikami

Zderzenie z użytkownikami - dzień pierwszy

Testy na localhost z kilkoma rekordami to jedno. Wpuszczenie do systemu ~400 uczniów, których głównym celem jest znalezienie luki i zrobienie „dymu” na korytarzu, to inny sport. Dzień pierwszy był brutalną lekcją utrzymania na żywo (live ops).

Chińskie znaki i brak twardego MaxLength

W pierwszych godzinach baza „spuchła”. Jeden z uczniów odkrył, że formularz dodawania piosenki przyjmuje dowolną długość tekstu - skryptem lub ręcznie zasypał endpoint tysiącami znaków w polu tytułu.

[ live hotfix ]

Klasyczny brak walidacji domenowej po stronie API. Fix: reguły w FluentValidation (limit długości), sprzątanie śmieci w Postgresie z CLI i restart kontenera na serwerze szkolnym - zanim zadzwonił dzwonek na kolejną przerwę.

Stress-test AI Guardraila

Gdy uczniowie zorientowali się, że utwory nie wchodzą od razu, zaczęli testować granice modelu w Pythonie: hymn ZSRR, wulgaryzmy „zakamuflowane” w tekście, treści skrajne.

Tu uratowała nas decyzja o buforze Neutral (SongsToCheck): LLM odrzucał oczywisty spam (Negative), a przy sprytniejszych próbach - gdy w sieci nie było oficjalnego tekstu - bezpiecznie klasyfikował jako niepewne. Z poziomu aplikacji Flutter czyściliśmy bufor jednym kliknięciem.

Produkt: komunikacja dev → user

Uczniowie irytują się, gdy „ich piosenka nie leci”, a nie wiedzą dlaczego. Zamiast zostawiać ich w ciemności, dokładaliśmy lekkie moduły: Modules.Feedback oraz ogłoszenia.

Dev announcements - baner w Reactcie dla wszystkich: gdy Hangfire zsunął się z dzwonkiem albo serwer stał na hotfixie, komunikat w stylu: system wstrzymany, wracamy na następnej przerwie.

POST /feedback - prosty kanał zgłoszeń z poziomu apki, żeby czuli się częścią projektu.

Komunikacja z użytkownikiem: zamiast surowego błędu serwera ogłoszenie w UI - tu po migracji DB i problemach z dodawaniem utworów, z prośbą o wylogowanie i nowy kod oraz jasnym „early access”.

Zbudowaliśmy system, ale to zderzenie z użytkownikami ukształtowało z niego prawdziwy produkt.

09 - Wnioski i sukcesja

Wnioski, sukces i ostatnie słowo

Większość szkolnych projektów umiera w dniu zakończenia roku. Smart Radiowęzeł działa do dziś. Przy końcu technikum zrobiliśmy pełny handover dla młodszych roczników profili informatycznych.

Przekazaliśmy dokumentację, repozytoria na GitHubie oraz dostęp do infrastruktury (m.in. Supabase z produkcją). Że inni uczniowie podnieśli dev environment i dalej utrzymują system - to dla mnie największy sukces inżynieryjny: budowa na tyle dojrzała, że przeżyła swoich autorów.

Fizyczna warstwa produktu: plakat z dwoma krokami (sieć szkolna, QR do appki) - tyle samo ważna jak kod, gdy produkt żyje na korytarzu, a nie tylko w repozytorium.

Czego nauczyły okopy - trzy twarde wnioski

01 - UX wygrywa z checklistą „enterprise security”. Autoryzacja musi pasować do kontekstu: odrzucenie Azure AD na rzecz 4-znakowych kodów uratowało adopcję. Najbezpieczniejszy system nie jest tym, którego „nie da się zhakować”, lecz tym, którego ludzie faktycznie używają.

02 - Pragmatyczna architektura. Modularny monolit przy ~400 użytkownikach na LAN: kilka DbContextów na jednej bazie dało czytelne granice bez narzutu armii mikroserwisów. Przy bare-metalu i jednym docker-compose.yml prostota wdrożenia jest krytyczna.

03 - AI jako Guardrail, nie zabawka. Zamiast robić z LLM centralnego bohatera UI (jak w moim GroupNote), tutaj model w Pythonie jest tarczą (sentyment, SongsToCheck) - oszczędność godzin moderacji. To jest sensowne „utility AI”.

Refleksja: GroupNote nauczyło mnie zaawansowanego kodu i chmury. Radiowęzeł nauczył dostarczania produktu: kod jest narzędziem do brudnego, fizycznego problemu - dzwonek, korytarz, głośniki.

W GroupNote ten sam modularny monolit sprzyjał scope creep - kolejne moduły dosypywały się prawie bez tarcia, a zamiast MVP powstała lista ficzerów przed pierwszym prawdziwym użytkownikiem (klasyczna pułapka „jeszcze jednego ficzera” i złudzenie progresu z samego kodowania). Tutaj ta sama idea jasnych granic w kodzie posłużyła odwrotnie: trzymaliśmy zakres, który dało się utrzymać operacyjnie w jednym stacku na szkolnym LAN.

Mały zestaw przed dzwonkiem

Zamiast armii ficzerów - to, co musiało przeżyć dzwonek: głosowanie, kolejka, odtwarzanie; AI Guardrail z buforem Neutral, a nie „idealny model od dnia zero”; auth bez Azure AD; OpenTelemetry + Aspire zamiast farmy Grafany na start.

„Jeszcze jedna rzecz” - tym razem z kontekstem

I tak były hotfixe: limity pól, ogłoszenia po migracji, feedback. Różnica: kolejna iteracja wynikała z realnego zachowania uczniów, nie z listy „fajnie byłoby”; priorytetem było „działa na następnej przerwie”, nie „kompletne na slajdzie”.

Utrzymanie na żywo kosztuje - ale kupuje coś, czego nie da się zasymulować w IDE: setki telefonów w tej samej sekundzie co dzwonek. I co najważniejsze i zamyka całą tą historię. To produkt który został wdrożony i realnie zyskał użytkowników, nawet więcej niż się spodziewaliśny. Ogromna frajda i doświadczenie.

Zamykam ten case study tam, gdzie GroupNote kończyła się na archiwum: przy wdrożeniu, które przetrwało po nas.