From 718bc4df9383a78cd51bafe25f5b21ca4afe37fe Mon Sep 17 00:00:00 2001
From: Kevin Lyda <kevin@lyda.ie>
Date: Fri, 23 May 2025 13:01:56 +0100
Subject: [PATCH] A bunch of fixes

Several commands fixed.  A single owner for a folder.  Some
TODOs removed.  More permission checking.
---
 NOTES.md                                 |   1 +
 batch/batch.go                           |   1 -
 folders/folders.go                       |  53 +++++-----
 folders/messages.go                      |   7 +-
 repl/command.go                          |  13 +--
 repl/folders.go                          |  41 ++++++--
 repl/messages.go                         |   4 +-
 repl/set.go                              |  37 ++-----
 repl/show.go                             |  10 +-
 storage/broadcast.sql.go                 |  14 +--
 storage/display.go                       |   3 +-
 storage/folders.sql.go                   |  90 ++++++++++------
 storage/messages.sql.go                  |  97 ++++++++++++++++++
 storage/migrations/1_create_table.up.sql |  24 +----
 storage/models.go                        |  30 +++---
 storage/queries/broadcast.sql            |   4 +-
 storage/queries/folders.sql              |  20 ++--
 storage/queries/messages.sql             |  11 ++
 storage/queries/seed.sql                 |   7 +-
 storage/queries/standard.sql             |  15 ---
 storage/queries/system.sql               |   3 +
 storage/queries/users.sql                |   5 +-
 storage/seed.sql.go                      |  13 +--
 storage/standard.sql.go                  | 125 ++---------------------
 storage/system.sql.go                    |  16 +++
 storage/users.sql.go                     |  24 +++--
 this/alert.go                            |  40 ++++++++
 this/broadcast.go                        |  26 -----
 this/this.go                             |   2 -
 29 files changed, 374 insertions(+), 362 deletions(-)
 create mode 100644 this/alert.go
 delete mode 100644 this/broadcast.go

diff --git a/NOTES.md b/NOTES.md
index cf4dc6f..3841447 100644
--- a/NOTES.md
+++ b/NOTES.md
@@ -42,6 +42,7 @@ Switch between MAIL and BULLETIN modes? MAIL commands are documented
     * Review sql queries and clean out the ones not used.
     * Review sql queries and find duplicates.
     * Use [dupl](https://github.com/mibk/dupl) to find things to generalise.
+  * flag abbreviations with values don't seem to work?
 
 Polishing.  Review each command and put a + next to each as it is
 fully done.
diff --git a/batch/batch.go b/batch/batch.go
index 1a9c266..f7b2ece 100644
--- a/batch/batch.go
+++ b/batch/batch.go
@@ -108,7 +108,6 @@ the first user.`)
 	ask.CheckErr(err)
 	ask.CheckErr(q.SeedUserSystem(ctx))
 	ask.CheckErr(q.SeedFolderGeneral(ctx))
-	ask.CheckErr(q.SeedGeneralOwner(ctx))
 	_, err = q.AddUser(ctx, storage.AddUserParams{
 		Login: login,
 		Name:  name,
diff --git a/folders/folders.go b/folders/folders.go
index d05a5e6..b4b9184 100644
--- a/folders/folders.go
+++ b/folders/folders.go
@@ -11,14 +11,14 @@ import (
 
 // ValidFolder validates the folder name for this user.
 func ValidFolder(folder string) (storage.Folder, error) {
-	if strings.Contains(folder, "%") {
-		return storage.Folder{}, errors.New("Folder name cannot contain a %")
+	if !IsAlphaNum(folder) {
+		return storage.Folder{}, errors.New("Folder can only have letters and numbers")
 	}
 	correct := FindFolder(folder)
 	if correct.Name == "" {
 		return storage.Folder{}, errors.New("Unable to select the folder")
 	}
-	if !IsFolderAccess(correct.Name, this.User.Login) {
+	if !IsFolderReadable(correct.Name, this.User.Login) {
 		// TODO: Should be:
 		//       WRITE(6,'('' You are not allowed to access folder.'')')
 		//       WRITE(6,'('' See '',A,'' if you wish to access folder.'')')
@@ -35,33 +35,18 @@ const (
 )
 
 // CreateFolder creates a new folder.
-func CreateFolder(owner string, options storage.CreateFolderParams) error {
+func CreateFolder(options storage.CreateFolderParams) error {
 	if !IsAlphaNum(options.Name) {
 		return errors.New("Folder can only have letters and numbers")
 	}
 	options.Name = strings.ToUpper(options.Name)
 
 	ctx := storage.Context()
-	tx, err := this.Store.Begin()
+	err := this.Q.CreateFolder(ctx, options)
 	if err != nil {
 		return err
 	}
-	defer tx.Rollback()
-	qtx := this.Q.WithTx(tx)
-	err = qtx.CreateFolder(ctx, options)
-	if err != nil {
-		return err
-	}
-	err = qtx.AddFolderOwner(ctx, storage.AddFolderOwnerParams{
-		Folder: options.Name,
-		Login:  owner,
-	})
-	if err != nil {
-		return err
-	}
-
-	// TODO: process this error a bit more to give a better error message.
-	return tx.Commit()
+	return err
 }
 
 // ListFolder provides a list of folders that this.User has access to.
@@ -87,16 +72,30 @@ func FindFolder(name string) storage.Folder {
 	return folder
 }
 
-// IsFolderAccess checks if a user can access a folder.
-func IsFolderAccess(name, login string) bool {
+// IsFolderReadable checks if a user can read messages from a folder.
+func IsFolderReadable(name, login string) bool {
+	ctx := storage.Context()
+	admin, _ := this.Q.IsUserAdmin(ctx, login)
+	if admin == 1 {
+		return true
+	}
+	found, _ := this.Q.IsFolderReadable(ctx, storage.IsFolderReadableParams{
+		Name:  name,
+		Owner: login,
+	})
+	return found == 1
+}
+
+// IsFolderWriteable checks if a user can write messages into a folder.
+func IsFolderWriteable(name, login string) bool {
 	ctx := storage.Context()
 	admin, _ := this.Q.IsUserAdmin(ctx, login)
 	if admin == 1 {
 		return true
 	}
-	found, _ := this.Q.IsFolderAccess(ctx, storage.IsFolderAccessParams{
+	found, _ := this.Q.IsFolderWriteable(ctx, storage.IsFolderWriteableParams{
 		Name:  name,
-		Login: login,
+		Owner: login,
 	})
 	return found == 1
 }
@@ -109,8 +108,8 @@ func IsFolderOwner(folder, login string) bool {
 		return true
 	}
 	found, _ := this.Q.IsFolderOwner(ctx, storage.IsFolderOwnerParams{
-		Folder: folder,
-		Login:  login,
+		Name:  folder,
+		Owner: login,
 	})
 	return found == 1
 }
diff --git a/folders/messages.go b/folders/messages.go
index 8450cf4..ce002ce 100644
--- a/folders/messages.go
+++ b/folders/messages.go
@@ -17,9 +17,11 @@ func CreateMessage(author, subject, message, folder string, permanent, shutdown
 		if err != nil {
 			return err
 		}
+		sysdef, err := this.Q.GetExpire(ctx)
 		if days <= 0 {
-			// TODO: Get from site config.
-			days = 14
+			days = sysdef.DefaultExpire
+		} else {
+			days = min(days, sysdef.ExpireLimit)
 		}
 		exp := time.Now().AddDate(0, 0, int(days))
 		expiration = &exp
@@ -34,7 +36,6 @@ func CreateMessage(author, subject, message, folder string, permanent, shutdown
 		Shutdown:   int64(shutdown),
 		Expiration: *expiration,
 	})
-	// TODO: process this error a bit more to give a better error message.
 	return err
 }
 
diff --git a/repl/command.go b/repl/command.go
index e4b9031..e86544a 100644
--- a/repl/command.go
+++ b/repl/command.go
@@ -1132,11 +1132,11 @@ characteristics of the BULLETIN Utility.
 
 The following options are available:
 
-  ACCESS           ALWAYS           BRIEF            DEFAULT_EXPIRE
-  EXPIRE_LIMIT     FOLDER           NOALWAYS         NOBRIEF
-  NONOTIFY         NOPROMPT_EXPIRE  NOREADNEW        NOSHOWNEW
-  NOSYSTEM         NOTIFY           PROMPT_EXPIRE    READNEW
-  SHOWNEW          SYSTEM
+  ALWAYS           BRIEF            DEFAULT_EXPIRE   EXPIRE_LIMIT
+  FOLDER           NOALWAYS         NOBRIEF          NONOTIFY
+	NOPROMPT_EXPIRE  NOREADNEW        NOSHOWNEW        NOSYSTEM
+	NOTIFY           PROMPT_EXPIRE    READNEW          SHOWNEW
+	SYSTEM
 `,
 		Action: ActionSet,
 		Commands: dclish.Commands{
@@ -1254,7 +1254,8 @@ on that command for more information.
   Format:
     SET FOLDER [folder-name]`,
 				MaxArgs: 1,
-				Action:  ActionSetFolder,
+				// This is an alias for SELECT so...
+				Action: ActionSelect,
 				Flags: dclish.Flags{
 					"/MARKED": {
 						Description: `  Selects messages that have been marked (indicated by an asterisk). After
diff --git a/repl/folders.go b/repl/folders.go
index c8c1e5b..3cd7d35 100644
--- a/repl/folders.go
+++ b/repl/folders.go
@@ -78,11 +78,13 @@ func ActionCreate(cmd *dclish.Command) error {
 		options.Visibility = folders.FolderSemiPrivate
 	}
 
-	var owner string
 	if cmd.Flags["/OWNER"].Value != "" {
-		owner = cmd.Flags["/OWNER"].Value
+		if this.User.Admin == 0 {
+			return errors.New("Must be admin to specify /OWNER")
+		}
+		options.Owner = cmd.Flags["/OWNER"].Value
 	} else {
-		owner = this.User.Login
+		options.Owner = this.User.Login
 	}
 
 	// Verify options...
@@ -96,7 +98,7 @@ func ActionCreate(cmd *dclish.Command) error {
 	if options.Description == "" || len(options.Description) > 53 {
 		return errors.New("Description must exist and be under 53 characters")
 	}
-	err := folders.CreateFolder(owner, options)
+	err := folders.CreateFolder(options)
 	return err
 }
 
@@ -109,7 +111,7 @@ func ActionSelect(cmd *dclish.Command) error {
 	if folder.Name == "" {
 		return errors.New("Unable to select the folder")
 	}
-	if folders.IsFolderAccess(folder.Name, this.User.Login) {
+	if folders.IsFolderReadable(folder.Name, this.User.Login) {
 		this.Folder = folder
 		this.ReadFirstCall = true
 		fmt.Printf("Folder has been set to '%s'.\n", folder.Name)
@@ -123,8 +125,33 @@ func ActionSelect(cmd *dclish.Command) error {
 
 // ActionModify handles the `MODIFY` command.  This modifies a folder.
 func ActionModify(cmd *dclish.Command) error {
-	fmt.Printf("TODO: implement MODIFY:\n%s\n\n", cmd.Description)
-	return nil
+	if this.User.Login != this.Folder.Owner && this.User.Admin == 0 {
+		return errors.New("Must be folder owner or admin to modify the folder")
+	}
+	description := this.Folder.Description
+	if cmd.Flags["/DESCRIPTION"].Set {
+		description = cmd.Flags["/DESCRIPTION"].Value
+	}
+	owner := this.Folder.Owner
+	if cmd.Flags["/OWNER"].Set {
+		owner = cmd.Flags["/OWNER"].Value
+	}
+	name := this.Folder.Name
+	if cmd.Flags["/NAME"].Set {
+		name = cmd.Flags["/NAME"].Value
+		if !folders.IsAlphaNum(name) {
+			return errors.New("Invalid folder name")
+		}
+	}
+
+	ctx := storage.Context()
+	err := this.Q.UpdateFolderMain(ctx, storage.UpdateFolderMainParams{
+		Description: description,
+		Owner:       owner,
+		NewName:     name,
+		OldName:     this.Folder.Name,
+	})
+	return err
 }
 
 // ActionRemove handles the `REMOVE` command.  This modifies a folder.
diff --git a/repl/messages.go b/repl/messages.go
index b3e58b9..0e7baa3 100644
--- a/repl/messages.go
+++ b/repl/messages.go
@@ -31,7 +31,7 @@ func ActionDirectory(cmd *dclish.Command) error {
 			fmt.Println("Folder does not exist.")
 			return nil
 		}
-		if !folders.IsFolderAccess(folder.Name, this.User.Login) {
+		if !folders.IsFolderReadable(folder.Name, this.User.Login) {
 			fmt.Println("No permission to access folder.")
 			return nil
 		}
@@ -652,7 +652,7 @@ func ActionSearch(cmd *dclish.Command) error {
 			if folder.Name != "" {
 				return fmt.Errorf("Folder '%s' not found", optFolders[i])
 			}
-			if folders.IsFolderAccess(optFolders[i], this.User.Login) {
+			if !folders.IsFolderReadable(optFolders[i], this.User.Login) {
 				return fmt.Errorf("Folder '%s' is not accessible", optFolders[i])
 			}
 		}
diff --git a/repl/set.go b/repl/set.go
index 3cd9b8a..5222acb 100644
--- a/repl/set.go
+++ b/repl/set.go
@@ -5,7 +5,6 @@ import (
 	"errors"
 	"fmt"
 	"strconv"
-	"strings"
 
 	"git.lyda.ie/kevin/bulletin/dclish"
 	"git.lyda.ie/kevin/bulletin/folders"
@@ -49,8 +48,9 @@ func setAlert(cmd *dclish.Command, alert int64) error {
 		})
 	}
 	return this.Q.UpdateUserAlert(ctx, storage.UpdateUserAlertParams{
-		Alert: alert,
-		Login: this.User.Login,
+		Login:  this.User.Login,
+		Folder: folder.Name,
+		Alert:  alert,
 	})
 }
 
@@ -122,31 +122,6 @@ func ActionSetExpireLimit(cmd *dclish.Command) error {
 	return this.Q.UpdateExpireLimit(ctx, value)
 }
 
-// ActionSetFolder handles the `SET FOLDER` command.  This selects a folder.
-func ActionSetFolder(cmd *dclish.Command) error {
-	if len(cmd.Args) != 1 {
-		return errors.New("TODO: need to add code for /MARK")
-	}
-	// TODO: Move shared code here and in ActionSelect and ActionDirectory into a function.
-	if strings.Contains(cmd.Args[0], "%") {
-		return errors.New("Folder name cannot contain a %")
-	}
-	folder := folders.FindFolder(cmd.Args[0])
-	if folder.Name == "" {
-		return errors.New("Unable to select the folder")
-	}
-	if folders.IsFolderAccess(folder.Name, this.User.Login) {
-		this.Folder = folder
-		fmt.Printf("TODO: getting message %d.\n", this.MsgID)
-		fmt.Printf("Folder has been set to '%s'.\n", folder.Name)
-		return nil
-	}
-	// TODO: Should be:
-	//       WRITE(6,'('' You are not allowed to access folder.'')')
-	//       WRITE(6,'('' See '',A,'' if you wish to access folder.'')')
-	return errors.New("Unable to select the folder")
-}
-
 // ActionSetNotify handles the `SET NOTIFY` command.
 func ActionSetNotify(_ *dclish.Command) error {
 	// TODO: parse flags and args.
@@ -211,6 +186,9 @@ func ActionSetNoShowNew(cmd *dclish.Command) error {
 
 // ActionSetSystem handles the `SET SYSTEM` command.
 func ActionSetSystem(_ *dclish.Command) error {
+	if this.User.Admin == 0 {
+		return errors.New("You are not an admin")
+	}
 	// TODO: parse flags and args.
 	ctx := storage.Context()
 	this.Q.UpdateFolderSystem(ctx, storage.UpdateFolderSystemParams{
@@ -222,6 +200,9 @@ func ActionSetSystem(_ *dclish.Command) error {
 
 // ActionSetNosystem handles the `SET SYSTEM` command.
 func ActionSetNosystem(_ *dclish.Command) error {
+	if this.User.Admin == 0 {
+		return errors.New("You are not an admin")
+	}
 	// TODO: parse flags and args.
 	ctx := storage.Context()
 	this.Q.UpdateFolderSystem(ctx, storage.UpdateFolderSystemParams{
diff --git a/repl/show.go b/repl/show.go
index fdcd1c0..17aaaa8 100644
--- a/repl/show.go
+++ b/repl/show.go
@@ -41,8 +41,6 @@ func ActionShowFlags(_ *dclish.Command) error {
 
 // ActionShowFolder handles the `SHOW FOLDER` command.
 func ActionShowFolder(cmd *dclish.Command) error {
-	ctx := storage.Context()
-
 	folder := this.Folder
 	if len(cmd.Args) == 1 {
 		folder = folders.FindFolder(cmd.Args[0])
@@ -50,11 +48,6 @@ func ActionShowFolder(cmd *dclish.Command) error {
 	if folder.Name == "" {
 		fmt.Println("ERROR: Specified folder was not found.")
 	}
-	owners, err := this.Q.GetOwners(ctx, folder.Name)
-	if err != nil || len(owners) == 0 {
-		fmt.Printf("ERROR: This folder seems to lack owners (%s).\n", err)
-		return nil
-	}
 
 	full := false
 	if cmd.Flags["/FULL"].Value == "true" {
@@ -64,7 +57,7 @@ func ActionShowFolder(cmd *dclish.Command) error {
 		}
 		full = true
 	}
-	fmt.Printf("Settings for %s.\n", folder.Name)
+	fmt.Printf("Settings for %s (%s).\n", folder.Name, folder.Owner)
 	if full {
 		switch folder.Visibility {
 		case folders.FolderPublic:
@@ -112,7 +105,6 @@ func ActionShowPrivileges(_ *dclish.Command) error {
 	fmt.Printf("Account privileges for %s:\n", this.User.Login)
 	fmt.Printf("  Admin     : %s\n", ask.YesNo(this.User.Admin))
 	fmt.Printf("  Moderator : %s\n", ask.YesNo(this.User.Moderator))
-	fmt.Printf("  Alert     : %s\n", ask.YesNo(this.User.Alert))
 	return nil
 }
 
diff --git a/storage/broadcast.sql.go b/storage/broadcast.sql.go
index 550d63e..36e0498 100644
--- a/storage/broadcast.sql.go
+++ b/storage/broadcast.sql.go
@@ -78,16 +78,16 @@ func (q *Queries) ReapBroadcasts(ctx context.Context) error {
 	return err
 }
 
-const updateLastBroadcast = `-- name: UpdateLastBroadcast :exec
-UPDATE users SET last_broadcast = ? WHERE login = ?
+const updateLastActivity = `-- name: UpdateLastActivity :exec
+UPDATE users SET last_activity = ? WHERE login = ?
 `
 
-type UpdateLastBroadcastParams struct {
-	LastBroadcast time.Time
-	Login         string
+type UpdateLastActivityParams struct {
+	LastActivity time.Time
+	Login        string
 }
 
-func (q *Queries) UpdateLastBroadcast(ctx context.Context, arg UpdateLastBroadcastParams) error {
-	_, err := q.db.ExecContext(ctx, updateLastBroadcast, arg.LastBroadcast, arg.Login)
+func (q *Queries) UpdateLastActivity(ctx context.Context, arg UpdateLastActivityParams) error {
+	_, err := q.db.ExecContext(ctx, updateLastActivity, arg.LastActivity, arg.Login)
 	return err
 }
diff --git a/storage/display.go b/storage/display.go
index 607b781..654e98b 100644
--- a/storage/display.go
+++ b/storage/display.go
@@ -79,12 +79,11 @@ func (m *Message) OneLine(expire bool) string {
 
 // String displays a user (mainly used for debugging).
 func (u User) String() string {
-	return fmt.Sprintf("%-12s %-25.25s [a%d, m%d, !%d, d%d, p%d] [%s]",
+	return fmt.Sprintf("%-12s %-25.25s [a%d, m%d, d%d, p%d] [%s]",
 		u.Login,
 		u.Name,
 		u.Admin,
 		u.Moderator,
-		u.Alert,
 		u.Disabled,
 		u.Prompt,
 		u.LastLogin.Format("06-01-02 15:04:05"))
diff --git a/storage/folders.sql.go b/storage/folders.sql.go
index c24ab00..374acf4 100644
--- a/storage/folders.sql.go
+++ b/storage/folders.sql.go
@@ -9,25 +9,11 @@ import (
 	"context"
 )
 
-const addFolderOwner = `-- name: AddFolderOwner :exec
-INSERT INTO owners (folder, login) VALUES (?, ?)
-`
-
-type AddFolderOwnerParams struct {
-	Folder string
-	Login  string
-}
-
-func (q *Queries) AddFolderOwner(ctx context.Context, arg AddFolderOwnerParams) error {
-	_, err := q.db.ExecContext(ctx, addFolderOwner, arg.Folder, arg.Login)
-	return err
-}
-
 const createFolder = `-- name: CreateFolder :exec
 INSERT INTO folders
-    (name, always, alert, description, notify, system, expire, visibility)
+    (name, always, alert, description, owner, notify, system, expire, visibility)
   VALUES
-    (?, ?, ?, ?, ?, ?, ?, ?)
+    (?, ?, ?, ?, ?, ?, ?, ?, ?)
 `
 
 type CreateFolderParams struct {
@@ -35,6 +21,7 @@ type CreateFolderParams struct {
 	Always      int64
 	Alert       int64
 	Description string
+	Owner       string
 	Notify      int64
 	System      int64
 	Expire      int64
@@ -47,6 +34,7 @@ func (q *Queries) CreateFolder(ctx context.Context, arg CreateFolderParams) erro
 		arg.Always,
 		arg.Alert,
 		arg.Description,
+		arg.Owner,
 		arg.Notify,
 		arg.System,
 		arg.Expire,
@@ -56,7 +44,7 @@ func (q *Queries) CreateFolder(ctx context.Context, arg CreateFolderParams) erro
 }
 
 const findFolderExact = `-- name: FindFolderExact :one
-SELECT name, "always", alert, description, notify, system, expire, visibility, create_at, update_at FROM folders where name = ?
+SELECT name, "always", alert, description, owner, notify, system, expire, visibility, create_at, update_at FROM folders where name = ?
 `
 
 func (q *Queries) FindFolderExact(ctx context.Context, name string) (Folder, error) {
@@ -67,6 +55,7 @@ func (q *Queries) FindFolderExact(ctx context.Context, name string) (Folder, err
 		&i.Always,
 		&i.Alert,
 		&i.Description,
+		&i.Owner,
 		&i.Notify,
 		&i.System,
 		&i.Expire,
@@ -78,7 +67,7 @@ func (q *Queries) FindFolderExact(ctx context.Context, name string) (Folder, err
 }
 
 const findFolderPrefix = `-- name: FindFolderPrefix :one
-SELECT name, "always", alert, description, notify, system, expire, visibility, create_at, update_at FROM folders where name LIKE ?
+SELECT name, "always", alert, description, owner, notify, system, expire, visibility, create_at, update_at FROM folders where name LIKE ?
 ORDER BY name
 LIMIT 1
 `
@@ -91,6 +80,7 @@ func (q *Queries) FindFolderPrefix(ctx context.Context, name string) (Folder, er
 		&i.Always,
 		&i.Alert,
 		&i.Description,
+		&i.Owner,
 		&i.Notify,
 		&i.System,
 		&i.Expire,
@@ -112,34 +102,49 @@ func (q *Queries) GetFolderExpire(ctx context.Context, name string) (int64, erro
 	return expire, err
 }
 
-const isFolderAccess = `-- name: IsFolderAccess :one
-SELECT 1 FROM folders AS f LEFT JOIN owners AS o ON f.name = c.folder
-  WHERE f.name = ? AND (f.visibility = 0 OR o.login = ?)
+const isFolderOwner = `-- name: IsFolderOwner :one
+SELECT 1 FROM folders WHERE name = ? AND owner = ?
 `
 
-type IsFolderAccessParams struct {
+type IsFolderOwnerParams struct {
 	Name  string
-	Login string
+	Owner string
 }
 
-func (q *Queries) IsFolderAccess(ctx context.Context, arg IsFolderAccessParams) (int64, error) {
-	row := q.db.QueryRowContext(ctx, isFolderAccess, arg.Name, arg.Login)
+func (q *Queries) IsFolderOwner(ctx context.Context, arg IsFolderOwnerParams) (int64, error) {
+	row := q.db.QueryRowContext(ctx, isFolderOwner, arg.Name, arg.Owner)
 	var column_1 int64
 	err := row.Scan(&column_1)
 	return column_1, err
 }
 
-const isFolderOwner = `-- name: IsFolderOwner :one
-SELECT 1 FROM owners WHERE folder = ? AND login = ?
+const isFolderReadable = `-- name: IsFolderReadable :one
+SELECT 1 FROM folders WHERE name = ? AND (visibility <= 1 OR owner = ?)
 `
 
-type IsFolderOwnerParams struct {
-	Folder string
-	Login  string
+type IsFolderReadableParams struct {
+	Name  string
+	Owner string
 }
 
-func (q *Queries) IsFolderOwner(ctx context.Context, arg IsFolderOwnerParams) (int64, error) {
-	row := q.db.QueryRowContext(ctx, isFolderOwner, arg.Folder, arg.Login)
+func (q *Queries) IsFolderReadable(ctx context.Context, arg IsFolderReadableParams) (int64, error) {
+	row := q.db.QueryRowContext(ctx, isFolderReadable, arg.Name, arg.Owner)
+	var column_1 int64
+	err := row.Scan(&column_1)
+	return column_1, err
+}
+
+const isFolderWriteable = `-- name: IsFolderWriteable :one
+SELECT 1 FROM folders WHERE name = ? AND (visibility = 0 OR owner = ?)
+`
+
+type IsFolderWriteableParams struct {
+	Name  string
+	Owner string
+}
+
+func (q *Queries) IsFolderWriteable(ctx context.Context, arg IsFolderWriteableParams) (int64, error) {
+	row := q.db.QueryRowContext(ctx, isFolderWriteable, arg.Name, arg.Owner)
 	var column_1 int64
 	err := row.Scan(&column_1)
 	return column_1, err
@@ -259,6 +264,27 @@ func (q *Queries) UpdateFolderExpire(ctx context.Context, arg UpdateFolderExpire
 	return err
 }
 
+const updateFolderMain = `-- name: UpdateFolderMain :exec
+UPDATE folders SET description = ?, owner = ?, name = ?3  WHERE name = ?4
+`
+
+type UpdateFolderMainParams struct {
+	Description string
+	Owner       string
+	NewName     string
+	OldName     string
+}
+
+func (q *Queries) UpdateFolderMain(ctx context.Context, arg UpdateFolderMainParams) error {
+	_, err := q.db.ExecContext(ctx, updateFolderMain,
+		arg.Description,
+		arg.Owner,
+		arg.NewName,
+		arg.OldName,
+	)
+	return err
+}
+
 const updateFolderNotify = `-- name: UpdateFolderNotify :exec
 UPDATE folders SET notify = ? WHERE name = ?
 `
diff --git a/storage/messages.sql.go b/storage/messages.sql.go
index 3e46406..0d6de1f 100644
--- a/storage/messages.sql.go
+++ b/storage/messages.sql.go
@@ -51,6 +51,64 @@ func (q *Queries) DeleteAllMessages(ctx context.Context, folder string) error {
 	return err
 }
 
+const getAlertMessages = `-- name: GetAlertMessages :many
+SELECT f.alert AS folder_alert, fc.alert AS user_alert, m.id, m.folder, m.author, m.subject, m.message, m.permanent, m.system, m.shutdown, m.expiration, m.create_at, m.update_at FROM messages AS m
+  LEFT JOIN folder_configs AS fc ON m.folder = fc.folder
+  LEFT JOIN folders AS f ON m.folder = f.name
+  WHERE m.create_at >= ?
+    AND fc.login = ?
+    AND (f.alert > 3 OR fc.alert > 0 OR (f.alert > 0 AND fc.alert IS NULL))
+  ORDER BY m.folder, m.id
+`
+
+type GetAlertMessagesParams struct {
+	CreateAt time.Time
+	Login    string
+}
+
+type GetAlertMessagesRow struct {
+	FolderAlert sql.NullInt64
+	UserAlert   sql.NullInt64
+	Message     Message
+}
+
+func (q *Queries) GetAlertMessages(ctx context.Context, arg GetAlertMessagesParams) ([]GetAlertMessagesRow, error) {
+	rows, err := q.db.QueryContext(ctx, getAlertMessages, arg.CreateAt, arg.Login)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	var items []GetAlertMessagesRow
+	for rows.Next() {
+		var i GetAlertMessagesRow
+		if err := rows.Scan(
+			&i.FolderAlert,
+			&i.UserAlert,
+			&i.Message.ID,
+			&i.Message.Folder,
+			&i.Message.Author,
+			&i.Message.Subject,
+			&i.Message.Message,
+			&i.Message.Permanent,
+			&i.Message.System,
+			&i.Message.Shutdown,
+			&i.Message.Expiration,
+			&i.Message.CreateAt,
+			&i.Message.UpdateAt,
+		); 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 getLastRead = `-- name: GetLastRead :many
 SELECT CAST(MAX(m.id) AS INT) AS id, m.author FROM messages AS m, users AS u
   WHERE folder = ? AND u.login == m.author
@@ -149,6 +207,45 @@ func (q *Queries) GetLastReadByUser(ctx context.Context, arg GetLastReadByUserPa
 	return i, err
 }
 
+const getShutdownMessages = `-- name: GetShutdownMessages :many
+SELECT id, folder, author, subject, message, permanent, system, shutdown, expiration, create_at, update_at FROM messages WHERE create_at >= ? AND shutdown = 1
+`
+
+func (q *Queries) GetShutdownMessages(ctx context.Context, createAt time.Time) ([]Message, error) {
+	rows, err := q.db.QueryContext(ctx, getShutdownMessages, createAt)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	var items []Message
+	for rows.Next() {
+		var i Message
+		if err := rows.Scan(
+			&i.ID,
+			&i.Folder,
+			&i.Author,
+			&i.Subject,
+			&i.Message,
+			&i.Permanent,
+			&i.System,
+			&i.Shutdown,
+			&i.Expiration,
+			&i.CreateAt,
+			&i.UpdateAt,
+		); 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 lastMsgidIgnoringSeen = `-- name: LastMsgidIgnoringSeen :one
 SELECT CAST(MAX(id) AS INT) FROM messages AS m WHERE m.folder = ?1
 `
diff --git a/storage/migrations/1_create_table.up.sql b/storage/migrations/1_create_table.up.sql
index 9ce50b9..578676e 100644
--- a/storage/migrations/1_create_table.up.sql
+++ b/storage/migrations/1_create_table.up.sql
@@ -3,11 +3,10 @@ CREATE TABLE users (
   name           VARCHAR(53)  NOT NULL,
   admin          INT          DEFAULT 0 NOT NULL,
   moderator      INT          DEFAULT 0 NOT NULL,
-  --- 0=no, 1=brief, 2=readnew, 3=shownew
-  alert          INT          DEFAULT 0 NOT NULL,
   disabled       INT          DEFAULT 0 NOT NULL,
   prompt         INT          DEFAULT 0 NOT NULL,
-  last_broadcast TIMESTAMP    DEFAULT CURRENT_TIMESTAMP NOT NULL,
+  signature      TEXT         DEFAULT "" NOT NULL,
+  last_activity  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
@@ -40,6 +39,7 @@ CREATE TABLE folders (
   --- 0=no, 1(4)=brief(p), 2(5)=readnew(p), 3(6)=shownew(p)
   alert       INT          DEFAULT 0 NOT NULL,
   description VARCHAR(53)  DEFAULT 0 NOT NULL,
+  owner       VARCHAR(12)  NOT NULL,
   notify      INT          DEFAULT 0 NOT NULL,
   system      INT          DEFAULT 0 NOT NULL,
   expire      INT          DEFAULT 14 NOT NULL,
@@ -85,24 +85,6 @@ BEGIN
   SELECT RAISE (ABORT, 'GENERAL folder is protected');
 END;
 
-CREATE TABLE owners (
-  folder      VARCHAR(25)  REFERENCES folders(name)
-                           ON DELETE CASCADE ON UPDATE CASCADE NOT NULL,
-  login       VARCHAR(25)  REFERENCES users(login)
-                           ON UPDATE CASCADE NOT NULL,
-  create_at   TIMESTAMP    DEFAULT CURRENT_TIMESTAMP NOT NULL,
-  update_at   TIMESTAMP    DEFAULT CURRENT_TIMESTAMP NOT NULL,
-  PRIMARY KEY (folder, login)
-) WITHOUT ROWID;
-
-CREATE TRIGGER owners_after_update_update_at
-  AFTER UPDATE ON owners FOR EACH ROW
-    WHEN NEW.update_at = OLD.update_at    --- avoid infinite loop
-BEGIN
-  UPDATE owners SET update_at=CURRENT_TIMESTAMP
-    WHERE folder=NEW.folder AND login=NEW.login;
-END;
-
 CREATE TABLE messages (
   id          INT          NOT NULL,
   folder      VARCHAR(25)  REFERENCES folders(name)
diff --git a/storage/models.go b/storage/models.go
index 2c0d9c0..3efeab4 100644
--- a/storage/models.go
+++ b/storage/models.go
@@ -20,6 +20,7 @@ type Folder struct {
 	Always      int64
 	Alert       int64
 	Description string
+	Owner       string
 	Notify      int64
 	System      int64
 	Expire      int64
@@ -60,13 +61,6 @@ type Message struct {
 	UpdateAt   time.Time
 }
 
-type Owner struct {
-	Folder   string
-	Login    string
-	CreateAt time.Time
-	UpdateAt time.Time
-}
-
 type Seen struct {
 	Login    string
 	Folder   string
@@ -81,15 +75,15 @@ type System struct {
 }
 
 type User struct {
-	Login         string
-	Name          string
-	Admin         int64
-	Moderator     int64
-	Alert         int64
-	Disabled      int64
-	Prompt        int64
-	LastBroadcast time.Time
-	LastLogin     time.Time
-	CreateAt      time.Time
-	UpdateAt      time.Time
+	Login        string
+	Name         string
+	Admin        int64
+	Moderator    int64
+	Disabled     int64
+	Prompt       int64
+	Signature    interface{}
+	LastActivity time.Time
+	LastLogin    time.Time
+	CreateAt     time.Time
+	UpdateAt     time.Time
 }
diff --git a/storage/queries/broadcast.sql b/storage/queries/broadcast.sql
index 4b5dceb..c6f4813 100644
--- a/storage/queries/broadcast.sql
+++ b/storage/queries/broadcast.sql
@@ -7,8 +7,8 @@ INSERT INTO broadcast
 -- name: GetBroadcasts :many
 SELECT * FROM broadcast WHERE create_at > ? ORDER BY create_at;
 
--- name: UpdateLastBroadcast :exec
-UPDATE users SET last_broadcast = ? WHERE login = ?;
+-- name: UpdateLastActivity :exec
+UPDATE users SET last_activity = ? WHERE login = ?;
 
 -- name: ClearBroadcasts :exec
 DELETE FROM broadcast;
diff --git a/storage/queries/folders.sql b/storage/queries/folders.sql
index 9f7e956..881979d 100644
--- a/storage/queries/folders.sql
+++ b/storage/queries/folders.sql
@@ -1,11 +1,8 @@
 -- name: CreateFolder :exec
 INSERT INTO folders
-    (name, always, alert, description, notify, system, expire, visibility)
+    (name, always, alert, description, owner, notify, system, expire, visibility)
   VALUES
-    (?, ?, ?, ?, ?, ?, ?, ?);
-
--- name: AddFolderOwner :exec
-INSERT INTO owners (folder, login) VALUES (?, ?);
+    (?, ?, ?, ?, ?, ?, ?, ?, ?);
 
 -- name: ListFolderForAdmin :many
 SELECT f.name, count(m.id) as count, f.description
@@ -27,12 +24,14 @@ SELECT * FROM folders where name LIKE ?
 ORDER BY name
 LIMIT 1;
 
--- name: IsFolderAccess :one
-SELECT 1 FROM folders AS f LEFT JOIN owners AS o ON f.name = c.folder
-  WHERE f.name = ? AND (f.visibility = 0 OR o.login = ?);
+-- name: IsFolderReadable :one
+SELECT 1 FROM folders WHERE name = ? AND (visibility <= 1 OR owner = ?);
+
+-- name: IsFolderWriteable :one
+SELECT 1 FROM folders WHERE name = ? AND (visibility = 0 OR owner = ?);
 
 -- name: IsFolderOwner :one
-SELECT 1 FROM owners WHERE folder = ? AND login = ?;
+SELECT 1 FROM folders WHERE name = ? AND owner = ?;
 
 -- name: GetFolderExpire :one
 SELECT expire FROM folders WHERE name = ?;
@@ -54,3 +53,6 @@ UPDATE folders SET visibility = ? WHERE name = ?;
 
 -- name: UpdateFolderExpire :exec
 UPDATE folders SET expire = ? WHERE name = ?;
+
+-- name: UpdateFolderMain :exec
+UPDATE folders SET description = ?, owner = ?, name = sqlc.arg(new_name)  WHERE name = sqlc.arg(old_name);
diff --git a/storage/queries/messages.sql b/storage/queries/messages.sql
index 7d9abf3..399ba7b 100644
--- a/storage/queries/messages.sql
+++ b/storage/queries/messages.sql
@@ -106,3 +106,14 @@ UPDATE messages SET
     expiration = ?
   WHERE id = ?;
 
+-- name: GetShutdownMessages :many
+SELECT * FROM messages WHERE create_at >= ? AND shutdown = 1;
+
+-- name: GetAlertMessages :many
+SELECT f.alert AS folder_alert, fc.alert AS user_alert, sqlc.embed(m) FROM messages AS m
+  LEFT JOIN folder_configs AS fc ON m.folder = fc.folder
+  LEFT JOIN folders AS f ON m.folder = f.name
+  WHERE m.create_at >= ?
+    AND fc.login = ?
+    AND (f.alert > 3 OR fc.alert > 0 OR (f.alert > 0 AND fc.alert IS NULL))
+  ORDER BY m.folder, m.id;
diff --git a/storage/queries/seed.sql b/storage/queries/seed.sql
index 8d76543..edd8d08 100644
--- a/storage/queries/seed.sql
+++ b/storage/queries/seed.sql
@@ -3,11 +3,8 @@ INSERT INTO users (login, name, admin, moderator)
   VALUES ('SYSTEM', 'System User', 1, 1);
 
 -- name: SeedFolderGeneral :exec
-INSERT INTO folders (name, description, system, alert)
-  VALUES ('GENERAL', 'Default general bulletin folder.', 1, 1);
-
--- name: SeedGeneralOwner :exec
-INSERT INTO owners (folder, login) VALUES ('GENERAL', 'SYSTEM');
+INSERT INTO folders (name, owner, description, system, alert)
+  VALUES ('GENERAL', 'SYSTEM', 'Default general bulletin folder.', 1, 1);
 
 -- name: SeedCreateMessage :exec
 INSERT INTO messages
diff --git a/storage/queries/standard.sql b/storage/queries/standard.sql
index bd37e86..2b475b6 100644
--- a/storage/queries/standard.sql
+++ b/storage/queries/standard.sql
@@ -16,21 +16,6 @@ SELECT * FROM folders WHERE name = ?;
 -- name: DeleteFolder :exec
 DELETE FROM folders WHERE name = ?;
 
--- name: AddOwner :exec
-INSERT INTO owners (folder, login) VALUES (?, ?);
-
--- name: ListOwners :many
-SELECT * FROM owners;
-
--- name: GetOwner :one
-SELECT * FROM owners WHERE folder = ? AND login = ?;
-
--- name: GetOwners :many
-SELECT * FROM owners WHERE folder = ?;
-
--- name: DeleteOwner :exec
-DELETE FROM owners WHERE folder = ? AND login = ?;
-
 -- name: AddMessage :exec
 INSERT INTO messages (id, folder) VALUES (?, ?);
 
diff --git a/storage/queries/system.sql b/storage/queries/system.sql
index 1d901a6..3196183 100644
--- a/storage/queries/system.sql
+++ b/storage/queries/system.sql
@@ -3,3 +3,6 @@ UPDATE system SET default_expire = ? WHERE rowid = 1;
 
 -- name: UpdateExpireLimit :exec
 UPDATE system SET expire_limit = ? WHERE rowid = 1;
+
+-- name: GetExpire :one
+SELECT default_expire, expire_limit FROM system WHERE rowid = 1;
diff --git a/storage/queries/users.sql b/storage/queries/users.sql
index ad343c7..3694400 100644
--- a/storage/queries/users.sql
+++ b/storage/queries/users.sql
@@ -15,7 +15,10 @@ UPDATE users SET disabled = ? WHERE login = ? AND login != 'SYSTEM';
 UPDATE users SET admin = ? WHERE login = ? AND login != 'SYSTEM';
 
 -- name: UpdateUserAlert :exec
-UPDATE users SET alert = ? WHERE login = ? AND login != 'SYSTEM';
+INSERT INTO folder_configs (login, folder, alert)
+  VALUES (?1, ?2, ?3)
+  ON CONFLICT(login, folder) DO UPDATE
+  SET login = ?1, folder = ?2, alert = ?3;
 
 -- name: UpdateUserName :exec
 UPDATE users SET name = ? WHERE login = ? AND login != 'SYSTEM';
diff --git a/storage/seed.sql.go b/storage/seed.sql.go
index e33741e..52abe2e 100644
--- a/storage/seed.sql.go
+++ b/storage/seed.sql.go
@@ -40,8 +40,8 @@ func (q *Queries) SeedCreateMessage(ctx context.Context, arg SeedCreateMessagePa
 }
 
 const seedFolderGeneral = `-- name: SeedFolderGeneral :exec
-INSERT INTO folders (name, description, system, alert)
-  VALUES ('GENERAL', 'Default general bulletin folder.', 1, 1)
+INSERT INTO folders (name, owner, description, system, alert)
+  VALUES ('GENERAL', 'SYSTEM', 'Default general bulletin folder.', 1, 1)
 `
 
 func (q *Queries) SeedFolderGeneral(ctx context.Context) error {
@@ -49,15 +49,6 @@ func (q *Queries) SeedFolderGeneral(ctx context.Context) error {
 	return err
 }
 
-const seedGeneralOwner = `-- name: SeedGeneralOwner :exec
-INSERT INTO owners (folder, login) VALUES ('GENERAL', 'SYSTEM')
-`
-
-func (q *Queries) SeedGeneralOwner(ctx context.Context) error {
-	_, err := q.db.ExecContext(ctx, seedGeneralOwner)
-	return err
-}
-
 const seedUserSystem = `-- name: SeedUserSystem :exec
 INSERT INTO users (login, name, admin, moderator)
   VALUES ('SYSTEM', 'System User', 1, 1)
diff --git a/storage/standard.sql.go b/storage/standard.sql.go
index ea36c68..56080b8 100644
--- a/storage/standard.sql.go
+++ b/storage/standard.sql.go
@@ -75,20 +75,6 @@ func (q *Queries) AddMessage(ctx context.Context, arg AddMessageParams) error {
 	return err
 }
 
-const addOwner = `-- name: AddOwner :exec
-INSERT INTO owners (folder, login) VALUES (?, ?)
-`
-
-type AddOwnerParams struct {
-	Folder string
-	Login  string
-}
-
-func (q *Queries) AddOwner(ctx context.Context, arg AddOwnerParams) error {
-	_, err := q.db.ExecContext(ctx, addOwner, arg.Folder, arg.Login)
-	return err
-}
-
 const addSeen = `-- name: AddSeen :exec
 INSERT INTO seen (folder, login, msgid) VALUES (?, ?, ?)
 `
@@ -170,20 +156,6 @@ func (q *Queries) DeleteMessage(ctx context.Context, arg DeleteMessageParams) er
 	return err
 }
 
-const deleteOwner = `-- name: DeleteOwner :exec
-DELETE FROM owners WHERE folder = ? AND login = ?
-`
-
-type DeleteOwnerParams struct {
-	Folder string
-	Login  string
-}
-
-func (q *Queries) DeleteOwner(ctx context.Context, arg DeleteOwnerParams) error {
-	_, err := q.db.ExecContext(ctx, deleteOwner, arg.Folder, arg.Login)
-	return err
-}
-
 const deleteSeen = `-- name: DeleteSeen :exec
 DELETE FROM seen WHERE folder = ? AND login = ? AND msgid = ?
 `
@@ -209,7 +181,7 @@ func (q *Queries) DeleteUser(ctx context.Context, login string) error {
 }
 
 const getFolder = `-- name: GetFolder :one
-SELECT name, "always", alert, description, notify, system, expire, visibility, create_at, update_at FROM folders WHERE name = ?
+SELECT name, "always", alert, description, owner, notify, system, expire, visibility, create_at, update_at FROM folders WHERE name = ?
 `
 
 func (q *Queries) GetFolder(ctx context.Context, name string) (Folder, error) {
@@ -220,6 +192,7 @@ func (q *Queries) GetFolder(ctx context.Context, name string) (Folder, error) {
 		&i.Always,
 		&i.Alert,
 		&i.Description,
+		&i.Owner,
 		&i.Notify,
 		&i.System,
 		&i.Expire,
@@ -312,59 +285,6 @@ func (q *Queries) GetMessage(ctx context.Context, arg GetMessageParams) (Message
 	return i, err
 }
 
-const getOwner = `-- name: GetOwner :one
-SELECT folder, login, create_at, update_at FROM owners WHERE folder = ? AND login = ?
-`
-
-type GetOwnerParams struct {
-	Folder string
-	Login  string
-}
-
-func (q *Queries) GetOwner(ctx context.Context, arg GetOwnerParams) (Owner, error) {
-	row := q.db.QueryRowContext(ctx, getOwner, arg.Folder, arg.Login)
-	var i Owner
-	err := row.Scan(
-		&i.Folder,
-		&i.Login,
-		&i.CreateAt,
-		&i.UpdateAt,
-	)
-	return i, err
-}
-
-const getOwners = `-- name: GetOwners :many
-SELECT folder, login, create_at, update_at FROM owners WHERE folder = ?
-`
-
-func (q *Queries) GetOwners(ctx context.Context, folder string) ([]Owner, error) {
-	rows, err := q.db.QueryContext(ctx, getOwners, folder)
-	if err != nil {
-		return nil, err
-	}
-	defer rows.Close()
-	var items []Owner
-	for rows.Next() {
-		var i Owner
-		if err := rows.Scan(
-			&i.Folder,
-			&i.Login,
-			&i.CreateAt,
-			&i.UpdateAt,
-		); 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 getSeen = `-- name: GetSeen :one
 SELECT login, folder, msgid, create_at FROM seen WHERE folder = ? AND login = ? AND msgid = ?
 `
@@ -458,7 +378,7 @@ func (q *Queries) ListFolderConfig(ctx context.Context) ([]FolderConfig, error)
 }
 
 const listFolders = `-- name: ListFolders :many
-SELECT name, "always", alert, description, notify, system, expire, visibility, create_at, update_at FROM folders
+SELECT name, "always", alert, description, owner, notify, system, expire, visibility, create_at, update_at FROM folders
 `
 
 func (q *Queries) ListFolders(ctx context.Context) ([]Folder, error) {
@@ -475,6 +395,7 @@ func (q *Queries) ListFolders(ctx context.Context) ([]Folder, error) {
 			&i.Always,
 			&i.Alert,
 			&i.Description,
+			&i.Owner,
 			&i.Notify,
 			&i.System,
 			&i.Expire,
@@ -593,38 +514,6 @@ func (q *Queries) ListMessages(ctx context.Context, folder string) ([]Message, e
 	return items, nil
 }
 
-const listOwners = `-- name: ListOwners :many
-SELECT folder, login, create_at, update_at FROM owners
-`
-
-func (q *Queries) ListOwners(ctx context.Context) ([]Owner, error) {
-	rows, err := q.db.QueryContext(ctx, listOwners)
-	if err != nil {
-		return nil, err
-	}
-	defer rows.Close()
-	var items []Owner
-	for rows.Next() {
-		var i Owner
-		if err := rows.Scan(
-			&i.Folder,
-			&i.Login,
-			&i.CreateAt,
-			&i.UpdateAt,
-		); 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 listSeen = `-- name: ListSeen :many
 SELECT login, folder, msgid, create_at FROM seen
 `
@@ -658,7 +547,7 @@ func (q *Queries) ListSeen(ctx context.Context) ([]Seen, error) {
 }
 
 const listUsers = `-- name: ListUsers :many
-SELECT login, name, admin, moderator, alert, disabled, prompt, last_broadcast, last_login, create_at, update_at FROM users
+SELECT login, name, admin, moderator, disabled, prompt, signature, last_activity, last_login, create_at, update_at FROM users
 `
 
 func (q *Queries) ListUsers(ctx context.Context) ([]User, error) {
@@ -675,10 +564,10 @@ func (q *Queries) ListUsers(ctx context.Context) ([]User, error) {
 			&i.Name,
 			&i.Admin,
 			&i.Moderator,
-			&i.Alert,
 			&i.Disabled,
 			&i.Prompt,
-			&i.LastBroadcast,
+			&i.Signature,
+			&i.LastActivity,
 			&i.LastLogin,
 			&i.CreateAt,
 			&i.UpdateAt,
diff --git a/storage/system.sql.go b/storage/system.sql.go
index 920642d..91aebac 100644
--- a/storage/system.sql.go
+++ b/storage/system.sql.go
@@ -9,6 +9,22 @@ import (
 	"context"
 )
 
+const getExpire = `-- name: GetExpire :one
+SELECT default_expire, expire_limit FROM system WHERE rowid = 1
+`
+
+type GetExpireRow struct {
+	DefaultExpire int64
+	ExpireLimit   int64
+}
+
+func (q *Queries) GetExpire(ctx context.Context) (GetExpireRow, error) {
+	row := q.db.QueryRowContext(ctx, getExpire)
+	var i GetExpireRow
+	err := row.Scan(&i.DefaultExpire, &i.ExpireLimit)
+	return i, err
+}
+
 const updateDefaultExpire = `-- name: UpdateDefaultExpire :exec
 UPDATE system SET default_expire = ? WHERE rowid = 1
 `
diff --git a/storage/users.sql.go b/storage/users.sql.go
index b80de52..0dffa9b 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, prompt, last_broadcast, last_login, create_at, update_at
+RETURNING login, name, admin, moderator, disabled, prompt, signature, last_activity, last_login, create_at, update_at
 `
 
 type AddUserParams struct {
@@ -37,10 +37,10 @@ func (q *Queries) AddUser(ctx context.Context, arg AddUserParams) (User, error)
 		&i.Name,
 		&i.Admin,
 		&i.Moderator,
-		&i.Alert,
 		&i.Disabled,
 		&i.Prompt,
-		&i.LastBroadcast,
+		&i.Signature,
+		&i.LastActivity,
 		&i.LastLogin,
 		&i.CreateAt,
 		&i.UpdateAt,
@@ -129,7 +129,7 @@ func (q *Queries) GetLastLoginByLogin(ctx context.Context, login string) (GetLas
 }
 
 const getUser = `-- name: GetUser :one
-SELECT login, name, admin, moderator, alert, disabled, prompt, last_broadcast, last_login, create_at, update_at FROM users WHERE login = ?
+SELECT login, name, admin, moderator, disabled, prompt, signature, last_activity, last_login, create_at, update_at FROM users WHERE login = ?
 `
 
 func (q *Queries) GetUser(ctx context.Context, login string) (User, error) {
@@ -140,10 +140,10 @@ func (q *Queries) GetUser(ctx context.Context, login string) (User, error) {
 		&i.Name,
 		&i.Admin,
 		&i.Moderator,
-		&i.Alert,
 		&i.Disabled,
 		&i.Prompt,
-		&i.LastBroadcast,
+		&i.Signature,
+		&i.LastActivity,
 		&i.LastLogin,
 		&i.CreateAt,
 		&i.UpdateAt,
@@ -177,16 +177,20 @@ func (q *Queries) UpdateUserAdmin(ctx context.Context, arg UpdateUserAdminParams
 }
 
 const updateUserAlert = `-- name: UpdateUserAlert :exec
-UPDATE users SET alert = ? WHERE login = ? AND login != 'SYSTEM'
+INSERT INTO folder_configs (login, folder, alert)
+  VALUES (?1, ?2, ?3)
+  ON CONFLICT(login, folder) DO UPDATE
+  SET login = ?1, folder = ?2, alert = ?3
 `
 
 type UpdateUserAlertParams struct {
-	Alert int64
-	Login string
+	Login  string
+	Folder string
+	Alert  int64
 }
 
 func (q *Queries) UpdateUserAlert(ctx context.Context, arg UpdateUserAlertParams) error {
-	_, err := q.db.ExecContext(ctx, updateUserAlert, arg.Alert, arg.Login)
+	_, err := q.db.ExecContext(ctx, updateUserAlert, arg.Login, arg.Folder, arg.Alert)
 	return err
 }
 
diff --git a/this/alert.go b/this/alert.go
new file mode 100644
index 0000000..3335703
--- /dev/null
+++ b/this/alert.go
@@ -0,0 +1,40 @@
+package this
+
+import (
+	"fmt"
+	"time"
+
+	"git.lyda.ie/kevin/bulletin/storage"
+)
+
+// ShowBroadcast print broadcast messages.
+func ShowBroadcast() {
+	ctx := storage.Context()
+	User.LastActivity = time.Now()
+	msgs, _ := Q.GetBroadcasts(ctx, User.LastActivity)
+	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")
+	}
+
+	alerts, _ := Q.GetAlertMessages(ctx, storage.GetAlertMessagesParams{
+		CreateAt: User.LastActivity,
+		Login:    User.Name,
+	})
+	if len(alerts) > 0 {
+		// TODO: fix printing.
+		fmt.Println("ALERT MSG START")
+		for i := range alerts {
+			fmt.Printf("%s", alerts[i].Message.OneLine(false))
+		}
+		fmt.Println("ALERT MSG END")
+	}
+
+	Q.UpdateLastActivity(ctx, storage.UpdateLastActivityParams{
+		LastActivity: User.LastActivity,
+		Login:        User.Login,
+	})
+}
diff --git a/this/broadcast.go b/this/broadcast.go
deleted file mode 100644
index 5682ab7..0000000
--- a/this/broadcast.go
+++ /dev/null
@@ -1,26 +0,0 @@
-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 7cc73fa..d90291e 100644
--- a/this/this.go
+++ b/this/this.go
@@ -4,8 +4,6 @@ Package this has the current state of the running bulletin process.
 Each bulletin process is run by a single user.  The `this` package
 tracks the current user state - it has the user login, the current
 folder, the current message id and other things.
-
-TODO: Eventually `this` will need to handle broadcast messages.
 */
 package this
 
-- 
GitLab