feat: add account and card lifecycle controls
Test and publish / verify (push) Successful in 2m31s

This commit is contained in:
2026-07-29 12:50:13 +03:30
parent eba6b3565c
commit 71d253fb03
6 changed files with 502 additions and 33 deletions
+180 -19
View File
@@ -27,6 +27,7 @@ type User struct {
Email string
Role string
AvatarURL string
Active bool
}
type Attendance struct {
@@ -103,6 +104,7 @@ type BoardTask struct {
CreatorName string
DueDate string
CreatedAt string
ArchivedAt string
Tags []BoardTag
}
@@ -132,6 +134,38 @@ func (s *Store) Close() error { return s.db.Close() }
func (s *Store) Ping(ctx context.Context) error { return s.db.PingContext(ctx) }
func (s *Store) ensureColumn(table, column, definition string) error {
rows, err := s.db.Query(`PRAGMA table_info(` + table + `)`)
if err != nil {
return err
}
found := false
for rows.Next() {
var cid, notNull, primaryKey int
var name, columnType string
var defaultValue any
if err := rows.Scan(&cid, &name, &columnType, &notNull, &defaultValue, &primaryKey); err != nil {
rows.Close()
return err
}
if name == column {
found = true
}
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
if err := rows.Close(); err != nil {
return err
}
if found {
return nil
}
_, err = s.db.Exec(`ALTER TABLE ` + table + ` ADD COLUMN ` + definition)
return err
}
func (s *Store) migrate() error {
const schema = `
CREATE TABLE IF NOT EXISTS users (
@@ -142,6 +176,7 @@ CREATE TABLE IF NOT EXISTS users (
email TEXT UNIQUE COLLATE NOCASE,
role TEXT NOT NULL DEFAULT 'member' CHECK(role IN ('member','admin')),
avatar_url TEXT NOT NULL DEFAULT '',
active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS oauth_accounts (
@@ -200,6 +235,7 @@ CREATE TABLE IF NOT EXISTS board_tasks (
assignee_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
creator_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
due_date TEXT,
archived_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_board_tasks_status ON board_tasks(status,created_at);
@@ -219,6 +255,12 @@ CREATE INDEX IF NOT EXISTS idx_board_task_tags_tag ON board_task_tags(tag_id,tas
if _, err := s.db.Exec(schema); err != nil {
return err
}
if err := s.ensureColumn("users", "active", "active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1))"); err != nil {
return err
}
if err := s.ensureColumn("board_tasks", "archived_at", "archived_at DATETIME"); err != nil {
return err
}
var count int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
return err
@@ -297,12 +339,15 @@ func tokenHash(token string) string {
func (s *Store) Authenticate(identifier, password string) (*User, error) {
var u User
var hash string
err := s.db.QueryRow(`SELECT id,username,password_hash,display_name,COALESCE(email,''),role,avatar_url
err := s.db.QueryRow(`SELECT id,username,password_hash,display_name,COALESCE(email,''),role,avatar_url,active
FROM users WHERE username=? OR email=?`, strings.TrimSpace(identifier), strings.TrimSpace(identifier)).
Scan(&u.ID, &u.Username, &hash, &u.DisplayName, &u.Email, &u.Role, &u.AvatarURL)
Scan(&u.ID, &u.Username, &hash, &u.DisplayName, &u.Email, &u.Role, &u.AvatarURL, &u.Active)
if err != nil || !verifyPassword(hash, password) {
return nil, errors.New("invalid email, username, or password")
}
if !u.Active {
return nil, errors.New("this account is locked; contact an administrator")
}
return &u, nil
}
@@ -394,7 +439,20 @@ func validUsername(username string) bool {
}
func (s *Store) Users() ([]User, error) {
rows, err := s.db.Query(`SELECT id,username,display_name,COALESCE(email,''),role,avatar_url FROM users ORDER BY display_name`)
return s.users(false)
}
func (s *Store) ActiveUsers() ([]User, error) {
return s.users(true)
}
func (s *Store) users(activeOnly bool) ([]User, error) {
query := `SELECT id,username,display_name,COALESCE(email,''),role,avatar_url,active FROM users`
if activeOnly {
query += ` WHERE active=1`
}
query += ` ORDER BY display_name`
rows, err := s.db.Query(query)
if err != nil {
return nil, err
}
@@ -402,7 +460,7 @@ func (s *Store) Users() ([]User, error) {
var out []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, &u.Role, &u.AvatarURL); err != nil {
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, &u.Role, &u.AvatarURL, &u.Active); err != nil {
return nil, err
}
out = append(out, u)
@@ -419,8 +477,16 @@ func (s *Store) CreateSession(userID int64) (token, csrf string, err error) {
if err != nil {
return "", "", err
}
_, err = s.db.Exec(`INSERT INTO sessions(token_hash,user_id,csrf_token,expires_at) VALUES(?,?,?,?)`,
tokenHash(token), userID, csrf, time.Now().Add(30*24*time.Hour))
result, err := s.db.Exec(`INSERT INTO sessions(token_hash,user_id,csrf_token,expires_at)
SELECT ?,id,?,? FROM users WHERE id=? AND active=1`,
tokenHash(token), csrf, time.Now().Add(30*24*time.Hour), userID)
if err != nil {
return "", "", err
}
count, _ := result.RowsAffected()
if count == 0 {
return "", "", errors.New("account is locked or was not found")
}
return
}
@@ -434,15 +500,24 @@ func (s *Store) UpsertOAuthUser(provider, providerID, username, email, displayNa
}
defer tx.Rollback()
var userID int64
err = tx.QueryRow(`SELECT user_id FROM oauth_accounts WHERE provider=? AND provider_user_id=?`, provider, providerID).Scan(&userID)
var active bool
err = tx.QueryRow(`SELECT o.user_id,u.active FROM oauth_accounts o
JOIN users u ON u.id=o.user_id
WHERE o.provider=? AND o.provider_user_id=?`, provider, providerID).Scan(&userID, &active)
if err == nil {
if !active {
return 0, errors.New("account is locked")
}
return userID, tx.Commit()
}
if !errors.Is(err, sql.ErrNoRows) {
return 0, err
}
if email != "" {
err = tx.QueryRow(`SELECT id FROM users WHERE email=?`, email).Scan(&userID)
err = tx.QueryRow(`SELECT id,active FROM users WHERE email=?`, email).Scan(&userID, &active)
if err == nil && !active {
return 0, errors.New("account is locked")
}
}
if email == "" || errors.Is(err, sql.ErrNoRows) {
username = strings.TrimSpace(username)
@@ -487,10 +562,10 @@ func (s *Store) UpsertOAuthUser(provider, providerID, username, email, displayNa
func (s *Store) Session(token string) (*User, string, error) {
var u User
var csrf string
err := s.db.QueryRow(`SELECT u.id,u.username,u.display_name,COALESCE(u.email,''),u.role,u.avatar_url,s.csrf_token
err := s.db.QueryRow(`SELECT u.id,u.username,u.display_name,COALESCE(u.email,''),u.role,u.avatar_url,u.active,s.csrf_token
FROM sessions s JOIN users u ON u.id=s.user_id
WHERE s.token_hash=? AND s.expires_at>?`, tokenHash(token), time.Now()).
Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, &u.Role, &u.AvatarURL, &csrf)
WHERE s.token_hash=? AND s.expires_at>? AND u.active=1`, tokenHash(token), time.Now()).
Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, &u.Role, &u.AvatarURL, &u.Active, &csrf)
if err != nil {
return nil, "", err
}
@@ -501,6 +576,53 @@ func (s *Store) DeleteSession(token string) {
_, _ = s.db.Exec(`DELETE FROM sessions WHERE token_hash=?`, tokenHash(token))
}
func (s *Store) SetUserActive(userID, actorID int64, active bool) error {
if userID < 1 {
return errors.New("account was not found")
}
if !active && userID == actorID {
return errors.New("you cannot lock your own account")
}
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
var role string
var currentlyActive bool
if err := tx.QueryRow(`SELECT role,active FROM users WHERE id=?`, userID).Scan(&role, &currentlyActive); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return errors.New("account was not found")
}
return err
}
if currentlyActive == active {
return tx.Commit()
}
if !active && role == "admin" {
var activeAdmins int
if err := tx.QueryRow(`SELECT COUNT(*) FROM users WHERE role='admin' AND active=1`).Scan(&activeAdmins); err != nil {
return err
}
if activeAdmins <= 1 {
return errors.New("the last active administrator cannot be locked")
}
}
activeValue := 0
if active {
activeValue = 1
}
if _, err := tx.Exec(`UPDATE users SET active=? WHERE id=?`, activeValue, userID); err != nil {
return err
}
if !active {
if _, err := tx.Exec(`DELETE FROM sessions WHERE user_id=?`, userID); err != nil {
return err
}
}
return tx.Commit()
}
func (s *Store) TodayAttendance(userID int64, day string) (*Attendance, error) {
var a Attendance
err := s.db.QueryRow(`SELECT id,user_id,day,
@@ -661,6 +783,7 @@ func (s *Store) DayRoster(day string) ([]DayRosterRow, error) {
),'')
FROM users u
LEFT JOIN attendance a ON a.user_id=u.id AND a.day=?
WHERE u.active=1
ORDER BY u.display_name`, day, day)
if err != nil {
return nil, err
@@ -820,16 +943,32 @@ func (s *Store) CreateBoardTask(creatorID int64, title, description, status stri
}
func (s *Store) BoardTasks() ([]BoardTask, error) {
rows, err := s.db.Query(`SELECT
return s.boardTasks(false)
}
func (s *Store) ArchivedBoardTasks() ([]BoardTask, error) {
return s.boardTasks(true)
}
func (s *Store) boardTasks(archived bool) ([]BoardTask, error) {
query := `SELECT
t.id,t.title,t.description,t.status,t.assignee_id,
COALESCE(a.display_name,''),COALESCE(a.username,''),
t.creator_id,c.display_name,COALESCE(t.due_date,''),CAST(t.created_at AS TEXT)
t.creator_id,c.display_name,COALESCE(t.due_date,''),CAST(t.created_at AS TEXT),
COALESCE(CAST(t.archived_at AS TEXT),'')
FROM board_tasks t
JOIN users c ON c.id=t.creator_id
LEFT JOIN users a ON a.id=t.assignee_id
ORDER BY CASE t.status
LEFT JOIN users a ON a.id=t.assignee_id`
if archived {
query += ` WHERE t.archived_at IS NOT NULL
ORDER BY t.archived_at DESC,t.id DESC`
} else {
query += ` WHERE t.archived_at IS NULL
ORDER BY CASE t.status
WHEN 'backlog' THEN 0 WHEN 'in_progress' THEN 1 WHEN 'blocked' THEN 2 ELSE 3 END,
t.created_at,t.id`)
t.created_at,t.id`
}
rows, err := s.db.Query(query)
if err != nil {
return nil, err
}
@@ -839,7 +978,7 @@ func (s *Store) BoardTasks() ([]BoardTask, error) {
if err := rows.Scan(
&task.ID, &task.Title, &task.Description, &task.Status, &task.AssigneeID,
&task.AssigneeName, &task.AssigneeUser, &task.CreatorID, &task.CreatorName,
&task.DueDate, &task.CreatedAt,
&task.DueDate, &task.CreatedAt, &task.ArchivedAt,
); err != nil {
return nil, err
}
@@ -880,7 +1019,7 @@ func (s *Store) MoveBoardTask(id int64, status string) error {
if !validBoardStatus(status) {
return errors.New("invalid board column")
}
result, err := s.db.Exec(`UPDATE board_tasks SET status=? WHERE id=?`, status, id)
result, err := s.db.Exec(`UPDATE board_tasks SET status=? WHERE id=? AND archived_at IS NULL`, status, id)
if err != nil {
return err
}
@@ -891,6 +1030,28 @@ func (s *Store) MoveBoardTask(id int64, status string) error {
return nil
}
func (s *Store) SetBoardTaskArchived(id, userID int64, admin, archived bool) error {
archiveValue := any(nil)
if archived {
archiveValue = time.Now().UTC()
}
query := `UPDATE board_tasks SET archived_at=? WHERE id=?`
args := []any{archiveValue, id}
if !admin {
query += ` AND creator_id=?`
args = append(args, userID)
}
result, err := s.db.Exec(query, args...)
if err != nil {
return err
}
count, _ := result.RowsAffected()
if count == 0 {
return errors.New("only the creator or an admin can archive or restore this task")
}
return nil
}
func (s *Store) DeleteBoardTask(id, userID int64, admin bool) error {
query := `DELETE FROM board_tasks WHERE id=?`
args := []any{id}
@@ -904,7 +1065,7 @@ func (s *Store) DeleteBoardTask(id, userID int64, admin bool) error {
}
count, _ := result.RowsAffected()
if count == 0 {
return errors.New("only the creator or an admin can remove this task")
return errors.New("only the creator or an admin can delete this task")
}
return nil
}