DEV Community

Cover image for SagreEventi: an offline-first app for Italian food festivals
Angelo Pirola
Angelo Pirola

Posted on

SagreEventi: an offline-first app for Italian food festivals

October in Italy means chestnuts and new wine. Every weekend there is a sagra (a local food festival) somewhere, but finding them means jumping between town websites, social media groups and word of mouth. That is how SagreEventi was born: a place where people who go to these festivals can report them and browse them, without creating an account.

The code is open source (MIT license): AngeloDotNet/SagreEventi.SQLServer.

In this article I walk through what the app does, the main technical choices and the points where I stopped to think.

What it does

The idea is deliberately simple. Anyone can:

  • browse events: a home page with the upcoming events hanging from a festival bunting, a searchable catalog and a month-by-month agenda;
  • filter and sort by free text (accent-insensitive), city and period (in progress, upcoming, finished), in a grid or list view;
  • report a new event (name, city, dates, description) with a validated form;
  • take it with them: add the event to their calendar (.ics), open the city on a map, share it.

Users with the Admin or Editor role sign in with email and password (ASP.NET Core Identity, bearer tokens) and can edit existing events and moderate reports. Public registration is disabled: users are created by the seed at start-up.

What it does not do: it does not sell tickets and does not handle bookings. Keeping payments and ticketing out of scope simplified the project a lot.

The stack

API

  • ASP.NET Core (.NET 10) with Minimal API
  • Entity Framework Core + SQL Server
  • ASP.NET Core Identity with roles
  • Rate limiting, native OpenAPI, FusionCache

Client

  • Angular 21 (standalone components, signals, zoneless)
  • PrimeNG 21 with a custom Aura preset, PrimeIcons
  • Just Angular, PrimeNG and RxJS: no state library, no CSS framework

A note on method: the API is a handful of endpoints, so a new client is a weekend of work. There is a single contract and it can serve several clients. The project started with a Blazor WebAssembly client, which I later removed in favor of Angular + PrimeNG.

Why Angular 21 and not 22?

This choice is worth explaining. The latest PrimeNG release published under the MIT license is 21.1.x, which supports Angular 21. PrimeNG 22 adds Angular 22 support but introduces a license key (with a free Community tier that requires registration). To stay fully free and officially compatible, I pinned the client to Angular 21 and PrimeNG ^21.1.10. Before upgrading, it is worth rereading PrimeTek's current terms.

Moderation: trust reporters a little less

If anyone can add events, you need a filter. Every report has a state: InAttesa (pending, the default for anonymous users), Approvato (approved) or Rifiutato (rejected).

  • GetEventi returns only approved events.
  • Neither the client nor the server trusts the state sent by a client: the stato field is output only.
  • Whoever reports an event sees it as pending on their own device (the «Le mie» section). If the event is rejected, the reporter is not notified: it keeps showing as pending. The rejection reason is a note visible only to Admin and Editor.
  • Events reported by Admin/Editor are published immediately.
  • From /gestione/revisione you can approve or reject reports in bulk (up to 100 at a time) and withdraw an event that was already approved.

A detail I like: the API has no DELETE. A withdrawn event becomes a rejected, concluded event, and clients use that to delete their local copy at the next sync. A background service automatically concludes events whose end date has passed (the interval is configurable with Eventi:ScadenzaIntervalloMinuti), and a concluded event can no longer be edited by anyone.

Offline-first and sync

At festivals, especially in the countryside, mobile coverage is often a gamble. That is why the Angular client keeps a local copy of the events (in localStorage) and reconciles it with SQL Server using a three-step protocol:

  1. Push: events created or edited on the device (marked as pending) are sent with PUT /api/Eventi/UpdateEventi.
  2. Pull: changes made by others are fetched with GET /api/Eventi/GetEventi?since=<cursor>, where the cursor is the latest modification time returned by the server.
  3. Merge: server data replaces local data, unless the local copy is pending and newer. Concluded events are removed.

A sync runs at start-up, when the browser comes back online and when you press the button in the top bar, which also shows how many changes are waiting.

An event looks like this (JSON, camelCase):

{
  "id": "0b1e9e8e-5c0f-4a5e-8d0e-6b1a2f6c9a11",
  "nomeEvento": "Sagra della castagna",
  "cittaEvento": "Soriano nel Cimino",
  "dataInizioEvento": "2026-10-24T00:00:00",
  "dataFineEvento": "2026-10-26T00:00:00",
  "descrizioneEvento": "Caldarroste, vino novello e musica dal vivo.",
  "eventoConcluso": false,
  "dataOraUltimaModifica": "2026-10-22T18:30:00.000Z",
  "stato": "Approvato"
}
Enter fullscreen mode Exit fullscreen mode

The server sets dataOraUltimaModifica on every write: the value sent by the client is only used to decide which version is newer.

Security: public APIs, but not defenseless

Anonymous write endpoints call for a few precautions:

  • Per-IP rate limiting on writes and login, with a maximum of 20 events per request.
  • Changes to existing events only with an Admin/Editor token: anyone else gets 403. New events in the same request are still inserted.
  • Length limits on fields (name 200, city 100, description 4000, and so on): longer values get 400.
  • X-Forwarded-* headers are trusted only from the Docker network, and nginx overwrites X-Forwarded-For, so the per-IP limits cannot be dodged.
  • CORS limited to the origins listed in Cors:Origins, and a restricted AllowedHosts.

The integration test suite (xUnit with in-memory SQLite) covers events, moderation, users, login, rate limits, forwarded headers, CORS and malicious input.

Docker in one command

docker-compose.yml starts four containers on a private network:

Service Role
sqlserver SQL Server 2022, data in the sqldata volume
api The API on port 8080 (internal only), non-root user, health check on /healthz
web The built Angular client, served by an unprivileged nginx
proxy A dedicated nginx: the only public entry point (80/443), terminates HTTPS

The client and the API share one origin, so production needs no CORS and apiBaseUrl stays empty in config.json: a single build runs in every environment.

To try it:

cp .env.example .env     # fill in the variables
# create deploy/certs/fullchain.pem and privkey.pem (a self-signed certificate is fine for a trial)
docker compose up --build
Enter fullscreen mode Exit fullscreen mode

Then open https://localhost and sign in with ADMIN_EMAIL / ADMIN_PASSWORD from the .env file. EF Core migrations run at start-up and the Admin user is created.

A fair warning: the Docker files are written but not yet tried on a real Docker host. The manual checks to run are in the repository's TEST-MANUALI.md file. The compose file is meant for trials and small deployments: for production you need real certificates, a managed or backed-up SQL Server and a secret store instead of .env.

Running it locally (without Docker)

You need the .NET 10 SDK, a SQL Server instance and Node.js (^20.19, ^22.12 or ^24).

git clone https://github.com/AngeloDotNet/SagreEventi.SQLServer.git
cd SagreEventi.SQLServer/src/SagreEventi.Web.Server
dotnet run
Enter fullscreen mode Exit fullscreen mode

The connection string and the first users go in appsettings.local.json (git-ignored). Then the client:

cd src/SagreEventi.Web.Angular
npm install
npm start
Enter fullscreen mode Exit fullscreen mode

Open http://localhost:4200: in development, requests to /api are forwarded to https://localhost:5001, so there is nothing else to configure.

What is missing (and what comes next)

The roadmap is as honest as the rest:

  • try the Docker stack on a real host and tick off the manual checks;
  • an installable offline app shell (service worker);
  • server-side search and pagination once the catalog grows;
  • automated tests for the Angular client's sync logic (the API already has its own);
  • geolocation: events near you.

Conclusion

SagreEventi is a small project, but it brings together quite a few concrete topics: public APIs with moderation, offline sync, roles, rate limiting, deployment behind a reverse proxy. If you need inspiration for an app with a .NET API and an Angular client, the repository can be a good starting point.

If the project is useful to you, a ⭐ on GitHub helps others find it. Issues and pull requests are welcome (starting from the develop branch).

And you, how do you handle offline sync in your apps? Let me know in the comments.

Top comments (0)