mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
refactor(server): places, unsplash, vacay, collections and file downloads raise DomainError, so REST and MCP stop translating results
This commit is contained in:
@@ -312,20 +312,14 @@ export class CollectionsController {
|
||||
if (!(await this.collections.isOwner(user.id, body.collection_id))) {
|
||||
throw new HttpException({ error: 'Only the owner can invite' }, 403);
|
||||
}
|
||||
const result = await this.collections.sendInvite(body.collection_id, user.id, user.username, user.email, body.user_id, body.role);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
await this.collections.sendInvite(body.collection_id, user.id, user.username, user.email, body.user_id, body.role);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post('invite/accept')
|
||||
@HttpCode(200)
|
||||
async acceptInvite(@CurrentUser() user: User, @Body() body: CollectionInviteActionDto, @Headers('x-socket-id') socketId?: string) {
|
||||
const result = await this.collections.acceptInvite(user.id, body.collection_id, socketId);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
await this.collections.acceptInvite(user.id, body.collection_id, socketId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
|
||||
@@ -363,8 +363,7 @@ export class CollectionsMcp {
|
||||
// of two where an unexpected throw escaped to the SDK instead of isError.
|
||||
try {
|
||||
const me = await this.users.findUsernameEmail(ctx.userId);
|
||||
const res = await this.collections.sendInvite(collection_id, ctx.userId, me?.username ?? '', me?.email ?? '', user_id, role);
|
||||
if (res.error) return { content: [{ type: 'text' as const, text: res.error }], isError: true };
|
||||
await this.collections.sendInvite(collection_id, ctx.userId, me?.username ?? '', me?.email ?? '', user_id, role);
|
||||
return ok({ success: true });
|
||||
} catch (err) { return fail(err); }
|
||||
}
|
||||
@@ -429,8 +428,7 @@ export class CollectionsMcp {
|
||||
async acceptCollectionInvite({ collectionId }: { collectionId: number }, ctx: McpContext) {
|
||||
// try/catch added with the post-fold quirk pass (see inviteToCollection).
|
||||
try {
|
||||
const res = await this.collections.acceptInvite(ctx.userId, collectionId, undefined);
|
||||
if (res.error) return { content: [{ type: 'text' as const, text: res.error }], isError: true };
|
||||
await this.collections.acceptInvite(ctx.userId, collectionId, undefined);
|
||||
return ok({ success: true });
|
||||
} catch (err) { return fail(err); }
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DomainError } from '../common/domain-error';
|
||||
import path from 'path';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
@@ -98,9 +99,7 @@ function serializeLinks(links: CollectionLink[] | undefined): string | null {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function httpError(status: number, message: string): never {
|
||||
const err = new Error(message) as Error & { status: number };
|
||||
err.status = status;
|
||||
throw err;
|
||||
throw new DomainError(status, message);
|
||||
}
|
||||
|
||||
/** Re-exported under this service's historical name — the underlying type
|
||||
@@ -1365,17 +1364,17 @@ export class CollectionsService {
|
||||
async sendInvite(
|
||||
collectionId: number, inviterId: number, inviterUsername: string, inviterEmail: string, targetUserId: number,
|
||||
role: 'viewer' | 'editor' | 'admin' = 'editor',
|
||||
): Promise<{ error?: string; status?: number }> {
|
||||
if (!(await this.isOwner(inviterId, collectionId))) return { error: 'Not allowed', status: 403 };
|
||||
if (targetUserId === inviterId) return { error: 'Cannot invite yourself', status: 400 };
|
||||
): Promise<void> {
|
||||
if (!(await this.isOwner(inviterId, collectionId))) throw new DomainError(403, 'Not allowed');
|
||||
if (targetUserId === inviterId) throw new DomainError(400, 'Cannot invite yourself');
|
||||
|
||||
const targetUser = await this.users.findIdUsername(targetUserId);
|
||||
if (!targetUser) return { error: 'User not found', status: 404 };
|
||||
if (!targetUser) throw new DomainError(404, 'User not found');
|
||||
|
||||
const existing = await this.members.findByCollectionAndUser(collectionId, targetUserId);
|
||||
if (existing) {
|
||||
if (existing.status === 'accepted') return { error: 'Already a member', status: 400 };
|
||||
if (existing.status === 'pending') return { error: 'Invite already pending', status: 400 };
|
||||
if (existing.status === 'accepted') throw new DomainError(400, 'Already a member');
|
||||
if (existing.status === 'pending') throw new DomainError(400, 'Invite already pending');
|
||||
}
|
||||
|
||||
await this.members.insertInvite(collectionId, targetUserId, role);
|
||||
@@ -1387,16 +1386,13 @@ export class CollectionsService {
|
||||
// reaches nothing in this direction — and it hid the edge while handing the
|
||||
// send a second NotificationsService built outside the container.
|
||||
this.notifications.send({ event: 'collection_invite', actorId: inviterId, scope: 'user', targetId: targetUserId, params: { actor: inviterEmail, collectionId: String(collectionId) } }).catch(() => {});
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
async acceptInvite(userId: number, collectionId: number, socketId: string | undefined): Promise<{ error?: string; status?: number }> {
|
||||
async acceptInvite(userId: number, collectionId: number, socketId: string | undefined): Promise<void> {
|
||||
const inviteId = await this.members.findPendingInvite(collectionId, userId);
|
||||
if (inviteId === undefined) return { error: 'No pending invite', status: 404 };
|
||||
if (inviteId === undefined) throw new DomainError(404, 'No pending invite');
|
||||
await this.members.accept(inviteId);
|
||||
await this.notifyCollectionUsers(collectionId, socketId, 'collections:accepted');
|
||||
return {};
|
||||
}
|
||||
|
||||
async declineInvite(userId: number, collectionId: number, socketId: string | undefined): Promise<void> {
|
||||
|
||||
@@ -32,9 +32,6 @@ export class FilesDownloadController {
|
||||
@Param('id') id: string,
|
||||
): Promise<void> {
|
||||
const auth = await this.files.authenticateDownload(req);
|
||||
if ('error' in auth) {
|
||||
throw new HttpException({ error: auth.error }, auth.status);
|
||||
}
|
||||
|
||||
const trip = await this.files.verifyTripAccess(tripId, auth.userId);
|
||||
if (!trip) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DomainError } from '../common/domain-error';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import path from 'path';
|
||||
@@ -156,7 +157,7 @@ export class FilesService {
|
||||
// download route)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async authenticateDownload(req: Request): Promise<{ userId: number } | { error: string; status: number }> {
|
||||
async authenticateDownload(req: Request): Promise<{ userId: number }> {
|
||||
const cookieToken = (req as { cookies?: Record<string, string> }).cookies?.trek_session;
|
||||
const authHeader = req.headers['authorization'];
|
||||
const bearerToken = authHeader ? (authHeader.split(' ')[1] || undefined) : undefined;
|
||||
@@ -169,17 +170,17 @@ export class FilesService {
|
||||
// previously this bypassed the check and stolen download tokens stayed
|
||||
// valid across a password reset.
|
||||
const user = await verifyJwtAndLoadUser(jwtToken, this.em.getRepository(Users), this.em.getRepository(UserSessions));
|
||||
if (!user) return { error: 'Invalid or expired token', status: 401 };
|
||||
if (!user) throw new DomainError(401, 'Invalid or expired token');
|
||||
return { userId: user.id };
|
||||
}
|
||||
|
||||
if (queryToken) {
|
||||
const uid = this.tokens.consume(queryToken, 'download');
|
||||
if (!uid) return { error: 'Invalid or expired token', status: 401 };
|
||||
if (!uid) throw new DomainError(401, 'Invalid or expired token');
|
||||
return { userId: uid };
|
||||
}
|
||||
|
||||
return { error: 'Authentication required', status: 401 };
|
||||
throw new DomainError(401, 'Authentication required');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -291,9 +291,6 @@ export class PlacesController {
|
||||
: isDirectionsUrl(url)
|
||||
? await this.places.importGoogleDirections(tripId, url, opts)
|
||||
: await this.places.importGoogleList(tripId, url, opts);
|
||||
if ('error' in result) {
|
||||
throw new HttpException({ error: result.error }, result.status);
|
||||
}
|
||||
for (const place of result.places) {
|
||||
this.places.broadcast(tripId, 'place:created', { place }, socketId);
|
||||
}
|
||||
@@ -479,9 +476,6 @@ export class PlacesController {
|
||||
async image(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string) {
|
||||
try {
|
||||
const result = await this.places.searchImage(tripId, id, user.id);
|
||||
if ('error' in result) {
|
||||
throw new HttpException({ error: result.error }, result.status);
|
||||
}
|
||||
return { photos: result.photos };
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof HttpException) throw err;
|
||||
|
||||
@@ -390,10 +390,6 @@ export class PlacesMcp {
|
||||
: await this.places.importGoogleList(String(tripId), url, opts))
|
||||
: await this.places.importNaverList(String(tripId), url, opts);
|
||||
|
||||
if ('error' in result) {
|
||||
return { content: [{ type: 'text' as const, text: result.error }], isError: true };
|
||||
}
|
||||
|
||||
for (const place of result.places) {
|
||||
this.guards.safeBroadcast(tripId, 'place:created', { place });
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DomainError } from '../common/domain-error';
|
||||
import path from 'node:path';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
@@ -70,7 +71,6 @@ import {
|
||||
type GpxImportOptions,
|
||||
type GpxImportResult,
|
||||
type KmlImportOptions,
|
||||
type ListImportError,
|
||||
type ListImportOptions,
|
||||
type ListImportResult,
|
||||
type PlaceImportResult,
|
||||
@@ -1144,9 +1144,9 @@ export class PlacesService {
|
||||
// Import Google Maps list
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async importGoogleList(tripId: string, url: string, opts?: ListImportOptions): Promise<ListImportResult | ListImportError> {
|
||||
async importGoogleList(tripId: string, url: string, opts?: ListImportOptions): Promise<ListImportResult> {
|
||||
const read = await this.placeImport.readGoogleList(url);
|
||||
if ('error' in read) return read;
|
||||
if ('error' in read) throw new DomainError(read.status, read.error);
|
||||
// A short link that lands on a route is a directions link: imported as one,
|
||||
// with the resolved URL, so the hop is not made twice.
|
||||
if ('directions' in read) return this.importGoogleDirections(tripId, read.directions, opts);
|
||||
@@ -1246,9 +1246,9 @@ export class PlacesService {
|
||||
* The stops of a route somebody else planned, read and placed by
|
||||
* PlaceImportService (no API key, the stops are in the link), stored here.
|
||||
*/
|
||||
async importGoogleDirections(tripId: string, url: string, opts?: ListImportOptions): Promise<ListImportResult | ListImportError> {
|
||||
async importGoogleDirections(tripId: string, url: string, opts?: ListImportOptions): Promise<ListImportResult> {
|
||||
const read = await this.placeImport.readGoogleDirections(url, (query) => this.maps.geocodeQuery(query));
|
||||
if ('error' in read) return read;
|
||||
if ('error' in read) throw new DomainError(read.status, read.error);
|
||||
const { places, unplaceable } = read;
|
||||
|
||||
const { created, skipped } = await this.storeGooglePlaces(tripId, places);
|
||||
@@ -1267,9 +1267,9 @@ export class PlacesService {
|
||||
// Import Naver Maps list
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async importNaverList(tripId: string, url: string, opts?: ListImportOptions): Promise<ListImportResult | ListImportError> {
|
||||
async importNaverList(tripId: string, url: string, opts?: ListImportOptions): Promise<ListImportResult> {
|
||||
const read = await this.placeImport.readNaverList(url);
|
||||
if ('error' in read) return read;
|
||||
if ('error' in read) throw new DomainError(read.status, read.error);
|
||||
const { listName, places } = read;
|
||||
|
||||
// Rule 21 / M1 (Task 9 fix wave) — same ruling as `importGpxRows`: the
|
||||
@@ -1514,16 +1514,16 @@ export class PlacesService {
|
||||
// `toRowId` first (Task 3 review H1, absorbed here) — same gate shape
|
||||
// as every other place-id route in this service.
|
||||
const id = toRowId(placeId);
|
||||
if (id === null) return { error: 'Place not found', status: 404 };
|
||||
if (id === null) throw new DomainError(404, 'Place not found');
|
||||
// Rule 21 (H1): the trip id gets the same `toRowId` treatment as the
|
||||
// place id above, parsed once and used for the one downstream read —
|
||||
// `Number(tripId)` used to disagree with it and let a hex trip id read
|
||||
// a different trip's place.
|
||||
const tid = toRowId(tripId);
|
||||
if (tid === null) return { error: 'Place not found', status: 404 };
|
||||
if (tid === null) throw new DomainError(404, 'Place not found');
|
||||
// PL48 — same statement as PL9 (`applyUpdate`'s pre-image read).
|
||||
const place = await this.placesRepo.findInTrip(id, tid);
|
||||
if (!place) return { error: 'Place not found', status: 404 };
|
||||
if (!place) throw new DomainError(404, 'Place not found');
|
||||
|
||||
return this.unsplash.searchUnsplashPhotos(place.name + (place.address ? ' ' + place.address : ''), 5, await this.unsplash.getUnsplashKey(userId));
|
||||
}
|
||||
|
||||
@@ -93,9 +93,6 @@ export class TripsController {
|
||||
async coverImages(@CurrentUser() user: User, @Query('query') query?: string) {
|
||||
try {
|
||||
const result = await this.trips.searchCoverImages(query || '', user.id);
|
||||
if ('error' in result) {
|
||||
throw new HttpException({ error: result.error }, result.status);
|
||||
}
|
||||
return { photos: result.photos };
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof HttpException) throw err;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
errorResult, ok,
|
||||
} from '../../nest-mcp';
|
||||
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
|
||||
import { DomainError } from '../common/domain-error';
|
||||
import { tripIdPromptArg } from '../mcp-shared/prompt-args';
|
||||
import { z } from 'zod';
|
||||
import { CalendarService } from '../calendar/calendar.service';
|
||||
@@ -202,9 +203,11 @@ export class TripsMcp {
|
||||
async searchCoverImages({ query }: { query: string }, ctx: McpContext) {
|
||||
try {
|
||||
const result = await this.trips.searchCoverImages(query, ctx.userId);
|
||||
if ('error' in result) return errorResult(result.error);
|
||||
return ok({ photos: result.photos });
|
||||
} catch (err) {
|
||||
// A refusal (no query, Unsplash answering an error) keeps its own text;
|
||||
// the registry's error mapper turns it into the tool result.
|
||||
if (err instanceof DomainError) throw err;
|
||||
console.error('Unsplash cover image error:', err);
|
||||
return errorResult('Error searching for cover images');
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DomainError } from '../common/domain-error';
|
||||
import { Readable } from 'node:stream';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
@@ -81,7 +82,7 @@ export class UnsplashService {
|
||||
async searchUnsplashPhotos(query: string, perPage = 9, accessKey?: string | null) {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) {
|
||||
return { error: 'Search query is required', status: 400 };
|
||||
throw new DomainError(400, 'Search query is required');
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
@@ -116,11 +117,11 @@ export class UnsplashService {
|
||||
try {
|
||||
data = await response.json() as UnsplashSearchResponse;
|
||||
} catch {
|
||||
return { error: 'Unsplash search unavailable', status: response.ok ? 502 : response.status };
|
||||
throw new DomainError(response.ok ? 502 : response.status, 'Unsplash search unavailable');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return { error: data.errors?.[0] || data.error || 'Unsplash search unavailable', status: response.status };
|
||||
throw new DomainError(response.status, data.errors?.[0] || data.error || 'Unsplash search unavailable');
|
||||
}
|
||||
|
||||
const photos: UnsplashPhoto[] = (data.results || [])
|
||||
|
||||
@@ -124,20 +124,14 @@ export class VacayController {
|
||||
throw new HttpException({ error: 'user_id required' }, 400);
|
||||
}
|
||||
const plan = await this.vacay.getActivePlan(user.id);
|
||||
const result = await this.vacay.sendInvite(plan.id, user.id, user.username, user.email, body.user_id as number);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
await this.vacay.sendInvite(plan.id, user.id, user.username, user.email, body.user_id as number);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post('invite/accept')
|
||||
@HttpCode(200)
|
||||
async acceptInvite(@CurrentUser() user: User, @Body() body: VacayInviteActionDto, @Headers('x-socket-id') socketId?: string) {
|
||||
const result = await this.vacay.acceptInvite(user.id, body.plan_id as number, socketId);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
await this.vacay.acceptInvite(user.id, body.plan_id as number, socketId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -286,10 +280,7 @@ export class VacayController {
|
||||
if (!body.user_id) {
|
||||
throw new HttpException({ error: 'user_id required' }, 400);
|
||||
}
|
||||
const result = await this.vacay.shareCalendar(user.id, user.email, Number.parseInt(String(body.user_id)), socketId);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
await this.vacay.shareCalendar(user.id, user.email, Number.parseInt(String(body.user_id)), socketId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -327,27 +318,18 @@ export class VacayController {
|
||||
@Get('holidays/countries')
|
||||
async holidayCountries() {
|
||||
const result = await this.vacay.getCountries();
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, 502);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
@Get('holidays/:year/:country')
|
||||
async holidays(@Param('year') year: string, @Param('country') country: string) {
|
||||
const result = await this.vacay.getHolidays(year, country);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, 502);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
@Get('school-holidays/regions/:country')
|
||||
async schoolHolidayRegions(@Param('country') country: string) {
|
||||
const result = await this.vacay.getSchoolHolidayRegions(country, country.toUpperCase() === 'DE' ? 'DE' : 'EN');
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, 502);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
@@ -372,9 +354,6 @@ export class VacayController {
|
||||
|
||||
private async schoolHolidays(year: string, country: string, subdivision?: string, group?: string) {
|
||||
const result = await this.vacay.getSchoolHolidays(year, country, subdivision, country.toUpperCase() === 'DE' ? 'DE' : 'EN', group);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, 502);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
errorResult, ok,
|
||||
} from '../../nest-mcp';
|
||||
import { catchDomainError, DomainError } from '../common/domain-error';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
vacayAddHolidayCalendarRequestSchema,
|
||||
@@ -185,8 +186,7 @@ export class VacayMcp {
|
||||
const planId = await this.vacay.getActivePlanId(ctx.userId);
|
||||
const me = await this.users.findUsernameEmail(ctx.userId);
|
||||
if (!me) return errorResult('User not found.');
|
||||
const result = await this.vacay.sendInvite(planId, ctx.userId, me.username, me.email, targetUserId);
|
||||
if (result.error) return errorResult(result.error);
|
||||
await this.vacay.sendInvite(planId, ctx.userId, me.username, me.email, targetUserId);
|
||||
return ok({ success: true });
|
||||
}
|
||||
|
||||
@@ -201,8 +201,7 @@ export class VacayMcp {
|
||||
access: { group: 'vacay', mode: 'write' },
|
||||
})
|
||||
async acceptVacayInvite({ planId }: { planId: number }, ctx: McpContext) {
|
||||
const result = await this.vacay.acceptInvite(ctx.userId, planId, undefined);
|
||||
if (result.error) return errorResult(result.error);
|
||||
await this.vacay.acceptInvite(ctx.userId, planId, undefined);
|
||||
return ok({ success: true });
|
||||
}
|
||||
|
||||
@@ -468,7 +467,6 @@ export class VacayMcp {
|
||||
})
|
||||
async listHolidayCountries(_args: Record<string, never>, _ctx: McpContext) {
|
||||
const result = await this.vacay.getCountries();
|
||||
if (result.error) return errorResult(result.error);
|
||||
return ok({ countries: result.data });
|
||||
}
|
||||
|
||||
@@ -485,7 +483,6 @@ export class VacayMcp {
|
||||
})
|
||||
async listHolidays({ country, year }: { country: string; year: number }, _ctx: McpContext) {
|
||||
const result = await this.vacay.getHolidays(String(year), country);
|
||||
if (result.error) return errorResult(result.error);
|
||||
return ok({ holidays: result.data });
|
||||
}
|
||||
|
||||
@@ -501,7 +498,6 @@ export class VacayMcp {
|
||||
})
|
||||
async listSchoolHolidayRegions({ country }: { country: string }, _ctx: McpContext) {
|
||||
const result = await this.vacay.getSchoolHolidayRegions(country, schoolHolidayLanguage(country));
|
||||
if (result.error) return errorResult(result.error);
|
||||
return ok({ regions: result.data, calendar_regions: calendarRegionCodes(country, result.data) });
|
||||
}
|
||||
|
||||
@@ -523,7 +519,6 @@ export class VacayMcp {
|
||||
_ctx: McpContext,
|
||||
) {
|
||||
const result = await this.vacay.getSchoolHolidays(String(year), country, subdivision, schoolHolidayLanguage(country), group);
|
||||
if (result.error) return errorResult(result.error);
|
||||
return ok({ holidays: result.data });
|
||||
}
|
||||
|
||||
@@ -564,8 +559,7 @@ export class VacayMcp {
|
||||
async shareVacayCalendar({ targetUserId }: { targetUserId: number }, ctx: McpContext) {
|
||||
const me = await this.users.findUsernameEmail(ctx.userId);
|
||||
if (!me) return errorResult('User not found.');
|
||||
const result = await this.vacay.shareCalendar(ctx.userId, me.email, targetUserId);
|
||||
if (result.error) return errorResult(result.error);
|
||||
await this.vacay.shareCalendar(ctx.userId, me.email, targetUserId);
|
||||
return ok({ success: true });
|
||||
}
|
||||
|
||||
@@ -662,7 +656,8 @@ export class VacayMcp {
|
||||
});
|
||||
if (!plan.holidays_enabled || !plan.holidays_region) return json([]);
|
||||
const yearStr = Array.isArray(year) ? year[0] : year;
|
||||
const result = await this.vacay.getHolidays(yearStr, plan.holidays_region);
|
||||
return json(result.data ?? []);
|
||||
// A failed fetch reads as no holidays here, as it always has.
|
||||
const result = await catchDomainError(() => this.vacay.getHolidays(yearStr, plan.holidays_region));
|
||||
return json(result instanceof DomainError ? [] : (result.data ?? []));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DomainError } from '../common/domain-error';
|
||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { manualSchoolRegionId } from '@trek/shared';
|
||||
@@ -699,22 +700,22 @@ export class VacayService {
|
||||
// Invitations
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async sendInvite(planId: number, inviterId: number, inviterUsername: string, inviterEmail: string, targetUserId: number): Promise<{ error?: string; status?: number }> {
|
||||
if (targetUserId === inviterId) return { error: 'Cannot invite yourself', status: 400 };
|
||||
async sendInvite(planId: number, inviterId: number, inviterUsername: string, inviterEmail: string, targetUserId: number): Promise<void> {
|
||||
if (targetUserId === inviterId) throw new DomainError(400, 'Cannot invite yourself');
|
||||
|
||||
// The picker no longer offers guests, but the id arrives from the client, so the
|
||||
// write path has to refuse them too rather than trust the list it handed out.
|
||||
const targetUser = await this.members.findInvitableUser(targetUserId);
|
||||
if (!targetUser) return { error: 'User not found', status: 404 };
|
||||
if (!targetUser) throw new DomainError(404, 'User not found');
|
||||
|
||||
const existing = await this.members.findMembership(planId, targetUserId);
|
||||
if (existing) {
|
||||
if (existing.status === 'accepted') return { error: 'Already fused', status: 400 };
|
||||
if (existing.status === 'pending') return { error: 'Invite already pending', status: 400 };
|
||||
if (existing.status === 'accepted') throw new DomainError(400, 'Already fused');
|
||||
if (existing.status === 'pending') throw new DomainError(400, 'Invite already pending');
|
||||
}
|
||||
|
||||
const targetFusion = await this.members.findAcceptedForUser(targetUserId);
|
||||
if (targetFusion) return { error: 'User is already fused with another plan', status: 400 };
|
||||
if (targetFusion) throw new DomainError(400, 'User is already fused with another plan');
|
||||
|
||||
await this.members.insertPending(planId, targetUserId);
|
||||
|
||||
@@ -732,16 +733,14 @@ export class VacayService {
|
||||
// reaches nothing in this direction — and it hid the edge while handing the
|
||||
// send a second NotificationsService built outside the container.
|
||||
this.notifications.send({ event: 'vacay_invite', actorId: inviterId, scope: 'user', targetId: targetUserId, params: { actor: inviterEmail, planId: String(planId) } }).catch(() => {});
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
async acceptInvite(userId: number, planId: number, socketId: string | undefined): Promise<{ error?: string; status?: number }> {
|
||||
async acceptInvite(userId: number, planId: number, socketId: string | undefined): Promise<void> {
|
||||
// The accept flow is a multi-statement write (status flip + entry/year/color
|
||||
// migration + seeding) — atomic, so a failure can't leave the member half-fused.
|
||||
const result = await this.uow.transactional(async (): Promise<{ error?: string; status?: number }> => {
|
||||
await this.uow.transactional(async () => {
|
||||
const invite = await this.members.findPending(planId, userId);
|
||||
if (!invite) return { error: 'No pending invite', status: 404 };
|
||||
if (!invite) throw new DomainError(404, 'No pending invite');
|
||||
|
||||
await this.members.accept(invite.id);
|
||||
|
||||
@@ -781,13 +780,11 @@ export class VacayService {
|
||||
for (const y of targetYears) {
|
||||
await this.userYears.insertIgnore(userId, planId, y, 30, 0);
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
// Only announce a fusion that actually happened — the transaction returns the
|
||||
// refusal for an invite that was already gone.
|
||||
if (!result.error) await this.notifyPlanUsers(planId, socketId, 'vacay:accepted');
|
||||
return result;
|
||||
// Only announce a fusion that actually happened: an invite that was already
|
||||
// gone refuses inside the transaction and never reaches this line.
|
||||
await this.notifyPlanUsers(planId, socketId, 'vacay:accepted');
|
||||
}
|
||||
|
||||
async declineInvite(userId: number, planId: number, socketId: string | undefined): Promise<void> {
|
||||
@@ -928,18 +925,18 @@ export class VacayService {
|
||||
return { outgoing, incoming };
|
||||
}
|
||||
|
||||
async shareCalendar(ownerId: number, ownerEmail: string, targetUserId: number, socketId?: string): Promise<{ error?: string; status?: number }> {
|
||||
if (targetUserId === ownerId) return { error: 'Cannot share with yourself', status: 400 };
|
||||
async shareCalendar(ownerId: number, ownerEmail: string, targetUserId: number, socketId?: string): Promise<void> {
|
||||
if (targetUserId === ownerId) throw new DomainError(400, 'Cannot share with yourself');
|
||||
|
||||
const targetOk = await this.shares.existsInvitableUser(targetUserId);
|
||||
if (!targetOk) return { error: 'User not found', status: 404 };
|
||||
if (!targetOk) throw new DomainError(404, 'User not found');
|
||||
|
||||
const existing = await this.shares.findByOwnerAndUser(ownerId, targetUserId);
|
||||
if (existing) return { error: 'Already shared', status: 400 };
|
||||
if (existing) throw new DomainError(400, 'Already shared');
|
||||
|
||||
// Plan members already see the whole calendar — sharing with them is moot.
|
||||
if ((await this.getPlanUsers(await this.getActivePlanId(ownerId))).find(u => u.id === targetUserId)) {
|
||||
return { error: 'User is already in your calendar', status: 400 };
|
||||
throw new DomainError(400, 'User is already in your calendar');
|
||||
}
|
||||
|
||||
await this.shares.insertShare(ownerId, targetUserId);
|
||||
@@ -951,8 +948,6 @@ export class VacayService {
|
||||
} catch { /* websocket not available */ }
|
||||
|
||||
this.notifications.send({ event: 'vacay_share', actorId: ownerId, scope: 'user', targetId: targetUserId, params: { actor: ownerEmail } }).catch(() => {});
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1315,43 +1310,43 @@ export class VacayService {
|
||||
// Holidays (nager.at proxy with cache)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async getCountries(): Promise<{ data?: unknown; error?: string }> {
|
||||
async getCountries(): Promise<{ data?: unknown }> {
|
||||
const cacheKey = 'countries';
|
||||
const cached = this.holidayCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.time < CACHE_TTL) return { data: cached.data };
|
||||
try {
|
||||
const resp = await fetch('https://date.nager.at/api/v3/AvailableCountries', { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
||||
if (!resp.ok) { discardBody(resp); return { error: 'Failed to fetch countries' }; }
|
||||
if (!resp.ok) { discardBody(resp); throw new DomainError(502, 'Failed to fetch countries'); }
|
||||
const data = await readCappedJson(resp, MAX_HOLIDAY_BYTES);
|
||||
if (data === undefined) return { error: 'Failed to fetch countries' };
|
||||
if (data === undefined) throw new DomainError(502, 'Failed to fetch countries');
|
||||
this.holidayCache.set(cacheKey, { data, time: Date.now() });
|
||||
return { data };
|
||||
} catch {
|
||||
return { error: 'Failed to fetch countries' };
|
||||
throw new DomainError(502, 'Failed to fetch countries');
|
||||
}
|
||||
}
|
||||
|
||||
async getHolidays(year: string, country: string): Promise<{ data?: unknown; error?: string }> {
|
||||
async getHolidays(year: string, country: string): Promise<{ data?: unknown }> {
|
||||
// Both segments land in the URL path, so they are checked before the cache
|
||||
// lookup rather than after it.
|
||||
if (!YEAR_RE.test(year) || !COUNTRY_RE.test(country)) return { error: 'Failed to fetch holidays' };
|
||||
if (!YEAR_RE.test(year) || !COUNTRY_RE.test(country)) throw new DomainError(502, 'Failed to fetch holidays');
|
||||
const cacheKey = `${year}-${country}`;
|
||||
const cached = this.holidayCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.time < CACHE_TTL) return { data: cached.data };
|
||||
try {
|
||||
const resp = await fetch(`https://date.nager.at/api/v3/PublicHolidays/${year}/${country}`, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
||||
if (!resp.ok) { discardBody(resp); return { error: 'Failed to fetch holidays' }; }
|
||||
if (!resp.ok) { discardBody(resp); throw new DomainError(502, 'Failed to fetch holidays'); }
|
||||
const data = await readCappedJson(resp, MAX_HOLIDAY_BYTES);
|
||||
if (data === undefined) return { error: 'Failed to fetch holidays' };
|
||||
if (data === undefined) throw new DomainError(502, 'Failed to fetch holidays');
|
||||
this.holidayCache.set(cacheKey, { data, time: Date.now() });
|
||||
return { data };
|
||||
} catch {
|
||||
return { error: 'Failed to fetch holidays' };
|
||||
throw new DomainError(502, 'Failed to fetch holidays');
|
||||
}
|
||||
}
|
||||
|
||||
async getSchoolHolidayRegions(country: string, language = 'EN'): Promise<{ data?: unknown; error?: string }> {
|
||||
if (!COUNTRY_RE.test(country)) return { error: 'Failed to fetch school holiday regions' };
|
||||
async getSchoolHolidayRegions(country: string, language = 'EN'): Promise<{ data?: unknown }> {
|
||||
if (!COUNTRY_RE.test(country)) throw new DomainError(502, 'Failed to fetch school holiday regions');
|
||||
const normalizedLanguage = String(language || 'EN').slice(0, 2).toUpperCase();
|
||||
const cacheKey = `school-regions-${country}-${normalizedLanguage}`;
|
||||
const cached = this.holidayCache.get(cacheKey);
|
||||
@@ -1364,21 +1359,21 @@ export class VacayService {
|
||||
if (!groupsResp.ok || !subdivisionsResp.ok) {
|
||||
discardBody(groupsResp);
|
||||
discardBody(subdivisionsResp);
|
||||
return { error: 'Failed to fetch school holiday regions' };
|
||||
throw new DomainError(502, 'Failed to fetch school holiday regions');
|
||||
}
|
||||
const groups = await readCappedJson(groupsResp, MAX_HOLIDAY_BYTES);
|
||||
const subdivisions = await readCappedJson(subdivisionsResp, MAX_HOLIDAY_BYTES);
|
||||
if (groups === undefined || subdivisions === undefined) return { error: 'Failed to fetch school holiday regions' };
|
||||
if (groups === undefined || subdivisions === undefined) throw new DomainError(502, 'Failed to fetch school holiday regions');
|
||||
const data = { groups, subdivisions };
|
||||
this.holidayCache.set(cacheKey, { data, time: Date.now() });
|
||||
return { data };
|
||||
} catch {
|
||||
return { error: 'Failed to fetch school holiday regions' };
|
||||
throw new DomainError(502, 'Failed to fetch school holiday regions');
|
||||
}
|
||||
}
|
||||
|
||||
async getSchoolHolidays(year: string, country: string, subdivision?: string | null, language = 'EN', group?: string | null): Promise<{ data?: unknown; error?: string }> {
|
||||
if (!YEAR_RE.test(year) || !COUNTRY_RE.test(country)) return { error: 'Failed to fetch school holidays' };
|
||||
async getSchoolHolidays(year: string, country: string, subdivision?: string | null, language = 'EN', group?: string | null): Promise<{ data?: unknown }> {
|
||||
if (!YEAR_RE.test(year) || !COUNTRY_RE.test(country)) throw new DomainError(502, 'Failed to fetch school holidays');
|
||||
const normalizedLanguage = String(language || 'EN').slice(0, 2).toUpperCase();
|
||||
const normalizedSubdivision = subdivision || '';
|
||||
const normalizedGroup = group || '';
|
||||
@@ -1398,13 +1393,13 @@ export class VacayService {
|
||||
headers: { accept: 'text/json' },
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (!resp.ok) { discardBody(resp); return { error: 'Failed to fetch school holidays' }; }
|
||||
if (!resp.ok) { discardBody(resp); throw new DomainError(502, 'Failed to fetch school holidays'); }
|
||||
const data = await readCappedJson(resp, MAX_HOLIDAY_BYTES);
|
||||
if (data === undefined) return { error: 'Failed to fetch school holidays' };
|
||||
if (data === undefined) throw new DomainError(502, 'Failed to fetch school holidays');
|
||||
this.holidayCache.set(cacheKey, { data, time: Date.now() });
|
||||
return { data };
|
||||
} catch {
|
||||
return { error: 'Failed to fetch school holidays' };
|
||||
throw new DomainError(502, 'Failed to fetch school holidays');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user