448 lines
14 KiB
Go
448 lines
14 KiB
Go
|
|
package main
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"crypto/rand"
|
|||
|
|
"database/sql"
|
|||
|
|
"encoding/hex"
|
|||
|
|
"encoding/json"
|
|||
|
|
"errors"
|
|||
|
|
"strings"
|
|||
|
|
"sync"
|
|||
|
|
"time"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// newUUID 生成 v4 风格 UUID(同步元数据用)。
|
|||
|
|
func newUUID() string {
|
|||
|
|
b := make([]byte, 16)
|
|||
|
|
_, _ = rand.Read(b)
|
|||
|
|
b[6] = (b[6] & 0x0f) | 0x40
|
|||
|
|
b[8] = (b[8] & 0x3f) | 0x80
|
|||
|
|
h := hex.EncodeToString(b)
|
|||
|
|
return h[:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// nowRFC 返回 UTC RFC3339 时间戳,进程内严格递增:同一秒内的连续写入
|
|||
|
|
// 借用下一秒,保证 LWW(按字符串比较 updated_at)能区分先后。
|
|||
|
|
func nowRFC() string {
|
|||
|
|
nowMu.Lock()
|
|||
|
|
defer nowMu.Unlock()
|
|||
|
|
s := time.Now().UTC().Format(time.RFC3339)
|
|||
|
|
if s <= lastNowRFC {
|
|||
|
|
if t, e := time.Parse(time.RFC3339, lastNowRFC); e == nil {
|
|||
|
|
s = t.Add(time.Second).UTC().Format(time.RFC3339)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
lastNowRFC = s
|
|||
|
|
return s
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var (
|
|||
|
|
nowMu sync.Mutex
|
|||
|
|
lastNowRFC string
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
var todoStatuses = map[string]bool{"open": true, "doing": true, "done": true}
|
|||
|
|
var ticketStatuses = map[string]bool{"open": true, "in_progress": true, "resolved": true, "closed": true}
|
|||
|
|
var priorities = map[string]bool{"low": true, "medium": true, "high": true}
|
|||
|
|
|
|||
|
|
// histEntry 生命周期节点:何时进入了哪个状态。
|
|||
|
|
type histEntry struct {
|
|||
|
|
Status string `json:"status"`
|
|||
|
|
At string `json:"at"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// appendHistory 在既有轨迹(JSON 数组,可为空)末尾追加一个状态节点。
|
|||
|
|
func appendHistory(prev, status, at string) string {
|
|||
|
|
var list []histEntry
|
|||
|
|
if strings.TrimSpace(prev) != "" {
|
|||
|
|
_ = json.Unmarshal([]byte(prev), &list)
|
|||
|
|
}
|
|||
|
|
list = append(list, histEntry{Status: status, At: at})
|
|||
|
|
b, _ := json.Marshal(list)
|
|||
|
|
return string(b)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- Todos ----------
|
|||
|
|
|
|||
|
|
const todoCols = `t.id,t.uuid,t.title,t.content,t.project_id,COALESCE(p.name,''),t.due_at,t.priority,t.status,t.created_at,t.updated_at,t.history,t.team_id`
|
|||
|
|
|
|||
|
|
func scanTodo(r interface{ Scan(...any) error }) (Todo, error) {
|
|||
|
|
var x Todo
|
|||
|
|
e := r.Scan(&x.ID, &x.UUID, &x.Title, &x.Content, &x.ProjectID, &x.ProjectName, &x.DueAt, &x.Priority, &x.Status, &x.CreatedAt, &x.UpdatedAt, &x.History, &x.TeamID)
|
|||
|
|
return x, e
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *Store) ListTodos(status string, projectID int64) ([]Todo, error) {
|
|||
|
|
q := `SELECT ` + todoCols + ` FROM todos t LEFT JOIN projects p ON p.id=t.project_id WHERE t.deleted=0`
|
|||
|
|
args := []any{}
|
|||
|
|
if status != "" && status != "all" {
|
|||
|
|
q += ` AND t.status=?`
|
|||
|
|
args = append(args, status)
|
|||
|
|
}
|
|||
|
|
if projectID > 0 {
|
|||
|
|
q += ` AND t.project_id=?`
|
|||
|
|
args = append(args, projectID)
|
|||
|
|
}
|
|||
|
|
q += ` ORDER BY CASE t.status WHEN 'doing' THEN 0 WHEN 'open' THEN 1 ELSE 2 END, CASE WHEN t.due_at='' THEN 1 ELSE 0 END, t.due_at, t.id DESC`
|
|||
|
|
rows, e := s.db.Query(q, args...)
|
|||
|
|
if e != nil {
|
|||
|
|
return nil, e
|
|||
|
|
}
|
|||
|
|
defer rows.Close()
|
|||
|
|
out := []Todo{}
|
|||
|
|
for rows.Next() {
|
|||
|
|
x, e := scanTodo(rows)
|
|||
|
|
if e != nil {
|
|||
|
|
return nil, e
|
|||
|
|
}
|
|||
|
|
out = append(out, x)
|
|||
|
|
}
|
|||
|
|
return out, rows.Err()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *Store) GetTodo(id int64) (Todo, error) {
|
|||
|
|
return scanTodo(s.db.QueryRow(`SELECT `+todoCols+` FROM todos t LEFT JOIN projects p ON p.id=t.project_id WHERE t.id=? AND t.deleted=0`, id))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *Store) SaveTodo(x Todo) (Todo, error) {
|
|||
|
|
x.Title = strings.TrimSpace(x.Title)
|
|||
|
|
if x.Title == "" {
|
|||
|
|
return x, errors.New("TODO_TITLE_REQUIRED")
|
|||
|
|
}
|
|||
|
|
if !priorities[x.Priority] {
|
|||
|
|
x.Priority = "medium"
|
|||
|
|
}
|
|||
|
|
if !todoStatuses[x.Status] {
|
|||
|
|
x.Status = "open"
|
|||
|
|
}
|
|||
|
|
now := nowRFC()
|
|||
|
|
if x.ID == 0 {
|
|||
|
|
res, e := s.db.Exec(`INSERT INTO todos(uuid,title,content,project_id,due_at,priority,status,reminded,created_at,updated_at,deleted,dirty,history) VALUES(?,?,?,?,?,?,?,0,?,?,0,1,?)`,
|
|||
|
|
newUUID(), x.Title, x.Content, x.ProjectID, x.DueAt, x.Priority, x.Status, now, now, appendHistory("", x.Status, now))
|
|||
|
|
if e != nil {
|
|||
|
|
return x, e
|
|||
|
|
}
|
|||
|
|
id, _ := res.LastInsertId()
|
|||
|
|
return s.GetTodo(id)
|
|||
|
|
}
|
|||
|
|
// 编辑时若状态发生变化,为生命周期追加一个节点。
|
|||
|
|
var oldStatus, hist string
|
|||
|
|
if e := s.db.QueryRow(`SELECT status,history FROM todos WHERE id=? AND deleted=0`, x.ID).Scan(&oldStatus, &hist); e != nil {
|
|||
|
|
return x, e
|
|||
|
|
}
|
|||
|
|
if oldStatus != x.Status {
|
|||
|
|
hist = appendHistory(hist, x.Status, now)
|
|||
|
|
}
|
|||
|
|
if _, e := s.db.Exec(`UPDATE todos SET title=?,content=?,project_id=?,due_at=?,priority=?,status=?,reminded=0,updated_at=?,dirty=1,history=? WHERE id=? AND deleted=0`,
|
|||
|
|
x.Title, x.Content, x.ProjectID, x.DueAt, x.Priority, x.Status, now, hist, x.ID); e != nil {
|
|||
|
|
return x, e
|
|||
|
|
}
|
|||
|
|
return s.GetTodo(x.ID)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *Store) SetTodoStatus(id int64, status string) error {
|
|||
|
|
if !todoStatuses[status] {
|
|||
|
|
return errors.New("TODO_STATUS_INVALID")
|
|||
|
|
}
|
|||
|
|
var oldStatus, hist string
|
|||
|
|
switch e := s.db.QueryRow(`SELECT status,history FROM todos WHERE id=? AND deleted=0`, id).Scan(&oldStatus, &hist); {
|
|||
|
|
case e == sql.ErrNoRows:
|
|||
|
|
return nil
|
|||
|
|
case e != nil:
|
|||
|
|
return e
|
|||
|
|
}
|
|||
|
|
if oldStatus == status {
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
now := nowRFC()
|
|||
|
|
_, e := s.db.Exec(`UPDATE todos SET status=?,updated_at=?,dirty=1,history=? WHERE id=? AND deleted=0`, status, now, appendHistory(hist, status, now), id)
|
|||
|
|
return e
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *Store) DeleteTodo(id int64) error {
|
|||
|
|
_, e := s.db.Exec(`UPDATE todos SET deleted=1,updated_at=?,dirty=1 WHERE id=?`, nowRFC(), id)
|
|||
|
|
return e
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- Tickets ----------
|
|||
|
|
|
|||
|
|
const ticketCols = `t.id,t.uuid,t.title,t.description,t.type,t.project_id,COALESCE(p.name,''),t.start_at,t.due_at,t.status,t.priority,t.created_at,t.updated_at,t.history,t.team_id`
|
|||
|
|
|
|||
|
|
func scanTicket(r interface{ Scan(...any) error }) (Ticket, error) {
|
|||
|
|
var x Ticket
|
|||
|
|
e := r.Scan(&x.ID, &x.UUID, &x.Title, &x.Description, &x.Type, &x.ProjectID, &x.ProjectName, &x.StartAt, &x.DueAt, &x.Status, &x.Priority, &x.CreatedAt, &x.UpdatedAt, &x.History, &x.TeamID)
|
|||
|
|
return x, e
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *Store) ListTickets(status string, projectID int64) ([]Ticket, error) {
|
|||
|
|
q := `SELECT ` + ticketCols + ` FROM tickets t LEFT JOIN projects p ON p.id=t.project_id WHERE t.deleted=0`
|
|||
|
|
args := []any{}
|
|||
|
|
if status != "" && status != "all" {
|
|||
|
|
q += ` AND t.status=?`
|
|||
|
|
args = append(args, status)
|
|||
|
|
}
|
|||
|
|
if projectID > 0 {
|
|||
|
|
q += ` AND t.project_id=?`
|
|||
|
|
args = append(args, projectID)
|
|||
|
|
}
|
|||
|
|
q += ` ORDER BY CASE t.status WHEN 'in_progress' THEN 0 WHEN 'open' THEN 1 WHEN 'resolved' THEN 2 ELSE 3 END, t.due_at, t.id DESC`
|
|||
|
|
rows, e := s.db.Query(q, args...)
|
|||
|
|
if e != nil {
|
|||
|
|
return nil, e
|
|||
|
|
}
|
|||
|
|
defer rows.Close()
|
|||
|
|
out := []Ticket{}
|
|||
|
|
for rows.Next() {
|
|||
|
|
x, e := scanTicket(rows)
|
|||
|
|
if e != nil {
|
|||
|
|
return nil, e
|
|||
|
|
}
|
|||
|
|
out = append(out, x)
|
|||
|
|
}
|
|||
|
|
return out, rows.Err()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *Store) GetTicket(id int64) (Ticket, error) {
|
|||
|
|
return scanTicket(s.db.QueryRow(`SELECT `+ticketCols+` FROM tickets t LEFT JOIN projects p ON p.id=t.project_id WHERE t.id=? AND t.deleted=0`, id))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *Store) SaveTicket(x Ticket) (Ticket, error) {
|
|||
|
|
x.Title = strings.TrimSpace(x.Title)
|
|||
|
|
if x.Title == "" {
|
|||
|
|
return x, errors.New("TICKET_TITLE_REQUIRED")
|
|||
|
|
}
|
|||
|
|
if x.ProjectID <= 0 {
|
|||
|
|
return x, errors.New("TICKET_PROJECT_REQUIRED")
|
|||
|
|
}
|
|||
|
|
if p, e := s.GetProject(x.ProjectID); e != nil || p.ID == 0 {
|
|||
|
|
return x, errors.New("TICKET_PROJECT_REQUIRED")
|
|||
|
|
}
|
|||
|
|
if strings.TrimSpace(x.StartAt) == "" || strings.TrimSpace(x.DueAt) == "" {
|
|||
|
|
return x, errors.New("TICKET_SCHEDULE_REQUIRED")
|
|||
|
|
}
|
|||
|
|
if x.DueAt < x.StartAt {
|
|||
|
|
return x, errors.New("TICKET_SCHEDULE_INVALID")
|
|||
|
|
}
|
|||
|
|
if !priorities[x.Priority] {
|
|||
|
|
x.Priority = "medium"
|
|||
|
|
}
|
|||
|
|
if !ticketStatuses[x.Status] {
|
|||
|
|
x.Status = "open"
|
|||
|
|
}
|
|||
|
|
switch x.Type {
|
|||
|
|
case "feature", "bug", "task", "improvement":
|
|||
|
|
default:
|
|||
|
|
x.Type = "task"
|
|||
|
|
}
|
|||
|
|
now := nowRFC()
|
|||
|
|
if x.ID == 0 {
|
|||
|
|
res, e := s.db.Exec(`INSERT INTO tickets(uuid,title,description,type,project_id,start_at,due_at,status,priority,reminded,created_at,updated_at,deleted,dirty,history) VALUES(?,?,?,?,?,?,?,?,?,0,?,?,0,1,?)`,
|
|||
|
|
newUUID(), x.Title, x.Description, x.Type, x.ProjectID, x.StartAt, x.DueAt, x.Status, x.Priority, now, now, appendHistory("", x.Status, now))
|
|||
|
|
if e != nil {
|
|||
|
|
return x, e
|
|||
|
|
}
|
|||
|
|
id, _ := res.LastInsertId()
|
|||
|
|
return s.GetTicket(id)
|
|||
|
|
}
|
|||
|
|
// 编辑时若状态发生变化,为生命周期追加一个节点。
|
|||
|
|
var oldStatus, hist string
|
|||
|
|
if e := s.db.QueryRow(`SELECT status,history FROM tickets WHERE id=? AND deleted=0`, x.ID).Scan(&oldStatus, &hist); e != nil {
|
|||
|
|
return x, e
|
|||
|
|
}
|
|||
|
|
if oldStatus != x.Status {
|
|||
|
|
hist = appendHistory(hist, x.Status, now)
|
|||
|
|
}
|
|||
|
|
if _, e := s.db.Exec(`UPDATE tickets SET title=?,description=?,type=?,project_id=?,start_at=?,due_at=?,status=?,priority=?,reminded=0,updated_at=?,dirty=1,history=? WHERE id=? AND deleted=0`,
|
|||
|
|
x.Title, x.Description, x.Type, x.ProjectID, x.StartAt, x.DueAt, x.Status, x.Priority, now, hist, x.ID); e != nil {
|
|||
|
|
return x, e
|
|||
|
|
}
|
|||
|
|
return s.GetTicket(x.ID)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *Store) SetTicketStatus(id int64, status string) error {
|
|||
|
|
if !ticketStatuses[status] {
|
|||
|
|
return errors.New("TICKET_STATUS_INVALID")
|
|||
|
|
}
|
|||
|
|
var oldStatus, hist string
|
|||
|
|
switch e := s.db.QueryRow(`SELECT status,history FROM tickets WHERE id=? AND deleted=0`, id).Scan(&oldStatus, &hist); {
|
|||
|
|
case e == sql.ErrNoRows:
|
|||
|
|
return nil
|
|||
|
|
case e != nil:
|
|||
|
|
return e
|
|||
|
|
}
|
|||
|
|
if oldStatus == status {
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
now := nowRFC()
|
|||
|
|
_, e := s.db.Exec(`UPDATE tickets SET status=?,updated_at=?,dirty=1,history=? WHERE id=? AND deleted=0`, status, now, appendHistory(hist, status, now), id)
|
|||
|
|
return e
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *Store) DeleteTicket(id int64) error {
|
|||
|
|
_, e := s.db.Exec(`UPDATE tickets SET deleted=1,updated_at=?,dirty=1 WHERE id=?`, nowRFC(), id)
|
|||
|
|
return e
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- Notes(多条笔记:顶栏笔记中心 + 工作台记事本) ----------
|
|||
|
|
|
|||
|
|
// GetNote 返回最近更新的一条笔记(工作台记事本入口),无笔记时创建一条空笔记。
|
|||
|
|
func (s *Store) GetNote() (Note, error) {
|
|||
|
|
var x Note
|
|||
|
|
e := s.db.QueryRow(`SELECT id,uuid,content,updated_at FROM notes WHERE deleted=0 ORDER BY updated_at DESC,id DESC LIMIT 1`).
|
|||
|
|
Scan(&x.ID, &x.UUID, &x.Content, &x.UpdatedAt)
|
|||
|
|
if e == nil {
|
|||
|
|
return x, nil
|
|||
|
|
}
|
|||
|
|
x = Note{UUID: newUUID(), UpdatedAt: nowRFC()}
|
|||
|
|
res, e := s.db.Exec(`INSERT INTO notes(uuid,content,updated_at,deleted,dirty) VALUES(?, '', ?, 0, 1)`, x.UUID, x.UpdatedAt)
|
|||
|
|
if e != nil {
|
|||
|
|
return x, e
|
|||
|
|
}
|
|||
|
|
x.ID, _ = res.LastInsertId()
|
|||
|
|
return x, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// SaveNote 兼容旧入口:更新最近一条笔记(无则创建)。
|
|||
|
|
func (s *Store) SaveNote(content string) (Note, error) {
|
|||
|
|
n, e := s.GetNote()
|
|||
|
|
if e != nil {
|
|||
|
|
return n, e
|
|||
|
|
}
|
|||
|
|
return s.SaveNoteByID(n.ID, content)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *Store) ListNotes(limit int) ([]Note, error) {
|
|||
|
|
if limit <= 0 {
|
|||
|
|
limit = 100
|
|||
|
|
}
|
|||
|
|
rows, e := s.db.Query(`SELECT id,uuid,content,updated_at FROM notes WHERE deleted=0 ORDER BY updated_at DESC,id DESC LIMIT ?`, limit)
|
|||
|
|
if e != nil {
|
|||
|
|
return nil, e
|
|||
|
|
}
|
|||
|
|
defer rows.Close()
|
|||
|
|
out := []Note{}
|
|||
|
|
for rows.Next() {
|
|||
|
|
var x Note
|
|||
|
|
if e = rows.Scan(&x.ID, &x.UUID, &x.Content, &x.UpdatedAt); e != nil {
|
|||
|
|
return nil, e
|
|||
|
|
}
|
|||
|
|
out = append(out, x)
|
|||
|
|
}
|
|||
|
|
return out, rows.Err()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// SaveNoteByID 保存指定笔记;id=0 时新建。
|
|||
|
|
func (s *Store) SaveNoteByID(id int64, content string) (Note, error) {
|
|||
|
|
now := nowRFC()
|
|||
|
|
if id == 0 {
|
|||
|
|
x := Note{UUID: newUUID(), Content: content, UpdatedAt: now}
|
|||
|
|
res, e := s.db.Exec(`INSERT INTO notes(uuid,content,updated_at,deleted,dirty) VALUES(?,?,?,0,1)`, x.UUID, content, now)
|
|||
|
|
if e != nil {
|
|||
|
|
return x, e
|
|||
|
|
}
|
|||
|
|
x.ID, _ = res.LastInsertId()
|
|||
|
|
return x, nil
|
|||
|
|
}
|
|||
|
|
if _, e := s.db.Exec(`UPDATE notes SET content=?,updated_at=?,dirty=1 WHERE id=? AND deleted=0`, content, now, id); e != nil {
|
|||
|
|
return Note{}, e
|
|||
|
|
}
|
|||
|
|
var x Note
|
|||
|
|
e := s.db.QueryRow(`SELECT id,uuid,content,updated_at FROM notes WHERE id=?`, id).
|
|||
|
|
Scan(&x.ID, &x.UUID, &x.Content, &x.UpdatedAt)
|
|||
|
|
if e == sql.ErrNoRows {
|
|||
|
|
return Note{}, errors.New("NOTE_NOT_FOUND")
|
|||
|
|
}
|
|||
|
|
return x, e
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *Store) DeleteNote(id int64) error {
|
|||
|
|
_, e := s.db.Exec(`UPDATE notes SET deleted=1,updated_at=?,dirty=1 WHERE id=?`, nowRFC(), id)
|
|||
|
|
return e
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- Favorites(纯本地) ----------
|
|||
|
|
|
|||
|
|
func (s *Store) ListFavorites() ([]int64, error) {
|
|||
|
|
rows, e := s.db.Query(`SELECT project_id FROM favorites ORDER BY created_at DESC`)
|
|||
|
|
if e != nil {
|
|||
|
|
return nil, e
|
|||
|
|
}
|
|||
|
|
defer rows.Close()
|
|||
|
|
out := []int64{}
|
|||
|
|
for rows.Next() {
|
|||
|
|
var id int64
|
|||
|
|
if e = rows.Scan(&id); e != nil {
|
|||
|
|
return nil, e
|
|||
|
|
}
|
|||
|
|
out = append(out, id)
|
|||
|
|
}
|
|||
|
|
return out, rows.Err()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *Store) ToggleFavorite(projectID int64) (bool, error) {
|
|||
|
|
var exists int
|
|||
|
|
_ = s.db.QueryRow(`SELECT 1 FROM favorites WHERE project_id=?`, projectID).Scan(&exists)
|
|||
|
|
if exists == 1 {
|
|||
|
|
_, e := s.db.Exec(`DELETE FROM favorites WHERE project_id=?`, projectID)
|
|||
|
|
return false, e
|
|||
|
|
}
|
|||
|
|
_, e := s.db.Exec(`INSERT INTO favorites(project_id,created_at) VALUES(?,?)`, projectID, nowRFC())
|
|||
|
|
return true, e
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- Messages(纯本地) ----------
|
|||
|
|
|
|||
|
|
func (s *Store) AddMessage(kind, title, body, sourceType string, sourceID int64) (Message, error) {
|
|||
|
|
now := nowRFC()
|
|||
|
|
res, e := s.db.Exec(`INSERT INTO messages(kind,title,body,source_type,source_id,is_read,created_at) VALUES(?,?,?,?,?,0,?)`,
|
|||
|
|
kind, title, body, sourceType, sourceID, now)
|
|||
|
|
if e != nil {
|
|||
|
|
return Message{}, e
|
|||
|
|
}
|
|||
|
|
id, _ := res.LastInsertId()
|
|||
|
|
return Message{ID: id, Kind: kind, Title: title, Body: body, SourceType: sourceType, SourceID: sourceID, CreatedAt: now}, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *Store) ListMessages(limit int64) ([]Message, error) {
|
|||
|
|
if limit <= 0 || limit > 200 {
|
|||
|
|
limit = 50
|
|||
|
|
}
|
|||
|
|
rows, e := s.db.Query(`SELECT id,kind,title,body,source_type,source_id,is_read,created_at FROM messages ORDER BY id DESC LIMIT ?`, limit)
|
|||
|
|
if e != nil {
|
|||
|
|
return nil, e
|
|||
|
|
}
|
|||
|
|
defer rows.Close()
|
|||
|
|
out := []Message{}
|
|||
|
|
for rows.Next() {
|
|||
|
|
var x Message
|
|||
|
|
var read int
|
|||
|
|
if e = rows.Scan(&x.ID, &x.Kind, &x.Title, &x.Body, &x.SourceType, &x.SourceID, &read, &x.CreatedAt); e != nil {
|
|||
|
|
return nil, e
|
|||
|
|
}
|
|||
|
|
x.Read = read == 1
|
|||
|
|
out = append(out, x)
|
|||
|
|
}
|
|||
|
|
return out, rows.Err()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *Store) UnreadMessageCount() int64 {
|
|||
|
|
var n int64
|
|||
|
|
_ = s.db.QueryRow(`SELECT COUNT(*) FROM messages WHERE is_read=0`).Scan(&n)
|
|||
|
|
return n
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *Store) MarkMessageRead(id int64) error {
|
|||
|
|
_, e := s.db.Exec(`UPDATE messages SET is_read=1 WHERE id=?`, id)
|
|||
|
|
return e
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *Store) MarkAllMessagesRead() error {
|
|||
|
|
_, e := s.db.Exec(`UPDATE messages SET is_read=1 WHERE is_read=0`)
|
|||
|
|
return e
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *Store) ClearMessages() error {
|
|||
|
|
_, e := s.db.Exec(`DELETE FROM messages`)
|
|||
|
|
return e
|
|||
|
|
}
|