refactor(server): places, unsplash, vacay, collections and file downloads raise DomainError, so REST and MCP stop translating results

This commit is contained in:
Maurice
2026-10-10 15:33:19 +02:00
parent e81efcbc57
commit aba2ed1ee3
28 changed files with 227 additions and 269 deletions
@@ -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) {
+5 -4
View File
@@ -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;
-4
View File
@@ -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 });
}
+10 -10
View File
@@ -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;
+4 -1
View File
@@ -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');
}
+4 -3
View File
@@ -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 || [])
+3 -24
View File
@@ -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;
}
}
+7 -12
View File
@@ -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 ?? []));
}
}
+37 -42
View File
@@ -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');
}
}
}