Files
2026-02-23 14:02:44 +01:00

174 lines
6.7 KiB
Markdown

# HotlinePlanner Sync — Dev Log
## Stack
- **Runtime**: ASP.NET Core 10.0 (net10.0)
- **Auth**: Microsoft.Identity.Web 4.3.0 — Confidential Client / OIDC
- **Graph**: Microsoft.Graph 5.103.0 (SDK v5)
- **Database**: SQLite + EF Core 10.0.3
- **Real-time**: ASP.NET Core SignalR (built-in)
- **Token cache**: In-memory (prototype) — lost on restart
---
## Setup Checklist
### 1. Azure App Registration
1. Go to [https://portal.azure.com](https://portal.azure.com) → App registrations → New registration
2. Name: `HotlinePlannerSync` | Supported account types: **Single tenant** (or multitenant as needed)
3. Redirect URI: `https://YOUR-NGROK-URL.ngrok-free.app/signin-oidc` (Web)
4. After creation, note: **Application (client) ID** and **Directory (tenant) ID**
5. Certificates & secrets → New client secret → copy the **Value** (not the ID)
6. API permissions → Add: `Calendars.Read` (Delegated), `offline_access` (Delegated) → Grant admin consent
### 2. ngrok (local webhook exposure)
```bash
# Install ngrok from https://ngrok.com
ngrok http 5001 --scheme https
# Copy the HTTPS URL, e.g. https://abc123.ngrok-free.app
```
Update `appsettings.Development.json`:
```json
"Webhook": {
"BaseUrl": "https://abc123.ngrok-free.app",
"ClientState": "your-random-secret-string"
}
```
**Important**: ngrok URL changes on each restart unless you have a paid plan with a static domain.
### 3. Fill appsettings
In `appsettings.Development.json` (or use `dotnet user-secrets`):
```json
"AzureAd": {
"TenantId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"ClientId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"ClientSecret": "your~client~secret~value"
}
```
Prefer **user-secrets** to keep credentials out of source control:
```bash
dotnet user-secrets set "AzureAd:TenantId" "xxx"
dotnet user-secrets set "AzureAd:ClientId" "xxx"
dotnet user-secrets set "AzureAd:ClientSecret" "xxx"
dotnet user-secrets set "Webhook:BaseUrl" "https://xxx.ngrok-free.app"
dotnet user-secrets set "Webhook:ClientState" "my-secret-state"
```
### 4. Run
```bash
dotnet run
# App starts on https://localhost:5001 (or http://localhost:5000)
```
---
## Architecture
```
Browser ──── OIDC login ──────────────────► Microsoft Entra ID
│ │
│ ◄─── redirect + tokens ─────────────┘
HomeController.Onboard()
├─ Upsert UserConfig (SQLite)
├─ SubscriptionService.CreateSubscription() ──► Graph POST /subscriptions
└─ DeltaSyncService.Sync() [initial] ──► Graph GET /me/calendarView/delta
Microsoft Graph ──── POST /api/notifications/listen ──────► NotificationsController
├─ Handshake? → return validationToken as text/plain
├─ Log to WebhookLogs (FIRST)
├─ Verify ClientState
├─ Return 202 Accepted
└─ Fire & forget: DeltaSyncService.Sync(userId)
└─► Graph GET /me/calendarView/delta?$deltaToken=...
├─ Page through results
├─ Deduplicate (SHA-256 of Subject+Start+End)
└─ Save new DeltaLink to UserConfig
SubscriptionRenewalBackgroundService (every 12h)
└─ Find subscriptions expiring < 24h
└─ PATCH /subscriptions/{id} with new ExpirationDateTime
DashboardHub (SignalR)
└─ All events flow through ActivityLogger → SignalR push → live feed in browser
```
---
## Database Tables
### UserConfigs
| Column | Type | Notes |
|---|---|---|
| UserId | TEXT PK | Azure AD Object ID |
| Email | TEXT | preferred_username claim |
| DisplayName | TEXT | name claim |
| SubscriptionId | TEXT | Graph subscription ID |
| SubscriptionExpiration | DATETIME | When subscription expires |
| SubscriptionStatus | TEXT | None / Active / Expiring / Expired |
| LastDeltaLink | TEXT | Full delta URL for incremental sync |
| CreatedAt | DATETIME | First login |
| LastSyncAt | DATETIME | Last successful delta sync |
| TotalEventsSynced | INT | Running count |
| TenantId | TEXT | Azure AD Tenant ID |
### WebhookLogs
| Column | Type | Notes |
|---|---|---|
| Id | INT PK | Auto-increment |
| ReceivedAt | DATETIME | UTC timestamp |
| RawPayload | TEXT | Full JSON body |
| EndpointType | TEXT | Listen / Lifecycle |
| IsHandshake | BOOL | Validation token request |
| ClientState | TEXT | Received client state |
| SubscriptionId | TEXT | From notification |
| UserId | TEXT | Extracted from resource path |
| ChangeType | TEXT | created / updated / deleted |
| LifecycleEvent | TEXT | reauthorizationRequired etc. |
| ProcessingStatus | TEXT | Received / Processing / Completed / Failed / Skipped |
| ErrorMessage | TEXT | If failed |
| EventsProcessed | INT | New/updated from delta |
| EventsDeduplicated | INT | Filtered by hash |
---
## Key Endpoints
| Endpoint | Auth | Description |
|---|---|---|
| `GET /` | Public | Landing page |
| `GET /Home/Login` | Public | Triggers OIDC flow |
| `GET /Home/Onboard` | Authenticated | Post-login onboarding |
| `GET /Sync` | Authenticated | Live dashboard |
| `GET /sync/data` | Authenticated | JSON data for dashboard AJAX |
| `POST /sync/manual` | Authenticated | Trigger manual delta sync |
| `GET /sync/log/{id}` | Authenticated | Raw payload for a log entry |
| `POST /api/notifications/listen` | **Anonymous** | Graph change notifications |
| `POST /api/notifications/lifecycle` | **Anonymous** | Graph lifecycle notifications |
---
## Delta Query Notes
- **Initial sync**: `GET /me/calendarView/delta?startDateTime=now-30d&endDateTime=now+90d`
- **Incremental sync**: `GET {stored_delta_link}` (URL already contains all params)
- Delta link stored per-user in `UserConfigs.LastDeltaLink`
- If delta link is gone (410 Gone), the code will log an error; manually clear `LastDeltaLink` to force a full re-sync
## Token Cache Notes
- `AddInMemoryTokenCaches()` stores tokens in RAM — lost on app restart
- Users must re-login after restart for tokens to repopulate
- For production: replace with `AddDistributedTokenCaches()` + Redis/SQL
---
## Changes from Original Spec
- Target: **.NET 10.0** (not 8.0 — SDK 10.0.102 is installed)
- Excel/OneDrive output: **removed** (per user request)
- Real-time dashboard: **SignalR** push (not polling)
- Dashboard shows everything: auth events, subscription creation, webhook handshakes, delta syncs, renewals