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
+
+
+ 1773751126422
+
+
+
+ 1773759603136
+
+
+
+ 1773759603136
+
+
+
+ 1773760223302
+
+
+
+ 1773760223302
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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