diff --git a/Dockerfile b/Dockerfile index f806404..7f49701 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.26-alpine AS build +FROM golang:1.27-alpine AS build RUN apk add --no-cache make git diff --git a/api.http b/api.http new file mode 100644 index 0000000..b841c5c --- /dev/null +++ b/api.http @@ -0,0 +1,72 @@ +### POST login +POST http://localhost:8080/api/v1/login +Content-Type: application/json + +{ + "email": "admin@example.com", + "password": "12345" +} + +> {% client.global.set("access_token", response.body.access_token); %} + +### + +POST http://localhost:8080/api/v1/lists +Content-Type: application/json +Authorization: Bearer {{access_token}} + +{ + "name": "test_list" +} + +### + +GET http://localhost:8080/api/v1/lists +Content-Type: application/json +Authorization: Bearer {{access_token}} + +{ +} + +### + +GET http://localhost:8080/api/v1/lists/e817465a-d75d-420b-9d4a-610496ed3bc7 +Content-Type: application/json +Authorization: Bearer {{access_token}} + +{ +} + + +### + +POST http://localhost:8080/api/v1/lists/item +Content-Type: application/json +Authorization: Bearer {{access_token}} + +{ + "list_id": "cd2c280a-05fa-4ea0-b350-0d3f972b9777", + "title": "hello" +} + +### + +PUT http://localhost:8080/api/v1/lists/item +Content-Type: application/json +Authorization: Bearer {{access_token}} + +{ + "list_item_id": "3b0666b0-bfc5-41e2-8c22-e3437da40298", + "title": "hello 2", + "is_completed": true +} + +### + +POST http://localhost:8080/api/v1/lists/items/3b0666b0-bfc5-41e2-8c22-e3437da40298 +Content-Type: application/json +Authorization: Bearer {{access_token}} + +{ + "is_completed": false +} diff --git a/cmd/bootstrap/main.go b/cmd/bootstrap/main.go index 6aa38ca..1d087c7 100644 --- a/cmd/bootstrap/main.go +++ b/cmd/bootstrap/main.go @@ -11,6 +11,7 @@ import ( "github.com/joho/godotenv" "github.com/robindittmar/dttmr-api/internal/config" "github.com/robindittmar/dttmr-api/internal/database" + "github.com/robindittmar/dttmr-api/internal/database/migrations" "github.com/robindittmar/dttmr-api/internal/domain" "github.com/robindittmar/dttmr-api/internal/repository" ) @@ -51,7 +52,7 @@ func main() { } }() - if err := database.RunMigrations(db); err != nil { + if err := database.RunMigrations(db, migrations.MigrationFS); err != nil { slog.Error("failed to run migrations", slog.Any("error", err)) os.Exit(1) } diff --git a/go.mod b/go.mod index 0f9f902..ed5c78d 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/robindittmar/dttmr-api -go 1.26 +go 1.27 require ( github.com/DATA-DOG/go-sqlmock v1.5.2 @@ -8,21 +8,20 @@ require ( github.com/golang-migrate/migrate/v4 v4.19.1 github.com/jackc/pgx/v5 v5.10.0 github.com/joho/godotenv v1.5.1 - github.com/stretchr/testify v1.11.1 - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 - go.opentelemetry.io/otel v1.45.0 - go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.45.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0 - go.opentelemetry.io/otel/sdk v1.45.0 - go.opentelemetry.io/otel/sdk/metric v1.45.0 - go.opentelemetry.io/otel/trace v1.45.0 + github.com/stretchr/testify v1.12.1 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 + go.opentelemetry.io/otel v1.46.0 + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.46.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0 + go.opentelemetry.io/otel/sdk v1.46.0 + go.opentelemetry.io/otel/sdk/metric v1.46.0 + go.opentelemetry.io/otel/trace v1.46.0 golang.org/x/crypto v0.55.0 ) require ( github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/felixge/httpsnoop v1.1.0 // indirect github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -32,19 +31,18 @@ require ( github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/lib/pq v1.12.3 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/stretchr/objx v0.5.2 // indirect + github.com/stretchr/objx v0.5.3 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 // indirect - go.opentelemetry.io/otel/metric v1.45.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect + go.opentelemetry.io/otel/metric v1.46.0 // indirect go.opentelemetry.io/proto/otlp v1.11.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/net v0.58.0 // 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 - google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect - google.golang.org/grpc v1.83.1 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260825221802-da73d73af1c5 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 // indirect + google.golang.org/grpc v1.83.2 // indirect google.golang.org/protobuf v1.36.12 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/internal/database/migrate.go b/internal/database/migrate.go index 70cfa27..93bf54f 100644 --- a/internal/database/migrate.go +++ b/internal/database/migrate.go @@ -2,9 +2,9 @@ package database import ( "database/sql" - "embed" "errors" "fmt" + "io/fs" "log/slog" "github.com/golang-migrate/migrate/v4" @@ -12,11 +12,8 @@ import ( "github.com/golang-migrate/migrate/v4/source/iofs" ) -//go:embed migrations/*.sql -var migrationFS embed.FS - -func RunMigrations(db *sql.DB) error { - sourceDriver, err := iofs.New(migrationFS, "migrations") +func RunMigrations(db *sql.DB, migrationFS fs.FS) error { + sourceDriver, err := iofs.New(migrationFS, ".") if err != nil { return fmt.Errorf("failed to load embedded migrations: %w", err) } diff --git a/internal/database/migrations/000003_create_workouts.down.sql b/internal/database/migrations/000003_create_workouts.down.sql new file mode 100644 index 0000000..ff32d5a --- /dev/null +++ b/internal/database/migrations/000003_create_workouts.down.sql @@ -0,0 +1,5 @@ +DROP TABLE IF EXISTS workout_sets; +DROP TABLE IF EXISTS workouts; +DROP TABLE IF EXISTS template_exercises; +DROP TABLE IF EXISTS templates; +DROP TABLE IF EXISTS exercises; diff --git a/internal/database/migrations/000003_create_workouts.up.sql b/internal/database/migrations/000003_create_workouts.up.sql new file mode 100644 index 0000000..29b708c --- /dev/null +++ b/internal/database/migrations/000003_create_workouts.up.sql @@ -0,0 +1,101 @@ +CREATE TABLE exercises ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + equipment TEXT[] NOT NULL DEFAULT '{}' + CHECK ( + equipment <@ ARRAY [ + 'floor', + 'rings', + 'pull_up_bar', + 'parallel_bars', + 'low_bar', + 'parallettes', + 'resistance_band' + ]::TEXT[]), + metric TEXT NOT NULL DEFAULT 'reps' + CHECK (metric IN ('reps', 'seconds')), + load TEXT NOT NULL DEFAULT 'bodyweight' + CHECK (load IN ('bodyweight', 'absolute')), + tags TEXT[] NOT NULL DEFAULT '{}', -- push/pull/legs/core/skill/shoulders/... + notes TEXT, + hidden BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + modified_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX exercises_name_key ON exercises (lower(name)); +CREATE INDEX exercises_equipment_idx ON exercises USING gin (equipment); +CREATE INDEX exercises_tags_idx ON exercises USING gin (tags); + + +CREATE TABLE templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE template_exercises ( + template_id UUID NOT NULL REFERENCES templates(id) ON DELETE CASCADE, + exercise_id UUID NOT NULL REFERENCES exercises(id) ON DELETE CASCADE, + position SMALLINT NOT NULL, + target TEXT, + + PRIMARY KEY (template_id, exercise_id) +); + + +CREATE TABLE workouts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + template_id UUID REFERENCES templates(id) ON DELETE SET NULL, + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + ended_at TIMESTAMPTZ, + rpe smallint CHECK (rpe BETWEEN 1 AND 10), + bodyweight_kg NUMERIC(5,2), + notes TEXT +); + +CREATE INDEX workouts_time_idx ON workouts (started_at DESC); + + +CREATE TABLE workout_sets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workout_id UUID NOT NULL REFERENCES workouts(id) ON DELETE CASCADE, + exercise_id UUID NOT NULL REFERENCES exercises(id) ON DELETE RESTRICT, + logged_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + reps SMALLINT, + seconds SMALLINT, + weight_kg NUMERIC(5,2), + note TEXT, + + CONSTRAINT set_has_number CHECK (reps IS NOT NULL OR seconds IS NOT NULL) +); + +CREATE INDEX sets_workout_idx ON workout_sets (workout_id, logged_at); +CREATE INDEX sets_exercise_ifx ON workout_sets (exercise_id, logged_at DESC); + +INSERT INTO exercises (name, metric, load, tags, equipment) VALUES + ('Push-up', 'reps', 'bodyweight', '{push,chest,triceps}', '{floor,rings,parallettes}'), + ('Diamond push-up', 'reps', 'bodyweight', '{push,triceps}', '{floor,rings,parallettes}'), + ('Pseudo planche push-up','reps', 'bodyweight', '{push,shoulders,skill}', '{floor,rings,parallettes}'), + ('Pike push-up', 'reps', 'bodyweight', '{push,shoulders}', '{floor,rings,parallettes}'), + ('Dip', 'reps', 'bodyweight', '{push,chest,triceps}', '{rings,parallel_bars}'), + ('Handstand hold', 'seconds', 'bodyweight', '{push,shoulders,skill}', '{floor,rings,parallettes}'), + ('Handstand Push-up', 'reps', 'bodyweight', '{push,shoulders,skill}', '{floor,rings,parallettes}'), + ('Pull-up', 'reps', 'bodyweight', '{pull,back,biceps}', '{rings,pull_up_bar}'), + ('Chin-up', 'reps', 'bodyweight', '{pull,back,biceps}', '{rings,pull_up_bar}'), + ('Inverted row', 'reps', 'bodyweight', '{pull,back}', '{rings,parallel_bars,low_bar}'), + ('Muscle-up', 'reps', 'bodyweight', '{pull,push,skill}', '{rings,pull_up_bar}'), + ('Dead hang', 'seconds', 'bodyweight', '{pull,grip}', '{rings,pull_up_bar}'), + ('Front lever hold', 'seconds', 'bodyweight', '{pull,core,skill}', '{rings,pull_up_bar,low_bar}'), + ('Pistol squat', 'reps', 'bodyweight', '{legs}', '{floor}'), + ('Bulgarian split squat','reps', 'bodyweight', '{legs}', '{floor}'), + ('Nordic curl', 'reps', 'bodyweight', '{legs,hamstrings}', '{floor}'), + ('Calf raise', 'reps', 'bodyweight', '{legs}', '{floor}'), + ('Plank', 'seconds', 'bodyweight', '{core}', '{floor}'), + ('Hollow body hold', 'seconds', 'bodyweight', '{core}', '{floor}'), + ('L-sit', 'seconds', 'bodyweight', '{core,skill}', '{floor,parallettes,parallel_bars}'), + ('Hanging leg raise', 'reps', 'bodyweight', '{core}', '{rings,pull_up_bar}'), + ('Face pull', 'reps', 'bodyweight', '{pull,shoulders}', '{rings,resistance_band}'), + ('Lateral raise', 'reps', 'absolute', '{shoulders,isolation}', '{floor}'); diff --git a/internal/database/migrations/embed.go b/internal/database/migrations/embed.go new file mode 100644 index 0000000..2d1fab3 --- /dev/null +++ b/internal/database/migrations/embed.go @@ -0,0 +1,6 @@ +package migrations + +import "embed" + +//go:embed *.sql +var MigrationFS embed.FS diff --git a/internal/domain/auth.go b/internal/domain/auth.go index 68754a0..eb86bc5 100644 --- a/internal/domain/auth.go +++ b/internal/domain/auth.go @@ -149,11 +149,11 @@ func (s *AuthService) issueTokens(ctx context.Context, authUser *AuthUser) (Toke func (s *AuthService) GenerateAccessToken(authUser *AuthUser) (string, error) { claims := JWTClaims{ - UserID: authUser.ID, - Email: authUser.Email, - Name: authUser.Name, + UserID: authUser.ID, + Email: authUser.Email, + Name: authUser.Name, + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute * 15)), } - claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(time.Minute * 15)) token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)