Skip to content

Repository files navigation

Digital Visit Card

Читать на русском

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.


Contents


How it works

  • Data model: a single Profile row (the card owner) with related SkillCategory, Skill, Experience and Link records. Every translatable field is stored as an explicit xxxEn / xxxRu pair 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 simple x-api-key header guard (see Design decisions).
  • Frontend: a small dependency-free TypeScript file fetches the profile query and renders the card, with an EN/RU toggle persisted in localStorage. It's compiled with tsc (no bundler needed) straight into backend/public, and NestJS serves it as static files alongside the API — one server, one process.

Project structure

.
├── 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

Quickstart: local development

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:backend

Open 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.


Deployment with Docker

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 --build

The card is then served at http://localhost:3000. The Postgres data lives in a named Docker volume (visitcard_pgdata) so it survives container restarts.


GraphQL API

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.


Design decisions

  • Single-row profile, no user accounts. A visit card has exactly one owner, so the schema and API treat Profile as 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 as Profile.email/Profile.telegram. Those scalar columns existed early on but became dead weight once the card's contact section was built on the generic Link table instead — worse, they were a footgun, since editing one didn't update the other. Link is now the single source of truth, which also means adding a new contact channel (GitHub, a website) is just addLink, 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 simple SELECT, at the cost of a bit of repetition in the schema — a reasonable trade for this scope.
  • SkillCategory is its own table, not categoryEn/categoryRu repeated on every Skill row. 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. SkillCategory fixes both: a skill references a category by id (no retyping, no typo), and the category has its own order, so an empty, newly created category can still be positioned correctly. removeSkillCategory refuses 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 / updateSkill reject a name that already exists for the profile (case-insensitively, trimmed) — same for addLink/updateLink on url, and addExperience / updateExperience on the (companyEn, roleEn, periodEn) triple; renaming a record to its own current value is allowed. Each also has a matching @@unique constraint in schema.prisma, so a race between two concurrent requests still can't create a duplicate — the upfront check is for a clean ConflictException (409), the constraint is the actual guarantee.

About

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages