Add platform-projects-core backend
This commit is contained in:
parent
3649385924
commit
8ca16f064b
59 changed files with 2065 additions and 0 deletions
10
platform-projects-core/.env.example
Normal file
10
platform-projects-core/.env.example
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
SERVICE_NAME=platform-projects-core
|
||||||
|
ENVIRONMENT=development
|
||||||
|
HTTP_ADDRESS=:8080
|
||||||
|
DATABASE_DSN=postgres://postgres:postgres@localhost:5432/platform_projects?sslmode=disable
|
||||||
|
AUTH_JWKS_URL=
|
||||||
|
AUTH_FALLBACK_SECRET=dev-secret
|
||||||
|
AUTH_AUDIENCE=platform
|
||||||
|
AUTH_ISSUER=platform-core
|
||||||
|
AUTH_CLOCK_SKEW_SECONDS=60
|
||||||
|
|
||||||
16
platform-projects-core/Dockerfile
Normal file
16
platform-projects-core/Dockerfile
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
FROM golang:1.22-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . ./
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -o /bin/platform-projects-core ./cmd/api
|
||||||
|
|
||||||
|
FROM alpine:3.19
|
||||||
|
RUN apk add --no-cache ca-certificates
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /bin/platform-projects-core /app/platform-projects-core
|
||||||
|
EXPOSE 8080
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --retries=3 CMD wget -qO- http://localhost:8080/api/v1/health || exit 1
|
||||||
|
ENTRYPOINT ["/app/platform-projects-core"]
|
||||||
|
|
||||||
22
platform-projects-core/Makefile
Normal file
22
platform-projects-core/Makefile
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
APP_NAME=platform-projects-core
|
||||||
|
|
||||||
|
.PHONY: run
|
||||||
|
run:
|
||||||
|
go run ./cmd/api
|
||||||
|
|
||||||
|
.PHONY: lint
|
||||||
|
lint:
|
||||||
|
golangci-lint run ./...
|
||||||
|
|
||||||
|
.PHONY: test
|
||||||
|
test:
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
.PHONY: migrate
|
||||||
|
migrate:
|
||||||
|
migrate -path internal/db/migrations -database "$${DATABASE_DSN}" up
|
||||||
|
|
||||||
|
.PHONY: sqlc
|
||||||
|
sqlc:
|
||||||
|
sqlc generate -f internal/db/sqlc/sqlc.yaml
|
||||||
|
|
||||||
75
platform-projects-core/README.md
Normal file
75
platform-projects-core/README.md
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
# platform-projects-core
|
||||||
|
|
||||||
|
`platform-projects-core` é o ERP técnico da plataforma. Ele é a **fonte de verdade** sobre projetos, produtos e ambientes.
|
||||||
|
|
||||||
|
## Responsabilidade e limites
|
||||||
|
|
||||||
|
**Este serviço faz:**
|
||||||
|
- Governança de projetos, ambientes, repositórios e vínculos externos.
|
||||||
|
- Orquestração de metadados com multi-tenancy por design.
|
||||||
|
- Auditoria e observabilidade para uso enterprise.
|
||||||
|
|
||||||
|
**Este serviço não faz:**
|
||||||
|
- Não executa código de cliente.
|
||||||
|
- Não faz CI/CD ou deploy.
|
||||||
|
- Não autentica usuários finais.
|
||||||
|
- Não substitui GitHub, GitLab ou clouds.
|
||||||
|
|
||||||
|
## Fluxo de dados
|
||||||
|
|
||||||
|
1. Requisições autenticadas chegam em `/api/v1`.
|
||||||
|
2. Middleware JWT valida, injeta `tenantId` no contexto e bloqueia requisições inválidas.
|
||||||
|
3. Handlers transformam DTOs em comandos e chamam os casos de uso.
|
||||||
|
4. Casos de uso aplicam regras de domínio e persistem via repositórios.
|
||||||
|
5. Respostas padronizadas são retornadas ao cliente.
|
||||||
|
|
||||||
|
## Modelo de domínio (resumo)
|
||||||
|
|
||||||
|
- **Project (Aggregate Root)**: slug único por tenant, status controlado.
|
||||||
|
- **Environment**: pertence a um projeto, tipos controlados.
|
||||||
|
- **Repository**: metadados, provider enum, URL validada, branch obrigatória.
|
||||||
|
- **Project Links**: somente IDs externos, meta JSON sem segredo.
|
||||||
|
|
||||||
|
## Exemplo de uso
|
||||||
|
|
||||||
|
Criar projeto:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8080/api/v1/projects \
|
||||||
|
-H "Authorization: Bearer <token>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name":"Core ERP","slug":"core-erp","description":"ERP técnico"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Criar ambiente:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8080/api/v1/environments \
|
||||||
|
-H "Authorization: Bearer <token>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"project_id":"<project-id>","type":"production"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Escalabilidade com segurança
|
||||||
|
|
||||||
|
- Multi-tenancy explícito em todas as tabelas e queries.
|
||||||
|
- JWT validado por JWKS e fallback opcional por secret interno.
|
||||||
|
- Logs estruturados e middlewares defensivos.
|
||||||
|
- Configuração validada no boot (fail-fast).
|
||||||
|
|
||||||
|
## Comandos
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make run
|
||||||
|
make migrate
|
||||||
|
make sqlc
|
||||||
|
make test
|
||||||
|
make lint
|
||||||
|
```
|
||||||
|
|
||||||
|
## Observabilidade
|
||||||
|
|
||||||
|
- Logs estruturados via `slog`.
|
||||||
|
- Endpoint `/api/v1/health`.
|
||||||
|
- Endpoint `/api/v1/metrics` (placeholder para instrumentação enterprise).
|
||||||
|
|
||||||
74
platform-projects-core/cmd/api/main.go
Normal file
74
platform-projects-core/cmd/api/main.go
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
apihttp "platform-projects-core/internal/api/http"
|
||||||
|
"platform-projects-core/internal/config"
|
||||||
|
"platform-projects-core/internal/infrastructure/auth"
|
||||||
|
"platform-projects-core/internal/infrastructure/postgres"
|
||||||
|
"platform-projects-core/internal/observability"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("config error", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger := observability.NewLogger(cfg.Environment)
|
||||||
|
logger.Info("starting service", "service", cfg.ServiceName)
|
||||||
|
|
||||||
|
pool, err := postgres.NewPool(ctx, cfg.Database)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("database connection failed", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
jwtValidator, err := auth.NewValidator(ctx, cfg.Auth)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("jwt validator init failed", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
router := apihttp.NewRouter(apihttp.RouterDependencies{
|
||||||
|
Logger: logger,
|
||||||
|
DB: pool,
|
||||||
|
JWTValidator: jwtValidator,
|
||||||
|
Config: cfg,
|
||||||
|
})
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: cfg.HTTP.Address,
|
||||||
|
Handler: router,
|
||||||
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||||
|
logger.Error("http shutdown error", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
logger.Info("http server listening", "addr", cfg.HTTP.Address)
|
||||||
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
logger.Error("http server failed", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("service stopped")
|
||||||
|
}
|
||||||
30
platform-projects-core/docker-compose.yml
Normal file
30
platform-projects-core/docker-compose.yml
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
version: "3.9"
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
build: .
|
||||||
|
environment:
|
||||||
|
SERVICE_NAME: platform-projects-core
|
||||||
|
ENVIRONMENT: development
|
||||||
|
HTTP_ADDRESS: ":8080"
|
||||||
|
DATABASE_DSN: postgres://postgres:postgres@db:5432/platform_projects?sslmode=disable
|
||||||
|
AUTH_JWKS_URL: ""
|
||||||
|
AUTH_FALLBACK_SECRET: "dev-secret"
|
||||||
|
AUTH_AUDIENCE: "platform"
|
||||||
|
AUTH_ISSUER: "platform-core"
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
db:
|
||||||
|
image: postgres:16
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: platform_projects
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
|
||||||
21
platform-projects-core/docs/architecture.md
Normal file
21
platform-projects-core/docs/architecture.md
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
# Arquitetura
|
||||||
|
|
||||||
|
## Camadas
|
||||||
|
|
||||||
|
- **API Layer**: HTTP, validação, DTOs, respostas padronizadas.
|
||||||
|
- **Application**: casos de uso e contratos explícitos.
|
||||||
|
- **Domain**: entidades e regras de negócio.
|
||||||
|
- **Infrastructure**: adaptadores para banco, JWT e observabilidade.
|
||||||
|
|
||||||
|
## Contratos explícitos
|
||||||
|
|
||||||
|
- Use cases dependem de interfaces de repositório.
|
||||||
|
- Handlers não conhecem SQL.
|
||||||
|
- Infraestrutura não conhece HTTP.
|
||||||
|
|
||||||
|
## Multi-tenancy
|
||||||
|
|
||||||
|
- `tenant_id` obrigatório em todas as tabelas.
|
||||||
|
- Todas as queries exigem `tenant_id`.
|
||||||
|
- `tenantId` é injetado no contexto durante autenticação.
|
||||||
|
|
||||||
39
platform-projects-core/docs/domain-model.md
Normal file
39
platform-projects-core/docs/domain-model.md
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
# Modelo de domínio
|
||||||
|
|
||||||
|
## Project (Aggregate Root)
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `tenant_id`
|
||||||
|
- `name`
|
||||||
|
- `slug`
|
||||||
|
- `description`
|
||||||
|
- `status` (active, paused, archived)
|
||||||
|
- `created_at`
|
||||||
|
- `updated_at`
|
||||||
|
|
||||||
|
**Regras:**
|
||||||
|
- `slug` é único por tenant.
|
||||||
|
- Projeto arquivado não aceita novos vínculos.
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `tenant_id`
|
||||||
|
- `project_id`
|
||||||
|
- `type`
|
||||||
|
- `paused`
|
||||||
|
|
||||||
|
## Repository
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `tenant_id`
|
||||||
|
- `project_id`
|
||||||
|
- `provider`
|
||||||
|
- `url`
|
||||||
|
- `default_branch`
|
||||||
|
|
||||||
|
## Project Links
|
||||||
|
|
||||||
|
- `infra`, `billing`, `security`
|
||||||
|
- apenas IDs externos e `meta` JSON sem segredo
|
||||||
|
|
||||||
14
platform-projects-core/docs/security-model.md
Normal file
14
platform-projects-core/docs/security-model.md
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# Modelo de segurança
|
||||||
|
|
||||||
|
## Autenticação
|
||||||
|
|
||||||
|
- JWT validado via JWKS (padrão).
|
||||||
|
- Fallback opcional para secret interno (MVP).
|
||||||
|
- Claims obrigatórias: `sub`, `tenantId`, `roles`.
|
||||||
|
|
||||||
|
## Autorização
|
||||||
|
|
||||||
|
- `tenantId` injetado no contexto.
|
||||||
|
- Toda query utiliza `tenant_id` explicitamente.
|
||||||
|
- Falha de segurança gera erro explícito para o cliente.
|
||||||
|
|
||||||
20
platform-projects-core/go.mod
Normal file
20
platform-projects-core/go.mod
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
module platform-projects-core
|
||||||
|
|
||||||
|
go 1.22
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/MicahParks/keyfunc/v2 v2.1.0
|
||||||
|
github.com/go-chi/chi/v5 v5.0.10
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/jackc/pgx/v5 v5.6.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||||
|
golang.org/x/crypto v0.17.0 // indirect
|
||||||
|
golang.org/x/sync v0.1.0 // indirect
|
||||||
|
golang.org/x/text v0.14.0 // indirect
|
||||||
|
)
|
||||||
36
platform-projects-core/go.sum
Normal file
36
platform-projects-core/go.sum
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
github.com/MicahParks/keyfunc/v2 v2.1.0 h1:6ZXKb9Rp6qp1bDbJefnG7cTH8yMN1IC/4nf+GVjO99k=
|
||||||
|
github.com/MicahParks/keyfunc/v2 v2.1.0/go.mod h1:rW42fi+xgLJ2FRRXAfNx9ZA8WpD4OeE/yHVMteCkw9k=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk=
|
||||||
|
github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
|
||||||
|
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
|
||||||
|
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||||
|
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
|
||||||
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||||
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"platform-projects-core/internal/api/transport"
|
||||||
|
"platform-projects-core/internal/application/environments"
|
||||||
|
"platform-projects-core/internal/infrastructure/repositories"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EnvironmentsHandler struct {
|
||||||
|
logger *slog.Logger
|
||||||
|
usecase environments.CreateEnvironmentUseCase
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEnvironmentsHandler(logger *slog.Logger, pool *pgxpool.Pool) http.Handler {
|
||||||
|
repo := repositories.NewEnvironmentsRepository(pool)
|
||||||
|
h := &EnvironmentsHandler{
|
||||||
|
logger: logger,
|
||||||
|
usecase: environments.CreateEnvironmentUseCase{Repo: repo},
|
||||||
|
}
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Post("/", h.create)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
type createEnvironmentRequest struct {
|
||||||
|
ProjectID string `json:"project_id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *EnvironmentsHandler) create(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID, ok := transport.TenantFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
transport.WriteError(w, http.StatusUnauthorized, "unauthorized", "tenant missing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req createEnvironmentRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
transport.WriteError(w, http.StatusBadRequest, "invalid_payload", "invalid JSON payload")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
env, err := h.usecase.Execute(r.Context(), environments.CreateEnvironmentInput{
|
||||||
|
TenantID: tenantID,
|
||||||
|
ProjectID: req.ProjectID,
|
||||||
|
Type: req.Type,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeDomainError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
transport.WriteJSON(w, http.StatusCreated, transport.Envelope{"environment": env})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"platform-projects-core/internal/api/transport"
|
||||||
|
"platform-projects-core/internal/application/project_links"
|
||||||
|
"platform-projects-core/internal/infrastructure/repositories"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProjectLinksHandler struct {
|
||||||
|
logger *slog.Logger
|
||||||
|
infra project_links.LinkInfraUseCase
|
||||||
|
billing project_links.LinkBillingUseCase
|
||||||
|
security project_links.LinkSecurityUseCase
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProjectLinksHandler(logger *slog.Logger, pool *pgxpool.Pool) http.Handler {
|
||||||
|
repo := repositories.NewProjectLinksRepository(pool)
|
||||||
|
h := &ProjectLinksHandler{
|
||||||
|
logger: logger,
|
||||||
|
infra: project_links.LinkInfraUseCase{Repo: repo},
|
||||||
|
billing: project_links.LinkBillingUseCase{Repo: repo},
|
||||||
|
security: project_links.LinkSecurityUseCase{Repo: repo},
|
||||||
|
}
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Post("/infra", h.linkInfra)
|
||||||
|
r.Post("/billing", h.linkBilling)
|
||||||
|
r.Post("/security", h.linkSecurity)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
type linkRequest struct {
|
||||||
|
ProjectID string `json:"project_id"`
|
||||||
|
External string `json:"external"`
|
||||||
|
Meta map[string]string `json:"meta"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ProjectLinksHandler) linkInfra(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID, ok := transport.TenantFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
transport.WriteError(w, http.StatusUnauthorized, "unauthorized", "tenant missing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req linkRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
transport.WriteError(w, http.StatusBadRequest, "invalid_payload", "invalid JSON payload")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
link, err := h.infra.Execute(r.Context(), project_links.LinkInfraInput{
|
||||||
|
TenantID: tenantID,
|
||||||
|
ProjectID: req.ProjectID,
|
||||||
|
External: req.External,
|
||||||
|
Meta: req.Meta,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeDomainError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
transport.WriteJSON(w, http.StatusCreated, transport.Envelope{"link": link})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ProjectLinksHandler) linkBilling(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID, ok := transport.TenantFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
transport.WriteError(w, http.StatusUnauthorized, "unauthorized", "tenant missing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req linkRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
transport.WriteError(w, http.StatusBadRequest, "invalid_payload", "invalid JSON payload")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
link, err := h.billing.Execute(r.Context(), project_links.LinkBillingInput{
|
||||||
|
TenantID: tenantID,
|
||||||
|
ProjectID: req.ProjectID,
|
||||||
|
External: req.External,
|
||||||
|
Meta: req.Meta,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeDomainError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
transport.WriteJSON(w, http.StatusCreated, transport.Envelope{"link": link})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ProjectLinksHandler) linkSecurity(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID, ok := transport.TenantFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
transport.WriteError(w, http.StatusUnauthorized, "unauthorized", "tenant missing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req linkRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
transport.WriteError(w, http.StatusBadRequest, "invalid_payload", "invalid JSON payload")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
link, err := h.security.Execute(r.Context(), project_links.LinkSecurityInput{
|
||||||
|
TenantID: tenantID,
|
||||||
|
ProjectID: req.ProjectID,
|
||||||
|
External: req.External,
|
||||||
|
Meta: req.Meta,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeDomainError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
transport.WriteJSON(w, http.StatusCreated, transport.Envelope{"link": link})
|
||||||
|
}
|
||||||
147
platform-projects-core/internal/api/handlers/projects_handler.go
Normal file
147
platform-projects-core/internal/api/handlers/projects_handler.go
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"platform-projects-core/internal/api/transport"
|
||||||
|
appprojects "platform-projects-core/internal/application/projects"
|
||||||
|
"platform-projects-core/internal/domain/common"
|
||||||
|
domainprojects "platform-projects-core/internal/domain/projects"
|
||||||
|
"platform-projects-core/internal/infrastructure/repositories"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProjectsHandler struct {
|
||||||
|
logger *slog.Logger
|
||||||
|
usecases struct {
|
||||||
|
create appprojects.CreateProjectUseCase
|
||||||
|
update appprojects.UpdateProjectUseCase
|
||||||
|
archive appprojects.ArchiveProjectUseCase
|
||||||
|
list appprojects.ListProjectsUseCase
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProjectsHandler(logger *slog.Logger, pool *pgxpool.Pool) http.Handler {
|
||||||
|
repo := repositories.NewProjectsRepository(pool)
|
||||||
|
h := &ProjectsHandler{logger: logger}
|
||||||
|
h.usecases.create = appprojects.CreateProjectUseCase{Repo: repo}
|
||||||
|
h.usecases.update = appprojects.UpdateProjectUseCase{Repo: repo}
|
||||||
|
h.usecases.archive = appprojects.ArchiveProjectUseCase{Repo: repo}
|
||||||
|
h.usecases.list = appprojects.ListProjectsUseCase{Repo: repo}
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Post("/", h.create)
|
||||||
|
r.Get("/", h.list)
|
||||||
|
r.Put("/{projectId}", h.update)
|
||||||
|
r.Delete("/{projectId}", h.archive)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
type createProjectRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ProjectsHandler) create(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID, ok := transport.TenantFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
transport.WriteError(w, http.StatusUnauthorized, "unauthorized", "tenant missing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req createProjectRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
transport.WriteError(w, http.StatusBadRequest, "invalid_payload", "invalid JSON payload")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
project, err := h.usecases.create.Execute(r.Context(), appprojects.CreateProjectInput{
|
||||||
|
TenantID: tenantID,
|
||||||
|
Name: req.Name,
|
||||||
|
Slug: req.Slug,
|
||||||
|
Description: req.Description,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeDomainError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
transport.WriteJSON(w, http.StatusCreated, transport.Envelope{"project": project})
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateProjectRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ProjectsHandler) update(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID, ok := transport.TenantFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
transport.WriteError(w, http.StatusUnauthorized, "unauthorized", "tenant missing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
projectID := chi.URLParam(r, "projectId")
|
||||||
|
var req updateProjectRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
transport.WriteError(w, http.StatusBadRequest, "invalid_payload", "invalid JSON payload")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
project, err := h.usecases.update.Execute(r.Context(), appprojects.UpdateProjectInput{
|
||||||
|
TenantID: tenantID,
|
||||||
|
ProjectID: projectID,
|
||||||
|
Name: req.Name,
|
||||||
|
Description: req.Description,
|
||||||
|
Status: domainprojects.Status(req.Status),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeDomainError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
transport.WriteJSON(w, http.StatusOK, transport.Envelope{"project": project})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ProjectsHandler) archive(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID, ok := transport.TenantFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
transport.WriteError(w, http.StatusUnauthorized, "unauthorized", "tenant missing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
projectID := chi.URLParam(r, "projectId")
|
||||||
|
if err := h.usecases.archive.Execute(r.Context(), appprojects.ArchiveProjectInput{
|
||||||
|
TenantID: tenantID,
|
||||||
|
ProjectID: projectID,
|
||||||
|
}); err != nil {
|
||||||
|
writeDomainError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
transport.WriteJSON(w, http.StatusOK, transport.Envelope{"status": "archived"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ProjectsHandler) list(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID, ok := transport.TenantFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
transport.WriteError(w, http.StatusUnauthorized, "unauthorized", "tenant missing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
projectsList, err := h.usecases.list.Execute(r.Context(), appprojects.ListProjectsInput{TenantID: tenantID})
|
||||||
|
if err != nil {
|
||||||
|
writeDomainError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
transport.WriteJSON(w, http.StatusOK, transport.Envelope{"projects": projectsList})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeDomainError(w http.ResponseWriter, err error) {
|
||||||
|
switch err {
|
||||||
|
case common.ErrInvalidInput:
|
||||||
|
transport.WriteError(w, http.StatusBadRequest, "invalid_input", "invalid input")
|
||||||
|
case common.ErrNotFound:
|
||||||
|
transport.WriteError(w, http.StatusNotFound, "not_found", "resource not found")
|
||||||
|
case common.ErrConflict:
|
||||||
|
transport.WriteError(w, http.StatusConflict, "conflict", "conflict")
|
||||||
|
default:
|
||||||
|
transport.WriteError(w, http.StatusInternalServerError, "internal_error", "unexpected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"platform-projects-core/internal/api/transport"
|
||||||
|
apprepos "platform-projects-core/internal/application/repositories"
|
||||||
|
infrarepos "platform-projects-core/internal/infrastructure/repositories"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RepositoriesHandler struct {
|
||||||
|
logger *slog.Logger
|
||||||
|
usecase apprepos.LinkRepositoryUseCase
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRepositoriesHandler(logger *slog.Logger, pool *pgxpool.Pool) http.Handler {
|
||||||
|
repo := infrarepos.NewRepositoriesRepository(pool)
|
||||||
|
h := &RepositoriesHandler{
|
||||||
|
logger: logger,
|
||||||
|
usecase: apprepos.LinkRepositoryUseCase{Repo: repo},
|
||||||
|
}
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Post("/", h.link)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
type linkRepositoryRequest struct {
|
||||||
|
ProjectID string `json:"project_id"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
DefaultBranch string `json:"default_branch"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RepositoriesHandler) link(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID, ok := transport.TenantFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
transport.WriteError(w, http.StatusUnauthorized, "unauthorized", "tenant missing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req linkRepositoryRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
transport.WriteError(w, http.StatusBadRequest, "invalid_payload", "invalid JSON payload")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
repo, err := h.usecase.Execute(r.Context(), apprepos.LinkRepositoryInput{
|
||||||
|
TenantID: tenantID,
|
||||||
|
ProjectID: req.ProjectID,
|
||||||
|
Provider: req.Provider,
|
||||||
|
URL: req.URL,
|
||||||
|
DefaultBranch: req.DefaultBranch,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeDomainError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
transport.WriteJSON(w, http.StatusCreated, transport.Envelope{"repository": repo})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"platform-projects-core/internal/api/transport"
|
||||||
|
"platform-projects-core/internal/application/teams"
|
||||||
|
"platform-projects-core/internal/infrastructure/repositories"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TeamsHandler struct {
|
||||||
|
logger *slog.Logger
|
||||||
|
usecase teams.ManageTeamUseCase
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTeamsHandler(logger *slog.Logger, pool *pgxpool.Pool) http.Handler {
|
||||||
|
repo := repositories.NewTeamsRepository(pool)
|
||||||
|
h := &TeamsHandler{
|
||||||
|
logger: logger,
|
||||||
|
usecase: teams.ManageTeamUseCase{Repo: repo},
|
||||||
|
}
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Post("/", h.upsert)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
type manageTeamRequest struct {
|
||||||
|
ProjectID string `json:"project_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TeamsHandler) upsert(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID, ok := transport.TenantFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
transport.WriteError(w, http.StatusUnauthorized, "unauthorized", "tenant missing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req manageTeamRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
transport.WriteError(w, http.StatusBadRequest, "invalid_payload", "invalid JSON payload")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
team, err := h.usecase.Execute(r.Context(), teams.ManageTeamInput{
|
||||||
|
TenantID: tenantID,
|
||||||
|
ProjectID: req.ProjectID,
|
||||||
|
Name: req.Name,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeDomainError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
transport.WriteJSON(w, http.StatusCreated, transport.Envelope{"team": team})
|
||||||
|
}
|
||||||
36
platform-projects-core/internal/api/http/middleware.go
Normal file
36
platform-projects-core/internal/api/http/middleware.go
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"platform-projects-core/internal/api/transport"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RequestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
start := time.Now()
|
||||||
|
ww := transport.WrapResponseWriter(w)
|
||||||
|
next.ServeHTTP(ww, r)
|
||||||
|
logger.Info("http request",
|
||||||
|
"method", r.Method,
|
||||||
|
"path", r.URL.Path,
|
||||||
|
"status", transport.StatusCode(ww),
|
||||||
|
"duration_ms", time.Since(start).Milliseconds(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func SecurityHeaders() func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
w.Header().Set("X-Frame-Options", "DENY")
|
||||||
|
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
52
platform-projects-core/internal/api/http/router.go
Normal file
52
platform-projects-core/internal/api/http/router.go
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"platform-projects-core/internal/api/handlers"
|
||||||
|
"platform-projects-core/internal/api/transport"
|
||||||
|
"platform-projects-core/internal/config"
|
||||||
|
"platform-projects-core/internal/infrastructure/auth"
|
||||||
|
"platform-projects-core/internal/observability"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RouterDependencies struct {
|
||||||
|
Logger *slog.Logger
|
||||||
|
DB *pgxpool.Pool
|
||||||
|
JWTValidator *auth.Validator
|
||||||
|
Config config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRouter(deps RouterDependencies) http.Handler {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
logger := deps.Logger
|
||||||
|
|
||||||
|
r.Use(middleware.RequestID)
|
||||||
|
r.Use(middleware.RealIP)
|
||||||
|
r.Use(middleware.Recoverer)
|
||||||
|
r.Use(RequestLogger(logger))
|
||||||
|
r.Use(SecurityHeaders())
|
||||||
|
|
||||||
|
r.Route("/api/v1", func(r chi.Router) {
|
||||||
|
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
transport.WriteJSON(w, http.StatusOK, transport.Envelope{"status": "ok"})
|
||||||
|
})
|
||||||
|
r.Get("/metrics", observability.MetricsHandler().ServeHTTP)
|
||||||
|
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(auth.Middleware(deps.JWTValidator))
|
||||||
|
|
||||||
|
r.Mount("/projects", handlers.NewProjectsHandler(logger, deps.DB))
|
||||||
|
r.Mount("/environments", handlers.NewEnvironmentsHandler(logger, deps.DB))
|
||||||
|
r.Mount("/repositories", handlers.NewRepositoriesHandler(logger, deps.DB))
|
||||||
|
r.Mount("/teams", handlers.NewTeamsHandler(logger, deps.DB))
|
||||||
|
r.Mount("/project-links", handlers.NewProjectLinksHandler(logger, deps.DB))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
16
platform-projects-core/internal/api/transport/context.go
Normal file
16
platform-projects-core/internal/api/transport/context.go
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
package transport
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
const tenantKey contextKey = "tenant_id"
|
||||||
|
|
||||||
|
func WithTenant(ctx context.Context, tenantID string) context.Context {
|
||||||
|
return context.WithValue(ctx, tenantKey, tenantID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TenantFromContext(ctx context.Context) (string, bool) {
|
||||||
|
tenant, ok := ctx.Value(tenantKey).(string)
|
||||||
|
return tenant, ok
|
||||||
|
}
|
||||||
41
platform-projects-core/internal/api/transport/response.go
Normal file
41
platform-projects-core/internal/api/transport/response.go
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
package transport
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Envelope map[string]any
|
||||||
|
|
||||||
|
type ErrorResponse struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type responseWriter struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
statusCode int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *responseWriter) WriteHeader(statusCode int) {
|
||||||
|
w.statusCode = statusCode
|
||||||
|
w.ResponseWriter.WriteHeader(statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func WriteJSON(w http.ResponseWriter, status int, payload any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func WriteError(w http.ResponseWriter, status int, code, message string) {
|
||||||
|
WriteJSON(w, status, Envelope{"error": ErrorResponse{Message: message, Code: code}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func WrapResponseWriter(w http.ResponseWriter) *responseWriter {
|
||||||
|
return &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
|
||||||
|
}
|
||||||
|
|
||||||
|
func StatusCode(w *responseWriter) int {
|
||||||
|
return w.statusCode
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
package environments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"platform-projects-core/internal/domain/projects"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EnvironmentRepository interface {
|
||||||
|
Create(ctx context.Context, env projects.Environment) (projects.Environment, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateEnvironmentUseCase struct {
|
||||||
|
Repo EnvironmentRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateEnvironmentInput struct {
|
||||||
|
TenantID string
|
||||||
|
ProjectID string
|
||||||
|
Type string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc CreateEnvironmentUseCase) Execute(ctx context.Context, input CreateEnvironmentInput) (projects.Environment, error) {
|
||||||
|
env, err := projects.NewEnvironment(input.TenantID, input.ProjectID, input.Type)
|
||||||
|
if err != nil {
|
||||||
|
return projects.Environment{}, err
|
||||||
|
}
|
||||||
|
return uc.Repo.Create(ctx, env)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
package project_links
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"platform-projects-core/internal/domain/common"
|
||||||
|
"platform-projects-core/internal/domain/project_links"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LinkBillingUseCase struct {
|
||||||
|
Repo LinkRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
type LinkBillingInput struct {
|
||||||
|
TenantID string
|
||||||
|
ProjectID string
|
||||||
|
External string
|
||||||
|
Meta map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc LinkBillingUseCase) Execute(ctx context.Context, input LinkBillingInput) (project_links.BillingLink, error) {
|
||||||
|
return uc.Repo.LinkBilling(ctx, project_links.BillingLink{
|
||||||
|
ID: common.NewID(),
|
||||||
|
TenantID: input.TenantID,
|
||||||
|
ProjectID: input.ProjectID,
|
||||||
|
External: input.External,
|
||||||
|
Meta: input.Meta,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
package project_links
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"platform-projects-core/internal/domain/common"
|
||||||
|
"platform-projects-core/internal/domain/project_links"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LinkRepository interface {
|
||||||
|
LinkInfra(ctx context.Context, link project_links.InfraLink) (project_links.InfraLink, error)
|
||||||
|
LinkBilling(ctx context.Context, link project_links.BillingLink) (project_links.BillingLink, error)
|
||||||
|
LinkSecurity(ctx context.Context, link project_links.SecurityLink) (project_links.SecurityLink, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type LinkInfraUseCase struct {
|
||||||
|
Repo LinkRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
type LinkInfraInput struct {
|
||||||
|
TenantID string
|
||||||
|
ProjectID string
|
||||||
|
External string
|
||||||
|
Meta map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc LinkInfraUseCase) Execute(ctx context.Context, input LinkInfraInput) (project_links.InfraLink, error) {
|
||||||
|
return uc.Repo.LinkInfra(ctx, project_links.InfraLink{
|
||||||
|
ID: common.NewID(),
|
||||||
|
TenantID: input.TenantID,
|
||||||
|
ProjectID: input.ProjectID,
|
||||||
|
External: input.External,
|
||||||
|
Meta: input.Meta,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
package project_links
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"platform-projects-core/internal/domain/common"
|
||||||
|
"platform-projects-core/internal/domain/project_links"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LinkSecurityUseCase struct {
|
||||||
|
Repo LinkRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
type LinkSecurityInput struct {
|
||||||
|
TenantID string
|
||||||
|
ProjectID string
|
||||||
|
External string
|
||||||
|
Meta map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc LinkSecurityUseCase) Execute(ctx context.Context, input LinkSecurityInput) (project_links.SecurityLink, error) {
|
||||||
|
return uc.Repo.LinkSecurity(ctx, project_links.SecurityLink{
|
||||||
|
ID: common.NewID(),
|
||||||
|
TenantID: input.TenantID,
|
||||||
|
ProjectID: input.ProjectID,
|
||||||
|
External: input.External,
|
||||||
|
Meta: input.Meta,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
package projects
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"platform-projects-core/internal/domain/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ArchiveProjectUseCase struct {
|
||||||
|
Repo ProjectRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
type ArchiveProjectInput struct {
|
||||||
|
TenantID string
|
||||||
|
ProjectID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc ArchiveProjectUseCase) Execute(ctx context.Context, input ArchiveProjectInput) error {
|
||||||
|
if input.TenantID == "" || input.ProjectID == "" {
|
||||||
|
return common.ErrInvalidInput
|
||||||
|
}
|
||||||
|
return uc.Repo.Archive(ctx, input.TenantID, input.ProjectID)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
package projects
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"platform-projects-core/internal/domain/projects"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProjectRepository interface {
|
||||||
|
Create(ctx context.Context, project projects.Project) (projects.Project, error)
|
||||||
|
Update(ctx context.Context, project projects.Project) (projects.Project, error)
|
||||||
|
Archive(ctx context.Context, tenantID, projectID string) error
|
||||||
|
List(ctx context.Context, tenantID string) ([]projects.Project, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateProjectUseCase struct {
|
||||||
|
Repo ProjectRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateProjectInput struct {
|
||||||
|
TenantID string
|
||||||
|
Name string
|
||||||
|
Slug string
|
||||||
|
Description string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc CreateProjectUseCase) Execute(ctx context.Context, input CreateProjectInput) (projects.Project, error) {
|
||||||
|
project, err := projects.NewProject(input.TenantID, input.Name, input.Slug, input.Description)
|
||||||
|
if err != nil {
|
||||||
|
return projects.Project{}, err
|
||||||
|
}
|
||||||
|
return uc.Repo.Create(ctx, project)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
package projects
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"platform-projects-core/internal/domain/common"
|
||||||
|
"platform-projects-core/internal/domain/projects"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ListProjectsUseCase struct {
|
||||||
|
Repo ProjectRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListProjectsInput struct {
|
||||||
|
TenantID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc ListProjectsUseCase) Execute(ctx context.Context, input ListProjectsInput) ([]projects.Project, error) {
|
||||||
|
if input.TenantID == "" {
|
||||||
|
return nil, common.ErrInvalidInput
|
||||||
|
}
|
||||||
|
return uc.Repo.List(ctx, input.TenantID)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
package projects
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"platform-projects-core/internal/domain/common"
|
||||||
|
"platform-projects-core/internal/domain/projects"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UpdateProjectUseCase struct {
|
||||||
|
Repo ProjectRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateProjectInput struct {
|
||||||
|
TenantID string
|
||||||
|
ProjectID string
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Status projects.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc UpdateProjectUseCase) Execute(ctx context.Context, input UpdateProjectInput) (projects.Project, error) {
|
||||||
|
if input.TenantID == "" || input.ProjectID == "" {
|
||||||
|
return projects.Project{}, common.ErrInvalidInput
|
||||||
|
}
|
||||||
|
if !input.Status.Valid() {
|
||||||
|
return projects.Project{}, common.ErrInvalidInput
|
||||||
|
}
|
||||||
|
|
||||||
|
project := projects.Project{
|
||||||
|
ID: input.ProjectID,
|
||||||
|
TenantID: input.TenantID,
|
||||||
|
Name: input.Name,
|
||||||
|
Description: input.Description,
|
||||||
|
Status: input.Status,
|
||||||
|
UpdatedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
return uc.Repo.Update(ctx, project)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"platform-projects-core/internal/domain/projects"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RepositoryRepository interface {
|
||||||
|
Link(ctx context.Context, repo projects.Repository) (projects.Repository, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type LinkRepositoryUseCase struct {
|
||||||
|
Repo RepositoryRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
type LinkRepositoryInput struct {
|
||||||
|
TenantID string
|
||||||
|
ProjectID string
|
||||||
|
Provider string
|
||||||
|
URL string
|
||||||
|
DefaultBranch string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc LinkRepositoryUseCase) Execute(ctx context.Context, input LinkRepositoryInput) (projects.Repository, error) {
|
||||||
|
repo, err := projects.NewRepository(input.TenantID, input.ProjectID, input.Provider, input.URL, input.DefaultBranch)
|
||||||
|
if err != nil {
|
||||||
|
return projects.Repository{}, err
|
||||||
|
}
|
||||||
|
return uc.Repo.Link(ctx, repo)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
package teams
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"platform-projects-core/internal/domain/projects"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TeamRepository interface {
|
||||||
|
Upsert(ctx context.Context, team projects.Team) (projects.Team, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ManageTeamUseCase struct {
|
||||||
|
Repo TeamRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
type ManageTeamInput struct {
|
||||||
|
TenantID string
|
||||||
|
ProjectID string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc ManageTeamUseCase) Execute(ctx context.Context, input ManageTeamInput) (projects.Team, error) {
|
||||||
|
team, err := projects.NewTeam(input.TenantID, input.ProjectID, input.Name)
|
||||||
|
if err != nil {
|
||||||
|
return projects.Team{}, err
|
||||||
|
}
|
||||||
|
return uc.Repo.Upsert(ctx, team)
|
||||||
|
}
|
||||||
101
platform-projects-core/internal/config/config.go
Normal file
101
platform-projects-core/internal/config/config.go
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
ServiceName string
|
||||||
|
Environment string
|
||||||
|
HTTP HTTPConfig
|
||||||
|
Database DatabaseConfig
|
||||||
|
Auth AuthConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
type HTTPConfig struct {
|
||||||
|
Address string
|
||||||
|
}
|
||||||
|
|
||||||
|
type DatabaseConfig struct {
|
||||||
|
DSN string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthConfig struct {
|
||||||
|
JWKSURL string
|
||||||
|
Audience string
|
||||||
|
Issuer string
|
||||||
|
FallbackSecret string
|
||||||
|
ClockSkew int
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() (Config, error) {
|
||||||
|
cfg := Config{
|
||||||
|
ServiceName: getenvDefault("SERVICE_NAME", "platform-projects-core"),
|
||||||
|
Environment: getenvDefault("ENVIRONMENT", "development"),
|
||||||
|
HTTP: HTTPConfig{
|
||||||
|
Address: getenvDefault("HTTP_ADDRESS", ":8080"),
|
||||||
|
},
|
||||||
|
Database: DatabaseConfig{
|
||||||
|
DSN: os.Getenv("DATABASE_DSN"),
|
||||||
|
},
|
||||||
|
Auth: AuthConfig{
|
||||||
|
JWKSURL: os.Getenv("AUTH_JWKS_URL"),
|
||||||
|
Audience: os.Getenv("AUTH_AUDIENCE"),
|
||||||
|
Issuer: os.Getenv("AUTH_ISSUER"),
|
||||||
|
FallbackSecret: os.Getenv("AUTH_FALLBACK_SECRET"),
|
||||||
|
ClockSkew: parseIntDefault("AUTH_CLOCK_SKEW_SECONDS", 60),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Database.DSN == "" {
|
||||||
|
return Config{}, errors.New("DATABASE_DSN is required")
|
||||||
|
}
|
||||||
|
if cfg.Auth.JWKSURL == "" && cfg.Auth.FallbackSecret == "" {
|
||||||
|
return Config{}, errors.New("AUTH_JWKS_URL or AUTH_FALLBACK_SECRET must be provided")
|
||||||
|
}
|
||||||
|
if cfg.Auth.Audience == "" {
|
||||||
|
return Config{}, errors.New("AUTH_AUDIENCE is required")
|
||||||
|
}
|
||||||
|
if cfg.Auth.Issuer == "" {
|
||||||
|
return Config{}, errors.New("AUTH_ISSUER is required")
|
||||||
|
}
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getenvDefault(key, fallback string) string {
|
||||||
|
value := strings.TrimSpace(os.Getenv(key))
|
||||||
|
if value == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseIntDefault(key string, fallback int) int {
|
||||||
|
value := strings.TrimSpace(os.Getenv(key))
|
||||||
|
if value == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
parsed, err := strconv.Atoi(value)
|
||||||
|
if err != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) Validate() error {
|
||||||
|
if c.ServiceName == "" {
|
||||||
|
return fmt.Errorf("service name must not be empty")
|
||||||
|
}
|
||||||
|
if c.HTTP.Address == "" {
|
||||||
|
return fmt.Errorf("http address must not be empty")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
DROP TABLE IF EXISTS project_security_links;
|
||||||
|
DROP TABLE IF EXISTS project_billing_links;
|
||||||
|
DROP TABLE IF EXISTS project_infra_links;
|
||||||
|
DROP TABLE IF EXISTS teams;
|
||||||
|
DROP TABLE IF EXISTS repositories;
|
||||||
|
DROP TABLE IF EXISTS environments;
|
||||||
|
DROP TABLE IF EXISTS projects;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL,
|
||||||
|
UNIQUE (tenant_id, slug)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS environments (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
project_id UUID NOT NULL REFERENCES projects(id),
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
paused BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS repositories (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
project_id UUID NOT NULL REFERENCES projects(id),
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
default_branch TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS teams (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
project_id UUID NOT NULL REFERENCES projects(id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
UNIQUE (tenant_id, project_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS project_infra_links (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
project_id UUID NOT NULL REFERENCES projects(id),
|
||||||
|
external_id TEXT NOT NULL,
|
||||||
|
meta JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS project_billing_links (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
project_id UUID NOT NULL REFERENCES projects(id),
|
||||||
|
external_id TEXT NOT NULL,
|
||||||
|
meta JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS project_security_links (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
project_id UUID NOT NULL REFERENCES projects(id),
|
||||||
|
external_id TEXT NOT NULL,
|
||||||
|
meta JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS projects_tenant_idx ON projects (tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS environments_tenant_idx ON environments (tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS repositories_tenant_idx ON repositories (tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS teams_tenant_idx ON teams (tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS project_infra_links_tenant_idx ON project_infra_links (tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS project_billing_links_tenant_idx ON project_billing_links (tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS project_security_links_tenant_idx ON project_security_links (tenant_id);
|
||||||
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
-- name: CreateEnvironment :exec
|
||||||
|
INSERT INTO environments (id, tenant_id, project_id, type, paused, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7);
|
||||||
11
platform-projects-core/internal/db/queries/project_links.sql
Normal file
11
platform-projects-core/internal/db/queries/project_links.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
-- name: LinkInfra :exec
|
||||||
|
INSERT INTO project_infra_links (id, tenant_id, project_id, external_id, meta)
|
||||||
|
VALUES ($1, $2, $3, $4, $5);
|
||||||
|
|
||||||
|
-- name: LinkBilling :exec
|
||||||
|
INSERT INTO project_billing_links (id, tenant_id, project_id, external_id, meta)
|
||||||
|
VALUES ($1, $2, $3, $4, $5);
|
||||||
|
|
||||||
|
-- name: LinkSecurity :exec
|
||||||
|
INSERT INTO project_security_links (id, tenant_id, project_id, external_id, meta)
|
||||||
|
VALUES ($1, $2, $3, $4, $5);
|
||||||
22
platform-projects-core/internal/db/queries/projects.sql
Normal file
22
platform-projects-core/internal/db/queries/projects.sql
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
-- name: CreateProject :exec
|
||||||
|
INSERT INTO projects (id, tenant_id, name, slug, description, status, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8);
|
||||||
|
|
||||||
|
-- name: UpdateProject :exec
|
||||||
|
UPDATE projects
|
||||||
|
SET name = $1,
|
||||||
|
description = $2,
|
||||||
|
status = $3,
|
||||||
|
updated_at = $4
|
||||||
|
WHERE tenant_id = $5 AND id = $6;
|
||||||
|
|
||||||
|
-- name: ArchiveProject :exec
|
||||||
|
UPDATE projects
|
||||||
|
SET status = 'archived', updated_at = $1
|
||||||
|
WHERE tenant_id = $2 AND id = $3;
|
||||||
|
|
||||||
|
-- name: ListProjects :many
|
||||||
|
SELECT id, tenant_id, name, slug, description, status, created_at, updated_at
|
||||||
|
FROM projects
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
ORDER BY created_at DESC;
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
-- name: LinkRepository :exec
|
||||||
|
INSERT INTO repositories (id, tenant_id, project_id, provider, url, default_branch)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6);
|
||||||
4
platform-projects-core/internal/db/queries/teams.sql
Normal file
4
platform-projects-core/internal/db/queries/teams.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
-- name: UpsertTeam :exec
|
||||||
|
INSERT INTO teams (id, tenant_id, project_id, name)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (tenant_id, project_id, name) DO UPDATE SET name = excluded.name;
|
||||||
10
platform-projects-core/internal/db/sqlc/sqlc.yaml
Normal file
10
platform-projects-core/internal/db/sqlc/sqlc.yaml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
version: "2"
|
||||||
|
sql:
|
||||||
|
- schema: "../migrations/0001_init.up.sql"
|
||||||
|
queries: "../queries"
|
||||||
|
engine: "postgresql"
|
||||||
|
gen:
|
||||||
|
go:
|
||||||
|
package: "generated"
|
||||||
|
out: "../generated"
|
||||||
|
sql_package: "pgx/v5"
|
||||||
12
platform-projects-core/internal/domain/common/errors.go
Normal file
12
platform-projects-core/internal/domain/common/errors.go
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
package common
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrUnauthorized = errors.New("unauthorized")
|
||||||
|
ErrForbidden = errors.New("forbidden")
|
||||||
|
ErrNotFound = errors.New("not found")
|
||||||
|
ErrConflict = errors.New("conflict")
|
||||||
|
ErrInvalidInput = errors.New("invalid input")
|
||||||
|
ErrPreconditionFail = errors.New("precondition failed")
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
package common
|
||||||
|
|
||||||
|
import "github.com/google/uuid"
|
||||||
|
|
||||||
|
func NewID() string {
|
||||||
|
return uuid.NewString()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package project_links
|
||||||
|
|
||||||
|
type BillingLink struct {
|
||||||
|
ID string
|
||||||
|
TenantID string
|
||||||
|
ProjectID string
|
||||||
|
External string
|
||||||
|
Meta map[string]string
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package project_links
|
||||||
|
|
||||||
|
type InfraLink struct {
|
||||||
|
ID string
|
||||||
|
TenantID string
|
||||||
|
ProjectID string
|
||||||
|
External string
|
||||||
|
Meta map[string]string
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package project_links
|
||||||
|
|
||||||
|
type SecurityLink struct {
|
||||||
|
ID string
|
||||||
|
TenantID string
|
||||||
|
ProjectID string
|
||||||
|
External string
|
||||||
|
Meta map[string]string
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
package projects
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"platform-projects-core/internal/domain/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Environment struct {
|
||||||
|
ID string
|
||||||
|
TenantID string
|
||||||
|
ProjectID string
|
||||||
|
Type string
|
||||||
|
Paused bool
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEnvironment(tenantID, projectID, envType string) (Environment, error) {
|
||||||
|
if tenantID == "" || projectID == "" || strings.TrimSpace(envType) == "" {
|
||||||
|
return Environment{}, common.ErrInvalidInput
|
||||||
|
}
|
||||||
|
return Environment{
|
||||||
|
ID: common.NewID(),
|
||||||
|
TenantID: tenantID,
|
||||||
|
ProjectID: projectID,
|
||||||
|
Type: strings.TrimSpace(envType),
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
UpdatedAt: time.Now().UTC(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
41
platform-projects-core/internal/domain/projects/project.go
Normal file
41
platform-projects-core/internal/domain/projects/project.go
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
package projects
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"platform-projects-core/internal/domain/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Project struct {
|
||||||
|
ID string
|
||||||
|
TenantID string
|
||||||
|
Name string
|
||||||
|
Slug string
|
||||||
|
Description string
|
||||||
|
Status Status
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProject(tenantID, name, slug, description string) (Project, error) {
|
||||||
|
slug = strings.TrimSpace(slug)
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if tenantID == "" || name == "" || slug == "" {
|
||||||
|
return Project{}, common.ErrInvalidInput
|
||||||
|
}
|
||||||
|
return Project{
|
||||||
|
ID: common.NewID(),
|
||||||
|
TenantID: tenantID,
|
||||||
|
Name: name,
|
||||||
|
Slug: slug,
|
||||||
|
Description: strings.TrimSpace(description),
|
||||||
|
Status: StatusActive,
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
UpdatedAt: time.Now().UTC(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Project) CanLink() bool {
|
||||||
|
return p.Status != StatusArchived
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
package projects
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"platform-projects-core/internal/domain/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Repository struct {
|
||||||
|
ID string
|
||||||
|
TenantID string
|
||||||
|
ProjectID string
|
||||||
|
Provider string
|
||||||
|
URL string
|
||||||
|
DefaultBranch string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRepository(tenantID, projectID, provider, repoURL, defaultBranch string) (Repository, error) {
|
||||||
|
if tenantID == "" || projectID == "" || strings.TrimSpace(provider) == "" {
|
||||||
|
return Repository{}, common.ErrInvalidInput
|
||||||
|
}
|
||||||
|
parsed, err := url.ParseRequestURI(repoURL)
|
||||||
|
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||||
|
return Repository{}, common.ErrInvalidInput
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(defaultBranch) == "" {
|
||||||
|
return Repository{}, common.ErrInvalidInput
|
||||||
|
}
|
||||||
|
return Repository{
|
||||||
|
ID: common.NewID(),
|
||||||
|
TenantID: tenantID,
|
||||||
|
ProjectID: projectID,
|
||||||
|
Provider: strings.TrimSpace(provider),
|
||||||
|
URL: repoURL,
|
||||||
|
DefaultBranch: strings.TrimSpace(defaultBranch),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
18
platform-projects-core/internal/domain/projects/status.go
Normal file
18
platform-projects-core/internal/domain/projects/status.go
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
package projects
|
||||||
|
|
||||||
|
type Status string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusActive Status = "active"
|
||||||
|
StatusPaused Status = "paused"
|
||||||
|
StatusArchived Status = "archived"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s Status) Valid() bool {
|
||||||
|
switch s {
|
||||||
|
case StatusActive, StatusPaused, StatusArchived:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
26
platform-projects-core/internal/domain/projects/team.go
Normal file
26
platform-projects-core/internal/domain/projects/team.go
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
package projects
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"platform-projects-core/internal/domain/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Team struct {
|
||||||
|
ID string
|
||||||
|
TenantID string
|
||||||
|
ProjectID string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTeam(tenantID, projectID, name string) (Team, error) {
|
||||||
|
if tenantID == "" || projectID == "" || strings.TrimSpace(name) == "" {
|
||||||
|
return Team{}, common.ErrInvalidInput
|
||||||
|
}
|
||||||
|
return Team{
|
||||||
|
ID: common.NewID(),
|
||||||
|
TenantID: tenantID,
|
||||||
|
ProjectID: projectID,
|
||||||
|
Name: strings.TrimSpace(name),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
type Claims struct {
|
||||||
|
Subject string `json:"sub"`
|
||||||
|
TenantID string `json:"tenantId"`
|
||||||
|
Roles []string `json:"roles"`
|
||||||
|
Issuer string `json:"iss"`
|
||||||
|
Audience []string `json:"aud"`
|
||||||
|
Expires int64 `json:"exp"`
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/MicahParks/keyfunc/v2"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"platform-projects-core/internal/api/transport"
|
||||||
|
"platform-projects-core/internal/config"
|
||||||
|
"platform-projects-core/internal/domain/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Validator struct {
|
||||||
|
jwks *keyfunc.JWKS
|
||||||
|
audience string
|
||||||
|
issuer string
|
||||||
|
fallbackKey []byte
|
||||||
|
clockSkew time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewValidator(ctx context.Context, cfg config.AuthConfig) (*Validator, error) {
|
||||||
|
validator := &Validator{
|
||||||
|
audience: cfg.Audience,
|
||||||
|
issuer: cfg.Issuer,
|
||||||
|
clockSkew: time.Duration(cfg.ClockSkew) * time.Second,
|
||||||
|
}
|
||||||
|
if cfg.FallbackSecret != "" {
|
||||||
|
validator.fallbackKey = []byte(cfg.FallbackSecret)
|
||||||
|
}
|
||||||
|
if cfg.JWKSURL == "" {
|
||||||
|
return validator, nil
|
||||||
|
}
|
||||||
|
jwks, err := keyfunc.Get(cfg.JWKSURL, keyfunc.Options{RefreshTimeout: 5 * time.Second})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("jwks fetch: %w", err)
|
||||||
|
}
|
||||||
|
validator.jwks = jwks
|
||||||
|
return validator, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Middleware(validator *Validator) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims, err := validator.Validate(r.Context(), r.Header.Get("Authorization"))
|
||||||
|
if err != nil {
|
||||||
|
transport.WriteError(w, http.StatusUnauthorized, "unauthorized", "invalid token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := transport.WithTenant(r.Context(), claims.TenantID)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Validator) Validate(ctx context.Context, authHeader string) (Claims, error) {
|
||||||
|
parts := strings.Fields(authHeader)
|
||||||
|
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
|
||||||
|
return Claims{}, common.ErrUnauthorized
|
||||||
|
}
|
||||||
|
jwtToken := parts[1]
|
||||||
|
|
||||||
|
parser := jwt.NewParser(jwt.WithAudience(v.audience), jwt.WithIssuer(v.issuer), jwt.WithLeeway(v.clockSkew))
|
||||||
|
parsed, err := parser.Parse(jwtToken, v.keyFunc)
|
||||||
|
if err != nil || !parsed.Valid {
|
||||||
|
return Claims{}, common.ErrUnauthorized
|
||||||
|
}
|
||||||
|
mapClaims, ok := parsed.Claims.(jwt.MapClaims)
|
||||||
|
if !ok {
|
||||||
|
return Claims{}, common.ErrUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := Claims{
|
||||||
|
Subject: getString(mapClaims, "sub"),
|
||||||
|
TenantID: getString(mapClaims, "tenantId"),
|
||||||
|
Roles: getStringSlice(mapClaims, "roles"),
|
||||||
|
Issuer: getString(mapClaims, "iss"),
|
||||||
|
Audience: getStringSlice(mapClaims, "aud"),
|
||||||
|
}
|
||||||
|
if claims.Subject == "" || claims.TenantID == "" || len(claims.Roles) == 0 {
|
||||||
|
return Claims{}, common.ErrUnauthorized
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Validator) keyFunc(token *jwt.Token) (any, error) {
|
||||||
|
if v.jwks != nil {
|
||||||
|
return v.jwks.Keyfunc(token)
|
||||||
|
}
|
||||||
|
if len(v.fallbackKey) > 0 {
|
||||||
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, errors.New("unexpected signing method")
|
||||||
|
}
|
||||||
|
return v.fallbackKey, nil
|
||||||
|
}
|
||||||
|
return nil, errors.New("no jwks or fallback secret configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
func getString(claims jwt.MapClaims, key string) string {
|
||||||
|
if value, ok := claims[key].(string); ok {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func getStringSlice(claims jwt.MapClaims, key string) []string {
|
||||||
|
value, ok := claims[key]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if slice, ok := value.([]any); ok {
|
||||||
|
out := make([]string, 0, len(slice))
|
||||||
|
for _, item := range slice {
|
||||||
|
if str, ok := item.(string); ok {
|
||||||
|
out = append(out, str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if str, ok := value.(string); ok {
|
||||||
|
return []string{str}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"platform-projects-core/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewPool(ctx context.Context, cfg config.DatabaseConfig) (*pgxpool.Pool, error) {
|
||||||
|
poolCfg, err := pgxpool.ParseConfig(cfg.DSN)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse dsn: %w", err)
|
||||||
|
}
|
||||||
|
poolCfg.MaxConnLifetime = time.Hour
|
||||||
|
poolCfg.MaxConnIdleTime = 30 * time.Minute
|
||||||
|
pool, err := pgxpool.NewWithConfig(ctx, poolCfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("connect: %w", err)
|
||||||
|
}
|
||||||
|
if err := pool.Ping(ctx); err != nil {
|
||||||
|
pool.Close()
|
||||||
|
return nil, fmt.Errorf("ping: %w", err)
|
||||||
|
}
|
||||||
|
return pool, nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"platform-projects-core/internal/domain/projects"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EnvironmentsRepository struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEnvironmentsRepository(pool *pgxpool.Pool) *EnvironmentsRepository {
|
||||||
|
return &EnvironmentsRepository{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *EnvironmentsRepository) Create(ctx context.Context, env projects.Environment) (projects.Environment, error) {
|
||||||
|
query := `INSERT INTO environments (id, tenant_id, project_id, type, paused, created_at, updated_at)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7)`
|
||||||
|
_, err := r.pool.Exec(ctx, query, env.ID, env.TenantID, env.ProjectID, env.Type, env.Paused, env.CreatedAt, env.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return projects.Environment{}, err
|
||||||
|
}
|
||||||
|
return env, nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"platform-projects-core/internal/domain/project_links"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProjectLinksRepository struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProjectLinksRepository(pool *pgxpool.Pool) *ProjectLinksRepository {
|
||||||
|
return &ProjectLinksRepository{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ProjectLinksRepository) LinkInfra(ctx context.Context, link project_links.InfraLink) (project_links.InfraLink, error) {
|
||||||
|
meta, _ := json.Marshal(link.Meta)
|
||||||
|
query := `INSERT INTO project_infra_links (id, tenant_id, project_id, external_id, meta)
|
||||||
|
VALUES ($1,$2,$3,$4,$5)`
|
||||||
|
_, err := r.pool.Exec(ctx, query, link.ID, link.TenantID, link.ProjectID, link.External, meta)
|
||||||
|
if err != nil {
|
||||||
|
return project_links.InfraLink{}, err
|
||||||
|
}
|
||||||
|
return link, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ProjectLinksRepository) LinkBilling(ctx context.Context, link project_links.BillingLink) (project_links.BillingLink, error) {
|
||||||
|
meta, _ := json.Marshal(link.Meta)
|
||||||
|
query := `INSERT INTO project_billing_links (id, tenant_id, project_id, external_id, meta)
|
||||||
|
VALUES ($1,$2,$3,$4,$5)`
|
||||||
|
_, err := r.pool.Exec(ctx, query, link.ID, link.TenantID, link.ProjectID, link.External, meta)
|
||||||
|
if err != nil {
|
||||||
|
return project_links.BillingLink{}, err
|
||||||
|
}
|
||||||
|
return link, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ProjectLinksRepository) LinkSecurity(ctx context.Context, link project_links.SecurityLink) (project_links.SecurityLink, error) {
|
||||||
|
meta, _ := json.Marshal(link.Meta)
|
||||||
|
query := `INSERT INTO project_security_links (id, tenant_id, project_id, external_id, meta)
|
||||||
|
VALUES ($1,$2,$3,$4,$5)`
|
||||||
|
_, err := r.pool.Exec(ctx, query, link.ID, link.TenantID, link.ProjectID, link.External, meta)
|
||||||
|
if err != nil {
|
||||||
|
return project_links.SecurityLink{}, err
|
||||||
|
}
|
||||||
|
return link, nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"platform-projects-core/internal/domain/common"
|
||||||
|
"platform-projects-core/internal/domain/projects"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProjectsRepository struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProjectsRepository(pool *pgxpool.Pool) *ProjectsRepository {
|
||||||
|
return &ProjectsRepository{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ProjectsRepository) Create(ctx context.Context, project projects.Project) (projects.Project, error) {
|
||||||
|
query := `INSERT INTO projects (id, tenant_id, name, slug, description, status, created_at, updated_at)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`
|
||||||
|
_, err := r.pool.Exec(ctx, query, project.ID, project.TenantID, project.Name, project.Slug, project.Description, project.Status, project.CreatedAt, project.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return projects.Project{}, err
|
||||||
|
}
|
||||||
|
return project, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ProjectsRepository) Update(ctx context.Context, project projects.Project) (projects.Project, error) {
|
||||||
|
query := `UPDATE projects SET name=$1, description=$2, status=$3, updated_at=$4 WHERE tenant_id=$5 AND id=$6`
|
||||||
|
cmd, err := r.pool.Exec(ctx, query, project.Name, project.Description, project.Status, time.Now().UTC(), project.TenantID, project.ID)
|
||||||
|
if err != nil {
|
||||||
|
return projects.Project{}, err
|
||||||
|
}
|
||||||
|
if cmd.RowsAffected() == 0 {
|
||||||
|
return projects.Project{}, common.ErrNotFound
|
||||||
|
}
|
||||||
|
return project, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ProjectsRepository) Archive(ctx context.Context, tenantID, projectID string) error {
|
||||||
|
query := `UPDATE projects SET status='archived', updated_at=$1 WHERE tenant_id=$2 AND id=$3`
|
||||||
|
cmd, err := r.pool.Exec(ctx, query, time.Now().UTC(), tenantID, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cmd.RowsAffected() == 0 {
|
||||||
|
return common.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ProjectsRepository) List(ctx context.Context, tenantID string) ([]projects.Project, error) {
|
||||||
|
query := `SELECT id, tenant_id, name, slug, description, status, created_at, updated_at FROM projects WHERE tenant_id=$1 ORDER BY created_at DESC`
|
||||||
|
rows, err := r.pool.Query(ctx, query, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var results []projects.Project
|
||||||
|
for rows.Next() {
|
||||||
|
var p projects.Project
|
||||||
|
err := rows.Scan(&p.ID, &p.TenantID, &p.Name, &p.Slug, &p.Description, &p.Status, &p.CreatedAt, &p.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
results = append(results, p)
|
||||||
|
}
|
||||||
|
return results, rows.Err()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"platform-projects-core/internal/domain/projects"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RepositoriesRepository struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRepositoriesRepository(pool *pgxpool.Pool) *RepositoriesRepository {
|
||||||
|
return &RepositoriesRepository{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RepositoriesRepository) Link(ctx context.Context, repo projects.Repository) (projects.Repository, error) {
|
||||||
|
query := `INSERT INTO repositories (id, tenant_id, project_id, provider, url, default_branch)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6)`
|
||||||
|
_, err := r.pool.Exec(ctx, query, repo.ID, repo.TenantID, repo.ProjectID, repo.Provider, repo.URL, repo.DefaultBranch)
|
||||||
|
if err != nil {
|
||||||
|
return projects.Repository{}, err
|
||||||
|
}
|
||||||
|
return repo, nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"platform-projects-core/internal/domain/projects"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TeamsRepository struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTeamsRepository(pool *pgxpool.Pool) *TeamsRepository {
|
||||||
|
return &TeamsRepository{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TeamsRepository) Upsert(ctx context.Context, team projects.Team) (projects.Team, error) {
|
||||||
|
query := `INSERT INTO teams (id, tenant_id, project_id, name)
|
||||||
|
VALUES ($1,$2,$3,$4)
|
||||||
|
ON CONFLICT (tenant_id, project_id, name) DO UPDATE SET name=excluded.name`
|
||||||
|
_, err := r.pool.Exec(ctx, query, team.ID, team.TenantID, team.ProjectID, team.Name)
|
||||||
|
if err != nil {
|
||||||
|
return projects.Team{}, err
|
||||||
|
}
|
||||||
|
return team, nil
|
||||||
|
}
|
||||||
17
platform-projects-core/internal/observability/logging.go
Normal file
17
platform-projects-core/internal/observability/logging.go
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
package observability
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewLogger(environment string) *slog.Logger {
|
||||||
|
level := new(slog.LevelVar)
|
||||||
|
if environment == "production" {
|
||||||
|
level.Set(slog.LevelInfo)
|
||||||
|
} else {
|
||||||
|
level.Set(slog.LevelDebug)
|
||||||
|
}
|
||||||
|
|
||||||
|
return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
|
||||||
|
}
|
||||||
9
platform-projects-core/internal/observability/metrics.go
Normal file
9
platform-projects-core/internal/observability/metrics.go
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
package observability
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
func MetricsHandler() http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotImplemented)
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue