Add platform-projects-core backend

This commit is contained in:
Tiago Yamamoto 2025-12-27 15:49:10 -03:00
parent 3649385924
commit 8ca16f064b
59 changed files with 2065 additions and 0 deletions

View 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

View 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"]

View 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

View 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).

View 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")
}

View 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:

View 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.

View 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

View 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.

View 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
)

View 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=

View file

@ -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})
}

View file

@ -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})
}

View 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")
}
}

View file

@ -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})
}

View file

@ -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})
}

View 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)
})
}
}

View 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
}

View 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
}

View 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
}

View file

@ -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)
}

View file

@ -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,
})
}

View file

@ -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,
})
}

View file

@ -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,
})
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
}

View 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
}

View file

@ -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;

View file

@ -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);

View file

@ -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);

View 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);

View 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;

View file

@ -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);

View 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;

View 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"

View 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")
)

View file

@ -0,0 +1,7 @@
package common
import "github.com/google/uuid"
func NewID() string {
return uuid.NewString()
}

View file

@ -0,0 +1,9 @@
package project_links
type BillingLink struct {
ID string
TenantID string
ProjectID string
External string
Meta map[string]string
}

View file

@ -0,0 +1,9 @@
package project_links
type InfraLink struct {
ID string
TenantID string
ProjectID string
External string
Meta map[string]string
}

View file

@ -0,0 +1,9 @@
package project_links
type SecurityLink struct {
ID string
TenantID string
ProjectID string
External string
Meta map[string]string
}

View file

@ -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
}

View 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
}

View file

@ -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
}

View 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
}
}

View 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
}

View file

@ -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"`
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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()
}

View file

@ -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
}

View file

@ -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
}

View 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}))
}

View 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)
})
}