mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
feat(tasks): add categories templates and batch controls
This commit is contained in:
1 parent
3bedbdc669
commit
04fda4cd01
9 files changed
+2337
-295
No files matched your search
@@ -0,0 +1,201 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
const MaxTaskCategoryNameRunes = 80
|
||||
|
||||
var (
|
||||
ErrTaskCategoryInvalid = errors.New("invalid task category")
|
||||
ErrTaskCategoryNameConflict = errors.New("task category name already exists")
|
||||
ErrTaskCategoryNotFound = errors.New("task category not found")
|
||||
ErrTaskCategoryTaskNotFound = errors.New("task not found")
|
||||
)
|
||||
|
||||
// TaskCategory is a globally reusable task grouping label.
|
||||
type TaskCategory struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
NKey string `json:"-"`
|
||||
TaskCount int `json:"task_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
const taskCategoryCols = `category.id, category.name, category.nkey,
|
||||
count(task.id) FILTER (WHERE task.deleted_at IS NULL),
|
||||
category.created_at, category.updated_at`
|
||||
|
||||
func scanTaskCategory(row interface{ Scan(...any) error }) (TaskCategory, error) {
|
||||
var category TaskCategory
|
||||
err := row.Scan(&category.ID, &category.Name, &category.NKey, &category.TaskCount, &category.CreatedAt, &category.UpdatedAt)
|
||||
return category, err
|
||||
}
|
||||
|
||||
func normalizeTaskCategoryName(name string) (string, string, error) {
|
||||
name = strings.Join(strings.Fields(name), " ")
|
||||
if name == "" {
|
||||
return "", "", fmt.Errorf("%w: name is required", ErrTaskCategoryInvalid)
|
||||
}
|
||||
if utf8.RuneCountInString(name) > MaxTaskCategoryNameRunes {
|
||||
return "", "", fmt.Errorf("%w: name exceeds %d characters", ErrTaskCategoryInvalid, MaxTaskCategoryNameRunes)
|
||||
}
|
||||
return name, strings.ToLower(name), nil
|
||||
}
|
||||
|
||||
func taskCategoryUniqueViolation(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(err, &pgErr) && pgErr.Code == "23505"
|
||||
}
|
||||
|
||||
func (d *DB) CreateTaskCategory(name string) (*TaskCategory, error) {
|
||||
name, nkey, err := normalizeTaskCategoryName(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
category, err := scanTaskCategory(d.QueryRow(`
|
||||
WITH inserted AS (
|
||||
INSERT INTO task_categories(name, nkey)
|
||||
VALUES ($1,$2)
|
||||
ON CONFLICT (nkey) DO NOTHING
|
||||
RETURNING *
|
||||
)
|
||||
SELECT inserted.id, inserted.name, inserted.nkey, 0, inserted.created_at, inserted.updated_at
|
||||
FROM inserted`, name, nkey))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrTaskCategoryNameConflict
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &category, nil
|
||||
}
|
||||
|
||||
func (d *DB) ListTaskCategories() ([]*TaskCategory, error) {
|
||||
rows, err := d.Query(`
|
||||
SELECT ` + taskCategoryCols + `
|
||||
FROM task_categories category
|
||||
LEFT JOIN tasks task ON task.category_id=category.id
|
||||
GROUP BY category.id
|
||||
ORDER BY category.name, category.id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
categories := []*TaskCategory{}
|
||||
for rows.Next() {
|
||||
category, err := scanTaskCategory(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
categories = append(categories, &category)
|
||||
}
|
||||
return categories, rows.Err()
|
||||
}
|
||||
|
||||
func (d *DB) GetTaskCategory(id int64) (*TaskCategory, error) {
|
||||
category, err := scanTaskCategory(d.QueryRow(`
|
||||
SELECT `+taskCategoryCols+`
|
||||
FROM task_categories category
|
||||
LEFT JOIN tasks task ON task.category_id=category.id
|
||||
WHERE category.id=$1
|
||||
GROUP BY category.id`, id))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &category, nil
|
||||
}
|
||||
|
||||
func (d *DB) RenameTaskCategory(id int64, name string) (*TaskCategory, error) {
|
||||
name, nkey, err := normalizeTaskCategoryName(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
category, err := scanTaskCategory(d.QueryRow(`
|
||||
WITH updated AS (
|
||||
UPDATE task_categories SET name=$2, nkey=$3 WHERE id=$1 RETURNING *
|
||||
)
|
||||
SELECT updated.id, updated.name, updated.nkey,
|
||||
(SELECT count(*) FROM tasks WHERE category_id=updated.id AND deleted_at IS NULL),
|
||||
updated.created_at, updated.updated_at
|
||||
FROM updated`, id, name, nkey))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrTaskCategoryNotFound
|
||||
}
|
||||
if taskCategoryUniqueViolation(err) {
|
||||
return nil, ErrTaskCategoryNameConflict
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &category, nil
|
||||
}
|
||||
|
||||
// DeleteTaskCategory moves affected tasks to the uncategorized bucket through
|
||||
// the tasks.category_id ON DELETE SET NULL foreign key.
|
||||
func (d *DB) DeleteTaskCategory(id int64) (bool, error) {
|
||||
result, err := d.Exec(`DELETE FROM task_categories WHERE id=$1`, id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
return rows > 0, err
|
||||
}
|
||||
|
||||
// SetTaskCategory updates one live task. A nil category means uncategorized.
|
||||
func (d *DB) SetTaskCategory(taskID int64, categoryID *int64) (*TaskCategory, error) {
|
||||
if categoryID == nil {
|
||||
result, err := d.Exec(`UPDATE tasks SET category_id=NULL WHERE id=$1 AND deleted_at IS NULL`, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rows == 0 {
|
||||
return nil, ErrTaskCategoryTaskNotFound
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
if *categoryID <= 0 {
|
||||
return nil, fmt.Errorf("%w: category id must be positive", ErrTaskCategoryInvalid)
|
||||
}
|
||||
category, err := scanTaskCategory(d.QueryRow(`
|
||||
WITH selected AS (
|
||||
SELECT * FROM task_categories WHERE id=$2
|
||||
), updated AS (
|
||||
UPDATE tasks SET category_id=$2
|
||||
WHERE id=$1 AND deleted_at IS NULL AND EXISTS (SELECT 1 FROM selected)
|
||||
RETURNING id
|
||||
)
|
||||
SELECT selected.id, selected.name, selected.nkey,
|
||||
(SELECT count(*) FROM tasks WHERE category_id=selected.id AND deleted_at IS NULL),
|
||||
selected.created_at, selected.updated_at
|
||||
FROM selected, updated`, taskID, *categoryID))
|
||||
if err == sql.ErrNoRows {
|
||||
var taskExists bool
|
||||
if checkErr := d.QueryRow(`SELECT EXISTS(SELECT 1 FROM tasks WHERE id=$1 AND deleted_at IS NULL)`, taskID).Scan(&taskExists); checkErr != nil {
|
||||
return nil, checkErr
|
||||
}
|
||||
if !taskExists {
|
||||
return nil, ErrTaskCategoryTaskNotFound
|
||||
}
|
||||
return nil, ErrTaskCategoryNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &category, nil
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTaskCategoryCRUDAndTaskAssignment(t *testing.T) {
|
||||
d, err := Open(testDSN(t))
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
name := fmt.Sprintf("Category %d", time.Now().UnixNano())
|
||||
category, err := d.CreateTaskCategory(" " + strings.ReplaceAll(name, " ", " ") + " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if category.Name != name {
|
||||
t.Fatalf("category name=%q, want %q", category.Name, name)
|
||||
}
|
||||
if _, err := d.CreateTaskCategory(strings.ToUpper(name)); !errors.Is(err, ErrTaskCategoryNameConflict) {
|
||||
t.Fatalf("duplicate error=%v, want %v", err, ErrTaskCategoryNameConflict)
|
||||
}
|
||||
|
||||
task, err := d.CreateTaskWithOptions("categorized task", "goal", TaskCreateOptions{CategoryID: &category.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.DeleteTask(task.ID) })
|
||||
loaded, err := d.GetTask(task.ID)
|
||||
if err != nil || loaded == nil || loaded.CategoryID == nil || *loaded.CategoryID != category.ID || loaded.CategoryName != name {
|
||||
t.Fatalf("categorized task=%+v err=%v", loaded, err)
|
||||
}
|
||||
|
||||
renamed, err := d.RenameTaskCategory(category.ID, name+" renamed")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if renamed.TaskCount != 1 {
|
||||
t.Fatalf("renamed task count=%d, want 1", renamed.TaskCount)
|
||||
}
|
||||
loaded, _ = d.GetTask(task.ID)
|
||||
if loaded.CategoryName != renamed.Name {
|
||||
t.Fatalf("task category name=%q, want %q", loaded.CategoryName, renamed.Name)
|
||||
}
|
||||
|
||||
if _, err := d.SetTaskCategory(task.ID, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, _ = d.GetTask(task.ID)
|
||||
if loaded.CategoryID != nil || loaded.CategoryName != "" {
|
||||
t.Fatalf("uncategorized task retained category: %+v", loaded)
|
||||
}
|
||||
if _, err := d.SetTaskCategory(task.ID, &category.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deleted, err := d.DeleteTaskCategory(category.ID)
|
||||
if err != nil || !deleted {
|
||||
t.Fatalf("delete category=%v err=%v", deleted, err)
|
||||
}
|
||||
loaded, _ = d.GetTask(task.ID)
|
||||
if loaded.CategoryID != nil || loaded.CategoryName != "" {
|
||||
t.Fatalf("category deletion did not clear task: %+v", loaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTaskRejectsMissingCategoryAtomically(t *testing.T) {
|
||||
d, err := Open(testDSN(t))
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
missing := int64(1 << 62)
|
||||
description := fmt.Sprintf("missing-category-%d", time.Now().UnixNano())
|
||||
if _, err := d.CreateTaskWithOptions(description, "goal", TaskCreateOptions{CategoryID: &missing}); !errors.Is(err, ErrTaskCategoryNotFound) {
|
||||
t.Fatalf("create error=%v, want %v", err, ErrTaskCategoryNotFound)
|
||||
}
|
||||
var count int
|
||||
if err := d.QueryRow(`SELECT count(*) FROM explorations WHERE description=$1`, description).Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("failed create leaked %d exploration rows", count)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxTaskTemplateNameRunes = 120
|
||||
MaxTaskTemplateTextRunes = 16000
|
||||
)
|
||||
|
||||
var (
|
||||
ErrTaskTemplateInvalid = errors.New("invalid task template")
|
||||
ErrTaskTemplateNameConflict = errors.New("task template name already exists")
|
||||
ErrTaskTemplateNotFound = errors.New("task template not found")
|
||||
)
|
||||
|
||||
// TaskTemplate is a reusable task description and goal preset.
|
||||
type TaskTemplate struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
NKey string `json:"-"`
|
||||
Description string `json:"description"`
|
||||
Goal string `json:"goal"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TaskTemplateInput is the create/update payload after normalization.
|
||||
type TaskTemplateInput struct {
|
||||
Name string
|
||||
Description string
|
||||
Goal string
|
||||
}
|
||||
|
||||
// TaskTemplatePatch changes only fields whose pointers are non-nil.
|
||||
type TaskTemplatePatch struct {
|
||||
Name *string
|
||||
Description *string
|
||||
Goal *string
|
||||
}
|
||||
|
||||
const taskTemplateCols = `id, name, nkey, description, goal, created_at, updated_at`
|
||||
|
||||
func scanTaskTemplate(row interface{ Scan(...any) error }) (TaskTemplate, error) {
|
||||
var t TaskTemplate
|
||||
err := row.Scan(&t.ID, &t.Name, &t.NKey, &t.Description, &t.Goal, &t.CreatedAt, &t.UpdatedAt)
|
||||
return t, err
|
||||
}
|
||||
|
||||
// taskTemplateName normalizes display whitespace while preserving the user's case.
|
||||
func taskTemplateName(name string) string { return strings.Join(strings.Fields(name), " ") }
|
||||
|
||||
// taskTemplateNKey is the case-insensitive identity used by the unique index.
|
||||
func taskTemplateNKey(name string) string { return strings.ToLower(taskTemplateName(name)) }
|
||||
|
||||
func normalizeTaskTemplateInput(in TaskTemplateInput) (TaskTemplateInput, string, error) {
|
||||
in.Name = taskTemplateName(in.Name)
|
||||
in.Description = strings.TrimSpace(in.Description)
|
||||
in.Goal = strings.TrimSpace(in.Goal)
|
||||
switch {
|
||||
case in.Name == "":
|
||||
return in, "", fmt.Errorf("%w: name is required", ErrTaskTemplateInvalid)
|
||||
case utf8.RuneCountInString(in.Name) > MaxTaskTemplateNameRunes:
|
||||
return in, "", fmt.Errorf("%w: name exceeds %d characters", ErrTaskTemplateInvalid, MaxTaskTemplateNameRunes)
|
||||
case in.Description == "":
|
||||
return in, "", fmt.Errorf("%w: description is required", ErrTaskTemplateInvalid)
|
||||
case utf8.RuneCountInString(in.Description) > MaxTaskTemplateTextRunes:
|
||||
return in, "", fmt.Errorf("%w: description exceeds %d characters", ErrTaskTemplateInvalid, MaxTaskTemplateTextRunes)
|
||||
case in.Goal == "":
|
||||
return in, "", fmt.Errorf("%w: goal is required", ErrTaskTemplateInvalid)
|
||||
case utf8.RuneCountInString(in.Goal) > MaxTaskTemplateTextRunes:
|
||||
return in, "", fmt.Errorf("%w: goal exceeds %d characters", ErrTaskTemplateInvalid, MaxTaskTemplateTextRunes)
|
||||
}
|
||||
return in, taskTemplateNKey(in.Name), nil
|
||||
}
|
||||
|
||||
func normalizeTaskTemplatePatch(patch TaskTemplatePatch) (TaskTemplatePatch, *string, error) {
|
||||
if patch.Name == nil && patch.Description == nil && patch.Goal == nil {
|
||||
return patch, nil, fmt.Errorf("%w: no fields supplied", ErrTaskTemplateInvalid)
|
||||
}
|
||||
var nkey *string
|
||||
if patch.Name != nil {
|
||||
name := taskTemplateName(*patch.Name)
|
||||
if name == "" {
|
||||
return patch, nil, fmt.Errorf("%w: name is required", ErrTaskTemplateInvalid)
|
||||
}
|
||||
if utf8.RuneCountInString(name) > MaxTaskTemplateNameRunes {
|
||||
return patch, nil, fmt.Errorf("%w: name exceeds %d characters", ErrTaskTemplateInvalid, MaxTaskTemplateNameRunes)
|
||||
}
|
||||
key := taskTemplateNKey(name)
|
||||
patch.Name = &name
|
||||
nkey = &key
|
||||
}
|
||||
if patch.Description != nil {
|
||||
description := strings.TrimSpace(*patch.Description)
|
||||
if description == "" {
|
||||
return patch, nil, fmt.Errorf("%w: description is required", ErrTaskTemplateInvalid)
|
||||
}
|
||||
if utf8.RuneCountInString(description) > MaxTaskTemplateTextRunes {
|
||||
return patch, nil, fmt.Errorf("%w: description exceeds %d characters", ErrTaskTemplateInvalid, MaxTaskTemplateTextRunes)
|
||||
}
|
||||
patch.Description = &description
|
||||
}
|
||||
if patch.Goal != nil {
|
||||
goal := strings.TrimSpace(*patch.Goal)
|
||||
if goal == "" {
|
||||
return patch, nil, fmt.Errorf("%w: goal is required", ErrTaskTemplateInvalid)
|
||||
}
|
||||
if utf8.RuneCountInString(goal) > MaxTaskTemplateTextRunes {
|
||||
return patch, nil, fmt.Errorf("%w: goal exceeds %d characters", ErrTaskTemplateInvalid, MaxTaskTemplateTextRunes)
|
||||
}
|
||||
patch.Goal = &goal
|
||||
}
|
||||
return patch, nkey, nil
|
||||
}
|
||||
|
||||
func taskTemplateUniqueViolation(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(err, &pgErr) && pgErr.Code == "23505"
|
||||
}
|
||||
|
||||
// CreateTaskTemplate inserts one globally reusable preset.
|
||||
func (d *DB) CreateTaskTemplate(in TaskTemplateInput) (*TaskTemplate, error) {
|
||||
in, nkey, err := normalizeTaskTemplateInput(in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t, err := scanTaskTemplate(d.QueryRow(`
|
||||
INSERT INTO task_templates(name, nkey, description, goal)
|
||||
VALUES ($1,$2,$3,$4)
|
||||
ON CONFLICT (nkey) DO NOTHING
|
||||
RETURNING `+taskTemplateCols, in.Name, nkey, in.Description, in.Goal))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrTaskTemplateNameConflict
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// ListTaskTemplates returns the most recently maintained templates first.
|
||||
func (d *DB) ListTaskTemplates() ([]*TaskTemplate, error) {
|
||||
rows, err := d.Query(`SELECT ` + taskTemplateCols + ` FROM task_templates ORDER BY updated_at DESC, id DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []*TaskTemplate{}
|
||||
for rows.Next() {
|
||||
t, err := scanTaskTemplate(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, &t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetTaskTemplate returns nil when id does not exist.
|
||||
func (d *DB) GetTaskTemplate(id int64) (*TaskTemplate, error) {
|
||||
t, err := scanTaskTemplate(d.QueryRow(`SELECT `+taskTemplateCols+` FROM task_templates WHERE id=$1`, id))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// UpdateTaskTemplate replaces the editable fields of one preset.
|
||||
func (d *DB) UpdateTaskTemplate(id int64, in TaskTemplateInput) (*TaskTemplate, error) {
|
||||
in, _, err := normalizeTaskTemplateInput(in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return d.PatchTaskTemplate(id, TaskTemplatePatch{
|
||||
Name: &in.Name, Description: &in.Description, Goal: &in.Goal,
|
||||
})
|
||||
}
|
||||
|
||||
// PatchTaskTemplate atomically changes only the supplied fields. Keeping the
|
||||
// merge in one UPDATE prevents concurrent disjoint PATCH requests from losing
|
||||
// each other's changes.
|
||||
func (d *DB) PatchTaskTemplate(id int64, patch TaskTemplatePatch) (*TaskTemplate, error) {
|
||||
patch, nkey, err := normalizeTaskTemplatePatch(patch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t, err := scanTaskTemplate(d.QueryRow(`UPDATE task_templates
|
||||
SET name=CASE WHEN $2 THEN $3::text ELSE name END,
|
||||
nkey=CASE WHEN $2 THEN $4::text ELSE nkey END,
|
||||
description=CASE WHEN $5 THEN $6::text ELSE description END,
|
||||
goal=CASE WHEN $7 THEN $8::text ELSE goal END
|
||||
WHERE id=$1
|
||||
RETURNING `+taskTemplateCols,
|
||||
id,
|
||||
patch.Name != nil, patch.Name, nkey,
|
||||
patch.Description != nil, patch.Description,
|
||||
patch.Goal != nil, patch.Goal,
|
||||
))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrTaskTemplateNotFound
|
||||
}
|
||||
if taskTemplateUniqueViolation(err) {
|
||||
return nil, ErrTaskTemplateNameConflict
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// DeleteTaskTemplate deletes one preset and reports whether it existed.
|
||||
func (d *DB) DeleteTaskTemplate(id int64) (bool, error) {
|
||||
result, err := d.Exec(`DELETE FROM task_templates WHERE id=$1`, id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, err := result.RowsAffected()
|
||||
return n > 0, err
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTaskTemplateCRUDAndNormalizedUniqueness(t *testing.T) {
|
||||
d, err := Open(testDSN(t))
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
suffix := time.Now().UnixNano()
|
||||
name := fmt.Sprintf("Template %d", suffix)
|
||||
created, err := d.CreateTaskTemplate(TaskTemplateInput{
|
||||
Name: " " + strings.ReplaceAll(name, " ", " ") + " ",
|
||||
Description: " initial description ",
|
||||
Goal: " initial goal ",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = d.DeleteTaskTemplate(created.ID) })
|
||||
if created.Name != name || created.Description != "initial description" || created.Goal != "initial goal" {
|
||||
t.Fatalf("template was not normalized: %+v", created)
|
||||
}
|
||||
|
||||
if _, err := d.CreateTaskTemplate(TaskTemplateInput{
|
||||
Name: strings.ToUpper(name), Description: "duplicate", Goal: "duplicate",
|
||||
}); !errors.Is(err, ErrTaskTemplateNameConflict) {
|
||||
t.Fatalf("duplicate create error = %v, want %v", err, ErrTaskTemplateNameConflict)
|
||||
}
|
||||
|
||||
got, err := d.GetTaskTemplate(created.ID)
|
||||
if err != nil || got == nil || got.Name != name {
|
||||
t.Fatalf("GetTaskTemplate = %+v, %v", got, err)
|
||||
}
|
||||
listed, err := d.ListTaskTemplates()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, template := range listed {
|
||||
if template.ID == created.ID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("created template %d missing from list", created.ID)
|
||||
}
|
||||
|
||||
updatedName := name + " updated"
|
||||
updated, err := d.UpdateTaskTemplate(created.ID, TaskTemplateInput{
|
||||
Name: updatedName, Description: "new description", Goal: "new goal",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.Name != updatedName || updated.Description != "new description" || updated.Goal != "new goal" {
|
||||
t.Fatalf("unexpected updated template: %+v", updated)
|
||||
}
|
||||
|
||||
if _, err := d.CreateTaskTemplate(TaskTemplateInput{Name: "", Description: "x", Goal: "y"}); !errors.Is(err, ErrTaskTemplateInvalid) {
|
||||
t.Fatalf("empty name error = %v, want %v", err, ErrTaskTemplateInvalid)
|
||||
}
|
||||
deleted, err := d.DeleteTaskTemplate(created.ID)
|
||||
if err != nil || !deleted {
|
||||
t.Fatalf("DeleteTaskTemplate = %v, %v", deleted, err)
|
||||
}
|
||||
deleted, err = d.DeleteTaskTemplate(created.ID)
|
||||
if err != nil || deleted {
|
||||
t.Fatalf("second DeleteTaskTemplate = %v, %v", deleted, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskTemplateDisjointPatchesCompose(t *testing.T) {
|
||||
d, err := Open(testDSN(t))
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
created, err := d.CreateTaskTemplate(TaskTemplateInput{
|
||||
Name: fmt.Sprintf("Concurrent template %d", time.Now().UnixNano()),
|
||||
Description: "initial description",
|
||||
Goal: "initial goal",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = d.DeleteTaskTemplate(created.ID) })
|
||||
|
||||
description := "description from concurrent patch"
|
||||
goal := "goal from concurrent patch"
|
||||
start := make(chan struct{})
|
||||
errs := make(chan error, 2)
|
||||
var wg sync.WaitGroup
|
||||
for _, patch := range []TaskTemplatePatch{{Description: &description}, {Goal: &goal}} {
|
||||
patch := patch
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_, err := d.PatchTaskTemplate(created.ID, patch)
|
||||
errs <- err
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := d.GetTaskTemplate(created.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got == nil || got.Description != description || got.Goal != goal {
|
||||
t.Fatalf("disjoint patches lost an update: %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
const maxTaskCategoryRequestBytes = 16 << 10
|
||||
|
||||
type taskCategoryRequest struct {
|
||||
Name *string `json:"name"`
|
||||
}
|
||||
|
||||
func decodeTaskCategoryRequest(w http.ResponseWriter, r *http.Request) (*taskCategoryRequest, bool) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxTaskCategoryRequestBytes)
|
||||
var request taskCategoryRequest
|
||||
if err := decode(r, &request); err != nil {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
writeErr(w, http.StatusRequestEntityTooLarge, "请求正文过大")
|
||||
} else {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
if request.Name == nil || strings.TrimSpace(*request.Name) == "" {
|
||||
writeErr(w, http.StatusBadRequest, "分类名称不能为空")
|
||||
return nil, false
|
||||
}
|
||||
if utf8.RuneCountInString(strings.TrimSpace(*request.Name)) > db.MaxTaskCategoryNameRunes {
|
||||
writeErr(w, http.StatusBadRequest, "分类名称最多 80 个字符")
|
||||
return nil, false
|
||||
}
|
||||
return &request, true
|
||||
}
|
||||
|
||||
func writeTaskCategoryError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, db.ErrTaskCategoryInvalid):
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
case errors.Is(err, db.ErrTaskCategoryNameConflict):
|
||||
writeErr(w, http.StatusConflict, "分类名称已存在")
|
||||
case errors.Is(err, db.ErrTaskCategoryNotFound), errors.Is(err, db.ErrTaskCategoryTaskNotFound):
|
||||
writeErr(w, http.StatusNotFound, err.Error())
|
||||
default:
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) pgListTaskCategories(w http.ResponseWriter, _ *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
categories, err := pg.ListTaskCategories()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"categories": categories})
|
||||
}
|
||||
|
||||
func (s *Server) pgCreateTaskCategory(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
request, ok := decodeTaskCategoryRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
category, err := pg.CreateTaskCategory(*request.Name)
|
||||
if err != nil {
|
||||
writeTaskCategoryError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, category)
|
||||
}
|
||||
|
||||
func (s *Server) pgRenameTaskCategory(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathInt(r, "id")
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "bad task category id")
|
||||
return
|
||||
}
|
||||
request, ok := decodeTaskCategoryRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
category, err := s.m.RenameTaskCategory(id, *request.Name)
|
||||
if err != nil {
|
||||
writeTaskCategoryError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, category)
|
||||
}
|
||||
|
||||
func (s *Server) pgDeleteTaskCategory(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathInt(r, "id")
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "bad task category id")
|
||||
return
|
||||
}
|
||||
deleted, err := s.m.DeleteTaskCategory(id)
|
||||
if err != nil {
|
||||
writeTaskCategoryError(w, err)
|
||||
return
|
||||
}
|
||||
if !deleted {
|
||||
writeErr(w, http.StatusNotFound, "task category not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"deleted": id})
|
||||
}
|
||||
|
||||
func (s *Server) updateTaskCategory(w http.ResponseWriter, r *http.Request) {
|
||||
taskID := r.PathValue("id")
|
||||
if _, ok := s.m.Task(taskID); !ok {
|
||||
writeErr(w, http.StatusNotFound, "task not found")
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxTaskCategoryRequestBytes)
|
||||
var request struct {
|
||||
CategoryID json.RawMessage `json:"category_id"`
|
||||
}
|
||||
if err := decode(r, &request); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if len(request.CategoryID) == 0 {
|
||||
writeErr(w, http.StatusBadRequest, "category_id is required")
|
||||
return
|
||||
}
|
||||
var categoryID *int64
|
||||
if !bytes.Equal(bytes.TrimSpace(request.CategoryID), []byte("null")) {
|
||||
var id int64
|
||||
if err := json.Unmarshal(request.CategoryID, &id); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "任务分类 id 无效")
|
||||
return
|
||||
}
|
||||
categoryID = &id
|
||||
}
|
||||
if categoryID != nil && *categoryID <= 0 {
|
||||
writeErr(w, http.StatusBadRequest, "任务分类 id 无效")
|
||||
return
|
||||
}
|
||||
if _, err := s.m.SetTaskCategory(taskID, categoryID); err != nil {
|
||||
writeTaskCategoryError(w, err)
|
||||
return
|
||||
}
|
||||
task, _ := s.m.Task(taskID)
|
||||
writeJSON(w, http.StatusOK, taskDTO(task, s.resolvedTaskStatus(task)))
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
const maxTaskTemplateRequestBytes = 512 << 10
|
||||
|
||||
// taskTemplateRequest uses pointers so PATCH can distinguish omitted fields from
|
||||
// explicit empty values. Empty values are still rejected by the DB validator.
|
||||
type taskTemplateRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
Goal *string `json:"goal"`
|
||||
}
|
||||
|
||||
func decodeTaskTemplateRequest(w http.ResponseWriter, r *http.Request, req *taskTemplateRequest) bool {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxTaskTemplateRequestBytes)
|
||||
if err := decode(r, req); err != nil {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
writeErr(w, http.StatusRequestEntityTooLarge, "请求正文过大")
|
||||
} else {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validateTaskTemplateRequest(req taskTemplateRequest) error {
|
||||
checks := []struct {
|
||||
name string
|
||||
value *string
|
||||
limit int
|
||||
}{
|
||||
{name: "name", value: req.Name, limit: db.MaxTaskTemplateNameRunes},
|
||||
{name: "description", value: req.Description, limit: db.MaxTaskTemplateTextRunes},
|
||||
{name: "goal", value: req.Goal, limit: db.MaxTaskTemplateTextRunes},
|
||||
}
|
||||
for _, check := range checks {
|
||||
if check.value == nil {
|
||||
continue
|
||||
}
|
||||
value := strings.TrimSpace(*check.value)
|
||||
if check.name == "name" {
|
||||
value = strings.Join(strings.Fields(value), " ")
|
||||
}
|
||||
if utf8.RuneCountInString(value) > check.limit {
|
||||
return fmt.Errorf("%s 最多 %d 个字符", check.name, check.limit)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeTaskTemplateErr(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, db.ErrTaskTemplateInvalid):
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
case errors.Is(err, db.ErrTaskTemplateNameConflict):
|
||||
writeErr(w, http.StatusConflict, "模板名称已存在")
|
||||
case errors.Is(err, db.ErrTaskTemplateNotFound):
|
||||
writeErr(w, http.StatusNotFound, "task template not found")
|
||||
default:
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) pgListTaskTemplates(w http.ResponseWriter, _ *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
templates, err := pg.ListTaskTemplates()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"templates": templates})
|
||||
}
|
||||
|
||||
func (s *Server) pgCreateTaskTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
var req taskTemplateRequest
|
||||
if !decodeTaskTemplateRequest(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if err := validateTaskTemplateRequest(req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
template, err := pg.CreateTaskTemplate(db.TaskTemplateInput{
|
||||
Name: stringValue(req.Name),
|
||||
Description: stringValue(req.Description),
|
||||
Goal: stringValue(req.Goal),
|
||||
})
|
||||
if err != nil {
|
||||
writeTaskTemplateErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, template)
|
||||
}
|
||||
|
||||
func (s *Server) pgUpdateTaskTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
id, ok := pathInt(r, "id")
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "bad task template id")
|
||||
return
|
||||
}
|
||||
var req taskTemplateRequest
|
||||
if !decodeTaskTemplateRequest(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if err := validateTaskTemplateRequest(req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if req.Name == nil && req.Description == nil && req.Goal == nil {
|
||||
writeErr(w, http.StatusBadRequest, "至少需要提供 name、description 或 goal")
|
||||
return
|
||||
}
|
||||
template, err := pg.PatchTaskTemplate(id, db.TaskTemplatePatch{
|
||||
Name: req.Name, Description: req.Description, Goal: req.Goal,
|
||||
})
|
||||
if err != nil {
|
||||
writeTaskTemplateErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, template)
|
||||
}
|
||||
|
||||
func (s *Server) pgDeleteTaskTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
id, ok := pathInt(r, "id")
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "bad task template id")
|
||||
return
|
||||
}
|
||||
deleted, err := pg.DeleteTaskTemplate(id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if !deleted {
|
||||
writeErr(w, http.StatusNotFound, "task template not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"deleted": id})
|
||||
}
|
||||
|
||||
func stringValue(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
func TestTaskTemplateHTTPCRUD(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
defer m.Close()
|
||||
s := New(context.Background(), m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
h := s.Handler()
|
||||
token, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
do := func(method, path string, body any) (int, map[string]any) {
|
||||
var data []byte
|
||||
if body != nil {
|
||||
data, _ = json.Marshal(body)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, bytes.NewReader(data))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
return rec.Code, out
|
||||
}
|
||||
|
||||
name := fmt.Sprintf("http-template-%d", time.Now().UnixNano())
|
||||
code, created := do(http.MethodPost, "/api/task-templates", map[string]any{
|
||||
"name": name, "description": "description", "goal": "goal",
|
||||
})
|
||||
if code != http.StatusCreated {
|
||||
t.Fatalf("create status=%d body=%v", code, created)
|
||||
}
|
||||
id := int64(created["id"].(float64))
|
||||
t.Cleanup(func() { _, _ = m.pg.DeleteTaskTemplate(id) })
|
||||
|
||||
code, duplicate := do(http.MethodPost, "/api/task-templates", map[string]any{
|
||||
"name": " " + name + " ", "description": "other", "goal": "other",
|
||||
})
|
||||
if code != http.StatusConflict {
|
||||
t.Fatalf("duplicate status=%d body=%v", code, duplicate)
|
||||
}
|
||||
|
||||
code, updated := do(http.MethodPatch, fmt.Sprintf("/api/task-templates/%d", id), map[string]any{"goal": "patched goal"})
|
||||
if code != http.StatusOK || updated["goal"] != "patched goal" || updated["description"] != "description" {
|
||||
t.Fatalf("patch status=%d body=%v", code, updated)
|
||||
}
|
||||
code, _ = do(http.MethodPatch, fmt.Sprintf("/api/task-templates/%d", id), map[string]any{
|
||||
"goal": strings.Repeat("界", db.MaxTaskTemplateTextRunes+1),
|
||||
})
|
||||
if code != http.StatusBadRequest {
|
||||
t.Fatalf("overlong patch status=%d, want %d", code, http.StatusBadRequest)
|
||||
}
|
||||
code, _ = do(http.MethodPost, "/api/task-templates", map[string]any{
|
||||
"name": "oversized", "description": strings.Repeat("x", maxTaskTemplateRequestBytes+1), "goal": "goal",
|
||||
})
|
||||
if code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("oversized create status=%d, want %d", code, http.StatusRequestEntityTooLarge)
|
||||
}
|
||||
code, listed := do(http.MethodGet, "/api/task-templates", nil)
|
||||
if code != http.StatusOK || listed["templates"] == nil {
|
||||
t.Fatalf("list status=%d body=%v", code, listed)
|
||||
}
|
||||
code, deleted := do(http.MethodDelete, fmt.Sprintf("/api/task-templates/%d", id), nil)
|
||||
if code != http.StatusOK || int64(deleted["deleted"].(float64)) != id {
|
||||
t.Fatalf("delete status=%d body=%v", code, deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationPatchReturnsPinState(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
defer m.Close()
|
||||
s := New(context.Background(), m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
conversation, err := m.pg.CreateConversation("mainagent", "pin through http", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = m.pg.DeleteConversation(conversation.ID) })
|
||||
token, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(map[string]any{"title": " pinned title ", "pinned": true})
|
||||
req := httptest.NewRequest(http.MethodPatch, fmt.Sprintf("/api/conversations/%d", conversation.ID), bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("patch status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var updated struct {
|
||||
Title string `json:"title"`
|
||||
Pinned bool `json:"pinned"`
|
||||
PinnedAt *string `json:"pinned_at"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &updated); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.Title != "pinned title" || !updated.Pinned || updated.PinnedAt == nil {
|
||||
t.Fatalf("unexpected conversation patch response: %+v", updated)
|
||||
}
|
||||
|
||||
body, _ = json.Marshal(map[string]any{"title": strings.Repeat("会", maxConversationTitleRunes+1)})
|
||||
req = httptest.NewRequest(http.MethodPatch, fmt.Sprintf("/api/conversations/%d", conversation.ID), bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec = httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("overlong conversation title status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
body, _ = json.Marshal(map[string]any{"title": strings.Repeat("x", maxConversationRequestBytes+1)})
|
||||
req = httptest.NewRequest(http.MethodPatch, fmt.Sprintf("/api/conversations/%d", conversation.ID), bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec = httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("oversized conversation patch status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
body, _ = json.Marshal(map[string]any{
|
||||
"agent_key": "mainagent", "title": strings.Repeat("会", maxConversationTitleRunes+1),
|
||||
})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/conversations", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec = httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("overlong conversation create status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
body, _ = json.Marshal(map[string]any{
|
||||
"agent_key": "mainagent", "title": strings.Repeat("x", maxConversationRequestBytes+1),
|
||||
})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/conversations", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec = httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("oversized conversation create status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,459 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { LibraryIcon, PlusIcon, SaveIcon, Settings2Icon, Trash2Icon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
} from "@/components/ui/combobox";
|
||||
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/lib/api";
|
||||
import type { TaskTemplate } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TemplateDraft {
|
||||
name: string;
|
||||
description: string;
|
||||
goal: string;
|
||||
}
|
||||
|
||||
interface TaskTemplateManagerProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
templates: TaskTemplate[];
|
||||
seed: Pick<TemplateDraft, "description" | "goal"> | null;
|
||||
onCreated: (template: TaskTemplate) => void;
|
||||
onUpdated: (template: TaskTemplate) => void;
|
||||
onDeleted: (id: number) => void;
|
||||
}
|
||||
|
||||
const emptyDraft = (): TemplateDraft => ({ name: "", description: "", goal: "" });
|
||||
|
||||
function templateDraft(template: TaskTemplate): TemplateDraft {
|
||||
return {
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
goal: template.goal,
|
||||
};
|
||||
}
|
||||
|
||||
function TaskTemplateManager({
|
||||
open,
|
||||
onOpenChange,
|
||||
templates,
|
||||
seed,
|
||||
onCreated,
|
||||
onUpdated,
|
||||
onDeleted,
|
||||
}: TaskTemplateManagerProps) {
|
||||
const [selectedID, setSelectedID] = React.useState<number | null>(null);
|
||||
const [draft, setDraft] = React.useState<TemplateDraft>(emptyDraft);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = React.useState(false);
|
||||
const [deleting, setDeleting] = React.useState(false);
|
||||
const wasOpen = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open && !wasOpen.current) {
|
||||
if (seed) {
|
||||
setSelectedID(null);
|
||||
setDraft({ name: "", description: seed.description, goal: seed.goal });
|
||||
} else if (templates[0]) {
|
||||
setSelectedID(templates[0].id);
|
||||
setDraft(templateDraft(templates[0]));
|
||||
} else {
|
||||
setSelectedID(null);
|
||||
setDraft(emptyDraft());
|
||||
}
|
||||
}
|
||||
wasOpen.current = open;
|
||||
}, [open, seed, templates]);
|
||||
|
||||
const selectTemplate = (template: TaskTemplate) => {
|
||||
setSelectedID(template.id);
|
||||
setDraft(templateDraft(template));
|
||||
};
|
||||
|
||||
const startNew = () => {
|
||||
setSelectedID(null);
|
||||
setDraft(emptyDraft());
|
||||
};
|
||||
|
||||
const updateDraft = (field: keyof TemplateDraft, value: string) => {
|
||||
setDraft((current) => ({ ...current, [field]: value }));
|
||||
};
|
||||
|
||||
async function save() {
|
||||
const input = {
|
||||
name: draft.name.trim(),
|
||||
description: draft.description.trim(),
|
||||
goal: draft.goal.trim(),
|
||||
};
|
||||
if (!input.name || !input.description || !input.goal) {
|
||||
toast.error("请填写模板名称、描述和目标");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (selectedID == null) {
|
||||
const created = await api.createTaskTemplate(input);
|
||||
onCreated(created);
|
||||
setSelectedID(created.id);
|
||||
setDraft(templateDraft(created));
|
||||
toast.success("模板已创建");
|
||||
} else {
|
||||
const updated = await api.updateTaskTemplate(selectedID, input);
|
||||
onUpdated(updated);
|
||||
setDraft(templateDraft(updated));
|
||||
toast.success("模板已更新");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`保存失败:${(error as Error).message}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (selectedID == null) return;
|
||||
const deletedID = selectedID;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await api.deleteTaskTemplate(deletedID);
|
||||
onDeleted(deletedID);
|
||||
const next = templates.find((template) => template.id !== deletedID);
|
||||
if (next) {
|
||||
setSelectedID(next.id);
|
||||
setDraft(templateDraft(next));
|
||||
} else {
|
||||
startNew();
|
||||
}
|
||||
setDeleteOpen(false);
|
||||
toast.success("模板已删除");
|
||||
} catch (error) {
|
||||
toast.error(`删除失败:${(error as Error).message}`);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
let saveLabel = saving ? "保存中" : "保存修改";
|
||||
if (!saving && selectedID == null) saveLabel = "创建模板";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="grid h-full w-full! max-w-none! grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:w-[48rem]! sm:max-w-[48rem]!">
|
||||
<SheetHeader className="border-b px-6 py-5">
|
||||
<SheetTitle>任务模板管理</SheetTitle>
|
||||
<SheetDescription>模板只保存描述与目标;修改不会影响已经创建的任务。</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="grid min-h-0 overflow-y-auto lg:grid-cols-[15rem_minmax(0,1fr)] lg:overflow-hidden">
|
||||
<div className="flex min-h-0 flex-col border-b p-3 lg:border-r lg:border-b-0">
|
||||
<Button type="button" variant="outline" className="w-full" onClick={startNew}>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
新建模板
|
||||
</Button>
|
||||
<ScrollArea className="mt-2 max-h-44 lg:max-h-none lg:flex-1">
|
||||
<div className="flex flex-col gap-1 pr-2">
|
||||
{templates.length === 0 && (
|
||||
<p className="px-2 py-6 text-center text-muted-foreground text-sm">暂无模板</p>
|
||||
)}
|
||||
{templates.map((template) => (
|
||||
<button
|
||||
key={template.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"min-w-0 rounded-md px-2.5 py-2 text-left transition-colors",
|
||||
selectedID === template.id ? "bg-accent text-accent-foreground" : "hover:bg-accent/50",
|
||||
)}
|
||||
onClick={() => selectTemplate(template)}
|
||||
>
|
||||
<span className="block truncate font-medium text-sm">{template.name}</span>
|
||||
<span className="block truncate text-muted-foreground text-xs">{template.description}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0">
|
||||
<FieldGroup className="p-6">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="task-template-name">模板名称</FieldLabel>
|
||||
<Input
|
||||
id="task-template-name"
|
||||
value={draft.name}
|
||||
maxLength={120}
|
||||
placeholder="例如:外部 Web 渗透"
|
||||
onChange={(event) => updateDraft("name", event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="task-template-description">描述</FieldLabel>
|
||||
<Textarea
|
||||
id="task-template-description"
|
||||
className="min-h-28"
|
||||
value={draft.description}
|
||||
placeholder="测试对象与背景"
|
||||
onChange={(event) => updateDraft("description", event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="task-template-goal">目标</FieldLabel>
|
||||
<Textarea
|
||||
id="task-template-goal"
|
||||
className="min-h-28"
|
||||
value={draft.goal}
|
||||
placeholder="任务需要达成的目标"
|
||||
onChange={(event) => updateDraft("goal", event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<SheetFooter className="border-t px-6 py-4 sm:flex-row sm:items-center">
|
||||
{selectedID != null && (
|
||||
<Button type="button" variant="destructive" className="sm:mr-auto" onClick={() => setDeleteOpen(true)}>
|
||||
<Trash2Icon data-icon="inline-start" />
|
||||
删除模板
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
<Button type="button" disabled={saving} onClick={() => void save()}>
|
||||
{saving ? <Spinner data-icon="inline-start" /> : <SaveIcon data-icon="inline-start" />}
|
||||
{saveLabel}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>删除模板「{draft.name || "未命名模板"}」?</AlertDialogTitle>
|
||||
<AlertDialogDescription>已由该模板创建的任务不会受到影响。</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={deleting}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
void remove();
|
||||
}}
|
||||
>
|
||||
{deleting && <Spinner data-icon="inline-start" />}
|
||||
{deleting ? "删除中" : "删除"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface TaskTemplateControlsProps {
|
||||
description: string;
|
||||
goal: string;
|
||||
selectedTemplateID: number | null;
|
||||
onSelectedTemplateIDChange: (id: number | null) => void;
|
||||
onApply: (template: TaskTemplate) => void;
|
||||
portalContainer?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
export function TaskTemplateControls({
|
||||
description,
|
||||
goal,
|
||||
selectedTemplateID,
|
||||
onSelectedTemplateIDChange,
|
||||
onApply,
|
||||
portalContainer,
|
||||
}: TaskTemplateControlsProps) {
|
||||
const [templates, setTemplates] = React.useState<TaskTemplate[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [templateInputValue, setTemplateInputValue] = React.useState("");
|
||||
const [pendingTemplate, setPendingTemplate] = React.useState<TaskTemplate | null>(null);
|
||||
const [managerOpen, setManagerOpen] = React.useState(false);
|
||||
const [managerSeed, setManagerSeed] = React.useState<Pick<TemplateDraft, "description" | "goal"> | null>(null);
|
||||
|
||||
const loadTemplates = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setTemplates(await api.taskTemplates());
|
||||
} catch (error) {
|
||||
setTemplates([]);
|
||||
toast.error(`加载模板失败:${(error as Error).message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadTemplates();
|
||||
}, [loadTemplates]);
|
||||
|
||||
const selectedTemplate = React.useMemo(
|
||||
() => templates.find((template) => template.id === selectedTemplateID) ?? null,
|
||||
[selectedTemplateID, templates],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
setTemplateInputValue(selectedTemplate?.name ?? "");
|
||||
}, [selectedTemplate]);
|
||||
|
||||
const applyTemplate = (template: TaskTemplate) => {
|
||||
onApply(template);
|
||||
onSelectedTemplateIDChange(template.id);
|
||||
setPendingTemplate(null);
|
||||
};
|
||||
|
||||
const chooseTemplate = (template: TaskTemplate | null) => {
|
||||
if (!template) {
|
||||
setTemplateInputValue("");
|
||||
onSelectedTemplateIDChange(null);
|
||||
return;
|
||||
}
|
||||
setTemplateInputValue(template.name);
|
||||
const hasContent = description.trim() !== "" || goal.trim() !== "";
|
||||
const changesContent = description !== template.description || goal !== template.goal;
|
||||
if (hasContent && changesContent) {
|
||||
setPendingTemplate(template);
|
||||
return;
|
||||
}
|
||||
applyTemplate(template);
|
||||
};
|
||||
|
||||
const openManager = (seed: Pick<TemplateDraft, "description" | "goal"> | null) => {
|
||||
setManagerSeed(seed);
|
||||
setManagerOpen(true);
|
||||
};
|
||||
|
||||
const upsertTemplate = (template: TaskTemplate) => {
|
||||
setTemplates((current) => [template, ...current.filter((item) => item.id !== template.id)]);
|
||||
};
|
||||
|
||||
const deleteTemplate = (id: number) => {
|
||||
setTemplates((current) => current.filter((template) => template.id !== id));
|
||||
if (selectedTemplateID === id) onSelectedTemplateIDChange(null);
|
||||
};
|
||||
|
||||
let pickerPlaceholder = loading ? "正在加载模板" : "暂无任务模板";
|
||||
if (!loading && templates.length > 0) pickerPlaceholder = "搜索并选择任务模板";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Field>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<FieldLabel htmlFor="task-template-picker">任务模板</FieldLabel>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => openManager(null)}>
|
||||
<Settings2Icon data-icon="inline-start" />
|
||||
管理模板
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!description.trim() || !goal.trim()}
|
||||
onClick={() => openManager({ description, goal })}
|
||||
>
|
||||
<SaveIcon data-icon="inline-start" />
|
||||
另存为模板
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Combobox
|
||||
items={templates}
|
||||
itemToStringLabel={(template) => template.name}
|
||||
itemToStringValue={(template) => String(template.id)}
|
||||
inputValue={templateInputValue}
|
||||
onInputValueChange={(value) => setTemplateInputValue(value)}
|
||||
value={selectedTemplate}
|
||||
onValueChange={chooseTemplate}
|
||||
>
|
||||
<ComboboxInput
|
||||
id="task-template-picker"
|
||||
className="w-full"
|
||||
placeholder={pickerPlaceholder}
|
||||
disabled={loading || templates.length === 0}
|
||||
showClear
|
||||
/>
|
||||
<ComboboxContent portalContainer={portalContainer}>
|
||||
<ComboboxEmpty>没有匹配的模板</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(template) => (
|
||||
<ComboboxItem key={template.id} value={template}>
|
||||
<LibraryIcon />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-sm">{template.name}</p>
|
||||
{template.description && (
|
||||
<p className="truncate text-muted-foreground text-xs">{template.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
<FieldDescription>选择后会复制模板的描述和目标,不与模板保持关联。</FieldDescription>
|
||||
</Field>
|
||||
|
||||
<AlertDialog
|
||||
open={pendingTemplate != null}
|
||||
onOpenChange={(open) => {
|
||||
if (open) return;
|
||||
setPendingTemplate(null);
|
||||
setTemplateInputValue(selectedTemplate?.name ?? "");
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>使用模板「{pendingTemplate?.name}」?</AlertDialogTitle>
|
||||
<AlertDialogDescription>当前已填写的描述和目标将被模板内容覆盖。</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => pendingTemplate && applyTemplate(pendingTemplate)}>
|
||||
覆盖并使用
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<TaskTemplateManager
|
||||
open={managerOpen}
|
||||
onOpenChange={setManagerOpen}
|
||||
templates={templates}
|
||||
seed={managerSeed}
|
||||
onCreated={upsertTemplate}
|
||||
onUpdated={upsertTemplate}
|
||||
onDeleted={deleteTemplate}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user