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

2
.gitignore vendored
View File

@@ -3,3 +3,5 @@ pg_data
.idea
*.log
*.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
APP = cmd/history-api/
APP_DIR = cmd/history-api
MAIN_APP = ./cmd/history-api/
MAIN_FILE = $(APP_DIR)/main.go
DOCS_DIR = docs
.PHONY: postgres createdb dropdb migrate-up migrate-down migrate-reset sqlc run build dev
.PHONY: migrate-up migrate-down migrate-reset swagger sqlc run build dev
migrate-up:
migrate -path db/migrations -database "$(DB_URL)" up
@@ -13,13 +16,18 @@ migrate-reset:
migrate -path db/migrations -database "$(DB_URL)" drop -f
migrate -path db/migrations -database "$(DB_URL)" up
swagger:
@echo "=> Generating Swagger docs..."
swag init -g $(MAIN_FILE) -o $(DOCS_DIR) --parseDependency --parseInternal
@echo "=> Swagger docs generated at $(DOCS_DIR)"
sqlc:
sqlc generate
run:
go run $(APP)
@set GOARCH=amd64& set CGO_ENABLED=0&go run $(MAIN_APP)
build:
go build -o app $(APP)
@set GOOS=linux& set GOARCH=amd64& set CGO_ENABLED=0&go build -trimpath -ldflags="-s -w" -o build/history-api $(MAIN_APP)
dev: sqlc migrate-up run
dev: swagger sqlc migrate-up run

View File

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

View File

@@ -1,13 +1,22 @@
package main
import (
// "history-api/internal/routes"
// "history-api/internal/services"
"database/sql"
_ "embed"
"history-api/docs"
"history-api/internal/controllers"
"history-api/internal/gen/sqlc"
"history-api/internal/repositories"
"history-api/internal/routes"
"history-api/internal/services"
"history-api/pkg/cache"
"os"
swagger "github.com/gofiber/contrib/v3/swaggerui"
middleware "github.com/gofiber/contrib/v3/zerolog"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/cors"
"github.com/gofiber/fiber/v3/middleware/logger"
"github.com/rs/zerolog"
)
var (
@@ -27,17 +36,21 @@ func NewHttpServer() *FiberServer {
}
cfg := swagger.Config{
BasePath: "/",
FilePath: "./docs/swagger.json",
FileContent: docs.SwaggerJSON,
Path: "swagger",
Title: "Swagger API Docs",
}
server.App.Use(swagger.New(cfg))
server.App.Use(logger.New())
logger := zerolog.New(os.Stderr).With().Timestamp().Logger()
server.App.Use(middleware.New(middleware.Config{
Logger: &logger,
}))
return server
}
func (s *FiberServer) RegisterFiberRoutes() {
func (s *FiberServer) SetupServer(sqlPg sqlc.DBTX, sqlTile *sql.DB, redis cache.Cache) {
// Apply CORS middleware
s.App.Use(cors.New(cors.Config{
AllowOrigins: []string{"*"},
@@ -47,9 +60,21 @@ func (s *FiberServer) RegisterFiberRoutes() {
MaxAge: 300,
}))
// routes.UserRoutes(s.App)
// routes.AuthRoutes(s.App)
// routes.MediaRoute(s.App)
// routes.NotFoundRoute(s.App)
// repo setup
userRepo := repositories.NewUserRepository(sqlPg, redis)
roleRepo := repositories.NewRoleRepository(sqlPg, redis)
tileRepo := repositories.NewTileRepository(sqlTile, redis)
// service setup
authService := services.NewAuthService(userRepo, roleRepo)
tileService := services.NewTileService(tileRepo)
// controller setup
authController := controllers.NewAuthController(authService)
tileController := controllers.NewTileController(tileService)
// route setup
routes.AuthRoutes(s.App, authController)
routes.TileRoutes(s.App, tileController)
routes.NotFoundRoute(s.App)
}

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 (
id UUID PRIMARY KEY DEFAULT uuidv7(),
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
password_hash TEXT,
google_id VARCHAR(255) UNIQUE,
auth_provider VARCHAR(50) NOT NULL DEFAULT 'local',
is_verified BOOLEAN NOT NULL DEFAULT false,

View File

@@ -3,6 +3,7 @@ CREATE TABLE IF NOT EXISTS user_verifications (
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
verify_type SMALLINT NOT NULL, -- 1 = ID_CARD, 2 = EDUCATION, 3 = EXPERT
document_url TEXT NOT NULL,
is_deleted BOOLEAN NOT NULL DEFAULT false,
status SMALLINT NOT NULL DEFAULT 1, -- 1 pending, 2 approved, 3 rejected
reviewed_by UUID REFERENCES users(id),
reviewed_at TIMESTAMPTZ,
@@ -10,9 +11,18 @@ CREATE TABLE IF NOT EXISTS user_verifications (
);
CREATE INDEX idx_user_verifications_user_id ON user_verifications(user_id);
CREATE INDEX idx_user_verifications_user_type ON user_verifications(user_id, verify_type);
CREATE INDEX idx_user_verifications_status ON user_verifications(status);
CREATE INDEX idx_user_verifications_user_id
ON user_verifications(user_id)
WHERE is_deleted = false;
CREATE INDEX idx_user_verifications_user_type
ON user_verifications(user_id, verify_type)
WHERE is_deleted = false;
CREATE INDEX idx_user_verifications_status
ON user_verifications(status)
WHERE is_deleted = false;
CREATE INDEX idx_user_verifications_status_created
ON user_verifications(status, created_at DESC);
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,
token VARCHAR(255) NOT NULL UNIQUE,
token_type SMALLINT NOT NULL,
is_deleted BOOLEAN NOT NULL DEFAULT false,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_user_tokens_token
ON user_tokens(token);
ON user_tokens(token)
WHERE is_deleted = false;
CREATE INDEX idx_user_tokens_user_id
ON user_tokens(user_id);
ON user_tokens(user_id)
WHERE is_deleted = false;
CREATE INDEX idx_user_tokens_type
ON user_tokens(token_type);
ON user_tokens(token_type)
WHERE is_deleted = false;
CREATE INDEX idx_user_tokens_expires_at
ON user_tokens(expires_at);
ON user_tokens(expires_at)
WHERE is_deleted = false;

View File

@@ -11,11 +11,38 @@ CREATE TABLE IF NOT EXISTS entities (
description TEXT,
thumbnail_url TEXT,
status SMALLINT DEFAULT 1, -- 1 draft, 2 published
is_deleted BOOLEAN NOT NULL DEFAULT false,
reviewed_by UUID REFERENCES users(id),
reviewed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_entities_slug ON entities(slug);
CREATE UNIQUE INDEX uniq_entities_slug_active
ON entities(slug)
WHERE is_deleted = false;
CREATE INDEX idx_entities_type
ON entities(type_id)
WHERE is_deleted = false;
CREATE INDEX idx_entities_status_created
ON entities(status, created_at DESC)
WHERE is_deleted = false;
CREATE INDEX idx_entities_type_status
ON entities(type_id, status)
WHERE is_deleted = false;
CREATE INDEX idx_entities_reviewed_by
ON entities(reviewed_by)
WHERE is_deleted = false;
CREATE INDEX idx_entities_name_search
ON entities USING gin (name gin_trgm_ops);
CREATE TRIGGER trigger_entities_updated_at
BEFORE UPDATE ON entities

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

View File

@@ -1,8 +1,11 @@
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE IF NOT EXISTS geometries (
id UUID PRIMARY KEY DEFAULT uuidv7(),
geom GEOMETRY, -- point / polygon / line
time_start INT,
time_end INT,
is_deleted BOOLEAN NOT NULL DEFAULT false,
bbox GEOMETRY, -- optional
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
@@ -13,9 +16,11 @@ CREATE TABLE IF NOT EXISTS geo_versions (
geo_id UUID REFERENCES geometries(id) ON DELETE CASCADE,
created_user UUID REFERENCES users(id),
geom GEOMETRY,
is_deleted BOOLEAN NOT NULL DEFAULT false,
note TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
approved_at TIMESTAMPTZ
reviewed_by UUID REFERENCES users(id),
reviewed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE IF NOT EXISTS entity_geometries (
@@ -24,8 +29,33 @@ CREATE TABLE IF NOT EXISTS entity_geometries (
PRIMARY KEY (entity_id, geometry_id)
);
CREATE INDEX idx_geo_time ON geometries(time_start, time_end);
CREATE INDEX idx_geom_spatial ON geometries USING GIST (geom);
CREATE INDEX idx_geom_spatial_active
ON geometries USING GIST (geom)
WHERE is_deleted = false;
CREATE INDEX idx_geom_bbox
ON geometries USING GIST (bbox)
WHERE is_deleted = false;
CREATE INDEX idx_geom_time_range
ON geometries
USING GIST (int4range(time_start, time_end))
WHERE is_deleted = false;
CREATE INDEX idx_geo_versions_geo_id
ON geo_versions(geo_id)
WHERE is_deleted = false;
CREATE INDEX idx_geo_versions_reviewed_by
ON geo_versions(reviewed_by)
WHERE is_deleted = false;
CREATE INDEX idx_geo_versions_created_at
ON geo_versions(created_at DESC)
WHERE is_deleted = false;
CREATE INDEX idx_entity_geometries_geometry
ON entity_geometries(geometry_id);
CREATE TRIGGER trigger_geometries_updated_at
BEFORE UPDATE ON geometries

View File

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

View File

@@ -1,18 +1,18 @@
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT uuidv7(),
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
password_hash TEXT,
google_id VARCHAR(255) UNIQUE,
auth_provider VARCHAR(50) NOT NULL DEFAULT 'local',
is_verified BOOLEAN DEFAULT false,
is_verified BOOLEAN NOT NULL DEFAULT false,
is_deleted BOOLEAN NOT NULL DEFAULT false,
token_version INT NOT NULL DEFAULT 1,
refresh_token TEXT,
is_deleted BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE user_profiles (
CREATE TABLE IF NOT EXISTS user_profiles (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
display_name TEXT,
full_name TEXT,
@@ -26,21 +26,10 @@ CREATE TABLE user_profiles (
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE user_verifications (
id UUID PRIMARY KEY DEFAULT uuidv7(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
verify_type SMALLINT NOT NULL, -- 1 = ID_CARD, 2 = EDUCATION, 3 = EXPERT
document_url TEXT NOT NULL,
status SMALLINT NOT NULL DEFAULT 1, -- 1 pending, 2 approved, 3 rejected
reviewed_by UUID REFERENCES users(id),
reviewed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE IF NOT EXISTS roles (
id UUID PRIMARY KEY DEFAULT uuidv7(),
name TEXT UNIQUE NOT NULL,
is_deleted BOOLEAN DEFAULT false,
is_deleted BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
@@ -51,10 +40,22 @@ CREATE TABLE IF NOT EXISTS user_roles (
PRIMARY KEY (user_id, role_id)
);
CREATE TABLE IF NOT EXISTS user_verifications (
id UUID PRIMARY KEY DEFAULT uuidv7(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
verify_type SMALLINT NOT NULL, -- 1 = ID_CARD, 2 = EDUCATION, 3 = EXPERT
document_url TEXT NOT NULL,
status SMALLINT NOT NULL DEFAULT 1, -- 1 pending, 2 approved, 3 rejected
reviewed_by UUID REFERENCES users(id),
reviewed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE IF NOT EXISTS user_tokens (
id UUID PRIMARY KEY DEFAULT uuidv7(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(255) NOT NULL UNIQUE, --1 = PasswordReset, 2 = EmailVerify, 3 = MagicLink, 4 = RefreshToken
token VARCHAR(255) NOT NULL UNIQUE,
is_deleted BOOLEAN NOT NULL DEFAULT false,
token_type SMALLINT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()

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
container_name: history_db
restart: unless-stopped
env_file:
- ./assets/resources/.env
environment:
POSTGRES_USER: history
POSTGRES_PASSWORD: secret
POSTGRES_DB: history_map
PGDATA: /var/lib/postgresql/data
ports:
- "5432:5432"
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB}
- PGDATA=/var/lib/postgresql/data
volumes:
- ./pg_data:/var/lib/postgresql
- pg_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U history -d history_map"]
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 3s
retries: 5
networks:
- history-api-project
cache:
image: redis:8.6.1-alpine
container_name: history_redis
restart: unless-stopped
networks:
- history-api-project
migrate:
image: migrate/migrate
container_name: history_migrate
depends_on:
db:
condition: service_healthy
env_file:
- ./assets/resources/.env
volumes:
- ./db/migrations:/migrations
# Sử dụng sh -c để có thể chạy chuỗi lệnh
entrypoint:
- sh
- -c
- |
# Lấy URL database từ biến môi trường
DB_URL="postgres://$${POSTGRES_USER}:$${POSTGRES_PASSWORD}@db:5432/$${POSTGRES_DB}?sslmode=disable"
echo "Checking for dirty database..."
/migrate -path /migrations -database "$$DB_URL" up || \
(echo "Database is dirty, forcing to version 8..." && \
/migrate -path /migrations -database "$$DB_URL" force 8 && \
/migrate -path /migrations -database "$$DB_URL" up)
networks:
- history-api-project
app:
build: .
container_name: history_app
restart: unless-stopped
depends_on:
migrate:
condition: service_completed_successfully
cache:
condition: service_started
ports:
- "6379:6379"
- "3344:3344"
networks:
- history-api-project
volumes:
pg_data:
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
require (
github.com/glebarez/go-sqlite v1.22.0
github.com/go-playground/validator/v10 v10.30.1
github.com/gofiber/contrib/v3/jwt v1.1.0
github.com/gofiber/contrib/v3/swaggerui v1.0.1
github.com/gofiber/contrib/v3/zerolog v1.0.1
github.com/gofiber/fiber/v3 v3.1.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/jackc/pgx/v5 v5.8.0
github.com/joho/godotenv v1.5.1
github.com/redis/go-redis/v9 v9.18.0
github.com/rs/zerolog v1.34.0
github.com/swaggo/swag v1.16.6
golang.org/x/crypto v0.49.0
)
require (
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/MicahParks/keyfunc/v2 v2.1.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/go-openapi/analysis v0.24.2 // indirect
github.com/go-openapi/errors v0.22.6 // indirect
@@ -42,28 +49,33 @@ require (
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/gofiber/schema v1.7.0 // indirect
github.com/gofiber/utils/v2 v2.0.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/oklog/ulid v1.3.1 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/tinylib/msgp v1.6.3 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.69.0 // indirect
go.mongodb.org/mongo-driver v1.17.9 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/tools v0.42.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
modernc.org/libc v1.37.6 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.7.2 // indirect
modernc.org/sqlite v1.28.0 // indirect
)

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/go.mod h1:rW42fi+xgLJ2FRRXAfNx9ZA8WpD4OeE/yHVMteCkw9k=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
@@ -15,10 +17,14 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/go-openapi/analysis v0.24.2 h1:6p7WXEuKy1llDgOH8FooVeO+Uq2za9qoAOq4ZN08B50=
github.com/go-openapi/analysis v0.24.2/go.mod h1:x27OOHKANE0lutg2ml4kzYLoHGMKgRm1Cj2ijVOjJuE=
github.com/go-openapi/errors v0.22.6 h1:eDxcf89O8odEnohIXwEjY1IB4ph5vmbUsBMsFNwXWPo=
@@ -35,6 +41,7 @@ github.com/go-openapi/spec v0.22.3 h1:qRSmj6Smz2rEBxMnLRBMeBWxbbOvuOoElvSvObIgwQ
github.com/go-openapi/spec v0.22.3/go.mod h1:iIImLODL2loCh3Vnox8TY2YWYJZjMAKYyLH2Mu8lOZs=
github.com/go-openapi/strfmt v0.25.0 h1:7R0RX7mbKLa9EYCTHRcCuIPcaqlyQiWNPTXwClK0saQ=
github.com/go-openapi/strfmt v0.25.0/go.mod h1:nNXct7OzbwrMY9+5tLX4I21pzcmE6ccMGXl3jFdPfn8=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y=
@@ -76,6 +83,8 @@ github.com/gofiber/contrib/v3/jwt v1.1.0 h1:RmQHJGNlOF+1hy69AJo+YiVcLTUKA2bXy/yj
github.com/gofiber/contrib/v3/jwt v1.1.0/go.mod h1:THoVk0kTAkLJFunaNxk8fRO5WYfrwV4k/8oFAoa7UCM=
github.com/gofiber/contrib/v3/swaggerui v1.0.1 h1:o3EdD0VQjeL4rq1gBxQB5bUEYMNT3eGKxpg2hjPIigI=
github.com/gofiber/contrib/v3/swaggerui v1.0.1/go.mod h1:tIqJ2SDnY7AfqsJHllyaF2vuhkKeBBCHtwWsZrFLCkk=
github.com/gofiber/contrib/v3/zerolog v1.0.1 h1:Sr9T4g0Z1DAOoo5X0tAKFd9uNKFu++vwaZOk1ymIq1s=
github.com/gofiber/contrib/v3/zerolog v1.0.1/go.mod h1:LgfsEgvOp0Yg4ra77kr3Md3UyQ2BjPO6T8LNV/gkCi4=
github.com/gofiber/fiber/v3 v3.1.0 h1:1p4I820pIa+FGxfwWuQZ5rAyX0WlGZbGT6Hnuxt6hKY=
github.com/gofiber/fiber/v3 v3.1.0/go.mod h1:n2nYQovvL9z3Too/FGOfgtERjW3GQcAUqgfoezGBZdU=
github.com/gofiber/schema v1.7.0 h1:yNM+FNRZjyYEli9Ey0AXRBrAY9jTnb+kmGs3lJGPvKg=
@@ -86,6 +95,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63Y
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@@ -98,10 +109,10 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU=
github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -125,6 +136,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
@@ -137,6 +150,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
@@ -155,19 +170,23 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -176,3 +195,11 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.37.6 h1:orZH3c5wmhIQFTXF+Nt+eeauyd+ZIt2BX6ARe+kD+aw=
modernc.org/libc v1.37.6/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ=
modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=

View File

@@ -1 +1,122 @@
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,22 +11,35 @@ import (
type Role struct {
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
IsDeleted pgtype.Bool `json:"is_deleted"`
IsDeleted bool `json:"is_deleted"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type User struct {
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
PasswordHash string `json:"password_hash"`
AvatarUrl pgtype.Text `json:"avatar_url"`
IsActive pgtype.Bool `json:"is_active"`
IsVerified pgtype.Bool `json:"is_verified"`
PasswordHash pgtype.Text `json:"password_hash"`
GoogleID pgtype.Text `json:"google_id"`
AuthProvider string `json:"auth_provider"`
IsVerified bool `json:"is_verified"`
IsDeleted bool `json:"is_deleted"`
TokenVersion int32 `json:"token_version"`
RefreshToken pgtype.Text `json:"refresh_token"`
IsDeleted pgtype.Bool `json:"is_deleted"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type UserProfile struct {
UserID pgtype.UUID `json:"user_id"`
DisplayName pgtype.Text `json:"display_name"`
FullName pgtype.Text `json:"full_name"`
AvatarUrl pgtype.Text `json:"avatar_url"`
Bio pgtype.Text `json:"bio"`
Location pgtype.Text `json:"location"`
Website pgtype.Text `json:"website"`
CountryCode pgtype.Text `json:"country_code"`
Phone pgtype.Text `json:"phone"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
@@ -40,7 +53,19 @@ type UserToken struct {
ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"user_id"`
Token string `json:"token"`
IsDeleted bool `json:"is_deleted"`
TokenType int16 `json:"token_type"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type UserVerification struct {
ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"user_id"`
VerifyType int16 `json:"verify_type"`
DocumentUrl string `json:"document_url"`
Status int16 `json:"status"`
ReviewedBy pgtype.UUID `json:"reviewed_by"`
ReviewedAt pgtype.Timestamptz `json:"reviewed_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}

View File

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

View File

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

View File

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

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

View File

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

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 (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgtype"
"history-api/internal/gen/sqlc"
"history-api/pkg/models"
"history-api/internal/models"
"history-api/pkg/cache"
"history-api/pkg/convert"
)
type RoleRepository interface {
@@ -25,43 +29,63 @@ type RoleRepository interface {
type roleRepository struct {
q *sqlc.Queries
c cache.Cache
}
func NewRoleRepository(db sqlc.DBTX) RoleRepository {
func NewRoleRepository(db sqlc.DBTX, c cache.Cache) RoleRepository {
return &roleRepository{
q: sqlc.New(db),
c: c,
}
}
func (r *roleRepository) GetByID(ctx context.Context, id pgtype.UUID) (*models.RoleEntity, error) {
cacheId := fmt.Sprintf("role:id:%s", convert.UUIDToString(id))
var role models.RoleEntity
err := r.c.Get(ctx, cacheId, &role)
if err == nil {
return &role, nil
}
row, err := r.q.GetRoleByID(ctx, id)
if err != nil {
return nil, err
}
role := &models.RoleEntity{
ID: row.ID,
role = models.RoleEntity{
ID: convert.UUIDToString(row.ID),
Name: row.Name,
IsDeleted: row.IsDeleted,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
CreatedAt: convert.TimeToPtr(row.CreatedAt),
UpdatedAt: convert.TimeToPtr(row.UpdatedAt),
}
return role, nil
_ = r.c.Set(ctx, cacheId, role, 5*time.Minute)
return &role, nil
}
func (r *roleRepository) GetByname(ctx context.Context, name string) (*models.RoleEntity, error) {
cacheId := fmt.Sprintf("role:name:%s", name)
var role models.RoleEntity
err := r.c.Get(ctx, cacheId, &role)
if err == nil {
return &role, nil
}
row, err := r.q.GetRoleByName(ctx, name)
if err != nil {
return nil, err
}
role := &models.RoleEntity{
ID: row.ID,
role = models.RoleEntity{
ID: convert.UUIDToString(row.ID),
Name: row.Name,
IsDeleted: row.IsDeleted,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
CreatedAt: convert.TimeToPtr(row.CreatedAt),
UpdatedAt: convert.TimeToPtr(row.UpdatedAt),
}
return role, nil
_ = r.c.Set(ctx, cacheId, role, 5*time.Minute)
return &role, nil
}
func (r *roleRepository) Create(ctx context.Context, name string) (*models.RoleEntity, error) {
@@ -69,14 +93,19 @@ func (r *roleRepository) Create(ctx context.Context, name string) (*models.RoleE
if err != nil {
return nil, err
}
role := &models.RoleEntity{
ID: row.ID,
role := models.RoleEntity{
ID: convert.UUIDToString(row.ID),
Name: row.Name,
IsDeleted: row.IsDeleted,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
CreatedAt: convert.TimeToPtr(row.CreatedAt),
UpdatedAt: convert.TimeToPtr(row.UpdatedAt),
}
return role, nil
mapCache := map[string]any{
fmt.Sprintf("role:name:%s", name): role,
fmt.Sprintf("role:id:%s", convert.UUIDToString(row.ID)): role,
}
_ = r.c.MSet(ctx, mapCache, 5*time.Minute)
return &role, nil
}
func (r *roleRepository) Update(ctx context.Context, params sqlc.UpdateRoleParams) (*models.RoleEntity, error) {
@@ -84,14 +113,20 @@ func (r *roleRepository) Update(ctx context.Context, params sqlc.UpdateRoleParam
if err != nil {
return nil, err
}
role := &models.RoleEntity{
ID: row.ID,
role := models.RoleEntity{
ID: convert.UUIDToString(row.ID),
Name: row.Name,
IsDeleted: row.IsDeleted,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
CreatedAt: convert.TimeToPtr(row.CreatedAt),
UpdatedAt: convert.TimeToPtr(row.UpdatedAt),
}
return role, nil
mapCache := map[string]any{
fmt.Sprintf("role:name:%s", row.Name): role,
fmt.Sprintf("role:id:%s", convert.UUIDToString(row.ID)): role,
}
_ = r.c.MSet(ctx, mapCache, 5*time.Minute)
return &role, nil
}
func (r *roleRepository) All(ctx context.Context) ([]*models.RoleEntity, error) {
@@ -103,11 +138,11 @@ func (r *roleRepository) All(ctx context.Context) ([]*models.RoleEntity, error)
var users []*models.RoleEntity
for _, row := range rows {
user := &models.RoleEntity{
ID: row.ID,
ID: convert.UUIDToString(row.ID),
Name: row.Name,
IsDeleted: row.IsDeleted,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
CreatedAt: convert.TimeToPtr(row.CreatedAt),
UpdatedAt: convert.TimeToPtr(row.UpdatedAt),
}
users = append(users, user)
}
@@ -116,14 +151,27 @@ func (r *roleRepository) All(ctx context.Context) ([]*models.RoleEntity, error)
}
func (r *roleRepository) Delete(ctx context.Context, id pgtype.UUID) error {
err := r.q.DeleteRole(ctx, id)
role, err := r.GetByID(ctx, id)
if err != nil {
return err
}
err = r.q.DeleteRole(ctx, id)
if err != nil {
return err
}
_ = r.c.Del(ctx, fmt.Sprintf("role:id:%s", role.ID), fmt.Sprintf("role:name:%s", role.Name))
return nil
}
func (r *roleRepository) Restore(ctx context.Context, id pgtype.UUID) error {
err := r.q.RestoreRole(ctx, id)
if err != nil {
return err
}
_ = r.c.Del(ctx, fmt.Sprintf("role:id:%s", convert.UUIDToString(id)))
return nil
}
func (r *roleRepository) AddUserRole(ctx context.Context, params sqlc.AddUserRoleParams) error {
err := r.q.AddUserRole(ctx, params)
@@ -144,5 +192,3 @@ func (r *roleRepository) RemoveAllRolesFromUser(ctx context.Context, roleId pgty
err := r.q.RemoveAllRolesFromUser(ctx, roleId)
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,21 +2,28 @@ package repositories
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgtype"
"history-api/internal/gen/sqlc"
"history-api/pkg/models"
"history-api/internal/models"
"history-api/pkg/cache"
"history-api/pkg/convert"
)
type UserRepository interface {
GetByID(ctx context.Context, id pgtype.UUID) (*models.UserEntity, error)
GetByEmail(ctx context.Context, email string) (*models.UserEntity, error)
All(ctx context.Context) ([]*models.UserEntity, error)
Create(ctx context.Context, params sqlc.CreateUserParams) (*models.UserEntity, error)
Update(ctx context.Context, params sqlc.UpdateUserParams) (*models.UserEntity, error)
UpsertUser(ctx context.Context, params sqlc.UpsertUserParams) (*models.UserEntity, error)
CreateProfile(ctx context.Context, params sqlc.CreateUserProfileParams) (*models.UserProfileSimple, error)
UpdateProfile(ctx context.Context, params sqlc.UpdateUserProfileParams) (*models.UserEntity, error)
UpdatePassword(ctx context.Context, params sqlc.UpdateUserPasswordParams) error
ExistEmail(ctx context.Context, email string) (bool, error)
UpdateRefreshToken(ctx context.Context, params sqlc.UpdateUserRefreshTokenParams) error
GetTokenVersion(ctx context.Context, id pgtype.UUID) (int32, error)
UpdateTokenVersion(ctx context.Context, params sqlc.UpdateTokenVersionParams) error
Verify(ctx context.Context, id pgtype.UUID) error
Delete(ctx context.Context, id pgtype.UUID) error
Restore(ctx context.Context, id pgtype.UUID) error
@@ -24,112 +31,156 @@ type UserRepository interface {
type userRepository struct {
q *sqlc.Queries
c cache.Cache
}
func NewUserRepository(db sqlc.DBTX) UserRepository {
func NewUserRepository(db sqlc.DBTX, c cache.Cache) UserRepository {
return &userRepository{
q: sqlc.New(db),
c: c,
}
}
func (r *userRepository) GetByID(ctx context.Context, id pgtype.UUID) (*models.UserEntity, error) {
cacheId := fmt.Sprintf("user:id:%s", convert.UUIDToString(id))
var user models.UserEntity
err := r.c.Get(ctx, cacheId, &user)
if err == nil {
return &user, nil
}
row, err := r.q.GetUserByID(ctx, id)
if err != nil {
return nil, err
}
user := &models.UserEntity{
ID: row.ID,
Name: row.Name,
user = models.UserEntity{
ID: convert.UUIDToString(row.ID),
Email: row.Email,
PasswordHash: row.PasswordHash,
AvatarUrl: row.AvatarUrl,
IsActive: row.IsActive,
PasswordHash: convert.TextToString(row.PasswordHash),
IsVerified: row.IsVerified,
TokenVersion: row.TokenVersion,
IsDeleted: row.IsDeleted,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
CreatedAt: convert.TimeToPtr(row.CreatedAt),
UpdatedAt: convert.TimeToPtr(row.UpdatedAt),
}
if err := user.ParseRoles(row.Roles); err != nil {
return nil, err
}
return user, nil
if err := user.ParseProfile(row.Profile); err != nil {
return nil, err
}
_ = r.c.Set(ctx, cacheId, user, 5*time.Minute)
return &user, nil
}
func (r *userRepository) GetByEmail(ctx context.Context, email string) (*models.UserEntity, error) {
cacheId := fmt.Sprintf("user:email:%s", email)
var user models.UserEntity
err := r.c.Get(ctx, cacheId, &user)
if err == nil {
return &user, nil
}
row, err := r.q.GetUserByEmail(ctx, email)
if err != nil {
return nil, err
}
user := &models.UserEntity{
ID: row.ID,
Name: row.Name,
user = models.UserEntity{
ID: convert.UUIDToString(row.ID),
Email: row.Email,
PasswordHash: row.PasswordHash,
AvatarUrl: row.AvatarUrl,
IsActive: row.IsActive,
PasswordHash: convert.TextToString(row.PasswordHash),
IsVerified: row.IsVerified,
TokenVersion: row.TokenVersion,
IsDeleted: row.IsDeleted,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
CreatedAt: convert.TimeToPtr(row.CreatedAt),
UpdatedAt: convert.TimeToPtr(row.UpdatedAt),
}
if err := user.ParseRoles(row.Roles); err != nil {
return nil, err
}
return user, nil
if err := user.ParseProfile(row.Profile); err != nil {
return nil, err
}
func (r *userRepository) Create(ctx context.Context, params sqlc.CreateUserParams) (*models.UserEntity, error) {
row, err := r.q.CreateUser(ctx, params)
_ = r.c.Set(ctx, cacheId, user, 5*time.Minute)
return &user, nil
}
func (r *userRepository) UpsertUser(ctx context.Context, params sqlc.UpsertUserParams) (*models.UserEntity, error) {
row, err := r.q.UpsertUser(ctx, params)
if err != nil {
return nil, err
}
return &models.UserEntity{
ID: row.ID,
Name: row.Name,
ID: convert.UUIDToString(row.ID),
Email: row.Email,
PasswordHash: row.PasswordHash,
AvatarUrl: row.AvatarUrl,
IsActive: row.IsActive,
PasswordHash: convert.TextToString(row.PasswordHash),
IsVerified: row.IsVerified,
TokenVersion: row.TokenVersion,
RefreshToken: row.RefreshToken,
IsDeleted: row.IsDeleted,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
CreatedAt: convert.TimeToPtr(row.CreatedAt),
UpdatedAt: convert.TimeToPtr(row.UpdatedAt),
Roles: make([]*models.RoleSimple, 0),
}, nil
}
func (r *userRepository) Update(ctx context.Context, params sqlc.UpdateUserParams) (*models.UserEntity, error) {
row, err := r.q.UpdateUser(ctx, params)
func (r *userRepository) UpdateProfile(ctx context.Context, params sqlc.UpdateUserProfileParams) (*models.UserEntity, error) {
user, err := r.GetByID(ctx, params.UserID)
if err != nil {
return nil, err
}
user := &models.UserEntity{
ID: row.ID,
Name: row.Name,
Email: row.Email,
PasswordHash: row.PasswordHash,
AvatarUrl: row.AvatarUrl,
IsActive: row.IsActive,
IsVerified: row.IsVerified,
TokenVersion: row.TokenVersion,
IsDeleted: row.IsDeleted,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
row, err := r.q.UpdateUserProfile(ctx, params)
if err != nil {
return nil, err
}
profile := models.UserProfileSimple{
DisplayName: convert.TextToString(row.DisplayName),
FullName: convert.TextToString(row.FullName),
AvatarUrl: convert.TextToString(row.AvatarUrl),
Bio: convert.TextToString(row.Bio),
Location: convert.TextToString(row.Location),
Website: convert.TextToString(row.Website),
CountryCode: convert.TextToString(row.CountryCode),
Phone: convert.TextToString(row.Phone),
}
if err := user.ParseRoles(row.Roles); err != nil {
user.Profile = &profile
mapCache := map[string]any{
fmt.Sprintf("user:email:%s", user.Email): user,
fmt.Sprintf("user:id:%s", user.ID): user,
}
_ = r.c.MSet(ctx, mapCache, 5*time.Minute)
return user, nil
}
func (r *userRepository) CreateProfile(ctx context.Context, params sqlc.CreateUserProfileParams) (*models.UserProfileSimple, error) {
row, err := r.q.CreateUserProfile(ctx, params)
if err != nil {
return nil, err
}
return user, nil
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) {
@@ -141,23 +192,24 @@ func (r *userRepository) All(ctx context.Context) ([]*models.UserEntity, error)
var users []*models.UserEntity
for _, row := range rows {
user := &models.UserEntity{
ID: row.ID,
Name: row.Name,
ID: convert.UUIDToString(row.ID),
Email: row.Email,
PasswordHash: row.PasswordHash,
AvatarUrl: row.AvatarUrl,
IsActive: row.IsActive,
PasswordHash: convert.TextToString(row.PasswordHash),
IsVerified: row.IsVerified,
TokenVersion: row.TokenVersion,
IsDeleted: row.IsDeleted,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
CreatedAt: convert.TimeToPtr(row.CreatedAt),
UpdatedAt: convert.TimeToPtr(row.UpdatedAt),
}
if err := user.ParseRoles(row.Roles); err != nil {
return nil, err
}
if err := user.ParseProfile(row.Profile); err != nil {
return nil, err
}
users = append(users, user)
}
@@ -165,29 +217,138 @@ func (r *userRepository) All(ctx context.Context) ([]*models.UserEntity, error)
}
func (r *userRepository) Verify(ctx context.Context, id pgtype.UUID) error {
err := r.q.VerifyUser(ctx, id)
user, err := r.GetByID(ctx, id)
if err != nil {
return err
}
func (r *userRepository) Delete(ctx context.Context, id pgtype.UUID) error {
err := r.q.DeleteUser(ctx, id)
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 {
user, err := r.GetByID(ctx, id)
if err != nil {
return err
}
err = r.q.DeleteUser(ctx, id)
if err != nil {
return err
}
_ = r.c.Del(
ctx,
fmt.Sprintf("user:id:%s", user.ID),
fmt.Sprintf("user:email:%s", user.Email),
fmt.Sprintf("user:token:%s", user.ID),
)
return nil
}
func (r *userRepository) Restore(ctx context.Context, id pgtype.UUID) error {
err := r.q.RestoreUser(ctx, id)
if err != nil {
return err
}
_ = r.c.Del(ctx, fmt.Sprintf("user:id:%s", convert.UUIDToString(id)))
return nil
}
func (r *userRepository) GetTokenVersion(ctx context.Context, id pgtype.UUID) (int32, error) {
cacheId := fmt.Sprintf("user:token:%s", convert.UUIDToString(id))
var token int32
err := r.c.Get(ctx, cacheId, &token)
if err == nil {
return token, nil
}
raw, err := r.q.GetTokenVersion(ctx, id)
if err != nil {
return 0, err
}
_ = r.c.Set(ctx, cacheId, raw, 5*time.Minute)
return raw, nil
}
func (r *userRepository) UpdateTokenVersion(ctx context.Context, params sqlc.UpdateTokenVersionParams) error {
err := r.q.UpdateTokenVersion(ctx, params)
if err != nil {
return err
}
cacheId := fmt.Sprintf("user:token:%s", convert.UUIDToString(params.ID))
_ = r.c.Set(ctx, cacheId, params.TokenVersion, 5*time.Minute)
return nil
}
func (r *userRepository) UpdatePassword(ctx context.Context, params sqlc.UpdateUserPasswordParams) error {
err := r.q.UpdateUserPassword(ctx, params)
user, err := r.GetByID(ctx, params.ID)
if err != nil {
return err
}
err = r.q.UpdateUserPassword(ctx, params)
if err != nil {
return err
}
err = r.UpdateTokenVersion(ctx, sqlc.UpdateTokenVersionParams{
ID: params.ID,
TokenVersion: user.TokenVersion + 1,
})
if err != nil {
return err
}
func (r *userRepository) ExistEmail(ctx context.Context, email string) (bool, error) {
row, err := r.q.ExistsUserByEmail(ctx, email)
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) UpdateRefreshToken(ctx context.Context, params sqlc.UpdateUserRefreshTokenParams) error {
user, err := r.GetByID(ctx, params.ID)
if err != nil {
return false, err
return err
}
return row, nil
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
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")
}

123
pkg/cache/redis.go vendored
View File

@@ -2,31 +2,136 @@ package cache
import (
"context"
"encoding/json"
"fmt"
"history-api/pkg/config"
"time"
"github.com/redis/go-redis/v9"
)
var RI *redis.Client
type Cache interface {
Set(ctx context.Context, key string, value any, ttl time.Duration) error
Get(ctx context.Context, key string, dest any) error
Del(ctx context.Context, keys ...string) error
DelByPattern(ctx context.Context, pattern string) error
MGet(ctx context.Context, keys ...string) [][]byte
MSet(ctx context.Context, pairs map[string]any, ttl time.Duration) error
}
func Connect() error {
connectionURI, err := config.GetConfig("REDIS_CONNECTION_URI")
type RedisClient struct {
client *redis.Client
}
func NewRedisClient() (Cache, error) {
uri, err := config.GetConfig("REDIS_CONNECTION_URI")
if err != nil {
return err
return nil, err
}
rdb := redis.NewClient(&redis.Options{
Addr: connectionURI,
Password: "",
DB: 0,
Addr: uri,
MinIdleConns: 10,
DialTimeout: 5 * time.Second,
ReadTimeout: 3 * time.Second,
WriteTimeout: 3 * time.Second,
MaxRetries: 3,
MinRetryBackoff: 8 * time.Millisecond,
MaxRetryBackoff: 512 * time.Millisecond,
DisableIdentity: true,
})
if err := rdb.Ping(context.Background()).Err(); err != nil {
return fmt.Errorf("Could not connect to Redis: %v", err)
return nil, fmt.Errorf("could not connect to Redis: %v", err)
}
return &RedisClient{client: rdb}, nil
}
RI = rdb
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
}
func (r Role) ToSlice() []string {
return []string{r.String()}
func ParseRole(s string) (Role, bool) {
r := Role(s)
if CheckValidRole(r) {
return r, true
}
return "", false
}
func (r Role) ToSlice() []Role {
return []Role{r}
}

View File

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

View File

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

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.Tag = fieldError.Tag()
element.Value = fieldError.Param()
element.Message = "Field " + fieldError.Field() + " failed validation on tag '" + fieldError.Tag() + "'"
switch fieldError.Tag() {
case "required":
element.Message = fieldError.Field() + " is required"
case "min":
element.Message = fieldError.Field() + " must be at least " + fieldError.Param() + " characters"
case "max":
element.Message = fieldError.Field() + " must be at most " + fieldError.Param() + " characters"
case "email":
element.Message = "Invalid email format"
default:
element.Message = fieldError.Error()
}
errorsList = append(errorsList, element)
}
}