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;