'use strict'; const express = require('express'); const BUILDINGS_CACHE_TTL_MS = 30 * 1000; // 30 секунд function createResponseCache() { const store = new Map(); return { get(key) { const entry = store.get(key); if (!entry || Date.now() > entry.expiresAt) { if (entry) store.delete(key); return null; } return entry.data; }, set(key, data, ttlMs = BUILDINGS_CACHE_TTL_MS) { store.set(key, { data, expiresAt: Date.now() + ttlMs }); }, invalidatePrefix(prefix) { for (const key of store.keys()) { if (key.startsWith(prefix)) store.delete(key); } } }; } /** * Buildings API router. * @param {{ query: (text: string, params?: any[]) => Promise }} deps * @returns {express.Router} */ function createBuildingsRouter(deps) { const { query } = deps; const router = express.Router(); const cache = createResponseCache(); function invalidateBuildingsCache() { cache.invalidatePrefix('buildings:'); } // GET /buildings -> список домов (пагинация, опционально ?light=1). При scope=own_district — дома назначенных участков (employee_districts + fallback assigned_district_id). router.get('/buildings', async (req, res) => { try { const limit = Math.min(Math.max(1, parseInt(req.query.limit, 10) || 100), 500); const offset = Math.max(0, parseInt(req.query.offset, 10) || 0); const light = req.query.light === '1' || req.query.light === 'true'; let districtIds = null; // null = все участки, [] = нет доступа, [id, ...] = только эти участки if (req.user && req.user.userId) { const userScopeRows = await query( `SELECT pu.scope, e.id AS employee_id, e.assigned_district_id FROM portal_users pu JOIN employees e ON e.id = pu.employee_id WHERE pu.id = $1`, [req.user.userId] ); const row = userScopeRows[0]; if (row && row.scope === 'own_district') { let ids = []; try { const edRows = await query( 'SELECT district_id FROM employee_districts WHERE employee_id = $1 ORDER BY district_id', [row.employee_id] ); ids = (edRows || []).map((r) => r.district_id).filter(Boolean); } catch (_) {} if (ids.length === 0 && row.assigned_district_id) { ids = [row.assigned_district_id]; } districtIds = ids; } } const cacheKey = districtIds === null ? `buildings:list:${limit}:${offset}:${light}` : `buildings:list:${limit}:${offset}:${light}:districts:${[...districtIds].sort().join(',')}`; const cached = districtIds === null ? cache.get(cacheKey) : null; if (cached !== null) { return res.json(cached); } let rows; if (districtIds !== null && districtIds.length === 0) { rows = []; } else if (districtIds !== null && districtIds.length > 0) { rows = await query( `SELECT id, data FROM buildings WHERE (data->>'districtId') = ANY($1::text[]) ORDER BY id LIMIT $2 OFFSET $3`, [districtIds, limit, offset] ); } else { rows = await query( 'SELECT id, data FROM buildings ORDER BY id LIMIT $1 OFFSET $2', [limit, offset] ); } let data; if (light) { data = rows.map((r) => { const d = r.data || {}; return { id: d.id || r.id, districtId: d.districtId ?? null, passport: d.passport ? { address: d.passport.address || '' } : { address: '' } }; }); } else { data = rows.map((r) => r.data); const ids = rows.map((r) => (r.data && r.data.id) || r.id).filter(Boolean); if (ids.length > 0) { try { const placeholders = ids.map((_, i) => `$${i + 1}`).join(', '); const accountRows = await query( `SELECT building_id, data FROM building_personal_accounts WHERE building_id IN (${placeholders})`, ids ); const accountsByBuilding = {}; for (const ar of accountRows) { if (!accountsByBuilding[ar.building_id]) accountsByBuilding[ar.building_id] = []; accountsByBuilding[ar.building_id].push(ar.data); } for (const b of data) { const bid = b.id; b.accounts = Array.isArray(accountsByBuilding[bid]) ? accountsByBuilding[bid] : (Array.isArray(b.accounts) ? b.accounts : []); } } catch (e) { if (!e.message || !e.message.includes('building_personal_accounts')) { console.warn('Error loading accounts for list:', e.message); } } } } if (districtIds === null) cache.set(cacheKey, data); res.json(data); } catch (err) { console.error('Error fetching buildings:', err); res.status(500).json({ error: 'Failed to fetch buildings' }); } }); // POST /buildings -> создание нового дома router.post('/buildings', async (req, res) => { const building = req.body; if (!building || !building.id || !building.passport || !building.passport.address) { return res .status(400) .json({ error: 'Ожидается объект Building с полями id и passport.address' }); } try { await query('INSERT INTO buildings (id, data) VALUES ($1, $2)', [ building.id, building, ]); try { const existingSurvey = await query( `SELECT id FROM nps_surveys WHERE building_id = $1`, [building.id] ); if (existingSurvey.length === 0) { const accessKey = `nps-${building.id}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; await query( `INSERT INTO nps_surveys (building_id, title, description, status, access_key, created_by) VALUES ($1, $2, $3, $4, $5, 'system')`, [ building.id, 'Опрос удовлетворенности жителей', 'Помогите нам стать лучше! Поделитесь своим мнением о качестве обслуживания.', 'draft', accessKey ] ); console.log(`[Auto NPS] Создан опрос для нового дома ${building.id}`); } } catch (npsErr) { console.error(`[Auto NPS] Ошибка создания опроса для дома ${building.id}:`, npsErr); } try { const now = new Date(); const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1); const currentMonthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59); const months = [ 'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь' ]; const monthName = months[now.getMonth()]; const reportMonth = `${monthName} ${now.getFullYear()}`; const existingReport = await query( `SELECT id FROM resident_reports WHERE building_id = $1 AND month = $2`, [building.id, reportMonth] ); if (existingReport.length === 0) { const initialContent = { applications: { total: 156, completed: 153, quality: 98 }, finances: { collected: 2400000, expenses: 1890000, balance: 560000 }, nps: { score: 72, totalResponses: 45 } }; const newReport = await query( `INSERT INTO resident_reports (building_id, month, period_start, period_end, status, content, published_at) VALUES ($1, $2, $3, $4, $5, $6::jsonb, NOW()) RETURNING id`, [ building.id, reportMonth, currentMonthStart.toISOString().split('T')[0], currentMonthEnd.toISOString().split('T')[0], 'published', JSON.stringify(initialContent) ] ); console.log(`[Auto Report] Создан опубликованный отчет для нового дома ${building.id} (ID: ${newReport[0].id})`); } } catch (reportErr) { console.error(`[Auto Report] Ошибка создания отчета для дома ${building.id}:`, reportErr); } invalidateBuildingsCache(); res.status(201).json(building); } catch (err) { console.error('Error creating building:', err); res.status(500).json({ error: 'Failed to create building' }); } }); // GET /buildings/:id — здание + лицевые счета (из building_personal_accounts или из data) router.get('/buildings/:id', async (req, res) => { try { const { id } = req.params; const cacheKey = `buildings:id:${id}`; const cached = cache.get(cacheKey); if (cached !== null) { return res.json(cached); } const rows = await query('SELECT data FROM buildings WHERE id = $1', [id]); if (!rows.length) { return res.status(404).json({ error: 'Дом не найден' }); } const data = rows[0].data; try { const accountRows = await query( 'SELECT id, data FROM building_personal_accounts WHERE building_id = $1', [id] ); if (accountRows.length > 0) { data.accounts = accountRows.map((r) => r.data); } else if (!Array.isArray(data.accounts)) { data.accounts = []; } } catch (e) { if (!Array.isArray(data.accounts)) data.accounts = []; } cache.set(cacheKey, data); res.json(data); } catch (err) { console.error('Error fetching building:', err); res.status(500).json({ error: err.message || 'Failed to fetch building' }); } }); // PUT /buildings/:id — если есть таблица building_personal_accounts: не пишем accounts в data; иначе пишем всё (fallback) router.put('/buildings/:id', async (req, res) => { const { id } = req.params; const building = req.body; if (!building || !building.passport || !building.passport.address) { return res .status(400) .json({ error: 'Ожидается объект Building с полями passport.address' }); } try { let useAccountsTable = false; try { await query('SELECT 1 FROM building_personal_accounts LIMIT 1'); useAccountsTable = true; } catch (_) {} const toSave = { ...building }; if (useAccountsTable) delete toSave.accounts; await query('UPDATE buildings SET data = $2 WHERE id = $1', [id, toSave]); invalidateBuildingsCache(); const dataForResponse = { ...toSave }; if (useAccountsTable) { try { const accountRows = await query( 'SELECT id, data FROM building_personal_accounts WHERE building_id = $1', [id] ); dataForResponse.accounts = accountRows.length > 0 ? accountRows.map((r) => r.data) : []; } catch (e) { dataForResponse.accounts = Array.isArray(building.accounts) ? building.accounts : []; } } else { dataForResponse.accounts = Array.isArray(building.accounts) ? building.accounts : []; } res.json(dataForResponse); } catch (err) { console.error('Error updating building:', err); res.status(500).json({ error: 'Failed to update building' }); } }); // DELETE /buildings/:id router.delete('/buildings/:id', async (req, res) => { const { id } = req.params; try { const buildingRows = await query('SELECT id FROM buildings WHERE id = $1', [id]); if (buildingRows.length === 0) { return res.status(404).json({ error: 'Дом не найден' }); } await query('DELETE FROM buildings WHERE id = $1', [id]); invalidateBuildingsCache(); res.json({ success: true, message: 'Дом успешно удален' }); } catch (err) { console.error('Error deleting building:', err); res.status(500).json({ error: 'Ошибка при удалении дома' }); } }); // POST /buildings/:id/accounts — в building_personal_accounts или в buildings.data (fallback) router.post('/buildings/:id/accounts', async (req, res) => { const { id: buildingId } = req.params; const newAccount = req.body; if (!newAccount || !newAccount.apartmentNumber) { return res.status(400).json({ error: 'Ожидается объект PersonalAccount с полем apartmentNumber' }); } try { const buildingRows = await query('SELECT data FROM buildings WHERE id = $1', [buildingId]); if (buildingRows.length === 0) { return res.status(404).json({ error: 'Building not found' }); } let useAccountsTable = false; try { await query('SELECT 1 FROM building_personal_accounts LIMIT 1'); useAccountsTable = true; } catch (_) {} const accounts = Array.isArray(buildingRows[0].data?.accounts) ? buildingRows[0].data.accounts : []; if (!newAccount.id) newAccount.id = `${buildingId}-acc-${Date.now()}`; if (!newAccount.accountNumber) newAccount.accountNumber = `${buildingId.replace('b-', '')}00${accounts.length + 1}`; if (useAccountsTable) { await query( 'INSERT INTO building_personal_accounts (id, building_id, data) VALUES ($1, $2, $3)', [newAccount.id, buildingId, newAccount] ); } else { const building = buildingRows[0].data; building.accounts = [...accounts, newAccount]; building.isDirty = true; await query('UPDATE buildings SET data = $2 WHERE id = $1', [buildingId, building]); } invalidateBuildingsCache(); res.status(201).json(newAccount); } catch (err) { console.error('Error creating account:', err); res.status(500).json({ error: 'Failed to create account' }); } }); // PUT /buildings/:id/accounts/:accountId — обновление в building_personal_accounts или в data (fallback) router.put('/buildings/:id/accounts/:accountId', async (req, res) => { const { id: buildingId, accountId } = req.params; const updatedAccount = req.body; if (!updatedAccount || !updatedAccount.id) { return res.status(400).json({ error: 'Ожидается объект PersonalAccount с полем id' }); } try { const buildingRows = await query('SELECT data FROM buildings WHERE id = $1', [buildingId]); if (buildingRows.length === 0) { return res.status(404).json({ error: 'Building not found' }); } let useAccountsTable = false; try { await query('SELECT 1 FROM building_personal_accounts LIMIT 1'); useAccountsTable = true; } catch (_) {} if (useAccountsTable) { const updateResult = await query( 'UPDATE building_personal_accounts SET data = $3 WHERE id = $1 AND building_id = $2 RETURNING id', [accountId, buildingId, updatedAccount] ); if (updateResult.length === 0) { return res.status(404).json({ error: 'Account not found' }); } } else { const building = buildingRows[0].data; const accounts = Array.isArray(building.accounts) ? building.accounts : []; const idx = accounts.findIndex((a) => a.id === accountId); if (idx === -1) return res.status(404).json({ error: 'Account not found' }); building.accounts = accounts.map((a) => (a.id === accountId ? updatedAccount : a)); building.isDirty = true; await query('UPDATE buildings SET data = $2 WHERE id = $1', [buildingId, building]); } invalidateBuildingsCache(); res.json(updatedAccount); } catch (err) { console.error('Error updating account:', err); res.status(500).json({ error: 'Failed to update account' }); } }); // DELETE /buildings/:id/accounts/:accountId — из building_personal_accounts или из data (fallback) router.delete('/buildings/:id/accounts/:accountId', async (req, res) => { const { id: buildingId, accountId } = req.params; try { const buildingRows = await query('SELECT data FROM buildings WHERE id = $1', [buildingId]); if (buildingRows.length === 0) { return res.status(404).json({ error: 'Building not found' }); } let useAccountsTable = false; try { await query('SELECT 1 FROM building_personal_accounts LIMIT 1'); useAccountsTable = true; } catch (_) {} if (useAccountsTable) { const deleteResult = await query( 'DELETE FROM building_personal_accounts WHERE id = $1 AND building_id = $2 RETURNING id', [accountId, buildingId] ); if (deleteResult.length === 0) { return res.status(404).json({ error: 'Account not found' }); } } else { const building = buildingRows[0].data; const accounts = Array.isArray(building.accounts) ? building.accounts : []; const filtered = accounts.filter((a) => a.id !== accountId); if (filtered.length === accounts.length) { return res.status(404).json({ error: 'Account not found' }); } building.accounts = filtered; building.isDirty = true; await query('UPDATE buildings SET data = $2 WHERE id = $1', [buildingId, building]); } invalidateBuildingsCache(); res.json({ success: true, message: 'Account deleted' }); } catch (err) { console.error('Error deleting account:', err); res.status(500).json({ error: 'Failed to delete account' }); } }); return router; } module.exports = createBuildingsRouter;