add db layer

This commit is contained in:
SalehAskarzadeh 2026-03-31 10:21:07 +03:30
commit ee164a7aa7
13 changed files with 437 additions and 0 deletions

21
.env Normal file
View File

@ -0,0 +1,21 @@
# Server
SERVER_PORT=9000
BASE_URL=http://localhost:9000
#Database
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=SALEH@url-shortner
DB_NAME=urlshortner
DB_SSLMODE=disable
# Redis
REDIS_ADDR=localhost:6378
REDIS_PASSWORD=SALEH@url-shortner
REDIS_DB=0
# App
DEFAULT_EXPIREY_HOURS=720
API_KEY=my-secret-api-key

10
.idea/.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# Editor-based HTTP Client requests
/httpRequests/

11
.idea/go.imports.xml Normal file
View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GoImports">
<option name="excludedPackages">
<array>
<option value="github.com/pkg/errors" />
<option value="golang.org/x/net/context" />
</array>
</option>
</component>
</project>

8
.idea/modules.xml Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/url-shortner.iml" filepath="$PROJECT_DIR$/.idea/url-shortner.iml" />
</modules>
</component>
</project>

9
.idea/url-shortner.iml Normal file
View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

6
.idea/vcs.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

27
cmd/server/main.go Normal file
View File

@ -0,0 +1,27 @@
package main
import (
"log"
"net/http"
"github.com/SalehGoML/url-shortner/internal/config"
"github.com/gin-gonic/gin"
)
func main() {
cfg := config.Load()
log.Printf("Starting server on port %s", cfg.ServerPort)
r := gin.Default()
r.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"message": "URL Shortener is running",
})
})
if err := r.Run(":" + cfg.ServerPort); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}

28
docker-compose.yml Normal file
View File

@ -0,0 +1,28 @@
services:
postgres:
image: postgres:16-alpine
container_name: urlshortner-db
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: SALEH@url-shortner
POSTGRES_DB: urlshortner
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
- ./migrations:/docker-entrypoint-initdb.d
redis:
image: redis:7-alpine
container_name: urlshortner-cache
ports:
- "6379:6379"
command: redis-server --appendonly yes
volumes:
- redisdata:/data
volumes:
pgdata:
redisdata:

3
go.mod Normal file
View File

@ -0,0 +1,3 @@
module git.gocasts.ir/msaskarzadeh/url-shortner.git
go 1.25.0

68
internal/config/config.go Normal file
View File

@ -0,0 +1,68 @@
package config
import (
"log"
"os"
"strconv"
"github.com/joho/godotenv"
)
type Config struct {
ServerPort string
BaseURL string
DBHost string
DBPort string
DBUser string
DBPassword string
DBName string
DBSSLMode string
RedisAddr string
RedisPassword string
RedisDB int
DefaultExpiryHrs int
APIKey string
}
func Load() *Config {
if err := godotenv.Load(); err != nil {
log.Println("No .env file found, using system env vars")
}
redisDB, _ := strconv.Atoi(getEnv("REDIS_DB", "0"))
expiryHrs, _ := strconv.Atoi(getEnv("DEFAULT_EXPIRY_HOURS", "720"))
return &Config{
ServerPort: getEnv("SERVER_PORT", "9000"),
BaseURL: getEnv("BASE_URL", "http://localhost:9000"),
DBHost: getEnv("DB_HOST", "localhost"),
DBPort: getEnv("DB_PORT", "5432"),
DBUser: getEnv("DB_USER", "postgres"),
DBPassword: getEnv("DB_PASSWORD", "SALEH@url-shortner"),
DBName: getEnv("DB_NAME", "url-shortner"),
DBSSLMode: getEnv("DB_SSLMODE", "disable"),
RedisAddr: getEnv("REDIS_ADDR", "localhost:6379"),
RedisPassword: getEnv("REDIS_PASSWORD", ""),
RedisDB: redisDB,
DefaultExpiryHrs: expiryHrs,
APIKey: getEnv("API_KEY", ""),
}
}
func getEnv(key, fallback string) string {
if val, exists := os.LookupEnv(key); exists {
return val
}
return fallback
}
func (c *Config) DNS() string {
return "host=" + c.DBHost +
" port=" + c.DBPort +
" user=" + c.DBUser +
" password=" + c.DBPassword +
" dbname=" + c.DBName +
" sslmode=" + c.DBSSLMode
}

48
internal/model/url.go Normal file
View File

@ -0,0 +1,48 @@
package model
import "time"
type URL struct {
ID int64 `json:"id"`
ShortCode string `json:"short_code"`
OriginalURL string `json:"original"`
UserID string `json:"user_id"`
Clicks int64 `json:"clicks"`
IsActive bool `json:"is_active"`
CreatedAt int64 `json:"created_at"`
ExpiresAt int64 `json:"expires_at,omitempty"`
UpdatedAt int64 `json:"updated_at"`
}
type ClickLog struct {
ID int64 `json:"id"`
URLID string `json:"url_id"`
IPAddress string `json:"ip_address"`
UserAgent string `json:"user_agent"`
Referer string `json:"referer"`
ClickedAt time.Time `json:"clicked_at"`
}
type CreateURLRequest struct {
OriginalURL string `json:"original"`
CustomCode string `json:"custom_code,omitempty"`
ExpiryHours int `json:"expiry_hours,omitempty"`
}
type CreateURLResponse struct {
ShortCode string `json:"short_code" binding:"required"`
ShortURL string `json:"short_url"`
OriginalURL string `json:"original_url"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type URLStats struct {
ShortCode string `json:"short_code"`
OriginalURL string `json:"original_url"`
Clicks int64 `json:"clicks"`
IsActive bool `json:"is_active"`
CreatedAt int64 `json:"created_at"`
ExpiresAt int64 `json:"expires_at,omitempty"`
RecentClick []ClickLog `json:"recent_click"`
}

View File

@ -0,0 +1,171 @@
package repository
import (
"context"
"database/sql"
"fmt"
"time"
"git.gocasts.ir/msaskarzadeh/url-shortner.git/internal/model"
)
type URLRepository struct {
db *sql.DB
}
func NewURLRepository(dns string) (*URLRepository, error) {
db, err := sql.Open("postgres", dns)
if err != nil {
return nil, fmt.Errorf("failed to open DB: %w", err)
}
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(5 * time.Minute)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
return nil, fmt.Errorf("failed to ping DB: %w", err)
}
return &URLRepository{db: db}, nil
}
func (r *URLRepository) Create(ctx context.Context, url *model.URL) error {
query := `
INSERT INTO urls (short_code, original_url, user_id, expires_at)
VALUES ($1, $2, $3, $4)
RETURNING id, created_at, updated_at`
return r.db.QueryRowContext(ctx, query,
url.ShortCode,
url.OriginalURL,
url.UserID,
url.ExpiresAt,
).Scan(&url.ID, &url.CreatedAt, &url.UpdatedAt)
}
func (r *URLRepository) GetShortCode(ctx context.Context, code string) (*model.URL, error) {
query := `
SELECT id, short_code, original_url, user_id, clicks,
is_active, created_at, expires_at, updated_at
FROM urls
WHERE short_code = $1`
url := &model.URL{}
err := r.db.QueryRowContext(ctx, query, code).Scan(
&url.ID,
&url.ShortCode,
&url.OriginalURL,
&url.UserID,
&url.Clicks,
&url.IsActive,
&url.CreatedAt,
&url.ExpiresAt,
&url.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get URL: %w", err)
}
return url, nil
}
func (r *URLRepository) IncrementClicks(ctx context.Context, id int64) error {
query := `
UPDATE urls
SET clicks = clicks + 1,
updated_at = NOW()
WHERE id = $1`
_, err := r.db.ExecContext(ctx, query, id)
if err != nil {
return fmt.Errorf("failed to increment clicks: %w", err)
}
return nil
}
func (r *URLRepository) LogClicks(ctx context.Context, logEntry *model.ClickLog) error {
query := `
INSERT INTO click_logs (url_id, ip_address, user_agent, referer)
VALUES ($1, $2, $3, $4)
RETURNING id, clicked_at`
return r.db.QueryRowContext(
ctx, query,
logEntry.URLID,
logEntry.IPAddress,
logEntry.UserAgent,
logEntry.Referer,
).Scan(&logEntry.ID, &logEntry.ClickedAt)
}
func (r *URLRepository) GetClicks(ctx context.Context, urlID int64, limit int) ([]model.ClickLog, error) {
query := `
SELECT id, url_id, ip_address, user_agent, referer, clicked_at
FROM click_logs
WHERE url_id = $1
ORDER BY clicked_at DESC
LIMIT $2`
rows, err := r.db.QueryContext(ctx, query, urlID, limit)
if err != nil {
return nil, fmt.Errorf("failed to fetch recent clicks: %w", err)
}
defer rows.Close()
var logs []model.ClickLog
for rows.Next() {
var l model.ClickLog
if err := rows.Scan(
&l.ID, &l.URLID, &l.IPAddress,
&l.UserAgent, &l.Referer, &l.ClickedAt,
); err != nil {
return nil, err
}
logs = append(logs, l)
}
return logs, nil
}
func (r *URLRepository) DeactivateExpiredLinks(ctx context.Context) (int64, error) {
query := `
UPDATE urls
SET is_active = false,
updated_at = NOW()
WHERE expires_at IS NOT NULL
AND expires_at < NOW()
AND is_active = true`
result, err := r.db.ExecContext(ctx, query)
if err != nil {
return 0, fmt.Errorf("failed to deactivate expired links: %w", err)
}
affected, err := result.RowsAffected()
return affected, err
}
func (r *URLRepository) CheckShortExists(ctx context.Context, code string) (bool, error) {
query := `SELECT 1 FROM urls WHERE short_code = $1`
var exists int
err := r.db.QueryRowContext(ctx, query, code).Scan(&exists)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, fmt.Errorf("failed to check code: %w", err)
}
return true, nil
}

View File

@ -0,0 +1,27 @@
CREATE TABLE IF NOT EXISTS urls (
id BIGSERIAL PRIMARY KEY,
short_code VARCHAR(10) NOT NULL UNIQUE,
original_url TEXT NOT NULL,
user_id VARCHAR(100) DEFAULT 'anonymous',
clicks BIGINT DEFAULT 0,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT NOW(),
expires_at TIMESTAMP,
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_urls_short_code ON urls(short_code);
CREATE INDEX idx_urls_active_expiry ON urls(is_active, expires_at);
CREATE TABLE IF NOT EXISTS click_logs (
id BIGSERIAL PRIMARY KEY,
url_id BIGINT REFERENCES urls(id) ON DELETE CASCADE,
ip_address VARCHAR(45),
user_agent TEXT,
referer TEXT,
clicked_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_click_logs_url_id ON click_logs(url_id);
CREATE INDEX idx_click_logs_time ON click_logs(clicked_at);