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,18 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY tsconfig.json ./
COPY src ./src
COPY drizzle ./drizzle
RUN npm run build
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev
COPY --from=builder /app/dist ./dist
COPY drizzle ./drizzle
EXPOSE 3019
ENV PORT=3019
CMD ["node", "dist/index.js"]

View File

@@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS user_profiles (
user_id TEXT PRIMARY KEY,
display_name TEXT,
avatar_url TEXT,
timezone TEXT,
locale TEXT,
profile_data JSONB DEFAULT '{}',
preferences JSONB DEFAULT '{}',
personalization JSONB DEFAULT '{}',
created_at TIMESTAMP DEFAULT NOW() NOT NULL,
updated_at TIMESTAMP DEFAULT NOW() NOT NULL
);

View File

@@ -0,0 +1,23 @@
{
"name": "profile-svc",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"db:migrate": "tsx src/db/push.ts"
},
"dependencies": {
"fastify": "^4.28.1",
"@fastify/cors": "^9.0.1",
"drizzle-orm": "^0.40.1",
"postgres": "^3.4.4"
},
"devDependencies": {
"@types/node": "^22.10.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}

View File

@@ -0,0 +1,8 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema.js';
const connectionString =
process.env.POSTGRES_URL ?? 'postgresql://gooseek:gooseek@localhost:5432/gooseek';
const client = postgres(connectionString);
export const db = drizzle(client, { schema });

View File

@@ -0,0 +1,18 @@
import postgres from 'postgres';
import { readFileSync } from 'fs';
import { join } from 'path';
const connectionString =
process.env.POSTGRES_URL ?? 'postgresql://gooseek:gooseek@localhost:5432/gooseek';
const client = postgres(connectionString);
async function main() {
const sql = readFileSync(join(process.cwd(), 'drizzle/0000_init.sql'), 'utf8');
await client.unsafe(sql);
console.log('Profile schema applied');
await client.end();
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,25 @@
import { jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core';
/**
* Профиль пользователя: личные данные и персонализация
* user_id — из auth-svc (better-auth)
*/
export const userProfiles = pgTable('user_profiles', {
userId: text('user_id').primaryKey(),
/** Отображаемое имя (может отличаться от auth) */
displayName: text('display_name'),
/** URL аватара */
avatarUrl: text('avatar_url'),
/** Часовой пояс (IANA, напр. Europe/Moscow) */
timezone: text('timezone'),
/** Локаль (напр. ru, en-US) */
locale: text('locale'),
/** Личные данные (расширяемые): bio, links и т.п. */
profileData: jsonb('profile_data').$type<Record<string, unknown>>().default({}),
/** Preferences: theme, measureUnit, autoMediaSearch, showWeatherWidget и т.д. */
preferences: jsonb('preferences').$type<Record<string, unknown>>().default({}),
/** Персонализация: systemInstructions, responseFormat и т.д. */
personalization: jsonb('personalization').$type<Record<string, unknown>>().default({}),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});

View File

@@ -0,0 +1,196 @@
/**
* profile-svc — личные данные и персонализация пользователя
* API: GET/PATCH /api/v1/profile
*/
import Fastify from 'fastify';
import cors from '@fastify/cors';
import { eq } from 'drizzle-orm';
import { db } from './db/index.js';
import { userProfiles } from './db/schema.js';
const PORT = parseInt(process.env.PORT ?? '3019', 10);
const AUTH_SERVICE_URL = process.env.AUTH_SERVICE_URL ?? '';
const app = Fastify({ logger: true });
await app.register(cors, { origin: true });
interface AuthUser {
id: string;
name?: string;
email?: string;
image?: string;
}
async function getAuthUserFromToken(authHeader: string | undefined): Promise<AuthUser | null> {
if (!authHeader?.startsWith('Bearer ')) return null;
const token = authHeader.slice(7);
if (!AUTH_SERVICE_URL) {
return token ? { id: 'guest' } : null;
}
try {
const res = await fetch(`${AUTH_SERVICE_URL.replace(/\/$/, '')}/api/auth/me`, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(5000),
});
if (!res.ok) return null;
const data = (await res.json()) as { id?: string; name?: string; email?: string; image?: string };
return data.id ? { id: data.id, name: data.name, email: data.email, image: data.image } : null;
} catch {
return null;
}
}
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 () => ({ status: 'ready' }));
/** GET /api/v1/profile — получить профиль текущего пользователя */
app.get('/api/v1/profile', async (req, reply) => {
const authUser = await getAuthUserFromToken(req.headers.authorization);
if (!authUser) {
return reply.status(401).send({ error: 'Authorization required' });
}
const userId = authUser.id;
try {
let [row] = await db.select().from(userProfiles).where(eq(userProfiles.userId, userId));
if (!row) {
try {
const [inserted] = await db
.insert(userProfiles)
.values({
userId,
displayName: authUser.name ?? null,
avatarUrl: authUser.image ?? null,
timezone: null,
locale: null,
profileData: {},
preferences: {},
personalization: {},
})
.returning();
row = inserted;
} catch (err) {
const [existing] = await db.select().from(userProfiles).where(eq(userProfiles.userId, userId));
if (existing) row = existing;
else throw err;
}
}
return reply.send({
userId: row.userId,
displayName: row.displayName ?? null,
avatarUrl: row.avatarUrl ?? null,
timezone: row.timezone ?? null,
locale: row.locale ?? null,
profileData: row.profileData ?? {},
preferences: row.preferences ?? {},
personalization: row.personalization ?? {},
createdAt: row.createdAt?.toISOString?.() ?? row.createdAt,
updatedAt: row.updatedAt?.toISOString?.() ?? row.updatedAt,
});
} catch (err) {
req.log.error(err);
return reply.status(500).send({ error: 'Failed to fetch profile' });
}
});
/** PATCH /api/v1/profile — обновить профиль (merge) */
app.patch<{
Body: {
displayName?: string;
avatarUrl?: string;
timezone?: string;
locale?: string;
profileData?: Record<string, unknown>;
preferences?: Record<string, unknown>;
personalization?: Record<string, unknown>;
};
}>('/api/v1/profile', async (req, reply) => {
const authUser = await getAuthUserFromToken(req.headers.authorization);
if (!authUser) {
return reply.status(401).send({ error: 'Authorization required' });
}
const userId = authUser.id;
const body = req.body ?? {};
try {
const [existing] = await db.select().from(userProfiles).where(eq(userProfiles.userId, userId));
const now = new Date();
const updates: Record<string, unknown> = {
updatedAt: now,
};
if (body.displayName !== undefined) updates.displayName = body.displayName;
if (body.avatarUrl !== undefined) updates.avatarUrl = body.avatarUrl;
if (body.timezone !== undefined) updates.timezone = body.timezone;
if (body.locale !== undefined) updates.locale = body.locale;
if (existing) {
// Merge для вложенных объектов (partial update)
if (body.profileData !== undefined) {
updates.profileData = {
...(existing.profileData as Record<string, unknown> ?? {}),
...body.profileData,
};
}
if (body.preferences !== undefined) {
updates.preferences = {
...(existing.preferences as Record<string, unknown> ?? {}),
...body.preferences,
};
}
if (body.personalization !== undefined) {
updates.personalization = {
...(existing.personalization as Record<string, unknown> ?? {}),
...body.personalization,
};
}
await db
.update(userProfiles)
.set(updates as Record<string, unknown>)
.where(eq(userProfiles.userId, userId));
} else {
await db.insert(userProfiles).values({
userId,
displayName: body.displayName ?? null,
avatarUrl: body.avatarUrl ?? null,
timezone: body.timezone ?? null,
locale: body.locale ?? null,
profileData: body.profileData ?? {},
preferences: body.preferences ?? {},
personalization: body.personalization ?? {},
});
}
const [updated] = await db.select().from(userProfiles).where(eq(userProfiles.userId, userId));
return reply.send({
userId: updated!.userId,
displayName: updated!.displayName ?? null,
avatarUrl: updated!.avatarUrl ?? null,
timezone: updated!.timezone ?? null,
locale: updated!.locale ?? null,
profileData: updated!.profileData ?? {},
preferences: updated!.preferences ?? {},
personalization: updated!.personalization ?? {},
createdAt: updated!.createdAt?.toISOString?.() ?? updated!.createdAt,
updatedAt: updated!.updatedAt?.toISOString?.() ?? updated!.updatedAt,
});
} catch (err) {
req.log.error(err);
return reply.status(500).send({ error: 'Failed to update profile' });
}
});
try {
await app.listen({ port: PORT, host: '0.0.0.0' });
console.log('profile-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"]
}