Add a GraphQL digital card that migrates and seeds itself on startup.
The NestJS API serves the public profile through Apollo Sandbox and loads only the relations selected by the query.
This commit is contained in:
commit
bd22447bd3
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
.git
|
||||
.env
|
||||
.env.local
|
||||
src/generated
|
||||
schema.gql
|
||||
*.md
|
||||
.idea
|
||||
.vscode
|
||||
2
.env.example
Normal file
2
.env.example
Normal file
@ -0,0 +1,2 @@
|
||||
DATABASE_URL=postgresql://card:card@localhost:5432/card
|
||||
PORT=3000
|
||||
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
dist
|
||||
node_modules
|
||||
coverage
|
||||
src/generated
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.idea
|
||||
.vscode
|
||||
*.tsbuildinfo
|
||||
4
.prettierrc
Normal file
4
.prettierrc
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
23
Dockerfile
Normal file
23
Dockerfile
Normal file
@ -0,0 +1,23 @@
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY nest-cli.json tsconfig.json tsconfig.build.json prisma.config.ts ./
|
||||
COPY prisma ./prisma
|
||||
COPY src ./src
|
||||
RUN npx prisma generate && npm run build
|
||||
|
||||
FROM node:22-alpine AS run
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
COPY --from=build --chown=node:node /app/node_modules ./node_modules
|
||||
COPY --from=build --chown=node:node /app/dist ./dist
|
||||
COPY --from=build --chown=node:node /app/prisma ./prisma
|
||||
COPY --from=build --chown=node:node /app/package.json ./package.json
|
||||
COPY --from=build --chown=node:node /app/prisma.config.ts ./prisma.config.ts
|
||||
RUN chown node:node /app
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
HEALTHCHECK --interval=5s --timeout=5s --start-period=20s --retries=10 \
|
||||
CMD node -e "fetch('http://127.0.0.1:3000/health').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||
CMD ["node", "dist/main.js"]
|
||||
123
README.md
Normal file
123
README.md
Normal file
@ -0,0 +1,123 @@
|
||||
# Цифровая визитка
|
||||
|
||||
GraphQL API, которое отдаёт публичную визитку Ильи Братских: профиль, навыки, опыт, проекты и связанные с ними факты. Контракт смотрится в Apollo Sandbox.
|
||||
|
||||
Факты взяты с [bratskih.tech/about](https://bratskih.tech/about) и из публичных репозиториев, которые этой странице соответствуют. Годы хранятся так, как они указаны в резюме: месяц не выдумывается. В список навыков не добавлены технологии только из-за того, что они использованы в этом репозитории.
|
||||
|
||||
## Стек
|
||||
|
||||
NestJS 11, TypeScript, Prisma ORM 7, PostgreSQL, Apollo Server, Docker.
|
||||
|
||||
## Запуск с нуля
|
||||
|
||||
Нужны Docker и свободные порты 3000 и 5432.
|
||||
|
||||
```powershell
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Когда контейнер `api` станет healthy, Sandbox открывается на [http://localhost:3000/graphql](http://localhost:3000/graphql). Корень сайта перенаправляет туда же. Проверка живости: `GET /health`.
|
||||
|
||||
Пустая база не требует ручных шагов. Процесс при старте применяет миграции и записывает визитку, если её содержимое ещё не совпадает с кодом.
|
||||
|
||||
Запрос из задания:
|
||||
|
||||
```graphql
|
||||
query {
|
||||
profile {
|
||||
name
|
||||
description
|
||||
skills {
|
||||
name
|
||||
}
|
||||
experience {
|
||||
company
|
||||
position
|
||||
}
|
||||
projects {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Более полный пример лежит в `examples/profile.graphql`.
|
||||
|
||||
Остановить и удалить данные:
|
||||
|
||||
```powershell
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
## Локальная разработка
|
||||
|
||||
```powershell
|
||||
Copy-Item .env.example .env
|
||||
docker compose up -d postgres
|
||||
npm install
|
||||
npm test
|
||||
npm run start:dev
|
||||
```
|
||||
|
||||
`npm test` генерирует Prisma Client и гоняет модульные тесты. База для них не нужна. `npm run test:e2e` поднимает приложение на PostgreSQL из `.env` и проверяет Sandbox, пример запроса и число SQL-обращений. Локальный старт перезаписывает `schema.gql`. В контейнере схема держится в памяти: процесс не пишет в файловую систему образа.
|
||||
|
||||
## Структура
|
||||
|
||||
```text
|
||||
src/
|
||||
main.ts миграции, затем HTTP
|
||||
config/ проверка окружения
|
||||
health/ / и /health
|
||||
prisma/ клиент и migrate deploy
|
||||
profile/
|
||||
domain/ визитка, период, план связей
|
||||
application/ сценарий чтения
|
||||
infrastructure/ Prisma, include, отображение строк
|
||||
presentation/ GraphQL-типы, selection set, ошибки
|
||||
seed/ декларативные данные и их запись
|
||||
prisma/schema.prisma
|
||||
prisma/migrations
|
||||
```
|
||||
|
||||
## Архитектура
|
||||
|
||||
Визитка — один агрегат. Корень — `Profile`. Навыки, места работы, достижения, проекты, образование и конкурсы не живут отдельно и не имеют собственного сценария изменения. Поэтому это один модуль `profile`, а не четыре сервиса с одинаковой зависимостью от профиля.
|
||||
|
||||
Слои внутри модуля разделены по ответственности:
|
||||
|
||||
- GraphQL знает про selection set и форму ответа. Он не собирает SQL.
|
||||
- Сервис проверяет идентификатор и решает, что профиль существует. Он не знает про Prisma.
|
||||
- Репозиторий один раз читает агрегат. Состав `include` зависит от плана связей.
|
||||
- Период вроде `2023 — н.в.` считается при чтении. В базе лежат годы, чтобы не хранить уже отформатированную строку.
|
||||
|
||||
Запись в базу есть только у сида. Публичного изменения данных нет: визитка — опубликованная модель чтения, а правка содержания проходит через код и ревью. Для этого сервиса мутации были бы другим продуктом.
|
||||
|
||||
## Модель
|
||||
|
||||
`Profile` связан с дочерними строками каскадом. Навык уникален в пределах профиля. Проект ссылается на навыки через `ProjectSkill`, поэтому один и тот же навык не копируется в каждый проект. Подсветки проекта и достижения — отдельные упорядоченные строки, а не массив внутри JSON: их можно дочитать отдельным запросом и не тащить, если поле не выбрано.
|
||||
|
||||
Порядок на сайте задаёт `sortOrder`. Год окончания `null` означает текущее место. Статус проекта тоже может отсутствовать, если резюме его не называет.
|
||||
|
||||
## Вложенные поля и база
|
||||
|
||||
Корень `profile` смотрит на дерево запроса, включая фрагменты и алиасы, и строит план: какие связи вообще нужны. Репозиторий передаёт в Prisma `include` только для этих связей. Поля ответа читают уже загруженный объект, отдельных resolver-ов на каждую связь нет.
|
||||
|
||||
Для запроса из задания план такой: навыки, места работы без достижений, проекты без навыков и подсветок. Достижения, образование и ссылки в этот запрос не попадают и не читаются.
|
||||
|
||||
Prisma получает `include` только по выбранным связям. Драйвер PostgreSQL умеет relation joins, поэтому несколько связей могут уйти одним SQL, но состав этого SQL всё равно ограничен планом. Число обращений не растёт вместе с числом строк: e2e на PostgreSQL проверяет, что два места работы отдают достижения одним чтением, а запрос без достижений таблицу достижений не открывает. Форму `include` для примера из задания фиксирует `profile-query.spec.ts`.
|
||||
|
||||
DataLoader здесь не используется. Он нужен, когда каждый field resolver ходит в базу сам и одинаковые ключи надо склеить. В этом графе состав запроса известен в корне, и вторая очередь загрузки была бы лишней. Если появится список профилей, которые догружаются из разных родителей, DataLoader встанет на границу репозитория, а не вместо плана.
|
||||
|
||||
## Старт и данные
|
||||
|
||||
`main` сначала запускает `prisma migrate deploy`, затем Nest. Сид выполняется в `onApplicationBootstrap`, уже после соединения с базой.
|
||||
|
||||
Сид идемпотентный. Содержимое визитки хешируется, хеш лежит в `Profile.contentHash`. Совпадает — запись не трогается, идентификаторы живут между перезапусками. Не совпадает — агрегат заменяется в одной транзакции. Повторный старт двух процессов сериализуется `pg_advisory_xact_lock`, чтобы они не создали две визитки.
|
||||
|
||||
Prisma 7 ходит в PostgreSQL через драйвер `pg`, без отдельного бинарного query engine. Строка подключения для CLI лежит в `prisma.config.ts`, для приложения её проверяет схема окружения: это должен быть PostgreSQL URL, порт по умолчанию 3000. Клиент Prisma CLI при отсутствии переменной подставляет локальный URL из Compose, чтобы `prisma generate` не требовал поднятую базу. Сам API без `DATABASE_URL` не стартует.
|
||||
|
||||
Образ собирается в два этапа. В нём остаётся Prisma CLI, потому что миграции применяет тот же процесс, который потом слушает порт. Отдельный init-контейнер не нужен: `docker compose up --build` на пустом volume достаточен.
|
||||
|
||||
## Что сознательно не делалось
|
||||
|
||||
Нет авторизации и мутаций: визитка публичная. Нет пагинации: объём фиксирован и мал. Нет общего маппера «любой GraphQL-запрос в любой Prisma include»: схема маленькая, явный план читается и тестируется. Месяцы работы не заполнялись, где резюме указывает только год.
|
||||
45
compose.yaml
Normal file
45
compose.yaml
Normal file
@ -0,0 +1,45 @@
|
||||
name: digital-card
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
environment:
|
||||
POSTGRES_USER: card
|
||||
POSTGRES_PASSWORD: card
|
||||
POSTGRES_DB: card
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- card-postgres:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U card -d card"]
|
||||
interval: 2s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
|
||||
api:
|
||||
build: .
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DATABASE_URL: postgresql://card:card@postgres:5432/card
|
||||
PORT: 3000
|
||||
NODE_ENV: production
|
||||
ports:
|
||||
- "3000:3000"
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://127.0.0.1:3000/health').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||
]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
start_period: 20s
|
||||
|
||||
volumes:
|
||||
card-postgres:
|
||||
35
eslint.config.mjs
Normal file
35
eslint.config.mjs
Normal file
@ -0,0 +1,35 @@
|
||||
// @ts-check
|
||||
import eslint from '@eslint/js';
|
||||
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||
import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ['eslint.config.mjs', 'src/generated/**'],
|
||||
},
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
eslintPluginPrettierRecommended,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.jest,
|
||||
},
|
||||
sourceType: 'module',
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-floating-promises': 'warn',
|
||||
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
||||
},
|
||||
},
|
||||
);
|
||||
16
examples/profile.graphql
Normal file
16
examples/profile.graphql
Normal file
@ -0,0 +1,16 @@
|
||||
query Card {
|
||||
profile {
|
||||
name
|
||||
description
|
||||
skills {
|
||||
name
|
||||
}
|
||||
experience {
|
||||
company
|
||||
position
|
||||
}
|
||||
projects {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
8
nest-cli.json
Normal file
8
nest-cli.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
12851
package-lock.json
generated
Normal file
12851
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
110
package.json
Normal file
110
package.json
Normal file
@ -0,0 +1,110 @@
|
||||
{
|
||||
"name": "digital-card",
|
||||
"version": "1.0.0",
|
||||
"description": "GraphQL digital card of Ilya Bratskikh",
|
||||
"author": "Илья Братских",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.12"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\" \"prisma.config.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main.js",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" \"prisma.config.ts\" --fix",
|
||||
"pretest": "prisma generate",
|
||||
"test": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js",
|
||||
"test:e2e": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js --config ./test/jest-e2e.json --runInBand",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate deploy",
|
||||
"smoke": "node scripts/smoke.mjs",
|
||||
"docker:up": "docker compose up --build",
|
||||
"docker:down": "docker compose down"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/server": "^5.0.0",
|
||||
"@as-integrations/express5": "^1.1.2",
|
||||
"@nestjs/apollo": "^13.2.3",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/graphql": "^13.2.3",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@prisma/adapter-pg": "^7.10.1",
|
||||
"@prisma/client": "^7.10.1",
|
||||
"dotenv": "^18.0.0",
|
||||
"graphql": "^16.11.0",
|
||||
"pg": "^8.16.3",
|
||||
"prisma": "^7.10.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/pg": "^8.15.5",
|
||||
"@types/supertest": "^7.0.0",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-prettier": "^5.2.2",
|
||||
"globals": "^17.0.0",
|
||||
"jest": "^30.0.0",
|
||||
"prettier": "^3.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.20.0"
|
||||
},
|
||||
"jest": {
|
||||
"extensionsToTreatAsEsm": [
|
||||
".ts"
|
||||
],
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.ts$": [
|
||||
"ts-jest",
|
||||
{
|
||||
"useESM": true,
|
||||
"tsconfig": {
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"moduleNameMapper": {
|
||||
"^(\\.{1,2}/.*)\\.js$": "$1"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s",
|
||||
"!generated/**"
|
||||
],
|
||||
"coveragePathIgnorePatterns": [
|
||||
"generated"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
15
prisma.config.ts
Normal file
15
prisma.config.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import 'dotenv/config';
|
||||
import { defineConfig } from 'prisma/config';
|
||||
|
||||
const databaseUrl =
|
||||
process.env.DATABASE_URL ?? 'postgresql://card:card@localhost:5432/card';
|
||||
|
||||
export default defineConfig({
|
||||
schema: 'prisma/schema.prisma',
|
||||
migrations: {
|
||||
path: 'prisma/migrations',
|
||||
},
|
||||
datasource: {
|
||||
url: databaseUrl,
|
||||
},
|
||||
});
|
||||
206
prisma/migrations/20261101190000_init/migration.sql
Normal file
206
prisma/migrations/20261101190000_init/migration.sql
Normal file
@ -0,0 +1,206 @@
|
||||
-- CreateSchema
|
||||
CREATE SCHEMA IF NOT EXISTS "public";
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "LinkKind" AS ENUM ('GITHUB', 'WEBSITE', 'TELEGRAM', 'EMAIL', 'PHONE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SkillGroup" AS ENUM ('LANGUAGE', 'BACKEND', 'FRONTEND', 'MOBILE', 'DATA', 'SYSTEMS');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ProjectStatus" AS ENUM ('ACTIVE', 'COMPLETED', 'PROTOTYPE');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Profile" (
|
||||
"id" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"contentHash" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"location" TEXT NOT NULL,
|
||||
"availability" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Profile_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Link" (
|
||||
"id" TEXT NOT NULL,
|
||||
"profileId" TEXT NOT NULL,
|
||||
"kind" "LinkKind" NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"sortOrder" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "Link_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Skill" (
|
||||
"id" TEXT NOT NULL,
|
||||
"profileId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"group" "SkillGroup" NOT NULL,
|
||||
"sortOrder" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "Skill_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Experience" (
|
||||
"id" TEXT NOT NULL,
|
||||
"profileId" TEXT NOT NULL,
|
||||
"company" TEXT NOT NULL,
|
||||
"position" TEXT NOT NULL,
|
||||
"startYear" INTEGER NOT NULL,
|
||||
"endYear" INTEGER,
|
||||
"summary" TEXT NOT NULL,
|
||||
"sortOrder" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "Experience_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Achievement" (
|
||||
"id" TEXT NOT NULL,
|
||||
"experienceId" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"sortOrder" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "Achievement_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Project" (
|
||||
"id" TEXT NOT NULL,
|
||||
"profileId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"url" TEXT,
|
||||
"description" TEXT NOT NULL,
|
||||
"role" TEXT,
|
||||
"status" "ProjectStatus",
|
||||
"startYear" INTEGER,
|
||||
"endYear" INTEGER,
|
||||
"sortOrder" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "Project_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ProjectHighlight" (
|
||||
"id" TEXT NOT NULL,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"text" TEXT NOT NULL,
|
||||
"sortOrder" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "ProjectHighlight_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ProjectSkill" (
|
||||
"projectId" TEXT NOT NULL,
|
||||
"skillId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "ProjectSkill_pkey" PRIMARY KEY ("projectId","skillId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Education" (
|
||||
"id" TEXT NOT NULL,
|
||||
"profileId" TEXT NOT NULL,
|
||||
"institution" TEXT NOT NULL,
|
||||
"specialty" TEXT NOT NULL,
|
||||
"specialtyCode" TEXT NOT NULL,
|
||||
"graduatedYear" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "Education_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Award" (
|
||||
"id" TEXT NOT NULL,
|
||||
"profileId" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"result" TEXT NOT NULL,
|
||||
"year" INTEGER,
|
||||
"url" TEXT,
|
||||
"sortOrder" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "Award_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Profile_slug_key" ON "Profile"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Link_profileId_sortOrder_idx" ON "Link"("profileId", "sortOrder");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Skill_profileId_sortOrder_idx" ON "Skill"("profileId", "sortOrder");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Skill_profileId_name_key" ON "Skill"("profileId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Experience_profileId_sortOrder_idx" ON "Experience"("profileId", "sortOrder");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Achievement_experienceId_sortOrder_idx" ON "Achievement"("experienceId", "sortOrder");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Project_profileId_sortOrder_idx" ON "Project"("profileId", "sortOrder");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ProjectHighlight_projectId_sortOrder_idx" ON "ProjectHighlight"("projectId", "sortOrder");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ProjectSkill_skillId_idx" ON "ProjectSkill"("skillId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Education_profileId_key" ON "Education"("profileId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Award_profileId_sortOrder_idx" ON "Award"("profileId", "sortOrder");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Link" ADD CONSTRAINT "Link_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "Profile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Skill" ADD CONSTRAINT "Skill_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "Profile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Experience" ADD CONSTRAINT "Experience_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "Profile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Achievement" ADD CONSTRAINT "Achievement_experienceId_fkey" FOREIGN KEY ("experienceId") REFERENCES "Experience"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Project" ADD CONSTRAINT "Project_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "Profile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ProjectHighlight" ADD CONSTRAINT "ProjectHighlight_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ProjectSkill" ADD CONSTRAINT "ProjectSkill_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ProjectSkill" ADD CONSTRAINT "ProjectSkill_skillId_fkey" FOREIGN KEY ("skillId") REFERENCES "Skill"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Education" ADD CONSTRAINT "Education_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "Profile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Award" ADD CONSTRAINT "Award_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "Profile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- Periods are stored as years. An end year cannot precede the start year.
|
||||
ALTER TABLE "Experience" ADD CONSTRAINT "Experience_period_check" CHECK ("endYear" IS NULL OR "endYear" >= "startYear");
|
||||
|
||||
ALTER TABLE "Project" ADD CONSTRAINT "Project_period_check" CHECK (
|
||||
("startYear" IS NULL AND "endYear" IS NULL)
|
||||
OR ("startYear" IS NOT NULL AND ("endYear" IS NULL OR "endYear" >= "startYear"))
|
||||
);
|
||||
|
||||
ALTER TABLE "Education" ADD CONSTRAINT "Education_year_check" CHECK ("graduatedYear" BETWEEN 1950 AND 2100);
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
165
prisma/schema.prisma
Normal file
165
prisma/schema.prisma
Normal file
@ -0,0 +1,165 @@
|
||||
// The profile is the aggregate root. Skills, jobs, projects, education and
|
||||
// awards do not exist outside it and are replaced together when the seed changes.
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "../src/generated/prisma"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
}
|
||||
|
||||
model Profile {
|
||||
id String @id @default(cuid())
|
||||
slug String @unique
|
||||
contentHash String
|
||||
name String
|
||||
title String
|
||||
description String
|
||||
location String
|
||||
availability String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
links Link[]
|
||||
skills Skill[]
|
||||
experiences Experience[]
|
||||
projects Project[]
|
||||
education Education?
|
||||
awards Award[]
|
||||
}
|
||||
|
||||
enum LinkKind {
|
||||
GITHUB
|
||||
WEBSITE
|
||||
TELEGRAM
|
||||
EMAIL
|
||||
PHONE
|
||||
}
|
||||
|
||||
model Link {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
kind LinkKind
|
||||
label String
|
||||
url String
|
||||
sortOrder Int
|
||||
|
||||
@@index([profileId, sortOrder])
|
||||
}
|
||||
|
||||
enum SkillGroup {
|
||||
LANGUAGE
|
||||
BACKEND
|
||||
FRONTEND
|
||||
MOBILE
|
||||
DATA
|
||||
SYSTEMS
|
||||
}
|
||||
|
||||
model Skill {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
group SkillGroup
|
||||
sortOrder Int
|
||||
projects ProjectSkill[]
|
||||
|
||||
@@unique([profileId, name])
|
||||
@@index([profileId, sortOrder])
|
||||
}
|
||||
|
||||
model Experience {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
company String
|
||||
position String
|
||||
startYear Int
|
||||
endYear Int?
|
||||
summary String
|
||||
sortOrder Int
|
||||
achievements Achievement[]
|
||||
|
||||
@@index([profileId, sortOrder])
|
||||
}
|
||||
|
||||
model Achievement {
|
||||
id String @id @default(cuid())
|
||||
experienceId String
|
||||
experience Experience @relation(fields: [experienceId], references: [id], onDelete: Cascade)
|
||||
description String
|
||||
sortOrder Int
|
||||
|
||||
@@index([experienceId, sortOrder])
|
||||
}
|
||||
|
||||
enum ProjectStatus {
|
||||
ACTIVE
|
||||
COMPLETED
|
||||
PROTOTYPE
|
||||
}
|
||||
|
||||
model Project {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
url String?
|
||||
description String
|
||||
role String?
|
||||
status ProjectStatus?
|
||||
startYear Int?
|
||||
endYear Int?
|
||||
sortOrder Int
|
||||
highlights ProjectHighlight[]
|
||||
skills ProjectSkill[]
|
||||
|
||||
@@index([profileId, sortOrder])
|
||||
}
|
||||
|
||||
model ProjectHighlight {
|
||||
id String @id @default(cuid())
|
||||
projectId String
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
text String
|
||||
sortOrder Int
|
||||
|
||||
@@index([projectId, sortOrder])
|
||||
}
|
||||
|
||||
model ProjectSkill {
|
||||
projectId String
|
||||
skillId String
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
skill Skill @relation(fields: [skillId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([projectId, skillId])
|
||||
@@index([skillId])
|
||||
}
|
||||
|
||||
model Education {
|
||||
id String @id @default(cuid())
|
||||
profileId String @unique
|
||||
profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
institution String
|
||||
specialty String
|
||||
specialtyCode String
|
||||
graduatedYear Int
|
||||
}
|
||||
|
||||
model Award {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
title String
|
||||
result String
|
||||
year Int?
|
||||
url String?
|
||||
sortOrder Int
|
||||
|
||||
@@index([profileId, sortOrder])
|
||||
}
|
||||
125
schema.gql
Normal file
125
schema.gql
Normal file
@ -0,0 +1,125 @@
|
||||
# ------------------------------------------------------
|
||||
# THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY)
|
||||
# ------------------------------------------------------
|
||||
|
||||
"""Достижение на месте работы"""
|
||||
type Achievement {
|
||||
description: String!
|
||||
id: ID!
|
||||
}
|
||||
|
||||
"""Конкурс или чемпионат"""
|
||||
type Award {
|
||||
result: String!
|
||||
title: String!
|
||||
url: String
|
||||
year: Int
|
||||
}
|
||||
|
||||
"""Высшее образование"""
|
||||
type Education {
|
||||
graduatedYear: Int!
|
||||
institution: String!
|
||||
specialty: String!
|
||||
specialtyCode: String!
|
||||
}
|
||||
|
||||
"""Место работы"""
|
||||
type Experience {
|
||||
achievements: [Achievement!]!
|
||||
company: String!
|
||||
endYear: Int
|
||||
id: ID!
|
||||
|
||||
"""Место считается текущим, если год окончания не указан"""
|
||||
isCurrent: Boolean!
|
||||
|
||||
"""Период, вычисленный из годов. Открытый период заканчивается «н.в.»"""
|
||||
period: String!
|
||||
position: String!
|
||||
startYear: Int!
|
||||
summary: String!
|
||||
}
|
||||
|
||||
"""Ссылка на профессиональный ресурс"""
|
||||
type Link {
|
||||
id: ID!
|
||||
kind: LinkKind!
|
||||
label: String!
|
||||
url: String!
|
||||
}
|
||||
|
||||
"""Тип профессиональной ссылки"""
|
||||
enum LinkKind {
|
||||
EMAIL
|
||||
GITHUB
|
||||
PHONE
|
||||
TELEGRAM
|
||||
WEBSITE
|
||||
}
|
||||
|
||||
"""Публичная визитка"""
|
||||
type Profile {
|
||||
availability: String!
|
||||
awards: [Award!]!
|
||||
description: String!
|
||||
education: Education
|
||||
experience: [Experience!]!
|
||||
id: ID!
|
||||
links: [Link!]!
|
||||
location: String!
|
||||
name: String!
|
||||
projects: [Project!]!
|
||||
skills: [Skill!]!
|
||||
slug: String!
|
||||
title: String!
|
||||
}
|
||||
|
||||
"""Проект"""
|
||||
type Project {
|
||||
description: String!
|
||||
endYear: Int
|
||||
highlights: [String!]!
|
||||
id: ID!
|
||||
name: String!
|
||||
period: String
|
||||
role: String
|
||||
skills: [Skill!]!
|
||||
startYear: Int
|
||||
status: ProjectStatus
|
||||
url: String
|
||||
}
|
||||
|
||||
"""Статус проекта, если он указан в резюме"""
|
||||
enum ProjectStatus {
|
||||
ACTIVE
|
||||
COMPLETED
|
||||
PROTOTYPE
|
||||
}
|
||||
|
||||
type Query {
|
||||
"""
|
||||
Публичная визитка. Вложенные связи загружаются вместе с профилем и только если они есть в запросе.
|
||||
"""
|
||||
profile(
|
||||
"""Стабильный идентификатор. По умолчанию визитка Ильи Братских."""
|
||||
slug: String
|
||||
): Profile!
|
||||
}
|
||||
|
||||
"""Навык"""
|
||||
type Skill {
|
||||
group: SkillGroup!
|
||||
id: ID!
|
||||
name: String!
|
||||
}
|
||||
|
||||
"""Группа навыка"""
|
||||
enum SkillGroup {
|
||||
BACKEND
|
||||
DATA
|
||||
FRONTEND
|
||||
LANGUAGE
|
||||
MOBILE
|
||||
SYSTEMS
|
||||
}
|
||||
41
scripts/smoke.mjs
Normal file
41
scripts/smoke.mjs
Normal file
@ -0,0 +1,41 @@
|
||||
const url = process.env.GRAPHQL_URL ?? 'http://127.0.0.1:3000/graphql';
|
||||
|
||||
const query = `query {
|
||||
profile {
|
||||
name
|
||||
description
|
||||
skills { name }
|
||||
experience { company position }
|
||||
projects { name url }
|
||||
}
|
||||
}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ query }),
|
||||
});
|
||||
|
||||
const body = await response.json();
|
||||
if (!response.ok || body.errors) {
|
||||
console.error(JSON.stringify(body, null, 2));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const profile = body.data.profile;
|
||||
const linked = profile.projects.some(
|
||||
(project) => typeof project.url === 'string' && project.url.includes('github.com'),
|
||||
);
|
||||
if (
|
||||
profile.name !== 'Илья Братских' ||
|
||||
profile.skills.length === 0 ||
|
||||
profile.experience.length === 0 ||
|
||||
!linked
|
||||
) {
|
||||
console.error(JSON.stringify(profile, null, 2));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`OK ${profile.name}: ${profile.skills.length} skills, ${profile.experience.length} jobs, ${profile.projects.length} projects`,
|
||||
);
|
||||
40
src/app.module.ts
Normal file
40
src/app.module.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { join } from 'node:path';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ApolloDriver, type ApolloDriverConfig } from '@nestjs/apollo';
|
||||
import { GraphQLModule } from '@nestjs/graphql';
|
||||
import { ApolloServerPluginLandingPageLocalDefault } from '@apollo/server/plugin/landingPage/default';
|
||||
import { AppConfigModule } from './config/config.module.js';
|
||||
import { HealthModule } from './health/health.module.js';
|
||||
import { PrismaModule } from './prisma/prisma.module.js';
|
||||
import { ProfileModule } from './profile/profile.module.js';
|
||||
import { SeedModule } from './seed/seed.module.js';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AppConfigModule,
|
||||
PrismaModule,
|
||||
GraphQLModule.forRoot<ApolloDriverConfig>({
|
||||
driver: ApolloDriver,
|
||||
path: '/graphql',
|
||||
autoSchemaFile:
|
||||
process.env.NODE_ENV === 'production'
|
||||
? true
|
||||
: join(process.cwd(), 'schema.gql'),
|
||||
sortSchema: true,
|
||||
introspection: true,
|
||||
// null keeps Nest from adding a second landing-page plugin.
|
||||
// The embedded Apollo Sandbox below is the only one.
|
||||
playground: null as unknown as ApolloDriverConfig['playground'],
|
||||
plugins: [
|
||||
// Nest resolves Apollo Server's CJS types, while this package imports the ESM build.
|
||||
ApolloServerPluginLandingPageLocalDefault({
|
||||
embed: true,
|
||||
}) as NonNullable<ApolloDriverConfig['plugins']>[number],
|
||||
],
|
||||
}),
|
||||
HealthModule,
|
||||
ProfileModule,
|
||||
SeedModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
14
src/config/config.module.ts
Normal file
14
src/config/config.module.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { validateEnv } from './validate-env.js';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
cache: true,
|
||||
validate: validateEnv,
|
||||
}),
|
||||
],
|
||||
})
|
||||
export class AppConfigModule {}
|
||||
21
src/config/validate-env.spec.ts
Normal file
21
src/config/validate-env.spec.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { validateEnv } from './validate-env.js';
|
||||
|
||||
describe('validateEnv', () => {
|
||||
it('defaults the port and accepts a postgres URL', () => {
|
||||
expect(
|
||||
validateEnv({
|
||||
DATABASE_URL: 'postgresql://card:card@localhost:5432/card',
|
||||
}),
|
||||
).toEqual({
|
||||
DATABASE_URL: 'postgresql://card:card@localhost:5432/card',
|
||||
PORT: 3000,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a missing or non-postgres URL', () => {
|
||||
expect(() => validateEnv({})).toThrow(/DATABASE_URL/);
|
||||
expect(() =>
|
||||
validateEnv({ DATABASE_URL: 'mysql://localhost/card' }),
|
||||
).toThrow(/PostgreSQL/);
|
||||
});
|
||||
});
|
||||
30
src/config/validate-env.ts
Normal file
30
src/config/validate-env.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const envSchema = z.object({
|
||||
DATABASE_URL: z
|
||||
.string()
|
||||
.regex(
|
||||
/^postgres(ql)?:\/\//,
|
||||
'DATABASE_URL must be a PostgreSQL connection string',
|
||||
),
|
||||
PORT: z.preprocess(
|
||||
(value) => (value === undefined || value === '' ? 3000 : value),
|
||||
z.coerce.number().int().positive(),
|
||||
),
|
||||
});
|
||||
|
||||
export type AppConfig = z.infer<typeof envSchema>;
|
||||
|
||||
export function validateEnv(config: Record<string, unknown>): AppConfig {
|
||||
const result = envSchema.safeParse({
|
||||
DATABASE_URL: config.DATABASE_URL,
|
||||
PORT: config.PORT,
|
||||
});
|
||||
if (!result.success) {
|
||||
const details = result.error.issues
|
||||
.map((issue) => `${issue.path.join('.') || 'env'}: ${issue.message}`)
|
||||
.join('\n');
|
||||
throw new Error(`Environment validation failed:\n${details}`);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
15
src/health/health.controller.ts
Normal file
15
src/health/health.controller.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { Controller, Get, Redirect } from '@nestjs/common';
|
||||
|
||||
@Controller()
|
||||
export class HealthController {
|
||||
@Get()
|
||||
@Redirect('/graphql', 302)
|
||||
root(): void {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@Get('health')
|
||||
health(): { status: 'ok' } {
|
||||
return { status: 'ok' };
|
||||
}
|
||||
}
|
||||
7
src/health/health.module.ts
Normal file
7
src/health/health.module.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller.js';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
28
src/main.ts
Normal file
28
src/main.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import 'reflect-metadata';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module.js';
|
||||
import { applyMigrations } from './prisma/apply-migrations.js';
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
if (existsSync('.env')) {
|
||||
process.loadEnvFile('.env');
|
||||
}
|
||||
|
||||
await applyMigrations();
|
||||
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.enableShutdownHooks();
|
||||
const port = app.get(ConfigService).getOrThrow<number>('PORT');
|
||||
await app.listen(port, '0.0.0.0');
|
||||
Logger.log(`Apollo Sandbox: http://localhost:${port}/graphql`, 'Bootstrap');
|
||||
}
|
||||
|
||||
bootstrap().catch((error: unknown) => {
|
||||
const message =
|
||||
error instanceof Error ? (error.stack ?? error.message) : String(error);
|
||||
Logger.error(message, 'Bootstrap');
|
||||
process.exit(1);
|
||||
});
|
||||
30
src/prisma/apply-migrations.ts
Normal file
30
src/prisma/apply-migrations.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export function applyMigrations(): Promise<void> {
|
||||
const cli = join(
|
||||
process.cwd(),
|
||||
'node_modules',
|
||||
'prisma',
|
||||
'build',
|
||||
'index.js',
|
||||
);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [cli, 'migrate', 'deploy'], {
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
child.on('error', reject);
|
||||
child.on('exit', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(
|
||||
new Error(`prisma migrate deploy exited with code ${code ?? 'null'}`),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
9
src/prisma/prisma.module.ts
Normal file
9
src/prisma/prisma.module.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service.js';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
76
src/prisma/prisma.service.ts
Normal file
76
src/prisma/prisma.service.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleDestroy,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
import { Pool, type QueryResult } from 'pg';
|
||||
import { PrismaClient } from '../generated/prisma/client.js';
|
||||
|
||||
type QueryArgs = Parameters<Pool['query']>;
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService
|
||||
extends PrismaClient
|
||||
implements OnModuleInit, OnModuleDestroy
|
||||
{
|
||||
private readonly logger = new Logger(PrismaService.name);
|
||||
private readonly pool: Pool;
|
||||
private readonly queries: string[];
|
||||
|
||||
constructor() {
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
if (!connectionString) {
|
||||
throw new Error('DATABASE_URL is not set');
|
||||
}
|
||||
|
||||
const queries: string[] = [];
|
||||
const pool = new Pool({
|
||||
connectionString,
|
||||
max: 10,
|
||||
connectionTimeoutMillis: 5_000,
|
||||
});
|
||||
const run = pool.query.bind(pool) as (
|
||||
...args: QueryArgs
|
||||
) => Promise<QueryResult>;
|
||||
pool.query = ((...args: QueryArgs) => {
|
||||
queries.push(sqlText(args[0]));
|
||||
return run(...args);
|
||||
}) as Pool['query'];
|
||||
|
||||
super({ adapter: new PrismaPg(pool) });
|
||||
this.pool = pool;
|
||||
this.queries = queries;
|
||||
}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.$disconnect();
|
||||
await this.pool.end();
|
||||
}
|
||||
|
||||
takeQueries(): string[] {
|
||||
const captured = this.queries.splice(0, this.queries.length);
|
||||
if (process.env.SQL_LOG === '1') {
|
||||
for (const query of captured) {
|
||||
this.logger.debug(query);
|
||||
}
|
||||
}
|
||||
return captured;
|
||||
}
|
||||
}
|
||||
|
||||
function sqlText(query: unknown): string {
|
||||
if (typeof query === 'string') {
|
||||
return query;
|
||||
}
|
||||
if (typeof query === 'object' && query !== null && 'text' in query) {
|
||||
const text = query.text;
|
||||
return typeof text === 'string' ? text : '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
8
src/profile/application/profile.repository.ts
Normal file
8
src/profile/application/profile.repository.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import type { PublicProfile } from '../domain/profile.js';
|
||||
import type { RelationPlan } from '../domain/relation-plan.js';
|
||||
|
||||
export const PROFILE_REPOSITORY = Symbol('PROFILE_REPOSITORY');
|
||||
|
||||
export interface ProfileRepository {
|
||||
findBySlug(slug: string, plan: RelationPlan): Promise<PublicProfile | null>;
|
||||
}
|
||||
75
src/profile/application/profile.service.spec.ts
Normal file
75
src/profile/application/profile.service.spec.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { PUBLIC_PROFILE_SLUG } from '../profile.constants.js';
|
||||
import { emptyRelationPlan } from '../domain/relation-plan.js';
|
||||
import {
|
||||
InvalidProfileSlugError,
|
||||
ProfileNotFoundError,
|
||||
} from '../domain/errors.js';
|
||||
import type { PublicProfile } from '../domain/profile.js';
|
||||
import type { ProfileRepository } from './profile.repository.js';
|
||||
import { ProfileService } from './profile.service.js';
|
||||
|
||||
function profile(): PublicProfile {
|
||||
return {
|
||||
id: 'profile-1',
|
||||
slug: PUBLIC_PROFILE_SLUG,
|
||||
name: 'Илья Братских',
|
||||
title: 'Fullstack Developer (Middle+/Senior)',
|
||||
description: 'Описание',
|
||||
location: 'Москва',
|
||||
availability: 'Удалённо, гибрид или офис',
|
||||
links: [],
|
||||
skills: [],
|
||||
experience: [],
|
||||
projects: [],
|
||||
education: null,
|
||||
awards: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe('ProfileService', () => {
|
||||
const plan = emptyRelationPlan();
|
||||
|
||||
it('returns the profile and forwards the relation plan', async () => {
|
||||
const calls: Array<{ slug: string; plan: unknown }> = [];
|
||||
const found = profile();
|
||||
const repository: ProfileRepository = {
|
||||
findBySlug: (slug, requested) => {
|
||||
calls.push({ slug, plan: requested });
|
||||
return Promise.resolve(found);
|
||||
},
|
||||
};
|
||||
|
||||
const service = new ProfileService(repository);
|
||||
|
||||
await expect(service.getBySlug(PUBLIC_PROFILE_SLUG, plan)).resolves.toBe(
|
||||
found,
|
||||
);
|
||||
expect(calls).toEqual([{ slug: PUBLIC_PROFILE_SLUG, plan }]);
|
||||
});
|
||||
|
||||
it('does not query the database for a malformed slug', async () => {
|
||||
let called = false;
|
||||
const repository: ProfileRepository = {
|
||||
findBySlug: () => {
|
||||
called = true;
|
||||
return Promise.resolve(null);
|
||||
},
|
||||
};
|
||||
const service = new ProfileService(repository);
|
||||
|
||||
await expect(service.getBySlug('Илья', plan)).rejects.toBeInstanceOf(
|
||||
InvalidProfileSlugError,
|
||||
);
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
|
||||
it('reports a missing profile', async () => {
|
||||
const service = new ProfileService({
|
||||
findBySlug: () => Promise.resolve(null),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.getBySlug(PUBLIC_PROFILE_SLUG, plan),
|
||||
).rejects.toBeInstanceOf(ProfileNotFoundError);
|
||||
});
|
||||
});
|
||||
33
src/profile/application/profile.service.ts
Normal file
33
src/profile/application/profile.service.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
InvalidProfileSlugError,
|
||||
ProfileNotFoundError,
|
||||
} from '../domain/errors.js';
|
||||
import type { PublicProfile } from '../domain/profile.js';
|
||||
import type { RelationPlan } from '../domain/relation-plan.js';
|
||||
import {
|
||||
PROFILE_REPOSITORY,
|
||||
type ProfileRepository,
|
||||
} from './profile.repository.js';
|
||||
|
||||
const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
|
||||
@Injectable()
|
||||
export class ProfileService {
|
||||
constructor(
|
||||
@Inject(PROFILE_REPOSITORY) private readonly profiles: ProfileRepository,
|
||||
) {}
|
||||
|
||||
async getBySlug(slug: string, plan: RelationPlan): Promise<PublicProfile> {
|
||||
if (slug.length > 64 || !SLUG.test(slug)) {
|
||||
throw new InvalidProfileSlugError(slug);
|
||||
}
|
||||
|
||||
const profile = await this.profiles.findBySlug(slug, plan);
|
||||
if (!profile) {
|
||||
throw new ProfileNotFoundError(slug);
|
||||
}
|
||||
|
||||
return profile;
|
||||
}
|
||||
}
|
||||
16
src/profile/domain/assert-enum.spec.ts
Normal file
16
src/profile/domain/assert-enum.spec.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { SkillGroup } from './enums.js';
|
||||
import { assertEnumValue } from './assert-enum.js';
|
||||
|
||||
describe('assertEnumValue', () => {
|
||||
it('returns a known enum value', () => {
|
||||
expect(assertEnumValue(SkillGroup, 'LANGUAGE', 'skill group')).toBe(
|
||||
SkillGroup.LANGUAGE,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a value that is not in the enum', () => {
|
||||
expect(() => assertEnumValue(SkillGroup, 'DESIGN', 'skill group')).toThrow(
|
||||
'Unexpected skill group: DESIGN',
|
||||
);
|
||||
});
|
||||
});
|
||||
11
src/profile/domain/assert-enum.ts
Normal file
11
src/profile/domain/assert-enum.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export function assertEnumValue<T extends Record<string, string>>(
|
||||
enumObject: T,
|
||||
value: string,
|
||||
label: string,
|
||||
): T[keyof T] {
|
||||
const allowed = Object.values(enumObject);
|
||||
if (!allowed.includes(value)) {
|
||||
throw new Error(`Unexpected ${label}: ${value}`);
|
||||
}
|
||||
return value as T[keyof T];
|
||||
}
|
||||
22
src/profile/domain/enums.ts
Normal file
22
src/profile/domain/enums.ts
Normal file
@ -0,0 +1,22 @@
|
||||
export enum LinkKind {
|
||||
GITHUB = 'GITHUB',
|
||||
WEBSITE = 'WEBSITE',
|
||||
TELEGRAM = 'TELEGRAM',
|
||||
EMAIL = 'EMAIL',
|
||||
PHONE = 'PHONE',
|
||||
}
|
||||
|
||||
export enum SkillGroup {
|
||||
LANGUAGE = 'LANGUAGE',
|
||||
BACKEND = 'BACKEND',
|
||||
FRONTEND = 'FRONTEND',
|
||||
MOBILE = 'MOBILE',
|
||||
DATA = 'DATA',
|
||||
SYSTEMS = 'SYSTEMS',
|
||||
}
|
||||
|
||||
export enum ProjectStatus {
|
||||
ACTIVE = 'ACTIVE',
|
||||
COMPLETED = 'COMPLETED',
|
||||
PROTOTYPE = 'PROTOTYPE',
|
||||
}
|
||||
13
src/profile/domain/errors.ts
Normal file
13
src/profile/domain/errors.ts
Normal file
@ -0,0 +1,13 @@
|
||||
export class ProfileNotFoundError extends Error {
|
||||
constructor(slug: string) {
|
||||
super(`Профиль «${slug}» не найден`);
|
||||
this.name = 'ProfileNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidProfileSlugError extends Error {
|
||||
constructor(slug: string) {
|
||||
super(`Некорректный идентификатор профиля: ${slug}`);
|
||||
this.name = 'InvalidProfileSlugError';
|
||||
}
|
||||
}
|
||||
20
src/profile/domain/period.spec.ts
Normal file
20
src/profile/domain/period.spec.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { formatOptionalPeriod, formatPeriod } from './period.js';
|
||||
|
||||
describe('work period', () => {
|
||||
it('keeps an open period in the resume wording', () => {
|
||||
expect(formatPeriod(2023, null)).toBe('2023 — н.в.');
|
||||
});
|
||||
|
||||
it('joins a closed range with an en dash', () => {
|
||||
expect(formatPeriod(2022, 2023)).toBe('2022–2023');
|
||||
});
|
||||
|
||||
it('collapses a single year', () => {
|
||||
expect(formatPeriod(2024, 2024)).toBe('2024');
|
||||
});
|
||||
|
||||
it('omits a period when the start year is unknown', () => {
|
||||
expect(formatOptionalPeriod(null, null)).toBeNull();
|
||||
expect(formatOptionalPeriod(null, 2024)).toBeNull();
|
||||
});
|
||||
});
|
||||
24
src/profile/domain/period.ts
Normal file
24
src/profile/domain/period.ts
Normal file
@ -0,0 +1,24 @@
|
||||
const PRESENT = 'н.в.';
|
||||
|
||||
export function formatPeriod(
|
||||
startYear: number,
|
||||
endYear: number | null,
|
||||
): string {
|
||||
if (endYear === null) {
|
||||
return `${startYear} — ${PRESENT}`;
|
||||
}
|
||||
if (startYear === endYear) {
|
||||
return String(startYear);
|
||||
}
|
||||
return `${startYear}–${endYear}`;
|
||||
}
|
||||
|
||||
export function formatOptionalPeriod(
|
||||
startYear: number | null,
|
||||
endYear: number | null,
|
||||
): string | null {
|
||||
if (startYear === null) {
|
||||
return null;
|
||||
}
|
||||
return formatPeriod(startYear, endYear);
|
||||
}
|
||||
75
src/profile/domain/profile.ts
Normal file
75
src/profile/domain/profile.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import type { LinkKind, ProjectStatus, SkillGroup } from './enums.js';
|
||||
|
||||
export type PublicLink = {
|
||||
id: string;
|
||||
kind: LinkKind;
|
||||
label: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type PublicSkill = {
|
||||
id: string;
|
||||
name: string;
|
||||
group: SkillGroup;
|
||||
};
|
||||
|
||||
export type PublicAchievement = {
|
||||
id: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type PublicExperience = {
|
||||
id: string;
|
||||
company: string;
|
||||
position: string;
|
||||
startYear: number;
|
||||
endYear: number | null;
|
||||
period: string;
|
||||
isCurrent: boolean;
|
||||
summary: string;
|
||||
achievements: PublicAchievement[];
|
||||
};
|
||||
|
||||
export type PublicProject = {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string | null;
|
||||
description: string;
|
||||
role: string | null;
|
||||
status: ProjectStatus | null;
|
||||
startYear: number | null;
|
||||
endYear: number | null;
|
||||
period: string | null;
|
||||
highlights: string[];
|
||||
skills: PublicSkill[];
|
||||
};
|
||||
|
||||
export type PublicEducation = {
|
||||
institution: string;
|
||||
specialty: string;
|
||||
specialtyCode: string;
|
||||
graduatedYear: number;
|
||||
};
|
||||
|
||||
export type PublicAward = {
|
||||
title: string;
|
||||
result: string;
|
||||
year: number | null;
|
||||
url: string | null;
|
||||
};
|
||||
|
||||
export type PublicProfile = {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
title: string;
|
||||
description: string;
|
||||
location: string;
|
||||
availability: string;
|
||||
links: PublicLink[];
|
||||
skills: PublicSkill[];
|
||||
experience: PublicExperience[];
|
||||
projects: PublicProject[];
|
||||
education: PublicEducation | null;
|
||||
awards: PublicAward[];
|
||||
};
|
||||
19
src/profile/domain/relation-plan.ts
Normal file
19
src/profile/domain/relation-plan.ts
Normal file
@ -0,0 +1,19 @@
|
||||
export type RelationPlan = {
|
||||
links: boolean;
|
||||
skills: boolean;
|
||||
education: boolean;
|
||||
awards: boolean;
|
||||
experience: { achievements: boolean } | null;
|
||||
projects: { skills: boolean; highlights: boolean } | null;
|
||||
};
|
||||
|
||||
export function emptyRelationPlan(): RelationPlan {
|
||||
return {
|
||||
links: false,
|
||||
skills: false,
|
||||
education: false,
|
||||
awards: false,
|
||||
experience: null,
|
||||
projects: null,
|
||||
};
|
||||
}
|
||||
26
src/profile/infrastructure/prisma-profile.repository.ts
Normal file
26
src/profile/infrastructure/prisma-profile.repository.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { ProfileRepository } from '../application/profile.repository.js';
|
||||
import type { PublicProfile } from '../domain/profile.js';
|
||||
import type { RelationPlan } from '../domain/relation-plan.js';
|
||||
import { PrismaService } from '../../prisma/prisma.service.js';
|
||||
import { mapProfile } from './profile.mapper.js';
|
||||
import { profileInclude } from './profile-include.js';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaProfileRepository implements ProfileRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findBySlug(
|
||||
slug: string,
|
||||
plan: RelationPlan,
|
||||
): Promise<PublicProfile | null> {
|
||||
const row = await this.prisma.profile.findUnique({
|
||||
where: { slug },
|
||||
include: profileInclude(plan),
|
||||
});
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
return mapProfile(row);
|
||||
}
|
||||
}
|
||||
47
src/profile/infrastructure/profile-include.ts
Normal file
47
src/profile/infrastructure/profile-include.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import type { Prisma } from '../../generated/prisma/client.js';
|
||||
import type { RelationPlan } from '../domain/relation-plan.js';
|
||||
|
||||
const bySortOrder = { sortOrder: 'asc' as const };
|
||||
|
||||
export function profileInclude(plan: RelationPlan): Prisma.ProfileInclude {
|
||||
const include: Prisma.ProfileInclude = {};
|
||||
|
||||
if (plan.links) {
|
||||
include.links = { orderBy: bySortOrder };
|
||||
}
|
||||
if (plan.skills) {
|
||||
include.skills = { orderBy: bySortOrder };
|
||||
}
|
||||
if (plan.education) {
|
||||
include.education = true;
|
||||
}
|
||||
if (plan.awards) {
|
||||
include.awards = { orderBy: bySortOrder };
|
||||
}
|
||||
if (plan.experience) {
|
||||
include.experiences = plan.experience.achievements
|
||||
? {
|
||||
orderBy: bySortOrder,
|
||||
include: { achievements: { orderBy: bySortOrder } },
|
||||
}
|
||||
: { orderBy: bySortOrder };
|
||||
}
|
||||
if (plan.projects) {
|
||||
const projectInclude: Prisma.ProjectInclude = {};
|
||||
if (plan.projects.highlights) {
|
||||
projectInclude.highlights = { orderBy: bySortOrder };
|
||||
}
|
||||
if (plan.projects.skills) {
|
||||
projectInclude.skills = {
|
||||
include: { skill: true },
|
||||
orderBy: { skill: bySortOrder },
|
||||
};
|
||||
}
|
||||
include.projects =
|
||||
Object.keys(projectInclude).length > 0
|
||||
? { orderBy: bySortOrder, include: projectInclude }
|
||||
: { orderBy: bySortOrder };
|
||||
}
|
||||
|
||||
return include;
|
||||
}
|
||||
99
src/profile/infrastructure/profile.mapper.spec.ts
Normal file
99
src/profile/infrastructure/profile.mapper.spec.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import { mapProfile } from './profile.mapper.js';
|
||||
|
||||
describe('mapProfile', () => {
|
||||
it('computes the period and keeps child rows in display order', () => {
|
||||
const profile = mapProfile({
|
||||
id: 'p1',
|
||||
slug: 'ilya-bratskikh',
|
||||
name: 'Илья Братских',
|
||||
title: 'Fullstack Developer (Middle+/Senior)',
|
||||
description: 'Описание',
|
||||
location: 'Москва',
|
||||
availability: 'Удалённо, гибрид или офис',
|
||||
experiences: [
|
||||
{
|
||||
id: 'old',
|
||||
company: 'БСТМ МВД России',
|
||||
position: 'Инженер-разработчик',
|
||||
startYear: 2022,
|
||||
endYear: 2023,
|
||||
summary: 'Экспертиза',
|
||||
sortOrder: 1,
|
||||
achievements: [
|
||||
{ id: 'a2', description: 'Второе', sortOrder: 1 },
|
||||
{ id: 'a1', description: 'Первое', sortOrder: 0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'current',
|
||||
company: 'АО «Синтелс»',
|
||||
position: 'Fullstack Developer & Researcher',
|
||||
startYear: 2023,
|
||||
endYear: null,
|
||||
summary: 'Продукт и исследования',
|
||||
sortOrder: 0,
|
||||
achievements: [],
|
||||
},
|
||||
],
|
||||
projects: [
|
||||
{
|
||||
id: 'project',
|
||||
name: 'Таблица',
|
||||
url: null,
|
||||
description: 'Виртуальный скроллинг',
|
||||
role: null,
|
||||
status: 'ACTIVE',
|
||||
startYear: 2025,
|
||||
endYear: null,
|
||||
sortOrder: 0,
|
||||
highlights: [
|
||||
{ text: 'Второе', sortOrder: 1 },
|
||||
{ text: 'Первое', sortOrder: 0 },
|
||||
],
|
||||
skills: [
|
||||
{
|
||||
skill: {
|
||||
id: 'wasm',
|
||||
name: 'WASM',
|
||||
group: 'SYSTEMS',
|
||||
sortOrder: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
skill: {
|
||||
id: 'flutter',
|
||||
name: 'Flutter',
|
||||
group: 'MOBILE',
|
||||
sortOrder: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(profile.experience.map((job) => job.company)).toEqual([
|
||||
'АО «Синтелс»',
|
||||
'БСТМ МВД России',
|
||||
]);
|
||||
expect(profile.experience[0]).toMatchObject({
|
||||
period: '2023 — н.в.',
|
||||
isCurrent: true,
|
||||
});
|
||||
expect(profile.experience[1]).toMatchObject({
|
||||
period: '2022–2023',
|
||||
isCurrent: false,
|
||||
});
|
||||
expect(
|
||||
profile.experience[1]?.achievements.map((item) => item.description),
|
||||
).toEqual(['Первое', 'Второе']);
|
||||
expect(profile.projects[0]).toMatchObject({
|
||||
period: '2025 — н.в.',
|
||||
highlights: ['Первое', 'Второе'],
|
||||
});
|
||||
expect(profile.projects[0]?.skills.map((skill) => skill.name)).toEqual([
|
||||
'Flutter',
|
||||
'WASM',
|
||||
]);
|
||||
});
|
||||
});
|
||||
185
src/profile/infrastructure/profile.mapper.ts
Normal file
185
src/profile/infrastructure/profile.mapper.ts
Normal file
@ -0,0 +1,185 @@
|
||||
import { assertEnumValue } from '../domain/assert-enum.js';
|
||||
import { LinkKind, ProjectStatus, SkillGroup } from '../domain/enums.js';
|
||||
import { formatOptionalPeriod, formatPeriod } from '../domain/period.js';
|
||||
import type {
|
||||
PublicAchievement,
|
||||
PublicAward,
|
||||
PublicEducation,
|
||||
PublicExperience,
|
||||
PublicLink,
|
||||
PublicProfile,
|
||||
PublicProject,
|
||||
PublicSkill,
|
||||
} from '../domain/profile.js';
|
||||
|
||||
type Ordered = { sortOrder: number };
|
||||
|
||||
type SkillRow = Ordered & {
|
||||
id: string;
|
||||
name: string;
|
||||
group: string;
|
||||
};
|
||||
|
||||
type ProfileRow = {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
title: string;
|
||||
description: string;
|
||||
location: string;
|
||||
availability: string;
|
||||
links?: Array<
|
||||
Ordered & { id: string; kind: string; label: string; url: string }
|
||||
>;
|
||||
skills?: SkillRow[];
|
||||
education?: {
|
||||
institution: string;
|
||||
specialty: string;
|
||||
specialtyCode: string;
|
||||
graduatedYear: number;
|
||||
} | null;
|
||||
awards?: Array<
|
||||
Ordered & {
|
||||
title: string;
|
||||
result: string;
|
||||
year: number | null;
|
||||
url: string | null;
|
||||
}
|
||||
>;
|
||||
experiences?: Array<
|
||||
Ordered & {
|
||||
id: string;
|
||||
company: string;
|
||||
position: string;
|
||||
startYear: number;
|
||||
endYear: number | null;
|
||||
summary: string;
|
||||
achievements?: Array<Ordered & { id: string; description: string }>;
|
||||
}
|
||||
>;
|
||||
projects?: Array<
|
||||
Ordered & {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string | null;
|
||||
description: string;
|
||||
role: string | null;
|
||||
status: string | null;
|
||||
startYear: number | null;
|
||||
endYear: number | null;
|
||||
highlights?: Array<Ordered & { text: string }>;
|
||||
skills?: Array<{ skill: SkillRow }>;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
export function mapProfile(row: ProfileRow): PublicProfile {
|
||||
return {
|
||||
id: row.id,
|
||||
slug: row.slug,
|
||||
name: row.name,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
location: row.location,
|
||||
availability: row.availability,
|
||||
links: sortByOrder(row.links).map(mapLink),
|
||||
skills: sortByOrder(row.skills).map(mapSkill),
|
||||
experience: sortByOrder(row.experiences).map(mapExperience),
|
||||
projects: sortByOrder(row.projects).map(mapProject),
|
||||
education: row.education ? mapEducation(row.education) : null,
|
||||
awards: sortByOrder(row.awards).map(mapAward),
|
||||
};
|
||||
}
|
||||
|
||||
function mapLink(row: NonNullable<ProfileRow['links']>[number]): PublicLink {
|
||||
return {
|
||||
id: row.id,
|
||||
kind: assertEnumValue(LinkKind, row.kind, 'link kind'),
|
||||
label: row.label,
|
||||
url: row.url,
|
||||
};
|
||||
}
|
||||
|
||||
function mapSkill(row: SkillRow): PublicSkill {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
group: assertEnumValue(SkillGroup, row.group, 'skill group'),
|
||||
};
|
||||
}
|
||||
|
||||
function mapExperience(
|
||||
row: NonNullable<ProfileRow['experiences']>[number],
|
||||
): PublicExperience {
|
||||
return {
|
||||
id: row.id,
|
||||
company: row.company,
|
||||
position: row.position,
|
||||
startYear: row.startYear,
|
||||
endYear: row.endYear,
|
||||
period: formatPeriod(row.startYear, row.endYear),
|
||||
isCurrent: row.endYear === null,
|
||||
summary: row.summary,
|
||||
achievements: sortByOrder(row.achievements).map(mapAchievement),
|
||||
};
|
||||
}
|
||||
|
||||
function mapAchievement(
|
||||
row: NonNullable<
|
||||
NonNullable<ProfileRow['experiences']>[number]['achievements']
|
||||
>[number],
|
||||
): PublicAchievement {
|
||||
return { id: row.id, description: row.description };
|
||||
}
|
||||
|
||||
function mapProject(
|
||||
row: NonNullable<ProfileRow['projects']>[number],
|
||||
): PublicProject {
|
||||
const skills = (row.skills ?? [])
|
||||
.map((link) => link.skill)
|
||||
.sort((left, right) => left.sortOrder - right.sortOrder)
|
||||
.map(mapSkill);
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
url: row.url,
|
||||
description: row.description,
|
||||
role: row.role,
|
||||
status:
|
||||
row.status === null
|
||||
? null
|
||||
: assertEnumValue(ProjectStatus, row.status, 'project status'),
|
||||
startYear: row.startYear,
|
||||
endYear: row.endYear,
|
||||
period: formatOptionalPeriod(row.startYear, row.endYear),
|
||||
highlights: sortByOrder(row.highlights).map((highlight) => highlight.text),
|
||||
skills,
|
||||
};
|
||||
}
|
||||
|
||||
function mapEducation(
|
||||
row: NonNullable<ProfileRow['education']>,
|
||||
): PublicEducation {
|
||||
return {
|
||||
institution: row.institution,
|
||||
specialty: row.specialty,
|
||||
specialtyCode: row.specialtyCode,
|
||||
graduatedYear: row.graduatedYear,
|
||||
};
|
||||
}
|
||||
|
||||
function mapAward(row: NonNullable<ProfileRow['awards']>[number]): PublicAward {
|
||||
return {
|
||||
title: row.title,
|
||||
result: row.result,
|
||||
year: row.year,
|
||||
url: row.url,
|
||||
};
|
||||
}
|
||||
|
||||
function sortByOrder<T extends Ordered>(items: T[] | undefined): T[] {
|
||||
return [...(items ?? [])].sort(
|
||||
(left, right) => left.sortOrder - right.sortOrder,
|
||||
);
|
||||
}
|
||||
23
src/profile/presentation/profile-exception.filter.ts
Normal file
23
src/profile/presentation/profile-exception.filter.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Catch,
|
||||
NotFoundException,
|
||||
type HttpException,
|
||||
} from '@nestjs/common';
|
||||
import { GqlExceptionFilter } from '@nestjs/graphql';
|
||||
import {
|
||||
InvalidProfileSlugError,
|
||||
ProfileNotFoundError,
|
||||
} from '../domain/errors.js';
|
||||
|
||||
@Catch(ProfileNotFoundError, InvalidProfileSlugError)
|
||||
export class ProfileExceptionFilter implements GqlExceptionFilter {
|
||||
catch(
|
||||
exception: ProfileNotFoundError | InvalidProfileSlugError,
|
||||
): HttpException {
|
||||
if (exception instanceof InvalidProfileSlugError) {
|
||||
return new BadRequestException(exception.message);
|
||||
}
|
||||
return new NotFoundException(exception.message);
|
||||
}
|
||||
}
|
||||
196
src/profile/presentation/profile.model.ts
Normal file
196
src/profile/presentation/profile.model.ts
Normal file
@ -0,0 +1,196 @@
|
||||
import { Field, ID, Int, ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||
import { LinkKind, ProjectStatus, SkillGroup } from '../domain/enums.js';
|
||||
|
||||
registerEnumType(LinkKind, {
|
||||
name: 'LinkKind',
|
||||
description: 'Тип профессиональной ссылки',
|
||||
});
|
||||
|
||||
registerEnumType(SkillGroup, {
|
||||
name: 'SkillGroup',
|
||||
description: 'Группа навыка',
|
||||
});
|
||||
|
||||
registerEnumType(ProjectStatus, {
|
||||
name: 'ProjectStatus',
|
||||
description: 'Статус проекта, если он указан в резюме',
|
||||
});
|
||||
|
||||
@ObjectType('Link', { description: 'Ссылка на профессиональный ресурс' })
|
||||
export class LinkModel {
|
||||
@Field(() => ID)
|
||||
id!: string;
|
||||
|
||||
@Field(() => LinkKind)
|
||||
kind!: LinkKind;
|
||||
|
||||
@Field(() => String)
|
||||
label!: string;
|
||||
|
||||
@Field(() => String)
|
||||
url!: string;
|
||||
}
|
||||
|
||||
@ObjectType('Skill', { description: 'Навык' })
|
||||
export class SkillModel {
|
||||
@Field(() => ID)
|
||||
id!: string;
|
||||
|
||||
@Field(() => String)
|
||||
name!: string;
|
||||
|
||||
@Field(() => SkillGroup)
|
||||
group!: SkillGroup;
|
||||
}
|
||||
|
||||
@ObjectType('Achievement', { description: 'Достижение на месте работы' })
|
||||
export class AchievementModel {
|
||||
@Field(() => ID)
|
||||
id!: string;
|
||||
|
||||
@Field(() => String)
|
||||
description!: string;
|
||||
}
|
||||
|
||||
@ObjectType('Experience', { description: 'Место работы' })
|
||||
export class ExperienceModel {
|
||||
@Field(() => ID)
|
||||
id!: string;
|
||||
|
||||
@Field(() => String)
|
||||
company!: string;
|
||||
|
||||
@Field(() => String)
|
||||
position!: string;
|
||||
|
||||
@Field(() => Int)
|
||||
startYear!: number;
|
||||
|
||||
@Field(() => Int, { nullable: true })
|
||||
endYear!: number | null;
|
||||
|
||||
@Field(() => String, {
|
||||
description:
|
||||
'Период, вычисленный из годов. Открытый период заканчивается «н.в.»',
|
||||
})
|
||||
period!: string;
|
||||
|
||||
@Field(() => Boolean, {
|
||||
description: 'Место считается текущим, если год окончания не указан',
|
||||
})
|
||||
isCurrent!: boolean;
|
||||
|
||||
@Field(() => String)
|
||||
summary!: string;
|
||||
|
||||
@Field(() => [AchievementModel])
|
||||
achievements!: AchievementModel[];
|
||||
}
|
||||
|
||||
@ObjectType('Project', { description: 'Проект' })
|
||||
export class ProjectModel {
|
||||
@Field(() => ID)
|
||||
id!: string;
|
||||
|
||||
@Field(() => String)
|
||||
name!: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
url!: string | null;
|
||||
|
||||
@Field(() => String)
|
||||
description!: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
role!: string | null;
|
||||
|
||||
@Field(() => ProjectStatus, { nullable: true })
|
||||
status!: ProjectStatus | null;
|
||||
|
||||
@Field(() => Int, { nullable: true })
|
||||
startYear!: number | null;
|
||||
|
||||
@Field(() => Int, { nullable: true })
|
||||
endYear!: number | null;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
period!: string | null;
|
||||
|
||||
@Field(() => [String])
|
||||
highlights!: string[];
|
||||
|
||||
@Field(() => [SkillModel])
|
||||
skills!: SkillModel[];
|
||||
}
|
||||
|
||||
@ObjectType('Education', { description: 'Высшее образование' })
|
||||
export class EducationModel {
|
||||
@Field(() => String)
|
||||
institution!: string;
|
||||
|
||||
@Field(() => String)
|
||||
specialty!: string;
|
||||
|
||||
@Field(() => String)
|
||||
specialtyCode!: string;
|
||||
|
||||
@Field(() => Int)
|
||||
graduatedYear!: number;
|
||||
}
|
||||
|
||||
@ObjectType('Award', { description: 'Конкурс или чемпионат' })
|
||||
export class AwardModel {
|
||||
@Field(() => String)
|
||||
title!: string;
|
||||
|
||||
@Field(() => String)
|
||||
result!: string;
|
||||
|
||||
@Field(() => Int, { nullable: true })
|
||||
year!: number | null;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
url!: string | null;
|
||||
}
|
||||
|
||||
@ObjectType('Profile', { description: 'Публичная визитка' })
|
||||
export class ProfileModel {
|
||||
@Field(() => ID)
|
||||
id!: string;
|
||||
|
||||
@Field(() => String)
|
||||
slug!: string;
|
||||
|
||||
@Field(() => String)
|
||||
name!: string;
|
||||
|
||||
@Field(() => String)
|
||||
title!: string;
|
||||
|
||||
@Field(() => String)
|
||||
description!: string;
|
||||
|
||||
@Field(() => String)
|
||||
location!: string;
|
||||
|
||||
@Field(() => String)
|
||||
availability!: string;
|
||||
|
||||
@Field(() => [LinkModel])
|
||||
links!: LinkModel[];
|
||||
|
||||
@Field(() => [SkillModel])
|
||||
skills!: SkillModel[];
|
||||
|
||||
@Field(() => [ExperienceModel])
|
||||
experience!: ExperienceModel[];
|
||||
|
||||
@Field(() => [ProjectModel])
|
||||
projects!: ProjectModel[];
|
||||
|
||||
@Field(() => EducationModel, { nullable: true })
|
||||
education!: EducationModel | null;
|
||||
|
||||
@Field(() => [AwardModel])
|
||||
awards!: AwardModel[];
|
||||
}
|
||||
32
src/profile/presentation/profile.resolver.ts
Normal file
32
src/profile/presentation/profile.resolver.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { Args, Info, Query, Resolver } from '@nestjs/graphql';
|
||||
import type { GraphQLResolveInfo } from 'graphql';
|
||||
import { ProfileService } from '../application/profile.service.js';
|
||||
import type { PublicProfile } from '../domain/profile.js';
|
||||
import { PUBLIC_PROFILE_SLUG } from '../profile.constants.js';
|
||||
import { ProfileModel } from './profile.model.js';
|
||||
import { relationPlanFromInfo } from './relation-plan.js';
|
||||
|
||||
@Resolver(() => ProfileModel)
|
||||
export class ProfileResolver {
|
||||
constructor(private readonly profiles: ProfileService) {}
|
||||
|
||||
@Query(() => ProfileModel, {
|
||||
description:
|
||||
'Публичная визитка. Вложенные связи загружаются вместе с профилем и только если они есть в запросе.',
|
||||
})
|
||||
profile(
|
||||
@Info() info: GraphQLResolveInfo,
|
||||
@Args('slug', {
|
||||
type: () => String,
|
||||
nullable: true,
|
||||
description:
|
||||
'Стабильный идентификатор. По умолчанию визитка Ильи Братских.',
|
||||
})
|
||||
slug?: string | null,
|
||||
): Promise<PublicProfile> {
|
||||
return this.profiles.getBySlug(
|
||||
slug ?? PUBLIC_PROFILE_SLUG,
|
||||
relationPlanFromInfo(info),
|
||||
);
|
||||
}
|
||||
}
|
||||
85
src/profile/presentation/relation-plan.spec.ts
Normal file
85
src/profile/presentation/relation-plan.spec.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import { relationPlanFromQuery } from './relation-plan.js';
|
||||
|
||||
const EXAMPLE = `
|
||||
query {
|
||||
profile {
|
||||
name
|
||||
description
|
||||
skills { name }
|
||||
experience { company position }
|
||||
projects { name }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
describe('relation plan', () => {
|
||||
it('requests only the relations used by the assignment example', () => {
|
||||
expect(relationPlanFromQuery(EXAMPLE)).toEqual({
|
||||
links: false,
|
||||
skills: true,
|
||||
education: false,
|
||||
awards: false,
|
||||
experience: { achievements: false },
|
||||
projects: { skills: false, highlights: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('follows aliases, fragments and inline fragments', () => {
|
||||
const plan = relationPlanFromQuery(`
|
||||
query {
|
||||
profile {
|
||||
jobs: experience { company }
|
||||
...Extra
|
||||
... on Profile { awards { title } }
|
||||
}
|
||||
}
|
||||
fragment Extra on Profile {
|
||||
projects {
|
||||
...ProjectBits
|
||||
}
|
||||
}
|
||||
fragment ProjectBits on Project {
|
||||
skills { name }
|
||||
highlights
|
||||
}
|
||||
`);
|
||||
|
||||
expect(plan.experience).toEqual({ achievements: false });
|
||||
expect(plan.awards).toBe(true);
|
||||
expect(plan.projects).toEqual({ skills: true, highlights: true });
|
||||
expect(plan.skills).toBe(false);
|
||||
});
|
||||
|
||||
it('loads achievements once when any selection asks for them', () => {
|
||||
const plan = relationPlanFromQuery(`
|
||||
query {
|
||||
profile {
|
||||
experience { company }
|
||||
...Deep
|
||||
}
|
||||
}
|
||||
fragment Deep on Profile {
|
||||
experience { achievements { description } }
|
||||
}
|
||||
`);
|
||||
|
||||
expect(plan.experience).toEqual({ achievements: true });
|
||||
});
|
||||
|
||||
it('ignores meta fields', () => {
|
||||
const plan = relationPlanFromQuery(`
|
||||
query {
|
||||
profile { __typename name }
|
||||
}
|
||||
`);
|
||||
|
||||
expect(plan).toEqual({
|
||||
links: false,
|
||||
skills: false,
|
||||
education: false,
|
||||
awards: false,
|
||||
experience: null,
|
||||
projects: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
146
src/profile/presentation/relation-plan.ts
Normal file
146
src/profile/presentation/relation-plan.ts
Normal file
@ -0,0 +1,146 @@
|
||||
import {
|
||||
Kind,
|
||||
parse,
|
||||
type FieldNode,
|
||||
type FragmentDefinitionNode,
|
||||
type GraphQLResolveInfo,
|
||||
type SelectionNode,
|
||||
} from 'graphql';
|
||||
import {
|
||||
emptyRelationPlan,
|
||||
type RelationPlan,
|
||||
} from '../domain/relation-plan.js';
|
||||
|
||||
/**
|
||||
* Reads the GraphQL selection set and records which relations the repository
|
||||
* should include. Nested resolvers then read data that is already loaded.
|
||||
*/
|
||||
export function relationPlanFromInfo(info: GraphQLResolveInfo): RelationPlan {
|
||||
const plan = emptyRelationPlan();
|
||||
for (const field of info.fieldNodes) {
|
||||
mergeSelections(plan, field.selectionSet?.selections, info.fragments);
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
export function relationPlanFromQuery(source: string): RelationPlan {
|
||||
const document = parse(source);
|
||||
const fragments: Record<string, FragmentDefinitionNode> = {};
|
||||
let selections: readonly SelectionNode[] | undefined;
|
||||
|
||||
for (const definition of document.definitions) {
|
||||
if (definition.kind === Kind.FRAGMENT_DEFINITION) {
|
||||
fragments[definition.name.value] = definition;
|
||||
}
|
||||
if (definition.kind === Kind.OPERATION_DEFINITION) {
|
||||
const profile = definition.selectionSet.selections.find(
|
||||
(selection): selection is FieldNode =>
|
||||
selection.kind === Kind.FIELD && selection.name.value === 'profile',
|
||||
);
|
||||
selections = profile?.selectionSet?.selections;
|
||||
}
|
||||
}
|
||||
|
||||
const plan = emptyRelationPlan();
|
||||
mergeSelections(plan, selections, fragments);
|
||||
return plan;
|
||||
}
|
||||
|
||||
function mergeSelections(
|
||||
plan: RelationPlan,
|
||||
selections: readonly SelectionNode[] | undefined,
|
||||
fragments: Record<string, FragmentDefinitionNode>,
|
||||
): void {
|
||||
if (!selections) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const selection of selections) {
|
||||
if (selection.kind === Kind.FIELD) {
|
||||
applyField(plan, selection, fragments);
|
||||
continue;
|
||||
}
|
||||
if (selection.kind === Kind.INLINE_FRAGMENT) {
|
||||
mergeSelections(plan, selection.selectionSet.selections, fragments);
|
||||
continue;
|
||||
}
|
||||
const fragment = fragments[selection.name.value];
|
||||
if (fragment) {
|
||||
mergeSelections(plan, fragment.selectionSet.selections, fragments);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyField(
|
||||
plan: RelationPlan,
|
||||
field: FieldNode,
|
||||
fragments: Record<string, FragmentDefinitionNode>,
|
||||
): void {
|
||||
const name = field.name.value;
|
||||
if (name === 'links') {
|
||||
plan.links = true;
|
||||
return;
|
||||
}
|
||||
if (name === 'skills') {
|
||||
plan.skills = true;
|
||||
return;
|
||||
}
|
||||
if (name === 'education') {
|
||||
plan.education = true;
|
||||
return;
|
||||
}
|
||||
if (name === 'awards') {
|
||||
plan.awards = true;
|
||||
return;
|
||||
}
|
||||
if (name === 'experience') {
|
||||
const nested = nestedFieldNames(field, fragments);
|
||||
plan.experience = {
|
||||
achievements:
|
||||
Boolean(plan.experience?.achievements) || nested.has('achievements'),
|
||||
};
|
||||
return;
|
||||
}
|
||||
if (name === 'projects') {
|
||||
const nested = nestedFieldNames(field, fragments);
|
||||
plan.projects = {
|
||||
skills: Boolean(plan.projects?.skills) || nested.has('skills'),
|
||||
highlights:
|
||||
Boolean(plan.projects?.highlights) || nested.has('highlights'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function nestedFieldNames(
|
||||
field: FieldNode,
|
||||
fragments: Record<string, FragmentDefinitionNode>,
|
||||
): Set<string> {
|
||||
const names = new Set<string>();
|
||||
collectFieldNames(names, field.selectionSet?.selections, fragments);
|
||||
return names;
|
||||
}
|
||||
|
||||
function collectFieldNames(
|
||||
names: Set<string>,
|
||||
selections: readonly SelectionNode[] | undefined,
|
||||
fragments: Record<string, FragmentDefinitionNode>,
|
||||
): void {
|
||||
if (!selections) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const selection of selections) {
|
||||
if (selection.kind === Kind.FIELD) {
|
||||
names.add(selection.name.value);
|
||||
continue;
|
||||
}
|
||||
if (selection.kind === Kind.INLINE_FRAGMENT) {
|
||||
collectFieldNames(names, selection.selectionSet.selections, fragments);
|
||||
continue;
|
||||
}
|
||||
const fragment = fragments[selection.name.value];
|
||||
if (fragment) {
|
||||
collectFieldNames(names, fragment.selectionSet.selections, fragments);
|
||||
}
|
||||
}
|
||||
}
|
||||
52
src/profile/profile-query.spec.ts
Normal file
52
src/profile/profile-query.spec.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { profileInclude } from './infrastructure/profile-include.js';
|
||||
import { relationPlanFromQuery } from './presentation/relation-plan.js';
|
||||
|
||||
describe('profile query shape', () => {
|
||||
it('includes three relations and does not prefetch achievements or project skills', () => {
|
||||
const plan = relationPlanFromQuery(`
|
||||
query {
|
||||
profile {
|
||||
name
|
||||
description
|
||||
skills { name }
|
||||
experience { company position }
|
||||
projects { name }
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
expect(profileInclude(plan)).toEqual({
|
||||
skills: { orderBy: { sortOrder: 'asc' } },
|
||||
experiences: { orderBy: { sortOrder: 'asc' } },
|
||||
projects: { orderBy: { sortOrder: 'asc' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('adds a nested include only for the selected child collections', () => {
|
||||
const plan = relationPlanFromQuery(`
|
||||
query {
|
||||
profile {
|
||||
experience { achievements { description } }
|
||||
projects { highlights skills { name } }
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
expect(profileInclude(plan)).toEqual({
|
||||
experiences: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: { achievements: { orderBy: { sortOrder: 'asc' } } },
|
||||
},
|
||||
projects: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: {
|
||||
highlights: { orderBy: { sortOrder: 'asc' } },
|
||||
skills: {
|
||||
include: { skill: true },
|
||||
orderBy: { skill: { sortOrder: 'asc' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
1
src/profile/profile.constants.ts
Normal file
1
src/profile/profile.constants.ts
Normal file
@ -0,0 +1 @@
|
||||
export const PUBLIC_PROFILE_SLUG = 'ilya-bratskikh';
|
||||
24
src/profile/profile.module.ts
Normal file
24
src/profile/profile.module.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_FILTER } from '@nestjs/core';
|
||||
import { ProfileService } from './application/profile.service.js';
|
||||
import { PROFILE_REPOSITORY } from './application/profile.repository.js';
|
||||
import { PrismaProfileRepository } from './infrastructure/prisma-profile.repository.js';
|
||||
import { ProfileExceptionFilter } from './presentation/profile-exception.filter.js';
|
||||
import { ProfileResolver } from './presentation/profile.resolver.js';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
ProfileResolver,
|
||||
ProfileService,
|
||||
PrismaProfileRepository,
|
||||
{
|
||||
provide: PROFILE_REPOSITORY,
|
||||
useExisting: PrismaProfileRepository,
|
||||
},
|
||||
{
|
||||
provide: APP_FILTER,
|
||||
useClass: ProfileExceptionFilter,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class ProfileModule {}
|
||||
49
src/seed/card.data.spec.ts
Normal file
49
src/seed/card.data.spec.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { PUBLIC_PROFILE_SLUG } from '../profile/profile.constants.js';
|
||||
import { cardSeed } from './card.data.js';
|
||||
|
||||
describe('card seed', () => {
|
||||
const skillNames = new Set(cardSeed.skills.map((skill) => skill.name));
|
||||
|
||||
it('describes the public profile from the resume', () => {
|
||||
expect(cardSeed.slug).toBe(PUBLIC_PROFILE_SLUG);
|
||||
expect(cardSeed.name).toBe('Илья Братских');
|
||||
expect(cardSeed.links.map((link) => link.kind)).toEqual([
|
||||
'GITHUB',
|
||||
'WEBSITE',
|
||||
'TELEGRAM',
|
||||
'EMAIL',
|
||||
'PHONE',
|
||||
]);
|
||||
expect(
|
||||
cardSeed.experience
|
||||
.filter((job) => job.endYear === null)
|
||||
.map((job) => job.company),
|
||||
).toEqual(['АО «Синтелс»']);
|
||||
});
|
||||
|
||||
it('references only declared skills', () => {
|
||||
expect(skillNames.size).toBe(cardSeed.skills.length);
|
||||
for (const project of cardSeed.projects) {
|
||||
for (const name of project.skillNames) {
|
||||
expect(skillNames.has(name)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps project names and skill lists unique', () => {
|
||||
expect(new Set(cardSeed.projects.map((project) => project.name)).size).toBe(
|
||||
cardSeed.projects.length,
|
||||
);
|
||||
for (const project of cardSeed.projects) {
|
||||
expect(new Set(project.skillNames).size).toBe(project.skillNames.length);
|
||||
}
|
||||
});
|
||||
|
||||
it('publishes at least one repository link', () => {
|
||||
expect(
|
||||
cardSeed.projects.some((project) =>
|
||||
project.url?.startsWith('https://github.com/KBAHTNET/'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
292
src/seed/card.data.ts
Normal file
292
src/seed/card.data.ts
Normal file
@ -0,0 +1,292 @@
|
||||
import {
|
||||
LinkKind,
|
||||
ProjectStatus,
|
||||
SkillGroup,
|
||||
} from '../profile/domain/enums.js';
|
||||
import { PUBLIC_PROFILE_SLUG } from '../profile/profile.constants.js';
|
||||
|
||||
export type CardSeed = {
|
||||
slug: string;
|
||||
name: string;
|
||||
title: string;
|
||||
description: string;
|
||||
location: string;
|
||||
availability: string;
|
||||
links: Array<{ kind: LinkKind; label: string; url: string }>;
|
||||
skills: Array<{ name: string; group: SkillGroup }>;
|
||||
education: {
|
||||
institution: string;
|
||||
specialty: string;
|
||||
specialtyCode: string;
|
||||
graduatedYear: number;
|
||||
};
|
||||
awards: Array<{
|
||||
title: string;
|
||||
result: string;
|
||||
year: number | null;
|
||||
url: string | null;
|
||||
}>;
|
||||
experience: Array<{
|
||||
company: string;
|
||||
position: string;
|
||||
startYear: number;
|
||||
endYear: number | null;
|
||||
summary: string;
|
||||
achievements: string[];
|
||||
}>;
|
||||
projects: Array<{
|
||||
name: string;
|
||||
url: string | null;
|
||||
description: string;
|
||||
role: string | null;
|
||||
status: ProjectStatus | null;
|
||||
startYear: number | null;
|
||||
endYear: number | null;
|
||||
highlights: string[];
|
||||
skillNames: string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
export const cardSeed: CardSeed = {
|
||||
slug: PUBLIC_PROFILE_SLUG,
|
||||
name: 'Илья Братских',
|
||||
title: 'Fullstack Developer (Middle+/Senior)',
|
||||
description:
|
||||
'Fullstack-разработчик уровня Middle+/Senior. Четыре года коммерческой разработки и три года некоммерческой, коммерческая практика с 2022 года. Интересны роли полного цикла: архитектура, backend, сложные интерфейсы и highload. Основной стек — Node.js (NestJS, Next.js), Vue, React и Flutter. Python и Go использую как дополнительные инструменты.',
|
||||
location: 'Москва',
|
||||
availability: 'Удалённо, гибрид или офис',
|
||||
links: [
|
||||
{
|
||||
kind: LinkKind.GITHUB,
|
||||
label: 'GitHub',
|
||||
url: 'https://github.com/KBAHTNET',
|
||||
},
|
||||
{
|
||||
kind: LinkKind.WEBSITE,
|
||||
label: 'Сайт',
|
||||
url: 'https://bratskih.tech',
|
||||
},
|
||||
{
|
||||
kind: LinkKind.TELEGRAM,
|
||||
label: 'Telegram',
|
||||
url: 'https://t.me/ilyabratskih',
|
||||
},
|
||||
{
|
||||
kind: LinkKind.EMAIL,
|
||||
label: 'Email',
|
||||
url: 'mailto:ilyabratskih@mail.ru',
|
||||
},
|
||||
{
|
||||
kind: LinkKind.PHONE,
|
||||
label: '+7 (977) 861-24-21',
|
||||
url: 'tel:+79778612421',
|
||||
},
|
||||
],
|
||||
skills: [
|
||||
{ name: 'TypeScript', group: SkillGroup.LANGUAGE },
|
||||
{ name: 'JavaScript', group: SkillGroup.LANGUAGE },
|
||||
{ name: 'Python', group: SkillGroup.LANGUAGE },
|
||||
{ name: 'Go', group: SkillGroup.LANGUAGE },
|
||||
{ name: 'C++', group: SkillGroup.LANGUAGE },
|
||||
{ name: 'C#', group: SkillGroup.LANGUAGE },
|
||||
{ name: 'Dart', group: SkillGroup.LANGUAGE },
|
||||
{ name: 'Bash', group: SkillGroup.LANGUAGE },
|
||||
{ name: 'Node.js', group: SkillGroup.BACKEND },
|
||||
{ name: 'NestJS', group: SkillGroup.BACKEND },
|
||||
{ name: 'Django', group: SkillGroup.BACKEND },
|
||||
{ name: 'RabbitMQ', group: SkillGroup.BACKEND },
|
||||
{ name: 'Socket.io', group: SkillGroup.BACKEND },
|
||||
{ name: 'Vue', group: SkillGroup.FRONTEND },
|
||||
{ name: 'React', group: SkillGroup.FRONTEND },
|
||||
{ name: 'Next.js', group: SkillGroup.FRONTEND },
|
||||
{ name: 'Vuex', group: SkillGroup.FRONTEND },
|
||||
{ name: 'Redux', group: SkillGroup.FRONTEND },
|
||||
{ name: 'MobX', group: SkillGroup.FRONTEND },
|
||||
{ name: 'Flutter', group: SkillGroup.MOBILE },
|
||||
{ name: 'PyTorch', group: SkillGroup.DATA },
|
||||
{ name: 'TensorFlow', group: SkillGroup.DATA },
|
||||
{ name: 'Electron', group: SkillGroup.SYSTEMS },
|
||||
{ name: 'WASM', group: SkillGroup.SYSTEMS },
|
||||
{ name: 'DPDK', group: SkillGroup.SYSTEMS },
|
||||
{ name: 'WebRTC', group: SkillGroup.SYSTEMS },
|
||||
{ name: 'Selenium', group: SkillGroup.SYSTEMS },
|
||||
],
|
||||
education: {
|
||||
institution:
|
||||
'Московский ордена Почета университет Министерства внутренних дел Российской Федерации имени В.Я. Кикотя',
|
||||
specialty: 'Компьютерная экспертиза',
|
||||
specialtyCode: '10.05.05',
|
||||
graduatedYear: 2022,
|
||||
},
|
||||
awards: [
|
||||
{
|
||||
title: 'Чемпионат по алгоритмам Яндекса, второй сезон, трек «Фронтенд»',
|
||||
result: '25 место из 766, 15 решённых задач из 28, 2607.58 балла',
|
||||
year: null,
|
||||
url: null,
|
||||
},
|
||||
{
|
||||
title: 'Конкурс профессионального мастерства МВД России',
|
||||
result:
|
||||
'3 место. Команда под моим лидерством, номинация «Искусственный интеллект на службе полиции»',
|
||||
year: 2021,
|
||||
url: 'https://мвд.рф/news/item/24328582',
|
||||
},
|
||||
],
|
||||
experience: [
|
||||
{
|
||||
company: 'АО «Синтелс»',
|
||||
position: 'Fullstack Developer & Researcher',
|
||||
startYear: 2023,
|
||||
endYear: null,
|
||||
summary:
|
||||
'Продуктовая команда (графы и СУБД): team lead, BFF и DevOps; два frontend-разработчика, включая меня, и два backend. Исследовательская команда (брокеры сообщений): архитектор стенда и DevOps.',
|
||||
achievements: [
|
||||
'Web-приложение для графов: построение связей, собственный алгоритм размещения и клиент-серверная архитектура.',
|
||||
'Конструктор правил поиска: единоличное fullstack-владение, парсинг языка, AST и визуальное редактирование.',
|
||||
'Исследование брокеров: архитектура стенда и DevOps, оптика 100G, генерация трафика на DPDK и TRex, протоколы GTP-C и GTP-U.',
|
||||
'Оптимизация работы с большими таблицами: виртуальный скроллинг и WASM, исследование графовых СУБД.',
|
||||
],
|
||||
},
|
||||
{
|
||||
company: 'БСТМ МВД России',
|
||||
position: 'Инженер-разработчик',
|
||||
startYear: 2022,
|
||||
endYear: 2023,
|
||||
summary:
|
||||
'Инженерная разработка, администрирование серверов и компьютерная экспертиза.',
|
||||
achievements: [
|
||||
'Скрипты поиска местоположения на Python.',
|
||||
'Администрирование серверов и подготовка требований к закупке.',
|
||||
'Компьютерная экспертиза VDS/VPS и reverse engineering.',
|
||||
'Микросервисы для графов на Node.js и Vue.',
|
||||
'Клиент-серверное приложение на C# для форензики образов дисков.',
|
||||
],
|
||||
},
|
||||
],
|
||||
projects: [
|
||||
{
|
||||
name: 'Высокопроизводительная таблица для больших объёмов данных',
|
||||
url: null,
|
||||
description:
|
||||
'Собственная таблица с виртуальным скроллингом вместо Syncfusion DataGrid: миллионы строк без многогигабайтного потребления памяти.',
|
||||
role: 'Frontend core, оптимизация',
|
||||
status: ProjectStatus.ACTIVE,
|
||||
startYear: 2025,
|
||||
endYear: null,
|
||||
highlights: [
|
||||
'Виртуальный скроллинг и динамическая подгрузка данных.',
|
||||
'Высота скролла считается по префиксным суммам, чтобы строки разной высоты не тормозили прокрутку.',
|
||||
'Под этот расчёт доработан Flutter ListView.',
|
||||
'На 10 млн записей потребление памяти снижено с 3 ГБ до 500 МБ.',
|
||||
'Внедрено в продукт: больше 10 млн строк разной высоты и неограниченное число строк одинаковой высоты.',
|
||||
],
|
||||
skillNames: ['Flutter', 'WASM'],
|
||||
},
|
||||
{
|
||||
name: 'Стенд нагрузочного тестирования брокеров',
|
||||
url: null,
|
||||
description:
|
||||
'Сравнение отечественных брокеров сообщений на трафике GTP-C/GTP-U в условиях, близких к сети оператора связи.',
|
||||
role: 'Архитектор, DevOps, исследователь',
|
||||
status: ProjectStatus.ACTIVE,
|
||||
startYear: 2024,
|
||||
endYear: null,
|
||||
highlights: [
|
||||
'Физический стенд на оптике 100G с масштабируемой схемой коммутации.',
|
||||
'Генераторы трафика на Python (scapy) и C++ (DPDK, Cisco TRex).',
|
||||
'Схема рассчитана на имитацию нагрузки 300 Гбит/с и выше.',
|
||||
'Автоматический сбор метрик и визуализация результатов.',
|
||||
'По научно-техническому отчёту выбиралась стратегия использования брокеров; разработка ПО продолжается.',
|
||||
],
|
||||
skillNames: ['Python', 'C++', 'DPDK'],
|
||||
},
|
||||
{
|
||||
name: 'Конструктор правил поиска с визуальным AST',
|
||||
url: null,
|
||||
description:
|
||||
'Инструмент визуальной сборки сложных запросов на собственном языке поиска: от текста к дереву и обратно.',
|
||||
role: 'Fullstack, единоличная разработка',
|
||||
status: ProjectStatus.COMPLETED,
|
||||
startYear: 2023,
|
||||
endYear: 2024,
|
||||
highlights: [
|
||||
'Лексический и синтаксический разбор языка поиска в AST.',
|
||||
'Интерактивное дерево с редактированием узлов, включая drag-and-drop.',
|
||||
'Обратная генерация текста правила из дерева.',
|
||||
'Невалидные узлы подсвечиваются и не попадают в итоговое правило.',
|
||||
],
|
||||
skillNames: [],
|
||||
},
|
||||
{
|
||||
name: 'Shared Clipboard',
|
||||
url: null,
|
||||
description:
|
||||
'Синхронизация буфера обмена между устройствами, в том числе между машиной в интернете и изолированной машиной через безопасную среду.',
|
||||
role: null,
|
||||
status: ProjectStatus.ACTIVE,
|
||||
startYear: null,
|
||||
endYear: null,
|
||||
highlights: [
|
||||
'Единый клиент на Node.js, Electron и Vue.',
|
||||
'Модули на C++ следят за горячими клавишами и вставляют текст: у Electron нет события изменения буфера.',
|
||||
'Нативные модули общаются с Node.js через stdin/stdout.',
|
||||
],
|
||||
skillNames: [
|
||||
'JavaScript',
|
||||
'TypeScript',
|
||||
'Node.js',
|
||||
'Electron',
|
||||
'Vue',
|
||||
'C++',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Социальная сеть',
|
||||
url: null,
|
||||
description:
|
||||
'Микросервисная архитектура: авторизация, лента, чаты и хранилище файлов. Сервисы общаются через RabbitMQ.',
|
||||
role: null,
|
||||
status: null,
|
||||
startYear: null,
|
||||
endYear: null,
|
||||
highlights: [],
|
||||
skillNames: ['RabbitMQ'],
|
||||
},
|
||||
{
|
||||
name: 'Удалённый доступ к рабочему столу',
|
||||
url: 'https://github.com/KBAHTNET/RemoteDesktop',
|
||||
description:
|
||||
'Прототип удалённого управления рабочим столом на WebRTC и WebSocket.',
|
||||
role: null,
|
||||
status: ProjectStatus.PROTOTYPE,
|
||||
startYear: null,
|
||||
endYear: null,
|
||||
highlights: [],
|
||||
skillNames: ['Node.js', 'Electron', 'Vue', 'WebRTC', 'Socket.io'],
|
||||
},
|
||||
{
|
||||
name: 'VPN-агрегатор',
|
||||
url: null,
|
||||
description:
|
||||
'Мобильное приложение: агрегация VPN-конфигов и собственный движок.',
|
||||
role: null,
|
||||
status: ProjectStatus.ACTIVE,
|
||||
startYear: null,
|
||||
endYear: null,
|
||||
highlights: [],
|
||||
skillNames: ['Dart', 'Flutter'],
|
||||
},
|
||||
{
|
||||
name: 'AI-обучение и чат-боты',
|
||||
url: 'https://github.com/KBAHTNET/ai_learning',
|
||||
description: 'Нейронные сети, обработка изображений и чат-боты.',
|
||||
role: null,
|
||||
status: ProjectStatus.COMPLETED,
|
||||
startYear: null,
|
||||
endYear: null,
|
||||
highlights: [],
|
||||
skillNames: ['Python', 'PyTorch', 'TensorFlow'],
|
||||
},
|
||||
],
|
||||
};
|
||||
13
src/seed/seed.hash.spec.ts
Normal file
13
src/seed/seed.hash.spec.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { hashSeed } from './seed.hash.js';
|
||||
|
||||
describe('hashSeed', () => {
|
||||
it('ignores object key order', () => {
|
||||
expect(hashSeed({ b: 1, a: { d: true, c: 'x' } })).toBe(
|
||||
hashSeed({ a: { c: 'x', d: true }, b: 1 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('changes when the content changes', () => {
|
||||
expect(hashSeed({ name: 'Илья' })).not.toBe(hashSeed({ name: 'Ilya' }));
|
||||
});
|
||||
});
|
||||
24
src/seed/seed.hash.ts
Normal file
24
src/seed/seed.hash.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
export function hashSeed(value: unknown): string {
|
||||
return createHash('sha256').update(stableStringify(value)).digest('hex');
|
||||
}
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
if (value === undefined) {
|
||||
return 'null';
|
||||
}
|
||||
if (value === null || typeof value !== 'object') {
|
||||
return JSON.stringify(value) ?? 'null';
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((item) => stableStringify(item)).join(',')}]`;
|
||||
}
|
||||
|
||||
const entries = Object.entries(value).sort(([left], [right]) =>
|
||||
left < right ? -1 : left > right ? 1 : 0,
|
||||
);
|
||||
return `{${entries
|
||||
.map(([key, entry]) => `${JSON.stringify(key)}:${stableStringify(entry)}`)
|
||||
.join(',')}}`;
|
||||
}
|
||||
14
src/seed/seed.module.ts
Normal file
14
src/seed/seed.module.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { Module, type OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { cardSeed } from './card.data.js';
|
||||
import { SeedService } from './seed.service.js';
|
||||
|
||||
@Module({
|
||||
providers: [SeedService],
|
||||
})
|
||||
export class SeedModule implements OnApplicationBootstrap {
|
||||
constructor(private readonly seed: SeedService) {}
|
||||
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
await this.seed.reconcile(cardSeed);
|
||||
}
|
||||
}
|
||||
139
src/seed/seed.service.ts
Normal file
139
src/seed/seed.service.ts
Normal file
@ -0,0 +1,139 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service.js';
|
||||
import type { CardSeed } from './card.data.js';
|
||||
import { hashSeed } from './seed.hash.js';
|
||||
|
||||
const SEED_LOCK_KEY = 'digital-card-seed';
|
||||
|
||||
@Injectable()
|
||||
export class SeedService {
|
||||
private readonly logger = new Logger(SeedService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async reconcile(seed: CardSeed): Promise<'applied' | 'unchanged'> {
|
||||
const contentHash = hashSeed(seed);
|
||||
|
||||
return this.prisma.$transaction(
|
||||
async (tx) => {
|
||||
await tx.$queryRaw`
|
||||
WITH locked AS (
|
||||
SELECT pg_advisory_xact_lock(hashtext(${SEED_LOCK_KEY}))
|
||||
)
|
||||
SELECT 1 AS ok
|
||||
`;
|
||||
|
||||
const existing = await tx.profile.findUnique({
|
||||
where: { slug: seed.slug },
|
||||
select: { id: true, contentHash: true },
|
||||
});
|
||||
if (existing?.contentHash === contentHash) {
|
||||
this.logger.log(`Profile "${seed.slug}" is up to date`);
|
||||
return 'unchanged' as const;
|
||||
}
|
||||
if (existing) {
|
||||
await tx.profile.delete({ where: { id: existing.id } });
|
||||
}
|
||||
|
||||
const profile = await tx.profile.create({
|
||||
data: {
|
||||
slug: seed.slug,
|
||||
contentHash,
|
||||
name: seed.name,
|
||||
title: seed.title,
|
||||
description: seed.description,
|
||||
location: seed.location,
|
||||
availability: seed.availability,
|
||||
links: {
|
||||
create: seed.links.map((link, sortOrder) => ({
|
||||
kind: link.kind,
|
||||
label: link.label,
|
||||
url: link.url,
|
||||
sortOrder,
|
||||
})),
|
||||
},
|
||||
skills: {
|
||||
create: seed.skills.map((skill, sortOrder) => ({
|
||||
name: skill.name,
|
||||
group: skill.group,
|
||||
sortOrder,
|
||||
})),
|
||||
},
|
||||
education: { create: seed.education },
|
||||
awards: {
|
||||
create: seed.awards.map((award, sortOrder) => ({
|
||||
title: award.title,
|
||||
result: award.result,
|
||||
year: award.year,
|
||||
url: award.url,
|
||||
sortOrder,
|
||||
})),
|
||||
},
|
||||
experiences: {
|
||||
create: seed.experience.map((job, sortOrder) => ({
|
||||
company: job.company,
|
||||
position: job.position,
|
||||
startYear: job.startYear,
|
||||
endYear: job.endYear,
|
||||
summary: job.summary,
|
||||
sortOrder,
|
||||
achievements: {
|
||||
create: job.achievements.map(
|
||||
(description, achievementOrder) => ({
|
||||
description,
|
||||
sortOrder: achievementOrder,
|
||||
}),
|
||||
),
|
||||
},
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { skills: { select: { id: true, name: true } } },
|
||||
});
|
||||
|
||||
const skillIdByName = new Map(
|
||||
profile.skills.map((skill) => [skill.name, skill.id]),
|
||||
);
|
||||
|
||||
for (const [sortOrder, project] of seed.projects.entries()) {
|
||||
const skillIds = project.skillNames.map((name) => {
|
||||
const skillId = skillIdByName.get(name);
|
||||
if (!skillId) {
|
||||
throw new Error(
|
||||
`Project "${project.name}" references unknown skill "${name}"`,
|
||||
);
|
||||
}
|
||||
return skillId;
|
||||
});
|
||||
|
||||
await tx.project.create({
|
||||
data: {
|
||||
profileId: profile.id,
|
||||
name: project.name,
|
||||
url: project.url,
|
||||
description: project.description,
|
||||
role: project.role,
|
||||
status: project.status,
|
||||
startYear: project.startYear,
|
||||
endYear: project.endYear,
|
||||
sortOrder,
|
||||
highlights: {
|
||||
create: project.highlights.map((text, highlightOrder) => ({
|
||||
text,
|
||||
sortOrder: highlightOrder,
|
||||
})),
|
||||
},
|
||||
skills: {
|
||||
create: skillIds.map((skillId) => ({ skillId })),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
this.logger.log(`Profile "${seed.slug}" seeded`);
|
||||
return 'applied' as const;
|
||||
},
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
}
|
||||
}
|
||||
23
test/jest-e2e.json
Normal file
23
test/jest-e2e.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testTimeout": 120000,
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"extensionsToTreatAsEsm": [".ts"],
|
||||
"moduleNameMapper": {
|
||||
"^(\\.{1,2}/.*)\\.js$": "$1"
|
||||
},
|
||||
"transform": {
|
||||
"^.+\\.ts$": [
|
||||
"ts-jest",
|
||||
{
|
||||
"useESM": true,
|
||||
"tsconfig": {
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
195
test/profile.e2e-spec.ts
Normal file
195
test/profile.e2e-spec.ts
Normal file
@ -0,0 +1,195 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import request from 'supertest';
|
||||
import { AppModule } from '../src/app.module.js';
|
||||
import { applyMigrations } from '../src/prisma/apply-migrations.js';
|
||||
import { PrismaService } from '../src/prisma/prisma.service.js';
|
||||
|
||||
type GraphqlBody<T> = {
|
||||
data?: T;
|
||||
errors?: Array<{ message: string }>;
|
||||
};
|
||||
|
||||
function readBody<T>(response: request.Response): GraphqlBody<T> {
|
||||
const payload: unknown = response.body;
|
||||
if (typeof payload !== 'object' || payload === null) {
|
||||
throw new Error('GraphQL response was not an object');
|
||||
}
|
||||
const record = payload as Record<string, unknown>;
|
||||
const errors = Array.isArray(record.errors)
|
||||
? record.errors.filter(isErrorItem)
|
||||
: undefined;
|
||||
return {
|
||||
data: record.data as T | undefined,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
function isErrorItem(value: unknown): value is { message: string } {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'message' in value &&
|
||||
typeof value.message === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function http(app: INestApplication) {
|
||||
// The Nest server handle is typed as any.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
return request(app.getHttpServer());
|
||||
}
|
||||
|
||||
if (existsSync('.env')) {
|
||||
process.loadEnvFile('.env');
|
||||
}
|
||||
|
||||
const EXAMPLE = `
|
||||
query {
|
||||
profile {
|
||||
name
|
||||
description
|
||||
skills { name }
|
||||
experience { company position }
|
||||
projects { name }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
describe('digital card', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
await applyMigrations();
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
app = moduleRef.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('opens Apollo Sandbox and a health check', async () => {
|
||||
const sandbox = await http(app).get('/graphql').set('Accept', 'text/html');
|
||||
expect(sandbox.status).toBe(200);
|
||||
expect(sandbox.text.toLowerCase()).toMatch(/sandbox|apollo/);
|
||||
|
||||
const health = await http(app).get('/health');
|
||||
expect(health.status).toBe(200);
|
||||
expect(health.body).toEqual({ status: 'ok' });
|
||||
});
|
||||
|
||||
it('returns the profile for the assignment query', async () => {
|
||||
const prisma = app.get(PrismaService);
|
||||
prisma.takeQueries();
|
||||
|
||||
const response = await http(app).post('/graphql').send({ query: EXAMPLE });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = readBody<{
|
||||
profile: {
|
||||
name: string;
|
||||
description: string;
|
||||
skills: Array<{ name: string }>;
|
||||
experience: Array<{ company: string; position: string }>;
|
||||
projects: Array<{ name: string }>;
|
||||
};
|
||||
}>(response);
|
||||
expect(body.errors).toBeUndefined();
|
||||
|
||||
const profile = body.data?.profile;
|
||||
expect(profile?.name).toBe('Илья Братских');
|
||||
expect(profile?.description.length).toBeGreaterThan(0);
|
||||
expect(profile?.skills.map((skill) => skill.name)).toContain('NestJS');
|
||||
expect(profile?.experience).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
company: 'АО «Синтелс»',
|
||||
position: 'Fullstack Developer & Researcher',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(profile?.projects.map((project) => project.name)).toContain(
|
||||
'AI-обучение и чат-боты',
|
||||
);
|
||||
|
||||
const statements = prisma.takeQueries().filter(isApplicationSql);
|
||||
expect(statements.length).toBeGreaterThan(0);
|
||||
expect(statements.length).toBeLessThanOrEqual(6);
|
||||
expect(statements.filter((sql) => /Achievement/i.test(sql))).toHaveLength(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it('loads nested achievements without a query per job', async () => {
|
||||
const prisma = app.get(PrismaService);
|
||||
prisma.takeQueries();
|
||||
|
||||
const response = await http(app)
|
||||
.post('/graphql')
|
||||
.send({
|
||||
query: `
|
||||
query {
|
||||
profile {
|
||||
experience { company achievements { description } }
|
||||
projects { name url skills { name } highlights }
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = readBody<{
|
||||
profile: {
|
||||
experience: Array<{
|
||||
company: string;
|
||||
achievements: Array<{ description: string }>;
|
||||
}>;
|
||||
projects: Array<{
|
||||
name: string;
|
||||
url: string | null;
|
||||
skills: Array<{ name: string }>;
|
||||
}>;
|
||||
};
|
||||
}>(response);
|
||||
expect(body.errors).toBeUndefined();
|
||||
const jobs = body.data?.profile.experience ?? [];
|
||||
const sintels = jobs.find((job) => job.company.includes('Синтелс'));
|
||||
expect(sintels?.achievements).toHaveLength(4);
|
||||
|
||||
const projects = body.data?.profile.projects ?? [];
|
||||
const ai = projects.find((project) => project.name.startsWith('AI-'));
|
||||
expect(ai?.url).toBe('https://github.com/KBAHTNET/ai_learning');
|
||||
expect(ai?.skills.map((skill) => skill.name)).toEqual(
|
||||
expect.arrayContaining(['Python', 'PyTorch']),
|
||||
);
|
||||
|
||||
const statements = prisma.takeQueries().filter(isApplicationSql);
|
||||
expect(statements.filter((sql) => /Achievement/i.test(sql))).toHaveLength(
|
||||
1,
|
||||
);
|
||||
expect(statements.length).toBeLessThanOrEqual(8);
|
||||
});
|
||||
|
||||
it('rejects an unknown or malformed slug', async () => {
|
||||
const missing = await http(app).post('/graphql').send({
|
||||
query: 'query { profile(slug: "nobody") { name } }',
|
||||
});
|
||||
expect(readBody(missing).errors?.[0]?.message).toContain('не найден');
|
||||
|
||||
const invalid = await http(app).post('/graphql').send({
|
||||
query: 'query { profile(slug: "Bad Slug") { name } }',
|
||||
});
|
||||
expect(readBody(invalid).errors?.[0]?.message).toContain('Некорректный');
|
||||
});
|
||||
});
|
||||
|
||||
function isApplicationSql(sql: string): boolean {
|
||||
return /profile|skill|experience|achievement|project|education|award|link/i.test(
|
||||
sql,
|
||||
);
|
||||
}
|
||||
4
tsconfig.build.json
Normal file
4
tsconfig.build.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts", "prisma.config.ts"]
|
||||
}
|
||||
25
tsconfig.json
Normal file
25
tsconfig.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"resolvePackageJsonExports": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2023",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user