Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions backend-api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,10 @@ DATABASE_URL=postgres://civiclens:password@localhost:5432/civiclens_dev?sslmode=
# ─── Auth ─────────────────────────────────────────────────────────────────────
# Generate with: openssl rand -hex 32
JWT_SECRET=change-me-in-production-use-a-long-random-secret

# ─── Initial Accounts & Auto-Seeding ──────────────────────────────────────────
INITIAL_ADMIN_EMAIL=admin@civiclens.org
INITIAL_ADMIN_PASSWORD=AdminSecurePass123!
INITIAL_USER_EMAIL=user@civiclens.org
INITIAL_USER_PASSWORD=UserSecurePass123!

27 changes: 14 additions & 13 deletions backend-api/.golangci.yml
Comment thread
specfor marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
version: "2"

# golangci-lint configuration for CivicLens backend API
# Documentation: https://golangci-lint.run/usage/configuration/

run:
timeout: 5m
go: "1.22"

output:
formats:
- format: colored-line-number
print-issued-lines: true
print-linter-name: true
# output:
# format: colored-line-number
# print-issued-lines: true
# print-linter-name: true

linters:
enable:
Expand All @@ -23,11 +24,11 @@ linters:
- staticcheck # Comprehensive static analysis
- unused # Finds unused code
- ineffassign # Detects ineffectual assignments
- deadcode # Finds unreachable code
# - deadcode # Finds unreachable code

# Style & conventions
- gofmt # Enforces gofmt formatting
- goimports # Enforces import grouping
# - gofmt # Enforces gofmt formatting
# - goimports # Enforces import grouping
- misspell # Catches common misspellings
- godot # Checks that comments end with a period
- revive # Fast, configurable, extensible linter
Expand All @@ -49,11 +50,11 @@ linters:
- gochecknoglobals # Allows global vars for now

linters-settings:
gofmt:
simplify: true

goimports:
local-prefixes: github.com/civiclens
# gofmt:
# simplify: true
#
# goimports:
# local-prefixes: github.com/civiclens

errcheck:
check-type-assertions: true
Expand Down
55 changes: 43 additions & 12 deletions backend-api/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -4,46 +4,62 @@
# Usage:
# make run – start the development server
# make test – run all tests with race detection
# make test-v – run tests with verbose output
Comment on lines 4 to +7
# make lint – run golangci-lint
# make fmt – format all Go files
# make vet – run go vet
# make tidy – tidy go module dependencies
# make sqlc-gen – regenerate type-safe DB code from SQL files
# make build – produce a production binary
# make clean – remove build artifacts
# ────────────────────────────────────────────────────────────────────────────

.PHONY: all run build test lint fmt sqlc-gen clean tidy help
.PHONY: all run build test test-v lint fmt vet tidy sqlc-gen clean help

# Default goal
all: fmt lint test build

BINARY_NAME := civiclens-api
BUILD_DIR := ./bin

# Prevent Go from auto-downloading a newer toolchain triggered by transitive deps
export GOTOOLCHAIN := local

# OS detection for cross-platform support
ifeq ($(OS),Windows_NT)
BINARY_EXT := .exe
else
BINARY_EXT :=
endif

BINARY_NAME := civiclens-api$(BINARY_EXT)
BUILD_DIR := bin
BINARY_PATH := $(BUILD_DIR)/$(BINARY_NAME)

# ── Development ──────────────────────────────────────────────────────────────

## run: Start the API server using environment variables from .env
run:
go run ./cmd/server

## build: Compile a production binary to ./bin/
## build: Compile a production binary to bin/
build: export CGO_ENABLED := 0
build:
ifeq ($(OS),Windows_NT)
@if not exist $(BUILD_DIR) mkdir $(BUILD_DIR)
else
@mkdir -p $(BUILD_DIR)
CGO_ENABLED=0 go build -ldflags="-s -w" -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/server
@echo "Binary written to $(BUILD_DIR)/$(BINARY_NAME)"
endif
go build -ldflags="-s -w" -o $(BINARY_PATH) ./cmd/server
@echo Binary written to $(BINARY_PATH)

# ── Testing ───────────────────────────────────────────────────────────────────

## test: Run all tests with race detection and coverage report
## test: Run all tests with coverage report
test:
go test -race -coverprofile=coverage.out -covermode=atomic ./...
go test -coverprofile=coverage.out -covermode=atomic ./...
go tool cover -func=coverage.out

## test-v: Run tests with verbose output
test-v:
go test -race -v ./...
go test -v ./...

# ── Code Quality ─────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -74,10 +90,25 @@ sqlc-gen:

## clean: Remove build artifacts and coverage reports
clean:
rm -rf $(BUILD_DIR) coverage.out
ifeq ($(OS),Windows_NT)
@if exist $(BUILD_DIR) rmdir /s /q $(BUILD_DIR)
@if exist coverage.out del /q coverage.out
else
@rm -rf $(BUILD_DIR) coverage.out
endif

# ── Help ─────────────────────────────────────────────────────────────────────

## help: Display this help message
help:
@grep -E '^## ' $(MAKEFILE_LIST) | sed 's/^## //'
@echo Usage:
@echo make run - start the development server
@echo make test - run all tests with race detection
@echo make test-v - run tests with verbose output
@echo make lint - run golangci-lint
@echo make fmt - format all Go files
@echo make vet - run go vet
@echo make tidy - tidy go module dependencies
@echo make sqlc-gen - regenerate type-safe DB code from SQL files
@echo make build - produce a production binary
@echo make clean - remove build artifacts
4 changes: 3 additions & 1 deletion backend-api/cmd/server/main.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
// Package main is the entrypoint for the CivicLens backend API.
package main

import (
"context"
"errors"
"fmt"
"log"
"net/http"
Expand Down Expand Up @@ -36,7 +38,7 @@ func main() {
serverAddr := fmt.Sprintf(":%s", cfg.Port)
go func() {
log.Printf("starting server on %s", serverAddr)
if err := e.Start(serverAddr); err != nil && err != http.ErrServerClosed {
if err := e.Start(serverAddr); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server error: %v", err)
}
}()
Expand Down
1 change: 1 addition & 0 deletions backend-api/coverage
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
mode: atomic
18 changes: 14 additions & 4 deletions backend-api/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ type Config struct {

// Auth
JWTSecret string

// Initial Accounts & Auto-Seeding
InitialAdminEmail string
InitialAdminPassword string
InitialUserEmail string
InitialUserPassword string
}

// Load reads configuration from environment variables.
Expand All @@ -28,10 +34,14 @@ func Load() (*Config, error) {
_ = godotenv.Load()

cfg := &Config{
Port: getEnv("PORT", "8080"),
Environment: getEnv("ENVIRONMENT", "development"),
DatabaseURL: os.Getenv("DATABASE_URL"),
JWTSecret: os.Getenv("JWT_SECRET"),
Port: getEnv("PORT", "8080"),
Environment: getEnv("ENVIRONMENT", "development"),
DatabaseURL: os.Getenv("DATABASE_URL"),
JWTSecret: os.Getenv("JWT_SECRET"),
InitialAdminEmail: getEnv("INITIAL_ADMIN_EMAIL", "admin@civiclens.org"),
InitialAdminPassword: getEnv("INITIAL_ADMIN_PASSWORD", "AdminPass123!"),
InitialUserEmail: getEnv("INITIAL_USER_EMAIL", "user@civiclens.org"),
InitialUserPassword: getEnv("INITIAL_USER_PASSWORD", "UserPass123!"),
}
Comment on lines +41 to 45

if err := cfg.validate(); err != nil {
Expand Down
64 changes: 64 additions & 0 deletions backend-api/internal/config/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package config

import (
"os"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestConfig_LoadSuccess(t *testing.T) {
// Set required environment variables
t.Setenv("DATABASE_URL", "postgres://user:pass@localhost:5432/testdb")
t.Setenv("JWT_SECRET", "super-secret-jwt-key")
t.Setenv("INITIAL_ADMIN_EMAIL", "admin@civiclens.org")
t.Setenv("INITIAL_ADMIN_PASSWORD", "AdminSecret123!")
t.Setenv("INITIAL_USER_EMAIL", "user@civiclens.org")
t.Setenv("INITIAL_USER_PASSWORD", "UserSecret123!")

cfg, err := Load()
require.NoError(t, err)
assert.Equal(t, "postgres://user:pass@localhost:5432/testdb", cfg.DatabaseURL)
assert.Equal(t, "super-secret-jwt-key", cfg.JWTSecret)
assert.Equal(t, "admin@civiclens.org", cfg.InitialAdminEmail)
assert.Equal(t, "AdminSecret123!", cfg.InitialAdminPassword)
assert.Equal(t, "user@civiclens.org", cfg.InitialUserEmail)
assert.Equal(t, "UserSecret123!", cfg.InitialUserPassword)
}

func TestConfig_DefaultAdminAndUser(t *testing.T) {
t.Setenv("DATABASE_URL", "postgres://user:pass@localhost:5432/testdb")
t.Setenv("JWT_SECRET", "super-secret-jwt-key")
_ = os.Unsetenv("INITIAL_ADMIN_EMAIL")
_ = os.Unsetenv("INITIAL_ADMIN_PASSWORD")
_ = os.Unsetenv("INITIAL_USER_EMAIL")
_ = os.Unsetenv("INITIAL_USER_PASSWORD")

cfg, err := Load()
require.NoError(t, err)
assert.Equal(t, "admin@civiclens.org", cfg.InitialAdminEmail)
assert.NotEmpty(t, cfg.InitialAdminPassword)
assert.Equal(t, "user@civiclens.org", cfg.InitialUserEmail)
assert.NotEmpty(t, cfg.InitialUserPassword)
}

func TestConfig_ValidationMissingDatabaseURL(t *testing.T) {
_ = os.Unsetenv("DATABASE_URL")
t.Setenv("JWT_SECRET", "super-secret-jwt-key")

cfg, err := Load()
assert.Error(t, err)
assert.Nil(t, cfg)
assert.Contains(t, err.Error(), "DATABASE_URL is required")
}

func TestConfig_ValidationMissingJWTSecret(t *testing.T) {
t.Setenv("DATABASE_URL", "postgres://user:pass@localhost:5432/testdb")
_ = os.Unsetenv("JWT_SECRET")

cfg, err := Load()
assert.Error(t, err)
assert.Nil(t, cfg)
assert.Contains(t, err.Error(), "JWT_SECRET is required")
}
8 changes: 6 additions & 2 deletions backend-api/internal/handler/health.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package handler

import (
"fmt"
"net/http"
"time"

Expand All @@ -17,9 +18,12 @@ type healthResponse struct {
// Health handles GET /health.
// It returns a simple liveness check payload — useful for container orchestrators.
func (h *Handler) Health(c echo.Context) error {
return c.JSON(http.StatusOK, healthResponse{
if err := c.JSON(http.StatusOK, healthResponse{
Status: "ok",
Timestamp: time.Now().UTC(),
Service: "civiclens-api",
})
}); err != nil {
return fmt.Errorf("failed to encode health response: %w", err)
}
return nil
}
6 changes: 3 additions & 3 deletions backend-api/sqlc/sqlc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ version: "2"

sql:
- engine: "postgresql"
schema: "sqlc/schema/"
queries: "sqlc/queries/"
schema: "schema/"
queries: "queries/"
gen:
go:
package: "sqlcdb"
out: "internal/db/sqlcdb"
out: "../internal/db/sqlcdb"
# Use pgx/v5 for all emit options
sql_package: "pgx/v5"
emit_json_tags: true
Expand Down