diff --git a/platform-projects-core/.env.example b/platform-projects-core/.env.example new file mode 100644 index 0000000..63d7f98 --- /dev/null +++ b/platform-projects-core/.env.example @@ -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 + diff --git a/platform-projects-core/Dockerfile b/platform-projects-core/Dockerfile new file mode 100644 index 0000000..a09c84f --- /dev/null +++ b/platform-projects-core/Dockerfile @@ -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"] + diff --git a/platform-projects-core/Makefile b/platform-projects-core/Makefile new file mode 100644 index 0000000..6a85188 --- /dev/null +++ b/platform-projects-core/Makefile @@ -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 + diff --git a/platform-projects-core/README.md b/platform-projects-core/README.md new file mode 100644 index 0000000..a4475a8 --- /dev/null +++ b/platform-projects-core/README.md @@ -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 " \ + -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 " \ + -H "Content-Type: application/json" \ + -d '{"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). + diff --git a/platform-projects-core/cmd/api/main.go b/platform-projects-core/cmd/api/main.go new file mode 100644 index 0000000..ab48baa --- /dev/null +++ b/platform-projects-core/cmd/api/main.go @@ -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") +} diff --git a/platform-projects-core/docker-compose.yml b/platform-projects-core/docker-compose.yml new file mode 100644 index 0000000..25004a1 --- /dev/null +++ b/platform-projects-core/docker-compose.yml @@ -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: + diff --git a/platform-projects-core/docs/architecture.md b/platform-projects-core/docs/architecture.md new file mode 100644 index 0000000..ad7f07b --- /dev/null +++ b/platform-projects-core/docs/architecture.md @@ -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. + diff --git a/platform-projects-core/docs/domain-model.md b/platform-projects-core/docs/domain-model.md new file mode 100644 index 0000000..3f76605 --- /dev/null +++ b/platform-projects-core/docs/domain-model.md @@ -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 + diff --git a/platform-projects-core/docs/security-model.md b/platform-projects-core/docs/security-model.md new file mode 100644 index 0000000..6faf399 --- /dev/null +++ b/platform-projects-core/docs/security-model.md @@ -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. + diff --git a/platform-projects-core/go.mod b/platform-projects-core/go.mod new file mode 100644 index 0000000..d5c4e6c --- /dev/null +++ b/platform-projects-core/go.mod @@ -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 +) diff --git a/platform-projects-core/go.sum b/platform-projects-core/go.sum new file mode 100644 index 0000000..c98020c --- /dev/null +++ b/platform-projects-core/go.sum @@ -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= diff --git a/platform-projects-core/internal/api/handlers/environments_handler.go b/platform-projects-core/internal/api/handlers/environments_handler.go new file mode 100644 index 0000000..2e690e1 --- /dev/null +++ b/platform-projects-core/internal/api/handlers/environments_handler.go @@ -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}) +} diff --git a/platform-projects-core/internal/api/handlers/project_links_handler.go b/platform-projects-core/internal/api/handlers/project_links_handler.go new file mode 100644 index 0000000..81ccb39 --- /dev/null +++ b/platform-projects-core/internal/api/handlers/project_links_handler.go @@ -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}) +} diff --git a/platform-projects-core/internal/api/handlers/projects_handler.go b/platform-projects-core/internal/api/handlers/projects_handler.go new file mode 100644 index 0000000..b3188f0 --- /dev/null +++ b/platform-projects-core/internal/api/handlers/projects_handler.go @@ -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") + } +} diff --git a/platform-projects-core/internal/api/handlers/repositories_handler.go b/platform-projects-core/internal/api/handlers/repositories_handler.go new file mode 100644 index 0000000..5e4d9be --- /dev/null +++ b/platform-projects-core/internal/api/handlers/repositories_handler.go @@ -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}) +} diff --git a/platform-projects-core/internal/api/handlers/teams_handler.go b/platform-projects-core/internal/api/handlers/teams_handler.go new file mode 100644 index 0000000..dd5b845 --- /dev/null +++ b/platform-projects-core/internal/api/handlers/teams_handler.go @@ -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}) +} diff --git a/platform-projects-core/internal/api/http/middleware.go b/platform-projects-core/internal/api/http/middleware.go new file mode 100644 index 0000000..0920f80 --- /dev/null +++ b/platform-projects-core/internal/api/http/middleware.go @@ -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) + }) + } +} diff --git a/platform-projects-core/internal/api/http/router.go b/platform-projects-core/internal/api/http/router.go new file mode 100644 index 0000000..19094b4 --- /dev/null +++ b/platform-projects-core/internal/api/http/router.go @@ -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 +} diff --git a/platform-projects-core/internal/api/transport/context.go b/platform-projects-core/internal/api/transport/context.go new file mode 100644 index 0000000..0f00c69 --- /dev/null +++ b/platform-projects-core/internal/api/transport/context.go @@ -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 +} diff --git a/platform-projects-core/internal/api/transport/response.go b/platform-projects-core/internal/api/transport/response.go new file mode 100644 index 0000000..6504b08 --- /dev/null +++ b/platform-projects-core/internal/api/transport/response.go @@ -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 +} diff --git a/platform-projects-core/internal/application/environments/create_environment.go b/platform-projects-core/internal/application/environments/create_environment.go new file mode 100644 index 0000000..bd8566e --- /dev/null +++ b/platform-projects-core/internal/application/environments/create_environment.go @@ -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) +} diff --git a/platform-projects-core/internal/application/project_links/link_billing.go b/platform-projects-core/internal/application/project_links/link_billing.go new file mode 100644 index 0000000..aca1c7c --- /dev/null +++ b/platform-projects-core/internal/application/project_links/link_billing.go @@ -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, + }) +} diff --git a/platform-projects-core/internal/application/project_links/link_infra.go b/platform-projects-core/internal/application/project_links/link_infra.go new file mode 100644 index 0000000..139149a --- /dev/null +++ b/platform-projects-core/internal/application/project_links/link_infra.go @@ -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, + }) +} diff --git a/platform-projects-core/internal/application/project_links/link_security.go b/platform-projects-core/internal/application/project_links/link_security.go new file mode 100644 index 0000000..57235b0 --- /dev/null +++ b/platform-projects-core/internal/application/project_links/link_security.go @@ -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, + }) +} diff --git a/platform-projects-core/internal/application/projects/archive_project.go b/platform-projects-core/internal/application/projects/archive_project.go new file mode 100644 index 0000000..65d4653 --- /dev/null +++ b/platform-projects-core/internal/application/projects/archive_project.go @@ -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) +} diff --git a/platform-projects-core/internal/application/projects/create_project.go b/platform-projects-core/internal/application/projects/create_project.go new file mode 100644 index 0000000..d218327 --- /dev/null +++ b/platform-projects-core/internal/application/projects/create_project.go @@ -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) +} diff --git a/platform-projects-core/internal/application/projects/list_projects.go b/platform-projects-core/internal/application/projects/list_projects.go new file mode 100644 index 0000000..d37c9f6 --- /dev/null +++ b/platform-projects-core/internal/application/projects/list_projects.go @@ -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) +} diff --git a/platform-projects-core/internal/application/projects/update_project.go b/platform-projects-core/internal/application/projects/update_project.go new file mode 100644 index 0000000..5aa2a37 --- /dev/null +++ b/platform-projects-core/internal/application/projects/update_project.go @@ -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) +} diff --git a/platform-projects-core/internal/application/repositories/link_repository.go b/platform-projects-core/internal/application/repositories/link_repository.go new file mode 100644 index 0000000..2005533 --- /dev/null +++ b/platform-projects-core/internal/application/repositories/link_repository.go @@ -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) +} diff --git a/platform-projects-core/internal/application/teams/manage_team.go b/platform-projects-core/internal/application/teams/manage_team.go new file mode 100644 index 0000000..7a00bfa --- /dev/null +++ b/platform-projects-core/internal/application/teams/manage_team.go @@ -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) +} diff --git a/platform-projects-core/internal/config/config.go b/platform-projects-core/internal/config/config.go new file mode 100644 index 0000000..b05c0eb --- /dev/null +++ b/platform-projects-core/internal/config/config.go @@ -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 +} diff --git a/platform-projects-core/internal/db/migrations/0001_init.down.sql b/platform-projects-core/internal/db/migrations/0001_init.down.sql new file mode 100644 index 0000000..8f53f16 --- /dev/null +++ b/platform-projects-core/internal/db/migrations/0001_init.down.sql @@ -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; + diff --git a/platform-projects-core/internal/db/migrations/0001_init.up.sql b/platform-projects-core/internal/db/migrations/0001_init.up.sql new file mode 100644 index 0000000..81ad586 --- /dev/null +++ b/platform-projects-core/internal/db/migrations/0001_init.up.sql @@ -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); + diff --git a/platform-projects-core/internal/db/queries/environments.sql b/platform-projects-core/internal/db/queries/environments.sql new file mode 100644 index 0000000..e42155f --- /dev/null +++ b/platform-projects-core/internal/db/queries/environments.sql @@ -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); diff --git a/platform-projects-core/internal/db/queries/project_links.sql b/platform-projects-core/internal/db/queries/project_links.sql new file mode 100644 index 0000000..8338641 --- /dev/null +++ b/platform-projects-core/internal/db/queries/project_links.sql @@ -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); diff --git a/platform-projects-core/internal/db/queries/projects.sql b/platform-projects-core/internal/db/queries/projects.sql new file mode 100644 index 0000000..f04ba27 --- /dev/null +++ b/platform-projects-core/internal/db/queries/projects.sql @@ -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; diff --git a/platform-projects-core/internal/db/queries/repositories.sql b/platform-projects-core/internal/db/queries/repositories.sql new file mode 100644 index 0000000..acf04cc --- /dev/null +++ b/platform-projects-core/internal/db/queries/repositories.sql @@ -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); diff --git a/platform-projects-core/internal/db/queries/teams.sql b/platform-projects-core/internal/db/queries/teams.sql new file mode 100644 index 0000000..c5edf10 --- /dev/null +++ b/platform-projects-core/internal/db/queries/teams.sql @@ -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; diff --git a/platform-projects-core/internal/db/sqlc/sqlc.yaml b/platform-projects-core/internal/db/sqlc/sqlc.yaml new file mode 100644 index 0000000..6c2af45 --- /dev/null +++ b/platform-projects-core/internal/db/sqlc/sqlc.yaml @@ -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" diff --git a/platform-projects-core/internal/domain/common/errors.go b/platform-projects-core/internal/domain/common/errors.go new file mode 100644 index 0000000..db3e056 --- /dev/null +++ b/platform-projects-core/internal/domain/common/errors.go @@ -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") +) diff --git a/platform-projects-core/internal/domain/common/identifiers.go b/platform-projects-core/internal/domain/common/identifiers.go new file mode 100644 index 0000000..7bd076a --- /dev/null +++ b/platform-projects-core/internal/domain/common/identifiers.go @@ -0,0 +1,7 @@ +package common + +import "github.com/google/uuid" + +func NewID() string { + return uuid.NewString() +} diff --git a/platform-projects-core/internal/domain/project_links/billing_link.go b/platform-projects-core/internal/domain/project_links/billing_link.go new file mode 100644 index 0000000..6e6eaf1 --- /dev/null +++ b/platform-projects-core/internal/domain/project_links/billing_link.go @@ -0,0 +1,9 @@ +package project_links + +type BillingLink struct { + ID string + TenantID string + ProjectID string + External string + Meta map[string]string +} diff --git a/platform-projects-core/internal/domain/project_links/infra_link.go b/platform-projects-core/internal/domain/project_links/infra_link.go new file mode 100644 index 0000000..7cd3960 --- /dev/null +++ b/platform-projects-core/internal/domain/project_links/infra_link.go @@ -0,0 +1,9 @@ +package project_links + +type InfraLink struct { + ID string + TenantID string + ProjectID string + External string + Meta map[string]string +} diff --git a/platform-projects-core/internal/domain/project_links/security_link.go b/platform-projects-core/internal/domain/project_links/security_link.go new file mode 100644 index 0000000..66104b5 --- /dev/null +++ b/platform-projects-core/internal/domain/project_links/security_link.go @@ -0,0 +1,9 @@ +package project_links + +type SecurityLink struct { + ID string + TenantID string + ProjectID string + External string + Meta map[string]string +} diff --git a/platform-projects-core/internal/domain/projects/environment.go b/platform-projects-core/internal/domain/projects/environment.go new file mode 100644 index 0000000..08ec5ae --- /dev/null +++ b/platform-projects-core/internal/domain/projects/environment.go @@ -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 +} diff --git a/platform-projects-core/internal/domain/projects/project.go b/platform-projects-core/internal/domain/projects/project.go new file mode 100644 index 0000000..5d0448a --- /dev/null +++ b/platform-projects-core/internal/domain/projects/project.go @@ -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 +} diff --git a/platform-projects-core/internal/domain/projects/repository.go b/platform-projects-core/internal/domain/projects/repository.go new file mode 100644 index 0000000..88c4678 --- /dev/null +++ b/platform-projects-core/internal/domain/projects/repository.go @@ -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 +} diff --git a/platform-projects-core/internal/domain/projects/status.go b/platform-projects-core/internal/domain/projects/status.go new file mode 100644 index 0000000..f00d8a3 --- /dev/null +++ b/platform-projects-core/internal/domain/projects/status.go @@ -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 + } +} diff --git a/platform-projects-core/internal/domain/projects/team.go b/platform-projects-core/internal/domain/projects/team.go new file mode 100644 index 0000000..bbf6f23 --- /dev/null +++ b/platform-projects-core/internal/domain/projects/team.go @@ -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 +} diff --git a/platform-projects-core/internal/infrastructure/auth/claims.go b/platform-projects-core/internal/infrastructure/auth/claims.go new file mode 100644 index 0000000..d908166 --- /dev/null +++ b/platform-projects-core/internal/infrastructure/auth/claims.go @@ -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"` +} diff --git a/platform-projects-core/internal/infrastructure/auth/jwt_middleware.go b/platform-projects-core/internal/infrastructure/auth/jwt_middleware.go new file mode 100644 index 0000000..41e1fdf --- /dev/null +++ b/platform-projects-core/internal/infrastructure/auth/jwt_middleware.go @@ -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 +} diff --git a/platform-projects-core/internal/infrastructure/postgres/pool.go b/platform-projects-core/internal/infrastructure/postgres/pool.go new file mode 100644 index 0000000..ab44b24 --- /dev/null +++ b/platform-projects-core/internal/infrastructure/postgres/pool.go @@ -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 +} diff --git a/platform-projects-core/internal/infrastructure/repositories/environments_repo_sqlc.go b/platform-projects-core/internal/infrastructure/repositories/environments_repo_sqlc.go new file mode 100644 index 0000000..27ee660 --- /dev/null +++ b/platform-projects-core/internal/infrastructure/repositories/environments_repo_sqlc.go @@ -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 +} diff --git a/platform-projects-core/internal/infrastructure/repositories/project_links_repo_sqlc.go b/platform-projects-core/internal/infrastructure/repositories/project_links_repo_sqlc.go new file mode 100644 index 0000000..9a7110e --- /dev/null +++ b/platform-projects-core/internal/infrastructure/repositories/project_links_repo_sqlc.go @@ -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 +} diff --git a/platform-projects-core/internal/infrastructure/repositories/projects_repo_sqlc.go b/platform-projects-core/internal/infrastructure/repositories/projects_repo_sqlc.go new file mode 100644 index 0000000..24b8c2d --- /dev/null +++ b/platform-projects-core/internal/infrastructure/repositories/projects_repo_sqlc.go @@ -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() +} diff --git a/platform-projects-core/internal/infrastructure/repositories/repositories_repo_sqlc.go b/platform-projects-core/internal/infrastructure/repositories/repositories_repo_sqlc.go new file mode 100644 index 0000000..39c9b2e --- /dev/null +++ b/platform-projects-core/internal/infrastructure/repositories/repositories_repo_sqlc.go @@ -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 +} diff --git a/platform-projects-core/internal/infrastructure/repositories/teams_repo_sqlc.go b/platform-projects-core/internal/infrastructure/repositories/teams_repo_sqlc.go new file mode 100644 index 0000000..28d9724 --- /dev/null +++ b/platform-projects-core/internal/infrastructure/repositories/teams_repo_sqlc.go @@ -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 +} diff --git a/platform-projects-core/internal/observability/logging.go b/platform-projects-core/internal/observability/logging.go new file mode 100644 index 0000000..2bed391 --- /dev/null +++ b/platform-projects-core/internal/observability/logging.go @@ -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})) +} diff --git a/platform-projects-core/internal/observability/metrics.go b/platform-projects-core/internal/observability/metrics.go new file mode 100644 index 0000000..2175d74 --- /dev/null +++ b/platform-projects-core/internal/observability/metrics.go @@ -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) + }) +}