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).
-
GetEventireturns only approved events. - Neither the client nor the server trusts the state sent by a client: the
statofield 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
AdminandEditor. - Events reported by
Admin/Editorare published immediately. - From
/gestione/revisioneyou 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:
-
Push: events created or edited on the device (marked as pending) are sent with
PUT /api/Eventi/UpdateEventi. -
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. - 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"
}
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/Editortoken: anyone else gets403. 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 overwritesX-Forwarded-For, so the per-IP limits cannot be dodged. -
CORS limited to the origins listed in
Cors:Origins, and a restrictedAllowedHosts.
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
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.mdfile. 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
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
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)