mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 23:16:46 +00:00
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:
@@ -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) {
|
||||
|
||||
@@ -29,6 +29,7 @@ import { MemoriesModule } from './memories/memories.module';
|
||||
import { AirtrailModule } from './integrations/airtrail.module';
|
||||
import { JourneyModule } from './journey/journey.module';
|
||||
import { ShareModule } from './share/share.module';
|
||||
import { FeedsModule } from './feeds/feeds.module';
|
||||
import { SettingsModule } from './settings/settings.module';
|
||||
import { BackupModule } from './backup/backup.module';
|
||||
import { BookingImportModule } from './booking-import/booking-import.module';
|
||||
@@ -47,7 +48,7 @@ import { IdempotencyInterceptor } from './common/idempotency.interceptor';
|
||||
* migrated.
|
||||
*/
|
||||
@Module({
|
||||
imports: [DatabaseModule, WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, ShareModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, BookingImportModule],
|
||||
imports: [DatabaseModule, WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, ShareModule, FeedsModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, BookingImportModule],
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
HealthService,
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpException,
|
||||
Param,
|
||||
Post,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { FeedsService } from './feeds.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import type { User } from '../../types';
|
||||
import { db } from '../../db/database';
|
||||
|
||||
/**
|
||||
* Public subscribable ICS feed endpoints — no auth required.
|
||||
* The secret token in the URL acts as the access credential.
|
||||
*/
|
||||
@Controller('api/feed')
|
||||
export class FeedsPublicController {
|
||||
constructor(private readonly feeds: FeedsService) {}
|
||||
|
||||
@Get('trip/:token.ics')
|
||||
tripFeed(@Param('token') token: string, @Res() res: Response): void {
|
||||
const result = this.feeds.buildTripIcs(token);
|
||||
if (!result) {
|
||||
res.status(404).json({ error: 'Feed not found' });
|
||||
return;
|
||||
}
|
||||
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `inline; filename="${result.filename}"`);
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store');
|
||||
res.setHeader('X-Published-TTL', 'PT1H');
|
||||
res.send(result.ics);
|
||||
}
|
||||
|
||||
@Get('user/:token.ics')
|
||||
userFeed(@Param('token') token: string, @Res() res: Response): void {
|
||||
const result = this.feeds.buildUserIcs(token);
|
||||
if (!result) {
|
||||
res.status(404).json({ error: 'Feed not found' });
|
||||
return;
|
||||
}
|
||||
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `inline; filename="all-trips.ics"`);
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store');
|
||||
res.setHeader('X-Published-TTL', 'PT1H');
|
||||
res.send(result.ics);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticated token management — generate / regenerate feed tokens.
|
||||
*/
|
||||
@Controller('api/trips/:tripId/feed')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class TripFeedTokenController {
|
||||
constructor(private readonly feeds: FeedsService) {}
|
||||
|
||||
@Get('token')
|
||||
get(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
const result = this.feeds.getTripToken(tripId, user.id);
|
||||
return result ?? { feed_url: null };
|
||||
}
|
||||
|
||||
@Post('token')
|
||||
generate(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
const row = db
|
||||
.prepare('SELECT id FROM trips WHERE id = ? AND (user_id = ? OR id IN (SELECT trip_id FROM trip_members WHERE user_id = ?))')
|
||||
.get(tripId, user.id, user.id);
|
||||
if (!row) throw new HttpException({ error: 'Trip not found' }, 404);
|
||||
return this.feeds.generateTripToken(tripId, user.id);
|
||||
}
|
||||
|
||||
@Delete('token')
|
||||
regenerate(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
const row = db
|
||||
.prepare('SELECT id FROM trips WHERE id = ? AND (user_id = ? OR id IN (SELECT trip_id FROM trip_members WHERE user_id = ?))')
|
||||
.get(tripId, user.id, user.id);
|
||||
if (!row) throw new HttpException({ error: 'Trip not found' }, 404);
|
||||
return this.feeds.regenerateTripToken(tripId, user.id);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('api/feed/user')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class UserFeedTokenController {
|
||||
constructor(private readonly feeds: FeedsService) {}
|
||||
|
||||
@Get('token')
|
||||
get(@CurrentUser() user: User) {
|
||||
return this.feeds.getUserToken(user.id) ?? { feed_url: null };
|
||||
}
|
||||
|
||||
@Post('token')
|
||||
generate(@CurrentUser() user: User) {
|
||||
return this.feeds.generateUserToken(user.id);
|
||||
}
|
||||
|
||||
@Delete('token')
|
||||
regenerate(@CurrentUser() user: User) {
|
||||
return this.feeds.regenerateUserToken(user.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { FeedsService } from './feeds.service';
|
||||
import { FeedsPublicController, TripFeedTokenController, UserFeedTokenController } from './feeds.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [FeedsPublicController, TripFeedTokenController, UserFeedTokenController],
|
||||
providers: [FeedsService],
|
||||
})
|
||||
export class FeedsModule {}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { db } from '../../db/database';
|
||||
import { exportICS } from '../../services/tripService';
|
||||
|
||||
const ninetyDaysAgo = () => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - 90);
|
||||
return d.toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
function feedUrl(token: string, scope: 'trip' | 'user'): string {
|
||||
const base = (process.env.APP_URL || '').replace(/\/$/, '');
|
||||
return `${base}/api/feed/${scope}/${token}.ics`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FeedsService {
|
||||
// ── Trip feed token ─────────────────────────────────────────────────────
|
||||
|
||||
getTripToken(tripId: string, userId: number): { feed_url: string } | null {
|
||||
const row = db
|
||||
.prepare('SELECT feed_token FROM trips WHERE id = ? AND (user_id = ? OR id IN (SELECT trip_id FROM trip_members WHERE user_id = ?))')
|
||||
.get(tripId, userId, userId) as { feed_token: string | null } | undefined;
|
||||
if (!row || !row.feed_token) return null;
|
||||
return { feed_url: feedUrl(row.feed_token, 'trip') };
|
||||
}
|
||||
|
||||
generateTripToken(tripId: string, userId: number): { feed_url: string } {
|
||||
const existing = this.getTripToken(tripId, userId);
|
||||
if (existing) return existing;
|
||||
const token = randomUUID();
|
||||
db.prepare('UPDATE trips SET feed_token = ? WHERE id = ?').run(token, tripId);
|
||||
return { feed_url: feedUrl(token, 'trip') };
|
||||
}
|
||||
|
||||
regenerateTripToken(tripId: string, userId: number): { feed_url: string } {
|
||||
const trip = db
|
||||
.prepare('SELECT id FROM trips WHERE id = ? AND (user_id = ? OR id IN (SELECT trip_id FROM trip_members WHERE user_id = ?))')
|
||||
.get(tripId, userId, userId);
|
||||
if (!trip) return { feed_url: '' };
|
||||
const token = randomUUID();
|
||||
db.prepare('UPDATE trips SET feed_token = ? WHERE id = ?').run(token, tripId);
|
||||
return { feed_url: feedUrl(token, 'trip') };
|
||||
}
|
||||
|
||||
// ── User (all-trips) feed token ──────────────────────────────────────────
|
||||
|
||||
getUserToken(userId: number): { feed_url: string } | null {
|
||||
const row = db.prepare('SELECT feed_token FROM users WHERE id = ?').get(userId) as
|
||||
| { feed_token: string | null }
|
||||
| undefined;
|
||||
if (!row || !row.feed_token) return null;
|
||||
return { feed_url: feedUrl(row.feed_token, 'user') };
|
||||
}
|
||||
|
||||
generateUserToken(userId: number): { feed_url: string } {
|
||||
const existing = this.getUserToken(userId);
|
||||
if (existing) return existing;
|
||||
const token = randomUUID();
|
||||
db.prepare('UPDATE users SET feed_token = ? WHERE id = ?').run(token, userId);
|
||||
return { feed_url: feedUrl(token, 'user') };
|
||||
}
|
||||
|
||||
regenerateUserToken(userId: number): { feed_url: string } {
|
||||
const token = randomUUID();
|
||||
db.prepare('UPDATE users SET feed_token = ? WHERE id = ?').run(token, userId);
|
||||
return { feed_url: feedUrl(token, 'user') };
|
||||
}
|
||||
|
||||
// ── ICS generation ───────────────────────────────────────────────────────
|
||||
|
||||
buildTripIcs(token: string): { ics: string; filename: string } | null {
|
||||
const row = db.prepare('SELECT id FROM trips WHERE feed_token = ?').get(token) as
|
||||
| { id: number }
|
||||
| undefined;
|
||||
if (!row) return null;
|
||||
try {
|
||||
const { ics, filename } = exportICS(row.id);
|
||||
// Inject calendar-subscription refresh hints into the VCALENDAR header so
|
||||
// clients re-fetch hourly. The one-time download path (exportICS) is left
|
||||
// untouched; this is feed-only.
|
||||
const withHints = ics.replace(
|
||||
'METHOD:PUBLISH\r\n',
|
||||
'METHOD:PUBLISH\r\nREFRESH-INTERVAL;VALUE=DURATION:PT1H\r\nX-PUBLISHED-TTL:PT1H\r\n',
|
||||
);
|
||||
return { ics: withHints, filename };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
buildUserIcs(token: string): { ics: string; calName: string } | null {
|
||||
const user = db.prepare('SELECT id, username FROM users WHERE feed_token = ?').get(token) as
|
||||
| { id: number; username: string }
|
||||
| undefined;
|
||||
if (!user) return null;
|
||||
|
||||
const cutoff = ninetyDaysAgo();
|
||||
const trips = db
|
||||
.prepare(
|
||||
`SELECT id FROM trips
|
||||
WHERE user_id = ?
|
||||
AND is_archived = 0
|
||||
AND (end_date IS NULL OR end_date >= ?)
|
||||
ORDER BY start_date ASC`,
|
||||
)
|
||||
.all(user.id, cutoff) as { id: number }[];
|
||||
|
||||
const esc = (s: string) =>
|
||||
s.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n');
|
||||
|
||||
let combined =
|
||||
'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//TREK//Travel Planner//EN\r\nCALSCALE:GREGORIAN\r\nMETHOD:PUBLISH\r\n';
|
||||
combined += `X-WR-CALNAME:${esc(user.username + ' – All Trips')}\r\n`;
|
||||
combined += 'REFRESH-INTERVAL;VALUE=DURATION:PT1H\r\nX-PUBLISHED-TTL:PT1H\r\n';
|
||||
|
||||
for (const { id } of trips) {
|
||||
try {
|
||||
const { ics } = exportICS(id);
|
||||
// Strip outer VCALENDAR wrapper and extract VEVENT blocks
|
||||
const events = [...ics.matchAll(/BEGIN:VEVENT[\s\S]*?END:VEVENT/g)].map((m) => m[0]);
|
||||
for (const ev of events) combined += ev + '\r\n';
|
||||
} catch {
|
||||
// skip failed trips
|
||||
}
|
||||
}
|
||||
|
||||
combined += 'END:VCALENDAR\r\n';
|
||||
return { ics: combined, calName: user.username + ' – All Trips' };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user