- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
161 lines
5.0 KiB
TypeScript
161 lines
5.0 KiB
TypeScript
import webpush from 'web-push';
|
|
import { storage } from '../storage';
|
|
|
|
const VAPID_KEY_CONFIG_KEY = 'vapid_public_key';
|
|
|
|
interface WebPushSubscription {
|
|
endpoint: string;
|
|
keys: {
|
|
p256dh: string;
|
|
auth: string;
|
|
};
|
|
}
|
|
|
|
class WebPushService {
|
|
private initialized = false;
|
|
private vapidPublicKey: string = '';
|
|
private vapidPrivateKey: string = '';
|
|
|
|
constructor() {
|
|
this.initialize();
|
|
}
|
|
|
|
private initialize() {
|
|
const publicKey = process.env.VAPID_PUBLIC_KEY;
|
|
const privateKey = process.env.VAPID_PRIVATE_KEY;
|
|
|
|
if (!publicKey || !privateKey) {
|
|
console.warn('[WebPush] WARNING: VAPID_PUBLIC_KEY and/or VAPID_PRIVATE_KEY are not set.');
|
|
console.warn('[WebPush] Generating temporary VAPID keys for this session only.');
|
|
console.warn('[WebPush] All existing push subscriptions will be invalidated on every server restart!');
|
|
console.warn('[WebPush] To fix this, generate permanent keys and set them as environment variables:');
|
|
console.warn('[WebPush] npx web-push generate-vapid-keys');
|
|
console.warn('[WebPush] Then set VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY in your environment secrets.');
|
|
const keys = webpush.generateVAPIDKeys();
|
|
this.vapidPublicKey = keys.publicKey;
|
|
this.vapidPrivateKey = keys.privateKey;
|
|
} else {
|
|
this.vapidPublicKey = publicKey;
|
|
this.vapidPrivateKey = privateKey;
|
|
}
|
|
|
|
webpush.setVapidDetails(
|
|
'mailto:admin@iistwin.ru',
|
|
this.vapidPublicKey,
|
|
this.vapidPrivateKey
|
|
);
|
|
|
|
this.initialized = true;
|
|
console.log('[WebPush] Service initialized');
|
|
}
|
|
|
|
async checkVapidKeyRotation(): Promise<void> {
|
|
try {
|
|
const storedKey = await storage.getSystemConfig(VAPID_KEY_CONFIG_KEY);
|
|
|
|
if (storedKey === null) {
|
|
await storage.setSystemConfig(VAPID_KEY_CONFIG_KEY, this.vapidPublicKey);
|
|
console.log('[WebPush] VAPID public key stored in system config for future rotation checks.');
|
|
return;
|
|
}
|
|
|
|
if (storedKey !== this.vapidPublicKey) {
|
|
console.warn('[WebPush] VAPID public key has changed! Previous key does not match current key.');
|
|
console.warn('[WebPush] Clearing all existing web push subscriptions — they are no longer valid.');
|
|
const count = await storage.clearAllWebPushSubscriptions();
|
|
console.warn(`[WebPush] Removed ${count} stale push subscription(s).`);
|
|
await storage.setSystemConfig(VAPID_KEY_CONFIG_KEY, this.vapidPublicKey);
|
|
console.log('[WebPush] System config updated with new VAPID public key.');
|
|
}
|
|
} catch (err) {
|
|
console.error('[WebPush] Error during VAPID key rotation check:', err);
|
|
}
|
|
}
|
|
|
|
getVapidPublicKey(): string {
|
|
return this.vapidPublicKey;
|
|
}
|
|
|
|
async registerSubscription(
|
|
userId: number,
|
|
organizationId: number,
|
|
subscription: WebPushSubscription
|
|
): Promise<void> {
|
|
await storage.registerWebPushSubscription(userId, organizationId, subscription.endpoint, JSON.stringify(subscription));
|
|
console.log(`[WebPush] Subscription registered for user ${userId}`);
|
|
}
|
|
|
|
async unregisterSubscription(endpoint: string): Promise<void> {
|
|
await storage.unregisterWebPushSubscription(endpoint);
|
|
console.log(`[WebPush] Subscription unregistered: ${endpoint.substring(0, 50)}...`);
|
|
}
|
|
|
|
async sendNotification(
|
|
subscription: WebPushSubscription,
|
|
payload: {
|
|
title?: string;
|
|
body?: string;
|
|
icon?: string;
|
|
url?: string;
|
|
tag?: string;
|
|
data?: Record<string, any>;
|
|
type?: string;
|
|
[key: string]: any;
|
|
}
|
|
): Promise<boolean> {
|
|
if (!this.initialized) {
|
|
console.log('[WebPush] Service not initialized');
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const result = await webpush.sendNotification(
|
|
subscription,
|
|
JSON.stringify(payload)
|
|
);
|
|
console.log(`[WebPush] FCM response status=${result.statusCode}, body=${result.body?.substring?.(0, 200) ?? result.body}`);
|
|
return true;
|
|
} catch (error: any) {
|
|
if (error.statusCode === 410 || error.statusCode === 404) {
|
|
console.log('[WebPush] Subscription expired, removing...');
|
|
await this.unregisterSubscription(subscription.endpoint);
|
|
} else {
|
|
console.error('[WebPush] Send error:', error.message);
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async sendToUser(
|
|
userId: number,
|
|
organizationId: number,
|
|
payload: {
|
|
title?: string;
|
|
body?: string;
|
|
icon?: string;
|
|
url?: string;
|
|
tag?: string;
|
|
data?: Record<string, any>;
|
|
type?: string;
|
|
[key: string]: any;
|
|
}
|
|
): Promise<number> {
|
|
const subscriptions = await storage.getWebPushSubscriptions(userId, organizationId);
|
|
let sent = 0;
|
|
|
|
for (const sub of subscriptions) {
|
|
try {
|
|
const subscription = JSON.parse(sub.subscription);
|
|
const success = await this.sendNotification(subscription, payload);
|
|
if (success) sent++;
|
|
} catch (error) {
|
|
console.error('[WebPush] Parse error for subscription:', error);
|
|
}
|
|
}
|
|
|
|
return sent;
|
|
}
|
|
}
|
|
|
|
export const webPushService = new WebPushService();
|