mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 23:16:46 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user