├── internal
├── models
│ ├── success.go
│ ├── syrup
│ │ ├── syrup_success.go
│ │ ├── syrup_version.go
│ │ ├── syrup_error.go
│ │ ├── syrup_merchant.go
│ │ └── syrup_coupon.go
│ ├── tags.go
│ ├── error.go
│ ├── regions.go
│ ├── healthcheck.go
│ ├── categorie.go
│ ├── vote_models.go
│ ├── merchant.go
│ ├── timestamparray.go
│ └── coupon.go
├── handlers
│ ├── get_health.go
│ ├── syrup
│ │ ├── get_version.go
│ │ ├── post_invalid.go
│ │ ├── get_merchants.go
│ │ ├── post_valid.go
│ │ └── get_coupons.go
│ └── coupons
│ │ ├── get_tags.go
│ │ ├── get_by_id.go
│ │ ├── get_regions.go
│ │ ├── get_merchants.go
│ │ ├── get_categories.go
│ │ ├── post_vote.go
│ │ ├── post_coupon.go
│ │ └── get_coupons.go
├── database
│ ├── postgres.go
│ └── redis.go
├── config
│ └── config.go
├── jobs
│ └── score_updates.go
├── routes
│ └── routes.go
├── middleware
│ └── ratelimiter.go
└── repositories
│ └── coupon_repo.go
├── .env.example
├── .gitignore
├── docker-compose.yml
├── self_host.md
├── go.mod
├── cmd
└── api
│ └── main.go
├── README.md
├── go.sum
├── docs
├── swagger.yaml
└── swagger.json
└── LICENSE
/internal/models/success.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | type Success struct {
4 | Message string `json:"message" example:"Success"`
5 | }
6 |
--------------------------------------------------------------------------------
/internal/models/syrup/syrup_success.go:
--------------------------------------------------------------------------------
1 | package syrup
2 |
3 | type Success struct {
4 | Success string `json:"success" example:"true"`
5 | }
6 |
--------------------------------------------------------------------------------
/internal/models/tags.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | type TagResponse struct {
4 | Tags []string `json:"tags"`
5 | Total int `json:"total"`
6 | }
7 |
--------------------------------------------------------------------------------
/internal/models/error.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | type ErrorResponse struct {
4 | Message string `json:"message" example:"Internal server error"`
5 | }
6 |
--------------------------------------------------------------------------------
/internal/models/regions.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | type RegionResponse struct {
4 | Regions []string `json:"regions"`
5 | Total int `json:"total"`
6 | }
7 |
--------------------------------------------------------------------------------
/internal/models/healthcheck.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | type HealthCheckResponse struct {
4 | Status string `json:"status" example:"ok"`
5 | Version string `json:"version" example:"1.0"`
6 | }
7 |
--------------------------------------------------------------------------------
/internal/models/syrup/syrup_version.go:
--------------------------------------------------------------------------------
1 | package syrup
2 |
3 | type VersionInfo struct {
4 | Version string `json:"version" example:"1.0.0"`
5 | Provider string `json:"provider" example:"DiscountDB"`
6 | }
7 |
--------------------------------------------------------------------------------
/internal/models/categorie.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | type CategoriesResponse struct {
4 | Total int `json:"total" example:"2"`
5 | Categories []string `json:"data" example:[\"Electronics\",\"Clothing\"]`
6 | }
7 |
--------------------------------------------------------------------------------
/internal/models/syrup/syrup_error.go:
--------------------------------------------------------------------------------
1 | package syrup
2 |
3 | type ErrorResponse struct {
4 | Error string `json:"error" example:"Internal Server Error"`
5 | Message string `json:"message" example:"Something went wrong"`
6 | }
7 |
--------------------------------------------------------------------------------
/internal/models/vote_models.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import "time"
4 |
5 | type Vote struct {
6 | ID int64
7 | Timestamp time.Time
8 | }
9 |
10 | type VoteBody struct {
11 | ID int64 `json:"id"`
12 | Dir string `json:"dir"`
13 | }
14 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | DB_USERNAME = root
2 | DB_PASSWORD = root
3 | DB_HOST = localhost
4 | DB_PORT = 25060
5 | DB_DATABASE = yourdbname
6 | DB_SSL_MODE = disable
7 |
8 | REDIS_USERNAME = default
9 | REDIS_PASSWORD = root
10 | REDIS_HOST = localhost
11 | REDIS_PORT = 25061
12 | REDIS_USE_TLS = false
--------------------------------------------------------------------------------
/internal/models/syrup/syrup_merchant.go:
--------------------------------------------------------------------------------
1 | package syrup
2 |
3 | type Merchant struct {
4 | MerchantName string `json:"merchant_name"`
5 | Domains []string `json:"domains"`
6 | }
7 |
8 | type MerchantList struct {
9 | Merchants []Merchant `json:"merchants"`
10 | Total int `json:"total"`
11 | }
12 |
--------------------------------------------------------------------------------
/internal/models/merchant.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | type Merchant struct {
4 | Name string `json:"merchant_name" example:"merchant1"`
5 | Domains []string `json:"merchant_url" example:["https://merchant1.com"]`
6 | }
7 |
8 | type MerchantResponse struct {
9 | Total int `json:"total" example:"2"`
10 | Data []Merchant `json:"data"`
11 | }
12 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Binaries for programs and plugins
2 | *.exe
3 | *.exe~
4 | *.dll
5 | *.so
6 | *.dylib
7 |
8 | # Test binary, built with `go test -c`
9 | *.test
10 |
11 | # Output of the go coverage tool, specifically when used with LiteIDE
12 | *.out
13 |
14 | # Go workspace file
15 | go.work
16 | go.work.sum
17 |
18 | # env file
19 | .env
20 |
21 | # Idea
22 | .idea
23 | .vscode
--------------------------------------------------------------------------------
/internal/handlers/get_health.go:
--------------------------------------------------------------------------------
1 | package handlers
2 |
3 | import "github.com/gofiber/fiber/v2"
4 |
5 | // HealthCheck godoc
6 | // @Summary Health check endpoint
7 | // @Description Get API health status
8 | // @Tags health
9 | // @Accept json
10 | // @Produce json
11 | // @Success 200 {object} models.HealthCheckResponse
12 | // @Failure 500 {object} models.ErrorResponse
13 | // @Router /health [get]
14 | func HealthCheck(c *fiber.Ctx) error {
15 | return c.JSON(fiber.Map{
16 | "status": "ok",
17 | "version": "1.0",
18 | })
19 | }
20 |
--------------------------------------------------------------------------------
/internal/models/syrup/syrup_coupon.go:
--------------------------------------------------------------------------------
1 | package syrup
2 |
3 | type Coupon struct {
4 | ID string `json:"id" example:"123"`
5 | Title string `json:"title" example:"Discount"`
6 | Description string `json:"description" example:"Get 10% off"`
7 | Code string `json:"code" example:"DISCOUNT10"`
8 | Score float64 `json:"score" example:"5"`
9 | }
10 |
11 | type CouponList struct {
12 | Coupons []Coupon `json:"coupons"`
13 | Total int `json:"total"`
14 | MerchantName string `json:"merchant_name" example:"Amazon"`
15 | }
16 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | postgres:
3 | image: postgres:16
4 | container_name: postgres
5 | environment:
6 | POSTGRES_USER: root
7 | POSTGRES_PASSWORD: root
8 | POSTGRES_DB: discountdb
9 | ports:
10 | - "25060:5432"
11 | volumes:
12 | - postgres_data:/var/lib/postgresql/data
13 | networks:
14 | - app-network
15 | restart: unless-stopped
16 |
17 | redis:
18 | image: redis:7
19 | container_name: redis
20 | command: redis-server --requirepass root
21 | ports:
22 | - "25061:6379"
23 | volumes:
24 | - redis_data:/data
25 | networks:
26 | - app-network
27 | restart: unless-stopped
28 |
29 | volumes:
30 | postgres_data:
31 | redis_data:
32 |
33 | networks:
34 | app-network:
35 | driver: bridge
--------------------------------------------------------------------------------
/internal/database/postgres.go:
--------------------------------------------------------------------------------
1 | package database
2 |
3 | import (
4 | "database/sql"
5 | "discountdb-api/internal/config"
6 | "fmt"
7 | "time"
8 | )
9 |
10 | func NewPostgresDB(cfg *config.Config) (*sql.DB, error) {
11 | connStr := fmt.Sprintf(
12 | "host=%s port=%s user=%s password=%s dbname=%s sslmode=%s",
13 | cfg.DBHost, cfg.DBPort, cfg.DBUser, cfg.DBPassword, cfg.DBName, cfg.DBSSLMode,
14 | )
15 |
16 | db, err := sql.Open("postgres", connStr)
17 | if err != nil {
18 | return nil, fmt.Errorf("error opening database connection: %w", err)
19 | }
20 |
21 | // Connection pool settings
22 | db.SetMaxOpenConns(25)
23 | db.SetMaxIdleConns(25)
24 | db.SetConnMaxLifetime(5 * time.Minute)
25 |
26 | // Test the connection
27 | if err := db.Ping(); err != nil {
28 | return nil, fmt.Errorf("error connecting to the database: %w", err)
29 | }
30 |
31 | return db, nil
32 | }
33 |
--------------------------------------------------------------------------------
/internal/config/config.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "github.com/joho/godotenv"
5 | "os"
6 | )
7 |
8 | type Config struct {
9 | DBHost string
10 | DBPort string
11 | DBUser string
12 | DBPassword string
13 | DBName string
14 | DBSSLMode string
15 |
16 | REDISHost string
17 | REDISPort string
18 | REDISUser string
19 | REDISPassword string
20 | REDISUseTLS bool
21 | }
22 |
23 | func LoadConfig() (*Config, error) {
24 | _ = godotenv.Load()
25 |
26 | config := &Config{
27 | DBHost: os.Getenv("DB_HOST"),
28 | DBPort: os.Getenv("DB_PORT"),
29 | DBUser: os.Getenv("DB_USERNAME"),
30 | DBPassword: os.Getenv("DB_PASSWORD"),
31 | DBName: os.Getenv("DB_DATABASE"),
32 | DBSSLMode: os.Getenv("DB_SSL_MODE"),
33 |
34 | REDISHost: os.Getenv("REDIS_HOST"),
35 | REDISPort: os.Getenv("REDIS_PORT"),
36 | REDISUser: os.Getenv("REDIS_USERNAME"),
37 | REDISPassword: os.Getenv("REDIS_PASSWORD"),
38 | REDISUseTLS: os.Getenv("REDIS_USE_TLS") == "true",
39 | }
40 |
41 | return config, nil
42 | }
43 |
--------------------------------------------------------------------------------
/internal/models/timestamparray.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import (
4 | "database/sql/driver"
5 | "strings"
6 | "time"
7 | )
8 |
9 | type TimestampArray []time.Time
10 |
11 | func (a *TimestampArray) Scan(src interface{}) error {
12 | switch v := src.(type) {
13 | case []byte:
14 | return a.scanString(string(v))
15 | case string:
16 | return a.scanString(v)
17 | case nil:
18 | *a = nil
19 | return nil
20 | default:
21 | return driver.ErrSkip
22 | }
23 | }
24 |
25 | func (a *TimestampArray) scanString(str string) error {
26 | if str == "{}" {
27 | *a = TimestampArray{}
28 | return nil
29 | }
30 |
31 | str = strings.Trim(str, "{}")
32 | parts := strings.Split(str, ",")
33 |
34 | times := make([]time.Time, len(parts))
35 | for i, part := range parts {
36 | part = strings.Trim(strings.TrimSpace(part), "\"")
37 | t, err := time.Parse("2006-01-02 15:04:05.999999", part)
38 | if err != nil {
39 | return err
40 | }
41 | times[i] = t
42 | }
43 |
44 | *a = times
45 | return nil
46 | }
47 |
48 | func (a TimestampArray) Value() (driver.Value, error) {
49 | if a == nil {
50 | return nil, nil
51 | }
52 |
53 | strs := make([]string, len(a))
54 | for i, t := range a {
55 | strs[i] = "\"" + t.Format("2006-01-02 15:04:05.999999") + "\""
56 | }
57 |
58 | return "{" + strings.Join(strs, ",") + "}", nil
59 | }
60 |
--------------------------------------------------------------------------------
/internal/handlers/syrup/get_version.go:
--------------------------------------------------------------------------------
1 | package syrup
2 |
3 | import (
4 | "discountdb-api/internal/models/syrup"
5 | "github.com/gofiber/fiber/v2"
6 | )
7 |
8 | // GetVersionInfo godoc
9 | // @Summary Get API Version
10 | // @Description Returns information about the API implementation
11 | // @Tags syrup
12 | // @Produce json
13 | // @Param X-Syrup-API-Key header string false "Optional API key for authentication"
14 | // @Success 200 {object} syrup.VersionInfo "Successful response"
15 | // @Header 200 {string} X-RateLimit-Limit "The maximum number of requests allowed per time window"
16 | // @Header 200 {string} X-RateLimit-Remaining "The number of requests remaining in the time window"
17 | // @Header 200 {string} X-RateLimit-Reset "The time when the rate limit window resets (Unix timestamp)"
18 | // @Failure 400 {object} syrup.ErrorResponse "Bad Request"
19 | // @Failure 401 {object} syrup.ErrorResponse "Unauthorized"
20 | // @Failure 429 {object} syrup.ErrorResponse "Too Many Requests"
21 | // @Header 429 {integer} X-RateLimit-RetryAfter "Time to wait before retrying (seconds)"
22 | // @Failure 500 {object} syrup.ErrorResponse "Internal Server Error"
23 | // @Router /syrup/version [get]
24 | func GetVersionInfo(ctx *fiber.Ctx) error {
25 | return ctx.JSON(syrup.VersionInfo{
26 | Version: "1.0.0",
27 | Provider: "DiscountDB",
28 | })
29 | }
30 |
--------------------------------------------------------------------------------
/internal/database/redis.go:
--------------------------------------------------------------------------------
1 | package database
2 |
3 | import (
4 | "context"
5 | "crypto/tls"
6 | "discountdb-api/internal/config"
7 | "fmt"
8 | "github.com/redis/go-redis/v9"
9 | "time"
10 | )
11 |
12 | func NewRedisClient(cfg *config.Config) (*redis.Client, error) {
13 | var tlsConfig *tls.Config
14 | if cfg.REDISUseTLS {
15 | tlsConfig = &tls.Config{
16 | MinVersion: tls.VersionTLS12,
17 | }
18 | }
19 |
20 | rdb := redis.NewClient(&redis.Options{
21 | Addr: fmt.Sprintf("%s:%s", cfg.REDISHost, cfg.REDISPort),
22 | Username: cfg.REDISUser,
23 | Password: cfg.REDISPassword,
24 | DB: 0,
25 |
26 | // Connection Pool
27 | PoolSize: 30,
28 | MinIdleConns: 10,
29 | ConnMaxLifetime: 30 * time.Minute,
30 | PoolTimeout: 4 * time.Second,
31 |
32 | // Timeouts
33 | DialTimeout: 5 * time.Second,
34 | ReadTimeout: 5 * time.Second,
35 | WriteTimeout: 5 * time.Second,
36 |
37 | // Retry Strategy
38 | MaxRetries: 3,
39 | MinRetryBackoff: 8 * time.Millisecond,
40 | MaxRetryBackoff: 512 * time.Millisecond,
41 |
42 | // TLS Config
43 | TLSConfig: tlsConfig,
44 | })
45 |
46 | // Test the connection
47 | ctx := context.Background()
48 | if err := rdb.Ping(ctx).Err(); err != nil {
49 | return nil, fmt.Errorf("error connecting to Redis: %w", err)
50 | }
51 |
52 | return rdb, nil
53 | }
54 |
--------------------------------------------------------------------------------
/internal/handlers/syrup/post_invalid.go:
--------------------------------------------------------------------------------
1 | package syrup
2 |
3 | import (
4 | "github.com/gofiber/fiber/v2"
5 | "github.com/redis/go-redis/v9"
6 | )
7 |
8 | // PostCouponInvalid godoc
9 | // @Summary Report Invalid Coupon
10 | // @Description Report that a coupon code failed to work
11 | // @Tags syrup
12 | // @Produce json
13 | // @Param X-Syrup-API-Key header string false "Optional API key for authentication"
14 | // @Param id path string true "The ID of the coupon"
15 | // @Success 200 {object} syrup.Success "Successful response"
16 | // @Header 200 {string} X-RateLimit-Limit "The maximum number of requests allowed per time window"
17 | // @Header 200 {string} X-RateLimit-Remaining "The number of requests remaining in the time window"
18 | // @Header 200 {string} X-RateLimit-Reset "The time when the rate limit window resets (Unix timestamp)"
19 | // @Failure 400 {object} syrup.ErrorResponse "Bad Request"
20 | // @Failure 401 {object} syrup.ErrorResponse "Unauthorized"
21 | // @Failure 429 {object} syrup.ErrorResponse "Too Many Requests"
22 | // @Header 429 {integer} X-RateLimit-RetryAfter "Time to wait before retrying (seconds)"
23 | // @Failure 500 {object} syrup.ErrorResponse "Internal Server Error"
24 | // @Router /syrup/coupons/invalid/{id} [post]
25 | func PostCouponInvalid(ctx *fiber.Ctx, rdb *redis.Client) error {
26 | return PostCouponVote(ctx, rdb, "down")
27 | }
28 |
--------------------------------------------------------------------------------
/internal/handlers/syrup/get_merchants.go:
--------------------------------------------------------------------------------
1 | package syrup
2 |
3 | import (
4 | "discountdb-api/internal/handlers/coupons"
5 | "discountdb-api/internal/models/syrup"
6 | "discountdb-api/internal/repositories"
7 | "github.com/gofiber/fiber/v2"
8 | "github.com/redis/go-redis/v9"
9 | )
10 |
11 | // GetMerchants godoc
12 | // @Summary List all Merchants
13 | // @Description Returns a list of all merchants and their domains
14 | // @Tags syrup
15 | // @Produce json
16 | // @Param X-Syrup-API-Key header string false "Optional API key for authentication"
17 | // @Success 200 {object} syrup.MerchantList "Successful response"
18 | // @Header 200 {string} X-RateLimit-Limit "The maximum number of requests allowed per time window"
19 | // @Header 200 {string} X-RateLimit-Remaining "The number of requests remaining in the time window"
20 | // @Header 200 {string} X-RateLimit-Reset "The time when the rate limit window resets (Unix timestamp)"
21 | // @Failure 400 {object} syrup.ErrorResponse "Bad Request"
22 | // @Failure 401 {object} syrup.ErrorResponse "Unauthorized"
23 | // @Failure 429 {object} syrup.ErrorResponse "Too Many Requests"
24 | // @Header 429 {integer} X-RateLimit-RetryAfter "Time to wait before retrying (seconds)"
25 | // @Failure 500 {object} syrup.ErrorResponse "Internal Server Error"
26 | // @Router /syrup/merchants [get]
27 | func GetMerchants(ctx *fiber.Ctx, couponRepo *repositories.CouponRepository, rdb *redis.Client) error {
28 | merchants, err := coupons.GetMerchantsResponse(ctx, couponRepo, rdb)
29 |
30 | if err != nil {
31 | return err
32 | }
33 |
34 | response := syrup.MerchantList{
35 | Total: merchants.Total,
36 | }
37 |
38 | for _, merchant := range merchants.Data {
39 | response.Merchants = append(response.Merchants, syrup.Merchant{
40 | MerchantName: merchant.Name,
41 | Domains: merchant.Domains,
42 | })
43 | }
44 |
45 | return ctx.JSON(response)
46 | }
47 |
--------------------------------------------------------------------------------
/self_host.md:
--------------------------------------------------------------------------------
1 | # Instructions for self-hosting
2 |
3 | This guide will help you set up and run the [DiscountDB API](https://github.com/ImGajeed76/discountdb-api) with
4 | containerized PostgreSQL and Redis services.
5 |
6 | ## Prerequisites
7 |
8 | - [Git](https://git-scm.com/downloads)
9 | - [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/)
10 | - [Go](https://golang.org/doc/install) (1.24.0 or later)
11 |
12 | ## Setup Steps
13 |
14 | ### 1. Clone the Repository
15 |
16 | ```bash
17 | git clone https://github.com/ImGajeed76/discountdb-api.git
18 | cd discountdb-api
19 | ```
20 |
21 | ### 2. Update Environment Variables and Compose File
22 |
23 | Copy the `.env.example` file to `.env`:
24 |
25 | ```bash
26 | cp .env.example .env
27 | ```
28 |
29 | Check and update all the environment variables in the `.env` file.
30 |
31 | If you just want a quick local setup you can use this `.env` file:
32 |
33 | ```bash
34 | DB_USERNAME = root
35 | DB_PASSWORD = root
36 | DB_HOST = localhost
37 | DB_PORT = 25060
38 | DB_DATABASE = discountdb
39 | DB_SSL_MODE = disable
40 |
41 | REDIS_USERNAME = default
42 | REDIS_PASSWORD = root
43 | REDIS_HOST = localhost
44 | REDIS_PORT = 25061
45 | REDIS_USE_TLS = false
46 | ```
47 |
48 | Update the following in the `docker-compose.yml` file:
49 |
50 | ```yaml
51 | POSTGRES_USER: [ DB_USERNAME ]
52 | POSTGRES_PASSWORD: [ DB_PASSWORD ]
53 | POSTGRES_DB: [ DB_DATABASE ]
54 |
55 | ports:
56 | - "[DB_PORT]:5432"
57 | ```
58 |
59 | ```yaml
60 | command: redis-server --requirepass [REDIS_PASSWORD]
61 |
62 | ports:
63 | - "[REDIS_PORT]:6379"
64 | ```
65 |
66 | ### 3. Start the Services
67 |
68 | Start the services using Docker Compose:
69 |
70 | ```bash
71 | docker-compose up -d
72 | ```
73 |
74 | This will spin up Redis and PostgreSQL containers.
75 |
76 | ### 4. Run the API Locally
77 |
78 | Once Redis and PostgreSQL are running via Docker Compose, you can run the API:
79 |
80 | ```bash
81 | go run cmd/api/main.go
82 | ```
83 |
--------------------------------------------------------------------------------
/internal/handlers/coupons/get_tags.go:
--------------------------------------------------------------------------------
1 | package coupons
2 |
3 | import (
4 | "discountdb-api/internal/models"
5 | "discountdb-api/internal/repositories"
6 | "encoding/json"
7 | "github.com/gofiber/fiber/v2"
8 | "github.com/redis/go-redis/v9"
9 | "log"
10 | )
11 |
12 | func GetTagsResponse(c *fiber.Ctx, couponRepo *repositories.CouponRepository, rdb *redis.Client) (*models.TagResponse, error) {
13 | // redis cache
14 | key := "tags"
15 |
16 | var response models.TagResponse
17 | if rdb != nil {
18 | if cached, err := rdb.Get(c.Context(), key).Result(); err == nil {
19 | if err := json.Unmarshal([]byte(cached), &response); err == nil {
20 | return &response, nil
21 | }
22 | // If unmarshal fails, just log and continue to fetch fresh data
23 | log.Printf("Failed to unmarshal cached data: %v", err)
24 | }
25 | }
26 |
27 | // Get tags if not in cache
28 | tags, err := couponRepo.GetTags(c.Context())
29 | if err != nil {
30 | log.Printf("Failed to get tags: %v", err)
31 | return nil, c.Status(fiber.StatusInternalServerError).JSON(models.ErrorResponse{Message: "Failed to get tags"})
32 | }
33 |
34 | // Set cache
35 | if rdb != nil {
36 | if cached, err := json.Marshal(tags); err == nil {
37 | if err := rdb.Set(c.Context(), key, cached, cacheExpire).Err(); err != nil {
38 | log.Printf("Failed to cache response: %v", err)
39 | }
40 | } else {
41 | log.Printf("Failed to marshal response for caching: %v", err)
42 | }
43 | }
44 |
45 | return tags, nil
46 | }
47 |
48 | // GetTags godoc
49 | // @Summary Get all tags
50 | // @Description Retrieve a list of all tags
51 | // @Tags tags
52 | // @Produce json
53 | // @Success 200 {object} models.TagResponse
54 | // @Failure 500 {object} models.ErrorResponse
55 | // @Router /coupons/tags [get]
56 | func GetTags(c *fiber.Ctx, couponRepo *repositories.CouponRepository, rdb *redis.Client) error {
57 | tags, err := GetTagsResponse(c, couponRepo, rdb)
58 |
59 | if err != nil {
60 | return err
61 | }
62 |
63 | return c.JSON(tags)
64 | }
65 |
--------------------------------------------------------------------------------
/internal/handlers/coupons/get_by_id.go:
--------------------------------------------------------------------------------
1 | package coupons
2 |
3 | import (
4 | "discountdb-api/internal/models"
5 | "discountdb-api/internal/repositories"
6 | "encoding/json"
7 | "github.com/gofiber/fiber/v2"
8 | "github.com/redis/go-redis/v9"
9 | "log"
10 | )
11 |
12 | // GetCouponByID godoc
13 | // @Summary Get coupon by ID
14 | // @Description Retrieve a single coupon by its ID
15 | // @Tags coupons
16 | // @Accept json
17 | // @Produce json
18 | // @Param id path int true "Coupon ID"
19 | // @Success 200 {object} models.Coupon
20 | // @Failure 404 {object} models.ErrorResponse
21 | // @Failure 500 {object} models.ErrorResponse
22 | // @Router /coupons/{id} [get]
23 | func GetCouponByID(c *fiber.Ctx, couponRepo *repositories.CouponRepository, rdb *redis.Client) error {
24 | id, err := c.ParamsInt("id")
25 | if err != nil {
26 | return c.Status(fiber.StatusBadRequest).JSON(models.ErrorResponse{Message: "Invalid coupon ID"})
27 | }
28 |
29 | // redis cache
30 | key := "coupon:id:" + string(id)
31 |
32 | var response fiber.Map
33 | if rdb != nil {
34 | if cached, err := rdb.Get(c.Context(), key).Result(); err == nil {
35 | if err := json.Unmarshal([]byte(cached), &response); err == nil {
36 | return c.JSON(response)
37 | }
38 | // If unmarshal fails, just log and continue to fetch fresh data
39 | log.Printf("Failed to unmarshal cached data: %v", err)
40 | }
41 | }
42 |
43 | // Get coupon by ID if not in cache
44 | coupon, err := couponRepo.GetByID(c.Context(), int64(id))
45 | if err != nil {
46 | log.Printf("Failed to get coupon: %v", err)
47 | return c.Status(fiber.StatusInternalServerError).JSON(models.ErrorResponse{Message: "Failed to get coupon"})
48 | }
49 |
50 | // Set cache
51 | if rdb != nil {
52 | if cached, err := json.Marshal(coupon); err == nil {
53 | if err := rdb.Set(c.Context(), key, cached, cacheExpire).Err(); err != nil {
54 | log.Printf("Failed to cache response: %v", err)
55 | }
56 | } else {
57 | log.Printf("Failed to marshal response for caching: %v", err)
58 | }
59 | }
60 |
61 | return c.JSON(coupon)
62 | }
63 |
--------------------------------------------------------------------------------
/internal/handlers/coupons/get_regions.go:
--------------------------------------------------------------------------------
1 | package coupons
2 |
3 | import (
4 | "discountdb-api/internal/models"
5 | "discountdb-api/internal/repositories"
6 | "encoding/json"
7 | "github.com/gofiber/fiber/v2"
8 | "github.com/redis/go-redis/v9"
9 | "log"
10 | )
11 |
12 | func GetRegionsResponse(c *fiber.Ctx, couponRepo *repositories.CouponRepository, rdb *redis.Client) (*models.RegionResponse, error) {
13 | // redis cache
14 | key := "regions"
15 |
16 | var response models.RegionResponse
17 | if rdb != nil {
18 | if cached, err := rdb.Get(c.Context(), key).Result(); err == nil {
19 | if err := json.Unmarshal([]byte(cached), &response); err == nil {
20 | return &response, nil
21 | }
22 | // If unmarshal fails, just log and continue to fetch fresh data
23 | log.Printf("Failed to unmarshal cached data: %v", err)
24 | }
25 | }
26 |
27 | // Get regions if not in cache
28 | regions, err := couponRepo.GetRegions(c.Context())
29 | if err != nil {
30 | log.Printf("Failed to get regions: %v", err)
31 | return nil, c.Status(fiber.StatusInternalServerError).JSON(models.ErrorResponse{Message: "Failed to get regions"})
32 | }
33 |
34 | // Set cache
35 | if rdb != nil {
36 | if cached, err := json.Marshal(regions); err == nil {
37 | if err := rdb.Set(c.Context(), key, cached, cacheExpire).Err(); err != nil {
38 | log.Printf("Failed to cache response: %v", err)
39 | }
40 | } else {
41 | log.Printf("Failed to marshal response for caching: %v", err)
42 | }
43 | }
44 |
45 | return regions, nil
46 | }
47 |
48 | // GetRegions godoc
49 | // @Summary Get all regions
50 | // @Description Retrieve a list of all regions
51 | // @Tags regions
52 | // @Produce json
53 | // @Success 200 {object} models.RegionResponse
54 | // @Failure 500 {object} models.ErrorResponse
55 | // @Router /coupons/regions [get]
56 | func GetRegions(c *fiber.Ctx, couponRepo *repositories.CouponRepository, rdb *redis.Client) error {
57 | regions, err := GetRegionsResponse(c, couponRepo, rdb)
58 |
59 | if err != nil {
60 | return err
61 | }
62 |
63 | return c.JSON(regions)
64 | }
65 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module discountdb-api
2 |
3 | go 1.22.0
4 |
5 | require (
6 | github.com/gofiber/contrib/swagger v1.2.0
7 | github.com/gofiber/fiber/v2 v2.52.9
8 | github.com/joho/godotenv v1.5.1
9 | github.com/lib/pq v1.10.9
10 | github.com/redis/go-redis/v9 v9.7.3
11 | github.com/swaggo/swag v1.16.4
12 | )
13 |
14 | require (
15 | github.com/KyleBanks/depth v1.2.1 // indirect
16 | github.com/andybalholm/brotli v1.1.0 // indirect
17 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
18 | github.com/cespare/xxhash/v2 v2.2.0 // indirect
19 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
20 | github.com/go-openapi/analysis v0.21.4 // indirect
21 | github.com/go-openapi/errors v0.20.4 // indirect
22 | github.com/go-openapi/jsonpointer v0.21.0 // indirect
23 | github.com/go-openapi/jsonreference v0.21.0 // indirect
24 | github.com/go-openapi/loads v0.21.2 // indirect
25 | github.com/go-openapi/runtime v0.26.2 // indirect
26 | github.com/go-openapi/spec v0.21.0 // indirect
27 | github.com/go-openapi/strfmt v0.21.8 // indirect
28 | github.com/go-openapi/swag v0.23.0 // indirect
29 | github.com/go-openapi/validate v0.22.3 // indirect
30 | github.com/google/uuid v1.6.0 // indirect
31 | github.com/josharian/intern v1.0.0 // indirect
32 | github.com/klauspost/compress v1.17.9 // indirect
33 | github.com/mailru/easyjson v0.9.0 // indirect
34 | github.com/mattn/go-colorable v0.1.13 // indirect
35 | github.com/mattn/go-isatty v0.0.20 // indirect
36 | github.com/mattn/go-runewidth v0.0.16 // indirect
37 | github.com/mitchellh/mapstructure v1.5.0 // indirect
38 | github.com/oklog/ulid v1.3.1 // indirect
39 | github.com/rivo/uniseg v0.2.0 // indirect
40 | github.com/valyala/bytebufferpool v1.0.0 // indirect
41 | github.com/valyala/fasthttp v1.51.0 // indirect
42 | github.com/valyala/tcplisten v1.0.0 // indirect
43 | go.mongodb.org/mongo-driver v1.13.1 // indirect
44 | golang.org/x/sys v0.28.0 // indirect
45 | golang.org/x/tools v0.28.0 // indirect
46 | gopkg.in/yaml.v2 v2.4.0 // indirect
47 | gopkg.in/yaml.v3 v3.0.1 // indirect
48 | )
49 |
--------------------------------------------------------------------------------
/internal/jobs/score_updates.go:
--------------------------------------------------------------------------------
1 | package jobs
2 |
3 | import (
4 | "context"
5 | "database/sql"
6 | "log"
7 | "time"
8 | )
9 |
10 | type ScoreUpdater struct {
11 | db *sql.DB
12 | batchSize int
13 | interval time.Duration
14 | done chan bool
15 | }
16 |
17 | func NewScoreUpdater(db *sql.DB, batchSize int, interval time.Duration) *ScoreUpdater {
18 | return &ScoreUpdater{
19 | db: db,
20 | batchSize: batchSize,
21 | interval: interval,
22 | done: make(chan bool),
23 | }
24 | }
25 |
26 | func (s *ScoreUpdater) updateScores() error {
27 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
28 | defer cancel()
29 |
30 | // Get total count of coupons that need updating
31 | var totalToUpdate int
32 | err := s.db.QueryRowContext(ctx, `
33 | SELECT COUNT(*)
34 | FROM coupons
35 | WHERE last_score_update IS NULL
36 | OR last_score_update < CURRENT_TIMESTAMP - INTERVAL '1 hour'
37 | `).Scan(&totalToUpdate)
38 | if err != nil {
39 | return err
40 | }
41 |
42 | // No coupons need updating
43 | if totalToUpdate == 0 {
44 | return nil
45 | }
46 |
47 | // Calculate number of batches needed
48 | batches := (totalToUpdate + s.batchSize - 1) / s.batchSize
49 |
50 | // Process all batches
51 | for i := 0; i < batches; i++ {
52 | _, err := s.db.ExecContext(ctx, "SELECT update_materialized_scores_batch($1)", s.batchSize)
53 | if err != nil {
54 | return err
55 | }
56 |
57 | // Small sleep between batches to reduce database load
58 | time.Sleep(100 * time.Millisecond)
59 | }
60 |
61 | // Log success
62 | log.Printf("Updated scores for %d coupons", totalToUpdate)
63 |
64 | return nil
65 | }
66 |
67 | func (s *ScoreUpdater) Start() {
68 | go func() {
69 | ticker := time.NewTicker(s.interval)
70 | defer ticker.Stop()
71 |
72 | for {
73 | select {
74 | case <-ticker.C:
75 | if err := s.updateScores(); err != nil {
76 | log.Printf("Error updating scores: %v", err)
77 | }
78 | case <-s.done:
79 | return
80 | }
81 | }
82 | }()
83 | }
84 |
85 | func (s *ScoreUpdater) Stop() {
86 | s.done <- true
87 | }
88 |
--------------------------------------------------------------------------------
/internal/handlers/coupons/get_merchants.go:
--------------------------------------------------------------------------------
1 | package coupons
2 |
3 | import (
4 | "discountdb-api/internal/models"
5 | "discountdb-api/internal/repositories"
6 | "encoding/json"
7 | "github.com/gofiber/fiber/v2"
8 | "github.com/redis/go-redis/v9"
9 | "log"
10 | )
11 |
12 | func GetMerchantsResponse(c *fiber.Ctx, couponRepo *repositories.CouponRepository, rdb *redis.Client) (*models.MerchantResponse, error) {
13 | // redis cache
14 | key := "merchants"
15 |
16 | var response models.MerchantResponse
17 | if rdb != nil {
18 | if cached, err := rdb.Get(c.Context(), key).Result(); err == nil {
19 | if err := json.Unmarshal([]byte(cached), &response); err == nil {
20 | return &response, nil
21 | }
22 | // If unmarshal fails, just log and continue to fetch fresh data
23 | log.Printf("Failed to unmarshal cached data: %v", err)
24 | }
25 | }
26 |
27 | // Get merchants if not in cache
28 | merchants, err := couponRepo.GetMerchants(c.Context())
29 | if err != nil {
30 | log.Printf("Failed to get merchants: %v", err)
31 | return nil, c.Status(fiber.StatusInternalServerError).JSON(models.ErrorResponse{Message: "Failed to get merchants"})
32 | }
33 |
34 | // Set cache
35 | if rdb != nil {
36 | if cached, err := json.Marshal(merchants); err == nil {
37 | if err := rdb.Set(c.Context(), key, cached, cacheExpire).Err(); err != nil {
38 | log.Printf("Failed to cache response: %v", err)
39 | }
40 | } else {
41 | log.Printf("Failed to marshal response for caching: %v", err)
42 | }
43 | }
44 |
45 | return merchants, nil
46 | }
47 |
48 | // GetMerchants godoc
49 | // @Summary Get all merchants
50 | // @Description Retrieve a list of all merchants
51 | // @Tags merchants
52 | // @Produce json
53 | // @Success 200 {object} models.MerchantResponse
54 | // @Failure 500 {object} models.ErrorResponse
55 | // @Router /coupons/merchants [get]
56 | func GetMerchants(c *fiber.Ctx, couponRepo *repositories.CouponRepository, rdb *redis.Client) error {
57 | merchants, err := GetMerchantsResponse(c, couponRepo, rdb)
58 |
59 | if err != nil {
60 | return err
61 | }
62 |
63 | return c.JSON(merchants)
64 | }
65 |
--------------------------------------------------------------------------------
/internal/handlers/coupons/get_categories.go:
--------------------------------------------------------------------------------
1 | package coupons
2 |
3 | import (
4 | "discountdb-api/internal/models"
5 | "discountdb-api/internal/repositories"
6 | "encoding/json"
7 | "github.com/gofiber/fiber/v2"
8 | "github.com/redis/go-redis/v9"
9 | "log"
10 | )
11 |
12 | func GetCategoriesResponse(c *fiber.Ctx, couponRepo *repositories.CouponRepository, rdb *redis.Client) (*models.CategoriesResponse, error) {
13 | // redis cache
14 | key := "coupons"
15 |
16 | var response models.CategoriesResponse
17 | if rdb != nil {
18 | if cached, err := rdb.Get(c.Context(), key).Result(); err == nil {
19 | if err := json.Unmarshal([]byte(cached), &response); err == nil {
20 | return &response, nil
21 | }
22 | // If unmarshal fails, just log and continue to fetch fresh data
23 | log.Printf("Failed to unmarshal cached data: %v", err)
24 | }
25 | }
26 |
27 | // Get categories if not in cache
28 | categories, err := couponRepo.GetCategories(c.Context())
29 | if err != nil {
30 | log.Printf("Failed to get categories: %v", err)
31 | return nil, c.Status(fiber.StatusInternalServerError).JSON(models.ErrorResponse{Message: "Failed to get categories"})
32 | }
33 |
34 | // Set cache
35 | if rdb != nil {
36 | if cached, err := json.Marshal(categories); err == nil {
37 | if err := rdb.Set(c.Context(), key, cached, cacheExpire).Err(); err != nil {
38 | log.Printf("Failed to cache response: %v", err)
39 | }
40 | } else {
41 | log.Printf("Failed to marshal response for caching: %v", err)
42 | }
43 | }
44 |
45 | return categories, nil
46 | }
47 |
48 | // GetCategories godoc
49 | // @Summary Get all categories
50 | // @Description Retrieve a list of all categories
51 | // @Tags categories
52 | // @Produce json
53 | // @Success 200 {object} models.CategoriesResponse
54 | // @Failure 500 {object} models.ErrorResponse
55 | // @Router /coupons/categories [get]
56 | func GetCategories(c *fiber.Ctx, couponRepo *repositories.CouponRepository, rdb *redis.Client) error {
57 | categories, err := GetCategoriesResponse(c, couponRepo, rdb)
58 |
59 | if err != nil {
60 | return err
61 | }
62 |
63 | return c.JSON(categories)
64 | }
65 |
--------------------------------------------------------------------------------
/cmd/api/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "database/sql"
5 | "discountdb-api/internal/config"
6 | "discountdb-api/internal/database"
7 | "discountdb-api/internal/jobs"
8 | "discountdb-api/internal/routes"
9 | "github.com/gofiber/contrib/swagger"
10 | "github.com/gofiber/fiber/v2"
11 | "github.com/gofiber/fiber/v2/middleware/cors"
12 | "github.com/gofiber/fiber/v2/middleware/logger"
13 | "github.com/redis/go-redis/v9"
14 | "log"
15 | "time"
16 | )
17 |
18 | // @title DiscountDB API
19 | // @version 1.0
20 | // @description This is the DiscountDB API documentation
21 | // @termsOfService http://swagger.io/terms/
22 | // @host api.discountdb.ch
23 | // @BasePath /api/v1
24 | func main() {
25 | // Load configuration
26 | cfg, err := config.LoadConfig()
27 | if err != nil {
28 | log.Fatalf("Failed to load config: %v", err)
29 | }
30 |
31 | // Initialize database
32 | db, err := database.NewPostgresDB(cfg)
33 | if err != nil {
34 | log.Fatalf("Failed to initialize database: %v", err)
35 | }
36 | defer func(db *sql.DB) {
37 | err := db.Close()
38 | if err != nil {
39 | log.Fatalf("Failed to close database connection: %v", err)
40 | }
41 | }(db)
42 | log.Printf("Successfully connected to database: %s", cfg.DBName)
43 |
44 | // Initialize redis
45 | rdb, err := database.NewRedisClient(cfg)
46 | if err != nil {
47 | log.Fatalf("Failed to initialize redis: %v", err)
48 | }
49 | defer func(rdb *redis.Client) {
50 | err := rdb.Close()
51 | if err != nil {
52 | log.Fatalf("Failed to close redis connection: %v", err)
53 | }
54 | }(rdb)
55 | log.Printf("Successfully connected to redis: %s", cfg.REDISHost)
56 |
57 | // Initialize Cron Jobs
58 | scoreUpdate := jobs.NewScoreUpdater(db, 1000, 1*time.Hour)
59 | scoreUpdate.Start()
60 |
61 | // Initialize Fiber app
62 | app := fiber.New(fiber.Config{
63 | AppName: "DiscountDB API v1.0",
64 | })
65 |
66 | app.Use(logger.New())
67 |
68 | app.Use(swagger.New(swagger.Config{
69 | BasePath: "/api/v1/",
70 | FilePath: "./docs/swagger.json",
71 | Path: "docs",
72 | Title: "DiscountDB API v1.0",
73 | }))
74 |
75 | app.Use(cors.New(cors.Config{
76 | AllowOrigins: "*",
77 | }))
78 |
79 | routes.SetupRoutes(app, db, rdb)
80 |
81 | log.Fatal(app.Listen(":3000"))
82 | }
83 |
--------------------------------------------------------------------------------
/internal/handlers/syrup/post_valid.go:
--------------------------------------------------------------------------------
1 | package syrup
2 |
3 | import (
4 | "discountdb-api/internal/handlers/coupons"
5 | "discountdb-api/internal/models"
6 | "discountdb-api/internal/models/syrup"
7 | "encoding/json"
8 | "github.com/gofiber/fiber/v2"
9 | "github.com/redis/go-redis/v9"
10 | "strconv"
11 | "time"
12 | )
13 |
14 | func PostCouponVote(ctx *fiber.Ctx, rdb *redis.Client, dir string) error {
15 | vote := models.VoteBody{
16 | Dir: dir,
17 | }
18 |
19 | if id, err := strconv.Atoi(ctx.Params("id")); err != nil || id < 1 {
20 | return ctx.Status(fiber.StatusBadRequest).JSON(
21 | syrup.ErrorResponse{
22 | Error: "InvalidID",
23 | Message: "Invalid coupon ID",
24 | },
25 | )
26 | } else {
27 | vote.ID = int64(id)
28 | }
29 |
30 | voteQueue := coupons.VoteQueue{
31 | ID: vote.ID,
32 | Timestamp: time.Now(),
33 | VoteType: vote.Dir,
34 | }
35 |
36 | queueJSON, err := json.Marshal(voteQueue)
37 | if err != nil {
38 | return err
39 | }
40 |
41 | err = rdb.RPush(ctx.Context(), "vote_queue", queueJSON).Err()
42 | if err != nil {
43 | return err
44 | }
45 |
46 | return ctx.JSON(syrup.Success{
47 | Success: "Coupon successfully reported as valid",
48 | })
49 | }
50 |
51 | // PostCouponValid godoc
52 | // @Summary Report Valid Coupon
53 | // @Description Report that a coupon code was successfully used
54 | // @Tags syrup
55 | // @Produce json
56 | // @Param X-Syrup-API-Key header string false "Optional API key for authentication"
57 | // @Param id path string true "The ID of the coupon"
58 | // @Success 200 {object} syrup.Success "Successful response"
59 | // @Header 200 {string} X-RateLimit-Limit "The maximum number of requests allowed per time window"
60 | // @Header 200 {string} X-RateLimit-Remaining "The number of requests remaining in the time window"
61 | // @Header 200 {string} X-RateLimit-Reset "The time when the rate limit window resets (Unix timestamp)"
62 | // @Failure 400 {object} syrup.ErrorResponse "Bad Request"
63 | // @Failure 401 {object} syrup.ErrorResponse "Unauthorized"
64 | // @Failure 429 {object} syrup.ErrorResponse "Too Many Requests"
65 | // @Header 429 {integer} X-RateLimit-RetryAfter "Time to wait before retrying (seconds)"
66 | // @Failure 500 {object} syrup.ErrorResponse "Internal Server Error"
67 | // @Router /syrup/coupons/valid/{id} [post]
68 | func PostCouponValid(ctx *fiber.Ctx, rdb *redis.Client) error {
69 | return PostCouponVote(ctx, rdb, "up")
70 | }
71 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # DiscountDB API 🚀
2 |
3 | Backend API service for DiscountDB - the open-source coupon and discount database.
4 |
5 | ## Technology Stack 💻
6 |
7 | - **Language**: Go
8 | - **Cache**: Redis
9 | - **Database**: PostgreSQL
10 | - **API Documentation**: Swagger/OpenAPI
11 |
12 | ## Prerequisites
13 |
14 | - Go 1.21+
15 | - Docker and Docker Compose
16 | - [swag](https://github.com/swaggo/swag) (for API documentation)
17 |
18 | ## Getting Started 🛠️
19 |
20 | ### 1. Clone the repository
21 |
22 | ```bash
23 | git clone https://github.com/ImGajeed76/discountdb-api.git
24 | cd discountdb-api
25 | ```
26 |
27 | ### 2. Install dependencies
28 |
29 | ```bash
30 | go mod download
31 | ```
32 |
33 | ### 3. Set up environment variables
34 |
35 | ```bash
36 | cp .env.example .env
37 | ```
38 |
39 | Configure the following in your `.env`:
40 |
41 | - PostgreSQL connection details
42 | - Redis connection details
43 |
44 | ### 4. Generate Swagger documentation
45 |
46 | ```bash
47 | # Install swag if you haven't already
48 | go install github.com/swaggo/swag/cmd/swag@latest
49 | ```
50 |
51 | ```bash
52 | swag init -g ./cmd/api/main.go
53 | ```
54 |
55 | ### 5. Run the API using Docker Compose
56 |
57 | To simplify the setup of Redis and PostgreSQL, use the provided `docker-compose.yml` file:
58 |
59 | 1. Start the services using Docker Compose:
60 |
61 | ```bash
62 | docker-compose up -d
63 | ```
64 |
65 | This will spin up Redis and PostgreSQL containers.
66 |
67 | ### 6. Run the API locally
68 |
69 | Once Redis and PostgreSQL are running via Docker Compose, you can run the API:
70 |
71 | ```bash
72 | go run cmd/api/main.go
73 | ```
74 |
75 | ## Troubleshooting
76 |
77 | - Ensure Docker is running and the containers are healthy (`docker ps` to check their status).
78 | - If ports 5432 or 6379 are already in use, update the `docker-compose.yml` file to use different host ports.
79 |
80 | ## Stopping the Docker Services
81 |
82 | To stop the services:
83 |
84 | ```bash
85 | docker-compose down
86 | ```
87 |
88 | This will stop and remove the containers.
89 |
90 | ## License 📜
91 |
92 | This project is licensed under the GNU General Public License v3 (GPL-3.0). See the [LICENSE](LICENSE) file for details.
93 |
94 | ## Related Projects 🔗
95 |
96 | - [DiscountDB Frontend](https://github.com/ImGajeed76/discountdb) - Main repository containing the SvelteKit frontend
97 |
98 | ## Contributing 🤝
99 |
100 | 1. Fork the repository
101 | 2. Create a feature branch
102 | 3. Commit your changes
103 | 4. Push to the branch
104 | 5. Submit a pull request
105 |
106 | ## Support 💬
107 |
108 | For questions and support:
109 |
110 | - Open an [issue](https://github.com/ImGajeed76/discountdb-api/issues)
111 | - Visit the [main project repository](https://github.com/ImGajeed76/discountdb)
112 |
--------------------------------------------------------------------------------
/internal/handlers/coupons/post_vote.go:
--------------------------------------------------------------------------------
1 | package coupons
2 |
3 | import (
4 | "context"
5 | "discountdb-api/internal/models"
6 | "discountdb-api/internal/repositories"
7 | "encoding/json"
8 | "github.com/gofiber/fiber/v2"
9 | "github.com/redis/go-redis/v9"
10 | "strconv"
11 | "time"
12 | )
13 |
14 | type VoteQueue struct {
15 | ID int64 `json:"id"`
16 | Timestamp time.Time `json:"timestamp"`
17 | VoteType string `json:"vote_type"`
18 | }
19 |
20 | // PostVote godoc
21 | // @Summary Vote on a coupon
22 | // @Description Vote on a coupon by ID
23 | // @Tags votes
24 | // @Produce json
25 | // @Param dir path string true "Vote direction (up or down)"
26 | // @Param id path string true "Coupon ID"
27 | // @Success 200 {object} models.Success
28 | // @Failure 400 {object} models.ErrorResponse
29 | // @Router /coupons/vote/{dir}/{id} [post]
30 | func PostVote(c *fiber.Ctx, rdb *redis.Client) error {
31 | // Get vote direction
32 | dir := c.Params("dir")
33 | if dir != "up" && dir != "down" {
34 | return c.Status(fiber.StatusBadRequest).JSON(models.ErrorResponse{
35 | Message: "Invalid vote direction",
36 | })
37 | }
38 |
39 | vote := models.VoteBody{
40 | Dir: dir,
41 | }
42 |
43 | if id, err := strconv.Atoi(c.Params("id")); err != nil || id < 1 {
44 | return c.Status(fiber.StatusBadRequest).JSON(
45 | models.ErrorResponse{
46 | Message: "Invalid coupon ID",
47 | },
48 | )
49 | } else {
50 | vote.ID = int64(id)
51 | }
52 |
53 | voteQueue := VoteQueue{
54 | ID: vote.ID,
55 | Timestamp: time.Now(),
56 | VoteType: vote.Dir,
57 | }
58 |
59 | queueJSON, err := json.Marshal(voteQueue)
60 | if err != nil {
61 | return err
62 | }
63 |
64 | err = rdb.RPush(c.Context(), "vote_queue", queueJSON).Err()
65 | if err != nil {
66 | return err
67 | }
68 |
69 | return c.JSON(models.Success{
70 | Message: "Vote successfully added to queue",
71 | })
72 | }
73 |
74 | func ProcessVoteQueue(ctx context.Context, couponRepo *repositories.CouponRepository, rdb *redis.Client, batchSize int) error {
75 | for {
76 | // Get votes batch
77 | results, err := rdb.LRange(ctx, "vote_queue", 0, int64(batchSize-1)).Result()
78 | if err != nil {
79 | return err
80 | }
81 | if len(results) == 0 {
82 | time.Sleep(5 * time.Second)
83 | continue
84 | }
85 |
86 | upVotes := []models.Vote{}
87 | downVotes := []models.Vote{}
88 |
89 | // Parse votes
90 | for _, result := range results {
91 | var voteQueue VoteQueue
92 | if err := json.Unmarshal([]byte(result), &voteQueue); err != nil {
93 | continue
94 | }
95 |
96 | vote := models.Vote{ID: voteQueue.ID, Timestamp: voteQueue.Timestamp}
97 | if voteQueue.VoteType == "up" {
98 | upVotes = append(upVotes, vote)
99 | } else {
100 | downVotes = append(downVotes, vote)
101 | }
102 | }
103 |
104 | // Process votes
105 | if len(upVotes) > 0 {
106 | if err := couponRepo.BatchAddVotes(ctx, upVotes, "up"); err != nil {
107 | return err
108 | }
109 | }
110 | if len(downVotes) > 0 {
111 | if err := couponRepo.BatchAddVotes(ctx, downVotes, "down"); err != nil {
112 | return err
113 | }
114 | }
115 |
116 | // Remove processed votes
117 | rdb.LTrim(ctx, "vote_queue", int64(len(results)), -1)
118 | }
119 | }
120 |
--------------------------------------------------------------------------------
/internal/models/coupon.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import "time"
4 |
5 | type DiscountType string
6 |
7 | const (
8 | PercentageOff DiscountType = "PERCENTAGE_OFF"
9 | FixedAmount DiscountType = "FIXED_AMOUNT"
10 | BOGO DiscountType = "BOGO"
11 | FreeShipping DiscountType = "FREE_SHIPPING"
12 | )
13 |
14 | type Coupon struct {
15 | // Required Information
16 | ID int64 `json:"id"`
17 | CreatedAt time.Time `json:"created_at"`
18 | Code string `json:"code"`
19 | Title string `json:"title"`
20 | Description string `json:"description"`
21 | DiscountValue float64 `json:"discount_value"`
22 | DiscountType DiscountType `json:"discount_type"`
23 | MerchantName string `json:"merchant_name"`
24 | MerchantURL string `json:"merchant_url"`
25 |
26 | // Optional Validity Information
27 | StartDate *time.Time `json:"start_date,omitempty"`
28 | EndDate *time.Time `json:"end_date,omitempty"`
29 | TermsConditions string `json:"terms_conditions,omitempty"`
30 | MinimumPurchaseAmount float64 `json:"minimum_purchase_amount,omitempty"`
31 | MaximumDiscountAmount float64 `json:"maximum_discount_amount,omitempty"`
32 |
33 | // Voting Information
34 | UpVotes TimestampArray `json:"up_votes"`
35 | DownVotes TimestampArray `json:"down_votes"`
36 |
37 | // Metadata
38 | Categories []string `json:"categories,omitempty"`
39 | Tags []string `json:"tags,omitempty"`
40 | Regions []string `json:"regions,omitempty"` // countries/regions where valid
41 | StoreType string `json:"store_type,omitempty"` // "online", "in_store", "both"
42 |
43 | // Score calculated by db
44 | MaterializedScore float64 `json:"score"`
45 | LastScoreUpdate *time.Time `json:"-"` // not exposed to API
46 | }
47 |
48 | type CouponsSearchResponse struct {
49 | Data []Coupon `json:"data" example:[{"id":1,"title":"Discount","description":"Get 10% off","score":5,"created_at":"2021-01-01T00:00:00Z"}]`
50 | Total int `json:"total" example:"100"`
51 | Limit int `json:"limit" example:"10"`
52 | Offset int `json:"offset" example:"0"`
53 | }
54 |
55 | type CouponsSearchParams struct {
56 | SearchString string `json:"search_string" example:"discount"`
57 | SortBy string `json:"sort_by" example:"newest" enums:"newest,oldest,high_score,low_score"`
58 | Limit int `json:"limit" example:"10" minimum:"1"`
59 | Offset int `json:"offset" example:"0" minimum:"0"`
60 | }
61 |
62 | type CouponCreateRequest struct {
63 | // Required Information
64 | Code string `json:"code"`
65 | Title string `json:"title"`
66 | Description string `json:"description"`
67 | DiscountValue float64 `json:"discount_value"`
68 | DiscountType DiscountType `json:"discount_type"`
69 | MerchantName string `json:"merchant_name"`
70 | MerchantURL string `json:"merchant_url"`
71 |
72 | // Optional Validity Information
73 | StartDate *time.Time `json:"start_date,omitempty"`
74 | EndDate *time.Time `json:"end_date,omitempty"`
75 | TermsConditions string `json:"terms_conditions,omitempty"`
76 | MinimumPurchaseAmount float64 `json:"minimum_purchase_amount,omitempty"`
77 | MaximumDiscountAmount float64 `json:"maximum_discount_amount,omitempty"`
78 |
79 | // Metadata
80 | Categories []string `json:"categories,omitempty"`
81 | Tags []string `json:"tags,omitempty"`
82 | Regions []string `json:"regions,omitempty"` // countries/regions where valid
83 | StoreType string `json:"store_type,omitempty"` // "online", "in_store", "both"
84 | }
85 |
86 | type CouponCreateResponse struct {
87 | ID int64 `json:"id"`
88 | CreatedAt time.Time `json:"created_at"`
89 | MaterializedScore float64 `json:"score"`
90 | }
91 |
--------------------------------------------------------------------------------
/internal/handlers/syrup/get_coupons.go:
--------------------------------------------------------------------------------
1 | package syrup
2 |
3 | import (
4 | "discountdb-api/internal/handlers/coupons"
5 | "discountdb-api/internal/models/syrup"
6 | "discountdb-api/internal/repositories"
7 | "github.com/gofiber/fiber/v2"
8 | "github.com/redis/go-redis/v9"
9 | "strconv"
10 | )
11 |
12 | // GetCoupons godoc
13 | // @Summary List Coupons
14 | // @Description Returns a paginated list of coupons for a specific domain
15 | // @Tags syrup
16 | // @Produce json
17 | // @Param X-Syrup-API-Key header string false "Optional API key for authentication"
18 | // @Param domain query string true "The website domain to fetch coupons for"
19 | // @Param limit query int false "Maximum number of coupons to return" minimum(1) default(20) maximum(100)
20 | // @Param offset query int false "Number of coupons to skip" minimum(0) default(0)
21 | // @Success 200 {object} syrup.CouponList "Successful response"
22 | // @Header 200 {string} X-RateLimit-Limit "The maximum number of requests allowed per time window"
23 | // @Header 200 {string} X-RateLimit-Remaining "The number of requests remaining in the time window"
24 | // @Header 200 {string} X-RateLimit-Reset "The time when the rate limit window resets (Unix timestamp)"
25 | // @Failure 400 {object} syrup.ErrorResponse "Bad Request"
26 | // @Failure 401 {object} syrup.ErrorResponse "Unauthorized"
27 | // @Failure 429 {object} syrup.ErrorResponse "Too Many Requests"
28 | // @Header 429 {integer} X-RateLimit-RetryAfter "Time to wait before retrying (seconds)"
29 | // @Failure 500 {object} syrup.ErrorResponse "Internal Server Error"
30 | // @Router /syrup/coupons [get]
31 | func GetCoupons(ctx *fiber.Ctx, couponRepo *repositories.CouponRepository, rdb *redis.Client) error {
32 | params := repositories.SearchParams{
33 | Limit: 20,
34 | Offset: 0,
35 | SearchIn: []string{
36 | "merchant_url",
37 | },
38 | }
39 |
40 | params.SearchString = ctx.Query("domain")
41 | params.SortBy = repositories.SortByHighScore
42 |
43 | if limitStr := ctx.Query("limitStr"); limitStr != "" {
44 | limit, err := strconv.Atoi(limitStr)
45 | if err != nil {
46 | return ctx.Status(fiber.StatusBadRequest).JSON(
47 | syrup.ErrorResponse{
48 | Error: "InvalidLimit",
49 | Message: "Invalid limit value",
50 | },
51 | )
52 | }
53 | if limit < 1 {
54 | return ctx.Status(fiber.StatusBadRequest).JSON(
55 | syrup.ErrorResponse{
56 | Error: "InvalidLimit",
57 | Message: "Limit must be greater than 0",
58 | },
59 | )
60 | }
61 | if limit > 100 {
62 | return ctx.Status(fiber.StatusBadRequest).JSON(
63 | syrup.ErrorResponse{
64 | Error: "InvalidLimit",
65 | Message: "Limit must be less than or equal to 100",
66 | },
67 | )
68 | }
69 | params.Limit = limit
70 | }
71 |
72 | if offsetStr := ctx.Query("offset"); offsetStr != "" {
73 | offset, err := strconv.Atoi(offsetStr)
74 | if err != nil {
75 | return ctx.Status(fiber.StatusBadRequest).JSON(
76 | syrup.ErrorResponse{
77 | Error: "InvalidOffset",
78 | Message: "Invalid offset value",
79 | },
80 | )
81 | }
82 | if offset < 0 {
83 | return ctx.Status(fiber.StatusBadRequest).JSON(
84 | syrup.ErrorResponse{
85 | Error: "InvalidOffset",
86 | Message: "Offset must be greater than or equal to 0",
87 | },
88 | )
89 | }
90 | params.Offset = offset
91 | }
92 |
93 | // Search for coupons
94 | response, err := coupons.SearchCoupons(params, ctx, couponRepo, rdb)
95 | if err != nil {
96 | return err
97 | }
98 |
99 | // Remap response to syrup.CouponList
100 | merchantName := "N/A"
101 |
102 | couponList := syrup.CouponList{
103 | Total: response.Total,
104 | }
105 |
106 | for _, coupon := range response.Data {
107 | if merchantName == "N/A" && coupon.MerchantName != "" {
108 | merchantName = coupon.MerchantName
109 | }
110 |
111 | couponList.Coupons = append(couponList.Coupons, syrup.Coupon{
112 | ID: strconv.FormatInt(coupon.ID, 10),
113 | Code: coupon.Code,
114 | Title: coupon.Title,
115 | Description: coupon.Description,
116 | Score: coupon.MaterializedScore,
117 | })
118 | }
119 |
120 | couponList.MerchantName = merchantName
121 |
122 | return ctx.JSON(couponList)
123 | }
124 |
--------------------------------------------------------------------------------
/internal/routes/routes.go:
--------------------------------------------------------------------------------
1 | package routes
2 |
3 | import (
4 | "context"
5 | "database/sql"
6 | "discountdb-api/internal/handlers"
7 | "discountdb-api/internal/handlers/coupons"
8 | "discountdb-api/internal/handlers/syrup"
9 | "discountdb-api/internal/middleware"
10 | "discountdb-api/internal/repositories"
11 | "fmt"
12 | "github.com/gofiber/fiber/v2"
13 | "github.com/redis/go-redis/v9"
14 | "log"
15 | "time"
16 | )
17 |
18 | func SetupRoutes(app *fiber.App, db *sql.DB, rdb *redis.Client) {
19 | ctx := context.Background()
20 | api := app.Group("/api/v1")
21 |
22 | // Middlewares
23 | defaultRateLimiter := middleware.NewRateLimiter(middleware.RateLimiterConfig{
24 | Max: 100,
25 | Window: time.Minute,
26 | Redis: rdb,
27 | KeyPrefix: "ratelimit:",
28 | })
29 |
30 | singleVoteRateLimiter := middleware.NewRateLimiter(middleware.RateLimiterConfig{
31 | Max: 1,
32 | Window: 10 * time.Minute,
33 | Redis: rdb,
34 | KeyPrefix: "singlevotelimit:",
35 | KeyFunc: func(c *fiber.Ctx) string {
36 | return fmt.Sprintf("%s:%s", c.IP(), c.Params("id"))
37 | },
38 | })
39 |
40 | voteRateLimiter := middleware.NewRateLimiter(middleware.RateLimiterConfig{
41 | Max: 10,
42 | Window: 10 * time.Minute,
43 | Redis: rdb,
44 | KeyPrefix: "votelimit:",
45 | KeyFunc: func(c *fiber.Ctx) string {
46 | return fmt.Sprintf("%s:%s", c.IP(), c.Params("id"))
47 | },
48 | })
49 |
50 | createCouponRateLimiter := middleware.NewRateLimiter(middleware.RateLimiterConfig{
51 | Max: 2,
52 | Window: 10 * time.Minute,
53 | Redis: rdb,
54 | KeyPrefix: "createcouponlimit:",
55 | })
56 |
57 | // Default route
58 | app.Get("/", func(c *fiber.Ctx) error {
59 | return c.SendString("API is running") // Or redirect to docs/API info
60 | })
61 |
62 | // Health check endpoint
63 | api.Get("/health", handlers.HealthCheck)
64 |
65 | // Coupon endpoints
66 | couponRepo := repositories.NewCouponRepository(db)
67 | if err := couponRepo.CreateTable(ctx); err != nil {
68 | log.Fatalf("Failed to create coupon table: %v", err)
69 | }
70 |
71 | api.Post("/coupons", createCouponRateLimiter, func(ctx *fiber.Ctx) error {
72 | return coupons.PostCoupon(ctx, couponRepo, rdb)
73 | })
74 | api.Get("/coupons/search", defaultRateLimiter, func(ctx *fiber.Ctx) error {
75 | return coupons.GetCoupons(ctx, couponRepo, rdb)
76 | })
77 | api.Get("/coupons/merchants", defaultRateLimiter, func(ctx *fiber.Ctx) error {
78 | return coupons.GetMerchants(ctx, couponRepo, rdb)
79 | })
80 | api.Get("/coupons/categories", defaultRateLimiter, func(ctx *fiber.Ctx) error {
81 | return coupons.GetCategories(ctx, couponRepo, rdb)
82 | })
83 | api.Get("/coupons/tags", defaultRateLimiter, func(ctx *fiber.Ctx) error {
84 | return coupons.GetTags(ctx, couponRepo, rdb)
85 | })
86 | api.Get("/coupons/regions", defaultRateLimiter, func(ctx *fiber.Ctx) error {
87 | return coupons.GetRegions(ctx, couponRepo, rdb)
88 | })
89 | api.Post("/coupons/vote/:dir/:id", voteRateLimiter, singleVoteRateLimiter, func(ctx *fiber.Ctx) error {
90 | return coupons.PostVote(ctx, rdb)
91 | })
92 | // This has to be the last route to avoid conflicts
93 | api.Get("/coupons/:id", defaultRateLimiter, func(ctx *fiber.Ctx) error {
94 | return coupons.GetCouponByID(ctx, couponRepo, rdb)
95 | })
96 |
97 | // Start processing vote queue
98 | go func() {
99 | if err := coupons.ProcessVoteQueue(context.Background(), couponRepo, rdb, 100); err != nil {
100 | log.Printf("Vote processor error: %v", err)
101 | }
102 | }()
103 |
104 | // Syrup Endpoint
105 | api.Get("/syrup/version", syrup.GetVersionInfo)
106 | api.Get("/syrup/coupons", defaultRateLimiter, func(ctx *fiber.Ctx) error {
107 | return syrup.GetCoupons(ctx, couponRepo, rdb)
108 | })
109 | api.Post("/syrup/coupons/valid/:id", voteRateLimiter, singleVoteRateLimiter, func(ctx *fiber.Ctx) error {
110 | return syrup.PostCouponValid(ctx, rdb)
111 | })
112 | api.Post("/syrup/coupons/invalid/:id", voteRateLimiter, singleVoteRateLimiter, func(ctx *fiber.Ctx) error {
113 | return syrup.PostCouponInvalid(ctx, rdb)
114 | })
115 | api.Get("/syrup/merchants", defaultRateLimiter, func(ctx *fiber.Ctx) error {
116 | return syrup.GetMerchants(ctx, couponRepo, rdb)
117 | })
118 | }
119 |
--------------------------------------------------------------------------------
/internal/handlers/coupons/post_coupon.go:
--------------------------------------------------------------------------------
1 | package coupons
2 |
3 | import (
4 | "discountdb-api/internal/models"
5 | "discountdb-api/internal/repositories"
6 | "github.com/gofiber/fiber/v2"
7 | "github.com/redis/go-redis/v9"
8 | "log"
9 | "strings"
10 | "time"
11 | )
12 |
13 | func ValidateCouponRequest(c *fiber.Ctx) (*models.CouponCreateRequest, error) {
14 | var coupon models.CouponCreateRequest
15 |
16 | if err := c.BodyParser(&coupon); err != nil {
17 | log.Printf("Error parsing create coupon request body: %v", err)
18 | return nil, c.Status(fiber.StatusBadRequest).JSON(models.ErrorResponse{
19 | Message: "Invalid request payload",
20 | })
21 | }
22 |
23 | // Validate coupon fields
24 | if coupon.Code == "" {
25 | return nil, c.Status(fiber.StatusBadRequest).JSON(models.ErrorResponse{
26 | Message: "Coupon code is required",
27 | })
28 | }
29 |
30 | if coupon.Title == "" {
31 | return nil, c.Status(fiber.StatusBadRequest).JSON(models.ErrorResponse{
32 | Message: "Coupon title is required",
33 | })
34 | }
35 |
36 | if coupon.Description == "" {
37 | return nil, c.Status(fiber.StatusBadRequest).JSON(models.ErrorResponse{
38 | Message: "Coupon description is required",
39 | })
40 | }
41 |
42 | if coupon.MerchantName == "" {
43 | return nil, c.Status(fiber.StatusBadRequest).JSON(models.ErrorResponse{
44 | Message: "Merchant name is required",
45 | })
46 | }
47 |
48 | if coupon.MerchantURL == "" {
49 | return nil, c.Status(fiber.StatusBadRequest).JSON(models.ErrorResponse{
50 | Message: "Merchant URL is required",
51 | })
52 | }
53 |
54 | coupon.MerchantURL = strings.TrimPrefix(coupon.MerchantURL, "https://")
55 | coupon.MerchantURL = strings.TrimPrefix(coupon.MerchantURL, "http://")
56 |
57 | if coupon.DiscountValue == 0 && coupon.DiscountType != models.FreeShipping && coupon.DiscountType != models.BOGO {
58 | return nil, c.Status(fiber.StatusBadRequest).JSON(models.ErrorResponse{
59 | Message: "Discount value is required",
60 | })
61 | }
62 |
63 | if coupon.DiscountType != models.PercentageOff &&
64 | coupon.DiscountType != models.FixedAmount &&
65 | coupon.DiscountType != models.FreeShipping &&
66 | coupon.DiscountType != models.BOGO {
67 | return nil, c.Status(fiber.StatusBadRequest).JSON(models.ErrorResponse{
68 | Message: "Invalid discount type",
69 | })
70 | }
71 |
72 | return &coupon, nil
73 | }
74 |
75 | // PostCoupon godoc
76 | // @Summary Create a new coupon
77 | // @Description Create a new coupon
78 | // @Tags coupons
79 | // @Accept json
80 | // @Produce json
81 | // @Param coupon body models.CouponCreateRequest true "CouponCreateRequest object"
82 | // @Success 200 {object} models.CouponCreateResponse
83 | // @Failure 400 {object} models.ErrorResponse "Bad Request"
84 | // @Failure 500 {object} models.ErrorResponse "Internal Server Error"
85 | // @Router /coupons [post]
86 | func PostCoupon(c *fiber.Ctx, couponRepo *repositories.CouponRepository, rdb *redis.Client) error {
87 | couponRequest, err := ValidateCouponRequest(c)
88 | if err != nil {
89 | return err
90 | }
91 |
92 | // Create coupon
93 | coupon := models.Coupon{
94 | ID: 0,
95 | CreatedAt: time.Now(),
96 | Code: couponRequest.Code,
97 | Title: couponRequest.Title,
98 | Description: couponRequest.Description,
99 | DiscountValue: couponRequest.DiscountValue,
100 | DiscountType: couponRequest.DiscountType,
101 | MerchantName: couponRequest.MerchantName,
102 | MerchantURL: couponRequest.MerchantURL,
103 | StartDate: couponRequest.StartDate,
104 | EndDate: couponRequest.EndDate,
105 | TermsConditions: couponRequest.TermsConditions,
106 | MinimumPurchaseAmount: couponRequest.MinimumPurchaseAmount,
107 | MaximumDiscountAmount: couponRequest.MaximumDiscountAmount,
108 | UpVotes: models.TimestampArray{},
109 | DownVotes: models.TimestampArray{},
110 | Categories: couponRequest.Categories,
111 | Tags: couponRequest.Tags,
112 | Regions: couponRequest.Regions,
113 | StoreType: couponRequest.StoreType,
114 | MaterializedScore: 0,
115 | LastScoreUpdate: nil,
116 | }
117 |
118 | // Save coupon
119 | if err := couponRepo.Create(c.Context(), &coupon); err != nil {
120 | log.Printf("Failed to create coupon: %v", err)
121 | return c.Status(fiber.StatusInternalServerError).JSON(models.ErrorResponse{
122 | Message: "Failed to create coupon",
123 | })
124 | }
125 |
126 | return c.JSON(models.CouponCreateResponse{
127 | ID: coupon.ID,
128 | MaterializedScore: coupon.MaterializedScore,
129 | CreatedAt: coupon.CreatedAt,
130 | })
131 | }
132 |
--------------------------------------------------------------------------------
/internal/middleware/ratelimiter.go:
--------------------------------------------------------------------------------
1 | package middleware
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "math"
7 | "time"
8 |
9 | "github.com/gofiber/fiber/v2"
10 | "github.com/redis/go-redis/v9"
11 | )
12 |
13 | // RateLimiterConfig holds the configuration for the rate limiter middleware
14 | type RateLimiterConfig struct {
15 | // Maximum number of requests allowed within the window
16 | Max int
17 |
18 | // Duration of the sliding window
19 | Window time.Duration
20 |
21 | // Redis client instance
22 | Redis *redis.Client
23 |
24 | // Optional prefix for Redis keys
25 | KeyPrefix string
26 |
27 | // Optional response when rate limit is exceeded
28 | LimitExceededHandler fiber.Handler
29 |
30 | // Optional key generation function
31 | KeyFunc func(c *fiber.Ctx) string
32 | }
33 |
34 | // ConfigDefault provides default configuration
35 | var ConfigDefault = RateLimiterConfig{
36 | Max: 60,
37 | Window: time.Minute,
38 | KeyPrefix: "ratelimit",
39 | LimitExceededHandler: func(c *fiber.Ctx) error {
40 | return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{
41 | "error": "Too many requests",
42 | })
43 | },
44 | KeyFunc: func(c *fiber.Ctx) string {
45 | return c.IP()
46 | },
47 | }
48 |
49 | // validateConfig ensures the configuration is valid
50 | func validateConfig(cfg *RateLimiterConfig) error {
51 | if cfg.Max <= 0 {
52 | return fmt.Errorf("max requests must be greater than 0")
53 | }
54 | if cfg.Window < time.Second {
55 | return fmt.Errorf("window must be at least 1 second")
56 | }
57 | if cfg.Window > 24*time.Hour {
58 | return fmt.Errorf("window must not exceed 24 hours")
59 | }
60 | return nil
61 | }
62 |
63 | // configDefault returns a config with default values for unset fields
64 | func configDefault(config ...RateLimiterConfig) (RateLimiterConfig, error) {
65 | cfg := ConfigDefault
66 |
67 | if len(config) > 0 {
68 | if config[0].Max > 0 {
69 | cfg.Max = config[0].Max
70 | }
71 | if config[0].Window != 0 {
72 | cfg.Window = config[0].Window
73 | }
74 | if config[0].KeyPrefix != "" {
75 | cfg.KeyPrefix = config[0].KeyPrefix
76 | }
77 | if config[0].LimitExceededHandler != nil {
78 | cfg.LimitExceededHandler = config[0].LimitExceededHandler
79 | }
80 | if config[0].KeyFunc != nil {
81 | cfg.KeyFunc = config[0].KeyFunc
82 | }
83 | if config[0].Redis != nil {
84 | cfg.Redis = config[0].Redis
85 | }
86 | }
87 |
88 | if err := validateConfig(&cfg); err != nil {
89 | return cfg, fmt.Errorf("invalid rate limiter config: %w", err)
90 | }
91 |
92 | return cfg, nil
93 | }
94 |
95 | // getRemainingTime calculates the time remaining until the rate limit resets
96 | func getRemainingTime(ctx context.Context, redis *redis.Client, key string) (int64, error) {
97 | ttl, err := redis.TTL(ctx, key).Result()
98 | if err != nil {
99 | return 0, err
100 | }
101 | return int64(math.Ceil(ttl.Seconds())), nil
102 | }
103 |
104 | // NewRateLimiter creates a new rate limiter middleware
105 | func NewRateLimiter(config ...RateLimiterConfig) fiber.Handler {
106 | cfg, err := configDefault(config...)
107 | if err != nil {
108 | panic(err)
109 | }
110 |
111 | if cfg.Redis == nil {
112 | panic("Redis client is required for rate limiter")
113 | }
114 |
115 | // Return the middleware handler
116 | return func(c *fiber.Ctx) error {
117 | // Use request context for proper cancellation
118 | ctx := c.Context()
119 |
120 | // Generate Redis key with proper separator
121 | key := fmt.Sprintf("%s:%s", cfg.KeyPrefix, cfg.KeyFunc(c))
122 |
123 | // Use Redis MULTI/EXEC for atomic operations
124 | pipe := cfg.Redis.TxPipeline()
125 |
126 | // Increment counter and set expiration in single transaction
127 | incr := pipe.Incr(ctx, key)
128 | pipe.Expire(ctx, key, cfg.Window)
129 |
130 | // Execute transaction
131 | _, err := pipe.Exec(ctx)
132 | if err != nil {
133 | return fmt.Errorf("rate limiter redis error: %w", err)
134 | }
135 |
136 | // Get the current count
137 | count := incr.Val()
138 |
139 | // If this is the first request, ensure the key expires
140 | if count == 1 {
141 | cfg.Redis.Expire(ctx, key, cfg.Window)
142 | }
143 |
144 | // Calculate remaining attempts
145 | remaining := int64(cfg.Max) - count
146 | if remaining < 0 {
147 | remaining = 0
148 | }
149 |
150 | // Get remaining time until reset
151 | remainingTime, err := getRemainingTime(ctx, cfg.Redis, key)
152 | if err != nil {
153 | return fmt.Errorf("error getting remaining time: %w", err)
154 | }
155 |
156 | // Set rate limit headers
157 | c.Set("X-RateLimit-Limit", fmt.Sprintf("%d", cfg.Max))
158 | c.Set("X-RateLimit-Remaining", fmt.Sprintf("%d", remaining))
159 | c.Set("X-RateLimit-Reset", fmt.Sprintf("%d", remainingTime))
160 |
161 | // Check if limit is exceeded
162 | if count > int64(cfg.Max) {
163 | c.Set("X-RateLimit-RetryAfter", fmt.Sprintf("%d", remainingTime))
164 | return cfg.LimitExceededHandler(c)
165 | }
166 |
167 | return c.Next()
168 | }
169 | }
170 |
--------------------------------------------------------------------------------
/internal/handlers/coupons/get_coupons.go:
--------------------------------------------------------------------------------
1 | package coupons
2 |
3 | import (
4 | "discountdb-api/internal/models"
5 | "discountdb-api/internal/repositories"
6 | "encoding/json"
7 | "fmt"
8 | "github.com/gofiber/fiber/v2"
9 | "github.com/redis/go-redis/v9"
10 | "log"
11 | "strconv"
12 | "time"
13 | )
14 |
15 | const (
16 | defaultLimit = 10
17 | defaultOffset = 0
18 | cacheExpire = 5 * time.Minute
19 | )
20 |
21 | // ParseSearchParams extracts and validates search parameters from the request
22 | func ParseSearchParams(c *fiber.Ctx) (repositories.SearchParams, error) {
23 | params := repositories.SearchParams{
24 | Limit: defaultLimit,
25 | Offset: defaultOffset,
26 | SearchIn: []string{
27 | "code",
28 | "title",
29 | "description",
30 | "merchant_name",
31 | "merchant_url",
32 | },
33 | }
34 |
35 | // Parse search string
36 | params.SearchString = c.Query("q")
37 |
38 | // Parse sorting
39 | sortBy := c.Query("sort_by", string(repositories.SortByNewest))
40 | params.SortBy = repositories.SortBy(sortBy)
41 | if !isValidSortBy(params.SortBy) {
42 | return params, fmt.Errorf("invalid sort_by parameter: %s", sortBy)
43 | }
44 |
45 | // Parse limit
46 | if limitStr := c.Query("limit"); limitStr != "" {
47 | limit, err := strconv.Atoi(limitStr)
48 | if err != nil {
49 | return params, fmt.Errorf("invalid limit parameter: %s", limitStr)
50 | }
51 | if limit < 1 {
52 | return params, fmt.Errorf("limit must be greater than 0")
53 | }
54 | if limit > 100 {
55 | return params, fmt.Errorf("limit must not exceed 100")
56 | }
57 | params.Limit = limit
58 | }
59 |
60 | // Parse offset
61 | if offsetStr := c.Query("offset"); offsetStr != "" {
62 | offset, err := strconv.Atoi(offsetStr)
63 | if err != nil {
64 | return params, fmt.Errorf("invalid offset parameter: %s", offsetStr)
65 | }
66 | if offset < 0 {
67 | return params, fmt.Errorf("offset must be non-negative")
68 | }
69 | params.Offset = offset
70 | }
71 |
72 | return params, nil
73 | }
74 |
75 | func isValidSortBy(s repositories.SortBy) bool {
76 | switch s {
77 | case repositories.SortByNewest, repositories.SortByOldest, repositories.SortByHighScore, repositories.SortByLowScore:
78 | return true
79 | default:
80 | return false
81 | }
82 | }
83 |
84 | func SearchCoupons(params repositories.SearchParams, c *fiber.Ctx, couponRepo *repositories.CouponRepository, rdb *redis.Client) (*models.CouponsSearchResponse, error) {
85 | // Just use the raw query string as the cache key
86 | key := "coupons:" + string(c.Request().URI().QueryString())
87 |
88 | // Try to get from cache
89 | var response models.CouponsSearchResponse
90 | if rdb != nil {
91 | if cached, err := rdb.Get(c.Context(), key).Result(); err == nil {
92 | if err := json.Unmarshal([]byte(cached), &response); err == nil {
93 | return &response, nil
94 | }
95 | // If unmarshal fails, just log and continue to fetch fresh data
96 | log.Printf("Failed to unmarshal cached data: %v", err)
97 | }
98 | }
99 |
100 | // Search for coupons if not in cache
101 | coupons, err := couponRepo.Search(c.Context(), params)
102 | if err != nil {
103 | log.Printf("Failed to search coupons: %v", err)
104 | return nil, c.Status(fiber.StatusInternalServerError).JSON(models.ErrorResponse{Message: "Failed to search coupons"})
105 | }
106 |
107 | total, err := couponRepo.GetTotalCount(c.Context(), params)
108 | if err != nil {
109 | log.Printf("Failed to get total count: %v", err)
110 | return nil, c.Status(fiber.StatusInternalServerError).JSON(models.ErrorResponse{Message: "Failed to get total count"})
111 | }
112 |
113 | // Prepare response
114 | response = models.CouponsSearchResponse{
115 | Data: coupons,
116 | Total: int(total),
117 | Limit: params.Limit,
118 | Offset: params.Offset,
119 | }
120 |
121 | // Cache the response
122 | if rdb != nil {
123 | if cached, err := json.Marshal(response); err == nil {
124 | if err := rdb.Set(c.Context(), key, cached, cacheExpire).Err(); err != nil {
125 | log.Printf("Failed to cache response: %v", err)
126 | }
127 | } else {
128 | log.Printf("Failed to marshal response for caching: %v", err)
129 | }
130 | }
131 |
132 | return &response, nil
133 | }
134 |
135 | // GetCoupons godoc
136 | // @Summary Get coupons with filtering and pagination
137 | // @Description Retrieve a list of coupons with optional search, sorting, and pagination
138 | // @Tags coupons
139 | // @Accept json
140 | // @Produce json
141 | // @Param q query string false "Search query string"
142 | // @Param sort_by query string false "Sort order (newest, oldest, high_score, low_score)" Enums(newest, oldest, high_score, low_score) default(newest)
143 | // @Param limit query integer false "Number of items per page" minimum(1) default(10)
144 | // @Param offset query integer false "Number of items to skip" minimum(0) default(0)
145 | // @Success 200 {object} models.CouponsSearchResponse
146 | // @Failure 400 {object} models.ErrorResponse
147 | // @Failure 500 {object} models.ErrorResponse
148 | // @Router /coupons/search [get]
149 | func GetCoupons(c *fiber.Ctx, couponRepo *repositories.CouponRepository, rdb *redis.Client) error {
150 | // Get url parameters
151 | params, err := ParseSearchParams(c)
152 | if err != nil {
153 | return c.Status(fiber.StatusBadRequest).JSON(models.ErrorResponse{Message: err.Error()})
154 | }
155 |
156 | // Search for coupons
157 | response, err := SearchCoupons(params, c, couponRepo, rdb)
158 | if err != nil {
159 | return err
160 | }
161 |
162 | return c.JSON(response)
163 | }
164 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
2 | github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
3 | github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
4 | github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
5 | github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
6 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
7 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
8 | github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
9 | github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
10 | github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
11 | github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
12 | github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
13 | github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
14 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
15 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
16 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
17 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
18 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
19 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
20 | github.com/go-openapi/analysis v0.21.4 h1:ZDFLvSNxpDaomuCueM0BlSXxpANBlFYiBvr+GXrvIHc=
21 | github.com/go-openapi/analysis v0.21.4/go.mod h1:4zQ35W4neeZTqh3ol0rv/O8JBbka9QyAgQRPp9y3pfo=
22 | github.com/go-openapi/errors v0.20.2/go.mod h1:cM//ZKUKyO06HSwqAelJ5NsEMMcpa6VpXe8DOa1Mi1M=
23 | github.com/go-openapi/errors v0.20.4 h1:unTcVm6PispJsMECE3zWgvG4xTiKda1LIR5rCRWLG6M=
24 | github.com/go-openapi/errors v0.20.4/go.mod h1:Z3FlZ4I8jEGxjUK+bugx3on2mIAk4txuAOhlsB1FSgk=
25 | github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
26 | github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
27 | github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
28 | github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
29 | github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo=
30 | github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
31 | github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
32 | github.com/go-openapi/loads v0.21.2 h1:r2a/xFIYeZ4Qd2TnGpWDIQNcP80dIaZgf704za8enro=
33 | github.com/go-openapi/loads v0.21.2/go.mod h1:Jq58Os6SSGz0rzh62ptiu8Z31I+OTHqmULx5e/gJbNw=
34 | github.com/go-openapi/runtime v0.26.2 h1:elWyB9MacRzvIVgAZCBJmqTi7hBzU0hlKD4IvfX0Zl0=
35 | github.com/go-openapi/runtime v0.26.2/go.mod h1:O034jyRZ557uJKzngbMDJXkcKJVzXJiymdSfgejrcRw=
36 | github.com/go-openapi/spec v0.20.6/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA=
37 | github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY=
38 | github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
39 | github.com/go-openapi/strfmt v0.21.3/go.mod h1:k+RzNO0Da+k3FrrynSNN8F7n/peCmQQqbbXjtDfvmGg=
40 | github.com/go-openapi/strfmt v0.21.8 h1:VYBUoKYRLAlgKDrIxR/I0lKrztDQ0tuTDrbhLVP8Erg=
41 | github.com/go-openapi/strfmt v0.21.8/go.mod h1:adeGTkxE44sPyLk0JV235VQAO/ZXUr8KAzYjclFs3ew=
42 | github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
43 | github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
44 | github.com/go-openapi/swag v0.21.1/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
45 | github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
46 | github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
47 | github.com/go-openapi/validate v0.22.3 h1:KxG9mu5HBRYbecRb37KRCihvGGtND2aXziBAv0NNfyI=
48 | github.com/go-openapi/validate v0.22.3/go.mod h1:kVxh31KbfsxU8ZyoHaDbLBWU5CnMdqBUEtadQ2G4d5M=
49 | github.com/gofiber/contrib/swagger v1.2.0 h1:+tm7mBLFfUxZASQyf1zkvRkAZRZGmnIT+E0Vvj7BZo4=
50 | github.com/gofiber/contrib/swagger v1.2.0/go.mod h1:NRtN6G1RkdpgwFifq4nID/5cdxv410RDH9rUr9fhiqU=
51 | github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw=
52 | github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
53 | github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
54 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
55 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
56 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
57 | github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
58 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
59 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
60 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
61 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
62 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
63 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
64 | github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
65 | github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
66 | github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
67 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
68 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
69 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
70 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
71 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
72 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
73 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
74 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
75 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
76 | github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
77 | github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
78 | github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
79 | github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
80 | github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
81 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
82 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
83 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
84 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
85 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
86 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
87 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
88 | github.com/mitchellh/mapstructure v1.3.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
89 | github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
90 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
91 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
92 | github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
93 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
94 | github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4=
95 | github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
96 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
97 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
98 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
99 | github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM=
100 | github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
101 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
102 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
103 | github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
104 | github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
105 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
106 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
107 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
108 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
109 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
110 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
111 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
112 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
113 | github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A=
114 | github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg=
115 | github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
116 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
117 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
118 | github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
119 | github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
120 | github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
121 | github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
122 | github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
123 | github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
124 | github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
125 | github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
126 | github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
127 | github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
128 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
129 | go.mongodb.org/mongo-driver v1.10.0/go.mod h1:wsihk0Kdgv8Kqu1Anit4sfK+22vSFbUrAVEYRhCXrA8=
130 | go.mongodb.org/mongo-driver v1.13.1 h1:YIc7HTYsKndGK4RFzJ3covLz1byri52x0IoMB0Pt/vk=
131 | go.mongodb.org/mongo-driver v1.13.1/go.mod h1:wcDf1JBCXy2mOW0bWHwO/IOYqdca1MPCwDtFu/Z9+eo=
132 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
133 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
134 | golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
135 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
136 | golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
137 | golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
138 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
139 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
140 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
141 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
142 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
143 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
144 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
145 | golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
146 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
147 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
148 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
149 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
150 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
151 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
152 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
153 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
154 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
155 | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
156 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
157 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
158 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
159 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
160 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
161 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
162 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
163 | golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
164 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
165 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
166 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
167 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
168 | golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8=
169 | golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw=
170 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
171 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
172 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
173 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
174 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
175 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
176 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
177 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
178 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
179 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
180 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
181 | gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
182 | gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
183 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
184 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
185 |
--------------------------------------------------------------------------------
/internal/repositories/coupon_repo.go:
--------------------------------------------------------------------------------
1 | package repositories
2 |
3 | import (
4 | "context"
5 | "database/sql"
6 | "discountdb-api/internal/models"
7 | "errors"
8 | "fmt"
9 | "github.com/lib/pq"
10 | "log"
11 | "time"
12 | )
13 |
14 | type CouponRepository struct {
15 | db *sql.DB
16 | }
17 |
18 | func NewCouponRepository(db *sql.DB) *CouponRepository {
19 | return &CouponRepository{db: db}
20 | }
21 |
22 | // Migration SQL to create the table
23 | const createTableSQL = `
24 | CREATE TABLE IF NOT EXISTS coupons (
25 | id BIGSERIAL PRIMARY KEY,
26 | created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
27 | code VARCHAR(255) NOT NULL,
28 | title VARCHAR(255) NOT NULL,
29 | description TEXT,
30 | discount_value DECIMAL(10,2) NOT NULL,
31 | discount_type VARCHAR(50) NOT NULL,
32 | merchant_name VARCHAR(255) NOT NULL,
33 | merchant_url TEXT NOT NULL,
34 |
35 | start_date TIMESTAMP,
36 | end_date TIMESTAMP,
37 | terms_conditions TEXT,
38 | minimum_purchase_amount DECIMAL(10,2),
39 | maximum_discount_amount DECIMAL(10,2),
40 |
41 | up_votes TIMESTAMP[] DEFAULT ARRAY[]::TIMESTAMP[],
42 | down_votes TIMESTAMP[] DEFAULT ARRAY[]::TIMESTAMP[],
43 |
44 | categories TEXT[] DEFAULT ARRAY[]::TEXT[],
45 | tags TEXT[] DEFAULT ARRAY[]::TEXT[],
46 | regions TEXT[] DEFAULT ARRAY[]::TEXT[],
47 | store_type VARCHAR(50),
48 |
49 | materialized_score DECIMAL(10,4),
50 | last_score_update TIMESTAMP,
51 |
52 | CONSTRAINT valid_discount_type CHECK (
53 | discount_type IN ('PERCENTAGE_OFF', 'FIXED_AMOUNT', 'BOGO', 'FREE_SHIPPING')
54 | ),
55 | CONSTRAINT valid_store_type CHECK (
56 | store_type IN ('online', 'in_store', 'both')
57 | )
58 | );
59 |
60 | -- Create indexes for common queries
61 | CREATE INDEX IF NOT EXISTS idx_coupons_merchant ON coupons(merchant_name);
62 | CREATE INDEX IF NOT EXISTS idx_coupons_created_at ON coupons(created_at);
63 | CREATE INDEX IF NOT EXISTS idx_coupons_code ON coupons(code);
64 | CREATE INDEX IF NOT EXISTS idx_coupons_score ON coupons(materialized_score);
65 |
66 | CREATE OR REPLACE FUNCTION calculate_coupon_score(
67 | p_discount_value DECIMAL,
68 | p_discount_type VARCHAR,
69 | p_maximum_discount_amount DECIMAL,
70 | p_created_at TIMESTAMP,
71 | p_up_votes TIMESTAMP[],
72 | p_down_votes TIMESTAMP[]
73 | ) RETURNS DECIMAL AS $$
74 | DECLARE
75 | vote_score DECIMAL;
76 | discount_score DECIMAL;
77 | freshness_score DECIMAL;
78 | BEGIN
79 | -- Calculate vote score
80 | SELECT COALESCE(
81 | SUM(
82 | CASE
83 | WHEN age < INTERVAL '1 day' THEN 1.0
84 | WHEN age < INTERVAL '1 week' THEN 0.8
85 | WHEN age < INTERVAL '1 month' THEN 0.6
86 | WHEN age < INTERVAL '6 months' THEN 0.4
87 | ELSE 0.2
88 | END
89 | ), 0) INTO vote_score
90 | FROM (
91 | SELECT CURRENT_TIMESTAMP - unnest(p_up_votes) as age
92 | ) up;
93 |
94 | SELECT vote_score - COALESCE(
95 | SUM(
96 | CASE
97 | WHEN age < INTERVAL '1 day' THEN 1.0
98 | WHEN age < INTERVAL '1 week' THEN 0.8
99 | WHEN age < INTERVAL '1 month' THEN 0.6
100 | WHEN age < INTERVAL '6 months' THEN 0.4
101 | ELSE 0.2
102 | END
103 | ), 0) INTO vote_score
104 | FROM (
105 | SELECT CURRENT_TIMESTAMP - unnest(p_down_votes) as age
106 | ) down;
107 |
108 | -- Calculate discount score
109 | discount_score := CASE
110 | WHEN p_discount_type = 'PERCENTAGE_OFF' THEN
111 | LEAST(p_discount_value / 100.0, 1.0)
112 | WHEN p_discount_type = 'FIXED_AMOUNT' THEN
113 | CASE
114 | WHEN p_maximum_discount_amount > 0 THEN
115 | LEAST(p_discount_value / p_maximum_discount_amount, 1.0)
116 | ELSE
117 | LEAST(p_discount_value / 1000.0, 1.0)
118 | END
119 | WHEN p_discount_type IN ('BOGO', 'FREE_SHIPPING') THEN
120 | 0.5
121 | END;
122 |
123 | -- Calculate freshness score
124 | freshness_score := CASE
125 | WHEN CURRENT_TIMESTAMP - p_created_at < INTERVAL '1 day' THEN 1.0
126 | WHEN CURRENT_TIMESTAMP - p_created_at < INTERVAL '1 week' THEN 0.8
127 | WHEN CURRENT_TIMESTAMP - p_created_at < INTERVAL '1 month' THEN 0.6
128 | WHEN CURRENT_TIMESTAMP - p_created_at < INTERVAL '3 months' THEN 0.4
129 | WHEN CURRENT_TIMESTAMP - p_created_at < INTERVAL '6 months' THEN 0.2
130 | ELSE 0.1
131 | END;
132 |
133 | -- Return weighted score
134 | RETURN (vote_score * 0.4) + (discount_score * 0.4) + (freshness_score * 0.2);
135 | END;
136 | $$ LANGUAGE plpgsql;
137 |
138 | -- Create batch update function
139 | CREATE OR REPLACE FUNCTION update_materialized_scores_batch(batch_size INT)
140 | RETURNS void AS $$
141 | BEGIN
142 | WITH coupons_to_update AS (
143 | SELECT id, discount_value, discount_type, maximum_discount_amount,
144 | created_at, up_votes, down_votes
145 | FROM coupons
146 | WHERE last_score_update IS NULL
147 | OR last_score_update < CURRENT_TIMESTAMP - INTERVAL '1 hour'
148 | ORDER BY last_score_update NULLS FIRST
149 | LIMIT batch_size
150 | FOR UPDATE SKIP LOCKED
151 | )
152 | UPDATE coupons c
153 | SET materialized_score = calculate_coupon_score(
154 | ct.discount_value,
155 | ct.discount_type,
156 | ct.maximum_discount_amount,
157 | ct.created_at,
158 | ct.up_votes,
159 | ct.down_votes
160 | ),
161 | last_score_update = CURRENT_TIMESTAMP
162 | FROM coupons_to_update ct
163 | WHERE c.id = ct.id;
164 | END;
165 | $$ LANGUAGE plpgsql;
166 |
167 | -- Create trigger function
168 | CREATE OR REPLACE FUNCTION update_coupon_score() RETURNS TRIGGER AS $$
169 | BEGIN
170 | NEW.materialized_score := calculate_coupon_score(
171 | NEW.discount_value,
172 | NEW.discount_type,
173 | NEW.maximum_discount_amount,
174 | NEW.created_at,
175 | NEW.up_votes,
176 | NEW.down_votes
177 | );
178 | NEW.last_score_update := CURRENT_TIMESTAMP;
179 | RETURN NEW;
180 | END;
181 | $$ LANGUAGE plpgsql;
182 |
183 | -- Create trigger
184 | DROP TRIGGER IF EXISTS update_score_trigger ON coupons;
185 | CREATE TRIGGER update_score_trigger
186 | BEFORE INSERT OR UPDATE OF discount_value, discount_type, maximum_discount_amount, up_votes, down_votes
187 | ON coupons
188 | FOR EACH ROW
189 | EXECUTE FUNCTION update_coupon_score();
190 | `
191 |
192 | func (r *CouponRepository) CreateTable(ctx context.Context) error {
193 | tx, err := r.db.BeginTx(ctx, nil)
194 | if err != nil {
195 | return fmt.Errorf("failed to begin transaction: %v", err)
196 | }
197 |
198 | // Execute the entire script
199 | _, err = tx.ExecContext(ctx, createTableSQL)
200 | if err != nil {
201 | tx.Rollback()
202 | return fmt.Errorf("failed to create tables and functions: %v", err)
203 | }
204 |
205 | return tx.Commit()
206 | }
207 |
208 | func (r *CouponRepository) Create(ctx context.Context, coupon *models.Coupon) error {
209 | const query = `
210 | INSERT INTO coupons (
211 | code, title, description, discount_value, discount_type,
212 | merchant_name, merchant_url, start_date, end_date,
213 | terms_conditions, minimum_purchase_amount, maximum_discount_amount,
214 | up_votes, down_votes, categories, tags, regions, store_type
215 | ) VALUES (
216 | $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12,
217 | $13, $14, $15, $16, $17, $18
218 | ) RETURNING id, created_at, materialized_score`
219 |
220 | return r.db.QueryRowContext(ctx, query,
221 | coupon.Code, coupon.Title, coupon.Description,
222 | coupon.DiscountValue, coupon.DiscountType,
223 | coupon.MerchantName, coupon.MerchantURL,
224 | coupon.StartDate, coupon.EndDate,
225 | coupon.TermsConditions, coupon.MinimumPurchaseAmount,
226 | coupon.MaximumDiscountAmount, &coupon.UpVotes,
227 | &coupon.DownVotes, pq.Array(coupon.Categories),
228 | pq.Array(coupon.Tags), pq.Array(coupon.Regions),
229 | coupon.StoreType,
230 | ).Scan(&coupon.ID, &coupon.CreatedAt, &coupon.MaterializedScore)
231 | }
232 |
233 | func (r *CouponRepository) GetByID(ctx context.Context, id int64) (*models.Coupon, error) {
234 | const query = `
235 | SELECT
236 | id, created_at, code, title, description,
237 | discount_value, discount_type, merchant_name, merchant_url,
238 | start_date, end_date, terms_conditions,
239 | minimum_purchase_amount, maximum_discount_amount,
240 | up_votes, down_votes, categories, tags,
241 | regions, store_type, materialized_score,
242 | last_score_update
243 | FROM coupons
244 | WHERE id = $1`
245 |
246 | coupon := &models.Coupon{}
247 | err := r.db.QueryRowContext(ctx, query, id).Scan(
248 | &coupon.ID, &coupon.CreatedAt, &coupon.Code,
249 | &coupon.Title, &coupon.Description, &coupon.DiscountValue,
250 | &coupon.DiscountType, &coupon.MerchantName, &coupon.MerchantURL,
251 | &coupon.StartDate, &coupon.EndDate, &coupon.TermsConditions,
252 | &coupon.MinimumPurchaseAmount, &coupon.MaximumDiscountAmount,
253 | &coupon.UpVotes, &coupon.DownVotes,
254 | pq.Array(&coupon.Categories), pq.Array(&coupon.Tags),
255 | pq.Array(&coupon.Regions), &coupon.StoreType,
256 | &coupon.MaterializedScore, &coupon.LastScoreUpdate,
257 | )
258 | if errors.Is(err, sql.ErrNoRows) {
259 | return nil, nil
260 | }
261 | return coupon, err
262 | }
263 |
264 | func (r *CouponRepository) BatchAddVotes(ctx context.Context, votes []models.Vote, voteType string) error {
265 | const upQuery = `
266 | UPDATE coupons AS c
267 | SET up_votes = CASE
268 | WHEN v.id = c.id THEN array_append(c.up_votes, v.timestamp)
269 | ELSE c.up_votes
270 | END
271 | FROM (SELECT unnest($1::bigint[]) AS id, unnest($2::timestamp[]) AS timestamp) AS v
272 | WHERE c.id = v.id`
273 |
274 | const downQuery = `
275 | UPDATE coupons AS c
276 | SET down_votes = CASE
277 | WHEN v.id = c.id THEN array_append(c.down_votes, v.timestamp)
278 | ELSE c.down_votes
279 | END
280 | FROM (SELECT unnest($1::bigint[]) AS id, unnest($2::timestamp[]) AS timestamp) AS v
281 | WHERE c.id = v.id`
282 |
283 | ids := make([]int64, len(votes))
284 | timestamps := make([]time.Time, len(votes))
285 | for i, v := range votes {
286 | ids[i] = v.ID
287 | timestamps[i] = v.Timestamp
288 | }
289 |
290 | query := upQuery
291 | if voteType == "down" {
292 | query = downQuery
293 | }
294 |
295 | result, err := r.db.ExecContext(ctx, query, pq.Array(ids), pq.Array(timestamps))
296 | if err != nil {
297 | return err
298 | }
299 |
300 | rows, err := result.RowsAffected()
301 | if err != nil {
302 | return err
303 | }
304 | if rows == 0 {
305 | return sql.ErrNoRows
306 | }
307 | return nil
308 | }
309 |
310 | // --- Search and Filtering ---
311 |
312 | // SortBy represents the available sorting options for coupons
313 | type SortBy string
314 |
315 | const (
316 | SortByNewest SortBy = "newest"
317 | SortByOldest SortBy = "oldest"
318 | SortByHighScore SortBy = "high_score"
319 | SortByLowScore SortBy = "low_score"
320 | )
321 |
322 | // SearchParams contains all parameters for searching and filtering coupons
323 | type SearchParams struct {
324 | SearchString string
325 | SortBy SortBy
326 | Limit int
327 | Offset int
328 | SearchIn []string
329 | }
330 |
331 | func (r *CouponRepository) Search(ctx context.Context, params SearchParams) ([]models.Coupon, error) {
332 | // Base query
333 | query := `
334 | SELECT
335 | id, created_at, code, title, description,
336 | discount_value, discount_type, merchant_name, merchant_url,
337 | start_date, end_date, terms_conditions,
338 | minimum_purchase_amount, maximum_discount_amount,
339 | up_votes, down_votes, categories, tags,
340 | regions, store_type, materialized_score,
341 | last_score_update
342 | FROM coupons
343 | WHERE 1=1
344 | `
345 |
346 | // Initialize parameters array and counter
347 | queryParams := make([]interface{}, 0)
348 | paramCounter := 1
349 |
350 | // Add search condition if search string is provided
351 | if params.SearchString != "" && len(params.SearchIn) > 0 {
352 | /*
353 | query += fmt.Sprintf(`
354 | AND (
355 | code ILIKE $%d OR
356 | title ILIKE $%d OR
357 | description ILIKE $%d OR
358 | merchant_name ILIKE $%d OR
359 | merchant_url ILIKE $%d
360 | )`, paramCounter, paramCounter, paramCounter, paramCounter, paramCounter)
361 | */
362 | query += `AND (`
363 |
364 | for i, searchIn := range params.SearchIn {
365 | if i > 0 {
366 | query += " OR "
367 | }
368 | query += fmt.Sprintf(`%s ILIKE $%d`, searchIn, paramCounter)
369 | }
370 |
371 | query += `)`
372 |
373 | searchTerm := "%" + params.SearchString + "%"
374 | queryParams = append(queryParams, searchTerm)
375 | paramCounter++
376 | }
377 |
378 | switch params.SortBy {
379 | case SortByNewest:
380 | query += ` ORDER BY created_at DESC`
381 | case SortByOldest:
382 | query += ` ORDER BY created_at ASC`
383 | case SortByHighScore:
384 | query += ` ORDER BY materialized_score DESC`
385 | case SortByLowScore:
386 | query += ` ORDER BY materialized_score ASC`
387 | default:
388 | query += ` ORDER BY created_at DESC`
389 | }
390 |
391 | // Add pagination
392 | query += fmt.Sprintf(` LIMIT $%d OFFSET $%d`, paramCounter, paramCounter+1)
393 | queryParams = append(queryParams, params.Limit, params.Offset)
394 |
395 | // Execute query
396 | rows, err := r.db.QueryContext(ctx, query, queryParams...)
397 | if err != nil {
398 | return nil, err
399 | }
400 | defer func(rows *sql.Rows) {
401 | err := rows.Close()
402 | if err != nil {
403 | log.Fatalf("Failed to close rows: %v", err)
404 | }
405 | }(rows)
406 |
407 | // Parse results
408 | var coupons []models.Coupon
409 | for rows.Next() {
410 | coupon := &models.Coupon{}
411 | err := rows.Scan(
412 | &coupon.ID, &coupon.CreatedAt, &coupon.Code,
413 | &coupon.Title, &coupon.Description, &coupon.DiscountValue,
414 | &coupon.DiscountType, &coupon.MerchantName, &coupon.MerchantURL,
415 | &coupon.StartDate, &coupon.EndDate, &coupon.TermsConditions,
416 | &coupon.MinimumPurchaseAmount, &coupon.MaximumDiscountAmount,
417 | &coupon.UpVotes, &coupon.DownVotes,
418 | pq.Array(&coupon.Categories), pq.Array(&coupon.Tags),
419 | pq.Array(&coupon.Regions), &coupon.StoreType,
420 | &coupon.MaterializedScore, &coupon.LastScoreUpdate,
421 | )
422 | if err != nil {
423 | return nil, err
424 | }
425 | coupons = append(coupons, *coupon)
426 | }
427 |
428 | return coupons, nil
429 | }
430 |
431 | // GetTotalCount returns the total number of coupons matching the search criteria
432 | // This is useful for pagination
433 | func (r *CouponRepository) GetTotalCount(ctx context.Context, params SearchParams) (int64, error) {
434 | query := `SELECT COUNT(*) FROM coupons WHERE 1=1`
435 | queryParams := make([]interface{}, 0)
436 | paramCounter := 1
437 |
438 | if params.SearchString != "" && len(params.SearchIn) > 0 {
439 | query += ` AND (`
440 |
441 | for i, searchIn := range params.SearchIn {
442 | if i > 0 {
443 | query += " OR "
444 | }
445 | query += fmt.Sprintf(`%s ILIKE $%d`, searchIn, paramCounter)
446 | }
447 |
448 | query += `)`
449 |
450 | searchTerm := "%" + params.SearchString + "%"
451 | queryParams = append(queryParams, searchTerm)
452 | paramCounter++
453 | }
454 |
455 | var count int64
456 | err := r.db.QueryRowContext(ctx, query, queryParams...).Scan(&count)
457 | return count, err
458 | }
459 |
460 | // --- Merchants ---
461 |
462 | func (r *CouponRepository) GetMerchants(ctx context.Context) (*models.MerchantResponse, error) {
463 | query := `
464 | SELECT
465 | merchant_name,
466 | ARRAY_AGG(DISTINCT merchant_url) as merchant_url
467 | FROM coupons
468 | GROUP BY merchant_name
469 | ORDER BY merchant_name;
470 | `
471 | rows, err := r.db.QueryContext(ctx, query)
472 | if err != nil {
473 | return nil, err
474 | }
475 | defer func(rows *sql.Rows) {
476 | err := rows.Close()
477 | if err != nil {
478 | log.Fatalf("Failed to close rows: %v", err)
479 | }
480 | }(rows)
481 |
482 | var merchants []models.Merchant
483 | for rows.Next() {
484 | merchant := models.Merchant{}
485 | err := rows.Scan(&merchant.Name, pq.Array(&merchant.Domains))
486 | if err != nil {
487 | return nil, err
488 | }
489 | merchants = append(merchants, merchant)
490 | }
491 |
492 | merchantResponse := &models.MerchantResponse{
493 | Total: len(merchants),
494 | Data: merchants,
495 | }
496 |
497 | return merchantResponse, nil
498 | }
499 |
500 | // --- Categories ---
501 |
502 | func (r *CouponRepository) GetCategories(ctx context.Context) (*models.CategoriesResponse, error) {
503 | query := `SELECT DISTINCT unnest(categories) FROM coupons ORDER BY 1;`
504 | rows, err := r.db.QueryContext(ctx, query)
505 | if err != nil {
506 | return nil, err
507 | }
508 | defer func(rows *sql.Rows) {
509 | err := rows.Close()
510 | if err != nil {
511 | log.Fatalf("Failed to close rows: %v", err)
512 | }
513 | }(rows)
514 |
515 | var categories []string
516 | for rows.Next() {
517 | var category string
518 | err := rows.Scan(&category)
519 | if err != nil {
520 | return nil, err
521 | }
522 | categories = append(categories, category)
523 | }
524 |
525 | categoriesResponse := &models.CategoriesResponse{
526 | Total: len(categories),
527 | Categories: categories,
528 | }
529 |
530 | return categoriesResponse, nil
531 | }
532 |
533 | // --- Tags ---
534 |
535 | func (r *CouponRepository) GetTags(ctx context.Context) (*models.TagResponse, error) {
536 | query := `SELECT DISTINCT unnest(tags) FROM coupons ORDER BY 1;`
537 | rows, err := r.db.QueryContext(ctx, query)
538 | if err != nil {
539 | return nil, err
540 | }
541 | defer func(rows *sql.Rows) {
542 | err := rows.Close()
543 | if err != nil {
544 | log.Fatalf("Failed to close rows: %v", err)
545 | }
546 | }(rows)
547 |
548 | var tags []string
549 | for rows.Next() {
550 | var tag string
551 | err := rows.Scan(&tag)
552 | if err != nil {
553 | return nil, err
554 | }
555 | tags = append(tags, tag)
556 | }
557 |
558 | tagsResponse := &models.TagResponse{
559 | Total: len(tags),
560 | Tags: tags,
561 | }
562 |
563 | return tagsResponse, nil
564 | }
565 |
566 | // --- Regions ---
567 |
568 | func (r *CouponRepository) GetRegions(ctx context.Context) (*models.RegionResponse, error) {
569 | query := `SELECT DISTINCT unnest(regions) FROM coupons ORDER BY 1;`
570 | rows, err := r.db.QueryContext(ctx, query)
571 | if err != nil {
572 | return nil, err
573 | }
574 | defer func(rows *sql.Rows) {
575 | err := rows.Close()
576 | if err != nil {
577 | log.Fatalf("Failed to close rows: %v", err)
578 | }
579 | }(rows)
580 |
581 | var regions []string
582 | for rows.Next() {
583 | var region string
584 | err := rows.Scan(®ion)
585 | if err != nil {
586 | return nil, err
587 | }
588 | regions = append(regions, region)
589 | }
590 |
591 | regionsResponse := &models.RegionResponse{
592 | Total: len(regions),
593 | Regions: regions,
594 | }
595 |
596 | return regionsResponse, nil
597 | }
598 |
--------------------------------------------------------------------------------
/docs/swagger.yaml:
--------------------------------------------------------------------------------
1 | basePath: /api/v1
2 | definitions:
3 | models.CategoriesResponse:
4 | properties:
5 | data:
6 | items:
7 | type: string
8 | type: array
9 | total:
10 | example: 2
11 | type: integer
12 | type: object
13 | models.Coupon:
14 | properties:
15 | categories:
16 | description: Metadata
17 | items:
18 | type: string
19 | type: array
20 | code:
21 | type: string
22 | created_at:
23 | type: string
24 | description:
25 | type: string
26 | discount_type:
27 | $ref: '#/definitions/models.DiscountType'
28 | discount_value:
29 | type: number
30 | down_votes:
31 | items:
32 | type: string
33 | type: array
34 | end_date:
35 | type: string
36 | id:
37 | description: Required Information
38 | type: integer
39 | maximum_discount_amount:
40 | type: number
41 | merchant_name:
42 | type: string
43 | merchant_url:
44 | type: string
45 | minimum_purchase_amount:
46 | type: number
47 | regions:
48 | description: countries/regions where valid
49 | items:
50 | type: string
51 | type: array
52 | score:
53 | description: Score calculated by db
54 | type: number
55 | start_date:
56 | description: Optional Validity Information
57 | type: string
58 | store_type:
59 | description: '"online", "in_store", "both"'
60 | type: string
61 | tags:
62 | items:
63 | type: string
64 | type: array
65 | terms_conditions:
66 | type: string
67 | title:
68 | type: string
69 | up_votes:
70 | description: Voting Information
71 | items:
72 | type: string
73 | type: array
74 | type: object
75 | models.CouponCreateRequest:
76 | properties:
77 | categories:
78 | description: Metadata
79 | items:
80 | type: string
81 | type: array
82 | code:
83 | description: Required Information
84 | type: string
85 | description:
86 | type: string
87 | discount_type:
88 | $ref: '#/definitions/models.DiscountType'
89 | discount_value:
90 | type: number
91 | end_date:
92 | type: string
93 | maximum_discount_amount:
94 | type: number
95 | merchant_name:
96 | type: string
97 | merchant_url:
98 | type: string
99 | minimum_purchase_amount:
100 | type: number
101 | regions:
102 | description: countries/regions where valid
103 | items:
104 | type: string
105 | type: array
106 | start_date:
107 | description: Optional Validity Information
108 | type: string
109 | store_type:
110 | description: '"online", "in_store", "both"'
111 | type: string
112 | tags:
113 | items:
114 | type: string
115 | type: array
116 | terms_conditions:
117 | type: string
118 | title:
119 | type: string
120 | type: object
121 | models.CouponCreateResponse:
122 | properties:
123 | created_at:
124 | type: string
125 | id:
126 | type: integer
127 | score:
128 | type: number
129 | type: object
130 | models.CouponsSearchResponse:
131 | properties:
132 | data:
133 | items:
134 | $ref: '#/definitions/models.Coupon'
135 | type: array
136 | limit:
137 | example: 10
138 | type: integer
139 | offset:
140 | example: 0
141 | type: integer
142 | total:
143 | example: 100
144 | type: integer
145 | type: object
146 | models.DiscountType:
147 | enum:
148 | - PERCENTAGE_OFF
149 | - FIXED_AMOUNT
150 | - BOGO
151 | - FREE_SHIPPING
152 | type: string
153 | x-enum-varnames:
154 | - PercentageOff
155 | - FixedAmount
156 | - BOGO
157 | - FreeShipping
158 | models.ErrorResponse:
159 | properties:
160 | message:
161 | example: Internal server error
162 | type: string
163 | type: object
164 | models.HealthCheckResponse:
165 | properties:
166 | status:
167 | example: ok
168 | type: string
169 | version:
170 | example: "1.0"
171 | type: string
172 | type: object
173 | models.Merchant:
174 | properties:
175 | merchant_name:
176 | example: merchant1
177 | type: string
178 | merchant_url:
179 | items:
180 | type: string
181 | type: array
182 | type: object
183 | models.MerchantResponse:
184 | properties:
185 | data:
186 | items:
187 | $ref: '#/definitions/models.Merchant'
188 | type: array
189 | total:
190 | example: 2
191 | type: integer
192 | type: object
193 | models.RegionResponse:
194 | properties:
195 | regions:
196 | items:
197 | type: string
198 | type: array
199 | total:
200 | type: integer
201 | type: object
202 | models.Success:
203 | properties:
204 | message:
205 | example: Success
206 | type: string
207 | type: object
208 | models.TagResponse:
209 | properties:
210 | tags:
211 | items:
212 | type: string
213 | type: array
214 | total:
215 | type: integer
216 | type: object
217 | syrup.Coupon:
218 | properties:
219 | code:
220 | example: DISCOUNT10
221 | type: string
222 | description:
223 | example: Get 10% off
224 | type: string
225 | id:
226 | example: "123"
227 | type: string
228 | score:
229 | example: 5
230 | type: number
231 | title:
232 | example: Discount
233 | type: string
234 | type: object
235 | syrup.CouponList:
236 | properties:
237 | coupons:
238 | items:
239 | $ref: '#/definitions/syrup.Coupon'
240 | type: array
241 | merchant_name:
242 | example: Amazon
243 | type: string
244 | total:
245 | type: integer
246 | type: object
247 | syrup.ErrorResponse:
248 | properties:
249 | error:
250 | example: Internal Server Error
251 | type: string
252 | message:
253 | example: Something went wrong
254 | type: string
255 | type: object
256 | syrup.Merchant:
257 | properties:
258 | domains:
259 | items:
260 | type: string
261 | type: array
262 | merchant_name:
263 | type: string
264 | type: object
265 | syrup.MerchantList:
266 | properties:
267 | merchants:
268 | items:
269 | $ref: '#/definitions/syrup.Merchant'
270 | type: array
271 | total:
272 | type: integer
273 | type: object
274 | syrup.Success:
275 | properties:
276 | success:
277 | example: "true"
278 | type: string
279 | type: object
280 | syrup.VersionInfo:
281 | properties:
282 | provider:
283 | example: DiscountDB
284 | type: string
285 | version:
286 | example: 1.0.0
287 | type: string
288 | type: object
289 | host: api.discountdb.ch
290 | info:
291 | contact: {}
292 | description: This is the DiscountDB API documentation
293 | termsOfService: http://swagger.io/terms/
294 | title: DiscountDB API
295 | version: "1.0"
296 | paths:
297 | /coupons:
298 | post:
299 | consumes:
300 | - application/json
301 | description: Create a new coupon
302 | parameters:
303 | - description: CouponCreateRequest object
304 | in: body
305 | name: coupon
306 | required: true
307 | schema:
308 | $ref: '#/definitions/models.CouponCreateRequest'
309 | produces:
310 | - application/json
311 | responses:
312 | "200":
313 | description: OK
314 | schema:
315 | $ref: '#/definitions/models.CouponCreateResponse'
316 | "400":
317 | description: Bad Request
318 | schema:
319 | $ref: '#/definitions/models.ErrorResponse'
320 | "500":
321 | description: Internal Server Error
322 | schema:
323 | $ref: '#/definitions/models.ErrorResponse'
324 | summary: Create a new coupon
325 | tags:
326 | - coupons
327 | /coupons/{id}:
328 | get:
329 | consumes:
330 | - application/json
331 | description: Retrieve a single coupon by its ID
332 | parameters:
333 | - description: Coupon ID
334 | in: path
335 | name: id
336 | required: true
337 | type: integer
338 | produces:
339 | - application/json
340 | responses:
341 | "200":
342 | description: OK
343 | schema:
344 | $ref: '#/definitions/models.Coupon'
345 | "404":
346 | description: Not Found
347 | schema:
348 | $ref: '#/definitions/models.ErrorResponse'
349 | "500":
350 | description: Internal Server Error
351 | schema:
352 | $ref: '#/definitions/models.ErrorResponse'
353 | summary: Get coupon by ID
354 | tags:
355 | - coupons
356 | /coupons/categories:
357 | get:
358 | description: Retrieve a list of all categories
359 | produces:
360 | - application/json
361 | responses:
362 | "200":
363 | description: OK
364 | schema:
365 | $ref: '#/definitions/models.CategoriesResponse'
366 | "500":
367 | description: Internal Server Error
368 | schema:
369 | $ref: '#/definitions/models.ErrorResponse'
370 | summary: Get all categories
371 | tags:
372 | - categories
373 | /coupons/merchants:
374 | get:
375 | description: Retrieve a list of all merchants
376 | produces:
377 | - application/json
378 | responses:
379 | "200":
380 | description: OK
381 | schema:
382 | $ref: '#/definitions/models.MerchantResponse'
383 | "500":
384 | description: Internal Server Error
385 | schema:
386 | $ref: '#/definitions/models.ErrorResponse'
387 | summary: Get all merchants
388 | tags:
389 | - merchants
390 | /coupons/regions:
391 | get:
392 | description: Retrieve a list of all regions
393 | produces:
394 | - application/json
395 | responses:
396 | "200":
397 | description: OK
398 | schema:
399 | $ref: '#/definitions/models.RegionResponse'
400 | "500":
401 | description: Internal Server Error
402 | schema:
403 | $ref: '#/definitions/models.ErrorResponse'
404 | summary: Get all regions
405 | tags:
406 | - regions
407 | /coupons/search:
408 | get:
409 | consumes:
410 | - application/json
411 | description: Retrieve a list of coupons with optional search, sorting, and pagination
412 | parameters:
413 | - description: Search query string
414 | in: query
415 | name: q
416 | type: string
417 | - default: newest
418 | description: Sort order (newest, oldest, high_score, low_score)
419 | enum:
420 | - newest
421 | - oldest
422 | - high_score
423 | - low_score
424 | in: query
425 | name: sort_by
426 | type: string
427 | - default: 10
428 | description: Number of items per page
429 | in: query
430 | minimum: 1
431 | name: limit
432 | type: integer
433 | - default: 0
434 | description: Number of items to skip
435 | in: query
436 | minimum: 0
437 | name: offset
438 | type: integer
439 | produces:
440 | - application/json
441 | responses:
442 | "200":
443 | description: OK
444 | schema:
445 | $ref: '#/definitions/models.CouponsSearchResponse'
446 | "400":
447 | description: Bad Request
448 | schema:
449 | $ref: '#/definitions/models.ErrorResponse'
450 | "500":
451 | description: Internal Server Error
452 | schema:
453 | $ref: '#/definitions/models.ErrorResponse'
454 | summary: Get coupons with filtering and pagination
455 | tags:
456 | - coupons
457 | /coupons/tags:
458 | get:
459 | description: Retrieve a list of all tags
460 | produces:
461 | - application/json
462 | responses:
463 | "200":
464 | description: OK
465 | schema:
466 | $ref: '#/definitions/models.TagResponse'
467 | "500":
468 | description: Internal Server Error
469 | schema:
470 | $ref: '#/definitions/models.ErrorResponse'
471 | summary: Get all tags
472 | tags:
473 | - tags
474 | /coupons/vote/{dir}/{id}:
475 | post:
476 | description: Vote on a coupon by ID
477 | parameters:
478 | - description: Vote direction (up or down)
479 | in: path
480 | name: dir
481 | required: true
482 | type: string
483 | - description: Coupon ID
484 | in: path
485 | name: id
486 | required: true
487 | type: string
488 | produces:
489 | - application/json
490 | responses:
491 | "200":
492 | description: OK
493 | schema:
494 | $ref: '#/definitions/models.Success'
495 | "400":
496 | description: Bad Request
497 | schema:
498 | $ref: '#/definitions/models.ErrorResponse'
499 | summary: Vote on a coupon
500 | tags:
501 | - votes
502 | /health:
503 | get:
504 | consumes:
505 | - application/json
506 | description: Get API health status
507 | produces:
508 | - application/json
509 | responses:
510 | "200":
511 | description: OK
512 | schema:
513 | $ref: '#/definitions/models.HealthCheckResponse'
514 | "500":
515 | description: Internal Server Error
516 | schema:
517 | $ref: '#/definitions/models.ErrorResponse'
518 | summary: Health check endpoint
519 | tags:
520 | - health
521 | /syrup/coupons:
522 | get:
523 | description: Returns a paginated list of coupons for a specific domain
524 | parameters:
525 | - description: Optional API key for authentication
526 | in: header
527 | name: X-Syrup-API-Key
528 | type: string
529 | - description: The website domain to fetch coupons for
530 | in: query
531 | name: domain
532 | required: true
533 | type: string
534 | - default: 20
535 | description: Maximum number of coupons to return
536 | in: query
537 | maximum: 100
538 | minimum: 1
539 | name: limit
540 | type: integer
541 | - default: 0
542 | description: Number of coupons to skip
543 | in: query
544 | minimum: 0
545 | name: offset
546 | type: integer
547 | produces:
548 | - application/json
549 | responses:
550 | "200":
551 | description: Successful response
552 | headers:
553 | X-RateLimit-Limit:
554 | description: The maximum number of requests allowed per time window
555 | type: string
556 | X-RateLimit-Remaining:
557 | description: The number of requests remaining in the time window
558 | type: string
559 | X-RateLimit-Reset:
560 | description: The time when the rate limit window resets (Unix timestamp)
561 | type: string
562 | schema:
563 | $ref: '#/definitions/syrup.CouponList'
564 | "400":
565 | description: Bad Request
566 | schema:
567 | $ref: '#/definitions/syrup.ErrorResponse'
568 | "401":
569 | description: Unauthorized
570 | schema:
571 | $ref: '#/definitions/syrup.ErrorResponse'
572 | "429":
573 | description: Too Many Requests
574 | headers:
575 | X-RateLimit-RetryAfter:
576 | description: Time to wait before retrying (seconds)
577 | type: integer
578 | schema:
579 | $ref: '#/definitions/syrup.ErrorResponse'
580 | "500":
581 | description: Internal Server Error
582 | schema:
583 | $ref: '#/definitions/syrup.ErrorResponse'
584 | summary: List Coupons
585 | tags:
586 | - syrup
587 | /syrup/coupons/invalid/{id}:
588 | post:
589 | description: Report that a coupon code failed to work
590 | parameters:
591 | - description: Optional API key for authentication
592 | in: header
593 | name: X-Syrup-API-Key
594 | type: string
595 | - description: The ID of the coupon
596 | in: path
597 | name: id
598 | required: true
599 | type: string
600 | produces:
601 | - application/json
602 | responses:
603 | "200":
604 | description: Successful response
605 | headers:
606 | X-RateLimit-Limit:
607 | description: The maximum number of requests allowed per time window
608 | type: string
609 | X-RateLimit-Remaining:
610 | description: The number of requests remaining in the time window
611 | type: string
612 | X-RateLimit-Reset:
613 | description: The time when the rate limit window resets (Unix timestamp)
614 | type: string
615 | schema:
616 | $ref: '#/definitions/syrup.Success'
617 | "400":
618 | description: Bad Request
619 | schema:
620 | $ref: '#/definitions/syrup.ErrorResponse'
621 | "401":
622 | description: Unauthorized
623 | schema:
624 | $ref: '#/definitions/syrup.ErrorResponse'
625 | "429":
626 | description: Too Many Requests
627 | headers:
628 | X-RateLimit-RetryAfter:
629 | description: Time to wait before retrying (seconds)
630 | type: integer
631 | schema:
632 | $ref: '#/definitions/syrup.ErrorResponse'
633 | "500":
634 | description: Internal Server Error
635 | schema:
636 | $ref: '#/definitions/syrup.ErrorResponse'
637 | summary: Report Invalid Coupon
638 | tags:
639 | - syrup
640 | /syrup/coupons/valid/{id}:
641 | post:
642 | description: Report that a coupon code was successfully used
643 | parameters:
644 | - description: Optional API key for authentication
645 | in: header
646 | name: X-Syrup-API-Key
647 | type: string
648 | - description: The ID of the coupon
649 | in: path
650 | name: id
651 | required: true
652 | type: string
653 | produces:
654 | - application/json
655 | responses:
656 | "200":
657 | description: Successful response
658 | headers:
659 | X-RateLimit-Limit:
660 | description: The maximum number of requests allowed per time window
661 | type: string
662 | X-RateLimit-Remaining:
663 | description: The number of requests remaining in the time window
664 | type: string
665 | X-RateLimit-Reset:
666 | description: The time when the rate limit window resets (Unix timestamp)
667 | type: string
668 | schema:
669 | $ref: '#/definitions/syrup.Success'
670 | "400":
671 | description: Bad Request
672 | schema:
673 | $ref: '#/definitions/syrup.ErrorResponse'
674 | "401":
675 | description: Unauthorized
676 | schema:
677 | $ref: '#/definitions/syrup.ErrorResponse'
678 | "429":
679 | description: Too Many Requests
680 | headers:
681 | X-RateLimit-RetryAfter:
682 | description: Time to wait before retrying (seconds)
683 | type: integer
684 | schema:
685 | $ref: '#/definitions/syrup.ErrorResponse'
686 | "500":
687 | description: Internal Server Error
688 | schema:
689 | $ref: '#/definitions/syrup.ErrorResponse'
690 | summary: Report Valid Coupon
691 | tags:
692 | - syrup
693 | /syrup/merchants:
694 | get:
695 | description: Returns a list of all merchants and their domains
696 | parameters:
697 | - description: Optional API key for authentication
698 | in: header
699 | name: X-Syrup-API-Key
700 | type: string
701 | produces:
702 | - application/json
703 | responses:
704 | "200":
705 | description: Successful response
706 | headers:
707 | X-RateLimit-Limit:
708 | description: The maximum number of requests allowed per time window
709 | type: string
710 | X-RateLimit-Remaining:
711 | description: The number of requests remaining in the time window
712 | type: string
713 | X-RateLimit-Reset:
714 | description: The time when the rate limit window resets (Unix timestamp)
715 | type: string
716 | schema:
717 | $ref: '#/definitions/syrup.MerchantList'
718 | "400":
719 | description: Bad Request
720 | schema:
721 | $ref: '#/definitions/syrup.ErrorResponse'
722 | "401":
723 | description: Unauthorized
724 | schema:
725 | $ref: '#/definitions/syrup.ErrorResponse'
726 | "429":
727 | description: Too Many Requests
728 | headers:
729 | X-RateLimit-RetryAfter:
730 | description: Time to wait before retrying (seconds)
731 | type: integer
732 | schema:
733 | $ref: '#/definitions/syrup.ErrorResponse'
734 | "500":
735 | description: Internal Server Error
736 | schema:
737 | $ref: '#/definitions/syrup.ErrorResponse'
738 | summary: List all Merchants
739 | tags:
740 | - syrup
741 | /syrup/version:
742 | get:
743 | description: Returns information about the API implementation
744 | parameters:
745 | - description: Optional API key for authentication
746 | in: header
747 | name: X-Syrup-API-Key
748 | type: string
749 | produces:
750 | - application/json
751 | responses:
752 | "200":
753 | description: Successful response
754 | headers:
755 | X-RateLimit-Limit:
756 | description: The maximum number of requests allowed per time window
757 | type: string
758 | X-RateLimit-Remaining:
759 | description: The number of requests remaining in the time window
760 | type: string
761 | X-RateLimit-Reset:
762 | description: The time when the rate limit window resets (Unix timestamp)
763 | type: string
764 | schema:
765 | $ref: '#/definitions/syrup.VersionInfo'
766 | "400":
767 | description: Bad Request
768 | schema:
769 | $ref: '#/definitions/syrup.ErrorResponse'
770 | "401":
771 | description: Unauthorized
772 | schema:
773 | $ref: '#/definitions/syrup.ErrorResponse'
774 | "429":
775 | description: Too Many Requests
776 | headers:
777 | X-RateLimit-RetryAfter:
778 | description: Time to wait before retrying (seconds)
779 | type: integer
780 | schema:
781 | $ref: '#/definitions/syrup.ErrorResponse'
782 | "500":
783 | description: Internal Server Error
784 | schema:
785 | $ref: '#/definitions/syrup.ErrorResponse'
786 | summary: Get API Version
787 | tags:
788 | - syrup
789 | swagger: "2.0"
790 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | GNU GENERAL PUBLIC LICENSE
2 | Version 3, 29 June 2007
3 |
4 | Copyright (C) 2007 Free Software Foundation, Inc.
5 | Everyone is permitted to copy and distribute verbatim copies
6 | of this license document, but changing it is not allowed.
7 |
8 | Preamble
9 |
10 | The GNU General Public License is a free, copyleft license for
11 | software and other kinds of works.
12 |
13 | The licenses for most software and other practical works are designed
14 | to take away your freedom to share and change the works. By contrast,
15 | the GNU General Public License is intended to guarantee your freedom to
16 | share and change all versions of a program--to make sure it remains free
17 | software for all its users. We, the Free Software Foundation, use the
18 | GNU General Public License for most of our software; it applies also to
19 | any other work released this way by its authors. You can apply it to
20 | your programs, too.
21 |
22 | When we speak of free software, we are referring to freedom, not
23 | price. Our General Public Licenses are designed to make sure that you
24 | have the freedom to distribute copies of free software (and charge for
25 | them if you wish), that you receive source code or can get it if you
26 | want it, that you can change the software or use pieces of it in new
27 | free programs, and that you know you can do these things.
28 |
29 | To protect your rights, we need to prevent others from denying you
30 | these rights or asking you to surrender the rights. Therefore, you have
31 | certain responsibilities if you distribute copies of the software, or if
32 | you modify it: responsibilities to respect the freedom of others.
33 |
34 | For example, if you distribute copies of such a program, whether
35 | gratis or for a fee, you must pass on to the recipients the same
36 | freedoms that you received. You must make sure that they, too, receive
37 | or can get the source code. And you must show them these terms so they
38 | know their rights.
39 |
40 | Developers that use the GNU GPL protect your rights with two steps:
41 | (1) assert copyright on the software, and (2) offer you this License
42 | giving you legal permission to copy, distribute and/or modify it.
43 |
44 | For the developers' and authors' protection, the GPL clearly explains
45 | that there is no warranty for this free software. For both users' and
46 | authors' sake, the GPL requires that modified versions be marked as
47 | changed, so that their problems will not be attributed erroneously to
48 | authors of previous versions.
49 |
50 | Some devices are designed to deny users access to install or run
51 | modified versions of the software inside them, although the manufacturer
52 | can do so. This is fundamentally incompatible with the aim of
53 | protecting users' freedom to change the software. The systematic
54 | pattern of such abuse occurs in the area of products for individuals to
55 | use, which is precisely where it is most unacceptable. Therefore, we
56 | have designed this version of the GPL to prohibit the practice for those
57 | products. If such problems arise substantially in other domains, we
58 | stand ready to extend this provision to those domains in future versions
59 | of the GPL, as needed to protect the freedom of users.
60 |
61 | Finally, every program is threatened constantly by software patents.
62 | States should not allow patents to restrict development and use of
63 | software on general-purpose computers, but in those that do, we wish to
64 | avoid the special danger that patents applied to a free program could
65 | make it effectively proprietary. To prevent this, the GPL assures that
66 | patents cannot be used to render the program non-free.
67 |
68 | The precise terms and conditions for copying, distribution and
69 | modification follow.
70 |
71 | TERMS AND CONDITIONS
72 |
73 | 0. Definitions.
74 |
75 | "This License" refers to version 3 of the GNU General Public License.
76 |
77 | "Copyright" also means copyright-like laws that apply to other kinds of
78 | works, such as semiconductor masks.
79 |
80 | "The Program" refers to any copyrightable work licensed under this
81 | License. Each licensee is addressed as "you". "Licensees" and
82 | "recipients" may be individuals or organizations.
83 |
84 | To "modify" a work means to copy from or adapt all or part of the work
85 | in a fashion requiring copyright permission, other than the making of an
86 | exact copy. The resulting work is called a "modified version" of the
87 | earlier work or a work "based on" the earlier work.
88 |
89 | A "covered work" means either the unmodified Program or a work based
90 | on the Program.
91 |
92 | To "propagate" a work means to do anything with it that, without
93 | permission, would make you directly or secondarily liable for
94 | infringement under applicable copyright law, except executing it on a
95 | computer or modifying a private copy. Propagation includes copying,
96 | distribution (with or without modification), making available to the
97 | public, and in some countries other activities as well.
98 |
99 | To "convey" a work means any kind of propagation that enables other
100 | parties to make or receive copies. Mere interaction with a user through
101 | a computer network, with no transfer of a copy, is not conveying.
102 |
103 | An interactive user interface displays "Appropriate Legal Notices"
104 | to the extent that it includes a convenient and prominently visible
105 | feature that (1) displays an appropriate copyright notice, and (2)
106 | tells the user that there is no warranty for the work (except to the
107 | extent that warranties are provided), that licensees may convey the
108 | work under this License, and how to view a copy of this License. If
109 | the interface presents a list of user commands or options, such as a
110 | menu, a prominent item in the list meets this criterion.
111 |
112 | 1. Source Code.
113 |
114 | The "source code" for a work means the preferred form of the work
115 | for making modifications to it. "Object code" means any non-source
116 | form of a work.
117 |
118 | A "Standard Interface" means an interface that either is an official
119 | standard defined by a recognized standards body, or, in the case of
120 | interfaces specified for a particular programming language, one that
121 | is widely used among developers working in that language.
122 |
123 | The "System Libraries" of an executable work include anything, other
124 | than the work as a whole, that (a) is included in the normal form of
125 | packaging a Major Component, but which is not part of that Major
126 | Component, and (b) serves only to enable use of the work with that
127 | Major Component, or to implement a Standard Interface for which an
128 | implementation is available to the public in source code form. A
129 | "Major Component", in this context, means a major essential component
130 | (kernel, window system, and so on) of the specific operating system
131 | (if any) on which the executable work runs, or a compiler used to
132 | produce the work, or an object code interpreter used to run it.
133 |
134 | The "Corresponding Source" for a work in object code form means all
135 | the source code needed to generate, install, and (for an executable
136 | work) run the object code and to modify the work, including scripts to
137 | control those activities. However, it does not include the work's
138 | System Libraries, or general-purpose tools or generally available free
139 | programs which are used unmodified in performing those activities but
140 | which are not part of the work. For example, Corresponding Source
141 | includes interface definition files associated with source files for
142 | the work, and the source code for shared libraries and dynamically
143 | linked subprograms that the work is specifically designed to require,
144 | such as by intimate data communication or control flow between those
145 | subprograms and other parts of the work.
146 |
147 | The Corresponding Source need not include anything that users
148 | can regenerate automatically from other parts of the Corresponding
149 | Source.
150 |
151 | The Corresponding Source for a work in source code form is that
152 | same work.
153 |
154 | 2. Basic Permissions.
155 |
156 | All rights granted under this License are granted for the term of
157 | copyright on the Program, and are irrevocable provided the stated
158 | conditions are met. This License explicitly affirms your unlimited
159 | permission to run the unmodified Program. The output from running a
160 | covered work is covered by this License only if the output, given its
161 | content, constitutes a covered work. This License acknowledges your
162 | rights of fair use or other equivalent, as provided by copyright law.
163 |
164 | You may make, run and propagate covered works that you do not
165 | convey, without conditions so long as your license otherwise remains
166 | in force. You may convey covered works to others for the sole purpose
167 | of having them make modifications exclusively for you, or provide you
168 | with facilities for running those works, provided that you comply with
169 | the terms of this License in conveying all material for which you do
170 | not control copyright. Those thus making or running the covered works
171 | for you must do so exclusively on your behalf, under your direction
172 | and control, on terms that prohibit them from making any copies of
173 | your copyrighted material outside their relationship with you.
174 |
175 | Conveying under any other circumstances is permitted solely under
176 | the conditions stated below. Sublicensing is not allowed; section 10
177 | makes it unnecessary.
178 |
179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
180 |
181 | No covered work shall be deemed part of an effective technological
182 | measure under any applicable law fulfilling obligations under article
183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or
184 | similar laws prohibiting or restricting circumvention of such
185 | measures.
186 |
187 | When you convey a covered work, you waive any legal power to forbid
188 | circumvention of technological measures to the extent such circumvention
189 | is effected by exercising rights under this License with respect to
190 | the covered work, and you disclaim any intention to limit operation or
191 | modification of the work as a means of enforcing, against the work's
192 | users, your or third parties' legal rights to forbid circumvention of
193 | technological measures.
194 |
195 | 4. Conveying Verbatim Copies.
196 |
197 | You may convey verbatim copies of the Program's source code as you
198 | receive it, in any medium, provided that you conspicuously and
199 | appropriately publish on each copy an appropriate copyright notice;
200 | keep intact all notices stating that this License and any
201 | non-permissive terms added in accord with section 7 apply to the code;
202 | keep intact all notices of the absence of any warranty; and give all
203 | recipients a copy of this License along with the Program.
204 |
205 | You may charge any price or no price for each copy that you convey,
206 | and you may offer support or warranty protection for a fee.
207 |
208 | 5. Conveying Modified Source Versions.
209 |
210 | You may convey a work based on the Program, or the modifications to
211 | produce it from the Program, in the form of source code under the
212 | terms of section 4, provided that you also meet all of these conditions:
213 |
214 | a) The work must carry prominent notices stating that you modified
215 | it, and giving a relevant date.
216 |
217 | b) The work must carry prominent notices stating that it is
218 | released under this License and any conditions added under section
219 | 7. This requirement modifies the requirement in section 4 to
220 | "keep intact all notices".
221 |
222 | c) You must license the entire work, as a whole, under this
223 | License to anyone who comes into possession of a copy. This
224 | License will therefore apply, along with any applicable section 7
225 | additional terms, to the whole of the work, and all its parts,
226 | regardless of how they are packaged. This License gives no
227 | permission to license the work in any other way, but it does not
228 | invalidate such permission if you have separately received it.
229 |
230 | d) If the work has interactive user interfaces, each must display
231 | Appropriate Legal Notices; however, if the Program has interactive
232 | interfaces that do not display Appropriate Legal Notices, your
233 | work need not make them do so.
234 |
235 | A compilation of a covered work with other separate and independent
236 | works, which are not by their nature extensions of the covered work,
237 | and which are not combined with it such as to form a larger program,
238 | in or on a volume of a storage or distribution medium, is called an
239 | "aggregate" if the compilation and its resulting copyright are not
240 | used to limit the access or legal rights of the compilation's users
241 | beyond what the individual works permit. Inclusion of a covered work
242 | in an aggregate does not cause this License to apply to the other
243 | parts of the aggregate.
244 |
245 | 6. Conveying Non-Source Forms.
246 |
247 | You may convey a covered work in object code form under the terms
248 | of sections 4 and 5, provided that you also convey the
249 | machine-readable Corresponding Source under the terms of this License,
250 | in one of these ways:
251 |
252 | a) Convey the object code in, or embodied in, a physical product
253 | (including a physical distribution medium), accompanied by the
254 | Corresponding Source fixed on a durable physical medium
255 | customarily used for software interchange.
256 |
257 | b) Convey the object code in, or embodied in, a physical product
258 | (including a physical distribution medium), accompanied by a
259 | written offer, valid for at least three years and valid for as
260 | long as you offer spare parts or customer support for that product
261 | model, to give anyone who possesses the object code either (1) a
262 | copy of the Corresponding Source for all the software in the
263 | product that is covered by this License, on a durable physical
264 | medium customarily used for software interchange, for a price no
265 | more than your reasonable cost of physically performing this
266 | conveying of source, or (2) access to copy the
267 | Corresponding Source from a network server at no charge.
268 |
269 | c) Convey individual copies of the object code with a copy of the
270 | written offer to provide the Corresponding Source. This
271 | alternative is allowed only occasionally and noncommercially, and
272 | only if you received the object code with such an offer, in accord
273 | with subsection 6b.
274 |
275 | d) Convey the object code by offering access from a designated
276 | place (gratis or for a charge), and offer equivalent access to the
277 | Corresponding Source in the same way through the same place at no
278 | further charge. You need not require recipients to copy the
279 | Corresponding Source along with the object code. If the place to
280 | copy the object code is a network server, the Corresponding Source
281 | may be on a different server (operated by you or a third party)
282 | that supports equivalent copying facilities, provided you maintain
283 | clear directions next to the object code saying where to find the
284 | Corresponding Source. Regardless of what server hosts the
285 | Corresponding Source, you remain obligated to ensure that it is
286 | available for as long as needed to satisfy these requirements.
287 |
288 | e) Convey the object code using peer-to-peer transmission, provided
289 | you inform other peers where the object code and Corresponding
290 | Source of the work are being offered to the general public at no
291 | charge under subsection 6d.
292 |
293 | A separable portion of the object code, whose source code is excluded
294 | from the Corresponding Source as a System Library, need not be
295 | included in conveying the object code work.
296 |
297 | A "User Product" is either (1) a "consumer product", which means any
298 | tangible personal property which is normally used for personal, family,
299 | or household purposes, or (2) anything designed or sold for incorporation
300 | into a dwelling. In determining whether a product is a consumer product,
301 | doubtful cases shall be resolved in favor of coverage. For a particular
302 | product received by a particular user, "normally used" refers to a
303 | typical or common use of that class of product, regardless of the status
304 | of the particular user or of the way in which the particular user
305 | actually uses, or expects or is expected to use, the product. A product
306 | is a consumer product regardless of whether the product has substantial
307 | commercial, industrial or non-consumer uses, unless such uses represent
308 | the only significant mode of use of the product.
309 |
310 | "Installation Information" for a User Product means any methods,
311 | procedures, authorization keys, or other information required to install
312 | and execute modified versions of a covered work in that User Product from
313 | a modified version of its Corresponding Source. The information must
314 | suffice to ensure that the continued functioning of the modified object
315 | code is in no case prevented or interfered with solely because
316 | modification has been made.
317 |
318 | If you convey an object code work under this section in, or with, or
319 | specifically for use in, a User Product, and the conveying occurs as
320 | part of a transaction in which the right of possession and use of the
321 | User Product is transferred to the recipient in perpetuity or for a
322 | fixed term (regardless of how the transaction is characterized), the
323 | Corresponding Source conveyed under this section must be accompanied
324 | by the Installation Information. But this requirement does not apply
325 | if neither you nor any third party retains the ability to install
326 | modified object code on the User Product (for example, the work has
327 | been installed in ROM).
328 |
329 | The requirement to provide Installation Information does not include a
330 | requirement to continue to provide support service, warranty, or updates
331 | for a work that has been modified or installed by the recipient, or for
332 | the User Product in which it has been modified or installed. Access to a
333 | network may be denied when the modification itself materially and
334 | adversely affects the operation of the network or violates the rules and
335 | protocols for communication across the network.
336 |
337 | Corresponding Source conveyed, and Installation Information provided,
338 | in accord with this section must be in a format that is publicly
339 | documented (and with an implementation available to the public in
340 | source code form), and must require no special password or key for
341 | unpacking, reading or copying.
342 |
343 | 7. Additional Terms.
344 |
345 | "Additional permissions" are terms that supplement the terms of this
346 | License by making exceptions from one or more of its conditions.
347 | Additional permissions that are applicable to the entire Program shall
348 | be treated as though they were included in this License, to the extent
349 | that they are valid under applicable law. If additional permissions
350 | apply only to part of the Program, that part may be used separately
351 | under those permissions, but the entire Program remains governed by
352 | this License without regard to the additional permissions.
353 |
354 | When you convey a copy of a covered work, you may at your option
355 | remove any additional permissions from that copy, or from any part of
356 | it. (Additional permissions may be written to require their own
357 | removal in certain cases when you modify the work.) You may place
358 | additional permissions on material, added by you to a covered work,
359 | for which you have or can give appropriate copyright permission.
360 |
361 | Notwithstanding any other provision of this License, for material you
362 | add to a covered work, you may (if authorized by the copyright holders of
363 | that material) supplement the terms of this License with terms:
364 |
365 | a) Disclaiming warranty or limiting liability differently from the
366 | terms of sections 15 and 16 of this License; or
367 |
368 | b) Requiring preservation of specified reasonable legal notices or
369 | author attributions in that material or in the Appropriate Legal
370 | Notices displayed by works containing it; or
371 |
372 | c) Prohibiting misrepresentation of the origin of that material, or
373 | requiring that modified versions of such material be marked in
374 | reasonable ways as different from the original version; or
375 |
376 | d) Limiting the use for publicity purposes of names of licensors or
377 | authors of the material; or
378 |
379 | e) Declining to grant rights under trademark law for use of some
380 | trade names, trademarks, or service marks; or
381 |
382 | f) Requiring indemnification of licensors and authors of that
383 | material by anyone who conveys the material (or modified versions of
384 | it) with contractual assumptions of liability to the recipient, for
385 | any liability that these contractual assumptions directly impose on
386 | those licensors and authors.
387 |
388 | All other non-permissive additional terms are considered "further
389 | restrictions" within the meaning of section 10. If the Program as you
390 | received it, or any part of it, contains a notice stating that it is
391 | governed by this License along with a term that is a further
392 | restriction, you may remove that term. If a license document contains
393 | a further restriction but permits relicensing or conveying under this
394 | License, you may add to a covered work material governed by the terms
395 | of that license document, provided that the further restriction does
396 | not survive such relicensing or conveying.
397 |
398 | If you add terms to a covered work in accord with this section, you
399 | must place, in the relevant source files, a statement of the
400 | additional terms that apply to those files, or a notice indicating
401 | where to find the applicable terms.
402 |
403 | Additional terms, permissive or non-permissive, may be stated in the
404 | form of a separately written license, or stated as exceptions;
405 | the above requirements apply either way.
406 |
407 | 8. Termination.
408 |
409 | You may not propagate or modify a covered work except as expressly
410 | provided under this License. Any attempt otherwise to propagate or
411 | modify it is void, and will automatically terminate your rights under
412 | this License (including any patent licenses granted under the third
413 | paragraph of section 11).
414 |
415 | However, if you cease all violation of this License, then your
416 | license from a particular copyright holder is reinstated (a)
417 | provisionally, unless and until the copyright holder explicitly and
418 | finally terminates your license, and (b) permanently, if the copyright
419 | holder fails to notify you of the violation by some reasonable means
420 | prior to 60 days after the cessation.
421 |
422 | Moreover, your license from a particular copyright holder is
423 | reinstated permanently if the copyright holder notifies you of the
424 | violation by some reasonable means, this is the first time you have
425 | received notice of violation of this License (for any work) from that
426 | copyright holder, and you cure the violation prior to 30 days after
427 | your receipt of the notice.
428 |
429 | Termination of your rights under this section does not terminate the
430 | licenses of parties who have received copies or rights from you under
431 | this License. If your rights have been terminated and not permanently
432 | reinstated, you do not qualify to receive new licenses for the same
433 | material under section 10.
434 |
435 | 9. Acceptance Not Required for Having Copies.
436 |
437 | You are not required to accept this License in order to receive or
438 | run a copy of the Program. Ancillary propagation of a covered work
439 | occurring solely as a consequence of using peer-to-peer transmission
440 | to receive a copy likewise does not require acceptance. However,
441 | nothing other than this License grants you permission to propagate or
442 | modify any covered work. These actions infringe copyright if you do
443 | not accept this License. Therefore, by modifying or propagating a
444 | covered work, you indicate your acceptance of this License to do so.
445 |
446 | 10. Automatic Licensing of Downstream Recipients.
447 |
448 | Each time you convey a covered work, the recipient automatically
449 | receives a license from the original licensors, to run, modify and
450 | propagate that work, subject to this License. You are not responsible
451 | for enforcing compliance by third parties with this License.
452 |
453 | An "entity transaction" is a transaction transferring control of an
454 | organization, or substantially all assets of one, or subdividing an
455 | organization, or merging organizations. If propagation of a covered
456 | work results from an entity transaction, each party to that
457 | transaction who receives a copy of the work also receives whatever
458 | licenses to the work the party's predecessor in interest had or could
459 | give under the previous paragraph, plus a right to possession of the
460 | Corresponding Source of the work from the predecessor in interest, if
461 | the predecessor has it or can get it with reasonable efforts.
462 |
463 | You may not impose any further restrictions on the exercise of the
464 | rights granted or affirmed under this License. For example, you may
465 | not impose a license fee, royalty, or other charge for exercise of
466 | rights granted under this License, and you may not initiate litigation
467 | (including a cross-claim or counterclaim in a lawsuit) alleging that
468 | any patent claim is infringed by making, using, selling, offering for
469 | sale, or importing the Program or any portion of it.
470 |
471 | 11. Patents.
472 |
473 | A "contributor" is a copyright holder who authorizes use under this
474 | License of the Program or a work on which the Program is based. The
475 | work thus licensed is called the contributor's "contributor version".
476 |
477 | A contributor's "essential patent claims" are all patent claims
478 | owned or controlled by the contributor, whether already acquired or
479 | hereafter acquired, that would be infringed by some manner, permitted
480 | by this License, of making, using, or selling its contributor version,
481 | but do not include claims that would be infringed only as a
482 | consequence of further modification of the contributor version. For
483 | purposes of this definition, "control" includes the right to grant
484 | patent sublicenses in a manner consistent with the requirements of
485 | this License.
486 |
487 | Each contributor grants you a non-exclusive, worldwide, royalty-free
488 | patent license under the contributor's essential patent claims, to
489 | make, use, sell, offer for sale, import and otherwise run, modify and
490 | propagate the contents of its contributor version.
491 |
492 | In the following three paragraphs, a "patent license" is any express
493 | agreement or commitment, however denominated, not to enforce a patent
494 | (such as an express permission to practice a patent or covenant not to
495 | sue for patent infringement). To "grant" such a patent license to a
496 | party means to make such an agreement or commitment not to enforce a
497 | patent against the party.
498 |
499 | If you convey a covered work, knowingly relying on a patent license,
500 | and the Corresponding Source of the work is not available for anyone
501 | to copy, free of charge and under the terms of this License, through a
502 | publicly available network server or other readily accessible means,
503 | then you must either (1) cause the Corresponding Source to be so
504 | available, or (2) arrange to deprive yourself of the benefit of the
505 | patent license for this particular work, or (3) arrange, in a manner
506 | consistent with the requirements of this License, to extend the patent
507 | license to downstream recipients. "Knowingly relying" means you have
508 | actual knowledge that, but for the patent license, your conveying the
509 | covered work in a country, or your recipient's use of the covered work
510 | in a country, would infringe one or more identifiable patents in that
511 | country that you have reason to believe are valid.
512 |
513 | If, pursuant to or in connection with a single transaction or
514 | arrangement, you convey, or propagate by procuring conveyance of, a
515 | covered work, and grant a patent license to some of the parties
516 | receiving the covered work authorizing them to use, propagate, modify
517 | or convey a specific copy of the covered work, then the patent license
518 | you grant is automatically extended to all recipients of the covered
519 | work and works based on it.
520 |
521 | A patent license is "discriminatory" if it does not include within
522 | the scope of its coverage, prohibits the exercise of, or is
523 | conditioned on the non-exercise of one or more of the rights that are
524 | specifically granted under this License. You may not convey a covered
525 | work if you are a party to an arrangement with a third party that is
526 | in the business of distributing software, under which you make payment
527 | to the third party based on the extent of your activity of conveying
528 | the work, and under which the third party grants, to any of the
529 | parties who would receive the covered work from you, a discriminatory
530 | patent license (a) in connection with copies of the covered work
531 | conveyed by you (or copies made from those copies), or (b) primarily
532 | for and in connection with specific products or compilations that
533 | contain the covered work, unless you entered into that arrangement,
534 | or that patent license was granted, prior to 28 March 2007.
535 |
536 | Nothing in this License shall be construed as excluding or limiting
537 | any implied license or other defenses to infringement that may
538 | otherwise be available to you under applicable patent law.
539 |
540 | 12. No Surrender of Others' Freedom.
541 |
542 | If conditions are imposed on you (whether by court order, agreement or
543 | otherwise) that contradict the conditions of this License, they do not
544 | excuse you from the conditions of this License. If you cannot convey a
545 | covered work so as to satisfy simultaneously your obligations under this
546 | License and any other pertinent obligations, then as a consequence you may
547 | not convey it at all. For example, if you agree to terms that obligate you
548 | to collect a royalty for further conveying from those to whom you convey
549 | the Program, the only way you could satisfy both those terms and this
550 | License would be to refrain entirely from conveying the Program.
551 |
552 | 13. Use with the GNU Affero General Public License.
553 |
554 | Notwithstanding any other provision of this License, you have
555 | permission to link or combine any covered work with a work licensed
556 | under version 3 of the GNU Affero General Public License into a single
557 | combined work, and to convey the resulting work. The terms of this
558 | License will continue to apply to the part which is the covered work,
559 | but the special requirements of the GNU Affero General Public License,
560 | section 13, concerning interaction through a network will apply to the
561 | combination as such.
562 |
563 | 14. Revised Versions of this License.
564 |
565 | The Free Software Foundation may publish revised and/or new versions of
566 | the GNU General Public License from time to time. Such new versions will
567 | be similar in spirit to the present version, but may differ in detail to
568 | address new problems or concerns.
569 |
570 | Each version is given a distinguishing version number. If the
571 | Program specifies that a certain numbered version of the GNU General
572 | Public License "or any later version" applies to it, you have the
573 | option of following the terms and conditions either of that numbered
574 | version or of any later version published by the Free Software
575 | Foundation. If the Program does not specify a version number of the
576 | GNU General Public License, you may choose any version ever published
577 | by the Free Software Foundation.
578 |
579 | If the Program specifies that a proxy can decide which future
580 | versions of the GNU General Public License can be used, that proxy's
581 | public statement of acceptance of a version permanently authorizes you
582 | to choose that version for the Program.
583 |
584 | Later license versions may give you additional or different
585 | permissions. However, no additional obligations are imposed on any
586 | author or copyright holder as a result of your choosing to follow a
587 | later version.
588 |
589 | 15. Disclaimer of Warranty.
590 |
591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
599 |
600 | 16. Limitation of Liability.
601 |
602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
610 | SUCH DAMAGES.
611 |
612 | 17. Interpretation of Sections 15 and 16.
613 |
614 | If the disclaimer of warranty and limitation of liability provided
615 | above cannot be given local legal effect according to their terms,
616 | reviewing courts shall apply local law that most closely approximates
617 | an absolute waiver of all civil liability in connection with the
618 | Program, unless a warranty or assumption of liability accompanies a
619 | copy of the Program in return for a fee.
620 |
621 | END OF TERMS AND CONDITIONS
622 |
623 | How to Apply These Terms to Your New Programs
624 |
625 | If you develop a new program, and you want it to be of the greatest
626 | possible use to the public, the best way to achieve this is to make it
627 | free software which everyone can redistribute and change under these terms.
628 |
629 | To do so, attach the following notices to the program. It is safest
630 | to attach them to the start of each source file to most effectively
631 | state the exclusion of warranty; and each file should have at least
632 | the "copyright" line and a pointer to where the full notice is found.
633 |
634 |
635 | Copyright (C)
636 |
637 | This program is free software: you can redistribute it and/or modify
638 | it under the terms of the GNU General Public License as published by
639 | the Free Software Foundation, either version 3 of the License, or
640 | (at your option) any later version.
641 |
642 | This program is distributed in the hope that it will be useful,
643 | but WITHOUT ANY WARRANTY; without even the implied warranty of
644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
645 | GNU General Public License for more details.
646 |
647 | You should have received a copy of the GNU General Public License
648 | along with this program. If not, see .
649 |
650 | Also add information on how to contact you by electronic and paper mail.
651 |
652 | If the program does terminal interaction, make it output a short
653 | notice like this when it starts in an interactive mode:
654 |
655 | Copyright (C)
656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
657 | This is free software, and you are welcome to redistribute it
658 | under certain conditions; type `show c' for details.
659 |
660 | The hypothetical commands `show w' and `show c' should show the appropriate
661 | parts of the General Public License. Of course, your program's commands
662 | might be different; for a GUI interface, you would use an "about box".
663 |
664 | You should also get your employer (if you work as a programmer) or school,
665 | if any, to sign a "copyright disclaimer" for the program, if necessary.
666 | For more information on this, and how to apply and follow the GNU GPL, see
667 | .
668 |
669 | The GNU General Public License does not permit incorporating your program
670 | into proprietary programs. If your program is a subroutine library, you
671 | may consider it more useful to permit linking proprietary applications with
672 | the library. If this is what you want to do, use the GNU Lesser General
673 | Public License instead of this License. But first, please read
674 | .
675 |
--------------------------------------------------------------------------------
/docs/swagger.json:
--------------------------------------------------------------------------------
1 | {
2 | "swagger": "2.0",
3 | "info": {
4 | "description": "This is the DiscountDB API documentation",
5 | "title": "DiscountDB API",
6 | "termsOfService": "http://swagger.io/terms/",
7 | "contact": {},
8 | "version": "1.0"
9 | },
10 | "host": "api.discountdb.ch",
11 | "basePath": "/api/v1",
12 | "paths": {
13 | "/coupons": {
14 | "post": {
15 | "description": "Create a new coupon",
16 | "consumes": [
17 | "application/json"
18 | ],
19 | "produces": [
20 | "application/json"
21 | ],
22 | "tags": [
23 | "coupons"
24 | ],
25 | "summary": "Create a new coupon",
26 | "parameters": [
27 | {
28 | "description": "CouponCreateRequest object",
29 | "name": "coupon",
30 | "in": "body",
31 | "required": true,
32 | "schema": {
33 | "$ref": "#/definitions/models.CouponCreateRequest"
34 | }
35 | }
36 | ],
37 | "responses": {
38 | "200": {
39 | "description": "OK",
40 | "schema": {
41 | "$ref": "#/definitions/models.CouponCreateResponse"
42 | }
43 | },
44 | "400": {
45 | "description": "Bad Request",
46 | "schema": {
47 | "$ref": "#/definitions/models.ErrorResponse"
48 | }
49 | },
50 | "500": {
51 | "description": "Internal Server Error",
52 | "schema": {
53 | "$ref": "#/definitions/models.ErrorResponse"
54 | }
55 | }
56 | }
57 | }
58 | },
59 | "/coupons/categories": {
60 | "get": {
61 | "description": "Retrieve a list of all categories",
62 | "produces": [
63 | "application/json"
64 | ],
65 | "tags": [
66 | "categories"
67 | ],
68 | "summary": "Get all categories",
69 | "responses": {
70 | "200": {
71 | "description": "OK",
72 | "schema": {
73 | "$ref": "#/definitions/models.CategoriesResponse"
74 | }
75 | },
76 | "500": {
77 | "description": "Internal Server Error",
78 | "schema": {
79 | "$ref": "#/definitions/models.ErrorResponse"
80 | }
81 | }
82 | }
83 | }
84 | },
85 | "/coupons/merchants": {
86 | "get": {
87 | "description": "Retrieve a list of all merchants",
88 | "produces": [
89 | "application/json"
90 | ],
91 | "tags": [
92 | "merchants"
93 | ],
94 | "summary": "Get all merchants",
95 | "responses": {
96 | "200": {
97 | "description": "OK",
98 | "schema": {
99 | "$ref": "#/definitions/models.MerchantResponse"
100 | }
101 | },
102 | "500": {
103 | "description": "Internal Server Error",
104 | "schema": {
105 | "$ref": "#/definitions/models.ErrorResponse"
106 | }
107 | }
108 | }
109 | }
110 | },
111 | "/coupons/regions": {
112 | "get": {
113 | "description": "Retrieve a list of all regions",
114 | "produces": [
115 | "application/json"
116 | ],
117 | "tags": [
118 | "regions"
119 | ],
120 | "summary": "Get all regions",
121 | "responses": {
122 | "200": {
123 | "description": "OK",
124 | "schema": {
125 | "$ref": "#/definitions/models.RegionResponse"
126 | }
127 | },
128 | "500": {
129 | "description": "Internal Server Error",
130 | "schema": {
131 | "$ref": "#/definitions/models.ErrorResponse"
132 | }
133 | }
134 | }
135 | }
136 | },
137 | "/coupons/search": {
138 | "get": {
139 | "description": "Retrieve a list of coupons with optional search, sorting, and pagination",
140 | "consumes": [
141 | "application/json"
142 | ],
143 | "produces": [
144 | "application/json"
145 | ],
146 | "tags": [
147 | "coupons"
148 | ],
149 | "summary": "Get coupons with filtering and pagination",
150 | "parameters": [
151 | {
152 | "type": "string",
153 | "description": "Search query string",
154 | "name": "q",
155 | "in": "query"
156 | },
157 | {
158 | "enum": [
159 | "newest",
160 | "oldest",
161 | "high_score",
162 | "low_score"
163 | ],
164 | "type": "string",
165 | "default": "newest",
166 | "description": "Sort order (newest, oldest, high_score, low_score)",
167 | "name": "sort_by",
168 | "in": "query"
169 | },
170 | {
171 | "minimum": 1,
172 | "type": "integer",
173 | "default": 10,
174 | "description": "Number of items per page",
175 | "name": "limit",
176 | "in": "query"
177 | },
178 | {
179 | "minimum": 0,
180 | "type": "integer",
181 | "default": 0,
182 | "description": "Number of items to skip",
183 | "name": "offset",
184 | "in": "query"
185 | }
186 | ],
187 | "responses": {
188 | "200": {
189 | "description": "OK",
190 | "schema": {
191 | "$ref": "#/definitions/models.CouponsSearchResponse"
192 | }
193 | },
194 | "400": {
195 | "description": "Bad Request",
196 | "schema": {
197 | "$ref": "#/definitions/models.ErrorResponse"
198 | }
199 | },
200 | "500": {
201 | "description": "Internal Server Error",
202 | "schema": {
203 | "$ref": "#/definitions/models.ErrorResponse"
204 | }
205 | }
206 | }
207 | }
208 | },
209 | "/coupons/tags": {
210 | "get": {
211 | "description": "Retrieve a list of all tags",
212 | "produces": [
213 | "application/json"
214 | ],
215 | "tags": [
216 | "tags"
217 | ],
218 | "summary": "Get all tags",
219 | "responses": {
220 | "200": {
221 | "description": "OK",
222 | "schema": {
223 | "$ref": "#/definitions/models.TagResponse"
224 | }
225 | },
226 | "500": {
227 | "description": "Internal Server Error",
228 | "schema": {
229 | "$ref": "#/definitions/models.ErrorResponse"
230 | }
231 | }
232 | }
233 | }
234 | },
235 | "/coupons/vote/{dir}/{id}": {
236 | "post": {
237 | "description": "Vote on a coupon by ID",
238 | "produces": [
239 | "application/json"
240 | ],
241 | "tags": [
242 | "votes"
243 | ],
244 | "summary": "Vote on a coupon",
245 | "parameters": [
246 | {
247 | "type": "string",
248 | "description": "Vote direction (up or down)",
249 | "name": "dir",
250 | "in": "path",
251 | "required": true
252 | },
253 | {
254 | "type": "string",
255 | "description": "Coupon ID",
256 | "name": "id",
257 | "in": "path",
258 | "required": true
259 | }
260 | ],
261 | "responses": {
262 | "200": {
263 | "description": "OK",
264 | "schema": {
265 | "$ref": "#/definitions/models.Success"
266 | }
267 | },
268 | "400": {
269 | "description": "Bad Request",
270 | "schema": {
271 | "$ref": "#/definitions/models.ErrorResponse"
272 | }
273 | }
274 | }
275 | }
276 | },
277 | "/coupons/{id}": {
278 | "get": {
279 | "description": "Retrieve a single coupon by its ID",
280 | "consumes": [
281 | "application/json"
282 | ],
283 | "produces": [
284 | "application/json"
285 | ],
286 | "tags": [
287 | "coupons"
288 | ],
289 | "summary": "Get coupon by ID",
290 | "parameters": [
291 | {
292 | "type": "integer",
293 | "description": "Coupon ID",
294 | "name": "id",
295 | "in": "path",
296 | "required": true
297 | }
298 | ],
299 | "responses": {
300 | "200": {
301 | "description": "OK",
302 | "schema": {
303 | "$ref": "#/definitions/models.Coupon"
304 | }
305 | },
306 | "404": {
307 | "description": "Not Found",
308 | "schema": {
309 | "$ref": "#/definitions/models.ErrorResponse"
310 | }
311 | },
312 | "500": {
313 | "description": "Internal Server Error",
314 | "schema": {
315 | "$ref": "#/definitions/models.ErrorResponse"
316 | }
317 | }
318 | }
319 | }
320 | },
321 | "/health": {
322 | "get": {
323 | "description": "Get API health status",
324 | "consumes": [
325 | "application/json"
326 | ],
327 | "produces": [
328 | "application/json"
329 | ],
330 | "tags": [
331 | "health"
332 | ],
333 | "summary": "Health check endpoint",
334 | "responses": {
335 | "200": {
336 | "description": "OK",
337 | "schema": {
338 | "$ref": "#/definitions/models.HealthCheckResponse"
339 | }
340 | },
341 | "500": {
342 | "description": "Internal Server Error",
343 | "schema": {
344 | "$ref": "#/definitions/models.ErrorResponse"
345 | }
346 | }
347 | }
348 | }
349 | },
350 | "/syrup/coupons": {
351 | "get": {
352 | "description": "Returns a paginated list of coupons for a specific domain",
353 | "produces": [
354 | "application/json"
355 | ],
356 | "tags": [
357 | "syrup"
358 | ],
359 | "summary": "List Coupons",
360 | "parameters": [
361 | {
362 | "type": "string",
363 | "description": "Optional API key for authentication",
364 | "name": "X-Syrup-API-Key",
365 | "in": "header"
366 | },
367 | {
368 | "type": "string",
369 | "description": "The website domain to fetch coupons for",
370 | "name": "domain",
371 | "in": "query",
372 | "required": true
373 | },
374 | {
375 | "maximum": 100,
376 | "minimum": 1,
377 | "type": "integer",
378 | "default": 20,
379 | "description": "Maximum number of coupons to return",
380 | "name": "limit",
381 | "in": "query"
382 | },
383 | {
384 | "minimum": 0,
385 | "type": "integer",
386 | "default": 0,
387 | "description": "Number of coupons to skip",
388 | "name": "offset",
389 | "in": "query"
390 | }
391 | ],
392 | "responses": {
393 | "200": {
394 | "description": "Successful response",
395 | "schema": {
396 | "$ref": "#/definitions/syrup.CouponList"
397 | },
398 | "headers": {
399 | "X-RateLimit-Limit": {
400 | "type": "string",
401 | "description": "The maximum number of requests allowed per time window"
402 | },
403 | "X-RateLimit-Remaining": {
404 | "type": "string",
405 | "description": "The number of requests remaining in the time window"
406 | },
407 | "X-RateLimit-Reset": {
408 | "type": "string",
409 | "description": "The time when the rate limit window resets (Unix timestamp)"
410 | }
411 | }
412 | },
413 | "400": {
414 | "description": "Bad Request",
415 | "schema": {
416 | "$ref": "#/definitions/syrup.ErrorResponse"
417 | }
418 | },
419 | "401": {
420 | "description": "Unauthorized",
421 | "schema": {
422 | "$ref": "#/definitions/syrup.ErrorResponse"
423 | }
424 | },
425 | "429": {
426 | "description": "Too Many Requests",
427 | "schema": {
428 | "$ref": "#/definitions/syrup.ErrorResponse"
429 | },
430 | "headers": {
431 | "X-RateLimit-RetryAfter": {
432 | "type": "integer",
433 | "description": "Time to wait before retrying (seconds)"
434 | }
435 | }
436 | },
437 | "500": {
438 | "description": "Internal Server Error",
439 | "schema": {
440 | "$ref": "#/definitions/syrup.ErrorResponse"
441 | }
442 | }
443 | }
444 | }
445 | },
446 | "/syrup/coupons/invalid/{id}": {
447 | "post": {
448 | "description": "Report that a coupon code failed to work",
449 | "produces": [
450 | "application/json"
451 | ],
452 | "tags": [
453 | "syrup"
454 | ],
455 | "summary": "Report Invalid Coupon",
456 | "parameters": [
457 | {
458 | "type": "string",
459 | "description": "Optional API key for authentication",
460 | "name": "X-Syrup-API-Key",
461 | "in": "header"
462 | },
463 | {
464 | "type": "string",
465 | "description": "The ID of the coupon",
466 | "name": "id",
467 | "in": "path",
468 | "required": true
469 | }
470 | ],
471 | "responses": {
472 | "200": {
473 | "description": "Successful response",
474 | "schema": {
475 | "$ref": "#/definitions/syrup.Success"
476 | },
477 | "headers": {
478 | "X-RateLimit-Limit": {
479 | "type": "string",
480 | "description": "The maximum number of requests allowed per time window"
481 | },
482 | "X-RateLimit-Remaining": {
483 | "type": "string",
484 | "description": "The number of requests remaining in the time window"
485 | },
486 | "X-RateLimit-Reset": {
487 | "type": "string",
488 | "description": "The time when the rate limit window resets (Unix timestamp)"
489 | }
490 | }
491 | },
492 | "400": {
493 | "description": "Bad Request",
494 | "schema": {
495 | "$ref": "#/definitions/syrup.ErrorResponse"
496 | }
497 | },
498 | "401": {
499 | "description": "Unauthorized",
500 | "schema": {
501 | "$ref": "#/definitions/syrup.ErrorResponse"
502 | }
503 | },
504 | "429": {
505 | "description": "Too Many Requests",
506 | "schema": {
507 | "$ref": "#/definitions/syrup.ErrorResponse"
508 | },
509 | "headers": {
510 | "X-RateLimit-RetryAfter": {
511 | "type": "integer",
512 | "description": "Time to wait before retrying (seconds)"
513 | }
514 | }
515 | },
516 | "500": {
517 | "description": "Internal Server Error",
518 | "schema": {
519 | "$ref": "#/definitions/syrup.ErrorResponse"
520 | }
521 | }
522 | }
523 | }
524 | },
525 | "/syrup/coupons/valid/{id}": {
526 | "post": {
527 | "description": "Report that a coupon code was successfully used",
528 | "produces": [
529 | "application/json"
530 | ],
531 | "tags": [
532 | "syrup"
533 | ],
534 | "summary": "Report Valid Coupon",
535 | "parameters": [
536 | {
537 | "type": "string",
538 | "description": "Optional API key for authentication",
539 | "name": "X-Syrup-API-Key",
540 | "in": "header"
541 | },
542 | {
543 | "type": "string",
544 | "description": "The ID of the coupon",
545 | "name": "id",
546 | "in": "path",
547 | "required": true
548 | }
549 | ],
550 | "responses": {
551 | "200": {
552 | "description": "Successful response",
553 | "schema": {
554 | "$ref": "#/definitions/syrup.Success"
555 | },
556 | "headers": {
557 | "X-RateLimit-Limit": {
558 | "type": "string",
559 | "description": "The maximum number of requests allowed per time window"
560 | },
561 | "X-RateLimit-Remaining": {
562 | "type": "string",
563 | "description": "The number of requests remaining in the time window"
564 | },
565 | "X-RateLimit-Reset": {
566 | "type": "string",
567 | "description": "The time when the rate limit window resets (Unix timestamp)"
568 | }
569 | }
570 | },
571 | "400": {
572 | "description": "Bad Request",
573 | "schema": {
574 | "$ref": "#/definitions/syrup.ErrorResponse"
575 | }
576 | },
577 | "401": {
578 | "description": "Unauthorized",
579 | "schema": {
580 | "$ref": "#/definitions/syrup.ErrorResponse"
581 | }
582 | },
583 | "429": {
584 | "description": "Too Many Requests",
585 | "schema": {
586 | "$ref": "#/definitions/syrup.ErrorResponse"
587 | },
588 | "headers": {
589 | "X-RateLimit-RetryAfter": {
590 | "type": "integer",
591 | "description": "Time to wait before retrying (seconds)"
592 | }
593 | }
594 | },
595 | "500": {
596 | "description": "Internal Server Error",
597 | "schema": {
598 | "$ref": "#/definitions/syrup.ErrorResponse"
599 | }
600 | }
601 | }
602 | }
603 | },
604 | "/syrup/merchants": {
605 | "get": {
606 | "description": "Returns a list of all merchants and their domains",
607 | "produces": [
608 | "application/json"
609 | ],
610 | "tags": [
611 | "syrup"
612 | ],
613 | "summary": "List all Merchants",
614 | "parameters": [
615 | {
616 | "type": "string",
617 | "description": "Optional API key for authentication",
618 | "name": "X-Syrup-API-Key",
619 | "in": "header"
620 | }
621 | ],
622 | "responses": {
623 | "200": {
624 | "description": "Successful response",
625 | "schema": {
626 | "$ref": "#/definitions/syrup.MerchantList"
627 | },
628 | "headers": {
629 | "X-RateLimit-Limit": {
630 | "type": "string",
631 | "description": "The maximum number of requests allowed per time window"
632 | },
633 | "X-RateLimit-Remaining": {
634 | "type": "string",
635 | "description": "The number of requests remaining in the time window"
636 | },
637 | "X-RateLimit-Reset": {
638 | "type": "string",
639 | "description": "The time when the rate limit window resets (Unix timestamp)"
640 | }
641 | }
642 | },
643 | "400": {
644 | "description": "Bad Request",
645 | "schema": {
646 | "$ref": "#/definitions/syrup.ErrorResponse"
647 | }
648 | },
649 | "401": {
650 | "description": "Unauthorized",
651 | "schema": {
652 | "$ref": "#/definitions/syrup.ErrorResponse"
653 | }
654 | },
655 | "429": {
656 | "description": "Too Many Requests",
657 | "schema": {
658 | "$ref": "#/definitions/syrup.ErrorResponse"
659 | },
660 | "headers": {
661 | "X-RateLimit-RetryAfter": {
662 | "type": "integer",
663 | "description": "Time to wait before retrying (seconds)"
664 | }
665 | }
666 | },
667 | "500": {
668 | "description": "Internal Server Error",
669 | "schema": {
670 | "$ref": "#/definitions/syrup.ErrorResponse"
671 | }
672 | }
673 | }
674 | }
675 | },
676 | "/syrup/version": {
677 | "get": {
678 | "description": "Returns information about the API implementation",
679 | "produces": [
680 | "application/json"
681 | ],
682 | "tags": [
683 | "syrup"
684 | ],
685 | "summary": "Get API Version",
686 | "parameters": [
687 | {
688 | "type": "string",
689 | "description": "Optional API key for authentication",
690 | "name": "X-Syrup-API-Key",
691 | "in": "header"
692 | }
693 | ],
694 | "responses": {
695 | "200": {
696 | "description": "Successful response",
697 | "schema": {
698 | "$ref": "#/definitions/syrup.VersionInfo"
699 | },
700 | "headers": {
701 | "X-RateLimit-Limit": {
702 | "type": "string",
703 | "description": "The maximum number of requests allowed per time window"
704 | },
705 | "X-RateLimit-Remaining": {
706 | "type": "string",
707 | "description": "The number of requests remaining in the time window"
708 | },
709 | "X-RateLimit-Reset": {
710 | "type": "string",
711 | "description": "The time when the rate limit window resets (Unix timestamp)"
712 | }
713 | }
714 | },
715 | "400": {
716 | "description": "Bad Request",
717 | "schema": {
718 | "$ref": "#/definitions/syrup.ErrorResponse"
719 | }
720 | },
721 | "401": {
722 | "description": "Unauthorized",
723 | "schema": {
724 | "$ref": "#/definitions/syrup.ErrorResponse"
725 | }
726 | },
727 | "429": {
728 | "description": "Too Many Requests",
729 | "schema": {
730 | "$ref": "#/definitions/syrup.ErrorResponse"
731 | },
732 | "headers": {
733 | "X-RateLimit-RetryAfter": {
734 | "type": "integer",
735 | "description": "Time to wait before retrying (seconds)"
736 | }
737 | }
738 | },
739 | "500": {
740 | "description": "Internal Server Error",
741 | "schema": {
742 | "$ref": "#/definitions/syrup.ErrorResponse"
743 | }
744 | }
745 | }
746 | }
747 | }
748 | },
749 | "definitions": {
750 | "models.CategoriesResponse": {
751 | "type": "object",
752 | "properties": {
753 | "data": {
754 | "type": "array",
755 | "items": {
756 | "type": "string"
757 | }
758 | },
759 | "total": {
760 | "type": "integer",
761 | "example": 2
762 | }
763 | }
764 | },
765 | "models.Coupon": {
766 | "type": "object",
767 | "properties": {
768 | "categories": {
769 | "description": "Metadata",
770 | "type": "array",
771 | "items": {
772 | "type": "string"
773 | }
774 | },
775 | "code": {
776 | "type": "string"
777 | },
778 | "created_at": {
779 | "type": "string"
780 | },
781 | "description": {
782 | "type": "string"
783 | },
784 | "discount_type": {
785 | "$ref": "#/definitions/models.DiscountType"
786 | },
787 | "discount_value": {
788 | "type": "number"
789 | },
790 | "down_votes": {
791 | "type": "array",
792 | "items": {
793 | "type": "string"
794 | }
795 | },
796 | "end_date": {
797 | "type": "string"
798 | },
799 | "id": {
800 | "description": "Required Information",
801 | "type": "integer"
802 | },
803 | "maximum_discount_amount": {
804 | "type": "number"
805 | },
806 | "merchant_name": {
807 | "type": "string"
808 | },
809 | "merchant_url": {
810 | "type": "string"
811 | },
812 | "minimum_purchase_amount": {
813 | "type": "number"
814 | },
815 | "regions": {
816 | "description": "countries/regions where valid",
817 | "type": "array",
818 | "items": {
819 | "type": "string"
820 | }
821 | },
822 | "score": {
823 | "description": "Score calculated by db",
824 | "type": "number"
825 | },
826 | "start_date": {
827 | "description": "Optional Validity Information",
828 | "type": "string"
829 | },
830 | "store_type": {
831 | "description": "\"online\", \"in_store\", \"both\"",
832 | "type": "string"
833 | },
834 | "tags": {
835 | "type": "array",
836 | "items": {
837 | "type": "string"
838 | }
839 | },
840 | "terms_conditions": {
841 | "type": "string"
842 | },
843 | "title": {
844 | "type": "string"
845 | },
846 | "up_votes": {
847 | "description": "Voting Information",
848 | "type": "array",
849 | "items": {
850 | "type": "string"
851 | }
852 | }
853 | }
854 | },
855 | "models.CouponCreateRequest": {
856 | "type": "object",
857 | "properties": {
858 | "categories": {
859 | "description": "Metadata",
860 | "type": "array",
861 | "items": {
862 | "type": "string"
863 | }
864 | },
865 | "code": {
866 | "description": "Required Information",
867 | "type": "string"
868 | },
869 | "description": {
870 | "type": "string"
871 | },
872 | "discount_type": {
873 | "$ref": "#/definitions/models.DiscountType"
874 | },
875 | "discount_value": {
876 | "type": "number"
877 | },
878 | "end_date": {
879 | "type": "string"
880 | },
881 | "maximum_discount_amount": {
882 | "type": "number"
883 | },
884 | "merchant_name": {
885 | "type": "string"
886 | },
887 | "merchant_url": {
888 | "type": "string"
889 | },
890 | "minimum_purchase_amount": {
891 | "type": "number"
892 | },
893 | "regions": {
894 | "description": "countries/regions where valid",
895 | "type": "array",
896 | "items": {
897 | "type": "string"
898 | }
899 | },
900 | "start_date": {
901 | "description": "Optional Validity Information",
902 | "type": "string"
903 | },
904 | "store_type": {
905 | "description": "\"online\", \"in_store\", \"both\"",
906 | "type": "string"
907 | },
908 | "tags": {
909 | "type": "array",
910 | "items": {
911 | "type": "string"
912 | }
913 | },
914 | "terms_conditions": {
915 | "type": "string"
916 | },
917 | "title": {
918 | "type": "string"
919 | }
920 | }
921 | },
922 | "models.CouponCreateResponse": {
923 | "type": "object",
924 | "properties": {
925 | "created_at": {
926 | "type": "string"
927 | },
928 | "id": {
929 | "type": "integer"
930 | },
931 | "score": {
932 | "type": "number"
933 | }
934 | }
935 | },
936 | "models.CouponsSearchResponse": {
937 | "type": "object",
938 | "properties": {
939 | "data": {
940 | "type": "array",
941 | "items": {
942 | "$ref": "#/definitions/models.Coupon"
943 | }
944 | },
945 | "limit": {
946 | "type": "integer",
947 | "example": 10
948 | },
949 | "offset": {
950 | "type": "integer",
951 | "example": 0
952 | },
953 | "total": {
954 | "type": "integer",
955 | "example": 100
956 | }
957 | }
958 | },
959 | "models.DiscountType": {
960 | "type": "string",
961 | "enum": [
962 | "PERCENTAGE_OFF",
963 | "FIXED_AMOUNT",
964 | "BOGO",
965 | "FREE_SHIPPING"
966 | ],
967 | "x-enum-varnames": [
968 | "PercentageOff",
969 | "FixedAmount",
970 | "BOGO",
971 | "FreeShipping"
972 | ]
973 | },
974 | "models.ErrorResponse": {
975 | "type": "object",
976 | "properties": {
977 | "message": {
978 | "type": "string",
979 | "example": "Internal server error"
980 | }
981 | }
982 | },
983 | "models.HealthCheckResponse": {
984 | "type": "object",
985 | "properties": {
986 | "status": {
987 | "type": "string",
988 | "example": "ok"
989 | },
990 | "version": {
991 | "type": "string",
992 | "example": "1.0"
993 | }
994 | }
995 | },
996 | "models.Merchant": {
997 | "type": "object",
998 | "properties": {
999 | "merchant_name": {
1000 | "type": "string",
1001 | "example": "merchant1"
1002 | },
1003 | "merchant_url": {
1004 | "type": "array",
1005 | "items": {
1006 | "type": "string"
1007 | }
1008 | }
1009 | }
1010 | },
1011 | "models.MerchantResponse": {
1012 | "type": "object",
1013 | "properties": {
1014 | "data": {
1015 | "type": "array",
1016 | "items": {
1017 | "$ref": "#/definitions/models.Merchant"
1018 | }
1019 | },
1020 | "total": {
1021 | "type": "integer",
1022 | "example": 2
1023 | }
1024 | }
1025 | },
1026 | "models.RegionResponse": {
1027 | "type": "object",
1028 | "properties": {
1029 | "regions": {
1030 | "type": "array",
1031 | "items": {
1032 | "type": "string"
1033 | }
1034 | },
1035 | "total": {
1036 | "type": "integer"
1037 | }
1038 | }
1039 | },
1040 | "models.Success": {
1041 | "type": "object",
1042 | "properties": {
1043 | "message": {
1044 | "type": "string",
1045 | "example": "Success"
1046 | }
1047 | }
1048 | },
1049 | "models.TagResponse": {
1050 | "type": "object",
1051 | "properties": {
1052 | "tags": {
1053 | "type": "array",
1054 | "items": {
1055 | "type": "string"
1056 | }
1057 | },
1058 | "total": {
1059 | "type": "integer"
1060 | }
1061 | }
1062 | },
1063 | "syrup.Coupon": {
1064 | "type": "object",
1065 | "properties": {
1066 | "code": {
1067 | "type": "string",
1068 | "example": "DISCOUNT10"
1069 | },
1070 | "description": {
1071 | "type": "string",
1072 | "example": "Get 10% off"
1073 | },
1074 | "id": {
1075 | "type": "string",
1076 | "example": "123"
1077 | },
1078 | "score": {
1079 | "type": "number",
1080 | "example": 5
1081 | },
1082 | "title": {
1083 | "type": "string",
1084 | "example": "Discount"
1085 | }
1086 | }
1087 | },
1088 | "syrup.CouponList": {
1089 | "type": "object",
1090 | "properties": {
1091 | "coupons": {
1092 | "type": "array",
1093 | "items": {
1094 | "$ref": "#/definitions/syrup.Coupon"
1095 | }
1096 | },
1097 | "merchant_name": {
1098 | "type": "string",
1099 | "example": "Amazon"
1100 | },
1101 | "total": {
1102 | "type": "integer"
1103 | }
1104 | }
1105 | },
1106 | "syrup.ErrorResponse": {
1107 | "type": "object",
1108 | "properties": {
1109 | "error": {
1110 | "type": "string",
1111 | "example": "Internal Server Error"
1112 | },
1113 | "message": {
1114 | "type": "string",
1115 | "example": "Something went wrong"
1116 | }
1117 | }
1118 | },
1119 | "syrup.Merchant": {
1120 | "type": "object",
1121 | "properties": {
1122 | "domains": {
1123 | "type": "array",
1124 | "items": {
1125 | "type": "string"
1126 | }
1127 | },
1128 | "merchant_name": {
1129 | "type": "string"
1130 | }
1131 | }
1132 | },
1133 | "syrup.MerchantList": {
1134 | "type": "object",
1135 | "properties": {
1136 | "merchants": {
1137 | "type": "array",
1138 | "items": {
1139 | "$ref": "#/definitions/syrup.Merchant"
1140 | }
1141 | },
1142 | "total": {
1143 | "type": "integer"
1144 | }
1145 | }
1146 | },
1147 | "syrup.Success": {
1148 | "type": "object",
1149 | "properties": {
1150 | "success": {
1151 | "type": "string",
1152 | "example": "true"
1153 | }
1154 | }
1155 | },
1156 | "syrup.VersionInfo": {
1157 | "type": "object",
1158 | "properties": {
1159 | "provider": {
1160 | "type": "string",
1161 | "example": "DiscountDB"
1162 | },
1163 | "version": {
1164 | "type": "string",
1165 | "example": "1.0.0"
1166 | }
1167 | }
1168 | }
1169 | }
1170 | }
--------------------------------------------------------------------------------