diff --git a/cmd/nourybot/commands.go b/cmd/nourybot/commands.go index 15ef329..f6c1a9d 100644 --- a/cmd/nourybot/commands.go +++ b/cmd/nourybot/commands.go @@ -114,7 +114,16 @@ func (app *application) handleCommand(message twitch.PrivateMessage) { case "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. // if it doesnt then ignore it. // ################## diff --git a/cmd/nourybot/main.go b/cmd/nourybot/main.go index 13cc74a..1732f20 100644 --- a/cmd/nourybot/main.go +++ b/cmd/nourybot/main.go @@ -12,6 +12,7 @@ import ( "github.com/joho/godotenv" _ "github.com/lib/pq" "github.com/lyx0/nourybot/internal/common" + "github.com/lyx0/nourybot/internal/data" "github.com/nicklaw5/helix" "github.com/rs/zerolog/log" @@ -37,8 +38,8 @@ type application struct { HelixClient *helix.Client Log *zap.SugaredLogger Db *sql.DB - // Models data.Models - Scheduler *cron.Cron + Models data.Models + Scheduler *cron.Cron // Rdb *redis.Client } @@ -128,6 +129,8 @@ func main() { HelixClient: helixClient, Log: sugar, Db: db, + Models: data.NewModels(db), + Scheduler: cron.New(), } // Received a PrivateMessage (normal chat message). @@ -180,6 +183,12 @@ func main() { "Database", db.Stats(), "Helix", helixResp, ) + + // Load the initial timers from the database. + app.InitialTimers() + + // Start the timers. + app.Scheduler.Start() }) // Actually connect to chat. err = app.TwitchClient.Connect() diff --git a/cmd/nourybot/timer.go b/cmd/nourybot/timer.go new file mode 100644 index 0000000..756f1e4 --- /dev/null +++ b/cmd/nourybot/timer.go @@ -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) +} diff --git a/internal/data/channel.go b/internal/data/channel.go new file mode 100644 index 0000000..0282552 --- /dev/null +++ b/internal/data/channel.go @@ -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 +} diff --git a/internal/data/commands.go b/internal/data/commands.go new file mode 100644 index 0000000..11f8f07 --- /dev/null +++ b/internal/data/commands.go @@ -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 +} diff --git a/internal/data/models.go b/internal/data/models.go new file mode 100644 index 0000000..f1a6065 --- /dev/null +++ b/internal/data/models.go @@ -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}, + } +} diff --git a/internal/data/timers.go b/internal/data/timers.go new file mode 100644 index 0000000..a13a51b --- /dev/null +++ b/internal/data/timers.go @@ -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 +} diff --git a/internal/data/user.go b/internal/data/user.go new file mode 100644 index 0000000..01ed131 --- /dev/null +++ b/internal/data/user.go @@ -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 +}