feat: add project icon picker component and integrate with project management
- Implemented ProjectIconPicker component for selecting project icons. - Created ProjectIcon component to display selected icons. - Updated calendar page to remove unused PageHeading component. - Modified growth page to enhance UI and improve text clarity. - Refactored index page to include project icons and improve layout. - Added mine page for user profile management with avatar selection. - Introduced projects-sort page for managing project order with drag-and-drop functionality. - Enhanced projects page to allow icon selection when adding or editing projects. - Updated record page to streamline user experience and improve form actions. - Adjusted practice service to support project icon management. - Added tests for project saving functionality to ensure correct handling of icons. - Updated global type definitions to support SVG and PNG imports.
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
ALTER TABLE projects ADD COLUMN icon_id text NOT NULL DEFAULT '' CHECK (char_length(icon_id) <= 64);
|
||||
|
||||
-- Existing installations have never offered icon selection. Seed familiar names
|
||||
-- once; subsequent migrations/logins must preserve explicit changes or removal.
|
||||
UPDATE projects SET icon_id = CASE name
|
||||
WHEN '零基础' THEN 'plie'
|
||||
WHEN '基础提升' THEN 'tendu'
|
||||
WHEN '软开素质' THEN 'flexibility'
|
||||
WHEN '足髋训练' THEN 'foot-hip'
|
||||
WHEN '核心臀腿' THEN 'core-glutes'
|
||||
WHEN '小球核心' THEN 'ball-core'
|
||||
WHEN '天鹅臂颈' THEN 'swan-arms'
|
||||
WHEN '呼吸训练' THEN 'breathing'
|
||||
ELSE ''
|
||||
END;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Keep identity allocation for new projects while allowing owned projects to swap positions.
|
||||
ALTER TABLE projects ALTER COLUMN position SET GENERATED BY DEFAULT;
|
||||
@@ -11,6 +11,12 @@ import (
|
||||
//go:embed 001_practice.sql
|
||||
var practiceSQL string
|
||||
|
||||
//go:embed 002_project_icons.sql
|
||||
var projectIconsSQL string
|
||||
|
||||
//go:embed 003_project_order.sql
|
||||
var projectOrderSQL string
|
||||
|
||||
// Migrate applies versioned migrations atomically. A transaction-scoped lock keeps
|
||||
// concurrent service starts from applying the same version twice.
|
||||
func Migrate(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
@@ -25,17 +31,20 @@ func Migrate(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
if _, err = tx.Exec(ctx, "CREATE TABLE IF NOT EXISTS schema_migrations (version integer PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())"); err != nil {
|
||||
return err
|
||||
}
|
||||
var applied bool
|
||||
if err = tx.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version=1)").Scan(&applied); err != nil {
|
||||
return err
|
||||
}
|
||||
if !applied {
|
||||
if _, err = tx.Exec(ctx, practiceSQL); err != nil {
|
||||
return fmt.Errorf("migration 1: %w", err)
|
||||
}
|
||||
if _, err = tx.Exec(ctx, "INSERT INTO schema_migrations(version) VALUES (1)"); err != nil {
|
||||
for i, sql := range []string{practiceSQL, projectIconsSQL, projectOrderSQL} {
|
||||
version := i + 1
|
||||
var applied bool
|
||||
if err = tx.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version=$1)", version).Scan(&applied); err != nil {
|
||||
return err
|
||||
}
|
||||
if !applied {
|
||||
if _, err = tx.Exec(ctx, sql); err != nil {
|
||||
return fmt.Errorf("migration %d: %w", version, err)
|
||||
}
|
||||
if _, err = tx.Exec(ctx, "INSERT INTO schema_migrations(version) VALUES ($1)", version); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ func NewAppHandler(pool *pgxpool.Pool, verifier IdentityVerifier, now func() tim
|
||||
mux.HandleFunc("POST /v1/session", a.login)
|
||||
mux.HandleFunc("GET /v1/projects", a.auth(a.projects))
|
||||
mux.HandleFunc("POST /v1/projects", a.auth(a.saveProject))
|
||||
mux.HandleFunc("PUT /v1/projects/order", a.auth(a.reorderProjects))
|
||||
mux.HandleFunc("PUT /v1/projects/{id}", a.auth(a.saveProject))
|
||||
mux.HandleFunc("DELETE /v1/projects/{id}", a.auth(a.removeProject))
|
||||
mux.HandleFunc("GET /v1/records", a.auth(a.records))
|
||||
@@ -139,14 +140,28 @@ func (a *app) projects(w http.ResponseWriter, r *http.Request, user string) erro
|
||||
}
|
||||
return err
|
||||
}
|
||||
func (a *app) saveProject(w http.ResponseWriter, r *http.Request, user string) error {
|
||||
func (a *app) reorderProjects(w http.ResponseWriter, r *http.Request, user string) error {
|
||||
var input struct {
|
||||
Name string `json:"name"`
|
||||
ProjectIDs []string `json:"projectIds"`
|
||||
}
|
||||
if err := decode(w, r, &input); err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := a.store.SaveProject(r.Context(), user, r.PathValue("id"), input.Name)
|
||||
if err := a.store.ReorderProjects(r.Context(), user, input.ProjectIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
respond(w, 200, map[string]string{"action": "reordered"})
|
||||
return nil
|
||||
}
|
||||
func (a *app) saveProject(w http.ResponseWriter, r *http.Request, user string) error {
|
||||
var input struct {
|
||||
Name string `json:"name"`
|
||||
IconID *string `json:"iconId"`
|
||||
}
|
||||
if err := decode(w, r, &input); err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := a.store.SaveProject(r.Context(), user, r.PathValue("id"), input.Name, input.IconID)
|
||||
if err == nil {
|
||||
respond(w, 200, p)
|
||||
}
|
||||
|
||||
@@ -160,6 +160,158 @@ func (h *harness) project(token, name string) string {
|
||||
return h.request("POST", "/v1/projects", token, string(body), 200)["id"].(string)
|
||||
}
|
||||
|
||||
func TestProjectOrderPersistsAndRejectsStaleOrForeignLists(t *testing.T) {
|
||||
h := setup(t)
|
||||
alice, bob := h.login("alice"), h.login("bob")
|
||||
projectIDs := func(token string) []string {
|
||||
h.t.Helper()
|
||||
projects := h.request("GET", "/v1/projects", token, "", 200)["projects"].([]any)
|
||||
ids := make([]string, len(projects))
|
||||
for i, raw := range projects {
|
||||
ids[i] = raw.(map[string]any)["id"].(string)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
setOrder := func(ids []string, status int) {
|
||||
h.t.Helper()
|
||||
body, _ := json.Marshal(map[string][]string{"projectIds": ids})
|
||||
h.request("PUT", "/v1/projects/order", alice, string(body), status)
|
||||
}
|
||||
initial := projectIDs(alice)
|
||||
want := append([]string(nil), initial...)
|
||||
for i, j := 0, len(want)-1; i < j; i, j = i+1, j-1 {
|
||||
want[i], want[j] = want[j], want[i]
|
||||
}
|
||||
setOrder(want, 200)
|
||||
h.h = httpapi.NewAppHandler(h.pool, testIdentity{}, func() time.Time { return h.now })
|
||||
if got := projectIDs(h.login("alice")); strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("order did not persist: %v", got)
|
||||
}
|
||||
newID := h.project(alice, "新项目")
|
||||
want = append(want, newID)
|
||||
if got := projectIDs(alice); strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("new project did not append: %v", got)
|
||||
}
|
||||
duplicate := append([]string(nil), want...)
|
||||
duplicate[0] = duplicate[1]
|
||||
setOrder(duplicate, 400)
|
||||
setOrder(want[:len(want)-1], 400)
|
||||
foreign := append([]string(nil), want...)
|
||||
foreign[0] = projectIDs(bob)[0]
|
||||
setOrder(foreign, 400)
|
||||
if got := projectIDs(alice); strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("invalid order changed projects: %v", got)
|
||||
}
|
||||
h.request("POST", "/v1/records", alice, recordBody(want[0], "2026-09-28", 15, "", "sort-archive-0001"), 200)
|
||||
h.request("DELETE", "/v1/projects/"+want[0], alice, "", 200)
|
||||
setOrder(want, 400)
|
||||
active := projectIDs(alice)
|
||||
if strings.Join(active, ",") != strings.Join(want[1:], ",") {
|
||||
t.Fatalf("archived project stayed active: %v", active)
|
||||
}
|
||||
for i, j := 0, len(active)-1; i < j; i, j = i+1, j-1 {
|
||||
active[i], active[j] = active[j], active[i]
|
||||
}
|
||||
setOrder(active, 200)
|
||||
if got := projectIDs(alice); strings.Join(got, ",") != strings.Join(active, ",") {
|
||||
t.Fatalf("reorder after archive failed: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectIconsPersistClearAndRespectOwnership(t *testing.T) {
|
||||
h := setup(t)
|
||||
alice, bob := h.login("alice"), h.login("bob")
|
||||
presets := h.request("GET", "/v1/projects", alice, "", 200)["projects"].([]any)
|
||||
want := []string{"plie", "tendu", "flexibility", "foot-hip", "core-glutes", "ball-core", "swan-arms", "breathing"}
|
||||
for i, raw := range presets {
|
||||
if raw.(map[string]any)["iconId"] != want[i] {
|
||||
t.Fatalf("preset %d: %v", i, raw)
|
||||
}
|
||||
}
|
||||
p := h.request("POST", "/v1/projects", alice, `{"name":"自选练习","iconId":"ball-core"}`, 200)
|
||||
id := p["id"].(string)
|
||||
if p["iconId"] != "ball-core" {
|
||||
t.Fatal(p)
|
||||
}
|
||||
// Older clients rename without sending iconId. This must not clear artwork.
|
||||
p = h.request("PUT", "/v1/projects/"+id, alice, `{"name":"晚间练习"}`, 200)
|
||||
if p["iconId"] != "ball-core" {
|
||||
t.Fatal("rename cleared the icon")
|
||||
}
|
||||
for _, icon := range []string{"plie", "tendu", "releve", "port-de-bras", "arabesque", "attitude", "pirouette", "grand-jete", "foot-hip", "flexibility", "ball-core", "swan-arms", "breathing", "core-glutes", "soft-shoes", "pointe-shoes", "barre", "piano"} {
|
||||
p = h.request("PUT", "/v1/projects/"+id, alice, `{"name":"晚间练习","iconId":"`+icon+`"}`, 200)
|
||||
if p["iconId"] != icon {
|
||||
t.Fatalf("icon did not round trip: %s", icon)
|
||||
}
|
||||
}
|
||||
h.request("PUT", "/v1/projects/"+id, bob, `{"name":"偷改","iconId":"breathing"}`, 404)
|
||||
h.request("PUT", "/v1/projects/"+id, alice, `{"name":"不应保存","iconId":"unknown"}`, 400)
|
||||
h.request("POST", "/v1/projects", alice, `{"name":"拒绝外部图片","iconId":"https://example.com/a.svg"}`, 400)
|
||||
h.request("POST", "/v1/projects", alice, `{"name":"拒绝对象","iconId":{}}`, 400)
|
||||
// A fresh handler and login must read the last confirmed value from the database.
|
||||
h.h = httpapi.NewAppHandler(h.pool, testIdentity{}, func() time.Time { return h.now })
|
||||
find := func(token string) map[string]any {
|
||||
for _, raw := range h.request("GET", "/v1/projects?includeArchived=true", token, "", 200)["projects"].([]any) {
|
||||
project := raw.(map[string]any)
|
||||
if project["id"] == id {
|
||||
return project
|
||||
}
|
||||
}
|
||||
t.Fatal("saved project missing")
|
||||
return nil
|
||||
}
|
||||
p = find(h.login("alice"))
|
||||
if p["name"] != "晚间练习" || p["iconId"] != "piano" {
|
||||
t.Fatal("rejected write changed persisted project", p)
|
||||
}
|
||||
h.request("POST", "/v1/records", alice, recordBody(id, "2026-09-28", 15, "", "icon-archive-0001"), 200)
|
||||
h.request("DELETE", "/v1/projects/"+id, alice, "", 200)
|
||||
if p = find(alice); p["archived"] != true || p["iconId"] != "piano" {
|
||||
t.Fatal("archiving lost artwork", p)
|
||||
}
|
||||
// Clearing a preset stays cleared after login and a service migration restart.
|
||||
presetID := presets[0].(map[string]any)["id"].(string)
|
||||
p = h.request("PUT", "/v1/projects/"+presetID, alice, `{"name":"零基础","iconId":""}`, 200)
|
||||
if p["iconId"] != "" {
|
||||
t.Fatal("explicit clear failed", p)
|
||||
}
|
||||
if err := database.Migrate(context.Background(), h.pool); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projects := h.request("GET", "/v1/projects", h.login("alice"), "", 200)["projects"].([]any)
|
||||
if projects[0].(map[string]any)["iconId"] != "" {
|
||||
t.Fatal("restart restored a cleared icon")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectIconMigrationPreservesExistingProjectsAndRecords(t *testing.T) {
|
||||
h := setup(t)
|
||||
token := h.login("alice")
|
||||
id := h.project(token, "自定义旧项目")
|
||||
h.request("POST", "/v1/records", token, recordBody(id, "2026-09-28", 30, "已有笔记", "icon-migrate-001"), 200)
|
||||
// Recreate the version-1 shape only inside this test's isolated schema.
|
||||
ctx := context.Background()
|
||||
if _, err := h.pool.Exec(ctx, "ALTER TABLE projects DROP COLUMN icon_id; DELETE FROM schema_migrations WHERE version=2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.Migrate(ctx, h.pool); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projects := h.request("GET", "/v1/projects", token, "", 200)["projects"].([]any)
|
||||
if len(projects) != 9 || projects[0].(map[string]any)["iconId"] != "plie" {
|
||||
t.Fatal("existing presets were not migrated", projects)
|
||||
}
|
||||
custom := projects[8].(map[string]any)
|
||||
if custom["id"] != id || custom["iconId"] != "" || custom["name"] != "自定义旧项目" {
|
||||
t.Fatal("migration changed custom project", custom)
|
||||
}
|
||||
records := h.request("GET", "/v1/records", token, "", 200)["records"].([]any)
|
||||
if len(records) != 1 || records[0].(map[string]any)["note"] != "已有笔记" {
|
||||
t.Fatal("migration changed existing history", records)
|
||||
}
|
||||
h.summary(token, 30, 1)
|
||||
}
|
||||
|
||||
func recordBody(project, date string, minutes int, note, key string) string {
|
||||
body, _ := json.Marshal(map[string]any{"projectId": project, "date": date, "minutes": minutes, "note": note, "requestId": key})
|
||||
return string(body)
|
||||
@@ -187,7 +339,9 @@ func TestRecordsRoundTripCorrectionsAndStatistics(t *testing.T) {
|
||||
token := h.login("alice")
|
||||
class := h.project(token, "晚课")
|
||||
extra := h.project(token, "课后练习")
|
||||
h.summary(token, 0, 0)
|
||||
if got := h.summary(token, 0, 0)["allTime"].(map[string]any)["firstDate"]; got != "" {
|
||||
t.Fatalf("empty history has first date %v", got)
|
||||
}
|
||||
one := h.request("POST", "/v1/records", token, recordBody(class, "2026-09-28", 90, "老师反馈:放松肩颈", "lesson-000000001"), 200)
|
||||
two := h.request("POST", "/v1/records", token, recordBody(extra, "2026-09-28", 15, "", "lesson-000000002"), 200)
|
||||
h.summary(token, 105, 1)
|
||||
@@ -198,6 +352,9 @@ func TestRecordsRoundTripCorrectionsAndStatistics(t *testing.T) {
|
||||
id1, id2 := one["id"].(string), two["id"].(string)
|
||||
h.request("PUT", "/v1/records/"+id1, token, recordBody(class, "2026-09-27", 90, "改到周日", ""), 200)
|
||||
review := h.summary(token, 105, 2)
|
||||
if got := review["allTime"].(map[string]any)["firstDate"]; got != "2026-09-27" {
|
||||
t.Fatalf("earliest practice date after correction: %v", got)
|
||||
}
|
||||
if review["period"].(map[string]any)["totalMinutes"] != float64(15) {
|
||||
t.Fatal("Sunday must belong to the previous week")
|
||||
}
|
||||
@@ -207,7 +364,9 @@ func TestRecordsRoundTripCorrectionsAndStatistics(t *testing.T) {
|
||||
h.summary(token, 60, 1)
|
||||
h.request("DELETE", "/v1/records/"+id1, token, "", 200)
|
||||
h.request("DELETE", "/v1/records/"+id1, token, "", 200)
|
||||
h.summary(token, 0, 0)
|
||||
if got := h.summary(token, 0, 0)["allTime"].(map[string]any)["firstDate"]; got != "" {
|
||||
t.Fatalf("deleted history retained first date %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveRenameAndOwnershipAcrossAccounts(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package practice
|
||||
|
||||
// Icon IDs address bundled artwork, never client-provided URLs or file paths.
|
||||
func validProjectIcon(id string) bool {
|
||||
switch id {
|
||||
case "", "plie", "tendu", "releve", "port-de-bras", "arabesque", "attitude",
|
||||
"pirouette", "grand-jete", "foot-hip", "flexibility", "ball-core",
|
||||
"swan-arms", "breathing", "core-glutes", "soft-shoes", "pointe-shoes", "barre", "piano":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -13,10 +13,12 @@ type ProjectMinutes struct {
|
||||
Minutes int64 `json:"minutes"`
|
||||
}
|
||||
type Summary struct {
|
||||
TotalMinutes int64 `json:"totalMinutes"`
|
||||
PracticeDays int `json:"practiceDays"`
|
||||
RecordCount int64 `json:"recordCount"`
|
||||
Projects []ProjectMinutes `json:"projects"`
|
||||
TotalMinutes int64 `json:"totalMinutes"`
|
||||
PracticeDays int `json:"practiceDays"`
|
||||
// FirstDate is the earliest actual practice date, or empty without records.
|
||||
FirstDate string `json:"firstDate"`
|
||||
RecordCount int64 `json:"recordCount"`
|
||||
Projects []ProjectMinutes `json:"projects"`
|
||||
}
|
||||
type DayMinutes struct {
|
||||
Date string `json:"date"`
|
||||
@@ -42,6 +44,9 @@ func newSummary() *summaryBuilder {
|
||||
func (b *summaryBuilder) add(date string, project ProjectMinutes, count int64) {
|
||||
b.summary.TotalMinutes += project.Minutes
|
||||
b.summary.RecordCount += count
|
||||
if b.summary.FirstDate == "" || date < b.summary.FirstDate {
|
||||
b.summary.FirstDate = date
|
||||
}
|
||||
b.dates[date] = true
|
||||
previous := b.projects[project.ProjectID]
|
||||
project.Minutes += previous.Minutes
|
||||
|
||||
@@ -27,7 +27,10 @@ var (
|
||||
ErrDeleted = errors.New("这次提交的记录已被删除,不会重复创建")
|
||||
)
|
||||
|
||||
var presets = []string{"零基础", "基础提升", "软开素质", "足髋训练", "核心臀腿", "小球核心", "天鹅臂颈", "呼吸训练"}
|
||||
var presets = []struct{ name, iconID string }{
|
||||
{"零基础", "plie"}, {"基础提升", "tendu"}, {"软开素质", "flexibility"}, {"足髋训练", "foot-hip"},
|
||||
{"核心臀腿", "core-glutes"}, {"小球核心", "ball-core"}, {"天鹅臂颈", "swan-arms"}, {"呼吸训练", "breathing"},
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
db *pgxpool.Pool
|
||||
@@ -73,8 +76,8 @@ func (s *Store) Login(ctx context.Context, identity string) (Session, error) {
|
||||
return Session{}, err
|
||||
}
|
||||
if result.RowsAffected() == 1 {
|
||||
for _, name := range presets {
|
||||
if _, err = tx.Exec(ctx, "INSERT INTO projects(id,user_id,name) VALUES($1,$2,$3)", newID(), id, name); err != nil {
|
||||
for _, preset := range presets {
|
||||
if _, err = tx.Exec(ctx, "INSERT INTO projects(id,user_id,name,icon_id) VALUES($1,$2,$3,$4)", newID(), id, preset.name, preset.iconID); err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
}
|
||||
@@ -133,12 +136,13 @@ func (s *Store) write(ctx context.Context, user string, fn func(pgx.Tx) error) e
|
||||
type Project struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
IconID string `json:"iconId"`
|
||||
Archived bool `json:"archived"`
|
||||
HasRecords bool `json:"hasRecords"`
|
||||
}
|
||||
|
||||
func (s *Store) Projects(ctx context.Context, user string, archived bool) ([]Project, error) {
|
||||
rows, err := s.db.Query(ctx, `SELECT p.id,p.name,p.archived,EXISTS(SELECT 1 FROM records r WHERE r.user_id=p.user_id AND r.project_id=p.id)
|
||||
rows, err := s.db.Query(ctx, `SELECT p.id,p.name,p.icon_id,p.archived,EXISTS(SELECT 1 FROM records r WHERE r.user_id=p.user_id AND r.project_id=p.id)
|
||||
FROM projects p WHERE p.user_id=$1 AND ($2 OR NOT p.archived) ORDER BY p.position`, user, archived)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -147,7 +151,7 @@ func (s *Store) Projects(ctx context.Context, user string, archived bool) ([]Pro
|
||||
projects := []Project{}
|
||||
for rows.Next() {
|
||||
var p Project
|
||||
if err := rows.Scan(&p.ID, &p.Name, &p.Archived, &p.HasRecords); err != nil {
|
||||
if err := rows.Scan(&p.ID, &p.Name, &p.IconID, &p.Archived, &p.HasRecords); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
projects = append(projects, p)
|
||||
@@ -155,20 +159,75 @@ func (s *Store) Projects(ctx context.Context, user string, archived bool) ([]Pro
|
||||
return projects, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) SaveProject(ctx context.Context, user, id, name string) (Project, error) {
|
||||
// ReorderProjects accepts exactly the owner's active project IDs. Reusing their
|
||||
// current position slots leaves archived history and automatic append positions intact.
|
||||
func (s *Store) ReorderProjects(ctx context.Context, user string, ids []string) error {
|
||||
if ids == nil {
|
||||
return ErrInvalid
|
||||
}
|
||||
return s.write(ctx, user, func(tx pgx.Tx) error {
|
||||
rows, err := tx.Query(ctx, `SELECT id,position FROM projects WHERE user_id=$1 AND NOT archived ORDER BY position`, user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
positions := make([]int64, 0, len(ids))
|
||||
available := make(map[string]bool, len(ids))
|
||||
for rows.Next() {
|
||||
var id string
|
||||
var position int64
|
||||
if err := rows.Scan(&id, &position); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
positions = append(positions, position)
|
||||
available[id] = true
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(ids) != len(positions) {
|
||||
return ErrInvalid
|
||||
}
|
||||
for _, id := range ids {
|
||||
if !available[id] {
|
||||
return ErrInvalid
|
||||
}
|
||||
delete(available, id)
|
||||
}
|
||||
for index, id := range ids {
|
||||
if _, err := tx.Exec(ctx, `UPDATE projects SET position=$3 WHERE user_id=$1 AND id=$2 AND NOT archived`, user, id, positions[index]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// SaveProject persists a name and optional icon choice within the owner's account.
|
||||
// A nil iconID preserves existing artwork for older clients; an empty string clears it.
|
||||
// Unknown icon IDs return ErrInvalid, and another owner's project returns ErrUnavailable.
|
||||
func (s *Store) SaveProject(ctx context.Context, user, id, name string, iconID *string) (Project, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || utf8.RuneCountInString(name) > NameLimit || strings.ContainsRune(name, 0) {
|
||||
return Project{}, ErrInvalid
|
||||
}
|
||||
if iconID != nil && !validProjectIcon(*iconID) {
|
||||
return Project{}, ErrInvalid
|
||||
}
|
||||
p := Project{ID: id, Name: name}
|
||||
if iconID != nil {
|
||||
p.IconID = *iconID
|
||||
}
|
||||
err := s.write(ctx, user, func(tx pgx.Tx) error {
|
||||
if id == "" {
|
||||
p.ID = newID()
|
||||
_, err := tx.Exec(ctx, "INSERT INTO projects(id,user_id,name) VALUES($1,$2,$3)", p.ID, user, name)
|
||||
_, err := tx.Exec(ctx, "INSERT INTO projects(id,user_id,name,icon_id) VALUES($1,$2,$3,$4)", p.ID, user, name, p.IconID)
|
||||
return err
|
||||
}
|
||||
err := tx.QueryRow(ctx, `UPDATE projects SET name=$3 WHERE user_id=$1 AND id=$2 RETURNING archived,
|
||||
EXISTS(SELECT 1 FROM records WHERE user_id=$1 AND project_id=$2)`, user, id, name).Scan(&p.Archived, &p.HasRecords)
|
||||
err := tx.QueryRow(ctx, `UPDATE projects SET name=$3,icon_id=COALESCE($4,icon_id) WHERE user_id=$1 AND id=$2 RETURNING icon_id,archived,
|
||||
EXISTS(SELECT 1 FROM records WHERE user_id=$1 AND project_id=$2)`, user, id, name, iconID).Scan(&p.IconID, &p.Archived, &p.HasRecords)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrUnavailable
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user