feat(server): expose the active trip as its own endpoint

GET /api/trips/active answers with the trip a user means by "my trip"
right now: the one running today, else the next one starting, else the
most recently started. Archived trips never qualify.

That order is the dashboard hero's (sortTrips), on purpose - the startup
redirect this is for has to land on the same trip the dashboard features,
or the two would disagree about which trip is yours.

Kept apart from list() because it runs on a first paint: four columns of
one row instead of every trip with its per-trip day and place counts.
This commit is contained in:
Maurice
2026-08-08 21:03:11 +02:00
committed by Maurice
parent aca6c556ed
commit 99ee307034
7 changed files with 188 additions and 4 deletions
+13
View File
@@ -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 {
+30 -1
View File
@@ -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;
}
+31
View File
@@ -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));
@@ -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<TripsService>)).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<TripsService>)).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' } });
+49 -1
View File
@@ -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');
});
});
+21 -1
View File
@@ -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);
});
});
+25
View File
@@ -118,3 +118,28 @@ export const tripTransferOwnershipRequestSchema = z.object({
newOwnerId: z.number().int().positive(),
});
export type TripTransferOwnershipRequest = z.infer<typeof tripTransferOwnershipRequestSchema>;
/**
* 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<typeof activeTripSchema>;
// 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<typeof activeTripResponseSchema>;