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
+4 -2
View File
@@ -104,8 +104,10 @@ gates, and the first `PluginHostDepsFactory` swap done as part of a service
migration — no bridge entry for the plugin host); day-notes followed (the first
migration needing **no bridge at all**: after its three tools + resource moved
to `day-notes.mcp.ts` and the plugin host injection, nothing outside the
container consumed it). Repeat these steps per
service (next up: tripInviteService). This is a **pure relocation** — byte-identical
container consumed it); trip-invite followed (the smallest port: no MCP
surface, no plugin-host import and no bridge — the SQL folded straight into
`trip-invite.service.ts`). Repeat these steps per
service (next up: assignmentService). This is a **pure relocation** — byte-identical
SQL, statuses, bodies, and error strings. The plugin RPC host is **no longer a
bridge consumer**: since Option A of `src/nest/plugins/DI-MIGRATION.md` it
injects domain services via `PluginHostDepsFactory`, so a migrated domain adds
+4 -2
View File
@@ -99,7 +99,8 @@ factory *until their own domain migrates*. Each future migration then swaps one
import for one injected service (+ module export/import) — **no more bridge
files for the plugin host, ever**. The factory's import list shrinks
monotonically as the recipe in `src/nest/README.md` proceeds (packing and
day-notes swapped in 2026-07; next up: tripInviteService).
day-notes swapped in 2026-07; trip-invite migrated without touching the
factory — it was never imported here; next factory swap: assignmentService).
### Test impact (as landed)
@@ -216,7 +217,8 @@ none of it is throwaway.
factory, tags/categories bridges deleted, wiring test rewritten against
stubs (see §2).
2. **Per domain, as the service migrations proceed** (`packingService` and
`dayNoteService` done 2026-07; `tripInviteService` next, per
`dayNoteService` done 2026-07; `tripInviteService` done 2026-07 with no
factory impact — it was never imported here; `assignmentService` next, per
`src/nest/README.md`): migrate the
service to DI as usual; the factory swaps one legacy import for one
injected service. Optionally pilot `tags.rpc.ts` here — tags is small and
@@ -1,17 +1,30 @@
import { Injectable } from '@nestjs/common';
import crypto from 'crypto';
import { DatabaseService } from '../database/database.service';
import type { TripAccess } from '../database/database.service';
import { checkPermission } from '../../services/permissions';
import { joinTripAsMember } from '../../services/tripMembership';
import type { User } from '../../types';
import * as svc from '../../services/tripInviteService';
type Trip = TripAccess;
export interface TripInviteInfo {
token: string;
expires_at: string | null;
created_at: string;
}
/**
* Thin Nest wrapper around the trip invite-link service. Trip access and the
* 'share_manage' permission mirror the public share link exactly (the invite
* link lives next to it in the Share area).
* Per-trip invite links (#1143) — the legacy tripInviteService SQL folded in
* over the injected DatabaseService (byte-identical statements and coercions).
*
* 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. Trip access and the 'share_manage'
* permission mirror the public share link exactly (the invite link lives next
* to it in the Share area).
*/
@Injectable()
export class TripInviteService {
@@ -25,13 +38,76 @@ export class TripInviteService {
return checkPermission('share_manage', user.role, trip.user_id, user.id, trip.user_id !== user.id);
}
get(tripId: string) { return svc.getTripInviteLink(tripId); }
createOrRotate(tripId: string, userId: number, expiresInDays?: number | null) {
return svc.createOrRotateTripInviteLink(tripId, userId, expiresInDays ?? null);
/** The current invite link for a trip, or null if none exists. */
get(tripId: string | number): TripInviteInfo | null {
const row = this.dbs.get<{ token: string; expires_at: string | null; created_at: string }>(
'SELECT token, expires_at, created_at FROM trip_invite_tokens WHERE trip_id = ?',
tripId,
);
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.
*/
createOrRotate(tripId: string | number, createdBy: number, expiresInDays?: number | null): TripInviteInfo {
const token = crypto.randomBytes(24).toString('base64url');
// Any non-positive/absent value (0, negatives, NaN, null, undefined) means
// "no expiry" — deliberate: the UI sends null when no bound was chosen.
const expiresAt =
expiresInDays && expiresInDays > 0
? new Date(Date.now() + expiresInDays * 86400000).toISOString()
: null;
// Probe + write + re-select are one atomic unit so a concurrent rotation
// can't interleave between them (the trip_id UNIQUE constraint would turn
// that into a spurious 500).
return this.dbs.transaction(() => {
const existing = this.dbs.get('SELECT id FROM trip_invite_tokens WHERE trip_id = ?', tripId);
if (existing) {
this.dbs.run(
'UPDATE trip_invite_tokens SET token = ?, expires_at = ?, created_by = ?, created_at = CURRENT_TIMESTAMP WHERE trip_id = ?',
token, expiresAt, createdBy, tripId,
);
} else {
this.dbs.run(
'INSERT INTO trip_invite_tokens (trip_id, token, created_by, expires_at) VALUES (?, ?, ?, ?)',
tripId, token, createdBy, expiresAt,
);
}
return this.get(tripId)!;
});
}
/** Remove the trip's invite link entirely (disable). */
remove(tripId: string | number): void {
this.dbs.run('DELETE FROM trip_invite_tokens WHERE trip_id = ?', 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).
*/
resolve(token: string): { trip_id: number; title: string } | null {
const row = this.dbs.get<{ trip_id: number; title: string; expires_at: string | null }>(
`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 = ?`,
token,
);
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 };
}
remove(tripId: string) { return svc.deleteTripInviteLink(tripId); }
resolve(token: string) { return svc.resolveTripInvite(token); }
/** Join the resolved trip as the current (authenticated, non-guest) user.
* invited_by is null — they joined via a link, not a personal invite. */
join(tripId: number, userId: number) { return joinTripAsMember(tripId, userId, null); }
-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 };
}