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:
18
services/profile-svc/Dockerfile
Normal file
18
services/profile-svc/Dockerfile
Normal 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"]
|
||||
12
services/profile-svc/drizzle/0000_init.sql
Normal file
12
services/profile-svc/drizzle/0000_init.sql
Normal 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
|
||||
);
|
||||
23
services/profile-svc/package.json
Normal file
23
services/profile-svc/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
8
services/profile-svc/src/db/index.ts
Normal file
8
services/profile-svc/src/db/index.ts
Normal 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 });
|
||||
18
services/profile-svc/src/db/push.ts
Normal file
18
services/profile-svc/src/db/push.ts
Normal 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);
|
||||
});
|
||||
25
services/profile-svc/src/db/schema.ts
Normal file
25
services/profile-svc/src/db/schema.ts
Normal 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(),
|
||||
});
|
||||
196
services/profile-svc/src/index.ts
Normal file
196
services/profile-svc/src/index.ts
Normal 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);
|
||||
}
|
||||
13
services/profile-svc/tsconfig.json
Normal file
13
services/profile-svc/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user