feat: default locale Russian, geo determines language for other countries

- localization-svc: defaultLocale ru, resolveLocale only by geo
- web-svc: DEFAULT_LOCALE ru, layout lang=ru, embeddedTranslations fallback ru
- countryToLocale: default ru when no country or unknown country

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
home
2026-02-23 15:10:38 +03:00
parent 8fc82a3b90
commit cd6b7857ba
606 changed files with 26148 additions and 14297 deletions

View File

@@ -0,0 +1,15 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
EXPOSE 3004
CMD ["node", "dist/index.js"]

View File

@@ -0,0 +1,21 @@
{
"name": "travel-svc",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"fastify": "^4.28.1",
"@fastify/cors": "^9.0.1",
"ioredis": "^5.4.1"
},
"devDependencies": {
"@types/node": "^22.10.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}

View File

@@ -0,0 +1,270 @@
/**
* travel-svc — Trending, Inspiration Cards, itineraries
* docs/architecture: 01-perplexity-analogue-design.md §2.2.D
* Redis: travel:trending, travel:inspiration, travel:itinerary:{hash}
*/
import Fastify from 'fastify';
import cors from '@fastify/cors';
import Redis from 'ioredis';
import crypto from 'node:crypto';
const PORT = parseInt(process.env.PORT ?? '3004', 10);
const REDIS_URL = process.env.REDIS_URL ?? 'redis://localhost:6379';
const OPENAI_API_KEY = process.env.OPENAI_API_KEY ?? '';
const ITINERARY_TTL = 4 * 60 * 60; // 4h
// @ts-expect-error — ioredis + NodeNext ESM constructability
const redis = new Redis(REDIS_URL);
const STUB_TRENDING = [
{ id: 'paris', name: 'Paris', image: 'https://images.unsplash.com/photo-1502602898657-3e72660eb936?w=400', description: '' },
{ id: 'tokyo', name: 'Tokyo', image: 'https://images.unsplash.com/photo-1540959733332-eab4deabeeaf?w=400', description: '' },
];
const STUB_INSPIRATION = [
{ title: 'Best hiking trails in Slovenia', summary: '', image: '', url: '#' },
];
const app = Fastify({ logger: true });
await app.register(cors, { origin: true });
app.get('/health', async () => ({ status: 'ok' }));
app.get('/metrics', async (_req, reply) => {
reply.header('Content-Type', 'text/plain; charset=utf-8');
return reply.send('# HELP gooseek_up Service is up (1) or down (0)\n# TYPE gooseek_up gauge\ngooseek_up 1\n');
});
app.get('/ready', async () => {
try {
await redis.ping();
return { status: 'ready' };
} catch {
return { status: 'degraded' };
}
});
app.get('/api/v1/travel/trending', async (req, reply) => {
const cached = await redis.get('travel:trending');
if (cached) return reply.send(JSON.parse(cached));
return reply.send({ items: STUB_TRENDING });
});
app.get('/api/v1/travel/inspiration', async (req, reply) => {
const cached = await redis.get('travel:inspiration');
if (cached) return reply.send(JSON.parse(cached));
return reply.send({ items: STUB_INSPIRATION });
});
interface ItineraryDay {
day: number;
title: string;
activities: string[];
tips?: string;
}
interface ItineraryPayload {
days: ItineraryDay[];
summary?: string;
}
async function generateItinerary(query: string, days: number): Promise<ItineraryPayload | null> {
if (!OPENAI_API_KEY || !query.trim()) return null;
try {
const res = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: [
{
role: 'system',
content: `You are a travel planner. Generate a ${days}-day itinerary in JSON format.
Return ONLY valid JSON, no markdown. Format:
{ "days": [ { "day": 1, "title": "Day 1: Arrival", "activities": ["Activity 1", "Activity 2"], "tips": "Optional tip" } ], "summary": "Brief overview" }`,
},
{
role: 'user',
content: `Create a ${days}-day itinerary for: ${query.trim()}. Include specific places, restaurants, and practical tips.`,
},
],
max_tokens: 2000,
temperature: 0.7,
}),
signal: AbortSignal.timeout(60000),
});
if (!res.ok) return null;
const data = (await res.json()) as { choices?: { message?: { content?: string } }[] };
const text = data?.choices?.[0]?.message?.content?.trim();
if (!text) return null;
const cleaned = text.replace(/^```json?\s*/i, '').replace(/\s*```$/i, '');
const parsed = JSON.parse(cleaned) as ItineraryPayload;
if (!Array.isArray(parsed?.days) || parsed.days.length === 0) return null;
return {
days: parsed.days.map((d) => ({
day: Number(d.day) || 0,
title: String(d.title ?? ''),
activities: Array.isArray(d.activities) ? d.activities.map(String) : [],
tips: d.tips ? String(d.tips) : undefined,
})),
summary: parsed.summary ? String(parsed.summary) : undefined,
};
} catch {
return null;
}
}
app.post<{ Body: unknown }>('/api/v1/travel/itinerary', async (req, reply) => {
try {
const body = req.body as { query?: string; days?: number };
const query = typeof body?.query === 'string' ? body.query.trim() : '';
const days = Math.min(14, Math.max(1, Number(body?.days) || 3));
if (!query) {
return reply.status(400).send({ error: 'query is required' });
}
const hash = crypto.createHash('sha256').update(JSON.stringify({ query, days })).digest('hex').slice(0, 16);
const cacheKey = `travel:itinerary:${hash}`;
const cached = await redis.get(cacheKey);
if (cached) {
return reply.send(JSON.parse(cached));
}
const payload = await generateItinerary(query, days);
if (payload) {
await redis.setex(cacheKey, ITINERARY_TTL, JSON.stringify(payload));
return reply.send(payload);
}
return reply.send({
message: 'Itinerary generation requires OPENAI_API_KEY. Configure it in travel-svc env.',
days: [],
});
} catch (err) {
req.log.error(err);
return reply.status(500).send({ error: 'Failed to generate itinerary' });
}
});
// Stepper state — сохранение между шагами (Поиск → Места → Маршрут → Отели → Билеты)
// Redis: travel:stepper:{sessionId} TTL 24h
const STEPPER_TTL = 86400; // 24h
app.post('/api/v1/travel/stepper/state', async (req, reply) => {
try {
const body = req.body as { sessionId?: string; state?: Record<string, unknown> };
const sessionId = body?.sessionId;
const state = body?.state;
if (!sessionId || typeof sessionId !== 'string' || !state || typeof state !== 'object') {
return reply.status(400).send({ error: 'sessionId and state required' });
}
const key = `travel:stepper:${sessionId}`;
await redis.setex(key, STEPPER_TTL, JSON.stringify(state));
return reply.send({ ok: true });
} catch (err) {
req.log.error(err);
return reply.status(500).send({ error: 'Failed to save stepper state' });
}
});
app.get<{ Params: { sessionId: string } }>('/api/v1/travel/stepper/state/:sessionId', async (req, reply) => {
try {
const { sessionId } = req.params;
const key = `travel:stepper:${sessionId}`;
const raw = await redis.get(key);
if (!raw) return reply.send({ state: null });
const state = JSON.parse(raw) as Record<string, unknown>;
return reply.send({ state });
} catch (err) {
req.log.error(err);
return reply.status(500).send({ error: 'Failed to load stepper state' });
}
});
// Weather widget — proxy к Open-Meteo (перенесено из web-svc)
async function geocodeCity(city: string): Promise<{ lat: number; lng: number; name: string } | null> {
const res = await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1`, {
signal: AbortSignal.timeout(5000),
});
const data = (await res.json()) as { results?: { latitude?: number; longitude?: number; name?: string }[] };
const result = data.results?.[0];
if (!result?.latitude || !result?.longitude) return null;
return { lat: result.latitude, lng: result.longitude, name: result.name ?? city };
}
app.post<{ Body: unknown }>('/api/v1/weather', async (req, reply) => {
try {
const body = req.body as { lat?: number; lng?: number; city?: string; measureUnit?: 'Imperial' | 'Metric' };
let lat: number;
let lng: number;
let cityName: string | undefined = body.city;
if (body.lat != null && body.lng != null) {
lat = body.lat;
lng = body.lng;
} else if (body.city?.trim()) {
const geo = await geocodeCity(body.city.trim());
if (!geo) return reply.status(404).send({ message: 'City not found.' });
lat = geo.lat;
lng = geo.lng;
cityName = geo.name;
} else {
return reply.status(400).send({ message: 'Invalid request. Provide lat/lng or city.' });
}
const unit = body.measureUnit === 'Metric' ? '' : '&temperature_unit=fahrenheit';
const windUnit = body.measureUnit === 'Metric' ? '' : '&wind_speed_unit=mph';
const res = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}&current=weather_code,temperature_2m,is_day,relative_humidity_2m,wind_speed_10m&timezone=auto${unit}${windUnit}`,
{ signal: AbortSignal.timeout(10000) },
);
const data = (await res.json()) as { error?: boolean; reason?: string; current?: Record<string, number> };
if (data.error) {
return reply.status(500).send({ message: data.reason ?? 'Weather fetch failed' });
}
const code = data.current?.weather_code ?? 0;
const isDay = data.current?.is_day === 1;
const dayOrNight = isDay ? 'day' : 'night';
const iconMap: Record<number, string> = {
0: `clear-${dayOrNight}`, 2: `cloudy-1-${dayOrNight}`, 3: `cloudy-1-${dayOrNight}`,
45: `fog-${dayOrNight}`, 48: `fog-${dayOrNight}`, 55: `rainy-1-${dayOrNight}`,
57: `frost-${dayOrNight}`, 65: `rainy-2-${dayOrNight}`, 67: 'rain-and-sleet-mix',
75: `snowy-2-${dayOrNight}`, 77: `snowy-1-${dayOrNight}`, 82: `rainy-3-${dayOrNight}`,
87: `snowy-3-${dayOrNight}`, 95: `scattered-thunderstorms-${dayOrNight}`,
99: 'severe-thunderstorm',
};
const condMap: Record<number, string> = {
0: 'Clear', 1: 'Mainly Clear', 2: 'Partly Cloudy', 3: 'Cloudy', 45: 'Fog', 48: 'Fog',
51: 'Light Drizzle', 53: 'Moderate Drizzle', 55: 'Dense Drizzle', 56: 'Light Freezing Drizzle',
57: 'Dense Freezing Drizzle', 61: 'Slight Rain', 63: 'Moderate Rain', 65: 'Heavy Rain',
66: 'Light Freezing Rain', 67: 'Heavy Freezing Rain', 71: 'Slight Snow Fall',
73: 'Moderate Snow Fall', 75: 'Heavy Snow Fall', 77: 'Snow', 80: 'Slight Rain Showers',
81: 'Moderate Rain Showers', 82: 'Heavy Rain Showers', 85: 'Slight Snow Showers',
86: 'Moderate Snow Showers', 87: 'Heavy Snow Showers', 95: 'Thunderstorm',
96: 'Thunderstorm with Slight Hail', 99: 'Thunderstorm with Heavy Hail',
};
return reply.send({
temperature: data.current?.temperature_2m ?? 0,
condition: condMap[code] ?? 'Clear',
humidity: data.current?.relative_humidity_2m ?? 0,
windSpeed: data.current?.wind_speed_10m ?? 0,
icon: iconMap[code] ?? `clear-${dayOrNight}`,
temperatureUnit: body.measureUnit === 'Metric' ? 'C' : 'F',
windSpeedUnit: body.measureUnit === 'Metric' ? 'm/s' : 'mph',
city: cityName,
});
} catch (err) {
req.log.error(err);
return reply.status(500).send({ message: 'An error has occurred.' });
}
});
try {
await app.listen({ port: PORT, host: '0.0.0.0' });
console.log(`travel-svc listening on :${PORT}`);
} catch (err) {
console.error(err);
process.exit(1);
}

View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"skipLibCheck": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}