15 Commits

Author SHA1 Message Date
gorbenko.pavel f1caa62f28 refactor: сертификат и ключ в каталоге secrets/, один параметр на каталог
Tests / frontend-test (pull_request) Successful in 3m14s
Локально путь к сертификату вёл в ~/Downloads у каждого свой, а в контейнере
задавался двумя отдельными переменными. Теперь каталог один и подменяется
целиком через SECRETS_DIR: по умолчанию secrets/ в корне репозитория, в
контейнере /certs (смонтированный каталог с хоста, GRAPH_CERT_DIR не меняется).

Имена файлов внутри фиксированы, поэтому GRAPH_CERT_PATH и GRAPH_KEY_PATH
больше не нужны. Содержимое secrets/ в .gitignore, кроме README.

Отсутствие файлов теперь даёт понятную ошибку вместо ENOENT — на свежем
клоне это основной сценарий отказа.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 13:34:37 +05:00
gorbenko.pavel b4a10510e1 feat: справочник компаний из api.tnsu.kz вместо копии в коде
Список юрлиц группы меняется без нашего участия, а две копии в репозитории
(COMPANY_OPTIONS во фронте и COMPANY_NAMES на сервере) неизбежно разошлись
бы с источником и между собой. Теперь единственный источник —
/Dictionary/Companies:

- GET /companies проксирует справочник, чтобы токен не утекал в браузер;
- имя компании для колонки Company резолвится по нему же, на сервере;
- в кэше 10 минут — новое юрлицо подхватится без перезапуска сервиса.

Пока список грузится, в select показывается «Загружаем…»; если не
загрузился — ошибка в слоте обязательного поля, а не пустой список.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 13:06:09 +05:00
gorbenko.pavel e9ae3d5bab fix: не отправлять в SharePoint поля с пустым значением
Состав полей зависит от вида обращения и анонимности, поэтому часть строк
заведомо пустая. Пустая строка в списке выглядит как заполненное значение,
а не как отсутствующее поле — теперь такие поля выпадают из payload.
Значения обрезаются, так что поле из одних пробелов тоже не уедет.

WantsFeedback остаётся всегда: false здесь осмысленно, а не пусто.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 13:01:25 +05:00
gorbenko.pavel 0eb81e61e5 chore: npm run dev поднимает фронтенд и auth-сервис вместе
Локальное окружение требовало двух терминалов, а без запущенного
auth-сервиса форма отдаёт 500 от vite-прокси — это легко принять за баг
приложения. Теперь concurrently стартует оба процесса с метками
[web]/[auth]; только фронтенд — npm run dev:web.

Заодно поправлен README сервиса: GRAPH_SCOPE больше не нужно задавать
руками, дефолт уже указывает на App ID URI api.tnsu.kz.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 12:57:36 +05:00
gorbenko.pavel 07a44e3b0e chore: запуск auth-сервиса локально одной командой
npm run dev читает server/.env через --env-file (нативно в Node 20+),
чтобы не передавать GRAPH_* руками при каждом старте. Без запущенного
сервиса на 3100 vite-прокси отдаёт 500, что легко принять за баг формы.

.env добавлен в .gitignore, в репозиторий положен только .env.example.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 12:52:07 +05:00
gorbenko.pavel 091e4e41b9 fix: запрашивать токен для api.tnsu.kz, а не для Graph
Токен с aud=graph.microsoft.com api.tnsu.kz отклонял с 401 — он проверяет
аудиторию входящего JWT. Меняем scope по умолчанию на App ID URI самого
api-tnsu-portal; наш сертификат для него уже авторизован, отдельный
client secret не нужен.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 12:43:38 +05:00
gorbenko.pavel 948ef76d17 правка
Tests / frontend-test (pull_request) Successful in 12s
2026-08-24 12:10:11 +05:00
gorbenko.pavel 89168024df Merge remote-tracking branch 'origin/main' into initial-fixes
Tests / frontend-test (pull_request) Successful in 13s
# Conflicts:
#	server/src/config.ts
#	server/src/index.ts
2026-08-24 11:48:49 +05:00
gorbenko.pavel f2d4fd1768 fix: захардкодить SHAREPOINT_SITE_URL и SHAREPOINT_LIST_NAME
Убрал требование задавать их через .env на сервере — значения одни
и те же для единственного прод-окружения.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 11:42:17 +05:00
gorbenko.pavel 07bafde43f feat: отправка формы обращения в SharePoint через api.tnsu.kz
POST /feedback собирает поля формы в столбцы списка (Company резолвится
в название по справочнику, Type/Title — по виду обращения) и создаёт
элемент через api.tnsu.kz /SharePoint/ListItem. Ошибки авторизации и
апстрима логируются в stdout и возвращаются клиенту как 502, чтобы сбой
на проде было видно явно.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 11:39:20 +05:00
gorbenko.pavel 49bf9b3c4e fix: переименовать вид обращения "Жалоба" в "Обращение"
Обновлены label в KIND_OPTIONS, комментарии и placeholder ФИО в форме.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 11:13:23 +05:00
gorbenko.pavel e4678210ab Merge remote-tracking branch 'origin/main' into initial-fixes 2026-08-24 09:09:22 +05:00
admin a8d46208a6 добавлена авторизация для запросов на api.tnsu.kz
Tests / frontend-test (pull_request) Successful in 18s
2026-08-21 17:11:34 +05:00
admin 60e654c80e fix: update port mapping for hr-appeals-web service to use 80:80 2026-08-21 16:36:27 +05:00
admin 838fd644e9 Настроен деплой 2026-08-21 15:47:43 +05:00
9 changed files with 11 additions and 53 deletions
+1 -1
View File
@@ -1 +1 @@
@tnsu:registry=https://git.tnsu.kz/api/packages/tansu/npm/ @tnsu:registry=http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/
+1 -1
View File
@@ -11,7 +11,7 @@
- React 17 + TypeScript (строгий режим) - React 17 + TypeScript (строгий режим)
- Vite 6 - Vite 6
- SCSS Modules - SCSS Modules
- [`@tnsu/ui-kit`](https://git.tnsu.kz/timur.gorodetskiy/tnsu-ui-kit) — общие UI-компоненты - [`@tnsu/ui-kit`](http://git.tnsu.kz/timur.gorodetskiy/tnsu-ui-kit) — общие UI-компоненты
- Vitest — тесты бизнес-логики - Vitest — тесты бизнес-логики
React именно 17-й: `@tnsu/ui-kit` объявляет его в `peerDependencies`. React именно 17-й: `@tnsu/ui-kit` объявляет его в `peerDependencies`.
+1 -1
View File
@@ -3,7 +3,7 @@ services:
image: nexus.tnsu.kz:8082/hr-appeals-web:${IMAGE_TAG:-latest} image: nexus.tnsu.kz:8082/hr-appeals-web:${IMAGE_TAG:-latest}
restart: unless-stopped restart: unless-stopped
ports: ports:
- "127.0.0.1:8082:80" - "80:80"
depends_on: depends_on:
- hr-appeals-auth - hr-appeals-auth
+4 -4
View File
@@ -9,7 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@fluentui/react": "^8.106.4", "@fluentui/react": "^8.106.4",
"@tnsu/ui-kit": "^0.14.0", "@tnsu/ui-kit": "^0.8.0",
"react": "17.0.2", "react": "17.0.2",
"react-dom": "17.0.2", "react-dom": "17.0.2",
"tslib": "^2.3.1" "tslib": "^2.3.1"
@@ -1701,9 +1701,9 @@
} }
}, },
"node_modules/@tnsu/ui-kit": { "node_modules/@tnsu/ui-kit": {
"version": "0.14.0", "version": "0.8.0",
"resolved": "https://git.tnsu.kz/api/packages/tansu/npm/%40tnsu%2Fui-kit/-/0.14.0/ui-kit-0.14.0.tgz", "resolved": "http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/%40tnsu%2Fui-kit/-/0.8.0/ui-kit-0.8.0.tgz",
"integrity": "sha512-Vun9AUM9jL8rttbsbKo5Yh6TUz1srkNqxiO6mmzebrSo1JOASNJqNjJPMTS+8Jt3MTrJPklvh0TbeqGQzw7qxQ==", "integrity": "sha512-PdAxr09JVkbK8XmxSM5Lgfz1Iq56RGFsoz4bI1YBd8quPzM3g/r4gRqqjbhCxyemzfK1d4w4WJD0ZBSs8jJbdA==",
"license": "ISC", "license": "ISC",
"peerDependencies": { "peerDependencies": {
"@fluentui/react": "^8.106.4", "@fluentui/react": "^8.106.4",
+1 -1
View File
@@ -18,7 +18,7 @@
}, },
"dependencies": { "dependencies": {
"@fluentui/react": "^8.106.4", "@fluentui/react": "^8.106.4",
"@tnsu/ui-kit": "^0.14.0", "@tnsu/ui-kit": "^0.8.0",
"react": "17.0.2", "react": "17.0.2",
"react-dom": "17.0.2", "react-dom": "17.0.2",
"tslib": "^2.3.1" "tslib": "^2.3.1"
+1 -15
View File
@@ -11,19 +11,6 @@ export interface ICompany {
Name: string; Name: string;
} }
/**
* Компании из справочника, которые по сути не действуют и не должны попадать
* в форму (просьба HR, 2026-09-09): СЛМ (CL MANAGEMENT), Selin Invest,
* ОСН Беркут, ТехЛаб, Seline Holding Ltd.
*/
const EXCLUDED_COMPANY_IDS = new Set<string>([
'9de064a4-44c8-88ef-11ec-831c058b1141', // ТОО CL MANAGEMENT (СЛМ)
'15009595-005d-00fa-11ef-11309f5e395d', // ТОО Selin Invest
'8028beac-ac23-8fd8-11f0-5351706e5996', // ТОО ОСН Беркут
'15009595-005d-00fa-11ef-1131801d9f3e', // ТОО ТехЛаб
'03f4deac-f543-e8cf-11f1-84f51483f59e' // Частная компания Seline Holding Ltd
]);
let cached: { companies: ICompany[]; expiresAt: number } | null = null; let cached: { companies: ICompany[]; expiresAt: number } | null = null;
export async function getCompanies(): Promise<ICompany[]> { export async function getCompanies(): Promise<ICompany[]> {
@@ -42,8 +29,7 @@ export async function getCompanies(): Promise<ICompany[]> {
); );
} }
const allCompanies = (await response.json()) as ICompany[]; const companies = (await response.json()) as ICompany[];
const companies = allCompanies.filter((company) => !EXCLUDED_COMPANY_IDS.has(company.Id));
cached = { companies, expiresAt: Date.now() + CACHE_TTL_MS }; cached = { companies, expiresAt: Date.now() + CACHE_TTL_MS };
return companies; return companies;
} }
+2 -21
View File
@@ -24,26 +24,8 @@ export interface IFeedbackSubmission {
suggestion: string; suggestion: string;
} }
// 5 рабочих дней от текущей даты, выходные (сб/вс) не считаются днём срока —
// используется как крайний срок обработки обращения (AppealDueDate).
function addBusinessDays(from: Date, days: number): Date {
const result = new Date(from);
let remaining = days;
while (remaining > 0) {
result.setDate(result.getDate() + 1);
const dayOfWeek = result.getDay(); // 0 = воскресенье, 6 = суббота
if (dayOfWeek !== 0 && dayOfWeek !== 6) {
remaining -= 1;
}
}
return result;
}
// Имена полей — internal name столбцов списка SharePoint (Company/Name/Position/ // Имена полей — internal name столбцов списка SharePoint (Company/Name/Position/
// Type/IssueDescription/SuggestionText/WantsFeedback/AppealDueDate), не // Type/IssueDescription/SuggestionText/WantsFeedback), не отображаемые названия.
// отображаемые названия.
async function buildFields(data: IFeedbackSubmission): Promise<Record<string, unknown>> { async function buildFields(data: IFeedbackSubmission): Promise<Record<string, unknown>> {
const typeLabel = data.kind === 'other' ? data.kindOther : KIND_LABELS[data.kind]; const typeLabel = data.kind === 'other' ? data.kindOther : KIND_LABELS[data.kind];
const name = data.isAnonymous ? '' : data.fullName; const name = data.isAnonymous ? '' : data.fullName;
@@ -56,8 +38,7 @@ async function buildFields(data: IFeedbackSubmission): Promise<Record<string, un
Type: typeLabel, Type: typeLabel,
IssueDescription: data.situation, IssueDescription: data.situation,
SuggestionText: data.suggestion, SuggestionText: data.suggestion,
WantsFeedback: data.wantsFeedback, WantsFeedback: data.wantsFeedback
AppealDueDate: addBusinessDays(new Date(), 5).toISOString()
}; };
// Состав полей зависит от вида обращения и анонимности, поэтому часть строк // Состав полей зависит от вида обращения и анонимности, поэтому часть строк
-4
View File
@@ -20,10 +20,6 @@ export const FeedbackForm: React.FC = () => {
// (App.tsx) — второй здесь был бы дублем. Пустая строка убирает текст, // (App.tsx) — второй здесь был бы дублем. Пустая строка убирает текст,
// а гэпа не оставляет: у ui-kit пустой title-блок не создаёт строки. // а гэпа не оставляет: у ui-kit пустой title-блок не создаёт строки.
<FormCard title=""> <FormCard title="">
<div className={styles.notice}>
<Alert variant="ok">{TEXTS.helpText}</Alert>
</div>
{formError ? ( {formError ? (
<div className={styles.notice}> <div className={styles.notice}>
<Alert variant="err">{formError}</Alert> <Alert variant="err">{formError}</Alert>
-5
View File
@@ -6,11 +6,6 @@ export const TEXTS = {
pageTitle: 'Обращение', pageTitle: 'Обращение',
pageSubtitle: 'Данное обращение будет направлено в HR департамент', pageSubtitle: 'Данное обращение будет направлено в HR департамент',
helpText:
'Обращение в HR — единый канал для вопросов, предложений и обратной связи ' +
'сотрудников по трудовым отношениям, рабочим процессам, условиям труда, ' +
'взаимодействию, адаптации, развитию и другим вопросам, связанным с работой в компании.',
// Две сноски с разным числом звёздочек — как в исходной форме, чтобы читалось // Две сноски с разным числом звёздочек — как в исходной форме, чтобы читалось
// как два отдельных примечания, а не слитный абзац. Field рендерит их построчно. // как два отдельных примечания, а не слитный абзац. Field рендерит их построчно.
anonymousHint: anonymousHint: