- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
139 lines
4.4 KiB
TypeScript
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();
|