6.7 KiB
6.7 KiB
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
- Go to https://portal.azure.com → App registrations → New registration
- Name:
HotlinePlannerSync| Supported account types: Single tenant (or multitenant as needed) - Redirect URI:
https://YOUR-NGROK-URL.ngrok-free.app/signin-oidc(Web) - After creation, note: Application (client) ID and Directory (tenant) ID
- Certificates & secrets → New client secret → copy the Value (not the ID)
- API permissions → Add:
Calendars.Read(Delegated),offline_access(Delegated) → Grant admin consent
2. ngrok (local webhook exposure)
# 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:
"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):
"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:
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
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 |
| 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
LastDeltaLinkto 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