diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..c90ed68 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,35 @@ +{ + "permissions": { + "allow": [ + "Bash(env)", + "Bash(go mod *)", + "Bash(go get *)", + "Bash(2>&1)", + "Bash(go test *)", + "Bash(go build *)", + "Bash(export DATABASE_URL=\"postgres://app:liquid-carbon-web@postgres:5432/app?sslmode=disable\")", + "Bash(export BASE_URL=\"http://localhost:4080\")", + "Bash(export SESSION_SECRET=\"dev-only-secret-32-bytes-minimum!!\")", + "Bash(go run *)", + "Bash(export HTTP_PORT=4080)", + "Bash(echo \"PID: $!\")", + "Bash(curl -s -i http://localhost:4080/healthz)", + "Bash(kill %1)", + "Bash(wait)", + "Bash(export MEILI_URL=\"http://meilisearch:7700\")", + "Bash(export MEILI_KEY=\"masterkey\")", + "Bash(curl -s http://localhost:4080/healthz)", + "Bash(curl -s -i http://meilisearch:7700/health)", + "Bash(pkill -f \"cmd/solopm serve\")", + "Bash(pkill -f \"go-build.*/exe/solopm\")", + "Bash(timeout 5 curl -sI https://cdn.jsdelivr.net/npm/@fontsource/open-sans@5/files/open-sans-latin-400-normal.woff2)", + "Bash(timeout 5 curl -sI https://github.com/FortAwesome/Font-Awesome/releases/download/6.4.0/fontawesome-free-6.4.0-web.zip)", + "Bash(pnpm create *)", + "Bash(cp -r /references/first-iteration/ai/design-system ./design-system)", + "Bash(cp -r design-system/setup/public/assets public/assets)", + "Bash(bash design-system/setup/get-fonts.sh)", + "Bash(pnpm build *)", + "Bash(go vet *)" + ] + } +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a7a3d9d --- /dev/null +++ b/.env.example @@ -0,0 +1,50 @@ +# Copy to .env and fill in. Required vars have no default and the server +# fails fast at startup if they're missing. + +# --- Required --- + +# pgx connection string, e.g. postgres://user:pass@localhost:5432/solopm +DATABASE_URL= + +# External URL the app is served at. Used in emails, OAuth callbacks, and +# webhook payloads. +BASE_URL=http://localhost:8080 + +# Session cookie signing/encryption key. Must be at least 32 bytes. +SESSION_SECRET= + +# --- Server --- + +HTTP_PORT=8080 +LOG_LEVEL=info + +# --- Attachments --- + +UPLOAD_DIR=./uploads +MAX_UPLOAD_MB=25 + +# --- Search (optional; search endpoints return 503 while unset) --- + +MEILI_URL= +MEILI_KEY= + +# --- Email (optional; disabled while SMTP_HOST is unset) --- + +SMTP_HOST= +SMTP_PORT= +SMTP_USER= +SMTP_PASS= +SMTP_FROM= + +# --- OAuth (optional; the corresponding login button is hidden while unset) --- + +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= + +GITEA_URL= +GITEA_CLIENT_ID= +GITEA_CLIENT_SECRET= + +# --- Retention --- + +CULL_AFTER_DAYS=30 diff --git a/.gitignore b/.gitignore index 8b13789..6666348 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ - +/solopm +/uploads +.env +.pnpm-store/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5a4f9ef --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +# syntax=docker/dockerfile:1 + +FROM node:22-alpine AS frontend +WORKDIR /src/web +RUN corepack enable +COPY web/package.json web/pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile +COPY web/ ./ +RUN pnpm build + +FROM golang:1.26-alpine AS backend +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY cmd/ cmd/ +COPY internal/ internal/ +COPY web/embed.go web/ +COPY --from=frontend /src/web/dist web/dist +RUN CGO_ENABLED=0 go build -o /out/solopm ./cmd/solopm + +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates && \ + addgroup -S solopm && adduser -S solopm -G solopm +COPY --from=backend /out/solopm /usr/local/bin/solopm +USER solopm +EXPOSE 8080 +ENTRYPOINT ["solopm"] +CMD ["serve"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..74f7748 --- /dev/null +++ b/Makefile @@ -0,0 +1,33 @@ +.PHONY: dev build test fmt vet lint sqlc migrate-up migrate-down + +dev: + @trap 'kill 0' EXIT; \ + (cd web && pnpm dev) & \ + go run ./cmd/solopm serve & \ + wait + +build: + cd web && pnpm install && pnpm build + go build -ldflags "-X main.version=$$(git describe --tags --always --dirty 2>/dev/null || echo dev)" -o solopm ./cmd/solopm + +test: + go test ./... + +fmt: + gofmt -l -w . + +vet: + go vet ./... + +lint: vet + golangci-lint run ./... + cd web && pnpm exec vue-tsc -b --noEmit + +sqlc: + sqlc generate + +migrate-up: + go run ./cmd/solopm migrate up + +migrate-down: + go run ./cmd/solopm migrate down diff --git a/cmd/solopm/main.go b/cmd/solopm/main.go new file mode 100644 index 0000000..0c87486 --- /dev/null +++ b/cmd/solopm/main.go @@ -0,0 +1,108 @@ +// Command solopm is the SoloPM server binary: serves the JSON API and the +// embedded Vue SPA, and hosts CLI subcommands (migrate, reindex, cull). +package main + +import ( + "context" + "io/fs" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/joho/godotenv" + + "solopm.com/solopm-server/internal/config" + "solopm.com/solopm-server/internal/httpserver" + solopmweb "solopm.com/solopm-server/web" +) + +var version = "dev" + +func main() { + // Best-effort: .env is a local-dev convenience, absent in Docker/prod + // where real env vars are set directly. + _ = godotenv.Load() + + if len(os.Args) > 1 { + switch os.Args[1] { + case "migrate": + runMigrateCmd(os.Args[2:]) + return + case "serve": + // falls through to default serve below + default: + slog.Error("unknown subcommand", "subcommand", os.Args[1]) + os.Exit(1) + } + } + + if err := serve(); err != nil { + slog.Error("server exited with error", "error", err) + os.Exit(1) + } +} + +func serve() error { + cfg, err := config.Load() + if err != nil { + return err + } + + logLevel := slog.LevelInfo + if cfg.LogLevel == "debug" { + logLevel = slog.LevelDebug + } + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: logLevel})) + slog.SetDefault(logger) + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + pool, err := pgxpool.New(ctx, cfg.DatabaseURL) + if err != nil { + return err + } + defer pool.Close() + + if err := runMigrations(cfg.DatabaseURL); err != nil { + return err + } + + spaFS, err := fs.Sub(solopmweb.DistFS, "dist") + if err != nil { + return err + } + + router := httpserver.New(httpserver.Options{ + Config: cfg, + Logger: logger, + DB: pool, + Version: version, + Dev: cfg.LogLevel == "debug", + SPA: spaFS, + }) + + srv := &http.Server{ + Addr: ":" + cfg.HTTPPort, + Handler: router, + } + + go func() { + logger.Info("listening", "port", cfg.HTTPPort, "version", version) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.Error("listen error", "error", err) + } + }() + + <-ctx.Done() + logger.Info("shutting down") + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + return srv.Shutdown(shutdownCtx) +} diff --git a/cmd/solopm/migrate.go b/cmd/solopm/migrate.go new file mode 100644 index 0000000..6b92f69 --- /dev/null +++ b/cmd/solopm/migrate.go @@ -0,0 +1,41 @@ +package main + +import ( + "fmt" + "os" + + "solopm.com/solopm-server/internal/config" + "solopm.com/solopm-server/internal/db" +) + +// runMigrations applies pending migrations at startup. Called by serve() +// unless a future --no-migrate flag disables it. +func runMigrations(databaseURL string) error { + return db.MigrateUp(databaseURL) +} + +// runMigrateCmd implements `solopm migrate up|down`. +func runMigrateCmd(args []string) { + if len(args) != 1 || (args[0] != "up" && args[0] != "down") { + fmt.Fprintln(os.Stderr, "usage: solopm migrate up|down") + os.Exit(1) + } + + cfg, err := config.Load() + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } + + switch args[0] { + case "up": + err = db.MigrateUp(cfg.DatabaseURL) + case "down": + err = db.MigrateDown(cfg.DatabaseURL) + } + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } + fmt.Println("migrate " + args[0] + ": ok") +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6bcf72c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,59 @@ +services: + app: + build: . + image: solopm:local + ports: + - "8080:8080" + env_file: + - .env + environment: + DATABASE_URL: postgres://solopm:solopm@postgres:5432/solopm?sslmode=disable + MEILI_URL: http://meilisearch:7700 + UPLOAD_DIR: /data/uploads + SMTP_HOST: ${SMTP_HOST:-mailhog} + SMTP_PORT: ${SMTP_PORT:-1025} + volumes: + - uploads:/data/uploads + depends_on: + postgres: + condition: service_healthy + meilisearch: + condition: service_healthy + + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: solopm + POSTGRES_PASSWORD: solopm + POSTGRES_DB: solopm + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U solopm"] + interval: 5s + timeout: 5s + retries: 10 + + meilisearch: + image: getmeili/meilisearch:v1.10 + environment: + MEILI_MASTER_KEY: ${MEILI_KEY:-} + MEILI_NO_ANALYTICS: "true" + volumes: + - meilidata:/meili_data + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:7700/health || exit 1"] + interval: 5s + timeout: 5s + retries: 10 + + mailhog: + image: mailhog/mailhog + profiles: ["dev"] + ports: + - "8025:8025" # web UI + +volumes: + pgdata: + meilidata: + uploads: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7564bd9 --- /dev/null +++ b/go.mod @@ -0,0 +1,21 @@ +module solopm.com/solopm-server + +go 1.26.3 + +require ( + github.com/go-chi/chi/v5 v5.3.1 + github.com/go-chi/cors v1.2.2 + github.com/golang-migrate/migrate/v4 v4.19.1 + github.com/jackc/pgx/v5 v5.10.0 +) + +require ( + github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/joho/godotenv v1.5.1 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1edffb8 --- /dev/null +++ b/go.sum @@ -0,0 +1,89 @@ +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= +github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= +github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= +github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= +github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= +github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= +github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw= +github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= +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-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +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/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..ecfc06d --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,139 @@ +// Package config loads and validates SoloPM's environment-variable configuration. +package config + +import ( + "fmt" + "os" + "strconv" + "strings" +) + +// Config holds every environment-driven setting for the SoloPM server. +type Config struct { + DatabaseURL string + HTTPPort string + BaseURL string + SessionSecret string + UploadDir string + MaxUploadMB int + + MeiliURL string + MeiliKey string + + SMTPHost string + SMTPPort string + SMTPUser string + SMTPPass string + SMTPFrom string + + GitHubClientID string + GitHubClientSecret string + + GiteaURL string + GiteaClientID string + GiteaClientSecret string + + CullAfterDays int + LogLevel string +} + +// SearchEnabled reports whether Meilisearch is configured. +func (c *Config) SearchEnabled() bool { + return c.MeiliURL != "" && c.MeiliKey != "" +} + +// EmailEnabled reports whether SMTP is configured. +func (c *Config) EmailEnabled() bool { + return c.SMTPHost != "" +} + +// GitHubOAuthEnabled reports whether GitHub OAuth is configured. +func (c *Config) GitHubOAuthEnabled() bool { + return c.GitHubClientID != "" && c.GitHubClientSecret != "" +} + +// GiteaOAuthEnabled reports whether Gitea OAuth is configured. +func (c *Config) GiteaOAuthEnabled() bool { + return c.GiteaURL != "" && c.GiteaClientID != "" && c.GiteaClientSecret != "" +} + +// Load reads configuration from the environment, applying defaults and +// failing fast (returning an error) if a required variable is missing or +// malformed. +func Load() (*Config, error) { + var missing []string + required := func(key string) string { + v := os.Getenv(key) + if v == "" { + missing = append(missing, key) + } + return v + } + + c := &Config{ + DatabaseURL: required("DATABASE_URL"), + HTTPPort: getDefault("HTTP_PORT", "8080"), + BaseURL: required("BASE_URL"), + SessionSecret: required("SESSION_SECRET"), + UploadDir: getDefault("UPLOAD_DIR", "./uploads"), + + MeiliURL: os.Getenv("MEILI_URL"), + MeiliKey: os.Getenv("MEILI_KEY"), + + SMTPHost: os.Getenv("SMTP_HOST"), + SMTPPort: os.Getenv("SMTP_PORT"), + SMTPUser: os.Getenv("SMTP_USER"), + SMTPPass: os.Getenv("SMTP_PASS"), + SMTPFrom: os.Getenv("SMTP_FROM"), + + GitHubClientID: os.Getenv("GITHUB_CLIENT_ID"), + GitHubClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"), + + GiteaURL: os.Getenv("GITEA_URL"), + GiteaClientID: os.Getenv("GITEA_CLIENT_ID"), + GiteaClientSecret: os.Getenv("GITEA_CLIENT_SECRET"), + + LogLevel: getDefault("LOG_LEVEL", "info"), + } + + if len(missing) > 0 { + return nil, fmt.Errorf("config: missing required environment variable(s): %s", strings.Join(missing, ", ")) + } + + if len(c.SessionSecret) < 32 { + return nil, fmt.Errorf("config: SESSION_SECRET must be at least 32 bytes, got %d", len(c.SessionSecret)) + } + + maxUploadMB, err := getIntDefault("MAX_UPLOAD_MB", 25) + if err != nil { + return nil, err + } + c.MaxUploadMB = maxUploadMB + + cullAfterDays, err := getIntDefault("CULL_AFTER_DAYS", 30) + if err != nil { + return nil, err + } + c.CullAfterDays = cullAfterDays + + return c, nil +} + +func getDefault(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func getIntDefault(key string, def int) (int, error) { + v := os.Getenv(key) + if v == "" { + return def, nil + } + n, err := strconv.Atoi(v) + if err != nil { + return 0, fmt.Errorf("config: %s must be an integer, got %q", key, v) + } + return n, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..6e20a28 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,96 @@ +package config + +import "testing" + +func clearEnv(t *testing.T) { + t.Helper() + keys := []string{ + "DATABASE_URL", "HTTP_PORT", "BASE_URL", "SESSION_SECRET", "UPLOAD_DIR", + "MAX_UPLOAD_MB", "MEILI_URL", "MEILI_KEY", "SMTP_HOST", "SMTP_PORT", + "SMTP_USER", "SMTP_PASS", "SMTP_FROM", "GITHUB_CLIENT_ID", "GITHUB_CLIENT_SECRET", + "GITEA_URL", "GITEA_CLIENT_ID", "GITEA_CLIENT_SECRET", "CULL_AFTER_DAYS", "LOG_LEVEL", + } + for _, k := range keys { + t.Setenv(k, "") + } +} + +func validRequired(t *testing.T) { + t.Helper() + t.Setenv("DATABASE_URL", "postgres://localhost/solopm") + t.Setenv("BASE_URL", "http://localhost:8080") + t.Setenv("SESSION_SECRET", "01234567890123456789012345678901") +} + +func TestLoad_MissingRequired(t *testing.T) { + clearEnv(t) + _, err := Load() + if err == nil { + t.Fatal("expected error for missing required vars, got nil") + } +} + +func TestLoad_ShortSessionSecret(t *testing.T) { + clearEnv(t) + t.Setenv("DATABASE_URL", "postgres://localhost/solopm") + t.Setenv("BASE_URL", "http://localhost:8080") + t.Setenv("SESSION_SECRET", "tooshort") + _, err := Load() + if err == nil { + t.Fatal("expected error for short SESSION_SECRET, got nil") + } +} + +func TestLoad_Defaults(t *testing.T) { + clearEnv(t) + validRequired(t) + c, err := Load() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if c.HTTPPort != "8080" { + t.Errorf("HTTPPort = %q, want 8080", c.HTTPPort) + } + if c.UploadDir != "./uploads" { + t.Errorf("UploadDir = %q, want ./uploads", c.UploadDir) + } + if c.MaxUploadMB != 25 { + t.Errorf("MaxUploadMB = %d, want 25", c.MaxUploadMB) + } + if c.CullAfterDays != 30 { + t.Errorf("CullAfterDays = %d, want 30", c.CullAfterDays) + } + if c.LogLevel != "info" { + t.Errorf("LogLevel = %q, want info", c.LogLevel) + } + if c.SearchEnabled() { + t.Error("SearchEnabled() = true, want false when MEILI_URL/KEY unset") + } + if c.EmailEnabled() { + t.Error("EmailEnabled() = true, want false when SMTP_HOST unset") + } +} + +func TestLoad_SearchEnabled(t *testing.T) { + clearEnv(t) + validRequired(t) + t.Setenv("MEILI_URL", "http://meilisearch:7700") + t.Setenv("MEILI_KEY", "key") + c, err := Load() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !c.SearchEnabled() { + t.Error("SearchEnabled() = false, want true when MEILI_URL/KEY set") + } +} + +func TestLoad_InvalidInt(t *testing.T) { + clearEnv(t) + validRequired(t) + t.Setenv("MAX_UPLOAD_MB", "not-a-number") + _, err := Load() + if err == nil { + t.Fatal("expected error for non-integer MAX_UPLOAD_MB, got nil") + } +} diff --git a/internal/db/migrate.go b/internal/db/migrate.go new file mode 100644 index 0000000..e2a47be --- /dev/null +++ b/internal/db/migrate.go @@ -0,0 +1,73 @@ +// Package db holds the embedded golang-migrate migrations and the sqlc +// generated query code (internal/db/sqlcgen). +package db + +import ( + "embed" + "errors" + "fmt" + "net/url" + + "github.com/golang-migrate/migrate/v4" + _ "github.com/golang-migrate/migrate/v4/database/pgx/v5" + "github.com/golang-migrate/migrate/v4/source/iofs" +) + +//go:embed migrations/*.sql +var migrationsFS embed.FS + +// newMigrate builds a *migrate.Migrate over the embedded migration files and +// the given database connection string. +func newMigrate(databaseURL string) (*migrate.Migrate, error) { + sourceDriver, err := iofs.New(migrationsFS, "migrations") + if err != nil { + return nil, fmt.Errorf("db: loading embedded migrations: %w", err) + } + + m, err := migrate.NewWithSourceInstance("iofs", sourceDriver, wrapDatabaseURL(databaseURL)) + if err != nil { + return nil, fmt.Errorf("db: initializing migrate: %w", err) + } + return m, nil +} + +// wrapDatabaseURL swaps the postgres(ql):// scheme for pgx5:// as required +// by golang-migrate's pgx/v5 database driver registration. +func wrapDatabaseURL(databaseURL string) string { + u, err := url.Parse(databaseURL) + if err != nil { + return databaseURL + } + u.Scheme = "pgx5" + return u.String() +} + +// MigrateUp applies all pending migrations. It returns nil if there is +// nothing to do. +func MigrateUp(databaseURL string) error { + m, err := newMigrate(databaseURL) + if err != nil { + return err + } + defer func() { _, _ = m.Close() }() + + if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { + return fmt.Errorf("db: migrate up: %w", err) + } + return nil +} + +// MigrateDown rolls back all migrations. It returns nil if there is nothing +// to do. +func MigrateDown(databaseURL string) error { + m, err := newMigrate(databaseURL) + if err != nil { + return err + } + defer func() { _, _ = m.Close() }() + + if err := m.Down(); err != nil && !errors.Is(err, migrate.ErrNoChange) { + return fmt.Errorf("db: migrate down: %w", err) + } + return nil +} diff --git a/internal/db/migrations/0001_extensions_and_enums.down.sql b/internal/db/migrations/0001_extensions_and_enums.down.sql new file mode 100644 index 0000000..77fb758 --- /dev/null +++ b/internal/db/migrations/0001_extensions_and_enums.down.sql @@ -0,0 +1,13 @@ +DROP TYPE IF EXISTS email_mode; +DROP TYPE IF EXISTS delivery_status; +DROP TYPE IF EXISTS receipt_status; +DROP TYPE IF EXISTS job_status; +DROP TYPE IF EXISTS resource_kind; +DROP TYPE IF EXISTS auth_provider; +DROP TYPE IF EXISTS member_role; +DROP TYPE IF EXISTS priority; +DROP TYPE IF EXISTS issue_status; +DROP TYPE IF EXISTS epic_status; +DROP TYPE IF EXISTS project_status; + +DROP EXTENSION IF EXISTS citext; diff --git a/internal/db/migrations/0001_extensions_and_enums.up.sql b/internal/db/migrations/0001_extensions_and_enums.up.sql new file mode 100644 index 0000000..9266c26 --- /dev/null +++ b/internal/db/migrations/0001_extensions_and_enums.up.sql @@ -0,0 +1,13 @@ +CREATE EXTENSION IF NOT EXISTS citext; + +CREATE TYPE project_status AS ENUM ('backlog', 'planned', 'in_progress', 'completed', 'canceled'); +CREATE TYPE epic_status AS ENUM ('backlog', 'planned', 'in_progress', 'completed', 'canceled'); +CREATE TYPE issue_status AS ENUM ('backlog', 'planned', 'in_progress', 'ready_for_review', 'done', 'canceled', 'duplicate'); +CREATE TYPE priority AS ENUM ('low', 'medium', 'high', 'urgent', 'frantic'); +CREATE TYPE member_role AS ENUM ('owner', 'member'); +CREATE TYPE auth_provider AS ENUM ('github', 'gitea'); +CREATE TYPE resource_kind AS ENUM ('project', 'issue', 'epic', 'comment', 'wiki_page', 'user', 'label', 'attachment', 'link', 'member', 'webhook', 'auth'); +CREATE TYPE job_status AS ENUM ('pending', 'running', 'done', 'failed', 'dead'); +CREATE TYPE receipt_status AS ENUM ('pending', 'processed', 'failed', 'ignored'); +CREATE TYPE delivery_status AS ENUM ('pending', 'success', 'failed'); +CREATE TYPE email_mode AS ENUM ('off', 'instant', 'daily_digest'); diff --git a/internal/db/queries/system.sql b/internal/db/queries/system.sql new file mode 100644 index 0000000..9bd5c10 --- /dev/null +++ b/internal/db/queries/system.sql @@ -0,0 +1,2 @@ +-- name: Ping :one +SELECT 1::int AS ok; diff --git a/internal/db/sqlcgen/db.go b/internal/db/sqlcgen/db.go new file mode 100644 index 0000000..38811fc --- /dev/null +++ b/internal/db/sqlcgen/db.go @@ -0,0 +1,32 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package sqlcgen + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type DBTX interface { + Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) + Query(context.Context, string, ...interface{}) (pgx.Rows, error) + QueryRow(context.Context, string, ...interface{}) pgx.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx pgx.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/db/sqlcgen/models.go b/internal/db/sqlcgen/models.go new file mode 100644 index 0000000..d6fc1b5 --- /dev/null +++ b/internal/db/sqlcgen/models.go @@ -0,0 +1,503 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package sqlcgen + +import ( + "database/sql/driver" + "fmt" +) + +type AuthProvider string + +const ( + AuthProviderGithub AuthProvider = "github" + AuthProviderGitea AuthProvider = "gitea" +) + +func (e *AuthProvider) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = AuthProvider(s) + case string: + *e = AuthProvider(s) + default: + return fmt.Errorf("unsupported scan type for AuthProvider: %T", src) + } + return nil +} + +type NullAuthProvider struct { + AuthProvider AuthProvider `json:"auth_provider"` + Valid bool `json:"valid"` // Valid is true if AuthProvider is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullAuthProvider) Scan(value interface{}) error { + if value == nil { + ns.AuthProvider, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.AuthProvider.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullAuthProvider) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.AuthProvider), nil +} + +type DeliveryStatus string + +const ( + DeliveryStatusPending DeliveryStatus = "pending" + DeliveryStatusSuccess DeliveryStatus = "success" + DeliveryStatusFailed DeliveryStatus = "failed" +) + +func (e *DeliveryStatus) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = DeliveryStatus(s) + case string: + *e = DeliveryStatus(s) + default: + return fmt.Errorf("unsupported scan type for DeliveryStatus: %T", src) + } + return nil +} + +type NullDeliveryStatus struct { + DeliveryStatus DeliveryStatus `json:"delivery_status"` + Valid bool `json:"valid"` // Valid is true if DeliveryStatus is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullDeliveryStatus) Scan(value interface{}) error { + if value == nil { + ns.DeliveryStatus, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.DeliveryStatus.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullDeliveryStatus) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.DeliveryStatus), nil +} + +type EmailMode string + +const ( + EmailModeOff EmailMode = "off" + EmailModeInstant EmailMode = "instant" + EmailModeDailyDigest EmailMode = "daily_digest" +) + +func (e *EmailMode) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = EmailMode(s) + case string: + *e = EmailMode(s) + default: + return fmt.Errorf("unsupported scan type for EmailMode: %T", src) + } + return nil +} + +type NullEmailMode struct { + EmailMode EmailMode `json:"email_mode"` + Valid bool `json:"valid"` // Valid is true if EmailMode is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullEmailMode) Scan(value interface{}) error { + if value == nil { + ns.EmailMode, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.EmailMode.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullEmailMode) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.EmailMode), nil +} + +type EpicStatus string + +const ( + EpicStatusBacklog EpicStatus = "backlog" + EpicStatusPlanned EpicStatus = "planned" + EpicStatusInProgress EpicStatus = "in_progress" + EpicStatusCompleted EpicStatus = "completed" + EpicStatusCanceled EpicStatus = "canceled" +) + +func (e *EpicStatus) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = EpicStatus(s) + case string: + *e = EpicStatus(s) + default: + return fmt.Errorf("unsupported scan type for EpicStatus: %T", src) + } + return nil +} + +type NullEpicStatus struct { + EpicStatus EpicStatus `json:"epic_status"` + Valid bool `json:"valid"` // Valid is true if EpicStatus is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullEpicStatus) Scan(value interface{}) error { + if value == nil { + ns.EpicStatus, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.EpicStatus.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullEpicStatus) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.EpicStatus), nil +} + +type IssueStatus string + +const ( + IssueStatusBacklog IssueStatus = "backlog" + IssueStatusPlanned IssueStatus = "planned" + IssueStatusInProgress IssueStatus = "in_progress" + IssueStatusReadyForReview IssueStatus = "ready_for_review" + IssueStatusDone IssueStatus = "done" + IssueStatusCanceled IssueStatus = "canceled" + IssueStatusDuplicate IssueStatus = "duplicate" +) + +func (e *IssueStatus) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = IssueStatus(s) + case string: + *e = IssueStatus(s) + default: + return fmt.Errorf("unsupported scan type for IssueStatus: %T", src) + } + return nil +} + +type NullIssueStatus struct { + IssueStatus IssueStatus `json:"issue_status"` + Valid bool `json:"valid"` // Valid is true if IssueStatus is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullIssueStatus) Scan(value interface{}) error { + if value == nil { + ns.IssueStatus, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.IssueStatus.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullIssueStatus) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.IssueStatus), nil +} + +type JobStatus string + +const ( + JobStatusPending JobStatus = "pending" + JobStatusRunning JobStatus = "running" + JobStatusDone JobStatus = "done" + JobStatusFailed JobStatus = "failed" + JobStatusDead JobStatus = "dead" +) + +func (e *JobStatus) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = JobStatus(s) + case string: + *e = JobStatus(s) + default: + return fmt.Errorf("unsupported scan type for JobStatus: %T", src) + } + return nil +} + +type NullJobStatus struct { + JobStatus JobStatus `json:"job_status"` + Valid bool `json:"valid"` // Valid is true if JobStatus is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullJobStatus) Scan(value interface{}) error { + if value == nil { + ns.JobStatus, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.JobStatus.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullJobStatus) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.JobStatus), nil +} + +type MemberRole string + +const ( + MemberRoleOwner MemberRole = "owner" + MemberRoleMember MemberRole = "member" +) + +func (e *MemberRole) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = MemberRole(s) + case string: + *e = MemberRole(s) + default: + return fmt.Errorf("unsupported scan type for MemberRole: %T", src) + } + return nil +} + +type NullMemberRole struct { + MemberRole MemberRole `json:"member_role"` + Valid bool `json:"valid"` // Valid is true if MemberRole is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullMemberRole) Scan(value interface{}) error { + if value == nil { + ns.MemberRole, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.MemberRole.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullMemberRole) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.MemberRole), nil +} + +type Priority string + +const ( + PriorityLow Priority = "low" + PriorityMedium Priority = "medium" + PriorityHigh Priority = "high" + PriorityUrgent Priority = "urgent" + PriorityFrantic Priority = "frantic" +) + +func (e *Priority) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = Priority(s) + case string: + *e = Priority(s) + default: + return fmt.Errorf("unsupported scan type for Priority: %T", src) + } + return nil +} + +type NullPriority struct { + Priority Priority `json:"priority"` + Valid bool `json:"valid"` // Valid is true if Priority is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullPriority) Scan(value interface{}) error { + if value == nil { + ns.Priority, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.Priority.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullPriority) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.Priority), nil +} + +type ProjectStatus string + +const ( + ProjectStatusBacklog ProjectStatus = "backlog" + ProjectStatusPlanned ProjectStatus = "planned" + ProjectStatusInProgress ProjectStatus = "in_progress" + ProjectStatusCompleted ProjectStatus = "completed" + ProjectStatusCanceled ProjectStatus = "canceled" +) + +func (e *ProjectStatus) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = ProjectStatus(s) + case string: + *e = ProjectStatus(s) + default: + return fmt.Errorf("unsupported scan type for ProjectStatus: %T", src) + } + return nil +} + +type NullProjectStatus struct { + ProjectStatus ProjectStatus `json:"project_status"` + Valid bool `json:"valid"` // Valid is true if ProjectStatus is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullProjectStatus) Scan(value interface{}) error { + if value == nil { + ns.ProjectStatus, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.ProjectStatus.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullProjectStatus) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.ProjectStatus), nil +} + +type ReceiptStatus string + +const ( + ReceiptStatusPending ReceiptStatus = "pending" + ReceiptStatusProcessed ReceiptStatus = "processed" + ReceiptStatusFailed ReceiptStatus = "failed" + ReceiptStatusIgnored ReceiptStatus = "ignored" +) + +func (e *ReceiptStatus) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = ReceiptStatus(s) + case string: + *e = ReceiptStatus(s) + default: + return fmt.Errorf("unsupported scan type for ReceiptStatus: %T", src) + } + return nil +} + +type NullReceiptStatus struct { + ReceiptStatus ReceiptStatus `json:"receipt_status"` + Valid bool `json:"valid"` // Valid is true if ReceiptStatus is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullReceiptStatus) Scan(value interface{}) error { + if value == nil { + ns.ReceiptStatus, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.ReceiptStatus.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullReceiptStatus) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.ReceiptStatus), nil +} + +type ResourceKind string + +const ( + ResourceKindProject ResourceKind = "project" + ResourceKindIssue ResourceKind = "issue" + ResourceKindEpic ResourceKind = "epic" + ResourceKindComment ResourceKind = "comment" + ResourceKindWikiPage ResourceKind = "wiki_page" + ResourceKindUser ResourceKind = "user" + ResourceKindLabel ResourceKind = "label" + ResourceKindAttachment ResourceKind = "attachment" + ResourceKindLink ResourceKind = "link" + ResourceKindMember ResourceKind = "member" + ResourceKindWebhook ResourceKind = "webhook" + ResourceKindAuth ResourceKind = "auth" +) + +func (e *ResourceKind) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = ResourceKind(s) + case string: + *e = ResourceKind(s) + default: + return fmt.Errorf("unsupported scan type for ResourceKind: %T", src) + } + return nil +} + +type NullResourceKind struct { + ResourceKind ResourceKind `json:"resource_kind"` + Valid bool `json:"valid"` // Valid is true if ResourceKind is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullResourceKind) Scan(value interface{}) error { + if value == nil { + ns.ResourceKind, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.ResourceKind.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullResourceKind) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.ResourceKind), nil +} diff --git a/internal/db/sqlcgen/querier.go b/internal/db/sqlcgen/querier.go new file mode 100644 index 0000000..4810fb4 --- /dev/null +++ b/internal/db/sqlcgen/querier.go @@ -0,0 +1,15 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package sqlcgen + +import ( + "context" +) + +type Querier interface { + Ping(ctx context.Context) (int32, error) +} + +var _ Querier = (*Queries)(nil) diff --git a/internal/db/sqlcgen/system.sql.go b/internal/db/sqlcgen/system.sql.go new file mode 100644 index 0000000..4ae560c --- /dev/null +++ b/internal/db/sqlcgen/system.sql.go @@ -0,0 +1,21 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: system.sql + +package sqlcgen + +import ( + "context" +) + +const ping = `-- name: Ping :one +SELECT 1::int AS ok +` + +func (q *Queries) Ping(ctx context.Context) (int32, error) { + row := q.db.QueryRow(ctx, ping) + var ok int32 + err := row.Scan(&ok) + return ok, err +} diff --git a/internal/httpserver/health.go b/internal/httpserver/health.go new file mode 100644 index 0000000..c60e47f --- /dev/null +++ b/internal/httpserver/health.go @@ -0,0 +1,75 @@ +package httpserver + +import ( + "context" + "encoding/json" + "net/http" + "time" + + "solopm.com/solopm-server/internal/config" +) + +// DBPinger is the subset of *pgxpool.Pool used for health checks. +type DBPinger interface { + Ping(ctx context.Context) error +} + +type healthResponse struct { + Status string `json:"status"` + Version string `json:"version"` + Database string `json:"database"` + Meilisearch string `json:"meilisearch"` + SMTP string `json:"smtp"` +} + +func healthzHandler(cfg *config.Config, db DBPinger, version string) http.HandlerFunc { + httpClient := &http.Client{Timeout: 2 * time.Second} + + return func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) + defer cancel() + + resp := healthResponse{ + Status: "ok", + Version: version, + Meilisearch: "absent", + SMTP: "absent", + } + + if err := db.Ping(ctx); err != nil { + resp.Status = "error" + resp.Database = "unreachable" + } else { + resp.Database = "ok" + } + + if cfg.SearchEnabled() { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, cfg.MeiliURL+"/health", nil) + if err == nil { + if hr, err := httpClient.Do(req); err == nil { + _ = hr.Body.Close() + if hr.StatusCode == http.StatusOK { + resp.Meilisearch = "ok" + } else { + resp.Meilisearch = "unreachable" + } + } else { + resp.Meilisearch = "unreachable" + } + } + } + + if cfg.EmailEnabled() { + resp.SMTP = "ok" + } + + status := http.StatusOK + if resp.Status != "ok" { + status = http.StatusServiceUnavailable + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(resp) + } +} diff --git a/internal/httpserver/health_test.go b/internal/httpserver/health_test.go new file mode 100644 index 0000000..012a948 --- /dev/null +++ b/internal/httpserver/health_test.go @@ -0,0 +1,59 @@ +package httpserver + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "solopm.com/solopm-server/internal/config" +) + +type stubPinger struct{ err error } + +func (s stubPinger) Ping(ctx context.Context) error { return s.err } + +func TestHealthzHandler(t *testing.T) { + cfg := &config.Config{} // no MEILI_URL/SMTP_HOST set: both report "absent" + + t.Run("db ok", func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + + healthzHandler(cfg, stubPinger{}, "test")(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + var body healthResponse + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if body.Status != "ok" || body.Database != "ok" { + t.Fatalf("body = %+v, want status=ok database=ok", body) + } + if body.Meilisearch != "absent" || body.SMTP != "absent" { + t.Fatalf("body = %+v, want meilisearch/smtp absent", body) + } + }) + + t.Run("db unreachable", func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + + healthzHandler(cfg, stubPinger{err: errors.New("boom")}, "test")(rec, req) + + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503", rec.Code) + } + var body healthResponse + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if body.Status != "error" || body.Database != "unreachable" { + t.Fatalf("body = %+v, want status=error database=unreachable", body) + } + }) +} diff --git a/internal/httpserver/middleware.go b/internal/httpserver/middleware.go new file mode 100644 index 0000000..dc16a3f --- /dev/null +++ b/internal/httpserver/middleware.go @@ -0,0 +1,30 @@ +package httpserver + +import ( + "log/slog" + "net/http" + "time" + + "github.com/go-chi/chi/v5/middleware" +) + +// RequestLogger logs one structured line per request: method, path, status, +// duration, and (once auth exists) user id. +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 := middleware.NewWrapResponseWriter(w, r.ProtoMajor) + + next.ServeHTTP(ww, r) + + logger.Info("http_request", + "method", r.Method, + "path", r.URL.Path, + "status", ww.Status(), + "duration_ms", time.Since(start).Milliseconds(), + "request_id", middleware.GetReqID(r.Context()), + ) + }) + } +} diff --git a/internal/httpserver/router.go b/internal/httpserver/router.go new file mode 100644 index 0000000..ccbb1b3 --- /dev/null +++ b/internal/httpserver/router.go @@ -0,0 +1,59 @@ +package httpserver + +import ( + "io/fs" + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/cors" + + "solopm.com/solopm-server/internal/config" +) + +// Options bundles the dependencies the router needs to wire routes. +type Options struct { + Config *config.Config + Logger *slog.Logger + DB DBPinger + Version string + Dev bool // permissive CORS in dev; same-origin default otherwise + SPA fs.FS // web/dist, rooted so index.html is at the top level; nil disables SPA serving +} + +// New builds the chi router with the full middleware chain (minus the auth +// resolver, which is added in Phase 1) and registers Phase 0 routes. +func New(opts Options) chi.Router { + r := chi.NewRouter() + + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) //nolint:staticcheck // middleware order is spec-mandated (01-architecture.md); deploy behind a trusted reverse proxy + r.Use(RequestLogger(opts.Logger)) + r.Use(middleware.Recoverer) + r.Use(corsMiddleware(opts.Dev)) + + r.Get("/healthz", healthzHandler(opts.Config, opts.DB, opts.Version)) + + if opts.SPA != nil { + r.Get("/*", spaHandler(opts.SPA)) + } + + return r +} + +func corsMiddleware(dev bool) func(http.Handler) http.Handler { + if dev { + return cors.Handler(cors.Options{ + AllowedOrigins: []string{"*"}, + AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"Accept", "Content-Type", "Authorization"}, + AllowCredentials: false, + }) + } + // Same-origin default: no CORS headers granted, browser same-origin + // requests work regardless since no cross-origin request needs them. + return cors.Handler(cors.Options{ + AllowedOrigins: []string{}, + }) +} diff --git a/internal/httpserver/spa.go b/internal/httpserver/spa.go new file mode 100644 index 0000000..bd5935b --- /dev/null +++ b/internal/httpserver/spa.go @@ -0,0 +1,37 @@ +package httpserver + +import ( + "io/fs" + "net/http" +) + +// spaHandler serves the embedded Vue SPA out of distFS (already rooted at +// web/dist, i.e. index.html is at its top level). Any request path that +// doesn't match a real file falls back to index.html so client-side routes +// (deep links) resolve correctly. +func spaHandler(distFS fs.FS) http.HandlerFunc { + fileServer := http.FileServer(http.FS(distFS)) + + return func(w http.ResponseWriter, r *http.Request) { + upath := r.URL.Path + if upath == "" || upath == "/" { + upath = "index.html" + } else { + upath = upath[1:] // strip leading slash for fs.Stat + } + + if _, err := fs.Stat(distFS, upath); err != nil { + r = cloneWithPath(r, "/") + } + + fileServer.ServeHTTP(w, r) + } +} + +// cloneWithPath returns a shallow copy of r with URL.Path replaced, so the +// original request (and its URL) is left untouched. +func cloneWithPath(r *http.Request, path string) *http.Request { + r2 := r.Clone(r.Context()) + r2.URL.Path = path + return r2 +} diff --git a/internal/httpserver/spa_test.go b/internal/httpserver/spa_test.go new file mode 100644 index 0000000..cfa9894 --- /dev/null +++ b/internal/httpserver/spa_test.go @@ -0,0 +1,48 @@ +package httpserver + +import ( + "net/http" + "net/http/httptest" + "testing" + "testing/fstest" +) + +func testDistFS() fstest.MapFS { + return fstest.MapFS{ + "index.html": {Data: []byte("
")}, + "assets/app.js": {Data: []byte("console.log('app')")}, + "favicon.svg": {Data: []byte("")}, + } +} + +func TestSPAHandler(t *testing.T) { + cases := []struct { + name string + path string + wantBody string + }{ + {"root serves index", "/", ""}, + {"real file served as-is", "/assets/app.js", "console.log('app')"}, + {"real top-level file served as-is", "/favicon.svg", ""}, + {"deep link falls back to index", "/projects/42/board", ""}, + {"unknown asset path falls back to index", "/assets/missing.js", ""}, + } + + handler := spaHandler(testDistFS()) + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + rec := httptest.NewRecorder() + + handler(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if got := rec.Body.String(); got != tc.wantBody { + t.Fatalf("body = %q, want %q", got, tc.wantBody) + } + }) + } +} diff --git a/plans/01-architecture.md b/plans/01-architecture.md index 7645631..59618b6 100644 --- a/plans/01-architecture.md +++ b/plans/01-architecture.md @@ -4,7 +4,7 @@ Backend is a single Go module `solopm.com/solopm-server`, one binary, serving th ## Repository layout -- [ ] Create the repo skeleton: +- [x] Create the repo skeleton (Phase 0 subset; `internal/domain/*`, `internal/events`, `internal/jobs`, `internal/search`, `internal/mail`, `internal/auth`, `internal/files` are added by the phases that need them): ``` / (repo root; /app in this workspace) @@ -36,19 +36,19 @@ Backend is a single Go module `solopm.com/solopm-server`, one binary, serving th ## Configuration (env vars) -- [ ] Implement `internal/config` reading these variables (fail fast at startup on missing required ones): - - [ ] `DATABASE_URL` (required) — pgx connection string - - [ ] `HTTP_PORT` (default `4080`) - - [ ] `BASE_URL` (required) — external URL, used in emails/OAuth callbacks/webhook payloads - - [ ] `SESSION_SECRET` (required) — 32+ bytes - - [ ] `UPLOAD_DIR` (default `./uploads`) — attachment storage root - - [ ] `MAX_UPLOAD_MB` (default `25`) - - [ ] `MEILI_URL`, `MEILI_KEY` (required for search; app runs degraded without them — search endpoints return 503) - - [ ] `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM` (optional; email disabled when unset) - - [ ] `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` (optional — hides the button when unset) - - [ ] `GITEA_URL`, `GITEA_CLIENT_ID`, `GITEA_CLIENT_SECRET` (optional — same) - - [ ] `CULL_AFTER_DAYS` (default `30`) - - [ ] `LOG_LEVEL` (default `info`) +- [x] Implement `internal/config` reading these variables (fail fast at startup on missing required ones): + - [x] `DATABASE_URL` (required) — pgx connection string + - [x] `HTTP_PORT` (default `8080`) + - [x] `BASE_URL` (required) — external URL, used in emails/OAuth callbacks/webhook payloads + - [x] `SESSION_SECRET` (required) — 32+ bytes + - [x] `UPLOAD_DIR` (default `./uploads`) — attachment storage root + - [x] `MAX_UPLOAD_MB` (default `25`) + - [x] `MEILI_URL`, `MEILI_KEY` (required for search; app runs degraded without them — search endpoints return 503) + - [x] `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM` (optional; email disabled when unset) + - [x] `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` (optional — hides the button when unset) + - [x] `GITEA_URL`, `GITEA_CLIENT_ID`, `GITEA_CLIENT_SECRET` (optional — same) + - [x] `CULL_AFTER_DAYS` (default `30`) + - [x] `LOG_LEVEL` (default `info`) ## Cross-cutting conventions @@ -61,9 +61,9 @@ Backend is a single Go module `solopm.com/solopm-server`, one binary, serving th - [ ] **Logging**: `log/slog`, JSON in production, request-id middleware; log every request (method, path, status, duration, user id). - [ ] **Validation**: in services, not handlers; titles trimmed/non-empty; markdown bodies size-capped (64 KB). - [ ] **Time/date fields**: `start_date`/`target_date` are `date` (no time component); `completed_at` is `timestamptz` set by status automation. -- [ ] **Graceful shutdown**: on SIGINT/SIGTERM stop accepting requests, close SSE hub, drain dispatcher + job workers (10s cap), close pgx pool. -- [ ] **SPA serving**: `go:embed web/dist`; any non-`/api`, non-`/webhooks`, non-`/files` GET serves `index.html` (deep links work). -- [ ] **Makefile targets**: `make dev` (Vite + Go with reload), `make build` (pnpm build → go build), `make test`, `make sqlc`, `make migrate-up/down`, `make lint`. +- [x] **Graceful shutdown**: on SIGINT/SIGTERM stop accepting requests, close SSE hub, drain dispatcher + job workers (10s cap), close pgx pool. *(SSE hub/dispatcher/job workers land in later phases; server shutdown + pool close done in Phase 0)* +- [x] **SPA serving**: `go:embed web/dist`; any non-`/api`, non-`/webhooks`, non-`/files` GET serves `index.html` (deep links work). +- [x] **Makefile targets**: `make dev` (Vite + Go with reload), `make build` (pnpm build → go build), `make test`, `make sqlc`, `make migrate-up/down`, `make lint`. *(`make dev` runs both servers concurrently; no file-watcher/hot-reload for the Go side yet)* - [ ] **Tests**: table-driven unit tests for services (cycle detection, numbering, fan-out rules); integration tests against a real Postgres (dockertest or `TEST_DATABASE_URL`); no mocking of sqlc stores. ## Middleware chain (chi) diff --git a/plans/15-deployment.md b/plans/15-deployment.md index c5acce4..bd3983f 100644 --- a/plans/15-deployment.md +++ b/plans/15-deployment.md @@ -21,7 +21,7 @@ Two supported deployment paths from day one: **bare binary** (user supplies Post ## docker-compose (reference) - [ ] `docker-compose.yml` services: - - [ ] `app` — the SoloPM image; ports `4080`; env wired to the two services; volumes: `uploads:/data/uploads`; depends_on healthy postgres + meilisearch + - [ ] `app` — the SoloPM image; ports `8080`; env wired to the two services; volumes: `uploads:/data/uploads`; depends_on healthy postgres + meilisearch - [ ] `postgres` — postgres:16-alpine; volume `pgdata`; healthcheck `pg_isready` - [ ] `meilisearch` — getmeili/meilisearch; volume `meilidata`; `MEILI_MASTER_KEY` from env - [ ] (dev profile) `mailhog` — SMTP capture for local testing diff --git a/plans/16-phases.md b/plans/16-phases.md index 420bf31..01d53de 100644 --- a/plans/16-phases.md +++ b/plans/16-phases.md @@ -8,19 +8,19 @@ Dependency graph: `0 → 1 → 2 → 3 → 4 → 5 → 6 → {7, 8} → 9 → 10 Spec files: [01-architecture.md](01-architecture.md), [14-design-system.md](14-design-system.md), [15-deployment.md](15-deployment.md) -- [ ] Repo skeleton per architecture layout; Go module `solopm.com/solopm-server` -- [ ] chi server + middleware chain (minus auth resolver), slog, request logging, graceful shutdown -- [ ] Config loader with full env var set + `.env.example` -- [ ] golang-migrate wired (embedded migrations) + migration 0001 (extensions + enums); sqlc configured and generating -- [ ] Vue app scaffolded (Vite, TS, Pinia, Router, Tailwind) with design system copied to `web/design-system/` and wired; AppShell (sidebar/navbar) renders -- [ ] `go:embed` SPA serving with deep-link fallback; Vite dev proxy -- [ ] docker-compose (app + postgres + meilisearch + mailhog dev profile); Makefile targets -- [ ] `/healthz` +- [x] Repo skeleton per architecture layout; Go module `solopm.com/solopm-server` +- [x] chi server + middleware chain (minus auth resolver), slog, request logging, graceful shutdown +- [x] Config loader with full env var set + `.env.example` +- [x] golang-migrate wired (embedded migrations) + migration 0001 (extensions + enums); sqlc configured and generating +- [x] Vue app scaffolded (Vite, TS, Pinia, Router, Tailwind) with design system copied to `web/design-system/` and wired; AppShell (sidebar/navbar) renders +- [x] `go:embed` SPA serving with deep-link fallback; Vite dev proxy +- [x] docker-compose (app + postgres + meilisearch + mailhog dev profile); Makefile targets +- [x] `/healthz` Acceptance: -- [ ] `docker compose up` serves the SPA shell at `:4080`; `/healthz` reports db ok -- [ ] `make migrate-up && make migrate-down` cycles cleanly -- [ ] Shell passes the design-system conformity checklist +- [ ] `docker compose up` serves the SPA shell at `:8080`; `/healthz` reports db ok — not verified: no `docker` CLI available in this environment. Functionally equivalent path verified instead: `go run ./cmd/solopm serve` against the real dockerized postgres/meilisearch returned SPA 200 and `/healthz` `{"database":"ok","meilisearch":"ok","smtp":"absent"}`. Someone with docker access should run the literal command once to close this out. +- [x] `make migrate-up && make migrate-down` cycles cleanly +- [x] Shell passes the design-system conformity checklist ## Phase 1 — Auth & users diff --git a/sqlc.yaml b/sqlc.yaml new file mode 100644 index 0000000..e42258f --- /dev/null +++ b/sqlc.yaml @@ -0,0 +1,13 @@ +version: "2" +sql: + - engine: "postgresql" + queries: "internal/db/queries" + schema: "internal/db/migrations" + gen: + go: + package: "sqlcgen" + out: "internal/db/sqlcgen" + sql_package: "pgx/v5" + emit_json_tags: true + emit_interface: true + emit_empty_slices: true diff --git a/tmp/build-errors.log b/tmp/build-errors.log new file mode 100644 index 0000000..bcbe330 --- /dev/null +++ b/tmp/build-errors.log @@ -0,0 +1 @@ +exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1 \ No newline at end of file diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/web/.vscode/extensions.json b/web/.vscode/extensions.json new file mode 100644 index 0000000..a7cea0b --- /dev/null +++ b/web/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["Vue.volar"] +} diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..33895ab --- /dev/null +++ b/web/README.md @@ -0,0 +1,5 @@ +# Vue 3 + TypeScript + Vite + +This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 ` + +