feat(feeds): subscribable ICS calendar feeds for trips

Adds TripIt-style live calendar subscriptions alongside the existing one-time
.ics download. A trip (or all of a user's trips) exposes a secret, revocable
feed URL that Google/Apple/Outlook poll to stay in sync.

- Public read endpoints GET /api/feed/trip/:token.ics and /api/feed/user/:token.ics
  (no auth — the secret token is the credential), reusing the existing exportICS()
  generator and adding REFRESH-INTERVAL / X-PUBLISHED-TTL hints.
- JWT-guarded token endpoints to generate (lazy, idempotent) and regenerate/revoke
  per-trip and per-user feed tokens; tokens stored in nullable feed_token columns.
- All-trips feed excludes archived trips and trips ended >90 days ago.
- UI: ICS toolbar button becomes a Download/Subscribe menu; modal offers one-click
  "Add to Google Calendar" (render?cid=webcal://) and a webcal:// link for
  Apple/Outlook, plus copy-link fallbacks. All-trips feed reachable from dashboard.
- Feed base URL read from the existing APP_URL env var.

Purely additive: new endpoints + two nullable columns, no breaking changes.

Tests: server/tests/e2e/feeds.e2e.test.ts covers lazy token generate + idempotency,
regenerate-invalidates-old, 401/404 auth+access, public feed content-type + hint
injection, unknown-token 404, and the archived/>90-day all-trips exclusion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
michael-bohr
2026-06-24 20:30:27 -04:00
committed by Maurice
parent 72dfa2c60c
commit 7173e82fe8
12 changed files with 920 additions and 35 deletions
+15
View File
@@ -3078,6 +3078,21 @@ function runMigrations(db: Database.Database): void {
if (!err.message?.includes('duplicate column name')) throw err;
}
},
// Calendar feed tokens — subscribable ICS links for per-trip and all-trips feeds
() => {
try {
db.exec('ALTER TABLE trips ADD COLUMN feed_token TEXT');
} catch (err: any) {
if (!err.message?.includes('duplicate column name')) throw err;
}
try {
db.exec('ALTER TABLE users ADD COLUMN feed_token TEXT');
} catch (err: any) {
if (!err.message?.includes('duplicate column name')) throw err;
}
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_trips_feed_token ON trips(feed_token) WHERE feed_token IS NOT NULL');
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_users_feed_token ON users(feed_token) WHERE feed_token IS NOT NULL');
},
];
if (currentVersion < migrations.length) {