A bilingual (EN/RU) digital business card, built as a small full-stack prototype: NestJS + GraphQL + Prisma + PostgreSQL on the backend, a minimal TypeScript static frontend, all wired up with npm workspaces and Docker for deployment.
- How it works
- Project structure
- Quickstart: local development
- Deployment with Docker
- GraphQL API
- Design decisions
- Data model: a single
Profilerow (the card owner) with relatedSkillCategory,Skill,ExperienceandLinkrecords. Every translatable field is stored as an explicitxxxEn/xxxRupair in Postgres via Prisma — simple and fully queryable, no extra i18n framework needed for two fixed locales. - API: a NestJS app exposes a GraphQL endpoint (
/graphql).profile(locale: EN | RU)— public query, returns the card already localized.updateProfileInfo,addSkillCategory/updateSkillCategory/removeSkillCategory,addSkill/updateSkill/removeSkill,addExperience/updateExperience/removeExperience,addLink/updateLink/removeLink— mutations, protected by a simplex-api-keyheader guard (see Design decisions).
- Frontend: a small dependency-free TypeScript file fetches the
profilequery and renders the card, with an EN/RU toggle persisted inlocalStorage. It's compiled withtsc(no bundler needed) straight intobackend/public, and NestJS serves it as static files alongside the API — one server, one process.
.
├── backend/ # NestJS + GraphQL + Prisma
│ ├── prisma/
│ │ ├── schema.prisma # Profile / SkillCategory / Skill / Experience / Link models
│ │ ├── migrations/
│ │ └── seed.ts # idempotent seed with the CV content
│ ├── scripts/
│ │ └── db-init.ts # idempotently creates the database if missing
│ ├── src/
│ │ ├── common/ # Locale enum, ApiKeyGuard
│ │ ├── prisma/ # PrismaService/PrismaModule
│ │ ├── profile/ # models (GraphQL types), dto, service, resolvers
│ │ ├── app.module.ts
│ │ └── main.ts
│ └── public/ # built frontend lands here (gitignored)
├── frontend/ # static bilingual card UI
│ └── src/{index.html,styles.css,main.ts}
├── docker-compose.yml # postgres + app, for deployment
├── Dockerfile # multi-stage build for the whole workspace
└── package.json # npm workspaces root
This is the primary way to run the project day-to-day — Docker is for deployment (see below).
Prerequisites: Node.js 20+, npm, and a local PostgreSQL server.
# 1. Install dependencies (installs both workspaces)
npm install
# 2. Point Prisma at your local Postgres
cp backend/.env.example backend/.env
# edit backend/.env if your Postgres user/password/port differ
# 3-5. Create the database (if needed), apply migrations, seed the card content —
# all in one idempotent step, safe to re-run any time:
npm run setup --workspace backend
# 6. Build the frontend once (writes into backend/public)
npm run build --workspace frontend
# 7. Start the API + static frontend in watch mode
npm run dev:backendOpen http://localhost:3000 for the card, or http://localhost:3000/graphql for the Apollo Sandbox to explore/run queries and mutations directly.
If you change frontend files, re-run npm run build --workspace frontend (or
npm run dev:frontend to watch), then refresh the browser.
For deployment (or to try the whole stack in containers without installing Postgres
locally), a docker-compose.yml builds the app and a Postgres instance together,
applies migrations, and seeds the database on startup.
cp .env.example .env
# edit .env — set a real ADMIN_API_KEY before deploying anywhere public
docker compose up --buildThe card is then served at http://localhost:3000. The Postgres data lives in a
named Docker volume (visitcard_pgdata) so it survives container restarts.
Public read (no auth):
query {
profile(locale: RU) {
name
title
bio
location
skillGroups { category items }
experience { company role period highlight }
links { label url }
}
}Mutations require an x-api-key header matching ADMIN_API_KEY. Skills belong to a
SkillCategory by id — skillGroups { id } is that id, so adding a skill to an
existing group is a lookup plus one mutation, never retyping the category's name:
# 1. Find the category id (skillGroups[].id) to add a skill to
curl -s http://localhost:3000/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"{ profile(locale:EN){ skillGroups { id category } } }"}'
# 2. Add the skill, referencing that category by id
curl -X POST http://localhost:3000/graphql \
-H 'Content-Type: application/json' \
-H 'x-api-key: <your ADMIN_API_KEY>' \
-d '{"query":"mutation { addSkill(input:{name:\"Docker\",categoryId:\"<id from step 1>\"}) { skillGroups { category items } } }"}'Creating a brand-new category is a separate mutation
(addSkillCategory(input:{nameEn, nameRu, order})); removeSkillCategory refuses
to delete one that still has skills, so nothing gets silently orphaned or cascaded
away by accident.
- Single-row profile, no user accounts. A visit card has exactly one owner, so
the schema and API treat
Profileas a singleton rather than building out multi-tenant auth for a prototype that doesn't need it. - API-key guard, not full JWT/OAuth. Mutations are protected by a shared secret
in
x-api-key(ApiKeyGuard,backend/src/common/guards/api-key.guard.ts). This demonstrates NestJS guards and keeps write access closed by default, without building out a login flow for a single owner. A real multi-user deployment would swap this for JWT/OAuth. - No phone number in the seed data. The source CV lists a phone number; since this is a demo repo, only email and Telegram are stored/exposed as contact links.
- Contact info lives only in
Link, not also asProfile.email/Profile.telegram. Those scalar columns existed early on but became dead weight once the card's contact section was built on the genericLinktable instead — worse, they were a footgun, since editing one didn't update the other.Linkis now the single source of truth, which also means adding a new contact channel (GitHub, a website) is justaddLink, no schema change. - Bilingual via explicit column pairs, not a translations table. With exactly
two fixed locales,
nameEn/nameRu-style columns keep every query a single simpleSELECT, at the cost of a bit of repetition in the schema — a reasonable trade for this scope. SkillCategoryis its own table, notcategoryEn/categoryRurepeated on everySkillrow. The repeated-string version had two real problems: a typo while adding a skill ("Tools"vs"Toosl") would silently create a second group instead of joining the existing one, and a group's display order had to be inferred from the order its skills happened to be in — there was no way to say "this group comes first" independent of what's in it.SkillCategoryfixes both: a skill references a category by id (no retyping, no typo), and the category has its ownorder, so an empty, newly created category can still be positioned correctly.removeSkillCategoryrefuses to delete a category that still has skills, rather than silently cascading them away.- Uniqueness enforced both in the service and at the DB level.
addSkill/updateSkillreject a name that already exists for the profile (case-insensitively, trimmed) — same foraddLink/updateLinkonurl, andaddExperience/updateExperienceon the(companyEn, roleEn, periodEn)triple; renaming a record to its own current value is allowed. Each also has a matching@@uniqueconstraint inschema.prisma, so a race between two concurrent requests still can't create a duplicate — the upfront check is for a cleanConflictException(409), the constraint is the actual guarantee.