diff --git a/.history/.env_20250808194630 b/.history/.env_20250808194630
new file mode 100644
index 0000000..3c1ddb8
--- /dev/null
+++ b/.history/.env_20250808194630
@@ -0,0 +1,19 @@
+# ---------- PostgreSQL ----------
+POSTGRES_USER=postgres
+POSTGRES_PASSWORD=postgres
+POSTGRES_DB=postgres
+POSTGRES_HOST=postgres
+POSTGRES_PORT=5432
+
+# ---------- Service Ports ----------
+# Можно переопределять порты хоста (левая часть маппинга ports)
+AUTH_PORT=8001
+AUTH_DATABASE_URL=postgresql+psycopg2://postgres:postgres@postgres:5432/auth_db
+PROFILES_PORT=8002
+PROFILES_DATABASE_URL=postgresql+psycopg2://postgres:postgres@postgres:5432/profiles_db
+MATCH_PORT=8003
+MATCH_DATABASE_URL=postgresql+psycopg2://postgres:postgres@postgres:5432/match_db
+CHAT_PORT=8004
+CHAT_DATABASE_URL=postgresql+psycopg2://postgres:postgres@postgres:5432/chat_db
+PAYMENTS_PORT=8005
+PAYMENTS_DATABASE_URL=postgresql+psycopg2://postgres:postgres@postgres:5432/payments_db
diff --git a/.history/.env_20250808200305 b/.history/.env_20250808200305
new file mode 100644
index 0000000..e180c84
--- /dev/null
+++ b/.history/.env_20250808200305
@@ -0,0 +1,24 @@
+# ---------- PostgreSQL ----------
+POSTGRES_USER=postgres
+POSTGRES_PASSWORD=postgres
+POSTGRES_DB=postgres
+POSTGRES_HOST=postgres
+POSTGRES_PORT=5432
+
+# ---------- Service Ports ----------
+# Можно переопределять порты хоста (левая часть маппинга ports)
+AUTH_PORT=8001
+AUTH_DATABASE_URL=postgresql+psycopg2://postgres:postgres@postgres:5432/auth_db
+PROFILES_PORT=8002
+PROFILES_DATABASE_URL=postgresql+psycopg2://postgres:postgres@postgres:5432/profiles_db
+MATCH_PORT=8003
+MATCH_DATABASE_URL=postgresql+psycopg2://postgres:postgres@postgres:5432/match_db
+CHAT_PORT=8004
+CHAT_DATABASE_URL=postgresql+psycopg2://postgres:postgres@postgres:5432/chat_db
+PAYMENTS_PORT=8005
+PAYMENTS_DATABASE_URL=postgresql+psycopg2://postgres:postgres@postgres:5432/payments_db
+# ---------- JWT / Auth ----------
+JWT_SECRET=devsecret_change_me
+JWT_ALGORITHM=HS256
+ACCESS_TOKEN_EXPIRES_MIN=15
+REFRESH_TOKEN_EXPIRES_MIN=43200 # 30 days
\ No newline at end of file
diff --git a/.history/.env_20250808200329 b/.history/.env_20250808200329
new file mode 100644
index 0000000..c98c5cf
--- /dev/null
+++ b/.history/.env_20250808200329
@@ -0,0 +1,24 @@
+# ---------- PostgreSQL ----------
+POSTGRES_USER=postgres
+POSTGRES_PASSWORD=postgres
+POSTGRES_DB=postgres
+POSTGRES_HOST=postgres
+POSTGRES_PORT=5432
+
+# ---------- Service Ports ----------
+# Можно переопределять порты хоста (левая часть маппинга ports)
+AUTH_PORT=8001
+AUTH_DATABASE_URL=postgresql+psycopg2://postgres:postgres@postgres:5432/auth_db
+PROFILES_PORT=8002
+PROFILES_DATABASE_URL=postgresql+psycopg2://postgres:postgres@postgres:5432/profiles_db
+MATCH_PORT=8003
+MATCH_DATABASE_URL=postgresql+psycopg2://postgres:postgres@postgres:5432/match_db
+CHAT_PORT=8004
+CHAT_DATABASE_URL=postgresql+psycopg2://postgres:postgres@postgres:5432/chat_db
+PAYMENTS_PORT=8005
+PAYMENTS_DATABASE_URL=postgresql+psycopg2://postgres:postgres@postgres:5432/payments_db
+# ---------- JWT / Auth ----------
+JWT_SECRET=NJ6bF1H506VbPNS9TBsRTCZU14laJVTHCevT1FhWvyiNjC39V8
+JWT_ALGORITHM=HS256
+ACCESS_TOKEN_EXPIRES_MIN=15
+REFRESH_TOKEN_EXPIRES_MIN=43200 # 30 days
\ No newline at end of file
diff --git a/.history/docker-compose_20250808194542.yml b/.history/docker-compose_20250808194542.yml
new file mode 100644
index 0000000..63aa1a6
--- /dev/null
+++ b/.history/docker-compose_20250808194542.yml
@@ -0,0 +1,105 @@
+version: "3.9"
+services:
+ postgres:
+ image: postgres:16
+ container_name: postgres
+ environment:
+ POSTGRES_USER: ${POSTGRES_USER:-postgres}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
+ ports:
+ - "5432:5432"
+ volumes:
+ - pgdata:/var/lib/postgresql/data
+ - ./infra/db/init:/docker-entrypoint-initdb.d
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-postgres}"]
+ interval: 5s
+ timeout: 5s
+ retries: 40
+
+ gateway:
+ image: nginx:alpine
+ container_name: gateway
+ depends_on:
+ - auth
+ - profiles
+ - match
+ - chat
+ - payments
+ ports:
+ - "8080:80"
+ volumes:
+ - ./infra/gateway/nginx.conf:/etc/nginx/conf.d/default.conf:ro
+
+ auth:
+ build:
+ context: ./services/auth
+ container_name: marriage_auth
+ env_file:
+ - .env
+ environment:
+ DATABASE_URL: ${AUTH_DATABASE_URL}
+ depends_on:
+ - postgres
+ ports:
+ - "${AUTH_PORT:-8001}:8000"
+ command: ./docker-entrypoint.sh
+
+ profiles:
+ build:
+ context: ./services/profiles
+ container_name: marriage_profiles
+ env_file:
+ - .env
+ environment:
+ DATABASE_URL: ${PROFILES_DATABASE_URL}
+ depends_on:
+ - postgres
+ ports:
+ - "${PROFILES_PORT:-8002}:8000"
+ command: ./docker-entrypoint.sh
+
+ match:
+ build:
+ context: ./services/match
+ container_name: marriage_match
+ env_file:
+ - .env
+ environment:
+ DATABASE_URL: ${MATCH_DATABASE_URL}
+ depends_on:
+ - postgres
+ ports:
+ - "${MATCH_PORT:-8003}:8000"
+ command: ./docker-entrypoint.sh
+
+ chat:
+ build:
+ context: ./services/chat
+ container_name: marriage_chat
+ env_file:
+ - .env
+ environment:
+ DATABASE_URL: ${CHAT_DATABASE_URL}
+ depends_on:
+ - postgres
+ ports:
+ - "${CHAT_PORT:-8004}:8000"
+ command: ./docker-entrypoint.sh
+
+ payments:
+ build:
+ context: ./services/payments
+ container_name: marriage_payments
+ env_file:
+ - .env
+ environment:
+ DATABASE_URL: ${PAYMENTS_DATABASE_URL}
+ depends_on:
+ - postgres
+ ports:
+ - "${PAYMENTS_PORT:-8005}:8000"
+ command: ./docker-entrypoint.sh
+
+volumes:
+ pgdata:
diff --git a/.history/docker-compose_20250808201541.yml b/.history/docker-compose_20250808201541.yml
new file mode 100644
index 0000000..eb86f45
--- /dev/null
+++ b/.history/docker-compose_20250808201541.yml
@@ -0,0 +1,105 @@
+
+services:
+ postgres:
+ image: postgres:16
+ container_name: postgres
+ environment:
+ POSTGRES_USER: ${POSTGRES_USER:-postgres}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
+ ports:
+ - "5432:5432"
+ volumes:
+ - pgdata:/var/lib/postgresql/data
+ - ./infra/db/init:/docker-entrypoint-initdb.d
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-postgres}"]
+ interval: 5s
+ timeout: 5s
+ retries: 40
+
+ gateway:
+ image: nginx:alpine
+ container_name: gateway
+ depends_on:
+ - auth
+ - profiles
+ - match
+ - chat
+ - payments
+ ports:
+ - "8080:80"
+ volumes:
+ - ./infra/gateway/nginx.conf:/etc/nginx/conf.d/default.conf:ro
+
+ auth:
+ build:
+ context: ./services/auth
+ container_name: marriage_auth
+ env_file:
+ - .env
+ environment:
+ DATABASE_URL: ${AUTH_DATABASE_URL}
+ depends_on:
+ - postgres
+ ports:
+ - "${AUTH_PORT:-8001}:8000"
+ command: ./docker-entrypoint.sh
+
+ profiles:
+ build:
+ context: ./services/profiles
+ container_name: marriage_profiles
+ env_file:
+ - .env
+ environment:
+ DATABASE_URL: ${PROFILES_DATABASE_URL}
+ depends_on:
+ - postgres
+ ports:
+ - "${PROFILES_PORT:-8002}:8000"
+ command: ./docker-entrypoint.sh
+
+ match:
+ build:
+ context: ./services/match
+ container_name: marriage_match
+ env_file:
+ - .env
+ environment:
+ DATABASE_URL: ${MATCH_DATABASE_URL}
+ depends_on:
+ - postgres
+ ports:
+ - "${MATCH_PORT:-8003}:8000"
+ command: ./docker-entrypoint.sh
+
+ chat:
+ build:
+ context: ./services/chat
+ container_name: marriage_chat
+ env_file:
+ - .env
+ environment:
+ DATABASE_URL: ${CHAT_DATABASE_URL}
+ depends_on:
+ - postgres
+ ports:
+ - "${CHAT_PORT:-8004}:8000"
+ command: ./docker-entrypoint.sh
+
+ payments:
+ build:
+ context: ./services/payments
+ container_name: marriage_payments
+ env_file:
+ - .env
+ environment:
+ DATABASE_URL: ${PAYMENTS_DATABASE_URL}
+ depends_on:
+ - postgres
+ ports:
+ - "${PAYMENTS_PORT:-8005}:8000"
+ command: ./docker-entrypoint.sh
+
+volumes:
+ pgdata:
diff --git a/.history/fix_alembic_20250808201237.sh b/.history/fix_alembic_20250808201237.sh
new file mode 100644
index 0000000..e69de29
diff --git a/.history/fix_alembic_20250808201241.sh b/.history/fix_alembic_20250808201241.sh
new file mode 100644
index 0000000..7abd070
--- /dev/null
+++ b/.history/fix_alembic_20250808201241.sh
@@ -0,0 +1,54 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SERVICES=(auth profiles match chat payments)
+
+# Добавим импорт моделей в env.py, если его нет
+for s in "${SERVICES[@]}"; do
+ ENV="services/$s/alembic/env.py"
+ if ! grep -q "from app import models" "$ENV"; then
+ # вставим строку сразу после импорта Base
+ awk '
+ /from app\.db\.session import Base/ && !x {print; print "from app import models # noqa: F401"; x=1; next}
+ {print}
+ ' "$ENV" > "$ENV.tmp" && mv "$ENV.tmp" "$ENV"
+ echo "[fix] added 'from app import models' to $ENV"
+ fi
+done
+
+# Создадим шаблон mako для Alembic в каждом сервисе (если отсутствует)
+for s in "${SERVICES[@]}"; do
+ TPL="services/$s/alembic/script.py.mako"
+ if [[ ! -f "$TPL" ]]; then
+ mkdir -p "$(dirname "$TPL")"
+ cat > "$TPL" <<'MAKO'
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+"""
+
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision: str = '${up_revision}'
+down_revision: Union[str, None] = ${down_revision | repr}
+branch_labels: Union[str, Sequence[str], None] = ${branch_labels | repr}
+depends_on: Union[str, Sequence[str], None] = ${depends_on | repr}
+
+def upgrade() -> None:
+ pass
+
+
+def downgrade() -> None:
+ pass
+MAKO
+ echo "[fix] created $TPL"
+ fi
+done
+
+echo "✅ Alembic templates fixed."
+echo "Совет: предупреждение docker-compose про 'version' можно игнорировать или удалить строку 'version: \"3.9\"' из docker-compose.yml."
diff --git a/.history/logs/api_20250808212556.log b/.history/logs/api_20250808212556.log
new file mode 100644
index 0000000..7078563
--- /dev/null
+++ b/.history/logs/api_20250808212556.log
@@ -0,0 +1,486 @@
+2025-08-08 21:23:00 | INFO | api_e2e | === API E2E START ===
+2025-08-08 21:23:00 | INFO | api_e2e | BASE_URL=http://localhost:8080 clients=2 domain=agency.dev
+2025-08-08 21:23:00 | INFO | api_e2e | Waiting gateway/auth health: http://localhost:8080/auth/health
+2025-08-08 21:23:00 | DEBUG | api_e2e | HTTP GET http://localhost:8080/auth/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:23:00 | DEBUG | api_e2e | ← 200 in 11 ms | body={"status":"ok","service":"auth"}
+2025-08-08 21:23:00 | INFO | api_e2e | gateway/auth is healthy
+2025-08-08 21:23:00 | INFO | api_e2e | Waiting profiles health: http://localhost:8080/profiles/health
+2025-08-08 21:23:00 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:23:03 | DEBUG | api_e2e | ← -1 in 3095 ms | body=
+
502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:23:03 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:23:04 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:23:07 | DEBUG | api_e2e | ← -1 in 3095 ms | body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:23:07 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:23:08 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:23:11 | DEBUG | api_e2e | ← -1 in 3095 ms | body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:23:11 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:23:12 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:23:16 | DEBUG | api_e2e | ← -1 in 3096 ms | body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:23:16 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:23:17 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:23:20 | DEBUG | api_e2e | ← -1 in 3095 ms | body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:23:20 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:23:21 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:23:24 | DEBUG | api_e2e | ← -1 in 3094 ms | body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:23:24 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:23:25 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:23:28 | DEBUG | api_e2e | ← -1 in 3096 ms | body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:23:28 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:23:29 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:23:32 | DEBUG | api_e2e | ← -1 in 3094 ms | body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:23:32 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:23:33 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:23:36 | DEBUG | api_e2e | ← -1 in 3096 ms | body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:23:36 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:23:37 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:23:40 | DEBUG | api_e2e | ← -1 in 3096 ms | body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:23:40 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:23:41 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:23:44 | DEBUG | api_e2e | ← -1 in 3094 ms | body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:23:44 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:23:45 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:24:00 | INFO | api_e2e | === API E2E START ===
+2025-08-08 21:24:00 | INFO | api_e2e | BASE_URL=http://localhost:8080 clients=2 domain=agency.dev
+2025-08-08 21:24:00 | INFO | api_e2e | Waiting gateway/auth health: http://localhost:8080/auth/health
+2025-08-08 21:24:00 | DEBUG | api_e2e | HTTP GET http://localhost:8080/auth/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:24:00 | DEBUG | api_e2e | ← 200 in 3 ms | body={"status":"ok","service":"auth"}
+2025-08-08 21:24:00 | INFO | api_e2e | gateway/auth is healthy
+2025-08-08 21:24:00 | INFO | api_e2e | Waiting profiles health: http://localhost:8080/profiles/health
+2025-08-08 21:24:00 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:24:03 | DEBUG | api_e2e | ← -1 in 3075 ms | body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:24:03 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:24:04 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:24:07 | DEBUG | api_e2e | ← -1 in 3096 ms | body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:24:07 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:24:08 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:24:11 | DEBUG | api_e2e | ← -1 in 3096 ms | body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:24:11 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:24:12 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:24:15 | DEBUG | api_e2e | ← -1 in 3094 ms | body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:24:15 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:24:16 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:24:20 | DEBUG | api_e2e | ← -1 in 3095 ms | body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:24:20 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:24:21 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:24:24 | DEBUG | api_e2e | ← -1 in 3095 ms | body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:24:24 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:24:25 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:24:28 | DEBUG | api_e2e | ← -1 in 3095 ms | body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:24:28 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:24:29 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:24:32 | DEBUG | api_e2e | ← -1 in 3096 ms | body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:24:32 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:24:33 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:24:36 | DEBUG | api_e2e | ← -1 in 3095 ms | body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:24:36 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:24:37 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:24:40 | DEBUG | api_e2e | ← -1 in 3095 ms | body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:24:40 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:24:41 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:24:44 | DEBUG | api_e2e | ← -1 in 3094 ms | body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:24:44 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:24:45 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:24:46 | DEBUG | api_e2e | ← -1 in 1047 ms | body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:24:46 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:24:47 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:24:47 | DEBUG | api_e2e | ← -1 in 1 ms | body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:24:47 | ERROR | api_e2e | profiles/health unexpected status -1, expected [200]; body=
+502 Bad Gateway
+
+502 Bad Gateway
+
nginx/1.29.0
+
+
+
+2025-08-08 21:24:48 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:24:58 | ERROR | api_e2e | profiles/health FAILED transport error: HTTPConnectionPool(host='localhost', port=8080): Read timed out. (read timeout=10.0) (10010 ms)
+2025-08-08 21:24:59 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:25:09 | ERROR | api_e2e | profiles/health FAILED transport error: HTTPConnectionPool(host='localhost', port=8080): Read timed out. (read timeout=10.0) (10010 ms)
+2025-08-08 21:25:56 | INFO | api_e2e | === API E2E START ===
+2025-08-08 21:25:56 | INFO | api_e2e | BASE_URL=http://localhost:8080 clients=2 domain=agency.dev
+2025-08-08 21:25:56 | INFO | api_e2e | Waiting gateway/auth health: http://localhost:8080/auth/health
+2025-08-08 21:25:56 | DEBUG | api_e2e | HTTP GET http://localhost:8080/auth/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:25:56 | DEBUG | api_e2e | ← 200 in 7 ms | body={"status":"ok","service":"auth"}
+2025-08-08 21:25:56 | INFO | api_e2e | gateway/auth is healthy
+2025-08-08 21:25:56 | INFO | api_e2e | Waiting profiles health: http://localhost:8080/profiles/health
+2025-08-08 21:25:56 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:25:56 | DEBUG | api_e2e | ← 200 in 2 ms | body={"status":"ok","service":"profiles"}
+2025-08-08 21:25:56 | INFO | api_e2e | profiles is healthy
+2025-08-08 21:25:56 | INFO | api_e2e | Waiting match health: http://localhost:8080/match/health
+2025-08-08 21:25:56 | DEBUG | api_e2e | HTTP GET http://localhost:8080/match/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:25:56 | DEBUG | api_e2e | ← 200 in 5 ms | body={"status":"ok","service":"match"}
+2025-08-08 21:25:56 | INFO | api_e2e | match is healthy
+2025-08-08 21:25:56 | INFO | api_e2e | Waiting chat health: http://localhost:8080/chat/health
+2025-08-08 21:25:56 | DEBUG | api_e2e | HTTP GET http://localhost:8080/chat/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:25:56 | DEBUG | api_e2e | ← 200 in 6 ms | body={"status":"ok","service":"chat"}
+2025-08-08 21:25:56 | INFO | api_e2e | chat is healthy
+2025-08-08 21:25:56 | INFO | api_e2e | Waiting payments health: http://localhost:8080/payments/health
+2025-08-08 21:25:56 | DEBUG | api_e2e | HTTP GET http://localhost:8080/payments/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:25:56 | DEBUG | api_e2e | ← 200 in 6 ms | body={"status":"ok","service":"payments"}
+2025-08-08 21:25:56 | INFO | api_e2e | payments is healthy
+2025-08-08 21:25:56 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'admin+1754655956.xaji0y@agency.dev', 'password': '***hidden***'}
+2025-08-08 21:25:56 | DEBUG | api_e2e | ← -1 in 32 ms | body={"detail":"Invalid credentials"}
+2025-08-08 21:25:56 | ERROR | api_e2e | login unexpected status -1, expected [200]; body={"detail":"Invalid credentials"}
+2025-08-08 21:25:56 | INFO | api_e2e | Login failed for admin+1754655956.xaji0y@agency.dev: login unexpected status -1, expected [200]; body={"detail":"Invalid credentials"}; will try register
+2025-08-08 21:25:56 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/register | headers={Authorization: Bearer } | body={'email': 'admin+1754655956.xaji0y@agency.dev', 'password': '***hidden***', 'full_name': 'Michael Cunningham', 'role': 'ADMIN'}
+2025-08-08 21:25:56 | DEBUG | api_e2e | ← -1 in 257 ms | body=Internal Server Error
+2025-08-08 21:25:56 | ERROR | api_e2e | register unexpected status -1, expected [200, 201]; body=Internal Server Error
+2025-08-08 21:25:56 | WARNING | api_e2e | register returned non-2xx: register unexpected status -1, expected [200, 201]; body=Internal Server Error — will try login anyway
+2025-08-08 21:25:56 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'admin+1754655956.xaji0y@agency.dev', 'password': '***hidden***'}
+2025-08-08 21:25:56 | DEBUG | api_e2e | ← 200 in 214 ms | body={"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxNzJkNjIwOS0yOGJlLTQyYzAtYmFjMy0yNzBlMWZkNjNmNmMiLCJlbWFpbCI6ImFkbWluKzE3NTQ2NTU5NTYueGFqaTB5QGFnZW5jeS5kZXYiLCJyb2xlIjoiQURNSU4iLCJ0eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzU0NjU2ODU2fQ.FUgvIMnAsD-FWP8yjFy0IJS6NKLyAseVyuT6gS2uFLE","refresh_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxNzJkNjIwOS0yOGJlLTQyYzAtYmFjMy0yNzBlMWZkNjNmNmMiLCJlbWFpbCI6ImFkbWluKzE3NTQ2NTU5NTYueGFqaTB5QGFnZW5jeS5kZXYiLCJyb2xlIjoiQURNSU4iLCJ0eXBlIjoicmVmcmVzaCIsImV4cCI6MTc1NzI0Nzk1Nn0.XOx0ehDA4wjIfi9nYdI9iVPsLHS8mXV4L0Be8PvcK5g","token_type":"bearer"}
+2025-08-08 21:25:56 | INFO | api_e2e | Registered+Login OK: admin+1754655956.xaji0y@agency.dev -> 172d6209-28be-42c0-bac3-270e1fd63f6c
+2025-08-08 21:25:56 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'user1+1754655956.6dpbhs@agency.dev', 'password': '***hidden***'}
+2025-08-08 21:25:56 | DEBUG | api_e2e | ← -1 in 3 ms | body={"detail":"Invalid credentials"}
+2025-08-08 21:25:56 | ERROR | api_e2e | login unexpected status -1, expected [200]; body={"detail":"Invalid credentials"}
+2025-08-08 21:25:56 | INFO | api_e2e | Login failed for user1+1754655956.6dpbhs@agency.dev: login unexpected status -1, expected [200]; body={"detail":"Invalid credentials"}; will try register
+2025-08-08 21:25:56 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/register | headers={Authorization: Bearer } | body={'email': 'user1+1754655956.6dpbhs@agency.dev', 'password': '***hidden***', 'full_name': 'Charlotte Porter', 'role': 'CLIENT'}
+2025-08-08 21:25:56 | DEBUG | api_e2e | ← -1 in 226 ms | body=Internal Server Error
+2025-08-08 21:25:56 | ERROR | api_e2e | register unexpected status -1, expected [200, 201]; body=Internal Server Error
+2025-08-08 21:25:56 | WARNING | api_e2e | register returned non-2xx: register unexpected status -1, expected [200, 201]; body=Internal Server Error — will try login anyway
+2025-08-08 21:25:56 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'user1+1754655956.6dpbhs@agency.dev', 'password': '***hidden***'}
+2025-08-08 21:25:57 | DEBUG | api_e2e | ← 200 in 213 ms | body={"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIwOWFhMWMzZS0zN2U4LTRmNWEtODcxNy1kN2FhNDUxMDU0MzUiLCJlbWFpbCI6InVzZXIxKzE3NTQ2NTU5NTYuNmRwYmhzQGFnZW5jeS5kZXYiLCJyb2xlIjoiQ0xJRU5UIiwidHlwZSI6ImFjY2VzcyIsImV4cCI6MTc1NDY1Njg1N30.eeKSArd-im1KjEDUZxzus4e3b3yLuhqMxp065gPZPXE","refresh_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIwOWFhMWMzZS0zN2U4LTRmNWEtODcxNy1kN2FhNDUxMDU0MzUiLCJlbWFpbCI6InVzZXIxKzE3NTQ2NTU5NTYuNmRwYmhzQGFnZW5jeS5kZXYiLCJyb2xlIjoiQ0xJRU5UIiwidHlwZSI6InJlZnJlc2giLCJleHAiOjE3NTcyNDc5NTd9.O7k9PubE1j3BHDw-IgbmXIIfrltA-viHei70j0p92Js","token_type":"bearer"}
+2025-08-08 21:25:57 | INFO | api_e2e | Registered+Login OK: user1+1754655956.6dpbhs@agency.dev -> 09aa1c3e-37e8-4f5a-8717-d7aa45105435
+2025-08-08 21:25:57 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'user2+1754655957.ahxthv@agency.dev', 'password': '***hidden***'}
+2025-08-08 21:25:57 | DEBUG | api_e2e | ← -1 in 3 ms | body={"detail":"Invalid credentials"}
+2025-08-08 21:25:57 | ERROR | api_e2e | login unexpected status -1, expected [200]; body={"detail":"Invalid credentials"}
+2025-08-08 21:25:57 | INFO | api_e2e | Login failed for user2+1754655957.ahxthv@agency.dev: login unexpected status -1, expected [200]; body={"detail":"Invalid credentials"}; will try register
+2025-08-08 21:25:57 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/register | headers={Authorization: Bearer } | body={'email': 'user2+1754655957.ahxthv@agency.dev', 'password': '***hidden***', 'full_name': 'Denise Hess', 'role': 'CLIENT'}
+2025-08-08 21:25:57 | DEBUG | api_e2e | ← -1 in 225 ms | body=Internal Server Error
+2025-08-08 21:25:57 | ERROR | api_e2e | register unexpected status -1, expected [200, 201]; body=Internal Server Error
+2025-08-08 21:25:57 | WARNING | api_e2e | register returned non-2xx: register unexpected status -1, expected [200, 201]; body=Internal Server Error — will try login anyway
+2025-08-08 21:25:57 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'user2+1754655957.ahxthv@agency.dev', 'password': '***hidden***'}
+2025-08-08 21:25:57 | DEBUG | api_e2e | ← 200 in 213 ms | body={"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIwNWRkNjcyMi1hNDAzLTQzYzMtYWViZC0wNjRlOWQ4NTQ1ZDIiLCJlbWFpbCI6InVzZXIyKzE3NTQ2NTU5NTcuYWh4dGh2QGFnZW5jeS5kZXYiLCJyb2xlIjoiQ0xJRU5UIiwidHlwZSI6ImFjY2VzcyIsImV4cCI6MTc1NDY1Njg1N30.dqvCxPqUX8zhL12dzl1vbstTJgEvMHD43Gppj2Jzllk","refresh_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIwNWRkNjcyMi1hNDAzLTQzYzMtYWViZC0wNjRlOWQ4NTQ1ZDIiLCJlbWFpbCI6InVzZXIyKzE3NTQ2NTU5NTcuYWh4dGh2QGFnZW5jeS5kZXYiLCJyb2xlIjoiQ0xJRU5UIiwidHlwZSI6InJlZnJlc2giLCJleHAiOjE3NTcyNDc5NTd9.AIFXUWyrp_BEmmJWmWHnhGOp_b0IZIZoue1PtzhxPCw","token_type":"bearer"}
+2025-08-08 21:25:57 | INFO | api_e2e | Registered+Login OK: user2+1754655957.ahxthv@agency.dev -> 05dd6722-a403-43c3-aebd-064e9d8545d2
+2025-08-08 21:25:57 | INFO | api_e2e | [1/3] Ensure profile for admin+1754655956.xaji0y@agency.dev (role=ADMIN)
+2025-08-08 21:25:57 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/v1/profiles/me | headers={Authorization: Bearer eyJhbGciOiJI...} | body={}
+2025-08-08 21:25:57 | DEBUG | api_e2e | ← -1 in 2 ms | body={"detail":"Not authenticated"}
+2025-08-08 21:25:57 | ERROR | api_e2e | profiles/me unexpected status -1, expected [200, 404]; body={"detail":"Not authenticated"}
diff --git a/.history/logs/api_20250808212604.log b/.history/logs/api_20250808212604.log
new file mode 100644
index 0000000..e69de29
diff --git a/.history/logs/api_20250808213512.log b/.history/logs/api_20250808213512.log
new file mode 100644
index 0000000..e69de29
diff --git a/.history/logs/api_20250808213904.log b/.history/logs/api_20250808213904.log
new file mode 100644
index 0000000..e69de29
diff --git a/.history/logs/api_20250808213928.log b/.history/logs/api_20250808213928.log
new file mode 100644
index 0000000..e69de29
diff --git a/.history/migrate_20250808200653.sh b/.history/migrate_20250808200653.sh
new file mode 100644
index 0000000..e69de29
diff --git a/.history/migrate_20250808200656.sh b/.history/migrate_20250808200656.sh
new file mode 100644
index 0000000..e548ac8
--- /dev/null
+++ b/.history/migrate_20250808200656.sh
@@ -0,0 +1,6 @@
+for s in auth profiles match chat payments; do
+ f="services/$s/alembic/env.py"
+ # добавим импорт пакета моделей, если его нет
+ grep -q "from app import models" "$f" || \
+ sed -i 's/from app.db.session import Base # noqa/from app.db.session import Base # noqa\nfrom app import models # noqa: F401/' "$f"
+done
\ No newline at end of file
diff --git a/.history/migrate_20250808200715.sh b/.history/migrate_20250808200715.sh
new file mode 100644
index 0000000..e99b378
--- /dev/null
+++ b/.history/migrate_20250808200715.sh
@@ -0,0 +1,10 @@
+for s in auth profiles match chat payments; do
+ f="services/$s/alembic/env.py"
+ # добавим импорт пакета моделей, если его нет
+ grep -q "from app import models" "$f" || \
+ sed -i 's/from app.db.session import Base # noqa/from app.db.session import Base # noqa\nfrom app import models # noqa: F401/' "$f"
+done
+
+for s in auth profiles match chat payments; do
+ docker compose run --rm $s alembic revision --autogenerate -m "init"
+done
diff --git a/.history/models_20250808195719.sh b/.history/models_20250808195719.sh
new file mode 100644
index 0000000..57c01de
--- /dev/null
+++ b/.history/models_20250808195719.sh
@@ -0,0 +1,1560 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# -------------------------------------------------------------------
+# Apply models + CRUD + API + JWT auth to the existing scaffold
+# Requires: the scaffold created earlier (services/* exist)
+# -------------------------------------------------------------------
+
+ROOT_DIR="."
+SERVICES=(auth profiles match chat payments)
+
+ensure_line() {
+ # ensure_line
+ local file="$1" ; shift
+ local line="$*"
+ grep -qxF "$line" "$file" 2>/dev/null || echo "$line" >> "$file"
+}
+
+write_file() {
+ # write_file <<'EOF' ... EOF
+ local path="$1"
+ mkdir -p "$(dirname "$path")"
+ # The content will be provided by heredoc by the caller
+ cat > "$path"
+}
+
+append_file() {
+ local path="$1"
+ mkdir -p "$(dirname "$path")"
+ cat >> "$path"
+}
+
+require_file() {
+ local path="$1"
+ if [[ ! -f "$path" ]]; then
+ echo "ERROR: Missing $path. Run scaffold.sh first." >&2
+ exit 1
+ fi
+}
+
+# Basic checks
+require_file docker-compose.yml
+
+# -------------------------------------------------------------------
+# 1) .env.example — добавить JWT настройки (общие для всех сервисов)
+# -------------------------------------------------------------------
+ENV_FILE=".env.example"
+require_file "$ENV_FILE"
+
+ensure_line "$ENV_FILE" "# ---------- JWT / Auth ----------"
+ensure_line "$ENV_FILE" "JWT_SECRET=devsecret_change_me"
+ensure_line "$ENV_FILE" "JWT_ALGORITHM=HS256"
+ensure_line "$ENV_FILE" "ACCESS_TOKEN_EXPIRES_MIN=15"
+ensure_line "$ENV_FILE" "REFRESH_TOKEN_EXPIRES_MIN=43200 # 30 days"
+
+# -------------------------------------------------------------------
+# 2) requirements.txt — добавить PyJWT везде, в auth — ещё passlib[bcrypt]
+# -------------------------------------------------------------------
+for s in "${SERVICES[@]}"; do
+ REQ="services/$s/requirements.txt"
+ require_file "$REQ"
+ ensure_line "$REQ" "PyJWT>=2.8"
+ if [[ "$s" == "auth" ]]; then
+ ensure_line "$REQ" "passlib[bcrypt]>=1.7"
+ fi
+done
+
+# -------------------------------------------------------------------
+# 3) Общая безопасность (JWT) для всех сервисов
+# В auth добавим + генерацию токенов, в остальных — верификация и RBAC
+# -------------------------------------------------------------------
+for s in "${SERVICES[@]}"; do
+ SEC="services/$s/src/app/core/security.py"
+ mkdir -p "$(dirname "$SEC")"
+ if [[ "$s" == "auth" ]]; then
+ write_file "$SEC" <<'PY'
+from __future__ import annotations
+import os
+from datetime import datetime, timedelta, timezone
+from enum import Enum
+from typing import Any, Callable, Optional
+
+import jwt
+from fastapi import Depends, HTTPException, status
+from fastapi.security import OAuth2PasswordBearer
+from pydantic import BaseModel
+
+JWT_SECRET = os.getenv("JWT_SECRET", "devsecret_change_me")
+JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
+ACCESS_TOKEN_EXPIRES_MIN = int(os.getenv("ACCESS_TOKEN_EXPIRES_MIN", "15"))
+REFRESH_TOKEN_EXPIRES_MIN = int(os.getenv("REFRESH_TOKEN_EXPIRES_MIN", "43200"))
+
+class TokenType(str, Enum):
+ access = "access"
+ refresh = "refresh"
+
+class UserClaims(BaseModel):
+ sub: str
+ email: str
+ role: str
+ type: str
+ exp: int
+
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/v1/token")
+
+def _create_token(token_type: TokenType, *, sub: str, email: str, role: str, expires_minutes: int) -> str:
+ now = datetime.now(timezone.utc)
+ exp = now + timedelta(minutes=expires_minutes)
+ payload: dict[str, Any] = {
+ "sub": sub,
+ "email": email,
+ "role": role,
+ "type": token_type.value,
+ "exp": int(exp.timestamp()),
+ }
+ return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
+
+def create_access_token(*, sub: str, email: str, role: str) -> str:
+ return _create_token(TokenType.access, sub=sub, email=email, role=role, expires_minutes=ACCESS_TOKEN_EXPIRES_MIN)
+
+def create_refresh_token(*, sub: str, email: str, role: str) -> str:
+ return _create_token(TokenType.refresh, sub=sub, email=email, role=role, expires_minutes=REFRESH_TOKEN_EXPIRES_MIN)
+
+def decode_token(token: str) -> UserClaims:
+ try:
+ payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
+ return UserClaims(**payload)
+ except jwt.ExpiredSignatureError:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
+ except jwt.PyJWTError:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
+
+def get_current_user(token: str = Depends(oauth2_scheme)) -> UserClaims:
+ return decode_token(token)
+
+def require_roles(*roles: str) -> Callable[[UserClaims], UserClaims]:
+ def dep(user: UserClaims = Depends(get_current_user)) -> UserClaims:
+ if roles and user.role not in roles:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient role")
+ return user
+ return dep
+PY
+ else
+ write_file "$SEC" <<'PY'
+from __future__ import annotations
+import os
+from enum import Enum
+from typing import Any, Callable
+
+import jwt
+from fastapi import Depends, HTTPException, status
+from fastapi.security import OAuth2PasswordBearer
+from pydantic import BaseModel
+
+JWT_SECRET = os.getenv("JWT_SECRET", "devsecret_change_me")
+JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
+
+class UserClaims(BaseModel):
+ sub: str
+ email: str
+ role: str
+ type: str
+ exp: int
+
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/v1/token")
+
+def decode_token(token: str) -> UserClaims:
+ try:
+ payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
+ return UserClaims(**payload)
+ except jwt.ExpiredSignatureError:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
+ except jwt.PyJWTError:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
+
+def get_current_user(token: str = Depends(oauth2_scheme)) -> UserClaims:
+ return decode_token(token)
+
+def require_roles(*roles: str):
+ def dep(user: UserClaims = Depends(get_current_user)) -> UserClaims:
+ if roles and user.role not in roles:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient role")
+ return user
+ return dep
+PY
+ fi
+done
+
+# -------------------------------------------------------------------
+# 4) AUTH service — модели, CRUD, токены, эндпоинты
+# -------------------------------------------------------------------
+# models
+write_file services/auth/src/app/models/user.py <<'PY'
+from __future__ import annotations
+import uuid
+from datetime import datetime
+from enum import Enum
+
+from sqlalchemy import String, Boolean, DateTime
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import Mapped, mapped_column
+from sqlalchemy.sql import func
+
+from app.db.session import Base
+
+class Role(str, Enum):
+ ADMIN = "ADMIN"
+ MATCHMAKER = "MATCHMAKER"
+ CLIENT = "CLIENT"
+
+class User(Base):
+ __tablename__ = "users"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
+ password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
+ full_name: Mapped[str | None] = mapped_column(String(255), default=None)
+ role: Mapped[str] = mapped_column(String(32), default=Role.CLIENT.value, nullable=False)
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
+PY
+
+write_file services/auth/src/app/models/__init__.py <<'PY'
+from .user import User, Role # noqa: F401
+PY
+
+# schemas
+write_file services/auth/src/app/schemas/user.py <<'PY'
+from __future__ import annotations
+from typing import Optional
+from pydantic import BaseModel, EmailStr, ConfigDict
+
+class UserBase(BaseModel):
+ email: EmailStr
+ full_name: Optional[str] = None
+ role: str = "CLIENT"
+ is_active: bool = True
+
+class UserCreate(BaseModel):
+ email: EmailStr
+ password: str
+ full_name: Optional[str] = None
+ role: str = "CLIENT"
+
+class UserUpdate(BaseModel):
+ full_name: Optional[str] = None
+ role: Optional[str] = None
+ is_active: Optional[bool] = None
+ password: Optional[str] = None
+
+class UserRead(BaseModel):
+ id: str
+ email: EmailStr
+ full_name: Optional[str] = None
+ role: str
+ is_active: bool
+ model_config = ConfigDict(from_attributes=True)
+
+class LoginRequest(BaseModel):
+ email: EmailStr
+ password: str
+
+class TokenPair(BaseModel):
+ access_token: str
+ refresh_token: str
+ token_type: str = "bearer"
+PY
+
+# passwords
+write_file services/auth/src/app/core/passwords.py <<'PY'
+from passlib.context import CryptContext
+
+_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
+
+def hash_password(p: str) -> str:
+ return _pwd.hash(p)
+
+def verify_password(p: str, hashed: str) -> bool:
+ return _pwd.verify(p, hashed)
+PY
+
+# repositories
+write_file services/auth/src/app/repositories/user_repository.py <<'PY'
+from __future__ import annotations
+from typing import Optional, Sequence
+from sqlalchemy.orm import Session
+from sqlalchemy import select, update, delete
+
+from app.models.user import User
+
+class UserRepository:
+ def __init__(self, db: Session):
+ self.db = db
+
+ def get(self, user_id) -> Optional[User]:
+ return self.db.get(User, user_id)
+
+ def get_by_email(self, email: str) -> Optional[User]:
+ stmt = select(User).where(User.email == email)
+ return self.db.execute(stmt).scalar_one_or_none()
+
+ def list(self, *, offset: int = 0, limit: int = 50) -> Sequence[User]:
+ stmt = select(User).offset(offset).limit(limit).order_by(User.created_at.desc())
+ return self.db.execute(stmt).scalars().all()
+
+ def create(self, *, email: str, password_hash: str, full_name: str | None, role: str) -> User:
+ user = User(email=email, password_hash=password_hash, full_name=full_name, role=role)
+ self.db.add(user)
+ self.db.commit()
+ self.db.refresh(user)
+ return user
+
+ def update(self, user: User, **fields) -> User:
+ for k, v in fields.items():
+ if v is not None:
+ setattr(user, k, v)
+ self.db.add(user)
+ self.db.commit()
+ self.db.refresh(user)
+ return user
+
+ def delete(self, user: User) -> None:
+ self.db.delete(user)
+ self.db.commit()
+PY
+
+# services
+write_file services/auth/src/app/services/user_service.py <<'PY'
+from __future__ import annotations
+from typing import Optional
+from sqlalchemy.orm import Session
+
+from app.repositories.user_repository import UserRepository
+from app.core.passwords import hash_password, verify_password
+from app.models.user import User
+
+class UserService:
+ def __init__(self, db: Session):
+ self.repo = UserRepository(db)
+
+ # CRUD
+ def create_user(self, *, email: str, password: str, full_name: str | None, role: str) -> User:
+ if self.repo.get_by_email(email):
+ raise ValueError("Email already in use")
+ pwd_hash = hash_password(password)
+ return self.repo.create(email=email, password_hash=pwd_hash, full_name=full_name, role=role)
+
+ def get_user(self, user_id) -> Optional[User]:
+ return self.repo.get(user_id)
+
+ def get_by_email(self, email: str) -> Optional[User]:
+ return self.repo.get_by_email(email)
+
+ def list_users(self, *, offset: int = 0, limit: int = 50):
+ return self.repo.list(offset=offset, limit=limit)
+
+ def update_user(self, user: User, *, full_name: str | None = None, role: str | None = None,
+ is_active: bool | None = None, password: str | None = None) -> User:
+ fields = {}
+ if full_name is not None: fields["full_name"] = full_name
+ if role is not None: fields["role"] = role
+ if is_active is not None: fields["is_active"] = is_active
+ if password: fields["password_hash"] = hash_password(password)
+ return self.repo.update(user, **fields)
+
+ def delete_user(self, user: User) -> None:
+ self.repo.delete(user)
+
+ # Auth
+ def authenticate(self, *, email: str, password: str) -> Optional[User]:
+ user = self.repo.get_by_email(email)
+ if not user or not user.is_active:
+ return None
+ if not verify_password(password, user.password_hash):
+ return None
+ return user
+PY
+
+# api routes
+write_file services/auth/src/app/api/routes/auth.py <<'PY'
+from __future__ import annotations
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy.orm import Session
+
+from app.db.session import get_db
+from app.schemas.user import UserCreate, LoginRequest, TokenPair, UserRead
+from app.services.user_service import UserService
+from app.core.security import create_access_token, create_refresh_token, get_current_user, UserClaims
+
+router = APIRouter(prefix="/v1", tags=["auth"])
+
+@router.post("/register", response_model=UserRead, status_code=201)
+def register(payload: UserCreate, db: Session = Depends(get_db)):
+ svc = UserService(db)
+ try:
+ user = svc.create_user(email=payload.email, password=payload.password,
+ full_name=payload.full_name, role=payload.role)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+ return user
+
+@router.post("/token", response_model=TokenPair)
+def token(payload: LoginRequest, db: Session = Depends(get_db)):
+ svc = UserService(db)
+ user = svc.authenticate(email=payload.email, password=payload.password)
+ if not user:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
+ access = create_access_token(sub=str(user.id), email=user.email, role=user.role)
+ refresh = create_refresh_token(sub=str(user.id), email=user.email, role=user.role)
+ return TokenPair(access_token=access, refresh_token=refresh)
+
+class RefreshRequest(LoginRequest.__class__):
+ refresh_token: str # type: ignore
+
+@router.post("/refresh", response_model=TokenPair)
+def refresh_token(req: dict):
+ # expects: {"refresh_token": ""}
+ from app.core.security import decode_token
+ token = req.get("refresh_token")
+ if not token:
+ raise HTTPException(status_code=400, detail="Missing refresh_token")
+ claims = decode_token(token)
+ if claims.type != "refresh":
+ raise HTTPException(status_code=400, detail="Not a refresh token")
+ access = create_access_token(sub=claims.sub, email=claims.email, role=claims.role)
+ refresh = create_refresh_token(sub=claims.sub, email=claims.email, role=claims.role)
+ return TokenPair(access_token=access, refresh_token=refresh)
+
+@router.get("/me", response_model=UserRead)
+def me(claims: UserClaims = Depends(get_current_user), db: Session = Depends(get_db)):
+ svc = UserService(db)
+ u = svc.get_user(claims.sub)
+ if not u:
+ raise HTTPException(status_code=404, detail="User not found")
+ return u
+PY
+
+write_file services/auth/src/app/api/routes/users.py <<'PY'
+from __future__ import annotations
+from fastapi import APIRouter, Depends, HTTPException, Query, status
+from sqlalchemy.orm import Session
+
+from app.db.session import get_db
+from app.core.security import require_roles
+from app.schemas.user import UserRead, UserUpdate, UserCreate
+from app.services.user_service import UserService
+
+router = APIRouter(prefix="/v1/users", tags=["users"])
+
+@router.get("", response_model=list[UserRead])
+def list_users(offset: int = 0, limit: int = Query(50, le=200), db: Session = Depends(get_db),
+ _: dict = Depends(require_roles("ADMIN"))):
+ return UserService(db).list_users(offset=offset, limit=limit)
+
+@router.post("", response_model=UserRead, status_code=201)
+def create_user(payload: UserCreate, db: Session = Depends(get_db),
+ _: dict = Depends(require_roles("ADMIN"))):
+ try:
+ return UserService(db).create_user(email=payload.email, password=payload.password,
+ full_name=payload.full_name, role=payload.role)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+
+@router.get("/{user_id}", response_model=UserRead)
+def get_user(user_id: str, db: Session = Depends(get_db),
+ _: dict = Depends(require_roles("ADMIN"))):
+ u = UserService(db).get_user(user_id)
+ if not u:
+ raise HTTPException(status_code=404, detail="User not found")
+ return u
+
+@router.patch("/{user_id}", response_model=UserRead)
+def update_user(user_id: str, payload: UserUpdate, db: Session = Depends(get_db),
+ _: dict = Depends(require_roles("ADMIN"))):
+ svc = UserService(db)
+ u = svc.get_user(user_id)
+ if not u:
+ raise HTTPException(status_code=404, detail="User not found")
+ return svc.update_user(u, full_name=payload.full_name, role=payload.role,
+ is_active=payload.is_active, password=payload.password)
+
+@router.delete("/{user_id}", status_code=204)
+def delete_user(user_id: str, db: Session = Depends(get_db),
+ _: dict = Depends(require_roles("ADMIN"))):
+ svc = UserService(db)
+ u = svc.get_user(user_id)
+ if not u:
+ return
+ svc.delete_user(u)
+PY
+
+# main.py update for auth
+write_file services/auth/src/app/main.py <<'PY'
+from fastapi import FastAPI
+from .api.routes.ping import router as ping_router
+from .api.routes.auth import router as auth_router
+from .api.routes.users import router as users_router
+
+app = FastAPI(title="AUTH Service")
+
+@app.get("/health")
+def health():
+ return {"status": "ok", "service": "auth"}
+
+app.include_router(ping_router, prefix="/v1")
+app.include_router(auth_router)
+app.include_router(users_router)
+PY
+
+# -------------------------------------------------------------------
+# 5) PROFILES service — Profile + Photo CRUD + поиск
+# -------------------------------------------------------------------
+write_file services/profiles/src/app/models/profile.py <<'PY'
+from __future__ import annotations
+import uuid
+from datetime import date, datetime
+
+from sqlalchemy import String, Date, DateTime, Text
+from sqlalchemy.dialects.postgresql import UUID, JSONB
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+from sqlalchemy.sql import func
+
+from app.db.session import Base
+
+class Profile(Base):
+ __tablename__ = "profiles"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
+ gender: Mapped[str] = mapped_column(String(16), nullable=False) # male/female/other
+ birthdate: Mapped[date | None] = mapped_column(Date, default=None)
+ city: Mapped[str | None] = mapped_column(String(120), default=None)
+ bio: Mapped[str | None] = mapped_column(Text, default=None)
+ languages: Mapped[dict | None] = mapped_column(JSONB, default=list) # e.g., ["ru","en"]
+ interests: Mapped[dict | None] = mapped_column(JSONB, default=list)
+ preferences: Mapped[dict | None] = mapped_column(JSONB, default=dict)
+ verification_status: Mapped[str] = mapped_column(String(16), default="unverified")
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
+
+ photos = relationship("Photo", back_populates="profile", cascade="all, delete-orphan")
+PY
+
+write_file services/profiles/src/app/models/photo.py <<'PY'
+from __future__ import annotations
+import uuid
+from datetime import datetime
+
+from sqlalchemy import String, Boolean, DateTime, ForeignKey
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+from sqlalchemy.sql import func
+
+from app.db.session import Base
+
+class Photo(Base):
+ __tablename__ = "photos"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ profile_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
+ url: Mapped[str] = mapped_column(String(500), nullable=False)
+ is_main: Mapped[bool] = mapped_column(Boolean, default=False)
+ status: Mapped[str] = mapped_column(String(16), default="pending") # pending/approved/rejected
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+
+ profile = relationship("Profile", back_populates="photos", primaryjoin="Photo.profile_id==Profile.id", viewonly=True)
+PY
+
+write_file services/profiles/src/app/models/__init__.py <<'PY'
+from .profile import Profile # noqa
+from .photo import Photo # noqa
+PY
+
+write_file services/profiles/src/app/schemas/profile.py <<'PY'
+from __future__ import annotations
+from datetime import date
+from typing import Optional, Any
+from pydantic import BaseModel, ConfigDict
+
+class PhotoCreate(BaseModel):
+ url: str
+ is_main: bool = False
+
+class PhotoRead(BaseModel):
+ id: str
+ url: str
+ is_main: bool
+ status: str
+ model_config = ConfigDict(from_attributes=True)
+
+class ProfileCreate(BaseModel):
+ gender: str
+ birthdate: Optional[date] = None
+ city: Optional[str] = None
+ bio: Optional[str] = None
+ languages: Optional[list[str]] = None
+ interests: Optional[list[str]] = None
+ preferences: Optional[dict[str, Any]] = None
+
+class ProfileUpdate(BaseModel):
+ gender: Optional[str] = None
+ birthdate: Optional[date] = None
+ city: Optional[str] = None
+ bio: Optional[str] = None
+ languages: Optional[list[str]] = None
+ interests: Optional[list[str]] = None
+ preferences: Optional[dict[str, Any]] = None
+ verification_status: Optional[str] = None
+
+class ProfileRead(BaseModel):
+ id: str
+ user_id: str
+ gender: str
+ birthdate: Optional[date] = None
+ city: Optional[str] = None
+ bio: Optional[str] = None
+ languages: Optional[list[str]] = None
+ interests: Optional[list[str]] = None
+ preferences: Optional[dict] = None
+ verification_status: str
+ model_config = ConfigDict(from_attributes=True)
+PY
+
+write_file services/profiles/src/app/repositories/profile_repository.py <<'PY'
+from __future__ import annotations
+from typing import Optional, Sequence
+from datetime import date, timedelta
+
+from sqlalchemy import select, and_
+from sqlalchemy.orm import Session
+
+from app.models.profile import Profile
+from app.models.photo import Photo
+
+class ProfileRepository:
+ def __init__(self, db: Session):
+ self.db = db
+
+ # Profile CRUD
+ def create_profile(self, *, user_id, **fields) -> Profile:
+ p = Profile(user_id=user_id, **fields)
+ self.db.add(p)
+ self.db.commit()
+ self.db.refresh(p)
+ return p
+
+ def get_profile(self, profile_id) -> Optional[Profile]:
+ return self.db.get(Profile, profile_id)
+
+ def get_by_user(self, user_id) -> Optional[Profile]:
+ stmt = select(Profile).where(Profile.user_id == user_id)
+ return self.db.execute(stmt).scalar_one_or_none()
+
+ def update_profile(self, profile: Profile, **fields) -> Profile:
+ for k, v in fields.items():
+ if v is not None:
+ setattr(profile, k, v)
+ self.db.add(profile)
+ self.db.commit()
+ self.db.refresh(profile)
+ return profile
+
+ def delete_profile(self, profile: Profile) -> None:
+ self.db.delete(profile)
+ self.db.commit()
+
+ def list_profiles(self, *, gender: str | None = None, city: str | None = None,
+ age_min: int | None = None, age_max: int | None = None,
+ offset: int = 0, limit: int = 50) -> Sequence[Profile]:
+ stmt = select(Profile)
+ conds = []
+ if gender:
+ conds.append(Profile.gender == gender)
+ if city:
+ conds.append(Profile.city == city)
+ # Age filter -> birthdate between (today - age_max) and (today - age_min)
+ if age_min is not None or age_max is not None:
+ today = date.today()
+ if age_min is not None:
+ max_birthdate = date(today.year - age_min, today.month, today.day)
+ conds.append(Profile.birthdate <= max_birthdate)
+ if age_max is not None:
+ min_birthdate = date(today.year - age_max - 1, today.month, today.day) + timedelta(days=1)
+ conds.append(Profile.birthdate >= min_birthdate)
+ if conds:
+ stmt = stmt.where(and_(*conds))
+ stmt = stmt.offset(offset).limit(limit).order_by(Profile.created_at.desc())
+ return self.db.execute(stmt).scalars().all()
+
+ # Photos
+ def add_photo(self, *, profile_id, url: str, is_main: bool) -> Photo:
+ photo = Photo(profile_id=profile_id, url=url, is_main=is_main)
+ self.db.add(photo)
+ if is_main:
+ # unset other main photos
+ self.db.execute(select(Photo).where(Photo.profile_id == profile_id))
+ self.db.commit()
+ self.db.refresh(photo)
+ return photo
+
+ def list_photos(self, *, profile_id) -> Sequence[Photo]:
+ stmt = select(Photo).where(Photo.profile_id == profile_id)
+ return self.db.execute(stmt).scalars().all()
+
+ def get_photo(self, photo_id) -> Optional[Photo]:
+ return self.db.get(Photo, photo_id)
+
+ def delete_photo(self, photo: Photo) -> None:
+ self.db.delete(photo)
+ self.db.commit()
+PY
+
+write_file services/profiles/src/app/services/profile_service.py <<'PY'
+from __future__ import annotations
+from sqlalchemy.orm import Session
+from typing import Optional
+
+from app.repositories.profile_repository import ProfileRepository
+from app.models.profile import Profile
+from app.models.photo import Photo
+
+class ProfileService:
+ def __init__(self, db: Session):
+ self.repo = ProfileRepository(db)
+
+ def create_profile(self, *, user_id, **fields) -> Profile:
+ return self.repo.create_profile(user_id=user_id, **fields)
+
+ def get_profile(self, profile_id) -> Optional[Profile]:
+ return self.repo.get_profile(profile_id)
+
+ def get_by_user(self, user_id) -> Optional[Profile]:
+ return self.repo.get_by_user(user_id)
+
+ def update_profile(self, profile: Profile, **fields) -> Profile:
+ return self.repo.update_profile(profile, **fields)
+
+ def delete_profile(self, profile: Profile) -> None:
+ return self.repo.delete_profile(profile)
+
+ def list_profiles(self, **filters):
+ return self.repo.list_profiles(**filters)
+
+ # photos
+ def add_photo(self, profile_id, *, url: str, is_main: bool) -> Photo:
+ return self.repo.add_photo(profile_id=profile_id, url=url, is_main=is_main)
+
+ def list_photos(self, profile_id):
+ return self.repo.list_photos(profile_id=profile_id)
+
+ def get_photo(self, photo_id) -> Photo | None:
+ return self.repo.get_photo(photo_id)
+
+ def delete_photo(self, photo: Photo) -> None:
+ self.repo.delete_photo(photo)
+PY
+
+write_file services/profiles/src/app/api/routes/profiles.py <<'PY'
+from __future__ import annotations
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy.orm import Session
+
+from app.db.session import get_db
+from app.core.security import get_current_user, require_roles, UserClaims
+from app.schemas.profile import ProfileCreate, ProfileRead, ProfileUpdate, PhotoCreate, PhotoRead
+from app.services.profile_service import ProfileService
+
+router = APIRouter(prefix="/v1", tags=["profiles"])
+
+@router.post("/profiles", response_model=ProfileRead, status_code=201)
+def create_profile(payload: ProfileCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ svc = ProfileService(db)
+ if svc.get_by_user(user.sub):
+ raise HTTPException(status_code=400, detail="Profile already exists")
+ p = svc.create_profile(user_id=user.sub, **payload.model_dump(exclude_none=True))
+ return p
+
+@router.get("/profiles/me", response_model=ProfileRead)
+def get_my_profile(db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ svc = ProfileService(db)
+ p = svc.get_by_user(user.sub)
+ if not p:
+ raise HTTPException(status_code=404, detail="Profile not found")
+ return p
+
+@router.get("/profiles", response_model=list[ProfileRead])
+def list_profiles(gender: str | None = None, city: str | None = None,
+ age_min: int | None = Query(None, ge=18, le=120),
+ age_max: int | None = Query(None, ge=18, le=120),
+ offset: int = 0, limit: int = Query(50, le=200),
+ db: Session = Depends(get_db),
+ _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))):
+ return ProfileService(db).list_profiles(gender=gender, city=city, age_min=age_min, age_max=age_max, offset=offset, limit=limit)
+
+@router.get("/profiles/{profile_id}", response_model=ProfileRead)
+def get_profile(profile_id: str, db: Session = Depends(get_db), _: UserClaims = Depends(get_current_user)):
+ p = ProfileService(db).get_profile(profile_id)
+ if not p:
+ raise HTTPException(status_code=404, detail="Profile not found")
+ return p
+
+@router.patch("/profiles/{profile_id}", response_model=ProfileRead)
+def update_profile(profile_id: str, payload: ProfileUpdate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ svc = ProfileService(db)
+ p = svc.get_profile(profile_id)
+ if not p:
+ raise HTTPException(status_code=404, detail="Profile not found")
+ if str(p.user_id) != user.sub and user.role not in ("ADMIN","MATCHMAKER"):
+ raise HTTPException(status_code=403, detail="Not allowed")
+ return svc.update_profile(p, **payload.model_dump(exclude_none=True))
+
+@router.delete("/profiles/{profile_id}", status_code=204)
+def delete_profile(profile_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ svc = ProfileService(db)
+ p = svc.get_profile(profile_id)
+ if not p:
+ return
+ if str(p.user_id) != user.sub and user.role not in ("ADMIN","MATCHMAKER"):
+ raise HTTPException(status_code=403, detail="Not allowed")
+ svc.delete_profile(p)
+
+# Photos
+@router.post("/profiles/{profile_id}/photos", response_model=PhotoRead, status_code=201)
+def add_photo(profile_id: str, payload: PhotoCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ svc = ProfileService(db)
+ p = svc.get_profile(profile_id)
+ if not p:
+ raise HTTPException(status_code=404, detail="Profile not found")
+ if str(p.user_id) != user.sub and user.role not in ("ADMIN","MATCHMAKER"):
+ raise HTTPException(status_code=403, detail="Not allowed")
+ photo = svc.add_photo(profile_id, url=payload.url, is_main=payload.is_main)
+ return photo
+
+@router.get("/profiles/{profile_id}/photos", response_model=list[PhotoRead])
+def list_photos(profile_id: str, db: Session = Depends(get_db), _: UserClaims = Depends(get_current_user)):
+ svc = ProfileService(db)
+ return svc.list_photos(profile_id)
+
+@router.delete("/photos/{photo_id}", status_code=204)
+def delete_photo(photo_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ svc = ProfileService(db)
+ photo = svc.get_photo(photo_id)
+ if not photo:
+ return
+ # Lookup profile to check ownership
+ p = svc.get_profile(photo.profile_id)
+ if not p or (str(p.user_id) != user.sub and user.role not in ("ADMIN","MATCHMAKER")):
+ raise HTTPException(status_code=403, detail="Not allowed")
+ svc.delete_photo(photo)
+PY
+
+# main.py for profiles
+write_file services/profiles/src/app/main.py <<'PY'
+from fastapi import FastAPI
+from .api.routes.ping import router as ping_router
+from .api.routes.profiles import router as profiles_router
+
+app = FastAPI(title="PROFILES Service")
+
+@app.get("/health")
+def health():
+ return {"status": "ok", "service": "profiles"}
+
+app.include_router(ping_router, prefix="/v1")
+app.include_router(profiles_router)
+PY
+
+# -------------------------------------------------------------------
+# 6) MATCH service — пары и статусы (proposed/accepted/rejected/blocked)
+# -------------------------------------------------------------------
+write_file services/match/src/app/models/pair.py <<'PY'
+from __future__ import annotations
+import uuid
+from datetime import datetime
+from sqlalchemy import String, Float, DateTime
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import Mapped, mapped_column
+from sqlalchemy.sql import func
+
+from app.db.session import Base
+
+class MatchPair(Base):
+ __tablename__ = "match_pairs"
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ # User IDs to validate permissions; profile IDs можно добавить позже
+ user_id_a: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
+ user_id_b: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
+ status: Mapped[str] = mapped_column(String(16), default="proposed") # proposed/accepted/rejected/blocked
+ score: Mapped[float | None] = mapped_column(Float, default=None)
+ notes: Mapped[str | None] = mapped_column(String(1000), default=None)
+ created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True) # matchmaker/admin
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
+PY
+
+write_file services/match/src/app/models/__init__.py <<'PY'
+from .pair import MatchPair # noqa
+PY
+
+write_file services/match/src/app/schemas/pair.py <<'PY'
+from __future__ import annotations
+from typing import Optional
+from pydantic import BaseModel, ConfigDict
+
+class PairCreate(BaseModel):
+ user_id_a: str
+ user_id_b: str
+ score: Optional[float] = None
+ notes: Optional[str] = None
+
+class PairUpdate(BaseModel):
+ score: Optional[float] = None
+ notes: Optional[str] = None
+
+class PairRead(BaseModel):
+ id: str
+ user_id_a: str
+ user_id_b: str
+ status: str
+ score: Optional[float] = None
+ notes: Optional[str] = None
+ model_config = ConfigDict(from_attributes=True)
+PY
+
+write_file services/match/src/app/repositories/pair_repository.py <<'PY'
+from __future__ import annotations
+from typing import Optional, Sequence
+from sqlalchemy import select, or_
+from sqlalchemy.orm import Session
+
+from app.models.pair import MatchPair
+
+class PairRepository:
+ def __init__(self, db: Session):
+ self.db = db
+
+ def create(self, **fields) -> MatchPair:
+ obj = MatchPair(**fields)
+ self.db.add(obj)
+ self.db.commit()
+ self.db.refresh(obj)
+ return obj
+
+ def get(self, pair_id) -> Optional[MatchPair]:
+ return self.db.get(MatchPair, pair_id)
+
+ def list(self, *, for_user_id: str | None = None, status: str | None = None,
+ offset: int = 0, limit: int = 50) -> Sequence[MatchPair]:
+ stmt = select(MatchPair)
+ if for_user_id:
+ stmt = stmt.where(or_(MatchPair.user_id_a == for_user_id, MatchPair.user_id_b == for_user_id))
+ if status:
+ stmt = stmt.where(MatchPair.status == status)
+ stmt = stmt.offset(offset).limit(limit).order_by(MatchPair.created_at.desc())
+ return self.db.execute(stmt).scalars().all()
+
+ def update(self, obj: MatchPair, **fields) -> MatchPair:
+ for k, v in fields.items():
+ if v is not None:
+ setattr(obj, k, v)
+ self.db.add(obj)
+ self.db.commit()
+ self.db.refresh(obj)
+ return obj
+
+ def delete(self, obj: MatchPair) -> None:
+ self.db.delete(obj)
+ self.db.commit()
+PY
+
+write_file services/match/src/app/services/pair_service.py <<'PY'
+from __future__ import annotations
+from sqlalchemy.orm import Session
+from typing import Optional
+from app.repositories.pair_repository import PairRepository
+from app.models.pair import MatchPair
+
+class PairService:
+ def __init__(self, db: Session):
+ self.repo = PairRepository(db)
+
+ def create(self, **fields) -> MatchPair:
+ return self.repo.create(**fields)
+
+ def get(self, pair_id) -> Optional[MatchPair]:
+ return self.repo.get(pair_id)
+
+ def list(self, **filters):
+ return self.repo.list(**filters)
+
+ def update(self, obj: MatchPair, **fields) -> MatchPair:
+ return self.repo.update(obj, **fields)
+
+ def delete(self, obj: MatchPair) -> None:
+ return self.repo.delete(obj)
+
+ def set_status(self, obj: MatchPair, status: str) -> MatchPair:
+ return self.repo.update(obj, status=status)
+PY
+
+write_file services/match/src/app/api/routes/pairs.py <<'PY'
+from __future__ import annotations
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy.orm import Session
+
+from app.db.session import get_db
+from app.core.security import get_current_user, require_roles, UserClaims
+from app.schemas.pair import PairCreate, PairUpdate, PairRead
+from app.services.pair_service import PairService
+
+router = APIRouter(prefix="/v1/pairs", tags=["pairs"])
+
+@router.post("", response_model=PairRead, status_code=201)
+def create_pair(payload: PairCreate, db: Session = Depends(get_db),
+ user: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))):
+ svc = PairService(db)
+ return svc.create(user_id_a=payload.user_id_a, user_id_b=payload.user_id_b,
+ score=payload.score, notes=payload.notes, created_by=user.sub)
+
+@router.get("", response_model=list[PairRead])
+def list_pairs(for_user_id: str | None = None, status: str | None = None,
+ offset: int = 0, limit: int = Query(50, le=200),
+ db: Session = Depends(get_db),
+ _: UserClaims = Depends(get_current_user)):
+ return PairService(db).list(for_user_id=for_user_id, status=status, offset=offset, limit=limit)
+
+@router.get("/{pair_id}", response_model=PairRead)
+def get_pair(pair_id: str, db: Session = Depends(get_db), _: UserClaims = Depends(get_current_user)):
+ obj = PairService(db).get(pair_id)
+ if not obj:
+ raise HTTPException(status_code=404, detail="Not found")
+ return obj
+
+@router.patch("/{pair_id}", response_model=PairRead)
+def update_pair(pair_id: str, payload: PairUpdate, db: Session = Depends(get_db),
+ _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))):
+ svc = PairService(db)
+ obj = svc.get(pair_id)
+ if not obj:
+ raise HTTPException(status_code=404, detail="Not found")
+ return svc.update(obj, **payload.model_dump(exclude_none=True))
+
+@router.post("/{pair_id}/accept", response_model=PairRead)
+def accept(pair_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ svc = PairService(db)
+ obj = svc.get(pair_id)
+ if not obj:
+ raise HTTPException(status_code=404, detail="Not found")
+ # Validate that current user participates
+ if user.sub not in (str(obj.user_id_a), str(obj.user_id_b)):
+ raise HTTPException(status_code=403, detail="Not allowed")
+ return svc.set_status(obj, "accepted")
+
+@router.post("/{pair_id}/reject", response_model=PairRead)
+def reject(pair_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ svc = PairService(db)
+ obj = svc.get(pair_id)
+ if not obj:
+ raise HTTPException(status_code=404, detail="Not found")
+ if user.sub not in (str(obj.user_id_a), str(obj.user_id_b)):
+ raise HTTPException(status_code=403, detail="Not allowed")
+ return svc.set_status(obj, "rejected")
+
+@router.delete("/{pair_id}", status_code=204)
+def delete_pair(pair_id: str, db: Session = Depends(get_db),
+ _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))):
+ svc = PairService(db)
+ obj = svc.get(pair_id)
+ if not obj:
+ return
+ svc.delete(obj)
+PY
+
+write_file services/match/src/app/main.py <<'PY'
+from fastapi import FastAPI
+from .api.routes.ping import router as ping_router
+from .api.routes.pairs import router as pairs_router
+
+app = FastAPI(title="MATCH Service")
+
+@app.get("/health")
+def health():
+ return {"status": "ok", "service": "match"}
+
+app.include_router(ping_router, prefix="/v1")
+app.include_router(pairs_router)
+PY
+
+# -------------------------------------------------------------------
+# 7) CHAT service — комнаты и сообщения (REST, без WS)
+# -------------------------------------------------------------------
+write_file services/chat/src/app/models/chat.py <<'PY'
+from __future__ import annotations
+import uuid
+from datetime import datetime
+from sqlalchemy import String, DateTime, Text, ForeignKey, Boolean
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import Mapped, mapped_column
+from sqlalchemy.sql import func
+
+from app.db.session import Base
+
+class ChatRoom(Base):
+ __tablename__ = "chat_rooms"
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ title: Mapped[str | None] = mapped_column(String(255), default=None)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+
+class ChatParticipant(Base):
+ __tablename__ = "chat_participants"
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ room_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
+ user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
+ is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
+
+class Message(Base):
+ __tablename__ = "chat_messages"
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ room_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
+ sender_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
+ content: Mapped[str] = mapped_column(Text, nullable=False)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+PY
+
+write_file services/chat/src/app/models/__init__.py <<'PY'
+from .chat import ChatRoom, ChatParticipant, Message # noqa
+PY
+
+write_file services/chat/src/app/schemas/chat.py <<'PY'
+from __future__ import annotations
+from pydantic import BaseModel, ConfigDict
+from typing import Optional
+
+class RoomCreate(BaseModel):
+ title: Optional[str] = None
+ participants: list[str] # user IDs
+
+class RoomRead(BaseModel):
+ id: str
+ title: Optional[str] = None
+ model_config = ConfigDict(from_attributes=True)
+
+class MessageCreate(BaseModel):
+ content: str
+
+class MessageRead(BaseModel):
+ id: str
+ room_id: str
+ sender_id: str
+ content: str
+ model_config = ConfigDict(from_attributes=True)
+PY
+
+write_file services/chat/src/app/repositories/chat_repository.py <<'PY'
+from __future__ import annotations
+from typing import Sequence, Optional
+from sqlalchemy.orm import Session
+from sqlalchemy import select, or_
+
+from app.models.chat import ChatRoom, ChatParticipant, Message
+
+class ChatRepository:
+ def __init__(self, db: Session):
+ self.db = db
+
+ # Rooms
+ def create_room(self, title: str | None) -> ChatRoom:
+ r = ChatRoom(title=title)
+ self.db.add(r)
+ self.db.commit()
+ self.db.refresh(r)
+ return r
+
+ def add_participant(self, room_id, user_id, is_admin: bool = False) -> ChatParticipant:
+ p = ChatParticipant(room_id=room_id, user_id=user_id, is_admin=is_admin)
+ self.db.add(p)
+ self.db.commit()
+ self.db.refresh(p)
+ return p
+
+ def list_rooms_for_user(self, user_id) -> Sequence[ChatRoom]:
+ stmt = select(ChatRoom).join(ChatParticipant, ChatParticipant.room_id == ChatRoom.id)\
+ .where(ChatParticipant.user_id == user_id)
+ return self.db.execute(stmt).scalars().all()
+
+ def get_room(self, room_id) -> Optional[ChatRoom]:
+ return self.db.get(ChatRoom, room_id)
+
+ # Messages
+ def create_message(self, room_id, sender_id, content: str) -> Message:
+ m = Message(room_id=room_id, sender_id=sender_id, content=content)
+ self.db.add(m)
+ self.db.commit()
+ self.db.refresh(m)
+ return m
+
+ def list_messages(self, room_id, *, offset: int = 0, limit: int = 100) -> Sequence[Message]:
+ stmt = select(Message).where(Message.room_id == room_id).offset(offset).limit(limit).order_by(Message.created_at.asc())
+ return self.db.execute(stmt).scalars().all()
+PY
+
+write_file services/chat/src/app/services/chat_service.py <<'PY'
+from __future__ import annotations
+from sqlalchemy.orm import Session
+from typing import Optional, Sequence
+
+from app.repositories.chat_repository import ChatRepository
+from app.models.chat import ChatRoom, ChatParticipant, Message
+
+class ChatService:
+ def __init__(self, db: Session):
+ self.repo = ChatRepository(db)
+
+ def create_room(self, *, title: str | None, participant_ids: list[str], creator_id: str) -> ChatRoom:
+ room = self.repo.create_room(title)
+ # creator -> admin
+ self.repo.add_participant(room.id, creator_id, is_admin=True)
+ for uid in participant_ids:
+ if uid != creator_id:
+ self.repo.add_participant(room.id, uid, is_admin=False)
+ return room
+
+ def list_rooms_for_user(self, user_id: str) -> Sequence[ChatRoom]:
+ return self.repo.list_rooms_for_user(user_id)
+
+ def get_room(self, room_id: str) -> ChatRoom | None:
+ return self.repo.get_room(room_id)
+
+ def create_message(self, room_id: str, sender_id: str, content: str) -> Message:
+ return self.repo.create_message(room_id, sender_id, content)
+
+ def list_messages(self, room_id: str, offset: int = 0, limit: int = 100):
+ return self.repo.list_messages(room_id, offset=offset, limit=limit)
+PY
+
+write_file services/chat/src/app/api/routes/chat.py <<'PY'
+from __future__ import annotations
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy.orm import Session
+
+from app.db.session import get_db
+from app.core.security import get_current_user, UserClaims
+from app.schemas.chat import RoomCreate, RoomRead, MessageCreate, MessageRead
+from app.services.chat_service import ChatService
+
+router = APIRouter(prefix="/v1", tags=["chat"])
+
+@router.post("/rooms", response_model=RoomRead, status_code=201)
+def create_room(payload: RoomCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ svc = ChatService(db)
+ room = svc.create_room(title=payload.title, participant_ids=payload.participants, creator_id=user.sub)
+ return room
+
+@router.get("/rooms", response_model=list[RoomRead])
+def my_rooms(db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ return ChatService(db).list_rooms_for_user(user.sub)
+
+@router.get("/rooms/{room_id}", response_model=RoomRead)
+def get_room(room_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ room = ChatService(db).get_room(room_id)
+ if not room:
+ raise HTTPException(status_code=404, detail="Not found")
+ # NOTE: для простоты опускаем проверку участия (добавьте в проде)
+ return room
+
+@router.post("/rooms/{room_id}/messages", response_model=MessageRead, status_code=201)
+def send_message(room_id: str, payload: MessageCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ svc = ChatService(db)
+ room = svc.get_room(room_id)
+ if not room:
+ raise HTTPException(status_code=404, detail="Room not found")
+ msg = svc.create_message(room_id, user.sub, payload.content)
+ return msg
+
+@router.get("/rooms/{room_id}/messages", response_model=list[MessageRead])
+def list_messages(room_id: str, offset: int = 0, limit: int = Query(100, le=500),
+ db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ svc = ChatService(db)
+ room = svc.get_room(room_id)
+ if not room:
+ raise HTTPException(status_code=404, detail="Room not found")
+ return svc.list_messages(room_id, offset=offset, limit=limit)
+PY
+
+write_file services/chat/src/app/main.py <<'PY'
+from fastapi import FastAPI
+from .api.routes.ping import router as ping_router
+from .api.routes.chat import router as chat_router
+
+app = FastAPI(title="CHAT Service")
+
+@app.get("/health")
+def health():
+ return {"status": "ok", "service": "chat"}
+
+app.include_router(ping_router, prefix="/v1")
+app.include_router(chat_router)
+PY
+
+# -------------------------------------------------------------------
+# 8) PAYMENTS service — инвойсы (простая версия)
+# -------------------------------------------------------------------
+write_file services/payments/src/app/models/payment.py <<'PY'
+from __future__ import annotations
+import uuid
+from datetime import datetime
+from sqlalchemy import String, DateTime, Numeric
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import Mapped, mapped_column
+from sqlalchemy.sql import func
+
+from app.db.session import Base
+
+class Invoice(Base):
+ __tablename__ = "invoices"
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ client_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
+ amount: Mapped[float] = mapped_column(Numeric(12,2), nullable=False)
+ currency: Mapped[str] = mapped_column(String(3), default="USD")
+ status: Mapped[str] = mapped_column(String(16), default="pending") # pending/paid/canceled
+ description: Mapped[str | None] = mapped_column(String(500), default=None)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
+PY
+
+write_file services/payments/src/app/models/__init__.py <<'PY'
+from .payment import Invoice # noqa
+PY
+
+write_file services/payments/src/app/schemas/payment.py <<'PY'
+from __future__ import annotations
+from typing import Optional
+from pydantic import BaseModel, ConfigDict
+
+class InvoiceCreate(BaseModel):
+ client_id: str
+ amount: float
+ currency: str = "USD"
+ description: Optional[str] = None
+
+class InvoiceUpdate(BaseModel):
+ amount: Optional[float] = None
+ currency: Optional[str] = None
+ description: Optional[str] = None
+ status: Optional[str] = None
+
+class InvoiceRead(BaseModel):
+ id: str
+ client_id: str
+ amount: float
+ currency: str
+ status: str
+ description: Optional[str] = None
+ model_config = ConfigDict(from_attributes=True)
+PY
+
+write_file services/payments/src/app/repositories/payment_repository.py <<'PY'
+from __future__ import annotations
+from typing import Optional, Sequence
+from sqlalchemy.orm import Session
+from sqlalchemy import select
+
+from app.models.payment import Invoice
+
+class PaymentRepository:
+ def __init__(self, db: Session):
+ self.db = db
+
+ def create_invoice(self, **fields) -> Invoice:
+ obj = Invoice(**fields)
+ self.db.add(obj)
+ self.db.commit()
+ self.db.refresh(obj)
+ return obj
+
+ def get_invoice(self, inv_id) -> Optional[Invoice]:
+ return self.db.get(Invoice, inv_id)
+
+ def list_invoices(self, *, client_id: str | None = None, status: str | None = None,
+ offset: int = 0, limit: int = 50) -> Sequence[Invoice]:
+ stmt = select(Invoice)
+ if client_id:
+ stmt = stmt.where(Invoice.client_id == client_id)
+ if status:
+ stmt = stmt.where(Invoice.status == status)
+ stmt = stmt.offset(offset).limit(limit).order_by(Invoice.created_at.desc())
+ return self.db.execute(stmt).scalars().all()
+
+ def update_invoice(self, obj: Invoice, **fields) -> Invoice:
+ for k, v in fields.items():
+ if v is not None:
+ setattr(obj, k, v)
+ self.db.add(obj)
+ self.db.commit()
+ self.db.refresh(obj)
+ return obj
+
+ def delete_invoice(self, obj: Invoice) -> None:
+ self.db.delete(obj)
+ self.db.commit()
+PY
+
+write_file services/payments/src/app/services/payment_service.py <<'PY'
+from __future__ import annotations
+from sqlalchemy.orm import Session
+from typing import Optional
+from app.repositories.payment_repository import PaymentRepository
+from app.models.payment import Invoice
+
+class PaymentService:
+ def __init__(self, db: Session):
+ self.repo = PaymentRepository(db)
+
+ def create_invoice(self, **fields) -> Invoice:
+ return self.repo.create_invoice(**fields)
+
+ def get_invoice(self, inv_id) -> Invoice | None:
+ return self.repo.get_invoice(inv_id)
+
+ def list_invoices(self, **filters):
+ return self.repo.list_invoices(**filters)
+
+ def update_invoice(self, obj: Invoice, **fields) -> Invoice:
+ return self.repo.update_invoice(obj, **fields)
+
+ def delete_invoice(self, obj: Invoice) -> None:
+ return self.repo.delete_invoice(obj)
+
+ def mark_paid(self, obj: Invoice) -> Invoice:
+ return self.repo.update_invoice(obj, status="paid")
+PY
+
+write_file services/payments/src/app/api/routes/payments.py <<'PY'
+from __future__ import annotations
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy.orm import Session
+
+from app.db.session import get_db
+from app.core.security import get_current_user, require_roles, UserClaims
+from app.schemas.payment import InvoiceCreate, InvoiceUpdate, InvoiceRead
+from app.services.payment_service import PaymentService
+
+router = APIRouter(prefix="/v1/invoices", tags=["payments"])
+
+@router.post("", response_model=InvoiceRead, status_code=201)
+def create_invoice(payload: InvoiceCreate, db: Session = Depends(get_db),
+ _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))):
+ return PaymentService(db).create_invoice(**payload.model_dump(exclude_none=True))
+
+@router.get("", response_model=list[InvoiceRead])
+def list_invoices(client_id: str | None = None, status: str | None = None,
+ offset: int = 0, limit: int = Query(50, le=200),
+ db: Session = Depends(get_db),
+ user: UserClaims = Depends(get_current_user)):
+ # Клиент видит только свои инвойсы, админ/матчмейкер — любые
+ if user.role in ("ADMIN","MATCHMAKER"):
+ return PaymentService(db).list_invoices(client_id=client_id, status=status, offset=offset, limit=limit)
+ else:
+ return PaymentService(db).list_invoices(client_id=user.sub, status=status, offset=offset, limit=limit)
+
+@router.get("/{inv_id}", response_model=InvoiceRead)
+def get_invoice(inv_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ inv = PaymentService(db).get_invoice(inv_id)
+ if not inv:
+ raise HTTPException(status_code=404, detail="Not found")
+ if user.role not in ("ADMIN","MATCHMAKER") and str(inv.client_id) != user.sub:
+ raise HTTPException(status_code=403, detail="Not allowed")
+ return inv
+
+@router.patch("/{inv_id}", response_model=InvoiceRead)
+def update_invoice(inv_id: str, payload: InvoiceUpdate, db: Session = Depends(get_db),
+ _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))):
+ svc = PaymentService(db)
+ inv = svc.get_invoice(inv_id)
+ if not inv:
+ raise HTTPException(status_code=404, detail="Not found")
+ return svc.update_invoice(inv, **payload.model_dump(exclude_none=True))
+
+@router.post("/{inv_id}/mark-paid", response_model=InvoiceRead)
+def mark_paid(inv_id: str, db: Session = Depends(get_db),
+ _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))):
+ svc = PaymentService(db)
+ inv = svc.get_invoice(inv_id)
+ if not inv:
+ raise HTTPException(status_code=404, detail="Not found")
+ return svc.mark_paid(inv)
+
+@router.delete("/{inv_id}", status_code=204)
+def delete_invoice(inv_id: str, db: Session = Depends(get_db),
+ _: UserClaims = Depends(require_roles("ADMIN"))):
+ svc = PaymentService(db)
+ inv = svc.get_invoice(inv_id)
+ if not inv:
+ return
+ svc.delete_invoice(inv)
+PY
+
+write_file services/payments/src/app/main.py <<'PY'
+from fastapi import FastAPI
+from .api.routes.ping import router as ping_router
+from .api.routes.payments import router as payments_router
+
+app = FastAPI(title="PAYMENTS Service")
+
+@app.get("/health")
+def health():
+ return {"status": "ok", "service": "payments"}
+
+app.include_router(ping_router, prefix="/v1")
+app.include_router(payments_router)
+PY
+
+# -------------------------------------------------------------------
+# 9) Обновить __init__.py пакетов (если scaffold создал пустые)
+# -------------------------------------------------------------------
+for s in "${SERVICES[@]}"; do
+ touch "services/$s/src/app/__init__.py"
+ touch "services/$s/src/app/api/__init__.py"
+ touch "services/$s/src/app/api/routes/__init__.py"
+ touch "services/$s/src/app/core/__init__.py"
+ touch "services/$s/src/app/db/__init__.py"
+ touch "services/$s/src/app/repositories/__init__.py"
+ touch "services/$s/src/app/schemas/__init__.py"
+ touch "services/$s/src/app/services/__init__.py"
+done
+
+echo "✅ Models + CRUD + API + Auth applied."
+
+cat <<'NEXT'
+Next steps:
+
+1) Сгенерируйте первичные миграции по моделям:
+ for s in auth profiles match chat payments; do
+ docker compose run --rm $s alembic revision --autogenerate -m "init"
+ done
+
+2) Поднимите окружение (alembic upgrade выполнится в entrypoint):
+ docker compose up --build
+
+3) Получите токен:
+ POST http://localhost:8080/auth/v1/register
+ POST http://localhost:8080/auth/v1/token
+ -> Authorization: Bearer
+
+4) Проверьте CRUD:
+ - Profiles: GET http://localhost:8080/profiles/v1/profiles/me
+ - Match: POST http://localhost:8080/match/v1/pairs
+ - Chat: POST http://localhost:8080/chat/v1/rooms
+ - Payments: POST http://localhost:8080/payments/v1/invoices
+
+Замечания по безопасности/продакшену:
+- Секрет JWT смените в .env (JWT_SECRET), храните в секретах CI/CD.
+- Сроки жизни токенов подберите под бизнес-политику.
+- Для refresh-токенов сейчас статeless-вариант; при необходимости добавьте
+ хранилище jti/ревокацию.
+- Для CHAT и MATCH в проде добавьте строгую проверку участия/прав.
+- В PROFILES поля languages/interests/preferences — JSONB; при желании замените
+ на нормализованные таблицы или ARRAY.
+NEXT
diff --git a/.history/models_20250808195931.sh b/.history/models_20250808195931.sh
new file mode 100644
index 0000000..1469e6c
--- /dev/null
+++ b/.history/models_20250808195931.sh
@@ -0,0 +1,1564 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# -------------------------------------------------------------------
+# Apply models + CRUD + API + JWT auth to the existing scaffold
+# Requires: the scaffold created earlier (services/* exist)
+# -------------------------------------------------------------------
+
+ROOT_DIR="."
+SERVICES=(auth profiles match chat payments)
+
+ensure_line() {
+ # ensure_line
+ local file="$1" ; shift
+ local line="$*"
+ grep -qxF "$line" "$file" 2>/dev/null || echo "$line" >> "$file"
+}
+
+write_file() {
+ # write_file <<'EOF' ... EOF
+ local path="$1"
+ mkdir -p "$(dirname "$path")"
+ # The content will be provided by heredoc by the caller
+ cat > "$path"
+}
+
+append_file() {
+ local path="$1"
+ mkdir -p "$(dirname "$path")"
+ cat >> "$path"
+}
+
+require_file() {
+ local path="$1"
+ if [[ ! -f "$path" ]]; then
+ echo "ERROR: Missing $path. Run scaffold.sh first." >&2
+ exit 1
+ fi
+}
+
+# Basic checks
+require_file docker-compose.yml
+
+# -------------------------------------------------------------------
+# 1) .env.example — добавить JWT настройки (общие для всех сервисов)
+# -------------------------------------------------------------------
+ENV_FILE=".env.example"
+require_file "$ENV_FILE"
+
+ensure_line "$ENV_FILE" "# ---------- JWT / Auth ----------"
+ensure_line "$ENV_FILE" "JWT_SECRET=devsecret_change_me"
+ensure_line "$ENV_FILE" "JWT_ALGORITHM=HS256"
+ensure_line "$ENV_FILE" "ACCESS_TOKEN_EXPIRES_MIN=15"
+ensure_line "$ENV_FILE" "REFRESH_TOKEN_EXPIRES_MIN=43200 # 30 days"
+
+# -------------------------------------------------------------------
+# 2) requirements.txt — добавить PyJWT везде, в auth — ещё passlib[bcrypt]
+# -------------------------------------------------------------------
+for s in "${SERVICES[@]}"; do
+ REQ="services/$s/requirements.txt"
+ require_file "$REQ"
+ ensure_line "$REQ" "PyJWT>=2.8"
+ if [[ "$s" == "auth" ]]; then
+ ensure_line "$REQ" "passlib[bcrypt]>=1.7"
+ fi
+done
+
+# -------------------------------------------------------------------
+# 3) Общая безопасность (JWT) для всех сервисов
+# В auth добавим + генерацию токенов, в остальных — верификация и RBAC
+# -------------------------------------------------------------------
+for s in "${SERVICES[@]}"; do
+ SEC="services/$s/src/app/core/security.py"
+ mkdir -p "$(dirname "$SEC")"
+ if [[ "$s" == "auth" ]]; then
+ write_file "$SEC" <<'PY'
+from __future__ import annotations
+import os
+from datetime import datetime, timedelta, timezone
+from enum import Enum
+from typing import Any, Callable, Optional
+
+import jwt
+from fastapi import Depends, HTTPException, status
+from fastapi.security import OAuth2PasswordBearer
+from pydantic import BaseModel
+
+JWT_SECRET = os.getenv("JWT_SECRET", "devsecret_change_me")
+JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
+ACCESS_TOKEN_EXPIRES_MIN = int(os.getenv("ACCESS_TOKEN_EXPIRES_MIN", "15"))
+REFRESH_TOKEN_EXPIRES_MIN = int(os.getenv("REFRESH_TOKEN_EXPIRES_MIN", "43200"))
+
+class TokenType(str, Enum):
+ access = "access"
+ refresh = "refresh"
+
+class UserClaims(BaseModel):
+ sub: str
+ email: str
+ role: str
+ type: str
+ exp: int
+
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/v1/token")
+
+def _create_token(token_type: TokenType, *, sub: str, email: str, role: str, expires_minutes: int) -> str:
+ now = datetime.now(timezone.utc)
+ exp = now + timedelta(minutes=expires_minutes)
+ payload: dict[str, Any] = {
+ "sub": sub,
+ "email": email,
+ "role": role,
+ "type": token_type.value,
+ "exp": int(exp.timestamp()),
+ }
+ return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
+
+def create_access_token(*, sub: str, email: str, role: str) -> str:
+ return _create_token(TokenType.access, sub=sub, email=email, role=role, expires_minutes=ACCESS_TOKEN_EXPIRES_MIN)
+
+def create_refresh_token(*, sub: str, email: str, role: str) -> str:
+ return _create_token(TokenType.refresh, sub=sub, email=email, role=role, expires_minutes=REFRESH_TOKEN_EXPIRES_MIN)
+
+def decode_token(token: str) -> UserClaims:
+ try:
+ payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
+ return UserClaims(**payload)
+ except jwt.ExpiredSignatureError:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
+ except jwt.PyJWTError:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
+
+def get_current_user(token: str = Depends(oauth2_scheme)) -> UserClaims:
+ return decode_token(token)
+
+def require_roles(*roles: str) -> Callable[[UserClaims], UserClaims]:
+ def dep(user: UserClaims = Depends(get_current_user)) -> UserClaims:
+ if roles and user.role not in roles:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient role")
+ return user
+ return dep
+PY
+ else
+ write_file "$SEC" <<'PY'
+from __future__ import annotations
+import os
+from enum import Enum
+from typing import Any, Callable
+
+import jwt
+from fastapi import Depends, HTTPException, status
+from fastapi.security import OAuth2PasswordBearer
+from pydantic import BaseModel
+
+JWT_SECRET = os.getenv("JWT_SECRET", "devsecret_change_me")
+JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
+
+class UserClaims(BaseModel):
+ sub: str
+ email: str
+ role: str
+ type: str
+ exp: int
+
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/v1/token")
+
+def decode_token(token: str) -> UserClaims:
+ try:
+ payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
+ return UserClaims(**payload)
+ except jwt.ExpiredSignatureError:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
+ except jwt.PyJWTError:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
+
+def get_current_user(token: str = Depends(oauth2_scheme)) -> UserClaims:
+ return decode_token(token)
+
+def require_roles(*roles: str):
+ def dep(user: UserClaims = Depends(get_current_user)) -> UserClaims:
+ if roles and user.role not in roles:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient role")
+ return user
+ return dep
+PY
+ fi
+done
+
+# -------------------------------------------------------------------
+# 4) AUTH service — модели, CRUD, токены, эндпоинты
+# -------------------------------------------------------------------
+# models
+write_file services/auth/src/app/models/user.py <<'PY'
+from __future__ import annotations
+import uuid
+from datetime import datetime
+from enum import Enum
+
+from sqlalchemy import String, Boolean, DateTime
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import Mapped, mapped_column
+from sqlalchemy.sql import func
+
+from app.db.session import Base
+
+class Role(str, Enum):
+ ADMIN = "ADMIN"
+ MATCHMAKER = "MATCHMAKER"
+ CLIENT = "CLIENT"
+
+class User(Base):
+ __tablename__ = "users"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
+ password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
+ full_name: Mapped[str | None] = mapped_column(String(255), default=None)
+ role: Mapped[str] = mapped_column(String(32), default=Role.CLIENT.value, nullable=False)
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
+PY
+
+write_file services/auth/src/app/models/__init__.py <<'PY'
+from .user import User, Role # noqa: F401
+PY
+
+# schemas
+write_file services/auth/src/app/schemas/user.py <<'PY'
+from __future__ import annotations
+from typing import Optional
+from pydantic import BaseModel, EmailStr, ConfigDict
+
+class UserBase(BaseModel):
+ email: EmailStr
+ full_name: Optional[str] = None
+ role: str = "CLIENT"
+ is_active: bool = True
+
+class UserCreate(BaseModel):
+ email: EmailStr
+ password: str
+ full_name: Optional[str] = None
+ role: str = "CLIENT"
+
+class UserUpdate(BaseModel):
+ full_name: Optional[str] = None
+ role: Optional[str] = None
+ is_active: Optional[bool] = None
+ password: Optional[str] = None
+
+class UserRead(BaseModel):
+ id: str
+ email: EmailStr
+ full_name: Optional[str] = None
+ role: str
+ is_active: bool
+ model_config = ConfigDict(from_attributes=True)
+
+class LoginRequest(BaseModel):
+ email: EmailStr
+ password: str
+
+class TokenPair(BaseModel):
+ access_token: str
+ refresh_token: str
+ token_type: str = "bearer"
+PY
+
+# passwords
+write_file services/auth/src/app/core/passwords.py <<'PY'
+from passlib.context import CryptContext
+
+_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
+
+def hash_password(p: str) -> str:
+ return _pwd.hash(p)
+
+def verify_password(p: str, hashed: str) -> bool:
+ return _pwd.verify(p, hashed)
+PY
+
+# repositories
+write_file services/auth/src/app/repositories/user_repository.py <<'PY'
+from __future__ import annotations
+from typing import Optional, Sequence
+from sqlalchemy.orm import Session
+from sqlalchemy import select, update, delete
+
+from app.models.user import User
+
+class UserRepository:
+ def __init__(self, db: Session):
+ self.db = db
+
+ def get(self, user_id) -> Optional[User]:
+ return self.db.get(User, user_id)
+
+ def get_by_email(self, email: str) -> Optional[User]:
+ stmt = select(User).where(User.email == email)
+ return self.db.execute(stmt).scalar_one_or_none()
+
+ def list(self, *, offset: int = 0, limit: int = 50) -> Sequence[User]:
+ stmt = select(User).offset(offset).limit(limit).order_by(User.created_at.desc())
+ return self.db.execute(stmt).scalars().all()
+
+ def create(self, *, email: str, password_hash: str, full_name: str | None, role: str) -> User:
+ user = User(email=email, password_hash=password_hash, full_name=full_name, role=role)
+ self.db.add(user)
+ self.db.commit()
+ self.db.refresh(user)
+ return user
+
+ def update(self, user: User, **fields) -> User:
+ for k, v in fields.items():
+ if v is not None:
+ setattr(user, k, v)
+ self.db.add(user)
+ self.db.commit()
+ self.db.refresh(user)
+ return user
+
+ def delete(self, user: User) -> None:
+ self.db.delete(user)
+ self.db.commit()
+PY
+
+# services
+write_file services/auth/src/app/services/user_service.py <<'PY'
+from __future__ import annotations
+from typing import Optional
+from sqlalchemy.orm import Session
+
+from app.repositories.user_repository import UserRepository
+from app.core.passwords import hash_password, verify_password
+from app.models.user import User
+
+class UserService:
+ def __init__(self, db: Session):
+ self.repo = UserRepository(db)
+
+ # CRUD
+ def create_user(self, *, email: str, password: str, full_name: str | None, role: str) -> User:
+ if self.repo.get_by_email(email):
+ raise ValueError("Email already in use")
+ pwd_hash = hash_password(password)
+ return self.repo.create(email=email, password_hash=pwd_hash, full_name=full_name, role=role)
+
+ def get_user(self, user_id) -> Optional[User]:
+ return self.repo.get(user_id)
+
+ def get_by_email(self, email: str) -> Optional[User]:
+ return self.repo.get_by_email(email)
+
+ def list_users(self, *, offset: int = 0, limit: int = 50):
+ return self.repo.list(offset=offset, limit=limit)
+
+ def update_user(self, user: User, *, full_name: str | None = None, role: str | None = None,
+ is_active: bool | None = None, password: str | None = None) -> User:
+ fields = {}
+ if full_name is not None: fields["full_name"] = full_name
+ if role is not None: fields["role"] = role
+ if is_active is not None: fields["is_active"] = is_active
+ if password: fields["password_hash"] = hash_password(password)
+ return self.repo.update(user, **fields)
+
+ def delete_user(self, user: User) -> None:
+ self.repo.delete(user)
+
+ # Auth
+ def authenticate(self, *, email: str, password: str) -> Optional[User]:
+ user = self.repo.get_by_email(email)
+ if not user or not user.is_active:
+ return None
+ if not verify_password(password, user.password_hash):
+ return None
+ return user
+PY
+
+# api routes
+write_file services/auth/src/app/api/routes/auth.py <<'PY'
+from __future__ import annotations
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy.orm import Session
+
+from app.db.session import get_db
+from app.schemas.user import UserCreate, LoginRequest, TokenPair, UserRead
+from app.services.user_service import UserService
+from app.core.security import create_access_token, create_refresh_token, get_current_user, UserClaims
+
+router = APIRouter(prefix="/v1", tags=["auth"])
+
+@router.post("/register", response_model=UserRead, status_code=201)
+def register(payload: UserCreate, db: Session = Depends(get_db)):
+ svc = UserService(db)
+ try:
+ user = svc.create_user(email=payload.email, password=payload.password,
+ full_name=payload.full_name, role=payload.role)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+ return user
+
+@router.post("/token", response_model=TokenPair)
+def token(payload: LoginRequest, db: Session = Depends(get_db)):
+ svc = UserService(db)
+ user = svc.authenticate(email=payload.email, password=payload.password)
+ if not user:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
+ access = create_access_token(sub=str(user.id), email=user.email, role=user.role)
+ refresh = create_refresh_token(sub=str(user.id), email=user.email, role=user.role)
+ return TokenPair(access_token=access, refresh_token=refresh)
+
+class RefreshRequest(LoginRequest.__class__):
+ refresh_token: str # type: ignore
+
+@router.post("/refresh", response_model=TokenPair)
+def refresh_token(req: dict):
+ # expects: {"refresh_token": ""}
+ from app.core.security import decode_token
+ token = req.get("refresh_token")
+ if not token:
+ raise HTTPException(status_code=400, detail="Missing refresh_token")
+ claims = decode_token(token)
+ if claims.type != "refresh":
+ raise HTTPException(status_code=400, detail="Not a refresh token")
+ access = create_access_token(sub=claims.sub, email=claims.email, role=claims.role)
+ refresh = create_refresh_token(sub=claims.sub, email=claims.email, role=claims.role)
+ return TokenPair(access_token=access, refresh_token=refresh)
+
+@router.get("/me", response_model=UserRead)
+def me(claims: UserClaims = Depends(get_current_user), db: Session = Depends(get_db)):
+ svc = UserService(db)
+ u = svc.get_user(claims.sub)
+ if not u:
+ raise HTTPException(status_code=404, detail="User not found")
+ return u
+PY
+
+write_file services/auth/src/app/api/routes/users.py <<'PY'
+from __future__ import annotations
+from fastapi import APIRouter, Depends, HTTPException, Query, status
+from sqlalchemy.orm import Session
+
+from app.db.session import get_db
+from app.core.security import require_roles
+from app.schemas.user import UserRead, UserUpdate, UserCreate
+from app.services.user_service import UserService
+
+router = APIRouter(prefix="/v1/users", tags=["users"])
+
+@router.get("", response_model=list[UserRead])
+def list_users(offset: int = 0, limit: int = Query(50, le=200), db: Session = Depends(get_db),
+ _: dict = Depends(require_roles("ADMIN"))):
+ return UserService(db).list_users(offset=offset, limit=limit)
+
+@router.post("", response_model=UserRead, status_code=201)
+def create_user(payload: UserCreate, db: Session = Depends(get_db),
+ _: dict = Depends(require_roles("ADMIN"))):
+ try:
+ return UserService(db).create_user(email=payload.email, password=payload.password,
+ full_name=payload.full_name, role=payload.role)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+
+@router.get("/{user_id}", response_model=UserRead)
+def get_user(user_id: str, db: Session = Depends(get_db),
+ _: dict = Depends(require_roles("ADMIN"))):
+ u = UserService(db).get_user(user_id)
+ if not u:
+ raise HTTPException(status_code=404, detail="User not found")
+ return u
+
+@router.patch("/{user_id}", response_model=UserRead)
+def update_user(user_id: str, payload: UserUpdate, db: Session = Depends(get_db),
+ _: dict = Depends(require_roles("ADMIN"))):
+ svc = UserService(db)
+ u = svc.get_user(user_id)
+ if not u:
+ raise HTTPException(status_code=404, detail="User not found")
+ return svc.update_user(u, full_name=payload.full_name, role=payload.role,
+ is_active=payload.is_active, password=payload.password)
+
+@router.delete("/{user_id}", status_code=204)
+def delete_user(user_id: str, db: Session = Depends(get_db),
+ _: dict = Depends(require_roles("ADMIN"))):
+ svc = UserService(db)
+ u = svc.get_user(user_id)
+ if not u:
+ return
+ svc.delete_user(u)
+PY
+
+# main.py update for auth
+write_file services/auth/src/app/main.py <<'PY'
+from fastapi import FastAPI
+from .api.routes.ping import router as ping_router
+from .api.routes.auth import router as auth_router
+from .api.routes.users import router as users_router
+
+app = FastAPI(title="AUTH Service")
+
+@app.get("/health")
+def health():
+ return {"status": "ok", "service": "auth"}
+
+app.include_router(ping_router, prefix="/v1")
+app.include_router(auth_router)
+app.include_router(users_router)
+PY
+
+# -------------------------------------------------------------------
+# 5) PROFILES service — Profile + Photo CRUD + поиск
+# -------------------------------------------------------------------
+write_file services/profiles/src/app/models/profile.py <<'PY'
+from __future__ import annotations
+import uuid
+from datetime import date, datetime
+
+from sqlalchemy import String, Date, DateTime, Text
+from sqlalchemy.dialects.postgresql import UUID, JSONB
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+from sqlalchemy.sql import func
+
+from app.db.session import Base
+
+class Profile(Base):
+ __tablename__ = "profiles"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
+ gender: Mapped[str] = mapped_column(String(16), nullable=False) # male/female/other
+ birthdate: Mapped[date | None] = mapped_column(Date, default=None)
+ city: Mapped[str | None] = mapped_column(String(120), default=None)
+ bio: Mapped[str | None] = mapped_column(Text, default=None)
+ languages: Mapped[dict | None] = mapped_column(JSONB, default=list) # e.g., ["ru","en"]
+ interests: Mapped[dict | None] = mapped_column(JSONB, default=list)
+ preferences: Mapped[dict | None] = mapped_column(JSONB, default=dict)
+ verification_status: Mapped[str] = mapped_column(String(16), default="unverified")
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
+
+ photos = relationship("Photo", back_populates="profile", cascade="all, delete-orphan")
+PY
+
+write_file services/profiles/src/app/models/photo.py <<'PY'
+from __future__ import annotations
+import uuid
+from datetime import datetime
+
+from sqlalchemy import String, Boolean, DateTime, ForeignKey
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+from sqlalchemy.sql import func
+
+from app.db.session import Base
+
+class Photo(Base):
+ __tablename__ = "photos"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ profile_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
+ url: Mapped[str] = mapped_column(String(500), nullable=False)
+ is_main: Mapped[bool] = mapped_column(Boolean, default=False)
+ status: Mapped[str] = mapped_column(String(16), default="pending") # pending/approved/rejected
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+
+ profile = relationship("Profile", back_populates="photos", primaryjoin="Photo.profile_id==Profile.id", viewonly=True)
+PY
+
+write_file services/profiles/src/app/models/__init__.py <<'PY'
+from .profile import Profile # noqa
+from .photo import Photo # noqa
+PY
+
+write_file services/profiles/src/app/schemas/profile.py <<'PY'
+from __future__ import annotations
+from datetime import date
+from typing import Optional, Any
+from pydantic import BaseModel, ConfigDict
+
+class PhotoCreate(BaseModel):
+ url: str
+ is_main: bool = False
+
+class PhotoRead(BaseModel):
+ id: str
+ url: str
+ is_main: bool
+ status: str
+ model_config = ConfigDict(from_attributes=True)
+
+class ProfileCreate(BaseModel):
+ gender: str
+ birthdate: Optional[date] = None
+ city: Optional[str] = None
+ bio: Optional[str] = None
+ languages: Optional[list[str]] = None
+ interests: Optional[list[str]] = None
+ preferences: Optional[dict[str, Any]] = None
+
+class ProfileUpdate(BaseModel):
+ gender: Optional[str] = None
+ birthdate: Optional[date] = None
+ city: Optional[str] = None
+ bio: Optional[str] = None
+ languages: Optional[list[str]] = None
+ interests: Optional[list[str]] = None
+ preferences: Optional[dict[str, Any]] = None
+ verification_status: Optional[str] = None
+
+class ProfileRead(BaseModel):
+ id: str
+ user_id: str
+ gender: str
+ birthdate: Optional[date] = None
+ city: Optional[str] = None
+ bio: Optional[str] = None
+ languages: Optional[list[str]] = None
+ interests: Optional[list[str]] = None
+ preferences: Optional[dict] = None
+ verification_status: str
+ model_config = ConfigDict(from_attributes=True)
+PY
+
+write_file services/profiles/src/app/repositories/profile_repository.py <<'PY'
+from __future__ import annotations
+from typing import Optional, Sequence
+from datetime import date, timedelta
+
+from sqlalchemy import select, and_
+from sqlalchemy.orm import Session
+
+from app.models.profile import Profile
+from app.models.photo import Photo
+
+class ProfileRepository:
+ def __init__(self, db: Session):
+ self.db = db
+
+ # Profile CRUD
+ def create_profile(self, *, user_id, **fields) -> Profile:
+ p = Profile(user_id=user_id, **fields)
+ self.db.add(p)
+ self.db.commit()
+ self.db.refresh(p)
+ return p
+
+ def get_profile(self, profile_id) -> Optional[Profile]:
+ return self.db.get(Profile, profile_id)
+
+ def get_by_user(self, user_id) -> Optional[Profile]:
+ stmt = select(Profile).where(Profile.user_id == user_id)
+ return self.db.execute(stmt).scalar_one_or_none()
+
+ def update_profile(self, profile: Profile, **fields) -> Profile:
+ for k, v in fields.items():
+ if v is not None:
+ setattr(profile, k, v)
+ self.db.add(profile)
+ self.db.commit()
+ self.db.refresh(profile)
+ return profile
+
+ def delete_profile(self, profile: Profile) -> None:
+ self.db.delete(profile)
+ self.db.commit()
+
+ def list_profiles(self, *, gender: str | None = None, city: str | None = None,
+ age_min: int | None = None, age_max: int | None = None,
+ offset: int = 0, limit: int = 50) -> Sequence[Profile]:
+ stmt = select(Profile)
+ conds = []
+ if gender:
+ conds.append(Profile.gender == gender)
+ if city:
+ conds.append(Profile.city == city)
+ # Age filter -> birthdate between (today - age_max) and (today - age_min)
+ if age_min is not None or age_max is not None:
+ today = date.today()
+ if age_min is not None:
+ max_birthdate = date(today.year - age_min, today.month, today.day)
+ conds.append(Profile.birthdate <= max_birthdate)
+ if age_max is not None:
+ min_birthdate = date(today.year - age_max - 1, today.month, today.day) + timedelta(days=1)
+ conds.append(Profile.birthdate >= min_birthdate)
+ if conds:
+ stmt = stmt.where(and_(*conds))
+ stmt = stmt.offset(offset).limit(limit).order_by(Profile.created_at.desc())
+ return self.db.execute(stmt).scalars().all()
+
+ # Photos
+ def add_photo(self, *, profile_id, url: str, is_main: bool) -> Photo:
+ photo = Photo(profile_id=profile_id, url=url, is_main=is_main)
+ self.db.add(photo)
+ if is_main:
+ # unset other main photos
+ self.db.execute(select(Photo).where(Photo.profile_id == profile_id))
+ self.db.commit()
+ self.db.refresh(photo)
+ return photo
+
+ def list_photos(self, *, profile_id) -> Sequence[Photo]:
+ stmt = select(Photo).where(Photo.profile_id == profile_id)
+ return self.db.execute(stmt).scalars().all()
+
+ def get_photo(self, photo_id) -> Optional[Photo]:
+ return self.db.get(Photo, photo_id)
+
+ def delete_photo(self, photo: Photo) -> None:
+ self.db.delete(photo)
+ self.db.commit()
+PY
+
+write_file services/profiles/src/app/services/profile_service.py <<'PY'
+from __future__ import annotations
+from sqlalchemy.orm import Session
+from typing import Optional
+
+from app.repositories.profile_repository import ProfileRepository
+from app.models.profile import Profile
+from app.models.photo import Photo
+
+class ProfileService:
+ def __init__(self, db: Session):
+ self.repo = ProfileRepository(db)
+
+ def create_profile(self, *, user_id, **fields) -> Profile:
+ return self.repo.create_profile(user_id=user_id, **fields)
+
+ def get_profile(self, profile_id) -> Optional[Profile]:
+ return self.repo.get_profile(profile_id)
+
+ def get_by_user(self, user_id) -> Optional[Profile]:
+ return self.repo.get_by_user(user_id)
+
+ def update_profile(self, profile: Profile, **fields) -> Profile:
+ return self.repo.update_profile(profile, **fields)
+
+ def delete_profile(self, profile: Profile) -> None:
+ return self.repo.delete_profile(profile)
+
+ def list_profiles(self, **filters):
+ return self.repo.list_profiles(**filters)
+
+ # photos
+ def add_photo(self, profile_id, *, url: str, is_main: bool) -> Photo:
+ return self.repo.add_photo(profile_id=profile_id, url=url, is_main=is_main)
+
+ def list_photos(self, profile_id):
+ return self.repo.list_photos(profile_id=profile_id)
+
+ def get_photo(self, photo_id) -> Photo | None:
+ return self.repo.get_photo(photo_id)
+
+ def delete_photo(self, photo: Photo) -> None:
+ self.repo.delete_photo(photo)
+PY
+
+write_file services/profiles/src/app/api/routes/profiles.py <<'PY'
+from __future__ import annotations
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy.orm import Session
+
+from app.db.session import get_db
+from app.core.security import get_current_user, require_roles, UserClaims
+from app.schemas.profile import ProfileCreate, ProfileRead, ProfileUpdate, PhotoCreate, PhotoRead
+from app.services.profile_service import ProfileService
+
+router = APIRouter(prefix="/v1", tags=["profiles"])
+
+@router.post("/profiles", response_model=ProfileRead, status_code=201)
+def create_profile(payload: ProfileCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ svc = ProfileService(db)
+ if svc.get_by_user(user.sub):
+ raise HTTPException(status_code=400, detail="Profile already exists")
+ p = svc.create_profile(user_id=user.sub, **payload.model_dump(exclude_none=True))
+ return p
+
+@router.get("/profiles/me", response_model=ProfileRead)
+def get_my_profile(db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ svc = ProfileService(db)
+ p = svc.get_by_user(user.sub)
+ if not p:
+ raise HTTPException(status_code=404, detail="Profile not found")
+ return p
+
+@router.get("/profiles", response_model=list[ProfileRead])
+def list_profiles(gender: str | None = None, city: str | None = None,
+ age_min: int | None = Query(None, ge=18, le=120),
+ age_max: int | None = Query(None, ge=18, le=120),
+ offset: int = 0, limit: int = Query(50, le=200),
+ db: Session = Depends(get_db),
+ _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))):
+ return ProfileService(db).list_profiles(gender=gender, city=city, age_min=age_min, age_max=age_max, offset=offset, limit=limit)
+
+@router.get("/profiles/{profile_id}", response_model=ProfileRead)
+def get_profile(profile_id: str, db: Session = Depends(get_db), _: UserClaims = Depends(get_current_user)):
+ p = ProfileService(db).get_profile(profile_id)
+ if not p:
+ raise HTTPException(status_code=404, detail="Profile not found")
+ return p
+
+@router.patch("/profiles/{profile_id}", response_model=ProfileRead)
+def update_profile(profile_id: str, payload: ProfileUpdate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ svc = ProfileService(db)
+ p = svc.get_profile(profile_id)
+ if not p:
+ raise HTTPException(status_code=404, detail="Profile not found")
+ if str(p.user_id) != user.sub and user.role not in ("ADMIN","MATCHMAKER"):
+ raise HTTPException(status_code=403, detail="Not allowed")
+ return svc.update_profile(p, **payload.model_dump(exclude_none=True))
+
+@router.delete("/profiles/{profile_id}", status_code=204)
+def delete_profile(profile_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ svc = ProfileService(db)
+ p = svc.get_profile(profile_id)
+ if not p:
+ return
+ if str(p.user_id) != user.sub and user.role not in ("ADMIN","MATCHMAKER"):
+ raise HTTPException(status_code=403, detail="Not allowed")
+ svc.delete_profile(p)
+
+# Photos
+@router.post("/profiles/{profile_id}/photos", response_model=PhotoRead, status_code=201)
+def add_photo(profile_id: str, payload: PhotoCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ svc = ProfileService(db)
+ p = svc.get_profile(profile_id)
+ if not p:
+ raise HTTPException(status_code=404, detail="Profile not found")
+ if str(p.user_id) != user.sub and user.role not in ("ADMIN","MATCHMAKER"):
+ raise HTTPException(status_code=403, detail="Not allowed")
+ photo = svc.add_photo(profile_id, url=payload.url, is_main=payload.is_main)
+ return photo
+
+@router.get("/profiles/{profile_id}/photos", response_model=list[PhotoRead])
+def list_photos(profile_id: str, db: Session = Depends(get_db), _: UserClaims = Depends(get_current_user)):
+ svc = ProfileService(db)
+ return svc.list_photos(profile_id)
+
+@router.delete("/photos/{photo_id}", status_code=204)
+def delete_photo(photo_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ svc = ProfileService(db)
+ photo = svc.get_photo(photo_id)
+ if not photo:
+ return
+ # Lookup profile to check ownership
+ p = svc.get_profile(photo.profile_id)
+ if not p or (str(p.user_id) != user.sub and user.role not in ("ADMIN","MATCHMAKER")):
+ raise HTTPException(status_code=403, detail="Not allowed")
+ svc.delete_photo(photo)
+PY
+
+# main.py for profiles
+write_file services/profiles/src/app/main.py <<'PY'
+from fastapi import FastAPI
+from .api.routes.ping import router as ping_router
+from .api.routes.profiles import router as profiles_router
+
+app = FastAPI(title="PROFILES Service")
+
+@app.get("/health")
+def health():
+ return {"status": "ok", "service": "profiles"}
+
+app.include_router(ping_router, prefix="/v1")
+app.include_router(profiles_router)
+PY
+
+# -------------------------------------------------------------------
+# 6) MATCH service — пары и статусы (proposed/accepted/rejected/blocked)
+# -------------------------------------------------------------------
+write_file services/match/src/app/models/pair.py <<'PY'
+from __future__ import annotations
+import uuid
+from datetime import datetime
+from sqlalchemy import String, Float, DateTime
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import Mapped, mapped_column
+from sqlalchemy.sql import func
+
+from app.db.session import Base
+
+class MatchPair(Base):
+ __tablename__ = "match_pairs"
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ # User IDs to validate permissions; profile IDs можно добавить позже
+ user_id_a: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
+ user_id_b: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
+ status: Mapped[str] = mapped_column(String(16), default="proposed") # proposed/accepted/rejected/blocked
+ score: Mapped[float | None] = mapped_column(Float, default=None)
+ notes: Mapped[str | None] = mapped_column(String(1000), default=None)
+ created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True) # matchmaker/admin
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
+PY
+
+write_file services/match/src/app/models/__init__.py <<'PY'
+from .pair import MatchPair # noqa
+PY
+
+write_file services/match/src/app/schemas/pair.py <<'PY'
+from __future__ import annotations
+from typing import Optional
+from pydantic import BaseModel, ConfigDict
+
+class PairCreate(BaseModel):
+ user_id_a: str
+ user_id_b: str
+ score: Optional[float] = None
+ notes: Optional[str] = None
+
+class PairUpdate(BaseModel):
+ score: Optional[float] = None
+ notes: Optional[str] = None
+
+class PairRead(BaseModel):
+ id: str
+ user_id_a: str
+ user_id_b: str
+ status: str
+ score: Optional[float] = None
+ notes: Optional[str] = None
+ model_config = ConfigDict(from_attributes=True)
+PY
+
+write_file services/match/src/app/repositories/pair_repository.py <<'PY'
+from __future__ import annotations
+from typing import Optional, Sequence
+from sqlalchemy import select, or_
+from sqlalchemy.orm import Session
+
+from app.models.pair import MatchPair
+
+class PairRepository:
+ def __init__(self, db: Session):
+ self.db = db
+
+ def create(self, **fields) -> MatchPair:
+ obj = MatchPair(**fields)
+ self.db.add(obj)
+ self.db.commit()
+ self.db.refresh(obj)
+ return obj
+
+ def get(self, pair_id) -> Optional[MatchPair]:
+ return self.db.get(MatchPair, pair_id)
+
+ def list(self, *, for_user_id: str | None = None, status: str | None = None,
+ offset: int = 0, limit: int = 50) -> Sequence[MatchPair]:
+ stmt = select(MatchPair)
+ if for_user_id:
+ stmt = stmt.where(or_(MatchPair.user_id_a == for_user_id, MatchPair.user_id_b == for_user_id))
+ if status:
+ stmt = stmt.where(MatchPair.status == status)
+ stmt = stmt.offset(offset).limit(limit).order_by(MatchPair.created_at.desc())
+ return self.db.execute(stmt).scalars().all()
+
+ def update(self, obj: MatchPair, **fields) -> MatchPair:
+ for k, v in fields.items():
+ if v is not None:
+ setattr(obj, k, v)
+ self.db.add(obj)
+ self.db.commit()
+ self.db.refresh(obj)
+ return obj
+
+ def delete(self, obj: MatchPair) -> None:
+ self.db.delete(obj)
+ self.db.commit()
+PY
+
+write_file services/match/src/app/services/pair_service.py <<'PY'
+from __future__ import annotations
+from sqlalchemy.orm import Session
+from typing import Optional
+from app.repositories.pair_repository import PairRepository
+from app.models.pair import MatchPair
+
+class PairService:
+ def __init__(self, db: Session):
+ self.repo = PairRepository(db)
+
+ def create(self, **fields) -> MatchPair:
+ return self.repo.create(**fields)
+
+ def get(self, pair_id) -> Optional[MatchPair]:
+ return self.repo.get(pair_id)
+
+ def list(self, **filters):
+ return self.repo.list(**filters)
+
+ def update(self, obj: MatchPair, **fields) -> MatchPair:
+ return self.repo.update(obj, **fields)
+
+ def delete(self, obj: MatchPair) -> None:
+ return self.repo.delete(obj)
+
+ def set_status(self, obj: MatchPair, status: str) -> MatchPair:
+ return self.repo.update(obj, status=status)
+PY
+
+write_file services/match/src/app/api/routes/pairs.py <<'PY'
+from __future__ import annotations
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy.orm import Session
+
+from app.db.session import get_db
+from app.core.security import get_current_user, require_roles, UserClaims
+from app.schemas.pair import PairCreate, PairUpdate, PairRead
+from app.services.pair_service import PairService
+
+router = APIRouter(prefix="/v1/pairs", tags=["pairs"])
+
+@router.post("", response_model=PairRead, status_code=201)
+def create_pair(payload: PairCreate, db: Session = Depends(get_db),
+ user: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))):
+ svc = PairService(db)
+ return svc.create(user_id_a=payload.user_id_a, user_id_b=payload.user_id_b,
+ score=payload.score, notes=payload.notes, created_by=user.sub)
+
+@router.get("", response_model=list[PairRead])
+def list_pairs(for_user_id: str | None = None, status: str | None = None,
+ offset: int = 0, limit: int = Query(50, le=200),
+ db: Session = Depends(get_db),
+ _: UserClaims = Depends(get_current_user)):
+ return PairService(db).list(for_user_id=for_user_id, status=status, offset=offset, limit=limit)
+
+@router.get("/{pair_id}", response_model=PairRead)
+def get_pair(pair_id: str, db: Session = Depends(get_db), _: UserClaims = Depends(get_current_user)):
+ obj = PairService(db).get(pair_id)
+ if not obj:
+ raise HTTPException(status_code=404, detail="Not found")
+ return obj
+
+@router.patch("/{pair_id}", response_model=PairRead)
+def update_pair(pair_id: str, payload: PairUpdate, db: Session = Depends(get_db),
+ _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))):
+ svc = PairService(db)
+ obj = svc.get(pair_id)
+ if not obj:
+ raise HTTPException(status_code=404, detail="Not found")
+ return svc.update(obj, **payload.model_dump(exclude_none=True))
+
+@router.post("/{pair_id}/accept", response_model=PairRead)
+def accept(pair_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ svc = PairService(db)
+ obj = svc.get(pair_id)
+ if not obj:
+ raise HTTPException(status_code=404, detail="Not found")
+ # Validate that current user participates
+ if user.sub not in (str(obj.user_id_a), str(obj.user_id_b)):
+ raise HTTPException(status_code=403, detail="Not allowed")
+ return svc.set_status(obj, "accepted")
+
+@router.post("/{pair_id}/reject", response_model=PairRead)
+def reject(pair_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ svc = PairService(db)
+ obj = svc.get(pair_id)
+ if not obj:
+ raise HTTPException(status_code=404, detail="Not found")
+ if user.sub not in (str(obj.user_id_a), str(obj.user_id_b)):
+ raise HTTPException(status_code=403, detail="Not allowed")
+ return svc.set_status(obj, "rejected")
+
+@router.delete("/{pair_id}", status_code=204)
+def delete_pair(pair_id: str, db: Session = Depends(get_db),
+ _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))):
+ svc = PairService(db)
+ obj = svc.get(pair_id)
+ if not obj:
+ return
+ svc.delete(obj)
+PY
+
+write_file services/match/src/app/main.py <<'PY'
+from fastapi import FastAPI
+from .api.routes.ping import router as ping_router
+from .api.routes.pairs import router as pairs_router
+
+app = FastAPI(title="MATCH Service")
+
+@app.get("/health")
+def health():
+ return {"status": "ok", "service": "match"}
+
+app.include_router(ping_router, prefix="/v1")
+app.include_router(pairs_router)
+PY
+
+# -------------------------------------------------------------------
+# 7) CHAT service — комнаты и сообщения (REST, без WS)
+# -------------------------------------------------------------------
+write_file services/chat/src/app/models/chat.py <<'PY'
+from __future__ import annotations
+import uuid
+from datetime import datetime
+from sqlalchemy import String, DateTime, Text, ForeignKey, Boolean
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import Mapped, mapped_column
+from sqlalchemy.sql import func
+
+from app.db.session import Base
+
+class ChatRoom(Base):
+ __tablename__ = "chat_rooms"
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ title: Mapped[str | None] = mapped_column(String(255), default=None)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+
+class ChatParticipant(Base):
+ __tablename__ = "chat_participants"
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ room_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
+ user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
+ is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
+
+class Message(Base):
+ __tablename__ = "chat_messages"
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ room_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
+ sender_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
+ content: Mapped[str] = mapped_column(Text, nullable=False)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+PY
+
+write_file services/chat/src/app/models/__init__.py <<'PY'
+from .chat import ChatRoom, ChatParticipant, Message # noqa
+PY
+
+write_file services/chat/src/app/schemas/chat.py <<'PY'
+from __future__ import annotations
+from pydantic import BaseModel, ConfigDict
+from typing import Optional
+
+class RoomCreate(BaseModel):
+ title: Optional[str] = None
+ participants: list[str] # user IDs
+
+class RoomRead(BaseModel):
+ id: str
+ title: Optional[str] = None
+ model_config = ConfigDict(from_attributes=True)
+
+class MessageCreate(BaseModel):
+ content: str
+
+class MessageRead(BaseModel):
+ id: str
+ room_id: str
+ sender_id: str
+ content: str
+ model_config = ConfigDict(from_attributes=True)
+PY
+
+write_file services/chat/src/app/repositories/chat_repository.py <<'PY'
+from __future__ import annotations
+from typing import Sequence, Optional
+from sqlalchemy.orm import Session
+from sqlalchemy import select, or_
+
+from app.models.chat import ChatRoom, ChatParticipant, Message
+
+class ChatRepository:
+ def __init__(self, db: Session):
+ self.db = db
+
+ # Rooms
+ def create_room(self, title: str | None) -> ChatRoom:
+ r = ChatRoom(title=title)
+ self.db.add(r)
+ self.db.commit()
+ self.db.refresh(r)
+ return r
+
+ def add_participant(self, room_id, user_id, is_admin: bool = False) -> ChatParticipant:
+ p = ChatParticipant(room_id=room_id, user_id=user_id, is_admin=is_admin)
+ self.db.add(p)
+ self.db.commit()
+ self.db.refresh(p)
+ return p
+
+ def list_rooms_for_user(self, user_id) -> Sequence[ChatRoom]:
+ stmt = select(ChatRoom).join(ChatParticipant, ChatParticipant.room_id == ChatRoom.id)\
+ .where(ChatParticipant.user_id == user_id)
+ return self.db.execute(stmt).scalars().all()
+
+ def get_room(self, room_id) -> Optional[ChatRoom]:
+ return self.db.get(ChatRoom, room_id)
+
+ # Messages
+ def create_message(self, room_id, sender_id, content: str) -> Message:
+ m = Message(room_id=room_id, sender_id=sender_id, content=content)
+ self.db.add(m)
+ self.db.commit()
+ self.db.refresh(m)
+ return m
+
+ def list_messages(self, room_id, *, offset: int = 0, limit: int = 100) -> Sequence[Message]:
+ stmt = select(Message).where(Message.room_id == room_id).offset(offset).limit(limit).order_by(Message.created_at.asc())
+ return self.db.execute(stmt).scalars().all()
+PY
+
+write_file services/chat/src/app/services/chat_service.py <<'PY'
+from __future__ import annotations
+from sqlalchemy.orm import Session
+from typing import Optional, Sequence
+
+from app.repositories.chat_repository import ChatRepository
+from app.models.chat import ChatRoom, ChatParticipant, Message
+
+class ChatService:
+ def __init__(self, db: Session):
+ self.repo = ChatRepository(db)
+
+ def create_room(self, *, title: str | None, participant_ids: list[str], creator_id: str) -> ChatRoom:
+ room = self.repo.create_room(title)
+ # creator -> admin
+ self.repo.add_participant(room.id, creator_id, is_admin=True)
+ for uid in participant_ids:
+ if uid != creator_id:
+ self.repo.add_participant(room.id, uid, is_admin=False)
+ return room
+
+ def list_rooms_for_user(self, user_id: str) -> Sequence[ChatRoom]:
+ return self.repo.list_rooms_for_user(user_id)
+
+ def get_room(self, room_id: str) -> ChatRoom | None:
+ return self.repo.get_room(room_id)
+
+ def create_message(self, room_id: str, sender_id: str, content: str) -> Message:
+ return self.repo.create_message(room_id, sender_id, content)
+
+ def list_messages(self, room_id: str, offset: int = 0, limit: int = 100):
+ return self.repo.list_messages(room_id, offset=offset, limit=limit)
+PY
+
+write_file services/chat/src/app/api/routes/chat.py <<'PY'
+from __future__ import annotations
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy.orm import Session
+
+from app.db.session import get_db
+from app.core.security import get_current_user, UserClaims
+from app.schemas.chat import RoomCreate, RoomRead, MessageCreate, MessageRead
+from app.services.chat_service import ChatService
+
+router = APIRouter(prefix="/v1", tags=["chat"])
+
+@router.post("/rooms", response_model=RoomRead, status_code=201)
+def create_room(payload: RoomCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ svc = ChatService(db)
+ room = svc.create_room(title=payload.title, participant_ids=payload.participants, creator_id=user.sub)
+ return room
+
+@router.get("/rooms", response_model=list[RoomRead])
+def my_rooms(db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ return ChatService(db).list_rooms_for_user(user.sub)
+
+@router.get("/rooms/{room_id}", response_model=RoomRead)
+def get_room(room_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ room = ChatService(db).get_room(room_id)
+ if not room:
+ raise HTTPException(status_code=404, detail="Not found")
+ # NOTE: для простоты опускаем проверку участия (добавьте в проде)
+ return room
+
+@router.post("/rooms/{room_id}/messages", response_model=MessageRead, status_code=201)
+def send_message(room_id: str, payload: MessageCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ svc = ChatService(db)
+ room = svc.get_room(room_id)
+ if not room:
+ raise HTTPException(status_code=404, detail="Room not found")
+ msg = svc.create_message(room_id, user.sub, payload.content)
+ return msg
+
+@router.get("/rooms/{room_id}/messages", response_model=list[MessageRead])
+def list_messages(room_id: str, offset: int = 0, limit: int = Query(100, le=500),
+ db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ svc = ChatService(db)
+ room = svc.get_room(room_id)
+ if not room:
+ raise HTTPException(status_code=404, detail="Room not found")
+ return svc.list_messages(room_id, offset=offset, limit=limit)
+PY
+
+write_file services/chat/src/app/main.py <<'PY'
+from fastapi import FastAPI
+from .api.routes.ping import router as ping_router
+from .api.routes.chat import router as chat_router
+
+app = FastAPI(title="CHAT Service")
+
+@app.get("/health")
+def health():
+ return {"status": "ok", "service": "chat"}
+
+app.include_router(ping_router, prefix="/v1")
+app.include_router(chat_router)
+PY
+
+# -------------------------------------------------------------------
+# 8) PAYMENTS service — инвойсы (простая версия)
+# -------------------------------------------------------------------
+write_file services/payments/src/app/models/payment.py <<'PY'
+from __future__ import annotations
+import uuid
+from datetime import datetime
+from sqlalchemy import String, DateTime, Numeric
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import Mapped, mapped_column
+from sqlalchemy.sql import func
+
+from app.db.session import Base
+
+class Invoice(Base):
+ __tablename__ = "invoices"
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ client_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
+ amount: Mapped[float] = mapped_column(Numeric(12,2), nullable=False)
+ currency: Mapped[str] = mapped_column(String(3), default="USD")
+ status: Mapped[str] = mapped_column(String(16), default="pending") # pending/paid/canceled
+ description: Mapped[str | None] = mapped_column(String(500), default=None)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
+PY
+
+write_file services/payments/src/app/models/__init__.py <<'PY'
+from .payment import Invoice # noqa
+PY
+
+write_file services/payments/src/app/schemas/payment.py <<'PY'
+from __future__ import annotations
+from typing import Optional
+from pydantic import BaseModel, ConfigDict
+
+class InvoiceCreate(BaseModel):
+ client_id: str
+ amount: float
+ currency: str = "USD"
+ description: Optional[str] = None
+
+class InvoiceUpdate(BaseModel):
+ amount: Optional[float] = None
+ currency: Optional[str] = None
+ description: Optional[str] = None
+ status: Optional[str] = None
+
+class InvoiceRead(BaseModel):
+ id: str
+ client_id: str
+ amount: float
+ currency: str
+ status: str
+ description: Optional[str] = None
+ model_config = ConfigDict(from_attributes=True)
+PY
+
+write_file services/payments/src/app/repositories/payment_repository.py <<'PY'
+from __future__ import annotations
+from typing import Optional, Sequence
+from sqlalchemy.orm import Session
+from sqlalchemy import select
+
+from app.models.payment import Invoice
+
+class PaymentRepository:
+ def __init__(self, db: Session):
+ self.db = db
+
+ def create_invoice(self, **fields) -> Invoice:
+ obj = Invoice(**fields)
+ self.db.add(obj)
+ self.db.commit()
+ self.db.refresh(obj)
+ return obj
+
+ def get_invoice(self, inv_id) -> Optional[Invoice]:
+ return self.db.get(Invoice, inv_id)
+
+ def list_invoices(self, *, client_id: str | None = None, status: str | None = None,
+ offset: int = 0, limit: int = 50) -> Sequence[Invoice]:
+ stmt = select(Invoice)
+ if client_id:
+ stmt = stmt.where(Invoice.client_id == client_id)
+ if status:
+ stmt = stmt.where(Invoice.status == status)
+ stmt = stmt.offset(offset).limit(limit).order_by(Invoice.created_at.desc())
+ return self.db.execute(stmt).scalars().all()
+
+ def update_invoice(self, obj: Invoice, **fields) -> Invoice:
+ for k, v in fields.items():
+ if v is not None:
+ setattr(obj, k, v)
+ self.db.add(obj)
+ self.db.commit()
+ self.db.refresh(obj)
+ return obj
+
+ def delete_invoice(self, obj: Invoice) -> None:
+ self.db.delete(obj)
+ self.db.commit()
+PY
+
+write_file services/payments/src/app/services/payment_service.py <<'PY'
+from __future__ import annotations
+from sqlalchemy.orm import Session
+from typing import Optional
+from app.repositories.payment_repository import PaymentRepository
+from app.models.payment import Invoice
+
+class PaymentService:
+ def __init__(self, db: Session):
+ self.repo = PaymentRepository(db)
+
+ def create_invoice(self, **fields) -> Invoice:
+ return self.repo.create_invoice(**fields)
+
+ def get_invoice(self, inv_id) -> Invoice | None:
+ return self.repo.get_invoice(inv_id)
+
+ def list_invoices(self, **filters):
+ return self.repo.list_invoices(**filters)
+
+ def update_invoice(self, obj: Invoice, **fields) -> Invoice:
+ return self.repo.update_invoice(obj, **fields)
+
+ def delete_invoice(self, obj: Invoice) -> None:
+ return self.repo.delete_invoice(obj)
+
+ def mark_paid(self, obj: Invoice) -> Invoice:
+ return self.repo.update_invoice(obj, status="paid")
+PY
+
+write_file services/payments/src/app/api/routes/payments.py <<'PY'
+from __future__ import annotations
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy.orm import Session
+
+from app.db.session import get_db
+from app.core.security import get_current_user, require_roles, UserClaims
+from app.schemas.payment import InvoiceCreate, InvoiceUpdate, InvoiceRead
+from app.services.payment_service import PaymentService
+
+router = APIRouter(prefix="/v1/invoices", tags=["payments"])
+
+@router.post("", response_model=InvoiceRead, status_code=201)
+def create_invoice(payload: InvoiceCreate, db: Session = Depends(get_db),
+ _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))):
+ return PaymentService(db).create_invoice(**payload.model_dump(exclude_none=True))
+
+@router.get("", response_model=list[InvoiceRead])
+def list_invoices(client_id: str | None = None, status: str | None = None,
+ offset: int = 0, limit: int = Query(50, le=200),
+ db: Session = Depends(get_db),
+ user: UserClaims = Depends(get_current_user)):
+ # Клиент видит только свои инвойсы, админ/матчмейкер — любые
+ if user.role in ("ADMIN","MATCHMAKER"):
+ return PaymentService(db).list_invoices(client_id=client_id, status=status, offset=offset, limit=limit)
+ else:
+ return PaymentService(db).list_invoices(client_id=user.sub, status=status, offset=offset, limit=limit)
+
+@router.get("/{inv_id}", response_model=InvoiceRead)
+def get_invoice(inv_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ inv = PaymentService(db).get_invoice(inv_id)
+ if not inv:
+ raise HTTPException(status_code=404, detail="Not found")
+ if user.role not in ("ADMIN","MATCHMAKER") and str(inv.client_id) != user.sub:
+ raise HTTPException(status_code=403, detail="Not allowed")
+ return inv
+
+@router.patch("/{inv_id}", response_model=InvoiceRead)
+def update_invoice(inv_id: str, payload: InvoiceUpdate, db: Session = Depends(get_db),
+ _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))):
+ svc = PaymentService(db)
+ inv = svc.get_invoice(inv_id)
+ if not inv:
+ raise HTTPException(status_code=404, detail="Not found")
+ return svc.update_invoice(inv, **payload.model_dump(exclude_none=True))
+
+@router.post("/{inv_id}/mark-paid", response_model=InvoiceRead)
+def mark_paid(inv_id: str, db: Session = Depends(get_db),
+ _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))):
+ svc = PaymentService(db)
+ inv = svc.get_invoice(inv_id)
+ if not inv:
+ raise HTTPException(status_code=404, detail="Not found")
+ return svc.mark_paid(inv)
+
+@router.delete("/{inv_id}", status_code=204)
+def delete_invoice(inv_id: str, db: Session = Depends(get_db),
+ _: UserClaims = Depends(require_roles("ADMIN"))):
+ svc = PaymentService(db)
+ inv = svc.get_invoice(inv_id)
+ if not inv:
+ return
+ svc.delete_invoice(inv)
+PY
+
+write_file services/payments/src/app/main.py <<'PY'
+from fastapi import FastAPI
+from .api.routes.ping import router as ping_router
+from .api.routes.payments import router as payments_router
+
+app = FastAPI(title="PAYMENTS Service")
+
+@app.get("/health")
+def health():
+ return {"status": "ok", "service": "payments"}
+
+app.include_router(ping_router, prefix="/v1")
+app.include_router(payments_router)
+PY
+
+# -------------------------------------------------------------------
+# 9) Обновить __init__.py пакетов (если scaffold создал пустые)
+# -------------------------------------------------------------------
+for s in "${SERVICES[@]}"; do
+ touch "services/$s/src/app/__init__.py"
+ touch "services/$s/src/app/api/__init__.py"
+ touch "services/$s/src/app/api/routes/__init__.py"
+ touch "services/$s/src/app/core/__init__.py"
+ touch "services/$s/src/app/db/__init__.py"
+ touch "services/$s/src/app/repositories/__init__.py"
+ touch "services/$s/src/app/schemas/__init__.py"
+ touch "services/$s/src/app/services/__init__.py"
+done
+
+for s in auth profiles match chat payments; do
+ docker compose run --rm $s alembic revision --autogenerate -m "init"
+done
+
+echo "✅ Models + CRUD + API + Auth applied."
+
+cat <<'NEXT'
+Next steps:
+
+1) Сгенерируйте первичные миграции по моделям:
+ for s in auth profiles match chat payments; do
+ docker compose run --rm $s alembic revision --autogenerate -m "init"
+ done
+
+2) Поднимите окружение (alembic upgrade выполнится в entrypoint):
+ docker compose up --build
+
+3) Получите токен:
+ POST http://localhost:8080/auth/v1/register
+ POST http://localhost:8080/auth/v1/token
+ -> Authorization: Bearer
+
+4) Проверьте CRUD:
+ - Profiles: GET http://localhost:8080/profiles/v1/profiles/me
+ - Match: POST http://localhost:8080/match/v1/pairs
+ - Chat: POST http://localhost:8080/chat/v1/rooms
+ - Payments: POST http://localhost:8080/payments/v1/invoices
+
+Замечания по безопасности/продакшену:
+- Секрет JWT смените в .env (JWT_SECRET), храните в секретах CI/CD.
+- Сроки жизни токенов подберите под бизнес-политику.
+- Для refresh-токенов сейчас статeless-вариант; при необходимости добавьте
+ хранилище jti/ревокацию.
+- Для CHAT и MATCH в проде добавьте строгую проверку участия/прав.
+- В PROFILES поля languages/interests/preferences — JSONB; при желании замените
+ на нормализованные таблицы или ARRAY.
+NEXT
diff --git a/.history/patch_20250808204338.sh b/.history/patch_20250808204338.sh
new file mode 100644
index 0000000..e69de29
diff --git a/.history/patch_20250808204342.sh b/.history/patch_20250808204342.sh
new file mode 100644
index 0000000..4c1b211
--- /dev/null
+++ b/.history/patch_20250808204342.sh
@@ -0,0 +1,68 @@
+# Сохраняем фиксер
+cat > fix_profiles_fk.sh <<'BASH'
+#!/usr/bin/env bash
+set -euo pipefail
+
+# 1) Обновим модель Photo: добавим ForeignKey + нормальную relationship
+cat > services/profiles/src/app/models/photo.py <<'PY'
+from __future__ import annotations
+import uuid
+from datetime import datetime
+
+from sqlalchemy import String, Boolean, DateTime, ForeignKey
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+from sqlalchemy.sql import func
+
+from app.db.session import Base
+
+class Photo(Base):
+ __tablename__ = "photos"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ profile_id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True),
+ ForeignKey("profiles.id", ondelete="CASCADE"),
+ index=True,
+ nullable=False,
+ )
+ url: Mapped[str] = mapped_column(String(500), nullable=False)
+ is_main: Mapped[bool] = mapped_column(Boolean, default=False)
+ status: Mapped[str] = mapped_column(String(16), default="pending") # pending/approved/rejected
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+
+ profile = relationship("Profile", back_populates="photos")
+PY
+
+# (необязательно, но полезно) поправим типы JSONB в Profile
+awk '
+ {print}
+ /languages:/ && $0 ~ /dict/ { sub(/dict \| None/, "list[str] | None"); print "# (autofixed: changed languages type to list[str])"}
+ /interests:/ && $0 ~ /dict/ { sub(/dict \| None/, "list[str] | None"); print "# (autofixed: changed interests type to list[str])"}
+' services/profiles/src/app/models/profile.py > services/profiles/src/app/models/profile.py.tmp \
+ && mv services/profiles/src/app/models/profile.py.tmp services/profiles/src/app/models/profile.py || true
+
+# 2) Сгенерируем ревизию Alembic (сравнить модели с БД)
+docker compose up -d postgres
+docker compose run --rm -v "$PWD/services/profiles":/app profiles \
+ sh -lc 'alembic revision --autogenerate -m "add FK photos.profile_id -> profiles.id"'
+
+# 3) Если автогенерация не добавила FK — вживлём вручную в последнюю ревизию
+LAST=$(ls -1t services/profiles/alembic/versions/*.py | head -n1)
+if ! grep -q "create_foreign_key" "$LAST"; then
+ # вставим импорт postgresql (на будущее) и create_foreign_key в upgrade()
+ sed -i '/import sqlalchemy as sa/a from sqlalchemy.dialects import postgresql' "$LAST"
+ awk '
+ BEGIN{done=0}
+ /def upgrade/ && done==0 {print; print " op.create_foreign_key("; print " '\''fk_photos_profile_id_profiles'\'',"; print " '\''photos'\'', '\''profiles'\'',"; print " ['\''profile_id'\''], ['\''id'\''],"; print " ondelete='\''CASCADE'\''"; print " )"; done=1; next}
+ {print}
+ ' "$LAST" > "$LAST.tmp" && mv "$LAST.tmp" "$LAST"
+fi
+
+# 4) Применим миграции и перезапустим сервис
+docker compose run --rm profiles alembic upgrade head
+docker compose restart profiles
+BASH
+
+chmod +x fix_profiles_fk.sh
+./fix_profiles_fk.sh
diff --git a/.history/patch_alembic_template_20250808201930.sh b/.history/patch_alembic_template_20250808201930.sh
new file mode 100644
index 0000000..e69de29
diff --git a/.history/patch_alembic_template_20250808201932.sh b/.history/patch_alembic_template_20250808201932.sh
new file mode 100644
index 0000000..32b1e3a
--- /dev/null
+++ b/.history/patch_alembic_template_20250808201932.sh
@@ -0,0 +1,50 @@
+cat > patch_alembic_template.sh <<'BASH'
+#!/usr/bin/env bash
+set -euo pipefail
+SERVICES=(auth profiles match chat payments)
+
+for s in "${SERVICES[@]}"; do
+ TPL="services/$s/alembic/script.py.mako"
+ mkdir -p "services/$s/alembic"
+ cat > "$TPL" <<'MAKO'
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = ${repr(up_revision)}
+down_revision = ${repr(down_revision)}
+branch_labels = ${repr(branch_labels)}
+depends_on = ${repr(depends_on)}
+
+def upgrade():
+ ${upgrades if upgrades else "pass"}
+
+
+def downgrade():
+ ${downgrades if downgrades else "pass"}
+MAKO
+ echo "[ok] template updated: $TPL"
+done
+
+# Убедимся, что в env.py импортированы модели (для автогенерации)
+for s in "${SERVICES[@]}"; do
+ ENV="services/$s/alembic/env.py"
+ if ! grep -q "from app import models" "$ENV"; then
+ awk '
+ /from app\.db\.session import Base/ && !x {print; print "from app import models # noqa: F401"; x=1; next}
+ {print}
+ ' "$ENV" > "$ENV.tmp" && mv "$ENV.tmp" "$ENV"
+ echo "[ok] added 'from app import models' to $ENV"
+ fi
+done
+BASH
+
+chmod +x patch_alembic_template.sh
+./patch_alembic_template.sh
diff --git a/.history/patch_alembic_template_20250808201952.sh b/.history/patch_alembic_template_20250808201952.sh
new file mode 100644
index 0000000..fe35e0b
--- /dev/null
+++ b/.history/patch_alembic_template_20250808201952.sh
@@ -0,0 +1,60 @@
+#!/usr/bin/env bash
+set -euo pipefail
+SERVICES=(auth profiles match chat payments)
+
+for s in "${SERVICES[@]}"; do
+ TPL="services/$s/alembic/script.py.mako"
+ mkdir -p "services/$s/alembic"
+ cat > "$TPL" <<'MAKO'
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = ${repr(up_revision)}
+down_revision = ${repr(down_revision)}
+branch_labels = ${repr(branch_labels)}
+depends_on = ${repr(depends_on)}
+
+def upgrade():
+ ${upgrades if upgrades else "pass"}
+
+
+def downgrade():
+ ${downgrades if downgrades else "pass"}
+MAKO
+ echo "[ok] template updated: $TPL"
+done
+
+# Убедимся, что в env.py импортированы модели (для автогенерации)
+for s in "${SERVICES[@]}"; do
+ ENV="services/$s/alembic/env.py"
+ if ! grep -q "from app import models" "$ENV"; then
+ awk '
+ /from app\.db\.session import Base/ && !x {print; print "from app import models # noqa: F401"; x=1; next}
+ {print}
+ ' "$ENV" > "$ENV.tmp" && mv "$ENV.tmp" "$ENV"
+ echo "[ok] added 'from app import models' to $ENV"
+ fi
+done
+
+# удалить ревизии, созданные с битым шаблоном
+for s in auth profiles match chat payments; do
+ rm -f services/$s/alembic/versions/*.py
+done
+
+# поднять Postgres (если не запущен)
+docker compose up -d postgres
+
+# автогенерация первичных ревизий (каждая сохранится в services//alembic/versions/)
+for s in auth profiles match chat payments; do
+ echo "[gen] $s"
+ docker compose run --rm -v "$PWD/services/$s":/app "$s" \
+ sh -lc 'alembic revision --autogenerate -m "init"'
+done
\ No newline at end of file
diff --git a/.history/patch_alembic_template_20250808202000.sh b/.history/patch_alembic_template_20250808202000.sh
new file mode 100644
index 0000000..1e5d5ec
--- /dev/null
+++ b/.history/patch_alembic_template_20250808202000.sh
@@ -0,0 +1,65 @@
+#!/usr/bin/env bash
+set -euo pipefail
+SERVICES=(auth profiles match chat payments)
+
+for s in "${SERVICES[@]}"; do
+ TPL="services/$s/alembic/script.py.mako"
+ mkdir -p "services/$s/alembic"
+ cat > "$TPL" <<'MAKO'
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = ${repr(up_revision)}
+down_revision = ${repr(down_revision)}
+branch_labels = ${repr(branch_labels)}
+depends_on = ${repr(depends_on)}
+
+def upgrade():
+ ${upgrades if upgrades else "pass"}
+
+
+def downgrade():
+ ${downgrades if downgrades else "pass"}
+MAKO
+ echo "[ok] template updated: $TPL"
+done
+
+# Убедимся, что в env.py импортированы модели (для автогенерации)
+for s in "${SERVICES[@]}"; do
+ ENV="services/$s/alembic/env.py"
+ if ! grep -q "from app import models" "$ENV"; then
+ awk '
+ /from app\.db\.session import Base/ && !x {print; print "from app import models # noqa: F401"; x=1; next}
+ {print}
+ ' "$ENV" > "$ENV.tmp" && mv "$ENV.tmp" "$ENV"
+ echo "[ok] added 'from app import models' to $ENV"
+ fi
+done
+
+# удалить ревизии, созданные с битым шаблоном
+for s in auth profiles match chat payments; do
+ rm -f services/$s/alembic/versions/*.py
+done
+
+# поднять Postgres (если не запущен)
+docker compose up -d postgres
+
+# автогенерация первичных ревизий (каждая сохранится в services//alembic/versions/)
+for s in auth profiles match chat payments; do
+ echo "[gen] $s"
+ docker compose run --rm -v "$PWD/services/$s":/app "$s" \
+ sh -lc 'alembic revision --autogenerate -m "init"'
+done
+
+for s in auth profiles match chat payments; do
+ echo "---- $s"
+ ls -1 services/$s/alembic/versions/
+done
\ No newline at end of file
diff --git a/.history/scripts/api_e2e_20250808212121.py b/.history/scripts/api_e2e_20250808212121.py
new file mode 100644
index 0000000..e69de29
diff --git a/.history/scripts/api_e2e_20250808212124.py b/.history/scripts/api_e2e_20250808212124.py
new file mode 100644
index 0000000..9b376e1
--- /dev/null
+++ b/.history/scripts/api_e2e_20250808212124.py
@@ -0,0 +1,437 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+import base64
+import json
+import logging
+import os
+import random
+import string
+import sys
+import time
+from dataclasses import dataclass
+from logging.handlers import RotatingFileHandler
+from typing import Any, Dict, Iterable, List, Optional, Tuple
+from urllib.parse import urljoin
+
+import requests
+from faker import Faker
+
+# -------------------------
+# Конфигурация по умолчанию
+# -------------------------
+DEFAULT_BASE_URL = os.getenv("BASE_URL", "http://localhost:8080")
+DEFAULT_PASSWORD = os.getenv("PASS", "secret123")
+DEFAULT_CLIENTS = int(os.getenv("CLIENTS", "2"))
+DEFAULT_EMAIL_DOMAIN = os.getenv("EMAIL_DOMAIN", "agency.dev")
+DEFAULT_LOG_FILE = os.getenv("LOG_FILE", "logs/api.log")
+DEFAULT_TIMEOUT = float(os.getenv("HTTP_TIMEOUT", "10.0"))
+
+# -------------------------
+# Логирование
+# -------------------------
+def setup_logger(path: str) -> logging.Logger:
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ logger = logging.getLogger("api_e2e")
+ logger.setLevel(logging.DEBUG)
+
+ # Ротация логов: до 5 файлов по 5 МБ
+ file_handler = RotatingFileHandler(path, maxBytes=5 * 1024 * 1024, backupCount=5, encoding="utf-8")
+ file_handler.setLevel(logging.DEBUG)
+ file_handler.setFormatter(logging.Formatter(
+ fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
+ datefmt="%Y-%m-%d %H:%M:%S",
+ ))
+ logger.addHandler(file_handler)
+
+ # Консоль — INFO и короче
+ console = logging.StreamHandler(sys.stdout)
+ console.setLevel(logging.INFO)
+ console.setFormatter(logging.Formatter("%(levelname)s | %(message)s"))
+ logger.addHandler(console)
+ return logger
+
+# -------------------------
+# Утилиты
+# -------------------------
+def b64url_json(token_part: str) -> Dict[str, Any]:
+ """Декодирует часть JWT (payload) без валидации сигнатуры."""
+ s = token_part + "=" * (-len(token_part) % 4)
+ return json.loads(base64.urlsafe_b64decode(s).decode("utf-8"))
+
+def decode_jwt_sub(token: str) -> str:
+ try:
+ payload = b64url_json(token.split(".")[1])
+ return str(payload.get("sub", "")) # UUID пользователя
+ except Exception:
+ return ""
+
+def mask_token(token: Optional[str]) -> str:
+ if not token:
+ return ""
+ return token[:12] + "..." if len(token) > 12 else token
+
+def now_ms() -> int:
+ return int(time.time() * 1000)
+
+@dataclass
+class UserCreds:
+ id: str
+ email: str
+ access_token: str
+ role: str
+
+# -------------------------
+# Класс-клиент
+# -------------------------
+class APIE2E:
+ def __init__(self, base_url: str, logger: logging.Logger, timeout: float = DEFAULT_TIMEOUT) -> None:
+ self.base_url = base_url.rstrip("/") + "/"
+ self.logger = logger
+ self.timeout = timeout
+ self.sess = requests.Session()
+
+ self.urls = {
+ "auth": urljoin(self.base_url, "auth/"),
+ "profiles": urljoin(self.base_url, "profiles/"),
+ "match": urljoin(self.base_url, "match/"),
+ "chat": urljoin(self.base_url, "chat/"),
+ "payments": urljoin(self.base_url, "payments/"),
+ }
+
+ # --------- низкоуровневый запрос с логированием ----------
+ def req(
+ self,
+ method: str,
+ url: str,
+ token: Optional[str] = None,
+ body: Optional[Dict[str, Any]] = None,
+ expected: Iterable[int] = (200,),
+ name: Optional[str] = None,
+ ) -> Tuple[int, Dict[str, Any], str]:
+ """Возвращает (status_code, json_body_or_{} , raw_text). Бросает исключение, если код не из expected."""
+ headers = {"Accept": "application/json"}
+ if token:
+ headers["Authorization"] = f"Bearer {token}"
+
+ # Безопасное логирование тела запроса
+ log_body = {}
+ if body:
+ log_body = dict(body)
+ # маскируем пароль/токен в логах
+ for key in list(log_body.keys()):
+ if key.lower() in ("password", "token", "access_token", "refresh_token"):
+ log_body[key] = "***hidden***"
+
+ started = now_ms()
+ self.logger.debug(
+ f"HTTP {method} {url} | headers={{Authorization: Bearer {mask_token(token)}}} | body={log_body}"
+ )
+
+ resp = None
+ text = ""
+ data: Dict[str, Any] = {}
+ try:
+ resp = self.sess.request(
+ method=method,
+ url=url,
+ json=body,
+ timeout=self.timeout,
+ )
+ text = resp.text
+ try:
+ data = resp.json() if text else {}
+ except ValueError:
+ data = {}
+ except Exception as e:
+ duration = now_ms() - started
+ self.logger.error(f"{name or url} FAILED transport error: {e} ({duration} ms)")
+ raise
+
+ duration = now_ms() - started
+ status = resp.status_code if resp else -1
+
+ self.logger.debug(f"← {status} in {duration} ms | body={text[:2000]}")
+
+ if expected and status not in expected:
+ msg = f"{name or url} unexpected status {status}, expected {list(expected)}; body={text}"
+ self.logger.error(msg)
+ raise RuntimeError(msg)
+
+ return status, data, text
+
+ # --------- health ----------
+ def wait_health(self, name: str, url: str, timeout_sec: int = 60) -> None:
+ self.logger.info(f"Waiting {name} health: {url}")
+ deadline = time.time() + timeout_sec
+ while time.time() < deadline:
+ try:
+ code, _, _ = self.req("GET", url, expected=(200,), name=f"{name}/health")
+ if code == 200:
+ self.logger.info(f"{name} is healthy")
+ return
+ except Exception:
+ pass
+ time.sleep(1)
+ raise TimeoutError(f"{name} not healthy in time: {url}")
+
+ # --------- auth ----------
+ def login(self, email: str, password: str) -> Tuple[str, str]:
+ url = urljoin(self.urls["auth"], "v1/token")
+ _, data, _ = self.req("POST", url, body={"email": email, "password": password}, expected=(200,), name="login")
+ token = data.get("access_token", "")
+ if not token:
+ raise RuntimeError("access_token is empty")
+ user_id = decode_jwt_sub(token)
+ if not user_id:
+ raise RuntimeError("cannot decode user id (sub) from token")
+ return user_id, token
+
+ def register(self, email: str, password: str, full_name: str, role: str) -> None:
+ url = urljoin(self.urls["auth"], "v1/register")
+ # /register в вашем бэке может возвращать 201/200, а иногда 500 при сериализации —
+ # поэтому не падаем на 500 сразу, а логинимся ниже.
+ try:
+ self.req(
+ "POST",
+ url,
+ body={"email": email, "password": password, "full_name": full_name, "role": role},
+ expected=(200, 201),
+ name="register",
+ )
+ except RuntimeError as e:
+ self.logger.warning(f"register returned non-2xx: {e} — will try login anyway")
+
+ def login_or_register(self, email: str, password: str, full_name: str, role: str) -> UserCreds:
+ # 1) пробуем логин
+ try:
+ uid, token = self.login(email, password)
+ self.logger.info(f"Login OK: {email} -> {uid}")
+ return UserCreds(id=uid, email=email, access_token=token, role=role)
+ except Exception as e:
+ self.logger.info(f"Login failed for {email}: {e}; will try register")
+
+ # 2) регистрируем (не фатально, если вернулся 500)
+ self.register(email, password, full_name, role)
+
+ # 3) снова логин
+ uid, token = self.login(email, password)
+ self.logger.info(f"Registered+Login OK: {email} -> {uid}")
+ return UserCreds(id=uid, email=email, access_token=token, role=role)
+
+ # --------- profiles ----------
+ def get_my_profile(self, token: str) -> Tuple[int, Dict[str, Any]]:
+ url = urljoin(self.urls["profiles"], "v1/profiles/me")
+ code, data, _ = self.req("GET", url, token=token, expected=(200, 404), name="profiles/me")
+ return code, data
+
+ def create_profile(
+ self,
+ token: str,
+ gender: str,
+ city: str,
+ languages: List[str],
+ interests: List[str],
+ ) -> Dict[str, Any]:
+ url = urljoin(self.urls["profiles"], "v1/profiles")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=token,
+ body={"gender": gender, "city": city, "languages": languages, "interests": interests},
+ expected=(200, 201),
+ name="profiles/create",
+ )
+ return data
+
+ def ensure_profile(
+ self, token: str, gender: str, city: str, languages: List[str], interests: List[str]
+ ) -> Dict[str, Any]:
+ code, p = self.get_my_profile(token)
+ if code == 200:
+ self.logger.info(f"Profile exists: id={p.get('id')}")
+ return p
+ self.logger.info("Profile not found -> creating")
+ p = self.create_profile(token, gender, city, languages, interests)
+ self.logger.info(f"Profile created: id={p.get('id')}")
+ return p
+
+ # --------- match ----------
+ def create_pair(self, admin_token: str, user_id_a: str, user_id_b: str, score: float, notes: str) -> Dict[str, Any]:
+ url = urljoin(self.urls["match"], "v1/pairs")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=admin_token,
+ body={"user_id_a": user_id_a, "user_id_b": user_id_b, "score": score, "notes": notes},
+ expected=(200, 201),
+ name="match/create_pair",
+ )
+ return data
+
+ # --------- chat ----------
+ def create_room(self, admin_token: str, title: str, participants: List[str]) -> Dict[str, Any]:
+ url = urljoin(self.urls["chat"], "v1/rooms")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=admin_token,
+ body={"title": title, "participants": participants},
+ expected=(200, 201),
+ name="chat/create_room",
+ )
+ return data
+
+ def send_message(self, admin_token: str, room_id: str, content: str) -> Dict[str, Any]:
+ url = urljoin(self.urls["chat"], f"v1/rooms/{room_id}/messages")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=admin_token,
+ body={"content": content},
+ expected=(200, 201),
+ name="chat/send_message",
+ )
+ return data
+
+ # --------- payments ----------
+ def create_invoice(
+ self, admin_token: str, client_id: str, amount: float, currency: str, description: str
+ ) -> Dict[str, Any]:
+ url = urljoin(self.urls["payments"], "v1/invoices")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=admin_token,
+ body={"client_id": client_id, "amount": amount, "currency": currency, "description": description},
+ expected=(200, 201),
+ name="payments/create_invoice",
+ )
+ return data
+
+ def mark_invoice_paid(self, admin_token: str, invoice_id: str) -> Dict[str, Any]:
+ url = urljoin(self.urls["payments"], f"v1/invoices/{invoice_id}/mark-paid")
+ _, data, _ = self.req("POST", url, token=admin_token, expected=(200,), name="payments/mark_paid")
+ return data
+
+# -------------------------
+# Генерация данных
+# -------------------------
+GENDERS = ["female", "male", "other"]
+CITIES = [
+ "Moscow", "Saint Petersburg", "Kazan", "Novosibirsk", "Krasnodar",
+ "Yekaterinburg", "Minsk", "Kyiv", "Almaty", "Tbilisi",
+]
+LANG_POOL = ["ru", "en", "de", "fr", "es", "it", "zh", "uk", "kk", "tr", "pl"]
+INTR_POOL = ["music", "travel", "reading", "sports", "movies", "games", "cooking", "yoga", "art", "tech"]
+
+def pick_languages(n: int = 2) -> List[str]:
+ n = max(1, min(n, len(LANG_POOL)))
+ return sorted(random.sample(LANG_POOL, n))
+
+def pick_interests(n: int = 3) -> List[str]:
+ n = max(1, min(n, len(INTR_POOL)))
+ return sorted(random.sample(INTR_POOL, n))
+
+def random_email(prefix: str, domain: str) -> str:
+ suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
+ return f"{prefix}+{int(time.time())}.{suffix}@{domain}"
+
+# -------------------------
+# Основной сценарий
+# -------------------------
+def main():
+ import argparse
+
+ parser = argparse.ArgumentParser(description="E2E тест API брачного агентства с фейковыми анкетами.")
+ parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="Gateway base URL (по умолчанию http://localhost:8080)")
+ parser.add_argument("--clients", type=int, default=DEFAULT_CLIENTS, help="Количество клиентских аккаунтов (>=2)")
+ parser.add_argument("--password", default=DEFAULT_PASSWORD, help="Пароль для всех тестовых пользователей")
+ parser.add_argument("--email-domain", default=DEFAULT_EMAIL_DOMAIN, help="Домен e-mail для теста (напр. agency.dev)")
+ parser.add_argument("--log-file", default=DEFAULT_LOG_FILE, help="Файл логов (по умолчанию logs/api.log)")
+ parser.add_argument("--seed", type=int, default=42, help="Генератор случайных чисел (seed)")
+ args = parser.parse_args()
+
+ random.seed(args.seed)
+ fake = Faker()
+ logger = setup_logger(args.log_file)
+ logger.info("=== API E2E START ===")
+ logger.info(f"BASE_URL={args.base_url} clients={args.clients} domain={args.email_domain}")
+
+ if args.clients < 2:
+ logger.error("Нужно минимум 2 клиента (для пары).")
+ sys.exit(2)
+
+ api = APIE2E(args.base_url, logger)
+
+ # Health checks через gateway
+ api.wait_health("gateway/auth", urljoin(api.urls["auth"], "health"))
+ api.wait_health("profiles", urljoin(api.urls["profiles"], "health"))
+ api.wait_health("match", urljoin(api.urls["match"], "health"))
+ api.wait_health("chat", urljoin(api.urls["chat"], "health"))
+ api.wait_health("payments", urljoin(api.urls["payments"], "health"))
+
+ # Админ
+ admin_email = random_email("admin", args.email_domain)
+ admin_full = fake.name()
+ admin = api.login_or_register(admin_email, args.password, admin_full, role="ADMIN")
+
+ # Клиенты
+ clients: List[UserCreds] = []
+ for i in range(args.clients):
+ email = random_email(f"user{i+1}", args.email_domain)
+ full = fake.name()
+ u = api.login_or_register(email, args.password, full, role="CLIENT")
+ clients.append(u)
+
+ # Профили для всех
+ for i, u in enumerate([admin] + clients, start=1):
+ gender = random.choice(GENDERS)
+ city = random.choice(CITIES)
+ languages = pick_languages(random.choice([1, 2, 3]))
+ interests = pick_interests(random.choice([2, 3, 4]))
+ logger.info(f"[{i}/{1+len(clients)}] Ensure profile for {u.email} (role={u.role})")
+ api.ensure_profile(u.access_token, gender, city, languages, interests)
+
+ # Match‑пара между двумя случайными клиентами
+ a, b = random.sample(clients, 2)
+ score = round(random.uniform(0.6, 0.98), 2)
+ pair = api.create_pair(admin.access_token, a.id, b.id, score, notes="e2e generated")
+ pair_id = str(pair.get("id", ""))
+ logger.info(f"Match pair created: id={pair_id}, {a.email} ↔ {b.email}, score={score}")
+
+ # Чат‑комната и сообщение
+ room = api.create_room(admin.access_token, title=f"{a.email} & {b.email}", participants=[a.id, b.id])
+ room_id = str(room.get("id", ""))
+ msg = api.send_message(admin.access_token, room_id, content="Hello from admin (e2e)")
+ msg_id = str(msg.get("id", ""))
+ logger.info(f"Chat message sent: room={room_id}, msg={msg_id}")
+
+ # Счёт для первого клиента
+ amount = random.choice([99.0, 199.0, 299.0])
+ inv = api.create_invoice(admin.access_token, client_id=a.id, amount=amount, currency="USD",
+ description="Consultation (e2e)")
+ inv_id = str(inv.get("id", ""))
+ invp = api.mark_invoice_paid(admin.access_token, inv_id)
+ logger.info(f"Invoice paid: id={inv_id}, status={invp.get('status')}")
+
+ # Итог
+ summary = {
+ "admin": {"email": admin.email, "id": admin.id},
+ "clients": [{"email": c.email, "id": c.id} for c in clients],
+ "pair_id": pair_id,
+ "room_id": room_id,
+ "message_id": msg_id,
+ "invoice_id": inv_id,
+ "invoice_status": invp.get("status"),
+ }
+ logger.info("=== SUMMARY ===")
+ logger.info(json.dumps(summary, ensure_ascii=False, indent=2))
+ print(json.dumps(summary, ensure_ascii=False, indent=2)) # на stdout — короткое резюме
+
+if __name__ == "__main__":
+ try:
+ main()
+ except KeyboardInterrupt:
+ print("\nInterrupted.", file=sys.stderr)
+ sys.exit(130)
diff --git a/.history/scripts/api_e2e_20250808213334.py b/.history/scripts/api_e2e_20250808213334.py
new file mode 100644
index 0000000..f7179fe
--- /dev/null
+++ b/.history/scripts/api_e2e_20250808213334.py
@@ -0,0 +1,416 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+import base64
+import json
+import logging
+import os
+import random
+import string
+import sys
+import time
+from dataclasses import dataclass
+from logging.handlers import RotatingFileHandler
+from typing import Any, Dict, Iterable, List, Optional, Tuple
+from urllib.parse import urljoin
+
+import requests
+from faker import Faker
+
+# -------------------------
+# Конфигурация по умолчанию
+# -------------------------
+DEFAULT_BASE_URL = os.getenv("BASE_URL", "http://localhost:8080")
+DEFAULT_PASSWORD = os.getenv("PASS", "secret123")
+DEFAULT_CLIENTS = int(os.getenv("CLIENTS", "2"))
+DEFAULT_EMAIL_DOMAIN = os.getenv("EMAIL_DOMAIN", "agency.dev")
+DEFAULT_LOG_FILE = os.getenv("LOG_FILE", "logs/api.log")
+DEFAULT_TIMEOUT = float(os.getenv("HTTP_TIMEOUT", "10.0"))
+
+# -------------------------
+# Логирование
+# -------------------------
+def setup_logger(path: str) -> logging.Logger:
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ logger = logging.getLogger("api_e2e")
+ logger.setLevel(logging.DEBUG)
+
+ # Ротация логов: до 5 файлов по 5 МБ
+ file_handler = RotatingFileHandler(path, maxBytes=5 * 1024 * 1024, backupCount=5, encoding="utf-8")
+ file_handler.setLevel(logging.DEBUG)
+ file_handler.setFormatter(logging.Formatter(
+ fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
+ datefmt="%Y-%m-%d %H:%M:%S",
+ ))
+ logger.addHandler(file_handler)
+
+ # Консоль — INFO и короче
+ console = logging.StreamHandler(sys.stdout)
+ console.setLevel(logging.INFO)
+ console.setFormatter(logging.Formatter("%(levelname)s | %(message)s"))
+ logger.addHandler(console)
+ return logger
+
+# -------------------------
+# Утилиты
+# -------------------------
+def b64url_json(token_part: str) -> Dict[str, Any]:
+ """Декодирует часть JWT (payload) без валидации сигнатуры."""
+ s = token_part + "=" * (-len(token_part) % 4)
+ return json.loads(base64.urlsafe_b64decode(s).decode("utf-8"))
+
+def decode_jwt_sub(token: str) -> str:
+ try:
+ payload = b64url_json(token.split(".")[1])
+ return str(payload.get("sub", "")) # UUID пользователя
+ except Exception:
+ return ""
+
+def mask_token(token: Optional[str]) -> str:
+ if not token:
+ return ""
+ return token[:12] + "..." if len(token) > 12 else token
+
+def now_ms() -> int:
+ return int(time.time() * 1000)
+
+@dataclass
+class UserCreds:
+ id: str
+ email: str
+ access_token: str
+ role: str
+
+# -------------------------
+# Класс-клиент
+# -------------------------
+class APIE2E:
+ def __init__(self, base_url: str, logger: logging.Logger, timeout: float = DEFAULT_TIMEOUT) -> None:
+ self.base_url = base_url.rstrip("/") + "/"
+ self.logger = logger
+ self.timeout = timeout
+ self.sess = requests.Session()
+
+ self.urls = {
+ "auth": urljoin(self.base_url, "auth/"),
+ "profiles": urljoin(self.base_url, "profiles/"),
+ "match": urljoin(self.base_url, "match/"),
+ "chat": urljoin(self.base_url, "chat/"),
+ "payments": urljoin(self.base_url, "payments/"),
+ }
+
+ # --------- низкоуровневый запрос с логированием ----------
+ def req(self, method, url, token=None, body=None, expected=(200,), name=None):
+ headers = {"Accept": "application/json"}
+ if token:
+ headers["Authorization"] = f"Bearer {token}"
+
+ log_body = {}
+ if body:
+ log_body = dict(body)
+ for key in list(log_body.keys()):
+ if key.lower() in ("password", "token", "access_token", "refresh_token"):
+ log_body[key] = "***hidden***"
+
+ started = now_ms()
+ self.logger.debug(
+ f"HTTP {method} {url} | headers={{Authorization: Bearer {mask_token(token)}}} | body={log_body}"
+ )
+
+ try:
+ resp = self.sess.request(method=method, url=url, json=body, timeout=self.timeout)
+ except Exception as e:
+ duration = now_ms() - started
+ self.logger.error(f"{name or url} FAILED transport error: {e} ({duration} ms)")
+ raise
+
+ text = resp.text or ""
+ try:
+ data = resp.json() if text else {}
+ except ValueError:
+ data = {}
+
+ duration = now_ms() - started
+ self.logger.debug(f"← {resp.status_code} in {duration} ms | body={text[:2000]}")
+ if expected and resp.status_code not in expected:
+ msg = f"{name or url} unexpected status {resp.status_code}, expected {list(expected)}; body={text}"
+ self.logger.error(msg)
+ raise RuntimeError(msg)
+ return resp.status_code, data, text
+
+
+ # --------- health ----------
+ def wait_health(self, name: str, url: str, timeout_sec: int = 60) -> None:
+ self.logger.info(f"Waiting {name} health: {url}")
+ deadline = time.time() + timeout_sec
+ while time.time() < deadline:
+ try:
+ code, _, _ = self.req("GET", url, expected=(200,), name=f"{name}/health")
+ if code == 200:
+ self.logger.info(f"{name} is healthy")
+ return
+ except Exception:
+ pass
+ time.sleep(1)
+ raise TimeoutError(f"{name} not healthy in time: {url}")
+
+ # --------- auth ----------
+ def login(self, email: str, password: str) -> Tuple[str, str]:
+ url = urljoin(self.urls["auth"], "v1/token")
+ _, data, _ = self.req("POST", url, body={"email": email, "password": password}, expected=(200,), name="login")
+ token = data.get("access_token", "")
+ if not token:
+ raise RuntimeError("access_token is empty")
+ user_id = decode_jwt_sub(token)
+ if not user_id:
+ raise RuntimeError("cannot decode user id (sub) from token")
+ return user_id, token
+
+ def register(self, email: str, password: str, full_name: str, role: str) -> None:
+ url = urljoin(self.urls["auth"], "v1/register")
+ # /register в вашем бэке может возвращать 201/200, а иногда 500 при сериализации —
+ # поэтому не падаем на 500 сразу, а логинимся ниже.
+ try:
+ self.req(
+ "POST",
+ url,
+ body={"email": email, "password": password, "full_name": full_name, "role": role},
+ expected=(200, 201),
+ name="register",
+ )
+ except RuntimeError as e:
+ self.logger.warning(f"register returned non-2xx: {e} — will try login anyway")
+
+ def login_or_register(self, email: str, password: str, full_name: str, role: str) -> UserCreds:
+ # 1) пробуем логин
+ try:
+ uid, token = self.login(email, password)
+ self.logger.info(f"Login OK: {email} -> {uid}")
+ return UserCreds(id=uid, email=email, access_token=token, role=role)
+ except Exception as e:
+ self.logger.info(f"Login failed for {email}: {e}; will try register")
+
+ # 2) регистрируем (не фатально, если вернулся 500)
+ self.register(email, password, full_name, role)
+
+ # 3) снова логин
+ uid, token = self.login(email, password)
+ self.logger.info(f"Registered+Login OK: {email} -> {uid}")
+ return UserCreds(id=uid, email=email, access_token=token, role=role)
+
+ # --------- profiles ----------
+ def get_my_profile(self, token: str) -> Tuple[int, Dict[str, Any]]:
+ url = urljoin(self.urls["profiles"], "v1/profiles/me")
+ code, data, _ = self.req("GET", url, token=token, expected=(200, 404), name="profiles/me")
+ return code, data
+
+ def create_profile(
+ self,
+ token: str,
+ gender: str,
+ city: str,
+ languages: List[str],
+ interests: List[str],
+ ) -> Dict[str, Any]:
+ url = urljoin(self.urls["profiles"], "v1/profiles")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=token,
+ body={"gender": gender, "city": city, "languages": languages, "interests": interests},
+ expected=(200, 201),
+ name="profiles/create",
+ )
+ return data
+
+ def ensure_profile(
+ self, token: str, gender: str, city: str, languages: List[str], interests: List[str]
+ ) -> Dict[str, Any]:
+ code, p = self.get_my_profile(token)
+ if code == 200:
+ self.logger.info(f"Profile exists: id={p.get('id')}")
+ return p
+ self.logger.info("Profile not found -> creating")
+ p = self.create_profile(token, gender, city, languages, interests)
+ self.logger.info(f"Profile created: id={p.get('id')}")
+ return p
+
+ # --------- match ----------
+ def create_pair(self, admin_token: str, user_id_a: str, user_id_b: str, score: float, notes: str) -> Dict[str, Any]:
+ url = urljoin(self.urls["match"], "v1/pairs")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=admin_token,
+ body={"user_id_a": user_id_a, "user_id_b": user_id_b, "score": score, "notes": notes},
+ expected=(200, 201),
+ name="match/create_pair",
+ )
+ return data
+
+ # --------- chat ----------
+ def create_room(self, admin_token: str, title: str, participants: List[str]) -> Dict[str, Any]:
+ url = urljoin(self.urls["chat"], "v1/rooms")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=admin_token,
+ body={"title": title, "participants": participants},
+ expected=(200, 201),
+ name="chat/create_room",
+ )
+ return data
+
+ def send_message(self, admin_token: str, room_id: str, content: str) -> Dict[str, Any]:
+ url = urljoin(self.urls["chat"], f"v1/rooms/{room_id}/messages")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=admin_token,
+ body={"content": content},
+ expected=(200, 201),
+ name="chat/send_message",
+ )
+ return data
+
+ # --------- payments ----------
+ def create_invoice(
+ self, admin_token: str, client_id: str, amount: float, currency: str, description: str
+ ) -> Dict[str, Any]:
+ url = urljoin(self.urls["payments"], "v1/invoices")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=admin_token,
+ body={"client_id": client_id, "amount": amount, "currency": currency, "description": description},
+ expected=(200, 201),
+ name="payments/create_invoice",
+ )
+ return data
+
+ def mark_invoice_paid(self, admin_token: str, invoice_id: str) -> Dict[str, Any]:
+ url = urljoin(self.urls["payments"], f"v1/invoices/{invoice_id}/mark-paid")
+ _, data, _ = self.req("POST", url, token=admin_token, expected=(200,), name="payments/mark_paid")
+ return data
+
+# -------------------------
+# Генерация данных
+# -------------------------
+GENDERS = ["female", "male", "other"]
+CITIES = [
+ "Moscow", "Saint Petersburg", "Kazan", "Novosibirsk", "Krasnodar",
+ "Yekaterinburg", "Minsk", "Kyiv", "Almaty", "Tbilisi",
+]
+LANG_POOL = ["ru", "en", "de", "fr", "es", "it", "zh", "uk", "kk", "tr", "pl"]
+INTR_POOL = ["music", "travel", "reading", "sports", "movies", "games", "cooking", "yoga", "art", "tech"]
+
+def pick_languages(n: int = 2) -> List[str]:
+ n = max(1, min(n, len(LANG_POOL)))
+ return sorted(random.sample(LANG_POOL, n))
+
+def pick_interests(n: int = 3) -> List[str]:
+ n = max(1, min(n, len(INTR_POOL)))
+ return sorted(random.sample(INTR_POOL, n))
+
+def random_email(prefix: str, domain: str) -> str:
+ suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
+ return f"{prefix}+{int(time.time())}.{suffix}@{domain}"
+
+# -------------------------
+# Основной сценарий
+# -------------------------
+def main():
+ import argparse
+
+ parser = argparse.ArgumentParser(description="E2E тест API брачного агентства с фейковыми анкетами.")
+ parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="Gateway base URL (по умолчанию http://localhost:8080)")
+ parser.add_argument("--clients", type=int, default=DEFAULT_CLIENTS, help="Количество клиентских аккаунтов (>=2)")
+ parser.add_argument("--password", default=DEFAULT_PASSWORD, help="Пароль для всех тестовых пользователей")
+ parser.add_argument("--email-domain", default=DEFAULT_EMAIL_DOMAIN, help="Домен e-mail для теста (напр. agency.dev)")
+ parser.add_argument("--log-file", default=DEFAULT_LOG_FILE, help="Файл логов (по умолчанию logs/api.log)")
+ parser.add_argument("--seed", type=int, default=42, help="Генератор случайных чисел (seed)")
+ args = parser.parse_args()
+
+ random.seed(args.seed)
+ fake = Faker()
+ logger = setup_logger(args.log_file)
+ logger.info("=== API E2E START ===")
+ logger.info(f"BASE_URL={args.base_url} clients={args.clients} domain={args.email_domain}")
+
+ if args.clients < 2:
+ logger.error("Нужно минимум 2 клиента (для пары).")
+ sys.exit(2)
+
+ api = APIE2E(args.base_url, logger)
+
+ # Health checks через gateway
+ api.wait_health("gateway/auth", urljoin(api.urls["auth"], "health"))
+ api.wait_health("profiles", urljoin(api.urls["profiles"], "health"))
+ api.wait_health("match", urljoin(api.urls["match"], "health"))
+ api.wait_health("chat", urljoin(api.urls["chat"], "health"))
+ api.wait_health("payments", urljoin(api.urls["payments"], "health"))
+
+ # Админ
+ admin_email = random_email("admin", args.email_domain)
+ admin_full = fake.name()
+ admin = api.login_or_register(admin_email, args.password, admin_full, role="ADMIN")
+
+ # Клиенты
+ clients: List[UserCreds] = []
+ for i in range(args.clients):
+ email = random_email(f"user{i+1}", args.email_domain)
+ full = fake.name()
+ u = api.login_or_register(email, args.password, full, role="CLIENT")
+ clients.append(u)
+
+ # Профили для всех
+ for i, u in enumerate([admin] + clients, start=1):
+ gender = random.choice(GENDERS)
+ city = random.choice(CITIES)
+ languages = pick_languages(random.choice([1, 2, 3]))
+ interests = pick_interests(random.choice([2, 3, 4]))
+ logger.info(f"[{i}/{1+len(clients)}] Ensure profile for {u.email} (role={u.role})")
+ api.ensure_profile(u.access_token, gender, city, languages, interests)
+
+ # Match‑пара между двумя случайными клиентами
+ a, b = random.sample(clients, 2)
+ score = round(random.uniform(0.6, 0.98), 2)
+ pair = api.create_pair(admin.access_token, a.id, b.id, score, notes="e2e generated")
+ pair_id = str(pair.get("id", ""))
+ logger.info(f"Match pair created: id={pair_id}, {a.email} ↔ {b.email}, score={score}")
+
+ # Чат‑комната и сообщение
+ room = api.create_room(admin.access_token, title=f"{a.email} & {b.email}", participants=[a.id, b.id])
+ room_id = str(room.get("id", ""))
+ msg = api.send_message(admin.access_token, room_id, content="Hello from admin (e2e)")
+ msg_id = str(msg.get("id", ""))
+ logger.info(f"Chat message sent: room={room_id}, msg={msg_id}")
+
+ # Счёт для первого клиента
+ amount = random.choice([99.0, 199.0, 299.0])
+ inv = api.create_invoice(admin.access_token, client_id=a.id, amount=amount, currency="USD",
+ description="Consultation (e2e)")
+ inv_id = str(inv.get("id", ""))
+ invp = api.mark_invoice_paid(admin.access_token, inv_id)
+ logger.info(f"Invoice paid: id={inv_id}, status={invp.get('status')}")
+
+ # Итог
+ summary = {
+ "admin": {"email": admin.email, "id": admin.id},
+ "clients": [{"email": c.email, "id": c.id} for c in clients],
+ "pair_id": pair_id,
+ "room_id": room_id,
+ "message_id": msg_id,
+ "invoice_id": inv_id,
+ "invoice_status": invp.get("status"),
+ }
+ logger.info("=== SUMMARY ===")
+ logger.info(json.dumps(summary, ensure_ascii=False, indent=2))
+ print(json.dumps(summary, ensure_ascii=False, indent=2)) # на stdout — короткое резюме
+
+if __name__ == "__main__":
+ try:
+ main()
+ except KeyboardInterrupt:
+ print("\nInterrupted.", file=sys.stderr)
+ sys.exit(130)
diff --git a/.history/scripts/api_e2e_20250808215311.py b/.history/scripts/api_e2e_20250808215311.py
new file mode 100644
index 0000000..c48b626
--- /dev/null
+++ b/.history/scripts/api_e2e_20250808215311.py
@@ -0,0 +1,419 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+import base64
+import json
+import logging
+import os
+import random
+import string
+import sys
+import time
+from dataclasses import dataclass
+from logging.handlers import RotatingFileHandler
+from typing import Any, Dict, Iterable, List, Optional, Tuple
+from urllib.parse import urljoin
+
+import requests
+from faker import Faker
+
+# -------------------------
+# Конфигурация по умолчанию
+# -------------------------
+DEFAULT_BASE_URL = os.getenv("BASE_URL", "http://localhost:8080")
+DEFAULT_PASSWORD = os.getenv("PASS", "secret123")
+DEFAULT_CLIENTS = int(os.getenv("CLIENTS", "2"))
+DEFAULT_EMAIL_DOMAIN = os.getenv("EMAIL_DOMAIN", "agency.dev")
+DEFAULT_LOG_FILE = os.getenv("LOG_FILE", "logs/api.log")
+DEFAULT_TIMEOUT = float(os.getenv("HTTP_TIMEOUT", "10.0"))
+
+# -------------------------
+# Логирование
+# -------------------------
+def setup_logger(path: str) -> logging.Logger:
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ logger = logging.getLogger("api_e2e")
+ logger.setLevel(logging.DEBUG)
+
+ # Ротация логов: до 5 файлов по 5 МБ
+ file_handler = RotatingFileHandler(path, maxBytes=5 * 1024 * 1024, backupCount=5, encoding="utf-8")
+ file_handler.setLevel(logging.DEBUG)
+ file_handler.setFormatter(logging.Formatter(
+ fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
+ datefmt="%Y-%m-%d %H:%M:%S",
+ ))
+ logger.addHandler(file_handler)
+
+ # Консоль — INFO и короче
+ console = logging.StreamHandler(sys.stdout)
+ console.setLevel(logging.INFO)
+ console.setFormatter(logging.Formatter("%(levelname)s | %(message)s"))
+ logger.addHandler(console)
+ return logger
+
+# -------------------------
+# Утилиты
+# -------------------------
+def b64url_json(token_part: str) -> Dict[str, Any]:
+ """Декодирует часть JWT (payload) без валидации сигнатуры."""
+ s = token_part + "=" * (-len(token_part) % 4)
+ return json.loads(base64.urlsafe_b64decode(s).decode("utf-8"))
+
+def decode_jwt_sub(token: str) -> str:
+ try:
+ payload = b64url_json(token.split(".")[1])
+ return str(payload.get("sub", "")) # UUID пользователя
+ except Exception:
+ return ""
+
+def mask_token(token: Optional[str]) -> str:
+ if not token:
+ return ""
+ return token[:12] + "..." if len(token) > 12 else token
+
+def now_ms() -> int:
+ return int(time.time() * 1000)
+
+@dataclass
+class UserCreds:
+ id: str
+ email: str
+ access_token: str
+ role: str
+
+# -------------------------
+# Класс-клиент
+# -------------------------
+class APIE2E:
+ import requests, time, json, logging
+
+ self.session = requests.Session()
+ def __init__(self, base_url: str, logger: logging.Logger, timeout: float = DEFAULT_TIMEOUT) -> None:
+ self.base_url = base_url.rstrip("/") + "/"
+ self.logger = logger
+ self.timeout = timeout
+ self.sess = requests.Session()
+
+ self.urls = {
+ "auth": urljoin(self.base_url, "auth/"),
+ "profiles": urljoin(self.base_url, "profiles/"),
+ "match": urljoin(self.base_url, "match/"),
+ "chat": urljoin(self.base_url, "chat/"),
+ "payments": urljoin(self.base_url, "payments/"),
+ }
+
+ # --------- низкоуровневый запрос с логированием ----------
+ def req(self, method, url, token=None, body=None, expected=(200,), name=None):
+ headers = {"Accept": "application/json"}
+ if token:
+ headers["Authorization"] = f"Bearer {token}"
+
+ log_body = {}
+ if body:
+ log_body = dict(body)
+ for key in list(log_body.keys()):
+ if key.lower() in ("password", "token", "access_token", "refresh_token"):
+ log_body[key] = "***hidden***"
+
+ started = now_ms()
+ self.logger.debug(
+ f"HTTP {method} {url} | headers={{Authorization: Bearer {mask_token(token)}}} | body={log_body}"
+ )
+
+ try:
+ resp = self.sess.request(method=method, url=url, json=body, timeout=self.timeout)
+ except Exception as e:
+ duration = now_ms() - started
+ self.logger.error(f"{name or url} FAILED transport error: {e} ({duration} ms)")
+ raise
+
+ text = resp.text or ""
+ try:
+ data = resp.json() if text else {}
+ except ValueError:
+ data = {}
+
+ duration = now_ms() - started
+ self.logger.debug(f"← {resp.status_code} in {duration} ms | body={text[:2000]}")
+ if expected and resp.status_code not in expected:
+ msg = f"{name or url} unexpected status {resp.status_code}, expected {list(expected)}; body={text}"
+ self.logger.error(msg)
+ raise RuntimeError(msg)
+ return resp.status_code, data, text
+
+
+ # --------- health ----------
+ def wait_health(self, name: str, url: str, timeout_sec: int = 60) -> None:
+ self.logger.info(f"Waiting {name} health: {url}")
+ deadline = time.time() + timeout_sec
+ while time.time() < deadline:
+ try:
+ code, _, _ = self.req("GET", url, expected=(200,), name=f"{name}/health")
+ if code == 200:
+ self.logger.info(f"{name} is healthy")
+ return
+ except Exception:
+ pass
+ time.sleep(1)
+ raise TimeoutError(f"{name} not healthy in time: {url}")
+
+ # --------- auth ----------
+ def login(self, email: str, password: str) -> Tuple[str, str]:
+ url = urljoin(self.urls["auth"], "v1/token")
+ _, data, _ = self.req("POST", url, body={"email": email, "password": password}, expected=(200,), name="login")
+ token = data.get("access_token", "")
+ if not token:
+ raise RuntimeError("access_token is empty")
+ user_id = decode_jwt_sub(token)
+ if not user_id:
+ raise RuntimeError("cannot decode user id (sub) from token")
+ return user_id, token
+
+ def register(self, email: str, password: str, full_name: str, role: str) -> None:
+ url = urljoin(self.urls["auth"], "v1/register")
+ # /register в вашем бэке может возвращать 201/200, а иногда 500 при сериализации —
+ # поэтому не падаем на 500 сразу, а логинимся ниже.
+ try:
+ self.req(
+ "POST",
+ url,
+ body={"email": email, "password": password, "full_name": full_name, "role": role},
+ expected=(200, 201),
+ name="register",
+ )
+ except RuntimeError as e:
+ self.logger.warning(f"register returned non-2xx: {e} — will try login anyway")
+
+ def login_or_register(self, email: str, password: str, full_name: str, role: str) -> UserCreds:
+ # 1) пробуем логин
+ try:
+ uid, token = self.login(email, password)
+ self.logger.info(f"Login OK: {email} -> {uid}")
+ return UserCreds(id=uid, email=email, access_token=token, role=role)
+ except Exception as e:
+ self.logger.info(f"Login failed for {email}: {e}; will try register")
+
+ # 2) регистрируем (не фатально, если вернулся 500)
+ self.register(email, password, full_name, role)
+
+ # 3) снова логин
+ uid, token = self.login(email, password)
+ self.logger.info(f"Registered+Login OK: {email} -> {uid}")
+ return UserCreds(id=uid, email=email, access_token=token, role=role)
+
+ # --------- profiles ----------
+ def get_my_profile(self, token: str) -> Tuple[int, Dict[str, Any]]:
+ url = urljoin(self.urls["profiles"], "v1/profiles/me")
+ code, data, _ = self.req("GET", url, token=token, expected=(200, 404), name="profiles/me")
+ return code, data
+
+ def create_profile(
+ self,
+ token: str,
+ gender: str,
+ city: str,
+ languages: List[str],
+ interests: List[str],
+ ) -> Dict[str, Any]:
+ url = urljoin(self.urls["profiles"], "v1/profiles")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=token,
+ body={"gender": gender, "city": city, "languages": languages, "interests": interests},
+ expected=(200, 201),
+ name="profiles/create",
+ )
+ return data
+
+ def ensure_profile(
+ self, token: str, gender: str, city: str, languages: List[str], interests: List[str]
+ ) -> Dict[str, Any]:
+ code, p = self.get_my_profile(token)
+ if code == 200:
+ self.logger.info(f"Profile exists: id={p.get('id')}")
+ return p
+ self.logger.info("Profile not found -> creating")
+ p = self.create_profile(token, gender, city, languages, interests)
+ self.logger.info(f"Profile created: id={p.get('id')}")
+ return p
+
+ # --------- match ----------
+ def create_pair(self, admin_token: str, user_id_a: str, user_id_b: str, score: float, notes: str) -> Dict[str, Any]:
+ url = urljoin(self.urls["match"], "v1/pairs")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=admin_token,
+ body={"user_id_a": user_id_a, "user_id_b": user_id_b, "score": score, "notes": notes},
+ expected=(200, 201),
+ name="match/create_pair",
+ )
+ return data
+
+ # --------- chat ----------
+ def create_room(self, admin_token: str, title: str, participants: List[str]) -> Dict[str, Any]:
+ url = urljoin(self.urls["chat"], "v1/rooms")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=admin_token,
+ body={"title": title, "participants": participants},
+ expected=(200, 201),
+ name="chat/create_room",
+ )
+ return data
+
+ def send_message(self, admin_token: str, room_id: str, content: str) -> Dict[str, Any]:
+ url = urljoin(self.urls["chat"], f"v1/rooms/{room_id}/messages")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=admin_token,
+ body={"content": content},
+ expected=(200, 201),
+ name="chat/send_message",
+ )
+ return data
+
+ # --------- payments ----------
+ def create_invoice(
+ self, admin_token: str, client_id: str, amount: float, currency: str, description: str
+ ) -> Dict[str, Any]:
+ url = urljoin(self.urls["payments"], "v1/invoices")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=admin_token,
+ body={"client_id": client_id, "amount": amount, "currency": currency, "description": description},
+ expected=(200, 201),
+ name="payments/create_invoice",
+ )
+ return data
+
+ def mark_invoice_paid(self, admin_token: str, invoice_id: str) -> Dict[str, Any]:
+ url = urljoin(self.urls["payments"], f"v1/invoices/{invoice_id}/mark-paid")
+ _, data, _ = self.req("POST", url, token=admin_token, expected=(200,), name="payments/mark_paid")
+ return data
+
+# -------------------------
+# Генерация данных
+# -------------------------
+GENDERS = ["female", "male", "other"]
+CITIES = [
+ "Moscow", "Saint Petersburg", "Kazan", "Novosibirsk", "Krasnodar",
+ "Yekaterinburg", "Minsk", "Kyiv", "Almaty", "Tbilisi",
+]
+LANG_POOL = ["ru", "en", "de", "fr", "es", "it", "zh", "uk", "kk", "tr", "pl"]
+INTR_POOL = ["music", "travel", "reading", "sports", "movies", "games", "cooking", "yoga", "art", "tech"]
+
+def pick_languages(n: int = 2) -> List[str]:
+ n = max(1, min(n, len(LANG_POOL)))
+ return sorted(random.sample(LANG_POOL, n))
+
+def pick_interests(n: int = 3) -> List[str]:
+ n = max(1, min(n, len(INTR_POOL)))
+ return sorted(random.sample(INTR_POOL, n))
+
+def random_email(prefix: str, domain: str) -> str:
+ suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
+ return f"{prefix}+{int(time.time())}.{suffix}@{domain}"
+
+# -------------------------
+# Основной сценарий
+# -------------------------
+def main():
+ import argparse
+
+ parser = argparse.ArgumentParser(description="E2E тест API брачного агентства с фейковыми анкетами.")
+ parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="Gateway base URL (по умолчанию http://localhost:8080)")
+ parser.add_argument("--clients", type=int, default=DEFAULT_CLIENTS, help="Количество клиентских аккаунтов (>=2)")
+ parser.add_argument("--password", default=DEFAULT_PASSWORD, help="Пароль для всех тестовых пользователей")
+ parser.add_argument("--email-domain", default=DEFAULT_EMAIL_DOMAIN, help="Домен e-mail для теста (напр. agency.dev)")
+ parser.add_argument("--log-file", default=DEFAULT_LOG_FILE, help="Файл логов (по умолчанию logs/api.log)")
+ parser.add_argument("--seed", type=int, default=42, help="Генератор случайных чисел (seed)")
+ args = parser.parse_args()
+
+ random.seed(args.seed)
+ fake = Faker()
+ logger = setup_logger(args.log_file)
+ logger.info("=== API E2E START ===")
+ logger.info(f"BASE_URL={args.base_url} clients={args.clients} domain={args.email_domain}")
+
+ if args.clients < 2:
+ logger.error("Нужно минимум 2 клиента (для пары).")
+ sys.exit(2)
+
+ api = APIE2E(args.base_url, logger)
+
+ # Health checks через gateway
+ api.wait_health("gateway/auth", urljoin(api.urls["auth"], "health"))
+ api.wait_health("profiles", urljoin(api.urls["profiles"], "health"))
+ api.wait_health("match", urljoin(api.urls["match"], "health"))
+ api.wait_health("chat", urljoin(api.urls["chat"], "health"))
+ api.wait_health("payments", urljoin(api.urls["payments"], "health"))
+
+ # Админ
+ admin_email = random_email("admin", args.email_domain)
+ admin_full = fake.name()
+ admin = api.login_or_register(admin_email, args.password, admin_full, role="ADMIN")
+
+ # Клиенты
+ clients: List[UserCreds] = []
+ for i in range(args.clients):
+ email = random_email(f"user{i+1}", args.email_domain)
+ full = fake.name()
+ u = api.login_or_register(email, args.password, full, role="CLIENT")
+ clients.append(u)
+
+ # Профили для всех
+ for i, u in enumerate([admin] + clients, start=1):
+ gender = random.choice(GENDERS)
+ city = random.choice(CITIES)
+ languages = pick_languages(random.choice([1, 2, 3]))
+ interests = pick_interests(random.choice([2, 3, 4]))
+ logger.info(f"[{i}/{1+len(clients)}] Ensure profile for {u.email} (role={u.role})")
+ api.ensure_profile(u.access_token, gender, city, languages, interests)
+
+ # Match‑пара между двумя случайными клиентами
+ a, b = random.sample(clients, 2)
+ score = round(random.uniform(0.6, 0.98), 2)
+ pair = api.create_pair(admin.access_token, a.id, b.id, score, notes="e2e generated")
+ pair_id = str(pair.get("id", ""))
+ logger.info(f"Match pair created: id={pair_id}, {a.email} ↔ {b.email}, score={score}")
+
+ # Чат‑комната и сообщение
+ room = api.create_room(admin.access_token, title=f"{a.email} & {b.email}", participants=[a.id, b.id])
+ room_id = str(room.get("id", ""))
+ msg = api.send_message(admin.access_token, room_id, content="Hello from admin (e2e)")
+ msg_id = str(msg.get("id", ""))
+ logger.info(f"Chat message sent: room={room_id}, msg={msg_id}")
+
+ # Счёт для первого клиента
+ amount = random.choice([99.0, 199.0, 299.0])
+ inv = api.create_invoice(admin.access_token, client_id=a.id, amount=amount, currency="USD",
+ description="Consultation (e2e)")
+ inv_id = str(inv.get("id", ""))
+ invp = api.mark_invoice_paid(admin.access_token, inv_id)
+ logger.info(f"Invoice paid: id={inv_id}, status={invp.get('status')}")
+
+ # Итог
+ summary = {
+ "admin": {"email": admin.email, "id": admin.id},
+ "clients": [{"email": c.email, "id": c.id} for c in clients],
+ "pair_id": pair_id,
+ "room_id": room_id,
+ "message_id": msg_id,
+ "invoice_id": inv_id,
+ "invoice_status": invp.get("status"),
+ }
+ logger.info("=== SUMMARY ===")
+ logger.info(json.dumps(summary, ensure_ascii=False, indent=2))
+ print(json.dumps(summary, ensure_ascii=False, indent=2)) # на stdout — короткое резюме
+
+if __name__ == "__main__":
+ try:
+ main()
+ except KeyboardInterrupt:
+ print("\nInterrupted.", file=sys.stderr)
+ sys.exit(130)
diff --git a/.history/scripts/api_e2e_20250808215326.py b/.history/scripts/api_e2e_20250808215326.py
new file mode 100644
index 0000000..360ab82
--- /dev/null
+++ b/.history/scripts/api_e2e_20250808215326.py
@@ -0,0 +1,423 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+import base64
+import json
+import logging
+import os
+import random
+import string
+import sys
+import time
+from dataclasses import dataclass
+from logging.handlers import RotatingFileHandler
+from typing import Any, Dict, Iterable, List, Optional, Tuple
+from urllib.parse import urljoin
+
+import requests
+from faker import Faker
+
+# -------------------------
+# Конфигурация по умолчанию
+# -------------------------
+DEFAULT_BASE_URL = os.getenv("BASE_URL", "http://localhost:8080")
+DEFAULT_PASSWORD = os.getenv("PASS", "secret123")
+DEFAULT_CLIENTS = int(os.getenv("CLIENTS", "2"))
+DEFAULT_EMAIL_DOMAIN = os.getenv("EMAIL_DOMAIN", "agency.dev")
+DEFAULT_LOG_FILE = os.getenv("LOG_FILE", "logs/api.log")
+DEFAULT_TIMEOUT = float(os.getenv("HTTP_TIMEOUT", "10.0"))
+
+# -------------------------
+# Логирование
+# -------------------------
+def setup_logger(path: str) -> logging.Logger:
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ logger = logging.getLogger("api_e2e")
+ logger.setLevel(logging.DEBUG)
+
+ # Ротация логов: до 5 файлов по 5 МБ
+ file_handler = RotatingFileHandler(path, maxBytes=5 * 1024 * 1024, backupCount=5, encoding="utf-8")
+ file_handler.setLevel(logging.DEBUG)
+ file_handler.setFormatter(logging.Formatter(
+ fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
+ datefmt="%Y-%m-%d %H:%M:%S",
+ ))
+ logger.addHandler(file_handler)
+
+ # Консоль — INFO и короче
+ console = logging.StreamHandler(sys.stdout)
+ console.setLevel(logging.INFO)
+ console.setFormatter(logging.Formatter("%(levelname)s | %(message)s"))
+ logger.addHandler(console)
+ return logger
+
+# -------------------------
+# Утилиты
+# -------------------------
+def b64url_json(token_part: str) -> Dict[str, Any]:
+ """Декодирует часть JWT (payload) без валидации сигнатуры."""
+ s = token_part + "=" * (-len(token_part) % 4)
+ return json.loads(base64.urlsafe_b64decode(s).decode("utf-8"))
+
+def decode_jwt_sub(token: str) -> str:
+ try:
+ payload = b64url_json(token.split(".")[1])
+ return str(payload.get("sub", "")) # UUID пользователя
+ except Exception:
+ return ""
+
+def mask_token(token: Optional[str]) -> str:
+ if not token:
+ return ""
+ return token[:12] + "..." if len(token) > 12 else token
+
+def now_ms() -> int:
+ return int(time.time() * 1000)
+
+@dataclass
+class UserCreds:
+ id: str
+ email: str
+ access_token: str
+ role: str
+
+# -------------------------
+# Класс-клиент
+# -------------------------
+class APIE2E:
+ import requests, time, json, logging
+
+ self.session = requests.Session()
+ def __init__(self, base_url: str, logger: logging.Logger, timeout: float = DEFAULT_TIMEOUT) -> None:
+ self.base_url = base_url.rstrip("/") + "/"
+ self.logger = logger
+ self.timeout = timeout
+ self.sess = requests.Session()
+
+ self.urls = {
+ "auth": urljoin(self.base_url, "auth/"),
+ "profiles": urljoin(self.base_url, "profiles/"),
+ "match": urljoin(self.base_url, "match/"),
+ "chat": urljoin(self.base_url, "chat/"),
+ "payments": urljoin(self.base_url, "payments/"),
+ }
+
+ # --------- низкоуровневый запрос с логированием ----------
+ def req(self, method, url, body=None, token=None, expected=(200,), name=""):
+ headers = {"Content-Type": "application/json"}
+ if token:
+ headers["Authorization"] = f"Bearer {token}"
+
+ # Готовим запрос, чтобы увидеть финальные заголовки
+ req = requests.Request(method, url,
+ headers=headers,
+ data=(json.dumps(body) if body is not None else None))
+ prep = self.session.prepare_request(req)
+
+ # ЛОГ: какие заголовки действительно уйдут
+ self.log.debug("HTTP %s %s | headers=%s | body=%s",
+ method, url,
+ {k: (v if k.lower() != "authorization" else f"Bearer {v.split(' ',1)[1][:10]}...") for k,v in prep.headers.items()},
+ (body if body is not None else {}))
+
+ t0 = time.time()
+ resp = self.session.send(prep,
+ allow_redirects=False, # ВАЖНО
+ timeout=15)
+ dt = int((time.time()-t0)*1000)
+
+ # ЛОГ: редиректы, если были
+ if resp.is_redirect or resp.is_permanent_redirect or resp.history:
+ self.log.warning("%s got redirect chain: %s",
+ name or url,
+ " -> ".join(f"{r.status_code}:{r.headers.get('Location','')}" for r in resp.history+[resp]))
+
+ text = resp.text
+ self.log.debug("← %s in %d ms | body=%s", resp.status_code, dt, text[:1000])
+
+ if resp.status_code not in expected:
+ raise RuntimeError(f"{name or url} unexpected status {resp.status_code}, expected {list(expected)}; body={text}")
+
+ data = None
+ try:
+ data = resp.json() if text else None
+ except Exception:
+ pass
+ return resp.status_code, data, resp.headers
+
+
+ # --------- health ----------
+ def wait_health(self, name: str, url: str, timeout_sec: int = 60) -> None:
+ self.logger.info(f"Waiting {name} health: {url}")
+ deadline = time.time() + timeout_sec
+ while time.time() < deadline:
+ try:
+ code, _, _ = self.req("GET", url, expected=(200,), name=f"{name}/health")
+ if code == 200:
+ self.logger.info(f"{name} is healthy")
+ return
+ except Exception:
+ pass
+ time.sleep(1)
+ raise TimeoutError(f"{name} not healthy in time: {url}")
+
+ # --------- auth ----------
+ def login(self, email: str, password: str) -> Tuple[str, str]:
+ url = urljoin(self.urls["auth"], "v1/token")
+ _, data, _ = self.req("POST", url, body={"email": email, "password": password}, expected=(200,), name="login")
+ token = data.get("access_token", "")
+ if not token:
+ raise RuntimeError("access_token is empty")
+ user_id = decode_jwt_sub(token)
+ if not user_id:
+ raise RuntimeError("cannot decode user id (sub) from token")
+ return user_id, token
+
+ def register(self, email: str, password: str, full_name: str, role: str) -> None:
+ url = urljoin(self.urls["auth"], "v1/register")
+ # /register в вашем бэке может возвращать 201/200, а иногда 500 при сериализации —
+ # поэтому не падаем на 500 сразу, а логинимся ниже.
+ try:
+ self.req(
+ "POST",
+ url,
+ body={"email": email, "password": password, "full_name": full_name, "role": role},
+ expected=(200, 201),
+ name="register",
+ )
+ except RuntimeError as e:
+ self.logger.warning(f"register returned non-2xx: {e} — will try login anyway")
+
+ def login_or_register(self, email: str, password: str, full_name: str, role: str) -> UserCreds:
+ # 1) пробуем логин
+ try:
+ uid, token = self.login(email, password)
+ self.logger.info(f"Login OK: {email} -> {uid}")
+ return UserCreds(id=uid, email=email, access_token=token, role=role)
+ except Exception as e:
+ self.logger.info(f"Login failed for {email}: {e}; will try register")
+
+ # 2) регистрируем (не фатально, если вернулся 500)
+ self.register(email, password, full_name, role)
+
+ # 3) снова логин
+ uid, token = self.login(email, password)
+ self.logger.info(f"Registered+Login OK: {email} -> {uid}")
+ return UserCreds(id=uid, email=email, access_token=token, role=role)
+
+ # --------- profiles ----------
+ def get_my_profile(self, token: str) -> Tuple[int, Dict[str, Any]]:
+ url = urljoin(self.urls["profiles"], "v1/profiles/me")
+ code, data, _ = self.req("GET", url, token=token, expected=(200, 404), name="profiles/me")
+ return code, data
+
+ def create_profile(
+ self,
+ token: str,
+ gender: str,
+ city: str,
+ languages: List[str],
+ interests: List[str],
+ ) -> Dict[str, Any]:
+ url = urljoin(self.urls["profiles"], "v1/profiles")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=token,
+ body={"gender": gender, "city": city, "languages": languages, "interests": interests},
+ expected=(200, 201),
+ name="profiles/create",
+ )
+ return data
+
+ def ensure_profile(
+ self, token: str, gender: str, city: str, languages: List[str], interests: List[str]
+ ) -> Dict[str, Any]:
+ code, p = self.get_my_profile(token)
+ if code == 200:
+ self.logger.info(f"Profile exists: id={p.get('id')}")
+ return p
+ self.logger.info("Profile not found -> creating")
+ p = self.create_profile(token, gender, city, languages, interests)
+ self.logger.info(f"Profile created: id={p.get('id')}")
+ return p
+
+ # --------- match ----------
+ def create_pair(self, admin_token: str, user_id_a: str, user_id_b: str, score: float, notes: str) -> Dict[str, Any]:
+ url = urljoin(self.urls["match"], "v1/pairs")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=admin_token,
+ body={"user_id_a": user_id_a, "user_id_b": user_id_b, "score": score, "notes": notes},
+ expected=(200, 201),
+ name="match/create_pair",
+ )
+ return data
+
+ # --------- chat ----------
+ def create_room(self, admin_token: str, title: str, participants: List[str]) -> Dict[str, Any]:
+ url = urljoin(self.urls["chat"], "v1/rooms")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=admin_token,
+ body={"title": title, "participants": participants},
+ expected=(200, 201),
+ name="chat/create_room",
+ )
+ return data
+
+ def send_message(self, admin_token: str, room_id: str, content: str) -> Dict[str, Any]:
+ url = urljoin(self.urls["chat"], f"v1/rooms/{room_id}/messages")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=admin_token,
+ body={"content": content},
+ expected=(200, 201),
+ name="chat/send_message",
+ )
+ return data
+
+ # --------- payments ----------
+ def create_invoice(
+ self, admin_token: str, client_id: str, amount: float, currency: str, description: str
+ ) -> Dict[str, Any]:
+ url = urljoin(self.urls["payments"], "v1/invoices")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=admin_token,
+ body={"client_id": client_id, "amount": amount, "currency": currency, "description": description},
+ expected=(200, 201),
+ name="payments/create_invoice",
+ )
+ return data
+
+ def mark_invoice_paid(self, admin_token: str, invoice_id: str) -> Dict[str, Any]:
+ url = urljoin(self.urls["payments"], f"v1/invoices/{invoice_id}/mark-paid")
+ _, data, _ = self.req("POST", url, token=admin_token, expected=(200,), name="payments/mark_paid")
+ return data
+
+# -------------------------
+# Генерация данных
+# -------------------------
+GENDERS = ["female", "male", "other"]
+CITIES = [
+ "Moscow", "Saint Petersburg", "Kazan", "Novosibirsk", "Krasnodar",
+ "Yekaterinburg", "Minsk", "Kyiv", "Almaty", "Tbilisi",
+]
+LANG_POOL = ["ru", "en", "de", "fr", "es", "it", "zh", "uk", "kk", "tr", "pl"]
+INTR_POOL = ["music", "travel", "reading", "sports", "movies", "games", "cooking", "yoga", "art", "tech"]
+
+def pick_languages(n: int = 2) -> List[str]:
+ n = max(1, min(n, len(LANG_POOL)))
+ return sorted(random.sample(LANG_POOL, n))
+
+def pick_interests(n: int = 3) -> List[str]:
+ n = max(1, min(n, len(INTR_POOL)))
+ return sorted(random.sample(INTR_POOL, n))
+
+def random_email(prefix: str, domain: str) -> str:
+ suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
+ return f"{prefix}+{int(time.time())}.{suffix}@{domain}"
+
+# -------------------------
+# Основной сценарий
+# -------------------------
+def main():
+ import argparse
+
+ parser = argparse.ArgumentParser(description="E2E тест API брачного агентства с фейковыми анкетами.")
+ parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="Gateway base URL (по умолчанию http://localhost:8080)")
+ parser.add_argument("--clients", type=int, default=DEFAULT_CLIENTS, help="Количество клиентских аккаунтов (>=2)")
+ parser.add_argument("--password", default=DEFAULT_PASSWORD, help="Пароль для всех тестовых пользователей")
+ parser.add_argument("--email-domain", default=DEFAULT_EMAIL_DOMAIN, help="Домен e-mail для теста (напр. agency.dev)")
+ parser.add_argument("--log-file", default=DEFAULT_LOG_FILE, help="Файл логов (по умолчанию logs/api.log)")
+ parser.add_argument("--seed", type=int, default=42, help="Генератор случайных чисел (seed)")
+ args = parser.parse_args()
+
+ random.seed(args.seed)
+ fake = Faker()
+ logger = setup_logger(args.log_file)
+ logger.info("=== API E2E START ===")
+ logger.info(f"BASE_URL={args.base_url} clients={args.clients} domain={args.email_domain}")
+
+ if args.clients < 2:
+ logger.error("Нужно минимум 2 клиента (для пары).")
+ sys.exit(2)
+
+ api = APIE2E(args.base_url, logger)
+
+ # Health checks через gateway
+ api.wait_health("gateway/auth", urljoin(api.urls["auth"], "health"))
+ api.wait_health("profiles", urljoin(api.urls["profiles"], "health"))
+ api.wait_health("match", urljoin(api.urls["match"], "health"))
+ api.wait_health("chat", urljoin(api.urls["chat"], "health"))
+ api.wait_health("payments", urljoin(api.urls["payments"], "health"))
+
+ # Админ
+ admin_email = random_email("admin", args.email_domain)
+ admin_full = fake.name()
+ admin = api.login_or_register(admin_email, args.password, admin_full, role="ADMIN")
+
+ # Клиенты
+ clients: List[UserCreds] = []
+ for i in range(args.clients):
+ email = random_email(f"user{i+1}", args.email_domain)
+ full = fake.name()
+ u = api.login_or_register(email, args.password, full, role="CLIENT")
+ clients.append(u)
+
+ # Профили для всех
+ for i, u in enumerate([admin] + clients, start=1):
+ gender = random.choice(GENDERS)
+ city = random.choice(CITIES)
+ languages = pick_languages(random.choice([1, 2, 3]))
+ interests = pick_interests(random.choice([2, 3, 4]))
+ logger.info(f"[{i}/{1+len(clients)}] Ensure profile for {u.email} (role={u.role})")
+ api.ensure_profile(u.access_token, gender, city, languages, interests)
+
+ # Match‑пара между двумя случайными клиентами
+ a, b = random.sample(clients, 2)
+ score = round(random.uniform(0.6, 0.98), 2)
+ pair = api.create_pair(admin.access_token, a.id, b.id, score, notes="e2e generated")
+ pair_id = str(pair.get("id", ""))
+ logger.info(f"Match pair created: id={pair_id}, {a.email} ↔ {b.email}, score={score}")
+
+ # Чат‑комната и сообщение
+ room = api.create_room(admin.access_token, title=f"{a.email} & {b.email}", participants=[a.id, b.id])
+ room_id = str(room.get("id", ""))
+ msg = api.send_message(admin.access_token, room_id, content="Hello from admin (e2e)")
+ msg_id = str(msg.get("id", ""))
+ logger.info(f"Chat message sent: room={room_id}, msg={msg_id}")
+
+ # Счёт для первого клиента
+ amount = random.choice([99.0, 199.0, 299.0])
+ inv = api.create_invoice(admin.access_token, client_id=a.id, amount=amount, currency="USD",
+ description="Consultation (e2e)")
+ inv_id = str(inv.get("id", ""))
+ invp = api.mark_invoice_paid(admin.access_token, inv_id)
+ logger.info(f"Invoice paid: id={inv_id}, status={invp.get('status')}")
+
+ # Итог
+ summary = {
+ "admin": {"email": admin.email, "id": admin.id},
+ "clients": [{"email": c.email, "id": c.id} for c in clients],
+ "pair_id": pair_id,
+ "room_id": room_id,
+ "message_id": msg_id,
+ "invoice_id": inv_id,
+ "invoice_status": invp.get("status"),
+ }
+ logger.info("=== SUMMARY ===")
+ logger.info(json.dumps(summary, ensure_ascii=False, indent=2))
+ print(json.dumps(summary, ensure_ascii=False, indent=2)) # на stdout — короткое резюме
+
+if __name__ == "__main__":
+ try:
+ main()
+ except KeyboardInterrupt:
+ print("\nInterrupted.", file=sys.stderr)
+ sys.exit(130)
diff --git a/.history/scripts/api_e2e_20250808215359.py b/.history/scripts/api_e2e_20250808215359.py
new file mode 100644
index 0000000..f5e0329
--- /dev/null
+++ b/.history/scripts/api_e2e_20250808215359.py
@@ -0,0 +1,423 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+import base64
+import json
+import logging
+import os
+import random
+import string
+import sys
+import time
+from dataclasses import dataclass
+from logging.handlers import RotatingFileHandler
+from typing import Any, Dict, Iterable, List, Optional, Tuple
+from urllib.parse import urljoin
+
+import requests
+from faker import Faker
+
+# -------------------------
+# Конфигурация по умолчанию
+# -------------------------
+DEFAULT_BASE_URL = os.getenv("BASE_URL", "http://localhost:8080")
+DEFAULT_PASSWORD = os.getenv("PASS", "secret123")
+DEFAULT_CLIENTS = int(os.getenv("CLIENTS", "2"))
+DEFAULT_EMAIL_DOMAIN = os.getenv("EMAIL_DOMAIN", "agency.dev")
+DEFAULT_LOG_FILE = os.getenv("LOG_FILE", "logs/api.log")
+DEFAULT_TIMEOUT = float(os.getenv("HTTP_TIMEOUT", "10.0"))
+
+# -------------------------
+# Логирование
+# -------------------------
+def setup_logger(path: str) -> logging.Logger:
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ logger = logging.getLogger("api_e2e")
+ logger.setLevel(logging.DEBUG)
+
+ # Ротация логов: до 5 файлов по 5 МБ
+ file_handler = RotatingFileHandler(path, maxBytes=5 * 1024 * 1024, backupCount=5, encoding="utf-8")
+ file_handler.setLevel(logging.DEBUG)
+ file_handler.setFormatter(logging.Formatter(
+ fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
+ datefmt="%Y-%m-%d %H:%M:%S",
+ ))
+ logger.addHandler(file_handler)
+
+ # Консоль — INFO и короче
+ console = logging.StreamHandler(sys.stdout)
+ console.setLevel(logging.INFO)
+ console.setFormatter(logging.Formatter("%(levelname)s | %(message)s"))
+ logger.addHandler(console)
+ return logger
+
+# -------------------------
+# Утилиты
+# -------------------------
+def b64url_json(token_part: str) -> Dict[str, Any]:
+ """Декодирует часть JWT (payload) без валидации сигнатуры."""
+ s = token_part + "=" * (-len(token_part) % 4)
+ return json.loads(base64.urlsafe_b64decode(s).decode("utf-8"))
+
+def decode_jwt_sub(token: str) -> str:
+ try:
+ payload = b64url_json(token.split(".")[1])
+ return str(payload.get("sub", "")) # UUID пользователя
+ except Exception:
+ return ""
+
+def mask_token(token: Optional[str]) -> str:
+ if not token:
+ return ""
+ return token[:12] + "..." if len(token) > 12 else token
+
+def now_ms() -> int:
+ return int(time.time() * 1000)
+
+@dataclass
+class UserCreds:
+ id: str
+ email: str
+ access_token: str
+ role: str
+
+# -------------------------
+# Класс-клиент
+# -------------------------
+class APIE2E:
+ import requests, time, json, logging
+
+
+ def __init__(self, base_url: str, logger: logging.Logger, timeout: float = DEFAULT_TIMEOUT) -> None:
+ self.base_url = base_url.rstrip("/") + "/"
+ self.logger = logger
+ self.timeout = timeout
+ self.sess = requests.Session()
+
+ self.urls = {
+ "auth": urljoin(self.base_url, "auth/"),
+ "profiles": urljoin(self.base_url, "profiles/"),
+ "match": urljoin(self.base_url, "match/"),
+ "chat": urljoin(self.base_url, "chat/"),
+ "payments": urljoin(self.base_url, "payments/"),
+ }
+ self.session = requests.Session()
+ # --------- низкоуровневый запрос с логированием ----------
+ def req(self, method, url, body=None, token=None, expected=(200,), name=""):
+ headers = {"Content-Type": "application/json"}
+ if token:
+ headers["Authorization"] = f"Bearer {token}"
+
+ # Готовим запрос, чтобы увидеть финальные заголовки
+ req = requests.Request(method, url,
+ headers=headers,
+ data=(json.dumps(body) if body is not None else None))
+ prep = self.session.prepare_request(req)
+
+ # ЛОГ: какие заголовки действительно уйдут
+ self.log.debug("HTTP %s %s | headers=%s | body=%s",
+ method, url,
+ {k: (v if k.lower() != "authorization" else f"Bearer {v.split(' ',1)[1][:10]}...") for k,v in prep.headers.items()},
+ (body if body is not None else {}))
+
+ t0 = time.time()
+ resp = self.session.send(prep,
+ allow_redirects=False, # ВАЖНО
+ timeout=15)
+ dt = int((time.time()-t0)*1000)
+
+ # ЛОГ: редиректы, если были
+ if resp.is_redirect or resp.is_permanent_redirect or resp.history:
+ self.log.warning("%s got redirect chain: %s",
+ name or url,
+ " -> ".join(f"{r.status_code}:{r.headers.get('Location','')}" for r in resp.history+[resp]))
+
+ text = resp.text
+ self.log.debug("← %s in %d ms | body=%s", resp.status_code, dt, text[:1000])
+
+ if resp.status_code not in expected:
+ raise RuntimeError(f"{name or url} unexpected status {resp.status_code}, expected {list(expected)}; body={text}")
+
+ data = None
+ try:
+ data = resp.json() if text else None
+ except Exception:
+ pass
+ return resp.status_code, data, resp.headers
+
+
+ # --------- health ----------
+ def wait_health(self, name: str, url: str, timeout_sec: int = 60) -> None:
+ self.logger.info(f"Waiting {name} health: {url}")
+ deadline = time.time() + timeout_sec
+ while time.time() < deadline:
+ try:
+ code, _, _ = self.req("GET", url, expected=(200,), name=f"{name}/health")
+ if code == 200:
+ self.logger.info(f"{name} is healthy")
+ return
+ except Exception:
+ pass
+ time.sleep(1)
+ raise TimeoutError(f"{name} not healthy in time: {url}")
+
+ # --------- auth ----------
+ def login(self, email: str, password: str) -> Tuple[str, str]:
+ url = urljoin(self.urls["auth"], "v1/token")
+ _, data, _ = self.req("POST", url, body={"email": email, "password": password}, expected=(200,), name="login")
+ token = data.get("access_token", "")
+ if not token:
+ raise RuntimeError("access_token is empty")
+ user_id = decode_jwt_sub(token)
+ if not user_id:
+ raise RuntimeError("cannot decode user id (sub) from token")
+ return user_id, token
+
+ def register(self, email: str, password: str, full_name: str, role: str) -> None:
+ url = urljoin(self.urls["auth"], "v1/register")
+ # /register в вашем бэке может возвращать 201/200, а иногда 500 при сериализации —
+ # поэтому не падаем на 500 сразу, а логинимся ниже.
+ try:
+ self.req(
+ "POST",
+ url,
+ body={"email": email, "password": password, "full_name": full_name, "role": role},
+ expected=(200, 201),
+ name="register",
+ )
+ except RuntimeError as e:
+ self.logger.warning(f"register returned non-2xx: {e} — will try login anyway")
+
+ def login_or_register(self, email: str, password: str, full_name: str, role: str) -> UserCreds:
+ # 1) пробуем логин
+ try:
+ uid, token = self.login(email, password)
+ self.logger.info(f"Login OK: {email} -> {uid}")
+ return UserCreds(id=uid, email=email, access_token=token, role=role)
+ except Exception as e:
+ self.logger.info(f"Login failed for {email}: {e}; will try register")
+
+ # 2) регистрируем (не фатально, если вернулся 500)
+ self.register(email, password, full_name, role)
+
+ # 3) снова логин
+ uid, token = self.login(email, password)
+ self.logger.info(f"Registered+Login OK: {email} -> {uid}")
+ return UserCreds(id=uid, email=email, access_token=token, role=role)
+
+ # --------- profiles ----------
+ def get_my_profile(self, token: str) -> Tuple[int, Dict[str, Any]]:
+ url = urljoin(self.urls["profiles"], "v1/profiles/me")
+ code, data, _ = self.req("GET", url, token=token, expected=(200, 404), name="profiles/me")
+ return code, data
+
+ def create_profile(
+ self,
+ token: str,
+ gender: str,
+ city: str,
+ languages: List[str],
+ interests: List[str],
+ ) -> Dict[str, Any]:
+ url = urljoin(self.urls["profiles"], "v1/profiles")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=token,
+ body={"gender": gender, "city": city, "languages": languages, "interests": interests},
+ expected=(200, 201),
+ name="profiles/create",
+ )
+ return data
+
+ def ensure_profile(
+ self, token: str, gender: str, city: str, languages: List[str], interests: List[str]
+ ) -> Dict[str, Any]:
+ code, p = self.get_my_profile(token)
+ if code == 200:
+ self.logger.info(f"Profile exists: id={p.get('id')}")
+ return p
+ self.logger.info("Profile not found -> creating")
+ p = self.create_profile(token, gender, city, languages, interests)
+ self.logger.info(f"Profile created: id={p.get('id')}")
+ return p
+
+ # --------- match ----------
+ def create_pair(self, admin_token: str, user_id_a: str, user_id_b: str, score: float, notes: str) -> Dict[str, Any]:
+ url = urljoin(self.urls["match"], "v1/pairs")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=admin_token,
+ body={"user_id_a": user_id_a, "user_id_b": user_id_b, "score": score, "notes": notes},
+ expected=(200, 201),
+ name="match/create_pair",
+ )
+ return data
+
+ # --------- chat ----------
+ def create_room(self, admin_token: str, title: str, participants: List[str]) -> Dict[str, Any]:
+ url = urljoin(self.urls["chat"], "v1/rooms")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=admin_token,
+ body={"title": title, "participants": participants},
+ expected=(200, 201),
+ name="chat/create_room",
+ )
+ return data
+
+ def send_message(self, admin_token: str, room_id: str, content: str) -> Dict[str, Any]:
+ url = urljoin(self.urls["chat"], f"v1/rooms/{room_id}/messages")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=admin_token,
+ body={"content": content},
+ expected=(200, 201),
+ name="chat/send_message",
+ )
+ return data
+
+ # --------- payments ----------
+ def create_invoice(
+ self, admin_token: str, client_id: str, amount: float, currency: str, description: str
+ ) -> Dict[str, Any]:
+ url = urljoin(self.urls["payments"], "v1/invoices")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=admin_token,
+ body={"client_id": client_id, "amount": amount, "currency": currency, "description": description},
+ expected=(200, 201),
+ name="payments/create_invoice",
+ )
+ return data
+
+ def mark_invoice_paid(self, admin_token: str, invoice_id: str) -> Dict[str, Any]:
+ url = urljoin(self.urls["payments"], f"v1/invoices/{invoice_id}/mark-paid")
+ _, data, _ = self.req("POST", url, token=admin_token, expected=(200,), name="payments/mark_paid")
+ return data
+
+# -------------------------
+# Генерация данных
+# -------------------------
+GENDERS = ["female", "male", "other"]
+CITIES = [
+ "Moscow", "Saint Petersburg", "Kazan", "Novosibirsk", "Krasnodar",
+ "Yekaterinburg", "Minsk", "Kyiv", "Almaty", "Tbilisi",
+]
+LANG_POOL = ["ru", "en", "de", "fr", "es", "it", "zh", "uk", "kk", "tr", "pl"]
+INTR_POOL = ["music", "travel", "reading", "sports", "movies", "games", "cooking", "yoga", "art", "tech"]
+
+def pick_languages(n: int = 2) -> List[str]:
+ n = max(1, min(n, len(LANG_POOL)))
+ return sorted(random.sample(LANG_POOL, n))
+
+def pick_interests(n: int = 3) -> List[str]:
+ n = max(1, min(n, len(INTR_POOL)))
+ return sorted(random.sample(INTR_POOL, n))
+
+def random_email(prefix: str, domain: str) -> str:
+ suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
+ return f"{prefix}+{int(time.time())}.{suffix}@{domain}"
+
+# -------------------------
+# Основной сценарий
+# -------------------------
+def main():
+ import argparse
+
+ parser = argparse.ArgumentParser(description="E2E тест API брачного агентства с фейковыми анкетами.")
+ parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="Gateway base URL (по умолчанию http://localhost:8080)")
+ parser.add_argument("--clients", type=int, default=DEFAULT_CLIENTS, help="Количество клиентских аккаунтов (>=2)")
+ parser.add_argument("--password", default=DEFAULT_PASSWORD, help="Пароль для всех тестовых пользователей")
+ parser.add_argument("--email-domain", default=DEFAULT_EMAIL_DOMAIN, help="Домен e-mail для теста (напр. agency.dev)")
+ parser.add_argument("--log-file", default=DEFAULT_LOG_FILE, help="Файл логов (по умолчанию logs/api.log)")
+ parser.add_argument("--seed", type=int, default=42, help="Генератор случайных чисел (seed)")
+ args = parser.parse_args()
+
+ random.seed(args.seed)
+ fake = Faker()
+ logger = setup_logger(args.log_file)
+ logger.info("=== API E2E START ===")
+ logger.info(f"BASE_URL={args.base_url} clients={args.clients} domain={args.email_domain}")
+
+ if args.clients < 2:
+ logger.error("Нужно минимум 2 клиента (для пары).")
+ sys.exit(2)
+
+ api = APIE2E(args.base_url, logger)
+
+ # Health checks через gateway
+ api.wait_health("gateway/auth", urljoin(api.urls["auth"], "health"))
+ api.wait_health("profiles", urljoin(api.urls["profiles"], "health"))
+ api.wait_health("match", urljoin(api.urls["match"], "health"))
+ api.wait_health("chat", urljoin(api.urls["chat"], "health"))
+ api.wait_health("payments", urljoin(api.urls["payments"], "health"))
+
+ # Админ
+ admin_email = random_email("admin", args.email_domain)
+ admin_full = fake.name()
+ admin = api.login_or_register(admin_email, args.password, admin_full, role="ADMIN")
+
+ # Клиенты
+ clients: List[UserCreds] = []
+ for i in range(args.clients):
+ email = random_email(f"user{i+1}", args.email_domain)
+ full = fake.name()
+ u = api.login_or_register(email, args.password, full, role="CLIENT")
+ clients.append(u)
+
+ # Профили для всех
+ for i, u in enumerate([admin] + clients, start=1):
+ gender = random.choice(GENDERS)
+ city = random.choice(CITIES)
+ languages = pick_languages(random.choice([1, 2, 3]))
+ interests = pick_interests(random.choice([2, 3, 4]))
+ logger.info(f"[{i}/{1+len(clients)}] Ensure profile for {u.email} (role={u.role})")
+ api.ensure_profile(u.access_token, gender, city, languages, interests)
+
+ # Match‑пара между двумя случайными клиентами
+ a, b = random.sample(clients, 2)
+ score = round(random.uniform(0.6, 0.98), 2)
+ pair = api.create_pair(admin.access_token, a.id, b.id, score, notes="e2e generated")
+ pair_id = str(pair.get("id", ""))
+ logger.info(f"Match pair created: id={pair_id}, {a.email} ↔ {b.email}, score={score}")
+
+ # Чат‑комната и сообщение
+ room = api.create_room(admin.access_token, title=f"{a.email} & {b.email}", participants=[a.id, b.id])
+ room_id = str(room.get("id", ""))
+ msg = api.send_message(admin.access_token, room_id, content="Hello from admin (e2e)")
+ msg_id = str(msg.get("id", ""))
+ logger.info(f"Chat message sent: room={room_id}, msg={msg_id}")
+
+ # Счёт для первого клиента
+ amount = random.choice([99.0, 199.0, 299.0])
+ inv = api.create_invoice(admin.access_token, client_id=a.id, amount=amount, currency="USD",
+ description="Consultation (e2e)")
+ inv_id = str(inv.get("id", ""))
+ invp = api.mark_invoice_paid(admin.access_token, inv_id)
+ logger.info(f"Invoice paid: id={inv_id}, status={invp.get('status')}")
+
+ # Итог
+ summary = {
+ "admin": {"email": admin.email, "id": admin.id},
+ "clients": [{"email": c.email, "id": c.id} for c in clients],
+ "pair_id": pair_id,
+ "room_id": room_id,
+ "message_id": msg_id,
+ "invoice_id": inv_id,
+ "invoice_status": invp.get("status"),
+ }
+ logger.info("=== SUMMARY ===")
+ logger.info(json.dumps(summary, ensure_ascii=False, indent=2))
+ print(json.dumps(summary, ensure_ascii=False, indent=2)) # на stdout — короткое резюме
+
+if __name__ == "__main__":
+ try:
+ main()
+ except KeyboardInterrupt:
+ print("\nInterrupted.", file=sys.stderr)
+ sys.exit(130)
diff --git a/.history/scripts/api_e2e_20250808215427.py b/.history/scripts/api_e2e_20250808215427.py
new file mode 100644
index 0000000..42a572e
--- /dev/null
+++ b/.history/scripts/api_e2e_20250808215427.py
@@ -0,0 +1,424 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+import base64
+import json
+import logging
+import os
+import random
+import string
+import sys
+import time
+from dataclasses import dataclass
+from logging.handlers import RotatingFileHandler
+from typing import Any, Dict, Iterable, List, Optional, Tuple
+from urllib.parse import urljoin
+
+import requests
+from faker import Faker
+
+# -------------------------
+# Конфигурация по умолчанию
+# -------------------------
+DEFAULT_BASE_URL = os.getenv("BASE_URL", "http://localhost:8080")
+DEFAULT_PASSWORD = os.getenv("PASS", "secret123")
+DEFAULT_CLIENTS = int(os.getenv("CLIENTS", "2"))
+DEFAULT_EMAIL_DOMAIN = os.getenv("EMAIL_DOMAIN", "agency.dev")
+DEFAULT_LOG_FILE = os.getenv("LOG_FILE", "logs/api.log")
+DEFAULT_TIMEOUT = float(os.getenv("HTTP_TIMEOUT", "10.0"))
+
+# -------------------------
+# Логирование
+# -------------------------
+def setup_logger(path: str) -> logging.Logger:
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ logger = logging.getLogger("api_e2e")
+ logger.setLevel(logging.DEBUG)
+
+ # Ротация логов: до 5 файлов по 5 МБ
+ file_handler = RotatingFileHandler(path, maxBytes=5 * 1024 * 1024, backupCount=5, encoding="utf-8")
+ file_handler.setLevel(logging.DEBUG)
+ file_handler.setFormatter(logging.Formatter(
+ fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
+ datefmt="%Y-%m-%d %H:%M:%S",
+ ))
+ logger.addHandler(file_handler)
+
+ # Консоль — INFO и короче
+ console = logging.StreamHandler(sys.stdout)
+ console.setLevel(logging.INFO)
+ console.setFormatter(logging.Formatter("%(levelname)s | %(message)s"))
+ logger.addHandler(console)
+ return logger
+
+# -------------------------
+# Утилиты
+# -------------------------
+def b64url_json(token_part: str) -> Dict[str, Any]:
+ """Декодирует часть JWT (payload) без валидации сигнатуры."""
+ s = token_part + "=" * (-len(token_part) % 4)
+ return json.loads(base64.urlsafe_b64decode(s).decode("utf-8"))
+
+def decode_jwt_sub(token: str) -> str:
+ try:
+ payload = b64url_json(token.split(".")[1])
+ return str(payload.get("sub", "")) # UUID пользователя
+ except Exception:
+ return ""
+
+def mask_token(token: Optional[str]) -> str:
+ if not token:
+ return ""
+ return token[:12] + "..." if len(token) > 12 else token
+
+def now_ms() -> int:
+ return int(time.time() * 1000)
+
+@dataclass
+class UserCreds:
+ id: str
+ email: str
+ access_token: str
+ role: str
+
+# -------------------------
+# Класс-клиент
+# -------------------------
+class APIE2E:
+ import requests, time, json, logging
+
+
+ def __init__(self, base_url: str, logger: logging.Logger, timeout: float = DEFAULT_TIMEOUT) -> None:
+ self.base_url = base_url.rstrip("/") + "/"
+ self.logger = logger
+ self.timeout = timeout
+ self.sess = requests.Session()
+
+ self.urls = {
+ "auth": urljoin(self.base_url, "auth/"),
+ "profiles": urljoin(self.base_url, "profiles/"),
+ "match": urljoin(self.base_url, "match/"),
+ "chat": urljoin(self.base_url, "chat/"),
+ "payments": urljoin(self.base_url, "payments/"),
+ }
+ self.session = requests.Session()
+
+ # --------- низкоуровневый запрос с логированием ----------
+ def req(self, method, url, body=None, token=None, expected=(200,), name=""):
+ headers = {"Content-Type": "application/json"}
+ if token:
+ headers["Authorization"] = f"Bearer {token}"
+
+ # Готовим запрос, чтобы увидеть финальные заголовки
+ req = requests.Request(method, url,
+ headers=headers,
+ data=(json.dumps(body) if body is not None else None))
+ prep = self.session.prepare_request(req)
+
+ # ЛОГ: какие заголовки действительно уйдут
+ self.log.debug("HTTP %s %s | headers=%s | body=%s",
+ method, url,
+ {k: (v if k.lower() != "authorization" else f"Bearer {v.split(' ',1)[1][:10]}...") for k,v in prep.headers.items()},
+ (body if body is not None else {}))
+
+ t0 = time.time()
+ resp = self.session.send(prep,
+ allow_redirects=False, # ВАЖНО
+ timeout=15)
+ dt = int((time.time()-t0)*1000)
+
+ # ЛОГ: редиректы, если были
+ if resp.is_redirect or resp.is_permanent_redirect or resp.history:
+ self.log.warning("%s got redirect chain: %s",
+ name or url,
+ " -> ".join(f"{r.status_code}:{r.headers.get('Location','')}" for r in resp.history+[resp]))
+
+ text = resp.text
+ self.log.debug("← %s in %d ms | body=%s", resp.status_code, dt, text[:1000])
+
+ if resp.status_code not in expected:
+ raise RuntimeError(f"{name or url} unexpected status {resp.status_code}, expected {list(expected)}; body={text}")
+
+ data = None
+ try:
+ data = resp.json() if text else None
+ except Exception:
+ pass
+ return resp.status_code, data, resp.headers
+
+
+ # --------- health ----------
+ def wait_health(self, name: str, url: str, timeout_sec: int = 60) -> None:
+ self.logger.info(f"Waiting {name} health: {url}")
+ deadline = time.time() + timeout_sec
+ while time.time() < deadline:
+ try:
+ code, _, _ = self.req("GET", url, expected=(200,), name=f"{name}/health")
+ if code == 200:
+ self.logger.info(f"{name} is healthy")
+ return
+ except Exception:
+ pass
+ time.sleep(1)
+ raise TimeoutError(f"{name} not healthy in time: {url}")
+
+ # --------- auth ----------
+ def login(self, email: str, password: str) -> Tuple[str, str]:
+ url = urljoin(self.urls["auth"], "v1/token")
+ _, data, _ = self.req("POST", url, body={"email": email, "password": password}, expected=(200,), name="login")
+ token = data.get("access_token", "")
+ if not token:
+ raise RuntimeError("access_token is empty")
+ user_id = decode_jwt_sub(token)
+ if not user_id:
+ raise RuntimeError("cannot decode user id (sub) from token")
+ return user_id, token
+
+ def register(self, email: str, password: str, full_name: str, role: str) -> None:
+ url = urljoin(self.urls["auth"], "v1/register")
+ # /register в вашем бэке может возвращать 201/200, а иногда 500 при сериализации —
+ # поэтому не падаем на 500 сразу, а логинимся ниже.
+ try:
+ self.req(
+ "POST",
+ url,
+ body={"email": email, "password": password, "full_name": full_name, "role": role},
+ expected=(200, 201),
+ name="register",
+ )
+ except RuntimeError as e:
+ self.logger.warning(f"register returned non-2xx: {e} — will try login anyway")
+
+ def login_or_register(self, email: str, password: str, full_name: str, role: str) -> UserCreds:
+ # 1) пробуем логин
+ try:
+ uid, token = self.login(email, password)
+ self.logger.info(f"Login OK: {email} -> {uid}")
+ return UserCreds(id=uid, email=email, access_token=token, role=role)
+ except Exception as e:
+ self.logger.info(f"Login failed for {email}: {e}; will try register")
+
+ # 2) регистрируем (не фатально, если вернулся 500)
+ self.register(email, password, full_name, role)
+
+ # 3) снова логин
+ uid, token = self.login(email, password)
+ self.logger.info(f"Registered+Login OK: {email} -> {uid}")
+ return UserCreds(id=uid, email=email, access_token=token, role=role)
+
+ # --------- profiles ----------
+ def get_my_profile(self, token: str) -> Tuple[int, Dict[str, Any]]:
+ url = urljoin(self.urls["profiles"], "v1/profiles/me")
+ code, data, _ = self.req("GET", url, token=token, expected=(200, 404), name="profiles/me")
+ return code, data
+
+ def create_profile(
+ self,
+ token: str,
+ gender: str,
+ city: str,
+ languages: List[str],
+ interests: List[str],
+ ) -> Dict[str, Any]:
+ url = urljoin(self.urls["profiles"], "v1/profiles")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=token,
+ body={"gender": gender, "city": city, "languages": languages, "interests": interests},
+ expected=(200, 201),
+ name="profiles/create",
+ )
+ return data
+
+ def ensure_profile(
+ self, token: str, gender: str, city: str, languages: List[str], interests: List[str]
+ ) -> Dict[str, Any]:
+ code, p = self.get_my_profile(token)
+ if code == 200:
+ self.logger.info(f"Profile exists: id={p.get('id')}")
+ return p
+ self.logger.info("Profile not found -> creating")
+ p = self.create_profile(token, gender, city, languages, interests)
+ self.logger.info(f"Profile created: id={p.get('id')}")
+ return p
+
+ # --------- match ----------
+ def create_pair(self, admin_token: str, user_id_a: str, user_id_b: str, score: float, notes: str) -> Dict[str, Any]:
+ url = urljoin(self.urls["match"], "v1/pairs")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=admin_token,
+ body={"user_id_a": user_id_a, "user_id_b": user_id_b, "score": score, "notes": notes},
+ expected=(200, 201),
+ name="match/create_pair",
+ )
+ return data
+
+ # --------- chat ----------
+ def create_room(self, admin_token: str, title: str, participants: List[str]) -> Dict[str, Any]:
+ url = urljoin(self.urls["chat"], "v1/rooms")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=admin_token,
+ body={"title": title, "participants": participants},
+ expected=(200, 201),
+ name="chat/create_room",
+ )
+ return data
+
+ def send_message(self, admin_token: str, room_id: str, content: str) -> Dict[str, Any]:
+ url = urljoin(self.urls["chat"], f"v1/rooms/{room_id}/messages")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=admin_token,
+ body={"content": content},
+ expected=(200, 201),
+ name="chat/send_message",
+ )
+ return data
+
+ # --------- payments ----------
+ def create_invoice(
+ self, admin_token: str, client_id: str, amount: float, currency: str, description: str
+ ) -> Dict[str, Any]:
+ url = urljoin(self.urls["payments"], "v1/invoices")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=admin_token,
+ body={"client_id": client_id, "amount": amount, "currency": currency, "description": description},
+ expected=(200, 201),
+ name="payments/create_invoice",
+ )
+ return data
+
+ def mark_invoice_paid(self, admin_token: str, invoice_id: str) -> Dict[str, Any]:
+ url = urljoin(self.urls["payments"], f"v1/invoices/{invoice_id}/mark-paid")
+ _, data, _ = self.req("POST", url, token=admin_token, expected=(200,), name="payments/mark_paid")
+ return data
+
+# -------------------------
+# Генерация данных
+# -------------------------
+GENDERS = ["female", "male", "other"]
+CITIES = [
+ "Moscow", "Saint Petersburg", "Kazan", "Novosibirsk", "Krasnodar",
+ "Yekaterinburg", "Minsk", "Kyiv", "Almaty", "Tbilisi",
+]
+LANG_POOL = ["ru", "en", "de", "fr", "es", "it", "zh", "uk", "kk", "tr", "pl"]
+INTR_POOL = ["music", "travel", "reading", "sports", "movies", "games", "cooking", "yoga", "art", "tech"]
+
+def pick_languages(n: int = 2) -> List[str]:
+ n = max(1, min(n, len(LANG_POOL)))
+ return sorted(random.sample(LANG_POOL, n))
+
+def pick_interests(n: int = 3) -> List[str]:
+ n = max(1, min(n, len(INTR_POOL)))
+ return sorted(random.sample(INTR_POOL, n))
+
+def random_email(prefix: str, domain: str) -> str:
+ suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
+ return f"{prefix}+{int(time.time())}.{suffix}@{domain}"
+
+# -------------------------
+# Основной сценарий
+# -------------------------
+def main():
+ import argparse
+
+ parser = argparse.ArgumentParser(description="E2E тест API брачного агентства с фейковыми анкетами.")
+ parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="Gateway base URL (по умолчанию http://localhost:8080)")
+ parser.add_argument("--clients", type=int, default=DEFAULT_CLIENTS, help="Количество клиентских аккаунтов (>=2)")
+ parser.add_argument("--password", default=DEFAULT_PASSWORD, help="Пароль для всех тестовых пользователей")
+ parser.add_argument("--email-domain", default=DEFAULT_EMAIL_DOMAIN, help="Домен e-mail для теста (напр. agency.dev)")
+ parser.add_argument("--log-file", default=DEFAULT_LOG_FILE, help="Файл логов (по умолчанию logs/api.log)")
+ parser.add_argument("--seed", type=int, default=42, help="Генератор случайных чисел (seed)")
+ args = parser.parse_args()
+
+ random.seed(args.seed)
+ fake = Faker()
+ logger = setup_logger(args.log_file)
+ logger.info("=== API E2E START ===")
+ logger.info(f"BASE_URL={args.base_url} clients={args.clients} domain={args.email_domain}")
+
+ if args.clients < 2:
+ logger.error("Нужно минимум 2 клиента (для пары).")
+ sys.exit(2)
+
+ api = APIE2E(args.base_url, logger)
+
+ # Health checks через gateway
+ api.wait_health("gateway/auth", urljoin(api.urls["auth"], "health"))
+ api.wait_health("profiles", urljoin(api.urls["profiles"], "health"))
+ api.wait_health("match", urljoin(api.urls["match"], "health"))
+ api.wait_health("chat", urljoin(api.urls["chat"], "health"))
+ api.wait_health("payments", urljoin(api.urls["payments"], "health"))
+
+ # Админ
+ admin_email = random_email("admin", args.email_domain)
+ admin_full = fake.name()
+ admin = api.login_or_register(admin_email, args.password, admin_full, role="ADMIN")
+
+ # Клиенты
+ clients: List[UserCreds] = []
+ for i in range(args.clients):
+ email = random_email(f"user{i+1}", args.email_domain)
+ full = fake.name()
+ u = api.login_or_register(email, args.password, full, role="CLIENT")
+ clients.append(u)
+
+ # Профили для всех
+ for i, u in enumerate([admin] + clients, start=1):
+ gender = random.choice(GENDERS)
+ city = random.choice(CITIES)
+ languages = pick_languages(random.choice([1, 2, 3]))
+ interests = pick_interests(random.choice([2, 3, 4]))
+ logger.info(f"[{i}/{1+len(clients)}] Ensure profile for {u.email} (role={u.role})")
+ api.ensure_profile(u.access_token, gender, city, languages, interests)
+
+ # Match‑пара между двумя случайными клиентами
+ a, b = random.sample(clients, 2)
+ score = round(random.uniform(0.6, 0.98), 2)
+ pair = api.create_pair(admin.access_token, a.id, b.id, score, notes="e2e generated")
+ pair_id = str(pair.get("id", ""))
+ logger.info(f"Match pair created: id={pair_id}, {a.email} ↔ {b.email}, score={score}")
+
+ # Чат‑комната и сообщение
+ room = api.create_room(admin.access_token, title=f"{a.email} & {b.email}", participants=[a.id, b.id])
+ room_id = str(room.get("id", ""))
+ msg = api.send_message(admin.access_token, room_id, content="Hello from admin (e2e)")
+ msg_id = str(msg.get("id", ""))
+ logger.info(f"Chat message sent: room={room_id}, msg={msg_id}")
+
+ # Счёт для первого клиента
+ amount = random.choice([99.0, 199.0, 299.0])
+ inv = api.create_invoice(admin.access_token, client_id=a.id, amount=amount, currency="USD",
+ description="Consultation (e2e)")
+ inv_id = str(inv.get("id", ""))
+ invp = api.mark_invoice_paid(admin.access_token, inv_id)
+ logger.info(f"Invoice paid: id={inv_id}, status={invp.get('status')}")
+
+ # Итог
+ summary = {
+ "admin": {"email": admin.email, "id": admin.id},
+ "clients": [{"email": c.email, "id": c.id} for c in clients],
+ "pair_id": pair_id,
+ "room_id": room_id,
+ "message_id": msg_id,
+ "invoice_id": inv_id,
+ "invoice_status": invp.get("status"),
+ }
+ logger.info("=== SUMMARY ===")
+ logger.info(json.dumps(summary, ensure_ascii=False, indent=2))
+ print(json.dumps(summary, ensure_ascii=False, indent=2)) # на stdout — короткое резюме
+
+if __name__ == "__main__":
+ try:
+ main()
+ except KeyboardInterrupt:
+ print("\nInterrupted.", file=sys.stderr)
+ sys.exit(130)
diff --git a/.history/scripts/api_e2e_20250808215516.py b/.history/scripts/api_e2e_20250808215516.py
new file mode 100644
index 0000000..54b9aef
--- /dev/null
+++ b/.history/scripts/api_e2e_20250808215516.py
@@ -0,0 +1,424 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+import base64
+import json
+import logging
+import os
+import random
+import string
+import sys
+import time
+from dataclasses import dataclass
+from logging.handlers import RotatingFileHandler
+from typing import Any, Dict, Iterable, List, Optional, Tuple
+from urllib.parse import urljoin
+
+import requests
+from faker import Faker
+
+# -------------------------
+# Конфигурация по умолчанию
+# -------------------------
+DEFAULT_BASE_URL = os.getenv("BASE_URL", "http://localhost:8080")
+DEFAULT_PASSWORD = os.getenv("PASS", "secret123")
+DEFAULT_CLIENTS = int(os.getenv("CLIENTS", "2"))
+DEFAULT_EMAIL_DOMAIN = os.getenv("EMAIL_DOMAIN", "agency.dev")
+DEFAULT_LOG_FILE = os.getenv("LOG_FILE", "logs/api.log")
+DEFAULT_TIMEOUT = float(os.getenv("HTTP_TIMEOUT", "10.0"))
+
+# -------------------------
+# Логирование
+# -------------------------
+def setup_logger(path: str) -> logging.Logger:
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ logger = logging.getLogger("api_e2e")
+ logger.setLevel(logging.DEBUG)
+
+ # Ротация логов: до 5 файлов по 5 МБ
+ file_handler = RotatingFileHandler(path, maxBytes=5 * 1024 * 1024, backupCount=5, encoding="utf-8")
+ file_handler.setLevel(logging.DEBUG)
+ file_handler.setFormatter(logging.Formatter(
+ fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
+ datefmt="%Y-%m-%d %H:%M:%S",
+ ))
+ logger.addHandler(file_handler)
+
+ # Консоль — INFO и короче
+ console = logging.StreamHandler(sys.stdout)
+ console.setLevel(logging.INFO)
+ console.setFormatter(logging.Formatter("%(levelname)s | %(message)s"))
+ logger.addHandler(console)
+ return logger
+
+# -------------------------
+# Утилиты
+# -------------------------
+def b64url_json(token_part: str) -> Dict[str, Any]:
+ """Декодирует часть JWT (payload) без валидации сигнатуры."""
+ s = token_part + "=" * (-len(token_part) % 4)
+ return json.loads(base64.urlsafe_b64decode(s).decode("utf-8"))
+
+def decode_jwt_sub(token: str) -> str:
+ try:
+ payload = b64url_json(token.split(".")[1])
+ return str(payload.get("sub", "")) # UUID пользователя
+ except Exception:
+ return ""
+
+def mask_token(token: Optional[str]) -> str:
+ if not token:
+ return ""
+ return token[:12] + "..." if len(token) > 12 else token
+
+def now_ms() -> int:
+ return int(time.time() * 1000)
+
+@dataclass
+class UserCreds:
+ id: str
+ email: str
+ access_token: str
+ role: str
+
+# -------------------------
+# Класс-клиент
+# -------------------------
+class APIE2E:
+
+
+ def __init__(self, base_url: str, logger: logging.Logger, timeout: float = DEFAULT_TIMEOUT) -> None:
+ import requests, time, json, logging
+ self.base_url = base_url.rstrip("/") + "/"
+ self.logger = logger
+ self.timeout = timeout
+ self.sess = requests.Session()
+
+ self.urls = {
+ "auth": urljoin(self.base_url, "auth/"),
+ "profiles": urljoin(self.base_url, "profiles/"),
+ "match": urljoin(self.base_url, "match/"),
+ "chat": urljoin(self.base_url, "chat/"),
+ "payments": urljoin(self.base_url, "payments/"),
+ }
+ self.session = requests.Session()
+
+ # --------- низкоуровневый запрос с логированием ----------
+ def req(self, method, url, body=None, token=None, expected=(200,), name=""):
+ headers = {"Content-Type": "application/json"}
+ if token:
+ headers["Authorization"] = f"Bearer {token}"
+
+ # Готовим запрос, чтобы увидеть финальные заголовки
+ req = requests.Request(method, url,
+ headers=headers,
+ data=(json.dumps(body) if body is not None else None))
+ prep = self.session.prepare_request(req)
+
+ # ЛОГ: какие заголовки действительно уйдут
+ self.log.debug("HTTP %s %s | headers=%s | body=%s",
+ method, url,
+ {k: (v if k.lower() != "authorization" else f"Bearer {v.split(' ',1)[1][:10]}...") for k,v in prep.headers.items()},
+ (body if body is not None else {}))
+
+ t0 = time.time()
+ resp = self.session.send(prep,
+ allow_redirects=False, # ВАЖНО
+ timeout=15)
+ dt = int((time.time()-t0)*1000)
+
+ # ЛОГ: редиректы, если были
+ if resp.is_redirect or resp.is_permanent_redirect or resp.history:
+ self.log.warning("%s got redirect chain: %s",
+ name or url,
+ " -> ".join(f"{r.status_code}:{r.headers.get('Location','')}" for r in resp.history+[resp]))
+
+ text = resp.text
+ self.log.debug("← %s in %d ms | body=%s", resp.status_code, dt, text[:1000])
+
+ if resp.status_code not in expected:
+ raise RuntimeError(f"{name or url} unexpected status {resp.status_code}, expected {list(expected)}; body={text}")
+
+ data = None
+ try:
+ data = resp.json() if text else None
+ except Exception:
+ pass
+ return resp.status_code, data, resp.headers
+
+
+ # --------- health ----------
+ def wait_health(self, name: str, url: str, timeout_sec: int = 60) -> None:
+ self.logger.info(f"Waiting {name} health: {url}")
+ deadline = time.time() + timeout_sec
+ while time.time() < deadline:
+ try:
+ code, _, _ = self.req("GET", url, expected=(200,), name=f"{name}/health")
+ if code == 200:
+ self.logger.info(f"{name} is healthy")
+ return
+ except Exception:
+ pass
+ time.sleep(1)
+ raise TimeoutError(f"{name} not healthy in time: {url}")
+
+ # --------- auth ----------
+ def login(self, email: str, password: str) -> Tuple[str, str]:
+ url = urljoin(self.urls["auth"], "v1/token")
+ _, data, _ = self.req("POST", url, body={"email": email, "password": password}, expected=(200,), name="login")
+ token = data.get("access_token", "")
+ if not token:
+ raise RuntimeError("access_token is empty")
+ user_id = decode_jwt_sub(token)
+ if not user_id:
+ raise RuntimeError("cannot decode user id (sub) from token")
+ return user_id, token
+
+ def register(self, email: str, password: str, full_name: str, role: str) -> None:
+ url = urljoin(self.urls["auth"], "v1/register")
+ # /register в вашем бэке может возвращать 201/200, а иногда 500 при сериализации —
+ # поэтому не падаем на 500 сразу, а логинимся ниже.
+ try:
+ self.req(
+ "POST",
+ url,
+ body={"email": email, "password": password, "full_name": full_name, "role": role},
+ expected=(200, 201),
+ name="register",
+ )
+ except RuntimeError as e:
+ self.logger.warning(f"register returned non-2xx: {e} — will try login anyway")
+
+ def login_or_register(self, email: str, password: str, full_name: str, role: str) -> UserCreds:
+ # 1) пробуем логин
+ try:
+ uid, token = self.login(email, password)
+ self.logger.info(f"Login OK: {email} -> {uid}")
+ return UserCreds(id=uid, email=email, access_token=token, role=role)
+ except Exception as e:
+ self.logger.info(f"Login failed for {email}: {e}; will try register")
+
+ # 2) регистрируем (не фатально, если вернулся 500)
+ self.register(email, password, full_name, role)
+
+ # 3) снова логин
+ uid, token = self.login(email, password)
+ self.logger.info(f"Registered+Login OK: {email} -> {uid}")
+ return UserCreds(id=uid, email=email, access_token=token, role=role)
+
+ # --------- profiles ----------
+ def get_my_profile(self, token: str) -> Tuple[int, Dict[str, Any]]:
+ url = urljoin(self.urls["profiles"], "v1/profiles/me")
+ code, data, _ = self.req("GET", url, token=token, expected=(200, 404), name="profiles/me")
+ return code, data
+
+ def create_profile(
+ self,
+ token: str,
+ gender: str,
+ city: str,
+ languages: List[str],
+ interests: List[str],
+ ) -> Dict[str, Any]:
+ url = urljoin(self.urls["profiles"], "v1/profiles")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=token,
+ body={"gender": gender, "city": city, "languages": languages, "interests": interests},
+ expected=(200, 201),
+ name="profiles/create",
+ )
+ return data
+
+ def ensure_profile(
+ self, token: str, gender: str, city: str, languages: List[str], interests: List[str]
+ ) -> Dict[str, Any]:
+ code, p = self.get_my_profile(token)
+ if code == 200:
+ self.logger.info(f"Profile exists: id={p.get('id')}")
+ return p
+ self.logger.info("Profile not found -> creating")
+ p = self.create_profile(token, gender, city, languages, interests)
+ self.logger.info(f"Profile created: id={p.get('id')}")
+ return p
+
+ # --------- match ----------
+ def create_pair(self, admin_token: str, user_id_a: str, user_id_b: str, score: float, notes: str) -> Dict[str, Any]:
+ url = urljoin(self.urls["match"], "v1/pairs")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=admin_token,
+ body={"user_id_a": user_id_a, "user_id_b": user_id_b, "score": score, "notes": notes},
+ expected=(200, 201),
+ name="match/create_pair",
+ )
+ return data
+
+ # --------- chat ----------
+ def create_room(self, admin_token: str, title: str, participants: List[str]) -> Dict[str, Any]:
+ url = urljoin(self.urls["chat"], "v1/rooms")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=admin_token,
+ body={"title": title, "participants": participants},
+ expected=(200, 201),
+ name="chat/create_room",
+ )
+ return data
+
+ def send_message(self, admin_token: str, room_id: str, content: str) -> Dict[str, Any]:
+ url = urljoin(self.urls["chat"], f"v1/rooms/{room_id}/messages")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=admin_token,
+ body={"content": content},
+ expected=(200, 201),
+ name="chat/send_message",
+ )
+ return data
+
+ # --------- payments ----------
+ def create_invoice(
+ self, admin_token: str, client_id: str, amount: float, currency: str, description: str
+ ) -> Dict[str, Any]:
+ url = urljoin(self.urls["payments"], "v1/invoices")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=admin_token,
+ body={"client_id": client_id, "amount": amount, "currency": currency, "description": description},
+ expected=(200, 201),
+ name="payments/create_invoice",
+ )
+ return data
+
+ def mark_invoice_paid(self, admin_token: str, invoice_id: str) -> Dict[str, Any]:
+ url = urljoin(self.urls["payments"], f"v1/invoices/{invoice_id}/mark-paid")
+ _, data, _ = self.req("POST", url, token=admin_token, expected=(200,), name="payments/mark_paid")
+ return data
+
+# -------------------------
+# Генерация данных
+# -------------------------
+GENDERS = ["female", "male", "other"]
+CITIES = [
+ "Moscow", "Saint Petersburg", "Kazan", "Novosibirsk", "Krasnodar",
+ "Yekaterinburg", "Minsk", "Kyiv", "Almaty", "Tbilisi",
+]
+LANG_POOL = ["ru", "en", "de", "fr", "es", "it", "zh", "uk", "kk", "tr", "pl"]
+INTR_POOL = ["music", "travel", "reading", "sports", "movies", "games", "cooking", "yoga", "art", "tech"]
+
+def pick_languages(n: int = 2) -> List[str]:
+ n = max(1, min(n, len(LANG_POOL)))
+ return sorted(random.sample(LANG_POOL, n))
+
+def pick_interests(n: int = 3) -> List[str]:
+ n = max(1, min(n, len(INTR_POOL)))
+ return sorted(random.sample(INTR_POOL, n))
+
+def random_email(prefix: str, domain: str) -> str:
+ suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
+ return f"{prefix}+{int(time.time())}.{suffix}@{domain}"
+
+# -------------------------
+# Основной сценарий
+# -------------------------
+def main():
+ import argparse
+
+ parser = argparse.ArgumentParser(description="E2E тест API брачного агентства с фейковыми анкетами.")
+ parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="Gateway base URL (по умолчанию http://localhost:8080)")
+ parser.add_argument("--clients", type=int, default=DEFAULT_CLIENTS, help="Количество клиентских аккаунтов (>=2)")
+ parser.add_argument("--password", default=DEFAULT_PASSWORD, help="Пароль для всех тестовых пользователей")
+ parser.add_argument("--email-domain", default=DEFAULT_EMAIL_DOMAIN, help="Домен e-mail для теста (напр. agency.dev)")
+ parser.add_argument("--log-file", default=DEFAULT_LOG_FILE, help="Файл логов (по умолчанию logs/api.log)")
+ parser.add_argument("--seed", type=int, default=42, help="Генератор случайных чисел (seed)")
+ args = parser.parse_args()
+
+ random.seed(args.seed)
+ fake = Faker()
+ logger = setup_logger(args.log_file)
+ logger.info("=== API E2E START ===")
+ logger.info(f"BASE_URL={args.base_url} clients={args.clients} domain={args.email_domain}")
+
+ if args.clients < 2:
+ logger.error("Нужно минимум 2 клиента (для пары).")
+ sys.exit(2)
+
+ api = APIE2E(args.base_url, logger)
+
+ # Health checks через gateway
+ api.wait_health("gateway/auth", urljoin(api.urls["auth"], "health"))
+ api.wait_health("profiles", urljoin(api.urls["profiles"], "health"))
+ api.wait_health("match", urljoin(api.urls["match"], "health"))
+ api.wait_health("chat", urljoin(api.urls["chat"], "health"))
+ api.wait_health("payments", urljoin(api.urls["payments"], "health"))
+
+ # Админ
+ admin_email = random_email("admin", args.email_domain)
+ admin_full = fake.name()
+ admin = api.login_or_register(admin_email, args.password, admin_full, role="ADMIN")
+
+ # Клиенты
+ clients: List[UserCreds] = []
+ for i in range(args.clients):
+ email = random_email(f"user{i+1}", args.email_domain)
+ full = fake.name()
+ u = api.login_or_register(email, args.password, full, role="CLIENT")
+ clients.append(u)
+
+ # Профили для всех
+ for i, u in enumerate([admin] + clients, start=1):
+ gender = random.choice(GENDERS)
+ city = random.choice(CITIES)
+ languages = pick_languages(random.choice([1, 2, 3]))
+ interests = pick_interests(random.choice([2, 3, 4]))
+ logger.info(f"[{i}/{1+len(clients)}] Ensure profile for {u.email} (role={u.role})")
+ api.ensure_profile(u.access_token, gender, city, languages, interests)
+
+ # Match‑пара между двумя случайными клиентами
+ a, b = random.sample(clients, 2)
+ score = round(random.uniform(0.6, 0.98), 2)
+ pair = api.create_pair(admin.access_token, a.id, b.id, score, notes="e2e generated")
+ pair_id = str(pair.get("id", ""))
+ logger.info(f"Match pair created: id={pair_id}, {a.email} ↔ {b.email}, score={score}")
+
+ # Чат‑комната и сообщение
+ room = api.create_room(admin.access_token, title=f"{a.email} & {b.email}", participants=[a.id, b.id])
+ room_id = str(room.get("id", ""))
+ msg = api.send_message(admin.access_token, room_id, content="Hello from admin (e2e)")
+ msg_id = str(msg.get("id", ""))
+ logger.info(f"Chat message sent: room={room_id}, msg={msg_id}")
+
+ # Счёт для первого клиента
+ amount = random.choice([99.0, 199.0, 299.0])
+ inv = api.create_invoice(admin.access_token, client_id=a.id, amount=amount, currency="USD",
+ description="Consultation (e2e)")
+ inv_id = str(inv.get("id", ""))
+ invp = api.mark_invoice_paid(admin.access_token, inv_id)
+ logger.info(f"Invoice paid: id={inv_id}, status={invp.get('status')}")
+
+ # Итог
+ summary = {
+ "admin": {"email": admin.email, "id": admin.id},
+ "clients": [{"email": c.email, "id": c.id} for c in clients],
+ "pair_id": pair_id,
+ "room_id": room_id,
+ "message_id": msg_id,
+ "invoice_id": inv_id,
+ "invoice_status": invp.get("status"),
+ }
+ logger.info("=== SUMMARY ===")
+ logger.info(json.dumps(summary, ensure_ascii=False, indent=2))
+ print(json.dumps(summary, ensure_ascii=False, indent=2)) # на stdout — короткое резюме
+
+if __name__ == "__main__":
+ try:
+ main()
+ except KeyboardInterrupt:
+ print("\nInterrupted.", file=sys.stderr)
+ sys.exit(130)
diff --git a/.history/scripts/api_e2e_20250808215528.py b/.history/scripts/api_e2e_20250808215528.py
new file mode 100644
index 0000000..2eba317
--- /dev/null
+++ b/.history/scripts/api_e2e_20250808215528.py
@@ -0,0 +1,424 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+import base64
+import json
+import logging
+import os
+import random
+import string
+import sys
+import time
+from dataclasses import dataclass
+from logging.handlers import RotatingFileHandler
+from typing import Any, Dict, Iterable, List, Optional, Tuple
+from urllib.parse import urljoin
+
+import requests
+from faker import Faker
+
+# -------------------------
+# Конфигурация по умолчанию
+# -------------------------
+DEFAULT_BASE_URL = os.getenv("BASE_URL", "http://localhost:8080")
+DEFAULT_PASSWORD = os.getenv("PASS", "secret123")
+DEFAULT_CLIENTS = int(os.getenv("CLIENTS", "2"))
+DEFAULT_EMAIL_DOMAIN = os.getenv("EMAIL_DOMAIN", "agency.dev")
+DEFAULT_LOG_FILE = os.getenv("LOG_FILE", "logs/api.log")
+DEFAULT_TIMEOUT = float(os.getenv("HTTP_TIMEOUT", "10.0"))
+
+# -------------------------
+# Логирование
+# -------------------------
+def setup_logger(path: str) -> logging.Logger:
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ logger = logging.getLogger("api_e2e")
+ logger.setLevel(logging.DEBUG)
+
+ # Ротация логов: до 5 файлов по 5 МБ
+ file_handler = RotatingFileHandler(path, maxBytes=5 * 1024 * 1024, backupCount=5, encoding="utf-8")
+ file_handler.setLevel(logging.DEBUG)
+ file_handler.setFormatter(logging.Formatter(
+ fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
+ datefmt="%Y-%m-%d %H:%M:%S",
+ ))
+ logger.addHandler(file_handler)
+
+ # Консоль — INFO и короче
+ console = logging.StreamHandler(sys.stdout)
+ console.setLevel(logging.INFO)
+ console.setFormatter(logging.Formatter("%(levelname)s | %(message)s"))
+ logger.addHandler(console)
+ return logger
+
+# -------------------------
+# Утилиты
+# -------------------------
+def b64url_json(token_part: str) -> Dict[str, Any]:
+ """Декодирует часть JWT (payload) без валидации сигнатуры."""
+ s = token_part + "=" * (-len(token_part) % 4)
+ return json.loads(base64.urlsafe_b64decode(s).decode("utf-8"))
+
+def decode_jwt_sub(token: str) -> str:
+ try:
+ payload = b64url_json(token.split(".")[1])
+ return str(payload.get("sub", "")) # UUID пользователя
+ except Exception:
+ return ""
+
+def mask_token(token: Optional[str]) -> str:
+ if not token:
+ return ""
+ return token[:12] + "..." if len(token) > 12 else token
+
+def now_ms() -> int:
+ return int(time.time() * 1000)
+
+@dataclass
+class UserCreds:
+ id: str
+ email: str
+ access_token: str
+ role: str
+
+# -------------------------
+# Класс-клиент
+# -------------------------
+class APIE2E:
+
+
+ def __init__(self, base_url: str, logger: logging.Logger, timeout: float = DEFAULT_TIMEOUT) -> None:
+
+ self.base_url = base_url.rstrip("/") + "/"
+ self.logger = logger
+ self.timeout = timeout
+ self.sess = requests.Session()
+
+ self.urls = {
+ "auth": urljoin(self.base_url, "auth/"),
+ "profiles": urljoin(self.base_url, "profiles/"),
+ "match": urljoin(self.base_url, "match/"),
+ "chat": urljoin(self.base_url, "chat/"),
+ "payments": urljoin(self.base_url, "payments/"),
+ }
+
+
+ # --------- низкоуровневый запрос с логированием ----------
+ def req(self, method, url, body=None, token=None, expected=(200,), name=""):
+ headers = {"Content-Type": "application/json"}
+ if token:
+ headers["Authorization"] = f"Bearer {token}"
+
+ # Готовим запрос, чтобы увидеть финальные заголовки
+ req = requests.Request(method, url,
+ headers=headers,
+ data=(json.dumps(body) if body is not None else None))
+ prep = self.session.prepare_request(req)
+
+ # ЛОГ: какие заголовки действительно уйдут
+ self.log.debug("HTTP %s %s | headers=%s | body=%s",
+ method, url,
+ {k: (v if k.lower() != "authorization" else f"Bearer {v.split(' ',1)[1][:10]}...") for k,v in prep.headers.items()},
+ (body if body is not None else {}))
+
+ t0 = time.time()
+ resp = self.session.send(prep,
+ allow_redirects=False, # ВАЖНО
+ timeout=15)
+ dt = int((time.time()-t0)*1000)
+
+ # ЛОГ: редиректы, если были
+ if resp.is_redirect or resp.is_permanent_redirect or resp.history:
+ self.log.warning("%s got redirect chain: %s",
+ name or url,
+ " -> ".join(f"{r.status_code}:{r.headers.get('Location','')}" for r in resp.history+[resp]))
+
+ text = resp.text
+ self.log.debug("← %s in %d ms | body=%s", resp.status_code, dt, text[:1000])
+
+ if resp.status_code not in expected:
+ raise RuntimeError(f"{name or url} unexpected status {resp.status_code}, expected {list(expected)}; body={text}")
+
+ data = None
+ try:
+ data = resp.json() if text else None
+ except Exception:
+ pass
+ return resp.status_code, data, resp.headers
+
+
+ # --------- health ----------
+ def wait_health(self, name: str, url: str, timeout_sec: int = 60) -> None:
+ self.logger.info(f"Waiting {name} health: {url}")
+ deadline = time.time() + timeout_sec
+ while time.time() < deadline:
+ try:
+ code, _, _ = self.req("GET", url, expected=(200,), name=f"{name}/health")
+ if code == 200:
+ self.logger.info(f"{name} is healthy")
+ return
+ except Exception:
+ pass
+ time.sleep(1)
+ raise TimeoutError(f"{name} not healthy in time: {url}")
+
+ # --------- auth ----------
+ def login(self, email: str, password: str) -> Tuple[str, str]:
+ url = urljoin(self.urls["auth"], "v1/token")
+ _, data, _ = self.req("POST", url, body={"email": email, "password": password}, expected=(200,), name="login")
+ token = data.get("access_token", "")
+ if not token:
+ raise RuntimeError("access_token is empty")
+ user_id = decode_jwt_sub(token)
+ if not user_id:
+ raise RuntimeError("cannot decode user id (sub) from token")
+ return user_id, token
+
+ def register(self, email: str, password: str, full_name: str, role: str) -> None:
+ url = urljoin(self.urls["auth"], "v1/register")
+ # /register в вашем бэке может возвращать 201/200, а иногда 500 при сериализации —
+ # поэтому не падаем на 500 сразу, а логинимся ниже.
+ try:
+ self.req(
+ "POST",
+ url,
+ body={"email": email, "password": password, "full_name": full_name, "role": role},
+ expected=(200, 201),
+ name="register",
+ )
+ except RuntimeError as e:
+ self.logger.warning(f"register returned non-2xx: {e} — will try login anyway")
+
+ def login_or_register(self, email: str, password: str, full_name: str, role: str) -> UserCreds:
+ # 1) пробуем логин
+ try:
+ uid, token = self.login(email, password)
+ self.logger.info(f"Login OK: {email} -> {uid}")
+ return UserCreds(id=uid, email=email, access_token=token, role=role)
+ except Exception as e:
+ self.logger.info(f"Login failed for {email}: {e}; will try register")
+
+ # 2) регистрируем (не фатально, если вернулся 500)
+ self.register(email, password, full_name, role)
+
+ # 3) снова логин
+ uid, token = self.login(email, password)
+ self.logger.info(f"Registered+Login OK: {email} -> {uid}")
+ return UserCreds(id=uid, email=email, access_token=token, role=role)
+
+ # --------- profiles ----------
+ def get_my_profile(self, token: str) -> Tuple[int, Dict[str, Any]]:
+ url = urljoin(self.urls["profiles"], "v1/profiles/me")
+ code, data, _ = self.req("GET", url, token=token, expected=(200, 404), name="profiles/me")
+ return code, data
+
+ def create_profile(
+ self,
+ token: str,
+ gender: str,
+ city: str,
+ languages: List[str],
+ interests: List[str],
+ ) -> Dict[str, Any]:
+ url = urljoin(self.urls["profiles"], "v1/profiles")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=token,
+ body={"gender": gender, "city": city, "languages": languages, "interests": interests},
+ expected=(200, 201),
+ name="profiles/create",
+ )
+ return data
+
+ def ensure_profile(
+ self, token: str, gender: str, city: str, languages: List[str], interests: List[str]
+ ) -> Dict[str, Any]:
+ code, p = self.get_my_profile(token)
+ if code == 200:
+ self.logger.info(f"Profile exists: id={p.get('id')}")
+ return p
+ self.logger.info("Profile not found -> creating")
+ p = self.create_profile(token, gender, city, languages, interests)
+ self.logger.info(f"Profile created: id={p.get('id')}")
+ return p
+
+ # --------- match ----------
+ def create_pair(self, admin_token: str, user_id_a: str, user_id_b: str, score: float, notes: str) -> Dict[str, Any]:
+ url = urljoin(self.urls["match"], "v1/pairs")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=admin_token,
+ body={"user_id_a": user_id_a, "user_id_b": user_id_b, "score": score, "notes": notes},
+ expected=(200, 201),
+ name="match/create_pair",
+ )
+ return data
+
+ # --------- chat ----------
+ def create_room(self, admin_token: str, title: str, participants: List[str]) -> Dict[str, Any]:
+ url = urljoin(self.urls["chat"], "v1/rooms")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=admin_token,
+ body={"title": title, "participants": participants},
+ expected=(200, 201),
+ name="chat/create_room",
+ )
+ return data
+
+ def send_message(self, admin_token: str, room_id: str, content: str) -> Dict[str, Any]:
+ url = urljoin(self.urls["chat"], f"v1/rooms/{room_id}/messages")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=admin_token,
+ body={"content": content},
+ expected=(200, 201),
+ name="chat/send_message",
+ )
+ return data
+
+ # --------- payments ----------
+ def create_invoice(
+ self, admin_token: str, client_id: str, amount: float, currency: str, description: str
+ ) -> Dict[str, Any]:
+ url = urljoin(self.urls["payments"], "v1/invoices")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=admin_token,
+ body={"client_id": client_id, "amount": amount, "currency": currency, "description": description},
+ expected=(200, 201),
+ name="payments/create_invoice",
+ )
+ return data
+
+ def mark_invoice_paid(self, admin_token: str, invoice_id: str) -> Dict[str, Any]:
+ url = urljoin(self.urls["payments"], f"v1/invoices/{invoice_id}/mark-paid")
+ _, data, _ = self.req("POST", url, token=admin_token, expected=(200,), name="payments/mark_paid")
+ return data
+
+# -------------------------
+# Генерация данных
+# -------------------------
+GENDERS = ["female", "male", "other"]
+CITIES = [
+ "Moscow", "Saint Petersburg", "Kazan", "Novosibirsk", "Krasnodar",
+ "Yekaterinburg", "Minsk", "Kyiv", "Almaty", "Tbilisi",
+]
+LANG_POOL = ["ru", "en", "de", "fr", "es", "it", "zh", "uk", "kk", "tr", "pl"]
+INTR_POOL = ["music", "travel", "reading", "sports", "movies", "games", "cooking", "yoga", "art", "tech"]
+
+def pick_languages(n: int = 2) -> List[str]:
+ n = max(1, min(n, len(LANG_POOL)))
+ return sorted(random.sample(LANG_POOL, n))
+
+def pick_interests(n: int = 3) -> List[str]:
+ n = max(1, min(n, len(INTR_POOL)))
+ return sorted(random.sample(INTR_POOL, n))
+
+def random_email(prefix: str, domain: str) -> str:
+ suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
+ return f"{prefix}+{int(time.time())}.{suffix}@{domain}"
+
+# -------------------------
+# Основной сценарий
+# -------------------------
+def main():
+ import argparse
+
+ parser = argparse.ArgumentParser(description="E2E тест API брачного агентства с фейковыми анкетами.")
+ parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="Gateway base URL (по умолчанию http://localhost:8080)")
+ parser.add_argument("--clients", type=int, default=DEFAULT_CLIENTS, help="Количество клиентских аккаунтов (>=2)")
+ parser.add_argument("--password", default=DEFAULT_PASSWORD, help="Пароль для всех тестовых пользователей")
+ parser.add_argument("--email-domain", default=DEFAULT_EMAIL_DOMAIN, help="Домен e-mail для теста (напр. agency.dev)")
+ parser.add_argument("--log-file", default=DEFAULT_LOG_FILE, help="Файл логов (по умолчанию logs/api.log)")
+ parser.add_argument("--seed", type=int, default=42, help="Генератор случайных чисел (seed)")
+ args = parser.parse_args()
+
+ random.seed(args.seed)
+ fake = Faker()
+ logger = setup_logger(args.log_file)
+ logger.info("=== API E2E START ===")
+ logger.info(f"BASE_URL={args.base_url} clients={args.clients} domain={args.email_domain}")
+
+ if args.clients < 2:
+ logger.error("Нужно минимум 2 клиента (для пары).")
+ sys.exit(2)
+
+ api = APIE2E(args.base_url, logger)
+
+ # Health checks через gateway
+ api.wait_health("gateway/auth", urljoin(api.urls["auth"], "health"))
+ api.wait_health("profiles", urljoin(api.urls["profiles"], "health"))
+ api.wait_health("match", urljoin(api.urls["match"], "health"))
+ api.wait_health("chat", urljoin(api.urls["chat"], "health"))
+ api.wait_health("payments", urljoin(api.urls["payments"], "health"))
+
+ # Админ
+ admin_email = random_email("admin", args.email_domain)
+ admin_full = fake.name()
+ admin = api.login_or_register(admin_email, args.password, admin_full, role="ADMIN")
+
+ # Клиенты
+ clients: List[UserCreds] = []
+ for i in range(args.clients):
+ email = random_email(f"user{i+1}", args.email_domain)
+ full = fake.name()
+ u = api.login_or_register(email, args.password, full, role="CLIENT")
+ clients.append(u)
+
+ # Профили для всех
+ for i, u in enumerate([admin] + clients, start=1):
+ gender = random.choice(GENDERS)
+ city = random.choice(CITIES)
+ languages = pick_languages(random.choice([1, 2, 3]))
+ interests = pick_interests(random.choice([2, 3, 4]))
+ logger.info(f"[{i}/{1+len(clients)}] Ensure profile for {u.email} (role={u.role})")
+ api.ensure_profile(u.access_token, gender, city, languages, interests)
+
+ # Match‑пара между двумя случайными клиентами
+ a, b = random.sample(clients, 2)
+ score = round(random.uniform(0.6, 0.98), 2)
+ pair = api.create_pair(admin.access_token, a.id, b.id, score, notes="e2e generated")
+ pair_id = str(pair.get("id", ""))
+ logger.info(f"Match pair created: id={pair_id}, {a.email} ↔ {b.email}, score={score}")
+
+ # Чат‑комната и сообщение
+ room = api.create_room(admin.access_token, title=f"{a.email} & {b.email}", participants=[a.id, b.id])
+ room_id = str(room.get("id", ""))
+ msg = api.send_message(admin.access_token, room_id, content="Hello from admin (e2e)")
+ msg_id = str(msg.get("id", ""))
+ logger.info(f"Chat message sent: room={room_id}, msg={msg_id}")
+
+ # Счёт для первого клиента
+ amount = random.choice([99.0, 199.0, 299.0])
+ inv = api.create_invoice(admin.access_token, client_id=a.id, amount=amount, currency="USD",
+ description="Consultation (e2e)")
+ inv_id = str(inv.get("id", ""))
+ invp = api.mark_invoice_paid(admin.access_token, inv_id)
+ logger.info(f"Invoice paid: id={inv_id}, status={invp.get('status')}")
+
+ # Итог
+ summary = {
+ "admin": {"email": admin.email, "id": admin.id},
+ "clients": [{"email": c.email, "id": c.id} for c in clients],
+ "pair_id": pair_id,
+ "room_id": room_id,
+ "message_id": msg_id,
+ "invoice_id": inv_id,
+ "invoice_status": invp.get("status"),
+ }
+ logger.info("=== SUMMARY ===")
+ logger.info(json.dumps(summary, ensure_ascii=False, indent=2))
+ print(json.dumps(summary, ensure_ascii=False, indent=2)) # на stdout — короткое резюме
+
+if __name__ == "__main__":
+ try:
+ main()
+ except KeyboardInterrupt:
+ print("\nInterrupted.", file=sys.stderr)
+ sys.exit(130)
diff --git a/.history/scripts/api_e2e_20250808215617.py b/.history/scripts/api_e2e_20250808215617.py
new file mode 100644
index 0000000..7e9f8e5
--- /dev/null
+++ b/.history/scripts/api_e2e_20250808215617.py
@@ -0,0 +1,417 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+import base64
+import json
+import logging
+import os
+import random
+import string
+import sys
+import time
+from dataclasses import dataclass
+from logging.handlers import RotatingFileHandler
+from typing import Any, Dict, Iterable, List, Optional, Tuple
+from urllib.parse import urljoin
+
+import requests
+from faker import Faker
+
+# -------------------------
+# Конфигурация по умолчанию
+# -------------------------
+DEFAULT_BASE_URL = os.getenv("BASE_URL", "http://localhost:8080")
+DEFAULT_PASSWORD = os.getenv("PASS", "secret123")
+DEFAULT_CLIENTS = int(os.getenv("CLIENTS", "2"))
+DEFAULT_EMAIL_DOMAIN = os.getenv("EMAIL_DOMAIN", "agency.dev")
+DEFAULT_LOG_FILE = os.getenv("LOG_FILE", "logs/api.log")
+DEFAULT_TIMEOUT = float(os.getenv("HTTP_TIMEOUT", "10.0"))
+
+# -------------------------
+# Логирование
+# -------------------------
+def setup_logger(path: str) -> logging.Logger:
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ logger = logging.getLogger("api_e2e")
+ logger.setLevel(logging.DEBUG)
+
+ # Ротация логов: до 5 файлов по 5 МБ
+ file_handler = RotatingFileHandler(path, maxBytes=5 * 1024 * 1024, backupCount=5, encoding="utf-8")
+ file_handler.setLevel(logging.DEBUG)
+ file_handler.setFormatter(logging.Formatter(
+ fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
+ datefmt="%Y-%m-%d %H:%M:%S",
+ ))
+ logger.addHandler(file_handler)
+
+ # Консоль — INFO и короче
+ console = logging.StreamHandler(sys.stdout)
+ console.setLevel(logging.INFO)
+ console.setFormatter(logging.Formatter("%(levelname)s | %(message)s"))
+ logger.addHandler(console)
+ return logger
+
+# -------------------------
+# Утилиты
+# -------------------------
+def b64url_json(token_part: str) -> Dict[str, Any]:
+ """Декодирует часть JWT (payload) без валидации сигнатуры."""
+ s = token_part + "=" * (-len(token_part) % 4)
+ return json.loads(base64.urlsafe_b64decode(s).decode("utf-8"))
+
+def decode_jwt_sub(token: str) -> str:
+ try:
+ payload = b64url_json(token.split(".")[1])
+ return str(payload.get("sub", "")) # UUID пользователя
+ except Exception:
+ return ""
+
+def mask_token(token: Optional[str]) -> str:
+ if not token:
+ return ""
+ return token[:12] + "..." if len(token) > 12 else token
+
+def now_ms() -> int:
+ return int(time.time() * 1000)
+
+@dataclass
+class UserCreds:
+ id: str
+ email: str
+ access_token: str
+ role: str
+
+# -------------------------
+# Класс-клиент
+# -------------------------
+class APIE2E:
+
+ def __init__(self, base_url: str, logger: logging.Logger, timeout: float = DEFAULT_TIMEOUT) -> None:
+ self.base_url = base_url.rstrip("/") + "/"
+ self.logger = logger
+ self.timeout = timeout
+ self.sess = requests.Session()
+
+ self.urls = {
+ "auth": urljoin(self.base_url, "auth/"),
+ "profiles": urljoin(self.base_url, "profiles/"),
+ "match": urljoin(self.base_url, "match/"),
+ "chat": urljoin(self.base_url, "chat/"),
+ "payments": urljoin(self.base_url, "payments/"),
+ }
+
+ # --------- низкоуровневый запрос с логированием ----------
+ def req(self, method, url, token=None, body=None, expected=(200,), name=None):
+ headers = {"Accept": "application/json"}
+ if token:
+ headers["Authorization"] = f"Bearer {token}"
+
+ log_body = {}
+ if body:
+ log_body = dict(body)
+ for key in list(log_body.keys()):
+ if key.lower() in ("password", "token", "access_token", "refresh_token"):
+ log_body[key] = "***hidden***"
+
+ started = now_ms()
+ self.logger.debug(
+ f"HTTP {method} {url} | headers={{Authorization: Bearer {mask_token(token)}}} | body={log_body}"
+ )
+
+ try:
+ resp = self.sess.request(method=method, url=url, json=body, timeout=self.timeout)
+ except Exception as e:
+ duration = now_ms() - started
+ self.logger.error(f"{name or url} FAILED transport error: {e} ({duration} ms)")
+ raise
+
+ text = resp.text or ""
+ try:
+ data = resp.json() if text else {}
+ except ValueError:
+ data = {}
+
+ duration = now_ms() - started
+ self.logger.debug(f"← {resp.status_code} in {duration} ms | body={text[:2000]}")
+ if expected and resp.status_code not in expected:
+ msg = f"{name or url} unexpected status {resp.status_code}, expected {list(expected)}; body={text}"
+ self.logger.error(msg)
+ raise RuntimeError(msg)
+ return resp.status_code, data, text
+
+
+ # --------- health ----------
+ def wait_health(self, name: str, url: str, timeout_sec: int = 60) -> None:
+ self.logger.info(f"Waiting {name} health: {url}")
+ deadline = time.time() + timeout_sec
+ while time.time() < deadline:
+ try:
+ code, _, _ = self.req("GET", url, expected=(200,), name=f"{name}/health")
+ if code == 200:
+ self.logger.info(f"{name} is healthy")
+ return
+ except Exception:
+ pass
+ time.sleep(1)
+ raise TimeoutError(f"{name} not healthy in time: {url}")
+
+ # --------- auth ----------
+ def login(self, email: str, password: str) -> Tuple[str, str]:
+ url = urljoin(self.urls["auth"], "v1/token")
+ _, data, _ = self.req("POST", url, body={"email": email, "password": password}, expected=(200,), name="login")
+ token = data.get("access_token", "")
+ if not token:
+ raise RuntimeError("access_token is empty")
+ user_id = decode_jwt_sub(token)
+ if not user_id:
+ raise RuntimeError("cannot decode user id (sub) from token")
+ return user_id, token
+
+ def register(self, email: str, password: str, full_name: str, role: str) -> None:
+ url = urljoin(self.urls["auth"], "v1/register")
+ # /register в вашем бэке может возвращать 201/200, а иногда 500 при сериализации —
+ # поэтому не падаем на 500 сразу, а логинимся ниже.
+ try:
+ self.req(
+ "POST",
+ url,
+ body={"email": email, "password": password, "full_name": full_name, "role": role},
+ expected=(200, 201),
+ name="register",
+ )
+ except RuntimeError as e:
+ self.logger.warning(f"register returned non-2xx: {e} — will try login anyway")
+
+ def login_or_register(self, email: str, password: str, full_name: str, role: str) -> UserCreds:
+ # 1) пробуем логин
+ try:
+ uid, token = self.login(email, password)
+ self.logger.info(f"Login OK: {email} -> {uid}")
+ return UserCreds(id=uid, email=email, access_token=token, role=role)
+ except Exception as e:
+ self.logger.info(f"Login failed for {email}: {e}; will try register")
+
+ # 2) регистрируем (не фатально, если вернулся 500)
+ self.register(email, password, full_name, role)
+
+ # 3) снова логин
+ uid, token = self.login(email, password)
+ self.logger.info(f"Registered+Login OK: {email} -> {uid}")
+ return UserCreds(id=uid, email=email, access_token=token, role=role)
+
+ # --------- profiles ----------
+ def get_my_profile(self, token: str) -> Tuple[int, Dict[str, Any]]:
+ url = urljoin(self.urls["profiles"], "v1/profiles/me")
+ code, data, _ = self.req("GET", url, token=token, expected=(200, 404), name="profiles/me")
+ return code, data
+
+ def create_profile(
+ self,
+ token: str,
+ gender: str,
+ city: str,
+ languages: List[str],
+ interests: List[str],
+ ) -> Dict[str, Any]:
+ url = urljoin(self.urls["profiles"], "v1/profiles")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=token,
+ body={"gender": gender, "city": city, "languages": languages, "interests": interests},
+ expected=(200, 201),
+ name="profiles/create",
+ )
+ return data
+
+ def ensure_profile(
+ self, token: str, gender: str, city: str, languages: List[str], interests: List[str]
+ ) -> Dict[str, Any]:
+ code, p = self.get_my_profile(token)
+ if code == 200:
+ self.logger.info(f"Profile exists: id={p.get('id')}")
+ return p
+ self.logger.info("Profile not found -> creating")
+ p = self.create_profile(token, gender, city, languages, interests)
+ self.logger.info(f"Profile created: id={p.get('id')}")
+ return p
+
+ # --------- match ----------
+ def create_pair(self, admin_token: str, user_id_a: str, user_id_b: str, score: float, notes: str) -> Dict[str, Any]:
+ url = urljoin(self.urls["match"], "v1/pairs")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=admin_token,
+ body={"user_id_a": user_id_a, "user_id_b": user_id_b, "score": score, "notes": notes},
+ expected=(200, 201),
+ name="match/create_pair",
+ )
+ return data
+
+ # --------- chat ----------
+ def create_room(self, admin_token: str, title: str, participants: List[str]) -> Dict[str, Any]:
+ url = urljoin(self.urls["chat"], "v1/rooms")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=admin_token,
+ body={"title": title, "participants": participants},
+ expected=(200, 201),
+ name="chat/create_room",
+ )
+ return data
+
+ def send_message(self, admin_token: str, room_id: str, content: str) -> Dict[str, Any]:
+ url = urljoin(self.urls["chat"], f"v1/rooms/{room_id}/messages")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=admin_token,
+ body={"content": content},
+ expected=(200, 201),
+ name="chat/send_message",
+ )
+ return data
+
+ # --------- payments ----------
+ def create_invoice(
+ self, admin_token: str, client_id: str, amount: float, currency: str, description: str
+ ) -> Dict[str, Any]:
+ url = urljoin(self.urls["payments"], "v1/invoices")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=admin_token,
+ body={"client_id": client_id, "amount": amount, "currency": currency, "description": description},
+ expected=(200, 201),
+ name="payments/create_invoice",
+ )
+ return data
+
+ def mark_invoice_paid(self, admin_token: str, invoice_id: str) -> Dict[str, Any]:
+ url = urljoin(self.urls["payments"], f"v1/invoices/{invoice_id}/mark-paid")
+ _, data, _ = self.req("POST", url, token=admin_token, expected=(200,), name="payments/mark_paid")
+ return data
+
+# -------------------------
+# Генерация данных
+# -------------------------
+GENDERS = ["female", "male", "other"]
+CITIES = [
+ "Moscow", "Saint Petersburg", "Kazan", "Novosibirsk", "Krasnodar",
+ "Yekaterinburg", "Minsk", "Kyiv", "Almaty", "Tbilisi",
+]
+LANG_POOL = ["ru", "en", "de", "fr", "es", "it", "zh", "uk", "kk", "tr", "pl"]
+INTR_POOL = ["music", "travel", "reading", "sports", "movies", "games", "cooking", "yoga", "art", "tech"]
+
+def pick_languages(n: int = 2) -> List[str]:
+ n = max(1, min(n, len(LANG_POOL)))
+ return sorted(random.sample(LANG_POOL, n))
+
+def pick_interests(n: int = 3) -> List[str]:
+ n = max(1, min(n, len(INTR_POOL)))
+ return sorted(random.sample(INTR_POOL, n))
+
+def random_email(prefix: str, domain: str) -> str:
+ suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
+ return f"{prefix}+{int(time.time())}.{suffix}@{domain}"
+
+# -------------------------
+# Основной сценарий
+# -------------------------
+def main():
+ import argparse
+
+ parser = argparse.ArgumentParser(description="E2E тест API брачного агентства с фейковыми анкетами.")
+ parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="Gateway base URL (по умолчанию http://localhost:8080)")
+ parser.add_argument("--clients", type=int, default=DEFAULT_CLIENTS, help="Количество клиентских аккаунтов (>=2)")
+ parser.add_argument("--password", default=DEFAULT_PASSWORD, help="Пароль для всех тестовых пользователей")
+ parser.add_argument("--email-domain", default=DEFAULT_EMAIL_DOMAIN, help="Домен e-mail для теста (напр. agency.dev)")
+ parser.add_argument("--log-file", default=DEFAULT_LOG_FILE, help="Файл логов (по умолчанию logs/api.log)")
+ parser.add_argument("--seed", type=int, default=42, help="Генератор случайных чисел (seed)")
+ args = parser.parse_args()
+
+ random.seed(args.seed)
+ fake = Faker()
+ logger = setup_logger(args.log_file)
+ logger.info("=== API E2E START ===")
+ logger.info(f"BASE_URL={args.base_url} clients={args.clients} domain={args.email_domain}")
+
+ if args.clients < 2:
+ logger.error("Нужно минимум 2 клиента (для пары).")
+ sys.exit(2)
+
+ api = APIE2E(args.base_url, logger)
+
+ # Health checks через gateway
+ api.wait_health("gateway/auth", urljoin(api.urls["auth"], "health"))
+ api.wait_health("profiles", urljoin(api.urls["profiles"], "health"))
+ api.wait_health("match", urljoin(api.urls["match"], "health"))
+ api.wait_health("chat", urljoin(api.urls["chat"], "health"))
+ api.wait_health("payments", urljoin(api.urls["payments"], "health"))
+
+ # Админ
+ admin_email = random_email("admin", args.email_domain)
+ admin_full = fake.name()
+ admin = api.login_or_register(admin_email, args.password, admin_full, role="ADMIN")
+
+ # Клиенты
+ clients: List[UserCreds] = []
+ for i in range(args.clients):
+ email = random_email(f"user{i+1}", args.email_domain)
+ full = fake.name()
+ u = api.login_or_register(email, args.password, full, role="CLIENT")
+ clients.append(u)
+
+ # Профили для всех
+ for i, u in enumerate([admin] + clients, start=1):
+ gender = random.choice(GENDERS)
+ city = random.choice(CITIES)
+ languages = pick_languages(random.choice([1, 2, 3]))
+ interests = pick_interests(random.choice([2, 3, 4]))
+ logger.info(f"[{i}/{1+len(clients)}] Ensure profile for {u.email} (role={u.role})")
+ api.ensure_profile(u.access_token, gender, city, languages, interests)
+
+ # Match‑пара между двумя случайными клиентами
+ a, b = random.sample(clients, 2)
+ score = round(random.uniform(0.6, 0.98), 2)
+ pair = api.create_pair(admin.access_token, a.id, b.id, score, notes="e2e generated")
+ pair_id = str(pair.get("id", ""))
+ logger.info(f"Match pair created: id={pair_id}, {a.email} ↔ {b.email}, score={score}")
+
+ # Чат‑комната и сообщение
+ room = api.create_room(admin.access_token, title=f"{a.email} & {b.email}", participants=[a.id, b.id])
+ room_id = str(room.get("id", ""))
+ msg = api.send_message(admin.access_token, room_id, content="Hello from admin (e2e)")
+ msg_id = str(msg.get("id", ""))
+ logger.info(f"Chat message sent: room={room_id}, msg={msg_id}")
+
+ # Счёт для первого клиента
+ amount = random.choice([99.0, 199.0, 299.0])
+ inv = api.create_invoice(admin.access_token, client_id=a.id, amount=amount, currency="USD",
+ description="Consultation (e2e)")
+ inv_id = str(inv.get("id", ""))
+ invp = api.mark_invoice_paid(admin.access_token, inv_id)
+ logger.info(f"Invoice paid: id={inv_id}, status={invp.get('status')}")
+
+ # Итог
+ summary = {
+ "admin": {"email": admin.email, "id": admin.id},
+ "clients": [{"email": c.email, "id": c.id} for c in clients],
+ "pair_id": pair_id,
+ "room_id": room_id,
+ "message_id": msg_id,
+ "invoice_id": inv_id,
+ "invoice_status": invp.get("status"),
+ }
+ logger.info("=== SUMMARY ===")
+ logger.info(json.dumps(summary, ensure_ascii=False, indent=2))
+ print(json.dumps(summary, ensure_ascii=False, indent=2)) # на stdout — короткое резюме
+
+if __name__ == "__main__":
+ try:
+ main()
+ except KeyboardInterrupt:
+ print("\nInterrupted.", file=sys.stderr)
+ sys.exit(130)
diff --git a/.history/scripts/e2e_20250808205322.sh b/.history/scripts/e2e_20250808205322.sh
new file mode 100644
index 0000000..e69de29
diff --git a/.history/scripts/e2e_20250808205324.sh b/.history/scripts/e2e_20250808205324.sh
new file mode 100644
index 0000000..0f54893
--- /dev/null
+++ b/.history/scripts/e2e_20250808205324.sh
@@ -0,0 +1,276 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+
+# ------------------------------------------------------------
+# E2E smoke test for the matchmaking microservices
+# Services via gateway: /auth, /profiles, /match, /chat, /payments
+# ------------------------------------------------------------
+
+BASE_URL="${BASE_URL:-http://localhost:8080}"
+AUTH="$BASE_URL/auth"
+PROFILES="$BASE_URL/profiles"
+MATCH="$BASE_URL/match"
+CHAT="$BASE_URL/chat"
+PAYMENTS="$BASE_URL/payments"
+
+# Colors
+NC='\033[0m'; B='\033[1m'; G='\033[0;32m'; Y='\033[0;33m'; R='\033[0;31m'; C='\033[0;36m'
+
+TMP_DIR="$(mktemp -d)"
+trap 'rm -rf "$TMP_DIR"' EXIT
+
+require() {
+ command -v "$1" >/dev/null 2>&1 || { echo -e "${R}ERROR:${NC} '$1' is required"; exit 1; }
+}
+require curl
+require python3
+
+log() { echo -e "${C}[$(date +%H:%M:%S)]${NC} $*"; }
+ok() { echo -e "${G}✔${NC} $*"; }
+warn(){ echo -e "${Y}⚠${NC} $*"; }
+fail(){ echo -e "${R}✖${NC} $*"; exit 1; }
+
+json_get() {
+ # json_get (dot notation; arrays allowed by numeric index)
+ python3 - "$1" "$2" <<'PY'
+import sys, json
+f, path = sys.argv[1], sys.argv[2]
+with open(f, 'r') as fh:
+ try:
+ data = json.load(fh)
+ except Exception:
+ print(""); sys.exit(0)
+cur = data
+for key in path.split('.'):
+ if isinstance(cur, list):
+ try:
+ key = int(key)
+ except:
+ print(""); sys.exit(0)
+ cur = cur[key] if 0 <= key < len(cur) else None
+ elif isinstance(cur, dict):
+ cur = cur.get(key)
+ else:
+ cur = None
+ if cur is None:
+ break
+print("" if cur is None else cur)
+PY
+}
+
+http_req() {
+ # http_req [] [] -> prints HTTP code; body to $RESP
+ local METHOD="$1"; shift
+ local URL="$1"; shift
+ local TOKEN="${1:-}"; shift || true
+ local BODY="${1:-}"; shift || true
+ local RESP="${TMP_DIR}/resp_$(date +%s%N).json"
+
+ local args=(-sS -X "$METHOD" "$URL" -o "$RESP" -w "%{http_code}")
+ if [[ -n "$TOKEN" ]]; then args+=(-H "Authorization: Bearer $TOKEN"); fi
+ if [[ -n "$BODY" ]]; then args+=(-H "Content-Type: application/json" -d "$BODY"); fi
+
+ local CODE
+ CODE="$(curl "${args[@]}")"
+ echo "$CODE|$RESP"
+}
+
+expect_code() {
+ # expect_code "" "||..."
+ local ACT="$1"; local ALLOWED="$2"
+ if [[ "$ALLOWED" == *"|${ACT}|"* || "$ALLOWED" == "${ACT}|"* || "$ALLOWED" == *"|${ACT}" || "$ALLOWED" == "${ACT}" ]]; then
+ return 0
+ fi
+ return 1
+}
+
+wait_health() {
+ local NAME="$1"; local URL="$2"; local tries=60
+ log "Waiting ${NAME} health: ${URL}"
+ for ((i=1; i<=tries; i++)); do
+ local CODE
+ CODE="$(curl -s -o /dev/null -w "%{http_code}" "$URL" || true)"
+ if [[ "$CODE" == "200" ]]; then ok "${NAME} is healthy"; return 0; fi
+ sleep 1
+ done
+ fail "${NAME} not healthy in time: ${URL}"
+}
+
+register_or_login() {
+ # register_or_login -> echoes "|"
+ local EMAIL="$1" PASS="$2" FULL="$3" ROLE="$4"
+
+ local BODY REG RESPCODE RESP REG_ID
+ BODY=$(printf '{"email":"%s","password":"%s","full_name":"%s","role":"%s"}' "$EMAIL" "$PASS" "$FULL" "$ROLE")
+ REG="$(http_req POST "$AUTH/v1/register" "" "$BODY")"
+ RESPCODE="${REG%%|*}"; RESP="${REG##*|}"
+
+ if expect_code "$RESPCODE" "201|200"; then
+ ok "Registered user ${EMAIL}"
+ else
+ # maybe already exists
+ local MSG
+ MSG="$(json_get "$RESP" "detail")"
+ if [[ "$RESPCODE" == "400" && "$MSG" == "Email already in use" ]]; then
+ warn "User ${EMAIL} already exists, will login"
+ else
+ warn "Register response ($RESPCODE): $(cat "$RESP" 2>/dev/null || true)"
+ fi
+ fi
+
+ # token
+ local TOK TOKCODE TOKRESP
+ BODY=$(printf '{"email":"%s","password":"%s"}' "$EMAIL" "$PASS")
+ TOK="$(http_req POST "$AUTH/v1/token" "" "$BODY")"
+ TOKCODE="${TOK%%|*}"; TOKRESP="${TOK##*|}"
+ expect_code "$TOKCODE" "200" || fail "Token failed (${TOKCODE}): $(cat "$TOKRESP")"
+
+ local ACCESS REFRESH
+ ACCESS="$(json_get "$TOKRESP" "access_token")"
+ REFRESH="$(json_get "$TOKRESP" "refresh_token")"
+ [[ -n "$ACCESS" ]] || fail "Empty access token for ${EMAIL}"
+
+ # resolve user id via /me
+ local ME MECODE MERESP UID
+ ME="$(http_req GET "$AUTH/v1/me" "$ACCESS")"
+ MECODE="${ME%%|*}"; MERESP="${ME##*|}"
+ expect_code "$MECODE" "200" || fail "/me failed for ${EMAIL} (${MECODE}): $(cat "$MERESP")"
+ UID="$(json_get "$MERESP" "id")"
+ [[ -n "$UID" ]] || fail "Failed to parse user id for ${EMAIL}"
+
+ echo "${UID}|${ACCESS}"
+}
+
+ensure_profile() {
+ # ensure_profile
+ local TOKEN="$1" G="$2" CITY="$3" LANGS_CSV="$4" INTERESTS_CSV="$5"
+
+ # GET /profiles/me: 200 or 404
+ local ME MECODE MERESP
+ ME="$(http_req GET "$PROFILES/v1/profiles/me" "$TOKEN")"
+ MECODE="${ME%%|*}"; MERESP="${ME##*|}"
+ if [[ "$MECODE" == "200" ]]; then
+ ok "Profile already exists"
+ echo "$MERESP" > "${TMP_DIR}/last_profile.json"
+ return 0
+ elif [[ "$MECODE" != "404" ]]; then
+ warn "Unexpected /profiles/me code ${MECODE}: $(cat "$MERESP")"
+ fi
+
+ # Create profile
+ IFS=',' read -r -a langs <<< "$LANGS_CSV"
+ IFS=',' read -r -a intrs <<< "$INTERESTS_CSV"
+ local langs_json intrs_json
+ langs_json="$(printf '%s\n' "${langs[@]}" | sed 's/^/"/;s/$/"/' | paste -sd, -)"
+ intrs_json="$(printf '%s\n' "${intrs[@]}" | sed 's/^/"/;s/$/"/' | paste -sd, -)"
+ local BODY
+ BODY=$(cat < "${TMP_DIR}/last_profile.json"
+}
+
+main() {
+ echo -e "${B}=== E2E smoke test start ===${NC}"
+ echo "BASE_URL: $BASE_URL"
+ echo
+
+ # 0) Wait for services
+ wait_health "gateway" "$BASE_URL/"
+ wait_health "auth" "$AUTH/health"
+ wait_health "profiles" "$PROFILES/health"
+ wait_health "match" "$MATCH/health"
+ wait_health "chat" "$CHAT/health"
+ wait_health "payments" "$PAYMENTS/health"
+
+ # 1) Register/login users
+ TS="$(date +%s)"
+ ADMIN_EMAIL="${ADMIN_EMAIL:-admin+$TS@agency.dev}"
+ ALICE_EMAIL="${ALICE_EMAIL:-alice+$TS@agency.dev}"
+ BOB_EMAIL="${BOB_EMAIL:-bob+$TS@agency.dev}"
+ PASS="${PASS:-secret123}"
+
+ log "Register/login admin: ${ADMIN_EMAIL}"
+ IFS='|' read -r ADMIN_ID ADMIN_ACCESS < <(register_or_login "$ADMIN_EMAIL" "$PASS" "Admin" "ADMIN")
+ ok "Admin id: $ADMIN_ID"
+
+ log "Register/login Alice: ${ALICE_EMAIL}"
+ IFS='|' read -r ALICE_ID ALICE_ACCESS < <(register_or_login "$ALICE_EMAIL" "$PASS" "Alice" "CLIENT")
+ ok "Alice id: $ALICE_ID"
+
+ log "Register/login Bob: ${BOB_EMAIL}"
+ IFS='|' read -r BOB_ID BOB_ACCESS < <(register_or_login "$BOB_EMAIL" "$PASS" "Bob" "CLIENT")
+ ok "Bob id: $BOB_ID"
+
+ # 2) Ensure profiles for all three
+ log "Ensure profile for Admin"
+ ensure_profile "$ADMIN_ACCESS" "other" "Moscow" "ru,en" "admin,ops"
+
+ log "Ensure profile for Alice"
+ ensure_profile "$ALICE_ACCESS" "female" "Moscow" "ru,en" "music,travel"
+
+ log "Ensure profile for Bob"
+ ensure_profile "$BOB_ACCESS" "male" "Moscow" "ru" "sports,reading"
+
+ # 3) Create match pair (admin)
+ log "Create match pair (Alice ↔ Bob)"
+ BODY=$(printf '{"user_id_a":"%s","user_id_b":"%s","score":%.2f,"notes":"e2e smoke"}' "$ALICE_ID" "$BOB_ID" 0.87)
+ PAIR="$(http_req POST "$MATCH/v1/pairs" "$ADMIN_ACCESS" "$BODY")"
+ PCODE="${PAIR%%|*}"; PRESP="${PAIR##*|}"
+ expect_code "$PCODE" "201|200" || fail "Create pair failed (${PCODE}): $(cat "$PRESP")"
+ PAIR_ID="$(json_get "$PRESP" "id")"
+ ok "Pair created: $PAIR_ID"
+
+ # 4) Create chat room and send a message (admin)
+ log "Create chat room (Admin + Alice + Bob)"
+ BODY=$(printf '{"title":"Alice & Bob","participants":["%s","%s"]}' "$ALICE_ID" "$BOB_ID")
+ ROOM="$(http_req POST "$CHAT/v1/rooms" "$ADMIN_ACCESS" "$BODY")"
+ RCODE="${ROOM%%|*}"; RRESP="${ROOM##*|}"
+ expect_code "$RCODE" "201|200" || fail "Create room failed (${RCODE}): $(cat "$RRESP")"
+ ROOM_ID="$(json_get "$RRESP" "id")"
+ ok "Room created: $ROOM_ID"
+
+ log "Send message to room"
+ BODY='{"content":"Hello from admin (e2e)"}'
+ MSG="$(http_req POST "$CHAT/v1/rooms/$ROOM_ID/messages" "$ADMIN_ACCESS" "$BODY")"
+ MCODE="${MSG%%|*}"; MRESP="${MSG##*|}"
+ expect_code "$MCODE" "201|200" || fail "Send message failed (${MCODE}): $(cat "$MRESP")"
+ MSG_ID="$(json_get "$MRESP" "id")"
+ ok "Message sent: $MSG_ID"
+
+ # 5) Create invoice for Alice and mark paid (admin)
+ log "Create invoice for Alice"
+ BODY=$(printf '{"client_id":"%s","amount":199.00,"currency":"USD","description":"Consultation (e2e)"}' "$ALICE_ID")
+ INV="$(http_req POST "$PAYMENTS/v1/invoices" "$ADMIN_ACCESS" "$BODY")"
+ INVCODE="${INV%%|*}"; INVRESP="${INV##*|}"
+ expect_code "$INVCODE" "201|200" || fail "Create invoice failed (${INVCODE}): $(cat "$INVRESP")"
+ INV_ID="$(json_get "$INVRESP" "id")"
+ ok "Invoice created: $INV_ID"
+
+ log "Mark invoice paid"
+ PAID="$(http_req POST "$PAYMENTS/v1/invoices/$INV_ID/mark-paid" "$ADMIN_ACCESS")"
+ PDCODE="${PAID%%|*}"; PDRESP="${PAID##*|}"
+ expect_code "$PDCODE" "200" || fail "Mark paid failed (${PDCODE}): $(cat "$PDRESP")"
+ STATUS="$(json_get "$PDRESP" "status")"
+ [[ "$STATUS" == "paid" ]] || fail "Invoice status not 'paid' (got '$STATUS')"
+ ok "Invoice marked paid"
+
+ echo
+ echo -e "${B}=== E2E summary ===${NC}"
+ echo -e "Admin: ${G}${ADMIN_EMAIL}${NC} (id: ${ADMIN_ID})"
+ echo -e "Alice: ${G}${ALICE_EMAIL}${NC} (id: ${ALICE_ID})"
+ echo -e "Bob: ${G}${BOB_EMAIL}${NC} (id: ${BOB_ID})"
+ echo -e "Pair: ${C}${PAIR_ID}${NC}"
+ echo -e "Room: ${C}${ROOM_ID}${NC} Message: ${C}${MSG_ID}${NC}"
+ echo -e "Invoice:${C}${INV_ID}${NC} Status: ${G}${STATUS}${NC}"
+ echo
+ ok "E2E smoke test finished successfully."
+}
+
+main "$@"
diff --git a/.history/scripts/e2e_20250808205905.sh b/.history/scripts/e2e_20250808205905.sh
new file mode 100644
index 0000000..a6a3626
--- /dev/null
+++ b/.history/scripts/e2e_20250808205905.sh
@@ -0,0 +1,284 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+
+# ------------------------------------------------------------
+# E2E smoke test for the matchmaking microservices (via gateway)
+# ------------------------------------------------------------
+
+BASE_URL="${BASE_URL:-http://localhost:8080}"
+AUTH="$BASE_URL/auth"
+PROFILES="$BASE_URL/profiles"
+MATCH="$BASE_URL/match"
+CHAT="$BASE_URL/chat"
+PAYMENTS="$BASE_URL/payments"
+
+# Где проверять доступность gateway (по умолчанию /auth/health).
+GW_HEALTH_PATH="${GW_HEALTH_PATH:-/auth/health}"
+
+# Colors
+NC='\033[0m'; B='\033[1m'; G='\033[0;32m'; Y='\033[0;33m'; R='\033[0;31m'; C='\033[0;36m'
+
+TMP_DIR="$(mktemp -d)"
+trap 'rm -rf "$TMP_DIR"' EXIT
+
+require() {
+ command -v "$1" >/dev/null 2>&1 || { echo -e "${R}ERROR:${NC} '$1' is required"; exit 1; }
+}
+require curl
+require python3
+
+log() { echo -e "${C}[$(date +%H:%M:%S)]${NC} $*"; }
+ok() { echo -e "${G}✔${NC} $*"; }
+warn(){ echo -e "${Y}⚠${NC} $*"; }
+fail(){ echo -e "${R}✖${NC} $*"; exit 1; }
+
+json_get() {
+ # json_get
+ python3 - "$1" "$2" <<'PY'
+import sys, json
+f, path = sys.argv[1], sys.argv[2]
+with open(f, 'r') as fh:
+ try:
+ data = json.load(fh)
+ except Exception:
+ print(""); sys.exit(0)
+cur = data
+for key in path.split('.'):
+ if isinstance(cur, list):
+ try:
+ key = int(key)
+ except:
+ print(""); sys.exit(0)
+ cur = cur[key] if 0 <= key < len(cur) else None
+ elif isinstance(cur, dict):
+ cur = cur.get(key)
+ else:
+ cur = None
+ if cur is None:
+ break
+print("" if cur is None else cur)
+PY
+}
+
+http_req() {
+ # http_req [] [] -> prints HTTP code; body to $RESP
+ local METHOD="$1"; shift
+ local URL="$1"; shift
+ local TOKEN="${1:-}"; shift || true
+ local BODY="${1:-}"; shift || true
+ local RESP="${TMP_DIR}/resp_$(date +%s%N).json"
+
+ local args=(-sS --max-time 10 -X "$METHOD" "$URL" -o "$RESP" -w "%{http_code}")
+ if [[ -n "$TOKEN" ]]; then args+=(-H "Authorization: Bearer $TOKEN"); fi
+ if [[ -n "$BODY" ]]; then args+=(-H "Content-Type: application/json" -d "$BODY"); fi
+
+ local CODE
+ CODE="$(curl "${args[@]}" || true)"
+ echo "$CODE|$RESP"
+}
+
+expect_code() {
+ # expect_code "" "||..."
+ local ACT="$1"; local ALLOWED="$2"
+ if [[ "$ALLOWED" == *"|${ACT}|"* || "$ALLOWED" == "${ACT}|"* || "$ALLOWED" == *"|${ACT}" || "$ALLOWED" == "${ACT}" ]]; then
+ return 0
+ fi
+ return 1
+}
+
+wait_http() {
+ # wait_http [|default 200] [|60]
+ local NAME="$1" URL="$2" ALLOWED="${3:-200}" TRIES="${4:-60}"
+ log "Waiting ${NAME} at ${URL} (allowed: ${ALLOWED})"
+ for ((i=1; i<=TRIES; i++)); do
+ local CODE
+ CODE="$(curl -s -o /dev/null -w "%{http_code}" "$URL" || true)"
+ if expect_code "$CODE" "$ALLOWED"; then
+ ok "${NAME} is ready (${CODE})"
+ return 0
+ fi
+ sleep 1
+ done
+ fail "${NAME} not ready in time: ${URL}"
+}
+
+wait_health() {
+ # wait_health [|60] (expects 200)
+ wait_http "$1" "$2" "200" "${3:-60}"
+}
+
+register_or_login() {
+ # register_or_login -> echoes "|"
+ local EMAIL="$1" PASS="$2" FULL="$3" ROLE="$4"
+
+ local BODY REG RESPCODE RESP
+ BODY=$(printf '{"email":"%s","password":"%s","full_name":"%s","role":"%s"}' "$EMAIL" "$PASS" "$FULL" "$ROLE")
+ REG="$(http_req POST "$AUTH/v1/register" "" "$BODY")"
+ RESPCODE="${REG%%|*}"; RESP="${REG##*|}"
+
+ if expect_code "$RESPCODE" "201|200"; then
+ ok "Registered user ${EMAIL}"
+ else
+ local MSG
+ MSG="$(json_get "$RESP" "detail")"
+ if [[ "$RESPCODE" == "400" && "$MSG" == "Email already in use" ]]; then
+ warn "User ${EMAIL} already exists, will login"
+ else
+ warn "Register response ($RESPCODE): $(cat "$RESP" 2>/dev/null || true)"
+ fi
+ fi
+
+ local TOK TOKCODE TOKRESP
+ BODY=$(printf '{"email":"%s","password":"%s"}' "$EMAIL" "$PASS")
+ TOK="$(http_req POST "$AUTH/v1/token" "" "$BODY")"
+ TOKCODE="${TOK%%|*}"; TOKRESP="${TOK##*|}"
+ expect_code "$TOKCODE" "200" || fail "Token failed (${TOKCODE}): $(cat "$TOKRESP")"
+
+ local ACCESS
+ ACCESS="$(json_get "$TOKRESP" "access_token")"
+ [[ -n "$ACCESS" ]] || fail "Empty access token for ${EMAIL}"
+
+ local ME MECODE MERESP UID
+ ME="$(http_req GET "$AUTH/v1/me" "$ACCESS")"
+ MECODE="${ME%%|*}"; MERESP="${ME##*|}"
+ expect_code "$MECODE" "200" || fail "/me failed for ${EMAIL} (${MECODE}): $(cat "$MERESP")"
+ UID="$(json_get "$MERESP" "id")"
+ [[ -n "$UID" ]] || fail "Failed to parse user id for ${EMAIL}"
+
+ echo "${UID}|${ACCESS}"
+}
+
+ensure_profile() {
+ # ensure_profile
+ local TOKEN="$1" G="$2" CITY="$3" LANGS_CSV="$4" INTERESTS_CSV="$5"
+
+ local ME MECODE MERESP
+ ME="$(http_req GET "$PROFILES/v1/profiles/me" "$TOKEN")"
+ MECODE="${ME%%|*}"; MERESP="${ME##*|}"
+ if [[ "$MECODE" == "200" ]]; then
+ ok "Profile already exists"
+ echo "$MERESP" > "${TMP_DIR}/last_profile.json"
+ return 0
+ elif [[ "$MECODE" != "404" ]]; then
+ warn "Unexpected /profiles/me code ${MECODE}: $(cat "$MERESP")"
+ fi
+
+ IFS=',' read -r -a langs <<< "$LANGS_CSV"
+ IFS=',' read -r -a intrs <<< "$INTERESTS_CSV"
+ local langs_json intrs_json
+ langs_json="$(printf '%s\n' "${langs[@]}" | sed 's/^/"/;s/$/"/' | paste -sd, -)"
+ intrs_json="$(printf '%s\n' "${intrs[@]}" | sed 's/^/"/;s/$/"/' | paste -sd, -)"
+
+ local BODY
+ BODY=$(cat < "${TMP_DIR}/last_profile.json"
+}
+
+main() {
+ echo -e "${B}=== E2E smoke test start ===${NC}"
+ echo "BASE_URL: $BASE_URL"
+ echo
+
+ # 0) Wait for gateway by checking proxied /auth/health (root / может отдавать 404/403)
+ wait_health "gateway" "${BASE_URL}${GW_HEALTH_PATH}"
+
+ # 1) Wait for services (через gateway)
+ wait_health "auth" "$AUTH/health"
+ wait_health "profiles" "$PROFILES/health"
+ wait_health "match" "$MATCH/health"
+ wait_health "chat" "$CHAT/health"
+ wait_health "payments" "$PAYMENTS/health"
+
+ # 2) Register/login users
+ TS="$(date +%s)"
+ ADMIN_EMAIL="${ADMIN_EMAIL:-admin+$TS@agency.dev}"
+ ALICE_EMAIL="${ALICE_EMAIL:-alice+$TS@agency.dev}"
+ BOB_EMAIL="${BOB_EMAIL:-bob+$TS@agency.dev}"
+ PASS="${PASS:-secret123}"
+
+ log "Register/login admin: ${ADMIN_EMAIL}"
+ IFS='|' read -r ADMIN_ID ADMIN_ACCESS < <(register_or_login "$ADMIN_EMAIL" "$PASS" "Admin" "ADMIN")
+ ok "Admin id: $ADMIN_ID"
+
+ log "Register/login Alice: ${ALICE_EMAIL}"
+ IFS='|' read -r ALICE_ID ALICE_ACCESS < <(register_or_login "$ALICE_EMAIL" "$PASS" "Alice" "CLIENT")
+ ok "Alice id: $ALICE_ID"
+
+ log "Register/login Bob: ${BOB_EMAIL}"
+ IFS='|' read -r BOB_ID BOB_ACCESS < <(register_or_login "$BOB_EMAIL" "$PASS" "Bob" "CLIENT")
+ ok "Bob id: $BOB_ID"
+
+ # 3) Ensure profiles
+ log "Ensure profile for Admin"
+ ensure_profile "$ADMIN_ACCESS" "other" "Moscow" "ru,en" "admin,ops"
+
+ log "Ensure profile for Alice"
+ ensure_profile "$ALICE_ACCESS" "female" "Moscow" "ru,en" "music,travel"
+
+ log "Ensure profile for Bob"
+ ensure_profile "$BOB_ACCESS" "male" "Moscow" "ru" "sports,reading"
+
+ # 4) Create match pair
+ log "Create match pair (Alice ↔ Bob)"
+ BODY=$(printf '{"user_id_a":"%s","user_id_b":"%s","score":%.2f,"notes":"e2e smoke"}' "$ALICE_ID" "$BOB_ID" 0.87)
+ PAIR="$(http_req POST "$MATCH/v1/pairs" "$ADMIN_ACCESS" "$BODY")"
+ PCODE="${PAIR%%|*}"; PRESP="${PAIR##*|}"
+ expect_code "$PCODE" "201|200" || fail "Create pair failed (${PCODE}): $(cat "$PRESP")"
+ PAIR_ID="$(json_get "$PRESP" "id")"
+ ok "Pair created: $PAIR_ID"
+
+ # 5) Create chat room and send a message
+ log "Create chat room (Admin + Alice + Bob)"
+ BODY=$(printf '{"title":"Alice & Bob","participants":["%s","%s"]}' "$ALICE_ID" "$BOB_ID")
+ ROOM="$(http_req POST "$CHAT/v1/rooms" "$ADMIN_ACCESS" "$BODY")"
+ RCODE="${ROOM%%|*}"; RRESP="${ROOM##*|}"
+ expect_code "$RCODE" "201|200" || fail "Create room failed (${RCODE}): $(cat "$RRESP")"
+ ROOM_ID="$(json_get "$RRESP" "id")"
+ ok "Room created: $ROOM_ID"
+
+ log "Send message to room"
+ BODY='{"content":"Hello from admin (e2e)"}'
+ MSG="$(http_req POST "$CHAT/v1/rooms/$ROOM_ID/messages" "$ADMIN_ACCESS" "$BODY")"
+ MCODE="${MSG%%|*}"; MRESP="${MSG##*|}"
+ expect_code "$MCODE" "201|200" || fail "Send message failed (${MCODE}): $(cat "$MRESP")"
+ MSG_ID="$(json_get "$MRESP" "id")"
+ ok "Message sent: $MSG_ID"
+
+ # 6) Create invoice for Alice and mark paid
+ log "Create invoice for Alice"
+ BODY=$(printf '{"client_id":"%s","amount":199.00,"currency":"USD","description":"Consultation (e2e)"}' "$ALICE_ID")
+ INV="$(http_req POST "$PAYMENTS/v1/invoices" "$ADMIN_ACCESS" "$BODY")"
+ INVCODE="${INV%%|*}"; INVRESP="${INV##*|}"
+ expect_code "$INVCODE" "201|200" || fail "Create invoice failed (${INVCODE}): $(cat "$INVRESP")"
+ INV_ID="$(json_get "$INVRESP" "id")"
+ ok "Invoice created: $INV_ID"
+
+ log "Mark invoice paid"
+ PAID="$(http_req POST "$PAYMENTS/v1/invoices/$INV_ID/mark-paid" "$ADMIN_ACCESS")"
+ PDCODE="${PAID%%|*}"; PDRESP="${PAID##*|}"
+ expect_code "$PDCODE" "200" || fail "Mark paid failed (${PDCODE}): $(cat "$PDRESP")"
+ STATUS="$(json_get "$PDRESP" "status")"
+ [[ "$STATUS" == "paid" ]] || fail "Invoice status not 'paid' (got '$STATUS')"
+ ok "Invoice marked paid"
+
+ echo
+ echo -e "${B}=== E2E summary ===${NC}"
+ echo -e "Admin: ${G}${ADMIN_EMAIL}${NC} (id: ${ADMIN_ID})"
+ echo -e "Alice: ${G}${ALICE_EMAIL}${NC} (id: ${ALICE_ID})"
+ echo -e "Bob: ${G}${BOB_EMAIL}${NC} (id: ${BOB_ID})"
+ echo -e "Pair: ${C}${PAIR_ID}${NC}"
+ echo -e "Room: ${C}${ROOM_ID}${NC} Message: ${C}${MSG_ID}${NC}"
+ echo -e "Invoice:${C}${INV_ID}${NC} Status: ${G}${STATUS}${NC}"
+ echo
+ ok "E2E smoke test finished successfully."
+}
+
+main "$@"
diff --git a/.history/scripts/e2e_20250808210443.sh b/.history/scripts/e2e_20250808210443.sh
new file mode 100644
index 0000000..8bb0f6e
--- /dev/null
+++ b/.history/scripts/e2e_20250808210443.sh
@@ -0,0 +1,289 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+
+# ------------------------------------------------------------
+# E2E smoke test for the matchmaking microservices (via gateway)
+# ------------------------------------------------------------
+
+BASE_URL="${BASE_URL:-http://localhost:8080}"
+AUTH="$BASE_URL/auth"
+PROFILES="$BASE_URL/profiles"
+MATCH="$BASE_URL/match"
+CHAT="$BASE_URL/chat"
+PAYMENTS="$BASE_URL/payments"
+
+# Где проверять gateway (root / часто не 200)
+GW_HEALTH_PATH="${GW_HEALTH_PATH:-/auth/health}"
+
+# Colors
+NC='\033[0m'; B='\033[1m'; G='\033[0;32m'; Y='\033[0;33m'; R='\033[0;31m'; C='\033[0;36m'
+
+TMP_DIR="$(mktemp -d)"
+cleanup() { rm -rf "$TMP_DIR"; }
+trap cleanup EXIT
+
+require() {
+ command -v "$1" >/dev/null 2>&1 || { echo -e "${R}ERROR:${NC} '$1' is required" >&2; exit 1; }
+}
+require curl
+require python3
+
+log() { echo -e "${C}[$(date +%H:%M:%S)]${NC} $*" >&2; }
+ok() { echo -e "${G}✔${NC} $*" >&2; }
+warn() { echo -e "${Y}⚠${NC} $*" >&2; }
+fail() { echo -e "${R}✖${NC} $*" >&2; exit 1; }
+
+json_get() {
+ # json_get
+ python3 - "$1" "$2" <<'PY'
+import sys, json, os
+f, path = sys.argv[1], sys.argv[2]
+if not os.path.exists(f):
+ print(""); sys.exit(0)
+with open(f, 'r') as fh:
+ try:
+ data = json.load(fh)
+ except Exception:
+ print(""); sys.exit(0)
+cur = data
+for key in path.split('.'):
+ if isinstance(cur, list):
+ try:
+ key = int(key)
+ except:
+ print(""); sys.exit(0)
+ cur = cur[key] if 0 <= key < len(cur) else None
+ elif isinstance(cur, dict):
+ cur = cur.get(key)
+ else:
+ cur = None
+ if cur is None:
+ break
+print("" if cur is None else cur)
+PY
+}
+
+http_req() {
+ # http_req [] [] -> prints "HTTP_CODE|/path/to/body.json"
+ local METHOD="$1"; shift
+ local URL="$1"; shift
+ local TOKEN="${1:-}"; shift || true
+ local BODY="${1:-}"; shift || true
+ local RESP="${TMP_DIR}/resp_$(date +%s%N).json"
+
+ local args=(-sS --connect-timeout 5 --max-time 10 -X "$METHOD" "$URL" -o "$RESP" -w "%{http_code}")
+ if [[ -n "$TOKEN" ]]; then args+=(-H "Authorization: Bearer $TOKEN"); fi
+ if [[ -n "$BODY" ]]; then args+=(-H "Content-Type: application/json" -d "$BODY"); fi
+
+ local CODE
+ CODE="$(curl "${args[@]}" || true)"
+ [[ -e "$RESP" ]] || : > "$RESP" # гарантируем наличие файла
+ echo "$CODE|$RESP"
+}
+
+expect_code() {
+ # expect_code "" "||..."
+ local ACT="$1"; local ALLOWED="$2"
+ if [[ "$ALLOWED" == *"|${ACT}|"* || "$ALLOWED" == "${ACT}|"* || "$ALLOWED" == *"|${ACT}" || "$ALLOWED" == "${ACT}" ]]; then
+ return 0
+ fi
+ return 1
+}
+
+wait_http() {
+ # wait_http [|default 200] [|60]
+ local NAME="$1" URL="$2" ALLOWED="${3:-200}" TRIES="${4:-60}"
+ log "Waiting ${NAME} at ${URL} (allowed: ${ALLOWED})"
+ for ((i=1; i<=TRIES; i++)); do
+ local CODE
+ CODE="$(curl -s -o /dev/null -w "%{http_code}" "$URL" || true)"
+ if expect_code "$CODE" "$ALLOWED"; then
+ ok "${NAME} is ready (${CODE})"
+ return 0
+ fi
+ sleep 1
+ done
+ fail "${NAME} not ready in time: ${URL}"
+}
+
+wait_health() { wait_http "$1" "$2" "200" "${3:-60}"; }
+
+register_or_login() {
+ # register_or_login -> echoes "|"
+ local EMAIL="$1" PASS="$2" FULL="$3" ROLE="$4"
+
+ # try register (не фатально, даже если 500/409 — дальше попытаемся получить токен)
+ local BODY REG RESPCODE RESP
+ BODY=$(printf '{"email":"%s","password":"%s","full_name":"%s","role":"%s"}' "$EMAIL" "$PASS" "$FULL" "$ROLE")
+ REG="$(http_req POST "$AUTH/v1/register" "" "$BODY")"
+ RESPCODE="${REG%%|*}"; RESP="${REG##*|}"
+ if expect_code "$RESPCODE" "201|200"; then
+ ok "Registered user ${EMAIL}"
+ else
+ local MSG; MSG="$(json_get "$RESP" "detail")"
+ if [[ "$RESPCODE" == "400" && "$MSG" == "Email already in use" ]]; then
+ warn "User ${EMAIL} already exists, will login"
+ else
+ warn "Register response ($RESPCODE): $(cat "$RESP" 2>/dev/null || true)"
+ fi
+ fi
+
+ # get token (обязательно)
+ local TOK TOKCODE TOKRESP
+ BODY=$(printf '{"email":"%s","password":"%s"}' "$EMAIL" "$PASS")
+ TOK="$(http_req POST "$AUTH/v1/token" "" "$BODY")"
+ TOKCODE="${TOK%%|*}"; TOKRESP="${TOK##*|}"
+ expect_code "$TOKCODE" "200" || fail "Token failed (${TOKCODE}): $(cat "$TOKRESP")"
+
+ local ACCESS
+ ACCESS="$(json_get "$TOKRESP" "access_token")"
+ [[ -n "$ACCESS" ]] || fail "Empty access token for ${EMAIL}"
+
+ # resolve user id via /me
+ local ME MECODE MERESP USER_ID
+ ME="$(http_req GET "$AUTH/v1/me" "$ACCESS")"
+ MECODE="${ME%%|*}"; MERESP="${ME##*|}"
+ expect_code "$MECODE" "200" || fail "/me failed for ${EMAIL} (${MECODE}): $(cat "$MERESP")"
+ USER_ID="$(json_get "$MERESP" "id")"
+ [[ -n "$USER_ID" ]] || fail "Failed to parse user id for ${EMAIL}"
+
+ # ВНИМАНИЕ: в stdout только данные!
+ echo "${USER_ID}|${ACCESS}"
+}
+
+ensure_profile() {
+ # ensure_profile
+ local TOKEN="$1" G="$2" CITY="$3" LANGS_CSV="$4" INTERESTS_CSV="$5"
+ [[ -n "$TOKEN" ]] || fail "Empty token passed to ensure_profile"
+
+ local ME MECODE MERESP
+ ME="$(http_req GET "$PROFILES/v1/profiles/me" "$TOKEN")"
+ MECODE="${ME%%|*}"; MERESP="${ME##*|}"
+ if [[ "$MECODE" == "200" ]]; then
+ ok "Profile already exists"
+ echo "$MERESP" > "${TMP_DIR}/last_profile.json"
+ return 0
+ elif [[ "$MECODE" != "404" ]]; then
+ warn "Unexpected /profiles/me code ${MECODE}: $(cat "$MERESP")"
+ fi
+
+ IFS=',' read -r -a langs <<< "$LANGS_CSV"
+ IFS=',' read -r -a intrs <<< "$INTERESTS_CSV"
+ local langs_json intrs_json
+ langs_json="$(printf '%s\n' "${langs[@]}" | sed 's/^/"/;s/$/"/' | paste -sd, -)"
+ intrs_json="$(printf '%s\n' "${intrs[@]}" | sed 's/^/"/;s/$/"/' | paste -sd, -)"
+
+ local BODY
+ BODY=$(cat < "${TMP_DIR}/last_profile.json"
+}
+
+main() {
+ echo -e "${B}=== E2E smoke test start ===${NC}" >&2
+ echo "BASE_URL: $BASE_URL" >&2
+ echo >&2
+
+ # 0) Gateway health via proxied /auth/health
+ wait_health "gateway" "${BASE_URL}${GW_HEALTH_PATH}"
+
+ # 1) Service health (via gateway)
+ wait_health "auth" "$AUTH/health"
+ wait_health "profiles" "$PROFILES/health"
+ wait_health "match" "$MATCH/health"
+ wait_health "chat" "$CHAT/health"
+ wait_health "payments" "$PAYMENTS/health"
+
+ # 2) Register/login users
+ TS="$(date +%s)"
+ ADMIN_EMAIL="${ADMIN_EMAIL:-admin+$TS@agency.dev}"
+ ALICE_EMAIL="${ALICE_EMAIL:-alice+$TS@agency.dev}"
+ BOB_EMAIL="${BOB_EMAIL:-bob+$TS@agency.dev}"
+ PASS="${PASS:-secret123}"
+
+ log "Register/login admin: ${ADMIN_EMAIL}"
+ IFS='|' read -r ADMIN_ID ADMIN_ACCESS < <(register_or_login "$ADMIN_EMAIL" "$PASS" "Admin" "ADMIN")
+ ok "Admin id: $ADMIN_ID"
+
+ log "Register/login Alice: ${ALICE_EMAIL}"
+ IFS='|' read -r ALICE_ID ALICE_ACCESS < <(register_or_login "$ALICE_EMAIL" "$PASS" "Alice" "CLIENT")
+ ok "Alice id: $ALICE_ID"
+
+ log "Register/login Bob: ${BOB_EMAIL}"
+ IFS='|' read -r BOB_ID BOB_ACCESS < <(register_or_login "$BOB_EMAIL" "$PASS" "Bob" "CLIENT")
+ ok "Bob id: $BOB_ID"
+
+ # 3) Ensure profiles
+ log "Ensure profile for Admin"
+ ensure_profile "$ADMIN_ACCESS" "other" "Moscow" "ru,en" "admin,ops"
+
+ log "Ensure profile for Alice"
+ ensure_profile "$ALICE_ACCESS" "female" "Moscow" "ru,en" "music,travel"
+
+ log "Ensure profile for Bob"
+ ensure_profile "$BOB_ACCESS" "male" "Moscow" "ru" "sports,reading"
+
+ # 4) Create match pair
+ log "Create match pair (Alice ↔ Bob)"
+ BODY=$(printf '{"user_id_a":"%s","user_id_b":"%s","score":%.2f,"notes":"e2e smoke"}' "$ALICE_ID" "$BOB_ID" 0.87)
+ PAIR="$(http_req POST "$MATCH/v1/pairs" "$ADMIN_ACCESS" "$BODY")"
+ PCODE="${PAIR%%|*}"; PRESP="${PAIR##*|}"
+ expect_code "$PCODE" "201|200" || fail "Create pair failed (${PCODE}): $(cat "$PRESP")"
+ PAIR_ID="$(json_get "$PRESP" "id")"
+ ok "Pair created: $PAIR_ID"
+
+ # 5) Create chat room and send a message
+ log "Create chat room (Admin + Alice + Bob)"
+ BODY=$(printf '{"title":"Alice & Bob","participants":["%s","%s"]}' "$ALICE_ID" "$BOB_ID")
+ ROOM="$(http_req POST "$CHAT/v1/rooms" "$ADMIN_ACCESS" "$BODY")"
+ RCODE="${ROOM%%|*}"; RRESP="${ROOM##*|}"
+ expect_code "$RCODE" "201|200" || fail "Create room failed (${RCODE}): $(cat "$RRESP")"
+ ROOM_ID="$(json_get "$RRESP" "id")"
+ ok "Room created: $ROOM_ID"
+
+ log "Send message to room"
+ BODY='{"content":"Hello from admin (e2e)"}'
+ MSG="$(http_req POST "$CHAT/v1/rooms/$ROOM_ID/messages" "$ADMIN_ACCESS" "$BODY")"
+ MCODE="${MSG%%|*}"; MRESP="${MSG##*|}"
+ expect_code "$MCODE" "201|200" || fail "Send message failed (${MCODE}): $(cat "$MRESP")"
+ MSG_ID="$(json_get "$MRESP" "id")"
+ ok "Message sent: $MSG_ID"
+
+ # 6) Create invoice for Alice and mark paid
+ log "Create invoice for Alice"
+ BODY=$(printf '{"client_id":"%s","amount":199.00,"currency":"USD","description":"Consultation (e2e)"}' "$ALICE_ID")
+ INV="$(http_req POST "$PAYMENTS/v1/invoices" "$ADMIN_ACCESS" "$BODY")"
+ INVCODE="${INV%%|*}"; INVRESP="${INV##*|}"
+ expect_code "$INVCODE" "201|200" || fail "Create invoice failed (${INVCODE}): $(cat "$INVRESP")"
+ INV_ID="$(json_get "$INVRESP" "id")"
+ ok "Invoice created: $INV_ID"
+
+ log "Mark invoice paid"
+ PAID="$(http_req POST "$PAYMENTS/v1/invoices/$INV_ID/mark-paid" "$ADMIN_ACCESS")"
+ PDCODE="${PAID%%|*}"; PDRESP="${PAID##*|}"
+ expect_code "$PDCODE" "200" || fail "Mark paid failed (${PDCODE}): $(cat "$PDRESP")"
+ STATUS="$(json_get "$PDRESP" "status")"
+ [[ "$STATUS" == "paid" ]] || fail "Invoice status not 'paid' (got '$STATUS')"
+ ok "Invoice marked paid"
+
+ {
+ echo "=== E2E summary ==="
+ echo "Admin: ${ADMIN_EMAIL} (id: ${ADMIN_ID})"
+ echo "Alice: ${ALICE_EMAIL} (id: ${ALICE_ID})"
+ echo "Bob: ${BOB_EMAIL} (id: ${BOB_ID})"
+ echo "Pair: ${PAIR_ID}"
+ echo "Room: ${ROOM_ID} Message: ${MSG_ID}"
+ echo "Invoice:${INV_ID} Status: ${STATUS}"
+ } >&2
+
+ ok "E2E smoke test finished successfully."
+}
+
+main "$@"
diff --git a/.history/scripts/e2e_20250808211132.sh b/.history/scripts/e2e_20250808211132.sh
new file mode 100644
index 0000000..85999b4
--- /dev/null
+++ b/.history/scripts/e2e_20250808211132.sh
@@ -0,0 +1,208 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+
+BASE_URL="${BASE_URL:-http://localhost:8080}"
+AUTH="$BASE_URL/auth"; PROFILES="$BASE_URL/profiles"; MATCH="$BASE_URL/match"; CHAT="$BASE_URL/chat"; PAYMENTS="$BASE_URL/payments"
+GW_HEALTH_PATH="${GW_HEALTH_PATH:-/auth/health}"
+
+NC='\033[0m'; B='\033[1m'; G='\033[0;32m'; Y='\033[0;33m'; R='\033[0;31m'; C='\033[0;36m'
+TMP_DIR="$(mktemp -d)"
+trap 'rm -rf "$TMP_DIR"' EXIT
+
+require(){ command -v "$1" >/dev/null 2>&1 || { echo -e "${R}ERROR:${NC} '$1' is required" >&2; exit 1; }; }
+require curl; require python3
+log(){ echo -e "${C}[$(date +%H:%M:%S)]${NC} $*" >&2; }
+ok(){ echo -e "${G}✔${NC} $*" >&2; }
+warn(){ echo -e "${Y}⚠${NC} $*" >&2; }
+fail(){ echo -e "${R}✖${NC} $*" >&2; exit 1; }
+
+json_get(){ python3 - "$1" "$2" <<'PY'
+import sys, json, os
+f,p=sys.argv[1],sys.argv[2]
+if not os.path.exists(f): print(""); sys.exit(0)
+try: data=json.load(open(f))
+except: print(""); sys.exit(0)
+cur=data
+for k in p.split('.'):
+ if isinstance(cur,list):
+ try:k=int(k)
+ except: print(""); sys.exit(0)
+ cur=cur[k] if 0<=k
+python3 - "$1" "$2" <<'PY'
+import sys, json, base64
+t, claim = sys.argv[1], sys.argv[2]
+try:
+ b = t.split('.')[1]
+ b += '=' * (-len(b) % 4)
+ payload = json.loads(base64.urlsafe_b64decode(b).decode())
+ print(payload.get(claim,""))
+except Exception:
+ print("")
+PY
+}
+
+http_req(){
+ local METHOD="$1"; shift; local URL="$1"; shift
+ local TOKEN="${1:-}"; shift || true
+ local BODY="${1:-}"; shift || true
+ local RESP="${TMP_DIR}/resp_$(date +%s%N).json"
+ local args=(-sS --connect-timeout 5 --max-time 10 -X "$METHOD" "$URL" -o "$RESP" -w "%{http_code}")
+ [[ -n "$TOKEN" ]] && args+=(-H "Authorization: Bearer $TOKEN")
+ [[ -n "$BODY" ]] && args+=(-H "Content-Type: application/json" -d "$BODY")
+ local CODE; CODE="$(curl "${args[@]}" || true)"
+ [[ -e "$RESP" ]] || : > "$RESP"
+ echo "$CODE|$RESP"
+}
+
+expect_code(){ [[ "$2" == *"|${1}|"* || "$2" == "${1}|"* || "$2" == *"|${1}" || "$2" == "${1}" ]]; }
+
+wait_http(){
+ local NAME="$1" URL="$2" ALLOWED="${3:-200}" TRIES="${4:-60}"
+ log "Waiting ${NAME} at ${URL} (allowed: ${ALLOWED})"
+ for((i=1;i<=TRIES;i++)); do
+ local CODE; CODE="$(curl -s -o /dev/null -w "%{http_code}" "$URL" || true)"
+ if expect_code "$CODE" "$ALLOWED"; then ok "${NAME} is ready (${CODE})"; return 0; fi
+ sleep 1
+ done; fail "${NAME} not ready in time: ${URL}"
+}
+wait_health(){ wait_http "$1" "$2" "200" "${3:-60}"; }
+
+login_or_register(){ # echo "|"
+ local EMAIL="$1" PASS="$2" FULL="$3" ROLE="$4"
+ local BODY TOK TOKCODE TOKRESP ACCESS USER_ID
+
+ # 1) пытаемся логиниться
+ BODY=$(printf '{"email":"%s","password":"%s"}' "$EMAIL" "$PASS")
+ TOK="$(http_req POST "$AUTH/v1/token" "" "$BODY")"; TOKCODE="${TOK%%|*}"; TOKRESP="${TOK##*|}"
+ if expect_code "$TOKCODE" "200"; then
+ ACCESS="$(json_get "$TOKRESP" "access_token")"
+ USER_ID="$(jwt_get "$ACCESS" sub)"
+ [[ -n "$ACCESS" && -n "$USER_ID" ]] || fail "Login parse failed for $EMAIL"
+ ok "Login ok for $EMAIL"
+ echo "${USER_ID}|${ACCESS}"; return 0
+ fi
+ warn "Login failed for $EMAIL ($TOKCODE) → will register"
+
+ # 2) регистрируем
+ BODY=$(printf '{"email":"%s","password":"%s","full_name":"%s","role":"%s"}' "$EMAIL" "$PASS" "$FULL" "$ROLE")
+ local REG RESPCODE RESP; REG="$(http_req POST "$AUTH/v1/register" "" "$BODY")"
+ RESPCODE="${REG%%|*}"; RESP="${REG##*|}"
+ if expect_code "$RESPCODE" "201|200"; then
+ ok "Registered $EMAIL"
+ else
+ local MSG; MSG="$(json_get "$RESP" "detail")"
+ if [[ "$RESPCODE" == "400" && "$MSG" == "Email already in use" ]]; then
+ warn "Already exists: $EMAIL"
+ else
+ warn "Register response ($RESPCODE): $(cat "$RESP" 2>/dev/null || true)"
+ fi
+ fi
+
+ # 3) снова логин
+ TOK="$(http_req POST "$AUTH/v1/token" "" "$BODY")"; TOKCODE="${TOK%%|*}"; TOKRESP="${TOK##*|}"
+ expect_code "$TOKCODE" "200" || fail "Token failed (${TOKCODE}): $(cat "$TOKRESP")"
+ ACCESS="$(json_get "$TOKRESP" "access_token")"
+ USER_ID="$(jwt_get "$ACCESS" sub)"
+ [[ -n "$ACCESS" && -n "$USER_ID" ]] || fail "Login parse failed after register for $EMAIL"
+ echo "${USER_ID}|${ACCESS}"
+}
+
+ensure_profile(){ #
+ local TOKEN="$1" G="$2" CITY="$3" LANGS="$4" INTRS="$5"
+ [[ -n "$TOKEN" ]] || fail "Empty token in ensure_profile"
+
+ local ME MECODE MERESP; ME="$(http_req GET "$PROFILES/v1/profiles/me" "$TOKEN")"
+ MECODE="${ME%%|*}"; MERESP="${ME##*|}"
+ if [[ "$MECODE" == "200" ]]; then ok "Profile exists"; return 0
+ elif [[ "$MECODE" != "404" ]]; then warn "Unexpected /profiles/me $MECODE: $(cat "$MERESP")"; fi
+
+ local lj ij; IFS=',' read -r -a _l <<< "$LANGS"; lj="$(printf '%s\n' "${_l[@]}"|sed 's/^/"/;s/$/"/'|paste -sd, -)"
+ IFS=',' read -r -a _i <<< "$INTRS"; ij="$(printf '%s\n' "${_i[@]}"|sed 's/^/"/;s/$/"/'|paste -sd, -)"
+ local BODY; BODY=$(cat <&2
+ echo "BASE_URL: $BASE_URL" >&2; echo >&2
+
+ wait_health "gateway" "${BASE_URL}${GW_HEALTH_PATH}"
+ wait_health "auth" "$AUTH/health"; wait_health "profiles" "$PROFILES/health"
+ wait_health "match" "$MATCH/health"; wait_health "chat" "$CHAT/health"; wait_health "payments" "$PAYMENTS/health"
+
+ TS="$(date +%s)"
+ ADMIN_EMAIL="${ADMIN_EMAIL:-admin+$TS@agency.dev}"
+ ALICE_EMAIL="${ALICE_EMAIL:-alice+$TS@agency.dev}"
+ BOB_EMAIL="${BOB_EMAIL:-bob+$TS@agency.dev}"
+ PASS="${PASS:-secret123}"
+
+ log "Admin: ${ADMIN_EMAIL}"
+ IFS='|' read -r ADMIN_ID ADMIN_ACCESS < <(login_or_register "$ADMIN_EMAIL" "$PASS" "Admin" "ADMIN"); ok "Admin id: $ADMIN_ID"
+
+ log "Alice: ${ALICE_EMAIL}"
+ IFS='|' read -r ALICE_ID ALICE_ACCESS < <(login_or_register "$ALICE_EMAIL" "$PASS" "Alice" "CLIENT"); ok "Alice id: $ALICE_ID"
+
+ log "Bob: ${BOB_EMAIL}"
+ IFS='|' read -r BOB_ID BOB_ACCESS < <(login_or_register "$BOB_EMAIL" "$PASS" "Bob" "CLIENT"); ok "Bob id: $BOB_ID"
+
+ log "Profiles"
+ ensure_profile "$ADMIN_ACCESS" "other" "Moscow" "ru,en" "admin,ops"
+ ensure_profile "$ALICE_ACCESS" "female" "Moscow" "ru,en" "music,travel"
+ ensure_profile "$BOB_ACCESS" "male" "Moscow" "ru" "sports,reading"
+
+ log "Match Alice ↔ Bob"
+ BODY=$(printf '{"user_id_a":"%s","user_id_b":"%s","score":%.2f,"notes":"e2e smoke"}' "$ALICE_ID" "$BOB_ID" 0.87)
+ PAIR="$(http_req POST "$MATCH/v1/pairs" "$ADMIN_ACCESS" "$BODY")"; PCODE="${PAIR%%|*}"; PRESP="${PAIR##*|}"
+ expect_code "$PCODE" "201|200" || fail "Create pair failed (${PCODE}): $(cat "$PRESP")"
+ PAIR_ID="$(json_get "$PRESP" "id")"; ok "Pair: $PAIR_ID"
+
+ log "Chat"
+ BODY=$(printf '{"title":"Alice & Bob","participants":["%s","%s"]}' "$ALICE_ID" "$BOB_ID")
+ ROOM="$(http_req POST "$CHAT/v1/rooms" "$ADMIN_ACCESS" "$BODY")"; RCODE="${ROOM%%|*}"; RRESP="${ROOM##*|}"
+ expect_code "$RCODE" "201|200" || fail "Create room failed (${RCODE}): $(cat "$RRESP")"
+ ROOM_ID="$(json_get "$RRESP" "id")"; ok "Room: $ROOM_ID"
+
+ BODY='{"content":"Hello from admin (e2e)"}'
+ MSG="$(http_req POST "$CHAT/v1/rooms/$ROOM_ID/messages" "$ADMIN_ACCESS" "$BODY")"; MCODE="${MSG%%|*}"; MRESP="${MSG##*|}"
+ expect_code "$MCODE" "201|200" || fail "Send message failed (${MCODE}): $(cat "$MRESP")"
+ MSG_ID="$(json_get "$MRESP" "id")"; ok "Message: $MSG_ID"
+
+ log "Payments"
+ BODY=$(printf '{"client_id":"%s","amount":199.00,"currency":"USD","description":"Consultation (e2e)"}' "$ALICE_ID")
+ INV="$(http_req POST "$PAYMENTS/v1/invoices" "$ADMIN_ACCESS" "$BODY")"; INVCODE="${INV%%|*}"; INVRESP="${INV##*|}"
+ expect_code "$INVCODE" "201|200" || fail "Create invoice failed (${INVCODE}): $(cat "$INVRESP")"
+ INV_ID="$(json_get "$INVRESP" "id")"; ok "Invoice: $INV_ID"
+
+ PAID="$(http_req POST "$PAYMENTS/v1/invoices/$INV_ID/mark-paid" "$ADMIN_ACCESS")"; PDCODE="${PAID%%|*}"; PDRESP="${PAID##*|}"
+ expect_code "$PDCODE" "200" || fail "Mark paid failed (${PDCODE}): $(cat "$PDRESP")"
+ STATUS="$(json_get "$PDRESP" "status")"; [[ "$STATUS" == "paid" ]] || fail "Invoice not paid"
+ ok "Invoice status: $STATUS"
+
+ {
+ echo "=== E2E summary ==="
+ echo "Admin: ${ADMIN_EMAIL} (id: ${ADMIN_ID})"
+ echo "Alice: ${ALICE_EMAIL} (id: ${ALICE_ID})"
+ echo "Bob: ${BOB_EMAIL} (id: ${BOB_ID})"
+ echo "Pair: ${PAIR_ID}"
+ echo "Room: ${ROOM_ID} Message: ${MSG_ID}"
+ echo "Invoice:${INV_ID} Status: ${STATUS}"
+ } >&2
+
+ ok "E2E smoke test finished successfully."
+}
+main "$@"
diff --git a/.history/scripts/fix_email_validation_20250808211220.sh b/.history/scripts/fix_email_validation_20250808211220.sh
new file mode 100644
index 0000000..e69de29
diff --git a/.history/scripts/fix_email_validation_20250808211222.sh b/.history/scripts/fix_email_validation_20250808211222.sh
new file mode 100644
index 0000000..2cb5f68
--- /dev/null
+++ b/.history/scripts/fix_email_validation_20250808211222.sh
@@ -0,0 +1,18 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+FILE="services/auth/src/app/schemas/user.py"
+[ -f "$FILE" ] || { echo "Not found: $FILE"; exit 1; }
+
+tmp="$(mktemp)"
+awk '
+ BEGIN{incls=""}
+ /^class (UserRead|UserPublic|UserOut|UserResponse)\b/ {incls=$1}
+ incls!="" && /email: *EmailStr/ { sub(/EmailStr/, "str") }
+ /^class [A-Za-z_0-9]+\b/ && $2!=incls { incls="" }
+ { print }
+' "$FILE" > "$tmp" && mv "$tmp" "$FILE"
+
+echo "[auth] rebuilding..."
+docker compose build auth
+docker compose restart auth
\ No newline at end of file
diff --git a/.history/scripts/migrate_20250808200714.sh b/.history/scripts/migrate_20250808200714.sh
new file mode 100644
index 0000000..e99b378
--- /dev/null
+++ b/.history/scripts/migrate_20250808200714.sh
@@ -0,0 +1,10 @@
+for s in auth profiles match chat payments; do
+ f="services/$s/alembic/env.py"
+ # добавим импорт пакета моделей, если его нет
+ grep -q "from app import models" "$f" || \
+ sed -i 's/from app.db.session import Base # noqa/from app.db.session import Base # noqa\nfrom app import models # noqa: F401/' "$f"
+done
+
+for s in auth profiles match chat payments; do
+ docker compose run --rm $s alembic revision --autogenerate -m "init"
+done
diff --git a/.history/scripts/migrate_20250808214443.sh b/.history/scripts/migrate_20250808214443.sh
new file mode 100644
index 0000000..feb04f8
--- /dev/null
+++ b/.history/scripts/migrate_20250808214443.sh
@@ -0,0 +1,6 @@
+#!/usr/bin/env bash
+for s in auth profiles match chat payments; do
+ docker compose run --rm $s alembic revision --autogenerate -m "init"
+ docker compose run --rm $s alembic upgrade head
+
+done
diff --git a/.history/scripts/patch_20250808204341.sh b/.history/scripts/patch_20250808204341.sh
new file mode 100644
index 0000000..4c1b211
--- /dev/null
+++ b/.history/scripts/patch_20250808204341.sh
@@ -0,0 +1,68 @@
+# Сохраняем фиксер
+cat > fix_profiles_fk.sh <<'BASH'
+#!/usr/bin/env bash
+set -euo pipefail
+
+# 1) Обновим модель Photo: добавим ForeignKey + нормальную relationship
+cat > services/profiles/src/app/models/photo.py <<'PY'
+from __future__ import annotations
+import uuid
+from datetime import datetime
+
+from sqlalchemy import String, Boolean, DateTime, ForeignKey
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+from sqlalchemy.sql import func
+
+from app.db.session import Base
+
+class Photo(Base):
+ __tablename__ = "photos"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ profile_id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True),
+ ForeignKey("profiles.id", ondelete="CASCADE"),
+ index=True,
+ nullable=False,
+ )
+ url: Mapped[str] = mapped_column(String(500), nullable=False)
+ is_main: Mapped[bool] = mapped_column(Boolean, default=False)
+ status: Mapped[str] = mapped_column(String(16), default="pending") # pending/approved/rejected
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+
+ profile = relationship("Profile", back_populates="photos")
+PY
+
+# (необязательно, но полезно) поправим типы JSONB в Profile
+awk '
+ {print}
+ /languages:/ && $0 ~ /dict/ { sub(/dict \| None/, "list[str] | None"); print "# (autofixed: changed languages type to list[str])"}
+ /interests:/ && $0 ~ /dict/ { sub(/dict \| None/, "list[str] | None"); print "# (autofixed: changed interests type to list[str])"}
+' services/profiles/src/app/models/profile.py > services/profiles/src/app/models/profile.py.tmp \
+ && mv services/profiles/src/app/models/profile.py.tmp services/profiles/src/app/models/profile.py || true
+
+# 2) Сгенерируем ревизию Alembic (сравнить модели с БД)
+docker compose up -d postgres
+docker compose run --rm -v "$PWD/services/profiles":/app profiles \
+ sh -lc 'alembic revision --autogenerate -m "add FK photos.profile_id -> profiles.id"'
+
+# 3) Если автогенерация не добавила FK — вживлём вручную в последнюю ревизию
+LAST=$(ls -1t services/profiles/alembic/versions/*.py | head -n1)
+if ! grep -q "create_foreign_key" "$LAST"; then
+ # вставим импорт postgresql (на будущее) и create_foreign_key в upgrade()
+ sed -i '/import sqlalchemy as sa/a from sqlalchemy.dialects import postgresql' "$LAST"
+ awk '
+ BEGIN{done=0}
+ /def upgrade/ && done==0 {print; print " op.create_foreign_key("; print " '\''fk_photos_profile_id_profiles'\'',"; print " '\''photos'\'', '\''profiles'\'',"; print " ['\''profile_id'\''], ['\''id'\''],"; print " ondelete='\''CASCADE'\''"; print " )"; done=1; next}
+ {print}
+ ' "$LAST" > "$LAST.tmp" && mv "$LAST.tmp" "$LAST"
+fi
+
+# 4) Применим миграции и перезапустим сервис
+docker compose run --rm profiles alembic upgrade head
+docker compose restart profiles
+BASH
+
+chmod +x fix_profiles_fk.sh
+./fix_profiles_fk.sh
diff --git a/.history/scripts/patch_20250808211820.sh b/.history/scripts/patch_20250808211820.sh
new file mode 100644
index 0000000..63c15ec
--- /dev/null
+++ b/.history/scripts/patch_20250808211820.sh
@@ -0,0 +1,123 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# 1) Репозиторий: приводить user_id к uuid.UUID
+cat > services/profiles/src/app/repositories/profile_repository.py <<'PY'
+import uuid
+from typing import Optional
+
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from app.models.profile import Profile
+from app.schemas.profile import ProfileCreate
+
+class ProfileRepository:
+ def __init__(self, db: Session):
+ self.db = db
+
+ @staticmethod
+ def _to_uuid(v) -> uuid.UUID:
+ if isinstance(v, uuid.UUID):
+ return v
+ return uuid.UUID(str(v))
+
+ def get_by_user(self, user_id) -> Optional[Profile]:
+ uid = self._to_uuid(user_id)
+ stmt = select(Profile).where(Profile.user_id == uid)
+ return self.db.execute(stmt).scalar_one_or_none()
+
+ def create(self, user_id, obj: ProfileCreate) -> Profile:
+ uid = self._to_uuid(user_id)
+ p = Profile(
+ user_id=uid,
+ gender=obj.gender,
+ city=obj.city,
+ languages=obj.languages or [],
+ interests=obj.interests or [],
+ )
+ self.db.add(p)
+ self.db.commit()
+ self.db.refresh(p)
+ return p
+PY
+
+# 2) Схемы: дефолты - пустые списки (чтобы не было None → JSONB)
+cat > services/profiles/src/app/schemas/profile.py <<'PY'
+from __future__ import annotations
+from typing import Optional, List
+from pydantic import BaseModel, Field
+
+class ProfileBase(BaseModel):
+ gender: str
+ city: str
+ languages: List[str] = Field(default_factory=list)
+ interests: List[str] = Field(default_factory=list)
+
+class ProfileCreate(ProfileBase):
+ pass
+
+class ProfileOut(ProfileBase):
+ id: str
+ user_id: str
+
+ class Config:
+ from_attributes = True
+PY
+
+# 3) Роут: ловим ошибки явно → 400 вместо 500
+cat > services/profiles/src/app/api/routes/profiles.py <<'PY'
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy.orm import Session
+from sqlalchemy.exc import IntegrityError, DataError
+
+from app.db.deps import get_db
+from app.schemas.profile import ProfileCreate, ProfileOut
+from app.services.profile_service import ProfileService
+from app.core.security import get_current_user # возвращает объект с полями sub, email, role
+
+router = APIRouter(prefix="/v1/profiles", tags=["profiles"])
+
+@router.get("/me", response_model=ProfileOut)
+def get_my_profile(db: Session = Depends(get_db), user=Depends(get_current_user)):
+ svc = ProfileService(db)
+ p = svc.get_by_user(user.sub)
+ if not p:
+ # 404, если профиль отсутствует
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
+ return p
+
+@router.post("", response_model=ProfileOut, status_code=status.HTTP_201_CREATED)
+def create_profile(payload: ProfileCreate, db: Session = Depends(get_db), user=Depends(get_current_user)):
+ svc = ProfileService(db)
+ try:
+ existing = svc.get_by_user(user.sub)
+ if existing:
+ return existing
+ p = svc.create(user.sub, payload)
+ return p
+ except (IntegrityError, DataError, ValueError) as exc:
+ db.rollback()
+ raise HTTPException(status_code=400, detail=f"Invalid data: {exc}")
+PY
+
+# 4) Сервис — тонкая обёртка над репозиторием
+cat > services/profiles/src/app/services/profile_service.py <<'PY'
+from sqlalchemy.orm import Session
+from app.repositories.profile_repository import ProfileRepository
+from app.schemas.profile import ProfileCreate
+
+class ProfileService:
+ def __init__(self, db: Session):
+ self.repo = ProfileRepository(db)
+
+ def get_by_user(self, user_id):
+ return self.repo.get_by_user(user_id)
+
+ def create(self, user_id, obj: ProfileCreate):
+ return self.repo.create(user_id, obj)
+PY
+
+echo "[profiles] rebuilding..."
+docker compose build profiles
+docker compose restart profiles
\ No newline at end of file
diff --git a/.history/scripts/patch_20250808212435.sh b/.history/scripts/patch_20250808212435.sh
new file mode 100644
index 0000000..b1b5740
--- /dev/null
+++ b/.history/scripts/patch_20250808212435.sh
@@ -0,0 +1,33 @@
+# scripts/fix_profiles_deps.sh
+cat > scripts/fix_profiles_deps.sh <<'BASH'
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT="services/profiles/src/app"
+mkdir -p "$ROOT/db"
+
+# __init__.py чтобы пакет точно импортировался
+[[ -f "$ROOT/__init__.py" ]] || echo "# app package" > "$ROOT/__init__.py"
+[[ -f "$ROOT/db/__init__.py" ]] || echo "# db package" > "$ROOT/db/__init__.py"
+
+# deps.py с get_db()
+cat > "$ROOT/db/deps.py" <<'PY'
+from typing import Generator
+from sqlalchemy.orm import Session
+from app.db.session import SessionLocal # должен существовать в проекте
+
+def get_db() -> Generator[Session, None, None]:
+ db = SessionLocal()
+ try:
+ yield db
+ finally:
+ db.close()
+PY
+
+echo "[profiles] rebuilding..."
+docker compose build profiles
+docker compose restart profiles
+BASH
+
+chmod +x scripts/fix_profiles_deps.sh
+./scripts/fix_profiles_deps.sh
diff --git a/.history/scripts/patch_20250808213107.sh b/.history/scripts/patch_20250808213107.sh
new file mode 100644
index 0000000..e7295c6
--- /dev/null
+++ b/.history/scripts/patch_20250808213107.sh
@@ -0,0 +1,85 @@
+# scripts/patch_profiles_security.sh
+cat > scripts/patch_profiles_security.sh <<'BASH'
+#!/usr/bin/env bash
+set -euo pipefail
+
+REQ="services/profiles/requirements.txt"
+PY="services/profiles/src/app/core/security.py"
+
+# 1) гарантируем зависимость PyJWT
+grep -qE '(^|[[:space:]])PyJWT' "$REQ" 2>/dev/null || {
+ echo "PyJWT>=2.8.0" >> "$REQ"
+ echo "[profiles] added PyJWT to requirements.txt"
+}
+
+# 2) модуль security.py
+mkdir -p "$(dirname "$PY")"
+cat > "$PY" <<'PY'
+import os
+from typing import Optional
+
+import jwt
+from fastapi import Depends, HTTPException, status
+from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
+from pydantic import BaseModel
+
+reusable_bearer = HTTPBearer(auto_error=True)
+
+JWT_SECRET = os.getenv("JWT_SECRET", "dev-secret")
+JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
+
+# Возможность включить строгую проверку audience/issuer в будущем
+JWT_VERIFY_AUD = os.getenv("JWT_VERIFY_AUD", "0") == "1"
+JWT_AUDIENCE: Optional[str] = os.getenv("JWT_AUDIENCE") or None
+JWT_VERIFY_ISS = os.getenv("JWT_VERIFY_ISS", "0") == "1"
+JWT_ISSUER: Optional[str] = os.getenv("JWT_ISSUER") or None
+
+# Допустимая рассинхронизация часов (сек)
+JWT_LEEWAY = int(os.getenv("JWT_LEEWAY", "30"))
+
+class JwtUser(BaseModel):
+ sub: str
+ email: Optional[str] = None
+ role: Optional[str] = None
+
+def decode_token(token: str) -> JwtUser:
+ options = {
+ "verify_signature": True,
+ "verify_exp": True,
+ "verify_aud": JWT_VERIFY_AUD,
+ "verify_iss": JWT_VERIFY_ISS,
+ }
+ kwargs = {"algorithms": [JWT_ALGORITHM], "options": options, "leeway": JWT_LEEWAY}
+ if JWT_VERIFY_AUD and JWT_AUDIENCE:
+ kwargs["audience"] = JWT_AUDIENCE
+ if JWT_VERIFY_ISS and JWT_ISSUER:
+ kwargs["issuer"] = JWT_ISSUER
+
+ try:
+ payload = jwt.decode(token, JWT_SECRET, **kwargs)
+ sub = str(payload.get("sub") or "")
+ if not sub:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token: no sub")
+ return JwtUser(sub=sub, email=payload.get("email"), role=payload.get("role"))
+ except jwt.ExpiredSignatureError:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
+ except jwt.InvalidAudienceError:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid audience")
+ except jwt.InvalidIssuerError:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid issuer")
+ except jwt.InvalidTokenError:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
+
+def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(reusable_bearer)) -> JwtUser:
+ if credentials.scheme.lower() != "bearer":
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid auth scheme")
+ return decode_token(credentials.credentials)
+PY
+
+echo "[profiles] rebuilding..."
+docker compose build profiles
+docker compose restart profiles
+BASH
+
+chmod +x scripts/patch_profiles_security.sh
+./scripts/patch_profiles_security.sh
diff --git a/.history/scripts/patch_20250808213457.sh b/.history/scripts/patch_20250808213457.sh
new file mode 100644
index 0000000..fe650f7
--- /dev/null
+++ b/.history/scripts/patch_20250808213457.sh
@@ -0,0 +1,50 @@
+# scripts/fix_profiles_schema_uuid.sh
+cat > scripts/fix_profiles_schema_uuid.sh <<'BASH'
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCHEMA="services/profiles/src/app/schemas/profile.py"
+mkdir -p "$(dirname "$SCHEMA")"
+
+cat > "$SCHEMA" <<'PY'
+from __future__ import annotations
+from typing import List
+from uuid import UUID
+
+try:
+ # Pydantic v2
+ from pydantic import BaseModel, Field, ConfigDict
+ _V2 = True
+except Exception:
+ # Pydantic v1 fallback
+ from pydantic import BaseModel, Field
+ ConfigDict = None
+ _V2 = False
+
+class ProfileBase(BaseModel):
+ gender: str
+ city: str
+ languages: List[str] = Field(default_factory=list)
+ interests: List[str] = Field(default_factory=list)
+
+class ProfileCreate(ProfileBase):
+ pass
+
+class ProfileOut(ProfileBase):
+ id: UUID
+ user_id: UUID
+
+ if _V2:
+ model_config = ConfigDict(from_attributes=True)
+ else:
+ class Config:
+ orm_mode = True
+PY
+
+echo "[profiles] rebuilding..."
+docker compose build profiles
+docker compose restart profiles
+BASH
+
+chmod +x scripts/fix_profiles_schema_uuid.sh
+./scripts/fix_profiles_schema_uuid.sh
diff --git a/.history/scripts/patch_20250808213938.sh b/.history/scripts/patch_20250808213938.sh
new file mode 100644
index 0000000..fe650f7
--- /dev/null
+++ b/.history/scripts/patch_20250808213938.sh
@@ -0,0 +1,50 @@
+# scripts/fix_profiles_schema_uuid.sh
+cat > scripts/fix_profiles_schema_uuid.sh <<'BASH'
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCHEMA="services/profiles/src/app/schemas/profile.py"
+mkdir -p "$(dirname "$SCHEMA")"
+
+cat > "$SCHEMA" <<'PY'
+from __future__ import annotations
+from typing import List
+from uuid import UUID
+
+try:
+ # Pydantic v2
+ from pydantic import BaseModel, Field, ConfigDict
+ _V2 = True
+except Exception:
+ # Pydantic v1 fallback
+ from pydantic import BaseModel, Field
+ ConfigDict = None
+ _V2 = False
+
+class ProfileBase(BaseModel):
+ gender: str
+ city: str
+ languages: List[str] = Field(default_factory=list)
+ interests: List[str] = Field(default_factory=list)
+
+class ProfileCreate(ProfileBase):
+ pass
+
+class ProfileOut(ProfileBase):
+ id: UUID
+ user_id: UUID
+
+ if _V2:
+ model_config = ConfigDict(from_attributes=True)
+ else:
+ class Config:
+ orm_mode = True
+PY
+
+echo "[profiles] rebuilding..."
+docker compose build profiles
+docker compose restart profiles
+BASH
+
+chmod +x scripts/fix_profiles_schema_uuid.sh
+./scripts/fix_profiles_schema_uuid.sh
diff --git a/.history/scripts/patch_20250808213956.sh b/.history/scripts/patch_20250808213956.sh
new file mode 100644
index 0000000..6f49ac1
--- /dev/null
+++ b/.history/scripts/patch_20250808213956.sh
@@ -0,0 +1,49 @@
+# scripts/patch_profiles_router.sh
+cat > scripts/patch_profiles_router.sh <<'BASH'
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROUTER="services/profiles/src/app/api/routes/profiles.py"
+mkdir -p "$(dirname "$ROUTER")"
+
+cat > "$ROUTER" <<'PY'
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy.orm import Session
+
+from app.db.deps import get_db
+from app.core.security import get_current_user, JwtUser
+from app.schemas.profile import ProfileCreate, ProfileOut
+from app.repositories.profile_repository import ProfileRepository
+from app.services.profile_service import ProfileService
+
+# отключаем авто-редирект /path -> /path/
+router = APIRouter(prefix="/v1/profiles", tags=["profiles"], redirect_slashes=False)
+
+@router.get("/me", response_model=ProfileOut)
+def get_my_profile(current: JwtUser = Depends(get_current_user),
+ db: Session = Depends(get_db)):
+ svc = ProfileService(ProfileRepository(db))
+ p = svc.get_by_user(current.sub)
+ if not p:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
+ return p
+
+@router.post("", response_model=ProfileOut, status_code=status.HTTP_201_CREATED)
+def create_my_profile(payload: ProfileCreate,
+ current: JwtUser = Depends(get_current_user),
+ db: Session = Depends(get_db)):
+ svc = ProfileService(ProfileRepository(db))
+ existing = svc.get_by_user(current.sub)
+ if existing:
+ # если хотите строго — верните 409; оставлю 200/201 для удобства e2e
+ return existing
+ return svc.create(current.sub, payload)
+PY
+
+echo "[profiles] rebuilding..."
+docker compose build profiles
+docker compose restart profiles
+BASH
+
+chmod +x scripts/patch_profiles_router.sh
+./scripts/patch_profiles_router.sh
diff --git a/.history/scripts/patch_20250808214013.sh b/.history/scripts/patch_20250808214013.sh
new file mode 100644
index 0000000..fc60753
--- /dev/null
+++ b/.history/scripts/patch_20250808214013.sh
@@ -0,0 +1,61 @@
+# scripts/patch_profiles_repo_service.sh
+cat > scripts/patch_profiles_repo_service.sh <<'BASH'
+#!/usr/bin/env bash
+set -euo pipefail
+
+REPO="services/profiles/src/app/repositories/profile_repository.py"
+SRV="services/profiles/src/app/services/profile_service.py"
+mkdir -p "$(dirname "$REPO")" "$(dirname "$SRV")"
+
+cat > "$REPO" <<'PY'
+from typing import Optional
+from uuid import UUID
+from sqlalchemy.orm import Session
+from sqlalchemy import select
+from app.models.profile import Profile
+from app.schemas.profile import ProfileCreate
+
+class ProfileRepository:
+ def __init__(self, db: Session):
+ self.db = db
+
+ def get_by_user(self, user_id: UUID) -> Optional[Profile]:
+ return self.db.execute(select(Profile).where(Profile.user_id == user_id)).scalar_one_or_none()
+
+ def create(self, user_id: UUID, data: ProfileCreate) -> Profile:
+ p = Profile(
+ user_id=user_id,
+ gender=data.gender,
+ city=data.city,
+ languages=list(data.languages or []),
+ interests=list(data.interests or []),
+ )
+ self.db.add(p)
+ self.db.commit()
+ self.db.refresh(p)
+ return p
+PY
+
+cat > "$SRV" <<'PY'
+from uuid import UUID
+from app.schemas.profile import ProfileCreate
+from app.repositories.profile_repository import ProfileRepository
+
+class ProfileService:
+ def __init__(self, repo: ProfileRepository):
+ self.repo = repo
+
+ def get_by_user(self, user_id: UUID):
+ return self.repo.get_by_user(user_id)
+
+ def create(self, user_id: UUID, data: ProfileCreate):
+ return self.repo.create(user_id, data)
+PY
+
+echo "[profiles] rebuilding..."
+docker compose build profiles
+docker compose restart profiles
+BASH
+
+chmod +x scripts/patch_profiles_repo_service.sh
+./scripts/patch_profiles_repo_service.sh
diff --git a/.history/scripts/patch_20250808214025.sh b/.history/scripts/patch_20250808214025.sh
new file mode 100644
index 0000000..e5404b1
--- /dev/null
+++ b/.history/scripts/patch_20250808214025.sh
@@ -0,0 +1,31 @@
+# scripts/patch_gateway_auth_header.sh
+cat > scripts/patch_gateway_auth_header.sh <<'BASH'
+#!/usr/bin/env bash
+set -euo pipefail
+
+CFG="infra/gateway/nginx.conf"
+[ -f "$CFG" ] || { echo "Not found: $CFG"; exit 1; }
+
+# Грубая, но надёжная вставка proxy_set_header Authorization во все блоки location к сервисам
+awk '
+ /location[[:space:]]+\/(auth|profiles|match|chat|payments)\//,/\}/ {
+ print
+ if ($0 ~ /proxy_pass/ && !seen_auth) {
+ print " proxy_set_header Authorization $http_authorization;"
+ print " proxy_set_header X-Forwarded-Proto $scheme;"
+ print " proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;"
+ print " proxy_set_header Host $host;"
+ seen_auth=1
+ }
+ next
+ }
+ { print }
+ /\}/ { seen_auth=0 }
+' "$CFG" > "$CFG.tmp" && mv "$CFG.tmp" "$CFG"
+
+echo "[gateway] restart..."
+docker compose restart gateway
+BASH
+
+chmod +x scripts/patch_gateway_auth_header.sh
+./scripts/patch_gateway_auth_header.sh
diff --git a/.history/scripts/test_20250808204608.sh b/.history/scripts/test_20250808204608.sh
new file mode 100644
index 0000000..92f9447
--- /dev/null
+++ b/.history/scripts/test_20250808204608.sh
@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+
+# Все запросы будут иметь заголовок Authorization: Bearer $ACCESS
+# 404, если профиля ещё нет — это корректно
+curl -sS http://localhost:8080/profiles/v1/profiles/me \
+ -H "Authorization: Bearer $ACCESS"
+
+# Создание профиля
+printf '%s' \
+'{"gender":"female","city":"Moscow","languages":["ru","en"],"interests":["music","travel"]}' \
+| POST -H "Authorization: Bearer '"$ACCESS"'" \
+ -H "Content-Type: application/json" \
+ http://localhost:8080/profiles/v1/profiles
+
+# Теперь должен отдать ваш профиль
+curl -sS http://localhost:8080/profiles/v1/profiles/me \
+ -H "Authorization: Bearer $ACCESS"
diff --git a/.history/scripts/test_20250808214044.sh b/.history/scripts/test_20250808214044.sh
new file mode 100644
index 0000000..e707b02
--- /dev/null
+++ b/.history/scripts/test_20250808214044.sh
@@ -0,0 +1,29 @@
+#!/usr/bin/env bash
+
+# 1) Здоровье сервисов
+curl -sS http://localhost:8080/auth/health
+curl -sS http://localhost:8080/profiles/health
+
+# 2) Токен (любой юзер)
+curl -sS -X POST http://localhost:8080/auth/v1/token \
+ -H "Content-Type: application/json" \
+ -d '{"email":"admin@agency.dev","password":"secret123"}' | tee /tmp/token.json
+
+ACCESS=$(python3 - <<'PY' /tmp/token.json
+import sys, json; print(json.load(open(sys.argv[1]))["access_token"])
+PY
+)
+
+# 3) /me — ожидаемо 404 (если профиля нет), главное НЕ 401
+curl -i -sS http://localhost:8080/profiles/v1/profiles/me \
+ -H "Authorization: Bearer $ACCESS"
+
+# 4) Создать профиль — должно быть 201/200, без 500
+curl -i -sS -X POST http://localhost:8080/profiles/v1/profiles \
+ -H "Authorization: Bearer $ACCESS" \
+ -H "Content-Type: application/json" \
+ -d '{"gender":"female","city":"Moscow","languages":["ru","en"],"interests":["music","travel"]}'
+
+# 5) Снова /me — теперь 200 с JSON (UUIDы как строки)
+curl -sS http://localhost:8080/profiles/v1/profiles/me \
+ -H "Authorization: Bearer $ACCESS" | jq .
diff --git a/.history/services/auth/requirements_20250808195758.txt b/.history/services/auth/requirements_20250808195758.txt
new file mode 100644
index 0000000..add41b9
--- /dev/null
+++ b/.history/services/auth/requirements_20250808195758.txt
@@ -0,0 +1,12 @@
+fastapi
+uvicorn[standard]
+SQLAlchemy>=2.0
+psycopg2-binary
+alembic
+pydantic>=2
+pydantic-settings
+python-dotenv
+httpx>=0.27
+pytest
+PyJWT>=2.8
+passlib[bcrypt]>=1.7
diff --git a/.history/services/auth/requirements_20250808200038.txt b/.history/services/auth/requirements_20250808200038.txt
new file mode 100644
index 0000000..04996b5
--- /dev/null
+++ b/.history/services/auth/requirements_20250808200038.txt
@@ -0,0 +1,13 @@
+fastapi
+uvicorn[standard]
+SQLAlchemy>=2.0
+psycopg2-binary
+alembic
+pydantic>=2
+pydantic-settings
+pydantic[email]
+python-dotenv
+httpx>=0.27
+pytest
+PyJWT>=2.8
+passlib[bcrypt]>=1.7
diff --git a/.history/services/profiles/alembic/versions/769f535c9249_init_20250808202004.py b/.history/services/profiles/alembic/versions/769f535c9249_init_20250808202004.py
new file mode 100644
index 0000000..eada411
--- /dev/null
+++ b/.history/services/profiles/alembic/versions/769f535c9249_init_20250808202004.py
@@ -0,0 +1,54 @@
+"""init
+
+Revision ID: 769f535c9249
+Revises:
+Create Date: 2025-08-08 11:20:05.142049+00:00
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = '769f535c9249'
+down_revision = None
+branch_labels = None
+depends_on = None
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('photos',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('profile_id', sa.UUID(), nullable=False),
+ sa.Column('url', sa.String(length=500), nullable=False),
+ sa.Column('is_main', sa.Boolean(), nullable=False),
+ sa.Column('status', sa.String(length=16), nullable=False),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_photos_profile_id'), 'photos', ['profile_id'], unique=False)
+ op.create_table('profiles',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('user_id', sa.UUID(), nullable=False),
+ sa.Column('gender', sa.String(length=16), nullable=False),
+ sa.Column('birthdate', sa.Date(), nullable=True),
+ sa.Column('city', sa.String(length=120), nullable=True),
+ sa.Column('bio', sa.Text(), nullable=True),
+ sa.Column('languages', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
+ sa.Column('interests', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
+ sa.Column('preferences', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
+ sa.Column('verification_status', sa.String(length=16), nullable=False),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_profiles_user_id'), 'profiles', ['user_id'], unique=False)
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_index(op.f('ix_profiles_user_id'), table_name='profiles')
+ op.drop_table('profiles')
+ op.drop_index(op.f('ix_photos_profile_id'), table_name='photos')
+ op.drop_table('photos')
+ # ### end Alembic commands ###
diff --git a/.history/services/profiles/alembic/versions/769f535c9249_init_20250808203551.py b/.history/services/profiles/alembic/versions/769f535c9249_init_20250808203551.py
new file mode 100644
index 0000000..6f6ba0c
--- /dev/null
+++ b/.history/services/profiles/alembic/versions/769f535c9249_init_20250808203551.py
@@ -0,0 +1,55 @@
+"""init
+
+Revision ID: 769f535c9249
+Revises:
+Create Date: 2025-08-08 11:20:05.142049+00:00
+"""
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision = '769f535c9249'
+down_revision = None
+branch_labels = None
+depends_on = None
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('photos',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('profile_id', sa.UUID(), nullable=False),
+ sa.Column('url', sa.String(length=500), nullable=False),
+ sa.Column('is_main', sa.Boolean(), nullable=False),
+ sa.Column('status', sa.String(length=16), nullable=False),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_photos_profile_id'), 'photos', ['profile_id'], unique=False)
+ op.create_table('profiles',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('user_id', sa.UUID(), nullable=False),
+ sa.Column('gender', sa.String(length=16), nullable=False),
+ sa.Column('birthdate', sa.Date(), nullable=True),
+ sa.Column('city', sa.String(length=120), nullable=True),
+ sa.Column('bio', sa.Text(), nullable=True),
+ sa.Column('languages', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
+ sa.Column('interests', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
+ sa.Column('preferences', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
+ sa.Column('verification_status', sa.String(length=16), nullable=False),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_profiles_user_id'), 'profiles', ['user_id'], unique=False)
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_index(op.f('ix_profiles_user_id'), table_name='profiles')
+ op.drop_table('profiles')
+ op.drop_index(op.f('ix_photos_profile_id'), table_name='photos')
+ op.drop_table('photos')
+ # ### end Alembic commands ###
diff --git a/.history/services/profiles/docker-entrypoint_20250808194542.sh b/.history/services/profiles/docker-entrypoint_20250808194542.sh
new file mode 100644
index 0000000..2828898
--- /dev/null
+++ b/.history/services/profiles/docker-entrypoint_20250808194542.sh
@@ -0,0 +1,6 @@
+#!/usr/bin/env sh
+set -e
+# Run migrations (no-op if no revisions yet)
+alembic -c alembic.ini upgrade head || true
+# Start app
+exec uvicorn app.main:app --host 0.0.0.0 --port 8000
diff --git a/.history/services/profiles/docker-entrypoint_20250808203201.sh b/.history/services/profiles/docker-entrypoint_20250808203201.sh
new file mode 100644
index 0000000..ae2ee5e
--- /dev/null
+++ b/.history/services/profiles/docker-entrypoint_20250808203201.sh
@@ -0,0 +1,6 @@
+#!/usr/bin/env sh
+set -e
+# Run migrations (no-op if no revisions yet)
+alembic -c alembic.ini upgrade head || true
+# Start app
+exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --log-level debug
diff --git a/.history/services/profiles/src/app/models/photo_20250808195936.py b/.history/services/profiles/src/app/models/photo_20250808195936.py
new file mode 100644
index 0000000..d7b2a81
--- /dev/null
+++ b/.history/services/profiles/src/app/models/photo_20250808195936.py
@@ -0,0 +1,22 @@
+from __future__ import annotations
+import uuid
+from datetime import datetime
+
+from sqlalchemy import String, Boolean, DateTime, ForeignKey
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+from sqlalchemy.sql import func
+
+from app.db.session import Base
+
+class Photo(Base):
+ __tablename__ = "photos"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ profile_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
+ url: Mapped[str] = mapped_column(String(500), nullable=False)
+ is_main: Mapped[bool] = mapped_column(Boolean, default=False)
+ status: Mapped[str] = mapped_column(String(16), default="pending") # pending/approved/rejected
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+
+ profile = relationship("Profile", back_populates="photos", primaryjoin="Photo.profile_id==Profile.id", viewonly=True)
diff --git a/.history/services/profiles/src/app/models/photo_20250808204310.py b/.history/services/profiles/src/app/models/photo_20250808204310.py
new file mode 100644
index 0000000..49d3db9
--- /dev/null
+++ b/.history/services/profiles/src/app/models/photo_20250808204310.py
@@ -0,0 +1,27 @@
+from __future__ import annotations
+import uuid
+from datetime import datetime
+
+from sqlalchemy import String, Boolean, DateTime, ForeignKey
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+from sqlalchemy.sql import func
+
+from app.db.session import Base
+
+class Photo(Base):
+ __tablename__ = "photos"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ profile_id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True),
+ ForeignKey("profiles.id", ondelete="CASCADE"),
+ index=True,
+ nullable=False,
+ )
+ url: Mapped[str] = mapped_column(String(500), nullable=False)
+ is_main: Mapped[bool] = mapped_column(Boolean, default=False)
+ status: Mapped[str] = mapped_column(String(16), default="pending") # pending/approved/rejected
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+
+ profile = relationship("Profile", back_populates="photos")
\ No newline at end of file
diff --git a/.history/services/profiles/src/app/models/profile_20250808195936.py b/.history/services/profiles/src/app/models/profile_20250808195936.py
new file mode 100644
index 0000000..23df3d2
--- /dev/null
+++ b/.history/services/profiles/src/app/models/profile_20250808195936.py
@@ -0,0 +1,28 @@
+from __future__ import annotations
+import uuid
+from datetime import date, datetime
+
+from sqlalchemy import String, Date, DateTime, Text
+from sqlalchemy.dialects.postgresql import UUID, JSONB
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+from sqlalchemy.sql import func
+
+from app.db.session import Base
+
+class Profile(Base):
+ __tablename__ = "profiles"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
+ gender: Mapped[str] = mapped_column(String(16), nullable=False) # male/female/other
+ birthdate: Mapped[date | None] = mapped_column(Date, default=None)
+ city: Mapped[str | None] = mapped_column(String(120), default=None)
+ bio: Mapped[str | None] = mapped_column(Text, default=None)
+ languages: Mapped[dict | None] = mapped_column(JSONB, default=list) # e.g., ["ru","en"]
+ interests: Mapped[dict | None] = mapped_column(JSONB, default=list)
+ preferences: Mapped[dict | None] = mapped_column(JSONB, default=dict)
+ verification_status: Mapped[str] = mapped_column(String(16), default="unverified")
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
+
+ photos = relationship("Photo", back_populates="profile", cascade="all, delete-orphan")
diff --git a/.history/services/profiles/src/app/models/profile_20250808204008.py b/.history/services/profiles/src/app/models/profile_20250808204008.py
new file mode 100644
index 0000000..652b24c
--- /dev/null
+++ b/.history/services/profiles/src/app/models/profile_20250808204008.py
@@ -0,0 +1,29 @@
+from __future__ import annotations
+import uuid
+from datetime import date, datetime
+
+from sqlalchemy import String, Date, DateTime, Text
+from sqlalchemy.dialects.postgresql import UUID, JSONB
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+from sqlalchemy.sql import func
+from typing import Optional
+
+from app.db.session import Base
+
+class Profile(Base):
+ __tablename__ = "profiles"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
+ gender: Mapped[str] = mapped_column(String(16), nullable=False) # male/female/other
+ birthdate: Mapped[date | None] = mapped_column(Date, default=None)
+ city: Mapped[str | None] = mapped_column(String(120), default=None)
+ bio: Mapped[str | None] = mapped_column(Text, default=None)
+ languages: Mapped[dict | None] = mapped_column(JSONB, default=list) # e.g., ["ru","en"]
+ interests: Mapped[dict | None] = mapped_column(JSONB, default=list)
+ preferences: Mapped[dict | None] = mapped_column(JSONB, default=dict)
+ verification_status: Mapped[str] = mapped_column(String(16), default="unverified")
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
+
+ photos = relationship("Photo", back_populates="profile", cascade="all, delete-orphan")
diff --git a/.history/services/profiles/src/app/models/profile_20250808204024.py b/.history/services/profiles/src/app/models/profile_20250808204024.py
new file mode 100644
index 0000000..ef84110
--- /dev/null
+++ b/.history/services/profiles/src/app/models/profile_20250808204024.py
@@ -0,0 +1,29 @@
+from __future__ import annotations
+import uuid
+from datetime import date, datetime
+
+from sqlalchemy import String, Date, DateTime, Text
+from sqlalchemy.dialects.postgresql import UUID, JSONB
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+from sqlalchemy.sql import func
+from typing import Optional
+
+from app.db.session import Base
+
+class Profile(Base):
+ __tablename__ = "profiles"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
+ gender: Mapped[str] = mapped_column(String(16), nullable=False) # male/female/other
+ birthdate: Mapped[date | None] = mapped_column(Date, default=None)
+ city: Mapped[str | None] = mapped_column(String(120), default=None)
+ bio: Mapped[str | None] = mapped_column(Text, default=None)
+ languages: Mapped[Optional[list[str]]] = mapped_column(JSONB, default=list)
+ interests: Mapped[Optional[list[str]]] = mapped_column(JSONB, default=list)
+ preferences: Mapped[dict | None] = mapped_column(JSONB, default=dict)
+ verification_status: Mapped[str] = mapped_column(String(16), default="unverified")
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
+
+ photos = relationship("Photo", back_populates="profile", cascade="all, delete-orphan")
diff --git a/.history/services/profiles/src/app/models/profile_20250808204059.py b/.history/services/profiles/src/app/models/profile_20250808204059.py
new file mode 100644
index 0000000..ee678b1
--- /dev/null
+++ b/.history/services/profiles/src/app/models/profile_20250808204059.py
@@ -0,0 +1,24 @@
+from __future__ import annotations
+import uuid
+from datetime import date, datetime
+
+from sqlalchemy import String, Date, DateTime, Text
+from sqlalchemy.dialects.postgresql import UUID, JSONB
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+from sqlalchemy.sql import func
+from typing import Optional
+
+from app.db.session import Base
+
+class Profile(Base):
+ __tablename__ = "profiles"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
+ gender: Mapped[str] = mapped_column(String(16), nullable=False) # male/female/other
+ birthdate: Mapped[date | None] = mapped_column(Date, default=None)
+ city: Mapped[str | None] = mapped_column(String(120), default=None)
+ bio: Mapped[str | None] = mapped_column(Text, default=None)
+ languages: Mapped[Optional[list[str]]] = mapped_column(JSONB, default=list)
+ interests: Mapped[Optional[list[str]]] = mapped_column(JSONB, default=list)
+ preferences: Mapped[Optional[dict[str, str]]] = mapped_column(JSONB, default=dict)
\ No newline at end of file
diff --git a/.history/services/profiles/src/app/models/profile_20250808204229.py b/.history/services/profiles/src/app/models/profile_20250808204229.py
new file mode 100644
index 0000000..0c561fd
--- /dev/null
+++ b/.history/services/profiles/src/app/models/profile_20250808204229.py
@@ -0,0 +1,29 @@
+from __future__ import annotations
+import uuid
+from datetime import date, datetime
+
+from sqlalchemy import String, Date, DateTime, Text
+from sqlalchemy.dialects.postgresql import UUID, JSONB
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+from sqlalchemy.sql import func
+from typing import Optional
+
+from app.db.session import Base
+
+class Profile(Base):
+ __tablename__ = "profiles"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
+ gender: Mapped[str] = mapped_column(String(16), nullable=False) # male/female/other
+ birthdate: Mapped[date | None] = mapped_column(Date, default=None)
+ city: Mapped[str | None] = mapped_column(String(120), default=None)
+ bio: Mapped[str | None] = mapped_column(Text, default=None)
+ languages: Mapped[Optional[list[str]]] = mapped_column(JSONB, default=list)
+ interests: Mapped[Optional[list[str]]] = mapped_column(JSONB, default=list)
+ preferences: Mapped[dict | None] = mapped_column(JSONB, default=dict)
+ verification_status: Mapped[str] = mapped_column(String(16), default="unverified")
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
+
+ photos = relationship("Photo", back_populates="profile", cascade="all, delete-orphan")
\ No newline at end of file
diff --git a/.history/test_20250808204537.sh b/.history/test_20250808204537.sh
new file mode 100644
index 0000000..e69de29
diff --git a/.history/test_20250808204550.sh b/.history/test_20250808204550.sh
new file mode 100644
index 0000000..f09d470
--- /dev/null
+++ b/.history/test_20250808204550.sh
@@ -0,0 +1,16 @@
+#!/usr/bin/env bash
+set -euo pipefail
+# 404, если профиля ещё нет — это корректно
+curl -sS http://localhost:8080/profiles/v1/profiles/me \
+ -H "Authorization: Bearer $ACCESS"
+
+# Создание профиля
+printf '%s' \
+'{"gender":"female","city":"Moscow","languages":["ru","en"],"interests":["music","travel"]}' \
+| POST -H "Authorization: Bearer '"$ACCESS"'" \
+ -H "Content-Type: application/json" \
+ http://localhost:8080/profiles/v1/profiles
+
+# Теперь должен отдать ваш профиль
+curl -sS http://localhost:8080/profiles/v1/profiles/me \
+ -H "Authorization: Bearer $ACCESS"
diff --git a/.history/test_20250808204607.sh b/.history/test_20250808204607.sh
new file mode 100644
index 0000000..8ce0151
--- /dev/null
+++ b/.history/test_20250808204607.sh
@@ -0,0 +1,16 @@
+#!/usr/bin/env bash
+
+# 404, если профиля ещё нет — это корректно
+curl -sS http://localhost:8080/profiles/v1/profiles/me \
+ -H "Authorization: Bearer $ACCESS"
+
+# Создание профиля
+printf '%s' \
+'{"gender":"female","city":"Moscow","languages":["ru","en"],"interests":["music","travel"]}' \
+| POST -H "Authorization: Bearer '"$ACCESS"'" \
+ -H "Content-Type: application/json" \
+ http://localhost:8080/profiles/v1/profiles
+
+# Теперь должен отдать ваш профиль
+curl -sS http://localhost:8080/profiles/v1/profiles/me \
+ -H "Authorization: Bearer $ACCESS"
diff --git a/.history/test_20250808204608.sh b/.history/test_20250808204608.sh
new file mode 100644
index 0000000..8ce0151
--- /dev/null
+++ b/.history/test_20250808204608.sh
@@ -0,0 +1,16 @@
+#!/usr/bin/env bash
+
+# 404, если профиля ещё нет — это корректно
+curl -sS http://localhost:8080/profiles/v1/profiles/me \
+ -H "Authorization: Bearer $ACCESS"
+
+# Создание профиля
+printf '%s' \
+'{"gender":"female","city":"Moscow","languages":["ru","en"],"interests":["music","travel"]}' \
+| POST -H "Authorization: Bearer '"$ACCESS"'" \
+ -H "Content-Type: application/json" \
+ http://localhost:8080/profiles/v1/profiles
+
+# Теперь должен отдать ваш профиль
+curl -sS http://localhost:8080/profiles/v1/profiles/me \
+ -H "Authorization: Bearer $ACCESS"
diff --git a/.history/test_20250808204610.sh b/.history/test_20250808204610.sh
new file mode 100644
index 0000000..92f9447
--- /dev/null
+++ b/.history/test_20250808204610.sh
@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+
+# Все запросы будут иметь заголовок Authorization: Bearer $ACCESS
+# 404, если профиля ещё нет — это корректно
+curl -sS http://localhost:8080/profiles/v1/profiles/me \
+ -H "Authorization: Bearer $ACCESS"
+
+# Создание профиля
+printf '%s' \
+'{"gender":"female","city":"Moscow","languages":["ru","en"],"interests":["music","travel"]}' \
+| POST -H "Authorization: Bearer '"$ACCESS"'" \
+ -H "Content-Type: application/json" \
+ http://localhost:8080/profiles/v1/profiles
+
+# Теперь должен отдать ваш профиль
+curl -sS http://localhost:8080/profiles/v1/profiles/me \
+ -H "Authorization: Bearer $ACCESS"
diff --git a/docker-compose.yml b/docker-compose.yml
index 63aa1a6..eb86f45 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,4 +1,4 @@
-version: "3.9"
+
services:
postgres:
image: postgres:16
diff --git a/infra/db/init/02_create_tables.sql b/infra/db/init/02_create_tables.sql
new file mode 100644
index 0000000..e69de29
diff --git a/infra/gateway/nginx.conf b/infra/gateway/nginx.conf
index 784757b..bcac648 100644
--- a/infra/gateway/nginx.conf
+++ b/infra/gateway/nginx.conf
@@ -9,6 +9,10 @@ server {
}
location /auth/ {
proxy_pass http://auth:8000/;
+ proxy_set_header Authorization $http_authorization;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header Host $host;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
diff --git a/logs/api.log b/logs/api.log
new file mode 100644
index 0000000..1e121d7
--- /dev/null
+++ b/logs/api.log
@@ -0,0 +1,193 @@
+2025-08-08 21:41:03 | INFO | api_e2e | === API E2E START ===
+2025-08-08 21:41:03 | INFO | api_e2e | BASE_URL=http://localhost:8080 clients=2 domain=agency.dev
+2025-08-08 21:41:03 | INFO | api_e2e | Waiting gateway/auth health: http://localhost:8080/auth/health
+2025-08-08 21:41:03 | DEBUG | api_e2e | HTTP GET http://localhost:8080/auth/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:41:03 | DEBUG | api_e2e | ← 200 in 3 ms | body={"status":"ok","service":"auth"}
+2025-08-08 21:41:03 | INFO | api_e2e | gateway/auth is healthy
+2025-08-08 21:41:03 | INFO | api_e2e | Waiting profiles health: http://localhost:8080/profiles/health
+2025-08-08 21:41:03 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:41:03 | DEBUG | api_e2e | ← 200 in 1 ms | body={"status":"ok","service":"profiles"}
+2025-08-08 21:41:03 | INFO | api_e2e | profiles is healthy
+2025-08-08 21:41:03 | INFO | api_e2e | Waiting match health: http://localhost:8080/match/health
+2025-08-08 21:41:03 | DEBUG | api_e2e | HTTP GET http://localhost:8080/match/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:41:03 | DEBUG | api_e2e | ← 200 in 2 ms | body={"status":"ok","service":"match"}
+2025-08-08 21:41:03 | INFO | api_e2e | match is healthy
+2025-08-08 21:41:03 | INFO | api_e2e | Waiting chat health: http://localhost:8080/chat/health
+2025-08-08 21:41:03 | DEBUG | api_e2e | HTTP GET http://localhost:8080/chat/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:41:03 | DEBUG | api_e2e | ← 200 in 2 ms | body={"status":"ok","service":"chat"}
+2025-08-08 21:41:03 | INFO | api_e2e | chat is healthy
+2025-08-08 21:41:03 | INFO | api_e2e | Waiting payments health: http://localhost:8080/payments/health
+2025-08-08 21:41:03 | DEBUG | api_e2e | HTTP GET http://localhost:8080/payments/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:41:03 | DEBUG | api_e2e | ← 200 in 2 ms | body={"status":"ok","service":"payments"}
+2025-08-08 21:41:03 | INFO | api_e2e | payments is healthy
+2025-08-08 21:41:03 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'admin+1754656863.xaji0y@agency.dev', 'password': '***hidden***'}
+2025-08-08 21:41:03 | DEBUG | api_e2e | ← 500 in 6 ms | body=Internal Server Error
+2025-08-08 21:41:03 | ERROR | api_e2e | login unexpected status 500, expected [200]; body=Internal Server Error
+2025-08-08 21:41:03 | INFO | api_e2e | Login failed for admin+1754656863.xaji0y@agency.dev: login unexpected status 500, expected [200]; body=Internal Server Error; will try register
+2025-08-08 21:41:03 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/register | headers={Authorization: Bearer } | body={'email': 'admin+1754656863.xaji0y@agency.dev', 'password': '***hidden***', 'full_name': 'Corey Briggs', 'role': 'ADMIN'}
+2025-08-08 21:41:03 | DEBUG | api_e2e | ← 500 in 7 ms | body=Internal Server Error
+2025-08-08 21:41:03 | ERROR | api_e2e | register unexpected status 500, expected [200, 201]; body=Internal Server Error
+2025-08-08 21:41:03 | WARNING | api_e2e | register returned non-2xx: register unexpected status 500, expected [200, 201]; body=Internal Server Error — will try login anyway
+2025-08-08 21:41:03 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'admin+1754656863.xaji0y@agency.dev', 'password': '***hidden***'}
+2025-08-08 21:41:03 | DEBUG | api_e2e | ← 500 in 6 ms | body=Internal Server Error
+2025-08-08 21:41:03 | ERROR | api_e2e | login unexpected status 500, expected [200]; body=Internal Server Error
+2025-08-08 21:43:12 | INFO | api_e2e | === API E2E START ===
+2025-08-08 21:43:12 | INFO | api_e2e | BASE_URL=http://localhost:8080 clients=2 domain=agency.dev
+2025-08-08 21:43:12 | INFO | api_e2e | Waiting gateway/auth health: http://localhost:8080/auth/health
+2025-08-08 21:43:12 | DEBUG | api_e2e | HTTP GET http://localhost:8080/auth/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:43:12 | DEBUG | api_e2e | ← 200 in 2 ms | body={"status":"ok","service":"auth"}
+2025-08-08 21:43:12 | INFO | api_e2e | gateway/auth is healthy
+2025-08-08 21:43:12 | INFO | api_e2e | Waiting profiles health: http://localhost:8080/profiles/health
+2025-08-08 21:43:12 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:43:12 | DEBUG | api_e2e | ← 200 in 2 ms | body={"status":"ok","service":"profiles"}
+2025-08-08 21:43:12 | INFO | api_e2e | profiles is healthy
+2025-08-08 21:43:12 | INFO | api_e2e | Waiting match health: http://localhost:8080/match/health
+2025-08-08 21:43:12 | DEBUG | api_e2e | HTTP GET http://localhost:8080/match/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:43:12 | DEBUG | api_e2e | ← 200 in 2 ms | body={"status":"ok","service":"match"}
+2025-08-08 21:43:12 | INFO | api_e2e | match is healthy
+2025-08-08 21:43:12 | INFO | api_e2e | Waiting chat health: http://localhost:8080/chat/health
+2025-08-08 21:43:12 | DEBUG | api_e2e | HTTP GET http://localhost:8080/chat/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:43:12 | DEBUG | api_e2e | ← 200 in 2 ms | body={"status":"ok","service":"chat"}
+2025-08-08 21:43:12 | INFO | api_e2e | chat is healthy
+2025-08-08 21:43:12 | INFO | api_e2e | Waiting payments health: http://localhost:8080/payments/health
+2025-08-08 21:43:12 | DEBUG | api_e2e | HTTP GET http://localhost:8080/payments/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:43:12 | DEBUG | api_e2e | ← 200 in 2 ms | body={"status":"ok","service":"payments"}
+2025-08-08 21:43:12 | INFO | api_e2e | payments is healthy
+2025-08-08 21:43:12 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'admin+1754656992.xaji0y@agency.dev', 'password': '***hidden***'}
+2025-08-08 21:43:12 | DEBUG | api_e2e | ← 500 in 6 ms | body=Internal Server Error
+2025-08-08 21:43:12 | ERROR | api_e2e | login unexpected status 500, expected [200]; body=Internal Server Error
+2025-08-08 21:43:12 | INFO | api_e2e | Login failed for admin+1754656992.xaji0y@agency.dev: login unexpected status 500, expected [200]; body=Internal Server Error; will try register
+2025-08-08 21:43:12 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/register | headers={Authorization: Bearer } | body={'email': 'admin+1754656992.xaji0y@agency.dev', 'password': '***hidden***', 'full_name': 'Heather Franklin', 'role': 'ADMIN'}
+2025-08-08 21:43:12 | DEBUG | api_e2e | ← 500 in 7 ms | body=Internal Server Error
+2025-08-08 21:43:12 | ERROR | api_e2e | register unexpected status 500, expected [200, 201]; body=Internal Server Error
+2025-08-08 21:43:12 | WARNING | api_e2e | register returned non-2xx: register unexpected status 500, expected [200, 201]; body=Internal Server Error — will try login anyway
+2025-08-08 21:43:12 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'admin+1754656992.xaji0y@agency.dev', 'password': '***hidden***'}
+2025-08-08 21:43:12 | DEBUG | api_e2e | ← 500 in 9 ms | body=Internal Server Error
+2025-08-08 21:43:12 | ERROR | api_e2e | login unexpected status 500, expected [200]; body=Internal Server Error
+2025-08-08 21:45:36 | INFO | api_e2e | === API E2E START ===
+2025-08-08 21:45:36 | INFO | api_e2e | BASE_URL=http://localhost:8080 clients=2 domain=agency.dev
+2025-08-08 21:45:36 | INFO | api_e2e | Waiting gateway/auth health: http://localhost:8080/auth/health
+2025-08-08 21:45:36 | DEBUG | api_e2e | HTTP GET http://localhost:8080/auth/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:45:36 | DEBUG | api_e2e | ← 200 in 3 ms | body={"status":"ok","service":"auth"}
+2025-08-08 21:45:36 | INFO | api_e2e | gateway/auth is healthy
+2025-08-08 21:45:36 | INFO | api_e2e | Waiting profiles health: http://localhost:8080/profiles/health
+2025-08-08 21:45:36 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:45:36 | DEBUG | api_e2e | ← 200 in 1 ms | body={"status":"ok","service":"profiles"}
+2025-08-08 21:45:36 | INFO | api_e2e | profiles is healthy
+2025-08-08 21:45:36 | INFO | api_e2e | Waiting match health: http://localhost:8080/match/health
+2025-08-08 21:45:36 | DEBUG | api_e2e | HTTP GET http://localhost:8080/match/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:45:36 | DEBUG | api_e2e | ← 200 in 2 ms | body={"status":"ok","service":"match"}
+2025-08-08 21:45:36 | INFO | api_e2e | match is healthy
+2025-08-08 21:45:36 | INFO | api_e2e | Waiting chat health: http://localhost:8080/chat/health
+2025-08-08 21:45:36 | DEBUG | api_e2e | HTTP GET http://localhost:8080/chat/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:45:36 | DEBUG | api_e2e | ← 200 in 2 ms | body={"status":"ok","service":"chat"}
+2025-08-08 21:45:36 | INFO | api_e2e | chat is healthy
+2025-08-08 21:45:36 | INFO | api_e2e | Waiting payments health: http://localhost:8080/payments/health
+2025-08-08 21:45:36 | DEBUG | api_e2e | HTTP GET http://localhost:8080/payments/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:45:36 | DEBUG | api_e2e | ← 200 in 2 ms | body={"status":"ok","service":"payments"}
+2025-08-08 21:45:36 | INFO | api_e2e | payments is healthy
+2025-08-08 21:45:36 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'admin+1754657136.xaji0y@agency.dev', 'password': '***hidden***'}
+2025-08-08 21:45:36 | DEBUG | api_e2e | ← 401 in 4 ms | body={"detail":"Invalid credentials"}
+2025-08-08 21:45:36 | ERROR | api_e2e | login unexpected status 401, expected [200]; body={"detail":"Invalid credentials"}
+2025-08-08 21:45:36 | INFO | api_e2e | Login failed for admin+1754657136.xaji0y@agency.dev: login unexpected status 401, expected [200]; body={"detail":"Invalid credentials"}; will try register
+2025-08-08 21:45:36 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/register | headers={Authorization: Bearer } | body={'email': 'admin+1754657136.xaji0y@agency.dev', 'password': '***hidden***', 'full_name': 'Allison Sanders', 'role': 'ADMIN'}
+2025-08-08 21:45:36 | DEBUG | api_e2e | ← 500 in 227 ms | body=Internal Server Error
+2025-08-08 21:45:36 | ERROR | api_e2e | register unexpected status 500, expected [200, 201]; body=Internal Server Error
+2025-08-08 21:45:36 | WARNING | api_e2e | register returned non-2xx: register unexpected status 500, expected [200, 201]; body=Internal Server Error — will try login anyway
+2025-08-08 21:45:36 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'admin+1754657136.xaji0y@agency.dev', 'password': '***hidden***'}
+2025-08-08 21:45:36 | DEBUG | api_e2e | ← 200 in 213 ms | body={"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1MDEyZDZkNC01ZjgwLTQzMzktOTIxMC0wNzI3ZGU1OTAwOGMiLCJlbWFpbCI6ImFkbWluKzE3NTQ2NTcxMzYueGFqaTB5QGFnZW5jeS5kZXYiLCJyb2xlIjoiQURNSU4iLCJ0eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzU0NjU4MDM2fQ.GNe6OFWt4zPlFC-8eGjVEwV-b_mj5AO3HRu75C2oikU","refresh_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1MDEyZDZkNC01ZjgwLTQzMzktOTIxMC0wNzI3ZGU1OTAwOGMiLCJlbWFpbCI6ImFkbWluKzE3NTQ2NTcxMzYueGFqaTB5QGFnZW5jeS5kZXYiLCJyb2xlIjoiQURNSU4iLCJ0eXBlIjoicmVmcmVzaCIsImV4cCI6MTc1NzI0OTEzNn0.OGorK2VQ9KmOTSpnSzN_jJfv5Tvu5QkYldiTlm-sP_Q","token_type":"bearer"}
+2025-08-08 21:45:36 | INFO | api_e2e | Registered+Login OK: admin+1754657136.xaji0y@agency.dev -> 5012d6d4-5f80-4339-9210-0727de59008c
+2025-08-08 21:45:36 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'user1+1754657136.6dpbhs@agency.dev', 'password': '***hidden***'}
+2025-08-08 21:45:36 | DEBUG | api_e2e | ← 401 in 3 ms | body={"detail":"Invalid credentials"}
+2025-08-08 21:45:36 | ERROR | api_e2e | login unexpected status 401, expected [200]; body={"detail":"Invalid credentials"}
+2025-08-08 21:45:36 | INFO | api_e2e | Login failed for user1+1754657136.6dpbhs@agency.dev: login unexpected status 401, expected [200]; body={"detail":"Invalid credentials"}; will try register
+2025-08-08 21:45:36 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/register | headers={Authorization: Bearer } | body={'email': 'user1+1754657136.6dpbhs@agency.dev', 'password': '***hidden***', 'full_name': 'Joshua Harris', 'role': 'CLIENT'}
+2025-08-08 21:45:36 | DEBUG | api_e2e | ← 500 in 225 ms | body=Internal Server Error
+2025-08-08 21:45:36 | ERROR | api_e2e | register unexpected status 500, expected [200, 201]; body=Internal Server Error
+2025-08-08 21:45:36 | WARNING | api_e2e | register returned non-2xx: register unexpected status 500, expected [200, 201]; body=Internal Server Error — will try login anyway
+2025-08-08 21:45:36 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'user1+1754657136.6dpbhs@agency.dev', 'password': '***hidden***'}
+2025-08-08 21:45:36 | DEBUG | api_e2e | ← 200 in 212 ms | body={"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhMDhmODg4MS04ZThhLTRiZDQtODNmNy02NGFjN2MwYjQzODQiLCJlbWFpbCI6InVzZXIxKzE3NTQ2NTcxMzYuNmRwYmhzQGFnZW5jeS5kZXYiLCJyb2xlIjoiQ0xJRU5UIiwidHlwZSI6ImFjY2VzcyIsImV4cCI6MTc1NDY1ODAzNn0.JYz3xrtGtQ6V0g14CWinTVj1P1cz8cWDQSNz_Z-e64k","refresh_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhMDhmODg4MS04ZThhLTRiZDQtODNmNy02NGFjN2MwYjQzODQiLCJlbWFpbCI6InVzZXIxKzE3NTQ2NTcxMzYuNmRwYmhzQGFnZW5jeS5kZXYiLCJyb2xlIjoiQ0xJRU5UIiwidHlwZSI6InJlZnJlc2giLCJleHAiOjE3NTcyNDkxMzZ9.IfmVSwhWJbQjFj1mSmhN9qV20CYvHwy4aUuaaictCEI","token_type":"bearer"}
+2025-08-08 21:45:36 | INFO | api_e2e | Registered+Login OK: user1+1754657136.6dpbhs@agency.dev -> a08f8881-8e8a-4bd4-83f7-64ac7c0b4384
+2025-08-08 21:45:36 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'user2+1754657136.ahxthv@agency.dev', 'password': '***hidden***'}
+2025-08-08 21:45:36 | DEBUG | api_e2e | ← 401 in 4 ms | body={"detail":"Invalid credentials"}
+2025-08-08 21:45:36 | ERROR | api_e2e | login unexpected status 401, expected [200]; body={"detail":"Invalid credentials"}
+2025-08-08 21:45:36 | INFO | api_e2e | Login failed for user2+1754657136.ahxthv@agency.dev: login unexpected status 401, expected [200]; body={"detail":"Invalid credentials"}; will try register
+2025-08-08 21:45:36 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/register | headers={Authorization: Bearer } | body={'email': 'user2+1754657136.ahxthv@agency.dev', 'password': '***hidden***', 'full_name': 'Adrian Taylor', 'role': 'CLIENT'}
+2025-08-08 21:45:37 | DEBUG | api_e2e | ← 500 in 225 ms | body=Internal Server Error
+2025-08-08 21:45:37 | ERROR | api_e2e | register unexpected status 500, expected [200, 201]; body=Internal Server Error
+2025-08-08 21:45:37 | WARNING | api_e2e | register returned non-2xx: register unexpected status 500, expected [200, 201]; body=Internal Server Error — will try login anyway
+2025-08-08 21:45:37 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'user2+1754657136.ahxthv@agency.dev', 'password': '***hidden***'}
+2025-08-08 21:45:37 | DEBUG | api_e2e | ← 200 in 212 ms | body={"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI2MDkyMTc5Zi03MmUwLTQ0NGMtYmI1YS0yNDRjYzVjMjNiMGIiLCJlbWFpbCI6InVzZXIyKzE3NTQ2NTcxMzYuYWh4dGh2QGFnZW5jeS5kZXYiLCJyb2xlIjoiQ0xJRU5UIiwidHlwZSI6ImFjY2VzcyIsImV4cCI6MTc1NDY1ODAzN30.zhLgSUtLDDuisejsK-vIsxsplwEfXmSqtDdLuuOG6xY","refresh_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI2MDkyMTc5Zi03MmUwLTQ0NGMtYmI1YS0yNDRjYzVjMjNiMGIiLCJlbWFpbCI6InVzZXIyKzE3NTQ2NTcxMzYuYWh4dGh2QGFnZW5jeS5kZXYiLCJyb2xlIjoiQ0xJRU5UIiwidHlwZSI6InJlZnJlc2giLCJleHAiOjE3NTcyNDkxMzd9.FFHAxduf-mGYZwFeP-SkLlRYtBV3U5v31hvtrAHbo30","token_type":"bearer"}
+2025-08-08 21:45:37 | INFO | api_e2e | Registered+Login OK: user2+1754657136.ahxthv@agency.dev -> 6092179f-72e0-444c-bb5a-244cc5c23b0b
+2025-08-08 21:45:37 | INFO | api_e2e | [1/3] Ensure profile for admin+1754657136.xaji0y@agency.dev (role=ADMIN)
+2025-08-08 21:45:37 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/v1/profiles/me | headers={Authorization: Bearer eyJhbGciOiJI...} | body={}
+2025-08-08 21:45:37 | DEBUG | api_e2e | ← 403 in 1 ms | body={"detail":"Not authenticated"}
+2025-08-08 21:45:37 | ERROR | api_e2e | profiles/me unexpected status 403, expected [200, 404]; body={"detail":"Not authenticated"}
+2025-08-08 21:54:30 | INFO | api_e2e | === API E2E START ===
+2025-08-08 21:54:30 | INFO | api_e2e | BASE_URL=http://localhost:8080 clients=2 domain=agency.dev
+2025-08-08 21:54:30 | INFO | api_e2e | Waiting gateway/auth health: http://localhost:8080/auth/health
+2025-08-08 21:55:18 | INFO | api_e2e | === API E2E START ===
+2025-08-08 21:55:18 | INFO | api_e2e | BASE_URL=http://localhost:8080 clients=2 domain=agency.dev
+2025-08-08 21:55:18 | INFO | api_e2e | Waiting gateway/auth health: http://localhost:8080/auth/health
+2025-08-08 21:55:29 | INFO | api_e2e | === API E2E START ===
+2025-08-08 21:55:29 | INFO | api_e2e | BASE_URL=http://localhost:8080 clients=2 domain=agency.dev
+2025-08-08 21:55:29 | INFO | api_e2e | Waiting gateway/auth health: http://localhost:8080/auth/health
+2025-08-08 21:56:19 | INFO | api_e2e | === API E2E START ===
+2025-08-08 21:56:19 | INFO | api_e2e | BASE_URL=http://localhost:8080 clients=2 domain=agency.dev
+2025-08-08 21:56:19 | INFO | api_e2e | Waiting gateway/auth health: http://localhost:8080/auth/health
+2025-08-08 21:56:19 | DEBUG | api_e2e | HTTP GET http://localhost:8080/auth/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:56:19 | DEBUG | api_e2e | ← 200 in 14 ms | body={"status":"ok","service":"auth"}
+2025-08-08 21:56:19 | INFO | api_e2e | gateway/auth is healthy
+2025-08-08 21:56:19 | INFO | api_e2e | Waiting profiles health: http://localhost:8080/profiles/health
+2025-08-08 21:56:19 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:56:19 | DEBUG | api_e2e | ← 200 in 5 ms | body={"status":"ok","service":"profiles"}
+2025-08-08 21:56:19 | INFO | api_e2e | profiles is healthy
+2025-08-08 21:56:19 | INFO | api_e2e | Waiting match health: http://localhost:8080/match/health
+2025-08-08 21:56:19 | DEBUG | api_e2e | HTTP GET http://localhost:8080/match/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:56:19 | DEBUG | api_e2e | ← 200 in 3 ms | body={"status":"ok","service":"match"}
+2025-08-08 21:56:19 | INFO | api_e2e | match is healthy
+2025-08-08 21:56:19 | INFO | api_e2e | Waiting chat health: http://localhost:8080/chat/health
+2025-08-08 21:56:19 | DEBUG | api_e2e | HTTP GET http://localhost:8080/chat/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:56:19 | DEBUG | api_e2e | ← 200 in 2 ms | body={"status":"ok","service":"chat"}
+2025-08-08 21:56:19 | INFO | api_e2e | chat is healthy
+2025-08-08 21:56:19 | INFO | api_e2e | Waiting payments health: http://localhost:8080/payments/health
+2025-08-08 21:56:19 | DEBUG | api_e2e | HTTP GET http://localhost:8080/payments/health | headers={Authorization: Bearer } | body={}
+2025-08-08 21:56:19 | DEBUG | api_e2e | ← 200 in 2 ms | body={"status":"ok","service":"payments"}
+2025-08-08 21:56:19 | INFO | api_e2e | payments is healthy
+2025-08-08 21:56:19 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'admin+1754657779.xaji0y@agency.dev', 'password': '***hidden***'}
+2025-08-08 21:56:19 | DEBUG | api_e2e | ← 401 in 10 ms | body={"detail":"Invalid credentials"}
+2025-08-08 21:56:19 | ERROR | api_e2e | login unexpected status 401, expected [200]; body={"detail":"Invalid credentials"}
+2025-08-08 21:56:19 | INFO | api_e2e | Login failed for admin+1754657779.xaji0y@agency.dev: login unexpected status 401, expected [200]; body={"detail":"Invalid credentials"}; will try register
+2025-08-08 21:56:19 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/register | headers={Authorization: Bearer } | body={'email': 'admin+1754657779.xaji0y@agency.dev', 'password': '***hidden***', 'full_name': 'Eric Roberson', 'role': 'ADMIN'}
+2025-08-08 21:56:20 | DEBUG | api_e2e | ← 500 in 230 ms | body=Internal Server Error
+2025-08-08 21:56:20 | ERROR | api_e2e | register unexpected status 500, expected [200, 201]; body=Internal Server Error
+2025-08-08 21:56:20 | WARNING | api_e2e | register returned non-2xx: register unexpected status 500, expected [200, 201]; body=Internal Server Error — will try login anyway
+2025-08-08 21:56:20 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'admin+1754657779.xaji0y@agency.dev', 'password': '***hidden***'}
+2025-08-08 21:56:20 | DEBUG | api_e2e | ← 200 in 213 ms | body={"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MDZhYmI3OS1kOWI2LTRjMzAtOGQ0ZC0xOTUwYWI2MTE5ZGUiLCJlbWFpbCI6ImFkbWluKzE3NTQ2NTc3NzkueGFqaTB5QGFnZW5jeS5kZXYiLCJyb2xlIjoiQURNSU4iLCJ0eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzU0NjU4NjgwfQ.fopkkb3_QSCoDCkyYDVeQRCJse2VFP2cHDYx8QkZ6eY","refresh_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MDZhYmI3OS1kOWI2LTRjMzAtOGQ0ZC0xOTUwYWI2MTE5ZGUiLCJlbWFpbCI6ImFkbWluKzE3NTQ2NTc3NzkueGFqaTB5QGFnZW5jeS5kZXYiLCJyb2xlIjoiQURNSU4iLCJ0eXBlIjoicmVmcmVzaCIsImV4cCI6MTc1NzI0OTc4MH0.vegho7J95DF_XuJqKaoTa79RcQHEhM-O4Uo-iDF4s2M","token_type":"bearer"}
+2025-08-08 21:56:20 | INFO | api_e2e | Registered+Login OK: admin+1754657779.xaji0y@agency.dev -> 406abb79-d9b6-4c30-8d4d-1950ab6119de
+2025-08-08 21:56:20 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'user1+1754657780.6dpbhs@agency.dev', 'password': '***hidden***'}
+2025-08-08 21:56:20 | DEBUG | api_e2e | ← 401 in 3 ms | body={"detail":"Invalid credentials"}
+2025-08-08 21:56:20 | ERROR | api_e2e | login unexpected status 401, expected [200]; body={"detail":"Invalid credentials"}
+2025-08-08 21:56:20 | INFO | api_e2e | Login failed for user1+1754657780.6dpbhs@agency.dev: login unexpected status 401, expected [200]; body={"detail":"Invalid credentials"}; will try register
+2025-08-08 21:56:20 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/register | headers={Authorization: Bearer } | body={'email': 'user1+1754657780.6dpbhs@agency.dev', 'password': '***hidden***', 'full_name': 'Stephen Garcia', 'role': 'CLIENT'}
+2025-08-08 21:56:20 | DEBUG | api_e2e | ← 500 in 224 ms | body=Internal Server Error
+2025-08-08 21:56:20 | ERROR | api_e2e | register unexpected status 500, expected [200, 201]; body=Internal Server Error
+2025-08-08 21:56:20 | WARNING | api_e2e | register returned non-2xx: register unexpected status 500, expected [200, 201]; body=Internal Server Error — will try login anyway
+2025-08-08 21:56:20 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'user1+1754657780.6dpbhs@agency.dev', 'password': '***hidden***'}
+2025-08-08 21:56:20 | DEBUG | api_e2e | ← 200 in 212 ms | body={"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMjM2YzNkZS01YzllLTRiNGMtODE5My1hZjIwODI0MDgxZGUiLCJlbWFpbCI6InVzZXIxKzE3NTQ2NTc3ODAuNmRwYmhzQGFnZW5jeS5kZXYiLCJyb2xlIjoiQ0xJRU5UIiwidHlwZSI6ImFjY2VzcyIsImV4cCI6MTc1NDY1ODY4MH0.8lkRa-uwaI5MD5Bz-NQPYuxuFb84lAroVX9nqwIwSWU","refresh_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMjM2YzNkZS01YzllLTRiNGMtODE5My1hZjIwODI0MDgxZGUiLCJlbWFpbCI6InVzZXIxKzE3NTQ2NTc3ODAuNmRwYmhzQGFnZW5jeS5kZXYiLCJyb2xlIjoiQ0xJRU5UIiwidHlwZSI6InJlZnJlc2giLCJleHAiOjE3NTcyNDk3ODB9.bMMXN_To2KRtLME0NiY1BkHfpQXkkPm4WOv4KUD3PYs","token_type":"bearer"}
+2025-08-08 21:56:20 | INFO | api_e2e | Registered+Login OK: user1+1754657780.6dpbhs@agency.dev -> 2236c3de-5c9e-4b4c-8193-af20824081de
+2025-08-08 21:56:20 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'user2+1754657780.ahxthv@agency.dev', 'password': '***hidden***'}
+2025-08-08 21:56:20 | DEBUG | api_e2e | ← 401 in 4 ms | body={"detail":"Invalid credentials"}
+2025-08-08 21:56:20 | ERROR | api_e2e | login unexpected status 401, expected [200]; body={"detail":"Invalid credentials"}
+2025-08-08 21:56:20 | INFO | api_e2e | Login failed for user2+1754657780.ahxthv@agency.dev: login unexpected status 401, expected [200]; body={"detail":"Invalid credentials"}; will try register
+2025-08-08 21:56:20 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/register | headers={Authorization: Bearer } | body={'email': 'user2+1754657780.ahxthv@agency.dev', 'password': '***hidden***', 'full_name': 'Colleen Morrow', 'role': 'CLIENT'}
+2025-08-08 21:56:21 | DEBUG | api_e2e | ← 500 in 226 ms | body=Internal Server Error
+2025-08-08 21:56:21 | ERROR | api_e2e | register unexpected status 500, expected [200, 201]; body=Internal Server Error
+2025-08-08 21:56:21 | WARNING | api_e2e | register returned non-2xx: register unexpected status 500, expected [200, 201]; body=Internal Server Error — will try login anyway
+2025-08-08 21:56:21 | DEBUG | api_e2e | HTTP POST http://localhost:8080/auth/v1/token | headers={Authorization: Bearer } | body={'email': 'user2+1754657780.ahxthv@agency.dev', 'password': '***hidden***'}
+2025-08-08 21:56:21 | DEBUG | api_e2e | ← 200 in 212 ms | body={"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI4NzFlZTBjMi05ZTIyLTQ1OTYtYWZhOS03YWJiZmQxMzBlODYiLCJlbWFpbCI6InVzZXIyKzE3NTQ2NTc3ODAuYWh4dGh2QGFnZW5jeS5kZXYiLCJyb2xlIjoiQ0xJRU5UIiwidHlwZSI6ImFjY2VzcyIsImV4cCI6MTc1NDY1ODY4MX0.Xx_ASHRjT8B_4EBpndcQoKcys8lJ_uJIN0log_f-2Ss","refresh_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI4NzFlZTBjMi05ZTIyLTQ1OTYtYWZhOS03YWJiZmQxMzBlODYiLCJlbWFpbCI6InVzZXIyKzE3NTQ2NTc3ODAuYWh4dGh2QGFnZW5jeS5kZXYiLCJyb2xlIjoiQ0xJRU5UIiwidHlwZSI6InJlZnJlc2giLCJleHAiOjE3NTcyNDk3ODF9.hK54H_2APL3FgjfUNdmoHxJaC5BMfw3XokhticH5pKQ","token_type":"bearer"}
+2025-08-08 21:56:21 | INFO | api_e2e | Registered+Login OK: user2+1754657780.ahxthv@agency.dev -> 871ee0c2-9e22-4596-afa9-7abbfd130e86
+2025-08-08 21:56:21 | INFO | api_e2e | [1/3] Ensure profile for admin+1754657779.xaji0y@agency.dev (role=ADMIN)
+2025-08-08 21:56:21 | DEBUG | api_e2e | HTTP GET http://localhost:8080/profiles/v1/profiles/me | headers={Authorization: Bearer eyJhbGciOiJI...} | body={}
+2025-08-08 21:56:21 | DEBUG | api_e2e | ← 403 in 2 ms | body={"detail":"Not authenticated"}
+2025-08-08 21:56:21 | ERROR | api_e2e | profiles/me unexpected status 403, expected [200, 404]; body={"detail":"Not authenticated"}
diff --git a/scripts/api_e2e.py b/scripts/api_e2e.py
new file mode 100644
index 0000000..7e9f8e5
--- /dev/null
+++ b/scripts/api_e2e.py
@@ -0,0 +1,417 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+import base64
+import json
+import logging
+import os
+import random
+import string
+import sys
+import time
+from dataclasses import dataclass
+from logging.handlers import RotatingFileHandler
+from typing import Any, Dict, Iterable, List, Optional, Tuple
+from urllib.parse import urljoin
+
+import requests
+from faker import Faker
+
+# -------------------------
+# Конфигурация по умолчанию
+# -------------------------
+DEFAULT_BASE_URL = os.getenv("BASE_URL", "http://localhost:8080")
+DEFAULT_PASSWORD = os.getenv("PASS", "secret123")
+DEFAULT_CLIENTS = int(os.getenv("CLIENTS", "2"))
+DEFAULT_EMAIL_DOMAIN = os.getenv("EMAIL_DOMAIN", "agency.dev")
+DEFAULT_LOG_FILE = os.getenv("LOG_FILE", "logs/api.log")
+DEFAULT_TIMEOUT = float(os.getenv("HTTP_TIMEOUT", "10.0"))
+
+# -------------------------
+# Логирование
+# -------------------------
+def setup_logger(path: str) -> logging.Logger:
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ logger = logging.getLogger("api_e2e")
+ logger.setLevel(logging.DEBUG)
+
+ # Ротация логов: до 5 файлов по 5 МБ
+ file_handler = RotatingFileHandler(path, maxBytes=5 * 1024 * 1024, backupCount=5, encoding="utf-8")
+ file_handler.setLevel(logging.DEBUG)
+ file_handler.setFormatter(logging.Formatter(
+ fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
+ datefmt="%Y-%m-%d %H:%M:%S",
+ ))
+ logger.addHandler(file_handler)
+
+ # Консоль — INFO и короче
+ console = logging.StreamHandler(sys.stdout)
+ console.setLevel(logging.INFO)
+ console.setFormatter(logging.Formatter("%(levelname)s | %(message)s"))
+ logger.addHandler(console)
+ return logger
+
+# -------------------------
+# Утилиты
+# -------------------------
+def b64url_json(token_part: str) -> Dict[str, Any]:
+ """Декодирует часть JWT (payload) без валидации сигнатуры."""
+ s = token_part + "=" * (-len(token_part) % 4)
+ return json.loads(base64.urlsafe_b64decode(s).decode("utf-8"))
+
+def decode_jwt_sub(token: str) -> str:
+ try:
+ payload = b64url_json(token.split(".")[1])
+ return str(payload.get("sub", "")) # UUID пользователя
+ except Exception:
+ return ""
+
+def mask_token(token: Optional[str]) -> str:
+ if not token:
+ return ""
+ return token[:12] + "..." if len(token) > 12 else token
+
+def now_ms() -> int:
+ return int(time.time() * 1000)
+
+@dataclass
+class UserCreds:
+ id: str
+ email: str
+ access_token: str
+ role: str
+
+# -------------------------
+# Класс-клиент
+# -------------------------
+class APIE2E:
+
+ def __init__(self, base_url: str, logger: logging.Logger, timeout: float = DEFAULT_TIMEOUT) -> None:
+ self.base_url = base_url.rstrip("/") + "/"
+ self.logger = logger
+ self.timeout = timeout
+ self.sess = requests.Session()
+
+ self.urls = {
+ "auth": urljoin(self.base_url, "auth/"),
+ "profiles": urljoin(self.base_url, "profiles/"),
+ "match": urljoin(self.base_url, "match/"),
+ "chat": urljoin(self.base_url, "chat/"),
+ "payments": urljoin(self.base_url, "payments/"),
+ }
+
+ # --------- низкоуровневый запрос с логированием ----------
+ def req(self, method, url, token=None, body=None, expected=(200,), name=None):
+ headers = {"Accept": "application/json"}
+ if token:
+ headers["Authorization"] = f"Bearer {token}"
+
+ log_body = {}
+ if body:
+ log_body = dict(body)
+ for key in list(log_body.keys()):
+ if key.lower() in ("password", "token", "access_token", "refresh_token"):
+ log_body[key] = "***hidden***"
+
+ started = now_ms()
+ self.logger.debug(
+ f"HTTP {method} {url} | headers={{Authorization: Bearer {mask_token(token)}}} | body={log_body}"
+ )
+
+ try:
+ resp = self.sess.request(method=method, url=url, json=body, timeout=self.timeout)
+ except Exception as e:
+ duration = now_ms() - started
+ self.logger.error(f"{name or url} FAILED transport error: {e} ({duration} ms)")
+ raise
+
+ text = resp.text or ""
+ try:
+ data = resp.json() if text else {}
+ except ValueError:
+ data = {}
+
+ duration = now_ms() - started
+ self.logger.debug(f"← {resp.status_code} in {duration} ms | body={text[:2000]}")
+ if expected and resp.status_code not in expected:
+ msg = f"{name or url} unexpected status {resp.status_code}, expected {list(expected)}; body={text}"
+ self.logger.error(msg)
+ raise RuntimeError(msg)
+ return resp.status_code, data, text
+
+
+ # --------- health ----------
+ def wait_health(self, name: str, url: str, timeout_sec: int = 60) -> None:
+ self.logger.info(f"Waiting {name} health: {url}")
+ deadline = time.time() + timeout_sec
+ while time.time() < deadline:
+ try:
+ code, _, _ = self.req("GET", url, expected=(200,), name=f"{name}/health")
+ if code == 200:
+ self.logger.info(f"{name} is healthy")
+ return
+ except Exception:
+ pass
+ time.sleep(1)
+ raise TimeoutError(f"{name} not healthy in time: {url}")
+
+ # --------- auth ----------
+ def login(self, email: str, password: str) -> Tuple[str, str]:
+ url = urljoin(self.urls["auth"], "v1/token")
+ _, data, _ = self.req("POST", url, body={"email": email, "password": password}, expected=(200,), name="login")
+ token = data.get("access_token", "")
+ if not token:
+ raise RuntimeError("access_token is empty")
+ user_id = decode_jwt_sub(token)
+ if not user_id:
+ raise RuntimeError("cannot decode user id (sub) from token")
+ return user_id, token
+
+ def register(self, email: str, password: str, full_name: str, role: str) -> None:
+ url = urljoin(self.urls["auth"], "v1/register")
+ # /register в вашем бэке может возвращать 201/200, а иногда 500 при сериализации —
+ # поэтому не падаем на 500 сразу, а логинимся ниже.
+ try:
+ self.req(
+ "POST",
+ url,
+ body={"email": email, "password": password, "full_name": full_name, "role": role},
+ expected=(200, 201),
+ name="register",
+ )
+ except RuntimeError as e:
+ self.logger.warning(f"register returned non-2xx: {e} — will try login anyway")
+
+ def login_or_register(self, email: str, password: str, full_name: str, role: str) -> UserCreds:
+ # 1) пробуем логин
+ try:
+ uid, token = self.login(email, password)
+ self.logger.info(f"Login OK: {email} -> {uid}")
+ return UserCreds(id=uid, email=email, access_token=token, role=role)
+ except Exception as e:
+ self.logger.info(f"Login failed for {email}: {e}; will try register")
+
+ # 2) регистрируем (не фатально, если вернулся 500)
+ self.register(email, password, full_name, role)
+
+ # 3) снова логин
+ uid, token = self.login(email, password)
+ self.logger.info(f"Registered+Login OK: {email} -> {uid}")
+ return UserCreds(id=uid, email=email, access_token=token, role=role)
+
+ # --------- profiles ----------
+ def get_my_profile(self, token: str) -> Tuple[int, Dict[str, Any]]:
+ url = urljoin(self.urls["profiles"], "v1/profiles/me")
+ code, data, _ = self.req("GET", url, token=token, expected=(200, 404), name="profiles/me")
+ return code, data
+
+ def create_profile(
+ self,
+ token: str,
+ gender: str,
+ city: str,
+ languages: List[str],
+ interests: List[str],
+ ) -> Dict[str, Any]:
+ url = urljoin(self.urls["profiles"], "v1/profiles")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=token,
+ body={"gender": gender, "city": city, "languages": languages, "interests": interests},
+ expected=(200, 201),
+ name="profiles/create",
+ )
+ return data
+
+ def ensure_profile(
+ self, token: str, gender: str, city: str, languages: List[str], interests: List[str]
+ ) -> Dict[str, Any]:
+ code, p = self.get_my_profile(token)
+ if code == 200:
+ self.logger.info(f"Profile exists: id={p.get('id')}")
+ return p
+ self.logger.info("Profile not found -> creating")
+ p = self.create_profile(token, gender, city, languages, interests)
+ self.logger.info(f"Profile created: id={p.get('id')}")
+ return p
+
+ # --------- match ----------
+ def create_pair(self, admin_token: str, user_id_a: str, user_id_b: str, score: float, notes: str) -> Dict[str, Any]:
+ url = urljoin(self.urls["match"], "v1/pairs")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=admin_token,
+ body={"user_id_a": user_id_a, "user_id_b": user_id_b, "score": score, "notes": notes},
+ expected=(200, 201),
+ name="match/create_pair",
+ )
+ return data
+
+ # --------- chat ----------
+ def create_room(self, admin_token: str, title: str, participants: List[str]) -> Dict[str, Any]:
+ url = urljoin(self.urls["chat"], "v1/rooms")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=admin_token,
+ body={"title": title, "participants": participants},
+ expected=(200, 201),
+ name="chat/create_room",
+ )
+ return data
+
+ def send_message(self, admin_token: str, room_id: str, content: str) -> Dict[str, Any]:
+ url = urljoin(self.urls["chat"], f"v1/rooms/{room_id}/messages")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=admin_token,
+ body={"content": content},
+ expected=(200, 201),
+ name="chat/send_message",
+ )
+ return data
+
+ # --------- payments ----------
+ def create_invoice(
+ self, admin_token: str, client_id: str, amount: float, currency: str, description: str
+ ) -> Dict[str, Any]:
+ url = urljoin(self.urls["payments"], "v1/invoices")
+ _, data, _ = self.req(
+ "POST",
+ url,
+ token=admin_token,
+ body={"client_id": client_id, "amount": amount, "currency": currency, "description": description},
+ expected=(200, 201),
+ name="payments/create_invoice",
+ )
+ return data
+
+ def mark_invoice_paid(self, admin_token: str, invoice_id: str) -> Dict[str, Any]:
+ url = urljoin(self.urls["payments"], f"v1/invoices/{invoice_id}/mark-paid")
+ _, data, _ = self.req("POST", url, token=admin_token, expected=(200,), name="payments/mark_paid")
+ return data
+
+# -------------------------
+# Генерация данных
+# -------------------------
+GENDERS = ["female", "male", "other"]
+CITIES = [
+ "Moscow", "Saint Petersburg", "Kazan", "Novosibirsk", "Krasnodar",
+ "Yekaterinburg", "Minsk", "Kyiv", "Almaty", "Tbilisi",
+]
+LANG_POOL = ["ru", "en", "de", "fr", "es", "it", "zh", "uk", "kk", "tr", "pl"]
+INTR_POOL = ["music", "travel", "reading", "sports", "movies", "games", "cooking", "yoga", "art", "tech"]
+
+def pick_languages(n: int = 2) -> List[str]:
+ n = max(1, min(n, len(LANG_POOL)))
+ return sorted(random.sample(LANG_POOL, n))
+
+def pick_interests(n: int = 3) -> List[str]:
+ n = max(1, min(n, len(INTR_POOL)))
+ return sorted(random.sample(INTR_POOL, n))
+
+def random_email(prefix: str, domain: str) -> str:
+ suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
+ return f"{prefix}+{int(time.time())}.{suffix}@{domain}"
+
+# -------------------------
+# Основной сценарий
+# -------------------------
+def main():
+ import argparse
+
+ parser = argparse.ArgumentParser(description="E2E тест API брачного агентства с фейковыми анкетами.")
+ parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="Gateway base URL (по умолчанию http://localhost:8080)")
+ parser.add_argument("--clients", type=int, default=DEFAULT_CLIENTS, help="Количество клиентских аккаунтов (>=2)")
+ parser.add_argument("--password", default=DEFAULT_PASSWORD, help="Пароль для всех тестовых пользователей")
+ parser.add_argument("--email-domain", default=DEFAULT_EMAIL_DOMAIN, help="Домен e-mail для теста (напр. agency.dev)")
+ parser.add_argument("--log-file", default=DEFAULT_LOG_FILE, help="Файл логов (по умолчанию logs/api.log)")
+ parser.add_argument("--seed", type=int, default=42, help="Генератор случайных чисел (seed)")
+ args = parser.parse_args()
+
+ random.seed(args.seed)
+ fake = Faker()
+ logger = setup_logger(args.log_file)
+ logger.info("=== API E2E START ===")
+ logger.info(f"BASE_URL={args.base_url} clients={args.clients} domain={args.email_domain}")
+
+ if args.clients < 2:
+ logger.error("Нужно минимум 2 клиента (для пары).")
+ sys.exit(2)
+
+ api = APIE2E(args.base_url, logger)
+
+ # Health checks через gateway
+ api.wait_health("gateway/auth", urljoin(api.urls["auth"], "health"))
+ api.wait_health("profiles", urljoin(api.urls["profiles"], "health"))
+ api.wait_health("match", urljoin(api.urls["match"], "health"))
+ api.wait_health("chat", urljoin(api.urls["chat"], "health"))
+ api.wait_health("payments", urljoin(api.urls["payments"], "health"))
+
+ # Админ
+ admin_email = random_email("admin", args.email_domain)
+ admin_full = fake.name()
+ admin = api.login_or_register(admin_email, args.password, admin_full, role="ADMIN")
+
+ # Клиенты
+ clients: List[UserCreds] = []
+ for i in range(args.clients):
+ email = random_email(f"user{i+1}", args.email_domain)
+ full = fake.name()
+ u = api.login_or_register(email, args.password, full, role="CLIENT")
+ clients.append(u)
+
+ # Профили для всех
+ for i, u in enumerate([admin] + clients, start=1):
+ gender = random.choice(GENDERS)
+ city = random.choice(CITIES)
+ languages = pick_languages(random.choice([1, 2, 3]))
+ interests = pick_interests(random.choice([2, 3, 4]))
+ logger.info(f"[{i}/{1+len(clients)}] Ensure profile for {u.email} (role={u.role})")
+ api.ensure_profile(u.access_token, gender, city, languages, interests)
+
+ # Match‑пара между двумя случайными клиентами
+ a, b = random.sample(clients, 2)
+ score = round(random.uniform(0.6, 0.98), 2)
+ pair = api.create_pair(admin.access_token, a.id, b.id, score, notes="e2e generated")
+ pair_id = str(pair.get("id", ""))
+ logger.info(f"Match pair created: id={pair_id}, {a.email} ↔ {b.email}, score={score}")
+
+ # Чат‑комната и сообщение
+ room = api.create_room(admin.access_token, title=f"{a.email} & {b.email}", participants=[a.id, b.id])
+ room_id = str(room.get("id", ""))
+ msg = api.send_message(admin.access_token, room_id, content="Hello from admin (e2e)")
+ msg_id = str(msg.get("id", ""))
+ logger.info(f"Chat message sent: room={room_id}, msg={msg_id}")
+
+ # Счёт для первого клиента
+ amount = random.choice([99.0, 199.0, 299.0])
+ inv = api.create_invoice(admin.access_token, client_id=a.id, amount=amount, currency="USD",
+ description="Consultation (e2e)")
+ inv_id = str(inv.get("id", ""))
+ invp = api.mark_invoice_paid(admin.access_token, inv_id)
+ logger.info(f"Invoice paid: id={inv_id}, status={invp.get('status')}")
+
+ # Итог
+ summary = {
+ "admin": {"email": admin.email, "id": admin.id},
+ "clients": [{"email": c.email, "id": c.id} for c in clients],
+ "pair_id": pair_id,
+ "room_id": room_id,
+ "message_id": msg_id,
+ "invoice_id": inv_id,
+ "invoice_status": invp.get("status"),
+ }
+ logger.info("=== SUMMARY ===")
+ logger.info(json.dumps(summary, ensure_ascii=False, indent=2))
+ print(json.dumps(summary, ensure_ascii=False, indent=2)) # на stdout — короткое резюме
+
+if __name__ == "__main__":
+ try:
+ main()
+ except KeyboardInterrupt:
+ print("\nInterrupted.", file=sys.stderr)
+ sys.exit(130)
diff --git a/scripts/e2e.sh b/scripts/e2e.sh
new file mode 100755
index 0000000..85999b4
--- /dev/null
+++ b/scripts/e2e.sh
@@ -0,0 +1,208 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+
+BASE_URL="${BASE_URL:-http://localhost:8080}"
+AUTH="$BASE_URL/auth"; PROFILES="$BASE_URL/profiles"; MATCH="$BASE_URL/match"; CHAT="$BASE_URL/chat"; PAYMENTS="$BASE_URL/payments"
+GW_HEALTH_PATH="${GW_HEALTH_PATH:-/auth/health}"
+
+NC='\033[0m'; B='\033[1m'; G='\033[0;32m'; Y='\033[0;33m'; R='\033[0;31m'; C='\033[0;36m'
+TMP_DIR="$(mktemp -d)"
+trap 'rm -rf "$TMP_DIR"' EXIT
+
+require(){ command -v "$1" >/dev/null 2>&1 || { echo -e "${R}ERROR:${NC} '$1' is required" >&2; exit 1; }; }
+require curl; require python3
+log(){ echo -e "${C}[$(date +%H:%M:%S)]${NC} $*" >&2; }
+ok(){ echo -e "${G}✔${NC} $*" >&2; }
+warn(){ echo -e "${Y}⚠${NC} $*" >&2; }
+fail(){ echo -e "${R}✖${NC} $*" >&2; exit 1; }
+
+json_get(){ python3 - "$1" "$2" <<'PY'
+import sys, json, os
+f,p=sys.argv[1],sys.argv[2]
+if not os.path.exists(f): print(""); sys.exit(0)
+try: data=json.load(open(f))
+except: print(""); sys.exit(0)
+cur=data
+for k in p.split('.'):
+ if isinstance(cur,list):
+ try:k=int(k)
+ except: print(""); sys.exit(0)
+ cur=cur[k] if 0<=k
+python3 - "$1" "$2" <<'PY'
+import sys, json, base64
+t, claim = sys.argv[1], sys.argv[2]
+try:
+ b = t.split('.')[1]
+ b += '=' * (-len(b) % 4)
+ payload = json.loads(base64.urlsafe_b64decode(b).decode())
+ print(payload.get(claim,""))
+except Exception:
+ print("")
+PY
+}
+
+http_req(){
+ local METHOD="$1"; shift; local URL="$1"; shift
+ local TOKEN="${1:-}"; shift || true
+ local BODY="${1:-}"; shift || true
+ local RESP="${TMP_DIR}/resp_$(date +%s%N).json"
+ local args=(-sS --connect-timeout 5 --max-time 10 -X "$METHOD" "$URL" -o "$RESP" -w "%{http_code}")
+ [[ -n "$TOKEN" ]] && args+=(-H "Authorization: Bearer $TOKEN")
+ [[ -n "$BODY" ]] && args+=(-H "Content-Type: application/json" -d "$BODY")
+ local CODE; CODE="$(curl "${args[@]}" || true)"
+ [[ -e "$RESP" ]] || : > "$RESP"
+ echo "$CODE|$RESP"
+}
+
+expect_code(){ [[ "$2" == *"|${1}|"* || "$2" == "${1}|"* || "$2" == *"|${1}" || "$2" == "${1}" ]]; }
+
+wait_http(){
+ local NAME="$1" URL="$2" ALLOWED="${3:-200}" TRIES="${4:-60}"
+ log "Waiting ${NAME} at ${URL} (allowed: ${ALLOWED})"
+ for((i=1;i<=TRIES;i++)); do
+ local CODE; CODE="$(curl -s -o /dev/null -w "%{http_code}" "$URL" || true)"
+ if expect_code "$CODE" "$ALLOWED"; then ok "${NAME} is ready (${CODE})"; return 0; fi
+ sleep 1
+ done; fail "${NAME} not ready in time: ${URL}"
+}
+wait_health(){ wait_http "$1" "$2" "200" "${3:-60}"; }
+
+login_or_register(){ # echo "|"
+ local EMAIL="$1" PASS="$2" FULL="$3" ROLE="$4"
+ local BODY TOK TOKCODE TOKRESP ACCESS USER_ID
+
+ # 1) пытаемся логиниться
+ BODY=$(printf '{"email":"%s","password":"%s"}' "$EMAIL" "$PASS")
+ TOK="$(http_req POST "$AUTH/v1/token" "" "$BODY")"; TOKCODE="${TOK%%|*}"; TOKRESP="${TOK##*|}"
+ if expect_code "$TOKCODE" "200"; then
+ ACCESS="$(json_get "$TOKRESP" "access_token")"
+ USER_ID="$(jwt_get "$ACCESS" sub)"
+ [[ -n "$ACCESS" && -n "$USER_ID" ]] || fail "Login parse failed for $EMAIL"
+ ok "Login ok for $EMAIL"
+ echo "${USER_ID}|${ACCESS}"; return 0
+ fi
+ warn "Login failed for $EMAIL ($TOKCODE) → will register"
+
+ # 2) регистрируем
+ BODY=$(printf '{"email":"%s","password":"%s","full_name":"%s","role":"%s"}' "$EMAIL" "$PASS" "$FULL" "$ROLE")
+ local REG RESPCODE RESP; REG="$(http_req POST "$AUTH/v1/register" "" "$BODY")"
+ RESPCODE="${REG%%|*}"; RESP="${REG##*|}"
+ if expect_code "$RESPCODE" "201|200"; then
+ ok "Registered $EMAIL"
+ else
+ local MSG; MSG="$(json_get "$RESP" "detail")"
+ if [[ "$RESPCODE" == "400" && "$MSG" == "Email already in use" ]]; then
+ warn "Already exists: $EMAIL"
+ else
+ warn "Register response ($RESPCODE): $(cat "$RESP" 2>/dev/null || true)"
+ fi
+ fi
+
+ # 3) снова логин
+ TOK="$(http_req POST "$AUTH/v1/token" "" "$BODY")"; TOKCODE="${TOK%%|*}"; TOKRESP="${TOK##*|}"
+ expect_code "$TOKCODE" "200" || fail "Token failed (${TOKCODE}): $(cat "$TOKRESP")"
+ ACCESS="$(json_get "$TOKRESP" "access_token")"
+ USER_ID="$(jwt_get "$ACCESS" sub)"
+ [[ -n "$ACCESS" && -n "$USER_ID" ]] || fail "Login parse failed after register for $EMAIL"
+ echo "${USER_ID}|${ACCESS}"
+}
+
+ensure_profile(){ #
+ local TOKEN="$1" G="$2" CITY="$3" LANGS="$4" INTRS="$5"
+ [[ -n "$TOKEN" ]] || fail "Empty token in ensure_profile"
+
+ local ME MECODE MERESP; ME="$(http_req GET "$PROFILES/v1/profiles/me" "$TOKEN")"
+ MECODE="${ME%%|*}"; MERESP="${ME##*|}"
+ if [[ "$MECODE" == "200" ]]; then ok "Profile exists"; return 0
+ elif [[ "$MECODE" != "404" ]]; then warn "Unexpected /profiles/me $MECODE: $(cat "$MERESP")"; fi
+
+ local lj ij; IFS=',' read -r -a _l <<< "$LANGS"; lj="$(printf '%s\n' "${_l[@]}"|sed 's/^/"/;s/$/"/'|paste -sd, -)"
+ IFS=',' read -r -a _i <<< "$INTRS"; ij="$(printf '%s\n' "${_i[@]}"|sed 's/^/"/;s/$/"/'|paste -sd, -)"
+ local BODY; BODY=$(cat <&2
+ echo "BASE_URL: $BASE_URL" >&2; echo >&2
+
+ wait_health "gateway" "${BASE_URL}${GW_HEALTH_PATH}"
+ wait_health "auth" "$AUTH/health"; wait_health "profiles" "$PROFILES/health"
+ wait_health "match" "$MATCH/health"; wait_health "chat" "$CHAT/health"; wait_health "payments" "$PAYMENTS/health"
+
+ TS="$(date +%s)"
+ ADMIN_EMAIL="${ADMIN_EMAIL:-admin+$TS@agency.dev}"
+ ALICE_EMAIL="${ALICE_EMAIL:-alice+$TS@agency.dev}"
+ BOB_EMAIL="${BOB_EMAIL:-bob+$TS@agency.dev}"
+ PASS="${PASS:-secret123}"
+
+ log "Admin: ${ADMIN_EMAIL}"
+ IFS='|' read -r ADMIN_ID ADMIN_ACCESS < <(login_or_register "$ADMIN_EMAIL" "$PASS" "Admin" "ADMIN"); ok "Admin id: $ADMIN_ID"
+
+ log "Alice: ${ALICE_EMAIL}"
+ IFS='|' read -r ALICE_ID ALICE_ACCESS < <(login_or_register "$ALICE_EMAIL" "$PASS" "Alice" "CLIENT"); ok "Alice id: $ALICE_ID"
+
+ log "Bob: ${BOB_EMAIL}"
+ IFS='|' read -r BOB_ID BOB_ACCESS < <(login_or_register "$BOB_EMAIL" "$PASS" "Bob" "CLIENT"); ok "Bob id: $BOB_ID"
+
+ log "Profiles"
+ ensure_profile "$ADMIN_ACCESS" "other" "Moscow" "ru,en" "admin,ops"
+ ensure_profile "$ALICE_ACCESS" "female" "Moscow" "ru,en" "music,travel"
+ ensure_profile "$BOB_ACCESS" "male" "Moscow" "ru" "sports,reading"
+
+ log "Match Alice ↔ Bob"
+ BODY=$(printf '{"user_id_a":"%s","user_id_b":"%s","score":%.2f,"notes":"e2e smoke"}' "$ALICE_ID" "$BOB_ID" 0.87)
+ PAIR="$(http_req POST "$MATCH/v1/pairs" "$ADMIN_ACCESS" "$BODY")"; PCODE="${PAIR%%|*}"; PRESP="${PAIR##*|}"
+ expect_code "$PCODE" "201|200" || fail "Create pair failed (${PCODE}): $(cat "$PRESP")"
+ PAIR_ID="$(json_get "$PRESP" "id")"; ok "Pair: $PAIR_ID"
+
+ log "Chat"
+ BODY=$(printf '{"title":"Alice & Bob","participants":["%s","%s"]}' "$ALICE_ID" "$BOB_ID")
+ ROOM="$(http_req POST "$CHAT/v1/rooms" "$ADMIN_ACCESS" "$BODY")"; RCODE="${ROOM%%|*}"; RRESP="${ROOM##*|}"
+ expect_code "$RCODE" "201|200" || fail "Create room failed (${RCODE}): $(cat "$RRESP")"
+ ROOM_ID="$(json_get "$RRESP" "id")"; ok "Room: $ROOM_ID"
+
+ BODY='{"content":"Hello from admin (e2e)"}'
+ MSG="$(http_req POST "$CHAT/v1/rooms/$ROOM_ID/messages" "$ADMIN_ACCESS" "$BODY")"; MCODE="${MSG%%|*}"; MRESP="${MSG##*|}"
+ expect_code "$MCODE" "201|200" || fail "Send message failed (${MCODE}): $(cat "$MRESP")"
+ MSG_ID="$(json_get "$MRESP" "id")"; ok "Message: $MSG_ID"
+
+ log "Payments"
+ BODY=$(printf '{"client_id":"%s","amount":199.00,"currency":"USD","description":"Consultation (e2e)"}' "$ALICE_ID")
+ INV="$(http_req POST "$PAYMENTS/v1/invoices" "$ADMIN_ACCESS" "$BODY")"; INVCODE="${INV%%|*}"; INVRESP="${INV##*|}"
+ expect_code "$INVCODE" "201|200" || fail "Create invoice failed (${INVCODE}): $(cat "$INVRESP")"
+ INV_ID="$(json_get "$INVRESP" "id")"; ok "Invoice: $INV_ID"
+
+ PAID="$(http_req POST "$PAYMENTS/v1/invoices/$INV_ID/mark-paid" "$ADMIN_ACCESS")"; PDCODE="${PAID%%|*}"; PDRESP="${PAID##*|}"
+ expect_code "$PDCODE" "200" || fail "Mark paid failed (${PDCODE}): $(cat "$PDRESP")"
+ STATUS="$(json_get "$PDRESP" "status")"; [[ "$STATUS" == "paid" ]] || fail "Invoice not paid"
+ ok "Invoice status: $STATUS"
+
+ {
+ echo "=== E2E summary ==="
+ echo "Admin: ${ADMIN_EMAIL} (id: ${ADMIN_ID})"
+ echo "Alice: ${ALICE_EMAIL} (id: ${ALICE_ID})"
+ echo "Bob: ${BOB_EMAIL} (id: ${BOB_ID})"
+ echo "Pair: ${PAIR_ID}"
+ echo "Room: ${ROOM_ID} Message: ${MSG_ID}"
+ echo "Invoice:${INV_ID} Status: ${STATUS}"
+ } >&2
+
+ ok "E2E smoke test finished successfully."
+}
+main "$@"
diff --git a/scripts/fix_alembic.sh b/scripts/fix_alembic.sh
new file mode 100755
index 0000000..7abd070
--- /dev/null
+++ b/scripts/fix_alembic.sh
@@ -0,0 +1,54 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SERVICES=(auth profiles match chat payments)
+
+# Добавим импорт моделей в env.py, если его нет
+for s in "${SERVICES[@]}"; do
+ ENV="services/$s/alembic/env.py"
+ if ! grep -q "from app import models" "$ENV"; then
+ # вставим строку сразу после импорта Base
+ awk '
+ /from app\.db\.session import Base/ && !x {print; print "from app import models # noqa: F401"; x=1; next}
+ {print}
+ ' "$ENV" > "$ENV.tmp" && mv "$ENV.tmp" "$ENV"
+ echo "[fix] added 'from app import models' to $ENV"
+ fi
+done
+
+# Создадим шаблон mako для Alembic в каждом сервисе (если отсутствует)
+for s in "${SERVICES[@]}"; do
+ TPL="services/$s/alembic/script.py.mako"
+ if [[ ! -f "$TPL" ]]; then
+ mkdir -p "$(dirname "$TPL")"
+ cat > "$TPL" <<'MAKO'
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+"""
+
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision: str = '${up_revision}'
+down_revision: Union[str, None] = ${down_revision | repr}
+branch_labels: Union[str, Sequence[str], None] = ${branch_labels | repr}
+depends_on: Union[str, Sequence[str], None] = ${depends_on | repr}
+
+def upgrade() -> None:
+ pass
+
+
+def downgrade() -> None:
+ pass
+MAKO
+ echo "[fix] created $TPL"
+ fi
+done
+
+echo "✅ Alembic templates fixed."
+echo "Совет: предупреждение docker-compose про 'version' можно игнорировать или удалить строку 'version: \"3.9\"' из docker-compose.yml."
diff --git a/scripts/fix_email_validation.sh b/scripts/fix_email_validation.sh
new file mode 100755
index 0000000..2cb5f68
--- /dev/null
+++ b/scripts/fix_email_validation.sh
@@ -0,0 +1,18 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+FILE="services/auth/src/app/schemas/user.py"
+[ -f "$FILE" ] || { echo "Not found: $FILE"; exit 1; }
+
+tmp="$(mktemp)"
+awk '
+ BEGIN{incls=""}
+ /^class (UserRead|UserPublic|UserOut|UserResponse)\b/ {incls=$1}
+ incls!="" && /email: *EmailStr/ { sub(/EmailStr/, "str") }
+ /^class [A-Za-z_0-9]+\b/ && $2!=incls { incls="" }
+ { print }
+' "$FILE" > "$tmp" && mv "$tmp" "$FILE"
+
+echo "[auth] rebuilding..."
+docker compose build auth
+docker compose restart auth
\ No newline at end of file
diff --git a/scripts/fix_profiles_deps.sh b/scripts/fix_profiles_deps.sh
new file mode 100755
index 0000000..2785990
--- /dev/null
+++ b/scripts/fix_profiles_deps.sh
@@ -0,0 +1,27 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT="services/profiles/src/app"
+mkdir -p "$ROOT/db"
+
+# __init__.py чтобы пакет точно импортировался
+[[ -f "$ROOT/__init__.py" ]] || echo "# app package" > "$ROOT/__init__.py"
+[[ -f "$ROOT/db/__init__.py" ]] || echo "# db package" > "$ROOT/db/__init__.py"
+
+# deps.py с get_db()
+cat > "$ROOT/db/deps.py" <<'PY'
+from typing import Generator
+from sqlalchemy.orm import Session
+from app.db.session import SessionLocal # должен существовать в проекте
+
+def get_db() -> Generator[Session, None, None]:
+ db = SessionLocal()
+ try:
+ yield db
+ finally:
+ db.close()
+PY
+
+echo "[profiles] rebuilding..."
+docker compose build profiles
+docker compose restart profiles
diff --git a/scripts/fix_profiles_fk.sh b/scripts/fix_profiles_fk.sh
new file mode 100755
index 0000000..e0e84da
--- /dev/null
+++ b/scripts/fix_profiles_fk.sh
@@ -0,0 +1,62 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# 1) Обновим модель Photo: добавим ForeignKey + нормальную relationship
+cat > services/profiles/src/app/models/photo.py <<'PY'
+from __future__ import annotations
+import uuid
+from datetime import datetime
+
+from sqlalchemy import String, Boolean, DateTime, ForeignKey
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+from sqlalchemy.sql import func
+
+from app.db.session import Base
+
+class Photo(Base):
+ __tablename__ = "photos"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ profile_id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True),
+ ForeignKey("profiles.id", ondelete="CASCADE"),
+ index=True,
+ nullable=False,
+ )
+ url: Mapped[str] = mapped_column(String(500), nullable=False)
+ is_main: Mapped[bool] = mapped_column(Boolean, default=False)
+ status: Mapped[str] = mapped_column(String(16), default="pending") # pending/approved/rejected
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+
+ profile = relationship("Profile", back_populates="photos")
+PY
+
+# (необязательно, но полезно) поправим типы JSONB в Profile
+awk '
+ {print}
+ /languages:/ && $0 ~ /dict/ { sub(/dict \| None/, "list[str] | None"); print "# (autofixed: changed languages type to list[str])"}
+ /interests:/ && $0 ~ /dict/ { sub(/dict \| None/, "list[str] | None"); print "# (autofixed: changed interests type to list[str])"}
+' services/profiles/src/app/models/profile.py > services/profiles/src/app/models/profile.py.tmp \
+ && mv services/profiles/src/app/models/profile.py.tmp services/profiles/src/app/models/profile.py || true
+
+# 2) Сгенерируем ревизию Alembic (сравнить модели с БД)
+docker compose up -d postgres
+docker compose run --rm -v "$PWD/services/profiles":/app profiles \
+ sh -lc 'alembic revision --autogenerate -m "add FK photos.profile_id -> profiles.id"'
+
+# 3) Если автогенерация не добавила FK — вживлём вручную в последнюю ревизию
+LAST=$(ls -1t services/profiles/alembic/versions/*.py | head -n1)
+if ! grep -q "create_foreign_key" "$LAST"; then
+ # вставим импорт postgresql (на будущее) и create_foreign_key в upgrade()
+ sed -i '/import sqlalchemy as sa/a from sqlalchemy.dialects import postgresql' "$LAST"
+ awk '
+ BEGIN{done=0}
+ /def upgrade/ && done==0 {print; print " op.create_foreign_key("; print " '\''fk_photos_profile_id_profiles'\'',"; print " '\''photos'\'', '\''profiles'\'',"; print " ['\''profile_id'\''], ['\''id'\''],"; print " ondelete='\''CASCADE'\''"; print " )"; done=1; next}
+ {print}
+ ' "$LAST" > "$LAST.tmp" && mv "$LAST.tmp" "$LAST"
+fi
+
+# 4) Применим миграции и перезапустим сервис
+docker compose run --rm profiles alembic upgrade head
+docker compose restart profiles
diff --git a/scripts/fix_profiles_schema_uuid.sh b/scripts/fix_profiles_schema_uuid.sh
new file mode 100755
index 0000000..e7316f5
--- /dev/null
+++ b/scripts/fix_profiles_schema_uuid.sh
@@ -0,0 +1,44 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCHEMA="services/profiles/src/app/schemas/profile.py"
+mkdir -p "$(dirname "$SCHEMA")"
+
+cat > "$SCHEMA" <<'PY'
+from __future__ import annotations
+from typing import List
+from uuid import UUID
+
+try:
+ # Pydantic v2
+ from pydantic import BaseModel, Field, ConfigDict
+ _V2 = True
+except Exception:
+ # Pydantic v1 fallback
+ from pydantic import BaseModel, Field
+ ConfigDict = None
+ _V2 = False
+
+class ProfileBase(BaseModel):
+ gender: str
+ city: str
+ languages: List[str] = Field(default_factory=list)
+ interests: List[str] = Field(default_factory=list)
+
+class ProfileCreate(ProfileBase):
+ pass
+
+class ProfileOut(ProfileBase):
+ id: UUID
+ user_id: UUID
+
+ if _V2:
+ model_config = ConfigDict(from_attributes=True)
+ else:
+ class Config:
+ orm_mode = True
+PY
+
+echo "[profiles] rebuilding..."
+docker compose build profiles
+docker compose restart profiles
diff --git a/scripts/migrate.sh b/scripts/migrate.sh
new file mode 100755
index 0000000..feb04f8
--- /dev/null
+++ b/scripts/migrate.sh
@@ -0,0 +1,6 @@
+#!/usr/bin/env bash
+for s in auth profiles match chat payments; do
+ docker compose run --rm $s alembic revision --autogenerate -m "init"
+ docker compose run --rm $s alembic upgrade head
+
+done
diff --git a/scripts/models.sh b/scripts/models.sh
new file mode 100755
index 0000000..1469e6c
--- /dev/null
+++ b/scripts/models.sh
@@ -0,0 +1,1564 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# -------------------------------------------------------------------
+# Apply models + CRUD + API + JWT auth to the existing scaffold
+# Requires: the scaffold created earlier (services/* exist)
+# -------------------------------------------------------------------
+
+ROOT_DIR="."
+SERVICES=(auth profiles match chat payments)
+
+ensure_line() {
+ # ensure_line
+ local file="$1" ; shift
+ local line="$*"
+ grep -qxF "$line" "$file" 2>/dev/null || echo "$line" >> "$file"
+}
+
+write_file() {
+ # write_file <<'EOF' ... EOF
+ local path="$1"
+ mkdir -p "$(dirname "$path")"
+ # The content will be provided by heredoc by the caller
+ cat > "$path"
+}
+
+append_file() {
+ local path="$1"
+ mkdir -p "$(dirname "$path")"
+ cat >> "$path"
+}
+
+require_file() {
+ local path="$1"
+ if [[ ! -f "$path" ]]; then
+ echo "ERROR: Missing $path. Run scaffold.sh first." >&2
+ exit 1
+ fi
+}
+
+# Basic checks
+require_file docker-compose.yml
+
+# -------------------------------------------------------------------
+# 1) .env.example — добавить JWT настройки (общие для всех сервисов)
+# -------------------------------------------------------------------
+ENV_FILE=".env.example"
+require_file "$ENV_FILE"
+
+ensure_line "$ENV_FILE" "# ---------- JWT / Auth ----------"
+ensure_line "$ENV_FILE" "JWT_SECRET=devsecret_change_me"
+ensure_line "$ENV_FILE" "JWT_ALGORITHM=HS256"
+ensure_line "$ENV_FILE" "ACCESS_TOKEN_EXPIRES_MIN=15"
+ensure_line "$ENV_FILE" "REFRESH_TOKEN_EXPIRES_MIN=43200 # 30 days"
+
+# -------------------------------------------------------------------
+# 2) requirements.txt — добавить PyJWT везде, в auth — ещё passlib[bcrypt]
+# -------------------------------------------------------------------
+for s in "${SERVICES[@]}"; do
+ REQ="services/$s/requirements.txt"
+ require_file "$REQ"
+ ensure_line "$REQ" "PyJWT>=2.8"
+ if [[ "$s" == "auth" ]]; then
+ ensure_line "$REQ" "passlib[bcrypt]>=1.7"
+ fi
+done
+
+# -------------------------------------------------------------------
+# 3) Общая безопасность (JWT) для всех сервисов
+# В auth добавим + генерацию токенов, в остальных — верификация и RBAC
+# -------------------------------------------------------------------
+for s in "${SERVICES[@]}"; do
+ SEC="services/$s/src/app/core/security.py"
+ mkdir -p "$(dirname "$SEC")"
+ if [[ "$s" == "auth" ]]; then
+ write_file "$SEC" <<'PY'
+from __future__ import annotations
+import os
+from datetime import datetime, timedelta, timezone
+from enum import Enum
+from typing import Any, Callable, Optional
+
+import jwt
+from fastapi import Depends, HTTPException, status
+from fastapi.security import OAuth2PasswordBearer
+from pydantic import BaseModel
+
+JWT_SECRET = os.getenv("JWT_SECRET", "devsecret_change_me")
+JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
+ACCESS_TOKEN_EXPIRES_MIN = int(os.getenv("ACCESS_TOKEN_EXPIRES_MIN", "15"))
+REFRESH_TOKEN_EXPIRES_MIN = int(os.getenv("REFRESH_TOKEN_EXPIRES_MIN", "43200"))
+
+class TokenType(str, Enum):
+ access = "access"
+ refresh = "refresh"
+
+class UserClaims(BaseModel):
+ sub: str
+ email: str
+ role: str
+ type: str
+ exp: int
+
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/v1/token")
+
+def _create_token(token_type: TokenType, *, sub: str, email: str, role: str, expires_minutes: int) -> str:
+ now = datetime.now(timezone.utc)
+ exp = now + timedelta(minutes=expires_minutes)
+ payload: dict[str, Any] = {
+ "sub": sub,
+ "email": email,
+ "role": role,
+ "type": token_type.value,
+ "exp": int(exp.timestamp()),
+ }
+ return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
+
+def create_access_token(*, sub: str, email: str, role: str) -> str:
+ return _create_token(TokenType.access, sub=sub, email=email, role=role, expires_minutes=ACCESS_TOKEN_EXPIRES_MIN)
+
+def create_refresh_token(*, sub: str, email: str, role: str) -> str:
+ return _create_token(TokenType.refresh, sub=sub, email=email, role=role, expires_minutes=REFRESH_TOKEN_EXPIRES_MIN)
+
+def decode_token(token: str) -> UserClaims:
+ try:
+ payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
+ return UserClaims(**payload)
+ except jwt.ExpiredSignatureError:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
+ except jwt.PyJWTError:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
+
+def get_current_user(token: str = Depends(oauth2_scheme)) -> UserClaims:
+ return decode_token(token)
+
+def require_roles(*roles: str) -> Callable[[UserClaims], UserClaims]:
+ def dep(user: UserClaims = Depends(get_current_user)) -> UserClaims:
+ if roles and user.role not in roles:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient role")
+ return user
+ return dep
+PY
+ else
+ write_file "$SEC" <<'PY'
+from __future__ import annotations
+import os
+from enum import Enum
+from typing import Any, Callable
+
+import jwt
+from fastapi import Depends, HTTPException, status
+from fastapi.security import OAuth2PasswordBearer
+from pydantic import BaseModel
+
+JWT_SECRET = os.getenv("JWT_SECRET", "devsecret_change_me")
+JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
+
+class UserClaims(BaseModel):
+ sub: str
+ email: str
+ role: str
+ type: str
+ exp: int
+
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/v1/token")
+
+def decode_token(token: str) -> UserClaims:
+ try:
+ payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
+ return UserClaims(**payload)
+ except jwt.ExpiredSignatureError:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
+ except jwt.PyJWTError:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
+
+def get_current_user(token: str = Depends(oauth2_scheme)) -> UserClaims:
+ return decode_token(token)
+
+def require_roles(*roles: str):
+ def dep(user: UserClaims = Depends(get_current_user)) -> UserClaims:
+ if roles and user.role not in roles:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient role")
+ return user
+ return dep
+PY
+ fi
+done
+
+# -------------------------------------------------------------------
+# 4) AUTH service — модели, CRUD, токены, эндпоинты
+# -------------------------------------------------------------------
+# models
+write_file services/auth/src/app/models/user.py <<'PY'
+from __future__ import annotations
+import uuid
+from datetime import datetime
+from enum import Enum
+
+from sqlalchemy import String, Boolean, DateTime
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import Mapped, mapped_column
+from sqlalchemy.sql import func
+
+from app.db.session import Base
+
+class Role(str, Enum):
+ ADMIN = "ADMIN"
+ MATCHMAKER = "MATCHMAKER"
+ CLIENT = "CLIENT"
+
+class User(Base):
+ __tablename__ = "users"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
+ password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
+ full_name: Mapped[str | None] = mapped_column(String(255), default=None)
+ role: Mapped[str] = mapped_column(String(32), default=Role.CLIENT.value, nullable=False)
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
+PY
+
+write_file services/auth/src/app/models/__init__.py <<'PY'
+from .user import User, Role # noqa: F401
+PY
+
+# schemas
+write_file services/auth/src/app/schemas/user.py <<'PY'
+from __future__ import annotations
+from typing import Optional
+from pydantic import BaseModel, EmailStr, ConfigDict
+
+class UserBase(BaseModel):
+ email: EmailStr
+ full_name: Optional[str] = None
+ role: str = "CLIENT"
+ is_active: bool = True
+
+class UserCreate(BaseModel):
+ email: EmailStr
+ password: str
+ full_name: Optional[str] = None
+ role: str = "CLIENT"
+
+class UserUpdate(BaseModel):
+ full_name: Optional[str] = None
+ role: Optional[str] = None
+ is_active: Optional[bool] = None
+ password: Optional[str] = None
+
+class UserRead(BaseModel):
+ id: str
+ email: EmailStr
+ full_name: Optional[str] = None
+ role: str
+ is_active: bool
+ model_config = ConfigDict(from_attributes=True)
+
+class LoginRequest(BaseModel):
+ email: EmailStr
+ password: str
+
+class TokenPair(BaseModel):
+ access_token: str
+ refresh_token: str
+ token_type: str = "bearer"
+PY
+
+# passwords
+write_file services/auth/src/app/core/passwords.py <<'PY'
+from passlib.context import CryptContext
+
+_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
+
+def hash_password(p: str) -> str:
+ return _pwd.hash(p)
+
+def verify_password(p: str, hashed: str) -> bool:
+ return _pwd.verify(p, hashed)
+PY
+
+# repositories
+write_file services/auth/src/app/repositories/user_repository.py <<'PY'
+from __future__ import annotations
+from typing import Optional, Sequence
+from sqlalchemy.orm import Session
+from sqlalchemy import select, update, delete
+
+from app.models.user import User
+
+class UserRepository:
+ def __init__(self, db: Session):
+ self.db = db
+
+ def get(self, user_id) -> Optional[User]:
+ return self.db.get(User, user_id)
+
+ def get_by_email(self, email: str) -> Optional[User]:
+ stmt = select(User).where(User.email == email)
+ return self.db.execute(stmt).scalar_one_or_none()
+
+ def list(self, *, offset: int = 0, limit: int = 50) -> Sequence[User]:
+ stmt = select(User).offset(offset).limit(limit).order_by(User.created_at.desc())
+ return self.db.execute(stmt).scalars().all()
+
+ def create(self, *, email: str, password_hash: str, full_name: str | None, role: str) -> User:
+ user = User(email=email, password_hash=password_hash, full_name=full_name, role=role)
+ self.db.add(user)
+ self.db.commit()
+ self.db.refresh(user)
+ return user
+
+ def update(self, user: User, **fields) -> User:
+ for k, v in fields.items():
+ if v is not None:
+ setattr(user, k, v)
+ self.db.add(user)
+ self.db.commit()
+ self.db.refresh(user)
+ return user
+
+ def delete(self, user: User) -> None:
+ self.db.delete(user)
+ self.db.commit()
+PY
+
+# services
+write_file services/auth/src/app/services/user_service.py <<'PY'
+from __future__ import annotations
+from typing import Optional
+from sqlalchemy.orm import Session
+
+from app.repositories.user_repository import UserRepository
+from app.core.passwords import hash_password, verify_password
+from app.models.user import User
+
+class UserService:
+ def __init__(self, db: Session):
+ self.repo = UserRepository(db)
+
+ # CRUD
+ def create_user(self, *, email: str, password: str, full_name: str | None, role: str) -> User:
+ if self.repo.get_by_email(email):
+ raise ValueError("Email already in use")
+ pwd_hash = hash_password(password)
+ return self.repo.create(email=email, password_hash=pwd_hash, full_name=full_name, role=role)
+
+ def get_user(self, user_id) -> Optional[User]:
+ return self.repo.get(user_id)
+
+ def get_by_email(self, email: str) -> Optional[User]:
+ return self.repo.get_by_email(email)
+
+ def list_users(self, *, offset: int = 0, limit: int = 50):
+ return self.repo.list(offset=offset, limit=limit)
+
+ def update_user(self, user: User, *, full_name: str | None = None, role: str | None = None,
+ is_active: bool | None = None, password: str | None = None) -> User:
+ fields = {}
+ if full_name is not None: fields["full_name"] = full_name
+ if role is not None: fields["role"] = role
+ if is_active is not None: fields["is_active"] = is_active
+ if password: fields["password_hash"] = hash_password(password)
+ return self.repo.update(user, **fields)
+
+ def delete_user(self, user: User) -> None:
+ self.repo.delete(user)
+
+ # Auth
+ def authenticate(self, *, email: str, password: str) -> Optional[User]:
+ user = self.repo.get_by_email(email)
+ if not user or not user.is_active:
+ return None
+ if not verify_password(password, user.password_hash):
+ return None
+ return user
+PY
+
+# api routes
+write_file services/auth/src/app/api/routes/auth.py <<'PY'
+from __future__ import annotations
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy.orm import Session
+
+from app.db.session import get_db
+from app.schemas.user import UserCreate, LoginRequest, TokenPair, UserRead
+from app.services.user_service import UserService
+from app.core.security import create_access_token, create_refresh_token, get_current_user, UserClaims
+
+router = APIRouter(prefix="/v1", tags=["auth"])
+
+@router.post("/register", response_model=UserRead, status_code=201)
+def register(payload: UserCreate, db: Session = Depends(get_db)):
+ svc = UserService(db)
+ try:
+ user = svc.create_user(email=payload.email, password=payload.password,
+ full_name=payload.full_name, role=payload.role)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+ return user
+
+@router.post("/token", response_model=TokenPair)
+def token(payload: LoginRequest, db: Session = Depends(get_db)):
+ svc = UserService(db)
+ user = svc.authenticate(email=payload.email, password=payload.password)
+ if not user:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
+ access = create_access_token(sub=str(user.id), email=user.email, role=user.role)
+ refresh = create_refresh_token(sub=str(user.id), email=user.email, role=user.role)
+ return TokenPair(access_token=access, refresh_token=refresh)
+
+class RefreshRequest(LoginRequest.__class__):
+ refresh_token: str # type: ignore
+
+@router.post("/refresh", response_model=TokenPair)
+def refresh_token(req: dict):
+ # expects: {"refresh_token": ""}
+ from app.core.security import decode_token
+ token = req.get("refresh_token")
+ if not token:
+ raise HTTPException(status_code=400, detail="Missing refresh_token")
+ claims = decode_token(token)
+ if claims.type != "refresh":
+ raise HTTPException(status_code=400, detail="Not a refresh token")
+ access = create_access_token(sub=claims.sub, email=claims.email, role=claims.role)
+ refresh = create_refresh_token(sub=claims.sub, email=claims.email, role=claims.role)
+ return TokenPair(access_token=access, refresh_token=refresh)
+
+@router.get("/me", response_model=UserRead)
+def me(claims: UserClaims = Depends(get_current_user), db: Session = Depends(get_db)):
+ svc = UserService(db)
+ u = svc.get_user(claims.sub)
+ if not u:
+ raise HTTPException(status_code=404, detail="User not found")
+ return u
+PY
+
+write_file services/auth/src/app/api/routes/users.py <<'PY'
+from __future__ import annotations
+from fastapi import APIRouter, Depends, HTTPException, Query, status
+from sqlalchemy.orm import Session
+
+from app.db.session import get_db
+from app.core.security import require_roles
+from app.schemas.user import UserRead, UserUpdate, UserCreate
+from app.services.user_service import UserService
+
+router = APIRouter(prefix="/v1/users", tags=["users"])
+
+@router.get("", response_model=list[UserRead])
+def list_users(offset: int = 0, limit: int = Query(50, le=200), db: Session = Depends(get_db),
+ _: dict = Depends(require_roles("ADMIN"))):
+ return UserService(db).list_users(offset=offset, limit=limit)
+
+@router.post("", response_model=UserRead, status_code=201)
+def create_user(payload: UserCreate, db: Session = Depends(get_db),
+ _: dict = Depends(require_roles("ADMIN"))):
+ try:
+ return UserService(db).create_user(email=payload.email, password=payload.password,
+ full_name=payload.full_name, role=payload.role)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+
+@router.get("/{user_id}", response_model=UserRead)
+def get_user(user_id: str, db: Session = Depends(get_db),
+ _: dict = Depends(require_roles("ADMIN"))):
+ u = UserService(db).get_user(user_id)
+ if not u:
+ raise HTTPException(status_code=404, detail="User not found")
+ return u
+
+@router.patch("/{user_id}", response_model=UserRead)
+def update_user(user_id: str, payload: UserUpdate, db: Session = Depends(get_db),
+ _: dict = Depends(require_roles("ADMIN"))):
+ svc = UserService(db)
+ u = svc.get_user(user_id)
+ if not u:
+ raise HTTPException(status_code=404, detail="User not found")
+ return svc.update_user(u, full_name=payload.full_name, role=payload.role,
+ is_active=payload.is_active, password=payload.password)
+
+@router.delete("/{user_id}", status_code=204)
+def delete_user(user_id: str, db: Session = Depends(get_db),
+ _: dict = Depends(require_roles("ADMIN"))):
+ svc = UserService(db)
+ u = svc.get_user(user_id)
+ if not u:
+ return
+ svc.delete_user(u)
+PY
+
+# main.py update for auth
+write_file services/auth/src/app/main.py <<'PY'
+from fastapi import FastAPI
+from .api.routes.ping import router as ping_router
+from .api.routes.auth import router as auth_router
+from .api.routes.users import router as users_router
+
+app = FastAPI(title="AUTH Service")
+
+@app.get("/health")
+def health():
+ return {"status": "ok", "service": "auth"}
+
+app.include_router(ping_router, prefix="/v1")
+app.include_router(auth_router)
+app.include_router(users_router)
+PY
+
+# -------------------------------------------------------------------
+# 5) PROFILES service — Profile + Photo CRUD + поиск
+# -------------------------------------------------------------------
+write_file services/profiles/src/app/models/profile.py <<'PY'
+from __future__ import annotations
+import uuid
+from datetime import date, datetime
+
+from sqlalchemy import String, Date, DateTime, Text
+from sqlalchemy.dialects.postgresql import UUID, JSONB
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+from sqlalchemy.sql import func
+
+from app.db.session import Base
+
+class Profile(Base):
+ __tablename__ = "profiles"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
+ gender: Mapped[str] = mapped_column(String(16), nullable=False) # male/female/other
+ birthdate: Mapped[date | None] = mapped_column(Date, default=None)
+ city: Mapped[str | None] = mapped_column(String(120), default=None)
+ bio: Mapped[str | None] = mapped_column(Text, default=None)
+ languages: Mapped[dict | None] = mapped_column(JSONB, default=list) # e.g., ["ru","en"]
+ interests: Mapped[dict | None] = mapped_column(JSONB, default=list)
+ preferences: Mapped[dict | None] = mapped_column(JSONB, default=dict)
+ verification_status: Mapped[str] = mapped_column(String(16), default="unverified")
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
+
+ photos = relationship("Photo", back_populates="profile", cascade="all, delete-orphan")
+PY
+
+write_file services/profiles/src/app/models/photo.py <<'PY'
+from __future__ import annotations
+import uuid
+from datetime import datetime
+
+from sqlalchemy import String, Boolean, DateTime, ForeignKey
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+from sqlalchemy.sql import func
+
+from app.db.session import Base
+
+class Photo(Base):
+ __tablename__ = "photos"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ profile_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
+ url: Mapped[str] = mapped_column(String(500), nullable=False)
+ is_main: Mapped[bool] = mapped_column(Boolean, default=False)
+ status: Mapped[str] = mapped_column(String(16), default="pending") # pending/approved/rejected
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+
+ profile = relationship("Profile", back_populates="photos", primaryjoin="Photo.profile_id==Profile.id", viewonly=True)
+PY
+
+write_file services/profiles/src/app/models/__init__.py <<'PY'
+from .profile import Profile # noqa
+from .photo import Photo # noqa
+PY
+
+write_file services/profiles/src/app/schemas/profile.py <<'PY'
+from __future__ import annotations
+from datetime import date
+from typing import Optional, Any
+from pydantic import BaseModel, ConfigDict
+
+class PhotoCreate(BaseModel):
+ url: str
+ is_main: bool = False
+
+class PhotoRead(BaseModel):
+ id: str
+ url: str
+ is_main: bool
+ status: str
+ model_config = ConfigDict(from_attributes=True)
+
+class ProfileCreate(BaseModel):
+ gender: str
+ birthdate: Optional[date] = None
+ city: Optional[str] = None
+ bio: Optional[str] = None
+ languages: Optional[list[str]] = None
+ interests: Optional[list[str]] = None
+ preferences: Optional[dict[str, Any]] = None
+
+class ProfileUpdate(BaseModel):
+ gender: Optional[str] = None
+ birthdate: Optional[date] = None
+ city: Optional[str] = None
+ bio: Optional[str] = None
+ languages: Optional[list[str]] = None
+ interests: Optional[list[str]] = None
+ preferences: Optional[dict[str, Any]] = None
+ verification_status: Optional[str] = None
+
+class ProfileRead(BaseModel):
+ id: str
+ user_id: str
+ gender: str
+ birthdate: Optional[date] = None
+ city: Optional[str] = None
+ bio: Optional[str] = None
+ languages: Optional[list[str]] = None
+ interests: Optional[list[str]] = None
+ preferences: Optional[dict] = None
+ verification_status: str
+ model_config = ConfigDict(from_attributes=True)
+PY
+
+write_file services/profiles/src/app/repositories/profile_repository.py <<'PY'
+from __future__ import annotations
+from typing import Optional, Sequence
+from datetime import date, timedelta
+
+from sqlalchemy import select, and_
+from sqlalchemy.orm import Session
+
+from app.models.profile import Profile
+from app.models.photo import Photo
+
+class ProfileRepository:
+ def __init__(self, db: Session):
+ self.db = db
+
+ # Profile CRUD
+ def create_profile(self, *, user_id, **fields) -> Profile:
+ p = Profile(user_id=user_id, **fields)
+ self.db.add(p)
+ self.db.commit()
+ self.db.refresh(p)
+ return p
+
+ def get_profile(self, profile_id) -> Optional[Profile]:
+ return self.db.get(Profile, profile_id)
+
+ def get_by_user(self, user_id) -> Optional[Profile]:
+ stmt = select(Profile).where(Profile.user_id == user_id)
+ return self.db.execute(stmt).scalar_one_or_none()
+
+ def update_profile(self, profile: Profile, **fields) -> Profile:
+ for k, v in fields.items():
+ if v is not None:
+ setattr(profile, k, v)
+ self.db.add(profile)
+ self.db.commit()
+ self.db.refresh(profile)
+ return profile
+
+ def delete_profile(self, profile: Profile) -> None:
+ self.db.delete(profile)
+ self.db.commit()
+
+ def list_profiles(self, *, gender: str | None = None, city: str | None = None,
+ age_min: int | None = None, age_max: int | None = None,
+ offset: int = 0, limit: int = 50) -> Sequence[Profile]:
+ stmt = select(Profile)
+ conds = []
+ if gender:
+ conds.append(Profile.gender == gender)
+ if city:
+ conds.append(Profile.city == city)
+ # Age filter -> birthdate between (today - age_max) and (today - age_min)
+ if age_min is not None or age_max is not None:
+ today = date.today()
+ if age_min is not None:
+ max_birthdate = date(today.year - age_min, today.month, today.day)
+ conds.append(Profile.birthdate <= max_birthdate)
+ if age_max is not None:
+ min_birthdate = date(today.year - age_max - 1, today.month, today.day) + timedelta(days=1)
+ conds.append(Profile.birthdate >= min_birthdate)
+ if conds:
+ stmt = stmt.where(and_(*conds))
+ stmt = stmt.offset(offset).limit(limit).order_by(Profile.created_at.desc())
+ return self.db.execute(stmt).scalars().all()
+
+ # Photos
+ def add_photo(self, *, profile_id, url: str, is_main: bool) -> Photo:
+ photo = Photo(profile_id=profile_id, url=url, is_main=is_main)
+ self.db.add(photo)
+ if is_main:
+ # unset other main photos
+ self.db.execute(select(Photo).where(Photo.profile_id == profile_id))
+ self.db.commit()
+ self.db.refresh(photo)
+ return photo
+
+ def list_photos(self, *, profile_id) -> Sequence[Photo]:
+ stmt = select(Photo).where(Photo.profile_id == profile_id)
+ return self.db.execute(stmt).scalars().all()
+
+ def get_photo(self, photo_id) -> Optional[Photo]:
+ return self.db.get(Photo, photo_id)
+
+ def delete_photo(self, photo: Photo) -> None:
+ self.db.delete(photo)
+ self.db.commit()
+PY
+
+write_file services/profiles/src/app/services/profile_service.py <<'PY'
+from __future__ import annotations
+from sqlalchemy.orm import Session
+from typing import Optional
+
+from app.repositories.profile_repository import ProfileRepository
+from app.models.profile import Profile
+from app.models.photo import Photo
+
+class ProfileService:
+ def __init__(self, db: Session):
+ self.repo = ProfileRepository(db)
+
+ def create_profile(self, *, user_id, **fields) -> Profile:
+ return self.repo.create_profile(user_id=user_id, **fields)
+
+ def get_profile(self, profile_id) -> Optional[Profile]:
+ return self.repo.get_profile(profile_id)
+
+ def get_by_user(self, user_id) -> Optional[Profile]:
+ return self.repo.get_by_user(user_id)
+
+ def update_profile(self, profile: Profile, **fields) -> Profile:
+ return self.repo.update_profile(profile, **fields)
+
+ def delete_profile(self, profile: Profile) -> None:
+ return self.repo.delete_profile(profile)
+
+ def list_profiles(self, **filters):
+ return self.repo.list_profiles(**filters)
+
+ # photos
+ def add_photo(self, profile_id, *, url: str, is_main: bool) -> Photo:
+ return self.repo.add_photo(profile_id=profile_id, url=url, is_main=is_main)
+
+ def list_photos(self, profile_id):
+ return self.repo.list_photos(profile_id=profile_id)
+
+ def get_photo(self, photo_id) -> Photo | None:
+ return self.repo.get_photo(photo_id)
+
+ def delete_photo(self, photo: Photo) -> None:
+ self.repo.delete_photo(photo)
+PY
+
+write_file services/profiles/src/app/api/routes/profiles.py <<'PY'
+from __future__ import annotations
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy.orm import Session
+
+from app.db.session import get_db
+from app.core.security import get_current_user, require_roles, UserClaims
+from app.schemas.profile import ProfileCreate, ProfileRead, ProfileUpdate, PhotoCreate, PhotoRead
+from app.services.profile_service import ProfileService
+
+router = APIRouter(prefix="/v1", tags=["profiles"])
+
+@router.post("/profiles", response_model=ProfileRead, status_code=201)
+def create_profile(payload: ProfileCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ svc = ProfileService(db)
+ if svc.get_by_user(user.sub):
+ raise HTTPException(status_code=400, detail="Profile already exists")
+ p = svc.create_profile(user_id=user.sub, **payload.model_dump(exclude_none=True))
+ return p
+
+@router.get("/profiles/me", response_model=ProfileRead)
+def get_my_profile(db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ svc = ProfileService(db)
+ p = svc.get_by_user(user.sub)
+ if not p:
+ raise HTTPException(status_code=404, detail="Profile not found")
+ return p
+
+@router.get("/profiles", response_model=list[ProfileRead])
+def list_profiles(gender: str | None = None, city: str | None = None,
+ age_min: int | None = Query(None, ge=18, le=120),
+ age_max: int | None = Query(None, ge=18, le=120),
+ offset: int = 0, limit: int = Query(50, le=200),
+ db: Session = Depends(get_db),
+ _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))):
+ return ProfileService(db).list_profiles(gender=gender, city=city, age_min=age_min, age_max=age_max, offset=offset, limit=limit)
+
+@router.get("/profiles/{profile_id}", response_model=ProfileRead)
+def get_profile(profile_id: str, db: Session = Depends(get_db), _: UserClaims = Depends(get_current_user)):
+ p = ProfileService(db).get_profile(profile_id)
+ if not p:
+ raise HTTPException(status_code=404, detail="Profile not found")
+ return p
+
+@router.patch("/profiles/{profile_id}", response_model=ProfileRead)
+def update_profile(profile_id: str, payload: ProfileUpdate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ svc = ProfileService(db)
+ p = svc.get_profile(profile_id)
+ if not p:
+ raise HTTPException(status_code=404, detail="Profile not found")
+ if str(p.user_id) != user.sub and user.role not in ("ADMIN","MATCHMAKER"):
+ raise HTTPException(status_code=403, detail="Not allowed")
+ return svc.update_profile(p, **payload.model_dump(exclude_none=True))
+
+@router.delete("/profiles/{profile_id}", status_code=204)
+def delete_profile(profile_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ svc = ProfileService(db)
+ p = svc.get_profile(profile_id)
+ if not p:
+ return
+ if str(p.user_id) != user.sub and user.role not in ("ADMIN","MATCHMAKER"):
+ raise HTTPException(status_code=403, detail="Not allowed")
+ svc.delete_profile(p)
+
+# Photos
+@router.post("/profiles/{profile_id}/photos", response_model=PhotoRead, status_code=201)
+def add_photo(profile_id: str, payload: PhotoCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ svc = ProfileService(db)
+ p = svc.get_profile(profile_id)
+ if not p:
+ raise HTTPException(status_code=404, detail="Profile not found")
+ if str(p.user_id) != user.sub and user.role not in ("ADMIN","MATCHMAKER"):
+ raise HTTPException(status_code=403, detail="Not allowed")
+ photo = svc.add_photo(profile_id, url=payload.url, is_main=payload.is_main)
+ return photo
+
+@router.get("/profiles/{profile_id}/photos", response_model=list[PhotoRead])
+def list_photos(profile_id: str, db: Session = Depends(get_db), _: UserClaims = Depends(get_current_user)):
+ svc = ProfileService(db)
+ return svc.list_photos(profile_id)
+
+@router.delete("/photos/{photo_id}", status_code=204)
+def delete_photo(photo_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ svc = ProfileService(db)
+ photo = svc.get_photo(photo_id)
+ if not photo:
+ return
+ # Lookup profile to check ownership
+ p = svc.get_profile(photo.profile_id)
+ if not p or (str(p.user_id) != user.sub and user.role not in ("ADMIN","MATCHMAKER")):
+ raise HTTPException(status_code=403, detail="Not allowed")
+ svc.delete_photo(photo)
+PY
+
+# main.py for profiles
+write_file services/profiles/src/app/main.py <<'PY'
+from fastapi import FastAPI
+from .api.routes.ping import router as ping_router
+from .api.routes.profiles import router as profiles_router
+
+app = FastAPI(title="PROFILES Service")
+
+@app.get("/health")
+def health():
+ return {"status": "ok", "service": "profiles"}
+
+app.include_router(ping_router, prefix="/v1")
+app.include_router(profiles_router)
+PY
+
+# -------------------------------------------------------------------
+# 6) MATCH service — пары и статусы (proposed/accepted/rejected/blocked)
+# -------------------------------------------------------------------
+write_file services/match/src/app/models/pair.py <<'PY'
+from __future__ import annotations
+import uuid
+from datetime import datetime
+from sqlalchemy import String, Float, DateTime
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import Mapped, mapped_column
+from sqlalchemy.sql import func
+
+from app.db.session import Base
+
+class MatchPair(Base):
+ __tablename__ = "match_pairs"
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ # User IDs to validate permissions; profile IDs можно добавить позже
+ user_id_a: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
+ user_id_b: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
+ status: Mapped[str] = mapped_column(String(16), default="proposed") # proposed/accepted/rejected/blocked
+ score: Mapped[float | None] = mapped_column(Float, default=None)
+ notes: Mapped[str | None] = mapped_column(String(1000), default=None)
+ created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True) # matchmaker/admin
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
+PY
+
+write_file services/match/src/app/models/__init__.py <<'PY'
+from .pair import MatchPair # noqa
+PY
+
+write_file services/match/src/app/schemas/pair.py <<'PY'
+from __future__ import annotations
+from typing import Optional
+from pydantic import BaseModel, ConfigDict
+
+class PairCreate(BaseModel):
+ user_id_a: str
+ user_id_b: str
+ score: Optional[float] = None
+ notes: Optional[str] = None
+
+class PairUpdate(BaseModel):
+ score: Optional[float] = None
+ notes: Optional[str] = None
+
+class PairRead(BaseModel):
+ id: str
+ user_id_a: str
+ user_id_b: str
+ status: str
+ score: Optional[float] = None
+ notes: Optional[str] = None
+ model_config = ConfigDict(from_attributes=True)
+PY
+
+write_file services/match/src/app/repositories/pair_repository.py <<'PY'
+from __future__ import annotations
+from typing import Optional, Sequence
+from sqlalchemy import select, or_
+from sqlalchemy.orm import Session
+
+from app.models.pair import MatchPair
+
+class PairRepository:
+ def __init__(self, db: Session):
+ self.db = db
+
+ def create(self, **fields) -> MatchPair:
+ obj = MatchPair(**fields)
+ self.db.add(obj)
+ self.db.commit()
+ self.db.refresh(obj)
+ return obj
+
+ def get(self, pair_id) -> Optional[MatchPair]:
+ return self.db.get(MatchPair, pair_id)
+
+ def list(self, *, for_user_id: str | None = None, status: str | None = None,
+ offset: int = 0, limit: int = 50) -> Sequence[MatchPair]:
+ stmt = select(MatchPair)
+ if for_user_id:
+ stmt = stmt.where(or_(MatchPair.user_id_a == for_user_id, MatchPair.user_id_b == for_user_id))
+ if status:
+ stmt = stmt.where(MatchPair.status == status)
+ stmt = stmt.offset(offset).limit(limit).order_by(MatchPair.created_at.desc())
+ return self.db.execute(stmt).scalars().all()
+
+ def update(self, obj: MatchPair, **fields) -> MatchPair:
+ for k, v in fields.items():
+ if v is not None:
+ setattr(obj, k, v)
+ self.db.add(obj)
+ self.db.commit()
+ self.db.refresh(obj)
+ return obj
+
+ def delete(self, obj: MatchPair) -> None:
+ self.db.delete(obj)
+ self.db.commit()
+PY
+
+write_file services/match/src/app/services/pair_service.py <<'PY'
+from __future__ import annotations
+from sqlalchemy.orm import Session
+from typing import Optional
+from app.repositories.pair_repository import PairRepository
+from app.models.pair import MatchPair
+
+class PairService:
+ def __init__(self, db: Session):
+ self.repo = PairRepository(db)
+
+ def create(self, **fields) -> MatchPair:
+ return self.repo.create(**fields)
+
+ def get(self, pair_id) -> Optional[MatchPair]:
+ return self.repo.get(pair_id)
+
+ def list(self, **filters):
+ return self.repo.list(**filters)
+
+ def update(self, obj: MatchPair, **fields) -> MatchPair:
+ return self.repo.update(obj, **fields)
+
+ def delete(self, obj: MatchPair) -> None:
+ return self.repo.delete(obj)
+
+ def set_status(self, obj: MatchPair, status: str) -> MatchPair:
+ return self.repo.update(obj, status=status)
+PY
+
+write_file services/match/src/app/api/routes/pairs.py <<'PY'
+from __future__ import annotations
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy.orm import Session
+
+from app.db.session import get_db
+from app.core.security import get_current_user, require_roles, UserClaims
+from app.schemas.pair import PairCreate, PairUpdate, PairRead
+from app.services.pair_service import PairService
+
+router = APIRouter(prefix="/v1/pairs", tags=["pairs"])
+
+@router.post("", response_model=PairRead, status_code=201)
+def create_pair(payload: PairCreate, db: Session = Depends(get_db),
+ user: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))):
+ svc = PairService(db)
+ return svc.create(user_id_a=payload.user_id_a, user_id_b=payload.user_id_b,
+ score=payload.score, notes=payload.notes, created_by=user.sub)
+
+@router.get("", response_model=list[PairRead])
+def list_pairs(for_user_id: str | None = None, status: str | None = None,
+ offset: int = 0, limit: int = Query(50, le=200),
+ db: Session = Depends(get_db),
+ _: UserClaims = Depends(get_current_user)):
+ return PairService(db).list(for_user_id=for_user_id, status=status, offset=offset, limit=limit)
+
+@router.get("/{pair_id}", response_model=PairRead)
+def get_pair(pair_id: str, db: Session = Depends(get_db), _: UserClaims = Depends(get_current_user)):
+ obj = PairService(db).get(pair_id)
+ if not obj:
+ raise HTTPException(status_code=404, detail="Not found")
+ return obj
+
+@router.patch("/{pair_id}", response_model=PairRead)
+def update_pair(pair_id: str, payload: PairUpdate, db: Session = Depends(get_db),
+ _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))):
+ svc = PairService(db)
+ obj = svc.get(pair_id)
+ if not obj:
+ raise HTTPException(status_code=404, detail="Not found")
+ return svc.update(obj, **payload.model_dump(exclude_none=True))
+
+@router.post("/{pair_id}/accept", response_model=PairRead)
+def accept(pair_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ svc = PairService(db)
+ obj = svc.get(pair_id)
+ if not obj:
+ raise HTTPException(status_code=404, detail="Not found")
+ # Validate that current user participates
+ if user.sub not in (str(obj.user_id_a), str(obj.user_id_b)):
+ raise HTTPException(status_code=403, detail="Not allowed")
+ return svc.set_status(obj, "accepted")
+
+@router.post("/{pair_id}/reject", response_model=PairRead)
+def reject(pair_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ svc = PairService(db)
+ obj = svc.get(pair_id)
+ if not obj:
+ raise HTTPException(status_code=404, detail="Not found")
+ if user.sub not in (str(obj.user_id_a), str(obj.user_id_b)):
+ raise HTTPException(status_code=403, detail="Not allowed")
+ return svc.set_status(obj, "rejected")
+
+@router.delete("/{pair_id}", status_code=204)
+def delete_pair(pair_id: str, db: Session = Depends(get_db),
+ _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))):
+ svc = PairService(db)
+ obj = svc.get(pair_id)
+ if not obj:
+ return
+ svc.delete(obj)
+PY
+
+write_file services/match/src/app/main.py <<'PY'
+from fastapi import FastAPI
+from .api.routes.ping import router as ping_router
+from .api.routes.pairs import router as pairs_router
+
+app = FastAPI(title="MATCH Service")
+
+@app.get("/health")
+def health():
+ return {"status": "ok", "service": "match"}
+
+app.include_router(ping_router, prefix="/v1")
+app.include_router(pairs_router)
+PY
+
+# -------------------------------------------------------------------
+# 7) CHAT service — комнаты и сообщения (REST, без WS)
+# -------------------------------------------------------------------
+write_file services/chat/src/app/models/chat.py <<'PY'
+from __future__ import annotations
+import uuid
+from datetime import datetime
+from sqlalchemy import String, DateTime, Text, ForeignKey, Boolean
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import Mapped, mapped_column
+from sqlalchemy.sql import func
+
+from app.db.session import Base
+
+class ChatRoom(Base):
+ __tablename__ = "chat_rooms"
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ title: Mapped[str | None] = mapped_column(String(255), default=None)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+
+class ChatParticipant(Base):
+ __tablename__ = "chat_participants"
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ room_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
+ user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
+ is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
+
+class Message(Base):
+ __tablename__ = "chat_messages"
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ room_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
+ sender_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
+ content: Mapped[str] = mapped_column(Text, nullable=False)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+PY
+
+write_file services/chat/src/app/models/__init__.py <<'PY'
+from .chat import ChatRoom, ChatParticipant, Message # noqa
+PY
+
+write_file services/chat/src/app/schemas/chat.py <<'PY'
+from __future__ import annotations
+from pydantic import BaseModel, ConfigDict
+from typing import Optional
+
+class RoomCreate(BaseModel):
+ title: Optional[str] = None
+ participants: list[str] # user IDs
+
+class RoomRead(BaseModel):
+ id: str
+ title: Optional[str] = None
+ model_config = ConfigDict(from_attributes=True)
+
+class MessageCreate(BaseModel):
+ content: str
+
+class MessageRead(BaseModel):
+ id: str
+ room_id: str
+ sender_id: str
+ content: str
+ model_config = ConfigDict(from_attributes=True)
+PY
+
+write_file services/chat/src/app/repositories/chat_repository.py <<'PY'
+from __future__ import annotations
+from typing import Sequence, Optional
+from sqlalchemy.orm import Session
+from sqlalchemy import select, or_
+
+from app.models.chat import ChatRoom, ChatParticipant, Message
+
+class ChatRepository:
+ def __init__(self, db: Session):
+ self.db = db
+
+ # Rooms
+ def create_room(self, title: str | None) -> ChatRoom:
+ r = ChatRoom(title=title)
+ self.db.add(r)
+ self.db.commit()
+ self.db.refresh(r)
+ return r
+
+ def add_participant(self, room_id, user_id, is_admin: bool = False) -> ChatParticipant:
+ p = ChatParticipant(room_id=room_id, user_id=user_id, is_admin=is_admin)
+ self.db.add(p)
+ self.db.commit()
+ self.db.refresh(p)
+ return p
+
+ def list_rooms_for_user(self, user_id) -> Sequence[ChatRoom]:
+ stmt = select(ChatRoom).join(ChatParticipant, ChatParticipant.room_id == ChatRoom.id)\
+ .where(ChatParticipant.user_id == user_id)
+ return self.db.execute(stmt).scalars().all()
+
+ def get_room(self, room_id) -> Optional[ChatRoom]:
+ return self.db.get(ChatRoom, room_id)
+
+ # Messages
+ def create_message(self, room_id, sender_id, content: str) -> Message:
+ m = Message(room_id=room_id, sender_id=sender_id, content=content)
+ self.db.add(m)
+ self.db.commit()
+ self.db.refresh(m)
+ return m
+
+ def list_messages(self, room_id, *, offset: int = 0, limit: int = 100) -> Sequence[Message]:
+ stmt = select(Message).where(Message.room_id == room_id).offset(offset).limit(limit).order_by(Message.created_at.asc())
+ return self.db.execute(stmt).scalars().all()
+PY
+
+write_file services/chat/src/app/services/chat_service.py <<'PY'
+from __future__ import annotations
+from sqlalchemy.orm import Session
+from typing import Optional, Sequence
+
+from app.repositories.chat_repository import ChatRepository
+from app.models.chat import ChatRoom, ChatParticipant, Message
+
+class ChatService:
+ def __init__(self, db: Session):
+ self.repo = ChatRepository(db)
+
+ def create_room(self, *, title: str | None, participant_ids: list[str], creator_id: str) -> ChatRoom:
+ room = self.repo.create_room(title)
+ # creator -> admin
+ self.repo.add_participant(room.id, creator_id, is_admin=True)
+ for uid in participant_ids:
+ if uid != creator_id:
+ self.repo.add_participant(room.id, uid, is_admin=False)
+ return room
+
+ def list_rooms_for_user(self, user_id: str) -> Sequence[ChatRoom]:
+ return self.repo.list_rooms_for_user(user_id)
+
+ def get_room(self, room_id: str) -> ChatRoom | None:
+ return self.repo.get_room(room_id)
+
+ def create_message(self, room_id: str, sender_id: str, content: str) -> Message:
+ return self.repo.create_message(room_id, sender_id, content)
+
+ def list_messages(self, room_id: str, offset: int = 0, limit: int = 100):
+ return self.repo.list_messages(room_id, offset=offset, limit=limit)
+PY
+
+write_file services/chat/src/app/api/routes/chat.py <<'PY'
+from __future__ import annotations
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy.orm import Session
+
+from app.db.session import get_db
+from app.core.security import get_current_user, UserClaims
+from app.schemas.chat import RoomCreate, RoomRead, MessageCreate, MessageRead
+from app.services.chat_service import ChatService
+
+router = APIRouter(prefix="/v1", tags=["chat"])
+
+@router.post("/rooms", response_model=RoomRead, status_code=201)
+def create_room(payload: RoomCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ svc = ChatService(db)
+ room = svc.create_room(title=payload.title, participant_ids=payload.participants, creator_id=user.sub)
+ return room
+
+@router.get("/rooms", response_model=list[RoomRead])
+def my_rooms(db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ return ChatService(db).list_rooms_for_user(user.sub)
+
+@router.get("/rooms/{room_id}", response_model=RoomRead)
+def get_room(room_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ room = ChatService(db).get_room(room_id)
+ if not room:
+ raise HTTPException(status_code=404, detail="Not found")
+ # NOTE: для простоты опускаем проверку участия (добавьте в проде)
+ return room
+
+@router.post("/rooms/{room_id}/messages", response_model=MessageRead, status_code=201)
+def send_message(room_id: str, payload: MessageCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ svc = ChatService(db)
+ room = svc.get_room(room_id)
+ if not room:
+ raise HTTPException(status_code=404, detail="Room not found")
+ msg = svc.create_message(room_id, user.sub, payload.content)
+ return msg
+
+@router.get("/rooms/{room_id}/messages", response_model=list[MessageRead])
+def list_messages(room_id: str, offset: int = 0, limit: int = Query(100, le=500),
+ db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ svc = ChatService(db)
+ room = svc.get_room(room_id)
+ if not room:
+ raise HTTPException(status_code=404, detail="Room not found")
+ return svc.list_messages(room_id, offset=offset, limit=limit)
+PY
+
+write_file services/chat/src/app/main.py <<'PY'
+from fastapi import FastAPI
+from .api.routes.ping import router as ping_router
+from .api.routes.chat import router as chat_router
+
+app = FastAPI(title="CHAT Service")
+
+@app.get("/health")
+def health():
+ return {"status": "ok", "service": "chat"}
+
+app.include_router(ping_router, prefix="/v1")
+app.include_router(chat_router)
+PY
+
+# -------------------------------------------------------------------
+# 8) PAYMENTS service — инвойсы (простая версия)
+# -------------------------------------------------------------------
+write_file services/payments/src/app/models/payment.py <<'PY'
+from __future__ import annotations
+import uuid
+from datetime import datetime
+from sqlalchemy import String, DateTime, Numeric
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import Mapped, mapped_column
+from sqlalchemy.sql import func
+
+from app.db.session import Base
+
+class Invoice(Base):
+ __tablename__ = "invoices"
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ client_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
+ amount: Mapped[float] = mapped_column(Numeric(12,2), nullable=False)
+ currency: Mapped[str] = mapped_column(String(3), default="USD")
+ status: Mapped[str] = mapped_column(String(16), default="pending") # pending/paid/canceled
+ description: Mapped[str | None] = mapped_column(String(500), default=None)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
+PY
+
+write_file services/payments/src/app/models/__init__.py <<'PY'
+from .payment import Invoice # noqa
+PY
+
+write_file services/payments/src/app/schemas/payment.py <<'PY'
+from __future__ import annotations
+from typing import Optional
+from pydantic import BaseModel, ConfigDict
+
+class InvoiceCreate(BaseModel):
+ client_id: str
+ amount: float
+ currency: str = "USD"
+ description: Optional[str] = None
+
+class InvoiceUpdate(BaseModel):
+ amount: Optional[float] = None
+ currency: Optional[str] = None
+ description: Optional[str] = None
+ status: Optional[str] = None
+
+class InvoiceRead(BaseModel):
+ id: str
+ client_id: str
+ amount: float
+ currency: str
+ status: str
+ description: Optional[str] = None
+ model_config = ConfigDict(from_attributes=True)
+PY
+
+write_file services/payments/src/app/repositories/payment_repository.py <<'PY'
+from __future__ import annotations
+from typing import Optional, Sequence
+from sqlalchemy.orm import Session
+from sqlalchemy import select
+
+from app.models.payment import Invoice
+
+class PaymentRepository:
+ def __init__(self, db: Session):
+ self.db = db
+
+ def create_invoice(self, **fields) -> Invoice:
+ obj = Invoice(**fields)
+ self.db.add(obj)
+ self.db.commit()
+ self.db.refresh(obj)
+ return obj
+
+ def get_invoice(self, inv_id) -> Optional[Invoice]:
+ return self.db.get(Invoice, inv_id)
+
+ def list_invoices(self, *, client_id: str | None = None, status: str | None = None,
+ offset: int = 0, limit: int = 50) -> Sequence[Invoice]:
+ stmt = select(Invoice)
+ if client_id:
+ stmt = stmt.where(Invoice.client_id == client_id)
+ if status:
+ stmt = stmt.where(Invoice.status == status)
+ stmt = stmt.offset(offset).limit(limit).order_by(Invoice.created_at.desc())
+ return self.db.execute(stmt).scalars().all()
+
+ def update_invoice(self, obj: Invoice, **fields) -> Invoice:
+ for k, v in fields.items():
+ if v is not None:
+ setattr(obj, k, v)
+ self.db.add(obj)
+ self.db.commit()
+ self.db.refresh(obj)
+ return obj
+
+ def delete_invoice(self, obj: Invoice) -> None:
+ self.db.delete(obj)
+ self.db.commit()
+PY
+
+write_file services/payments/src/app/services/payment_service.py <<'PY'
+from __future__ import annotations
+from sqlalchemy.orm import Session
+from typing import Optional
+from app.repositories.payment_repository import PaymentRepository
+from app.models.payment import Invoice
+
+class PaymentService:
+ def __init__(self, db: Session):
+ self.repo = PaymentRepository(db)
+
+ def create_invoice(self, **fields) -> Invoice:
+ return self.repo.create_invoice(**fields)
+
+ def get_invoice(self, inv_id) -> Invoice | None:
+ return self.repo.get_invoice(inv_id)
+
+ def list_invoices(self, **filters):
+ return self.repo.list_invoices(**filters)
+
+ def update_invoice(self, obj: Invoice, **fields) -> Invoice:
+ return self.repo.update_invoice(obj, **fields)
+
+ def delete_invoice(self, obj: Invoice) -> None:
+ return self.repo.delete_invoice(obj)
+
+ def mark_paid(self, obj: Invoice) -> Invoice:
+ return self.repo.update_invoice(obj, status="paid")
+PY
+
+write_file services/payments/src/app/api/routes/payments.py <<'PY'
+from __future__ import annotations
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy.orm import Session
+
+from app.db.session import get_db
+from app.core.security import get_current_user, require_roles, UserClaims
+from app.schemas.payment import InvoiceCreate, InvoiceUpdate, InvoiceRead
+from app.services.payment_service import PaymentService
+
+router = APIRouter(prefix="/v1/invoices", tags=["payments"])
+
+@router.post("", response_model=InvoiceRead, status_code=201)
+def create_invoice(payload: InvoiceCreate, db: Session = Depends(get_db),
+ _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))):
+ return PaymentService(db).create_invoice(**payload.model_dump(exclude_none=True))
+
+@router.get("", response_model=list[InvoiceRead])
+def list_invoices(client_id: str | None = None, status: str | None = None,
+ offset: int = 0, limit: int = Query(50, le=200),
+ db: Session = Depends(get_db),
+ user: UserClaims = Depends(get_current_user)):
+ # Клиент видит только свои инвойсы, админ/матчмейкер — любые
+ if user.role in ("ADMIN","MATCHMAKER"):
+ return PaymentService(db).list_invoices(client_id=client_id, status=status, offset=offset, limit=limit)
+ else:
+ return PaymentService(db).list_invoices(client_id=user.sub, status=status, offset=offset, limit=limit)
+
+@router.get("/{inv_id}", response_model=InvoiceRead)
+def get_invoice(inv_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ inv = PaymentService(db).get_invoice(inv_id)
+ if not inv:
+ raise HTTPException(status_code=404, detail="Not found")
+ if user.role not in ("ADMIN","MATCHMAKER") and str(inv.client_id) != user.sub:
+ raise HTTPException(status_code=403, detail="Not allowed")
+ return inv
+
+@router.patch("/{inv_id}", response_model=InvoiceRead)
+def update_invoice(inv_id: str, payload: InvoiceUpdate, db: Session = Depends(get_db),
+ _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))):
+ svc = PaymentService(db)
+ inv = svc.get_invoice(inv_id)
+ if not inv:
+ raise HTTPException(status_code=404, detail="Not found")
+ return svc.update_invoice(inv, **payload.model_dump(exclude_none=True))
+
+@router.post("/{inv_id}/mark-paid", response_model=InvoiceRead)
+def mark_paid(inv_id: str, db: Session = Depends(get_db),
+ _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))):
+ svc = PaymentService(db)
+ inv = svc.get_invoice(inv_id)
+ if not inv:
+ raise HTTPException(status_code=404, detail="Not found")
+ return svc.mark_paid(inv)
+
+@router.delete("/{inv_id}", status_code=204)
+def delete_invoice(inv_id: str, db: Session = Depends(get_db),
+ _: UserClaims = Depends(require_roles("ADMIN"))):
+ svc = PaymentService(db)
+ inv = svc.get_invoice(inv_id)
+ if not inv:
+ return
+ svc.delete_invoice(inv)
+PY
+
+write_file services/payments/src/app/main.py <<'PY'
+from fastapi import FastAPI
+from .api.routes.ping import router as ping_router
+from .api.routes.payments import router as payments_router
+
+app = FastAPI(title="PAYMENTS Service")
+
+@app.get("/health")
+def health():
+ return {"status": "ok", "service": "payments"}
+
+app.include_router(ping_router, prefix="/v1")
+app.include_router(payments_router)
+PY
+
+# -------------------------------------------------------------------
+# 9) Обновить __init__.py пакетов (если scaffold создал пустые)
+# -------------------------------------------------------------------
+for s in "${SERVICES[@]}"; do
+ touch "services/$s/src/app/__init__.py"
+ touch "services/$s/src/app/api/__init__.py"
+ touch "services/$s/src/app/api/routes/__init__.py"
+ touch "services/$s/src/app/core/__init__.py"
+ touch "services/$s/src/app/db/__init__.py"
+ touch "services/$s/src/app/repositories/__init__.py"
+ touch "services/$s/src/app/schemas/__init__.py"
+ touch "services/$s/src/app/services/__init__.py"
+done
+
+for s in auth profiles match chat payments; do
+ docker compose run --rm $s alembic revision --autogenerate -m "init"
+done
+
+echo "✅ Models + CRUD + API + Auth applied."
+
+cat <<'NEXT'
+Next steps:
+
+1) Сгенерируйте первичные миграции по моделям:
+ for s in auth profiles match chat payments; do
+ docker compose run --rm $s alembic revision --autogenerate -m "init"
+ done
+
+2) Поднимите окружение (alembic upgrade выполнится в entrypoint):
+ docker compose up --build
+
+3) Получите токен:
+ POST http://localhost:8080/auth/v1/register
+ POST http://localhost:8080/auth/v1/token
+ -> Authorization: Bearer
+
+4) Проверьте CRUD:
+ - Profiles: GET http://localhost:8080/profiles/v1/profiles/me
+ - Match: POST http://localhost:8080/match/v1/pairs
+ - Chat: POST http://localhost:8080/chat/v1/rooms
+ - Payments: POST http://localhost:8080/payments/v1/invoices
+
+Замечания по безопасности/продакшену:
+- Секрет JWT смените в .env (JWT_SECRET), храните в секретах CI/CD.
+- Сроки жизни токенов подберите под бизнес-политику.
+- Для refresh-токенов сейчас статeless-вариант; при необходимости добавьте
+ хранилище jti/ревокацию.
+- Для CHAT и MATCH в проде добавьте строгую проверку участия/прав.
+- В PROFILES поля languages/interests/preferences — JSONB; при желании замените
+ на нормализованные таблицы или ARRAY.
+NEXT
diff --git a/scripts/patch.sh b/scripts/patch.sh
new file mode 100755
index 0000000..e5404b1
--- /dev/null
+++ b/scripts/patch.sh
@@ -0,0 +1,31 @@
+# scripts/patch_gateway_auth_header.sh
+cat > scripts/patch_gateway_auth_header.sh <<'BASH'
+#!/usr/bin/env bash
+set -euo pipefail
+
+CFG="infra/gateway/nginx.conf"
+[ -f "$CFG" ] || { echo "Not found: $CFG"; exit 1; }
+
+# Грубая, но надёжная вставка proxy_set_header Authorization во все блоки location к сервисам
+awk '
+ /location[[:space:]]+\/(auth|profiles|match|chat|payments)\//,/\}/ {
+ print
+ if ($0 ~ /proxy_pass/ && !seen_auth) {
+ print " proxy_set_header Authorization $http_authorization;"
+ print " proxy_set_header X-Forwarded-Proto $scheme;"
+ print " proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;"
+ print " proxy_set_header Host $host;"
+ seen_auth=1
+ }
+ next
+ }
+ { print }
+ /\}/ { seen_auth=0 }
+' "$CFG" > "$CFG.tmp" && mv "$CFG.tmp" "$CFG"
+
+echo "[gateway] restart..."
+docker compose restart gateway
+BASH
+
+chmod +x scripts/patch_gateway_auth_header.sh
+./scripts/patch_gateway_auth_header.sh
diff --git a/scripts/patch_alembic_template.sh b/scripts/patch_alembic_template.sh
new file mode 100755
index 0000000..1e5d5ec
--- /dev/null
+++ b/scripts/patch_alembic_template.sh
@@ -0,0 +1,65 @@
+#!/usr/bin/env bash
+set -euo pipefail
+SERVICES=(auth profiles match chat payments)
+
+for s in "${SERVICES[@]}"; do
+ TPL="services/$s/alembic/script.py.mako"
+ mkdir -p "services/$s/alembic"
+ cat > "$TPL" <<'MAKO'
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = ${repr(up_revision)}
+down_revision = ${repr(down_revision)}
+branch_labels = ${repr(branch_labels)}
+depends_on = ${repr(depends_on)}
+
+def upgrade():
+ ${upgrades if upgrades else "pass"}
+
+
+def downgrade():
+ ${downgrades if downgrades else "pass"}
+MAKO
+ echo "[ok] template updated: $TPL"
+done
+
+# Убедимся, что в env.py импортированы модели (для автогенерации)
+for s in "${SERVICES[@]}"; do
+ ENV="services/$s/alembic/env.py"
+ if ! grep -q "from app import models" "$ENV"; then
+ awk '
+ /from app\.db\.session import Base/ && !x {print; print "from app import models # noqa: F401"; x=1; next}
+ {print}
+ ' "$ENV" > "$ENV.tmp" && mv "$ENV.tmp" "$ENV"
+ echo "[ok] added 'from app import models' to $ENV"
+ fi
+done
+
+# удалить ревизии, созданные с битым шаблоном
+for s in auth profiles match chat payments; do
+ rm -f services/$s/alembic/versions/*.py
+done
+
+# поднять Postgres (если не запущен)
+docker compose up -d postgres
+
+# автогенерация первичных ревизий (каждая сохранится в services//alembic/versions/)
+for s in auth profiles match chat payments; do
+ echo "[gen] $s"
+ docker compose run --rm -v "$PWD/services/$s":/app "$s" \
+ sh -lc 'alembic revision --autogenerate -m "init"'
+done
+
+for s in auth profiles match chat payments; do
+ echo "---- $s"
+ ls -1 services/$s/alembic/versions/
+done
\ No newline at end of file
diff --git a/scripts/patch_gateway_auth_header.sh b/scripts/patch_gateway_auth_header.sh
new file mode 100755
index 0000000..6cb4fe8
--- /dev/null
+++ b/scripts/patch_gateway_auth_header.sh
@@ -0,0 +1,25 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+CFG="infra/gateway/nginx.conf"
+[ -f "$CFG" ] || { echo "Not found: $CFG"; exit 1; }
+
+# Грубая, но надёжная вставка proxy_set_header Authorization во все блоки location к сервисам
+awk '
+ /location[[:space:]]+\/(auth|profiles|match|chat|payments)\//,/\}/ {
+ print
+ if ($0 ~ /proxy_pass/ && !seen_auth) {
+ print " proxy_set_header Authorization $http_authorization;"
+ print " proxy_set_header X-Forwarded-Proto $scheme;"
+ print " proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;"
+ print " proxy_set_header Host $host;"
+ seen_auth=1
+ }
+ next
+ }
+ { print }
+ /\}/ { seen_auth=0 }
+' "$CFG" > "$CFG.tmp" && mv "$CFG.tmp" "$CFG"
+
+echo "[gateway] restart..."
+docker compose restart gateway
diff --git a/scripts/patch_profiles_repo_service.sh b/scripts/patch_profiles_repo_service.sh
new file mode 100755
index 0000000..9ab0103
--- /dev/null
+++ b/scripts/patch_profiles_repo_service.sh
@@ -0,0 +1,55 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+REPO="services/profiles/src/app/repositories/profile_repository.py"
+SRV="services/profiles/src/app/services/profile_service.py"
+mkdir -p "$(dirname "$REPO")" "$(dirname "$SRV")"
+
+cat > "$REPO" <<'PY'
+from typing import Optional
+from uuid import UUID
+from sqlalchemy.orm import Session
+from sqlalchemy import select
+from app.models.profile import Profile
+from app.schemas.profile import ProfileCreate
+
+class ProfileRepository:
+ def __init__(self, db: Session):
+ self.db = db
+
+ def get_by_user(self, user_id: UUID) -> Optional[Profile]:
+ return self.db.execute(select(Profile).where(Profile.user_id == user_id)).scalar_one_or_none()
+
+ def create(self, user_id: UUID, data: ProfileCreate) -> Profile:
+ p = Profile(
+ user_id=user_id,
+ gender=data.gender,
+ city=data.city,
+ languages=list(data.languages or []),
+ interests=list(data.interests or []),
+ )
+ self.db.add(p)
+ self.db.commit()
+ self.db.refresh(p)
+ return p
+PY
+
+cat > "$SRV" <<'PY'
+from uuid import UUID
+from app.schemas.profile import ProfileCreate
+from app.repositories.profile_repository import ProfileRepository
+
+class ProfileService:
+ def __init__(self, repo: ProfileRepository):
+ self.repo = repo
+
+ def get_by_user(self, user_id: UUID):
+ return self.repo.get_by_user(user_id)
+
+ def create(self, user_id: UUID, data: ProfileCreate):
+ return self.repo.create(user_id, data)
+PY
+
+echo "[profiles] rebuilding..."
+docker compose build profiles
+docker compose restart profiles
diff --git a/scripts/patch_profiles_router.sh b/scripts/patch_profiles_router.sh
new file mode 100755
index 0000000..157e256
--- /dev/null
+++ b/scripts/patch_profiles_router.sh
@@ -0,0 +1,43 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROUTER="services/profiles/src/app/api/routes/profiles.py"
+mkdir -p "$(dirname "$ROUTER")"
+
+cat > "$ROUTER" <<'PY'
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy.orm import Session
+
+from app.db.deps import get_db
+from app.core.security import get_current_user, JwtUser
+from app.schemas.profile import ProfileCreate, ProfileOut
+from app.repositories.profile_repository import ProfileRepository
+from app.services.profile_service import ProfileService
+
+# отключаем авто-редирект /path -> /path/
+router = APIRouter(prefix="/v1/profiles", tags=["profiles"], redirect_slashes=False)
+
+@router.get("/me", response_model=ProfileOut)
+def get_my_profile(current: JwtUser = Depends(get_current_user),
+ db: Session = Depends(get_db)):
+ svc = ProfileService(ProfileRepository(db))
+ p = svc.get_by_user(current.sub)
+ if not p:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
+ return p
+
+@router.post("", response_model=ProfileOut, status_code=status.HTTP_201_CREATED)
+def create_my_profile(payload: ProfileCreate,
+ current: JwtUser = Depends(get_current_user),
+ db: Session = Depends(get_db)):
+ svc = ProfileService(ProfileRepository(db))
+ existing = svc.get_by_user(current.sub)
+ if existing:
+ # если хотите строго — верните 409; оставлю 200/201 для удобства e2e
+ return existing
+ return svc.create(current.sub, payload)
+PY
+
+echo "[profiles] rebuilding..."
+docker compose build profiles
+docker compose restart profiles
diff --git a/scripts/patch_profiles_security.sh b/scripts/patch_profiles_security.sh
new file mode 100755
index 0000000..9fb0301
--- /dev/null
+++ b/scripts/patch_profiles_security.sh
@@ -0,0 +1,79 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+REQ="services/profiles/requirements.txt"
+PY="services/profiles/src/app/core/security.py"
+
+# 1) гарантируем зависимость PyJWT
+grep -qE '(^|[[:space:]])PyJWT' "$REQ" 2>/dev/null || {
+ echo "PyJWT>=2.8.0" >> "$REQ"
+ echo "[profiles] added PyJWT to requirements.txt"
+}
+
+# 2) модуль security.py
+mkdir -p "$(dirname "$PY")"
+cat > "$PY" <<'PY'
+import os
+from typing import Optional
+
+import jwt
+from fastapi import Depends, HTTPException, status
+from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
+from pydantic import BaseModel
+
+reusable_bearer = HTTPBearer(auto_error=True)
+
+JWT_SECRET = os.getenv("JWT_SECRET", "dev-secret")
+JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
+
+# Возможность включить строгую проверку audience/issuer в будущем
+JWT_VERIFY_AUD = os.getenv("JWT_VERIFY_AUD", "0") == "1"
+JWT_AUDIENCE: Optional[str] = os.getenv("JWT_AUDIENCE") or None
+JWT_VERIFY_ISS = os.getenv("JWT_VERIFY_ISS", "0") == "1"
+JWT_ISSUER: Optional[str] = os.getenv("JWT_ISSUER") or None
+
+# Допустимая рассинхронизация часов (сек)
+JWT_LEEWAY = int(os.getenv("JWT_LEEWAY", "30"))
+
+class JwtUser(BaseModel):
+ sub: str
+ email: Optional[str] = None
+ role: Optional[str] = None
+
+def decode_token(token: str) -> JwtUser:
+ options = {
+ "verify_signature": True,
+ "verify_exp": True,
+ "verify_aud": JWT_VERIFY_AUD,
+ "verify_iss": JWT_VERIFY_ISS,
+ }
+ kwargs = {"algorithms": [JWT_ALGORITHM], "options": options, "leeway": JWT_LEEWAY}
+ if JWT_VERIFY_AUD and JWT_AUDIENCE:
+ kwargs["audience"] = JWT_AUDIENCE
+ if JWT_VERIFY_ISS and JWT_ISSUER:
+ kwargs["issuer"] = JWT_ISSUER
+
+ try:
+ payload = jwt.decode(token, JWT_SECRET, **kwargs)
+ sub = str(payload.get("sub") or "")
+ if not sub:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token: no sub")
+ return JwtUser(sub=sub, email=payload.get("email"), role=payload.get("role"))
+ except jwt.ExpiredSignatureError:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
+ except jwt.InvalidAudienceError:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid audience")
+ except jwt.InvalidIssuerError:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid issuer")
+ except jwt.InvalidTokenError:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
+
+def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(reusable_bearer)) -> JwtUser:
+ if credentials.scheme.lower() != "bearer":
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid auth scheme")
+ return decode_token(credentials.credentials)
+PY
+
+echo "[profiles] rebuilding..."
+docker compose build profiles
+docker compose restart profiles
diff --git a/scripts/test.sh b/scripts/test.sh
new file mode 100755
index 0000000..e707b02
--- /dev/null
+++ b/scripts/test.sh
@@ -0,0 +1,29 @@
+#!/usr/bin/env bash
+
+# 1) Здоровье сервисов
+curl -sS http://localhost:8080/auth/health
+curl -sS http://localhost:8080/profiles/health
+
+# 2) Токен (любой юзер)
+curl -sS -X POST http://localhost:8080/auth/v1/token \
+ -H "Content-Type: application/json" \
+ -d '{"email":"admin@agency.dev","password":"secret123"}' | tee /tmp/token.json
+
+ACCESS=$(python3 - <<'PY' /tmp/token.json
+import sys, json; print(json.load(open(sys.argv[1]))["access_token"])
+PY
+)
+
+# 3) /me — ожидаемо 404 (если профиля нет), главное НЕ 401
+curl -i -sS http://localhost:8080/profiles/v1/profiles/me \
+ -H "Authorization: Bearer $ACCESS"
+
+# 4) Создать профиль — должно быть 201/200, без 500
+curl -i -sS -X POST http://localhost:8080/profiles/v1/profiles \
+ -H "Authorization: Bearer $ACCESS" \
+ -H "Content-Type: application/json" \
+ -d '{"gender":"female","city":"Moscow","languages":["ru","en"],"interests":["music","travel"]}'
+
+# 5) Снова /me — теперь 200 с JSON (UUIDы как строки)
+curl -sS http://localhost:8080/profiles/v1/profiles/me \
+ -H "Authorization: Bearer $ACCESS" | jq .
diff --git a/services/auth/alembic/env.py b/services/auth/alembic/env.py
index df746af..034d7df 100644
--- a/services/auth/alembic/env.py
+++ b/services/auth/alembic/env.py
@@ -12,6 +12,7 @@ if SRC_DIR not in sys.path:
sys.path.append(SRC_DIR)
from app.db.session import Base # noqa
+from app import models # noqa: F401
config = context.config
diff --git a/services/auth/alembic/script.py.mako b/services/auth/alembic/script.py.mako
new file mode 100644
index 0000000..e378537
--- /dev/null
+++ b/services/auth/alembic/script.py.mako
@@ -0,0 +1,22 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = ${repr(up_revision)}
+down_revision = ${repr(down_revision)}
+branch_labels = ${repr(branch_labels)}
+depends_on = ${repr(depends_on)}
+
+def upgrade():
+ ${upgrades if upgrades else "pass"}
+
+
+def downgrade():
+ ${downgrades if downgrades else "pass"}
diff --git a/services/auth/alembic/versions/df0effc5d87a_init.py b/services/auth/alembic/versions/df0effc5d87a_init.py
new file mode 100644
index 0000000..3053dfd
--- /dev/null
+++ b/services/auth/alembic/versions/df0effc5d87a_init.py
@@ -0,0 +1,38 @@
+"""init
+
+Revision ID: df0effc5d87a
+Revises:
+Create Date: 2025-08-08 11:20:03.816755+00:00
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = 'df0effc5d87a'
+down_revision = None
+branch_labels = None
+depends_on = None
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('users',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('email', sa.String(length=255), nullable=False),
+ sa.Column('password_hash', sa.String(length=255), nullable=False),
+ sa.Column('full_name', sa.String(length=255), nullable=True),
+ sa.Column('role', sa.String(length=32), nullable=False),
+ sa.Column('is_active', sa.Boolean(), nullable=False),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_index(op.f('ix_users_email'), table_name='users')
+ op.drop_table('users')
+ # ### end Alembic commands ###
diff --git a/services/auth/requirements.txt b/services/auth/requirements.txt
index 87d1574..04996b5 100644
--- a/services/auth/requirements.txt
+++ b/services/auth/requirements.txt
@@ -5,6 +5,9 @@ psycopg2-binary
alembic
pydantic>=2
pydantic-settings
+pydantic[email]
python-dotenv
httpx>=0.27
pytest
+PyJWT>=2.8
+passlib[bcrypt]>=1.7
diff --git a/services/auth/src/app/api/routes/auth.py b/services/auth/src/app/api/routes/auth.py
new file mode 100644
index 0000000..f74db56
--- /dev/null
+++ b/services/auth/src/app/api/routes/auth.py
@@ -0,0 +1,55 @@
+from __future__ import annotations
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy.orm import Session
+
+from app.db.session import get_db
+from app.schemas.user import UserCreate, LoginRequest, TokenPair, UserRead
+from app.services.user_service import UserService
+from app.core.security import create_access_token, create_refresh_token, get_current_user, UserClaims
+
+router = APIRouter(prefix="/v1", tags=["auth"])
+
+@router.post("/register", response_model=UserRead, status_code=201)
+def register(payload: UserCreate, db: Session = Depends(get_db)):
+ svc = UserService(db)
+ try:
+ user = svc.create_user(email=payload.email, password=payload.password,
+ full_name=payload.full_name, role=payload.role)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+ return user
+
+@router.post("/token", response_model=TokenPair)
+def token(payload: LoginRequest, db: Session = Depends(get_db)):
+ svc = UserService(db)
+ user = svc.authenticate(email=payload.email, password=payload.password)
+ if not user:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
+ access = create_access_token(sub=str(user.id), email=user.email, role=user.role)
+ refresh = create_refresh_token(sub=str(user.id), email=user.email, role=user.role)
+ return TokenPair(access_token=access, refresh_token=refresh)
+
+class RefreshRequest(LoginRequest.__class__):
+ refresh_token: str # type: ignore
+
+@router.post("/refresh", response_model=TokenPair)
+def refresh_token(req: dict):
+ # expects: {"refresh_token": ""}
+ from app.core.security import decode_token
+ token = req.get("refresh_token")
+ if not token:
+ raise HTTPException(status_code=400, detail="Missing refresh_token")
+ claims = decode_token(token)
+ if claims.type != "refresh":
+ raise HTTPException(status_code=400, detail="Not a refresh token")
+ access = create_access_token(sub=claims.sub, email=claims.email, role=claims.role)
+ refresh = create_refresh_token(sub=claims.sub, email=claims.email, role=claims.role)
+ return TokenPair(access_token=access, refresh_token=refresh)
+
+@router.get("/me", response_model=UserRead)
+def me(claims: UserClaims = Depends(get_current_user), db: Session = Depends(get_db)):
+ svc = UserService(db)
+ u = svc.get_user(claims.sub)
+ if not u:
+ raise HTTPException(status_code=404, detail="User not found")
+ return u
diff --git a/services/auth/src/app/api/routes/users.py b/services/auth/src/app/api/routes/users.py
new file mode 100644
index 0000000..7abbbf9
--- /dev/null
+++ b/services/auth/src/app/api/routes/users.py
@@ -0,0 +1,51 @@
+from __future__ import annotations
+from fastapi import APIRouter, Depends, HTTPException, Query, status
+from sqlalchemy.orm import Session
+
+from app.db.session import get_db
+from app.core.security import require_roles
+from app.schemas.user import UserRead, UserUpdate, UserCreate
+from app.services.user_service import UserService
+
+router = APIRouter(prefix="/v1/users", tags=["users"])
+
+@router.get("", response_model=list[UserRead])
+def list_users(offset: int = 0, limit: int = Query(50, le=200), db: Session = Depends(get_db),
+ _: dict = Depends(require_roles("ADMIN"))):
+ return UserService(db).list_users(offset=offset, limit=limit)
+
+@router.post("", response_model=UserRead, status_code=201)
+def create_user(payload: UserCreate, db: Session = Depends(get_db),
+ _: dict = Depends(require_roles("ADMIN"))):
+ try:
+ return UserService(db).create_user(email=payload.email, password=payload.password,
+ full_name=payload.full_name, role=payload.role)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+
+@router.get("/{user_id}", response_model=UserRead)
+def get_user(user_id: str, db: Session = Depends(get_db),
+ _: dict = Depends(require_roles("ADMIN"))):
+ u = UserService(db).get_user(user_id)
+ if not u:
+ raise HTTPException(status_code=404, detail="User not found")
+ return u
+
+@router.patch("/{user_id}", response_model=UserRead)
+def update_user(user_id: str, payload: UserUpdate, db: Session = Depends(get_db),
+ _: dict = Depends(require_roles("ADMIN"))):
+ svc = UserService(db)
+ u = svc.get_user(user_id)
+ if not u:
+ raise HTTPException(status_code=404, detail="User not found")
+ return svc.update_user(u, full_name=payload.full_name, role=payload.role,
+ is_active=payload.is_active, password=payload.password)
+
+@router.delete("/{user_id}", status_code=204)
+def delete_user(user_id: str, db: Session = Depends(get_db),
+ _: dict = Depends(require_roles("ADMIN"))):
+ svc = UserService(db)
+ u = svc.get_user(user_id)
+ if not u:
+ return
+ svc.delete_user(u)
diff --git a/services/auth/src/app/core/passwords.py b/services/auth/src/app/core/passwords.py
new file mode 100644
index 0000000..3c560ff
--- /dev/null
+++ b/services/auth/src/app/core/passwords.py
@@ -0,0 +1,9 @@
+from passlib.context import CryptContext
+
+_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
+
+def hash_password(p: str) -> str:
+ return _pwd.hash(p)
+
+def verify_password(p: str, hashed: str) -> bool:
+ return _pwd.verify(p, hashed)
diff --git a/services/auth/src/app/core/security.py b/services/auth/src/app/core/security.py
new file mode 100644
index 0000000..1b12ec2
--- /dev/null
+++ b/services/auth/src/app/core/security.py
@@ -0,0 +1,65 @@
+from __future__ import annotations
+import os
+from datetime import datetime, timedelta, timezone
+from enum import Enum
+from typing import Any, Callable, Optional
+
+import jwt
+from fastapi import Depends, HTTPException, status
+from fastapi.security import OAuth2PasswordBearer
+from pydantic import BaseModel
+
+JWT_SECRET = os.getenv("JWT_SECRET", "devsecret_change_me")
+JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
+ACCESS_TOKEN_EXPIRES_MIN = int(os.getenv("ACCESS_TOKEN_EXPIRES_MIN", "15"))
+REFRESH_TOKEN_EXPIRES_MIN = int(os.getenv("REFRESH_TOKEN_EXPIRES_MIN", "43200"))
+
+class TokenType(str, Enum):
+ access = "access"
+ refresh = "refresh"
+
+class UserClaims(BaseModel):
+ sub: str
+ email: str
+ role: str
+ type: str
+ exp: int
+
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/v1/token")
+
+def _create_token(token_type: TokenType, *, sub: str, email: str, role: str, expires_minutes: int) -> str:
+ now = datetime.now(timezone.utc)
+ exp = now + timedelta(minutes=expires_minutes)
+ payload: dict[str, Any] = {
+ "sub": sub,
+ "email": email,
+ "role": role,
+ "type": token_type.value,
+ "exp": int(exp.timestamp()),
+ }
+ return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
+
+def create_access_token(*, sub: str, email: str, role: str) -> str:
+ return _create_token(TokenType.access, sub=sub, email=email, role=role, expires_minutes=ACCESS_TOKEN_EXPIRES_MIN)
+
+def create_refresh_token(*, sub: str, email: str, role: str) -> str:
+ return _create_token(TokenType.refresh, sub=sub, email=email, role=role, expires_minutes=REFRESH_TOKEN_EXPIRES_MIN)
+
+def decode_token(token: str) -> UserClaims:
+ try:
+ payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
+ return UserClaims(**payload)
+ except jwt.ExpiredSignatureError:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
+ except jwt.PyJWTError:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
+
+def get_current_user(token: str = Depends(oauth2_scheme)) -> UserClaims:
+ return decode_token(token)
+
+def require_roles(*roles: str) -> Callable[[UserClaims], UserClaims]:
+ def dep(user: UserClaims = Depends(get_current_user)) -> UserClaims:
+ if roles and user.role not in roles:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient role")
+ return user
+ return dep
diff --git a/services/auth/src/app/main.py b/services/auth/src/app/main.py
index 8d35f4d..0c112ef 100644
--- a/services/auth/src/app/main.py
+++ b/services/auth/src/app/main.py
@@ -1,5 +1,7 @@
from fastapi import FastAPI
from .api.routes.ping import router as ping_router
+from .api.routes.auth import router as auth_router
+from .api.routes.users import router as users_router
app = FastAPI(title="AUTH Service")
@@ -7,5 +9,6 @@ app = FastAPI(title="AUTH Service")
def health():
return {"status": "ok", "service": "auth"}
-# v1 API
app.include_router(ping_router, prefix="/v1")
+app.include_router(auth_router)
+app.include_router(users_router)
diff --git a/services/auth/src/app/models/__init__.py b/services/auth/src/app/models/__init__.py
index e69de29..f6b37fb 100644
--- a/services/auth/src/app/models/__init__.py
+++ b/services/auth/src/app/models/__init__.py
@@ -0,0 +1 @@
+from .user import User, Role # noqa: F401
diff --git a/services/auth/src/app/models/user.py b/services/auth/src/app/models/user.py
new file mode 100644
index 0000000..3b70f32
--- /dev/null
+++ b/services/auth/src/app/models/user.py
@@ -0,0 +1,28 @@
+from __future__ import annotations
+import uuid
+from datetime import datetime
+from enum import Enum
+
+from sqlalchemy import String, Boolean, DateTime
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import Mapped, mapped_column
+from sqlalchemy.sql import func
+
+from app.db.session import Base
+
+class Role(str, Enum):
+ ADMIN = "ADMIN"
+ MATCHMAKER = "MATCHMAKER"
+ CLIENT = "CLIENT"
+
+class User(Base):
+ __tablename__ = "users"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
+ password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
+ full_name: Mapped[str | None] = mapped_column(String(255), default=None)
+ role: Mapped[str] = mapped_column(String(32), default=Role.CLIENT.value, nullable=False)
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
diff --git a/services/auth/src/app/repositories/user_repository.py b/services/auth/src/app/repositories/user_repository.py
new file mode 100644
index 0000000..13c3dbc
--- /dev/null
+++ b/services/auth/src/app/repositories/user_repository.py
@@ -0,0 +1,41 @@
+from __future__ import annotations
+from typing import Optional, Sequence
+from sqlalchemy.orm import Session
+from sqlalchemy import select, update, delete
+
+from app.models.user import User
+
+class UserRepository:
+ def __init__(self, db: Session):
+ self.db = db
+
+ def get(self, user_id) -> Optional[User]:
+ return self.db.get(User, user_id)
+
+ def get_by_email(self, email: str) -> Optional[User]:
+ stmt = select(User).where(User.email == email)
+ return self.db.execute(stmt).scalar_one_or_none()
+
+ def list(self, *, offset: int = 0, limit: int = 50) -> Sequence[User]:
+ stmt = select(User).offset(offset).limit(limit).order_by(User.created_at.desc())
+ return self.db.execute(stmt).scalars().all()
+
+ def create(self, *, email: str, password_hash: str, full_name: str | None, role: str) -> User:
+ user = User(email=email, password_hash=password_hash, full_name=full_name, role=role)
+ self.db.add(user)
+ self.db.commit()
+ self.db.refresh(user)
+ return user
+
+ def update(self, user: User, **fields) -> User:
+ for k, v in fields.items():
+ if v is not None:
+ setattr(user, k, v)
+ self.db.add(user)
+ self.db.commit()
+ self.db.refresh(user)
+ return user
+
+ def delete(self, user: User) -> None:
+ self.db.delete(user)
+ self.db.commit()
diff --git a/services/auth/src/app/schemas/user.py b/services/auth/src/app/schemas/user.py
new file mode 100644
index 0000000..5e69bed
--- /dev/null
+++ b/services/auth/src/app/schemas/user.py
@@ -0,0 +1,38 @@
+from __future__ import annotations
+from typing import Optional
+from pydantic import BaseModel, EmailStr, ConfigDict
+
+class UserBase(BaseModel):
+ email: EmailStr
+ full_name: Optional[str] = None
+ role: str = "CLIENT"
+ is_active: bool = True
+
+class UserCreate(BaseModel):
+ email: EmailStr
+ password: str
+ full_name: Optional[str] = None
+ role: str = "CLIENT"
+
+class UserUpdate(BaseModel):
+ full_name: Optional[str] = None
+ role: Optional[str] = None
+ is_active: Optional[bool] = None
+ password: Optional[str] = None
+
+class UserRead(BaseModel):
+ id: str
+ email: EmailStr
+ full_name: Optional[str] = None
+ role: str
+ is_active: bool
+ model_config = ConfigDict(from_attributes=True)
+
+class LoginRequest(BaseModel):
+ email: EmailStr
+ password: str
+
+class TokenPair(BaseModel):
+ access_token: str
+ refresh_token: str
+ token_type: str = "bearer"
diff --git a/services/auth/src/app/services/user_service.py b/services/auth/src/app/services/user_service.py
new file mode 100644
index 0000000..95bbf65
--- /dev/null
+++ b/services/auth/src/app/services/user_service.py
@@ -0,0 +1,48 @@
+from __future__ import annotations
+from typing import Optional
+from sqlalchemy.orm import Session
+
+from app.repositories.user_repository import UserRepository
+from app.core.passwords import hash_password, verify_password
+from app.models.user import User
+
+class UserService:
+ def __init__(self, db: Session):
+ self.repo = UserRepository(db)
+
+ # CRUD
+ def create_user(self, *, email: str, password: str, full_name: str | None, role: str) -> User:
+ if self.repo.get_by_email(email):
+ raise ValueError("Email already in use")
+ pwd_hash = hash_password(password)
+ return self.repo.create(email=email, password_hash=pwd_hash, full_name=full_name, role=role)
+
+ def get_user(self, user_id) -> Optional[User]:
+ return self.repo.get(user_id)
+
+ def get_by_email(self, email: str) -> Optional[User]:
+ return self.repo.get_by_email(email)
+
+ def list_users(self, *, offset: int = 0, limit: int = 50):
+ return self.repo.list(offset=offset, limit=limit)
+
+ def update_user(self, user: User, *, full_name: str | None = None, role: str | None = None,
+ is_active: bool | None = None, password: str | None = None) -> User:
+ fields = {}
+ if full_name is not None: fields["full_name"] = full_name
+ if role is not None: fields["role"] = role
+ if is_active is not None: fields["is_active"] = is_active
+ if password: fields["password_hash"] = hash_password(password)
+ return self.repo.update(user, **fields)
+
+ def delete_user(self, user: User) -> None:
+ self.repo.delete(user)
+
+ # Auth
+ def authenticate(self, *, email: str, password: str) -> Optional[User]:
+ user = self.repo.get_by_email(email)
+ if not user or not user.is_active:
+ return None
+ if not verify_password(password, user.password_hash):
+ return None
+ return user
diff --git a/services/chat/alembic/env.py b/services/chat/alembic/env.py
index df746af..034d7df 100644
--- a/services/chat/alembic/env.py
+++ b/services/chat/alembic/env.py
@@ -12,6 +12,7 @@ if SRC_DIR not in sys.path:
sys.path.append(SRC_DIR)
from app.db.session import Base # noqa
+from app import models # noqa: F401
config = context.config
diff --git a/services/chat/alembic/script.py.mako b/services/chat/alembic/script.py.mako
new file mode 100644
index 0000000..e378537
--- /dev/null
+++ b/services/chat/alembic/script.py.mako
@@ -0,0 +1,22 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = ${repr(up_revision)}
+down_revision = ${repr(down_revision)}
+branch_labels = ${repr(branch_labels)}
+depends_on = ${repr(depends_on)}
+
+def upgrade():
+ ${upgrades if upgrades else "pass"}
+
+
+def downgrade():
+ ${downgrades if downgrades else "pass"}
diff --git a/services/chat/alembic/versions/8cc8115aaf0e_init.py b/services/chat/alembic/versions/8cc8115aaf0e_init.py
new file mode 100644
index 0000000..79749dd
--- /dev/null
+++ b/services/chat/alembic/versions/8cc8115aaf0e_init.py
@@ -0,0 +1,56 @@
+"""init
+
+Revision ID: 8cc8115aaf0e
+Revises:
+Create Date: 2025-08-08 11:20:07.718286+00:00
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = '8cc8115aaf0e'
+down_revision = None
+branch_labels = None
+depends_on = None
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('chat_messages',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('room_id', sa.UUID(), nullable=False),
+ sa.Column('sender_id', sa.UUID(), nullable=False),
+ sa.Column('content', sa.Text(), nullable=False),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_chat_messages_room_id'), 'chat_messages', ['room_id'], unique=False)
+ op.create_index(op.f('ix_chat_messages_sender_id'), 'chat_messages', ['sender_id'], unique=False)
+ op.create_table('chat_participants',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('room_id', sa.UUID(), nullable=False),
+ sa.Column('user_id', sa.UUID(), nullable=False),
+ sa.Column('is_admin', sa.Boolean(), nullable=False),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_chat_participants_room_id'), 'chat_participants', ['room_id'], unique=False)
+ op.create_index(op.f('ix_chat_participants_user_id'), 'chat_participants', ['user_id'], unique=False)
+ op.create_table('chat_rooms',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('title', sa.String(length=255), nullable=True),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.PrimaryKeyConstraint('id')
+ )
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_table('chat_rooms')
+ op.drop_index(op.f('ix_chat_participants_user_id'), table_name='chat_participants')
+ op.drop_index(op.f('ix_chat_participants_room_id'), table_name='chat_participants')
+ op.drop_table('chat_participants')
+ op.drop_index(op.f('ix_chat_messages_sender_id'), table_name='chat_messages')
+ op.drop_index(op.f('ix_chat_messages_room_id'), table_name='chat_messages')
+ op.drop_table('chat_messages')
+ # ### end Alembic commands ###
diff --git a/services/chat/requirements.txt b/services/chat/requirements.txt
index 87d1574..1b92356 100644
--- a/services/chat/requirements.txt
+++ b/services/chat/requirements.txt
@@ -8,3 +8,4 @@ pydantic-settings
python-dotenv
httpx>=0.27
pytest
+PyJWT>=2.8
diff --git a/services/chat/src/app/api/routes/chat.py b/services/chat/src/app/api/routes/chat.py
new file mode 100644
index 0000000..ea4a481
--- /dev/null
+++ b/services/chat/src/app/api/routes/chat.py
@@ -0,0 +1,46 @@
+from __future__ import annotations
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy.orm import Session
+
+from app.db.session import get_db
+from app.core.security import get_current_user, UserClaims
+from app.schemas.chat import RoomCreate, RoomRead, MessageCreate, MessageRead
+from app.services.chat_service import ChatService
+
+router = APIRouter(prefix="/v1", tags=["chat"])
+
+@router.post("/rooms", response_model=RoomRead, status_code=201)
+def create_room(payload: RoomCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ svc = ChatService(db)
+ room = svc.create_room(title=payload.title, participant_ids=payload.participants, creator_id=user.sub)
+ return room
+
+@router.get("/rooms", response_model=list[RoomRead])
+def my_rooms(db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ return ChatService(db).list_rooms_for_user(user.sub)
+
+@router.get("/rooms/{room_id}", response_model=RoomRead)
+def get_room(room_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ room = ChatService(db).get_room(room_id)
+ if not room:
+ raise HTTPException(status_code=404, detail="Not found")
+ # NOTE: для простоты опускаем проверку участия (добавьте в проде)
+ return room
+
+@router.post("/rooms/{room_id}/messages", response_model=MessageRead, status_code=201)
+def send_message(room_id: str, payload: MessageCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ svc = ChatService(db)
+ room = svc.get_room(room_id)
+ if not room:
+ raise HTTPException(status_code=404, detail="Room not found")
+ msg = svc.create_message(room_id, user.sub, payload.content)
+ return msg
+
+@router.get("/rooms/{room_id}/messages", response_model=list[MessageRead])
+def list_messages(room_id: str, offset: int = 0, limit: int = Query(100, le=500),
+ db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ svc = ChatService(db)
+ room = svc.get_room(room_id)
+ if not room:
+ raise HTTPException(status_code=404, detail="Room not found")
+ return svc.list_messages(room_id, offset=offset, limit=limit)
diff --git a/services/chat/src/app/core/security.py b/services/chat/src/app/core/security.py
new file mode 100644
index 0000000..6842ef3
--- /dev/null
+++ b/services/chat/src/app/core/security.py
@@ -0,0 +1,40 @@
+from __future__ import annotations
+import os
+from enum import Enum
+from typing import Any, Callable
+
+import jwt
+from fastapi import Depends, HTTPException, status
+from fastapi.security import OAuth2PasswordBearer
+from pydantic import BaseModel
+
+JWT_SECRET = os.getenv("JWT_SECRET", "devsecret_change_me")
+JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
+
+class UserClaims(BaseModel):
+ sub: str
+ email: str
+ role: str
+ type: str
+ exp: int
+
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/v1/token")
+
+def decode_token(token: str) -> UserClaims:
+ try:
+ payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
+ return UserClaims(**payload)
+ except jwt.ExpiredSignatureError:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
+ except jwt.PyJWTError:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
+
+def get_current_user(token: str = Depends(oauth2_scheme)) -> UserClaims:
+ return decode_token(token)
+
+def require_roles(*roles: str):
+ def dep(user: UserClaims = Depends(get_current_user)) -> UserClaims:
+ if roles and user.role not in roles:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient role")
+ return user
+ return dep
diff --git a/services/chat/src/app/main.py b/services/chat/src/app/main.py
index e570874..a4ebf68 100644
--- a/services/chat/src/app/main.py
+++ b/services/chat/src/app/main.py
@@ -1,5 +1,6 @@
from fastapi import FastAPI
from .api.routes.ping import router as ping_router
+from .api.routes.chat import router as chat_router
app = FastAPI(title="CHAT Service")
@@ -7,5 +8,5 @@ app = FastAPI(title="CHAT Service")
def health():
return {"status": "ok", "service": "chat"}
-# v1 API
app.include_router(ping_router, prefix="/v1")
+app.include_router(chat_router)
diff --git a/services/chat/src/app/models/__init__.py b/services/chat/src/app/models/__init__.py
index e69de29..b4e6f58 100644
--- a/services/chat/src/app/models/__init__.py
+++ b/services/chat/src/app/models/__init__.py
@@ -0,0 +1 @@
+from .chat import ChatRoom, ChatParticipant, Message # noqa
diff --git a/services/chat/src/app/models/chat.py b/services/chat/src/app/models/chat.py
new file mode 100644
index 0000000..9bbaac6
--- /dev/null
+++ b/services/chat/src/app/models/chat.py
@@ -0,0 +1,30 @@
+from __future__ import annotations
+import uuid
+from datetime import datetime
+from sqlalchemy import String, DateTime, Text, ForeignKey, Boolean
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import Mapped, mapped_column
+from sqlalchemy.sql import func
+
+from app.db.session import Base
+
+class ChatRoom(Base):
+ __tablename__ = "chat_rooms"
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ title: Mapped[str | None] = mapped_column(String(255), default=None)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+
+class ChatParticipant(Base):
+ __tablename__ = "chat_participants"
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ room_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
+ user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
+ is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
+
+class Message(Base):
+ __tablename__ = "chat_messages"
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ room_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
+ sender_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
+ content: Mapped[str] = mapped_column(Text, nullable=False)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
diff --git a/services/chat/src/app/repositories/chat_repository.py b/services/chat/src/app/repositories/chat_repository.py
new file mode 100644
index 0000000..e1299e7
--- /dev/null
+++ b/services/chat/src/app/repositories/chat_repository.py
@@ -0,0 +1,45 @@
+from __future__ import annotations
+from typing import Sequence, Optional
+from sqlalchemy.orm import Session
+from sqlalchemy import select, or_
+
+from app.models.chat import ChatRoom, ChatParticipant, Message
+
+class ChatRepository:
+ def __init__(self, db: Session):
+ self.db = db
+
+ # Rooms
+ def create_room(self, title: str | None) -> ChatRoom:
+ r = ChatRoom(title=title)
+ self.db.add(r)
+ self.db.commit()
+ self.db.refresh(r)
+ return r
+
+ def add_participant(self, room_id, user_id, is_admin: bool = False) -> ChatParticipant:
+ p = ChatParticipant(room_id=room_id, user_id=user_id, is_admin=is_admin)
+ self.db.add(p)
+ self.db.commit()
+ self.db.refresh(p)
+ return p
+
+ def list_rooms_for_user(self, user_id) -> Sequence[ChatRoom]:
+ stmt = select(ChatRoom).join(ChatParticipant, ChatParticipant.room_id == ChatRoom.id)\
+ .where(ChatParticipant.user_id == user_id)
+ return self.db.execute(stmt).scalars().all()
+
+ def get_room(self, room_id) -> Optional[ChatRoom]:
+ return self.db.get(ChatRoom, room_id)
+
+ # Messages
+ def create_message(self, room_id, sender_id, content: str) -> Message:
+ m = Message(room_id=room_id, sender_id=sender_id, content=content)
+ self.db.add(m)
+ self.db.commit()
+ self.db.refresh(m)
+ return m
+
+ def list_messages(self, room_id, *, offset: int = 0, limit: int = 100) -> Sequence[Message]:
+ stmt = select(Message).where(Message.room_id == room_id).offset(offset).limit(limit).order_by(Message.created_at.asc())
+ return self.db.execute(stmt).scalars().all()
diff --git a/services/chat/src/app/schemas/chat.py b/services/chat/src/app/schemas/chat.py
new file mode 100644
index 0000000..48b35a2
--- /dev/null
+++ b/services/chat/src/app/schemas/chat.py
@@ -0,0 +1,22 @@
+from __future__ import annotations
+from pydantic import BaseModel, ConfigDict
+from typing import Optional
+
+class RoomCreate(BaseModel):
+ title: Optional[str] = None
+ participants: list[str] # user IDs
+
+class RoomRead(BaseModel):
+ id: str
+ title: Optional[str] = None
+ model_config = ConfigDict(from_attributes=True)
+
+class MessageCreate(BaseModel):
+ content: str
+
+class MessageRead(BaseModel):
+ id: str
+ room_id: str
+ sender_id: str
+ content: str
+ model_config = ConfigDict(from_attributes=True)
diff --git a/services/chat/src/app/services/chat_service.py b/services/chat/src/app/services/chat_service.py
new file mode 100644
index 0000000..344dfa4
--- /dev/null
+++ b/services/chat/src/app/services/chat_service.py
@@ -0,0 +1,31 @@
+from __future__ import annotations
+from sqlalchemy.orm import Session
+from typing import Optional, Sequence
+
+from app.repositories.chat_repository import ChatRepository
+from app.models.chat import ChatRoom, ChatParticipant, Message
+
+class ChatService:
+ def __init__(self, db: Session):
+ self.repo = ChatRepository(db)
+
+ def create_room(self, *, title: str | None, participant_ids: list[str], creator_id: str) -> ChatRoom:
+ room = self.repo.create_room(title)
+ # creator -> admin
+ self.repo.add_participant(room.id, creator_id, is_admin=True)
+ for uid in participant_ids:
+ if uid != creator_id:
+ self.repo.add_participant(room.id, uid, is_admin=False)
+ return room
+
+ def list_rooms_for_user(self, user_id: str) -> Sequence[ChatRoom]:
+ return self.repo.list_rooms_for_user(user_id)
+
+ def get_room(self, room_id: str) -> ChatRoom | None:
+ return self.repo.get_room(room_id)
+
+ def create_message(self, room_id: str, sender_id: str, content: str) -> Message:
+ return self.repo.create_message(room_id, sender_id, content)
+
+ def list_messages(self, room_id: str, offset: int = 0, limit: int = 100):
+ return self.repo.list_messages(room_id, offset=offset, limit=limit)
diff --git a/services/match/alembic/env.py b/services/match/alembic/env.py
index df746af..034d7df 100644
--- a/services/match/alembic/env.py
+++ b/services/match/alembic/env.py
@@ -12,6 +12,7 @@ if SRC_DIR not in sys.path:
sys.path.append(SRC_DIR)
from app.db.session import Base # noqa
+from app import models # noqa: F401
config = context.config
diff --git a/services/match/alembic/script.py.mako b/services/match/alembic/script.py.mako
new file mode 100644
index 0000000..e378537
--- /dev/null
+++ b/services/match/alembic/script.py.mako
@@ -0,0 +1,22 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = ${repr(up_revision)}
+down_revision = ${repr(down_revision)}
+branch_labels = ${repr(branch_labels)}
+depends_on = ${repr(depends_on)}
+
+def upgrade():
+ ${upgrades if upgrades else "pass"}
+
+
+def downgrade():
+ ${downgrades if downgrades else "pass"}
diff --git a/services/match/alembic/versions/00ce87deada6_init.py b/services/match/alembic/versions/00ce87deada6_init.py
new file mode 100644
index 0000000..62e7950
--- /dev/null
+++ b/services/match/alembic/versions/00ce87deada6_init.py
@@ -0,0 +1,41 @@
+"""init
+
+Revision ID: 00ce87deada6
+Revises:
+Create Date: 2025-08-08 11:20:06.424809+00:00
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = '00ce87deada6'
+down_revision = None
+branch_labels = None
+depends_on = None
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('match_pairs',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('user_id_a', sa.UUID(), nullable=False),
+ sa.Column('user_id_b', sa.UUID(), nullable=False),
+ sa.Column('status', sa.String(length=16), nullable=False),
+ sa.Column('score', sa.Float(), nullable=True),
+ sa.Column('notes', sa.String(length=1000), nullable=True),
+ sa.Column('created_by', sa.UUID(), nullable=True),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_match_pairs_user_id_a'), 'match_pairs', ['user_id_a'], unique=False)
+ op.create_index(op.f('ix_match_pairs_user_id_b'), 'match_pairs', ['user_id_b'], unique=False)
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_index(op.f('ix_match_pairs_user_id_b'), table_name='match_pairs')
+ op.drop_index(op.f('ix_match_pairs_user_id_a'), table_name='match_pairs')
+ op.drop_table('match_pairs')
+ # ### end Alembic commands ###
diff --git a/services/match/requirements.txt b/services/match/requirements.txt
index 87d1574..1b92356 100644
--- a/services/match/requirements.txt
+++ b/services/match/requirements.txt
@@ -8,3 +8,4 @@ pydantic-settings
python-dotenv
httpx>=0.27
pytest
+PyJWT>=2.8
diff --git a/services/match/src/app/api/routes/pairs.py b/services/match/src/app/api/routes/pairs.py
new file mode 100644
index 0000000..a2e8053
--- /dev/null
+++ b/services/match/src/app/api/routes/pairs.py
@@ -0,0 +1,70 @@
+from __future__ import annotations
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy.orm import Session
+
+from app.db.session import get_db
+from app.core.security import get_current_user, require_roles, UserClaims
+from app.schemas.pair import PairCreate, PairUpdate, PairRead
+from app.services.pair_service import PairService
+
+router = APIRouter(prefix="/v1/pairs", tags=["pairs"])
+
+@router.post("", response_model=PairRead, status_code=201)
+def create_pair(payload: PairCreate, db: Session = Depends(get_db),
+ user: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))):
+ svc = PairService(db)
+ return svc.create(user_id_a=payload.user_id_a, user_id_b=payload.user_id_b,
+ score=payload.score, notes=payload.notes, created_by=user.sub)
+
+@router.get("", response_model=list[PairRead])
+def list_pairs(for_user_id: str | None = None, status: str | None = None,
+ offset: int = 0, limit: int = Query(50, le=200),
+ db: Session = Depends(get_db),
+ _: UserClaims = Depends(get_current_user)):
+ return PairService(db).list(for_user_id=for_user_id, status=status, offset=offset, limit=limit)
+
+@router.get("/{pair_id}", response_model=PairRead)
+def get_pair(pair_id: str, db: Session = Depends(get_db), _: UserClaims = Depends(get_current_user)):
+ obj = PairService(db).get(pair_id)
+ if not obj:
+ raise HTTPException(status_code=404, detail="Not found")
+ return obj
+
+@router.patch("/{pair_id}", response_model=PairRead)
+def update_pair(pair_id: str, payload: PairUpdate, db: Session = Depends(get_db),
+ _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))):
+ svc = PairService(db)
+ obj = svc.get(pair_id)
+ if not obj:
+ raise HTTPException(status_code=404, detail="Not found")
+ return svc.update(obj, **payload.model_dump(exclude_none=True))
+
+@router.post("/{pair_id}/accept", response_model=PairRead)
+def accept(pair_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ svc = PairService(db)
+ obj = svc.get(pair_id)
+ if not obj:
+ raise HTTPException(status_code=404, detail="Not found")
+ # Validate that current user participates
+ if user.sub not in (str(obj.user_id_a), str(obj.user_id_b)):
+ raise HTTPException(status_code=403, detail="Not allowed")
+ return svc.set_status(obj, "accepted")
+
+@router.post("/{pair_id}/reject", response_model=PairRead)
+def reject(pair_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ svc = PairService(db)
+ obj = svc.get(pair_id)
+ if not obj:
+ raise HTTPException(status_code=404, detail="Not found")
+ if user.sub not in (str(obj.user_id_a), str(obj.user_id_b)):
+ raise HTTPException(status_code=403, detail="Not allowed")
+ return svc.set_status(obj, "rejected")
+
+@router.delete("/{pair_id}", status_code=204)
+def delete_pair(pair_id: str, db: Session = Depends(get_db),
+ _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))):
+ svc = PairService(db)
+ obj = svc.get(pair_id)
+ if not obj:
+ return
+ svc.delete(obj)
diff --git a/services/match/src/app/core/security.py b/services/match/src/app/core/security.py
new file mode 100644
index 0000000..6842ef3
--- /dev/null
+++ b/services/match/src/app/core/security.py
@@ -0,0 +1,40 @@
+from __future__ import annotations
+import os
+from enum import Enum
+from typing import Any, Callable
+
+import jwt
+from fastapi import Depends, HTTPException, status
+from fastapi.security import OAuth2PasswordBearer
+from pydantic import BaseModel
+
+JWT_SECRET = os.getenv("JWT_SECRET", "devsecret_change_me")
+JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
+
+class UserClaims(BaseModel):
+ sub: str
+ email: str
+ role: str
+ type: str
+ exp: int
+
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/v1/token")
+
+def decode_token(token: str) -> UserClaims:
+ try:
+ payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
+ return UserClaims(**payload)
+ except jwt.ExpiredSignatureError:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
+ except jwt.PyJWTError:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
+
+def get_current_user(token: str = Depends(oauth2_scheme)) -> UserClaims:
+ return decode_token(token)
+
+def require_roles(*roles: str):
+ def dep(user: UserClaims = Depends(get_current_user)) -> UserClaims:
+ if roles and user.role not in roles:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient role")
+ return user
+ return dep
diff --git a/services/match/src/app/main.py b/services/match/src/app/main.py
index b185770..e0c339d 100644
--- a/services/match/src/app/main.py
+++ b/services/match/src/app/main.py
@@ -1,5 +1,6 @@
from fastapi import FastAPI
from .api.routes.ping import router as ping_router
+from .api.routes.pairs import router as pairs_router
app = FastAPI(title="MATCH Service")
@@ -7,5 +8,5 @@ app = FastAPI(title="MATCH Service")
def health():
return {"status": "ok", "service": "match"}
-# v1 API
app.include_router(ping_router, prefix="/v1")
+app.include_router(pairs_router)
diff --git a/services/match/src/app/models/__init__.py b/services/match/src/app/models/__init__.py
index e69de29..fbf60b8 100644
--- a/services/match/src/app/models/__init__.py
+++ b/services/match/src/app/models/__init__.py
@@ -0,0 +1 @@
+from .pair import MatchPair # noqa
diff --git a/services/match/src/app/models/pair.py b/services/match/src/app/models/pair.py
new file mode 100644
index 0000000..8847535
--- /dev/null
+++ b/services/match/src/app/models/pair.py
@@ -0,0 +1,22 @@
+from __future__ import annotations
+import uuid
+from datetime import datetime
+from sqlalchemy import String, Float, DateTime
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import Mapped, mapped_column
+from sqlalchemy.sql import func
+
+from app.db.session import Base
+
+class MatchPair(Base):
+ __tablename__ = "match_pairs"
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ # User IDs to validate permissions; profile IDs можно добавить позже
+ user_id_a: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
+ user_id_b: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
+ status: Mapped[str] = mapped_column(String(16), default="proposed") # proposed/accepted/rejected/blocked
+ score: Mapped[float | None] = mapped_column(Float, default=None)
+ notes: Mapped[str | None] = mapped_column(String(1000), default=None)
+ created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True) # matchmaker/admin
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
diff --git a/services/match/src/app/repositories/pair_repository.py b/services/match/src/app/repositories/pair_repository.py
new file mode 100644
index 0000000..57984b3
--- /dev/null
+++ b/services/match/src/app/repositories/pair_repository.py
@@ -0,0 +1,43 @@
+from __future__ import annotations
+from typing import Optional, Sequence
+from sqlalchemy import select, or_
+from sqlalchemy.orm import Session
+
+from app.models.pair import MatchPair
+
+class PairRepository:
+ def __init__(self, db: Session):
+ self.db = db
+
+ def create(self, **fields) -> MatchPair:
+ obj = MatchPair(**fields)
+ self.db.add(obj)
+ self.db.commit()
+ self.db.refresh(obj)
+ return obj
+
+ def get(self, pair_id) -> Optional[MatchPair]:
+ return self.db.get(MatchPair, pair_id)
+
+ def list(self, *, for_user_id: str | None = None, status: str | None = None,
+ offset: int = 0, limit: int = 50) -> Sequence[MatchPair]:
+ stmt = select(MatchPair)
+ if for_user_id:
+ stmt = stmt.where(or_(MatchPair.user_id_a == for_user_id, MatchPair.user_id_b == for_user_id))
+ if status:
+ stmt = stmt.where(MatchPair.status == status)
+ stmt = stmt.offset(offset).limit(limit).order_by(MatchPair.created_at.desc())
+ return self.db.execute(stmt).scalars().all()
+
+ def update(self, obj: MatchPair, **fields) -> MatchPair:
+ for k, v in fields.items():
+ if v is not None:
+ setattr(obj, k, v)
+ self.db.add(obj)
+ self.db.commit()
+ self.db.refresh(obj)
+ return obj
+
+ def delete(self, obj: MatchPair) -> None:
+ self.db.delete(obj)
+ self.db.commit()
diff --git a/services/match/src/app/schemas/pair.py b/services/match/src/app/schemas/pair.py
new file mode 100644
index 0000000..310798a
--- /dev/null
+++ b/services/match/src/app/schemas/pair.py
@@ -0,0 +1,22 @@
+from __future__ import annotations
+from typing import Optional
+from pydantic import BaseModel, ConfigDict
+
+class PairCreate(BaseModel):
+ user_id_a: str
+ user_id_b: str
+ score: Optional[float] = None
+ notes: Optional[str] = None
+
+class PairUpdate(BaseModel):
+ score: Optional[float] = None
+ notes: Optional[str] = None
+
+class PairRead(BaseModel):
+ id: str
+ user_id_a: str
+ user_id_b: str
+ status: str
+ score: Optional[float] = None
+ notes: Optional[str] = None
+ model_config = ConfigDict(from_attributes=True)
diff --git a/services/match/src/app/services/pair_service.py b/services/match/src/app/services/pair_service.py
new file mode 100644
index 0000000..d88961b
--- /dev/null
+++ b/services/match/src/app/services/pair_service.py
@@ -0,0 +1,27 @@
+from __future__ import annotations
+from sqlalchemy.orm import Session
+from typing import Optional
+from app.repositories.pair_repository import PairRepository
+from app.models.pair import MatchPair
+
+class PairService:
+ def __init__(self, db: Session):
+ self.repo = PairRepository(db)
+
+ def create(self, **fields) -> MatchPair:
+ return self.repo.create(**fields)
+
+ def get(self, pair_id) -> Optional[MatchPair]:
+ return self.repo.get(pair_id)
+
+ def list(self, **filters):
+ return self.repo.list(**filters)
+
+ def update(self, obj: MatchPair, **fields) -> MatchPair:
+ return self.repo.update(obj, **fields)
+
+ def delete(self, obj: MatchPair) -> None:
+ return self.repo.delete(obj)
+
+ def set_status(self, obj: MatchPair, status: str) -> MatchPair:
+ return self.repo.update(obj, status=status)
diff --git a/services/payments/alembic/env.py b/services/payments/alembic/env.py
index df746af..034d7df 100644
--- a/services/payments/alembic/env.py
+++ b/services/payments/alembic/env.py
@@ -12,6 +12,7 @@ if SRC_DIR not in sys.path:
sys.path.append(SRC_DIR)
from app.db.session import Base # noqa
+from app import models # noqa: F401
config = context.config
diff --git a/services/payments/alembic/script.py.mako b/services/payments/alembic/script.py.mako
new file mode 100644
index 0000000..e378537
--- /dev/null
+++ b/services/payments/alembic/script.py.mako
@@ -0,0 +1,22 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = ${repr(up_revision)}
+down_revision = ${repr(down_revision)}
+branch_labels = ${repr(branch_labels)}
+depends_on = ${repr(depends_on)}
+
+def upgrade():
+ ${upgrades if upgrades else "pass"}
+
+
+def downgrade():
+ ${downgrades if downgrades else "pass"}
diff --git a/services/payments/alembic/versions/6641523a6967_init.py b/services/payments/alembic/versions/6641523a6967_init.py
new file mode 100644
index 0000000..226a638
--- /dev/null
+++ b/services/payments/alembic/versions/6641523a6967_init.py
@@ -0,0 +1,38 @@
+"""init
+
+Revision ID: 6641523a6967
+Revises:
+Create Date: 2025-08-08 11:20:09.064584+00:00
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = '6641523a6967'
+down_revision = None
+branch_labels = None
+depends_on = None
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('invoices',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('client_id', sa.UUID(), nullable=False),
+ sa.Column('amount', sa.Numeric(precision=12, scale=2), nullable=False),
+ sa.Column('currency', sa.String(length=3), nullable=False),
+ sa.Column('status', sa.String(length=16), nullable=False),
+ sa.Column('description', sa.String(length=500), nullable=True),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_invoices_client_id'), 'invoices', ['client_id'], unique=False)
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_index(op.f('ix_invoices_client_id'), table_name='invoices')
+ op.drop_table('invoices')
+ # ### end Alembic commands ###
diff --git a/services/payments/requirements.txt b/services/payments/requirements.txt
index 87d1574..1b92356 100644
--- a/services/payments/requirements.txt
+++ b/services/payments/requirements.txt
@@ -8,3 +8,4 @@ pydantic-settings
python-dotenv
httpx>=0.27
pytest
+PyJWT>=2.8
diff --git a/services/payments/src/app/api/routes/payments.py b/services/payments/src/app/api/routes/payments.py
new file mode 100644
index 0000000..89b09ab
--- /dev/null
+++ b/services/payments/src/app/api/routes/payments.py
@@ -0,0 +1,62 @@
+from __future__ import annotations
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy.orm import Session
+
+from app.db.session import get_db
+from app.core.security import get_current_user, require_roles, UserClaims
+from app.schemas.payment import InvoiceCreate, InvoiceUpdate, InvoiceRead
+from app.services.payment_service import PaymentService
+
+router = APIRouter(prefix="/v1/invoices", tags=["payments"])
+
+@router.post("", response_model=InvoiceRead, status_code=201)
+def create_invoice(payload: InvoiceCreate, db: Session = Depends(get_db),
+ _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))):
+ return PaymentService(db).create_invoice(**payload.model_dump(exclude_none=True))
+
+@router.get("", response_model=list[InvoiceRead])
+def list_invoices(client_id: str | None = None, status: str | None = None,
+ offset: int = 0, limit: int = Query(50, le=200),
+ db: Session = Depends(get_db),
+ user: UserClaims = Depends(get_current_user)):
+ # Клиент видит только свои инвойсы, админ/матчмейкер — любые
+ if user.role in ("ADMIN","MATCHMAKER"):
+ return PaymentService(db).list_invoices(client_id=client_id, status=status, offset=offset, limit=limit)
+ else:
+ return PaymentService(db).list_invoices(client_id=user.sub, status=status, offset=offset, limit=limit)
+
+@router.get("/{inv_id}", response_model=InvoiceRead)
+def get_invoice(inv_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
+ inv = PaymentService(db).get_invoice(inv_id)
+ if not inv:
+ raise HTTPException(status_code=404, detail="Not found")
+ if user.role not in ("ADMIN","MATCHMAKER") and str(inv.client_id) != user.sub:
+ raise HTTPException(status_code=403, detail="Not allowed")
+ return inv
+
+@router.patch("/{inv_id}", response_model=InvoiceRead)
+def update_invoice(inv_id: str, payload: InvoiceUpdate, db: Session = Depends(get_db),
+ _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))):
+ svc = PaymentService(db)
+ inv = svc.get_invoice(inv_id)
+ if not inv:
+ raise HTTPException(status_code=404, detail="Not found")
+ return svc.update_invoice(inv, **payload.model_dump(exclude_none=True))
+
+@router.post("/{inv_id}/mark-paid", response_model=InvoiceRead)
+def mark_paid(inv_id: str, db: Session = Depends(get_db),
+ _: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))):
+ svc = PaymentService(db)
+ inv = svc.get_invoice(inv_id)
+ if not inv:
+ raise HTTPException(status_code=404, detail="Not found")
+ return svc.mark_paid(inv)
+
+@router.delete("/{inv_id}", status_code=204)
+def delete_invoice(inv_id: str, db: Session = Depends(get_db),
+ _: UserClaims = Depends(require_roles("ADMIN"))):
+ svc = PaymentService(db)
+ inv = svc.get_invoice(inv_id)
+ if not inv:
+ return
+ svc.delete_invoice(inv)
diff --git a/services/payments/src/app/core/security.py b/services/payments/src/app/core/security.py
new file mode 100644
index 0000000..6842ef3
--- /dev/null
+++ b/services/payments/src/app/core/security.py
@@ -0,0 +1,40 @@
+from __future__ import annotations
+import os
+from enum import Enum
+from typing import Any, Callable
+
+import jwt
+from fastapi import Depends, HTTPException, status
+from fastapi.security import OAuth2PasswordBearer
+from pydantic import BaseModel
+
+JWT_SECRET = os.getenv("JWT_SECRET", "devsecret_change_me")
+JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
+
+class UserClaims(BaseModel):
+ sub: str
+ email: str
+ role: str
+ type: str
+ exp: int
+
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/v1/token")
+
+def decode_token(token: str) -> UserClaims:
+ try:
+ payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
+ return UserClaims(**payload)
+ except jwt.ExpiredSignatureError:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
+ except jwt.PyJWTError:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
+
+def get_current_user(token: str = Depends(oauth2_scheme)) -> UserClaims:
+ return decode_token(token)
+
+def require_roles(*roles: str):
+ def dep(user: UserClaims = Depends(get_current_user)) -> UserClaims:
+ if roles and user.role not in roles:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient role")
+ return user
+ return dep
diff --git a/services/payments/src/app/main.py b/services/payments/src/app/main.py
index 58c9f4f..46148c2 100644
--- a/services/payments/src/app/main.py
+++ b/services/payments/src/app/main.py
@@ -1,5 +1,6 @@
from fastapi import FastAPI
from .api.routes.ping import router as ping_router
+from .api.routes.payments import router as payments_router
app = FastAPI(title="PAYMENTS Service")
@@ -7,5 +8,5 @@ app = FastAPI(title="PAYMENTS Service")
def health():
return {"status": "ok", "service": "payments"}
-# v1 API
app.include_router(ping_router, prefix="/v1")
+app.include_router(payments_router)
diff --git a/services/payments/src/app/models/__init__.py b/services/payments/src/app/models/__init__.py
index e69de29..ffbe1c2 100644
--- a/services/payments/src/app/models/__init__.py
+++ b/services/payments/src/app/models/__init__.py
@@ -0,0 +1 @@
+from .payment import Invoice # noqa
diff --git a/services/payments/src/app/models/payment.py b/services/payments/src/app/models/payment.py
new file mode 100644
index 0000000..5ad8684
--- /dev/null
+++ b/services/payments/src/app/models/payment.py
@@ -0,0 +1,20 @@
+from __future__ import annotations
+import uuid
+from datetime import datetime
+from sqlalchemy import String, DateTime, Numeric
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import Mapped, mapped_column
+from sqlalchemy.sql import func
+
+from app.db.session import Base
+
+class Invoice(Base):
+ __tablename__ = "invoices"
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ client_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
+ amount: Mapped[float] = mapped_column(Numeric(12,2), nullable=False)
+ currency: Mapped[str] = mapped_column(String(3), default="USD")
+ status: Mapped[str] = mapped_column(String(16), default="pending") # pending/paid/canceled
+ description: Mapped[str | None] = mapped_column(String(500), default=None)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
diff --git a/services/payments/src/app/repositories/payment_repository.py b/services/payments/src/app/repositories/payment_repository.py
new file mode 100644
index 0000000..0fa95cd
--- /dev/null
+++ b/services/payments/src/app/repositories/payment_repository.py
@@ -0,0 +1,43 @@
+from __future__ import annotations
+from typing import Optional, Sequence
+from sqlalchemy.orm import Session
+from sqlalchemy import select
+
+from app.models.payment import Invoice
+
+class PaymentRepository:
+ def __init__(self, db: Session):
+ self.db = db
+
+ def create_invoice(self, **fields) -> Invoice:
+ obj = Invoice(**fields)
+ self.db.add(obj)
+ self.db.commit()
+ self.db.refresh(obj)
+ return obj
+
+ def get_invoice(self, inv_id) -> Optional[Invoice]:
+ return self.db.get(Invoice, inv_id)
+
+ def list_invoices(self, *, client_id: str | None = None, status: str | None = None,
+ offset: int = 0, limit: int = 50) -> Sequence[Invoice]:
+ stmt = select(Invoice)
+ if client_id:
+ stmt = stmt.where(Invoice.client_id == client_id)
+ if status:
+ stmt = stmt.where(Invoice.status == status)
+ stmt = stmt.offset(offset).limit(limit).order_by(Invoice.created_at.desc())
+ return self.db.execute(stmt).scalars().all()
+
+ def update_invoice(self, obj: Invoice, **fields) -> Invoice:
+ for k, v in fields.items():
+ if v is not None:
+ setattr(obj, k, v)
+ self.db.add(obj)
+ self.db.commit()
+ self.db.refresh(obj)
+ return obj
+
+ def delete_invoice(self, obj: Invoice) -> None:
+ self.db.delete(obj)
+ self.db.commit()
diff --git a/services/payments/src/app/schemas/payment.py b/services/payments/src/app/schemas/payment.py
new file mode 100644
index 0000000..7947d40
--- /dev/null
+++ b/services/payments/src/app/schemas/payment.py
@@ -0,0 +1,24 @@
+from __future__ import annotations
+from typing import Optional
+from pydantic import BaseModel, ConfigDict
+
+class InvoiceCreate(BaseModel):
+ client_id: str
+ amount: float
+ currency: str = "USD"
+ description: Optional[str] = None
+
+class InvoiceUpdate(BaseModel):
+ amount: Optional[float] = None
+ currency: Optional[str] = None
+ description: Optional[str] = None
+ status: Optional[str] = None
+
+class InvoiceRead(BaseModel):
+ id: str
+ client_id: str
+ amount: float
+ currency: str
+ status: str
+ description: Optional[str] = None
+ model_config = ConfigDict(from_attributes=True)
diff --git a/services/payments/src/app/services/payment_service.py b/services/payments/src/app/services/payment_service.py
new file mode 100644
index 0000000..f279f41
--- /dev/null
+++ b/services/payments/src/app/services/payment_service.py
@@ -0,0 +1,27 @@
+from __future__ import annotations
+from sqlalchemy.orm import Session
+from typing import Optional
+from app.repositories.payment_repository import PaymentRepository
+from app.models.payment import Invoice
+
+class PaymentService:
+ def __init__(self, db: Session):
+ self.repo = PaymentRepository(db)
+
+ def create_invoice(self, **fields) -> Invoice:
+ return self.repo.create_invoice(**fields)
+
+ def get_invoice(self, inv_id) -> Invoice | None:
+ return self.repo.get_invoice(inv_id)
+
+ def list_invoices(self, **filters):
+ return self.repo.list_invoices(**filters)
+
+ def update_invoice(self, obj: Invoice, **fields) -> Invoice:
+ return self.repo.update_invoice(obj, **fields)
+
+ def delete_invoice(self, obj: Invoice) -> None:
+ return self.repo.delete_invoice(obj)
+
+ def mark_paid(self, obj: Invoice) -> Invoice:
+ return self.repo.update_invoice(obj, status="paid")
diff --git a/services/profiles/alembic/env.py b/services/profiles/alembic/env.py
index df746af..034d7df 100644
--- a/services/profiles/alembic/env.py
+++ b/services/profiles/alembic/env.py
@@ -12,6 +12,7 @@ if SRC_DIR not in sys.path:
sys.path.append(SRC_DIR)
from app.db.session import Base # noqa
+from app import models # noqa: F401
config = context.config
diff --git a/services/profiles/alembic/script.py.mako b/services/profiles/alembic/script.py.mako
new file mode 100644
index 0000000..e378537
--- /dev/null
+++ b/services/profiles/alembic/script.py.mako
@@ -0,0 +1,22 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = ${repr(up_revision)}
+down_revision = ${repr(down_revision)}
+branch_labels = ${repr(branch_labels)}
+depends_on = ${repr(depends_on)}
+
+def upgrade():
+ ${upgrades if upgrades else "pass"}
+
+
+def downgrade():
+ ${downgrades if downgrades else "pass"}
diff --git a/services/profiles/alembic/versions/5c69d1403313_add_fk_photos_profile_id_profiles_id.py b/services/profiles/alembic/versions/5c69d1403313_add_fk_photos_profile_id_profiles_id.py
new file mode 100644
index 0000000..c5685a1
--- /dev/null
+++ b/services/profiles/alembic/versions/5c69d1403313_add_fk_photos_profile_id_profiles_id.py
@@ -0,0 +1,26 @@
+"""add FK photos.profile_id -> profiles.id
+
+Revision ID: 5c69d1403313
+Revises: 769f535c9249
+Create Date: 2025-08-08 11:43:53.014776+00:00
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = '5c69d1403313'
+down_revision = '769f535c9249'
+branch_labels = None
+depends_on = None
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_foreign_key(None, 'photos', 'profiles', ['profile_id'], ['id'], ondelete='CASCADE')
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_constraint(None, 'photos', type_='foreignkey')
+ # ### end Alembic commands ###
diff --git a/services/profiles/alembic/versions/769f535c9249_init.py b/services/profiles/alembic/versions/769f535c9249_init.py
new file mode 100644
index 0000000..6f6ba0c
--- /dev/null
+++ b/services/profiles/alembic/versions/769f535c9249_init.py
@@ -0,0 +1,55 @@
+"""init
+
+Revision ID: 769f535c9249
+Revises:
+Create Date: 2025-08-08 11:20:05.142049+00:00
+"""
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision = '769f535c9249'
+down_revision = None
+branch_labels = None
+depends_on = None
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('photos',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('profile_id', sa.UUID(), nullable=False),
+ sa.Column('url', sa.String(length=500), nullable=False),
+ sa.Column('is_main', sa.Boolean(), nullable=False),
+ sa.Column('status', sa.String(length=16), nullable=False),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_photos_profile_id'), 'photos', ['profile_id'], unique=False)
+ op.create_table('profiles',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('user_id', sa.UUID(), nullable=False),
+ sa.Column('gender', sa.String(length=16), nullable=False),
+ sa.Column('birthdate', sa.Date(), nullable=True),
+ sa.Column('city', sa.String(length=120), nullable=True),
+ sa.Column('bio', sa.Text(), nullable=True),
+ sa.Column('languages', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
+ sa.Column('interests', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
+ sa.Column('preferences', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
+ sa.Column('verification_status', sa.String(length=16), nullable=False),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_profiles_user_id'), 'profiles', ['user_id'], unique=False)
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_index(op.f('ix_profiles_user_id'), table_name='profiles')
+ op.drop_table('profiles')
+ op.drop_index(op.f('ix_photos_profile_id'), table_name='photos')
+ op.drop_table('photos')
+ # ### end Alembic commands ###
diff --git a/services/profiles/docker-entrypoint.sh b/services/profiles/docker-entrypoint.sh
index 2828898..ae2ee5e 100755
--- a/services/profiles/docker-entrypoint.sh
+++ b/services/profiles/docker-entrypoint.sh
@@ -3,4 +3,4 @@ set -e
# Run migrations (no-op if no revisions yet)
alembic -c alembic.ini upgrade head || true
# Start app
-exec uvicorn app.main:app --host 0.0.0.0 --port 8000
+exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --log-level debug
diff --git a/services/profiles/requirements.txt b/services/profiles/requirements.txt
index 87d1574..1b92356 100644
--- a/services/profiles/requirements.txt
+++ b/services/profiles/requirements.txt
@@ -8,3 +8,4 @@ pydantic-settings
python-dotenv
httpx>=0.27
pytest
+PyJWT>=2.8
diff --git a/services/profiles/src/app/api/routes/profiles.py b/services/profiles/src/app/api/routes/profiles.py
new file mode 100644
index 0000000..437b933
--- /dev/null
+++ b/services/profiles/src/app/api/routes/profiles.py
@@ -0,0 +1,31 @@
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy.orm import Session
+
+from app.db.deps import get_db
+from app.core.security import get_current_user, JwtUser
+from app.schemas.profile import ProfileCreate, ProfileOut
+from app.repositories.profile_repository import ProfileRepository
+from app.services.profile_service import ProfileService
+
+# отключаем авто-редирект /path -> /path/
+router = APIRouter(prefix="/v1/profiles", tags=["profiles"], redirect_slashes=False)
+
+@router.get("/me", response_model=ProfileOut)
+def get_my_profile(current: JwtUser = Depends(get_current_user),
+ db: Session = Depends(get_db)):
+ svc = ProfileService(ProfileRepository(db))
+ p = svc.get_by_user(current.sub)
+ if not p:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
+ return p
+
+@router.post("", response_model=ProfileOut, status_code=status.HTTP_201_CREATED)
+def create_my_profile(payload: ProfileCreate,
+ current: JwtUser = Depends(get_current_user),
+ db: Session = Depends(get_db)):
+ svc = ProfileService(ProfileRepository(db))
+ existing = svc.get_by_user(current.sub)
+ if existing:
+ # если хотите строго — верните 409; оставлю 200/201 для удобства e2e
+ return existing
+ return svc.create(current.sub, payload)
diff --git a/services/profiles/src/app/core/security.py b/services/profiles/src/app/core/security.py
new file mode 100644
index 0000000..e179799
--- /dev/null
+++ b/services/profiles/src/app/core/security.py
@@ -0,0 +1,59 @@
+import os
+from typing import Optional
+
+import jwt
+from fastapi import Depends, HTTPException, status
+from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
+from pydantic import BaseModel
+
+reusable_bearer = HTTPBearer(auto_error=True)
+
+JWT_SECRET = os.getenv("JWT_SECRET", "dev-secret")
+JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
+
+# Возможность включить строгую проверку audience/issuer в будущем
+JWT_VERIFY_AUD = os.getenv("JWT_VERIFY_AUD", "0") == "1"
+JWT_AUDIENCE: Optional[str] = os.getenv("JWT_AUDIENCE") or None
+JWT_VERIFY_ISS = os.getenv("JWT_VERIFY_ISS", "0") == "1"
+JWT_ISSUER: Optional[str] = os.getenv("JWT_ISSUER") or None
+
+# Допустимая рассинхронизация часов (сек)
+JWT_LEEWAY = int(os.getenv("JWT_LEEWAY", "30"))
+
+class JwtUser(BaseModel):
+ sub: str
+ email: Optional[str] = None
+ role: Optional[str] = None
+
+def decode_token(token: str) -> JwtUser:
+ options = {
+ "verify_signature": True,
+ "verify_exp": True,
+ "verify_aud": JWT_VERIFY_AUD,
+ "verify_iss": JWT_VERIFY_ISS,
+ }
+ kwargs = {"algorithms": [JWT_ALGORITHM], "options": options, "leeway": JWT_LEEWAY}
+ if JWT_VERIFY_AUD and JWT_AUDIENCE:
+ kwargs["audience"] = JWT_AUDIENCE
+ if JWT_VERIFY_ISS and JWT_ISSUER:
+ kwargs["issuer"] = JWT_ISSUER
+
+ try:
+ payload = jwt.decode(token, JWT_SECRET, **kwargs)
+ sub = str(payload.get("sub") or "")
+ if not sub:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token: no sub")
+ return JwtUser(sub=sub, email=payload.get("email"), role=payload.get("role"))
+ except jwt.ExpiredSignatureError:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
+ except jwt.InvalidAudienceError:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid audience")
+ except jwt.InvalidIssuerError:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid issuer")
+ except jwt.InvalidTokenError:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
+
+def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(reusable_bearer)) -> JwtUser:
+ if credentials.scheme.lower() != "bearer":
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid auth scheme")
+ return decode_token(credentials.credentials)
diff --git a/services/profiles/src/app/db/deps.py b/services/profiles/src/app/db/deps.py
new file mode 100644
index 0000000..95e6616
--- /dev/null
+++ b/services/profiles/src/app/db/deps.py
@@ -0,0 +1,10 @@
+from typing import Generator
+from sqlalchemy.orm import Session
+from app.db.session import SessionLocal # должен существовать в проекте
+
+def get_db() -> Generator[Session, None, None]:
+ db = SessionLocal()
+ try:
+ yield db
+ finally:
+ db.close()
diff --git a/services/profiles/src/app/main.py b/services/profiles/src/app/main.py
index c4b5a7f..a903e4f 100644
--- a/services/profiles/src/app/main.py
+++ b/services/profiles/src/app/main.py
@@ -1,5 +1,6 @@
from fastapi import FastAPI
from .api.routes.ping import router as ping_router
+from .api.routes.profiles import router as profiles_router
app = FastAPI(title="PROFILES Service")
@@ -7,5 +8,5 @@ app = FastAPI(title="PROFILES Service")
def health():
return {"status": "ok", "service": "profiles"}
-# v1 API
app.include_router(ping_router, prefix="/v1")
+app.include_router(profiles_router)
diff --git a/services/profiles/src/app/models/__init__.py b/services/profiles/src/app/models/__init__.py
index e69de29..8e3d0d6 100644
--- a/services/profiles/src/app/models/__init__.py
+++ b/services/profiles/src/app/models/__init__.py
@@ -0,0 +1,2 @@
+from .profile import Profile # noqa
+from .photo import Photo # noqa
diff --git a/services/profiles/src/app/models/photo.py b/services/profiles/src/app/models/photo.py
new file mode 100644
index 0000000..4b790cb
--- /dev/null
+++ b/services/profiles/src/app/models/photo.py
@@ -0,0 +1,27 @@
+from __future__ import annotations
+import uuid
+from datetime import datetime
+
+from sqlalchemy import String, Boolean, DateTime, ForeignKey
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+from sqlalchemy.sql import func
+
+from app.db.session import Base
+
+class Photo(Base):
+ __tablename__ = "photos"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ profile_id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True),
+ ForeignKey("profiles.id", ondelete="CASCADE"),
+ index=True,
+ nullable=False,
+ )
+ url: Mapped[str] = mapped_column(String(500), nullable=False)
+ is_main: Mapped[bool] = mapped_column(Boolean, default=False)
+ status: Mapped[str] = mapped_column(String(16), default="pending") # pending/approved/rejected
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+
+ profile = relationship("Profile", back_populates="photos")
diff --git a/services/profiles/src/app/models/profile.py b/services/profiles/src/app/models/profile.py
new file mode 100644
index 0000000..ef84110
--- /dev/null
+++ b/services/profiles/src/app/models/profile.py
@@ -0,0 +1,29 @@
+from __future__ import annotations
+import uuid
+from datetime import date, datetime
+
+from sqlalchemy import String, Date, DateTime, Text
+from sqlalchemy.dialects.postgresql import UUID, JSONB
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+from sqlalchemy.sql import func
+from typing import Optional
+
+from app.db.session import Base
+
+class Profile(Base):
+ __tablename__ = "profiles"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
+ gender: Mapped[str] = mapped_column(String(16), nullable=False) # male/female/other
+ birthdate: Mapped[date | None] = mapped_column(Date, default=None)
+ city: Mapped[str | None] = mapped_column(String(120), default=None)
+ bio: Mapped[str | None] = mapped_column(Text, default=None)
+ languages: Mapped[Optional[list[str]]] = mapped_column(JSONB, default=list)
+ interests: Mapped[Optional[list[str]]] = mapped_column(JSONB, default=list)
+ preferences: Mapped[dict | None] = mapped_column(JSONB, default=dict)
+ verification_status: Mapped[str] = mapped_column(String(16), default="unverified")
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
+
+ photos = relationship("Photo", back_populates="profile", cascade="all, delete-orphan")
diff --git a/services/profiles/src/app/repositories/profile_repository.py b/services/profiles/src/app/repositories/profile_repository.py
new file mode 100644
index 0000000..3ad39eb
--- /dev/null
+++ b/services/profiles/src/app/repositories/profile_repository.py
@@ -0,0 +1,26 @@
+from typing import Optional
+from uuid import UUID
+from sqlalchemy.orm import Session
+from sqlalchemy import select
+from app.models.profile import Profile
+from app.schemas.profile import ProfileCreate
+
+class ProfileRepository:
+ def __init__(self, db: Session):
+ self.db = db
+
+ def get_by_user(self, user_id: UUID) -> Optional[Profile]:
+ return self.db.execute(select(Profile).where(Profile.user_id == user_id)).scalar_one_or_none()
+
+ def create(self, user_id: UUID, data: ProfileCreate) -> Profile:
+ p = Profile(
+ user_id=user_id,
+ gender=data.gender,
+ city=data.city,
+ languages=list(data.languages or []),
+ interests=list(data.interests or []),
+ )
+ self.db.add(p)
+ self.db.commit()
+ self.db.refresh(p)
+ return p
diff --git a/services/profiles/src/app/schemas/profile.py b/services/profiles/src/app/schemas/profile.py
new file mode 100644
index 0000000..55d6123
--- /dev/null
+++ b/services/profiles/src/app/schemas/profile.py
@@ -0,0 +1,32 @@
+from __future__ import annotations
+from typing import List
+from uuid import UUID
+
+try:
+ # Pydantic v2
+ from pydantic import BaseModel, Field, ConfigDict
+ _V2 = True
+except Exception:
+ # Pydantic v1 fallback
+ from pydantic import BaseModel, Field
+ ConfigDict = None
+ _V2 = False
+
+class ProfileBase(BaseModel):
+ gender: str
+ city: str
+ languages: List[str] = Field(default_factory=list)
+ interests: List[str] = Field(default_factory=list)
+
+class ProfileCreate(ProfileBase):
+ pass
+
+class ProfileOut(ProfileBase):
+ id: UUID
+ user_id: UUID
+
+ if _V2:
+ model_config = ConfigDict(from_attributes=True)
+ else:
+ class Config:
+ orm_mode = True
diff --git a/services/profiles/src/app/services/profile_service.py b/services/profiles/src/app/services/profile_service.py
new file mode 100644
index 0000000..2970a7b
--- /dev/null
+++ b/services/profiles/src/app/services/profile_service.py
@@ -0,0 +1,13 @@
+from uuid import UUID
+from app.schemas.profile import ProfileCreate
+from app.repositories.profile_repository import ProfileRepository
+
+class ProfileService:
+ def __init__(self, repo: ProfileRepository):
+ self.repo = repo
+
+ def get_by_user(self, user_id: UUID):
+ return self.repo.get_by_user(user_id)
+
+ def create(self, user_id: UUID, data: ProfileCreate):
+ return self.repo.create(user_id, data)