phase 0 complete

This commit is contained in:
2026-08-14 11:29:11 -06:00
parent 4da16c7573
commit 85df9a208e
102 changed files with 25019 additions and 30 deletions
+139
View File
@@ -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
}
+96
View File
@@ -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")
}
}
+73
View File
@@ -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
}
@@ -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;
@@ -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');
+2
View File
@@ -0,0 +1,2 @@
-- name: Ping :one
SELECT 1::int AS ok;
+32
View File
@@ -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,
}
}
+503
View File
@@ -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
}
+15
View File
@@ -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)
+21
View File
@@ -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
}
+75
View File
@@ -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)
}
}
+59
View File
@@ -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)
}
})
}
+30
View File
@@ -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()),
)
})
}
}
+59
View File
@@ -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{},
})
}
+37
View File
@@ -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
}
+48
View File
@@ -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("<!doctype html><div id=app></div>")},
"assets/app.js": {Data: []byte("console.log('app')")},
"favicon.svg": {Data: []byte("<svg></svg>")},
}
}
func TestSPAHandler(t *testing.T) {
cases := []struct {
name string
path string
wantBody string
}{
{"root serves index", "/", "<!doctype html><div id=app></div>"},
{"real file served as-is", "/assets/app.js", "console.log('app')"},
{"real top-level file served as-is", "/favicon.svg", "<svg></svg>"},
{"deep link falls back to index", "/projects/42/board", "<!doctype html><div id=app></div>"},
{"unknown asset path falls back to index", "/assets/missing.js", "<!doctype html><div id=app></div>"},
}
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)
}
})
}
}