{"id":50815907,"url":"https://github.com/bambutcha/consentry","last_synced_at":"2026-06-13T09:33:31.382Z","repository":{"id":360221396,"uuid":"1249200892","full_name":"bambutcha/consentry","owner":"bambutcha","description":"Notification preferences microservice: user settings, global policies, quiet hours, and allow/deny evaluation API. TypeScript · Fastify · PostgreSQL · Docker · Kubernetes","archived":false,"fork":false,"pushed_at":"2026-05-25T13:23:56.000Z","size":64,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-25T15:26:00.734Z","etag":null,"topics":["devops","docker","fastify","kubernetes","kysely","microservice","nodejs","notifications","postgresql","typescript","vitest"],"latest_commit_sha":null,"homepage":null,"language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/bambutcha.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-25T13:03:05.000Z","updated_at":"2026-05-25T13:24:00.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/bambutcha/consentry","commit_stats":null,"previous_names":["bambutcha/consentry"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/bambutcha/consentry","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bambutcha%2Fconsentry","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bambutcha%2Fconsentry/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bambutcha%2Fconsentry/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bambutcha%2Fconsentry/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/bambutcha","download_url":"https://codeload.github.com/bambutcha/consentry/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bambutcha%2Fconsentry/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34279898,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-13T02:00:06.617Z","response_time":62,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["devops","docker","fastify","kubernetes","kysely","microservice","nodejs","notifications","postgresql","typescript","vitest"],"created_at":"2026-06-13T09:33:29.925Z","updated_at":"2026-06-13T09:33:31.368Z","avatar_url":"https://github.com/bambutcha.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Consentry\n\nСервис управления предпочтениями уведомлений - единый источник правды о  \nтом, какие уведомления, по каким каналам и в какое время можно отправлять  \nконкретному пользователю.\n\nУправляет тремя уровнями правил:\n\n1. **Дефолты** для всех пользователей (`src/domain/defaults.ts`).\n2. **Индивидуальные настройки** пользователя (включения/выключения по парам\n  «тип × канал» + quiet hours в его таймзоне).\n3. **Глобальные политики** - запреты по типу/каналу/региону\n  (например, `marketing_sms` запрещён в `EU`).\n\nСтек: **TypeScript + Node.js 20 + Fastify + Kysely + PostgreSQL + Vitest**.\n\n---\n\n## Запуск\n\n### Через Docker Compose (рекомендуется)\n\n```bash\ndocker compose up --build\n```\n\nКонтейнер `app` сам прогоняет миграции и seed на старте, API поднимается на\n`http://localhost:3000`.\n\n### Локально, против своего PostgreSQL\n\n```bash\ncp .env.example .env       # при необходимости поправь DATABASE_URL\nnpm install\nnpm run migrate            # накатывает схему\nnpm run seed               # добавляет политику запрета marketing_sms в EU\nnpm run dev                # http://localhost:3000\n```\n\n### Тесты\n\n```bash\nnpm test\n```\n\n- `quietHours.test.ts`, `evaluate.test.ts`, `merge.test.ts` - чистые\nдоменные тесты, инфраструктура не нужна.\n- `integration.test.ts` - полный путь HTTP + реальный PostgreSQL через\n[`testcontainers`]. Автоматически скипается, если Docker недоступен,\nчтобы прогон всегда был зелёным.\n\n---\n\n## API\n\nВсе тела запросов и ответов - JSON.\n\n### `GET /users/:id/preferences`\n\nВозвращает **эффективные** настройки (дефолты, перекрытые индивидуальными\nвыборами пользователя):\n\n```json\n{\n  \"userId\": \"user-1\",\n  \"items\": [\n    { \"notificationType\": \"marketing_email\", \"channel\": \"email\", \"enabled\": false },\n    { \"notificationType\": \"transactional_email\", \"channel\": \"email\", \"enabled\": true }\n  ],\n  \"quietHours\": null,\n  \"updatedAt\": \"2026-05-25T10:00:00.000Z\"\n}\n```\n\nЕсли пользователя ещё нет в БД - пустая запись создаётся при первом\nобращении, чтобы все последующие операции были адресуемы. Сами дефолты\n**не материализуются** в БД: они применяются на чтении и на `evaluate`.\nЭто даёт дешёвый rollout новых дефолтов - все пользователи получают новое\nзначение со следующего запроса, без backfill.\n\n### `POST /users/:id/preferences`\n\nТело - хотя бы одно поле должно присутствовать:\n\n```json\n{\n  \"items\": [\n    { \"notificationType\": \"marketing_email\", \"channel\": \"email\", \"enabled\": false }\n  ],\n  \"quietHours\": {\n    \"start\": \"22:00\",\n    \"end\": \"08:00\",\n    \"timezone\": \"Europe/Moscow\",\n    \"appliesTo\": [\"marketing_push\"]\n  }\n}\n```\n\nЗаголовки:\n\n- `Idempotency-Key: \u003copaque-string\u003e` - необязательный. Если не передан,\nсервис считает стабильный SHA-256 от канонического тела запроса, так\nчто побайтово идентичные повторы тоже схлопываются.\n\nОтвет - обновлённые эффективные настройки. Заголовок\n`x-idempotent-replay: true|false` подсказывает клиенту, был ли это повтор.\n\n### `POST /evaluate`\n\n```json\n{\n  \"userId\": \"user-1\",\n  \"notificationType\": \"marketing_email\",\n  \"channel\": \"email\",\n  \"region\": \"EU\",\n  \"datetime\": \"2026-05-21T21:30:00Z\"\n}\n```\n\nОтвет:\n\n```json\n{ \"decision\": \"deny\", \"reason\": \"blocked_by_global_policy\", \"detail\": \"...\" }\n```\n\nВозможные значения `reason`: `allowed`, `blocked_by_global_policy`,\n`disabled_by_user`, `quiet_hours`.\n\n### `GET /health`\n\nЭндпоинт для liveness/readiness-проб в Kubernetes.\n\n---\n\n## Архитектура\n\n```\nsrc/\n  domain/           # чистые типы и правила, без I/O\n    types.ts        # NotificationType, Channel, QuietHours, ...\n    defaults.ts     # матрица дефолтных предпочтений\n    quietHours.ts   # таймзоно-корректная проверка окна (Luxon)\n    evaluate.ts     # движок решений (одна функция)\n    merge.ts        # дефолты ⊕ перекрытия пользователя\n  application/\n    preferencesService.ts   # юзкейсы, структурное логирование, идемпотентность\n  infrastructure/\n    db/             # Kysely-схема, репозитории, мигратор, seed\n    http/           # Fastify + Zod-валидация\n    logging/        # фабрика логгера pino\n  config/env.ts     # валидация переменных окружения через Zod\n  main.ts           # composition root + graceful shutdown\nmigrations/         # plain SQL, накатывается собственным мигратором\nk8s/                # Deployment + Service + Secret\ntest/               # vitest: unit + интеграционные с PostgreSQL\n```\n\n### Почему именно так\n\n- **Чистое доменное ядро.** `evaluate`, `quietHours`, `merge` ничего не\nзнают про Postgres и HTTP - их тривиально покрыть unit-тестами.\n- **Kysely вместо ORM.** Сквозные TypeScript-типы поверх рукописного\nSQL, никакой кодогенерации в рантайме и DSL для миграций.\n- **Идемпотентность на границе хранилища.** Таблица\n`preference_change_log` с `UNIQUE (user_id, idempotency_key)` делает\nповторы no-op без оптимистичных блокировок. Ключ либо приходит от\nклиента, либо вычисляется детерминированно из канонического тела.\n- **Явный порядок правил** в `evaluate.ts`: глобальная политика →\nнастройка пользователя → quiet hours. Возвращаемый `reason` - это\nровно то, что хочется видеть на дашбордах.\n- **Дефолты применяются на чтении.** Принятие нового дефолта не требует\nbackfill - каждый пользователь получает новое значение со следующего\nзапроса.\n- **Quiet hours корректны по таймзоне.** Окна, пересекающие полночь\n(`22:00`–`08:00`), вычисляются в IANA-зоне пользователя через Luxon,\nбез арифметики на строках.\n\n### Observability\n\n`pino` пишет структурированные JSON-логи. Два ключевых события:\n\n- `preferences.updated` - с `idempotencyKey`, `alreadyApplied`,\nчислом изменённых элементов.\n- `evaluate.decision` - с `decision`, `reason` и всеми входами оценки.\n\nДля метрик это естественные точки расширения: те же события должны стать\nсчётчиками (`evaluate_decision_total{reason=...}`,\n`preference_updates_total{replay=\"true|false\"}`) и гистограммой\nлатентности `evaluate`. Реестр `prom-client`, прокинутый в Fastify\nна `/metrics`, - следующий шаг.\n\n---\n\n## Что добавил бы для продакшена\n\n- **Аутентификация и авторизация** на каждом маршруте (в тестовом\nнамеренно опущено). Как минимум - JWT для сервис-сервис вызовов и\nscope-проверка пользователя на `/users/:id/preferences`.\n- **Метрики Prometheus** (`prom-client`) и трейсинг\n(`@opentelemetry/*`) с экспортом туда, куда смотрит платформа;\nmiddleware с correlation-id.\n- **Кэш `policies.list()`** с коротким TTL и инвалидацией по событию -\nполитики меняются редко, а `evaluate` находится на горячем пути.\n- **CRUD для глобальных политик** (админский эндпоинт или внутренняя\nCLI). Сейчас они грузятся миграцией/сидом, потому что ТЗ не просит\nотдельной поверхности.\n- **Outbox-события `preferences.updated`** для downstream-сервисов,\nкоторым нужно инвалидировать свои кэши.\n- **Backfill-джоба**, материализующая дефолты в строки на пользователя,\nкогда каталог стабилизируется - упрощает запросы и даёт аудит.\n- **Контрактные тесты** для API-консьюмеров: автоматическая публикация\nOpenAPI-спеки, сгенерированной из Zod-схем через `zod-to-openapi`.\n- **CI**: lint + unit на каждый PR, интеграционные тесты на `main`\nчерез testcontainers, сборка образа + Trivy-скан, деплой через\nHelm/Argo.\n\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fbambutcha%2Fconsentry","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fbambutcha%2Fconsentry","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fbambutcha%2Fconsentry/lists"}