fix(number-fields): избегаем потери точности длинных чисел

- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
2026-07-07 21:03:40 +03:00
commit 1f5ecb6da4
1089 changed files with 237546 additions and 0 deletions

View File

@@ -0,0 +1,321 @@
import { storage } from "../storage";
import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware";
export function registerPushRoutes(app: import("express").Express): void {
// =====================
// Device Tokens for Push Notifications
// =====================
/**
* @swagger
* /api/devices/register:
* post:
* tags: [Devices]
* summary: Зарегистрировать устройство для push-уведомлений
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [token, platform]
* properties:
* token:
* type: string
* description: Pushy device token
* platform:
* type: string
* enum: [android, ios]
* responses:
* 200:
* description: Устройство зарегистрировано
* /api/devices/unregister:
* post:
* tags: [Devices]
* summary: Удалить устройство из push-уведомлений
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [token]
* properties:
* token:
* type: string
* responses:
* 200:
* description: Удалено
*/
// Register a device token
app.post('/api/devices/register', authenticateToken, async (req: AuthenticatedRequest, res) => {
try {
const { token, platform, deviceInfo } = req.body;
if (!token || !platform) {
return res.status(400).json({ error: 'Токен и платформа обязательны' });
}
if (!['android', 'ios', 'web'].includes(platform)) {
return res.status(400).json({ error: 'Неверная платформа. Допустимые значения: android, ios, web' });
}
const userId = req.user!.userId;
const organizationId = req.user!.organizationId;
await storage.registerDeviceToken(userId, organizationId, token, platform, deviceInfo);
await storage.updateUserPresence(userId, organizationId, true);
res.json({ success: true });
} catch (error) {
console.error('Register device token error:', error);
res.status(500).json({ error: 'Ошибка регистрации устройства' });
}
});
// Unregister a device token
app.post('/api/devices/unregister', authenticateToken, async (req: AuthenticatedRequest, res) => {
try {
const { token } = req.body;
if (!token) {
return res.status(400).json({ error: 'Токен обязателен' });
}
const userId = req.user!.userId;
const organizationId = req.user!.organizationId;
await storage.unregisterDeviceToken(userId, organizationId, token);
res.json({ success: true });
} catch (error) {
console.error('Unregister device token error:', error);
res.status(500).json({ error: 'Ошибка удаления токена устройства' });
}
});
// Heartbeat endpoint to update presence
app.post('/api/devices/heartbeat', authenticateToken, async (req: AuthenticatedRequest, res) => {
try {
const userId = req.user!.userId;
const organizationId = req.user!.organizationId;
await storage.heartbeatUserPresence(userId, organizationId);
res.json({ success: true });
} catch (error) {
console.error('Heartbeat error:', error);
res.status(500).json({ error: 'Ошибка обновления статуса' });
}
});
// Get user's registered devices
app.get('/api/devices', authenticateToken, async (req: AuthenticatedRequest, res) => {
try {
const userId = req.user!.userId;
const organizationId = req.user!.organizationId;
const tokens = await storage.getDeviceTokensByUser(userId, organizationId);
res.json({ success: true, devices: tokens.map(t => ({
id: t.id,
platform: t.platform,
lastActiveAt: t.lastActiveAt,
deviceInfo: t.deviceInfo,
}))});
} catch (error) {
console.error('Get devices error:', error);
res.status(500).json({ error: 'Ошибка получения списка устройств' });
}
});
/**
* @swagger
* /api/web-push/vapid-key:
* get:
* tags: [Devices]
* summary: Получить VAPID публичный ключ для Web Push
* description: Возвращает VAPID публичный ключ, необходимый для создания подписки Web Push на стороне клиента (PushManager.subscribe).
* responses:
* 200:
* description: VAPID публичный ключ
* content:
* application/json:
* schema:
* type: object
* properties:
* vapidPublicKey:
* type: string
* description: Base64url-encoded VAPID публичный ключ
* /api/web-push/subscribe:
* post:
* tags: [Devices]
* summary: Подписать браузер на Web Push уведомления
* description: Регистрирует Web Push подписку текущего пользователя. Subscription-объект получается из `PushManager.subscribe()` браузерного API. Требует авторизации.
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [subscription]
* properties:
* subscription:
* type: object
* description: Объект подписки из PushManager.subscribe()
* required: [endpoint]
* properties:
* endpoint:
* type: string
* description: URL эндпоинта push-сервиса браузера
* keys:
* type: object
* properties:
* p256dh:
* type: string
* description: Публичный ключ шифрования
* auth:
* type: string
* description: Секрет аутентификации
* responses:
* 200:
* description: Подписка зарегистрирована
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* 400:
* description: Subscription обязательна
* 401:
* description: Не авторизован
* /api/web-push/unsubscribe:
* post:
* tags: [Devices]
* summary: Отписать браузер от Web Push уведомлений
* description: Удаляет Web Push подписку по endpoint. Требует авторизации.
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [endpoint]
* properties:
* endpoint:
* type: string
* description: URL эндпоинта push-сервиса браузера (из subscription.endpoint)
* responses:
* 200:
* description: Подписка удалена
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* 400:
* description: Endpoint обязателен
* 401:
* description: Не авторизован
*/
// Web Push - Get VAPID public key
app.get('/api/web-push/vapid-key', async (_req, res) => {
try {
const { webPushService } = await import('../services/web-push.service');
res.json({ vapidPublicKey: webPushService.getVapidPublicKey() });
} catch (error) {
console.error('Get VAPID key error:', error);
res.status(500).json({ error: 'Ошибка получения VAPID ключа' });
}
});
// Web Push - Register subscription
app.post('/api/devices/register-web-push', authenticateToken, async (req: AuthenticatedRequest, res) => {
try {
const { subscription } = req.body;
if (!subscription || !subscription.endpoint) {
return res.status(400).json({ error: 'Subscription обязательна' });
}
const userId = req.user!.id;
const organizationId = req.user!.organizationId;
const { webPushService } = await import('../services/web-push.service');
await webPushService.registerSubscription(userId, organizationId, subscription);
res.json({ success: true });
} catch (error) {
console.error('Register web push error:', error);
res.status(500).json({ error: 'Ошибка регистрации web push подписки' });
}
});
// Web Push - Unregister subscription
app.post('/api/devices/unregister-web-push', authenticateToken, async (req: AuthenticatedRequest, res) => {
try {
const { endpoint } = req.body;
if (!endpoint) {
return res.status(400).json({ error: 'Endpoint обязателен' });
}
const { webPushService } = await import('../services/web-push.service');
await webPushService.unregisterSubscription(endpoint);
res.json({ success: true });
} catch (error) {
console.error('Unregister web push error:', error);
res.status(500).json({ error: 'Ошибка удаления web push подписки' });
}
});
// Web Push - Subscribe (canonical path)
app.post('/api/web-push/subscribe', authenticateToken, async (req: AuthenticatedRequest, res) => {
try {
const { subscription } = req.body;
if (!subscription || !subscription.endpoint) {
return res.status(400).json({ error: 'Subscription обязательна' });
}
const userId = req.user!.id;
const organizationId = req.user!.organizationId;
const { webPushService } = await import('../services/web-push.service');
await webPushService.registerSubscription(userId, organizationId, subscription);
res.json({ success: true });
} catch (error) {
console.error('Web push subscribe error:', error);
res.status(500).json({ error: 'Ошибка регистрации web push подписки' });
}
});
// Web Push - Unsubscribe (canonical path)
app.post('/api/web-push/unsubscribe', authenticateToken, async (req: AuthenticatedRequest, res) => {
try {
const { endpoint } = req.body;
if (!endpoint) {
return res.status(400).json({ error: 'Endpoint обязателен' });
}
const { webPushService } = await import('../services/web-push.service');
await webPushService.unregisterSubscription(endpoint);
res.json({ success: true });
} catch (error) {
console.error('Web push unsubscribe error:', error);
res.status(500).json({ error: 'Ошибка удаления web push подписки' });
}
});
}