fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
138
server/services/push.service.ts
Normal file
138
server/services/push.service.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
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();
|
||||
Reference in New Issue
Block a user