diff --git a/OVDB_database/Migrations/20260305000000_AddTrawellingWebhookSecret.cs b/OVDB_database/Migrations/20260305000000_AddTrawellingWebhookSecret.cs new file mode 100644 index 0000000..77705ac --- /dev/null +++ b/OVDB_database/Migrations/20260305000000_AddTrawellingWebhookSecret.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace OVDB_database.Migrations +{ + /// + public partial class AddTrawellingWebhookSecret : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "TrawellingWebhookSecret", + table: "Users", + type: "longtext", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "TrawellingWebhookSecret", + table: "Users"); + } + } +} diff --git a/OVDB_database/Migrations/OVDBDatabaseContextModelSnapshot.cs b/OVDB_database/Migrations/OVDBDatabaseContextModelSnapshot.cs index be0a4c1..830a755 100644 --- a/OVDB_database/Migrations/OVDBDatabaseContextModelSnapshot.cs +++ b/OVDB_database/Migrations/OVDBDatabaseContextModelSnapshot.cs @@ -819,6 +819,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("TrawellingUsername") .HasColumnType("longtext"); + b.Property("TrawellingWebhookSecret") + .HasColumnType("longtext"); + b.HasKey("Id"); b.HasIndex("Email") diff --git a/OVDB_database/Models/User.cs b/OVDB_database/Models/User.cs index ecae08a..d505e2f 100644 --- a/OVDB_database/Models/User.cs +++ b/OVDB_database/Models/User.cs @@ -57,6 +57,12 @@ public class User /// public string TraewellingTagMappings { get; set; } + /// + /// Secret used to verify incoming Träwelling webhook requests (HMAC-SHA256 signing key). + /// Also used as a per-user token in the webhook URL path. + /// + public string TrawellingWebhookSecret { get; set; } + /// /// Collection of active refresh tokens for this user (supports multiple sessions) /// diff --git a/OV_DB/Controllers/TraewellingController.cs b/OV_DB/Controllers/TraewellingController.cs index d37128b..90a0582 100644 --- a/OV_DB/Controllers/TraewellingController.cs +++ b/OV_DB/Controllers/TraewellingController.cs @@ -3,13 +3,17 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using Newtonsoft.Json; using OV_DB.Models; using OV_DB.Services; using OVDB_database.Database; using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; using System.Threading.Tasks; namespace OV_DB.Controllers @@ -472,5 +476,124 @@ public async Task BackfillScheduledTimes() } return null; } - } -} \ No newline at end of file + + /// + /// Get webhook configuration info: URL and secret to register in Träwelling. + /// Generates a new webhook secret if none exists for the user. + /// + [HttpGet("webhook-info")] + public async Task GetWebhookInfo() + { + try + { + var userId = GetCurrentUserId(); + if (!userId.HasValue) + return Unauthorized(); + + var user = await _dbContext.Users.FindAsync(userId.Value); + if (user == null) + return NotFound("User not found"); + + var secret = await _trawellingService.GetOrGenerateWebhookSecretAsync(userId.Value); + if (secret == null) + return StatusCode(500, "Error generating webhook secret"); + + var webhookUrl = $"{Request.Scheme}://{Request.Host}/api/Traewelling/webhook/{user.Guid}"; + + return Ok(new TrawellingWebhookInfo + { + WebhookUrl = webhookUrl, + Secret = secret, + Events = new[] { "checkin_create", "checkin_update", "checkin_delete" } + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting webhook info"); + return StatusCode(500, "Error retrieving webhook info"); + } + } + + /// + /// Receive a webhook event from Träwelling (anonymous, secured via HMAC-SHA256 signature). + /// The URL path includes the user GUID to identify which OVDB user this webhook belongs to. + /// + [HttpPost("webhook/{userGuid}")] + [AllowAnonymous] + public async Task ReceiveWebhook(Guid userGuid) + { + try + { + // Find user by GUID + var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Guid == userGuid); + if (user == null) + return NotFound(); + + if (string.IsNullOrEmpty(user.TrawellingWebhookSecret)) + return Unauthorized("Webhook not configured for this user"); + + // Read raw request body for HMAC verification + Request.EnableBuffering(); + string body; + using (var reader = new StreamReader(Request.Body, Encoding.UTF8, leaveOpen: true)) + { + body = await reader.ReadToEndAsync(); + } + Request.Body.Position = 0; + + // Verify HMAC-SHA256 signature from 'Signature' header + if (!Request.Headers.TryGetValue("Signature", out var signatureHeader)) + { + _logger.LogWarning("Webhook received for user {UserGuid} without Signature header", userGuid); + return Unauthorized("Missing signature"); + } + + var expectedSignature = ComputeHmacSha256(body, user.TrawellingWebhookSecret); + if (!string.Equals(expectedSignature, signatureHeader.ToString(), StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning("Invalid webhook signature for user {UserGuid}", userGuid); + return Unauthorized("Invalid signature"); + } + + // Parse and process the webhook payload + var payload = JsonConvert.DeserializeObject(body); + if (payload == null) + return BadRequest("Invalid payload"); + + _logger.LogInformation("Received Träwelling webhook event {Event} for user {UserId}", payload.Event, user.Id); + + switch (payload.Event) + { + case "checkin_create": + await _trawellingService.ProcessWebhookCheckinCreateAsync(user, payload.Status); + break; + case "checkin_update": + await _trawellingService.ProcessWebhookCheckinUpdateAsync(user, payload.Status); + break; + case "checkin_delete": + if (payload.Status != null) + await _trawellingService.ProcessWebhookCheckinDeleteAsync(user, payload.Status.Id); + break; + default: + _logger.LogInformation("Unhandled Träwelling webhook event type: {Event}", payload.Event); + break; + } + + return Ok(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing Träwelling webhook for user {UserGuid}", userGuid); + return StatusCode(500, "Error processing webhook"); + } + } + + private static string ComputeHmacSha256(string body, string secret) + { + var keyBytes = Encoding.UTF8.GetBytes(secret); + var bodyBytes = Encoding.UTF8.GetBytes(body); + using var hmac = new HMACSHA256(keyBytes); + var hashBytes = hmac.ComputeHash(bodyBytes); + return Convert.ToHexString(hashBytes).ToLower(); + } } +} diff --git a/OV_DB/Models/TrawellingDTOs.cs b/OV_DB/Models/TrawellingDTOs.cs index 5260558..ca448f6 100644 --- a/OV_DB/Models/TrawellingDTOs.cs +++ b/OV_DB/Models/TrawellingDTOs.cs @@ -635,6 +635,23 @@ public class TrawellingIgnoreResponse public string Message { get; set; } } + // Webhook support + public class TrawellingWebhookPayload + { + [JsonProperty("event")] + public string Event { get; set; } + + [JsonProperty("status")] + public TrawellingStatus Status { get; set; } + } + + public class TrawellingWebhookInfo + { + public string WebhookUrl { get; set; } + public string Secret { get; set; } + public string[] Events { get; set; } + } + public class TrawellingStats { public int TotalTrips { get; set; } diff --git a/OV_DB/OVDBFrontend/package-lock.json b/OV_DB/OVDBFrontend/package-lock.json index 7f6deeb..ef78e90 100644 --- a/OV_DB/OVDBFrontend/package-lock.json +++ b/OV_DB/OVDBFrontend/package-lock.json @@ -606,7 +606,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -865,21 +864,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@angular-devkit/build-angular/node_modules/readdirp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", - "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular-devkit/build-angular/node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -1030,21 +1014,6 @@ } } }, - "node_modules/@angular-devkit/build-webpack/node_modules/readdirp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", - "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular-devkit/build-webpack/node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -1203,7 +1172,6 @@ "integrity": "sha512-giIMYORf8P8MbBxh6EUfiR/7Y+omxJtK2C7a8lYTtLSOIGO0D8c8hXx9hTlPcdupVX+xZXDuZ85c9JDen+JSSA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@angular-eslint/bundled-angular-compiler": "20.1.1", "eslint-scope": "^8.0.2" @@ -1233,7 +1201,6 @@ "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-21.1.1.tgz", "integrity": "sha512-OQRyNbFBCkuihdCegrpN/Np5YQ7uV9if48LAoXxT68tYhK3S/Qbyx2MzJpOMFEFNfpjXRg1BZr8hVcZVFnArpg==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -1249,7 +1216,6 @@ "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.1.1.tgz", "integrity": "sha512-lzscv+A6FCQdyWIr0t0QHXEgkLzS9wJwgeOOOhtxbixxxuk7xVXdcK/jnswE1Maugh1m696jUkOhZpffks3psA==", "license": "MIT", - "peer": true, "dependencies": { "parse5": "^8.0.0", "tslib": "^2.3.0" @@ -1579,7 +1545,6 @@ "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@inquirer/checkbox": "^4.3.2", "@inquirer/confirm": "^5.1.21", @@ -1815,21 +1780,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@angular/cli/node_modules/readdirp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", - "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular/cli/node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -1918,7 +1868,6 @@ "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.1.1.tgz", "integrity": "sha512-Di2I6TooHdKun3SqRr45o4LbWJq/ZdwUt3fg0X3obPYaP/f6TrFQ4TMjcl03EfPufPtoQx6O+d32rcWVLhDxyw==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -1935,7 +1884,6 @@ "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.1.1.tgz", "integrity": "sha512-Urd3bh0zv0MQ//S7RRTanIkOMAZH/A7vSMXUDJ3aflplNs7JNbVqBwDNj8NoX1V+os+fd8JRJOReCc1EpH4ZKQ==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -1949,7 +1897,6 @@ "integrity": "sha512-CCB8SZS0BzqLOdOaMpPpOW256msuatYCFDRTaT+awYIY1vQp/eLXzkMTD2uqyHraQy8cReeH/P6optRP9A077Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "7.28.5", "@jridgewell/sourcemap-codec": "^1.4.14", @@ -2012,7 +1959,6 @@ "resolved": "https://registry.npmjs.org/@angular/core/-/core-21.1.1.tgz", "integrity": "sha512-KFRCEhsi02pY1EqJ5rnze4mzSaacqh14D8goDhtmARiUH0tefaHR+uKyu4bKSrWga2T/ExG0DJX52LhHRs2qSw==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -2038,7 +1984,6 @@ "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.1.1.tgz", "integrity": "sha512-NBbJOynLOeMsPo03+3dfdxE0P7SB7SXRqoFJ7WP2sOgOIxODna/huo2blmRlnZAVPTn1iQEB9Q+UeyP5c4/1+w==", "license": "MIT", - "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "tslib": "^2.3.0" @@ -2068,7 +2013,6 @@ "resolved": "https://registry.npmjs.org/@angular/material/-/material-21.1.1.tgz", "integrity": "sha512-flRS8Mqf41n5lhrG/D0iPl2zyhhEZBaASFjCMSk5idUWMfwdYlKtCaJ3iRFClIixBUwGPrp8ivjBGKsRGfM/Zw==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -2100,7 +2044,6 @@ "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.1.1.tgz", "integrity": "sha512-d6liZjPz29GUZ6dhxytFL/W2nMsYwPpc/E/vZpr5yV+u+gI2VjbnLbl8SG+jjj0/Hyq7s4aGhEKsRrCJJMXgNw==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -2197,7 +2140,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -3814,7 +3756,6 @@ "resolved": "https://registry.npmjs.org/@bluehalo/ngx-leaflet/-/ngx-leaflet-21.0.0.tgz", "integrity": "sha512-VgoZAza2NZnnsu37CvO7h25OHGu5Y61yK71wucIyVttoEf96NsmYWyeYKgfOd8asVXQvIKfbKEw9R3zyW/Rfwg==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.8.0" }, @@ -7061,21 +7002,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@schematics/angular/node_modules/readdirp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", - "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@schematics/angular/node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -7561,7 +7487,6 @@ "integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/types": "8.50.0", @@ -7669,7 +7594,6 @@ "integrity": "sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -7712,7 +7636,6 @@ "integrity": "sha512-87KgUXET09CRjGCi2Ejxy3PULXna63/bMYv72tCAlDJC3Yqwln0HiFJ3VJMst2+mEtNtZu5oFvX4qJGjKsnAgg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.50.0", @@ -7986,7 +7909,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8062,7 +7984,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -8549,7 +8470,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -8740,7 +8660,6 @@ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", "license": "MIT", - "peer": true, "dependencies": { "@kurkle/color": "^0.3.0" }, @@ -9573,6 +9492,29 @@ "node": ">= 0.8" } }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.18.4", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", @@ -9784,7 +9726,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -10085,7 +10026,6 @@ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -11380,7 +11320,6 @@ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -11594,15 +11533,13 @@ "version": "1.9.4", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", - "license": "BSD-2-Clause", - "peer": true + "license": "BSD-2-Clause" }, "node_modules/leaflet.markercluster": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz", "integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==", "license": "MIT", - "peer": true, "peerDependencies": { "leaflet": "^1.3.1" } @@ -11613,7 +11550,6 @@ "integrity": "sha512-j1n1IuTX1VQjIy3tT7cyGbX7nvQOsFLoIqobZv4ttI5axP923gA44zUj6miiA6R5Aoms4sEGVIIcucXUbRI14g==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "copy-anything": "^2.0.1", "parse-node-version": "^1.0.1", @@ -11718,7 +11654,6 @@ "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", @@ -11912,7 +11847,6 @@ "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" } @@ -12355,7 +12289,6 @@ "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", "license": "MIT", - "peer": true, "engines": { "node": "*" } @@ -13373,7 +13306,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -13997,7 +13929,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", "integrity": "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^1.9.0" }, @@ -14045,7 +13976,6 @@ "integrity": "sha512-uf6HoO8fy6ClsrShvMgaKUn14f2EHQLQRtpsZZLeU/Mv0Q1K5P0+x2uvH6Cub39TVVbWNSrraUhDAoFph6vh0A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -14952,7 +14882,6 @@ "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -15175,8 +15104,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tuf-js": { "version": "4.1.0", @@ -15234,7 +15162,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15249,7 +15176,6 @@ "integrity": "sha512-Q1/6yNUmCpH94fbgMUMg2/BSAr/6U7GBk61kZTv1/asghQOWOjTlp9K8mixS5NcJmm2creY+UFfGeW/+OcA64A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/eslint-plugin": "8.50.0", "@typescript-eslint/parser": "8.50.0", @@ -15278,14 +15204,6 @@ "node": ">=20.18.1" } }, - "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", @@ -15529,7 +15447,6 @@ "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -15609,7 +15526,6 @@ "integrity": "sha512-QcQ72gh8a+7JO63TAx/6XZf/CWhgMzu5m0QirvPfGvptOusAxG12w2+aua1Jkjr7hzaWDnJ2n6JFeexMHI+Zjg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/bonjour": "^3.5.13", "@types/connect-history-api-fallback": "^1.5.4", @@ -16382,7 +16298,6 @@ "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "dev": true, "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -16476,7 +16391,6 @@ "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -16495,8 +16409,7 @@ "version": "0.15.1", "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz", "integrity": "sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==", - "license": "MIT", - "peer": true + "license": "MIT" } } } diff --git a/OV_DB/OVDBFrontend/src/app/models/traewelling.model.ts b/OV_DB/OVDBFrontend/src/app/models/traewelling.model.ts index 5c81778..4e2e1cf 100644 --- a/OV_DB/OVDBFrontend/src/app/models/traewelling.model.ts +++ b/OV_DB/OVDBFrontend/src/app/models/traewelling.model.ts @@ -191,4 +191,11 @@ export interface TrawellingTripContext { description?: string; tags: TrawellingTag[]; date: string; +} + +// Webhook configuration +export interface TrawellingWebhookInfo { + webhookUrl: string; + secret: string; + events: string[]; } \ No newline at end of file diff --git a/OV_DB/OVDBFrontend/src/app/traewelling/services/traewelling.service.ts b/OV_DB/OVDBFrontend/src/app/traewelling/services/traewelling.service.ts index 93f2836..8cbe592 100644 --- a/OV_DB/OVDBFrontend/src/app/traewelling/services/traewelling.service.ts +++ b/OV_DB/OVDBFrontend/src/app/traewelling/services/traewelling.service.ts @@ -15,7 +15,8 @@ import { RouteSearchResult, TrawellingTripContext, TrawellingTransportCategory, - RoutesListResponse + RoutesListResponse, + TrawellingWebhookInfo } from '../../models/traewelling.model'; import { TraewellingTagMapping } from '../../models/user-profile.model'; import { ApiService } from '../../services/api.service'; @@ -55,6 +56,12 @@ export class TrawellingService { .toPromise(); } + async getWebhookInfo(): Promise { + return this.http.get(`${this.baseUrl}/webhook-info`) + .pipe(first()) + .toPromise(); + } + // Route Instance linking (existing functionality) async searchRouteInstances(trip: TrawellingTrip): Promise { const params = new HttpParams() diff --git a/OV_DB/OVDBFrontend/src/app/traewelling/traewelling.component.html b/OV_DB/OVDBFrontend/src/app/traewelling/traewelling.component.html index 2cfc731..2bc1f9b 100644 --- a/OV_DB/OVDBFrontend/src/app/traewelling/traewelling.component.html +++ b/OV_DB/OVDBFrontend/src/app/traewelling/traewelling.component.html @@ -17,6 +17,71 @@ + + @if (connectionStatus?.connected) { + + + + webhook + {{ 'TRAEWELLING.WEBHOOK_TITLE' | translate }} + + {{ 'TRAEWELLING.WEBHOOK_SUBTITLE' | translate }} + + +

{{ 'TRAEWELLING.WEBHOOK_DESCRIPTION' | translate }}

+ + + @if (webhookInfo) { +
+
+ +
+ {{ webhookInfo.webhookUrl }} + +
+
+
+ +
+ {{ showWebhookSecret ? webhookInfo.secret : '••••••••••••••••••••••••••••••••' }} + + +
+
+
+ +
+ @for (event of webhookInfo.events; track event) { + {{ event }} + } +
+
+

{{ 'TRAEWELLING.WEBHOOK_HINT' | translate }}

+
+ } +
+
+ } + @if(isLoading){
diff --git a/OV_DB/OVDBFrontend/src/app/traewelling/traewelling.component.scss b/OV_DB/OVDBFrontend/src/app/traewelling/traewelling.component.scss index 1007b9d..e3fb974 100644 --- a/OV_DB/OVDBFrontend/src/app/traewelling/traewelling.component.scss +++ b/OV_DB/OVDBFrontend/src/app/traewelling/traewelling.component.scss @@ -100,6 +100,75 @@ } } +.webhook-card { + margin-bottom: 16px; + + mat-card-title { + display: flex; + align-items: center; + gap: 8px; + } +} + +.webhook-details { + margin-top: 16px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.webhook-field { + label { + display: block; + font-size: 12px; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 4px; + text-transform: uppercase; + letter-spacing: 0.5px; + } +} + +.webhook-value-row { + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; +} + +.webhook-value { + font-family: monospace; + font-size: 13px; + background: rgba(0, 0, 0, 0.06); + padding: 4px 8px; + border-radius: 4px; + word-break: break-all; + flex: 1; +} + +.webhook-events { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 4px; +} + +.webhook-event-chip { + font-family: monospace; + font-size: 12px; + background: rgba(63, 81, 181, 0.1); + color: #3f51b5; + padding: 2px 8px; + border-radius: 12px; +} + +.webhook-hint { + font-size: 12px; + color: var(--text-secondary); + margin: 0; + font-style: italic; +} + // Light theme colors :root { --text-primary: rgba(0, 0, 0, 0.87); diff --git a/OV_DB/OVDBFrontend/src/app/traewelling/traewelling.component.ts b/OV_DB/OVDBFrontend/src/app/traewelling/traewelling.component.ts index 9a91e9e..ace8848 100644 --- a/OV_DB/OVDBFrontend/src/app/traewelling/traewelling.component.ts +++ b/OV_DB/OVDBFrontend/src/app/traewelling/traewelling.component.ts @@ -5,12 +5,14 @@ import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSnackBar } from '@angular/material/snack-bar'; -import { TranslateModule } from '@ngx-translate/core'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { TrawellingService } from './services/traewelling.service'; import { TrawellingConnectionStatus, TrawellingTripsResponse, - TrawellingTrip + TrawellingTrip, + TrawellingWebhookInfo } from '../models/traewelling.model'; import { TripCardComponent } from './components/trip-card/trip-card.component'; @@ -23,6 +25,7 @@ import { TripCardComponent } from './components/trip-card/trip-card.component'; MatButtonModule, MatIconModule, MatProgressSpinnerModule, + MatTooltipModule, TranslateModule, TripCardComponent ], @@ -32,6 +35,7 @@ import { TripCardComponent } from './components/trip-card/trip-card.component'; export class TrawellingComponent implements OnInit { private trawellingService = inject(TrawellingService); private snackBar = inject(MatSnackBar); + private translate = inject(TranslateService); connectionStatus: TrawellingConnectionStatus | null = null; trips: TrawellingTrip[] = []; @@ -40,6 +44,10 @@ export class TrawellingComponent implements OnInit { hasMorePages = false; currentPage = 1; + webhookInfo: TrawellingWebhookInfo | null = null; + isLoadingWebhook = false; + showWebhookSecret = false; + async ngOnInit() { await this.loadConnectionStatus(); if (this.connectionStatus?.connected) { @@ -52,6 +60,35 @@ export class TrawellingComponent implements OnInit { this.trips = this.trips.filter(trip => trip.id !== tripId); } + async loadWebhookInfo() { + if (this.webhookInfo) { + this.webhookInfo = null; + return; + } + this.isLoadingWebhook = true; + try { + this.webhookInfo = await this.trawellingService.getWebhookInfo(); + } catch (error) { + this.snackBar.open(this.translate.instant('TRAEWELLING.WEBHOOK_TITLE'), 'Close', { duration: 5000 }); + } finally { + this.isLoadingWebhook = false; + } + } + + copyWebhookUrl() { + if (!this.webhookInfo) return; + navigator.clipboard.writeText(this.webhookInfo.webhookUrl).then(() => { + this.snackBar.open(this.translate.instant('TRAEWELLING.COPIED_URL'), 'Close', { duration: 2000 }); + }); + } + + copyWebhookSecret() { + if (!this.webhookInfo) return; + navigator.clipboard.writeText(this.webhookInfo.secret).then(() => { + this.snackBar.open(this.translate.instant('TRAEWELLING.COPIED_SECRET'), 'Close', { duration: 2000 }); + }); + } + private async loadConnectionStatus() { try { this.connectionStatus = await this.trawellingService.getConnectionStatus(); diff --git a/OV_DB/OVDBFrontend/src/assets/i18n/en.json b/OV_DB/OVDBFrontend/src/assets/i18n/en.json index b52316d..7f2964d 100644 --- a/OV_DB/OVDBFrontend/src/assets/i18n/en.json +++ b/OV_DB/OVDBFrontend/src/assets/i18n/en.json @@ -475,6 +475,20 @@ "DESCRIPTION": "Description", "ROUTESLIST.EXPORTTOTRAINLOG": "Export selection for Trainlog", "ROUTESLIST.SHOWROUTES": "Show routes", - "ROUTESLIST.SHOWINSTANCES": "Show trips" + "ROUTESLIST.SHOWINSTANCES": "Show trips", + "WEBHOOK_TITLE": "Webhook configuration", + "WEBHOOK_SUBTITLE": "Automatic trip import via Träwelling webhooks", + "WEBHOOK_DESCRIPTION": "Register this webhook in Träwelling to automatically import and update trips. When you check in on Träwelling, OVDB will attempt to match the trip to an existing route.", + "WEBHOOK_SHOW": "Show webhook configuration", + "WEBHOOK_HIDE": "Hide webhook configuration", + "WEBHOOK_URL": "Webhook URL", + "WEBHOOK_SECRET": "Webhook secret (signing key)", + "WEBHOOK_EVENTS": "Events to subscribe", + "WEBHOOK_HINT": "Register the webhook in your Träwelling account settings under API > Webhooks. Use the URL above and paste the secret as the signing key.", + "COPY": "Copy to clipboard", + "COPIED_URL": "Webhook URL copied!", + "COPIED_SECRET": "Webhook secret copied!", + "SHOW_SECRET": "Show secret", + "HIDE_SECRET": "Hide secret" } } \ No newline at end of file diff --git a/OV_DB/OVDBFrontend/src/assets/i18n/nl.json b/OV_DB/OVDBFrontend/src/assets/i18n/nl.json index 5a7f2ec..d0fc793 100644 --- a/OV_DB/OVDBFrontend/src/assets/i18n/nl.json +++ b/OV_DB/OVDBFrontend/src/assets/i18n/nl.json @@ -470,10 +470,23 @@ "LINE": "Lijn", "START_TIME": "Vertrek", "END_TIME": "Aankomst", - "DESCRIPTION": "Beschrijving" + "DESCRIPTION": "Beschrijving", + "WEBHOOK_TITLE": "Webhook configuratie", + "WEBHOOK_SUBTITLE": "Automatisch ritten importeren via Träwelling webhooks", + "WEBHOOK_DESCRIPTION": "Registreer deze webhook in Träwelling om reizen automatisch te importeren en bij te werken. Wanneer je incheckt op Träwelling, probeert OVDB de rit te koppelen aan een bestaande route.", + "WEBHOOK_SHOW": "Webhook configuratie tonen", + "WEBHOOK_HIDE": "Webhook configuratie verbergen", + "WEBHOOK_URL": "Webhook URL", + "WEBHOOK_SECRET": "Webhook geheim (ondertekeningssleutel)", + "WEBHOOK_EVENTS": "Te abonneren gebeurtenissen", + "WEBHOOK_HINT": "Registreer de webhook in je Träwelling-accountinstellingen onder API > Webhooks. Gebruik de URL hierboven en plak het geheim als ondertekeningssleutel.", + "COPY": "Kopiëren naar klembord", + "COPIED_URL": "Webhook URL gekopieerd!", + "COPIED_SECRET": "Webhook geheim gekopieerd!", + "SHOW_SECRET": "Geheim tonen", + "HIDE_SECRET": "Geheim verbergen" }, "ROUTESLIST.EXPORTTOTRAINLOG": "Exporteer selectie voor Trainlog", "ROUTESLIST.SHOWROUTES": "Toon routes", "ROUTESLIST.SHOWINSTANCES": "Toon ritten" - } \ No newline at end of file diff --git a/OV_DB/Services/ITrawellingService.cs b/OV_DB/Services/ITrawellingService.cs index 9ca13ef..590a016 100644 --- a/OV_DB/Services/ITrawellingService.cs +++ b/OV_DB/Services/ITrawellingService.cs @@ -102,5 +102,31 @@ public interface ITrawellingService /// User whose trips to backfill /// Counts of (found, updated, failed) trips Task<(int found, int updated, int failed)> BackfillScheduledTimesAsync(User user); + + /// + /// Get or generate a webhook secret for the user. + /// The secret is used to sign/verify incoming Träwelling webhook payloads (HMAC-SHA256). + /// + /// OVDB User ID + /// The webhook secret string, or null if the user was not found + Task GetOrGenerateWebhookSecretAsync(int userId); + + /// + /// Process a Träwelling checkin_create webhook event. + /// Attempts to auto-link the new checkin to a matching existing RouteInstance. + /// + Task ProcessWebhookCheckinCreateAsync(User user, TrawellingStatus status); + + /// + /// Process a Träwelling checkin_update webhook event. + /// Updates timing data on the RouteInstance linked to this status, if one exists. + /// + Task ProcessWebhookCheckinUpdateAsync(User user, TrawellingStatus status); + + /// + /// Process a Träwelling checkin_delete webhook event. + /// Removes the TrawellingStatusId link from any RouteInstance linked to this status. + /// + Task ProcessWebhookCheckinDeleteAsync(User user, int statusId); } } \ No newline at end of file diff --git a/OV_DB/Services/TrawellingService.cs b/OV_DB/Services/TrawellingService.cs index eeb6f62..51ea560 100644 --- a/OV_DB/Services/TrawellingService.cs +++ b/OV_DB/Services/TrawellingService.cs @@ -10,6 +10,7 @@ using System.Collections.Generic; using System.Linq; using System.Net.Http; +using System.Security.Cryptography; using System.Text; using System.Threading.Tasks; using System.Web; @@ -1088,6 +1089,198 @@ private async Task MapStopoverToDto(User user, Trawelling return (found, updated, failed); } + public async Task GetOrGenerateWebhookSecretAsync(int userId) + { + try + { + var user = await _dbContext.Users.FindAsync(userId); + if (user == null) + return null; + + if (!string.IsNullOrEmpty(user.TrawellingWebhookSecret)) + return user.TrawellingWebhookSecret; + + user.TrawellingWebhookSecret = GenerateWebhookSecret(); + await _dbContext.SaveChangesAsync(); + _logger.LogInformation("Generated new webhook secret for user {UserId}", userId); + return user.TrawellingWebhookSecret; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error generating webhook secret for user {UserId}", userId); + return null; + } + } + + private static string GenerateWebhookSecret() + { + var bytes = new byte[32]; + using var rng = System.Security.Cryptography.RandomNumberGenerator.Create(); + rng.GetBytes(bytes); + return Convert.ToHexString(bytes).ToLower(); + } + + public async Task ProcessWebhookCheckinCreateAsync(User user, TrawellingStatus status) + { + try + { + if (status == null) + return; + + // Check if this status is already linked to a RouteInstance + var existing = await _dbContext.RouteInstances + .FirstOrDefaultAsync(ri => ri.TrawellingStatusId == status.Id); + + if (existing != null) + { + _logger.LogInformation("Webhook checkin_create: status {StatusId} already linked to RouteInstance {RouteInstanceId}, skipping", + status.Id, existing.RouteInstanceId); + return; + } + + // Also cache the status data for later use + _memoryCache.Set("TraewellingStatus|" + status.Id, status, TimeSpan.FromMinutes(30)); + + // Try to auto-link to an existing RouteInstance + var linked = await UpdateExistingRouteInstanceWithTrawellingDataAsync(user, status); + if (linked) + { + _logger.LogInformation("Webhook checkin_create: auto-linked status {StatusId} to a RouteInstance", status.Id); + } + else + { + _logger.LogInformation("Webhook checkin_create: no matching RouteInstance found for status {StatusId}, available for manual import", status.Id); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing webhook checkin_create for status {StatusId}", status?.Id); + } + } + + public async Task ProcessWebhookCheckinUpdateAsync(User user, TrawellingStatus status) + { + try + { + if (status == null) + return; + + // Update cache + _memoryCache.Remove("TraewellingStatus|" + status.Id); + _memoryCache.Remove("TrawellingTrip|" + status.Id); + _memoryCache.Set("TraewellingStatus|" + status.Id, status, TimeSpan.FromMinutes(30)); + + // Find any RouteInstance linked to this status + var routeInstance = await _dbContext.RouteInstances + .Include(ri => ri.Route) + .FirstOrDefaultAsync(ri => ri.TrawellingStatusId == status.Id); + + if (routeInstance == null) + { + _logger.LogInformation("Webhook checkin_update: no RouteInstance linked to status {StatusId}", status.Id); + return; + } + + var updated = false; + + // Update actual times + if (status.Train?.Origin?.Departure.HasValue == true) + { + var newStartTime = (status.Train.ManualDeparture ?? status.Train.Origin.Departure).Value.DateTime; + if (routeInstance.StartTime != newStartTime) + { + routeInstance.StartTime = newStartTime; + updated = true; + } + } + + if (status.Train?.Destination?.Arrival.HasValue == true) + { + var newEndTime = (status.Train.ManualArrival ?? status.Train.Destination.Arrival).Value.DateTime; + if (routeInstance.EndTime != newEndTime) + { + routeInstance.EndTime = newEndTime; + updated = true; + } + } + + // Update scheduled times + if (status.Train?.Origin?.DeparturePlanned.HasValue == true) + { + var newScheduledStart = status.Train.Origin.DeparturePlanned.Value.DateTime; + if (routeInstance.ScheduledStartTime != newScheduledStart) + { + routeInstance.ScheduledStartTime = newScheduledStart; + updated = true; + } + } + + if (status.Train?.Destination?.ArrivalPlanned.HasValue == true) + { + var newScheduledEnd = status.Train.Destination.ArrivalPlanned.Value.DateTime; + if (routeInstance.ScheduledEndTime != newScheduledEnd) + { + routeInstance.ScheduledEndTime = newScheduledEnd; + updated = true; + } + } + + // Recalculate duration if times changed + if (updated && routeInstance.StartTime.HasValue && routeInstance.EndTime.HasValue) + { + routeInstance.DurationHours = _timezoneService.CalculateDurationInHours( + routeInstance.StartTime.Value, + routeInstance.EndTime.Value, + routeInstance.Route?.LineString); + } + + if (updated) + { + await _dbContext.SaveChangesAsync(); + _logger.LogInformation("Webhook checkin_update: updated RouteInstance {RouteInstanceId} for status {StatusId}", + routeInstance.RouteInstanceId, status.Id); + } + else + { + _logger.LogDebug("Webhook checkin_update: no timing changes for RouteInstance {RouteInstanceId}", routeInstance.RouteInstanceId); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing webhook checkin_update for status {StatusId}", status?.Id); + } + } + + public async Task ProcessWebhookCheckinDeleteAsync(User user, int statusId) + { + try + { + // Remove cached data + _memoryCache.Remove("TraewellingStatus|" + statusId); + _memoryCache.Remove("TrawellingTrip|" + statusId); + + // Find and unlink any RouteInstance linked to this status + var routeInstance = await _dbContext.RouteInstances + .FirstOrDefaultAsync(ri => ri.TrawellingStatusId == statusId); + + if (routeInstance == null) + { + _logger.LogInformation("Webhook checkin_delete: no RouteInstance linked to status {StatusId}", statusId); + return; + } + + routeInstance.TrawellingStatusId = null; + await _dbContext.SaveChangesAsync(); + + _logger.LogInformation("Webhook checkin_delete: unlinked RouteInstance {RouteInstanceId} from deleted status {StatusId}", + routeInstance.RouteInstanceId, statusId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing webhook checkin_delete for status {StatusId}", statusId); + } + } + private async Task ExecuteWithExponentialBackoffAsync(Func> request, int maxRetries = 5) { int attempt = 0;