diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..8e40293 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,40 @@ +name: Build and Release +run-name: ${{ gitea.actor }} build 🚀 + +on: + push: + branches: + - master + +jobs: + release: + runs-on: ubuntu-latest + container: + image: azenkain/go-node:latest + + steps: + - uses: actions/checkout@v4 + + - name: Create .env file + run: | + mkdir -p assets/resources + printf "%s" "${{ secrets.ENV_FILE }}" > assets/resources/.env + + - name: Download Go dependencies + run: go mod download + + - name: Build for linux (embed env) + run: GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o build/history-api ./cmd/history-api + + - name: Stop and remove old containers + run: | + docker compose down || true + + - name: Remove unused Docker resources + run: | + docker system prune -a --volumes -f + + - name: Build and restart containers + run: | + docker compose pull + docker compose --env-file ./assets/resources/.env up -d --build \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5da90ee..39918c2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,6 @@ pg_data .idea *.log -*.env \ No newline at end of file +*.env +*.env.dev +build \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..20ce1ab --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM alpine:latest + +RUN apk --no-cache add ca-certificates tzdata +ENV TZ=Asia/Ho_Chi_Minh + +WORKDIR /app + +COPY build/history-api . +COPY data ./data + +EXPOSE 3344 + +CMD ["./history-api"] \ No newline at end of file diff --git a/Makefile b/Makefile index e64df13..3c42e40 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,10 @@ DB_URL ?= postgres://history:secret@localhost:5432/history_map?sslmode=disable -APP = cmd/history-api/ +APP_DIR = cmd/history-api +MAIN_APP = ./cmd/history-api/ +MAIN_FILE = $(APP_DIR)/main.go +DOCS_DIR = docs -.PHONY: postgres createdb dropdb migrate-up migrate-down migrate-reset sqlc run build dev +.PHONY: migrate-up migrate-down migrate-reset swagger sqlc run build dev migrate-up: migrate -path db/migrations -database "$(DB_URL)" up @@ -13,13 +16,18 @@ migrate-reset: migrate -path db/migrations -database "$(DB_URL)" drop -f migrate -path db/migrations -database "$(DB_URL)" up +swagger: + @echo "=> Generating Swagger docs..." + swag init -g $(MAIN_FILE) -o $(DOCS_DIR) --parseDependency --parseInternal + @echo "=> Swagger docs generated at $(DOCS_DIR)" + sqlc: sqlc generate run: - go run $(APP) + @set GOARCH=amd64& set CGO_ENABLED=0&go run $(MAIN_APP) build: - go build -o app $(APP) + @set GOOS=linux& set GOARCH=amd64& set CGO_ENABLED=0&go build -trimpath -ldflags="-s -w" -o build/history-api $(MAIN_APP) -dev: sqlc migrate-up run \ No newline at end of file +dev: swagger sqlc migrate-up run \ No newline at end of file diff --git a/cmd/history-api/main.go b/cmd/history-api/main.go index c1c2e7e..5c423cf 100644 --- a/cmd/history-api/main.go +++ b/cmd/history-api/main.go @@ -2,18 +2,16 @@ package main import ( "context" - // _ "history-api/docs" - "history-api/internal/gen/sqlc" + "fmt" + _ "history-api/docs" "history-api/pkg/cache" "history-api/pkg/config" - _ "history-api/pkg/log" - - "fmt" "history-api/pkg/database" + _ "history-api/pkg/log" + "history-api/pkg/mbtiles" "os/signal" "syscall" "time" - "github.com/rs/zerolog/log" ) @@ -46,15 +44,21 @@ func StartServer() { log.Error().Msg(err.Error()) panic(err) } - pool, err := database.Connect() - if err != nil { + poolPg, err := database.NewPostgresqlDB() + if err != nil { log.Error().Msg(err.Error()) panic(err) } - defer pool.Close() - queries := sqlc.New(pool) + defer poolPg.Close() - err = cache.Connect() + sqlTile, err := mbtiles.NewMBTilesDB("data/map.mbtiles") + if err != nil { + log.Error().Msg(err.Error()) + panic(err) + } + defer sqlTile.Close() + + redisClient, err := cache.NewRedisClient() if err != nil { log.Error().Msg(err.Error()) panic(err) @@ -71,7 +75,7 @@ func StartServer() { } serverHttp := NewHttpServer() - serverHttp.RegisterFiberRoutes() + serverHttp.SetupServer(poolPg, sqlTile, redisClient) Singleton = serverHttp done := make(chan bool, 1) @@ -90,15 +94,25 @@ func StartServer() { log.Info().Msg("Graceful shutdown complete.") } -// @title Firefly Manager API -// @version 1.0 -// @description API to update Firefly Manager data -// @host localhost:3344 -// @BasePath / +// @title History API +// @version 1.0 +// @description This is a sample server for History API. +// @termsOfService http://swagger.io/terms/ + +// @contact.name API Support +// @contact.url http://www.swagger.io/support +// @contact.email support@swagger.io + +// @license.name Apache 2.0 +// @license.url http://www.apache.org/licenses/LICENSE-2.0.html + +// @host localhost:3344 +// @BasePath / // @securityDefinitions.apikey BearerAuth // @in header // @name Authorization +// @description Type "Bearer " followed by a space and JWT token. func main() { StartServer() } diff --git a/cmd/history-api/server.go b/cmd/history-api/server.go index 34a17a2..80443d2 100644 --- a/cmd/history-api/server.go +++ b/cmd/history-api/server.go @@ -1,13 +1,22 @@ package main import ( - // "history-api/internal/routes" - // "history-api/internal/services" + "database/sql" + _ "embed" + "history-api/docs" + "history-api/internal/controllers" + "history-api/internal/gen/sqlc" + "history-api/internal/repositories" + "history-api/internal/routes" + "history-api/internal/services" + "history-api/pkg/cache" + "os" swagger "github.com/gofiber/contrib/v3/swaggerui" + middleware "github.com/gofiber/contrib/v3/zerolog" "github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3/middleware/cors" - "github.com/gofiber/fiber/v3/middleware/logger" + "github.com/rs/zerolog" ) var ( @@ -27,17 +36,21 @@ func NewHttpServer() *FiberServer { } cfg := swagger.Config{ BasePath: "/", - FilePath: "./docs/swagger.json", + FileContent: docs.SwaggerJSON, Path: "swagger", Title: "Swagger API Docs", } server.App.Use(swagger.New(cfg)) - server.App.Use(logger.New()) + + logger := zerolog.New(os.Stderr).With().Timestamp().Logger() + server.App.Use(middleware.New(middleware.Config{ + Logger: &logger, + })) return server } -func (s *FiberServer) RegisterFiberRoutes() { +func (s *FiberServer) SetupServer(sqlPg sqlc.DBTX, sqlTile *sql.DB, redis cache.Cache) { // Apply CORS middleware s.App.Use(cors.New(cors.Config{ AllowOrigins: []string{"*"}, @@ -47,9 +60,21 @@ func (s *FiberServer) RegisterFiberRoutes() { MaxAge: 300, })) - // routes.UserRoutes(s.App) - // routes.AuthRoutes(s.App) - // routes.MediaRoute(s.App) - // routes.NotFoundRoute(s.App) + // repo setup + userRepo := repositories.NewUserRepository(sqlPg, redis) + roleRepo := repositories.NewRoleRepository(sqlPg, redis) + tileRepo := repositories.NewTileRepository(sqlTile, redis) + // service setup + authService := services.NewAuthService(userRepo, roleRepo) + tileService := services.NewTileService(tileRepo) + + // controller setup + authController := controllers.NewAuthController(authService) + tileController := controllers.NewTileController(tileService) + + // route setup + routes.AuthRoutes(s.App, authController) + routes.TileRoutes(s.App, tileController) + routes.NotFoundRoute(s.App) } diff --git a/data/land.mbtiles b/data/land.mbtiles new file mode 100644 index 0000000..f3af536 Binary files /dev/null and b/data/land.mbtiles differ diff --git a/data/map.mbtiles b/data/map.mbtiles new file mode 100644 index 0000000..97ca6fe Binary files /dev/null and b/data/map.mbtiles differ diff --git a/db/migrations/000001_users.up.sql b/db/migrations/000001_users.up.sql index 0e7fab6..31dd238 100644 --- a/db/migrations/000001_users.up.sql +++ b/db/migrations/000001_users.up.sql @@ -3,7 +3,7 @@ CREATE EXTENSION IF NOT EXISTS postgis; CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY DEFAULT uuidv7(), email TEXT NOT NULL UNIQUE, - password_hash TEXT NOT NULL, + password_hash TEXT, google_id VARCHAR(255) UNIQUE, auth_provider VARCHAR(50) NOT NULL DEFAULT 'local', is_verified BOOLEAN NOT NULL DEFAULT false, diff --git a/db/migrations/000004_verifications.up.sql b/db/migrations/000004_verifications.up.sql index ad0fe82..b9e17dc 100644 --- a/db/migrations/000004_verifications.up.sql +++ b/db/migrations/000004_verifications.up.sql @@ -3,6 +3,7 @@ CREATE TABLE IF NOT EXISTS user_verifications ( user_id UUID REFERENCES users(id) ON DELETE CASCADE, verify_type SMALLINT NOT NULL, -- 1 = ID_CARD, 2 = EDUCATION, 3 = EXPERT document_url TEXT NOT NULL, + is_deleted BOOLEAN NOT NULL DEFAULT false, status SMALLINT NOT NULL DEFAULT 1, -- 1 pending, 2 approved, 3 rejected reviewed_by UUID REFERENCES users(id), reviewed_at TIMESTAMPTZ, @@ -10,9 +11,18 @@ CREATE TABLE IF NOT EXISTS user_verifications ( ); -CREATE INDEX idx_user_verifications_user_id ON user_verifications(user_id); -CREATE INDEX idx_user_verifications_user_type ON user_verifications(user_id, verify_type); -CREATE INDEX idx_user_verifications_status ON user_verifications(status); +CREATE INDEX idx_user_verifications_user_id +ON user_verifications(user_id) +WHERE is_deleted = false; + +CREATE INDEX idx_user_verifications_user_type +ON user_verifications(user_id, verify_type) +WHERE is_deleted = false; + +CREATE INDEX idx_user_verifications_status +ON user_verifications(status) +WHERE is_deleted = false; CREATE INDEX idx_user_verifications_status_created -ON user_verifications(status, created_at DESC); \ No newline at end of file +ON user_verifications(status, created_at DESC) +WHERE is_deleted = false; \ No newline at end of file diff --git a/db/migrations/000005_tokens.up.sql b/db/migrations/000005_tokens.up.sql index 9b13a11..792e052 100644 --- a/db/migrations/000005_tokens.up.sql +++ b/db/migrations/000005_tokens.up.sql @@ -3,18 +3,23 @@ CREATE TABLE IF NOT EXISTS user_tokens ( user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, token VARCHAR(255) NOT NULL UNIQUE, token_type SMALLINT NOT NULL, + is_deleted BOOLEAN NOT NULL DEFAULT false, expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ DEFAULT now() ); CREATE INDEX idx_user_tokens_token -ON user_tokens(token); +ON user_tokens(token) +WHERE is_deleted = false; CREATE INDEX idx_user_tokens_user_id -ON user_tokens(user_id); +ON user_tokens(user_id) +WHERE is_deleted = false; CREATE INDEX idx_user_tokens_type -ON user_tokens(token_type); +ON user_tokens(token_type) +WHERE is_deleted = false; CREATE INDEX idx_user_tokens_expires_at -ON user_tokens(expires_at); \ No newline at end of file +ON user_tokens(expires_at) +WHERE is_deleted = false; \ No newline at end of file diff --git a/db/migrations/000006_entities.up.sql b/db/migrations/000006_entities.up.sql index b6e4072..fb7def1 100644 --- a/db/migrations/000006_entities.up.sql +++ b/db/migrations/000006_entities.up.sql @@ -11,11 +11,38 @@ CREATE TABLE IF NOT EXISTS entities ( description TEXT, thumbnail_url TEXT, status SMALLINT DEFAULT 1, -- 1 draft, 2 published + is_deleted BOOLEAN NOT NULL DEFAULT false, + reviewed_by UUID REFERENCES users(id), + reviewed_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now() ); -CREATE INDEX idx_entities_slug ON entities(slug); +CREATE UNIQUE INDEX uniq_entities_slug_active +ON entities(slug) +WHERE is_deleted = false; + +CREATE INDEX idx_entities_type +ON entities(type_id) +WHERE is_deleted = false; + + +CREATE INDEX idx_entities_status_created +ON entities(status, created_at DESC) +WHERE is_deleted = false; + + +CREATE INDEX idx_entities_type_status +ON entities(type_id, status) +WHERE is_deleted = false; + + +CREATE INDEX idx_entities_reviewed_by +ON entities(reviewed_by) +WHERE is_deleted = false; + +CREATE INDEX idx_entities_name_search +ON entities USING gin (name gin_trgm_ops); CREATE TRIGGER trigger_entities_updated_at BEFORE UPDATE ON entities diff --git a/db/migrations/000007_wiki.up.sql b/db/migrations/000007_wiki.up.sql index 6984dfd..b09273c 100644 --- a/db/migrations/000007_wiki.up.sql +++ b/db/migrations/000007_wiki.up.sql @@ -1,25 +1,20 @@ -CREATE TABLE IF NOT EXISTS wiki_pages ( +CREATE TABLE IF NOT EXISTS wikis ( id UUID PRIMARY KEY DEFAULT uuidv7(), entity_id UUID REFERENCES entities(id) ON DELETE CASCADE, + user_id UUID REFERENCES users(id), title TEXT, + is_deleted BOOLEAN NOT NULL DEFAULT false, + note TEXT, content TEXT, created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now() ); -CREATE TABLE IF NOT EXISTS wiki_versions ( - id UUID PRIMARY KEY DEFAULT uuidv7(), - wiki_id UUID REFERENCES wiki_pages(id) ON DELETE CASCADE, - created_user UUID REFERENCES users(id), - note TEXT, - content TEXT, - created_at TIMESTAMPTZ DEFAULT now(), - approved_at TIMESTAMPTZ -); +CREATE INDEX idx_wiki_entity +ON wikis(entity_id) +WHERE is_deleted = false; -CREATE INDEX idx_wiki_entity ON wiki_pages(entity_id); - -CREATE TRIGGER trigger_wiki_pages_updated_at -BEFORE UPDATE ON wiki_pages +CREATE TRIGGER trigger_wikis_updated_at +BEFORE UPDATE ON wikis FOR EACH ROW EXECUTE FUNCTION update_updated_at(); \ No newline at end of file diff --git a/db/migrations/000008_geometries.up.sql b/db/migrations/000008_geometries.up.sql index 2c8aedd..07b8813 100644 --- a/db/migrations/000008_geometries.up.sql +++ b/db/migrations/000008_geometries.up.sql @@ -1,8 +1,11 @@ +CREATE EXTENSION IF NOT EXISTS btree_gist; + CREATE TABLE IF NOT EXISTS geometries ( id UUID PRIMARY KEY DEFAULT uuidv7(), geom GEOMETRY, -- point / polygon / line time_start INT, time_end INT, + is_deleted BOOLEAN NOT NULL DEFAULT false, bbox GEOMETRY, -- optional created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now() @@ -13,9 +16,11 @@ CREATE TABLE IF NOT EXISTS geo_versions ( geo_id UUID REFERENCES geometries(id) ON DELETE CASCADE, created_user UUID REFERENCES users(id), geom GEOMETRY, + is_deleted BOOLEAN NOT NULL DEFAULT false, note TEXT, - created_at TIMESTAMPTZ DEFAULT now(), - approved_at TIMESTAMPTZ + reviewed_by UUID REFERENCES users(id), + reviewed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT now() ); CREATE TABLE IF NOT EXISTS entity_geometries ( @@ -24,8 +29,33 @@ CREATE TABLE IF NOT EXISTS entity_geometries ( PRIMARY KEY (entity_id, geometry_id) ); -CREATE INDEX idx_geo_time ON geometries(time_start, time_end); -CREATE INDEX idx_geom_spatial ON geometries USING GIST (geom); +CREATE INDEX idx_geom_spatial_active +ON geometries USING GIST (geom) +WHERE is_deleted = false; + +CREATE INDEX idx_geom_bbox +ON geometries USING GIST (bbox) +WHERE is_deleted = false; + +CREATE INDEX idx_geom_time_range +ON geometries +USING GIST (int4range(time_start, time_end)) +WHERE is_deleted = false; + +CREATE INDEX idx_geo_versions_geo_id +ON geo_versions(geo_id) +WHERE is_deleted = false; + +CREATE INDEX idx_geo_versions_reviewed_by +ON geo_versions(reviewed_by) +WHERE is_deleted = false; + +CREATE INDEX idx_geo_versions_created_at +ON geo_versions(created_at DESC) +WHERE is_deleted = false; + +CREATE INDEX idx_entity_geometries_geometry +ON entity_geometries(geometry_id); CREATE TRIGGER trigger_geometries_updated_at BEFORE UPDATE ON geometries diff --git a/db/query/users.sql b/db/query/users.sql index bc370f6..d853325 100644 --- a/db/query/users.sql +++ b/db/query/users.sql @@ -1,42 +1,45 @@ --- name: CreateUser :one +-- name: UpsertUser :one INSERT INTO users ( - name, email, password_hash, + google_id, + auth_provider, + is_verified +) VALUES ( + $1, $2, $3, $4, $5 +) +ON CONFLICT (email) +DO UPDATE SET + google_id = EXCLUDED.google_id, + auth_provider = EXCLUDED.auth_provider, + is_verified = users.is_verified OR EXCLUDED.is_verified, + updated_at = now() +RETURNING *; + +-- name: CreateUserProfile :one +INSERT INTO user_profiles ( + user_id, + display_name, avatar_url ) VALUES ( - $1, $2, $3, $4 + $1, $2, $3 ) RETURNING *; --- name: UpdateUser :one -UPDATE users -SET - name = $1, - avatar_url = $2, - is_active = $3, - is_verified = $4, +-- name: UpdateUserProfile :one +UPDATE user_profiles +SET + display_name = $1, + full_name = $2, + avatar_url = $3, + bio = $4, + location = $5, + website = $6, + country_code = $7, + phone = $8, updated_at = now() -WHERE users.id = $5 AND users.is_deleted = false -RETURNING - users.id, - users.name, - users.email, - users.password_hash, - users.avatar_url, - users.is_active, - users.is_verified, - users.token_version, - users.refresh_token, - users.is_deleted, - users.created_at, - users.updated_at, - ( - SELECT COALESCE(json_agg(json_build_object('id', roles.id, 'name', roles.name)), '[]')::json - FROM user_roles - JOIN roles ON user_roles.role_id = roles.id - WHERE user_roles.user_id = users.id - ) AS roles; +WHERE user_id = $9 +RETURNING *; -- name: UpdateUserPassword :exec UPDATE users @@ -52,7 +55,6 @@ SET WHERE id = $1 AND is_deleted = false; - -- name: VerifyUser :exec UPDATE users SET @@ -72,86 +74,133 @@ SET is_deleted = false WHERE id = $1; --- name: ExistsUserByEmail :one -SELECT EXISTS ( - SELECT 1 FROM users - WHERE email = $1 - AND is_deleted = false -); - --- name: GetUsers :many -SELECT - u.id, - u.name, - u.email, - u.password_hash, - u.avatar_url, - u.is_active, - u.is_verified, - u.token_version, - u.refresh_token, - u.is_deleted, - u.created_at, - u.updated_at, - COALESCE( - json_agg( - json_build_object('id', r.id, 'name', r.name) - ) FILTER (WHERE r.id IS NOT NULL), - '[]' - )::json AS roles -FROM users u -LEFT JOIN user_roles ur ON u.id = ur.user_id -LEFT JOIN roles r ON ur.role_id = r.id -WHERE u.is_deleted = false -GROUP BY u.id; - -- name: GetUserByID :one SELECT - u.id, - u.name, - u.email, - u.password_hash, - u.avatar_url, - u.is_active, - u.is_verified, - u.token_version, + u.id, + u.email, + u.password_hash, + u.is_verified, + u.token_version, u.refresh_token, u.is_deleted, - u.created_at, + u.created_at, u.updated_at, - COALESCE( - json_agg( - json_build_object('id', r.id, 'name', r.name) - ) FILTER (WHERE r.id IS NOT NULL), - '[]' - )::json AS roles + + -- profile JSON + ( + SELECT json_build_object( + 'display_name', p.display_name, + 'full_name', p.full_name, + 'avatar_url', p.avatar_url, + 'bio', p.bio, + 'location', p.location, + 'website', p.website, + 'country_code', p.country_code, + 'phone', p.phone + ) + FROM user_profiles p + WHERE p.user_id = u.id + ) AS profile, + + -- roles JSON + ( + SELECT COALESCE( + json_agg(json_build_object('id', r.id, 'name', r.name)), + '[]' + )::json + FROM user_roles ur + JOIN roles r ON ur.role_id = r.id + WHERE ur.user_id = u.id + ) AS roles + FROM users u -LEFT JOIN user_roles ur ON u.id = ur.user_id -LEFT JOIN roles r ON ur.role_id = r.id -WHERE u.id = $1 AND u.is_deleted = false -GROUP BY u.id; +WHERE u.id = $1 AND u.is_deleted = false; + +-- name: GetTokenVersion :one +SELECT token_version +FROM users +WHERE id = $1 AND is_deleted = false; + +-- name: UpdateTokenVersion :exec +UPDATE users +SET token_version = $2 +WHERE id = $1 AND is_deleted = false; -- name: GetUserByEmail :one SELECT - u.id, - u.name, - u.email, - u.password_hash, - u.avatar_url, - u.is_active, - u.is_verified, - u.token_version, + u.id, + u.email, + u.password_hash, + u.is_verified, + u.token_version, u.is_deleted, - u.created_at, + u.created_at, u.updated_at, - COALESCE( - json_agg( - json_build_object('id', r.id, 'name', r.name) - ) FILTER (WHERE r.id IS NOT NULL), - '[]' - )::json AS roles + + ( + SELECT json_build_object( + 'display_name', p.display_name, + 'full_name', p.full_name, + 'avatar_url', p.avatar_url, + 'bio', p.bio, + 'location', p.location, + 'website', p.website, + 'country_code', p.country_code, + 'phone', p.phone + ) + FROM user_profiles p + WHERE p.user_id = u.id + ) AS profile, + + ( + SELECT COALESCE( + json_agg(json_build_object('id', r.id, 'name', r.name)), + '[]' + )::json + FROM user_roles ur + JOIN roles r ON ur.role_id = r.id + WHERE ur.user_id = u.id + ) AS roles + FROM users u -LEFT JOIN user_roles ur ON u.id = ur.user_id -LEFT JOIN roles r ON ur.role_id = r.id -WHERE u.email = $1 AND u.is_deleted = false -GROUP BY u.id; +WHERE u.email = $1 AND u.is_deleted = false; + +-- name: GetUsers :many +SELECT + u.id, + u.email, + u.password_hash, + u.is_verified, + u.token_version, + u.refresh_token, + u.is_deleted, + u.created_at, + u.updated_at, + + ( + SELECT json_build_object( + 'display_name', p.display_name, + 'full_name', p.full_name, + 'avatar_url', p.avatar_url, + 'bio', p.bio, + 'location', p.location, + 'website', p.website, + 'country_code', p.country_code, + 'phone', p.phone + ) + FROM user_profiles p + WHERE p.user_id = u.id + ) AS profile, + + ( + SELECT COALESCE( + json_agg(json_build_object('id', r.id, 'name', r.name)), + '[]' + )::json + FROM user_roles ur + JOIN roles r ON ur.role_id = r.id + WHERE ur.user_id = u.id + ) AS roles + +FROM users u +WHERE u.is_deleted = false; \ No newline at end of file diff --git a/db/schema.sql b/db/schema.sql index 9be9dc9..ea1c5b6 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,18 +1,18 @@ CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY DEFAULT uuidv7(), email TEXT NOT NULL UNIQUE, - password_hash TEXT NOT NULL, + password_hash TEXT, google_id VARCHAR(255) UNIQUE, auth_provider VARCHAR(50) NOT NULL DEFAULT 'local', - is_verified BOOLEAN DEFAULT false, + is_verified BOOLEAN NOT NULL DEFAULT false, + is_deleted BOOLEAN NOT NULL DEFAULT false, token_version INT NOT NULL DEFAULT 1, refresh_token TEXT, - is_deleted BOOLEAN DEFAULT false, created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now() ); -CREATE TABLE user_profiles ( +CREATE TABLE IF NOT EXISTS user_profiles ( user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, display_name TEXT, full_name TEXT, @@ -26,21 +26,10 @@ CREATE TABLE user_profiles ( updated_at TIMESTAMPTZ DEFAULT now() ); -CREATE TABLE user_verifications ( - id UUID PRIMARY KEY DEFAULT uuidv7(), - user_id UUID REFERENCES users(id) ON DELETE CASCADE, - verify_type SMALLINT NOT NULL, -- 1 = ID_CARD, 2 = EDUCATION, 3 = EXPERT - document_url TEXT NOT NULL, - status SMALLINT NOT NULL DEFAULT 1, -- 1 pending, 2 approved, 3 rejected - reviewed_by UUID REFERENCES users(id), - reviewed_at TIMESTAMPTZ, - created_at TIMESTAMPTZ DEFAULT now() -); - CREATE TABLE IF NOT EXISTS roles ( id UUID PRIMARY KEY DEFAULT uuidv7(), name TEXT UNIQUE NOT NULL, - is_deleted BOOLEAN DEFAULT false, + is_deleted BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now() ); @@ -51,11 +40,23 @@ CREATE TABLE IF NOT EXISTS user_roles ( PRIMARY KEY (user_id, role_id) ); +CREATE TABLE IF NOT EXISTS user_verifications ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + verify_type SMALLINT NOT NULL, -- 1 = ID_CARD, 2 = EDUCATION, 3 = EXPERT + document_url TEXT NOT NULL, + status SMALLINT NOT NULL DEFAULT 1, -- 1 pending, 2 approved, 3 rejected + reviewed_by UUID REFERENCES users(id), + reviewed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT now() +); + CREATE TABLE IF NOT EXISTS user_tokens ( id UUID PRIMARY KEY DEFAULT uuidv7(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - token VARCHAR(255) NOT NULL UNIQUE, --1 = PasswordReset, 2 = EmailVerify, 3 = MagicLink, 4 = RefreshToken + token VARCHAR(255) NOT NULL UNIQUE, + is_deleted BOOLEAN NOT NULL DEFAULT false, token_type SMALLINT NOT NULL, expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ DEFAULT now() -); \ No newline at end of file +); diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml new file mode 100644 index 0000000..a8e7ca4 --- /dev/null +++ b/docker-compose-dev.yml @@ -0,0 +1,29 @@ +services: + db: + image: postgis/postgis:18-3.6 + container_name: history_db + restart: unless-stopped + environment: + POSTGRES_USER: history + POSTGRES_PASSWORD: secret + POSTGRES_DB: history_map + PGDATA: /var/lib/postgresql/data + ports: + - "5432:5432" + volumes: + - ./pg_data:/var/lib/postgresql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U history -d history_map"] + interval: 5s + timeout: 3s + retries: 5 + + cache: + image: redis:8.6.1-alpine + container_name: history_redis + restart: unless-stopped + ports: + - "6379:6379" + +volumes: + pg_data: \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index a8e7ca4..34e7149 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,27 +3,71 @@ services: image: postgis/postgis:18-3.6 container_name: history_db restart: unless-stopped + env_file: + - ./assets/resources/.env environment: - POSTGRES_USER: history - POSTGRES_PASSWORD: secret - POSTGRES_DB: history_map - PGDATA: /var/lib/postgresql/data - ports: - - "5432:5432" + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + - POSTGRES_DB=${POSTGRES_DB} + - PGDATA=/var/lib/postgresql/data volumes: - - ./pg_data:/var/lib/postgresql + - pg_data:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U history -d history_map"] + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] interval: 5s timeout: 3s retries: 5 + networks: + - history-api-project cache: image: redis:8.6.1-alpine container_name: history_redis restart: unless-stopped + networks: + - history-api-project + + migrate: + image: migrate/migrate + container_name: history_migrate + depends_on: + db: + condition: service_healthy + env_file: + - ./assets/resources/.env + volumes: + - ./db/migrations:/migrations + # Sử dụng sh -c để có thể chạy chuỗi lệnh + entrypoint: + - sh + - -c + - | + # Lấy URL database từ biến môi trường + DB_URL="postgres://$${POSTGRES_USER}:$${POSTGRES_PASSWORD}@db:5432/$${POSTGRES_DB}?sslmode=disable" + + echo "Checking for dirty database..." + /migrate -path /migrations -database "$$DB_URL" up || \ + (echo "Database is dirty, forcing to version 8..." && \ + /migrate -path /migrations -database "$$DB_URL" force 8 && \ + /migrate -path /migrations -database "$$DB_URL" up) + networks: + - history-api-project + app: + build: . + container_name: history_app + restart: unless-stopped + depends_on: + migrate: + condition: service_completed_successfully + cache: + condition: service_started ports: - - "6379:6379" + - "3344:3344" + networks: + - history-api-project volumes: - pg_data: \ No newline at end of file + pg_data: + +networks: + history-api-project: \ No newline at end of file diff --git a/docs/docs.go b/docs/docs.go new file mode 100644 index 0000000..0e6991a --- /dev/null +++ b/docs/docs.go @@ -0,0 +1,322 @@ +// Package docs Code generated by swaggo/swag. DO NOT EDIT +package docs + +import "github.com/swaggo/swag" + +const docTemplate = `{ + "schemes": {{ marshal .Schemes }}, + "swagger": "2.0", + "info": { + "description": "{{escape .Description}}", + "title": "{{.Title}}", + "termsOfService": "http://swagger.io/terms/", + "contact": { + "name": "API Support", + "url": "http://www.swagger.io/support", + "email": "support@swagger.io" + }, + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + }, + "version": "{{.Version}}" + }, + "host": "{{.Host}}", + "basePath": "{{.BasePath}}", + "paths": { + "/auth/refresh": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get a new access token using the user's current session/refresh token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Refresh access token", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + } + } + } + }, + "/auth/signin": { + "post": { + "description": "Authenticate user and return token data", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Sign in an existing user", + "parameters": [ + { + "description": "Sign In request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_request.SignInDto" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + } + } + } + }, + "/auth/signup": { + "post": { + "description": "Create a new user account", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Sign up a new user", + "parameters": [ + { + "description": "Sign Up request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_request.SignUpDto" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + } + } + } + }, + "/tiles/metadata": { + "get": { + "description": "Retrieve map metadata", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tile" + ], + "summary": "Get tile metadata", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + } + } + } + }, + "/tiles/{z}/{x}/{y}": { + "get": { + "description": "Fetch vector or raster map tile data by Z, X, Y coordinates", + "produces": [ + "application/octet-stream" + ], + "tags": [ + "Tile" + ], + "summary": "Get a map tile", + "parameters": [ + { + "type": "integer", + "description": "Zoom level (0-22)", + "name": "z", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "X coordinate", + "name": "x", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Y coordinate", + "name": "y", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + } + } + } + } + }, + "definitions": { + "history-api_internal_dtos_request.SignInDto": { + "type": "object", + "required": [ + "email", + "password" + ], + "properties": { + "email": { + "type": "string", + "maxLength": 255, + "minLength": 5 + }, + "password": { + "type": "string", + "maxLength": 64, + "minLength": 8 + } + } + }, + "history-api_internal_dtos_request.SignUpDto": { + "type": "object", + "required": [ + "display_name", + "email", + "password" + ], + "properties": { + "display_name": { + "type": "string", + "maxLength": 50, + "minLength": 2 + }, + "email": { + "type": "string", + "maxLength": 255, + "minLength": 5 + }, + "password": { + "type": "string", + "maxLength": 64, + "minLength": 8 + } + } + }, + "history-api_internal_dtos_response.CommonResponse": { + "type": "object", + "properties": { + "data": {}, + "message": { + "type": "string" + }, + "status": { + "type": "boolean" + } + } + } + }, + "securityDefinitions": { + "BearerAuth": { + "description": "Type \"Bearer \" followed by a space and JWT token.", + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + } +}` + +// SwaggerInfo holds exported Swagger Info so clients can modify it +var SwaggerInfo = &swag.Spec{ + Version: "1.0", + Host: "localhost:3344", + BasePath: "/", + Schemes: []string{}, + Title: "History API", + Description: "This is a sample server for History API.", + InfoInstanceName: "swagger", + SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", +} + +func init() { + swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) +} diff --git a/docs/embed.go b/docs/embed.go new file mode 100644 index 0000000..6dcceb5 --- /dev/null +++ b/docs/embed.go @@ -0,0 +1,6 @@ +package docs + +import _ "embed" + +//go:embed swagger.json +var SwaggerJSON []byte \ No newline at end of file diff --git a/docs/swagger.json b/docs/swagger.json new file mode 100644 index 0000000..ae49c81 --- /dev/null +++ b/docs/swagger.json @@ -0,0 +1,298 @@ +{ + "swagger": "2.0", + "info": { + "description": "This is a sample server for History API.", + "title": "History API", + "termsOfService": "http://swagger.io/terms/", + "contact": { + "name": "API Support", + "url": "http://www.swagger.io/support", + "email": "support@swagger.io" + }, + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + }, + "version": "1.0" + }, + "host": "localhost:3344", + "basePath": "/", + "paths": { + "/auth/refresh": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get a new access token using the user's current session/refresh token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Refresh access token", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + } + } + } + }, + "/auth/signin": { + "post": { + "description": "Authenticate user and return token data", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Sign in an existing user", + "parameters": [ + { + "description": "Sign In request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_request.SignInDto" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + } + } + } + }, + "/auth/signup": { + "post": { + "description": "Create a new user account", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Sign up a new user", + "parameters": [ + { + "description": "Sign Up request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_request.SignUpDto" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + } + } + } + }, + "/tiles/metadata": { + "get": { + "description": "Retrieve map metadata", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tile" + ], + "summary": "Get tile metadata", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + } + } + } + }, + "/tiles/{z}/{x}/{y}": { + "get": { + "description": "Fetch vector or raster map tile data by Z, X, Y coordinates", + "produces": [ + "application/octet-stream" + ], + "tags": [ + "Tile" + ], + "summary": "Get a map tile", + "parameters": [ + { + "type": "integer", + "description": "Zoom level (0-22)", + "name": "z", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "X coordinate", + "name": "x", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Y coordinate", + "name": "y", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + } + } + } + } + }, + "definitions": { + "history-api_internal_dtos_request.SignInDto": { + "type": "object", + "required": [ + "email", + "password" + ], + "properties": { + "email": { + "type": "string", + "maxLength": 255, + "minLength": 5 + }, + "password": { + "type": "string", + "maxLength": 64, + "minLength": 8 + } + } + }, + "history-api_internal_dtos_request.SignUpDto": { + "type": "object", + "required": [ + "display_name", + "email", + "password" + ], + "properties": { + "display_name": { + "type": "string", + "maxLength": 50, + "minLength": 2 + }, + "email": { + "type": "string", + "maxLength": 255, + "minLength": 5 + }, + "password": { + "type": "string", + "maxLength": 64, + "minLength": 8 + } + } + }, + "history-api_internal_dtos_response.CommonResponse": { + "type": "object", + "properties": { + "data": {}, + "message": { + "type": "string" + }, + "status": { + "type": "boolean" + } + } + } + }, + "securityDefinitions": { + "BearerAuth": { + "description": "Type \"Bearer \" followed by a space and JWT token.", + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + } +} \ No newline at end of file diff --git a/docs/swagger.yaml b/docs/swagger.yaml new file mode 100644 index 0000000..c9d65c8 --- /dev/null +++ b/docs/swagger.yaml @@ -0,0 +1,202 @@ +basePath: / +definitions: + history-api_internal_dtos_request.SignInDto: + properties: + email: + maxLength: 255 + minLength: 5 + type: string + password: + maxLength: 64 + minLength: 8 + type: string + required: + - email + - password + type: object + history-api_internal_dtos_request.SignUpDto: + properties: + display_name: + maxLength: 50 + minLength: 2 + type: string + email: + maxLength: 255 + minLength: 5 + type: string + password: + maxLength: 64 + minLength: 8 + type: string + required: + - display_name + - email + - password + type: object + history-api_internal_dtos_response.CommonResponse: + properties: + data: {} + message: + type: string + status: + type: boolean + type: object +host: localhost:3344 +info: + contact: + email: support@swagger.io + name: API Support + url: http://www.swagger.io/support + description: This is a sample server for History API. + license: + name: Apache 2.0 + url: http://www.apache.org/licenses/LICENSE-2.0.html + termsOfService: http://swagger.io/terms/ + title: History API + version: "1.0" +paths: + /auth/refresh: + post: + consumes: + - application/json + description: Get a new access token using the user's current session/refresh + token + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + security: + - BearerAuth: [] + summary: Refresh access token + tags: + - Auth + /auth/signin: + post: + consumes: + - application/json + description: Authenticate user and return token data + parameters: + - description: Sign In request + in: body + name: request + required: true + schema: + $ref: '#/definitions/history-api_internal_dtos_request.SignInDto' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + summary: Sign in an existing user + tags: + - Auth + /auth/signup: + post: + consumes: + - application/json + description: Create a new user account + parameters: + - description: Sign Up request + in: body + name: request + required: true + schema: + $ref: '#/definitions/history-api_internal_dtos_request.SignUpDto' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + summary: Sign up a new user + tags: + - Auth + /tiles/{z}/{x}/{y}: + get: + description: Fetch vector or raster map tile data by Z, X, Y coordinates + parameters: + - description: Zoom level (0-22) + in: path + name: z + required: true + type: integer + - description: X coordinate + in: path + name: x + required: true + type: integer + - description: Y coordinate + in: path + name: "y" + required: true + type: integer + produces: + - application/octet-stream + responses: + "200": + description: OK + schema: + type: file + "400": + description: Bad Request + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + summary: Get a map tile + tags: + - Tile + /tiles/metadata: + get: + consumes: + - application/json + description: Retrieve map metadata + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + summary: Get tile metadata + tags: + - Tile +securityDefinitions: + BearerAuth: + description: Type "Bearer " followed by a space and JWT token. + in: header + name: Authorization + type: apiKey +swagger: "2.0" diff --git a/go.mod b/go.mod index 6d44f94..512fde3 100644 --- a/go.mod +++ b/go.mod @@ -3,21 +3,28 @@ module history-api go 1.26.1 require ( + github.com/glebarez/go-sqlite v1.22.0 github.com/go-playground/validator/v10 v10.30.1 github.com/gofiber/contrib/v3/jwt v1.1.0 github.com/gofiber/contrib/v3/swaggerui v1.0.1 + github.com/gofiber/contrib/v3/zerolog v1.0.1 github.com/gofiber/fiber/v3 v3.1.0 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/jackc/pgx/v5 v5.8.0 github.com/joho/godotenv v1.5.1 github.com/redis/go-redis/v9 v9.18.0 github.com/rs/zerolog v1.34.0 + github.com/swaggo/swag v1.16.6 + golang.org/x/crypto v0.49.0 ) require ( + github.com/KyleBanks/depth v1.2.1 // indirect github.com/MicahParks/keyfunc/v2 v2.1.0 // indirect github.com/andybalholm/brotli v1.2.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/go-openapi/analysis v0.24.2 // indirect github.com/go-openapi/errors v0.22.6 // indirect @@ -42,28 +49,33 @@ require ( github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/gofiber/schema v1.7.0 // indirect github.com/gofiber/utils/v2 v2.0.2 // indirect - github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/uuid v1.6.0 // 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/klauspost/compress v1.18.4 // indirect + github.com/klauspost/compress v1.18.5 // indirect github.com/kr/text v0.2.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/philhofer/fwd v1.2.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/tinylib/msgp v1.6.3 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.69.0 // indirect go.mongodb.org/mongo-driver v1.17.9 // indirect go.uber.org/atomic v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.48.0 // indirect - golang.org/x/net v0.51.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect + golang.org/x/mod v0.33.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect + golang.org/x/tools v0.42.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect + modernc.org/libc v1.37.6 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.7.2 // indirect + modernc.org/sqlite v1.28.0 // indirect ) diff --git a/go.sum b/go.sum index f118ee2..3fe0709 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/MicahParks/keyfunc/v2 v2.1.0 h1:6ZXKb9Rp6qp1bDbJefnG7cTH8yMN1IC/4nf+GVjO99k= github.com/MicahParks/keyfunc/v2 v2.1.0/go.mod h1:rW42fi+xgLJ2FRRXAfNx9ZA8WpD4OeE/yHVMteCkw9k= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= @@ -15,10 +17,14 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= +github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= github.com/go-openapi/analysis v0.24.2 h1:6p7WXEuKy1llDgOH8FooVeO+Uq2za9qoAOq4ZN08B50= github.com/go-openapi/analysis v0.24.2/go.mod h1:x27OOHKANE0lutg2ml4kzYLoHGMKgRm1Cj2ijVOjJuE= github.com/go-openapi/errors v0.22.6 h1:eDxcf89O8odEnohIXwEjY1IB4ph5vmbUsBMsFNwXWPo= @@ -35,6 +41,7 @@ github.com/go-openapi/spec v0.22.3 h1:qRSmj6Smz2rEBxMnLRBMeBWxbbOvuOoElvSvObIgwQ github.com/go-openapi/spec v0.22.3/go.mod h1:iIImLODL2loCh3Vnox8TY2YWYJZjMAKYyLH2Mu8lOZs= github.com/go-openapi/strfmt v0.25.0 h1:7R0RX7mbKLa9EYCTHRcCuIPcaqlyQiWNPTXwClK0saQ= github.com/go-openapi/strfmt v0.25.0/go.mod h1:nNXct7OzbwrMY9+5tLX4I21pzcmE6ccMGXl3jFdPfn8= +github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4= github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU= github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y= @@ -76,6 +83,8 @@ github.com/gofiber/contrib/v3/jwt v1.1.0 h1:RmQHJGNlOF+1hy69AJo+YiVcLTUKA2bXy/yj github.com/gofiber/contrib/v3/jwt v1.1.0/go.mod h1:THoVk0kTAkLJFunaNxk8fRO5WYfrwV4k/8oFAoa7UCM= github.com/gofiber/contrib/v3/swaggerui v1.0.1 h1:o3EdD0VQjeL4rq1gBxQB5bUEYMNT3eGKxpg2hjPIigI= github.com/gofiber/contrib/v3/swaggerui v1.0.1/go.mod h1:tIqJ2SDnY7AfqsJHllyaF2vuhkKeBBCHtwWsZrFLCkk= +github.com/gofiber/contrib/v3/zerolog v1.0.1 h1:Sr9T4g0Z1DAOoo5X0tAKFd9uNKFu++vwaZOk1ymIq1s= +github.com/gofiber/contrib/v3/zerolog v1.0.1/go.mod h1:LgfsEgvOp0Yg4ra77kr3Md3UyQ2BjPO6T8LNV/gkCi4= github.com/gofiber/fiber/v3 v3.1.0 h1:1p4I820pIa+FGxfwWuQZ5rAyX0WlGZbGT6Hnuxt6hKY= github.com/gofiber/fiber/v3 v3.1.0/go.mod h1:n2nYQovvL9z3Too/FGOfgtERjW3GQcAUqgfoezGBZdU= github.com/gofiber/schema v1.7.0 h1:yNM+FNRZjyYEli9Ey0AXRBrAY9jTnb+kmGs3lJGPvKg= @@ -86,6 +95,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63Y github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -98,10 +109,10 @@ 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/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= -github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= -github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= -github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU= +github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -125,6 +136,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= @@ -137,6 +150,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV 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= +github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= +github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s= github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= @@ -155,19 +170,23 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -176,3 +195,11 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 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= +modernc.org/libc v1.37.6 h1:orZH3c5wmhIQFTXF+Nt+eeauyd+ZIt2BX6ARe+kD+aw= +modernc.org/libc v1.37.6/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E= +modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= +modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ= +modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0= diff --git a/internal/controllers/authController.go b/internal/controllers/authController.go index 902154c..7b2df17 100644 --- a/internal/controllers/authController.go +++ b/internal/controllers/authController.go @@ -1 +1,122 @@ -package controllers \ No newline at end of file +package controllers + +import ( + "context" + "history-api/internal/dtos/request" + "history-api/internal/dtos/response" + "history-api/internal/services" + "history-api/pkg/validator" + "time" + + "github.com/gofiber/fiber/v3" +) + +type AuthController struct { + service services.AuthService +} + +func NewAuthController(svc services.AuthService) *AuthController { + return &AuthController{service: svc} +} + +// Signin godoc +// @Summary Sign in an existing user +// @Description Authenticate user and return token data +// @Tags Auth +// @Accept json +// @Produce json +// @Param request body request.SignInDto true "Sign In request" +// @Success 200 {object} response.CommonResponse +// @Failure 400 {object} response.CommonResponse +// @Failure 500 {object} response.CommonResponse +// @Router /auth/signin [post] +func (h *AuthController) Signin(c fiber.Ctx) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + dto := &request.SignInDto{} + + if err := validator.ValidateBodyDto(c, dto); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(response.CommonResponse{ + Status: false, + Message: err.Error(), + }) + } + + res, err := h.service.Signin(ctx, dto) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{ + Status: false, + Message: err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(response.CommonResponse{ + Status: true, + Data: res, + }) +} + +// Signup godoc +// @Summary Sign up a new user +// @Description Create a new user account +// @Tags Auth +// @Accept json +// @Produce json +// @Param request body request.SignUpDto true "Sign Up request" +// @Success 200 {object} response.CommonResponse +// @Failure 400 {object} response.CommonResponse +// @Failure 500 {object} response.CommonResponse +// @Router /auth/signup [post] +func (h *AuthController) Signup(c fiber.Ctx) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + dto := &request.SignUpDto{} + + if err := validator.ValidateBodyDto(c, dto); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(response.CommonResponse{ + Status: false, + Message: err.Error(), + }) + } + + res, err := h.service.Signup(ctx, dto) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{ + Status: false, + Message: err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(response.CommonResponse{ + Status: true, + Data: res, + }) +} + +// RefreshToken godoc +// @Summary Refresh access token +// @Description Get a new access token using the user's current session/refresh token +// @Tags Auth +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} response.CommonResponse +// @Failure 500 {object} response.CommonResponse +// @Router /auth/refresh [post] +func (h *AuthController) RefreshToken(c fiber.Ctx) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + res, err := h.service.RefreshToken(ctx, c.Locals("uid").(string)) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{ + Status: false, + Message: err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(response.CommonResponse{ + Status: true, + Data: res, + }) +} diff --git a/internal/controllers/tileController.go b/internal/controllers/tileController.go new file mode 100644 index 0000000..e3f6af5 --- /dev/null +++ b/internal/controllers/tileController.go @@ -0,0 +1,113 @@ +package controllers + +import ( + "context" + "fmt" + "history-api/internal/dtos/response" + "history-api/internal/services" + "strconv" + "time" + + "github.com/gofiber/fiber/v3" +) + +type TileController struct { + service services.TileService +} + +func NewTileController(svc services.TileService) *TileController { + return &TileController{service: svc} +} + +// GetMetadata godoc +// @Summary Get tile metadata +// @Description Retrieve map metadata +// @Tags Tile +// @Accept json +// @Produce json +// @Success 200 {object} response.CommonResponse +// @Failure 500 {object} response.CommonResponse +// @Router /tiles/metadata [get] +func (h *TileController) GetMetadata(c fiber.Ctx) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + res, err := h.service.GetMetadata(ctx) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{ + Status: false, + Message: err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(response.CommonResponse{ + Status: true, + Data: res, + }) +} + +// GetTile godoc +// @Summary Get a map tile +// @Description Fetch vector or raster map tile data by Z, X, Y coordinates +// @Tags Tile +// @Produce application/octet-stream +// @Param z path int true "Zoom level (0-22)" +// @Param x path int true "X coordinate" +// @Param y path int true "Y coordinate" +// @Success 200 {file} byte +// @Failure 400 {object} response.CommonResponse +// @Failure 500 {object} response.CommonResponse +// @Router /tiles/{z}/{x}/{y} [get] +func (h *TileController) GetTile(c fiber.Ctx) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + z, x, y, err := h.parseTileParams(c) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(response.CommonResponse{ + Status: false, + Message: err.Error(), + }) + } + + data, headers, err := h.service.GetTile(ctx, z, x, y) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{ + Status: false, + Message: err.Error(), + }) + } + + for k, v := range headers { + c.Set(k, v) + } + + return c.Status(fiber.StatusOK).Send(data) +} + +func (h *TileController) parseTileParams(c fiber.Ctx) (int, int, int, error) { + z, err := strconv.Atoi(c.Params("z")) + if err != nil { + return 0, 0, 0, fmt.Errorf("invalid z") + } + + x, err := strconv.Atoi(c.Params("x")) + if err != nil { + return 0, 0, 0, fmt.Errorf("invalid x") + } + + y, err := strconv.Atoi(c.Params("y")) + if err != nil { + return 0, 0, 0, fmt.Errorf("invalid y") + } + + if z < 0 || x < 0 || y < 0 { + return 0, 0, 0, fmt.Errorf("coordinates must be positive") + } + + if z > 22 { + return 0, 0, 0, fmt.Errorf("zoom level too large") + } + + return z, x, y, nil +} diff --git a/internal/dtos/request/auth.go b/internal/dtos/request/auth.go new file mode 100644 index 0000000..296eeb0 --- /dev/null +++ b/internal/dtos/request/auth.go @@ -0,0 +1,11 @@ +package request + +type SignUpDto struct { + Email string `json:"email" validate:"required,min=5,max=255,email"` + Password string `json:"password" validate:"required,min=8,max=64"` + DisplayName string `json:"display_name" validate:"required,min=2,max=50"` +} +type SignInDto struct { + Email string `json:"email" validate:"required,min=5,max=255,email"` + Password string `json:"password" validate:"required,min=8,max=64"` +} diff --git a/pkg/dtos/request/media.go b/internal/dtos/request/media.go similarity index 100% rename from pkg/dtos/request/media.go rename to internal/dtos/request/media.go diff --git a/pkg/dtos/request/user.go b/internal/dtos/request/user.go similarity index 100% rename from pkg/dtos/request/user.go rename to internal/dtos/request/user.go diff --git a/pkg/dtos/response/auth.go b/internal/dtos/response/auth.go similarity index 100% rename from pkg/dtos/response/auth.go rename to internal/dtos/response/auth.go diff --git a/pkg/dtos/response/common.go b/internal/dtos/response/common.go similarity index 100% rename from pkg/dtos/response/common.go rename to internal/dtos/response/common.go diff --git a/pkg/dtos/response/media.go b/internal/dtos/response/media.go similarity index 100% rename from pkg/dtos/response/media.go rename to internal/dtos/response/media.go diff --git a/internal/dtos/response/role.go b/internal/dtos/response/role.go new file mode 100644 index 0000000..525a03b --- /dev/null +++ b/internal/dtos/response/role.go @@ -0,0 +1,16 @@ +package response + +import "time" + +type RoleSimpleResponse struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type RoleResponse struct { + ID string `json:"id"` + Name string `json:"name"` + IsDeleted bool `json:"is_deleted"` + CreatedAt *time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at"` +} diff --git a/internal/dtos/response/token.go b/internal/dtos/response/token.go new file mode 100644 index 0000000..136b918 --- /dev/null +++ b/internal/dtos/response/token.go @@ -0,0 +1,11 @@ +package response + +import "time" + +type TokenResponse struct { + ID string `json:"id"` + UserID string `json:"user_id"` + TokenType int16 `json:"token_type"` + ExpiresAt *time.Time `json:"expires_at"` + CreatedAt *time.Time `json:"created_at"` +} diff --git a/internal/dtos/response/user.go b/internal/dtos/response/user.go new file mode 100644 index 0000000..2431c15 --- /dev/null +++ b/internal/dtos/response/user.go @@ -0,0 +1,26 @@ +package response + +import "time" + +type UserResponse struct { + ID string `json:"id"` + Email string `json:"email"` + Profile *UserProfileSimpleResponse `json:"profile"` + IsVerified bool `json:"is_verified"` + TokenVersion int32 `json:"token_version"` + IsDeleted bool `json:"is_deleted"` + CreatedAt *time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at"` + Roles []*RoleSimpleResponse `json:"roles"` +} + +type UserProfileSimpleResponse struct { + DisplayName string `json:"display_name"` + FullName string `json:"full_name"` + AvatarUrl string `json:"avatar_url"` + Bio string `json:"bio"` + Location string `json:"location"` + Website string `json:"website"` + CountryCode string `json:"country_code"` + Phone string `json:"phone"` +} diff --git a/internal/gen/sqlc/models.go b/internal/gen/sqlc/models.go index 8b9dc76..3017d03 100644 --- a/internal/gen/sqlc/models.go +++ b/internal/gen/sqlc/models.go @@ -11,26 +11,39 @@ import ( type Role struct { ID pgtype.UUID `json:"id"` Name string `json:"name"` - IsDeleted pgtype.Bool `json:"is_deleted"` + IsDeleted bool `json:"is_deleted"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` } type User struct { ID pgtype.UUID `json:"id"` - Name string `json:"name"` Email string `json:"email"` - PasswordHash string `json:"password_hash"` - AvatarUrl pgtype.Text `json:"avatar_url"` - IsActive pgtype.Bool `json:"is_active"` - IsVerified pgtype.Bool `json:"is_verified"` + PasswordHash pgtype.Text `json:"password_hash"` + GoogleID pgtype.Text `json:"google_id"` + AuthProvider string `json:"auth_provider"` + IsVerified bool `json:"is_verified"` + IsDeleted bool `json:"is_deleted"` TokenVersion int32 `json:"token_version"` RefreshToken pgtype.Text `json:"refresh_token"` - IsDeleted pgtype.Bool `json:"is_deleted"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` } +type UserProfile struct { + UserID pgtype.UUID `json:"user_id"` + DisplayName pgtype.Text `json:"display_name"` + FullName pgtype.Text `json:"full_name"` + AvatarUrl pgtype.Text `json:"avatar_url"` + Bio pgtype.Text `json:"bio"` + Location pgtype.Text `json:"location"` + Website pgtype.Text `json:"website"` + CountryCode pgtype.Text `json:"country_code"` + Phone pgtype.Text `json:"phone"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type UserRole struct { UserID pgtype.UUID `json:"user_id"` RoleID pgtype.UUID `json:"role_id"` @@ -40,7 +53,19 @@ type UserToken struct { ID pgtype.UUID `json:"id"` UserID pgtype.UUID `json:"user_id"` Token string `json:"token"` + IsDeleted bool `json:"is_deleted"` TokenType int16 `json:"token_type"` ExpiresAt pgtype.Timestamptz `json:"expires_at"` CreatedAt pgtype.Timestamptz `json:"created_at"` } + +type UserVerification struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + VerifyType int16 `json:"verify_type"` + DocumentUrl string `json:"document_url"` + Status int16 `json:"status"` + ReviewedBy pgtype.UUID `json:"reviewed_by"` + ReviewedAt pgtype.Timestamptz `json:"reviewed_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} diff --git a/internal/gen/sqlc/users.sql.go b/internal/gen/sqlc/users.sql.go index 829a27b..aa75c7a 100644 --- a/internal/gen/sqlc/users.sql.go +++ b/internal/gen/sqlc/users.sql.go @@ -11,44 +11,36 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) -const createUser = `-- name: CreateUser :one -INSERT INTO users ( - name, - email, - password_hash, +const createUserProfile = `-- name: CreateUserProfile :one +INSERT INTO user_profiles ( + user_id, + display_name, avatar_url ) VALUES ( - $1, $2, $3, $4 + $1, $2, $3 ) -RETURNING id, name, email, password_hash, avatar_url, is_active, is_verified, token_version, refresh_token, is_deleted, created_at, updated_at +RETURNING user_id, display_name, full_name, avatar_url, bio, location, website, country_code, phone, created_at, updated_at ` -type CreateUserParams struct { - Name string `json:"name"` - Email string `json:"email"` - PasswordHash string `json:"password_hash"` - AvatarUrl pgtype.Text `json:"avatar_url"` +type CreateUserProfileParams struct { + UserID pgtype.UUID `json:"user_id"` + DisplayName pgtype.Text `json:"display_name"` + AvatarUrl pgtype.Text `json:"avatar_url"` } -func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) { - row := q.db.QueryRow(ctx, createUser, - arg.Name, - arg.Email, - arg.PasswordHash, - arg.AvatarUrl, - ) - var i User +func (q *Queries) CreateUserProfile(ctx context.Context, arg CreateUserProfileParams) (UserProfile, error) { + row := q.db.QueryRow(ctx, createUserProfile, arg.UserID, arg.DisplayName, arg.AvatarUrl) + var i UserProfile err := row.Scan( - &i.ID, - &i.Name, - &i.Email, - &i.PasswordHash, + &i.UserID, + &i.DisplayName, + &i.FullName, &i.AvatarUrl, - &i.IsActive, - &i.IsVerified, - &i.TokenVersion, - &i.RefreshToken, - &i.IsDeleted, + &i.Bio, + &i.Location, + &i.Website, + &i.CountryCode, + &i.Phone, &i.CreatedAt, &i.UpdatedAt, ) @@ -58,8 +50,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e const deleteUser = `-- name: DeleteUser :exec UPDATE users SET - is_deleted = true, - updated_at = now() + is_deleted = true WHERE id = $1 ` @@ -68,59 +59,69 @@ func (q *Queries) DeleteUser(ctx context.Context, id pgtype.UUID) error { return err } -const existsUserByEmail = `-- name: ExistsUserByEmail :one -SELECT EXISTS ( - SELECT 1 FROM users - WHERE email = $1 - AND is_deleted = false -) +const getTokenVersion = `-- name: GetTokenVersion :one +SELECT token_version +FROM users +WHERE id = $1 AND is_deleted = false ` -func (q *Queries) ExistsUserByEmail(ctx context.Context, email string) (bool, error) { - row := q.db.QueryRow(ctx, existsUserByEmail, email) - var exists bool - err := row.Scan(&exists) - return exists, err +func (q *Queries) GetTokenVersion(ctx context.Context, id pgtype.UUID) (int32, error) { + row := q.db.QueryRow(ctx, getTokenVersion, id) + var token_version int32 + err := row.Scan(&token_version) + return token_version, err } const getUserByEmail = `-- name: GetUserByEmail :one SELECT - u.id, - u.name, - u.email, - u.password_hash, - u.avatar_url, - u.is_active, - u.is_verified, - u.token_version, + u.id, + u.email, + u.password_hash, + u.is_verified, + u.token_version, u.is_deleted, - u.created_at, + u.created_at, u.updated_at, - COALESCE( - json_agg( - json_build_object('id', r.id, 'name', r.name) - ) FILTER (WHERE r.id IS NOT NULL), - '[]' - )::json AS roles + + ( + SELECT json_build_object( + 'display_name', p.display_name, + 'full_name', p.full_name, + 'avatar_url', p.avatar_url, + 'bio', p.bio, + 'location', p.location, + 'website', p.website, + 'country_code', p.country_code, + 'phone', p.phone + ) + FROM user_profiles p + WHERE p.user_id = u.id + ) AS profile, + + ( + SELECT COALESCE( + json_agg(json_build_object('id', r.id, 'name', r.name)), + '[]' + )::json + FROM user_roles ur + JOIN roles r ON ur.role_id = r.id + WHERE ur.user_id = u.id + ) AS roles + FROM users u -LEFT JOIN user_roles ur ON u.id = ur.user_id -LEFT JOIN roles r ON ur.role_id = r.id WHERE u.email = $1 AND u.is_deleted = false -GROUP BY u.id ` type GetUserByEmailRow struct { ID pgtype.UUID `json:"id"` - Name string `json:"name"` Email string `json:"email"` - PasswordHash string `json:"password_hash"` - AvatarUrl pgtype.Text `json:"avatar_url"` - IsActive pgtype.Bool `json:"is_active"` - IsVerified pgtype.Bool `json:"is_verified"` + PasswordHash pgtype.Text `json:"password_hash"` + IsVerified bool `json:"is_verified"` TokenVersion int32 `json:"token_version"` - IsDeleted pgtype.Bool `json:"is_deleted"` + IsDeleted bool `json:"is_deleted"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Profile []byte `json:"profile"` Roles []byte `json:"roles"` } @@ -129,16 +130,14 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email string) (GetUserByEm var i GetUserByEmailRow err := row.Scan( &i.ID, - &i.Name, &i.Email, &i.PasswordHash, - &i.AvatarUrl, - &i.IsActive, &i.IsVerified, &i.TokenVersion, &i.IsDeleted, &i.CreatedAt, &i.UpdatedAt, + &i.Profile, &i.Roles, ) return i, err @@ -146,44 +145,58 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email string) (GetUserByEm const getUserByID = `-- name: GetUserByID :one SELECT - u.id, - u.name, - u.email, - u.password_hash, - u.avatar_url, - u.is_active, - u.is_verified, - u.token_version, + u.id, + u.email, + u.password_hash, + u.is_verified, + u.token_version, u.refresh_token, u.is_deleted, - u.created_at, + u.created_at, u.updated_at, - COALESCE( - json_agg( - json_build_object('id', r.id, 'name', r.name) - ) FILTER (WHERE r.id IS NOT NULL), - '[]' - )::json AS roles + + -- profile JSON + ( + SELECT json_build_object( + 'display_name', p.display_name, + 'full_name', p.full_name, + 'avatar_url', p.avatar_url, + 'bio', p.bio, + 'location', p.location, + 'website', p.website, + 'country_code', p.country_code, + 'phone', p.phone + ) + FROM user_profiles p + WHERE p.user_id = u.id + ) AS profile, + + -- roles JSON + ( + SELECT COALESCE( + json_agg(json_build_object('id', r.id, 'name', r.name)), + '[]' + )::json + FROM user_roles ur + JOIN roles r ON ur.role_id = r.id + WHERE ur.user_id = u.id + ) AS roles + FROM users u -LEFT JOIN user_roles ur ON u.id = ur.user_id -LEFT JOIN roles r ON ur.role_id = r.id WHERE u.id = $1 AND u.is_deleted = false -GROUP BY u.id ` type GetUserByIDRow struct { ID pgtype.UUID `json:"id"` - Name string `json:"name"` Email string `json:"email"` - PasswordHash string `json:"password_hash"` - AvatarUrl pgtype.Text `json:"avatar_url"` - IsActive pgtype.Bool `json:"is_active"` - IsVerified pgtype.Bool `json:"is_verified"` + PasswordHash pgtype.Text `json:"password_hash"` + IsVerified bool `json:"is_verified"` TokenVersion int32 `json:"token_version"` RefreshToken pgtype.Text `json:"refresh_token"` - IsDeleted pgtype.Bool `json:"is_deleted"` + IsDeleted bool `json:"is_deleted"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Profile []byte `json:"profile"` Roles []byte `json:"roles"` } @@ -192,17 +205,15 @@ func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (GetUserByIDR var i GetUserByIDRow err := row.Scan( &i.ID, - &i.Name, &i.Email, &i.PasswordHash, - &i.AvatarUrl, - &i.IsActive, &i.IsVerified, &i.TokenVersion, &i.RefreshToken, &i.IsDeleted, &i.CreatedAt, &i.UpdatedAt, + &i.Profile, &i.Roles, ) return i, err @@ -210,44 +221,56 @@ func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (GetUserByIDR const getUsers = `-- name: GetUsers :many SELECT - u.id, - u.name, - u.email, - u.password_hash, - u.avatar_url, - u.is_active, - u.is_verified, - u.token_version, - u.refresh_token, + u.id, + u.email, + u.password_hash, + u.is_verified, + u.token_version, + u.refresh_token, u.is_deleted, - u.created_at, + u.created_at, u.updated_at, - COALESCE( - json_agg( - json_build_object('id', r.id, 'name', r.name) - ) FILTER (WHERE r.id IS NOT NULL), - '[]' - )::json AS roles + + ( + SELECT json_build_object( + 'display_name', p.display_name, + 'full_name', p.full_name, + 'avatar_url', p.avatar_url, + 'bio', p.bio, + 'location', p.location, + 'website', p.website, + 'country_code', p.country_code, + 'phone', p.phone + ) + FROM user_profiles p + WHERE p.user_id = u.id + ) AS profile, + + ( + SELECT COALESCE( + json_agg(json_build_object('id', r.id, 'name', r.name)), + '[]' + )::json + FROM user_roles ur + JOIN roles r ON ur.role_id = r.id + WHERE ur.user_id = u.id + ) AS roles + FROM users u -LEFT JOIN user_roles ur ON u.id = ur.user_id -LEFT JOIN roles r ON ur.role_id = r.id WHERE u.is_deleted = false -GROUP BY u.id ` type GetUsersRow struct { ID pgtype.UUID `json:"id"` - Name string `json:"name"` Email string `json:"email"` - PasswordHash string `json:"password_hash"` - AvatarUrl pgtype.Text `json:"avatar_url"` - IsActive pgtype.Bool `json:"is_active"` - IsVerified pgtype.Bool `json:"is_verified"` + PasswordHash pgtype.Text `json:"password_hash"` + IsVerified bool `json:"is_verified"` TokenVersion int32 `json:"token_version"` RefreshToken pgtype.Text `json:"refresh_token"` - IsDeleted pgtype.Bool `json:"is_deleted"` + IsDeleted bool `json:"is_deleted"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Profile []byte `json:"profile"` Roles []byte `json:"roles"` } @@ -262,17 +285,15 @@ func (q *Queries) GetUsers(ctx context.Context) ([]GetUsersRow, error) { var i GetUsersRow if err := rows.Scan( &i.ID, - &i.Name, &i.Email, &i.PasswordHash, - &i.AvatarUrl, - &i.IsActive, &i.IsVerified, &i.TokenVersion, &i.RefreshToken, &i.IsDeleted, &i.CreatedAt, &i.UpdatedAt, + &i.Profile, &i.Roles, ); err != nil { return nil, err @@ -288,8 +309,7 @@ func (q *Queries) GetUsers(ctx context.Context) ([]GetUsersRow, error) { const restoreUser = `-- name: RestoreUser :exec UPDATE users SET - is_deleted = false, - updated_at = now() + is_deleted = false WHERE id = $1 ` @@ -298,99 +318,33 @@ func (q *Queries) RestoreUser(ctx context.Context, id pgtype.UUID) error { return err } -const updateUser = `-- name: UpdateUser :one +const updateTokenVersion = `-- name: UpdateTokenVersion :exec UPDATE users -SET - name = $1, - avatar_url = $2, - is_active = $3, - is_verified = $4, - updated_at = now() -WHERE users.id = $5 AND users.is_deleted = false -RETURNING - users.id, - users.name, - users.email, - users.password_hash, - users.avatar_url, - users.is_active, - users.is_verified, - users.token_version, - users.refresh_token, - users.is_deleted, - users.created_at, - users.updated_at, - ( - SELECT COALESCE(json_agg(json_build_object('id', roles.id, 'name', roles.name)), '[]')::json - FROM user_roles - JOIN roles ON user_roles.role_id = roles.id - WHERE user_roles.user_id = users.id - ) AS roles +SET token_version = $2 +WHERE id = $1 AND is_deleted = false ` -type UpdateUserParams struct { - Name string `json:"name"` - AvatarUrl pgtype.Text `json:"avatar_url"` - IsActive pgtype.Bool `json:"is_active"` - IsVerified pgtype.Bool `json:"is_verified"` - ID pgtype.UUID `json:"id"` +type UpdateTokenVersionParams struct { + ID pgtype.UUID `json:"id"` + TokenVersion int32 `json:"token_version"` } -type UpdateUserRow struct { - ID pgtype.UUID `json:"id"` - Name string `json:"name"` - Email string `json:"email"` - PasswordHash string `json:"password_hash"` - AvatarUrl pgtype.Text `json:"avatar_url"` - IsActive pgtype.Bool `json:"is_active"` - IsVerified pgtype.Bool `json:"is_verified"` - TokenVersion int32 `json:"token_version"` - RefreshToken pgtype.Text `json:"refresh_token"` - IsDeleted pgtype.Bool `json:"is_deleted"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - Roles []byte `json:"roles"` -} - -func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (UpdateUserRow, error) { - row := q.db.QueryRow(ctx, updateUser, - arg.Name, - arg.AvatarUrl, - arg.IsActive, - arg.IsVerified, - arg.ID, - ) - var i UpdateUserRow - err := row.Scan( - &i.ID, - &i.Name, - &i.Email, - &i.PasswordHash, - &i.AvatarUrl, - &i.IsActive, - &i.IsVerified, - &i.TokenVersion, - &i.RefreshToken, - &i.IsDeleted, - &i.CreatedAt, - &i.UpdatedAt, - &i.Roles, - ) - return i, err +func (q *Queries) UpdateTokenVersion(ctx context.Context, arg UpdateTokenVersionParams) error { + _, err := q.db.Exec(ctx, updateTokenVersion, arg.ID, arg.TokenVersion) + return err } const updateUserPassword = `-- name: UpdateUserPassword :exec UPDATE users SET - password_hash = $2, - updated_at = now() + password_hash = $2 WHERE id = $1 AND is_deleted = false ` type UpdateUserPasswordParams struct { ID pgtype.UUID `json:"id"` - PasswordHash string `json:"password_hash"` + PasswordHash pgtype.Text `json:"password_hash"` } func (q *Queries) UpdateUserPassword(ctx context.Context, arg UpdateUserPasswordParams) error { @@ -398,11 +352,137 @@ func (q *Queries) UpdateUserPassword(ctx context.Context, arg UpdateUserPassword return err } +const updateUserProfile = `-- name: UpdateUserProfile :one +UPDATE user_profiles +SET + display_name = $1, + full_name = $2, + avatar_url = $3, + bio = $4, + location = $5, + website = $6, + country_code = $7, + phone = $8, + updated_at = now() +WHERE user_id = $9 +RETURNING user_id, display_name, full_name, avatar_url, bio, location, website, country_code, phone, created_at, updated_at +` + +type UpdateUserProfileParams struct { + DisplayName pgtype.Text `json:"display_name"` + FullName pgtype.Text `json:"full_name"` + AvatarUrl pgtype.Text `json:"avatar_url"` + Bio pgtype.Text `json:"bio"` + Location pgtype.Text `json:"location"` + Website pgtype.Text `json:"website"` + CountryCode pgtype.Text `json:"country_code"` + Phone pgtype.Text `json:"phone"` + UserID pgtype.UUID `json:"user_id"` +} + +func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) (UserProfile, error) { + row := q.db.QueryRow(ctx, updateUserProfile, + arg.DisplayName, + arg.FullName, + arg.AvatarUrl, + arg.Bio, + arg.Location, + arg.Website, + arg.CountryCode, + arg.Phone, + arg.UserID, + ) + var i UserProfile + err := row.Scan( + &i.UserID, + &i.DisplayName, + &i.FullName, + &i.AvatarUrl, + &i.Bio, + &i.Location, + &i.Website, + &i.CountryCode, + &i.Phone, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updateUserRefreshToken = `-- name: UpdateUserRefreshToken :exec +UPDATE users +SET + refresh_token = $2 +WHERE id = $1 + AND is_deleted = false +` + +type UpdateUserRefreshTokenParams struct { + ID pgtype.UUID `json:"id"` + RefreshToken pgtype.Text `json:"refresh_token"` +} + +func (q *Queries) UpdateUserRefreshToken(ctx context.Context, arg UpdateUserRefreshTokenParams) error { + _, err := q.db.Exec(ctx, updateUserRefreshToken, arg.ID, arg.RefreshToken) + return err +} + +const upsertUser = `-- name: UpsertUser :one +INSERT INTO users ( + email, + password_hash, + google_id, + auth_provider, + is_verified +) VALUES ( + $1, $2, $3, $4, $5 +) +ON CONFLICT (email) +DO UPDATE SET + google_id = EXCLUDED.google_id, + auth_provider = EXCLUDED.auth_provider, + is_verified = users.is_verified OR EXCLUDED.is_verified, + updated_at = now() +RETURNING id, email, password_hash, google_id, auth_provider, is_verified, is_deleted, token_version, refresh_token, created_at, updated_at +` + +type UpsertUserParams struct { + Email string `json:"email"` + PasswordHash pgtype.Text `json:"password_hash"` + GoogleID pgtype.Text `json:"google_id"` + AuthProvider string `json:"auth_provider"` + IsVerified bool `json:"is_verified"` +} + +func (q *Queries) UpsertUser(ctx context.Context, arg UpsertUserParams) (User, error) { + row := q.db.QueryRow(ctx, upsertUser, + arg.Email, + arg.PasswordHash, + arg.GoogleID, + arg.AuthProvider, + arg.IsVerified, + ) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.PasswordHash, + &i.GoogleID, + &i.AuthProvider, + &i.IsVerified, + &i.IsDeleted, + &i.TokenVersion, + &i.RefreshToken, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + const verifyUser = `-- name: VerifyUser :exec UPDATE users SET - is_verified = true, - updated_at = now() + is_verified = true WHERE id = $1 AND is_deleted = false ` diff --git a/internal/middlewares/jwtMiddleware.go b/internal/middlewares/jwtMiddleware.go index c7a1c6b..ffd0f85 100644 --- a/internal/middlewares/jwtMiddleware.go +++ b/internal/middlewares/jwtMiddleware.go @@ -1,9 +1,9 @@ package middlewares import ( + "history-api/internal/dtos/response" "history-api/pkg/config" "history-api/pkg/constant" - "history-api/pkg/dtos/response" "slices" jwtware "github.com/gofiber/contrib/v3/jwt" diff --git a/internal/middlewares/roleMiddleware.go b/internal/middlewares/roleMiddleware.go index f4ee409..3d1faa4 100644 --- a/internal/middlewares/roleMiddleware.go +++ b/internal/middlewares/roleMiddleware.go @@ -1,8 +1,8 @@ package middlewares import ( + "history-api/internal/dtos/response" "history-api/pkg/constant" - "history-api/pkg/dtos/response" "slices" "github.com/gofiber/fiber/v3" diff --git a/internal/models/profile.go b/internal/models/profile.go new file mode 100644 index 0000000..8f7409f --- /dev/null +++ b/internal/models/profile.go @@ -0,0 +1,27 @@ +package models + +import "history-api/internal/dtos/response" + +type UserProfileSimple struct { + DisplayName string `json:"display_name"` + FullName string `json:"full_name"` + AvatarUrl string `json:"avatar_url"` + Bio string `json:"bio"` + Location string `json:"location"` + Website string `json:"website"` + CountryCode string `json:"country_code"` + Phone string `json:"phone"` +} + +func (p *UserProfileSimple) ToResponse() *response.UserProfileSimpleResponse { + return &response.UserProfileSimpleResponse{ + DisplayName: p.DisplayName, + FullName: p.FullName, + AvatarUrl: p.AvatarUrl, + Bio: p.Bio, + Location: p.Location, + Website: p.Website, + CountryCode: p.CountryCode, + Phone: p.Phone, + } +} diff --git a/pkg/models/role.go b/internal/models/role.go similarity index 51% rename from pkg/models/role.go rename to internal/models/role.go index 76080ae..168406e 100644 --- a/pkg/models/role.go +++ b/internal/models/role.go @@ -1,20 +1,19 @@ package models import ( - "history-api/pkg/dtos/response" - "history-api/pkg/convert" - - "github.com/jackc/pgx/v5/pgtype" + "history-api/internal/dtos/response" + "history-api/pkg/constant" + "time" ) type RoleSimple struct { - ID pgtype.UUID `json:"id"` - Name string `json:"name"` + ID string `json:"id"` + Name string `json:"name"` } func (r *RoleSimple) ToResponse() *response.RoleSimpleResponse { return &response.RoleSimpleResponse{ - ID: convert.UUIDToString(r.ID), + ID: r.ID, Name: r.Name, } } @@ -28,20 +27,20 @@ func RolesToResponse(rs []*RoleSimple) []*response.RoleSimpleResponse { } type RoleEntity struct { - ID pgtype.UUID `json:"id"` - Name string `json:"name"` - IsDeleted pgtype.Bool `json:"is_deleted"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ID string `json:"id"` + Name string `json:"name"` + IsDeleted bool `json:"is_deleted"` + CreatedAt *time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at"` } func (r *RoleEntity) ToResponse() *response.RoleResponse { return &response.RoleResponse{ - ID: convert.UUIDToString(r.ID), + ID: r.ID, Name: r.Name, - IsDeleted: convert.BoolVal(r.IsDeleted), - CreatedAt: convert.TimeToPtr(r.CreatedAt), - UpdatedAt: convert.TimeToPtr(r.UpdatedAt), + IsDeleted: r.IsDeleted, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, } } @@ -52,3 +51,15 @@ func RolesEntityToResponse(rs []*RoleEntity) []*response.RoleResponse { } return out } + +func RolesEntityToRoleConstant(rs []*RoleSimple) []constant.Role { + out := make([]constant.Role, len(rs)) + for i := range rs { + data, ok := constant.ParseRole(rs[i].Name) + if !ok { + continue + } + out[i] = data + } + return out +} diff --git a/pkg/models/token.go b/internal/models/token.go similarity index 95% rename from pkg/models/token.go rename to internal/models/token.go index 9f59d1d..bc51f2b 100644 --- a/pkg/models/token.go +++ b/internal/models/token.go @@ -1,8 +1,8 @@ package models import ( + "history-api/internal/dtos/response" "history-api/pkg/convert" - "history-api/pkg/dtos/response" "github.com/jackc/pgx/v5/pgtype" ) diff --git a/internal/models/user.go b/internal/models/user.go new file mode 100644 index 0000000..422dea0 --- /dev/null +++ b/internal/models/user.go @@ -0,0 +1,61 @@ +package models + +import ( + "encoding/json" + "history-api/internal/dtos/response" + "time" +) + +type UserEntity struct { + ID string `json:"id"` + Email string `json:"email"` + PasswordHash string `json:"password_hash"` + Profile *UserProfileSimple `json:"profile"` + IsVerified bool `json:"is_verified"` + TokenVersion int32 `json:"token_version"` + GoogleID string `json:"google_id"` + AuthProvider string `json:"auth_provider"` + RefreshToken string `json:"refresh_token"` + IsDeleted bool `json:"is_deleted"` + CreatedAt *time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at"` + Roles []*RoleSimple `json:"roles"` +} + +func (u *UserEntity) ParseRoles(data []byte) error { + if len(data) == 0 { + u.Roles = []*RoleSimple{} + return nil + } + return json.Unmarshal(data, &u.Roles) +} + +func (u *UserEntity) ParseProfile(data []byte) error { + if len(data) == 0 { + u.Profile = &UserProfileSimple{} + return nil + } + return json.Unmarshal(data, &u.Profile) +} + +func (u *UserEntity) ToResponse() *response.UserResponse { + return &response.UserResponse{ + ID: u.ID, + Email: u.Email, + IsVerified: u.IsVerified, + TokenVersion: u.TokenVersion, + IsDeleted: u.IsDeleted, + CreatedAt: u.CreatedAt, + UpdatedAt: u.UpdatedAt, + Roles: RolesToResponse(u.Roles), + Profile: u.Profile.ToResponse(), + } +} + +func UsersEntityToResponse(rs []*UserEntity) []*response.UserResponse { + out := make([]*response.UserResponse, len(rs)) + for i := range rs { + out[i] = rs[i].ToResponse() + } + return out +} diff --git a/internal/repositories/roleRepository.go b/internal/repositories/roleRepository.go index c453882..29522ad 100644 --- a/internal/repositories/roleRepository.go +++ b/internal/repositories/roleRepository.go @@ -2,11 +2,15 @@ package repositories import ( "context" + "fmt" + "time" "github.com/jackc/pgx/v5/pgtype" "history-api/internal/gen/sqlc" - "history-api/pkg/models" + "history-api/internal/models" + "history-api/pkg/cache" + "history-api/pkg/convert" ) type RoleRepository interface { @@ -25,43 +29,63 @@ type RoleRepository interface { type roleRepository struct { q *sqlc.Queries + c cache.Cache } -func NewRoleRepository(db sqlc.DBTX) RoleRepository { +func NewRoleRepository(db sqlc.DBTX, c cache.Cache) RoleRepository { return &roleRepository{ q: sqlc.New(db), + c: c, } } func (r *roleRepository) GetByID(ctx context.Context, id pgtype.UUID) (*models.RoleEntity, error) { + cacheId := fmt.Sprintf("role:id:%s", convert.UUIDToString(id)) + var role models.RoleEntity + err := r.c.Get(ctx, cacheId, &role) + if err == nil { + return &role, nil + } + row, err := r.q.GetRoleByID(ctx, id) if err != nil { return nil, err } - role := &models.RoleEntity{ - ID: row.ID, + role = models.RoleEntity{ + ID: convert.UUIDToString(row.ID), Name: row.Name, IsDeleted: row.IsDeleted, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, + CreatedAt: convert.TimeToPtr(row.CreatedAt), + UpdatedAt: convert.TimeToPtr(row.UpdatedAt), } - return role, nil + _ = r.c.Set(ctx, cacheId, role, 5*time.Minute) + + return &role, nil } func (r *roleRepository) GetByname(ctx context.Context, name string) (*models.RoleEntity, error) { + cacheId := fmt.Sprintf("role:name:%s", name) + var role models.RoleEntity + err := r.c.Get(ctx, cacheId, &role) + if err == nil { + return &role, nil + } row, err := r.q.GetRoleByName(ctx, name) if err != nil { return nil, err } - role := &models.RoleEntity{ - ID: row.ID, + role = models.RoleEntity{ + ID: convert.UUIDToString(row.ID), Name: row.Name, IsDeleted: row.IsDeleted, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, + CreatedAt: convert.TimeToPtr(row.CreatedAt), + UpdatedAt: convert.TimeToPtr(row.UpdatedAt), } - return role, nil + + _ = r.c.Set(ctx, cacheId, role, 5*time.Minute) + + return &role, nil } func (r *roleRepository) Create(ctx context.Context, name string) (*models.RoleEntity, error) { @@ -69,14 +93,19 @@ func (r *roleRepository) Create(ctx context.Context, name string) (*models.RoleE if err != nil { return nil, err } - role := &models.RoleEntity{ - ID: row.ID, + role := models.RoleEntity{ + ID: convert.UUIDToString(row.ID), Name: row.Name, IsDeleted: row.IsDeleted, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, + CreatedAt: convert.TimeToPtr(row.CreatedAt), + UpdatedAt: convert.TimeToPtr(row.UpdatedAt), } - return role, nil + mapCache := map[string]any{ + fmt.Sprintf("role:name:%s", name): role, + fmt.Sprintf("role:id:%s", convert.UUIDToString(row.ID)): role, + } + _ = r.c.MSet(ctx, mapCache, 5*time.Minute) + return &role, nil } func (r *roleRepository) Update(ctx context.Context, params sqlc.UpdateRoleParams) (*models.RoleEntity, error) { @@ -84,14 +113,20 @@ func (r *roleRepository) Update(ctx context.Context, params sqlc.UpdateRoleParam if err != nil { return nil, err } - role := &models.RoleEntity{ - ID: row.ID, + role := models.RoleEntity{ + ID: convert.UUIDToString(row.ID), Name: row.Name, IsDeleted: row.IsDeleted, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, + CreatedAt: convert.TimeToPtr(row.CreatedAt), + UpdatedAt: convert.TimeToPtr(row.UpdatedAt), } - return role, nil + + mapCache := map[string]any{ + fmt.Sprintf("role:name:%s", row.Name): role, + fmt.Sprintf("role:id:%s", convert.UUIDToString(row.ID)): role, + } + _ = r.c.MSet(ctx, mapCache, 5*time.Minute) + return &role, nil } func (r *roleRepository) All(ctx context.Context) ([]*models.RoleEntity, error) { @@ -103,11 +138,11 @@ func (r *roleRepository) All(ctx context.Context) ([]*models.RoleEntity, error) var users []*models.RoleEntity for _, row := range rows { user := &models.RoleEntity{ - ID: row.ID, - Name: row.Name, - IsDeleted: row.IsDeleted, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, + ID: convert.UUIDToString(row.ID), + Name: row.Name, + IsDeleted: row.IsDeleted, + CreatedAt: convert.TimeToPtr(row.CreatedAt), + UpdatedAt: convert.TimeToPtr(row.UpdatedAt), } users = append(users, user) } @@ -116,33 +151,44 @@ func (r *roleRepository) All(ctx context.Context) ([]*models.RoleEntity, error) } func (r *roleRepository) Delete(ctx context.Context, id pgtype.UUID) error { - err := r.q.DeleteRole(ctx, id) - return err + role, err := r.GetByID(ctx, id) + if err != nil { + return err + } + + err = r.q.DeleteRole(ctx, id) + if err != nil { + return err + } + _ = r.c.Del(ctx, fmt.Sprintf("role:id:%s", role.ID), fmt.Sprintf("role:name:%s", role.Name)) + return nil } func (r *roleRepository) Restore(ctx context.Context, id pgtype.UUID) error { err := r.q.RestoreRole(ctx, id) - return err + if err != nil { + return err + } + _ = r.c.Del(ctx, fmt.Sprintf("role:id:%s", convert.UUIDToString(id))) + return nil } func (r *roleRepository) AddUserRole(ctx context.Context, params sqlc.AddUserRoleParams) error { err := r.q.AddUserRole(ctx, params) - return err + return err } func (r *roleRepository) RemoveUserRole(ctx context.Context, params sqlc.RemoveUserRoleParams) error { err := r.q.RemoveUserRole(ctx, params) - return err + return err } func (r *roleRepository) RemoveAllUsersFromRole(ctx context.Context, roleId pgtype.UUID) error { err := r.q.RemoveAllUsersFromRole(ctx, roleId) - return err + return err } func (r *roleRepository) RemoveAllRolesFromUser(ctx context.Context, roleId pgtype.UUID) error { err := r.q.RemoveAllRolesFromUser(ctx, roleId) - return err + return err } - - diff --git a/internal/repositories/tileRepository.go b/internal/repositories/tileRepository.go new file mode 100644 index 0000000..b84adf8 --- /dev/null +++ b/internal/repositories/tileRepository.go @@ -0,0 +1,98 @@ +package repositories + +import ( + "context" + "database/sql" + "fmt" + "history-api/pkg/cache" + "time" +) + +type TileRepository interface { + GetMetadata(ctx context.Context) (map[string]string, error) + GetTile(ctx context.Context, z, x, y int) ([]byte, string, bool, error) +} + +type tileRepository struct { + db *sql.DB + c cache.Cache +} + +func NewTileRepository(db *sql.DB, c cache.Cache) TileRepository { + return &tileRepository{ + db: db, + c: c, + } +} + +func (r *tileRepository) GetMetadata(ctx context.Context) (map[string]string, error) { + cacheId := "mbtiles:metadata" + + var cached map[string]string + err := r.c.Get(ctx, cacheId, &cached) + if err == nil { + return cached, nil + } + + rows, err := r.db.QueryContext(ctx, "SELECT name, value FROM metadata") + if err != nil { + return nil, err + } + defer rows.Close() + + metadata := make(map[string]string) + + for rows.Next() { + var name, value string + if err := rows.Scan(&name, &value); err != nil { + return nil, err + } + metadata[name] = value + } + + _ = r.c.Set(ctx, cacheId, metadata, 10*time.Minute) + + return metadata, nil +} + +func (r *tileRepository) GetTile(ctx context.Context, z, x, y int) ([]byte, string, bool, error) { + if z < 0 || x < 0 || y < 0 { + return nil, "", false, fmt.Errorf("invalid tile coordinates") + } + + // cache key + cacheId := fmt.Sprintf("tile:%d:%d:%d", z, x, y) + + var cached []byte + err := r.c.Get(ctx, cacheId, &cached) + if err == nil { + meta, _ := r.GetMetadata(ctx) + return cached, meta["format"], meta["format"] == "pbf", nil + } + + // XYZ -> TMS + tmsY := (1 << z) - 1 - y + + var tileData []byte + + err = r.db.QueryRowContext(ctx, ` + SELECT tile_data + FROM tiles + WHERE zoom_level = ? + AND tile_column = ? + AND tile_row = ? + `, z, x, tmsY).Scan(&tileData) + + if err != nil { + return nil, "", false, err + } + + meta, err := r.GetMetadata(ctx) + if err != nil { + return nil, "", false, err + } + + _ = r.c.Set(ctx, cacheId, tileData, 5*time.Minute) + + return tileData, meta["format"], meta["format"] == "pbf", nil +} diff --git a/internal/repositories/userRepository.go b/internal/repositories/userRepository.go index 174e503..fd5c34b 100644 --- a/internal/repositories/userRepository.go +++ b/internal/repositories/userRepository.go @@ -2,136 +2,187 @@ package repositories import ( "context" + "fmt" + "time" "github.com/jackc/pgx/v5/pgtype" "history-api/internal/gen/sqlc" - "history-api/pkg/models" + "history-api/internal/models" + "history-api/pkg/cache" + "history-api/pkg/convert" ) type UserRepository interface { GetByID(ctx context.Context, id pgtype.UUID) (*models.UserEntity, error) GetByEmail(ctx context.Context, email string) (*models.UserEntity, error) All(ctx context.Context) ([]*models.UserEntity, error) - Create(ctx context.Context, params sqlc.CreateUserParams) (*models.UserEntity, error) - Update(ctx context.Context, params sqlc.UpdateUserParams) (*models.UserEntity, error) - UpdatePassword(ctx context.Context, params sqlc.UpdateUserPasswordParams) error - ExistEmail(ctx context.Context, email string) (bool, error) - Verify(ctx context.Context, id pgtype.UUID) error - Delete(ctx context.Context, id pgtype.UUID) error - Restore(ctx context.Context, id pgtype.UUID) error + UpsertUser(ctx context.Context, params sqlc.UpsertUserParams) (*models.UserEntity, error) + CreateProfile(ctx context.Context, params sqlc.CreateUserProfileParams) (*models.UserProfileSimple, error) + UpdateProfile(ctx context.Context, params sqlc.UpdateUserProfileParams) (*models.UserEntity, error) + UpdatePassword(ctx context.Context, params sqlc.UpdateUserPasswordParams) error + UpdateRefreshToken(ctx context.Context, params sqlc.UpdateUserRefreshTokenParams) error + GetTokenVersion(ctx context.Context, id pgtype.UUID) (int32, error) + UpdateTokenVersion(ctx context.Context, params sqlc.UpdateTokenVersionParams) error + Verify(ctx context.Context, id pgtype.UUID) error + Delete(ctx context.Context, id pgtype.UUID) error + Restore(ctx context.Context, id pgtype.UUID) error } type userRepository struct { q *sqlc.Queries + c cache.Cache } -func NewUserRepository(db sqlc.DBTX) UserRepository { +func NewUserRepository(db sqlc.DBTX, c cache.Cache) UserRepository { return &userRepository{ q: sqlc.New(db), + c: c, } } func (r *userRepository) GetByID(ctx context.Context, id pgtype.UUID) (*models.UserEntity, error) { + cacheId := fmt.Sprintf("user:id:%s", convert.UUIDToString(id)) + var user models.UserEntity + err := r.c.Get(ctx, cacheId, &user) + if err == nil { + return &user, nil + } + row, err := r.q.GetUserByID(ctx, id) if err != nil { return nil, err } - user := &models.UserEntity{ - ID: row.ID, - Name: row.Name, + user = models.UserEntity{ + ID: convert.UUIDToString(row.ID), Email: row.Email, - PasswordHash: row.PasswordHash, - AvatarUrl: row.AvatarUrl, - IsActive: row.IsActive, + PasswordHash: convert.TextToString(row.PasswordHash), IsVerified: row.IsVerified, TokenVersion: row.TokenVersion, IsDeleted: row.IsDeleted, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, + CreatedAt: convert.TimeToPtr(row.CreatedAt), + UpdatedAt: convert.TimeToPtr(row.UpdatedAt), } if err := user.ParseRoles(row.Roles); err != nil { - return nil, err + return nil, err } - return user, nil + + if err := user.ParseProfile(row.Profile); err != nil { + return nil, err + } + + _ = r.c.Set(ctx, cacheId, user, 5*time.Minute) + + return &user, nil } func (r *userRepository) GetByEmail(ctx context.Context, email string) (*models.UserEntity, error) { + cacheId := fmt.Sprintf("user:email:%s", email) + + var user models.UserEntity + err := r.c.Get(ctx, cacheId, &user) + if err == nil { + return &user, nil + } + row, err := r.q.GetUserByEmail(ctx, email) if err != nil { return nil, err } - user := &models.UserEntity{ - ID: row.ID, - Name: row.Name, + + user = models.UserEntity{ + ID: convert.UUIDToString(row.ID), Email: row.Email, - PasswordHash: row.PasswordHash, - AvatarUrl: row.AvatarUrl, - IsActive: row.IsActive, + PasswordHash: convert.TextToString(row.PasswordHash), IsVerified: row.IsVerified, TokenVersion: row.TokenVersion, IsDeleted: row.IsDeleted, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, + CreatedAt: convert.TimeToPtr(row.CreatedAt), + UpdatedAt: convert.TimeToPtr(row.UpdatedAt), } if err := user.ParseRoles(row.Roles); err != nil { - return nil, err + return nil, err } - return user, nil + + if err := user.ParseProfile(row.Profile); err != nil { + return nil, err + } + + _ = r.c.Set(ctx, cacheId, user, 5*time.Minute) + + return &user, nil } -func (r *userRepository) Create(ctx context.Context, params sqlc.CreateUserParams) (*models.UserEntity, error) { - row, err := r.q.CreateUser(ctx, params) +func (r *userRepository) UpsertUser(ctx context.Context, params sqlc.UpsertUserParams) (*models.UserEntity, error) { + row, err := r.q.UpsertUser(ctx, params) if err != nil { return nil, err } return &models.UserEntity{ - ID: row.ID, - Name: row.Name, + ID: convert.UUIDToString(row.ID), Email: row.Email, - PasswordHash: row.PasswordHash, - AvatarUrl: row.AvatarUrl, - IsActive: row.IsActive, + PasswordHash: convert.TextToString(row.PasswordHash), IsVerified: row.IsVerified, TokenVersion: row.TokenVersion, - RefreshToken: row.RefreshToken, IsDeleted: row.IsDeleted, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, + CreatedAt: convert.TimeToPtr(row.CreatedAt), + UpdatedAt: convert.TimeToPtr(row.UpdatedAt), Roles: make([]*models.RoleSimple, 0), }, nil } -func (r *userRepository) Update(ctx context.Context, params sqlc.UpdateUserParams) (*models.UserEntity, error) { - row, err := r.q.UpdateUser(ctx, params) +func (r *userRepository) UpdateProfile(ctx context.Context, params sqlc.UpdateUserProfileParams) (*models.UserEntity, error) { + user, err := r.GetByID(ctx, params.UserID) if err != nil { return nil, err } - user := &models.UserEntity{ - ID: row.ID, - Name: row.Name, - Email: row.Email, - PasswordHash: row.PasswordHash, - AvatarUrl: row.AvatarUrl, - IsActive: row.IsActive, - IsVerified: row.IsVerified, - TokenVersion: row.TokenVersion, - IsDeleted: row.IsDeleted, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, + + row, err := r.q.UpdateUserProfile(ctx, params) + if err != nil { + return nil, err + } + profile := models.UserProfileSimple{ + DisplayName: convert.TextToString(row.DisplayName), + FullName: convert.TextToString(row.FullName), + AvatarUrl: convert.TextToString(row.AvatarUrl), + Bio: convert.TextToString(row.Bio), + Location: convert.TextToString(row.Location), + Website: convert.TextToString(row.Website), + CountryCode: convert.TextToString(row.CountryCode), + Phone: convert.TextToString(row.Phone), } - if err := user.ParseRoles(row.Roles); err != nil { - return nil, err + user.Profile = &profile + mapCache := map[string]any{ + fmt.Sprintf("user:email:%s", user.Email): user, + fmt.Sprintf("user:id:%s", user.ID): user, } - + _ = r.c.MSet(ctx, mapCache, 5*time.Minute) return user, nil } +func (r *userRepository) CreateProfile(ctx context.Context, params sqlc.CreateUserProfileParams) (*models.UserProfileSimple, error) { + row, err := r.q.CreateUserProfile(ctx, params) + if err != nil { + return nil, err + } + + return &models.UserProfileSimple{ + DisplayName: convert.TextToString(row.DisplayName), + FullName: convert.TextToString(row.FullName), + AvatarUrl: convert.TextToString(row.AvatarUrl), + Bio: convert.TextToString(row.Bio), + Location: convert.TextToString(row.Location), + Website: convert.TextToString(row.Website), + CountryCode: convert.TextToString(row.CountryCode), + Phone: convert.TextToString(row.Phone), + }, nil +} + func (r *userRepository) All(ctx context.Context) ([]*models.UserEntity, error) { rows, err := r.q.GetUsers(ctx) if err != nil { @@ -141,23 +192,24 @@ func (r *userRepository) All(ctx context.Context) ([]*models.UserEntity, error) var users []*models.UserEntity for _, row := range rows { user := &models.UserEntity{ - ID: row.ID, - Name: row.Name, + ID: convert.UUIDToString(row.ID), Email: row.Email, - PasswordHash: row.PasswordHash, - AvatarUrl: row.AvatarUrl, - IsActive: row.IsActive, + PasswordHash: convert.TextToString(row.PasswordHash), IsVerified: row.IsVerified, TokenVersion: row.TokenVersion, IsDeleted: row.IsDeleted, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, + CreatedAt: convert.TimeToPtr(row.CreatedAt), + UpdatedAt: convert.TimeToPtr(row.UpdatedAt), } if err := user.ParseRoles(row.Roles); err != nil { return nil, err } + if err := user.ParseProfile(row.Profile); err != nil { + return nil, err + } + users = append(users, user) } @@ -165,29 +217,138 @@ func (r *userRepository) All(ctx context.Context) ([]*models.UserEntity, error) } func (r *userRepository) Verify(ctx context.Context, id pgtype.UUID) error { - err := r.q.VerifyUser(ctx, id) - return err + user, err := r.GetByID(ctx, id) + if err != nil { + return err + } + + err = r.q.VerifyUser(ctx, id) + if err != nil { + return err + } + err = r.q.UpdateTokenVersion(ctx, sqlc.UpdateTokenVersionParams{ + ID: id, + TokenVersion: user.TokenVersion + 1, + }) + if err != nil { + return err + } + + user.IsVerified = true + user.TokenVersion += 1 + + mapCache := map[string]any{ + fmt.Sprintf("user:email:%s", user.Email): user, + fmt.Sprintf("user:id:%s", user.ID): user, + } + _ = r.c.MSet(ctx, mapCache, 5*time.Minute) + return nil } func (r *userRepository) Delete(ctx context.Context, id pgtype.UUID) error { - err := r.q.DeleteUser(ctx, id) - return err + user, err := r.GetByID(ctx, id) + if err != nil { + return err + } + + err = r.q.DeleteUser(ctx, id) + if err != nil { + return err + } + + _ = r.c.Del( + ctx, + fmt.Sprintf("user:id:%s", user.ID), + fmt.Sprintf("user:email:%s", user.Email), + fmt.Sprintf("user:token:%s", user.ID), + ) + return nil } func (r *userRepository) Restore(ctx context.Context, id pgtype.UUID) error { err := r.q.RestoreUser(ctx, id) - return err + if err != nil { + return err + } + + _ = r.c.Del(ctx, fmt.Sprintf("user:id:%s", convert.UUIDToString(id))) + return nil +} + +func (r *userRepository) GetTokenVersion(ctx context.Context, id pgtype.UUID) (int32, error) { + cacheId := fmt.Sprintf("user:token:%s", convert.UUIDToString(id)) + var token int32 + err := r.c.Get(ctx, cacheId, &token) + if err == nil { + return token, nil + } + + raw, err := r.q.GetTokenVersion(ctx, id) + if err != nil { + return 0, err + } + + _ = r.c.Set(ctx, cacheId, raw, 5*time.Minute) + return raw, nil +} + +func (r *userRepository) UpdateTokenVersion(ctx context.Context, params sqlc.UpdateTokenVersionParams) error { + err := r.q.UpdateTokenVersion(ctx, params) + if err != nil { + return err + } + + cacheId := fmt.Sprintf("user:token:%s", convert.UUIDToString(params.ID)) + _ = r.c.Set(ctx, cacheId, params.TokenVersion, 5*time.Minute) + return nil } func (r *userRepository) UpdatePassword(ctx context.Context, params sqlc.UpdateUserPasswordParams) error { - err := r.q.UpdateUserPassword(ctx, params) - return err + user, err := r.GetByID(ctx, params.ID) + if err != nil { + return err + } + err = r.q.UpdateUserPassword(ctx, params) + if err != nil { + return err + } + err = r.UpdateTokenVersion(ctx, sqlc.UpdateTokenVersionParams{ + ID: params.ID, + TokenVersion: user.TokenVersion + 1, + }) + if err != nil { + return err + } + + user.PasswordHash = convert.TextToString(params.PasswordHash) + user.TokenVersion += 1 + mapCache := map[string]any{ + fmt.Sprintf("user:email:%s", user.Email): user, + fmt.Sprintf("user:id:%s", user.ID): user, + fmt.Sprintf("user:token:%s", user.ID): user.TokenVersion, + } + + _ = r.c.MSet(ctx, mapCache, 5*time.Minute) + return nil } -func (r *userRepository) ExistEmail(ctx context.Context, email string) (bool, error) { - row, err := r.q.ExistsUserByEmail(ctx, email) +func (r *userRepository) UpdateRefreshToken(ctx context.Context, params sqlc.UpdateUserRefreshTokenParams) error { + user, err := r.GetByID(ctx, params.ID) if err != nil { - return false, err + return err } - return row, nil -} \ No newline at end of file + err = r.q.UpdateUserRefreshToken(ctx, params) + if err != nil { + return err + } + + user.RefreshToken = convert.TextToString(params.RefreshToken) + mapCache := map[string]any{ + fmt.Sprintf("user:email:%s", user.Email): user, + fmt.Sprintf("user:id:%s", user.ID): user, + fmt.Sprintf("user:token:%s", user.ID): user.TokenVersion, + } + + _ = r.c.MSet(ctx, mapCache, 5*time.Minute) + return nil +} diff --git a/internal/routers/authRoutes.go b/internal/routers/authRoutes.go deleted file mode 100644 index e5a152a..0000000 --- a/internal/routers/authRoutes.go +++ /dev/null @@ -1 +0,0 @@ -package routers \ No newline at end of file diff --git a/internal/routes/authRoute.go b/internal/routes/authRoute.go new file mode 100644 index 0000000..1ebee53 --- /dev/null +++ b/internal/routes/authRoute.go @@ -0,0 +1,15 @@ +package routes + +import ( + "history-api/internal/controllers" + "history-api/internal/middlewares" + + "github.com/gofiber/fiber/v3" +) + +func AuthRoutes(app *fiber.App, controller *controllers.AuthController) { + route := app.Group("/auth") + route.Post("/signin", controller.Signin) + route.Post("/signup", controller.Signup) + route.Post("/refresh", middlewares.JwtRefresh(), controller.RefreshToken) +} diff --git a/internal/routes/notFoundRoute.go b/internal/routes/notFoundRoute.go new file mode 100644 index 0000000..3bdc078 --- /dev/null +++ b/internal/routes/notFoundRoute.go @@ -0,0 +1,18 @@ +package routes + +import ( + "history-api/internal/dtos/response" + + "github.com/gofiber/fiber/v3" +) + +func NotFoundRoute(app *fiber.App) { + app.Use( + func(c fiber.Ctx) error { + return c.Status(fiber.StatusNotFound).JSON(response.CommonResponse{ + Status: false, + Message: "sorry, endpoint is not found", + }) + }, + ) +} diff --git a/internal/routes/tileRoute.go b/internal/routes/tileRoute.go new file mode 100644 index 0000000..dc7147c --- /dev/null +++ b/internal/routes/tileRoute.go @@ -0,0 +1,13 @@ +package routes + +import ( + "history-api/internal/controllers" + + "github.com/gofiber/fiber/v3" +) + +func TileRoutes(app *fiber.App, controller *controllers.TileController) { + route := app.Group("/tiles") + route.Get("/metadata", controller.GetMetadata) + route.Get("/:z/:x/:y", controller.GetTile) +} diff --git a/internal/services/authService.go b/internal/services/authService.go index c1ce6ce..67ef754 100644 --- a/internal/services/authService.go +++ b/internal/services/authService.go @@ -1 +1,294 @@ -package services \ No newline at end of file +package services + +import ( + "context" + "history-api/internal/dtos/request" + "history-api/internal/dtos/response" + "history-api/internal/gen/sqlc" + "history-api/internal/models" + "history-api/internal/repositories" + "history-api/pkg/config" + "history-api/pkg/constant" + + "slices" + "time" + + "github.com/gofiber/fiber/v3" + "github.com/golang-jwt/jwt/v5" + "github.com/jackc/pgx/v5/pgtype" + "golang.org/x/crypto/bcrypt" +) + +type AuthService interface { + Signin(ctx context.Context, dto *request.SignInDto) (*response.AuthResponse, error) + Signup(ctx context.Context, dto *request.SignUpDto) (*response.AuthResponse, error) + ForgotPassword(ctx context.Context) error + VerifyToken(ctx context.Context) error + CreateToken(ctx context.Context) error + SigninWith3rd(ctx context.Context) error + RefreshToken(ctx context.Context, id string) (*response.AuthResponse, error) +} + +type authService struct { + userRepo repositories.UserRepository + roleRepo repositories.RoleRepository +} + +func NewAuthService( + userRepo repositories.UserRepository, + roleRepo repositories.RoleRepository, +) AuthService { + return &authService{ + userRepo: userRepo, + roleRepo: roleRepo, + } +} + +func (a *authService) genToken(Uid string, role []constant.Role) (*response.AuthResponse, error) { + jwtSecret, err := config.GetConfig("JWT_SECRET") + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "missing JWT_SECRET in environment") + } + jwtRefreshSecret, err := config.GetConfig("JWT_REFRESH_SECRET") + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "missing JWT_REFRESH_SECRET in environment") + } + + if jwtSecret == "" || jwtRefreshSecret == "" { + return nil, fiber.NewError(fiber.StatusInternalServerError, "missing JWT secrets in environment") + } + + claimsAccess := &response.JWTClaims{ + UId: Uid, + Roles: role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * time.Minute)), + }, + } + + claimsRefresh := &response.JWTClaims{ + UId: Uid, + Roles: role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * 24 * time.Hour)), + }, + } + + accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claimsAccess) + at, err := accessToken.SignedString([]byte(jwtSecret)) + if err != nil { + return nil, err + } + + refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claimsRefresh) + rt, err := refreshToken.SignedString([]byte(jwtRefreshSecret)) + if err != nil { + return nil, err + } + + res := response.AuthResponse{ + AccessToken: at, + RefreshToken: rt, + } + return &res, nil +} + +func (a *authService) saveNewRefreshToken(ctx context.Context, params sqlc.UpdateUserRefreshTokenParams) error { + err := a.userRepo.UpdateRefreshToken(ctx, params) + if err != nil { + return err + } + return nil +} + +func (a *authService) Signin(ctx context.Context, dto *request.SignInDto) (*response.AuthResponse, error) { + if !constant.EMAIL_REGEX.MatchString(dto.Email) { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid email") + } + + err := constant.ValidatePassword(dto.Password) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + user, err := a.userRepo.GetByEmail(ctx, dto.Email) + if err != nil || user == nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(dto.Password)); err != nil { + return nil, fiber.NewError(fiber.StatusUnauthorized, "Invalid identity or password!") + } + + data, err := a.genToken(user.ID, models.RolesEntityToRoleConstant(user.Roles)) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + + } + var pgID pgtype.UUID + err = pgID.Scan(user.ID) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + err = a.saveNewRefreshToken( + ctx, + sqlc.UpdateUserRefreshTokenParams{ + ID: pgID, + RefreshToken: pgtype.Text{ + String: data.RefreshToken, + Valid: data.RefreshToken != "", + }, + }, + ) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + return data, nil + +} + +func (a *authService) RefreshToken(ctx context.Context, id string) (*response.AuthResponse, error) { + var pgID pgtype.UUID + err := pgID.Scan(id) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + user, err := a.userRepo.GetByID(ctx, pgID) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Invalid user data") + } + roles := models.RolesEntityToRoleConstant(user.Roles) + + if slices.Contains(roles, constant.BANNED) { + return nil, fiber.NewError(fiber.StatusUnauthorized, "User is banned!") + } + + data, err := a.genToken(id, roles) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + err = a.saveNewRefreshToken( + ctx, + sqlc.UpdateUserRefreshTokenParams{ + ID: pgID, + RefreshToken: pgtype.Text{ + String: data.RefreshToken, + Valid: data.RefreshToken != "", + }, + }, + ) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + return data, nil +} + +func (a *authService) Signup(ctx context.Context, dto *request.SignUpDto) (*response.AuthResponse, error) { + if !constant.EMAIL_REGEX.MatchString(dto.Email) { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid email") + } + err := constant.ValidatePassword(dto.Password) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + user, err := a.userRepo.GetByEmail(ctx, dto.Email) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + if user != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "User already exists") + } + hashed, err := bcrypt.GenerateFromPassword([]byte(dto.Password), bcrypt.DefaultCost) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + user, err = a.userRepo.UpsertUser( + ctx, + sqlc.UpsertUserParams{ + Email: dto.Email, + PasswordHash: pgtype.Text{ + String: string(hashed), + Valid: len(hashed) != 0, + }, + IsVerified: true, + }, + ) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + var userId pgtype.UUID + err = userId.Scan(user.ID) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + _, err = a.userRepo.CreateProfile( + ctx, + sqlc.CreateUserProfileParams{ + UserID: userId, + DisplayName: pgtype.Text{ + String: dto.DisplayName, + Valid: dto.DisplayName != "", + }, + }, + ) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + err = a.roleRepo.AddUserRole( + ctx, + sqlc.AddUserRoleParams{ + UserID: userId, + Name: constant.USER.String(), + }, + ) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + data, err := a.genToken(user.ID, constant.USER.ToSlice()) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + err = a.saveNewRefreshToken( + ctx, + sqlc.UpdateUserRefreshTokenParams{ + ID: userId, + RefreshToken: pgtype.Text{ + String: data.RefreshToken, + Valid: data.RefreshToken != "", + }, + }, + ) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + return data, nil +} + +// ForgotPassword implements [AuthService]. +func (a *authService) ForgotPassword(ctx context.Context) error { + panic("unimplemented") +} + +// SigninWith3rd implements [AuthService]. +func (a *authService) SigninWith3rd(ctx context.Context) error { + panic("unimplemented") +} + +// CreateToken implements [AuthService]. +func (a *authService) CreateToken(ctx context.Context) error { + panic("unimplemented") +} + +// Verify implements [AuthService]. +func (a *authService) VerifyToken(ctx context.Context) error { + panic("unimplemented") +} diff --git a/internal/services/tileService.go b/internal/services/tileService.go new file mode 100644 index 0000000..6bd62e7 --- /dev/null +++ b/internal/services/tileService.go @@ -0,0 +1,57 @@ +package services + +import ( + "context" + "history-api/internal/repositories" + + "github.com/gofiber/fiber/v3" +) + +type TileService interface { + GetMetadata(ctx context.Context) (map[string]string, error) + GetTile(ctx context.Context, z, x, y int) ([]byte, map[string]string, error) +} + +type tileService struct { + tileRepo repositories.TileRepository +} + +func NewTileService( + TileRepo repositories.TileRepository, +) TileService { + return &tileService{ + tileRepo: TileRepo, + } +} + +func (t *tileService) GetMetadata(ctx context.Context) (map[string]string, error) { + metaData, err := t.tileRepo.GetMetadata(ctx) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + return metaData, nil +} + + +func (t *tileService) GetTile(ctx context.Context, z, x, y int) ([]byte, map[string]string, error) { + contentType := make(map[string]string) + + data, format, isPBF, err := t.tileRepo.GetTile(ctx, z, x, y) + if err != nil { + return nil, contentType, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + contentType["Content-Type"] = "image/png" + if format == "jpg" { + contentType["Content-Type"] = "image/jpeg" + } + if format == "pbf" { + contentType["Content-Type"] = "application/x-protobuf" + } + + if isPBF { + contentType["Content-Encoding"] = "gzip" + } + + return data, contentType, nil + +} diff --git a/internal/services/userService.go b/internal/services/userService.go new file mode 100644 index 0000000..c43a8d9 --- /dev/null +++ b/internal/services/userService.go @@ -0,0 +1,83 @@ +package services + +import ( + "context" + "history-api/internal/dtos/request" + "history-api/internal/dtos/response" + "history-api/internal/repositories" +) + +type UserService interface { + //user + GetUserCurrent(ctx context.Context, dto *request.SignInDto) (*response.AuthResponse, error) + UpdateProfile(ctx context.Context, id string) (*response.UserResponse, error) + ChangePassword(ctx context.Context, id string) (*response.UserResponse, error) + + //admin + DeleteUser(ctx context.Context, id string) (*response.UserResponse, error) + ChangeRoleUser(ctx context.Context, id string) (*response.UserResponse, error) + RestoreUser(ctx context.Context, id string) (*response.UserResponse, error) + GetUserByID(ctx context.Context, id string) (*response.UserResponse, error) + Search(ctx context.Context, id string) ([]*response.UserResponse, error) + GetAllUser(ctx context.Context, id string) ([]*response.UserResponse, error) +} + +type userService struct { + userRepo repositories.UserRepository + roleRepo repositories.RoleRepository +} + +func NewUserService( + userRepo repositories.UserRepository, + roleRepo repositories.RoleRepository, +) UserService { + return &userService{ + userRepo: userRepo, + roleRepo: roleRepo, + } +} + +// ChangePassword implements [UserService]. +func (u *userService) ChangePassword(ctx context.Context, id string) (*response.UserResponse, error) { + panic("unimplemented") +} + +// ChangeRoleUser implements [UserService]. +func (u *userService) ChangeRoleUser(ctx context.Context, id string) (*response.UserResponse, error) { + panic("unimplemented") +} + +// DeleteUser implements [UserService]. +func (u *userService) DeleteUser(ctx context.Context, id string) (*response.UserResponse, error) { + panic("unimplemented") +} + +// GetAllUser implements [UserService]. +func (u *userService) GetAllUser(ctx context.Context, id string) ([]*response.UserResponse, error) { + panic("unimplemented") +} + +// GetUserByID implements [UserService]. +func (u *userService) GetUserByID(ctx context.Context, id string) (*response.UserResponse, error) { + panic("unimplemented") +} + +// GetUserCurrent implements [UserService]. +func (u *userService) GetUserCurrent(ctx context.Context, dto *request.SignInDto) (*response.AuthResponse, error) { + panic("unimplemented") +} + +// RestoreUser implements [UserService]. +func (u *userService) RestoreUser(ctx context.Context, id string) (*response.UserResponse, error) { + panic("unimplemented") +} + +// Search implements [UserService]. +func (u *userService) Search(ctx context.Context, id string) ([]*response.UserResponse, error) { + panic("unimplemented") +} + +// UpdateProfile implements [UserService]. +func (u *userService) UpdateProfile(ctx context.Context, id string) (*response.UserResponse, error) { + panic("unimplemented") +} diff --git a/pkg/cache/redis.go b/pkg/cache/redis.go index fabedc8..d3df970 100644 --- a/pkg/cache/redis.go +++ b/pkg/cache/redis.go @@ -2,31 +2,136 @@ package cache import ( "context" + "encoding/json" "fmt" "history-api/pkg/config" + "time" "github.com/redis/go-redis/v9" ) -var RI *redis.Client +type Cache interface { + Set(ctx context.Context, key string, value any, ttl time.Duration) error + Get(ctx context.Context, key string, dest any) error + Del(ctx context.Context, keys ...string) error + DelByPattern(ctx context.Context, pattern string) error + MGet(ctx context.Context, keys ...string) [][]byte + MSet(ctx context.Context, pairs map[string]any, ttl time.Duration) error +} -func Connect() error { - connectionURI, err := config.GetConfig("REDIS_CONNECTION_URI") +type RedisClient struct { + client *redis.Client +} +func NewRedisClient() (Cache, error) { + uri, err := config.GetConfig("REDIS_CONNECTION_URI") if err != nil { - return err + return nil, err } rdb := redis.NewClient(&redis.Options{ - Addr: connectionURI, - Password: "", - DB: 0, + Addr: uri, + MinIdleConns: 10, + DialTimeout: 5 * time.Second, + ReadTimeout: 3 * time.Second, + WriteTimeout: 3 * time.Second, + + MaxRetries: 3, + MinRetryBackoff: 8 * time.Millisecond, + MaxRetryBackoff: 512 * time.Millisecond, + + DisableIdentity: true, }) if err := rdb.Ping(context.Background()).Err(); err != nil { - return fmt.Errorf("Could not connect to Redis: %v", err) + return nil, fmt.Errorf("could not connect to Redis: %v", err) } - - RI = rdb - return nil + return &RedisClient{client: rdb}, nil +} + +func (r *RedisClient) Del(ctx context.Context, keys ...string) error { + if len(keys) == 0 { + return nil + } + return r.client.Del(ctx, keys...).Err() +} + +func (r *RedisClient) DelByPattern(ctx context.Context, pattern string) error { + var cursor uint64 + for { + keys, nextCursor, err := r.client.Scan(ctx, cursor, pattern, 100).Result() + if err != nil { + return fmt.Errorf("error scanning keys with pattern %s: %v", pattern, err) + } + + if len(keys) > 0 { + if err := r.client.Del(ctx, keys...).Err(); err != nil { + return fmt.Errorf("error deleting keys during scan: %v", err) + } + } + + cursor = nextCursor + if cursor == 0 { + break + } + } + return nil +} + +func (r *RedisClient) Set(ctx context.Context, key string, value any, ttl time.Duration) error { + data, err := json.Marshal(value) + if err != nil { + return err + } + return r.client.Set(ctx, key, data, ttl).Err() +} + +func (r *RedisClient) Get(ctx context.Context, key string, dest any) error { + data, err := r.client.Get(ctx, key).Bytes() + if err != nil { + return err + } + return json.Unmarshal(data, dest) +} + +func (r *RedisClient) MSet(ctx context.Context, pairs map[string]any, ttl time.Duration) error { + pipe := r.client.Pipeline() + for key, value := range pairs { + data, err := json.Marshal(value) + if err != nil { + return fmt.Errorf("failed to marshal key %s: %v", key, err) + } + pipe.Set(ctx, key, data, ttl) + } + _, err := pipe.Exec(ctx) + return err +} + +func (r *RedisClient) MGet(ctx context.Context, keys ...string) [][]byte { + res, err := r.client.MGet(ctx, keys...).Result() + if err != nil { + return nil + } + results := make([][]byte, len(res)) + for i, val := range res { + if val != nil { + results[i] = []byte(val.(string)) + } + } + return results +} + +func GetMultiple[T any](ctx context.Context, c Cache, keys []string) ([]T, error) { + raws := c.MGet(ctx, keys...) + final := make([]T, 0) + for _, b := range raws { + if b == nil { + continue + } + var item T + if err := json.Unmarshal(b, &item); err == nil { + final = append(final, item) + } + } + return final, nil } diff --git a/pkg/constant/regex.go b/pkg/constant/regex.go new file mode 100644 index 0000000..c3c68b6 --- /dev/null +++ b/pkg/constant/regex.go @@ -0,0 +1,39 @@ +package constant + +import ( + "errors" + "regexp" +) + +var ( + // Password components (Go-compatible) + hasUpper = regexp.MustCompile(`[A-Z]`) + hasLower = regexp.MustCompile(`[a-z]`) + hasNumber = regexp.MustCompile(`\d`) + hasSpecial = regexp.MustCompile(`[!@#$%^&*()_+{}|:<>?~-]`) + + // Standard Regexes + PHONE_NUMBER_REGEX = regexp.MustCompile(`^\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}$`) + EMAIL_REGEX = regexp.MustCompile(`^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,4}$`) + YOUTUBE_VIDEO_ID_REGEX = regexp.MustCompile(`(?:\/|v=|\/v\/|embed\/|watch\?v=|watch\?.+&v=)([\w-]{11})`) + BANK_INPUT = regexp.MustCompile(`[__]{2,}`) +) + +func ValidatePassword(password string) error { + if len(password) < 8 { + return errors.New("password must be at least 8 characters long") + } + if !hasUpper.MatchString(password) { + return errors.New("password must contain at least one uppercase letter") + } + if !hasLower.MatchString(password) { + return errors.New("password must contain at least one lowercase letter") + } + if !hasNumber.MatchString(password) { + return errors.New("password must contain at least one number") + } + if !hasSpecial.MatchString(password) { + return errors.New("password must contain at least one special character") + } + return nil +} \ No newline at end of file diff --git a/pkg/constant/role.go b/pkg/constant/role.go index fa24592..4bed768 100644 --- a/pkg/constant/role.go +++ b/pkg/constant/role.go @@ -22,6 +22,13 @@ func CheckValidRole(r Role) bool { return r == ADMIN || r == MOD || r == HISTORIAN || r == USER || r == BANNED } -func (r Role) ToSlice() []string { - return []string{r.String()} +func ParseRole(s string) (Role, bool) { + r := Role(s) + if CheckValidRole(r) { + return r, true + } + return "", false +} +func (r Role) ToSlice() []Role { + return []Role{r} } diff --git a/pkg/convert/convert.go b/pkg/convert/convert.go index 2312d62..2eea37d 100644 --- a/pkg/convert/convert.go +++ b/pkg/convert/convert.go @@ -1,6 +1,10 @@ package convert -import "github.com/jackc/pgx/v5/pgtype" +import ( + "time" + + "github.com/jackc/pgx/v5/pgtype" +) func UUIDToString(v pgtype.UUID) string { if v.Valid { @@ -23,10 +27,10 @@ func BoolVal(v pgtype.Bool) bool { return false } -func TimeToPtr(v pgtype.Timestamptz) *string { - if v.Valid { - t := v.Time.Format("2006-01-02T15:04:05Z07:00") - return &t +func TimeToPtr(v pgtype.Timestamptz) *time.Time { + if !v.Valid { + return nil } - return nil + t := v.Time + return &t } diff --git a/pkg/database/db.go b/pkg/database/db.go index 70a7afd..571d5c4 100644 --- a/pkg/database/db.go +++ b/pkg/database/db.go @@ -7,7 +7,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" ) -func Connect() (*pgxpool.Pool, error) { +func NewPostgresqlDB() (*pgxpool.Pool, error) { ctx := context.Background() connectionURI, err := config.GetConfig("PGX_CONNECTION_URI") if err != nil { diff --git a/pkg/dtos/request/auth.go b/pkg/dtos/request/auth.go deleted file mode 100644 index 3e79cf2..0000000 --- a/pkg/dtos/request/auth.go +++ /dev/null @@ -1,13 +0,0 @@ -package request - -type SignUpDto struct { - Password string `json:"password" validate:"required"` - DiscordUserId string `json:"discord_user_id" validate:"required"` - Username string `json:"username" validate:"required"` -} - -type LoginDto struct { - Username string `json:"username" validate:"required"` - Password string `json:"password" validate:"required"` -} - diff --git a/pkg/dtos/response/role.go b/pkg/dtos/response/role.go deleted file mode 100644 index 5d15e1f..0000000 --- a/pkg/dtos/response/role.go +++ /dev/null @@ -1,14 +0,0 @@ -package response - -type RoleSimpleResponse struct { - ID string `json:"id"` - Name string `json:"name"` -} - -type RoleResponse struct { - ID string `json:"id"` - Name string `json:"name"` - IsDeleted bool `json:"is_deleted"` - CreatedAt *string `json:"created_at"` - UpdatedAt *string `json:"updated_at"` -} \ No newline at end of file diff --git a/pkg/dtos/response/token.go b/pkg/dtos/response/token.go deleted file mode 100644 index efea44a..0000000 --- a/pkg/dtos/response/token.go +++ /dev/null @@ -1,9 +0,0 @@ -package response - -type TokenResponse struct { - ID string `json:"id"` - UserID string `json:"user_id"` - TokenType int16 `json:"token_type"` - ExpiresAt *string `json:"expires_at"` - CreatedAt *string `json:"created_at"` -} \ No newline at end of file diff --git a/pkg/dtos/response/user.go b/pkg/dtos/response/user.go deleted file mode 100644 index 4d5a1a1..0000000 --- a/pkg/dtos/response/user.go +++ /dev/null @@ -1,15 +0,0 @@ -package response - -type UserResponse struct { - ID string `json:"id"` - Name string `json:"name"` - Email string `json:"email"` - AvatarUrl string `json:"avatar_url"` - IsActive bool `json:"is_active"` - IsVerified bool `json:"is_verified"` - TokenVersion int32 `json:"token_version"` - IsDeleted bool `json:"is_deleted"` - CreatedAt *string `json:"created_at"` - UpdatedAt *string `json:"updated_at"` - Roles []*RoleSimpleResponse `json:"roles"` -} \ No newline at end of file diff --git a/pkg/mbtiles/db.go b/pkg/mbtiles/db.go new file mode 100644 index 0000000..455b2fe --- /dev/null +++ b/pkg/mbtiles/db.go @@ -0,0 +1,26 @@ +package mbtiles + +import ( + "database/sql" + "fmt" + + _ "github.com/glebarez/go-sqlite" +) + +func NewMBTilesDB(path string) (*sql.DB, error) { + dsn := fmt.Sprintf("file:%s?mode=ro&_journal_mode=off&_synchronous=off", path) + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, err + } + + err = db.Ping() + if err != nil { + return nil, err + } + + db.SetMaxOpenConns(10) + db.SetMaxIdleConns(5) + + return db, nil +} \ No newline at end of file diff --git a/pkg/models/user.go b/pkg/models/user.go deleted file mode 100644 index 3e2b1db..0000000 --- a/pkg/models/user.go +++ /dev/null @@ -1,58 +0,0 @@ -package models - -import ( - "encoding/json" - "history-api/pkg/convert" - "history-api/pkg/dtos/response" - - "github.com/jackc/pgx/v5/pgtype" -) - -type UserEntity struct { - ID pgtype.UUID `json:"id"` - Name string `json:"name"` - Email string `json:"email"` - PasswordHash string `json:"password_hash"` - AvatarUrl pgtype.Text `json:"avatar_url"` - IsActive pgtype.Bool `json:"is_active"` - IsVerified pgtype.Bool `json:"is_verified"` - TokenVersion int32 `json:"token_version"` - RefreshToken pgtype.Text `json:"refresh_token"` - IsDeleted pgtype.Bool `json:"is_deleted"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - Roles []*RoleSimple `json:"roles"` -} - -func (u *UserEntity) ParseRoles(data []byte) error { - if len(data) == 0 { - u.Roles = []*RoleSimple{} - return nil - } - return json.Unmarshal(data, &u.Roles) -} - -func (u *UserEntity) ToResponse() *response.UserResponse { - return &response.UserResponse{ - ID: convert.UUIDToString(u.ID), - Name: u.Name, - Email: u.Email, - AvatarUrl: convert.TextToString(u.AvatarUrl), - IsActive: convert.BoolVal(u.IsActive), - IsVerified: convert.BoolVal(u.IsVerified), - TokenVersion: u.TokenVersion, - IsDeleted: convert.BoolVal(u.IsDeleted), - CreatedAt: convert.TimeToPtr(u.CreatedAt), - UpdatedAt: convert.TimeToPtr(u.UpdatedAt), - Roles: RolesToResponse(u.Roles), - } -} - - -func UsersEntityToResponse(rs []*UserEntity) []*response.UserResponse { - out := make([]*response.UserResponse, len(rs)) - for i := range rs { - out[i] = rs[i].ToResponse() - } - return out -} \ No newline at end of file diff --git a/pkg/validator/validator.go b/pkg/validator/validator.go index 3486e34..ecfeb48 100644 --- a/pkg/validator/validator.go +++ b/pkg/validator/validator.go @@ -41,8 +41,18 @@ func formatValidationError(err error) []ErrorResponse { element.FailedField = fieldError.Field() element.Tag = fieldError.Tag() element.Value = fieldError.Param() - element.Message = "Field " + fieldError.Field() + " failed validation on tag '" + fieldError.Tag() + "'" - + switch fieldError.Tag() { + case "required": + element.Message = fieldError.Field() + " is required" + case "min": + element.Message = fieldError.Field() + " must be at least " + fieldError.Param() + " characters" + case "max": + element.Message = fieldError.Field() + " must be at most " + fieldError.Param() + " characters" + case "email": + element.Message = "Invalid email format" + default: + element.Message = fieldError.Error() + } errorsList = append(errorsList, element) } } @@ -79,4 +89,4 @@ func ValidateBodyDto(c fiber.Ctx, dto any) error { } return nil -} \ No newline at end of file +}