Skip to content

Commit 392319e

Browse files
committed
refactor: tRPC WIP part 3
1 parent ee032d1 commit 392319e

29 files changed

Lines changed: 477 additions & 1345 deletions

app/api/[trpc]/route.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,8 @@
1-
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
2-
import { journeys } from "../journeys/journeys";
3-
import { parseUrl } from "../parseUrl";
4-
import { splitJourney } from "../splitJourney/splitJourney";
51
import { t } from "@/utils/trpc-init";
2+
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
3+
import { combi } from "../combi/combi";
64

7-
const appRouter = t.router({
8-
journeys,
9-
splitJourney,
10-
parseUrl,
11-
});
5+
const appRouter = t.router({ combi });
126

137
export type AppRouter = typeof appRouter;
148

app/api/combi/combi.ts

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
import { fetchAndValidateJson } from "@/utils/fetchAndValidateJson";
2+
import { parseHinfahrtReconWithAPI } from "@/utils/parseHinfahrtRecon";
3+
import { vbidSchema, vendoJourneySchema } from "@/utils/schemas";
4+
import { t } from "@/utils/trpc-init";
5+
import { TRPCError } from "@trpc/server";
6+
import { createClient, type SearchJourneysOptions } from "db-vendo-client";
7+
import { data as loyaltyCards } from "db-vendo-client/format/loyalty-cards";
8+
import { profile as dbProfile } from "db-vendo-client/p/db/index";
9+
import { prettifyError, z } from "zod/v4";
10+
import { extractSplitPoints } from "./extractSplitPoints";
11+
12+
const client = createClient(dbProfile, "mail@lukasweihrauch.de");
13+
14+
export const combi = t.procedure
15+
.input(
16+
z.object({
17+
vbid: z.string(),
18+
travelClass: z.int(),
19+
bahnCard: z.int().nullable(),
20+
passengerAge: z.int().optional(),
21+
hasDeutschlandTicket: z.boolean(),
22+
})
23+
)
24+
.subscription(async function* ({ input }) {
25+
const vbidRequest = await fetchAndValidateJson({
26+
url: `https://www.bahn.de/web/api/angebote/verbindung/${input.vbid}`,
27+
schema: vbidSchema,
28+
});
29+
30+
const cookies = vbidRequest.response.headers.getSetCookie();
31+
const { data } = await parseHinfahrtReconWithAPI(vbidRequest.data, cookies);
32+
33+
// Find first segment with halte data for start station
34+
const firstSegmentWithHalte =
35+
data.verbindungen[0].verbindungsAbschnitte.find(
36+
(segment) => segment.halte.length > 0
37+
);
38+
39+
const lastSegmentWithHalte =
40+
data.verbindungen[0].verbindungsAbschnitte.findLast(
41+
(segment) => segment.halte.length > 0
42+
);
43+
44+
if (!firstSegmentWithHalte || !lastSegmentWithHalte) {
45+
throw new Error("No segments with station data found");
46+
}
47+
48+
const soidValue = firstSegmentWithHalte.halte[0].id;
49+
const zoidValue = lastSegmentWithHalte.halte.at(-1)!.id;
50+
51+
if (!soidValue || !zoidValue) {
52+
throw new TRPCError({
53+
code: "INTERNAL_SERVER_ERROR",
54+
message: "missing soid or zoid",
55+
});
56+
}
57+
58+
const fromStationId = soidValue.match(/@L=(\d+)/)?.[1];
59+
const toStationId = zoidValue.match(/@L=(\d+)/)?.[1];
60+
61+
const options: SearchJourneysOptions = {
62+
results: 10,
63+
stopovers: true,
64+
// Bei genauer Abfahrtszeit wollen wir exakte Treffer, nicht verschiedene Alternativen
65+
notOnlyFastRoutes: true,
66+
remarks: true, // Verbindungshinweise einschließen
67+
transfers: -1, // System entscheidet über optimale Anzahl Umstiege
68+
// Reiseklasse-Präferenz setzen - verwende firstClass boolean Parameter
69+
firstClass: input.travelClass === 1, // true für erste Klasse, false für zweite Klasse
70+
age: input.passengerAge, // Passagieralter für angemessene Preisgestaltung hinzufügen
71+
departure: undefined,
72+
};
73+
74+
if (input.bahnCard !== null && [25, 50, 100].includes(input.bahnCard)) {
75+
options.loyaltyCard = {
76+
type: loyaltyCards.BAHNCARD,
77+
discount: input.bahnCard,
78+
class: input.travelClass,
79+
};
80+
}
81+
82+
if (input.hasDeutschlandTicket) {
83+
options.deutschlandTicketDiscount = true;
84+
// Diese Option kann helfen, genauere Preise zurückzugeben wenn Deutschland-Ticket verfügbar ist
85+
options.deutschlandTicketConnectionsOnly = false; // Wir wollen alle Verbindungen, aber mit genauen Preisen
86+
}
87+
88+
const journeys = await client.journeys(fromStationId, toStationId, options);
89+
90+
const parseResult = z
91+
.object({ journeys: z.array(vendoJourneySchema) })
92+
.safeParse(journeys);
93+
94+
if (!parseResult.success) {
95+
throw new TRPCError({
96+
code: "INTERNAL_SERVER_ERROR",
97+
message: `Validation of 'journeys' response of DB-API failed: ${prettifyError(
98+
parseResult.error
99+
)}`,
100+
cause: parseResult.error,
101+
});
102+
}
103+
104+
const uniqueJourneys = parseResult.data.journeys.filter(
105+
(journey, index, arr) => {
106+
if (journey.legs.length === 0) {
107+
return false;
108+
}
109+
110+
const journeySignature = journey.legs
111+
.map(
112+
(leg) =>
113+
`${leg.line?.name || "walk"}-${leg.origin?.id}-${
114+
leg.destination?.id
115+
}-${leg.departure}`
116+
)
117+
.join("|");
118+
119+
const key = `${journeySignature}-${
120+
journey.price?.amount || "no-price"
121+
}`;
122+
return (
123+
arr.findIndex((j) => {
124+
if (!j.legs || j.legs.length === 0) {
125+
return false;
126+
}
127+
128+
const jSignature = j.legs
129+
.map(
130+
(leg) =>
131+
`${leg.line?.name || "walk"}-${leg.origin?.id}-${
132+
leg.destination?.id
133+
}-${leg.departure}`
134+
)
135+
.join("|");
136+
137+
const jKey = `${jSignature}-${j.price?.amount || "no-price"}`;
138+
return jKey === key;
139+
}) === index
140+
);
141+
}
142+
);
143+
144+
// Sort by departure time
145+
// TODO move sorting to frontend?
146+
const uniqueJourneysSorted = uniqueJourneys.toSorted(
147+
(a, b) => a.legs[0].departure.getTime() - b.legs[0].departure.getTime()
148+
);
149+
150+
yield { type: "journeys", journeys: uniqueJourneysSorted } as const;
151+
152+
// Split-Kandidaten aus vorhandenen Legs ableiten (keine zusätzlichen API Calls)
153+
const splitPoints = extractSplitPoints(uniqueJourneysSorted[0]);
154+
155+
const splitOptions = [];
156+
157+
for (let i = 0; i < splitPoints.length; i++) {
158+
const splitPoint = splitPoints[i];
159+
160+
yield {
161+
type: "processing",
162+
checked: i,
163+
currentStation: splitPoint.station?.name ?? null,
164+
total: splitPoints.length,
165+
} as const;
166+
167+
const origin = uniqueJourneysSorted[0].legs.at(0)!.origin;
168+
const destination = uniqueJourneysSorted[0].legs.at(-1)!.destination;
169+
170+
const queryOptions: SearchJourneysOptions = {
171+
results: 1,
172+
stopovers: true,
173+
firstClass: input.travelClass === 1,
174+
notOnlyFastRoutes: true,
175+
remarks: true,
176+
transfers: 3,
177+
age: input.passengerAge,
178+
deutschlandTicketDiscount: input.hasDeutschlandTicket,
179+
loyaltyCard:
180+
input.bahnCard && [25, 50, 100].includes(input.bahnCard)
181+
? {
182+
type: loyaltyCards.BAHNCARD,
183+
discount: input.bahnCard,
184+
class: input.travelClass || 2,
185+
}
186+
: undefined,
187+
};
188+
189+
const fetchJourney = async (params: {
190+
from: string;
191+
to: string;
192+
targetDeparture: Date;
193+
}) => {
194+
const untyped = await client.journeys(params.from, params.to, {
195+
...queryOptions,
196+
departure: params.targetDeparture,
197+
});
198+
199+
const validated = z
200+
.object({
201+
journeys: z.array(vendoJourneySchema),
202+
})
203+
.parse(untyped);
204+
const expected = params.targetDeparture.getTime();
205+
206+
return (
207+
validated.journeys.find(
208+
(journey) =>
209+
Math.abs(journey.legs[0].departure.getTime() - expected) <= 60_000 // 1 Minute Toleranz
210+
) || null
211+
);
212+
};
213+
214+
try {
215+
const [firstJourney, secondJourney] = await Promise.all([
216+
fetchJourney({
217+
from: origin!.id,
218+
to: splitPoint.station.id,
219+
targetDeparture: uniqueJourneysSorted[0].legs.at(0)!.departure,
220+
}),
221+
fetchJourney({
222+
from: splitPoint.station.id,
223+
to: destination!.id,
224+
targetDeparture: splitPoint.departure,
225+
}),
226+
]);
227+
228+
if (
229+
!firstJourney ||
230+
!secondJourney ||
231+
(firstJourney.price?.amount === undefined &&
232+
secondJourney.price?.amount === undefined)
233+
) {
234+
continue;
235+
}
236+
237+
let totalPrice: number | null = null; // null = unknown
238+
239+
// TODO this filter can probably be moved to frontend, no filtering on backend necessary
240+
if (
241+
firstJourney.price?.amount !== undefined &&
242+
secondJourney.price?.amount !== undefined
243+
) {
244+
totalPrice = firstJourney.price.amount + secondJourney.price.amount;
245+
const originalPrice = uniqueJourneysSorted[0].price?.amount || 0;
246+
247+
if (totalPrice >= originalPrice) {
248+
continue;
249+
}
250+
}
251+
252+
splitOptions.push({
253+
splitStations: [splitPoint.station],
254+
segments: [firstJourney, secondJourney],
255+
});
256+
} catch (error) {
257+
throw new TRPCError({
258+
code: "INTERNAL_SERVER_ERROR",
259+
message: `Error analyzing single split at ${splitPoint.station.name}`,
260+
cause: error,
261+
});
262+
}
263+
}
264+
265+
yield {
266+
type: "complete",
267+
splitOptions,
268+
} as const;
269+
});

app/api/splitJourney/extractSplitPoints.ts renamed to app/api/combi/extractSplitPoints.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import type { VendoJourney } from "@/utils/schemas";
22
import type { SplitPoint, TrainLine } from "@/utils/types";
3-
import { VERBOSE } from "./splitJourney";
43
import { TRPCError } from "@trpc/server";
54

65
export function extractSplitPoints(journey: VendoJourney) {
@@ -51,9 +50,5 @@ export function extractSplitPoints(journey: VendoJourney) {
5150
});
5251
}
5352

54-
if (VERBOSE) {
55-
console.log(`Extracted ${uniqueStops.length} unique split candidates.`);
56-
}
57-
5853
return uniqueStops;
5954
}

0 commit comments

Comments
 (0)