.NET 9 solution: Chateando.sln.
From backend/:
dotnet run --project src/Chateando.Api/Chateando.Api.csprojDefault URLs are https://localhost:7118 and http://localhost:5095 (src/Chateando.Api/Properties/launchSettings.json). Pick an explicit profile:
dotnet run --project src/Chateando.Api/Chateando.Api.csproj --launch-profile httpsSet ConnectionStrings:DefaultConnection (for example in src/Chateando.Api/appsettings.Development.json) before using the database. Default dev connection (PostgreSQL / Npgsql):
Host=localhost;Port=5432;Database=chateando;Username=chateando;Password=chateando_dev_password
Apply SQL migrations (then exit) with:
dotnet run --project src/Chateando.Api/Chateando.Api.csproj -- migrateIf Postgres runs in Docker (docker compose up), the container password comes from the repo root .env → POSTGRES_PASSWORD. Local dotnet run does not load .env; it uses appsettings.Development.json or User Secrets (UserSecretsId in Chateando.Api.csproj).
Keep the password the same in .env and appsettings.Development.json (see root README.md).
28P01: password authentication failed — wrong password for user chateando, or .env was changed after the postgres_data volume was first created. Fix: align passwords, or docker compose down -v and recreate the volume with a matching .env.
The Angular dev apps run at http://localhost:4200 (chat) and http://localhost:4201 (admin); the API CORS policy allows those origins in development.
src/Chateando.Api— HTTP host, minimal API maps (Features/Auth,Features/Departments,Features/Chat,Features/Integrations,Features/Announcements,Features/Admin), SignalR hub map, JWT and API-key wiring. References Application and Infrastructure only.src/Chateando.Domain— shared entities (Department,User,Conversation,Message,Announcement). No application or web references.src/Chateando.Application— cross-cutting use-case contracts and DTOs (Auth,Departments,Chat,Announcements,Configuration). References Domain only.src/Chateando.Infrastructure— EF CoreAppDbContext(Npgsql provider), SQL migration runner, embedded versioned PostgreSQL scripts underDatabase/Sql/, JWT auth (AuthService,JwtTokenFactory), and feature implementations. References Application and Domain.
Relational tables: Departments, Users, Conversations, Messages, Announcements (plus "__SchemaVersion" for the migration journal). Both department and user tables use int GENERATED ALWAYS AS IDENTITY primary keys (introduced in V001). All schema objects use quoted PascalCase identifiers (PostgreSQL is case-sensitive when quoted). Auth uses JWT only at the API layer; passwords are stored as hashes in Users.PasswordHash (no ASP.NET Identity tables).
V005 adds DM chat: Conversations (ordered pair UserAId / UserBId, same id for self-chat) and Messages. REST: GET /api/chat/directory, GET /api/chat/conversations/{peerUserId}/messages (authorized). Real time: SignalR hub /hubs/chat, methods SendMessage(recipientUserId, body, threadRootMessageId?, replyToMessageId?) and SendGroupMessage(conversationId, body, threadRootMessageId?, replyToMessageId?) (deploy chat UI and API together when changing hub signatures); JWT via query access_token on hub paths.
V006 adds notifications (inbox + Web Push subscriptions). See migration V006__notifications.sql.
V007 adds message read receipts (V007__message_read_states.sql): table ConversationReadStates (ConversationId, UserId, LastReadMessageId, ReadAtUtc). Cursors are monotonic per user per conversation; rows for removed members may remain but counts join current ConversationMembers only. REST (authorized):
| Method | Route |
|---|---|
| POST | /api/chat/mark-read — body { dmPeerUserId?, conversationId?, upToMessageId } (separate from notification inbox mark-read-for-chat) |
| GET | /api/chat/messages/{messageId}/readers — sender only (403 otherwise) |
History and send responses attach readReceipt on the caller's own messages (isFullyRead, readCount, totalCount). SignalR: ConversationReadUpdated (conversationId, userId, lastReadMessageId, readAtUtc) — DM notifies the other participant; groups broadcast to conv-{conversationId}. ReadAtUtc is the time the user read up to that cursor (same timestamp may appear on several messages in one visit).
V008 adds message threads and replies (V008__message_threads.sql): columns ThreadRootMessageId (NULL = main timeline) and ReplyToMessageId (SET NULL when quoted message is deleted). One thread level only (no nested threads). Main timeline lists roots and main-line replies in chronological order (replies are not nested under the quoted bubble). Create thread is only on main messages; Reply works in main chat or in the thread panel. REST send (text and multipart) accepts optional threadRootMessageId / replyToMessageId; GET /api/chat/messages/{messageId}/thread pages thread replies (resolves a thread-child id to its anchor). Push/inbox payloads may include threadRootMessageId and deep links ?thread={anchorId}. Unread badges (v1) count thread traffic; auto mark-read while the chat is open only advances for thread messages when that thread panel is open. BUZZ messages cannot thread or reply. Integration API does not expose thread/reply params in v1.
Groups (schema in earlier migrations): multi-user group conversations: Conversations.Kind (0=Direct, 1=Group), optional Name and CreatedByUserId, nullable UserAId/UserBId for groups, table ConversationMembers, and DM member backfill. DM routes are unchanged. Group REST (authorized):
| Method | Route |
|---|---|
| GET | /api/chat/groups |
| POST | /api/chat/groups |
| GET | /api/chat/groups/{conversationId} |
| POST | /api/chat/groups/{conversationId}/members |
| DELETE | /api/chat/groups/{conversationId}/members/{userId} |
| GET/POST | /api/chat/groups/{conversationId}/messages |
Rules (v1): any authenticated user can create a group; only CreatedByUserId may add/remove members (creator may remove themselves if at least one member remains; non-creators cannot self-leave). Max 50 members. Non-members or DM ids on group routes → 404; member but not creator on member management → 403. File download is authorized via ConversationMembers (DM rows are backfilled). SignalR: hub method SendGroupMessage(conversationId, body); clients join groups conv-{conversationId} on connect; event GroupMembershipChanged (action: added | removed | deleted) updates live membership. Buzz (DM only): hub SendBuzz(targetUserId) persists a message with body BUZZ (notifies both parties via MessageReceived / MessageSent) and sends BuzzReceived to the target only (client uses it for a ~2s chat shake).
Configure uploads and storage with Chat:FileStorageRoot (see appsettings.json). The API sets multipart and Kestrel body limits for large uploads; reverse proxies may still impose their own caps.
The API serves desktop update artifacts from /desktop-updates/* using a physical folder configured by:
DesktopUpdates:Path(environment variable:DesktopUpdates__Path)
Expected files are latest.yml, installer .exe, and .blockmap. latest.yml is served with Cache-Control: no-store.
In Docker Compose, map a persistent volume to this path (default used in this repo: /app/desktop-updates) so updates survive container recreation.
Presence (SignalR hub /hubs/chat): clients receive PresenceSnapshot on connect and PresenceChanged when any user goes online, idle, or offline. Idle threshold: Chat:PresenceIdleMinutes (default 5). Hub method Heartbeat resets activity; sending a message (hub or REST upload) also counts. State is in-memory per API process (not shared across scaled instances without a SignalR backplane).
V008 adds global announcements. V009 adds audience targeting, optional file attachments, and per-recipient delivery.
Tables: Announcements (Title, nullable Body, AudienceKind, optional TargetDepartmentId, file columns, CreatedByUserId, CreatedAtUtc), AnnouncementRecipients (AnnouncementId, UserId). Any authenticated user may publish to all users, a department, or selected user ids (author is always included). List and file download are limited to recipients only.
REST (authorized):
| Method | Route |
|---|---|
| GET | /api/announcements?skip=&take= — announcements where the caller is a recipient |
| POST | /api/announcements — multipart/form-data only (breaking vs JSON): title, audienceKind (all | department | users), optional departmentId, userIds (JSON array string when users), optional body, optional file |
| GET | /api/announcements/{id}/file — download attachment if caller is a recipient |
At least one of body (non-empty) or file is required. Files use the same storage and blocked extensions as chat (IChatFileStorage).
SignalR: after publish, AnnouncementPublished is sent only to Clients.Users for resolved recipient ids (not Clients.All).
V010 adds Users.IsAdmin (JWT claim isAdmin, /api/auth/me field). Admin REST (authorized; v1 does not require isAdmin):
| Method | Route |
|---|---|
| GET/POST/PUT/DELETE | /api/admin/users |
| GET/POST/PUT/DELETE | /api/admin/departments |
| GET/POST/PUT/DELETE | /api/admin/groups (+ members sub-routes) |
Deleting a group notifies members via SignalR GroupMembershipChanged with action: deleted. User delete returns 409 when the user has chat or announcement references.
Server-to-server endpoint for ERP, alerts, or other backends to post into a group by conversationId (the group id from GET /api/chat/groups or admin groups API). Messages appear from the system user Integrations:SenderUserName (default Chateando, created by migration V005 if missing). The Chateando user does not need to be a group member. Do not rely on a fixed numeric user id — lookup is by username.
Configure Integrations:ApiKey (min 8 characters; Docker: Integrations__ApiKey from INTEGRATION_API_KEY in .env). Startup fails if the key is missing or too short.
| Method | Route | Auth |
|---|---|---|
| POST | /api/integrations/groups/{conversationId}/messages |
X-Api-Key: <secret> or Authorization: Bearer <secret> |
Text only (JSON): body must be valid JSON with quoted property names. Accepted keys: "text" or "body" (case-insensitive).
POST /api/integrations/groups/42/messages
X-Api-Key: <secret>
Content-Type: application/json
{"text":"Order 1234 shipped"}Invalid (returns 400, not 500): {text: "..."} (unquoted keys), text=hello with JSON content-type, or empty body.
PowerShell curl: use single quotes so # is not treated as a comment:
curl.exe -X POST "http://localhost:5095/api/integrations/groups/42/messages" `
-H "X-Api-Key: <secret>" `
-H "Content-Type: application/json" `
-d '{"text":"Order 1234 shipped"}'Use the real conversationId from GET /api/chat/groups (field conversationId).
File or image (multipart): same fields as chat — optional form field body, file part file (or first file). Images use normal image content types (e.g. image/png).
POST /api/integrations/groups/42/messages
X-Api-Key: <secret>
Content-Type: multipart/form-dataResponse 200 with the same ChatMessageDto shape as POST /api/chat/groups/{id}/messages. Connected clients receive SignalR MessageReceived on conv-{conversationId}.
Security: treat the API key like a password; use only on trusted networks (VPN/internal), rotate periodically, and do not expose the API on the public internet without additional controls.
The database stores only relative paths (yyyy/MM/dd/...) in Messages.FileRelativePath. To move blobs to another disk, NAS, or mount: copy or sync the existing tree so that those relative segments stay the same, point Chat:FileStorageRoot at the new root, and restart the API. You do not need to rewrite paths in the database. In hosting environments you can override the setting with the Chat__FileStorageRoot environment variable.
Dev users (after migrations): username test1 / test2 (ids 1 / 2), password Test123!. Login uses username, not email.
DDL is applied by ISqlMigrationRunner, not Database.Migrate(). Scripts are embedded resources named like *.V001__description.sql (three-digit version). Applied versions are recorded in the PostgreSQL table "__SchemaVersion" (quoted identifier, no schema prefix).
Current migration set is V001–V008 (all PostgreSQL, idempotent). V001 creates the full base schema (Departments, Users, Conversations, Messages, plus IDENTITY primary keys and seed data). Subsequent versions add reactions (V002), announcement type (V003), the integration sender (V004/V005), notifications (V006), read receipts (V007), and message threads (V008). If you want a clean slate in dev, drop the database (or docker compose down -v) and re-run migrate; do not delete rows from "__SchemaVersion" while keeping the data, because re-running scripts will fail on existing objects unless every statement is idempotent.
From backend/:
dotnet run --project src/Chateando.Api/Chateando.Api.csproj -- migrateRequires a valid ConnectionStrings:DefaultConnection (for example in appsettings.Development.json). Password must match your Postgres instance (Docker: same as POSTGRES_PASSWORD in .env).
When you change the relational model:
- Add a new script under
src/Chateando.Infrastructure/Database/Sql/with the next version number (V002__...sql, and so on). - Keep
AppDbContextfluent configuration aligned with the database so the EF model matches what SQL created.
Scripts run as a single PostgreSQL batch (no statement terminator like SQL Server's GO). Prefer idempotent DDL/DML using PostgreSQL constructs: CREATE TABLE IF NOT EXISTS …, CREATE INDEX IF NOT EXISTS … (or DO $$ BEGIN … EXCEPTION WHEN duplicate_object THEN NULL; END $$; for constraints), ALTER TABLE … ADD COLUMN IF NOT EXISTS …, and INSERT … ON CONFLICT … DO NOTHING for seed data so a script can be re-applied safely when repairing a database.
Databases that previously used EF __EFMigrationsHistory may still have that table; it is unused by this runner. You can drop it after confirming the schema matches, or leave it for history.
There are no checked-in EF migrations. A design-time factory exists for tooling (for example validating the model against a database). If you introduce EF migrations for comparison or scaffolding, point --project at src/Chateando.Infrastructure/Chateando.Infrastructure.csproj and --startup-project at the API.
Offline desktop notifications use Web Push (WebPush section in configuration). Set PublicKey, PrivateKey, and Subject (mailto or HTTPS URL). Development defaults live in appsettings.Development.json; production values belong in environment variables or .env (see repo .env.example). Generate a key pair:
npx web-push generate-vapid-keysApply schema V006__notifications.sql (inbox + UserPushSubscriptions) via the SQL migration runner on startup. REST routes are under /api/notifications (VAPID public key, subscribe/unsubscribe, inbox list, unread count, mark read).
dotnet build Chateando.sln
dotnet test Chateando.slnTest projects mirror layers under tests/:
Chateando.Domain.TestsChateando.Application.TestsChateando.Infrastructure.Tests
More detail: .cursor/skills/backend-clean-architecture/SKILL.md. Full-stack run steps: ../README.md.