add database models, add timer functionality

This commit is contained in:
lyx0 2023-09-07 22:34:53 +02:00
parent ec86f396bd
commit b330d5aa56
8 changed files with 1259 additions and 3 deletions

View file

@ -114,7 +114,16 @@ func (app *application) handleCommand(message twitch.PrivateMessage) {
case "xkcd": case "xkcd":
reply, _ = commands.Xkcd() reply, _ = commands.Xkcd()
// ################## case "timer":
switch cmdParams[1] {
case "add":
app.AddTimer(cmdParams[2], cmdParams[3], message)
case "edit":
app.EditTimer(cmdParams[2], cmdParams[3], message)
case "delete":
app.DeleteTimer(cmdParams[2], message)
}
// Check if the commandName exists as the "name" of a command in the database. // Check if the commandName exists as the "name" of a command in the database.
// if it doesnt then ignore it. // if it doesnt then ignore it.
// ################## // ##################

View file

@ -12,6 +12,7 @@ import (
"github.com/joho/godotenv" "github.com/joho/godotenv"
_ "github.com/lib/pq" _ "github.com/lib/pq"
"github.com/lyx0/nourybot/internal/common" "github.com/lyx0/nourybot/internal/common"
"github.com/lyx0/nourybot/internal/data"
"github.com/nicklaw5/helix" "github.com/nicklaw5/helix"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
@ -37,7 +38,7 @@ type application struct {
HelixClient *helix.Client HelixClient *helix.Client
Log *zap.SugaredLogger Log *zap.SugaredLogger
Db *sql.DB Db *sql.DB
// Models data.Models Models data.Models
Scheduler *cron.Cron Scheduler *cron.Cron
// Rdb *redis.Client // Rdb *redis.Client
} }
@ -128,6 +129,8 @@ func main() {
HelixClient: helixClient, HelixClient: helixClient,
Log: sugar, Log: sugar,
Db: db, Db: db,
Models: data.NewModels(db),
Scheduler: cron.New(),
} }
// Received a PrivateMessage (normal chat message). // Received a PrivateMessage (normal chat message).
@ -180,6 +183,12 @@ func main() {
"Database", db.Stats(), "Database", db.Stats(),
"Helix", helixResp, "Helix", helixResp,
) )
// Load the initial timers from the database.
app.InitialTimers()
// Start the timers.
app.Scheduler.Start()
}) })
// Actually connect to chat. // Actually connect to chat.
err = app.TwitchClient.Connect() err = app.TwitchClient.Connect()

277
cmd/nourybot/timer.go Normal file
View file

@ -0,0 +1,277 @@
package main
import (
"fmt"
"regexp"
"strings"
"github.com/gempir/go-twitch-irc/v4"
"github.com/lyx0/nourybot/internal/data"
)
// AddTimer slices the message into relevant parts, adding the values onto a
// new data.Timer struct so that the timer can be inserted into the database.
func (app *application) AddTimer(name, repeat string, message twitch.PrivateMessage) {
cmdParams := strings.SplitN(message.Message, " ", 500)
// prefixLength is the length of `()add timer` plus +2 (for the space and zero based)
prefixLength := 13
// Split the message into the parts we need.
//
// message: ()addtimer sponsor 20m hecking love my madmonq pills BatChest
// parts: | prefix | |name | |repeat | <----------- text -------------> |
text := message.Message[prefixLength+len(name)+len(cmdParams[2]) : len(message.Message)]
// validateTimeFormat will be true if the repeat parameter is in
// the format of either 30m, 10h, or 10h30m.
validateTimeFormat, err := regexp.MatchString(`^(\d{1,2}[h])$|^(\d+[m])$|^(\d+[s])$|((\d{1,2}[h])((([0]?|[1-5]{1})[0-9])[m]))$`, repeat)
if err != nil {
app.Log.Errorw("Received malformed time format in timer",
"repeat", repeat,
"error", err,
)
return
}
timer := &data.Timer{
Name: name,
Text: text,
Channel: message.Channel,
Repeat: repeat,
}
// Check if the time string we got is valid, this is important
// because the Scheduler panics instead of erroring out if an invalid
// time format string is supplied.
if validateTimeFormat {
timer := &data.Timer{
Name: name,
Text: text,
Channel: message.Channel,
Repeat: repeat,
}
err = app.Models.Timers.Insert(timer)
if err != nil {
app.Log.Errorw("Error inserting new timer into database",
"timer", timer,
"error", err,
)
reply := fmt.Sprintln("Something went wrong FeelsBadMan")
app.Send(message.Channel, reply)
return
} else {
// cronName is the internal, unique tag/name for the timer.
// A timer named "sponsor" in channel "forsen" will be named "forsensponsor"
cronName := fmt.Sprintf("%s%s", message.Channel, name)
app.Scheduler.AddFunc(fmt.Sprintf("@every %s", repeat), func() { app.newPrivateMessageTimer(message.Channel, text) }, cronName)
app.Log.Infow("Added new timer",
"timer", timer,
)
reply := fmt.Sprintf("Successfully added timer %s repeating every %s", name, repeat)
app.Send(message.Channel, reply)
return
}
} else {
app.Log.Errorw("Received malformed time format in timer",
"timer", timer,
"error", err,
)
reply := "Something went wrong FeelsBadMan received wrong time format. Allowed formats: 30m, 10h, 10h30m"
app.Send(message.Channel, reply)
return
}
}
// EditTimer just contains the logic for deleting a timer, and then adding a new one
// with the same name. It is technically not editing the timer.
func (app *application) EditTimer(name, repeat string, message twitch.PrivateMessage) {
// Check if a timer with that name is in the database.
app.Log.Info(name)
old, err := app.Models.Timers.Get(name)
if err != nil {
app.Log.Errorw("Could not get timer",
"timer", old,
"error", err,
)
reply := "Something went wrong FeelsBadMan"
app.Send(message.Channel, reply)
return
}
// -----------------------
// Delete the old timer
// -----------------------
cronName := fmt.Sprintf("%s%s", message.Channel, name)
app.Scheduler.RemoveJob(cronName)
err = app.Models.Timers.Delete(name)
if err != nil {
app.Log.Errorw("Error deleting timer from database",
"name", name,
"cronName", cronName,
"error", err,
)
reply := fmt.Sprintln("Something went wrong FeelsBadMan")
app.Send(message.Channel, reply)
return
}
// -----------------------
// Add the new timer
// -----------------------
cmdParams := strings.SplitN(message.Message, " ", 500)
// prefixLength is the length of `()editcommand` plus +2 (for the space and zero based)
prefixLength := 14
// Split the message into the parts we need.
//
// message: ()addtimer sponsor 20m hecking love my madmonq pills BatChest
// parts: | prefix | |name | |repeat | <----------- text -------------> |
text := message.Message[prefixLength+len(name)+len(cmdParams[2]) : len(message.Message)]
// validateTimeFormat will be true if the repeat parameter is in
// the format of either 30m, 10h, or 10h30m.
validateTimeFormat, err := regexp.MatchString(`^(\d{1,2}[h])$|^(\d+[m])$|^(\d+[s])$|((\d{1,2}[h])((([0]?|[1-5]{1})[0-9])[m]))$`, repeat)
if err != nil {
app.Log.Errorw("Received malformed time format in timer",
"repeat", repeat,
"error", err,
)
return
}
timer := &data.Timer{
Name: name,
Text: text,
Channel: message.Channel,
Repeat: repeat,
}
// Check if the time string we got is valid, this is important
// because the Scheduler panics instead of erroring out if an invalid
// time format string is supplied.
if validateTimeFormat {
timer := &data.Timer{
Name: name,
Text: text,
Channel: message.Channel,
Repeat: repeat,
}
err = app.Models.Timers.Insert(timer)
if err != nil {
app.Log.Errorw("Error inserting new timer into database",
"timer", timer,
"error", err,
)
reply := fmt.Sprintln("Something went wrong FeelsBadMan")
app.Send(message.Channel, reply)
return
} else { // this is a bit scuffed. The else here is the end of a successful call.
// cronName is the internal, unique tag/name for the timer.
// A timer named "sponsor" in channel "forsen" will be named "forsensponsor"
cronName := fmt.Sprintf("%s%s", message.Channel, name)
app.Scheduler.AddFunc(fmt.Sprintf("@every %s", repeat), func() { app.newPrivateMessageTimer(message.Channel, text) }, cronName)
app.Log.Infow("Updated a timer",
"Name", name,
"Channel", message.Channel,
"Old timer", old,
"New timer", timer,
)
reply := fmt.Sprintf("Successfully updated timer %s", name)
app.Send(message.Channel, reply)
return
}
} else {
app.Log.Errorw("Received malformed time format in timer",
"timer", timer,
"error", err,
)
reply := "Something went wrong FeelsBadMan received wrong time format. Allowed formats: 30s, 30m, 10h, 10h30m"
app.Send(message.Channel, reply)
return
}
}
// InitialTimers is called on startup and queries the database for a list of
// timers and then adds each onto the scheduler.
func (app *application) InitialTimers() {
timer, err := app.Models.Timers.GetAll()
if err != nil {
app.Log.Errorw("Error trying to retrieve all timers from database", err)
return
}
// The slice of timers is only used to log them at
// the start so it looks a bit nicer.
var ts []*data.Timer
// Iterate over all timers and then add them onto the scheduler.
for i, v := range timer {
// idk why this works but it does so no touchy touchy.
// https://github.com/robfig/cron/issues/420#issuecomment-940949195
i, v := i, v
_ = i
// cronName is the internal, unique tag/name for the timer.
// A timer named "sponsor" in channel "forsen" will be named "forsensponsor"
cronName := fmt.Sprintf("%s%s", v.Channel, v.Name)
// Repeating is at what times the timer should repeat.
// 2 minute timer is @every 2m
repeating := fmt.Sprintf("@every %s", v.Repeat)
// Add new value to the slice
ts = append(ts, v)
app.Scheduler.AddFunc(repeating, func() { app.newPrivateMessageTimer(v.Channel, v.Text) }, cronName)
}
app.Log.Infow("Initial timers",
"timer", ts,
)
}
// newPrivateMessageTimer is a helper function to set timers
// which trigger into sending a twitch PrivateMessage.
func (app *application) newPrivateMessageTimer(channel, text string) {
app.Send(channel, text)
}
// DeleteTimer takes in the name of a timer and tries to delete the timer from the database.
func (app *application) DeleteTimer(name string, message twitch.PrivateMessage) {
cronName := fmt.Sprintf("%s%s", message.Channel, name)
app.Scheduler.RemoveJob(cronName)
app.Log.Infow("Deleting timer",
"name", name,
"message.Channel", message.Channel,
"cronName", cronName,
)
err := app.Models.Timers.Delete(name)
if err != nil {
app.Log.Errorw("Error deleting timer from database",
"name", name,
"cronName", cronName,
"error", err,
)
reply := fmt.Sprintln("Something went wrong FeelsBadMan")
app.Send(message.Channel, reply)
return
}
reply := fmt.Sprintf("Deleted timer with name %s", name)
app.Send(message.Channel, reply)
}

211
internal/data/channel.go Normal file
View file

@ -0,0 +1,211 @@
package data
import (
"context"
"database/sql"
"errors"
"time"
)
type Channel struct {
ID int `json:"id"`
AddedAt time.Time `json:"-"`
Login string `json:"login"`
TwitchID string `json:"twitchid"`
}
type ChannelModel struct {
DB *sql.DB
}
// Get takes the login name for a channel and queries the database for an
// existing entry with that login value. If it exists it returns a
// pointer to a Channel.
func (c ChannelModel) Get(login string) (*Channel, error) {
query := `
SELECT id, added_at, login, twitchid
FROM channels
WHERE login = $1`
var channel Channel
err := c.DB.QueryRow(query, login).Scan(
&channel.ID,
&channel.AddedAt,
&channel.Login,
&channel.TwitchID,
)
if err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
return nil, ErrRecordNotFound
default:
return nil, err
}
}
return &channel, nil
}
// Insert takes in a channel struct and inserts it into the database.
func (c ChannelModel) Insert(channel *Channel) error {
query := `
INSERT INTO channels(login, twitchid)
VALUES ($1, $2)
ON CONFLICT (login)
DO NOTHING
RETURNING id, added_at;
`
args := []interface{}{channel.Login, channel.TwitchID}
// Execute the query returning the number of affected rows.
result, err := c.DB.Exec(query, args...)
if err != nil {
return err
}
// Check how many rows were affected.
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return ErrChannelRecordAlreadyExists
}
return nil
}
// GetAll() returns a pointer to a slice of all channels (`[]*Channel`) in the database.
func (c ChannelModel) GetAll() ([]*Channel, error) {
query := `
SELECT id, added_at, login, twitchid
FROM channels
ORDER BY id`
// Create a context with 3 seconds timeout.
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
// Use QueryContext() the context and query. This returns a
// sql.Rows resultset containing our channels.
rows, err := c.DB.QueryContext(ctx, query)
if err != nil {
return nil, err
}
// Need to defer a call to rows.Close() to ensure the resultset
// is closed before GetAll() returns.
defer rows.Close()
// Initialize an empty slice to hold the data.
channels := []*Channel{}
// Iterate over the resultset.
for rows.Next() {
// Initialize an empty Channel struct where we put on
// a single channel value.
var channel Channel
// Scan the values onto the channel struct
err := rows.Scan(
&channel.ID,
&channel.AddedAt,
&channel.Login,
&channel.TwitchID,
)
if err != nil {
return nil, err
}
// Add the single movie struct onto the slice.
channels = append(channels, &channel)
}
// When rows.Next() finishes call rows.Err() to retrieve any errors.
if err = rows.Err(); err != nil {
return nil, err
}
return channels, nil
}
// GetJoinable() returns a slice of channel names (Channel.Login) in the database.
func (c ChannelModel) GetJoinable() ([]string, error) {
query := `
SELECT login
FROM channels
ORDER BY id`
// Create a context with 3 seconds timeout.
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
// Use QueryContext() the context and query. This returns a
// sql.Rows resultset containing our channels.
rows, err := c.DB.QueryContext(ctx, query)
if err != nil {
return nil, err
}
// Need to defer a call to rows.Close() to ensure the resultset
// is closed before GetAll() returns.
defer rows.Close()
// Initialize an empty slice to hold the data.
channels := []string{}
// Iterate over the resultset.
for rows.Next() {
// Initialize an empty Channel struct where we put on
// a single channel value.
var channel Channel
// Scan the values onto the channel struct
err := rows.Scan(
&channel.Login,
)
if err != nil {
return nil, err
}
// Add the single movie struct onto the slice.
channels = append(channels, channel.Login)
}
// When rows.Next() finishes call rows.Err() to retrieve any errors.
if err = rows.Err(); err != nil {
return nil, err
}
return channels, nil
}
// Delete takes in a login name and queries the database and if there is an
// entry with that login name deletes the entry.
func (c ChannelModel) Delete(login string) error {
// Prepare the statement.
query := `
DELETE FROM channels
WHERE login = $1`
// Execute the query returning the number of affected rows.
result, err := c.DB.Exec(query, login)
if err != nil {
return err
}
// Check how many rows were affected.
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
// We want atleast 1, if it is 0 the entry did not exist.
if rowsAffected == 0 {
return ErrRecordNotFound
}
return nil
}

205
internal/data/commands.go Normal file
View file

@ -0,0 +1,205 @@
package data
import (
"database/sql"
"errors"
)
type Command struct {
ID int `json:"id"`
Name string `json:"name"`
Text string `json:"text,omitempty"`
Category string `json:"category,omitempty"`
Level int `json:"level,omitempty"`
Help string `json:"help,omitempty"`
}
type CommandModel struct {
DB *sql.DB
}
// Get tries to find a command in the database with the provided name.
func (c CommandModel) Get(name string) (*Command, error) {
query := `
SELECT id, name, text, category, level, help
FROM commands
WHERE name = $1`
var command Command
err := c.DB.QueryRow(query, name).Scan(
&command.ID,
&command.Name,
&command.Text,
&command.Category,
&command.Level,
&command.Help,
)
if err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
return nil, ErrRecordNotFound
default:
return nil, err
}
}
return &command, nil
}
// Insert adds a command into the database.
func (c CommandModel) Insert(command *Command) error {
query := `
INSERT into commands(name, text, category, level, help)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (name)
DO NOTHING
RETURNING id;
`
args := []interface{}{command.Name, command.Text, command.Category, command.Level, command.Help}
result, err := c.DB.Exec(query, args...)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return ErrCommandRecordAlreadyExists
}
return nil
}
func (c CommandModel) Update(command *Command) error {
query := `
UPDATE commands
SET text = $2
WHERE name = $1
RETURNING id`
args := []interface{}{
command.Name,
command.Text,
}
err := c.DB.QueryRow(query, args...).Scan(&command.ID)
if err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
return ErrEditConflict
default:
return err
}
}
return nil
}
// SetCategory queries the database for an entry with the provided name,
// if there is one it updates the categories level with the provided level.
func (c CommandModel) SetCategory(name string, category string) error {
query := `
UPDATE commands
SET category = $2
WHERE name = $1`
result, err := c.DB.Exec(query, name, category)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return ErrRecordNotFound
}
return nil
}
// SetLevel queries the database for an entry with the provided name,
// if there is one it updates the entrys level with the provided level.
func (c CommandModel) SetLevel(name string, level int) error {
query := `
UPDATE commands
SET level = $2
WHERE name = $1`
result, err := c.DB.Exec(query, name, level)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return ErrRecordNotFound
}
return nil
}
// SetHelp sets the help text for a given name of a command in the database.
func (c CommandModel) SetHelp(name string, helptext string) error {
query := `
UPDATE commands
SET help = $2
WHERE name = $1`
result, err := c.DB.Exec(query, name, helptext)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return ErrRecordNotFound
}
return nil
}
// Delete takes in a command name and queries the database for an entry with
// the same name and tries to delete that entry.
func (c CommandModel) Delete(name string) error {
// Prepare the statement.
query := `
DELETE FROM commands
WHERE name = $1`
// Execute the query returning the number of affected rows.
result, err := c.DB.Exec(query, name)
if err != nil {
return err
}
// Check how many rows were affected.
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
// We want atleast 1, if it is 0 the entry did not exist.
if rowsAffected == 0 {
return ErrRecordNotFound
}
return nil
}

64
internal/data/models.go Normal file
View file

@ -0,0 +1,64 @@
package data
import (
"database/sql"
"errors"
)
var (
ErrRecordNotFound = errors.New("record not found")
ErrChannelRecordAlreadyExists = errors.New("channel already in database")
ErrEditConflict = errors.New("edit conflict")
ErrCommandRecordAlreadyExists = errors.New("command already exists")
ErrLastFMUserRecordAlreadyExists = errors.New("lastfm connection already set")
ErrUserAlreadyExists = errors.New("user already in database")
)
// struct Models wraps the models, making them callable
// as app.models.Channels.Get(login)
type Models struct {
Channels interface {
Insert(channel *Channel) error
Get(login string) (*Channel, error)
GetAll() ([]*Channel, error)
GetJoinable() ([]string, error)
Delete(login string) error
}
Users interface {
Insert(login, twitchId string) error
Get(login string) (*User, error)
Check(twitchId string) (*User, error)
SetLevel(login string, level int) error
GetLevel(twitchId string) (int, error)
SetLocation(twitchId, location string) error
GetLocation(twitchId string) (string, error)
SetLastFM(login, lastfmUser string) error
GetLastFM(login string) (string, error)
Delete(login string) error
}
Commands interface {
Get(name string) (*Command, error)
Insert(command *Command) error
Update(command *Command) error
SetLevel(name string, level int) error
SetCategory(name, category string) error
SetHelp(name, helptext string) error
Delete(name string) error
}
Timers interface {
Get(name string) (*Timer, error)
Insert(timer *Timer) error
Update(timer *Timer) error
GetAll() ([]*Timer, error)
Delete(name string) error
}
}
func NewModels(db *sql.DB) Models {
return Models{
Channels: ChannelModel{DB: db},
Users: UserModel{DB: db},
Commands: CommandModel{DB: db},
Timers: TimerModel{DB: db},
}
}

185
internal/data/timers.go Normal file
View file

@ -0,0 +1,185 @@
package data
import (
"context"
"database/sql"
"errors"
"time"
)
type Timer struct {
ID int `json:"id" redis:"timer-id"`
Name string `json:"name" redis:"timer-name"`
CronName string `redis:"timer-cronname"`
Text string `json:"text" redis:"timer-text"`
Channel string `json:"channel" redis:"timer-channel"`
Repeat string `json:"repeat" redis:"timer-repeat"`
}
type TimerModel struct {
DB *sql.DB
}
func (t TimerModel) Get(name string) (*Timer, error) {
query := `
SELECT id, name, text, channel, repeat
FROM timers
WHERE name = $1
`
var timer Timer
err := t.DB.QueryRow(query, name).Scan(
&timer.ID,
&timer.Name,
&timer.Text,
&timer.Channel,
&timer.Repeat,
)
if err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
return nil, ErrRecordNotFound
default:
return nil, err
}
}
return &timer, nil
}
// Insert adds a command into the database.
func (t TimerModel) Insert(timer *Timer) error {
query := `
INSERT into timers(name, text, channel, repeat)
VALUES ($1, $2, $3, $4)
RETURNING id;
`
args := []interface{}{timer.Name, timer.Text, timer.Channel, timer.Repeat}
result, err := t.DB.Exec(query, args...)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return ErrCommandRecordAlreadyExists
}
return nil
}
// GetAll() returns a pointer to a slice of all channels (`[]*Channel`) in the database.
func (t TimerModel) GetAll() ([]*Timer, error) {
query := `
SELECT id, name, text, channel, repeat
FROM timers
ORDER BY id`
// Create a context with 3 seconds timeout.
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
// Use QueryContext() the context and query. This returns a
// sql.Rows resultset containing our channels.
rows, err := t.DB.QueryContext(ctx, query)
if err != nil {
return nil, err
}
// Need to defer a call to rows.Close() to ensure the resultset
// is closed before GetAll() returns.
defer rows.Close()
// Initialize an empty slice to hold the data.
timers := []*Timer{}
// Iterate over the resultset.
for rows.Next() {
// Initialize an empty Channel struct where we put on
// a single channel value.
var timer Timer
// Scan the values onto the channel struct
err := rows.Scan(
&timer.ID,
&timer.Name,
&timer.Text,
&timer.Channel,
&timer.Repeat,
)
if err != nil {
return nil, err
}
// Add the single movie struct onto the slice.
timers = append(timers, &timer)
}
// When rows.Next() finishes call rows.Err() to retrieve any errors.
if err = rows.Err(); err != nil {
return nil, err
}
return timers, nil
}
func (t TimerModel) Update(timer *Timer) error {
query := `
UPDATE timers
SET text = $2, channel = $3, repeat = $4
WHERE name = $1
RETURNING id`
args := []interface{}{
timer.Name,
timer.Text,
timer.Channel,
timer.Repeat,
}
err := t.DB.QueryRow(query, args...).Scan(&timer.ID)
if err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
return ErrEditConflict
default:
return err
}
}
return nil
}
// Delete takes in a command name and queries the database for an entry with
// the same name and tries to delete that entry.
func (t TimerModel) Delete(name string) error {
// Prepare the statement.
query := `
DELETE FROM timers
WHERE name = $1`
// Execute the query returning the number of affected rows.
result, err := t.DB.Exec(query, name)
if err != nil {
return err
}
// Check how many rows were affected.
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
// We want atleast 1, if it is 0 the entry did not exist.
if rowsAffected == 0 {
return ErrRecordNotFound
}
return nil
}

296
internal/data/user.go Normal file
View file

@ -0,0 +1,296 @@
package data
import (
"database/sql"
"errors"
"time"
)
type User struct {
ID int `json:"id"`
AddedAt time.Time `json:"-"`
Login string `json:"login"`
TwitchID string `json:"twitchid"`
Level int `json:"level"`
Location string `json:"location,omitempty"`
LastFMUsername string `json:"lastfm_username,omitempty"`
}
type UserModel struct {
DB *sql.DB
}
// Insert inserts a user model into the database.
func (u UserModel) Insert(login, twitchId string) error {
query := `
INSERT INTO users(login, twitchid)
VALUES ($1, $2)
ON CONFLICT (login)
DO NOTHING
RETURNING id, added_at;
`
args := []interface{}{login, twitchId}
// Execute the query returning the number of affected rows.
result, err := u.DB.Exec(query, args...)
if err != nil {
return err
}
// Check how many rows were affected.
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return ErrUserAlreadyExists
}
return nil
}
// SetLocation searches the database for a record with the provided login value
// and if that exists sets the location to the supplied
func (u UserModel) SetLocation(twitchId, location string) error {
query := `
UPDATE users
SET location = $2
WHERE twitchId = $1`
result, err := u.DB.Exec(query, twitchId, location)
if err != nil {
return err
}
// Check how many rows were affected.
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
// We want atleast 1, if it is 0 the entry did not exist.
if rowsAffected == 0 {
return ErrRecordNotFound
}
return nil
}
// SetLocation searches the database for a record with the provided login value
// and if that exists sets the location to the supplied
func (u UserModel) GetLocation(twitchId string) (string, error) {
query := `
SELECT location
FROM users
WHERE twitchid = $1`
var user User
err := u.DB.QueryRow(query, twitchId).Scan(
&user.Location,
)
if err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
return "", ErrRecordNotFound
default:
return "", err
}
}
return user.Location, nil
}
// SetLocation searches the database for a record with the provided login value
// and if that exists sets the location to the supplied
func (u UserModel) SetLastFM(login, lastfmUser string) error {
query := `
UPDATE users
SET lastfm_username = $2
WHERE login = $1`
result, err := u.DB.Exec(query, login, lastfmUser)
if err != nil {
return err
}
// Check how many rows were affected.
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
// We want atleast 1, if it is 0 the entry did not exist.
if rowsAffected == 0 {
return ErrRecordNotFound
}
return nil
}
// SetLocation searches the database for a record with the provided login value
// and if that exists sets the location to the supplied
func (u UserModel) GetLastFM(login string) (string, error) {
query := `
SELECT lastfm_username
FROM users
WHERE login = $1`
var user User
err := u.DB.QueryRow(query, login).Scan(
&user.LastFMUsername,
)
if err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
return "", ErrRecordNotFound
default:
return "", err
}
}
return user.LastFMUsername, nil
}
// SetLocation searches the database for a record with the provided login value
// and if that exists sets the location to the supplied
func (u UserModel) GetLevel(twitchId string) (int, error) {
query := `
SELECT level
FROM users
WHERE twitchid = $1`
var user User
err := u.DB.QueryRow(query, twitchId).Scan(
&user.Level,
)
if err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
return -1, ErrRecordNotFound
default:
return -1, err
}
}
return user.Level, nil
}
// Setlevel searches the database for a record with the provided login value
// and if that exists sets the level to the supplied level value.
func (u UserModel) SetLevel(login string, level int) error {
query := `
UPDATE users
SET level = $2
WHERE login = $1`
// err := u.DB.QueryRow(query, args...).Scan(&user)
result, err := u.DB.Exec(query, login, level)
if err != nil {
return err
}
// Check how many rows were affected.
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
// We want atleast 1, if it is 0 the entry did not exist.
if rowsAffected == 0 {
return ErrRecordNotFound
}
return nil
}
// Get searches the database for a login name and returns the user struct on success.
func (u UserModel) Get(login string) (*User, error) {
query := `
SELECT id, added_at, login, twitchid, level, location
FROM users
WHERE login = $1`
var user User
err := u.DB.QueryRow(query, login).Scan(
&user.ID,
&user.AddedAt,
&user.Login,
&user.TwitchID,
&user.Level,
&user.Location,
)
if err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
return nil, ErrRecordNotFound
default:
return nil, err
}
}
return &user, nil
}
// Check checks the database for a record with the given login name.
func (u UserModel) Check(twitchId string) (*User, error) {
query := `
SELECT id, login
FROM users
WHERE twitchid = $1`
var user User
err := u.DB.QueryRow(query, twitchId).Scan(
&user.ID,
&user.Login,
)
if err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
return nil, ErrRecordNotFound
default:
return nil, err
}
}
return &user, nil
}
// Delete searches the database for a value with the supplied login name and if
// one exists deletes the record, returning any errors that might occur.
func (u UserModel) Delete(login string) error {
// Prepare the statement.
query := `
DELETE FROM users
WHERE login = $1`
// Execute the query returning the number of affected rows.
result, err := u.DB.Exec(query, login)
if err != nil {
return err
}
// Check how many rows were affected.
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
// We want atleast 1, if it is 0 the entry did not exist.
if rowsAffected == 0 {
return ErrRecordNotFound
}
return nil
}