feat(notification): add scheduled email pipeline with prefs/groups and DLQ UI

This commit is contained in:
2026-03-01 22:28:51 +08:00
parent 809a3dc522
commit 7e97aaa7fc
13 changed files with 2416 additions and 88 deletions

View File

@@ -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, &notifyGroupIDs, &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, &notifyGroupIDs, &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"))

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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 },
},
],
})

View File

@@ -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}`)
}

View File

@@ -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
}

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>