1014 lines
30 KiB
Go
1014 lines
30 KiB
Go
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
|
||
}
|
||
|
||
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
|
||
AssigneeID sql.NullInt64
|
||
AssigneeName string
|
||
AssigneeUser string
|
||
CreatorID int64
|
||
CreatorName string
|
||
DueDate string
|
||
CreatedAt string
|
||
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) 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 '',
|
||
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')),
|
||
assignee_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||
creator_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||
due_date TEXT,
|
||
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_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
|
||
}
|
||
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
|
||
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)
|
||
if err != nil || !verifyPassword(hash, password) {
|
||
return nil, errors.New("invalid email, username, or password")
|
||
}
|
||
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) {
|
||
rows, err := s.db.Query(`SELECT id,username,display_name,COALESCE(email,''),role,avatar_url FROM users ORDER BY display_name`)
|
||
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); 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
|
||
}
|
||
_, 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))
|
||
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
|
||
err = tx.QueryRow(`SELECT user_id FROM oauth_accounts WHERE provider=? AND provider_user_id=?`, provider, providerID).Scan(&userID)
|
||
if err == nil {
|
||
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)
|
||
}
|
||
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,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)
|
||
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) 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=?
|
||
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 (s *Store) CreateBoardTask(creatorID int64, title, description, status string, assigneeID *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"
|
||
}
|
||
var assignee any
|
||
if assigneeID != nil && *assigneeID > 0 {
|
||
assignee = *assigneeID
|
||
}
|
||
var due any
|
||
if dueDate != "" {
|
||
due = dueDate
|
||
}
|
||
result, err := s.db.Exec(`INSERT INTO board_tasks(title,description,status,assignee_id,creator_id,due_date)
|
||
VALUES(?,?,?,?,?,?)`, title, description, status, assignee, creatorID, due)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
return result.LastInsertId()
|
||
}
|
||
|
||
func (s *Store) BoardTasks() ([]BoardTask, error) {
|
||
rows, err := s.db.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)
|
||
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
|
||
WHEN 'backlog' THEN 0 WHEN 'in_progress' THEN 1 WHEN 'blocked' THEN 2 ELSE 3 END,
|
||
t.created_at,t.id`)
|
||
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.AssigneeID,
|
||
&task.AssigneeName, &task.AssigneeUser, &task.CreatorID, &task.CreatorName,
|
||
&task.DueDate, &task.CreatedAt,
|
||
); 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
|
||
}
|
||
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()
|
||
}
|
||
|
||
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)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
count, _ := result.RowsAffected()
|
||
if count == 0 {
|
||
return errors.New("task was not found")
|
||
}
|
||
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 remove 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
|
||
}
|