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 } 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 } type BoardTag struct { ID int64 Name string Color string } 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, ¬Null, &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 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, title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'backlog' CHECK(status IN ('backlog','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_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.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") return err } 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 3–40 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) 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 } 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 == "" { 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, ¤tlyActive); 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, 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 == "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) { 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(title,description,status,importance,creator_id,due_date,created_at) VALUES(?,?,?,?,?,?,?)`, 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) } 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.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 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 DESC` } 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) } } return tasks, tagRows.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) 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 }