init
Some checks failed
Build and Release / release (push) Failing after 51s

This commit is contained in:
2026-03-25 22:29:07 +07:00
parent eedd300861
commit 79199f627d
65 changed files with 3215 additions and 689 deletions

View File

@@ -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

4
.gitignore vendored
View File

@@ -2,4 +2,6 @@
pg_data pg_data
.idea .idea
*.log *.log
*.env *.env
*.env.dev
build

13
Dockerfile Normal file
View File

@@ -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"]

View File

@@ -1,7 +1,10 @@
DB_URL ?= postgres://history:secret@localhost:5432/history_map?sslmode=disable 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-up:
migrate -path db/migrations -database "$(DB_URL)" 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)" drop -f
migrate -path db/migrations -database "$(DB_URL)" up 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:
sqlc generate sqlc generate
run: run:
go run $(APP) @set GOARCH=amd64& set CGO_ENABLED=0&go run $(MAIN_APP)
build: 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 dev: swagger sqlc migrate-up run

View File

@@ -2,18 +2,16 @@ package main
import ( import (
"context" "context"
// _ "history-api/docs" "fmt"
"history-api/internal/gen/sqlc" _ "history-api/docs"
"history-api/pkg/cache" "history-api/pkg/cache"
"history-api/pkg/config" "history-api/pkg/config"
_ "history-api/pkg/log"
"fmt"
"history-api/pkg/database" "history-api/pkg/database"
_ "history-api/pkg/log"
"history-api/pkg/mbtiles"
"os/signal" "os/signal"
"syscall" "syscall"
"time" "time"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@@ -46,15 +44,21 @@ func StartServer() {
log.Error().Msg(err.Error()) log.Error().Msg(err.Error())
panic(err) panic(err)
} }
pool, err := database.Connect() poolPg, err := database.NewPostgresqlDB()
if err != nil { if err != nil {
log.Error().Msg(err.Error()) log.Error().Msg(err.Error())
panic(err) panic(err)
} }
defer pool.Close() defer poolPg.Close()
queries := sqlc.New(pool)
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 { if err != nil {
log.Error().Msg(err.Error()) log.Error().Msg(err.Error())
panic(err) panic(err)
@@ -71,7 +75,7 @@ func StartServer() {
} }
serverHttp := NewHttpServer() serverHttp := NewHttpServer()
serverHttp.RegisterFiberRoutes() serverHttp.SetupServer(poolPg, sqlTile, redisClient)
Singleton = serverHttp Singleton = serverHttp
done := make(chan bool, 1) done := make(chan bool, 1)
@@ -90,15 +94,25 @@ func StartServer() {
log.Info().Msg("Graceful shutdown complete.") log.Info().Msg("Graceful shutdown complete.")
} }
// @title Firefly Manager API // @title History API
// @version 1.0 // @version 1.0
// @description API to update Firefly Manager data // @description This is a sample server for History API.
// @host localhost:3344 // @termsOfService http://swagger.io/terms/
// @BasePath /
// @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 // @securityDefinitions.apikey BearerAuth
// @in header // @in header
// @name Authorization // @name Authorization
// @description Type "Bearer " followed by a space and JWT token.
func main() { func main() {
StartServer() StartServer()
} }

View File

@@ -1,13 +1,22 @@
package main package main
import ( import (
// "history-api/internal/routes" "database/sql"
// "history-api/internal/services" _ "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" swagger "github.com/gofiber/contrib/v3/swaggerui"
middleware "github.com/gofiber/contrib/v3/zerolog"
"github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/cors" "github.com/gofiber/fiber/v3/middleware/cors"
"github.com/gofiber/fiber/v3/middleware/logger" "github.com/rs/zerolog"
) )
var ( var (
@@ -27,17 +36,21 @@ func NewHttpServer() *FiberServer {
} }
cfg := swagger.Config{ cfg := swagger.Config{
BasePath: "/", BasePath: "/",
FilePath: "./docs/swagger.json", FileContent: docs.SwaggerJSON,
Path: "swagger", Path: "swagger",
Title: "Swagger API Docs", Title: "Swagger API Docs",
} }
server.App.Use(swagger.New(cfg)) 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 return server
} }
func (s *FiberServer) RegisterFiberRoutes() { func (s *FiberServer) SetupServer(sqlPg sqlc.DBTX, sqlTile *sql.DB, redis cache.Cache) {
// Apply CORS middleware // Apply CORS middleware
s.App.Use(cors.New(cors.Config{ s.App.Use(cors.New(cors.Config{
AllowOrigins: []string{"*"}, AllowOrigins: []string{"*"},
@@ -47,9 +60,21 @@ func (s *FiberServer) RegisterFiberRoutes() {
MaxAge: 300, MaxAge: 300,
})) }))
// routes.UserRoutes(s.App) // repo setup
// routes.AuthRoutes(s.App) userRepo := repositories.NewUserRepository(sqlPg, redis)
// routes.MediaRoute(s.App) roleRepo := repositories.NewRoleRepository(sqlPg, redis)
// routes.NotFoundRoute(s.App) 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)
} }

BIN
data/land.mbtiles Normal file

Binary file not shown.

BIN
data/map.mbtiles Normal file

Binary file not shown.

View File

@@ -3,7 +3,7 @@ CREATE EXTENSION IF NOT EXISTS postgis;
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT uuidv7(), id UUID PRIMARY KEY DEFAULT uuidv7(),
email TEXT NOT NULL UNIQUE, email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL, password_hash TEXT,
google_id VARCHAR(255) UNIQUE, google_id VARCHAR(255) UNIQUE,
auth_provider VARCHAR(50) NOT NULL DEFAULT 'local', auth_provider VARCHAR(50) NOT NULL DEFAULT 'local',
is_verified BOOLEAN NOT NULL DEFAULT false, is_verified BOOLEAN NOT NULL DEFAULT false,

View File

@@ -3,6 +3,7 @@ CREATE TABLE IF NOT EXISTS user_verifications (
user_id UUID REFERENCES users(id) ON DELETE CASCADE, user_id UUID REFERENCES users(id) ON DELETE CASCADE,
verify_type SMALLINT NOT NULL, -- 1 = ID_CARD, 2 = EDUCATION, 3 = EXPERT verify_type SMALLINT NOT NULL, -- 1 = ID_CARD, 2 = EDUCATION, 3 = EXPERT
document_url TEXT NOT NULL, document_url TEXT NOT NULL,
is_deleted BOOLEAN NOT NULL DEFAULT false,
status SMALLINT NOT NULL DEFAULT 1, -- 1 pending, 2 approved, 3 rejected status SMALLINT NOT NULL DEFAULT 1, -- 1 pending, 2 approved, 3 rejected
reviewed_by UUID REFERENCES users(id), reviewed_by UUID REFERENCES users(id),
reviewed_at TIMESTAMPTZ, 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_id
CREATE INDEX idx_user_verifications_user_type ON user_verifications(user_id, verify_type); ON user_verifications(user_id)
CREATE INDEX idx_user_verifications_status ON user_verifications(status); 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 CREATE INDEX idx_user_verifications_status_created
ON user_verifications(status, created_at DESC); ON user_verifications(status, created_at DESC)
WHERE is_deleted = false;

View File

@@ -3,18 +3,23 @@ CREATE TABLE IF NOT EXISTS user_tokens (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(255) NOT NULL UNIQUE, token VARCHAR(255) NOT NULL UNIQUE,
token_type SMALLINT NOT NULL, token_type SMALLINT NOT NULL,
is_deleted BOOLEAN NOT NULL DEFAULT false,
expires_at TIMESTAMPTZ NOT NULL, expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT now() created_at TIMESTAMPTZ DEFAULT now()
); );
CREATE INDEX idx_user_tokens_token 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 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 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 CREATE INDEX idx_user_tokens_expires_at
ON user_tokens(expires_at); ON user_tokens(expires_at)
WHERE is_deleted = false;

View File

@@ -11,11 +11,38 @@ CREATE TABLE IF NOT EXISTS entities (
description TEXT, description TEXT,
thumbnail_url TEXT, thumbnail_url TEXT,
status SMALLINT DEFAULT 1, -- 1 draft, 2 published 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(), created_at TIMESTAMPTZ DEFAULT now(),
updated_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 CREATE TRIGGER trigger_entities_updated_at
BEFORE UPDATE ON entities BEFORE UPDATE ON entities

View File

@@ -1,25 +1,20 @@
CREATE TABLE IF NOT EXISTS wiki_pages ( CREATE TABLE IF NOT EXISTS wikis (
id UUID PRIMARY KEY DEFAULT uuidv7(), id UUID PRIMARY KEY DEFAULT uuidv7(),
entity_id UUID REFERENCES entities(id) ON DELETE CASCADE, entity_id UUID REFERENCES entities(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id),
title TEXT, title TEXT,
is_deleted BOOLEAN NOT NULL DEFAULT false,
note TEXT,
content TEXT, content TEXT,
created_at TIMESTAMPTZ DEFAULT now(), created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now() updated_at TIMESTAMPTZ DEFAULT now()
); );
CREATE TABLE IF NOT EXISTS wiki_versions ( CREATE INDEX idx_wiki_entity
id UUID PRIMARY KEY DEFAULT uuidv7(), ON wikis(entity_id)
wiki_id UUID REFERENCES wiki_pages(id) ON DELETE CASCADE, WHERE is_deleted = false;
created_user UUID REFERENCES users(id),
note TEXT,
content TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
approved_at TIMESTAMPTZ
);
CREATE INDEX idx_wiki_entity ON wiki_pages(entity_id); CREATE TRIGGER trigger_wikis_updated_at
BEFORE UPDATE ON wikis
CREATE TRIGGER trigger_wiki_pages_updated_at
BEFORE UPDATE ON wiki_pages
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION update_updated_at(); EXECUTE FUNCTION update_updated_at();

View File

@@ -1,8 +1,11 @@
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE IF NOT EXISTS geometries ( CREATE TABLE IF NOT EXISTS geometries (
id UUID PRIMARY KEY DEFAULT uuidv7(), id UUID PRIMARY KEY DEFAULT uuidv7(),
geom GEOMETRY, -- point / polygon / line geom GEOMETRY, -- point / polygon / line
time_start INT, time_start INT,
time_end INT, time_end INT,
is_deleted BOOLEAN NOT NULL DEFAULT false,
bbox GEOMETRY, -- optional bbox GEOMETRY, -- optional
created_at TIMESTAMPTZ DEFAULT now(), created_at TIMESTAMPTZ DEFAULT now(),
updated_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, geo_id UUID REFERENCES geometries(id) ON DELETE CASCADE,
created_user UUID REFERENCES users(id), created_user UUID REFERENCES users(id),
geom GEOMETRY, geom GEOMETRY,
is_deleted BOOLEAN NOT NULL DEFAULT false,
note TEXT, note TEXT,
created_at TIMESTAMPTZ DEFAULT now(), reviewed_by UUID REFERENCES users(id),
approved_at TIMESTAMPTZ reviewed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now()
); );
CREATE TABLE IF NOT EXISTS entity_geometries ( CREATE TABLE IF NOT EXISTS entity_geometries (
@@ -24,8 +29,33 @@ CREATE TABLE IF NOT EXISTS entity_geometries (
PRIMARY KEY (entity_id, geometry_id) PRIMARY KEY (entity_id, geometry_id)
); );
CREATE INDEX idx_geo_time ON geometries(time_start, time_end); CREATE INDEX idx_geom_spatial_active
CREATE INDEX idx_geom_spatial ON geometries USING GIST (geom); 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 CREATE TRIGGER trigger_geometries_updated_at
BEFORE UPDATE ON geometries BEFORE UPDATE ON geometries

View File

@@ -1,42 +1,45 @@
-- name: CreateUser :one -- name: UpsertUser :one
INSERT INTO users ( INSERT INTO users (
name,
email, email,
password_hash, 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 avatar_url
) VALUES ( ) VALUES (
$1, $2, $3, $4 $1, $2, $3
) )
RETURNING *; RETURNING *;
-- name: UpdateUser :one -- name: UpdateUserProfile :one
UPDATE users UPDATE user_profiles
SET SET
name = $1, display_name = $1,
avatar_url = $2, full_name = $2,
is_active = $3, avatar_url = $3,
is_verified = $4, bio = $4,
location = $5,
website = $6,
country_code = $7,
phone = $8,
updated_at = now() updated_at = now()
WHERE users.id = $5 AND users.is_deleted = false WHERE user_id = $9
RETURNING 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;
-- name: UpdateUserPassword :exec -- name: UpdateUserPassword :exec
UPDATE users UPDATE users
@@ -52,7 +55,6 @@ SET
WHERE id = $1 WHERE id = $1
AND is_deleted = false; AND is_deleted = false;
-- name: VerifyUser :exec -- name: VerifyUser :exec
UPDATE users UPDATE users
SET SET
@@ -72,86 +74,133 @@ SET
is_deleted = false is_deleted = false
WHERE id = $1; 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 -- name: GetUserByID :one
SELECT SELECT
u.id, u.id,
u.name, u.email,
u.email, u.password_hash,
u.password_hash, u.is_verified,
u.avatar_url, u.token_version,
u.is_active,
u.is_verified,
u.token_version,
u.refresh_token, u.refresh_token,
u.is_deleted, u.is_deleted,
u.created_at, u.created_at,
u.updated_at, u.updated_at,
COALESCE(
json_agg( -- profile JSON
json_build_object('id', r.id, 'name', r.name) (
) FILTER (WHERE r.id IS NOT NULL), SELECT json_build_object(
'[]' 'display_name', p.display_name,
)::json AS roles '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 FROM users u
LEFT JOIN user_roles ur ON u.id = ur.user_id WHERE u.id = $1 AND u.is_deleted = false;
LEFT JOIN roles r ON ur.role_id = r.id
WHERE u.id = $1 AND u.is_deleted = false -- name: GetTokenVersion :one
GROUP BY u.id; 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 -- name: GetUserByEmail :one
SELECT SELECT
u.id, u.id,
u.name, u.email,
u.email, u.password_hash,
u.password_hash, u.is_verified,
u.avatar_url, u.token_version,
u.is_active,
u.is_verified,
u.token_version,
u.is_deleted, u.is_deleted,
u.created_at, u.created_at,
u.updated_at, u.updated_at,
COALESCE(
json_agg( (
json_build_object('id', r.id, 'name', r.name) SELECT json_build_object(
) FILTER (WHERE r.id IS NOT NULL), 'display_name', p.display_name,
'[]' 'full_name', p.full_name,
)::json AS roles '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 FROM users u
LEFT JOIN user_roles ur ON u.id = ur.user_id WHERE u.email = $1 AND u.is_deleted = false;
LEFT JOIN roles r ON ur.role_id = r.id
WHERE u.email = $1 AND u.is_deleted = false -- name: GetUsers :many
GROUP BY u.id; 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;

View File

@@ -1,18 +1,18 @@
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT uuidv7(), id UUID PRIMARY KEY DEFAULT uuidv7(),
email TEXT NOT NULL UNIQUE, email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL, password_hash TEXT,
google_id VARCHAR(255) UNIQUE, google_id VARCHAR(255) UNIQUE,
auth_provider VARCHAR(50) NOT NULL DEFAULT 'local', 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, token_version INT NOT NULL DEFAULT 1,
refresh_token TEXT, refresh_token TEXT,
is_deleted BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT now(), created_at TIMESTAMPTZ DEFAULT now(),
updated_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, user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
display_name TEXT, display_name TEXT,
full_name TEXT, full_name TEXT,
@@ -26,21 +26,10 @@ CREATE TABLE user_profiles (
updated_at TIMESTAMPTZ DEFAULT now() 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 ( CREATE TABLE IF NOT EXISTS roles (
id UUID PRIMARY KEY DEFAULT uuidv7(), id UUID PRIMARY KEY DEFAULT uuidv7(),
name TEXT UNIQUE NOT NULL, name TEXT UNIQUE NOT NULL,
is_deleted BOOLEAN DEFAULT false, is_deleted BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ DEFAULT now(), created_at TIMESTAMPTZ DEFAULT now(),
updated_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) 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 ( CREATE TABLE IF NOT EXISTS user_tokens (
id UUID PRIMARY KEY DEFAULT uuidv7(), id UUID PRIMARY KEY DEFAULT uuidv7(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, 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, token_type SMALLINT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL, expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT now() created_at TIMESTAMPTZ DEFAULT now()
); );

29
docker-compose-dev.yml Normal file
View File

@@ -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:

View File

@@ -3,27 +3,71 @@ services:
image: postgis/postgis:18-3.6 image: postgis/postgis:18-3.6
container_name: history_db container_name: history_db
restart: unless-stopped restart: unless-stopped
env_file:
- ./assets/resources/.env
environment: environment:
POSTGRES_USER: history - POSTGRES_USER=${POSTGRES_USER}
POSTGRES_PASSWORD: secret - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
POSTGRES_DB: history_map - POSTGRES_DB=${POSTGRES_DB}
PGDATA: /var/lib/postgresql/data - PGDATA=/var/lib/postgresql/data
ports:
- "5432:5432"
volumes: volumes:
- ./pg_data:/var/lib/postgresql - pg_data:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U history -d history_map"] test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s interval: 5s
timeout: 3s timeout: 3s
retries: 5 retries: 5
networks:
- history-api-project
cache: cache:
image: redis:8.6.1-alpine image: redis:8.6.1-alpine
container_name: history_redis container_name: history_redis
restart: unless-stopped 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: ports:
- "6379:6379" - "3344:3344"
networks:
- history-api-project
volumes: volumes:
pg_data: pg_data:
networks:
history-api-project:

322
docs/docs.go Normal file
View File

@@ -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)
}

6
docs/embed.go Normal file
View File

@@ -0,0 +1,6 @@
package docs
import _ "embed"
//go:embed swagger.json
var SwaggerJSON []byte

298
docs/swagger.json Normal file
View File

@@ -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"
}
}
}

202
docs/swagger.yaml Normal file
View File

@@ -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"

26
go.mod
View File

@@ -3,21 +3,28 @@ module history-api
go 1.26.1 go 1.26.1
require ( require (
github.com/glebarez/go-sqlite v1.22.0
github.com/go-playground/validator/v10 v10.30.1 github.com/go-playground/validator/v10 v10.30.1
github.com/gofiber/contrib/v3/jwt v1.1.0 github.com/gofiber/contrib/v3/jwt v1.1.0
github.com/gofiber/contrib/v3/swaggerui v1.0.1 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/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/jackc/pgx/v5 v5.8.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/redis/go-redis/v9 v9.18.0 github.com/redis/go-redis/v9 v9.18.0
github.com/rs/zerolog v1.34.0 github.com/rs/zerolog v1.34.0
github.com/swaggo/swag v1.16.6
golang.org/x/crypto v0.49.0
) )
require ( require (
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/MicahParks/keyfunc/v2 v2.1.0 // indirect github.com/MicahParks/keyfunc/v2 v2.1.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect github.com/andybalholm/brotli v1.2.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // 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/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/go-openapi/analysis v0.24.2 // indirect github.com/go-openapi/analysis v0.24.2 // indirect
github.com/go-openapi/errors v0.22.6 // 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/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/gofiber/schema v1.7.0 // indirect github.com/gofiber/schema v1.7.0 // indirect
github.com/gofiber/utils/v2 v2.0.2 // 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/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // 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/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/oklog/ulid v1.3.1 // indirect github.com/oklog/ulid v1.3.1 // indirect
github.com/philhofer/fwd v1.2.0 // 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/tinylib/msgp v1.6.3 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.69.0 // indirect github.com/valyala/fasthttp v1.69.0 // indirect
go.mongodb.org/mongo-driver v1.17.9 // indirect go.mongodb.org/mongo-driver v1.17.9 // indirect
go.uber.org/atomic v1.11.0 // indirect go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.48.0 // indirect golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.51.0 // indirect golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.41.0 // indirect golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.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 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
) )

55
go.sum
View File

@@ -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 h1:6ZXKb9Rp6qp1bDbJefnG7cTH8yMN1IC/4nf+GVjO99k=
github.com/MicahParks/keyfunc/v2 v2.1.0/go.mod h1:rW42fi+xgLJ2FRRXAfNx9ZA8WpD4OeE/yHVMteCkw9k= github.com/MicahParks/keyfunc/v2 v2.1.0/go.mod h1:rW42fi+xgLJ2FRRXAfNx9ZA8WpD4OeE/yHVMteCkw9k=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= 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/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 h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 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 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= 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 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= 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 h1:6p7WXEuKy1llDgOH8FooVeO+Uq2za9qoAOq4ZN08B50=
github.com/go-openapi/analysis v0.24.2/go.mod h1:x27OOHKANE0lutg2ml4kzYLoHGMKgRm1Cj2ijVOjJuE= github.com/go-openapi/analysis v0.24.2/go.mod h1:x27OOHKANE0lutg2ml4kzYLoHGMKgRm1Cj2ijVOjJuE=
github.com/go-openapi/errors v0.22.6 h1:eDxcf89O8odEnohIXwEjY1IB4ph5vmbUsBMsFNwXWPo= 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/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 h1:7R0RX7mbKLa9EYCTHRcCuIPcaqlyQiWNPTXwClK0saQ=
github.com/go-openapi/strfmt v0.25.0/go.mod h1:nNXct7OzbwrMY9+5tLX4I21pzcmE6ccMGXl3jFdPfn8= 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 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU= 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= 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/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 h1:o3EdD0VQjeL4rq1gBxQB5bUEYMNT3eGKxpg2hjPIigI=
github.com/gofiber/contrib/v3/swaggerui v1.0.1/go.mod h1:tIqJ2SDnY7AfqsJHllyaF2vuhkKeBBCHtwWsZrFLCkk= 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 h1:1p4I820pIa+FGxfwWuQZ5rAyX0WlGZbGT6Hnuxt6hKY=
github.com/gofiber/fiber/v3 v3.1.0/go.mod h1:n2nYQovvL9z3Too/FGOfgtERjW3GQcAUqgfoezGBZdU= github.com/gofiber/fiber/v3 v3.1.0/go.mod h1:n2nYQovvL9z3Too/FGOfgtERjW3GQcAUqgfoezGBZdU=
github.com/gofiber/schema v1.7.0 h1:yNM+FNRZjyYEli9Ey0AXRBrAY9jTnb+kmGs3lJGPvKg= 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/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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 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/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 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 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.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 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 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 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/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 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= 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 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 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= 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.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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 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 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 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.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 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 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.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.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.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= 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 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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=

View File

@@ -1 +1,122 @@
package controllers 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,
})
}

View File

@@ -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
}

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

@@ -11,26 +11,39 @@ import (
type Role struct { type Role struct {
ID pgtype.UUID `json:"id"` ID pgtype.UUID `json:"id"`
Name string `json:"name"` Name string `json:"name"`
IsDeleted pgtype.Bool `json:"is_deleted"` IsDeleted bool `json:"is_deleted"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
} }
type User struct { type User struct {
ID pgtype.UUID `json:"id"` ID pgtype.UUID `json:"id"`
Name string `json:"name"`
Email string `json:"email"` Email string `json:"email"`
PasswordHash string `json:"password_hash"` PasswordHash pgtype.Text `json:"password_hash"`
AvatarUrl pgtype.Text `json:"avatar_url"` GoogleID pgtype.Text `json:"google_id"`
IsActive pgtype.Bool `json:"is_active"` AuthProvider string `json:"auth_provider"`
IsVerified pgtype.Bool `json:"is_verified"` IsVerified bool `json:"is_verified"`
IsDeleted bool `json:"is_deleted"`
TokenVersion int32 `json:"token_version"` TokenVersion int32 `json:"token_version"`
RefreshToken pgtype.Text `json:"refresh_token"` RefreshToken pgtype.Text `json:"refresh_token"`
IsDeleted pgtype.Bool `json:"is_deleted"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_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 { type UserRole struct {
UserID pgtype.UUID `json:"user_id"` UserID pgtype.UUID `json:"user_id"`
RoleID pgtype.UUID `json:"role_id"` RoleID pgtype.UUID `json:"role_id"`
@@ -40,7 +53,19 @@ type UserToken struct {
ID pgtype.UUID `json:"id"` ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"user_id"` UserID pgtype.UUID `json:"user_id"`
Token string `json:"token"` Token string `json:"token"`
IsDeleted bool `json:"is_deleted"`
TokenType int16 `json:"token_type"` TokenType int16 `json:"token_type"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"` ExpiresAt pgtype.Timestamptz `json:"expires_at"`
CreatedAt pgtype.Timestamptz `json:"created_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"`
}

View File

@@ -11,44 +11,36 @@ import (
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
) )
const createUser = `-- name: CreateUser :one const createUserProfile = `-- name: CreateUserProfile :one
INSERT INTO users ( INSERT INTO user_profiles (
name, user_id,
email, display_name,
password_hash,
avatar_url avatar_url
) VALUES ( ) 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 { type CreateUserProfileParams struct {
Name string `json:"name"` UserID pgtype.UUID `json:"user_id"`
Email string `json:"email"` DisplayName pgtype.Text `json:"display_name"`
PasswordHash string `json:"password_hash"` AvatarUrl pgtype.Text `json:"avatar_url"`
AvatarUrl pgtype.Text `json:"avatar_url"`
} }
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) { func (q *Queries) CreateUserProfile(ctx context.Context, arg CreateUserProfileParams) (UserProfile, error) {
row := q.db.QueryRow(ctx, createUser, row := q.db.QueryRow(ctx, createUserProfile, arg.UserID, arg.DisplayName, arg.AvatarUrl)
arg.Name, var i UserProfile
arg.Email,
arg.PasswordHash,
arg.AvatarUrl,
)
var i User
err := row.Scan( err := row.Scan(
&i.ID, &i.UserID,
&i.Name, &i.DisplayName,
&i.Email, &i.FullName,
&i.PasswordHash,
&i.AvatarUrl, &i.AvatarUrl,
&i.IsActive, &i.Bio,
&i.IsVerified, &i.Location,
&i.TokenVersion, &i.Website,
&i.RefreshToken, &i.CountryCode,
&i.IsDeleted, &i.Phone,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
) )
@@ -58,8 +50,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
const deleteUser = `-- name: DeleteUser :exec const deleteUser = `-- name: DeleteUser :exec
UPDATE users UPDATE users
SET SET
is_deleted = true, is_deleted = true
updated_at = now()
WHERE id = $1 WHERE id = $1
` `
@@ -68,59 +59,69 @@ func (q *Queries) DeleteUser(ctx context.Context, id pgtype.UUID) error {
return err return err
} }
const existsUserByEmail = `-- name: ExistsUserByEmail :one const getTokenVersion = `-- name: GetTokenVersion :one
SELECT EXISTS ( SELECT token_version
SELECT 1 FROM users FROM users
WHERE email = $1 WHERE id = $1 AND is_deleted = false
AND is_deleted = false
)
` `
func (q *Queries) ExistsUserByEmail(ctx context.Context, email string) (bool, error) { func (q *Queries) GetTokenVersion(ctx context.Context, id pgtype.UUID) (int32, error) {
row := q.db.QueryRow(ctx, existsUserByEmail, email) row := q.db.QueryRow(ctx, getTokenVersion, id)
var exists bool var token_version int32
err := row.Scan(&exists) err := row.Scan(&token_version)
return exists, err return token_version, err
} }
const getUserByEmail = `-- name: GetUserByEmail :one const getUserByEmail = `-- name: GetUserByEmail :one
SELECT SELECT
u.id, u.id,
u.name, u.email,
u.email, u.password_hash,
u.password_hash, u.is_verified,
u.avatar_url, u.token_version,
u.is_active,
u.is_verified,
u.token_version,
u.is_deleted, u.is_deleted,
u.created_at, u.created_at,
u.updated_at, u.updated_at,
COALESCE(
json_agg( (
json_build_object('id', r.id, 'name', r.name) SELECT json_build_object(
) FILTER (WHERE r.id IS NOT NULL), 'display_name', p.display_name,
'[]' 'full_name', p.full_name,
)::json AS roles '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 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 WHERE u.email = $1 AND u.is_deleted = false
GROUP BY u.id
` `
type GetUserByEmailRow struct { type GetUserByEmailRow struct {
ID pgtype.UUID `json:"id"` ID pgtype.UUID `json:"id"`
Name string `json:"name"`
Email string `json:"email"` Email string `json:"email"`
PasswordHash string `json:"password_hash"` PasswordHash pgtype.Text `json:"password_hash"`
AvatarUrl pgtype.Text `json:"avatar_url"` IsVerified bool `json:"is_verified"`
IsActive pgtype.Bool `json:"is_active"`
IsVerified pgtype.Bool `json:"is_verified"`
TokenVersion int32 `json:"token_version"` TokenVersion int32 `json:"token_version"`
IsDeleted pgtype.Bool `json:"is_deleted"` IsDeleted bool `json:"is_deleted"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
Profile []byte `json:"profile"`
Roles []byte `json:"roles"` Roles []byte `json:"roles"`
} }
@@ -129,16 +130,14 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email string) (GetUserByEm
var i GetUserByEmailRow var i GetUserByEmailRow
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
&i.Name,
&i.Email, &i.Email,
&i.PasswordHash, &i.PasswordHash,
&i.AvatarUrl,
&i.IsActive,
&i.IsVerified, &i.IsVerified,
&i.TokenVersion, &i.TokenVersion,
&i.IsDeleted, &i.IsDeleted,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.Profile,
&i.Roles, &i.Roles,
) )
return i, err return i, err
@@ -146,44 +145,58 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email string) (GetUserByEm
const getUserByID = `-- name: GetUserByID :one const getUserByID = `-- name: GetUserByID :one
SELECT SELECT
u.id, u.id,
u.name, u.email,
u.email, u.password_hash,
u.password_hash, u.is_verified,
u.avatar_url, u.token_version,
u.is_active,
u.is_verified,
u.token_version,
u.refresh_token, u.refresh_token,
u.is_deleted, u.is_deleted,
u.created_at, u.created_at,
u.updated_at, u.updated_at,
COALESCE(
json_agg( -- profile JSON
json_build_object('id', r.id, 'name', r.name) (
) FILTER (WHERE r.id IS NOT NULL), SELECT json_build_object(
'[]' 'display_name', p.display_name,
)::json AS roles '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 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 WHERE u.id = $1 AND u.is_deleted = false
GROUP BY u.id
` `
type GetUserByIDRow struct { type GetUserByIDRow struct {
ID pgtype.UUID `json:"id"` ID pgtype.UUID `json:"id"`
Name string `json:"name"`
Email string `json:"email"` Email string `json:"email"`
PasswordHash string `json:"password_hash"` PasswordHash pgtype.Text `json:"password_hash"`
AvatarUrl pgtype.Text `json:"avatar_url"` IsVerified bool `json:"is_verified"`
IsActive pgtype.Bool `json:"is_active"`
IsVerified pgtype.Bool `json:"is_verified"`
TokenVersion int32 `json:"token_version"` TokenVersion int32 `json:"token_version"`
RefreshToken pgtype.Text `json:"refresh_token"` RefreshToken pgtype.Text `json:"refresh_token"`
IsDeleted pgtype.Bool `json:"is_deleted"` IsDeleted bool `json:"is_deleted"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
Profile []byte `json:"profile"`
Roles []byte `json:"roles"` Roles []byte `json:"roles"`
} }
@@ -192,17 +205,15 @@ func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (GetUserByIDR
var i GetUserByIDRow var i GetUserByIDRow
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
&i.Name,
&i.Email, &i.Email,
&i.PasswordHash, &i.PasswordHash,
&i.AvatarUrl,
&i.IsActive,
&i.IsVerified, &i.IsVerified,
&i.TokenVersion, &i.TokenVersion,
&i.RefreshToken, &i.RefreshToken,
&i.IsDeleted, &i.IsDeleted,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.Profile,
&i.Roles, &i.Roles,
) )
return i, err return i, err
@@ -210,44 +221,56 @@ func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (GetUserByIDR
const getUsers = `-- name: GetUsers :many const getUsers = `-- name: GetUsers :many
SELECT SELECT
u.id, u.id,
u.name, u.email,
u.email, u.password_hash,
u.password_hash, u.is_verified,
u.avatar_url, u.token_version,
u.is_active, u.refresh_token,
u.is_verified,
u.token_version,
u.refresh_token,
u.is_deleted, u.is_deleted,
u.created_at, u.created_at,
u.updated_at, u.updated_at,
COALESCE(
json_agg( (
json_build_object('id', r.id, 'name', r.name) SELECT json_build_object(
) FILTER (WHERE r.id IS NOT NULL), 'display_name', p.display_name,
'[]' 'full_name', p.full_name,
)::json AS roles '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 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 WHERE u.is_deleted = false
GROUP BY u.id
` `
type GetUsersRow struct { type GetUsersRow struct {
ID pgtype.UUID `json:"id"` ID pgtype.UUID `json:"id"`
Name string `json:"name"`
Email string `json:"email"` Email string `json:"email"`
PasswordHash string `json:"password_hash"` PasswordHash pgtype.Text `json:"password_hash"`
AvatarUrl pgtype.Text `json:"avatar_url"` IsVerified bool `json:"is_verified"`
IsActive pgtype.Bool `json:"is_active"`
IsVerified pgtype.Bool `json:"is_verified"`
TokenVersion int32 `json:"token_version"` TokenVersion int32 `json:"token_version"`
RefreshToken pgtype.Text `json:"refresh_token"` RefreshToken pgtype.Text `json:"refresh_token"`
IsDeleted pgtype.Bool `json:"is_deleted"` IsDeleted bool `json:"is_deleted"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
Profile []byte `json:"profile"`
Roles []byte `json:"roles"` Roles []byte `json:"roles"`
} }
@@ -262,17 +285,15 @@ func (q *Queries) GetUsers(ctx context.Context) ([]GetUsersRow, error) {
var i GetUsersRow var i GetUsersRow
if err := rows.Scan( if err := rows.Scan(
&i.ID, &i.ID,
&i.Name,
&i.Email, &i.Email,
&i.PasswordHash, &i.PasswordHash,
&i.AvatarUrl,
&i.IsActive,
&i.IsVerified, &i.IsVerified,
&i.TokenVersion, &i.TokenVersion,
&i.RefreshToken, &i.RefreshToken,
&i.IsDeleted, &i.IsDeleted,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.Profile,
&i.Roles, &i.Roles,
); err != nil { ); err != nil {
return nil, err return nil, err
@@ -288,8 +309,7 @@ func (q *Queries) GetUsers(ctx context.Context) ([]GetUsersRow, error) {
const restoreUser = `-- name: RestoreUser :exec const restoreUser = `-- name: RestoreUser :exec
UPDATE users UPDATE users
SET SET
is_deleted = false, is_deleted = false
updated_at = now()
WHERE id = $1 WHERE id = $1
` `
@@ -298,99 +318,33 @@ func (q *Queries) RestoreUser(ctx context.Context, id pgtype.UUID) error {
return err return err
} }
const updateUser = `-- name: UpdateUser :one const updateTokenVersion = `-- name: UpdateTokenVersion :exec
UPDATE users UPDATE users
SET SET token_version = $2
name = $1, WHERE id = $1 AND is_deleted = false
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
` `
type UpdateUserParams struct { type UpdateTokenVersionParams struct {
Name string `json:"name"` ID pgtype.UUID `json:"id"`
AvatarUrl pgtype.Text `json:"avatar_url"` TokenVersion int32 `json:"token_version"`
IsActive pgtype.Bool `json:"is_active"`
IsVerified pgtype.Bool `json:"is_verified"`
ID pgtype.UUID `json:"id"`
} }
type UpdateUserRow struct { func (q *Queries) UpdateTokenVersion(ctx context.Context, arg UpdateTokenVersionParams) error {
ID pgtype.UUID `json:"id"` _, err := q.db.Exec(ctx, updateTokenVersion, arg.ID, arg.TokenVersion)
Name string `json:"name"` return err
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
} }
const updateUserPassword = `-- name: UpdateUserPassword :exec const updateUserPassword = `-- name: UpdateUserPassword :exec
UPDATE users UPDATE users
SET SET
password_hash = $2, password_hash = $2
updated_at = now()
WHERE id = $1 WHERE id = $1
AND is_deleted = false AND is_deleted = false
` `
type UpdateUserPasswordParams struct { type UpdateUserPasswordParams struct {
ID pgtype.UUID `json:"id"` 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 { 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 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 const verifyUser = `-- name: VerifyUser :exec
UPDATE users UPDATE users
SET SET
is_verified = true, is_verified = true
updated_at = now()
WHERE id = $1 WHERE id = $1
AND is_deleted = false AND is_deleted = false
` `

View File

@@ -1,9 +1,9 @@
package middlewares package middlewares
import ( import (
"history-api/internal/dtos/response"
"history-api/pkg/config" "history-api/pkg/config"
"history-api/pkg/constant" "history-api/pkg/constant"
"history-api/pkg/dtos/response"
"slices" "slices"
jwtware "github.com/gofiber/contrib/v3/jwt" jwtware "github.com/gofiber/contrib/v3/jwt"

View File

@@ -1,8 +1,8 @@
package middlewares package middlewares
import ( import (
"history-api/internal/dtos/response"
"history-api/pkg/constant" "history-api/pkg/constant"
"history-api/pkg/dtos/response"
"slices" "slices"
"github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3"

View File

@@ -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,
}
}

View File

@@ -1,20 +1,19 @@
package models package models
import ( import (
"history-api/pkg/dtos/response" "history-api/internal/dtos/response"
"history-api/pkg/convert" "history-api/pkg/constant"
"time"
"github.com/jackc/pgx/v5/pgtype"
) )
type RoleSimple struct { type RoleSimple struct {
ID pgtype.UUID `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
} }
func (r *RoleSimple) ToResponse() *response.RoleSimpleResponse { func (r *RoleSimple) ToResponse() *response.RoleSimpleResponse {
return &response.RoleSimpleResponse{ return &response.RoleSimpleResponse{
ID: convert.UUIDToString(r.ID), ID: r.ID,
Name: r.Name, Name: r.Name,
} }
} }
@@ -28,20 +27,20 @@ func RolesToResponse(rs []*RoleSimple) []*response.RoleSimpleResponse {
} }
type RoleEntity struct { type RoleEntity struct {
ID pgtype.UUID `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
IsDeleted pgtype.Bool `json:"is_deleted"` IsDeleted bool `json:"is_deleted"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt *time.Time `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt *time.Time `json:"updated_at"`
} }
func (r *RoleEntity) ToResponse() *response.RoleResponse { func (r *RoleEntity) ToResponse() *response.RoleResponse {
return &response.RoleResponse{ return &response.RoleResponse{
ID: convert.UUIDToString(r.ID), ID: r.ID,
Name: r.Name, Name: r.Name,
IsDeleted: convert.BoolVal(r.IsDeleted), IsDeleted: r.IsDeleted,
CreatedAt: convert.TimeToPtr(r.CreatedAt), CreatedAt: r.CreatedAt,
UpdatedAt: convert.TimeToPtr(r.UpdatedAt), UpdatedAt: r.UpdatedAt,
} }
} }
@@ -52,3 +51,15 @@ func RolesEntityToResponse(rs []*RoleEntity) []*response.RoleResponse {
} }
return out 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
}

View File

@@ -1,8 +1,8 @@
package models package models
import ( import (
"history-api/internal/dtos/response"
"history-api/pkg/convert" "history-api/pkg/convert"
"history-api/pkg/dtos/response"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
) )

61
internal/models/user.go Normal file
View File

@@ -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
}

View File

@@ -2,11 +2,15 @@ package repositories
import ( import (
"context" "context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"history-api/internal/gen/sqlc" "history-api/internal/gen/sqlc"
"history-api/pkg/models" "history-api/internal/models"
"history-api/pkg/cache"
"history-api/pkg/convert"
) )
type RoleRepository interface { type RoleRepository interface {
@@ -25,43 +29,63 @@ type RoleRepository interface {
type roleRepository struct { type roleRepository struct {
q *sqlc.Queries q *sqlc.Queries
c cache.Cache
} }
func NewRoleRepository(db sqlc.DBTX) RoleRepository { func NewRoleRepository(db sqlc.DBTX, c cache.Cache) RoleRepository {
return &roleRepository{ return &roleRepository{
q: sqlc.New(db), q: sqlc.New(db),
c: c,
} }
} }
func (r *roleRepository) GetByID(ctx context.Context, id pgtype.UUID) (*models.RoleEntity, error) { 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) row, err := r.q.GetRoleByID(ctx, id)
if err != nil { if err != nil {
return nil, err return nil, err
} }
role := &models.RoleEntity{ role = models.RoleEntity{
ID: row.ID, ID: convert.UUIDToString(row.ID),
Name: row.Name, Name: row.Name,
IsDeleted: row.IsDeleted, IsDeleted: row.IsDeleted,
CreatedAt: row.CreatedAt, CreatedAt: convert.TimeToPtr(row.CreatedAt),
UpdatedAt: row.UpdatedAt, 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) { 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) row, err := r.q.GetRoleByName(ctx, name)
if err != nil { if err != nil {
return nil, err return nil, err
} }
role := &models.RoleEntity{ role = models.RoleEntity{
ID: row.ID, ID: convert.UUIDToString(row.ID),
Name: row.Name, Name: row.Name,
IsDeleted: row.IsDeleted, IsDeleted: row.IsDeleted,
CreatedAt: row.CreatedAt, CreatedAt: convert.TimeToPtr(row.CreatedAt),
UpdatedAt: row.UpdatedAt, 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) { 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 { if err != nil {
return nil, err return nil, err
} }
role := &models.RoleEntity{ role := models.RoleEntity{
ID: row.ID, ID: convert.UUIDToString(row.ID),
Name: row.Name, Name: row.Name,
IsDeleted: row.IsDeleted, IsDeleted: row.IsDeleted,
CreatedAt: row.CreatedAt, CreatedAt: convert.TimeToPtr(row.CreatedAt),
UpdatedAt: row.UpdatedAt, 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) { 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 { if err != nil {
return nil, err return nil, err
} }
role := &models.RoleEntity{ role := models.RoleEntity{
ID: row.ID, ID: convert.UUIDToString(row.ID),
Name: row.Name, Name: row.Name,
IsDeleted: row.IsDeleted, IsDeleted: row.IsDeleted,
CreatedAt: row.CreatedAt, CreatedAt: convert.TimeToPtr(row.CreatedAt),
UpdatedAt: row.UpdatedAt, 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) { 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 var users []*models.RoleEntity
for _, row := range rows { for _, row := range rows {
user := &models.RoleEntity{ user := &models.RoleEntity{
ID: row.ID, ID: convert.UUIDToString(row.ID),
Name: row.Name, Name: row.Name,
IsDeleted: row.IsDeleted, IsDeleted: row.IsDeleted,
CreatedAt: row.CreatedAt, CreatedAt: convert.TimeToPtr(row.CreatedAt),
UpdatedAt: row.UpdatedAt, UpdatedAt: convert.TimeToPtr(row.UpdatedAt),
} }
users = append(users, user) 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 { func (r *roleRepository) Delete(ctx context.Context, id pgtype.UUID) error {
err := r.q.DeleteRole(ctx, id) role, err := r.GetByID(ctx, id)
return err 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 { func (r *roleRepository) Restore(ctx context.Context, id pgtype.UUID) error {
err := r.q.RestoreRole(ctx, id) 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 { func (r *roleRepository) AddUserRole(ctx context.Context, params sqlc.AddUserRoleParams) error {
err := r.q.AddUserRole(ctx, params) err := r.q.AddUserRole(ctx, params)
return err return err
} }
func (r *roleRepository) RemoveUserRole(ctx context.Context, params sqlc.RemoveUserRoleParams) error { func (r *roleRepository) RemoveUserRole(ctx context.Context, params sqlc.RemoveUserRoleParams) error {
err := r.q.RemoveUserRole(ctx, params) err := r.q.RemoveUserRole(ctx, params)
return err return err
} }
func (r *roleRepository) RemoveAllUsersFromRole(ctx context.Context, roleId pgtype.UUID) error { func (r *roleRepository) RemoveAllUsersFromRole(ctx context.Context, roleId pgtype.UUID) error {
err := r.q.RemoveAllUsersFromRole(ctx, roleId) err := r.q.RemoveAllUsersFromRole(ctx, roleId)
return err return err
} }
func (r *roleRepository) RemoveAllRolesFromUser(ctx context.Context, roleId pgtype.UUID) error { func (r *roleRepository) RemoveAllRolesFromUser(ctx context.Context, roleId pgtype.UUID) error {
err := r.q.RemoveAllRolesFromUser(ctx, roleId) err := r.q.RemoveAllRolesFromUser(ctx, roleId)
return err return err
} }

View File

@@ -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
}

View File

@@ -2,136 +2,187 @@ package repositories
import ( import (
"context" "context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"history-api/internal/gen/sqlc" "history-api/internal/gen/sqlc"
"history-api/pkg/models" "history-api/internal/models"
"history-api/pkg/cache"
"history-api/pkg/convert"
) )
type UserRepository interface { type UserRepository interface {
GetByID(ctx context.Context, id pgtype.UUID) (*models.UserEntity, error) GetByID(ctx context.Context, id pgtype.UUID) (*models.UserEntity, error)
GetByEmail(ctx context.Context, email string) (*models.UserEntity, error) GetByEmail(ctx context.Context, email string) (*models.UserEntity, error)
All(ctx context.Context) ([]*models.UserEntity, error) All(ctx context.Context) ([]*models.UserEntity, error)
Create(ctx context.Context, params sqlc.CreateUserParams) (*models.UserEntity, error) UpsertUser(ctx context.Context, params sqlc.UpsertUserParams) (*models.UserEntity, error)
Update(ctx context.Context, params sqlc.UpdateUserParams) (*models.UserEntity, error) CreateProfile(ctx context.Context, params sqlc.CreateUserProfileParams) (*models.UserProfileSimple, error)
UpdatePassword(ctx context.Context, params sqlc.UpdateUserPasswordParams) error UpdateProfile(ctx context.Context, params sqlc.UpdateUserProfileParams) (*models.UserEntity, error)
ExistEmail(ctx context.Context, email string) (bool, error) UpdatePassword(ctx context.Context, params sqlc.UpdateUserPasswordParams) error
Verify(ctx context.Context, id pgtype.UUID) error UpdateRefreshToken(ctx context.Context, params sqlc.UpdateUserRefreshTokenParams) error
Delete(ctx context.Context, id pgtype.UUID) error GetTokenVersion(ctx context.Context, id pgtype.UUID) (int32, error)
Restore(ctx context.Context, id pgtype.UUID) 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 { type userRepository struct {
q *sqlc.Queries q *sqlc.Queries
c cache.Cache
} }
func NewUserRepository(db sqlc.DBTX) UserRepository { func NewUserRepository(db sqlc.DBTX, c cache.Cache) UserRepository {
return &userRepository{ return &userRepository{
q: sqlc.New(db), q: sqlc.New(db),
c: c,
} }
} }
func (r *userRepository) GetByID(ctx context.Context, id pgtype.UUID) (*models.UserEntity, error) { 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) row, err := r.q.GetUserByID(ctx, id)
if err != nil { if err != nil {
return nil, err return nil, err
} }
user := &models.UserEntity{ user = models.UserEntity{
ID: row.ID, ID: convert.UUIDToString(row.ID),
Name: row.Name,
Email: row.Email, Email: row.Email,
PasswordHash: row.PasswordHash, PasswordHash: convert.TextToString(row.PasswordHash),
AvatarUrl: row.AvatarUrl,
IsActive: row.IsActive,
IsVerified: row.IsVerified, IsVerified: row.IsVerified,
TokenVersion: row.TokenVersion, TokenVersion: row.TokenVersion,
IsDeleted: row.IsDeleted, IsDeleted: row.IsDeleted,
CreatedAt: row.CreatedAt, CreatedAt: convert.TimeToPtr(row.CreatedAt),
UpdatedAt: row.UpdatedAt, UpdatedAt: convert.TimeToPtr(row.UpdatedAt),
} }
if err := user.ParseRoles(row.Roles); err != nil { 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) { 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) row, err := r.q.GetUserByEmail(ctx, email)
if err != nil { if err != nil {
return nil, err return nil, err
} }
user := &models.UserEntity{
ID: row.ID, user = models.UserEntity{
Name: row.Name, ID: convert.UUIDToString(row.ID),
Email: row.Email, Email: row.Email,
PasswordHash: row.PasswordHash, PasswordHash: convert.TextToString(row.PasswordHash),
AvatarUrl: row.AvatarUrl,
IsActive: row.IsActive,
IsVerified: row.IsVerified, IsVerified: row.IsVerified,
TokenVersion: row.TokenVersion, TokenVersion: row.TokenVersion,
IsDeleted: row.IsDeleted, IsDeleted: row.IsDeleted,
CreatedAt: row.CreatedAt, CreatedAt: convert.TimeToPtr(row.CreatedAt),
UpdatedAt: row.UpdatedAt, UpdatedAt: convert.TimeToPtr(row.UpdatedAt),
} }
if err := user.ParseRoles(row.Roles); err != nil { 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) { func (r *userRepository) UpsertUser(ctx context.Context, params sqlc.UpsertUserParams) (*models.UserEntity, error) {
row, err := r.q.CreateUser(ctx, params) row, err := r.q.UpsertUser(ctx, params)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &models.UserEntity{ return &models.UserEntity{
ID: row.ID, ID: convert.UUIDToString(row.ID),
Name: row.Name,
Email: row.Email, Email: row.Email,
PasswordHash: row.PasswordHash, PasswordHash: convert.TextToString(row.PasswordHash),
AvatarUrl: row.AvatarUrl,
IsActive: row.IsActive,
IsVerified: row.IsVerified, IsVerified: row.IsVerified,
TokenVersion: row.TokenVersion, TokenVersion: row.TokenVersion,
RefreshToken: row.RefreshToken,
IsDeleted: row.IsDeleted, IsDeleted: row.IsDeleted,
CreatedAt: row.CreatedAt, CreatedAt: convert.TimeToPtr(row.CreatedAt),
UpdatedAt: row.UpdatedAt, UpdatedAt: convert.TimeToPtr(row.UpdatedAt),
Roles: make([]*models.RoleSimple, 0), Roles: make([]*models.RoleSimple, 0),
}, nil }, nil
} }
func (r *userRepository) Update(ctx context.Context, params sqlc.UpdateUserParams) (*models.UserEntity, error) { func (r *userRepository) UpdateProfile(ctx context.Context, params sqlc.UpdateUserProfileParams) (*models.UserEntity, error) {
row, err := r.q.UpdateUser(ctx, params) user, err := r.GetByID(ctx, params.UserID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
user := &models.UserEntity{
ID: row.ID, row, err := r.q.UpdateUserProfile(ctx, params)
Name: row.Name, if err != nil {
Email: row.Email, return nil, err
PasswordHash: row.PasswordHash, }
AvatarUrl: row.AvatarUrl, profile := models.UserProfileSimple{
IsActive: row.IsActive, DisplayName: convert.TextToString(row.DisplayName),
IsVerified: row.IsVerified, FullName: convert.TextToString(row.FullName),
TokenVersion: row.TokenVersion, AvatarUrl: convert.TextToString(row.AvatarUrl),
IsDeleted: row.IsDeleted, Bio: convert.TextToString(row.Bio),
CreatedAt: row.CreatedAt, Location: convert.TextToString(row.Location),
UpdatedAt: row.UpdatedAt, Website: convert.TextToString(row.Website),
CountryCode: convert.TextToString(row.CountryCode),
Phone: convert.TextToString(row.Phone),
} }
if err := user.ParseRoles(row.Roles); err != nil { user.Profile = &profile
return nil, err 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 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) { func (r *userRepository) All(ctx context.Context) ([]*models.UserEntity, error) {
rows, err := r.q.GetUsers(ctx) rows, err := r.q.GetUsers(ctx)
if err != nil { if err != nil {
@@ -141,23 +192,24 @@ func (r *userRepository) All(ctx context.Context) ([]*models.UserEntity, error)
var users []*models.UserEntity var users []*models.UserEntity
for _, row := range rows { for _, row := range rows {
user := &models.UserEntity{ user := &models.UserEntity{
ID: row.ID, ID: convert.UUIDToString(row.ID),
Name: row.Name,
Email: row.Email, Email: row.Email,
PasswordHash: row.PasswordHash, PasswordHash: convert.TextToString(row.PasswordHash),
AvatarUrl: row.AvatarUrl,
IsActive: row.IsActive,
IsVerified: row.IsVerified, IsVerified: row.IsVerified,
TokenVersion: row.TokenVersion, TokenVersion: row.TokenVersion,
IsDeleted: row.IsDeleted, IsDeleted: row.IsDeleted,
CreatedAt: row.CreatedAt, CreatedAt: convert.TimeToPtr(row.CreatedAt),
UpdatedAt: row.UpdatedAt, UpdatedAt: convert.TimeToPtr(row.UpdatedAt),
} }
if err := user.ParseRoles(row.Roles); err != nil { if err := user.ParseRoles(row.Roles); err != nil {
return nil, err return nil, err
} }
if err := user.ParseProfile(row.Profile); err != nil {
return nil, err
}
users = append(users, user) 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 { func (r *userRepository) Verify(ctx context.Context, id pgtype.UUID) error {
err := r.q.VerifyUser(ctx, id) user, err := r.GetByID(ctx, id)
return err 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 { func (r *userRepository) Delete(ctx context.Context, id pgtype.UUID) error {
err := r.q.DeleteUser(ctx, id) user, err := r.GetByID(ctx, id)
return err 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 { func (r *userRepository) Restore(ctx context.Context, id pgtype.UUID) error {
err := r.q.RestoreUser(ctx, id) 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 { func (r *userRepository) UpdatePassword(ctx context.Context, params sqlc.UpdateUserPasswordParams) error {
err := r.q.UpdateUserPassword(ctx, params) user, err := r.GetByID(ctx, params.ID)
return err 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) { func (r *userRepository) UpdateRefreshToken(ctx context.Context, params sqlc.UpdateUserRefreshTokenParams) error {
row, err := r.q.ExistsUserByEmail(ctx, email) user, err := r.GetByID(ctx, params.ID)
if err != nil { if err != nil {
return false, err return err
} }
return row, nil 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
}

View File

@@ -1 +0,0 @@
package routers

View File

@@ -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)
}

View File

@@ -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",
})
},
)
}

View File

@@ -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)
}

View File

@@ -1 +1,294 @@
package services 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")
}

View File

@@ -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
}

View File

@@ -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")
}

127
pkg/cache/redis.go vendored
View File

@@ -2,31 +2,136 @@ package cache
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"history-api/pkg/config" "history-api/pkg/config"
"time"
"github.com/redis/go-redis/v9" "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 { type RedisClient struct {
connectionURI, err := config.GetConfig("REDIS_CONNECTION_URI") client *redis.Client
}
func NewRedisClient() (Cache, error) {
uri, err := config.GetConfig("REDIS_CONNECTION_URI")
if err != nil { if err != nil {
return err return nil, err
} }
rdb := redis.NewClient(&redis.Options{ rdb := redis.NewClient(&redis.Options{
Addr: connectionURI, Addr: uri,
Password: "", MinIdleConns: 10,
DB: 0, 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 { 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)
} }
return &RedisClient{client: rdb}, nil
RI = rdb }
return 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
} }

39
pkg/constant/regex.go Normal file
View File

@@ -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
}

View File

@@ -22,6 +22,13 @@ func CheckValidRole(r Role) bool {
return r == ADMIN || r == MOD || r == HISTORIAN || r == USER || r == BANNED return r == ADMIN || r == MOD || r == HISTORIAN || r == USER || r == BANNED
} }
func (r Role) ToSlice() []string { func ParseRole(s string) (Role, bool) {
return []string{r.String()} r := Role(s)
if CheckValidRole(r) {
return r, true
}
return "", false
}
func (r Role) ToSlice() []Role {
return []Role{r}
} }

View File

@@ -1,6 +1,10 @@
package convert package convert
import "github.com/jackc/pgx/v5/pgtype" import (
"time"
"github.com/jackc/pgx/v5/pgtype"
)
func UUIDToString(v pgtype.UUID) string { func UUIDToString(v pgtype.UUID) string {
if v.Valid { if v.Valid {
@@ -23,10 +27,10 @@ func BoolVal(v pgtype.Bool) bool {
return false return false
} }
func TimeToPtr(v pgtype.Timestamptz) *string { func TimeToPtr(v pgtype.Timestamptz) *time.Time {
if v.Valid { if !v.Valid {
t := v.Time.Format("2006-01-02T15:04:05Z07:00") return nil
return &t
} }
return nil t := v.Time
return &t
} }

View File

@@ -7,7 +7,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
) )
func Connect() (*pgxpool.Pool, error) { func NewPostgresqlDB() (*pgxpool.Pool, error) {
ctx := context.Background() ctx := context.Background()
connectionURI, err := config.GetConfig("PGX_CONNECTION_URI") connectionURI, err := config.GetConfig("PGX_CONNECTION_URI")
if err != nil { if err != nil {

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

@@ -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"`
}

26
pkg/mbtiles/db.go Normal file
View File

@@ -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
}

View File

@@ -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
}

View File

@@ -41,8 +41,18 @@ func formatValidationError(err error) []ErrorResponse {
element.FailedField = fieldError.Field() element.FailedField = fieldError.Field()
element.Tag = fieldError.Tag() element.Tag = fieldError.Tag()
element.Value = fieldError.Param() 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) errorsList = append(errorsList, element)
} }
} }
@@ -79,4 +89,4 @@ func ValidateBodyDto(c fiber.Ctx, dto any) error {
} }
return nil return nil
} }