Files
iistwin/server/services/push.service.ts
Ильяс Султанов 1f5ecb6da4 fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
2026-07-07 21:03:40 +03:00

139 lines
4.4 KiB
TypeScript

import { notificationService, PushNotificationPayload } from './notification.service';
import { storage } from '../storage';
const PUSHY_API_URL = 'https://api.pushy.me/push/json';
// Known Pushy error messages that indicate a device token is permanently invalid.
// Only these trigger token cleanup; transient errors (rate-limit, 400 malformed, etc.) are left alone.
const INVALID_DEVICE_ERROR_PATTERNS = [
'devicenotregistered',
'invalid push token',
'device not found',
'device not registered',
];
interface PushyErrorResponse {
error?: string;
code?: number;
status?: number;
}
export class PushService {
isConfigured(): boolean {
return !!process.env.PUSHY_API_KEY;
}
async sendNotification(payload: PushNotificationPayload): Promise<{ success: number; failure: number }> {
const apiKey = process.env.PUSHY_API_KEY;
if (!apiKey) {
console.log('Push notification skipped: PUSHY_API_KEY not set');
return { success: 0, failure: payload.tokens.length };
}
if (payload.tokens.length === 0) {
return { success: 0, failure: 0 };
}
// Build data payload, always including a deep-link URL when taskId + formId are present
const data: Record<string, string> = {
...(payload.data || {}),
organizationId: payload.organizationId.toString(),
userId: payload.userId.toString(),
};
// Only build the deep-link URL if the upstream caller has not already provided one.
// Include ?tab=chat when the notification originates from a chat event (isChat flag).
if (!data.url && data.taskId && data.formId) {
const chatSuffix = data.isChat === 'true' ? '?tab=chat' : '';
data.url = `/forms/${data.formId}/tasks/${data.taskId}${chatSuffix}`;
}
let success = 0;
let failure = 0;
const invalidTokens: string[] = [];
for (const token of payload.tokens) {
try {
const response = await fetch(`${PUSHY_API_URL}?api_key=${apiKey}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
to: token,
data,
notification: {
title: payload.title,
body: payload.body,
sound: 'default',
badge: 1,
},
}),
});
if (response.ok) {
success++;
} else {
let errorBody: PushyErrorResponse = {};
try {
errorBody = await response.json() as PushyErrorResponse;
} catch {
// ignore parse errors
}
console.error(`Pushy push failed for token: ${errorBody.error ?? response.status}`);
if (errorBody.error && INVALID_DEVICE_ERROR_PATTERNS.some(
p => errorBody.error!.toLowerCase().includes(p)
)) {
invalidTokens.push(token);
}
failure++;
}
} catch (error) {
console.error('Pushy request error:', error);
failure++;
}
}
console.log(`Pushy push sent to user ${payload.userId} in org ${payload.organizationId}: ${success} success, ${failure} failed`);
if (invalidTokens.length > 0) {
const deletedCount = await storage.deleteInvalidDeviceTokens(invalidTokens);
console.log(`Cleaned up ${deletedCount} invalid device tokens`);
}
return { success, failure };
}
async processPushQueue(): Promise<{ processed: number; success: number; failure: number }> {
const pending = notificationService.getPendingPushNotifications();
if (pending.length === 0) {
return { processed: 0, success: 0, failure: 0 };
}
let totalSuccess = 0;
let totalFailure = 0;
for (const p of pending) {
const result = await this.sendNotification(p);
totalSuccess += result.success;
totalFailure += result.failure;
}
console.log(`Push queue processed: ${pending.length} notifications, ${totalSuccess} success, ${totalFailure} failed`);
return { processed: pending.length, success: totalSuccess, failure: totalFailure };
}
startQueueProcessor(intervalMs: number = 5000): NodeJS.Timeout {
console.log(`Starting push queue processor with ${intervalMs}ms interval`);
return setInterval(async () => {
if (notificationService.hasPendingPushNotifications()) {
await this.processPushQueue();
}
}, intervalMs);
}
}
export const pushService = new PushService();