шлется на реальный сервер (#2)
build-and-deploy / prepare (push) Successful in 0s
build-and-deploy / build-and-push (., Dockerfile, hr-appeals-web) (push) Successful in 8s
Tests / frontend-test (push) Successful in 11s
build-and-deploy / build-and-push (server, server/Dockerfile, hr-appeals-auth) (push) Successful in 4s
build-and-deploy / deploy (push) Successful in 15s
build-and-deploy / prepare (push) Successful in 0s
build-and-deploy / build-and-push (., Dockerfile, hr-appeals-web) (push) Successful in 8s
Tests / frontend-test (push) Successful in 11s
build-and-deploy / build-and-push (server, server/Dockerfile, hr-appeals-auth) (push) Successful in 4s
build-and-deploy / deploy (push) Successful in 15s
Co-authored-by: Mariya Trishina <trishina.mariya@tnsu.kz> Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/hr-appeals/pulls/2
This commit was merged in pull request #2.
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
# Локальный запуск: скопируйте в server/.env.
|
||||
# На проде эти переменные задаёт docker-compose (см. docker-compose.yml).
|
||||
|
||||
GRAPH_TENANT_ID=b4c2af35-bfd6-4cff-9af9-556efb53e65a
|
||||
GRAPH_CLIENT_ID=26d2e058-0b27-4a00-b49f-1f17c4eecd1d
|
||||
|
||||
# 3000 занят vite (см. vite.config.ts), поэтому локально сервис слушает 3100 —
|
||||
# именно туда проксирует /api из dev-сервера.
|
||||
PORT=3100
|
||||
|
||||
# Каталог с сертификатом и ключом. По умолчанию — secrets/ в корне репозитория,
|
||||
# задавать нужно только чтобы подменить его на другой (см. secrets/README.md).
|
||||
# SECRETS_DIR=/path/to/secrets
|
||||
+18
-4
@@ -13,10 +13,24 @@ assertion) и используется для авторизованных за
|
||||
|---|---|
|
||||
| `GRAPH_TENANT_ID` | Tenant ID Azure AD |
|
||||
| `GRAPH_CLIENT_ID` | Client ID App Registration, к которому привязан сертификат `SPFxDeploy` |
|
||||
| `GRAPH_CERT_PATH` | Путь к `SPFxDeploy_cert.pem` внутри контейнера (см. `docker-compose.yml` в корне репозитория) |
|
||||
| `GRAPH_KEY_PATH` | Путь к `SPFxDeploy_key.pem` внутри контейнера |
|
||||
| `GRAPH_SCOPE` | OAuth-scope запрашиваемого токена. **По умолчанию `https://graph.microsoft.com/.default` — для api.tnsu.kz это неверное значение.** Нужно явно указать App ID URI, под которым `api.tnsu.kz` зарегистрирован в Azure AD, например `api://<app-id-uri-api.tnsu.kz>/.default`. Точное значение уточнить у того, кто регистрировал API в Azure AD |
|
||||
| `PORT` | Порт HTTP-сервера сервиса, по умолчанию `3000` |
|
||||
| `SECRETS_DIR` | Каталог с `SPFxDeploy_cert.pem` и `SPFxDeploy_key.pem` (имена файлов фиксированы). По умолчанию — [`secrets/`](../secrets/README.md) в корне репозитория; в контейнере `/certs`, куда смонтирован каталог с хоста. Задавать нужно только чтобы подменить каталог целиком |
|
||||
| `GRAPH_SCOPE` | OAuth-scope запрашиваемого токена. По умолчанию — App ID URI самого `api.tnsu.kz` (`api://9232d9e2-.../.default`), задавать не нужно. Токен для `https://graph.microsoft.com/.default` тут не подойдёт: `api.tnsu.kz` проверяет `aud` входящего JWT и отвечает 401 |
|
||||
| `PORT` | Порт HTTP-сервера сервиса, по умолчанию `3000` (локально — `3100`, см. `.env.example`) |
|
||||
|
||||
## Локальный запуск
|
||||
|
||||
Из корня репозитория `npm run dev` поднимает сервис вместе с фронтендом. Отдельно:
|
||||
|
||||
```bash
|
||||
cp .env.example .env # tenant/client id и порт
|
||||
npm run dev # tsc + запуск с чтением .env (--env-file, Node 20+)
|
||||
```
|
||||
|
||||
Сертификат и ключ при этом должны лежать в [`secrets/`](../secrets/README.md) —
|
||||
в git они не хранятся.
|
||||
|
||||
Проверить, что авторизация работает: `curl http://localhost:3100/diag/token` →
|
||||
`{"ok":true}`. Если вернулась ошибка — в ней будет ответ Azure AD целиком.
|
||||
|
||||
## Как обратиться к api.tnsu.kz с авторизацией
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "tsc && node --env-file=.env dist/index.js",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -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<ICompany[]> {
|
||||
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<string> {
|
||||
const companies = await getCompanies();
|
||||
const found = companies.find((company) => company.Id === id);
|
||||
|
||||
if (!found) {
|
||||
console.warn(`Неизвестный Id компании из формы, пишу как есть: ${id}`);
|
||||
return id;
|
||||
}
|
||||
|
||||
return found.Name;
|
||||
}
|
||||
+21
-4
@@ -1,3 +1,5 @@
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
function requireEnv(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
@@ -6,11 +8,26 @@ function requireEnv(name: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Каталог с сертификатом и приватным ключом приложения. Одна переменная на весь
|
||||
* каталог, а не путь к каждому файлу: так его можно подменить централизованно —
|
||||
* локально это `secrets/` в корне репозитория (в git не попадает), в контейнере
|
||||
* SECRETS_DIR=/certs, куда смонтирован каталог с хоста (см. docker-compose.yml).
|
||||
*
|
||||
* __dirname здесь — server/dist, поэтому до корня репозитория два уровня вверх.
|
||||
*/
|
||||
const secretsDir = process.env.SECRETS_DIR ?? resolve(__dirname, '../../secrets');
|
||||
|
||||
export const config = {
|
||||
tenantId: requireEnv('GRAPH_TENANT_ID'),
|
||||
clientId: requireEnv('GRAPH_CLIENT_ID'),
|
||||
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)
|
||||
certPath: join(secretsDir, 'SPFxDeploy_cert.pem'),
|
||||
keyPath: join(secretsDir, 'SPFxDeploy_key.pem'),
|
||||
// Токен нужен не для Graph напрямую, а для api.tnsu.kz — он сам ходит в
|
||||
// SharePoint. Это его App ID URI: с graph.microsoft.com/.default api вернёт
|
||||
// 401, потому что проверяет aud во входящем JWT.
|
||||
scope: process.env.GRAPH_SCOPE ?? 'api://9232d9e2-9807-4753-808f-07250e5474b0/.default',
|
||||
port: Number(process.env.PORT ?? 3000),
|
||||
sharepointSiteUrl: 'https://tnsukz.sharepoint.com/sites/hrm',
|
||||
sharepointListName: 'Обращения'
|
||||
};
|
||||
|
||||
+17
-2
@@ -21,9 +21,24 @@ const REFRESH_MARGIN_MS = 60_000;
|
||||
|
||||
let cached: CachedToken | null = null;
|
||||
|
||||
function readSecret(path: string): string {
|
||||
try {
|
||||
return readFileSync(path, 'utf8');
|
||||
} catch (cause) {
|
||||
// Самая частая причина на свежем клоне — файлов просто нет: в git они не
|
||||
// хранятся. Ошибка ENOENT сама по себе не подсказывает, что делать.
|
||||
throw new Error(
|
||||
`Не удалось прочитать ${path}. Сертификат и ключ в git не хранятся — ` +
|
||||
'положите их в каталог secrets/ (см. secrets/README.md) или укажите ' +
|
||||
'другой каталог через SECRETS_DIR.',
|
||||
{ cause }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function buildClientAssertion(): string {
|
||||
const certificatePem = readFileSync(config.certPath, 'utf8');
|
||||
const privateKey = readFileSync(config.keyPath, 'utf8');
|
||||
const certificatePem = readSecret(config.certPath);
|
||||
const privateKey = readSecret(config.keyPath);
|
||||
|
||||
// x5t — SHA-1 отпечаток DER-сертификата, base64url. По нему Azure AD находит
|
||||
// публичный ключ приложения и проверяет подпись JWT.
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import express from 'express';
|
||||
import { getCompanies } from './companies';
|
||||
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 +23,32 @@ 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);
|
||||
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}`);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { resolveCompanyName } from './companies';
|
||||
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<AppealKind, string> = {
|
||||
complaint: 'Обращение',
|
||||
suggestion: 'Предложение',
|
||||
other: 'Другое'
|
||||
};
|
||||
|
||||
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), не отображаемые названия.
|
||||
async function buildFields(data: IFeedbackSubmission): Promise<Record<string, unknown>> {
|
||||
const typeLabel = data.kind === 'other' ? data.kindOther : KIND_LABELS[data.kind];
|
||||
const name = data.isAnonymous ? '' : data.fullName;
|
||||
|
||||
const fields: Record<string, unknown> = {
|
||||
Title: name ? `${typeLabel} — ${name}` : typeLabel,
|
||||
Company: await resolveCompanyName(data.company),
|
||||
Name: name,
|
||||
Position: data.isAnonymous ? '' : data.position,
|
||||
Type: typeLabel,
|
||||
IssueDescription: data.situation,
|
||||
SuggestionText: data.suggestion,
|
||||
WantsFeedback: data.wantsFeedback
|
||||
};
|
||||
|
||||
// Состав полей зависит от вида обращения и анонимности, поэтому часть строк
|
||||
// заведомо пустая (у предложения нет описания ситуации, у анонимного — ФИО).
|
||||
// Такие поля не отправляем вовсе: пустая строка в списке выглядит как
|
||||
// заполненное значение, а не как «поля не было». WantsFeedback при этом
|
||||
// остаётся всегда — false здесь осмысленное значение, а не пустота.
|
||||
return Object.fromEntries(
|
||||
Object.entries(fields)
|
||||
.map(([key, value]) => [key, typeof value === 'string' ? value.trim() : value])
|
||||
.filter(([, value]) => value !== '')
|
||||
);
|
||||
}
|
||||
|
||||
export async function submitFeedback(data: IFeedbackSubmission): Promise<void> {
|
||||
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: await buildFields(data)
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`api.tnsu.kz вернул ошибку при создании элемента списка: ${response.status} ${await response.text()}`
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user