diff --git a/docker-compose.yml b/docker-compose.yml index 463e2b6..59fff5d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,8 @@ services: GRAPH_CLIENT_ID: ${GRAPH_CLIENT_ID:?в .env на сервере не задан GRAPH_CLIENT_ID} GRAPH_CERT_PATH: /certs/SPFxDeploy_cert.pem GRAPH_KEY_PATH: /certs/SPFxDeploy_key.pem + SHAREPOINT_SITE_URL: ${SHAREPOINT_SITE_URL:?в .env на сервере не задан SHAREPOINT_SITE_URL} + SHAREPOINT_LIST_NAME: ${SHAREPOINT_LIST_NAME:?в .env на сервере не задан SHAREPOINT_LIST_NAME} volumes: # Каталог с сертификатом/ключом на сервере — путь задаётся в .env # (GRAPH_CERT_DIR), сами файлы в git не попадают. diff --git a/server/src/config.ts b/server/src/config.ts index 0aa44ee..7b23608 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -12,5 +12,7 @@ export const config = { certPath: requireEnv('GRAPH_CERT_PATH'), keyPath: requireEnv('GRAPH_KEY_PATH'), scope: process.env.GRAPH_SCOPE ?? 'https://graph.microsoft.com/.default', - port: Number(process.env.PORT ?? 3000) + port: Number(process.env.PORT ?? 3000), + sharepointSiteUrl: requireEnv('SHAREPOINT_SITE_URL'), + sharepointListName: requireEnv('SHAREPOINT_LIST_NAME') }; diff --git a/server/src/index.ts b/server/src/index.ts index 39fad2b..da89b4a 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,8 +1,10 @@ import express from 'express'; import { config } from './config'; import { getAppOnlyToken } from './graphAuth'; +import { submitFeedback } from './sharepoint'; const app = express(); +app.use(express.json()); app.get('/healthz', (_req, res) => { res.status(200).send('ok'); @@ -20,6 +22,20 @@ app.get('/diag/token', async (_req, res) => { } }); +app.post('/feedback', async (req, res) => { + try { + await submitFeedback(req.body); + res.status(201).json({ ok: true }); + } catch (error) { + const message = (error as Error).message; + // Логируем целиком в stdout прод-контейнера — если отвалится авторизация + // (протухший/незарегистрированный сертификат, неверный scope), это должно + // быть видно сразу в логах, а не тонуть в одной строке ответа клиенту. + console.error('Не удалось отправить обращение в SharePoint:', message); + res.status(502).json({ ok: false, error: message }); + } +}); + app.listen(config.port, () => { console.log(`hr-appeals-auth listening on :${config.port}`); }); diff --git a/server/src/sharepoint.ts b/server/src/sharepoint.ts new file mode 100644 index 0000000..a5a78e4 --- /dev/null +++ b/server/src/sharepoint.ts @@ -0,0 +1,91 @@ +import { config } from './config'; +import { getAppOnlyToken } from './graphAuth'; + +const SHAREPOINT_API_URL = 'https://api.tnsu.kz/SharePoint/ListItem'; + +export type AppealKind = 'complaint' | 'suggestion' | 'other'; + +const KIND_LABELS: Record = { + complaint: 'Обращение', + suggestion: 'Предложение', + 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; + fullName: string; + position: string; + wantsFeedback: boolean; + kind: AppealKind; + kindOther: string; + situation: string; + suggestion: string; +} + +// Имена полей — internal name столбцов списка SharePoint (Company/Name/Position/ +// Type/IssueDescription/SuggestionText/WantsFeedback), не отображаемые названия. +function buildFields(data: IFeedbackSubmission): Record { + 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}`); + } + + return { + Title: name ? `${typeLabel} — ${name}` : typeLabel, + Company: companyName ?? data.company, + Name: name, + Position: data.isAnonymous ? '' : data.position, + Type: typeLabel, + IssueDescription: data.situation, + SuggestionText: data.suggestion, + WantsFeedback: data.wantsFeedback + }; +} + +export async function submitFeedback(data: IFeedbackSubmission): Promise { + const token = await getAppOnlyToken(); + + const response = await fetch(SHAREPOINT_API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + siteUrl: config.sharepointSiteUrl, + listName: config.sharepointListName, + fields: buildFields(data) + }) + }); + + if (!response.ok) { + throw new Error( + `api.tnsu.kz вернул ошибку при создании элемента списка: ${response.status} ${await response.text()}` + ); + } +}