feat(notification): add scheduled email pipeline with prefs/groups and DLQ UI
This commit is contained in:
@@ -12,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"wolves.top/todo/internal/iam"
|
"wolves.top/todo/internal/iam"
|
||||||
|
"wolves.top/todo/internal/notification"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/jackc/pgconn"
|
"github.com/jackc/pgconn"
|
||||||
@@ -28,6 +29,7 @@ type Task struct {
|
|||||||
DueAt string `json:"due_at"`
|
DueAt string `json:"due_at"`
|
||||||
Priority int `json:"priority"`
|
Priority int `json:"priority"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
|
NotifyGroupIDs []int64 `json:"notify_group_ids"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -79,9 +81,85 @@ func (s *postgresStore) initSchema(ctx context.Context) error {
|
|||||||
due_at TEXT,
|
due_at TEXT,
|
||||||
priority INT NOT NULL DEFAULT 0,
|
priority INT NOT NULL DEFAULT 0,
|
||||||
tags TEXT[] NOT NULL DEFAULT '{}',
|
tags TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
notify_group_ids BIGINT[] NOT NULL DEFAULT '{}',
|
||||||
created_at TIMESTAMPTZ NOT NULL,
|
created_at TIMESTAMPTZ NOT NULL,
|
||||||
updated_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 {
|
for _, stmt := range statements {
|
||||||
if _, err := s.pool.Exec(ctx, stmt); err != nil {
|
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) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -101,10 +179,12 @@ func (s *postgresStore) List(ctx context.Context, userID int64) ([]Task, error)
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var task Task
|
var task Task
|
||||||
var tags []string
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
task.Tags = tags
|
task.Tags = tags
|
||||||
|
task.NotifyGroupIDs = notifyGroupIDs
|
||||||
result = append(result, task)
|
result = append(result, task)
|
||||||
}
|
}
|
||||||
return result, rows.Err()
|
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) {
|
func (s *postgresStore) Get(ctx context.Context, userID, id int64) (Task, error) {
|
||||||
var task Task
|
var task Task
|
||||||
var tags []string
|
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).
|
var notifyGroupIDs []int64
|
||||||
Scan(&task.ID, &task.Title, &task.Description, &task.Status, &task.DueAt, &task.Priority, &tags, &task.CreatedAt, &task.UpdatedAt)
|
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 {
|
if err != nil {
|
||||||
return Task{}, err
|
return Task{}, err
|
||||||
}
|
}
|
||||||
task.Tags = tags
|
task.Tags = tags
|
||||||
|
task.NotifyGroupIDs = notifyGroupIDs
|
||||||
return task, nil
|
return task, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,15 +212,20 @@ func (s *postgresStore) Create(ctx context.Context, userID int64, input Task) (T
|
|||||||
if input.Tags != nil {
|
if input.Tags != nil {
|
||||||
tags = input.Tags
|
tags = input.Tags
|
||||||
}
|
}
|
||||||
|
groupIDs := dedupeInt64(input.NotifyGroupIDs)
|
||||||
err := s.pool.QueryRow(ctx, `
|
err := s.pool.QueryRow(ctx, `
|
||||||
INSERT INTO tasks (user_id, title, description, status, due_at, priority, tags, created_at, updated_at)
|
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, now(), now())
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now(), now())
|
||||||
RETURNING id, created_at, updated_at`,
|
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)
|
).Scan(&input.ID, &input.CreatedAt, &input.UpdatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Task{}, err
|
return Task{}, err
|
||||||
}
|
}
|
||||||
|
input.NotifyGroupIDs = groupIDs
|
||||||
|
if err := s.syncTaskNotificationGroups(ctx, userID, input.ID, groupIDs); err != nil {
|
||||||
|
return Task{}, err
|
||||||
|
}
|
||||||
return input, nil
|
return input, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,21 +252,65 @@ func (s *postgresStore) Update(ctx context.Context, userID, id int64, input Task
|
|||||||
if input.Tags != nil {
|
if input.Tags != nil {
|
||||||
existing.Tags = input.Tags
|
existing.Tags = input.Tags
|
||||||
}
|
}
|
||||||
|
if input.NotifyGroupIDs != nil {
|
||||||
|
existing.NotifyGroupIDs = dedupeInt64(input.NotifyGroupIDs)
|
||||||
|
}
|
||||||
var updatedAt time.Time
|
var updatedAt time.Time
|
||||||
err = s.pool.QueryRow(ctx, `
|
err = s.pool.QueryRow(ctx, `
|
||||||
UPDATE tasks
|
UPDATE tasks
|
||||||
SET title = $1, description = $2, status = $3, due_at = $4, priority = $5, tags = $6, updated_at = now()
|
SET title = $1, description = $2, status = $3, due_at = $4, priority = $5, tags = $6, notify_group_ids = $7, updated_at = now()
|
||||||
WHERE id = $7 AND user_id = $8
|
WHERE id = $8 AND user_id = $9
|
||||||
RETURNING updated_at`,
|
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)
|
).Scan(&updatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Task{}, err
|
return Task{}, err
|
||||||
}
|
}
|
||||||
|
if err := s.syncTaskNotificationGroups(ctx, userID, id, existing.NotifyGroupIDs); err != nil {
|
||||||
|
return Task{}, err
|
||||||
|
}
|
||||||
existing.UpdatedAt = updatedAt
|
existing.UpdatedAt = updatedAt
|
||||||
return existing, nil
|
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 {
|
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)
|
result, err := s.pool.Exec(ctx, `DELETE FROM tasks WHERE id = $1 AND user_id = $2`, id, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -223,6 +354,7 @@ func (e *taskEmitter) Emit(ctx context.Context, eventType string, task Task, use
|
|||||||
"user_id": userID,
|
"user_id": userID,
|
||||||
"status": task.Status,
|
"status": task.Status,
|
||||||
"priority": task.Priority,
|
"priority": task.Priority,
|
||||||
|
"notify_group_ids": task.NotifyGroupIDs,
|
||||||
"at": time.Now().UTC().Format(time.RFC3339),
|
"at": time.Now().UTC().Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
data, err := json.Marshal(payload)
|
data, err := json.Marshal(payload)
|
||||||
@@ -254,10 +386,15 @@ func isUniqueViolation(err error) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
store, iamSvc, emitter := buildDependencies()
|
store, iamSvc, emitter, notifySvc := buildDependencies()
|
||||||
defer store.Close()
|
defer store.Close()
|
||||||
defer iamSvc.Close()
|
defer iamSvc.Close()
|
||||||
defer emitter.Close()
|
defer emitter.Close()
|
||||||
|
defer notifySvc.Close()
|
||||||
|
|
||||||
|
appCtx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
notifySvc.Start(appCtx)
|
||||||
|
|
||||||
gin.SetMode(gin.DebugMode)
|
gin.SetMode(gin.DebugMode)
|
||||||
router := gin.Default()
|
router := gin.Default()
|
||||||
@@ -299,6 +436,7 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
emitter.Emit(c.Request.Context(), "task.created", created, userID)
|
emitter.Emit(c.Request.Context(), "task.created", created, userID)
|
||||||
|
_ = notifySvc.PublishTaskEvent(c.Request.Context(), "task.created", toTaskSnapshot(userID, created))
|
||||||
c.JSON(http.StatusCreated, created)
|
c.JSON(http.StatusCreated, created)
|
||||||
})
|
})
|
||||||
tasks.GET(":id", func(c *gin.Context) {
|
tasks.GET(":id", func(c *gin.Context) {
|
||||||
@@ -331,6 +469,15 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
userID := c.GetInt64(iam.ContextUserIDKey)
|
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)
|
updated, err := store.Update(c.Request.Context(), userID, id, input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
@@ -340,7 +487,12 @@ func main() {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update task"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update task"})
|
||||||
return
|
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)
|
c.JSON(http.StatusOK, updated)
|
||||||
})
|
})
|
||||||
tasks.DELETE(":id", func(c *gin.Context) {
|
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 {
|
if err := router.Run(":8080"); err != nil {
|
||||||
panic(err)
|
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)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
@@ -392,7 +666,43 @@ func buildDependencies() (*postgresStore, *iam.Service, *taskEmitter) {
|
|||||||
topic = "todo.tasks"
|
topic = "todo.tasks"
|
||||||
}
|
}
|
||||||
emitter := newTaskEmitter(brokers, topic)
|
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) {
|
func parseID(value string) (int64, error) {
|
||||||
@@ -414,6 +724,25 @@ func splitCSV(value string) []string {
|
|||||||
return result
|
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 {
|
func corsMiddleware() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
origin := strings.TrimSpace(c.GetHeader("Origin"))
|
origin := strings.TrimSpace(c.GetHeader("Origin"))
|
||||||
|
|||||||
@@ -71,6 +71,20 @@ services:
|
|||||||
REDIS_ADDR: redis:6379
|
REDIS_ADDR: redis:6379
|
||||||
KAFKA_BROKERS: kafka:9092
|
KAFKA_BROKERS: kafka:9092
|
||||||
KAFKA_TOPIC: todo.tasks
|
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
|
AUTH_SECRET: dev-secret-change-me
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
- 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="/team">{{ t('nav_team') }}</RouterLink>
|
||||||
<RouterLink to="/settings/user">{{ t('nav_user_settings') }}</RouterLink>
|
<RouterLink to="/settings/user">{{ t('nav_user_settings') }}</RouterLink>
|
||||||
<RouterLink to="/settings/team">{{ t('nav_team_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>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<RouterLink to="/auth/login">{{ t('nav_login') }}</RouterLink>
|
<RouterLink to="/auth/login">{{ t('nav_login') }}</RouterLink>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, watch } from 'vue'
|
import { reactive, watch } from 'vue'
|
||||||
import type { Task } from '../types'
|
import type { NotificationGroup, Task } from '../types'
|
||||||
import { t } from '../i18n'
|
import { t } from '../i18n'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
editingTask?: Task | null
|
editingTask?: Task | null
|
||||||
|
availableGroups: NotificationGroup[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -19,6 +20,7 @@ const form = reactive({
|
|||||||
due_at: '',
|
due_at: '',
|
||||||
priority: 2,
|
priority: 2,
|
||||||
tags: '',
|
tags: '',
|
||||||
|
notify_group_ids: [] as number[],
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -29,6 +31,7 @@ watch(
|
|||||||
form.due_at = toLocal(task?.due_at ?? '')
|
form.due_at = toLocal(task?.due_at ?? '')
|
||||||
form.priority = task?.priority ?? 2
|
form.priority = task?.priority ?? 2
|
||||||
form.tags = task?.tags?.join(', ') ?? ''
|
form.tags = task?.tags?.join(', ') ?? ''
|
||||||
|
form.notify_group_ids = task?.notify_group_ids ? [...task.notify_group_ids] : []
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
@@ -42,6 +45,7 @@ watch(
|
|||||||
form.due_at = ''
|
form.due_at = ''
|
||||||
form.priority = 2
|
form.priority = 2
|
||||||
form.tags = ''
|
form.tags = ''
|
||||||
|
form.notify_group_ids = []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -60,6 +64,7 @@ function save() {
|
|||||||
.split(',')
|
.split(',')
|
||||||
.map((v) => v.trim())
|
.map((v) => v.trim())
|
||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
|
notify_group_ids: [...form.notify_group_ids],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +75,12 @@ function toLocal(v: string) {
|
|||||||
const offset = d.getTimezoneOffset() * 60000
|
const offset = d.getTimezoneOffset() * 60000
|
||||||
return new Date(d.getTime() - offset).toISOString().slice(0, 16)
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -98,6 +109,14 @@ function toLocal(v: string) {
|
|||||||
{{ t('tags') }}
|
{{ t('tags') }}
|
||||||
<input v-model="form.tags" type="text" :placeholder="t('tags_placeholder')" />
|
<input v-model="form.tags" type="text" :placeholder="t('tags_placeholder')" />
|
||||||
</label>
|
</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>
|
<label>
|
||||||
{{ t('description') }}
|
{{ t('description') }}
|
||||||
<textarea v-model="form.description" rows="4"></textarea>
|
<textarea v-model="form.description" rows="4"></textarea>
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ const messages = {
|
|||||||
nav_team: 'Team',
|
nav_team: 'Team',
|
||||||
nav_user_settings: 'User Settings',
|
nav_user_settings: 'User Settings',
|
||||||
nav_team_settings: 'Team Settings',
|
nav_team_settings: 'Team Settings',
|
||||||
|
nav_notification_groups: 'Notify Groups',
|
||||||
|
nav_notification_dlq: 'Notification DLQ',
|
||||||
nav_login: 'Login',
|
nav_login: 'Login',
|
||||||
logout: 'Logout',
|
logout: 'Logout',
|
||||||
guest_mode: 'Guest mode',
|
guest_mode: 'Guest mode',
|
||||||
@@ -35,6 +37,7 @@ const messages = {
|
|||||||
due_empty: '-',
|
due_empty: '-',
|
||||||
edit: 'Edit',
|
edit: 'Edit',
|
||||||
delete: 'Delete',
|
delete: 'Delete',
|
||||||
|
create: 'Create',
|
||||||
loading_tasks: 'Loading tasks...',
|
loading_tasks: 'Loading tasks...',
|
||||||
load_tasks_failed: 'Failed to load tasks. Please login again.',
|
load_tasks_failed: 'Failed to load tasks. Please login again.',
|
||||||
save_task_failed: 'Failed to save task.',
|
save_task_failed: 'Failed to save task.',
|
||||||
@@ -53,6 +56,7 @@ const messages = {
|
|||||||
low: 'Low',
|
low: 'Low',
|
||||||
tags: 'Tags',
|
tags: 'Tags',
|
||||||
tags_placeholder: 'backend, planning',
|
tags_placeholder: 'backend, planning',
|
||||||
|
notify_groups: 'Notify Groups',
|
||||||
description: 'Description',
|
description: 'Description',
|
||||||
cancel: 'Cancel',
|
cancel: 'Cancel',
|
||||||
save: 'Save',
|
save: 'Save',
|
||||||
@@ -60,12 +64,20 @@ const messages = {
|
|||||||
team_subtitle: 'Switch between teams and check active workload.',
|
team_subtitle: 'Switch between teams and check active workload.',
|
||||||
team_members: '{count} members',
|
team_members: '{count} members',
|
||||||
team_open_todos: '{count} open todos',
|
team_open_todos: '{count} open todos',
|
||||||
user_settings_title: 'User Settings',
|
user_settings_title: 'Notification Preferences',
|
||||||
user_settings_subtitle: 'Personal profile and preference controls.',
|
user_settings_subtitle: 'Manage your notification subscription and schedule.',
|
||||||
display_name: 'Display Name',
|
display_name: 'Display Name',
|
||||||
timezone: 'Timezone',
|
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: '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_title: 'Team Settings',
|
||||||
team_settings_subtitle: 'Global conventions for team collaboration.',
|
team_settings_subtitle: 'Global conventions for team collaboration.',
|
||||||
team_name: 'Team Name',
|
team_name: 'Team Name',
|
||||||
@@ -77,14 +89,32 @@ const messages = {
|
|||||||
task_created_success: 'Task created successfully',
|
task_created_success: 'Task created successfully',
|
||||||
task_deleted_success: 'Task deleted successfully',
|
task_deleted_success: 'Task deleted successfully',
|
||||||
confirm_delete: 'Are you sure you want to delete this task?',
|
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: {
|
zh: {
|
||||||
brand_name: 'SuperTodo',
|
brand_name: 'SuperTodo',
|
||||||
brand_caption: '简约的团队任务执行面板',
|
brand_caption: '简约的团队任务执行面板',
|
||||||
nav_todo: '待办',
|
nav_todo: '待办',
|
||||||
nav_team: '团队入口',
|
nav_team: '团队入口',
|
||||||
nav_user_settings: '用户设置',
|
nav_user_settings: '通知偏好',
|
||||||
nav_team_settings: '团队设置',
|
nav_team_settings: '团队设置',
|
||||||
|
nav_notification_groups: '通知组',
|
||||||
|
nav_notification_dlq: '死信队列',
|
||||||
nav_login: '登录',
|
nav_login: '登录',
|
||||||
logout: '退出登录',
|
logout: '退出登录',
|
||||||
guest_mode: '访客模式',
|
guest_mode: '访客模式',
|
||||||
@@ -109,6 +139,7 @@ const messages = {
|
|||||||
due_empty: '-',
|
due_empty: '-',
|
||||||
edit: '编辑',
|
edit: '编辑',
|
||||||
delete: '删除',
|
delete: '删除',
|
||||||
|
create: '创建',
|
||||||
loading_tasks: '任务加载中...',
|
loading_tasks: '任务加载中...',
|
||||||
load_tasks_failed: '加载任务失败,请重新登录。',
|
load_tasks_failed: '加载任务失败,请重新登录。',
|
||||||
save_task_failed: '保存任务失败。',
|
save_task_failed: '保存任务失败。',
|
||||||
@@ -127,6 +158,7 @@ const messages = {
|
|||||||
low: '低',
|
low: '低',
|
||||||
tags: '标签',
|
tags: '标签',
|
||||||
tags_placeholder: '后端, 规划',
|
tags_placeholder: '后端, 规划',
|
||||||
|
notify_groups: '通知组',
|
||||||
description: '描述',
|
description: '描述',
|
||||||
cancel: '取消',
|
cancel: '取消',
|
||||||
save: '保存',
|
save: '保存',
|
||||||
@@ -134,12 +166,20 @@ const messages = {
|
|||||||
team_subtitle: '在团队之间切换并查看当前工作量。',
|
team_subtitle: '在团队之间切换并查看当前工作量。',
|
||||||
team_members: '{count} 位成员',
|
team_members: '{count} 位成员',
|
||||||
team_open_todos: '{count} 个未完成任务',
|
team_open_todos: '{count} 个未完成任务',
|
||||||
user_settings_title: '用户设置',
|
user_settings_title: '通知偏好设置',
|
||||||
user_settings_subtitle: '管理个人资料和偏好设置。',
|
user_settings_subtitle: '管理订阅、免打扰与每日摘要。',
|
||||||
display_name: '显示名称',
|
display_name: '显示名称',
|
||||||
timezone: '时区',
|
timezone: '时区',
|
||||||
reminders: '接收任务提醒',
|
locale: '语言',
|
||||||
|
dnd_start: '免打扰开始',
|
||||||
|
dnd_end: '免打扰结束',
|
||||||
|
reminders: '接收通知',
|
||||||
|
daily_summary_enabled: '启用每日摘要',
|
||||||
|
daily_summary_time: '每日摘要时间',
|
||||||
save_settings: '保存设置',
|
save_settings: '保存设置',
|
||||||
|
save_settings_success: '设置保存成功',
|
||||||
|
save_settings_failed: '保存设置失败',
|
||||||
|
load_settings_failed: '加载设置失败',
|
||||||
team_settings_title: '团队设置',
|
team_settings_title: '团队设置',
|
||||||
team_settings_subtitle: '管理团队协作的默认规则。',
|
team_settings_subtitle: '管理团队协作的默认规则。',
|
||||||
team_name: '团队名称',
|
team_name: '团队名称',
|
||||||
@@ -151,6 +191,22 @@ const messages = {
|
|||||||
task_created_success: '任务创建成功',
|
task_created_success: '任务创建成功',
|
||||||
task_deleted_success: '任务删除成功',
|
task_deleted_success: '任务删除成功',
|
||||||
confirm_delete: '确定要删除这个任务吗?',
|
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
|
} as const
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,18 @@ const router = createRouter({
|
|||||||
component: () => import('./views/TeamSettingsView.vue'),
|
component: () => import('./views/TeamSettingsView.vue'),
|
||||||
meta: { requiresAuth: true },
|
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'
|
import { clearSession, session, setSessionToken } from '../stores/session'
|
||||||
|
|
||||||
const API_BASE = 'http://localhost:8080/api/v1'
|
const API_BASE = 'http://localhost:8080/api/v1'
|
||||||
@@ -115,3 +115,42 @@ export async function deleteTask(id: number) {
|
|||||||
method: 'DELETE',
|
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
|
due_at: string
|
||||||
priority: number
|
priority: number
|
||||||
tags: string[]
|
tags: string[]
|
||||||
|
notify_group_ids: number[]
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
@@ -14,3 +15,30 @@ export type Credentials = {
|
|||||||
email: string
|
email: string
|
||||||
password: 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">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import type { Task } from '../types'
|
import type { NotificationGroup, Task } from '../types'
|
||||||
import * as api from '../services/api'
|
import * as api from '../services/api'
|
||||||
import PaginationControl from '../components/PaginationControl.vue'
|
import PaginationControl from '../components/PaginationControl.vue'
|
||||||
import TodoEditorModal from '../components/TodoEditorModal.vue'
|
import TodoEditorModal from '../components/TodoEditorModal.vue'
|
||||||
@@ -10,6 +10,7 @@ import { useToast } from '../composables/useToast'
|
|||||||
const { success, error: showError } = useToast()
|
const { success, error: showError } = useToast()
|
||||||
|
|
||||||
const tasks = ref<Task[]>([])
|
const tasks = ref<Task[]>([])
|
||||||
|
const groups = ref<NotificationGroup[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const modalOpen = ref(false)
|
const modalOpen = ref(false)
|
||||||
const editingTask = ref<Task | null>(null)
|
const editingTask = ref<Task | null>(null)
|
||||||
@@ -19,7 +20,7 @@ const filter = ref<'all' | 'todo' | 'done'>('all')
|
|||||||
|
|
||||||
const filteredTasks = computed(() => {
|
const filteredTasks = computed(() => {
|
||||||
if (filter.value === 'all') return tasks.value
|
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)
|
const total = computed(() => filteredTasks.value.length)
|
||||||
@@ -29,6 +30,14 @@ const pagedTasks = computed(() => {
|
|||||||
return filteredTasks.value.slice(start, start + pageSize.value)
|
return filteredTasks.value.slice(start, start + pageSize.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function loadGroups() {
|
||||||
|
try {
|
||||||
|
groups.value = await api.listNotificationGroups()
|
||||||
|
} catch {
|
||||||
|
groups.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadTasks() {
|
async function loadTasks() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -56,10 +65,10 @@ async function saveTask(payload: Partial<Task>) {
|
|||||||
try {
|
try {
|
||||||
if (editingTask.value?.id) {
|
if (editingTask.value?.id) {
|
||||||
await api.updateTask(editingTask.value.id, payload)
|
await api.updateTask(editingTask.value.id, payload)
|
||||||
success(t('task_updated_success') || 'Task updated')
|
success(t('task_updated_success'))
|
||||||
} else {
|
} else {
|
||||||
await api.createTask({ ...payload, status: 'todo' })
|
await api.createTask({ ...payload, status: 'todo', notify_group_ids: payload.notify_group_ids ?? [] })
|
||||||
success(t('task_created_success') || 'Task created')
|
success(t('task_created_success'))
|
||||||
}
|
}
|
||||||
modalOpen.value = false
|
modalOpen.value = false
|
||||||
editingTask.value = null
|
editingTask.value = null
|
||||||
@@ -73,15 +82,11 @@ async function toggle(task: Task) {
|
|||||||
const originalStatus = task.status
|
const originalStatus = task.status
|
||||||
const newStatus = task.status === 'done' ? 'todo' : 'done'
|
const newStatus = task.status === 'done' ? 'todo' : 'done'
|
||||||
|
|
||||||
// Optimistic update
|
|
||||||
task.status = newStatus
|
task.status = newStatus
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.updateTask(task.id, { status: newStatus })
|
await api.updateTask(task.id, { status: newStatus })
|
||||||
// Background refresh to ensure consistency
|
|
||||||
// await loadTasks() // Optional: might cause jumping if list sort changes
|
|
||||||
} catch {
|
} catch {
|
||||||
// Revert on failure
|
|
||||||
task.status = originalStatus
|
task.status = originalStatus
|
||||||
showError(t('update_task_failed'))
|
showError(t('update_task_failed'))
|
||||||
}
|
}
|
||||||
@@ -91,11 +96,11 @@ async function remove(id: number) {
|
|||||||
if (!confirm(t('confirm_delete') || 'Are you sure?')) return
|
if (!confirm(t('confirm_delete') || 'Are you sure?')) return
|
||||||
|
|
||||||
const originalTasks = [...tasks.value]
|
const originalTasks = [...tasks.value]
|
||||||
tasks.value = tasks.value.filter(t => t.id !== id)
|
tasks.value = tasks.value.filter((task) => task.id !== id)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.deleteTask(id)
|
await api.deleteTask(id)
|
||||||
success(t('task_deleted_success') || 'Task deleted')
|
success(t('task_deleted_success'))
|
||||||
} catch {
|
} catch {
|
||||||
tasks.value = originalTasks
|
tasks.value = originalTasks
|
||||||
showError(t('delete_task_failed'))
|
showError(t('delete_task_failed'))
|
||||||
@@ -114,12 +119,20 @@ function formatDue(value: string) {
|
|||||||
return value ? new Date(value).toLocaleString() : t('due_empty')
|
return value ? new Date(value).toLocaleString() : t('due_empty')
|
||||||
}
|
}
|
||||||
|
|
||||||
function setFilter(f: 'all' | 'todo' | 'done') {
|
function setFilter(value: 'all' | 'todo' | 'done') {
|
||||||
filter.value = f
|
filter.value = value
|
||||||
page.value = 1
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -131,21 +144,9 @@ onMounted(loadTasks)
|
|||||||
</div>
|
</div>
|
||||||
<div class="page-actions">
|
<div class="page-actions">
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<button
|
<button class="btn sm" :class="{ primary: filter === 'all' }" @click="setFilter('all')">All</button>
|
||||||
class="btn sm"
|
<button class="btn sm" :class="{ primary: filter === 'todo' }" @click="setFilter('todo')">Active</button>
|
||||||
:class="{ primary: filter === 'all' }"
|
<button class="btn sm" :class="{ primary: filter === 'done' }" @click="setFilter('done')">Done</button>
|
||||||
@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>
|
</div>
|
||||||
<button class="btn" @click="loadTasks">{{ t('refresh') }}</button>
|
<button class="btn" @click="loadTasks">{{ t('refresh') }}</button>
|
||||||
<button class="btn primary" @click="openCreate">{{ t('new_todo') }}</button>
|
<button class="btn primary" @click="openCreate">{{ t('new_todo') }}</button>
|
||||||
@@ -174,6 +175,7 @@ onMounted(loadTasks)
|
|||||||
<div class="task-meta">
|
<div class="task-meta">
|
||||||
<small>{{ t('priority') }} P{{ task.priority || 2 }}</small>
|
<small>{{ t('priority') }} P{{ task.priority || 2 }}</small>
|
||||||
<small>{{ t('due_at') }} {{ formatDue(task.due_at) }}</small>
|
<small>{{ t('due_at') }} {{ formatDue(task.due_at) }}</small>
|
||||||
|
<small>{{ t('notify_groups') }} {{ groupNames(task.notify_group_ids || []) }}</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="task-row-actions">
|
<div class="task-row-actions">
|
||||||
@@ -187,7 +189,7 @@ onMounted(loadTasks)
|
|||||||
|
|
||||||
<PaginationControl :page="page" :page-size="pageSize" :total="total" @next="nextPage" @prev="prevPage" />
|
<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>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,49 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive } from 'vue'
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
import { session } from '../stores/session'
|
import type { NotificationPrefs } from '../types'
|
||||||
|
import * as api from '../services/api'
|
||||||
import { t } from '../i18n'
|
import { t } from '../i18n'
|
||||||
|
import { useToast } from '../composables/useToast'
|
||||||
|
|
||||||
const form = reactive({
|
const { success, error } = useToast()
|
||||||
displayName: 'Product Operator',
|
const loading = ref(false)
|
||||||
email: session.email,
|
|
||||||
|
const form = reactive<NotificationPrefs>({
|
||||||
|
subscribed: true,
|
||||||
|
dnd_start: '',
|
||||||
|
dnd_end: '',
|
||||||
|
locale: 'zh',
|
||||||
timezone: 'Asia/Shanghai',
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -18,24 +53,39 @@ const form = reactive({
|
|||||||
<p>{{ t('user_settings_subtitle') }}</p>
|
<p>{{ t('user_settings_subtitle') }}</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<form class="card settings-form" @submit.prevent>
|
<form class="card settings-form" @submit.prevent="savePrefs">
|
||||||
<label>
|
<label class="inline">
|
||||||
{{ t('display_name') }}
|
<input v-model="form.subscribed" type="checkbox" />
|
||||||
<input v-model="form.displayName" type="text" />
|
{{ t('reminders') }}
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
{{ t('email') }}
|
|
||||||
<input v-model="form.email" type="email" />
|
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
{{ t('timezone') }}
|
{{ 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>
|
||||||
<label class="inline">
|
<label class="inline">
|
||||||
<input v-model="form.notifications" type="checkbox" />
|
<input v-model="form.daily_summary_enabled" type="checkbox" />
|
||||||
{{ t('reminders') }}
|
{{ t('daily_summary_enabled') }}
|
||||||
</label>
|
</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>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user