diff --git a/.gitignore b/.gitignore index 332d5ce..b03c511 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ go.work # End of https://www.toptal.com/developers/gitignore/api/go,dotenv +.gocache diff --git a/.idea/customer-service.iml b/.idea/customer-service.iml new file mode 100644 index 0000000..7ee078d --- /dev/null +++ b/.idea/customer-service.iml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/go.imports.xml b/.idea/go.imports.xml new file mode 100644 index 0000000..d7202f0 --- /dev/null +++ b/.idea/go.imports.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/.idea/material_theme_project_new.xml b/.idea/material_theme_project_new.xml new file mode 100644 index 0000000..58025cb --- /dev/null +++ b/.idea/material_theme_project_new.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml new file mode 100644 index 0000000..5321eb1 --- /dev/null +++ b/.idea/workspace.xml @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + { + "associatedIndex": 6 +} + + + + { + "keyToString": { + "ModuleVcsDetector.initialDetectionPerformed": "true", + "RunOnceActivity.GoLinterPluginOnboardingV2": "true", + "RunOnceActivity.GoLinterPluginStorageMigration": "true", + "RunOnceActivity.ShowReadmeOnStart": "true", + "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true", + "RunOnceActivity.git.unshallow": "true", + "RunOnceActivity.go.analysis.ui.options.defaults": "true", + "RunOnceActivity.go.formatter.settings.were.checked": "true", + "RunOnceActivity.go.modules.go.list.on.any.changes.was.set": "true", + "RunOnceActivity.typescript.service.memoryLimit.init": "true", + "com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultModelSelectionForGA.v1": "true", + "git-widget-placeholder": "feature/keycloak-auth-configuration", + "go.sdk.automatically.set": "true", + "junie.onboarding.icon.badge.shown": "true", + "last_opened_file_path": "/Users/julienschneider", + "node.js.detected.package.eslint": "true", + "node.js.selected.package.eslint": "(autodetect)", + "nodejs_package_manager_path": "npm", + "to.speed.mode.migration.done": "true" + } +} + + + + + + + + + + 1773751126422 + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index 5e5506d..133f3a5 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,9 @@ It uses **PostgreSQL** as the database, **RabbitMQ** for asynchronous communicat ## πŸš€ Features - Management of **companies** (`companies`) -- Management of **users** (`users`) +- Management of **users** (`users-company`) +- Lookup of company users by **Keycloak ID** +- Global **CORS** support with `OPTIONS` preflight handling - Publishing RabbitMQ events (`CompanyCreated`, `UserCreated`) - Modular and extensible microservice architecture - Persistence via PostgreSQL @@ -19,10 +21,13 @@ It uses **PostgreSQL** as the database, **RabbitMQ** for asynchronous communicat β”œβ”€β”€ config/ β”‚ └── config.go # Configuration & env loading β”œβ”€β”€ internal/ -β”‚ β”œβ”€β”€ customer/ -β”‚ β”‚ β”œβ”€β”€ company/ # "Company" business logic -β”‚ β”‚ └── user/ # "User" business logic +β”‚ β”œβ”€β”€ company/ # "Company" business logic +β”‚ β”œβ”€β”€ user/ # "User" business logic β”‚ └── event/ # RabbitMQ publishing +β”œβ”€β”€ docs/ +β”‚ β”œβ”€β”€ API.md # Route-by-route API reference +β”‚ └── available-keycloak-users-contract.md +β”‚ # Proposed dropdown contract for frontend integration β”œβ”€β”€ routes/ β”‚ └── route.go # Application routes β”œβ”€β”€ docker-compose.yml # Full stack (Go + Postgres + RabbitMQ) @@ -45,6 +50,8 @@ Create a `.env` file at the root of the project and fill in the variables: cp .env.example .env ``` +The service only needs database and RabbitMQ configuration from `.env`. + ## 🐳 Running the project From the root of the project: @@ -57,21 +64,42 @@ docker compose up --build πŸ“‹ API (via Postman, curl, etc.) -| Method | Endpoint | Description | -| ------ | ---------------------- | --------------------------------- | -| POST | `/companies` | Create a company | -| GET | `/companies/:id` | Retrieve a company | -| POST | `/companies/:id/users` | Create a user linked to a company | -| GET | `/users/:id` | Retrieve a user | +| Method | Endpoint | Description | +| ------ | -------- | ----------- | +| GET | `/health` | Health check | +| POST | `/companies` | Create a company | +| GET | `/companies` | List companies | +| GET | `/companies/:id` | Retrieve a company | +| PUT | `/companies/:id` | Update a company | +| DELETE | `/companies/:id` | Delete a company | +| POST | `/companies/:id/users-company` | Create a user linked to a company | +| GET | `/companies/:id/users-company` | List users linked to a company | +| GET | `/users-company/keycloak/:keycloak_id` | Retrieve a user by Keycloak ID | +| GET | `/users-company/keycloak/:keycloak_id/role` | Retrieve only the user role by Keycloak ID | +| GET | `/users-company/keycloak/:keycloak_id/company-id` | Retrieve only the company ID by Keycloak ID | +| GET | `/users-company/:id` | Retrieve a user | +| PUT | `/users-company/:id` | Update a user | +| DELETE | `/users-company/:id` | Delete a user | + +Full route-by-route documentation with direct URLs and `curl` examples is available in [`docs/API.md`](docs/API.md). + +The proposed backend contract for the frontend "available Keycloak users" dropdown is documented in [`docs/available-keycloak-users-contract.md`](docs/available-keycloak-users-contract.md). + +## 🌐 CORS -> Sample CURLs are available in each resource-specific `repository`. +The API exposes global CORS headers for browser clients: + +- `Access-Control-Allow-Origin: *` +- `Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS` +- `Access-Control-Allow-Headers: Origin, Content-Type, Accept, Authorization` +- `OPTIONS` preflight requests return `204 No Content` ## πŸ—„οΈ Accessing PostgreSQL From your terminal: ```bash -psql -h localhost -p 5433 -U user -d customers +psql -h localhost -p 5434 -U user -d customer_service_db ``` Then: @@ -79,9 +107,19 @@ Then: ```sql \dt -- List tables SELECT * FROM companies; -SELECT * FROM users; +SELECT * FROM user_companies; ``` +## Create User +Please run this create for adding your keycloak account to an admin account. +```bash +curl -X POST http://localhost:8080/companies/1/users-company \ + -H "Content-Type: application/json" \ + -d '{ + "id_auth_kc": "08d697a6-8b89-40fc-aaf3-bdaaa65e0ae4", + "role": "admin" + }' +``` ## 🧹 Cleanup To stop and remove containers: diff --git a/cmd/main.go b/cmd/main.go index bc989ac..6197a5c 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -18,7 +18,10 @@ import ( ) func main() { - cfg := config.LoadConfig() + cfg, err := config.LoadConfig() + if err != nil { + log.Fatal("Invalid configuration:", err) + } // PostgreSQL connection log.Println("Connecting to Postgres with DSN:", cfg.PostgresDSN()) @@ -76,6 +79,7 @@ func main() { // Router r := gin.Default() + r.Use(routes.WithConfig(cfg), routes.CORSMiddleware()) // Fetch trusted proxies from .env proxies := os.Getenv("TRUSTED_PROXIES") diff --git a/config/config.go b/config/config.go index 757e5c8..ea84829 100644 --- a/config/config.go +++ b/config/config.go @@ -2,8 +2,8 @@ package config import ( "fmt" - "log" "os" + "strings" ) type Config struct { @@ -14,7 +14,7 @@ type Config struct { RabbitMQ string } -func LoadConfig() *Config { +func LoadConfig() (*Config, error) { cfg := &Config{ DBHost: os.Getenv("DB_HOST"), DBUser: os.Getenv("DB_USER"), @@ -23,11 +23,29 @@ func LoadConfig() *Config { RabbitMQ: os.Getenv("RABBITMQ_HOST"), } - if cfg.DBHost == "" || cfg.DBUser == "" || cfg.DBPassword == "" || cfg.DBName == "" || cfg.RabbitMQ == "" { - log.Fatal("Missing required environment variables") + var missing []string + requiredValues := []struct { + name string + value string + }{ + {name: "DB_HOST", value: cfg.DBHost}, + {name: "DB_USER", value: cfg.DBUser}, + {name: "DB_PASSWORD", value: cfg.DBPassword}, + {name: "DB_NAME", value: cfg.DBName}, + {name: "RABBITMQ_HOST", value: cfg.RabbitMQ}, } - return cfg + for _, item := range requiredValues { + if item.value == "" { + missing = append(missing, item.name) + } + } + + if len(missing) > 0 { + return nil, fmt.Errorf("missing required environment variables: %s", strings.Join(missing, ", ")) + } + + return cfg, nil } func (c *Config) PostgresDSN() string { diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..4360436 --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,39 @@ +package config + +import ( + "strings" + "testing" +) + +func setRequiredEnv(t *testing.T) { + t.Helper() + + t.Setenv("DB_HOST", "localhost") + t.Setenv("DB_USER", "user") + t.Setenv("DB_PASSWORD", "secret") + t.Setenv("DB_NAME", "customer_service_db") + t.Setenv("RABBITMQ_HOST", "rabbitmq") +} + +func TestLoadConfig_Success(t *testing.T) { + setRequiredEnv(t) + + _, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig returned error: %v", err) + } +} + +func TestLoadConfig_MissingRequiredEnv(t *testing.T) { + setRequiredEnv(t) + t.Setenv("RABBITMQ_HOST", "") + + _, err := LoadConfig() + if err == nil { + t.Fatal("expected LoadConfig to fail when a required env var is missing") + } + + if !strings.Contains(err.Error(), "RABBITMQ_HOST") { + t.Fatalf("expected error to mention RABBITMQ_HOST, got %v", err) + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 5ebd11c..24069fb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,11 +5,11 @@ services: env_file: - .env ports: - - "5433:5432" + - "5434:5432" volumes: - ./scripts:/docker-entrypoint-initdb.d healthcheck: - test: ["CMD-SHELL", "pg_isready -U $POSTGRES_USER"] + test: ["CMD-SHELL", "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB"] interval: 5s timeout: 5s retries: 5 diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..2a159a7 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,318 @@ +# API Reference + +Base URL: `http://localhost:8080` + +Proposal docs: +- See [`available-keycloak-users-contract.md`](available-keycloak-users-contract.md) for the proposed backend contract used by the frontend dropdown when creating a company user. + +CORS: +- `Access-Control-Allow-Origin: *` +- Allowed methods: `GET, POST, PUT, DELETE, OPTIONS` +- Preflight `OPTIONS` requests return `204 No Content` + +## Quick Summary + +| Method | Route | Purpose | +| --- | --- | --- | +| GET | `/health` | Health check | +| POST | `/companies` | Create a company | +| GET | `/companies` | List all companies | +| GET | `/companies/:id` | Get one company | +| PUT | `/companies/:id` | Update one company | +| DELETE | `/companies/:id` | Delete one company | +| POST | `/companies/:id/users-company` | Create a user for one company | +| GET | `/companies/:id/users-company` | List users for one company | +| GET | `/users-company/keycloak/:keycloak_id` | Get one user by Keycloak ID | +| GET | `/users-company/keycloak/:keycloak_id/role` | Get only the user role by Keycloak ID | +| GET | `/users-company/keycloak/:keycloak_id/company-id` | Get only the user company ID by Keycloak ID | +| GET | `/users-company/:id` | Get one user | +| PUT | `/users-company/:id` | Update one user | +| DELETE | `/users-company/:id` | Delete one user | + +## Data Format + +### Company JSON + +```json +{ + "id": "1", + "name": "Acme Corp", + "email": "contact@acme.com", + "phone": "0601020304", + "address": "42 rue de Paris", + "created_at": "2026-03-19T10:00:00Z", + "updated_at": "2026-03-19T10:00:00Z" +} +``` + +Write fields for create/update: + +```json +{ + "name": "Acme Corp", + "email": "contact@acme.com", + "phone": "0601020304", + "address": "42 rue de Paris" +} +``` + +### User JSON + +```json +{ + "id": 1, + "company_id": 1, + "id_auth_kc": "kc-acme-admin-001", + "role": "admin", + "created_at": "2026-03-19T10:00:00Z", + "updated_at": "2026-03-19T10:00:00Z" +} +``` + +Write fields for create/update: + +```json +{ + "id_auth_kc": "kc-acme-admin-001", + "role": "admin" +} +``` + +Allowed `role` values: `user`, `manager`, `admin` + +## Routes + +### 1. Health Check + +URL: `http://localhost:8080/health` + +Browser / direct URL: + +```text +http://localhost:8080/health +``` + +curl: + +```bash +curl http://localhost:8080/health +``` + +### 2. Create Company + +URL: `http://localhost:8080/companies` + +curl: + +```bash +curl -X POST http://localhost:8080/companies \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Acme Corp", + "email": "contact@acme.com", + "phone": "0601020304", + "address": "42 rue de Paris" + }' +``` + +### 3. List Companies + +URL: `http://localhost:8080/companies` + +Browser / direct URL: + +```text +http://localhost:8080/companies +``` + +curl: + +```bash +curl http://localhost:8080/companies +``` + +### 4. Get Company By ID + +URL: `http://localhost:8080/companies/{companyId}` + +Browser / direct URL example: + +```text +http://localhost:8080/companies/1 +``` + +curl: + +```bash +curl http://localhost:8080/companies/1 +``` + +### 5. Update Company + +URL: `http://localhost:8080/companies/{companyId}` + +curl: + +```bash +curl -X PUT http://localhost:8080/companies/1 \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Acme Corporation Updated", + "email": "support@acme.com", + "phone": "0707070707", + "address": "100 avenue de Lyon" + }' +``` + +### 6. Delete Company + +URL: `http://localhost:8080/companies/{companyId}` + +curl: + +```bash +curl -X DELETE http://localhost:8080/companies/1 +``` + +### 7. Create User For A Company + +URL: `http://localhost:8080/companies/{companyId}/users-company` + +curl: + +```bash +curl -X POST http://localhost:8080/companies/1/users-company \ + -H "Content-Type: application/json" \ + -d '{ + "id_auth_kc": "08d697a6-8b89-40fc-aaf3-bdaaa65e0ae4", + "role": "admin" + }' +``` + +### 8. List Users By Company + +URL: `http://localhost:8080/companies/{companyId}/users-company` + +Browser / direct URL example: + +```text +http://localhost:8080/companies/1/users-company +``` + +curl: + +```bash +curl http://localhost:8080/companies/1/users-company +``` + +### 9. Get User By Keycloak ID + +URL: `http://localhost:8080/users-company/keycloak/{keycloakId}` + +Browser / direct URL example: + +```text +http://localhost:8080/users-company/keycloak/kc-acme-001 +``` + +curl: + +```bash +curl http://localhost:8080/users-company/keycloak/kc-acme-001 +``` + +### 10. Get User Role By Keycloak ID + +URL: `http://localhost:8080/users-company/keycloak/{keycloakId}/role` + +Browser / direct URL example: + +```text +http://localhost:8080/users-company/keycloak/kc-acme-001/role +``` + +curl: + +```bash +curl http://localhost:8080/users-company/keycloak/kc-acme-001/role +``` + +Example response: + +```json +{ + "role": "admin" +} +``` + +### 11. Get User Company ID By Keycloak ID + +URL: `http://localhost:8080/users-company/keycloak/{keycloakId}/company-id` + +Browser / direct URL example: + +```text +http://localhost:8080/users-company/keycloak/kc-acme-001/company-id +``` + +curl: + +```bash +curl http://localhost:8080/users-company/keycloak/kc-acme-001/company-id +``` + +Example response: + +```json +{ + "company_id": 1 +} +``` + +### 12. Get User By ID + +URL: `http://localhost:8080/users-company/{userId}` + +Browser / direct URL example: + +```text +http://localhost:8080/users-company/1 +``` + +curl: + +```bash +curl http://localhost:8080/users-company/1 +``` + +### 13. Update User + +URL: `http://localhost:8080/users-company/{userId}` + +curl: + +```bash +curl -X PUT http://localhost:8080/users-company/1 \ + -H "Content-Type: application/json" \ + -d '{ + "id_auth_kc": "kc-user-002", + "role": "manager" + }' +``` + +### 14. Delete User + +URL: `http://localhost:8080/users-company/{userId}` + +curl: + +```bash +curl -X DELETE http://localhost:8080/users-company/1 +``` + +## Notes + +- `companyId` and `userId` must be numeric in the URL. +- In responses, company `id` is serialized as a string, while user `id` is serialized as a number. +- `GET` routes can be called directly from a browser with the URL examples above. +- `POST`, `PUT`, and `DELETE` should be called with `curl`, Postman, or another API client. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..e900a3b --- /dev/null +++ b/docs/README.md @@ -0,0 +1,14 @@ +# Project Diagrams + +This folder contains Mermaid diagrams that describe the `customer-service` runtime architecture, request flow, and core data model. + +## Available diagrams + +- [Architecture diagram](./architecture-diagram.md): infrastructure, application layers, and event publication flow. +- [Request sequence diagram](./request-sequence-diagram.md): how a typical request moves from Gin routing to services, repositories, PostgreSQL, and RabbitMQ. +- [Domain data model diagram](./domain-data-model-diagram.md): the relationship between companies, user memberships, and emitted domain events. + +## Notes + +- The diagrams reflect the current implementation in `cmd/main.go`, `routes/`, `internal/company/`, `internal/user/`, `internal/event/`, `config/config.go`, and `scripts/create_database.sql`. +- Existing API reference files in this folder were left unchanged. diff --git a/docs/architecture-diagram.md b/docs/architecture-diagram.md new file mode 100644 index 0000000..60a6700 --- /dev/null +++ b/docs/architecture-diagram.md @@ -0,0 +1,45 @@ +# Architecture Diagram + +This diagram shows the main runtime components of the service, from incoming HTTP traffic to persistence and event publishing. + +```mermaid +flowchart TB + client[Frontend or API client\nHTTP JSON requests] + + subgraph compose[Docker Compose environment] + db[(PostgreSQL 15\ncompanies and user_companies)] + rabbit[(RabbitMQ\ncustomer_events exchange)] + + subgraph app_container[customer-service container] + main[cmd/main.go] + cfg[Config loader\nenvironment variables] + gin[Gin engine] + cors[CORS middleware] + ctxcfg[Config context middleware] + routes[Route registration] + + subgraph app_layers[Application layers] + companyHandler[Company handler] + userHandler[User handler] + companyService[Company service] + userService[User service] + companyRepo[Company repository] + userRepo[User repository] + publisher[Event publisher] + end + end + end + + client -->|REST calls| gin + main --> cfg + cfg --> main + main --> gin + gin --> cors --> ctxcfg --> routes + routes --> companyHandler + routes --> userHandler + companyHandler --> companyService --> companyRepo --> db + userHandler --> userService --> userRepo --> db + userService --> companyRepo + companyService --> publisher --> rabbit + userService --> publisher +``` diff --git a/docs/available-keycloak-users-contract.md b/docs/available-keycloak-users-contract.md new file mode 100644 index 0000000..5730ef4 --- /dev/null +++ b/docs/available-keycloak-users-contract.md @@ -0,0 +1,126 @@ +# Available Keycloak Users Contract Proposal + +Status: proposal only, not implemented in `customer-service` yet. + +## Goal + +Support the company-user creation flow with a backend endpoint that returns Keycloak users who can still be linked through: + +`POST /companies/:id/users-company` + +Because `user_companies.id_auth_kc` is unique, this endpoint should only return users that are not already linked in `user_companies`. + +## Proposed Route + +`GET /companies/:id/available-keycloak-users` + +## Query Parameters + +- `search` optional string +Used to filter by username, email, first name, last name, or display name. + +- `limit` optional integer +Maximum number of rows returned. Default `25`, max `100`. + +## Response Contract + +`200 OK` + +```json +{ + "items": [ + { + "keycloak_id": "08d697a6-8b89-40fc-aaf3-bdaaa65e0ae4", + "username": "alice", + "email": "alice@example.com", + "first_name": "Alice", + "last_name": "Martin", + "display_name": "Alice Martin" + }, + { + "keycloak_id": "2f7f4ebf-5a0d-4a83-9f17-7cf4d7c9ac25", + "username": "bob", + "email": "bob@example.com", + "first_name": "Bob", + "last_name": "Nguyen", + "display_name": "Bob Nguyen" + } + ], + "meta": { + "company_id": 12, + "search": "ali", + "limit": 25, + "returned": 2, + "source": "keycloak", + "excluded_already_linked": true + } +} +``` + +## Field Semantics + +- `keycloak_id` +Stable Keycloak user identifier. This is the value the frontend sends as `id_auth_kc` when creating a company user. + +- `display_name` +Backend-computed label for dropdown usage. Prefer `first_name + last_name`; fall back to `username`; fall back to `email`. + +- `meta.excluded_already_linked` +Must be `true` for the default behavior so the frontend can trust this endpoint as a list of selectable candidates. + +## Sorting + +Return rows sorted by: + +1. exact `search` matches first when `search` is present +2. then alphabetical `display_name` +3. then alphabetical `username` + +## Error Contract + +`400 Bad Request` + +```json +{ + "error": "invalid company ID" +} +``` + +`401 Unauthorized` + +```json +{ + "error": "missing or invalid access token" +} +``` + +`403 Forbidden` + +```json +{ + "error": "forbidden" +} +``` + +`502 Bad Gateway` + +```json +{ + "error": "failed to fetch users from Keycloak" +} +``` + +## Why This Shape + +- The route is company-scoped because the dropdown is used from the company user creation screen. +- `items + meta` leaves room for paging, tracing, and source diagnostics without breaking the payload later. +- The backend, not the frontend, should decide which Keycloak users are still available because it owns the uniqueness rule on `id_auth_kc`. +- `display_name` avoids duplicating label-building logic in each frontend client. + +## Frontend Usage + +The create-user screen should call: + +`GET /companies/:companyId/available-keycloak-users` + +Then use `items[].display_name` for the dropdown label and `items[].keycloak_id` as the submitted value for `id_auth_kc`. diff --git a/docs/domain-data-model-diagram.md b/docs/domain-data-model-diagram.md new file mode 100644 index 0000000..7c6ec6a --- /dev/null +++ b/docs/domain-data-model-diagram.md @@ -0,0 +1,35 @@ +# Domain Data Model Diagram + +This extra diagram is especially useful here because the project revolves around a small domain model: companies, company-linked users, and emitted domain events. + +```mermaid +erDiagram + COMPANIES ||--o{ USER_COMPANIES : contains + + COMPANIES { + int id PK + string name + string email + string phone + string address + timestamp created_at + timestamp updated_at + } + + USER_COMPANIES { + int id PK + int company_id FK + string id_auth_kc UK + enum role + timestamp created_at + timestamp updated_at + } + + CUSTOMER_EVENTS { + string routing_key + json payload + } + + COMPANIES }o--o{ CUSTOMER_EVENTS : emits_on_create_update_delete + USER_COMPANIES }o--o{ CUSTOMER_EVENTS : emits_on_create_update_delete +``` diff --git a/docs/request-sequence-diagram.md b/docs/request-sequence-diagram.md new file mode 100644 index 0000000..b41ce30 --- /dev/null +++ b/docs/request-sequence-diagram.md @@ -0,0 +1,56 @@ +# Request Sequence Diagram + +This sequence shows the most representative workflow in the service: creating a user membership for a company. + +```mermaid +sequenceDiagram + autonumber + actor Client + participant Gin as Gin router + participant UserHandler as User handler + participant UserService as User service + participant CompanyRepo as Company repository + participant UserRepo as User repository + participant Postgres as PostgreSQL + participant Publisher as Event publisher + participant RabbitMQ as RabbitMQ + + Client->>Gin: POST /companies/{id}/users-company + Gin->>UserHandler: Dispatch matched route + UserHandler->>UserHandler: Parse company id and bind JSON body + + alt Invalid company id or invalid JSON + UserHandler-->>Client: 400 Bad Request + else Valid request + UserHandler->>UserService: CreateUser(ctx, user) + UserService->>CompanyRepo: GetByID(ctx, companyID) + CompanyRepo->>Postgres: SELECT company by id + Postgres-->>CompanyRepo: Company row, no row, or query error + CompanyRepo-->>UserService: Result + + alt Company lookup returns query error + UserService-->>UserHandler: Error + UserHandler-->>Client: 500 Internal Server Error + else No query error + UserService->>UserRepo: Create(ctx, user) + UserRepo->>Postgres: INSERT INTO user_companies + alt Foreign key or insert error + Postgres-->>UserRepo: Insert failure + UserRepo-->>UserService: Error + UserService-->>UserHandler: Error + UserHandler-->>Client: 500 Internal Server Error + else Insert succeeds + Postgres-->>UserRepo: New user_company id + UserRepo-->>UserService: Success + + opt Publisher configured + UserService->>Publisher: Publish("UserCreated", user) + Publisher->>RabbitMQ: Publish JSON message to exchange + end + + UserService-->>UserHandler: Success + UserHandler-->>Client: 201 Created + user JSON + end + end + end +``` diff --git a/internal/user/handler.go b/internal/user/handler.go index 40a6e3a..02c6a26 100644 --- a/internal/user/handler.go +++ b/internal/user/handler.go @@ -79,6 +79,57 @@ func (h *Handler) GetUserByID(c *gin.Context) { c.JSON(http.StatusOK, user) } +// --------- GET BY KEYCLOAK ID ---------- +func (h *Handler) GetUserByKeycloakID(c *gin.Context) { + keycloakID := c.Param("keycloak_id") + if keycloakID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid keycloak id"}) + return + } + + user, err := h.Service.GetUserByKeycloakID(c.Request.Context(), keycloakID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + + c.JSON(http.StatusOK, user) +} + +// --------- GET ROLE BY KEYCLOAK ID ---------- +func (h *Handler) GetUserRoleByKeycloakID(c *gin.Context) { + keycloakID := c.Param("keycloak_id") + if keycloakID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid keycloak id"}) + return + } + + role, err := h.Service.GetUserRoleByKeycloakID(c.Request.Context(), keycloakID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{"role": role}) +} + +// --------- GET COMPANY ID BY KEYCLOAK ID ---------- +func (h *Handler) GetUserCompanyIDByKeycloakID(c *gin.Context) { + keycloakID := c.Param("keycloak_id") + if keycloakID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid keycloak id"}) + return + } + + companyID, err := h.Service.GetUserCompanyIDByKeycloakID(c.Request.Context(), keycloakID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{"company_id": companyID}) +} + // --------- UPDATE ---------- func (h *Handler) UpdateUser(c *gin.Context) { idStr := c.Param("id") diff --git a/internal/user/handler_test.go b/internal/user/handler_test.go new file mode 100644 index 0000000..f3fe9dd --- /dev/null +++ b/internal/user/handler_test.go @@ -0,0 +1,153 @@ +package user + +import ( + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/gin-gonic/gin" +) + +func newUserTestRouter(db *sql.DB) *gin.Engine { + gin.SetMode(gin.TestMode) + + userHandler := NewHandler(NewService(NewRepository(db), nil, nil)) + + r := gin.New() + r.GET("/users-company/keycloak/:keycloak_id", userHandler.GetUserByKeycloakID) + r.GET("/users-company/keycloak/:keycloak_id/role", userHandler.GetUserRoleByKeycloakID) + r.GET("/users-company/keycloak/:keycloak_id/company-id", userHandler.GetUserCompanyIDByKeycloakID) + + return r +} + +func TestRoute_GetUserByKeycloakID(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to create sqlmock: %v", err) + } + defer db.Close() + + now := time.Now() + mock.ExpectQuery(`SELECT id, company_id, id_auth_kc, role, created_at, updated_at\s+FROM user_companies WHERE id_auth_kc=\$1`). + WithArgs("kc-acme-001"). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "company_id", "id_auth_kc", "role", "created_at", "updated_at", + }).AddRow(5, 7, "kc-acme-001", "admin", now, now)) + + req := httptest.NewRequest(http.MethodGet, "/users-company/keycloak/kc-acme-001", nil) + rec := httptest.NewRecorder() + + newUserTestRouter(db).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d with body %s", rec.Code, rec.Body.String()) + } + + var got User + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if got.ID != 5 || got.CompanyID != 7 || got.IDAuthKC != "kc-acme-001" || got.Role != "admin" { + t.Fatalf("unexpected response: %#v", got) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } +} + +func TestRoute_GetUserRoleByKeycloakID(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to create sqlmock: %v", err) + } + defer db.Close() + + mock.ExpectQuery(`SELECT role FROM user_companies WHERE id_auth_kc=\$1`). + WithArgs("kc-acme-001"). + WillReturnRows(sqlmock.NewRows([]string{"role"}).AddRow("manager")) + + req := httptest.NewRequest(http.MethodGet, "/users-company/keycloak/kc-acme-001/role", nil) + rec := httptest.NewRecorder() + + newUserTestRouter(db).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d with body %s", rec.Code, rec.Body.String()) + } + + var got map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if got["role"] != "manager" { + t.Fatalf("expected role manager, got %#v", got) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } +} + +func TestRoute_GetUserCompanyIDByKeycloakID(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to create sqlmock: %v", err) + } + defer db.Close() + + mock.ExpectQuery(`SELECT company_id FROM user_companies WHERE id_auth_kc=\$1`). + WithArgs("kc-acme-001"). + WillReturnRows(sqlmock.NewRows([]string{"company_id"}).AddRow(42)) + + req := httptest.NewRequest(http.MethodGet, "/users-company/keycloak/kc-acme-001/company-id", nil) + rec := httptest.NewRecorder() + + newUserTestRouter(db).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d with body %s", rec.Code, rec.Body.String()) + } + + var got map[string]int + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if got["company_id"] != 42 { + t.Fatalf("expected company_id 42, got %#v", got) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } +} + +func TestRoute_GetUserRoleByKeycloakID_NotFound(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to create sqlmock: %v", err) + } + defer db.Close() + + mock.ExpectQuery(`SELECT role FROM user_companies WHERE id_auth_kc=\$1`). + WithArgs("missing-user"). + WillReturnError(sql.ErrNoRows) + + req := httptest.NewRequest(http.MethodGet, "/users-company/keycloak/missing-user/role", nil) + rec := httptest.NewRecorder() + + newUserTestRouter(db).ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("expected status 404, got %d with body %s", rec.Code, rec.Body.String()) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } +} diff --git a/internal/user/repository.go b/internal/user/repository.go index 4ddf220..7b97da0 100644 --- a/internal/user/repository.go +++ b/internal/user/repository.go @@ -16,53 +16,86 @@ func NewRepository(db *sql.DB) *Repository { /* ---------- CREATE ---------- */ /* - curl -X POST http://localhost:8080/companies//users \ + curl -X POST http://localhost:8080/companies//users-company \ -H "Content-Type: application/json" \ -d '{ - "first_name": "John", - "last_name": "Doe", - "email": "john@acme.com", - "phone": "0611223344" + "id_auth_kc": "kc-user-001", + "role": "admin" }' */ func (r *Repository) Create(ctx context.Context, u *User) error { query := ` - INSERT INTO users (company_id, first_name, last_name, email, phone, created_at, updated_at) - VALUES ($1,$2,$3,$4,$5,NOW(),NOW()) + INSERT INTO user_companies (company_id, id_auth_kc, role, created_at, updated_at) + VALUES ($1,$2,$3,NOW(),NOW()) RETURNING id ` return r.DB.QueryRowContext(ctx, query, - u.CompanyID, u.FirstName, u.LastName, u.Email, u.Phone, + u.CompanyID, u.IDAuthKC, u.Role, ).Scan(&u.ID) } /* ---------- GET BY ID ---------- */ /* -curl http://localhost:8080/users/ +curl http://localhost:8080/users-company/ */ func (r *Repository) GetByID(ctx context.Context, id int) (*User, error) { - query := `SELECT id, company_id, first_name, last_name, email, phone, created_at, updated_at - FROM users WHERE id=$1` + query := `SELECT id, company_id, id_auth_kc, role, created_at, updated_at + FROM user_companies WHERE id=$1` u := &User{} err := r.DB.QueryRowContext(ctx, query, id).Scan( - &u.ID, &u.CompanyID, &u.FirstName, &u.LastName, &u.Email, &u.Phone, - &u.CreatedAt, &u.UpdatedAt, + &u.ID, &u.CompanyID, &u.IDAuthKC, &u.Role, &u.CreatedAt, &u.UpdatedAt, ) return u, err } +/* ---------- GET BY KEYCLOAK ID ---------- */ + +func (r *Repository) GetByKeycloakID(ctx context.Context, keycloakID string) (*User, error) { + query := `SELECT id, company_id, id_auth_kc, role, created_at, updated_at + FROM user_companies WHERE id_auth_kc=$1` + + u := &User{} + err := r.DB.QueryRowContext(ctx, query, keycloakID).Scan( + &u.ID, &u.CompanyID, &u.IDAuthKC, &u.Role, &u.CreatedAt, &u.UpdatedAt, + ) + + return u, err +} + +/* ---------- GET ROLE BY KEYCLOAK ID ---------- */ + +func (r *Repository) GetRoleByKeycloakID(ctx context.Context, keycloakID string) (string, error) { + query := `SELECT role FROM user_companies WHERE id_auth_kc=$1` + + var role string + err := r.DB.QueryRowContext(ctx, query, keycloakID).Scan(&role) + + return role, err +} + +/* ---------- GET COMPANY ID BY KEYCLOAK ID ---------- */ + +func (r *Repository) GetCompanyIDByKeycloakID(ctx context.Context, keycloakID string) (int, error) { + query := `SELECT company_id FROM user_companies WHERE id_auth_kc=$1` + + var companyID int + err := r.DB.QueryRowContext(ctx, query, keycloakID).Scan(&companyID) + + return companyID, err +} + /* ---------- GET BY COMPANY ---------- */ /* -curl http://localhost:8080/companies//users +curl http://localhost:8080/companies//users-company */ func (r *Repository) GetByCompany(ctx context.Context, companyID int) ([]User, error) { - query := `SELECT id, company_id, first_name, last_name, email, phone, created_at, updated_at - FROM users WHERE company_id=$1` + query := `SELECT id, company_id, id_auth_kc, role, created_at, updated_at + FROM user_companies WHERE company_id=$1` rows, err := r.DB.QueryContext(ctx, query, companyID) if err != nil { @@ -73,8 +106,7 @@ func (r *Repository) GetByCompany(ctx context.Context, companyID int) ([]User, e var users []User for rows.Next() { var u User - err := rows.Scan(&u.ID, &u.CompanyID, &u.FirstName, &u.LastName, &u.Email, &u.Phone, - &u.CreatedAt, &u.UpdatedAt) + err := rows.Scan(&u.ID, &u.CompanyID, &u.IDAuthKC, &u.Role, &u.CreatedAt, &u.UpdatedAt) if err != nil { return nil, err } @@ -87,23 +119,20 @@ func (r *Repository) GetByCompany(ctx context.Context, companyID int) ([]User, e /* ---------- UPDATE ---------- */ /* -curl -X PUT http://localhost:8080/users/ \ +curl -X PUT http://localhost:8080/users-company/ \ -H "Content-Type: application/json" \ -d '{ - "company_id": 3, - "first_name": "Johnny", - "last_name": "Doe", - "email": "johnny.doe@acme.com", - "phone": "0699887766" + "id_auth_kc": "kc-user-002", + "role": "manager" }' */ func (r *Repository) Update(ctx context.Context, u *User) error { - query := `UPDATE users SET first_name=$2, last_name=$3, email=$4, phone=$5, + query := `UPDATE user_companies SET id_auth_kc=$2, role=$3, updated_at=NOW() WHERE id=$1` res, err := r.DB.ExecContext(ctx, query, - u.ID, u.FirstName, u.LastName, u.Email, u.Phone, + u.ID, u.IDAuthKC, u.Role, ) if err != nil { return err @@ -123,10 +152,10 @@ func (r *Repository) Update(ctx context.Context, u *User) error { /* ---------- DELETE ---------- */ /* -curl -X DELETE http://localhost:8080/users/ +curl -X DELETE http://localhost:8080/users-company/ */ func (r *Repository) Delete(ctx context.Context, id int) error { - query := `DELETE FROM users WHERE id=$1` + query := `DELETE FROM user_companies WHERE id=$1` _, err := r.DB.ExecContext(ctx, query, id) return err } diff --git a/internal/user/service.go b/internal/user/service.go index c33bbb5..2633e4c 100644 --- a/internal/user/service.go +++ b/internal/user/service.go @@ -39,6 +39,18 @@ func (s *Service) GetUserByID(ctx context.Context, id int) (*User, error) { return s.Repo.GetByID(ctx, id) } +func (s *Service) GetUserByKeycloakID(ctx context.Context, keycloakID string) (*User, error) { + return s.Repo.GetByKeycloakID(ctx, keycloakID) +} + +func (s *Service) GetUserRoleByKeycloakID(ctx context.Context, keycloakID string) (string, error) { + return s.Repo.GetRoleByKeycloakID(ctx, keycloakID) +} + +func (s *Service) GetUserCompanyIDByKeycloakID(ctx context.Context, keycloakID string) (int, error) { + return s.Repo.GetCompanyIDByKeycloakID(ctx, keycloakID) +} + func (s *Service) GetUsersByCompany(ctx context.Context, companyID int) ([]User, error) { return s.Repo.GetByCompany(ctx, companyID) } diff --git a/internal/user/service_test.go b/internal/user/service_test.go index 3a6f1ee..862754a 100644 --- a/internal/user/service_test.go +++ b/internal/user/service_test.go @@ -55,10 +55,8 @@ func TestService_CreateUser_Success(t *testing.T) { now := time.Now() u := &User{ CompanyID: 7, - FirstName: "John", - LastName: "Doe", - Email: "john@acme.com", - Phone: "0611223344", + IDAuthKC: "kc-acme-001", + Role: "admin", } mock.ExpectQuery(`SELECT id, name, email, phone, address, created_at, updated_at\s+FROM companies\s+WHERE id = \$1`). @@ -67,8 +65,8 @@ func TestService_CreateUser_Success(t *testing.T) { "id", "name", "email", "phone", "address", "created_at", "updated_at", }).AddRow("7", "Acme", "info@acme.com", "0600000000", "Main St", now, now)) - mock.ExpectQuery(`INSERT INTO users \(company_id, first_name, last_name, email, phone, created_at, updated_at\)\s+VALUES \(\$1,\$2,\$3,\$4,\$5,NOW\(\),NOW\(\)\)\s+RETURNING id`). - WithArgs(u.CompanyID, u.FirstName, u.LastName, u.Email, u.Phone). + mock.ExpectQuery(`INSERT INTO user_companies \(company_id, id_auth_kc, role, created_at, updated_at\)\s+VALUES \(\$1,\$2,\$3,NOW\(\),NOW\(\)\)\s+RETURNING id`). + WithArgs(u.CompanyID, u.IDAuthKC, u.Role). WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(12)) err := svc.CreateUser(context.Background(), u) @@ -121,17 +119,17 @@ func TestService_GetUserByID(t *testing.T) { defer cleanup() now := time.Now() - mock.ExpectQuery(`SELECT id, company_id, first_name, last_name, email, phone, created_at, updated_at\s+FROM users WHERE id=\$1`). + mock.ExpectQuery(`SELECT id, company_id, id_auth_kc, role, created_at, updated_at\s+FROM user_companies WHERE id=\$1`). WithArgs(5). WillReturnRows(sqlmock.NewRows([]string{ - "id", "company_id", "first_name", "last_name", "email", "phone", "created_at", "updated_at", - }).AddRow(5, 7, "Jane", "Doe", "jane@acme.com", "0611223344", now, now)) + "id", "company_id", "id_auth_kc", "role", "created_at", "updated_at", + }).AddRow(5, 7, "kc-acme-001", "admin", now, now)) u, err := svc.GetUserByID(context.Background(), 5) if err != nil { t.Fatalf("GetUserByID returned error: %v", err) } - if u.ID != 5 || u.CompanyID != 7 { + if u.ID != 5 || u.CompanyID != 7 || u.IDAuthKC != "kc-acme-001" || u.Role != "admin" { t.Fatalf("unexpected user returned: %#v", u) } @@ -140,18 +138,84 @@ func TestService_GetUserByID(t *testing.T) { } } +func TestService_GetUserByKeycloakID(t *testing.T) { + svc, mock, _, cleanup := newTestService(t) + defer cleanup() + + now := time.Now() + mock.ExpectQuery(`SELECT id, company_id, id_auth_kc, role, created_at, updated_at\s+FROM user_companies WHERE id_auth_kc=\$1`). + WithArgs("kc-acme-001"). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "company_id", "id_auth_kc", "role", "created_at", "updated_at", + }).AddRow(5, 7, "kc-acme-001", "admin", now, now)) + + u, err := svc.GetUserByKeycloakID(context.Background(), "kc-acme-001") + if err != nil { + t.Fatalf("GetUserByKeycloakID returned error: %v", err) + } + if u.ID != 5 || u.CompanyID != 7 || u.IDAuthKC != "kc-acme-001" || u.Role != "admin" { + t.Fatalf("unexpected user returned: %#v", u) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } +} + +func TestService_GetUserRoleByKeycloakID(t *testing.T) { + svc, mock, _, cleanup := newTestService(t) + defer cleanup() + + mock.ExpectQuery(`SELECT role FROM user_companies WHERE id_auth_kc=\$1`). + WithArgs("kc-acme-001"). + WillReturnRows(sqlmock.NewRows([]string{"role"}).AddRow("manager")) + + role, err := svc.GetUserRoleByKeycloakID(context.Background(), "kc-acme-001") + if err != nil { + t.Fatalf("GetUserRoleByKeycloakID returned error: %v", err) + } + if role != "manager" { + t.Fatalf("expected role manager, got %s", role) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } +} + +func TestService_GetUserCompanyIDByKeycloakID(t *testing.T) { + svc, mock, _, cleanup := newTestService(t) + defer cleanup() + + mock.ExpectQuery(`SELECT company_id FROM user_companies WHERE id_auth_kc=\$1`). + WithArgs("kc-acme-001"). + WillReturnRows(sqlmock.NewRows([]string{"company_id"}).AddRow(42)) + + companyID, err := svc.GetUserCompanyIDByKeycloakID(context.Background(), "kc-acme-001") + if err != nil { + t.Fatalf("GetUserCompanyIDByKeycloakID returned error: %v", err) + } + if companyID != 42 { + t.Fatalf("expected company ID 42, got %d", companyID) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } +} + func TestService_GetUsersByCompany(t *testing.T) { svc, mock, _, cleanup := newTestService(t) defer cleanup() now := time.Now() - mock.ExpectQuery(`SELECT id, company_id, first_name, last_name, email, phone, created_at, updated_at\s+FROM users WHERE company_id=\$1`). + mock.ExpectQuery(`SELECT id, company_id, id_auth_kc, role, created_at, updated_at\s+FROM user_companies WHERE company_id=\$1`). WithArgs(7). WillReturnRows(sqlmock.NewRows([]string{ - "id", "company_id", "first_name", "last_name", "email", "phone", "created_at", "updated_at", + "id", "company_id", "id_auth_kc", "role", "created_at", "updated_at", }). - AddRow(1, 7, "A", "One", "a@acme.com", "0600000001", now, now). - AddRow(2, 7, "B", "Two", "b@acme.com", "0600000002", now, now)) + AddRow(1, 7, "kc-acme-001", "admin", now, now). + AddRow(2, 7, "kc-acme-002", "user", now, now)) users, err := svc.GetUsersByCompany(context.Background(), 7) if err != nil { @@ -171,15 +235,13 @@ func TestService_UpdateUser_Success(t *testing.T) { defer cleanup() u := &User{ - ID: 10, - FirstName: "Updated", - LastName: "Name", - Email: "updated@acme.com", - Phone: "0699887766", + ID: 10, + IDAuthKC: "kc-acme-009", + Role: "manager", } - mock.ExpectExec(`UPDATE users SET first_name=\$2, last_name=\$3, email=\$4, phone=\$5,\s+updated_at=NOW\(\) WHERE id=\$1`). - WithArgs(u.ID, u.FirstName, u.LastName, u.Email, u.Phone). + mock.ExpectExec(`UPDATE user_companies SET id_auth_kc=\$2, role=\$3,\s+updated_at=NOW\(\) WHERE id=\$1`). + WithArgs(u.ID, u.IDAuthKC, u.Role). WillReturnResult(sqlmock.NewResult(0, 1)) err := svc.UpdateUser(context.Background(), u) @@ -207,8 +269,8 @@ func TestService_UpdateUser_NotFound(t *testing.T) { u := &User{ID: 123} - mock.ExpectExec(`UPDATE users SET first_name=\$2, last_name=\$3, email=\$4, phone=\$5,\s+updated_at=NOW\(\) WHERE id=\$1`). - WithArgs(u.ID, u.FirstName, u.LastName, u.Email, u.Phone). + mock.ExpectExec(`UPDATE user_companies SET id_auth_kc=\$2, role=\$3,\s+updated_at=NOW\(\) WHERE id=\$1`). + WithArgs(u.ID, u.IDAuthKC, u.Role). WillReturnResult(sqlmock.NewResult(0, 0)) err := svc.UpdateUser(context.Background(), u) @@ -228,7 +290,7 @@ func TestService_DeleteUser(t *testing.T) { svc, mock, spy, cleanup := newTestService(t) defer cleanup() - mock.ExpectExec(`DELETE FROM users WHERE id=\$1`). + mock.ExpectExec(`DELETE FROM user_companies WHERE id=\$1`). WithArgs(77). WillReturnResult(sqlmock.NewResult(0, 1)) @@ -256,7 +318,7 @@ func TestService_DeleteUser_Error(t *testing.T) { svc, mock, spy, cleanup := newTestService(t) defer cleanup() - mock.ExpectExec(`DELETE FROM users WHERE id=\$1`). + mock.ExpectExec(`DELETE FROM user_companies WHERE id=\$1`). WithArgs(77). WillReturnError(errors.New("delete failed")) diff --git a/internal/user/user.go b/internal/user/user.go index 4585cab..97f559c 100644 --- a/internal/user/user.go +++ b/internal/user/user.go @@ -5,10 +5,8 @@ import "time" type User struct { ID int `json:"id"` CompanyID int `json:"company_id"` - FirstName string `json:"first_name"` - LastName string `json:"last_name"` - Email string `json:"email"` - Phone string `json:"phone"` + IDAuthKC string `json:"id_auth_kc"` + Role string `json:"role"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/objectif.md b/objectif.md new file mode 100644 index 0000000..5ebbd70 --- /dev/null +++ b/objectif.md @@ -0,0 +1,80 @@ +# Context and Architecture Decision (Keycloak + Customer Service) + +## Context +We currently have: +- **Keycloak** already running on the frontend side (Vue) for authentication, including username/password login and JWT issuance. +- A **Customer Service (Go + Postgres)** that manages the business model: **companies** and related logic. +- An initial **duplication** issue: Keycloak stores identity users, and the Customer Service also stores users. + +Goal: **a single login service** (Keycloak), while still keeping the business relationship `user ↔ company` inside the Customer Service, without duplicating identity data. + +## Decision +1. **Keycloak is the source of truth for identity and authentication** + - login / password / MFA / password reset / sessions + - Keycloak user identifiers (UUID) exposed in tokens through the `sub` claim + +2. **Customer Service is the source of truth for the business model** + - membership in a company + - business role inside the company, for example `company_owner`, `company_admin`, `member` + - business permissions, such as who can manage members + +3. **No authentication is performed against the Customer Service database** + - Keycloak does not query the Customer Service database for login or registration. + - The backend validates the Keycloak JWT, reads `sub`, then resolves the business context through its own database. + +## Selected Data Model +Inside Customer Service, we do not store identity data such as email, first name, last name, or password as required business records. +We only store a linking table, for example `company_members` or `user_company`: + +- `keycloak_user_id` (string/UUID) = the `sub` claim from the Keycloak JWT +- `company_id` +- `role` (business role) + +> Option: enrich the UI with display data such as email or name on demand through the Keycloak Admin API, without persisting those fields. + +## Runtime Flow +### Authentication and API calls +1. The user logs in through **Keycloak** from the Vue frontend via `keycloak-js`. +2. The frontend calls the API with `Authorization: Bearer `. +3. The backend: + - validates the JWT (issuer + JWKS + exp) + - extracts `sub` + - resolves `company_id` and `role` through the `company_members` table + +### Minimum required endpoint +- `GET /me` (protected): + - reads `sub` + - returns `{ company_id, role }` + - returns `404 { code: "NOT_LINKED" }` if no link exists + +## Member Management (company owner) +Long-term goal: a `company_owner` can add or remove members from their company. + +Decision: +- The "CRUD users" frontend for the owner calls **Customer Service** +- Customer Service applies business rules, for example owner/admin only +- Customer Service updates the relationship table (`company_members`) +- Keycloak account creation and deletion happens **through the backend** and never directly from the frontend + +### Phasing (current state) +- Right now, Keycloak accounts are created manually by a Keycloak admin. +- The backend must therefore expose an internal bootstrap endpoint to create the database link: + - `POST /admin/memberships/link { keycloak_user_id, company_id, role }` + +### Phasing (future evolution) +- Later, the backend will create Keycloak accounts from the owner UI through the Keycloak Admin API + - service account (`backend-admin`) + `client_credentials` + - user creation + required actions such as verify email / set password + - then insertion of the `company_members` link + +## Non-goals (to avoid unnecessary complexity) +- Do not implement a Keycloak User Federation/SPI to authenticate against the Customer Service database. +- Do not expose Keycloak admin credentials or admin access to the frontend. +- Do not systematically duplicate identity data such as email or name in the business database. + +## Consequences +- There are two "representations" of a user: + - **Keycloak** for identity and security + - **Customer Service** for business context: company and role via `keycloak_user_id` +- This is not duplicated authentication: there is still **only one login flow**, through Keycloak. +- Permissions such as "company owner manages members" are enforced by the backend through the business database. diff --git a/routes/config.go b/routes/config.go new file mode 100644 index 0000000..8fa95e5 --- /dev/null +++ b/routes/config.go @@ -0,0 +1,34 @@ +package routes + +import ( + appconfig "customer-service/config" + + "github.com/gin-gonic/gin" +) + +const configContextKey = "app_config" + +func WithConfig(cfg *appconfig.Config) gin.HandlerFunc { + return func(c *gin.Context) { + c.Set(configContextKey, cfg) + c.Next() + } +} + +func ConfigFromContext(c *gin.Context) (*appconfig.Config, bool) { + value, exists := c.Get(configContextKey) + if !exists { + return nil, false + } + + cfg, ok := value.(*appconfig.Config) + if !ok { + return nil, false + } + + return cfg, true +} + +func MustConfig(c *gin.Context) *appconfig.Config { + return c.MustGet(configContextKey).(*appconfig.Config) +} diff --git a/routes/cors.go b/routes/cors.go new file mode 100644 index 0000000..3f18050 --- /dev/null +++ b/routes/cors.go @@ -0,0 +1,22 @@ +package routes + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +func CORSMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + c.Header("Access-Control-Allow-Origin", "*") + c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization") + + if c.Request.Method == http.MethodOptions { + c.AbortWithStatus(http.StatusNoContent) + return + } + + c.Next() + } +} diff --git a/routes/cors_test.go b/routes/cors_test.go new file mode 100644 index 0000000..5bd0f8f --- /dev/null +++ b/routes/cors_test.go @@ -0,0 +1,59 @@ +package routes + +import ( + "net/http" + "net/http/httptest" + "testing" + + company "customer-service/internal/company" + user "customer-service/internal/user" + + "github.com/gin-gonic/gin" +) + +func newTestRouter() *gin.Engine { + gin.SetMode(gin.TestMode) + + r := gin.New() + r.Use(CORSMiddleware()) + RegisterRoutes(r, company.NewHandler(nil), user.NewHandler(nil)) + + return r +} + +func TestCORSMiddleware_PreflightOptions(t *testing.T) { + req := httptest.NewRequest(http.MethodOptions, "/companies", nil) + req.Header.Set("Origin", "http://localhost:3000") + req.Header.Set("Access-Control-Request-Method", http.MethodPost) + + rec := httptest.NewRecorder() + newTestRouter().ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("expected status 204, got %d", rec.Code) + } + if rec.Header().Get("Access-Control-Allow-Origin") != "*" { + t.Fatalf("expected wildcard allow-origin header, got %q", rec.Header().Get("Access-Control-Allow-Origin")) + } + if rec.Header().Get("Access-Control-Allow-Methods") == "" { + t.Fatal("expected allow-methods header to be set") + } + if rec.Header().Get("Access-Control-Allow-Headers") == "" { + t.Fatal("expected allow-headers header to be set") + } +} + +func TestCORSMiddleware_GetRequestIncludesHeaders(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/health", nil) + req.Header.Set("Origin", "http://localhost:3000") + + rec := httptest.NewRecorder() + newTestRouter().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + if rec.Header().Get("Access-Control-Allow-Origin") != "*" { + t.Fatalf("expected wildcard allow-origin header, got %q", rec.Header().Get("Access-Control-Allow-Origin")) + } +} diff --git a/routes/route.go b/routes/route.go index 79c4e2a..d890bb7 100644 --- a/routes/route.go +++ b/routes/route.go @@ -23,12 +23,15 @@ func RegisterRoutes(r *gin.Engine, companyHandler *company.Handler, userHandler companyGroup.DELETE("/:id", companyHandler.DeleteCompany) // ----------------- NESTED USER ROUTES ----------------- - companyGroup.POST("/:id/users", userHandler.CreateUser) - companyGroup.GET("/:id/users", userHandler.GetUsersByCompany) + companyGroup.POST("/:id/users-company", userHandler.CreateUser) + companyGroup.GET("/:id/users-company", userHandler.GetUsersByCompany) } // ----------------- FLAT USER ROUTES ----------------- - r.GET("/users/:id", userHandler.GetUserByID) - r.PUT("/users/:id", userHandler.UpdateUser) - r.DELETE("/users/:id", userHandler.DeleteUser) + r.GET("/users-company/keycloak/:keycloak_id", userHandler.GetUserByKeycloakID) + r.GET("/users-company/keycloak/:keycloak_id/role", userHandler.GetUserRoleByKeycloakID) + r.GET("/users-company/keycloak/:keycloak_id/company-id", userHandler.GetUserCompanyIDByKeycloakID) + r.GET("/users-company/:id", userHandler.GetUserByID) + r.PUT("/users-company/:id", userHandler.UpdateUser) + r.DELETE("/users-company/:id", userHandler.DeleteUser) } diff --git a/scripts/create_database.sql b/scripts/create_database.sql index a20cec6..96fee8b 100644 --- a/scripts/create_database.sql +++ b/scripts/create_database.sql @@ -3,8 +3,12 @@ -- \c customer_service_db; -- Drop tables (in dependency order) -DROP TABLE IF EXISTS users; +DROP TABLE IF EXISTS user_companies; DROP TABLE IF EXISTS companies; +DROP TYPE IF EXISTS user_role; + +-- User role enum +CREATE TYPE user_role AS ENUM ('user', 'manager', 'admin'); -- Companies table CREATE TABLE companies ( @@ -17,14 +21,12 @@ CREATE TABLE companies ( updated_at TIMESTAMP NOT NULL DEFAULT NOW() ); --- Users table -CREATE TABLE users ( +-- User companies table +CREATE TABLE user_companies ( id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, company_id INT NOT NULL REFERENCES companies(id) ON DELETE CASCADE, - first_name VARCHAR(100) NOT NULL, - last_name VARCHAR(100) NOT NULL, - email VARCHAR(255), - phone VARCHAR(50), + id_auth_kc VARCHAR(255) NOT NULL UNIQUE, + role user_role NOT NULL DEFAULT 'user', created_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW() ); diff --git a/scripts/fake_data_insert.sql b/scripts/fake_data_insert.sql index dea7960..cf19638 100644 --- a/scripts/fake_data_insert.sql +++ b/scripts/fake_data_insert.sql @@ -14,85 +14,56 @@ INSERT INTO companies (name, email, phone, address) VALUES ('DataPulse Analytics', 'hello@datapulse.com', '+33133990010', '88 Boulevard des DonnΓ©es, Strasbourg'); -- ========================================= --- INSERT 50 USERS +-- INSERT 50 USER-COMPANY LINKS -- ========================================= - --- Company 1 -INSERT INTO users (company_id, first_name, last_name, email, phone) VALUES -(1,'Alice','Martin','alice.martin@technova.com','+33600010001'), -(1,'Bruno','Duchamp','bruno.duchamp@technova.com','+33600010002'), -(1,'Camille','Robert','camille.robert@technova.com','+33600010003'), -(1,'Damien','Lefevre','damien.lefevre@technova.com','+33600010004'), -(1,'Eva','Morel','eva.morel@technova.com','+33600010005'); - --- Company 2 -INSERT INTO users (company_id, first_name, last_name, email, phone) VALUES -(2,'Claire','Dubois','claire.dubois@greenworld.com','+33600020001'), -(2,'David','Lambert','david.lambert@greenworld.com','+33600020002'), -(2,'Emma','Durand','emma.durand@greenworld.com','+33600020003'), -(2,'Florian','Guillot','florian.guillot@greenworld.com','+33600020004'), -(2,'HΓ©lΓ¨ne','Garnier','helene.garnier@greenworld.com','+33600020005'); - --- Company 3 -INSERT INTO users (company_id, first_name, last_name, email, phone) VALUES -(3,'FranΓ§ois','Morel','francois.morel@smartlogix.com','+33600030001'), -(3,'Isabelle','Jean','isabelle.jean@smartlogix.com','+33600030002'), -(3,'Julien','Bernard','julien.bernard@smartlogix.com','+33600030003'), -(3,'Karim','Said','karim.said@smartlogix.com','+33600030004'), -(3,'Laura','Masson','laura.masson@smartlogix.com','+33600030005'); - --- Company 4 -INSERT INTO users (company_id, first_name, last_name, email, phone) VALUES -(4,'Marc','Giraud','marc.giraud@blueocean.com','+33600040001'), -(4,'Nina','Perrot','nina.perrot@blueocean.com','+33600040002'), -(4,'Olivier','Roux','olivier.roux@blueocean.com','+33600040003'), -(4,'Pauline','Blanc','pauline.blanc@blueocean.com','+33600040004'), -(4,'Quentin','Paris','quentin.paris@blueocean.com','+33600040005'); - --- Company 5 -INSERT INTO users (company_id, first_name, last_name, email, phone) VALUES -(5,'Romain','Collet','romain.collet@nextgenlabs.com','+33600050001'), -(5,'Sarah','Dupuy','sarah.dupuy@nextgenlabs.com','+33600050002'), -(5,'Thomas','Legrand','thomas.legrand@nextgenlabs.com','+33600050003'), -(5,'Ugo','Barbier','ugo.barbier@nextgenlabs.com','+33600050004'), -(5,'ValΓ©rie','Picard','valerie.picard@nextgenlabs.com','+33600050005'); - --- Company 6 -INSERT INTO users (company_id, first_name, last_name, email, phone) VALUES -(6,'William','Lucas','william.lucas@urbanconnect.com','+33600060001'), -(6,'Xavier','Fabre','xavier.fabre@urbanconnect.com','+33600060002'), -(6,'Yasmine','Chevalier','yasmine.chevalier@urbanconnect.com','+33600060003'), -(6,'ZoΓ©','Besson','zoe.besson@urbanconnect.com','+33600060004'), -(6,'Adrien','Hardy','adrien.hardy@urbanconnect.com','+33600060005'); - --- Company 7 -INSERT INTO users (company_id, first_name, last_name, email, phone) VALUES -(7,'Amandine','Royer','amandine.royer@cybershield.com','+33600070001'), -(7,'Bastien','Hoareau','bastien.hoareau@cybershield.com','+33600070002'), -(7,'Cindy','GuΓ©rin','cindy.guerin@cybershield.com','+33600070003'), -(7,'Dorian','Pascal','dorian.pascal@cybershield.com','+33600070004'), -(7,'Elodie','Adam','elodie.adam@cybershield.com','+33600070005'); - --- Company 8 -INSERT INTO users (company_id, first_name, last_name, email, phone) VALUES -(8,'Fabrice','Diallo','fabrice.diallo@solaris.com','+33600080001'), -(8,'Gina','Rolland','gina.rolland@solaris.com','+33600080002'), -(8,'Hugo','Meunier','hugo.meunier@solaris.com','+33600080003'), -(8,'InΓ¨s','Boulanger','ines.boulanger@solaris.com','+33600080004'), -(8,'Jean','Colin','jean.colin@solaris.com','+33600080005'); - --- Company 9 -INSERT INTO users (company_id, first_name, last_name, email, phone) VALUES -(9,'Katia','Maurel','katia.maurel@aerodynamics.com','+33600090001'), -(9,'Luc','Girard','luc.girard@aerodynamics.com','+33600090002'), -(9,'Manon','PrΓ©vost','manon.prevost@aerodynamics.com','+33600090003'), -(9,'Nicolas','Fontaine','nicolas.fontaine@aerodynamics.com','+33600090004'), -(9,'Oceane','Leclerc','oceane.leclerc@aerodynamics.com','+33600090005'); - --- Company 10 -INSERT INTO users (company_id, first_name, last_name, email, phone) VALUES -(10,'Pascal','Briand','pascal.briand@datapulse.com','+33600100001'), -(10,'Rita','Fournier','rita.fournier@datapulse.com','+33600100002'), -(10,'Samir','Diallo','samir.diallo@datapulse.com','+33600100003'), -(10,'Tania','Maury','tania.maury@datapulse.com','+33600100004'), -(10,'Yohan','Chartier','yohan.chartier@datapulse.com','+33600100005'); +INSERT INTO user_companies (company_id, id_auth_kc) VALUES +(1, 'kc-technova-001'), +(1, 'kc-technova-002'), +(1, 'kc-technova-003'), +(1, 'kc-technova-004'), +(1, 'kc-technova-005'), +(2, 'kc-greenworld-001'), +(2, 'kc-greenworld-002'), +(2, 'kc-greenworld-003'), +(2, 'kc-greenworld-004'), +(2, 'kc-greenworld-005'), +(3, 'kc-smartlogix-001'), +(3, 'kc-smartlogix-002'), +(3, 'kc-smartlogix-003'), +(3, 'kc-smartlogix-004'), +(3, 'kc-smartlogix-005'), +(4, 'kc-blueocean-001'), +(4, 'kc-blueocean-002'), +(4, 'kc-blueocean-003'), +(4, 'kc-blueocean-004'), +(4, 'kc-blueocean-005'), +(5, 'kc-nextgenlabs-001'), +(5, 'kc-nextgenlabs-002'), +(5, 'kc-nextgenlabs-003'), +(5, 'kc-nextgenlabs-004'), +(5, 'kc-nextgenlabs-005'), +(6, 'kc-urbanconnect-001'), +(6, 'kc-urbanconnect-002'), +(6, 'kc-urbanconnect-003'), +(6, 'kc-urbanconnect-004'), +(6, 'kc-urbanconnect-005'), +(7, 'kc-cybershield-001'), +(7, 'kc-cybershield-002'), +(7, 'kc-cybershield-003'), +(7, 'kc-cybershield-004'), +(7, 'kc-cybershield-005'), +(8, 'kc-solaris-001'), +(8, 'kc-solaris-002'), +(8, 'kc-solaris-003'), +(8, 'kc-solaris-004'), +(8, 'kc-solaris-005'), +(9, 'kc-aerodynamics-001'), +(9, 'kc-aerodynamics-002'), +(9, 'kc-aerodynamics-003'), +(9, 'kc-aerodynamics-004'), +(9, 'kc-aerodynamics-005'), +(10, 'kc-datapulse-001'), +(10, 'kc-datapulse-002'), +(10, 'kc-datapulse-003'), +(10, 'kc-datapulse-004'), +(10, 'kc-datapulse-005'); diff --git a/test/test_api.sh b/test/test_api.sh index 389fd12..1b51328 100644 --- a/test/test_api.sh +++ b/test/test_api.sh @@ -18,13 +18,11 @@ COMPANY_ID=$(echo "$COMPANY" | jq -r '.id') echo "Company ID: $COMPANY_ID" echo "==> Creating user in company..." -USER=$(curl -s -X POST $BASE_URL/companies/$COMPANY_ID/users \ +USER=$(curl -s -X POST $BASE_URL/companies/$COMPANY_ID/users-company \ -H "Content-Type: application/json" \ -d '{ - "first_name": "John", - "last_name": "Doe", - "email": "john@acme.com", - "phone": "0611223344" + "id_auth_kc": "kc-acme-admin-001", + "role": "admin" }') echo "$USER" @@ -37,7 +35,7 @@ echo "==> Listing companies..." curl -s $BASE_URL/companies | jq . echo "==> Listing users of company..." -curl -s $BASE_URL/companies/$COMPANY_ID/users | jq . +curl -s $BASE_URL/companies/$COMPANY_ID/users-company | jq . echo "==> Updating company..." curl -s -X PUT $BASE_URL/companies/$COMPANY_ID \ @@ -50,18 +48,16 @@ curl -s -X PUT $BASE_URL/companies/$COMPANY_ID \ }' | jq . echo "==> Updating user..." -curl -s -X PUT $BASE_URL/users/$USER_ID \ +curl -s -X PUT $BASE_URL/users-company/$USER_ID \ -H "Content-Type: application/json" \ -d '{ - "first_name": "Jane", - "last_name": "Doe", - "email": "jane@acme.com", - "phone": "0699887766" + "id_auth_kc": "kc-acme-admin-002", + "role": "manager" }' | jq . echo "==> Deleting user..." -curl -s -X DELETE $BASE_URL/users/$USER_ID +curl -s -X DELETE $BASE_URL/users-company/$USER_ID echo "==> Deleting company..." curl -s -X DELETE $BASE_URL/companies/$COMPANY_ID