# 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