chore: move trip-invite backend to proper DI

Fold the legacy tripInviteService SQL into the Nest TripInviteService over
the injected DatabaseService (byte-identical statements; the JS-side expiry
check and non-positive-means-no-expiry coercion preserved) and delete the
legacy module. No bridge, no MCP port and no plugin-host change needed —
nothing outside the container consumed this domain.

Also wraps the rotation's probe + write + re-select in db.transaction()
per the repo rule that multi-statement writes are never bare.

Unit suite moved to tests/unit/nest preserving TRIP-INVITE-001..006 and
adding 007 (expiry pinning); the e2e converted to the DI-native pattern
(real SQL against temp trips/trip_invite_tokens tables).
This commit is contained in:
jubnl
2026-07-26 18:21:04 +02:00
parent 3762d3306c
commit cb0cd3300d
7 changed files with 282 additions and 217 deletions
-83
View File
@@ -1,83 +0,0 @@
import { db } from '../db/database';
import crypto from 'crypto';
/**
* Per-trip invite links (#1143).
*
* A trip has at most ONE invite token (mirrors the public share link: one
* rotating token per trip). Unlike the share link, this one is NOT public —
* opening it only does something for a logged-in user with an existing account,
* who is then added to the trip as a member. Rotating or disabling the link
* immediately invalidates the old URL.
*/
export interface TripInviteInfo {
token: string;
expires_at: string | null;
created_at: string;
}
/** The current invite link for a trip, or null if none exists. */
export function getTripInviteLink(tripId: string | number): TripInviteInfo | null {
const row = db
.prepare('SELECT token, expires_at, created_at FROM trip_invite_tokens WHERE trip_id = ?')
.get(tripId) as { token: string; expires_at: string | null; created_at: string } | undefined;
return row ? { token: row.token, expires_at: row.expires_at, created_at: row.created_at } : null;
}
/**
* Create the trip's invite link, or rotate it to a fresh token (there is only
* ever one row per trip). An optional expiry (days) can bound the link's life.
*/
export function createOrRotateTripInviteLink(
tripId: string | number,
createdBy: number,
expiresInDays?: number | null,
): TripInviteInfo {
const token = crypto.randomBytes(24).toString('base64url');
const expiresAt =
expiresInDays && expiresInDays > 0
? new Date(Date.now() + expiresInDays * 86400000).toISOString()
: null;
const existing = db.prepare('SELECT id FROM trip_invite_tokens WHERE trip_id = ?').get(tripId);
if (existing) {
db.prepare(
'UPDATE trip_invite_tokens SET token = ?, expires_at = ?, created_by = ?, created_at = CURRENT_TIMESTAMP WHERE trip_id = ?',
).run(token, expiresAt, createdBy, tripId);
} else {
db.prepare(
'INSERT INTO trip_invite_tokens (trip_id, token, created_by, expires_at) VALUES (?, ?, ?, ?)',
).run(tripId, token, createdBy, expiresAt);
}
return getTripInviteLink(tripId)!;
}
/** Remove the trip's invite link entirely (disable). */
export function deleteTripInviteLink(tripId: string | number): void {
db.prepare('DELETE FROM trip_invite_tokens WHERE trip_id = ?').run(tripId);
}
/**
* Resolve an invite token to its (still-existing, unexpired) trip. Returns null
* for unknown/expired tokens. Only ever called for an authenticated user, so the
* trip title is safe to return for the join confirmation screen; an anonymous
* caller never reaches this (the endpoint is JWT-guarded).
*/
export function resolveTripInvite(token: string): { trip_id: number; title: string } | null {
const row = db
.prepare(
`SELECT t.id AS trip_id, t.title AS title, ti.expires_at AS expires_at
FROM trip_invite_tokens ti
JOIN trips t ON ti.trip_id = t.id
WHERE ti.token = ?`,
)
.get(token) as { trip_id: number; title: string; expires_at: string | null } | undefined;
if (!row) return null;
// Check expiry in JS, not SQL: expires_at is stored as an ISO-8601 string
// (…T…Z) which does not compare lexicographically against SQLite's
// space-separated datetime('now'), so a SQL `>` would keep an expired link
// alive until the end of the day. Mirrors the admin-invite validation.
if (row.expires_at && new Date(row.expires_at) < new Date()) return null;
return { trip_id: row.trip_id, title: row.title };
}