Files
TREK/server/src/nest/trips/trips.service.ts
T
Maurice e56930ddaf feat(trips): guest members for accountless participants (#1362, #1291)
Add "guest" trip participants — people without a Trek account who can still be
assigned to costs, packing, to-dos and day-plan activities. A guest is a
credential-less users row (is_guest=1) joined into trip_members, so it is
assignable everywhere a real member is, with the cost-splitting, settlement,
packing and assignment paths working unchanged.

Guests are firewalled from everything account-related: they can never sign in
(password, OIDC and reset lookups skip them), never appear in the global user
directory, the member-add picker or admin user management, are never resolved as
notification recipients, can't be invited to another trip, and can't be made
owner. The trip owner manages guests from the share dialog in a dedicated,
clearly-labelled section (add / rename / remove), and guests carry a "Guest"
badge wherever members are picked. All 22 locales stay in parity.
2026-06-30 14:56:57 +02:00

149 lines
5.2 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { db, canAccessTrip } from '../../db/database';
import { broadcast } from '../../websocket';
import { checkPermission } from '../../services/permissions';
import type { User } from '../../types';
import * as tripSvc from '../../services/tripService';
import { listDays, listAccommodations } from '../../services/dayService';
import { listPlaces } from '../../services/placeService';
import { listItems as listPackingItems } from '../../services/packingService';
import { listItems as listTodoItems } from '../../services/todoService';
import { listBudgetItems } from '../../services/budgetService';
import { listReservations } from '../../services/reservationService';
import { listFiles } from '../../services/fileService';
import { searchUnsplashPhotos } from '../../services/unsplashService';
/**
* Thin Nest wrapper around the existing trip service + the per-domain list
* services used to build the offline bundle. Auth (canAccessTrip), permissions,
* the SQL and the ICS export reuse the legacy code unchanged. Per-field
* permission checks and audit logging stay in the controller (1:1 with the
* legacy route).
*/
@Injectable()
export class TripsService {
canAccessTrip(tripId: string, userId: number) {
return canAccessTrip(tripId, userId) as { user_id: number } | null | undefined;
}
can(action: string, role: string, ownerId: number | null, userId: number, isMember: boolean): boolean {
return checkPermission(action, role, ownerId, userId, isMember);
}
broadcast(tripId: string, event: string, payload: Record<string, unknown>, socketId: string | undefined): void {
broadcast(tripId, event, payload, socketId);
}
list(userId: number, archived: number) {
return tripSvc.listTrips(userId, archived);
}
create(userId: number, data: Parameters<typeof tripSvc.createTrip>[1]) {
return tripSvc.createTrip(userId, data);
}
get(tripId: string, userId: number) {
return tripSvc.getTrip(tripId, userId);
}
getRaw(tripId: string) {
return tripSvc.getTripRaw(tripId);
}
searchCoverImages(query: string) {
return searchUnsplashPhotos(query, 9);
}
getOwner(tripId: string) {
return tripSvc.getTripOwner(tripId);
}
update(tripId: string, userId: number, body: Parameters<typeof tripSvc.updateTrip>[2], role: string) {
return tripSvc.updateTrip(tripId, userId, body, role);
}
remove(tripId: string, userId: number, role: string) {
return tripSvc.deleteTrip(tripId, userId, role);
}
deleteOldCover(coverImage: string | null | undefined): void {
tripSvc.deleteOldCover(coverImage as never);
}
updateCoverImage(tripId: string, url: string): void {
tripSvc.updateCoverImage(tripId, url);
}
copy(tripId: string, userId: number, title?: string) {
return tripSvc.copyTripById(tripId, userId, title);
}
/** Re-read a freshly copied trip in list shape (mirrors the route's TRIP_SELECT query). */
getCopiedTrip(newTripId: number, userId: number) {
return db.prepare(`${tripSvc.TRIP_SELECT} WHERE t.id = :tripId`).get({ userId, tripId: newTripId });
}
listMembers(tripId: string, ownerId: number) {
return tripSvc.listMembers(tripId, ownerId);
}
addMember(tripId: string, identifier: string, ownerId: number, userId: number) {
return tripSvc.addMember(tripId, identifier, ownerId, userId);
}
removeMember(tripId: string, targetId: number): void {
tripSvc.removeMember(tripId, targetId);
}
transferOwnership(tripId: string, newOwnerId: number, currentOwnerId: number) {
return tripSvc.transferOwnership(tripId, newOwnerId, currentOwnerId);
}
createGuest(tripId: string, name: string, invitedBy: number) {
return tripSvc.createGuest(tripId, name, invitedBy);
}
renameGuest(tripId: string, guestUserId: number, name: string): boolean {
return tripSvc.renameGuest(tripId, guestUserId, name);
}
deleteGuest(tripId: string, guestUserId: number): boolean {
return tripSvc.deleteGuest(tripId, guestUserId);
}
exportICS(tripId: string) {
return tripSvc.exportICS(tripId);
}
/** Aggregates every trip sub-collection for offline caching (legacy /:id/bundle). */
bundle(tripId: string, trip: { user_id: number }) {
const { days } = listDays(tripId);
const { owner, members } = this.listMembers(tripId, trip.user_id);
return {
trip,
days,
places: listPlaces(String(tripId), {}),
packingItems: listPackingItems(tripId),
todoItems: listTodoItems(tripId),
budgetItems: listBudgetItems(tripId),
reservations: listReservations(tripId),
files: listFiles(tripId, false),
accommodations: listAccommodations(tripId),
members: [owner, ...(members || [])].filter(Boolean),
};
}
/** Fire-and-forget trip-invite notification (mirrors the route's dynamic import). */
notifyInvite(tripId: string, actor: User, targetUserId: number, tripTitle: string, inviteeEmail: string): void {
import('../../services/notificationService').then(({ send }) => {
send({
event: 'trip_invite',
actorId: actor.id,
scope: 'user',
targetId: targetUserId,
params: { trip: tripTitle, actor: actor.email, invitee: inviteeEmail, tripId: String(tripId) },
}).catch(() => {});
});
}
}