feat(notification): add scheduled email pipeline with prefs/groups and DLQ UI
This commit is contained in:
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"wolves.top/todo/internal/iam"
|
||||
"wolves.top/todo/internal/notification"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jackc/pgconn"
|
||||
@@ -21,15 +22,16 @@ import (
|
||||
)
|
||||
|
||||
type Task struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
DueAt string `json:"due_at"`
|
||||
Priority int `json:"priority"`
|
||||
Tags []string `json:"tags"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
DueAt string `json:"due_at"`
|
||||
Priority int `json:"priority"`
|
||||
Tags []string `json:"tags"`
|
||||
NotifyGroupIDs []int64 `json:"notify_group_ids"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type postgresStore struct {
|
||||
@@ -79,9 +81,85 @@ func (s *postgresStore) initSchema(ctx context.Context) error {
|
||||
due_at TEXT,
|
||||
priority INT NOT NULL DEFAULT 0,
|
||||
tags TEXT[] NOT NULL DEFAULT '{}',
|
||||
notify_group_ids BIGINT[] NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
)`,
|
||||
`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS notify_group_ids BIGINT[] NOT NULL DEFAULT '{}'`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS user_notification_prefs (
|
||||
user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
subscribed BOOLEAN NOT NULL DEFAULT true,
|
||||
dnd_start TIME NULL,
|
||||
dnd_end TIME NULL,
|
||||
locale TEXT NOT NULL DEFAULT 'zh',
|
||||
timezone TEXT NOT NULL DEFAULT 'Asia/Shanghai',
|
||||
daily_summary_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
daily_summary_time TIME NOT NULL DEFAULT '09:30:00',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS notification_groups (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
emails TEXT[] NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (user_id, name)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS task_notification_groups (
|
||||
task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
group_id BIGINT NOT NULL REFERENCES notification_groups(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (task_id, group_id)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS notification_events (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
event_type TEXT NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
occurred_at TIMESTAMPTZ NOT NULL,
|
||||
payload JSONB NOT NULL,
|
||||
trace_id TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS notification_jobs (
|
||||
job_id TEXT PRIMARY KEY,
|
||||
event_id TEXT NOT NULL REFERENCES notification_events(event_id),
|
||||
trace_id TEXT NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
channel TEXT NOT NULL DEFAULT 'email',
|
||||
to_emails TEXT[] NOT NULL,
|
||||
template_id TEXT NOT NULL,
|
||||
params JSONB NOT NULL,
|
||||
idempotency_key TEXT NOT NULL UNIQUE,
|
||||
status TEXT NOT NULL,
|
||||
scheduled_at TIMESTAMPTZ NOT NULL,
|
||||
available_at TIMESTAMPTZ NOT NULL,
|
||||
retry_count INT NOT NULL DEFAULT 0,
|
||||
max_retry INT NOT NULL DEFAULT 5,
|
||||
last_error_code TEXT NULL,
|
||||
last_error_message TEXT NULL,
|
||||
sent_at TIMESTAMPTZ NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_notification_jobs_status_available ON notification_jobs(status, available_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_notification_jobs_scheduled ON notification_jobs(scheduled_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS notification_attempts (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
job_id TEXT NOT NULL REFERENCES notification_jobs(job_id) ON DELETE CASCADE,
|
||||
attempt_no INT NOT NULL,
|
||||
success BOOLEAN NOT NULL,
|
||||
error_code TEXT NULL,
|
||||
error_message TEXT NULL,
|
||||
latency_ms INT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS notification_dlq (
|
||||
job_id TEXT PRIMARY KEY REFERENCES notification_jobs(job_id),
|
||||
reason TEXT NOT NULL,
|
||||
failed_at TIMESTAMPTZ NOT NULL,
|
||||
snapshot JSONB NOT NULL
|
||||
)`,
|
||||
}
|
||||
for _, stmt := range statements {
|
||||
if _, err := s.pool.Exec(ctx, stmt); err != nil {
|
||||
@@ -92,7 +170,7 @@ func (s *postgresStore) initSchema(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (s *postgresStore) List(ctx context.Context, userID int64) ([]Task, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT id, title, description, status, due_at, priority, tags, created_at, updated_at FROM tasks WHERE user_id = $1 ORDER BY id DESC`, userID)
|
||||
rows, err := s.pool.Query(ctx, `SELECT id, title, description, status, due_at, priority, tags, notify_group_ids, created_at, updated_at FROM tasks WHERE user_id = $1 ORDER BY id DESC`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -101,10 +179,12 @@ func (s *postgresStore) List(ctx context.Context, userID int64) ([]Task, error)
|
||||
for rows.Next() {
|
||||
var task Task
|
||||
var tags []string
|
||||
if err := rows.Scan(&task.ID, &task.Title, &task.Description, &task.Status, &task.DueAt, &task.Priority, &tags, &task.CreatedAt, &task.UpdatedAt); err != nil {
|
||||
var notifyGroupIDs []int64
|
||||
if err := rows.Scan(&task.ID, &task.Title, &task.Description, &task.Status, &task.DueAt, &task.Priority, &tags, ¬ifyGroupIDs, &task.CreatedAt, &task.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
task.Tags = tags
|
||||
task.NotifyGroupIDs = notifyGroupIDs
|
||||
result = append(result, task)
|
||||
}
|
||||
return result, rows.Err()
|
||||
@@ -113,12 +193,14 @@ func (s *postgresStore) List(ctx context.Context, userID int64) ([]Task, error)
|
||||
func (s *postgresStore) Get(ctx context.Context, userID, id int64) (Task, error) {
|
||||
var task Task
|
||||
var tags []string
|
||||
err := s.pool.QueryRow(ctx, `SELECT id, title, description, status, due_at, priority, tags, created_at, updated_at FROM tasks WHERE user_id = $1 AND id = $2`, userID, id).
|
||||
Scan(&task.ID, &task.Title, &task.Description, &task.Status, &task.DueAt, &task.Priority, &tags, &task.CreatedAt, &task.UpdatedAt)
|
||||
var notifyGroupIDs []int64
|
||||
err := s.pool.QueryRow(ctx, `SELECT id, title, description, status, due_at, priority, tags, notify_group_ids, created_at, updated_at FROM tasks WHERE user_id = $1 AND id = $2`, userID, id).
|
||||
Scan(&task.ID, &task.Title, &task.Description, &task.Status, &task.DueAt, &task.Priority, &tags, ¬ifyGroupIDs, &task.CreatedAt, &task.UpdatedAt)
|
||||
if err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
task.Tags = tags
|
||||
task.NotifyGroupIDs = notifyGroupIDs
|
||||
return task, nil
|
||||
}
|
||||
|
||||
@@ -130,15 +212,20 @@ func (s *postgresStore) Create(ctx context.Context, userID int64, input Task) (T
|
||||
if input.Tags != nil {
|
||||
tags = input.Tags
|
||||
}
|
||||
groupIDs := dedupeInt64(input.NotifyGroupIDs)
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO tasks (user_id, title, description, status, due_at, priority, tags, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, now(), now())
|
||||
INSERT INTO tasks (user_id, title, description, status, due_at, priority, tags, notify_group_ids, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now(), now())
|
||||
RETURNING id, created_at, updated_at`,
|
||||
userID, input.Title, input.Description, input.Status, input.DueAt, input.Priority, tags,
|
||||
userID, input.Title, input.Description, input.Status, input.DueAt, input.Priority, tags, groupIDs,
|
||||
).Scan(&input.ID, &input.CreatedAt, &input.UpdatedAt)
|
||||
if err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
input.NotifyGroupIDs = groupIDs
|
||||
if err := s.syncTaskNotificationGroups(ctx, userID, input.ID, groupIDs); err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
@@ -165,21 +252,65 @@ func (s *postgresStore) Update(ctx context.Context, userID, id int64, input Task
|
||||
if input.Tags != nil {
|
||||
existing.Tags = input.Tags
|
||||
}
|
||||
if input.NotifyGroupIDs != nil {
|
||||
existing.NotifyGroupIDs = dedupeInt64(input.NotifyGroupIDs)
|
||||
}
|
||||
var updatedAt time.Time
|
||||
err = s.pool.QueryRow(ctx, `
|
||||
UPDATE tasks
|
||||
SET title = $1, description = $2, status = $3, due_at = $4, priority = $5, tags = $6, updated_at = now()
|
||||
WHERE id = $7 AND user_id = $8
|
||||
SET title = $1, description = $2, status = $3, due_at = $4, priority = $5, tags = $6, notify_group_ids = $7, updated_at = now()
|
||||
WHERE id = $8 AND user_id = $9
|
||||
RETURNING updated_at`,
|
||||
existing.Title, existing.Description, existing.Status, existing.DueAt, existing.Priority, existing.Tags, id, userID,
|
||||
existing.Title, existing.Description, existing.Status, existing.DueAt, existing.Priority, existing.Tags, existing.NotifyGroupIDs, id, userID,
|
||||
).Scan(&updatedAt)
|
||||
if err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
if err := s.syncTaskNotificationGroups(ctx, userID, id, existing.NotifyGroupIDs); err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
existing.UpdatedAt = updatedAt
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
func (s *postgresStore) syncTaskNotificationGroups(ctx context.Context, userID, taskID int64, groupIDs []int64) error {
|
||||
groupIDs = dedupeInt64(groupIDs)
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM task_notification_groups WHERE task_id = $1`, taskID); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(groupIDs) == 0 {
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
rows, err := tx.Query(ctx, `SELECT id FROM notification_groups WHERE user_id = $1 AND id = ANY($2)`, userID, groupIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
valid := make([]int64, 0, len(groupIDs))
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
valid = append(valid, id)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
for _, groupID := range valid {
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO task_notification_groups (task_id, group_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, taskID, groupID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (s *postgresStore) Delete(ctx context.Context, userID, id int64) error {
|
||||
result, err := s.pool.Exec(ctx, `DELETE FROM tasks WHERE id = $1 AND user_id = $2`, id, userID)
|
||||
if err != nil {
|
||||
@@ -218,12 +349,13 @@ func (e *taskEmitter) Emit(ctx context.Context, eventType string, task Task, use
|
||||
return
|
||||
}
|
||||
payload := map[string]any{
|
||||
"type": eventType,
|
||||
"task_id": task.ID,
|
||||
"user_id": userID,
|
||||
"status": task.Status,
|
||||
"priority": task.Priority,
|
||||
"at": time.Now().UTC().Format(time.RFC3339),
|
||||
"type": eventType,
|
||||
"task_id": task.ID,
|
||||
"user_id": userID,
|
||||
"status": task.Status,
|
||||
"priority": task.Priority,
|
||||
"notify_group_ids": task.NotifyGroupIDs,
|
||||
"at": time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
@@ -254,10 +386,15 @@ func isUniqueViolation(err error) bool {
|
||||
}
|
||||
|
||||
func main() {
|
||||
store, iamSvc, emitter := buildDependencies()
|
||||
store, iamSvc, emitter, notifySvc := buildDependencies()
|
||||
defer store.Close()
|
||||
defer iamSvc.Close()
|
||||
defer emitter.Close()
|
||||
defer notifySvc.Close()
|
||||
|
||||
appCtx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
notifySvc.Start(appCtx)
|
||||
|
||||
gin.SetMode(gin.DebugMode)
|
||||
router := gin.Default()
|
||||
@@ -299,6 +436,7 @@ func main() {
|
||||
return
|
||||
}
|
||||
emitter.Emit(c.Request.Context(), "task.created", created, userID)
|
||||
_ = notifySvc.PublishTaskEvent(c.Request.Context(), "task.created", toTaskSnapshot(userID, created))
|
||||
c.JSON(http.StatusCreated, created)
|
||||
})
|
||||
tasks.GET(":id", func(c *gin.Context) {
|
||||
@@ -331,6 +469,15 @@ func main() {
|
||||
return
|
||||
}
|
||||
userID := c.GetInt64(iam.ContextUserIDKey)
|
||||
before, err := store.Get(c.Request.Context(), userID, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load task"})
|
||||
return
|
||||
}
|
||||
updated, err := store.Update(c.Request.Context(), userID, id, input)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
@@ -340,7 +487,12 @@ func main() {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update task"})
|
||||
return
|
||||
}
|
||||
emitter.Emit(c.Request.Context(), "task.updated", updated, userID)
|
||||
eventType := "task.updated"
|
||||
if before.Status != updated.Status {
|
||||
eventType = "task.status_changed"
|
||||
}
|
||||
emitter.Emit(c.Request.Context(), eventType, updated, userID)
|
||||
_ = notifySvc.PublishTaskEvent(c.Request.Context(), eventType, toTaskSnapshot(userID, updated))
|
||||
c.JSON(http.StatusOK, updated)
|
||||
})
|
||||
tasks.DELETE(":id", func(c *gin.Context) {
|
||||
@@ -363,12 +515,134 @@ func main() {
|
||||
})
|
||||
}
|
||||
|
||||
notifications := api.Group("/notifications")
|
||||
{
|
||||
notifications.GET("/prefs", func(c *gin.Context) {
|
||||
userID := c.GetInt64(iam.ContextUserIDKey)
|
||||
prefs, err := notifySvc.GetPrefs(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load prefs"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, prefs)
|
||||
})
|
||||
notifications.PUT("/prefs", func(c *gin.Context) {
|
||||
var input notification.UserNotificationPrefs
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
userID := c.GetInt64(iam.ContextUserIDKey)
|
||||
prefs, err := notifySvc.UpdatePrefs(c.Request.Context(), userID, input)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, prefs)
|
||||
})
|
||||
notifications.GET("/groups", func(c *gin.Context) {
|
||||
userID := c.GetInt64(iam.ContextUserIDKey)
|
||||
groups, err := notifySvc.ListGroups(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list groups"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, groups)
|
||||
})
|
||||
notifications.POST("/groups", func(c *gin.Context) {
|
||||
var input notification.NotificationGroup
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
userID := c.GetInt64(iam.ContextUserIDKey)
|
||||
group, err := notifySvc.CreateGroup(c.Request.Context(), userID, input)
|
||||
if err != nil {
|
||||
status := http.StatusBadRequest
|
||||
if isUniqueViolation(err) {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, group)
|
||||
})
|
||||
notifications.PUT("/groups/:id", func(c *gin.Context) {
|
||||
id, err := parseID(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
var input notification.NotificationGroup
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
userID := c.GetInt64(iam.ContextUserIDKey)
|
||||
group, err := notifySvc.UpdateGroup(c.Request.Context(), userID, id, input)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
status := http.StatusBadRequest
|
||||
if isUniqueViolation(err) {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, group)
|
||||
})
|
||||
notifications.DELETE("/groups/:id", func(c *gin.Context) {
|
||||
id, err := parseID(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
userID := c.GetInt64(iam.ContextUserIDKey)
|
||||
if err := notifySvc.DeleteGroup(c.Request.Context(), userID, id); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete"})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
})
|
||||
notifications.GET("/dlq", func(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
userID := c.GetInt64(iam.ContextUserIDKey)
|
||||
items, err := notifySvc.ListDLQ(c.Request.Context(), userID, page, pageSize)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load dlq"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, items)
|
||||
})
|
||||
notifications.GET("/metrics", func(c *gin.Context) {
|
||||
from, to, err := parseMetricsRange(c.Query("from"), c.Query("to"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid from/to"})
|
||||
return
|
||||
}
|
||||
metrics, err := notifySvc.GetMetrics(c.Request.Context(), from, to)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load metrics"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, metrics)
|
||||
})
|
||||
}
|
||||
|
||||
if err := router.Run(":8080"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func buildDependencies() (*postgresStore, *iam.Service, *taskEmitter) {
|
||||
func buildDependencies() (*postgresStore, *iam.Service, *taskEmitter, *notification.Service) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -392,7 +666,43 @@ func buildDependencies() (*postgresStore, *iam.Service, *taskEmitter) {
|
||||
topic = "todo.tasks"
|
||||
}
|
||||
emitter := newTaskEmitter(brokers, topic)
|
||||
return store, iamSvc, emitter
|
||||
|
||||
notifyCfg := notification.LoadConfigFromEnv()
|
||||
notifySvc := notification.NewService(store.Pool(), notifyCfg)
|
||||
|
||||
return store, iamSvc, emitter, notifySvc
|
||||
}
|
||||
|
||||
func toTaskSnapshot(userID int64, task Task) notification.TaskSnapshot {
|
||||
return notification.TaskSnapshot{
|
||||
ID: task.ID,
|
||||
UserID: userID,
|
||||
Title: task.Title,
|
||||
Description: task.Description,
|
||||
Status: task.Status,
|
||||
DueAt: task.DueAt,
|
||||
Priority: task.Priority,
|
||||
Tags: task.Tags,
|
||||
NotifyGroupIDs: task.NotifyGroupIDs,
|
||||
}
|
||||
}
|
||||
|
||||
func parseMetricsRange(fromStr, toStr string) (time.Time, time.Time, error) {
|
||||
var from, to time.Time
|
||||
var err error
|
||||
if strings.TrimSpace(fromStr) != "" {
|
||||
from, err = time.Parse(time.RFC3339, fromStr)
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, err
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(toStr) != "" {
|
||||
to, err = time.Parse(time.RFC3339, toStr)
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, err
|
||||
}
|
||||
}
|
||||
return from, to, nil
|
||||
}
|
||||
|
||||
func parseID(value string) (int64, error) {
|
||||
@@ -414,6 +724,25 @@ func splitCSV(value string) []string {
|
||||
return result
|
||||
}
|
||||
|
||||
func dedupeInt64(in []int64) []int64 {
|
||||
if len(in) == 0 {
|
||||
return in
|
||||
}
|
||||
seen := map[int64]struct{}{}
|
||||
out := make([]int64, 0, len(in))
|
||||
for _, id := range in {
|
||||
if id <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func corsMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
origin := strings.TrimSpace(c.GetHeader("Origin"))
|
||||
|
||||
@@ -71,6 +71,20 @@ services:
|
||||
REDIS_ADDR: redis:6379
|
||||
KAFKA_BROKERS: kafka:9092
|
||||
KAFKA_TOPIC: todo.tasks
|
||||
NOTIFY_ENABLED: "true"
|
||||
NOTIFY_TOPIC: notification.jobs
|
||||
NOTIFY_DISPATCH_BATCH: "200"
|
||||
NOTIFY_SCHED_TICK_SECONDS: "60"
|
||||
NOTIFY_MAX_RETRY: "5"
|
||||
NOTIFY_BACKOFF_BASE_SECONDS: "30"
|
||||
SMTP_HOST: smtp.qq.com
|
||||
SMTP_PORT: "465"
|
||||
SMTP_USER: 2914037183@qq.com
|
||||
SMTP_PASS: daxnelwgfaraddbi
|
||||
SMTP_FROM: 2914037183@qq.com
|
||||
SMTP_USE_TLS: "true"
|
||||
GOPROXY: https://goproxy.cn,direct
|
||||
GOSUMDB: "off"
|
||||
AUTH_SECRET: dev-secret-change-me
|
||||
depends_on:
|
||||
- postgres
|
||||
|
||||
1569
internal/notification/service.go
Normal file
1569
internal/notification/service.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,8 @@ async function onLogout() {
|
||||
<RouterLink to="/team">{{ t('nav_team') }}</RouterLink>
|
||||
<RouterLink to="/settings/user">{{ t('nav_user_settings') }}</RouterLink>
|
||||
<RouterLink to="/settings/team">{{ t('nav_team_settings') }}</RouterLink>
|
||||
<RouterLink to="/settings/notification-groups">{{ t('nav_notification_groups') }}</RouterLink>
|
||||
<RouterLink to="/settings/notification-dlq">{{ t('nav_notification_dlq') }}</RouterLink>
|
||||
</template>
|
||||
<template v-else>
|
||||
<RouterLink to="/auth/login">{{ t('nav_login') }}</RouterLink>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, watch } from 'vue'
|
||||
import type { Task } from '../types'
|
||||
import type { NotificationGroup, Task } from '../types'
|
||||
import { t } from '../i18n'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
editingTask?: Task | null
|
||||
availableGroups: NotificationGroup[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -19,6 +20,7 @@ const form = reactive({
|
||||
due_at: '',
|
||||
priority: 2,
|
||||
tags: '',
|
||||
notify_group_ids: [] as number[],
|
||||
})
|
||||
|
||||
watch(
|
||||
@@ -29,6 +31,7 @@ watch(
|
||||
form.due_at = toLocal(task?.due_at ?? '')
|
||||
form.priority = task?.priority ?? 2
|
||||
form.tags = task?.tags?.join(', ') ?? ''
|
||||
form.notify_group_ids = task?.notify_group_ids ? [...task.notify_group_ids] : []
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
@@ -42,6 +45,7 @@ watch(
|
||||
form.due_at = ''
|
||||
form.priority = 2
|
||||
form.tags = ''
|
||||
form.notify_group_ids = []
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -60,6 +64,7 @@ function save() {
|
||||
.split(',')
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean),
|
||||
notify_group_ids: [...form.notify_group_ids],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -70,6 +75,12 @@ function toLocal(v: string) {
|
||||
const offset = d.getTimezoneOffset() * 60000
|
||||
return new Date(d.getTime() - offset).toISOString().slice(0, 16)
|
||||
}
|
||||
|
||||
function onSelectGroups(event: Event) {
|
||||
const target = event.target as HTMLSelectElement
|
||||
const values = Array.from(target.selectedOptions).map((opt) => Number(opt.value)).filter(Boolean)
|
||||
form.notify_group_ids = values
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -98,6 +109,14 @@ function toLocal(v: string) {
|
||||
{{ t('tags') }}
|
||||
<input v-model="form.tags" type="text" :placeholder="t('tags_placeholder')" />
|
||||
</label>
|
||||
<label>
|
||||
{{ t('notify_groups') }}
|
||||
<select multiple size="5" :value="form.notify_group_ids.map(String)" @change="onSelectGroups">
|
||||
<option v-for="group in availableGroups" :key="group.id" :value="group.id">
|
||||
{{ group.name }} ({{ group.emails.length }})
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{{ t('description') }}
|
||||
<textarea v-model="form.description" rows="4"></textarea>
|
||||
|
||||
@@ -11,6 +11,8 @@ const messages = {
|
||||
nav_team: 'Team',
|
||||
nav_user_settings: 'User Settings',
|
||||
nav_team_settings: 'Team Settings',
|
||||
nav_notification_groups: 'Notify Groups',
|
||||
nav_notification_dlq: 'Notification DLQ',
|
||||
nav_login: 'Login',
|
||||
logout: 'Logout',
|
||||
guest_mode: 'Guest mode',
|
||||
@@ -35,6 +37,7 @@ const messages = {
|
||||
due_empty: '-',
|
||||
edit: 'Edit',
|
||||
delete: 'Delete',
|
||||
create: 'Create',
|
||||
loading_tasks: 'Loading tasks...',
|
||||
load_tasks_failed: 'Failed to load tasks. Please login again.',
|
||||
save_task_failed: 'Failed to save task.',
|
||||
@@ -53,6 +56,7 @@ const messages = {
|
||||
low: 'Low',
|
||||
tags: 'Tags',
|
||||
tags_placeholder: 'backend, planning',
|
||||
notify_groups: 'Notify Groups',
|
||||
description: 'Description',
|
||||
cancel: 'Cancel',
|
||||
save: 'Save',
|
||||
@@ -60,12 +64,20 @@ const messages = {
|
||||
team_subtitle: 'Switch between teams and check active workload.',
|
||||
team_members: '{count} members',
|
||||
team_open_todos: '{count} open todos',
|
||||
user_settings_title: 'User Settings',
|
||||
user_settings_subtitle: 'Personal profile and preference controls.',
|
||||
user_settings_title: 'Notification Preferences',
|
||||
user_settings_subtitle: 'Manage your notification subscription and schedule.',
|
||||
display_name: 'Display Name',
|
||||
timezone: 'Timezone',
|
||||
reminders: 'Receive task reminders',
|
||||
locale: 'Language',
|
||||
dnd_start: 'DND Start',
|
||||
dnd_end: 'DND End',
|
||||
reminders: 'Receive notifications',
|
||||
daily_summary_enabled: 'Enable Daily Summary',
|
||||
daily_summary_time: 'Daily Summary Time',
|
||||
save_settings: 'Save Settings',
|
||||
save_settings_success: 'Settings updated successfully',
|
||||
save_settings_failed: 'Failed to save settings',
|
||||
load_settings_failed: 'Failed to load settings',
|
||||
team_settings_title: 'Team Settings',
|
||||
team_settings_subtitle: 'Global conventions for team collaboration.',
|
||||
team_name: 'Team Name',
|
||||
@@ -77,14 +89,32 @@ const messages = {
|
||||
task_created_success: 'Task created successfully',
|
||||
task_deleted_success: 'Task deleted successfully',
|
||||
confirm_delete: 'Are you sure you want to delete this task?',
|
||||
notification_groups_title: 'Notification Groups',
|
||||
notification_groups_subtitle: 'Manage reusable recipient groups for tasks.',
|
||||
group_name: 'Group Name',
|
||||
group_emails: 'Emails',
|
||||
group_emails_placeholder: 'a@example.com, b@example.com',
|
||||
group_validation_error: 'Please input group name and emails',
|
||||
group_created: 'Group created',
|
||||
group_updated: 'Group updated',
|
||||
group_deleted: 'Group deleted',
|
||||
group_save_failed: 'Failed to save group',
|
||||
group_delete_failed: 'Failed to delete group',
|
||||
load_groups_failed: 'Failed to load groups',
|
||||
notification_dlq_title: 'Notification DLQ',
|
||||
notification_dlq_subtitle: 'Failed notification jobs waiting for compensation.',
|
||||
load_dlq_failed: 'Failed to load DLQ records',
|
||||
dlq_empty: 'No dead-letter records',
|
||||
},
|
||||
zh: {
|
||||
brand_name: 'SuperTodo',
|
||||
brand_caption: '简约的团队任务执行面板',
|
||||
nav_todo: '待办',
|
||||
nav_team: '团队入口',
|
||||
nav_user_settings: '用户设置',
|
||||
nav_user_settings: '通知偏好',
|
||||
nav_team_settings: '团队设置',
|
||||
nav_notification_groups: '通知组',
|
||||
nav_notification_dlq: '死信队列',
|
||||
nav_login: '登录',
|
||||
logout: '退出登录',
|
||||
guest_mode: '访客模式',
|
||||
@@ -109,6 +139,7 @@ const messages = {
|
||||
due_empty: '-',
|
||||
edit: '编辑',
|
||||
delete: '删除',
|
||||
create: '创建',
|
||||
loading_tasks: '任务加载中...',
|
||||
load_tasks_failed: '加载任务失败,请重新登录。',
|
||||
save_task_failed: '保存任务失败。',
|
||||
@@ -127,6 +158,7 @@ const messages = {
|
||||
low: '低',
|
||||
tags: '标签',
|
||||
tags_placeholder: '后端, 规划',
|
||||
notify_groups: '通知组',
|
||||
description: '描述',
|
||||
cancel: '取消',
|
||||
save: '保存',
|
||||
@@ -134,12 +166,20 @@ const messages = {
|
||||
team_subtitle: '在团队之间切换并查看当前工作量。',
|
||||
team_members: '{count} 位成员',
|
||||
team_open_todos: '{count} 个未完成任务',
|
||||
user_settings_title: '用户设置',
|
||||
user_settings_subtitle: '管理个人资料和偏好设置。',
|
||||
user_settings_title: '通知偏好设置',
|
||||
user_settings_subtitle: '管理订阅、免打扰与每日摘要。',
|
||||
display_name: '显示名称',
|
||||
timezone: '时区',
|
||||
reminders: '接收任务提醒',
|
||||
locale: '语言',
|
||||
dnd_start: '免打扰开始',
|
||||
dnd_end: '免打扰结束',
|
||||
reminders: '接收通知',
|
||||
daily_summary_enabled: '启用每日摘要',
|
||||
daily_summary_time: '每日摘要时间',
|
||||
save_settings: '保存设置',
|
||||
save_settings_success: '设置保存成功',
|
||||
save_settings_failed: '保存设置失败',
|
||||
load_settings_failed: '加载设置失败',
|
||||
team_settings_title: '团队设置',
|
||||
team_settings_subtitle: '管理团队协作的默认规则。',
|
||||
team_name: '团队名称',
|
||||
@@ -151,6 +191,22 @@ const messages = {
|
||||
task_created_success: '任务创建成功',
|
||||
task_deleted_success: '任务删除成功',
|
||||
confirm_delete: '确定要删除这个任务吗?',
|
||||
notification_groups_title: '通知组管理',
|
||||
notification_groups_subtitle: '为任务维护可复用收件人组。',
|
||||
group_name: '组名称',
|
||||
group_emails: '邮箱列表',
|
||||
group_emails_placeholder: 'a@example.com, b@example.com',
|
||||
group_validation_error: '请填写组名和至少一个邮箱',
|
||||
group_created: '通知组已创建',
|
||||
group_updated: '通知组已更新',
|
||||
group_deleted: '通知组已删除',
|
||||
group_save_failed: '保存通知组失败',
|
||||
group_delete_failed: '删除通知组失败',
|
||||
load_groups_failed: '加载通知组失败',
|
||||
notification_dlq_title: '通知死信队列',
|
||||
notification_dlq_subtitle: '查看投递失败并进入死信的通知任务。',
|
||||
load_dlq_failed: '加载死信记录失败',
|
||||
dlq_empty: '暂无死信记录',
|
||||
},
|
||||
} as const
|
||||
|
||||
|
||||
@@ -30,6 +30,18 @@ const router = createRouter({
|
||||
component: () => import('./views/TeamSettingsView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/settings/notification-groups',
|
||||
name: 'notification-groups',
|
||||
component: () => import('./views/NotificationGroupsView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/settings/notification-dlq',
|
||||
name: 'notification-dlq',
|
||||
component: () => import('./views/NotificationDlqView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Credentials, Task } from '../types'
|
||||
import type { Credentials, DlqItem, NotificationGroup, NotificationPrefs, Task } from '../types'
|
||||
import { clearSession, session, setSessionToken } from '../stores/session'
|
||||
|
||||
const API_BASE = 'http://localhost:8080/api/v1'
|
||||
@@ -115,3 +115,42 @@ export async function deleteTask(id: number) {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export async function getNotificationPrefs() {
|
||||
return request<NotificationPrefs>('/notifications/prefs')
|
||||
}
|
||||
|
||||
export async function updateNotificationPrefs(payload: NotificationPrefs) {
|
||||
return request<NotificationPrefs>('/notifications/prefs', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function listNotificationGroups() {
|
||||
return request<NotificationGroup[]>('/notifications/groups')
|
||||
}
|
||||
|
||||
export async function createNotificationGroup(payload: Omit<NotificationGroup, 'id'>) {
|
||||
return request<NotificationGroup>('/notifications/groups', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateNotificationGroup(id: number, payload: Omit<NotificationGroup, 'id'>) {
|
||||
return request<NotificationGroup>(`/notifications/groups/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteNotificationGroup(id: number) {
|
||||
return request<void>(`/notifications/groups/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export async function listNotificationDlq(page = 1, pageSize = 20) {
|
||||
return request<DlqItem[]>(`/notifications/dlq?page=${page}&page_size=${pageSize}`)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export type Task = {
|
||||
due_at: string
|
||||
priority: number
|
||||
tags: string[]
|
||||
notify_group_ids: number[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -14,3 +15,30 @@ export type Credentials = {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export type NotificationPrefs = {
|
||||
subscribed: boolean
|
||||
dnd_start: string
|
||||
dnd_end: string
|
||||
locale: 'zh' | 'en'
|
||||
timezone: string
|
||||
daily_summary_enabled: boolean
|
||||
daily_summary_time: string
|
||||
}
|
||||
|
||||
export type NotificationGroup = {
|
||||
id: number
|
||||
name: string
|
||||
emails: string[]
|
||||
}
|
||||
|
||||
export type DlqItem = {
|
||||
job_id: string
|
||||
template_id: string
|
||||
to_emails: string[]
|
||||
reason: string
|
||||
failed_at: string
|
||||
retry_count: number
|
||||
trace_id: string
|
||||
error_code: string
|
||||
}
|
||||
|
||||
74
web/src/views/NotificationDlqView.vue
Normal file
74
web/src/views/NotificationDlqView.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import type { DlqItem } from '../types'
|
||||
import * as api from '../services/api'
|
||||
import PaginationControl from '../components/PaginationControl.vue'
|
||||
import { t } from '../i18n'
|
||||
import { useToast } from '../composables/useToast'
|
||||
|
||||
const { error } = useToast()
|
||||
const loading = ref(false)
|
||||
const items = ref<DlqItem[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
items.value = await api.listNotificationDlq(page.value, pageSize.value)
|
||||
} catch {
|
||||
error(t('load_dlq_failed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
page.value += 1
|
||||
load()
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
if (page.value <= 1) return
|
||||
page.value -= 1
|
||||
load()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<h2>{{ t('notification_dlq_title') }}</h2>
|
||||
<p>{{ t('notification_dlq_subtitle') }}</p>
|
||||
</div>
|
||||
<button class="btn" @click="load">{{ t('refresh') }}</button>
|
||||
</header>
|
||||
|
||||
<div class="cards" v-if="!loading">
|
||||
<article v-for="item in items" :key="item.job_id" class="card dlq-card">
|
||||
<p><strong>Job:</strong> {{ item.job_id }}</p>
|
||||
<p><strong>Template:</strong> {{ item.template_id }}</p>
|
||||
<p><strong>To:</strong> {{ item.to_emails.join(', ') }}</p>
|
||||
<p><strong>Reason:</strong> {{ item.reason }}</p>
|
||||
<p><strong>Error:</strong> {{ item.error_code || '-' }}</p>
|
||||
<p><strong>Retry:</strong> {{ item.retry_count }}</p>
|
||||
<p><strong>At:</strong> {{ new Date(item.failed_at).toLocaleString() }}</p>
|
||||
</article>
|
||||
<div v-if="items.length === 0" class="card">{{ t('dlq_empty') }}</div>
|
||||
</div>
|
||||
|
||||
<p v-else>{{ t('loading_wait') }}</p>
|
||||
|
||||
<PaginationControl :page="page" :page-size="pageSize" :total="items.length === pageSize ? page * pageSize + 1 : (page - 1) * pageSize + items.length" @next="nextPage" @prev="prevPage" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dlq-card p {
|
||||
margin: 0 0 8px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
</style>
|
||||
134
web/src/views/NotificationGroupsView.vue
Normal file
134
web/src/views/NotificationGroupsView.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import type { NotificationGroup } from '../types'
|
||||
import * as api from '../services/api'
|
||||
import { t } from '../i18n'
|
||||
import { useToast } from '../composables/useToast'
|
||||
|
||||
const { success, error } = useToast()
|
||||
const loading = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
const groups = ref<NotificationGroup[]>([])
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
emails: '',
|
||||
})
|
||||
|
||||
function resetForm() {
|
||||
form.name = ''
|
||||
form.emails = ''
|
||||
editingId.value = null
|
||||
}
|
||||
|
||||
async function loadGroups() {
|
||||
loading.value = true
|
||||
try {
|
||||
groups.value = await api.listNotificationGroups()
|
||||
} catch {
|
||||
error(t('load_groups_failed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(group: NotificationGroup) {
|
||||
editingId.value = group.id
|
||||
form.name = group.name
|
||||
form.emails = group.emails.join(', ')
|
||||
}
|
||||
|
||||
async function saveGroup() {
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
emails: form.emails
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
}
|
||||
if (!payload.name || payload.emails.length === 0) {
|
||||
error(t('group_validation_error'))
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await api.updateNotificationGroup(editingId.value, payload)
|
||||
success(t('group_updated'))
|
||||
} else {
|
||||
await api.createNotificationGroup(payload)
|
||||
success(t('group_created'))
|
||||
}
|
||||
resetForm()
|
||||
await loadGroups()
|
||||
} catch {
|
||||
error(t('group_save_failed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeGroup(id: number) {
|
||||
if (!confirm(t('confirm_delete'))) return
|
||||
loading.value = true
|
||||
try {
|
||||
await api.deleteNotificationGroup(id)
|
||||
success(t('group_deleted'))
|
||||
await loadGroups()
|
||||
} catch {
|
||||
error(t('group_delete_failed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadGroups)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<h2>{{ t('notification_groups_title') }}</h2>
|
||||
<p>{{ t('notification_groups_subtitle') }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form class="card settings-form" @submit.prevent="saveGroup">
|
||||
<label>
|
||||
{{ t('group_name') }}
|
||||
<input v-model="form.name" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
{{ t('group_emails') }}
|
||||
<input v-model="form.emails" type="text" :placeholder="t('group_emails_placeholder')" />
|
||||
</label>
|
||||
<div class="page-actions">
|
||||
<button class="btn primary" type="submit" :disabled="loading">{{ editingId ? t('save') : t('create') }}</button>
|
||||
<button class="btn" type="button" @click="resetForm">{{ t('cancel') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="cards">
|
||||
<article v-for="group in groups" :key="group.id" class="card group-card">
|
||||
<h3>{{ group.name }}</h3>
|
||||
<p>{{ group.emails.join(', ') }}</p>
|
||||
<div class="page-actions">
|
||||
<button class="btn" @click="startEdit(group)">{{ t('edit') }}</button>
|
||||
<button class="btn danger" @click="removeGroup(group.id)">{{ t('delete') }}</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.group-card h3 {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.group-card p {
|
||||
margin: 0 0 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import type { Task } from '../types'
|
||||
import type { NotificationGroup, Task } from '../types'
|
||||
import * as api from '../services/api'
|
||||
import PaginationControl from '../components/PaginationControl.vue'
|
||||
import TodoEditorModal from '../components/TodoEditorModal.vue'
|
||||
@@ -10,6 +10,7 @@ import { useToast } from '../composables/useToast'
|
||||
const { success, error: showError } = useToast()
|
||||
|
||||
const tasks = ref<Task[]>([])
|
||||
const groups = ref<NotificationGroup[]>([])
|
||||
const loading = ref(false)
|
||||
const modalOpen = ref(false)
|
||||
const editingTask = ref<Task | null>(null)
|
||||
@@ -19,7 +20,7 @@ const filter = ref<'all' | 'todo' | 'done'>('all')
|
||||
|
||||
const filteredTasks = computed(() => {
|
||||
if (filter.value === 'all') return tasks.value
|
||||
return tasks.value.filter((t) => t.status === filter.value)
|
||||
return tasks.value.filter((task) => task.status === filter.value)
|
||||
})
|
||||
|
||||
const total = computed(() => filteredTasks.value.length)
|
||||
@@ -29,6 +30,14 @@ const pagedTasks = computed(() => {
|
||||
return filteredTasks.value.slice(start, start + pageSize.value)
|
||||
})
|
||||
|
||||
async function loadGroups() {
|
||||
try {
|
||||
groups.value = await api.listNotificationGroups()
|
||||
} catch {
|
||||
groups.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -56,10 +65,10 @@ async function saveTask(payload: Partial<Task>) {
|
||||
try {
|
||||
if (editingTask.value?.id) {
|
||||
await api.updateTask(editingTask.value.id, payload)
|
||||
success(t('task_updated_success') || 'Task updated')
|
||||
success(t('task_updated_success'))
|
||||
} else {
|
||||
await api.createTask({ ...payload, status: 'todo' })
|
||||
success(t('task_created_success') || 'Task created')
|
||||
await api.createTask({ ...payload, status: 'todo', notify_group_ids: payload.notify_group_ids ?? [] })
|
||||
success(t('task_created_success'))
|
||||
}
|
||||
modalOpen.value = false
|
||||
editingTask.value = null
|
||||
@@ -72,16 +81,12 @@ async function saveTask(payload: Partial<Task>) {
|
||||
async function toggle(task: Task) {
|
||||
const originalStatus = task.status
|
||||
const newStatus = task.status === 'done' ? 'todo' : 'done'
|
||||
|
||||
// Optimistic update
|
||||
|
||||
task.status = newStatus
|
||||
|
||||
|
||||
try {
|
||||
await api.updateTask(task.id, { status: newStatus })
|
||||
// Background refresh to ensure consistency
|
||||
// await loadTasks() // Optional: might cause jumping if list sort changes
|
||||
} catch {
|
||||
// Revert on failure
|
||||
task.status = originalStatus
|
||||
showError(t('update_task_failed'))
|
||||
}
|
||||
@@ -91,11 +96,11 @@ async function remove(id: number) {
|
||||
if (!confirm(t('confirm_delete') || 'Are you sure?')) return
|
||||
|
||||
const originalTasks = [...tasks.value]
|
||||
tasks.value = tasks.value.filter(t => t.id !== id)
|
||||
tasks.value = tasks.value.filter((task) => task.id !== id)
|
||||
|
||||
try {
|
||||
await api.deleteTask(id)
|
||||
success(t('task_deleted_success') || 'Task deleted')
|
||||
success(t('task_deleted_success'))
|
||||
} catch {
|
||||
tasks.value = originalTasks
|
||||
showError(t('delete_task_failed'))
|
||||
@@ -114,12 +119,20 @@ function formatDue(value: string) {
|
||||
return value ? new Date(value).toLocaleString() : t('due_empty')
|
||||
}
|
||||
|
||||
function setFilter(f: 'all' | 'todo' | 'done') {
|
||||
filter.value = f
|
||||
function setFilter(value: 'all' | 'todo' | 'done') {
|
||||
filter.value = value
|
||||
page.value = 1
|
||||
}
|
||||
|
||||
onMounted(loadTasks)
|
||||
function groupNames(ids: number[]) {
|
||||
if (!ids || ids.length === 0) return '-'
|
||||
const names = groups.value.filter((group) => ids.includes(group.id)).map((group) => group.name)
|
||||
return names.length ? names.join(', ') : '-'
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadTasks(), loadGroups()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -131,21 +144,9 @@ onMounted(loadTasks)
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<div class="filter-group">
|
||||
<button
|
||||
class="btn sm"
|
||||
:class="{ primary: filter === 'all' }"
|
||||
@click="setFilter('all')"
|
||||
>All</button>
|
||||
<button
|
||||
class="btn sm"
|
||||
:class="{ primary: filter === 'todo' }"
|
||||
@click="setFilter('todo')"
|
||||
>Active</button>
|
||||
<button
|
||||
class="btn sm"
|
||||
:class="{ primary: filter === 'done' }"
|
||||
@click="setFilter('done')"
|
||||
>Done</button>
|
||||
<button class="btn sm" :class="{ primary: filter === 'all' }" @click="setFilter('all')">All</button>
|
||||
<button class="btn sm" :class="{ primary: filter === 'todo' }" @click="setFilter('todo')">Active</button>
|
||||
<button class="btn sm" :class="{ primary: filter === 'done' }" @click="setFilter('done')">Done</button>
|
||||
</div>
|
||||
<button class="btn" @click="loadTasks">{{ t('refresh') }}</button>
|
||||
<button class="btn primary" @click="openCreate">{{ t('new_todo') }}</button>
|
||||
@@ -174,6 +175,7 @@ onMounted(loadTasks)
|
||||
<div class="task-meta">
|
||||
<small>{{ t('priority') }} P{{ task.priority || 2 }}</small>
|
||||
<small>{{ t('due_at') }} {{ formatDue(task.due_at) }}</small>
|
||||
<small>{{ t('notify_groups') }} {{ groupNames(task.notify_group_ids || []) }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="task-row-actions">
|
||||
@@ -187,7 +189,7 @@ onMounted(loadTasks)
|
||||
|
||||
<PaginationControl :page="page" :page-size="pageSize" :total="total" @next="nextPage" @prev="prevPage" />
|
||||
|
||||
<TodoEditorModal v-model="modalOpen" :editing-task="editingTask" @submit="saveTask" />
|
||||
<TodoEditorModal v-model="modalOpen" :editing-task="editingTask" :available-groups="groups" @submit="saveTask" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,14 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue'
|
||||
import { session } from '../stores/session'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import type { NotificationPrefs } from '../types'
|
||||
import * as api from '../services/api'
|
||||
import { t } from '../i18n'
|
||||
import { useToast } from '../composables/useToast'
|
||||
|
||||
const form = reactive({
|
||||
displayName: 'Product Operator',
|
||||
email: session.email,
|
||||
const { success, error } = useToast()
|
||||
const loading = ref(false)
|
||||
|
||||
const form = reactive<NotificationPrefs>({
|
||||
subscribed: true,
|
||||
dnd_start: '',
|
||||
dnd_end: '',
|
||||
locale: 'zh',
|
||||
timezone: 'Asia/Shanghai',
|
||||
notifications: true,
|
||||
daily_summary_enabled: true,
|
||||
daily_summary_time: '09:30',
|
||||
})
|
||||
|
||||
async function loadPrefs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const prefs = await api.getNotificationPrefs()
|
||||
Object.assign(form, prefs)
|
||||
} catch {
|
||||
error(t('load_settings_failed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function savePrefs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const updated = await api.updateNotificationPrefs(form)
|
||||
Object.assign(form, updated)
|
||||
success(t('save_settings_success'))
|
||||
} catch {
|
||||
error(t('save_settings_failed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadPrefs)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -18,24 +53,39 @@ const form = reactive({
|
||||
<p>{{ t('user_settings_subtitle') }}</p>
|
||||
</header>
|
||||
|
||||
<form class="card settings-form" @submit.prevent>
|
||||
<label>
|
||||
{{ t('display_name') }}
|
||||
<input v-model="form.displayName" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
{{ t('email') }}
|
||||
<input v-model="form.email" type="email" />
|
||||
<form class="card settings-form" @submit.prevent="savePrefs">
|
||||
<label class="inline">
|
||||
<input v-model="form.subscribed" type="checkbox" />
|
||||
{{ t('reminders') }}
|
||||
</label>
|
||||
<label>
|
||||
{{ t('timezone') }}
|
||||
<input v-model="form.timezone" type="text" />
|
||||
<input v-model="form.timezone" type="text" placeholder="Asia/Shanghai" />
|
||||
</label>
|
||||
<label>
|
||||
{{ t('locale') }}
|
||||
<select v-model="form.locale">
|
||||
<option value="zh">中文</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{{ t('dnd_start') }}
|
||||
<input v-model="form.dnd_start" type="time" />
|
||||
</label>
|
||||
<label>
|
||||
{{ t('dnd_end') }}
|
||||
<input v-model="form.dnd_end" type="time" />
|
||||
</label>
|
||||
<label class="inline">
|
||||
<input v-model="form.notifications" type="checkbox" />
|
||||
{{ t('reminders') }}
|
||||
<input v-model="form.daily_summary_enabled" type="checkbox" />
|
||||
{{ t('daily_summary_enabled') }}
|
||||
</label>
|
||||
<button class="btn primary" type="submit">{{ t('save_settings') }}</button>
|
||||
<label>
|
||||
{{ t('daily_summary_time') }}
|
||||
<input v-model="form.daily_summary_time" type="time" />
|
||||
</label>
|
||||
<button class="btn primary" type="submit" :disabled="loading">{{ loading ? t('loading_wait') : t('save_settings') }}</button>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user