diff --git a/server/src/companies.ts b/server/src/companies.ts new file mode 100644 index 0000000..ecc51b5 --- /dev/null +++ b/server/src/companies.ts @@ -0,0 +1,52 @@ +import { getAppOnlyToken } from './graphAuth'; + +const COMPANIES_URL = 'https://api.tnsu.kz/Dictionary/Companies'; + +// Справочник компаний меняется редко (новое юрлицо в группе), но перезапуск +// сервиса ради этого — лишнее, поэтому кэш с коротким временем жизни. +const CACHE_TTL_MS = 10 * 60 * 1000; + +export interface ICompany { + Id: string; + Name: string; +} + +let cached: { companies: ICompany[]; expiresAt: number } | null = null; + +export async function getCompanies(): Promise { + if (cached && cached.expiresAt > Date.now()) { + return cached.companies; + } + + const token = await getAppOnlyToken(); + const response = await fetch(COMPANIES_URL, { + headers: { Authorization: `Bearer ${token}` } + }); + + if (!response.ok) { + throw new Error( + `Не удалось получить справочник компаний: ${response.status} ${await response.text()}` + ); + } + + const companies = (await response.json()) as ICompany[]; + cached = { companies, expiresAt: Date.now() + CACHE_TTL_MS }; + return companies; +} + +/** + * Колонка Company в списке текстовая и ждёт название, а форма присылает Id из + * справочника. Если Id неизвестен — возвращаем его как есть: потерять обращение + * из-за рассинхрона справочника хуже, чем записать в колонку сырой Id. + */ +export async function resolveCompanyName(id: string): Promise { + const companies = await getCompanies(); + const found = companies.find((company) => company.Id === id); + + if (!found) { + console.warn(`Неизвестный Id компании из формы, пишу как есть: ${id}`); + return id; + } + + return found.Name; +} diff --git a/server/src/index.ts b/server/src/index.ts index da89b4a..d58532e 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,4 +1,5 @@ import express from 'express'; +import { getCompanies } from './companies'; import { config } from './config'; import { getAppOnlyToken } from './graphAuth'; import { submitFeedback } from './sharepoint'; @@ -22,6 +23,18 @@ app.get('/diag/token', async (_req, res) => { } }); +// Справочник компаний для выпадающего списка формы. Ходит в api.tnsu.kz за +// нас, чтобы токен не утекал в браузер, и отдаёт список как есть. +app.get('/companies', async (_req, res) => { + try { + res.status(200).json(await getCompanies()); + } catch (error) { + const message = (error as Error).message; + console.error('Не удалось получить справочник компаний:', message); + res.status(502).json({ ok: false, error: message }); + } +}); + app.post('/feedback', async (req, res) => { try { await submitFeedback(req.body); diff --git a/server/src/sharepoint.ts b/server/src/sharepoint.ts index a83f20c..2bd255e 100644 --- a/server/src/sharepoint.ts +++ b/server/src/sharepoint.ts @@ -1,3 +1,4 @@ +import { resolveCompanyName } from './companies'; import { config } from './config'; import { getAppOnlyToken } from './graphAuth'; @@ -11,27 +12,6 @@ const KIND_LABELS: Record = { other: 'Другое' }; -// Форма присылает id компании из справочника (см. COMPANY_OPTIONS во фронтовом -// src/features/feedback/model.ts) — колонка Company в SharePoint текстовая и -// ждёт название, а не id, поэтому здесь его резолвим. Список держим в синхроне -// с фронтовым вручную — общего пакета между сервером и клиентом сейчас нет. -const COMPANY_NAMES: Record = { - '8028abac-ac23-8fd8-11ef-cb57631a9ab0': 'ТОО AFL Engineering', - '9de064a4-44c8-88ef-11ec-831c058b1141': 'ТОО CL MANAGEMENT', - '9de064a4-44c8-88ef-11ec-8287fb7737a2': 'ТОО KazPromService', - 'ec3c78a7-29ef-4725-11ed-a6d8fcd0911d': 'ТОО QAZGEOMAX', - '15009595-005d-00fa-11ef-11309f5e395d': 'ТОО Selin Invest', - '03f4deac-f543-e8cf-11f1-84eb25329fc3': 'ТОО Selin Studio', - '9de064a4-44c8-88ef-11ec-8284930fad96': 'ТОО TANSU Construction', - '9de064a4-44c8-88ef-11ec-8285925d83d2': 'ТОО АСТЕЛ-К', - '15009595-005d-00fa-11ef-0b8bb5ec1a88': 'ТОО ГеоТрек', - '15009595-005d-00fa-11ef-0b8714483372': 'ТОО Монтажник-Т', - '8028beac-ac23-8fd8-11f0-5351706e5996': 'ТОО ОСН Беркут', - '8028b0ac-ac23-8fd8-11ef-dcdb24b82e02': 'ТОО СТ Global', - '15009595-005d-00fa-11ef-1131801d9f3e': 'ТОО ТехЛаб', - '03f4deac-f543-e8cf-11f1-84f51483f59e': 'Частная компания Seline Holding Ltd' -}; - export interface IFeedbackSubmission { company: string; isAnonymous: boolean; @@ -46,18 +26,13 @@ export interface IFeedbackSubmission { // Имена полей — internal name столбцов списка SharePoint (Company/Name/Position/ // Type/IssueDescription/SuggestionText/WantsFeedback), не отображаемые названия. -function buildFields(data: IFeedbackSubmission): Record { +async function buildFields(data: IFeedbackSubmission): Promise> { const typeLabel = data.kind === 'other' ? data.kindOther : KIND_LABELS[data.kind]; const name = data.isAnonymous ? '' : data.fullName; - const companyName = COMPANY_NAMES[data.company]; - - if (!companyName) { - console.warn(`Неизвестный id компании из формы, отправляю как есть: ${data.company}`); - } const fields: Record = { Title: name ? `${typeLabel} — ${name}` : typeLabel, - Company: companyName ?? data.company, + Company: await resolveCompanyName(data.company), Name: name, Position: data.isAnonymous ? '' : data.position, Type: typeLabel, @@ -90,7 +65,7 @@ export async function submitFeedback(data: IFeedbackSubmission): Promise { body: JSON.stringify({ siteUrl: config.sharepointSiteUrl, listName: config.sharepointListName, - fields: buildFields(data) + fields: await buildFields(data) }) }); diff --git a/src/features/feedback/model.test.ts b/src/features/feedback/model.test.ts index 341310a..a0cf078 100644 --- a/src/features/feedback/model.test.ts +++ b/src/features/feedback/model.test.ts @@ -1,4 +1,4 @@ -import { COMPANY_OPTIONS, getAppealFieldsConfig } from './model'; +import { getAppealFieldsConfig } from './model'; // Форма (AppealSection) и валидация (validateFeedback) читают одно и то же // правило из getAppealFieldsConfig — если оно разойдётся, форма и валидация @@ -32,18 +32,3 @@ describe('getAppealFieldsConfig', () => { }); }); }); - -// Справочник компаний используется бэкендом по Id (см. комментарий в model.ts) — -// дубликат Id незаметно перепутал бы два разных юрлица. -describe('COMPANY_OPTIONS', () => { - it('не содержит повторяющихся Id', () => { - const ids = COMPANY_OPTIONS.map((option) => option.value); - expect(new Set(ids).size).toBe(ids.length); - }); - - it('у каждой компании есть непустое название', () => { - COMPANY_OPTIONS.forEach((option) => { - expect(option.label.trim().length).toBeGreaterThan(0); - }); - }); -}); diff --git a/src/features/feedback/model.ts b/src/features/feedback/model.ts index dd2a861..1673b8a 100644 --- a/src/features/feedback/model.ts +++ b/src/features/feedback/model.ts @@ -18,27 +18,6 @@ export interface IFeedbackData { suggestion: string; } -/** - * Компании группы. В value — реальный Id из справочника, а не slug: именно его - * ждёт бэкенд, когда появится отправка. Порядок как в источнике (по алфавиту). - */ -export const COMPANY_OPTIONS: ISelectOption[] = [ - { value: '8028abac-ac23-8fd8-11ef-cb57631a9ab0', label: 'ТОО AFL Engineering' }, - { value: '9de064a4-44c8-88ef-11ec-831c058b1141', label: 'ТОО CL MANAGEMENT' }, - { value: '9de064a4-44c8-88ef-11ec-8287fb7737a2', label: 'ТОО KazPromService' }, - { value: 'ec3c78a7-29ef-4725-11ed-a6d8fcd0911d', label: 'ТОО QAZGEOMAX' }, - { value: '15009595-005d-00fa-11ef-11309f5e395d', label: 'ТОО Selin Invest' }, - { value: '03f4deac-f543-e8cf-11f1-84eb25329fc3', label: 'ТОО Selin Studio' }, - { value: '9de064a4-44c8-88ef-11ec-8284930fad96', label: 'ТОО TANSU Construction' }, - { value: '9de064a4-44c8-88ef-11ec-8285925d83d2', label: 'ТОО АСТЕЛ-К' }, - { value: '15009595-005d-00fa-11ef-0b8bb5ec1a88', label: 'ТОО ГеоТрек' }, - { value: '15009595-005d-00fa-11ef-0b8714483372', label: 'ТОО Монтажник-Т' }, - { value: '8028beac-ac23-8fd8-11f0-5351706e5996', label: 'ТОО ОСН Беркут' }, - { value: '8028b0ac-ac23-8fd8-11ef-dcdb24b82e02', label: 'ТОО СТ Global' }, - { value: '15009595-005d-00fa-11ef-1131801d9f3e', label: 'ТОО ТехЛаб' }, - { value: '03f4deac-f543-e8cf-11f1-84f51483f59e', label: 'Частная компания Seline Holding Ltd' } -]; - // Порядок как в исходной форме: «Предложение» первым — совпадает с дефолтным // значением ниже (EMPTY_FEEDBACK.kind), так что выбор пользователя. export const KIND_OPTIONS: ISelectOption[] = [ diff --git a/src/features/feedback/sections/AuthorSection.tsx b/src/features/feedback/sections/AuthorSection.tsx index 9a2c069..0f27454 100644 --- a/src/features/feedback/sections/AuthorSection.tsx +++ b/src/features/feedback/sections/AuthorSection.tsx @@ -1,24 +1,33 @@ import { FormGrid, Select, TextInput, Toggle } from '@tnsu/ui-kit'; import { Field } from '../../../components/Field/Field'; -import { COMPANY_OPTIONS } from '../model'; import { TEXTS } from '../texts'; +import { useCompanies } from '../useCompanies'; import type { ISectionProps } from './ISectionProps'; /** Кто обращается: компания, анонимность, ФИО и должность. */ -export const AuthorSection: React.FC = ({ data, errors, set }) => ( +export const AuthorSection: React.FC = ({ data, errors, set }) => { + const companies = useCompanies(); + + return ( + {/* Ошибку справочника показываем в слоте ошибки поля: без списка компанию + не выбрать, а обязательное поле молча пустым оставлять нельзя. */}