174 lines
6.7 KiB
Markdown
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
|