feat(music): stable queue identity, remove/reorder, and repeat commands
TrackEntry gets a QueueID generated once when a track is queued and kept for its whole life there, and musicrepo.ReplaceQueue now upserts by that ID instead of deleting and reinserting the whole queue on every sync (which churned every track's database id constantly, even ones that hadn't moved, and would've made remove/reorder commands target the wrong track). RemoveFromQueue and Reorder are new Manager functions built on top of that stable identity. Also adds repeat_song/ repeat_queue to the persisted now-playing state, and makes the two existing toggle functions actually sync it, they never did before.
This commit is contained in:
@@ -15,6 +15,7 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/disgoorg/json v1.2.0 // indirect
|
github.com/disgoorg/json v1.2.0 // indirect
|
||||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/gorilla/websocket v1.5.3 // indirect
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
|
|||||||
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
|
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
|
||||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ type QueueEntry struct {
|
|||||||
Title string
|
Title string
|
||||||
URL string
|
URL string
|
||||||
RequestedBy int64
|
RequestedBy int64
|
||||||
|
|
||||||
|
// ClientID is the track's stable identity (set once by the manager
|
||||||
|
// when a track is first queued), used to upsert this row across
|
||||||
|
// syncs instead of assigning it a fresh DB id every single time.
|
||||||
|
ClientID string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Repo struct {
|
type Repo struct {
|
||||||
@@ -20,9 +25,15 @@ func NewRepo(db *sql.DB) *Repo {
|
|||||||
return &Repo{db: db}
|
return &Repo{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repo) SetNowPlaying(ctx context.Context, guildID int64, title, url string, durationSeconds int, startedAt time.Time, paused bool, volume int) error {
|
func (r *Repo) SetNowPlaying(ctx context.Context, guildID int64, title, url string, durationSeconds int, startedAt time.Time, paused bool, volume int, repeatSong, repeatQueue bool) error {
|
||||||
const q = `INSERT INTO music_now_playing (guild_id, track_title, track_url, duration_seconds, started_at, paused, volume) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (guild_id) DO UPDATE SET track_title = $2, track_url = $3, duration_seconds = $4, started_at = $5, paused = $6, volume = $7`
|
const q = `
|
||||||
_, err := r.db.ExecContext(ctx, q, guildID, title, url, durationSeconds, startedAt, paused, volume)
|
INSERT INTO music_now_playing (guild_id, track_title, track_url, duration_seconds, started_at, paused, volume, repeat_song, repeat_queue)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
ON CONFLICT (guild_id) DO UPDATE SET
|
||||||
|
track_title = $2, track_url = $3, duration_seconds = $4, started_at = $5,
|
||||||
|
paused = $6, volume = $7, repeat_song = $8, repeat_queue = $9
|
||||||
|
`
|
||||||
|
_, err := r.db.ExecContext(ctx, q, guildID, title, url, durationSeconds, startedAt, paused, volume, repeatSong, repeatQueue)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,6 +43,12 @@ func (r *Repo) ClearNowPlaying(ctx context.Context, guildID int64) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReplaceQueue syncs music_queue with the current in-memory queue by
|
||||||
|
// upserting each entry on its stable ClientID, rather than deleting and
|
||||||
|
// recreating every row on every call. That matters because music_queue.id
|
||||||
|
// is only an internal DB identity, remove/reorder commands target a
|
||||||
|
// track by ClientID, and that has to survive a track just sitting in the
|
||||||
|
// queue unchanged across syncs.
|
||||||
func (r *Repo) ReplaceQueue(ctx context.Context, guildID int64, entries []QueueEntry) error {
|
func (r *Repo) ReplaceQueue(ctx context.Context, guildID int64, entries []QueueEntry) error {
|
||||||
tx, err := r.db.BeginTx(ctx, nil)
|
tx, err := r.db.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -39,19 +56,89 @@ func (r *Repo) ReplaceQueue(ctx context.Context, guildID int64, entries []QueueE
|
|||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
const q = `DELETE FROM music_queue WHERE guild_id = $1`
|
existing := make(map[string]bool)
|
||||||
_, err = tx.ExecContext(ctx, q, guildID)
|
rows, err := tx.QueryContext(ctx, `SELECT client_id FROM music_queue WHERE guild_id = $1`, guildID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var clientID string
|
||||||
|
if err := rows.Scan(&clientID); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
existing[clientID] = true
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
keep := make(map[string]bool, len(entries))
|
||||||
for i, entry := range entries {
|
for i, entry := range entries {
|
||||||
const q = `INSERT INTO music_queue (guild_id, track_title, track_url, requested_by, position) VALUES ($1, $2, $3, $4, $5)`
|
keep[entry.ClientID] = true
|
||||||
_, err = tx.ExecContext(ctx, q, guildID, entry.Title, entry.URL, entry.RequestedBy, i)
|
const q = `
|
||||||
if err != nil {
|
INSERT INTO music_queue (guild_id, position, track_title, track_url, requested_by, client_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
ON CONFLICT (guild_id, client_id) DO UPDATE SET
|
||||||
|
position = EXCLUDED.position,
|
||||||
|
track_title = EXCLUDED.track_title,
|
||||||
|
track_url = EXCLUDED.track_url,
|
||||||
|
requested_by = EXCLUDED.requested_by
|
||||||
|
`
|
||||||
|
if _, err := tx.ExecContext(ctx, q, guildID, i, entry.Title, entry.URL, entry.RequestedBy, entry.ClientID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for clientID := range existing {
|
||||||
|
if keep[clientID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const q = `DELETE FROM music_queue WHERE guild_id = $1 AND client_id = $2`
|
||||||
|
if _, err := tx.ExecContext(ctx, q, guildID, clientID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MusicCommand struct {
|
||||||
|
ID int64
|
||||||
|
GuildID int64
|
||||||
|
CommandType string
|
||||||
|
Payload []byte
|
||||||
|
RequestedBy int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) ListUnprocessedCommands(ctx context.Context) ([]MusicCommand, error) {
|
||||||
|
const q = `SELECT id, guild_id, command_type, payload, requested_by FROM music_commands WHERE processed_at IS NULL ORDER BY id ASC`
|
||||||
|
|
||||||
|
rows, err := r.db.QueryContext(ctx, q)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var commands []MusicCommand
|
||||||
|
for rows.Next() {
|
||||||
|
var cmd MusicCommand
|
||||||
|
if err := rows.Scan(&cmd.ID, &cmd.GuildID, &cmd.CommandType, &cmd.Payload, &cmd.RequestedBy); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
commands = append(commands, cmd)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return commands, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) MarkCommandProcessed(ctx context.Context, id int64) error {
|
||||||
|
const q = `UPDATE music_commands SET processed_at = NOW() WHERE id = $1`
|
||||||
|
_, err := r.db.ExecContext(ctx, q, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/disgoorg/disgolink/v3/disgolink"
|
"github.com/disgoorg/disgolink/v3/disgolink"
|
||||||
"github.com/disgoorg/disgolink/v3/lavalink"
|
"github.com/disgoorg/disgolink/v3/lavalink"
|
||||||
"github.com/disgoorg/snowflake/v2"
|
"github.com/disgoorg/snowflake/v2"
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
type TrackEntry struct {
|
type TrackEntry struct {
|
||||||
@@ -21,6 +22,14 @@ type TrackEntry struct {
|
|||||||
RequestedBy string
|
RequestedBy string
|
||||||
RequesterID string
|
RequesterID string
|
||||||
StartedAt time.Time
|
StartedAt time.Time
|
||||||
|
|
||||||
|
// QueueID is generated once, when the track is first added to the
|
||||||
|
// queue, and never changes for the rest of its life there - it's
|
||||||
|
// what lets syncState upsert this track's row across syncs instead
|
||||||
|
// of deleting and recreating it (and its DB id) every single time,
|
||||||
|
// which is what remove/reorder commands need to reliably target a
|
||||||
|
// specific track.
|
||||||
|
QueueID string
|
||||||
}
|
}
|
||||||
|
|
||||||
type guildPlayer struct {
|
type guildPlayer struct {
|
||||||
@@ -213,6 +222,8 @@ func syncState(guidID string) {
|
|||||||
copy(queue, gp.Queue)
|
copy(queue, gp.Queue)
|
||||||
paused := gp.Paused
|
paused := gp.Paused
|
||||||
volume := gp.Volume
|
volume := gp.Volume
|
||||||
|
repeatSong := gp.RepeatSong
|
||||||
|
repeatQueue := gp.RepeatQueue
|
||||||
manager.mu.Unlock()
|
manager.mu.Unlock()
|
||||||
|
|
||||||
gID, err := strconv.ParseInt(guidID, 10, 64)
|
gID, err := strconv.ParseInt(guidID, 10, 64)
|
||||||
@@ -245,7 +256,7 @@ func syncState(guidID string) {
|
|||||||
length := int(current.Track.Info.Length / 1000)
|
length := int(current.Track.Info.Length / 1000)
|
||||||
startedAt := current.StartedAt
|
startedAt := current.StartedAt
|
||||||
|
|
||||||
err = manager.repo.SetNowPlaying(context.Background(), gID, title, uri, length, startedAt, paused, volume)
|
err = manager.repo.SetNowPlaying(context.Background(), gID, title, uri, length, startedAt, paused, volume, repeatSong, repeatQueue)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -262,6 +273,7 @@ func syncState(guidID string) {
|
|||||||
Title: entry.Track.Info.Title,
|
Title: entry.Track.Info.Title,
|
||||||
URL: uri,
|
URL: uri,
|
||||||
RequestedBy: reqID,
|
RequestedBy: reqID,
|
||||||
|
ClientID: entry.QueueID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
err = manager.repo.ReplaceQueue(context.Background(), gID, queueEntries)
|
err = manager.repo.ReplaceQueue(context.Background(), gID, queueEntries)
|
||||||
@@ -346,6 +358,7 @@ func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester, re
|
|||||||
Track: t,
|
Track: t,
|
||||||
RequestedBy: requester,
|
RequestedBy: requester,
|
||||||
RequesterID: requesterID,
|
RequesterID: requesterID,
|
||||||
|
QueueID: uuid.New().String(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
firstEntry := entries[0]
|
firstEntry := entries[0]
|
||||||
@@ -551,12 +564,15 @@ func ToggleRepeatSong(guildID string) (bool, bool) {
|
|||||||
}
|
}
|
||||||
gp := getOrCreateGuildPlayer(guildID)
|
gp := getOrCreateGuildPlayer(guildID)
|
||||||
manager.mu.Lock()
|
manager.mu.Lock()
|
||||||
defer manager.mu.Unlock()
|
|
||||||
gp.RepeatSong = !gp.RepeatSong
|
gp.RepeatSong = !gp.RepeatSong
|
||||||
if gp.RepeatSong {
|
if gp.RepeatSong {
|
||||||
gp.RepeatQueue = false
|
gp.RepeatQueue = false
|
||||||
}
|
}
|
||||||
return gp.RepeatSong, gp.RepeatQueue
|
repeatSong, repeatQueue := gp.RepeatSong, gp.RepeatQueue
|
||||||
|
manager.mu.Unlock()
|
||||||
|
|
||||||
|
syncState(guildID)
|
||||||
|
return repeatSong, repeatQueue
|
||||||
}
|
}
|
||||||
|
|
||||||
func ToggleRepeatQueue(guildID string) (bool, bool) {
|
func ToggleRepeatQueue(guildID string) (bool, bool) {
|
||||||
@@ -565,12 +581,15 @@ func ToggleRepeatQueue(guildID string) (bool, bool) {
|
|||||||
}
|
}
|
||||||
gp := getOrCreateGuildPlayer(guildID)
|
gp := getOrCreateGuildPlayer(guildID)
|
||||||
manager.mu.Lock()
|
manager.mu.Lock()
|
||||||
defer manager.mu.Unlock()
|
|
||||||
gp.RepeatQueue = !gp.RepeatQueue
|
gp.RepeatQueue = !gp.RepeatQueue
|
||||||
if gp.RepeatQueue {
|
if gp.RepeatQueue {
|
||||||
gp.RepeatSong = false
|
gp.RepeatSong = false
|
||||||
}
|
}
|
||||||
return gp.RepeatSong, gp.RepeatQueue
|
repeatSong, repeatQueue := gp.RepeatSong, gp.RepeatQueue
|
||||||
|
manager.mu.Unlock()
|
||||||
|
|
||||||
|
syncState(guildID)
|
||||||
|
return repeatSong, repeatQueue
|
||||||
}
|
}
|
||||||
|
|
||||||
func repeatFlags(guildID string) (bool, bool) {
|
func repeatFlags(guildID string) (bool, bool) {
|
||||||
@@ -630,6 +649,72 @@ func SetVolume(guildID string, volume int) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func RemoveFromQueue(guildID, queueID string) error {
|
||||||
|
if manager == nil {
|
||||||
|
return fmt.Errorf("music manager not initialized")
|
||||||
|
}
|
||||||
|
gp := getOrCreateGuildPlayer(guildID)
|
||||||
|
|
||||||
|
manager.mu.Lock()
|
||||||
|
idx := -1
|
||||||
|
for i, q := range gp.Queue {
|
||||||
|
if q.QueueID == queueID {
|
||||||
|
idx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if idx == -1 {
|
||||||
|
manager.mu.Unlock()
|
||||||
|
return fmt.Errorf("queue not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if idx == 0 {
|
||||||
|
manager.mu.Unlock()
|
||||||
|
return Skip(guildID)
|
||||||
|
}
|
||||||
|
|
||||||
|
gp.Queue = append(gp.Queue[:idx], gp.Queue[idx+1:]...)
|
||||||
|
manager.mu.Unlock()
|
||||||
|
|
||||||
|
syncState(guildID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Reorder(guildID, queueID string, newPosition int) error {
|
||||||
|
if manager == nil {
|
||||||
|
return fmt.Errorf("music manager not initialized")
|
||||||
|
}
|
||||||
|
gp := getOrCreateGuildPlayer(guildID)
|
||||||
|
|
||||||
|
manager.mu.Lock()
|
||||||
|
idx := -1
|
||||||
|
for i, q := range gp.Queue {
|
||||||
|
if q.QueueID == queueID {
|
||||||
|
idx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if idx == -1 {
|
||||||
|
manager.mu.Unlock()
|
||||||
|
return fmt.Errorf("queue not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if idx == 0 {
|
||||||
|
manager.mu.Unlock()
|
||||||
|
return Skip(guildID)
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := gp.Queue[idx]
|
||||||
|
gp.Queue = append(gp.Queue[:idx], gp.Queue[idx+1:]...)
|
||||||
|
|
||||||
|
pos := max(1, min(newPosition, len(gp.Queue)))
|
||||||
|
gp.Queue = append(gp.Queue[:pos], append([]TrackEntry{entry}, gp.Queue[pos:]...)...)
|
||||||
|
manager.mu.Unlock()
|
||||||
|
|
||||||
|
syncState(guildID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func CurrentNowPlayingMessageID(guildID string) string {
|
func CurrentNowPlayingMessageID(guildID string) string {
|
||||||
if manager == nil {
|
if manager == nil {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
+7
-3
@@ -150,7 +150,9 @@ CREATE TABLE IF NOT EXISTS music_now_playing (
|
|||||||
duration_seconds INT NOT NULL,
|
duration_seconds INT NOT NULL,
|
||||||
started_at TIMESTAMPTZ NOT NULL,
|
started_at TIMESTAMPTZ NOT NULL,
|
||||||
paused BOOLEAN NOT NULL DEFAULT FALSE,
|
paused BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
volume INT NOT NULL DEFAULT 100
|
volume INT NOT NULL DEFAULT 100,
|
||||||
|
repeat_song BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
repeat_queue BOOLEAN NOT NULL DEFAULT FALSE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS music_queue (
|
CREATE TABLE IF NOT EXISTS music_queue (
|
||||||
@@ -160,13 +162,15 @@ CREATE TABLE IF NOT EXISTS music_queue (
|
|||||||
track_title TEXT NOT NULL,
|
track_title TEXT NOT NULL,
|
||||||
track_url TEXT NOT NULL,
|
track_url TEXT NOT NULL,
|
||||||
requested_by BIGINT NOT NULL,
|
requested_by BIGINT NOT NULL,
|
||||||
added_at TIMESTAMPTZ DEFAULT NOW()
|
added_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
client_id UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
UNIQUE (guild_id, client_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS music_commands (
|
CREATE TABLE IF NOT EXISTS music_commands (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
guild_id BIGINT NOT NULL,
|
guild_id BIGINT NOT NULL,
|
||||||
command_type TEXT NOT NULL CHECK (command_type IN ('skip', 'pause', 'resume', 'set_volume', 'remove_track', 'reorder')),
|
command_type TEXT NOT NULL CHECK (command_type IN ('skip', 'pause', 'resume', 'set_volume', 'remove_track', 'reorder', 'repeat_song', 'repeat_queue')),
|
||||||
payload JSONB,
|
payload JSONB,
|
||||||
requested_by BIGINT NOT NULL,
|
requested_by BIGINT NOT NULL,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
|||||||
Reference in New Issue
Block a user