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/notifications-svc/Dockerfile
Normal file
18
services/notifications-svc/Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
COPY drizzle ./drizzle
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY drizzle ./drizzle
|
||||
EXPOSE 3013
|
||||
ENV PORT=3013
|
||||
CMD ["node", "dist/index.js"]
|
||||
30
services/notifications-svc/drizzle/0000_init.sql
Normal file
30
services/notifications-svc/drizzle/0000_init.sql
Normal file
@@ -0,0 +1,30 @@
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notification_preferences (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
push_on_answer BOOLEAN DEFAULT true,
|
||||
email_on_deep_research BOOLEAN DEFAULT false,
|
||||
reminders JSONB DEFAULT '[]'::jsonb,
|
||||
updated_at TIMESTAMP DEFAULT NOW() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reminders (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
thread_id TEXT,
|
||||
query TEXT,
|
||||
remind_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW() NOT NULL,
|
||||
sent_at TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_id ON push_subscriptions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_reminders_user_id ON reminders(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_reminders_remind_at ON reminders(remind_at) WHERE sent_at IS NULL;
|
||||
27
services/notifications-svc/package.json
Normal file
27
services/notifications-svc/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "notifications-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/cors": "^9.0.1",
|
||||
"drizzle-orm": "^0.40.1",
|
||||
"fastify": "^4.28.1",
|
||||
"nodemailer": "^8.0.1",
|
||||
"postgres": "^3.4.4",
|
||||
"web-push": "^3.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/web-push": "^3.6.3",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
8
services/notifications-svc/src/db/index.ts
Normal file
8
services/notifications-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/notifications-svc/src/db/push.ts
Normal file
18
services/notifications-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('Schema applied');
|
||||
await client.end();
|
||||
}
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
28
services/notifications-svc/src/db/schema.ts
Normal file
28
services/notifications-svc/src/db/schema.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { boolean, jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const pushSubscriptions = pgTable('push_subscriptions', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id').notNull(),
|
||||
endpoint: text('endpoint').notNull(),
|
||||
p256dh: text('p256dh').notNull(),
|
||||
auth: text('auth').notNull(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const notificationPreferences = pgTable('notification_preferences', {
|
||||
userId: text('user_id').primaryKey(),
|
||||
pushOnAnswer: boolean('push_on_answer').default(true),
|
||||
emailOnDeepResearch: boolean('email_on_deep_research').default(false),
|
||||
reminders: jsonb('reminders').$type<unknown[]>().default([]),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const reminders = pgTable('reminders', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id').notNull(),
|
||||
threadId: text('thread_id'),
|
||||
query: text('query'),
|
||||
remindAt: timestamp('remind_at').notNull(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
sentAt: timestamp('sent_at'),
|
||||
});
|
||||
304
services/notifications-svc/src/index.ts
Normal file
304
services/notifications-svc/src/index.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* notifications-svc — Web Push, Email (SMTP), напоминания
|
||||
* docs/architecture: 01-perplexity-analogue-design.md §5.13, 06-roadmap-specification §1.6
|
||||
* API: POST /api/v1/notifications/subscribe, preferences, remind, send-email
|
||||
*/
|
||||
|
||||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import webpush from 'web-push';
|
||||
import { eq, and, lte, isNull } from 'drizzle-orm';
|
||||
import { db } from './db/index.js';
|
||||
import {
|
||||
pushSubscriptions,
|
||||
notificationPreferences,
|
||||
reminders,
|
||||
} from './db/schema.js';
|
||||
import { isSmtpConfigured, sendEmail } from './lib/email.js';
|
||||
|
||||
const PORT = parseInt(process.env.PORT ?? '3013', 10);
|
||||
const AUTH_SERVICE_URL = process.env.AUTH_SERVICE_URL ?? '';
|
||||
const VAPID_PUBLIC_KEY = process.env.VAPID_PUBLIC_KEY ?? '';
|
||||
const VAPID_PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY ?? '';
|
||||
const INTERNAL_API_KEY = process.env.INTERNAL_API_KEY ?? '';
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
|
||||
await app.register(cors, { origin: true });
|
||||
|
||||
let vapidPublic = VAPID_PUBLIC_KEY;
|
||||
let vapidPrivate = VAPID_PRIVATE_KEY;
|
||||
|
||||
if (!vapidPublic || !vapidPrivate) {
|
||||
const keys = webpush.generateVAPIDKeys();
|
||||
vapidPublic = keys.publicKey;
|
||||
vapidPrivate = keys.privateKey;
|
||||
app.log.warn(
|
||||
'VAPID keys not set. Using generated keys (not suitable for production). Set VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY.'
|
||||
);
|
||||
}
|
||||
webpush.setVapidDetails(
|
||||
'mailto:notifications@gooseek.local',
|
||||
vapidPublic,
|
||||
vapidPrivate
|
||||
);
|
||||
|
||||
if (!isSmtpConfigured()) {
|
||||
app.log.warn(
|
||||
'SMTP not configured. Email sending disabled. Set SMTP_URL (e.g. smtp://user:pass@smtp.example:587)'
|
||||
);
|
||||
}
|
||||
|
||||
async function getUserIdFromToken(
|
||||
authHeader: string | undefined
|
||||
): Promise<string | null> {
|
||||
if (!authHeader?.startsWith('Bearer ')) return null;
|
||||
const token = authHeader.slice(7);
|
||||
if (!token) return null;
|
||||
if (!AUTH_SERVICE_URL) {
|
||||
return 'guest';
|
||||
}
|
||||
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 };
|
||||
return data.id ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendPushToUser(
|
||||
userId: string,
|
||||
payload: { title?: string; body?: string; [k: string]: unknown }
|
||||
): Promise<void> {
|
||||
const subs = await db
|
||||
.select()
|
||||
.from(pushSubscriptions)
|
||||
.where(eq(pushSubscriptions.userId, userId));
|
||||
const textPayload = JSON.stringify(payload);
|
||||
for (const sub of subs) {
|
||||
try {
|
||||
await webpush.sendNotification(
|
||||
{
|
||||
endpoint: sub.endpoint,
|
||||
keys: { p256dh: sub.p256dh, auth: sub.auth },
|
||||
},
|
||||
textPayload,
|
||||
{ TTL: 86400 }
|
||||
);
|
||||
} catch (err) {
|
||||
app.log.warn({ err, subscriptionId: sub.id }, 'Push send failed');
|
||||
if ((err as { statusCode?: number }).statusCode === 410 || (err as { statusCode?: number }).statusCode === 404) {
|
||||
await db.delete(pushSubscriptions).where(eq(pushSubscriptions.id, sub.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startReminderWorker(): void {
|
||||
const intervalMs = 60_000;
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const due = await db
|
||||
.select()
|
||||
.from(reminders)
|
||||
.where(and(lte(reminders.remindAt, new Date()), isNull(reminders.sentAt)));
|
||||
for (const r of due) {
|
||||
await sendPushToUser(r.userId, {
|
||||
title: r.query ? `Reminder: ${r.query.slice(0, 80)}` : 'GooSeek Reminder',
|
||||
body: r.threadId ? `Thread ${r.threadId}` : 'Your reminder',
|
||||
type: 'reminder',
|
||||
threadId: r.threadId,
|
||||
});
|
||||
await db
|
||||
.update(reminders)
|
||||
.set({ sentAt: new Date() })
|
||||
.where(eq(reminders.id, r.id));
|
||||
}
|
||||
} catch (err) {
|
||||
app.log.error({ err }, 'Reminder worker error');
|
||||
}
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
app.get('/health', async () => ({
|
||||
status: 'ok',
|
||||
smtp: isSmtpConfigured(),
|
||||
}));
|
||||
app.get('/ready', async () => ({ status: 'ready' }));
|
||||
|
||||
app.get('/api/v1/notifications/vapid-public', async () => ({
|
||||
vapidPublicKey: vapidPublic,
|
||||
}));
|
||||
|
||||
app.post<{
|
||||
Body: { endpoint?: string; keys?: { p256dh?: string; auth?: string } };
|
||||
}>('/api/v1/notifications/subscribe', async (req, reply) => {
|
||||
const userId = await getUserIdFromToken(req.headers.authorization);
|
||||
if (!userId) {
|
||||
return reply.status(401).send({ error: 'Authorization required' });
|
||||
}
|
||||
const { endpoint, keys } = req.body ?? {};
|
||||
if (!endpoint?.trim() || !keys?.p256dh?.trim() || !keys?.auth?.trim()) {
|
||||
return reply.status(400).send({
|
||||
error: 'endpoint and keys.p256dh, keys.auth are required',
|
||||
});
|
||||
}
|
||||
const id = crypto.randomUUID();
|
||||
try {
|
||||
await db.insert(pushSubscriptions).values({
|
||||
id,
|
||||
userId,
|
||||
endpoint: endpoint.trim(),
|
||||
p256dh: keys.p256dh.trim(),
|
||||
auth: keys.auth.trim(),
|
||||
});
|
||||
return reply.status(201).send({ id });
|
||||
} catch (err) {
|
||||
req.log.error(err);
|
||||
return reply.status(500).send({ error: 'Failed to save subscription' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{
|
||||
Body: {
|
||||
pushOnAnswer?: boolean;
|
||||
emailOnDeepResearch?: boolean;
|
||||
reminders?: unknown[];
|
||||
};
|
||||
}>('/api/v1/notifications/preferences', async (req, reply) => {
|
||||
const userId = await getUserIdFromToken(req.headers.authorization);
|
||||
if (!userId) {
|
||||
return reply.status(401).send({ error: 'Authorization required' });
|
||||
}
|
||||
const { pushOnAnswer, emailOnDeepResearch, reminders: rems } = req.body ?? {};
|
||||
const pushVal = pushOnAnswer ?? true;
|
||||
const emailVal = emailOnDeepResearch ?? false;
|
||||
const remsVal = Array.isArray(rems) ? rems : [];
|
||||
try {
|
||||
await db
|
||||
.insert(notificationPreferences)
|
||||
.values({
|
||||
userId,
|
||||
pushOnAnswer: pushVal,
|
||||
emailOnDeepResearch: emailVal,
|
||||
reminders: remsVal,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: notificationPreferences.userId,
|
||||
set: {
|
||||
pushOnAnswer: pushVal,
|
||||
emailOnDeepResearch: emailVal,
|
||||
reminders: remsVal,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
return reply.status(200).send({ ok: true });
|
||||
} catch (err) {
|
||||
req.log.error(err);
|
||||
return reply.status(500).send({ error: 'Failed to save preferences' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{
|
||||
Body: { threadId?: string; query?: string; remindAt?: string };
|
||||
}>('/api/v1/notifications/remind', async (req, reply) => {
|
||||
const userId = await getUserIdFromToken(req.headers.authorization);
|
||||
if (!userId) {
|
||||
return reply.status(401).send({ error: 'Authorization required' });
|
||||
}
|
||||
const { threadId, query, remindAt } = req.body ?? {};
|
||||
if (!remindAt) {
|
||||
return reply.status(400).send({ error: 'remindAt (ISO8601) is required' });
|
||||
}
|
||||
const at = new Date(remindAt);
|
||||
if (isNaN(at.getTime())) {
|
||||
return reply.status(400).send({ error: 'remindAt must be valid ISO8601' });
|
||||
}
|
||||
if (at <= new Date()) {
|
||||
return reply.status(400).send({ error: 'remindAt must be in the future' });
|
||||
}
|
||||
const id = crypto.randomUUID();
|
||||
try {
|
||||
await db.insert(reminders).values({
|
||||
id,
|
||||
userId,
|
||||
threadId: threadId ?? null,
|
||||
query: query ?? null,
|
||||
remindAt: at,
|
||||
});
|
||||
return reply.status(201).send({
|
||||
id,
|
||||
remindAt: at.toISOString(),
|
||||
});
|
||||
} catch (err) {
|
||||
req.log.error(err);
|
||||
return reply.status(500).send({ error: 'Failed to create reminder' });
|
||||
}
|
||||
});
|
||||
|
||||
function checkInternalAuth(req: { headers: Record<string, string | string[] | undefined> }): boolean {
|
||||
if (!INTERNAL_API_KEY) return true;
|
||||
const key = req.headers['x-internal-service-key'];
|
||||
const val = Array.isArray(key) ? key[0] : key;
|
||||
return val === INTERNAL_API_KEY;
|
||||
}
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
app.post<{
|
||||
Body: { to?: string; subject?: string; text?: string; html?: string };
|
||||
}>('/api/v1/notifications/send-email', async (req, reply) => {
|
||||
if (!checkInternalAuth(req)) {
|
||||
return reply.status(401).send({ error: 'Invalid or missing X-Internal-Service-Key' });
|
||||
}
|
||||
if (!isSmtpConfigured()) {
|
||||
return reply.status(503).send({
|
||||
error: 'SMTP not configured. Set SMTP_URL (smtp://user:pass@host:587)',
|
||||
});
|
||||
}
|
||||
const { to, subject, text, html } = req.body ?? {};
|
||||
if (!to?.trim()) {
|
||||
return reply.status(400).send({ error: 'to (email) is required' });
|
||||
}
|
||||
if (!emailRegex.test(to.trim())) {
|
||||
return reply.status(400).send({ error: 'to must be a valid email address' });
|
||||
}
|
||||
if (!subject?.trim()) {
|
||||
return reply.status(400).send({ error: 'subject is required' });
|
||||
}
|
||||
if (!text?.trim() && !html?.trim()) {
|
||||
return reply.status(400).send({ error: 'text or html is required' });
|
||||
}
|
||||
try {
|
||||
await sendEmail({
|
||||
to: to.trim(),
|
||||
subject: subject.trim(),
|
||||
text: text?.trim(),
|
||||
html: html?.trim(),
|
||||
});
|
||||
return reply.status(200).send({ ok: true });
|
||||
} catch (err) {
|
||||
req.log.error({ err, to }, 'Email send failed');
|
||||
return reply.status(500).send({
|
||||
error: 'Failed to send email',
|
||||
details: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
startReminderWorker();
|
||||
|
||||
try {
|
||||
await app.listen({ port: PORT, host: '0.0.0.0' });
|
||||
console.log('notifications-svc listening on :' + PORT);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
63
services/notifications-svc/src/lib/email.ts
Normal file
63
services/notifications-svc/src/lib/email.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* SMTP email sender для notifications-svc
|
||||
* Использует SMTP_URL (smtp://user:pass@host:port) или отдельные переменные
|
||||
*/
|
||||
|
||||
import nodemailer from 'nodemailer';
|
||||
import type { Transporter } from 'nodemailer';
|
||||
|
||||
const SMTP_URL = process.env.SMTP_URL ?? '';
|
||||
const SMTP_FROM = process.env.SMTP_FROM ?? 'GooSeek <notifications@gooseek.local>';
|
||||
|
||||
let transporter: Transporter | null = null;
|
||||
|
||||
function getTransporter(): Transporter | null {
|
||||
if (transporter) return transporter;
|
||||
if (!SMTP_URL?.trim()) return null;
|
||||
try {
|
||||
transporter = nodemailer.createTransport(
|
||||
{ url: SMTP_URL, pool: true, maxConnections: 5, maxMessages: 100 }
|
||||
);
|
||||
return transporter;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isSmtpConfigured(): boolean {
|
||||
return !!SMTP_URL?.trim();
|
||||
}
|
||||
|
||||
export interface SendEmailOptions {
|
||||
to: string;
|
||||
subject: string;
|
||||
text?: string;
|
||||
html?: string;
|
||||
}
|
||||
|
||||
export async function sendEmail(options: SendEmailOptions): Promise<boolean> {
|
||||
const transport = getTransporter();
|
||||
if (!transport) {
|
||||
return false;
|
||||
}
|
||||
const { to, subject, text, html } = options;
|
||||
if (!to?.trim() || !subject?.trim()) {
|
||||
throw new Error('to and subject are required');
|
||||
}
|
||||
const body = text ?? html ?? '';
|
||||
if (!body) {
|
||||
throw new Error('text or html is required');
|
||||
}
|
||||
try {
|
||||
await transport.sendMail({
|
||||
from: SMTP_FROM,
|
||||
to: to.trim(),
|
||||
subject: subject.trim(),
|
||||
text: text?.trim(),
|
||||
html: html?.trim() || undefined,
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
14
services/notifications-svc/tsconfig.json
Normal file
14
services/notifications-svc/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user