mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
fix(trips): let a trip run past 365 days and refuse a longer range instead of clipping it
A trip longer than a year stopped at day 365: generateDays capped the day rows at MAX_TRIP_DAYS while the trip kept its full end date, so the last days had a date but no day to plan on, in the day list, the transport and booking dialogs and the phone's date pickers (#2403). The limit is MAX_TRIP_DAYS in @trek/shared now, 999 days, shared by the server, both trip forms and the MCP tools, and a range past it is refused rather than cut short: 400 on REST, an error result on MCP, bad params on the plugin RPC, and a message in the forms before anything is sent. The check runs before the trip row and the budget are touched, also catches a start date moved past the stored end on its own, and holds a day_count rebuild to the same bound. A trip that already carries a longer range can still be renamed. The MCP-only 90-day ceiling on create_trip goes with it; it clipped dated trips the same way. Closes #2403
This commit is contained in:
@@ -11,6 +11,7 @@ import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser, buildTrip } from '../../../tests/helpers/factories';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import type { Trip } from '../../types';
|
||||
import { MAX_TRIP_DAYS } from '@trek/shared';
|
||||
import TripFormModal from './TripFormModal';
|
||||
|
||||
const defaultProps = {
|
||||
@@ -321,7 +322,7 @@ describe('TripFormModal', () => {
|
||||
|
||||
it('FE-COMP-TRIPFORM-029: clearing the day count leaves the field empty (no snap to 1)', () => {
|
||||
render(<TripFormModal {...defaultProps} trip={null} />);
|
||||
const dayInput = document.querySelector('input[max="365"]') as HTMLInputElement;
|
||||
const dayInput = document.querySelector(`input[max="${MAX_TRIP_DAYS}"]`) as HTMLInputElement;
|
||||
expect(dayInput).toBeInTheDocument();
|
||||
expect(dayInput.value).toBe('7');
|
||||
fireEvent.change(dayInput, { target: { value: '' } });
|
||||
@@ -333,7 +334,7 @@ describe('TripFormModal', () => {
|
||||
const onSave = vi.fn();
|
||||
render(<TripFormModal {...defaultProps} trip={null} onSave={onSave} />);
|
||||
await user.type(screen.getByPlaceholderText(/Summer in Japan/i), 'No-date Trip');
|
||||
const dayInput = document.querySelector('input[max="365"]') as HTMLInputElement;
|
||||
const dayInput = document.querySelector(`input[max="${MAX_TRIP_DAYS}"]`) as HTMLInputElement;
|
||||
fireEvent.change(dayInput, { target: { value: '' } });
|
||||
const submitBtn = screen.getAllByText('Create New Trip').find(el => el.closest('button'))!;
|
||||
await user.click(submitBtn.closest('button')!);
|
||||
@@ -534,7 +535,7 @@ describe('TripFormModal', () => {
|
||||
expect(screen.getByPlaceholderText(/Summer in Japan/i)).toHaveValue('');
|
||||
expect(screen.getByText(/^EUR/)).toBeInTheDocument();
|
||||
// No dates -> the day-count field appears and falls back to 7.
|
||||
expect(document.querySelector('input[max="365"]')).toHaveValue('7');
|
||||
expect(document.querySelector(`input[max="${MAX_TRIP_DAYS}"]`)).toHaveValue('7');
|
||||
});
|
||||
|
||||
// ── Create follow-ups: members and cover ──────────────────────────────────
|
||||
@@ -921,7 +922,7 @@ describe('TripFormModal', () => {
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
// The day-count field disappears once the trip is dated.
|
||||
await waitFor(() => expect(document.querySelector('input[max="365"]')).toBeNull());
|
||||
await waitFor(() => expect(document.querySelector(`input[max="${MAX_TRIP_DAYS}"]`)).toBeNull());
|
||||
await submitNewTrip(user);
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ start_date: '2026-04-10', end_date: '2026-04-10' })
|
||||
@@ -951,18 +952,18 @@ describe('TripFormModal', () => {
|
||||
));
|
||||
});
|
||||
|
||||
it('FE-COMP-TRIPFORM-067: the day count is clamped to the 1..365 range', async () => {
|
||||
it('FE-COMP-TRIPFORM-067: the day count is clamped to the 1..MAX_TRIP_DAYS range', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSave = vi.fn().mockResolvedValue({});
|
||||
render(<TripFormModal {...defaultProps} trip={null} onSave={onSave} />);
|
||||
|
||||
const dayInput = document.querySelector('input[max="365"]') as HTMLInputElement;
|
||||
fireEvent.change(dayInput, { target: { value: '400' } });
|
||||
expect(dayInput.value).toBe('365');
|
||||
const dayInput = document.querySelector(`input[max="${MAX_TRIP_DAYS}"]`) as HTMLInputElement;
|
||||
fireEvent.change(dayInput, { target: { value: String(MAX_TRIP_DAYS + 1) } });
|
||||
expect(dayInput.value).toBe(String(MAX_TRIP_DAYS));
|
||||
|
||||
await user.type(screen.getByPlaceholderText(/Summer in Japan/i), 'Long Trip');
|
||||
await submitNewTrip(user);
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ day_count: 365 })));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ day_count: MAX_TRIP_DAYS })));
|
||||
});
|
||||
|
||||
it('FE-COMP-TRIPFORM-068: a currency without a known symbol is labelled with its code', () => {
|
||||
@@ -1249,4 +1250,44 @@ describe('TripFormModal', () => {
|
||||
await waitFor(() => expect(revokeObjectURL).toHaveBeenCalledWith('blob:first'));
|
||||
Object.defineProperty(URL, 'revokeObjectURL', { writable: true, configurable: true, value: original });
|
||||
});
|
||||
|
||||
it('FE-COMP-TRIPFORM-085: a trip longer than a year is saved with its full range (#2403)', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSave = vi.fn().mockResolvedValue({});
|
||||
const trip = buildTrip({ id: 1, title: 'Gap year', start_date: '2025-01-26', end_date: '2026-01-28' });
|
||||
render(<TripFormModal {...defaultProps} trip={trip} onSave={onSave} />);
|
||||
await user.click(screen.getByRole('button', { name: /Update/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ start_date: '2025-01-26', end_date: '2026-01-28' })
|
||||
));
|
||||
});
|
||||
|
||||
it('FE-COMP-TRIPFORM-086: a range past MAX_TRIP_DAYS is refused before anything is sent', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSave = vi.fn();
|
||||
const trip = buildTrip({ id: 1, title: 'Week', start_date: '2026-01-01', end_date: '2026-01-07' });
|
||||
render(<TripFormModal {...defaultProps} trip={trip} onSave={onSave} />);
|
||||
|
||||
await user.click(screen.getAllByRole('button', { name: 'Enter date manually' })[1]);
|
||||
const endInput = screen.getByPlaceholderText('DD.MM.YYYY');
|
||||
fireEvent.change(endInput, { target: { value: '2036-01-01' } });
|
||||
fireEvent.keyDown(endInput, { key: 'Enter' });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Update/i }));
|
||||
await screen.findByText(`A trip can span at most ${MAX_TRIP_DAYS} days`);
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-TRIPFORM-087: a trip stored with a longer range can still be renamed', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSave = vi.fn().mockResolvedValue({});
|
||||
const trip = buildTrip({ id: 1, title: 'Legacy', start_date: '2020-01-01', end_date: '2030-01-01' });
|
||||
render(<TripFormModal {...defaultProps} trip={trip} onSave={onSave} />);
|
||||
await user.clear(screen.getByPlaceholderText(/Summer in Japan/i));
|
||||
await user.type(screen.getByPlaceholderText(/Summer in Japan/i), 'Renamed');
|
||||
await user.click(screen.getByRole('button', { name: /Update/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Renamed', start_date: '2020-01-01', end_date: '2030-01-01' })
|
||||
));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useTranslation } from '../../i18n'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
import { normalizeImageFile } from '../../utils/convertHeic'
|
||||
import { getApiErrorMessage, type Trip } from '../../types'
|
||||
import type { TripCreateRequest } from '@trek/shared'
|
||||
import { MAX_TRIP_DAYS, tripSpanDays, type TripCreateRequest } from '@trek/shared'
|
||||
import { NumericInput } from '../shared/NumericInput'
|
||||
import { currenciesWith, SYMBOLS } from '../Budget/BudgetPanel.constants'
|
||||
|
||||
@@ -148,12 +148,17 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
if (!formData.title.trim()) { setError(t('dashboard.titleRequired')); return }
|
||||
if (formData.start_date && formData.end_date && new Date(formData.end_date) < new Date(formData.start_date)) {
|
||||
setError(t('dashboard.endDateError')); return
|
||||
if (formData.start_date && formData.end_date) {
|
||||
const span = tripSpanDays(formData.start_date, formData.end_date)
|
||||
if (span < 1) { setError(t('dashboard.endDateError')); return }
|
||||
// Only a range being set is held to the limit, as on the server: a trip that
|
||||
// already carries a longer one can still be renamed.
|
||||
const datesTouched = !trip || formData.start_date !== (trip.start_date || '') || formData.end_date !== (trip.end_date || '')
|
||||
if (datesTouched && span > MAX_TRIP_DAYS) { setError(t('dashboard.tripTooLong', { days: MAX_TRIP_DAYS })); return }
|
||||
}
|
||||
if (!formData.start_date && !formData.end_date) {
|
||||
const dc = Number(formData.day_count)
|
||||
if (formData.day_count === '' || !Number.isInteger(dc) || dc < 1 || dc > 365) {
|
||||
if (formData.day_count === '' || !Number.isInteger(dc) || dc < 1 || dc > MAX_TRIP_DAYS) {
|
||||
setError(t('dashboard.dayCountRequired')); return
|
||||
}
|
||||
}
|
||||
@@ -557,11 +562,11 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
<label className={labelCls}>
|
||||
{t('dashboard.dayCount')}
|
||||
</label>
|
||||
<NumericInput min={1} max={365} value={formData.day_count}
|
||||
<NumericInput min={1} max={MAX_TRIP_DAYS} value={formData.day_count}
|
||||
onValueChange={raw => {
|
||||
if (raw === '') { update('day_count', ''); return }
|
||||
const n = Math.floor(Number(raw))
|
||||
if (Number.isFinite(n)) update('day_count', Math.min(365, Math.max(1, n)))
|
||||
if (Number.isFinite(n)) update('day_count', Math.min(MAX_TRIP_DAYS, Math.max(1, n)))
|
||||
}}
|
||||
className={inputCls} />
|
||||
<p className="text-caption text-content-faint mt-1.5">{t('dashboard.dayCountHint')}</p>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { CustomDatePicker } from '../../../components/shared/CustomDateTimePicke
|
||||
import CustomSelect from '../../../components/shared/CustomSelect'
|
||||
import { currenciesWith, SYMBOLS } from '../../../components/Budget/BudgetPanel.constants'
|
||||
import type { DashboardTrip } from '../../../pages/dashboard/dashboardModel'
|
||||
import type { Trip, TripCreateRequest } from '@trek/shared'
|
||||
import { MAX_TRIP_DAYS, tripSpanDays, type Trip, type TripCreateRequest } from '@trek/shared'
|
||||
import MSheet from '../../components/MSheet'
|
||||
import MIconBtn from '../../components/MIconBtn'
|
||||
import MListRow from '../../components/MListRow'
|
||||
@@ -112,8 +112,11 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda
|
||||
const handleSave = async () => {
|
||||
setError('')
|
||||
if (!title.trim()) { setError(t('dashboard.titleRequired')); return }
|
||||
if (startDate && endDate && new Date(endDate) < new Date(startDate)) {
|
||||
setError(t('dashboard.endDateError')); return
|
||||
if (startDate && endDate) {
|
||||
const span = tripSpanDays(startDate, endDate)
|
||||
if (span < 1) { setError(t('dashboard.endDateError')); return }
|
||||
const datesTouched = !trip || startDate !== (trip.start_date || '') || endDate !== (trip.end_date || '')
|
||||
if (datesTouched && span > MAX_TRIP_DAYS) { setError(t('dashboard.tripTooLong', { days: MAX_TRIP_DAYS })); return }
|
||||
}
|
||||
setIsSaving(true)
|
||||
try {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { usePermissionsStore } from '../../../../src/store/permissionsStore';
|
||||
import { useSettingsStore } from '../../../../src/store/settingsStore';
|
||||
import { buildUser } from '../../../helpers/factories';
|
||||
import type { DashboardTrip } from '../../../../src/pages/dashboard/dashboardModel';
|
||||
import type { Trip, TripCreateRequest } from '@trek/shared';
|
||||
import { MAX_TRIP_DAYS, type Trip, type TripCreateRequest } from '@trek/shared';
|
||||
|
||||
interface CoverSearchPhoto {
|
||||
id: string
|
||||
@@ -136,6 +136,20 @@ describe('MNewTripSheet', () => {
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-NTSH-036: a range past MAX_TRIP_DAYS blocks the save (#2403)', () => {
|
||||
const onSave = vi.fn(async () => undefined);
|
||||
render(<MNewTripSheet open trip={null} onClose={() => {}} onSave={onSave} />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Summer in Japan'), { target: { value: 'Decade' } });
|
||||
fireEvent.change(screen.getByLabelText('Start Date'), { target: { value: '2026-01-01' } });
|
||||
fireEvent.change(screen.getByLabelText('End Date'), { target: { value: '2036-01-01' } });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create New Trip' }));
|
||||
|
||||
expect(screen.getByText(`A trip can span at most ${MAX_TRIP_DAYS} days`)).toBeInTheDocument();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-NTSH-005: a dateless create asks for the 7 default days and closes', async () => {
|
||||
const onSave = vi.fn(async (_data: TripCreateRequest) => undefined);
|
||||
const onClose = vi.fn();
|
||||
|
||||
@@ -17,8 +17,6 @@ export {
|
||||
TOOL_ANNOTATIONS_WRITE,
|
||||
} from '../../nest-mcp';
|
||||
|
||||
export const MAX_MCP_TRIP_DAYS = 90;
|
||||
|
||||
export function noAccess() {
|
||||
return errorResult('Trip not found or access denied.');
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { RealtimeService } from '../realtime/realtime.service';
|
||||
* src/mcp/tools/_shared.ts, reaching the db Proxy, permissions.bridge and the
|
||||
* src/websocket stub as module globals. The @McpController domain classes are
|
||||
* ordinary Nest providers, so they inject this instead. The pure result
|
||||
* helpers (noAccess/permissionDenied/adminRequired/MAX_MCP_TRIP_DAYS and the
|
||||
* helpers (noAccess/permissionDenied/adminRequired and the
|
||||
* src/nest-mcp re-exports) stay in _shared.ts — they carry no dependencies.
|
||||
*/
|
||||
@Injectable()
|
||||
|
||||
@@ -44,7 +44,7 @@ You are connected to TREK, a travel planning application. Below is a compact ref
|
||||
- The authenticated user can only access trips they own or are a member of. Never guess at trip IDs.
|
||||
- Only the trip owner can delete the trip, add members, or remove members.
|
||||
- Deleting a place removes all of its day assignments as well — warn the user before doing this.
|
||||
- Trips created via MCP are capped at 90 days.
|
||||
- A trip can span at most 999 days, by date range or by day_count.
|
||||
|
||||
## Dates and times
|
||||
|
||||
|
||||
@@ -20,8 +20,8 @@ const airtrailAddonOn = addonGate(ADDON_IDS.AIRTRAIL);
|
||||
/**
|
||||
* The picker sends whatever a human ticked; a model can name a hundred ids off
|
||||
* one hallucinated list, and each one becomes a reservation and a broadcast.
|
||||
* The same reasoning as MAX_MCP_TRIP_DAYS: the tool surface caps a bulk write
|
||||
* the REST route leaves open, and says how to continue.
|
||||
* The tool surface caps a bulk write the REST route leaves open, and says how
|
||||
* to continue.
|
||||
*/
|
||||
const MAX_MCP_AIRTRAIL_FLIGHTS = 50;
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import { RuntimeEnvService } from '../app-config/runtime-env.service';
|
||||
import type { Request, Response } from 'express';
|
||||
import type { Options } from 'multer';
|
||||
import path from 'path';
|
||||
import type { ActiveTripResponse } from '@trek/shared';
|
||||
import { MAX_TRIP_DAYS, type ActiveTripResponse } from '@trek/shared';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import type { User } from '../../types';
|
||||
import { TripsService } from './trips.service';
|
||||
@@ -116,11 +116,16 @@ export class TripsController {
|
||||
if (start_date && end_date && new Date(end_date) < new Date(start_date)) {
|
||||
throw new HttpException({ error: 'End date must be after start date' }, 400);
|
||||
}
|
||||
const parsedDayCount = day_count ? Math.min(Math.max(Number(day_count) || 7, 1), 365) : undefined;
|
||||
const { trip, tripId, reminderDays } = this.trips.create(user.id, { title, description, start_date, end_date, currency, reminder_days, day_count: parsedDayCount });
|
||||
this.audit.writeAudit({ userId: user.id, action: 'trip.create', ip: getClientIp(req), details: { tripId, title, reminder_days: reminderDays === 0 ? 'none' : `${reminderDays} days` } });
|
||||
if (reminderDays > 0) logInfo(`${user.email} set ${reminderDays}-day reminder for trip "${title}"`);
|
||||
return { trip };
|
||||
const parsedDayCount = day_count ? Math.min(Math.max(Number(day_count) || 7, 1), MAX_TRIP_DAYS) : undefined;
|
||||
try {
|
||||
const { trip, tripId, reminderDays } = this.trips.create(user.id, { title, description, start_date, end_date, currency, reminder_days, day_count: parsedDayCount });
|
||||
this.audit.writeAudit({ userId: user.id, action: 'trip.create', ip: getClientIp(req), details: { tripId, title, reminder_days: reminderDays === 0 ? 'none' : `${reminderDays} days` } });
|
||||
if (reminderDays > 0) logInfo(`${user.email} set ${reminderDays}-day reminder for trip "${title}"`);
|
||||
return { trip };
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof ValidationError) throw new HttpException({ error: e.message }, 400);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
|
||||
@@ -12,9 +12,10 @@ import { CalendarService } from '../calendar/calendar.service';
|
||||
import { TripMembersService } from '../trip-members/trip-members.service';
|
||||
import { TripReadModelService } from '../trip-read-model/trip-read-model.service';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { MAX_MCP_TRIP_DAYS, noAccess, permissionDenied } from '../../mcp/tools/_shared';
|
||||
import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
|
||||
import { canRead, canReadTrips, canDeleteTrips } from '../../mcp/scopes';
|
||||
import { TripsService, MAX_TRIP_DAYS, NotFoundError, ValidationError } from './trips.service';
|
||||
import { MAX_TRIP_DAYS } from '@trek/shared';
|
||||
import { TripsService, NotFoundError, ValidationError } from './trips.service';
|
||||
import { TodoService } from '../todo/todo.service';
|
||||
import { CollabService } from '../collab/collab.service';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
@@ -81,7 +82,7 @@ export class TripsMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'create_trip',
|
||||
description: 'Create a new trip. Returns the created trip with its generated days.',
|
||||
description: 'Create a new trip. Returns the created trip; its day_count is the number of days generated, one per day of the date range.',
|
||||
inputSchema: {
|
||||
title: z.string().min(1).max(200).describe('Trip title'),
|
||||
description: z.string().max(2000).optional().describe('Trip description'),
|
||||
@@ -117,8 +118,13 @@ export class TripsMcp {
|
||||
if (start_date && end_date && new Date(end_date) < new Date(start_date)) {
|
||||
return { content: [{ type: 'text' as const, text: 'End date must be after start date.' }], isError: true };
|
||||
}
|
||||
const { trip } = this.trips.create(ctx.userId, { title, description, start_date, end_date, currency, day_count, reminder_days }, MAX_MCP_TRIP_DAYS);
|
||||
return ok({ trip });
|
||||
try {
|
||||
const { trip } = this.trips.create(ctx.userId, { title, description, start_date, end_date, currency, day_count, reminder_days });
|
||||
return ok({ trip });
|
||||
} catch (err) {
|
||||
if (err instanceof ValidationError) return errorResult(err.message);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@Tool({
|
||||
@@ -177,9 +183,14 @@ export class TripsMcp {
|
||||
: { start_date, end_date };
|
||||
// update() re-anchors the budget before the trip row moves off the old
|
||||
// currency (#1543) and then runs the legacy updateTrip core.
|
||||
const { updatedTrip } = await this.trips.update(tripId, ctx.userId, { title, description, ...dates, currency, is_archived, cover_image, day_count, reminder_days, date_shift_mode }, 'user');
|
||||
this.guards.safeBroadcast(tripId, 'trip:updated', { trip: updatedTrip });
|
||||
return ok({ trip: updatedTrip });
|
||||
try {
|
||||
const { updatedTrip } = await this.trips.update(tripId, ctx.userId, { title, description, ...dates, currency, is_archived, cover_image, day_count, reminder_days, date_shift_mode }, 'user');
|
||||
this.guards.safeBroadcast(tripId, 'trip:updated', { trip: updatedTrip });
|
||||
return ok({ trip: updatedTrip });
|
||||
} catch (err) {
|
||||
if (err instanceof ValidationError) return errorResult(err.message);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@Tool({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import path from 'path';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import type { ActiveTrip, TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
|
||||
import { MAX_TRIP_DAYS, tripSpanDays, type ActiveTrip, type TrekWsPayload, type TrekWsTripEventName } from '@trek/shared';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import type { Trip, User } from '../../types';
|
||||
@@ -14,7 +14,19 @@ import { StorageService } from '../storage/storage.service';
|
||||
import { NotFoundError, ValidationError } from '../common/domain-errors';
|
||||
|
||||
export const MS_PER_DAY = 86400000;
|
||||
export const MAX_TRIP_DAYS = 365;
|
||||
|
||||
/**
|
||||
* The date range is refused, not cut short: generateDays used to clip the day
|
||||
* rows at the limit while the trip kept its full end date, so everything past
|
||||
* the cut-off had dates but no day to go on (#2403). An inverted range is
|
||||
* refused here as well, because a start date moved past the stored end date
|
||||
* arrives on its own and would otherwise empty the trip.
|
||||
*/
|
||||
function assertTripSpan(startDate: string, endDate: string) {
|
||||
const span = tripSpanDays(startDate, endDate);
|
||||
if (span < 1) throw new ValidationError('End date must be after start date');
|
||||
if (span > MAX_TRIP_DAYS) throw new ValidationError(`A trip can span at most ${MAX_TRIP_DAYS} days`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips `feed_token` from a trip row on its way out.
|
||||
@@ -171,7 +183,7 @@ export class TripsService {
|
||||
|
||||
// ── Day generation ────────────────────────────────────────────────────────
|
||||
|
||||
generateDays(tripId: number | bigint | string, startDate: string | null, endDate: string | null, maxDays?: number, dayCount?: number) {
|
||||
generateDays(tripId: number | bigint | string, startDate: string | null, endDate: string | null, dayCount?: number) {
|
||||
const existing = this.db.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ?').all(tripId) as { id: number; day_number: number; date: string | null }[];
|
||||
const setDayNumber = this.db.prepare('UPDATE days SET day_number = ? WHERE id = ?');
|
||||
|
||||
@@ -215,10 +227,8 @@ export class TripsService {
|
||||
}
|
||||
|
||||
const [sy, sm, sd] = startDate.split('-').map(Number);
|
||||
const [ey, em, ed] = endDate.split('-').map(Number);
|
||||
const startMs = Date.UTC(sy, sm - 1, sd);
|
||||
const endMs = Date.UTC(ey, em - 1, ed);
|
||||
const numDays = Math.min(Math.floor((endMs - startMs) / MS_PER_DAY) + 1, maxDays ?? MAX_TRIP_DAYS);
|
||||
const numDays = tripSpanDays(startDate, endDate);
|
||||
|
||||
const targetDates: string[] = [];
|
||||
for (let i = 0; i < numDays; i++) {
|
||||
@@ -309,7 +319,8 @@ export class TripsService {
|
||||
`).all({ userId, archived });
|
||||
}
|
||||
|
||||
create(userId: number, data: CreateTripData, maxDays?: number) {
|
||||
create(userId: number, data: CreateTripData) {
|
||||
if (data.start_date && data.end_date) assertTripSpan(data.start_date, data.end_date);
|
||||
const rd = data.reminder_days !== undefined
|
||||
? (Number(data.reminder_days) >= 0 && Number(data.reminder_days) <= 30 ? Number(data.reminder_days) : 3)
|
||||
: 3;
|
||||
@@ -320,7 +331,7 @@ export class TripsService {
|
||||
`).run(userId, data.title, data.description || null, data.start_date || null, data.end_date || null, data.currency || 'EUR', rd);
|
||||
|
||||
const tripId = result.lastInsertRowid;
|
||||
this.generateDays(tripId, data.start_date || null, data.end_date || null, maxDays, data.day_count);
|
||||
this.generateDays(tripId, data.start_date || null, data.end_date || null, data.day_count);
|
||||
|
||||
const trip = this.db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId, tripId });
|
||||
return { trip, tripId: Number(tripId), reminderDays: rd };
|
||||
@@ -384,15 +395,11 @@ export class TripsService {
|
||||
const trip = this.db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as Trip & { reminder_days?: number } | undefined;
|
||||
if (!trip) throw new NotFoundError('Trip not found');
|
||||
|
||||
const { title, description, start_date, end_date, currency, is_archived, cover_image, reminder_days } = data;
|
||||
|
||||
if (start_date && end_date && new Date(end_date) < new Date(start_date))
|
||||
throw new ValidationError('End date must be after start date');
|
||||
const { title, description, currency, is_archived, cover_image, reminder_days } = data;
|
||||
const { newStart, newEnd, dayCount, regenerate } = this.resolveRange(trip, data);
|
||||
|
||||
const newTitle = title || trip.title;
|
||||
const newDesc = description !== undefined ? description : trip.description;
|
||||
const newStart = start_date !== undefined ? start_date : trip.start_date;
|
||||
const newEnd = end_date !== undefined ? end_date : trip.end_date;
|
||||
const newCurrency = currency || trip.currency;
|
||||
const newArchived = is_archived !== undefined ? (is_archived ? 1 : 0) : trip.is_archived;
|
||||
const newCover = cover_image !== undefined ? cover_image : trip.cover_image;
|
||||
@@ -410,8 +417,7 @@ export class TripsService {
|
||||
if (trip.start_date && trip.end_date && newStart && newStart !== trip.start_date)
|
||||
this.vacay.shiftOwnerEntriesForTripWindow(trip.user_id, trip.start_date, trip.end_date, newStart);
|
||||
|
||||
const dayCount = data.day_count ? Math.min(Math.max(Number(data.day_count) || 7, 1), MAX_TRIP_DAYS) : undefined;
|
||||
if (newStart !== trip.start_date || newEnd !== trip.end_date || dayCount) {
|
||||
if (regenerate) {
|
||||
this.db.transaction(() => {
|
||||
// Accommodations have no absolute date columns, so their pre-change dates must be
|
||||
// snapshotted before generateDays re-dates the day rows in place.
|
||||
@@ -419,7 +425,7 @@ export class TripsService {
|
||||
(this.db.prepare('SELECT id, date FROM days WHERE trip_id = ?').all(tripId) as { id: number; date: string | null }[])
|
||||
.map(d => [d.id, d.date]),
|
||||
);
|
||||
this.generateDays(tripId, newStart || null, newEnd || null, undefined, dayCount);
|
||||
this.generateDays(tripId, newStart || null, newEnd || null, dayCount);
|
||||
if (data.date_shift_mode === 'shift_all') {
|
||||
// Explicit "shift everything": bookings stay glued to their (re-dated) day rows,
|
||||
// so re-stamp reservation_time to follow — same rules as reorderDays/insertDay.
|
||||
@@ -456,7 +462,31 @@ export class TripsService {
|
||||
return { updatedTrip, changes, isAdminEdit, ownerEmail, newTitle, newReminder, oldReminder };
|
||||
}
|
||||
|
||||
/**
|
||||
* The dates the update leaves the trip with, checked before anything is
|
||||
* written. The day grid is rebuilt whenever a date moves or a day_count
|
||||
* arrives, and a dated rebuild runs over the whole range, so the range is
|
||||
* held to the limit exactly then. A trip stored with a longer range can
|
||||
* still be renamed.
|
||||
*/
|
||||
private resolveRange(trip: Trip, data: UpdateTripData) {
|
||||
const { start_date, end_date } = data;
|
||||
if (start_date && end_date && new Date(end_date) < new Date(start_date))
|
||||
throw new ValidationError('End date must be after start date');
|
||||
const newStart = start_date !== undefined ? start_date : trip.start_date;
|
||||
const newEnd = end_date !== undefined ? end_date : trip.end_date;
|
||||
const dayCount = data.day_count ? Math.min(Math.max(Number(data.day_count) || 7, 1), MAX_TRIP_DAYS) : undefined;
|
||||
const regenerate = newStart !== trip.start_date || newEnd !== trip.end_date || dayCount !== undefined;
|
||||
if (regenerate && newStart && newEnd) assertTripSpan(newStart, newEnd);
|
||||
return { newStart, newEnd, dayCount, regenerate };
|
||||
}
|
||||
|
||||
async update(tripId: string | number, userId: number, body: UpdateTripData, role: string) {
|
||||
// A refused range must not leave the budget rebased onto a currency the trip
|
||||
// never took, so the dates are checked before the first write.
|
||||
const trip = this.getRaw(tripId);
|
||||
if (!trip) throw new NotFoundError('Trip not found');
|
||||
this.resolveRange(trip, body);
|
||||
// Re-anchor the budget while the outgoing currency is still on the trip row,
|
||||
// otherwise the frozen FX rates and the currency-less expenses that inherit the
|
||||
// trip's base are left pointing at a currency that no longer exists (#1543).
|
||||
|
||||
@@ -12,6 +12,7 @@ import { DatabaseModule } from '../../src/nest/database/database.module';
|
||||
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
import { MAX_TRIP_DAYS } from '@trek/shared';
|
||||
|
||||
const { db } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
@@ -197,6 +198,33 @@ describe('Trips e2e (real auth guard + temp SQLite)', () => {
|
||||
expect(forbidden.status).toBe(403);
|
||||
});
|
||||
|
||||
it('201 create keeps every day of a trip longer than a year (#2403)', async () => {
|
||||
// 2025-01-26 .. 2026-01-28 is 368 days; the day list used to stop at 365.
|
||||
const res = await request(server).post('/api/trips').set('Cookie', sessionCookie(1))
|
||||
.send({ title: 'Gap year', start_date: '2025-01-26', end_date: '2026-01-28' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.trip).toMatchObject({ start_date: '2025-01-26', end_date: '2026-01-28', day_count: 368 });
|
||||
const last = db.prepare('SELECT day_number, date FROM days WHERE trip_id = ? ORDER BY day_number DESC LIMIT 1')
|
||||
.get(res.body.trip.id) as { day_number: number; date: string };
|
||||
expect(last).toEqual({ day_number: 368, date: '2026-01-28' });
|
||||
});
|
||||
|
||||
it('400 on a date range past MAX_TRIP_DAYS, for create and update alike', async () => {
|
||||
const tooLong = await request(server).post('/api/trips').set('Cookie', sessionCookie(1))
|
||||
.send({ title: 'Decade', start_date: '2026-01-01', end_date: '2036-01-01' });
|
||||
expect(tooLong.status).toBe(400);
|
||||
expect(tooLong.body).toEqual({ error: `A trip can span at most ${MAX_TRIP_DAYS} days` });
|
||||
expect(db.prepare('SELECT COUNT(*) AS n FROM trips').get()).toEqual({ n: 0 });
|
||||
|
||||
const week = await request(server).post('/api/trips').set('Cookie', sessionCookie(1))
|
||||
.send({ title: 'Week', start_date: '2026-07-01', end_date: '2026-07-07' });
|
||||
const stretched = await request(server).put(`/api/trips/${week.body.trip.id}`).set('Cookie', sessionCookie(1))
|
||||
.send({ end_date: '2036-07-01' });
|
||||
expect(stretched.status).toBe(400);
|
||||
expect(stretched.body).toEqual({ error: `A trip can span at most ${MAX_TRIP_DAYS} days` });
|
||||
expect(db.prepare('SELECT end_date FROM trips WHERE id = ?').get(week.body.trip.id)).toEqual({ end_date: '2026-07-07' });
|
||||
});
|
||||
|
||||
it('404 on a missing trip', async () => {
|
||||
const res = await request(server).get('/api/trips/77').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(404);
|
||||
|
||||
@@ -43,6 +43,7 @@ import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip, createDay, createPlace, addTripMember, createBudgetItem, createPackingItem, createReservation, createDayNote, createCollabNote, createDayAssignment, createDayAccommodation } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
import { MAX_TRIP_DAYS } from '@trek/shared';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
@@ -99,16 +100,29 @@ describe('Tool: create_trip', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('caps days at 90 for very long trips', async () => {
|
||||
it('generates every day of a trip longer than a year (#2403)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_trip',
|
||||
arguments: { title: 'Long Trip', start_date: '2026-01-01', end_date: '2027-12-31' },
|
||||
arguments: { title: 'Long Trip', start_date: '2025-01-26', end_date: '2026-01-28' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
const days = testDb.prepare('SELECT COUNT(*) as c FROM days WHERE trip_id = ?').get(data.trip.id) as { c: number };
|
||||
expect(days.c).toBe(90);
|
||||
expect(days.c).toBe(368);
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a date range longer than MAX_TRIP_DAYS instead of cutting the days short', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_trip',
|
||||
arguments: { title: 'Decade', start_date: '2026-01-01', end_date: '2036-01-01' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(JSON.stringify(result.content)).toContain(`at most ${MAX_TRIP_DAYS} days`);
|
||||
expect(testDb.prepare('SELECT COUNT(*) as c FROM trips').get()).toEqual({ c: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -153,11 +167,11 @@ describe('Tool: create_trip', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a day_count outside 1..365', async () => {
|
||||
it('refuses a day_count outside 1..MAX_TRIP_DAYS', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
expect((await h.client.callTool({ name: 'create_trip', arguments: { title: 'Zero', day_count: 0 } })).isError).toBe(true);
|
||||
expect((await h.client.callTool({ name: 'create_trip', arguments: { title: 'Huge', day_count: 400 } })).isError).toBe(true);
|
||||
expect((await h.client.callTool({ name: 'create_trip', arguments: { title: 'Huge', day_count: MAX_TRIP_DAYS + 1 } })).isError).toBe(true);
|
||||
expect(testDb.prepare('SELECT COUNT(*) as c FROM trips').get()).toEqual({ c: 0 });
|
||||
});
|
||||
});
|
||||
@@ -378,16 +392,28 @@ describe('Tool: update_trip', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a day_count outside 1..365', async () => {
|
||||
it('refuses a day_count outside 1..MAX_TRIP_DAYS', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Untouched' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
expect((await h.client.callTool({ name: 'update_trip', arguments: { tripId: trip.id, day_count: 0 } })).isError).toBe(true);
|
||||
expect((await h.client.callTool({ name: 'update_trip', arguments: { tripId: trip.id, day_count: 400 } })).isError).toBe(true);
|
||||
expect((await h.client.callTool({ name: 'update_trip', arguments: { tripId: trip.id, day_count: MAX_TRIP_DAYS + 1 } })).isError).toBe(true);
|
||||
expect(testDb.prepare('SELECT COUNT(*) as c FROM days WHERE trip_id = ?').get(trip.id)).toEqual({ c: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a date range longer than MAX_TRIP_DAYS and leaves the trip untouched', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Untouched', start_date: '2026-07-01', end_date: '2026-07-07' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'update_trip', arguments: { tripId: trip.id, end_date: '2036-07-01' } });
|
||||
expect(result.isError).toBe(true);
|
||||
expect(JSON.stringify(result.content)).toContain(`at most ${MAX_TRIP_DAYS} days`);
|
||||
const row = testDb.prepare('SELECT end_date FROM trips WHERE id = ?').get(trip.id) as { end_date: string };
|
||||
expect(row.end_date).toBe('2026-07-07');
|
||||
});
|
||||
});
|
||||
|
||||
it('changes reminder_days and can turn the reminder off', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
@@ -23,7 +23,7 @@ import type { CalendarService } from '../../../src/nest/calendar/calendar.servic
|
||||
import type { TripReadModelService } from '../../../src/nest/trip-read-model/trip-read-model.service';
|
||||
import { NotFoundError, ValidationError } from '../../../src/nest/trips/trips.service';
|
||||
import type { User } from '../../../src/types';
|
||||
import { activeTripResponseSchema, tripCreateRequestSchema, tripTransferOwnershipRequestSchema } from '@trek/shared';
|
||||
import { MAX_TRIP_DAYS, activeTripResponseSchema, tripCreateRequestSchema, tripTransferOwnershipRequestSchema } from '@trek/shared';
|
||||
|
||||
const user = { id: 1, role: 'user', email: 'u@example.test' } as User;
|
||||
const req = { headers: {} } as Request;
|
||||
@@ -180,6 +180,24 @@ describe('TripsController (parity with the legacy /api/trips route)', () => {
|
||||
expect(create).toHaveBeenCalledWith(1, expect.objectContaining({ day_count: 7 }));
|
||||
});
|
||||
|
||||
it('clamps day_count to MAX_TRIP_DAYS', () => {
|
||||
const create = vi.fn().mockReturnValue({ trip: { id: 9 }, tripId: 9, reminderDays: 0 });
|
||||
tc(svc({ create } as Partial<TripsService>)).create(user, { title: 'T', day_count: MAX_TRIP_DAYS + 1 }, req);
|
||||
expect(create).toHaveBeenCalledWith(1, expect.objectContaining({ day_count: MAX_TRIP_DAYS }));
|
||||
});
|
||||
|
||||
it('maps a ValidationError from create to 400 (range past MAX_TRIP_DAYS)', () => {
|
||||
const create = vi.fn().mockImplementation(() => { throw new ValidationError(`A trip can span at most ${MAX_TRIP_DAYS} days`); });
|
||||
expect(thrown(() => tc(svc({ create } as Partial<TripsService>)).create(user, { title: 'T', start_date: '2026-01-01', end_date: '2036-01-01' }, req))).toEqual({
|
||||
status: 400, body: { error: `A trip can span at most ${MAX_TRIP_DAYS} days` },
|
||||
});
|
||||
});
|
||||
|
||||
it('re-throws an unknown error from create', () => {
|
||||
const create = vi.fn().mockImplementation(() => { throw new Error('boom'); });
|
||||
expect(() => tc(svc({ create } as Partial<TripsService>)).create(user, { title: 'T' }, req)).toThrow('boom');
|
||||
});
|
||||
|
||||
it('logs the reminder when reminderDays is set', () => {
|
||||
const create = vi.fn().mockReturnValue({ trip: { id: 9 }, tripId: 9, reminderDays: 3 });
|
||||
expect(tc(svc({ create } as Partial<TripsService>)).create(user, { title: 'T' }, req)).toEqual({ trip: { id: 9 } });
|
||||
|
||||
@@ -54,6 +54,7 @@ import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip, createReservation, createPlace, createDay, createDayAssignment, createDayNote, addTripMember } from '../../helpers/factories';
|
||||
import { MAX_TRIP_DAYS } from '@trek/shared';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { DaysService } from '../../../src/nest/days/days.service';
|
||||
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
||||
@@ -169,6 +170,10 @@ function getNotes(dayId: number) {
|
||||
return testDb.prepare('SELECT * FROM day_notes WHERE day_id = ?').all(dayId) as { id: number; day_id: number }[];
|
||||
}
|
||||
|
||||
function addDaysIso(date: string, n: number) {
|
||||
return new Date(Date.parse(date + 'T00:00:00Z') + n * 86400000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('generateDays', () => {
|
||||
@@ -260,6 +265,24 @@ describe('generateDays', () => {
|
||||
expect(getAssignments(daysAfter[4].id)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('TRIP-SVC-062: a range longer than a year gets every one of its days (#2403)', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { start_date: '2025-01-26', end_date: '2025-01-28' });
|
||||
// The reporter's range: 368 days, and the days used to stop at 365.
|
||||
svc.generateDays(trip.id, '2025-01-26', '2026-01-28');
|
||||
const days = getDays(trip.id);
|
||||
expect(days).toHaveLength(368);
|
||||
expect(days[364].date).toBe('2026-01-25');
|
||||
expect(days[367]).toMatchObject({ day_number: 368, date: '2026-01-28' });
|
||||
});
|
||||
|
||||
it('TRIP-SVC-063: a dateless day_count is clamped to MAX_TRIP_DAYS', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
svc.generateDays(trip.id, null, null, MAX_TRIP_DAYS + 50);
|
||||
expect(getDays(trip.id)).toHaveLength(MAX_TRIP_DAYS);
|
||||
});
|
||||
|
||||
it('TRIP-SVC-013: clearing dates converts all days to dateless without destroying assignments', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { start_date: '2025-09-01', end_date: '2025-09-04' });
|
||||
@@ -722,6 +745,17 @@ describe('folded trip CRUD', () => {
|
||||
expect(getDays(tripId)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('TRIP-SVC-064: create refuses a range past MAX_TRIP_DAYS and writes nothing', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const before = (testDb.prepare('SELECT COUNT(*) AS n FROM trips').get() as { n: number }).n;
|
||||
expect(() => svc.create(user.id, { title: 'Decade', start_date: '2026-01-01', end_date: '2036-01-01' }))
|
||||
.toThrow(`A trip can span at most ${MAX_TRIP_DAYS} days`);
|
||||
expect((testDb.prepare('SELECT COUNT(*) AS n FROM trips').get() as { n: number }).n).toBe(before);
|
||||
// The longest allowed range goes through in full.
|
||||
const { tripId } = svc.create(user.id, { title: 'Longest', start_date: '2026-01-01', end_date: addDaysIso('2026-01-01', MAX_TRIP_DAYS - 1) });
|
||||
expect(getDays(tripId)).toHaveLength(MAX_TRIP_DAYS);
|
||||
});
|
||||
|
||||
it('TRIP-SVC-045: remove deletes the trip, cleans skeleton journey entries and detaches filled ones', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
@@ -930,13 +964,15 @@ describe('folded trip CRUD', () => {
|
||||
|
||||
describe('TripsService wrapper helpers', () => {
|
||||
it('re-anchors the budget before the trip row leaves its old currency (#1543)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const order: string[] = [];
|
||||
const rebaseSpy = vi.spyOn(budgetSvc, 'rebaseTripCurrency').mockImplementation(async () => { order.push('rebase'); });
|
||||
const updateSpy = vi.spyOn(svc, 'updateTrip').mockImplementation(() => { order.push('update'); return {} as never; });
|
||||
try {
|
||||
await svc.update('9', 1, { currency: 'RUB' } as never, 'user');
|
||||
await svc.update(trip.id, user.id, { currency: 'RUB' } as never, 'user');
|
||||
// The rebase reads the outgoing currency off the trip row, so it has to run first.
|
||||
expect(rebaseSpy).toHaveBeenCalledWith('9', 'RUB');
|
||||
expect(rebaseSpy).toHaveBeenCalledWith(trip.id, 'RUB');
|
||||
expect(order).toEqual(['rebase', 'update']);
|
||||
} finally {
|
||||
rebaseSpy.mockRestore();
|
||||
@@ -944,6 +980,22 @@ describe('TripsService wrapper helpers', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('TRIP-SVC-068: update refuses a bad range before the budget is rebased (#2403)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { start_date: '2026-07-01', end_date: '2026-07-07' });
|
||||
const rebaseSpy = vi.spyOn(budgetSvc, 'rebaseTripCurrency').mockResolvedValue();
|
||||
try {
|
||||
await expect(svc.update(trip.id, user.id, { currency: 'USD', end_date: '2036-07-01' }, 'user'))
|
||||
.rejects.toThrow(`A trip can span at most ${MAX_TRIP_DAYS} days`);
|
||||
await expect(svc.update(trip.id, user.id, { currency: 'USD', start_date: '2026-07-10' }, 'user'))
|
||||
.rejects.toThrow('End date must be after start date');
|
||||
expect(rebaseSpy).not.toHaveBeenCalled();
|
||||
await expect(svc.update(99999, user.id, { currency: 'USD' }, 'user')).rejects.toThrow('Trip not found');
|
||||
} finally {
|
||||
rebaseSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('canAccessTrip delegates to the db helper; can() delegates to checkPermission; broadcast forwards', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
@@ -1006,6 +1058,40 @@ describe('folded quirk branches', () => {
|
||||
expect(() => svc.updateTrip(trip.id, owner.id, { start_date: '2025-06-10', end_date: '2025-06-01' }, 'user')).toThrow('End date must be after start date');
|
||||
});
|
||||
|
||||
it('TRIP-SVC-065: updateTrip refuses a range past MAX_TRIP_DAYS before touching the row', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Week', start_date: '2026-07-01', end_date: '2026-07-07' });
|
||||
expect(() => svc.updateTrip(trip.id, user.id, { title: 'Decade', end_date: '2036-07-01' }, 'user'))
|
||||
.toThrow(`A trip can span at most ${MAX_TRIP_DAYS} days`);
|
||||
expect(testDb.prepare('SELECT title, end_date FROM trips WHERE id = ?').get(trip.id)).toEqual({ title: 'Week', end_date: '2026-07-07' });
|
||||
expect(getDays(trip.id)).toHaveLength(7);
|
||||
// Moving only the start keeps the stored end and is measured against it.
|
||||
expect(() => svc.updateTrip(trip.id, user.id, { start_date: '2020-01-01' }, 'user'))
|
||||
.toThrow(`A trip can span at most ${MAX_TRIP_DAYS} days`);
|
||||
});
|
||||
|
||||
it('TRIP-SVC-066: a trip whose stored range already exceeds the limit can still be renamed', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Legacy' });
|
||||
testDb.prepare("UPDATE trips SET start_date = '2020-01-01', end_date = '2030-01-01' WHERE id = ?").run(trip.id);
|
||||
const result = svc.updateTrip(trip.id, user.id, { title: 'Renamed' }, 'user');
|
||||
expect(result.newTitle).toBe('Renamed');
|
||||
expect(result.changes).toEqual({ title: 'Renamed' });
|
||||
// A day_count would rebuild the grid over the whole stored range, so it is held to the limit too.
|
||||
expect(() => svc.updateTrip(trip.id, user.id, { day_count: 5 }, 'user'))
|
||||
.toThrow(`A trip can span at most ${MAX_TRIP_DAYS} days`);
|
||||
expect(getDays(trip.id)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('TRIP-SVC-067: a start date moved past the stored end is refused instead of emptying the trip', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Week', start_date: '2026-07-01', end_date: '2026-07-07' });
|
||||
expect(() => svc.updateTrip(trip.id, user.id, { start_date: '2026-07-10' }, 'user'))
|
||||
.toThrow('End date must be after start date');
|
||||
expect(testDb.prepare('SELECT start_date FROM trips WHERE id = ?').get(trip.id)).toEqual({ start_date: '2026-07-01' });
|
||||
expect(getDays(trip.id)).toHaveLength(7);
|
||||
});
|
||||
|
||||
it('TRIP-SVC-049: addMember inserts the membership and reports the trip title; removeMember deletes it', () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: invitee } = createUser(testDb);
|
||||
|
||||
@@ -15,6 +15,7 @@ import { PluginGuards } from '../../../src/nest/plugins/host/plugin-guards.servi
|
||||
import { TripsRpc } from '../../../src/nest/trips/trips.rpc';
|
||||
import { TripsModule } from '../../../src/nest/trips/trips.module';
|
||||
import { NotFoundError, ValidationError } from '../../../src/nest/trips/trips.service';
|
||||
import { MAX_TRIP_DAYS } from '@trek/shared';
|
||||
import type { TripsService } from '../../../src/nest/trips/trips.service';
|
||||
import type { ReservationsService } from '../../../src/nest/reservations/reservations.service';
|
||||
import type { DaysService } from '../../../src/nest/days/days.service';
|
||||
@@ -33,14 +34,17 @@ const req = (method: string, params: Record<string, unknown> = {}): RpcRequest =
|
||||
const ALL_TRIP_GRANTS = ['db:read:trips', 'db:write:trips', 'db:create:trips', 'db:write:members'];
|
||||
|
||||
/** Trip 1 belongs to user 42. `allow` decides each individual permission check. */
|
||||
export function build(opts: { allow?: (action: string) => boolean; updateThrows?: Error } = {}) {
|
||||
export function build(opts: { allow?: (action: string) => boolean; updateThrows?: Error; createThrows?: Error } = {}) {
|
||||
const trips = {
|
||||
list: vi.fn(() => [{ id: 1, title: 'Japan' }]),
|
||||
updateTrip: vi.fn(() => {
|
||||
if (opts.updateThrows) throw opts.updateThrows;
|
||||
return { updatedTrip: { id: 1, title: 'Japan' } };
|
||||
}),
|
||||
create: vi.fn((userId: number) => ({ trip: { id: 99, user_id: userId } })),
|
||||
create: vi.fn((userId: number) => {
|
||||
if (opts.createThrows) throw opts.createThrows;
|
||||
return { trip: { id: 99, user_id: userId } };
|
||||
}),
|
||||
removeMember: vi.fn(),
|
||||
} as unknown as TripsService & Record<string, ReturnType<typeof vi.fn>>;
|
||||
const reservations = { list: vi.fn(() => [{ id: 5 }]) } as unknown as ReservationsService & Record<string, ReturnType<typeof vi.fn>>;
|
||||
@@ -169,6 +173,12 @@ describe('TripsRpc writes', () => {
|
||||
expect(res.error.message).toBe('no permission to create trips');
|
||||
expect(f.trips.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('TRIPS-RPC-017 a range the service refuses comes back as BAD_PARAMS (#2403)', async () => {
|
||||
const f = build({ createThrows: new ValidationError(`A trip can span at most ${MAX_TRIP_DAYS} days`) });
|
||||
const res = (await f.host().dispatch(req('trips.create', { input: { title: 'Decade', start_date: '2026-01-01', end_date: '2036-01-01' } }), 42)) as RpcError;
|
||||
expect(res.error).toMatchObject({ code: 'BAD_PARAMS', message: `A trip can span at most ${MAX_TRIP_DAYS} days` });
|
||||
});
|
||||
});
|
||||
|
||||
describe('TripsRpc membership', () => {
|
||||
|
||||
@@ -87,6 +87,7 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.useUnsplashPhoto': 'استخدم صورة من Unsplash بواسطة {photographer}',
|
||||
'dashboard.titleRequired': 'العنوان مطلوب',
|
||||
'dashboard.endDateError': 'يجب أن يكون تاريخ النهاية بعد البداية',
|
||||
'dashboard.tripTooLong': 'لا يمكن أن تتجاوز مدة الرحلة {days} يومًا',
|
||||
'dashboard.dateShiftTitle': 'تاريخ بداية جديد',
|
||||
'dashboard.dateShiftIntro': 'لقد غيّرت موعد بداية هذه الرحلة. كيف تريد أن تتبع خططك التواريخ الجديدة؟',
|
||||
'dashboard.dateShiftKeepBookings': 'إبقاء الحجوزات في تواريخها',
|
||||
|
||||
@@ -87,6 +87,7 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.useUnsplashPhoto': 'Usar foto do Unsplash por {photographer}',
|
||||
'dashboard.titleRequired': 'O título é obrigatório',
|
||||
'dashboard.endDateError': 'A data final deve ser depois da inicial',
|
||||
'dashboard.tripTooLong': 'Uma viagem pode ter no máximo {days} dias',
|
||||
'dashboard.dateShiftTitle': 'Nova data de início',
|
||||
'dashboard.dateShiftIntro': 'Você alterou o início desta viagem. Como seus planos devem acompanhar as novas datas?',
|
||||
'dashboard.dateShiftKeepBookings': 'Manter reservas em suas datas',
|
||||
|
||||
@@ -75,6 +75,7 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.coverRemoveError': 'Error en eliminar la imatge',
|
||||
'dashboard.titleRequired': 'El títol és obligatori',
|
||||
'dashboard.endDateError': "La data de fi ha de ser posterior a la d'inici",
|
||||
'dashboard.tripTooLong': 'Un viatge pot durar com a màxim {days} dies',
|
||||
'dashboard.greeting.morning': 'Bon dia,',
|
||||
'dashboard.greeting.afternoon': 'Bona tarda,',
|
||||
'dashboard.greeting.evening': 'Bona nit,',
|
||||
|
||||
@@ -87,6 +87,7 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.useUnsplashPhoto': 'Použít fotku z Unsplash od {photographer}',
|
||||
'dashboard.titleRequired': 'Název je povinný',
|
||||
'dashboard.endDateError': 'Datum konce musí být po datu začátku',
|
||||
'dashboard.tripTooLong': 'Cesta může trvat nejvýše {days} dní',
|
||||
'dashboard.dateShiftTitle': 'Nové datum začátku',
|
||||
'dashboard.dateShiftIntro': 'Změnili jste začátek této cesty. Jak se mají vaše plány přizpůsobit novým termínům?',
|
||||
'dashboard.dateShiftKeepBookings': 'Ponechat rezervace v původních termínech',
|
||||
|
||||
@@ -88,6 +88,7 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.useUnsplashPhoto': 'Unsplash-Foto von {photographer} verwenden',
|
||||
'dashboard.titleRequired': 'Titel ist erforderlich',
|
||||
'dashboard.endDateError': 'Enddatum muss nach dem Startdatum liegen',
|
||||
'dashboard.tripTooLong': 'Eine Reise kann höchstens {days} Tage umfassen',
|
||||
'dashboard.dateShiftTitle': 'Neues Startdatum',
|
||||
'dashboard.dateShiftIntro':
|
||||
'Du hast den Beginn dieser Reise geändert. Wie sollen deine Pläne den neuen Daten folgen?',
|
||||
|
||||
@@ -101,6 +101,7 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.useUnsplashPhoto': 'Use Unsplash photo by {photographer}',
|
||||
'dashboard.titleRequired': 'Title is required',
|
||||
'dashboard.endDateError': 'End date must be after start date',
|
||||
'dashboard.tripTooLong': 'A trip can span at most {days} days',
|
||||
'dashboard.dateShiftTitle': 'New start date',
|
||||
'dashboard.dateShiftIntro': 'You changed when this trip starts. How should your plans follow the new dates?',
|
||||
'dashboard.dateShiftKeepBookings': 'Keep bookings on their dates',
|
||||
|
||||
@@ -87,6 +87,7 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.useUnsplashPhoto': 'Usar foto de Unsplash de {photographer}',
|
||||
'dashboard.titleRequired': 'El título es obligatorio',
|
||||
'dashboard.endDateError': 'La fecha de fin debe ser posterior a la de inicio',
|
||||
'dashboard.tripTooLong': 'Un viaje puede durar como máximo {days} días',
|
||||
'dashboard.dateShiftTitle': 'Nueva fecha de inicio',
|
||||
'dashboard.dateShiftIntro':
|
||||
'Has cambiado cuándo empieza este viaje. ¿Cómo deben seguir tus planes las nuevas fechas?',
|
||||
|
||||
@@ -89,6 +89,7 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.useUnsplashPhoto': 'Utiliser la photo Unsplash de {photographer}',
|
||||
'dashboard.titleRequired': 'Le titre est obligatoire',
|
||||
'dashboard.endDateError': 'La date de fin doit être postérieure à la date de début',
|
||||
'dashboard.tripTooLong': 'Un voyage ne peut pas dépasser {days} jours',
|
||||
'dashboard.dateShiftTitle': 'Nouvelle date de début',
|
||||
'dashboard.dateShiftIntro':
|
||||
'Vous avez modifié la date de début de ce voyage. Comment vos plans doivent-ils suivre les nouvelles dates ?',
|
||||
|
||||
@@ -99,6 +99,7 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.useUnsplashPhoto': 'Χρήση φωτογραφίας Unsplash από {photographer}',
|
||||
'dashboard.titleRequired': 'Ο τίτλος είναι υποχρεωτικός',
|
||||
'dashboard.endDateError': 'Η ημερομηνία λήξης πρέπει να είναι μετά την ημερομηνία έναρξης',
|
||||
'dashboard.tripTooLong': 'Ένα ταξίδι μπορεί να διαρκεί το πολύ {days} ημέρες',
|
||||
'dashboard.dateShiftTitle': 'Νέα ημερομηνία έναρξης',
|
||||
'dashboard.dateShiftIntro':
|
||||
'Αλλάξατε την ημερομηνία έναρξης αυτού του ταξιδιού. Πώς θέλετε να ακολουθήσουν τα σχέδιά σας τις νέες ημερομηνίες;',
|
||||
|
||||
@@ -89,6 +89,7 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.useUnsplashPhoto': '{photographer} Unsplash-fotójának használata',
|
||||
'dashboard.titleRequired': 'A cím megadása kötelező',
|
||||
'dashboard.endDateError': 'A záró dátumnak a kezdő dátum után kell lennie',
|
||||
'dashboard.tripTooLong': 'Egy utazás legfeljebb {days} napos lehet',
|
||||
'dashboard.dateShiftTitle': 'Új kezdő dátum',
|
||||
'dashboard.dateShiftIntro': 'Megváltoztattad az út kezdetét. Hogyan kövessék a terveid az új dátumokat?',
|
||||
'dashboard.dateShiftKeepBookings': 'A foglalások maradjanak a saját dátumaikon',
|
||||
|
||||
@@ -87,6 +87,7 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.useUnsplashPhoto': 'Gunakan foto Unsplash oleh {photographer}',
|
||||
'dashboard.titleRequired': 'Judul wajib diisi',
|
||||
'dashboard.endDateError': 'Tanggal selesai harus setelah tanggal mulai',
|
||||
'dashboard.tripTooLong': 'Perjalanan paling lama {days} hari',
|
||||
'dashboard.dateShiftTitle': 'Tanggal mulai baru',
|
||||
'dashboard.dateShiftIntro':
|
||||
'Kamu mengubah tanggal mulai perjalanan ini. Bagaimana rencanamu harus mengikuti tanggal baru?',
|
||||
|
||||
@@ -89,6 +89,7 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.useUnsplashPhoto': 'Usa foto Unsplash di {photographer}',
|
||||
'dashboard.titleRequired': 'Il titolo è obbligatorio',
|
||||
'dashboard.endDateError': 'La data di fine deve essere successiva alla data di inizio',
|
||||
'dashboard.tripTooLong': 'Un viaggio può durare al massimo {days} giorni',
|
||||
'dashboard.dateShiftTitle': 'Nuova data di inizio',
|
||||
'dashboard.dateShiftIntro':
|
||||
'Hai modificato la data di inizio di questo viaggio. Come devono adattarsi i tuoi piani alle nuove date?',
|
||||
|
||||
@@ -102,6 +102,7 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.useUnsplashPhoto': '{photographer}さんのUnsplash写真を使用',
|
||||
'dashboard.titleRequired': 'タイトルは必須です',
|
||||
'dashboard.endDateError': '終了日は開始日より後にしてください',
|
||||
'dashboard.tripTooLong': '旅行の期間は最大{days}日までです',
|
||||
'dashboard.dateShiftTitle': '新しい開始日',
|
||||
'dashboard.dateShiftIntro': '旅行の開始日が変更されました。予定を新しい日程にどのように合わせますか?',
|
||||
'dashboard.dateShiftKeepBookings': '予約を元の日付のままにする',
|
||||
|
||||
@@ -102,6 +102,7 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.useUnsplashPhoto': '{photographer}님의 Unsplash 사진 사용',
|
||||
'dashboard.titleRequired': '제목을 입력하세요',
|
||||
'dashboard.endDateError': '종료일은 시작일 이후여야 합니다',
|
||||
'dashboard.tripTooLong': '여행 기간은 최대 {days}일까지입니다',
|
||||
'dashboard.dateShiftTitle': '새 시작일',
|
||||
'dashboard.dateShiftIntro': '여행 시작일이 변경되었습니다. 계획을 새 날짜에 어떻게 맞출까요?',
|
||||
'dashboard.dateShiftKeepBookings': '예약은 원래 날짜에 유지',
|
||||
|
||||
@@ -87,6 +87,7 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.useUnsplashPhoto': 'Unsplash-foto van {photographer} gebruiken',
|
||||
'dashboard.titleRequired': 'Titel is verplicht',
|
||||
'dashboard.endDateError': 'Einddatum moet na de startdatum liggen',
|
||||
'dashboard.tripTooLong': 'Een reis kan maximaal {days} dagen duren',
|
||||
'dashboard.dateShiftTitle': 'Nieuwe startdatum',
|
||||
'dashboard.dateShiftIntro':
|
||||
'Je hebt gewijzigd wanneer deze reis begint. Hoe moeten je plannen de nieuwe datums volgen?',
|
||||
|
||||
@@ -83,6 +83,7 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.useUnsplashPhoto': 'Użyj zdjęcia z Unsplash autorstwa {photographer}',
|
||||
'dashboard.titleRequired': 'Nazwa podróży jest wymagana',
|
||||
'dashboard.endDateError': 'Data zakończenia musi być po dacie rozpoczęcia',
|
||||
'dashboard.tripTooLong': 'Podróż może trwać maksymalnie {days} dni',
|
||||
'dashboard.dateShiftTitle': 'Nowa data rozpoczęcia',
|
||||
'dashboard.dateShiftIntro':
|
||||
'Data rozpoczęcia podróży została zmieniona. Jak plany mają dostosować się do nowych dat?',
|
||||
|
||||
@@ -87,6 +87,7 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.useUnsplashPhoto': 'Использовать фото Unsplash от {photographer}',
|
||||
'dashboard.titleRequired': 'Название обязательно',
|
||||
'dashboard.endDateError': 'Дата окончания должна быть позже даты начала',
|
||||
'dashboard.tripTooLong': 'Поездка может длиться не более {days} дней',
|
||||
'dashboard.dateShiftTitle': 'Новая дата начала',
|
||||
'dashboard.dateShiftIntro': 'Вы изменили дату начала поездки. Как перенести ваши планы на новые даты?',
|
||||
'dashboard.dateShiftKeepBookings': 'Оставить бронирования на своих датах',
|
||||
|
||||
@@ -101,6 +101,7 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.useUnsplashPhoto': 'Använd Unsplash-foto av {photographer}',
|
||||
'dashboard.titleRequired': 'Titel är obligatoriskt',
|
||||
'dashboard.endDateError': 'Slutdatumet måste ligga efter startdatumet',
|
||||
'dashboard.tripTooLong': 'En resa kan vara högst {days} dagar',
|
||||
'dashboard.dateShiftTitle': 'Nytt startdatum',
|
||||
'dashboard.dateShiftIntro': 'Du har ändrat när resan börjar. Hur ska dina planer följa de nya datumen?',
|
||||
'dashboard.dateShiftKeepBookings': 'Behåll bokningar på sina datum',
|
||||
|
||||
@@ -101,6 +101,7 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.useUnsplashPhoto': '{photographer} tarafından çekilen Unsplash fotoğrafını kullan',
|
||||
'dashboard.titleRequired': 'Başlık gerekli',
|
||||
'dashboard.endDateError': 'Bitiş tarihi başlangıçtan sonra olmalı',
|
||||
'dashboard.tripTooLong': 'Bir seyahat en fazla {days} gün sürebilir',
|
||||
'dashboard.dateShiftTitle': 'Yeni başlangıç tarihi',
|
||||
'dashboard.dateShiftIntro':
|
||||
'Bu seyahatin başlangıç tarihini değiştirdiniz. Planlarınız yeni tarihlere nasıl uyum sağlasın?',
|
||||
|
||||
@@ -103,6 +103,7 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.useUnsplashPhoto': 'Використати фото Unsplash від {photographer}',
|
||||
'dashboard.titleRequired': "Назва обов'язкова",
|
||||
'dashboard.endDateError': 'Дата закінчення повинна бути пізніше дати початку',
|
||||
'dashboard.tripTooLong': 'Подорож може тривати не більше {days} днів',
|
||||
'dashboard.dateShiftTitle': 'Нова дата початку',
|
||||
'dashboard.dateShiftIntro': 'Ви змінили дату початку поїздки. Як ваші плани мають перейти на нові дати?',
|
||||
'dashboard.dateShiftKeepBookings': 'Залишити бронювання на своїх датах',
|
||||
|
||||
@@ -95,6 +95,7 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.coverRemoveError': 'Không thể xóa',
|
||||
'dashboard.titleRequired': 'Tiêu đề là bắt buộc',
|
||||
'dashboard.endDateError': 'Ngày kết thúc phải sau ngày bắt đầu',
|
||||
'dashboard.tripTooLong': 'Một chuyến đi có thể kéo dài tối đa {days} ngày',
|
||||
'dashboard.dateShiftTitle': 'Ngày bắt đầu mới',
|
||||
'dashboard.dateShiftIntro':
|
||||
'Bạn đã thay đổi thời điểm bắt đầu chuyến đi. Các kế hoạch của bạn nên theo ngày mới như thế nào?',
|
||||
|
||||
@@ -87,6 +87,7 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.useUnsplashPhoto': '使用 {photographer} 的 Unsplash 照片',
|
||||
'dashboard.titleRequired': '標題為必填項',
|
||||
'dashboard.endDateError': '結束日期必須晚於開始日期',
|
||||
'dashboard.tripTooLong': '行程最長為 {days} 天',
|
||||
'dashboard.dateShiftTitle': '新的開始日期',
|
||||
'dashboard.dateShiftIntro': '你變更了旅行的開始時間。你的計劃要如何配合新日期?',
|
||||
'dashboard.dateShiftKeepBookings': '預訂保留在原日期',
|
||||
|
||||
@@ -87,6 +87,7 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.useUnsplashPhoto': '使用 {photographer} 的 Unsplash 照片',
|
||||
'dashboard.titleRequired': '标题为必填项',
|
||||
'dashboard.endDateError': '结束日期必须晚于开始日期',
|
||||
'dashboard.tripTooLong': '行程最长为 {days} 天',
|
||||
'dashboard.dateShiftTitle': '新的开始日期',
|
||||
'dashboard.dateShiftIntro': '您更改了旅行的开始时间。您的计划应如何适应新的日期?',
|
||||
'dashboard.dateShiftKeepBookings': '保留预订的原定日期',
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import {
|
||||
MAX_TRIP_DAYS,
|
||||
tripSpanDays,
|
||||
tripCreateRequestSchema,
|
||||
tripUpdateRequestSchema,
|
||||
tripAddMemberRequestSchema,
|
||||
@@ -21,6 +23,26 @@ describe('tripCreateRequestSchema', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('MAX_TRIP_DAYS', () => {
|
||||
it('lets a trip run past a year and keeps day numbers to three digits (#2403)', () => {
|
||||
expect(MAX_TRIP_DAYS).toBeGreaterThan(366);
|
||||
expect(MAX_TRIP_DAYS).toBeLessThan(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tripSpanDays', () => {
|
||||
it('counts both ends, across a leap day and a year boundary', () => {
|
||||
expect(tripSpanDays('2026-07-01', '2026-07-01')).toBe(1);
|
||||
expect(tripSpanDays('2026-07-01', '2026-07-07')).toBe(7);
|
||||
expect(tripSpanDays('2024-02-28', '2024-03-01')).toBe(3);
|
||||
expect(tripSpanDays('2025-01-26', '2026-01-28')).toBe(368);
|
||||
});
|
||||
|
||||
it('goes below one when the end precedes the start', () => {
|
||||
expect(tripSpanDays('2026-07-07', '2026-07-01')).toBe(-5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tripUpdateRequestSchema', () => {
|
||||
it('is fully partial and accepts is_archived + cover_image', () => {
|
||||
expect(tripUpdateRequestSchema.safeParse({}).success).toBe(true);
|
||||
|
||||
@@ -13,6 +13,25 @@ import { z } from 'zod';
|
||||
* permission checks + audit logging. Trip rows are wide, so responses stay open.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The most days a trip can have, whether from its date range or from the
|
||||
* `day_count` of a dateless trip. A range longer than this is refused rather
|
||||
* than cut short: a trip whose end date lies past its last day is exactly the
|
||||
* kind of half-state #2403 reported, and a mistyped year is easier to spot as
|
||||
* an error than as a few hundred empty days. Three digits keep every day badge
|
||||
* in the planner intact.
|
||||
*/
|
||||
export const MAX_TRIP_DAYS = 999;
|
||||
|
||||
/** Calendar days in an inclusive YYYY-MM-DD range, counted the same way on both sides of the wire. */
|
||||
export function tripSpanDays(startDate: string, endDate: string): number {
|
||||
const utcDay = (date: string) => {
|
||||
const [y = NaN, m = NaN, d = NaN] = date.split('-').map(Number);
|
||||
return Date.UTC(y, m - 1, d);
|
||||
};
|
||||
return Math.floor((utcDay(endDate) - utcDay(startDate)) / 86400000) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trip entity as returned by the trip list / get / create / update endpoints
|
||||
* (server/src/services/tripService.ts -> TRIP_SELECT). Columns of the `trips`
|
||||
|
||||
@@ -20,9 +20,9 @@ A short free-text description. It is not shown on the trip card or on the Spotli
|
||||
|
||||
### Dates
|
||||
|
||||
Set a **Start date** and **End date** using the date picker. The day count is calculated automatically when both are set.
|
||||
Set a **Start date** and **End date** using the date picker. The day count is calculated automatically when both are set. A trip can span up to **999 days**.
|
||||
|
||||
On desktop, if you leave **both** dates empty, a separate **Day count** field appears. Enter a number between **1 and 365** to create a date-less itinerary with a fixed number of days. The mobile sheet has no such field: a date-less trip created there always starts with 7 days.
|
||||
On desktop, if you leave **both** dates empty, a separate **Day count** field appears. Enter a number between **1 and 999** to create a date-less itinerary with a fixed number of days. The mobile sheet has no such field: a date-less trip created there always starts with 7 days.
|
||||
|
||||
Only the **start** date is linked to the other one. Picking a start date fills the end date to match it, and moving the start date of an already-dated trip shifts the end date so the previous duration is kept. Nothing links them the other way round: you can pick an end date on its own, and the picker's **✕** button clears either field afterwards. A trip can therefore be saved with both dates, with only a start date, with only an end date, or with neither. A trip left with exactly one date gets an undated day grid (7 days by default), just like a date-less one.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user