How to build your own SMS voting system

Exolve SMS API and with Supabase set up, I got to work.

First you need to install it yourself Go and connect the following libraries:

  • Supabase: is a great database tool that integrates well with Golang via GORM. In our case, it will store vote data.

  • Exolve SMS API: the main tool for working with SMS. You can get acquainted with the API Here.

  • Gin: lightweight and fast framework for creating web applications.

  • GORM: One of the best ORM for Golang, allowing you to easily work with databases.

Initializing the Project

Let's create a new project and initialize it using the go mod init command:

$ mkdir sms-voting
$ cd sms-voting
$ go mod init sms-voting

We will use Supabase to store vote data.

Let's install the necessary libraries:

$ go get -u github.com/gin-gonic/gin
$ go get -u gorm.io/gorm
$ go get -u gorm.io/driver/postgres

We register on the very Supabaselet's create a new project and add a table for votes:

CREATE TABLE votes (
  id SERIAL PRIMARY KEY,
  candidate_name VARCHAR(255) NOT NULL,
  vote_count INT DEFAULT 0,
  created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

This will allow information about each vote to be stored and the number of votes for each candidate to be tracked.

Project structure

The structure will look like this:

sms-voting/
├── config/
│   └── config.go
├── db/
│   └── db.go
├── sms/
│   └── sms.go
├── handlers/
│   └── sms_handler.go
├── models/
│   └── vote.go
├── main.go
└── go.mod

Project configuration

Let's create a file config/config.go to store configuration parameters:

// config/config.go
package config

import (
    "log"
    "os"
)

type Config struct {
    DBHost        string
    DBUser        string
    DBPassword    string
    DBName        string
    ExolveAPIKey  string
    SenderNumber  string
    ServerPort    string
}

func LoadConfig() *Config {
    config := &Config{
        DBHost:       getEnv("DB_HOST", "localhost"),
        DBUser:       getEnv("DB_USER", "postgres"),
        DBPassword:   getEnv("DB_PASSWORD", "password"),
        DBName:       getEnv("DB_NAME", "votes_db"),
        ExolveAPIKey: getEnv("EXOLVE_API_KEY", ""),
        SenderNumber: getEnv("SENDER_NUMBER", ""),
        ServerPort:   getEnv("SERVER_PORT", "8080"),
    }

    if config.ExolveAPIKey == "" || config.SenderNumber == "" {
        log.Fatal("EXOLVE_API_KEY and SENDER_NUMBER must be set")
    }

    return config
}

func getEnv(key, fallback string) string {
    if value, exists := os.LookupEnv(key); exists {
        return value
    }
    return fallback
}

Don't forget to set environment variables before running the application:

export DB_HOST=your_db_host
export DB_USER=your_db_user
export DB_PASSWORD=your_db_password
export DB_NAME=your_db_name
export EXOLVE_API_KEY=your_exolve_api_key
export SENDER_NUMBER=your_sender_number
export SERVER_PORT=8080

Data Model

Let's create a models/vote.go file to describe the voting model:

// models/vote.go
package models

import (
    "time"

    "gorm.io/gorm"
)

type Vote struct {
    ID            uint      `gorm:"primaryKey" json:"id"`
    CandidateName string    `gorm:"not null" json:"candidate_name"`
    VoteCount     int       `gorm:"default:0" json:"vote_count"`
    CreatedAt     time.Time `gorm:"autoCreateTime" json:"created_at"`
}

Working with DB

Let's create a db/db.go file to set up a connection to the database:

// db/db.go
package db

import (
    "fmt"
    "log"

    "gorm.io/driver/postgres"
    "gorm.io/gorm"

    "sms-voting/config"
    "sms-voting/models"
)

type Database struct {
    Conn *gorm.DB
}

func NewDatabase(cfg *config.Config) *Database {
    dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=5432 sslmode=require",
        cfg.DBHost,
        cfg.DBUser,
        cfg.DBPassword,
        cfg.DBName)

    db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
    if err != nil {
        log.Fatal("failed to connect database:", err)
    }

    // Миграция схемы
    if err := db.AutoMigrate(&models.Vote{}); err != nil {
        log.Fatal("failed to migrate database:", err)
    }

    return &Database{Conn: db}
}

Interfaces

In order for everything to be flexible and convenient, while still testing, we will introduce interfaces for working with the database and sending SMS.

Database interface

Let's create a file models/vote_repository.go:

// models/vote_repository.go
package models

import (
    "errors"

    "gorm.io/gorm"
)

type VoteRepository interface {
    GetOrCreateVote(candidateName string) (*Vote, error)
    IncrementVote(vote *Vote) error
    GetAllVotes() ([]Vote, error)
}

type voteRepository struct {
    db *gorm.DB
}

func NewVoteRepository(db *gorm.DB) VoteRepository {
    return &voteRepository{db}
}

func (r *voteRepository) GetOrCreateVote(candidateName string) (*Vote, error) {
    var vote Vote
    if err := r.db.Where("candidate_name = ?", candidateName).First(&vote).Error; err != nil {
        if errors.Is(err, gorm.ErrRecordNotFound) {
            vote = Vote{CandidateName: candidateName}
            if err := r.db.Create(&vote).Error; err != nil {
                return nil, err
            }
        } else {
            return nil, err
        }
    }
    return &vote, nil
}

func (r *voteRepository) IncrementVote(vote *Vote) error {
    vote.VoteCount += 1
    return r.db.Save(vote).Error
}

func (r *voteRepository) GetAllVotes() ([]Vote, error) {
    var votes []Vote
    if err := r.db.Find(&votes).Error; err != nil {
        return nil, err
    }
    return votes, nil
}

Interface for sending SMS

// sms/sms_service.go
package sms

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io/ioutil"
    "net/http"
)

type SMSService interface {
    SendSMS(to string, message string) error
}

type exolveSMSService struct {
    apiKey       string
    senderNumber string
    apiURL       string
}

func NewExolveSMSService(apiKey, senderNumber string) SMSService {
    return &exolveSMSService{
        apiKey:       apiKey,
        senderNumber: senderNumber,
        apiURL:       "https://api.exolve.ru/sms/send",
    }
}

type SMSSendRequest struct {
    To      string `json:"to"`
    From    string `json:"from"`
    Message string `json:"message"`
}

func (s *exolveSMSService) SendSMS(to string, message string) error {
    smsSendReq := SMSSendRequest{
        To:      to,
        From:    s.senderNumber,
        Message: message,
    }

    body, err := json.Marshal(smsSendReq)
    if err != nil {
        return fmt.Errorf("error marshalling SMS request: %w", err)
    }

    req, err := http.NewRequest("POST", s.apiURL, bytes.NewBuffer(body))
    if err != nil {
        return fmt.Errorf("error creating SMS request: %w", err)
    }
    req.Header.Set("Authorization", "Bearer "+s.apiKey)
    req.Header.Set("Content-Type", "application/json")

    client := &http.Client{}
    resp, err := client.Do(req)
    if err != nil {
        return fmt.Errorf("error sending SMS: %w", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        respBody, _ := ioutil.ReadAll(resp.Body)
        return fmt.Errorf("failed to send SMS, status code: %d, response: %s", resp.StatusCode, string(respBody))
    }

    return nil
}

HTTP Request Handlers

Let's create a file handlers/sms_handler.go to process incoming SMS:

// handlers/sms_handler.go
package handlers

import (
    "fmt"
    "log"
    "net/http"

    "github.com/gin-gonic/gin"

    "sms-voting/models"
    "sms-voting/sms"
)

type SMSRequest struct {
    From string `json:"from"`
    Body string `json:"body"`
}

type SMSHandler struct {
    VoteRepo   models.VoteRepository
    SMSService sms.SMSService
}

func NewSMSHandler(voteRepo models.VoteRepository, smsService sms.SMSService) *SMSHandler {
    return &SMSHandler{
        VoteRepo:   voteRepo,
        SMSService: smsService,
    }
}

func (h *SMSHandler) HandleSMS(c *gin.Context) {
    var smsReq SMSRequest
    if err := c.ShouldBindJSON(&smsReq); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    candidateName := smsReq.Body
    vote, err := h.VoteRepo.GetOrCreateVote(candidateName)
    if err != nil {
        log.Printf("Error fetching/creating vote for candidate %s: %v", candidateName, err)
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"})
        return
    }

    if err := h.VoteRepo.IncrementVote(vote); err != nil {
        log.Printf("Error incrementing vote for candidate %s: %v", candidateName, err)
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"})
        return
    }

    responseMessage := fmt.Sprintf("Ваш голос за %s принят. Спасибо за участие!", vote.CandidateName)
    if err := h.SMSService.SendSMS(smsReq.From, responseMessage); err != nil {
        log.Printf("Error sending SMS to %s: %v", smsReq.From, err)
        // Можно решить, возвращать ли ошибку пользователю или нет
    }

    c.JSON(http.StatusOK, gin.H{"message": responseMessage})
}

Main application file

Let's create a main.go file that will launch the server:

// main.go
package main

import (
    "log"

    "github.com/gin-gonic/gin"

    "sms-voting/config"
    "sms-voting/db"
    "sms-voting/handlers"
    "sms-voting/models"
    "sms-voting/sms"
)

func main() {
    // Загрузка конфигурации
    cfg := config.LoadConfig()

    // Инициализация базы данных
    database := db.NewDatabase(cfg)
    voteRepo := models.NewVoteRepository(database.Conn)

    // Инициализация SMS-сервиса
    smsService := sms.NewExolveSMSService(cfg.ExolveAPIKey, cfg.SenderNumber)

    // Инициализация обработчиков
    smsHandler := handlers.NewSMSHandler(voteRepo, smsService)

    // Настройка роутера Gin
    router := gin.Default()
    router.POST("/sms", smsHandler.HandleSMS)

    // Запуск сервера
    log.Printf("Сервер запущен на порту %s", cfg.ServerPort)
    if err := router.Run(":" + cfg.ServerPort); err != nil {
        log.Fatalf("Не удалось запустить сервер: %v", err)
    }
}

Now you can start the server:

$ go run main.go

Example request body for testing:

{
    "from": "+79678880033",
    "body": "Кандидат"
}

After processing the request, the system will increase the vote counter for the specified candidate and send a confirmation SMS to the user.

What's next?

Additional functions can be added to this system: reports, campaign management, integration with other services, or the same scaling. Exolve SMS API also has many opportunities to improve scripts.

Share your ideas and improvements in the comments. Until next time!

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *