From 59351d54b821f0adff1acc22221e10f5f7d46e83 Mon Sep 17 00:00:00 2001
From: Kevin Lyda <kevin@lyda.ie>
Date: Wed, 21 May 2025 09:09:58 +0100
Subject: [PATCH] Initial broadcast support

Also implemented two more SET commands
---
 repl/command.go                          |  2 +
 repl/repl.go                             |  1 +
 repl/set.go                              | 20 +++--
 storage/broadcast.sql.go                 | 93 ++++++++++++++++++++++++
 storage/display.go                       | 13 ++++
 storage/migrations/1_create_table.up.sql | 27 ++++---
 storage/models.go                        | 26 ++++---
 storage/queries/broadcast.sql            | 17 +++++
 storage/queries/system.sql               |  5 ++
 storage/standard.sql.go                  |  3 +-
 storage/system.sql.go                    | 28 +++++++
 storage/users.sql.go                     |  6 +-
 this/broadcast.go                        | 26 +++++++
 this/this.go                             |  2 +
 14 files changed, 243 insertions(+), 26 deletions(-)
 create mode 100644 storage/broadcast.sql.go
 create mode 100644 storage/queries/broadcast.sql
 create mode 100644 storage/queries/system.sql
 create mode 100644 storage/system.sql.go
 create mode 100644 this/broadcast.go

diff --git a/repl/command.go b/repl/command.go
index 206c523..e6c4488 100644
--- a/repl/command.go
+++ b/repl/command.go
@@ -1254,6 +1254,7 @@ no  default expiration date will be present.  The latter should never be
 specified for a folder or else the messages will disappear.`,
 				MinArgs: 1,
 				MaxArgs: 1,
+				Action:  ActionSetDefaultExpire,
 			},
 			"EXPIRE_LIMIT": {
 				Description: `Specifies expiration limit that  is allowed for messages. Non-privileged
@@ -1268,6 +1269,7 @@ The command SHOW FOLDER/FULL will show  the  expiration  limit,  if  one
 exists.  (NOTE: SHOW FOLDER/FULL is a privileged command.)`,
 				MinArgs: 1,
 				MaxArgs: 1,
+				Action:  ActionSetExpireLimit,
 			},
 			"FOLDER": {
 				Description: `Select a folder of messages.  Identical to the SELECT command.  See help
diff --git a/repl/repl.go b/repl/repl.go
index 6f3e236..f7ea79e 100644
--- a/repl/repl.go
+++ b/repl/repl.go
@@ -57,6 +57,7 @@ func Loop() error {
 	// TODO: END
 
 	for {
+		this.ShowBroadcast()
 		line, err := rl.Readline()
 		if err != nil {
 			if err.Error() == "Interrupt" {
diff --git a/repl/set.go b/repl/set.go
index fc7bc80..ae87712 100644
--- a/repl/set.go
+++ b/repl/set.go
@@ -4,6 +4,7 @@ package repl
 import (
 	"errors"
 	"fmt"
+	"strconv"
 	"strings"
 
 	"git.lyda.ie/kevin/bulletin/dclish"
@@ -67,15 +68,24 @@ func ActionSetNobrief(_ *dclish.Command) error {
 }
 
 // ActionSetDefaultExpire handles the `SET DEFAULT_EXPIRE` command.
-func ActionSetDefaultExpire(_ *dclish.Command) error {
-	fmt.Println("TODO: implement ActionSetDefaultExpire.")
-	return nil
+func ActionSetDefaultExpire(cmd *dclish.Command) error {
+	value, err := strconv.ParseInt(cmd.Args[0], 10, 64)
+	if err != nil {
+		return err
+	}
+	ctx := storage.Context()
+	return this.Q.UpdateDefaultExpire(ctx, value)
 }
 
 // ActionSetExpireLimit handles the `SET EXPIRE_LIMIT` command.
-func ActionSetExpireLimit(_ *dclish.Command) error {
+func ActionSetExpireLimit(cmd *dclish.Command) error {
 	fmt.Println("TODO: implement ActionSetExpireLimit.")
-	return nil
+	value, err := strconv.ParseInt(cmd.Args[0], 10, 64)
+	if err != nil {
+		return err
+	}
+	ctx := storage.Context()
+	return this.Q.UpdateExpireLimit(ctx, value)
 }
 
 // ActionSetFolder handles the `SET FOLDER` command.  This selects a folder.
diff --git a/storage/broadcast.sql.go b/storage/broadcast.sql.go
new file mode 100644
index 0000000..550d63e
--- /dev/null
+++ b/storage/broadcast.sql.go
@@ -0,0 +1,93 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+//   sqlc v1.29.0
+// source: broadcast.sql
+
+package storage
+
+import (
+	"context"
+	"time"
+)
+
+const clearBroadcasts = `-- name: ClearBroadcasts :exec
+DELETE FROM broadcast
+`
+
+func (q *Queries) ClearBroadcasts(ctx context.Context) error {
+	_, err := q.db.ExecContext(ctx, clearBroadcasts)
+	return err
+}
+
+const createBroadcast = `-- name: CreateBroadcast :exec
+INSERT INTO broadcast
+    (author, bell, message)
+  VALUES
+    (?, ?, ?)
+`
+
+type CreateBroadcastParams struct {
+	Author  string
+	Bell    int64
+	Message string
+}
+
+func (q *Queries) CreateBroadcast(ctx context.Context, arg CreateBroadcastParams) error {
+	_, err := q.db.ExecContext(ctx, createBroadcast, arg.Author, arg.Bell, arg.Message)
+	return err
+}
+
+const getBroadcasts = `-- name: GetBroadcasts :many
+SELECT author, bell, message, create_at FROM broadcast WHERE create_at > ? ORDER BY create_at
+`
+
+func (q *Queries) GetBroadcasts(ctx context.Context, createAt time.Time) ([]Broadcast, error) {
+	rows, err := q.db.QueryContext(ctx, getBroadcasts, createAt)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	var items []Broadcast
+	for rows.Next() {
+		var i Broadcast
+		if err := rows.Scan(
+			&i.Author,
+			&i.Bell,
+			&i.Message,
+			&i.CreateAt,
+		); err != nil {
+			return nil, err
+		}
+		items = append(items, i)
+	}
+	if err := rows.Close(); err != nil {
+		return nil, err
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return items, nil
+}
+
+const reapBroadcasts = `-- name: ReapBroadcasts :exec
+DELETE FROM broadcast WHERE julianday(create_at) > 3
+`
+
+func (q *Queries) ReapBroadcasts(ctx context.Context) error {
+	_, err := q.db.ExecContext(ctx, reapBroadcasts)
+	return err
+}
+
+const updateLastBroadcast = `-- name: UpdateLastBroadcast :exec
+UPDATE users SET last_broadcast = ? WHERE login = ?
+`
+
+type UpdateLastBroadcastParams struct {
+	LastBroadcast time.Time
+	Login         string
+}
+
+func (q *Queries) UpdateLastBroadcast(ctx context.Context, arg UpdateLastBroadcastParams) error {
+	_, err := q.db.ExecContext(ctx, updateLastBroadcast, arg.LastBroadcast, arg.Login)
+	return err
+}
diff --git a/storage/display.go b/storage/display.go
index 05f904d..b995426 100644
--- a/storage/display.go
+++ b/storage/display.go
@@ -61,3 +61,16 @@ func (f Folder) String() string {
 		f.Expire,
 		f.Visibility)
 }
+
+// String displays a folder (mainly used for debugging).
+func (b Broadcast) String() string {
+	bell := ""
+	if b.Bell == 1 {
+		bell = "\a\a"
+	}
+	return fmt.Sprintf("%sFrom: %s   %s\n%s\n",
+		bell,
+		b.Author,
+		b.CreateAt.Format("06-01-02 15:04:05"),
+		b.Message)
+}
diff --git a/storage/migrations/1_create_table.up.sql b/storage/migrations/1_create_table.up.sql
index acec6f8..2f066f8 100644
--- a/storage/migrations/1_create_table.up.sql
+++ b/storage/migrations/1_create_table.up.sql
@@ -1,13 +1,14 @@
 CREATE TABLE users (
-  login       VARCHAR(12)  PRIMARY KEY NOT NULL,
-  name        VARCHAR(53)  NOT NULL,
-  admin       INT          DEFAULT 0 NOT NULL,
-  moderator   INT          DEFAULT 0 NOT NULL,
-  alert       INT          DEFAULT 0 NOT NULL,  --- 0=no, 1=brief, 2=readnew
-  disabled    INT          DEFAULT 0 NOT NULL,
-  last_login  TIMESTAMP    DEFAULT CURRENT_TIMESTAMP NOT NULL,
-  create_at   TIMESTAMP    DEFAULT CURRENT_TIMESTAMP NOT NULL,
-  update_at   TIMESTAMP    DEFAULT CURRENT_TIMESTAMP NOT NULL
+  login          VARCHAR(12)  PRIMARY KEY NOT NULL,
+  name           VARCHAR(53)  NOT NULL,
+  admin          INT          DEFAULT 0 NOT NULL,
+  moderator      INT          DEFAULT 0 NOT NULL,
+  alert          INT          DEFAULT 0 NOT NULL,  --- 0=no, 1=brief, 2=readnew
+  disabled       INT          DEFAULT 0 NOT NULL,
+  last_broadcast TIMESTAMP    DEFAULT CURRENT_TIMESTAMP NOT NULL,
+  last_login     TIMESTAMP    DEFAULT CURRENT_TIMESTAMP NOT NULL,
+  create_at      TIMESTAMP    DEFAULT CURRENT_TIMESTAMP NOT NULL,
+  update_at      TIMESTAMP    DEFAULT CURRENT_TIMESTAMP NOT NULL
 ) WITHOUT ROWID;
 
 CREATE TRIGGER users_after_update_update_at
@@ -182,3 +183,11 @@ CREATE TABLE system (
   default_expire  INT          DEFAULT -1 NOT NULL,
   expire_limit    INT          DEFAULT -1 NOT NULL
 );
+
+--- Broadcast messages.
+CREATE TABLE broadcast (
+  author          VARCHAR(12)  PRIMARY KEY NOT NULL,
+  bell            INT          DEFAULT 0 NOT NULL,
+  message         TEXT         NOT NULL,
+  create_at       TIMESTAMP    DEFAULT CURRENT_TIMESTAMP NOT NULL
+);
diff --git a/storage/models.go b/storage/models.go
index d0089ad..1292885 100644
--- a/storage/models.go
+++ b/storage/models.go
@@ -8,6 +8,13 @@ import (
 	"time"
 )
 
+type Broadcast struct {
+	Author   string
+	Bell     int64
+	Message  string
+	CreateAt time.Time
+}
+
 type Folder struct {
 	Name        string
 	Always      int64
@@ -75,13 +82,14 @@ type System struct {
 }
 
 type User struct {
-	Login     string
-	Name      string
-	Admin     int64
-	Moderator int64
-	Alert     int64
-	Disabled  int64
-	LastLogin time.Time
-	CreateAt  time.Time
-	UpdateAt  time.Time
+	Login         string
+	Name          string
+	Admin         int64
+	Moderator     int64
+	Alert         int64
+	Disabled      int64
+	LastBroadcast time.Time
+	LastLogin     time.Time
+	CreateAt      time.Time
+	UpdateAt      time.Time
 }
diff --git a/storage/queries/broadcast.sql b/storage/queries/broadcast.sql
new file mode 100644
index 0000000..4b5dceb
--- /dev/null
+++ b/storage/queries/broadcast.sql
@@ -0,0 +1,17 @@
+-- name: CreateBroadcast :exec
+INSERT INTO broadcast
+    (author, bell, message)
+  VALUES
+    (?, ?, ?);
+
+-- name: GetBroadcasts :many
+SELECT * FROM broadcast WHERE create_at > ? ORDER BY create_at;
+
+-- name: UpdateLastBroadcast :exec
+UPDATE users SET last_broadcast = ? WHERE login = ?;
+
+-- name: ClearBroadcasts :exec
+DELETE FROM broadcast;
+
+-- name: ReapBroadcasts :exec
+DELETE FROM broadcast WHERE julianday(create_at) > 3;
diff --git a/storage/queries/system.sql b/storage/queries/system.sql
new file mode 100644
index 0000000..1d901a6
--- /dev/null
+++ b/storage/queries/system.sql
@@ -0,0 +1,5 @@
+-- name: UpdateDefaultExpire :exec
+UPDATE system SET default_expire = ? WHERE rowid = 1;
+
+-- name: UpdateExpireLimit :exec
+UPDATE system SET expire_limit = ? WHERE rowid = 1;
diff --git a/storage/standard.sql.go b/storage/standard.sql.go
index f6c4615..01d0e14 100644
--- a/storage/standard.sql.go
+++ b/storage/standard.sql.go
@@ -660,7 +660,7 @@ func (q *Queries) ListSeen(ctx context.Context) ([]Seen, error) {
 }
 
 const listUsers = `-- name: ListUsers :many
-SELECT login, name, admin, moderator, alert, disabled, last_login, create_at, update_at FROM users
+SELECT login, name, admin, moderator, alert, disabled, last_broadcast, last_login, create_at, update_at FROM users
 `
 
 func (q *Queries) ListUsers(ctx context.Context) ([]User, error) {
@@ -679,6 +679,7 @@ func (q *Queries) ListUsers(ctx context.Context) ([]User, error) {
 			&i.Moderator,
 			&i.Alert,
 			&i.Disabled,
+			&i.LastBroadcast,
 			&i.LastLogin,
 			&i.CreateAt,
 			&i.UpdateAt,
diff --git a/storage/system.sql.go b/storage/system.sql.go
new file mode 100644
index 0000000..920642d
--- /dev/null
+++ b/storage/system.sql.go
@@ -0,0 +1,28 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+//   sqlc v1.29.0
+// source: system.sql
+
+package storage
+
+import (
+	"context"
+)
+
+const updateDefaultExpire = `-- name: UpdateDefaultExpire :exec
+UPDATE system SET default_expire = ? WHERE rowid = 1
+`
+
+func (q *Queries) UpdateDefaultExpire(ctx context.Context, defaultExpire int64) error {
+	_, err := q.db.ExecContext(ctx, updateDefaultExpire, defaultExpire)
+	return err
+}
+
+const updateExpireLimit = `-- name: UpdateExpireLimit :exec
+UPDATE system SET expire_limit = ? WHERE rowid = 1
+`
+
+func (q *Queries) UpdateExpireLimit(ctx context.Context, expireLimit int64) error {
+	_, err := q.db.ExecContext(ctx, updateExpireLimit, expireLimit)
+	return err
+}
diff --git a/storage/users.sql.go b/storage/users.sql.go
index 75f923c..613b4c1 100644
--- a/storage/users.sql.go
+++ b/storage/users.sql.go
@@ -12,7 +12,7 @@ import (
 
 const addUser = `-- name: AddUser :one
 INSERT INTO users (login, name, admin, disabled, last_login) VALUES (?, ?, ?, ?, ?)
-RETURNING login, name, admin, moderator, alert, disabled, last_login, create_at, update_at
+RETURNING login, name, admin, moderator, alert, disabled, last_broadcast, last_login, create_at, update_at
 `
 
 type AddUserParams struct {
@@ -39,6 +39,7 @@ func (q *Queries) AddUser(ctx context.Context, arg AddUserParams) (User, error)
 		&i.Moderator,
 		&i.Alert,
 		&i.Disabled,
+		&i.LastBroadcast,
 		&i.LastLogin,
 		&i.CreateAt,
 		&i.UpdateAt,
@@ -127,7 +128,7 @@ func (q *Queries) GetLastLoginByLogin(ctx context.Context, login string) (GetLas
 }
 
 const getUser = `-- name: GetUser :one
-SELECT login, name, admin, moderator, alert, disabled, last_login, create_at, update_at FROM users WHERE login = ?
+SELECT login, name, admin, moderator, alert, disabled, last_broadcast, last_login, create_at, update_at FROM users WHERE login = ?
 `
 
 func (q *Queries) GetUser(ctx context.Context, login string) (User, error) {
@@ -140,6 +141,7 @@ func (q *Queries) GetUser(ctx context.Context, login string) (User, error) {
 		&i.Moderator,
 		&i.Alert,
 		&i.Disabled,
+		&i.LastBroadcast,
 		&i.LastLogin,
 		&i.CreateAt,
 		&i.UpdateAt,
diff --git a/this/broadcast.go b/this/broadcast.go
new file mode 100644
index 0000000..5682ab7
--- /dev/null
+++ b/this/broadcast.go
@@ -0,0 +1,26 @@
+package this
+
+import (
+	"fmt"
+	"time"
+
+	"git.lyda.ie/kevin/bulletin/storage"
+)
+
+// ShowBroadcast print broadcast messages.
+func ShowBroadcast() {
+	ctx := storage.Context()
+	User.LastBroadcast = time.Now()
+	msgs, _ := Q.GetBroadcasts(ctx, User.LastBroadcast)
+	if len(msgs) > 0 {
+		fmt.Println("BROADCAST MSG START")
+		for i := range msgs {
+			fmt.Printf("\n%s\n", msgs[i])
+		}
+		fmt.Println("BROADCAST MSG END")
+		Q.UpdateLastBroadcast(ctx, storage.UpdateLastBroadcastParams{
+			LastBroadcast: User.LastBroadcast,
+			Login:         User.Login,
+		})
+	}
+}
diff --git a/this/this.go b/this/this.go
index 20f44b2..7cc73fa 100644
--- a/this/this.go
+++ b/this/this.go
@@ -100,10 +100,12 @@ func StartThis(login string) error {
 	if User.Disabled == 1 {
 		return errors.New("User is disabled")
 	}
+
 	Folder, err = Q.GetFolder(ctx, "GENERAL")
 	if err != nil {
 		return err
 	}
+
 	ReadFirstCall = true
 	MsgID, err = Q.NextMsgid(ctx, storage.NextMsgidParams{
 		Folder: Folder.Name,
-- 
GitLab