diff --git a/server/src/nest/trips/trips.controller.ts b/server/src/nest/trips/trips.controller.ts index 9aa44e356..15fd8e1ce 100644 --- a/server/src/nest/trips/trips.controller.ts +++ b/server/src/nest/trips/trips.controller.ts @@ -24,6 +24,7 @@ import { diskStorage } from 'multer'; import path from 'path'; import fs from 'fs'; import { v4 as uuidv4 } from 'uuid'; +import type { ActiveTripResponse } from '@trek/shared'; import type { User } from '../../types'; import { TripsService } from './trips.service'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; @@ -76,6 +77,18 @@ export class TripsController { return { trips: this.trips.list(user.id, archived === '1' ? 1 : 0) }; } + /** + * Where "open TREK straight in my trip" lands. Declared above @Get(':id') — + * a literal segment below it would never be reached. + */ + @Get('active') + active(@CurrentUser() user: User): ActiveTripResponse { + const row = this.trips.activeTrip(user.id); + if (!row) return { trip: null }; + const { id, title, start_date, end_date } = row; + return { trip: { id, title, start_date, end_date } }; + } + @Get('cover-images/search') async coverImages(@CurrentUser() user: User, @Query('query') query?: string) { try { diff --git a/server/src/nest/trips/trips.service.ts b/server/src/nest/trips/trips.service.ts index 7cb89218c..f4bf7d3d0 100644 --- a/server/src/nest/trips/trips.service.ts +++ b/server/src/nest/trips/trips.service.ts @@ -3,7 +3,7 @@ import path from 'path'; import fs from 'fs'; import { randomUUID } from 'crypto'; import { DatabaseService } from '../database/database.service'; -import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared'; +import type { ActiveTrip, TrekWsPayload, TrekWsTripEventName } from '@trek/shared'; import { RealtimeService } from '../realtime/realtime.service'; import { PermissionsService } from '../permissions/permissions.service'; import type { Trip, User } from '../../types'; @@ -416,6 +416,35 @@ export class TripsService { `).get({ userId, tripId }) as Trip | undefined; } + /** + * The trip a user most likely means by "my trip" right now: the one running + * today, else the next one starting, else the one that started most recently. + * Archived trips never qualify. Same order the dashboard hero picks its + * spotlight with (client sortTrips) — the two must agree, or "open my trip on + * startup" would land somewhere other than the trip the dashboard features. + * + * Kept separate from list() on purpose: this runs on the very first paint of + * a startup redirect, so it reads four columns of one row instead of every + * trip with its per-trip day/place counts. + */ + activeTrip(userId: number, today = new Date().toISOString().slice(0, 10)) { + return this.db.prepare(` + SELECT t.id, t.title, t.start_date, t.end_date, + CASE + WHEN t.start_date IS NOT NULL AND t.end_date IS NOT NULL AND t.start_date <= :today AND t.end_date >= :today THEN 0 + WHEN t.start_date IS NOT NULL AND t.start_date >= :today THEN 1 + ELSE 2 + END AS relevance + FROM trips t + LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId + WHERE (t.user_id = :userId OR m.user_id IS NOT NULL) AND t.is_archived = 0 + ORDER BY relevance ASC, + CASE WHEN relevance < 2 THEN t.start_date END ASC, + CASE WHEN relevance = 2 THEN t.start_date END DESC + LIMIT 1 + `).get({ userId, today }) as ActiveTrip & { relevance: number } | undefined; + } + getRaw(tripId: string | number): Trip | undefined { return this.db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as Trip | undefined; } diff --git a/server/tests/e2e/trips.e2e.test.ts b/server/tests/e2e/trips.e2e.test.ts index 6e435161a..c0f8520a5 100644 --- a/server/tests/e2e/trips.e2e.test.ts +++ b/server/tests/e2e/trips.e2e.test.ts @@ -200,6 +200,37 @@ describe('Trips e2e (real auth guard + temp SQLite)', () => { expect(res.body).toEqual({ error: 'Trip not found' }); }); + describe('GET /active (startup destination)', () => { + const seedDated = (title: string, start: string, end: string) => + Number(db.prepare('INSERT INTO trips (user_id, title, start_date, end_date) VALUES (1, ?, ?, ?)') + .run(title, start, end).lastInsertRowid); + + it('401 without a cookie', async () => { + expect((await request(server).get('/api/trips/active')).status).toBe(401); + }); + + // The literal route sits above @Get(':id'); if it ever slips below, this + // asks for a trip with the id "active" and comes back 404 instead. + it('resolves as its own route rather than as /api/trips/:id', async () => { + const running = seedDated('Running', '2000-01-01', '2999-12-31'); + const res = await request(server).get('/api/trips/active').set('Cookie', sessionCookie(1)); + expect(res.status).toBe(200); + expect(res.body).toEqual({ trip: { id: running, title: 'Running', start_date: '2000-01-01', end_date: '2999-12-31' } }); + }); + + it('answers { trip: null } when the user has no trip at all', async () => { + const res = await request(server).get('/api/trips/active').set('Cookie', sessionCookie(1)); + expect(res.status).toBe(200); + expect(res.body).toEqual({ trip: null }); + }); + + it('carries no wide trip columns — it is read on first paint', async () => { + seedDated('Running', '2000-01-01', '2999-12-31'); + const res = await request(server).get('/api/trips/active').set('Cookie', sessionCookie(1)); + expect(Object.keys(res.body.trip).sort()).toEqual(['end_date', 'id', 'start_date', 'title']); + }); + }); + it('200 bundle for an accessible trip (real member list)', async () => { const tripId = seedTrip('B'); const res = await request(server).get(`/api/trips/${tripId}/bundle`).set('Cookie', sessionCookie(1)); diff --git a/server/tests/unit/nest/trips.controller.test.ts b/server/tests/unit/nest/trips.controller.test.ts index 25f25e11e..76abf4144 100644 --- a/server/tests/unit/nest/trips.controller.test.ts +++ b/server/tests/unit/nest/trips.controller.test.ts @@ -20,7 +20,7 @@ import { TripsController } from '../../../src/nest/trips/trips.controller'; import type { TripsService } from '../../../src/nest/trips/trips.service'; import { NotFoundError, ValidationError } from '../../../src/nest/trips/trips.service'; import type { User } from '../../../src/types'; -import { tripCreateRequestSchema, tripTransferOwnershipRequestSchema } from '@trek/shared'; +import { activeTripResponseSchema, tripCreateRequestSchema, tripTransferOwnershipRequestSchema } from '@trek/shared'; const user = { id: 1, role: 'user', email: 'u@example.test' } as User; const req = { headers: {} } as Request; @@ -77,6 +77,24 @@ describe('TripsController (parity with the legacy /api/trips route)', () => { expect(list).toHaveBeenLastCalledWith(1, 0); }); + describe('GET /active (startup destination)', () => { + it('narrows the row to the contract shape and drops the sort helper', () => { + const activeTrip = vi.fn().mockReturnValue({ + id: 7, title: 'Japan', start_date: '2026-09-01', end_date: '2026-09-14', relevance: 1, + }); + const res = tc(svc({ activeTrip } as Partial)).active(user); + expect(res).toEqual({ trip: { id: 7, title: 'Japan', start_date: '2026-09-01', end_date: '2026-09-14' } }); + expect(activeTripResponseSchema.safeParse(res).success).toBe(true); + expect(activeTrip).toHaveBeenCalledWith(1); + }); + + it('answers null instead of 404 when the user has no trip, so the caller can fall back', () => { + const res = tc(svc({ activeTrip: vi.fn().mockReturnValue(undefined) } as Partial)).active(user); + expect(res).toEqual({ trip: null }); + expect(activeTripResponseSchema.safeParse(res).success).toBe(true); + }); + }); + describe('POST / (create)', () => { it('403 without trip_create; a missing title 400s in the ZodValidationPipe', () => { expect(thrown(() => tc(svc({ can: vi.fn().mockReturnValue(false) })).create(user, { title: 'T' }, req))).toEqual({ status: 403, body: { error: 'No permission to create trips' } }); diff --git a/server/tests/unit/nest/trips.service.test.ts b/server/tests/unit/nest/trips.service.test.ts index 68c1c4229..3c0be7f7a 100644 --- a/server/tests/unit/nest/trips.service.test.ts +++ b/server/tests/unit/nest/trips.service.test.ts @@ -1,5 +1,5 @@ /** - * Unit tests for the DI-native TripsService — TRIP-SVC-001 through TRIP-SVC-053 + * Unit tests for the DI-native TripsService — TRIP-SVC-001 through TRIP-SVC-058 * (001–038 moved 1:1 from the legacy tests/unit/services/tripService.test.ts; * the exportICS cases that duplicated the generateDays 010–012 numbering were * renumbered to 024–026 with the post-fold quirk-fix commit; 040–041 pin the @@ -1255,3 +1255,51 @@ describe('quirk fixes', () => { expect(row.username).toBe('Olive Displayed'); }); }); + +/** + * activeTrip powers the startup redirect, so its order has to stay identical to + * the dashboard's sortTrips (client/src/pages/dashboard/dashboardModel.ts): + * running today → next one starting (earliest first) → most recently started → + * undated last. If these drift, "open my trip" and the hero show different trips. + */ +describe('activeTrip (startup destination)', () => { + const TODAY = '2026-08-08'; + + it('TRIP-SVC-054: prefers the trip running today over anything upcoming', () => { + const { user } = createUser(testDb); + createTrip(testDb, user.id, { title: 'soon', start_date: '2026-08-20', end_date: '2026-08-25' }); + createTrip(testDb, user.id, { title: 'running', start_date: '2026-08-05', end_date: '2026-08-12' }); + expect(svc.activeTrip(user.id, TODAY)?.title).toBe('running'); + }); + + it('TRIP-SVC-055: without a running trip it takes the next one starting, earliest first', () => { + const { user } = createUser(testDb); + createTrip(testDb, user.id, { title: 'late', start_date: '2026-12-01', end_date: '2026-12-10' }); + createTrip(testDb, user.id, { title: 'soon', start_date: '2026-09-01', end_date: '2026-09-10' }); + expect(svc.activeTrip(user.id, TODAY)?.title).toBe('soon'); + }); + + it('TRIP-SVC-056: falls back to the most recently started trip, undated ones last', () => { + const { user } = createUser(testDb); + createTrip(testDb, user.id, { title: 'undated' }); + createTrip(testDb, user.id, { title: 'old', start_date: '2020-01-01', end_date: '2020-01-10' }); + createTrip(testDb, user.id, { title: 'recent', start_date: '2026-07-01', end_date: '2026-07-10' }); + expect(svc.activeTrip(user.id, TODAY)?.title).toBe('recent'); + }); + + it('TRIP-SVC-057: skips archived trips and returns undefined when nothing is left', () => { + const { user } = createUser(testDb); + const archived = createTrip(testDb, user.id, { title: 'archived', start_date: '2026-08-05', end_date: '2026-08-12' }); + testDb.prepare('UPDATE trips SET is_archived = 1 WHERE id = ?').run(archived.id); + expect(svc.activeTrip(user.id, TODAY)).toBeUndefined(); + }); + + it('TRIP-SVC-058: sees shared trips but never another user\'s private ones', () => { + const { user: owner } = createUser(testDb); + const { user: guest } = createUser(testDb); + createTrip(testDb, owner.id, { title: 'private', start_date: '2026-08-05', end_date: '2026-08-12' }); + const shared = createTrip(testDb, owner.id, { title: 'shared', start_date: '2026-09-01', end_date: '2026-09-10' }); + addTripMember(testDb, shared.id, guest.id); + expect(svc.activeTrip(guest.id, TODAY)?.title).toBe('shared'); + }); +}); diff --git a/shared/src/trip/trip.schema.spec.ts b/shared/src/trip/trip.schema.spec.ts index 622b189fb..07e5706a6 100644 --- a/shared/src/trip/trip.schema.spec.ts +++ b/shared/src/trip/trip.schema.spec.ts @@ -1,4 +1,9 @@ -import { tripCreateRequestSchema, tripUpdateRequestSchema, tripAddMemberRequestSchema } from './trip.schema'; +import { + tripCreateRequestSchema, + tripUpdateRequestSchema, + tripAddMemberRequestSchema, + activeTripResponseSchema, +} from './trip.schema'; import { describe, it, expect } from 'vitest'; @@ -29,3 +34,18 @@ describe('tripAddMemberRequestSchema', () => { expect(tripAddMemberRequestSchema.safeParse({}).success).toBe(false); }); }); + +describe('activeTripResponseSchema', () => { + it('accepts a trip with or without dates, and an explicit null', () => { + expect(activeTripResponseSchema.safeParse({ trip: { id: 1, title: 'Japan' } }).success).toBe(true); + expect( + activeTripResponseSchema.safeParse({ trip: { id: 1, title: 'Japan', start_date: null, end_date: null } }).success, + ).toBe(true); + expect(activeTripResponseSchema.safeParse({ trip: null }).success).toBe(true); + }); + + it('rejects a missing trip key — "no trip" is null, not absent', () => { + expect(activeTripResponseSchema.safeParse({}).success).toBe(false); + expect(activeTripResponseSchema.safeParse({ trip: { title: 'Japan' } }).success).toBe(false); + }); +}); diff --git a/shared/src/trip/trip.schema.ts b/shared/src/trip/trip.schema.ts index 36d94828a..c47a0fb80 100644 --- a/shared/src/trip/trip.schema.ts +++ b/shared/src/trip/trip.schema.ts @@ -118,3 +118,28 @@ export const tripTransferOwnershipRequestSchema = z.object({ newOwnerId: z.number().int().positive(), }); export type TripTransferOwnershipRequest = z.infer; + +/** + * The one trip TREK opens on for a user who wants to land in their trip instead + * of on the dashboard (GET /api/trips/active). Deliberately narrow: it is read + * on the very first paint to decide where to navigate, so it carries the id plus + * enough to label the destination — nothing that would make it as wide (or as + * slow) as the trip list it replaces there. + * + * Relevance order matches the dashboard hero (client sortTrips): the trip + * running today, else the next one starting, else the most recently started. + */ +export const activeTripSchema = z.object({ + id: z.number(), + title: z.string(), + start_date: z.string().nullable().optional(), + end_date: z.string().nullable().optional(), +}); +export type ActiveTrip = z.infer; + +// null when the user has no unarchived trip at all — the caller falls back to +// the dashboard rather than navigating into nothing. +export const activeTripResponseSchema = z.object({ + trip: activeTripSchema.nullable(), +}); +export type ActiveTripResponse = z.infer;