Skip to content

Commit faf39c5

Browse files
author
Armin
committed
implement SMS sending feature with user balance validation and MySQL integration
1 parent 294ac17 commit faf39c5

File tree

10 files changed

+231
-14
lines changed

10 files changed

+231
-14
lines changed

app/app.go

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,71 @@
11
package app
22

33
import (
4+
"fmt"
5+
"log/slog"
6+
"os"
7+
8+
_ "github.com/go-sql-driver/mysql"
9+
"github.com/jmoiron/sqlx"
410
"github.com/labstack/echo/v4"
511
)
612

713
var (
8-
Echo *echo.Echo
14+
Echo *echo.Echo
15+
Logger *slog.Logger
16+
DB *sqlx.DB
917
)
1018

1119
func Init() {
12-
InitEcho()
20+
initLogger()
21+
initDB()
22+
initEcho()
23+
}
24+
25+
func initLogger() {
26+
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{})
27+
Logger = slog.New(handler)
28+
}
29+
30+
type dbConfig struct {
31+
Username string
32+
Password string
33+
Host string
34+
Port int
35+
DBName string
36+
}
37+
38+
func ConnectionString(dbConfig dbConfig) string {
39+
url := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s",
40+
dbConfig.Username,
41+
dbConfig.Password,
42+
dbConfig.Host,
43+
dbConfig.Port,
44+
dbConfig.DBName)
45+
return url
46+
}
47+
48+
func initDB() {
49+
var err error
50+
DB, err = sqlx.Open("mysql", ConnectionString(dbConfig{
51+
Username: "sms_user",
52+
Password: "sms_pass",
53+
Host: "localhost",
54+
Port: 3306,
55+
DBName: "sms_gateway",
56+
}))
57+
if err != nil {
58+
panic(err)
59+
}
60+
61+
if err := DB.Ping(); err != nil {
62+
panic(err)
63+
}
64+
65+
// migrate
66+
1367
}
1468

15-
func InitEcho() {
69+
func initEcho() {
1670
Echo = echo.New()
1771
}

cmd/api/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@ import (
44
"fmt"
55
"sms-gateway/app"
66
"sms-gateway/config"
7+
"sms-gateway/internal/sms"
78
)
89

910
func main() {
1011
app.Init()
1112

13+
app.Echo.POST("/sms/send", sms.SendHandler)
14+
1215
fmt.Printf("Starting server on %s ...\n", config.AppListenAddr)
1316
if err := app.Echo.Start(config.AppListenAddr); err != nil {
1417
panic(err)

docker-compose.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
version: "3.9"
2+
3+
services:
4+
mysql:
5+
image: mysql:8.4
6+
container_name: sms_gateway_mysql
7+
restart: unless-stopped
8+
environment:
9+
MYSQL_DATABASE: sms_gateway
10+
MYSQL_USER: sms_user
11+
MYSQL_PASSWORD: sms_pass
12+
MYSQL_ROOT_PASSWORD: root_pass
13+
ports:
14+
- "3306:3306"
15+
volumes:
16+
- mysql_data:/var/lib/mysql
17+
healthcheck:
18+
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
19+
interval: 10s
20+
timeout: 5s
21+
retries: 5
22+
23+
volumes:
24+
mysql_data:
25+

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@ module sms-gateway
33
go 1.25
44

55
require (
6+
github.com/jmoiron/sqlx v1.4.0
67
github.com/joho/godotenv v1.5.1
78
github.com/labstack/echo/v4 v4.14.0
89
)
910

1011
require (
12+
filippo.io/edwards25519 v1.1.0 // indirect
13+
github.com/go-sql-driver/mysql v1.9.3 // indirect
1114
github.com/labstack/gommon v0.4.2 // indirect
1215
github.com/mattn/go-colorable v0.1.14 // indirect
1316
github.com/mattn/go-isatty v0.0.20 // indirect

go.sum

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,27 @@
1+
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
2+
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
13
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
24
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
5+
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
6+
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
7+
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
8+
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
9+
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
10+
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
311
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
412
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
513
github.com/labstack/echo/v4 v4.14.0 h1:+tiMrDLxwv6u0oKtD03mv+V1vXXB3wCqPHJqPuIe+7M=
614
github.com/labstack/echo/v4 v4.14.0/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
715
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
816
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
17+
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
18+
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
919
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
1020
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
1121
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
1222
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
23+
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
24+
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
1325
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
1426
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
1527
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=

internal/balance/db/db.sql

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
CREATE TABLE user_balances (
2+
user_id BIGINT PRIMARY KEY,
3+
balance BIGINT NOT NULL DEFAULT 0,
4+
last_updated TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
5+
version BIGINT NOT NULL DEFAULT 0
6+
);
7+
8+
-- seed
9+
insert into user_balances (user_id, balance)
10+
values (1,100);
11+
12+
13+
CREATE TABLE user_transactions (
14+
transaction_id BIGINT AUTO_INCREMENT PRIMARY KEY,
15+
user_id BIGINT NOT NULL,
16+
amount BIGINT NOT NULL, -- Positive for deposit, negative for withdrawal
17+
new_balance BIGINT NOT NULL, -- Balance after this transaction
18+
transaction_type VARCHAR(50) NOT NULL, -- e.g., 'deposit', 'withdrawal', 'transfer'
19+
description TEXT,
20+
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
21+
22+
INDEX idx_user_id_created_at (user_id, created_at DESC),
23+
INDEX idx_transaction_type (transaction_type),
24+
FOREIGN KEY (user_id) REFERENCES user_balances(user_id) ON DELETE CASCADE
25+
) ENGINE=InnoDB;

internal/balance/service.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package balance
2+
3+
import (
4+
"context"
5+
"database/sql"
6+
"errors"
7+
"sms-gateway/app"
8+
"sms-gateway/internal/model"
9+
)
10+
11+
type UserHasEnoughBalanceRequest struct {
12+
CustomerID int64
13+
Quantity int
14+
Type model.Type
15+
}
16+
17+
func UserHasEnoughBalance(ctx context.Context, req UserHasEnoughBalanceRequest) (bool, error) {
18+
19+
const query = `SELECT balance FROM user_balances WHERE user_id = ?`
20+
var balance int64
21+
if err := app.DB.QueryRowxContext(ctx, query, req.CustomerID).Scan(&balance); err != nil {
22+
if errors.Is(err, sql.ErrNoRows) {
23+
return false, nil
24+
}
25+
return false, err
26+
}
27+
28+
price := calculatePrice(req.Type, req.Quantity)
29+
return balance >= price, nil
30+
}
31+
32+
func calculatePrice(Type model.Type, Quantity int) int64 {
33+
return int64(getPricePerType(Type) * Quantity)
34+
}
35+
36+
// could read from DB
37+
func getPricePerType(t model.Type) int {
38+
if t == model.EXPRESS {
39+
return 3
40+
}
41+
return 1
42+
}

internal/model/model.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package model
2+
3+
type Type string
4+
5+
const (
6+
NORMAL Type = "normal"
7+
EXPRESS Type = "express"
8+
)
9+
10+
type SMS struct {
11+
CustomerID int64 `json:"customer_id"`
12+
Text string `json:"text"`
13+
Recipients []string `json:"recipients"`
14+
Type Type `json:"type"`
15+
}

internal/sms/handler.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package sms
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"sms-gateway/app"
7+
"sms-gateway/internal/balance"
8+
"sms-gateway/internal/model"
9+
10+
"github.com/labstack/echo/v4"
11+
)
12+
13+
func SendHandler(c echo.Context) error {
14+
var s model.SMS
15+
if err := json.NewDecoder(c.Request().Body).Decode(&s); err != nil {
16+
app.Logger.Error("invalid input ", "err", err)
17+
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid input"})
18+
}
19+
20+
if len(s.Recipients) == 0 {
21+
app.Logger.Error("zero recipients")
22+
return c.JSON(http.StatusBadRequest, map[string]string{"error": "zero recipients"})
23+
}
24+
25+
app.Logger.Info("", "s", s)
26+
27+
// check user balance
28+
hasBalance, err := balance.UserHasEnoughBalance(c.Request().Context(), balance.UserHasEnoughBalanceRequest{
29+
CustomerID: s.CustomerID,
30+
Quantity: len(s.Recipients),
31+
Type: s.Type,
32+
})
33+
if err != nil {
34+
app.Logger.Error("UserHasEnoughBalance ", "err", err)
35+
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal error"})
36+
}
37+
if !hasBalance {
38+
app.Logger.Info("User Has Not Enough Balance ", "user id ", s.CustomerID)
39+
return c.JSON(http.StatusPaymentRequired, map[string]string{"error": "dont have "})
40+
}
41+
42+
app.Logger.Info("user has Enough Balance", "s", s)
43+
44+
// if not ok return err
45+
//else cut down the balance
46+
//then send it in quew
47+
48+
return c.JSON(http.StatusOK, nil)
49+
}

pkg/env/env.go

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,3 @@ func RequiredNotEmpty(key string) string {
4646
}
4747
return value
4848
}
49-
50-
func Required(key string) string {
51-
_, osSet := os.LookupEnv(key)
52-
_, dotEnvSet := dotEnvMap[key]
53-
if !osSet && !dotEnvSet {
54-
if !testing.Testing() {
55-
panic(fmt.Sprintf("`%s` is not set", key))
56-
}
57-
}
58-
return getEnv(key)
59-
}

0 commit comments

Comments
 (0)