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:
Илья Братских 2026-11-01 20:28:56 +03:00
commit bd22447bd3
62 changed files with 16076 additions and 0 deletions

11
.dockerignore Normal file
View 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
View File

@ -0,0 +1,2 @@
DATABASE_URL=postgresql://card:card@localhost:5432/card
PORT=3000

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
* text=auto eol=lf

12
.gitignore vendored Normal file
View 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
View File

@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

23
Dockerfile Normal file
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,16 @@
query Card {
profile {
name
description
skills {
name
}
experience {
company
position
}
projects {
name
}
}
}

8
nest-cli.json Normal file
View 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

File diff suppressed because it is too large Load Diff

110
package.json Normal file
View 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
View 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,
},
});

View 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);

View 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
View 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
View 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
View 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
View 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 {}

View 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 {}

View 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/);
});
});

View 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;
}

View 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' };
}
}

View 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
View 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);
});

View 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'}`),
);
});
});
}

View 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 {}

View 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 '';
}

View 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>;
}

View 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);
});
});

View 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;
}
}

View 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',
);
});
});

View 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];
}

View 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',
}

View 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';
}
}

View 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();
});
});

View 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);
}

View 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[];
};

View 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,
};
}

View 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);
}
}

View 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;
}

View 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',
]);
});
});

View 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,
);
}

View 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);
}
}

View 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[];
}

View 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),
);
}
}

View 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,
});
});
});

View 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);
}
}
}

View 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' } },
},
},
},
});
});
});

View File

@ -0,0 +1 @@
export const PUBLIC_PROFILE_SLUG = 'ilya-bratskikh';

View 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 {}

View 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
View 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'],
},
],
};

View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts", "prisma.config.ts"]
}

25
tsconfig.json Normal file
View 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
}
}