Files
team-presence/internal/app/store.go
T
nfel 9380f116d7
Test and publish / verify (push) Successful in 2m22s
fix workspace access and vendor correct fonts
2026-08-04 20:07:35 +03:30

1518 lines
47 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"errors"
"fmt"
"net/mail"
"os"
"strconv"
"strings"
"time"
"unicode"
"unicode/utf8"
_ "modernc.org/sqlite"
)
type User struct {
ID int64
Username string
DisplayName string
Email string
Role string
AvatarURL string
Active bool
WorkspaceID int64 `json:"-"`
}
type Workspace struct {
ID int64
Name string
Slug string
CreatedAt string
}
type WorkspaceMember struct {
WorkspaceID int64
UserID int64
Role string
Workspace Workspace
User User
}
type Attendance struct {
ID int64
UserID int64
Day string
CheckIn string
CheckOut string
Mode string
}
type Request struct {
ID int64
UserID int64
UserName string
Kind string
StartDate string
EndDate string
Reason string
Status string
AdminNote string
CreatedAt time.Time
ReviewedAt sql.NullString
Reviewer string
DayCount int
StartJalali string
EndJalali string
}
type DayRosterRow struct {
User User
CheckIn string
CheckOut string
Mode string
RequestKind string
}
type WorkUpdate struct {
ID int64
UserID int64
UserName string
Username string
AvatarURL string
PeriodStart string
PeriodEnd string
Status string
Note string
CreatedAt string
}
type PersonReportSummary struct {
User User
PresentDays int
RemoteDays int
LeaveDays int
DoneUpdates int
Blocked int
Pending int
}
func (s PersonReportSummary) TotalUpdates() int {
return s.DoneUpdates + s.Blocked + s.Pending
}
type BoardTask struct {
ID int64
Title string
Description string
Status string
Importance string
CreatorID int64
CreatorName string
DueDate string
CreatedAt string
ArchivedAt string
Assignees []User
Tags []BoardTag
Todos []BoardTodo
TodoDone int
}
type BoardTag struct {
ID int64
Name string
Color string
}
type BoardTodo struct {
ID int64
TaskID int64
Body string
Completed bool
}
type Store struct{ db *sql.DB }
func OpenStore(path string) (*Store, error) {
db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)")
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
s := &Store{db: db}
if err := s.migrate(); err != nil {
db.Close()
return nil, err
}
return s, nil
}
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 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT,
display_name TEXT NOT NULL,
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 workspaces (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE COLLATE NOCASE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS workspace_members (
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member' CHECK(role IN ('member','admin')),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY(workspace_id,user_id)
);
CREATE TABLE IF NOT EXISTS oauth_accounts (
provider TEXT NOT NULL,
provider_user_id TEXT NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY(provider, provider_user_id)
);
CREATE TABLE IF NOT EXISTS sessions (
token_hash TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
csrf_token TEXT NOT NULL,
expires_at DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS attendance (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
day TEXT NOT NULL,
check_in DATETIME,
check_out DATETIME,
mode TEXT NOT NULL DEFAULT 'office' CHECK(mode IN ('office','remote')),
UNIQUE(user_id, day)
);
CREATE TABLE IF NOT EXISTS requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK(kind IN ('leave','remote')),
start_date TEXT NOT NULL,
end_date TEXT NOT NULL,
reason TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','cancelled')),
admin_note TEXT NOT NULL DEFAULT '',
reviewed_by INTEGER REFERENCES users(id),
reviewed_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CHECK(end_date >= start_date)
);
CREATE INDEX IF NOT EXISTS idx_attendance_day ON attendance(day);
CREATE INDEX IF NOT EXISTS idx_requests_status ON requests(status, start_date);
CREATE TABLE IF NOT EXISTS work_updates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
period_start TEXT NOT NULL,
period_end TEXT NOT NULL,
status TEXT NOT NULL CHECK(status IN ('done','blocked','pending')),
note TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CHECK(period_end >= period_start)
);
CREATE INDEX IF NOT EXISTS idx_work_updates_period ON work_updates(period_start,period_end);
CREATE TABLE IF NOT EXISTS board_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id INTEGER NOT NULL DEFAULT 1,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'backlog' CHECK(status IN ('backlog','todo','in_progress','blocked','done')),
importance TEXT NOT NULL DEFAULT 'normal' CHECK(importance IN ('low','normal','high','urgent')),
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);
CREATE TABLE IF NOT EXISTS board_task_assignees (
task_id INTEGER NOT NULL REFERENCES board_tasks(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY(task_id,user_id)
);
CREATE INDEX IF NOT EXISTS idx_board_task_assignees_user ON board_task_assignees(user_id,task_id);
CREATE TABLE IF NOT EXISTS board_task_todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL REFERENCES board_tasks(id) ON DELETE CASCADE,
body TEXT NOT NULL,
completed INTEGER NOT NULL DEFAULT 0 CHECK(completed IN (0,1)),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_board_task_todos_task ON board_task_todos(task_id,completed,created_at);
CREATE TABLE IF NOT EXISTS board_tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
color TEXT NOT NULL DEFAULT 'green',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS board_task_tags (
task_id INTEGER NOT NULL REFERENCES board_tasks(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES board_tags(id) ON DELETE CASCADE,
PRIMARY KEY(task_id,tag_id)
);
CREATE INDEX IF NOT EXISTS idx_board_task_tags_tag ON board_task_tags(tag_id,task_id);
`
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
}
if err := s.ensureColumn("board_tasks", "importance", "importance TEXT NOT NULL DEFAULT 'normal' CHECK(importance IN ('low','normal','high','urgent'))"); err != nil {
return err
}
if err := s.ensureColumn("board_tasks", "workspace_id", "workspace_id INTEGER NOT NULL DEFAULT 1"); err != nil { return err }
if err := s.ensureBoardTaskTodoStatus(); err != nil {
return err
}
if _, err := s.db.Exec(`INSERT OR IGNORE INTO board_task_assignees(task_id,user_id)
SELECT id,assignee_id FROM board_tasks WHERE assignee_id IS NOT NULL`); err != nil {
return err
}
if _, err := s.db.Exec(`UPDATE board_tasks SET assignee_id=NULL WHERE assignee_id IS NOT NULL`); err != nil {
return err
}
var count int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
return err
}
if count == 0 {
initialPassword := os.Getenv("INITIAL_ADMIN_PASSWORD")
if initialPassword == "" {
initialPassword = "admin123"
}
hash, err := hashPassword(initialPassword)
if err != nil {
return err
}
_, err = s.db.Exec(`INSERT INTO users(username,password_hash,display_name,email,role) VALUES(?,?,?,?,?)`,
"admin", hash, "Workspace Admin", "admin@localhost", "admin")
if err != nil { return err }
}
var workspaceCount int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM workspaces`).Scan(&workspaceCount); err != nil { return err }
if workspaceCount == 0 {
if _, err := s.db.Exec(`INSERT INTO workspaces(name,slug) VALUES(?,?)`, "Hamkar workspace", "hamkar"); err != nil { return err }
}
if _, err := s.db.Exec(`INSERT OR IGNORE INTO workspace_members(workspace_id,user_id,role) SELECT w.id,u.id,u.role FROM workspaces w CROSS JOIN users u`); err != nil { return err }
return nil
}
func (s *Store) ensureBoardTaskTodoStatus() error {
var tableSQL string
err := s.db.QueryRow(`SELECT COALESCE(sql,'') FROM sqlite_master WHERE type='table' AND name='board_tasks'`).Scan(&tableSQL)
if err != nil {
return err
}
if strings.Contains(strings.ToLower(tableSQL), "'todo'") {
return nil
}
if _, err := s.db.Exec(`PRAGMA foreign_keys=OFF`); err != nil {
return err
}
restoreForeignKeys := func() { _, _ = s.db.Exec(`PRAGMA foreign_keys=ON`) }
if _, err := s.db.Exec(`CREATE TABLE board_tasks_migrated (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'backlog' CHECK(status IN ('backlog','todo','in_progress','blocked','done')),
importance TEXT NOT NULL DEFAULT 'normal' CHECK(importance IN ('low','normal','high','urgent')),
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
)`); err != nil {
restoreForeignKeys()
return err
}
if _, err := s.db.Exec(`INSERT INTO board_tasks_migrated(id,title,description,status,importance,assignee_id,creator_id,due_date,archived_at,created_at)
SELECT id,title,description,status,importance,assignee_id,creator_id,due_date,archived_at,created_at FROM board_tasks`); err != nil {
_, _ = s.db.Exec(`DROP TABLE board_tasks_migrated`)
restoreForeignKeys()
return err
}
if _, err := s.db.Exec(`DROP TABLE board_tasks`); err != nil {
_, _ = s.db.Exec(`DROP TABLE board_tasks_migrated`)
restoreForeignKeys()
return err
}
if _, err := s.db.Exec(`ALTER TABLE board_tasks_migrated RENAME TO board_tasks`); err != nil {
restoreForeignKeys()
return err
}
if _, err := s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_board_tasks_status ON board_tasks(status,created_at)`); err != nil {
restoreForeignKeys()
return err
}
restoreForeignKeys()
return nil
}
func hashPassword(password string) (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
const rounds = 180000
key := deriveKey([]byte(password), salt, rounds)
return fmt.Sprintf("pbkdf2-sha256$%d$%s$%s", rounds,
base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key)), nil
}
func verifyPassword(encoded, password string) bool {
parts := strings.Split(encoded, "$")
if len(parts) != 4 || parts[0] != "pbkdf2-sha256" {
return false
}
rounds, err := strconv.Atoi(parts[1])
salt, err2 := base64.RawStdEncoding.DecodeString(parts[2])
expected, err3 := base64.RawStdEncoding.DecodeString(parts[3])
if err != nil || err2 != nil || err3 != nil || rounds < 10000 {
return false
}
return hmac.Equal(expected, deriveKey([]byte(password), salt, rounds))
}
func deriveKey(password, salt []byte, rounds int) []byte {
mac := hmac.New(sha256.New, password)
mac.Write(salt)
mac.Write([]byte{0, 0, 0, 1})
u := mac.Sum(nil)
out := append([]byte(nil), u...)
for i := 1; i < rounds; i++ {
mac.Reset()
mac.Write(u)
u = mac.Sum(nil)
for j := range out {
out[j] ^= u[j]
}
}
return out
}
func randomToken(bytes int) (string, error) {
b := make([]byte, bytes)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func tokenHash(token string) string {
sum := sha256.Sum256([]byte(token))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
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,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, &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
}
func (s *Store) CreateUser(username, password, displayName, email, role string) (int64, error) {
username = strings.TrimSpace(username)
displayName = strings.TrimSpace(displayName)
email = strings.ToLower(strings.TrimSpace(email))
if displayName == "" || len(password) < 8 {
return 0, errors.New("name and a password of at least 8 characters are required")
}
if username == "" && email == "" {
return 0, errors.New("enter an email address or username")
}
if email != "" {
parsed, err := mail.ParseAddress(email)
if err != nil || parsed.Address != email {
return 0, errors.New("enter a valid email address")
}
}
explicitUsername := username != ""
if !explicitUsername {
username = usernameFromEmail(email)
}
if !validUsername(username) {
return 0, errors.New("username must be 340 letters, numbers, dots, dashes, or underscores")
}
if !explicitUsername {
base := username
for suffix := 0; ; suffix++ {
if suffix > 0 {
username = fmt.Sprintf("%s-%d", base, suffix)
}
var count int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM users WHERE username=?`, username).Scan(&count); err != nil {
return 0, err
}
if count == 0 {
break
}
}
}
if role != "admin" {
role = "member"
}
hash, err := hashPassword(password)
if err != nil {
return 0, err
}
result, err := s.db.Exec(`INSERT INTO users(username,password_hash,display_name,email,role)
VALUES(?,?,?,NULLIF(?,''),?)`, username, hash, displayName, email, role)
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
return 0, errors.New("that username or email is already in use")
}
return 0, err
}
return result.LastInsertId()
}
func usernameFromEmail(email string) string {
local := strings.SplitN(email, "@", 2)[0]
var out strings.Builder
for _, r := range local {
if unicode.IsLetter(r) || unicode.IsDigit(r) || strings.ContainsRune("._-", r) {
out.WriteRune(r)
}
}
username := strings.Trim(out.String(), "._-")
if utf8.RuneCountInString(username) < 3 {
username = "member-" + username
}
if utf8.RuneCountInString(username) > 32 {
username = string([]rune(username)[:32])
}
return username
}
func validUsername(username string) bool {
count := utf8.RuneCountInString(username)
if count < 3 || count > 40 {
return false
}
for _, r := range username {
if !unicode.IsLetter(r) && !unicode.IsDigit(r) && !strings.ContainsRune("._-", r) {
return false
}
}
return true
}
func (s *Store) Users() ([]User, error) {
return s.users(false)
}
func (s *Store) Workspaces(userID int64) ([]Workspace, error) {
rows, err := s.db.Query(`SELECT w.id,w.name,w.slug,w.created_at FROM workspaces w JOIN workspace_members m ON m.workspace_id=w.id WHERE m.user_id=? ORDER BY w.name`, userID)
if err != nil { return nil, err }
defer rows.Close()
var out []Workspace
for rows.Next() { var w Workspace; if err := rows.Scan(&w.ID,&w.Name,&w.Slug,&w.CreatedAt); err != nil { return nil, err }; out = append(out,w) }
return out, rows.Err()
}
func (s *Store) AllWorkspaces() ([]Workspace, error) {
rows, err := s.db.Query(`SELECT id,name,slug,created_at FROM workspaces ORDER BY name`); if err != nil { return nil,err }; defer rows.Close()
var out []Workspace; for rows.Next() { var w Workspace; if err:=rows.Scan(&w.ID,&w.Name,&w.Slug,&w.CreatedAt); err!=nil{return nil,err}; out=append(out,w) }; return out,rows.Err()
}
func (s *Store) CreateWorkspace(name, slug string, ownerID int64) (int64, error) {
result, err := s.db.Exec(`INSERT INTO workspaces(name,slug) VALUES(?,?)`, strings.TrimSpace(name), strings.TrimSpace(strings.ToLower(slug)))
if err != nil { return 0, err }
id, err := result.LastInsertId(); if err != nil { return 0, err }
_, err = s.db.Exec(`INSERT INTO workspace_members(workspace_id,user_id,role) VALUES(?,?, 'admin')`, id, ownerID)
return id, err
}
func (s *Store) WorkspaceMember(workspaceID, userID int64) (WorkspaceMember, error) {
var m WorkspaceMember
err := s.db.QueryRow(`SELECT w.id,w.name,w.slug,w.created_at,u.id,u.username,u.display_name,u.email,u.role,u.avatar_url,u.active,m.role FROM workspaces w JOIN workspace_members m ON m.workspace_id=w.id JOIN users u ON u.id=m.user_id WHERE w.id=? AND u.id=?`, workspaceID,userID).Scan(&m.Workspace.ID,&m.Workspace.Name,&m.Workspace.Slug,&m.Workspace.CreatedAt,&m.User.ID,&m.User.Username,&m.User.DisplayName,&m.User.Email,&m.User.Role,&m.User.AvatarURL,&m.User.Active,&m.Role)
return m, err
}
func (s *Store) SetWorkspaceMembers(workspaceID int64, userIDs []int64) error {
tx, err := s.db.Begin(); if err != nil { return err }
defer tx.Rollback()
if _, err = tx.Exec(`DELETE FROM workspace_members WHERE workspace_id=?`, workspaceID); err != nil { return err }
for _, userID := range userIDs {
if _, err = tx.Exec(`INSERT INTO workspace_members(workspace_id,user_id,role) SELECT ?,id,CASE WHEN role='admin' THEN 'admin' ELSE 'member' END FROM users WHERE id=?`, workspaceID,userID); err != nil { return err }
}
return tx.Commit()
}
func (s *Store) WorkspaceMemberIDs(workspaceID int64) (map[int64]bool, error) {
rows, err := s.db.Query(`SELECT user_id FROM workspace_members WHERE workspace_id=?`, workspaceID); if err != nil { return nil, err }; defer rows.Close()
out := map[int64]bool{}; for rows.Next() { var id int64; if err:=rows.Scan(&id); err != nil { return nil,err }; out[id]=true }; return out, rows.Err()
}
func (s *Store) ActiveUsers() ([]User, error) {
return s.users(true)
}
func (s *Store) ActiveUsersInWorkspace(workspaceID int64) ([]User, error) {
rows, err := s.db.Query(`SELECT u.id,u.username,u.display_name,COALESCE(u.email,''),u.role,u.avatar_url,u.active FROM users u JOIN workspace_members m ON m.user_id=u.id WHERE m.workspace_id=? AND u.active=1 ORDER BY u.display_name`, workspaceID)
if err != nil { return nil, err }; defer rows.Close()
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,&u.Active); err!=nil{return nil,err}; out=append(out,u) }; return out,rows.Err()
}
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
}
defer rows.Close()
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, &u.Active); err != nil {
return nil, err
}
out = append(out, u)
}
return out, rows.Err()
}
func (s *Store) CreateSession(userID int64) (token, csrf string, err error) {
token, err = randomToken(32)
if err != nil {
return "", "", err
}
csrf, err = randomToken(24)
if err != nil {
return "", "", err
}
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
}
func (s *Store) UpsertOAuthUser(provider, providerID, username, email, displayName, avatar string) (int64, error) {
if providerID == "" || providerID == "<nil>" {
return 0, errors.New("OAuth profile did not contain an id")
}
tx, err := s.db.Begin()
if err != nil {
return 0, err
}
defer tx.Rollback()
var userID int64
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,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)
if username == "" {
username = provider + "-" + providerID
}
if displayName == "" {
displayName = username
}
candidate := username
for suffix := 0; ; suffix++ {
if suffix > 0 {
candidate = fmt.Sprintf("%s-%d", username, suffix)
}
var exists int
err = tx.QueryRow(`SELECT COUNT(*) FROM users WHERE username=?`, candidate).Scan(&exists)
if err != nil {
return 0, err
}
if exists == 0 {
break
}
}
result, err := tx.Exec(`INSERT INTO users(username,display_name,email,avatar_url) VALUES(?,?,NULLIF(?,''),?)`,
candidate, displayName, email, avatar)
if err != nil {
return 0, err
}
userID, err = result.LastInsertId()
if err != nil {
return 0, err
}
} else if err != nil {
return 0, err
}
if _, err := tx.Exec(`INSERT INTO oauth_accounts(provider,provider_user_id,user_id) VALUES(?,?,?)`, provider, providerID, userID); err != nil {
return 0, err
}
return userID, tx.Commit()
}
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,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>? 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
}
return &u, csrf, nil
}
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) SetUserAvatar(userID int64, avatarURL string) error {
_, err := s.db.Exec(`UPDATE users SET avatar_url=? WHERE id=?`, avatarURL, userID)
return err
}
func (s *Store) TodayAttendance(userID int64, day string) (*Attendance, error) {
var a Attendance
err := s.db.QueryRow(`SELECT id,user_id,day,
COALESCE(CAST(check_in AS TEXT),''),COALESCE(CAST(check_out AS TEXT),''),mode
FROM attendance WHERE user_id=? AND day=?`, userID, day).
Scan(&a.ID, &a.UserID, &a.Day, &a.CheckIn, &a.CheckOut, &a.Mode)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return &a, err
}
func (s *Store) CheckIn(userID int64, day, mode string) error {
if mode != "remote" {
mode = "office"
}
_, err := s.db.Exec(`INSERT INTO attendance(user_id,day,check_in,mode) VALUES(?,?,?,?)
ON CONFLICT(user_id,day) DO UPDATE SET check_in=COALESCE(attendance.check_in,excluded.check_in),mode=excluded.mode`,
userID, day, time.Now().In(time.Local).Format(time.RFC3339Nano), mode)
return err
}
func (s *Store) CheckOut(userID int64, day string) error {
result, err := s.db.Exec(`UPDATE attendance SET check_out=? WHERE user_id=? AND day=? AND check_in IS NOT NULL AND check_out IS NULL`,
time.Now().In(time.Local).Format(time.RFC3339Nano), userID, day)
if err != nil {
return err
}
n, _ := result.RowsAffected()
if n == 0 {
return errors.New("check in before checking out")
}
return nil
}
func (s *Store) AttendanceBetween(userID int64, start, end string) (map[string]Attendance, error) {
rows, err := s.db.Query(`SELECT id,user_id,day,
COALESCE(CAST(check_in AS TEXT),''),COALESCE(CAST(check_out AS TEXT),''),mode FROM attendance
WHERE user_id=? AND day BETWEEN ? AND ?`, userID, start, end)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]Attendance{}
for rows.Next() {
var a Attendance
if err := rows.Scan(&a.ID, &a.UserID, &a.Day, &a.CheckIn, &a.CheckOut, &a.Mode); err != nil {
return nil, err
}
out[a.Day] = a
}
return out, rows.Err()
}
func (s *Store) CreateRequest(userID int64, kind, start, end, reason string) error {
if kind != "leave" && kind != "remote" {
return errors.New("invalid request type")
}
_, err := s.db.Exec(`INSERT INTO requests(user_id,kind,start_date,end_date,reason) VALUES(?,?,?,?,?)`,
userID, kind, start, end, strings.TrimSpace(reason))
return err
}
func (s *Store) Requests(userID int64, admin bool, status string) ([]Request, error) {
query := `SELECT r.id,r.user_id,u.display_name,r.kind,r.start_date,r.end_date,r.reason,r.status,
r.admin_note,r.created_at,r.reviewed_at,COALESCE(a.display_name,'')
FROM requests r JOIN users u ON u.id=r.user_id LEFT JOIN users a ON a.id=r.reviewed_by`
args := []any{}
clauses := []string{}
if !admin {
clauses = append(clauses, "r.user_id=?")
args = append(args, userID)
}
if status != "" {
clauses = append(clauses, "r.status=?")
args = append(args, status)
}
if len(clauses) > 0 {
query += " WHERE " + strings.Join(clauses, " AND ")
}
query += " ORDER BY CASE r.status WHEN 'pending' THEN 0 ELSE 1 END,r.created_at DESC"
rows, err := s.db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Request
for rows.Next() {
var r Request
if err := rows.Scan(&r.ID, &r.UserID, &r.UserName, &r.Kind, &r.StartDate, &r.EndDate,
&r.Reason, &r.Status, &r.AdminNote, &r.CreatedAt, &r.ReviewedAt, &r.Reviewer); err != nil {
return nil, err
}
start, _ := time.Parse("2006-01-02", r.StartDate)
end, _ := time.Parse("2006-01-02", r.EndDate)
r.DayCount = int(end.Sub(start).Hours()/24) + 1
out = append(out, r)
}
return out, rows.Err()
}
func (s *Store) ReviewRequest(ctx context.Context, id, reviewerID int64, status, note string) error {
if status != "approved" && status != "rejected" {
return errors.New("invalid review decision")
}
result, err := s.db.ExecContext(ctx, `UPDATE requests SET status=?,admin_note=?,reviewed_by=?,reviewed_at=?
WHERE id=? AND status='pending'`, status, strings.TrimSpace(note), reviewerID, time.Now(), id)
if err != nil {
return err
}
n, _ := result.RowsAffected()
if n == 0 {
return errors.New("request was already reviewed")
}
return nil
}
func (s *Store) CancelRequest(id, userID int64) error {
_, err := s.db.Exec(`UPDATE requests SET status='cancelled' WHERE id=? AND user_id=? AND status='pending'`, id, userID)
return err
}
func (s *Store) ReportRows(start, end string) (*sql.Rows, error) {
return s.db.Query(`SELECT u.display_name,u.username,a.day,
COALESCE(substr(CAST(a.check_in AS TEXT),12,5),''),
COALESCE(substr(CAST(a.check_out AS TEXT),12,5),''),
a.mode,
COALESCE((SELECT r.kind FROM requests r WHERE r.user_id=u.id AND r.status='approved'
AND a.day BETWEEN r.start_date AND r.end_date ORDER BY r.id DESC LIMIT 1),'')
FROM attendance a JOIN users u ON u.id=a.user_id
WHERE a.day BETWEEN ? AND ? ORDER BY a.day,u.display_name`, start, end)
}
func (s *Store) Stats(userID int64, start, end string) (present, remote, leave int, err error) {
err = s.db.QueryRow(`SELECT
COUNT(*),COALESCE(SUM(CASE WHEN mode='remote' THEN 1 ELSE 0 END),0)
FROM attendance WHERE user_id=? AND day BETWEEN ? AND ?`, userID, start, end).Scan(&present, &remote)
if err != nil {
return
}
err = s.db.QueryRow(`SELECT COALESCE(SUM(julianday(end_date)-julianday(start_date)+1),0)
FROM requests WHERE user_id=? AND kind='leave' AND status='approved'
AND start_date<=? AND end_date>=?`, userID, end, start).Scan(&leave)
return
}
func (s *Store) DayRoster(day string) ([]DayRosterRow, error) {
rows, err := s.db.Query(`SELECT
u.id,u.username,u.display_name,COALESCE(u.email,''),u.role,u.avatar_url,
COALESCE(substr(CAST(a.check_in AS TEXT),12,5),''),
COALESCE(substr(CAST(a.check_out AS TEXT),12,5),''),
COALESCE(a.mode,''),
COALESCE((
SELECT r.kind FROM requests r
WHERE r.user_id=u.id AND r.status='approved' AND ? BETWEEN r.start_date AND r.end_date
ORDER BY CASE r.kind WHEN 'leave' THEN 0 ELSE 1 END,r.id DESC
LIMIT 1
),'')
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
}
defer rows.Close()
var roster []DayRosterRow
for rows.Next() {
var row DayRosterRow
if err := rows.Scan(
&row.User.ID, &row.User.Username, &row.User.DisplayName, &row.User.Email,
&row.User.Role, &row.User.AvatarURL, &row.CheckIn, &row.CheckOut,
&row.Mode, &row.RequestKind,
); err != nil {
return nil, err
}
roster = append(roster, row)
}
return roster, rows.Err()
}
func (s *Store) CreateWorkUpdate(userID int64, start, end, status, note string) error {
note = strings.TrimSpace(note)
if status != "done" && status != "blocked" && status != "pending" {
return errors.New("choose a valid update status")
}
if note == "" {
return errors.New("describe the work, outcome, or blocker")
}
if len([]rune(note)) > 4000 {
return errors.New("work update must be 4,000 characters or fewer")
}
_, err := s.db.Exec(`INSERT INTO work_updates(user_id,period_start,period_end,status,note) VALUES(?,?,?,?,?)`,
userID, start, end, status, note)
return err
}
func (s *Store) WorkUpdates(start, end string, limit int) ([]WorkUpdate, error) {
query := `SELECT w.id,w.user_id,u.display_name,u.username,u.avatar_url,
w.period_start,w.period_end,w.status,w.note,CAST(w.created_at AS TEXT)
FROM work_updates w JOIN users u ON u.id=w.user_id`
args := []any{}
if start != "" && end != "" {
query += ` WHERE w.period_start<=? AND w.period_end>=?`
args = append(args, end, start)
}
query += ` ORDER BY w.created_at DESC,w.id DESC`
if limit > 0 {
query += ` LIMIT ?`
args = append(args, limit)
}
rows, err := s.db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var updates []WorkUpdate
for rows.Next() {
var update WorkUpdate
if err := rows.Scan(
&update.ID, &update.UserID, &update.UserName, &update.Username, &update.AvatarURL,
&update.PeriodStart, &update.PeriodEnd, &update.Status, &update.Note, &update.CreatedAt,
); err != nil {
return nil, err
}
updates = append(updates, update)
}
return updates, rows.Err()
}
func (s *Store) DeleteWorkUpdate(id, userID int64) error {
result, err := s.db.Exec(`DELETE FROM work_updates WHERE id=? AND user_id=?`, id, userID)
if err != nil {
return err
}
count, _ := result.RowsAffected()
if count == 0 {
return errors.New("work update was not found")
}
return nil
}
func (s *Store) ReportSummary(start, end string) ([]PersonReportSummary, error) {
rows, err := s.db.Query(`SELECT
u.id,u.username,u.display_name,COALESCE(u.email,''),u.role,u.avatar_url,
(SELECT COUNT(*) FROM attendance a
WHERE a.user_id=u.id AND a.day BETWEEN ? AND ? AND a.mode='office'),
(SELECT COUNT(*) FROM attendance a
WHERE a.user_id=u.id AND a.day BETWEEN ? AND ? AND a.mode='remote'),
COALESCE((SELECT CAST(SUM(
julianday(MIN(r.end_date,?))-julianday(MAX(r.start_date,?))+1
) AS INTEGER) FROM requests r
WHERE r.user_id=u.id AND r.kind='leave' AND r.status='approved'
AND r.start_date<=? AND r.end_date>=?),0),
(SELECT COUNT(*) FROM work_updates w
WHERE w.user_id=u.id AND w.status='done' AND w.period_start<=? AND w.period_end>=?),
(SELECT COUNT(*) FROM work_updates w
WHERE w.user_id=u.id AND w.status='blocked' AND w.period_start<=? AND w.period_end>=?),
(SELECT COUNT(*) FROM work_updates w
WHERE w.user_id=u.id AND w.status='pending' AND w.period_start<=? AND w.period_end>=?)
FROM users u ORDER BY u.display_name`,
start, end,
start, end,
end, start, end, start,
end, start,
end, start,
end, start,
)
if err != nil {
return nil, err
}
defer rows.Close()
var summaries []PersonReportSummary
for rows.Next() {
var summary PersonReportSummary
if err := rows.Scan(
&summary.User.ID, &summary.User.Username, &summary.User.DisplayName, &summary.User.Email,
&summary.User.Role, &summary.User.AvatarURL, &summary.PresentDays, &summary.RemoteDays,
&summary.LeaveDays, &summary.DoneUpdates, &summary.Blocked, &summary.Pending,
); err != nil {
return nil, err
}
summaries = append(summaries, summary)
}
return summaries, rows.Err()
}
func validBoardStatus(status string) bool {
return status == "backlog" || status == "todo" || status == "in_progress" || status == "blocked" || status == "done"
}
func validBoardImportance(importance string) bool {
return importance == "low" || importance == "normal" || importance == "high" || importance == "urgent"
}
func (s *Store) CreateBoardTask(creatorID int64, title, description, status, importance string, assigneeIDs []int64, dueDate string) (int64, error) {
return s.CreateBoardTaskInWorkspace(1, creatorID, title, description, status, importance, assigneeIDs, dueDate)
}
func (s *Store) CreateBoardTaskInWorkspace(workspaceID, creatorID int64, title, description, status, importance string, assigneeIDs []int64, dueDate string) (int64, error) {
title = strings.TrimSpace(title)
description = strings.TrimSpace(description)
if title == "" {
return 0, errors.New("task title is required")
}
if len([]rune(title)) > 160 || len([]rune(description)) > 4000 {
return 0, errors.New("task title or description is too long")
}
if !validBoardStatus(status) {
status = "backlog"
}
if !validBoardImportance(importance) {
importance = "normal"
}
var due any
if dueDate != "" {
due = dueDate
}
tx, err := s.db.Begin()
if err != nil {
return 0, err
}
defer tx.Rollback()
result, err := tx.Exec(`INSERT INTO board_tasks(workspace_id,title,description,status,importance,creator_id,due_date,created_at)
VALUES(?,?,?,?,?,?,?,?)`, workspaceID, title, description, status, importance, creatorID, due, time.Now().UTC())
if err != nil {
return 0, err
}
taskID, err := result.LastInsertId()
if err != nil {
return 0, err
}
if err := setBoardTaskAssignees(tx, taskID, assigneeIDs); err != nil {
return 0, err
}
return taskID, tx.Commit()
}
func (s *Store) BoardTasks() ([]BoardTask, error) {
return s.boardTasks(false, 0)
}
func (s *Store) BoardTasksInWorkspace(workspaceID int64) ([]BoardTask, error) {
return s.boardTasks(false, workspaceID)
}
func (s *Store) ArchivedBoardTasks() ([]BoardTask, error) {
return s.boardTasks(true, 0)
}
func (s *Store) ArchivedBoardTasksInWorkspace(workspaceID int64) ([]BoardTask, error) {
return s.boardTasks(true, workspaceID)
}
func (s *Store) boardTasks(archived bool, workspaceID int64) ([]BoardTask, error) {
query := `SELECT
t.id,t.title,t.description,t.status,t.importance,
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`
if archived {
query += ` WHERE t.archived_at IS NOT NULL` + func() string { if workspaceID > 0 { return ` AND t.workspace_id=?` }; return `` }() + `
ORDER BY t.archived_at DESC,t.id DESC`
} else {
query += ` WHERE t.archived_at IS NULL` + func() string { if workspaceID > 0 { return ` AND t.workspace_id=?` }; return `` }() + `
ORDER BY CASE t.status
WHEN 'backlog' THEN 0 WHEN 'todo' THEN 1 WHEN 'in_progress' THEN 2 WHEN 'blocked' THEN 3 ELSE 4 END,
t.created_at DESC`
}
var rows *sql.Rows
var err error
if workspaceID > 0 { rows, err = s.db.Query(query, workspaceID) } else { rows, err = s.db.Query(query) }
if err != nil {
return nil, err
}
var tasks []BoardTask
for rows.Next() {
var task BoardTask
if err := rows.Scan(
&task.ID, &task.Title, &task.Description, &task.Status, &task.Importance,
&task.CreatorID, &task.CreatorName,
&task.DueDate, &task.CreatedAt, &task.ArchivedAt,
); err != nil {
return nil, err
}
tasks = append(tasks, task)
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, err
}
if err := rows.Close(); err != nil {
return nil, err
}
taskIndex := make(map[int64]int, len(tasks))
for index := range tasks {
taskIndex[tasks[index].ID] = index
}
assigneeRows, err := s.db.Query(`SELECT a.task_id,u.id,u.username,u.display_name,
COALESCE(u.email,''),u.role,u.avatar_url,u.active
FROM board_task_assignees a JOIN users u ON u.id=a.user_id
ORDER BY u.display_name`)
if err != nil {
return nil, err
}
for assigneeRows.Next() {
var taskID int64
var user User
if err := assigneeRows.Scan(
&taskID, &user.ID, &user.Username, &user.DisplayName,
&user.Email, &user.Role, &user.AvatarURL, &user.Active,
); err != nil {
assigneeRows.Close()
return nil, err
}
if index, ok := taskIndex[taskID]; ok {
tasks[index].Assignees = append(tasks[index].Assignees, user)
}
}
if err := assigneeRows.Err(); err != nil {
assigneeRows.Close()
return nil, err
}
if err := assigneeRows.Close(); err != nil {
return nil, err
}
tagRows, err := s.db.Query(`SELECT tt.task_id,t.id,t.name,t.color
FROM board_task_tags tt JOIN board_tags t ON t.id=tt.tag_id
ORDER BY t.name`)
if err != nil {
return nil, err
}
defer tagRows.Close()
for tagRows.Next() {
var taskID int64
var tag BoardTag
if err := tagRows.Scan(&taskID, &tag.ID, &tag.Name, &tag.Color); err != nil {
return nil, err
}
if index, ok := taskIndex[taskID]; ok {
tasks[index].Tags = append(tasks[index].Tags, tag)
}
}
if err := tagRows.Err(); err != nil {
return nil, err
}
todoRows, err := s.db.Query(`SELECT id,task_id,body,completed FROM board_task_todos
ORDER BY completed,created_at,id`)
if err != nil {
return nil, err
}
defer todoRows.Close()
for todoRows.Next() {
var todo BoardTodo
if err := todoRows.Scan(&todo.ID, &todo.TaskID, &todo.Body, &todo.Completed); err != nil {
return nil, err
}
if index, ok := taskIndex[todo.TaskID]; ok {
tasks[index].Todos = append(tasks[index].Todos, todo)
if todo.Completed {
tasks[index].TodoDone++
}
}
}
return tasks, todoRows.Err()
}
type sqlExecutor interface {
Exec(query string, args ...any) (sql.Result, error)
}
func setBoardTaskAssignees(executor sqlExecutor, taskID int64, userIDs []int64) error {
seen := map[int64]bool{}
for _, userID := range userIDs {
if userID < 1 || seen[userID] {
continue
}
seen[userID] = true
if _, err := executor.Exec(`INSERT INTO board_task_assignees(task_id,user_id)
SELECT ?,id FROM users WHERE id=? AND active=1`, taskID, userID); err != nil {
return err
}
}
return nil
}
func (s *Store) SetBoardTaskDetails(taskID int64, assigneeIDs []int64, importance string) error {
if !validBoardImportance(importance) {
return errors.New("choose a valid importance")
}
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
result, err := tx.Exec(`UPDATE board_tasks SET importance=? WHERE id=?`, importance, taskID)
if err != nil {
return err
}
count, _ := result.RowsAffected()
if count == 0 {
return errors.New("task was not found")
}
if _, err := tx.Exec(`DELETE FROM board_task_assignees WHERE task_id=?`, taskID); err != nil {
return err
}
if err := setBoardTaskAssignees(tx, taskID, assigneeIDs); err != nil {
return err
}
return tx.Commit()
}
func (s *Store) CreateBoardTaskTodo(taskID int64, body string) error {
body = strings.TrimSpace(body)
if body == "" || len([]rune(body)) > 240 {
return errors.New("to-do item must be between 1 and 240 characters")
}
result, err := s.db.Exec(`INSERT INTO board_task_todos(task_id,body) SELECT id,? FROM board_tasks WHERE id=?`, body, taskID)
if err != nil {
return err
}
count, _ := result.RowsAffected()
if count == 0 {
return errors.New("task was not found")
}
return nil
}
func (s *Store) SetBoardTaskTodoCompleted(taskID, todoID int64, completed bool) error {
value := 0
if completed {
value = 1
}
result, err := s.db.Exec(`UPDATE board_task_todos SET completed=? WHERE id=? AND task_id=?`, value, todoID, taskID)
if err != nil {
return err
}
count, _ := result.RowsAffected()
if count == 0 {
return errors.New("to-do item was not found")
}
return nil
}
func (s *Store) DeleteBoardTaskTodo(taskID, todoID int64) error {
result, err := s.db.Exec(`DELETE FROM board_task_todos WHERE id=? AND task_id=?`, todoID, taskID)
if err != nil {
return err
}
count, _ := result.RowsAffected()
if count == 0 {
return errors.New("to-do item was not found")
}
return nil
}
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=? AND archived_at IS NULL`, status, id)
if err != nil {
return err
}
count, _ := result.RowsAffected()
if count == 0 {
return errors.New("task was not found")
}
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}
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 delete this task")
}
return nil
}
func validTagColor(color string) bool {
switch color {
case "green", "blue", "red", "amber", "purple", "slate":
return true
default:
return false
}
}
func (s *Store) CreateBoardTag(name, color string) error {
name = strings.TrimSpace(name)
color = strings.TrimSpace(color)
if name == "" || len([]rune(name)) > 40 {
return errors.New("tag name must be between 1 and 40 characters")
}
if !validTagColor(color) {
return errors.New("choose a valid tag color")
}
_, err := s.db.Exec(`INSERT INTO board_tags(name,color) VALUES(?,?)`, name, color)
if err != nil && strings.Contains(strings.ToLower(err.Error()), "unique") {
return errors.New("a tag with that name already exists")
}
return err
}
func (s *Store) FindOrCreateBoardTag(name, color string) (BoardTag, error) {
name = strings.TrimSpace(name)
color = strings.TrimSpace(color)
if name == "" || len([]rune(name)) > 40 {
return BoardTag{}, errors.New("tag name must be between 1 and 40 characters")
}
if !validTagColor(color) {
return BoardTag{}, errors.New("choose a valid tag color")
}
if _, err := s.db.Exec(`INSERT INTO board_tags(name,color) VALUES(?,?)
ON CONFLICT(name) DO NOTHING`, name, color); err != nil {
return BoardTag{}, err
}
var tag BoardTag
err := s.db.QueryRow(`SELECT id,name,color FROM board_tags
WHERE name = ? COLLATE NOCASE`, name).Scan(&tag.ID, &tag.Name, &tag.Color)
return tag, err
}
func (s *Store) BoardTags() ([]BoardTag, error) {
rows, err := s.db.Query(`SELECT id,name,color FROM board_tags ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
var tags []BoardTag
for rows.Next() {
var tag BoardTag
if err := rows.Scan(&tag.ID, &tag.Name, &tag.Color); err != nil {
return nil, err
}
tags = append(tags, tag)
}
return tags, rows.Err()
}
func (s *Store) SetBoardTaskTags(taskID int64, tagIDs []int64) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
var exists int
if err := tx.QueryRow(`SELECT COUNT(*) FROM board_tasks WHERE id=?`, taskID).Scan(&exists); err != nil {
return err
}
if exists == 0 {
return errors.New("task was not found")
}
if _, err := tx.Exec(`DELETE FROM board_task_tags WHERE task_id=?`, taskID); err != nil {
return err
}
seen := map[int64]bool{}
for _, tagID := range tagIDs {
if tagID < 1 || seen[tagID] {
continue
}
seen[tagID] = true
if _, err := tx.Exec(`INSERT INTO board_task_tags(task_id,tag_id)
SELECT ?,id FROM board_tags WHERE id=?`, taskID, tagID); err != nil {
return err
}
}
return tx.Commit()
}
func (s *Store) DeleteBoardTag(id int64) error {
result, err := s.db.Exec(`DELETE FROM board_tags WHERE id=?`, id)
if err != nil {
return err
}
count, _ := result.RowsAffected()
if count == 0 {
return errors.New("tag was not found")
}
return nil
}